@struq/skill 0.5.1 → 0.5.3

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 (65) hide show
  1. package/README.md +137 -100
  2. package/package.json +5 -3
  3. package/skills/struq/SKILL.md +168 -165
  4. package/skills/struq/blocks/CATALOG.md +49 -46
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +2 -1
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +5 -3
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +6 -2
  8. package/skills/struq/blocks/catalog.json +50 -5
  9. package/skills/struq/blocks/film.json +11 -7
  10. package/skills/struq/blocks/footers/footer-contact.tsx +8 -78
  11. package/skills/struq/blocks/headers/header-bar.tsx +144 -29
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +36 -19
  13. package/skills/struq/blocks/heroes/hero-poster.tsx +36 -10
  14. package/skills/struq/blocks/kit/HeroCanvas.tsx +6 -2
  15. package/skills/struq/blocks/kit/SplitHeadline.tsx +2 -1
  16. package/skills/struq/blocks/kit/accent.ts +29 -3
  17. package/skills/struq/blocks/kit/blocks.css +530 -6
  18. package/skills/struq/blocks/kit/feel.ts +183 -0
  19. package/skills/struq/blocks/kit/gsap.ts +9 -6
  20. package/skills/struq/blocks/kit/middle/PlaceHours.tsx +254 -0
  21. package/skills/struq/blocks/kit/middle/ProcessStrip.tsx +428 -0
  22. package/skills/struq/blocks/kit/middle/ServicesList.tsx +496 -0
  23. package/skills/struq/blocks/kit/middle/entrance.ts +98 -0
  24. package/skills/struq/blocks/kit/middle/hours.ts +154 -0
  25. package/skills/struq/blocks/kit/middle/index.ts +14 -0
  26. package/skills/struq/blocks/kit/motion/HoverLink.tsx +33 -0
  27. package/skills/struq/blocks/kit/motion/ImageCurtain.tsx +79 -0
  28. package/skills/struq/blocks/kit/motion/MaskWipe.tsx +91 -0
  29. package/skills/struq/blocks/kit/motion/Pressable.tsx +58 -0
  30. package/skills/struq/blocks/kit/motion/RevealText.tsx +103 -0
  31. package/skills/struq/blocks/kit/motion/Rise.tsx +77 -0
  32. package/skills/struq/blocks/kit/motion/ScrubLine.tsx +108 -0
  33. package/skills/struq/blocks/kit/motion/index.ts +11 -0
  34. package/skills/struq/blocks/kit/quiet/Close.tsx +178 -52
  35. package/skills/struq/blocks/kit/quiet/Statement.tsx +3 -2
  36. package/skills/struq/blocks/kit/subject/AppScreen.tsx +388 -0
  37. package/skills/struq/blocks/kit/subject/Document.tsx +742 -0
  38. package/skills/struq/blocks/kit/subject/Mark.tsx +44 -0
  39. package/skills/struq/blocks/kit/subject/PatternPaper.tsx +410 -0
  40. package/skills/struq/blocks/kit/subject/SectionDrawing.tsx +349 -0
  41. package/skills/struq/blocks/kit/subject/Ticket.tsx +354 -0
  42. package/skills/struq/blocks/kit/subject/build.ts +66 -0
  43. package/skills/struq/blocks/kit/subject/draw.ts +196 -0
  44. package/skills/struq/blocks/kit/subject/drawings.ts +373 -0
  45. package/skills/struq/blocks/kit/subject/format.ts +122 -0
  46. package/skills/struq/blocks/kit/subject/index.ts +11 -0
  47. package/skills/struq/blocks/kit/subject/patterns.ts +204 -0
  48. package/skills/struq/blocks/preloaders/preloader-stack.tsx +75 -22
  49. package/skills/struq/blocks/showpieces/hero-opener.tsx +55 -7
  50. package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +64 -6
  51. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +56 -3
  52. package/skills/struq/blocks/showpieces/panel-wipe.tsx +93 -11
  53. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +38 -5
  54. package/skills/struq/canon/canon.json +1 -1
  55. package/skills/struq/references/art-direction.md +51 -0
  56. package/skills/struq/scripts/blocks.mjs +56 -20
  57. package/skills/struq/scripts/canon.mjs +36 -7
  58. package/skills/struq/scripts/check.mjs +417 -11
  59. package/skills/struq/scripts/draw.mjs +49 -0
  60. package/skills/struq/scripts/film-check.mjs +28 -4
  61. package/skills/struq/scripts/gate.mjs +13 -3
  62. package/skills/struq/scripts/hand.mjs +222 -0
  63. package/skills/struq/scripts/images.mjs +12 -3
  64. package/skills/struq/scripts/look.mjs +1184 -0
  65. package/skills/struq/scripts/stamp.mjs +47 -0
@@ -7,15 +7,20 @@
7
7
  //
8
8
  // It needs no browser and no dependencies. It reads the source and the public folder and prints
9
9
  // PASS, WARN or FAIL per check, in groups: images, AI tells, the motion floor, the hero, the film
10
- // (film-check.mjs, the budgets Struq's MCP holds too), typography, plus the plan in STRUQ.md. Every
10
+ // (film-check.mjs, the budgets Struq's MCP holds too), typography, plus the plan in STRUQ.md, the
11
+ // look look.mjs took of the rendered page (.struq/look.json, which must pass and be newer than the source), and the
12
+ // art director's asks (.struq/art-direction.md: each answered Built: or Refused:, never on the storyboard). Every
11
13
  // FAIL names the file and line and the fix. Exit code 1 while any FAIL remains. Fix, run again, and
12
14
  // only report done on "RESULT: PASS".
13
15
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
14
16
  import { join, relative, resolve, sep } from "node:path";
15
17
  import { appendLog } from "./log.mjs";
16
18
  import { altFitsBrief, packSignalForBrief } from "./packs.mjs";
19
+ import { savedHand } from "./draw.mjs";
17
20
  import { filmCheck, planCheck } from "./film-check.mjs";
21
+ import { checkHand } from "./hand.mjs";
18
22
  import { hasApproval } from "./gate.mjs";
23
+ import { sourceStamp } from "./stamp.mjs";
19
24
 
20
25
  const CANON = JSON.parse(readFileSync(new URL("../canon/canon.json", import.meta.url), "utf8"));
21
26
  const CATALOG = JSON.parse(readFileSync(new URL("../blocks/catalog.json", import.meta.url), "utf8")).blocks;
