@freetesh/canon 0.17.0 → 0.18.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 (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +31 -27
  4. package/cli/dist/api.d.ts +1 -2
  5. package/cli/dist/api.js +1 -2
  6. package/cli/dist/check.d.ts +7 -3
  7. package/cli/dist/check.js +18 -7
  8. package/cli/dist/checks/commands.d.ts +1 -1
  9. package/cli/dist/checks/commands.js +1 -1
  10. package/cli/dist/checks/designs.d.ts +2 -0
  11. package/cli/dist/checks/designs.js +13 -0
  12. package/cli/dist/checks/index.js +2 -1
  13. package/cli/dist/checks/refs.js +8 -4
  14. package/cli/dist/checks/shared-files.d.ts +1 -1
  15. package/cli/dist/checks/shared-files.js +5 -0
  16. package/cli/dist/commands/add.d.ts +1 -1
  17. package/cli/dist/commands/add.js +1 -4
  18. package/cli/dist/commands/check.js +4 -4
  19. package/cli/dist/commands/copies.js +1 -1
  20. package/cli/dist/commands/hooks.js +4 -4
  21. package/cli/dist/commands/learn.js +7 -5
  22. package/cli/dist/commands/pipeline.js +2 -2
  23. package/cli/dist/commands/scripts.d.ts +3 -0
  24. package/cli/dist/commands/scripts.js +32 -0
  25. package/cli/dist/commands/standards.js +1 -1
  26. package/cli/dist/commands/sync.js +1 -1
  27. package/cli/dist/commands/ui.js +3 -3
  28. package/cli/dist/config/load.d.ts +5 -11
  29. package/cli/dist/config/load.js +19 -23
  30. package/cli/dist/config/schema.d.ts +4 -2
  31. package/cli/dist/config/schema.js +4 -4
  32. package/cli/dist/doctor.js +2 -1
  33. package/cli/dist/index.js +4 -2
  34. package/cli/dist/learn.d.ts +2 -2
  35. package/cli/dist/learn.js +6 -6
  36. package/cli/dist/lib/env.d.ts +3 -3
  37. package/cli/dist/lib/env.js +4 -4
  38. package/cli/dist/packs.d.ts +1 -3
  39. package/cli/dist/packs.js +4 -7
  40. package/cli/dist/status.js +1 -1
  41. package/cli/dist/sync.d.ts +0 -1
  42. package/cli/dist/sync.js +1 -1
  43. package/package.json +4 -1
  44. package/scripts/arch-set.py +140 -0
  45. package/scripts/check-designs.py +1 -1
  46. package/scripts/flow.py +313 -0
  47. package/skills/ingest/SKILL.md +1 -1
  48. package/standards/context-layout.md +1 -1
  49. package/standards/library/design-sets.md +1 -1
  50. package/standards/library/secrets.md +3 -3
  51. package/standards/library/store-listing.md +1 -1
@@ -15,19 +15,6 @@ function readLayer(path) {
15
15
  }
16
16
  return parsed.data;
17
17
  }
18
- /** The nearest folder at or above `start` whose canon.yaml says `estate: true`, or null. */
19
- export function findEstate(start) {
20
- let folder = resolve(start);
21
- while (true) {
22
- const path = configPath(folder);
23
- if (existsSync(path) && readLayer(path).estate === true)
24
- return folder;
25
- const parent = dirname(folder);
26
- if (parent === folder)
27
- return null;
28
- folder = parent;
29
- }
30
- }
31
18
  /** The repo root plus each app in a monorepo's apps/ folder. */
