@struq/skill 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +22 -36
  2. package/package.json +3 -2
  3. package/skills/struq/SKILL.md +139 -307
  4. package/skills/struq/blocks/CATALOG.md +426 -0
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
  8. package/skills/struq/blocks/catalog.json +1302 -0
  9. package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
  10. package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
  11. package/skills/struq/blocks/headers/header-bar.tsx +652 -0
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
  13. package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
  14. package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
  15. package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
  16. package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
  17. package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
  18. package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
  19. package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +2 -2
  20. package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
  21. package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
  22. package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
  23. package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
  24. package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
  25. package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
  26. package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/images.ts +17 -0
  29. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  30. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  31. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  32. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  33. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  34. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  35. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  36. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  37. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  38. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  39. package/skills/struq/blocks/sections/approach.tsx +661 -0
  40. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  41. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  42. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  43. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  44. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  45. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  46. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  47. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  48. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  49. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  50. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  51. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  52. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  53. package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
  54. package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
  55. package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
  56. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  57. package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
  58. package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
  59. package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
  60. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  61. package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
  62. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  63. package/skills/struq/canon/canon.json +627 -0
  64. package/skills/struq/cinematic/README.md +7 -18
  65. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  66. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  67. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  68. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  69. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  70. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  71. package/skills/struq/recipes/app/fonts.ts +12 -22
  72. package/skills/struq/recipes/app/globals.css +34 -177
  73. package/skills/struq/recipes/app/layout.tsx +13 -11
  74. package/skills/struq/references/craft.md +77 -0
  75. package/skills/struq/references/iconography.md +28 -230
  76. package/skills/struq/references/stack-targets.md +23 -74
  77. package/skills/struq/references/tells.md +52 -0
  78. package/skills/struq/references/triage.md +6 -7
  79. package/skills/struq/scripts/blocks.mjs +156 -0
  80. package/skills/struq/scripts/canon.mjs +106 -281
  81. package/skills/struq/scripts/check.mjs +92 -126
  82. package/skills/struq/scripts/images.mjs +2 -2
  83. package/skills/struq/scripts/log.mjs +6 -23
  84. package/skills/struq/scripts/tokens.mjs +222 -0
  85. package/skills/struq/cinematic/showpieces/README.md +0 -18
  86. package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
  87. package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
  88. package/skills/struq/references/anti-patterns.md +0 -330
  89. package/skills/struq/references/baseline-prompt.md +0 -122
  90. package/skills/struq/references/brand.md +0 -143
  91. package/skills/struq/references/brief-router.md +0 -218
  92. package/skills/struq/references/composition.md +0 -190
  93. package/skills/struq/references/depth-parallax.md +0 -235
  94. package/skills/struq/references/direction.md +0 -188
  95. package/skills/struq/references/feel-layer.md +0 -107
  96. package/skills/struq/references/gsap-api.md +0 -293
  97. package/skills/struq/references/hero-scenes.md +0 -107
  98. package/skills/struq/references/imagery.md +0 -125
  99. package/skills/struq/references/loaders.md +0 -287
  100. package/skills/struq/references/micro-interactions.md +0 -270
  101. package/skills/struq/references/mobile-composition.md +0 -70
  102. package/skills/struq/references/nav-header.md +0 -296
  103. package/skills/struq/references/niche-packets.md +0 -338
  104. package/skills/struq/references/page-architecture.md +0 -134
  105. package/skills/struq/references/page-entrances.md +0 -198
  106. package/skills/struq/references/performance.md +0 -235
  107. package/skills/struq/references/pinning-scrub.md +0 -340
  108. package/skills/struq/references/pitfalls.md +0 -290
  109. package/skills/struq/references/route-transitions.md +0 -281
  110. package/skills/struq/references/scaling.md +0 -151
  111. package/skills/struq/references/scroll-reveals.md +0 -279
  112. package/skills/struq/references/sections.md +0 -141
  113. package/skills/struq/references/seo.md +0 -219
  114. package/skills/struq/references/setup.md +0 -397
  115. package/skills/struq/references/showcase.md +0 -87
  116. package/skills/struq/references/struq-imagery.md +0 -107
  117. package/skills/struq/references/svg-motion.md +0 -390
  118. package/skills/struq/references/transitions.md +0 -160
  119. package/skills/struq/references/typography.md +0 -720
  120. package/skills/struq/references/vibe-presets.md +0 -287
  121. package/skills/struq/references/webgl-r3f.md +0 -274
  122. package/skills/struq/references/webgl-scenes.md +0 -593
  123. package/skills/struq/scripts/font-names.mjs +0 -40
  124. package/skills/struq/scripts/recipes.mjs +0 -135
  125. /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
  126. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
  127. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
  128. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
  129. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ // Struq blocks, offline: the skill's own copy of Struq's admitted blocks (blocks/, catalog.json), installed into a
