@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.
- package/README.md +27 -27
- package/package.json +2 -2
- package/skills/struq/SKILL.md +168 -165
- package/skills/struq/blocks/CATALOG.md +49 -46
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +2 -1
- package/skills/struq/blocks/backgrounds/background-facets.tsx +5 -3
- package/skills/struq/blocks/backgrounds/background-silk.tsx +6 -2
- package/skills/struq/blocks/catalog.json +50 -5
- package/skills/struq/blocks/film.json +11 -7
- package/skills/struq/blocks/footers/footer-contact.tsx +8 -78
- package/skills/struq/blocks/headers/header-bar.tsx +144 -29
- package/skills/struq/blocks/headers/menu-curtain.tsx +36 -19
- package/skills/struq/blocks/heroes/hero-poster.tsx +36 -10
- package/skills/struq/blocks/kit/HeroCanvas.tsx +6 -2
- package/skills/struq/blocks/kit/SplitHeadline.tsx +2 -1
- package/skills/struq/blocks/kit/accent.ts +29 -3
- package/skills/struq/blocks/kit/blocks.css +530 -6
- package/skills/struq/blocks/kit/feel.ts +183 -0
- package/skills/struq/blocks/kit/gsap.ts +9 -6
- package/skills/struq/blocks/kit/middle/PlaceHours.tsx +254 -0
- package/skills/struq/blocks/kit/middle/ProcessStrip.tsx +428 -0
- package/skills/struq/blocks/kit/middle/ServicesList.tsx +496 -0
- package/skills/struq/blocks/kit/middle/entrance.ts +98 -0
- package/skills/struq/blocks/kit/middle/hours.ts +154 -0
- package/skills/struq/blocks/kit/middle/index.ts +14 -0
- package/skills/struq/blocks/kit/motion/HoverLink.tsx +33 -0
- package/skills/struq/blocks/kit/motion/ImageCurtain.tsx +79 -0
- package/skills/struq/blocks/kit/motion/MaskWipe.tsx +91 -0
- package/skills/struq/blocks/kit/motion/Pressable.tsx +58 -0
- package/skills/struq/blocks/kit/motion/RevealText.tsx +103 -0
- package/skills/struq/blocks/kit/motion/Rise.tsx +77 -0
- package/skills/struq/blocks/kit/motion/ScrubLine.tsx +108 -0
- package/skills/struq/blocks/kit/motion/index.ts +11 -0
- package/skills/struq/blocks/kit/quiet/Close.tsx +178 -52
- package/skills/struq/blocks/kit/quiet/Statement.tsx +3 -2
- package/skills/struq/blocks/kit/subject/AppScreen.tsx +388 -0
- package/skills/struq/blocks/kit/subject/Document.tsx +742 -0
- package/skills/struq/blocks/kit/subject/Mark.tsx +44 -0
- package/skills/struq/blocks/kit/subject/PatternPaper.tsx +410 -0
- package/skills/struq/blocks/kit/subject/SectionDrawing.tsx +349 -0
- package/skills/struq/blocks/kit/subject/Ticket.tsx +354 -0
- package/skills/struq/blocks/kit/subject/build.ts +66 -0
- package/skills/struq/blocks/kit/subject/draw.ts +196 -0
- package/skills/struq/blocks/kit/subject/drawings.ts +373 -0
- package/skills/struq/blocks/kit/subject/format.ts +122 -0
- package/skills/struq/blocks/kit/subject/index.ts +11 -0
- package/skills/struq/blocks/kit/subject/patterns.ts +204 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +75 -22
- package/skills/struq/blocks/showpieces/hero-opener.tsx +55 -7
- package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +64 -6
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +56 -3
- package/skills/struq/blocks/showpieces/panel-wipe.tsx +93 -11
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +38 -5
- package/skills/struq/canon/canon.json +1 -1
- package/skills/struq/references/art-direction.md +51 -0
- package/skills/struq/scripts/blocks.mjs +56 -20
- package/skills/struq/scripts/canon.mjs +36 -7
- package/skills/struq/scripts/check.mjs +417 -11
- package/skills/struq/scripts/draw.mjs +49 -0
- package/skills/struq/scripts/film-check.mjs +28 -4
- package/skills/struq/scripts/gate.mjs +13 -3
- package/skills/struq/scripts/hand.mjs +222 -0
- package/skills/struq/scripts/images.mjs +12 -3
- package/skills/struq/scripts/look.mjs +1184 -0
- 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
|
-
|
|
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
|
|
22
|
-
|
|
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: "
|
|
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) => {
|