@struq/skill 0.4.2 → 0.5.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 (50) hide show
  1. package/README.md +167 -59
  2. package/package.json +2 -2
  3. package/skills/struq/SKILL.md +145 -89
  4. package/skills/struq/blocks/CATALOG.md +74 -22
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +34 -12
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +14 -6
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +40 -15
  8. package/skills/struq/blocks/catalog.json +697 -16
  9. package/skills/struq/blocks/film.json +426 -0
  10. package/skills/struq/blocks/footers/footer-contact.tsx +81 -55
  11. package/skills/struq/blocks/footers/footer-reveal.tsx +1 -1
  12. package/skills/struq/blocks/headers/header-bar.tsx +24 -13
  13. package/skills/struq/blocks/headers/menu-curtain.tsx +24 -11
  14. package/skills/struq/blocks/heroes/hero-framed.tsx +33 -20
  15. package/skills/struq/blocks/heroes/hero-index.tsx +32 -11
  16. package/skills/struq/blocks/heroes/hero-poster.tsx +68 -35
  17. package/skills/struq/blocks/heroes/hero-split.tsx +22 -9
  18. package/skills/struq/blocks/heroes/hero-still.tsx +80 -74
  19. package/skills/struq/blocks/kit/accent.ts +82 -0
  20. package/skills/struq/blocks/kit/blocks.css +78 -35
  21. package/skills/struq/blocks/kit/quiet/Close.tsx +73 -0
  22. package/skills/struq/blocks/kit/quiet/PhotoText.tsx +79 -0
  23. package/skills/struq/blocks/kit/quiet/Rows.tsx +81 -0
  24. package/skills/struq/blocks/kit/quiet/Statement.tsx +157 -0
  25. package/skills/struq/blocks/kit/quiet/index.ts +7 -0
  26. package/skills/struq/blocks/kit/quiet/sheet.ts +12 -0
  27. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +87 -36
  28. package/skills/struq/blocks/preloaders/preloader-gate.tsx +116 -46
  29. package/skills/struq/blocks/preloaders/preloader-stack.tsx +10 -7
  30. package/skills/struq/blocks/sections/contact-form.tsx +1 -1
  31. package/skills/struq/blocks/sections/cta-closing.tsx +3 -2
  32. package/skills/struq/blocks/sections/fanned-cards.tsx +14 -10
  33. package/skills/struq/blocks/sections/film-strip.tsx +18 -14
  34. package/skills/struq/blocks/sections/grid-assembly.tsx +16 -11
  35. package/skills/struq/blocks/sections/manifesto.tsx +3 -2
  36. package/skills/struq/blocks/sections/panel-accordion.tsx +3 -2
  37. package/skills/struq/blocks/sections/split-pair.tsx +4 -3
  38. package/skills/struq/blocks/showpieces/hero-opener.tsx +76 -17
  39. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +3 -1
  40. package/skills/struq/blocks/transitions/zoom-through.tsx +9 -10
  41. package/skills/struq/canon/canon.json +1 -1
  42. package/skills/struq/references/craft.md +4 -0
  43. package/skills/struq/references/film.md +158 -0
  44. package/skills/struq/references/triage.md +3 -0
  45. package/skills/struq/scripts/blocks.mjs +89 -7
  46. package/skills/struq/scripts/canon.mjs +8 -1
  47. package/skills/struq/scripts/check.mjs +94 -11
  48. package/skills/struq/scripts/film-check.mjs +997 -0
  49. package/skills/struq/scripts/gate.mjs +146 -0
  50. package/skills/struq/scripts/packs.mjs +73 -12
@@ -3,17 +3,22 @@
3
3
  //
4
4
  // node <skill-dir>/scripts/check.mjs
5
5
  // node <skill-dir>/scripts/check.mjs --dir ./my-site
6
+ // node <skill-dir>/scripts/check.mjs --plan the storyboard in STRUQ.md alone, before anything is built
6
7
  //
7
8
  // 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".
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
11
+ // FAIL names the file and line and the fix. Exit code 1 while any FAIL remains. Fix, run again, and
12
+ // only report done on "RESULT: PASS".
11
13
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
12
14
  import { join, relative, resolve, sep } from "node:path";
13
15
  import { appendLog } from "./log.mjs";
14
16
  import { altFitsBrief, packSignalForBrief } from "./packs.mjs";
17
+ import { filmCheck, planCheck } from "./film-check.mjs";
18
+ import { hasApproval } from "./gate.mjs";
15
19
 
