@struq/skill 0.2.2 → 0.4.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 (122) hide show
  1. package/README.md +23 -35
  2. package/package.json +3 -2
  3. package/skills/struq/SKILL.md +148 -212
  4. package/skills/struq/blocks/CATALOG.md +426 -0
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
  8. package/skills/struq/blocks/catalog.json +1302 -0
  9. package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
  10. package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
  11. package/skills/struq/blocks/headers/header-bar.tsx +652 -0
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
  13. package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
  14. package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
  15. package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
  16. package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
  17. package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
  18. package/skills/struq/blocks/kit/Cursor.tsx +117 -0
  19. package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
  20. package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
  21. package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
  22. package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
  23. package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
  24. package/skills/struq/blocks/kit/Reveal.tsx +64 -0
  25. package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
  26. package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/gsap.ts +21 -0
  29. package/skills/struq/blocks/kit/images.ts +17 -0
  30. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  31. package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
  32. package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
  33. package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
  34. package/skills/struq/blocks/kit/scenes/types.ts +114 -0
  35. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  36. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  37. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  38. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  39. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  40. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  41. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  42. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  43. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  44. package/skills/struq/blocks/sections/approach.tsx +661 -0
  45. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  46. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  47. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  48. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  49. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  50. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  51. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  52. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  53. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  54. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  55. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  56. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  57. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  58. package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
  59. package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
  60. package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
  61. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  62. package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
  63. package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
  64. package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
  65. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  66. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
  67. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  68. package/skills/struq/canon/canon.json +627 -0
  69. package/skills/struq/cinematic/README.md +7 -18
  70. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  71. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  72. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  73. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  74. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  75. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  76. package/skills/struq/recipes/app/fonts.ts +12 -0
  77. package/skills/struq/recipes/app/globals.css +42 -0
  78. package/skills/struq/recipes/app/layout.tsx +30 -0
  79. package/skills/struq/references/craft.md +77 -0
  80. package/skills/struq/references/iconography.md +28 -230
  81. package/skills/struq/references/stack-targets.md +23 -74
  82. package/skills/struq/references/tells.md +52 -0
  83. package/skills/struq/references/triage.md +6 -7
  84. package/skills/struq/scripts/blocks.mjs +156 -0
  85. package/skills/struq/scripts/canon.mjs +134 -0
  86. package/skills/struq/scripts/check.mjs +513 -0
  87. package/skills/struq/scripts/images.mjs +360 -0
  88. package/skills/struq/scripts/log.mjs +75 -0
  89. package/skills/struq/scripts/packs.mjs +156 -0
  90. package/skills/struq/scripts/tokens.mjs +222 -0
  91. package/skills/struq/references/anti-patterns.md +0 -265
  92. package/skills/struq/references/baseline-prompt.md +0 -118
  93. package/skills/struq/references/brand.md +0 -143
  94. package/skills/struq/references/brief-router.md +0 -219
  95. package/skills/struq/references/composition.md +0 -191
  96. package/skills/struq/references/depth-parallax.md +0 -235
  97. package/skills/struq/references/direction.md +0 -188
  98. package/skills/struq/references/feel-layer.md +0 -107
  99. package/skills/struq/references/gsap-api.md +0 -293
  100. package/skills/struq/references/imagery.md +0 -144
  101. package/skills/struq/references/loaders.md +0 -287
  102. package/skills/struq/references/micro-interactions.md +0 -270
  103. package/skills/struq/references/mobile-composition.md +0 -70
  104. package/skills/struq/references/nav-header.md +0 -296
  105. package/skills/struq/references/page-architecture.md +0 -134
  106. package/skills/struq/references/page-entrances.md +0 -198
  107. package/skills/struq/references/performance.md +0 -235
  108. package/skills/struq/references/pinning-scrub.md +0 -341
  109. package/skills/struq/references/pitfalls.md +0 -290
  110. package/skills/struq/references/route-transitions.md +0 -281
  111. package/skills/struq/references/scaling.md +0 -151
  112. package/skills/struq/references/scroll-reveals.md +0 -280
  113. package/skills/struq/references/seo.md +0 -219
  114. package/skills/struq/references/setup.md +0 -397
  115. package/skills/struq/references/showcase.md +0 -87
  116. package/skills/struq/references/struq-imagery.md +0 -107
  117. package/skills/struq/references/svg-motion.md +0 -390
  118. package/skills/struq/references/transitions.md +0 -160
  119. package/skills/struq/references/typography.md +0 -720
  120. package/skills/struq/references/vibe-presets.md +0 -287
  121. package/skills/struq/references/webgl-r3f.md +0 -274
  122. package/skills/struq/references/webgl-scenes.md +0 -593
