@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.
Files changed (129) hide show
  1. package/README.md +22 -36
  2. package/package.json +3 -2
  3. package/skills/struq/SKILL.md +139 -307
  4. package/skills/struq/blocks/CATALOG.md +426 -0
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
  8. package/skills/struq/blocks/catalog.json +1302 -0
  9. package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
  10. package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
  11. package/skills/struq/blocks/headers/header-bar.tsx +652 -0
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
  13. package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
  14. package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
  15. package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
  16. package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
  17. package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
  18. package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
  19. package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +2 -2
  20. package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
  21. package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
  22. package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
  23. package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
  24. package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
  25. package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
  26. package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/images.ts +17 -0
  29. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  30. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  31. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  32. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  33. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  34. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  35. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  36. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  37. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  38. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  39. package/skills/struq/blocks/sections/approach.tsx +661 -0
  40. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  41. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  42. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  43. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  44. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  45. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  46. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  47. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  48. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  49. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  50. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  51. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  52. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  53. package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
  54. package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
  55. package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
  56. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  57. package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
  58. package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
  59. package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
  60. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  61. package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
  62. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  63. package/skills/struq/canon/canon.json +627 -0
  64. package/skills/struq/cinematic/README.md +7 -18
  65. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  66. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  67. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  68. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  69. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  70. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  71. package/skills/struq/recipes/app/fonts.ts +12 -22
  72. package/skills/struq/recipes/app/globals.css +34 -177
  73. package/skills/struq/recipes/app/layout.tsx +13 -11
  74. package/skills/struq/references/craft.md +77 -0
  75. package/skills/struq/references/iconography.md +28 -230
  76. package/skills/struq/references/stack-targets.md +23 -74
  77. package/skills/struq/references/tells.md +52 -0
  78. package/skills/struq/references/triage.md +6 -7
  79. package/skills/struq/scripts/blocks.mjs +156 -0
  80. package/skills/struq/scripts/canon.mjs +106 -281
  81. package/skills/struq/scripts/check.mjs +92 -126
  82. package/skills/struq/scripts/images.mjs +2 -2
  83. package/skills/struq/scripts/log.mjs +6 -23
  84. package/skills/struq/scripts/tokens.mjs +222 -0
  85. package/skills/struq/cinematic/showpieces/README.md +0 -18
  86. package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
  87. package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
  88. package/skills/struq/references/anti-patterns.md +0 -330
  89. package/skills/struq/references/baseline-prompt.md +0 -122
  90. package/skills/struq/references/brand.md +0 -143
  91. package/skills/struq/references/brief-router.md +0 -218
  92. package/skills/struq/references/composition.md +0 -190
  93. package/skills/struq/references/depth-parallax.md +0 -235
  94. package/skills/struq/references/direction.md +0 -188
  95. package/skills/struq/references/feel-layer.md +0 -107
  96. package/skills/struq/references/gsap-api.md +0 -293
  97. package/skills/struq/references/hero-scenes.md +0 -107
  98. package/skills/struq/references/imagery.md +0 -125
  99. package/skills/struq/references/loaders.md +0 -287
  100. package/skills/struq/references/micro-interactions.md +0 -270
  101. package/skills/struq/references/mobile-composition.md +0 -70
  102. package/skills/struq/references/nav-header.md +0 -296
  103. package/skills/struq/references/niche-packets.md +0 -338
  104. package/skills/struq/references/page-architecture.md +0 -134
  105. package/skills/struq/references/page-entrances.md +0 -198
  106. package/skills/struq/references/performance.md +0 -235
  107. package/skills/struq/references/pinning-scrub.md +0 -340
  108. package/skills/struq/references/pitfalls.md +0 -290
  109. package/skills/struq/references/route-transitions.md +0 -281
  110. package/skills/struq/references/scaling.md +0 -151
  111. package/skills/struq/references/scroll-reveals.md +0 -279
  112. package/skills/struq/references/sections.md +0 -141
  113. package/skills/struq/references/seo.md +0 -219
  114. package/skills/struq/references/setup.md +0 -397
  115. package/skills/struq/references/showcase.md +0 -87
  116. package/skills/struq/references/struq-imagery.md +0 -107
  117. package/skills/struq/references/svg-motion.md +0 -390
  118. package/skills/struq/references/transitions.md +0 -160
  119. package/skills/struq/references/typography.md +0 -720
  120. package/skills/struq/references/vibe-presets.md +0 -287
  121. package/skills/struq/references/webgl-r3f.md +0 -274
  122. package/skills/struq/references/webgl-scenes.md +0 -593
  123. package/skills/struq/scripts/font-names.mjs +0 -40
  124. package/skills/struq/scripts/recipes.mjs +0 -135
  125. /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
  126. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
  127. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
  128. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
  129. /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
+ }