@struq/skill 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +27 -27
  2. package/package.json +2 -2
  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
@@ -0,0 +1,204 @@
1
+ // components/struq/kit/subject/patterns.ts (Struq recipe)
2
+ // The pattern pieces PatternPaper draws: five women's blocks at about a European size 38, in centimetres, each the
3
+ // net piece (the stitching line) as straight and cubic segments, from which the cut line is offset outward. Drafted
4
+ // the way a block is drafted, and checked against the anatomy every block shares:
5
+ // - Bodice front: front neck 8.5 deep and 7 wide, a quarter curve that leaves the shoulder at a right angle and meets
6
+ // centre front square; shoulder sloping 20 degrees; the armhole scoops inward to the cross-front (17.6) before it
7
+ // swings out to the underarm; waist dart under the bust point; centre front on the fold.
8
+ // - Bodice back: back neck only 2.2 deep; a longer shoulder; a shallower armhole (cross-back 18.8); waist dart;
9
+ // centre back on the fold.
10
+ // - Sleeve: a set-in sleeve cap, an S curve each side (the underarm part scoops, the crown part swells), crown 14 high,
11
+ // the front side steeper than the back; underarm seams tapering to the wrist; a 3 cm hem.
12
+ // - Skirt panel: an A-line front, the waist rising 1.5 to the side, a hip curve to 20 below the waist, a flared side
13
+ // seam, the hem curved to meet the side seam and the fold square; a 4 cm hem; centre front on the fold.
14
+ // - Trouser front: the crotch curve scoops inward from the centre front line to the crotch point 27 below the
15
+ // waist; the crease line (grain) halfway across the hip; knee and hem narrower; a 4 cm hem.
16
+ // Notches: one on a front armhole, two on a back armhole (and on the sleeve's back), as every commercial pattern marks
17
+ // them. Each template ends where it starts, so the outline closes.
18
+ import type { Outline, Pt } from "./draw";
19
+
20
+ export type PieceName = "bodice-front" | "bodice-back" | "sleeve" | "skirt-panel" | "trouser-front";
21
+
22
+ export type PatternTemplate = {
23
+ outline: Outline;
24
+ /** The seam allowance where a segment does not set its own. */
25
+ allowance: number;
26
+ /** The straight grain arrow, from and to. */
27
+ grain: [Pt, Pt];
28
+ /** A fold edge: the segment index lying on it and its bracket's span along it (two points on the fold). */
29
+ fold?: { side: "CF" | "CB"; from: Pt; to: Pt };
30
+ /** Notches: on segment `seg` at t (0 to 1); `count` 2 for a back notch. */
31
+ notches: { seg: number; t: number; count?: 1 | 2 }[];
32
+ /** Darts: the two legs on segment `seg` at t1 and t2, the apex. */
33
+ darts: { seg: number; t1: number; t2: number; apex: Pt }[];
34
+ /** The label block's top left corner and its width, in the piece's units. */
35
+ label: { at: Pt; width: number };
36
+ /** True when the piece is cut as a pair (no fold). */
37
+ pair: boolean;
38
+ /** Long pieces lie on their side on a wide sheet. */
39
+ long?: boolean;
40
+ /** Where the label block stands once a long piece is turned (turned coordinates). */
41
+ turnedLabel?: { at: Pt; width: number };
42
+ };
43
+
44
+ export const PATTERN_PIECES: Record<PieceName, PatternTemplate> = {
45
+ "bodice-front": {
46
+ outline: {
47
+ start: [0, 8.5],
48
+ segs: [
49
+ { to: [7, 0], c1: [3.86, 8.5], c2: [7, 4.69] },
50
+ { to: [19.5, 4.5] },
51
+ { to: [17.6, 14], c1: [18.9, 7.5], c2: [17.6, 10.5] },
52
+ { to: [23, 21], c1: [17.6, 18.5], c2: [19.8, 21] },
53
+ { to: [21.5, 38] },
54
+ { to: [0, 39.5], c1: [14, 38.8], c2: [6, 39.5] },
55
+ { to: [0, 8.5], fold: true },
56
+ ],
57
+ },
58
+ allowance: 1.5,
59
+ grain: [
60
+ [16.4, 23],
61
+ [16.4, 34],
62
+ ],
63
+ fold: { side: "CF", from: [0, 13], to: [0, 35] },
64
+ notches: [
65
+ { seg: 3, t: 0.18 },
66
+ { seg: 4, t: 0.45 },
67
+ ],
68
+ darts: [{ seg: 5, t1: 0.44, t2: 0.62, apex: [9.5, 26] }],
69
+ label: { at: [2, 11.2], width: 12.5 },
70
+ pair: false,
71
+ },
72
+ "bodice-back": {
73
+ outline: {
74
+ start: [0, 2.2],
75
+ segs: [
76
+ { to: [7.5, 0], c1: [4.2, 2.2], c2: [7.5, 1.2] },
77
+ { to: [20.5, 4.6] },
78
+ { to: [18.8, 15], c1: [19.7, 8], c2: [18.8, 11.5] },
79
+ { to: [23, 21.5], c1: [18.8, 18.8], c2: [20.3, 21.5] },
80
+ { to: [21.5, 38.5] },
81
+ { to: [0, 38], c1: [14, 38.4], c2: [6, 38] },
82
+ { to: [0, 2.2], fold: true },
83
+ ],
84
+ },
85
+ allowance: 1.5,
86
+ grain: [
87
+ [16.6, 23],
88
+ [16.6, 34],
89
+ ],
90
+ fold: { side: "CB", from: [0, 8], to: [0, 34] },
91
+ notches: [
92
+ { seg: 3, t: 0.22, count: 2 },
93
+ { seg: 4, t: 0.45 },
94
+ ],
95
+ darts: [{ seg: 5, t1: 0.43, t2: 0.59, apex: [10, 23] }],
96
+ label: { at: [2, 7.5], width: 13 },
97
+ pair: false,
98
+ },
99
+ sleeve: {
100
+ outline: {
101
+ start: [0, 14],
102
+ segs: [
103
+ { to: [8.5, 8], c1: [3.5, 14], c2: [6, 11.5] },
104
+ { to: [17, 0], c1: [11, 4.5], c2: [13.5, 0] },
105
+ { to: [25.8, 7.8], c1: [20.8, 0], c2: [23.3, 4.2] },
106
+ { to: [34, 14], c1: [28.2, 11.2], c2: [30.6, 14] },
107
+ { to: [29, 58] },
108
+ { to: [5, 58], allowance: 3 },
109
+ { to: [0, 14] },
110
+ ],
111
+ },
112
+ allowance: 1.5,
113
+ grain: [
114
+ [17, 34],
115
+ [17, 52],
116
+ ],
117
+ notches: [
118
+ { seg: 0, t: 0.62, count: 2 },
119
+ { seg: 1, t: 0.999 },
120
+ { seg: 3, t: 0.4 },
121
+ ],
122
+ darts: [],
123
+ label: { at: [8.6, 17.5], width: 16.8 },
124
+ pair: true,
125
+ },
126
+ "skirt-panel": {
127
+ outline: {
128
+ start: [0, 1.5],
129
+ segs: [
130
+ { to: [18, 0], c1: [6, 1.5], c2: [13, 1.1] },
131
+ { to: [22.5, 20], c1: [20.5, 6], c2: [21.8, 12] },
132
+ { to: [29, 62] },
133
+ { to: [0, 64], c1: [20, 63.4], c2: [10, 64], allowance: 4 },
134
+ { to: [0, 1.5], fold: true },
135
+ ],
136
+ },
137
+ allowance: 1.5,
138
+ grain: [
139
+ [17, 36],
140
+ [17, 56],
141
+ ],
142
+ fold: { side: "CF", from: [0, 10], to: [0, 58] },
143
+ notches: [
144
+ { seg: 2, t: 0.02 },
145
+ { seg: 0, t: 0.999 },
146
+ ],
147
+ darts: [{ seg: 0, t1: 0.455, t2: 0.585, apex: [9.75, 11] }],
148
+ label: { at: [2.5, 18], width: 14 },
149
+ pair: false,
150
+ },
151
+ "trouser-front": {
152
+ outline: {
153
+ start: [5.5, 0],
154
+ segs: [
155
+ { to: [23.5, 0.6] },
156
+ { to: [25.8, 18], c1: [25.2, 6], c2: [25.8, 12] },
157
+ { to: [23.4, 60], c1: [25.8, 36], c2: [23.4, 48] },
158
+ { to: [22.4, 95] },
159
+ { to: [3.4, 95], allowance: 4 },
160
+ { to: [2.4, 60] },
161
+ { to: [0, 27], c1: [2.4, 45], c2: [1.5, 34] },
162
+ { to: [4.5, 18], c1: [3, 26.6], c2: [4.5, 23] },
163
+ { to: [5.5, 0] },
164
+ ],
165
+ },
166
+ allowance: 1.5,
167
+ grain: [
168
+ [12.9, 50],
169
+ [12.9, 86],
170
+ ],
171
+ notches: [
172
+ { seg: 7, t: 0.999 },
173
+ { seg: 1, t: 0.999 },
174
+ { seg: 2, t: 0.999 },
175
+ { seg: 5, t: 0.999 },
176
+ ],
177
+ darts: [{ seg: 0, t1: 0.439, t2: 0.55, apex: [14.4, 9] }],
178
+ label: { at: [6.2, 30], width: 13.5 },
179
+ pair: true,
180
+ long: true,
181
+ turnedLabel: { at: [24, -22.5], width: 24 },
182
+ },
183
+ };
184
+
185
+ /** A template turned a quarter (waist to the left), for a long piece on a wide sheet. */
186
+ export function turned(template: PatternTemplate): PatternTemplate {
187
+ const t = (p: Pt): Pt => [p[1], -p[0]];
188
+ return {
189
+ ...template,
190
+ outline: {
191
+ start: t(template.outline.start),
192
+ segs: template.outline.segs.map((seg) => ({
193
+ ...seg,
194
+ to: t(seg.to),
195
+ c1: seg.c1 ? t(seg.c1) : undefined,
196
+ c2: seg.c2 ? t(seg.c2) : undefined,
197
+ })),
198
+ },
199
+ grain: [t(template.grain[0]), t(template.grain[1])],
200
+ fold: template.fold ? { ...template.fold, from: t(template.fold.from), to: t(template.fold.to) } : undefined,
201
+ darts: template.darts.map((dart) => ({ ...dart, apex: t(dart.apex) })),
202
+ label: template.turnedLabel ?? { at: t([template.label.at[0] + template.label.width, template.label.at[1]]), width: template.label.width },
203
+ };
204
+ }
@@ -29,6 +29,14 @@
29
29
  // (one number, --stack-n), which the script takes over when it hydrates. If the bundle never arrives, that CSS hides
