@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
|
@@ -7,15 +7,20 @@
|
|
|
7
7
|
//
|
|
8
8
|
// It needs no browser and no dependencies. It reads the source and the public folder and prints
|
|
9
9
|
// PASS, WARN or FAIL per check, in groups: images, AI tells, the motion floor, the hero, the film
|
|
10
|
-
// (film-check.mjs, the budgets Struq's MCP holds too), typography, plus the plan in STRUQ.md
|
|
10
|
+
// (film-check.mjs, the budgets Struq's MCP holds too), typography, plus the plan in STRUQ.md, the
|
|
11
|
+
// look look.mjs took of the rendered page (.struq/look.json, which must pass and be newer than the source), and the
|
|
12
|
+
// art director's asks (.struq/art-direction.md: each answered Built: or Refused:, never on the storyboard). Every
|
|
11
13
|
// FAIL names the file and line and the fix. Exit code 1 while any FAIL remains. Fix, run again, and
|
|
12
14
|
// only report done on "RESULT: PASS".
|
|
13
15
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
14
16
|
import { join, relative, resolve, sep } from "node:path";
|
|
15
17
|
import { appendLog } from "./log.mjs";
|
|
16
18
|
import { altFitsBrief, packSignalForBrief } from "./packs.mjs";
|
|
19
|
+
import { savedHand } from "./draw.mjs";
|
|
17
20
|
import { filmCheck, planCheck } from "./film-check.mjs";
|
|
21
|
+
import { checkHand } from "./hand.mjs";
|
|
18
22
|
import { hasApproval } from "./gate.mjs";
|
|
23
|
+
import { sourceStamp } from "./stamp.mjs";
|
|
19
24
|
|
|
20
25
|
const CANON = JSON.parse(readFileSync(new URL("../canon/canon.json", import.meta.url), "utf8"));
|
|
21
26
|
const CATALOG = JSON.parse(readFileSync(new URL("../blocks/catalog.json", import.meta.url), "utf8")).blocks;
|
|
@@ -64,6 +69,12 @@ if (args.includes("--plan")) {
|
|
|
64
69
|
const appDir = ["app", "src/app"].map((d) => join(root, d)).find((d) => existsSync(d));
|
|
65
70
|
const routes = Math.max(1, appDir ? walk(appDir).filter((p) => /(^|[\\/])page\.(tsx|jsx|js)$/.test(p)).length : 0);
|
|
66
71
|
const { findings } = planCheck({ plan: planText, catalog: CATALOG, routes });
|
|
72
|
+
// The plan holds to the site's hand (canon.mjs draw), checked by Struq's hand.mjs, the same rule the MCP runs.
|
|
73
|
+
const hand = savedHand(root);
|
|
74
|
+
if (hand) findings.push(...checkHand(planText, hand));
|
|
75
|
+
else {
|
|
76
|
+
findings.push({ severity: "warn", id: "no-draw", detail: "No hand dealt for this site", fix: "run canon.mjs draw and choose the Palette and Type from it" });
|
|
77
|
+
}
|
|
67
78
|
for (const f of findings) console.log(`${f.severity === "fail" ? "FAIL" : "WARN"} ${f.id}: ${f.detail.replace(/\.$/, "")}. Fix: ${f.fix}`);
|
|
68
79
|
const failed = findings.some((f) => f.severity === "fail");
|
|
69
80
|
console.log(`PLAN: ${failed ? "FAIL" : "PASS"}`);
|
|
@@ -128,6 +139,36 @@ const pkg = JSON.parse(read("package.json"));
|
|
|
128
139
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
129
140
|
const plan = read("STRUQ.md");
|
|
130
141
|
|
|
142
|
+
/** "file:line" for an index into a file's text. */
|
|
143
|
+
const lineOf = (f, index) => `${f.rel}:${f.text.slice(0, index).split("\n").length}`;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The names a site shows: the wordmark props of its header, preloader, hero and footer blocks, a data-wordmark element,
|
|
147
|
+
* the name and title in lib/site.ts or a file like it, and the page title. Each with where it stands.
|
|
148
|
+
*/
|
|
149
|
+
const SHOWN_NAMES = (() => {
|
|
150
|
+
const shown = [];
|
|
151
|
+
const seen = (name, where) => name.trim() && shown.push({ name: name.trim(), where });
|
|
152
|
+
for (const f of own.filter((x) => !x.rel.endsWith(".css"))) {
|
|
153
|
+
for (const m of f.text.matchAll(/<(?:Header|MenuCurtain|Preloader|Footer|Wordmark|Hero|PageTransition|SiteHeader)\w*\b[^>]*?\bname=\{?\s*["'`]([^"'`]+)["'`]/g)) seen(m[1], lineOf(f, m.index));
|
|
154
|
+
for (const m of f.text.matchAll(/<\w+\b[^>]*\bdata-wordmark\b[^>]*>([^<{]+)</g)) seen(m[1], lineOf(f, m.index));
|
|
155
|
+
if (/(?:^|\/)(?:lib|src\/lib|config|src\/config|data|app)\/(?:site|brand|identity|config|constants|metadata)\.(?:ts|tsx|js|mjs)$/.test(f.rel)) {
|
|
156
|
+
for (const m of f.text.matchAll(/\b(?:name|siteName|brandName|title)\s*:\s*["'`]([^"'`]+)["'`]/g)) seen(m[1], lineOf(f, m.index));
|
|
157
|
+
}
|
|
158
|
+
if (/(?:^|\/)app\/layout\.(?:tsx|jsx|ts|js)$/.test(f.rel)) {
|
|
159
|
+
for (const m of f.text.matchAll(/\b(?:title|default)\s*:\s*["'`]([^"'`]+)["'`]/g)) seen(m[1], lineOf(f, m.index));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return shown;
|
|
163
|
+
})();
|
|
164
|
+
|
|
165
|
+
/** The plan's Brief line. A name it gives stands in it capitalised, never as its first word. */
|
|
166
|
+
const BRIEF = plan.match(/^Brief:\s*(.+)$/m)?.[1] ?? "";
|
|
167
|
+
function namedInBrief(name) {
|
|
168
|
+
const re = new RegExp(`(?<![\\p{L}\\p{N}])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\p{L}\\p{N}])`, "gu");
|
|
169
|
+
return [...BRIEF.matchAll(re)].some((m) => m.index > 0);
|
|
170
|
+
}
|
|
171
|
+
|
|
131
172
|
/* ------------------------------------------------------------------ IMAGES */
|
|
132
173
|
{
|
|
133
174
|
const G = "IMAGES";
|
|
@@ -163,9 +204,11 @@ const plan = read("STRUQ.md");
|
|
|
163
204
|
else if (plates.length) add(G, "WARN", `${plates.length} fallback plate(s) among the pack images. Run images.mjs ${pack} again when online.`, plates);
|
|
164
205
|
else add(G, "PASS", `${srcs.length} beeldbank images from pack "${pack}", each a verified image file`);
|
|
165
206
|
// An off-topic photo is worse than no photo: a pack the brief does not point at must show
|
|
166
|
-
// something the brief is about in at least one alt.
|
|
207
|
+
// something the brief is about in at least one alt. A module images.mjs --brief already fitted is trusted: its
|
|
208
|
+
// Dutch alts never match an English brief word for word, which made this a false fail (proof run 2026-11-14).
|
|
167
209
|
const brief = plan.match(/^Brief:\s*(.+)$/m)?.[1];
|
|
168
|
-
|
|
210
|
+
const fitted = /Fitted to the brief/.test(mod);
|
|
211
|
+
if (brief && srcs.length && !fitted && packSignalForBrief(brief) !== pack) {
|
|
169
212
|
const alts = [...mod.matchAll(/alt: "([^"]*)"/g)].map((m) => m[1]);
|
|
170
213
|
if (!alts.some((alt) => altFitsBrief(alt, brief))) {
|
|
171
214
|
add(G, "FAIL", `Off-topic photos: none of the "${pack}" photos shows anything the brief is about. Run images.mjs --brief "<the brief>" (it leaves them out) and let the opener carry the first screen.`, alts.slice(0, 4));
|
|
@@ -242,7 +285,7 @@ const plan = read("STRUQ.md");
|
|
|
242
285
|
if (hotlinks.length) add(G, "FAIL", "Hotlinked Struq images: download them with images.mjs and use the local /images/ path.", hotlinks);
|
|
243
286
|
|
|
244
287
|
const credit = uses(/imageCredit|struq\.app\/photos|struq\.nl\/beeldbank/);
|
|
245
|
-
if (libFile && !credit) add(G, "WARN", "No image credit. Put imageCredit (
|
|
288
|
+
if (libFile && !credit) add(G, "WARN", "No image credit. Put imageCredit ('Photos: Struq', 'Beeld: Struq' on a Dutch site) in the footer as one quiet text-small link.");
|
|
246
289
|
}
|
|
247
290
|
|
|
248
291
|
/* ------------------------------------------------------------------- TELLS */
|
|
@@ -261,8 +304,125 @@ const plan = read("STRUQ.md");
|
|
|
261
304
|
|
|
262
305
|
// Copy
|
|
263
306
|
tell("Notes to the client on the page",
|
|
264
|
-
/volg(?:t|en) zodra|zodra (?:de studio|de klant|je|jullie|we)\b[^"'<>]{0,40}(?:aanlever|doorgeef|doorgeven|binnen)|nog aan te leveren|wordt nog aangevuld|lorem ipsum|\bcoming soon\b|to be confirmed|\
|
|
265
|
-
"a line written to the client or the developer is not copy.
|
|
307
|
+
/volg(?:t|en) zodra|zodra (?:de studio|de klant|je|jullie|we)\b[^"'<>]{0,40}(?:aanlever|doorgeef|doorgeven|binnen)|nog aan te leveren|wordt nog aangevuld|(?:telefoon|e-?mail|adres|reserveren|gegevens|openingstijden|data|datum|prijzen)[^.<>"'{}]{0,30}\bvolg(?:t|en)\b|\bvolg(?:t|en) (?:nog|later|binnenkort)\b|lorem ipsum|\bcoming soon\b|to be (?:confirmed|announced)|will be announced|details to (?:follow|come)|\bto follow\)|\bTB[AC]\b|\byour (?:name|company|logo|photo|text) here\b|\bYour Name\b|\bexample\.(?:com|org|net)\b|\b(?:John|Jane) Doe\b|\[(?:naam|adres|telefoon|e-?mail|name|phone|address)\]/i,
|
|
308
|
+
"a line written to the client or the developer, or a stand-in (Your Name, example.com, to be announced), is not copy. A missing fact leaves no line on the page: drop the sentence, the field or the button, and list it under Assumptions: in STRUQ.md.");
|
|
309
|
+
|
|
310
|
+
// The visible copy of your own files: string literals and JSX text, line by line, comments left out. Class lists,
|
|
311
|
+
// paths and identifiers hold no sentences, so the honesty phrases below match the words a reader sees.
|
|
312
|
+
const copy = [];
|
|
313
|
+
for (const f of own.filter((x) => !x.rel.endsWith(".css"))) {
|
|
314
|
+
f.text.split(/\r?\n/).forEach((line, i) => {
|
|
315
|
+
const t = line.trim();
|
|
316
|
+
if (!t || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*") || t.startsWith("{/*")) return;
|
|
317
|
+
const segs = [];
|
|
318
|
+
for (const m of line.matchAll(/(["'`])((?:\\.|(?!\1)[^\\])*)\1/g)) segs.push(m[2]);
|
|
319
|
+
for (const m of line.matchAll(/>([^<>{}]+)(?=<|$)/g)) segs.push(m[1]);
|
|
320
|
+
// A line of JSX text between an opening and a closing tag on lines of their own.
|
|
321
|
+
if (/^[^<>{}=;()[\]]+$/.test(t)) segs.push(t);
|
|
322
|
+
const words = segs.map((s) => s.trim()).filter((s) => /[\p{L}\p{N}]/u.test(s));
|
|
323
|
+
if (words.length) copy.push({ where: `${f.rel}:${i + 1}`, line: t, segs: words });
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const copyHits = (re) => copy.filter((c) => c.segs.some((s) => re.test(s))).map((c) => `${c.where} ${c.line.slice(0, 120)}`);
|
|
327
|
+
|
|
328
|
+
// Copy that promises what is not there yet. Bare "not yet" and "nog niet" are common in a factual sentence, so they
|
|
329
|
+
// count only with an availability word after them, or closing the line.
|
|
330
|
+
const soon = copyHits(
|
|
331
|
+
/\bcoming soon\b|\b(?:is|are|we're|we are|now|soon) launching\b|\blaunching (?:soon|in|on|this|next|later|early|late|spring|summer|autumn|fall|winter|\d)|\blaunching[.!]?$|\bon (?:its|their) way\b|\bsoon available\b|\bavailable soon\b|\bnot yet (?:available|live|open|ready|online|launched|released|public|bookable|for sale|on sale|in stock)\b|\bnot yet[.!]?$|\bbinnenkort\b|\bvolg(?:t|en) snel\b|\bnog niet (?:beschikbaar|live|open|online|klaar|te koop|verkrijgbaar|bekend|te boeken|te reserveren|gepubliceerd|af)\b|\bnog niet[.!]?$|\bin de maak\b/i,
|
|
332
|
+
);
|
|
333
|
+
if (soon.length) add(G, "FAIL", "Coming-soon copy: a line that promises what is not there yet (coming soon, launching, on its way, binnenkort, volgt snel, nog niet beschikbaar, in de maak) is not copy. The site shows what exists today: drop the line, and list what is missing under Assumptions: in STRUQ.md.", soon);
|
|
334
|
+
else add(G, "PASS", "No coming-soon copy");
|
|
335
|
+
|
|
336
|
+
// An invitation needs a door: an address, a phone number, an email, a mailto or tel link, or an external booking or
|
|
337
|
+
// shop link somewhere on the page. Two kinds are held to it: the "come by" wording (tonight, see you, kom langs), and
|
|
338
|
+
// the imperative call in the trade's own words (Schuif aan, Pick up a mug, Book a class, Reserveer, Shop the collection)
|
|
339
|
+
// in a heading, a button, a link or a closing line. A statement that merely mentions the trade is no call: the verb
|
|
340
|
+
// opens a short line, and the line is the call.
|
|
341
|
+
const COME_BY = /\btonight\b|\bsee you\b|\bcome by\b|\bdrop in\b|\bcall us\b|\bvanavond\b|\btot straks\b|\bkom langs\b|\bloop (?:even )?binnen\b|\bbel ons\b|\bwaarvoor u ons belt\b/i;
|
|
342
|
+
const END = "(?:\\s|[.!,]|$)";
|
|
343
|
+
const CALL = new RegExp(
|
|
344
|
+
`^[\\s"'(\\[\\u2018\\u201C]*(?:${[
|
|
345
|
+
// English
|
|
346
|
+
`visit${END}`, "come\\s+(?:and|in|eat|taste|try|see|join|sit|visit|hungry|by|over|along)\\b", "(?:eat|dine|taste)\\s+(?:with|at|here|in|the|our|your|something|a|some)\\b",
|
|
347
|
+
"book\\s+(?:a|an|your|the|now|online|tickets?|ahead|with|today)\\b", `buy${END}`, "order\\s+(?:a|an|your|the|now|online|ahead|here|today|one|it)\\b",
|
|
348
|
+
"shop\\s+(?:the|our|all|now|online|this|new|with)\\b", `join${END}`, "try\\s+(?:a|an|one|it|the|our|us|this|some|your)\\b", "sit\\s+(?:down|with|at|in)\\b",
|
|
349
|
+
"pick\\s+up\\b", "roll\\s+out\\b", "grab\\s+(?:a|an|your|the|some|one)\\b", "reserve\\s+(?:a|an|your|the|now|online|seats?|tickets?|today)\\b",
|
|
350
|
+
"save\\s+(?:the\\s+date|your\\s+(?:seat|spot|place))\\b", "take\\s+a\\s+(?:seat|class|lesson)\\b", "pull\\s+up\\s+a\\s+chair\\b", "step\\s+(?:in|inside|up)\\b",
|
|
351
|
+
"stop\\s+by\\b", "walk\\s+in\\b", "get\\s+(?:your\\s+|a\\s+|an\\s+)?(?:tickets?|quote|table|seat|spot)\\b", "enrol{1,2}\\s+(?:now|today|in|for)\\b", "find\\s+us\\b",
|
|
352
|
+
// Dutch
|
|
353
|
+
"kom\\s+(?:proeven|eten|binnen|langs|kijken|naar|bij|eens|gerust|erbij|meedoen|kennismaken|mee|zitten)\\b", "schuif\\s+(?:even\\s+|gerust\\s+|gezellig\\s+)?aan\\b",
|
|
354
|
+
`bestel${END}`, `reserveer${END}`, "boek\\s+(?:een|je|jouw|uw|nu|online|tickets?|alvast|plek|plaats)\\b", `koop${END}`,
|
|
355
|
+
"neem\\s+(?:een\\s+)?(?:plaats|zitplaats|stoel)\\b", "ga\\s+zitten\\b", "proef\\s+(?:het|de|een|ons|onze|wat|zelf|mee)\\b", "probeer\\s+(?:een|het|de|ons|onze|eens|zelf|wat)\\b",
|
|
356
|
+
"doe\\s+mee\\b", "sluit\\s+(?:je|u)\\s+aan\\b", "meld\\s+(?:je|u)\\s+aan\\b", "schrijf\\s+(?:je|u)\\s+in\\b", "stap\\s+(?:gerust\\s+)?binnen\\b",
|
|
357
|
+
"loop\\s+(?:gerust\\s+|even\\s+)?(?:naar\\s+)?binnen\\b", "pak\\s+(?:een|je|uw)\\b", "bezoek\\s+(?:ons|onze|de)\\b", "vind\\s+ons\\b", "eet\\s+(?:mee|met|bij)\\b",
|
|
358
|
+
].join("|")})`,
|
|
359
|
+
"i",
|
|
360
|
+
);
|
|
361
|
+
const isCall = (s) => {
|
|
362
|
+
const words = s.trim().split(/\s+/);
|
|
363
|
+
if (words.length > 8 || s.length > 80 || !CALL.test(s)) return false;
|
|
364
|
+
// A lone lowercase word is a class name or an identifier, not a button.
|
|
365
|
+
return words.length > 1 || /^[^\p{L}]*\p{Lu}/u.test(s);
|
|
366
|
+
};
|
|
367
|
+
const calls = copy.filter((c) => {
|
|
368
|
+
// A class list is no copy: the strings of className and class attributes are left out.
|
|
369
|
+
const classes = new Set([...c.line.matchAll(/\b(?:className|class)\s*=\s*\{?\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/g)].map((m) => m[2].trim()));
|
|
370
|
+
const segs = c.segs.filter((s) => !classes.has(s));
|
|
371
|
+
const wholeLine = segs.length > 1 && segs.length <= 4 && segs.every((s) => s.split(/\s+/).length <= 5) ? [segs.join(" ")] : [];
|
|
372
|
+
return [...segs, ...wholeLine].some(isCall);
|
|
373
|
+
}).map((c) => `${c.where} ${c.line.slice(0, 120)}`);
|
|
374
|
+
const invites = [...new Set([...copyHits(COME_BY), ...calls])];
|
|
375
|
+
if (invites.length) {
|
|
376
|
+
const DOORS = [
|
|
377
|
+
/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/,
|
|
378
|
+
/(?:\+|\b00)\d{1,3}[\s.-]?\(?\d[\d\s.()-]{6,}\d|\b0\d{1,3}[\s.-]?\d{3}[\s.-]?\d{2,4}(?:[\s.-]?\d{2,4})?\b/,
|
|
379
|
+
/\b\p{Lu}[\p{L}'-]*(?:straat|weg|laan|plein|gracht|kade|dijk|singel|markt|steeg|dreef|hof|pad|haven|wal|baan|burgwal|straße|strasse|gasse|platz)\s+\d+[a-z]?\b/u,
|
|
380
|
+
/\b[1-9]\d{3}\s?[A-Z]{2}\b/,
|
|
381
|
+
/\b\d{1,5}\s+(?:\p{Lu}[\p{L}'-]+\s+){1,3}(?:Street|St|Road|Rd|Avenue|Ave|Lane|Way|Square|Place|Boulevard|Drive|Court|Row|Terrace)\b/u,
|
|
382
|
+
/\b[A-Z]{1,2}\d[A-Z\d]?\s\d[A-Z]{2}\b/,
|
|
383
|
+
];
|
|
384
|
+
// An external link is a door only to a booking or shop: not Struq's own credit, a font host, or a social profile.
|
|
385
|
+
const EXTERNAL = /\b(?:href|action)\s*[:=]\s*\{?\s*["'`]https?:\/\/(?!(?:www\.)?(?:struq\.(?:app|nl)|localhost|schema\.org|w3\.org|fonts\.(?:googleapis|gstatic)\.com|api\.fontshare\.com|(?:instagram|facebook|twitter|x|linkedin|tiktok|youtube|pinterest|github|behance|dribbble)\.com)\b)[^"'`\s]+/i;
|
|
386
|
+
const door = copy.some((c) => c.segs.some((s) => DOORS.some((re) => re.test(s)))) || own.some((f) => /\b(?:tel|mailto):[^"'`\s{}]{4,}/.test(f.text) || EXTERNAL.test(f.text));
|
|
387
|
+
if (door) add(G, "PASS", "The invitations on the page have a door: an address, a phone number, an email or a booking or shop link is on it");
|
|
388
|
+
else add(G, "FAIL", "Invitation without a door: the copy asks the reader to act (come by, call, eat, book, buy, order, join, sit down, pick up: tonight, kom langs, Schuif aan, Pick up a mug, Reserveer, Shop the collection), but the page shows no address, phone number, mailto or tel link, or external booking or shop link. With no door the site ends on the idea, never on an invitation: drop the line, the button or the link, and list the door the client must add under Assumptions: in STRUQ.md.", invites);
|
|
389
|
+
} else add(G, "PASS", "No invitation without a door");
|
|
390
|
+
|
|
391
|
+
// A stand-in name: the trade word shown as the business's name (a yoga studio called "Yoga"). The names a site shows are
|
|
392
|
+
// the wordmark props of its header, preloader, hero and footer blocks, a data-wordmark element, the name and title in
|
|
393
|
+
// lib/site.ts or a file like it, and the page title. A name that is only the trade or a generic word for the business
|
|
394
|
+
// is a stand-in, unless the Brief line itself gives that name (capitalised, and not as the first word of the line).
|
|
395
|
+
{
|
|
396
|
+
const GENERIC = new Set(
|
|
397
|
+
"yoga pilates bakery bakkerij bakker studio restaurant resto cafe café koffiebar bar pub brasserie plumbing plumber loodgieter loodgieters loodgietersbedrijf ceramics ceramic pottery keramiek aardewerk conference conferentie congres fashion label atelier salon kapper barbershop gym fitness dentist tandarts clinic praktijk shop store winkel webshop boutique gallery galerie agency bureau consultancy workshop hotel hostel guesthouse garden tuin hovenier cleaning schoonmaak electrician elektricien painter schilder builder aannemer lawyer advocaat accountant photography fotografie photographer fotograaf design ontwerp school academy academie club app software invoicing florist bloemist butcher slager".split(" "),
|
|
398
|
+
);
|
|
399
|
+
const STOP = new Set("the a an de het een my mijn our onze your jouw and en of van voor for by in at".split(" "));
|
|
400
|
+
const tokens = (name) => name.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
401
|
+
const isStandIn = (name) => {
|
|
402
|
+
const t = tokens(name);
|
|
403
|
+
return t.length > 0 && t.every((w) => GENERIC.has(w) || STOP.has(w)) && t.some((w) => GENERIC.has(w));
|
|
404
|
+
};
|
|
405
|
+
const shown = SHOWN_NAMES;
|
|
406
|
+
const named = namedInBrief;
|
|
407
|
+
const standIns = shown.filter((s) => isStandIn(s.name) && !named(s.name));
|
|
408
|
+
if (standIns.length) {
|
|
409
|
+
const names = [...new Set(standIns.map((s) => `"${s.name}"`))].join(", ");
|
|
410
|
+
add(G, "FAIL", `Stand-in name: the site is called ${names}, which is the trade or a generic word, not the client's name, and the Brief line does not give it. With no name in the brief the header shows no wordmark and the opener and the close carry no name; ask for the name at the one stop, and list it under Assumptions: in STRUQ.md.`, standIns.map((s) => `${s.where} "${s.name}"`));
|
|
411
|
+
} else add(G, "PASS", "No stand-in name: the name the site shows is not a bare trade word");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// A link to an anchor nothing on the page carries: a button that goes nowhere.
|
|
415
|
+
{
|
|
416
|
+
const ids = new Set(files.flatMap((f) => [...f.text.matchAll(/\bid=\{?\s*["'`]([\w-]+)["'`]/g)].map((m) => m[1])));
|
|
417
|
+
const dead = [];
|
|
418
|
+
for (const f of own) {
|
|
419
|
+
for (const m of f.text.matchAll(/\b(?:href|menuHref)\s*[:=]\s*\{?\s*["'`]#([\w-]+)["'`]/g)) {
|
|
420
|
+
if (!ids.has(m[1])) dead.push(`${f.rel} #${m[1]}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (dead.length) add(G, "FAIL", "Links to an anchor nothing on the page carries: give the section that id, or drop the link.", [...new Set(dead)]);
|
|
424
|
+
else add(G, "PASS", "Every in-page link lands on an id");
|
|
425
|
+
}
|
|
266
426
|
|
|
267
427
|
// Type and labels
|
|
268
428
|
tell("Eyebrows and uppercase labels", /\buppercase\b|text-transform:\s*uppercase|\b(eyebrow|kicker|overline)\b/i,
|
|
@@ -438,6 +598,127 @@ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`))
|
|
|
438
598
|
|
|
439
599
|
if (/prefers-reduced-motion/.test(all)) add(G, "PASS", "prefers-reduced-motion respected");
|
|
440
600
|
else add(G, "FAIL", "Nothing reads prefers-reduced-motion.");
|
|
601
|
+
|
|
602
|
+
// The primitives (components/struq/kit/motion) and the feel (kit/feel.ts) carry the house moves and their timing.
|
|
603
|
+
// Your own scenes under app/ and components/ (not components/struq/) compose them before writing GSAP by hand.
|
|
604
|
+
const site = files.filter((f) => /^(?:src\/)?(?:app|components)\//.test(f.rel) && !/(?:^|\/)components\/struq\//.test(f.rel) && !f.rel.endsWith(".css"));
|
|
605
|
+
const gsapFiles = site.filter((f) => /from\s*["'](?:gsap(?:\/[\w/]+)?|@gsap\/react|[^"']*components\/struq\/kit\/gsap)["']|\bgsap\./.test(f.text));
|
|
606
|
+
|
|
607
|
+
const split = hits(/\bSplitText\b/, { scope: site });
|
|
608
|
+
if (split.length) add(G, "FAIL", "SplitText outside the kit: use RevealText (@/components/struq/kit/motion) for a line or word reveal; it keeps the masks descender-safe and reads the temperament.", split);
|
|
609
|
+
else add(G, "PASS", "No hand-rolled SplitText: text reveals go through the kit");
|
|
610
|
+
|
|
611
|
+
const inlineStyle = /style=\{\{/;
|
|
612
|
+
const clip = hits(/\bclipPath\s*:|["']clip-path["']\s*:|\binset\(/, { scope: gsapFiles, exclude: inlineStyle });
|
|
613
|
+
if (clip.length) add(G, "WARN", "A clip-path tween of your own: MaskWipe wipes an element or photo in, ImageCurtain uncovers a photo, both on the feel's curtain ease.", clip);
|
|
614
|
+
const draw = hits(/\bstrokeDashoffset\s*:|\bdrawSVG\s*:/, { scope: gsapFiles, exclude: inlineStyle });
|
|
615
|
+
if (draw.length) add(G, "WARN", "A stroke drawn by hand: ScrubLine draws a rule or a path with the scroll at an even pace.", draw);
|
|
616
|
+
// A literal ease or duration in a GSAP call. ease "none" passes, and so does a duration beside it: a scrub is linear
|
|
617
|
+
// in every temperament, and a scrubbed timeline's durations only set proportions.
|
|
618
|
+
const timing = hits(/\bease\s*:\s*["'`](?!none["'`])|\bduration\s*:\s*\d/, { scope: gsapFiles, exclude: /style=\{\{|\bease\s*:\s*["'`]none["'`]/ });
|
|
619
|
+
if (timing.length) add(G, "WARN", "Ease or duration written into a GSAP call: take them from feel() (@/components/struq/kit/feel), so the scene moves in the site's temperament.", timing);
|
|
620
|
+
if (!clip.length && !draw.length && !timing.length) add(G, "PASS", "Own GSAP takes its timing from the feel and leaves wipes and drawn lines to the kit");
|
|
621
|
+
|
|
622
|
+
// The temperament: one on <body>, from the language's default or its neighbour.
|
|
623
|
+
const bodies = files
|
|
624
|
+
.filter((f) => /(?:^|\/)app\/layout\.(?:tsx|jsx)$/.test(f.rel))
|
|
625
|
+
.flatMap((f) =>
|
|
626
|
+
[...f.text.matchAll(/<body\b[^>]*>/g)]
|
|
627
|
+
.filter((m) => !/^\s*(?:\/\/|\/?\*|\{\/\*)/.test(f.text.slice(f.text.lastIndexOf("\n", m.index) + 1, m.index + 1)))
|
|
628
|
+
.map((m) => ({ tag: m[0], where: lineOf(f, m.index) })),
|
|
629
|
+
);
|
|
630
|
+
const TEMPERAMENTS = ["crisp", "weighted", "slow"];
|
|
631
|
+
const badBody = bodies.filter(({ tag }) => {
|
|
632
|
+
const value = tag.match(/\bdata-temperament=(?:["']([^"']*)["']|\{\s*["'`]([^"'`]*)["'`]\s*\})/);
|
|
633
|
+
return !value || !TEMPERAMENTS.includes(value[1] ?? value[2]);
|
|
634
|
+
});
|
|
635
|
+
if (!bodies.length) add(G, "FAIL", "No <body> in app/layout: set <body data-blocks=\"\" data-temperament=\"weighted\"> (crisp, weighted or slow, the language's default).");
|
|
636
|
+
else if (badBody.length) add(G, "FAIL", "<body> without a temperament: set data-temperament to crisp, weighted or slow, the language's default (cut crisp, weight weighted, curtain slow) or its neighbour with the reason on the Language line.", badBody.map((b) => `${b.where} ${b.tag.slice(0, 100)}`));
|
|
637
|
+
else add(G, "PASS", `The temperament is set on <body>: ${bodies.map((b) => b.tag.match(/data-temperament=\{?\s*["'`]([^"'`]*)/)[1]).join(", ")}`);
|
|
638
|
+
|
|
639
|
+
// The close is never the site's name at full width. What this reads: each page file's footer (a footer element, a
|
|
640
|
+
// Footer* block or WordmarkFinale), the scene just before it (or the last section passed into the footer as its
|
|
641
|
+
// children), followed one step into a local component. It fails when that scene's display-size text (text-display,
|
|
642
|
+
// text-sheet-moment, text-6xl to 9xl, text-[Nvw] at 8vw or more, text-[Nrem] at 6rem or more) is only a name the
|
|
643
|
+
// site shows or the Brief gives, and when wordmark-finale follows a block or loud scene directly, so the name at
|
|
644
|
+
// full width is the page's last scene. What it cannot read: a name passed as an expression ({site.name}), a size set
|
|
645
|
+
// in CSS or inline style, and a close built in a component more than one import away.
|
|
646
|
+
{
|
|
647
|
+
const DISPLAY = /\btext-(?:display|sheet-moment|poster|[6-9]xl)\b|\btext-\[(?:[^\]]*?[\s,(])?(\d+(?:\.\d+)?)(vw|rem)\b[^\]]*\]/;
|
|
648
|
+
const isDisplay = (cls) => {
|
|
649
|
+
const m = cls.match(DISPLAY);
|
|
650
|
+
if (!m) return false;
|
|
651
|
+
if (!m[1]) return true;
|
|
652
|
+
return m[2] === "vw" ? Number(m[1]) >= 8 : Number(m[1]) >= 6;
|
|
653
|
+
};
|
|
654
|
+
const norm = (s) => s.toLowerCase().replace(/'|&/g, "").replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
655
|
+
const names = new Set(SHOWN_NAMES.map((s) => norm(s.name)).filter(Boolean));
|
|
656
|
+
const isName = (text) => {
|
|
657
|
+
const t = norm(text);
|
|
658
|
+
if (!t) return false;
|
|
659
|
+
if (names.has(t)) return true;
|
|
660
|
+
return t.split(" ").length <= 3 && /^\p{Lu}/u.test(text.trim()) && namedInBrief(text.trim());
|
|
661
|
+
};
|
|
662
|
+
/** Display-size elements whose whole text is a name: `<tag class="...">Name</tag>`. */
|
|
663
|
+
const nameAtDisplay = (markup) =>
|
|
664
|
+
[...markup.matchAll(/<([\w.]+)\b([^>]*)>([^<{}]+)<\/\1>/g)]
|
|
665
|
+
.filter((m) => isDisplay(m[2].match(/className=(?:["'`]([^"'`]*)["'`]|\{\s*["'`]([^"'`]*)["'`]\s*\})/)?.slice(1).find(Boolean) ?? "") && isName(m[3]))
|
|
666
|
+
.map((m) => m[3].trim());
|
|
667
|
+
const importOf = (text, tag) => text.match(new RegExp(`import\\s*(?:\\{[^}]*\\b${tag}\\b[^}]*\\}|${tag})\\s*from\\s*["']([^"']+)["']`))?.[1] ?? "";
|
|
668
|
+
const localFile = (from, spec) => {
|
|
669
|
+
if (!spec || !(spec.startsWith(".") || spec.startsWith("@/"))) return null;
|
|
670
|
+
const base = spec.startsWith("@/") ? spec.slice(2) : join(from.rel, "..", spec).split(sep).join("/");
|
|
671
|
+
return files.find((f) => [".tsx", ".jsx", ".ts", ".js", "/index.tsx", "/index.ts"].some((ext) => f.rel === `${base}${ext}` || f.rel === `src/${base}${ext}`)) ?? null;
|
|
672
|
+
};
|
|
673
|
+
const FOOTER = /<(WordmarkFinale|Footer\w*|footer)\b/;
|
|
674
|
+
const fullWidth = [];
|
|
675
|
+
for (const page of files.filter((f) => /(?:^|\/)app\/(?:.*\/)?page\.(?:tsx|jsx)$/.test(f.rel))) {
|
|
676
|
+
const body = page.text.slice(Math.max(0, page.text.search(/\breturn\s*\(/)));
|
|
677
|
+
const offset = page.text.length - body.length;
|
|
678
|
+
const foot = body.match(FOOTER);
|
|
679
|
+
if (!foot) continue;
|
|
680
|
+
const before = body.slice(0, foot.index);
|
|
681
|
+
const end = body.indexOf(`</${foot[1]}>`, foot.index);
|
|
682
|
+
const inside = end > 0 ? body.slice(foot.index, end) : "";
|
|
683
|
+
// The candidate close: the last section passed into the footer, else the last section or component before it.
|
|
684
|
+
let markup = "";
|
|
685
|
+
let kind = "";
|
|
686
|
+
const innerSection = inside.lastIndexOf("<section");
|
|
687
|
+
if (innerSection >= 0) {
|
|
688
|
+
markup = inside.slice(innerSection, inside.indexOf("</section>", innerSection) + 10 || undefined);
|
|
689
|
+
kind = "own";
|
|
690
|
+
} else {
|
|
691
|
+
const tags = [...before.matchAll(/<(section\b|[A-Z][\w.]*)/g)].filter((m) => !/^(?:Header\w*|SiteHeader|MenuCurtain\w*|Fragment|SmoothScroll|Reveal|main)$/.test(m[1]));
|
|
692
|
+
const last = tags.at(-1);
|
|
693
|
+
if (!last) continue;
|
|
694
|
+
if (last[1].startsWith("section")) {
|
|
695
|
+
markup = before.slice(last.index, before.indexOf("</section>", last.index) + 10 || undefined);
|
|
696
|
+
kind = /data-energy=["']?[2-9]/.test(markup.slice(0, 300)) ? "loud" : "own";
|
|
697
|
+
} else {
|
|
698
|
+
const spec = importOf(page.text, last[1]);
|
|
699
|
+
if (/components\/struq\/kit\/quiet(?:\/Close)?$/.test(spec)) kind = "kit";
|
|
700
|
+
else if (/components\/struq\//.test(spec)) kind = "block";
|
|
701
|
+
else {
|
|
702
|
+
const file = localFile(page, spec);
|
|
703
|
+
markup = file?.text ?? before.slice(last.index);
|
|
704
|
+
kind = "own";
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
// One step into the local components the close renders.
|
|
709
|
+
for (const m of [...markup.matchAll(/<([A-Z]\w*)/g)]) {
|
|
710
|
+
const file = localFile(page, importOf(page.text, m[1]));
|
|
711
|
+
if (file) markup += `\n${file.text}`;
|
|
712
|
+
}
|
|
713
|
+
const hit = nameAtDisplay(markup);
|
|
714
|
+
if (hit.length) fullWidth.push(`${lineOf(page, offset + foot.index)} the scene before the footer sets "${hit[0]}" at display size`);
|
|
715
|
+
else if (foot[1] === "WordmarkFinale" && (kind === "block" || kind === "loud")) {
|
|
716
|
+
fullWidth.push(`${lineOf(page, offset + foot.index)} wordmark-finale follows a block directly, so the name at full width is the page's last scene`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (fullWidth.length) add(G, "FAIL", "The close is the site's name at full width: end on the idea, an echo of the opening or the real doors instead, with the kit's Close (shape idea, echo or door), and keep a wordmark there at text size.", fullWidth);
|
|
720
|
+
else add(G, "PASS", "The close is not the name at full width");
|
|
721
|
+
}
|
|
441
722
|
}
|
|
442
723
|
|
|
443
724
|
/* -------------------------------------------------------------------- TYPE */
|
|
@@ -512,12 +793,16 @@ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`))
|
|
|
512
793
|
/* -------------------------------------------------------------------- HERO */
|
|
513
794
|
{
|
|
514
795
|
const G = "HERO";
|
|
515
|
-
|
|
516
|
-
const
|
|
796
|
+
// A WebGL background opener (background-silk, -aurora, -facets) is a hero too: the catalogue casts it as an opening.
|
|
797
|
+
const heroBlock = usesKind("heroes") || usesKind("backgrounds") || usedBlocks.has("showpieces/hero-opener");
|
|
798
|
+
// No fitting photo (imageFit none), or no hero photo on purpose (images.mjs --no-hero): a type-led first screen.
|
|
799
|
+
const imageModule = read("lib/struq-images.ts") + read("src/lib/struq-images.ts");
|
|
800
|
+
const noFit = /imageFit[^=]*=\s*"none"/.test(imageModule) || /\bhero:\s*\[\s*\]/.test(imageModule);
|
|
517
801
|
if (heroBlock) {
|
|
518
802
|
add(G, "PASS", "A hero block opens the page");
|
|
519
803
|
const heroUse = own.flatMap((f) => [...f.text.matchAll(/<(Hero[A-Z]\w*)\b[^>]*?\/?>/gs)].map((m) => ({ f, tag: m[0] })));
|
|
520
|
-
|
|
804
|
+
// Comments never count: a prop written inside /* */ is not a prop. HeroSplit takes its photos through items.
|
|
805
|
+
const bare = noFit ? [] : heroUse.filter(({ tag }) => { const t = tag.replace(/\/\*[\s\S]*?\*\//g, ""); return /<Hero(Framed|Split|Index|Still|Poster)\b/.test(t) && !/\b(?:images?|items)=\{/.test(t); });
|
|
521
806
|
if (bare.length) add(G, "FAIL", "A photo hero without its photo: pass image={images.hero[0]} (or the client's photo).", bare.map(({ f }) => f.rel));
|
|
522
807
|
} else {
|
|
523
808
|
// A hand-built hero: a photo in it and a display line at poster size.
|
|
@@ -532,8 +817,10 @@ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`))
|
|
|
532
817
|
else add(G, "PASS", "The hand-built hero line runs at poster size");
|
|
533
818
|
}
|
|
534
819
|
}
|
|
820
|
+
// A brief with no name gets a header with no wordmark (the stand-in rule), so an own <header> passes on its own.
|
|
535
821
|
if (usesKind("headers") || own.some((f) => /data-wordmark/.test(f.text))) add(G, "PASS", "A header with the wordmark");
|
|
536
|
-
else add(G, "
|
|
822
|
+
else if (own.some((f) => /<header\b/.test(f.text))) add(G, "PASS", "An own header (no wordmark: the brief gives no name, or mark it data-wordmark)");
|
|
823
|
+
else add(G, "FAIL", "No header: use header-bar or menu-curtain, or build your own <header> (its name marked data-wordmark when the brief gives one).");
|
|
537
824
|
if (usesKind("footers") || usedBlocks.has("showpieces/wordmark-finale")) add(G, "PASS", "A designed footer block closes the page");
|
|
538
825
|
else add(G, "WARN", "No footer block: close with footer-reveal, footer-contact or wordmark-finale, or design your own as hard as the hero.");
|
|
539
826
|
|
|
@@ -566,6 +853,125 @@ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`))
|
|
|
566
853
|
}
|
|
567
854
|
}
|
|
568
855
|
|
|
856
|
+
/* -------------------------------------------------------------------- LOOK */
|
|
857
|
+
// look.mjs renders the built page and writes .struq/look.json with what it measured and a stamp of the source. The
|
|
858
|
+
// check holds to it: no look, a failing look, or a look of an older page than the source on disk all fail.
|
|
859
|
+
{
|
|
860
|
+
const G = "LOOK";
|
|
861
|
+
const again = "Serve the build (npx next build && npx next start -p 4321) and run node <skill-dir>/scripts/look.mjs.";
|
|
862
|
+
let look = null;
|
|
863
|
+
try {
|
|
864
|
+
look = JSON.parse(readFileSync(join(root, ".struq", "look.json"), "utf8"));
|
|
865
|
+
} catch {
|
|
866
|
+
look = null;
|
|
867
|
+
}
|
|
868
|
+
if (!look) add(G, "FAIL", `No look at the rendered page (.struq/look.json missing). ${again}`);
|
|
869
|
+
else {
|
|
870
|
+
const now = sourceStamp(root);
|
|
871
|
+
const fails = (look.findings ?? []).filter((f) => f.severity === "FAIL");
|
|
872
|
+
const stale = look.source?.hash ? look.source.hash !== now.hash : !(now.newest.mtime <= Date.parse(look.at));
|
|
873
|
+
if (stale) add(G, "FAIL", `The look is older than the source: ${now.newest.file ?? "a file under app/ or components/"} changed after it. Rebuild, restart the server and run look.mjs again.`);
|
|
874
|
+
else if (look.result !== "PASS" || fails.length) {
|
|
875
|
+
add(G, "FAIL", `look.mjs found ${fails.length} fail(s) on the rendered page. Open the screenshots it names, fix, rebuild and run it again.`, fails.map((f) => `${f.kind} ${f.viewport ?? ""}: ${f.text ? `"${f.text.slice(0, 50)}" ` : ""}${f.detail}${f.shot ? ` ${f.shot}` : ""}`));
|
|
876
|
+
} else {
|
|
877
|
+
const warns = (look.findings ?? []).length;
|
|
878
|
+
add(G, "PASS", `look.mjs passed on the current source${warns ? ` (${warns} warn in .struq/look.json: open their screenshots)` : ""}`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/* --------------------------------------------------------------------- ART */
|
|
884
|
+
// The art director's asks are binding. .struq/art-direction.md lists each ask as a numbered item, and the builder writes
|
|
885
|
+
// an answer line under it: "Built: <what changed>" or "Refused: <reason>". An ask with no answer fails. A refusal whose
|
|
886
|
+
// reason only cites the storyboard, the plan, a budget or a block fails too: the storyboard is rewritten for a good ask,
|
|
887
|
+
// a budget is kept by dropping something else, a block is swapped or restyled. A refusal stands on a rule that never
|
|
888
|
+
// bends (SKILL.md, Rules that never bend) or on a fact the brief did not give, and says which.
|
|
889
|
+
{
|
|
890
|
+
const G = "ART";
|
|
891
|
+
const file = join(root, ".struq", "art-direction.md");
|
|
892
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
893
|
+
if (!text) add(G, "FAIL", "No .struq/art-direction.md: hand the stills to a fresh subagent (SKILL.md, Art direction), save its asks there as numbered items, and answer each with Built: or Refused:.");
|
|
894
|
+
else {
|
|
895
|
+
const asks = [];
|
|
896
|
+
let cur = null;
|
|
897
|
+
for (const line of text.split(/\r?\n/)) {
|
|
898
|
+
if (/^\s*#/.test(line)) {
|
|
899
|
+
cur = null;
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
const ask = line.match(/^\s{0,3}\d+[.)]\s+(\S.*)$/);
|
|
903
|
+
if (ask) {
|
|
904
|
+
cur = { ask: ask[1], answer: null, reason: "" };
|
|
905
|
+
asks.push(cur);
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (!cur) continue;
|
|
909
|
+
const ans = line.match(/^\s*(?:[-*]\s+)?\*{0,2}(Built|Refused)\*{0,2}:\*{0,2}\s*(.*)$/i);
|
|
910
|
+
if (ans) {
|
|
911
|
+
cur.answer = ans[1].toLowerCase();
|
|
912
|
+
cur.reason = ans[2];
|
|
913
|
+
// An ask can be partly built and partly refused; keep the Built line for the leftover check below.
|
|
914
|
+
if (cur.answer === "built") cur.built = ans[2];
|
|
915
|
+
} else if (cur.answer === "refused" && line.trim()) cur.reason += ` ${line.trim()}`;
|
|
916
|
+
}
|
|
917
|
+
const shortAsk = (a) => `${a.ask.slice(0, 90)}${a.ask.length > 90 ? "..." : ""}`;
|
|
918
|
+
const unanswered = asks.filter((a) => !a.answer);
|
|
919
|
+
const EMPTY = /^[\s.:;,…-]*$/;
|
|
920
|
+
// What a refusal may stand on: a rule that never bends, or a fact the brief did not give.
|
|
921
|
+
const GROUND = /\b(?:invent\w*|made[- ]up|fabricat\w*|never bends|rule\s*[1-4]|honesty|stand-in|empty frame|eyebrow\w*|tells?|dash(?:es)?|the brief\s+(?:gave|gives|did not|does not|never|has no|names no|says nothing)|brief gave|not (?:given|in the brief)|no (?:photo|pack photo|address|phone|number|name|programme|program|dates?|price|prices|shop|booking|testimonial|product)\b|reduced.motion|copyright|licen[cs]e)/i;
|
|
922
|
+
// What is not a ground: the plan, the storyboard, a budget, or a block.
|
|
923
|
+
const NOT_GROUND = /\b(?:story-?board|(?:the |our |approved |agreed )?plan\b|budgets?|quiet share|pin(?:s| budget)?\b|one centerpiece|centerpiece|blocks?\b|block-owned|owns? (?:its|the)|fixed (?:layout|crop|composition)|the film|loud scene)/i;
|
|
924
|
+
const weak = asks.filter((a) => a.answer === "refused" && (EMPTY.test(a.reason) || (NOT_GROUND.test(a.reason) && !GROUND.test(a.reason))));
|
|
925
|
+
const bare = asks.filter((a) => a.answer === "built" && EMPTY.test(a.reason));
|
|
926
|
+
// A Built answer that says a quoted text was removed is checked against the files: trial-prim fashion logged
|
|
927
|
+
// 'the "Photos: Struq" credit is removed' and the credit was still on the page (2026-11-15).
|
|
928
|
+
const leftovers = [];
|
|
929
|
+
const isComment = (line) => /^(\/\/|\/?\*|\{\/\*)/.test(line.trim());
|
|
930
|
+
for (const a of asks.filter((x) => x.built)) {
|
|
931
|
+
for (const m of a.built.matchAll(/["“]([^"”]{3,160})["”]/g)) {
|
|
932
|
+
const after = a.built.slice(m.index + m[0].length, m.index + m[0].length + 50);
|
|
933
|
+
const before = a.built.slice(Math.max(0, m.index - 40), m.index);
|
|
934
|
+
const gone = /^\s*(?:[\w-]+\s+){0,2}(?:is|are|was|were|has been|have been|now)?\s*(?:removed|gone|deleted|dropped|cut)\b/i.test(after) || /\b(?:removed|deleted|dropped)\s+(?:the\s+)?(?:[\w-]+\s+)?$/i.test(before);
|
|
935
|
+
if (!gone) continue;
|
|
936
|
+
const words = m[1].trim().replace(/[.,;:!?]+$/, "");
|
|
937
|
+
if (words.length < 3) continue;
|
|
938
|
+
// Found in a shipped file (lib/struq-images.ts holds the credit as a constant), it counts only when the site's
|
|
939
|
+
// own files still use that export; found in an own file, it counts as it stands.
|
|
940
|
+
const hitIn = (f) => f.text.split(/\r?\n/).findIndex((line) => !isComment(line) && line.includes(words));
|
|
941
|
+
let where = null;
|
|
942
|
+
for (const f of files) {
|
|
943
|
+
if (f.rel.startsWith(".struq/") || /\.md$/i.test(f.rel)) continue;
|
|
944
|
+
const i = hitIn(f);
|
|
945
|
+
if (i < 0) continue;
|
|
946
|
+
if (!f.recipe) {
|
|
947
|
+
where = `${f.rel}:${i + 1}`;
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
950
|
+
const name = f.text.split(/\r?\n/)[i].match(/export\s+const\s+(\w+)/)?.[1];
|
|
951
|
+
const user = name && own.find((o) => !/\.md$/i.test(o.rel) && new RegExp(`\\b${name}\\b`).test(o.text));
|
|
952
|
+
if (user) {
|
|
953
|
+
where = `${f.rel}:${i + 1}, used by ${user.rel}`;
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (where) leftovers.push(`${shortAsk(a)} "${words}" is still in ${where}`);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
if (leftovers.length) add(G, "FAIL", `${leftovers.length} art-direction answer(s) say a text was removed that is still in the site. Remove it, or correct the Built line.`, leftovers);
|
|
961
|
+
if (!asks.length && /ship it/i.test(text)) add(G, "PASS", "The art director said ship it: no asks to answer");
|
|
962
|
+
else if (!asks.length) add(G, "FAIL", ".struq/art-direction.md holds no numbered asks. Write each ask as a numbered item (1. 2. 3.) with the still it names, and answer each with Built: or Refused:.");
|
|
963
|
+
else {
|
|
964
|
+
if (unanswered.length) add(G, "FAIL", `${unanswered.length} art-direction ask(s) with no answer. Under each ask write "Built: <what changed>" or "Refused: <reason>"; every ask is built unless it breaks a rule that never bends or a fact of the brief.`, unanswered.map(shortAsk));
|
|
965
|
+
if (weak.length) add(G, "FAIL", `${weak.length} art-direction ask(s) refused with no ground. The storyboard, the plan, a budget or a block is never a reason: rewrite the storyboard for a good ask, keep a budget by dropping something else, swap or restyle the block. Refuse only an ask that breaks a rule that never bends (SKILL.md) or a fact the brief did not give, and say which.`, weak.map((a) => `${shortAsk(a)} Refused: ${a.reason.slice(0, 100)}`));
|
|
966
|
+
if (bare.length) add(G, "FAIL", `${bare.length} art-direction ask(s) marked Built with nothing said. Write what changed.`, bare.map(shortAsk));
|
|
967
|
+
if (!unanswered.length && !weak.length && !bare.length && !leftovers.length) {
|
|
968
|
+
const built = asks.filter((a) => a.answer === "built").length;
|
|
969
|
+
add(G, "PASS", `Every art-direction ask is answered: ${built} built, ${asks.length - built} refused on a rule or a fact of the brief`);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
569
975
|
/* --------------------------------------------------------------------- LOG */
|
|
570
976
|
// Every site keeps STRUQ-LOG.md: an entry for each completed checklist step. Step 8 (this check) writes its own entry
|
|
571
977
|
// when this run ends, so it is not required beforehand.
|
|
@@ -587,7 +993,7 @@ const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`))
|
|
|
587
993
|
}
|
|
588
994
|
|
|
589
995
|
/* ------------------------------------------------------------------ REPORT */
|
|
590
|
-
const order = ["IMAGES", "TELLS", "MOTION", "HERO", "FILM", "TYPE", "PLAN", "LOG"];
|
|
996
|
+
const order = ["IMAGES", "TELLS", "MOTION", "HERO", "FILM", "TYPE", "PLAN", "LOOK", "ART", "LOG"];
|
|
591
997
|
console.log(`Struq check: ${root}\n`);
|
|
592
998
|
for (const g of order) {
|
|
593
999
|
const rows = results.filter((r) => r.group === g);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// This site's hand, kept in .struq/draw.json: two languages, two light and two dark palettes, three type pairings,
|
|
2
|
+
// five openings and five centerpieces. Dealt once by hand.mjs, the same module the Struq MCP deals with (copied from
|
|
3
|
+
// Struq's blocks/film/hand.mjs on release), so the skill and the MCP give a site the same kind of hand and check a
|
|
4
|
+
// plan against it the same way. canon.mjs draw and blocks.mjs arc both read it here.
|
|
5
|
+
//
|
|
6
|
+
// Proof runs of 2026-11-14 showed why there is a hand at all: listing every option, even shuffled, and naming the
|
|
7
|
+
// reflexes still drew most sites to the same few picks. A dealt hand makes every site start from a different place;
|
|
8
|
+
// the agent still chooses within it.
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { dealHand, handPools, isHandId } from "./hand.mjs";
|
|
13
|
+
import { appendLog } from "./log.mjs";
|
|
14
|
+
import { lightness } from "./tokens.mjs";
|
|
15
|
+
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const read = (...p) => JSON.parse(readFileSync(join(here, "..", ...p), "utf8"));
|
|
18
|
+
|
|
19
|
+
/** The pools out of the skill's canon and catalogue, as the MCP builds them out of Struq's. */
|
|
20
|
+
export function pools() {
|
|
21
|
+
const canon = read("canon", "canon.json");
|
|
22
|
+
const catalog = read("blocks", "catalog.json");
|
|
23
|
+
const blocks = Array.isArray(catalog) ? catalog : catalog.blocks;
|
|
24
|
+
return handPools({
|
|
25
|
+
palettes: canon.palettes.map((p) => ({ id: p.id, light: lightness(p.colors.background) >= 0.5, autoPick: p.autoPick })),
|
|
26
|
+
pairings: canon.pairings.map((t) => ({ id: t.id, autoPick: t.autoPick })),
|
|
27
|
+
catalog: blocks.map((b) => ({ slug: b.slug, role: b.role, home: b.home })),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The project's hand: read from .struq/draw.json, or dealt now and saved. `fresh` says whether it was just dealt. */
|
|
32
|
+
export function projectHand(root) {
|
|
33
|
+
const file = join(root, ".struq", "draw.json");
|
|
34
|
+
const saved = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
|
|
35
|
+
if (saved && isHandId(saved.id) && saved.openings && saved.pairings) return { hand: saved, fresh: false };
|
|
36
|
+
const hand = dealHand(pools());
|
|
37
|
+
mkdirSync(join(root, ".struq"), { recursive: true });
|
|
38
|
+
writeFileSync(file, JSON.stringify(hand, null, 2) + "\n");
|
|
39
|
+
appendLog(root, "Step 2 Draw", [
|
|
40
|
+
`Hand ${hand.id} dealt: languages ${hand.languages.join(", ")}; palettes ${hand.palettes.join(", ")}; pairings ${hand.pairings.join(", ")}; openings ${hand.openings.join(", ")}; centerpieces ${hand.centerpieces.join(", ")}.`,
|
|
41
|
+
]);
|
|
42
|
+
return { hand, fresh: true };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The saved hand, or null when none was dealt. */
|
|
46
|
+
export function savedHand(root) {
|
|
47
|
+
const file = join(root, ".struq", "draw.json");
|
|
48
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
|
|
49
|
+
}
|