@struq/skill 0.3.0 → 0.4.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 (129) hide show
  1. package/README.md +22 -36
  2. package/package.json +4 -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 +4 -4
  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
@@ -13,6 +13,8 @@ import { join, relative, resolve, sep } from "node:path";
13
13
  import { appendLog } from "./log.mjs";
14
14
  import { altFitsBrief, packSignalForBrief } from "./packs.mjs";
15
15
 
16
+ const CANON = JSON.parse(readFileSync(new URL("../canon/canon.json", import.meta.url), "utf8"));
17
+
16
18
  const args = process.argv.slice(2);
17
19
  const dirFlag = args.indexOf("--dir");
18
20
  const root = resolve(dirFlag >= 0 ? args[dirFlag + 1] : process.cwd());
@@ -43,7 +45,15 @@ function walk(dir, acc = []) {
43
45
  }
44
46
 
45
47
  const rel = (p) => relative(root, p).split(sep).join("/");
46
- const isRecipe = (p) => /(^|\/)components\/struq\/|(^|\/)lib\/(gsap|struq-images)\.ts$/.test(rel(p));
48
+ // A shipped file is one the skill installs under components/struq/ at the same path as in its blocks/ copy (or the
49
+ // images module images.mjs writes); anything else there, a scene or section you added, counts as your own.
50
+ const SHIPPED = new URL("../blocks/", import.meta.url);
51
+ const isRecipe = (p) => {
52
+ const r = rel(p);
53
+ if (/(^|\/)lib\/struq-images\.ts$/.test(r)) return true;
54
+ const m = r.match(/(?:^|\/)components\/struq\/(.+)$/);
55
+ return Boolean(m && existsSync(new URL(m[1], SHIPPED)));
56
+ };
47
57
  const files = walk(root)
