@erclx/canon 5.62.0 → 5.63.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/create-skill/references/skill-practice.md +1 -0
  4. package/claude/skills/design-record/references/design.md +1 -1
  5. package/claude/skills/design-taste/REQUIREMENT.md +2 -1
  6. package/claude/skills/design-taste/SKILL.md +6 -5
  7. package/claude/skills/design-taste/references/adopted.md +136 -8
  8. package/claude/skills/design-taste/references/craft.md +1 -1
  9. package/claude/skills/design-taste/references/kinds.md +8 -2
  10. package/claude/skills/design-taste/references/preflight.md +3 -3
  11. package/claude/skills/design-taste/references/tells.md +26 -5
  12. package/claude/skills/plan-feature/SKILL.md +2 -2
  13. package/claude/skills/plan-feature/references/plan.md +24 -3
  14. package/claude/skills/review-craft/SKILL.md +1 -1
  15. package/claude/skills/role-worker/SKILL.md +2 -1
  16. package/claude/skills/ui-checklist/SKILL.md +1 -1
  17. package/claude/skills/ui-craft/REQUIREMENT.md +29 -0
  18. package/claude/skills/ui-craft/SKILL.md +44 -0
  19. package/{governance/rules/ui → claude/skills/ui-craft/references}/a11y.md +0 -10
  20. package/claude/skills/ui-craft/references/adopted.md +27 -0
  21. package/{governance/rules/ui/surface-capture.md → claude/skills/ui-craft/references/capture.md} +0 -10
  22. package/{governance/rules/ui/ux-controls.md → claude/skills/ui-craft/references/controls.md} +3 -10
  23. package/claude/skills/ui-craft/references/copy.md +45 -0
  24. package/{governance/rules/ui → claude/skills/ui-craft/references}/forms.md +0 -9
  25. package/{governance/rules/ui → claude/skills/ui-craft/references}/keyboard.md +0 -10
  26. package/{governance/rules/ui → claude/skills/ui-craft/references}/motion.md +0 -10
  27. package/{governance/rules/ui → claude/skills/ui-craft/references}/pointer.md +0 -10
  28. package/{governance/rules/ui/ux-completeness.md → claude/skills/ui-craft/references/states.md} +0 -10
  29. package/claude/skills/ux-audit/SKILL.md +1 -1
  30. package/claude/skills/write-human/references/adopted.md +4 -1
  31. package/docs/agents/install-and-sync.md +2 -2
  32. package/docs/target/migrations.md +3 -3
  33. package/docs/target/sync.md +3 -2
  34. package/docs/workflow/skill-map.md +1 -0
  35. package/docs/workflow/visual-design-workflow.md +2 -2
  36. package/governance/detect.toml +1 -45
  37. package/governance/ledger/lib/test-scope.md +1 -1
  38. package/governance/ledger/{ui → skills/ui-craft}/a11y.md +3 -3
  39. package/governance/ledger/{ui/surface-capture.md → skills/ui-craft/capture.md} +3 -3
  40. package/governance/ledger/{ui/ux-controls.md → skills/ui-craft/controls.md} +12 -7
  41. package/governance/ledger/skills/ui-craft/copy.md +102 -0
  42. package/governance/ledger/{ui → skills/ui-craft}/forms.md +3 -3
  43. package/governance/ledger/{ui → skills/ui-craft}/keyboard.md +3 -3
  44. package/governance/ledger/{ui → skills/ui-craft}/motion.md +3 -3
  45. package/governance/ledger/{ui → skills/ui-craft}/pointer.md +3 -3
  46. package/governance/ledger/{ui/ux-completeness.md → skills/ui-craft/states.md} +3 -3
  47. package/governance/ledger/ui/craft.md +19 -0
  48. package/governance/renames.toml +17 -3
  49. package/governance/rules/lib/test-scope.md +1 -1
  50. package/governance/rules/ui/craft.md +22 -0
  51. package/governance/splits.toml +0 -2
  52. package/package.json +1 -1
  53. package/src/claude/cases/misc.ts +5 -0
  54. package/src/claude/skills-audit.ts +150 -12
  55. package/src/commands/claude.ts +5 -0
  56. package/src/commands/tooling.ts +4 -1
  57. package/src/gate/measures.ts +4 -3
  58. package/src/gov/audit.ts +6 -1
  59. package/src/inventory/subjects.ts +1 -1
  60. package/src/tooling/package.ts +22 -13
  61. package/tooling/web/reference.md +3 -1
  62. package/governance/ledger/ui/copy.md +0 -49
  63. package/governance/ledger/ui/design-taste.md +0 -19
  64. package/governance/ledger/ui/interface-casing.md +0 -47
  65. package/governance/ledger/ui/link-behavior.md +0 -29
  66. package/governance/rules/ui/copy.md +0 -28
  67. package/governance/rules/ui/design-taste.md +0 -18
  68. package/governance/rules/ui/interface-casing.md +0 -29
  69. package/governance/rules/ui/link-behavior.md +0 -19
  70. /package/tooling/web/{configs → seeds}/e2e/home.spec.ts +0 -0
