@erclx/canon 4.8.1 → 4.9.1
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 +10 -3
- package/docs/agents/install-and-sync.md +23 -1
- package/docs/agents/teach.md +2 -0
- package/docs/target-projects.md +6 -4
- package/docs/visual-design-workflow.md +2 -0
- package/package.json +1 -1
- package/src/commands/design.ts +147 -5
- package/src/commands/migrate.ts +27 -4
- 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/migrate/records.ts +71 -1
- 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
package/src/sync/engine.ts
CHANGED
|
@@ -139,6 +139,8 @@ export interface SyncAdapter {
|
|
|
139
139
|
collectMissing?(target: string): RetiredSurface[]
|
|
140
140
|
/** Dropped from the walk, so neither matching nor orphaned. */
|
|
141
141
|
isExcluded?(file: InstalledFile): boolean
|
|
142
|
+
/** Glob the walk lists. Defaults to `DEFAULT_INSTALL_PATTERN`. */
|
|
143
|
+
readonly installPattern?: string
|
|
142
144
|
/**
|
|
143
145
|
* Top-level folder under `installedRoot` that is project-authored by
|
|
144
146
|
* location rather than by the name inference `locateSource` runs.
|
|
@@ -158,15 +160,26 @@ export interface SyncAdapter {
|
|
|
158
160
|
}
|
|
159
161
|
|
|
160
162
|
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* `.claude/`, so a nested dot-directory would silently drop out of the walk.
|
|
163
|
+
* What a domain installs, when it installs something other than markdown.
|
|
164
|
+
* Design ships a stylesheet, and every other domain ships prose.
|
|
164
165
|
*/
|
|
165
|
-
export
|
|
166
|
+
export const DEFAULT_INSTALL_PATTERN = '**/*.md'
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Lists installed files matching the domain's pattern, dotfiles included.
|
|
170
|
+
* `Bun.Glob` skips entries beginning with a dot unless `dot` is set, and every
|
|
171
|
+
* domain installs under `.claude/`, so a nested dot-directory would silently
|
|
172
|
+
* drop out of the walk.
|
|
173
|
+
*/
|
|
174
|
+
export function listInstalled(
|
|
175
|
+
root: string,
|
|
176
|
+
target: string,
|
|
177
|
+
pattern: string = DEFAULT_INSTALL_PATTERN,
|
|
178
|
+
): InstalledFile[] {
|
|
166
179
|
if (!existsSync(root)) return []
|
|
167
180
|
|
|
168
181
|
return [
|
|
169
|
-
...new Bun.Glob(
|
|
182
|
+
...new Bun.Glob(pattern).scanSync({
|
|
170
183
|
cwd: root,
|
|
171
184
|
onlyFiles: true,
|
|
172
185
|
dot: true,
|
|
@@ -192,7 +205,11 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
|
192
205
|
const hashes = stampedHashes(readStamp(target), adapter.stamp?.domain)
|
|
193
206
|
const walked = new Set<string>()
|
|
194
207
|
|
|
195
|
-
for (const file of listInstalled(
|
|
208
|
+
for (const file of listInstalled(
|
|
209
|
+
adapter.installedRoot(target),
|
|
210
|
+
target,
|
|
211
|
+
adapter.installPattern,
|
|
212
|
+
)) {
|
|
196
213
|
if (adapter.isExcluded?.(file) === true) continue
|
|
197
214
|
walked.add(toStampKey(file.rel))
|
|
198
215
|
|
|
@@ -562,7 +579,11 @@ export async function recordStamp(
|
|
|
562
579
|
|
|
563
580
|
const hashes: Record<string, string> = {}
|
|
564
581
|
|
|
565
|
-
for (const file of listInstalled(
|
|
582
|
+
for (const file of listInstalled(
|
|
583
|
+
adapter.installedRoot(target),
|
|
584
|
+
target,
|
|
585
|
+
adapter.installPattern,
|
|
586
|
+
)) {
|
|
566
587
|
if (adapter.isExcluded?.(file) === true) continue
|
|
567
588
|
if (isProjectAuthored(adapter, file)) continue
|
|
568
589
|
|
package/src/sync/stamp.ts
CHANGED
|
@@ -6,16 +6,17 @@ import { execa } from 'execa'
|
|
|
6
6
|
import { recordTarget } from '@/targets/registry'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Domains the stamp can record. Governance
|
|
10
|
-
* sync engine. Tooling runs its own inject and manifest
|
|
11
|
-
* records the stack chain it resolved instead and carries no
|
|
9
|
+
* Domains the stamp can record. Governance and design both attribute file by
|
|
10
|
+
* file through the sync engine. Tooling runs its own inject and manifest
|
|
11
|
+
* machinery, so it records the stack chain it resolved instead and carries no
|
|
12
|
+
* file hashes.
|
|
12
13
|
*
|
|
13
14
|
* A stamp written before the standards or snippets install channel closed
|
|
14
15
|
* still carries a `standards` or `snippets` record. `isStamp` ignores the key
|
|
15
16
|
* and `sortDomains` drops it on the next write, so the target loses a domain
|
|
16
17
|
* nothing can refresh rather than losing the whole file.
|
|
17
18
|
*/
|
|
18
|
-
export const STAMP_DOMAINS = ['governance', 'tooling'] as const
|
|
19
|
+
export const STAMP_DOMAINS = ['governance', 'design', 'tooling'] as const
|
|
19
20
|
|
|
20
21
|
export type StampDomain = (typeof STAMP_DOMAINS)[number]
|
|
21
22
|
|
package/src/sync/target.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import { DESIGN_INSTALL_DIR } from '@/design/adapter'
|
|
3
4
|
import { isDirectory } from '@/target'
|
|
4
5
|
|
|
5
|
-
export const SYNC_DOMAINS = ['governance', 'claude'] as const
|
|
6
|
+
export const SYNC_DOMAINS = ['governance', 'design', 'claude'] as const
|
|
6
7
|
|
|
7
8
|
export type SyncDomain = (typeof SYNC_DOMAINS)[number]
|
|
8
9
|
|
|
@@ -13,6 +14,7 @@ export interface DomainState {
|
|
|
13
14
|
|
|
14
15
|
const DOMAIN_MARKERS: Record<SyncDomain, string> = {
|
|
15
16
|
governance: join('.claude', 'rules'),
|
|
17
|
+
design: DESIGN_INSTALL_DIR,
|
|
16
18
|
claude: '.claude',
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -24,6 +26,7 @@ const DOMAIN_MARKERS: Record<SyncDomain, string> = {
|
|
|
24
26
|
*/
|
|
25
27
|
const DOMAIN_PATHS: Record<SyncDomain, readonly string[]> = {
|
|
26
28
|
governance: ['.claude/rules/', '.claude/GOV.md'],
|
|
29
|
+
design: ['.claude/design/'],
|
|
27
30
|
claude: ['.gitignore'],
|
|
28
31
|
}
|
|
29
32
|
|
package/src/teach/workspace.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { join, relative } from 'node:path'
|
|
4
|
+
import { buildDesignCss } from '@/design/css'
|
|
4
5
|
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
5
6
|
import { type BodyLine, bodyLines } from '@/markdown/scan'
|
|
6
7
|
import { recordDir } from '@/record-root'
|
|
@@ -841,3 +842,59 @@ export async function defineTerms(
|
|
|
841
842
|
|
|
842
843
|
return { ok: true, slug: found.slug, path, defined: terms }
|
|
843
844
|
}
|
|
845
|
+
|
|
846
|
+
export interface StylesheetWritten {
|
|
847
|
+
readonly ok: true
|
|
848
|
+
readonly slug: string
|
|
849
|
+
/** Relative to the root, so a caller prints a path a reader can open. */
|
|
850
|
+
readonly path: string
|
|
851
|
+
/** False when the workspace already held one and this call left it alone. */
|
|
852
|
+
readonly written: boolean
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export type StylesheetOutcome = StylesheetWritten | TeachRefused
|
|
856
|
+
|
|
857
|
+
const STYLESHEET_BANNER = [
|
|
858
|
+
'Seeded by `canon teach stylesheet` from the design source in',
|
|
859
|
+
'src/design/tokens.ts. The tokens and the two components below are the',
|
|
860
|
+
'system this workspace renders in. Add lesson rules under them and read a',
|
|
861
|
+
'value through its custom property rather than restating the hex, which is',
|
|
862
|
+
'what let one workspace fork the palette from every other.',
|
|
863
|
+
].join('\n ')
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Writes a workspace's one stylesheet from the design source.
|
|
867
|
+
*
|
|
868
|
+
* Every workspace used to carry a hand-authored copy, which is how the course
|
|
869
|
+
* palette forked once per workspace. The name is fixed at `TEACH_STYLESHEET`
|
|
870
|
+
* and the folder at `TEACH_ASSETS` for the same reason a second lesson has to
|
|
871
|
+
* reach the file the first one wrote, and this is what puts the values in it.
|
|
872
|
+
*
|
|
873
|
+
* An existing stylesheet is left alone rather than replaced. A workspace adds
|
|
874
|
+
* lesson rules to this file as it goes, so overwriting would discard them, and
|
|
875
|
+
* `--force` is the caller saying it wants the seed back.
|
|
876
|
+
*/
|
|
877
|
+
export async function writeStylesheet(
|
|
878
|
+
root: string,
|
|
879
|
+
selector: string,
|
|
880
|
+
force = false,
|
|
881
|
+
): Promise<StylesheetOutcome> {
|
|
882
|
+
const found = await readWorkspace(root, selector)
|
|
883
|
+
if (!found.ok) return found
|
|
884
|
+
|
|
885
|
+
const workspace = found.workspace
|
|
886
|
+
const rel = join(workspace.path, TEACH_ASSETS, TEACH_STYLESHEET)
|
|
887
|
+
const path = join(root, rel)
|
|
888
|
+
|
|
889
|
+
if (existsSync(path) && !force) {
|
|
890
|
+
return { ok: true, slug: workspace.slug, path: rel, written: false }
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
await mkdir(join(root, workspace.path, TEACH_ASSETS), { recursive: true })
|
|
894
|
+
await writeFile(
|
|
895
|
+
path,
|
|
896
|
+
buildDesignCss(undefined, { banner: STYLESHEET_BANNER }),
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
return { ok: true, slug: workspace.slug, path: rel, written: true }
|
|
900
|
+
}
|