48
58
  .filter((p) => !/^(scripts|test|tests|e2e)\//.test(rel(p)))
49
59
  .map((p) => ({ path: p, rel: rel(p), recipe: isRecipe(p), text: readFileSync(p, "utf8") }));
@@ -195,12 +205,13 @@ const plan = read("STRUQ.md");
195
205
 
196
206
  /* ------------------------------------------------------------------- TELLS */
197
207
  // The combined tell list: Struq's tells (StruqVisual lib/consistentie/tells.ts), the deck-slop-audit
198
- // patterns and Roy's own rules, as one list. references/anti-patterns.md has the why and the fix for
208
+ // patterns and Roy's own rules, as one list. references/tells.md has the why and the fix for
199
209
  // each; this is its executable form. FAIL blocks done; WARN asks for a look.
200
210
  {
201
211
  const G = "TELLS";
202
212
  const tell = (name, pattern, fix, opts = {}) => {
203
- const found = hits(pattern, opts);
213
+ // Your own files only: the blocks under components/struq/ passed Struq's gate as they are.
214
+ const found = hits(pattern, { scope: own, ...opts });
204
215
  if (found.length) add(G, opts.warn ? "WARN" : "FAIL", `${name}: ${fix}`, found);
205
216
  else if (!opts.quietPass) add(G, "PASS", `No ${name.toLowerCase()}`);
206
217
  };
@@ -265,6 +276,7 @@ const plan = read("STRUQ.md");
265
276
  tell("Live dots", /animate-(ping|pulse)/, "no pulsing dot, above all not in a pill.");
266
277
  tell("Blue and purple utilities", /\b(text|bg|border|fill|stroke|ring|outline|decoration|shadow|accent)-(blue|indigo|violet|purple|fuchsia|sky)-\d/,
267
278
  "the palette tokens carry every colour; blue and purple only when the brand is that, declared in STRUQ.md.");
279
+ tell("Glow", /shadow-\[0_0_|drop-shadow-\[0_0_|(box|text)-shadow:\s*0(px)?\s+0(px)?\s+\d+px|\bblur-(2xl|3xl)\b/, "no coloured light behind a card or a word; elevation is a surface step.");
268
280
  tell("Drop-shadow elevation", /\bshadow-(md|lg|xl|2xl)\b/, "elevation is a tonal surface step plus a hairline.");
269
281
  tell("Side-stripe borders", /\bborder-[lr]-([2-9]|\[[2-9])/, "no coloured stripe down a card edge; a hairline or nothing.");
270
282
 
@@ -328,85 +340,69 @@ const plan = read("STRUQ.md");
328
340
  }
329
341
 
330
342
  /* ------------------------------------------------------------------ MOTION */
343
+ // The blocks (components/struq/<kind>/) carry their own motion; this checks what the site around them owes them.
344
+ const BLOCK_IMPORT = /from\s*["'][^"']*components\/struq\/(preloaders|page-transitions|headers|heroes|backgrounds|showpieces|transitions|sections|footers)\/([\w-]+)["']/g;
345
+ const usedBlocks = new Set(own.flatMap((f) => [...f.text.matchAll(BLOCK_IMPORT)].map((m) => `${m[1]}/${m[2]}`)));
346
+ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`));
331
347
  {
332
348
  const G = "MOTION";
333
349
  const all = files.map((f) => f.text).join("\n");
334
- const missing = ["gsap", "lenis"].filter((d) => !deps[d]);
335
- if (!deps.three && !deps["@react-three/fiber"]) missing.push("three");
350
+ const missing = ["gsap", "@gsap/react", "lenis"].filter((d) => !deps[d]);
351
+ const needsThree = usedBlocks.has("showpieces/hero-opener") || own.some((f) => /from\s*["']three["']|HeroCanvas/.test(f.text));
352
+ if (needsThree && !deps.three) missing.push("three");
336
353
  if (missing.length) add(G, "FAIL", `Missing packages: npm install ${missing.join(" ")}`);
337
- else add(G, "PASS", "gsap, lenis and three installed");
338
-
339
- const lenis = /new Lenis\(|ReactLenis/.test(all) && /gsap\.ticker\.add/.test(all) && /lagSmoothing\(0\)/.test(all);
340
- const mounted = files.some((f) => /(^|\/)app\/layout\.(tsx|jsx)$/.test(f.rel) && /<SmoothScroll|<ReactLenis/.test(f.text));
341
- if (lenis && mounted) add(G, "PASS", "Lenis smooth scroll on the GSAP ticker, mounted in the layout");
342
- else add(G, "FAIL", lenis ? "Lenis is not mounted: wrap {children} in <SmoothScroll> in app/layout.tsx." : "No Lenis on gsap.ticker: run recipes.mjs and mount <SmoothScroll> in app/layout.tsx.");
343
-
344
- if (uses(/<SplitHeadline\b|<HeroOpener\b|SplitText\.create|new SplitText/)) add(G, "PASS", "SplitText on the headline");
345
- else add(G, "FAIL", "The hero headline is not split: set it with <SplitHeadline as=\"h1\">.");
346
-
347
- // Text reveals: Reveal groups, and the showpieces that reveal their own text on scroll.
348
- const reveals = own.reduce((n, f) => n + (f.text.match(/<Reveal\b|<ManifestoScrub\b|<FillRows\b|<ScrubConnector\b|<SplitHeadline\b[^>]*on="scroll"/g) ?? []).length, 0);
349
- const masks = uses(/<MediaFrame\b|<ImageToFullbleed\b|<PanelWipe\b|<ProjectStack\b|clipPath|clip-path:\s*inset/);
350
- if (reveals >= 2 && masks) add(G, "PASS", `Masked image reveals and ${reveals} staggered text reveals`);
351
- else add(G, "FAIL", `Scroll reveals below the floor (${reveals} <Reveal>, masked images: ${masks ? "yes" : "no"}). Wrap text groups in <Reveal> and every image in <MediaFrame>.`);
352
-
353
- if (uses(/<HorizontalGallery\b|<PinnedZoom\b|<ImageToFullbleed\b|<PanelWipe\b|<ScrubConnector\b|<ProjectStack\b|<ManifestoScrub\b/) || own.some((f) => /pin:\s*(true|[a-zA-Z"'])/.test(f.text) && /scrub/.test(f.text))) add(G, "PASS", "A pinned or horizontal showpiece");
354
- else add(G, "FAIL", "No showpiece: place the lead toonstuk from the SKILL.md picker (or HorizontalGallery or PinnedZoom) at the act break.");
355
-
356
- const ownGl = own.filter((f) => /WebGLRenderer|<Canvas\b/.test(f.text));
357
- if (uses(/<HeroCanvas\b|<HeroOpener\b/)) add(G, "PASS", "WebGL hero layer (HeroCanvas: poster, DPR cap, reduced motion, pause offscreen)");
358
- else if (ownGl.length) {
359
- const t = ownGl.map((f) => f.text).join("\n");
360
- const gaps = [];
361
- if (!/setPixelRatio\(\s*Math\.min|dpr=\{?\[/.test(t)) gaps.push("a DPR cap (Math.min(devicePixelRatio, 1.75))");
362
- if (!/prefers-reduced-motion/.test(t)) gaps.push("a reduced-motion still frame");
363
- if (!/poster|<Image\b|<img\b|fallback/i.test(t)) gaps.push("a poster behind the canvas");
364
- if (gaps.length) add(G, "FAIL", `The custom WebGL layer is missing ${gaps.join(", ")}. Or build it inside HeroCanvas as a scene.`, ownGl.map((f) => f.rel));
365
- else add(G, "PASS", "Custom WebGL hero layer with poster, DPR cap and reduced motion");
366
- } else add(G, "FAIL", "No WebGL hero layer: put <HeroCanvas scene=\"contours\" /> (or ripple, dots, or the MCP background) behind the hero.");
354
+ else add(G, "PASS", "The block packages are installed");
355
+
356
+ const layout = files.find((f) => /(^|\/)app\/layout\.(tsx|jsx)$/.test(f.rel))?.text ?? "";
357
+ const lenis = /new Lenis\(|ReactLenis/.test(all) && /gsap\.ticker\.add/.test(all);
358
+ if (lenis && /<SmoothScroll|<ReactLenis/.test(layout)) add(G, "PASS", "Lenis on the GSAP ticker, mounted in the layout");
359
+ else add(G, "FAIL", lenis ? "Lenis is not mounted: wrap {children} in <SmoothScroll> in app/layout.tsx." : "No Lenis on gsap.ticker: run blocks.mjs init and mount <SmoothScroll> in app/layout.tsx.");
360
+
361
+ if (/classList\.add\(\s*["']sq-js["']\s*\)/.test(layout) && /data-blocks/.test(layout)) add(G, "PASS", "The motion gate: sq-js before paint, data-blocks on <body>");
362
+ else add(G, "FAIL", "The motion gate is missing: the layout puts sq-js on <html> in an inline script before paint and data-blocks on <body> (recipes/app/layout.tsx). Without them the blocks lose their tokens and their first frame.");
363
+
364
+ if (usedBlocks.size) add(G, "PASS", `${usedBlocks.size} Struq block(s) on the page: ${[...usedBlocks].join(", ")}`);
365
+ else add(G, "FAIL", "No Struq block on the page: pick the hero, the sections and the footer from blocks/CATALOG.md (or struq_blokken) and install them with blocks.mjs add.");
367
366
 
368
367
  if (/prefers-reduced-motion/.test(all)) add(G, "PASS", "prefers-reduced-motion respected");
369
368
  else add(G, "FAIL", "Nothing reads prefers-reduced-motion.");
370
-
371
- if (uses(/<MagneticButton\b|<Cursor\b|quickTo/)) add(G, "PASS", "Pointer layer (magnetic CTA or cursor)");
372
- else add(G, "WARN", "No magnetic CTA or custom cursor. Use <MagneticButton> for the primary CTA unless the genre is a utility.");
373
369
  }
374
370
 
375
371
  /* -------------------------------------------------------------------- TYPE */
376
372
  {
377
373
  const G = "TYPE";
378
374
  const fams = new Set();
379
- for (const f of files) {
380
- for (const m of f.text.matchAll(/import\s*\{([^}]+)\}\s*from\s*["']next\/font\/google["']/g)) {
381
- m[1].split(",").map((s) => s.trim()).filter(Boolean).forEach((s) => fams.add(s));
375
+ let italicAccent = false;
376
+ for (const f of own) {
377
+ for (const m of f.text.matchAll(/fonts\.googleapis\.com\/css2\?family=([A-Za-z0-9+]+)(:[^&"'\s]*)?/g)) {
378
+ fams.add(m[1].replace(/\+/g, " "));
379
+ if (/ital@1|ital,wght@1/.test(m[2] ?? "")) italicAccent = true;
382
380
  }
383
- if (/from\s*["']next\/font\/local["']/.test(f.text)) {
384
- for (const m of f.text.matchAll(/(\w+)\s*=\s*localFont\(/g)) fams.add(`local:${m[1]}`);
381
+ for (const m of f.text.matchAll(/api\.fontshare\.com\/v2\/css\?f\[\]=([a-z0-9-]+)/g)) fams.add(m[1]);
382
+ for (const m of f.text.matchAll(/import\s*\{([^}]+)\}\s*from\s*["']next\/font\/google["']/g)) {
383
+ m[1].split(",").map((s) => s.trim()).filter(Boolean).forEach((s) => fams.add(s.replace(/_/g, " ")));
385
384
  }
385
+ if (/from\s*["']next\/font\/local["']/.test(f.text)) for (const m of f.text.matchAll(/(\w+)\s*=\s*localFont\(/g)) fams.add(`local:${m[1]}`);
386
386
  }
387
- // A canon pairing may add one italic accent face (--face-accent, italic only).
388
- const accentFace = files.some((f) => /--face-accent/.test(f.text) && /style:\s*\[\s*"italic"\s*\]/.test(f.text));
389
- if (fams.size === 2) add(G, "PASS", `Two faces: ${[...fams].join(" + ")}`);
390
- else if (fams.size === 3 && accentFace) add(G, "PASS", `Two faces and one italic accent: ${[...fams].join(" + ")}`);
391
- else if (fams.size === 1) add(G, "WARN", `One face (${[...fams][0]}). Two faces (display and text) unless STRUQ.md declares a one-family system.`);
392
- else if (fams.size === 0) add(G, "FAIL", "No web fonts loaded: set the pairing in app/fonts.ts with next/font.");
393
- else add(G, "FAIL", `${fams.size} faces loaded (${[...fams].join(", ")}). Exactly two: one display, one text.`);
387
+ if (fams.size === 2 || (fams.size === 3 && italicAccent)) add(G, "PASS", `Faces: ${[...fams].join(" + ")}`);
388
+ else if (fams.size === 1) add(G, "WARN", `One face (${[...fams][0]}). A display and a text face, unless the pairing is one family.`);
389
+ else if (fams.size === 0) add(G, "FAIL", "No web fonts loaded: canon.mjs type <pairing> writes app/fonts.ts, and the layout links each URL.");
390
+ else add(G, "FAIL", `${fams.size} faces loaded (${[...fams].join(", ")}). One display, one text, at most one italic accent.`);
394
391
 
395
392
  const css = files.filter((f) => f.rel.endsWith(".css")).map((f) => f.text).join("\n");
396
- const weight = css.match(/--display-weight:\s*(\d{3})/)?.[1];
393
+ const ownCss = own.filter((f) => f.rel.endsWith(".css")).map((f) => f.text).join("\n");
394
+ const weight = (ownCss.match(/--display-weight:\s*(\d{3})/) ?? css.match(/--display-weight:\s*(\d{3})/))?.[1];
395
+ const typeId = plan.match(/^Type:\s*([\w-]+)/m)?.[1];
396
+ const poster = CANON.pairings.find((t) => t.id === typeId && String(t.displayScale?.weight) === weight && Number(weight) > 400);
397
397
  if (weight && Number(weight) <= 400) add(G, "PASS", `Display runs light (${weight})`);
398
- else if (weight) add(G, "FAIL", `Display weight ${weight}: run it at 300 (400 for a face without a 300).`);
399
- else if (uses(/font-(light|extralight|thin)/)) add(G, "PASS", "Display runs light");
400
- else add(G, "WARN", "No light display weight found (--display-weight: 300 in globals.css).");
398
+ else if (poster) add(G, "PASS", `Display at ${poster.id}'s poster weight (${weight})`);
399
+ else if (weight) add(G, "FAIL", `Display weight ${weight}: run it light (300, or 400 for a face that starts there); heavier only at a poster pairing's own weight.`);
400
+ else add(G, "WARN", "No --display-weight found: the STRUQ TOKENS block sets it (canon.mjs type).");
401
401
 
402
402
  if (/html\s*\{[^}]*font-size:\s*clamp\(/.test(css)) add(G, "PASS", "One fluid root-rem knob");
403
403
  else add(G, "FAIL", "No fluid root font size: html { font-size: clamp(1rem, 0.92rem + 0.2vw, 1.125rem); }");
404
404
 
405
- if (/--text-display/.test(css) && uses(/text-display/)) add(G, "PASS", "Display role token in use");
406
- else add(G, "FAIL", "The headline does not use the display role: font-display text-display.");
407
-
408
- // The hero opener sets the page's h1 itself.
409
- const h1 = own.reduce((n, f) => n + (f.text.match(/<h1\b|<HeroOpener\b|<SplitHeadline(?![^>]*as=["'](h2|h3|p)["'])[^>]*>/g) ?? []).length, 0);
405
+ const h1 = files.reduce((n, f) => n + (f.text.match(/<h1\b|<SplitHeadline(?![^>]*as=["'](h2|h3|p)["'])[^>]*>/g) ?? []).length, 0);
410
406
  if (h1 >= 1) add(G, "PASS", "The page has its h1");
411
407
  else add(G, "FAIL", "No h1 on the page.");
412
408
  }
@@ -419,97 +415,67 @@ const plan = read("STRUQ.md");
419
415
  const open = plan.split(/\r?\n/).filter((l) => /^\s*[-*]\s*\[\s\]/.test(l) && !/check\.mjs/.test(l));
420
416
  const done = plan.split(/\r?\n/).filter((l) => /^\s*[-*]\s*\[[xX]\]/.test(l)).length;
421
417
  if (open.length) add(G, "FAIL", `${open.length} Build checklist item(s) not ticked in STRUQ.md. Do them, then tick them.`, open.map((l) => l.trim()));
422
- else if (done >= 8) add(G, "PASS", `Build checklist ticked (${done} items)`);
418
+ else if (done >= 7) add(G, "PASS", `Build checklist ticked (${done} items)`);
423
419
  else add(G, "FAIL", "STRUQ.md has no Build checklist: copy it from SKILL.md and tick each step as you finish it.");
424
- const need = ["Palette", "Type", "System", "Images"].filter((k) => !new RegExp(`^${k}:\\s*\\S`, "mi").test(plan));
425
- if (need.length) add(G, "FAIL", `STRUQ.md does not record the canon picks: ${need.map((k) => `${k}:`).join(", ")} (canon.mjs prints each line).`);
426
- else add(G, "PASS", "Canon picks recorded (palette, type, system, images)");
420
+ const need = ["Palette", "Type", "Images", "Order"].filter((k) => !new RegExp(`^${k}:\\s*\\S`, "mi").test(plan));
421
+ if (need.length) add(G, "FAIL", `STRUQ.md does not record: ${need.map((k) => `${k}:`).join(", ")} (SKILL.md, the plan).`);
422
+ else add(G, "PASS", "Direction and order recorded (palette, type, images, order)");
427
423
  }
428
424
  }
429
425
 
430
426
  /* -------------------------------------------------------------------- HERO */
431
427
  {
432
428
  const G = "HERO";
433
- // The hero opener (toonstuk-hero-opener) carries its own photo, WebGL layer, quiet zones and line.
434
- const heroFiles = own.filter((f) => /<HeroCanvas\b|<HeroOpener\b/.test(f.text));
435
- // The hero is the section around HeroCanvas (or the HeroOpener tag): from the section that opens
436
- // before it to the first </section> after it. Photos further down the page do not count.
437
- const heroSlice = (text) => {
438
- const opener = text.indexOf("<HeroOpener");
439
- const at = opener >= 0 ? opener : text.indexOf("<HeroCanvas");
440
- if (opener >= 0) return text.slice(at, text.indexOf("/>", at) + 2 || text.length);
441
- const open = text.lastIndexOf("<section", at);
442
- const close = text.indexOf("</section>", at);
443
- return text.slice(open >= 0 ? open : at, close >= 0 ? close : text.length);
444
- };
445
- // With no fitting pack photo (imageFit none) the opener is typographic or WebGL on purpose.
429
+ const heroBlock = usesKind("heroes") || usedBlocks.has("showpieces/hero-opener");
446
430
  const noFit = /imageFit[^=]*=\s*"none"/.test(read("lib/struq-images.ts") + read("src/lib/struq-images.ts"));
447
- const bare = noFit ? [] : heroFiles.filter((f) => !/images\.hero\b|<MediaFrame\b|\bimage=\{/.test(heroSlice(f.text)));
448
- if (bare.length) add(G, "FAIL", "Hero without a photo: every pack has hero images, so the hero shows one (HeroOpener, HeroCanvas scene=\"ripple\" image={images.hero[0]}, or a MediaFrame beside the copy). The WebGL field is an atmosphere layer, not the picture.", bare.map((f) => f.rel));
449
- else if (heroFiles.length) add(G, "PASS", "The hero carries a photo");
450
- const quietless = heroFiles.filter((f) => !/data-quiet|<SplitHeadline\b|<HeroOpener\b/.test(f.text));
451
- if (quietless.length) add(G, "FAIL", "The hero copy is not marked: put data-quiet on the headline and the lead (SplitHeadline marks the h1 itself), so the field hushes behind them.", quietless.map((f) => f.rel));
452
- // The display line: the opener sets it at 16vw; a hand-built hero needs 10vw or more on desktop.
453
- const small = heroFiles.filter((f) => {
454
- if (/<HeroOpener\b/.test(f.text)) return false;
455
- const slice = heroSlice(f.text);
456
- const h1 = slice.match(/<(?:h1|SplitHeadline)\b[^>]*>/g)?.find((tag) => tag.startsWith("<h1") || !/as=["'](h2|h3|p)["']/.test(tag));
457
- if (!h1) return false;
458
- const vw = [...h1.matchAll(/text-\[(\d+(?:\.\d+)?)vw\]/g)].map((m) => Number(m[1]));
459
- return !vw.length || Math.max(...vw) < 10;
460
- });
461
- if (small.length) add(G, "FAIL", "Hero display line under 10vw: the opener's line runs at 14 to 20vw (text-[19vw] md:text-[16vw]), set across the photo. Use HeroOpener, or size the h1 yourself.", small.map((f) => f.rel));
462
- else if (heroFiles.length) add(G, "PASS", "The hero display line runs at poster size");
463
- if (own.some((f) => /data-wordmark/.test(f.text))) add(G, "PASS", "A header wordmark (data-wordmark)");
464
- else add(G, "FAIL", "No header wordmark: every page has a header with the name as wordmark, marked data-wordmark (references/sections.md, Header).");
465
- // The contrast scene: one, at most two, sections on the inverse ground or a full colour field.
466
- // Scenes on the page: the class in markup, not its definition in the stylesheet.
431
+ if (heroBlock) {
432
+ add(G, "PASS", "A hero block opens the page");
433
+ const heroUse = own.flatMap((f) => [...f.text.matchAll(/<(Hero[A-Z]\w*)\b[^>]*?\/?>/gs)].map((m) => ({ f, tag: m[0] })));
434
+ const bare = noFit ? [] : heroUse.filter(({ tag }) => /<Hero(Framed|Split|Index|Still|Poster)\b/.test(tag) && !/\bimages?=\{|\bimage=\{/.test(tag));
435
+ if (bare.length) add(G, "FAIL", "A photo hero without its photo: pass image={images.hero[0]} (or the client's photo).", bare.map(({ f }) => f.rel));
436
+ } else {
437
+ // A hand-built hero: a photo in it and a display line at poster size.
438
+ const heroFile = own.find((f) => /<h1\b|<SplitHeadline\b/.test(f.text));
439
+ if (!heroFile) add(G, "FAIL", "No hero: open the page with a hero block (blocks/CATALOG.md, Heroes).");
440
+ else {
441
+ const at = heroFile.text.search(/<h1\b|<SplitHeadline\b/);
442
+ const slice = heroFile.text.slice(Math.max(0, heroFile.text.lastIndexOf("<section", at)), heroFile.text.indexOf("</section>", at) + 1 || undefined);
443
+ if (!noFit && !/images\.hero\b|<MediaFrame\b|\bimage=\{/.test(slice)) add(G, "FAIL", "Hero without a photo: a hand-built hero shows images.hero[0] (or the client's photo). Or use a hero block.", [heroFile.rel]);
444
+ const vw = [...(slice.match(/<(?:h1|SplitHeadline)\b[^>]*>/)?.[0] ?? "").matchAll(/text-\[(\d+(?:\.\d+)?)vw\]/g)].map((m) => Number(m[1]));
445
+ if (!vw.length || Math.max(...vw) < 10) add(G, "FAIL", "Hand-built hero line under 10vw: set it at 12 to 20vw, or use a hero block.", [heroFile.rel]);
446
+ else add(G, "PASS", "The hand-built hero line runs at poster size");
447
+ }
448
+ }
449
+ if (usesKind("headers") || own.some((f) => /data-wordmark/.test(f.text))) add(G, "PASS", "A header with the wordmark");
450
+ else add(G, "FAIL", "No header: use header-bar or menu-curtain, or mark your own header's name data-wordmark.");
451
+ if (usesKind("footers") || usedBlocks.has("showpieces/wordmark-finale")) add(G, "PASS", "A designed footer block closes the page");
452
+ else add(G, "WARN", "No footer block: close with footer-reveal, footer-contact or wordmark-finale, or design your own as hard as the hero.");
453
+
467
454
  const scenes = own.filter((f) => !f.rel.endsWith(".css")).reduce((n, f) => n + (f.text.match(/\bscene-(inverse|field)\b/g) ?? []).length, 0);
468
- if (scenes === 0) add(G, "FAIL", "No contrast scene: wrap one section (the showpiece, the services, the invitation) in scene-inverse or scene-field, so the page does not stay on one ground.");
455
+ if (scenes === 0) add(G, "FAIL", "No contrast scene: wrap one section in scene-inverse or scene-field, so the page does not stay on one ground.");
469
456
  else if (scenes > 2) add(G, "FAIL", `${scenes} contrast scenes: one, at most two, or the contrast stops being a moment.`);
470
457
  else add(G, "PASS", `${scenes} contrast scene${scenes > 1 ? "s" : ""}`);
471
- // Custom hero scenes live beside the shipped ones; only the shipped files are trusted.
472
- const customScenes = files.filter((f) => f.rel.includes("components/struq/scenes/") && !/[/](contours|ripple|dots|types)[.]ts$/.test(f.rel));
473
- const geometry = hits(/new\s+THREE\.(Box|Cylinder|Torus|Sphere|Capsule|Cone|Tube|Lathe|Extrude|Tetrahedron|Octahedron|Icosahedron)Geometry\b|<(box|cylinder|torus|sphere|capsule|cone|tube|lathe|extrude)Geometry\b/, { scope: [...own, ...customScenes] });
474
- if (geometry.length) add(G, "FAIL", "Hand-built product geometry: never model the client's product from primitives. Use a shared hero scene with the product photography (pack vitrine for a launch).", geometry);
458
+
459
+ const geometry = hits(/new\s+THREE\.(Box|Cylinder|Torus|Sphere|Capsule|Cone|Tube|Lathe|Extrude|Tetrahedron|Octahedron|Icosahedron)Geometry\b|<(box|cylinder|torus|sphere|capsule|cone|tube|lathe|extrude)Geometry\b/, { scope: own });
460
+ if (geometry.length) add(G, "FAIL", "Hand-built product geometry: never model the client's product from primitives. Show the product photography.", geometry);
475
461
  else add(G, "PASS", "No product modelled from primitives");
476
462
  }
477
463
 
478
464
  /* --------------------------------------------------------------------- LOG */
479
- // Every site keeps STRUQ-LOG.md: an entry for each completed checklist step. Step 12 (this check)
480
- // writes its own entry when this run ends, so it is not required beforehand.
465
+ // Every site keeps STRUQ-LOG.md: an entry for each completed checklist step. Step 8 (this check) writes its own entry
466
+ // when this run ends, so it is not required beforehand.
481
467
  {
482
468
  const G = "LOG";
483
469
  const log = read("STRUQ-LOG.md");
484
470
  if (!log) add(G, "FAIL", "No STRUQ-LOG.md. Start it at the first step: node <skill-dir>/scripts/log.mjs \"Step 1 Project\" \"<what>\" \"<why>\".");
485
471
  else {
486
- const ticked = (plan.match(/^\s*[-*]\s*\[[xX]\]\s*(\d+)/gm) ?? []).map((l) => Number(l.match(/(\d+)\s*$/)[1])).filter((n) => n !== 12);
472
+ const ticked = (plan.match(/^\s*[-*]\s*\[[xX]\]\s*(\d+)/gm) ?? []).map((l) => Number(l.match(/(\d+)\s*$/)[1])).filter((n) => n !== 8);
487
473
  const missing = ticked.filter((n) => !new RegExp(`^## Step ${n}\\b`, "m").test(log));
488
474
  const entries = (log.match(/^## /gm) ?? []).length;
489
475
  const approval = log.match(/^## Step 2 Approval[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m);
490
- if (!approval || !/^Reply:\s*["\u201C\u2018'].+["\u201D\u2019']\s*$/m.test(approval[1])) {
476
+ if (!approval || !/^Reply:\s*["“‘'].+["”’']\s*$/m.test(approval[1])) {
491
477
  add(G, "FAIL", 'No Step 2 Approval entry with the user\'s own reply. The build request is not the approval: stop once, ask "Akkoord? Heb je een logo, foto\'s of echte cijfers, stuur ze nu.", then log the answer word for word: node <skill-dir>/scripts/log.mjs "Step 2 Approval" "Asked: Akkoord? ..." "Reply: \"<their words>\"".');
492
478
  } else add(G, "PASS", "The approval stop is logged with the user's reply");
493
- // The MCP build line (struq_bouwplan, logged with log.mjs mcp): every arc row gets its
494
- // struq_sectiespec, and the one approval stop sits after the bouwplan, before the projectschil.
495
- const at = (re) => {
496
- const m = re.exec(log);
497
- return m ? m.index : -1;
498
- };
499
- const planAt = at(/^## Step \d+ MCP struq_bouwplan\b/m);
500
- if (planAt >= 0) {
501
- const planEntry = log.slice(planAt).split(/\n(?=## )/)[0];
502
- const rows = Number(planEntry.match(/^Arc rows:\s*(\d+)/m)?.[1] ?? 0);
503
- const specRows = new Set([...log.matchAll(/^## Step \d+ MCP struq_sectiespec\b[^\n]*\n(?:(?!## )[^\n]*\n)*?Row:\s*(\d+)/gm)].map((m) => m[1]));
504
- if (rows && specRows.size < rows) {
505
- add(G, "FAIL", `MCP build line incomplete: ${specRows.size} of ${rows} arc rows have their struq_sectiespec. Call struq_sectiespec for every arc row with the planToken before writing that row, and log it: node <skill-dir>/scripts/log.mjs mcp struq_sectiespec --row <n> --id <arc id>.`);
506
- } else if (rows) add(G, "PASS", `MCP build line: struq_sectiespec for all ${rows} arc rows`);
507
- const approvalAt = at(/^## Step 2 Approval\b/m);
508
- const shellAt = at(/^## Step \d+ MCP struq_projectschil\b/m);
509
- if (approvalAt >= 0 && (approvalAt < planAt || (shellAt >= 0 && approvalAt > shellAt))) {
510
- add(G, "FAIL", "MCP approval stop out of place: with the MCP, stop once after struq_bouwplan and before struq_projectschil, with the plan and its needs in that one question.");
511
- }
512
- }
513
479
  if (missing.length) add(G, "FAIL", `STRUQ-LOG.md has no entry for ticked step(s) ${missing.join(", ")}. Add one per step with log.mjs: what was done and why.`);
514
480
  else add(G, "PASS", `STRUQ-LOG.md has ${entries} entries, one or more for every ticked step`);
515
481
  }
@@ -534,7 +500,7 @@ const warns = results.filter((r) => r.status === "WARN").length;
534
500
  if (!args.includes("--no-log")) {
535
501
  const names = (status) => results.filter((r) => r.status === status).map((r) => `${r.group} ${r.message.split(":")[0].replace(/[.(].*$/, "").trim()}`);
536
502
  const passed = results.filter((r) => r.status === "PASS").length;
537
- appendLog(root, "Step 12 Check", [
503
+ appendLog(root, "Step 8 Check", [
538
504
  `check.mjs: RESULT ${fails ? "FAIL" : "PASS"}, ${passed} pass, ${fails} fail, ${warns} warn.`,
539
505
  ...(fails ? [`Fail: ${names("FAIL").join("; ")}.`] : []),
540
506
  ...(warns ? [`Warn: ${names("WARN").join("; ")}.`] : []),
@@ -333,7 +333,7 @@ async function main() {
333
333
  if (droppedImages.length) console.log(`Left out ${droppedImages.length} photos that do not fit this client (${[...new Set(droppedImages.map((d) => d.reason))].join("; ")}).`);
334
334
  console.log("Captions: write them from each photo's alt below. Never name a subject the photo does not show.");
335
335
  if (imageFit === "none") {
336
- console.log("NONE of this pack's photos fits the brief. Do not use photos: the opener is typographic or the WebGL scene (HeroOpener without image).");
336
+ console.log("NONE of this pack's photos fits the brief. Use no photos: a background block (aurora, silk, facets) carries the hero's type.");
337
337
  console.log("Say so at the approval stop: \"Ik heb geen foto's van je <product>: stuur ze, dan komen ze in de hero.\"");
338
338
  } else if (imageFit === "partial") {
339
339
  console.log(`Only ${entries.length} photo(s) fit: use them where their alt fits (the material, the city, the street), and nowhere else.`);
@@ -345,7 +345,7 @@ async function main() {
345
345
  if (plates > 0) {
346
346
  console.log(`\nWARN ${plates} slot(s) use fallback plates. Run this script again when struq.nl answers.`);
347
347
  }
348
- appendLog(root, "Step 5 Images", [
348
+ appendLog(root, "Step 4 Images", [
349
349
  `images.mjs: pack ${slug} (${why}), ${real} images downloaded and verified, ${plates} fallback plates.`,
350
350
  ...(droppedImages.length ? [`Left out ${droppedImages.length} photos that do not fit this client: ${[...new Set(droppedImages.map((d) => d.reason))].join("; ")}.`] : []),
351
351
  ...(imageFit !== "full" ? [`Image fit: ${imageFit}${imageFit === "none" ? ", the opener is typographic or WebGL, and the approval stop asks for photos" : ""}.`] : []),
@@ -3,7 +3,7 @@
3
3
  // STRUQ.md is the plan and the contract; STRUQ-LOG.md is what happened, in order.
4
4
  //
5
5
  // From the shell, at every checklist step:
6
- // node <skill-dir>/scripts/log.mjs "Step 6 Hero" "Built components/site/Hero.tsx with HeroCanvas contours." "Why: calm, precise brief."
6
+ // node <skill-dir>/scripts/log.mjs "Step 5 Blocks" "hero-still, variant frame, in app/page.tsx." "Why: one calm photo for an architect."
7
7
  // add --dir <project> to run from elsewhere
8
8
  //
9
9
  // From a script: import { appendLog } from "./log.mjs"; appendLog(root, "Step 5 Images", ["...", "..."]);
@@ -52,33 +52,16 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
52
52
  const i = args.indexOf("--dir");
53
53
  const root = resolve(i >= 0 ? args[i + 1] : process.cwd());
54
54
  const rest = i >= 0 ? args.filter((_, j) => j !== i && j !== i + 1) : args;
55
- // MCP calls: node log.mjs mcp <tool> [--rows <arc rows>] [--token <planToken>] [--row <n>] [--id <arc id>] [--note "<text>"]
55
+ // MCP calls: node log.mjs mcp <struq_tool> [--note "<what it returned or what you took from it>"]
56
56
  if (rest[0] === "mcp") {
57
57
  const tool = rest[1];
58
- const opt = (name) => {
59
- const k = rest.indexOf(name);
60
- return k >= 0 ? rest[k + 1] : undefined;
61
- };
62
- const STEP = { struq_bouwplan: 2, struq_projectschil: 3, struq_motion: 3, struq_achtergrond: 3, struq_sectiespec: 7 };
58
+ const k = rest.indexOf("--note");
59
+ const STEP = { struq_richting: 2, struq_blokken: 2, struq_blok: 5, struq_controle: 8 };
63
60
  if (!tool || !/^struq_/.test(tool)) {
64
- console.error("Usage: node log.mjs mcp <struq_tool> [--rows <n>] [--token <planToken>] [--row <n>] [--id <arc id>] [--note \"<text>\"]");
61
+ console.error('Usage: node log.mjs mcp <struq_tool> [--note "<text>"]');
65
62
  process.exit(2);
66
63
  }
67
- if (tool === "struq_bouwplan" && !opt("--rows")) {
68
- console.error("Log the bouwplan with its arc length: --rows <number of arc rows>, so the check can count the sectiespec calls.");
69
- process.exit(2);
70
- }
71
- if (tool === "struq_sectiespec" && !opt("--row")) {
72
- console.error("Log each sectiespec with the arc row it is for: --row <arc index> --id <arc id>.");
73
- process.exit(2);
74
- }
75
- const lines = [
76
- opt("--rows") ? `Arc rows: ${opt("--rows")}` : null,
77
- opt("--row") ? `Row: ${opt("--row")}${opt("--id") ? ` (${opt("--id")})` : ""}` : null,
78
- opt("--token") ? `Plan token: ${opt("--token")}` : null,
79
- opt("--note") ?? (tool === "struq_bouwplan" ? "The plan; the one approval stop comes next, before struq_projectschil." : "Called."),
80
- ].filter(Boolean);
81
- const file = appendLog(root, `Step ${STEP[tool] ?? 7} MCP ${tool}`, lines);
64
+ const file = appendLog(root, `Step ${STEP[tool] ?? 5} MCP ${tool}`, [k >= 0 ? rest[k + 1] : "Called."]);
82
65
  console.log(`Logged ${tool} in ${file}`);
83
66
  process.exit(0);
84
67
  }