@struq/skill 0.2.1 → 0.3.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 +15 -5
- package/package.json +4 -4
- package/skills/struq/SKILL.md +304 -200
- package/skills/struq/cinematic/showpieces/README.md +18 -0
- package/skills/struq/cinematic/showpieces/toonstuk-fill-rows.tsx +74 -0
- package/skills/struq/cinematic/showpieces/toonstuk-hero-opener.tsx +128 -0
- package/skills/struq/cinematic/showpieces/toonstuk-image-to-fullbleed.tsx +78 -0
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +81 -0
- package/skills/struq/cinematic/showpieces/toonstuk-panel-wipe.tsx +89 -0
- package/skills/struq/cinematic/showpieces/toonstuk-pixel-resolve.tsx +107 -0
- package/skills/struq/cinematic/showpieces/toonstuk-project-stack.tsx +86 -0
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +78 -0
- package/skills/struq/cinematic/showpieces/toonstuk-wordmark-finale.tsx +105 -0
- package/skills/struq/recipes/app/fonts.ts +22 -0
- package/skills/struq/recipes/app/globals.css +185 -0
- package/skills/struq/recipes/app/layout.tsx +28 -0
- package/skills/struq/recipes/components/struq/Cursor.tsx +117 -0
- package/skills/struq/recipes/components/struq/HeroCanvas.tsx +278 -0
- package/skills/struq/recipes/components/struq/HorizontalGallery.tsx +106 -0
- package/skills/struq/recipes/components/struq/MagneticButton.tsx +92 -0
- package/skills/struq/recipes/components/struq/MediaFrame.tsx +96 -0
- package/skills/struq/recipes/components/struq/PinnedZoom.tsx +79 -0
- package/skills/struq/recipes/components/struq/Reveal.tsx +64 -0
- package/skills/struq/recipes/components/struq/SmoothScroll.tsx +57 -0
- package/skills/struq/recipes/components/struq/SplitHeadline.tsx +68 -0
- package/skills/struq/recipes/components/struq/scenes/contours.ts +161 -0
- package/skills/struq/recipes/components/struq/scenes/dots.ts +110 -0
- package/skills/struq/recipes/components/struq/scenes/ripple.ts +99 -0
- package/skills/struq/recipes/components/struq/scenes/types.ts +114 -0
- package/skills/struq/recipes/lib/gsap.ts +21 -0
- package/skills/struq/references/anti-patterns.md +71 -6
- package/skills/struq/references/baseline-prompt.md +18 -14
- package/skills/struq/references/brief-router.md +7 -8
- package/skills/struq/references/composition.md +0 -1
- package/skills/struq/references/depth-parallax.md +1 -1
- package/skills/struq/references/direction.md +5 -5
- package/skills/struq/references/hero-scenes.md +107 -0
- package/skills/struq/references/iconography.md +1 -1
- package/skills/struq/references/imagery.md +10 -29
- package/skills/struq/references/micro-interactions.md +1 -1
- package/skills/struq/references/mobile-composition.md +1 -1
- package/skills/struq/references/niche-packets.md +338 -0
- package/skills/struq/references/page-architecture.md +2 -2
- package/skills/struq/references/pinning-scrub.md +3 -4
- package/skills/struq/references/scroll-reveals.md +1 -2
- package/skills/struq/references/sections.md +141 -0
- package/skills/struq/references/seo.md +2 -2
- package/skills/struq/references/showcase.md +2 -2
- package/skills/struq/references/svg-motion.md +390 -390
- package/skills/struq/references/typography.md +39 -39
- package/skills/struq/references/vibe-presets.md +5 -5
- package/skills/struq/references/webgl-scenes.md +593 -593
- package/skills/struq/scripts/canon.mjs +309 -0
- package/skills/struq/scripts/check.mjs +547 -0
- package/skills/struq/scripts/font-names.mjs +40 -0
- package/skills/struq/scripts/images.mjs +360 -0
- package/skills/struq/scripts/log.mjs +92 -0
- package/skills/struq/scripts/packs.mjs +156 -0
- package/skills/struq/scripts/recipes.mjs +135 -0
- /package/{bin → cli}/struq.mjs +0 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Struq canon: Struq's own palettes, type pairings and design systems, written into the project.
|
|
3
|
+
// This is what keeps a Struq build off the defaults every AI falls back on.
|
|
4
|
+
//
|
|
5
|
+
// node <skill-dir>/scripts/canon.mjs list every palette, pairing, system and pack
|
|
6
|
+
// node <skill-dir>/scripts/canon.mjs palette <slug> writes the STRUQ TOKENS block in globals.css
|
|
7
|
+
// node <skill-dir>/scripts/canon.mjs type <slug> writes app/fonts.ts (Google or Fontshare)
|
|
8
|
+
// node <skill-dir>/scripts/canon.mjs type --display "Fraunces" --body "Urbanist"
|
|
9
|
+
// node <skill-dir>/scripts/canon.mjs system <slug> prints the section order and its refs
|
|
10
|
+
// add --dir <project> to run from elsewhere
|
|
11
|
+
//
|
|
12
|
+
// Public API, no account: https://struq.nl/api/assets?filter=palette|typography|design_system
|
|
13
|
+
// Node 18 or later, no dependencies. Google faces load through next/font/google (checked against the
|
|
14
|
+
// project's own next/font data); faces that are not on Google come from Fontshare as local woff2.
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
17
|
+
import { googleName } from "./font-names.mjs";
|
|
18
|
+
import { appendLog } from "./log.mjs";
|
|
19
|
+
|
|
20
|
+
// STRUQ_API_BASE points a test run at another Struq environment (for example https://staging.struq.nl).
|
|
21
|
+
// Real users never set it: the default is production.
|
|
22
|
+
const API = `${(process.env.STRUQ_API_BASE || "https://struq.nl").replace(/\/+$/, "")}/api`;
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
const opt = (name) => {
|
|
25
|
+
const i = args.indexOf(name);
|
|
26
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
27
|
+
};
|
|
28
|
+
const root = resolve(opt("--dir") ?? process.cwd());
|
|
29
|
+
const [cmd, slugArg] = args.filter((a, i) => !a.startsWith("--") && !["--dir", "--display", "--body", "--accent"].includes(args[i - 1]));
|
|
30
|
+
|
|
31
|
+
// Struq's own rules win over a canon entry: these faces never ship (SKILL.md, the tell list).
|
|
32
|
+
const BANNED = new Set(["Inter", "Roboto", "Arial", "Helvetica", "Open Sans", "Poppins", "Montserrat", "Lato", "Geist", "Space Grotesk", "Geist Mono", "JetBrains Mono", "Space Mono", "IBM Plex Mono", "Roboto Mono"]);
|
|
33
|
+
// The text face that replaces a banned one, by the display face it sits under.
|
|
34
|
+
const BODY_FOR = { Fraunces: "Urbanist", Newsreader: "Manrope", Sora: "Instrument Sans", Syne: "Hanken Grotesk", Manrope: "Figtree" };
|
|
35
|
+
// Commercial faces in the canon, and their free stand-ins.
|
|
36
|
+
const STAND_IN = { "Neue Haas Grotesk Text": "General Sans", "Neue Haas Grotesk": "General Sans", "PP Neue Montreal": "General Sans", "Söhne": "General Sans", Canela: "Newsreader", "PP Editorial New": "Instrument Serif", "GT Sectra": "Fraunces" };
|
|
37
|
+
|
|
38
|
+
async function getJson(url) {
|
|
39
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(20000), headers: { accept: "application/json" } });
|
|
40
|
+
if (!res.ok) throw new Error(`${url} answered ${res.status}`);
|
|
41
|
+
return res.json();
|
|
42
|
+
}
|
|
43
|
+
async function canon(filter) {
|
|
44
|
+
const all = [];
|
|
45
|
+
for (let page = 1; page <= 10; page++) {
|
|
46
|
+
const d = await getJson(`${API}/assets?filter=${filter}&page=${page}`);
|
|
47
|
+
all.push(...(d.items ?? []));
|
|
48
|
+
if (!d.hasMore) break;
|
|
49
|
+
}
|
|
50
|
+
return all;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseOklch(v) {
|
|
54
|
+
const m = String(v).match(/oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)/i);
|
|
55
|
+
if (!m) return null;
|
|
56
|
+
const L = m[1].endsWith("%") ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
|
|
57
|
+
return { L, C: parseFloat(m[2]), H: parseFloat(m[3]) };
|
|
58
|
+
}
|
|
59
|
+
function hexLightness(v) {
|
|
60
|
+
const m = String(v).match(/^#([0-9a-f]{6})$/i);
|
|
61
|
+
if (!m) return null;
|
|
62
|
+
const [r, g, b] = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16) / 255);
|
|
63
|
+
return (Math.max(r, g, b) + Math.min(r, g, b)) / 2;
|
|
64
|
+
}
|
|
65
|
+
const lightness = (v) => parseOklch(v)?.L ?? hexLightness(v) ?? 0.5;
|
|
66
|
+
const isCool = (v) => {
|
|
67
|
+
const o = parseOklch(v);
|
|
68
|
+
return o ? o.C > 0.06 && o.H >= 220 && o.H <= 320 : false;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/* ------------------------------------------------------------------ list */
|
|
72
|
+
async function list() {
|
|
73
|
+
const [palettes, types, systems] = await Promise.all([canon("palette"), canon("typography"), canon("design_system")]);
|
|
74
|
+
console.log(`PALETTES (${palettes.length}): slug, mood, ground, accent`);
|
|
75
|
+
for (const p of palettes) {
|
|
76
|
+
const c = p.data?.colors ?? {};
|
|
77
|
+
const ground = lightness(c.background) > 0.5 ? "light" : "dark";
|
|
78
|
+
const note = isCool(c.accent) ? " (blue or purple accent: only for a brand that is that colour)" : "";
|
|
79
|
+
console.log(` ${p.slug.padEnd(38)} ${(p.data?.mood ?? "").padEnd(36)} ${ground.padEnd(6)}${note}`);
|
|
80
|
+
}
|
|
81
|
+
console.log(`\nPAIRINGS (${types.length}): slug, display + body as Struq builds them`);
|
|
82
|
+
for (const t of types) {
|
|
83
|
+
const plan = planType(t.data.display.name, t.data.body.name);
|
|
84
|
+
console.log(` ${t.slug.padEnd(28)} ${plan.display} + ${plan.body}${plan.notes.length ? ` (${plan.notes.join("; ")})` : ""}`);
|
|
85
|
+
}
|
|
86
|
+
console.log(`\nDESIGN SYSTEMS (${systems.length}): slug, section order`);
|
|
87
|
+
for (const s of systems) console.log(` ${s.slug.padEnd(22)} ${(s.data?.silhouette?.sectionOrder ?? []).join(" > ")}`);
|
|
88
|
+
try {
|
|
89
|
+
const packs = await getJson(`${API}/media/packs`);
|
|
90
|
+
console.log(`\nBEELDBANK PACKS (${packs.packCount}): use with images.mjs`);
|
|
91
|
+
for (const p of packs.packs) console.log(` ${p.slug.padEnd(10)} ${p.description.slice(0, 100)}`);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.log(`\nBEELDBANK: ${e.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* --------------------------------------------------------------- palette */
|
|
98
|
+
async function palette(slug) {
|
|
99
|
+
const items = await canon("palette");
|
|
100
|
+
const p = items.find((i) => i.slug === slug);
|
|
101
|
+
if (!p) throw new Error(`Unknown palette "${slug}". Run: canon.mjs list`);
|
|
102
|
+
const c = p.data.colors;
|
|
103
|
+
const dark = lightness(c.background) < 0.5;
|
|
104
|
+
const accentInk = lightness(c.accent) > 0.62 ? (dark ? c.background : c.text) : dark ? c.text : c.background;
|
|
105
|
+
const cssPath = ["app/globals.css", "src/app/globals.css"].map((f) => join(root, f)).find(existsSync);
|
|
106
|
+
if (!cssPath) throw new Error("No app/globals.css. Run recipes.mjs first.");
|
|
107
|
+
let css = readFileSync(cssPath, "utf8");
|
|
108
|
+
const weight = css.match(/--display-weight:\s*(\d{3})/)?.[1] ?? "300";
|
|
109
|
+
const block = `:root {
|
|
110
|
+
/* canon palette: ${p.slug} (${p.data.mood}) */
|
|
111
|
+
--surface-0: ${c.background}; /* the page */
|
|
112
|
+
--surface-1: ${c.surface}; /* one step: panels, the showpiece ground */
|
|
113
|
+
--surface-2: color-mix(in oklch, var(--surface-1), var(--ink) ${dark ? 9 : 7}%); /* image wells, hover grounds */
|
|
114
|
+
--surface-3: color-mix(in oklch, var(--surface-1), var(--ink) ${dark ? 18 : 16}%); /* hairline accents, never text */
|
|
115
|
+
--ink: ${c.text};
|
|
116
|
+
--ink-muted: color-mix(in oklch, var(--ink) ${dark ? 70 : 66}%, var(--surface-0)); /* secondary text */
|
|
117
|
+
--accent: ${c.accent}; /* the one accent: the key CTA, one highlight per screen */
|
|
118
|
+
--accent-ink: ${accentInk}; /* text on the accent */
|
|
119
|
+
--accent-2: ${c.accentAlt ?? c.surface}; /* a second ground, never a second highlight colour */
|
|
120
|
+
--line: color-mix(in oklch, var(--ink) 13%, transparent); /* hairlines */
|
|
121
|
+
--on-photo: ${dark ? c.text : c.background}; /* type over a photo, with a scrim */
|
|
122
|
+
/* the contrast scene: the palette's inverse and field roles when it has them, else text and ground swapped */
|
|
123
|
+
--inverse: ${c.inverse ?? (dark ? c.surface : c.text)};
|
|
124
|
+
--on-inverse: ${c.onInverse ?? c.inverseText ?? (dark ? c.text : c.background)};
|
|
125
|
+
--field: ${c.field ?? c.accent};
|
|
126
|
+
--on-field: ${c.onField ?? c.fieldText ?? accentInk};
|
|
127
|
+
--display-weight: ${weight};
|
|
128
|
+
}`;
|
|
129
|
+
const re = /(STRUQ TOKENS[\s\S]*?\*\/\s*):root\s*\{[\s\S]*?\n\}/;
|
|
130
|
+
if (!re.test(css)) throw new Error(`No STRUQ TOKENS block in ${cssPath}. Run recipes.mjs, or merge the block from recipes/app/globals.css.`);
|
|
131
|
+
css = css.replace(re, `$1${block}`);
|
|
132
|
+
if (!css.includes("--color-accent-2")) css = css.replace(" --color-line: var(--line);", " --color-line: var(--line);\n --color-accent-2: var(--accent-2);");
|
|
133
|
+
// Projects started before the contrast scene existed get its colour tokens too.
|
|
134
|
+
if (!css.includes("--color-inverse")) {
|
|
135
|
+
css = css.replace(" --color-line: var(--line);", " --color-line: var(--line);\n --color-inverse: var(--inverse);\n --color-on-inverse: var(--on-inverse);\n --color-field: var(--field);\n --color-on-field: var(--on-field);");
|
|
136
|
+
}
|
|
137
|
+
writeFileSync(cssPath, css);
|
|
138
|
+
console.log(`Wrote palette ${p.slug} to ${cssPath.replace(root, ".")}`);
|
|
139
|
+
if (isCool(c.accent)) console.log('NOTE blue or purple accent: write "Cool hue: declared because <the brand colour>" in STRUQ.md, or pick a warmer palette.');
|
|
140
|
+
console.log(`STRUQ.md line: Palette: ${p.slug} (${p.data.mood})`);
|
|
141
|
+
appendLog(root, "Step 4 Canon palette", [
|
|
142
|
+
`canon.mjs wrote palette ${p.slug} (${p.data.mood}) into the STRUQ TOKENS block.`,
|
|
143
|
+
`Ground ${dark ? "dark" : "light"}, accent ${c.accent}.${isCool(c.accent) ? " Blue or purple accent: needs a Cool hue declaration." : ""}`,
|
|
144
|
+
]);
|
|
145
|
+
console.log("OK palette written");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ------------------------------------------------------------------ type */
|
|
149
|
+
function planType(display, body) {
|
|
150
|
+
const notes = [];
|
|
151
|
+
let d = display;
|
|
152
|
+
let b = body;
|
|
153
|
+
if (STAND_IN[d]) notes.push(`${d} is commercial, ${STAND_IN[d]} stands in`), (d = STAND_IN[d]);
|
|
154
|
+
if (STAND_IN[b]) notes.push(`${b} is commercial, ${STAND_IN[b]} stands in`), (b = STAND_IN[b]);
|
|
155
|
+
if (BANNED.has(d)) notes.push(`${d} is off the Struq list: pick another pairing`), (d = null);
|
|
156
|
+
if (BANNED.has(b)) {
|
|
157
|
+
const swap = BODY_FOR[d] ?? "Urbanist";
|
|
158
|
+
notes.push(`${b} is off the Struq list, ${swap} replaces it`);
|
|
159
|
+
b = swap;
|
|
160
|
+
}
|
|
161
|
+
return { display: d ?? "(none)", body: b, notes, ok: Boolean(d) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function googleData() {
|
|
165
|
+
const p = join(root, "node_modules", "next", "dist", "compiled", "@next", "font", "dist", "google", "font-data.json");
|
|
166
|
+
return existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function fontshare(name, weights) {
|
|
170
|
+
const slug = name.toLowerCase().replace(/\s+/g, "-");
|
|
171
|
+
const css = await fetch(`https://api.fontshare.com/v2/css?f[]=${slug}@${weights.join(",")}&display=swap`, { signal: AbortSignal.timeout(20000) }).then((r) => (r.ok ? r.text() : ""));
|
|
172
|
+
const faces = [...css.matchAll(/@font-face\s*\{([\s\S]*?)\}/g)].map((m) => ({
|
|
173
|
+
url: m[1].match(/url\('([^']+\.woff2)'\)/)?.[1],
|
|
174
|
+
weight: m[1].match(/font-weight:\s*(\d+)/)?.[1],
|
|
175
|
+
style: m[1].match(/font-style:\s*(\w+)/)?.[1] ?? "normal",
|
|
176
|
+
})).filter((f) => f.url && f.weight);
|
|
177
|
+
if (!faces.length) return null;
|
|
178
|
+
const dir = join(root, existsSync(join(root, "src", "app")) ? "src/app" : "app", "fonts");
|
|
179
|
+
mkdirSync(dir, { recursive: true });
|
|
180
|
+
const src = [];
|
|
181
|
+
for (const f of faces) {
|
|
182
|
+
const file = `${slug}-${f.weight}${f.style === "italic" ? "-italic" : ""}.woff2`;
|
|
183
|
+
const res = await fetch(f.url.startsWith("//") ? `https:${f.url}` : f.url, { signal: AbortSignal.timeout(30000) });
|
|
184
|
+
if (!res.ok) continue;
|
|
185
|
+
writeFileSync(join(dir, file), Buffer.from(await res.arrayBuffer()));
|
|
186
|
+
src.push({ path: `./fonts/${file}`, weight: f.weight, style: f.style });
|
|
187
|
+
}
|
|
188
|
+
return src.length ? src : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function faceCode(canonName, role, google) {
|
|
192
|
+
const variable = `--face-${role}`;
|
|
193
|
+
const id = role;
|
|
194
|
+
// The canon's name ("Big Shoulders Display") may be listed by Google under its family ("Big Shoulders").
|
|
195
|
+
const name = googleName(canonName, google) ?? canonName;
|
|
196
|
+
const g = google?.[name];
|
|
197
|
+
if (g) {
|
|
198
|
+
const ident = name.replace(/\s+/g, "_");
|
|
199
|
+
const parts = [`subsets: ["latin"]`];
|
|
200
|
+
const italicOnly = role === "accent";
|
|
201
|
+
if (!g.weights.includes("variable")) {
|
|
202
|
+
const wanted = g.weights.filter((w) => ["300", "400", "500"].includes(w));
|
|
203
|
+
parts.push(`weight: ${JSON.stringify(wanted.length ? wanted : [g.weights[0]])}`);
|
|
204
|
+
}
|
|
205
|
+
const axes = (g.axes ?? []).map((a) => a.tag).filter((t) => ["opsz", "SOFT"].includes(t));
|
|
206
|
+
if (axes.length && g.weights.includes("variable")) parts.push(`axes: ${JSON.stringify(axes)}`);
|
|
207
|
+
if (italicOnly && g.styles.includes("italic")) parts.push(`style: ["italic"]`);
|
|
208
|
+
else if (role === "display" && g.styles.includes("italic")) parts.push(`style: ["normal", "italic"]`);
|
|
209
|
+
parts.push(`variable: "${variable}"`, `display: "swap"`);
|
|
210
|
+
const light = g.weights.includes("300") ? 300 : g.axes?.find((a) => a.tag === "wght")?.min <= 300 ? 300 : 400;
|
|
211
|
+
return { kind: "google", id, ident, family: name, code: `export const ${id} = ${ident}({ ${parts.join(", ")} });`, light };
|
|
212
|
+
}
|
|
213
|
+
const local = await fontshare(canonName, role === "accent" ? ["400"] : ["300", "400", "500"]);
|
|
214
|
+
if (local) {
|
|
215
|
+
const light = local.some((s) => s.weight === "300") ? 300 : 400;
|
|
216
|
+
const src = local.map((s) => `{ path: "${s.path}", weight: "${s.weight}", style: "${s.style}" }`).join(", ");
|
|
217
|
+
return { kind: "local", id, family: canonName, code: `export const ${id} = localFont({ src: [${src}], variable: "${variable}", display: "swap" });`, light };
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function type(slug) {
|
|
223
|
+
let display = opt("--display");
|
|
224
|
+
let body = opt("--body");
|
|
225
|
+
let accent = opt("--accent");
|
|
226
|
+
let label = "custom";
|
|
227
|
+
if (slug) {
|
|
228
|
+
const items = await canon("typography");
|
|
229
|
+
const t = items.find((i) => i.slug === slug);
|
|
230
|
+
if (!t) throw new Error(`Unknown pairing "${slug}". Run: canon.mjs list`);
|
|
231
|
+
display = t.data.display.name;
|
|
232
|
+
body = t.data.body.name;
|
|
233
|
+
accent = t.data.accent?.name;
|
|
234
|
+
label = t.slug;
|
|
235
|
+
}
|
|
236
|
+
if (!display || !body) throw new Error('Usage: canon.mjs type <slug>, or type --display "Name" --body "Name" [--accent "Name"]');
|
|
237
|
+
const plan = planType(display, body);
|
|
238
|
+
for (const n of plan.notes) console.log(`NOTE ${n}`);
|
|
239
|
+
if (!plan.ok) throw new Error("The display face is off the Struq list. Pick another pairing (canon.mjs list).");
|
|
240
|
+
const google = googleData();
|
|
241
|
+
if (!google) console.log("NOTE next is not installed here, so Google faces cannot be checked; install first for a sure result.");
|
|
242
|
+
const d = await faceCode(plan.display, "display", google);
|
|
243
|
+
const b = await faceCode(plan.body, "body", google);
|
|
244
|
+
if (!d || !b) throw new Error(`Could not load ${!d ? plan.display : plan.body} from Google Fonts or Fontshare. Pick another pairing.`);
|
|
245
|
+
// The italic accent: its own face when the canon names one apart from display and body; the
|
|
246
|
+
// display face's italic otherwise (font-accent falls back to it in globals.css).
|
|
247
|
+
const accentName = accent && !BANNED.has(accent) && accent !== plan.display && accent !== plan.body ? accent : null;
|
|
248
|
+
const a = accentName ? await faceCode(accentName, "accent", google) : null;
|
|
249
|
+
if (accentName && !a) console.log(`NOTE the accent face ${accentName} could not be loaded; the display italic carries the accent word.`);
|
|
250
|
+
const faces = [d, b, a].filter(Boolean);
|
|
251
|
+
const imports = [];
|
|
252
|
+
const gIdents = faces.filter((f) => f.kind === "google").map((f) => f.ident);
|
|
253
|
+
if (gIdents.length) imports.push(`import { ${[...new Set(gIdents)].join(", ")} } from "next/font/google";`);
|
|
254
|
+
if (faces.some((f) => f.kind === "local")) imports.push(`import localFont from "next/font/local";`);
|
|
255
|
+
const file = join(root, existsSync(join(root, "src", "app")) ? "src/app" : "app", "fonts.ts");
|
|
256
|
+
writeFileSync(
|
|
257
|
+
file,
|
|
258
|
+
`// app/fonts.ts: written by the Struq skill (scripts/canon.mjs type). Pairing: ${label}, ${d.family} + ${b.family}${a ? `, accent ${a.family} italic` : ""}.
|
|
259
|
+
// Display and body, plus at most the one italic accent face. globals.css maps --face-display to
|
|
260
|
+
// font-display, --face-body to font-body and --face-accent to font-accent (the display italic when absent).
|
|
261
|
+
${imports.join("\n")}
|
|
262
|
+
|
|
263
|
+
${faces.map((f) => f.code).join("\n\n")}
|
|
264
|
+
|
|
265
|
+
/** Every face's CSS variable, for the <html> className in app/layout.tsx. */
|
|
266
|
+
export const fontVariables = [${faces.map((f) => f.id).join(", ")}].map((f) => f.variable).join(" ");
|
|
267
|
+
`,
|
|
268
|
+
);
|
|
269
|
+
const cssPath = ["app/globals.css", "src/app/globals.css"].map((f) => join(root, f)).find(existsSync);
|
|
270
|
+
if (cssPath) {
|
|
271
|
+
let css = readFileSync(cssPath, "utf8").replace(/--display-weight:\s*\d{3}/, `--display-weight: ${d.light}`);
|
|
272
|
+
if (!css.includes("--font-accent")) {
|
|
273
|
+
css = css.replace(/(\s*--font-body:[^\n]*\n)/, `$1 --font-accent: var(--face-accent, var(--face-display)), ui-serif, Georgia, serif;\n`);
|
|
274
|
+
}
|
|
275
|
+
writeFileSync(cssPath, css);
|
|
276
|
+
}
|
|
277
|
+
console.log(`Wrote ${file.replace(root, ".")}: ${d.family} (${d.kind}) + ${b.family} (${b.kind})${a ? ` + accent ${a.family} italic (${a.kind})` : ""}, display weight ${d.light}`);
|
|
278
|
+
console.log(`STRUQ.md line: Type: ${label}: ${d.family} + ${b.family}${a ? `, accent ${a.family}` : ""}`);
|
|
279
|
+
appendLog(root, "Step 4 Canon type", [
|
|
280
|
+
`canon.mjs wrote app/fonts.ts: ${d.family} (${d.kind}) over ${b.family} (${b.kind})${a ? `, accent ${a.family} italic` : ""}, pairing ${label}.`,
|
|
281
|
+
`Display weight ${d.light}.${plan.notes.length ? ` Overrides: ${plan.notes.join("; ")}.` : ""}`,
|
|
282
|
+
]);
|
|
283
|
+
console.log("OK type written");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* ---------------------------------------------------------------- system */
|
|
287
|
+
async function system(slug) {
|
|
288
|
+
const items = await canon("design_system");
|
|
289
|
+
const s = items.find((i) => i.slug === slug);
|
|
290
|
+
if (!s) throw new Error(`Unknown design system "${slug}". Run: canon.mjs list`);
|
|
291
|
+
const order = s.data?.silhouette?.sectionOrder ?? [];
|
|
292
|
+
console.log(`${s.slug}: ${order.join(" > ")}`);
|
|
293
|
+
console.log(`refs: palette ${s.data.paletteRef}, typography ${s.data.typographyRef} (your own palette and type picks win when the brief fits them better)`);
|
|
294
|
+
console.log("Struq adds to every order: the hero canvas in the hero, one showpiece at the act break, the designed footer.");
|
|
295
|
+
console.log("proof and story-proof hold only real facts from the brief; faq only questions the brief answers. Drop a section that would need invented content.");
|
|
296
|
+
console.log(`STRUQ.md line: System: ${s.slug} (${order.join(" > ")})`);
|
|
297
|
+
appendLog(root, "Step 2 Canon system", [`canon.mjs read design system ${s.slug}: ${order.join(" > ")}.`]);
|
|
298
|
+
console.log("OK system read");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const run = { list, palette: () => palette(slugArg), type: () => type(slugArg), system: () => system(slugArg) }[cmd];
|
|
302
|
+
if (!run) {
|
|
303
|
+
console.error("Usage: canon.mjs list | palette <slug> | type <slug> | system <slug> [--dir <project>]");
|
|
304
|
+
process.exit(2);
|
|
305
|
+
}
|
|
306
|
+
run().catch((e) => {
|
|
307
|
+
console.error(`FAIL ${e.message}`);
|
|
308
|
+
process.exitCode = 1;
|
|
309
|
+
});
|