32
19
  function appRoots(repo) {
33
20
  const apps = join(repo, "apps");
@@ -58,10 +45,19 @@ export function inferKind(repo) {
58
45
  return isPackage(repo) ? "package" : "content";
59
46
  }
60
47
  export function inferModules(repo, kind) {
61
- const modules = MODULES_BY_KIND[kind];
48
+ return withDesign(repo, MODULES_BY_KIND[kind]);
49
+ }
50
+ /** A repo that draws its screens always gets the design checks, even when its modules list leaves them out. */
51
+ function withDesign(repo, modules) {
62
52
  const hasDesigns = existsSync(join(repo, ".context", "designs"));
63
53
  return hasDesigns && !modules.includes("design") ? [...modules, "design"] : modules;
64
54
  }
55
+ /** The sets a source repo offers under its own copies.sets; none when the source is not checked out. */
56
+ function sourceSets(source, repo) {
57
+ if (!source || resolve(source) === resolve(repo) || !existsSync(configPath(source)))
58
+ return {};
59
+ return readLayer(configPath(source)).copies?.sets ?? {};
60
+ }
65
61
  function lastDefined(values) {
66
62
  return values.filter((value) => value !== undefined).pop();
67
63
  }
@@ -72,15 +68,13 @@ function mergeBy(lists, key) {
72
68
  return [...merged.values()];
73
69
  }
74
70
  /**
75
- * Reads the estate, product and local canon.yaml layers over defaults inferred
76
- * from the repo. Scalars: the most specific layer wins. Lists of names add up
71
+ * Reads the repo's canon.yaml and its uncommitted canon.local.yaml over defaults
72
+ * inferred from the repo. Scalars: the local layer wins. Lists of names add up
77
73
  * (packs, standards); lists of records merge by their key (commands, tools,
78
- * wiki types, context folders).
74
+ * wiki types, context folders). Copy sets come from the source repo's canon.yaml.
79
75
  */
80
- export function loadConfig(repo, options = {}) {
81
- const estateRoot = options.estateRoot === undefined ? findEstate(dirname(resolve(repo))) : options.estateRoot;
76
+ export function loadConfig(repo) {
82
77
  const candidates = [
83
- ...(estateRoot && resolve(estateRoot) !== resolve(repo) ? [{ layer: "estate", path: configPath(estateRoot) }] : []),
84
78
  { layer: "product", path: configPath(repo) },
85
79
  { layer: "local", path: join(repo, ".claude", "canon.local.yaml") },
86
80
  ];
@@ -88,10 +82,11 @@ export function loadConfig(repo, options = {}) {
88
82
  const layers = sources.map((source) => ({ data: readLayer(source.path), base: dirname(dirname(source.path)) }));
89
83
  const pick = (key) => layers.map((layer) => layer.data[key]);
90
84
  const kind = lastDefined(pick("kind")) ?? inferKind(repo);
85
+ const source = lastDefined(layers.map((layer) => (layer.data.copies?.source ? resolve(layer.base, layer.data.copies.source) : undefined)));
91
86
  const config = {
92
87
  canon: lastDefined(pick("canon")),
93
88
  kind,
94
- modules: lastDefined(pick("modules")) ?? inferModules(repo, kind),
89
+ modules: withDesign(repo, lastDefined(pick("modules")) ?? MODULES_BY_KIND[kind]),
95
90
  expo: Object.assign({}, ...pick("expo")),
96
91
  packs: [...new Set(pick("packs").flatMap((packs) => packs ?? []))].sort(),
97
92
  standards: [...new Set(layers.flatMap((layer) => (layer.data.standards ?? []).map((entry) => (isStandardName(entry) ? entry : resolve(layer.base, entry)))))],
@@ -102,9 +97,10 @@ export function loadConfig(repo, options = {}) {
102
97
  context: { folders: mergeBy(pick("context").map((context) => context?.folders ?? []), (entry) => entry.folder) },
103
98
  pipeline: lastDefined(pick("pipeline")) ?? [],
104
99
  name: lastDefined(pick("name")),
100
+ formerly: [...new Set(pick("formerly").flatMap((names) => names ?? []))],
105
101
  copies: {
106
- source: lastDefined(layers.map((layer) => (layer.data.copies?.source ? resolve(layer.base, layer.data.copies.source) : undefined))),
107
- sets: Object.assign({}, ...pick("copies").map((copies) => copies?.sets)),
102
+ source,
103
+ sets: Object.assign({}, sourceSets(source, repo), ...pick("copies").map((copies) => copies?.sets)),
108
104
  use: [...new Set(pick("copies").flatMap((copies) => copies?.use ?? []))],
109
105
  },
110
106
  };
@@ -66,7 +66,6 @@ declare const tool: z.ZodObject<{
66
66
  }, z.core.$strict>;
67
67
  /** One layer of `.claude/canon.yaml` as written: every field optional. */
68
68
  export declare const layerSchema: z.ZodObject<{
69
- estate: z.ZodOptional<z.ZodBoolean>;
70
69
  canon: z.ZodOptional<z.ZodString>;
71
70
  kind: z.ZodOptional<z.ZodEnum<{
72
71
  content: "content";
@@ -142,6 +141,7 @@ export declare const layerSchema: z.ZodObject<{
142
141
  gate: z.ZodDefault<z.ZodBoolean>;
143
142
  }, z.core.$strict>>>;
144
143
  name: z.ZodOptional<z.ZodString>;
144
+ formerly: z.ZodOptional<z.ZodArray<z.ZodString>>;
145
145
  copies: z.ZodOptional<z.ZodObject<{
146
146
  source: z.ZodOptional<z.ZodString>;
147
147
  sets: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
@@ -169,7 +169,7 @@ export interface Config {
169
169
  sdk?: number;
170
170
  };
171
171
  packs: string[];
172
- /** Library standard names and absolute folders, estate first; a later file of the same name wins. */
172
+ /** Library standard names and absolute folders; a later file of the same name wins. */
173
173
  standards: string[];
174
174
  commands: CommandSpec[];
175
175
  doctor: {
@@ -189,6 +189,8 @@ export interface Config {
189
189
  /** Ordered stages; empty when no layer declares one. */
190
190
  pipeline: StageSpec[];
191
191
  name?: string;
192
+ /** Older folder names of the repo. */
193
+ formerly: string[];
192
194
  /** `source` is absolute, resolved from the layer that set it. `use` adds up across layers. */
193
195
  copies: {
194
196
  source?: string;
@@ -80,8 +80,6 @@ const tool = z.object({ tool: z.string(), why: z.string(), hint: z.string() }).s
80
80
  /** One layer of `.claude/canon.yaml` as written: every field optional. */
81
81
  export const layerSchema = z
82
82
  .object({
83
- /** Marks the folder holding this file as an estate: the parent of several products. */
84
- estate: z.boolean(),
85
83
  canon: z.string(),
86
84
  kind: z.enum(KINDS),
87
85
  modules: z.array(z.enum(MODULES)),
@@ -98,9 +96,11 @@ export const layerSchema = z
98
96
  pipeline: z.array(stage),
99
97
  /** How the product is written in prose, for files shared with only the name changed. */
100
98
  name: z.string().min(1),
99
+ /** The repo's older folder names, so `canon flow` still counts conversations held under them. */
100
+ formerly: z.array(z.string().min(1)),
101
101
  /**
102
- * Files copied from one source repo. The estate names sets of them; a product lists the sets it
103
- * uses, and only those are held equal to the source.
102
+ * Files copied from one source repo. The source names sets of them in its own canon.yaml; a
103
+ * product names the source and lists the sets it uses, and only those are held equal to it.
104
104
  */
105
105
  copies: z.object({ source: z.string().min(1), sets: z.record(kebab, z.array(copyFile).min(1)), use: z.array(kebab) }).partial().strict(),
106
106
  })
@@ -1,8 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
- // What canon itself needs. A repo or estate adds its own under `doctor.tools`.
2
+ // What canon itself needs. A repo adds its own under `doctor.tools`.
3
3
  const CORE_TOOLS = [
4
4
  { tool: "node", why: "runs the canon CLI and hooks", hint: "install Node 22+", minimum: [22, 0] },
5
5
  { tool: "git", why: "canon works on git repos", hint: "install git" },
6
+ { tool: "python3", why: "runs the design checks", hint: "brew install python" },
6
7
  { tool: "qmd", why: "knowledge search for Claude", hint: "npm i -g @tobilu/qmd (see the canon README)" },
7
8
  ];
8
9
  const VERSION = /(\d+)\.(\d+)(?:\.(\d+))?/;
package/cli/dist/index.js CHANGED
@@ -15,13 +15,14 @@ import { registerStatus } from "./commands/status.js";
15
15
  import { registerSync } from "./commands/sync.js";
16
16
  import { registerUi } from "./commands/ui.js";
17
17
  import { registerWeigh } from "./commands/weigh.js";
18
- import { findEstate } from "./config/load.js";
18
+ import { findWorkspace } from "./check.js";
19
19
  import { registerMcp } from "./commands/mcp.js";
20
20
  import { registerSearch } from "./commands/search.js";
21
+ import { registerScripts } from "./commands/scripts.js";
21
22
  import { canonVersion } from "./status.js";
22
23
  const env = {
23
24
  canonRoot: resolve(import.meta.dirname, "..", ".."),
24
- estateRoot: process.env.CANON_ESTATE ?? findEstate(process.cwd()),
25
+ workspace: process.env.CANON_WORKSPACE ?? findWorkspace(process.cwd()),
25
26
  };
26
27
  const program = new Command("canon")
27
28
  .description("Shared standards, knowledge and decision checks for every product.")
@@ -40,6 +41,7 @@ registerLearn(program, env);
40
41
  registerApprove(program);
41
42
  registerPipeline(program, env);
42
43
  registerWeigh(program);
44
+ registerScripts(program, env);
43
45
  registerSearch(program);
44
46
  registerMcp(program);
45
47
  program.parseAsync().catch((error) => {
@@ -40,5 +40,5 @@ export declare function proposals(all: VerdictEntry[], options: {
40
40
  threshold: number;
41
41
  decisions: Decision[];
42
42
  }): Proposal[];
43
- export declare function readDecisions(estateRoot: string): Decision[];
44
- export declare function recordDecision(estateRoot: string, decision: Omit<Decision, "at">): void;
43
+ export declare function readDecisions(home: string): Decision[];
44
+ export declare function recordDecision(home: string, decision: Omit<Decision, "at">): void;
package/cli/dist/learn.js CHANGED
@@ -68,13 +68,13 @@ export function proposals(all, options) {
68
68
  const decidedAt = new Map(options.decisions.map((d) => [d.id, d.count]));
69
69
  return found.filter((p) => !decidedAt.has(p.id) || p.count > (decidedAt.get(p.id) ?? 0));
70
70
  }
71
- // In the estate, not the canon install: a global npm install may be read-only and is replaced on upgrade.
71
+ // Under <home>/.claude/canon, not the canon install: a global npm install may be read-only and is replaced on upgrade.
72
72
  const DECISIONS_DIR = join(".claude", "canon");
73
73
  const DECISIONS = join(DECISIONS_DIR, "decisions.jsonl");
74
- export function readDecisions(estateRoot) {
75
- return readJsonLines(join(estateRoot, DECISIONS));
74
+ export function readDecisions(home) {
75
+ return readJsonLines(join(home, DECISIONS));
76
76
  }
77
- export function recordDecision(estateRoot, decision) {
78
- mkdirSync(join(estateRoot, DECISIONS_DIR), { recursive: true });
79
- appendFileSync(join(estateRoot, DECISIONS), `${JSON.stringify({ ...decision, at: new Date().toISOString() })}\n`);
77
+ export function recordDecision(home, decision) {
78
+ mkdirSync(join(home, DECISIONS_DIR), { recursive: true });
79
+ appendFileSync(join(home, DECISIONS), `${JSON.stringify({ ...decision, at: new Date().toISOString() })}\n`);
80
80
  }
@@ -1,7 +1,7 @@
1
1
  export interface Env {
2
2
  canonRoot: string;
3
- /** The estate folder (parent of several products), or null when there is none. */
4
- estateRoot: string | null;
3
+ /** The folder holding the product repos, or null when there is none. */
4
+ workspace: string | null;
5
5
  }
6
6
  /** For commands that only make sense across products. */
7
- export declare function requireEstate(env: Env): string;
7
+ export declare function requireWorkspace(env: Env): string;
@@ -1,6 +1,6 @@
1
1
  /** For commands that only make sense across products. */
2
- export function requireEstate(env) {
3
- if (env.estateRoot)
4
- return env.estateRoot;
5
- throw new Error("no estate found — put `estate: true` in .claude/canon.yaml of the folder that holds your products, or set CANON_ESTATE");
2
+ export function requireWorkspace(env) {
3
+ if (env.workspace)
4
+ return env.workspace;
5
+ throw new Error("no products found — run this in a product repo or the folder that holds them, or set CANON_WORKSPACE");
6
6
  }
@@ -22,10 +22,8 @@ export declare function listPacks(canonRoot: string): Pack[];
22
22
  export declare function findPack(canonRoot: string, name: string): Pack;
23
23
  interface AddOptions {
24
24
  canonRoot: string;
25
- global?: boolean;
26
- estateRoot?: string;
27
25
  }
28
- /** Records a pack in canon.yaml (product, or estate with --global), keeping the file's comments and other keys. */
26
+ /** Records a pack in the repo's canon.yaml, keeping the file's comments and other keys. */
29
27
  export declare function addPack(repo: string, name: string, options: AddOptions): {
30
28
  file: string;
31
29
  added: boolean;
package/cli/dist/packs.js CHANGED
@@ -33,16 +33,13 @@ export function findPack(canonRoot, name) {
33
33
  throw new Error(`no pack "${name}" — available: ${packs.map((p) => p.name).join(", ")}`);
34
34
  return pack;
35
35
  }
36
- /** Records a pack in canon.yaml (product, or estate with --global), keeping the file's comments and other keys. */
36
+ /** Records a pack in the repo's canon.yaml, keeping the file's comments and other keys. */
37
37
  export function addPack(repo, name, options) {
38
38
  findPack(options.canonRoot, name);
39
- if (options.global && !options.estateRoot)
40
- throw new Error("--global needs an estate (a folder whose canon.yaml says estate: true)");
41
- const base = options.global ? options.estateRoot : repo;
42
- const file = join(base, ".claude", "canon.yaml");
43
- if (!existsSync(file) && !options.global)
39
+ const file = join(repo, ".claude", "canon.yaml");
40
+ if (!existsSync(file))
44
41
  throw new Error(`${repo} has no .claude/canon.yaml — run \`canon init\` first`);
45
- const document = parseDocument(existsSync(file) ? readFileSync(file, "utf8") : "");
42
+ const document = parseDocument(readFileSync(file, "utf8"));
46
43
  const current = document.get("packs")?.toJSON() ?? [];
47
44
  if (current.includes(name))
48
45
  return { file, added: false };
@@ -9,7 +9,7 @@ export function canonVersion(canonRoot) {
9
9
  return JSON.parse(readFileSync(manifest, "utf8")).version ?? "unknown";
10
10
  }
11
11
  export function buildStatus(repo, options) {
12
- const { config, sources } = loadConfig(repo, { estateRoot: options.estateRoot });
12
+ const { config, sources } = loadConfig(repo);
13
13
  const report = runChecks(repo, options);
14
14
  const errors = report.findings.filter((f) => f.level === "error").length;
15
15
  const warnings = report.findings.length - errors;
@@ -1,7 +1,6 @@
1
1
  import { type CommandRunner } from "./packs.ts";
2
2
  export interface SyncOptions {
3
3
  canonRoot: string;
4
- estateRoot?: string;
5
4
  cacheDir: string;
6
5
  run: CommandRunner;
7
6
  fetchText: (url: string) => Promise<string>;
package/cli/dist/sync.js CHANGED
@@ -10,7 +10,7 @@ export async function syncRepo(repo, options) {
10
10
  const file = join(repo, ".claude", "canon.yaml");
11
11
  if (!existsSync(file))
12
12
  throw new Error(`${repo} has no .claude/canon.yaml — run \`canon init\` first`);
13
- const { config } = loadConfig(repo, { estateRoot: options.estateRoot });
13
+ const { config } = loadConfig(repo);
14
14
  const packs = config.packs;
15
15
  for (const name of packs) {
16
16
  await fetchPackDocs(findPack(options.canonRoot, name), options);
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@freetesh/canon",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "description": "canon CLI: keep your domain knowledge and AI rules in your repo, and enforce them.",
5
5
  "license": "MIT",
6
6
  "author": "Ritesh Firodiya",
7
+ "homepage": "https://ritesh-firodiya.github.io/products/canon/",
7
8
  "repository": {
8
9
  "type": "git",
9
10
  "url": "git+https://github.com/ritesh-firodiya/canon-cli.git"
@@ -23,6 +24,8 @@
23
24
  "templates",
24
25
  "scripts/log-verdict.mjs",
25
26
  "scripts/check-designs.py",
27
+ "scripts/flow.py",
28
+ "scripts/arch-set.py",
26
29
  ".claude-plugin"
27
30
  ],
28
31
  "engines": {
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """Gives a design set its architecture screens, the same in every product.
3
+
4
+ canon arch-set <spec.json>
5
+
6
+ The spec names the set and says what to draw:
7
+ {
8
+ "set": "/abs/path/<product>/.context/designs/<surface>",
9
+ "lead": "One or two sentences: how the product is built.",
10
+ "diagrams": [["who-uses", "users", "Who uses it"], ...], # slug, lucide icon, title
11
+ "stack": [{"title": "App · src/", "icon": "smartphone", "rows": [["Expo", "57", "what it is for"], ...]}, ...],
12
+ "own": "One sentence: the product's own code.",
13
+ "cost": {"title": "Cost a month", "rows": [["RevenueCat", "purchases", "$0"], ...], "total": "$0", "note": "one sentence"}
14
+ }
15
+
16
+ Each diagram is an asset Archify already drew: <product>/.context/designs/architecture/<slug>.json and .html.
17
+ This writes <set>/architecture/overview.html and one screen per diagram, and declares them in routes.js:
18
+ the `architecture` field, an `architecture` flow, and an edge from the overview to each diagram.
19
+ A phone set draws these screens without the phone frame, full width under the bar; a wide set keeps its frame.
20
+ """
21
+ import json, os, re, sys
22
+
23
+ spec = json.load(open(sys.argv[1]))
24
+ SET = spec["set"]
25
+ ASSETS = os.path.normpath(os.path.join(SET, "..", "architecture"))
26
+ os.chdir(SET)
27
+
28
+ R = open("routes.js").read()
29
+ JSON_STYLE = '"product":' in R # a manifest written with quoted keys
30
+ product = re.search(r'"?product"?:\s*"([^"]+)"', R).group(1)
31
+ fw = int(re.search(r'"?frame"?:\s*\{\s*"?w"?:\s*(\d+)', R).group(1))
32
+ WIDE = fw >= 800
33
+ BOX = "rounded-xl border border-line bg-surface"
34
+
35
+
36
+ def page(file, title, body):
37
+ if WIDE:
38
+ wrap_open = '<div class="stage">\n <div class="phone">\n <div class="screen h-frame bg-page text-ink flex flex-col">\n'
39
+ wrap_close = '\n </div>\n </div>\n</div>\n'
40
+ else:
41
+ # A phone frame is 390px: too narrow to read a diagram. Fill the window under the bar instead.
42
+ wrap_open = '<div class="bg-page text-ink flex flex-col" style="height:calc(100dvh - var(--chrome-h, 48px))">\n'
43
+ wrap_close = '\n</div>\n'
44
+ return ('''<!doctype html>
45
+ <html lang="en">
46
+ <head>
47
+ <meta charset="utf-8" />
48
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
49
+ <title>%s — %s</title>
50
+ <script src="https://cdn.tailwindcss.com/3.4.17"></script>
51
+ <script src="../tailwind.config.js"></script>
52
+ <script src="https://unpkg.com/lucide@0.544.0/dist/umd/lucide.js"></script>
53
+ <link rel="stylesheet" href="../shared.css" />
54
+ </head>
55
+ <body data-chrome data-file="architecture/%s">
56
+
57
+ ''' % (product, title, file) + wrap_open + body + wrap_close + '''
58
+ <script src="../routes.js"></script>
59
+ <script src="../_chrome.js"></script>
60
+ <script>lucide.createIcons();</script>
61
+ </body>
62
+ </html>
63
+ ''')
64
+
65
+
66
+ os.makedirs("architecture", exist_ok=True)
67
+
68
+ # ---- one screen per diagram: Archify's embed mode, in a frame of the diagram's own shape
69
+ for slug, icon, title in spec["diagrams"]:
70
+ src = open(os.path.join(ASSETS, slug + ".html")).read()
71
+ w, h = map(float, re.search(r'<svg[^>]*viewBox="0 0 ([0-9.]+) ([0-9.]+)"', src).groups())
72
+ body = ('<div class="flex-1 min-h-0 flex justify-center bg-page"><iframe src="../../architecture/%s.html?embed=1&amp;theme=light" title="%s" '
73
+ 'class="border-0 max-w-full" style="height:100%%;aspect-ratio:%d / %d"></iframe></div>') % (slug, title, w, h + 24)
74
+ open("architecture/%s.html" % slug, "w").write(page(slug + ".html", "Architecture · " + title, body))
75
+
76
+ # ---- the overview: the diagrams, the stack, the cost
77
+ cards = "".join('<a href="%s.html" class="%s px-4 h-14 flex items-center gap-3 text-sm font-medium hover:border-line-strong"><i data-lucide="%s" class="w-4 h-4 text-ink-3"></i>%s'
78
+ '<i data-lucide="arrow-right" class="ml-auto w-4 h-4 text-ink-4"></i></a>' % (s, BOX, i, t) for s, i, t in spec["diagrams"])
79
+
80
+
81
+ def table(t):
82
+ return ('<div class="%s overflow-hidden"><div class="px-4 h-10 flex items-center gap-2 border-b border-line text-sm font-semibold"><i data-lucide="%s" class="w-4 h-4 text-ink-3"></i>%s</div>'
83
+ % (BOX, t["icon"], t["title"])
84
+ + "".join('<div class="px-4 h-9 flex items-center gap-3 border-t border-line first:border-t-0 text-sm"><span class="w-40 shrink-0 font-medium truncate">%s</span>'
85
+ '<span class="w-20 shrink-0 font-mono text-xs text-ink-3">%s</span><span class="flex-1 min-w-0 truncate text-ink-3">%s</span></div>' % tuple(r) for r in t["rows"])
86
+ + '</div>')
87
+
88
+
89
+ c = spec["cost"]
90
+ cost = ("".join('<div class="px-4 h-9 flex items-center gap-3 border-t border-line first:border-t-0 text-sm"><span class="w-40 font-medium">%s</span>'
91
+ '<span class="flex-1 text-ink-3">%s</span><span class="num">%s</span></div>' % tuple(r) for r in c["rows"])
92
+ + '<div class="px-4 h-10 flex items-center border-t border-line text-sm font-semibold"><span>A month</span><span class="ml-auto num">%s</span></div>' % c["total"])
93
+ overview = ('<div class="flex-1 min-h-0 overflow-y-auto"><main class="max-w-[1180px] mx-auto px-6 py-8">'
94
+ '<h1 class="text-h2 font-semibold">How %s is built</h1>' % product
95
+ + '<p class="mt-2 text-base text-ink-3 max-w-[760px]">%s</p>' % spec["lead"]
96
+ + '<div class="mt-6 grid grid-cols-3 gap-3">' + cards + '</div>'
97
+ + '<h2 class="mt-10 text-h3 font-semibold">Stack</h2><div class="mt-3 grid grid-cols-2 gap-3">' + "".join(table(t) for t in spec["stack"]) + '</div>'
98
+ + '<p class="mt-3 text-sm text-ink-3">%s</p>' % spec["own"]
99
+ + '<h2 class="mt-10 text-h3 font-semibold">%s</h2>' % c["title"]
100
+ + '<div class="mt-3 grid grid-cols-[1fr_320px] gap-3 items-start"><div class="%s overflow-hidden">%s</div>' % (BOX, cost)
101
+ + '<div class="%s p-4 text-sm text-ink-3">%s</div></div>' % (BOX, c["note"])
102
+ + '</main></div>')
103
+ open("architecture/overview.html", "w").write(page("overview.html", "Architecture", overview))
104
+
105
+ rows = [("overview", "Architecture")] + [(s, t) for s, _, t in spec["diagrams"]]
106
+ if JSON_STYLE:
107
+ # ---- routes.js, JSON-style: the field, the flow, the edges
108
+ if '"architecture": "architecture/overview.html"' not in R:
109
+ R = re.sub(r'(\n(\s*)"product":\s*"[^"]+",\n)', lambda m: m.group(1) + m.group(2) + '"architecture": "architecture/overview.html",\n', R, 1)
110
+ R = re.sub(r',\n \{\n "flow": "architecture",.*?\n \}(?=\n \])', "", R, flags=re.S)
111
+ items = ",\n".join(' {\n "file": "architecture/%s.html",\n "route": null,\n "screen": "%s",\n "states": {},\n "status": "review",\n "notes": "architecture"\n }' % r for r in rows)
112
+ block = ',\n {\n "flow": "architecture",\n "screens": [\n' + items + '\n ]\n }'
113
+ m = re.search(r'\n "flows": \[', R)
114
+ end = R.index("\n ],", m.end())
115
+ R = R[:end] + block + R[end:]
116
+ R = re.sub(r'\n\s*\{ "from": "architecture/[^"]+", "to": "architecture/[^"]+"[^}]*\},', "", R)
117
+ edges = "".join('\n { "from": "architecture/overview.html", "to": "architecture/%s.html", "side": true },' % s for s, _, _ in spec["diagrams"])
118
+ m = re.search(r'\n\s*"edges": \[', R)
119
+ R = R[:m.end()] + edges + R[m.end():]
120
+ open("routes.js", "w").write(R)
121
+
122
+ else:
123
+ # ---- routes.js: the field, the flow, the edges
124
+ files = ["architecture/overview.html"] + ["architecture/%s.html" % s for s, _, _ in spec["diagrams"]]
125
+ if "architecture:" not in R:
126
+ R = re.sub(r'(\n(\s*)product:\s*"[^"]+",\n)', lambda m: m.group(1) + m.group(2) + 'architecture: "architecture/overview.html",\n', R, 1)
127
+ R = re.sub(r'\n \{ flow: "architecture", screens: \[.*?\n \]\},', "", R, flags=re.S)
128
+ block = ('\n { flow: "architecture", screens: [\n' + "".join(
129
+ ' { file: "architecture/%s.html", route: null, screen: "%s", status: "needs review", notes: "architecture" },\n' % r for r in rows) + ' ]},')
130
+ m = re.search(r'\n flows: \[', R)
131
+ end = R.index("\n ],", m.end())
132
+ R = R[:end] + block + R[end:]
133
+ R = re.sub(r'\n\s*\{ from: "architecture/[^"]+", to: "architecture/[^"]+"[^}]*\},', "", R)
134
+ edges = "".join('\n { from: "architecture/overview.html", to: "architecture/%s.html", side: true },' % s for s, _, _ in spec["diagrams"])
135
+ m = re.search(r'\n\s*edges: \[', R)
136
+ if m:
137
+ R = R[:m.end()] + edges + R[m.end():]
138
+ open("routes.js", "w").write(R)
139
+
140
+ print("%s: %d architecture screens, %s" % (product, len(rows), "wide" if WIDE else "phone"))
@@ -251,7 +251,7 @@ head('architecture — the overview is declared and every framed diagram has its
251
251
  arch_page = R.get('architecture')
252
252
  arch_flow = next((fl for fl in R['flows'] if fl.get('flow') == 'architecture'), None)
253
253
  if not arch_page:
254
- bad('routes.js names no `architecture` page — run bin/arch-set with this set\'s spec')
254
+ bad('routes.js names no `architecture` page — run `canon arch-set` with this set\'s spec')
255
255
  elif arch_page not in declared_files:
256
256
  bad('`architecture: "%s"` is not a declared screen' % arch_page)
257
257
  if arch_flow is None: