@augurworks/augur 0.15.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/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- package/templates/space/publish.yml +91 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* augur canon — copy the design system out of a product you already have a login for.
|
|
4
|
+
*
|
|
5
|
+
* augur canon start <url> make the working folder and the brief for your agent
|
|
6
|
+
* augur canon collect <url> read a PUBLIC page over HTTP into observation.json
|
|
7
|
+
* augur canon snippet print the collector to paste into your own browser
|
|
8
|
+
* augur canon grade grade canon.json — what is still unanswered, and why
|
|
9
|
+
* augur canon apply write it into this workspace's design system
|
|
10
|
+
*
|
|
11
|
+
* WHAT THIS IS FOR. A team's fourth week is faster than its first only if the workspace
|
|
12
|
+
* accumulates a canon — named, pullable building blocks that every new screen is made
|
|
13
|
+
* of. Building one by hand takes weeks. Every team that would benefit already HAS one:
|
|
14
|
+
* it is their live product. This turns "copy the design system from this URL" into a
|
|
15
|
+
* flow with a working folder, a schema, and a grader.
|
|
16
|
+
*
|
|
17
|
+
* THE ENGINE DOES NOT THINK, AND THAT IS THE DESIGN. Deciding which of a product's
|
|
18
|
+
* eleven greys is its hairline is judgement. Augur ships the harness — the working
|
|
19
|
+
* folder, the token roles, the evidence collector, the grader, the emitter — and your
|
|
20
|
+
* own agent, with your own account at whatever provider you like, does the judging.
|
|
21
|
+
* There is no model call anywhere in this repository, no API key, no endpoint, and this
|
|
22
|
+
* command adds no dependency. `test/canon-no-inference.test.mjs` holds that shut.
|
|
23
|
+
*
|
|
24
|
+
* WHY A URL AND NOT A REPO. Extraction from a codebase already exists elsewhere and is
|
|
25
|
+
* the wrong door for the person who needs this: a PM or a designer holds a login to the
|
|
26
|
+
* product, not commit rights to it. So the collector runs in the browser they are
|
|
27
|
+
* already signed in to, and nothing it produces ever leaves their machine.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import fs from "node:fs";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
import { observe, mergeObservations } from "../src/canon/observe.mjs";
|
|
34
|
+
import {
|
|
35
|
+
ROLES, ROLE_BY_NAME, OBSERVED_ROLES, COMPONENT_TYPES, CANON_VERSION,
|
|
36
|
+
blankCanon, validateCanon, parseTokensCss,
|
|
37
|
+
} from "../src/canon/schema.mjs";
|
|
38
|
+
import { planApply, classNamesIn } from "../src/canon/emit.mjs";
|
|
39
|
+
|
|
40
|
+
const ENGINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
41
|
+
const argv = process.argv.slice(2);
|
|
42
|
+
const sub = argv[0];
|
|
43
|
+
const flag = (name, fallback = null) => {
|
|
44
|
+
const i = argv.indexOf(`--${name}`);
|
|
45
|
+
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : (i >= 0 ? true : fallback);
|
|
46
|
+
};
|
|
47
|
+
const has = (name) => argv.includes(`--${name}`);
|
|
48
|
+
const positional = argv.slice(1).filter((a, i, all) => !a.startsWith("--") && !(all[i - 1] || "").startsWith("--"));
|
|
49
|
+
|
|
50
|
+
const say = (...a) => console.log(...a);
|
|
51
|
+
const die = (msg, code = 1) => { console.error(`augur canon: ${msg}`); process.exit(code); };
|
|
52
|
+
|
|
53
|
+
const USAGE = `usage: augur canon <start|collect|snippet|grade|apply> [options]
|
|
54
|
+
|
|
55
|
+
start <url> [--dir <folder>] [--prefix <name>]
|
|
56
|
+
Make the working folder: the brief your agent reads, the schema, the
|
|
57
|
+
skeleton answer, and the browser collector. Prints what to do next.
|
|
58
|
+
|
|
59
|
+
collect <url> [--dir <folder>] [--out <file>]
|
|
60
|
+
Read a PUBLIC page and its stylesheets over HTTP into evidence. For a
|
|
61
|
+
product behind a login use "snippet" instead — that is the real door.
|
|
62
|
+
collect --html <file.html> [--css <file.css>]...
|
|
63
|
+
Same, from files you already have.
|
|
64
|
+
collect --merge
|
|
65
|
+
Fold every observation-*.json in the folder into one. Do this after
|
|
66
|
+
collecting three or four different screens.
|
|
67
|
+
|
|
68
|
+
snippet [--dir <folder>]
|
|
69
|
+
Write and print the collector to paste into your own browser's console,
|
|
70
|
+
on a page of your product, while you are signed in.
|
|
71
|
+
|
|
72
|
+
grade [--dir <folder>] [--strict] [--space <workspace>]
|
|
73
|
+
Grade the answer. With --space, grade the design system a workspace is
|
|
74
|
+
actually carrying instead.
|
|
75
|
+
|
|
76
|
+
apply [--dir <folder>] [--space <workspace>] [--dry-run]
|
|
77
|
+
Write the canon into the workspace's design system.`;
|
|
78
|
+
|
|
79
|
+
if (!sub || has("help") || sub === "help") { say(USAGE); process.exit(sub ? 0 : 1); }
|
|
80
|
+
|
|
81
|
+
const dir = path.resolve(String(flag("dir", "canon")));
|
|
82
|
+
const readJson = (p, fallback = null) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return fallback; } };
|
|
83
|
+
const write = (p, text) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, text); };
|
|
84
|
+
const rel = (p) => path.relative(process.cwd(), p) || ".";
|
|
85
|
+
|
|
86
|
+
/* ── The workspace this canon is for ───────────────────────────────────────── */
|
|
87
|
+
|
|
88
|
+
/** Walk up for a `space.json`, the same marker `discoverSpaces()` looks for. */
|
|
89
|
+
function findWorkspace(from) {
|
|
90
|
+
let d = path.resolve(from || process.cwd());
|
|
91
|
+
for (let i = 0; i < 8; i++) {
|
|
92
|
+
if (fs.existsSync(path.join(d, "space.json"))) return d;
|
|
93
|
+
const up = path.dirname(d);
|
|
94
|
+
if (up === d) break;
|
|
95
|
+
d = up;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The workspace's UI skill, detected exactly as build.js detects it. */
|
|
101
|
+
function findSkill(root) {
|
|
102
|
+
const space = readJson(path.join(root, "space.json"), {}) || {};
|
|
103
|
+
const declared = space.designSystem && space.designSystem.skill;
|
|
104
|
+
const skills = path.join(root, "skills");
|
|
105
|
+
let dirName = declared || null;
|
|
106
|
+
if (!dirName && fs.existsSync(skills)) {
|
|
107
|
+
dirName = fs.readdirSync(skills)
|
|
108
|
+
.filter((d) => d.endsWith("-ui") && fs.existsSync(path.join(skills, d, `${d}.css`)))
|
|
109
|
+
.sort()[0] || null;
|
|
110
|
+
}
|
|
111
|
+
if (!dirName) return null;
|
|
112
|
+
return { dirName, prefix: dirName.replace(/-ui$/, ""), abs: path.join(skills, dirName), relPath: `skills/${dirName}` };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ── start ─────────────────────────────────────────────────────────────────── */
|
|
116
|
+
|
|
117
|
+
async function cmdStart() {
|
|
118
|
+
const url = positional[0];
|
|
119
|
+
if (!url) die("start needs the URL of a page in your product:\n augur canon start https://app.example.com/dashboard");
|
|
120
|
+
const ws = findWorkspace(process.cwd());
|
|
121
|
+
const skill = ws ? findSkill(ws) : null;
|
|
122
|
+
const prefix = String(flag("prefix", skill?.prefix || guessPrefix(url)));
|
|
123
|
+
const classPrefix = String(flag("class-prefix", skill ? skillClassPrefix(skill) : prefix));
|
|
124
|
+
|
|
125
|
+
if (fs.existsSync(path.join(dir, "canon.json")) && !has("force"))
|
|
126
|
+
die(`${rel(path.join(dir, "canon.json"))} already exists — pass --force to start over, or just keep working in it`);
|
|
127
|
+
|
|
128
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
129
|
+
write(path.join(dir, "canon.json"), JSON.stringify(blankCanon({ url, prefix, classPrefix }), null, 2) + "\n");
|
|
130
|
+
write(path.join(dir, "schema.json"), JSON.stringify(schemaDoc(), null, 2) + "\n");
|
|
131
|
+
write(path.join(dir, "collect-in-browser.js"), fs.readFileSync(path.join(ENGINE, "src/canon/collect-in-browser.js"), "utf8"));
|
|
132
|
+
// The class names the workspace's own stylesheet has already spent. Handed to the agent
|
|
133
|
+
// up front, because `apply` refuses a collision and the candidates the evidence offers
|
|
134
|
+
// (a card, a chip, a button) are exactly the ones a starter design system already owns.
|
|
135
|
+
const taken = skill ? classNamesIn(readSafe(path.join(skill.abs, `${skill.dirName}.css`))) : [];
|
|
136
|
+
write(path.join(dir, "BRIEF.md"), brief({ url, prefix, classPrefix, dir, workspace: ws, skill, taken }));
|
|
137
|
+
|
|
138
|
+
say(`canon: working folder ready at ${rel(dir)}`);
|
|
139
|
+
say(` BRIEF.md what your agent does, start to finish`);
|
|
140
|
+
say(` schema.json the ${ROLES.length} token roles, machine-readable`);
|
|
141
|
+
say(` canon.json the answer — every observed role still null`);
|
|
142
|
+
say(` collect-in-browser.js paste into your browser, signed in`);
|
|
143
|
+
say(``);
|
|
144
|
+
if (ws) say(`Workspace: ${rel(ws)}${skill ? ` (design system "${skill.dirName}", tokens --${skill.prefix}-*, ${taken.length} class names already spent)` : " (no design system yet)"}`);
|
|
145
|
+
else say(`No space.json above ${rel(process.cwd())} — you can still build the canon here and apply it with --space later.`);
|
|
146
|
+
say(``);
|
|
147
|
+
say(`Next: hand ${rel(path.join(dir, "BRIEF.md"))} to your agent. It is written for one.`);
|
|
148
|
+
// `augur ship` commits everything, untracked included, so a working folder left inside a
|
|
149
|
+
// workspace goes into that workspace's repo — and the evidence names the product's
|
|
150
|
+
// internal screens and class names. It is the person's own product, so this is a
|
|
151
|
+
// surprise rather than a leak, but a surprise about where their product's internals
|
|
152
|
+
// went is not one to have quietly.
|
|
153
|
+
if (ws && dir.startsWith(ws + path.sep)) {
|
|
154
|
+
say(``);
|
|
155
|
+
say(`This folder holds evidence about ${(() => { try { return new URL(url).hostname; } catch { return "your product"; } })()} — screen names, class`);
|
|
156
|
+
say(`names, the URLs you collected. It sits inside the workspace, so \`augur ship\` will`);
|
|
157
|
+
say(`commit it. Move it outside, or add "${path.basename(dir)}/" to .gitignore, if that is not what you want.`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const guessPrefix = (url) => {
|
|
162
|
+
try {
|
|
163
|
+
const host = new URL(url).hostname.replace(/^www\./, "").split(".")[0];
|
|
164
|
+
return /^[a-z][a-z0-9-]*$/.test(host) ? host : "canon";
|
|
165
|
+
} catch { return "canon"; }
|
|
166
|
+
};
|
|
167
|
+
const skillClassPrefix = (skill) => {
|
|
168
|
+
const manifest = readJson(path.join(skill.abs, "skill.json"), {}) || {};
|
|
169
|
+
const list = Array.isArray(manifest.cssPrefixes) ? manifest.cssPrefixes : [];
|
|
170
|
+
return list.find((p) => p !== skill.prefix) || skill.prefix;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
function schemaDoc() {
|
|
174
|
+
return {
|
|
175
|
+
canonVersion: CANON_VERSION,
|
|
176
|
+
componentTypes: COMPONENT_TYPES,
|
|
177
|
+
observedRoles: OBSERVED_ROLES,
|
|
178
|
+
roles: ROLES.map((r) => ({
|
|
179
|
+
role: r.role, group: r.group, kind: r.kind, what: r.what,
|
|
180
|
+
answer: r.derived ? "optional — computed from the observed roles when you leave it out" : "required",
|
|
181
|
+
...(r.scale ? { scale: r.scale } : {}),
|
|
182
|
+
})),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ── The brief the user's own agent reads ──────────────────────────────────── */
|
|
187
|
+
|
|
188
|
+
function brief({ url, prefix, classPrefix, dir, workspace, skill, taken = [] }) {
|
|
189
|
+
const here = path.basename(dir);
|
|
190
|
+
const roleTable = ROLES.map((r) => `| \`${r.role}\` | ${r.kind} | ${r.derived ? "computed if omitted" : "**you answer**"} | ${r.what} |`).join("\n");
|
|
191
|
+
// An example whose class name this workspace has NOT already spent. The obvious one
|
|
192
|
+
// (`btn`) is exactly what a starter design system owns, so an agent following the
|
|
193
|
+
// example literally would meet a refusal at the last step.
|
|
194
|
+
const exampleName = ["navlink", "tile", "banner", "rail", "unit"].find((n) => !taken.includes(`${classPrefix}-${n}`)) || "extracted";
|
|
195
|
+
const takenNote = taken.length
|
|
196
|
+
? `**Check this list before you name anything.** \`apply\` REFUSES a class this workspace's
|
|
197
|
+
own stylesheet already defines, and the candidates the evidence hands you — a card, a chip,
|
|
198
|
+
a button — are exactly the ones a starter design system already owns. Spent already:
|
|
199
|
+
|
|
200
|
+
${taken.map((t) => `\`.${t}\``).join(" ")}
|
|
201
|
+
|
|
202
|
+
Name your part after what the PRODUCT calls it and add a word if you have to.`
|
|
203
|
+
: `Nothing is named in this workspace yet, so every class name is free.`;
|
|
204
|
+
return `# Extract this product's design system
|
|
205
|
+
|
|
206
|
+
You are doing this for the person who owns ${url}. They have a login to it; they do not
|
|
207
|
+
have its source. Your job is to turn what that product LOOKS like into a design system
|
|
208
|
+
this workspace can build screens from — the same shape a workspace is born with, so the
|
|
209
|
+
screens already here keep working and the next ones are made of the product's own parts.
|
|
210
|
+
|
|
211
|
+
Augur does none of the deciding. It gives you a folder, a list of roles, a grader and an
|
|
212
|
+
emitter. Which of the eleven greys is the hairline is your call, and it is the only part
|
|
213
|
+
that needs a mind.
|
|
214
|
+
|
|
215
|
+
## 1 — Collect the evidence
|
|
216
|
+
|
|
217
|
+
Two doors. Use the one that fits, and use **three or four different screens** either way:
|
|
218
|
+
a login page is not a design system, and neither is one dashboard.
|
|
219
|
+
|
|
220
|
+
**Signed in (the usual case).** Open the product in a browser you are signed in to. Open
|
|
221
|
+
the developer console. Paste the whole of \`${here}/collect-in-browser.js\` and press
|
|
222
|
+
enter. It reads what the page actually renders — no request, no credential, nothing
|
|
223
|
+
leaves the page — and downloads an \`observation-*.json\`. Move each one into
|
|
224
|
+
\`${here}/\`. If you can drive a browser yourself, navigate and evaluate that file's
|
|
225
|
+
contents in the page instead; the result is the same object.
|
|
226
|
+
|
|
227
|
+
**Public page.** \`augur canon collect <url> --dir ${here}\` reads the page and its
|
|
228
|
+
stylesheets over HTTP. No login, so it only sees what a signed-out visitor sees.
|
|
229
|
+
|
|
230
|
+
Then fold them together:
|
|
231
|
+
|
|
232
|
+
augur canon collect --merge --dir ${here}
|
|
233
|
+
|
|
234
|
+
You now have \`${here}/observation.json\`: ranked colours (weighted by how much of the
|
|
235
|
+
screen they cover), font stacks, the sizes and gaps in use, radii, shadows, whatever
|
|
236
|
+
custom properties the product already declares, and the class families that are
|
|
237
|
+
candidate components. It is evidence. It is not an answer.
|
|
238
|
+
|
|
239
|
+
## 2 — Map the evidence onto the roles
|
|
240
|
+
|
|
241
|
+
Open \`${here}/canon.json\`. Every role below that says **you answer** is \`null\`. Fill
|
|
242
|
+
it in with a value from the evidence — a real one, spelled the way the product spells it.
|
|
243
|
+
|
|
244
|
+
${roleTable}
|
|
245
|
+
|
|
246
|
+
Reading the evidence:
|
|
247
|
+
|
|
248
|
+
- **The product's own custom properties beat every heuristic below.** If
|
|
249
|
+
\`customProperties\` in the observation holds \`--brand-primary\` or \`--x-paper\`, that
|
|
250
|
+
IS the answer and the frequency ranking is only how you confirm it. A product that
|
|
251
|
+
already has tokens has done half this job. Where a declared token and a heuristic
|
|
252
|
+
disagree, the declared token wins — and say so in \`notes\`.
|
|
253
|
+
- **\`sheet\` and \`paper\`** are the two most-covering background colours. \`sheet\` is
|
|
254
|
+
the one text sits on; \`paper\` is what is behind the sheet. Beware the inversion: on a
|
|
255
|
+
busy product the page background barely registers, because cards cover it. On a product
|
|
256
|
+
with no distinction, make \`paper\` a hair darker than \`sheet\` and say so in \`notes\`.
|
|
257
|
+
- **\`ink\`** is the highest-area \`color\` value, near-black on light products.
|
|
258
|
+
- **\`rule\`** is the colour that appears on \`borderColor\` far more than on anything
|
|
259
|
+
else, and it is easy to mistake for \`ink-3\`; the tell is the property it was seen on.
|
|
260
|
+
Two traps. A plain grey seen ONLY on the border properties of \`table\`/\`tr\`/\`td\` is
|
|
261
|
+
the browser's own default, not a decision — ignore it. And a product with a heavy
|
|
262
|
+
drawn line may have no faint hairline at all: \`rule\` equal to \`ink\` is a legitimate
|
|
263
|
+
answer, and softening it would delete the product's whole hand.
|
|
264
|
+
- **\`mark\` is the single most important decision here.** It is the colour the product
|
|
265
|
+
uses for the thing you are meant to act on: the primary button, the active tab, the
|
|
266
|
+
link. It is almost never the most COMMON colour — it is the most SATURATED one with a
|
|
267
|
+
meaningful count. If two candidates tie, take the one seen on a button. A palette of
|
|
268
|
+
equally-saturated colours used to tell CATEGORIES apart is not the mark; those are
|
|
269
|
+
\`x-\` tokens.
|
|
270
|
+
- **The type scale** is the distinct \`font-size\` values, largest to smallest, thinned
|
|
271
|
+
to seven. Products carry more than seven; pick the ones that carry real weight and drop
|
|
272
|
+
near-duplicates (15px and 15.5px are one step). It has to climb — the grader checks.
|
|
273
|
+
- **The space ramp** is the distinct paddings and gaps, thinned to eight, climbing.
|
|
274
|
+
- Leave a **computed** role \`null\` unless the product genuinely has its own. The
|
|
275
|
+
computed ones are derived from what you did answer, and they are derived to be legible
|
|
276
|
+
— a \`mark-ink\` computed from \`mark\` is guaranteed to pass contrast as text, and a
|
|
277
|
+
\`mark-on\` computed from it is guaranteed to be readable on a primary button. A colour
|
|
278
|
+
picked by eye frequently is neither.
|
|
279
|
+
- A value the roles have no slot for goes under an \`x-\` name:
|
|
280
|
+
\`"x-brand-navy": "#0b2545"\` emits as the real token \`--${prefix}-brand-navy\`.
|
|
281
|
+
**The \`x-\` is bookkeeping on the answer and is NOT part of the variable name** — write
|
|
282
|
+
\`var(--${prefix}-brand-navy)\`, never \`var(--${prefix}-x-brand-navy)\`.
|
|
283
|
+
- A role typed \`length\` holds one length. A product whose corners are a \`border-radius\`
|
|
284
|
+
SHORTHAND (four values, or a slash) cannot put that in \`radius-1\`: put a single
|
|
285
|
+
representative length in the role so the scale still works, and carry the real shorthand
|
|
286
|
+
as an \`x-\` token your components use.
|
|
287
|
+
- \`font-display\` and \`font-body\` are **stacks, not files**. The canon carries no
|
|
288
|
+
\`@font-face\` and downloads nothing, so a stack naming a face the workspace does not
|
|
289
|
+
have will quietly fall back. Either name a face that is already installed or self-hosted,
|
|
290
|
+
or add the \`@font-face\` to a component's \`css\` yourself.
|
|
291
|
+
|
|
292
|
+
## 3 — Name the components
|
|
293
|
+
|
|
294
|
+
Fill \`components\` with the parts the product is actually made of — the panel, the field,
|
|
295
|
+
the table row, the nav item. Take the class families from the evidence as your candidates
|
|
296
|
+
and take the sizes and colours from the roles you just filled.
|
|
297
|
+
|
|
298
|
+
${takenNote}
|
|
299
|
+
|
|
300
|
+
{
|
|
301
|
+
"name": "${exampleName}",
|
|
302
|
+
"type": "primitive",
|
|
303
|
+
"label": "Nav link",
|
|
304
|
+
"description": "A row in the sidebar; the current one wears the hot ink.",
|
|
305
|
+
"classes": ["${classPrefix}-${exampleName}", "${classPrefix}-${exampleName}--on"],
|
|
306
|
+
"css": ".${classPrefix}-${exampleName} { display: block; font: var(--${prefix}-text-md)/1.4 var(--${prefix}-font-display); padding: var(--${prefix}-s2) var(--${prefix}-s4); border-radius: var(--${prefix}-radius-1); color: var(--${prefix}-ink-2); }\\n.${classPrefix}-${exampleName}--on { background: var(--${prefix}-mark); color: var(--${prefix}-mark-on); }"
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
Three rules the grader enforces, all for the same reason — a component that hard-codes a
|
|
310
|
+
value stops moving when a token changes, and then the design system is decoration:
|
|
311
|
+
|
|
312
|
+
1. **No literal colours in \`css\`.** Every colour is \`var(--${prefix}-…)\`. If you need
|
|
313
|
+
one the roles do not name, add an \`x-\` token and use that.
|
|
314
|
+
2. **Every \`var()\` must name a token this canon defines** — and remember the \`x-\` is
|
|
315
|
+
dropped from the emitted name. Inventing one fails.
|
|
316
|
+
3. **Every class starts \`${classPrefix}-\`**, and the CSS defines every class it lists.
|
|
317
|
+
|
|
318
|
+
Six to twelve components is a design system. Forty is a copy of a stylesheet.
|
|
319
|
+
|
|
320
|
+
## 4 — Grade it, and fix what it says
|
|
321
|
+
|
|
322
|
+
augur canon grade --dir ${here}
|
|
323
|
+
|
|
324
|
+
It names every unanswered role, every scale that does not climb, every component that
|
|
325
|
+
hard-codes a colour, and it prints every contrast ratio it computed — passing ones
|
|
326
|
+
included, on the \`contrast\` line, so you can see the numbers rather than trust them.
|
|
327
|
+
**Do not stop until it passes.** It is arithmetic, not taste, and it is the same grade the
|
|
328
|
+
person will run. \`--strict\` turns the notes into failures too; a canon worth keeping
|
|
329
|
+
passes that as well.
|
|
330
|
+
|
|
331
|
+
## 5 — Apply it${workspace ? "" : " (needs a workspace)"}
|
|
332
|
+
|
|
333
|
+
augur canon apply --dir ${here}${workspace ? "" : " --space <folder with space.json>"}
|
|
334
|
+
|
|
335
|
+
That writes${skill ? ` into \`${skill.relPath}/\`` : " the workspace's design system"}:
|
|
336
|
+
|
|
337
|
+
- \`${prefix}-tokens.css\` — regenerated, every role filled. Every screen in the
|
|
338
|
+
workspace already links this file, so they all re-skin at once.
|
|
339
|
+
- \`${prefix}-canon.css\` — your components. A new file; the workspace's own component
|
|
340
|
+
stylesheet is never touched, and a class name that already exists is a refusal.
|
|
341
|
+
- \`skill.json\`, \`registry.json\`, \`CANON.md\` — the manifest, the overlay's labels,
|
|
342
|
+
and the canon's own record of where it came from.
|
|
343
|
+
|
|
344
|
+
## 6 — Prove it, do not claim it
|
|
345
|
+
|
|
346
|
+
Open a prototype in this workspace and LOOK at it — \`npm run offline\` from the
|
|
347
|
+
workspace's parent folder, or just open a prototype's \`index.html\` from disk. Read the
|
|
348
|
+
computed value of \`--${prefix}-mark\` on the page, not the file. The screens should be
|
|
349
|
+
wearing the product's colours, and nothing you write in a report changes it if they are
|
|
350
|
+
not.
|
|
351
|
+
|
|
352
|
+
A screen that did not move has exactly three causes, in the order worth checking:
|
|
353
|
+
|
|
354
|
+
1. **The page hard-codes the value.** A prototype is allowed to; it is somebody's HTML.
|
|
355
|
+
Find the literal and make it read the token instead. This is the commonest cause and
|
|
356
|
+
the one that looks most like a bug in the extraction.
|
|
357
|
+
2. **The page overwrites the token in script on load** — a default written into a
|
|
358
|
+
\`const\` and applied on start. Same fix.
|
|
359
|
+
3. **The page does not link \`${prefix}-tokens.css\`** at all. Rarest, and the only one
|
|
360
|
+
that is a wiring problem.
|
|
361
|
+
|
|
362
|
+
Then write one new screen using only \`.${classPrefix}-*\` classes and the tokens. If you
|
|
363
|
+
reach for a value that is not a token, that is the canon telling you it is missing a
|
|
364
|
+
role — add an \`x-\` token, re-apply, and carry on.
|
|
365
|
+
`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* ── collect ───────────────────────────────────────────────────────────────── */
|
|
369
|
+
|
|
370
|
+
async function cmdCollect() {
|
|
371
|
+
const out = String(flag("out", path.join(dir, "observation.json")));
|
|
372
|
+
|
|
373
|
+
if (has("merge")) {
|
|
374
|
+
if (!fs.existsSync(dir)) die(`no folder at ${rel(dir)} — run \`augur canon start <url>\` first`);
|
|
375
|
+
const files = fs.readdirSync(dir).filter((f) => /^observation.*\.json$/.test(f) && f !== "observation.json");
|
|
376
|
+
const all = files.map((f) => readJson(path.join(dir, f))).filter(Boolean);
|
|
377
|
+
if (fs.existsSync(path.join(dir, "observation.json")) && has("keep")) all.unshift(readJson(path.join(dir, "observation.json")));
|
|
378
|
+
if (!all.length) die(`no observation-*.json files in ${rel(dir)} — collect some first (see BRIEF.md)`);
|
|
379
|
+
const merged = mergeObservations(all);
|
|
380
|
+
merged.source.how = all.some((o) => o.source?.how === "browser") ? "browser" : "fetch";
|
|
381
|
+
write(path.join(dir, "observation.json"), JSON.stringify(merged, null, 2) + "\n");
|
|
382
|
+
say(`canon: merged ${all.length} observation(s) → ${rel(path.join(dir, "observation.json"))}`);
|
|
383
|
+
return summarise(merged);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const htmlFile = flag("html");
|
|
387
|
+
let observation;
|
|
388
|
+
if (typeof htmlFile === "string") {
|
|
389
|
+
const html = fs.readFileSync(htmlFile, "utf8");
|
|
390
|
+
const cssFiles = [];
|
|
391
|
+
for (let i = 0; i < argv.length; i++) if (argv[i] === "--css" && argv[i + 1]) cssFiles.push(argv[++i]);
|
|
392
|
+
const sheets = cssFiles.map((f) => ({ href: f, text: fs.readFileSync(f, "utf8") }));
|
|
393
|
+
for (const m of html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) sheets.push({ href: "[inline]", text: m[1] });
|
|
394
|
+
observation = observe({ url: `file://${path.resolve(htmlFile)}`, html, sheets });
|
|
395
|
+
} else {
|
|
396
|
+
const url = positional[0];
|
|
397
|
+
if (!url) die("collect needs a URL, or --html <file>, or --merge");
|
|
398
|
+
observation = await collectUrl(url);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
write(out, JSON.stringify(observation, null, 2) + "\n");
|
|
402
|
+
say(`canon: ${rel(out)}`);
|
|
403
|
+
summarise(observation);
|
|
404
|
+
if (observation.source.how === "fetch") {
|
|
405
|
+
say(``);
|
|
406
|
+
say(`This is what a signed-OUT visitor sees. If the product's real screens are behind`);
|
|
407
|
+
say(`the login, run the browser collector instead: augur canon snippet`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function collectUrl(url) {
|
|
412
|
+
const page = await fetchText(url);
|
|
413
|
+
const sheets = [];
|
|
414
|
+
for (const m of page.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) sheets.push({ href: "[inline]", text: m[1] });
|
|
415
|
+
const links = [...page.matchAll(/<link\b[^>]*>/gi)]
|
|
416
|
+
.filter((m) => /rel\s*=\s*["']?stylesheet/i.test(m[0]))
|
|
417
|
+
.map((m) => (/href\s*=\s*["']([^"']+)["']/i.exec(m[0]) || [])[1])
|
|
418
|
+
.filter(Boolean);
|
|
419
|
+
for (const href of links.slice(0, 25)) {
|
|
420
|
+
try {
|
|
421
|
+
const abs = new URL(href, url).href;
|
|
422
|
+
sheets.push({ href: abs, text: await fetchText(abs) });
|
|
423
|
+
} catch (e) { say(` (skipped ${href}: ${e.message})`); }
|
|
424
|
+
}
|
|
425
|
+
return observe({ url, html: page, sheets });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function fetchText(url) {
|
|
429
|
+
const res = await fetch(url, {
|
|
430
|
+
redirect: "follow",
|
|
431
|
+
headers: { "user-agent": "augur-canon/1 (design-system extraction; +local)" },
|
|
432
|
+
});
|
|
433
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
434
|
+
return await res.text();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function summarise(o) {
|
|
438
|
+
const top = (list, n = 6) => (list || []).slice(0, n).map((e) => e.value).join(" ");
|
|
439
|
+
say(` pages ${(o.source?.pages || []).length} elements ${o.stats?.elements || 0} stylesheets ${o.stats?.sheets || 0}`);
|
|
440
|
+
say(` colours ${top(o.colors)}`);
|
|
441
|
+
say(` type ${top(o.fontSizes, 8)}`);
|
|
442
|
+
say(` space ${top(o.spacings, 8)}`);
|
|
443
|
+
say(` radii ${top(o.radii, 5)}`);
|
|
444
|
+
if ((o.customProperties || []).length) say(` the product already declares ${o.customProperties.length} custom properties — read those first`);
|
|
445
|
+
say(` families ${(o.classFamilies || []).slice(0, 8).map((f) => f.root).join(" ")}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/* ── snippet ───────────────────────────────────────────────────────────────── */
|
|
449
|
+
|
|
450
|
+
function cmdSnippet() {
|
|
451
|
+
const src = fs.readFileSync(path.join(ENGINE, "src/canon/collect-in-browser.js"), "utf8");
|
|
452
|
+
const target = path.join(dir, "collect-in-browser.js");
|
|
453
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
454
|
+
fs.writeFileSync(target, src);
|
|
455
|
+
say(`canon: ${rel(target)}`);
|
|
456
|
+
say(``);
|
|
457
|
+
say(` 1. Open your product in a browser, signed in, on a screen that matters.`);
|
|
458
|
+
say(` 2. Open the developer console.`);
|
|
459
|
+
say(` 3. Paste the whole file and press enter.`);
|
|
460
|
+
say(` 4. It downloads observation-<host>-<time>.json. Move it into ${rel(dir)}.`);
|
|
461
|
+
say(` 5. Repeat on three or four different screens, then: augur canon collect --merge --dir ${rel(dir)}`);
|
|
462
|
+
say(``);
|
|
463
|
+
say(`It makes no request, reads no credential and touches no cookie — read it first if you like.`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/* ── grade ─────────────────────────────────────────────────────────────────── */
|
|
467
|
+
|
|
468
|
+
function cmdGrade() {
|
|
469
|
+
const spaceFlag = flag("space");
|
|
470
|
+
if (spaceFlag) {
|
|
471
|
+
const root = path.resolve(String(spaceFlag));
|
|
472
|
+
const skill = findSkill(root);
|
|
473
|
+
if (!skill) die(`no design system under ${rel(root)}/skills — nothing to grade`);
|
|
474
|
+
const tokensFile = path.join(skill.abs, `${skill.prefix}-tokens.css`);
|
|
475
|
+
if (!fs.existsSync(tokensFile)) die(`${rel(tokensFile)} does not exist`);
|
|
476
|
+
const { tokens, extras } = parseTokensCss(fs.readFileSync(tokensFile, "utf8"), skill.prefix);
|
|
477
|
+
const canon = { canonVersion: CANON_VERSION, prefix: skill.prefix, tokens, components: [], source: { url: `${skill.relPath}` } };
|
|
478
|
+
for (const [k, v] of Object.entries(extras)) canon.tokens[`x-${k}`] = v;
|
|
479
|
+
report(validateCanon(canon, { strict: has("strict") }), `${rel(tokensFile)}`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const file = path.join(dir, "canon.json");
|
|
483
|
+
const canon = readJson(file);
|
|
484
|
+
if (!canon) die(`no ${rel(file)} — run \`augur canon start <url>\` first`);
|
|
485
|
+
report(validateCanon(canon, { strict: has("strict") }), rel(file));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function report(graded, what) {
|
|
489
|
+
say(`canon: ${what}`);
|
|
490
|
+
say(` roles ${ROLES.length} total, ${OBSERVED_ROLES.length} to answer, ${graded.derived.length} computed`);
|
|
491
|
+
say(` components ${graded.componentCount || 0}`);
|
|
492
|
+
// Printed whether they pass or fail. A ratio nobody can see is a claim, and the one
|
|
493
|
+
// that passes today is what a person checks against when they change a value tomorrow.
|
|
494
|
+
if (graded.ratios?.length) {
|
|
495
|
+
say(` contrast ${graded.ratios.map((r) => `${r.fg}/${r.bg} ${r.ratio.toFixed(2)}${r.ok ? "" : ` (<${r.floor})`}`).join(" ")}`);
|
|
496
|
+
}
|
|
497
|
+
if (graded.warnings.length) { say(``); for (const w of graded.warnings) say(` note ${w}`); }
|
|
498
|
+
if (graded.errors.length) {
|
|
499
|
+
say(``);
|
|
500
|
+
for (const e of graded.errors) say(` FAIL ${e}`);
|
|
501
|
+
say(``);
|
|
502
|
+
say(`${graded.errors.length} thing(s) to fix. Every one of them is arithmetic — see BRIEF.md step 2.`);
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
say(``);
|
|
506
|
+
say(` PASS — complete, legible, and every component drinks from a token.`);
|
|
507
|
+
say(` Next: augur canon apply --dir ${rel(dir)}`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/* ── apply ─────────────────────────────────────────────────────────────────── */
|
|
511
|
+
|
|
512
|
+
function cmdApply() {
|
|
513
|
+
const file = path.join(dir, "canon.json");
|
|
514
|
+
const canon = readJson(file);
|
|
515
|
+
if (!canon) die(`no ${rel(file)} — run \`augur canon start <url>\` first`);
|
|
516
|
+
|
|
517
|
+
const root = flag("space") ? path.resolve(String(flag("space"))) : findWorkspace(process.cwd());
|
|
518
|
+
if (!root) die(`no space.json above ${rel(process.cwd())} — name the workspace with --space <folder>`);
|
|
519
|
+
let skill = findSkill(root);
|
|
520
|
+
if (!skill) {
|
|
521
|
+
// A workspace with no design system gets one named after the canon.
|
|
522
|
+
const dirName = `${canon.prefix}-ui`;
|
|
523
|
+
skill = { dirName, prefix: canon.prefix, abs: path.join(root, "skills", dirName), relPath: `skills/${dirName}` };
|
|
524
|
+
say(`canon: this workspace has no design system — creating ${skill.relPath}/`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Where the canon came from is recorded from the EVIDENCE, not from a field the person
|
|
528
|
+
// has to remember to fill: the provenance in CANON.md is worthless if it is blank
|
|
529
|
+
// because nobody was told to type it.
|
|
530
|
+
const observation = readJson(path.join(dir, "observation.json"), null);
|
|
531
|
+
canon.source = canon.source && typeof canon.source === "object" ? canon.source : {};
|
|
532
|
+
if (observation?.source) {
|
|
533
|
+
const pages = observation.source.pages || [];
|
|
534
|
+
if (!canon.source.url) canon.source.url = pages[0] || observation.source.url || "";
|
|
535
|
+
if (!canon.source.collectedAt) canon.source.collectedAt = observation.source.collectedAt || null;
|
|
536
|
+
if (!canon.source.how) canon.source.how = observation.source.how || "";
|
|
537
|
+
if (!canon.source.pages && pages.length > 1) canon.source.pages = pages;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const plan = planApply({
|
|
541
|
+
canon,
|
|
542
|
+
skillDir: skill.relPath,
|
|
543
|
+
skillPrefix: skill.prefix,
|
|
544
|
+
existingSkillJson: readJson(path.join(skill.abs, "skill.json"), null),
|
|
545
|
+
existingRegistry: readJson(path.join(root, "registry.json"), null),
|
|
546
|
+
existingComponentCss: readSafe(path.join(skill.abs, `${skill.dirName}.css`)),
|
|
547
|
+
observation,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
for (const n of plan.notes) say(` note ${n}`);
|
|
551
|
+
for (const w of plan.warnings) say(` note ${w}`);
|
|
552
|
+
if (!plan.ok) {
|
|
553
|
+
for (const e of plan.errors) say(` FAIL ${e}`);
|
|
554
|
+
die(`${plan.errors.length} thing(s) to fix before this can be written. Nothing was changed.`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const dry = has("dry-run");
|
|
558
|
+
for (const w of plan.writes) {
|
|
559
|
+
const target = path.join(root, w.path);
|
|
560
|
+
const before = readSafe(target);
|
|
561
|
+
const verb = before === "" ? "create" : before === w.text ? "unchanged" : "rewrite";
|
|
562
|
+
say(` ${dry ? "would " : ""}${verb.padEnd(9)} ${rel(target)}${verb === "rewrite" ? ` (${before.split("\n").length} → ${w.text.split("\n").length} lines)` : ""}`);
|
|
563
|
+
if (!dry && verb !== "unchanged") write(target, w.text);
|
|
564
|
+
}
|
|
565
|
+
say(``);
|
|
566
|
+
if (dry) { say(`Dry run — nothing written.`); return; }
|
|
567
|
+
say(`canon: ${skill.relPath}/ now carries the canon.`);
|
|
568
|
+
say(` Every screen that links ${skill.prefix}-tokens.css is wearing it. Go and LOOK at one:`);
|
|
569
|
+
say(` open a prototype's index.html, or run \`npm run offline\` from the folder above this one.`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const readSafe = (p) => { try { return fs.readFileSync(p, "utf8"); } catch { return ""; } };
|
|
573
|
+
|
|
574
|
+
/* ── router ────────────────────────────────────────────────────────────────── */
|
|
575
|
+
|
|
576
|
+
const run = { start: cmdStart, collect: cmdCollect, snippet: cmdSnippet, grade: cmdGrade, apply: cmdApply }[sub];
|
|
577
|
+
if (!run) { console.error(USAGE); process.exit(1); }
|
|
578
|
+
await run();
|