16
20
  const CANON = JSON.parse(readFileSync(new URL("../canon/canon.json", import.meta.url), "utf8"));
21
+ const CATALOG = JSON.parse(readFileSync(new URL("../blocks/catalog.json", import.meta.url), "utf8")).blocks;
17
22
 
18
23
  const args = process.argv.slice(2);
19
24
  const dirFlag = args.indexOf("--dir");
@@ -45,6 +50,25 @@ function walk(dir, acc = []) {
45
50
  }
46
51
 
47
52
  const rel = (p) => relative(root, p).split(sep).join("/");
53
+
54
+ /* -------------------------------------------------------------- PLAN ONLY */
55
+ // --plan holds the storyboard in STRUQ.md to the film budgets before anything is built: no package.json, no build, no
56
+ // checklist and no log needed. The routes are the page files under app/ (at least one).
57
+ if (args.includes("--plan")) {
58
+ const planText = existsSync(join(root, "STRUQ.md")) ? readFileSync(join(root, "STRUQ.md"), "utf8") : "";
59
+ if (!planText) {
60
+ console.log(`FAIL no-plan: No STRUQ.md in ${root}. Fix: write the treatment head and the Storyboard first (SKILL.md step 2).`);
61
+ console.log("PLAN: FAIL");
62
+ process.exit(1);
63
+ }
64
+ const appDir = ["app", "src/app"].map((d) => join(root, d)).find((d) => existsSync(d));
65
+ const routes = Math.max(1, appDir ? walk(appDir).filter((p) => /(^|[\\/])page\.(tsx|jsx|js)$/.test(p)).length : 0);
66
+ const { findings } = planCheck({ plan: planText, catalog: CATALOG, routes });
67
+ for (const f of findings) console.log(`${f.severity === "fail" ? "FAIL" : "WARN"} ${f.id}: ${f.detail.replace(/\.$/, "")}. Fix: ${f.fix}`);
68
+ const failed = findings.some((f) => f.severity === "fail");
69
+ console.log(`PLAN: ${failed ? "FAIL" : "PASS"}`);
70
+ process.exit(failed ? 1 : 0);
71
+ }
48
72
  // A shipped file is one the skill installs under components/struq/ at the same path as in its blocks/ copy (or the
49
73
  // images module images.mjs writes); anything else there, a scene or section you added, counts as your own.
50
74
  const SHIPPED = new URL("../blocks/", import.meta.url);
@@ -176,7 +200,8 @@ const plan = read("STRUQ.md");
176
200
  const floor = libFile ? Math.min(4, total) : 4;
177
201
  if (libFile && imageFit !== "full" && total === 0) add(G, "PASS", "No image placements needed: no pack photo fits");
178
202
  else if (used >= floor) add(G, "PASS", `${used} image placements in the page code`);