3
+ // Next.js App Router project. The same files the MCP's struq_blok returns.
4
+ //
5
+ // node <skill-dir>/scripts/blocks.mjs list [kind] the catalogue, one line per block with its variants
6
+ // node <skill-dir>/scripts/blocks.mjs init the kit, the app shell (on a starter) and the base packages
7
+ // node <skill-dir>/scripts/blocks.mjs add <slug> [<slug>] a block and every file it imports, plus its packages
8
+ // flags: --dir <project>, --force (replace files you already have), --no-install
9
+ //
10
+ // Everything lands under components/struq/ in the layout of Struq's blocks/ (kit/, heroes/, sections/ ...), so the
11
+ // relative imports between them hold. A layout or stylesheet you wrote yourself is never overwritten.
12
+ import { spawnSync } from "node:child_process";
13
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
14
+ import { dirname, join, relative, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { appendLog } from "./log.mjs";
17
+
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+ const BLOCKS = join(here, "..", "blocks");
20
+ const RECIPES = join(here, "..", "recipes");
21
+ const CATALOG = JSON.parse(readFileSync(join(BLOCKS, "catalog.json"), "utf8")).blocks;
22
+
23
+ const args = process.argv.slice(2);
24
+ const dirAt = args.indexOf("--dir");
25
+ const root = resolve(dirAt >= 0 ? args[dirAt + 1] : process.cwd());
26
+ const force = args.includes("--force");
27
+ const install = !args.includes("--no-install");
28
+ const [cmd, ...rest] = args.filter((x, i) => !x.startsWith("--") && (dirAt < 0 || i !== dirAt + 1));
29
+
30
+ const src = existsSync(join(root, "src", "app")) ? "src" : "";
31
+ const target = (...p) => join(root, src, "components", "struq", ...p);
32
+ const copied = [];
33
+ const kept = [];
34
+ const notes = [];
35
+
36
+ function place(from, to, overwrite = force) {
37
+ if (existsSync(to) && !overwrite) return kept.push(relative(root, to));
38
+ mkdirSync(dirname(to), { recursive: true });
39
+ copyFileSync(from, to);
40
+ copied.push(relative(root, to).split("\\").join("/"));
41
+ }
42
+
43
+ function walk(dir) {
44
+ return readdirSync(dir).flatMap((n) => (statSync(join(dir, n)).isDirectory() ? walk(join(dir, n)) : [join(dir, n)]));
45
+ }
46
+
47
+ function project() {
48
+ const pkgPath = join(root, "package.json");
49
+ if (!existsSync(pkgPath)) {
50
+ console.error(`FAIL no package.json in ${root}. Scaffold first: npx create-next-app@latest . --ts --tailwind --app --eslint --yes`);
51
+ process.exit(2);
52
+ }
53
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
54
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
55
+ if (!deps.next) {
56
+ console.error("FAIL not a Next.js project. The blocks use next/image and next/link; for Vite read references/stack-targets.md.");
57
+ process.exit(2);
58
+ }
59
+ return deps;
60
+ }
61
+
62
+ function installMissing(deps, wanted) {
63
+ const need = [...wanted].filter((d) => !deps[d]);
64
+ const dev = need.filter((d) => d.startsWith("@types/"));
65
+ const runtime = need.filter((d) => !d.startsWith("@types/"));
66
+ for (const [list, flag] of [[runtime, ""], [dev, "-D "]]) {
67
+ if (!list.length) continue;
68
+ const line = `npm install ${flag}${list.join(" ")}`;
69
+ if (!install) {
70
+ notes.push(`Run: ${line}`);
71
+ continue;
72
+ }
73
+ console.log(`> ${line}`);
74
+ if (spawnSync(line, { cwd: root, stdio: "inherit", shell: true }).status !== 0) notes.push(`The install failed. Run it yourself: ${line}`);
75
+ }
76
+ }
77
+
78
+ function report(step, what) {
79
+ console.log(`\nCopied ${copied.length} file(s)${copied.length ? ":" : "."}`);
80
+ for (const f of copied) console.log(` ${f}`);
81
+ if (kept.length) console.log(`Kept ${kept.length} existing file(s) (--force replaces them).`);
82
+ for (const n of notes) console.log(`NOTE ${n}`);
83
+ appendLog(root, step, [what, ...(notes.length ? [`Notes: ${notes.join(" ")}`] : [])]);
84
+ }
85
+
86
+ const commands = {
87
+ list() {
88
+ const kind = rest[0];
89
+ const rows = CATALOG.filter((b) => !kind || b.kind === kind);
90
+ if (!rows.length) throw new Error(`No blocks of kind "${kind}". Kinds: ${[...new Set(CATALOG.map((b) => b.kind))].join(", ")}`);
91
+ for (const b of rows) {
92
+ console.log(`${b.kind.padEnd(16)} ${b.slug.padEnd(24)} ${b.title}${b.variants.length ? ` [${b.variants.map((v) => v.id).join(", ")}]` : ""}`);
93
+ }
94
+ console.log(`\n${rows.length} block(s). What each does and its variants: blocks/CATALOG.md.`);
95
+ },
96
+
97
+ init() {
98
+ const deps = project();
99
+ // The kit, minus the WebGL hero layer: HeroCanvas and its scenes need three, so they come with hero-opener.
100
+ for (const f of walk(join(BLOCKS, "kit"))) {
101
+ const r = relative(join(BLOCKS, "kit"), f).split("\\").join("/");
102
+ if (r === "HeroCanvas.tsx" || r.startsWith("scenes/")) continue;
103
+ place(f, target("kit", r));
104
+ }
105
+ const appDir = join(root, src, "app");
106
+ const read = (p) => (existsSync(p) ? readFileSync(p, "utf8") : "");
107
+ const layout = read(join(appDir, "layout.tsx"));
108
+ if (!layout || /Create Next App|Geist/.test(layout) || force) place(join(RECIPES, "app", "layout.tsx"), join(appDir, "layout.tsx"), true);
109
+ else if (!/data-blocks/.test(layout)) {
110
+ notes.push("app/layout.tsx is your own: add the sq-js script, the font links, data-blocks on <body> and <SmoothScroll> (recipes/app/layout.tsx).");
111
+ }
112
+ const css = read(join(appDir, "globals.css"));
113
+ if (!css || (!css.includes("STRUQ TOKENS") && /--background|--foreground|Arial|prefers-color-scheme/.test(css)) || force) {
114
+ place(join(RECIPES, "app", "globals.css"), join(appDir, "globals.css"), true);
115
+ } else if (!css.includes("STRUQ TOKENS")) notes.push("app/globals.css is your own: add the kit import and the STRUQ TOKENS block from recipes/app/globals.css.");
116
+ place(join(RECIPES, "app", "fonts.ts"), join(appDir, "fonts.ts"));
117
+ for (const starter of ["next.svg", "vercel.svg", "file.svg", "globe.svg", "window.svg"]) rmSync(join(root, "public", starter), { force: true });
118
+ installMissing(deps, ["gsap", "@gsap/react", "lenis"]);
119
+ report("Step 1 Project", `blocks.mjs init copied the kit and ${copied.length} file(s) in all, kept ${kept.length}.`);
120
+ console.log("OK init ready");
121
+ },
122
+
123
+ add() {
124
+ if (!rest.length) throw new Error("Usage: blocks.mjs add <slug> [<slug> ...]. The slugs: blocks.mjs list");
125
+ const deps = project();
126
+ const wanted = new Set();
127
+ const added = [];
128
+ for (const slug of rest) {
129
+ const b = CATALOG.find((x) => x.slug === slug);
130
+ if (!b) throw new Error(`Unknown block "${slug}". Run: blocks.mjs list`);
131
+ for (const f of b.files) place(join(BLOCKS, f), target(f));
132
+ for (const p of b.packages) wanted.add(p);
133
+ added.push(b);
134
+ }
135
+ installMissing(deps, wanted);
136
+ for (const b of added) {
137
+ const from = `@/components/struq/${b.file.replace(/\.tsx?$/, "")}`;
138
+ console.log(`\n${b.slug}: import { ${b.exports.join(", ")} } from "${from}";`);
139
+ if (b.variants.length) console.log(` variant: ${b.variants.map((v) => `"${v.id}"`).join(" | ")} (default "${b.variants[0].id}")`);
140
+ }
141
+ report("Step 5 Blocks", `blocks.mjs added ${added.map((b) => b.slug).join(", ")}.`);
142
+ console.log("OK blocks added");
143
+ },
144
+ };
145
+
146
+ const run = commands[cmd];
147
+ if (!run) {
148
+ console.error("Usage: blocks.mjs list [kind] | init | add <slug> [<slug> ...] [--dir <project>] [--force] [--no-install]");
149
+ process.exit(2);
150
+ }
151
+ try {
152
+ run();
153
+ } catch (e) {
154
+ console.error(`FAIL ${e.message}`);
155
+ process.exitCode = 1;
156
+ }
@@ -1,309 +1,134 @@
1
1
  #!/usr/bin/env node
2
- // Struq canon: Struq's own palettes, type pairings and design systems, written into the project.
3
- // This is what keeps a Struq build off the defaults every AI falls back on.
2
+ // Struq canon, offline: Struq's palettes and type pairings, written into the project as the one token block the
3
+ // blocks read. The same block, derivation and font requests as Struq's block lab and the MCP's struq_richting
4
+ // (scripts/tokens.mjs), from the snapshot in canon/canon.json that the skill's release copies from Struq.
4
5
  //
5
- // node <skill-dir>/scripts/canon.mjs list every palette, pairing, system and pack
6
- // node <skill-dir>/scripts/canon.mjs palette <slug> writes the STRUQ TOKENS block in globals.css
7
- // node <skill-dir>/scripts/canon.mjs type <slug> writes app/fonts.ts (Google or Fontshare)
8
- // node <skill-dir>/scripts/canon.mjs type --display "Fraunces" --body "Urbanist"
9
- // node <skill-dir>/scripts/canon.mjs system <slug> prints the section order and its refs
6
+ // node <skill-dir>/scripts/canon.mjs list every palette and pairing
7
+ // node <skill-dir>/scripts/canon.mjs palette <id> writes the STRUQ TOKENS block in app/globals.css
8
+ // node <skill-dir>/scripts/canon.mjs type <id> the same block with the pairing, and app/fonts.ts
9
+ // node <skill-dir>/scripts/canon.mjs print <palette> <pairing> prints the block and the font URLs, writes nothing
10
10
  // add --dir <project> to run from elsewhere
11
11
  //
12
- // Public API, no account: https://struq.nl/api/assets?filter=palette|typography|design_system
13
- // Node 18 or later, no dependencies. Google faces load through next/font/google (checked against the
14
- // project's own next/font data); faces that are not on Google come from Fontshare as local woff2.
15
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
- import { join, resolve } from "node:path";
17
- import { googleName } from "./font-names.mjs";
12
+ // Node 18 or later, no network, no dependencies.
13
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
18
16
  import { appendLog } from "./log.mjs";
17
+ import { fontHrefs, headFamily, lightness, paletteTokens } from "./tokens.mjs";
19
18
 
20
- // STRUQ_API_BASE points a test run at another Struq environment (for example https://staging.struq.nl).
21
- // Real users never set it: the default is production.
22
- const API = `${(process.env.STRUQ_API_BASE || "https://struq.nl").replace(/\/+$/, "")}/api`;
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const CANON = JSON.parse(readFileSync(join(here, "..", "canon", "canon.json"), "utf8"));
23
21
  const args = process.argv.slice(2);
24
- const opt = (name) => {
25
- const i = args.indexOf(name);
26
- return i >= 0 ? args[i + 1] : undefined;
27
- };
28
- const root = resolve(opt("--dir") ?? process.cwd());
29
- const [cmd, slugArg] = args.filter((a, i) => !a.startsWith("--") && !["--dir", "--display", "--body", "--accent"].includes(args[i - 1]));
30
-
31
- // Struq's own rules win over a canon entry: these faces never ship (SKILL.md, the tell list).
32
- const BANNED = new Set(["Inter", "Roboto", "Arial", "Helvetica", "Open Sans", "Poppins", "Montserrat", "Lato", "Geist", "Space Grotesk", "Geist Mono", "JetBrains Mono", "Space Mono", "IBM Plex Mono", "Roboto Mono"]);
33
- // The text face that replaces a banned one, by the display face it sits under.
34
- const BODY_FOR = { Fraunces: "Urbanist", Newsreader: "Manrope", Sora: "Instrument Sans", Syne: "Hanken Grotesk", Manrope: "Figtree" };
35
- // Commercial faces in the canon, and their free stand-ins.
36
- const STAND_IN = { "Neue Haas Grotesk Text": "General Sans", "Neue Haas Grotesk": "General Sans", "PP Neue Montreal": "General Sans", "Söhne": "General Sans", Canela: "Newsreader", "PP Editorial New": "Instrument Serif", "GT Sectra": "Fraunces" };
22
+ const dirAt = args.indexOf("--dir");
23
+ const root = resolve(dirAt >= 0 ? args[dirAt + 1] : process.cwd());
24
+ const [cmd, a, b] = args.filter((_, i) => dirAt < 0 || (i !== dirAt && i !== dirAt + 1));
37
25
 
38
- async function getJson(url) {
39
- const res = await fetch(url, { signal: AbortSignal.timeout(20000), headers: { accept: "application/json" } });
40
- if (!res.ok) throw new Error(`${url} answered ${res.status}`);
41
- return res.json();
42
- }
43
- async function canon(filter) {
44
- const all = [];
45
- for (let page = 1; page <= 10; page++) {
46
- const d = await getJson(`${API}/assets?filter=${filter}&page=${page}`);
47
- all.push(...(d.items ?? []));
48
- if (!d.hasMore) break;
49
- }
50
- return all;
51
- }
52
-
53
- function parseOklch(v) {
54
- const m = String(v).match(/oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)/i);
55
- if (!m) return null;
56
- const L = m[1].endsWith("%") ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
57
- return { L, C: parseFloat(m[2]), H: parseFloat(m[3]) };
58
- }
59
- function hexLightness(v) {
60
- const m = String(v).match(/^#([0-9a-f]{6})$/i);
61
- if (!m) return null;
62
- const [r, g, b] = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16) / 255);
63
- return (Math.max(r, g, b) + Math.min(r, g, b)) / 2;
64
- }
65
- const lightness = (v) => parseOklch(v)?.L ?? hexLightness(v) ?? 0.5;
66
- const isCool = (v) => {
67
- const o = parseOklch(v);
68
- return o ? o.C > 0.06 && o.H >= 220 && o.H <= 320 : false;
26
+ const palette = (id) => {
27
+ const p = CANON.palettes.find((x) => x.id === id);
28
+ if (!p) throw new Error(`Unknown palette "${id}". Run: canon.mjs list`);
29
+ return p;
69
30
  };
31
+ const pairing = (id) => {
32
+ const t = CANON.pairings.find((x) => x.id === id);
33
+ if (!t) throw new Error(`Unknown pairing "${id}". Run: canon.mjs list`);
34
+ return t;
35
+ };
36
+ const faces = (t) => [t.display, t.body, t.accentItalic].filter(Boolean).map(headFamily);
70
37
 
71
- /* ------------------------------------------------------------------ list */
72
- async function list() {
73
- const [palettes, types, systems] = await Promise.all([canon("palette"), canon("typography"), canon("design_system")]);
74
- console.log(`PALETTES (${palettes.length}): slug, mood, ground, accent`);
75
- for (const p of palettes) {
76
- const c = p.data?.colors ?? {};
77
- const ground = lightness(c.background) > 0.5 ? "light" : "dark";
78
- const note = isCool(c.accent) ? " (blue or purple accent: only for a brand that is that colour)" : "";
79
- console.log(` ${p.slug.padEnd(38)} ${(p.data?.mood ?? "").padEnd(36)} ${ground.padEnd(6)}${note}`);
80
- }
81
- console.log(`\nPAIRINGS (${types.length}): slug, display + body as Struq builds them`);
82
- for (const t of types) {
83
- const plan = planType(t.data.display.name, t.data.body.name);
84
- console.log(` ${t.slug.padEnd(28)} ${plan.display} + ${plan.body}${plan.notes.length ? ` (${plan.notes.join("; ")})` : ""}`);
85
- }
86
- console.log(`\nDESIGN SYSTEMS (${systems.length}): slug, section order`);
87
- for (const s of systems) console.log(` ${s.slug.padEnd(22)} ${(s.data?.silhouette?.sectionOrder ?? []).join(" > ")}`);
88
- try {
89
- const packs = await getJson(`${API}/media/packs`);
90
- console.log(`\nBEELDBANK PACKS (${packs.packCount}): use with images.mjs`);
91
- for (const p of packs.packs) console.log(` ${p.slug.padEnd(10)} ${p.description.slice(0, 100)}`);
92
- } catch (e) {
93
- console.log(`\nBEELDBANK: ${e.message}`);
94
- }
95
- }
96
-
97
- /* --------------------------------------------------------------- palette */
98
- async function palette(slug) {
99
- const items = await canon("palette");
100
- const p = items.find((i) => i.slug === slug);
101
- if (!p) throw new Error(`Unknown palette "${slug}". Run: canon.mjs list`);
102
- const c = p.data.colors;
103
- const dark = lightness(c.background) < 0.5;
104
- const accentInk = lightness(c.accent) > 0.62 ? (dark ? c.background : c.text) : dark ? c.text : c.background;
105
- const cssPath = ["app/globals.css", "src/app/globals.css"].map((f) => join(root, f)).find(existsSync);
106
- if (!cssPath) throw new Error("No app/globals.css. Run recipes.mjs first.");
107
- let css = readFileSync(cssPath, "utf8");
108
- const weight = css.match(/--display-weight:\s*(\d{3})/)?.[1] ?? "300";
109
- const block = `:root {
110
- /* canon palette: ${p.slug} (${p.data.mood}) */
111
- --surface-0: ${c.background}; /* the page */
112
- --surface-1: ${c.surface}; /* one step: panels, the showpiece ground */
113
- --surface-2: color-mix(in oklch, var(--surface-1), var(--ink) ${dark ? 9 : 7}%); /* image wells, hover grounds */
114
- --surface-3: color-mix(in oklch, var(--surface-1), var(--ink) ${dark ? 18 : 16}%); /* hairline accents, never text */
115
- --ink: ${c.text};
116
- --ink-muted: color-mix(in oklch, var(--ink) ${dark ? 70 : 66}%, var(--surface-0)); /* secondary text */
117
- --accent: ${c.accent}; /* the one accent: the key CTA, one highlight per screen */
118
- --accent-ink: ${accentInk}; /* text on the accent */
119
- --accent-2: ${c.accentAlt ?? c.surface}; /* a second ground, never a second highlight colour */
120
- --line: color-mix(in oklch, var(--ink) 13%, transparent); /* hairlines */
121
- --on-photo: ${dark ? c.text : c.background}; /* type over a photo, with a scrim */
122
- /* the contrast scene: the palette's inverse and field roles when it has them, else text and ground swapped */
123
- --inverse: ${c.inverse ?? (dark ? c.surface : c.text)};
124
- --on-inverse: ${c.onInverse ?? c.inverseText ?? (dark ? c.text : c.background)};
125
- --field: ${c.field ?? c.accent};
126
- --on-field: ${c.onField ?? c.fieldText ?? accentInk};
127
- --display-weight: ${weight};
128
- }`;
129
- const re = /(STRUQ TOKENS[\s\S]*?\*\/\s*):root\s*\{[\s\S]*?\n\}/;
130
- if (!re.test(css)) throw new Error(`No STRUQ TOKENS block in ${cssPath}. Run recipes.mjs, or merge the block from recipes/app/globals.css.`);
131
- css = css.replace(re, `$1${block}`);
132
- if (!css.includes("--color-accent-2")) css = css.replace(" --color-line: var(--line);", " --color-line: var(--line);\n --color-accent-2: var(--accent-2);");
133
- // Projects started before the contrast scene existed get its colour tokens too.
134
- if (!css.includes("--color-inverse")) {
135
- css = css.replace(" --color-line: var(--line);", " --color-line: var(--line);\n --color-inverse: var(--inverse);\n --color-on-inverse: var(--on-inverse);\n --color-field: var(--field);\n --color-on-field: var(--on-field);");
136
- }
137
- writeFileSync(cssPath, css);
138
- console.log(`Wrote palette ${p.slug} to ${cssPath.replace(root, ".")}`);
139
- if (isCool(c.accent)) console.log('NOTE blue or purple accent: write "Cool hue: declared because <the brand colour>" in STRUQ.md, or pick a warmer palette.');
140
- console.log(`STRUQ.md line: Palette: ${p.slug} (${p.data.mood})`);
141
- appendLog(root, "Step 4 Canon palette", [
142
- `canon.mjs wrote palette ${p.slug} (${p.data.mood}) into the STRUQ TOKENS block.`,
143
- `Ground ${dark ? "dark" : "light"}, accent ${c.accent}.${isCool(c.accent) ? " Blue or purple accent: needs a Cool hue declaration." : ""}`,
144
- ]);
145
- console.log("OK palette written");
38
+ export function tokenBlock(p, t) {
39
+ const lines = paletteTokens(p, t).map(([k, v]) => ` ${k}: ${v};`);
40
+ return `/* STRUQ TOKENS: palette ${p.id}, type ${t.id}. Written by canon.mjs, the block Struq's lab and struq_richting give. */\n[data-blocks] {\n${lines.join("\n")}\n}`;
146
41
  }
147
42
 
148
- /* ------------------------------------------------------------------ type */
149
- function planType(display, body) {
150
- const notes = [];
151
- let d = display;
152
- let b = body;
153
- if (STAND_IN[d]) notes.push(`${d} is commercial, ${STAND_IN[d]} stands in`), (d = STAND_IN[d]);
154
- if (STAND_IN[b]) notes.push(`${b} is commercial, ${STAND_IN[b]} stands in`), (b = STAND_IN[b]);
155
- if (BANNED.has(d)) notes.push(`${d} is off the Struq list: pick another pairing`), (d = null);
156
- if (BANNED.has(b)) {
157
- const swap = BODY_FOR[d] ?? "Urbanist";
158
- notes.push(`${b} is off the Struq list, ${swap} replaces it`);
159
- b = swap;
160
- }
161
- return { display: d ?? "(none)", body: b, notes, ok: Boolean(d) };
162
- }
43
+ const BLOCK = /\/\* STRUQ TOKENS[^*]*\*\/\s*\[data-blocks\]\s*\{[\s\S]*?\n\}/;
163
44
 
164
- function googleData() {
165
- const p = join(root, "node_modules", "next", "dist", "compiled", "@next", "font", "dist", "google", "font-data.json");
166
- return existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : null;
45
+ function cssFile() {
46
+ const path = ["app/globals.css", "src/app/globals.css"].map((f) => join(root, f)).find(existsSync);
47
+ if (!path) throw new Error("No app/globals.css. Run blocks.mjs init first.");
48
+ return path;
167
49
  }
168
50
 
169
- async function fontshare(name, weights) {
170
- const slug = name.toLowerCase().replace(/\s+/g, "-");
171
- const css = await fetch(`https://api.fontshare.com/v2/css?f[]=${slug}@${weights.join(",")}&display=swap`, { signal: AbortSignal.timeout(20000) }).then((r) => (r.ok ? r.text() : ""));
172
- const faces = [...css.matchAll(/@font-face\s*\{([\s\S]*?)\}/g)].map((m) => ({
173
- url: m[1].match(/url\('([^']+\.woff2)'\)/)?.[1],
174
- weight: m[1].match(/font-weight:\s*(\d+)/)?.[1],
175
- style: m[1].match(/font-style:\s*(\w+)/)?.[1] ?? "normal",
176
- })).filter((f) => f.url && f.weight);
177
- if (!faces.length) return null;
178
- const dir = join(root, existsSync(join(root, "src", "app")) ? "src/app" : "app", "fonts");
179
- mkdirSync(dir, { recursive: true });
180
- const src = [];
181
- for (const f of faces) {
182
- const file = `${slug}-${f.weight}${f.style === "italic" ? "-italic" : ""}.woff2`;
183
- const res = await fetch(f.url.startsWith("//") ? `https:${f.url}` : f.url, { signal: AbortSignal.timeout(30000) });
184
- if (!res.ok) continue;
185
- writeFileSync(join(dir, file), Buffer.from(await res.arrayBuffer()));
186
- src.push({ path: `./fonts/${file}`, weight: f.weight, style: f.style });
187
- }
188
- return src.length ? src : null;
51
+ function write(p, t) {
52
+ const path = cssFile();
53
+ const css = readFileSync(path, "utf8");
54
+ if (!BLOCK.test(css)) throw new Error(`No STRUQ TOKENS block in ${path}. Run blocks.mjs init, or copy the block from recipes/app/globals.css.`);
55
+ writeFileSync(path, css.replace(BLOCK, tokenBlock(p, t)));
56
+ return path;
189
57
  }
190
58
 
191
- async function faceCode(canonName, role, google) {
192
- const variable = `--face-${role}`;
193
- const id = role;
194
- // The canon's name ("Big Shoulders Display") may be listed by Google under its family ("Big Shoulders").
195
- const name = googleName(canonName, google) ?? canonName;
196
- const g = google?.[name];
197
- if (g) {
198
- const ident = name.replace(/\s+/g, "_");
199
- const parts = [`subsets: ["latin"]`];
200
- const italicOnly = role === "accent";
201
- if (!g.weights.includes("variable")) {
202
- const wanted = g.weights.filter((w) => ["300", "400", "500"].includes(w));
203
- parts.push(`weight: ${JSON.stringify(wanted.length ? wanted : [g.weights[0]])}`);
204
- }
205
- const axes = (g.axes ?? []).map((a) => a.tag).filter((t) => ["opsz", "SOFT"].includes(t));
206
- if (axes.length && g.weights.includes("variable")) parts.push(`axes: ${JSON.stringify(axes)}`);
207
- if (italicOnly && g.styles.includes("italic")) parts.push(`style: ["italic"]`);
208
- else if (role === "display" && g.styles.includes("italic")) parts.push(`style: ["normal", "italic"]`);
209
- parts.push(`variable: "${variable}"`, `display: "swap"`);
210
- const light = g.weights.includes("300") ? 300 : g.axes?.find((a) => a.tag === "wght")?.min <= 300 ? 300 : 400;
211
- return { kind: "google", id, ident, family: name, code: `export const ${id} = ${ident}({ ${parts.join(", ")} });`, light };
212
- }
213
- const local = await fontshare(canonName, role === "accent" ? ["400"] : ["300", "400", "500"]);
214
- if (local) {
215
- const light = local.some((s) => s.weight === "300") ? 300 : 400;
216
- const src = local.map((s) => `{ path: "${s.path}", weight: "${s.weight}", style: "${s.style}" }`).join(", ");
217
- return { kind: "local", id, family: canonName, code: `export const ${id} = localFont({ src: [${src}], variable: "${variable}", display: "swap" });`, light };
218
- }
219
- return null;
59
+ function current() {
60
+ const css = readFileSync(cssFile(), "utf8");
61
+ const m = css.match(/STRUQ TOKENS: palette (\S+), type (\S+?)\./);
62
+ return { palette: m?.[1] ?? CANON.palettes[0].id, type: m?.[2] ?? CANON.pairings[0].id };
220
63
  }
221
64
 
222
- async function type(slug) {
223
- let display = opt("--display");
224
- let body = opt("--body");
225
- let accent = opt("--accent");
226
- let label = "custom";
227
- if (slug) {
228
- const items = await canon("typography");
229
- const t = items.find((i) => i.slug === slug);
230
- if (!t) throw new Error(`Unknown pairing "${slug}". Run: canon.mjs list`);
231
- display = t.data.display.name;
232
- body = t.data.body.name;
233
- accent = t.data.accent?.name;
234
- label = t.slug;
235
- }
236
- if (!display || !body) throw new Error('Usage: canon.mjs type <slug>, or type --display "Name" --body "Name" [--accent "Name"]');
237
- const plan = planType(display, body);
238
- for (const n of plan.notes) console.log(`NOTE ${n}`);
239
- if (!plan.ok) throw new Error("The display face is off the Struq list. Pick another pairing (canon.mjs list).");
240
- const google = googleData();
241
- if (!google) console.log("NOTE next is not installed here, so Google faces cannot be checked; install first for a sure result.");
242
- const d = await faceCode(plan.display, "display", google);
243
- const b = await faceCode(plan.body, "body", google);
244
- if (!d || !b) throw new Error(`Could not load ${!d ? plan.display : plan.body} from Google Fonts or Fontshare. Pick another pairing.`);
245
- // The italic accent: its own face when the canon names one apart from display and body; the
246
- // display face's italic otherwise (font-accent falls back to it in globals.css).
247
- const accentName = accent && !BANNED.has(accent) && accent !== plan.display && accent !== plan.body ? accent : null;
248
- const a = accentName ? await faceCode(accentName, "accent", google) : null;
249
- if (accentName && !a) console.log(`NOTE the accent face ${accentName} could not be loaded; the display italic carries the accent word.`);
250
- const faces = [d, b, a].filter(Boolean);
251
- const imports = [];
252
- const gIdents = faces.filter((f) => f.kind === "google").map((f) => f.ident);
253
- if (gIdents.length) imports.push(`import { ${[...new Set(gIdents)].join(", ")} } from "next/font/google";`);
254
- if (faces.some((f) => f.kind === "local")) imports.push(`import localFont from "next/font/local";`);
65
+ function writeFonts(t) {
66
+ const hrefs = fontHrefs(t, CANON);
255
67
  const file = join(root, existsSync(join(root, "src", "app")) ? "src/app" : "app", "fonts.ts");
256
68
  writeFileSync(
257
69
  file,
258
- `// app/fonts.ts: written by the Struq skill (scripts/canon.mjs type). Pairing: ${label}, ${d.family} + ${b.family}${a ? `, accent ${a.family} italic` : ""}.
259
- // Display and body, plus at most the one italic accent face. globals.css maps --face-display to
260
- // font-display, --face-body to font-body and --face-accent to font-accent (the display italic when absent).
261
- ${imports.join("\n")}
262
-
263
- ${faces.map((f) => f.code).join("\n\n")}
264
-
265
- /** Every face's CSS variable, for the <html> className in app/layout.tsx. */
266
- export const fontVariables = [${faces.map((f) => f.id).join(", ")}].map((f) => f.variable).join(" ");
70
+ `// app/fonts.ts: written by the Struq skill (canon.mjs type ${t.id}): ${faces(t).join(", ")}.
71
+ // Each Google face is asked for its real weight range, then as the bare family as a fallback, because Google refuses a
72
+ // request for a weight the family lacks. The layout puts one <link> per URL in <head>; the token block in globals.css
73
+ // names the faces (--face-display, --face-body, --face-accent), so the blocks measure with the real face.
74
+ export const fontHrefs: string[] = ${JSON.stringify(hrefs, null, 2)};
267
75
  `,
268
76
  );
269
- const cssPath = ["app/globals.css", "src/app/globals.css"].map((f) => join(root, f)).find(existsSync);
270
- if (cssPath) {
271
- let css = readFileSync(cssPath, "utf8").replace(/--display-weight:\s*\d{3}/, `--display-weight: ${d.light}`);
272
- if (!css.includes("--font-accent")) {
273
- css = css.replace(/(\s*--font-body:[^\n]*\n)/, `$1 --font-accent: var(--face-accent, var(--face-display)), ui-serif, Georgia, serif;\n`);
274
- }
275
- writeFileSync(cssPath, css);
276
- }
277
- console.log(`Wrote ${file.replace(root, ".")}: ${d.family} (${d.kind}) + ${b.family} (${b.kind})${a ? ` + accent ${a.family} italic (${a.kind})` : ""}, display weight ${d.light}`);
278
- console.log(`STRUQ.md line: Type: ${label}: ${d.family} + ${b.family}${a ? `, accent ${a.family}` : ""}`);
279
- appendLog(root, "Step 4 Canon type", [
280
- `canon.mjs wrote app/fonts.ts: ${d.family} (${d.kind}) over ${b.family} (${b.kind})${a ? `, accent ${a.family} italic` : ""}, pairing ${label}.`,
281
- `Display weight ${d.light}.${plan.notes.length ? ` Overrides: ${plan.notes.join("; ")}.` : ""}`,
282
- ]);
283
- console.log("OK type written");
77
+ return { file, hrefs };
284
78
  }
285
79
 
286
- /* ---------------------------------------------------------------- system */
287
- async function system(slug) {
288
- const items = await canon("design_system");
289
- const s = items.find((i) => i.slug === slug);
290
- if (!s) throw new Error(`Unknown design system "${slug}". Run: canon.mjs list`);
291
- const order = s.data?.silhouette?.sectionOrder ?? [];
292
- console.log(`${s.slug}: ${order.join(" > ")}`);
293
- console.log(`refs: palette ${s.data.paletteRef}, typography ${s.data.typographyRef} (your own palette and type picks win when the brief fits them better)`);
294
- console.log("Struq adds to every order: the hero canvas in the hero, one showpiece at the act break, the designed footer.");
295
- console.log("proof and story-proof hold only real facts from the brief; faq only questions the brief answers. Drop a section that would need invented content.");
296
- console.log(`STRUQ.md line: System: ${s.slug} (${order.join(" > ")})`);
297
- appendLog(root, "Step 2 Canon system", [`canon.mjs read design system ${s.slug}: ${order.join(" > ")}.`]);
298
- console.log("OK system read");
299
- }
80
+ const commands = {
81
+ list() {
82
+ console.log(`PALETTES (${CANON.palettes.length}): id, mood, ground. "kept out" means kept for a brand that asks, never picked unasked.`);
83
+ for (const p of CANON.palettes) {
84
+ const ground = lightness(p.colors.background) < 0.5 ? "dark" : "light";
85
+ console.log(` ${p.id.padEnd(40)} ${(p.mood ?? "").padEnd(44)} ${ground}${p.autoPick === false ? " kept out" : ""}`);
86
+ }
87
+ console.log(`\nPAIRINGS (${CANON.pairings.length}): id, display + body (+ italic accent), display weight`);
88
+ for (const t of CANON.pairings) {
89
+ const [d, bo, ac] = [t.display, t.body, t.accentItalic].map((s) => (s ? headFamily(s) : null));
90
+ console.log(` ${t.id.padEnd(26)} ${d} + ${bo}${ac ? ` (+ ${ac} italic)` : ""}, ${t.displayScale?.weight ?? 300}${t.autoPick === false ? " kept out" : ""}`);
91
+ }
92
+ console.log(`\nSnapshot: Struq ${CANON.source.ref} ${CANON.source.commit}.`);
93
+ },
94
+ palette() {
95
+ const p = palette(a);
96
+ const t = pairing(current().type);
97
+ const path = write(p, t);
98
+ console.log(`Wrote palette ${p.id} (${p.mood}) to ${path.replace(root, ".")}, with type ${t.id}.`);
99
+ console.log(`STRUQ.md line: Palette: ${p.id} (${p.mood})`);
100
+ appendLog(root, "Step 3 Canon palette", [`canon.mjs wrote palette ${p.id} (${p.mood}) into the STRUQ TOKENS block.`]);
101
+ console.log("OK palette written");
102
+ },
103
+ type() {
104
+ const t = pairing(a);
105
+ const p = palette(current().palette);
106
+ const path = write(p, t);
107
+ const { file, hrefs } = writeFonts(t);
108
+ console.log(`Wrote type ${t.id} (${faces(t).join(" + ")}) to ${path.replace(root, ".")} and ${file.replace(root, ".")} (${hrefs.length} font URLs).`);
109
+ console.log(`STRUQ.md line: Type: ${t.id}: ${faces(t).join(" + ")}`);
110
+ appendLog(root, "Step 3 Canon type", [`canon.mjs wrote type ${t.id}: ${faces(t).join(" + ")}, display weight ${t.displayScale?.weight ?? 300}.`]);
111
+ console.log("OK type written");
112
+ },
113
+ print() {
114
+ const p = palette(a);
115
+ const t = pairing(b ?? CANON.pairings[0].id);
116
+ console.log(tokenBlock(p, t));
117
+ console.log("");
118
+ for (const h of fontHrefs(t, CANON)) console.log(h);
119
+ },
120
+ };
300
121
 
301
- const run = { list, palette: () => palette(slugArg), type: () => type(slugArg), system: () => system(slugArg) }[cmd];
302
- if (!run) {
303
- console.error("Usage: canon.mjs list | palette <slug> | type <slug> | system <slug> [--dir <project>]");
304
- process.exit(2);
122
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
123
+ const run = commands[cmd];
124
+ if (!run || (cmd !== "list" && !a)) {
125
+ console.error("Usage: canon.mjs list | palette <id> | type <id> | print <palette> [pairing] [--dir <project>]");
126
+ process.exit(2);
127
+ }
128
+ try {
129
+ run();
130
+ } catch (e) {
131
+ console.error(`FAIL ${e.message}`);
132
+ process.exitCode = 1;
133
+ }
305
134
  }
306
- run().catch((e) => {
307
- console.error(`FAIL ${e.message}`);
308
- process.exitCode = 1;
309
- });