@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,380 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// panel-accordion (Struq section recipe)
|
|
3
|
+
// A full-height row of tall photo panels, like the shutters of a gallery. Each panel is a narrow window onto its
|
|
4
|
+
// photograph with the project's name at its foot; hovering or focusing one opens it wide while the others narrow, and
|
|
5
|
+
// moving across them flows open from one to the next with weight. Opening only widens the window: the photograph is
|
|
6
|
+
// already full size behind it, centred on the window, and never rescales. As the window opens, its small name sinks
|
|
7
|
+
// out of a mask and the name in the display face rises in with a short line. On scroll into view the panels rise
|
|
8
|
+
// from their bottom edge one after another, and then the first one opens on its own.
|
|
9
|
+
//
|
|
10
|
+
// <PanelAccordion title="Vijf huizen, één hand." items={[{ image, title: "Cipressenhof", line: "Woonhuis, Lucca, 2025", href: "/werk/cipressenhof" }, ...]} />
|
|
11
|
+
//
|
|
12
|
+
// How it moves: every panel has one number, its openness (they always add up to one), and on every frame each window
|
|
13
|
+
// is written from those numbers in whole pixels: a translate for where it sits and a clip for how wide it is. No
|
|
14
|
+
// width or flex-basis is ever animated, so nothing is laid out while it moves. A new hover simply retargets the one
|
|
15
|
+
// tween from wherever the panels are, so an open that is overtaken turns back instead of starting over.
|
|
16
|
+
// Every photograph loads at once and is decoded before the panels can arrive.
|
|
17
|
+
//
|
|
18
|
+
// Keyboard: each panel is a link, and focus opens it. Phones: the panels stack as short rows of the same windows;
|
|
19
|
+
// a tap opens a row, a tap on the open row follows its link. Reduced motion: opens are hard cuts and the panels are
|
|
20
|
+
// simply there. Without scripts: the panels at equal size with their names, each a working link.
|
|
21
|
+
import Image from "next/image";
|
|
22
|
+
import { useRef } from "react";
|
|
23
|
+
import { ScrollTrigger, gsap, useGSAP } from "../kit/gsap";
|
|
24
|
+
import type { StruqImage } from "../kit/images";
|
|
25
|
+
|
|
26
|
+
type Item = {
|
|
27
|
+
image: StruqImage;
|
|
28
|
+
/** The project's name. */
|
|
29
|
+
title: string;
|
|
30
|
+
/** One short line beside the name when the panel is open: what and where. */
|
|
31
|
+
line?: string;
|
|
32
|
+
href: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type Props = {
|
|
36
|
+
/** The section's heading. */
|
|
37
|
+
title?: string;
|
|
38
|
+
/** Three to six projects; five reads best. */
|
|
39
|
+
items: Item[];
|
|
40
|
+
className?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const T = {
|
|
44
|
+
open: 0.85, // one panel opening while the others narrow
|
|
45
|
+
arrive: 1.0, // one panel rising from its bottom edge
|
|
46
|
+
stagger: 0.09,
|
|
47
|
+
first: 0.2, // the beat before the first panel opens on its own
|
|
48
|
+
openShare: 0.5, // the open panel's share of the row on wide screens
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
/** 0 below `a`, 1 above `b`, smooth between. */
|
|
52
|
+
function smooth(x: number, a: number, b: number) {
|
|
53
|
+
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
|
|
54
|
+
return t * t * (3 - 2 * t);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A colour as sRGB 0 to 1, whatever syntax the palette writes it in (the canvas does the conversion). */
|
|
58
|
+
function toRgb(ctx: CanvasRenderingContext2D, color: string): [number, number, number] {
|
|
59
|
+
ctx.clearRect(0, 0, 1, 1);
|
|
60
|
+
ctx.fillStyle = "#000";
|
|
61
|
+
ctx.fillStyle = color;
|
|
62
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
63
|
+
const d = ctx.getImageData(0, 0, 1, 1).data;
|
|
64
|
+
return [d[0] / 255, d[1] / 255, d[2] / 255];
|
|
65
|
+
}
|
|
66
|
+
function luminance([r, g, b]: [number, number, number]) {
|
|
67
|
+
const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
68
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The focus line is the accent when it holds 3:1 against the band's ground; in the few palettes where it does not (a
|
|
73
|
+
* light ochre or coral on cream), it is the accent mixed toward the ink just far enough to hold it.
|
|
74
|
+
*/
|
|
75
|
+
function focusInk(el: HTMLElement) {
|
|
76
|
+
try {
|
|
77
|
+
const cs = getComputedStyle(el);
|
|
78
|
+
const accent = cs.getPropertyValue("--accent").trim();
|
|
79
|
+
const ground = cs.getPropertyValue("--surface-0").trim();
|
|
80
|
+
const ink = cs.getPropertyValue("--ink").trim();
|
|
81
|
+
if (!accent || !ground || !ink) return;
|
|
82
|
+
const cv = document.createElement("canvas");
|
|
83
|
+
cv.width = 1;
|
|
84
|
+
cv.height = 1;
|
|
85
|
+
const ctx = cv.getContext("2d", { willReadFrequently: true });
|
|
86
|
+
if (!ctx) return;
|
|
87
|
+
const g = luminance(toRgb(ctx, ground));
|
|
88
|
+
const ratio = (c: string) => {
|
|
89
|
+
const l = luminance(toRgb(ctx, c));
|
|
90
|
+
return (Math.max(l, g) + 0.05) / (Math.min(l, g) + 0.05);
|
|
91
|
+
};
|
|
92
|
+
for (let k = 0; k <= 100; k += 10) {
|
|
93
|
+
const c = k === 0 ? accent : `color-mix(in oklch, ${accent}, ${ink} ${k}%)`;
|
|
94
|
+
if (ratio(c) >= 3) {
|
|
95
|
+
if (k > 0) el.style.setProperty("--focus", c);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// The accent stays.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function PanelAccordion({ title, items, className = "" }: Props) {
|
|
105
|
+
const root = useRef<HTMLElement>(null);
|
|
106
|
+
|
|
107
|
+
useGSAP(
|
|
108
|
+
() => {
|
|
109
|
+
const el = root.current;
|
|
110
|
+
if (!el) return;
|
|
111
|
+
const track = el.querySelector("[data-track]") as HTMLElement;
|
|
112
|
+
const panels = Array.from(el.querySelectorAll<HTMLAnchorElement>("[data-panel]"));
|
|
113
|
+
const n = panels.length;
|
|
114
|
+
if (n < 2) return;
|
|
115
|
+
const caps = panels.map((p) => p.querySelector("[data-cap]") as HTMLElement);
|
|
116
|
+
const smalls = panels.map((p) => p.querySelector("[data-small]") as HTMLElement);
|
|
117
|
+
const bigs = panels.map((p) => p.querySelector("[data-big]") as HTMLElement);
|
|
118
|
+
const names = panels.map((p) => p.querySelector("[data-name]") as HTMLElement);
|
|
119
|
+
const imgs = panels.map((p) => p.querySelector("img") as HTMLImageElement);
|
|
120
|
+
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
121
|
+
const phoneQuery = window.matchMedia("(max-width: 767px)");
|
|
122
|
+
const touch = window.matchMedia("(hover: none)").matches;
|
|
123
|
+
|
|
124
|
+
// The numbers everything is drawn from.
|
|
125
|
+
const open: Record<string, number> = {};
|
|
126
|
+
panels.forEach((_, i) => (open[`a${i}`] = 1 / n));
|
|
127
|
+
const arrive = panels.map(() => ({ v: reduce ? 0 : 1 })); // 1: below its bottom edge, 0: arrived
|
|
128
|
+
let current = -1;
|
|
129
|
+
let dirty = true;
|
|
130
|
+
const mark = () => {
|
|
131
|
+
dirty = true;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Geometry, measured on every resize. On wide screens the windows run along x, on phones along y.
|
|
135
|
+
let phone = false;
|
|
136
|
+
let along = 0; // the track's length along the row
|
|
137
|
+
let across = 0; // the panels' size across the row
|
|
138
|
+
let big = 0; // the open window's length
|
|
139
|
+
let small = 0; // a narrow window's length
|
|
140
|
+
let gap = 8;
|
|
141
|
+
let capS = 0; // the band on a narrow panel
|
|
142
|
+
let capB: number[] = []; // the band on the open panel, per panel
|
|
143
|
+
const measure = () => {
|
|
144
|
+
phone = phoneQuery.matches;
|
|
145
|
+
gap = phone ? 6 : 8;
|
|
146
|
+
if (phone) {
|
|
147
|
+
across = track.clientWidth;
|
|
148
|
+
big = Math.round(Math.min(window.innerHeight * 0.52, across * 1.2));
|
|
149
|
+
small = 104;
|
|
150
|
+
along = big + (n - 1) * (small + gap);
|
|
151
|
+
track.style.height = `${along}px`;
|
|
152
|
+
} else {
|
|
153
|
+
track.style.height = "";
|
|
154
|
+
along = track.clientWidth;
|
|
155
|
+
across = track.clientHeight;
|
|
156
|
+
big = Math.round(along * T.openShare);
|
|
157
|
+
small = (along - big - (n - 1) * gap) / (n - 1);
|
|
158
|
+
}
|
|
159
|
+
panels.forEach((p) => {
|
|
160
|
+
Object.assign(p.style, {
|
|
161
|
+
position: "absolute",
|
|
162
|
+
left: "0px",
|
|
163
|
+
top: "0px",
|
|
164
|
+
flex: "none",
|
|
165
|
+
width: `${phone ? across : big}px`,
|
|
166
|
+
height: `${phone ? big : across}px`,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
caps.forEach((c) => Object.assign(c.style, { top: "0px", bottom: "auto" }));
|
|
170
|
+
// The open name at display size, fitted to the open window whatever the face.
|
|
171
|
+
const pad = phone ? 32 : 40;
|
|
172
|
+
const room = (phone ? across : big) - pad;
|
|
173
|
+
names.forEach((nm) => {
|
|
174
|
+
nm.style.fontSize = "";
|
|
175
|
+
const w = nm.scrollWidth;
|
|
176
|
+
if (w > room) {
|
|
177
|
+
const fs = parseFloat(getComputedStyle(nm).fontSize) || 16;
|
|
178
|
+
nm.style.fontSize = `${Math.floor(((fs * room) / w) * 10) / 10}px`;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
// A narrow panel shows only the top of its band (the small name); the open one shows the whole band. Both
|
|
182
|
+
// reach a pixel past the window's foot, so the clip, never the band's own edge, ends it.
|
|
183
|
+
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
184
|
+
capS = Math.round(rem * (phone ? 3.25 : 4.75));
|
|
185
|
+
capB = caps.map((c) => Math.max(capS, Math.floor(c.getBoundingClientRect().height) - 1));
|
|
186
|
+
mark();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const paint = () => {
|
|
190
|
+
let pos = 0;
|
|
191
|
+
panels.forEach((p, i) => {
|
|
192
|
+
const a = open[`a${i}`];
|
|
193
|
+
const size = small + a * (big - small);
|
|
194
|
+
// The window's two edges in whole pixels, and the element placed so the photo stays centred on it.
|
|
195
|
+
const lead = Math.round(pos);
|
|
196
|
+
const end = Math.round(pos + size);
|
|
197
|
+
pos += size + gap;
|
|
198
|
+
const at = Math.round((lead + end) / 2 - big / 2);
|
|
199
|
+
const before = lead - at;
|
|
200
|
+
const after = at + big - end;
|
|
201
|
+
const rise = arrive[i].v;
|
|
202
|
+
// The band grows from the small name's height to the open name's as the panel opens.
|
|
203
|
+
const capH = Math.round(capS + ((capB[i] ?? capS) - capS) * smooth(a, 0.3, 0.62));
|
|
204
|
+
if (phone) {
|
|
205
|
+
const top = before + Math.round(rise * (end - lead));
|
|
206
|
+
p.style.transform = `translate3d(0, ${at}px, 0)`;
|
|
207
|
+
p.style.clipPath = `inset(${top}px 0px ${after}px 0px)`;
|
|
208
|
+
caps[i].style.transform = `translate3d(0, ${big - after - capH}px, 0)`;
|
|
209
|
+
} else {
|
|
210
|
+
const top = Math.round(rise * across);
|
|
211
|
+
p.style.transform = `translate3d(${at}px, 0, 0)`;
|
|
212
|
+
p.style.clipPath = `inset(${top}px ${after}px 0px ${before}px)`;
|
|
213
|
+
caps[i].style.transform = `translate3d(${before}px, ${across - capH}px, 0)`;
|
|
214
|
+
}
|
|
215
|
+
// The two names swap as one roll in the same window of openness, so between them one whole name always stands:
|
|
216
|
+
// as much of the display name rises in as the small one sinks out. A closing panel runs the same in reverse.
|
|
217
|
+
const roll = smooth(a, 0.36, 0.62);
|
|
218
|
+
smalls[i].style.transform = `translate3d(0, ${roll * 115}%, 0)`;
|
|
219
|
+
bigs[i].style.transform = `translate3d(0, ${(1 - roll) * 115}%, 0)`;
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
let tween: gsap.core.Tween | null = null;
|
|
224
|
+
const openTo = (i: number) => {
|
|
225
|
+
if (i === current) return;
|
|
226
|
+
current = i;
|
|
227
|
+
panels.forEach((p, k) => p.toggleAttribute("data-open", k === i));
|
|
228
|
+
const target: Record<string, number> = {};
|
|
229
|
+
panels.forEach((_, k) => (target[`a${k}`] = k === i ? 1 : 0));
|
|
230
|
+
tween?.kill();
|
|
231
|
+
if (reduce) {
|
|
232
|
+
Object.assign(open, target);
|
|
233
|
+
mark();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
// From wherever the panels are now: an overtaken open turns back, it never restarts.
|
|
237
|
+
tween = gsap.to(open, { ...target, duration: T.open, ease: "power3.out", onUpdate: mark });
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// One draw per frame, after the tweens have moved, and only when something changed.
|
|
241
|
+
const tick = () => {
|
|
242
|
+
if (!dirty) return;
|
|
243
|
+
dirty = false;
|
|
244
|
+
paint();
|
|
245
|
+
};
|
|
246
|
+
el.dataset.motion = "";
|
|
247
|
+
measure();
|
|
248
|
+
paint();
|
|
249
|
+
// The display name is fitted with the real face, so measure again once it has landed.
|
|
250
|
+
document.fonts?.ready.then(() => {
|
|
251
|
+
if (el.isConnected) measure();
|
|
252
|
+
});
|
|
253
|
+
focusInk(el);
|
|
254
|
+
gsap.ticker.add(tick);
|
|
255
|
+
|
|
256
|
+
// Every photograph is loaded and decoded before the panels may arrive.
|
|
257
|
+
const decoded = Promise.all(imgs.map((img) => img?.decode().catch(() => undefined)));
|
|
258
|
+
let arrived = reduce;
|
|
259
|
+
const land = () => {
|
|
260
|
+
if (arrived) return;
|
|
261
|
+
arrived = true;
|
|
262
|
+
decoded.then(() => {
|
|
263
|
+
gsap.to(arrive, {
|
|
264
|
+
v: 0,
|
|
265
|
+
duration: T.arrive,
|
|
266
|
+
ease: "expo.out",
|
|
267
|
+
stagger: T.stagger,
|
|
268
|
+
onUpdate: mark,
|
|
269
|
+
onComplete: () => {
|
|
270
|
+
gsap.delayedCall(T.first, () => {
|
|
271
|
+
if (current === -1) openTo(0);
|
|
272
|
+
});
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
if (reduce) openTo(0);
|
|
278
|
+
const trigger = ScrollTrigger.create({ trigger: track, start: "top 78%", once: true, onEnter: land });
|
|
279
|
+
|
|
280
|
+
const onEnter = (e: PointerEvent) => {
|
|
281
|
+
if (e.pointerType === "touch") return;
|
|
282
|
+
openTo(panels.indexOf(e.currentTarget as HTMLAnchorElement));
|
|
283
|
+
};
|
|
284
|
+
const onFocus = (e: FocusEvent) => openTo(panels.indexOf(e.currentTarget as HTMLAnchorElement));
|
|
285
|
+
// A tap opens a closed panel first; a tap on the open one follows its link.
|
|
286
|
+
const onClick = (e: MouseEvent) => {
|
|
287
|
+
const i = panels.indexOf(e.currentTarget as HTMLAnchorElement);
|
|
288
|
+
if ((touch || phone) && i !== current) {
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
openTo(i);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
panels.forEach((p) => {
|
|
294
|
+
p.addEventListener("pointerenter", onEnter);
|
|
295
|
+
p.addEventListener("focus", onFocus);
|
|
296
|
+
p.addEventListener("click", onClick);
|
|
297
|
+
});
|
|
298
|
+
const resize = new ResizeObserver(() => measure());
|
|
299
|
+
resize.observe(track);
|
|
300
|
+
phoneQuery.addEventListener("change", measure);
|
|
301
|
+
|
|
302
|
+
return () => {
|
|
303
|
+
trigger.kill();
|
|
304
|
+
tween?.kill();
|
|
305
|
+
gsap.ticker.remove(tick);
|
|
306
|
+
resize.disconnect();
|
|
307
|
+
phoneQuery.removeEventListener("change", measure);
|
|
308
|
+
panels.forEach((p) => {
|
|
309
|
+
p.removeEventListener("pointerenter", onEnter);
|
|
310
|
+
p.removeEventListener("focus", onFocus);
|
|
311
|
+
p.removeEventListener("click", onClick);
|
|
312
|
+
p.removeAttribute("style");
|
|
313
|
+
p.removeAttribute("data-open");
|
|
314
|
+
});
|
|
315
|
+
caps.forEach((c) => c.removeAttribute("style"));
|
|
316
|
+
track.style.height = "";
|
|
317
|
+
delete el.dataset.motion;
|
|
318
|
+
el.style.removeProperty("--focus");
|
|
319
|
+
names.forEach((nm) => (nm.style.fontSize = ""));
|
|
320
|
+
};
|
|
321
|
+
},
|
|
322
|
+
{ scope: root },
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
return (
|
|
326
|
+
<section
|
|
327
|
+
ref={root}
|
|
328
|
+
data-panel-accordion=""
|
|
329
|
+
className={`flex min-h-svh flex-col bg-surface-0 px-6 py-[7vh] text-ink md:px-[4vw] ${className}`}
|
|
330
|
+
>
|
|
331
|
+
{title ? <h2 className="font-display text-h2 mb-[5vh] max-w-[16ch]">{title}</h2> : null}
|
|
332
|
+
<div data-track="" className="relative flex flex-1 flex-col gap-1.5 md:min-h-[60svh] md:flex-row md:gap-2">
|
|
333
|
+
{items.map((item, i) => (
|
|
334
|
+
<a
|
|
335
|
+
key={item.href + i}
|
|
336
|
+
href={item.href}
|
|
337
|
+
data-panel=""
|
|
338
|
+
className="group relative block h-[26svh] overflow-hidden outline-none md:h-auto md:flex-1"
|
|
339
|
+
>
|
|
340
|
+
<Image
|
|
341
|
+
src={item.image.src}
|
|
342
|
+
alt={item.image.alt}
|
|
343
|
+
fill
|
|
344
|
+
loading="eager"
|
|
345
|
+
sizes="(min-width: 768px) 50vw, 100vw"
|
|
346
|
+
unoptimized={item.image.unoptimized}
|
|
347
|
+
className="object-cover"
|
|
348
|
+
/>
|
|
349
|
+
{/* The name on the page ground at the window's foot; focus draws the accent along its top. The band is as
|
|
350
|
+
tall as the open name needs and reaches a pixel past the window on each side, so the clip, never the
|
|
351
|
+
band's own edge, ends it; only its top part shows on a narrow panel, and it grows as the panel opens. */}
|
|
352
|
+
<div
|
|
353
|
+
data-cap=""
|
|
354
|
+
className="absolute bottom-0 left-[-1px] w-[calc(100%+2px)] bg-surface-0 group-focus-visible:shadow-[inset_0_2px_0_var(--focus,var(--accent))]"
|
|
355
|
+
>
|
|
356
|
+
<div className="grid items-start px-4 pb-4 pt-3 md:px-5 md:pb-5 md:pt-4">
|
|
357
|
+
<span className="block overflow-hidden [grid-area:1/1]">
|
|
358
|
+
<span data-small="" className="text-small block whitespace-nowrap">
|
|
359
|
+
{item.title}
|
|
360
|
+
</span>
|
|
361
|
+
</span>
|
|
362
|
+
<span className="block overflow-hidden pb-[0.14em] [grid-area:1/1]">
|
|
363
|
+
<span data-big="" className="flex flex-col items-start gap-2 md:gap-3" style={{ transform: "translateY(115%)" }}>
|
|
364
|
+
<span
|
|
365
|
+
data-name=""
|
|
366
|
+
className="font-display text-h3 block whitespace-nowrap leading-[0.95] tracking-[-0.02em] md:text-[clamp(2.5rem,1rem_+_3.6vw,5.5rem)]"
|
|
367
|
+
>
|
|
368
|
+
{item.title}
|
|
369
|
+
</span>
|
|
370
|
+
{item.line ? <span className="text-small text-ink-muted whitespace-nowrap max-md:hidden">{item.line}</span> : null}
|
|
371
|
+
</span>
|
|
372
|
+
</span>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
</a>
|
|
376
|
+
))}
|
|
377
|
+
</div>
|
|
378
|
+
</section>
|
|
379
|
+
);
|
|
380
|
+
}
|