@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.
Files changed (65) hide show
  1. package/README.md +137 -100
  2. package/package.json +5 -3
  3. package/skills/struq/SKILL.md +168 -165
  4. package/skills/struq/blocks/CATALOG.md +49 -46
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +2 -1
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +5 -3
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +6 -2
  8. package/skills/struq/blocks/catalog.json +50 -5
  9. package/skills/struq/blocks/film.json +11 -7
  10. package/skills/struq/blocks/footers/footer-contact.tsx +8 -78
  11. package/skills/struq/blocks/headers/header-bar.tsx +144 -29
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +36 -19
  13. package/skills/struq/blocks/heroes/hero-poster.tsx +36 -10
  14. package/skills/struq/blocks/kit/HeroCanvas.tsx +6 -2
  15. package/skills/struq/blocks/kit/SplitHeadline.tsx +2 -1
  16. package/skills/struq/blocks/kit/accent.ts +29 -3
  17. package/skills/struq/blocks/kit/blocks.css +530 -6
  18. package/skills/struq/blocks/kit/feel.ts +183 -0
  19. package/skills/struq/blocks/kit/gsap.ts +9 -6
  20. package/skills/struq/blocks/kit/middle/PlaceHours.tsx +254 -0
  21. package/skills/struq/blocks/kit/middle/ProcessStrip.tsx +428 -0
  22. package/skills/struq/blocks/kit/middle/ServicesList.tsx +496 -0
  23. package/skills/struq/blocks/kit/middle/entrance.ts +98 -0
  24. package/skills/struq/blocks/kit/middle/hours.ts +154 -0
  25. package/skills/struq/blocks/kit/middle/index.ts +14 -0
  26. package/skills/struq/blocks/kit/motion/HoverLink.tsx +33 -0
  27. package/skills/struq/blocks/kit/motion/ImageCurtain.tsx +79 -0
  28. package/skills/struq/blocks/kit/motion/MaskWipe.tsx +91 -0
  29. package/skills/struq/blocks/kit/motion/Pressable.tsx +58 -0
  30. package/skills/struq/blocks/kit/motion/RevealText.tsx +103 -0
  31. package/skills/struq/blocks/kit/motion/Rise.tsx +77 -0
  32. package/skills/struq/blocks/kit/motion/ScrubLine.tsx +108 -0
  33. package/skills/struq/blocks/kit/motion/index.ts +11 -0
  34. package/skills/struq/blocks/kit/quiet/Close.tsx +178 -52
  35. package/skills/struq/blocks/kit/quiet/Statement.tsx +3 -2
  36. package/skills/struq/blocks/kit/subject/AppScreen.tsx +388 -0
  37. package/skills/struq/blocks/kit/subject/Document.tsx +742 -0
  38. package/skills/struq/blocks/kit/subject/Mark.tsx +44 -0
  39. package/skills/struq/blocks/kit/subject/PatternPaper.tsx +410 -0
  40. package/skills/struq/blocks/kit/subject/SectionDrawing.tsx +349 -0
  41. package/skills/struq/blocks/kit/subject/Ticket.tsx +354 -0
  42. package/skills/struq/blocks/kit/subject/build.ts +66 -0
  43. package/skills/struq/blocks/kit/subject/draw.ts +196 -0
  44. package/skills/struq/blocks/kit/subject/drawings.ts +373 -0
  45. package/skills/struq/blocks/kit/subject/format.ts +122 -0
  46. package/skills/struq/blocks/kit/subject/index.ts +11 -0
  47. package/skills/struq/blocks/kit/subject/patterns.ts +204 -0
  48. package/skills/struq/blocks/preloaders/preloader-stack.tsx +75 -22
  49. package/skills/struq/blocks/showpieces/hero-opener.tsx +55 -7
  50. package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +64 -6
  51. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +56 -3
  52. package/skills/struq/blocks/showpieces/panel-wipe.tsx +93 -11
  53. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +38 -5
  54. package/skills/struq/canon/canon.json +1 -1
  55. package/skills/struq/references/art-direction.md +51 -0
  56. package/skills/struq/scripts/blocks.mjs +56 -20
  57. package/skills/struq/scripts/canon.mjs +36 -7
  58. package/skills/struq/scripts/check.mjs +417 -11
  59. package/skills/struq/scripts/draw.mjs +49 -0
  60. package/skills/struq/scripts/film-check.mjs +28 -4
  61. package/skills/struq/scripts/gate.mjs +13 -3
  62. package/skills/struq/scripts/hand.mjs +222 -0
  63. package/skills/struq/scripts/images.mjs +12 -3
  64. package/skills/struq/scripts/look.mjs +1184 -0
  65. 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
- gsap.set(q("[data-word]"), { opacity: 0.16 });
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
- q("[data-word]").forEach((node: Element, i: number) => {
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 ref={stage} data-showpiece="manifesto-scrub" className={`relative flex items-center bg-surface-0 px-6 py-32 md:min-h-svh md:px-[8vw] md:py-0 ${className}`}>
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
- // Reduced motion and phones: the composed still, both words above the full-bleed photo.
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
- const word = "font-display block text-[15vw] leading-[0.85] tracking-[-0.04em] md:text-[11vw]";
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
- <div className="flex justify-between px-6 pb-8 pt-24 md:hidden motion-reduce:md:flex">
67
- <span className={word}>{left}</span>
68
- <span className={word}>{right}</span>
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
- <div data-panel-left="" className="absolute inset-y-0 left-0 hidden w-[calc(50%+1px)] items-center justify-end bg-surface-0 pr-[1.5vw] motion-safe:md:flex">
82
- <span className={word}>{left}</span>
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 pl-[1.5vw] motion-safe:md:flex">
85
- <span className={word}>{right}</span>
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 wraps or
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 () => ro.disconnect();
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
- <p ref={mark} data-wordmark="" aria-label={name} className="font-display mt-10 inline-block whitespace-nowrap leading-[0.8] tracking-[-0.04em]">
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>
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "Struq lib/canon",
4
4
  "ref": "origin/main",
5
- "commit": "a538491"
5
+ "commit": "f98bd198"
6
6
  },
7
7
  "palettes": [
8
8
  {
@@ -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 in the treatment's
101
- * format, the default pick filled in (the own scene first where the arc prefers it, as its kit quiet scene when it has
102
- * one, else the first block) and what else fits after `or:`. The defaults pass check.mjs --plan as they stand.
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 [first, ...others] = pick(scene);
117
- const head = `${`${at + 1}.`.padEnd(at + 1 >= 10 ? 4 : 3)}${scene.name.padEnd(width)} ${`(${scene.tag})`.padEnd(8)} ${first}`;
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("echoes the opening: <how>");
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 (others.length) parts.push(`or: ${others.join(", ")}`);
123
- if (scene.footer?.length) parts.push(`footer: ${scene.footer[0]}`);
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(`Use: paste the Tone arc and the Storyboard into STRUQ.md under your treatment head, fill every <...>, keep one pick per line (or: lists what else fits), then run check.mjs --plan.`);
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
- console.log(`PALETTES (${CANON.palettes.length}): id, mood, ground. "kept out" means kept for a brand that asks, never picked unasked.`);
87
- for (const p of CANON.palettes) {
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 {