@struq/skill 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/skills/struq/SKILL.md +145 -89
  4. package/skills/struq/blocks/CATALOG.md +74 -22
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +34 -12
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +14 -6
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +40 -15
  8. package/skills/struq/blocks/catalog.json +697 -16
  9. package/skills/struq/blocks/film.json +426 -0
  10. package/skills/struq/blocks/footers/footer-contact.tsx +81 -55
  11. package/skills/struq/blocks/footers/footer-reveal.tsx +1 -1
  12. package/skills/struq/blocks/headers/header-bar.tsx +24 -13
  13. package/skills/struq/blocks/headers/menu-curtain.tsx +24 -11
  14. package/skills/struq/blocks/heroes/hero-framed.tsx +33 -20
  15. package/skills/struq/blocks/heroes/hero-index.tsx +32 -11
  16. package/skills/struq/blocks/heroes/hero-poster.tsx +68 -35
  17. package/skills/struq/blocks/heroes/hero-split.tsx +22 -9
  18. package/skills/struq/blocks/heroes/hero-still.tsx +80 -74
  19. package/skills/struq/blocks/kit/accent.ts +82 -0
  20. package/skills/struq/blocks/kit/blocks.css +78 -35
  21. package/skills/struq/blocks/kit/quiet/Close.tsx +73 -0
  22. package/skills/struq/blocks/kit/quiet/PhotoText.tsx +79 -0
  23. package/skills/struq/blocks/kit/quiet/Rows.tsx +81 -0
  24. package/skills/struq/blocks/kit/quiet/Statement.tsx +157 -0
  25. package/skills/struq/blocks/kit/quiet/index.ts +7 -0
  26. package/skills/struq/blocks/kit/quiet/sheet.ts +12 -0
  27. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +87 -36
  28. package/skills/struq/blocks/preloaders/preloader-gate.tsx +116 -46
  29. package/skills/struq/blocks/preloaders/preloader-stack.tsx +10 -7
  30. package/skills/struq/blocks/sections/contact-form.tsx +1 -1
  31. package/skills/struq/blocks/sections/cta-closing.tsx +3 -2
  32. package/skills/struq/blocks/sections/fanned-cards.tsx +14 -10
  33. package/skills/struq/blocks/sections/film-strip.tsx +18 -14
  34. package/skills/struq/blocks/sections/grid-assembly.tsx +16 -11
  35. package/skills/struq/blocks/sections/manifesto.tsx +3 -2
  36. package/skills/struq/blocks/sections/panel-accordion.tsx +3 -2
  37. package/skills/struq/blocks/sections/split-pair.tsx +4 -3
  38. package/skills/struq/blocks/showpieces/hero-opener.tsx +76 -17
  39. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +3 -1
  40. package/skills/struq/blocks/transitions/zoom-through.tsx +9 -10
  41. package/skills/struq/canon/canon.json +1 -1
  42. package/skills/struq/references/craft.md +4 -0
  43. package/skills/struq/references/film.md +158 -0
  44. package/skills/struq/references/triage.md +3 -0
  45. package/skills/struq/scripts/blocks.mjs +89 -7
  46. package/skills/struq/scripts/canon.mjs +8 -1
  47. package/skills/struq/scripts/check.mjs +94 -11
  48. package/skills/struq/scripts/film-check.mjs +997 -0
  49. package/skills/struq/scripts/gate.mjs +146 -0
  50. package/skills/struq/scripts/packs.mjs +73 -12
