@struq/skill 0.4.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +27 -27
  2. package/cli/struq.mjs +2 -2
  3. package/package.json +6 -6
  4. package/skills/struq/SKILL.md +145 -89
  5. package/skills/struq/blocks/CATALOG.md +74 -22
  6. package/skills/struq/blocks/backgrounds/background-aurora.tsx +34 -12
  7. package/skills/struq/blocks/backgrounds/background-facets.tsx +14 -6
  8. package/skills/struq/blocks/backgrounds/background-silk.tsx +40 -15
  9. package/skills/struq/blocks/catalog.json +697 -16
  10. package/skills/struq/blocks/film.json +426 -0
  11. package/skills/struq/blocks/footers/footer-contact.tsx +81 -55
  12. package/skills/struq/blocks/footers/footer-reveal.tsx +1 -1
  13. package/skills/struq/blocks/headers/header-bar.tsx +24 -13
  14. package/skills/struq/blocks/headers/menu-curtain.tsx +24 -11
  15. package/skills/struq/blocks/heroes/hero-framed.tsx +33 -20
  16. package/skills/struq/blocks/heroes/hero-index.tsx +32 -11
  17. package/skills/struq/blocks/heroes/hero-poster.tsx +68 -35
  18. package/skills/struq/blocks/heroes/hero-split.tsx +22 -9
  19. package/skills/struq/blocks/heroes/hero-still.tsx +80 -74
  20. package/skills/struq/blocks/kit/accent.ts +82 -0
  21. package/skills/struq/blocks/kit/blocks.css +78 -35
  22. package/skills/struq/blocks/kit/quiet/Close.tsx +73 -0
  23. package/skills/struq/blocks/kit/quiet/PhotoText.tsx +79 -0
  24. package/skills/struq/blocks/kit/quiet/Rows.tsx +81 -0
  25. package/skills/struq/blocks/kit/quiet/Statement.tsx +157 -0
  26. package/skills/struq/blocks/kit/quiet/index.ts +7 -0
  27. package/skills/struq/blocks/kit/quiet/sheet.ts +12 -0
  28. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +87 -36
  29. package/skills/struq/blocks/preloaders/preloader-gate.tsx +116 -46
  30. package/skills/struq/blocks/preloaders/preloader-stack.tsx +10 -7
  31. package/skills/struq/blocks/sections/contact-form.tsx +1 -1
  32. package/skills/struq/blocks/sections/cta-closing.tsx +3 -2
  33. package/skills/struq/blocks/sections/fanned-cards.tsx +14 -10
  34. package/skills/struq/blocks/sections/film-strip.tsx +18 -14
  35. package/skills/struq/blocks/sections/grid-assembly.tsx +16 -11
  36. package/skills/struq/blocks/sections/manifesto.tsx +3 -2
  37. package/skills/struq/blocks/sections/panel-accordion.tsx +3 -2
  38. package/skills/struq/blocks/sections/split-pair.tsx +4 -3
  39. package/skills/struq/blocks/showpieces/hero-opener.tsx +76 -17
  40. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +3 -1
  41. package/skills/struq/blocks/transitions/zoom-through.tsx +9 -10
  42. package/skills/struq/canon/canon.json +1 -1
  43. package/skills/struq/references/craft.md +4 -0
  44. package/skills/struq/references/film.md +158 -0
  45. package/skills/struq/references/iconography.md +1 -1
  46. package/skills/struq/references/triage.md +3 -0
  47. package/skills/struq/scripts/blocks.mjs +89 -7
  48. package/skills/struq/scripts/canon.mjs +8 -1
  49. package/skills/struq/scripts/check.mjs +99 -15
  50. package/skills/struq/scripts/film-check.mjs +997 -0
  51. package/skills/struq/scripts/gate.mjs +146 -0
  52. package/skills/struq/scripts/images.mjs +64 -37
  53. 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
+ }
@@ -11,11 +11,12 @@
11
11
  // given, and photos whose alt names a subject the client does not do are left out, best fit first.
12
12
  //
13
13
  // What it does, in order:
