@struq/skill 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -35
- package/package.json +3 -2
- package/skills/struq/SKILL.md +148 -212
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/blocks/kit/Cursor.tsx +117 -0
- package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
- package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
- package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
- package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
- package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
- package/skills/struq/blocks/kit/Reveal.tsx +64 -0
- package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
- package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/gsap.ts +21 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
- package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
- package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
- package/skills/struq/blocks/kit/scenes/types.ts +114 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
- package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
- package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
- package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
- package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -0
- package/skills/struq/recipes/app/globals.css +42 -0
- package/skills/struq/recipes/app/layout.tsx +30 -0
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +134 -0
- package/skills/struq/scripts/check.mjs +513 -0
- package/skills/struq/scripts/images.mjs +360 -0
- package/skills/struq/scripts/log.mjs +75 -0
- package/skills/struq/scripts/packs.mjs +156 -0
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/references/anti-patterns.md +0 -265
- package/skills/struq/references/baseline-prompt.md +0 -118
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -219
- package/skills/struq/references/composition.md +0 -191
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/imagery.md +0 -144
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -341
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -280
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Struq self-check: run it from the project root before you report a build as done.
|
|
3
|
+
//
|
|
4
|
+
// node <skill-dir>/scripts/check.mjs
|
|
5
|
+
// node <skill-dir>/scripts/check.mjs --dir ./my-site
|
|
6
|
+
//
|
|
7
|
+
// It needs no browser and no dependencies. It reads the source and the public folder and prints
|
|
8
|
+
// PASS, WARN or FAIL per check, in four groups: images, AI tells, the motion floor, typography,
|
|
9
|
+
// plus the plan in STRUQ.md. Every FAIL names the file and line and the fix. Exit code 1 while any
|
|
10
|
+
// FAIL remains. Fix, run again, and only report done on "RESULT: PASS".
|
|
11
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
12
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
13
|
+
import { appendLog } from "./log.mjs";
|
|
14
|
+
import { altFitsBrief, packSignalForBrief } from "./packs.mjs";
|
|
15
|
+
|
|
16
|
+
const CANON = JSON.parse(readFileSync(new URL("../canon/canon.json", import.meta.url), "utf8"));
|
|
17
|
+
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
const dirFlag = args.indexOf("--dir");
|
|
20
|
+
const root = resolve(dirFlag >= 0 ? args[dirFlag + 1] : process.cwd());
|
|
21
|
+
|
|
22
|
+
const SKIP_DIRS = new Set(["node_modules", ".next", ".git", "out", "dist", "build", ".turbo", ".vercel", "public", ".claude", ".agents", ".cursor", "coverage"]);
|
|
23
|
+
const CODE_EXT = /\.(tsx|ts|jsx|js|mjs|css|mdx)$/;
|
|
24
|
+
|
|
25
|
+
function walk(dir, acc = []) {
|
|
26
|
+
let names = [];
|
|
27
|
+
try {
|
|
28
|
+
names = readdirSync(dir);
|
|
29
|
+
} catch {
|
|
30
|
+
return acc;
|
|
31
|
+
}
|
|
32
|
+
for (const name of names) {
|
|
33
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
34
|
+
const full = join(dir, name);
|
|
35
|
+
let st;
|
|
36
|
+
try {
|
|
37
|
+
st = statSync(full);
|
|
38
|
+
} catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (st.isDirectory()) walk(full, acc);
|
|
42
|
+
else if (CODE_EXT.test(name) && !/\.(config|d)\.(ts|js|mjs)$/.test(name) && !name.endsWith(".d.ts")) acc.push(full);
|
|
43
|
+
}
|
|
44
|
+
return acc;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const rel = (p) => relative(root, p).split(sep).join("/");
|
|
48
|
+
// A shipped file is one the skill installs under components/struq/ at the same path as in its blocks/ copy (or the
|
|
49
|
+
// images module images.mjs writes); anything else there, a scene or section you added, counts as your own.
|
|
50
|
+
const SHIPPED = new URL("../blocks/", import.meta.url);
|
|
51
|
+
const isRecipe = (p) => {
|
|
52
|
+
const r = rel(p);
|
|
53
|
+
if (/(^|\/)lib\/struq-images\.ts$/.test(r)) return true;
|
|
54
|
+
const m = r.match(/(?:^|\/)components\/struq\/(.+)$/);
|
|
55
|
+
return Boolean(m && existsSync(new URL(m[1], SHIPPED)));
|
|
56
|
+
};
|
|
57
|
+
const files = walk(root)
|
|
58
|
+
.filter((p) => !/^(scripts|test|tests|e2e)\//.test(rel(p)))
|
|
59
|
+
.map((p) => ({ path: p, rel: rel(p), recipe: isRecipe(p), text: readFileSync(p, "utf8") }));
|
|
60
|
+
const own = files.filter((f) => !f.recipe);
|
|
61
|
+
const read = (p) => (existsSync(join(root, p)) ? readFileSync(join(root, p), "utf8") : "");
|
|
62
|
+
|
|
63
|
+
const results = [];
|
|
64
|
+
const add = (group, status, message, where = []) => results.push({ group, status, message, where });
|
|
65
|
+
|
|
66
|
+
/** Every non-comment line matching a pattern, as "file:line snippet". */
|
|
67
|
+
function hits(pattern, { scope = files, exclude = null } = {}) {
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const f of scope) {
|
|
70
|
+
const lines = f.text.split(/\r?\n/);
|
|
71
|
+
lines.forEach((line, i) => {
|
|
72
|
+
const t = line.trim();
|
|
73
|
+
if (!t || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*") || t.startsWith("{/*")) return;
|
|
74
|
+
if (pattern.test(line) && !(exclude && exclude.test(line))) {
|
|
75
|
+
out.push(`${f.rel}:${i + 1} ${t.slice(0, 120)}`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
const uses = (pattern) => own.some((f) => pattern.test(f.text));
|
|
82
|
+
|
|
83
|
+
function isImageFile(path) {
|
|
84
|
+
try {
|
|
85
|
+
const buf = readFileSync(path);
|
|
86
|
+
if (buf.length < 8000) return false;
|
|
87
|
+
const a = (s, e) => buf.subarray(s, e).toString("latin1");
|
|
88
|
+
return (
|
|
89
|
+
(a(0, 4) === "RIFF" && a(8, 12) === "WEBP") ||
|
|
90
|
+
(buf[0] === 0xff && buf[1] === 0xd8) ||
|
|
91
|
+
(buf[0] === 0x89 && a(1, 4) === "PNG") ||
|
|
92
|
+
a(4, 12) === "ftypavif"
|
|
93
|
+
);
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!existsSync(join(root, "package.json"))) {
|
|
100
|
+
console.error(`FAIL no package.json in ${root}. Run from the project root or pass --dir.`);
|
|
101
|
+
process.exit(2);
|
|
102
|
+
}
|
|
103
|
+
const pkg = JSON.parse(read("package.json"));
|
|
104
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
105
|
+
const plan = read("STRUQ.md");
|
|
106
|
+
|
|
107
|
+
/* ------------------------------------------------------------------ IMAGES */
|
|
108
|
+
{
|
|
109
|
+
const G = "IMAGES";
|
|
110
|
+
const libFile = ["lib/struq-images.ts", "src/lib/struq-images.ts"].find((p) => existsSync(join(root, p)));
|
|
111
|
+
const pub = join(root, "public");
|
|
112
|
+
const listPublic = (dir, acc = []) => {
|
|
113
|
+
if (!existsSync(dir)) return acc;
|
|
114
|
+
for (const n of readdirSync(dir)) {
|
|
115
|
+
const full = join(dir, n);
|
|
116
|
+
if (statSync(full).isDirectory()) listPublic(full, acc);
|
|
117
|
+
else if (/\.(webp|jpe?g|png|avif|svg)$/i.test(n)) acc.push(full);
|
|
118
|
+
}
|
|
119
|
+
return acc;
|
|
120
|
+
};
|
|
121
|
+
const all = listPublic(pub);
|
|
122
|
+
const realPhotos = all.filter((p) => !p.endsWith(".svg") && isImageFile(p));
|
|
123
|
+
const brokenPhotos = all.filter((p) => !p.endsWith(".svg") && !isImageFile(p));
|
|
124
|
+
|
|
125
|
+
const mod = libFile ? read(libFile) : "";
|
|
126
|
+
const imageFit = mod.match(/imageFit[^=]*=\s*"(full|partial|none)"/)?.[1] ?? "full";
|
|
127
|
+
// The photos per slot in the pack module, for references that point past what was downloaded.
|
|
128
|
+
const slotCount = Object.fromEntries(
|
|
129
|
+
["hero", "band", "card", "column", "detail", "backdrop"].map((s) => [s, (mod.match(new RegExp(`\\b${s}:\\s*\\[([^\\]]*)\\]`))?.[1].match(/src:/g) ?? []).length]),
|
|
130
|
+
);
|
|
131
|
+
if (libFile) {
|
|
132
|
+
const srcs = [...mod.matchAll(/src: "([^"]+)"/g)].map((m) => m[1]);
|
|
133
|
+
const missing = srcs.filter((s) => !existsSync(join(pub, s)));
|
|
134
|
+
const plates = srcs.filter((s) => s.endsWith(".svg"));
|
|
135
|
+
const pack = mod.match(/imagePack = "([^"]+)"/)?.[1] ?? "?";
|
|
136
|
+
if (imageFit === "none" && !srcs.length) add(G, "PASS", `No photo of pack "${pack}" fits this brief (imageFit none): the opener carries the first screen, and the approval stop asks for photos`);
|
|
137
|
+
else if (missing.length) add(G, "FAIL", `${missing.length} image(s) in ${libFile} are not on disk. Run images.mjs ${pack} again.`, missing);
|
|
138
|
+
else if (plates.length === srcs.length) add(G, "WARN", `struq.nl was unreachable: all ${srcs.length} images are fallback plates. Run images.mjs ${pack} again when online.`);
|
|
139
|
+
else if (plates.length) add(G, "WARN", `${plates.length} fallback plate(s) among the pack images. Run images.mjs ${pack} again when online.`, plates);
|
|
140
|
+
else add(G, "PASS", `${srcs.length} beeldbank images from pack "${pack}", each a verified image file`);
|
|
141
|
+
// An off-topic photo is worse than no photo: a pack the brief does not point at must show
|
|
142
|
+
// something the brief is about in at least one alt.
|
|
143
|
+
const brief = plan.match(/^Brief:\s*(.+)$/m)?.[1];
|
|
144
|
+
if (brief && srcs.length && packSignalForBrief(brief) !== pack) {
|
|
145
|
+
const alts = [...mod.matchAll(/alt: "([^"]*)"/g)].map((m) => m[1]);
|
|
146
|
+
if (!alts.some((alt) => altFitsBrief(alt, brief))) {
|
|
147
|
+
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));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} else if (realPhotos.length >= 3) {
|
|
151
|
+
add(G, "PASS", `${realPhotos.length} image files of the project's own in public/`);
|
|
152
|
+
} else {
|
|
153
|
+
add(G, "FAIL", "No images. Run: node <skill-dir>/scripts/images.mjs --brief \"<the brief>\" (the pack table is in SKILL.md). The images are free and CC0: no permission needed.");
|
|
154
|
+
}
|
|
155
|
+
if (brokenPhotos.length) add(G, "FAIL", "These image files are not real images (an error page saved as .webp?). Delete them and run images.mjs again.", brokenPhotos.map(rel));
|
|
156
|
+
|
|
157
|
+
// Each pack reference (images.card[2], images.card.slice(0, 5)) or own image tag counts once.
|
|
158
|
+
const used = own.reduce((n, f) => n + (f.text.match(/images\.(hero|band|card|column|detail|backdrop)\b|<img\b|src=["'{`]\/images\//g) ?? []).length, 0);
|
|
159
|
+
const total = Object.values(slotCount).reduce((a, b) => a + b, 0);
|
|
160
|
+
const floor = libFile ? Math.min(4, total) : 4;
|
|
161
|
+
if (libFile && imageFit !== "full" && total === 0) add(G, "PASS", "No image placements needed: no pack photo fits");
|
|
162
|
+
else if (used >= floor) add(G, "PASS", `${used} image placements in the page code`);
|
|
163
|
+
else add(G, "FAIL", `Only ${used} image placement(s). A Struq site carries a hero image and at least three more: use <MediaFrame image={images.card[0]} />.`);
|
|
164
|
+
// References past what the pack holds render as empty swatches (an inline manifesto image, a card).
|
|
165
|
+
if (libFile) {
|
|
166
|
+
const past = [];
|
|
167
|
+
for (const f of own) {
|
|
168
|
+
for (const m of f.text.matchAll(/images\.(hero|band|card|column|detail|backdrop)\[(\d+)\]/g)) {
|
|
169
|
+
if (Number(m[2]) >= slotCount[m[1]]) past.push(`${f.rel} images.${m[1]}[${m[2]}] (the pack holds ${slotCount[m[1]]})`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (past.length) add(G, "FAIL", "Image references past the pack: they render as empty swatches. Use only the photos lib/struq-images.ts lists.", past);
|
|
173
|
+
}
|
|
174
|
+
// Inline manifesto photos are a word's size: a texture (detail, backdrop) reads as a grey swatch.
|
|
175
|
+
const textures = [];
|
|
176
|
+
for (const f of own) {
|
|
177
|
+
for (const m of f.text.matchAll(/<ManifestoScrub\b[\s\S]*?\/>/g)) {
|
|
178
|
+
if (/images\.(detail|backdrop)\[/.test(m[0])) textures.push(`${f.rel}:${f.text.slice(0, m.index).split("\n").length}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (textures.length) add(G, "FAIL", "Inline manifesto photo is a texture: at word size a detail or backdrop photo reads as a grey swatch. Use photos with a subject (card, column, hero).", textures);
|
|
182
|
+
|
|
183
|
+
const localSrc = [];
|
|
184
|
+
for (const f of own) {
|
|
185
|
+
for (const m of f.text.matchAll(/(?:src=|src:\s*)["'`](\/[^"'`?#]+\.(?:webp|jpe?g|png|avif|svg|gif))["'`]/g)) {
|
|
186
|
+
if (!existsSync(join(pub, m[1]))) localSrc.push(`${f.rel} ${m[1]}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (localSrc.length) add(G, "FAIL", "Image paths that point at no file in public/:", localSrc);
|
|
190
|
+
|
|
191
|
+
const placeholders = hits(
|
|
192
|
+
/\b(Image|Media|Photo|Img)?Placeholder\b|Planned image|placehold\.co|via\.placeholder|picsum\.photos|source\.unsplash|nog te fotograferen|te maken:|foto volgt|beeld volgt|to be shot|image coming soon|photo coming soon/i,
|
|
193
|
+
{ scope: own, exclude: /placeholder=["'{]/ },
|
|
194
|
+
);
|
|
195
|
+
const emptyFrames = hits(/<(div|figure|span)\b[^>]*(aspect-|aspectRatio)[^>]*(\/>|>\s*<\/(div|figure|span)>)/, { scope: own });
|
|
196
|
+
const hotlinks = hits(/https?:\/\/(www\.)?struq\.nl\/media\//, { scope: own });
|
|
197
|
+
if (placeholders.length || emptyFrames.length) {
|
|
198
|
+
add(G, "FAIL", "Placeholder or empty frames. A Struq site never ships an empty frame: put a MediaFrame with a pack image in each.", [...placeholders, ...emptyFrames]);
|
|
199
|
+
} else add(G, "PASS", "No placeholder or empty image frames");
|
|
200
|
+
if (hotlinks.length) add(G, "FAIL", "Hotlinked struq.nl images: download them with images.mjs and use the local /images/ path.", hotlinks);
|
|
201
|
+
|
|
202
|
+
const credit = uses(/imageCredit|struq\.nl\/beeldbank/);
|
|
203
|
+
if (libFile && !credit) add(G, "WARN", "No image credit. Put imageCredit (\"Beeld: Struq\") in the footer as one quiet text-small link.");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ------------------------------------------------------------------- TELLS */
|
|
207
|
+
// The combined tell list: Struq's tells (StruqVisual lib/consistentie/tells.ts), the deck-slop-audit
|
|
208
|
+
// patterns and Roy's own rules, as one list. references/tells.md has the why and the fix for
|
|
209
|
+
// each; this is its executable form. FAIL blocks done; WARN asks for a look.
|
|
210
|
+
{
|
|
211
|
+
const G = "TELLS";
|
|
212
|
+
const tell = (name, pattern, fix, opts = {}) => {
|
|
213
|
+
// Your own files only: the blocks under components/struq/ passed Struq's gate as they are.
|
|
214
|
+
const found = hits(pattern, { scope: own, ...opts });
|
|
215
|
+
if (found.length) add(G, opts.warn ? "WARN" : "FAIL", `${name}: ${fix}`, found);
|
|
216
|
+
else if (!opts.quietPass) add(G, "PASS", `No ${name.toLowerCase()}`);
|
|
217
|
+
};
|
|
218
|
+
const ownOnly = { scope: own };
|
|
219
|
+
|
|
220
|
+
// Type and labels
|
|
221
|
+
tell("Eyebrows and uppercase labels", /\buppercase\b|text-transform:\s*uppercase|\b(eyebrow|kicker|overline)\b/i,
|
|
222
|
+
"small uppercase labels above headings, uppercase marquees and tracked caps are AI tells. Delete the label; the heading, a hairline rule or a jump in scale does the wayfinding. A deliberate all-caps wordmark passes when its element carries data-wordmark.",
|
|
223
|
+
{ exclude: /data-wordmark|<figcaption\b|data-caption/ });
|
|
224
|
+
tell("Monospace as a design face", /font-mono|monospace|_Mono\b|\bMono\(|JetBrains|Fira_Code|Space_Mono/,
|
|
225
|
+
"mono is for literal code only. Labels, stats and dates use the text face with tabular-nums.", { exclude: /<(code|pre)\b/ });
|
|
226
|
+
tell("Default faces", /(next\/font|font-family|--font-|--face-|import\s*\{)[^\n]*\b(Inter|Roboto|Arial|Helvetica|Open[ _]Sans|Poppins|Montserrat|Lato|Geist|Space[ _]Grotesk)\b/,
|
|
227
|
+
"set a canon or SKILL.md pairing with canon.mjs type (Inter, Roboto, Arial, Geist and friends read as nobody chose).");
|
|
228
|
+
tell("Heavy display", /(<h1|<h2|font-display|text-display|text-h2|SplitHeadline)[^\n]*font-(bold|extrabold|black|semibold)|font-(bold|extrabold|black|semibold)[^\n]*(<h1|<h2|font-display|text-display|text-h2)/,
|
|
229
|
+
"display runs light (300 to 400); hierarchy from scale, not weight.");
|
|
230
|
+
// A display heading cut off by a clipping box (overflow hidden on it or on its direct parent).
|
|
231
|
+
{
|
|
232
|
+
const DISPLAY = /font-display|text-display|text-h2|text-\[\d+(?:\.\d+)?vw\]/;
|
|
233
|
+
const clipped = [];
|
|
234
|
+
for (const f of own.filter((x) => !x.rel.endsWith(".css"))) {
|
|
235
|
+
for (const m of f.text.matchAll(/<(h1|h2)\b[^>]*>/g)) {
|
|
236
|
+
const tag = m[0];
|
|
237
|
+
const before = f.text.slice(0, m.index);
|
|
238
|
+
const parent = before.slice(before.lastIndexOf("<")).replace(/\s+$/, "");
|
|
239
|
+
const parentClips = /overflow-hidden/.test(parent) && parent.endsWith(">") && !parent.startsWith("</");
|
|
240
|
+
if (DISPLAY.test(tag) && (/overflow-hidden/.test(tag) || parentClips)) {
|
|
241
|
+
clipped.push(`${f.rel}:${before.split("\n").length} ${tag.slice(0, 100)}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// A long display word in a narrow column runs out of it, under the image beside it.
|
|
246
|
+
for (const f of own.filter((x) => !x.rel.endsWith(".css"))) {
|
|
247
|
+
for (const m of f.text.matchAll(/<(h1|h2|SplitHeadline)\b([^>]*)>([^<]*)</g)) {
|
|
248
|
+
if (!DISPLAY.test(m[2])) continue;
|
|
249
|
+
const before = f.text.slice(Math.max(0, m.index - 400), m.index);
|
|
250
|
+
const span = [...before.matchAll(/col-span-(\d+)/g)].pop();
|
|
251
|
+
const longest = Math.max(0, ...m[3].split(/\s+/).map((w) => w.length));
|
|
252
|
+
const after = f.text.slice(m.index, m.index + 700);
|
|
253
|
+
const lifted = /\bz-(10|20|30)\b[^>]*>[\s\S]{0,200}<(MediaFrame|Image|img)\b/.test(after);
|
|
254
|
+
if (span && Number(span[1]) <= 4 && (longest >= 11 || lifted)) {
|
|
255
|
+
clipped.push(`${f.rel}:${f.text.slice(0, m.index).split("\n").length} "${m[3].trim().slice(0, 40)}" in col-span-${span[1]}${lifted ? " beside a lifted image" : ""}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (clipped.length) add(G, "FAIL", "Display heading clipped: a large heading sits in an overflow-hidden box (or on top of an image box) and loses its letters. Give it its own grid cell beside the image, never under or behind it.", clipped);
|
|
260
|
+
}
|
|
261
|
+
tell("Numbered sections", />\s*0[1-9]\s*[<./]|['"`]0[1-9]['"`]|`0\$\{/,
|
|
262
|
+
"01, 02, 03 stamped on sections or rows is a tell; keep at most one deliberate oversized numeral.", ownOnly);
|
|
263
|
+
tell("Stat rows", />\s*\d{1,4}\s*[+%]\s*</,
|
|
264
|
+
"a banner of big numbers (20+, 51+, 99%) is a template tell, and usually invented. A real figure from the brief goes inside a sentence.", ownOnly);
|
|
265
|
+
tell("Raw text sizes", /\btext-(xs|sm|base|lg|xl|[2-9]xl)\b/, "use the role tokens: text-display, text-h2, text-h3, text-lead, text-body, text-small.", { ...ownOnly, warn: true, quietPass: true });
|
|
266
|
+
tell("Breakpoint size ladders", /text-\w+\s+md:text-\w+\s+lg:text-\w+|p[xy]-\d+\s+md:p[xy]-\d+\s+lg:p[xy]-\d+/, "one fluid root-rem knob; breakpoints change layout, never size.", { ...ownOnly, warn: true, quietPass: true });
|
|
267
|
+
|
|
268
|
+
// Colour and surface
|
|
269
|
+
tell("Gradients", /bg-gradient-|bg-linear-|bg-radial|bg-conic|(linear|radial|conic)-gradient\(|\b(from|via|to)-(?!transparent\b)[a-z]+-\d{1,3}\b/,
|
|
270
|
+
"flat surface steps (bg-surface-1, bg-surface-2) and a hairline; never a gradient.", { exclude: /mask/i });
|
|
271
|
+
tell("Gradient text", /bg-clip-text|background-clip:\s*text/, "headlines are ink; hierarchy comes from scale.");
|
|
272
|
+
tell("Tinted translucent fills and glass", /backdrop-blur|backdrop-filter|\bbg-(accent|white|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone|surface-\d)(-\d+)?\/\d+/,
|
|
273
|
+
"an opaque surface step. A flat black scrim over a photo is the only translucent fill.");
|
|
274
|
+
tell("Status badges", /\bbg-(green|red|blue|yellow|emerald|amber|lime|sky)-(50|100|200)\b/, "no pastel status chips; say it in the copy.");
|
|
275
|
+
tell("Pills", /rounded-full[^"'`]*\bpx-|\bpx-[^"'`]*rounded-full/, "buttons and labels take the site radius (0 to 4px), never a pill.");
|
|
276
|
+
tell("Live dots", /animate-(ping|pulse)/, "no pulsing dot, above all not in a pill.");
|
|
277
|
+
tell("Blue and purple utilities", /\b(text|bg|border|fill|stroke|ring|outline|decoration|shadow|accent)-(blue|indigo|violet|purple|fuchsia|sky)-\d/,
|
|
278
|
+
"the palette tokens carry every colour; blue and purple only when the brand is that, declared in STRUQ.md.");
|
|
279
|
+
tell("Glow", /shadow-\[0_0_|drop-shadow-\[0_0_|(box|text)-shadow:\s*0(px)?\s+0(px)?\s+\d+px|\bblur-(2xl|3xl)\b/, "no coloured light behind a card or a word; elevation is a surface step.");
|
|
280
|
+
tell("Drop-shadow elevation", /\bshadow-(md|lg|xl|2xl)\b/, "elevation is a tonal surface step plus a hairline.");
|
|
281
|
+
tell("Side-stripe borders", /\bborder-[lr]-([2-9]|\[[2-9])/, "no coloured stripe down a card edge; a hairline or nothing.");
|
|
282
|
+
|
|
283
|
+
// Layout and motion
|
|
284
|
+
tell("Identical card grids", /grid-cols-3\b/, "three equal cards in a row read as a template; asymmetric 5/7 splits or hairline rows.", { ...ownOnly, warn: true, quietPass: true });
|
|
285
|
+
tell("Uniform section padding", /\bpy-(16|24)\b/, "padding varies by role (dense 6 to 8rem, breathers 12 to 20rem).", { ...ownOnly, warn: true, quietPass: true });
|
|
286
|
+
tell("Opacity-only entrances", /initial=\{\{\s*opacity:\s*0\s*\}\}|whileInView=\{\{\s*opacity:[^,}]*\}\}/, "every entrance moves AND fades: <Reveal>, or y with autoAlpha.");
|
|
287
|
+
tell("Uniform hovers", /hover:scale-1\d\d\b|transition-all\b/, "one designed hover per element type; never scale-105 plus transition-all on everything.", { ...ownOnly, warn: true, quietPass: true });
|
|
288
|
+
tell("Single-layer parallax", /background-attachment:\s*fixed|\bbg-fixed\b/, "depth needs relative rates: MediaFrame parallax inside its frame.");
|
|
289
|
+
tell("anticipatePin with smooth scroll", /anticipatePin/, "remove it; scrub: 1. With Lenis it jerks the pin on engage.");
|
|
290
|
+
|
|
291
|
+
// Icons and content
|
|
292
|
+
tell("Cliche icons and emoji", /<(Sparkles|Rocket|Zap|Lightbulb|Wand|Flame)\b|\b(Sparkles|Rocket|Zap)Icon\b|[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u, "no emoji, no sparkle, rocket, lightning, bulb, wand or flame icons.");
|
|
293
|
+
tell("Filler content", /Lorem ipsum|\bAcme\b|John Doe|Jane Doe|SITE_TITLE|SITE_DESCRIPTION|Your journey starts here/i, "real copy for the brief; replace the layout's SITE_TITLE and SITE_DESCRIPTION.");
|
|
294
|
+
tell("Dashes", /[\u2012\u2013\u2014\u2015\u2E3A\u2E3B]|&(mdash|ndash|horbar);|&#(821[0-3]);|ȁ[2-5];/i,
|
|
295
|
+
"em and en dashes read as machine-written copy. Restructure the sentence: a comma, a colon, a full stop, or \"to\" for a range.");
|
|
296
|
+
tell("Generic CTAs", />\s*(Learn more|Get started|Read more|Click here|Submit|Sign up|Contact us|Get in touch|Discover more|Find out more|Join now|Lees meer|Meer informatie|Meer weten|Klik hier|Neem contact op|Aan de slag|Ontdek meer)\s*</i, "name the action and its result, verb first: \"Reserveer een tafel voor vanavond\", \"Bekijk de projecten\", \"Plan een kennismaking\" (Struq tells 1.2.0, generic-ctas).", ownOnly);
|
|
297
|
+
tell("Generic section headings", />\s*(Features|About Us|Our Services|Why Choose Us|What We Do|Our Story|Capabilities|Onze diensten|Over ons|Waarom kiezen voor ons)\s*</i, "a heading that says something only this business could say.", { ...ownOnly, warn: true, quietPass: true });
|
|
298
|
+
tell("Hype copy", /\b(Unleash|Supercharge|Next-?gen|Seamless|Elevate your|Revolutioni[sz]e|Game-?changer|Cutting-?edge|State-of-the-art|Unparalleled|Next-level|Turbocharge|naadloos|ontgrendel|revolutionair|toekomstbestendig)\b/i, "plain, specific claims from the brief.", { ...ownOnly, warn: true, quietPass: true });
|
|
299
|
+
|
|
300
|
+
// Palette hues: blue and purple only when STRUQ.md declares it. Hex and oklch() tokens.
|
|
301
|
+
const css = files.filter((f) => f.rel.endsWith(".css")).map((f) => f.text).join("\n");
|
|
302
|
+
const coolDeclared = /cool hue:\s*declared/i.test(plan);
|
|
303
|
+
const cool = [];
|
|
304
|
+
for (const m of css.matchAll(/--(?:color-)?(surface-\d|ink|ink-muted|accent|accent-ink|accent-2)\s*:\s*(#[0-9a-f]{6}\b|oklch\([^)]*\))/gi)) {
|
|
305
|
+
const v = m[2];
|
|
306
|
+
const o = v.match(/oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)/i);
|
|
307
|
+
if (o) {
|
|
308
|
+
const L = o[2] ? parseFloat(o[1]) / 100 : parseFloat(o[1]);
|
|
309
|
+
const C = parseFloat(o[3]);
|
|
310
|
+
const H = parseFloat(o[4]);
|
|
311
|
+
if (C > 0.06 && H >= 220 && H <= 320 && L > 0.12 && L < 0.92) cool.push(`--${m[1]}: ${v}`);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(v.slice(i, i + 2), 16) / 255);
|
|
315
|
+
const max = Math.max(r, g, b);
|
|
316
|
+
const min = Math.min(r, g, b);
|
|
317
|
+
const l = (max + min) / 2;
|
|
318
|
+
const d = max - min;
|
|
319
|
+
if (d === 0) continue;
|
|
320
|
+
const s = d / (1 - Math.abs(2 * l - 1));
|
|
321
|
+
let h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
322
|
+
h = (h * 60 + 360) % 360;
|
|
323
|
+
if (h >= 200 && h <= 320 && s > 0.3 && l > 0.12 && l < 0.88) cool.push(`--${m[1]}: ${v} (hue ${Math.round(h)})`);
|
|
324
|
+
}
|
|
325
|
+
// Purple or blue written straight into components, outside the tokens.
|
|
326
|
+
for (const f of own.filter((x) => !x.rel.endsWith(".css"))) {
|
|
327
|
+
for (const m of f.text.matchAll(/#([0-9a-f]{6})\b/gi)) {
|
|
328
|
+
const [r, g, b] = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16) / 255);
|
|
329
|
+
const max = Math.max(r, g, b);
|
|
330
|
+
const min = Math.min(r, g, b);
|
|
331
|
+
const d = max - min;
|
|
332
|
+
if (d < 0.25) continue;
|
|
333
|
+
let h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
334
|
+
h = (h * 60 + 360) % 360;
|
|
335
|
+
if (h >= 220 && h <= 320) cool.push(`${f.rel}: #${m[1]} (hue ${Math.round(h)})`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (cool.length && !coolDeclared) add(G, "FAIL", "Blue or purple in the palette. Pick a canon palette that fits (canon.mjs list), or write \"Cool hue: declared because <the brand's own colour>\" in STRUQ.md.", cool);
|
|
339
|
+
else add(G, "PASS", cool.length ? "Cool hue declared in STRUQ.md" : "Palette free of default blue and purple");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/* ------------------------------------------------------------------ MOTION */
|
|
343
|
+
// The blocks (components/struq/<kind>/) carry their own motion; this checks what the site around them owes them.
|
|
344
|
+
const BLOCK_IMPORT = /from\s*["'][^"']*components\/struq\/(preloaders|page-transitions|headers|heroes|backgrounds|showpieces|transitions|sections|footers)\/([\w-]+)["']/g;
|
|
345
|
+
const usedBlocks = new Set(own.flatMap((f) => [...f.text.matchAll(BLOCK_IMPORT)].map((m) => `${m[1]}/${m[2]}`)));
|
|
346
|
+
const usesKind = (kind) => [...usedBlocks].some((b) => b.startsWith(`${kind}/`));
|
|
347
|
+
{
|
|
348
|
+
const G = "MOTION";
|
|
349
|
+
const all = files.map((f) => f.text).join("\n");
|
|
350
|
+
const missing = ["gsap", "@gsap/react", "lenis"].filter((d) => !deps[d]);
|
|
351
|
+
const needsThree = usedBlocks.has("showpieces/hero-opener") || own.some((f) => /from\s*["']three["']|HeroCanvas/.test(f.text));
|
|
352
|
+
if (needsThree && !deps.three) missing.push("three");
|
|
353
|
+
if (missing.length) add(G, "FAIL", `Missing packages: npm install ${missing.join(" ")}`);
|
|
354
|
+
else add(G, "PASS", "The block packages are installed");
|
|
355
|
+
|
|
356
|
+
const layout = files.find((f) => /(^|\/)app\/layout\.(tsx|jsx)$/.test(f.rel))?.text ?? "";
|
|
357
|
+
const lenis = /new Lenis\(|ReactLenis/.test(all) && /gsap\.ticker\.add/.test(all);
|
|
358
|
+
if (lenis && /<SmoothScroll|<ReactLenis/.test(layout)) add(G, "PASS", "Lenis on the GSAP ticker, mounted in the layout");
|
|
359
|
+
else add(G, "FAIL", lenis ? "Lenis is not mounted: wrap {children} in <SmoothScroll> in app/layout.tsx." : "No Lenis on gsap.ticker: run blocks.mjs init and mount <SmoothScroll> in app/layout.tsx.");
|
|
360
|
+
|
|
361
|
+
if (/classList\.add\(\s*["']sq-js["']\s*\)/.test(layout) && /data-blocks/.test(layout)) add(G, "PASS", "The motion gate: sq-js before paint, data-blocks on <body>");
|
|
362
|
+
else add(G, "FAIL", "The motion gate is missing: the layout puts sq-js on <html> in an inline script before paint and data-blocks on <body> (recipes/app/layout.tsx). Without them the blocks lose their tokens and their first frame.");
|
|
363
|
+
|
|
364
|
+
if (usedBlocks.size) add(G, "PASS", `${usedBlocks.size} Struq block(s) on the page: ${[...usedBlocks].join(", ")}`);
|
|
365
|
+
else add(G, "FAIL", "No Struq block on the page: pick the hero, the sections and the footer from blocks/CATALOG.md (or struq_blokken) and install them with blocks.mjs add.");
|
|
366
|
+
|
|
367
|
+
if (/prefers-reduced-motion/.test(all)) add(G, "PASS", "prefers-reduced-motion respected");
|
|
368
|
+
else add(G, "FAIL", "Nothing reads prefers-reduced-motion.");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/* -------------------------------------------------------------------- TYPE */
|
|
372
|
+
{
|
|
373
|
+
const G = "TYPE";
|
|
374
|
+
const fams = new Set();
|
|
375
|
+
let italicAccent = false;
|
|
376
|
+
for (const f of own) {
|
|
377
|
+
for (const m of f.text.matchAll(/fonts\.googleapis\.com\/css2\?family=([A-Za-z0-9+]+)(:[^&"'\s]*)?/g)) {
|
|
378
|
+
fams.add(m[1].replace(/\+/g, " "));
|
|
379
|
+
if (/ital@1|ital,wght@1/.test(m[2] ?? "")) italicAccent = true;
|
|
380
|
+
}
|
|
381
|
+
for (const m of f.text.matchAll(/api\.fontshare\.com\/v2\/css\?f\[\]=([a-z0-9-]+)/g)) fams.add(m[1]);
|
|
382
|
+
for (const m of f.text.matchAll(/import\s*\{([^}]+)\}\s*from\s*["']next\/font\/google["']/g)) {
|
|
383
|
+
m[1].split(",").map((s) => s.trim()).filter(Boolean).forEach((s) => fams.add(s.replace(/_/g, " ")));
|
|
384
|
+
}
|
|
385
|
+
if (/from\s*["']next\/font\/local["']/.test(f.text)) for (const m of f.text.matchAll(/(\w+)\s*=\s*localFont\(/g)) fams.add(`local:${m[1]}`);
|
|
386
|
+
}
|
|
387
|
+
if (fams.size === 2 || (fams.size === 3 && italicAccent)) add(G, "PASS", `Faces: ${[...fams].join(" + ")}`);
|
|
388
|
+
else if (fams.size === 1) add(G, "WARN", `One face (${[...fams][0]}). A display and a text face, unless the pairing is one family.`);
|
|
389
|
+
else if (fams.size === 0) add(G, "FAIL", "No web fonts loaded: canon.mjs type <pairing> writes app/fonts.ts, and the layout links each URL.");
|
|
390
|
+
else add(G, "FAIL", `${fams.size} faces loaded (${[...fams].join(", ")}). One display, one text, at most one italic accent.`);
|
|
391
|
+
|
|
392
|
+
const css = files.filter((f) => f.rel.endsWith(".css")).map((f) => f.text).join("\n");
|
|
393
|
+
const ownCss = own.filter((f) => f.rel.endsWith(".css")).map((f) => f.text).join("\n");
|
|
394
|
+
const weight = (ownCss.match(/--display-weight:\s*(\d{3})/) ?? css.match(/--display-weight:\s*(\d{3})/))?.[1];
|
|
395
|
+
const typeId = plan.match(/^Type:\s*([\w-]+)/m)?.[1];
|
|
396
|
+
const poster = CANON.pairings.find((t) => t.id === typeId && String(t.displayScale?.weight) === weight && Number(weight) > 400);
|
|
397
|
+
if (weight && Number(weight) <= 400) add(G, "PASS", `Display runs light (${weight})`);
|
|
398
|
+
else if (poster) add(G, "PASS", `Display at ${poster.id}'s poster weight (${weight})`);
|
|
399
|
+
else if (weight) add(G, "FAIL", `Display weight ${weight}: run it light (300, or 400 for a face that starts there); heavier only at a poster pairing's own weight.`);
|
|
400
|
+
else add(G, "WARN", "No --display-weight found: the STRUQ TOKENS block sets it (canon.mjs type).");
|
|
401
|
+
|
|
402
|
+
if (/html\s*\{[^}]*font-size:\s*clamp\(/.test(css)) add(G, "PASS", "One fluid root-rem knob");
|
|
403
|
+
else add(G, "FAIL", "No fluid root font size: html { font-size: clamp(1rem, 0.92rem + 0.2vw, 1.125rem); }");
|
|
404
|
+
|
|
405
|
+
const h1 = files.reduce((n, f) => n + (f.text.match(/<h1\b|<SplitHeadline(?![^>]*as=["'](h2|h3|p)["'])[^>]*>/g) ?? []).length, 0);
|
|
406
|
+
if (h1 >= 1) add(G, "PASS", "The page has its h1");
|
|
407
|
+
else add(G, "FAIL", "No h1 on the page.");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/* -------------------------------------------------------------------- PLAN */
|
|
411
|
+
{
|
|
412
|
+
const G = "PLAN";
|
|
413
|
+
if (!plan) add(G, "FAIL", "No STRUQ.md in the project root: write the plan and the Build checklist (SKILL.md).");
|
|
414
|
+
else {
|
|
415
|
+
const open = plan.split(/\r?\n/).filter((l) => /^\s*[-*]\s*\[\s\]/.test(l) && !/check\.mjs/.test(l));
|
|
416
|
+
const done = plan.split(/\r?\n/).filter((l) => /^\s*[-*]\s*\[[xX]\]/.test(l)).length;
|
|
417
|
+
if (open.length) add(G, "FAIL", `${open.length} Build checklist item(s) not ticked in STRUQ.md. Do them, then tick them.`, open.map((l) => l.trim()));
|
|
418
|
+
else if (done >= 7) add(G, "PASS", `Build checklist ticked (${done} items)`);
|
|
419
|
+
else add(G, "FAIL", "STRUQ.md has no Build checklist: copy it from SKILL.md and tick each step as you finish it.");
|
|
420
|
+
const need = ["Palette", "Type", "Images", "Order"].filter((k) => !new RegExp(`^${k}:\\s*\\S`, "mi").test(plan));
|
|
421
|
+
if (need.length) add(G, "FAIL", `STRUQ.md does not record: ${need.map((k) => `${k}:`).join(", ")} (SKILL.md, the plan).`);
|
|
422
|
+
else add(G, "PASS", "Direction and order recorded (palette, type, images, order)");
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/* -------------------------------------------------------------------- HERO */
|
|
427
|
+
{
|
|
428
|
+
const G = "HERO";
|
|
429
|
+
const heroBlock = usesKind("heroes") || usedBlocks.has("showpieces/hero-opener");
|
|
430
|
+
const noFit = /imageFit[^=]*=\s*"none"/.test(read("lib/struq-images.ts") + read("src/lib/struq-images.ts"));
|
|
431
|
+
if (heroBlock) {
|
|
432
|
+
add(G, "PASS", "A hero block opens the page");
|
|
433
|
+
const heroUse = own.flatMap((f) => [...f.text.matchAll(/<(Hero[A-Z]\w*)\b[^>]*?\/?>/gs)].map((m) => ({ f, tag: m[0] })));
|
|
434
|
+
const bare = noFit ? [] : heroUse.filter(({ tag }) => /<Hero(Framed|Split|Index|Still|Poster)\b/.test(tag) && !/\bimages?=\{|\bimage=\{/.test(tag));
|
|
435
|
+
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));
|
|
436
|
+
} else {
|
|
437
|
+
// A hand-built hero: a photo in it and a display line at poster size.
|
|
438
|
+
const heroFile = own.find((f) => /<h1\b|<SplitHeadline\b/.test(f.text));
|
|
439
|
+
if (!heroFile) add(G, "FAIL", "No hero: open the page with a hero block (blocks/CATALOG.md, Heroes).");
|
|
440
|
+
else {
|
|
441
|
+
const at = heroFile.text.search(/<h1\b|<SplitHeadline\b/);
|
|
442
|
+
const slice = heroFile.text.slice(Math.max(0, heroFile.text.lastIndexOf("<section", at)), heroFile.text.indexOf("</section>", at) + 1 || undefined);
|
|
443
|
+
if (!noFit && !/images\.hero\b|<MediaFrame\b|\bimage=\{/.test(slice)) add(G, "FAIL", "Hero without a photo: a hand-built hero shows images.hero[0] (or the client's photo). Or use a hero block.", [heroFile.rel]);
|
|
444
|
+
const vw = [...(slice.match(/<(?:h1|SplitHeadline)\b[^>]*>/)?.[0] ?? "").matchAll(/text-\[(\d+(?:\.\d+)?)vw\]/g)].map((m) => Number(m[1]));
|
|
445
|
+
if (!vw.length || Math.max(...vw) < 10) add(G, "FAIL", "Hand-built hero line under 10vw: set it at 12 to 20vw, or use a hero block.", [heroFile.rel]);
|
|
446
|
+
else add(G, "PASS", "The hand-built hero line runs at poster size");
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (usesKind("headers") || own.some((f) => /data-wordmark/.test(f.text))) add(G, "PASS", "A header with the wordmark");
|
|
450
|
+
else add(G, "FAIL", "No header: use header-bar or menu-curtain, or mark your own header's name data-wordmark.");
|
|
451
|
+
if (usesKind("footers") || usedBlocks.has("showpieces/wordmark-finale")) add(G, "PASS", "A designed footer block closes the page");
|
|
452
|
+
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.");
|
|
453
|
+
|
|
454
|
+
const scenes = own.filter((f) => !f.rel.endsWith(".css")).reduce((n, f) => n + (f.text.match(/\bscene-(inverse|field)\b/g) ?? []).length, 0);
|
|
455
|
+
if (scenes === 0) add(G, "FAIL", "No contrast scene: wrap one section in scene-inverse or scene-field, so the page does not stay on one ground.");
|
|
456
|
+
else if (scenes > 2) add(G, "FAIL", `${scenes} contrast scenes: one, at most two, or the contrast stops being a moment.`);
|
|
457
|
+
else add(G, "PASS", `${scenes} contrast scene${scenes > 1 ? "s" : ""}`);
|
|
458
|
+
|
|
459
|
+
const geometry = hits(/new\s+THREE\.(Box|Cylinder|Torus|Sphere|Capsule|Cone|Tube|Lathe|Extrude|Tetrahedron|Octahedron|Icosahedron)Geometry\b|<(box|cylinder|torus|sphere|capsule|cone|tube|lathe|extrude)Geometry\b/, { scope: own });
|
|
460
|
+
if (geometry.length) add(G, "FAIL", "Hand-built product geometry: never model the client's product from primitives. Show the product photography.", geometry);
|
|
461
|
+
else add(G, "PASS", "No product modelled from primitives");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/* --------------------------------------------------------------------- LOG */
|
|
465
|
+
// Every site keeps STRUQ-LOG.md: an entry for each completed checklist step. Step 8 (this check) writes its own entry
|
|
466
|
+
// when this run ends, so it is not required beforehand.
|
|
467
|
+
{
|
|
468
|
+
const G = "LOG";
|
|
469
|
+
const log = read("STRUQ-LOG.md");
|
|
470
|
+
if (!log) add(G, "FAIL", "No STRUQ-LOG.md. Start it at the first step: node <skill-dir>/scripts/log.mjs \"Step 1 Project\" \"<what>\" \"<why>\".");
|
|
471
|
+
else {
|
|
472
|
+
const ticked = (plan.match(/^\s*[-*]\s*\[[xX]\]\s*(\d+)/gm) ?? []).map((l) => Number(l.match(/(\d+)\s*$/)[1])).filter((n) => n !== 8);
|
|
473
|
+
const missing = ticked.filter((n) => !new RegExp(`^## Step ${n}\\b`, "m").test(log));
|
|
474
|
+
const entries = (log.match(/^## /gm) ?? []).length;
|
|
475
|
+
const approval = log.match(/^## Step 2 Approval[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m);
|
|
476
|
+
if (!approval || !/^Reply:\s*["“‘'].+["”’']\s*$/m.test(approval[1])) {
|
|
477
|
+
add(G, "FAIL", 'No Step 2 Approval entry with the user\'s own reply. The build request is not the approval: stop once, ask "Akkoord? Heb je een logo, foto\'s of echte cijfers, stuur ze nu.", then log the answer word for word: node <skill-dir>/scripts/log.mjs "Step 2 Approval" "Asked: Akkoord? ..." "Reply: \"<their words>\"".');
|
|
478
|
+
} else add(G, "PASS", "The approval stop is logged with the user's reply");
|
|
479
|
+
if (missing.length) add(G, "FAIL", `STRUQ-LOG.md has no entry for ticked step(s) ${missing.join(", ")}. Add one per step with log.mjs: what was done and why.`);
|
|
480
|
+
else add(G, "PASS", `STRUQ-LOG.md has ${entries} entries, one or more for every ticked step`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/* ------------------------------------------------------------------ REPORT */
|
|
485
|
+
const order = ["IMAGES", "TELLS", "MOTION", "HERO", "TYPE", "PLAN", "LOG"];
|
|
486
|
+
console.log(`Struq check: ${root}\n`);
|
|
487
|
+
for (const g of order) {
|
|
488
|
+
const rows = results.filter((r) => r.group === g);
|
|
489
|
+
if (!rows.length) continue;
|
|
490
|
+
console.log(g);
|
|
491
|
+
for (const r of rows) {
|
|
492
|
+
console.log(` ${r.status.padEnd(4)} ${r.message}`);
|
|
493
|
+
for (const w of r.where.slice(0, 8)) console.log(` ${w}`);
|
|
494
|
+
if (r.where.length > 8) console.log(` ... and ${r.where.length - 8} more`);
|
|
495
|
+
}
|
|
496
|
+
console.log("");
|
|
497
|
+
}
|
|
498
|
+
const fails = results.filter((r) => r.status === "FAIL").length;
|
|
499
|
+
const warns = results.filter((r) => r.status === "WARN").length;
|
|
500
|
+
if (!args.includes("--no-log")) {
|
|
501
|
+
const names = (status) => results.filter((r) => r.status === status).map((r) => `${r.group} ${r.message.split(":")[0].replace(/[.(].*$/, "").trim()}`);
|
|
502
|
+
const passed = results.filter((r) => r.status === "PASS").length;
|
|
503
|
+
appendLog(root, "Step 8 Check", [
|
|
504
|
+
`check.mjs: RESULT ${fails ? "FAIL" : "PASS"}, ${passed} pass, ${fails} fail, ${warns} warn.`,
|
|
505
|
+
...(fails ? [`Fail: ${names("FAIL").join("; ")}.`] : []),
|
|
506
|
+
...(warns ? [`Warn: ${names("WARN").join("; ")}.`] : []),
|
|
507
|
+
]);
|
|
508
|
+
}
|
|
509
|
+
if (fails) {
|
|
510
|
+
console.log(`RESULT: FAIL (${fails} fail, ${warns} warn). Fix every FAIL, run the check again, and only then report done.`);
|
|
511
|
+
process.exit(1);
|
|
512
|
+
}
|
|
513
|
+
console.log(`RESULT: PASS${warns ? ` (${warns} warn: read them, fix what applies)` : ""}`);
|