@struq/skill 0.3.0 → 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 +22 -36
- package/package.json +3 -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 +2 -2
- 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,385 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// film-strip (Struq section recipe)
|
|
3
|
+
// Work shown as a reel running through a projector. The stage pins on the opening frame: the
|
|
4
|
+
// section's display line and one line of copy on the left, the first project seated in the gate
|
|
5
|
+
// beside it, the next ones waiting to its right, a row of sprocket holes along both edges of the
|
|
6
|
+
// strip, the project's title large at the foot and a hairline progress line above it. Portrait
|
|
7
|
+
// frames hang high, landscape frames stand low, as on a contact sheet. As you scroll the reel
|
|
8
|
+
// advances one frame at a time: a short, exact move (strip, sprockets and progress line travel
|
|
9
|
+
// together), then a hold with the next frame seated in the gate, its photo settling out of a
|
|
10
|
+
// push-in as it lands. While a frame moves in, the old title leaves upward through its mask and
|
|
11
|
+
// the new one rises into it, so a stopped scroll always lands on one seated frame and one whole
|
|
12
|
+
// title. The last stop is the closing frame, the call to action; when the pin lets go the reel
|
|
13
|
+
// keeps running out to the left as the page moves on.
|
|
14
|
+
//
|
|
15
|
+
// The reel is one number (`pos`, the stop in the gate: 0 is the first project, n the closing
|
|
16
|
+
// frame). The strip's travel, every photo's push-in and drift, the sprockets, the titles in flight
|
|
17
|
+
// and the progress line are derived from it on every frame. The frames sit in normal flow, so the
|
|
18
|
+
// first paint is the opening frame exactly. The pin grows with the frames and stays under 300%.
|
|
19
|
+
// Lead for a studio, a photographer, an architect: any brief with a handful of projects to show.
|
|
20
|
+
//
|
|
21
|
+
// <FilmStrip intro={{ title: "Recent werk", text: "..." }} items={[{ image, title: "Casa Ostuni", meta: "Woonhuis, 2025" }]}
|
|
22
|
+
// outro={{ line: "Jouw huis is de volgende.", cta: { label: "Plan een gesprek", href: "/contact" } }} />
|
|
23
|
+
//
|
|
24
|
+
// Phones: no pin. The frames sit in a native horizontal swipe with scroll snap between the two
|
|
25
|
+
// sprocket rows, each title under its frame. Reduced motion, and pages without scripts: the same
|
|
26
|
+
// plain row, no scrub.
|
|
27
|
+
import Image from "next/image";
|
|
28
|
+
import { useRef } from "react";
|
|
29
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
30
|
+
import type { StruqImage } from "../kit/images";
|
|
31
|
+
|
|
32
|
+
type Shape = "portrait" | "landscape";
|
|
33
|
+
|
|
34
|
+
type Item = {
|
|
35
|
+
image: StruqImage;
|
|
36
|
+
/** The project's name, set at the foot while its frame is in the gate. */
|
|
37
|
+
title: string;
|
|
38
|
+
/** Metadata under the title: client or place, discipline, year. */
|
|
39
|
+
meta?: string;
|
|
40
|
+
/** Portrait frames hang high, landscape frames stand low. Defaults to the photo's own ratio. */
|
|
41
|
+
shape?: Shape;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type Props = {
|
|
45
|
+
items: Item[];
|
|
46
|
+
/** The section's display line (set at poster scale) and one line of copy, left of the first frame. */
|
|
47
|
+
intro: { title: string; text?: string };
|
|
48
|
+
/** The closing frame: one line and the call to action. */
|
|
49
|
+
outro?: { line: string; cta?: { label: string; href: string } };
|
|
50
|
+
className?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The push-in a photo settles out of as its frame seats in the gate. */
|
|
54
|
+
const PUSH = 1.18;
|
|
55
|
+
/** How far a photo drifts inside its frame as it passes the gate, as a share of the frame's width. */
|
|
56
|
+
const DRIFT = 0.05;
|
|
57
|
+
/** Units of the timeline: one move between stops, the hold on each seated stop. */
|
|
58
|
+
const MOVE = 1;
|
|
59
|
+
const HOLD = 0.75;
|
|
60
|
+
/** How far the reel runs on to the left as the pin lets go, as a share of the stage's width. */
|
|
61
|
+
const RUN_OUT = 0.45;
|
|
62
|
+
|
|
63
|
+
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
|
|
64
|
+
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
65
|
+
/** power2.inOut, for the titles' own swap inside a move. */
|
|
66
|
+
const inOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
|
|
67
|
+
const shapeOf = (item: Item): Shape =>
|
|
68
|
+
item.shape ?? (["4:5", "1:1"].includes(item.image.ratio) || item.image.height > item.image.width ? "portrait" : "landscape");
|
|
69
|
+
|
|
70
|
+
// One sprocket hole, as a mask: the row takes its colour from a token.
|
|
71
|
+
const SPROCKET =
|
|
72
|
+
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='20' viewBox='0 0 32 20'%3E%3Crect x='9' y='4' width='14' height='12' rx='2.5' fill='%23000'/%3E%3C/svg%3E\")";
|
|
73
|
+
|
|
74
|
+
/** The projector shows once the page runs scripts (struq.nl's pre-paint `sq-js`, a client project's `js`). */
|
|
75
|
+
const PROJECTOR = "hidden md:motion-safe:[:is(.js,.sq-js)_&]:block";
|
|
76
|
+
const STILL = "md:motion-safe:[:is(.js,.sq-js)_&]:hidden";
|
|
77
|
+
|
|
78
|
+
export function FilmStrip({ items, intro, outro, className = "" }: Props) {
|
|
79
|
+
const stage = useRef<HTMLElement>(null);
|
|
80
|
+
const n = items.length;
|
|
81
|
+
|
|
82
|
+
useGSAP(
|
|
83
|
+
() => {
|
|
84
|
+
const el = stage.current;
|
|
85
|
+
if (!el || n < 1) return;
|
|
86
|
+
const mm = gsap.matchMedia();
|
|
87
|
+
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
88
|
+
const root = el.querySelector<HTMLElement>("[data-projector]");
|
|
89
|
+
const foot = el.querySelector<HTMLElement>("[data-foot]");
|
|
90
|
+
const track = el.querySelector<HTMLElement>("[data-track]");
|
|
91
|
+
const fill = el.querySelector<HTMLElement>("[data-fill]");
|
|
92
|
+
if (!root || !root.offsetParent || !track || !fill) return;
|
|
93
|
+
const stops = Array.from(track.querySelectorAll<HTMLElement>("[data-stop]"));
|
|
94
|
+
const pushes = Array.from(track.querySelectorAll<HTMLElement>("[data-push]"));
|
|
95
|
+
const titles = Array.from(el.querySelectorAll<HTMLElement>("[data-title]"));
|
|
96
|
+
const metas = Array.from(el.querySelectorAll<HTMLElement>("[data-meta]"));
|
|
97
|
+
const sprockets = Array.from(el.querySelectorAll<HTMLElement>("[data-sprockets]"));
|
|
98
|
+
const last = stops.length - 1;
|
|
99
|
+
|
|
100
|
+
// Each stop's left edge in the track, measured from the first frame's (the gate).
|
|
101
|
+
const g = { at: [] as number[], fw: [] as number[], hole: 32, W: 1 };
|
|
102
|
+
const measure = () => {
|
|
103
|
+
const gate = stops[0].offsetLeft;
|
|
104
|
+
g.at = stops.map((s) => s.offsetLeft - gate);
|
|
105
|
+
g.fw = stops.map((s) => s.offsetWidth);
|
|
106
|
+
g.hole = parseFloat(getComputedStyle(el).fontSize) * 2 || 32;
|
|
107
|
+
g.W = el.clientWidth;
|
|
108
|
+
// The title stands under the gate, so the seated frame is right above its name.
|
|
109
|
+
if (foot) {
|
|
110
|
+
foot.style.left = `${gate}px`;
|
|
111
|
+
// Fit: every title on one line within the column under the gate, whatever the face. Measured at the
|
|
112
|
+
// natural size, then one shared size for all, so the swap never changes scale mid-reel.
|
|
113
|
+
foot.style.setProperty("--fs-fit", "1");
|
|
114
|
+
const room = foot.clientWidth;
|
|
115
|
+
const need = Math.max(0, ...titles.map((t) => t.scrollWidth));
|
|
116
|
+
if (room > 0 && need > room) foot.style.setProperty("--fs-fit", String(Math.floor((room / need) * 1000) / 1000)); }
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// `pos` is the stop in the gate; `out` is the run-out after the pin, both feeding one travel.
|
|
120
|
+
const s = { pos: 0, out: 0 };
|
|
121
|
+
const paint = () => {
|
|
122
|
+
const pos = s.pos;
|
|
123
|
+
const i = clamp(Math.floor(pos), 0, Math.max(0, last - 1));
|
|
124
|
+
const travel = (last > 0 ? lerp(g.at[i], g.at[i + 1], clamp(pos - i, 0, 1)) : 0) + s.out * RUN_OUT * g.W;
|
|
125
|
+
track.style.transform = `translate3d(${(-travel).toFixed(2)}px, 0, 0)`;
|
|
126
|
+
// Each photo settles out of its push-in as its frame seats, and drifts against the travel.
|
|
127
|
+
for (let j = 0; j < pushes.length; j += 1) {
|
|
128
|
+
const d = j - pos;
|
|
129
|
+
const k = lerp(1, PUSH, clamp(Math.abs(d), 0, 1));
|
|
130
|
+
const x = clamp(d, -1.2, 1.2) * DRIFT * (g.fw[j] || 0);
|
|
131
|
+
pushes[j].style.transform = `translate3d(${x.toFixed(2)}px, 0, 0) scale(${k.toFixed(4)})`;
|
|
132
|
+
}
|
|
133
|
+
const run = -(travel % g.hole);
|
|
134
|
+
for (const sp of sprockets) sp.style.transform = `translate3d(${run.toFixed(2)}px, 0, 0)`;
|
|
135
|
+
fill.style.transform = `scaleX(${(last > 0 ? pos / last : 1).toFixed(4)})`;
|
|
136
|
+
// The titles: within each move the old one leaves up and the new one rises into the mask, over the
|
|
137
|
+
// middle of the move, so both are whole whenever a stop is seated. The closing frame has none.
|
|
138
|
+
const swap = (x: number) => inOut(clamp((x - 0.25) / 0.5, 0, 1));
|
|
139
|
+
for (let j = 0; j < titles.length; j += 1) {
|
|
140
|
+
let y: number;
|
|
141
|
+
if (pos <= j - 1) y = 140;
|
|
142
|
+
else if (pos < j) y = 140 * (1 - swap(pos - (j - 1)));
|
|
143
|
+
else if (pos < j + 1) y = -140 * swap(pos - j);
|
|
144
|
+
else y = -140;
|
|
145
|
+
const t = `translate3d(0, ${y.toFixed(2)}%, 0)`;
|
|
146
|
+
titles[j].style.transform = t;
|
|
147
|
+
if (metas[j]) metas[j].style.transform = t;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
measure();
|
|
152
|
+
paint();
|
|
153
|
+
|
|
154
|
+
const tl = gsap.timeline({
|
|
155
|
+
defaults: { ease: "none" },
|
|
156
|
+
onUpdate: paint,
|
|
157
|
+
scrollTrigger: {
|
|
158
|
+
trigger: el,
|
|
159
|
+
start: "top top",
|
|
160
|
+
end: `+=${Math.min(300, 40 + 52 * last)}%`,
|
|
161
|
+
pin: true,
|
|
162
|
+
scrub: 1,
|
|
163
|
+
onRefresh: () => {
|
|
164
|
+
measure();
|
|
165
|
+
paint();
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
// A hold on the opening frame, then for each stop: the move, the hold. The last hold is the longest.
|
|
170
|
+
tl.to({}, { duration: 0.6 });
|
|
171
|
+
for (let j = 1; j <= last; j += 1) {
|
|
172
|
+
tl.to(s, { pos: j, duration: MOVE, ease: "power3.inOut" });
|
|
173
|
+
tl.to({}, { duration: j === last ? 1.1 : HOLD });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The release is a move: the reel keeps running out to the left as the page carries the stage away, the
|
|
177
|
+
// frames and the sprockets together, so the holes loop on and never end on screen.
|
|
178
|
+
const pin = tl.scrollTrigger!;
|
|
179
|
+
gsap.fromTo(
|
|
180
|
+
s,
|
|
181
|
+
{ out: 0 },
|
|
182
|
+
{
|
|
183
|
+
out: 1,
|
|
184
|
+
ease: "none",
|
|
185
|
+
immediateRender: false,
|
|
186
|
+
onUpdate: paint,
|
|
187
|
+
scrollTrigger: { start: () => pin.end, end: () => pin.end + el.offsetHeight, scrub: true },
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
return () => {
|
|
192
|
+
for (const node of [track, fill, ...pushes, ...titles, ...metas, ...sprockets]) node.style.transform = "";
|
|
193
|
+
if (foot) {
|
|
194
|
+
foot.style.left = "";
|
|
195
|
+
foot.style.removeProperty("--fs-fit");
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
return () => mm.revert();
|
|
200
|
+
},
|
|
201
|
+
{ scope: stage, dependencies: [n] },
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const holes = (
|
|
205
|
+
<div
|
|
206
|
+
aria-hidden
|
|
207
|
+
className="pointer-events-none absolute -left-16 -right-16 top-0 h-5 bg-line [-webkit-mask-repeat:repeat-x] [-webkit-mask-size:2em_1.25em] [mask-repeat:repeat-x] [mask-size:2em_1.25em]"
|
|
208
|
+
style={{ maskImage: SPROCKET, WebkitMaskImage: SPROCKET }}
|
|
209
|
+
/>
|
|
210
|
+
);
|
|
211
|
+
const sprocketRow = (moving: boolean, where: string) => (
|
|
212
|
+
<div aria-hidden className={`absolute inset-x-0 h-5 overflow-hidden ${where}`}>
|
|
213
|
+
<div data-sprockets={moving ? "" : undefined} className="absolute inset-0">
|
|
214
|
+
{holes}
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
const display = "font-display leading-[0.88] tracking-[-0.04em] text-ink";
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<section
|
|
222
|
+
ref={stage}
|
|
223
|
+
aria-label={intro.title}
|
|
224
|
+
data-section="film-strip"
|
|
225
|
+
className={`relative bg-surface-0 ${className}`}
|
|
226
|
+
style={
|
|
227
|
+
{
|
|
228
|
+
// The foot: the title (its size), its metadata and the margins; the strip takes what is left.
|
|
229
|
+
"--fs-t": "clamp(2.5rem, 0.9rem + 3.9vw, 6rem)",
|
|
230
|
+
"--fs-foot": "calc(var(--fs-t) * 1.04 + 4.6rem)",
|
|
231
|
+
"--fs-top": "max(6svh, 2.5rem)",
|
|
232
|
+
} as React.CSSProperties
|
|
233
|
+
}
|
|
234
|
+
>
|
|
235
|
+
{/* The projector (wide screens with motion, once scripts run). */}
|
|
236
|
+
<div data-projector="" className={`relative h-svh overflow-hidden ${PROJECTOR}`}>
|
|
237
|
+
{/* The strip: sprockets along both edges, the reel between them. */}
|
|
238
|
+
<div className="absolute inset-x-0 top-[var(--fs-top)] bottom-[calc(var(--fs-foot)+2.75rem)]">
|
|
239
|
+
{sprocketRow(true, "top-0")}
|
|
240
|
+
<div className="absolute inset-x-0 bottom-9 top-9">
|
|
241
|
+
<ul
|
|
242
|
+
data-track=""
|
|
243
|
+
className="m-0 flex h-full w-max list-none items-start gap-[5.5vw] p-0 pl-[5vw] pr-[20vw] will-change-transform"
|
|
244
|
+
>
|
|
245
|
+
{/* The intro panel: the section's display line at poster scale, and one line of copy. */}
|
|
246
|
+
<li className="flex h-full w-[40vw] shrink-0 flex-col justify-between">
|
|
247
|
+
<h2 className={`m-0 text-[clamp(4rem,9.5vw,11rem)] ${display}`}>{intro.title}</h2>
|
|
248
|
+
{intro.text ? <p className="m-0 max-w-[34ch] text-body text-ink-muted">{intro.text}</p> : null}
|
|
249
|
+
</li>
|
|
250
|
+
{items.map((item) => {
|
|
251
|
+
const portrait = shapeOf(item) === "portrait";
|
|
252
|
+
return (
|
|
253
|
+
<li
|
|
254
|
+
key={item.title}
|
|
255
|
+
data-stop=""
|
|
256
|
+
className={`relative shrink-0 overflow-hidden bg-surface-2 ${
|
|
257
|
+
portrait ? "aspect-[4/5] h-[min(92%,38vw)] self-start" : "aspect-[3/2] h-[min(70%,28vw)] self-end"
|
|
258
|
+
}`}
|
|
259
|
+
>
|
|
260
|
+
<div
|
|
261
|
+
data-push=""
|
|
262
|
+
className="absolute -inset-x-[7%] inset-y-0 will-change-transform"
|
|
263
|
+
style={{ transform: `scale(${PUSH})` }}
|
|
264
|
+
>
|
|
265
|
+
<Image
|
|
266
|
+
src={item.image.src}
|
|
267
|
+
alt={item.image.alt}
|
|
268
|
+
fill
|
|
269
|
+
sizes="45vw"
|
|
270
|
+
unoptimized={item.image.unoptimized}
|
|
271
|
+
className="object-cover"
|
|
272
|
+
/>
|
|
273
|
+
</div>
|
|
274
|
+
{/* The name the foot shows for this frame, for screen readers (the foot itself is a picture). */}
|
|
275
|
+
<p className="sr-only">
|
|
276
|
+
{item.title}
|
|
277
|
+
{item.meta ? `, ${item.meta}` : ""}
|
|
278
|
+
</p>
|
|
279
|
+
</li>
|
|
280
|
+
);
|
|
281
|
+
})}
|
|
282
|
+
{/* The closing frame: the call to action, seated in the gate at the last stop. */}
|
|
283
|
+
{outro ? (
|
|
284
|
+
<li data-stop="" className="flex w-[36vw] shrink-0 flex-col gap-8 self-center">
|
|
285
|
+
<p className={`m-0 text-[clamp(2.75rem,1rem_+_4.4vw,7rem)] ${display}`}>{outro.line}</p>
|
|
286
|
+
{outro.cta ? (
|
|
287
|
+
<a
|
|
288
|
+
href={outro.cta.href}
|
|
289
|
+
className="w-fit border-b border-ink pb-1 text-lead text-ink transition-[border-color] duration-500 [transition-timing-function:var(--ease-out-expo)] hover:border-accent"
|
|
290
|
+
>
|
|
291
|
+
{outro.cta.label}
|
|
292
|
+
</a>
|
|
293
|
+
) : null}
|
|
294
|
+
</li>
|
|
295
|
+
) : null}
|
|
296
|
+
</ul>
|
|
297
|
+
</div>
|
|
298
|
+
{sprocketRow(true, "bottom-0")}
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
{/* The progress line, the length of the reel's run. */}
|
|
302
|
+
<div aria-hidden className="absolute inset-x-[5vw] bottom-[calc(var(--fs-foot)+1.25rem)] h-px bg-line">
|
|
303
|
+
<span data-fill="" className="absolute inset-0 origin-left bg-ink" style={{ transform: "scaleX(0)" }} />
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
{/* The title of the frame in the gate, and its metadata, each in its own mask, standing under the gate
|
|
307
|
+
(where the first frame's left edge is: 5vw, the intro's 40vw and one gap, until measured). */}
|
|
308
|
+
<div data-foot="" aria-hidden className="absolute bottom-10 left-[50.5vw] right-[5vw]">
|
|
309
|
+
<p className={`relative m-0 overflow-hidden pb-[0.14em] text-[length:calc(var(--fs-t)*var(--fs-fit,1))] ${display}`}>
|
|
310
|
+
{items.map((item, j) => (
|
|
311
|
+
<span
|
|
312
|
+
key={item.title}
|
|
313
|
+
data-title=""
|
|
314
|
+
className={`block whitespace-nowrap will-change-transform ${j === 0 ? "relative" : "absolute inset-x-0 top-0"}`}
|
|
315
|
+
style={{ transform: j === 0 ? "translate3d(0, 0%, 0)" : "translate3d(0, 140%, 0)" }}
|
|
316
|
+
>
|
|
317
|
+
{item.title}
|
|
318
|
+
</span>
|
|
319
|
+
))}
|
|
320
|
+
</p>
|
|
321
|
+
<p className="relative m-0 mt-3 overflow-hidden text-small text-ink-muted">
|
|
322
|
+
{items.map((item, j) => (
|
|
323
|
+
<span
|
|
324
|
+
key={item.title}
|
|
325
|
+
data-meta=""
|
|
326
|
+
className={`block whitespace-nowrap ${j === 0 ? "relative" : "absolute inset-x-0 top-0"}`}
|
|
327
|
+
style={{ transform: j === 0 ? "translate3d(0, 0%, 0)" : "translate3d(0, 140%, 0)" }}
|
|
328
|
+
>
|
|
329
|
+
{item.meta ?? " "}
|
|
330
|
+
</span>
|
|
331
|
+
))}
|
|
332
|
+
</p>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
{/* The plain reel: phones swipe it natively, frame by frame; reduced motion and pages without scripts
|
|
337
|
+
show it still. Still film: the sprocket rows run above and below it. */}
|
|
338
|
+
<div className={`py-14 md:py-[10svh] ${STILL}`}>
|
|
339
|
+
<div className="px-6 md:px-[5vw]">
|
|
340
|
+
<h2 className={`m-0 text-[clamp(3.5rem,15vw,11rem)] md:text-[clamp(4rem,9.5vw,11rem)] ${display}`}>{intro.title}</h2>
|
|
341
|
+
{intro.text ? <p className="m-0 mt-6 max-w-[34ch] text-body text-ink-muted">{intro.text}</p> : null}
|
|
342
|
+
</div>
|
|
343
|
+
<div className="relative mt-10 py-9">
|
|
344
|
+
{sprocketRow(false, "top-0")}
|
|
345
|
+
<ul className="m-0 flex list-none snap-x snap-mandatory scroll-px-6 items-start gap-5 overflow-x-auto px-6 [scrollbar-width:none] md:scroll-px-[5vw] md:gap-[3vw] md:px-[5vw] [&::-webkit-scrollbar]:hidden">
|
|
346
|
+
{items.map((item) => {
|
|
347
|
+
const portrait = shapeOf(item) === "portrait";
|
|
348
|
+
return (
|
|
349
|
+
<li key={item.title} className="shrink-0 snap-start">
|
|
350
|
+
<div
|
|
351
|
+
className={`relative overflow-hidden bg-surface-2 ${
|
|
352
|
+
portrait ? "aspect-[4/5] h-[min(62vw,58svh)] md:h-[30vw]" : "aspect-[3/2] h-[min(54vw,48svh)] md:h-[24vw]"
|
|
353
|
+
}`}
|
|
354
|
+
>
|
|
355
|
+
<Image
|
|
356
|
+
src={item.image.src}
|
|
357
|
+
alt={item.image.alt}
|
|
358
|
+
fill
|
|
359
|
+
sizes="(min-width: 768px) 36vw, 80vw"
|
|
360
|
+
unoptimized={item.image.unoptimized}
|
|
361
|
+
className="object-cover"
|
|
362
|
+
/>
|
|
363
|
+
</div>
|
|
364
|
+
<p className="m-0 mt-4 font-display text-h3 text-ink">{item.title}</p>
|
|
365
|
+
{item.meta ? <p className="m-0 mt-1 text-small text-ink-muted">{item.meta}</p> : null}
|
|
366
|
+
</li>
|
|
367
|
+
);
|
|
368
|
+
})}
|
|
369
|
+
</ul>
|
|
370
|
+
{sprocketRow(false, "bottom-0")}
|
|
371
|
+
</div>
|
|
372
|
+
{outro ? (
|
|
373
|
+
<div className="mt-12 flex flex-col gap-6 px-6 md:px-[5vw]">
|
|
374
|
+
<p className={`m-0 max-w-[16ch] text-h2 ${display}`}>{outro.line}</p>
|
|
375
|
+
{outro.cta ? (
|
|
376
|
+
<a href={outro.cta.href} className="w-fit border-b border-ink pb-1 text-lead text-ink">
|
|
377
|
+
{outro.cta.label}
|
|
378
|
+
</a>
|
|
379
|
+
) : null}
|
|
380
|
+
</div>
|
|
381
|
+
) : null}
|
|
382
|
+
</div>
|
|
383
|
+
</section>
|
|
384
|
+
);
|
|
385
|
+
}
|