@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.
Files changed (41) hide show
  1. package/README.md +55 -41
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/claude-docs/REQUIREMENT.md +2 -2
  4. package/claude/skills/claude-docs/SKILL.md +7 -36
  5. package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +13 -8
  6. package/claude/skills/claude-tasks/SKILL.md +4 -5
  7. package/claude/skills/claude-teach/SKILL.md +11 -1
  8. package/claude/skills/claude-worker/SKILL.md +2 -2
  9. package/docs/agents/commands.md +8 -3
  10. package/docs/agents/install-and-sync.md +23 -1
  11. package/docs/agents/overview.md +3 -3
  12. package/docs/agents/tasks.md +10 -4
  13. package/docs/agents/teach.md +2 -0
  14. package/docs/ai-workflow.md +2 -4
  15. package/docs/visual-design-workflow.md +2 -0
  16. package/docs/zshrc-aliases.md +19 -7
  17. package/package.json +1 -1
  18. package/scripts/core/bootstrap.sh +4 -0
  19. package/src/commands/design.ts +147 -5
  20. package/src/commands/sync.ts +3 -5
  21. package/src/commands/tasks.ts +18 -1
  22. package/src/commands/teach.ts +72 -0
  23. package/src/design/adapter.ts +59 -0
  24. package/src/design/base.css +123 -0
  25. package/src/design/components.ts +118 -0
  26. package/src/design/contrast.ts +81 -0
  27. package/src/design/css.ts +142 -0
  28. package/src/design/document.ts +143 -0
  29. package/src/design/regen.ts +56 -0
  30. package/src/design/render.ts +43 -9
  31. package/src/design/tokens.ts +315 -0
  32. package/src/gate/stages.ts +28 -0
  33. package/src/slides/styles.ts +42 -15
  34. package/src/sync/check.ts +41 -0
  35. package/src/sync/engine.ts +28 -7
  36. package/src/sync/stamp.ts +5 -4
  37. package/src/sync/target.ts +4 -1
  38. package/src/tasks/archive.ts +91 -20
  39. package/src/tasks/validate.ts +76 -0
  40. package/src/teach/workspace.ts +57 -0
  41. package/standards/tasks.md +4 -4
@@ -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 `clps` inherit `--plugin-dir` through `clp`. `$TOOLKIT` expands at invocation time, so updating the variable and re-sourcing reroutes all `clp` calls without touching the alias definitions.
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` through `cls` carry no explicit plugin dir. 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.
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`, `clpc`, and `clps` bake in `--plugin-dir`. Use them outside the toolkit repository, where auto-discovery does not fire.
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. Outside the toolkit repo, `clpc` is the same shortcut as `clc` with the plugin loaded.
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.8.0",
4
+ "version": "4.9.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -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"
@@ -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 { creationRel } from '@/record-root'
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 { palette } from '@/ui'
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
  }
@@ -7,7 +7,7 @@ import {
7
7
  buildCheckReport,
8
8
  type CheckReport,
9
9
  hasDrift,
10
- SCANNED_DOMAINS,
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 unmigrated = report.unmigrated.map((entry) => entry.domain)
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)
@@ -81,7 +81,9 @@ export function register(program: Command): void {
81
81
 
82
82
  tasks
83
83
  .command('archive')
84
- .description('Move a shipped task out of the board and clear its ordering')
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
  }
@@ -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
+ }