@struq/skill 0.5.1 → 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 +137 -100
- package/package.json +5 -3
- 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,14 +7,56 @@
|
|
|
7
7
|
//
|
|
8
8
|
// <ManifestoScrub text="Wij koken met wat de zee en het land die week geven, en verder niets." />
|
|
9
9
|
//
|
|
10
|
+
// The faint ink is never too faint to read: the words wait at the weakest strength of the ink that still holds 3:1
|
|
11
|
+
// (display-size text) against the ground the section actually has, measured from the palette (about half strength on
|
|
12
|
+
// a light ground, a little less on the inverse), and fill to full ink from there.
|
|
13
|
+
// The section says its ground to the header bar: data-bar-tone "inverse" when a className sets scene-inverse,
|
|
14
|
+
// "ground" otherwise.
|
|
15
|
+
//
|
|
10
16
|
// Reduced motion and phones: the composed still, the whole paragraph in full ink.
|
|
11
17
|
import { useRef } from "react";
|
|
12
18
|
import { gsap, useGSAP } from "../kit/gsap";
|
|
13
19
|
|
|
14
20
|
type Props = { text: string; className?: string };
|
|
15
21
|
|
|
22
|
+
/** Large text: WCAG 3:1. A little over it, so rounding never lands under. */
|
|
23
|
+
const WAIT_RATIO = 3.1;
|
|
24
|
+
|
|
25
|
+
const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
26
|
+
const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
27
|
+
|
|
28
|
+
/** A colour's three sRGB channels (0 to 1), through a one-pixel canvas so every colour syntax resolves. */
|
|
29
|
+
function channels(css: string): [number, number, number] | null {
|
|
30
|
+
const cv = document.createElement("canvas");
|
|
31
|
+
cv.width = 1;
|
|
32
|
+
cv.height = 1;
|
|
33
|
+
const ctx = cv.getContext("2d", { willReadFrequently: true });
|
|
34
|
+
if (!ctx) return null;
|
|
35
|
+
ctx.fillStyle = css;
|
|
36
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
37
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
38
|
+
return [r / 255, g / 255, b / 255];
|
|
39
|
+
}
|
|
40
|
+
const luminance = ([r, g, b]: [number, number, number]) => 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
|
41
|
+
|
|
42
|
+
/** The weakest opacity of `ink` over `ground` (blended in sRGB, as the browser does) that holds WAIT_RATIO. */
|
|
43
|
+
function waitingOpacity(ink: [number, number, number], ground: [number, number, number]) {
|
|
44
|
+
const g = luminance(ground);
|
|
45
|
+
const at = (a: number) => luminance(ink.map((c, i) => c * a + ground[i] * (1 - a)) as [number, number, number]);
|
|
46
|
+
if (contrast(at(1), g) < WAIT_RATIO) return 1;
|
|
47
|
+
let lo = 0;
|
|
48
|
+
let hi = 1;
|
|
49
|
+
for (let i = 0; i < 16; i += 1) {
|
|
50
|
+
const mid = (lo + hi) / 2;
|
|
51
|
+
if (contrast(at(mid), g) >= WAIT_RATIO) hi = mid;
|
|
52
|
+
else lo = mid;
|
|
53
|
+
}
|
|
54
|
+
return Math.ceil(hi * 100) / 100;
|
|
55
|
+
}
|
|
56
|
+
|
|
16
57
|
export function ManifestoScrub({ text, className = "" }: Props) {
|
|
17
58
|
const stage = useRef<HTMLElement>(null);
|
|
59
|
+
const tone = /(^|\s)scene-inverse(\s|$)/.test(className) ? "inverse" : "ground";
|
|
18
60
|
|
|
19
61
|
useGSAP(
|
|
20
62
|
() => {
|
|
@@ -23,12 +65,18 @@ export function ManifestoScrub({ text, className = "" }: Props) {
|
|
|
23
65
|
const q = gsap.utils.selector(el);
|
|
24
66
|
const mm = gsap.matchMedia();
|
|
25
67
|
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
26
|
-
|
|
68
|
+
const words = q("[data-word]");
|
|
69
|
+
const p = q("p")[0] as HTMLElement | undefined;
|
|
70
|
+
const ink = p ? channels(getComputedStyle(p).color) : null;
|
|
71
|
+
const ground = channels(getComputedStyle(el).backgroundColor);
|
|
72
|
+
// 0.5 when the colours cannot be read: enough for 3:1 on every palette the canon ships.
|
|
73
|
+
const wait = ink && ground ? waitingOpacity(ink, ground) : 0.5;
|
|
74
|
+
gsap.set(words, { opacity: wait });
|
|
27
75
|
const tl = gsap.timeline({
|
|
28
76
|
defaults: { ease: "none" },
|
|
29
77
|
scrollTrigger: { trigger: el, start: "top top", end: "+=200%", pin: true, scrub: 1 },
|
|
30
78
|
});
|
|
31
|
-
|
|
79
|
+
words.forEach((node: Element, i: number) => {
|
|
32
80
|
tl.to(node, { opacity: 1, duration: 0.3 }, i * 0.12);
|
|
33
81
|
});
|
|
34
82
|
tl.to({}, { duration: 0.6 });
|
|
@@ -39,7 +87,12 @@ export function ManifestoScrub({ text, className = "" }: Props) {
|
|
|
39
87
|
);
|
|
40
88
|
|
|
41
89
|
return (
|
|
42
|
-
<section
|
|
90
|
+
<section
|
|
91
|
+
ref={stage}
|
|
92
|
+
data-showpiece="manifesto-scrub"
|
|
93
|
+
data-bar-tone={tone}
|
|
94
|
+
className={`relative flex items-center bg-surface-0 px-6 py-32 md:min-h-svh md:px-[8vw] md:py-0 ${className}`}
|
|
95
|
+
>
|
|
43
96
|
<p className="font-display max-w-[22ch] text-[9vw] leading-[1.02] tracking-[-0.025em] md:text-[5.2vw]">
|
|
44
97
|
{text.split(/(\s+)/).map((w, j) => (w.trim() ? <span key={j} data-word="">{w}</span> : w))}
|
|
45
98
|
</p>
|
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
//
|
|
9
9
|
// <PanelWipe image={images.band[0]} left="Vuur" right="Zout" caption="De oven brandt vanaf vier uur." />
|
|
10
10
|
//
|
|
11
|
-
//
|
|
11
|
+
// Both words share one size, fitted after the display face loads: each holds its own half with a clear gap at the
|
|
12
|
+
// seam, so a long pair never runs together or off the edge. One word or a short phrase per side.
|
|
13
|
+
//
|
|
14
|
+
// Reduced motion and phones: the composed still, both words above the full-bleed photo (the second on its own line
|
|
15
|
+
// when the pair is too long for one).
|
|
12
16
|
import Image from "next/image";
|
|
13
|
-
import { useRef } from "react";
|
|
17
|
+
import { useEffect, useRef } from "react";
|
|
14
18
|
import { gsap, useGSAP } from "../kit/gsap";
|
|
15
19
|
import type { StruqImage } from "../kit/images";
|
|
16
20
|
|
|
@@ -25,9 +29,71 @@ type Props = {
|
|
|
25
29
|
className?: string;
|
|
26
30
|
};
|
|
27
31
|
|
|
32
|
+
// Inline, because the kit balances display type (text-wrap), which would let a two-word word break inside itself.
|
|
33
|
+
const NOWRAP = { whiteSpace: "nowrap", textWrap: "nowrap" } as React.CSSProperties;
|
|
34
|
+
|
|
35
|
+
/** A word's width at 100px, its own padding included, on a fresh copy (the word itself may be mid-move). */
|
|
36
|
+
function widthAt100(w: HTMLElement) {
|
|
37
|
+
const c = w.cloneNode(true) as HTMLElement;
|
|
38
|
+
Object.assign(c.style, { position: "absolute", left: "0", top: "0", visibility: "hidden", fontSize: "100px", display: "inline-block" });
|
|
39
|
+
w.parentElement?.appendChild(c);
|
|
40
|
+
const width = c.offsetWidth;
|
|
41
|
+
c.remove();
|
|
42
|
+
return width;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const contentWidth = (box: HTMLElement) => {
|
|
46
|
+
const cs = getComputedStyle(box);
|
|
47
|
+
return box.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
|
48
|
+
};
|
|
49
|
+
|
|
28
50
|
export function PanelWipe({ image, left, right, caption, close = true, className = "" }: Props) {
|
|
29
51
|
const stage = useRef<HTMLElement>(null);
|
|
30
52
|
|
|
53
|
+
// Fit both words to one size that holds the width: in the panels each word inside its own half, in the still layout
|
|
54
|
+
// the longer word inside the line. Measured with the display face in, and again whenever the width or a face changes.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const el = stage.current;
|
|
57
|
+
if (!el) return;
|
|
58
|
+
const panels = window.matchMedia("(min-width: 768px) and (prefers-reduced-motion: no-preference)");
|
|
59
|
+
const fit = () => {
|
|
60
|
+
const W = el.clientWidth;
|
|
61
|
+
if (!W) return;
|
|
62
|
+
const max = (W >= 768 ? 0.11 : 0.15) * W;
|
|
63
|
+
let size = max;
|
|
64
|
+
if (panels.matches) {
|
|
65
|
+
for (const side of ["[data-panel-left]", "[data-panel-right]"]) {
|
|
66
|
+
const box = el.querySelector<HTMLElement>(side);
|
|
67
|
+
const w = box?.querySelector<HTMLElement>("[data-pw-word]");
|
|
68
|
+
if (!box || !w) continue;
|
|
69
|
+
const width = widthAt100(w);
|
|
70
|
+
if (width > 0) size = Math.min(size, (100 * contentWidth(box)) / width);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
const box = el.querySelector<HTMLElement>("[data-pw-still]");
|
|
74
|
+
if (box) {
|
|
75
|
+
const room = contentWidth(box);
|
|
76
|
+
for (const w of Array.from(box.querySelectorAll<HTMLElement>("[data-pw-word]"))) {
|
|
77
|
+
const width = widthAt100(w);
|
|
78
|
+
if (width > 0) size = Math.min(size, (100 * room) / width);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
el.style.setProperty("--pw-size", `${Math.floor(size)}px`);
|
|
83
|
+
};
|
|
84
|
+
fit();
|
|
85
|
+
document.fonts?.ready.then(fit);
|
|
86
|
+
document.fonts?.addEventListener?.("loadingdone", fit);
|
|
87
|
+
panels.addEventListener("change", fit);
|
|
88
|
+
const ro = new ResizeObserver(fit);
|
|
89
|
+
ro.observe(el);
|
|
90
|
+
return () => {
|
|
91
|
+
ro.disconnect();
|
|
92
|
+
panels.removeEventListener("change", fit);
|
|
93
|
+
document.fonts?.removeEventListener?.("loadingdone", fit);
|
|
94
|
+
};
|
|
95
|
+
}, [left, right]);
|
|
96
|
+
|
|
31
97
|
useGSAP(
|
|
32
98
|
() => {
|
|
33
99
|
const el = stage.current;
|
|
@@ -59,13 +125,23 @@ export function PanelWipe({ image, left, right, caption, close = true, className
|
|
|
59
125
|
{ scope: stage },
|
|
60
126
|
);
|
|
61
127
|
|
|
62
|
-
|
|
128
|
+
// Both words at one fitted size (--pw-size), never above 15vw on phones and 11vw on wider screens.
|
|
129
|
+
const word =
|
|
130
|
+
"font-display block w-fit leading-[0.85] tracking-[-0.04em] text-[length:var(--pw-size,15vw)] md:text-[length:var(--pw-size,11vw)]";
|
|
63
131
|
return (
|
|
64
132
|
<section ref={stage} data-showpiece="panel-wipe" className={`relative overflow-hidden bg-surface-0 motion-safe:md:h-svh ${className}`}>
|
|
65
|
-
{/* Still layout (phones, reduced motion): the words, then the photo.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
133
|
+
{/* Still layout (phones, reduced motion): the words, then the photo. Side by side when both fit at the full
|
|
134
|
+
size, else the second word drops to its own line on the right; a gap of its own size keeps them apart. */}
|
|
135
|
+
<div
|
|
136
|
+
data-pw-still=""
|
|
137
|
+
className="flex flex-wrap justify-between gap-x-[0.3em] gap-y-[0.16em] px-sheet-margin pb-8 pt-24 text-[length:var(--pw-size,15vw)] md:hidden motion-reduce:md:flex md:text-[length:var(--pw-size,11vw)]"
|
|
138
|
+
>
|
|
139
|
+
<span data-pw-word="" className={word} style={NOWRAP}>
|
|
140
|
+
{left}
|
|
141
|
+
</span>
|
|
142
|
+
<span data-pw-word="" className={`${word} ml-auto`} style={NOWRAP}>
|
|
143
|
+
{right}
|
|
144
|
+
</span>
|
|
69
145
|
</div>
|
|
70
146
|
<div className="relative aspect-[4/5] overflow-hidden motion-safe:md:absolute motion-safe:md:inset-0 motion-safe:md:aspect-auto motion-reduce:md:aspect-[16/9]">
|
|
71
147
|
<div data-photo="" className="absolute inset-0">
|
|
@@ -78,11 +154,17 @@ export function PanelWipe({ image, left, right, caption, close = true, className
|
|
|
78
154
|
</p>
|
|
79
155
|
) : null}
|
|
80
156
|
</div>
|
|
81
|
-
|
|
82
|
-
|
|
157
|
+
{/* Each word holds its half: the page margin outside, and on the seam side a third of its own size, so the two
|
|
158
|
+
meet with a clear gap and never run together or off the edge. */}
|
|
159
|
+
<div data-panel-left="" className="absolute inset-y-0 left-0 hidden w-[calc(50%+1px)] items-center justify-end bg-surface-0 pl-sheet-margin motion-safe:md:flex">
|
|
160
|
+
<span data-pw-word="" className={`${word} pr-[0.3em]`} style={NOWRAP}>
|
|
161
|
+
{left}
|
|
162
|
+
</span>
|
|
83
163
|
</div>
|
|
84
|
-
<div data-panel-right="" className="absolute inset-y-0 right-0 hidden w-[calc(50%+1px)] items-center justify-start bg-surface-0
|
|
85
|
-
<span className={word}
|
|
164
|
+
<div data-panel-right="" className="absolute inset-y-0 right-0 hidden w-[calc(50%+1px)] items-center justify-start bg-surface-0 pr-sheet-margin motion-safe:md:flex">
|
|
165
|
+
<span data-pw-word="" className={`${word} pl-[0.3em]`} style={NOWRAP}>
|
|
166
|
+
{right}
|
|
167
|
+
</span>
|
|
86
168
|
</div>
|
|
87
169
|
</section>
|
|
88
170
|
);
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// toonstuk-wordmark-finale (Struq showpiece recipe)
|
|
3
3
|
// The footer as a finale: the brand name set edge to edge at the full width of the page, its
|
|
4
4
|
// letters rising out of a mask as the footer arrives, with one closing line and a live local clock
|
|
5
|
-
// (or just the line) above it. The name is measured and fitted to the width, so it never
|
|
6
|
-
// falls short, whatever the face.
|
|
5
|
+
// (or just the line) above it. The name is measured and fitted to the width on one line, so it never
|
|
6
|
+
// wraps or falls short, whatever the face, and its foot gets the room its own descenders need.
|
|
7
7
|
// Every site can close with it; it pairs with any lead showpiece.
|
|
8
8
|
//
|
|
9
9
|
// <WordmarkFinale name="Zout" line="Tot vanavond." place="Amsterdam" timeZone="Europe/Amsterdam" />
|
|
@@ -41,23 +41,47 @@ export function WordmarkFinale({ name, line, place, timeZone, children, classNam
|
|
|
41
41
|
const mark = useRef<HTMLParagraphElement>(null);
|
|
42
42
|
const now = useClock(timeZone);
|
|
43
43
|
|
|
44
|
-
// Fit the name to the full width: measure once at 100px, then scale.
|
|
44
|
+
// Fit the name to the full width on one line: measure once at 100px, then scale. Then give the line box the room
|
|
45
|
+
// its own descenders need (g, j, y hang far below a 0.8 line), measured on this name in this face, so the footer's
|
|
46
|
+
// clip never cuts them; a name without descenders keeps its tight foot.
|
|
45
47
|
useEffect(() => {
|
|
46
48
|
const el = mark.current;
|
|
47
49
|
const box = root.current;
|
|
48
50
|
if (!el || !box) return;
|
|
51
|
+
const canvas = document.createElement("canvas").getContext("2d");
|
|
52
|
+
const foot = () => {
|
|
53
|
+
const cs = getComputedStyle(el);
|
|
54
|
+
const size = parseFloat(cs.fontSize) || 0;
|
|
55
|
+
let below = 0.35 * size;
|
|
56
|
+
if (canvas) {
|
|
57
|
+
canvas.font = `${cs.fontStyle} ${cs.fontWeight} ${size}px ${cs.fontFamily}`;
|
|
58
|
+
const m = canvas.measureText(name);
|
|
59
|
+
if (m.fontBoundingBoxAscent !== undefined && m.fontBoundingBoxDescent !== undefined) {
|
|
60
|
+
const lineH = parseFloat(cs.lineHeight) || 0.8 * size;
|
|
61
|
+
// From the baseline down to the line box's bottom edge, then how far the name's ink hangs past it.
|
|
62
|
+
const toEdge = (lineH - (m.fontBoundingBoxAscent + m.fontBoundingBoxDescent)) / 2 + m.fontBoundingBoxDescent;
|
|
63
|
+
below = m.actualBoundingBoxDescent - toEdge;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
el.style.paddingBottom = `${Math.max(0, Math.ceil(below + 0.03 * size))}px`;
|
|
67
|
+
};
|
|
49
68
|
const fit = () => {
|
|
50
69
|
el.style.fontSize = "100px";
|
|
51
70
|
const width = el.scrollWidth || 1;
|
|
52
71
|
const style = getComputedStyle(box);
|
|
53
72
|
const avail = box.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
|
54
73
|
el.style.fontSize = `${Math.floor((100 * avail) / width)}px`;
|
|
74
|
+
foot();
|
|
55
75
|
};
|
|
56
76
|
fit();
|
|
57
77
|
document.fonts?.ready.then(fit);
|
|
78
|
+
document.fonts?.addEventListener?.("loadingdone", fit);
|
|
58
79
|
const ro = new ResizeObserver(fit);
|
|
59
80
|
ro.observe(box);
|
|
60
|
-
return () =>
|
|
81
|
+
return () => {
|
|
82
|
+
ro.disconnect();
|
|
83
|
+
document.fonts?.removeEventListener?.("loadingdone", fit);
|
|
84
|
+
};
|
|
61
85
|
}, [name]);
|
|
62
86
|
|
|
63
87
|
useGSAP(
|
|
@@ -99,7 +123,16 @@ export function WordmarkFinale({ name, line, place, timeZone, children, classNam
|
|
|
99
123
|
</p>
|
|
100
124
|
) : null}
|
|
101
125
|
</div>
|
|
102
|
-
|
|
126
|
+
{/* One line, always: inline, because the kit balances display type (text-wrap), which would let a two-word name
|
|
127
|
+
wrap at the 100px measure and then collide at a 0.8 leading. The foot's room (0.35em) holds until the
|
|
128
|
+
script measures this name's own descenders. */}
|
|
129
|
+
<p
|
|
130
|
+
ref={mark}
|
|
131
|
+
data-wordmark=""
|
|
132
|
+
aria-label={name}
|
|
133
|
+
className="font-display mt-10 block w-fit pb-[0.35em] leading-[0.8] tracking-[-0.04em]"
|
|
134
|
+
style={{ whiteSpace: "nowrap", textWrap: "nowrap" } as React.CSSProperties}
|
|
135
|
+
>
|
|
103
136
|
{name}
|
|
104
137
|
</p>
|
|
105
138
|
</footer>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Art direction: the fresh pair of eyes
|
|
2
|
+
|
|
3
|
+
You are the art director of a studio whose sites win Site of the Day. You did not build this one and you never see
|
|
4
|
+
its code or its plan. You get the brief, the one-sentence idea, and the screenshots `look.mjs` saved, in scroll order,
|
|
5
|
+
phone and wide. Judge what a visitor sees.
|
|
6
|
+
|
|
7
|
+
Open every still. Then look for, in this order:
|
|
8
|
+
|
|
9
|
+
1. **The idea.** From the first screen and the last alone, can you say what this site believes? Name any scene that
|
|
10
|
+
does not serve it.
|
|
11
|
+
2. **The first screen.** Would it stop a juror scrolling a gallery of fifty? If it could open any client's site, say
|
|
12
|
+
what only this client's would show.
|
|
13
|
+
3. **The subject.** Is what the client makes shown, and drawn with a maker's care: true proportions, the object's real
|
|
14
|
+
anatomy, a document set like the real thing, an app screen that looks used? A diagram, a grey card or an empty
|
|
15
|
+
frame where the subject should be is the first thing to fix.
|
|
16
|
+
4. **Type.** Scale contrast, line length, a widow or orphan, a word or number broken across lines, text fighting a
|
|
17
|
+
photo, a heading off the grid.
|
|
18
|
+
5. **Rhythm.** Two scenes with the same layout, a stretch where nothing new happens, a blank screen, an ending that
|
|
19
|
+
repeats the opening without adding to it.
|
|
20
|
+
6. **Reflexes** a juror has seen a thousand times: one italic word in every headline; a giant sentence right after
|
|
21
|
+
the opening; a glossary of the trade; a labelled anatomy diagram; the name at full width as the close; a dot or
|
|
22
|
+
particle wave as the opening; one-word rows; a photo beside a paragraph.
|
|
23
|
+
7. **Honesty.** A line that invents a fact, invites the visitor to a door the site never gives (an address, a number,
|
|
24
|
+
a link), speaks of a stock photo as the client's own, or promises something coming.
|
|
25
|
+
|
|
26
|
+
Answer with at most five changes, the ones that would raise the site most, worst first, as a numbered list (1. to 5.).
|
|
27
|
+
Each: the still's file name, what is wrong in one sentence, and what to build instead, concrete enough to build without
|
|
28
|
+
asking. No scores, no praise, no general advice. If nothing would raise it, answer "Ship it."
|
|
29
|
+
|
|
30
|
+
## What the builder does with it
|
|
31
|
+
|
|
32
|
+
The asks are binding. Save them in `.struq/art-direction.md` under a heading for the round, each as its numbered item,
|
|
33
|
+
and write one answer line under each, by the builder:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
# Art direction, round 1
|
|
37
|
+
|
|
38
|
+
1. desktop-00: the first screen could open any restaurant. Open on the room through the letterbox.
|
|
39
|
+
Built: the opener is HeroPoster with the room photo in the letterbox; the storyboard's Opening line is rewritten.
|
|
40
|
+
2. desktop-09: "Schuif aan." invites to a door the site never gives. End on the idea.
|
|
41
|
+
Refused: the brief gave no address, booking or phone (rule 2), so the line is dropped and Assumptions names the door.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **Build every ask.** When it needs another order, another opening or another centerpiece, rewrite the storyboard in
|
|
45
|
+
STRUQ.md and swap the blocks: the storyboard is rewritten for a good ask, never quoted against it. When it breaks a
|
|
46
|
+
budget (a pin, the quiet share, a second loud scene), keep the budget by dropping something else.
|
|
47
|
+
- **Refuse only an ask that breaks a rule that never bends** (SKILL.md, the four rules) **or a fact of the brief** (a
|
|
48
|
+
price, an address, a programme the brief did not give), and the line says which.
|
|
49
|
+
- **Never refuse on the storyboard, the plan, a budget or a block.** `check.mjs` fails an ask with no `Built:` or
|
|
50
|
+
`Refused:` line, and a refusal that cites only those.
|
|
51
|
+
- A second round, from a new subagent, goes under its own heading in the same file.
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
// Everything lands under components/struq/ in the layout of Struq's blocks/ (kit/, heroes/, sections/ ...), so the
|
|
17
17
|
// relative imports between them hold. A layout or stylesheet you wrote yourself is never overwritten.
|
|
18
18
|
import { spawnSync } from "node:child_process";
|
|
19
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
19
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
20
20
|
import { dirname, join, relative, resolve } from "node:path";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { projectHand } from "./draw.mjs";
|
|
22
23
|
import { addRefusal, refuse } from "./gate.mjs";
|
|
23
24
|
import { appendLog } from "./log.mjs";
|
|
24
25
|
|
|
@@ -96,31 +97,65 @@ function report(step, what) {
|
|
|
96
97
|
appendLog(root, step, [what, ...(notes.length ? [`Notes: ${notes.join(" ")}`] : [])]);
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
/** A copy of a list in random order: the printed options carry no ranking, so their order must not suggest one. */
|
|
101
|
+
function shuffled(list) {
|
|
102
|
+
const out = [...list];
|
|
103
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
104
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
105
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* This site's five openings or centerpieces, from the project's hand (draw.mjs: dealt once by Struq's hand.mjs and kept
|
|
112
|
+
* in .struq/draw.json), so printing the arc again shows the same hand. Every opening hand holds one that needs no photo.
|
|
113
|
+
*/
|
|
114
|
+
function hand(role) {
|
|
115
|
+
const { hand: dealt } = projectHand(root);
|
|
116
|
+
return new Set(role === "opening" ? dealt.openings : dealt.centerpieces);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The blocks of a role in this site's hand, each with its variants and that variant's energy, e.g. `hero-poster:window E3`. */
|
|
120
|
+
function pool(role) {
|
|
121
|
+
const dealt = hand(role);
|
|
122
|
+
return shuffled(CATALOG.filter((b) => b.role === role && !b.home && dealt.has(b.slug))).flatMap((b) => {
|
|
123
|
+
const variants = b.variants?.length ? b.variants.map((v) => v.id) : [null];
|
|
124
|
+
return variants.map((v) => {
|
|
125
|
+
const energy = (v && b.byVariant?.[v]?.energy) ?? b.energy ?? 0;
|
|
126
|
+
return `${b.slug}${v ? `:${v}` : ""} E${energy}`;
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
99
131
|
/**
|
|
100
|
-
* A language's arc as STRUQ.md lines: the Tone arc, then one numbered Storyboard line per scene
|
|
101
|
-
*
|
|
102
|
-
*
|
|
132
|
+
* A language's arc as STRUQ.md lines: the Tone arc, then one numbered Storyboard line per scene with no pick filled in.
|
|
133
|
+
* There is no default on purpose (proof runs of 2026-11-14: with the first option pre-filled, 11 of 12 sites on two
|
|
134
|
+
* unrelated briefs opened with the same hero and all 12 had the same centerpiece). Every slot says `<cast: ...>`, which
|
|
135
|
+
* the plan check refuses until it is replaced. The Opening and the Centerpiece list the whole catalogue of their role,
|
|
136
|
+
* since no opening or centerpiece belongs to one language; the middle scenes list what suits the language, plus an own
|
|
137
|
+
* scene. The arc's own suggestions are not ranked above the rest.
|
|
103
138
|
*/
|
|
104
|
-
function arcLines(arc) {
|
|
105
|
-
const pick = (scene) => {
|
|
106
|
-
const own = scene.kit ?? "own scene";
|
|
107
|
-
const ownFirst = scene.own && (scene.ownFirst || !scene.blocks.length);
|
|
108
|
-
const options = ownFirst ? [own, ...scene.blocks] : [...scene.blocks, ...(scene.own ? [own] : [])];
|
|
109
|
-
if (scene.kit && scene.own && !options.includes("own scene")) options.push("own scene");
|
|
110
|
-
return options;
|
|
111
|
-
};
|
|
112
|
-
const width = Math.max(...arc.scenes.map((s) => s.name.length));
|
|
139
|
+
function arcLines(arc, footers = []) {
|
|
113
140
|
const last = arc.scenes.length - 1;
|
|
141
|
+
// The middle scenes carry no name of their own: names like Statement and Release read as a script, and every site
|
|
142
|
+
// that followed them came out with the same middle (Roy, 2026-11-15: the middle is composed per client).
|
|
143
|
+
const nameOf = (scene, at) => (scene.tag === "frame" || at === last || ["Opening", "Centerpiece"].includes(scene.name) ? scene.name : "Scene");
|
|
144
|
+
const width = Math.max(...arc.scenes.map((s, at) => nameOf(s, at).length));
|
|
114
145
|
const lines = [`Tone arc: ${arc.scenes.map((s) => s.tone[0]).join(", ")}`, "Storyboard:"];
|
|
115
146
|
arc.scenes.forEach((scene, at) => {
|
|
116
|
-
const
|
|
117
|
-
|
|
147
|
+
const role = scene.name === "Opening" ? "opening" : scene.name === "Centerpiece" ? "centerpiece" : null;
|
|
148
|
+
// An own scene first, then the language's blocks. The kit's quiet scenes are no longer offered.
|
|
149
|
+
const options = scene.tag === "frame" ? scene.blocks : role ? pool(role) : ["own scene", ...shuffled(scene.blocks)];
|
|
150
|
+
const tag = scene.tag === "frame" ? "frame" : "<loud|quiet>";
|
|
151
|
+
const cast = scene.tag === "frame" ? options[0] : at === last ? "<cast: how this site ends, and why>" : role ? "<cast: what this idea needs here, and why>" : "<cast: name this scene, what it shows of this client, and why>";
|
|
152
|
+
const head = `${`${at + 1}.`.padEnd(at + 1 >= 10 ? 4 : 3)}${nameOf(scene, at).padEnd(width)} (${tag}) ${cast}`;
|
|
118
153
|
const parts = [head];
|
|
119
154
|
if (scene.tag === "frame") parts.push("lands on: <what>");
|
|
120
|
-
else if (at === last) parts.push("
|
|
155
|
+
else if (at === last) parts.push("ends on: <what the visitor is left with>");
|
|
121
156
|
else parts.push("on screen: <...>", "enters: <...>", "hands over: <...>");
|
|
122
|
-
if (
|
|
123
|
-
if (scene.footer?.length) parts.push(`footer:
|
|
157
|
+
if (scene.tag !== "frame" && options.length) parts.push(`options: ${options.join(", ")}`);
|
|
158
|
+
if (scene.footer?.length) parts.push(`footer: <${shuffled([...new Set([...scene.footer, ...footers])]).join(" | ")}>`);
|
|
124
159
|
lines.push(parts.join(" | "));
|
|
125
160
|
});
|
|
126
161
|
return lines;
|
|
@@ -178,10 +213,11 @@ const commands = {
|
|
|
178
213
|
const language = languages.find((l) => l.id === id);
|
|
179
214
|
const arc = film.FILM_ARCS.find((a) => a.language === id);
|
|
180
215
|
if (!language || !arc) throw new Error(`No language "${id}". The languages: ${languages.map((l) => l.id).join(", ")}.`);
|
|
181
|
-
for (const line of arcLines(arc)) console.log(line);
|
|
216
|
+
for (const line of arcLines(arc, film.FILM_ARCS.flatMap((a) => a.scenes.flatMap((s) => s.footer ?? [])))) console.log(line);
|
|
182
217
|
const f = language.frames;
|
|
183
218
|
console.log(`\nFrames of ${id}: preloader ${f.preloader}, page transition ${f.pageTransition} (only with a second route), menu ${f.menu}. Header: ${film.FILM_HEADER.join(" or ")}.`);
|
|
184
|
-
console.log(`
|
|
219
|
+
console.log(`The Opening and the Centerpiece options are this site's hand, dealt once (.struq/draw.json): cast from it; a block the client named goes outside it, marked (named) on its line.`);
|
|
220
|
+
console.log(`Use: paste the Tone arc and the Storyboard into STRUQ.md under your treatment head. Cast every <cast: ...> from the Idea, the Motif and the Unlike line, not from the order of the options: each option is a different first screen or moment (blocks/CATALOG.md says what each does). Write one pick per line with its reason after "because", delete the options list, set (loud) for E2 or more and (quiet) for E1 or less, and change, add or drop middle scenes as the idea needs (7 to 9 scenes, the budgets in SKILL.md hold). Then run check.mjs --plan.`);
|
|
185
221
|
},
|
|
186
222
|
|
|
187
223
|
add() {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// blocks read. The same block, derivation and font requests as Struq's block lab and the MCP's struq_richting
|
|
4
4
|
// (scripts/tokens.mjs), from the snapshot in canon/canon.json that the skill's release copies from Struq.
|
|
5
5
|
//
|
|
6
|
+
// node <skill-dir>/scripts/canon.mjs draw this site's hand: 2 languages, 3 pairings, 4 palettes
|
|
6
7
|
// node <skill-dir>/scripts/canon.mjs list every palette and pairing
|
|
7
8
|
// node <skill-dir>/scripts/canon.mjs palette <id> writes the STRUQ TOKENS block in app/globals.css
|
|
8
9
|
// node <skill-dir>/scripts/canon.mjs type <id> the same block with the pairing, and app/fonts.ts
|
|
@@ -13,9 +14,11 @@
|
|
|
13
14
|
// reply to the treatment (gate.mjs). list and print never ask.
|
|
14
15
|
//
|
|
15
16
|
// Node 18 or later, no network, no dependencies.
|
|
16
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
18
|
import { dirname, join, resolve } from "node:path";
|
|
18
19
|
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { projectHand } from "./draw.mjs";
|
|
21
|
+
import { HAND_LENSES } from "./hand.mjs";
|
|
19
22
|
import { refuse, stopRefusal } from "./gate.mjs";
|
|
20
23
|
import { appendLog } from "./log.mjs";
|
|
21
24
|
import { fontHrefs, headFamily, lightness, paletteTokens } from "./tokens.mjs";
|
|
@@ -38,6 +41,7 @@ const pairing = (id) => {
|
|
|
38
41
|
return t;
|
|
39
42
|
};
|
|
40
43
|
const faces = (t) => [t.display, t.body, t.accentItalic].filter(Boolean).map(headFamily);
|
|
44
|
+
const shuffled = (list) => list.map((x) => [Math.random(), x]).sort((p, q) => p[0] - q[0]).map(([, x]) => x);
|
|
41
45
|
|
|
42
46
|
export function tokenBlock(p, t) {
|
|
43
47
|
const lines = paletteTokens(p, t).map(([k, v]) => ` ${k}: ${v};`);
|
|
@@ -83,18 +87,43 @@ export const fontHrefs: string[] = ${JSON.stringify(hrefs, null, 2)};
|
|
|
83
87
|
|
|
84
88
|
const commands = {
|
|
85
89
|
list() {
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
// Random order on every call: the first line is never a recommendation.
|
|
91
|
+
console.log(`PALETTES (${CANON.palettes.length}), in random order: id, mood, ground. "kept out" means kept for a brand that asks, never picked unasked.`);
|
|
92
|
+
for (const p of shuffled(CANON.palettes)) {
|
|
88
93
|
const ground = lightness(p.colors.background) < 0.5 ? "dark" : "light";
|
|
89
94
|
console.log(` ${p.id.padEnd(40)} ${(p.mood ?? "").padEnd(44)} ${ground}${p.autoPick === false ? " kept out" : ""}`);
|
|
90
95
|
}
|
|
91
|
-
console.log(`\nPAIRINGS (${CANON.pairings.length}): id, display + body (+ italic accent), display weight`);
|
|
92
|
-
for (const t of CANON.pairings) {
|
|
96
|
+
console.log(`\nPAIRINGS (${CANON.pairings.length}), in random order: id, display + body (+ italic accent), display weight`);
|
|
97
|
+
for (const t of shuffled(CANON.pairings)) {
|
|
93
98
|
const [d, bo, ac] = [t.display, t.body, t.accentItalic].map((s) => (s ? headFamily(s) : null));
|
|
94
99
|
console.log(` ${t.id.padEnd(26)} ${d} + ${bo}${ac ? ` (+ ${ac} italic)` : ""}, ${t.displayScale?.weight ?? 300}${t.autoPick === false ? " kept out" : ""}`);
|
|
95
100
|
}
|
|
96
101
|
console.log(`\nSnapshot: Struq ${CANON.source.ref} ${CANON.source.commit}.`);
|
|
97
102
|
},
|
|
103
|
+
// The hand this site chooses from (draw.mjs, dealt by Struq's hand.mjs): two languages, one light and one dark
|
|
104
|
+
// palette, two pairings, three openings and one centerpiece, saved to .struq/draw.json so the plan check holds
|
|
105
|
+
// the plan to it. Dealt once: drawing again shows the same hand.
|
|
106
|
+
draw() {
|
|
107
|
+
const { hand: draw, fresh } = projectHand(root);
|
|
108
|
+
const palettes = draw.palettes.map(palette);
|
|
109
|
+
const pairings = draw.pairings.map(pairing);
|
|
110
|
+
console.log(`Hand ${draw.id}${fresh ? "" : " (dealt before, the same hand)"}. Choose the Language, the Palette and the Type from it,`);
|
|
111
|
+
console.log("for the idea; only the client's own brand colours or faces go outside it, marked (brand) on the line.");
|
|
112
|
+
console.log(`Write "Hand: ${draw.id}" into STRUQ.md. blocks.mjs arc shows the hand's openings and centerpieces.\n`);
|
|
113
|
+
console.log(`LANGUAGES ${draw.languages.join(", ")}\n`);
|
|
114
|
+
console.log("PALETTES");
|
|
115
|
+
for (const p of palettes) console.log(` ${p.id.padEnd(40)} ${(p.mood ?? "").padEnd(44)} ${lightness(p.colors.background) < 0.5 ? "dark" : "light"}`);
|
|
116
|
+
console.log("\nPAIRINGS");
|
|
117
|
+
for (const t of pairings) {
|
|
118
|
+
const [d, bo, ac] = [t.display, t.body, t.accentItalic].map((s) => (s ? headFamily(s) : null));
|
|
119
|
+
console.log(` ${t.id.padEnd(26)} ${d} + ${bo}${ac ? ` (+ ${ac} italic, optional)` : ""}, display weight ${t.displayScale?.weight ?? 300}`);
|
|
120
|
+
}
|
|
121
|
+
if (draw.lenses?.length) {
|
|
122
|
+
console.log("\nLENSES where your three ideas start looking, one idea from each; the Idea line names its lens");
|
|
123
|
+
for (const id of draw.lenses) console.log(` ${id.padEnd(14)} ${HAND_LENSES.find((lens) => lens.id === id)?.prompt ?? ""}`);
|
|
124
|
+
}
|
|
125
|
+
console.log("\nOK hand saved to .struq/draw.json");
|
|
126
|
+
},
|
|
98
127
|
palette() {
|
|
99
128
|
const p = palette(a);
|
|
100
129
|
const t = pairing(current().type);
|
|
@@ -125,8 +154,8 @@ const commands = {
|
|
|
125
154
|
|
|
126
155
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
127
156
|
const run = commands[cmd];
|
|
128
|
-
if (!run || (cmd !== "list" && !a)) {
|
|
129
|
-
console.error("Usage: canon.mjs list | palette <id> | type <id> | print <palette> [pairing] [--dir <project>]");
|
|
157
|
+
if (!run || (cmd !== "list" && cmd !== "draw" && !a)) {
|
|
158
|
+
console.error("Usage: canon.mjs draw | list | palette <id> | type <id> | print <palette> [pairing] [--dir <project>]");
|
|
130
159
|
process.exit(2);
|
|
131
160
|
}
|
|
132
161
|
try {
|