30
30
  // the armed cover at 2.3 seconds and the hold lets go at 2.4; once the script runs, its own ceiling does the same.
31
31
  // `always` plays it on every load (the lab).
32
+ //
33
+ // An opening without a photo (hero-opener without an image, a background-* block): pass opening="photoless", or no
34
+ // hero at all. The stack then never shows a photo the page does not have. The frame flips through panels in the
35
+ // palette's own grounds (a raised surface, the field, the inverse), each with the name's first letter in the display
36
+ // face, held and counted the same way, and the last panel is the opening's own ground, read from the page at the
37
+ // target (or, without one, from what stands at the middle of the screen). The frame grows on that ground to the
38
+ // target's box (the full screen without one), so the cover lands on the opening's first frame and simply goes.
39
+ // <PreloaderStack name="Noord" opening="photoless" />
32
40
  import Image from "next/image";
33
41
  import { useRef, useState } from "react";
34
42
  import { gsap, useGSAP } from "../kit/gsap";
@@ -56,8 +64,8 @@ const T = {
56
64
  /** Runs while the browser parses the cover, before the first paint. Never throws. */
57
65
  const ARM = `(function(){try{var e=document.currentScript.parentElement;if(matchMedia("(prefers-reduced-motion: reduce)").matches)return;if(!e.hasAttribute("data-always")&&sessionStorage.getItem("${SEEN_KEY}")==="1")return;e.setAttribute("data-t0",String(performance.now()));e.setAttribute("data-armed","");document.documentElement.setAttribute("data-curtain","closed");if(!location.hash){history.scrollRestoration="manual";scrollTo(0,0)}var k={" ":1,PageDown:1,PageUp:1,ArrowDown:1,ArrowUp:1,Home:1,End:1},o={capture:true,passive:false},s=function(v){v.preventDefault();v.stopImmediatePropagation()},d=function(v){if(k[v.key])v.preventDefault()},r=function(){removeEventListener("wheel",s,o);removeEventListener("touchmove",s,o);removeEventListener("keydown",d,true);window.__preloaderStackHold=null};addEventListener("wheel",s,o);addEventListener("touchmove",s,o);addEventListener("keydown",d,true);window.__preloaderStackHold=r;setTimeout(r,${BUDGET * 1000})}catch(x){}})()`;
58
66
 
59
- /** Runs after the frame is parsed: marks the cover data-first once the first print is loaded and decoded. */
60
- const FIRST = `(function(){try{var r=document.currentScript.closest("[data-preloader]"),i=r&&r.querySelector("[data-cut] img");if(!i)return;var s=function(){r.setAttribute("data-first","")},g=function(){i.decode?i.decode().then(s,function(){}):s()};i.complete?g():i.addEventListener("load",g,{once:true})}catch(x){}})()`;
67
+ /** Runs after the frame is parsed: marks the cover data-first once the first print is loaded and decoded (a panel at once). */
68
+ const FIRST = `(function(){try{var r=document.currentScript.closest("[data-preloader]"),c=r&&r.querySelector("[data-cut]"),i=c&&c.querySelector("img");if(!c)return;var s=function(){r.setAttribute("data-first","")};if(!i)return s();var g=function(){i.decode?i.decode().then(s,function(){}):s()};i.complete?g():i.addEventListener("load",g,{once:true})}catch(x){}})()`;
61
69
 
62
70
  /** Runs after the caption is parsed: marks the cover data-fonts once the caption's own faces are loaded. */
63
71
  const FONTS = `(function(){var r;try{r=document.currentScript.closest("[data-preloader]");var f=document.fonts,d=function(){r.setAttribute("data-fonts","")};if(!f)return d();Promise.all([].map.call(r.querySelectorAll("[data-caption] p"),function(p){var c=getComputedStyle(p);return f.load(c.fontStyle+" "+c.fontWeight+" 16px "+c.fontFamily)})).then(d,d)}catch(x){r&&r.setAttribute("data-fonts","")}})()`;
@@ -81,16 +89,33 @@ function flipPlan(count: number, room: number): number[] {
81
89
  return [];
82
90
  }
83
91
 
92
+ /** The photoless panels, in order: the palette's own grounds, each in its own ink. */
93
+ const PANELS = ["bg-surface-2 text-ink", "scene-field", "bg-surface-1 text-ink", "scene-inverse"] as const;
94
+
95
+ /** The first colour behind a point that is not see-through, walking up from an element; null when there is none. */
96
+ function groundOf(start: Element | null): string | null {
97
+ for (let n = start; n; n = n.parentElement) {
98
+ const c = getComputedStyle(n).backgroundColor;
99
+ if (c && c !== "transparent" && !/^rgba\([^)]*,\s*0\)$/.test(c)) return c;
100
+ }
101
+ return null;
102
+ }
103
+
84
104
  /** Set once the cover has played in this document, so a remount never replays it. */
