@erclx/canon 4.49.0 → 4.51.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/docs-draft/REQUIREMENT.md +35 -0
- package/claude/skills/docs-draft/SKILL.md +59 -0
- package/docs/workflow/ai-workflow.md +1 -0
- package/package.json +1 -1
- package/src/claude/cases/authoring.ts +5 -0
- package/src/design/base.css +8 -0
- package/src/design/css.ts +1 -0
- package/src/design/tokens.ts +26 -2
- package/src/tasks/archive.ts +44 -20
- package/src/tasks/record.ts +4 -3
- package/tooling/astro/configs/playwright.config.ts +1 -0
- package/tooling/web/manifest.toml +1 -0
- package/tooling/web/reference.md +1 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: docs-draft
|
|
3
|
+
description: Why a brand-new docs/*.md page needs a placement decision and a confirm step, not the rewrite path docs-sync already owns
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Docs draft requirement
|
|
7
|
+
|
|
8
|
+
## Gap
|
|
9
|
+
|
|
10
|
+
Without this skill, a session drafting documentation for a surface that has no page yet either invents structure from memory or reaches for `docs-sync`, which has nothing to diff a nonexistent page against and reports the topic as unrelated to any change. Either way the page ships with no read of `standards/docs.md` and no catalog placement decided against the folder's actual shelves.
|
|
11
|
+
|
|
12
|
+
## Must
|
|
13
|
+
|
|
14
|
+
- Read `standards/docs.md` before drafting, since the frontmatter contract and the four reader questions are what make the page arguable against a sibling
|
|
15
|
+
- Decide placement from the existing catalog rather than guessing a folder, reusing a `category` value verbatim when one fits and defaulting to the `docs/` root when none does
|
|
16
|
+
- Confirm the resolved path and the full content with the user before writing, since placement is a judgment call with no diff to preview it against
|
|
17
|
+
- Run `canon indexes regen` on the containing folder after writing, so the folder's `index.md` picks up the new page immediately rather than drifting until the next unrelated regen
|
|
18
|
+
|
|
19
|
+
## Must not
|
|
20
|
+
|
|
21
|
+
- Rewrite an existing page. A topic already covered by a page refuses toward `docs-sync`.
|
|
22
|
+
- Hand-edit an `index.md`. It regenerates from sibling frontmatter, and a hand edit is overwritten on the next regen.
|
|
23
|
+
- Build a hand-drawn diagram-and-capture authoring loop. `standards/docs.md`'s Mermaid-fence permission is the only path to a diagram this skill takes.
|
|
24
|
+
|
|
25
|
+
## Guards
|
|
26
|
+
|
|
27
|
+
- No topic given: stop and ask what surface the page should cover.
|
|
28
|
+
- The derived slug already resolves through `canon docs <slug>`: stop and point at `docs-sync` instead. This catches an exact-name collision only.
|
|
29
|
+
- A page in the catalog already covers the topic under a different name: stop the same way, checked against the titles and descriptions Placement already reads.
|
|
30
|
+
|
|
31
|
+
## Out of scope
|
|
32
|
+
|
|
33
|
+
- Rewriting or syncing an existing `docs/*.md` section against a diff since main: `docs-sync`
|
|
34
|
+
- The `.claude/` planning surface: `claude-docs`
|
|
35
|
+
- Drafting a standard, a snippet, or a governance rule: `create-standard`, `create-snippet`, `create-rule`
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: docs-draft
|
|
3
|
+
description: Drafts a brand-new `docs/*.md` page against `standards/docs.md`, decides its placement in the existing catalog, confirms with the user, then writes. Use when asked to "add a docs page for X", "write a new doc for X", "document X under docs/", or "create a docs page for X" where no existing page covers the topic. Do NOT use to rewrite or sync an existing `docs/*.md` section against a diff, which is `docs-sync`.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Docs draft
|
|
7
|
+
|
|
8
|
+
Drafts one brand-new `docs/*.md` page end to end: read the standard, decide where the page belongs in the existing catalog, confirm the draft with the user, then write.
|
|
9
|
+
|
|
10
|
+
Read these files in parallel:
|
|
11
|
+
|
|
12
|
+
- `${CLAUDE_SKILL_DIR}/../../standards/docs.md`: the reader a page serves, its frontmatter, structure, and what it links out to rather than restates
|
|
13
|
+
- `${CLAUDE_SKILL_DIR}/../../standards/markdown.md`: banned words, punctuation, and formatting for all generated text
|
|
14
|
+
- The `write-human` skill: voice, rhythm, and sentence construction for all generated text
|
|
15
|
+
|
|
16
|
+
## Guards
|
|
17
|
+
|
|
18
|
+
- If no topic is given, stop: `❌ No topic given. Name the surface this page should cover.`
|
|
19
|
+
- Derive a kebab-case slug from the topic and run `canon docs <slug>`. A resolved page means the topic is already covered under that exact name. Stop: `❌ <slug> already resolves to an existing page. Run canon:docs-sync instead.` This is a name match rather than a topic match. Placement below checks the wider case.
|
|
20
|
+
|
|
21
|
+
## Placement
|
|
22
|
+
|
|
23
|
+
- Read `docs/index.md` and its sub-catalogs for the closest existing `category`. <!-- canon-allow-reference: illustrates the target project's own docs/ tree, not a citation of this repository's own corpus -->
|
|
24
|
+
- Check every page title and description this read surfaces against the topic. The Guards check above only catches an exact-slug collision, and a page already covering the same subject under a different slug still resolves here at no extra cost, since this read already runs. Stop the same way on a match: `❌ <path> already covers this topic under a different name. Run canon:docs-sync instead.`
|
|
25
|
+
- Reuse a matching `category` value verbatim. A near-miss spelling opens a second shelf holding one page, per the docs standard.
|
|
26
|
+
- Default to the `docs/` root with no `category` when nothing fits. A subfolder earns itself only once a shelf of pages already sits there, per the standard's splitting rule.
|
|
27
|
+
|
|
28
|
+
## Draft
|
|
29
|
+
|
|
30
|
+
- Draft `title`, `description`, and `category` (where one applies), then the page body, against `${CLAUDE_SKILL_DIR}/../../standards/docs.md`.
|
|
31
|
+
- Write for a reader with no source open. The standard's four questions are the test: what the surface is, what to run or write, what it refuses, and where to go for the adjacent surface.
|
|
32
|
+
|
|
33
|
+
## Confirm
|
|
34
|
+
|
|
35
|
+
- Show the resolved path and the full drafted content before writing.
|
|
36
|
+
- Confirm both with the user. This skill waits for that answer rather than treating the tool permission dialog as the gate, since placement here is a judgment call with no diff to preview it against.
|
|
37
|
+
|
|
38
|
+
## Write
|
|
39
|
+
|
|
40
|
+
- Write the file at the confirmed path, creating the folder when it is absent.
|
|
41
|
+
- Run `canon markdown audit <path>`.
|
|
42
|
+
- Run `canon indexes regen <folder>` on the containing folder, so its `index.md` picks up the new page.
|
|
43
|
+
|
|
44
|
+
## Response format
|
|
45
|
+
|
|
46
|
+
### Preview
|
|
47
|
+
|
|
48
|
+
**Topic:** `<topic>`
|
|
49
|
+
**Placement:** `<path>` (category: `<category-or-root>`)
|
|
50
|
+
|
|
51
|
+
```markdown
|
|
52
|
+
<drafted frontmatter and body>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### After confirmation
|
|
56
|
+
|
|
57
|
+
```plaintext
|
|
58
|
+
✅ Drafted: <path>
|
|
59
|
+
```
|
|
@@ -290,6 +290,7 @@ This section is the corpus the coverage claim is measured against: every name `c
|
|
|
290
290
|
| `canon:create-skill` | For a new `SKILL.md` |
|
|
291
291
|
| `canon:create-snippet` | For a reusable prompt |
|
|
292
292
|
| `canon:create-standard` | For a new authoring convention |
|
|
293
|
+
| `canon:docs-draft` | For a brand-new `docs/*.md` page, drafted against `standards/docs.md` |
|
|
293
294
|
| `canon:bash-script` | For an interactive, human-facing shell tool |
|
|
294
295
|
| `canon:bash-cli-script` | For a non-interactive automation, CI, or pipeline script |
|
|
295
296
|
| `canon:ci-workflow` | For a GitHub Actions workflow file |
|
package/package.json
CHANGED
|
@@ -37,6 +37,11 @@ export const AUTHORING_CASES: readonly SkillCase[] = [
|
|
|
37
37
|
"The docs folder and README are stale against what's on main, refresh them.",
|
|
38
38
|
expect: 'docs-sync',
|
|
39
39
|
},
|
|
40
|
+
{
|
|
41
|
+
prompt:
|
|
42
|
+
'Write a brand-new docs page for the capture command, nothing under docs/ covers it yet.',
|
|
43
|
+
expect: 'docs-draft',
|
|
44
|
+
},
|
|
40
45
|
{
|
|
41
46
|
prompt: 'Say what that dense answer actually means in plain terms.',
|
|
42
47
|
expect: 'restate-plainly',
|
package/src/design/base.css
CHANGED
|
@@ -28,14 +28,22 @@
|
|
|
28
28
|
--space-frame-top: 44px;
|
|
29
29
|
--space-frame-inline: 52px;
|
|
30
30
|
--space-frame-bottom: 38px;
|
|
31
|
+
--type-display-family: Noto Sans Mono, DejaVu Sans Mono, monospace;
|
|
31
32
|
--type-display-size: 34px;
|
|
32
33
|
--type-display-lh: 1.3;
|
|
34
|
+
--type-page-display-family: Noto Sans, DejaVu Sans, sans-serif;
|
|
35
|
+
--type-page-display-size: 52px;
|
|
36
|
+
--type-page-display-lh: 1.1;
|
|
37
|
+
--type-heading-family: Noto Sans Mono, DejaVu Sans Mono, monospace;
|
|
33
38
|
--type-heading-size: 19px;
|
|
34
39
|
--type-heading-lh: 1.3;
|
|
40
|
+
--type-body-family: Noto Sans Mono, DejaVu Sans Mono, monospace;
|
|
35
41
|
--type-body-size: 16px;
|
|
36
42
|
--type-body-lh: 1.65;
|
|
43
|
+
--type-label-family: Noto Sans Mono, DejaVu Sans Mono, monospace;
|
|
37
44
|
--type-label-size: 12px;
|
|
38
45
|
--type-label-lh: 1.45;
|
|
46
|
+
--type-code-family: Noto Sans Mono, DejaVu Sans Mono, monospace;
|
|
39
47
|
--type-code-size: 14.5px;
|
|
40
48
|
--type-code-lh: 1.3;
|
|
41
49
|
--radius-frame: 12px;
|
package/src/design/css.ts
CHANGED
|
@@ -76,6 +76,7 @@ function tokenProperties(tokens: DesignTokens): string[] {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
for (const role of tokens.typography) {
|
|
79
|
+
lines.push(` --type-${slug(role.role)}-family: ${role.family};`)
|
|
79
80
|
lines.push(` --type-${slug(role.role)}-size: ${role.size};`)
|
|
80
81
|
lines.push(` --type-${slug(role.role)}-lh: ${role.lineHeight};`)
|
|
81
82
|
}
|
package/src/design/tokens.ts
CHANGED
|
@@ -68,9 +68,21 @@ export interface DesignTokens {
|
|
|
68
68
|
readonly preamble: string
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/** The
|
|
71
|
+
/** The monospace stack every surface but the landing page hero declares. */
|
|
72
72
|
const MONO = 'Noto Sans Mono, DejaVu Sans Mono, monospace'
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The proportional stack the landing page hero declares and nothing else does.
|
|
76
|
+
* Noto Sans is the proportional sibling of the mono above rather than a face
|
|
77
|
+
* picked for taste, so the pairing is one superfamily rather than two systems.
|
|
78
|
+
*
|
|
79
|
+
* The stack stops at two names and a generic because `src/design/base.css` is
|
|
80
|
+
* written by `canon design regen` and formatted by prettier, and a declaration
|
|
81
|
+
* past 80 columns is wrapped by the second and flattened by the first. Every
|
|
82
|
+
* emitted line stays inside the width so the two writers never disagree.
|
|
83
|
+
*/
|
|
84
|
+
const SANS = 'Noto Sans, DejaVu Sans, sans-serif'
|
|
85
|
+
|
|
74
86
|
const DARK_GROUNDS = ['background', 'surface'] as const
|
|
75
87
|
const LIGHT_GROUNDS = ['light-background', 'light-surface'] as const
|
|
76
88
|
|
|
@@ -87,6 +99,8 @@ export const TOKENS: DesignTokens = {
|
|
|
87
99
|
|
|
88
100
|
personality: [
|
|
89
101
|
'Warm neutrals carry the frame under a single rust accent, rendered in the same monospace the terminal uses. The subject picks the register rather than taste: a toolkit whose primary surface is a shell has no proportional voice available, so the rendered surfaces match the terminal instead of the reverse. One accent carries every count, link, and primary action. Promoting a second and third into structural roles is what reads as a generated interface, so the palette stays at one.',
|
|
102
|
+
'',
|
|
103
|
+
'One surface is carved out of the sentence above, and it is the landing page hero. A rendered surface here shows a reader what the terminal did, so matching the terminal is what makes it legible. A public page addresses somebody who has never opened the terminal at all, and the shell has no voice available for that, which is the reverse of the case the rule was written for. The `page-display` role below is the whole of the carve-out. Every other role on that page stays monospace, including its body, its controls, and every frame it embeds, so the page reads as two families rather than as a second design system.',
|
|
90
104
|
].join('\n'),
|
|
91
105
|
|
|
92
106
|
colorNote: [
|
|
@@ -193,7 +207,9 @@ export const TOKENS: DesignTokens = {
|
|
|
193
207
|
],
|
|
194
208
|
|
|
195
209
|
typographyNote: [
|
|
196
|
-
'One family covers every role. The size scale runs from 11.5 to
|
|
210
|
+
'One family covers every role but `page-display`, which is the landing page hero and takes the proportional sibling of the same superfamily. The size scale runs from 11.5 to 52 pixels, and six values map onto a role. Five further values are adjustments inside a single component and get no role here, since a scale with five invented steps reads as a system the surfaces do not implement. They are 11.5, 12.5, 13, 14, and 15 pixels.',
|
|
211
|
+
'',
|
|
212
|
+
'The 52 pixel step sits above the 34 the rest of the scale tops out at, and it is the one size no other surface reaches. A hero headline set at the display cap reads as a section heading rather than an opening, which four rendered arms measured on 2026-09-04 before the step was added.',
|
|
197
213
|
'',
|
|
198
214
|
'A tagged cell is one no rendering surface exercises yet, which is a declaration the system has not tested rather than one it has.',
|
|
199
215
|
'',
|
|
@@ -208,6 +224,14 @@ export const TOKENS: DesignTokens = {
|
|
|
208
224
|
size: '34px',
|
|
209
225
|
lineHeight: '1.3',
|
|
210
226
|
},
|
|
227
|
+
{
|
|
228
|
+
role: 'page-display',
|
|
229
|
+
family: SANS,
|
|
230
|
+
weight: '700',
|
|
231
|
+
size: '52px',
|
|
232
|
+
lineHeight: '1.1',
|
|
233
|
+
verify: ['family', 'size', 'lineHeight'],
|
|
234
|
+
},
|
|
211
235
|
{
|
|
212
236
|
role: 'heading',
|
|
213
237
|
family: MONO,
|
package/src/tasks/archive.ts
CHANGED
|
@@ -400,7 +400,8 @@ export async function planCitations(
|
|
|
400
400
|
|
|
401
401
|
const stems = await listTaskStems(dir)
|
|
402
402
|
if (!stems.includes(stem)) {
|
|
403
|
-
|
|
403
|
+
const unmatched = describeUnmatchedStem(stems, stem)
|
|
404
|
+
return refuse(unmatched.reason, unmatched.message, unmatched.detail)
|
|
404
405
|
}
|
|
405
406
|
|
|
406
407
|
const target = readPlanTarget(await readFile(join(dir, `${stem}.md`), 'utf8'))
|
|
@@ -488,6 +489,45 @@ function refuse(
|
|
|
488
489
|
return { ok: false, reason, message, detail }
|
|
489
490
|
}
|
|
490
491
|
|
|
492
|
+
export interface UnmatchedStem {
|
|
493
|
+
readonly reason: 'no-match' | 'ambiguous'
|
|
494
|
+
readonly message: string
|
|
495
|
+
readonly detail: readonly string[]
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The three-way refusal a bare stem earns once it fails an exact match:
|
|
500
|
+
* exactly one task starts with it, several do, or none does. `archive.ts`'s
|
|
501
|
+
* own `resolveStem`, `planCitations`, and `record.ts`'s `resolveStem` all
|
|
502
|
+
* refuse a stem this way, so the wording lives once rather than three times.
|
|
503
|
+
*/
|
|
504
|
+
export function describeUnmatchedStem(
|
|
505
|
+
stems: readonly string[],
|
|
506
|
+
name: string,
|
|
507
|
+
): UnmatchedStem {
|
|
508
|
+
const prefixed = stems.filter((stem) => stem.startsWith(name))
|
|
509
|
+
if (prefixed.length === 1) {
|
|
510
|
+
const [match] = prefixed
|
|
511
|
+
return {
|
|
512
|
+
reason: 'no-match',
|
|
513
|
+
message: `${name} does not name a task by itself. One task starts with it: ${match}. Pass the full name instead.`,
|
|
514
|
+
detail: [match],
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (prefixed.length > 1) {
|
|
518
|
+
return {
|
|
519
|
+
reason: 'ambiguous',
|
|
520
|
+
message: `${name} does not name a task by itself. ${prefixed.length} tasks start with it. Pass the full name instead.`,
|
|
521
|
+
detail: prefixed,
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
reason: 'no-match',
|
|
526
|
+
message: `No task named ${name} on the board.`,
|
|
527
|
+
detail: stems,
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
491
531
|
async function resolveStem(
|
|
492
532
|
dir: string,
|
|
493
533
|
selector: TaskSelector,
|
|
@@ -496,27 +536,11 @@ async function resolveStem(
|
|
|
496
536
|
|
|
497
537
|
if (selector.kind === 'stem') {
|
|
498
538
|
if (!stems.includes(selector.stem)) {
|
|
499
|
-
const
|
|
500
|
-
if (prefixed.length === 1) {
|
|
501
|
-
const [match] = prefixed
|
|
502
|
-
return refuse(
|
|
503
|
-
'no-match',
|
|
504
|
-
`${selector.stem} does not name a task by itself. One task starts with it: ${match}. Pass the full name to archive it.`,
|
|
505
|
-
[match],
|
|
506
|
-
)
|
|
507
|
-
}
|
|
508
|
-
if (prefixed.length > 1) {
|
|
509
|
-
return refuse(
|
|
510
|
-
'ambiguous',
|
|
511
|
-
`${selector.stem} does not name a task by itself. ${prefixed.length} tasks start with it. Pass the full name to archive one.`,
|
|
512
|
-
prefixed,
|
|
513
|
-
)
|
|
514
|
-
}
|
|
515
|
-
return refuse(
|
|
516
|
-
'no-match',
|
|
517
|
-
`No task named ${selector.stem} on the board.`,
|
|
539
|
+
const { reason, message, detail } = describeUnmatchedStem(
|
|
518
540
|
stems,
|
|
541
|
+
selector.stem,
|
|
519
542
|
)
|
|
543
|
+
return refuse(reason, message, detail)
|
|
520
544
|
}
|
|
521
545
|
return selector.stem
|
|
522
546
|
}
|
package/src/tasks/record.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile, writeFile } from 'node:fs/promises'
|
|
|
3
3
|
import { basename, join, relative } from 'node:path'
|
|
4
4
|
import { planCandidates } from '@/tasks/answers'
|
|
5
5
|
import {
|
|
6
|
+
describeUnmatchedStem,
|
|
6
7
|
fenceMask,
|
|
7
8
|
linkTo,
|
|
8
9
|
listTaskStems,
|
|
@@ -241,11 +242,11 @@ async function resolveStem(
|
|
|
241
242
|
|
|
242
243
|
if (selector.kind === 'stem') {
|
|
243
244
|
if (!stems.includes(selector.stem)) {
|
|
244
|
-
|
|
245
|
-
'no-match',
|
|
246
|
-
`No task named ${selector.stem} on the board.`,
|
|
245
|
+
const { reason, message, detail } = describeUnmatchedStem(
|
|
247
246
|
stems,
|
|
247
|
+
selector.stem,
|
|
248
248
|
)
|
|
249
|
+
return refuse(reason, message, detail)
|
|
249
250
|
}
|
|
250
251
|
return selector.stem
|
|
251
252
|
}
|
|
@@ -49,6 +49,7 @@ packages = [
|
|
|
49
49
|
"check:full" = "./scripts/verify.sh && bun run test:e2e"
|
|
50
50
|
|
|
51
51
|
[scripts.override]
|
|
52
|
+
"lint" = "eslint . --max-warnings 0"
|
|
52
53
|
"screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 4173) && export PREVIEW_PORT && bash scripts/screenshot.sh"
|
|
53
54
|
|
|
54
55
|
[gitignore]
|
package/tooling/web/reference.md
CHANGED
|
@@ -53,6 +53,7 @@ Sticky negative knowledge. Do not relearn.
|
|
|
53
53
|
|
|
54
54
|
- Do NOT use `tsc -b` in a Vite project. Composite mode emits `.js` next to `.ts` and ESLint lints the emitted files. Use `tsc --noEmit`.
|
|
55
55
|
- Do NOT accept `eslint@^10` alongside `typescript-eslint@^8`. Chain breaks with `TypeError: Class extends value undefined` from `LegacyESLint`. Pin `eslint@^9` until `typescript-eslint@^9` with ESLint 10 support ships.
|
|
56
|
+
- Do NOT trust `vite-react`'s scaffolded `lint` script. It ships its own `"lint": "oxlint"` under a bare `.oxlintrc.json`, which runs a different rule set from the stack's ESLint config and never gates on it. `[scripts.override]` forces the stack's own eslint invocation back over it.
|
|
56
57
|
- Do NOT rely on bare-folder exclude globs like `exclude: ["e2e"]`. Use `"e2e/**/*"`.
|
|
57
58
|
- Do NOT ship Vitest with no-tests-fail. Fresh scaffolds have zero tests. Use `passWithNoTests: true` or equivalent until the project has at least one test.
|
|
58
59
|
- Do NOT put Playwright `trace` at the top level of `defineConfig`. It lives under `use`.
|