@struq/skill 0.4.3 → 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 +1 -1
  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
@@ -0,0 +1,146 @@
1
+ // Struq's one stop, held by the scripts instead of by advice. Nothing goes into a project before its treatment passes
2
+ // the plan check (film-check.mjs planCheck, the same rules as check.mjs --plan) and the user's reply to it is logged in
3
+ // STRUQ-LOG.md. blocks.mjs add and canon.mjs palette and type call these before they touch a file; blocks.mjs add also
4
+ // installs only what the storyboard names, in the site's language, and a page transition only once there are routes.
5
+ //
6
+ // A refusal is two lines: the reason, then exactly what to do next. The caller prints them and exits 1.
7
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { planCheck, readTreatment, resolveBlock } from "./film-check.mjs";
11
+
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+ const CATALOG = JSON.parse(readFileSync(join(here, "..", "blocks", "catalog.json"), "utf8")).blocks;
14
+ const BY_SLUG = new Map(CATALOG.map((b) => [b.slug, b]));
15
+
16
+ const CHECK_CMD = `node "${join(here, "check.mjs")}" --plan`;
17
+ const LOG_CMD = `node "${join(here, "log.mjs")}"`;
18
+
19
+ /** The approval entry check.mjs asks for too: a Step 2 Approval heading with the user's words on a Reply: line. */
20
+ export function hasApproval(log) {
21
+ const entry = String(log ?? "").match(/^## Step 2 Approval[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m);
22
+ return Boolean(entry && /^Reply:\s*["“‘'].+["”’']\s*$/m.test(entry[1]));
23
+ }
24
+
25
+ /** The page files under app/ (or src/app/): one per route. */
26
+ export function routeCount(root) {
27
+ const app = ["app", join("src", "app")].map((d) => join(root, d)).find((d) => existsSync(d));
28
+ if (!app) return 0;
29
+ const walk = (dir) =>
30
+ readdirSync(dir).flatMap((name) => {
31
+ if (name === "node_modules" || name.startsWith(".")) return [];
32
+ const full = join(dir, name);
33
+ return statSync(full).isDirectory() ? walk(full) : /^page\.(tsx|jsx|js)$/.test(name) ? [full] : [];
34
+ });
35
+ return walk(app).length;
36
+ }
37
+
38
+ const read = (path) => (existsSync(path) ? readFileSync(path, "utf8") : "");
39
+
40
+ /** null when STRUQ.md holds a plan that passes the plan check, else the refusal. */
41
+ function planRefusal(root) {
42
+ const plan = read(join(root, "STRUQ.md"));
43
+ const next = `Write the treatment in STRUQ.md and run ${CHECK_CMD} until PLAN: PASS, then run this again.`;
44
+ if (!plan.trim()) return { reason: `No STRUQ.md in ${root}, so there is no treatment to build from.`, next };
45
+ const { findings } = planCheck({ plan, catalog: CATALOG, routes: Math.max(1, routeCount(root)) });
46
+ const failed = findings.filter((f) => f.severity === "fail");
47
+ if (!failed.length) return null;
48
+ return { reason: `The plan in STRUQ.md does not pass: ${failed.map((f) => `${f.id} (${f.detail.replace(/\.$/, "")})`).join("; ")}.`, next };
49
+ }
50
+
51
+ /** null when STRUQ-LOG.md carries the user's reply to the treatment, else the refusal. */
52
+ function approvalRefusal(root) {
53
+ if (hasApproval(read(join(root, "STRUQ-LOG.md")))) return null;
54
+ return {
55
+ reason: "The treatment is not approved yet: STRUQ-LOG.md has no Step 2 Approval entry with the user's reply.",
56
+ next: `Send the treatment to the user and stop; when they reply, log it with ${LOG_CMD} "Step 2 Approval" "Asked: ..." "Reply: \\"<their words>\\"" and run this again.`,
57
+ };
58
+ }
59
+
60
+ /** The plan gate and the approval gate, in that order: null when both hold, else the first refusal. */
61
+ export function stopRefusal(root) {
62
+ return planRefusal(root) ?? approvalRefusal(root);
63
+ }
64
+
65
+ const SLUG = /(?<![A-Za-z0-9-])([a-z0-9]+(?:-[a-z0-9]+)*)(?::([a-z0-9]+(?:-[a-z0-9]+)*))?(?![A-Za-z0-9-])/g;
66
+
67
+ /** Every catalogue block the storyboard names, read as the plan check reads a line: after the (tone) tag, and after footer:. */
68
+ export function namedBlocks(plan) {
69
+ const treatment = readTreatment(plan);
70
+ const named = [];
71
+ (treatment.storyboard ?? []).forEach((text, at) => {
72
+ const head = text.split("|")[0];
73
+ const tag = /\(([^)]*)\)/.exec(head);
74
+ const scene = `${at + 1} ${(tag ? head.slice(0, tag.index) : "").trim()}`.trim();
75
+ const zones = [tag ? head.slice(tag.index + tag[0].length) : head];
76
+ const footer = /\bfooter\s*:([^|]*)/i.exec(text);
77
+ if (footer) zones.push(footer[1]);
78
+ for (const zone of zones) {
79
+ for (const m of zone.matchAll(SLUG)) if (BY_SLUG.has(m[1])) named.push({ slug: m[1], variant: m[2] ?? null, scene });
80
+ }
81
+ });
82
+ if (treatment.centerpiece && BY_SLUG.has(treatment.centerpiece)) named.push({ slug: treatment.centerpiece, variant: null, scene: "Centerpiece" });
83
+ return { language: treatment.language, named };
84
+ }
85
+
86
+ /** The variant a block goes in: the one asked for, else the storyboard's, else the one of the site's language. */
87
+ function chosenVariant(block, asked, named, language) {
88
+ if (asked) return asked;
89
+ const fromBoard = named.find((n) => n.slug === block.slug && n.variant)?.variant;
90
+ if (fromBoard) return fromBoard;
91
+ const own = block.variants.find((v) => resolveBlock(CATALOG, block.slug, v.id)?.home === language);
92
+ return own?.id ?? null;
93
+ }
94
+
95
+ /**
96
+ * The refusal for `blocks.mjs add`, or null when every requested block may go in. `requests` is [{ slug, variant }]
97
+ * with known slugs. Every refused slug is listed with its reason; nothing is installed when one is refused.
98
+ */
99
+ export function addRefusal(root, requests) {
100
+ const stop = stopRefusal(root);
101
+ if (stop) return stop;
102
+ const { language, named } = namedBlocks(read(join(root, "STRUQ.md")));
103
+ const routes = routeCount(root);
104
+ const refused = [];
105
+ const unnamed = [];
106
+ const nexts = new Set();
107
+ for (const { slug, variant } of requests) {
108
+ const block = BY_SLUG.get(slug);
109
+ const chosen = chosenVariant(block, variant, named, language);
110
+ const film = resolveBlock(CATALOG, slug, chosen);
111
+ const shown = chosen ? `${slug}:${chosen}` : slug;
112
+ const reasons = [];
113
+ const exempt = slug === "header-bar" || (slug === "menu-curtain" && film.home === language);
114
+ if (!exempt && !named.some((n) => n.slug === slug)) {
115
+ reasons.push("the storyboard does not name it");
116
+ unnamed.push(slug);
117
+ }
118
+ if (film.home && film.home !== language) {
119
+ reasons.push(`it belongs to the ${film.home} language and the site's Language is ${language}`);
120
+ nexts.add(`Take the variant or block of the ${language} language (blocks.mjs list shows them).`);
121
+ }
122
+ if (block.kind === "page-transition" && routes < 2) {
123
+ reasons.push(`a page transition needs two routes and app/ holds ${routes} page.tsx`);
124
+ nexts.add("Leave the page transition out until app/ holds a second page.tsx.");
125
+ }
126
+ if (reasons.length) refused.push(`${shown} (${reasons.join("; ")})`);
127
+ }
128
+ if (!refused.length) return null;
129
+ if (unnamed.length) {
130
+ const asked = unnamed.join(" or ");
131
+ nexts.add(`Install only what the storyboard names; if the user asked for ${asked}, put it on a scene line in STRUQ.md first and run ${CHECK_CMD}.`);
132
+ }
133
+ const shown = named.filter((n, at) => n.scene !== "Centerpiece" || !named.slice(0, at).some((m) => m.slug === n.slug));
134
+ const board = shown.length ? shown.map((n) => `${n.scene}: ${n.variant ? `${n.slug}:${n.variant}` : n.slug}`).join(", ") : "nothing";
135
+ return {
136
+ reason: `Refused, nothing installed: ${refused.join("; ")}. The storyboard names ${board}.`,
137
+ next: `${[...nexts].reverse().join(" ")} Then run add again.`,
138
+ };
139
+ }
140
+
141
+ /** Print a refusal as its two lines and set exit code 1. */
142
+ export function refuse(what, refusal) {
143
+ console.error(`FAIL ${what}: ${refusal.reason}`);
144
+ console.error(`Next: ${refusal.next}`);
145
+ process.exitCode = 1;
146
+ }
@@ -1,9 +1,10 @@
1
1
  // Struq beeldbank: which pack fits a brief, and which photos inside a pack fit this client.
2
2
  //
3
3
  // The pack rule mirrors Struq's MCP (public canon logic), in order:
4
- // 1. A sector word in the brief picks that sector's pack, matched as whole words (neighbouring
5
- // words are also tried joined, so "e-bike" matches "ebike"). Most matches wins; table order
6
- // breaks a tie.
4
+ // 1. A sector word in the brief picks that sector's pack. A stem of four letters or more also
5
+ // matches its inflections and the compounds that hold it (architectenstudio, boetiekhotel);
6
+ // a shorter one only matches whole. Neighbouring words are also tried joined, so "e-bike"
7
+ // matches "ebike". Most matches wins; table order breaks a tie.
7
8
  // 2. No sector word, but the brief launches or sells a product: vitrine (product photography),
8
9
  // and the hero scene carries the product.
9
10
  // 3. Anything else: halide, because a wrong photograph is worse than an abstract one.
@@ -24,9 +25,10 @@ export function normalize(text) {
24
25
  }
25
26
 
26
27
  export const PACK_SIGNALS = [
27
- ["proof", ["restaurant", "restaurants", "visrestaurant", "bistro", "brasserie", "eetcafe", "horeca", "keuken", "chef", "kok", "koken", "kookt", "bakkerij", "bakker", "patisserie", "lunchroom", "koffiebar", "bakery", "cafe", "wine", "wijnbar", "pizzeria", "catering", "kitchen", "dining"]],
28
+ ["proof", ["restaurant", "restaurants", "visrestaurant", "bistro", "brasserie", "eetcafe", "horeca", "keuken", "chef", "kok", "koken", "kookt", "bakkerij", "bakker", "patisserie", "lunchroom", "koffiebar", "koffie", "bakery", "cafe", "wine", "wijnbar", "pizzeria", "catering", "kitchen", "dining"]],
28
29
  ["datum", ["architect", "architecten", "architectenbureau", "architectuur", "architectuurbureau", "woningbouw", "fabriekspand", "fabriekspanden", "stedenbouw", "vastgoed", "makelaar", "makelaardij", "projectontwikkelaar", "architects", "architecture", "realestate", "property"]],
29
- ["mortar", ["bouw", "bouwbedrijf", "aannemer", "aannemersbedrijf", "renovatie", "verbouwing", "timmerman", "dakdekker", "loodgieter", "installateur", "installatiebedrijf", "elektricien", "construction", "contractor", "builder", "plumber", "renovation"]],
30
+ // No bare "bouw": nearly every brief opens with it as a verb ("Bouw een website voor ...").
31
+ ["mortar", ["bouwbedrijf", "aannemer", "aannemersbedrijf", "renovatie", "verbouwing", "timmerman", "dakdekker", "loodgieter", "installateur", "installatiebedrijf", "elektricien", "construction", "contractor", "builder", "plumber", "renovation"]],
30
32
  ["portico", ["hotel", "hotels", "resort", "vakantie", "vakantiehuis", "vakantiepark", "gastenverblijf", "reisbureau", "reizen", "travel", "holiday", "villa"]],
31
33
  ["transit", ["logistiek", "logistieke", "vracht", "vrachtvervoer", "distributie", "distributiecentrum", "transport", "transportbedrijf", "transporteur", "expeditie", "magazijn", "container", "containers", "depot", "pallets", "logistics", "freight", "warehouse", "haulage", "shipping"]],
32
34
  ["cadence", ["sport", "sportschool", "sportclub", "gym", "fitness", "crossfit", "boksen", "atletiek", "hardlopen", "wielrennen", "running", "boxing", "athletics"]],
@@ -48,14 +50,70 @@ export const PACK_SIGNALS = [
48
50
 
49
51
  export const PRODUCT_LAUNCH_WORDS = ["lancering", "lanceren", "lanceert", "gelanceerd", "productlancering", "launch", "launches", "launching", "introductie", "product", "producten", "products"];
50
52
 
53
+ // Endings that only inflect a word (architect-en, verbouwing-en, renovatie-s, makelaar-s).
54
+ const INFLECTIONS = new Set(["s", "e", "n", "en", "es", "er", "ers", "ing", "ingen", "ies"]);
55
+
56
+ // Everyday words that hold a sector stem but do not name the sector: "zorgvuldig" (careful) reached the care pack
57
+ // through "zorg", "natuurlijk" (natural) the nature pack through "natuur". A brief word that starts with one of these
58
+ // never matches by stem, so each entry also covers its inflections (zorgvuldige, natuurlijke). Struq's MCP keeps the
59
+ // same list, so both land a brief on the same pack.
60
+ export const NOT_A_SECTOR = [
61
+ "zorgvuldig", "zorgeloos", "zorgeloze", "zorgelijk", "zorgen", "klantenzorg", "kwaliteitszorg",
62
+ "natuurlijk", "natuurkunde", "natuurkundig", "natuurgetrouw", "signatuur",
63
+ "kunstmatig", "kunststof", "kunstlicht",
64
+ "modern", "moderate", "moderator", "moderation",
65
+ "eventual", "operation",
66
+ "bedrijfscultuur", "organisatiecultuur", "werkcultuur", "teamcultuur", "agriculture",
67
+ "metaalachtig",
68
+ "verteler", "verhalenverteler",
69
+ "grondstof", "voedingsstof",
70
+ "praktijkgericht", "praktijkervaring", "praktijkvoorbeeld",
71
+ "whitelabel",
72
+ ];
73
+
74
+ /**
75
+ * True when a brief word is a sector stem, an inflection of it or a compound that holds it.
76
+ * A stem under four letters only matches whole (so "art" never matches "smart"). A longer stem
77
+ * matches at the start of the word when the rest is an inflection or a word of its own (five
78
+ * letters or more: architect-enstudio, mode-label, keramiek-atelier, but not mode-rn), and at the
79
+ * end when a modifier of three letters or more comes before it (boetiek-hotel, tandarts-praktijk).
80
+ * A word that starts with an entry of NOT_A_SECTOR never matches by stem; a word equal to a sector
81
+ * word still matches whole.
82
+ */
83
+ export function wordMatches(word, stem) {
84
+ if (word === stem) return true;
85
+ if (stem.length < 4) return false;
86
+ if (NOT_A_SECTOR.some((entry) => word.startsWith(entry))) return false;
87
+ if (word.startsWith(stem)) {
88
+ const rest = word.slice(stem.length);
89
+ return INFLECTIONS.has(rest) || rest.length >= 5;
90
+ }
91
+ return word.endsWith(stem) && word.length - stem.length >= 3;
92
+ }
93
+
94
+ /**
95
+ * A matcher for one brief: its words match by stem, neighbours joined only whole ("e bike" reads
96
+ * as "ebike", but "modern design" never reads as a compound of "mode").
97
+ */
98
+ function briefMatcher(brief) {
99
+ const words = normalize(brief).split(" ").filter(Boolean);
100
+ const joined = new Set();
101
+ for (let i = 0; i < words.length - 1; i++) joined.add(`${words[i]}${words[i + 1]}`);
102
+ return (stem) => joined.has(stem) || words.some((w) => wordMatches(w, stem));
103
+ }
104
+
105
+ /** True when the brief says one of this pack's sector words (the pack shows the client's world). */
106
+ export function packMatchesBrief(pack, brief) {
107
+ const says = briefMatcher(brief);
108
+ return (PACK_SIGNALS.find(([p]) => p === pack)?.[1] ?? []).some(says);
109
+ }
110
+
51
111
  /** The pack a brief's own sector words point at, or null. */
52
112
  export function packSignalForBrief(brief) {
53
- const words = normalize(brief).split(" ").filter(Boolean);
54
- const tokens = new Set(words);
55
- for (let i = 0; i < words.length - 1; i++) tokens.add(`${words[i]}${words[i + 1]}`);
113
+ const says = briefMatcher(brief);
56
114
  let best = null;
57
115
  for (const [pack, signals] of PACK_SIGNALS) {
58
- const score = signals.filter((s) => tokens.has(s)).length;
116
+ const score = signals.filter(says).length;
59
117
  if (score > 0 && (!best || score > best.score)) best = { pack, score };
60
118
  }
61
119
  return best?.pack ?? null;
@@ -118,7 +176,7 @@ export function altFitsBrief(alt, brief) {
118
176
  * Keep the photos that fit this client, best fit first per slot.
119
177
  * images: [{ slot, alt, description, ... }]. Returns { keep, dropped, fit } in slot order.
120
178
  *
121
- * When the brief's own sector words point at this pack, its photos show the client's world and
179
+ * When the brief names one of this pack's sector words, its photos show the client's world and
122
180
  * only off-subject ones are left out (fit "full"). When they do not (a product launch handed
123
181
  * vitrine, a pack named by hand), a photo is kept only if its alt names something the brief is
124
182
  * about: the material, the city, the street. Otherwise none: an off-topic photo is worse than no
@@ -126,7 +184,10 @@ export function altFitsBrief(alt, brief) {
126
184
  */
127
185
  export function fitImages(pack, brief, images) {
128
186
  const b = words(brief);
129
- const strict = packSignalForBrief(brief) !== pack;
187
+ const says = briefMatcher(brief);
188
+ // Strict only when the brief names none of this pack's sector words: a pack named by hand for
189
+ // an architect is the client's world even when another sector scores higher.
190
+ const strict = !packMatchesBrief(pack, brief);
130
191
  const rules = IMAGE_RULES[pack] ?? [];
131
192
  const keep = [];
132
193
  const dropped = [];
@@ -135,7 +196,7 @@ export function fitImages(pack, brief, images) {
135
196
  // Dutch compounds carry the subject inside them (meelsporen, broodkorst), so a term counts
136
197
  // when an alt word contains it; the brief's own words still have to match whole.
137
198
  const names = (t) => [...altWords].some((w) => w.includes(t));
138
- const offSubject = rules.find((r) => r.terms.some(names) && !r.unless.some((u) => b.has(u)));
199
+ const offSubject = rules.find((r) => r.terms.some(names) && !r.unless.some(says));
139
200
  if (offSubject) dropped.push({ ...img, reason: `shows ${offSubject.subject}, which the brief does not name` });
140
201
  else if (strict && !altFitsBrief(img.alt, brief)) dropped.push({ ...img, reason: "shows nothing this brief is about" });
141
202
  else keep.push(img);