@@ -1,9 +1,9 @@
1
1
  ---
2
- title: Motion rule ledger
3
- description: Sources for governance/rules/ui/motion.md, one row per bullet
2
+ title: Motion reference ledger
3
+ description: Sources for claude/skills/ui-craft/references/motion.md, one row per bullet
4
4
  ---
5
5
 
6
- # Motion rule ledger
6
+ # Motion reference ledger
7
7
 
8
8
  The field is web accessibility, and the authorities are the W3C, through WCAG 2.2 and the ARIA Authoring Practices Guide, and MDN for the media feature.
9
9
 
@@ -1,9 +1,9 @@
1
1
  ---
2
- title: Pointer rule ledger
3
- description: Sources for governance/rules/ui/pointer.md, one row per bullet
2
+ title: Pointer reference ledger
3
+ description: Sources for claude/skills/ui-craft/references/pointer.md, one row per bullet
4
4
  ---
5
5
 
6
- # Pointer rule ledger
6
+ # Pointer reference ledger
7
7
 
8
8
  The field is web accessibility, and the authorities are the W3C, through WCAG 2.2 and the ARIA Authoring Practices Guide, MDN for popover behavior, and the platform guidelines from Apple and Android for native targets. These bullets were split from `keyboard.md`.
9
9
 
@@ -1,9 +1,9 @@
1
1
  ---
2
- title: UX completeness rule ledger
3
- description: Sources for governance/rules/ui/ux-completeness.md, one row per bullet
2
+ title: UX completeness reference ledger
3
+ description: Sources for claude/skills/ui-craft/references/states.md, one row per bullet
4
4
  ---
5
5
 
6
- # UX completeness rule ledger
6
+ # UX completeness reference ledger
7
7
 
8
8
  The field is interaction design, and the authorities are the Nielsen Norman Group's research articles, WCAG 2.2 for timing, MDN for the `title` attribute and CSS truncation, and web.dev for layout shift.
9
9
 
@@ -0,0 +1,19 @@
1
+ ---
2
+ title: UI craft rule ledger
3
+ description: Sources for governance/rules/ui/craft.md, one row per bullet
4
+ ---
5
+
6
+ # UI craft rule ledger
7
+
8
+ The rule is a pointer to the `ui-craft` and `design-taste` skills, which carry their own sources, so both rows are house choices naming the bug the pointer prevents.
9
+
10
+ ## Authority
11
+
12
+ | Bullet | Source | Read |
13
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
14
+ | Load the skills | House choice: a rendered edit made without the behavior floor or the layer order reaches for a model's defaults, and a second copy of either in the rule drifts from the skill that owns it | 2026-10-24 |
15
+ | Report an unresolved skill | House choice: the rule ships with the CLI and the skills with the plugin, so a project holding governance alone would otherwise proceed silently without them | 2026-10-18 |
16
+
17
+ ## Declined
18
+
19
+ The fold kept both bullets and widened the first to name `ui-craft` beside `design-taste`, since the practice rules it replaced now live in that skill.
@@ -3,9 +3,11 @@
3
3
  # so `canon gov sync` replaces a held old file with the rule under its new name
4
4
  # rather than only deleting it.
5
5
  #
6
- # An entry declares one-to-one identity and nothing else. A fold of several
7
- # rules into one, or a split of one into several, is a retirement plus an
8
- # addition and takes no entry here. Add the entry in the same change as the
6
+ # An entry declares one-to-one identity, or a fold onto a pointer rule that
7
+ # now routes to the skill carrying the folded rules, so a target holding any
8
+ # of them lands on the pointer rather than losing the practice with nothing in
9
+ # its place. Any other fold of several rules into one, or a split of one into
10
+ # several, is a retirement plus an addition and takes no entry here. Add the entry in the same change as the
9
11
  # rename or renumber. Prune an entry only once its target rule is retired and
10
12
  # the retire path covers it, since a chain ending on a rule nothing ships
11
13
  # resolves to nothing and a sync deletes the held file instead. Otherwise leave
@@ -73,3 +75,15 @@
73
75
  "460-design-taste" = "design-taste"
74
76
  "465-interface-casing" = "interface-casing"
