@struq/skill 0.4.3 → 0.5.1
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/cli/struq.mjs +2 -2
- package/package.json +6 -6
- package/skills/struq/SKILL.md +145 -89
- package/skills/struq/blocks/CATALOG.md +74 -22
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +34 -12
- package/skills/struq/blocks/backgrounds/background-facets.tsx +14 -6
- package/skills/struq/blocks/backgrounds/background-silk.tsx +40 -15
- package/skills/struq/blocks/catalog.json +697 -16
- package/skills/struq/blocks/film.json +426 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +81 -55
- package/skills/struq/blocks/footers/footer-reveal.tsx +1 -1
- package/skills/struq/blocks/headers/header-bar.tsx +24 -13
- package/skills/struq/blocks/headers/menu-curtain.tsx +24 -11
- package/skills/struq/blocks/heroes/hero-framed.tsx +33 -20
- package/skills/struq/blocks/heroes/hero-index.tsx +32 -11
- package/skills/struq/blocks/heroes/hero-poster.tsx +68 -35
- package/skills/struq/blocks/heroes/hero-split.tsx +22 -9
- package/skills/struq/blocks/heroes/hero-still.tsx +80 -74
- package/skills/struq/blocks/kit/accent.ts +82 -0
- package/skills/struq/blocks/kit/blocks.css +78 -35
- package/skills/struq/blocks/kit/quiet/Close.tsx +73 -0
- package/skills/struq/blocks/kit/quiet/PhotoText.tsx +79 -0
- package/skills/struq/blocks/kit/quiet/Rows.tsx +81 -0
- package/skills/struq/blocks/kit/quiet/Statement.tsx +157 -0
- package/skills/struq/blocks/kit/quiet/index.ts +7 -0
- package/skills/struq/blocks/kit/quiet/sheet.ts +12 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +87 -36
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +116 -46
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +10 -7
- package/skills/struq/blocks/sections/contact-form.tsx +1 -1
- package/skills/struq/blocks/sections/cta-closing.tsx +3 -2
- package/skills/struq/blocks/sections/fanned-cards.tsx +14 -10
- package/skills/struq/blocks/sections/film-strip.tsx +18 -14
- package/skills/struq/blocks/sections/grid-assembly.tsx +16 -11
- package/skills/struq/blocks/sections/manifesto.tsx +3 -2
- package/skills/struq/blocks/sections/panel-accordion.tsx +3 -2
- package/skills/struq/blocks/sections/split-pair.tsx +4 -3
- package/skills/struq/blocks/showpieces/hero-opener.tsx +76 -17
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +3 -1
- package/skills/struq/blocks/transitions/zoom-through.tsx +9 -10
- package/skills/struq/canon/canon.json +1 -1
- package/skills/struq/references/craft.md +4 -0
- package/skills/struq/references/film.md +158 -0
- package/skills/struq/references/iconography.md +1 -1
- package/skills/struq/references/triage.md +3 -0
- package/skills/struq/scripts/blocks.mjs +89 -7
- package/skills/struq/scripts/canon.mjs +8 -1
- package/skills/struq/scripts/check.mjs +99 -15
- package/skills/struq/scripts/film-check.mjs +997 -0
- package/skills/struq/scripts/gate.mjs +146 -0
- package/skills/struq/scripts/images.mjs +64 -37
- package/skills/struq/scripts/packs.mjs +73 -12
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
//
|
|
23
23
|
// <HeroIndex items={[{ title: "Olijfhof", place: "Alentejo", year: "2025", image: images.hero[2], href: "/werk/olijfhof" }, ...]} />
|
|
24
24
|
//
|
|
25
|
+
// `accent` (optional) is the site's one accent word: the names are this hero's headline, and where the word first
|
|
26
|
+
// stands in one of them it is set in the pairing's italic accent face, inside the name's mask, as the Statement and
|
|
27
|
+
// the Close set the same word. Left out, or in no name, the list is exactly as it was.
|
|
28
|
+
//
|
|
25
29
|
// The photo box carries data-stack-target, so preloader-stack and page-transition-stack land on the first photograph.
|
|
26
30
|
// Keyboard: focus swaps like hover; one focus ring, on the link. Phones and touch: the list over the first photograph,
|
|
27
31
|
// a tap follows the link. Reduced motion: swaps are hard cuts, no drift, the names simply stand. Without scripts: the
|
|
@@ -37,6 +41,7 @@
|
|
|
37
41
|
// Reduced motion: the same stretches, cuts instead of wipes.
|
|
38
42
|
import Image from "next/image";
|
|
39
43
|
import { useRef } from "react";
|
|
44
|
+
import { accentLines } from "../kit/accent";
|
|
40
45
|
import { ScrollTrigger, gsap, useGSAP } from "../kit/gsap";
|
|
41
46
|
import type { StruqImage } from "../kit/images";
|
|
42
47
|
import { photoLuminances, type PhotoBox } from "../kit/photo-read";
|
|
@@ -62,6 +67,9 @@ type Props = {
|
|
|
62
67
|
side?: Side;
|
|
63
68
|
/** Five or six projects; the first is the photograph at rest. */
|
|
64
69
|
items: Item[];
|
|
70
|
+
/** The site's one accent word: where it first stands in the list of names it is set in the pairing's italic accent
|
|
71
|
+
* face (--face-accent), as the Statement and the Close set it. A word no name holds changes nothing. */
|
|
72
|
+
accent?: string;
|
|
65
73
|
/** The composition; the first is the default. */
|
|
66
74
|
variant?: HeroIndexVariant;
|
|
67
75
|
className?: string;
|
|
@@ -101,6 +109,8 @@ const WIPE = 0.85;
|
|
|
101
109
|
const RETURN = 0.45;
|
|
102
110
|
|
|
103
111
|
const FINE = "(hover: hover) and (pointer: fine) and (min-width: 1024px)";
|
|
112
|
+
/** A name's size, set on the name and on its mask (the mask's room for accents and descenders is in its own em). */
|
|
113
|
+
const NAME_SIZE = "text-[min(11.5vw,7.6svh)] md:text-[min(clamp(2.75rem,1rem_+_5.2vw,7.5rem),8.4svh)]";
|
|
104
114
|
|
|
105
115
|
const linear = (c: number) =>
|
|
106
116
|
c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
@@ -162,8 +172,12 @@ function texture(
|
|
|
162
172
|
return Math.sqrt(at(ys, 0.75)) - Math.sqrt(at(ys, 0.25));
|
|
163
173
|
}
|
|
164
174
|
|
|
165
|
-
export function HeroIndex({ items, side, variant = "lijst", className = "" }: Props) {
|
|
175
|
+
export function HeroIndex({ items, side, accent, variant = "lijst", className = "" }: Props) {
|
|
166
176
|
const root = useRef<HTMLElement>(null);
|
|
177
|
+
const names = accentLines(
|
|
178
|
+
items.map((item) => item.title),
|
|
179
|
+
accent,
|
|
180
|
+
);
|
|
167
181
|
// Over a photograph the names need a measured ink; in the window they stand on the ground.
|
|
168
182
|
const overPhoto = variant !== "venster";
|
|
169
183
|
const walks = variant === "doorlopen";
|
|
@@ -236,9 +250,12 @@ export function HeroIndex({ items, side, variant = "lijst", className = "" }: Pr
|
|
|
236
250
|
return rows.map((row) => {
|
|
237
251
|
const name = row.querySelector<HTMLElement>("[data-name]")!;
|
|
238
252
|
const meta = row.querySelector<HTMLElement>("[data-meta]")!;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
253
|
+
// Where the line stands: the mask's box less its room for accents and descenders (blocks.css), read as
|
|
254
|
+
// laid out, so the rise in flight does not move it.
|
|
255
|
+
const maskEl = row.querySelector<HTMLElement>(".struq-line-mask")!;
|
|
256
|
+
const box = maskEl.getBoundingClientRect();
|
|
257
|
+
const cs = getComputedStyle(maskEl);
|
|
258
|
+
const mask = { top: box.top + parseFloat(cs.paddingTop), bottom: box.bottom - parseFloat(cs.paddingBottom) };
|
|
242
259
|
const range = document.createRange();
|
|
243
260
|
range.selectNodeContents(name);
|
|
244
261
|
const g = range.getBoundingClientRect();
|
|
@@ -745,8 +762,10 @@ export function HeroIndex({ items, side, variant = "lijst", className = "" }: Pr
|
|
|
745
762
|
// The window stands on the side away from the list: wide screens beside it, phones above it.
|
|
746
763
|
const windowBox =
|
|
747
764
|
listSide === "left"
|
|
748
|
-
?
|
|
749
|
-
|
|
765
|
+
? // sheet-allow: the window's inner edge at 54vw leaves the list its half
|
|
766
|
+
"absolute inset-x-sheet-margin top-6 h-[38svh] overflow-hidden md:inset-x-auto md:top-[7svh] md:bottom-[7svh] md:left-[54vw] md:right-sheet-margin md:h-auto"
|
|
767
|
+
: // sheet-allow: the window's inner edge at 54vw leaves the list its half
|
|
768
|
+
"absolute inset-x-sheet-margin top-6 h-[38svh] overflow-hidden md:inset-x-auto md:top-[7svh] md:bottom-[7svh] md:left-sheet-margin md:right-[54vw] md:h-auto";
|
|
750
769
|
|
|
751
770
|
return (
|
|
752
771
|
<section
|
|
@@ -797,8 +816,8 @@ export function HeroIndex({ items, side, variant = "lijst", className = "" }: Pr
|
|
|
797
816
|
aria-label="Werk"
|
|
798
817
|
className={
|
|
799
818
|
walks
|
|
800
|
-
? "absolute inset-0 z-10 flex flex-col items-center justify-center px-
|
|
801
|
-
: "absolute inset-x-0 bottom-0 z-10 px-
|
|
819
|
+
? "absolute inset-0 z-10 flex flex-col items-center justify-center px-sheet-margin"
|
|
820
|
+
: "absolute inset-x-0 bottom-0 z-10 px-sheet-margin pb-[7svh] md:pb-[6svh]"
|
|
802
821
|
}
|
|
803
822
|
>
|
|
804
823
|
<ul
|
|
@@ -817,7 +836,9 @@ export function HeroIndex({ items, side, variant = "lijst", className = "" }: Pr
|
|
|
817
836
|
href={item.href}
|
|
818
837
|
className="group/row block w-fit rounded-[0.15rem] py-[0.2svh] outline-offset-[0.35rem] transition-opacity duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-current group-data-[active]/list:not-data-[current]:opacity-80"
|
|
819
838
|
>
|
|
820
|
-
|
|
839
|
+
{/* The shared line mask carries the name's size, so its room is the name's; leading 0 keeps the
|
|
840
|
+
line box to the name and its place line. */}
|
|
841
|
+
<span className={`struq-line-mask block leading-[0] ${NAME_SIZE}`}>
|
|
821
842
|
<span data-rise="" className="block">
|
|
822
843
|
{/* The place line stands right-aligned under its name. */}
|
|
823
844
|
<span
|
|
@@ -826,9 +847,9 @@ export function HeroIndex({ items, side, variant = "lijst", className = "" }: Pr
|
|
|
826
847
|
>
|
|
827
848
|
<span
|
|
828
849
|
data-name=""
|
|
829
|
-
className=
|
|
850
|
+
className={`font-display whitespace-nowrap leading-[0.98] tracking-[-0.035em] ${NAME_SIZE}`}
|
|
830
851
|
>
|
|
831
|
-
{
|
|
852
|
+
{names[i]}
|
|
832
853
|
</span>
|
|
833
854
|
<span
|
|
834
855
|
data-meta=""
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
// <HeroPoster image={images.hero[1]} lines={["Noord bouwt", "met daglicht"]} place="Architectuur en bouw, Rotterdam"
|
|
23
23
|
// link={{ label: "Bekijk het werk", href: "#werk" }} />
|
|
24
24
|
//
|
|
25
|
+
// `accent` (optional) is the site's one accent word: where it first stands in the two lines it is set in the pairing's
|
|
26
|
+
// italic accent face, as the Statement and the Close set the same word. The fit measures each line with the mark in
|
|
27
|
+
// place and waits for the accent face as it waits for the display face, so a wider italic is fitted, never spilled.
|
|
28
|
+
// Left out, or not in the lines, the poster is exactly as it was.
|
|
29
|
+
//
|
|
25
30
|
// Phones: one word per line, the window between at the height that is left, showing the photo's own crop; the
|
|
26
31
|
// entrance only (no pin, no open).
|
|
27
32
|
//
|
|
@@ -36,6 +41,7 @@
|
|
|
36
41
|
// slides to registration and opens to the full screen, so the crops merge into one photograph.
|
|
37
42
|
import Image from "next/image";
|
|
38
43
|
import { useRef } from "react";
|
|
44
|
+
import { accentFaceReady, accentLines } from "../kit/accent";
|
|
39
45
|
import { gsap, useGSAP } from "../kit/gsap";
|
|
40
46
|
import type { StruqImage } from "../kit/images";
|
|
41
47
|
import { covered, whenUncovered } from "../preloaders/preloader-slices-uncovered";
|
|
@@ -58,6 +64,9 @@ type Inner = {
|
|
|
58
64
|
image: StruqImage;
|
|
59
65
|
/** The two lines of the poster: the studio's name, or its line split in two. */
|
|
60
66
|
lines: [string, string];
|
|
67
|
+
/** The site's one accent word: where it first stands in the lines it is set in the pairing's italic accent face
|
|
68
|
+
* (--face-accent), as the Statement and the Close set it. A word the lines do not hold changes nothing. */
|
|
69
|
+
accent?: string;
|
|
61
70
|
/** The foot line: place, discipline. */
|
|
62
71
|
place?: string;
|
|
63
72
|
/** One link in the foot line. */
|
|
@@ -73,6 +82,13 @@ const EM_PER_CHAR = 0.66;
|
|
|
73
82
|
const LEADING = 0.84;
|
|
74
83
|
|
|
75
84
|
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
85
|
+
/** The poster's side margin as laid out: the sheet margin on its padded box ([data-hp-sheet]), read in px. */
|
|
86
|
+
const sideOf = (el: HTMLElement) => {
|
|
87
|
+
const pad = el.querySelector<HTMLElement>("[data-hp-sheet]");
|
|
88
|
+
return pad ? parseFloat(getComputedStyle(pad).paddingLeft) || 0 : 0;
|
|
89
|
+
};
|
|
90
|
+
/** The width the script-free estimates fit into: the screen inside the sheet margin on both sides. */
|
|
91
|
+
const INSIDE = "(100vw - 2 * var(--spacing-sheet-margin))";
|
|
76
92
|
|
|
77
93
|
export function HeroPoster({ variant = "window", ...rest }: Props) {
|
|
78
94
|
if (variant === "column") return <PosterColumn {...rest} />;
|
|
@@ -80,9 +96,10 @@ export function HeroPoster({ variant = "window", ...rest }: Props) {
|
|
|
80
96
|
return <PosterWindow {...rest} />;
|
|
81
97
|
}
|
|
82
98
|
|
|
83
|
-
function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
|
|
99
|
+
function PosterWindow({ image, lines, accent, place, link, className = "" }: Inner) {
|
|
84
100
|
const stage = useRef<HTMLElement>(null);
|
|
85
101
|
const words = lines.map((l) => l.split(" "));
|
|
102
|
+
const marked = markWords(words, accent);
|
|
86
103
|
const longest = (ws: string[]) => Math.max(...ws.map((w) => w.length));
|
|
87
104
|
|
|
88
105
|
useGSAP(
|
|
@@ -113,7 +130,7 @@ function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
|
|
|
113
130
|
const W = el.clientWidth;
|
|
114
131
|
const H = el.clientHeight;
|
|
115
132
|
const wide = W >= 768;
|
|
116
|
-
const room = W -
|
|
133
|
+
const room = W - 2 * sideOf(el);
|
|
117
134
|
const widthOf = (w: HTMLElement) => {
|
|
118
135
|
const c = w.cloneNode(true) as HTMLElement;
|
|
119
136
|
Object.assign(c.style, { position: "absolute", left: "0", top: "0", visibility: "hidden", fontSize: "100px" });
|
|
@@ -198,7 +215,7 @@ function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
|
|
|
198
215
|
};
|
|
199
216
|
// With the face in, or on what there is after FONT_WAIT; and again whenever a face arrives later than that. The
|
|
200
217
|
// poster counts as shown once the entrance has started (or the fallback did it), so a late face eases in.
|
|
201
|
-
const faceIn =
|
|
218
|
+
const faceIn = faceReady(el);
|
|
202
219
|
settle(frame);
|
|
203
220
|
faceIn.then(() => {
|
|
204
221
|
refit();
|
|
@@ -318,23 +335,23 @@ function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
|
|
|
318
335
|
mm.revert();
|
|
319
336
|
};
|
|
320
337
|
},
|
|
321
|
-
{ scope: stage, dependencies: [lines[0], lines[1]] },
|
|
338
|
+
{ scope: stage, dependencies: [lines[0], lines[1], accent] },
|
|
322
339
|
);
|
|
323
340
|
|
|
324
341
|
// The script-free estimate per line: the width over its characters, and on wide screens a height cap so two lines
|
|
325
342
|
// and a window always fit. Phones stack one word per line, so the longest word sets it.
|
|
326
343
|
const est = (i: 0 | 1) =>
|
|
327
|
-
`min(18vw, calc(
|
|
328
|
-
const estPhone = (i: 0 | 1) => `calc(
|
|
344
|
+
`min(18vw, calc(${INSIDE} / ${(lines[i].length * EM_PER_CHAR).toFixed(2)}), 30svh)`;
|
|
345
|
+
const estPhone = (i: 0 | 1) => `calc(${INSIDE} / ${(longest(words[i]) * EM_PER_CHAR).toFixed(2)})`;
|
|
329
346
|
|
|
330
347
|
// The line's box is LEADING of its size per row; the mask carries the descender room in the display em.
|
|
331
348
|
const word = "font-display block w-fit whitespace-nowrap leading-[0.84] tracking-[-0.045em] text-ink";
|
|
332
|
-
const mask = "
|
|
333
|
-
const small = "
|
|
349
|
+
const mask = "struq-line-mask block";
|
|
350
|
+
const small = "struq-line-mask block";
|
|
334
351
|
// Inline, because the kit balances display type (text-wrap), which would let a poster line wrap; and no transition,
|
|
335
352
|
// or a page that transitions every property hands the fit a size from mid-change.
|
|
336
353
|
const nowrap = { whiteSpace: "nowrap", textWrap: "nowrap", transition: "none" } as React.CSSProperties;
|
|
337
|
-
const stack = (parts:
|
|
354
|
+
const stack = (parts: React.ReactNode[]) =>
|
|
338
355
|
parts.map((part, i) => (
|
|
339
356
|
<span key={i} data-hp-part="" className="inline-block max-md:block">
|
|
340
357
|
{part}
|
|
@@ -360,7 +377,7 @@ function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
|
|
|
360
377
|
<h1 className="sr-only">{`${lines[0]} ${lines[1]}`}</h1>
|
|
361
378
|
|
|
362
379
|
{/* The poster: line, window, line, foot. The lines stand above the photo as it opens past them. */}
|
|
363
|
-
<div className="relative flex h-full flex-col px-
|
|
380
|
+
<div data-hp-sheet="" className="relative flex h-full flex-col px-sheet-margin pb-6 pt-8 md:pb-[3svh] md:pt-[4svh]">
|
|
364
381
|
<div data-hp-top="" className="relative z-10 will-change-transform">
|
|
365
382
|
<p aria-hidden className="m-0">
|
|
366
383
|
<span
|
|
@@ -368,7 +385,7 @@ function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
|
|
|
368
385
|
>
|
|
369
386
|
<span data-hp-rise="" data-hp-line="" className="block">
|
|
370
387
|
<span data-hp-word="" className={word} style={nowrap}>
|
|
371
|
-
{stack(
|
|
388
|
+
{stack(marked[0])}
|
|
372
389
|
</span>
|
|
373
390
|
</span>
|
|
374
391
|
</span>
|
|
@@ -384,7 +401,7 @@ function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
|
|
|
384
401
|
>
|
|
385
402
|
<div
|
|
386
403
|
data-stack-photo=""
|
|
387
|
-
className="absolute inset-0 md:inset-auto md
|
|
404
|
+
className="absolute inset-0 md:inset-auto md:-left-sheet-margin md:top-[calc(-4svh_-_0.84_*_var(--hp-s1,var(--hp-e1))_-_1.2svh)] md:h-svh md:w-screen"
|
|
388
405
|
>
|
|
389
406
|
<Image
|
|
390
407
|
src={image.src}
|
|
@@ -406,7 +423,7 @@ function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
|
|
|
406
423
|
>
|
|
407
424
|
<span data-hp-rise="" data-hp-line="" className="block">
|
|
408
425
|
<span data-hp-word="" className={`${word} ml-auto text-right`} style={nowrap}>
|
|
409
|
-
{stack(
|
|
426
|
+
{stack(marked[1])}
|
|
410
427
|
</span>
|
|
411
428
|
</span>
|
|
412
429
|
</span>
|
|
@@ -504,13 +521,26 @@ function sizer(el: HTMLElement, names: string[], onScreen: () => number[], shown
|
|
|
504
521
|
};
|
|
505
522
|
}
|
|
506
523
|
|
|
507
|
-
/** The display face in, or what there is after FONT_WAIT. */
|
|
508
|
-
const faceReady = () =>
|
|
509
|
-
Promise.race([
|
|
524
|
+
/** The display face in (and the accent face, when a word is marked), or what there is after FONT_WAIT. */
|
|
525
|
+
const faceReady = (el: HTMLElement) =>
|
|
526
|
+
Promise.race([
|
|
527
|
+
Promise.all([document.fonts?.ready ?? Promise.resolve(), accentFaceReady(el)]),
|
|
528
|
+
new Promise((r) => setTimeout(r, FONT_WAIT)),
|
|
529
|
+
]);
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The poster's words (one list per line) with the accent marked on the first word that holds it, so a block that
|
|
533
|
+
* sets one word per part or per row marks the same word the line would.
|
|
534
|
+
*/
|
|
535
|
+
function markWords(words: string[][], accent?: string): React.ReactNode[][] {
|
|
536
|
+
const flat = accentLines(words.flat(), accent);
|
|
537
|
+
let at = 0;
|
|
538
|
+
return words.map((ws) => flat.slice(at, (at += ws.length)));
|
|
539
|
+
}
|
|
510
540
|
|
|
511
541
|
const WORD = "font-display block w-fit whitespace-nowrap leading-[0.84] tracking-[-0.045em]";
|
|
512
|
-
const MASK = "
|
|
513
|
-
const SMALL = "
|
|
542
|
+
const MASK = "struq-line-mask block";
|
|
543
|
+
const SMALL = "struq-line-mask block";
|
|
514
544
|
// Inline, because the kit balances display type (text-wrap), which would let a poster line wrap; and no transition,
|
|
515
545
|
// or a page that transitions every property hands the fit a size from mid-change.
|
|
516
546
|
const NOWRAP = { whiteSpace: "nowrap", textWrap: "nowrap", transition: "none" } as React.CSSProperties;
|
|
@@ -560,9 +590,10 @@ function Foot({ place, link, copy = false }: { place?: string; link?: Inner["lin
|
|
|
560
590
|
* first scroll (pin 150%) it opens sideways by registration while the upper line leaves left, the lower line right
|
|
561
591
|
* and the foot down. Phones: one word per row, the same slit, the entrance only.
|
|
562
592
|
*/
|
|
563
|
-
function PosterColumn({ image, lines, place, link, className = "" }: Inner) {
|
|
593
|
+
function PosterColumn({ image, lines, accent, place, link, className = "" }: Inner) {
|
|
564
594
|
const stage = useRef<HTMLElement>(null);
|
|
565
595
|
const words = lines.map((l) => l.split(" "));
|
|
596
|
+
const marked = markWords(words, accent);
|
|
566
597
|
const longest = (ws: string[]) => Math.max(...ws.map((w) => w.length));
|
|
567
598
|
|
|
568
599
|
useGSAP(
|
|
@@ -586,7 +617,7 @@ function PosterColumn({ image, lines, place, link, className = "" }: Inner) {
|
|
|
586
617
|
const W = el.clientWidth;
|
|
587
618
|
const H = el.clientHeight;
|
|
588
619
|
const wide = W >= 768;
|
|
589
|
-
const room = W -
|
|
620
|
+
const room = W - 2 * sideOf(el);
|
|
590
621
|
const sizes = lineWords.map((w) => {
|
|
591
622
|
const width = widthAt100(w, !wide);
|
|
592
623
|
return width > 0 ? (100 * room) / width : 100;
|
|
@@ -650,7 +681,7 @@ function PosterColumn({ image, lines, place, link, className = "" }: Inner) {
|
|
|
650
681
|
sizes.set(fit(), frame);
|
|
651
682
|
decoded.then(() => !dead && grade());
|
|
652
683
|
};
|
|
653
|
-
const faceIn = faceReady();
|
|
684
|
+
const faceIn = faceReady(el);
|
|
654
685
|
sizes.set(fit(), frame);
|
|
655
686
|
faceIn.then(() => {
|
|
656
687
|
refit();
|
|
@@ -747,13 +778,13 @@ function PosterColumn({ image, lines, place, link, className = "" }: Inner) {
|
|
|
747
778
|
mm.revert();
|
|
748
779
|
};
|
|
749
780
|
},
|
|
750
|
-
{ scope: stage, dependencies: [lines[0], lines[1]] },
|
|
781
|
+
{ scope: stage, dependencies: [lines[0], lines[1], accent] },
|
|
751
782
|
);
|
|
752
783
|
|
|
753
|
-
const est = (i: 0 | 1) => `min(18vw, calc(
|
|
754
|
-
const estPhone = (i: 0 | 1) => `calc(
|
|
784
|
+
const est = (i: 0 | 1) => `min(18vw, calc(${INSIDE} / ${(lines[i].length * EM_PER_CHAR).toFixed(2)}), 26svh)`;
|
|
785
|
+
const estPhone = (i: 0 | 1) => `calc(${INSIDE} / ${(longest(words[i]) * EM_PER_CHAR).toFixed(2)})`;
|
|
755
786
|
// The space stands between the words, outside them: a space at the end of an inline block is dropped.
|
|
756
|
-
const parts = (ps:
|
|
787
|
+
const parts = (ps: React.ReactNode[]) =>
|
|
757
788
|
ps.map((part, i) => (
|
|
758
789
|
<span key={i}>
|
|
759
790
|
<span data-hp-part="" className="inline-block max-md:block">
|
|
@@ -770,7 +801,7 @@ function PosterColumn({ image, lines, place, link, className = "" }: Inner) {
|
|
|
770
801
|
>
|
|
771
802
|
<span data-hp-rise="" data-hp-line="" className="block">
|
|
772
803
|
<span data-hp-word="" className={`${WORD} ${i === 1 ? "ml-auto text-right" : ""}`} style={NOWRAP}>
|
|
773
|
-
{parts(
|
|
804
|
+
{parts(marked[i])}
|
|
774
805
|
</span>
|
|
775
806
|
</span>
|
|
776
807
|
</span>
|
|
@@ -781,7 +812,8 @@ function PosterColumn({ image, lines, place, link, className = "" }: Inner) {
|
|
|
781
812
|
<div
|
|
782
813
|
data-hp-layer={copy ? "copy" : "ground"}
|
|
783
814
|
aria-hidden={copy || undefined}
|
|
784
|
-
|
|
815
|
+
data-hp-sheet=""
|
|
816
|
+
className={`absolute inset-0 flex flex-col px-sheet-margin pb-6 pt-8 md:pb-[3svh] md:pt-[4svh] ${copy ? "text-on-photo" : "text-ink"}`}
|
|
785
817
|
>
|
|
786
818
|
<div className="flex-1" />
|
|
787
819
|
<div data-hp-l1="" className="will-change-transform">
|
|
@@ -845,9 +877,10 @@ function PosterColumn({ image, lines, place, link, className = "" }: Inner) {
|
|
|
845
877
|
* Phones: the same stack, each window its own crop, the entrance only. Without scripts or under reduced motion the
|
|
846
878
|
* composed poster shows with every crop in place.
|
|
847
879
|
*/
|
|
848
|
-
function PosterStack({ image, lines, place, link, className = "" }: Inner) {
|
|
880
|
+
function PosterStack({ image, lines, accent, place, link, className = "" }: Inner) {
|
|
849
881
|
const stage = useRef<HTMLElement>(null);
|
|
850
882
|
const rows = lines.flatMap((l) => l.split(" ")).filter(Boolean);
|
|
883
|
+
const marked = accentLines(rows, accent);
|
|
851
884
|
const n = rows.length;
|
|
852
885
|
const m = Math.max(0, n - 1);
|
|
853
886
|
const middle = Math.floor((m - 1) / 2);
|
|
@@ -873,7 +906,7 @@ function PosterStack({ image, lines, place, link, className = "" }: Inner) {
|
|
|
873
906
|
const W = el.clientWidth;
|
|
874
907
|
const H = el.clientHeight;
|
|
875
908
|
const wide = W >= 768;
|
|
876
|
-
const room = W -
|
|
909
|
+
const room = W - 2 * sideOf(el);
|
|
877
910
|
const widest = Math.max(1, ...wordEls.map((w) => widthAt100(w, false)));
|
|
878
911
|
const free = H - (foot?.offsetHeight ?? 0) - (wide ? H * 0.1 : 72) - m * H * (wide ? 0.075 : 0.08);
|
|
879
912
|
return [Math.floor(Math.min((100 * room) / widest, free / (LEADING * n)))];
|
|
@@ -888,7 +921,7 @@ function PosterStack({ image, lines, place, link, className = "" }: Inner) {
|
|
|
888
921
|
if (dead) return;
|
|
889
922
|
sizes.set(fit(), frame);
|
|
890
923
|
};
|
|
891
|
-
const faceIn = faceReady();
|
|
924
|
+
const faceIn = faceReady(el);
|
|
892
925
|
sizes.set(fit(), frame);
|
|
893
926
|
faceIn.then(() => {
|
|
894
927
|
refit();
|
|
@@ -1017,13 +1050,13 @@ function PosterStack({ image, lines, place, link, className = "" }: Inner) {
|
|
|
1017
1050
|
mm.revert();
|
|
1018
1051
|
};
|
|
1019
1052
|
},
|
|
1020
|
-
{ scope: stage, dependencies: [lines[0], lines[1]] },
|
|
1053
|
+
{ scope: stage, dependencies: [lines[0], lines[1], accent] },
|
|
1021
1054
|
);
|
|
1022
1055
|
|
|
1023
1056
|
// The script-free estimate: the longest word over the width, capped so the rows and their windows fit the height.
|
|
1024
1057
|
const per = (longest * EM_PER_CHAR).toFixed(2);
|
|
1025
|
-
const est = `min(calc(
|
|
1026
|
-
const estPhone = `min(calc(
|
|
1058
|
+
const est = `min(calc(${INSIDE} / ${per}), calc((90svh - 2rem - ${m} * 7.5svh) / ${(n * LEADING).toFixed(2)}))`;
|
|
1059
|
+
const estPhone = `min(calc(${INSIDE} / ${per}), calc((100svh - 7rem - ${m} * 8svh) / ${(n * LEADING).toFixed(2)}))`;
|
|
1027
1060
|
|
|
1028
1061
|
return (
|
|
1029
1062
|
<section
|
|
@@ -1035,8 +1068,8 @@ function PosterStack({ image, lines, place, link, className = "" }: Inner) {
|
|
|
1035
1068
|
style={{ "--hp-e": est, "--hp-p": estPhone } as React.CSSProperties}
|
|
1036
1069
|
>
|
|
1037
1070
|
<h1 className="sr-only">{`${lines[0]} ${lines[1]}`}</h1>
|
|
1038
|
-
<div className="relative flex h-full flex-col px-
|
|
1039
|
-
{
|
|
1071
|
+
<div data-hp-sheet="" className="relative flex h-full flex-col px-sheet-margin pb-6 pt-8 md:pb-[3svh] md:pt-[4svh]">
|
|
1072
|
+
{marked.map((w, i) => (
|
|
1040
1073
|
<div key={i} className="contents">
|
|
1041
1074
|
<div data-hp-row="" className="relative z-10 will-change-transform">
|
|
1042
1075
|
<p aria-hidden className="m-0">
|
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
// <HeroSplit name="Noord" lead="Wij bouwen" items={[{ word: "huizen.", image }, { word: "interieurs.", image }, ...]}
|
|
15
15
|
// line="Aan de Maashaven in Rotterdam." link={{ href: "/werk", label: "Bekijk het werk" }} />
|
|
16
16
|
//
|
|
17
|
+
// `accent` (optional) is the site's one accent word: where it first stands in the headline (the lead, then the
|
|
18
|
+
// cycling words) it is set in the pairing's italic accent face in every copy of the line, as the Statement and the
|
|
19
|
+
// Close set the same word. The fit measures with the mark in place and refits once the accent face is in, so the
|
|
20
|
+
// wider italic never pushes the line past its span. Left out, or not in the headline, the hero is exactly as it was.
|
|
21
|
+
//
|
|
17
22
|
// One clock: the words (in every copy) and the photos are tweens in one looping timeline, so they can never drift; it
|
|
18
23
|
// starts once every photograph is decoded and pauses off screen or in a hidden tab. The cycling words share one grid
|
|
19
24
|
// cell, so a change of word never moves anything; the headline is fitted with the loaded face and refit when it lands
|
|
@@ -35,6 +40,7 @@
|
|
|
35
40
|
// the edge passes under it; scrolling back closes it. Phones, reduced motion and no scripts: the split.
|
|
36
41
|
import Image from "next/image";
|
|
37
42
|
import { useRef } from "react";
|
|
43
|
+
import { accentFaceReady, accentLines } from "../kit/accent";
|
|
38
44
|
import { ScrollTrigger, gsap, useGSAP } from "../kit/gsap";
|
|
39
45
|
import type { StruqImage } from "../kit/images";
|
|
40
46
|
import { photoLuminances } from "../kit/photo-read";
|
|
@@ -54,6 +60,10 @@ type Props = {
|
|
|
54
60
|
lead: string;
|
|
55
61
|
/** Two to five disciplines, each with its photograph. */
|
|
56
62
|
items: Item[];
|
|
63
|
+
/** The site's one accent word: where it first stands in the headline (the lead, then the cycling words) it is set in
|
|
64
|
+
* the pairing's italic accent face (--face-accent), as the Statement and the Close set it. A word the headline does
|
|
65
|
+
* not hold changes nothing. */
|
|
66
|
+
accent?: string;
|
|
57
67
|
/** One short line at the foot of the type side. */
|
|
58
68
|
line?: string;
|
|
59
69
|
link: { href: string; label: string };
|
|
@@ -121,8 +131,10 @@ function veilFor(under: number, ink: number, veilS: number): number {
|
|
|
121
131
|
return hi;
|
|
122
132
|
}
|
|
123
133
|
|
|
124
|
-
export function HeroSplit({ name, lead, items, line, link, photo = "right", variant = "naad", className = "" }: Props) {
|
|
134
|
+
export function HeroSplit({ name, lead, items, accent, line, link, photo = "right", variant = "naad", className = "" }: Props) {
|
|
125
135
|
const root = useRef<HTMLElement>(null);
|
|
136
|
+
// The headline is the lead and the cycling words: the accent is marked where it first stands in that order.
|
|
137
|
+
const [leadShown, ...wordsShown] = accentLines([lead, ...items.map((item) => item.word)], accent);
|
|
126
138
|
const left = photo === "left";
|
|
127
139
|
const flat = variant === "liggend";
|
|
128
140
|
const opening = variant === "opening";
|
|
@@ -242,7 +254,8 @@ export function HeroSplit({ name, lead, items, line, link, photo = "right", vari
|
|
|
242
254
|
gsap.set(rise, { y: 0, yPercent: shown ? 0 : 115 });
|
|
243
255
|
el.dataset.ready = "";
|
|
244
256
|
const refit = () => fit(true);
|
|
245
|
-
|
|
257
|
+
// The accent face too, when a word is marked: the fit measures the italic as it will stand.
|
|
258
|
+
document.fonts?.ready.then(() => accentFaceReady(el)).then(() => {
|
|
246
259
|
refit();
|
|
247
260
|
ScrollTrigger.refresh();
|
|
248
261
|
});
|
|
@@ -514,11 +527,11 @@ export function HeroSplit({ name, lead, items, line, link, photo = "right", vari
|
|
|
514
527
|
wide.removeEventListener("change", onWide);
|
|
515
528
|
};
|
|
516
529
|
},
|
|
517
|
-
{ scope: root, dependencies: [variant] },
|
|
530
|
+
{ scope: root, dependencies: [variant, accent] },
|
|
518
531
|
);
|
|
519
532
|
|
|
520
533
|
const mask = (child: React.ReactNode, key?: number | string) => (
|
|
521
|
-
<span key={key} className="struq-line-mask block
|
|
534
|
+
<span key={key} className="struq-line-mask block">
|
|
522
535
|
<span data-rise="" className="block">
|
|
523
536
|
{child}
|
|
524
537
|
</span>
|
|
@@ -530,12 +543,12 @@ export function HeroSplit({ name, lead, items, line, link, photo = "right", vari
|
|
|
530
543
|
<>
|
|
531
544
|
{mask(
|
|
532
545
|
<span data-fit="" className="inline-block whitespace-nowrap">
|
|
533
|
-
{
|
|
546
|
+
{leadShown}
|
|
534
547
|
</span>,
|
|
535
548
|
`${copy}-lead`,
|
|
536
549
|
)}
|
|
537
550
|
{mask(
|
|
538
|
-
<span data-fit="" className="struq-line-mask inline-grid
|
|
551
|
+
<span data-fit="" className="struq-line-mask inline-grid whitespace-nowrap align-top">
|
|
539
552
|
{items.map((item, i) => (
|
|
540
553
|
<span
|
|
541
554
|
key={item.word}
|
|
@@ -544,7 +557,7 @@ export function HeroSplit({ name, lead, items, line, link, photo = "right", vari
|
|
|
544
557
|
className="[grid-area:1/1]"
|
|
545
558
|
style={i === 0 ? undefined : { transform: `translateY(${AWAY}%)` }}
|
|
546
559
|
>
|
|
547
|
-
{
|
|
560
|
+
{wordsShown[i]}
|
|
548
561
|
</span>
|
|
549
562
|
))}
|
|
550
563
|
</span>,
|
|
@@ -556,7 +569,7 @@ export function HeroSplit({ name, lead, items, line, link, photo = "right", vari
|
|
|
556
569
|
// Lying down the headline stands on the seam (58% down) and the seam cuts the cycling word through its middle.
|
|
557
570
|
const headClass = `font-display absolute ${
|
|
558
571
|
flat ? "top-[calc(58%-1.45em)]" : "bottom-[calc(5vh+5.5rem)]"
|
|
559
|
-
} leading-[0.92] tracking-[-0.035em] text-[length:var(--hsp-size,9vw)] px-
|
|
572
|
+
} leading-[0.92] tracking-[-0.035em] text-[length:var(--hsp-size,9vw)] px-sheet-margin left-0`;
|
|
560
573
|
|
|
561
574
|
const grid = flat
|
|
562
575
|
? "md:grid-cols-1 md:grid-rows-[58fr_42fr]"
|
|
@@ -612,7 +625,7 @@ export function HeroSplit({ name, lead, items, line, link, photo = "right", vari
|
|
|
612
625
|
Lying down, the name, the line and the link share one row at the foot. */}
|
|
613
626
|
<div
|
|
614
627
|
data-type=""
|
|
615
|
-
className={`row-start-2 flex flex-col justify-between gap-8 px-
|
|
628
|
+
className={`row-start-2 flex flex-col justify-between gap-8 px-sheet-margin pb-8 pt-7 md:pb-[5vh] md:pt-[4vh] ${
|
|
616
629
|
flat ? "md:col-start-1 md:row-start-2 md:flex-row md:items-end" : `md:row-start-1 ${left ? "md:col-start-2" : "md:col-start-1"}`
|
|
617
630
|
}`}
|
|
618
631
|
>
|