@erclx/canon 5.61.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 (87) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +2 -1
  5. package/claude/skills/create-skill/references/skill-practice.md +1 -0
  6. package/claude/skills/design-identity/references/card-route.md +3 -3
  7. package/claude/skills/design-record/references/design.md +1 -1
  8. package/claude/skills/design-taste/REQUIREMENT.md +2 -1
  9. package/claude/skills/design-taste/SKILL.md +6 -5
  10. package/claude/skills/design-taste/references/adopted.md +136 -8
  11. package/claude/skills/design-taste/references/craft.md +1 -1
  12. package/claude/skills/design-taste/references/kinds.md +8 -2
  13. package/claude/skills/design-taste/references/preflight.md +3 -3
  14. package/claude/skills/design-taste/references/tells.md +26 -5
  15. package/claude/skills/plan-feature/SKILL.md +2 -2
  16. package/claude/skills/plan-feature/references/plan.md +24 -3
  17. package/claude/skills/review-craft/SKILL.md +1 -1
  18. package/claude/skills/role-worker/SKILL.md +2 -1
  19. package/claude/skills/target-update/SKILL.md +9 -1
  20. package/claude/skills/ui-checklist/SKILL.md +1 -1
  21. package/claude/skills/ui-craft/REQUIREMENT.md +29 -0
  22. package/claude/skills/ui-craft/SKILL.md +44 -0
  23. package/{governance/rules/ui → claude/skills/ui-craft/references}/a11y.md +2 -10
  24. package/claude/skills/ui-craft/references/adopted.md +27 -0
  25. package/{governance/rules/ui/surface-capture.md → claude/skills/ui-craft/references/capture.md} +5 -15
  26. package/claude/skills/ui-craft/references/controls.md +18 -0
  27. package/claude/skills/ui-craft/references/copy.md +45 -0
  28. package/{governance/rules/ui → claude/skills/ui-craft/references}/forms.md +0 -9
  29. package/{governance/rules/ui → claude/skills/ui-craft/references}/keyboard.md +3 -16
  30. package/{governance/rules/ui → claude/skills/ui-craft/references}/motion.md +0 -10
  31. package/claude/skills/ui-craft/references/pointer.md +13 -0
  32. package/{governance/rules/ui/ux-completeness.md → claude/skills/ui-craft/references/states.md} +2 -20
  33. package/claude/skills/ux-audit/SKILL.md +1 -1
  34. package/claude/skills/write-human/references/adopted.md +4 -1
  35. package/docs/agents/install-and-sync.md +47 -13
  36. package/docs/index.md +1 -1
  37. package/docs/target/migrations.md +6 -8
  38. package/docs/target/sync.md +21 -1
  39. package/docs/workflow/ai-workflow.md +1 -1
  40. package/docs/workflow/index.md +3 -3
  41. package/docs/workflow/skill-map.md +1 -0
  42. package/docs/workflow/visual-design-workflow.md +58 -32
  43. package/governance/detect.toml +1 -37
  44. package/governance/ledger/lib/test-scope.md +1 -1
  45. package/governance/ledger/{ui → skills/ui-craft}/a11y.md +11 -9
  46. package/governance/ledger/{ui/surface-capture.md → skills/ui-craft/capture.md} +15 -15
  47. package/governance/ledger/skills/ui-craft/controls.md +31 -0
  48. package/governance/ledger/skills/ui-craft/copy.md +102 -0
  49. package/governance/ledger/{ui → skills/ui-craft}/forms.md +3 -3
  50. package/governance/ledger/skills/ui-craft/keyboard.md +67 -0
  51. package/governance/ledger/{ui → skills/ui-craft}/motion.md +3 -3
  52. package/governance/ledger/skills/ui-craft/pointer.md +24 -0
  53. package/governance/ledger/{ui/ux-completeness.md → skills/ui-craft/states.md} +14 -26
  54. package/governance/ledger/ui/craft.md +19 -0
  55. package/governance/renames.toml +17 -3
  56. package/governance/rules/lib/test-scope.md +1 -1
  57. package/governance/rules/ui/craft.md +22 -0
  58. package/governance/splits.toml +13 -0
  59. package/package.json +1 -1
  60. package/src/claude/cases/misc.ts +5 -0
  61. package/src/claude/skills-audit.ts +150 -12
  62. package/src/commands/claude.ts +5 -0
  63. package/src/commands/sync.ts +3 -1
  64. package/src/commands/tooling.ts +332 -10
  65. package/src/gate/measures.ts +4 -3
  66. package/src/gov/adapter.ts +53 -4
  67. package/src/gov/audit.ts +6 -1
  68. package/src/gov/splits.ts +36 -0
  69. package/src/inventory/subjects.ts +1 -1
  70. package/src/sync/check.ts +9 -1
  71. package/src/sync/stamp.ts +135 -11
  72. package/src/tooling/disposition.ts +167 -0
  73. package/src/tooling/inject.ts +17 -2
  74. package/src/tooling/package.ts +22 -13
  75. package/src/tooling/scan.ts +86 -7
  76. package/src/tooling/stamp.ts +51 -7
  77. package/tooling/web/reference.md +3 -1
  78. package/governance/ledger/ui/copy.md +0 -49
  79. package/governance/ledger/ui/design-taste.md +0 -19
  80. package/governance/ledger/ui/interface-casing.md +0 -47
  81. package/governance/ledger/ui/keyboard.md +0 -68
  82. package/governance/ledger/ui/link-behavior.md +0 -29
  83. package/governance/rules/ui/copy.md +0 -28
  84. package/governance/rules/ui/design-taste.md +0 -18
  85. package/governance/rules/ui/interface-casing.md +0 -29
  86. package/governance/rules/ui/link-behavior.md +0 -19
  87. /package/tooling/web/{configs → seeds}/e2e/home.spec.ts +0 -0