179
- 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]} />.`);
203
+ // A floor, not a law: a quiet film may hold fewer photos, so a short count asks for a look rather than blocking.
204
+ else add(G, "WARN", `Only ${used} image placement(s). A Struq site carries a hero image and at least three more: use <MediaFrame image={images.card[0]} />.`);
180
205
  // References past what the pack holds render as empty swatches (an inline manifesto image, a card).
181
206
  if (libFile) {
182
207
  const past = [];
@@ -278,6 +303,31 @@ const plan = read("STRUQ.md");
278
303
  }
279
304
  }
280
305
  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);
306
+
307
+ // A heading of your own that cuts its descenders: overflow hidden or clip on the heading itself, or on the element
308
+ // that wraps it directly when that box is held to the line (a fixed height or leading-none). A tall wrapper such as
309
+ // a full-screen hero (h-screen, h-full, h-[100vh]) leaves the heading room and is not counted. Headings the clipped
310
+ // check above already names are left to it, so one heading is reported once.
311
+ const CLIPS = /\boverflow-(?:hidden|clip)\b|overflow:\s*["']?(?:hidden|clip)\b/;
312
+ const HELD = /(?:^|[\s"'`{])(?:h-(?:\d+(?:\.\d+)?|px)|h-\[(?![^\]]*(?:vh|vw|%))[^\]]+\]|leading-none)(?=[\s"'`}]|$)/;
313
+ const TIGHT = /\bleading-\[0?\.[0-8]\d*\]/;
314
+ const named = new Set(clipped.map((c) => c.split(" ")[0]));
315
+ const cut = [];
316
+ const tight = [];
317
+ for (const f of own.filter((x) => !x.rel.endsWith(".css"))) {
318
+ for (const m of f.text.matchAll(/<(h[1-6]|SplitHeadline)\b[^>]*>/g)) {
319
+ const tag = m[0];
320
+ const before = f.text.slice(0, m.index);
321
+ const where = `${f.rel}:${before.split("\n").length}`;
322
+ const open = before.slice(before.lastIndexOf("<")).replace(/\s+$/, "");
323
+ const wrapper = /^<[A-Za-z]/.test(open) && open.endsWith(">") && !open.endsWith("/>") ? open : "";
324
+ const cuts = CLIPS.test(tag) || (wrapper && CLIPS.test(wrapper) && HELD.test(wrapper));
325
+ if (cuts && !named.has(where)) cut.push(`${where} ${(CLIPS.test(tag) ? tag : `${wrapper} ${tag}`).replace(/\s+/g, " ").slice(0, 110)}`);
326
+ if (TIGHT.test(tag)) tight.push(`${where} ${tag.match(TIGHT)[0]}`);
327
+ }
328
+ }
329
+ if (cut.length) add(G, "FAIL", "Heading that cuts descenders: overflow-hidden or overflow-clip on a heading of your own (or on a box held to its line by a fixed height or leading-none) cuts g, j, p and y. Drop the clip, and set big type with SplitHeadline or the .struq-line-mask class, whose mask leaves room under the baseline.", cut);
330
+ if (tight.length) add(G, "WARN", "Heading leading under 0.9: descenders of one line run into the next line's capitals. Keep a leading of 1 in a serif, 0.95 in a sans, and look at a line with a g and a j.", tight);
281
331
  }
282
332
  tell("Numbered sections", />\s*0[1-9]\s*[<./]|['"`]0[1-9]['"`]|`0\$\{/,
283
333
  "01, 02, 03 stamped on sections or rows is a tell; keep at most one deliberate oversized numeral.", ownOnly);
@@ -438,9 +488,23 @@ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`))
438
488
  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()));
439
489
  else if (done >= 7) add(G, "PASS", `Build checklist ticked (${done} items)`);
440
490
  else add(G, "FAIL", "STRUQ.md has no Build checklist: copy it from SKILL.md and tick each step as you finish it.");
441
- const need = ["Palette", "Type", "Images", "Order"].filter((k) => !new RegExp(`^${k}:\\s*\\S`, "mi").test(plan));
442
- if (need.length) add(G, "FAIL", `STRUQ.md does not record: ${need.map((k) => `${k}:`).join(", ")} (SKILL.md, the plan).`);
443
- else add(G, "PASS", "Direction and order recorded (palette, type, images, order)");
491
+ const has = (k) => new RegExp(`^${k}:\\s*\\S`, "mi").test(plan);
492
+ // The storyboard is the order now; a plan from before the treatment may still carry an Order: line.
493
+ const need = ["Palette", "Type", "Images"].filter((k) => !has(k));
494
+ if (!/^Storyboard:/mi.test(plan) && !has("Order")) need.push("Storyboard");
495
+ if (need.length) add(G, "FAIL", `STRUQ.md does not record: ${need.map((k) => `${k}:`).join(", ")} (SKILL.md, the treatment).`);
496
+ else add(G, "PASS", "Direction and order recorded (palette, type, images, storyboard)");
497
+
498
+ // The treatment: the film the site is cast into (SKILL.md, The film).
499
+ const missing = ["Idea", "Motif", "Lexicon", "Centerpiece"].filter((k) => !has(k));
500
+ const language = plan.match(/^Language:\s*(\S+)/mi)?.[1]?.toLowerCase().replace(/[^a-z]/g, "");
501
+ if (!["weight", "cut", "curtain"].includes(language)) missing.push("Language");
502
+ const board = plan.match(/^Storyboard:[^\n]*\n((?:[ \t]*(?:\d+\.|\.\.\.)[^\n]*\n?)*)/mi);
503
+ const scenes = board ? (board[1].match(/^[ \t]*\d+\.\s+\S/gm) ?? []).length : 0;
504
+ if (!board) missing.push("Storyboard");
505
+ if (missing.length) add(G, "FAIL", `The treatment in STRUQ.md lacks: ${missing.map((k) => `${k}:`).join(", ")}. Write the head of STRUQ.md from the template in SKILL.md step 2 (Language: is weight, cut or curtain; the storyboard numbers its scenes).`);
506
+ else if (scenes < 7 || scenes > 9) add(G, "FAIL", `The storyboard has ${scenes} numbered scene(s): seven to nine, cast from the language's arc (SKILL.md, The film).`);
507
+ else add(G, "PASS", `Treatment recorded: language ${language}, ${scenes} scenes in the storyboard`);
444
508
  }
445
509
  }