14
- // 1. Reads the pack from https://struq.nl/api/media/packs/<slug> (public, no key, CC0 images).
14
+ // 1. Reads the pack from https://struq.app/api/media/packs/<slug> (public, no key, CC0 images),
15
+ // or from https://struq.nl when struq.app does not answer.
15
16
  // 2. Downloads all 24 images to public/images/<slot>-<n>.webp and checks each one is a real image.
16
17
  // 3. Writes lib/struq-images.ts (src/lib/ when the project uses src/): every image with its alt,
17
18
  // size and description, grouped by slot, plus the credit line. Components import from it.
18
- // 4. If struq.nl cannot be reached, or a file fails its check, it writes a graded fallback plate
19
+ // 4. If Struq cannot be reached, or a file fails its check, it writes a graded fallback plate
19
20
  // in the site palette for that slot instead, so no frame is ever empty, and says so.
20
21
  //
21
22
  // Node 18 or later, no dependencies. Safe to run again: a file is kept only when the last run
@@ -26,12 +27,21 @@ import { join, resolve } from "node:path";
26
27
  import { appendLog } from "./log.mjs";
27
28
  import { fitImages, packForBrief, packReason } from "./packs.mjs";
28
29
 
29
- // STRUQ_API_BASE points a test run at another Struq environment (for example https://staging.struq.nl).
30
- // Real users never set it: the default is production.
31
- const BASE = (process.env.STRUQ_API_BASE || "https://struq.nl").replace(/\/+$/, "");
32
- const API = `${BASE}/api/media/packs`;
33
- /** The download URL, on the same base as the API (directPath), falling back to the absolute publicUrl. */
34
- const imageUrl = (img) => (img.directPath ? `${BASE}${img.directPath}` : img.publicUrl);
30
+ // Struq lives at struq.app. The old domain struq.nl keeps answering the pack API and the media for
31
+ // installed skills, so a run tries struq.app first and falls back to struq.nl when struq.app gives a
32
+ // network error, a non-2xx answer or something that is not the pack.
33
+ // STRUQ_API_BASE points a test run at another Struq environment (for example https://staging.struq.app),
34
+ // with no fallback. Real users never set it: the default is production.
35
+ const DEFAULT_BASES = ["https://struq.app", "https://struq.nl"];
36
+ const BASES = process.env.STRUQ_API_BASE ? [process.env.STRUQ_API_BASE.replace(/\/+$/, "")] : DEFAULT_BASES;
37
+ const PACKS = "/api/media/packs";
38
+ const HOSTS = BASES.map((b) => b.replace(/^https?:\/\//, "")).join(" and ");
39
+ /** The base that last answered the pack API; image downloads try it first. */
40
+ let activeBase = BASES[0];
41
+ /** The bases in the order to try: the one that answered first, then the rest. */
42
+ const basesInOrder = () => [activeBase, ...BASES.filter((b) => b !== activeBase)];
43
+ /** Download URLs on the same bases as the API (directPath), or the absolute publicUrl. */
44
+ const imageUrls = (img) => (img.directPath ? basesInOrder().map((b) => `${b}${img.directPath}`) : [img.publicUrl]);
35
45
  const SLOTS = ["hero", "band", "card", "column", "detail", "backdrop"];
36
46
  const FALLBACK_COUNTS = { hero: 3, band: 2, card: 8, column: 4, detail: 4, backdrop: 3 };
37
47
  const FALLBACK_SIZE = {
@@ -53,10 +63,22 @@ const brief = flag("--brief");
53
63
  const slug = args.find((a, i) => !a.startsWith("--") && args[i - 1] !== "--dir" && args[i - 1] !== "--brief") ?? (brief ? packForBrief(brief) : undefined);
54
64
  const why = brief && !args.find((a, i) => !a.startsWith("--") && args[i - 1] !== "--dir" && args[i - 1] !== "--brief") ? packReason(brief) : "named on the command line";
55
65
 
56
- async function getJson(url) {
57
- const res = await fetch(url, { signal: AbortSignal.timeout(20000), headers: { accept: "application/json" } });
58
- if (!res.ok) throw new Error(`${url} answered ${res.status}`);
59
- return res.json();
66
+ /** GET a path of the pack API from each base in turn; the first JSON answer wins and sets activeBase. */
67
+ async function getJson(path) {
68
+ const errors = [];
69
+ for (const base of basesInOrder()) {
70
+ const url = `${base}${path}`;
71
+ try {
72
+ const res = await fetch(url, { signal: AbortSignal.timeout(20000), headers: { accept: "application/json" } });
73
+ if (!res.ok) throw new Error(`${url} answered ${res.status}`);
74
+ const json = await res.json();
75
+ activeBase = base;
76
+ return json;
77
+ } catch (error) {
78
+ errors.push(error.message.startsWith(url) ? error.message : `${url}: ${error.message}`);
79
+ }
80
+ }
81
+ throw new Error(errors.join("; "));
60
82
  }
61
83
 
62
84
  function isImage(buf) {
@@ -77,20 +99,23 @@ function fileIsImage(path) {
77
99
  }
78
100
  }
79
101
 
80
- async function download(url, path) {
81
- for (let attempt = 0; attempt < 2; attempt++) {
82
- try {
83
- const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
84
- if (!res.ok) continue;
85
- const buf = Buffer.from(await res.arrayBuffer());
86
- if (!isImage(buf)) continue;
87
- writeFileSync(path, buf);
88
- return true;
89
- } catch {
90
- // retry once, then fall back
102
+ /** Tries each URL twice in turn; returns the URL that gave a real image, or null. */
103
+ async function download(urls, path) {
104
+ for (const url of urls) {
105
+ for (let attempt = 0; attempt < 2; attempt++) {
106
+ try {
107
+ const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
108
+ if (!res.ok) continue;
109
+ const buf = Buffer.from(await res.arrayBuffer());
110
+ if (!isImage(buf)) continue;
111
+ writeFileSync(path, buf);
112
+ return url;
113
+ } catch {
114
+ // retry once, then the next base, then fall back
115
+ }
91
116
  }
92
117
  }
93
- return false;
118
+ return null;
94
119
  }
95
120
 
96
121
  function readPalette() {
@@ -161,7 +186,7 @@ function writeModule(entries, packSlug, fallback, imageFit = "full") {
161
186
  return ` ${s}: [\n${rows}\n ],`;
162
187
  }).join("\n");
163
188
  const source = `// Written by the Struq skill (scripts/images.mjs). Do not hand-edit the list: run the script again
164
- // to change pack. Pack: ${packSlug}${fallback ? " (FALLBACK PLATES: struq.nl was unreachable, run the script again when online)" : ""}.
189
+ // to change pack. Pack: ${packSlug}${fallback ? " (FALLBACK PLATES: Struq was unreachable, run the script again when online)" : ""}.
165
190
  // Beeldbank images are CC0. Translate the alt texts when the site is not in Dutch.
166
191
 
167
192
  export type StruqImage = {
@@ -192,7 +217,7 @@ export const imagePack = ${JSON.stringify(packSlug)};
192
217
  export const imageFit: "full" | "partial" | "none" = ${JSON.stringify(imageFit)};
193
218
 
194
219
  /** The credit line for the footer: one quiet text-small link, never a badge. */
195
- export const imageCredit = { label: "Beeld: Struq", href: "https://struq.nl/beeldbank" };
220
+ export const imageCredit = { label: "Beeld: Struq", href: "https://struq.app/photos" };
196
221
 
197
222
  export const images: Record<ImageSlot, StruqImage[]> = {
198
223
  ${body}
@@ -226,7 +251,7 @@ function removeStale(outDir, manifest) {
226
251
  }
227
252
 
228
253
  async function list() {
229
- const index = await getJson(API);
254
+ const index = await getJson(PACKS);
230
255
  console.log(`${index.packCount} packs, ${index.totalImages} images, ${index.license}\n`);
231
256
  for (const p of index.packs) console.log(`${p.slug.padEnd(10)} ${p.description}`);
232
257
  }
@@ -250,11 +275,11 @@ async function main() {
250
275
  let droppedImages = [];
251
276
  let imageFit = "full";
252
277
  try {
253
- pack = await getJson(`${API}/${encodeURIComponent(slug)}`);
278
+ pack = await getJson(`${PACKS}/${encodeURIComponent(slug)}`);
254
279
  if (!Array.isArray(pack?.images) || pack.images.length === 0) throw new Error("pack has no images");
255
280
  } catch (error) {
256
281
  try {
257
- const index = await getJson(API);
282
+ const index = await getJson(PACKS);
258
283
  const known = index.packs.map((p) => p.slug);
259
284
  if (!known.includes(slug)) {
260
285
  console.error(`Unknown pack "${slug}". Packs: ${known.join(", ")}`);
@@ -263,7 +288,7 @@ async function main() {
263
288
  } catch {
264
289
  // the API itself is down: fall through to the plates
265
290
  }
266
- console.warn(`WARN struq.nl unreachable (${error.message}). Writing graded fallback plates instead.`);
291
+ console.warn(`WARN ${HOSTS} unreachable (${error.message}). Writing graded fallback plates instead.`);
267
292
  pack = null;
268
293
  }
269
294
 
@@ -293,10 +318,12 @@ async function main() {
293
318
  while (cursor < jobs.length) {
294
319
  const job = jobs[cursor++];
295
320
  const target = join(outDir, `${job.name}.webp`);
296
- const source = imageUrl(job.img);
297
- const kept = previous.get(`public/images/${job.name}.webp`) === source && fileIsImage(target);
298
- const ok = kept || (await download(source, target));
299
- if (ok) {
321
+ const urls = imageUrls(job.img);
322
+ const last = previous.get(`public/images/${job.name}.webp`);
323
+ // The same photo from either base counts as the same source.
324
+ const kept = urls.includes(last) && fileIsImage(target);
325
+ const source = kept ? last : await download(urls, target);
326
+ if (source) {
300
327
  real++;
301
328
  entries.push({
302
329
  slot: job.img.slot,
@@ -345,7 +372,7 @@ async function main() {
345
372
  width: size[0],
346
373
  height: size[1],
347
374
  ratio: `${size[0]}:${size[1]}`,
348
- description: "Fallback plate in the site palette (struq.nl was unreachable).",
375
+ description: "Fallback plate in the site palette (Struq was unreachable).",
349
376
  unoptimized: true,
350
377
  });
351
378
  manifest.push({ file: `public/images/${slot}-${n}.svg`, id: null, slot, source: "fallback" });
@@ -372,13 +399,13 @@ async function main() {
372
399
  console.log(`${`images.${e.slot}[${e.order - 1}]`.padEnd(18)} ${e.ratio.padEnd(5)} ${e.description.slice(0, 110)}`);
373
400
  }
374
401
  if (plates > 0) {
375
- console.log(`\nWARN ${plates} slot(s) use fallback plates. Run this script again when struq.nl answers.`);
402
+ console.log(`\nWARN ${plates} slot(s) use fallback plates. Run this script again when Struq answers.`);
376
403
  }
377
404
  appendLog(root, "Step 4 Images", [
378
405
  `images.mjs: pack ${slug} (${why}), ${real} images downloaded and verified, ${plates} fallback plates.`,
379
406
  ...(droppedImages.length ? [`Left out ${droppedImages.length} photos that do not fit this client: ${[...new Set(droppedImages.map((d) => d.reason))].join("; ")}.`] : []),
380
407
  ...(imageFit !== "full" ? [`Image fit: ${imageFit}${imageFit === "none" ? ", the opener is typographic or WebGL, and the approval stop asks for photos" : ""}.`] : []),
381
- `Files in public/images, list in ${module.replace(root, ".")}${BASE !== "https://struq.nl" ? `, from ${BASE}` : ""}.`,
408
+ `Files in public/images, list in ${module.replace(root, ".")}${activeBase !== DEFAULT_BASES[0] ? `, from ${activeBase}` : ""}.`,
382
409
  ]);
383
410
  console.log(imageFit === "none" ? "\nOK no fitting photos: build the opener without them" : real > 0 ? "\nOK images ready" : "\nOK fallback plates ready (no frame is empty)");
384
411
  }
@@ -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);