75
77
  "470-motion" = "motion"
78
+ "design-taste" = "craft"
79
+ "a11y" = "craft"
80
+ "keyboard" = "craft"
81
+ "pointer" = "craft"
82
+ "forms" = "craft"
83
+ "motion" = "craft"
84
+ "ux-controls" = "craft"
85
+ "ux-completeness" = "craft"
86
+ "surface-capture" = "craft"
87
+ "copy" = "craft"
88
+ "interface-casing" = "craft"
89
+ "link-behavior" = "craft"
@@ -20,5 +20,5 @@ paths:
20
20
 
21
21
  - Answer a question about the running page with a script against the dev server rather than with the suite.
22
22
  - Do not enable `fullyParallel` in the Playwright config.
23
- - Follow `.claude/rules/canon/ui/surface-capture.md` for capture scope.
23
+ - Follow the `canon:ui-craft` skill's capture reference for capture scope, reporting it when the skill does not resolve.
24
24
  - Follow `.claude/rules/canon/lib/e2e-reliability.md` for waits and guards.
@@ -0,0 +1,22 @@
1
+ ---
2
+ description: Route edits to a rendered surface to the ui-craft skill for how it behaves and to the design-taste skill for how it looks
3
+ paths:
4
+ - '**/*.tsx'
5
+ - '**/*.jsx'
6
+ - '**/*.vue'
7
+ - '**/*.svelte'
8
+ - '**/*.astro'
9
+ - '**/*.html'
10
+ - '**/*.mdx'
11
+ - '**/*.css'
12
+ - '**/*.scss'
13
+ - '**/content/**/*.ts'
14
+ - 'canon/DESIGN.md'
15
+ ---
16
+
17
+ # UI craft standards
18
+
19
+ ## Authority
20
+
21
+ - Load the `canon:ui-craft` skill for how a surface behaves and the `canon:design-taste` skill for how it looks. Each is the single source for its half.
22
+ - Report it rather than proceeding silently when either skill does not resolve. Both ship with the plugin and this rule ships with the CLI, so governance alone carries neither.
@@ -11,5 +11,3 @@
11
11
  # entry only once its key or partner is retired, which the live-rule check in
12
12
  # `src/gov/splits.test.ts` forces.
13
13
  [split]
14
- keyboard = ["pointer"]
15
- ux-completeness = ["ux-controls"]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.62.0",
4
+ "version": "5.63.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -17,6 +17,11 @@ export const MISC_CASES: readonly SkillCase[] = [
17
17
  'Which layer of this landing page should I settle first, and what has to stay constant across it?',
18
18
  expect: 'design-taste',
19
19
  },