@@ -0,0 +1,997 @@
1
+ // blocks/film/film-check.mjs
2
+ //
3
+ // The film check: is a page built from Struq blocks one film or a showreel? It reads the treatment in STRUQ.md, finds
4
+ // the blocks in the site's own source, resolves each block's film fields for the variant it uses, and holds the page
5
+ // to the film grammar's budgets. Counts and fixes, never a score.
6
+ //
7
+ // `planCheck` holds the same budgets earlier, on the storyboard alone, so a plan that breaks them is refused before a
8
+ // single block is installed. Both run one shared set of rules, so the two cannot drift.
9
+ //
10
+ // A plain ES module with no imports at all: it is copied as is into the public skill and served as a file by the MCP,
11
+ // so it has to run on bare Node 18 wherever it lands.
12
+
13
+ /** One entry per rule: FAIL breaks the film, WARN bends it. `fix` tells an agent what to change. */
14
+ export const FILM_RULES = [
15
+ {
16
+ id: 'no-treatment',
17
+ severity: 'fail',
18
+ title: 'The plan carries a treatment',
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
+ },
21
+ {
22
+ id: 'one-centerpiece',
23
+ severity: 'fail',
24
+ title: 'Exactly one centerpiece',
25
+ fix: 'Keep one centerpiece block on the page and replace the others with quieter blocks or own scenes, or add one if there is none.',
26
+ },
27
+ {
28
+ id: 'no-pin-after-centerpiece',
29
+ severity: 'fail',
30
+ title: 'No pin after the centerpiece',
31
+ fix: 'Move each pinned block before the centerpiece, or pick a variant of it that does not pin.',
32
+ },
33
+ {
34
+ id: 'frames-one-language',
35
+ severity: 'fail',
36
+ title: "Every framed block speaks the plan's language",
37
+ fix: "Swap each listed block for the variant or block whose home is the plan's Language.",
38
+ },
39
+ {
40
+ id: 'one-clock',
41
+ severity: 'fail',
42
+ title: 'At most one live clock',
43
+ fix: 'Keep one live clock: leave out the prop that turns the others on (clock, or timeZone on wordmark-finale); every clock is off without it.',
44
+ },
45
+ {
46
+ id: 'one-marquee',
47
+ severity: 'fail',
48
+ title: 'At most one marquee',
49
+ fix: 'Keep one marquee band on the page and pick a variant or block without one for the others.',
50
+ },
51
+ {
52
+ id: 'pinned-screens',
53
+ severity: 'fail',
54
+ title: 'At most 4.5 pinned screens, at most 1.5 in the opening',
55
+ fix: 'Pick variants that do not pin, or pin shorter, until the page holds 4.5 screens or fewer and the opening 1.5 or fewer.',
56
+ },
57
+ {
58
+ id: 'transition-needs-routes',
59
+ severity: 'fail',
60
+ title: 'A page transition needs more than one route',
61
+ fix: 'Remove the page transition: a site with one route never navigates, so the preloader alone frames the arrival.',
62
+ },
63
+ {
64
+ id: 'centerpiece-is-planned',
65
+ severity: 'fail',
66
+ title: 'The planned centerpiece is the one on the page',
67
+ fix: "Make the plan's Centerpiece line name the centerpiece block on the page, or put the planned one on the page.",
68
+ },
69
+ {
70
+ id: 'scene-count',
71
+ severity: 'fail',
72
+ title: 'The storyboard has seven to nine scenes (plan check only)',
73
+ fix: 'Write seven to nine numbered storyboard lines: merge scenes that say the same thing, or add a quiet scene where the film needs a breath.',
74
+ },
75
+ {
76
+ id: 'unknown-block',
77
+ severity: 'fail',
78
+ title: 'Every block the storyboard names is in the catalogue (plan check only)',
79
+ fix: 'Name a block slug and variant exactly as the catalogue lists them, or write the scene as an own scene.',
80
+ },
81
+ {
82
+ id: 'loud-adjacent',
83
+ severity: 'warn',
84
+ title: 'No two loud scenes side by side',
85
+ fix: 'Put a quiet scene (energy 1 or less) between the two, or pick a quieter variant for one of them.',
86
+ },
87
+ {
88
+ id: 'quiet-share',
89
+ severity: 'warn',
90
+ title: 'At least 40 percent of the scenes are quiet',
91
+ fix: 'Swap loud scenes for quiet variants or own scenes until at least 40 percent of the scenes have energy 1 or less.',
92
+ },
93
+ {
94
+ id: 'quiet-is-quiet',
95
+ severity: 'warn',
96
+ title: 'A scene tagged quiet names a quiet block (plan check only)',
97
+ fix: 'Pick the quieter variant the detail names, write the scene as an own scene, or tag it loud and keep a quiet scene beside it.',
98
+ },
99
+ {
100
+ id: 'full-bleed-reveals',
101
+ severity: 'warn',
102
+ title: 'At most two full-bleed reveals',
103
+ fix: 'Keep two full-bleed reveals at most and pick variants that hold the photo in a frame for the rest.',
104
+ },
105
+ {
106
+ id: 'foreign-metaphor',
107
+ severity: 'warn',
108
+ title: "No metaphor from outside the client's world",
109
+ fix: "Fill the block's words, labels and counters from the plan's Lexicon, or pick a block without a metaphor.",
110
+ },
111
+ {
112
+ id: 'one-motif',
113
+ severity: 'warn',
114
+ title: 'One motif on the page',
115
+ fix: "Pick a variant or block whose motif is the plan's Motif or neutral.",
116
+ },
117
+ {
118
+ id: 'storyboard-matches-page',
119
+ severity: 'warn',
120
+ title: 'The page follows the storyboard',
121
+ fix: 'Build the storyboard\'s blocks in its order, or rewrite the storyboard to match the page that was built.',
122
+ },
123
+ {
124
+ id: 'own-scene-unmarked',
125
+ severity: 'warn',
126
+ title: 'Every quiet own scene on the storyboard is marked on the page',
127
+ fix: "Build the quiet scenes with the kit's Statement, PhotoText, Rows and Close, or put data-scene=\"<name>\" (and data-energy when it is not 1) on the root of each own scene, so the check can see it.",
128
+ },
129
+ {
130
+ id: 'easing-values',
131
+ severity: 'warn',
132
+ title: 'At most two cubic-bezier curves',
133
+ fix: 'Use at most two cubic-bezier curves in your own files, or the eases the blocks already carry.',
134
+ },
135
+ ];
136
+
137
+ const RULE = new Map(FILM_RULES.map((rule, order) => [rule.id, { ...rule, order }]));
138
+
139
+ const FIELDS = ['role', 'home', 'motif', 'metaphor', 'energy', 'pins', 'scroll', 'fullBleed', 'interactions', 'clockProp'];
140
+
141
+ /**
142
+ * Interactions that only run when the page passes a prop. The catalogue tags the block with the interaction it can
143
+ * have and names the prop that turns its clock on (`clockProp`: `clock`, or `timeZone` for wordmark-finale). This
144
+ * table is the fallback for a catalogue from before that field.
145
+ */
146
+ const PROP_GATED = { 'wordmark-finale': { clock: 'timeZone' } };
147
+
148
+ /**
149
+ * The kit's composed quiet scenes. On a page they count only when imported from a path ending in kit/quiet or
150
+ * kit/quiet/<Name>; on a storyboard their name stands where a block's slug would.
151
+ */
152
+ const KIT_SCENES = ['Statement', 'PhotoText', 'Rows', 'Close'];
153
+ const KIT_NAME = /^[^A-Za-z0-9]*(Statement|PhotoText|Rows|Close)(?![A-Za-z0-9-])/;
154
+
155
+ const PINNED_CAP = 4.5;
156
+ const OPENING_CAP = 1.5;
157
+
158
+ // ---------------------------------------------------------------------------------------------------------------------
159
+ // The catalogue
160
+
161
+ function pascal(slug) {
162
+ return slug
163
+ .split('-')
164
+ .filter(Boolean)
165
+ .map((part) => part[0].toUpperCase() + part.slice(1))
166
+ .join('');
167
+ }
168
+
169
+ /**
170
+ * Index the catalogue by slug. An entry with a string `variant` is a flattened entry (one row per block and variant);
171
+ * any other entry is the block itself, whose base values describe its default variant.
172
+ */
173
+ function indexCatalog(catalog) {
174
+ const index = new Map();
175
+ for (const entry of Array.isArray(catalog) ? catalog : []) {
176
+ if (!entry || typeof entry !== 'object' || typeof entry.slug !== 'string' || !entry.slug) continue;
177
+ let item = index.get(entry.slug);
178
+ if (!item) {
179
+ item = { slug: entry.slug, base: null, flat: new Map() };
180
+ index.set(entry.slug, item);
181
+ }
182
+ if (typeof entry.variant === 'string') item.flat.set(entry.variant, entry);
183
+ else item.base = entry;
184
+ }
185
+ return index;
186
+ }
187
+
188
+ function variantIds(item) {
189
+ const listed = item.base && Array.isArray(item.base.variants) ? item.base.variants : [];
190
+ const ids = listed.map((variant) => variant && variant.id).filter((id) => typeof id === 'string');
191
+ for (const id of item.flat.keys()) if (!ids.includes(id)) ids.push(id);
192
+ return ids;
193
+ }
194
+
195
+ /** A block's effective film fields for one variant: the base, then `byVariant`, then any flattened values on top. */
196
+ function resolveItem(item, variant) {
197
+ const ids = variantIds(item);
198
+ const given = typeof variant === 'string' && variant ? variant : null;
199
+ const id = given && ids.includes(given) ? given : ids[0];
200
+ const base = item.base || (id !== undefined && item.flat.get(id)) || {};
201
+ const layers = [base];
202
+ if (id !== undefined) {
203
+ layers.push(base.byVariant && typeof base.byVariant === 'object' ? base.byVariant[id] : undefined);
204
+ layers.push(Array.isArray(base.variants) ? base.variants.find((entry) => entry && entry.id === id) : undefined);
205
+ layers.push(item.flat.get(id));
206
+ }
207
+ const film = {};
208
+ for (const layer of layers) {
209
+ if (!layer || typeof layer !== 'object') continue;
210
+ for (const field of FIELDS) {
211
+ if (Object.prototype.hasOwnProperty.call(layer, field) && layer[field] !== undefined) film[field] = layer[field];
212
+ }
213
+ }
214
+ const kind = base.kind !== undefined ? base.kind : (item.flat.get(id) || {}).kind;
215
+ return {
216
+ slug: item.slug,
217
+ variant: given || (id !== undefined ? id : null),
218
+ kind: typeof kind === 'string' ? kind : null,
219
+ role: typeof film.role === 'string' ? film.role : null,
220
+ home: typeof film.home === 'string' ? film.home : null,
221
+ motif: typeof film.motif === 'string' ? film.motif : null,
222
+ metaphor: typeof film.metaphor === 'string' && film.metaphor ? film.metaphor : null,
223
+ energy: typeof film.energy === 'number' ? film.energy : 0,
224
+ pins: film.pins === true,
225
+ scroll: typeof film.scroll === 'number' ? film.scroll : 0,
226
+ fullBleed: film.fullBleed === true,
227
+ interactions: Array.isArray(film.interactions) ? film.interactions.slice() : [],
228
+ clockProp: typeof film.clockProp === 'string' && film.clockProp ? film.clockProp : null,
229
+ };
230
+ }
231
+
232
+ /** The effective film fields of one block in one variant (the default variant when none is given). */
233
+ export function resolveBlock(catalog, slug, variant) {
234
+ const item = indexCatalog(catalog).get(slug);
235
+ return item ? resolveItem(item, variant) : null;
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------------------------------------------------
239
+ // The treatment
240
+
241
+ const KEY_LINE = /^([A-Za-z][A-Za-z ]*?)\s*:\s*(.*)$/;
242
+
243
+ function firstWord(value) {
244
+ const match = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*/i.exec(value || '');
245
+ return match ? match[0].toLowerCase() : null;
246
+ }
247
+
248
+ /**
249
+ * The treatment head of STRUQ.md: the Language, Motif and Centerpiece as their first word, the Lexicon as written,
250
+ * and the Storyboard as the text of its numbered lines (null when the plan has no Storyboard line).
251
+ */
252
+ export function readTreatment(plan) {
253
+ const raw = (typeof plan === 'string' ? plan : '').split(/\r?\n/);
254
+ const lines = raw.map((line) => line.replace(/\*\*|__/g, '').trim());
255
+ const fields = new Map();
256
+ let storyboardAt = -1;
257
+ lines.forEach((line, at) => {
258
+ const match = KEY_LINE.exec(line);
259
+ if (!match) return;
260
+ const key = match[1].trim().toLowerCase();
261
+ if (fields.has(key)) return;
262
+ fields.set(key, match[2].trim());
263
+ if (key === 'storyboard') storyboardAt = at;
264
+ });
265
+ let storyboard = null;
266
+ if (storyboardAt >= 0) {
267
+ storyboard = [];
268
+ for (let at = storyboardAt + 1; at < lines.length; at++) {
269
+ const line = lines[at];
270
+ if (!line) continue;
271
+ const scene = /^\d+[.)]\s+(.*)$/.exec(line);
272
+ if (scene) {
273
+ storyboard.push(scene[1]);
274
+ continue;
275
+ }
276
+ // An unindented key line or a heading ends the storyboard; anything else is a scene's continuation.
277
+ if (line.startsWith('#') || (/^[A-Za-z]/.test(raw[at]) && KEY_LINE.test(line))) break;
278
+ }
279
+ }
280
+ return {
281
+ language: fields.has('language') ? firstWord(fields.get('language')) : null,
282
+ motif: fields.has('motif') ? firstWord(fields.get('motif')) : null,
283
+ centerpiece: fields.has('centerpiece') ? firstWord(fields.get('centerpiece')) : null,
284
+ lexicon: fields.has('lexicon') ? fields.get('lexicon') || null : null,
285
+ hasLanguage: fields.has('language'),
286
+ storyboard,
287
+ };
288
+ }
289
+
290
+ const SLUG_TOKEN = /(?<![A-Za-z0-9-])([a-z0-9]+(?:-[a-z0-9]+)*)(?::([a-z0-9]+(?:-[a-z0-9]+)*))?(?![A-Za-z0-9-])/g;
291
+
292
+ /** The kit quiet scene a line names first after its `(tone)` tag, such as `Statement`, or null. */
293
+ function kitAfterTag(head) {
294
+ const tag = TONE_TAG.exec(head);
295
+ if (!tag) return null;
296
+ const match = KIT_NAME.exec(head.slice(tag.index + tag[0].length));
297
+ return match ? match[1] : null;
298
+ }
299
+
300
+ /**
301
+ * One storyboard line: an own scene, a kit quiet scene named after its `(tone)` tag, or the block it names (looked
302
+ * for after the `(tone)` group first).
303
+ */
304
+ function readScene(text, index) {
305
+ const head = text.split('|')[0];
306
+ const own = /\bown scene\b/i.test(head);
307
+ if (own) return { own, kit: null, item: null, variant: null };
308
+ const kit = kitAfterTag(head);
309
+ if (kit) return { own, kit, item: null, variant: null };
310
+ const close = head.lastIndexOf(')');
311
+ const zones = close >= 0 ? [head.slice(close + 1), head] : [head];
312
+ for (const zone of zones) {
313
+ for (const match of zone.matchAll(SLUG_TOKEN)) {
314
+ const item = index.get(match[1]);
315
+ if (item) return { own, kit: null, item, variant: match[2] || null };
316
+ }
317
+ }
318
+ return { own, kit: null, item: null, variant: null };
319
+ }
320
+
321
+ const TONE_TAG = /\(([^)]*)\)/;
322
+ const OWN_SCENE = /^[^A-Za-z0-9]*own scene\b/i;
323
+ const FOOTER = /\bfooter\s*:([^|]*)/i;
324
+ /** A clock asked for in words: a timeZone, or the word clock or klok (never a variant id such as footer-contact:clock). */
325
+ const CLOCK_ASK = /timezone|(?<![:\w-])(?:clock|klok)\b/gi;
326
+ const NEGATED = /\b(?:zonder|geen|no|not|without)\s+(?:(?:a|an|the|een|de)\s+)?$/i;
327
+
328
+ /** True when the line asks for a clock, and not as "zonder timeZone", "geen klok" or "no clock". */
329
+ function asksClock(text) {
330
+ for (const match of text.matchAll(CLOCK_ASK)) if (!NEGATED.test(text.slice(0, match.index))) return true;
331
+ return false;
332
+ }
333
+
334
+ /**
335
+ * One storyboard line as the plan check reads it, strictly: the scene's name before the `(tone)` tag, the tone, and
336
+ * the first slug after the tag (or `own scene`, or a kit quiet scene such as `Statement`); the block after `footer:` comes along as a second block. A line with
337
+ * no tag falls back to the first slug the catalogue knows, as the page check reads it.
338
+ */
339
+ function readPlanLine(text) {
340
+ const head = text.split('|')[0];
341
+ const tag = TONE_TAG.exec(head);
342
+ const name = (tag ? head.slice(0, tag.index) : '').trim() || null;
343
+ const tone = tag ? tag[1].trim().toLowerCase() : null;
344
+ const after = tag ? head.slice(tag.index + tag[0].length) : head;
345
+ const own = OWN_SCENE.test(after);
346
+ const kitMatch = own ? null : KIT_NAME.exec(after);
347
+ const kit = kitMatch ? kitMatch[1] : null;
348
+ let block = null;
349
+ if (!own && !kit) {
350
+ const match = after.match(new RegExp(SLUG_TOKEN.source));
351
+ if (match) block = { slug: match[1], variant: match[2] || null };
352
+ }
353
+ let footer = null;
354
+ const footerAt = FOOTER.exec(text);
355
+ if (footerAt) {
356
+ const match = footerAt[1].match(new RegExp(SLUG_TOKEN.source));
357
+ if (match) footer = { slug: match[1], variant: match[2] || null };
358
+ }
359
+ return { name, tone, tagged: Boolean(tag), own, kit, block, footer, clock: asksClock(text) };
360
+ }
361
+
362
+ // ---------------------------------------------------------------------------------------------------------------------
363
+ // The page
364
+
365
+ const PAGE_FILE = /(^|\/)page\.(tsx|jsx|js)$/;
366
+ const LAYOUT_FILE = /(^|\/)layout\.(tsx|jsx|js)$/;
367
+ const ROOT_PAGE = /(^|\/)app\/page\.(tsx|jsx|js)$/;
368
+ const ROOT_LAYOUT = /(^|\/)app\/layout\.(tsx|jsx|js)$/;
369
+ const SCRIPT_FILE = /\.(tsx|jsx|ts|js|mjs)$/;
370
+ const STRUQ_COPY = /(^|\/)components\/struq\//;
371
+
372
+ /** A path without its route groups, so app/(site)/page.tsx reads as the root page. */
373
+ function routePath(path) {
374
+ return path.replace(/\/\([^/]*\)(?=\/)/g, '');
375
+ }
376
+
377
+ /** The site's own files in page order: root layouts (outermost first), the root page, then shared non-route files. */
378
+ function orderFiles(files) {
379
+ const own = (Array.isArray(files) ? files : [])
380
+ .filter((file) => file && typeof file.text === 'string')
381
+ .map((file) => ({ path: String(file.path || '').replace(/\\/g, '/').replace(/^\.\//, ''), text: file.text }))
382
+ .filter((file) => !STRUQ_COPY.test(file.path));
383
+ const layouts = own
384
+ .filter((file) => ROOT_LAYOUT.test(routePath(file.path)))
385
+ .sort((a, b) => a.path.length - b.path.length);
386
+ const pages = own.filter((file) => ROOT_PAGE.test(routePath(file.path)));
387
+ const shared = own.filter((file) => !PAGE_FILE.test(file.path) && !LAYOUT_FILE.test(file.path));
388
+ return {
389
+ own,
390
+ ordered: [...layouts, ...pages, ...shared],
391
+ routes: own.filter((file) => PAGE_FILE.test(file.path)).length,
392
+ };
393
+ }
394
+
395
+ /** Block comments anywhere and line comments that open a line; a page that comments a block out does not use it. */
396
+ function stripComments(text) {
397
+ return text.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/^[ \t]*\/\/.*$/gm, '');
398
+ }
399
+
400
+ /** The index just past the `>` that closes a JSX opening tag, skipping strings and `{...}` expressions. */
401
+ function tagEnd(source, from) {
402
+ let depth = 0;
403
+ let quote = null;
404
+ for (let at = from; at < source.length; at++) {
405
+ const char = source[at];
406
+ if (quote) {
407
+ if (char === '\\') at++;
408
+ else if (char === quote) quote = null;
409
+ continue;
410
+ }
411
+ if (char === '"' || char === "'" || char === '`') quote = char;
412
+ else if (char === '{') depth++;
413
+ else if (char === '}') depth = Math.max(0, depth - 1);
414
+ else if (char === '>' && depth === 0) return at + 1;
415
+ }
416
+ return source.length;
417
+ }
418
+
419
+ const VARIANT_PROP = /(?:^|\s)variant\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*(?:"([^"]*)"|'([^']*)'|`([^`$]*)`)\s*\})/;
420
+ const JSX_TAG = /(?<![\w$.])<([A-Z][A-Za-z0-9]*)(?=[\s/>])/g;
421
+ const PROP_START = /[A-Za-z_$]/;
422
+ const PROP_CHAR = /[\w$-]/;
423
+
424
+ /** The end of a quoted string that starts at `from`. */
425
+ function stringEnd(text, from) {
426
+ const quote = text[from];
427
+ for (let at = from + 1; at < text.length; at++) {
428
+ if (text[at] === '\\') at++;
429
+ else if (text[at] === quote) return at + 1;
430
+ }
431
+ return text.length;
432
+ }
433
+
434
+ /** The end of a `{...}` group that starts at `from`, strings inside it skipped. */
435
+ function braceEnd(text, from) {
436
+ let depth = 0;
437
+ for (let at = from; at < text.length; at++) {
438
+ const char = text[at];
439
+ if (char === '"' || char === "'" || char === '`') at = stringEnd(text, at) - 1;
440
+ else if (char === '{') depth++;
441
+ else if (char === '}' && --depth === 0) return at + 1;
442
+ }
443
+ return text.length;
444
+ }
445
+
446
+ /**
447
+ * The props a tag passes: `name=...` and a bare `name` (true). `name={false}` is left out, and a spread cannot be
448
+ * read, so it adds nothing. Words inside string and expression values are never read as props.
449
+ */
450
+ function propNames(attributes) {
451
+ const names = new Set();
452
+ let at = 0;
453
+ while (at < attributes.length) {
454
+ const char = attributes[at];
455
+ if (char === '{') {
456
+ at = braceEnd(attributes, at);
457
+ continue;
458
+ }
459
+ if (!PROP_START.test(char)) {
460
+ at++;
461
+ continue;
462
+ }
463
+ let end = at;
464
+ while (end < attributes.length && PROP_CHAR.test(attributes[end])) end++;
465
+ const name = attributes.slice(at, end);
466
+ at = end;
467
+ while (at < attributes.length && /\s/.test(attributes[at])) at++;
468
+ if (attributes[at] !== '=') {
469
+ names.add(name);
470
+ continue;
471
+ }
472
+ at++;
473
+ while (at < attributes.length && /\s/.test(attributes[at])) at++;
474
+ const from = at;
475
+ if (attributes[at] === '"' || attributes[at] === "'") at = stringEnd(attributes, at);
476
+ else if (attributes[at] === '{') at = braceEnd(attributes, at);
477
+ if (!/^\{\s*false\s*\}$/.test(attributes.slice(from, at))) names.add(name);
478
+ }
479
+ return names;
480
+ }
481
+
482
+ function readVariant(attributes) {
483
+ const match = VARIANT_PROP.exec(attributes);
484
+ if (!match) return null;
485
+ const value = match.slice(1).find((group) => group !== undefined);
486
+ return value ? value.trim() : null;
487
+ }
488
+
489
+ const IMPORT_FROM = /\bimport\s+(?!type\b)([\w$]+\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*["']([^"']+)["']/g;
490
+ const KIT_PATH = /(?:^|\/)kit\/quiet(?:\/(Statement|PhotoText|Rows|Close))?(?:\/index)?(?:\.(?:tsx|ts|jsx|js))?$/;
491
+ const ANY_TAG = /(?<![\w$.])<([A-Za-z][\w.]*)(?=[\s/>])/g;
492
+ const DATA_SCENE = /(?:^|\s)data-scene\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*(?:"([^"]*)"|'([^']*)'|`([^`$]*)`|([^}]*))\s*\})/;
493
+ const DATA_ENERGY = /(?:^|\s)data-energy\s*=\s*(?:"\s*(\d+(?:\.\d+)?)\s*"|'\s*(\d+(?:\.\d+)?)\s*'|\{\s*["'`]?\s*(\d+(?:\.\d+)?)\s*["'`]?\s*\})/;
494
+
495
+ /**
496
+ * The local names a file gives the kit's quiet scenes, mapped to the kit name: a named import from a path ending in
497
+ * kit/quiet or kit/quiet/<Name> (aliases followed), or a default import from kit/quiet/<Name>.
498
+ */
499
+ function kitImports(source) {
500
+ const local = new Map();
501
+ for (const match of source.matchAll(IMPORT_FROM)) {
502
+ const path = KIT_PATH.exec(match[3]);
503
+ if (!path) continue;
504
+ const fallback = match[1] ? match[1].replace(/[\s,]/g, '') : '';
505
+ if (fallback && path[1]) local.set(fallback, path[1]);
506
+ for (const part of (match[2] || '').split(',')) {
507
+ const names = /^\s*(?:type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
508
+ if (!names || /^\s*type\s/.test(part)) continue;
509
+ const kit = names[1];
510
+ if (KIT_SCENES.includes(kit) && (!path[1] || path[1] === kit)) local.set(names[2] || kit, kit);
511
+ }
512
+ }
513
+ return local;
514
+ }
515
+
516
+ function firstGroup(match) {
517
+ return match ? match.slice(1).find((group) => group !== undefined) : undefined;
518
+ }
519
+
520
+ /**
521
+ * Every scene tag in the ordered files, in source order: block tags, the kit's quiet scenes where the file imports
522
+ * them from the kit, and any element that marks itself with `data-scene`. Other components are ignored.
523
+ */
524
+ function findBlocks(ordered, components) {
525
+ const found = [];
526
+ for (const file of ordered) {
527
+ if (!SCRIPT_FILE.test(file.path)) continue;
528
+ const source = stripComments(file.text);
529
+ const kits = kitImports(source);
530
+ ANY_TAG.lastIndex = 0;
531
+ let match;
532
+ while ((match = ANY_TAG.exec(source))) {
533
+ const name = match[1];
534
+ const item = /^[A-Z]/.test(name) ? components.get(name) : undefined;
535
+ const kit = item ? null : kits.get(name) || null;
536
+ const start = match.index + match[0].length;
537
+ if (item || kit) {
538
+ const end = tagEnd(source, start);
539
+ const attributes = source.slice(start, end);
540
+ if (item) found.push({ item, file: file.path, variant: readVariant(attributes), props: propNames(attributes) });
541
+ else found.push({ marked: { slug: `quiet:${kit}`, energy: 1 }, file: file.path });
542
+ ANY_TAG.lastIndex = end;
543
+ continue;
544
+ }
545
+ // Any other tag is a scene only when it carries data-scene; otherwise the scan goes on inside it.
546
+ const end = tagEnd(source, start);
547
+ const attributes = source.slice(start, end);
548
+ const scene = DATA_SCENE.exec(attributes);
549
+ if (!scene) continue;
550
+ const value = (firstGroup(scene) || '').trim() || 'scene';
551
+ const energy = Number(firstGroup(DATA_ENERGY.exec(attributes)) ?? 1);
552
+ found.push({ marked: { slug: `own:${value}`, energy: Number.isFinite(energy) ? energy : 1 }, file: file.path });
553
+ ANY_TAG.lastIndex = end;
554
+ }
555
+ }
556
+ return found;
557
+ }
558
+
559
+ /** A scene the page marks itself (a kit quiet scene or a `data-scene` element), shaped like a resolved block. */
560
+ function markedScene({ slug, energy }) {
561
+ return {
562
+ slug,
563
+ variant: null,
564
+ kind: 'own-scene',
565
+ role: energy <= 1 ? 'quiet' : 'scene',
566
+ home: null,
567
+ motif: null,
568
+ metaphor: null,
569
+ energy,
570
+ pins: false,
571
+ scroll: 0,
572
+ fullBleed: false,
573
+ interactions: [],
574
+ clockProp: null,
575
+ own: true,
576
+ marked: true,
577
+ };
578
+ }
579
+
580
+ // ---------------------------------------------------------------------------------------------------------------------
581
+ // The check
582
+
583
+ function label(block) {
584
+ if (block.own && block.slug) return block.slug;
585
+ if (block.own) return block.name ? `own scene (${block.name})` : 'own scene';
586
+ return block.variant ? `${block.slug}:${block.variant}` : block.slug;
587
+ }
588
+
589
+ function list(blocks) {
590
+ return blocks.map(label).join(', ');
591
+ }
592
+
593
+ function screens(value) {
594
+ return String(Math.round(value * 100) / 100);
595
+ }
596
+
597
+ function plural(count, word) {
598
+ return `${count} ${word}${count === 1 ? '' : 's'}`;
599
+ }
600
+
601
+ /** cubic-bezier(.16, 1, .3, 1) and cubic-bezier(0.16,1,0.3,1) are one curve. */
602
+ function curveKey(args) {
603
+ return args
604
+ .split(',')
605
+ .map((part) => {
606
+ const value = part.trim();
607
+ return value !== '' && Number.isFinite(Number(value)) ? String(Number(value)) : value;
608
+ })
609
+ .join(',');
610
+ }
611
+
612
+ /** An `add(id, count, detail)` that pushes a finding with the rule's severity and fix. */
613
+ function collector(findings) {
614
+ return (id, count, detail) => {
615
+ const rule = RULE.get(id);
616
+ findings.push({ id, severity: rule.severity, count, detail, fix: rule.fix });
617
+ };
618
+ }
619
+
620
+ function missingTreatment(treatment) {
621
+ const missing = [!treatment.hasLanguage && 'Language:', treatment.storyboard === null && 'Storyboard:'].filter(Boolean);
622
+ return `The plan has no ${missing.join(' and no ')} line, so the plan-dependent rules were skipped.`;
623
+ }
624
+
625
+ /** Drop the interactions that only run when a prop is passed and the prop is not among `props`. */
626
+ function gateInteractions(block, props) {
627
+ const gates = { ...(PROP_GATED[block.slug] || {}) };
628
+ if (block.clockProp) gates.clock = block.clockProp;
629
+ block.interactions = block.interactions.filter((interaction) => !gates[interaction] || props.has(gates[interaction]));
630
+ return block;
631
+ }
632
+
633
+ /**
634
+ * The budgets that hold for a page and a storyboard alike. `used` is every block in order (frames included, and own
635
+ * scenes in a storyboard), `scenes` the ones whose role is not a frame, `extraOwn` own scenes known only from the
636
+ * storyboard (the built page cannot see them), `where` names what was read in the details: page or storyboard.
637
+ */
638
+ function holdBudgets(add, { used, scenes, extraOwn, treatment, planned, routes, where }) {
639
+ // FAIL
640
+
641
+ const centerpieces = used.filter((block) => block.role === 'centerpiece');
642
+ if (centerpieces.length === 0) add('one-centerpiece', 1, `No centerpiece on the ${where}.`);
643
+ else if (centerpieces.length > 1) {
644
+ add('one-centerpiece', centerpieces.length, `${plural(centerpieces.length, 'centerpiece')}: ${list(centerpieces)}.`);
645
+ }
646
+
647
+ if (centerpieces.length) {
648
+ const after = used.slice(used.indexOf(centerpieces[0]) + 1).filter((block) => block.pins);
649
+ if (after.length) {
650
+ add('no-pin-after-centerpiece', after.length, `Pinned after ${label(centerpieces[0])}: ${list(after)}.`);
651
+ }
652
+ }
653
+
654
+ if (planned) {
655
+ const off = used.filter((block) => block.home && block.home !== treatment.language);
656
+ if (off.length) {
657
+ const parts = off.map((block) => `${label(block)} is ${block.home}`).join(', ');
658
+ add('frames-one-language', off.length, `The plan's Language is ${treatment.language || 'empty'}; ${parts}.`);
659
+ }
660
+ }
661
+
662
+ for (const [id, interaction] of [['one-clock', 'clock'], ['one-marquee', 'marquee']]) {
663
+ const live = used.filter((block) => block.interactions.includes(interaction));
664
+ if (live.length > 1) add(id, live.length, `${plural(live.length, interaction)}: ${list(live)}.`);
665
+ }
666
+
667
+ {
668
+ const pinned = used.filter((block) => block.scroll > 0);
669
+ const total = pinned.reduce((sum, block) => sum + block.scroll, 0);
670
+ const problems = [];
671
+ if (total > PINNED_CAP + 1e-9) {
672
+ problems.push(`${screens(total)} pinned screens in all, the cap is ${PINNED_CAP} (${list(pinned)})`);
673
+ }
674
+ for (const block of used) {
675
+ if (block.role === 'opening' && block.scroll > OPENING_CAP + 1e-9) {
676
+ problems.push(`the opening ${label(block)} holds ${screens(block.scroll)} screens, the cap is ${OPENING_CAP}`);
677
+ }
678
+ }
679
+ if (problems.length) add('pinned-screens', problems.length, `${problems.join('; ')}.`);
680
+ }
681
+
682
+ {
683
+ const transitions = used.filter((block) =>
684
+ block.kind ? block.kind === 'page-transition' : block.slug.startsWith('page-transition'),
685
+ );
686
+ if (transitions.length && routes <= 1) {
687
+ add('transition-needs-routes', transitions.length, `${list(transitions)} on a site with ${plural(routes, 'route')}.`);
688
+ }
689
+ }
690
+
691
+ if (planned) {
692
+ const named = treatment.centerpiece;
693
+ if (!named) add('centerpiece-is-planned', 1, 'The plan has no Centerpiece line.');
694
+ else if (!centerpieces.some((block) => block.slug === named)) {
695
+ const onPage = centerpieces.length ? `the ${where} has ${list(centerpieces)}` : `it is not on the ${where}`;
696
+ add('centerpiece-is-planned', 1, `The plan names ${named}; ${onPage}.`);
697
+ }
698
+ }
699
+
700
+ // WARN
701
+
702
+ {
703
+ const pairs = [];
704
+ for (let at = 0; at + 1 < scenes.length; at++) {
705
+ if (scenes[at].energy >= 2 && scenes[at + 1].energy >= 2) {
706
+ pairs.push(`${label(scenes[at])} (${scenes[at].energy}) then ${label(scenes[at + 1])} (${scenes[at + 1].energy})`);
707
+ }
708
+ }
709
+ if (pairs.length) add('loud-adjacent', pairs.length, `${plural(pairs.length, 'loud pair')}: ${pairs.join('; ')}.`);
710
+ }
711
+
712
+ {
713
+ const ownScenes = scenes.filter((block) => block.own).length + extraOwn;
714
+ const total = scenes.length + extraOwn;
715
+ const quiet = scenes.filter((block) => block.energy <= 1).length + extraOwn;
716
+ if (total > 0 && quiet * 10 < total * 4) {
717
+ const counted = ownScenes ? `, ${plural(ownScenes, 'own scene')} counted as quiet` : '';
718
+ add(
719
+ 'quiet-share',
720
+ Math.ceil((total * 4) / 10) - quiet,
721
+ `${quiet} of ${total} scenes are quiet (energy 1 or less${counted}); 40 percent needs ${Math.ceil((total * 4) / 10)}.`,
722
+ );
723
+ }
724
+ }
725
+
726
+ {
727
+ const reveals = used.filter((block) => block.fullBleed);
728
+ if (reveals.length > 2) add('full-bleed-reveals', reveals.length, `${plural(reveals.length, 'full-bleed reveal')}: ${list(reveals)}.`);
729
+ }
730
+
731
+ {
732
+ const carried = used.filter((block) => block.metaphor);
733
+ if (carried.length) {
734
+ const parts = carried.map((block) => `${label(block)} carries ${block.metaphor}`).join(', ');
735
+ add('foreign-metaphor', carried.length, `${parts}.`);
736
+ }
737
+ }
738
+
739
+ if (planned) {
740
+ const speaking = scenes.filter((block) => block.motif && block.motif !== 'neutral');
741
+ if (treatment.motif) {
742
+ const off = speaking.filter((block) => block.motif !== treatment.motif);
743
+ if (off.length) {
744
+ const parts = off.map((block) => `${label(block)} speaks ${block.motif}`).join(', ');
745
+ add('one-motif', off.length, `The plan's Motif is ${treatment.motif}; ${parts}.`);
746
+ }
747
+ } else {
748
+ const families = [...new Set(speaking.map((block) => block.motif))];
749
+ if (families.length > 1) {
750
+ add('one-motif', speaking.length, `The plan names no Motif and the scenes speak ${families.join(', ')}.`);
751
+ }
752
+ }
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Check one site against the film grammar.
758
+ * `plan` is the text of STRUQ.md, `files` the site's own source files (never components/struq/**), `catalog` the
759
+ * block catalogue. Returns the findings in rule order and every block found on the page, in page order, with the
760
+ * scenes the page marks itself between them: the kit's quiet scenes as `quiet:<Name>` (Statement, PhotoText, Rows,
761
+ * Close, imported from kit/quiet) and any element with `data-scene="<name>"` as `own:<name>`, energy 1 unless its
762
+ * `data-energy` says otherwise.
763
+ */
764
+ export function filmCheck({ plan, files, catalog } = {}) {
765
+ const index = indexCatalog(catalog);
766
+ const components = new Map([...index.values()].map((item) => [pascal(item.slug), item]));
767
+ const { own, ordered, routes } = orderFiles(files);
768
+
769
+ const used = findBlocks(ordered, components).map(({ item, marked, file, variant, props }) => {
770
+ const block = marked ? markedScene(marked) : gateInteractions(resolveItem(item, variant), props);
771
+ block.file = file;
772
+ return block;
773
+ });
774
+ const scenes = used.filter((block) => block.role && block.role !== 'frame');
775
+ const markedOnPage = scenes.filter((block) => block.marked);
776
+
777
+ const treatment = readTreatment(plan);
778
+ const planned = treatment.hasLanguage && treatment.storyboard !== null;
779
+ const storyboard = planned ? treatment.storyboard.map((line) => readScene(line, index)) : [];
780
+
781
+ const findings = [];
782
+ const add = collector(findings);
783
+ if (!planned) add('no-treatment', 1, missingTreatment(treatment));
784
+ // Own and kit scenes the storyboard plans but the page does not mark still count as quiet; marked ones are scenes.
785
+ const plannedOwn = storyboard.filter((scene) => scene.own || scene.kit).length;
786
+ const extraOwn = Math.max(0, plannedOwn - markedOnPage.length);
787
+ holdBudgets(add, { used, scenes, extraOwn, treatment, planned, routes, where: 'page' });
788
+
789
+ if (planned) {
790
+ // A block or a kit scene must stand at its place; a plain own scene matches a marked scene there, and is passed
791
+ // over when the page marks none (own-scene-unmarked says so).
792
+ const story = [];
793
+ for (const scene of storyboard) {
794
+ if (scene.own) story.push({ key: 'own scene', any: true });
795
+ else if (scene.kit) story.push({ key: `quiet:${scene.kit}`, any: false });
796
+ else if (scene.item) {
797
+ const block = resolveItem(scene.item, scene.variant);
798
+ if (block.role && block.role !== 'frame') story.push({ key: block.slug, any: false });
799
+ }
800
+ }
801
+ const page = scenes.map((block) => ({ key: block.slug, marked: block.marked === true }));
802
+ let differ = 0;
803
+ for (let s = 0, p = 0; s < story.length || p < page.length; ) {
804
+ const want = story[s];
805
+ const have = page[p];
806
+ if (want && want.any && !(have && have.marked)) {
807
+ s++;
808
+ continue;
809
+ }
810
+ if (!want || !have || (!want.any && want.key !== have.key)) differ++;
811
+ s++;
812
+ p++;
813
+ }
814
+ if (differ) {
815
+ add(
816
+ 'storyboard-matches-page',
817
+ differ,
818
+ `Storyboard scenes: ${story.map((scene) => scene.key).join(', ') || 'none'}. Page scenes: ${page.map((scene) => scene.key).join(', ') || 'none'}.`,
819
+ );
820
+ }
821
+
822
+ if (plannedOwn > markedOnPage.length) {
823
+ const shown = markedOnPage.length ? ` (${list(markedOnPage)})` : '';
824
+ add(
825
+ 'own-scene-unmarked',
826
+ plannedOwn - markedOnPage.length,
827
+ `The storyboard has ${plural(plannedOwn, 'quiet own or kit scene')}; the page marks ${markedOnPage.length}${shown}.`,
828
+ );
829
+ }
830
+ }
831
+
832
+ {
833
+ const curves = new Set();
834
+ for (const file of own) {
835
+ for (const match of file.text.matchAll(/cubic-bezier\(([^)]*)\)/gi)) curves.add(curveKey(match[1]));
836
+ }
837
+ if (curves.size > 2) {
838
+ const parts = [...curves].map((curve) => `cubic-bezier(${curve})`).join(', ');
839
+ add('easing-values', curves.size, `${plural(curves.size, 'distinct curve')}: ${parts}.`);
840
+ }
841
+ }
842
+
843
+ findings.sort((a, b) => RULE.get(a.id).order - RULE.get(b.id).order);
844
+ return {
845
+ findings,
846
+ scenes: used.map((block) => ({
847
+ slug: block.slug,
848
+ variant: block.variant,
849
+ file: block.file,
850
+ role: block.role,
851
+ energy: block.energy,
852
+ pins: block.pins,
853
+ scroll: block.scroll,
854
+ })),
855
+ };
856
+ }
857
+
858
+ const SCENE_MIN = 7;
859
+ const SCENE_MAX = 9;
860
+
861
+ /** The quietest variant of a block with energy 1 or less, as `slug:variant`, or null when it has none. */
862
+ function quieterVariant(item) {
863
+ const quiet = variantIds(item)
864
+ .map((id) => resolveItem(item, id))
865
+ .filter((block) => block.energy <= 1)
866
+ .sort((a, b) => a.energy - b.energy);
867
+ return quiet.length ? label(quiet[0]) : null;
868
+ }
869
+
870
+ /**
871
+ * Check a storyboard before anything is built: the same budgets as `filmCheck`, read from the numbered Storyboard
872
+ * lines of STRUQ.md alone. Each line gives the first block after its `(tone)` tag (or an own scene: quiet, energy 1,
873
+ * no block; or a kit quiet scene, Statement, PhotoText, Rows or Close, read as `quiet:<Name>` with energy 1) and the
874
+ * block after `footer:`. `routes` is the number of routes the site will have. Returns the findings
875
+ * in rule order and the scenes the rules read, in storyboard order.
876
+ */
877
+ export function planCheck({ plan, catalog, routes = 1 } = {}) {
878
+ const index = indexCatalog(catalog);
879
+ const treatment = readTreatment(plan);
880
+ const findings = [];
881
+ const add = collector(findings);
882
+ const done = (used) => {
883
+ findings.sort((a, b) => RULE.get(a.id).order - RULE.get(b.id).order);
884
+ return {
885
+ findings,
886
+ scenes: used.map((block) => ({
887
+ scene: block.scene,
888
+ tone: block.tone,
889
+ own: block.own === true,
890
+ slug: block.slug,
891
+ variant: block.variant,
892
+ role: block.role,
893
+ energy: block.energy,
894
+ pins: block.pins,
895
+ scroll: block.scroll,
896
+ })),
897
+ };
898
+ };
899
+
900
+ if (treatment.storyboard === null) {
901
+ add('no-treatment', 1, missingTreatment(treatment));
902
+ return done([]);
903
+ }
904
+ const planned = treatment.hasLanguage;
905
+ if (!planned) add('no-treatment', 1, missingTreatment(treatment));
906
+
907
+ const count = treatment.storyboard.length;
908
+ if (count < SCENE_MIN || count > SCENE_MAX) {
909
+ add('scene-count', 1, `The storyboard has ${plural(count, 'scene')}; it needs ${SCENE_MIN} to ${SCENE_MAX}.`);
910
+ }
911
+
912
+ const used = [];
913
+ const unknown = [];
914
+ const loudQuiet = [];
915
+ treatment.storyboard.forEach((text, at) => {
916
+ const scene = at + 1;
917
+ const line = readPlanLine(text);
918
+ const where = `scene ${scene}${line.name ? ` (${line.name})` : ''}`;
919
+ // A line that asks for a clock (a timeZone, clock or klok) passes whichever prop turns its block's clock on.
920
+ const props = new Set(line.clock ? ['timeZone', 'clock'] : []);
921
+ const place = (named) => {
922
+ const item = index.get(named.slug);
923
+ const shown = named.variant ? `${named.slug}:${named.variant}` : named.slug;
924
+ if (!item) {
925
+ unknown.push(`${where} names ${shown}, which is not a block`);
926
+ return null;
927
+ }
928
+ if (named.variant && !variantIds(item).includes(named.variant)) {
929
+ const ids = variantIds(item);
930
+ const has = ids.length ? `its variants are ${ids.join(', ')}` : 'it has no variants';
931
+ unknown.push(`${where} names ${shown}; ${has}`);
932
+ }
933
+ const block = gateInteractions(resolveItem(item, named.variant), props);
934
+ Object.assign(block, { scene, tone: line.tone });
935
+ used.push(block);
936
+ return { item, block };
937
+ };
938
+
939
+ let main = null;
940
+ if (!line.tagged) {
941
+ // No tag: read the line the way the page check does and name nothing as unknown.
942
+ const loose = readScene(text, index);
943
+ if (loose.own) line.own = true;
944
+ else if (loose.kit) line.kit = loose.kit;
945
+ else if (loose.item) main = place({ slug: loose.item.slug, variant: loose.variant });
946
+ } else if (!line.own && !line.kit) {
947
+ if (line.block) main = place(line.block);
948
+ else unknown.push(`${where} names no block and is not an own scene`);
949
+ }
950
+ if (line.kit) {
951
+ // A kit quiet scene: a known own scene, composed from the kit, energy 1.
952
+ used.push({
953
+ ...markedScene({ slug: `quiet:${line.kit}`, energy: 1 }),
954
+ scene,
955
+ tone: line.tone,
956
+ name: line.name,
957
+ marked: false,
958
+ });
959
+ }
960
+ if (line.own) {
961
+ used.push({
962
+ scene,
963
+ tone: line.tone,
964
+ own: true,
965
+ name: line.name,
966
+ slug: null,
967
+ variant: null,
968
+ kind: 'own-scene',
969
+ role: 'scene',
970
+ home: null,
971
+ motif: null,
972
+ metaphor: null,
973
+ energy: 1,
974
+ pins: false,
975
+ scroll: 0,
976
+ fullBleed: false,
977
+ interactions: [],
978
+ });
979
+ }
980
+ if (line.footer && !(main && main.block.slug === line.footer.slug)) place(line.footer);
981
+
982
+ const tones = (line.tone || '').split(',').map((word) => word.trim());
983
+ if (main && tones.includes('quiet') && main.block.energy >= 2) {
984
+ const quieter = quieterVariant(main.item);
985
+ const hint = quieter ? `, ${quieter} is quieter` : ', the block has no variant with energy 1 or less';
986
+ loudQuiet.push(`${where} is tagged quiet and names ${label(main.block)} (energy ${main.block.energy})${hint}`);
987
+ }
988
+ });
989
+
990
+ if (unknown.length) add('unknown-block', unknown.length, `${unknown.join('; ')}.`);
991
+ if (loudQuiet.length) add('quiet-is-quiet', loudQuiet.length, `${loudQuiet.join('; ')}.`);
992
+
993
+ const scenes = used.filter((block) => block.role && block.role !== 'frame');
994
+ const sites = typeof routes === 'number' && Number.isFinite(routes) ? routes : 1;
995
+ holdBudgets(add, { used, scenes, extraOwn: 0, treatment, planned, routes: sites, where: 'storyboard' });
996
+ return done(used);
997
+ }