@struq/skill 0.2.1 → 0.3.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 (60) hide show
  1. package/README.md +15 -5
  2. package/package.json +4 -4
  3. package/skills/struq/SKILL.md +304 -200
  4. package/skills/struq/cinematic/showpieces/README.md +18 -0
  5. package/skills/struq/cinematic/showpieces/toonstuk-fill-rows.tsx +74 -0
  6. package/skills/struq/cinematic/showpieces/toonstuk-hero-opener.tsx +128 -0
  7. package/skills/struq/cinematic/showpieces/toonstuk-image-to-fullbleed.tsx +78 -0
  8. package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +81 -0
  9. package/skills/struq/cinematic/showpieces/toonstuk-panel-wipe.tsx +89 -0
  10. package/skills/struq/cinematic/showpieces/toonstuk-pixel-resolve.tsx +107 -0
  11. package/skills/struq/cinematic/showpieces/toonstuk-project-stack.tsx +86 -0
  12. package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +78 -0
  13. package/skills/struq/cinematic/showpieces/toonstuk-wordmark-finale.tsx +105 -0
  14. package/skills/struq/recipes/app/fonts.ts +22 -0
  15. package/skills/struq/recipes/app/globals.css +185 -0
  16. package/skills/struq/recipes/app/layout.tsx +28 -0
  17. package/skills/struq/recipes/components/struq/Cursor.tsx +117 -0
  18. package/skills/struq/recipes/components/struq/HeroCanvas.tsx +278 -0
  19. package/skills/struq/recipes/components/struq/HorizontalGallery.tsx +106 -0
  20. package/skills/struq/recipes/components/struq/MagneticButton.tsx +92 -0
  21. package/skills/struq/recipes/components/struq/MediaFrame.tsx +96 -0
  22. package/skills/struq/recipes/components/struq/PinnedZoom.tsx +79 -0
  23. package/skills/struq/recipes/components/struq/Reveal.tsx +64 -0
  24. package/skills/struq/recipes/components/struq/SmoothScroll.tsx +57 -0
  25. package/skills/struq/recipes/components/struq/SplitHeadline.tsx +68 -0
  26. package/skills/struq/recipes/components/struq/scenes/contours.ts +161 -0
  27. package/skills/struq/recipes/components/struq/scenes/dots.ts +110 -0
  28. package/skills/struq/recipes/components/struq/scenes/ripple.ts +99 -0
  29. package/skills/struq/recipes/components/struq/scenes/types.ts +114 -0
  30. package/skills/struq/recipes/lib/gsap.ts +21 -0
  31. package/skills/struq/references/anti-patterns.md +71 -6
  32. package/skills/struq/references/baseline-prompt.md +18 -14
  33. package/skills/struq/references/brief-router.md +7 -8
  34. package/skills/struq/references/composition.md +0 -1
  35. package/skills/struq/references/depth-parallax.md +1 -1
  36. package/skills/struq/references/direction.md +5 -5
  37. package/skills/struq/references/hero-scenes.md +107 -0
  38. package/skills/struq/references/iconography.md +1 -1
  39. package/skills/struq/references/imagery.md +10 -29
  40. package/skills/struq/references/micro-interactions.md +1 -1
  41. package/skills/struq/references/mobile-composition.md +1 -1
  42. package/skills/struq/references/niche-packets.md +338 -0
  43. package/skills/struq/references/page-architecture.md +2 -2
  44. package/skills/struq/references/pinning-scrub.md +3 -4
  45. package/skills/struq/references/scroll-reveals.md +1 -2
  46. package/skills/struq/references/sections.md +141 -0
  47. package/skills/struq/references/seo.md +2 -2
  48. package/skills/struq/references/showcase.md +2 -2
  49. package/skills/struq/references/svg-motion.md +390 -390
  50. package/skills/struq/references/typography.md +39 -39
  51. package/skills/struq/references/vibe-presets.md +5 -5
  52. package/skills/struq/references/webgl-scenes.md +593 -593
  53. package/skills/struq/scripts/canon.mjs +309 -0
  54. package/skills/struq/scripts/check.mjs +547 -0
  55. package/skills/struq/scripts/font-names.mjs +40 -0
  56. package/skills/struq/scripts/images.mjs +360 -0
  57. package/skills/struq/scripts/log.mjs +92 -0
  58. package/skills/struq/scripts/packs.mjs +156 -0
  59. package/skills/struq/scripts/recipes.mjs +135 -0
  60. /package/{bin → cli}/struq.mjs +0 -0
