@struq/skill 0.2.2 → 0.4.0
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 +23 -35
- package/package.json +3 -2
- package/skills/struq/SKILL.md +148 -212
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/blocks/kit/Cursor.tsx +117 -0
- package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
- package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
- package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
- package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
- package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
- package/skills/struq/blocks/kit/Reveal.tsx +64 -0
- package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
- package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/gsap.ts +21 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
- package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
- package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
- package/skills/struq/blocks/kit/scenes/types.ts +114 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
- package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
- package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
- package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
- package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -0
- package/skills/struq/recipes/app/globals.css +42 -0
- package/skills/struq/recipes/app/layout.tsx +30 -0
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +134 -0
- package/skills/struq/scripts/check.mjs +513 -0
- package/skills/struq/scripts/images.mjs +360 -0
- package/skills/struq/scripts/log.mjs +75 -0
- package/skills/struq/scripts/packs.mjs +156 -0
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/references/anti-patterns.md +0 -265
- package/skills/struq/references/baseline-prompt.md +0 -118
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -219
- package/skills/struq/references/composition.md +0 -191
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/imagery.md +0 -144
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -341
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -280
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// hero-still (Struq hero recipe)
|
|
3
|
+
// A film still with a title. One photograph edge to edge, held with a very slow push-in (six percent over twenty
|
|
4
|
+
// seconds); the headline rises line by line through masks at display size on the photo's calm side; under the still,
|
|
5
|
+
// on the page ground like a film's caption strip, a quiet bar with three facts on one line: the studio's place, the
|
|
6
|
+
// live local time, one link. On scroll
|
|
7
|
+
// the photograph moves slower than the page and the headline leaves first, so the hero hands over with depth.
|
|
8
|
+
//
|
|
9
|
+
// <HeroStill image={images.hero[0]} lines={["Ruimte die", "blijft."]} place="Noord, Rotterdam"
|
|
10
|
+
// timeZone="Europe/Amsterdam" link={{ href: "/werk", label: "Bekijk het werk" }} />
|
|
11
|
+
//
|
|
12
|
+
// The photo's box carries data-stack-target, so preloader-stack and page-transition-stack land on it exactly, and the
|
|
13
|
+
// entrance starts only when the cover lifts (whenUncovered), at once when there is none. The push-in never re-rasters:
|
|
14
|
+
// the photograph is laid out six percent larger than its box and shown scaled down to fit, so at rest it is exactly
|
|
15
|
+
// an object-cover of the box, and the push-in only scales that layer back up to its own size (a compositor animation,
|
|
16
|
+
// rastered once at the largest scale it reaches). It starts a moment after the entrance, so a preloader landing on the
|
|
17
|
+
// box always meets the photo at rest.
|
|
18
|
+
//
|
|
19
|
+
// Side and veil come from the photograph itself. Unless a side is given, the headline takes whichever side needs the
|
|
20
|
+
// weaker veil (decided while its lines are still under their masks, so the choice is never seen; the photo's
|
|
21
|
+
// negativeSpace sets the first paint and breaks a tie), and the veil is the weakest that gives the display-size
|
|
22
|
+
// headline 3:1 over what is actually under its glyphs, never past 0.45 (read from a small canvas; a photo on another
|
|
23
|
+
// origin cannot be read and keeps the safe veil the CSS starts with). The caption sits on the ground, in the ink.
|
|
24
|
+
//
|
|
25
|
+
// The first paint is the composed still: the photo and its veil; the headline and the caption wait below their masks
|
|
26
|
+
// and arrive with the entrance, or after 3 seconds if the script never runs (a script that arrives after that leaves
|
|
27
|
+
// them in place) (blocks.css, "--- hero-still ---"). Phones:
|
|
28
|
+
// the same still, the headline sized to fit, the caption on two lines. Reduced motion: no push-in, no rise, no drift.
|
|
29
|
+
// Without scripts: the composed still with everything in place.
|
|
30
|
+
//
|
|
31
|
+
// Three variants of the same still, picked with `variant`:
|
|
32
|
+
// - still (the default, above).
|
|
33
|
+
// - frame: the photograph held inside generous margins of page ground, the headline on the ground beside it in the
|
|
34
|
+
// ink. On the first scroll (pin 100%) the frame opens by registration to the full bleed of the still while the
|
|
35
|
+
// headline leaves upward; the caption strip stays. Phones: a smaller frame with the headline under it, and the
|
|
36
|
+
// still's own hand-over.
|
|
37
|
+
// - horizon: the headline set across the photograph's horizon (found in the photo itself), its upper lines on the sky
|
|
38
|
+
// or wall side and its lower lines on the ground side, each half in the ink that side measures for, over its own
|
|
39
|
+
// light veil band. The upper lines rise to the horizon and the lower lines drop to it; on scroll the headline rides
|
|
40
|
+
// with the photograph, so it stays on its horizon as the still hands over.
|
|
41
|
+
import Image from "next/image";
|
|
42
|
+
import { useEffect, useRef, useState } from "react";
|
|
43
|
+
import { ScrollTrigger, gsap, useGSAP } from "../kit/gsap";
|
|
44
|
+
import type { StruqImage } from "../kit/images";
|
|
45
|
+
import { photoLuminances, type PhotoBox } from "../kit/photo-read";
|
|
46
|
+
import { covered, whenUncovered } from "../preloaders/preloader-slices-uncovered";
|
|
47
|
+
|
|
48
|
+
export const HERO_STILL_VARIANTS = [
|
|
49
|
+
{ id: "still", title: "Still" },
|
|
50
|
+
{ id: "frame", title: "Kader" },
|
|
51
|
+
{ id: "horizon", title: "Horizon" },
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
export type HeroStillVariant = (typeof HERO_STILL_VARIANTS)[number]["id"];
|
|
55
|
+
|
|
56
|
+
type Props = Inner & {
|
|
57
|
+
/** The composition: the full-bleed still (default), the photo in a frame of ground, or the headline on the horizon. */
|
|
58
|
+
variant?: HeroStillVariant;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type Inner = {
|
|
62
|
+
image: StruqImage;
|
|
63
|
+
/** The headline, one entry per line, display size. Two or three short lines read best. */
|
|
64
|
+
lines: string[];
|
|
65
|
+
/** The studio's place, the caption's first fact. */
|
|
66
|
+
place: string;
|
|
67
|
+
/** IANA zone for the live local time, the caption's second fact. */
|
|
68
|
+
timeZone?: string;
|
|
69
|
+
/** The caption's third fact: one link. */
|
|
70
|
+
link: { href: string; label: string };
|
|
71
|
+
/** Where the headline stands. Left out, the block reads the photo and takes the side that needs the weaker veil
|
|
72
|
+
* (the photo's negativeSpace breaks a tie); the first paint, and a page without scripts, use negativeSpace. */
|
|
73
|
+
side?: "left" | "right";
|
|
74
|
+
/** horizon only: where the photo's horizon lies, as a share of the still's height. Left out, the block looks for
|
|
75
|
+
* the strongest change of brightness across the photo's middle band (a horizon of texture, such as a wall meeting
|
|
76
|
+
* gravel of the same tone, is not found that way and is best given). */
|
|
77
|
+
horizon?: number;
|
|
78
|
+
className?: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const HEAD_RATIO = 3; // display-size type: WCAG large text
|
|
82
|
+
const MAX_VEIL = 0.45; // past this the photo reads as a dimmed banner, not a still
|
|
83
|
+
|
|
84
|
+
const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
85
|
+
const encode = (l: number) => (l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055);
|
|
86
|
+
const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
87
|
+
|
|
88
|
+
/** The relative luminance of any CSS colour, through a one-pixel canvas so every colour syntax resolves. */
|
|
89
|
+
function luminanceOf(color: string): number {
|
|
90
|
+
const cv = document.createElement("canvas");
|
|
91
|
+
cv.width = 1;
|
|
92
|
+
cv.height = 1;
|
|
93
|
+
const ctx = cv.getContext("2d", { willReadFrequently: true });
|
|
94
|
+
if (!ctx) return 0;
|
|
95
|
+
ctx.fillStyle = "#000";
|
|
96
|
+
ctx.fillStyle = color;
|
|
97
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
98
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
99
|
+
return 0.2126 * linear(r / 255) + 0.7152 * linear(g / 255) + 0.0722 * linear(b / 255);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The brightest part (the top tenth) of the photo under a box, the box in the photo box's own coordinates, with the
|
|
104
|
+
* photo shown as object-cover of that box. Null when the photo cannot be read.
|
|
105
|
+
*/
|
|
106
|
+
function brightUnder(img: HTMLImageElement, box: { w: number; h: number }, at: DOMRect, from: DOMRect): number | null {
|
|
107
|
+
const ys = photoLuminances(
|
|
108
|
+
img,
|
|
109
|
+
{ x: from.left, y: from.top, w: box.w, h: box.h },
|
|
110
|
+
{ x: at.left, y: at.top, w: at.width, h: at.height },
|
|
111
|
+
);
|
|
112
|
+
return ys ? ys[Math.floor(ys.length * 0.9)] : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The weakest veil (composited in sRGB, as the browser does) that brings `under` to `ratio` against `ink`. */
|
|
116
|
+
function veilFor(under: number, ink: number, veilS: number, ratio: number): number {
|
|
117
|
+
if (contrast(under, ink) >= ratio) return 0;
|
|
118
|
+
const s = encode(under);
|
|
119
|
+
const ok = (a: number) => contrast(linear(s * (1 - a) + veilS * a), ink) >= ratio;
|
|
120
|
+
if (!ok(MAX_VEIL)) return MAX_VEIL;
|
|
121
|
+
let lo = 0;
|
|
122
|
+
let hi = MAX_VEIL;
|
|
123
|
+
for (let i = 0; i < 14; i += 1) {
|
|
124
|
+
const mid = (lo + hi) / 2;
|
|
125
|
+
if (ok(mid)) hi = mid;
|
|
126
|
+
else lo = mid;
|
|
127
|
+
}
|
|
128
|
+
return hi;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The time in a zone as HH:MM, the same five characters at every minute. */
|
|
132
|
+
function timeIn(zone: string) {
|
|
133
|
+
return new Intl.DateTimeFormat("nl-NL", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: zone }).format(
|
|
134
|
+
new Date(),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The veil under a light ink: the page ground held dark (its own hue, never black), as the CSS sets it. */
|
|
139
|
+
const DIM_VEIL = "oklch(from var(--surface-0) min(l, 0.2) c h)";
|
|
140
|
+
|
|
141
|
+
/** The luminance of a colour as the page resolves it (custom properties, relative colours), through a probe. */
|
|
142
|
+
function resolvedLuminance(host: HTMLElement, css: string) {
|
|
143
|
+
const probe = document.createElement("span");
|
|
144
|
+
probe.style.display = "none";
|
|
145
|
+
probe.style.color = css;
|
|
146
|
+
host.appendChild(probe);
|
|
147
|
+
const c = getComputedStyle(probe).color;
|
|
148
|
+
probe.remove();
|
|
149
|
+
return luminanceOf(c);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type InkPick = { ink: string; tone: "dark" | "light"; veil: number };
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Of the light photo ink and the darkest ink the page has, the one that needs the weaker veil to hold 3:1 (display
|
|
156
|
+
* type) over what the photo holds under `boxes` (in the coordinates of `frame`, the box the photo covers): the light
|
|
157
|
+
* ink against the brightest tenth with the dark veil, the dark ink against the darkest tenth with a pale veil of the
|
|
158
|
+
* light ink. A near tie, or a palette without a dark ink, keeps the light ink. Null when the photo cannot be read.
|
|
159
|
+
*/
|
|
160
|
+
export function inkOver(img: HTMLImageElement, frame: PhotoBox, boxes: PhotoBox[], host: HTMLElement): InkPick | null {
|
|
161
|
+
const ys: number[] = [];
|
|
162
|
+
for (const b of boxes) {
|
|
163
|
+
const r = photoLuminances(img, frame, b);
|
|
164
|
+
if (!r) return null;
|
|
165
|
+
ys.push(...r);
|
|
166
|
+
}
|
|
167
|
+
if (!ys.length) return null;
|
|
168
|
+
ys.sort((a, b) => a - b);
|
|
169
|
+
const hi = ys[Math.floor(ys.length * 0.9)];
|
|
170
|
+
const lo = ys[Math.floor(ys.length * 0.1)];
|
|
171
|
+
const light = resolvedLuminance(host, "var(--on-photo)");
|
|
172
|
+
const dark = ["--ink", "--inverse", "--surface-0"]
|
|
173
|
+
.map((v) => ({ v, y: resolvedLuminance(host, `var(${v})`) }))
|
|
174
|
+
.sort((a, b) => a.y - b.y)[0];
|
|
175
|
+
const forLight = veilFor(hi, light, encode(resolvedLuminance(host, DIM_VEIL)), HEAD_RATIO);
|
|
176
|
+
const forDark = veilFor(lo, dark.y, encode(light), HEAD_RATIO);
|
|
177
|
+
if (contrast(dark.y, light) >= HEAD_RATIO && forDark < forLight - 0.04) {
|
|
178
|
+
return { ink: `var(${dark.v})`, tone: "light", veil: forDark };
|
|
179
|
+
}
|
|
180
|
+
return { ink: "var(--on-photo)", tone: "dark", veil: forLight };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function HeroStill({ variant = "still", ...rest }: Props) {
|
|
184
|
+
if (variant === "frame") return <StillFrame {...rest} />;
|
|
185
|
+
if (variant === "horizon") return <StillHorizon {...rest} />;
|
|
186
|
+
return <StillFilm {...rest} />;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function StillFilm({ image, lines, place, timeZone = "Europe/Amsterdam", link, side, className = "" }: Inner) {
|
|
190
|
+
const root = useRef<HTMLElement>(null);
|
|
191
|
+
const first = side ?? (image.negativeSpace === "right" ? "right" : "left");
|
|
192
|
+
const [time, setTime] = useState(() => timeIn(timeZone));
|
|
193
|
+
|
|
194
|
+
// The clock: renewed on each minute, in tabular figures, so nothing around it ever moves.
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
let tick = 0;
|
|
197
|
+
const next = () => {
|
|
198
|
+
setTime(timeIn(timeZone));
|
|
199
|
+
tick = window.setTimeout(next, 60_000 - (Date.now() % 60_000) + 50);
|
|
200
|
+
};
|
|
201
|
+
next();
|
|
202
|
+
return () => window.clearTimeout(tick);
|
|
203
|
+
}, [timeZone]);
|
|
204
|
+
|
|
205
|
+
useGSAP(
|
|
206
|
+
(_, contextSafe) => {
|
|
207
|
+
const el = root.current;
|
|
208
|
+
if (!el || !contextSafe) return;
|
|
209
|
+
const q = gsap.utils.selector(el);
|
|
210
|
+
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
211
|
+
const frame = q("[data-stack-target]")[0] as HTMLElement;
|
|
212
|
+
const img = frame.querySelector("img") as HTMLImageElement;
|
|
213
|
+
const veil = q("[data-veil]")[0] as HTMLElement;
|
|
214
|
+
const rise = q("[data-rise]");
|
|
215
|
+
const head = q("[data-head]")[0] as HTMLElement;
|
|
216
|
+
|
|
217
|
+
const h1 = head.querySelector("h1") as HTMLElement;
|
|
218
|
+
// Only what is under the type: the glyphs' own width (a range; the rise moves the lines vertically only), over
|
|
219
|
+
// the height of an untransformed box that holds them.
|
|
220
|
+
const under = (n: HTMLElement, holder: HTMLElement = n) => {
|
|
221
|
+
const range = document.createRange();
|
|
222
|
+
range.selectNodeContents(n);
|
|
223
|
+
const text = range.getBoundingClientRect();
|
|
224
|
+
const own = holder.getBoundingClientRect();
|
|
225
|
+
return new DOMRect(text.left, own.top, text.width, own.height);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The veil from the photo itself, and (unless the side was given, and only while the headline is still under
|
|
230
|
+
* its masks) the side the headline stands on: whichever needs the weaker veil. The headline holds 3:1 (display
|
|
231
|
+
* size), the veil stops at MAX_VEIL; the caption sits on the page ground. The CSS's safe veil stays when the photo
|
|
232
|
+
* cannot be read.
|
|
233
|
+
*/
|
|
234
|
+
const grade = () => {
|
|
235
|
+
const ink = luminanceOf(getComputedStyle(el).color);
|
|
236
|
+
const veilS = encode(luminanceOf(getComputedStyle(veil).backgroundColor));
|
|
237
|
+
const from = frame.getBoundingClientRect();
|
|
238
|
+
const box = { w: from.width, h: from.height };
|
|
239
|
+
const read = (n: HTMLElement, holder?: HTMLElement) => brightUnder(img, box, under(n, holder), from);
|
|
240
|
+
const headNeed = () => {
|
|
241
|
+
const r = read(h1);
|
|
242
|
+
return r === null ? null : veilFor(r, ink, veilS, HEAD_RATIO);
|
|
243
|
+
};
|
|
244
|
+
let need = headNeed();
|
|
245
|
+
if (need === null) return;
|
|
246
|
+
if (!side && !shown) {
|
|
247
|
+
const was = el.dataset.side;
|
|
248
|
+
el.dataset.side = was === "right" ? "left" : "right";
|
|
249
|
+
const other = headNeed();
|
|
250
|
+
// The other side only when it is clearly better; a tie keeps the photo's own calm side.
|
|
251
|
+
if (other !== null && other < need - 0.02) need = other;
|
|
252
|
+
else el.dataset.side = was;
|
|
253
|
+
}
|
|
254
|
+
graded = need;
|
|
255
|
+
if (played || !covered()) veil.style.opacity = graded.toFixed(3);
|
|
256
|
+
};
|
|
257
|
+
let graded: number | null = null;
|
|
258
|
+
let played = false;
|
|
259
|
+
// Under a preloader the photo is shown bare, the way the preloader's own print of it lands; the veil then
|
|
260
|
+
// settles in with the headline, so the landing never darkens in one frame.
|
|
261
|
+
if (covered()) {
|
|
262
|
+
veil.style.transition = "none";
|
|
263
|
+
veil.style.opacity = "0";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let cancel = () => {};
|
|
267
|
+
// Strict mode runs this twice; a decode that settles after its run was torn down must do nothing.
|
|
268
|
+
let gone = false;
|
|
269
|
+
const decoded = img.decode().catch(() => undefined);
|
|
270
|
+
// A late script: the CSS fallback (3s) may already have shown the lines. Then they stay; the script still grades
|
|
271
|
+
// the veil and starts the push-in, but never hides and raises them a second time.
|
|
272
|
+
// Decided once per element: strict mode's second run must not read the state its own first run left behind.
|
|
273
|
+
// The answer is this element's own fallback clock, which starts when the element is first styled (at the first
|
|
274
|
+
// paint, or when a client-side navigation mounts it): once it has run 2.9s the CSS has shown the lines.
|
|
275
|
+
const clock = rise[0]
|
|
276
|
+
.getAnimations()
|
|
277
|
+
.find((a) => (a as CSSAnimation).animationName === "struq-hs-rise");
|
|
278
|
+
el.dataset.shown ??= String(
|
|
279
|
+
reduce || (clock ? Number(clock.currentTime ?? 0) >= 2900 : getComputedStyle(rise[0]).transform === "none"),
|
|
280
|
+
);
|
|
281
|
+
const shown = el.dataset.shown === "true";
|
|
282
|
+
// Shown means in place, whatever state the element came back in.
|
|
283
|
+
if (shown) gsap.set(rise, { y: 0, yPercent: 0 });
|
|
284
|
+
// y: 0 as well: GSAP would otherwise read the CSS start state (translateY 115%) into a pixel y and keep it.
|
|
285
|
+
if (!shown) gsap.set(rise, { y: 0, yPercent: 115 });
|
|
286
|
+
el.dataset.ready = "";
|
|
287
|
+
decoded.then(() => {
|
|
288
|
+
if (gone) return;
|
|
289
|
+
grade();
|
|
290
|
+
const play = contextSafe(() => {
|
|
291
|
+
played = true;
|
|
292
|
+
veil.style.transition = "opacity 1.2s cubic-bezier(0.16, 1, 0.3, 1)";
|
|
293
|
+
veil.style.opacity = (graded ?? 0.4).toFixed(3);
|
|
294
|
+
if (reduce) {
|
|
295
|
+
gsap.set(rise, { yPercent: 0 });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (!shown) gsap.to(rise, { yPercent: 0, duration: 1.15, ease: "expo.out", stagger: 0.09, delay: 0.1 });
|
|
299
|
+
// The push-in, a moment later, so a preloader landing on the box meets the photo at rest.
|
|
300
|
+
el.dataset.pushing = "";
|
|
301
|
+
});
|
|
302
|
+
// The signal says a cover has started to leave; the headline rises once it can actually be seen (nothing of
|
|
303
|
+
// a cover over its first line any more), at most 1.6s later.
|
|
304
|
+
const seen = () => {
|
|
305
|
+
const line = rise[0]?.parentElement?.getBoundingClientRect();
|
|
306
|
+
if (!line) return true;
|
|
307
|
+
const hit = document.elementFromPoint(line.left + 8, line.top + line.height / 2);
|
|
308
|
+
return !hit || el.contains(hit);
|
|
309
|
+
};
|
|
310
|
+
let watch = 0;
|
|
311
|
+
const whenSeen = () => {
|
|
312
|
+
const until = performance.now() + 1600;
|
|
313
|
+
const look = () => {
|
|
314
|
+
if (gone) return;
|
|
315
|
+
if (seen() || performance.now() > until) play();
|
|
316
|
+
else watch = requestAnimationFrame(look);
|
|
317
|
+
};
|
|
318
|
+
look();
|
|
319
|
+
};
|
|
320
|
+
const stop = whenUncovered(whenSeen);
|
|
321
|
+
cancel = () => {
|
|
322
|
+
stop();
|
|
323
|
+
cancelAnimationFrame(watch);
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// The hand-over: the photograph drifts down (moves slower than the page), the headline leaves first.
|
|
328
|
+
if (!reduce) {
|
|
329
|
+
gsap
|
|
330
|
+
.timeline({ scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.4 } })
|
|
331
|
+
.to(q("[data-drift]"), { yPercent: 22, ease: "none" }, 0)
|
|
332
|
+
.to(head, { yPercent: -60, ease: "none", duration: 0.55 }, 0);
|
|
333
|
+
}
|
|
334
|
+
const refresh = () => ScrollTrigger.refresh();
|
|
335
|
+
document.fonts?.ready.then(refresh);
|
|
336
|
+
window.addEventListener("resize", grade);
|
|
337
|
+
return () => {
|
|
338
|
+
gone = true;
|
|
339
|
+
cancel();
|
|
340
|
+
window.removeEventListener("resize", grade);
|
|
341
|
+
// data-ready and data-shown stay: a remount must not bring the CSS start state (and its 3s clock) back.
|
|
342
|
+
delete el.dataset.pushing;
|
|
343
|
+
};
|
|
344
|
+
},
|
|
345
|
+
{ scope: root },
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const mask = (child: React.ReactNode, key?: number) => (
|
|
349
|
+
<span key={key} className="struq-line-mask block overflow-hidden">
|
|
350
|
+
<span data-rise="" className="block">
|
|
351
|
+
{child}
|
|
352
|
+
</span>
|
|
353
|
+
</span>
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
<section
|
|
358
|
+
ref={root}
|
|
359
|
+
data-hero-still=""
|
|
360
|
+
data-side={first}
|
|
361
|
+
suppressHydrationWarning
|
|
362
|
+
className={`group/hs relative flex h-svh min-h-[32rem] flex-col overflow-hidden bg-surface-0 text-on-photo ${className}`}
|
|
363
|
+
>
|
|
364
|
+
{/* The still: the photograph with its headline, above the caption bar. */}
|
|
365
|
+
<div data-still="" className="relative min-h-0 flex-1">
|
|
366
|
+
{/* The photo's box: what a preloader lands on. Inside, the photograph sits six percent larger than the box and
|
|
367
|
+
is shown scaled back to it, so the slow push-in only ever scales it up to its own raster. */}
|
|
368
|
+
<div data-stack-target="" className="absolute inset-0 overflow-hidden">
|
|
369
|
+
<div data-drift="" className="absolute inset-0">
|
|
370
|
+
<div data-push="" className="absolute -inset-[3%]">
|
|
371
|
+
<Image
|
|
372
|
+
src={image.src}
|
|
373
|
+
alt={image.alt}
|
|
374
|
+
fill
|
|
375
|
+
priority
|
|
376
|
+
sizes="100vw"
|
|
377
|
+
unoptimized={image.unoptimized}
|
|
378
|
+
className="object-cover"
|
|
379
|
+
/>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
<div data-veil="" aria-hidden="true" className="absolute inset-0" />
|
|
383
|
+
</div>
|
|
384
|
+
|
|
385
|
+
<div
|
|
386
|
+
data-head=""
|
|
387
|
+
className="absolute inset-x-0 bottom-[5vh] px-6 group-data-[side=right]/hs:flex group-data-[side=right]/hs:justify-end group-data-[side=right]/hs:text-right md:bottom-[6vh] md:px-[4vw]"
|
|
388
|
+
>
|
|
389
|
+
<h1 className="font-display text-[clamp(3rem,13.5vw,4.75rem)] leading-[0.92] tracking-[-0.035em] md:text-[clamp(3.5rem,8vw,9.5rem)]">
|
|
390
|
+
{lines.map((line, i) => mask(line, i))}
|
|
391
|
+
</h1>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
{/* The caption bar, on the page ground under the still like a film's caption strip: three facts on one line (two
|
|
396
|
+
on phones), the clock in tabular figures at a fixed width. On the ground its ink holds on any photograph. */}
|
|
397
|
+
<div data-bar="" className="text-small relative bg-surface-0 px-6 pb-4 pt-3.5 text-ink md:px-[4vw] md:pb-5 md:pt-4">
|
|
398
|
+
<div>
|
|
399
|
+
{mask(
|
|
400
|
+
<span className="grid grid-cols-[1fr_auto] gap-x-6 gap-y-1 md:grid-cols-[1fr_auto_1fr]">
|
|
401
|
+
<span>{place}</span>
|
|
402
|
+
<span className="tabular-nums lining-nums md:text-center">
|
|
403
|
+
<time suppressHydrationWarning className="inline-block min-w-[5ch] text-right">
|
|
404
|
+
{time}
|
|
405
|
+
</time>{" "}
|
|
406
|
+
lokale tijd
|
|
407
|
+
</span>
|
|
408
|
+
<a href={link.href} className="col-span-2 underline decoration-1 underline-offset-4 md:col-span-1 md:justify-self-end">
|
|
409
|
+
{link.label}
|
|
410
|
+
</a>
|
|
411
|
+
</span>,
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
</section>
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// The frame and the horizon share what follows: the clock, the caption strip, the line masks and the entrance.
|
|
420
|
+
|
|
421
|
+
function useClock(timeZone: string) {
|
|
422
|
+
const [time, setTime] = useState(() => timeIn(timeZone));
|
|
423
|
+
useEffect(() => {
|
|
424
|
+
let tick = 0;
|
|
425
|
+
const next = () => {
|
|
426
|
+
setTime(timeIn(timeZone));
|
|
427
|
+
tick = window.setTimeout(next, 60_000 - (Date.now() % 60_000) + 50);
|
|
428
|
+
};
|
|
429
|
+
next();
|
|
430
|
+
return () => window.clearTimeout(tick);
|
|
431
|
+
}, [timeZone]);
|
|
432
|
+
return time;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const lineMask = (child: React.ReactNode, key?: number) => (
|
|
436
|
+
<span key={key} className="struq-line-mask block overflow-hidden">
|
|
437
|
+
<span data-rise="" className="block">
|
|
438
|
+
{child}
|
|
439
|
+
</span>
|
|
440
|
+
</span>
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
/** The caption strip on the page ground, as the still has it: place, the live local time, one link. */
|
|
444
|
+
function Caption({ place, time, link }: { place: string; time: string; link: Inner["link"] }) {
|
|
445
|
+
return (
|
|
446
|
+
<div data-bar="" className="text-small relative bg-surface-0 px-6 pb-4 pt-3.5 text-ink md:px-[4vw] md:pb-5 md:pt-4">
|
|
447
|
+
<div>
|
|
448
|
+
{lineMask(
|
|
449
|
+
<span className="grid grid-cols-[1fr_auto] gap-x-6 gap-y-1 md:grid-cols-[1fr_auto_1fr]">
|
|
450
|
+
<span>{place}</span>
|
|
451
|
+
<span className="tabular-nums lining-nums md:text-center">
|
|
452
|
+
<time suppressHydrationWarning className="inline-block min-w-[5ch] text-right">
|
|
453
|
+
{time}
|
|
454
|
+
</time>{" "}
|
|
455
|
+
lokale tijd
|
|
456
|
+
</span>
|
|
457
|
+
<a href={link.href} className="col-span-2 underline decoration-1 underline-offset-4 md:col-span-1 md:justify-self-end">
|
|
458
|
+
{link.label}
|
|
459
|
+
</a>
|
|
460
|
+
</span>,
|
|
461
|
+
)}
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
type Safe = (fn: () => void) => () => void;
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The still's entrance for the frame and the horizon: once the photo is decoded and nothing covers the headline, its
|
|
471
|
+
* lines rise through their masks (lines under [data-below] drop from above instead) and the push-in starts. A script
|
|
472
|
+
* that arrives after the CSS fallback (3s) leaves the lines in place. `before` runs after the decode, while the lines
|
|
473
|
+
* still wait; `play` as they start. Returns whether the lines were already shown, and the cleanup.
|
|
474
|
+
*/
|
|
475
|
+
function entrance(
|
|
476
|
+
el: HTMLElement,
|
|
477
|
+
img: HTMLImageElement,
|
|
478
|
+
safe: Safe,
|
|
479
|
+
opts: { reduce: boolean; before?: () => void; play?: () => void },
|
|
480
|
+
) {
|
|
481
|
+
const rise = Array.from(el.querySelectorAll<HTMLElement>("[data-rise]"));
|
|
482
|
+
const from = (n: Element) => (n.closest("[data-below]") ? -115 : 115);
|
|
483
|
+
// The element's own fallback clock, read once per element (strict mode's second run must not read its first).
|
|
484
|
+
const clock = rise[0]?.getAnimations().find((a) => (a as CSSAnimation).animationName === "struq-hs-rise");
|
|
485
|
+
el.dataset.shown ??= String(
|
|
486
|
+
opts.reduce || (clock ? Number(clock.currentTime ?? 0) >= 2900 : getComputedStyle(rise[0]).transform === "none"),
|
|
487
|
+
);
|
|
488
|
+
const shown = el.dataset.shown === "true";
|
|
489
|
+
// y: 0 as well: GSAP would otherwise read the CSS start state into a pixel y and keep it.
|
|
490
|
+
gsap.set(rise, { y: 0, yPercent: (_: number, n: Element) => (shown ? 0 : from(n)) });
|
|
491
|
+
el.dataset.ready = "";
|
|
492
|
+
let gone = false;
|
|
493
|
+
let cancel = () => {};
|
|
494
|
+
img
|
|
495
|
+
.decode()
|
|
496
|
+
.catch(() => undefined)
|
|
497
|
+
.then(() => {
|
|
498
|
+
if (gone) return;
|
|
499
|
+
opts.before?.();
|
|
500
|
+
const play = safe(() => {
|
|
501
|
+
opts.play?.();
|
|
502
|
+
if (opts.reduce) {
|
|
503
|
+
gsap.set(rise, { yPercent: 0 });
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (!shown) gsap.to(rise, { yPercent: 0, duration: 1.15, ease: "expo.out", stagger: 0.09, delay: 0.1 });
|
|
507
|
+
el.dataset.pushing = "";
|
|
508
|
+
});
|
|
509
|
+
// The lines start once a leaving cover no longer hides the first of them, at most 1.6s after it starts to go.
|
|
510
|
+
const seen = () => {
|
|
511
|
+
const line = rise[0]?.parentElement?.getBoundingClientRect();
|
|
512
|
+
if (!line) return true;
|
|
513
|
+
const hit = document.elementFromPoint(line.left + 8, line.top + line.height / 2);
|
|
514
|
+
return !hit || el.contains(hit);
|
|
515
|
+
};
|
|
516
|
+
let watch = 0;
|
|
517
|
+
const stop = whenUncovered(() => {
|
|
518
|
+
const until = performance.now() + 1600;
|
|
519
|
+
const look = () => {
|
|
520
|
+
if (gone) return;
|
|
521
|
+
if (seen() || performance.now() > until) play();
|
|
522
|
+
else watch = requestAnimationFrame(look);
|
|
523
|
+
};
|
|
524
|
+
look();
|
|
525
|
+
});
|
|
526
|
+
cancel = () => {
|
|
527
|
+
stop();
|
|
528
|
+
cancelAnimationFrame(watch);
|
|
529
|
+
};
|
|
530
|
+
});
|
|
531
|
+
return {
|
|
532
|
+
shown,
|
|
533
|
+
stop: () => {
|
|
534
|
+
gone = true;
|
|
535
|
+
cancel();
|
|
536
|
+
delete el.dataset.pushing;
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* frame: the photograph inside margins of page ground, the headline on the ground beside it. The frame's box carries
|
|
545
|
+
* data-stack-target and the photo inside it is the whole still at the still's origin (data-stack-photo), so a stack
|
|
546
|
+
* preloader lands on the frame with the photo laid as the still, and opening the frame on the first scroll is a clip
|
|
547
|
+
* from whole-pixel numbers to the full bleed, never a scale. The headline leaves upward ahead of the opening edge.
|
|
548
|
+
*/
|
|
549
|
+
function StillFrame({ image, lines, place, timeZone = "Europe/Amsterdam", link, className = "" }: Inner) {
|
|
550
|
+
const root = useRef<HTMLElement>(null);
|
|
551
|
+
const time = useClock(timeZone);
|
|
552
|
+
|
|
553
|
+
useGSAP(
|
|
554
|
+
(_, contextSafe) => {
|
|
555
|
+
const el = root.current;
|
|
556
|
+
if (!el || !contextSafe) return;
|
|
557
|
+
const still = el.querySelector<HTMLElement>("[data-still]");
|
|
558
|
+
const slot = el.querySelector<HTMLElement>("[data-hs-slot]");
|
|
559
|
+
const photo = el.querySelector<HTMLElement>("[data-hs-photo]");
|
|
560
|
+
const head = el.querySelector<HTMLElement>("[data-head]");
|
|
561
|
+
const img = photo?.querySelector("img");
|
|
562
|
+
if (!still || !slot || !photo || !head || !img) return;
|
|
563
|
+
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
564
|
+
const run = entrance(el, img, contextSafe as Safe, { reduce });
|
|
565
|
+
|
|
566
|
+
const mm = gsap.matchMedia();
|
|
567
|
+
mm.add(
|
|
568
|
+
{
|
|
569
|
+
wide: "(min-width: 768px) and (prefers-reduced-motion: no-preference)",
|
|
570
|
+
phone: "(max-width: 767px) and (prefers-reduced-motion: no-preference)",
|
|
571
|
+
},
|
|
572
|
+
(ctx) => {
|
|
573
|
+
if (!ctx.conditions?.wide) {
|
|
574
|
+
// Phones: the still's own hand-over, the photo slower than the page and the headline leaving first.
|
|
575
|
+
gsap
|
|
576
|
+
.timeline({ scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.4 } })
|
|
577
|
+
.to(el.querySelector("[data-drift]"), { yPercent: 22, ease: "none" }, 0)
|
|
578
|
+
.to(head, { yPercent: -60, ease: "none", duration: 0.55 }, 0);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
el.dataset.live = "";
|
|
582
|
+
const g = { W: 1, H: 1, x: 0, y: 0, w: 1, h: 1, head: 0 };
|
|
583
|
+
const s = { grow: 0 };
|
|
584
|
+
const measure = () => {
|
|
585
|
+
g.W = still.clientWidth;
|
|
586
|
+
g.H = still.clientHeight;
|
|
587
|
+
const b = still.getBoundingClientRect();
|
|
588
|
+
const r = slot.getBoundingClientRect();
|
|
589
|
+
g.x = r.left - b.left;
|
|
590
|
+
g.y = r.top - b.top;
|
|
591
|
+
g.w = r.width;
|
|
592
|
+
g.h = r.height;
|
|
593
|
+
// The photo is the still, at the still's origin, measured (the CSS places it from the viewport units).
|
|
594
|
+
Object.assign(photo.style, {
|
|
595
|
+
left: `${Math.round(-g.x)}px`,
|
|
596
|
+
top: `${Math.round(-g.y)}px`,
|
|
597
|
+
width: `${g.W}px`,
|
|
598
|
+
height: `${g.H}px`,
|
|
599
|
+
});
|
|
600
|
+
g.head = head.offsetTop + head.offsetHeight + 8;
|
|
601
|
+
};
|
|
602
|
+
const paint = () => {
|
|
603
|
+
const t = Math.round(lerp(g.y, 0, s.grow));
|
|
604
|
+
const r = Math.round(lerp(g.W - g.x - g.w, 0, s.grow));
|
|
605
|
+
const b = Math.round(lerp(g.H - g.y - g.h, 0, s.grow));
|
|
606
|
+
const l = Math.round(lerp(g.x, 0, s.grow));
|
|
607
|
+
photo.style.clipPath = `inset(${t}px ${r}px ${b}px ${l}px)`;
|
|
608
|
+
head.style.transform = `translate3d(0, ${Math.round(-g.head * Math.min(1, s.grow / 0.55))}px, 0)`;
|
|
609
|
+
};
|
|
610
|
+
const again = () => {
|
|
611
|
+
measure();
|
|
612
|
+
paint();
|
|
613
|
+
};
|
|
614
|
+
again();
|
|
615
|
+
// Hold a beat on the frame, open it to the full bleed, hold on the full still, hand over.
|
|
616
|
+
gsap
|
|
617
|
+
.timeline({
|
|
618
|
+
defaults: { ease: "none" },
|
|
619
|
+
onUpdate: paint,
|
|
620
|
+
scrollTrigger: { trigger: el, start: "top top", end: "+=100%", pin: true, scrub: 0.4, onRefresh: again },
|
|
621
|
+
})
|
|
622
|
+
.to({}, { duration: 0.15 })
|
|
623
|
+
.to(s, { grow: 1, duration: 1, ease: "power3.inOut" }, 0.15)
|
|
624
|
+
.to({}, { duration: 0.35 }, 1.15);
|
|
625
|
+
window.addEventListener("resize", again);
|
|
626
|
+
return () => {
|
|
627
|
+
window.removeEventListener("resize", again);
|
|
628
|
+
delete el.dataset.live;
|
|
629
|
+
photo.style.clipPath = "";
|
|
630
|
+
head.style.transform = "";
|
|
631
|
+
for (const k of ["left", "top", "width", "height"] as const) photo.style[k] = "";
|
|
632
|
+
};
|
|
633
|
+
},
|
|
634
|
+
);
|
|
635
|
+
document.fonts?.ready.then(() => ScrollTrigger.refresh());
|
|
636
|
+
return () => {
|
|
637
|
+
run.stop();
|
|
638
|
+
mm.revert();
|
|
639
|
+
};
|
|
640
|
+
},
|
|
641
|
+
{ scope: root },
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
return (
|
|
645
|
+
<section
|
|
646
|
+
ref={root}
|
|
647
|
+
data-hero-still=""
|
|
648
|
+
data-hs-variant="frame"
|
|
649
|
+
suppressHydrationWarning
|
|
650
|
+
className={`group/hs relative flex h-svh min-h-[32rem] flex-col overflow-hidden bg-surface-0 text-ink ${className}`}
|
|
651
|
+
>
|
|
652
|
+
<div data-still="" className="relative min-h-0 flex-1">
|
|
653
|
+
{/* The frame: what a preloader lands on. The photo inside is the whole still, registered at its origin. */}
|
|
654
|
+
<div data-hs-slot="" data-stack-target="">
|
|
655
|
+
<div data-hs-photo="" data-stack-photo="">
|
|
656
|
+
<div data-drift="" className="absolute inset-0">
|
|
657
|
+
<div data-push="" className="absolute -inset-[3%]">
|
|
658
|
+
<Image
|
|
659
|
+
src={image.src}
|
|
660
|
+
alt={image.alt}
|
|
661
|
+
fill
|
|
662
|
+
priority
|
|
663
|
+
sizes="100vw"
|
|
664
|
+
unoptimized={image.unoptimized}
|
|
665
|
+
className="object-cover"
|
|
666
|
+
/>
|
|
667
|
+
</div>
|
|
668
|
+
</div>
|
|
669
|
+
</div>
|
|
670
|
+
</div>
|
|
671
|
+
<div
|
|
672
|
+
data-head=""
|
|
673
|
+
className="absolute inset-x-6 bottom-[4svh] z-10 will-change-transform md:bottom-[3svh] md:left-[4vw] md:right-auto md:max-w-[34vw]"
|
|
674
|
+
>
|
|
675
|
+
<h1 className="font-display text-[clamp(3rem,13.5vw,4.75rem)] leading-[0.92] tracking-[-0.035em] md:text-[clamp(3rem,5.4vw,7.5rem)]">
|
|
676
|
+
{lines.map((line, i) => lineMask(line, i))}
|
|
677
|
+
</h1>
|
|
678
|
+
</div>
|
|
679
|
+
</div>
|
|
680
|
+
<Caption place={place} time={time} link={link} />
|
|
681
|
+
</section>
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* horizon: the photograph edge to edge as in the still, the headline split at its horizon. The horizon is the
|
|
687
|
+
* strongest change of brightness across the photo's middle band, where both halves of the headline still fit (56%
|
|
688
|
+
* when the photo cannot be read). Each half takes the ink that needs the weaker veil over what is under its own
|
|
689
|
+
* glyphs, over its own veil band that ends at the horizon; the side is measured the same way unless it is given.
|
|
690
|
+
*/
|
|
691
|
+
function StillHorizon({ image, lines, place, timeZone = "Europe/Amsterdam", link, side, horizon, className = "" }: Inner) {
|
|
692
|
+
const root = useRef<HTMLElement>(null);
|
|
693
|
+
const time = useClock(timeZone);
|
|
694
|
+
const first = side ?? (image.negativeSpace === "right" ? "right" : "left");
|
|
695
|
+
const up = lines.slice(0, Math.ceil(lines.length / 2));
|
|
696
|
+
const down = lines.slice(up.length);
|
|
697
|
+
|
|
698
|
+
useGSAP(
|
|
699
|
+
(_, contextSafe) => {
|
|
700
|
+
const el = root.current;
|
|
701
|
+
if (!el || !contextSafe) return;
|
|
702
|
+
const still = el.querySelector<HTMLElement>("[data-still]");
|
|
703
|
+
const frame = el.querySelector<HTMLElement>("[data-stack-target]");
|
|
704
|
+
const img = frame?.querySelector("img");
|
|
705
|
+
const above = el.querySelector<HTMLElement>("[data-above]");
|
|
706
|
+
const below = el.querySelector<HTMLElement>("[data-below]");
|
|
707
|
+
const bandUp = el.querySelector<HTMLElement>("[data-band='above']");
|
|
708
|
+
const bandDown = el.querySelector<HTMLElement>("[data-band='below']");
|
|
709
|
+
if (!still || !frame || !img || !above || !bandUp || !bandDown) return;
|
|
710
|
+
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
711
|
+
const bands = [bandUp, bandDown];
|
|
712
|
+
const graded: number[] = [];
|
|
713
|
+
let played = false;
|
|
714
|
+
// Under a preloader the photo is shown bare, the way the preloader's print lands; the bands settle in later.
|
|
715
|
+
if (covered()) {
|
|
716
|
+
for (const b of bands) {
|
|
717
|
+
b.style.transition = "none";
|
|
718
|
+
b.style.opacity = "0";
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const grade = () => {
|
|
723
|
+
const from = frame.getBoundingClientRect();
|
|
724
|
+
const box = { x: 0, y: 0, w: from.width, h: from.height };
|
|
725
|
+
// The horizon: the one given, else the place where the brightness of the four fiftieths above and the four
|
|
726
|
+
// below differ most; either way within the band where both halves of the headline fit.
|
|
727
|
+
const lineH = parseFloat(getComputedStyle(above).fontSize) * 0.92;
|
|
728
|
+
const lo = Math.max(0.3, (box.h * 0.07 + up.length * lineH) / box.h);
|
|
729
|
+
const hi = Math.max(lo, Math.min(0.8, 1 - (box.h * 0.06 + down.length * lineH) / box.h));
|
|
730
|
+
let at = Math.min(hi, Math.max(lo, horizon ?? 0.56));
|
|
731
|
+
if (horizon === undefined) {
|
|
732
|
+
const rows: number[] = [];
|
|
733
|
+
for (let k = 0; k < 50; k += 1) {
|
|
734
|
+
const ys = photoLuminances(img, box, { x: 0, y: (k * box.h) / 50, w: box.w, h: box.h / 50 });
|
|
735
|
+
if (!ys) return;
|
|
736
|
+
rows.push(ys.reduce((a, b) => a + b, 0) / ys.length);
|
|
737
|
+
}
|
|
738
|
+
const mean = (a: number, b: number) => rows.slice(a, b).reduce((s, v) => s + v, 0) / (b - a);
|
|
739
|
+
let edge = -1;
|
|
740
|
+
for (let k = 4; k <= 46; k += 1) {
|
|
741
|
+
const t = k / 50;
|
|
742
|
+
if (t < lo || t > hi) continue;
|
|
743
|
+
const e = Math.abs(mean(k - 4, k) - mean(k, k + 4));
|
|
744
|
+
if (e > edge) {
|
|
745
|
+
edge = e;
|
|
746
|
+
at = t;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
still.style.setProperty("--hs-hz", `${(at * 100).toFixed(2)}%`);
|
|
751
|
+
|
|
752
|
+
// Each half's ink over what is under its own glyphs (the range's width, its untransformed box's height).
|
|
753
|
+
const under = (n: HTMLElement): PhotoBox => {
|
|
754
|
+
const range = document.createRange();
|
|
755
|
+
range.selectNodeContents(n);
|
|
756
|
+
const t = range.getBoundingClientRect();
|
|
757
|
+
const o = n.getBoundingClientRect();
|
|
758
|
+
return { x: t.left - from.left, y: o.top - from.top, w: t.width, h: o.height };
|
|
759
|
+
};
|
|
760
|
+
const read = () => {
|
|
761
|
+
const a = inkOver(img, box, [under(above)], el);
|
|
762
|
+
const b = below ? inkOver(img, box, [under(below)], el) : a;
|
|
763
|
+
return a && b ? { a, b, need: a.veil + b.veil } : null;
|
|
764
|
+
};
|
|
765
|
+
let pick = read();
|
|
766
|
+
if (!pick) return;
|
|
767
|
+
if (!side && !run.shown) {
|
|
768
|
+
const was = el.dataset.side;
|
|
769
|
+
el.dataset.side = was === "right" ? "left" : "right";
|
|
770
|
+
const other = read();
|
|
771
|
+
if (other && other.need < pick.need - 0.04) pick = other;
|
|
772
|
+
else el.dataset.side = was;
|
|
773
|
+
}
|
|
774
|
+
above.style.color = pick.a.ink;
|
|
775
|
+
if (below) below.style.color = pick.b.ink;
|
|
776
|
+
bandUp.dataset.tone = pick.a.tone;
|
|
777
|
+
bandDown.dataset.tone = pick.b.tone;
|
|
778
|
+
graded[0] = pick.a.veil;
|
|
779
|
+
graded[1] = pick.b.veil;
|
|
780
|
+
if (played || !covered()) bands.forEach((b, i) => (b.style.opacity = graded[i].toFixed(3)));
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
const run = entrance(el, img, contextSafe as Safe, {
|
|
784
|
+
reduce,
|
|
785
|
+
before: grade,
|
|
786
|
+
play: () => {
|
|
787
|
+
played = true;
|
|
788
|
+
bands.forEach((b, i) => {
|
|
789
|
+
b.style.transition = "opacity 1.2s cubic-bezier(0.16, 1, 0.3, 1)";
|
|
790
|
+
b.style.opacity = (graded[i] ?? 0.4).toFixed(3);
|
|
791
|
+
});
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
// The hand-over: the photograph moves slower than the page, and the headline rides with it on its horizon.
|
|
796
|
+
if (!reduce) {
|
|
797
|
+
gsap
|
|
798
|
+
.timeline({ scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.4, invalidateOnRefresh: true } })
|
|
799
|
+
.to(el.querySelector("[data-drift]"), { yPercent: 22, ease: "none" }, 0)
|
|
800
|
+
.to(el.querySelector("[data-ride]"), { y: () => Math.round(still.clientHeight * 0.22), ease: "none" }, 0);
|
|
801
|
+
}
|
|
802
|
+
document.fonts?.ready.then(() => ScrollTrigger.refresh());
|
|
803
|
+
window.addEventListener("resize", grade);
|
|
804
|
+
return () => {
|
|
805
|
+
run.stop();
|
|
806
|
+
window.removeEventListener("resize", grade);
|
|
807
|
+
};
|
|
808
|
+
},
|
|
809
|
+
{ scope: root },
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
const head =
|
|
813
|
+
"absolute inset-x-6 font-display text-[clamp(3rem,13.5vw,4.75rem)] leading-[0.92] tracking-[-0.035em] group-data-[side=right]/hs:text-right md:inset-x-[4vw] md:text-[clamp(3.5rem,8vw,9.5rem)]";
|
|
814
|
+
|
|
815
|
+
return (
|
|
816
|
+
<section
|
|
817
|
+
ref={root}
|
|
818
|
+
data-hero-still=""
|
|
819
|
+
data-hs-variant="horizon"
|
|
820
|
+
data-side={first}
|
|
821
|
+
suppressHydrationWarning
|
|
822
|
+
className={`group/hs relative flex h-svh min-h-[32rem] flex-col overflow-hidden bg-surface-0 text-on-photo ${className}`}
|
|
823
|
+
>
|
|
824
|
+
<div
|
|
825
|
+
data-still=""
|
|
826
|
+
className="relative min-h-0 flex-1"
|
|
827
|
+
style={{ "--hs-hz": `${((horizon ?? 0.56) * 100).toFixed(2)}%` } as React.CSSProperties}
|
|
828
|
+
>
|
|
829
|
+
<div data-stack-target="" className="absolute inset-0 overflow-hidden">
|
|
830
|
+
<div data-drift="" className="absolute inset-0">
|
|
831
|
+
<div data-push="" className="absolute -inset-[3%]">
|
|
832
|
+
<Image
|
|
833
|
+
src={image.src}
|
|
834
|
+
alt={image.alt}
|
|
835
|
+
fill
|
|
836
|
+
priority
|
|
837
|
+
sizes="100vw"
|
|
838
|
+
unoptimized={image.unoptimized}
|
|
839
|
+
className="object-cover"
|
|
840
|
+
/>
|
|
841
|
+
</div>
|
|
842
|
+
{/* One light veil band per half, meeting at the horizon. */}
|
|
843
|
+
<div data-veil="" data-band="above" aria-hidden="true" className="absolute inset-x-0 top-0 h-[var(--hs-hz)]" />
|
|
844
|
+
<div data-veil="" data-band="below" aria-hidden="true" className="absolute inset-x-0 bottom-0 top-[var(--hs-hz)]" />
|
|
845
|
+
</div>
|
|
846
|
+
</div>
|
|
847
|
+
<div data-ride="" data-head="" className="absolute inset-0 will-change-transform">
|
|
848
|
+
<h1 className="sr-only">{lines.join(" ")}</h1>
|
|
849
|
+
<div data-above="" aria-hidden className={`${head} bottom-[calc(100%_-_var(--hs-hz))] pb-[0.03em]`}>
|
|
850
|
+
{up.map((line, i) => lineMask(line, i))}
|
|
851
|
+
</div>
|
|
852
|
+
{down.length ? (
|
|
853
|
+
<div data-below="" aria-hidden className={`${head} top-[var(--hs-hz)] pt-[0.03em]`}>
|
|
854
|
+
{down.map((line, i) => lineMask(line, i))}
|
|
855
|
+
</div>
|
|
856
|
+
) : null}
|
|
857
|
+
</div>
|
|
858
|
+
</div>
|
|
859
|
+
<Caption place={place} time={time} link={link} />
|
|
860
|
+
</section>
|
|
861
|
+
);
|
|
862
|
+
}
|