@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,670 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// split-pair (Struq section recipe)
|
|
3
|
+
// Two tall photos in tension, meeting at a hard seam, with one oversized word set across both so
|
|
4
|
+
// the seam cuts through its letters. The stage pins on the ground: the word whole in the accent, a
|
|
5
|
+
// hard line standing through it where the seam will be. Then the first photo rises in from below on
|
|
6
|
+
// the left. Its half of the word rides with it, a step behind the ground's copy, so the letters
|
|
7
|
+
// break along the rising edge and turn from the accent to the photo's own ink; as the photo lands
|
|
8
|
+
// they lock into register. The stage holds on that half-and-half frame: one half of the word on the
|
|
9
|
+
// photo, the other still in the accent on the ground, the seam the line where they part. The second
|
|
10
|
+
// photo comes down from above on the right, against the first, its half breaking and locking the
|
|
11
|
+
// same way, and the pair holds whole. While it holds the two photos run against each other inside
|
|
12
|
+
// their frames, the left up and the right down at half its speed, and each half of the word rides
|
|
13
|
+
// with its photo, so the letters shear apart along the seam the longer the reader stays. When the
|
|
14
|
+
// pin lets go the halves leave at their own speeds, the left ahead of the page and the right behind
|
|
15
|
+
// it, and the shear runs on as they part.
|
|
16
|
+
//
|
|
17
|
+
// The word, the line and the captions exist once on the ground and once inside each photo's frame.
|
|
18
|
+
// Each frame's clip cuts its copy exactly, so the colour changes on the frame's edge, letter by
|
|
19
|
+
// letter, with no blend mode guessing at the photo. The block reads the photos once they load: it
|
|
20
|
+
// stands the word at the height where both halves read without any veil (on the photos' calm side
|
|
21
|
+
// when they name one, and preferring a height where the two halves take opposite inks), gives each
|
|
22
|
+
// half the ink that reads best there (a dark derived from the ground, or the palette's light
|
|
23
|
+
// on-photo ink), and sets the line under the word on whichever photo it reads on. A photo is never
|
|
24
|
+
// veiled when a placement reads without it. Only when no height reads, or a photo cannot be read at
|
|
25
|
+
// all (one on another origin taints the canvas), does a photo take a veil, computed in sRGB just as
|
|
26
|
+
// strong as 3:1 for the word and 4.5:1 for the small copy need; both photos then share it when they
|
|
27
|
+
// share an ink, so the pair keeps one grade.
|
|
28
|
+
//
|
|
29
|
+
// Every clip and every move is written from plain numbers on every frame, never tweened as a
|
|
30
|
+
// string. The pin is 300%: three key frames, each held, the moves between them short.
|
|
31
|
+
// Lead for an architect, a photographer, a fashion or interior studio: any brief with two pictures
|
|
32
|
+
// that answer each other (inside and outside, shade and light, before and after).
|
|
33
|
+
//
|
|
34
|
+
// <SplitPair images={[images.column[3], images.column[2]]} word="Tegenlicht"
|
|
35
|
+
// line="Elk huis begint bij de plek waar de zon valt." captions={["Trap, Huis Ostuni", "Dakterras, Huis Ostuni"]} />
|
|
36
|
+
//
|
|
37
|
+
// Phones and reduced motion: the composed still. The two photos side by side as tall slices, one
|
|
38
|
+
// set lower than the other, the word across both and the seam between them; on phones the line
|
|
39
|
+
// follows on the ground below the pair.
|
|
40
|
+
import Image from "next/image";
|
|
41
|
+
import { useRef } from "react";
|
|
42
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
43
|
+
import type { StruqImage } from "../kit/images";
|
|
44
|
+
import { photoLuminances } from "../kit/photo-read";
|
|
45
|
+
|
|
46
|
+
type Place = "top" | "center" | "bottom";
|
|
47
|
+
type Side = "l" | "r";
|
|
48
|
+
|
|
49
|
+
type Props = {
|
|
50
|
+
/** The pair: the first rises in on the left, the second comes down on the right. */
|
|
51
|
+
images: [StruqImage, StruqImage];
|
|
52
|
+
/** One word (or a short phrase on one line), set across both photos. */
|
|
53
|
+
word: string;
|
|
54
|
+
/** One short line of copy, set under the word on whichever photo it reads on (the first by default). */
|
|
55
|
+
line?: string;
|
|
56
|
+
/** Metadata for each photo: place, client, year. */
|
|
57
|
+
captions?: [string, string];
|
|
58
|
+
/** Where the word stands. Left out, the block reads the photos and finds the height, near their calm side. */
|
|
59
|
+
place?: Place;
|
|
60
|
+
className?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The word's centre line, as a share of the stage's height, for each named side. */
|
|
64
|
+
const PLACE_Y: Record<Place, number> = { top: 0.32, center: 0.47, bottom: 0.6 };
|
|
65
|
+
/** How far each photo travels with its frame as it arrives, as a share of the stage (the frame travels 1). */
|
|
66
|
+
const TRAVEL = 0.4;
|
|
67
|
+
/** How far each photo runs inside its frame while the pair holds, as a share of the stage: the first up, the
|
|
68
|
+
* second down at half its speed. */
|
|
69
|
+
const RUN_L = -0.22;
|
|
70
|
+
const RUN_R = 0.11;
|
|
71
|
+
/** How much of its photo's travel and run each half of the word rides along with. */
|
|
72
|
+
const RIDE = 0.35;
|
|
73
|
+
/** The push-in each photo settles out of as it arrives. */
|
|
74
|
+
const PUSH = 1.22;
|
|
75
|
+
/** A photo that names a calm top or bottom is framed toward it: zoomed this much about that edge, so its calm band
|
|
76
|
+
* grows to hold the word. */
|
|
77
|
+
const CALM_ZOOM = 1.22;
|
|
78
|
+
/** How far each half leaves ahead of (left) or behind (right) the page when the pin lets go, per stage height. */
|
|
79
|
+
const LEAVE_L = -0.42;
|
|
80
|
+
const LEAVE_R = 0.22;
|
|
81
|
+
/** Contrast the word (display size) and the small copy must hold. */
|
|
82
|
+
const WORD_RATIO = 3;
|
|
83
|
+
const SMALL_RATIO = 4.5;
|
|
84
|
+
|
|
85
|
+
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
|
|
86
|
+
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
87
|
+
const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
88
|
+
const encode = (l: number) => (l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055);
|
|
89
|
+
const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
90
|
+
|
|
91
|
+
type Box = { x: number; y: number; w: number; h: number };
|
|
92
|
+
type Read = { lo: number; hi: number };
|
|
93
|
+
type Inks = { dark: number; light: number; darkS: number; lightS: number };
|
|
94
|
+
|
|
95
|
+
/** Layout position relative to `top`, untouched by transforms: offsets walk up the positioned parents. */
|
|
96
|
+
function boxOf(n: HTMLElement, top: HTMLElement): Box {
|
|
97
|
+
let x = 0;
|
|
98
|
+
let y = 0;
|
|
99
|
+
let cur: HTMLElement | null = n;
|
|
100
|
+
while (cur && cur !== top) {
|
|
101
|
+
x += cur.offsetLeft;
|
|
102
|
+
y += cur.offsetTop;
|
|
103
|
+
cur = cur.offsetParent as HTMLElement | null;
|
|
104
|
+
}
|
|
105
|
+
return { x, y, w: n.offsetWidth, h: n.offsetHeight };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Two boxes' overlap, or null. */
|
|
109
|
+
function overlap(a: Box, b: Box): Box | null {
|
|
110
|
+
const x = Math.max(a.x, b.x);
|
|
111
|
+
const y = Math.max(a.y, b.y);
|
|
112
|
+
const r = Math.min(a.x + a.w, b.x + b.w);
|
|
113
|
+
const btm = Math.min(a.y + a.h, b.y + b.h);
|
|
114
|
+
return r > x && btm > y ? { x, y, w: r - x, h: btm - y } : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The relative luminance of any CSS colour, through a one-pixel canvas so every colour syntax resolves. */
|
|
118
|
+
function luminanceOf(color: string): number {
|
|
119
|
+
const cv = document.createElement("canvas");
|
|
120
|
+
cv.width = 1;
|
|
121
|
+
cv.height = 1;
|
|
122
|
+
const ctx = cv.getContext("2d", { willReadFrequently: true });
|
|
123
|
+
if (!ctx) return 0;
|
|
124
|
+
ctx.fillStyle = "#000";
|
|
125
|
+
ctx.fillStyle = color;
|
|
126
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
127
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
128
|
+
return 0.2126 * linear(r / 255) + 0.7152 * linear(g / 255) + 0.0722 * linear(b / 255);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The luminance of a photo under a box, read from a small canvas: its darker and brighter ends (`tail` from
|
|
133
|
+
* each end). `frame` is the image element's own layout box (object-fit: cover inside it). Null when the
|
|
134
|
+
* photo cannot be read.
|
|
135
|
+
*/
|
|
136
|
+
function sample(img: HTMLImageElement, frame: Box, at: Box, tail: number): Read | null {
|
|
137
|
+
const ys = photoLuminances(img, frame, at);
|
|
138
|
+
if (!ys) return null;
|
|
139
|
+
return { lo: ys[Math.floor(ys.length * tail)], hi: ys[Math.min(ys.length - 1, Math.floor(ys.length * (1 - tail)))] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The weakest veil that brings `under` (a photo luminance) to `ratio` against an ink, with the veil composited the
|
|
144
|
+
* way the browser does it: in sRGB, channel by channel, not in linear light. `veilS` is the veil's colour as an
|
|
145
|
+
* sRGB grey. Dark ink takes a light veil over the photo's darkest parts; light ink a dark veil over its brightest.
|
|
146
|
+
*/
|
|
147
|
+
function veilFor(under: number, ink: number, veilS: number, ratio: number): number {
|
|
148
|
+
if (contrast(under, ink) >= ratio) return 0;
|
|
149
|
+
const s = encode(under);
|
|
150
|
+
const ok = (a: number) => contrast(linear(s * (1 - a) + veilS * a), ink) >= ratio;
|
|
151
|
+
if (!ok(0.85)) return 0.85;
|
|
152
|
+
let lo = 0;
|
|
153
|
+
let hi = 0.85;
|
|
154
|
+
for (let i = 0; i < 14; i += 1) {
|
|
155
|
+
const mid = (lo + hi) / 2;
|
|
156
|
+
if (ok(mid)) hi = mid;
|
|
157
|
+
else lo = mid;
|
|
158
|
+
}
|
|
159
|
+
return hi;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Film grain, the same tile over both photos so the pair reads as one grade.
|
|
163
|
+
const GRAIN =
|
|
164
|
+
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")";
|
|
165
|
+
|
|
166
|
+
export function SplitPair({ images, word, line, captions, place, className = "" }: Props) {
|
|
167
|
+
const stage = useRef<HTMLElement>(null);
|
|
168
|
+
const [first, second] = images;
|
|
169
|
+
// The named calm side: the side both photos share, or the one either names (top or bottom).
|
|
170
|
+
const rows = [first.negativeSpace, second.negativeSpace].filter((n): n is Place => n === "top" || n === "bottom");
|
|
171
|
+
const named: Place = place ?? (rows.length === 2 && rows[0] !== rows[1] ? "center" : rows[0] ?? "center");
|
|
172
|
+
// The captions go to the edge away from the word's named side.
|
|
173
|
+
const capsLow = named === "top";
|
|
174
|
+
|
|
175
|
+
useGSAP(
|
|
176
|
+
() => {
|
|
177
|
+
const el = stage.current;
|
|
178
|
+
if (!el) return;
|
|
179
|
+
const one = <T extends HTMLElement = HTMLElement>(s: string) => el.querySelector<T>(s);
|
|
180
|
+
const all = (s: string) => Array.from(el.querySelectorAll<HTMLElement>(s));
|
|
181
|
+
const groundWord = one("[data-layer='ground'] [data-word]");
|
|
182
|
+
const seam = one("[data-seam]");
|
|
183
|
+
const colL = one("[data-col='l']");
|
|
184
|
+
const colR = one("[data-col='r']");
|
|
185
|
+
if (!groundWord || !seam || !colL || !colR) return;
|
|
186
|
+
const cols: Record<Side, HTMLElement> = { l: colL, r: colR };
|
|
187
|
+
|
|
188
|
+
// Fit: the word spans the frame whatever the face, never wider than nine tenths of it; a short word may
|
|
189
|
+
// grow by a quarter. Measured at the natural size, then scaled; the height cap lives in the CSS.
|
|
190
|
+
const fit = () => {
|
|
191
|
+
el.style.setProperty("--sp-fit", "1");
|
|
192
|
+
const need = groundWord.offsetWidth;
|
|
193
|
+
if (need <= 0) return;
|
|
194
|
+
const k = Math.min(1.25, (el.clientWidth * 0.9) / need);
|
|
195
|
+
el.style.setProperty("--sp-fit", String(Math.floor(k * 1000) / 1000));
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// The inks as they resolve in this palette, and whether the accent stands on the ground at 3:1.
|
|
199
|
+
// Read from two fixed probes, never by toggling a tone (a colour caught mid-change reads wrong).
|
|
200
|
+
const inks = (): Inks => {
|
|
201
|
+
const probe = (k: string) => {
|
|
202
|
+
const n = el.querySelector<HTMLElement>(`[data-ink='${k}']`);
|
|
203
|
+
return n ? getComputedStyle(n).color : "#000";
|
|
204
|
+
};
|
|
205
|
+
delete el.dataset.ground;
|
|
206
|
+
const ground = luminanceOf(getComputedStyle(el).backgroundColor);
|
|
207
|
+
if (contrast(luminanceOf(probe("accent")), ground) < WORD_RATIO) el.dataset.ground = "ink";
|
|
208
|
+
const dark = luminanceOf(probe("dark"));
|
|
209
|
+
const light = luminanceOf(probe("light"));
|
|
210
|
+
// A veil takes the opposite ink's colour: the on-photo light under dark ink, the derived dark under light.
|
|
211
|
+
return { dark, light, darkS: encode(dark), lightS: encode(light) };
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const apply = (tone: Record<Side, { dark: boolean; veil: number }>) => {
|
|
215
|
+
// One grade: photos that share an ink share the veil, the stronger of the two.
|
|
216
|
+
if (tone.l.dark === tone.r.dark) tone.l.veil = tone.r.veil = Math.max(tone.l.veil, tone.r.veil);
|
|
217
|
+
for (const side of ["l", "r"] as const) {
|
|
218
|
+
const col = cols[side];
|
|
219
|
+
col.dataset.tone = tone[side].dark ? "dark" : "light";
|
|
220
|
+
const v = col.querySelector<HTMLElement>("[data-veil]");
|
|
221
|
+
if (v) v.style.opacity = tone[side].veil.toFixed(3);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Read the photos, then stand the word, choose each half's ink and the line's side.
|
|
226
|
+
const settle = () => {
|
|
227
|
+
fit();
|
|
228
|
+
const ink = inks();
|
|
229
|
+
const W = el.clientWidth;
|
|
230
|
+
const H = el.querySelector<HTMLElement>("[data-stage]")?.clientHeight ?? el.clientHeight;
|
|
231
|
+
const S = seam.offsetWidth;
|
|
232
|
+
const wide = window.matchMedia("(min-width: 768px) and (prefers-reduced-motion: no-preference)").matches;
|
|
233
|
+
const halves: Record<Side, Box> = {
|
|
234
|
+
l: { x: 0, y: 0, w: (W - S) / 2, h: H },
|
|
235
|
+
r: { x: (W + S) / 2, y: 0, w: (W - S) / 2, h: H },
|
|
236
|
+
};
|
|
237
|
+
// How far each photo moves under its copy while the pair holds (the copy rides along with part of it).
|
|
238
|
+
const slip: Record<Side, number> = wide
|
|
239
|
+
? { l: Math.abs(RUN_L) * (1 - RIDE) * H, r: Math.abs(RUN_R) * (1 - RIDE) * H }
|
|
240
|
+
: { l: 0, r: 0 };
|
|
241
|
+
const pad = 12;
|
|
242
|
+
const span = (b: Box, side: Side, dy: number): Box => ({
|
|
243
|
+
x: b.x - pad,
|
|
244
|
+
y: b.y + dy - pad - (side === "r" ? slip.r : 0),
|
|
245
|
+
w: b.w + pad * 2,
|
|
246
|
+
h: b.h + pad * 2 + (side === "l" ? slip.l : slip.r),
|
|
247
|
+
});
|
|
248
|
+
const photo = (side: Side) => {
|
|
249
|
+
const img = cols[side].querySelector<HTMLImageElement>("[data-photo] img");
|
|
250
|
+
const frame = cols[side].querySelector<HTMLElement>("[data-photo]");
|
|
251
|
+
if (!img || !frame || !img.complete) return null;
|
|
252
|
+
// The layout box, then the calm-side zoom the photo carries (scaled about its calm edge).
|
|
253
|
+
const f = boxOf(frame, el);
|
|
254
|
+
const edge = frame.dataset.calm === "top" ? 0 : frame.dataset.calm === "bottom" ? 1 : null;
|
|
255
|
+
if (edge === null) return { img, f };
|
|
256
|
+
const k = CALM_ZOOM;
|
|
257
|
+
return { img, f: { x: f.x - ((k - 1) * f.w) / 2, y: f.y - (k - 1) * f.h * edge, w: f.w * k, h: f.h * k } };
|
|
258
|
+
};
|
|
259
|
+
const read = (side: Side, b: Box | null, dy: number, tail: number) => {
|
|
260
|
+
const p = photo(side);
|
|
261
|
+
if (!p || !b) return null;
|
|
262
|
+
const at = overlap(span(b, side, dy), halves[side]);
|
|
263
|
+
return at ? sample(p.img, p.f, at, tail) : null;
|
|
264
|
+
};
|
|
265
|
+
// The best ink for a reading, and the contrast it holds.
|
|
266
|
+
const best = (r: Read) => {
|
|
267
|
+
const d = contrast(r.lo, ink.dark);
|
|
268
|
+
const l = contrast(r.hi, ink.light);
|
|
269
|
+
return d >= l ? { dark: true, c: d } : { dark: false, c: l };
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// The word's box (its letters' height), the line under it on each side, the captions, all where they are now.
|
|
273
|
+
const word = colL.querySelector<HTMLElement>("[data-word]");
|
|
274
|
+
if (!word) return;
|
|
275
|
+
const wb0 = boxOf(word, el);
|
|
276
|
+
const wb: Box = { x: wb0.x, y: wb0.y + wb0.h * 0.08, w: wb0.w, h: wb0.h * 0.84 };
|
|
277
|
+
// The line on each side, as it would stand under the word and over it (it may go either way).
|
|
278
|
+
const above = el.dataset.linePos === "above";
|
|
279
|
+
const lineOf = (side: Side) => {
|
|
280
|
+
const n = cols[side].querySelector<HTMLElement>(`[data-line-at='${side}']`);
|
|
281
|
+
if (!n || n.offsetWidth <= 0) return null;
|
|
282
|
+
const b = boxOf(n, el);
|
|
283
|
+
const gap = above ? wb0.y - (b.y + b.h) : b.y - (wb0.y + wb0.h);
|
|
284
|
+
return { below: { ...b, y: wb0.y + wb0.h + gap }, above: { ...b, y: wb0.y - gap - b.h }, reach: gap + b.h };
|
|
285
|
+
};
|
|
286
|
+
const lineBoxes = { l: lineOf("l"), r: lineOf("r") };
|
|
287
|
+
const reach = Math.max(0, lineBoxes.l?.reach ?? 0, lineBoxes.r?.reach ?? 0);
|
|
288
|
+
const capsOf = (side: Side) =>
|
|
289
|
+
all(`[data-col='${side}'] [data-cap]`)
|
|
290
|
+
.filter((n) => n.offsetWidth > 0)
|
|
291
|
+
.map((n) => boxOf(n, el));
|
|
292
|
+
const caps: Record<Side, Box[]> = { l: capsOf("l"), r: capsOf("r") };
|
|
293
|
+
|
|
294
|
+
const readable = !!(photo("l") && photo("r") && read("l", wb, 0, 0.2) && read("r", wb, 0, 0.2));
|
|
295
|
+
if (!readable) {
|
|
296
|
+
// Nothing to read: the treatment that holds over any photo, for the small copy, at the lightest touch.
|
|
297
|
+
const darkVeil = veilFor(0, ink.dark, ink.lightS, SMALL_RATIO);
|
|
298
|
+
const lightVeil = veilFor(1, ink.light, ink.darkS, SMALL_RATIO);
|
|
299
|
+
const t = darkVeil <= lightVeil ? { dark: true, veil: darkVeil } : { dark: false, veil: lightVeil };
|
|
300
|
+
apply({ l: { ...t }, r: { ...t } });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// The heights the word may stand at: clear of the captions and the frames' stagger in the still, with room
|
|
305
|
+
// for the line under it or, when the word stands low, over it.
|
|
306
|
+
const cur = wb0.y + wb0.h / 2;
|
|
307
|
+
const capTop = Math.min(H, ...caps.l.concat(caps.r).map((b) => b.y));
|
|
308
|
+
const capBottom = Math.max(0, ...caps.l.concat(caps.r).map((b) => b.y + b.h));
|
|
309
|
+
const top = (capsLow ? H * 0.07 : Math.max(H * 0.07, capBottom)) + 16;
|
|
310
|
+
const bottom = (capsLow ? Math.min(H * 0.93, capTop) : H * 0.93) - 16;
|
|
311
|
+
const posFor = (y: number): "below" | "above" | null =>
|
|
312
|
+
y + wb0.h / 2 + reach <= bottom ? "below" : y - wb0.h / 2 - reach >= top ? "above" : null;
|
|
313
|
+
const minY = top + wb0.h / 2;
|
|
314
|
+
const maxY = bottom - wb0.h / 2;
|
|
315
|
+
const prefer = (place ? PLACE_Y[place] : PLACE_Y[named]) * H;
|
|
316
|
+
const heights = (
|
|
317
|
+
place || maxY <= minY ? [clamp(prefer, minY, Math.max(minY, maxY))] : Array.from({ length: 11 }, (_, i) => lerp(minY, maxY, i / 10))
|
|
318
|
+
).filter((y, i, a) => posFor(y) !== null || a.length === 1);
|
|
319
|
+
|
|
320
|
+
let pick = { y: heights[0], score: -Infinity, ok: false, tone: { l: { dark: true, c: 1 }, r: { dark: true, c: 1 } } };
|
|
321
|
+
for (const y of heights) {
|
|
322
|
+
const dy = y - cur;
|
|
323
|
+
const rl = read("l", wb, dy, 0.2);
|
|
324
|
+
const rr = read("r", wb, dy, 0.2);
|
|
325
|
+
if (!rl || !rr) continue;
|
|
326
|
+
const tl = best(rl);
|
|
327
|
+
const tr = best(rr);
|
|
328
|
+
const ok = Math.min(tl.c, tr.c) >= WORD_RATIO;
|
|
329
|
+
// Ranked on the strictest read, so a narrow subject under the letters (a tree, a handrail, a lamp) pulls
|
|
330
|
+
// the height down even where the word would still pass.
|
|
331
|
+
const strict = (side: Side, dark: boolean) => {
|
|
332
|
+
const r = read(side, wb, dy, 0.06);
|
|
333
|
+
return r ? contrast(dark ? r.lo : r.hi, dark ? ink.dark : ink.light) : 1;
|
|
334
|
+
};
|
|
335
|
+
// Both halves count: one busy photo must not let the word land on the other one's subject.
|
|
336
|
+
const sl = Math.min(strict("l", tl.dark), 7);
|
|
337
|
+
const sr = Math.min(strict("r", tr.dark), 7);
|
|
338
|
+
const calm = (Math.min(sl, sr) + (sl + sr) / 2) / 2;
|
|
339
|
+
const score = calm + (tl.dark !== tr.dark ? 2 : 0) - (6 * Math.abs(y - prefer)) / H + (ok ? 100 : 0);
|
|
340
|
+
if (score > pick.score) pick = { y, score, ok, tone: { l: tl, r: tr } };
|
|
341
|
+
}
|
|
342
|
+
el.style.setProperty("--sp-y", String((pick.y / H).toFixed(4)));
|
|
343
|
+
const dy = pick.y - cur;
|
|
344
|
+
const pos = posFor(pick.y) ?? "below";
|
|
345
|
+
el.dataset.linePos = pos;
|
|
346
|
+
const lines: Record<Side, Box | null> = { l: lineBoxes.l?.[pos] ?? null, r: lineBoxes.r?.[pos] ?? null };
|
|
347
|
+
|
|
348
|
+
// The line stands on whichever photo it reads on with that photo's ink; captions read with their photo's.
|
|
349
|
+
const smallC = (side: Side, b: Box | null, dark: boolean) => {
|
|
350
|
+
const r = read(side, b, dy, 0.15);
|
|
351
|
+
return r ? contrast(dark ? r.lo : r.hi, dark ? ink.dark : ink.light) : 0;
|
|
352
|
+
};
|
|
353
|
+
const lineC = (side: Side) => (lines[side] ? smallC(side, lines[side], pick.tone[side].dark) : 0);
|
|
354
|
+
const lineAt: Side = lineC("r") > lineC("l") + 0.3 && lineC("l") < SMALL_RATIO ? "r" : "l";
|
|
355
|
+
el.dataset.lineAt = lineAt;
|
|
356
|
+
|
|
357
|
+
// A veil only where nothing else reads: the weakest that brings every piece of copy on the photo to its ratio.
|
|
358
|
+
const veilOf = (side: Side) => {
|
|
359
|
+
const dark = pick.tone[side].dark;
|
|
360
|
+
const v = (b: Box | null, tail: number, ratio: number, moveWithWord: boolean) => {
|
|
361
|
+
const r = read(side, b, moveWithWord ? dy : 0, tail);
|
|
362
|
+
if (!r) return 0;
|
|
363
|
+
return dark ? veilFor(r.lo, ink.dark, ink.lightS, ratio) : veilFor(r.hi, ink.light, ink.darkS, ratio);
|
|
364
|
+
};
|
|
365
|
+
return Math.max(
|
|
366
|
+
v(wb, 0.2, WORD_RATIO, true),
|
|
367
|
+
lineAt === side ? v(lines[side], 0.15, SMALL_RATIO, true) : 0,
|
|
368
|
+
...caps[side].map((b) => v(b, 0.15, SMALL_RATIO, false)),
|
|
369
|
+
);
|
|
370
|
+
};
|
|
371
|
+
apply({ l: { dark: pick.tone.l.dark, veil: veilOf("l") }, r: { dark: pick.tone.r.dark, veil: veilOf("r") } });
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const imgs = Array.from(el.querySelectorAll("img"));
|
|
375
|
+
for (const img of imgs) img.addEventListener("load", settle);
|
|
376
|
+
settle();
|
|
377
|
+
document.fonts?.ready.then(settle);
|
|
378
|
+
window.addEventListener("resize", settle);
|
|
379
|
+
|
|
380
|
+
const mm = gsap.matchMedia();
|
|
381
|
+
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
382
|
+
const run = { l: one("[data-col='l'] [data-run]"), r: one("[data-col='r'] [data-run]") };
|
|
383
|
+
const push = { l: one("[data-col='l'] [data-push]"), r: one("[data-col='r'] [data-push]") };
|
|
384
|
+
const ride = { l: one("[data-col='l'] [data-ride]"), r: one("[data-col='r'] [data-ride]") };
|
|
385
|
+
if (!run.l || !run.r || !push.l || !push.r || !ride.l || !ride.r) return;
|
|
386
|
+
const moving = [run.l, run.r, push.l, push.r, ride.l, ride.r];
|
|
387
|
+
|
|
388
|
+
const g = { W: 1, H: 1, S: 2 };
|
|
389
|
+
const measure = () => {
|
|
390
|
+
settle();
|
|
391
|
+
g.W = el.clientWidth;
|
|
392
|
+
g.H = el.clientHeight;
|
|
393
|
+
g.S = seam.offsetWidth || 2;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// Every value the scroll drives, as plain numbers: each frame's arrival, each photo's push-in, and the
|
|
397
|
+
// run of the two photos against each other while the pair holds.
|
|
398
|
+
const s = { l: 0, r: 0, pl: 0, pr: 0, run: 0 };
|
|
399
|
+
const paint = () => {
|
|
400
|
+
const { W, H, S } = g;
|
|
401
|
+
const inner = (W + S) / 2;
|
|
402
|
+
// The first frame's top edge rises from the bottom; the second's bottom edge comes down from the top.
|
|
403
|
+
colL.style.clipPath = `inset(${(H * (1 - s.l)).toFixed(2)}px ${inner.toFixed(2)}px 0px 0px)`;
|
|
404
|
+
colR.style.clipPath = `inset(0px 0px ${(H * (1 - s.r)).toFixed(2)}px ${inner.toFixed(2)}px)`;
|
|
405
|
+
// Each photo travels with its frame at a fraction of the frame's speed, so it sits deeper than the
|
|
406
|
+
// frame; once the pair stands, the two run inside their frames against each other. Each half of the
|
|
407
|
+
// word rides with part of its photo's movement: out of register while it arrives, in register as it
|
|
408
|
+
// lands, sheared at the seam as the photos run.
|
|
409
|
+
const yl = H * (TRAVEL * (1 - s.l) + RUN_L * s.run);
|
|
410
|
+
const yr = H * (-TRAVEL * (1 - s.r) + RUN_R * s.run);
|
|
411
|
+
run.l!.style.transform = `translate3d(0, ${yl.toFixed(2)}px, 0)`;
|
|
412
|
+
run.r!.style.transform = `translate3d(0, ${yr.toFixed(2)}px, 0)`;
|
|
413
|
+
ride.l!.style.transform = `translate3d(0, ${(yl * RIDE).toFixed(2)}px, 0)`;
|
|
414
|
+
ride.r!.style.transform = `translate3d(0, ${(yr * RIDE).toFixed(2)}px, 0)`;
|
|
415
|
+
push.l!.style.transform = `scale(${lerp(PUSH, 1, s.pl).toFixed(4)})`;
|
|
416
|
+
push.r!.style.transform = `scale(${lerp(PUSH, 1, s.pr).toFixed(4)})`;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
measure();
|
|
420
|
+
paint();
|
|
421
|
+
|
|
422
|
+
const riseL = all("[data-col='l'] [data-rise]");
|
|
423
|
+
const riseR = all("[data-col='r'] [data-rise]");
|
|
424
|
+
gsap.set([...riseL, ...riseR], { yPercent: 118 });
|
|
425
|
+
|
|
426
|
+
const tl = gsap.timeline({
|
|
427
|
+
defaults: { ease: "none" },
|
|
428
|
+
onUpdate: paint,
|
|
429
|
+
scrollTrigger: {
|
|
430
|
+
trigger: el,
|
|
431
|
+
start: "top top",
|
|
432
|
+
end: "+=300%",
|
|
433
|
+
pin: true,
|
|
434
|
+
scrub: 1,
|
|
435
|
+
onRefresh: () => {
|
|
436
|
+
measure();
|
|
437
|
+
paint();
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
// Hold on the ground: the word whole in the accent, the seam standing through it (0.64 of the move after).
|
|
442
|
+
tl.to({}, { duration: 0.8 })
|
|
443
|
+
// The first photo rises in on the left, its half of the word breaking along the edge and locking into
|
|
444
|
+
// register as it lands, the photo settling out of its push-in on the same ease.
|
|
445
|
+
.to(s, { l: 1, duration: 1.25, ease: "power3.inOut" }, 0.8)
|
|
446
|
+
.to(s, { pl: 1, duration: 1.5, ease: "power3.inOut" }, 0.8)
|
|
447
|
+
// Its line and caption stand up out of their masks.
|
|
448
|
+
.to(riseL, { yPercent: 0, duration: 0.9, stagger: 0.08, ease: "expo.out" }, 1.85)
|
|
449
|
+
// Hold on the half-and-half frame. Then the second photo comes down on the right, against the first.
|
|
450
|
+
.to(s, { r: 1, duration: 1.25, ease: "power3.inOut" }, 3.25)
|
|
451
|
+
.to(s, { pr: 1, duration: 1.5, ease: "power3.inOut" }, 3.25)
|
|
452
|
+
.to(riseR, { yPercent: 0, duration: 0.9, ease: "expo.out" }, 4.3)
|
|
453
|
+
// Covered from here on; put away so it never shows when the halves part.
|
|
454
|
+
.set(groundWord, { autoAlpha: 0 }, 4.5)
|
|
455
|
+
// Hold on the pair, the photos running against each other and the halves of the word shearing with
|
|
456
|
+
// them: a steady pull tied to the scroll, so wherever the reader stops the pair stands composed.
|
|
457
|
+
.to(s, { run: 1, duration: 2.3, ease: "none" }, 4.5)
|
|
458
|
+
.to({}, { duration: 2.3 }, 4.5);
|
|
459
|
+
|
|
460
|
+
// The approach, when the stage arrives from below the fold: the seam drops through the frame and the
|
|
461
|
+
// word stands up out of its mask, both tied to the scroll and finished before the pin, so the first
|
|
462
|
+
// pinned frame is the composed one. Skipped when the stage is already in view on the first paint.
|
|
463
|
+
if (el.getBoundingClientRect().top > window.innerHeight * 0.9) {
|
|
464
|
+
const approach = { trigger: el, start: "top 90%", end: "top 15%", scrub: true };
|
|
465
|
+
gsap.fromTo(seam, { scaleY: 0 }, { scaleY: 1, transformOrigin: "50% 0%", ease: "power2.inOut", scrollTrigger: approach });
|
|
466
|
+
gsap.fromTo(groundWord, { yPercent: 108 }, { yPercent: 0, ease: "power3.out", scrollTrigger: { ...approach, start: "top 75%" } });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// The release is the idea once more: the two halves leave at their own speeds.
|
|
470
|
+
const pin = tl.scrollTrigger!;
|
|
471
|
+
const leave = (n: HTMLElement, k: number) =>
|
|
472
|
+
gsap.fromTo(
|
|
473
|
+
n,
|
|
474
|
+
{ y: 0 },
|
|
475
|
+
{
|
|
476
|
+
y: () => k * el.offsetHeight,
|
|
477
|
+
ease: "none",
|
|
478
|
+
immediateRender: false,
|
|
479
|
+
scrollTrigger: { start: () => pin.end, end: () => pin.end + el.offsetHeight, scrub: true, invalidateOnRefresh: true },
|
|
480
|
+
},
|
|
481
|
+
);
|
|
482
|
+
leave(colL, LEAVE_L);
|
|
483
|
+
leave(colR, LEAVE_R);
|
|
484
|
+
|
|
485
|
+
return () => {
|
|
486
|
+
for (const n of [colL, colR]) {
|
|
487
|
+
n.style.clipPath = "";
|
|
488
|
+
n.style.transform = "";
|
|
489
|
+
}
|
|
490
|
+
for (const n of moving) n.style.transform = "";
|
|
491
|
+
};
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
return () => {
|
|
495
|
+
window.removeEventListener("resize", settle);
|
|
496
|
+
for (const img of imgs) img.removeEventListener("load", settle);
|
|
497
|
+
mm.revert();
|
|
498
|
+
};
|
|
499
|
+
},
|
|
500
|
+
{ scope: stage, dependencies: [named, place] },
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
const mask = "block overflow-hidden pb-[0.15em] -mb-[0.15em]";
|
|
504
|
+
|
|
505
|
+
// The word and the line under it, laid out once and rendered on the ground and on each photo. Only the ground
|
|
506
|
+
// copy is the real text; the copies on the photos are pictures of it.
|
|
507
|
+
const words = (layer: "ground" | Side) => (
|
|
508
|
+
<div aria-hidden className="pointer-events-none absolute inset-0">
|
|
509
|
+
<div
|
|
510
|
+
className="absolute inset-x-0 top-[calc(var(--sp-y)*100%)] flex -translate-y-1/2 justify-center text-[length:min(calc(var(--sp-base)*var(--sp-fit,1)),34svh)]"
|
|
511
|
+
>
|
|
512
|
+
{/* The mask the word rises out of, with room for accents above and descenders below. */}
|
|
513
|
+
<span className="-mx-[0.1em] -mb-[0.24em] -mt-[0.08em] block overflow-hidden px-[0.1em] pb-[0.24em] pt-[0.08em]">
|
|
514
|
+
<span
|
|
515
|
+
data-word=""
|
|
516
|
+
className="font-display block w-fit whitespace-nowrap leading-[0.82] tracking-[-0.045em] [word-spacing:0.08em]"
|
|
517
|
+
>
|
|
518
|
+
{word}
|
|
519
|
+
</span>
|
|
520
|
+
</span>
|
|
521
|
+
{/* The line under the word, on the first photo or the second: the block reads which one it reads on. */}
|
|
522
|
+
{line && layer !== "ground"
|
|
523
|
+
? (["l", "r"] as const).map((at) => (
|
|
524
|
+
<span
|
|
525
|
+
key={at}
|
|
526
|
+
className={`absolute top-full mt-[0.3em] max-md:hidden group-data-[line-pos=above]/sp:bottom-full group-data-[line-pos=above]/sp:top-auto group-data-[line-pos=above]/sp:mb-[0.3em] group-data-[line-pos=above]/sp:mt-0 ${
|
|
527
|
+
at === "l"
|
|
528
|
+
? "left-6 md:left-[4vw] group-data-[line-at=r]/sp:invisible"
|
|
529
|
+
: "right-6 text-right md:right-[4vw] invisible group-data-[line-at=r]/sp:visible"
|
|
530
|
+
}`}
|
|
531
|
+
>
|
|
532
|
+
<span className={mask}>
|
|
533
|
+
<span data-rise="" data-line-at={at} className="block max-w-[min(30ch,40vw)] text-lead">
|
|
534
|
+
{line}
|
|
535
|
+
</span>
|
|
536
|
+
</span>
|
|
537
|
+
</span>
|
|
538
|
+
))
|
|
539
|
+
: null}
|
|
540
|
+
</div>
|
|
541
|
+
</div>
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
// The captions stand still on their photos while the word rides.
|
|
545
|
+
const caps = (side: Side) =>
|
|
546
|
+
captions ? (
|
|
547
|
+
<div
|
|
548
|
+
aria-hidden
|
|
549
|
+
className={`pointer-events-none absolute inset-x-0 flex justify-between px-6 text-small md:px-[4vw] ${
|
|
550
|
+
// In the still the pair is staggered by 7%, so the captions stand clear of both frames' edges.
|
|
551
|
+
capsLow ? "bottom-[calc(7%_+_1.25rem)] md:motion-safe:bottom-[6svh]" : "top-[calc(7%_+_1.25rem)] md:motion-safe:top-[5svh]"
|
|
552
|
+
}`}
|
|
553
|
+
>
|
|
554
|
+
<span className={mask}>
|
|
555
|
+
<span data-rise={side === "l" ? "" : undefined} data-cap={side === "l" ? "" : undefined} className="block max-w-[40vw]">
|
|
556
|
+
{captions[0]}
|
|
557
|
+
</span>
|
|
558
|
+
</span>
|
|
559
|
+
<span className={`${mask} text-right`}>
|
|
560
|
+
<span data-rise={side === "r" ? "" : undefined} data-cap={side === "r" ? "" : undefined} className="block max-w-[40vw]">
|
|
561
|
+
{captions[1]}
|
|
562
|
+
</span>
|
|
563
|
+
</span>
|
|
564
|
+
</div>
|
|
565
|
+
) : null;
|
|
566
|
+
|
|
567
|
+
// One photo in its frame. The frame is the whole stage clipped to its half, so the type inside it lands on
|
|
568
|
+
// exactly the same pixels as the ground's copy.
|
|
569
|
+
const column = (img: StruqImage, side: Side) => {
|
|
570
|
+
const left = side === "l";
|
|
571
|
+
// Still (phones, reduced motion): both halves, the first set lower than the second. With motion on wide
|
|
572
|
+
// screens the first paint is the opening frame: both frames shut.
|
|
573
|
+
const clip = left
|
|
574
|
+
? "[clip-path:inset(7%_calc(50%_+_var(--sp-seam)/2)_0_0)] md:motion-safe:[clip-path:inset(100%_calc(50%_+_var(--sp-seam)/2)_0_0)]"
|
|
575
|
+
: "[clip-path:inset(0_0_7%_calc(50%_+_var(--sp-seam)/2))] md:motion-safe:[clip-path:inset(0_0_100%_calc(50%_+_var(--sp-seam)/2))]";
|
|
576
|
+
// Taller than the stage: room for the arrival's travel and for the run while the pair holds (the first runs
|
|
577
|
+
// up, so its room is below; the second runs down, so its room is above).
|
|
578
|
+
const room = left ? "-top-[6%] h-[132%]" : "-top-[14%] h-[124%]";
|
|
579
|
+
const calmEdge = img.negativeSpace === "top" || img.negativeSpace === "bottom" ? img.negativeSpace : null;
|
|
580
|
+
return (
|
|
581
|
+
<div data-col={side} data-tone="dark" className={`group/col absolute inset-0 overflow-hidden ${clip}`}>
|
|
582
|
+
<div className={`absolute w-1/2 ${room} ${left ? "left-0" : "right-0"}`}>
|
|
583
|
+
<div data-run="" className="absolute inset-0 will-change-transform">
|
|
584
|
+
<div data-push="" className="absolute inset-0 will-change-transform">
|
|
585
|
+
<div
|
|
586
|
+
data-photo=""
|
|
587
|
+
data-calm={calmEdge ?? undefined}
|
|
588
|
+
className="absolute inset-0"
|
|
589
|
+
style={
|
|
590
|
+
calmEdge
|
|
591
|
+
? { transform: `scale(${CALM_ZOOM})`, transformOrigin: calmEdge === "top" ? "50% 0%" : "50% 100%" }
|
|
592
|
+
: undefined
|
|
593
|
+
}
|
|
594
|
+
>
|
|
595
|
+
<Image
|
|
596
|
+
src={img.src}
|
|
597
|
+
alt={img.alt}
|
|
598
|
+
fill
|
|
599
|
+
sizes="50vw"
|
|
600
|
+
unoptimized={img.unoptimized}
|
|
601
|
+
className="object-cover [filter:saturate(0.88)_contrast(1.07)]"
|
|
602
|
+
/>
|
|
603
|
+
</div>
|
|
604
|
+
{/* The grade: shadows lifted toward the page's ground, one grain over both photos. */}
|
|
605
|
+
<div aria-hidden className="absolute inset-0 bg-surface-0 opacity-[0.08] mix-blend-screen" />
|
|
606
|
+
<div aria-hidden className="absolute inset-0 opacity-[0.1] mix-blend-overlay" style={{ backgroundImage: GRAIN }} />
|
|
607
|
+
</div>
|
|
608
|
+
</div>
|
|
609
|
+
</div>
|
|
610
|
+
{/* Only where no placement reads (or the photo cannot be read): the weakest veil the copy needs. */}
|
|
611
|
+
<div
|
|
612
|
+
data-veil=""
|
|
613
|
+
aria-hidden
|
|
614
|
+
className="absolute inset-0 bg-[var(--sp-dark)] group-data-[tone=dark]/col:bg-on-photo"
|
|
615
|
+
style={{ opacity: 0 }}
|
|
616
|
+
/>
|
|
617
|
+
<div className="text-on-photo group-data-[tone=dark]/col:text-[var(--sp-dark)]">
|
|
618
|
+
<div data-ride="" className="absolute inset-0 will-change-transform">
|
|
619
|
+
{words(side)}
|
|
620
|
+
</div>
|
|
621
|
+
{caps(side)}
|
|
622
|
+
</div>
|
|
623
|
+
</div>
|
|
624
|
+
);
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
return (
|
|
628
|
+
<section
|
|
629
|
+
ref={stage}
|
|
630
|
+
data-section="split-pair"
|
|
631
|
+
data-line-at="l"
|
|
632
|
+
style={
|
|
633
|
+
{
|
|
634
|
+
"--sp-dark": "oklch(from var(--surface-0) min(calc(l * 0.42), 0.19) c h)",
|
|
635
|
+
"--sp-seam": "max(2px, 0.17vw)",
|
|
636
|
+
"--sp-y": String(PLACE_Y[named]),
|
|
637
|
+
} as React.CSSProperties
|
|
638
|
+
}
|
|
639
|
+
className={`group/sp relative bg-surface-0 ${className}`}
|
|
640
|
+
>
|
|
641
|
+
<h2 className="sr-only">{word}</h2>
|
|
642
|
+
{line ? <p className="sr-only">{line}</p> : null}
|
|
643
|
+
{/* The three inks as this palette resolves them, read once to measure contrast. */}
|
|
644
|
+
<span aria-hidden className="pointer-events-none absolute size-0 overflow-hidden">
|
|
645
|
+
<span data-ink="dark" className="text-[var(--sp-dark)]" />
|
|
646
|
+
<span data-ink="light" className="text-on-photo" />
|
|
647
|
+
<span data-ink="accent" className="text-accent" />
|
|
648
|
+
</span>
|
|
649
|
+
<div className="relative h-[86svh] overflow-hidden [--sp-base:22vw] md:h-svh md:[--sp-base:20vw]" data-stage="">
|
|
650
|
+
{/* The ground: the word in the accent (in ink where the accent is too faint on the ground) and the seam,
|
|
651
|
+
a hard line standing where the photos will meet. */}
|
|
652
|
+
<div data-layer="ground" className="absolute inset-0 text-accent group-data-[ground=ink]/sp:text-ink">
|
|
653
|
+
<span
|
|
654
|
+
data-seam=""
|
|
655
|
+
aria-hidden
|
|
656
|
+
className="absolute inset-y-0 left-[calc(50%_-_var(--sp-seam)/2)] w-[var(--sp-seam)] bg-ink"
|
|
657
|
+
/>
|
|
658
|
+
{words("ground")}
|
|
659
|
+
</div>
|
|
660
|
+
{column(first, "l")}
|
|
661
|
+
{column(second, "r")}
|
|
662
|
+
</div>
|
|
663
|
+
{line ? (
|
|
664
|
+
<p aria-hidden className="px-6 pb-14 pt-7 text-lead text-ink md:hidden">
|
|
665
|
+
{line}
|
|
666
|
+
</p>
|
|
667
|
+
) : null}
|
|
668
|
+
</section>
|
|
669
|
+
);
|
|
670
|
+
}
|