@@ -0,0 +1,547 @@
1
+ #!/usr/bin/env node
2
+ // Struq self-check: run it from the project root before you report a build as done.
3
+ //
4
+ // node <skill-dir>/scripts/check.mjs
5
+ // node <skill-dir>/scripts/check.mjs --dir ./my-site
6
+ //
7
+ // It needs no browser and no dependencies. It reads the source and the public folder and prints
8
+ // PASS, WARN or FAIL per check, in four groups: images, AI tells, the motion floor, typography,
9
+ // plus the plan in STRUQ.md. Every FAIL names the file and line and the fix. Exit code 1 while any
10
+ // FAIL remains. Fix, run again, and only report done on "RESULT: PASS".
11
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
12
+ import { join, relative, resolve, sep } from "node:path";
13
+ import { appendLog } from "./log.mjs";
14
+ import { altFitsBrief, packSignalForBrief } from "./packs.mjs";
15
+
16
+ const args = process.argv.slice(2);
17
+ const dirFlag = args.indexOf("--dir");
18
+ const root = resolve(dirFlag >= 0 ? args[dirFlag + 1] : process.cwd());
19
+
20
+ const SKIP_DIRS = new Set(["node_modules", ".next", ".git", "out", "dist", "build", ".turbo", ".vercel", "public", ".claude", ".agents", ".cursor", "coverage"]);
21
+ const CODE_EXT = /\.(tsx|ts|jsx|js|mjs|css|mdx)$/;
22
+
23
+ function walk(dir, acc = []) {
24
+ let names = [];
25
+ try {
26
+ names = readdirSync(dir);
27
+ } catch {
28
+ return acc;
29
+ }
30
+ for (const name of names) {
31
+ if (SKIP_DIRS.has(name)) continue;
32
+ const full = join(dir, name);
33
+ let st;
34
+ try {
35
+ st = statSync(full);
36
+ } catch {
37
+ continue;
38
+ }
39
+ if (st.isDirectory()) walk(full, acc);
40
+ else if (CODE_EXT.test(name) && !/\.(config|d)\.(ts|js|mjs)$/.test(name) && !name.endsWith(".d.ts")) acc.push(full);
41
+ }
42
+ return acc;
43
+ }
44
+
45
+ const rel = (p) => relative(root, p).split(sep).join("/");
46
+ const isRecipe = (p) => /(^|\/)components\/struq\/|(^|\/)lib\/(gsap|struq-images)\.ts$/.test(rel(p));
47
+ const files = walk(root)
48
+ .filter((p) => !/^(scripts|test|tests|e2e)\//.test(rel(p)))
49
+ .map((p) => ({ path: p, rel: rel(p), recipe: isRecipe(p), text: readFileSync(p, "utf8") }));
50
+ const own = files.filter((f) => !f.recipe);
51
+ const read = (p) => (existsSync(join(root, p)) ? readFileSync(join(root, p), "utf8") : "");
52
+
53
+ const results = [];
54
+ const add = (group, status, message, where = []) => results.push({ group, status, message, where });
55
+
56
+ /** Every non-comment line matching a pattern, as "file:line snippet". */
57
+ function hits(pattern, { scope = files, exclude = null } = {}) {
58
+ const out = [];
59
+ for (const f of scope) {
60
+ const lines = f.text.split(/\r?\n/);
61
+ lines.forEach((line, i) => {
62
+ const t = line.trim();
63
+ if (!t || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*") || t.startsWith("{/*")) return;
64
+ if (pattern.test(line) && !(exclude && exclude.test(line))) {
65
+ out.push(`${f.rel}:${i + 1} ${t.slice(0, 120)}`);
66
+ }
67
+ });
68
+ }
69
+ return out;
70
+ }
71
+ const uses = (pattern) => own.some((f) => pattern.test(f.text));
72
+
73
+ function isImageFile(path) {
74
+ try {
75
+ const buf = readFileSync(path);
76
+ if (buf.length < 8000) return false;
77
+ const a = (s, e) => buf.subarray(s, e).toString("latin1");
78
+ return (
79
+ (a(0, 4) === "RIFF" && a(8, 12) === "WEBP") ||
80
+ (buf[0] === 0xff && buf[1] === 0xd8) ||
81
+ (buf[0] === 0x89 && a(1, 4) === "PNG") ||
82
+ a(4, 12) === "ftypavif"
83
+ );
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ if (!existsSync(join(root, "package.json"))) {
90
+ console.error(`FAIL no package.json in ${root}. Run from the project root or pass --dir.`);
91
+ process.exit(2);
92
+ }
93
+ const pkg = JSON.parse(read("package.json"));
94
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
95
+ const plan = read("STRUQ.md");
96
+
97
+ /* ------------------------------------------------------------------ IMAGES */
98
+ {
99
+ const G = "IMAGES";
100
+ const libFile = ["lib/struq-images.ts", "src/lib/struq-images.ts"].find((p) => existsSync(join(root, p)));
101
+ const pub = join(root, "public");
102
+ const listPublic = (dir, acc = []) => {
103
+ if (!existsSync(dir)) return acc;
104
+ for (const n of readdirSync(dir)) {
105
+ const full = join(dir, n);
106
+ if (statSync(full).isDirectory()) listPublic(full, acc);
107
+ else if (/\.(webp|jpe?g|png|avif|svg)$/i.test(n)) acc.push(full);
108
+ }
109
+ return acc;
110
+ };
111
+ const all = listPublic(pub);
112
+ const realPhotos = all.filter((p) => !p.endsWith(".svg") && isImageFile(p));
113
+ const brokenPhotos = all.filter((p) => !p.endsWith(".svg") && !isImageFile(p));
114
+
115
+ const mod = libFile ? read(libFile) : "";
116
+ const imageFit = mod.match(/imageFit[^=]*=\s*"(full|partial|none)"/)?.[1] ?? "full";
117
+ // The photos per slot in the pack module, for references that point past what was downloaded.
118
+ const slotCount = Object.fromEntries(
119
+ ["hero", "band", "card", "column", "detail", "backdrop"].map((s) => [s, (mod.match(new RegExp(`\\b${s}:\\s*\\[([^\\]]*)\\]`))?.[1].match(/src:/g) ?? []).length]),
120
+ );
121
+ if (libFile) {
122
+ const srcs = [...mod.matchAll(/src: "([^"]+)"/g)].map((m) => m[1]);
123
+ const missing = srcs.filter((s) => !existsSync(join(pub, s)));
124
+ const plates = srcs.filter((s) => s.endsWith(".svg"));
125
+ const pack = mod.match(/imagePack = "([^"]+)"/)?.[1] ?? "?";
126
+ if (imageFit === "none" && !srcs.length) add(G, "PASS", `No photo of pack "${pack}" fits this brief (imageFit none): the opener carries the first screen, and the approval stop asks for photos`);
127
+ else if (missing.length) add(G, "FAIL", `${missing.length} image(s) in ${libFile} are not on disk. Run images.mjs ${pack} again.`, missing);
128
+ else if (plates.length === srcs.length) add(G, "WARN", `struq.nl was unreachable: all ${srcs.length} images are fallback plates. Run images.mjs ${pack} again when online.`);
129
+ else if (plates.length) add(G, "WARN", `${plates.length} fallback plate(s) among the pack images. Run images.mjs ${pack} again when online.`, plates);
130
+ else add(G, "PASS", `${srcs.length} beeldbank images from pack "${pack}", each a verified image file`);
131
+ // An off-topic photo is worse than no photo: a pack the brief does not point at must show
132
+ // something the brief is about in at least one alt.
133
+ const brief = plan.match(/^Brief:\s*(.+)$/m)?.[1];
134
+ if (brief && srcs.length && packSignalForBrief(brief) !== pack) {
135
+ const alts = [...mod.matchAll(/alt: "([^"]*)"/g)].map((m) => m[1]);
136
+ if (!alts.some((alt) => altFitsBrief(alt, brief))) {
137
+ add(G, "FAIL", `Off-topic photos: none of the "${pack}" photos shows anything the brief is about. Run images.mjs --brief "<the brief>" (it leaves them out) and let the opener carry the first screen.`, alts.slice(0, 4));
138
+ }
139
+ }
140
+ } else if (realPhotos.length >= 3) {
141
+ add(G, "PASS", `${realPhotos.length} image files of the project's own in public/`);
142
+ } else {
143
+ add(G, "FAIL", "No images. Run: node <skill-dir>/scripts/images.mjs --brief \"<the brief>\" (the pack table is in SKILL.md). The images are free and CC0: no permission needed.");
144
+ }
145
+ if (brokenPhotos.length) add(G, "FAIL", "These image files are not real images (an error page saved as .webp?). Delete them and run images.mjs again.", brokenPhotos.map(rel));
146
+
147
+ // Each pack reference (images.card[2], images.card.slice(0, 5)) or own image tag counts once.
148
+ const used = own.reduce((n, f) => n + (f.text.match(/images\.(hero|band|card|column|detail|backdrop)\b|<img\b|src=["'{`]\/images\//g) ?? []).length, 0);
149
+ const total = Object.values(slotCount).reduce((a, b) => a + b, 0);
150
+ const floor = libFile ? Math.min(4, total) : 4;
151
+ if (libFile && imageFit !== "full" && total === 0) add(G, "PASS", "No image placements needed: no pack photo fits");
152
+ else if (used >= floor) add(G, "PASS", `${used} image placements in the page code`);
153
+ else add(G, "FAIL", `Only ${used} image placement(s). A Struq site carries a hero image and at least three more: use <MediaFrame image={images.card[0]} />.`);
154
+ // References past what the pack holds render as empty swatches (an inline manifesto image, a card).
155
+ if (libFile) {
156
+ const past = [];
157
+ for (const f of own) {
158
+ for (const m of f.text.matchAll(/images\.(hero|band|card|column|detail|backdrop)\[(\d+)\]/g)) {
159
+ if (Number(m[2]) >= slotCount[m[1]]) past.push(`${f.rel} images.${m[1]}[${m[2]}] (the pack holds ${slotCount[m[1]]})`);
160
+ }
161
+ }
162
+ if (past.length) add(G, "FAIL", "Image references past the pack: they render as empty swatches. Use only the photos lib/struq-images.ts lists.", past);
163
+ }
164
+ // Inline manifesto photos are a word's size: a texture (detail, backdrop) reads as a grey swatch.
165
+ const textures = [];
166
+ for (const f of own) {
167
+ for (const m of f.text.matchAll(/<ManifestoScrub\b[\s\S]*?\/>/g)) {
168
+ if (/images\.(detail|backdrop)\[/.test(m[0])) textures.push(`${f.rel}:${f.text.slice(0, m.index).split("\n").length}`);
169
+ }
170
+ }
171
+ if (textures.length) add(G, "FAIL", "Inline manifesto photo is a texture: at word size a detail or backdrop photo reads as a grey swatch. Use photos with a subject (card, column, hero).", textures);
172
+
173
+ const localSrc = [];
174
+ for (const f of own) {
175
+ for (const m of f.text.matchAll(/(?:src=|src:\s*)["'`](\/[^"'`?#]+\.(?:webp|jpe?g|png|avif|svg|gif))["'`]/g)) {
176
+ if (!existsSync(join(pub, m[1]))) localSrc.push(`${f.rel} ${m[1]}`);
177
+ }
178
+ }
179
+ if (localSrc.length) add(G, "FAIL", "Image paths that point at no file in public/:", localSrc);
180
+
181
+ const placeholders = hits(
182
+ /\b(Image|Media|Photo|Img)?Placeholder\b|Planned image|placehold\.co|via\.placeholder|picsum\.photos|source\.unsplash|nog te fotograferen|te maken:|foto volgt|beeld volgt|to be shot|image coming soon|photo coming soon/i,
183
+ { scope: own, exclude: /placeholder=["'{]/ },
184
+ );
185
+ const emptyFrames = hits(/<(div|figure|span)\b[^>]*(aspect-|aspectRatio)[^>]*(\/>|>\s*<\/(div|figure|span)>)/, { scope: own });
186
+ const hotlinks = hits(/https?:\/\/(www\.)?struq\.nl\/media\//, { scope: own });
187
+ if (placeholders.length || emptyFrames.length) {
188
+ add(G, "FAIL", "Placeholder or empty frames. A Struq site never ships an empty frame: put a MediaFrame with a pack image in each.", [...placeholders, ...emptyFrames]);
189
+ } else add(G, "PASS", "No placeholder or empty image frames");
190
+ if (hotlinks.length) add(G, "FAIL", "Hotlinked struq.nl images: download them with images.mjs and use the local /images/ path.", hotlinks);
191
+
192
+ const credit = uses(/imageCredit|struq\.nl\/beeldbank/);
193
+ if (libFile && !credit) add(G, "WARN", "No image credit. Put imageCredit (\"Beeld: Struq\") in the footer as one quiet text-small link.");
194
+ }
195
+
196
+ /* ------------------------------------------------------------------- TELLS */
197
+ // 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
199
+ // each; this is its executable form. FAIL blocks done; WARN asks for a look.
200
+ {
201
+ const G = "TELLS";
202
+ const tell = (name, pattern, fix, opts = {}) => {
203
+ const found = hits(pattern, opts);
204
+ if (found.length) add(G, opts.warn ? "WARN" : "FAIL", `${name}: ${fix}`, found);
205
+ else if (!opts.quietPass) add(G, "PASS", `No ${name.toLowerCase()}`);
206
+ };
207
+ const ownOnly = { scope: own };
208
+
209
+ // Type and labels
210
+ tell("Eyebrows and uppercase labels", /\buppercase\b|text-transform:\s*uppercase|\b(eyebrow|kicker|overline)\b/i,
211
+ "small uppercase labels above headings, uppercase marquees and tracked caps are AI tells. Delete the label; the heading, a hairline rule or a jump in scale does the wayfinding. A deliberate all-caps wordmark passes when its element carries data-wordmark.",
212
+ { exclude: /data-wordmark|<figcaption\b|data-caption/ });
213
+ tell("Monospace as a design face", /font-mono|monospace|_Mono\b|\bMono\(|JetBrains|Fira_Code|Space_Mono/,
214
+ "mono is for literal code only. Labels, stats and dates use the text face with tabular-nums.", { exclude: /<(code|pre)\b/ });
215
+ tell("Default faces", /(next\/font|font-family|--font-|--face-|import\s*\{)[^\n]*\b(Inter|Roboto|Arial|Helvetica|Open[ _]Sans|Poppins|Montserrat|Lato|Geist|Space[ _]Grotesk)\b/,
216
+ "set a canon or SKILL.md pairing with canon.mjs type (Inter, Roboto, Arial, Geist and friends read as nobody chose).");
217
+ tell("Heavy display", /(<h1|<h2|font-display|text-display|text-h2|SplitHeadline)[^\n]*font-(bold|extrabold|black|semibold)|font-(bold|extrabold|black|semibold)[^\n]*(<h1|<h2|font-display|text-display|text-h2)/,
218
+ "display runs light (300 to 400); hierarchy from scale, not weight.");
219
+ // A display heading cut off by a clipping box (overflow hidden on it or on its direct parent).
220
+ {
221
+ const DISPLAY = /font-display|text-display|text-h2|text-\[\d+(?:\.\d+)?vw\]/;
222
+ const clipped = [];
223
+ for (const f of own.filter((x) => !x.rel.endsWith(".css"))) {
224
+ for (const m of f.text.matchAll(/<(h1|h2)\b[^>]*>/g)) {
225
+ const tag = m[0];
226
+ const before = f.text.slice(0, m.index);
227
+ const parent = before.slice(before.lastIndexOf("<")).replace(/\s+$/, "");
228
+ const parentClips = /overflow-hidden/.test(parent) && parent.endsWith(">") && !parent.startsWith("</");
229
+ if (DISPLAY.test(tag) && (/overflow-hidden/.test(tag) || parentClips)) {
230
+ clipped.push(`${f.rel}:${before.split("\n").length} ${tag.slice(0, 100)}`);
231
+ }
232
+ }
233
+ }
234
+ // A long display word in a narrow column runs out of it, under the image beside it.
235
+ for (const f of own.filter((x) => !x.rel.endsWith(".css"))) {
236
+ for (const m of f.text.matchAll(/<(h1|h2|SplitHeadline)\b([^>]*)>([^<]*)</g)) {
237
+ if (!DISPLAY.test(m[2])) continue;
238
+ const before = f.text.slice(Math.max(0, m.index - 400), m.index);
239
+ const span = [...before.matchAll(/col-span-(\d+)/g)].pop();
240
+ const longest = Math.max(0, ...m[3].split(/\s+/).map((w) => w.length));
241
+ const after = f.text.slice(m.index, m.index + 700);
242
+ const lifted = /\bz-(10|20|30)\b[^>]*>[\s\S]{0,200}<(MediaFrame|Image|img)\b/.test(after);
243
+ if (span && Number(span[1]) <= 4 && (longest >= 11 || lifted)) {
244
+ clipped.push(`${f.rel}:${f.text.slice(0, m.index).split("\n").length} "${m[3].trim().slice(0, 40)}" in col-span-${span[1]}${lifted ? " beside a lifted image" : ""}`);
245
+ }
246
+ }
247
+ }
248
+ if (clipped.length) add(G, "FAIL", "Display heading clipped: a large heading sits in an overflow-hidden box (or on top of an image box) and loses its letters. Give it its own grid cell beside the image, never under or behind it.", clipped);
249
+ }
250
+ tell("Numbered sections", />\s*0[1-9]\s*[<./]|['"`]0[1-9]['"`]|`0\$\{/,
251
+ "01, 02, 03 stamped on sections or rows is a tell; keep at most one deliberate oversized numeral.", ownOnly);
252
+ tell("Stat rows", />\s*\d{1,4}\s*[+%]\s*</,
253
+ "a banner of big numbers (20+, 51+, 99%) is a template tell, and usually invented. A real figure from the brief goes inside a sentence.", ownOnly);
254
+ tell("Raw text sizes", /\btext-(xs|sm|base|lg|xl|[2-9]xl)\b/, "use the role tokens: text-display, text-h2, text-h3, text-lead, text-body, text-small.", { ...ownOnly, warn: true, quietPass: true });
255
+ tell("Breakpoint size ladders", /text-\w+\s+md:text-\w+\s+lg:text-\w+|p[xy]-\d+\s+md:p[xy]-\d+\s+lg:p[xy]-\d+/, "one fluid root-rem knob; breakpoints change layout, never size.", { ...ownOnly, warn: true, quietPass: true });
256
+
257
+ // Colour and surface
258
+ tell("Gradients", /bg-gradient-|bg-linear-|bg-radial|bg-conic|(linear|radial|conic)-gradient\(|\b(from|via|to)-(?!transparent\b)[a-z]+-\d{1,3}\b/,
259
+ "flat surface steps (bg-surface-1, bg-surface-2) and a hairline; never a gradient.", { exclude: /mask/i });
260
+ tell("Gradient text", /bg-clip-text|background-clip:\s*text/, "headlines are ink; hierarchy comes from scale.");
261
+ tell("Tinted translucent fills and glass", /backdrop-blur|backdrop-filter|\bbg-(accent|white|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone|surface-\d)(-\d+)?\/\d+/,
262
+ "an opaque surface step. A flat black scrim over a photo is the only translucent fill.");
263
+ tell("Status badges", /\bbg-(green|red|blue|yellow|emerald|amber|lime|sky)-(50|100|200)\b/, "no pastel status chips; say it in the copy.");
264
+ tell("Pills", /rounded-full[^"'`]*\bpx-|\bpx-[^"'`]*rounded-full/, "buttons and labels take the site radius (0 to 4px), never a pill.");
265
+ tell("Live dots", /animate-(ping|pulse)/, "no pulsing dot, above all not in a pill.");
266
+ tell("Blue and purple utilities", /\b(text|bg|border|fill|stroke|ring|outline|decoration|shadow|accent)-(blue|indigo|violet|purple|fuchsia|sky)-\d/,
267
+ "the palette tokens carry every colour; blue and purple only when the brand is that, declared in STRUQ.md.");
268
+ tell("Drop-shadow elevation", /\bshadow-(md|lg|xl|2xl)\b/, "elevation is a tonal surface step plus a hairline.");
269
+ tell("Side-stripe borders", /\bborder-[lr]-([2-9]|\[[2-9])/, "no coloured stripe down a card edge; a hairline or nothing.");
270
+
271
+ // Layout and motion
272
+ tell("Identical card grids", /grid-cols-3\b/, "three equal cards in a row read as a template; asymmetric 5/7 splits or hairline rows.", { ...ownOnly, warn: true, quietPass: true });
273
+ tell("Uniform section padding", /\bpy-(16|24)\b/, "padding varies by role (dense 6 to 8rem, breathers 12 to 20rem).", { ...ownOnly, warn: true, quietPass: true });
274
+ tell("Opacity-only entrances", /initial=\{\{\s*opacity:\s*0\s*\}\}|whileInView=\{\{\s*opacity:[^,}]*\}\}/, "every entrance moves AND fades: <Reveal>, or y with autoAlpha.");
275
+ tell("Uniform hovers", /hover:scale-1\d\d\b|transition-all\b/, "one designed hover per element type; never scale-105 plus transition-all on everything.", { ...ownOnly, warn: true, quietPass: true });
276
+ tell("Single-layer parallax", /background-attachment:\s*fixed|\bbg-fixed\b/, "depth needs relative rates: MediaFrame parallax inside its frame.");
277
+ tell("anticipatePin with smooth scroll", /anticipatePin/, "remove it; scrub: 1. With Lenis it jerks the pin on engage.");
278
+
279
+ // Icons and content
280
+ tell("Cliche icons and emoji", /<(Sparkles|Rocket|Zap|Lightbulb|Wand|Flame)\b|\b(Sparkles|Rocket|Zap)Icon\b|[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u, "no emoji, no sparkle, rocket, lightning, bulb, wand or flame icons.");
281
+ tell("Filler content", /Lorem ipsum|\bAcme\b|John Doe|Jane Doe|SITE_TITLE|SITE_DESCRIPTION|Your journey starts here/i, "real copy for the brief; replace the layout's SITE_TITLE and SITE_DESCRIPTION.");
282
+ tell("Dashes", /[\u2012\u2013\u2014\u2015\u2E3A\u2E3B]|&(mdash|ndash|horbar);|&#(821[0-3]);|&#x201[2-5];/i,
283
+ "em and en dashes read as machine-written copy. Restructure the sentence: a comma, a colon, a full stop, or \"to\" for a range.");
284
+ tell("Generic CTAs", />\s*(Learn more|Get started|Read more|Click here|Submit|Sign up|Contact us|Get in touch|Discover more|Find out more|Join now|Lees meer|Meer informatie|Meer weten|Klik hier|Neem contact op|Aan de slag|Ontdek meer)\s*</i, "name the action and its result, verb first: \"Reserveer een tafel voor vanavond\", \"Bekijk de projecten\", \"Plan een kennismaking\" (Struq tells 1.2.0, generic-ctas).", ownOnly);
285
+ tell("Generic section headings", />\s*(Features|About Us|Our Services|Why Choose Us|What We Do|Our Story|Capabilities|Onze diensten|Over ons|Waarom kiezen voor ons)\s*</i, "a heading that says something only this business could say.", { ...ownOnly, warn: true, quietPass: true });
286
+ tell("Hype copy", /\b(Unleash|Supercharge|Next-?gen|Seamless|Elevate your|Revolutioni[sz]e|Game-?changer|Cutting-?edge|State-of-the-art|Unparalleled|Next-level|Turbocharge|naadloos|ontgrendel|revolutionair|toekomstbestendig)\b/i, "plain, specific claims from the brief.", { ...ownOnly, warn: true, quietPass: true });
287
+
288
+ // Palette hues: blue and purple only when STRUQ.md declares it. Hex and oklch() tokens.
289
+ const css = files.filter((f) => f.rel.endsWith(".css")).map((f) => f.text).join("\n");
290
+ const coolDeclared = /cool hue:\s*declared/i.test(plan);
291
+ const cool = [];
292
+ for (const m of css.matchAll(/--(?:color-)?(surface-\d|ink|ink-muted|accent|accent-ink|accent-2)\s*:\s*(#[0-9a-f]{6}\b|oklch\([^)]*\))/gi)) {
293
+ const v = m[2];
294
+ const o = v.match(/oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)/i);
295
+ if (o) {
296
+ const L = o[2] ? parseFloat(o[1]) / 100 : parseFloat(o[1]);
297
+ const C = parseFloat(o[3]);
298
+ const H = parseFloat(o[4]);
299
+ if (C > 0.06 && H >= 220 && H <= 320 && L > 0.12 && L < 0.92) cool.push(`--${m[1]}: ${v}`);
300
+ continue;
301
+ }
302
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(v.slice(i, i + 2), 16) / 255);
303
+ const max = Math.max(r, g, b);
304
+ const min = Math.min(r, g, b);
305
+ const l = (max + min) / 2;
306
+ const d = max - min;
307
+ if (d === 0) continue;
308
+ const s = d / (1 - Math.abs(2 * l - 1));
309
+ let h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
310
+ h = (h * 60 + 360) % 360;
311
+ if (h >= 200 && h <= 320 && s > 0.3 && l > 0.12 && l < 0.88) cool.push(`--${m[1]}: ${v} (hue ${Math.round(h)})`);
312
+ }
313
+ // Purple or blue written straight into components, outside the tokens.
314
+ for (const f of own.filter((x) => !x.rel.endsWith(".css"))) {
315
+ for (const m of f.text.matchAll(/#([0-9a-f]{6})\b/gi)) {
316
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16) / 255);
317
+ const max = Math.max(r, g, b);
318
+ const min = Math.min(r, g, b);
319
+ const d = max - min;
320
+ if (d < 0.25) continue;
321
+ let h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
322
+ h = (h * 60 + 360) % 360;
323
+ if (h >= 220 && h <= 320) cool.push(`${f.rel}: #${m[1]} (hue ${Math.round(h)})`);
324
+ }
325
+ }
326
+ if (cool.length && !coolDeclared) add(G, "FAIL", "Blue or purple in the palette. Pick a canon palette that fits (canon.mjs list), or write \"Cool hue: declared because <the brand's own colour>\" in STRUQ.md.", cool);
327
+ else add(G, "PASS", cool.length ? "Cool hue declared in STRUQ.md" : "Palette free of default blue and purple");
328
+ }
329
+
330
+ /* ------------------------------------------------------------------ MOTION */
331
+ {
332
+ const G = "MOTION";
333
+ 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");
336
+ 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.");
367
+
368
+ if (/prefers-reduced-motion/.test(all)) add(G, "PASS", "prefers-reduced-motion respected");
369
+ 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
+ }
374
+
375
+ /* -------------------------------------------------------------------- TYPE */
376
+ {
377
+ const G = "TYPE";
378
+ 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));
382
+ }
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]}`);
385
+ }
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.`);
394
+
395
+ 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];
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).");
401
+
402
+ if (/html\s*\{[^}]*font-size:\s*clamp\(/.test(css)) add(G, "PASS", "One fluid root-rem knob");
403
+ else add(G, "FAIL", "No fluid root font size: html { font-size: clamp(1rem, 0.92rem + 0.2vw, 1.125rem); }");
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);
410
+ if (h1 >= 1) add(G, "PASS", "The page has its h1");
411
+ else add(G, "FAIL", "No h1 on the page.");
412
+ }
413
+
414
+ /* -------------------------------------------------------------------- PLAN */
415
+ {
416
+ const G = "PLAN";
417
+ if (!plan) add(G, "FAIL", "No STRUQ.md in the project root: write the plan and the Build checklist (SKILL.md).");
418
+ else {
419
+ const open = plan.split(/\r?\n/).filter((l) => /^\s*[-*]\s*\[\s\]/.test(l) && !/check\.mjs/.test(l));
420
+ const done = plan.split(/\r?\n/).filter((l) => /^\s*[-*]\s*\[[xX]\]/.test(l)).length;
421
+ 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)`);
423
+ 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)");
427
+ }
428
+ }
429
+
430
+ /* -------------------------------------------------------------------- HERO */
431
+ {
432
+ 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.
446
+ 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.
467
+ 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.");
469
+ else if (scenes > 2) add(G, "FAIL", `${scenes} contrast scenes: one, at most two, or the contrast stops being a moment.`);
470
+ 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);
475
+ else add(G, "PASS", "No product modelled from primitives");
476
+ }
477
+
478
+ /* --------------------------------------------------------------------- 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.
481
+ {
482
+ const G = "LOG";
483
+ const log = read("STRUQ-LOG.md");
484
+ 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
+ else {
486
+ const ticked = (plan.match(/^\s*[-*]\s*\[[xX]\]\s*(\d+)/gm) ?? []).map((l) => Number(l.match(/(\d+)\s*$/)[1])).filter((n) => n !== 12);
487
+ const missing = ticked.filter((n) => !new RegExp(`^## Step ${n}\\b`, "m").test(log));
488
+ const entries = (log.match(/^## /gm) ?? []).length;
489
+ 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])) {
491
+ 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
+ } 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
+ 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
+ else add(G, "PASS", `STRUQ-LOG.md has ${entries} entries, one or more for every ticked step`);
515
+ }
516
+ }
517
+
518
+ /* ------------------------------------------------------------------ REPORT */
519
+ const order = ["IMAGES", "TELLS", "MOTION", "HERO", "TYPE", "PLAN", "LOG"];
520
+ console.log(`Struq check: ${root}\n`);
521
+ for (const g of order) {
522
+ const rows = results.filter((r) => r.group === g);
523
+ if (!rows.length) continue;
524
+ console.log(g);
525
+ for (const r of rows) {
526
+ console.log(` ${r.status.padEnd(4)} ${r.message}`);
527
+ for (const w of r.where.slice(0, 8)) console.log(` ${w}`);
528
+ if (r.where.length > 8) console.log(` ... and ${r.where.length - 8} more`);
529
+ }
530
+ console.log("");
531
+ }
532
+ const fails = results.filter((r) => r.status === "FAIL").length;
533
+ const warns = results.filter((r) => r.status === "WARN").length;
534
+ if (!args.includes("--no-log")) {
535
+ const names = (status) => results.filter((r) => r.status === status).map((r) => `${r.group} ${r.message.split(":")[0].replace(/[.(].*$/, "").trim()}`);
536
+ const passed = results.filter((r) => r.status === "PASS").length;
537
+ appendLog(root, "Step 12 Check", [
538
+ `check.mjs: RESULT ${fails ? "FAIL" : "PASS"}, ${passed} pass, ${fails} fail, ${warns} warn.`,
539
+ ...(fails ? [`Fail: ${names("FAIL").join("; ")}.`] : []),
540
+ ...(warns ? [`Warn: ${names("WARN").join("; ")}.`] : []),
541
+ ]);
542
+ }
543
+ if (fails) {
544
+ console.log(`RESULT: FAIL (${fails} fail, ${warns} warn). Fix every FAIL, run the check again, and only then report done.`);
545
+ process.exit(1);
546
+ }
547
+ console.log(`RESULT: PASS${warns ? ` (${warns} warn: read them, fix what applies)` : ""}`);
@@ -0,0 +1,40 @@
1
+ // Struq fonts: the exact Google Fonts family name for a face the canon names.
2
+ //
3
+ // The canon names a face the way a designer says it ("Big Shoulders Display"); Google Fonts and
4
+ // next/font may list it under its family ("Big Shoulders", with an opsz axis that does the display
5
+ // cut). googleName finds the listed family, so a canon pairing never falls back to a default.
6
+
7
+ /** Canon names that Google lists under another family. */
8
+ export const GOOGLE_ALIAS = {
9
+ "Big Shoulders Display": "Big Shoulders",
10
+ "Big Shoulders Text": "Big Shoulders",
11
+ "Big Shoulders Inline Display": "Big Shoulders Inline",
12
+ "Big Shoulders Stencil Display": "Big Shoulders Stencil",
13
+ };
14
+
15
+ /** The optical-size suffixes a canon name may carry that the Google family does not. */
16
+ const SUFFIXES = [" Display", " Text", " Headline", " Deck", " Micro", " Caption"];
17
+
18
+ /**
19
+ * The Google family for a canon face name, or null when Google does not list it.
20
+ * fontData is next/font's own font-data.json (family -> metadata), read from the project.
21
+ */
22
+ export function googleName(name, fontData) {
23
+ if (!name) return null;
24
+ const has = (n) => Boolean(fontData && Object.prototype.hasOwnProperty.call(fontData, n));
25
+ if (has(name)) return name;
26
+ const alias = GOOGLE_ALIAS[name];
27
+ if (alias && (has(alias) || !fontData)) return alias;
28
+ for (const suffix of SUFFIXES) {
29
+ if (name.endsWith(suffix)) {
30
+ const base = name.slice(0, -suffix.length);
31
+ if (has(base)) return base;
32
+ }
33
+ }
34
+ return fontData ? null : name;
35
+ }
36
+
37
+ /** The Google Fonts CSS URL for a family, for checks and for agents without next/font data. */
38
+ export function googleCssUrl(family) {
39
+ return `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family).replace(/%20/g, "+")}&display=swap`;
40
+ }