@erclx/canon 4.8.0 → 4.9.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/README.md +55 -41
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-docs/REQUIREMENT.md +2 -2
- package/claude/skills/claude-docs/SKILL.md +7 -36
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +13 -8
- package/claude/skills/claude-tasks/SKILL.md +4 -5
- package/claude/skills/claude-teach/SKILL.md +11 -1
- package/claude/skills/claude-worker/SKILL.md +2 -2
- package/docs/agents/commands.md +8 -3
- package/docs/agents/install-and-sync.md +23 -1
- package/docs/agents/overview.md +3 -3
- package/docs/agents/tasks.md +10 -4
- package/docs/agents/teach.md +2 -0
- package/docs/ai-workflow.md +2 -4
- package/docs/visual-design-workflow.md +2 -0
- package/docs/zshrc-aliases.md +19 -7
- package/package.json +1 -1
- package/scripts/core/bootstrap.sh +4 -0
- package/src/commands/design.ts +147 -5
- package/src/commands/sync.ts +3 -5
- package/src/commands/tasks.ts +18 -1
- package/src/commands/teach.ts +72 -0
- package/src/design/adapter.ts +59 -0
- package/src/design/base.css +123 -0
- package/src/design/components.ts +118 -0
- package/src/design/contrast.ts +81 -0
- package/src/design/css.ts +142 -0
- package/src/design/document.ts +143 -0
- package/src/design/regen.ts +56 -0
- package/src/design/render.ts +43 -9
- package/src/design/tokens.ts +315 -0
- package/src/gate/stages.ts +28 -0
- package/src/slides/styles.ts +42 -15
- package/src/sync/check.ts +41 -0
- package/src/sync/engine.ts +28 -7
- package/src/sync/stamp.ts +5 -4
- package/src/sync/target.ts +4 -1
- package/src/tasks/archive.ts +91 -20
- package/src/tasks/validate.ts +76 -0
- package/src/teach/workspace.ts +57 -0
- package/standards/tasks.md +4 -4
package/docs/zshrc-aliases.md
CHANGED
|
@@ -12,6 +12,8 @@ Claude Code auto-discovers the toolkit plugin from `claude/.claude-plugin/plugin
|
|
|
12
12
|
|
|
13
13
|
`bun run bootstrap` installs this block. It owns the canonical copy in `scripts/core/bootstrap.sh`, appends it to `~/.zshrc` wrapped in the two marker comments below, and sets `TOOLKIT` to the cloned path. The markers are how a re-run recognizes its own block and skips.
|
|
14
14
|
|
|
15
|
+
Skipping means a re-run never updates a block already installed, so a shell that ran the bootstrap before the canonical block grew keeps whatever it got that day. To pick up an alias added since, delete the marked block and re-run the bootstrap, or paste the new lines into the block by hand.
|
|
16
|
+
|
|
15
17
|
A block installed by hand before the managed one has no markers. Bootstrap detects that case through the `alias clp=` line instead, warns, and leaves the block untouched rather than appending a second copy. To switch over, delete the hand-rolled block and re-run the bootstrap.
|
|
16
18
|
|
|
17
19
|
```zsh
|
|
@@ -27,26 +29,34 @@ alias cls='cl --model sonnet'
|
|
|
27
29
|
alias clp='claude --plugin-dir $TOOLKIT/claude'
|
|
28
30
|
alias clpc='clp -c'
|
|
29
31
|
alias clps='clp --model sonnet'
|
|
32
|
+
alias clpa='clp agents'
|
|
33
|
+
alias clpac='clpa --cwd .'
|
|
30
34
|
# <<< canon aliases <<<
|
|
31
35
|
```
|
|
32
36
|
|
|
33
|
-
The block sits after any `PATH` mutations and the `claude` CLI install. Zsh expands aliases recursively on the first word, so `clr`, `clc`, `clw`, and `cls` inherit their base through `cl`, and `clpc` and `
|
|
37
|
+
The block sits after any `PATH` mutations and the `claude` CLI install. Zsh expands aliases recursively on the first word, so `clr`, `clc`, `clw`, and `cls` inherit their base through `cl`, and `clpc`, `clps`, and `clpa` inherit `--plugin-dir` through `clp`. `clpac` inherits it one level further, through `clpa`. `$TOOLKIT` expands at invocation time, so updating the variable and re-sourcing reroutes all `clp` calls without touching the alias definitions.
|
|
38
|
+
|
|
39
|
+
To opt out, delete the block between the two markers. Bootstrap re-adds it on the next run, so skip that step by running `bun install` and `bun link` yourself instead.
|
|
34
40
|
|
|
35
41
|
## What each one does
|
|
36
42
|
|
|
37
|
-
`cl`
|
|
43
|
+
`cl`, `clw`, and `cls` carry no explicit plugin dir and start a fresh session. Use them inside the toolkit repository, where Claude Code auto-discovers the plugin from `claude/.claude-plugin/plugin.json`. Loading `--plugin-dir` on top of auto-discovery registers every skill twice and produces duplicate entries in the slash command list.
|
|
44
|
+
|
|
45
|
+
`clr` and `clc` resume a session rather than launching one, so auto-discovery never fires for them. A resume restores whatever plugin registration the session started with and recomputes nothing, which a bare `claude --resume` demonstrated by holding a stale registration through a full process restart until `--plugin-dir` was passed on the resume itself.
|
|
38
46
|
|
|
39
47
|
- `cl`: plain session in the current directory
|
|
40
|
-
- `clr`: opens the `/resume` picker scoped to the current directory. Trailing arguments filter by name. `clr auth` limits results to sessions containing "auth".
|
|
41
|
-
- `clc`: jumps straight into the most recent session for the current directory. No picker. Faster than `clr` when the terminal closed and you want back into the same session.
|
|
48
|
+
- `clr`: opens the `/resume` picker scoped to the current directory. Trailing arguments filter by name. `clr auth` limits results to sessions containing "auth". Resuming does not reload the plugin, so reach for `clpc` when the session needs it.
|
|
49
|
+
- `clc`: jumps straight into the most recent session for the current directory. No picker. Faster than `clr` when the terminal closed and you want back into the same session. Resuming does not reload the plugin here either.
|
|
42
50
|
- `clw`: creates a worktree under `.claude/worktrees/<name>/` on a fresh branch and starts a Claude Code session in it. Pass the worktree name as the trailing arg: `clw feat-auth`.
|
|
43
51
|
- `cls`: pins the session to Sonnet instead of the default Opus. Use for routine work where Opus cost is not justified.
|
|
44
52
|
|
|
45
|
-
`clp
|
|
53
|
+
The `clp` family bakes in `--plugin-dir`. Use it outside the toolkit repository, where auto-discovery does not fire, and for any resume that needs the plugin.
|
|
46
54
|
|
|
47
55
|
- `clp`: session with the toolkit plugin loaded explicitly
|
|
48
|
-
- `clpc`: jumps straight into the most recent session for the current directory with the plugin loaded. The `clp` mirror of `clc
|
|
56
|
+
- `clpc`: jumps straight into the most recent session for the current directory with the plugin loaded. The `clp` mirror of `clc`, and the one resume that carries the plugin.
|
|
49
57
|
- `clps`: `clp` pinned to Sonnet
|
|
58
|
+
- `clpa`: opens the agent view with the plugin loaded, covering background sessions from every directory
|
|
59
|
+
- `clpac`: the same view filtered to background sessions started under the current directory
|
|
50
60
|
|
|
51
61
|
## When to use which
|
|
52
62
|
|
|
@@ -58,7 +68,9 @@ Use `cls` or `clps` to save Opus usage on routine sessions. Switch mid-session w
|
|
|
58
68
|
|
|
59
69
|
Use `clw <name>` for features that will take more than one session. A worktree isolates the branch, the transcripts, and the `/resume` history. See [Claude Code and git worktrees](../wiki/claude/claude-worktrees.md) for fan-out rules.
|
|
60
70
|
|
|
61
|
-
Use `clc` to resume the last session without a picker. Use `clr` when you have several sessions and need to pick by name or recency.
|
|
71
|
+
Use `clc` to resume the last session without a picker. Use `clr` when you have several sessions and need to pick by name or recency. Reach for `clpc` over `clc` wherever the resumed session needs the plugin, inside the toolkit repository as well as outside it, since neither resume re-runs discovery.
|
|
72
|
+
|
|
73
|
+
Use `clpa` to see what is running across every directory, and `clpac` when only the current project matters.
|
|
62
74
|
|
|
63
75
|
## Why not a function
|
|
64
76
|
|
package/package.json
CHANGED
|
@@ -36,12 +36,16 @@ alias cls='cl --model sonnet'
|
|
|
36
36
|
alias clp='claude --plugin-dir \$TOOLKIT/claude'
|
|
37
37
|
alias clpc='clp -c'
|
|
38
38
|
alias clps='clp --model sonnet'
|
|
39
|
+
alias clpa='clp agents'
|
|
40
|
+
alias clpac='clpa --cwd .'
|
|
39
41
|
$MARKER_CLOSE
|
|
40
42
|
EOF
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
install_aliases() {
|
|
44
46
|
log_step "Installing Claude Code shell aliases"
|
|
47
|
+
log_info "This step manages a marked alias block in $ZSHRC, the only file outside the repo this script writes."
|
|
48
|
+
log_info "Delete the block and its two marker comments to remove the aliases. Re-running never adds a second copy."
|
|
45
49
|
|
|
46
50
|
if [ -f "$ZSHRC" ] && grep -qF "$MARKER_OPEN" "$ZSHRC"; then
|
|
47
51
|
log_info "Aliases already present in $ZSHRC, skipping"
|
package/src/commands/design.ts
CHANGED
|
@@ -1,14 +1,80 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
2
|
-
import { resolve } from 'node:path'
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { basename, join, relative, resolve } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
createDesignAdapter,
|
|
6
|
+
DESIGN_INSTALL_DIR,
|
|
7
|
+
DESIGN_PROJECT_SUBDIR,
|
|
8
|
+
} from '@/design/adapter'
|
|
9
|
+
import { buildDesignCss } from '@/design/css'
|
|
5
10
|
import { renderDesignDoc } from '@/design/render'
|
|
6
|
-
import {
|
|
11
|
+
import { DESIGN_BASE_CSS, DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
|
|
12
|
+
import { PROJECT_ROOT } from '@/project-root'
|
|
13
|
+
import { creationRel } from '@/record-root'
|
|
14
|
+
import { recordStamp, runDomainSync } from '@/sync/engine'
|
|
15
|
+
import { resolveTarget } from '@/target'
|
|
16
|
+
import { intro, logAdd, logError, logInfo, outro, palette } from '@/ui'
|
|
7
17
|
|
|
8
18
|
export function register(program: Command): void {
|
|
9
19
|
const design = program
|
|
10
20
|
.command('design')
|
|
11
|
-
.description('Design system commands (render)')
|
|
21
|
+
.description('Design system commands (regen, css, render, install, sync)')
|
|
22
|
+
|
|
23
|
+
design
|
|
24
|
+
.command('regen')
|
|
25
|
+
.description(
|
|
26
|
+
'Rewrite .claude/DESIGN.md and the base stylesheet from src/design/tokens.ts',
|
|
27
|
+
)
|
|
28
|
+
.addHelpText(
|
|
29
|
+
'after',
|
|
30
|
+
[
|
|
31
|
+
'',
|
|
32
|
+
'Unlike install and sync, this runs against the toolkit checkout rather',
|
|
33
|
+
'than a target. It rewrites the record and the base stylesheet from the',
|
|
34
|
+
'token module and refuses where the record is absent, which is what the',
|
|
35
|
+
'CLI installed into a project as a dependency looks like.',
|
|
36
|
+
'',
|
|
37
|
+
].join('\n'),
|
|
38
|
+
)
|
|
39
|
+
.action(() => {
|
|
40
|
+
const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
|
|
41
|
+
|
|
42
|
+
// Both outputs resolve from `PROJECT_ROOT`, which is the installed
|
|
43
|
+
// package directory when the CLI runs out of a target's `node_modules`.
|
|
44
|
+
// The record is the one output that is already committed here and ships
|
|
45
|
+
// with no package, so its absence is what separates the two.
|
|
46
|
+
if (!existsSync(join(PROJECT_ROOT, DESIGN_DOCUMENT))) {
|
|
47
|
+
process.stderr.write(
|
|
48
|
+
`${GREY}┌${NC}\n${GREY}│${NC} ${RED}✗${NC} No ${DESIGN_DOCUMENT} at ${PROJECT_ROOT}. Regen runs in the toolkit checkout, not against a target.\n${GREY}└${NC}\n`,
|
|
49
|
+
)
|
|
50
|
+
process.exitCode = 1
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
process.stderr.write(
|
|
55
|
+
`${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}Regenerate design source${NC}\n`,
|
|
56
|
+
)
|
|
57
|
+
const result = regenDesign(PROJECT_ROOT)
|
|
58
|
+
for (const path of [result.documentPath, result.cssPath]) {
|
|
59
|
+
process.stderr.write(
|
|
60
|
+
`${GREY}│${NC} ${GREEN}✓${NC} ${relative(PROJECT_ROOT, path)}\n`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
process.stderr.write(`${GREY}└${NC}\n`)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
design
|
|
67
|
+
.command('css')
|
|
68
|
+
.description('Emit the design tokens and components as CSS on stdout')
|
|
69
|
+
.option(
|
|
70
|
+
'--no-components',
|
|
71
|
+
'Custom properties only, without the component rules',
|
|
72
|
+
)
|
|
73
|
+
.action((opts: { components: boolean }) => {
|
|
74
|
+
process.stdout.write(
|
|
75
|
+
buildDesignCss(undefined, { components: opts.components }),
|
|
76
|
+
)
|
|
77
|
+
})
|
|
12
78
|
|
|
13
79
|
design
|
|
14
80
|
.command('render')
|
|
@@ -38,4 +104,80 @@ export function register(program: Command): void {
|
|
|
38
104
|
`${GREY}│${NC} ${GREEN}✓${NC} ${result.htmlPath}\n${GREY}│${NC} ${GREEN}✓${NC} ${result.cssPath}\n${GREY}└${NC}\n`,
|
|
39
105
|
)
|
|
40
106
|
})
|
|
107
|
+
|
|
108
|
+
design
|
|
109
|
+
.command('install')
|
|
110
|
+
.description('Install the base stylesheet into a project')
|
|
111
|
+
.argument('[target]', 'Target directory', '.')
|
|
112
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
113
|
+
.addHelpText(
|
|
114
|
+
'after',
|
|
115
|
+
[
|
|
116
|
+
'',
|
|
117
|
+
'Installs one toolkit-owned file and creates no override. A project',
|
|
118
|
+
`overrides a value by writing ${join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)}/`,
|
|
119
|
+
'itself, which sync never touches. Nothing arrives on a project that',
|
|
120
|
+
'has not run this, so a design value reaches a target on an install',
|
|
121
|
+
'rather than on the next sync.',
|
|
122
|
+
'',
|
|
123
|
+
].join('\n'),
|
|
124
|
+
)
|
|
125
|
+
.action(async (target: string) => {
|
|
126
|
+
process.exitCode = await runInstall(target)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
design
|
|
130
|
+
.command('sync')
|
|
131
|
+
.description(
|
|
132
|
+
'Update the base stylesheet already installed under .claude/design/',
|
|
133
|
+
)
|
|
134
|
+
.argument('[target]', 'Target directory', '.')
|
|
135
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
136
|
+
.action(async (target: string) => {
|
|
137
|
+
process.exitCode = await runDomainSync(
|
|
138
|
+
createDesignAdapter(PROJECT_ROOT),
|
|
139
|
+
target,
|
|
140
|
+
{ protectedRoot: PROJECT_ROOT },
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Copies the base and stops. The override folder is deliberately not created:
|
|
147
|
+
* an empty override is a file the project did not ask for and did not write,
|
|
148
|
+
* the three-way merge already handles a missing side, and an empty file invites
|
|
149
|
+
* a target to fill it before it has an opinion.
|
|
150
|
+
*/
|
|
151
|
+
async function runInstall(target: string): Promise<number> {
|
|
152
|
+
intro('canon design install')
|
|
153
|
+
|
|
154
|
+
const resolved = resolveTarget(target, PROJECT_ROOT)
|
|
155
|
+
if (typeof resolved === 'number') {
|
|
156
|
+
outro()
|
|
157
|
+
return resolved
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const source = join(PROJECT_ROOT, DESIGN_BASE_CSS)
|
|
161
|
+
if (!existsSync(source)) {
|
|
162
|
+
logError(
|
|
163
|
+
`No base stylesheet at ${DESIGN_BASE_CSS}. Run canon design regen.`,
|
|
164
|
+
)
|
|
165
|
+
outro()
|
|
166
|
+
return 1
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const dir = join(resolved, DESIGN_INSTALL_DIR)
|
|
170
|
+
mkdirSync(dir, { recursive: true })
|
|
171
|
+
|
|
172
|
+
const dest = join(dir, basename(source))
|
|
173
|
+
copyFileSync(source, dest)
|
|
174
|
+
logAdd(relative(resolved, dest))
|
|
175
|
+
|
|
176
|
+
await recordStamp(createDesignAdapter(PROJECT_ROOT), resolved, new Date())
|
|
177
|
+
|
|
178
|
+
logInfo(
|
|
179
|
+
`Override a value in ${join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)}/, which sync leaves alone.`,
|
|
180
|
+
)
|
|
181
|
+
outro()
|
|
182
|
+
return 0
|
|
41
183
|
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
buildCheckReport,
|
|
8
8
|
type CheckReport,
|
|
9
9
|
hasDrift,
|
|
10
|
-
|
|
10
|
+
uncoveredDomains,
|
|
11
11
|
} from '@/sync/check'
|
|
12
12
|
import { createGitRunner, createPullRequestOpener, hasGh } from '@/sync/git'
|
|
13
13
|
import {
|
|
@@ -35,6 +35,7 @@ import { describeSkew } from '@/version/skew'
|
|
|
35
35
|
|
|
36
36
|
const SYNC_ARGS: Record<SyncDomain, readonly string[]> = {
|
|
37
37
|
governance: ['gov', 'sync'],
|
|
38
|
+
design: ['design', 'sync'],
|
|
38
39
|
claude: ['claude', 'sync'],
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -195,10 +196,7 @@ function renderCheck(report: CheckReport): void {
|
|
|
195
196
|
// naming it here repeats what that section already said under a second
|
|
196
197
|
// remedy, where a scanned domain nobody installed has no section at all and
|
|
197
198
|
// this line is the only place it appears.
|
|
198
|
-
const
|
|
199
|
-
const uncovered = SCANNED_DOMAINS.filter(
|
|
200
|
-
(domain) => !report.covers.includes(domain) && !unmigrated.includes(domain),
|
|
201
|
-
)
|
|
199
|
+
const uncovered = uncoveredDomains(report)
|
|
202
200
|
if (uncovered.length === 0) return
|
|
203
201
|
|
|
204
202
|
const { GREY, NC } = palette(process.stderr)
|
package/src/commands/tasks.ts
CHANGED
|
@@ -81,7 +81,9 @@ export function register(program: Command): void {
|
|
|
81
81
|
|
|
82
82
|
tasks
|
|
83
83
|
.command('archive')
|
|
84
|
-
.description(
|
|
84
|
+
.description(
|
|
85
|
+
'Move a shipped task and its plan out of the board and clear its ordering',
|
|
86
|
+
)
|
|
85
87
|
.argument('[task]', 'Task filename stem, as in v28.1-trigger-escalation')
|
|
86
88
|
.helpOption('-h, --help', 'Show this help message')
|
|
87
89
|
.option(
|
|
@@ -93,6 +95,10 @@ export function register(program: Command): void {
|
|
|
93
95
|
.addHelpText(
|
|
94
96
|
'after',
|
|
95
97
|
[
|
|
98
|
+
'',
|
|
99
|
+
'The task carries its plan with it when no other live task cites that',
|
|
100
|
+
'plan, and the archived task keeps a working Plan: pointer at the new',
|
|
101
|
+
'path. A plan several tasks share stays where it is.',
|
|
96
102
|
'',
|
|
97
103
|
'Exit codes:',
|
|
98
104
|
' 0 the task was archived',
|
|
@@ -812,6 +818,11 @@ function report(
|
|
|
812
818
|
logStep('Archived')
|
|
813
819
|
logRemove(relative(root, outcome.from))
|
|
814
820
|
logAdd(relative(root, outcome.to))
|
|
821
|
+
if (outcome.plan) {
|
|
822
|
+
logRemove(relative(root, outcome.plan.from))
|
|
823
|
+
logAdd(relative(root, outcome.plan.to))
|
|
824
|
+
logInfo('retargeted the Plan: line')
|
|
825
|
+
}
|
|
815
826
|
if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
|
|
816
827
|
if (outcome.indexRegenerated) logInfo('regenerated index.md')
|
|
817
828
|
outro()
|
|
@@ -839,5 +850,11 @@ function recordFor(
|
|
|
839
850
|
to: relative(root, outcome.to),
|
|
840
851
|
priorityRowRemoved: outcome.priorityRowRemoved,
|
|
841
852
|
indexRegenerated: outcome.indexRegenerated,
|
|
853
|
+
plan: outcome.plan
|
|
854
|
+
? {
|
|
855
|
+
from: relative(root, outcome.plan.from),
|
|
856
|
+
to: relative(root, outcome.plan.to),
|
|
857
|
+
}
|
|
858
|
+
: null,
|
|
842
859
|
}
|
|
843
860
|
}
|
package/src/commands/teach.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type Term,
|
|
17
17
|
type TermOutcome,
|
|
18
18
|
type WorkspaceSummary,
|
|
19
|
+
writeStylesheet,
|
|
19
20
|
} from '@/teach/workspace'
|
|
20
21
|
import {
|
|
21
22
|
intro,
|
|
@@ -276,6 +277,77 @@ export function register(program: Command): void {
|
|
|
276
277
|
.action(async (topic: string, opts: LessonCommandOptions) => {
|
|
277
278
|
process.exitCode = await runLesson(topic, opts)
|
|
278
279
|
})
|
|
280
|
+
|
|
281
|
+
teach
|
|
282
|
+
.command('stylesheet')
|
|
283
|
+
.description('Seed a workspace stylesheet from the design source')
|
|
284
|
+
.argument('<topic>', 'Workspace folder or topic, as in regular-expressions')
|
|
285
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
286
|
+
.option('--force', 'Rewrite a stylesheet the workspace already carries')
|
|
287
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
288
|
+
.option('--root <path>', 'Teach root, defaulting to the main worktree')
|
|
289
|
+
.addHelpText(
|
|
290
|
+
'after',
|
|
291
|
+
[
|
|
292
|
+
'',
|
|
293
|
+
'Exit codes:',
|
|
294
|
+
' 0 the workspace carries a stylesheet, written now or already there',
|
|
295
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
296
|
+
'',
|
|
297
|
+
'The file it writes carries the design tokens as custom properties and',
|
|
298
|
+
'the component rules built on them, so a workspace renders in the same',
|
|
299
|
+
'system every other surface does. Add lesson rules under the seed and',
|
|
300
|
+
'read a value through its property rather than restating the hex.',
|
|
301
|
+
'',
|
|
302
|
+
'An existing stylesheet is left alone, since a workspace adds to this',
|
|
303
|
+
'file as it goes. Pass --force to take the seed back over it.',
|
|
304
|
+
'',
|
|
305
|
+
'Examples:',
|
|
306
|
+
' canon teach stylesheet regular-expressions --json',
|
|
307
|
+
'',
|
|
308
|
+
].join('\n'),
|
|
309
|
+
)
|
|
310
|
+
.action(async (topic: string, opts: StylesheetCommandOptions) => {
|
|
311
|
+
process.exitCode = await runStylesheet(topic, opts)
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
interface StylesheetCommandOptions {
|
|
316
|
+
readonly force?: boolean
|
|
317
|
+
readonly json?: boolean
|
|
318
|
+
readonly root?: string
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function runStylesheet(
|
|
322
|
+
topic: string,
|
|
323
|
+
opts: StylesheetCommandOptions,
|
|
324
|
+
): Promise<number> {
|
|
325
|
+
const emitJson = opts.json ?? false
|
|
326
|
+
const root = await rootFor(opts.root)
|
|
327
|
+
const outcome = await writeStylesheet(root, topic, opts.force ?? false)
|
|
328
|
+
|
|
329
|
+
if (!outcome.ok) {
|
|
330
|
+
return reportRefusal('canon teach stylesheet', outcome, emitJson, root)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (emitJson) {
|
|
334
|
+
process.stdout.write(
|
|
335
|
+
`${JSON.stringify({
|
|
336
|
+
ok: true,
|
|
337
|
+
root,
|
|
338
|
+
slug: outcome.slug,
|
|
339
|
+
path: outcome.path,
|
|
340
|
+
written: outcome.written,
|
|
341
|
+
})}\n`,
|
|
342
|
+
)
|
|
343
|
+
return 0
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
intro('canon teach stylesheet')
|
|
347
|
+
logStep(outcome.written ? 'Written' : 'Already present, left alone')
|
|
348
|
+
logInfo(outcome.path)
|
|
349
|
+
outro()
|
|
350
|
+
return 0
|
|
279
351
|
}
|
|
280
352
|
|
|
281
353
|
function collect(value: string, previous: string[]): string[] {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { basename, join } from 'node:path'
|
|
2
|
+
import { DESIGN_BASE_CSS } from '@/design/regen'
|
|
3
|
+
import type { InstalledFile, SyncAdapter } from '@/sync/engine'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The channel a design value reaches a target on.
|
|
7
|
+
*
|
|
8
|
+
* Base plus a target-owned override, reconciled by the three-way merge the sync
|
|
9
|
+
* engine already implements. Overwrite was dropped before this was built and
|
|
10
|
+
* the reason is a measurement rather than an argument: a headless sync in
|
|
11
|
+
* another domain deleted a deploy job, replaced a screenshot harness, and
|
|
12
|
+
* reverted a shipped fix, because naming a stack was read as consent to lose an
|
|
13
|
+
* edit inside it.
|
|
14
|
+
*
|
|
15
|
+
* Layering is what bounds what the merge has to arbitrate. Nothing a project
|
|
16
|
+
* wrote lives in the base, so the base changes freely, and the merge shrinks to
|
|
17
|
+
* the properties a target actually overrode.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The folder a design install writes into, under the target's `.claude/`. */
|
|
21
|
+
export const DESIGN_INSTALL_DIR = join('.claude', 'design')
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Where a target's own values go. `projectSubdir` makes every file here
|
|
25
|
+
* project-authored by location rather than by the name inference, so an
|
|
26
|
+
* override named `base.css` is still the target's and never overwritten.
|
|
27
|
+
*
|
|
28
|
+
* It ships absent rather than empty. An empty override is a file a project did
|
|
29
|
+
* not ask for and did not write, the merge already handles a missing side, and
|
|
30
|
+
* an empty file invites a target to fill it before it has an opinion.
|
|
31
|
+
*/
|
|
32
|
+
export const DESIGN_PROJECT_SUBDIR = 'project'
|
|
33
|
+
|
|
34
|
+
export function designSourceDir(root: string): string {
|
|
35
|
+
return join(root, 'src', 'design')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createDesignAdapter(root: string): SyncAdapter {
|
|
39
|
+
const base = join(root, DESIGN_BASE_CSS)
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
banner: 'canon design sync',
|
|
43
|
+
label: 'design',
|
|
44
|
+
missingMessage:
|
|
45
|
+
"No design surface found in target. Run 'canon design install' first.",
|
|
46
|
+
unit: 'changes',
|
|
47
|
+
installPattern: '**/*.css',
|
|
48
|
+
installedRoot: (target: string) => join(target, DESIGN_INSTALL_DIR),
|
|
49
|
+
/**
|
|
50
|
+
* One shipped file, matched by name the way the gov adapter matches a rule,
|
|
51
|
+
* so a base that moved inside the toolkit still syncs into the place the
|
|
52
|
+
* target already holds it.
|
|
53
|
+
*/
|
|
54
|
+
locateSource: (file: InstalledFile) =>
|
|
55
|
+
basename(file.path) === basename(base) ? base : undefined,
|
|
56
|
+
projectSubdir: DESIGN_PROJECT_SUBDIR,
|
|
57
|
+
stamp: { domain: 'design', toolkitRoot: root },
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/* Generated by `canon design regen` from src/design/tokens.ts. Do not edit.
|
|
2
|
+
This is the base half of a design install. A target overrides a value in
|
|
3
|
+
.claude/design/project/, which the three-way merge leaves alone, so this
|
|
4
|
+
file changes freely and nothing a project wrote lives in it. */
|
|
5
|
+
|
|
6
|
+
:root {
|
|
7
|
+
--color-background: #191512;
|
|
8
|
+
--color-surface: #211c19;
|
|
9
|
+
--color-border: #2f2823;
|
|
10
|
+
--color-text: #f4efe9;
|
|
11
|
+
--color-text-body: #c9c0b7;
|
|
12
|
+
--color-text-secondary: #a79d94;
|
|
13
|
+
--color-muted: #948a81;
|
|
14
|
+
--color-accent: #e0724b;
|
|
15
|
+
--color-light-background: #faf7f2;
|
|
16
|
+
--color-light-surface: #f4efe6;
|
|
17
|
+
--color-light-text: #1a1815;
|
|
18
|
+
--color-light-muted: #726b62;
|
|
19
|
+
--color-light-accent: #a4471c;
|
|
20
|
+
--color-light-border: #e4dcd0;
|
|
21
|
+
--space-xs: 6px;
|
|
22
|
+
--space-sm: 12px;
|
|
23
|
+
--space-md: 18px;
|
|
24
|
+
--space-lg: 24px;
|
|
25
|
+
--space-xl: 30px;
|
|
26
|
+
--space-frame-top: 44px;
|
|
27
|
+
--space-frame-inline: 52px;
|
|
28
|
+
--space-frame-bottom: 38px;
|
|
29
|
+
--type-display-size: 34px;
|
|
30
|
+
--type-display-lh: 1.3;
|
|
31
|
+
--type-heading-size: 19px;
|
|
32
|
+
--type-heading-lh: 1.3;
|
|
33
|
+
--type-body-size: 16px;
|
|
34
|
+
--type-body-lh: 1.65;
|
|
35
|
+
--type-label-size: 12px;
|
|
36
|
+
--type-label-lh: 1.45;
|
|
37
|
+
--type-code-size: 14.5px;
|
|
38
|
+
--type-code-lh: 1.3;
|
|
39
|
+
--radius-frame: 12px;
|
|
40
|
+
--radius-panel: 10px;
|
|
41
|
+
--radius-action: 7px;
|
|
42
|
+
--radius-pill: 999px;
|
|
43
|
+
--radius-marker: 999px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* The record declares no light counterpart for text-body, text-secondary, so
|
|
47
|
+
a light-ground surface using one is reading a dark value. Declare the
|
|
48
|
+
counterpart in src/design/tokens.ts rather than overriding it here. */
|
|
49
|
+
[data-theme='light'] {
|
|
50
|
+
--color-background: var(--color-light-background);
|
|
51
|
+
--color-surface: var(--color-light-surface);
|
|
52
|
+
--color-text: var(--color-light-text);
|
|
53
|
+
--color-muted: var(--color-light-muted);
|
|
54
|
+
--color-accent: var(--color-light-accent);
|
|
55
|
+
--color-border: var(--color-light-border);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* status
|
|
59
|
+
A dot and a word, not a pill. A bordered uppercase chip is a second
|
|
60
|
+
vocabulary for a fact the dot already carries, so the marker is the only
|
|
61
|
+
status shape and `.is-done` is the only variant. */
|
|
62
|
+
|
|
63
|
+
.status {
|
|
64
|
+
display: inline-flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: 0.42rem;
|
|
67
|
+
font-size: 0.75rem;
|
|
68
|
+
font-weight: 500;
|
|
69
|
+
letter-spacing: 0;
|
|
70
|
+
text-transform: none;
|
|
71
|
+
color: var(--color-muted);
|
|
72
|
+
white-space: nowrap;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.status::before {
|
|
76
|
+
content: '';
|
|
77
|
+
width: 6px;
|
|
78
|
+
height: 6px;
|
|
79
|
+
flex: none;
|
|
80
|
+
border-radius: var(--radius-marker);
|
|
81
|
+
background: var(--color-border);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.status.is-done::before {
|
|
85
|
+
background: var(--color-accent);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* scrollbar
|
|
89
|
+
Every scrolling region takes the same bar, the page included. Scoping it
|
|
90
|
+
to one component leaves its neighbors on the browser default, which is
|
|
91
|
+
what reads as two designs on one page. `scrollbar-color` covers Firefox
|
|
92
|
+
and the `::-webkit-` rules cover the rest, both from the same two tokens
|
|
93
|
+
so the two engines cannot drift apart. */
|
|
94
|
+
|
|
95
|
+
* {
|
|
96
|
+
scrollbar-width: thin;
|
|
97
|
+
scrollbar-color: var(--color-border) transparent;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
*::-webkit-scrollbar {
|
|
101
|
+
width: 10px;
|
|
102
|
+
height: 10px;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
*::-webkit-scrollbar-track {
|
|
106
|
+
background: transparent;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
*::-webkit-scrollbar-thumb {
|
|
110
|
+
border-radius: var(--radius-marker);
|
|
111
|
+
background: var(--color-border);
|
|
112
|
+
/* Inset by painting a border in the page color, which is what keeps the
|
|
113
|
+
thumb off the edges without a second element. */
|
|
114
|
+
border: 3px solid var(--color-background);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
*::-webkit-scrollbar-thumb:hover {
|
|
118
|
+
background: var(--color-muted);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
*::-webkit-scrollbar-corner {
|
|
122
|
+
background: transparent;
|
|
123
|
+
}
|