@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.
Files changed (190) hide show
  1. package/CANVAS.md +858 -0
  2. package/INSTALL.md +603 -0
  3. package/LICENSE +27 -0
  4. package/README.md +150 -0
  5. package/TRADEMARKS.md +72 -0
  6. package/agents/README.md +65 -0
  7. package/agents/canon-extract.md +117 -0
  8. package/agents/canon.md +151 -0
  9. package/agents/canvas.md +88 -0
  10. package/agents/currency.md +74 -0
  11. package/agents/drafts.md +92 -0
  12. package/agents/identity.md +148 -0
  13. package/agents/prototype-contract.md +69 -0
  14. package/agents/publishing.md +324 -0
  15. package/agents/review-feedback.md +76 -0
  16. package/agents/space-json.md +132 -0
  17. package/agents/ui-skill.md +101 -0
  18. package/agents/working-marks.md +86 -0
  19. package/brand/augur-eye.svg +3 -0
  20. package/brand/augur-icon.svg +41 -0
  21. package/brand/augur-mark.svg +5 -0
  22. package/brand/lentianova-bulky.otf +0 -0
  23. package/brand/lentianova-median.otf +0 -0
  24. package/brand/lentianova.woff2 +0 -0
  25. package/build.js +8409 -0
  26. package/changelog.md +133 -0
  27. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/package.json +57 -0
  29. package/pitis/PETS.md +121 -0
  30. package/pitis/README.md +117 -0
  31. package/pitis/index.html +266 -0
  32. package/pitis/piti.build.js +81 -0
  33. package/pitis/piti.js +929 -0
  34. package/pitis/roast-agent.md +185 -0
  35. package/realtime/src/index.js +119 -0
  36. package/realtime/wrangler.example.toml +44 -0
  37. package/scripts/adopt.mjs +106 -0
  38. package/scripts/board-snapshot.mjs +197 -0
  39. package/scripts/build-seed-pack.mjs +33 -0
  40. package/scripts/bundle-rekey.mjs +112 -0
  41. package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
  42. package/scripts/canon-extract.mjs +578 -0
  43. package/scripts/canon.mjs +599 -0
  44. package/scripts/canvas-screen.mjs +333 -0
  45. package/scripts/check-local.mjs +136 -0
  46. package/scripts/clawd-art.mjs +75 -0
  47. package/scripts/clawd-canvas.mjs +741 -0
  48. package/scripts/cli.mjs +100 -0
  49. package/scripts/clone.mjs +422 -0
  50. package/scripts/close.mjs +30 -0
  51. package/scripts/connect.mjs +103 -0
  52. package/scripts/deploy.mjs +157 -0
  53. package/scripts/dev.mjs +54 -0
  54. package/scripts/doc-lint.mjs +143 -0
  55. package/scripts/export.mjs +300 -0
  56. package/scripts/fork.mjs +64 -0
  57. package/scripts/freeze.mjs +74 -0
  58. package/scripts/frontdoor-parity.mjs +180 -0
  59. package/scripts/hook.mjs +67 -0
  60. package/scripts/identity-rekey.mjs +112 -0
  61. package/scripts/init.mjs +122 -0
  62. package/scripts/land.mjs +40 -0
  63. package/scripts/lib/adapters.mjs +156 -0
  64. package/scripts/lib/board-snapshot.mjs +511 -0
  65. package/scripts/lib/draft.mjs +385 -0
  66. package/scripts/lib/graduate.mjs +276 -0
  67. package/scripts/lib/instance.mjs +74 -0
  68. package/scripts/lib/marks.mjs +107 -0
  69. package/scripts/lib/materialize.mjs +138 -0
  70. package/scripts/lib/merge3.mjs +124 -0
  71. package/scripts/lib/offline-posture.mjs +120 -0
  72. package/scripts/lib/offline-respawn.mjs +18 -0
  73. package/scripts/lib/offline-wrangler.mjs +78 -0
  74. package/scripts/lib/publish-compose.mjs +12 -0
  75. package/scripts/lib/publish-conflict.mjs +78 -0
  76. package/scripts/lib/publish-evidence.mjs +144 -0
  77. package/scripts/lib/refine-compare.mjs +140 -0
  78. package/scripts/lib/refine-ledger.mjs +150 -0
  79. package/scripts/lib/refine-png.mjs +149 -0
  80. package/scripts/lib/seed-pack-build.mjs +141 -0
  81. package/scripts/lib/state-compare.mjs +68 -0
  82. package/scripts/lib/store.mjs +122 -0
  83. package/scripts/login.mjs +84 -0
  84. package/scripts/mark.mjs +112 -0
  85. package/scripts/migrate-board-keys.mjs +164 -0
  86. package/scripts/migrate.mjs +265 -0
  87. package/scripts/no-foreign-vocabulary.mjs +216 -0
  88. package/scripts/no-product-names.mjs +157 -0
  89. package/scripts/no-tenant-globals.mjs +1134 -0
  90. package/scripts/offline.mjs +247 -0
  91. package/scripts/og.mjs +170 -0
  92. package/scripts/one-tenant-resolver.mjs +146 -0
  93. package/scripts/open.mjs +63 -0
  94. package/scripts/publish.mjs +1041 -0
  95. package/scripts/read.mjs +31 -0
  96. package/scripts/refine.mjs +455 -0
  97. package/scripts/release-drift.mjs +138 -0
  98. package/scripts/restore.mjs +322 -0
  99. package/scripts/review.mjs +125 -0
  100. package/scripts/save.mjs +21 -0
  101. package/scripts/shell-lint.mjs +189 -0
  102. package/scripts/ship.mjs +460 -0
  103. package/scripts/shoot.mjs +264 -0
  104. package/scripts/smoke-invite.mjs +113 -0
  105. package/scripts/state-inventory.mjs +223 -0
  106. package/scripts/status.mjs +177 -0
  107. package/scripts/sync.mjs +27 -0
  108. package/scripts/tenant-do-rehearsal.mjs +1314 -0
  109. package/scripts/ui-copy-lint.mjs +202 -0
  110. package/scripts/watch.mjs +32 -0
  111. package/scripts/wrangler-preflight.mjs +246 -0
  112. package/seed/CANON.md +43 -0
  113. package/seed/README.md +119 -0
  114. package/seed/prototype-status.json +9 -0
  115. package/seed/registry.json +88 -0
  116. package/seed/skills/starter-ui/SKILL.md +52 -0
  117. package/seed/skills/starter-ui/skill.json +11 -0
  118. package/seed/skills/starter-ui/starter-tokens.css +73 -0
  119. package/seed/skills/starter-ui/starter-ui.css +318 -0
  120. package/seed/skills/starter-ui/starter-ui.js +50 -0
  121. package/seed/space.json +5 -0
  122. package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
  123. package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
  124. package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
  125. package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
  126. package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
  127. package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
  128. package/seed/threads.json +52 -0
  129. package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
  130. package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
  131. package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
  132. package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
  133. package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
  134. package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
  135. package/src/_worker.js +12668 -0
  136. package/src/board-key.mjs +57 -0
  137. package/src/board-room.mjs +881 -0
  138. package/src/bundle-keys.mjs +138 -0
  139. package/src/canon/collect-in-browser.js +215 -0
  140. package/src/canon/emit.mjs +232 -0
  141. package/src/canon/observe.mjs +260 -0
  142. package/src/canon/schema.mjs +451 -0
  143. package/src/canvas/DSEG-LICENSE.txt +95 -0
  144. package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
  145. package/src/canvas/canvas.css +885 -0
  146. package/src/canvas/canvas.js +5780 -0
  147. package/src/canvas/capture.js +485 -0
  148. package/src/chrome/appchrome.mjs +568 -0
  149. package/src/currency.mjs +203 -0
  150. package/src/delete-confirmation.mjs +254 -0
  151. package/src/drafts/drafts.js +326 -0
  152. package/src/entry.js +58 -0
  153. package/src/galleries.mjs +400 -0
  154. package/src/health-cron.mjs +173 -0
  155. package/src/identity.json +1 -0
  156. package/src/kv-codec.mjs +135 -0
  157. package/src/kv-identity.mjs +259 -0
  158. package/src/mail.mjs +599 -0
  159. package/src/provenance.mjs +74 -0
  160. package/src/publish-compose.mjs +363 -0
  161. package/src/publish-fork.mjs +221 -0
  162. package/src/publish-units.mjs +44 -0
  163. package/src/purge.mjs +81 -0
  164. package/src/review/COMMENTING-UX.md +306 -0
  165. package/src/review/cat.png +0 -0
  166. package/src/review/comment-cursor.svg +7 -0
  167. package/src/review/comments.js +1738 -0
  168. package/src/room-ticket.mjs +96 -0
  169. package/src/roster-chip.mjs +32 -0
  170. package/src/seed-pack.mjs +261 -0
  171. package/src/state-inventory.mjs +249 -0
  172. package/src/sw-logic.mjs +48 -0
  173. package/src/tenant-cache.mjs +141 -0
  174. package/src/tenant-context.mjs +386 -0
  175. package/src/tenant-do.js +2940 -0
  176. package/src/tenant-host.mjs +144 -0
  177. package/src/tenant-quotas.mjs +140 -0
  178. package/src/unit-core.mjs +103 -0
  179. package/src/unit-object.mjs +338 -0
  180. package/templates/README.md +67 -0
  181. package/templates/shell/deploy.yml +145 -0
  182. package/templates/shell/engine-bump.yml +142 -0
  183. package/templates/shell/health.yml +233 -0
  184. package/templates/shell/kv-backup.yml +201 -0
  185. package/templates/shell/roster-update.yml +110 -0
  186. package/templates/shell/space-preflight.yml +35 -0
  187. package/templates/shell/space-rebake.yml +79 -0
  188. package/templates/shell/store-backup.yml +152 -0
  189. package/templates/shell/wrangler.example.toml +266 -0
  190. 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();