446
510
 
@@ -473,7 +537,8 @@ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`))
473
537
  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.");
474
538
 
475
539
  const scenes = own.filter((f) => !f.rel.endsWith(".css")).reduce((n, f) => n + (f.text.match(/\bscene-(inverse|field)\b/g) ?? []).length, 0);
476
- if (scenes === 0) add(G, "FAIL", "No contrast scene: wrap one section in scene-inverse or scene-field, so the page does not stay on one ground.");
540
+ // The tone arc in the treatment decides where the ground turns; a page without a turn is a choice to look at.
541
+ if (scenes === 0) add(G, "WARN", "No contrast scene: wrap one section in scene-inverse or scene-field, so the page does not stay on one ground.");
477
542
  else if (scenes > 2) add(G, "FAIL", `${scenes} contrast scenes: one, at most two, or the contrast stops being a moment.`);
478
543
  else add(G, "PASS", `${scenes} contrast scene${scenes > 1 ? "s" : ""}`);
479
544
 
@@ -482,6 +547,24 @@ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`))
482
547
  else add(G, "PASS", "No product modelled from primitives");
483
548
  }
484
549
 
550
+ /* -------------------------------------------------------------------- FILM */
551
+ // film-check.mjs, Struq's own module: the blocks on the page, in page order, held to the film grammar's budgets (one
552
+ // centerpiece, no pin after it, one language, one clock, one marquee, the pinned screens ...). Your own files only;
553
+ // the copies under components/struq/ are the blocks themselves, not the page.
554
+ {
555
+ const G = "FILM";
556
+ const pageFiles = own
557
+ .filter((f) => !/(^|\/)components\/struq\//.test(f.rel))
558
+ .map((f) => ({ path: f.rel, text: f.text }));
559
+ const { findings, scenes } = filmCheck({ plan, files: pageFiles, catalog: CATALOG });
560
+ for (const f of findings) {
561
+ add(G, f.severity === "fail" ? "FAIL" : "WARN", `${f.id}: ${f.detail.replace(/\.$/, "")}. Fix: ${f.fix}`);
562
+ }
563
+ if (!findings.some((f) => f.severity === "fail")) {
564
+ add(G, "PASS", `The page holds the film budgets (${scenes.length} block${scenes.length === 1 ? "" : "s"} in page order)`);
565
+ }
566
+ }
567
+
485
568
  /* --------------------------------------------------------------------- LOG */
486
569
  // Every site keeps STRUQ-LOG.md: an entry for each completed checklist step. Step 8 (this check) writes its own entry
487
570
  // when this run ends, so it is not required beforehand.
@@ -493,8 +576,8 @@ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`))
493
576
  const ticked = (plan.match(/^\s*[-*]\s*\[[xX]\]\s*(\d+)/gm) ?? []).map((l) => Number(l.match(/(\d+)\s*$/)[1])).filter((n) => n !== 8);
494
577
  const missing = ticked.filter((n) => !new RegExp(`^## Step ${n}\\b`, "m").test(log));
495
578
  const entries = (log.match(/^## /gm) ?? []).length;
496
- const approval = log.match(/^## Step 2 Approval[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m);
497
- if (!approval || !/^Reply:\s*["“‘'].+["”’']\s*$/m.test(approval[1])) {
579
+ // The same test blocks.mjs add and canon.mjs hold before they write a file (gate.mjs).
580
+ if (!hasApproval(log)) {
498
581
  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>\"".');
499
582
  } else add(G, "PASS", "The approval stop is logged with the user's reply");
500
583
  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.`);
@@ -503,7 +586,7 @@ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`))
503
586
  }
504
587
 
505
588
  /* ------------------------------------------------------------------ REPORT */
506
- const order = ["IMAGES", "TELLS", "MOTION", "HERO", "TYPE", "PLAN", "LOG"];
589
+ const order = ["IMAGES", "TELLS", "MOTION", "HERO", "FILM", "TYPE", "PLAN", "LOG"];
507
590
  console.log(`Struq check: ${root}\n`);
508
591
  for (const g of order) {
509
592
  const rows = results.filter((r) => r.group === g);