@struq/skill 0.3.0 → 0.4.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 +22 -36
- package/package.json +4 -2
- package/skills/struq/SKILL.md +139 -307
- 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/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +4 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -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/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
- package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
- package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
- 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 -22
- package/skills/struq/recipes/app/globals.css +34 -177
- package/skills/struq/recipes/app/layout.tsx +13 -11
- 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 +106 -281
- package/skills/struq/scripts/check.mjs +92 -126
- package/skills/struq/scripts/images.mjs +2 -2
- package/skills/struq/scripts/log.mjs +6 -23
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/cinematic/showpieces/README.md +0 -18
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
- package/skills/struq/references/anti-patterns.md +0 -330
- package/skills/struq/references/baseline-prompt.md +0 -122
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -218
- package/skills/struq/references/composition.md +0 -190
- 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/hero-scenes.md +0 -107
- package/skills/struq/references/imagery.md +0 -125
- 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/niche-packets.md +0 -338
- 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 -340
- 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 -279
- package/skills/struq/references/sections.md +0 -141
- 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
- package/skills/struq/scripts/font-names.mjs +0 -40
- package/skills/struq/scripts/recipes.mjs +0 -135
- /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// grid-assembly (Struq section recipe)
|
|
3
|
+
// Prints thrown on a table that lock into an editorial grid, and one of them becomes the ground
|
|
4
|
+
// for what follows. The stage pins on a composed scatter: nine photos of different sizes lying
|
|
5
|
+
// at slight angles around the middle, a few hanging past the edges of the screen. As you scroll
|
|
6
|
+
// they travel into a precise grid with exact gaps, each straightening and settling into its own
|
|
7
|
+
// cell a beat after the last, and the section's line stands up in the one cell left for type. The
|
|
8
|
+
// grid holds. Then the photo in the hero cell grows: its frame opens from the cell to the full
|
|
9
|
+
// screen, the photo inside settling with it so its crop never jumps, while the other prints are
|
|
10
|
+
// pushed out of the way. Full bleed, the next line of copy rises on the photo's calm side, holds,
|
|
11
|
+
// and as the pin lets go the photo drifts slower than the page.
|
|
12
|
+
//
|
|
13
|
+
// Every move is written from plain numbers on every frame. A tile's whole path is one number
|
|
14
|
+
// (`--p` from the scatter to its cell, `--q` out of the way), read by one CSS transform. The hero
|
|
15
|
+
// print is a window onto the full-screen photo, registered to the screen; once the grid locks, a
|
|
16
|
+
// full-screen plate of the same photo takes over showing the very same pixels, and the grow is a
|
|
17
|
+
// clip opening from the cell's pixels to the screen's, the photo never changing scale. The scatter is
|
|
18
|
+
// the first paint, so nothing jumps when the pin engages, and it only exists once the page runs
|
|
19
|
+
// scripts (blocks.css, "--- grid-assembly ---"). The pin is 250%.
|
|
20
|
+
// Lead for an architect, a builder, a studio with a body of work: any brief whose story is many
|
|
21
|
+
// pieces making one thing.
|
|
22
|
+
//
|
|
23
|
+
// <GridAssembly tiles={[...nine photos]} hero={images.hero[1]} title="Noord bouwt in lagen." text="..."
|
|
24
|
+
// heading={["Van fundering", "tot dakrand."]} line="..." />
|
|
25
|
+
//
|
|
26
|
+
// Phones: the grid at rest, its tiles opening on a short entrance; then the photo full width with
|
|
27
|
+
// its line. Reduced motion and pages without scripts: the same still layout, nothing hidden.
|
|
28
|
+
import Image from "next/image";
|
|
29
|
+
import { useRef } from "react";
|
|
30
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
31
|
+
import type { StruqImage } from "../kit/images";
|
|
32
|
+
import { photoLuminances } from "../kit/photo-read";
|
|
33
|
+
|
|
34
|
+
type Props = {
|
|
35
|
+
/** Eight photos for the grid around the hero cell (more are ignored). */
|
|
36
|
+
tiles: StruqImage[];
|
|
37
|
+
/** The photo in the hero cell, the one that grows to the full screen. */
|
|
38
|
+
hero: StruqImage;
|
|
39
|
+
/** The section's line, set in the grid's type cell. */
|
|
40
|
+
title: string;
|
|
41
|
+
/** One short line under it. */
|
|
42
|
+
text?: string;
|
|
43
|
+
/** The line on the full-bleed photo, one entry per line. */
|
|
44
|
+
heading: string[];
|
|
45
|
+
/** One sentence under it. */
|
|
46
|
+
line?: string;
|
|
47
|
+
className?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type Cell = { c: number; cs: number; r: number; rs: number };
|
|
51
|
+
type Scatter = { x: number; y: number; rot: number; scale: number };
|
|
52
|
+
|
|
53
|
+
// The board: twelve columns, six rows, every cell filled. `t` is the type cell, `h` the hero cell.
|
|
54
|
+
const CELLS: Record<string, Cell> = {
|
|
55
|
+
t: { c: 1, cs: 4, r: 1, rs: 2 },
|
|
56
|
+
a: { c: 5, cs: 3, r: 1, rs: 3 },
|
|
57
|
+
b: { c: 8, cs: 5, r: 1, rs: 2 },
|
|
58
|
+
c: { c: 1, cs: 2, r: 3, rs: 2 },
|
|
59
|
+
d: { c: 3, cs: 2, r: 3, rs: 4 },
|
|
60
|
+
e: { c: 8, cs: 2, r: 3, rs: 2 },
|
|
61
|
+
f: { c: 10, cs: 3, r: 3, rs: 4 },
|
|
62
|
+
h: { c: 5, cs: 3, r: 4, rs: 3 },
|
|
63
|
+
i: { c: 8, cs: 2, r: 5, rs: 2 },
|
|
64
|
+
j: { c: 1, cs: 2, r: 5, rs: 2 },
|
|
65
|
+
};
|
|
66
|
+
const ORDER = ["a", "b", "c", "d", "e", "f", "i", "j"] as const;
|
|
67
|
+
|
|
68
|
+
// The scatter: where each print lies before it is placed (its centre, in vw and svh), its angle and size. One
|
|
69
|
+
// overlapping pile a little right of the middle, the hero print on top of it, three spilling past the edges:
|
|
70
|
+
// prints thrown onto a table, composed rather than random.
|
|
71
|
+
const SCATTER: Record<string, Scatter> = {
|
|
72
|
+
a: { x: 37, y: 31, rot: -8, scale: 0.74 },
|
|
73
|
+
b: { x: 64, y: 21, rot: 5, scale: 0.64 },
|
|
74
|
+
c: { x: 22, y: 60, rot: 10, scale: 0.92 },
|
|
75
|
+
d: { x: 46, y: 68, rot: -4, scale: 0.72 },
|
|
76
|
+
e: { x: 72, y: 52, rot: 12, scale: 0.96 },
|
|
77
|
+
f: { x: 94, y: 72, rot: -9, scale: 0.8 },
|
|
78
|
+
h: { x: 54, y: 43, rot: 2, scale: 0.84 },
|
|
79
|
+
i: { x: 12, y: 92, rot: -13, scale: 0.96 },
|
|
80
|
+
j: { x: 76, y: 94, rot: 7, scale: 0.9 },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// The board's geometry, the same numbers the CSS grid uses: 4vw sides, 7svh top and bottom, 0.9vw gaps.
|
|
84
|
+
const COL = "((92vw - 11 * 0.9vw) / 12)";
|
|
85
|
+
const ROW = "((86svh - 5 * 0.9vw) / 6)";
|
|
86
|
+
const centre = (cell: Cell) => ({
|
|
87
|
+
x: `(4vw + ${cell.c - 1} * (${COL} + 0.9vw) + (${cell.cs} * ${COL} + ${cell.cs - 1} * 0.9vw) / 2)`,
|
|
88
|
+
y: `(7svh + ${cell.r - 1} * (${ROW} + 0.9vw) + (${cell.rs} * ${ROW} + ${cell.rs - 1} * 0.9vw) / 2)`,
|
|
89
|
+
});
|
|
90
|
+
/** The custom properties a tile's scatter is written in: the offset from its cell to its place in the pile. */
|
|
91
|
+
const scatterVars = (key: string): React.CSSProperties => {
|
|
92
|
+
const s = SCATTER[key];
|
|
93
|
+
const c = centre(CELLS[key]);
|
|
94
|
+
return {
|
|
95
|
+
"--sx": `calc(${s.x}vw - ${c.x})`,
|
|
96
|
+
"--sy": `calc(${s.y}svh - ${c.y})`,
|
|
97
|
+
"--sr": `${s.rot}deg`,
|
|
98
|
+
"--sd": String(1 - s.scale),
|
|
99
|
+
} as React.CSSProperties;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Until the photo is read, and without scripts: dark ink over a light veil that holds 4.5:1 over any photo (the
|
|
103
|
+
* on-photo light over black, composited in sRGB, comes to about 0.21 luminance at this strength). */
|
|
104
|
+
const SAFE_VEIL = 0.52;
|
|
105
|
+
/** The share of the grow by which every other print has left the screen (and is no longer drawn). */
|
|
106
|
+
const OFF = 0.7;
|
|
107
|
+
/** How long one print takes to fly, as a share of the lock, and how far apart they leave. */
|
|
108
|
+
const FLY = 0.62;
|
|
109
|
+
/** The order the prints are placed in: the hero first, then outward. */
|
|
110
|
+
const PLACE_ORDER = ["h", "a", "e", "d", "b", "c", "f", "j", "i"];
|
|
111
|
+
|
|
112
|
+
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
|
|
113
|
+
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
114
|
+
/** power4.inOut: a hard settle, so each print lands exactly. */
|
|
115
|
+
const settle = (t: number) => (t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2);
|
|
116
|
+
const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
117
|
+
const encode = (l: number) => (l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055);
|
|
118
|
+
const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
119
|
+
|
|
120
|
+
/** The relative luminance of any CSS colour, through a one-pixel canvas so every colour syntax resolves. */
|
|
121
|
+
function luminanceOf(color: string): number {
|
|
122
|
+
const cv = document.createElement("canvas");
|
|
123
|
+
cv.width = 1;
|
|
124
|
+
cv.height = 1;
|
|
125
|
+
const ctx = cv.getContext("2d", { willReadFrequently: true });
|
|
126
|
+
if (!ctx) return 0;
|
|
127
|
+
ctx.fillStyle = "#000";
|
|
128
|
+
ctx.fillStyle = color;
|
|
129
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
130
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
131
|
+
return 0.2126 * linear(r / 255) + 0.7152 * linear(g / 255) + 0.0722 * linear(b / 255);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The weakest veil that brings `under` to `ratio` against an ink, composited as the browser does: in sRGB. */
|
|
135
|
+
function veilFor(under: number, ink: number, veilS: number, ratio: number): number {
|
|
136
|
+
if (contrast(under, ink) >= ratio) return 0;
|
|
137
|
+
const s = encode(under);
|
|
138
|
+
const ok = (a: number) => contrast(linear(s * (1 - a) + veilS * a), ink) >= ratio;
|
|
139
|
+
if (!ok(0.85)) return 0.85;
|
|
140
|
+
let lo = 0;
|
|
141
|
+
let hi = 0.85;
|
|
142
|
+
for (let i = 0; i < 14; i += 1) {
|
|
143
|
+
const mid = (lo + hi) / 2;
|
|
144
|
+
if (ok(mid)) hi = mid;
|
|
145
|
+
else lo = mid;
|
|
146
|
+
}
|
|
147
|
+
return hi;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The darker and brighter ends of a photo's luminance under a box of the screen (the photo covers the screen).
|
|
152
|
+
* Only read when the photo is from this page's own origin: a photo from elsewhere would taint the canvas.
|
|
153
|
+
*/
|
|
154
|
+
function readUnder(
|
|
155
|
+
img: HTMLImageElement,
|
|
156
|
+
W: number,
|
|
157
|
+
H: number,
|
|
158
|
+
box: { x: number; y: number; w: number; h: number },
|
|
159
|
+
tail: number,
|
|
160
|
+
) {
|
|
161
|
+
const ys = photoLuminances(img, { x: 0, y: 0, w: W, h: H }, box);
|
|
162
|
+
if (!ys) return null;
|
|
163
|
+
return { lo: ys[Math.floor(ys.length * tail)], hi: ys[Math.min(ys.length - 1, Math.floor(ys.length * (1 - tail)))] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function GridAssembly({ tiles, hero, title, text, heading, line, className = "" }: Props) {
|
|
167
|
+
const stage = useRef<HTMLElement>(null);
|
|
168
|
+
const photos = ORDER.map((key, i) => ({ key, image: tiles[i % Math.max(1, tiles.length)] })).filter((t) => t.image);
|
|
169
|
+
const side = hero.negativeSpace === "right" ? "right" : "left";
|
|
170
|
+
|
|
171
|
+
useGSAP(
|
|
172
|
+
() => {
|
|
173
|
+
const el = stage.current;
|
|
174
|
+
if (!el) return;
|
|
175
|
+
const one = <T extends HTMLElement = HTMLElement>(s: string) => el.querySelector<T>(s);
|
|
176
|
+
const all = (s: string) => Array.from(el.querySelectorAll<HTMLElement>(s));
|
|
177
|
+
|
|
178
|
+
// The ink of the line on the full-bleed photo: read from the photo where it may be read, else the
|
|
179
|
+
// treatment that holds over any photo.
|
|
180
|
+
const tone = () => {
|
|
181
|
+
const host = one("[data-bleed]")?.offsetParent ? one("[data-bleed]") : one("[data-still-bleed]");
|
|
182
|
+
const img = host?.querySelector("img");
|
|
183
|
+
const copy = host?.querySelector<HTMLElement>("[data-copy]");
|
|
184
|
+
const veil = host?.querySelector<HTMLElement>("[data-veil]");
|
|
185
|
+
const probe = (k: string) => {
|
|
186
|
+
const n = el.querySelector<HTMLElement>(`[data-ink='${k}']`);
|
|
187
|
+
return n ? luminanceOf(getComputedStyle(n).color) : 0;
|
|
188
|
+
};
|
|
189
|
+
if (!host || !img || !copy || !veil) return;
|
|
190
|
+
const light = probe("light");
|
|
191
|
+
const dark = probe("dark");
|
|
192
|
+
const hostBox = host.getBoundingClientRect();
|
|
193
|
+
const W = hostBox.width;
|
|
194
|
+
const H = hostBox.height;
|
|
195
|
+
// The heading (display size, 3:1, read against most of what lies under it) and the line (4.5:1, against
|
|
196
|
+
// nearly all of it), each on its current side and mirrored to the other side.
|
|
197
|
+
const parts = [
|
|
198
|
+
{ n: copy.querySelector<HTMLElement>("h3"), ratio: 3, tail: 0.1 },
|
|
199
|
+
{ n: copy.querySelector<HTMLElement>("p"), ratio: 4.5, tail: 0.03 },
|
|
200
|
+
].flatMap((p) => {
|
|
201
|
+
if (!p.n) return [];
|
|
202
|
+
const b = p.n.getBoundingClientRect();
|
|
203
|
+
const box = { x: b.left - hostBox.left - 12, y: b.top - hostBox.top - 12, w: b.width + 24, h: b.height + 24 };
|
|
204
|
+
return [{ ...p, here: box, there: { ...box, x: W - box.x - box.w } }];
|
|
205
|
+
});
|
|
206
|
+
const current = el.dataset.side === "right" ? "right" : "left";
|
|
207
|
+
// The weaker veil of the two inks over every part, or null when the photo cannot be read.
|
|
208
|
+
const treat = (which: "here" | "there") => {
|
|
209
|
+
let d = 0;
|
|
210
|
+
let l = 0;
|
|
211
|
+
for (const p of parts) {
|
|
212
|
+
const r = readUnder(img, W, H, p[which], p.tail);
|
|
213
|
+
if (!r) return null;
|
|
214
|
+
d = Math.max(d, veilFor(r.lo, dark, encode(light), p.ratio));
|
|
215
|
+
l = Math.max(l, veilFor(r.hi, light, encode(dark), p.ratio));
|
|
216
|
+
}
|
|
217
|
+
// A light veil strong enough for a mid-tone photo reads as fog where a dark one reads as shade, so dark ink
|
|
218
|
+
// only wins clearly: with no veil at all, or well under the dark veil the light ink would need.
|
|
219
|
+
const darkInk = d === 0 || d < l * 0.6;
|
|
220
|
+
return { dark: darkInk, veil: darkInk ? d : l };
|
|
221
|
+
};
|
|
222
|
+
// A photo that cannot be read: the treatment that holds over anything, for the small copy.
|
|
223
|
+
const safeD = veilFor(0, dark, encode(light), 4.5);
|
|
224
|
+
const safeL = veilFor(1, light, encode(dark), 4.5);
|
|
225
|
+
const a = treat("here") ?? { dark: safeD <= safeL, veil: Math.min(safeD, safeL) };
|
|
226
|
+
// The line stands on whichever side of the photo it reads on with the lighter touch (the photo's named calm
|
|
227
|
+
// side by default); a photo that cannot be read keeps its named side.
|
|
228
|
+
const b2 = treat("there");
|
|
229
|
+
const flip = b2 !== null && b2.veil + 0.02 < a.veil;
|
|
230
|
+
const t = flip && b2 ? b2 : a;
|
|
231
|
+
if (flip) el.dataset.side = current === "left" ? "right" : "left";
|
|
232
|
+
el.dataset.tone = t.dark ? "dark" : "light";
|
|
233
|
+
for (const v of all("[data-veil]")) v.style.opacity = t.veil.toFixed(3);
|
|
234
|
+
};
|
|
235
|
+
const imgs = Array.from(el.querySelectorAll("img"));
|
|
236
|
+
// Every photo decoded ahead of the section, the hidden full-screen plate included, so the grow never waits
|
|
237
|
+
// on a decode in its first frame.
|
|
238
|
+
const decode = (img: HTMLImageElement) => {
|
|
239
|
+
if (img.complete && img.naturalWidth) img.decode?.().catch(() => {});
|
|
240
|
+
};
|
|
241
|
+
for (const img of imgs) {
|
|
242
|
+
img.addEventListener("load", tone);
|
|
243
|
+
img.addEventListener("load", () => decode(img));
|
|
244
|
+
decode(img);
|
|
245
|
+
}
|
|
246
|
+
tone();
|
|
247
|
+
document.fonts?.ready.then(tone);
|
|
248
|
+
window.addEventListener("resize", tone);
|
|
249
|
+
|
|
250
|
+
const mm = gsap.matchMedia();
|
|
251
|
+
|
|
252
|
+
// Wide screens with motion: the pinned assembly.
|
|
253
|
+
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
254
|
+
const bleed = one("[data-bleed]");
|
|
255
|
+
const frame = one("[data-bleed-frame]");
|
|
256
|
+
const plate = one("[data-bleed-plate]");
|
|
257
|
+
const drift = one("[data-bleed-drift]");
|
|
258
|
+
const heroTile = one("[data-tile='h']");
|
|
259
|
+
const typeTile = one("[data-tile='t']");
|
|
260
|
+
const win = one("[data-tile='h'] [data-window]");
|
|
261
|
+
const veilOn = one("[data-veil-on]");
|
|
262
|
+
if (!bleed || !bleed.offsetParent || !frame || !plate || !drift || !heroTile || !typeTile || !win || !veilOn) return;
|
|
263
|
+
// If the script arrives after the CSS fallback has already dropped the tiles into the grid, the grid stays:
|
|
264
|
+
// the flight is skipped rather than thrown back into the pile.
|
|
265
|
+
const fellBack = getComputedStyle(heroTile).getPropertyValue("--p").trim() === "1";
|
|
266
|
+
el.dataset.motion = "1";
|
|
267
|
+
const tilesEls = all("[data-tile]");
|
|
268
|
+
const copyRise = all("[data-bleed] [data-rise]");
|
|
269
|
+
const stageBox = one("[data-stage]")!;
|
|
270
|
+
|
|
271
|
+
const g = { W: 1, H: 1, cell: { x: 0, y: 0, w: 1, h: 1 } };
|
|
272
|
+
const box = (n: HTMLElement) => {
|
|
273
|
+
let x = 0;
|
|
274
|
+
let y = 0;
|
|
275
|
+
let cur: HTMLElement | null = n;
|
|
276
|
+
while (cur && cur !== stageBox) {
|
|
277
|
+
x += cur.offsetLeft;
|
|
278
|
+
y += cur.offsetTop;
|
|
279
|
+
cur = cur.offsetParent as HTMLElement | null;
|
|
280
|
+
}
|
|
281
|
+
return { x, y, w: n.offsetWidth, h: n.offsetHeight };
|
|
282
|
+
};
|
|
283
|
+
const measure = () => {
|
|
284
|
+
tone();
|
|
285
|
+
g.W = stageBox.clientWidth;
|
|
286
|
+
g.H = stageBox.clientHeight;
|
|
287
|
+
g.cell = box(heroTile);
|
|
288
|
+
// Registration: the hero print is a window onto the full-screen photo, the same photo at the same size and
|
|
289
|
+
// place as the plate, so when the plate takes over at the lock it shows exactly the same pixels, and the
|
|
290
|
+
// grow is only a clip opening to the screen, never a change of scale.
|
|
291
|
+
win.style.left = `${-g.cell.x}px`;
|
|
292
|
+
win.style.top = `${-g.cell.y}px`;
|
|
293
|
+
win.style.width = `${g.W}px`;
|
|
294
|
+
win.style.height = `${g.H}px`;
|
|
295
|
+
win.style.right = "auto";
|
|
296
|
+
win.style.bottom = "auto";
|
|
297
|
+
// Each other print is pushed straight away from the hero cell, by translation only, just far enough to
|
|
298
|
+
// clear the nearest edge of the screen it is heading for: it is gone by OFF of the grow, then no longer
|
|
299
|
+
// drawn, so the prints never weigh on the frames where the photo opens widest.
|
|
300
|
+
const hx = g.cell.x + g.cell.w / 2;
|
|
301
|
+
const hy = g.cell.y + g.cell.h / 2;
|
|
302
|
+
for (const t of tilesEls) {
|
|
303
|
+
if (t === heroTile) continue;
|
|
304
|
+
const b = box(t);
|
|
305
|
+
const dx = b.x + b.w / 2 - hx;
|
|
306
|
+
const dy = b.y + b.h / 2 - hy;
|
|
307
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
308
|
+
const ux = dx / len;
|
|
309
|
+
const uy = dy / len;
|
|
310
|
+
const margin = 24;
|
|
311
|
+
const needX = ux > 0.01 ? (g.W - b.x + margin) / ux : ux < -0.01 ? (b.x + b.w + margin) / -ux : Infinity;
|
|
312
|
+
const needY = uy > 0.01 ? (g.H - b.y + margin) / uy : uy < -0.01 ? (b.y + b.h + margin) / -uy : Infinity;
|
|
313
|
+
const reach = Math.min(needX, needY) / OFF;
|
|
314
|
+
push.set(t, { x: ux * reach, y: uy * reach });
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
const push = new Map<HTMLElement, { x: number; y: number }>();
|
|
318
|
+
|
|
319
|
+
// The values the scroll drives: the lock (each print's own share of it), the grow, the copy.
|
|
320
|
+
const s = { lock: fellBack ? 1 : 0, grow: 0 };
|
|
321
|
+
const order = PLACE_ORDER.filter((k) => tilesEls.some((t) => t.dataset.tile === k));
|
|
322
|
+
const paint = () => {
|
|
323
|
+
const span = order.length > 1 ? (1 - FLY) / (order.length - 1) : 0;
|
|
324
|
+
for (const t of tilesEls) {
|
|
325
|
+
const key = t.dataset.tile ?? "";
|
|
326
|
+
const at = key === "t" ? order.length - 1 : Math.max(0, order.indexOf(key));
|
|
327
|
+
const p = settle(clamp((s.lock - at * span) / FLY, 0, 1));
|
|
328
|
+
t.style.setProperty("--p", p.toFixed(4));
|
|
329
|
+
// The push out of the way follows the grow's own ease, so every print is off screen at OFF. It is written
|
|
330
|
+
// in whole pixels: a layer whose only transform is a fractional translation is re-rastered by the browser
|
|
331
|
+
// each time the fraction changes, which stalled the grow while eight prints slid at once.
|
|
332
|
+
const q = key === "h" ? 0 : s.grow;
|
|
333
|
+
const m = push.get(t);
|
|
334
|
+
if (q > 0 && m) {
|
|
335
|
+
t.style.transform = `translate3d(${Math.round(q * m.x)}px, ${Math.round(q * m.y)}px, 0)`;
|
|
336
|
+
} else {
|
|
337
|
+
t.style.transform = "";
|
|
338
|
+
}
|
|
339
|
+
if (key !== "h") t.style.visibility = q >= OFF ? "hidden" : "";
|
|
340
|
+
}
|
|
341
|
+
// The hero's frame: a clip from its cell to the screen over the photo at full size. The plate takes over from the hero tile as soon as the grid is locked, clipped exactly to the cell, so
|
|
342
|
+
// the browser has painted it through the whole hold before the grow begins.
|
|
343
|
+
const e = s.grow;
|
|
344
|
+
const on = s.lock >= 0.999 || e > 0.0005;
|
|
345
|
+
bleed.style.visibility = on ? "visible" : "hidden";
|
|
346
|
+
heroTile.style.visibility = on ? "hidden" : "";
|
|
347
|
+
const L = lerp(g.cell.x, 0, e);
|
|
348
|
+
const T = lerp(g.cell.y, 0, e);
|
|
349
|
+
const R = lerp(g.W - (g.cell.x + g.cell.w), 0, e);
|
|
350
|
+
const B = lerp(g.H - (g.cell.y + g.cell.h), 0, e);
|
|
351
|
+
// The clip sits on the plate's own layer (and the veil's), never on a parent: clipped from outside, the
|
|
352
|
+
// browser rasters only the part of the photo inside the clip and stalls on the rest as the clip opens.
|
|
353
|
+
const cp = `inset(${T.toFixed(2)}px ${R.toFixed(2)}px ${B.toFixed(2)}px ${L.toFixed(2)}px)`;
|
|
354
|
+
plate.style.clipPath = cp;
|
|
355
|
+
veilOn.style.clipPath = cp;
|
|
356
|
+
// The veil the line needs comes in with the grow, so the take-over at the lock shows the bare photo.
|
|
357
|
+
veilOn.style.opacity = e.toFixed(4);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
measure();
|
|
361
|
+
paint();
|
|
362
|
+
gsap.set(copyRise, { yPercent: 118 });
|
|
363
|
+
|
|
364
|
+
const tl = gsap.timeline({
|
|
365
|
+
defaults: { ease: "none" },
|
|
366
|
+
onUpdate: paint,
|
|
367
|
+
scrollTrigger: {
|
|
368
|
+
trigger: el,
|
|
369
|
+
start: "top top",
|
|
370
|
+
end: "+=250%",
|
|
371
|
+
pin: true,
|
|
372
|
+
scrub: 1,
|
|
373
|
+
onRefresh: () => {
|
|
374
|
+
measure();
|
|
375
|
+
paint();
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
// Hold on the pile; the lock; hold on the grid; the grow; hold on the photo with its line.
|
|
380
|
+
// Units: the moves (lock 1.3, grow 1.8) take 3.1, the holds 4.4. The grow is the longest move and eases
|
|
381
|
+
// on power2.inOut, so the frame opens over about a quarter of the pin rather than snapping through its middle.
|
|
382
|
+
tl.to({}, { duration: 0.7 })
|
|
383
|
+
.to(s, { lock: 1, duration: 1.3, ease: "none" }, 0.7)
|
|
384
|
+
.to({}, { duration: 1.3 }, 2.0)
|
|
385
|
+
.to(s, { grow: 1, duration: 1.8, ease: "power2.inOut" }, 3.3)
|
|
386
|
+
// The line rises once the frame is fully open.
|
|
387
|
+
.to(copyRise, { yPercent: 0, duration: 0.9, stagger: 0.09, ease: "expo.out" }, 5.1)
|
|
388
|
+
// The last hold: the copy is whole from about 6.1, held 1.4 more; 2.4 after the grow lands.
|
|
389
|
+
.to({}, { duration: 2.4 }, 5.1);
|
|
390
|
+
|
|
391
|
+
// The release: the photo drifts slower than the page as the stage leaves.
|
|
392
|
+
const pin = tl.scrollTrigger!;
|
|
393
|
+
gsap.fromTo(
|
|
394
|
+
drift,
|
|
395
|
+
{ yPercent: 0 },
|
|
396
|
+
{
|
|
397
|
+
yPercent: 22,
|
|
398
|
+
ease: "none",
|
|
399
|
+
immediateRender: false,
|
|
400
|
+
scrollTrigger: { start: () => pin.end, end: () => pin.end + el.offsetHeight, scrub: true },
|
|
401
|
+
},
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
return () => {
|
|
405
|
+
delete el.dataset.motion;
|
|
406
|
+
for (const t of tilesEls) {
|
|
407
|
+
t.style.removeProperty("--p");
|
|
408
|
+
t.style.transform = "";
|
|
409
|
+
t.style.visibility = "";
|
|
410
|
+
}
|
|
411
|
+
heroTile.style.visibility = "";
|
|
412
|
+
bleed.style.visibility = "";
|
|
413
|
+
plate.style.clipPath = "";
|
|
414
|
+
veilOn.style.clipPath = "";
|
|
415
|
+
veilOn.style.opacity = "";
|
|
416
|
+
for (const p of ["left", "top", "width", "height", "right", "bottom"] as const) win.style[p] = "";
|
|
417
|
+
};
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Phones with motion: the grid at rest, each tile opening from its foot as the grid arrives.
|
|
421
|
+
mm.add("(max-width: 767px) and (prefers-reduced-motion: no-preference)", () => {
|
|
422
|
+
const tilesEls = all("[data-tile]");
|
|
423
|
+
const open = tilesEls.map(() => ({ v: 0 }));
|
|
424
|
+
const write = () =>
|
|
425
|
+
tilesEls.forEach((t, i) => {
|
|
426
|
+
t.style.clipPath = `inset(${(100 * (1 - open[i].v)).toFixed(2)}% 0% 0% 0%)`;
|
|
427
|
+
});
|
|
428
|
+
write();
|
|
429
|
+
el.dataset.shown = "1";
|
|
430
|
+
const tw = gsap.to(open, {
|
|
431
|
+
v: 1,
|
|
432
|
+
duration: 0.9,
|
|
433
|
+
stagger: 0.07,
|
|
434
|
+
ease: "expo.out",
|
|
435
|
+
paused: true,
|
|
436
|
+
onUpdate: write,
|
|
437
|
+
});
|
|
438
|
+
const st = gsap.timeline({ scrollTrigger: { trigger: el, start: "top 80%", once: true, onEnter: () => tw.play() } });
|
|
439
|
+
return () => {
|
|
440
|
+
st.scrollTrigger?.kill();
|
|
441
|
+
tw.kill();
|
|
442
|
+
delete el.dataset.shown;
|
|
443
|
+
for (const t of tilesEls) t.style.clipPath = "";
|
|
444
|
+
};
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
return () => {
|
|
448
|
+
window.removeEventListener("resize", tone);
|
|
449
|
+
for (const img of imgs) img.removeEventListener("load", tone);
|
|
450
|
+
mm.revert();
|
|
451
|
+
};
|
|
452
|
+
},
|
|
453
|
+
{ scope: stage, dependencies: [hero.src] },
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const grade = "object-cover [filter:saturate(0.9)_contrast(1.05)]";
|
|
457
|
+
const place = (cell: Cell) =>
|
|
458
|
+
({
|
|
459
|
+
"--c": `${cell.c} / span ${cell.cs}`,
|
|
460
|
+
"--r": `${cell.r} / span ${cell.rs}`,
|
|
461
|
+
}) as React.CSSProperties;
|
|
462
|
+
// Desktop: the board's cell. Phones: two columns, tall prints over two rows.
|
|
463
|
+
const tileClass = (tall: boolean, wide: boolean) =>
|
|
464
|
+
`relative overflow-hidden bg-surface-2 will-change-transform md:[grid-column:var(--c)] md:[grid-row:var(--r)] ${
|
|
465
|
+
wide ? "col-span-2" : ""
|
|
466
|
+
} ${tall ? "row-span-2" : ""}`;
|
|
467
|
+
const ink = "text-on-photo group-data-[tone=dark]/ga:text-[var(--ga-dark)]";
|
|
468
|
+
const display = "font-display leading-[0.92] tracking-[-0.035em]";
|
|
469
|
+
|
|
470
|
+
const copy = (rise: boolean) => (
|
|
471
|
+
<div
|
|
472
|
+
data-copy=""
|
|
473
|
+
className={`max-w-[46rem] ${ink} group-data-[side=right]/ga:ml-auto group-data-[side=right]/ga:text-right`}
|
|
474
|
+
>
|
|
475
|
+
<h3 className={`m-0 text-[clamp(2.75rem,1rem_+_5.4vw,8rem)] ${display}`}>
|
|
476
|
+
{heading.map((h) => (
|
|
477
|
+
<span key={h} className="block overflow-hidden pb-[0.1em] -mb-[0.1em]">
|
|
478
|
+
<span data-rise={rise ? "" : undefined} className="block">
|
|
479
|
+
{h}
|
|
480
|
+
</span>
|
|
481
|
+
</span>
|
|
482
|
+
))}
|
|
483
|
+
</h3>
|
|
484
|
+
{line ? (
|
|
485
|
+
<p className="m-0 mt-6 overflow-hidden text-lead">
|
|
486
|
+
<span data-rise={rise ? "" : undefined} className="block max-w-[34ch] group-data-[side=right]/ga:ml-auto">
|
|
487
|
+
{line}
|
|
488
|
+
</span>
|
|
489
|
+
</p>
|
|
490
|
+
) : null}
|
|
491
|
+
</div>
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
return (
|
|
495
|
+
<section
|
|
496
|
+
ref={stage}
|
|
497
|
+
data-grid-assembly=""
|
|
498
|
+
data-tone="dark"
|
|
499
|
+
data-side={side}
|
|
500
|
+
aria-label={title}
|
|
501
|
+
className={`group/ga relative bg-surface-0 ${className}`}
|
|
502
|
+
style={{ "--ga-dark": "oklch(from var(--surface-0) min(calc(l * 0.42), 0.19) c h)" } as React.CSSProperties}
|
|
503
|
+
>
|
|
504
|
+
{/* The inks as this palette resolves them, read once to measure contrast. */}
|
|
505
|
+
<span aria-hidden className="pointer-events-none absolute size-0 overflow-hidden">
|
|
506
|
+
<span data-ink="light" className="text-on-photo" />
|
|
507
|
+
<span data-ink="dark" className="text-[var(--ga-dark)]" />
|
|
508
|
+
</span>
|
|
509
|
+
|
|
510
|
+
<div data-stage="" className="relative px-4 py-12 md:h-svh md:overflow-hidden md:p-0">
|
|
511
|
+
{/* The board: twelve columns and six rows with exact gaps on wide screens, two columns on phones. */}
|
|
512
|
+
<div className="grid auto-rows-[34vw] grid-cols-2 gap-2 md:absolute md:inset-x-[4vw] md:inset-y-[7svh] md:auto-rows-auto md:grid-cols-12 md:grid-rows-6 md:gap-[0.9vw]">
|
|
513
|
+
{/* The type cell: the section's line, standing up as the grid locks. */}
|
|
514
|
+
<div
|
|
515
|
+
data-tile="t"
|
|
516
|
+
className={`${tileClass(false, true)} flex flex-col justify-between bg-transparent py-1 md:py-0`}
|
|
517
|
+
style={{ ...place(CELLS.t), ...({ "--sx": "0px", "--sy": "0px", "--sr": "0deg", "--sd": "0" } as React.CSSProperties) }}
|
|
518
|
+
>
|
|
519
|
+
<h2 className={`m-0 text-[clamp(2.25rem,1rem_+_2.9vw,5rem)] text-ink ${display}`}>
|
|
520
|
+
<span className="block overflow-hidden pb-[0.1em] -mb-[0.1em]">
|
|
521
|
+
<span data-ga-line="" className="block">
|
|
522
|
+
{title}
|
|
523
|
+
</span>
|
|
524
|
+
</span>
|
|
525
|
+
</h2>
|
|
526
|
+
{text ? (
|
|
527
|
+
<p className="m-0 overflow-hidden text-small text-ink-muted">
|
|
528
|
+
<span data-ga-line="" className="block max-w-[32ch]">
|
|
529
|
+
{text}
|
|
530
|
+
</span>
|
|
531
|
+
</p>
|
|
532
|
+
) : null}
|
|
533
|
+
</div>
|
|
534
|
+
{photos.map(({ key, image }) => {
|
|
535
|
+
const cell = CELLS[key];
|
|
536
|
+
return (
|
|
537
|
+
<div
|
|
538
|
+
key={key}
|
|
539
|
+
data-tile={key}
|
|
540
|
+
className={tileClass(cell.rs >= 3, cell.cs >= 5)}
|
|
541
|
+
style={{ ...place(cell), ...scatterVars(key) }}
|
|
542
|
+
>
|
|
543
|
+
<Image
|
|
544
|
+
src={image.src}
|
|
545
|
+
alt={image.alt}
|
|
546
|
+
fill
|
|
547
|
+
loading="eager"
|
|
548
|
+
sizes={`(min-width: 768px) ${Math.round((cell.cs / 12) * 92)}vw, ${cell.cs >= 5 ? 92 : 46}vw`}
|
|
549
|
+
unoptimized={image.unoptimized}
|
|
550
|
+
className={grade}
|
|
551
|
+
/>
|
|
552
|
+
</div>
|
|
553
|
+
);
|
|
554
|
+
})}
|
|
555
|
+
<div
|
|
556
|
+
data-tile="h"
|
|
557
|
+
className={tileClass(true, true)}
|
|
558
|
+
style={{ ...place(CELLS.h), ...scatterVars("h") }}
|
|
559
|
+
>
|
|
560
|
+
{/* A window onto the full-screen photo once the assembly runs (the script positions it); the cell's own
|
|
561
|
+
crop otherwise. */}
|
|
562
|
+
<div data-window="" className="absolute inset-0">
|
|
563
|
+
<Image
|
|
564
|
+
src={hero.src}
|
|
565
|
+
alt={hero.alt}
|
|
566
|
+
fill
|
|
567
|
+
loading="eager"
|
|
568
|
+
sizes="(min-width: 768px) 100vw, 92vw"
|
|
569
|
+
unoptimized={hero.unoptimized}
|
|
570
|
+
className={grade}
|
|
571
|
+
/>
|
|
572
|
+
</div>
|
|
573
|
+
</div>
|
|
574
|
+
</div>
|
|
575
|
+
|
|
576
|
+
{/* The grow: a full-screen plate of the hero photo, clipped to the hero's cell until it opens. */}
|
|
577
|
+
<div
|
|
578
|
+
data-bleed=""
|
|
579
|
+
className="pointer-events-none invisible absolute inset-0 z-10 hidden md:motion-safe:[:is(.js,.sq-js)_&]:block"
|
|
580
|
+
>
|
|
581
|
+
<div data-bleed-frame="" className="absolute inset-0 overflow-hidden">
|
|
582
|
+
<div data-bleed-drift="" className="absolute inset-0">
|
|
583
|
+
{/* Its own layer at scale 1 for the whole sequence: rastered whole while the grid holds, so the clip
|
|
584
|
+
can open without the browser painting a full-screen photo on the first frame of the grow. */}
|
|
585
|
+
<div data-bleed-plate="" className="absolute inset-0 will-change-transform [transform:translateZ(0)]">
|
|
586
|
+
<Image
|
|
587
|
+
src={hero.src}
|
|
588
|
+
alt=""
|
|
589
|
+
fill
|
|
590
|
+
loading="eager"
|
|
591
|
+
sizes="100vw"
|
|
592
|
+
unoptimized={hero.unoptimized}
|
|
593
|
+
className={grade}
|
|
594
|
+
/>
|
|
595
|
+
</div>
|
|
596
|
+
</div>
|
|
597
|
+
<div data-veil-on="" aria-hidden className="absolute inset-0">
|
|
598
|
+
<div data-veil="" className="absolute inset-0 bg-[var(--ga-dark)] group-data-[tone=dark]/ga:bg-on-photo" style={{ opacity: SAFE_VEIL }} />
|
|
599
|
+
</div>
|
|
600
|
+
<div className="absolute inset-x-[5vw] bottom-[9svh]">{copy(true)}</div>
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
</div>
|
|
604
|
+
|
|
605
|
+
{/* The still hand-over (phones, reduced motion, pages without scripts): the photo full width with its line. */}
|
|
606
|
+
<figure
|
|
607
|
+
data-still-bleed=""
|
|
608
|
+
className="relative m-0 h-[82svh] overflow-hidden md:h-svh md:motion-safe:[:is(.js,.sq-js)_&]:hidden"
|
|
609
|
+
>
|
|
610
|
+
<Image src={hero.src} alt="" fill loading="eager" sizes="100vw" unoptimized={hero.unoptimized} className={grade} />
|
|
611
|
+
<div data-veil="" aria-hidden className="absolute inset-0 bg-[var(--ga-dark)] group-data-[tone=dark]/ga:bg-on-photo" style={{ opacity: SAFE_VEIL }} />
|
|
612
|
+
<div className="absolute inset-x-6 bottom-[8svh] md:inset-x-[5vw]">{copy(false)}</div>
|
|
613
|
+
</figure>
|
|
614
|
+
</section>
|
|
615
|
+
);
|
|
616
|
+
}
|