@@ -1,6 +1,13 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { resolveSurfacePath } from '@/roots/surface'
4
+ import {
5
+ type Disposition,
6
+ hashFile,
7
+ readStamp,
8
+ stampedDispositions,
9
+ stampedHashes,
10
+ } from '@/sync/stamp'
4
11
  import { mergeSections } from '@/tooling/gitignore'
5
12
  import {
6
13
  ancestorsFirst,
@@ -19,10 +26,14 @@ import {
19
26
  } from '@/tooling/package'
20
27
  import { isSubfolderTarget, isWithheldInSubfolder } from '@/tooling/subfolder'
21
28
 
29
+ export type DriftCause = 'stale' | 'edited' | 'unattributed'
30
+
31
+ /** `cause` is set on a drifted config alone. */
22
32
  export interface ConfigState {
23
33
  readonly rel: string
24
34
  readonly stack: string
25
35
  readonly state: 'new' | 'drifted' | 'matching'
36
+ readonly cause?: DriftCause
26
37
  }
27
38
 
28
39
  export interface SeedState {
@@ -41,9 +52,24 @@ export interface WithheldState {
41
52
  readonly present: boolean
42
53
  }
43
54
 
55
+ /**
56
+ * A golden file the project declined or customized, which every write skips.
57
+ * `moved` is set on a customized config alone, true once the golden copy no
58
+ * longer hashes to the one the project took.
59
+ */
60
+ export interface DispositionState {
61
+ readonly rel: string
62
+ readonly stack: string
63
+ readonly kind: 'config' | 'seed'
64
+ readonly disposition: Disposition
65
+ readonly present: boolean
66
+ readonly moved?: boolean
67
+ }
68
+
44
69
  export interface ScanResult {
45
70
  readonly configs: readonly ConfigState[]
46
71
  readonly withheld: readonly WithheldState[]
72
+ readonly dispositions: readonly DispositionState[]
47
73
  readonly seeds: readonly SeedState[]
48
74
  readonly scripts: readonly ScriptState[]
49
75
  readonly deps: readonly DepState[]
@@ -53,6 +79,17 @@ export interface ScanResult {
53
79
  readonly totalChanges: number
54
80
  }
55
81
 
82
+ /**
83
+ * Why a drifted config differs, which decides whether a write may replace it.
84
+ * A copy still at the hash the last write recorded is `stale`, since canon
85
+ * moved and the project did not. A copy off it is `edited`, and a copy with no
86
+ * recorded hash is `unattributed`, since nothing says who changed it.
87
+ */
88
+ function driftCause(recorded: string | undefined, dest: string): DriftCause {
89
+ if (recorded === undefined) return 'unattributed'
90
+ return hashFile(dest) === recorded ? 'stale' : 'edited'
91
+ }
92
+
56
93
  function isIdentical(a: string, b: string): boolean {
57
94
  if (!existsSync(b)) return false
58
95
  return readFileSync(a).equals(readFileSync(b))
@@ -72,11 +109,19 @@ function isIdentical(a: string, b: string): boolean {
72
109
  * GitHub reads only at the root moves to `withheld` and counts as no change.
73
110
  * The scope is read here so every caller of `scan` agrees without passing a
74
111
  * flag.
112
+ *
113
+ * A path the project declined or customized moves to `dispositions` ahead of
114
+ * the subfolder test, so it is listed once. The record is read here for the
115
+ * same reason as the scope.
75
116
  */
76
117
  export function scan(chain: readonly Manifest[], target: string): ScanResult {
77
118
  const isSubfolder = isSubfolderTarget(target)
119
+ const stamp = readStamp(target)
120
+ const held = stampedDispositions(stamp, 'tooling')
121
+ const recorded = stampedHashes(stamp, 'tooling')
78
122
  const configs: ConfigState[] = []
79
123
  const withheld: WithheldState[] = []
124
+ const dispositions: DispositionState[] = []
80
125
  const seenConfigs = new Set<string>()
81
126
 
82
127
  for (const manifest of chain) {
@@ -87,18 +132,38 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
87
132
  const source = join(manifest.configsDir, rel)
88
133
  const dest = join(target, rel)
89
134
 
135
+ const entry = held[rel]
136
+ if (entry !== undefined) {
137
+ dispositions.push({
138
+ rel,
139
+ stack: manifest.name,
140
+ kind: 'config',
141
+ disposition: entry.disposition,
142
+ present: existsSync(dest),
143
+ ...(entry.disposition === 'customized'
144
+ ? { moved: hashFile(source) !== entry.base }
145
+ : {}),
146
+ })
147
+ continue
148
+ }
149
+
90
150
  if (isSubfolder && isWithheldInSubfolder(rel)) {
91
151
  withheld.push({ rel, stack: manifest.name, present: existsSync(dest) })
92
152
  continue
93
153
  }
94
154
 
95
- const state = !existsSync(dest)
96
- ? 'new'
97
- : isIdentical(source, dest)
98
- ? 'matching'
99
- : 'drifted'
100
-
101
- configs.push({ rel, stack: manifest.name, state })
155
+ if (!existsSync(dest)) {
156
+ configs.push({ rel, stack: manifest.name, state: 'new' })
157
+ } else if (isIdentical(source, dest)) {
158
+ configs.push({ rel, stack: manifest.name, state: 'matching' })
159
+ } else {
160
+ configs.push({
161
+ rel,
162
+ stack: manifest.name,
163
+ state: 'drifted',
164
+ cause: driftCause(recorded[rel], dest),
165
+ })
166
+ }
102
167
  }
103
168
  }
104
169
 
@@ -110,6 +175,18 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
110
175
  if (seenSeeds.has(rel)) continue
111
176
  seenSeeds.add(rel)
112
177
 
178
+ const entry = held[rel]
179
+ if (entry !== undefined) {
180
+ dispositions.push({
181
+ rel,
182
+ stack: manifest.name,
183
+ kind: 'seed',
184
+ disposition: entry.disposition,
185
+ present: existsSync(resolveSurfacePath(target, rel)),
186
+ })
187
+ continue
188
+ }
189
+
113
190
  if (isSubfolder && isWithheldInSubfolder(rel)) {
114
191
  withheld.push({
115
192
  rel,
@@ -144,6 +221,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
144
221
  const totalChanges =
145
222
  (newManifest ? 1 : 0) +
146
223
  configs.filter((entry) => entry.state !== 'matching').length +
224
+ dispositions.filter((entry) => entry.moved === true).length +
147
225
  seeds.filter((entry) => entry.state === 'missing').length +
148
226
  scripts.filter((entry) => entry.state !== 'matching').length +
149
227
  deps.filter((entry) => entry.state === 'missing').length +
@@ -152,6 +230,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
152
230
  return {
153
231
  configs,
154
232
  withheld,
233
+ dispositions,
155
234
  seeds,
156
235
  scripts,
157
236
  deps,
@@ -1,7 +1,19 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { readStamp, stampedChain, writeChainStamp } from '@/sync/stamp'
4
- import { isWholeStack, loadManifest, type Manifest } from '@/tooling/manifest'
3
+ import {
4
+ type DomainHashes,
5
+ hashFile,
6
+ readStamp,
7
+ stampedChain,
8
+ stampedHashes,
9
+ writeChainStamp,
10
+ } from '@/sync/stamp'
11
+ import {
12
+ configFiles,
13
+ isWholeStack,
14
+ loadManifest,
15
+ type Manifest,
16
+ } from '@/tooling/manifest'
5
17
  import { readPackage } from '@/tooling/package'
6
18
 
7
19
  /**
@@ -32,15 +44,47 @@ export async function recordToolingChain(
32
44
  ): Promise<string[] | undefined> {
33
45
  if (isWorkspaceRoot(target)) return undefined
34
46
 
35
- const merged = mergeAddOns(
36
- toolkitRoot,
37
- stampedChain(readStamp(target), 'tooling'),
38
- chain,
47
+ const stamp = readStamp(target)
48
+ const merged = mergeAddOns(toolkitRoot, stampedChain(stamp, 'tooling'), chain)
49
+ await writeChainStamp(
50
+ target,
51
+ { domain: 'tooling', toolkitRoot },
52
+ merged,
53
+ now,
54
+ matchedHashes(chain, target, stampedHashes(stamp, 'tooling')),
39
55
  )
40
- await writeChainStamp(target, { domain: 'tooling', toolkitRoot }, merged, now)
41
56
  return merged
42
57
  }
43
58
 
59
+ /**
60
+ * The golden hash of every config the target now matches, over the hashes an
61
+ * earlier run recorded. A config the target holds a different copy of keeps
62
+ * its previous hash, so a held edit still reads as edited on the next run
63
+ * rather than as a stale copy the write may replace.
64
+ */
65
+ function matchedHashes(
66
+ chain: readonly Manifest[],
67
+ target: string,
68
+ previous: DomainHashes,
69
+ ): DomainHashes {
70
+ const hashes: Record<string, string> = { ...previous }
71
+ const seen = new Set<string>()
72
+
73
+ for (const manifest of chain) {
74
+ for (const rel of configFiles(manifest)) {
75
+ if (seen.has(rel)) continue
76
+ seen.add(rel)
77
+
78
+ const dest = join(target, rel)
79
+ if (!existsSync(dest)) continue
80
+ const golden = hashFile(join(manifest.configsDir, rel))
81
+ if (hashFile(dest) === golden) hashes[rel] = golden
82
+ }
83
+ }
84
+
85
+ return hashes
86
+ }
87
+
44
88
  /**
45
89
  * An add-on stands alone beside a core chain, so syncing one must not displace
46
90
  * the chain it layers onto. Base extends nothing and still heads a core chain.
@@ -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.
@@ -1,19 +0,0 @@
1
- ---
2
- title: Design taste rule ledger
3
- description: Sources for governance/rules/ui/design-taste.md, one row per bullet
4
- ---
5
-
6
- # Design taste rule ledger
7
-
8
- The rule is a pointer to the `design-taste` skill, which carries its 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 skill | House choice: a style edit made without the layer order or the coherence locks reaches for a model's defaults, and a second copy of them in the rule drifts from the skill | 2026-10-18 |
15
- | Report an unresolved skill | House choice: the rule ships with the CLI and the skill with the plugin, so a project holding governance alone would otherwise proceed silently without it | 2026-10-18 |
16
-
17
- ## Declined
18
-
19
- The rewrite kept both bullets unchanged.
@@ -1,47 +0,0 @@
1
- ---
2
- title: Interface casing rule ledger
3
- description: Sources for governance/rules/ui/interface-casing.md, one row per bullet
4
- ---
5
-
6
- # Interface casing rule ledger
7
-
8
- The field is content design, and the authority is the GOV.UK style guide, which sets sentence case for every heading and title. The label count and the scope bullets are canon's own.
9
-
10
- ## Sentence case
11
-
12
- | Bullet | Source | Read |
13
- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
14
- | Sentence case | [A to Z of GOV.UK style](https://www.gov.uk/guidance/style-guide/a-to-z-of-gov-uk-style): "Always use sentence case, even in page titles and service names" | 2026-10-18 |
15
- | Proper nouns keep casing | [A to Z of GOV.UK style](https://www.gov.uk/guidance/style-guide/a-to-z-of-gov-uk-style): "The exceptions to this are proper nouns" | 2026-10-18 |
16
-
17
- ## Uppercase labels
18
-
19
- | Bullet | Source | Read |
20
- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |
21
- | Label count | House choice: an uppercase eyebrow over every section is the template tell `design-taste` reaches past, and the count makes the cap checkable against a page | 2026-10-18 |
22
-
23
- ## Where it applies
24
-
25
- | Bullet | Source | Read |
26
- | ---------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------- |
27
- | Copy outside the globs | House choice: a locale file holds the same rendered strings, so title case there ships with no rule firing on it | 2026-10-18 |
28
-
29
- ## Overriding
30
-
31
- | Bullet | Source | Read |
32
- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
33
- | Depart where needed | House choice: an acronym or brand mark lowered to sentence case misnames it, the same reason GOV.UK keeps "proper nouns" as its one exception | 2026-10-18 |
34
-
35
- ## Declined
36
-
37
- Every bullet the rewrite removed or reshaped, grouped by why.
38
-
39
- ### Cut without a check
40
-
41
- - Cut "treat the count as a judgment, since a glob cannot check it", which tells a session nothing it can verify against a line.
42
-
43
- ### Merged or reworded
44
-
45
- - Folded "treat title case on every heading as a template tell" into the sentence case bullet as its reason.
46
- - Shortened the uppercase label bullet under 200 characters, keeping the `ceil(sections / 3)` count and the delete remedy.
47
- - Reworded the scope bullet from a statement of what the rule covers to the directive a session can act on.
@@ -1,68 +0,0 @@
1
- ---
2
- title: Keyboard rule ledger
3
- description: Sources for governance/rules/ui/keyboard.md, one row per bullet
4
- ---
5
-
6
- # Keyboard rule ledger
7
-
8
- The field is web accessibility, and the authorities are the W3C, through WCAG 2.2 and the ARIA Authoring Practices Guide, MDN for CSS behavior, and the platform guidelines from Apple and Android for native targets. Every bullet here was split from `a11y.md`.
9
-
10
- ## Focus styles
11
-
12
- | Bullet | Source | Read |
13
- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
14
- | One project focus ring | House choice: per-component focus styles drift apart, and `canon inventory` reports the ring by computed style, so a one-off treatment shows as its own row | 2026-10-18 |
15
- | No outline removal without a replacement | [outline](https://developer.mozilla.org/en-US/docs/Web/CSS/outline): "Assigning outline a value of 0 or none will remove the browser's default focus style. If an element can be interacted with it must have a visible focus indicator." | 2026-10-18 |
16
- | 3:1 ring in every theme | [Understanding Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html): visual information needed to identify a state needs "a contrast ratio of at least 3:1 against adjacent color(s)" | 2026-10-18, WCAG 2.2 |
17
-
18
- ## Keyboard operation
19
-
20
- | Bullet | Source | Read |
21
- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------- |
22
- | Escape closes | [Dialog (modal) pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/): "Escape : Closes the dialog", and the [combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) gives Escape the same job for a popup | 2026-10-18, APG |
23
- | No bare character shortcut | [Understanding Character Key Shortcuts](https://www.w3.org/WAI/WCAG22/Understanding/character-key-shortcuts.html): a character-only shortcut needs a way to "Turn off", to "Remap", or to be "Active only on focus" | 2026-10-18, WCAG 2.2 |
24
- | Drag has a single-pointer path | [Understanding Dragging Movements](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html): "For any action that involves dragging, provide a simple pointer alternative" | 2026-10-18, WCAG 2.2 |
25
-
26
- ## Composite widgets
27
-
28
- | Bullet | Source | Read |
29
- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
30
- | One item in the tab sequence | [Developing a keyboard interface](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/): "the tab sequence should include only one focusable element of a composite UI component", managed by "a roving tabindex" or "the aria-activedescendant property" | 2026-10-18, APG |
31
- | Combobox keys | [Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/): "Down Arrow : If the popup is available, moves focus into the popup", Enter accepts the focused option, and "Escape : Dismisses the popup if it is visible" | 2026-10-18, APG |
32
-
33
- ## Focus management
34
-
35
- | Bullet | Source | Read |
36
- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
37
- | Focus into a dialog and back | [Dialog (modal) pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/): "When a dialog opens, focus moves to an element inside the dialog", and "When a dialog closes, focus returns to the element that invoked the dialog" | 2026-10-18, APG |
38
- | Focus never covered | [Understanding Focus Not Obscured (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html): "the component is not entirely hidden due to author-created content" | 2026-10-18, WCAG 2.2 |
39
-
40
- ## Pointer targets
41
-
42
- | Bullet | Source | Read |
43
- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
44
- | 24 by 24 on the web | [Understanding Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html): "at least 24 by 24 CSS pixels", except where "a 24 CSS pixel diameter circle is centered on the bounding box of each" and "the circles do not intersect" | 2026-10-18, WCAG 2.2 |
45
- | Native figures | [Apple accessibility guidelines](https://developer.apple.com/design/human-interface-guidelines/accessibility): a default "44x44 pt" control size on iOS and iPadOS, and [Android accessibility](https://developer.android.com/guide/topics/ui/accessibility/apps): "a focusable area, or touch target size , of at least 48dpx48dp" | 2026-10-18 |
46
-
47
- ## Declined
48
-
49
- Every bullet the split removed or reshaped, grouped by why.
50
-
51
- ### Browser defaults
52
-
53
- - Cut the Tab, Enter, and Space bullets of the old keyboard model. A native element does each by default, and `a11y.md` already sends a session to the native element.
54
-
55
- ### Rewritten to what the source says
56
-
57
- - Replaced "do not introduce non-standard key bindings" with the character key bullet. No reviewer can check "non-standard", and the criterion names the binding that breaks.
58
- - Narrowed "move focus into any overlay, popover, or dialog on open" to a dialog. The APG moves focus into a modal dialog, and a non-modal popover such as a tooltip leaves focus on its trigger.
59
-
60
- ### Cut without a source or a bug class
61
-
62
- - Cut "never move focus on hover", a convention Claude already follows that no cited page takes a side on.
63
-
64
- ### Merged or reworded
65
-
66
- - Merged "arrow keys navigate within composite widgets" into the roving `tabindex` bullet, which names the arrow keys.
67
- - Merged "Escape cancels in-progress edits" into the Escape bullet, and took in the inline confirmation case from the `ux-completeness.md` rewrite so the Escape behavior has one copy.
68
- - Reworded the combobox bullet to the pattern's own keys, dropping "typing filters", which the APG leaves to each autocomplete behavior.
@@ -1,29 +0,0 @@
1
- ---
2
- title: Link behavior rule ledger
3
- description: Sources for governance/rules/ui/link-behavior.md, one row per bullet
4
- ---
5
-
6
- # Link behavior rule ledger
7
-
8
- The field is interaction design, and the authority is the Nielsen Norman Group's research on new windows and tabs.
9
-
10
- ## Default target
11
-
12
- | Bullet | Source | Read |
13
- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |
14
- | Same tab, new tab by task | [Opening links in new browser windows and tabs](https://www.nngroup.com/articles/new-browser-windows-and-tabs/): "There is generally one good reason to open a page in a new window, and that is that the user will need to refer to that content in order to complete a task in another window" | 2026-10-18 |
15
- | No blanket external new tab | [Opening links in new browser windows and tabs](https://www.nngroup.com/articles/new-browser-windows-and-tabs/): "New windows or tabs prevent the use of the Back button" and "can cause disorientation" | 2026-10-18 |
16
-
17
- ## mailto: links
18
-
19
- | Bullet | Source | Read |
20
- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
21
- | `mailto:` same tab | House choice: the link opens the mail client and leaves the page in place, so `target="_blank"` only adds an empty tab the person must close | 2026-10-18 |
22
-
23
- ## Declined
24
-
25
- Every bullet the rewrite removed or reshaped, grouped by why.
26
-
27
- ### Rewritten to what the source says
28
-
29
- - Reworded the new tab exception from "a destination that would discard in-progress work" to a page the person must consult while finishing a task, the one reason NN/g names.
@@ -1,28 +0,0 @@
1
- ---
2
- description: Enforce UI copy and text standards for rendered strings in components
3
- paths:
4
- - '**/*.tsx'
5
- - '**/*.jsx'
6
- - '**/*.astro'
7
- - '**/*.html'
8
- ---
9
-
10
- # UI copy standards
11
-
12
- ## Punctuation
13
-
14
- - Write no em dash (`—`) or semicolon in rendered copy. Use a comma or a period, or restructure the sentence.
15
- - Add no trailing punctuation to a button label or a short action string.
16
-
17
- ## Button copy
18
-
19
- - Label a button with the verb of the action it performs, such as `Save` or `Delete`, never with filler such as `Click to delete`.
20
-
21
- ## Error messages
22
-
23
- - Tell the person how to fix the error, such as `Enter an email address` over `Email is invalid`.
24
- - Keep an error message to one short sentence in plain language, with no technical jargon or error codes.
25
-
26
- ## Placeholder text
27
-
28
- - Never use placeholder text as a label, a hint, or an example. Put a format example such as `name@example.com` in hint text under the label.
@@ -1,18 +0,0 @@
1
- ---
2
- description: Route edits that decide how a surface looks to the design-taste skill for the layer ordering, the coherence locks, and the defaults a model reaches for
3
- paths:
4
- - '**/*.tsx'
5
- - '**/*.jsx'
6
- - '**/*.astro'
7
- - '**/*.html'
8
- - '**/*.css'
9
- - '**/*.scss'
10
- - 'canon/DESIGN.md'
11
- ---
12
-
13
- # Design taste standards
14
-
15
- ## Authority
16
-
17
- - Load the `canon:design-taste` skill for which design decision settles first, what must stay constant across a surface, and the defaults to reach past. It is the single source for all three.
18
- - Report it rather than proceeding silently when the skill does not resolve. It ships with the plugin and this rule ships with the CLI, so a project that installed governance alone does not have it.
@@ -1,29 +0,0 @@
1
- ---
2
- description: Enforce casing for interface text in markup, MDX, and content modules, where copy lives in a content folder
3
- paths:
4
- - '**/*.tsx'
5
- - '**/*.jsx'
6
- - '**/*.astro'
7
- - '**/*.html'
8
- - '**/*.mdx'
9
- - '**/content/**/*.ts'
10
- ---
11
-
12
- # Interface casing standards
13
-
14
- ## Sentence case
15
-
16
- - Use sentence case for rendered headings, labels, nav items, tab titles, and dialog titles, since title case on every heading reads as a template default.
17
- - Keep the original casing of a proper noun or product name, such as `GitHub`, `macOS`, or `TypeScript`.
18
-
19
- ## Uppercase labels
20
-
21
- - Keep small uppercase labels above headings to `ceil(sections / 3)`, the first screen counting as one. Delete the extra labels, since position already names a section.
22
-
23
- ## Where it applies
24
-
25
- - Apply the same casing to interface copy kept outside the globs, such as a locale file, which this rule never fires on.
26
-
27
- ## Overriding
28
-
29
- - Depart from sentence case only where the case needs it, such as an acronym, a brand mark, or a set-in-caps identity the design chose on purpose.
@@ -1,19 +0,0 @@
1
- ---
2
- description: Enforce the same-tab default for an outbound link and for a mail link
3
- paths:
4
- - '**/*.tsx'
5
- - '**/*.jsx'
6
- - '**/*.astro'
7
- - '**/*.html'
8
- ---
9
-
10
- # Link behavior standards
11
-
12
- ## Default target
13
-
14
- - Open a link in the same tab. Reserve `target="_blank"` for a page the person must consult while finishing a task in the current one.
15
- - Never add `target="_blank"` as a default for every external link.
16
-
17
- ## mailto: links
18
-
19
- - Open a `mailto:` link in the same tab. It hands off to the mail client rather than replacing the page, so a new tab leaves an empty tab behind.
File without changes