@struq/skill 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +27 -27
  2. package/package.json +2 -2
  3. package/skills/struq/SKILL.md +168 -165
  4. package/skills/struq/blocks/CATALOG.md +49 -46
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +2 -1
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +5 -3
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +6 -2
  8. package/skills/struq/blocks/catalog.json +50 -5
  9. package/skills/struq/blocks/film.json +11 -7
  10. package/skills/struq/blocks/footers/footer-contact.tsx +8 -78
  11. package/skills/struq/blocks/headers/header-bar.tsx +144 -29
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +36 -19
  13. package/skills/struq/blocks/heroes/hero-poster.tsx +36 -10
  14. package/skills/struq/blocks/kit/HeroCanvas.tsx +6 -2
  15. package/skills/struq/blocks/kit/SplitHeadline.tsx +2 -1
  16. package/skills/struq/blocks/kit/accent.ts +29 -3
  17. package/skills/struq/blocks/kit/blocks.css +530 -6
  18. package/skills/struq/blocks/kit/feel.ts +183 -0
  19. package/skills/struq/blocks/kit/gsap.ts +9 -6
  20. package/skills/struq/blocks/kit/middle/PlaceHours.tsx +254 -0
  21. package/skills/struq/blocks/kit/middle/ProcessStrip.tsx +428 -0
  22. package/skills/struq/blocks/kit/middle/ServicesList.tsx +496 -0
  23. package/skills/struq/blocks/kit/middle/entrance.ts +98 -0
  24. package/skills/struq/blocks/kit/middle/hours.ts +154 -0
  25. package/skills/struq/blocks/kit/middle/index.ts +14 -0
  26. package/skills/struq/blocks/kit/motion/HoverLink.tsx +33 -0
  27. package/skills/struq/blocks/kit/motion/ImageCurtain.tsx +79 -0
  28. package/skills/struq/blocks/kit/motion/MaskWipe.tsx +91 -0
  29. package/skills/struq/blocks/kit/motion/Pressable.tsx +58 -0
  30. package/skills/struq/blocks/kit/motion/RevealText.tsx +103 -0
  31. package/skills/struq/blocks/kit/motion/Rise.tsx +77 -0
  32. package/skills/struq/blocks/kit/motion/ScrubLine.tsx +108 -0
  33. package/skills/struq/blocks/kit/motion/index.ts +11 -0
  34. package/skills/struq/blocks/kit/quiet/Close.tsx +178 -52
  35. package/skills/struq/blocks/kit/quiet/Statement.tsx +3 -2
  36. package/skills/struq/blocks/kit/subject/AppScreen.tsx +388 -0
  37. package/skills/struq/blocks/kit/subject/Document.tsx +742 -0
  38. package/skills/struq/blocks/kit/subject/Mark.tsx +44 -0
  39. package/skills/struq/blocks/kit/subject/PatternPaper.tsx +410 -0
  40. package/skills/struq/blocks/kit/subject/SectionDrawing.tsx +349 -0
  41. package/skills/struq/blocks/kit/subject/Ticket.tsx +354 -0
  42. package/skills/struq/blocks/kit/subject/build.ts +66 -0
  43. package/skills/struq/blocks/kit/subject/draw.ts +196 -0
  44. package/skills/struq/blocks/kit/subject/drawings.ts +373 -0
  45. package/skills/struq/blocks/kit/subject/format.ts +122 -0
  46. package/skills/struq/blocks/kit/subject/index.ts +11 -0
  47. package/skills/struq/blocks/kit/subject/patterns.ts +204 -0
  48. package/skills/struq/blocks/preloaders/preloader-stack.tsx +75 -22
  49. package/skills/struq/blocks/showpieces/hero-opener.tsx +55 -7
  50. package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +64 -6
  51. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +56 -3
  52. package/skills/struq/blocks/showpieces/panel-wipe.tsx +93 -11
  53. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +38 -5
  54. package/skills/struq/canon/canon.json +1 -1
  55. package/skills/struq/references/art-direction.md +51 -0
  56. package/skills/struq/scripts/blocks.mjs +56 -20
  57. package/skills/struq/scripts/canon.mjs +36 -7
  58. package/skills/struq/scripts/check.mjs +417 -11
  59. package/skills/struq/scripts/draw.mjs +49 -0
  60. package/skills/struq/scripts/film-check.mjs +28 -4
  61. package/skills/struq/scripts/gate.mjs +13 -3
  62. package/skills/struq/scripts/hand.mjs +222 -0
  63. package/skills/struq/scripts/images.mjs +12 -3
  64. package/skills/struq/scripts/look.mjs +1184 -0
  65. package/skills/struq/scripts/stamp.mjs +47 -0