20
+ {
21
+ prompt:
22
+ 'This settings form needs validation and an empty state, so what does it owe for keyboard users and error messages before I build it?',
23
+ expect: 'ui-craft',
24
+ },
20
25
  {
21
26
  prompt:
22
27
  'This project has no logo yet. Draft one and give me a social card to go with it.',
@@ -1,8 +1,20 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { dirname, join } from 'node:path'
2
+ import { dirname, join, relative } from 'node:path'
3
3
  import { isFamilyKey } from '@/claude/skills-families'
4
+ import { plural } from '@/cli/ui'
5
+ import {
6
+ compareLedger,
7
+ countLedgerRows,
8
+ type LedgerMismatch,
9
+ measureRule,
10
+ } from '@/gov/audit'
4
11
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
5
- import { type BodyLine, bodyLines, maskDisplayed } from '@/markdown/scan'
12
+ import {
13
+ type BodyLine,
14
+ bodyLines,
15
+ maskCodeSpans,
16
+ maskDisplayed,
17
+ } from '@/markdown/scan'
6
18
 
7
19
  /** Returned when a skill folder carries no `REQUIREMENT.md`, the gating check. */
8
20
  export const EXIT_MISSING_REQUIREMENT = 2
@@ -69,8 +81,22 @@ export const LEDGER_SKILLS: readonly string[] = [
69
81
  join(SHIPPED_CORPUS, 'design-taste'),
70
82
  join(SHIPPED_CORPUS, 'write-human'),
71
83
  join(SHIPPED_CORPUS, 'test-first'),
84
+ join(SHIPPED_CORPUS, 'ui-craft'),
72
85
  ]
73
86
 
87
+ /**
88
+ * The ledger-listed skills whose every reference but the source record carries
89
+ * a ledger of its own, one table row per bullet under each H2, the way a rule
90
+ * in a practice folder does. The ledgers sit outside the plugin as rule
91
+ * ledgers do, so a target pays nothing for them.
92
+ */
93
+ export const BULLET_LEDGER_SKILLS: readonly string[] = [
94
+ join(SHIPPED_CORPUS, 'ui-craft'),
95
+ ]
96
+
97
+ /** Where a per-bullet ledger lives, as `<skill>/<reference>.md` below it. */
98
+ export const SKILL_LEDGERS = join('governance', 'ledger', 'skills')
99
+
74
100
  /** The closing H2s a practice skill carries, matched exactly outside fences. */
75
101
  export const PRACTICE_SECTIONS: readonly string[] = [
76
102
  'Excuses and rebuttals',
@@ -81,6 +107,19 @@ export const PRACTICE_SECTIONS: readonly string[] = [
81
107
  /** The source ledger every practice skill carries beside its body. */
82
108
  export const PRACTICE_LEDGER = join('references', 'adopted.md')
83
109
 
110
+ /**
111
+ * A ledger cites a source by linking a page outside the project, as an inline
112
+ * link or an autolink. A relative link points back into the skill and cites
113
+ * nothing a reader could check the rule against.
114
+ */
115
+ const EXTERNAL_LINK = /\]\(\s*https?:\/\/|<https?:\/\//
116
+
117
+ /**
118
+ * A table cell opening on the marker the governance ledgers already use for a
119
+ * rule the toolkit chose, so one check reads every ledger alike.
120
+ */
121
+ const HOUSE_CHOICE = /\|\s*House choice\b/
122
+
84
123
  /**
85
124
  * The line count past which a reference opens with a contents list, from
86
125
  * Anthropic's skill authoring guidance. Counted over the whole file the way
@@ -166,13 +205,23 @@ interface SkillSource {
166
205
  readonly requirement: string | undefined
167
206
  /** Every H2 in `SKILL.md` outside a fence, for the practice shape. */
168
207
  readonly sections: readonly string[]
169
- readonly hasLedger: boolean
208
+ /** Undefined when the folder carries no ledger at all. */
209
+ readonly ledger: string | undefined
170
210
  /**
171
211
  * `SKILL.md` and every reference below the folder, keyed by the path a
172
212
  * finding names. `REQUIREMENT.md` and `EVAL.md` are left out, since the
173
213
  * requirement is where the standard sends a date.
174
214
  */
175
215
  readonly bodies: readonly SkillText[]
216
+ /** One entry per reference of a skill on the per-bullet ledger list. */
217
+ readonly referenceLedgers: readonly ReferenceLedger[]
218
+ }
219
+
220
+ interface ReferenceLedger {
221
+ readonly rel: string
222
+ readonly ledgerRel: string
223
+ /** Undefined when the ledger file is absent. */
224
+ readonly mismatches: readonly LedgerMismatch[] | undefined
176
225
  }
177
226
 
178
227
  interface SkillText {
@@ -195,7 +244,7 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
195
244
  )
196
245
 
197
246
  const perCorpus = await Promise.all(
198
- present.map((corpus) => readCorpus(corpus.rel, corpus.dir)),
247
+ present.map((corpus) => readCorpus(root, corpus.rel, corpus.dir)),
199
248
  )
200
249
  const sources = perCorpus.flat()
201
250
 
@@ -247,21 +296,29 @@ export function auditExitCode(report: SkillsAudit): number {
247
296
  return report.missingRequirement.length > 0 ? EXIT_MISSING_REQUIREMENT : 0
248
297
  }
249
298
 
250
- async function readCorpus(rel: string, dir: string): Promise<SkillSource[]> {
299
+ async function readCorpus(
300
+ root: string,
301
+ rel: string,
302
+ dir: string,
303
+ ): Promise<SkillSource[]> {
251
304
  const paths = [
252
305
  ...new Bun.Glob('*/SKILL.md').scanSync({ cwd: dir, onlyFiles: true }),
253
306
  ].sort()
254
307
 
255
- return Promise.all(paths.map((path) => readSkill(rel, dir, dirname(path))))
308
+ return Promise.all(
309
+ paths.map((path) => readSkill(root, rel, dir, dirname(path))),
310
+ )
256
311
  }
257
312
 
258
313
  async function readSkill(
314
+ root: string,
259
315
  rel: string,
260
316
  dir: string,
261
317
  folder: string,
262
318
  ): Promise<SkillSource> {
263
319
  const skillDir = join(dir, folder)
264
320
  const requirementPath = join(skillDir, 'REQUIREMENT.md')
321
+ const ledgerPath = join(skillDir, PRACTICE_LEDGER)
265
322
 
266
323
  const references = [
267
324
  ...new Bun.Glob('references/**/*.md').scanSync({
@@ -270,11 +327,14 @@ async function readSkill(
270
327
  }),
271
328
  ].sort()
272
329
 
273
- const [body, requirement, referenceTexts] = await Promise.all([
330
+ const [body, requirement, ledger, referenceTexts] = await Promise.all([
274
331
  Bun.file(join(skillDir, 'SKILL.md')).text(),
275
332
  existsSync(requirementPath)
276
333
  ? Bun.file(requirementPath).text()
277
334
  : Promise.resolve(undefined),
335
+ existsSync(ledgerPath)
336
+ ? Bun.file(ledgerPath).text()
337
+ : Promise.resolve(undefined),
278
338
  Promise.all(
279
339
  references.map((path) => Bun.file(join(skillDir, path)).text()),
280
340
  ),
@@ -282,6 +342,15 @@ async function readSkill(
282
342
 
283
343
  const fields = parseFrontmatter(body)
284
344
  const skillRel = join(rel, folder)
345
+ const referenceLedgers = BULLET_LEDGER_SKILLS.includes(skillRel)
346
+ ? await readReferenceLedgers(
347
+ root,
348
+ skillRel,
349
+ folder,
350
+ references,
351
+ referenceTexts,
352
+ )
353
+ : []
285
354
 
286
355
  return {
287
356
  rel: skillRel,
@@ -294,7 +363,7 @@ async function readSkill(
294
363
  requirement === undefined ? undefined : headings(requirement),
295
364
  requirement,
296
365
  sections: sectionsOf(body),
297
- hasLedger: existsSync(join(skillDir, PRACTICE_LEDGER)),
366
+ ledger,
298
367
  bodies: [
299
368
  { rel: join(skillRel, 'SKILL.md'), text: body, isReference: false },
300
369
  ...references.map((path, index) => ({
@@ -303,9 +372,45 @@ async function readSkill(
303
372
  isReference: true,
304
373
  })),
305
374
  ],
375
+ referenceLedgers,
306
376
  }
307
377
  }
308
378
 
379
+ /**
380
+ * Pairs each reference's bullets with its ledger's rows through the rule
381
+ * audit's own comparison, so a reference and a rule are held to one count.
382
+ * The source record names authorities rather than stating bullets, so it is
383
+ * the one reference asked for no ledger.
384
+ */
385
+ async function readReferenceLedgers(
386
+ root: string,
387
+ skillRel: string,
388
+ folder: string,
389
+ references: readonly string[],
390
+ texts: readonly string[],
391
+ ): Promise<ReferenceLedger[]> {
392
+ return Promise.all(
393
+ references
394
+ .map((path, index) => ({ path, text: texts[index] ?? '' }))
395
+ .filter(({ path }) => path !== PRACTICE_LEDGER)
396
+ .map(async ({ path, text }) => {
397
+ const ledgerRel = join(
398
+ SKILL_LEDGERS,
399
+ folder,
400
+ relative('references', path),
401
+ )
402
+ const ledgerPath = join(root, ledgerRel)
403
+ const mismatches = existsSync(ledgerPath)
404
+ ? compareLedger(
405
+ measureRule(text).groups,
406
+ countLedgerRows(await Bun.file(ledgerPath).text()),
407
+ )
408
+ : undefined
409
+ return { rel: join(skillRel, path), ledgerRel, mismatches }
410
+ }),
411
+ )
412
+ }
413
+
309
414
  /**
310
415
  * A key carrying an empty value declares nothing, so blank reads as absent
311
416
  * rather than as a name that disagrees with every folder.
@@ -493,11 +598,44 @@ function practiceFindings(source: SkillSource): SkillFinding[] {
493
598
  rel: source.rel,
494
599
  detail: `missing section: ${section}`,
495
600
  }))
496
- const ledger = source.hasLedger
497
- ? []
498
- : [{ rel: source.rel, detail: `missing ledger: ${PRACTICE_LEDGER}` }]
499
601
 
500
- return [...sections, ...ledger]
602
+ return [
603
+ ...sections,
604
+ ...ledgerFindings(source),
605
+ ...source.referenceLedgers.flatMap(referenceLedgerFindings),
606
+ ]
607
+ }
608
+
609
+ function referenceLedgerFindings(entry: ReferenceLedger): SkillFinding[] {
610
+ if (entry.mismatches === undefined) {
611
+ return [{ rel: entry.rel, detail: `missing ledger: ${entry.ledgerRel}` }]
612
+ }
613
+ return entry.mismatches.map((mismatch) => ({
614
+ rel: entry.rel,
615
+ detail: `${mismatch.heading === null ? 'above the first H2' : `## ${mismatch.heading}`}: ${plural(mismatch.bullets, 'bullet')}, ${plural(mismatch.rows, 'ledger row')}`,
616
+ }))
617
+ }
618
+
619
+ /**
620
+ * A ledger present but citing nothing passed for as long as the check read the
621
+ * file's existence alone, so the contents are read too. A sample block shows a
622
+ * reader the shape of a row and cites nothing, so fences and code spans are
623
+ * left out.
624
+ */
625
+ function ledgerFindings(source: SkillSource): SkillFinding[] {
626
+ if (source.ledger === undefined) {
627
+ return [{ rel: source.rel, detail: `missing ledger: ${PRACTICE_LEDGER}` }]
628
+ }
629
+
630
+ const cites = bodyLines(source.ledger)
631
+ .filter((line) => !line.fenced)
632
+ .map((line) => maskCodeSpans(line.text))
633
+ .some((text) => EXTERNAL_LINK.test(text) || HOUSE_CHOICE.test(text))
634
+ if (cites) return []
635
+
636
+ return [
637
+ { rel: source.rel, detail: `ledger cites nothing: ${PRACTICE_LEDGER}` },
638
+ ]
501
639
  }
502
640
 
503
641
  /**
@@ -30,6 +30,8 @@ import {
30
30
  DESCRIPTION_LIMIT,
31
31
  LEDGER_SKILLS,
32
32
  PRACTICE_LEDGER,
33
+ BULLET_LEDGER_SKILLS,
34
+ SKILL_LEDGERS,
33
35
  PRACTICE_SECTIONS,
34
36
  PRACTICE_SKILLS,
35
37
  REQUIREMENT_LIMITS,
@@ -1768,6 +1770,9 @@ function reportPracticeShape(report: SkillsAudit): void {
1768
1770
  logInfo(
1769
1771
  `A listed practice skill closes with ${PRACTICE_SECTIONS.map((section) => `## ${section}`).join(', ')}, and every skill on the wider ledger list carries ${PRACTICE_LEDGER}.`,
1770
1772
  )
1773
+ logInfo(
1774
+ `A skill on the per-bullet list, ${plural(BULLET_LEDGER_SKILLS.length, 'skill')} here, also matches each other reference to a ledger under ${SKILL_LEDGERS}, one row per bullet under each H2.`,
1775
+ )
1771
1776
  logInfo(
1772
1777
  `Both lists are kept here and name ${plural(PRACTICE_SKILLS.length, 'practice skill')} and ${plural(LEDGER_SKILLS.length, 'ledger skill')}, so a skill outside ${SHIPPED_CORPUS} is never read.`,
1773
1778
  )
@@ -258,7 +258,7 @@ export function register(program: Command): void {
258
258
  'never overwrites one.',
259
259
  '',
260
260
  'Examples:',
261
- ' canon tooling customize e2e/home.spec.ts',
261
+ ' canon tooling customize e2e/screenshot.ts',
262
262
  '',
263
263
  ].join('\n'),
264
264
  )
@@ -1014,6 +1014,9 @@ function reportPackage(result: ScanResult): void {
1014
1014
  logStep('Scanning dependencies')
1015
1015
  for (const entry of result.deps) {
1016
1016
  if (entry.state === 'present') logInfo(entry.name)
1017
+ if (entry.state === 'ahead') {
1018
+ logInfo(`${entry.name} ${entry.installed} (ahead of ${entry.spec})`)
1019
+ }
1017
1020
  }
1018
1021
  for (const entry of result.deps) {
1019
1022
  if (entry.state === 'missing') logAdd(entry.spec)
@@ -12,6 +12,7 @@ import {
12
12
  auditSkills,
13
13
  LEDGER_SKILLS,
14
14
  PRACTICE_LEDGER,
15
+ SKILL_LEDGERS,
15
16
  PRACTICE_SKILLS,
16
17
  SHIPPED_CORPUS,
17
18
  } from '@/claude/skills-audit'
@@ -384,7 +385,7 @@ export const skillProvenance: Measure = async (ctx) => {
384
385
 
385
386
  /**
386
387
  * Every listed practice skill against the closing sections, and every skill on
387
- * the wider ledger list against the ledger, both of which
388
+ * the wider ledger list against a ledger that cites something, both of which
388
389
  * `claude/skills/create-skill/references/skill-practice.md` requires.
389
390
  *
390
391
  * Read in-process for the reason `skillProvenance` is, so the verb keeps its
@@ -407,14 +408,14 @@ export const skillPracticeShape: Measure = async (ctx) => {
407
408
  .join(', ')
408
409
  return {
409
410
  emissions: [],
410
- failure: `${plural(found.length, 'practice shape finding')}: ${where}. Add the closing H2 exactly as named to a practice skill, or the ledger at ${PRACTICE_LEDGER} to any skill on the ledger list.`,
411
+ failure: `${plural(found.length, 'practice shape finding')}: ${where}. Add the closing H2 exactly as named to a practice skill, or the ledger at ${PRACTICE_LEDGER} to any skill on the ledger list, citing one linked source or one House choice row. A reference on the per-bullet list takes one ledger row per bullet under the same H2 at ${SKILL_LEDGERS}.`,
411
412
  }
412
413
  }
413
414
 
414
415
  return {
415
416
  emissions: [
416
417
  info(
417
- `${plural(PRACTICE_SKILLS.length, 'practice skill')} carry every closing section, and ${plural(LEDGER_SKILLS.length, 'listed skill')} carry a ledger`,
418
+ `${plural(PRACTICE_SKILLS.length, 'practice skill')} carry every closing section, and ${plural(LEDGER_SKILLS.length, 'listed skill')} carry a ledger citing a source`,
418
419
  ),
419
420
  ],
420
421
  }
package/src/gov/audit.ts CHANGED
@@ -294,7 +294,12 @@ export function countLedgerRows(text: string): HeadingCount[] {
294
294
  return [...counts].map(([key, count]) => ({ heading: key, count }))
295
295
  }
296
296
 
297
- function compareLedger(
297
+ /**
298
+ * Pairs bullets and ledger rows under each heading, naming every heading whose
299
+ * counts differ. The skill audit reads a practice skill's references through
300
+ * it, so a rule and a reference are held to one comparison.
301
+ */
302
+ export function compareLedger(
298
303
  groups: readonly HeadingCount[],
299
304
  rows: readonly HeadingCount[],
300
305
  ): LedgerMismatch[] {
@@ -27,7 +27,7 @@ export interface Subject {
27
27
 
28
28
  /**
29
29
  * Reports what each element changes about itself when it takes focus, which is
30
- * the reading `governance/rules/ui/keyboard.md` states three rules against and
30
+ * the reading the `ui-craft` keyboard reference states three rules against and
31
31
  * nothing measures.
32
32
  *
33
33
  * The difference between rest and focus is the treatment, rather than the
@@ -17,7 +17,9 @@ export interface ScriptState {
17
17
  export interface DepState {
18
18
  readonly spec: string
19
19
  readonly name: string
20
- readonly state: 'missing' | 'present'
20
+ readonly state: 'missing' | 'present' | 'ahead'
21
+ /** The spec the target records, set only when it is `ahead` of the pin. */
22
+ readonly installed?: string
21
23
  }
22
24
 
23
25
  export function readPackage(path: string): PackageJson | undefined {
@@ -89,18 +91,24 @@ function firstInteger(value: string): string | undefined {
89
91
 
90
92
  /**
91
93
  * Compares a manifest pin against what package.json records. A spec counts as
92
- * missing when absent, or when the pinned major differs from the installed
93
- * major. Anything looser than a major mismatch is left alone.
94
+ * missing when absent or on an earlier major than the pin, and ahead when it
95
+ * sits on a later one, which a write leaves alone. A spec with no integer to
96
+ * compare, such as `latest` or `workspace:*`, counts as present.
94
97
  */
95
- function isSatisfied(installed: string | undefined, pin: string): boolean {
96
- if (installed === undefined) return false
97
- if (pin === '') return true
98
+ function compareSpec(
99
+ installed: string | undefined,
100
+ pin: string,
101
+ ): DepState['state'] {
102
+ if (installed === undefined) return 'missing'
103
+ if (pin === '') return 'present'
98
104
 
99
105
  const pinMajor = firstInteger(pin)
100
106
  const installedMajor = firstInteger(installed)
101
- if (pinMajor === undefined) return true
107
+ if (pinMajor === undefined || installedMajor === undefined) return 'present'
102
108
 
103
- return pinMajor === installedMajor
109
+ const delta = Number(installedMajor) - Number(pinMajor)
110
+ if (delta === 0) return 'present'
111
+ return delta > 0 ? 'ahead' : 'missing'
104
112
  }
105
113
 
106
114
  /**
@@ -121,11 +129,12 @@ export function collectDeps(
121
129
  if (name === '' || seen.has(name)) continue
122
130
  seen.add(name)
123
131
 
124
- states.push({
125
- spec,
126
- name,
127
- state: isSatisfied(installed[name], pin) ? 'present' : 'missing',
128
- })
132
+ const state = compareSpec(installed[name], pin)
133
+ states.push(
134
+ state === 'ahead'
135
+ ? { spec, name, state, installed: installed[name] }
136
+ : { spec, name, state },
137
+ )
129
138
  }
130
139
  }
131
140
 
@@ -45,6 +45,8 @@ The web layer covers the tooling shared across Vite + React, Astro, and Next.js.
45
45
 
46
46
  ## Screenshot cases
47
47
 
48
+ The starter `e2e/home.spec.ts` is seeded once. Sync copies it into a project that has none and leaves it alone after, so grow it freely.
49
+
48
50
  - Edit the `CASES` in the seeded `e2e/capture/cases.ts`, one entry per output file naming a section, a theme, and a route, since sync overwrites the `e2e/screenshot.ts` harness
49
51
  - Move cases from `e2e/screenshot.ts` to `e2e/capture/cases.ts` before a sync, and set `EVIDENCE_ROOT = 'evidence'` to keep an existing baseline
50
52
  - Expect each case crossed with `VIEWPORTS`, written to `screenshots/<hostname>/<section>/<width>--<theme>.png`, so local and deployed runs stay apart
@@ -76,7 +78,7 @@ The web layer covers the tooling shared across Vite + React, Astro, and Next.js.
76
78
 
77
79
  - Expect the sweep under `screenshots/` to be ignored and only a flagged case's `EVIDENCE_ROOT` output tracked, so a target's first capture is the baseline it commits
78
80
  - Size a case's `sections` list against the frames each later change regenerates, since a case naming sections commits a folder per section
79
- - Run the capture after a surface changes, component files included, per `governance/rules/ui/surface-capture.md`, unless the production build strips the component out
81
+ - Run the capture after a surface changes, component files included, per the `ui-craft` skill's capture reference, unless the production build strips the component out
80
82
 
81
83
  ## Screenshot scripts and workflows
82
84
 
@@ -1,49 +0,0 @@
1
- ---
2
- title: UI copy rule ledger
3
- description: Sources for governance/rules/ui/copy.md, one row per bullet
4
- ---
5
-
6
- # UI copy rule ledger
7
-
8
- The field is content design, and the authority for researched interface copy is the GOV.UK Design System and its style guide.
9
-
10
- ## Punctuation
11
-
12
- | Bullet | Source | Read |
13
- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
14
- | No em dash or semicolon | House choice: both read as generated copy in an interface, and a comma, a period, or a split sentence carries the same meaning in a shorter line | 2026-10-18 |
15
- | No trailing label punctuation | [Button](https://design-system.service.gov.uk/components/button/): every example label, such as "Start now", "Sign in", and "Continue", carries no trailing punctuation | 2026-10-18 |
16
-
17
- ## Button copy
18
-
19
- | Bullet | Source | Read |
20
- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |
21
- | The action's verb | [Button](https://design-system.service.gov.uk/components/button/): "Write button text in sentence case, describing the action it performs" | 2026-10-18 |
22
-
23
- ## Error messages
24
-
25
- | Bullet | Source | Read |
26
- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
27
- | Say how to fix it | [Error message](https://design-system.service.gov.uk/components/error-message/): "Describe what has happened and tell them how to fix it", and "'Enter your first name' is clearer, more direct and natural than 'First name must have an entry'" | 2026-10-18 |
28
- | Short and plain | [Error message](https://design-system.service.gov.uk/components/error-message/): "The message must be in plain English, use positive language and get to the point", with no "technical jargon like 'form post error'" | 2026-10-18 |
29
-
30
- ## Placeholder text
31
-
32
- | Bullet | Source | Read |
33
- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |
34
- | No placeholder, a hint instead | [Text input](https://design-system.service.gov.uk/components/text-input/): "Do not use placeholder text in place of a label, or for hints or examples", since "it vanishes when the user starts typing" and "not all screen readers read it out" | 2026-10-18 |
35
-
36
- ## Declined
37
-
38
- Every bullet the rewrite removed or reshaped, grouped by why.
39
-
40
- ### Contradicted by the source
41
-
42
- - Replaced "show format examples over instructions in placeholder text" with the hint text bullet, since GOV.UK says not to use a placeholder "for hints or examples".
43
- - Cut "reserve instructions for labels or helper text", which the new placeholder bullet now carries.
44
-
45
- ### Merged or reworded
46
-
47
- - Merged the imperative verb and padded phrasing bullets. The `Save` over `Save changes` example went, since GOV.UK adds words "to better describe the action".
48
- - Merged "keep messages short and actionable" and "one sentence maximum" into the plain language bullet.
49
- - Reworded the error example to `Enter an email address`, the instruction form GOV.UK prefers over a description.