@erclx/canon 4.8.1 → 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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-teach/SKILL.md +11 -1
- package/docs/agents/commands.md +7 -2
- package/docs/agents/install-and-sync.md +23 -1
- package/docs/agents/teach.md +2 -0
- package/docs/visual-design-workflow.md +2 -0
- package/package.json +1 -1
- package/src/commands/design.ts +147 -5
- package/src/commands/sync.ts +3 -5
- 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/teach/workspace.ts +57 -0
|
@@ -95,12 +95,22 @@ canon teach lesson <topic> --json \
|
|
|
95
95
|
It writes nothing and reports four things:
|
|
96
96
|
|
|
97
97
|
- `lesson`, the numbered path the lesson takes. Write it there.
|
|
98
|
-
- `stylesheet` with `stylesheetExists` and `stylesheetHref`.
|
|
98
|
+
- `stylesheet` with `stylesheetExists` and `stylesheetHref`. Link it at `stylesheetHref` and seed it through the verb below when `stylesheetExists` is false, which is the first lesson in the workspace. Every lesson after that links the file already on disk and adds to it rather than replacing it, since overwriting discards what the last lesson put there.
|
|
99
99
|
- `success`, the mission's success lines, carried here so Step 5 needs no second read.
|
|
100
100
|
- `quiz`, one entry per question, carrying `order` and `answer`.
|
|
101
101
|
|
|
102
102
|
Write the correct option first, then present the options in the order `order` reports, reading it as authored indices where `0` is the correct one. Take the order as given. Position drawn here rather than chosen is the whole reason the verb exists, and a lesson that reorders on its own judgment puts the answer back in the first slot.
|
|
103
103
|
|
|
104
|
+
Seed the stylesheet through the verb rather than authoring a palette, on the first lesson in a workspace:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
canon teach stylesheet <topic> --json
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
It writes the design tokens as custom properties and the components built on them, from the one source every other rendered surface reads. Add lesson rules under the seed and reach a value through its property rather than restating the hex, which is what let each workspace fork the palette from every other. It refuses to overwrite, so running it again on a workspace that has grown its own rules is safe and reports `written` as false.
|
|
111
|
+
|
|
112
|
+
Report it rather than proceeding silently when the verb does not resolve, which is an installed CLI predating it. Do not fall back to writing a palette by hand.
|
|
113
|
+
|
|
104
114
|
Add every term the lesson defines to `GLOSSARY.md` through the verb, which places the entries alphabetically in the shape the standard fixes:
|
|
105
115
|
|
|
106
116
|
```bash
|
package/docs/agents/commands.md
CHANGED
|
@@ -22,6 +22,10 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
22
22
|
| `canon indexes regen` | Regenerate `index.md` files from sibling frontmatter |
|
|
23
23
|
| `canon docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name) |
|
|
24
24
|
| `canon design render` | Render `.claude/DESIGN.md` tokens to HTML and CSS |
|
|
25
|
+
| `canon design regen` | Rewrite this repository's `.claude/DESIGN.md` and `src/design/base.css` from `src/design/tokens.ts` |
|
|
26
|
+
| `canon design css` | Emit the design tokens and components as CSS on stdout (`--no-components` for properties alone) |
|
|
27
|
+
| `canon design install` | Install the base stylesheet into a project at `.claude/design/base.css` |
|
|
28
|
+
| `canon design sync` | Update a base stylesheet already installed under `.claude/design/` |
|
|
25
29
|
| `canon slides render` | Render a `.claude/SLIDES.md` source into a PowerPoint deck, reporting any unrecognized layout name on stderr |
|
|
26
30
|
| `canon slides list` | List the available slide layouts (`--json` for the catalog) |
|
|
27
31
|
| `canon feedback` | Write toolkit feedback from stdin to `.canon/review/feedback/`, or open a GitHub issue with `--github` |
|
|
@@ -36,6 +40,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
36
40
|
| `canon teach open` | Open a workspace at the next ordinal with its mission, resources, and glossary files (`--json`) |
|
|
37
41
|
| `canon teach resource` | Record sources and leads in a workspace, repeating `--read` or `--lead` as `<title>=<url>` (`--json`) |
|
|
38
42
|
| `canon teach glossary` | Add terms to a workspace glossary alphabetically, repeating `--term <term>=<definition>` (`--json`) |
|
|
43
|
+
| `canon teach stylesheet` | Seed a workspace stylesheet from the design source, leaving an existing one alone without `--force` (`--json`) |
|
|
39
44
|
| `canon records validate` | Report a session record or a standard against the standard governing it, per kind (`--json`) |
|
|
40
45
|
| `canon records migrate` | Rewrite the records a validate finding names a recoverable transform for (`--write`, `--json`) |
|
|
41
46
|
| `canon records size` | Report what each record folder holds and how much of it is recent, heaviest first (`--json`) |
|
|
@@ -112,11 +117,11 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
|
|
|
112
117
|
| `demo` | `compile`, `run` |
|
|
113
118
|
| `inventory` | `run` |
|
|
114
119
|
| `wiki` | `init` |
|
|
115
|
-
| `design` | `render`
|
|
120
|
+
| `design` | `regen`, `css`, `render`, `install`, `sync` |
|
|
116
121
|
| `slides` | `render`, `list` |
|
|
117
122
|
| `tasks` | `archive`, `validate` |
|
|
118
123
|
| `intake` | `list`, `answer` |
|
|
119
|
-
| `teach` | `list`, `open`, `resource`, `glossary`
|
|
124
|
+
| `teach` | `list`, `open`, `resource`, `glossary`, `lesson`, `stylesheet` |
|
|
120
125
|
| `comments` | `scan` |
|
|
121
126
|
| `context` | `audit` |
|
|
122
127
|
| `markdown` | `audit` |
|
|
@@ -27,7 +27,29 @@ number bands behind that placement, where `900-999` is the range a
|
|
|
27
27
|
project-authored rule takes and everything below it belongs to the toolkit.
|
|
28
28
|
|
|
29
29
|
`canon sync --check` does not report an orphaned entry. It skips every one, so
|
|
30
|
-
the destination reaches `canon gov sync` alone
|
|
30
|
+
the destination reaches `canon gov sync` alone among the two per-file domain
|
|
31
|
+
syncs, since design's own orphans are the target's overrides and are meant to
|
|
32
|
+
stay where they are.
|
|
33
|
+
|
|
34
|
+
`canon design install` copies one toolkit-owned file to
|
|
35
|
+
`.claude/design/base.css` and creates no override. A project overrides a value
|
|
36
|
+
by writing `.claude/design/project/` itself, which `canon design sync` never
|
|
37
|
+
touches, because that subfolder is project-authored by location the way
|
|
38
|
+
`.claude/rules/project/` is. An override named exactly like the shipped file is
|
|
39
|
+
still the project's.
|
|
40
|
+
|
|
41
|
+
The override ships absent rather than empty. An empty file is one the project
|
|
42
|
+
did not ask for and did not write, the reconciliation already handles a missing
|
|
43
|
+
side, and an empty override invites a target to fill it before it has an
|
|
44
|
+
opinion.
|
|
45
|
+
|
|
46
|
+
Nothing arrives on a project that has not run `canon design install`. The
|
|
47
|
+
domain is detected by that folder existing, so `canon sync` skips it entirely on
|
|
48
|
+
a target that never installed it, and the unstamped line at the end of
|
|
49
|
+
`canon sync --check` stays quiet about it for the same reason. Governance is
|
|
50
|
+
named there when it is absent and design is not, because a managed project
|
|
51
|
+
without governance has yet to install what every project carries, while one
|
|
52
|
+
without design chose that.
|
|
31
53
|
|
|
32
54
|
When the target's install recorded a stack, `canon gov sync` also reports a
|
|
33
55
|
rule that stack lists and `.claude/rules/` does not hold, as a `missing` entry
|
package/docs/agents/teach.md
CHANGED
|
@@ -125,6 +125,8 @@ It reports four things. `lesson` is the numbered path the lesson takes, derived
|
|
|
125
125
|
|
|
126
126
|
The stylesheet is reported rather than written. A verb that wrote it on every lesson would discard whatever the last lesson added, and the second lesson in a workspace is the one that needs the file the first one left.
|
|
127
127
|
|
|
128
|
+
`canon teach stylesheet <topic>` is what writes it, seeded from the design source so a workspace renders in the system every other surface does. It refuses to overwrite, reporting `written` as false where the workspace already carries one, and `--force` takes the seed back over it. Each workspace used to carry a hand-authored palette, which is how the course styling forked once per workspace, so a lesson adds its own rules under the seed and reaches a value through its custom property rather than restating the hex.
|
|
129
|
+
|
|
128
130
|
Each `quiz` entry carries `order`, the authored option indices in presentation order where index `0` is the correct answer, and `answer`, the one-based position that answer lands in. Both travel together because a caller deriving the position itself is a caller that can derive it wrongly.
|
|
129
131
|
|
|
130
132
|
The order is drawn here rather than instructed, and that is the point of the verb. An author told to vary the position still varies it by judgment, and the judgment settles on the first slot, which is the defect this design departs from. The draw is uniform over the options, so the position carries no information about which answer is correct.
|
|
@@ -24,6 +24,8 @@ The toolkit seed in `tooling/claude/seeds/.claude/DESIGN.md` ships a token-table
|
|
|
24
24
|
|
|
25
25
|
The `canon:claude-design-extract` skill drafts the file, sourcing tokens from a project's existing prose and CLI UI surfaces, or proposing them from `.claude/REQUIREMENTS.md` and a `## Personality` paragraph when no UI code exists yet. `canon design render` writes an HTML plus CSS preview to `.canon/review/design/` for eyeballing the current system without leaving Claude Code. See `.claude/context/design.md`.
|
|
26
26
|
|
|
27
|
+
A project wanting the toolkit's own values rather than its own runs `canon design install`, which copies one stylesheet to `.claude/design/base.css` carrying the token set as custom properties and two components built on them. That file is toolkit-owned and `canon design sync` refreshes it, so a project overrides a value in `.claude/design/project/` instead, which sync never touches. Nothing arrives without that install, and the two channels are independent: a record drafted by the extract skill is the project's own, and the installed stylesheet is the toolkit's.
|
|
28
|
+
|
|
27
29
|
A cell no source anchors ends in `? verify`, and the preview shows that marker beside the value rather than folding it in, so a swatch and a font sample stay built from the value alone. A confidence line above the sections names how many cells are anchored against how many are tagged, which is what tells a reader whether they are looking at a record of the code or a proposal about it. It reads the columns a source could anchor and leaves out the row names, so the ratio is not diluted by cells no tag could ever reach. The proposal path tags nearly all of them, so that count reads low on day one by design.
|
|
28
30
|
|
|
29
31
|
### Tools
|
package/package.json
CHANGED
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/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
|
+
}
|