@@ -0,0 +1,360 @@
1
+ #!/usr/bin/env node
2
+ // Struq imagery: download one beeldbank pack into the project.
3
+ //
4
+ // node <skill-dir>/scripts/images.mjs <pack-slug> run from the project root
5
+ // node <skill-dir>/scripts/images.mjs <pack-slug> --dir ./my-site
6
+ // node <skill-dir>/scripts/images.mjs --list every pack, with what it shows
7
+ // node <skill-dir>/scripts/images.mjs --brief "<the brief>" picks the pack and the fitting photos
8
+ // node <skill-dir>/scripts/images.mjs <pack-slug> --brief "<the brief>"
9
+ //
10
+ // With --brief (always pass it): the pack follows Struq's rule (scripts/packs.mjs) when no slug is
11
+ // given, and photos whose alt names a subject the client does not do are left out, best fit first.
12
+ //
13
+ // What it does, in order:
14
+ // 1. Reads the pack from https://struq.nl/api/media/packs/<slug> (public, no key, CC0 images).
15
+ // 2. Downloads all 24 images to public/images/<slot>-<n>.webp and checks each one is a real image.
16
+ // 3. Writes lib/struq-images.ts (src/lib/ when the project uses src/): every image with its alt,
17
+ // 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
+ // in the site palette for that slot instead, so no frame is ever empty, and says so.
20
+ //
21
+ // Node 18 or later, no dependencies. Safe to run again: valid files are kept.
22
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
23
+ import { join, resolve } from "node:path";
24
+ import { appendLog } from "./log.mjs";
25
+ import { fitImages, packForBrief, packReason } from "./packs.mjs";
26
+
27
+ // STRUQ_API_BASE points a test run at another Struq environment (for example https://staging.struq.nl).
28
+ // Real users never set it: the default is production.
29
+ const BASE = (process.env.STRUQ_API_BASE || "https://struq.nl").replace(/\/+$/, "");
30
+ const API = `${BASE}/api/media/packs`;
31
+ /** The download URL, on the same base as the API (directPath), falling back to the absolute publicUrl. */
32
+ const imageUrl = (img) => (img.directPath ? `${BASE}${img.directPath}` : img.publicUrl);
33
+ const SLOTS = ["hero", "band", "card", "column", "detail", "backdrop"];
34
+ const FALLBACK_COUNTS = { hero: 3, band: 2, card: 8, column: 4, detail: 4, backdrop: 3 };
35
+ const FALLBACK_SIZE = {
36
+ hero: [2048, 1152],
37
+ band: [2352, 1008],
38
+ card: [2048, 1365],
39
+ column: [1600, 2000],
40
+ detail: [1600, 1600],
41
+ backdrop: [2048, 1152],
42
+ };
43
+
44
+ const args = process.argv.slice(2);
45
+ const flag = (name) => {
46
+ const i = args.indexOf(name);
47
+ return i >= 0 ? args[i + 1] : undefined;
48
+ };
49
+ const root = resolve(flag("--dir") ?? process.cwd());
50
+ const brief = flag("--brief");
51
+ const slug = args.find((a, i) => !a.startsWith("--") && args[i - 1] !== "--dir" && args[i - 1] !== "--brief") ?? (brief ? packForBrief(brief) : undefined);
52
+ const why = brief && !args.find((a, i) => !a.startsWith("--") && args[i - 1] !== "--dir" && args[i - 1] !== "--brief") ? packReason(brief) : "named on the command line";
53
+
54
+ async function getJson(url) {
55
+ const res = await fetch(url, { signal: AbortSignal.timeout(20000), headers: { accept: "application/json" } });
56
+ if (!res.ok) throw new Error(`${url} answered ${res.status}`);
57
+ return res.json();
58
+ }
59
+
60
+ function isImage(buf) {
61
+ if (!buf || buf.length < 8000) return false;
62
+ const ascii = (a, b) => buf.subarray(a, b).toString("latin1");
63
+ if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") return true;
64
+ if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return true;
65
+ if (buf[0] === 0x89 && ascii(1, 4) === "PNG") return true;
66
+ if (ascii(4, 12) === "ftypavif") return true;
67
+ return false;
68
+ }
69
+
70
+ function fileIsImage(path) {
71
+ try {
72
+ return statSync(path).size > 8000 && isImage(readFileSync(path));
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ async function download(url, path) {
79
+ for (let attempt = 0; attempt < 2; attempt++) {
80
+ try {
81
+ const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
82
+ if (!res.ok) continue;
83
+ const buf = Buffer.from(await res.arrayBuffer());
84
+ if (!isImage(buf)) continue;
85
+ writeFileSync(path, buf);
86
+ return true;
87
+ } catch {
88
+ // retry once, then fall back
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+
94
+ function readPalette() {
95
+ const palette = { s0: "#f5efe6", s1: "#ede4d6", s2: "#e1d4bf", s3: "#cdbb9f", ink: "#1e1a15", accent: "#8a3b22" };
96
+ for (const file of ["app/globals.css", "src/app/globals.css", "src/styles/globals.css", "styles/globals.css"]) {
97
+ const path = join(root, file);
98
+ if (!existsSync(path)) continue;
99
+ const css = readFileSync(path, "utf8");
100
+ // Literal colours only (hex or oklch); a color-mix() step falls back to its neighbour.
101
+ const pick = (name) => css.match(new RegExp(`--${name}\\s*:\\s*(#[0-9a-fA-F]{3,8}|oklch\\([^)]*\\))`))?.[1];
102
+ palette.s0 = pick("surface-0") ?? palette.s0;
103
+ palette.s1 = pick("surface-1") ?? palette.s1;
104
+ palette.s2 = pick("surface-2") ?? (pick("surface-1") ? palette.s1 : palette.s2);
105
+ palette.s3 = pick("surface-3") ?? (pick("surface-0") ? palette.s0 : palette.s3);
106
+ palette.ink = pick("ink") ?? palette.ink;
107
+ palette.accent = pick("accent") ?? palette.accent;
108
+ break;
109
+ }
110
+ return palette;
111
+ }
112
+
113
+ // A graded plate in the site palette: two planes of light, a horizon hairline, film grain.
114
+ // Not a photo, but never an empty box: it reads as a deliberate abstract image.
115
+ function fallbackSvg(slot, n, [w, h], p) {
116
+ let seed = (slot.length * 977 + n * 131) % 2147483647;
117
+ const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
118
+ const x1 = Math.round(w * (0.15 + rnd() * 0.4));
119
+ const x2 = Math.round(x1 + w * (0.25 + rnd() * 0.25));
120
+ const tilt = Math.round(w * (0.08 + rnd() * 0.12));
121
+ const horizon = Math.round(h * (0.55 + rnd() * 0.2));
122
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
123
+ <filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" stitchTiles="stitch"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.22 0"/></filter>
124
+ <filter id="soft"><feGaussianBlur stdDeviation="${Math.round(w / 60)}"/></filter>
125
+ <rect width="${w}" height="${h}" fill="${p.s2}"/>
126
+ <polygon filter="url(#soft)" points="${x1},0 ${x2},0 ${x2 + tilt},${h} ${x1 + tilt},${h}" fill="${p.s0}" opacity="0.85"/>
127
+ <polygon filter="url(#soft)" points="${x2 + w * 0.05},0 ${x2 + w * 0.12},0 ${x2 + w * 0.12 + tilt},${h} ${x2 + w * 0.05 + tilt},${h}" fill="${p.s1}" opacity="0.7"/>
128
+ <rect y="${horizon}" width="${w}" height="${h - horizon}" fill="${p.s3}" opacity="0.55"/>
129
+ <rect y="${horizon}" width="${w}" height="2" fill="${p.ink}" opacity="0.35"/>
130
+ <rect width="${w}" height="${h}" filter="url(#g)"/>
131
+ </svg>
132
+ `;
133
+ }
134
+
135
+ function libDir() {
136
+ return existsSync(join(root, "src", "app")) ? join(root, "src", "lib") : join(root, "lib");
137
+ }
138
+
139
+ function writeModule(entries, packSlug, fallback, imageFit = "full") {
140
+ const dir = libDir();
141
+ mkdirSync(dir, { recursive: true });
142
+ const grouped = Object.fromEntries(SLOTS.map((s) => [s, entries.filter((e) => e.slot === s)]));
143
+ const body = SLOTS.map((s) => {
144
+ const rows = grouped[s]
145
+ .map((e) => {
146
+ const fields = [
147
+ `src: ${JSON.stringify(e.src)}`,
148
+ `alt: ${JSON.stringify(e.alt)}`,
149
+ `width: ${e.width}`,
150
+ `height: ${e.height}`,
151
+ `ratio: ${JSON.stringify(e.ratio)}`,
152
+ `description: ${JSON.stringify(e.description)}`,
153
+ ];
154
+ if (e.negativeSpace) fields.push(`negativeSpace: ${JSON.stringify(e.negativeSpace)}`);
155
+ if (e.unoptimized) fields.push("unoptimized: true");
156
+ return ` { ${fields.join(", ")} },`;
157
+ })
158
+ .join("\n");
159
+ return ` ${s}: [\n${rows}\n ],`;
160
+ }).join("\n");
161
+ const source = `// Written by the Struq skill (scripts/images.mjs). Do not hand-edit the list: run the script again
162
+ // to change pack. Pack: ${packSlug}${fallback ? " (FALLBACK PLATES: struq.nl was unreachable, run the script again when online)" : ""}.
163
+ // Beeldbank images are CC0. Translate the alt texts when the site is not in Dutch.
164
+
165
+ export type StruqImage = {
166
+ src: string;
167
+ alt: string;
168
+ width: number;
169
+ height: number;
170
+ /** "16:9", "21:9", "3:2", "4:5" or "1:1" */
171
+ ratio: string;
172
+ /** What the photo shows, in more words than the alt: read it to choose the right slot. */
173
+ description: string;
174
+ /** The photo's calm side ("left", "right", "top", "bottom", "center"): where copy reads over it. */
175
+ negativeSpace?: string;
176
+ /** Set on fallback plates (SVG), which next/image must not optimise. */
177
+ unoptimized?: boolean;
178
+ };
179
+
180
+ export type ImageSlot = ${SLOTS.map((s) => JSON.stringify(s)).join(" | ")};
181
+
182
+ export const imagePack = ${JSON.stringify(packSlug)};
183
+
184
+ /**
185
+ * "full": the pack shows this client's world. "partial": only the photos whose alt names something
186
+ * the brief is about are here. "none": no photo fits, so the first screen is typographic or the
187
+ * WebGL scene, and the approval stop asks the client for photos. Never fill a frame with an
188
+ * off-topic photo.
189
+ */
190
+ export const imageFit: "full" | "partial" | "none" = ${JSON.stringify(imageFit)};
191
+
192
+ /** The credit line for the footer: one quiet text-small link, never a badge. */
193
+ export const imageCredit = { label: "Beeld: Struq", href: "https://struq.nl/beeldbank" };
194
+
195
+ export const images: Record<ImageSlot, StruqImage[]> = {
196
+ ${body}
197
+ };
198
+ `;
199
+ const file = join(dir, "struq-images.ts");
200
+ writeFileSync(file, source);
201
+ return file;
202
+ }
203
+
204
+ async function list() {
205
+ const index = await getJson(API);
206
+ console.log(`${index.packCount} packs, ${index.totalImages} images, ${index.license}\n`);
207
+ for (const p of index.packs) console.log(`${p.slug.padEnd(10)} ${p.description}`);
208
+ }
209
+
210
+ async function main() {
211
+ if (args.includes("--list")) return list();
212
+ if (!slug) {
213
+ console.error('Usage: node images.mjs --brief "<the brief>" | <pack-slug> --brief "<the brief>" [--dir <project>] (or --list)');
214
+ process.exit(2);
215
+ }
216
+ if (!existsSync(join(root, "package.json"))) {
217
+ console.error(`No package.json in ${root}. Run this from the project root, or pass --dir.`);
218
+ process.exit(2);
219
+ }
220
+ const outDir = join(root, "public", "images");
221
+ mkdirSync(outDir, { recursive: true });
222
+ const palette = readPalette();
223
+
224
+ let pack = null;
225
+ let droppedImages = [];
226
+ let imageFit = "full";
227
+ try {
228
+ pack = await getJson(`${API}/${encodeURIComponent(slug)}`);
229
+ if (!Array.isArray(pack?.images) || pack.images.length === 0) throw new Error("pack has no images");
230
+ } catch (error) {
231
+ try {
232
+ const index = await getJson(API);
233
+ const known = index.packs.map((p) => p.slug);
234
+ if (!known.includes(slug)) {
235
+ console.error(`Unknown pack "${slug}". Packs: ${known.join(", ")}`);
236
+ process.exit(2);
237
+ }
238
+ } catch {
239
+ // the API itself is down: fall through to the plates
240
+ }
241
+ console.warn(`WARN struq.nl unreachable (${error.message}). Writing graded fallback plates instead.`);
242
+ pack = null;
243
+ }
244
+
245
+ const entries = [];
246
+ const manifest = [];
247
+ let real = 0;
248
+ let plates = 0;
249
+
250
+ if (pack) {
251
+ const counters = Object.fromEntries(SLOTS.map((s) => [s, 0]));
252
+ if (brief) {
253
+ const fit = fitImages(slug, brief, pack.images);
254
+ pack.images = fit.keep;
255
+ droppedImages = fit.dropped;
256
+ imageFit = fit.fit;
257
+ } else {
258
+ console.warn('WARN no --brief: every photo of the pack is used. Pass --brief so photos of subjects this client does not do are left out.');
259
+ }
260
+ const jobs = pack.images
261
+ .filter((img) => SLOTS.includes(img.slot))
262
+ .map((img) => {
263
+ const n = ++counters[img.slot];
264
+ return { img, n, name: `${img.slot}-${n}` };
265
+ });
266
+ let cursor = 0;
267
+ const worker = async () => {
268
+ while (cursor < jobs.length) {
269
+ const job = jobs[cursor++];
270
+ const target = join(outDir, `${job.name}.webp`);
271
+ const ok = fileIsImage(target) || (await download(imageUrl(job.img), target));
272
+ if (ok) {
273
+ real++;
274
+ entries.push({
275
+ slot: job.img.slot,
276
+ order: job.n,
277
+ src: `/images/${job.name}.webp`,
278
+ alt: job.img.alt ?? "",
279
+ width: job.img.width ?? FALLBACK_SIZE[job.img.slot][0],
280
+ height: job.img.height ?? FALLBACK_SIZE[job.img.slot][1],
281
+ ratio: job.img.aspectRatio ?? "",
282
+ description: job.img.description ?? "",
283
+ negativeSpace: job.img.negativeSpace ?? "",
284
+ });
285
+ manifest.push({ file: `public/images/${job.name}.webp`, id: job.img.id, slot: job.img.slot, source: imageUrl(job.img) });
286
+ } else {
287
+ plates++;
288
+ const size = [job.img.width ?? FALLBACK_SIZE[job.img.slot][0], job.img.height ?? FALLBACK_SIZE[job.img.slot][1]];
289
+ writeFileSync(join(outDir, `${job.name}.svg`), fallbackSvg(job.img.slot, job.n, size, palette));
290
+ entries.push({
291
+ slot: job.img.slot,
292
+ order: job.n,
293
+ src: `/images/${job.name}.svg`,
294
+ alt: job.img.alt ?? "",
295
+ width: size[0],
296
+ height: size[1],
297
+ ratio: job.img.aspectRatio ?? "",
298
+ description: `Fallback plate (download failed). Planned: ${job.img.description ?? ""}`,
299
+ unoptimized: true,
300
+ });
301
+ manifest.push({ file: `public/images/${job.name}.svg`, id: job.img.id, slot: job.img.slot, source: "fallback" });
302
+ }
303
+ }
304
+ };
305
+ await Promise.all([worker(), worker(), worker(), worker()]);
306
+ } else {
307
+ for (const slot of SLOTS) {
308
+ for (let n = 1; n <= FALLBACK_COUNTS[slot]; n++) {
309
+ plates++;
310
+ const size = FALLBACK_SIZE[slot];
311
+ writeFileSync(join(outDir, `${slot}-${n}.svg`), fallbackSvg(slot, n, size, palette));
312
+ entries.push({
313
+ slot,
314
+ order: n,
315
+ src: `/images/${slot}-${n}.svg`,
316
+ alt: "",
317
+ width: size[0],
318
+ height: size[1],
319
+ ratio: `${size[0]}:${size[1]}`,
320
+ description: "Fallback plate in the site palette (struq.nl was unreachable).",
321
+ unoptimized: true,
322
+ });
323
+ manifest.push({ file: `public/images/${slot}-${n}.svg`, id: null, slot, source: "fallback" });
324
+ }
325
+ }
326
+ }
327
+
328
+ entries.sort((a, b) => SLOTS.indexOf(a.slot) - SLOTS.indexOf(b.slot) || a.order - b.order);
329
+ writeFileSync(join(outDir, "struq-pack.json"), JSON.stringify({ pack: slug, images: manifest }, null, 2) + "\n");
330
+ const module = writeModule(entries, slug, !pack, imageFit);
331
+
332
+ console.log(`\nPack ${slug} (${why}): ${real} images downloaded and verified, ${plates} fallback plates.`);
333
+ if (droppedImages.length) console.log(`Left out ${droppedImages.length} photos that do not fit this client (${[...new Set(droppedImages.map((d) => d.reason))].join("; ")}).`);
334
+ console.log("Captions: write them from each photo's alt below. Never name a subject the photo does not show.");
335
+ if (imageFit === "none") {
336
+ console.log("NONE of this pack's photos fits the brief. Use no photos: a background block (aurora, silk, facets) carries the hero's type.");
337
+ console.log("Say so at the approval stop: \"Ik heb geen foto's van je <product>: stuur ze, dan komen ze in de hero.\"");
338
+ } else if (imageFit === "partial") {
339
+ console.log(`Only ${entries.length} photo(s) fit: use them where their alt fits (the material, the city, the street), and nowhere else.`);
340
+ }
341
+ console.log(`Module: ${module.replace(root, ".")} (import { images, imageCredit } from "@/lib/struq-images")\n`);
342
+ for (const e of entries) {
343
+ console.log(`${`images.${e.slot}[${e.order - 1}]`.padEnd(18)} ${e.ratio.padEnd(5)} ${e.description.slice(0, 110)}`);
344
+ }
345
+ if (plates > 0) {
346
+ console.log(`\nWARN ${plates} slot(s) use fallback plates. Run this script again when struq.nl answers.`);
347
+ }
348
+ appendLog(root, "Step 4 Images", [
349
+ `images.mjs: pack ${slug} (${why}), ${real} images downloaded and verified, ${plates} fallback plates.`,
350
+ ...(droppedImages.length ? [`Left out ${droppedImages.length} photos that do not fit this client: ${[...new Set(droppedImages.map((d) => d.reason))].join("; ")}.`] : []),
351
+ ...(imageFit !== "full" ? [`Image fit: ${imageFit}${imageFit === "none" ? ", the opener is typographic or WebGL, and the approval stop asks for photos" : ""}.`] : []),
352
+ `Files in public/images, list in ${module.replace(root, ".")}${BASE !== "https://struq.nl" ? `, from ${BASE}` : ""}.`,
353
+ ]);
354
+ 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)");
355
+ }
356
+
357
+ main().catch((error) => {
358
+ console.error(`FAIL ${error.message}`);
359
+ process.exit(1);
360
+ });
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ // Struq site log: STRUQ-LOG.md in the project root, one per site, append only, human first.
3
+ // STRUQ.md is the plan and the contract; STRUQ-LOG.md is what happened, in order.
4
+ //
5
+ // From the shell, at every checklist step:
6
+ // node <skill-dir>/scripts/log.mjs "Step 5 Blocks" "hero-still, variant frame, in app/page.tsx." "Why: one calm photo for an architect."
7
+ // add --dir <project> to run from elsewhere
8
+ //
9
+ // From a script: import { appendLog } from "./log.mjs"; appendLog(root, "Step 5 Images", ["...", "..."]);
10
+ //
11
+ // Entry shape: a heading with the step name and the local time, then two to five short lines.
12
+ // Earlier entries are never rewritten: the file is only ever opened for appending.
13
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
14
+ import { basename, join, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const DASHES = /[\u2012\u2013\u2014\u2015]/g;
18
+
19
+ function stamp(date = new Date()) {
20
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
21
+ const pad = (n) => String(n).padStart(2, "0");
22
+ return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}, ${pad(date.getHours())}:${pad(date.getMinutes())}`;
23
+ }
24
+
25
+ function projectName(root) {
26
+ try {
27
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
28
+ if (pkg.name) return pkg.name;
29
+ } catch {
30
+ // no package.json yet: the folder name will do
31
+ }
32
+ return basename(root);
33
+ }
34
+
35
+ /** Append one entry to <root>/STRUQ-LOG.md, creating the file with its header the first time. */
36
+ export function appendLog(root, step, lines) {
37
+ const file = join(root, "STRUQ-LOG.md");
38
+ const clean = (s) => String(s).replace(DASHES, ",").replace(/\s+/g, " ").trim();
39
+ const body = (Array.isArray(lines) ? lines : [lines]).map(clean).filter(Boolean).slice(0, 8);
40
+ let text = "";
41
+ if (!existsSync(file)) {
42
+ text += `# STRUQ-LOG.md: ${projectName(root)}\n\nThe build log of this site, oldest first. Append only: never edit or remove an entry. The plan and the contract are in STRUQ.md.\n`;
43
+ }
44
+ text += `\n## ${clean(step)} (${stamp()})\n${body.map((l) => `${l}\n`).join("")}`;
45
+ appendFileSync(file, text);
46
+ return file;
47
+ }
48
+
49
+ // CLI
50
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
51
+ const args = process.argv.slice(2);
52
+ const i = args.indexOf("--dir");
53
+ const root = resolve(i >= 0 ? args[i + 1] : process.cwd());
54
+ const rest = i >= 0 ? args.filter((_, j) => j !== i && j !== i + 1) : args;
55
+ // MCP calls: node log.mjs mcp <struq_tool> [--note "<what it returned or what you took from it>"]
56
+ if (rest[0] === "mcp") {
57
+ const tool = rest[1];
58
+ const k = rest.indexOf("--note");
59
+ const STEP = { struq_richting: 2, struq_blokken: 2, struq_blok: 5, struq_controle: 8 };
60
+ if (!tool || !/^struq_/.test(tool)) {
61
+ console.error('Usage: node log.mjs mcp <struq_tool> [--note "<text>"]');
62
+ process.exit(2);
63
+ }
64
+ const file = appendLog(root, `Step ${STEP[tool] ?? 5} MCP ${tool}`, [k >= 0 ? rest[k + 1] : "Called."]);
65
+ console.log(`Logged ${tool} in ${file}`);
66
+ process.exit(0);
67
+ }
68
+ const [step, ...lines] = rest;
69
+ if (!step || !lines.length || !/^Step [0-9]+/.test(step)) {
70
+ console.error('Usage: node log.mjs "Step <N> <Name>" "<what>" ["<why>" ...] [--dir <project>]. The step starts with Step and its checklist number.');
71
+ process.exit(2);
72
+ }
73
+ const file = appendLog(root, step, lines);
74
+ console.log(`Logged "${step}" in ${file}`);
75
+ }
@@ -0,0 +1,156 @@
1
+ // Struq beeldbank: which pack fits a brief, and which photos inside a pack fit this client.
2
+ //
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.
7
+ // 2. No sector word, but the brief launches or sells a product: vitrine (product photography),
8
+ // and the hero scene carries the product.
9
+ // 3. Anything else: halide, because a wrong photograph is worse than an abstract one.
10
+ // Vehicle and mobility words are not freight words: transit is for logistics, never for a bike.
11
+ // The vocabulary is the MCP's Dutch list plus the English words a brief may use.
12
+ //
13
+ // Inside a pack, fitImages drops the photos whose alt names a subject this client does not do
14
+ // (a fish restaurant gets no flour and dough from the bakery half of proof) and puts the photos
15
+ // whose alt shares words with the brief first in each slot.
16
+
17
+ export function normalize(text) {
18
+ return String(text)
19
+ .toLowerCase()
20
+ .normalize("NFD")
21
+ .replace(/[\u0300-\u036f]/g, "")
22
+ .replace(/[^a-z0-9]+/g, " ")
23
+ .trim();
24
+ }
25
+
26
+ 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
+ ["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
+ ["portico", ["hotel", "hotels", "resort", "vakantie", "vakantiehuis", "vakantiepark", "gastenverblijf", "reisbureau", "reizen", "travel", "holiday", "villa"]],
31
+ ["transit", ["logistiek", "logistieke", "vracht", "vrachtvervoer", "distributie", "distributiecentrum", "transport", "transportbedrijf", "transporteur", "expeditie", "magazijn", "container", "containers", "depot", "pallets", "logistics", "freight", "warehouse", "haulage", "shipping"]],
32
+ ["cadence", ["sport", "sportschool", "sportclub", "gym", "fitness", "crossfit", "boksen", "atletiek", "hardlopen", "wielrennen", "running", "boxing", "athletics"]],
33
+ ["vessel", ["yoga", "yogastudio", "pilates", "fysio", "fysiotherapie", "fysiotherapeut", "wellness", "sauna", "massage", "physio", "spa"]],
34
+ ["sable", ["kapper", "kapsalon", "barbier", "barbershop", "beauty", "schoonheidssalon", "nagelstudio", "salon", "hairdresser", "barber"]],
35
+ ["bolt", ["mode", "kleding", "kledingmerk", "boetiek", "textiel", "stoffen", "maatpak", "naaiatelier", "fashion", "clothing", "textile", "tailor"]],
36
+ ["linen", ["therapie", "therapeut", "coach", "coaching", "psycholoog", "praktijk", "zorg", "tandarts", "huisarts", "kliniek", "therapy", "therapist", "care", "clinic"]],
37
+ ["ledger", ["advocaat", "advocaten", "advocatenkantoor", "notaris", "accountant", "boekhouder", "consultancy", "juridisch", "verzekering", "financieel", "lawyer", "law", "accounting", "finance", "consulting"]],
38
+ ["forge", ["smid", "smederij", "metaal", "metaalbewerking", "lassen", "ambacht", "meubelmaker", "werkplaats", "blacksmith", "metalwork", "furniture", "workshop"]],
39
+ ["vitrine", ["webshop", "winkel", "etalage", "retail", "ecommerce", "sieraden", "keramiek", "cosmetica", "shop", "store", "jewellery", "jewelry", "ceramics", "cosmetics"]],
40
+ ["loam", ["boerderij", "kwekerij", "tuinderij", "landbouw", "akkerbouw", "oogst", "teler", "telers", "bloemen", "farm", "grower", "agriculture"]],
41
+ ["primer", ["school", "basisschool", "kinderopvang", "onderwijs", "creche", "bso", "childcare", "kindergarten"]],
42
+ ["quire", ["portfolio", "ontwerper", "designer", "illustrator", "fotograaf", "grafisch", "ontwerpstudio", "creatief", "photographer", "studio", "agency"]],
43
+ ["circuit", ["hardware", "elektronica", "sensor", "sensoren", "robotica", "iot", "electronics", "robotics"]],
44
+ ["assembly", ["festival", "festivals", "muziekfestival", "evenement", "evenementen", "congres", "conferentie", "theater", "podium", "poppodium", "concert", "concerten", "event", "events", "conference", "venue"]],
45
+ ["brume", ["natuur", "landschap", "kust", "zee", "strand", "wandelen", "nature", "landscape", "coast", "hiking"]],
46
+ ["halide", ["software", "saas", "softwarebedrijf", "app", "platform"]],
47
+ ];
48
+
49
+ export const PRODUCT_LAUNCH_WORDS = ["lancering", "lanceren", "lanceert", "gelanceerd", "productlancering", "launch", "launches", "launching", "introductie", "product", "producten", "products"];
50
+
51
+ /** The pack a brief's own sector words point at, or null. */
52
+ 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]}`);
56
+ let best = null;
57
+ for (const [pack, signals] of PACK_SIGNALS) {
58
+ const score = signals.filter((s) => tokens.has(s)).length;
59
+ if (score > 0 && (!best || score > best.score)) best = { pack, score };
60
+ }
61
+ return best?.pack ?? null;
62
+ }
63
+
64
+ /** The pack for a brief, with the stated fallbacks. */
65
+ export function packForBrief(brief) {
66
+ const matched = packSignalForBrief(brief);
67
+ if (matched) return matched;
68
+ const words = new Set(normalize(brief).split(" ").filter(Boolean));
69
+ return PRODUCT_LAUNCH_WORDS.some((w) => words.has(w)) ? "vitrine" : "halide";
70
+ }
71
+
72
+ /** Why a pack was picked, in one line for the log. */
73
+ export function packReason(brief) {
74
+ const matched = packSignalForBrief(brief);
75
+ if (matched) return `sector words in the brief point at ${matched}`;
76
+ const words = new Set(normalize(brief).split(" ").filter(Boolean));
77
+ return PRODUCT_LAUNCH_WORDS.some((w) => words.has(w))
78
+ ? "no sector word, a product launch: vitrine, and the hero scene carries the product"
79
+ : "no sector word: halide, a wrong photograph is worse than an abstract one";
80
+ }
81
+
82
+ // Per pack: photos whose alt names one of `terms` only fit a brief that says one of `unless`.
83
+ export const IMAGE_RULES = {
84
+ proof: [
85
+ {
86
+ subject: "bakery",
87
+ terms: ["meel", "meelafdrukken", "meelstof", "bestoven", "deeg", "deegbol", "brood", "broden", "desem", "desembroden", "zuurdesem", "zuurdesembrood", "bakker", "kruim", "korst", "bakproces", "bloem"],
88
+ unless: ["bakkerij", "bakker", "brood", "patisserie", "bakery", "bread", "lunchroom", "desem", "zuurdesem", "deeg", "meel", "pizzeria", "pizza"],
89
+ },
90
+ ],
91
+ vitrine: [
92
+ { subject: "cosmetics", terms: ["flessen", "flacons", "fles", "parfum", "cosmetica"], unless: ["cosmetica", "cosmetics", "parfum", "skincare", "huidverzorging", "fles", "flessen", "glas", "drank", "drink", "olie"] },
93
+ ],
94
+ transit: [
95
+ { subject: "freight", terms: ["containers", "zeecontainers", "container", "laadperron", "pallets", "heftruck", "depot"], unless: PACK_SIGNALS.find(([p]) => p === "transit")[1] },
96
+ ],
97
+ };
98
+
99
+ const words = (text) => new Set(normalize(text).split(" ").filter((w) => w.length >= 4));
100
+
101
+ // Words that say nothing about a subject, in a brief or an alt.
102
+ const STOP = new Set([
103
+ "voor", "naar", "door", "deze", "onze", "jouw", "maar", "ook", "wordt", "worden", "zijn", "heeft", "hebben", "alle", "meer", "veel", "waar", "wat", "welke", "tussen", "onder", "boven", "over", "zonder", "rond", "langs",
104
+ "with", "from", "that", "this", "your", "into", "about", "over", "under", "their", "every",
105
+ "lancering", "launch", "nieuwe", "nieuw", "website", "pagina", "site", "page", "landing", "landingspagina", "merk", "brand", "bedrijf", "company",
106
+ "elektrische", "elektrisch", "electric", "groot", "grote", "klein", "kleine", "mooi", "mooie", "spectaculair", "spectacular",
107
+ ]);
108
+ /** The words of a text that name a subject: four letters or more, no filler. */
109
+ export const contentWords = (text) => new Set([...words(text)].filter((w) => !STOP.has(w)));
110
+
111
+ /** True when a photo's alt names something the brief is about (compounds count: stadsstraat holds stad). */
112
+ export function altFitsBrief(alt, brief) {
113
+ const b = [...contentWords(brief)];
114
+ return [...contentWords(alt ?? "")].some((w) => b.some((x) => w === x || (x.length >= 4 && w.includes(x))));
115
+ }
116
+
117
+ /**
118
+ * Keep the photos that fit this client, best fit first per slot.
119
+ * images: [{ slot, alt, description, ... }]. Returns { keep, dropped, fit } in slot order.
120
+ *
121
+ * When the brief's own sector words point at this pack, its photos show the client's world and
122
+ * only off-subject ones are left out (fit "full"). When they do not (a product launch handed
123
+ * vitrine, a pack named by hand), a photo is kept only if its alt names something the brief is
124
+ * about: the material, the city, the street. Otherwise none: an off-topic photo is worse than no
125
+ * photo (fit "partial" or "none").
126
+ */
127
+ export function fitImages(pack, brief, images) {
128
+ const b = words(brief);
129
+ const strict = packSignalForBrief(brief) !== pack;
130
+ const rules = IMAGE_RULES[pack] ?? [];
131
+ const keep = [];
132
+ const dropped = [];
133
+ for (const img of images) {
134
+ const altWords = words(img.alt ?? "");
135
+ // Dutch compounds carry the subject inside them (meelsporen, broodkorst), so a term counts
136
+ // when an alt word contains it; the brief's own words still have to match whole.
137
+ 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)));
139
+ if (offSubject) dropped.push({ ...img, reason: `shows ${offSubject.subject}, which the brief does not name` });
140
+ else if (strict && !altFitsBrief(img.alt, brief)) dropped.push({ ...img, reason: "shows nothing this brief is about" });
141
+ else keep.push(img);
142
+ }
143
+ const fit = !strict ? "full" : keep.length ? "partial" : "none";
144
+ const score = (img) => {
145
+ const w = words(`${img.alt ?? ""} ${img.description ?? ""}`);
146
+ let n = 0;
147
+ for (const x of b) if (w.has(x)) n++;
148
+ return n;
149
+ };
150
+ const order = [];
151
+ const slots = [...new Set(keep.map((i) => i.slot))];
152
+ for (const slot of slots) {
153
+ order.push(...keep.filter((i) => i.slot === slot).map((img, i) => ({ img, i, s: score(img) })).sort((x, y) => y.s - x.s || x.i - y.i).map((x) => x.img));
154
+ }
155
+ return { keep: order, dropped, fit };
156
+ }