@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.
- package/README.md +27 -27
- package/package.json +2 -2
- 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
|
@@ -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]"),
|
|
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
|
|
92
|
-
/** The hero's own photo, the one the page shows in its [data-stack-target] box. */
|
|
93
|
-
hero
|
|
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({
|
|
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
|
|
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(() =>
|
|
159
|
-
let heroReady =
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
365
|
-
<
|
|
366
|
-
|
|
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
|
|
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({
|
|
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
|
|
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 ?
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
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}
|