85
105
  let playedThisDocument = false;
86
106
  /** A teardown's lift, held back one task so a remount in the same commit (strict mode) can take it back. */
87
107
  let pendingTeardown = 0;
88
108
 
89
109
  type Props = {
90
- /** The flip-through, in order: four to six photos of the client's work. */
91
- photos: StruqImage[];
92
- /** The hero's own photo, the one the page shows in its [data-stack-target] box. */
93
- hero: StruqImage;
110
+ /** The flip-through, in order: four to six photos of the client's work. Not used when the opening is photoless. */
111
+ photos?: StruqImage[];
112
+ /** The hero's own photo, the one the page shows in its [data-stack-target] box. Left out, the opening is photoless. */
113
+ hero?: StruqImage;
114
+ /**
115
+ * "photoless" when the opening shows no photo (hero-opener without an image, a background-* block): the frame flips
116
+ * through palette panels and lands on the opening's own ground. "photo" (the default) needs `hero`.
117
+ */
118
+ opening?: "photo" | "photoless";
94
119
  /** The hero image's box on the page; the frame lands on it. Without one it lands on the full screen. */
95
120
  target?: string;
96
121
  /** The client's name, under the frame. */
@@ -102,8 +127,19 @@ type Props = {
102
127
  className?: string;
103
128
  };
104
129
 
105
- export function PreloaderStack({ photos, hero, target = "[data-stack-target]", name, always = false, onReveal, className = "" }: Props) {
130
+ export function PreloaderStack({
131
+ photos = [],
132
+ hero,
133
+ opening = "photo",
134
+ target = "[data-stack-target]",
135
+ name,
136
+ always = false,
137
+ onReveal,
138
+ className = "",
139
+ }: Props) {
106
140
  const root = useRef<HTMLDivElement>(null);
141
+ const photoless = opening === "photoless" || !hero;
142
+ const mono = name ? (Array.from(name.trim())[0] ?? "") : "";
107
143
  const [done, setDone] = useState(false);
108
144
 
109
145
  useGSAP(
@@ -150,18 +186,19 @@ export function PreloaderStack({ photos, hero, target = "[data-stack-target]", n
150
186
  const frame = q("[data-frame]")[0] as HTMLElement;
151
187
  const cuts = q("[data-cut]") as HTMLElement[];
152
188
  const layer = q("[data-hero-layer]")[0] as HTMLElement;
153
- const box = q("[data-hero-box]")[0] as HTMLElement;
154
- const heroImg = box.querySelector("img") as HTMLImageElement;
189
+ const box = q("[data-hero-box]")[0] as HTMLElement | undefined;
190
+ const fill = q("[data-hero-fill]")[0] as HTMLElement | undefined;
191
+ const heroImg = box?.querySelector("img") ?? null;
155
192
  const caption = q("[data-caption]")[0] as HTMLElement | undefined;
156
193
 
157
- // Decode everything up front: a cut never shows a half-drawn photo.
158
- const decoded = cuts.map(() => false);
159
- let heroReady = false;
194
+ // Decode everything up front: a cut never shows a half-drawn photo. A panel has nothing to decode.
195
+ const decoded = cuts.map((c) => !c.querySelector("img"));
196
+ let heroReady = !heroImg;
160
197
  cuts.forEach((c, i) => {
161
198
  const img = c.querySelector("img");
162
199
  img?.decode().then(() => (decoded[i] = true), () => undefined);
163
200
  });
164
- heroImg.decode().then(() => (heroReady = true), () => undefined);
201
+ heroImg?.decode().then(() => (heroReady = true), () => undefined);
165
202
 
166
203
  const begin = () => {
167
204
  if (cancelled) return;
@@ -189,8 +226,13 @@ export function PreloaderStack({ photos, hero, target = "[data-stack-target]", n
189
226
  const vh = el.clientHeight;
190
227
  const S = boxOf(frame.getBoundingClientRect());
191
228
  const R = found ? boxOf(found.getBoundingClientRect()) : { x: 0, y: 0, w: vw, h: vh };
192
- const iw = heroImg.naturalWidth || hero.width;
193
- const ih = heroImg.naturalHeight || hero.height;
229
+ // Photoless: the landing panel is the opening's own ground, at the target or at the middle of the screen.
230
+ if (fill) {
231
+ const under = found ?? document.elementsFromPoint(vw / 2, vh / 2).find((n) => !el.contains(n)) ?? null;
232
+ fill.style.backgroundColor = groundOf(under) ?? getComputedStyle(ground).backgroundColor;
233
+ }
234
+ const iw = heroImg?.naturalWidth || hero?.width || 1;
235
+ const ih = heroImg?.naturalHeight || hero?.height || 1;
194
236
  // A hero whose window shows a larger registered photo marks that photo's box (data-stack-photo, inside the
195
237
  // target): the print is then laid as object-cover of that box, while the frame still lands on the target.
196
238
  const photoEl = found?.querySelector<HTMLElement>("[data-stack-photo]");
@@ -198,7 +240,7 @@ export function PreloaderStack({ photos, hero, target = "[data-stack-target]", n
198
240
  // The photo at its own aspect, scaled and centred exactly as object-cover shows it in P. Seen through R it
199
241
  // is the page's hero, pixel for pixel; seen through a smaller frame, scaled to cover that frame.
200
242
  const s1 = Math.max(P.w / iw, P.h / ih);
201
- Object.assign(box.style, {
243
+ if (box) Object.assign(box.style, {
202
244
  left: `${P.x + P.w / 2 - (iw * s1) / 2}px`,
203
245
  top: `${P.y + P.h / 2 - (ih * s1) / 2}px`,
204
246
  width: `${iw * s1}px`,
@@ -231,7 +273,7 @@ export function PreloaderStack({ photos, hero, target = "[data-stack-target]", n
231
273
  dx = lerp(S.x + S.w / 2, P.x + P.w / 2, grown.p) - (P.x + P.w / 2);
232
274
  dy = lerp(S.y + S.h / 2, P.y + P.h / 2, grown.p) - (P.y + P.h / 2);
233
275
  }
234
- box.style.transform = `translate(${dx}px, ${dy}px) scale(${k})`;
276
+ if (box) box.style.transform = `translate(${dx}px, ${dy}px) scale(${k})`;
235
277
  layer.style.clipPath = `inset(${F.y}px ${vw - F.x - F.w}px ${vh - F.y - F.h}px ${F.x}px)`;
236
278
  };
237
279
  paint();
@@ -326,7 +368,13 @@ export function PreloaderStack({ photos, hero, target = "[data-stack-target]", n
326
368
  <div data-ground="" className="absolute inset-0 bg-surface-0 text-ink">
327
369
  <div className="absolute left-1/2 top-[45%] w-[44vw] -translate-x-1/2 -translate-y-1/2 md:w-[19vw]">
328
370
  <div data-frame="" className="relative aspect-[3/4] w-full overflow-hidden">
329
- {photos.map((photo, i) => (
371
+ {photoless
372
+ ? PANELS.map((panel) => (
373
+ <div key={panel} data-cut="" className={`invisible absolute inset-0 grid place-items-center ${panel}`}>
374
+ {mono ? <span className="font-display text-[24vw] leading-none md:text-[10vw]">{mono}</span> : null}
375
+ </div>
376
+ ))
377
+ : photos.map((photo, i) => (
330
378
  <div key={photo.src} data-cut="" className="invisible absolute inset-0">
331
379
  {/* The first print shows from the first paint (blocks.css), so it is fetched first. */}
332
380
  <Image
@@ -339,7 +387,7 @@ export function PreloaderStack({ photos, hero, target = "[data-stack-target]", n
339
387
  className="object-cover"
340
388
  />
341
389
  </div>
342
- ))}
390
+ ))}
343
391
  </div>
344
392
  {/* Parsed right after the frame: the first print shows (blocks.css) only once it is loaded and decoded. */}
345
393
  <script dangerouslySetInnerHTML={{ __html: FIRST }} />
@@ -361,9 +409,14 @@ export function PreloaderStack({ photos, hero, target = "[data-stack-target]", n
361
409
  </div>
362
410
  {/* The hero's photo over everything, seen through the growing frame. Same file and sizes as the page's hero. */}
363
411
  <div data-hero-layer="" className="invisible absolute inset-0">
364
- <div data-hero-box="" className="absolute origin-center">
365
- <Image src={hero.src} alt="" fill sizes="100vw" priority unoptimized={hero.unoptimized} className="object-cover" />
366
- </div>
412
+ {hero && !photoless ? (
413
+ <div data-hero-box="" className="absolute origin-center">
414
+ <Image src={hero.src} alt="" fill sizes="100vw" priority unoptimized={hero.unoptimized} className="object-cover" />
415
+ </div>
416
+ ) : (
417
+ // Photoless: the opening's own ground, set from the page as the flip begins.
418
+ <div data-hero-fill="" className="absolute inset-0 bg-surface-0" />
419
+ )}
367
420
  </div>
368
421
  </div>
369
422
  );
@@ -10,6 +10,17 @@
10
10
  // <HeroOpener image={images.hero[0]} split={["Vuur", "Noord"]} lead="..." cta={...} />
11
11
  //
12
12
  // <HeroOpener image={images.hero[0]} title="Koken op vuur." accent="vuur" lead="..." cta={...} />
13
+ // <HeroOpener title="Getijden" backdrop={<TideLines />} lead="..." cta={...} />
14
+ //
15
+ // Without a photo the opener is type on the inverse ground, over the site's own motif. `backdrop` takes the builder's
16
+ // own drawing (an SVG or a small canvas from the site's world), set behind the display line; it wins over `scene`.
17
+ // `scene` picks a stock WebGL field instead ("dots", "contours") or nothing at all ("none": the ground and the type).
18
+ // The dots are the default only so earlier pages keep working: a fallback, not a choice. Every photoless site that
19
+ // leaves it on opens on the same dot wave, so give it a backdrop of its own or pick a scene that fits.
20
+ //
21
+ // The section says its ground to the header bar (data-bar-tone): "photo" over a photo, "inverse" without one, and
22
+ // "ground" when a className sets a page surface (bg-surface-*). Any other ground a className brings needs `barTone`.
23
+ // The scrim and the handover's dim are data-veil, so the bar reads the photo under it as the page shows it.
13
24
  //
14
25
  // The line is the display face at poster size, with at most one italic accent word: the site's one accent word, marked
15
26
  // in place when the title holds it (the same mark as the Statement and the Close, kept whole by SplitText's line cut),
@@ -20,7 +31,7 @@
20
31
  // The entrance (the line rising through its masks, the split words, the lead and the CTA) waits for
21
32
  // any preloader or page transition to start leaving (whenUncovered, from the helper the frames share),
22
33
  // and plays at once when nothing covers the page.
23
- import { useRef } from "react";
34
+ import { useRef, type ReactNode } from "react";
24
35
  import { findAccent, withAccent } from "../kit/accent";
25
36
  import { HeroCanvas } from "../kit/HeroCanvas";
26
37
  import { MagneticButton } from "../kit/MagneticButton";
@@ -29,7 +40,7 @@ import type { StruqImage } from "../kit/images";
29
40
  import { whenUncovered } from "../preloaders/preloader-slices-uncovered";
30
41
 
31
42
  type Props = {
32
- /** The beeldbank or client photo. Without one (imageFit "none"), the opener is typographic on the inverse ground over the dots scene. */
43
+ /** The beeldbank or client photo. Without one, the opener is typographic on the inverse ground, over `backdrop` or `scene`. */
33
44
  image?: StruqImage;
34
45
  /** The poster line ("across"). */
35
46
  title?: string;
@@ -52,10 +63,34 @@ type Props = {
52
63
  * beeldbank. With no calm left or right side, the lead gets a fixed backing and a step up in size.
53
64
  */
54
65
  calm?: "left" | "right" | "none";
66
+ /**
67
+ * Without a photo: the stock WebGL field behind the type, or "none" for the ground alone. "dots" by default, kept
68
+ * only so earlier pages keep working; a backdrop wins over it.
69
+ */
70
+ scene?: "dots" | "contours" | "none";
71
+ /** Without a photo: the builder's own motif (an SVG or a small canvas), drawn behind the display line. Wins over scene. */
72
+ backdrop?: ReactNode;
73
+ /** The ground the header bar reads here, when a className turns it to one the block cannot tell from its classes. */
74
+ barTone?: "ground" | "photo" | "inverse";
55
75
  className?: string;
56
76
  };
57
77
 
58
- export function HeroOpener({ image, title, accent, split, lead, cta, header, scrim = 0.34, calm, className = "" }: Props) {
78
+ export function HeroOpener({
79
+ image,
80
+ title,
81
+ accent,
82
+ split,
83
+ lead,
84
+ cta,
85
+ header,
86
+ scrim = 0.34,
87
+ calm,
88
+ scene = "dots",
89
+ backdrop,
90
+ barTone,
91
+ className = "",
92
+ }: Props) {
93
+ const tone = barTone ?? (image ? "photo" : /(^|\s)bg-surface-\d(\s|$)/.test(className) ? "ground" : "inverse");
59
94
  // The lead goes on the photo's calm side; the CTA takes the other. Over a busy frame (no calm
60
95
  // side known) the lead gets a flat backing at a fixed opacity and is set a step larger.
61
96
  const side = calm ?? (image?.negativeSpace === "left" || image?.negativeSpace === "right" ? image.negativeSpace : image ? "none" : "left");
@@ -129,11 +164,24 @@ export function HeroOpener({ image, title, accent, split, lead, cta, header, scr
129
164
  const size = "text-[19vw] md:text-[16vw]";
130
165
  const poster = `font-display block ${size} leading-[0.92] tracking-[-0.045em] [word-spacing:0.09em]`;
131
166
  return (
132
- <section ref={stage} data-showpiece="hero-opener" className={`relative h-svh overflow-hidden ${image ? "bg-black text-on-photo" : "scene-inverse"} ${className}`}>
167
+ <section
168
+ ref={stage}
169
+ data-showpiece="hero-opener"
170
+ data-bar-tone={tone}
171
+ className={`relative h-svh overflow-hidden ${image ? "bg-black text-on-photo" : "scene-inverse"} ${className}`}
172
+ >
133
173
  <div data-opener-inner="" className="absolute inset-0 origin-top">
134
- {image ? <HeroCanvas scene="ripple" image={image} grade={0.15} priority /> : <HeroCanvas scene="dots" />}
135
- {image ? <div aria-hidden className="absolute inset-0 bg-black" style={{ opacity: scrim }} /> : null}
136
- <div data-opener-dim="" aria-hidden className="absolute inset-0 bg-black opacity-0" />
174
+ {image ? (
175
+ <HeroCanvas scene="ripple" image={image} grade={0.15} priority />
176
+ ) : backdrop ? (
177
+ <div data-opener-backdrop="" aria-hidden className="absolute inset-0 overflow-hidden">
178
+ {backdrop}
179
+ </div>
180
+ ) : scene !== "none" ? (
181
+ <HeroCanvas scene={scene} />
182
+ ) : null}
183
+ {image ? <div data-veil="" aria-hidden className="absolute inset-0 bg-black" style={{ opacity: scrim }} /> : null}
184
+ <div data-opener-dim="" data-veil="" aria-hidden className="absolute inset-0 bg-black opacity-0" />
137
185
  {header ? <div className="absolute inset-x-0 top-0 z-20">{header}</div> : null}
138
186
  {split ? (
139
187
  <h1 aria-label={split.join(" ")} className="absolute inset-x-0 top-1/2 z-10 flex -translate-y-1/2 justify-between px-sheet-margin">
@@ -8,8 +8,15 @@
8
8
  // <ImageToFullbleed image={images.hero[0]} left="Zout" right="Noord" line="Koken met wat het seizoen geeft." />
9
9
  //
10
10
  // Reduced motion and phones: the composed still, the photo full bleed with both words and the line.
11
+ //
12
+ // Each word fits its own column at every width, however long it is (a Dutch compound such as "Vakmanschap"): the
13
+ // first paint sizes the pair from the longer word's letter count against its column (half the row on a phone, the
14
+ // room beside the small frame on a wide screen), then the script measures both words in the loaded face and sets
15
+ // the pair one size down until the longer one fits, again on a resize. Without scripts a word that still overflows
16
+ // breaks with a hyphen in the document's language, never past its column. Nothing clips the words, so no descender is
17
+ // ever cut.
11
18
  import Image from "next/image";
12
- import { useRef } from "react";
19
+ import { useRef, type CSSProperties } from "react";
13
20
  import { gsap, useGSAP } from "../kit/gsap";
14
21
  import type { StruqImage } from "../kit/images";
15
22
 
@@ -22,8 +29,13 @@ type Props = {
22
29
  className?: string;
23
30
  };
24
31
 
32
+ /** The share of an em a letter of a display face takes on average: the first paint's estimate, measured after. */
33
+ const LETTER_EM = 0.6;
34
+
25
35
  export function ImageToFullbleed({ image, left, right, line, className = "" }: Props) {
26
36
  const stage = useRef<HTMLElement>(null);
37
+ // The longer word's width in em, estimated from its letters, for the first paint's fit.
38
+ const letters = Math.max(1, Array.from(left).length, Array.from(right).length) * LETTER_EM;
27
39
 
28
40
  useGSAP(
29
41
  () => {
@@ -62,11 +74,53 @@ export function ImageToFullbleed({ image, left, right, line, className = "" }: P
62
74
  frame.style.clipPath = "";
63
75
  };
64
76
  });
65
- return () => mm.revert();
77
+ // The pair one size, set so the longer word fits its column: half the row, less the small frame and a gap on
78
+ // either side of it while the frame waits between the words (data-motion).
79
+ const row = q("[data-name]")[0] as HTMLElement;
80
+ const words = q("[data-left], [data-right]") as HTMLElement[];
81
+ let frame = 0;
82
+ const fit = () => {
83
+ if (!row) return;
84
+ words.forEach((w) => (w.style.fontSize = ""));
85
+ const cs = getComputedStyle(row);
86
+ const inner = row.clientWidth - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0);
87
+ const gap = parseFloat(cs.columnGap) || 0;
88
+ const hole = el.dataset.motion ? el.clientWidth * 0.2 : 0;
89
+ const col = (inner - hole - gap * (hole ? 2 : 1)) / 2;
90
+ let k = 1;
91
+ for (const w of words) {
92
+ w.style.whiteSpace = "nowrap";
93
+ const range = document.createRange();
94
+ range.selectNodeContents(w);
95
+ const width = range.getBoundingClientRect().width;
96
+ w.style.whiteSpace = "";
97
+ if (width > col && col > 0) k = Math.min(k, col / width);
98
+ }
99
+ if (k >= 1) return;
100
+ const size = parseFloat(getComputedStyle(words[0]).fontSize);
101
+ words.forEach((w) => (w.style.fontSize = `${Math.floor(size * k * 10) / 10}px`));
102
+ };
103
+ const later = () => {
104
+ cancelAnimationFrame(frame);
105
+ frame = requestAnimationFrame(fit);
106
+ };
107
+ fit();
108
+ document.fonts?.ready.then(later);
109
+ window.addEventListener("resize", later);
110
+ return () => {
111
+ cancelAnimationFrame(frame);
112
+ window.removeEventListener("resize", later);
113
+ words.forEach((w) => (w.style.fontSize = ""));
114
+ mm.revert();
115
+ };
66
116
  },
67
117
  { scope: stage },
68
118
  );
69
119
 
120
+ // Never larger than the column: half the row on a phone; on a wide screen the room beside the small frame (20vw).
121
+ const word =
122
+ "font-display block min-w-0 break-words hyphens-auto leading-[0.85] tracking-[-0.04em] text-[length:min(18vw,calc((100vw_-_3rem_-_4vw)/2/var(--itf-letters)))] md:text-[length:min(12vw,calc(32vw/var(--itf-letters)))]";
123
+
70
124
  return (
71
125
  <section ref={stage} data-showpiece="image-to-fullbleed" className={`relative h-svh overflow-hidden bg-surface-0 ${className}`}>
72
126
  <div data-frame="" className="absolute inset-0 overflow-hidden">
@@ -75,12 +129,16 @@ export function ImageToFullbleed({ image, left, right, line, className = "" }: P
75
129
  </div>
76
130
  <div data-scrim="" aria-hidden className="absolute inset-0 bg-black opacity-30" />
77
131
  </div>
78
- <div className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 items-center justify-between px-6 text-on-photo md:px-[4vw] motion-safe:md:text-ink">
79
- <span data-left="" className="font-display block text-[18vw] leading-[0.85] tracking-[-0.04em] md:text-[12vw]">{left}</span>
80
- <span data-right="" className="font-display block text-[18vw] leading-[0.85] tracking-[-0.04em] md:text-[12vw]">{right}</span>
132
+ <div
133
+ data-name=""
134
+ className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 items-center justify-between gap-[4vw] px-6 text-on-photo md:px-[4vw] motion-safe:md:text-ink"
135
+ style={{ "--itf-letters": letters.toFixed(2) } as CSSProperties}
136
+ >
137
+ <span data-left="" className={word}>{left}</span>
138
+ <span data-right="" className={`${word} text-right`}>{right}</span>
81
139
  </div>
82
140
  {line ? (
83
- <p data-line="" className="font-display text-h2 absolute bottom-[10vh] left-6 max-w-[18ch] text-on-photo md:left-[8vw]">
141
+ <p data-line="" className="font-display text-h2 absolute bottom-[10vh] left-6 right-6 max-w-[18ch] break-words hyphens-auto text-on-photo md:left-[8vw] md:right-[8vw]">
84
142
  {line}
85
143
  </p>
86
144
  ) : null}