@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,613 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// zoom-through (Struq transition recipe)
|
|
3
|
+
// A scene change inside a page: the camera pushes into a photo until that photo is the next
|
|
4
|
+
// section's ground. The stage pins on a composed frame: the display line on the ground, a short
|
|
5
|
+
// lead, and the photo in an upright window on the other side with its caption and four corner
|
|
6
|
+
// marks. It holds. Then the camera travels toward the window. Everything on the ground is nearer
|
|
7
|
+
// the lens than the photo, so it scales faster and falls away past the edges (the lead fastest,
|
|
8
|
+
// the display line next, the caption with the wall), while the window opens until all four of its
|
|
9
|
+
// edges leave the screen together: the camera arrives, it does not wipe. The photo sits deeper than
|
|
10
|
+
// the window: it grows far less than the frame, eases out of a small push-in and settles at full
|
|
11
|
+
// bleed, its subject held on the same point throughout. Then the next section stands in it: a strip
|
|
12
|
+
// of the page's ground rises from the bottom edge with the first line, and the heading, at the same
|
|
13
|
+
// display size as the line the camera left, rises out of masks on the photo's calm side. It holds,
|
|
14
|
+
// and the pin lets go with the photo drifting slower than the page, which continues from the strip.
|
|
15
|
+
//
|
|
16
|
+
// The camera is one number (`e`, 0 to 1). The window, the photo, every layer of type and the
|
|
17
|
+
// marks are all derived from it on every frame, about one fixed point (the window's centre), so
|
|
18
|
+
// the photo stays registered on its subject. The window's clip is written as pixel insets from
|
|
19
|
+
// plain numbers, never tweened as a string. The pin is 300%: every key frame holds.
|
|
20
|
+
// Lead for an architect, a hotel, a studio: any brief whose photo is a place to walk into.
|
|
21
|
+
//
|
|
22
|
+
// <ZoomThrough image={images.hero[1]} title="Licht" accent="eerst." lead="..." caption="Patiohuis, Alentejo"
|
|
23
|
+
// heading={["Naar", "binnen."]} line="We tekenen eerst het licht, daarna pas de muren." />
|
|
24
|
+
//
|
|
25
|
+
// The window goes over the photo's subject: by default on the side opposite the photo's calm side
|
|
26
|
+
// (`negativeSpace`), so the copy of both scenes stands where the photo is quiet. Pass `side` when the
|
|
27
|
+
// beeldbank's calm side is wrong for the photo.
|
|
28
|
+
//
|
|
29
|
+
// Contrast, on every path. The small line always sits on the ground strip, in ink on the page's
|
|
30
|
+
// own ground. The heading stands on the photo only where the photo earns it: once the photo has
|
|
31
|
+
// loaded, the block reads its pixels under the heading, low and then high on the calm side, then
|
|
32
|
+
// low and high on the window's side (the beeldbank's calm side can be wrong), and keeps the first
|
|
33
|
+
// place where a dark ink (derived from the ground) or the palette's on-photo ink holds 4.5:1 over
|
|
34
|
+
// the median and 3:1 over 95 in 100 pixels. Where none does, or the photo cannot be read (another
|
|
35
|
+
// origin without CORS), each heading line gets a band of ground running out to the calm edge, stepped
|
|
36
|
+
// on top of the strip: still display size, still in the photo's frame. That stepped frame is also the
|
|
37
|
+
// first paint, so nothing waits on a script to be legible. `headingInk` skips the reading when the
|
|
38
|
+
// author knows the photo. No scrim, no veil, no fixed black or white.
|
|
39
|
+
//
|
|
40
|
+
// Phones and reduced motion: the composed still. The lead and the display line on the ground,
|
|
41
|
+
// then the photo full bleed with the heading standing in it over the same ground strip.
|
|
42
|
+
import Image from "next/image";
|
|
43
|
+
import { useRef, useState } from "react";
|
|
44
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
45
|
+
import type { StruqImage } from "../kit/images";
|
|
46
|
+
import { photoLuminances } from "../kit/photo-read";
|
|
47
|
+
|
|
48
|
+
type Side = "left" | "right";
|
|
49
|
+
type Ink = "auto" | "light" | "dark" | "ground";
|
|
50
|
+
|
|
51
|
+
type Props = {
|
|
52
|
+
image: StruqImage;
|
|
53
|
+
/** The composed frame's display word or words, the first line. */
|
|
54
|
+
title: string;
|
|
55
|
+
/** The second line, in the display face's italic: the one accent. */
|
|
56
|
+
accent?: string;
|
|
57
|
+
/** One or two sentences on the ground, small against the display line. */
|
|
58
|
+
lead?: string;
|
|
59
|
+
/** Metadata for the photo: under the window, and on the ground strip at the end. */
|
|
60
|
+
caption?: string;
|
|
61
|
+
/** The next section's heading, one short entry per line: it is set at display size. */
|
|
62
|
+
heading: string[];
|
|
63
|
+
/** The next section's first line, on the ground strip. */
|
|
64
|
+
line?: string;
|
|
65
|
+
/** Where the copy of both scenes stands. Defaults to the photo's calm side; the window takes the other. */
|
|
66
|
+
side?: Side;
|
|
67
|
+
/**
|
|
68
|
+
* The heading's ink over the photo. "auto" (the default) reads the photo; "light" or "dark" is the author's
|
|
69
|
+
* word that the photo is dark or bright under the heading; "ground" always sets it on the ground strip.
|
|
70
|
+
*/
|
|
71
|
+
headingInk?: Ink;
|
|
72
|
+
className?: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The photo's push-in inside the window at the start; it eases to 1 as the camera arrives. */
|
|
76
|
+
const PUSH = 1.3;
|
|
77
|
+
/** Sharpness of the soft limit that stops the photo growing once the window clears the screen. */
|
|
78
|
+
const KNEE = 6;
|
|
79
|
+
const soft = (x: number) => Math.pow(Math.pow(x, -KNEE) + 1, -1 / KNEE);
|
|
80
|
+
/** The camera position at which all four window edges reach the screen edges, together; they carry on past them. */
|
|
81
|
+
const CROSS = 0.93;
|
|
82
|
+
/** How near the lens each layer of the composed frame sits (1 is the wall the window is cut into). */
|
|
83
|
+
const DEPTH = { caption: 1, title: 0.97, lead: 0.94 };
|
|
84
|
+
/** The window's centre on the screen, as a share of the stage: over the photo's subject. */
|
|
85
|
+
const AIM_Y = 46;
|
|
86
|
+
/** Half the window's width and height. Upright 4:5, at most 52% of the screen high and 30% wide. */
|
|
87
|
+
const HALF_W = "min(20.8svh, 15vw)";
|
|
88
|
+
const HALF_H = "min(26svh, 18.75vw)";
|
|
89
|
+
|
|
90
|
+
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
|
|
91
|
+
const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Which ink the photo allows under one box: "dark" (the ground-derived dark, luminance under 0.012), "light"
|
|
95
|
+
* (the palette's on-photo ink, taken at a cautious 0.8), "none" when neither holds 4.5:1 over the median and
|
|
96
|
+
* 3:1 over 95 in 100 pixels, and null when the pixels cannot be read.
|
|
97
|
+
*/
|
|
98
|
+
function readInk(img: HTMLImageElement, box: { x: number; y: number; w: number; h: number }, W: number, H: number) {
|
|
99
|
+
if (!W || !H) return null;
|
|
100
|
+
const ys = photoLuminances(img, { x: 0, y: 0, w: W, h: H }, box);
|
|
101
|
+
if (!ys) return null;
|
|
102
|
+
const at = (q: number) => ys[Math.min(ys.length - 1, Math.floor(ys.length * q))];
|
|
103
|
+
// Dark ink: (Y + 0.05) / 0.062 is 4.5 at Y 0.229 and 3 at Y 0.136.
|
|
104
|
+
if (at(0.5) >= 0.229 && at(0.05) >= 0.136) return "dark";
|
|
105
|
+
// Light ink: 0.85 / (Y + 0.05) is 4.5 at Y 0.139 and 3 at Y 0.233.
|
|
106
|
+
if (at(0.5) <= 0.139 && at(0.95) <= 0.233) return "light";
|
|
107
|
+
return "none";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function ZoomThrough({
|
|
111
|
+
image,
|
|
112
|
+
title,
|
|
113
|
+
accent,
|
|
114
|
+
lead,
|
|
115
|
+
caption,
|
|
116
|
+
heading,
|
|
117
|
+
line,
|
|
118
|
+
side,
|
|
119
|
+
headingInk = "auto",
|
|
120
|
+
className = "",
|
|
121
|
+
}: Props) {
|
|
122
|
+
const stage = useRef<HTMLElement>(null);
|
|
123
|
+
// Set when the photo is calmer under the heading on the window's side than on its own (the beeldbank's calm side
|
|
124
|
+
// can be wrong): the arrived copy then stands on that side.
|
|
125
|
+
const [flip, setFlip] = useState(false);
|
|
126
|
+
const flipNow = useRef(false);
|
|
127
|
+
const copySide: Side = side ?? (image.negativeSpace === "right" ? "right" : "left");
|
|
128
|
+
const aimX = copySide === "left" ? 70 : 30;
|
|
129
|
+
// The first paint is always legible: the heading on the ground strip unless the author vouches for the photo.
|
|
130
|
+
const firstPlace = headingInk === "light" || headingInk === "dark" ? "low" : "ground";
|
|
131
|
+
const firstInk = headingInk === "dark" ? "dark" : "light";
|
|
132
|
+
|
|
133
|
+
// The composed frame, laid out in CSS so the first paint is already the first frame.
|
|
134
|
+
const win = {
|
|
135
|
+
left: `calc(${aimX}% - ${HALF_W})`,
|
|
136
|
+
top: `calc(${AIM_Y}% - ${HALF_H})`,
|
|
137
|
+
width: `calc(2 * ${HALF_W})`,
|
|
138
|
+
height: `calc(2 * ${HALF_H})`,
|
|
139
|
+
};
|
|
140
|
+
const startClip = `inset(calc(${AIM_Y}% - ${HALF_H}) calc(${100 - aimX}% - ${HALF_W}) calc(${100 - AIM_Y}% - ${HALF_H}) calc(${aimX}% - ${HALF_W}))`;
|
|
141
|
+
const origin = `${aimX}% ${AIM_Y}%`;
|
|
142
|
+
const startScale = "scale(0.73)";
|
|
143
|
+
|
|
144
|
+
useGSAP(
|
|
145
|
+
() => {
|
|
146
|
+
const el = stage.current;
|
|
147
|
+
if (!el) return;
|
|
148
|
+
// Scripts run: the pinned stage replaces the still (CSS reads this, or the layout's pre-paint class).
|
|
149
|
+
el.dataset.live = "";
|
|
150
|
+
const one = <T extends HTMLElement = HTMLElement>(s: string) => el.querySelector<T>(s);
|
|
151
|
+
const tree = one("[data-tree]");
|
|
152
|
+
const windowBox = one("[data-window]");
|
|
153
|
+
const clip = one("[data-clip]");
|
|
154
|
+
const drift = one("[data-drift]");
|
|
155
|
+
const plane = one("[data-plane]");
|
|
156
|
+
const copy = one("[data-copy]");
|
|
157
|
+
const titleBox = one("[data-title]");
|
|
158
|
+
const stillTitle = one("[data-still-title]");
|
|
159
|
+
const still = one("[data-still-photo]");
|
|
160
|
+
if (!tree || !windowBox || !clip || !drift || !plane || !copy || !titleBox || !stillTitle || !still) return;
|
|
161
|
+
const layers = Array.from(el.querySelectorAll<HTMLElement>("[data-depth]"));
|
|
162
|
+
const marks = Array.from(el.querySelectorAll<HTMLElement>("[data-mark]"));
|
|
163
|
+
|
|
164
|
+
// Layout positions, untouched by transforms: offsets walk up to `top`.
|
|
165
|
+
const box = (n: HTMLElement, top: HTMLElement) => {
|
|
166
|
+
let x = 0;
|
|
167
|
+
let y = 0;
|
|
168
|
+
let cur: HTMLElement | null = n;
|
|
169
|
+
while (cur && cur !== top) {
|
|
170
|
+
x += cur.offsetLeft;
|
|
171
|
+
y += cur.offsetTop;
|
|
172
|
+
cur = cur.offsetParent as HTMLElement | null;
|
|
173
|
+
}
|
|
174
|
+
return { x, y, w: n.offsetWidth, h: n.offsetHeight };
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Fit: the display line never runs into the window or past the edge, whatever the face; the arrived
|
|
178
|
+
// heading takes the same size and fits the calm half of the photo.
|
|
179
|
+
const fit = () => {
|
|
180
|
+
const set = (name: string, room: number, lines: HTMLElement[]) => {
|
|
181
|
+
const need = Math.max(0, ...lines.map((n) => n.offsetWidth));
|
|
182
|
+
if (need > 0) el.style.setProperty(name, String(Math.floor(Math.min(1, room / need) * 1000) / 1000));
|
|
183
|
+
};
|
|
184
|
+
const all = (root: HTMLElement, s: string) => Array.from(root.querySelectorAll<HTMLElement>(s));
|
|
185
|
+
for (const v of ["--zt-fit", "--zt-sfit", "--zt-hfit"]) el.style.setProperty(v, "1");
|
|
186
|
+
if (tree.offsetParent) {
|
|
187
|
+
const W = tree.clientWidth;
|
|
188
|
+
const wb = box(windowBox, tree);
|
|
189
|
+
set("--zt-fit", copySide === "left" ? wb.x - W * 0.09 : W - (wb.x + wb.w) - W * 0.09, all(titleBox, "[data-line]"));
|
|
190
|
+
set("--zt-hfit", W * 0.44, all(copy, "[data-hline]"));
|
|
191
|
+
} else {
|
|
192
|
+
set("--zt-sfit", stillTitle.clientWidth, all(stillTitle, "[data-line]"));
|
|
193
|
+
const W = still.clientWidth;
|
|
194
|
+
set("--zt-hfit", W >= 768 ? W * 0.44 : W - 48, all(still, "[data-hline]"));
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Where the heading stands, read from whichever photo is on screen: low on the calm side, then high, else
|
|
199
|
+
// on the ground strip. In the pinned stage the heading is outside the window until the push, so the choice
|
|
200
|
+
// never shows. The pixels come from a plain CORS copy of the loaded file: a srcset image's natural size is
|
|
201
|
+
// density-corrected, and a photo from another origin without CORS cannot be read at all (it stays on ground).
|
|
202
|
+
let probe: HTMLImageElement | null = null;
|
|
203
|
+
let alive = true;
|
|
204
|
+
let onPlace = () => {};
|
|
205
|
+
const tone = () => {
|
|
206
|
+
if (headingInk !== "auto") return;
|
|
207
|
+
const motion = tree.offsetParent !== null;
|
|
208
|
+
const img = (motion ? plane : still).querySelector("img");
|
|
209
|
+
const src = img?.currentSrc || img?.src;
|
|
210
|
+
if (!img || !src || !img.complete) return;
|
|
211
|
+
if (probe?.src !== new URL(src, location.href).href || !probe.complete) {
|
|
212
|
+
const next = document.createElement("img");
|
|
213
|
+
next.crossOrigin = "anonymous";
|
|
214
|
+
next.decoding = "async";
|
|
215
|
+
next.src = src;
|
|
216
|
+
probe = next;
|
|
217
|
+
next.decode().then(
|
|
218
|
+
() => alive && probe === next && tone(),
|
|
219
|
+
() => undefined,
|
|
220
|
+
);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const host = motion ? tree : still;
|
|
224
|
+
const head = (motion ? copy : still).querySelector<HTMLElement>("[data-head]");
|
|
225
|
+
if (!head) return;
|
|
226
|
+
// `other`: the heading mirrored onto the window's side of the photo (the layout is symmetric, so its box
|
|
227
|
+
// is the mirror of the measured one).
|
|
228
|
+
const read = (place: "low" | "high", other: boolean) => {
|
|
229
|
+
el.dataset.place = place;
|
|
230
|
+
const W = host.clientWidth;
|
|
231
|
+
const b = Array.from(head.querySelectorAll<HTMLElement>("[data-hline]")).map((n) => {
|
|
232
|
+
const p = box(n, host);
|
|
233
|
+
return other !== flipNow.current ? { ...p, x: W - p.x - p.w } : p;
|
|
234
|
+
});
|
|
235
|
+
if (!b.length) return null;
|
|
236
|
+
const x0 = Math.min(...b.map((p) => p.x));
|
|
237
|
+
const y0 = Math.min(...b.map((p) => p.y));
|
|
238
|
+
const x1 = Math.max(...b.map((p) => p.x + p.w));
|
|
239
|
+
const y1 = Math.max(...b.map((p) => p.y + p.h));
|
|
240
|
+
const pad = 16;
|
|
241
|
+
return readInk(probe!, { x: x0 - pad, y: y0 - pad, w: x1 - x0 + pad * 2, h: y1 - y0 + pad * 2 }, W, host.clientHeight);
|
|
242
|
+
};
|
|
243
|
+
// The calm side low, then high; then the other side; only then the ground bands.
|
|
244
|
+
let place = "ground";
|
|
245
|
+
let ink = "light";
|
|
246
|
+
let other = false;
|
|
247
|
+
for (const [p, o] of [["low", false], ["high", false], ["low", true], ["high", true]] as const) {
|
|
248
|
+
const r = read(p, o);
|
|
249
|
+
if (r === "dark" || r === "light") {
|
|
250
|
+
place = p;
|
|
251
|
+
ink = r;
|
|
252
|
+
other = o;
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
el.dataset.place = place;
|
|
257
|
+
el.dataset.ink = ink;
|
|
258
|
+
flipNow.current = other;
|
|
259
|
+
setFlip(other);
|
|
260
|
+
onPlace();
|
|
261
|
+
};
|
|
262
|
+
const imgs = Array.from(el.querySelectorAll("img"));
|
|
263
|
+
for (const img of imgs) img.addEventListener("load", tone);
|
|
264
|
+
|
|
265
|
+
const settle = () => {
|
|
266
|
+
fit();
|
|
267
|
+
tone();
|
|
268
|
+
};
|
|
269
|
+
settle();
|
|
270
|
+
document.fonts?.ready.then(settle);
|
|
271
|
+
window.addEventListener("resize", settle);
|
|
272
|
+
|
|
273
|
+
const mm = gsap.matchMedia();
|
|
274
|
+
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
275
|
+
const strip = copy.querySelector<HTMLElement>("[data-strip]");
|
|
276
|
+
const headWrap = copy.querySelector<HTMLElement>("[data-head]");
|
|
277
|
+
const g = {
|
|
278
|
+
W: 1, H: 1, px: 0, py: 0, hw: 1, hh: 1,
|
|
279
|
+
// Per side (left, right, top, bottom): the window's start reach from the centre, the screen's, their ratio.
|
|
280
|
+
h0: [1, 1, 1, 1], d: [1, 1, 1, 1], r: [1, 1, 1, 1],
|
|
281
|
+
R: 1, Kc: 1, slab: 0,
|
|
282
|
+
};
|
|
283
|
+
const slab = () => {
|
|
284
|
+
const ground = el.dataset.place === "ground";
|
|
285
|
+
g.slab = (strip?.offsetHeight ?? 0) + (ground && headWrap ? headWrap.offsetHeight : 0);
|
|
286
|
+
};
|
|
287
|
+
const measure = () => {
|
|
288
|
+
fit();
|
|
289
|
+
g.W = tree.clientWidth;
|
|
290
|
+
g.H = tree.clientHeight;
|
|
291
|
+
const b = box(windowBox, tree);
|
|
292
|
+
g.hw = b.w / 2;
|
|
293
|
+
g.hh = b.h / 2;
|
|
294
|
+
g.px = b.x + g.hw;
|
|
295
|
+
g.py = b.y + g.hh;
|
|
296
|
+
g.h0 = [g.hw, g.hw, g.hh, g.hh];
|
|
297
|
+
g.d = [g.px, g.W - g.px, g.py, g.H - g.py];
|
|
298
|
+
g.r = g.h0.map((h, i) => h / g.d[i]);
|
|
299
|
+
g.R = Math.max(...g.r);
|
|
300
|
+
// The camera's own travel, for the type on the wall: as far as the window's farthest edge goes.
|
|
301
|
+
g.Kc = Math.pow(1 / Math.min(...g.r), 1 / CROSS);
|
|
302
|
+
slab();
|
|
303
|
+
const o = `${g.px.toFixed(1)}px ${g.py.toFixed(1)}px`;
|
|
304
|
+
for (const n of [plane, copy, ...layers]) n.style.transformOrigin = o;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// The camera (0 at the composed frame, 1 when the photo is the ground) and the ground strip's rise.
|
|
308
|
+
const s = { e: 0, strip: 0 };
|
|
309
|
+
const paint = () => {
|
|
310
|
+
// Each edge of the window opens from its own start to just past its own screen edge, on the same
|
|
311
|
+
// exponential curve, so all four leave the screen together however far off centre the window sits.
|
|
312
|
+
const reach = g.h0.map((h, i) => h * Math.pow(g.d[i] / h, s.e / CROSS));
|
|
313
|
+
const l = g.px - reach[0];
|
|
314
|
+
const r = g.px + reach[1];
|
|
315
|
+
const t = g.py - reach[2];
|
|
316
|
+
const b = g.py + reach[3];
|
|
317
|
+
const px = (v: number) => `${Math.max(0, v).toFixed(2)}px`;
|
|
318
|
+
clip.style.clipPath = `inset(${px(t)} ${px(g.W - r)} ${px(g.H - b)} ${px(l)})`;
|
|
319
|
+
// The photo: it grows less than the window, settles out of its push-in, and is exactly full bleed when
|
|
320
|
+
// the camera arrives. It always covers what the window shows.
|
|
321
|
+
const push = 1 + (PUSH - 1) * Math.pow(1 - s.e, 1.2);
|
|
322
|
+
const base = Math.pow(g.R, 1 - s.e / CROSS);
|
|
323
|
+
let sig = (soft(base) / soft(Math.pow(g.R, 1 - 1 / CROSS))) * push;
|
|
324
|
+
for (let i = 0; i < 4; i += 1) sig = Math.max(sig, Math.min(reach[i] / g.d[i], 1));
|
|
325
|
+
const ps = `scale(${sig.toFixed(5)})`;
|
|
326
|
+
plane.style.transform = ps;
|
|
327
|
+
copy.style.transform = ps;
|
|
328
|
+
// The composed frame, nearer the lens: each layer scales as its distance closes, and is put away
|
|
329
|
+
// once it is past the edges.
|
|
330
|
+
const m = 1 - 1 / Math.pow(g.Kc, s.e);
|
|
331
|
+
for (const n of layers) {
|
|
332
|
+
const dd = Number(n.dataset.depth) || 1;
|
|
333
|
+
const k = dd / Math.max(dd - m, 0.001);
|
|
334
|
+
n.style.transform = `scale(${k.toFixed(4)})`;
|
|
335
|
+
n.style.visibility = k > 14 ? "hidden" : "";
|
|
336
|
+
}
|
|
337
|
+
// The marks ride out on the window's corners.
|
|
338
|
+
for (const n of marks) {
|
|
339
|
+
const at = n.dataset.mark ?? "";
|
|
340
|
+
const x = at.includes("l") ? g.hw - reach[0] : reach[1] - g.hw;
|
|
341
|
+
const y = at.includes("t") ? g.hh - reach[2] : reach[3] - g.hh;
|
|
342
|
+
n.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0)`;
|
|
343
|
+
}
|
|
344
|
+
// The ground strip (and the heading's ground, when it stands there) rises from the bottom edge.
|
|
345
|
+
const lift = `translate3d(0, ${((1 - s.strip) * g.slab).toFixed(2)}px, 0)`;
|
|
346
|
+
if (strip) strip.style.transform = lift;
|
|
347
|
+
if (headWrap) headWrap.style.transform = el.dataset.place === "ground" ? lift : "";
|
|
348
|
+
};
|
|
349
|
+
onPlace = () => {
|
|
350
|
+
slab();
|
|
351
|
+
paint();
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
measure();
|
|
355
|
+
paint();
|
|
356
|
+
|
|
357
|
+
const heads = Array.from(copy.querySelectorAll<HTMLElement>("[data-rise]"));
|
|
358
|
+
const after = Array.from(copy.querySelectorAll<HTMLElement>("[data-after]"));
|
|
359
|
+
const PUSH_AT = 1.2;
|
|
360
|
+
const PUSH_FOR = 2.4;
|
|
361
|
+
const tl = gsap.timeline({
|
|
362
|
+
defaults: { ease: "none" },
|
|
363
|
+
onUpdate: paint,
|
|
364
|
+
scrollTrigger: {
|
|
365
|
+
trigger: el,
|
|
366
|
+
start: "top top",
|
|
367
|
+
end: "+=300%",
|
|
368
|
+
pin: true,
|
|
369
|
+
scrub: 1,
|
|
370
|
+
onRefresh: () => {
|
|
371
|
+
measure();
|
|
372
|
+
paint();
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
// Hold on the composed frame: half the push.
|
|
377
|
+
tl.to({}, { duration: PUSH_AT })
|
|
378
|
+
// The push: the composed frame falls away, the window opens past every edge at once, the photo settles.
|
|
379
|
+
.to(s, { e: 1, duration: PUSH_FOR, ease: "power2.inOut" })
|
|
380
|
+
// As the last edges leave, the ground strip rises with the line, and the heading stands up in the photo.
|
|
381
|
+
.to(s, { strip: 1, duration: 0.9, ease: "power3.inOut" }, PUSH_AT + PUSH_FOR * 0.8)
|
|
382
|
+
.from(heads, { yPercent: 118, duration: 0.95, stagger: 0.09, ease: "expo.out" }, PUSH_AT + PUSH_FOR * 0.84)
|
|
383
|
+
.from(after, { yPercent: 118, duration: 0.95, stagger: 0.06, ease: "expo.out" }, "<0.2")
|
|
384
|
+
// Hold on the arrived scene (more than half the push); the pin then lets it go.
|
|
385
|
+
.to({}, { duration: 1.5 });
|
|
386
|
+
|
|
387
|
+
// Depth on the way in and out: the photo lags the page as the stage arrives and as it leaves.
|
|
388
|
+
const pin = tl.scrollTrigger!;
|
|
389
|
+
gsap.fromTo(
|
|
390
|
+
drift,
|
|
391
|
+
{ yPercent: -6 },
|
|
392
|
+
{ yPercent: 0, ease: "none", scrollTrigger: { trigger: el, start: "top bottom", end: "top top", scrub: true } },
|
|
393
|
+
);
|
|
394
|
+
gsap.fromTo(
|
|
395
|
+
drift,
|
|
396
|
+
{ yPercent: 0 },
|
|
397
|
+
{
|
|
398
|
+
yPercent: 32,
|
|
399
|
+
ease: "none",
|
|
400
|
+
immediateRender: false,
|
|
401
|
+
scrollTrigger: { start: () => pin.end, end: () => pin.end + el.offsetHeight, scrub: true },
|
|
402
|
+
},
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
return () => {
|
|
406
|
+
onPlace = () => {};
|
|
407
|
+
clip.style.clipPath = startClip;
|
|
408
|
+
for (const n of [plane, copy]) {
|
|
409
|
+
n.style.transform = startScale;
|
|
410
|
+
n.style.transformOrigin = origin;
|
|
411
|
+
}
|
|
412
|
+
for (const n of layers) {
|
|
413
|
+
n.style.transform = "";
|
|
414
|
+
n.style.visibility = "";
|
|
415
|
+
n.style.transformOrigin = "";
|
|
416
|
+
}
|
|
417
|
+
for (const n of [...marks, strip, headWrap]) if (n) n.style.transform = "";
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
return () => {
|
|
422
|
+
alive = false;
|
|
423
|
+
delete el.dataset.live;
|
|
424
|
+
window.removeEventListener("resize", settle);
|
|
425
|
+
for (const img of imgs) img.removeEventListener("load", tone);
|
|
426
|
+
mm.revert();
|
|
427
|
+
};
|
|
428
|
+
},
|
|
429
|
+
{ scope: stage, dependencies: [copySide, headingInk] },
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const right = copySide === "right";
|
|
433
|
+
const display = `font-display block w-fit whitespace-nowrap leading-[0.86] tracking-[-0.04em] [word-spacing:0.08em]`;
|
|
434
|
+
const italic = "font-accent font-light italic tracking-[-0.02em]";
|
|
435
|
+
|
|
436
|
+
const photo = () => (
|
|
437
|
+
<>
|
|
438
|
+
<Image
|
|
439
|
+
src={image.src}
|
|
440
|
+
alt={image.alt}
|
|
441
|
+
fill
|
|
442
|
+
sizes="100vw"
|
|
443
|
+
unoptimized={image.unoptimized}
|
|
444
|
+
className="object-cover [filter:saturate(0.9)_contrast(1.06)]"
|
|
445
|
+
/>
|
|
446
|
+
{/* The grade: shadows lifted toward the page's own ground. */}
|
|
447
|
+
<div aria-hidden className="absolute inset-0 bg-surface-0 opacity-[0.07] mix-blend-screen" />
|
|
448
|
+
</>
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
// The arrived scene, the same in the pinned stage and the still: the heading over the photo and the strip with
|
|
452
|
+
// the line and the caption. `motion` adds the masks the heading and line rise from. Where the photo cannot carry
|
|
453
|
+
// the heading, each heading line gets its own band of ground running out to the calm edge, stepped on top of
|
|
454
|
+
// the strip: one cut-paper shape of the page's ground set into the photo, the heading still at display size and
|
|
455
|
+
// still in the photo's frame, never a block of copy under a picture.
|
|
456
|
+
const headRight = right !== flip;
|
|
457
|
+
const band = headRight
|
|
458
|
+
? "group-data-[place=ground]/zt:-mr-6 group-data-[place=ground]/zt:pr-6 md:group-data-[place=ground]/zt:-mr-[5vw] md:group-data-[place=ground]/zt:pr-[5vw] group-data-[place=ground]/zt:pl-[0.2em]"
|
|
459
|
+
: "group-data-[place=ground]/zt:-ml-6 group-data-[place=ground]/zt:pl-6 md:group-data-[place=ground]/zt:-ml-[5vw] md:group-data-[place=ground]/zt:pl-[5vw] group-data-[place=ground]/zt:pr-[0.2em]";
|
|
460
|
+
const arrived = (motion: boolean, size: string) => (
|
|
461
|
+
<div className="absolute inset-0 flex flex-col justify-end">
|
|
462
|
+
<div
|
|
463
|
+
data-head=""
|
|
464
|
+
className={`px-6 pb-[3svh] md:px-[5vw] text-on-photo group-data-[ink=dark]/zt:text-[var(--zt-dark)] group-data-[place=high]/zt:absolute group-data-[place=high]/zt:inset-x-0 group-data-[place=high]/zt:top-[11svh] group-data-[place=ground]/zt:pb-0 group-data-[place=ground]/zt:text-ink`}
|
|
465
|
+
>
|
|
466
|
+
<h2 className={`font-display ${size} leading-[0.88] tracking-[-0.04em] [word-spacing:0.08em]`}>
|
|
467
|
+
{heading.map((h) => (
|
|
468
|
+
<span
|
|
469
|
+
key={h}
|
|
470
|
+
className={`-mx-6 flex px-6 md:-mx-[5vw] md:px-[5vw] ${headRight ? "justify-end" : "justify-start"} ${
|
|
471
|
+
motion ? "overflow-hidden pb-[0.1em] -mb-[0.1em] group-data-[place=ground]/zt:pb-0 group-data-[place=ground]/zt:mb-0" : ""
|
|
472
|
+
}`}
|
|
473
|
+
>
|
|
474
|
+
<span className={`block group-data-[place=ground]/zt:bg-surface-0 group-data-[place=ground]/zt:pb-[0.12em] group-data-[place=ground]/zt:pt-[0.08em] ${band}`}>
|
|
475
|
+
<span data-rise={motion ? "" : undefined} data-hline="" className="block whitespace-nowrap">
|
|
476
|
+
{h}
|
|
477
|
+
</span>
|
|
478
|
+
</span>
|
|
479
|
+
</span>
|
|
480
|
+
))}
|
|
481
|
+
</h2>
|
|
482
|
+
</div>
|
|
483
|
+
{line || caption ? (
|
|
484
|
+
<div
|
|
485
|
+
data-strip=""
|
|
486
|
+
className={`flex flex-col gap-3 bg-surface-0 px-6 py-6 md:flex-row md:items-end md:justify-between md:gap-10 md:px-[5vw] md:py-[3.4svh] ${
|
|
487
|
+
headRight ? "items-end text-right md:flex-row-reverse" : ""
|
|
488
|
+
}`}
|
|
489
|
+
>
|
|
490
|
+
{line ? (
|
|
491
|
+
<span className={`block max-w-[38ch] ${motion ? "overflow-hidden" : ""}`}>
|
|
492
|
+
<span data-after={motion ? "" : undefined} className="block text-balance text-lead text-ink">
|
|
493
|
+
{line}
|
|
494
|
+
</span>
|
|
495
|
+
</span>
|
|
496
|
+
) : null}
|
|
497
|
+
{caption ? (
|
|
498
|
+
<span className={`block ${motion ? "overflow-hidden" : ""}`}>
|
|
499
|
+
<span data-after={motion ? "" : undefined} className="block text-small text-ink-muted">
|
|
500
|
+
{caption}
|
|
501
|
+
</span>
|
|
502
|
+
</span>
|
|
503
|
+
) : null}
|
|
504
|
+
</div>
|
|
505
|
+
) : null}
|
|
506
|
+
</div>
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
const mark = (at: "tl" | "tr" | "bl" | "br") => (
|
|
510
|
+
<span
|
|
511
|
+
key={at}
|
|
512
|
+
data-mark={at}
|
|
513
|
+
aria-hidden
|
|
514
|
+
className={`absolute size-[0.9rem] border-ink ${at[0] === "t" ? "-top-3 border-t" : "-bottom-3 border-b"} ${
|
|
515
|
+
at[1] === "l" ? "-left-3 border-l" : "-right-3 border-r"
|
|
516
|
+
}`}
|
|
517
|
+
/>
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
return (
|
|
521
|
+
<section
|
|
522
|
+
ref={stage}
|
|
523
|
+
data-transition="zoom-through"
|
|
524
|
+
data-place={firstPlace}
|
|
525
|
+
data-ink={firstInk}
|
|
526
|
+
style={
|
|
527
|
+
{
|
|
528
|
+
"--zt-dark": "oklch(from var(--surface-0) min(calc(l * 0.42), 0.19) c h)",
|
|
529
|
+
} as React.CSSProperties
|
|
530
|
+
}
|
|
531
|
+
className={`group/zt relative bg-surface-0 ${className}`}
|
|
532
|
+
>
|
|
533
|
+
{/* The pinned stage (wide screens with motion). Shown only once the page runs scripts. */}
|
|
534
|
+
<div data-tree="" className="relative hidden h-svh overflow-hidden md:motion-safe:[:is(.js,.sq-js)_&]:block md:motion-safe:group-data-[live]/zt:block">
|
|
535
|
+
<div data-clip="" className="absolute inset-0" style={{ clipPath: startClip }}>
|
|
536
|
+
<div data-drift="" className="absolute inset-0">
|
|
537
|
+
<div data-plane="" className="absolute inset-0" style={{ transform: startScale, transformOrigin: origin }}>
|
|
538
|
+
{photo()}
|
|
539
|
+
</div>
|
|
540
|
+
</div>
|
|
541
|
+
<div data-copy="" className="absolute inset-0" style={{ transform: startScale, transformOrigin: origin }}>
|
|
542
|
+
{arrived(true, "text-[length:calc(min(15vw,25svh)*var(--zt-hfit,1))]")}
|
|
543
|
+
</div>
|
|
544
|
+
</div>
|
|
545
|
+
|
|
546
|
+
{/* The composed frame: three layers at their own distance from the lens. */}
|
|
547
|
+
{lead ? (
|
|
548
|
+
<div data-depth={DEPTH.lead} className="pointer-events-none absolute inset-0">
|
|
549
|
+
<p className={`absolute top-[10svh] max-w-[30ch] text-body text-ink ${right ? "right-[5vw] text-right" : "left-[5vw]"}`}>
|
|
550
|
+
{lead}
|
|
551
|
+
</p>
|
|
552
|
+
</div>
|
|
553
|
+
) : null}
|
|
554
|
+
<div data-depth={DEPTH.title} className="pointer-events-none absolute inset-0">
|
|
555
|
+
<p
|
|
556
|
+
data-title=""
|
|
557
|
+
className={`absolute bottom-[7svh] flex flex-col text-[length:calc(min(15vw,25svh)*var(--zt-fit,1))] text-ink ${
|
|
558
|
+
right ? "right-[5vw] items-end" : "left-[5vw] items-start"
|
|
559
|
+
}`}
|
|
560
|
+
>
|
|
561
|
+
<span data-line="" className={display}>
|
|
562
|
+
{title}
|
|
563
|
+
</span>
|
|
564
|
+
{accent ? (
|
|
565
|
+
<span data-line="" className={`${display} ${italic}`}>
|
|
566
|
+
{accent}
|
|
567
|
+
</span>
|
|
568
|
+
) : null}
|
|
569
|
+
</p>
|
|
570
|
+
</div>
|
|
571
|
+
{caption ? (
|
|
572
|
+
<div data-depth={DEPTH.caption} className="pointer-events-none absolute inset-0">
|
|
573
|
+
<p
|
|
574
|
+
className="absolute text-small text-ink-muted"
|
|
575
|
+
style={{ left: win.left, top: `calc(${AIM_Y}% + ${HALF_H} + 1.1rem)` }}
|
|
576
|
+
>
|
|
577
|
+
{caption}
|
|
578
|
+
</p>
|
|
579
|
+
</div>
|
|
580
|
+
) : null}
|
|
581
|
+
<div data-window="" aria-hidden className="pointer-events-none absolute" style={win}>
|
|
582
|
+
{(["tl", "tr", "bl", "br"] as const).map(mark)}
|
|
583
|
+
</div>
|
|
584
|
+
</div>
|
|
585
|
+
|
|
586
|
+
{/* The composed still: phones, reduced motion, and pages without scripts. */}
|
|
587
|
+
<div className="md:motion-safe:[:is(.js,.sq-js)_&]:hidden md:motion-safe:group-data-[live]/zt:hidden">
|
|
588
|
+
<div className={`flex flex-col gap-8 px-6 pb-10 pt-16 md:px-[5vw] md:pb-[8svh] md:pt-[12svh] ${right ? "items-end text-right" : ""}`}>
|
|
589
|
+
{lead ? <p className="max-w-[30ch] text-body text-ink">{lead}</p> : null}
|
|
590
|
+
<p
|
|
591
|
+
data-still-title=""
|
|
592
|
+
className={`flex w-full flex-col text-[length:calc(19vw*var(--zt-sfit,1))] text-ink md:text-[length:calc(min(13vw,22svh)*var(--zt-sfit,1))] ${
|
|
593
|
+
right ? "items-end" : "items-start"
|
|
594
|
+
}`}
|
|
595
|
+
>
|
|
596
|
+
<span data-line="" className={display}>
|
|
597
|
+
{title}
|
|
598
|
+
</span>
|
|
599
|
+
{accent ? (
|
|
600
|
+
<span data-line="" className={`${display} ${italic}`}>
|
|
601
|
+
{accent}
|
|
602
|
+
</span>
|
|
603
|
+
) : null}
|
|
604
|
+
</p>
|
|
605
|
+
</div>
|
|
606
|
+
<div data-still-photo="" className="relative h-[92svh] overflow-hidden md:h-svh">
|
|
607
|
+
{photo()}
|
|
608
|
+
{arrived(false, "text-[length:calc(19vw*var(--zt-hfit,1))] md:text-[length:calc(min(13vw,22svh)*var(--zt-hfit,1))]")}
|
|
609
|
+
</div>
|
|
610
|
+
</div>
|
|
611
|
+
</section>
|
|
612
|
+
);
|
|
613
|
+
}
|