@@ -64,6 +69,12 @@ if (args.includes("--plan")) {
64
69
  const appDir = ["app", "src/app"].map((d) => join(root, d)).find((d) => existsSync(d));
65
70
  const routes = Math.max(1, appDir ? walk(appDir).filter((p) => /(^|[\\/])page\.(tsx|jsx|js)$/.test(p)).length : 0);
66
71
  const { findings } = planCheck({ plan: planText, catalog: CATALOG, routes });
72
+ // The plan holds to the site's hand (canon.mjs draw), checked by Struq's hand.mjs, the same rule the MCP runs.
73
+ const hand = savedHand(root);
74
+ if (hand) findings.push(...checkHand(planText, hand));
75
+ else {
76
+ findings.push({ severity: "warn", id: "no-draw", detail: "No hand dealt for this site", fix: "run canon.mjs draw and choose the Palette and Type from it" });
77
+ }
67
78
  for (const f of findings) console.log(`${f.severity === "fail" ? "FAIL" : "WARN"} ${f.id}: ${f.detail.replace(/\.$/, "")}. Fix: ${f.fix}`);
68
79
  const failed = findings.some((f) => f.severity === "fail");
69
80
  console.log(`PLAN: ${failed ? "FAIL" : "PASS"}`);
@@ -128,6 +139,36 @@ const pkg = JSON.parse(read("package.json"));
128
139
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
129
140
  const plan = read("STRUQ.md");
130
141
 
142
+ /** "file:line" for an index into a file's text. */
143
+ const lineOf = (f, index) => `${f.rel}:${f.text.slice(0, index).split("\n").length}`;
144
+
145
+ /**
146
+ * The names a site shows: the wordmark props of its header, preloader, hero and footer blocks, a data-wordmark element,
147
+ * the name and title in lib/site.ts or a file like it, and the page title. Each with where it stands.
148
+ */
149
+ const SHOWN_NAMES = (() => {
150
+ const shown = [];
151
+ const seen = (name, where) => name.trim() && shown.push({ name: name.trim(), where });
152
+ for (const f of own.filter((x) => !x.rel.endsWith(".css"))) {
153
+ for (const m of f.text.matchAll(/<(?:Header|MenuCurtain|Preloader|Footer|Wordmark|Hero|PageTransition|SiteHeader)\w*\b[^>]*?\bname=\{?\s*["'`]([^"'`]+)["'`]/g)) seen(m[1], lineOf(f, m.index));
154
+ for (const m of f.text.matchAll(/<\w+\b[^>]*\bdata-wordmark\b[^>]*>([^<{]+)</g)) seen(m[1], lineOf(f, m.index));
155
+ if (/(?:^|\/)(?:lib|src\/lib|config|src\/config|data|app)\/(?:site|brand|identity|config|constants|metadata)\.(?:ts|tsx|js|mjs)$/.test(f.rel)) {
156
+ for (const m of f.text.matchAll(/\b(?:name|siteName|brandName|title)\s*:\s*["'`]([^"'`]+)["'`]/g)) seen(m[1], lineOf(f, m.index));
157
+ }
158
+ if (/(?:^|\/)app\/layout\.(?:tsx|jsx|ts|js)$/.test(f.rel)) {
159
+ for (const m of f.text.matchAll(/\b(?:title|default)\s*:\s*["'`]([^"'`]+)["'`]/g)) seen(m[1], lineOf(f, m.index));
160
+ }
161
+ }
162
+ return shown;
163
+ })();
164
+
165
+ /** The plan's Brief line. A name it gives stands in it capitalised, never as its first word. */
166
+ const BRIEF = plan.match(/^Brief:\s*(.+)$/m)?.[1] ?? "";
167
+ function namedInBrief(name) {
168
+ const re = new RegExp(`(?<![\\p{L}\\p{N}])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\p{L}\\p{N}])`, "gu");
169
+ return [...BRIEF.matchAll(re)].some((m) => m.index > 0);
170
+ }
171
+
131
172
  /* ------------------------------------------------------------------ IMAGES */
132
173
  {
133
174
  const G = "IMAGES";
@@ -163,9 +204,11 @@ const plan = read("STRUQ.md");
163
204
  else if (plates.length) add(G, "WARN", `${plates.length} fallback plate(s) among the pack images. Run images.mjs ${pack} again when online.`, plates);
164
205
  else add(G, "PASS", `${srcs.length} beeldbank images from pack "${pack}", each a verified image file`);
165
206
  // An off-topic photo is worse than no photo: a pack the brief does not point at must show
166
- // something the brief is about in at least one alt.
207
+ // something the brief is about in at least one alt. A module images.mjs --brief already fitted is trusted: its
208
+ // Dutch alts never match an English brief word for word, which made this a false fail (proof run 2026-11-14).
167
209
  const brief = plan.match(/^Brief:\s*(.+)$/m)?.[1];
168
- if (brief && srcs.length && packSignalForBrief(brief) !== pack) {
210
+ const fitted = /Fitted to the brief/.test(mod);
211
+ if (brief && srcs.length && !fitted && packSignalForBrief(brief) !== pack) {
169
212
  const alts = [...mod.matchAll(/alt: "([^"]*)"/g)].map((m) => m[1]);
170
213
  if (!alts.some((alt) => altFitsBrief(alt, brief))) {
171
214
  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));
@@ -242,7 +285,7 @@ const plan = read("STRUQ.md");
242
285
  if (hotlinks.length) add(G, "FAIL", "Hotlinked Struq images: download them with images.mjs and use the local /images/ path.", hotlinks);
243
286
 
244
287
  const credit = uses(/imageCredit|struq\.app\/photos|struq\.nl\/beeldbank/);
245
- if (libFile && !credit) add(G, "WARN", "No image credit. Put imageCredit (\"Beeld: Struq\") in the footer as one quiet text-small link.");
288
+ if (libFile && !credit) add(G, "WARN", "No image credit. Put imageCredit ('Photos: Struq', 'Beeld: Struq' on a Dutch site) in the footer as one quiet text-small link.");
246
289
  }
247
290
 
248
291
  /* ------------------------------------------------------------------- TELLS */
@@ -261,8 +304,125 @@ const plan = read("STRUQ.md");
261
304
 
262
305
  // Copy
263
306
  tell("Notes to the client on the page",
264
- /volg(?:t|en) zodra|zodra (?:de studio|de klant|je|jullie|we)\b[^"'<>]{0,40}(?:aanlever|doorgeef|doorgeven|binnen)|nog aan te leveren|wordt nog aangevuld|lorem ipsum|\bcoming soon\b|to be confirmed|\bTBC\b|\[(?:naam|adres|telefoon|e-?mail|name|phone|address)\]/i,
265
- "a line written to the client or the developer is not copy. Leave a missing fact out of the page and list it under Assumptions: in STRUQ.md.");
307
+ /volg(?:t|en) zodra|zodra (?:de studio|de klant|je|jullie|we)\b[^"'<>]{0,40}(?:aanlever|doorgeef|doorgeven|binnen)|nog aan te leveren|wordt nog aangevuld|(?:telefoon|e-?mail|adres|reserveren|gegevens|openingstijden|data|datum|prijzen)[^.<>"'{}]{0,30}\bvolg(?:t|en)\b|\bvolg(?:t|en) (?:nog|later|binnenkort)\b|lorem ipsum|\bcoming soon\b|to be (?:confirmed|announced)|will be announced|details to (?:follow|come)|\bto follow\)|\bTB[AC]\b|\byour (?:name|company|logo|photo|text) here\b|\bYour Name\b|\bexample\.(?:com|org|net)\b|\b(?:John|Jane) Doe\b|\[(?:naam|adres|telefoon|e-?mail|name|phone|address)\]/i,
308
+ "a line written to the client or the developer, or a stand-in (Your Name, example.com, to be announced), is not copy. A missing fact leaves no line on the page: drop the sentence, the field or the button, and list it under Assumptions: in STRUQ.md.");
309
+
310
+ // The visible copy of your own files: string literals and JSX text, line by line, comments left out. Class lists,
311
+ // paths and identifiers hold no sentences, so the honesty phrases below match the words a reader sees.
312
+ const copy = [];
313
+ for (const f of own.filter((x) => !x.rel.endsWith(".css"))) {
314
+ f.text.split(/\r?\n/).forEach((line, i) => {
315
+ const t = line.trim();
316
+ if (!t || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*") || t.startsWith("{/*")) return;
317
+ const segs = [];
318
+ for (const m of line.matchAll(/(["'`])((?:\\.|(?!\1)[^\\])*)\1/g)) segs.push(m[2]);
319
+ for (const m of line.matchAll(/>([^<>{}]+)(?=<|$)/g)) segs.push(m[1]);
320
+ // A line of JSX text between an opening and a closing tag on lines of their own.
321
+ if (/^[^<>{}=;()[\]]+$/.test(t)) segs.push(t);
322
+ const words = segs.map((s) => s.trim()).filter((s) => /[\p{L}\p{N}]/u.test(s));
323
+ if (words.length) copy.push({ where: `${f.rel}:${i + 1}`, line: t, segs: words });
324
+ });
325
+ }
326
+ const copyHits = (re) => copy.filter((c) => c.segs.some((s) => re.test(s))).map((c) => `${c.where} ${c.line.slice(0, 120)}`);
327
+
328
+ // Copy that promises what is not there yet. Bare "not yet" and "nog niet" are common in a factual sentence, so they
329
+ // count only with an availability word after them, or closing the line.
330
+ const soon = copyHits(
331
+ /\bcoming soon\b|\b(?:is|are|we're|we are|now|soon) launching\b|\blaunching (?:soon|in|on|this|next|later|early|late|spring|summer|autumn|fall|winter|\d)|\blaunching[.!]?$|\bon (?:its|their) way\b|\bsoon available\b|\bavailable soon\b|\bnot yet (?:available|live|open|ready|online|launched|released|public|bookable|for sale|on sale|in stock)\b|\bnot yet[.!]?$|\bbinnenkort\b|\bvolg(?:t|en) snel\b|\bnog niet (?:beschikbaar|live|open|online|klaar|te koop|verkrijgbaar|bekend|te boeken|te reserveren|gepubliceerd|af)\b|\bnog niet[.!]?$|\bin de maak\b/i,
332
+ );
333
+ if (soon.length) add(G, "FAIL", "Coming-soon copy: a line that promises what is not there yet (coming soon, launching, on its way, binnenkort, volgt snel, nog niet beschikbaar, in de maak) is not copy. The site shows what exists today: drop the line, and list what is missing under Assumptions: in STRUQ.md.", soon);
334
+ else add(G, "PASS", "No coming-soon copy");
335
+
336
+ // An invitation needs a door: an address, a phone number, an email, a mailto or tel link, or an external booking or
337
+ // shop link somewhere on the page. Two kinds are held to it: the "come by" wording (tonight, see you, kom langs), and
338
+ // the imperative call in the trade's own words (Schuif aan, Pick up a mug, Book a class, Reserveer, Shop the collection)
339
+ // in a heading, a button, a link or a closing line. A statement that merely mentions the trade is no call: the verb
340
+ // opens a short line, and the line is the call.
341
+ const COME_BY = /\btonight\b|\bsee you\b|\bcome by\b|\bdrop in\b|\bcall us\b|\bvanavond\b|\btot straks\b|\bkom langs\b|\bloop (?:even )?binnen\b|\bbel ons\b|\bwaarvoor u ons belt\b/i;
342
+ const END = "(?:\\s|[.!,]|$)";
343
+ const CALL = new RegExp(
344
+ `^[\\s"'(\\[\\u2018\\u201C]*(?:${[
345
+ // English
346
+ `visit${END}`, "come\\s+(?:and|in|eat|taste|try|see|join|sit|visit|hungry|by|over|along)\\b", "(?:eat|dine|taste)\\s+(?:with|at|here|in|the|our|your|something|a|some)\\b",
347
+ "book\\s+(?:a|an|your|the|now|online|tickets?|ahead|with|today)\\b", `buy${END}`, "order\\s+(?:a|an|your|the|now|online|ahead|here|today|one|it)\\b",
348
+ "shop\\s+(?:the|our|all|now|online|this|new|with)\\b", `join${END}`, "try\\s+(?:a|an|one|it|the|our|us|this|some|your)\\b", "sit\\s+(?:down|with|at|in)\\b",
349
+ "pick\\s+up\\b", "roll\\s+out\\b", "grab\\s+(?:a|an|your|the|some|one)\\b", "reserve\\s+(?:a|an|your|the|now|online|seats?|tickets?|today)\\b",
350
+ "save\\s+(?:the\\s+date|your\\s+(?:seat|spot|place))\\b", "take\\s+a\\s+(?:seat|class|lesson)\\b", "pull\\s+up\\s+a\\s+chair\\b", "step\\s+(?:in|inside|up)\\b",
351
+ "stop\\s+by\\b", "walk\\s+in\\b", "get\\s+(?:your\\s+|a\\s+|an\\s+)?(?:tickets?|quote|table|seat|spot)\\b", "enrol{1,2}\\s+(?:now|today|in|for)\\b", "find\\s+us\\b",
352
+ // Dutch
353
+ "kom\\s+(?:proeven|eten|binnen|langs|kijken|naar|bij|eens|gerust|erbij|meedoen|kennismaken|mee|zitten)\\b", "schuif\\s+(?:even\\s+|gerust\\s+|gezellig\\s+)?aan\\b",
354
+ `bestel${END}`, `reserveer${END}`, "boek\\s+(?:een|je|jouw|uw|nu|online|tickets?|alvast|plek|plaats)\\b", `koop${END}`,
355
+ "neem\\s+(?:een\\s+)?(?:plaats|zitplaats|stoel)\\b", "ga\\s+zitten\\b", "proef\\s+(?:het|de|een|ons|onze|wat|zelf|mee)\\b", "probeer\\s+(?:een|het|de|ons|onze|eens|zelf|wat)\\b",
356
+ "doe\\s+mee\\b", "sluit\\s+(?:je|u)\\s+aan\\b", "meld\\s+(?:je|u)\\s+aan\\b", "schrijf\\s+(?:je|u)\\s+in\\b", "stap\\s+(?:gerust\\s+)?binnen\\b",
357
+ "loop\\s+(?:gerust\\s+|even\\s+)?(?:naar\\s+)?binnen\\b", "pak\\s+(?:een|je|uw)\\b", "bezoek\\s+(?:ons|onze|de)\\b", "vind\\s+ons\\b", "eet\\s+(?:mee|met|bij)\\b",
358
+ ].join("|")})`,
359
+ "i",
360
+ );
361
+ const isCall = (s) => {
362
+ const words = s.trim().split(/\s+/);
363
+ if (words.length > 8 || s.length > 80 || !CALL.test(s)) return false;
364
+ // A lone lowercase word is a class name or an identifier, not a button.
365
+ return words.length > 1 || /^[^\p{L}]*\p{Lu}/u.test(s);
366
+ };
367
+ const calls = copy.filter((c) => {
368
+ // A class list is no copy: the strings of className and class attributes are left out.
369
+ const classes = new Set([...c.line.matchAll(/\b(?:className|class)\s*=\s*\{?\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/g)].map((m) => m[2].trim()));
370
+ const segs = c.segs.filter((s) => !classes.has(s));
371
+ const wholeLine = segs.length > 1 && segs.length <= 4 && segs.every((s) => s.split(/\s+/).length <= 5) ? [segs.join(" ")] : [];
372
+ return [...segs, ...wholeLine].some(isCall);
373
+ }).map((c) => `${c.where} ${c.line.slice(0, 120)}`);
374
+ const invites = [...new Set([...copyHits(COME_BY), ...calls])];
375
+ if (invites.length) {
376
+ const DOORS = [
377
+ /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/,
378
+ /(?:\+|\b00)\d{1,3}[\s.-]?\(?\d[\d\s.()-]{6,}\d|\b0\d{1,3}[\s.-]?\d{3}[\s.-]?\d{2,4}(?:[\s.-]?\d{2,4})?\b/,
379
+ /\b\p{Lu}[\p{L}'-]*(?:straat|weg|laan|plein|gracht|kade|dijk|singel|markt|steeg|dreef|hof|pad|haven|wal|baan|burgwal|straße|strasse|gasse|platz)\s+\d+[a-z]?\b/u,
380
+ /\b[1-9]\d{3}\s?[A-Z]{2}\b/,
381
+ /\b\d{1,5}\s+(?:\p{Lu}[\p{L}'-]+\s+){1,3}(?:Street|St|Road|Rd|Avenue|Ave|Lane|Way|Square|Place|Boulevard|Drive|Court|Row|Terrace)\b/u,
382
+ /\b[A-Z]{1,2}\d[A-Z\d]?\s\d[A-Z]{2}\b/,
383
+ ];
384
+ // An external link is a door only to a booking or shop: not Struq's own credit, a font host, or a social profile.
385
+ const EXTERNAL = /\b(?:href|action)\s*[:=]\s*\{?\s*["'`]https?:\/\/(?!(?:www\.)?(?:struq\.(?:app|nl)|localhost|schema\.org|w3\.org|fonts\.(?:googleapis|gstatic)\.com|api\.fontshare\.com|(?:instagram|facebook|twitter|x|linkedin|tiktok|youtube|pinterest|github|behance|dribbble)\.com)\b)[^"'`\s]+/i;
386
+ const door = copy.some((c) => c.segs.some((s) => DOORS.some((re) => re.test(s)))) || own.some((f) => /\b(?:tel|mailto):[^"'`\s{}]{4,}/.test(f.text) || EXTERNAL.test(f.text));
387
+ if (door) add(G, "PASS", "The invitations on the page have a door: an address, a phone number, an email or a booking or shop link is on it");
388
+ else add(G, "FAIL", "Invitation without a door: the copy asks the reader to act (come by, call, eat, book, buy, order, join, sit down, pick up: tonight, kom langs, Schuif aan, Pick up a mug, Reserveer, Shop the collection), but the page shows no address, phone number, mailto or tel link, or external booking or shop link. With no door the site ends on the idea, never on an invitation: drop the line, the button or the link, and list the door the client must add under Assumptions: in STRUQ.md.", invites);
389
+ } else add(G, "PASS", "No invitation without a door");
390
+
391
+ // A stand-in name: the trade word shown as the business's name (a yoga studio called "Yoga"). The names a site shows are
392
+ // the wordmark props of its header, preloader, hero and footer blocks, a data-wordmark element, the name and title in
393
+ // lib/site.ts or a file like it, and the page title. A name that is only the trade or a generic word for the business
394
+ // is a stand-in, unless the Brief line itself gives that name (capitalised, and not as the first word of the line).
395
+ {
396
+ const GENERIC = new Set(
397
+ "yoga pilates bakery bakkerij bakker studio restaurant resto cafe café koffiebar bar pub brasserie plumbing plumber loodgieter loodgieters loodgietersbedrijf ceramics ceramic pottery keramiek aardewerk conference conferentie congres fashion label atelier salon kapper barbershop gym fitness dentist tandarts clinic praktijk shop store winkel webshop boutique gallery galerie agency bureau consultancy workshop hotel hostel guesthouse garden tuin hovenier cleaning schoonmaak electrician elektricien painter schilder builder aannemer lawyer advocaat accountant photography fotografie photographer fotograaf design ontwerp school academy academie club app software invoicing florist bloemist butcher slager".split(" "),
398
+ );
399
+ const STOP = new Set("the a an de het een my mijn our onze your jouw and en of van voor for by in at".split(" "));
400
+ const tokens = (name) => name.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
401
+ const isStandIn = (name) => {
402
+ const t = tokens(name);
403
+ return t.length > 0 && t.every((w) => GENERIC.has(w) || STOP.has(w)) && t.some((w) => GENERIC.has(w));
404
+ };
405
+ const shown = SHOWN_NAMES;
406
+ const named = namedInBrief;
407
+ const standIns = shown.filter((s) => isStandIn(s.name) && !named(s.name));
408
+ if (standIns.length) {
409
+ const names = [...new Set(standIns.map((s) => `"${s.name}"`))].join(", ");
410
+ add(G, "FAIL", `Stand-in name: the site is called ${names}, which is the trade or a generic word, not the client's name, and the Brief line does not give it. With no name in the brief the header shows no wordmark and the opener and the close carry no name; ask for the name at the one stop, and list it under Assumptions: in STRUQ.md.`, standIns.map((s) => `${s.where} "${s.name}"`));
411
+ } else add(G, "PASS", "No stand-in name: the name the site shows is not a bare trade word");
412
+ }
413
+
414
+ // A link to an anchor nothing on the page carries: a button that goes nowhere.
415
+ {
416
+ const ids = new Set(files.flatMap((f) => [...f.text.matchAll(/\bid=\{?\s*["'`]([\w-]+)["'`]/g)].map((m) => m[1])));
417
+ const dead = [];
418
+ for (const f of own) {
419
+ for (const m of f.text.matchAll(/\b(?:href|menuHref)\s*[:=]\s*\{?\s*["'`]#([\w-]+)["'`]/g)) {
420
+ if (!ids.has(m[1])) dead.push(`${f.rel} #${m[1]}`);
421
+ }
422
+ }
423
+ if (dead.length) add(G, "FAIL", "Links to an anchor nothing on the page carries: give the section that id, or drop the link.", [...new Set(dead)]);
424
+ else add(G, "PASS", "Every in-page link lands on an id");
425
+ }
266
426
 
267
427
  // Type and labels
268
428
  tell("Eyebrows and uppercase labels", /\buppercase\b|text-transform:\s*uppercase|\b(eyebrow|kicker|overline)\b/i,
@@ -438,6 +598,127 @@ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`))
438
598
 
439
599
  if (/prefers-reduced-motion/.test(all)) add(G, "PASS", "prefers-reduced-motion respected");
440
600
  else add(G, "FAIL", "Nothing reads prefers-reduced-motion.");
601
+
602
+ // The primitives (components/struq/kit/motion) and the feel (kit/feel.ts) carry the house moves and their timing.
603
+ // Your own scenes under app/ and components/ (not components/struq/) compose them before writing GSAP by hand.
604
+ const site = files.filter((f) => /^(?:src\/)?(?:app|components)\//.test(f.rel) && !/(?:^|\/)components\/struq\//.test(f.rel) && !f.rel.endsWith(".css"));
605
+ const gsapFiles = site.filter((f) => /from\s*["'](?:gsap(?:\/[\w/]+)?|@gsap\/react|[^"']*components\/struq\/kit\/gsap)["']|\bgsap\./.test(f.text));
606
+
607
+ const split = hits(/\bSplitText\b/, { scope: site });
608
+ if (split.length) add(G, "FAIL", "SplitText outside the kit: use RevealText (@/components/struq/kit/motion) for a line or word reveal; it keeps the masks descender-safe and reads the temperament.", split);
609
+ else add(G, "PASS", "No hand-rolled SplitText: text reveals go through the kit");
610
+
611
+ const inlineStyle = /style=\{\{/;
612
+ const clip = hits(/\bclipPath\s*:|["']clip-path["']\s*:|\binset\(/, { scope: gsapFiles, exclude: inlineStyle });
613
+ if (clip.length) add(G, "WARN", "A clip-path tween of your own: MaskWipe wipes an element or photo in, ImageCurtain uncovers a photo, both on the feel's curtain ease.", clip);
614
+ const draw = hits(/\bstrokeDashoffset\s*:|\bdrawSVG\s*:/, { scope: gsapFiles, exclude: inlineStyle });
615
+ if (draw.length) add(G, "WARN", "A stroke drawn by hand: ScrubLine draws a rule or a path with the scroll at an even pace.", draw);
616
+ // A literal ease or duration in a GSAP call. ease "none" passes, and so does a duration beside it: a scrub is linear
617
+ // in every temperament, and a scrubbed timeline's durations only set proportions.
618
+ const timing = hits(/\bease\s*:\s*["'`](?!none["'`])|\bduration\s*:\s*\d/, { scope: gsapFiles, exclude: /style=\{\{|\bease\s*:\s*["'`]none["'`]/ });
619
+ if (timing.length) add(G, "WARN", "Ease or duration written into a GSAP call: take them from feel() (@/components/struq/kit/feel), so the scene moves in the site's temperament.", timing);
620
+ if (!clip.length && !draw.length && !timing.length) add(G, "PASS", "Own GSAP takes its timing from the feel and leaves wipes and drawn lines to the kit");
621
+
622
+ // The temperament: one on <body>, from the language's default or its neighbour.
623
+ const bodies = files
624
+ .filter((f) => /(?:^|\/)app\/layout\.(?:tsx|jsx)$/.test(f.rel))
625
+ .flatMap((f) =>
626
+ [...f.text.matchAll(/<body\b[^>]*>/g)]
627
+ .filter((m) => !/^\s*(?:\/\/|\/?\*|\{\/\*)/.test(f.text.slice(f.text.lastIndexOf("\n", m.index) + 1, m.index + 1)))
628
+ .map((m) => ({ tag: m[0], where: lineOf(f, m.index) })),
629
+ );
630
+ const TEMPERAMENTS = ["crisp", "weighted", "slow"];
631
+ const badBody = bodies.filter(({ tag }) => {
632
+ const value = tag.match(/\bdata-temperament=(?:["']([^"']*)["']|\{\s*["'`]([^"'`]*)["'`]\s*\})/);
633
+ return !value || !TEMPERAMENTS.includes(value[1] ?? value[2]);
634
+ });
635
+ if (!bodies.length) add(G, "FAIL", "No <body> in app/layout: set <body data-blocks=\"\" data-temperament=\"weighted\"> (crisp, weighted or slow, the language's default).");
636
+ else if (badBody.length) add(G, "FAIL", "<body> without a temperament: set data-temperament to crisp, weighted or slow, the language's default (cut crisp, weight weighted, curtain slow) or its neighbour with the reason on the Language line.", badBody.map((b) => `${b.where} ${b.tag.slice(0, 100)}`));
637
+ else add(G, "PASS", `The temperament is set on <body>: ${bodies.map((b) => b.tag.match(/data-temperament=\{?\s*["'`]([^"'`]*)/)[1]).join(", ")}`);
638
+
639
+ // The close is never the site's name at full width. What this reads: each page file's footer (a footer element, a
640
+ // Footer* block or WordmarkFinale), the scene just before it (or the last section passed into the footer as its
641
+ // children), followed one step into a local component. It fails when that scene's display-size text (text-display,
642
+ // text-sheet-moment, text-6xl to 9xl, text-[Nvw] at 8vw or more, text-[Nrem] at 6rem or more) is only a name the
643
+ // site shows or the Brief gives, and when wordmark-finale follows a block or loud scene directly, so the name at
644
+ // full width is the page's last scene. What it cannot read: a name passed as an expression ({site.name}), a size set
645
+ // in CSS or inline style, and a close built in a component more than one import away.
646
+ {
647
+ const DISPLAY = /\btext-(?:display|sheet-moment|poster|[6-9]xl)\b|\btext-\[(?:[^\]]*?[\s,(])?(\d+(?:\.\d+)?)(vw|rem)\b[^\]]*\]/;
648
+ const isDisplay = (cls) => {
649
+ const m = cls.match(DISPLAY);
650
+ if (!m) return false;
651
+ if (!m[1]) return true;
652
+ return m[2] === "vw" ? Number(m[1]) >= 8 : Number(m[1]) >= 6;
653
+ };
654
+ const norm = (s) => s.toLowerCase().replace(/&apos;|&amp;/g, "").replace(/[^\p{L}\p{N}]+/gu, " ").trim();
655
+ const names = new Set(SHOWN_NAMES.map((s) => norm(s.name)).filter(Boolean));
656
+ const isName = (text) => {
657
+ const t = norm(text);
658
+ if (!t) return false;
659
+ if (names.has(t)) return true;
660
+ return t.split(" ").length <= 3 && /^\p{Lu}/u.test(text.trim()) && namedInBrief(text.trim());
661
+ };
662
+ /** Display-size elements whose whole text is a name: `<tag class="...">Name</tag>`. */
663
+ const nameAtDisplay = (markup) =>
664
+ [...markup.matchAll(/<([\w.]+)\b([^>]*)>([^<{}]+)<\/\1>/g)]
665
+ .filter((m) => isDisplay(m[2].match(/className=(?:["'`]([^"'`]*)["'`]|\{\s*["'`]([^"'`]*)["'`]\s*\})/)?.slice(1).find(Boolean) ?? "") && isName(m[3]))
666
+ .map((m) => m[3].trim());
667
+ const importOf = (text, tag) => text.match(new RegExp(`import\\s*(?:\\{[^}]*\\b${tag}\\b[^}]*\\}|${tag})\\s*from\\s*["']([^"']+)["']`))?.[1] ?? "";
668
+ const localFile = (from, spec) => {
669
+ if (!spec || !(spec.startsWith(".") || spec.startsWith("@/"))) return null;
670
+ const base = spec.startsWith("@/") ? spec.slice(2) : join(from.rel, "..", spec).split(sep).join("/");
671
+ return files.find((f) => [".tsx", ".jsx", ".ts", ".js", "/index.tsx", "/index.ts"].some((ext) => f.rel === `${base}${ext}` || f.rel === `src/${base}${ext}`)) ?? null;
672
+ };
673
+ const FOOTER = /<(WordmarkFinale|Footer\w*|footer)\b/;
674
+ const fullWidth = [];
675
+ for (const page of files.filter((f) => /(?:^|\/)app\/(?:.*\/)?page\.(?:tsx|jsx)$/.test(f.rel))) {
676
+ const body = page.text.slice(Math.max(0, page.text.search(/\breturn\s*\(/)));
677
+ const offset = page.text.length - body.length;
678
+ const foot = body.match(FOOTER);
679
+ if (!foot) continue;
680
+ const before = body.slice(0, foot.index);
681
+ const end = body.indexOf(`</${foot[1]}>`, foot.index);
682
+ const inside = end > 0 ? body.slice(foot.index, end) : "";
683
+ // The candidate close: the last section passed into the footer, else the last section or component before it.
684
+ let markup = "";
685
+ let kind = "";
686
+ const innerSection = inside.lastIndexOf("<section");
687
+ if (innerSection >= 0) {
688
+ markup = inside.slice(innerSection, inside.indexOf("</section>", innerSection) + 10 || undefined);
689
+ kind = "own";
690
+ } else {
691
+ const tags = [...before.matchAll(/<(section\b|[A-Z][\w.]*)/g)].filter((m) => !/^(?:Header\w*|SiteHeader|MenuCurtain\w*|Fragment|SmoothScroll|Reveal|main)$/.test(m[1]));
692
+ const last = tags.at(-1);
693
+ if (!last) continue;
694
+ if (last[1].startsWith("section")) {
695
+ markup = before.slice(last.index, before.indexOf("</section>", last.index) + 10 || undefined);
696
+ kind = /data-energy=["']?[2-9]/.test(markup.slice(0, 300)) ? "loud" : "own";
697
+ } else {
698
+ const spec = importOf(page.text, last[1]);
699
+ if (/components\/struq\/kit\/quiet(?:\/Close)?$/.test(spec)) kind = "kit";
700
+ else if (/components\/struq\//.test(spec)) kind = "block";
701
+ else {
702
+ const file = localFile(page, spec);
703
+ markup = file?.text ?? before.slice(last.index);
704
+ kind = "own";
705
+ }
706
+ }
707
+ }
708
+ // One step into the local components the close renders.
709
+ for (const m of [...markup.matchAll(/<([A-Z]\w*)/g)]) {
710
+ const file = localFile(page, importOf(page.text, m[1]));
711
+ if (file) markup += `\n${file.text}`;
712
+ }
713
+ const hit = nameAtDisplay(markup);
714
+ if (hit.length) fullWidth.push(`${lineOf(page, offset + foot.index)} the scene before the footer sets "${hit[0]}" at display size`);
715
+ else if (foot[1] === "WordmarkFinale" && (kind === "block" || kind === "loud")) {
716
+ fullWidth.push(`${lineOf(page, offset + foot.index)} wordmark-finale follows a block directly, so the name at full width is the page's last scene`);
717
+ }
718
+ }
719
+ if (fullWidth.length) add(G, "FAIL", "The close is the site's name at full width: end on the idea, an echo of the opening or the real doors instead, with the kit's Close (shape idea, echo or door), and keep a wordmark there at text size.", fullWidth);
720
+ else add(G, "PASS", "The close is not the name at full width");
721
+ }
441
722
  }
442
723
 
443
724
  /* -------------------------------------------------------------------- TYPE */
@@ -512,12 +793,16 @@ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`))
512
793
  /* -------------------------------------------------------------------- HERO */
513
794
  {
514
795
  const G = "HERO";
515
- const heroBlock = usesKind("heroes") || usedBlocks.has("showpieces/hero-opener");
516
- const noFit = /imageFit[^=]*=\s*"none"/.test(read("lib/struq-images.ts") + read("src/lib/struq-images.ts"));
796
+ // A WebGL background opener (background-silk, -aurora, -facets) is a hero too: the catalogue casts it as an opening.
797
+ const heroBlock = usesKind("heroes") || usesKind("backgrounds") || usedBlocks.has("showpieces/hero-opener");
798
+ // No fitting photo (imageFit none), or no hero photo on purpose (images.mjs --no-hero): a type-led first screen.
799
+ const imageModule = read("lib/struq-images.ts") + read("src/lib/struq-images.ts");
800
+ const noFit = /imageFit[^=]*=\s*"none"/.test(imageModule) || /\bhero:\s*\[\s*\]/.test(imageModule);
517
801
  if (heroBlock) {
518
802
  add(G, "PASS", "A hero block opens the page");
519
803
  const heroUse = own.flatMap((f) => [...f.text.matchAll(/<(Hero[A-Z]\w*)\b[^>]*?\/?>/gs)].map((m) => ({ f, tag: m[0] })));
520
- const bare = noFit ? [] : heroUse.filter(({ tag }) => /<Hero(Framed|Split|Index|Still|Poster)\b/.test(tag) && !/\bimages?=\{|\bimage=\{/.test(tag));
804
+ // Comments never count: a prop written inside /* */ is not a prop. HeroSplit takes its photos through items.
805
+ const bare = noFit ? [] : heroUse.filter(({ tag }) => { const t = tag.replace(/\/\*[\s\S]*?\*\//g, ""); return /<Hero(Framed|Split|Index|Still|Poster)\b/.test(t) && !/\b(?:images?|items)=\{/.test(t); });
521
806
  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));
522
807
  } else {
523
808
  // A hand-built hero: a photo in it and a display line at poster size.
@@ -532,8 +817,10 @@ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`))
532
817
  else add(G, "PASS", "The hand-built hero line runs at poster size");
533
818
  }
534
819
  }
820
+ // A brief with no name gets a header with no wordmark (the stand-in rule), so an own <header> passes on its own.
535
821
  if (usesKind("headers") || own.some((f) => /data-wordmark/.test(f.text))) add(G, "PASS", "A header with the wordmark");
536
- else add(G, "FAIL", "No header: use header-bar or menu-curtain, or mark your own header's name data-wordmark.");
822
+ else if (own.some((f) => /<header\b/.test(f.text))) add(G, "PASS", "An own header (no wordmark: the brief gives no name, or mark it data-wordmark)");
823
+ else add(G, "FAIL", "No header: use header-bar or menu-curtain, or build your own <header> (its name marked data-wordmark when the brief gives one).");
537
824
  if (usesKind("footers") || usedBlocks.has("showpieces/wordmark-finale")) add(G, "PASS", "A designed footer block closes the page");
538
825
  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.");
539
826
 
@@ -566,6 +853,125 @@ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`))
566
853
  }
567
854
  }
568
855
 
856
+ /* -------------------------------------------------------------------- LOOK */
857
+ // look.mjs renders the built page and writes .struq/look.json with what it measured and a stamp of the source. The
858
+ // check holds to it: no look, a failing look, or a look of an older page than the source on disk all fail.
859
+ {
860
+ const G = "LOOK";
861
+ const again = "Serve the build (npx next build && npx next start -p 4321) and run node <skill-dir>/scripts/look.mjs.";
862
+ let look = null;
863
+ try {
864
+ look = JSON.parse(readFileSync(join(root, ".struq", "look.json"), "utf8"));
865
+ } catch {
866
+ look = null;
867
+ }
868
+ if (!look) add(G, "FAIL", `No look at the rendered page (.struq/look.json missing). ${again}`);
869
+ else {
870
+ const now = sourceStamp(root);
871
+ const fails = (look.findings ?? []).filter((f) => f.severity === "FAIL");
872
+ const stale = look.source?.hash ? look.source.hash !== now.hash : !(now.newest.mtime <= Date.parse(look.at));
873
+ if (stale) add(G, "FAIL", `The look is older than the source: ${now.newest.file ?? "a file under app/ or components/"} changed after it. Rebuild, restart the server and run look.mjs again.`);
874
+ else if (look.result !== "PASS" || fails.length) {
875
+ add(G, "FAIL", `look.mjs found ${fails.length} fail(s) on the rendered page. Open the screenshots it names, fix, rebuild and run it again.`, fails.map((f) => `${f.kind} ${f.viewport ?? ""}: ${f.text ? `"${f.text.slice(0, 50)}" ` : ""}${f.detail}${f.shot ? ` ${f.shot}` : ""}`));
876
+ } else {
877
+ const warns = (look.findings ?? []).length;
878
+ add(G, "PASS", `look.mjs passed on the current source${warns ? ` (${warns} warn in .struq/look.json: open their screenshots)` : ""}`);
879
+ }
880
+ }
881
+ }
882
+
883
+ /* --------------------------------------------------------------------- ART */
884
+ // The art director's asks are binding. .struq/art-direction.md lists each ask as a numbered item, and the builder writes
885
+ // an answer line under it: "Built: <what changed>" or "Refused: <reason>". An ask with no answer fails. A refusal whose
886
+ // reason only cites the storyboard, the plan, a budget or a block fails too: the storyboard is rewritten for a good ask,
887
+ // a budget is kept by dropping something else, a block is swapped or restyled. A refusal stands on a rule that never
888
+ // bends (SKILL.md, Rules that never bend) or on a fact the brief did not give, and says which.
889
+ {
890
+ const G = "ART";
891
+ const file = join(root, ".struq", "art-direction.md");
892
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
893
+ if (!text) add(G, "FAIL", "No .struq/art-direction.md: hand the stills to a fresh subagent (SKILL.md, Art direction), save its asks there as numbered items, and answer each with Built: or Refused:.");
894
+ else {
895
+ const asks = [];
896
+ let cur = null;
897
+ for (const line of text.split(/\r?\n/)) {
898
+ if (/^\s*#/.test(line)) {
899
+ cur = null;
900
+ continue;
901
+ }
902
+ const ask = line.match(/^\s{0,3}\d+[.)]\s+(\S.*)$/);
903
+ if (ask) {
904
+ cur = { ask: ask[1], answer: null, reason: "" };
905
+ asks.push(cur);
906
+ continue;
907
+ }
908
+ if (!cur) continue;
909
+ const ans = line.match(/^\s*(?:[-*]\s+)?\*{0,2}(Built|Refused)\*{0,2}:\*{0,2}\s*(.*)$/i);
910
+ if (ans) {
911
+ cur.answer = ans[1].toLowerCase();
912
+ cur.reason = ans[2];
913
+ // An ask can be partly built and partly refused; keep the Built line for the leftover check below.
914
+ if (cur.answer === "built") cur.built = ans[2];
915
+ } else if (cur.answer === "refused" && line.trim()) cur.reason += ` ${line.trim()}`;
916
+ }
917
+ const shortAsk = (a) => `${a.ask.slice(0, 90)}${a.ask.length > 90 ? "..." : ""}`;
918
+ const unanswered = asks.filter((a) => !a.answer);
919
+ const EMPTY = /^[\s.:;,…-]*$/;
920
+ // What a refusal may stand on: a rule that never bends, or a fact the brief did not give.
921
+ const GROUND = /\b(?:invent\w*|made[- ]up|fabricat\w*|never bends|rule\s*[1-4]|honesty|stand-in|empty frame|eyebrow\w*|tells?|dash(?:es)?|the brief\s+(?:gave|gives|did not|does not|never|has no|names no|says nothing)|brief gave|not (?:given|in the brief)|no (?:photo|pack photo|address|phone|number|name|programme|program|dates?|price|prices|shop|booking|testimonial|product)\b|reduced.motion|copyright|licen[cs]e)/i;
922
+ // What is not a ground: the plan, the storyboard, a budget, or a block.
923
+ const NOT_GROUND = /\b(?:story-?board|(?:the |our |approved |agreed )?plan\b|budgets?|quiet share|pin(?:s| budget)?\b|one centerpiece|centerpiece|blocks?\b|block-owned|owns? (?:its|the)|fixed (?:layout|crop|composition)|the film|loud scene)/i;
924
+ const weak = asks.filter((a) => a.answer === "refused" && (EMPTY.test(a.reason) || (NOT_GROUND.test(a.reason) && !GROUND.test(a.reason))));
925
+ const bare = asks.filter((a) => a.answer === "built" && EMPTY.test(a.reason));
926
+ // A Built answer that says a quoted text was removed is checked against the files: trial-prim fashion logged
927
+ // 'the "Photos: Struq" credit is removed' and the credit was still on the page (2026-11-15).
928
+ const leftovers = [];
929
+ const isComment = (line) => /^(\/\/|\/?\*|\{\/\*)/.test(line.trim());
930
+ for (const a of asks.filter((x) => x.built)) {
931
+ for (const m of a.built.matchAll(/["“]([^"”]{3,160})["”]/g)) {
932
+ const after = a.built.slice(m.index + m[0].length, m.index + m[0].length + 50);
933
+ const before = a.built.slice(Math.max(0, m.index - 40), m.index);
934
+ const gone = /^\s*(?:[\w-]+\s+){0,2}(?:is|are|was|were|has been|have been|now)?\s*(?:removed|gone|deleted|dropped|cut)\b/i.test(after) || /\b(?:removed|deleted|dropped)\s+(?:the\s+)?(?:[\w-]+\s+)?$/i.test(before);
935
+ if (!gone) continue;
936
+ const words = m[1].trim().replace(/[.,;:!?]+$/, "");
937
+ if (words.length < 3) continue;
938
+ // Found in a shipped file (lib/struq-images.ts holds the credit as a constant), it counts only when the site's
939
+ // own files still use that export; found in an own file, it counts as it stands.
940
+ const hitIn = (f) => f.text.split(/\r?\n/).findIndex((line) => !isComment(line) && line.includes(words));
941
+ let where = null;
942
+ for (const f of files) {
943
+ if (f.rel.startsWith(".struq/") || /\.md$/i.test(f.rel)) continue;
944
+ const i = hitIn(f);
945
+ if (i < 0) continue;
946
+ if (!f.recipe) {
947
+ where = `${f.rel}:${i + 1}`;
948
+ break;
949
+ }
950
+ const name = f.text.split(/\r?\n/)[i].match(/export\s+const\s+(\w+)/)?.[1];
951
+ const user = name && own.find((o) => !/\.md$/i.test(o.rel) && new RegExp(`\\b${name}\\b`).test(o.text));
952
+ if (user) {
953
+ where = `${f.rel}:${i + 1}, used by ${user.rel}`;
954
+ break;
955
+ }
956
+ }
957
+ if (where) leftovers.push(`${shortAsk(a)} "${words}" is still in ${where}`);
958
+ }
959
+ }
960
+ if (leftovers.length) add(G, "FAIL", `${leftovers.length} art-direction answer(s) say a text was removed that is still in the site. Remove it, or correct the Built line.`, leftovers);
961
+ if (!asks.length && /ship it/i.test(text)) add(G, "PASS", "The art director said ship it: no asks to answer");
962
+ else if (!asks.length) add(G, "FAIL", ".struq/art-direction.md holds no numbered asks. Write each ask as a numbered item (1. 2. 3.) with the still it names, and answer each with Built: or Refused:.");
963
+ else {
964
+ if (unanswered.length) add(G, "FAIL", `${unanswered.length} art-direction ask(s) with no answer. Under each ask write "Built: <what changed>" or "Refused: <reason>"; every ask is built unless it breaks a rule that never bends or a fact of the brief.`, unanswered.map(shortAsk));
965
+ if (weak.length) add(G, "FAIL", `${weak.length} art-direction ask(s) refused with no ground. The storyboard, the plan, a budget or a block is never a reason: rewrite the storyboard for a good ask, keep a budget by dropping something else, swap or restyle the block. Refuse only an ask that breaks a rule that never bends (SKILL.md) or a fact the brief did not give, and say which.`, weak.map((a) => `${shortAsk(a)} Refused: ${a.reason.slice(0, 100)}`));
966
+ if (bare.length) add(G, "FAIL", `${bare.length} art-direction ask(s) marked Built with nothing said. Write what changed.`, bare.map(shortAsk));
967
+ if (!unanswered.length && !weak.length && !bare.length && !leftovers.length) {
968
+ const built = asks.filter((a) => a.answer === "built").length;
969
+ add(G, "PASS", `Every art-direction ask is answered: ${built} built, ${asks.length - built} refused on a rule or a fact of the brief`);
970
+ }
971
+ }
972
+ }
973
+ }
974
+
569
975
  /* --------------------------------------------------------------------- LOG */
570
976
  // Every site keeps STRUQ-LOG.md: an entry for each completed checklist step. Step 8 (this check) writes its own entry
571
977
  // when this run ends, so it is not required beforehand.
@@ -587,7 +993,7 @@ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`))
587
993
  }
588
994
 
589
995
  /* ------------------------------------------------------------------ REPORT */
590
- const order = ["IMAGES", "TELLS", "MOTION", "HERO", "FILM", "TYPE", "PLAN", "LOG"];
996
+ const order = ["IMAGES", "TELLS", "MOTION", "HERO", "FILM", "TYPE", "PLAN", "LOOK", "ART", "LOG"];
591
997
  console.log(`Struq check: ${root}\n`);
592
998
  for (const g of order) {
593
999
  const rows = results.filter((r) => r.group === g);
@@ -0,0 +1,49 @@
1
+ // This site's hand, kept in .struq/draw.json: two languages, two light and two dark palettes, three type pairings,
2
+ // five openings and five centerpieces. Dealt once by hand.mjs, the same module the Struq MCP deals with (copied from
3
+ // Struq's blocks/film/hand.mjs on release), so the skill and the MCP give a site the same kind of hand and check a
4
+ // plan against it the same way. canon.mjs draw and blocks.mjs arc both read it here.
5
+ //
6
+ // Proof runs of 2026-11-14 showed why there is a hand at all: listing every option, even shuffled, and naming the
7
+ // reflexes still drew most sites to the same few picks. A dealt hand makes every site start from a different place;
8
+ // the agent still chooses within it.
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { dealHand, handPools, isHandId } from "./hand.mjs";
13
+ import { appendLog } from "./log.mjs";
14
+ import { lightness } from "./tokens.mjs";
15
+
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const read = (...p) => JSON.parse(readFileSync(join(here, "..", ...p), "utf8"));
18
+
19
+ /** The pools out of the skill's canon and catalogue, as the MCP builds them out of Struq's. */
20
+ export function pools() {
21
+ const canon = read("canon", "canon.json");
22
+ const catalog = read("blocks", "catalog.json");
23
+ const blocks = Array.isArray(catalog) ? catalog : catalog.blocks;
24
+ return handPools({
25
+ palettes: canon.palettes.map((p) => ({ id: p.id, light: lightness(p.colors.background) >= 0.5, autoPick: p.autoPick })),
26
+ pairings: canon.pairings.map((t) => ({ id: t.id, autoPick: t.autoPick })),
27
+ catalog: blocks.map((b) => ({ slug: b.slug, role: b.role, home: b.home })),
28
+ });
29
+ }
30
+
31
+ /** The project's hand: read from .struq/draw.json, or dealt now and saved. `fresh` says whether it was just dealt. */
32
+ export function projectHand(root) {
33
+ const file = join(root, ".struq", "draw.json");
34
+ const saved = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
35
+ if (saved && isHandId(saved.id) && saved.openings && saved.pairings) return { hand: saved, fresh: false };
36
+ const hand = dealHand(pools());
37
+ mkdirSync(join(root, ".struq"), { recursive: true });
38
+ writeFileSync(file, JSON.stringify(hand, null, 2) + "\n");
39
+ appendLog(root, "Step 2 Draw", [
40
+ `Hand ${hand.id} dealt: languages ${hand.languages.join(", ")}; palettes ${hand.palettes.join(", ")}; pairings ${hand.pairings.join(", ")}; openings ${hand.openings.join(", ")}; centerpieces ${hand.centerpieces.join(", ")}.`,
41
+ ]);
42
+ return { hand, fresh: true };
43
+ }
44
+
45
+ /** The saved hand, or null when none was dealt. */
46
+ export function savedHand(root) {
47
+ const file = join(root, ".struq", "draw.json");
48
+ return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
49
+ }