@@ -18,6 +18,18 @@ export const FILM_RULES = [
18
18
  title: 'The plan carries a treatment',
19
19
  fix: 'Write the treatment head in STRUQ.md: Idea, Language, Motif, Lexicon, Centerpiece, Tone arc and a numbered Storyboard with one line per scene.',
20
20
  },
21
+ {
22
+ id: 'uncast-slot',
23
+ severity: 'fail',
24
+ title: 'Every scene is cast',
25
+ fix: 'Replace every <cast: ...> and <loud|quiet> the arc printed (blocks.mjs arc, or the treatment struq_richting returns) with one pick and its reason after "because", chosen from the Idea and the Motif, and delete the options list.',
26
+ },
27
+ {
28
+ id: 'no-unlike',
29
+ severity: 'warn',
30
+ title: 'The plan says what it is unlike',
31
+ fix: 'Add an Unlike: line under the Idea: the likeliest site for this brief, and Struq\'s own reflex (a framed photo with the name beside it, growing to full screen), then what this site does instead. Cast the Opening and the Centerpiece against it.',
32
+ },
21
33
  {
22
34
  id: 'one-centerpiece',
23
35
  severity: 'fail',
@@ -639,7 +651,10 @@ function holdBudgets(add, { used, scenes, extraOwn, treatment, planned, routes,
639
651
  // FAIL
640
652
 
641
653
  const centerpieces = used.filter((block) => block.role === 'centerpiece');
642
- if (centerpieces.length === 0) add('one-centerpiece', 1, `No centerpiece on the ${where}.`);
654
+ // The plan's Centerpiece line may name an own scene ("Centerpiece: own scene ..."). The storyboard shows it in its
655
+ // slot; the built page cannot tell an own scene's role, so there a plan that names own needs no centerpiece block.
656
+ const ownCenter = planned && treatment.centerpiece === 'own';
657
+ if (centerpieces.length === 0 && !(ownCenter && where === 'page')) add('one-centerpiece', 1, `No centerpiece on the ${where}.`);
643
658
  else if (centerpieces.length > 1) {
644
659
  add('one-centerpiece', centerpieces.length, `${plural(centerpieces.length, 'centerpiece')}: ${list(centerpieces)}.`);
645
660
  }
@@ -691,7 +706,7 @@ function holdBudgets(add, { used, scenes, extraOwn, treatment, planned, routes,
691
706
  if (planned) {
692
707
  const named = treatment.centerpiece;
693
708
  if (!named) add('centerpiece-is-planned', 1, 'The plan has no Centerpiece line.');
694
- else if (!centerpieces.some((block) => block.slug === named)) {
709
+ else if (ownCenter ? centerpieces.some((block) => !block.own) : !centerpieces.some((block) => block.slug === named)) {
695
710
  const onPage = centerpieces.length ? `the ${where} has ${list(centerpieces)}` : `it is not on the ${where}`;
696
711
  add('centerpiece-is-planned', 1, `The plan names ${named}; ${onPage}.`);
697
712
  }
@@ -903,6 +918,12 @@ export function planCheck({ plan, catalog, routes = 1 } = {}) {
903
918
  }
904
919
  const planned = treatment.hasLanguage;
905
920
  if (!planned) add('no-treatment', 1, missingTreatment(treatment));
921
+ // The arc is printed with no picks on purpose (2026-11-14); a slot left as printed was never cast.
922
+ const uncast = treatment.storyboard
923
+ .map((text, at) => (/<cast\b|<loud\|quiet>/i.test(text) ? at + 1 : 0))
924
+ .filter(Boolean);
925
+ if (uncast.length) add('uncast-slot', uncast.length, `Scene ${uncast.join(', ')} still ${uncast.length === 1 ? 'reads' : 'read'} as the arc printed ${uncast.length === 1 ? 'it' : 'them'}.`);
926
+ if (!/^Unlike:\s*\S/m.test(String(plan ?? ''))) add('no-unlike', 1, 'The plan has no Unlike: line.');
906
927
 
907
928
  const count = treatment.storyboard.length;
908
929
  if (count < SCENE_MIN || count > SCENE_MAX) {
@@ -958,6 +979,9 @@ export function planCheck({ plan, catalog, routes = 1 } = {}) {
958
979
  });
959
980
  }
960
981
  if (line.own) {
982
+ // An own scene in the Centerpiece slot is the centerpiece: the hand deals one centerpiece since 2026-11-15, and
983
+ // when it does not fit the brief the agent builds its own (release proof saas: film-strip for an invoicing app).
984
+ const center = /^centerpiece$/i.test((line.name || '').trim());
961
985
  used.push({
962
986
  scene,
963
987
  tone: line.tone,
@@ -966,11 +990,11 @@ export function planCheck({ plan, catalog, routes = 1 } = {}) {
966
990
  slug: null,
967
991
  variant: null,
968
992
  kind: 'own-scene',
969
- role: 'scene',
993
+ role: center ? 'centerpiece' : 'scene',
970
994
  home: null,
971
995
  motif: null,
972
996
  metaphor: null,
973
- energy: 1,
997
+ energy: center ? 3 : 1,
974
998
  pins: false,
975
999
  scroll: 0,
976
1000
  fullBleed: false,
@@ -16,10 +16,20 @@ const BY_SLUG = new Map(CATALOG.map((b) => [b.slug, b]));
16
16
  const CHECK_CMD = `node "${join(here, "check.mjs")}" --plan`;
17
17
  const LOG_CMD = `node "${join(here, "log.mjs")}"`;
18
18
 
19
- /** The approval entry check.mjs asks for too: a Step 2 Approval heading with the user's words on a Reply: line. */
19
+ /** The approval entry check.mjs asks for too: a Step 2 Approval heading with the user's words on a Reply: line.
20
+ * Every such entry counts, not only the first: the log is append-only, and agents log a "Reply: pending" line before
21
+ * the stop, which used to hide the real reply logged after it and block the build (release-proof restaurant, fashion
22
+ * and bakery, 2026-11-15). A quoted placeholder ("pending", "waiting", "TBD") is no reply. */
23
+ const PLACEHOLDER_REPLY = /^\s*(?:pending|waiting|awaiting\b.*|to come|tbd|tbc|n\/a|none|nog niet|wacht\w*|in afwachting\b.*)\s*\.?\s*$/i;
20
24
  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]));
25
+ const entries = String(log ?? "").matchAll(/^## Step 2 Approval[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/gm);
26
+ for (const [, body] of entries) {
27
+ for (const line of body.split(/\r?\n/)) {
28
+ const reply = line.match(/^Reply:\s*["“‘'](.+)["”’']\s*$/);
29
+ if (reply && !PLACEHOLDER_REPLY.test(reply[1])) return true;
30
+ }
31
+ }
32
+ return false;
23
33
  }
24
34
 
25
35
  /** The page files under app/ (or src/app/): one per route. */
@@ -0,0 +1,222 @@
1
+ // blocks/film/hand.mjs
2
+ //
3
+ // The hand: what one site chooses its direction from. Proof runs of 2026-11-14 (struq-showcases, proof/phase-1) showed
4
+ // why it exists. With a pre-filled storyboard 11 of 12 sites opened the same way; naming the reflex only moved it to
5
+ // the next one. Dealing each site a random hand and holding its plan to it gave real variety while the agent still
6
+ // chooses. A hand is two of the three motion languages, two type pairings, two palettes (one light, one dark), three
7
+ // openings, one centerpiece and three lenses. A lens is where the idea starts looking: the round of 2026-11-15
8
+ // showed that asking for three ideas still gave each brief the same idea on every run, so the starting points are
9
+ // dealt too, and the plan's Idea line names the lens it came from.
10
+ //
11
+ // The hand is small on purpose. Round 6 of 2026-11-15 showed the model picks the same palette
12
+ // (frontier-marrow-bone-blood-orange, dealt four times, picked four times), the same pairing (geometric-modern, three
13
+ // of three) and the same opener and centerpiece whenever they are dealt, so dealing fewer makes the deal itself the
14
+ // variety.
15
+ //
16
+ // The centerpiece is dealt alone. The release proof of 2026-11-15 gave three of eight sites (saas, conference,
17
+ // plumber) the same scrub-connector, picked from three dealt each time it was among them, so one card is the deal
18
+ // and the variety comes from the deal. An own scene stays allowed.
19
+ //
20
+ // A hand has a short id, and the id is the whole state: the same id deals the same hand from the same pools, so the
21
+ // MCP (which keeps nothing between calls) and a check running on the client's machine both rebuild it from the
22
+ // `Hand: <id>` line in STRUQ.md. Each item is ranked by a hash of the id and the item's own name (rendezvous hashing)
23
+ // and the lowest ranks are dealt, so a block admitted or a palette added later changes a dealt hand only where the
24
+ // newcomer outranks a card in it, never the whole hand.
25
+ //
26
+ // `checkHand` holds a plan's Idea, Language, Palette, Type, Opening and Centerpiece lines to a hand: anything else is
27
+ // `off-draw`, except the client's own brand colours or faces, marked (brand), and a block or language the client
28
+ // named, marked (named).
29
+ //
30
+ // A plain ES module with no imports at all, like film-check.mjs: the skill copies it verbatim on release and the MCP
31
+ // serves it as a file, so it has to run on bare Node 18 wherever it lands.
32
+
33
+ /** The motion languages a hand deals from. */
34
+ export const HAND_LANGUAGES = ['weight', 'cut', 'curtain'];
35
+
36
+ /**
37
+ * Where an idea can start looking. Each is a way into any client's world, never a style: the agent turns it on this
38
+ * client and writes the point of view it finds there.
39
+ */
40
+ export const HAND_LENSES = [
41
+ { id: 'material', prompt: 'the stuff the work is made of, seen close enough to touch' },
42
+ { id: 'hour', prompt: "one hour of the client's day, and what is true only then" },
43
+ { id: 'first-minute', prompt: "the visitor's first minute with the thing itself" },
44
+ { id: 'number', prompt: 'one number this trade lives by, and what it means to the visitor' },
45
+ { id: 'tool', prompt: "the tool in the maker's hand and the mark it leaves" },
46
+ { id: 'place', prompt: 'the street, room or city where the work happens' },
47
+ { id: 'mistake', prompt: 'the mistake this client saves the visitor from' },
48
+ { id: 'ritual', prompt: 'a gesture the trade repeats every day' },
49
+ { id: 'before-after', prompt: 'the state of things before the work and after it' },
50
+ { id: 'leftover', prompt: 'what is left when the work is done: offcuts, crumbs, receipts, rings on a table' },
51
+ { id: 'scale', prompt: 'the work at a scale nobody sees it: one stitch, one bolt, the whole city' },
52
+ { id: 'sound', prompt: 'what the work sounds like, carried by type and motion' },
53
+ { id: 'season', prompt: 'how the work changes across a year' },
54
+ { id: 'name', prompt: "the client's name taken apart and used as material" },
55
+ ];
56
+
57
+ /** How many of each a hand holds. */
58
+ export const HAND_SIZE = { languages: 2, lightPalettes: 1, darkPalettes: 1, pairings: 2, openings: 3, centerpieces: 1, lenses: 3 };
59
+
60
+ const ID_LENGTH = 6;
61
+ const ID_ALPHABET = 'abcdefghijkmnpqrstuvwxyz23456789';
62
+
63
+ /** True for a hand id: six lower-case letters and digits, without the look-alikes l, o, 0 and 1. */
64
+ export function isHandId(value) {
65
+ return typeof value === 'string' && new RegExp(`^[${ID_ALPHABET}]{${ID_LENGTH}}$`).test(value);
66
+ }
67
+
68
+ /** A fresh hand id from a random source in [0, 1), Math.random by default. */
69
+ export function newHandId(random = Math.random) {
70
+ let id = '';
71
+ for (let i = 0; i < ID_LENGTH; i++) id += ID_ALPHABET[Math.floor(random() * ID_ALPHABET.length) % ID_ALPHABET.length];
72
+ return id;
73
+ }
74
+
75
+ /** The `Hand: <id>` line of a plan, or null. */
76
+ export function readHandId(plan) {
77
+ const id = String(plan ?? '').match(/^Hand:\s*([a-z0-9]+)\s*$/m)?.[1] ?? null;
78
+ return isHandId(id) ? id : null;
79
+ }
80
+
81
+ /**
82
+ * The pools a hand deals from, out of the canon and the catalogue. Palettes and pairings marked `autoPick: false` are
83
+ * kept for a brand that asks and never dealt; a palette says whether its ground is light. Openings and centerpieces
84
+ * are the catalogue's blocks of that role with no home language (a block with a home is a frame of its language).
85
+ *
86
+ * @param {{ palettes: Array<{ id: string, light: boolean, autoPick?: boolean }>, pairings: Array<{ id: string, autoPick?: boolean }>, catalog: Array<{ slug: string, role?: string, home?: string | null }> }} input
87
+ */
88
+ export function handPools({ palettes = [], pairings = [], catalog = [] } = {}) {
89
+ const open = (list) => list.filter((entry) => entry.autoPick !== false);
90
+ const role = (name) => catalog.filter((block) => block.role === name && !block.home).map((block) => block.slug);
91
+ return {
92
+ languages: [...HAND_LANGUAGES],
93
+ lightPalettes: open(palettes).filter((p) => p.light).map((p) => p.id),
94
+ darkPalettes: open(palettes).filter((p) => !p.light).map((p) => p.id),
95
+ pairings: open(pairings).map((t) => t.id),
96
+ openings: role('opening'),
97
+ centerpieces: role('centerpiece'),
98
+ lenses: HAND_LENSES.map((lens) => lens.id),
99
+ };
100
+ }
101
+
102
+ /** A 53-bit hash of a string (cyrb53): spreads well enough to rank a few dozen names fairly. */
103
+ function hash(text) {
104
+ let h1 = 0xdeadbeef;
105
+ let h2 = 0x41c6ce57;
106
+ for (let i = 0; i < text.length; i++) {
107
+ const c = text.charCodeAt(i);
108
+ h1 = Math.imul(h1 ^ c, 2654435761);
109
+ h2 = Math.imul(h2 ^ c, 1597334677);
110
+ }
111
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
112
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
113
+ return 4294967296 * (2097151 & h2) + (h1 >>> 0);
114
+ }
115
+
116
+ /** The n names of a pool that rank lowest for this id, in rank order (which is random, so it suggests no favourite). */
117
+ function deal(id, pool, name, n) {
118
+ return [...new Set(pool)]
119
+ .map((item) => [hash(`${id}:${name}:${item}`), item])
120
+ .sort((a, b) => a[0] - b[0] || (a[1] < b[1] ? -1 : 1))
121
+ .slice(0, n)
122
+ .map(([, item]) => item);
123
+ }
124
+
125
+ /**
126
+ * An opening that needs no photo: a WebGL background or hero-opener. Every hand holds one, so a site whose photo pack
127
+ * shows nothing of the client can still open well (Roy, 2026-11-15: a type-led or WebGL first screen when no photo
128
+ * fits, never an off-topic one).
129
+ */
130
+ export const isPhotolessOpening = (slug) => slug.startsWith('background-') || slug === 'hero-opener';
131
+
132
+ /** The opening hand: the three that rank lowest for this id, the last swapped for the best photoless one if none is in. */
133
+ function openingsFor(id, pool) {
134
+ const ranked = deal(id, pool, 'opening', pool.length);
135
+ const hand = ranked.slice(0, HAND_SIZE.openings);
136
+ if (hand.some(isPhotolessOpening)) return hand;
137
+ const photoless = ranked.find(isPhotolessOpening);
138
+ return photoless ? [...hand.slice(0, -1), photoless] : hand;
139
+ }
140
+
141
+ /**
142
+ * Deal a hand from the pools (handPools). `source` is a hand id, to rebuild a hand already dealt, or a random source in
143
+ * [0, 1) for a new one (Math.random when left out). Returns the id with the dealt names, in the shape of the skill's
144
+ * .struq/draw.json: { id, languages, palettes, pairings, openings, centerpieces, lenses }, the palettes the light one
145
+ * then the dark one.
146
+ */
147
+ export function dealHand(pools, source = Math.random) {
148
+ const id = typeof source === 'string' ? source : newHandId(source);
149
+ if (!isHandId(id)) throw new Error(`"${id}" is not a hand id.`);
150
+ return {
151
+ id,
152
+ languages: deal(id, pools.languages ?? HAND_LANGUAGES, 'language', HAND_SIZE.languages),
153
+ palettes: [
154
+ ...deal(id, pools.lightPalettes ?? [], 'palette', HAND_SIZE.lightPalettes),
155
+ ...deal(id, pools.darkPalettes ?? [], 'palette', HAND_SIZE.darkPalettes),
156
+ ],
157
+ pairings: deal(id, pools.pairings ?? [], 'pairing', HAND_SIZE.pairings),
158
+ openings: openingsFor(id, pools.openings ?? []),
159
+ centerpieces: deal(id, pools.centerpieces ?? [], 'centerpiece', HAND_SIZE.centerpieces),
160
+ lenses: deal(id, pools.lenses ?? HAND_LENSES.map((lens) => lens.id), 'lens', HAND_SIZE.lenses),
161
+ };
162
+ }
163
+
164
+ /** One line of the treatment head, `Field: value`, or an empty string. */
165
+ function headLine(plan, field) {
166
+ return plan.match(new RegExp(`^${field}:\\s*(.*)$`, 'm'))?.[1] ?? '';
167
+ }
168
+
169
+ /** The numbered storyboard line of a scene, `3. Opening (loud) hero-poster ...`, or an empty string. */
170
+ function sceneLine(plan, scene) {
171
+ return plan.match(new RegExp(`^\\s*\\d+\\.\\s*${scene}\\b[^\\n]*$`, 'm'))?.[0] ?? '';
172
+ }
173
+
174
+ const BRAND = /\(brand\b|\bbrand\)/i;
175
+ const NAMED = /\(named\b|\bnamed\)/i;
176
+
177
+ /**
178
+ * Hold a plan to a hand: the Idea line opens with a dealt lens (`Idea: hour: <point of view>`), unless the client gave
179
+ * the idea, marked (named); the Language, Palette and Type lines and the Opening and Centerpiece scenes of the storyboard
180
+ * each come from it. A Palette or Type line marked (brand) is the client's own and passes; a Language line or an
181
+ * Opening or Centerpiece scene marked (named) is what the client asked for by name and passes. An own scene passes.
182
+ * A line not written yet is no finding here: the film check reports a missing treatment. A hand without a list (an
183
+ * older .struq/draw.json) skips that list.
184
+ *
185
+ * @returns {Array<{ severity: 'fail', id: 'off-draw', detail: string, fix: string }>}
186
+ */
187
+ export function checkHand(plan, hand) {
188
+ const text = String(plan ?? '');
189
+ const findings = [];
190
+ const off = (field, name, ids, fix) =>
191
+ findings.push({ severity: 'fail', id: 'off-draw', detail: `${field} ${name} is not in this site's hand (${ids.join(', ')})`, fix });
192
+ if (hand?.lenses) {
193
+ const idea = headLine(text, 'Idea');
194
+ const lens = idea.match(/^([a-z][\w-]*)\s*:/)?.[1];
195
+ if (idea && !(lens && hand.lenses.includes(lens)) && !NAMED.test(idea)) {
196
+ off('Idea', lens ? `lens ${lens}` : 'without a lens', hand.lenses, 'open the Idea line with the dealt lens it started from (Idea: <lens>: <point of view>), or mark an idea the client gave with (named)');
197
+ }
198
+ }
199
+ for (const [field, ids] of [['Language', hand?.languages], ['Palette', hand?.palettes], ['Type', hand?.pairings]]) {
200
+ if (!ids) continue;
201
+ const line = headLine(text, field);
202
+ const name = line.match(/^[\w-]+/)?.[0];
203
+ if (!name || ids.includes(name)) continue;
204
+ if (field === 'Language' ? NAMED.test(line) : BRAND.test(line)) continue;
205
+ off(
206
+ field,
207
+ name,
208
+ ids,
209
+ field === 'Language'
210
+ ? 'pick the language from the two the hand dealt, or mark one the client asked for by name with (named)'
211
+ : `choose the ${field} from the hand, or mark the client's own brand ${field === 'Type' ? 'faces' : 'colours'} with (brand)`,
212
+ );
213
+ }
214
+ for (const [scene, ids] of [['Opening', hand?.openings], ['Centerpiece', hand?.centerpieces]]) {
215
+ if (!ids) continue;
216
+ const line = sceneLine(text, scene);
217
+ const slug = line.match(/\)\s*([a-z][\w-]*)/)?.[1];
218
+ if (!slug || slug === 'own' || ids.includes(slug) || NAMED.test(line)) continue;
219
+ off(scene, slug, ids, `cast the ${scene} from the hand, or mark a block the client named with (named)`);
220
+ }
221
+ return findings;
222
+ }
@@ -6,6 +6,7 @@
6
6
  // node <skill-dir>/scripts/images.mjs --list every pack, with what it shows
7
7
  // node <skill-dir>/scripts/images.mjs --brief "<the brief>" picks the pack and the fitting photos
8
8
  // node <skill-dir>/scripts/images.mjs <pack-slug> --brief "<the brief>"
9
+ // add --no-hero when no hero photo shows the client's own subject: the first screen is then type-led or WebGL
9
10
  //
10
11
  // With --brief (always pass it): the pack follows Struq's rule (scripts/packs.mjs) when no slug is
11
12
  // given, and photos whose alt names a subject the client does not do are left out, best fit first.
@@ -60,6 +61,9 @@ const flag = (name) => {
60
61
  };
61
62
  const root = resolve(flag("--dir") ?? process.cwd());
62
63
  const brief = flag("--brief");
64
+ // --no-hero: you looked at the hero photos and none shows this client's own subject, so the pack only serves the
65
+ // supporting slots and the first screen is type-led or WebGL (Roy, 2026-11-15: never an off-topic first screen).
66
+ const noHero = args.includes("--no-hero");
63
67
  const slug = args.find((a, i) => !a.startsWith("--") && args[i - 1] !== "--dir" && args[i - 1] !== "--brief") ?? (brief ? packForBrief(brief) : undefined);
64
68
  const why = brief && !args.find((a, i) => !a.startsWith("--") && args[i - 1] !== "--dir" && args[i - 1] !== "--brief") ? packReason(brief) : "named on the command line";
65
69
 
@@ -186,7 +190,7 @@ function writeModule(entries, packSlug, fallback, imageFit = "full") {
186
190
  return ` ${s}: [\n${rows}\n ],`;
187
191
  }).join("\n");
188
192
  const source = `// Written by the Struq skill (scripts/images.mjs). Do not hand-edit the list: run the script again
189
- // to change pack. Pack: ${packSlug}${fallback ? " (FALLBACK PLATES: Struq was unreachable, run the script again when online)" : ""}.
193
+ // to change pack. Pack: ${packSlug}${fallback ? " (FALLBACK PLATES: Struq was unreachable, run the script again when online)" : ""}.${brief ? "\n// Fitted to the brief: images.mjs --brief kept only the photos that fit." : ""}
190
194
  // Beeldbank images are CC0. Translate the alt texts when the site is not in Dutch.
191
195
 
192
196
  export type StruqImage = {
@@ -216,8 +220,8 @@ export const imagePack = ${JSON.stringify(packSlug)};
216
220
  */
217
221
  export const imageFit: "full" | "partial" | "none" = ${JSON.stringify(imageFit)};
218
222
 
219
- /** The credit line for the footer: one quiet text-small link, never a badge. */
220
- export const imageCredit = { label: "Beeld: Struq", href: "https://struq.app/photos" };
223
+ /** The credit line for the footer: one quiet text-small link, never a badge. In the site's language ("Beeld: Struq" in Dutch). */
224
+ export const imageCredit = { label: "Photos: Struq", href: "https://struq.app/photos" };
221
225
 
222
226
  export const images: Record<ImageSlot, StruqImage[]> = {
223
227
  ${body}
@@ -307,6 +311,11 @@ async function main() {
307
311
  } else {
308
312
  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.');
309
313
  }
314
+ if (noHero) {
315
+ pack.images = pack.images.filter((img) => img.slot !== "hero");
316
+ if (imageFit === "full") imageFit = "partial";
317
+ console.log("--no-hero: no hero photo; open with a type-led or WebGL opening (background-*, hero-opener, or type alone).");
318
+ }
310
319
  const jobs = pack.images
311
320
  .filter((img) => SLOTS.includes(img.slot))
312
321
  .map((img) => {