@struq/skill 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -36
- package/package.json +4 -2
- package/skills/struq/SKILL.md +139 -307
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +4 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
- package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
- package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -22
- package/skills/struq/recipes/app/globals.css +34 -177
- package/skills/struq/recipes/app/layout.tsx +13 -11
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +106 -281
- package/skills/struq/scripts/check.mjs +92 -126
- package/skills/struq/scripts/images.mjs +2 -2
- package/skills/struq/scripts/log.mjs +6 -23
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/cinematic/showpieces/README.md +0 -18
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
- package/skills/struq/references/anti-patterns.md +0 -330
- package/skills/struq/references/baseline-prompt.md +0 -122
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -218
- package/skills/struq/references/composition.md +0 -190
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/hero-scenes.md +0 -107
- package/skills/struq/references/imagery.md +0 -125
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/niche-packets.md +0 -338
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -340
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -279
- package/skills/struq/references/sections.md +0 -141
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
- package/skills/struq/scripts/font-names.mjs +0 -40
- package/skills/struq/scripts/recipes.mjs +0 -135
- /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// menu-curtain (Struq header recipe): the bar, and the full-screen menu it opens as a curtain
|
|
3
|
+
// A minimal bar: the wordmark on the left, one button on the right that reads "Menu" and rolls to "Sluiten". The menu
|
|
4
|
+
// opens as a curtain in the language of one of the page transitions, the links rise in a stagger through their masks,
|
|
5
|
+
// the hovered or focused link shows its photo on the menu's calm side, and a foot line carries the contact line and
|
|
6
|
+
// the studio's local time. It closes the same way, reversed: every part moves from where it is to where it is going,
|
|
7
|
+
// so a close overtaken by an open turns round instead of starting over.
|
|
8
|
+
//
|
|
9
|
+
// gate a line draws from the middle of the screen and the menu opens from it, two edges parting up and down;
|
|
10
|
+
// the photo opens from its own middle line, the gate's line, as the links are hovered.
|
|
11
|
+
// slices the menu falls in vertical slices, left to right, each led by a 5px accent edge; the photo stands in the
|
|
12
|
+
// last slice as a tall print and falls with it, and a new photo falls over the last one on hover.
|
|
13
|
+
// stack three panels grow one after another from the button, the last one the menu; a small portrait frame
|
|
14
|
+
// appears on a hard cut, flips through a few prints and then cuts to the hovered link's photo.
|
|
15
|
+
//
|
|
16
|
+
// The bar turns its ink with what is under it, exactly at the edge: it is drawn three times (ink for the page ground,
|
|
17
|
+
// on-photo, on-inverse), each copy clipped to the stretch of the bar its own sections cover. Sections opt in with
|
|
18
|
+
// data-bar-tone="photo" or data-bar-tone="inverse"; anything else is ground. The curtain carries its own copy of the
|
|
19
|
+
// bar, so while it travels the bar's ink splits along its edge. The real bar (the link and the button) sits on top
|
|
20
|
+
// with transparent text: one focusable bar, never three.
|
|
21
|
+
//
|
|
22
|
+
// Mount it once at the top of the page; it is fixed and needs no wrapper:
|
|
23
|
+
// <MenuCurtain variant="gate" name="Noord" items={[{ label: "Werk", href: "/werk", image }]} startTone="photo"
|
|
24
|
+
// contact={{ label: "hallo@noord.studio", href: "mailto:hallo@noord.studio" }} place="Rotterdam" />
|
|
25
|
+
// Keep the hero's type out of the top var(--menu-bar-h) of the screen; the bar never gets a ground of its own.
|
|
26
|
+
//
|
|
27
|
+
// While open, the page under it does not move (Lenis stopped, or the root's overflow held without Lenis) and is
|
|
28
|
+
// inert; Tab cycles the bar and the menu, Escape closes and gives focus back to where it was. A link to a spot on the
|
|
29
|
+
// same page closes the menu and scrolls there. Photos are decoded before they can show; one that is not ready is
|
|
30
|
+
// waited for under an id, so a later hover always wins. Reduced motion: the same states, the curtain fading in and
|
|
31
|
+
// out, cuts for the photos. Without JavaScript (and 3 seconds into a page whose script never arrives) the bar shows
|
|
32
|
+
// its links in a row instead of the button.
|
|
33
|
+
import Image from "next/image";
|
|
34
|
+
import { useCallback, useEffect, useId, useRef, useState, type MouseEvent, type ReactNode, type Ref } from "react";
|
|
35
|
+
import { gsap, ScrollTrigger, useGSAP } from "../kit/gsap";
|
|
36
|
+
import type { StruqImage } from "../kit/images";
|
|
37
|
+
import { getLenis } from "../kit/SmoothScroll";
|
|
38
|
+
|
|
39
|
+
export const MENU_CURTAIN_VARIANTS = [
|
|
40
|
+
{ id: "gate", title: "De poort" },
|
|
41
|
+
{ id: "slices", title: "De repen" },
|
|
42
|
+
{ id: "stack", title: "De stapel" },
|
|
43
|
+
] as const;
|
|
44
|
+
export type MenuCurtainVariant = (typeof MENU_CURTAIN_VARIANTS)[number]["id"];
|
|
45
|
+
|
|
46
|
+
export type MenuItem = { label: string; href: string; image?: StruqImage };
|
|
47
|
+
export type BarTone = "ground" | "photo" | "inverse";
|
|
48
|
+
|
|
49
|
+
const TONES: BarTone[] = ["ground", "photo", "inverse"];
|
|
50
|
+
const TONE_INK: Record<BarTone, string> = { ground: "text-ink", photo: "text-on-photo", inverse: "text-on-inverse" };
|
|
51
|
+
const EMPTY = "inset(0px 0px 100% 0px)";
|
|
52
|
+
const SLICES = [0, 1, 2, 3];
|
|
53
|
+
const PANELS = [
|
|
54
|
+
{ ground: "bg-[color-mix(in_srgb,var(--surface-1),var(--ink)_7%)]", ink: "text-ink" },
|
|
55
|
+
{ ground: "bg-[color-mix(in_srgb,var(--surface-1),var(--ink)_16%)]", ink: "text-ink" },
|
|
56
|
+
{ ground: "bg-[var(--menu)]", ink: "text-[var(--on-menu)]" },
|
|
57
|
+
];
|
|
58
|
+
/** The accent edge that leads each slice. */
|
|
59
|
+
const EDGE = 5;
|
|
60
|
+
|
|
61
|
+
type Sched = readonly [delay: number, duration: number, ease: string];
|
|
62
|
+
type Part = { o: { v: number }; open: Sched; close: Sched; cut?: boolean };
|
|
63
|
+
type Api = { go: (open: boolean) => void; show: (i: number, cut?: boolean) => void };
|
|
64
|
+
|
|
65
|
+
function sameDocumentHash(href: string): string | null {
|
|
66
|
+
const url = new URL(href, location.href);
|
|
67
|
+
if (url.origin !== location.origin || url.pathname !== location.pathname || !url.hash) return null;
|
|
68
|
+
return decodeURIComponent(url.hash.slice(1));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Makes everything outside `el` inert, up to the body, and returns the undo. */
|
|
72
|
+
function inertOutside(el: HTMLElement) {
|
|
73
|
+
const done: HTMLElement[] = [];
|
|
74
|
+
let node: HTMLElement = el;
|
|
75
|
+
while (node.parentElement && node !== document.body) {
|
|
76
|
+
const parent: HTMLElement = node.parentElement;
|
|
77
|
+
for (const sib of Array.from(parent.children)) {
|
|
78
|
+
if (sib === node || !(sib instanceof HTMLElement) || sib.inert) continue;
|
|
79
|
+
if (sib.tagName === "SCRIPT" || sib.tagName === "STYLE" || sib.tagName === "LINK") continue;
|
|
80
|
+
sib.inert = true;
|
|
81
|
+
done.push(sib);
|
|
82
|
+
}
|
|
83
|
+
node = parent;
|
|
84
|
+
}
|
|
85
|
+
return () => done.forEach((n) => (n.inert = false));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The bar's face. Drawn once for real (a link and a button, transparent text, on top) and as aria-hidden copies in
|
|
90
|
+
* every ink, so all of them lay out alike to the pixel.
|
|
91
|
+
*/
|
|
92
|
+
function BarFace({
|
|
93
|
+
real = false,
|
|
94
|
+
name,
|
|
95
|
+
homeHref,
|
|
96
|
+
items,
|
|
97
|
+
open = false,
|
|
98
|
+
controls,
|
|
99
|
+
toggleRef,
|
|
100
|
+
onToggle,
|
|
101
|
+
}: {
|
|
102
|
+
real?: boolean;
|
|
103
|
+
name: string;
|
|
104
|
+
homeHref: string;
|
|
105
|
+
items: MenuItem[];
|
|
106
|
+
open?: boolean;
|
|
107
|
+
controls?: string;
|
|
108
|
+
toggleRef?: Ref<HTMLButtonElement>;
|
|
109
|
+
onToggle?: () => void;
|
|
110
|
+
}) {
|
|
111
|
+
const word = "font-display text-[1.375rem] leading-none tracking-[-0.02em]";
|
|
112
|
+
const toggle = "[grid-area:1/1] -my-3 inline-flex cursor-pointer items-center gap-3 py-3 text-small font-medium leading-none";
|
|
113
|
+
const inner = (
|
|
114
|
+
<>
|
|
115
|
+
<span aria-hidden="true" className="block h-[1.25em] overflow-hidden leading-[1.25em]">
|
|
116
|
+
<span data-roll="" className="flex flex-col items-end">
|
|
117
|
+
<span>Menu</span>
|
|
118
|
+
<span>Sluiten</span>
|
|
119
|
+
</span>
|
|
120
|
+
</span>
|
|
121
|
+
<span aria-hidden="true" className="relative block h-2 w-6">
|
|
122
|
+
<span data-ico="a" className="absolute inset-x-0 top-0 h-px bg-current" />
|
|
123
|
+
<span data-ico="b" className="absolute inset-x-0 bottom-0 h-px bg-current" />
|
|
124
|
+
</span>
|
|
125
|
+
</>
|
|
126
|
+
);
|
|
127
|
+
return (
|
|
128
|
+
<div className="flex h-[var(--menu-bar-h)] items-center justify-between gap-6 px-6 md:px-[3vw]">
|
|
129
|
+
{real ? (
|
|
130
|
+
<a href={homeHref} className={word}>
|
|
131
|
+
{name}
|
|
132
|
+
</a>
|
|
133
|
+
) : (
|
|
134
|
+
<span className={word}>{name}</span>
|
|
135
|
+
)}
|
|
136
|
+
<div className="grid justify-items-end">
|
|
137
|
+
<ul data-menu-fallback="" className="m-0 flex list-none flex-wrap justify-end gap-x-4 gap-y-1 p-0 text-small [grid-area:1/1]">
|
|
138
|
+
{items.map((item) => (
|
|
139
|
+
<li key={item.label}>{real ? <a href={item.href}>{item.label}</a> : <span>{item.label}</span>}</li>
|
|
140
|
+
))}
|
|
141
|
+
</ul>
|
|
142
|
+
{real ? (
|
|
143
|
+
<button
|
|
144
|
+
ref={toggleRef}
|
|
145
|
+
type="button"
|
|
146
|
+
data-menu-toggle=""
|
|
147
|
+
aria-expanded={open}
|
|
148
|
+
aria-controls={controls}
|
|
149
|
+
onClick={onToggle}
|
|
150
|
+
className={`${toggle} bg-transparent`}
|
|
151
|
+
>
|
|
152
|
+
<span className="sr-only">{open ? "Sluiten" : "Menu"}</span>
|
|
153
|
+
{inner}
|
|
154
|
+
</button>
|
|
155
|
+
) : (
|
|
156
|
+
<span data-menu-toggle="" className={toggle}>
|
|
157
|
+
{inner}
|
|
158
|
+
</span>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function MenuCurtain({
|
|
166
|
+
variant = "gate",
|
|
167
|
+
name,
|
|
168
|
+
homeHref = "/",
|
|
169
|
+
items,
|
|
170
|
+
contact,
|
|
171
|
+
place = "Rotterdam",
|
|
172
|
+
timeZone = "Europe/Amsterdam",
|
|
173
|
+
startTone = "ground",
|
|
174
|
+
}: {
|
|
175
|
+
variant?: MenuCurtainVariant;
|
|
176
|
+
/** The wordmark. */
|
|
177
|
+
name: string;
|
|
178
|
+
homeHref?: string;
|
|
179
|
+
/** Four or five links; each one's photo shows on the menu's calm side while it is hovered or focused. */
|
|
180
|
+
items: MenuItem[];
|
|
181
|
+
/** The foot line's contact: a mailto, a phone or a page. */
|
|
182
|
+
contact: { label: string; href: string };
|
|
183
|
+
/** The place whose local time the foot line keeps. */
|
|
184
|
+
place?: string;
|
|
185
|
+
timeZone?: string;
|
|
186
|
+
/** The ground under the bar on the first paint, before the script measures it (the hero's, usually "photo"). */
|
|
187
|
+
startTone?: BarTone;
|
|
188
|
+
}) {
|
|
189
|
+
const root = useRef<HTMLDivElement>(null);
|
|
190
|
+
const panel = useRef<HTMLDivElement>(null);
|
|
191
|
+
const toggleRef = useRef<HTMLButtonElement>(null);
|
|
192
|
+
const api = useRef<Api | null>(null);
|
|
193
|
+
const isOpen = useRef(false);
|
|
194
|
+
const active = useRef(0);
|
|
195
|
+
const opener = useRef<HTMLElement | null>(null);
|
|
196
|
+
const release = useRef<(() => void) | null>(null);
|
|
197
|
+
const [open, setOpen] = useState(false);
|
|
198
|
+
const [time, setTime] = useState("");
|
|
199
|
+
const menuId = useId();
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
const fmt = new Intl.DateTimeFormat("nl-NL", { hour: "2-digit", minute: "2-digit", timeZone });
|
|
203
|
+
const tick = () => setTime(fmt.format(new Date()));
|
|
204
|
+
tick();
|
|
205
|
+
const id = window.setInterval(tick, 15000);
|
|
206
|
+
return () => window.clearInterval(id);
|
|
207
|
+
}, [timeZone]);
|
|
208
|
+
|
|
209
|
+
/** Holds the page still and inert while the menu is open; the undo runs on every way out, unmount included. */
|
|
210
|
+
const hold = useCallback(() => {
|
|
211
|
+
const el = root.current;
|
|
212
|
+
if (!el || release.current) return;
|
|
213
|
+
const lenis = getLenis();
|
|
214
|
+
const html = document.documentElement;
|
|
215
|
+
const overflow = html.style.overflow;
|
|
216
|
+
if (lenis) lenis.stop();
|
|
217
|
+
else html.style.overflow = "hidden";
|
|
218
|
+
const unInert = inertOutside(el);
|
|
219
|
+
release.current = () => {
|
|
220
|
+
if (lenis) getLenis()?.start();
|
|
221
|
+
else html.style.overflow = overflow;
|
|
222
|
+
unInert();
|
|
223
|
+
release.current = null;
|
|
224
|
+
};
|
|
225
|
+
}, []);
|
|
226
|
+
|
|
227
|
+
useEffect(() => () => release.current?.(), []);
|
|
228
|
+
|
|
229
|
+
const setMenu = useCallback(
|
|
230
|
+
(next: boolean, giveFocusBack = true) => {
|
|
231
|
+
if (next === isOpen.current) return;
|
|
232
|
+
isOpen.current = next;
|
|
233
|
+
setOpen(next);
|
|
234
|
+
if (next) {
|
|
235
|
+
opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
236
|
+
hold();
|
|
237
|
+
api.current?.show(active.current, true);
|
|
238
|
+
} else {
|
|
239
|
+
release.current?.();
|
|
240
|
+
if (giveFocusBack) (opener.current?.isConnected ? opener.current : toggleRef.current)?.focus({ preventScroll: true });
|
|
241
|
+
}
|
|
242
|
+
api.current?.go(next);
|
|
243
|
+
},
|
|
244
|
+
[hold],
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// Escape closes; Tab goes round the bar and the menu only.
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
if (!open) return;
|
|
250
|
+
const onKey = (e: KeyboardEvent) => {
|
|
251
|
+
if (e.key === "Escape") {
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
setMenu(false);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (e.key !== "Tab" || !root.current) return;
|
|
257
|
+
const ring = Array.from(
|
|
258
|
+
root.current.querySelectorAll<HTMLElement>("[data-menu-real] a[href], [data-menu-real] button, [data-menu-panel] a[href]"),
|
|
259
|
+
).filter((n) => !n.closest("[data-menu-fallback]"));
|
|
260
|
+
if (ring.length === 0) return;
|
|
261
|
+
const i = ring.indexOf(document.activeElement as HTMLElement);
|
|
262
|
+
if (i < 0) {
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
ring[e.shiftKey ? ring.length - 1 : 0].focus();
|
|
265
|
+
} else if (e.shiftKey && i === 0) {
|
|
266
|
+
e.preventDefault();
|
|
267
|
+
ring[ring.length - 1].focus();
|
|
268
|
+
} else if (!e.shiftKey && i === ring.length - 1) {
|
|
269
|
+
e.preventDefault();
|
|
270
|
+
ring[0].focus();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
document.addEventListener("keydown", onKey);
|
|
274
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
275
|
+
}, [open, setMenu]);
|
|
276
|
+
|
|
277
|
+
const onLink = (e: MouseEvent<HTMLAnchorElement>) => {
|
|
278
|
+
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
279
|
+
const id = sameDocumentHash(e.currentTarget.href);
|
|
280
|
+
setMenu(false, false);
|
|
281
|
+
if (id === null) return;
|
|
282
|
+
e.preventDefault();
|
|
283
|
+
const target = document.getElementById(id);
|
|
284
|
+
if (!target) return;
|
|
285
|
+
const lenis = getLenis();
|
|
286
|
+
if (lenis) lenis.scrollTo(target);
|
|
287
|
+
else target.scrollIntoView();
|
|
288
|
+
if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");
|
|
289
|
+
target.focus({ preventScroll: true });
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const pick = (i: number) => {
|
|
293
|
+
active.current = i;
|
|
294
|
+
panel.current
|
|
295
|
+
?.querySelectorAll<HTMLElement>("[data-menu-link]")
|
|
296
|
+
.forEach((a, j) => a.toggleAttribute("data-on", j === i));
|
|
297
|
+
api.current?.show(i);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
useGSAP(
|
|
301
|
+
() => {
|
|
302
|
+
const el = root.current;
|
|
303
|
+
const overlay = panel.current;
|
|
304
|
+
if (!el || !overlay) return;
|
|
305
|
+
const nav = overlay.querySelector<HTMLElement>("nav");
|
|
306
|
+
el.setAttribute("data-ready", "");
|
|
307
|
+
const q = gsap.utils.selector(el);
|
|
308
|
+
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
309
|
+
|
|
310
|
+
// --- the bar's ink, cut exactly where the sections under it meet ---
|
|
311
|
+
const faces = TONES.map((t) => q(`[data-tone-face="${t}"]`)[0] as HTMLElement);
|
|
312
|
+
let barH = faces[0].offsetHeight;
|
|
313
|
+
const ink = () => {
|
|
314
|
+
const spans: Record<BarTone, [number, number] | null> = { ground: null, photo: null, inverse: null };
|
|
315
|
+
document.querySelectorAll<HTMLElement>("[data-bar-tone]").forEach((sec) => {
|
|
316
|
+
const tone = sec.dataset.barTone as BarTone;
|
|
317
|
+
if ((tone !== "photo" && tone !== "inverse") || el.contains(sec)) return;
|
|
318
|
+
const r = sec.getBoundingClientRect();
|
|
319
|
+
const a = Math.max(0, r.top);
|
|
320
|
+
const b = Math.min(barH, r.bottom);
|
|
321
|
+
if (b <= a) return;
|
|
322
|
+
const cur = spans[tone];
|
|
323
|
+
spans[tone] = cur ? [Math.min(cur[0], a), Math.max(cur[1], b)] : [a, b];
|
|
324
|
+
});
|
|
325
|
+
// The ground takes the longest stretch the marked sections leave.
|
|
326
|
+
const taken = [spans.photo, spans.inverse].filter((s): s is [number, number] => s !== null).sort((x, y) => x[0] - y[0]);
|
|
327
|
+
let best: [number, number] | null = null;
|
|
328
|
+
let y = 0;
|
|
329
|
+
for (const [a, b] of taken) {
|
|
330
|
+
if (a - y > (best ? best[1] - best[0] : 0)) best = [y, a];
|
|
331
|
+
y = Math.max(y, b);
|
|
332
|
+
}
|
|
333
|
+
if (barH - y > (best ? best[1] - best[0] : 0)) best = [y, barH];
|
|
334
|
+
spans.ground = best;
|
|
335
|
+
faces.forEach((face, i) => {
|
|
336
|
+
const s = spans[TONES[i]];
|
|
337
|
+
face.style.clipPath = s ? `inset(${s[0]}px 0px ${barH - s[1]}px 0px)` : EMPTY;
|
|
338
|
+
});
|
|
339
|
+
};
|
|
340
|
+
// On Lenis this runs inside its scroll (ScrollTrigger.update), so the split never trails the page by a frame.
|
|
341
|
+
const watch = ScrollTrigger.create({ start: 0, end: "max", onUpdate: ink, onRefresh: ink });
|
|
342
|
+
window.addEventListener("scroll", ink, { passive: true });
|
|
343
|
+
ink();
|
|
344
|
+
|
|
345
|
+
// --- the menu ---
|
|
346
|
+
const rolls = q("[data-roll]");
|
|
347
|
+
const icoA = q('[data-ico="a"]');
|
|
348
|
+
const icoB = q('[data-ico="b"]');
|
|
349
|
+
const rises = q("[data-rise]");
|
|
350
|
+
const foot = q("[data-foot]")[0] as HTMLElement;
|
|
351
|
+
const frame = q("[data-frame]")[0] as HTMLElement | undefined;
|
|
352
|
+
const layers = q("[data-photo]");
|
|
353
|
+
const cover = q("[data-cover]")[0] as HTMLElement | undefined;
|
|
354
|
+
const edges = q("[data-gate-edge]");
|
|
355
|
+
const grounds = q("[data-slice-ground]");
|
|
356
|
+
const sliceEdges = q("[data-slice-edge]");
|
|
357
|
+
const panels = q("[data-panel]");
|
|
358
|
+
|
|
359
|
+
const P = { roll: { v: 0 }, line: { v: 0 }, cover: { v: 0 }, frame: { v: 0 }, foot: { v: 0 } };
|
|
360
|
+
const rise = rises.map(() => ({ v: 0 }));
|
|
361
|
+
const sl = SLICES.map(() => ({ v: 0 }));
|
|
362
|
+
const pn = PANELS.map(() => ({ v: 0 }));
|
|
363
|
+
const fade = { v: reduce ? 0 : 1 };
|
|
364
|
+
|
|
365
|
+
let W = overlay.clientWidth;
|
|
366
|
+
let H = overlay.clientHeight;
|
|
367
|
+
let fh = frame?.offsetHeight ?? 0;
|
|
368
|
+
let fTop = frame?.getBoundingClientRect().top ?? 0;
|
|
369
|
+
let btn = toggleRef.current?.getBoundingClientRect() ?? new DOMRect(W - 80, 20, 60, 24);
|
|
370
|
+
const measure = () => {
|
|
371
|
+
W = overlay.clientWidth;
|
|
372
|
+
H = overlay.clientHeight;
|
|
373
|
+
barH = faces[0].offsetHeight;
|
|
374
|
+
fh = frame?.offsetHeight ?? 0;
|
|
375
|
+
fTop = frame?.getBoundingClientRect().top ?? 0;
|
|
376
|
+
if (toggleRef.current) btn = toggleRef.current.getBoundingClientRect();
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const paintLayer = (i: number, p: number) => {
|
|
380
|
+
const L = layers[i];
|
|
381
|
+
if (p <= 0) L.style.clipPath = EMPTY;
|
|
382
|
+
else if (p >= 1) L.style.clipPath = "none";
|
|
383
|
+
else if (variant === "gate") {
|
|
384
|
+
const c = (fh / 2) * (1 - p);
|
|
385
|
+
L.style.clipPath = `inset(${c}px 0px ${c}px 0px)`;
|
|
386
|
+
} else L.style.clipPath = `inset(0px 0px ${fh * (1 - p)}px 0px)`;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const paint = () => {
|
|
390
|
+
const r = P.roll.v;
|
|
391
|
+
rolls.forEach((e) => (e.style.transform = `translate3d(0, ${-50 * r}%, 0)`));
|
|
392
|
+
icoA.forEach((e) => (e.style.transform = `translate3d(0, ${3.5 * r}px, 0) rotate(${20 * r}deg)`));
|
|
393
|
+
icoB.forEach((e) => (e.style.transform = `translate3d(0, ${-3.5 * r}px, 0) rotate(${-20 * r}deg)`));
|
|
394
|
+
rises.forEach((e, i) => (e.style.transform = `translate3d(0, ${(1 - rise[i].v) * 110}%, 0)`));
|
|
395
|
+
foot.style.opacity = String(P.foot.v);
|
|
396
|
+
foot.style.transform = `translate3d(0, ${Math.round((1 - P.foot.v) * 16)}px, 0)`;
|
|
397
|
+
|
|
398
|
+
let shown = false;
|
|
399
|
+
if (variant === "gate" && cover) {
|
|
400
|
+
const t = (H / 2) * (1 - P.cover.v);
|
|
401
|
+
cover.style.clipPath = P.cover.v <= 0 ? "inset(50% 0px 50% 0px)" : `inset(${t}px 0px ${t}px 0px)`;
|
|
402
|
+
const [top, bottom] = edges;
|
|
403
|
+
const fadeOut = Math.min(1, (1 - P.cover.v) / 0.2);
|
|
404
|
+
top.style.transform = `translate3d(0, ${Math.round(t - 1)}px, 0) scaleX(${P.line.v})`;
|
|
405
|
+
bottom.style.transform = `translate3d(0, ${Math.round(H - t - 1)}px, 0) scaleX(${P.line.v})`;
|
|
406
|
+
top.style.opacity = bottom.style.opacity = String(fadeOut);
|
|
407
|
+
if (frame) {
|
|
408
|
+
const c = (fh / 2) * (1 - P.frame.v);
|
|
409
|
+
frame.style.clipPath = P.frame.v <= 0 ? "inset(50% 0px 50% 0px)" : `inset(${c}px 0px ${c}px 0px)`;
|
|
410
|
+
}
|
|
411
|
+
shown = P.line.v > 0 || P.cover.v > 0;
|
|
412
|
+
} else if (variant === "slices") {
|
|
413
|
+
sl.forEach((o, k) => {
|
|
414
|
+
const y = Math.round(o.v * (H + EDGE) - EDGE);
|
|
415
|
+
grounds[k].style.clipPath = o.v <= 0 ? EMPTY : `inset(0px 0px ${H - Math.max(0, y)}px 0px)`;
|
|
416
|
+
sliceEdges[k].style.transform = `translate3d(0, ${y}px, 0)`;
|
|
417
|
+
if (o.v > 0) shown = true;
|
|
418
|
+
});
|
|
419
|
+
if (frame) {
|
|
420
|
+
// The print stands in the last slice and falls with it.
|
|
421
|
+
const y = Math.round(sl[3].v * (H + EDGE) - EDGE);
|
|
422
|
+
const seen = Math.min(fh, Math.max(0, y - fTop));
|
|
423
|
+
frame.style.clipPath = seen <= 0 ? EMPTY : `inset(0px 0px ${fh - seen}px 0px)`;
|
|
424
|
+
}
|
|
425
|
+
} else {
|
|
426
|
+
pn.forEach((o, k) => {
|
|
427
|
+
const e = 1 - o.v;
|
|
428
|
+
panels[k].style.clipPath =
|
|
429
|
+
o.v <= 0
|
|
430
|
+
? EMPTY
|
|
431
|
+
: `inset(${btn.top * e}px ${(W - btn.right) * e}px ${(H - btn.bottom) * e}px ${btn.left * e}px)`;
|
|
432
|
+
});
|
|
433
|
+
if (frame) frame.style.clipPath = P.frame.v >= 0.5 ? "none" : EMPTY;
|
|
434
|
+
shown = pn[0].v > 0;
|
|
435
|
+
}
|
|
436
|
+
// The links live inside the curtain: the nav is clipped to whatever of the menu is open, so a rising link
|
|
437
|
+
// never shows over the page before the curtain reaches it.
|
|
438
|
+
if (nav) {
|
|
439
|
+
if (variant === "gate" && cover) nav.style.clipPath = cover.style.clipPath;
|
|
440
|
+
else if (variant === "slices") {
|
|
441
|
+
const n = sl.length;
|
|
442
|
+
const top: string[] = [];
|
|
443
|
+
const bottom: string[] = [];
|
|
444
|
+
sl.forEach((o, k) => {
|
|
445
|
+
const x0 = Math.round((W * k) / n);
|
|
446
|
+
const x1 = Math.round((W * (k + 1)) / n);
|
|
447
|
+
const y = o.v <= 0 ? 0 : Math.max(0, Math.round(o.v * (H + EDGE) - EDGE));
|
|
448
|
+
top.push(`${x0}px 0px`, `${x1}px 0px`);
|
|
449
|
+
bottom.unshift(`${x1}px ${y}px`, `${x0}px ${y}px`);
|
|
450
|
+
});
|
|
451
|
+
nav.style.clipPath = `polygon(${[...top, ...bottom].join(", ")})`;
|
|
452
|
+
} else nav.style.clipPath = panels[2]?.style.clipPath ?? "";
|
|
453
|
+
}
|
|
454
|
+
overlay.style.visibility = shown ? "visible" : "";
|
|
455
|
+
overlay.style.opacity = String(fade.v);
|
|
456
|
+
overlay.style.pointerEvents = isOpen.current ? "auto" : "";
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
// --- the photos: decoded before they can show; a newer wish always wins ---
|
|
460
|
+
const decoding: Array<Promise<boolean> | null> = layers.map(() => null);
|
|
461
|
+
const ready = layers.map(() => false);
|
|
462
|
+
const decode = (i: number) => {
|
|
463
|
+
if (!decoding[i]) {
|
|
464
|
+
const img = layers[i].querySelector("img");
|
|
465
|
+
decoding[i] = img
|
|
466
|
+
? img.decode().then(
|
|
467
|
+
() => (ready[i] = true),
|
|
468
|
+
() => false,
|
|
469
|
+
)
|
|
470
|
+
: Promise.resolve(false);
|
|
471
|
+
}
|
|
472
|
+
return decoding[i]!;
|
|
473
|
+
};
|
|
474
|
+
const idle = window.setTimeout(() => layers.forEach((_, i) => decode(i)), 300);
|
|
475
|
+
|
|
476
|
+
const pv = layers.map(() => ({ p: 0 }));
|
|
477
|
+
const aim = layers.map(() => 0);
|
|
478
|
+
const z = layers.map(() => 0);
|
|
479
|
+
const wipes: Array<gsap.core.Tween | null> = layers.map(() => null);
|
|
480
|
+
let top = 0;
|
|
481
|
+
let base = -1;
|
|
482
|
+
let wish = 0;
|
|
483
|
+
let flip: gsap.core.Tween | null = null;
|
|
484
|
+
|
|
485
|
+
const cutTo = (k: number) => {
|
|
486
|
+
wipes.forEach((t) => t?.kill());
|
|
487
|
+
pv.forEach((o, i) => {
|
|
488
|
+
o.p = i === k ? 1 : 0;
|
|
489
|
+
aim[i] = o.p;
|
|
490
|
+
paintLayer(i, o.p);
|
|
491
|
+
});
|
|
492
|
+
base = k;
|
|
493
|
+
};
|
|
494
|
+
const wipe = (i: number, to: number, done?: () => void) => {
|
|
495
|
+
wipes[i]?.kill();
|
|
496
|
+
aim[i] = to;
|
|
497
|
+
const d = Math.abs(to - pv[i].p);
|
|
498
|
+
wipes[i] = gsap.to(pv[i], {
|
|
499
|
+
p: to,
|
|
500
|
+
duration: Math.max(0.14, 0.6 * d),
|
|
501
|
+
ease: "power3.out",
|
|
502
|
+
onUpdate: () => paintLayer(i, pv[i].p),
|
|
503
|
+
onComplete: done,
|
|
504
|
+
});
|
|
505
|
+
};
|
|
506
|
+
const show = (k: number, cut = false) => {
|
|
507
|
+
if (k < 0 || k >= layers.length) return;
|
|
508
|
+
const id = ++wish;
|
|
509
|
+
flip?.kill();
|
|
510
|
+
if (!ready[k]) {
|
|
511
|
+
// Waited for under its id: shown only if it is still the newest wish, and then never as a cut.
|
|
512
|
+
decode(k).then((ok) => {
|
|
513
|
+
if (ok && id === wish) show(k);
|
|
514
|
+
});
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (cut || reduce || variant === "stack") {
|
|
518
|
+
cutTo(k);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (k === base) {
|
|
522
|
+
// Back to the photo underneath: whatever was wiping over it draws back the way it came.
|
|
523
|
+
pv.forEach((o, i) => {
|
|
524
|
+
if (i !== base && (o.p > 0 || aim[i] > 0)) wipe(i, 0);
|
|
525
|
+
});
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (aim[k] === 1 && wipes[k]?.isActive()) return;
|
|
529
|
+
z[k] = ++top;
|
|
530
|
+
layers[k].style.zIndex = String(z[k]);
|
|
531
|
+
wipe(k, 1, () => {
|
|
532
|
+
base = k;
|
|
533
|
+
pv.forEach((o, i) => {
|
|
534
|
+
if (i === k || z[i] > z[k]) return;
|
|
535
|
+
wipes[i]?.kill();
|
|
536
|
+
o.p = 0;
|
|
537
|
+
aim[i] = 0;
|
|
538
|
+
paintLayer(i, 0);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
/** The stack's frame, once it appears: a thumb through a few decoded prints, landing on the active one. */
|
|
544
|
+
const flipIn = () => {
|
|
545
|
+
const id = ++wish;
|
|
546
|
+
const others = layers.map((_, i) => i).filter((i) => i !== active.current && ready[i]).slice(0, 3);
|
|
547
|
+
const run = [...others, active.current];
|
|
548
|
+
let step = 0;
|
|
549
|
+
const next = () => {
|
|
550
|
+
if (id !== wish) return;
|
|
551
|
+
const k = run[step];
|
|
552
|
+
if (ready[k]) cutTo(k);
|
|
553
|
+
step += 1;
|
|
554
|
+
if (step < run.length) flip = gsap.delayedCall(0.075 + step * 0.012, next);
|
|
555
|
+
};
|
|
556
|
+
next();
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
// --- open and close: every part from where it is to where it is going ---
|
|
560
|
+
const lead = variant === "slices" ? 0.42 : 0.5;
|
|
561
|
+
const plan = (): { parts: Part[]; ref: () => number } => {
|
|
562
|
+
const n = rise.length;
|
|
563
|
+
const parts: Part[] = [{ o: P.roll, open: [0, 0.55, "power3.inOut"], close: [0, 0.5, "power3.inOut"] }];
|
|
564
|
+
rise.forEach((o, i) =>
|
|
565
|
+
parts.push({ o, open: [lead + i * 0.06, 0.9, "expo.out"], close: [(n - 1 - i) * 0.04, 0.38, "power2.in"] }),
|
|
566
|
+
);
|
|
567
|
+
parts.push({ o: P.foot, open: [lead + 0.32, 0.7, "expo.out"], close: [0, 0.3, "power2.in"] });
|
|
568
|
+
if (variant === "gate") {
|
|
569
|
+
parts.push({ o: P.line, open: [0, 0.35, "expo.out"], close: [0.8, 0.22, "power2.in"] });
|
|
570
|
+
parts.push({ o: P.cover, open: [0.2, 0.8, "power4.inOut"], close: [0.28, 0.6, "power4.inOut"] });
|
|
571
|
+
parts.push({ o: P.frame, open: [0.55, 0.75, "power3.inOut"], close: [0.1, 0.45, "power3.inOut"] });
|
|
572
|
+
return { parts, ref: () => P.cover.v };
|
|
573
|
+
}
|
|
574
|
+
if (variant === "slices") {
|
|
575
|
+
sl.forEach((o, k) =>
|
|
576
|
+
parts.push({ o, open: [k * 0.07, 0.6, "power3.inOut"], close: [0.26 + (3 - k) * 0.06, 0.5, "power3.inOut"] }),
|
|
577
|
+
);
|
|
578
|
+
return { parts, ref: () => sl.reduce((a, o) => a + o.v, 0) / sl.length };
|
|
579
|
+
}
|
|
580
|
+
pn.forEach((o, k) =>
|
|
581
|
+
parts.push({ o, open: [k * 0.08, 0.7, "power4.inOut"], close: [0.26 + (2 - k) * 0.08, 0.55, "power4.inOut"] }),
|
|
582
|
+
);
|
|
583
|
+
parts.push({ o: P.frame, open: [0.62, 0, "none"], close: [0.02, 0, "none"], cut: true });
|
|
584
|
+
return { parts, ref: () => pn[2].v };
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
let tl: gsap.core.Timeline | null = null;
|
|
588
|
+
const go = (opening: boolean) => {
|
|
589
|
+
tl?.kill();
|
|
590
|
+
if (!opening) flip?.kill();
|
|
591
|
+
measure();
|
|
592
|
+
const { parts, ref } = plan();
|
|
593
|
+
const target = opening ? 1 : 0;
|
|
594
|
+
tl = gsap.timeline({ onUpdate: paint, onComplete: paint });
|
|
595
|
+
if (reduce) {
|
|
596
|
+
// The same states without the travel: the curtain fades, nothing moves.
|
|
597
|
+
if (opening) {
|
|
598
|
+
parts.forEach((p) => (p.o.v = 1));
|
|
599
|
+
tl.to(fade, { v: 1, duration: 0.2, ease: "power1.out" });
|
|
600
|
+
} else {
|
|
601
|
+
tl.to(fade, { v: 0, duration: 0.16, ease: "power1.in" }).call(() => parts.forEach((p) => (p.o.v = 0)));
|
|
602
|
+
}
|
|
603
|
+
paint();
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const f = opening ? 1 - ref() : ref();
|
|
607
|
+
for (const p of parts) {
|
|
608
|
+
const [delay, duration, ease] = opening ? p.open : p.close;
|
|
609
|
+
if (Math.abs(target - p.o.v) < 1e-4) continue;
|
|
610
|
+
if (p.cut) tl.set(p.o, { v: target }, delay * f);
|
|
611
|
+
else tl.to(p.o, { v: target, duration: Math.max(0.12, duration * Math.abs(target - p.o.v)), ease }, delay * f);
|
|
612
|
+
}
|
|
613
|
+
if (variant === "stack" && opening && P.frame.v < 0.5) tl.call(flipIn, [], 0.62 * f);
|
|
614
|
+
paint();
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
api.current = { go, show };
|
|
618
|
+
paint();
|
|
619
|
+
|
|
620
|
+
const onResize = () => {
|
|
621
|
+
measure();
|
|
622
|
+
paint();
|
|
623
|
+
layers.forEach((_, i) => paintLayer(i, pv[i].p));
|
|
624
|
+
};
|
|
625
|
+
window.addEventListener("resize", onResize);
|
|
626
|
+
|
|
627
|
+
return () => {
|
|
628
|
+
tl?.kill();
|
|
629
|
+
flip?.kill();
|
|
630
|
+
wipes.forEach((t) => t?.kill());
|
|
631
|
+
watch.kill();
|
|
632
|
+
window.clearTimeout(idle);
|
|
633
|
+
window.removeEventListener("scroll", ink);
|
|
634
|
+
window.removeEventListener("resize", onResize);
|
|
635
|
+
api.current = null;
|
|
636
|
+
};
|
|
637
|
+
},
|
|
638
|
+
{ scope: root, dependencies: [variant, items.length] },
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
const bar = (children: ReactNode) => <div className="absolute inset-x-0 top-0">{children}</div>;
|
|
642
|
+
const face = { name, homeHref, items };
|
|
643
|
+
const frameBox =
|
|
644
|
+
variant === "gate"
|
|
645
|
+
? "right-[3vw] top-1/2 aspect-[4/5] h-[min(58svh,40vw)] -translate-y-1/2"
|
|
646
|
+
: variant === "slices"
|
|
647
|
+
? "bottom-[4.5rem] right-[3vw] top-[var(--menu-bar-h)] left-[calc(75cqw+1.5rem)]"
|
|
648
|
+
: "right-[13vw] top-1/2 aspect-[4/5] h-[min(46svh,30vw)] -translate-y-1/2";
|
|
649
|
+
const frameSizes = variant === "gate" ? "32vw" : "24vw";
|
|
650
|
+
const frameStart = variant === "gate" ? "inset(50% 0px 50% 0px)" : EMPTY;
|
|
651
|
+
|
|
652
|
+
return (
|
|
653
|
+
<div ref={root} data-menu-curtain="" data-variant={variant}>
|
|
654
|
+
{/* The bar's ink over the page: one copy per ground, each clipped to the stretch it covers. */}
|
|
655
|
+
<div aria-hidden="true" className="pointer-events-none fixed inset-x-0 top-0 z-[79]">
|
|
656
|
+
{TONES.map((tone) => (
|
|
657
|
+
<div
|
|
658
|
+
key={tone}
|
|
659
|
+
data-tone-face={tone}
|
|
660
|
+
className={`absolute inset-x-0 top-0 ${TONE_INK[tone]}`}
|
|
661
|
+
style={{ clipPath: tone === startTone ? "none" : EMPTY }}
|
|
662
|
+
>
|
|
663
|
+
<BarFace {...face} />
|
|
664
|
+
</div>
|
|
665
|
+
))}
|
|
666
|
+
</div>
|
|
667
|
+
|
|
668
|
+
<div
|
|
669
|
+
ref={panel}
|
|
670
|
+
id={menuId}
|
|
671
|
+
data-menu-panel=""
|
|
672
|
+
inert={!open}
|
|
673
|
+
data-lenis-prevent=""
|
|
674
|
+
className="@container invisible pointer-events-none fixed inset-0 z-[80] text-[var(--on-menu)] [--n:4] max-md:[--n:3]"
|
|
675
|
+
>
|
|
676
|
+
{variant === "gate" ? (
|
|
677
|
+
<>
|
|
678
|
+
<div data-cover="" aria-hidden="true" className="absolute inset-0 bg-[var(--menu)]" style={{ clipPath: "inset(50% 0px 50% 0px)" }}>
|
|
679
|
+
{bar(<BarFace {...face} />)}
|
|
680
|
+
</div>
|
|
681
|
+
{/* The line the gate opens from; it splits into the two parting edges. */}
|
|
682
|
+
{["top", "bottom"].map((side) => (
|
|
683
|
+
<div
|
|
684
|
+
key={side}
|
|
685
|
+
data-gate-edge={side}
|
|
686
|
+
aria-hidden="true"
|
|
687
|
+
className="absolute inset-x-0 top-0 h-[2px] bg-accent"
|
|
688
|
+
style={{ transform: "translate3d(0, 0px, 0) scaleX(0)" }}
|
|
689
|
+
/>
|
|
690
|
+
))}
|
|
691
|
+
</>
|
|
692
|
+
) : null}
|
|
693
|
+
|
|
694
|
+
{variant === "slices"
|
|
695
|
+
? SLICES.map((i) => (
|
|
696
|
+
<div
|
|
697
|
+
key={i}
|
|
698
|
+
aria-hidden="true"
|
|
699
|
+
className={`absolute inset-y-0 ${i === 3 ? "max-md:hidden" : ""}`}
|
|
700
|
+
style={{ left: `calc(${i} * 100cqw / var(--n))`, width: "calc(100cqw / var(--n) + 1px)" }}
|
|
701
|
+
>
|
|
702
|
+
<div data-slice-ground="" className="absolute inset-0 overflow-hidden bg-[var(--menu)]" style={{ clipPath: EMPTY }}>
|
|
703
|
+
<div className="absolute inset-y-0" style={{ left: `calc(${-i} * 100cqw / var(--n))`, width: "100cqw" }}>
|
|
704
|
+
{bar(<BarFace {...face} />)}
|
|
705
|
+
</div>
|
|
706
|
+
</div>
|
|
707
|
+
<div
|
|
708
|
+
data-slice-edge=""
|
|
709
|
+
className="absolute inset-x-0 top-0 bg-accent"
|
|
710
|
+
style={{ height: EDGE, transform: `translate3d(0, ${-EDGE}px, 0)` }}
|
|
711
|
+
/>
|
|
712
|
+
</div>
|
|
713
|
+
))
|
|
714
|
+
: null}
|
|
715
|
+
|
|
716
|
+
{variant === "stack"
|
|
717
|
+
? PANELS.map((p, k) => (
|
|
718
|
+
<div key={k} data-panel={k} aria-hidden="true" className={`absolute inset-0 ${p.ground} ${p.ink}`} style={{ clipPath: EMPTY }}>
|
|
719
|
+
{bar(<BarFace {...face} />)}
|
|
720
|
+
</div>
|
|
721
|
+
))
|
|
722
|
+
: null}
|
|
723
|
+
|
|
724
|
+
<nav aria-label="Menu" className="absolute inset-0">
|
|
725
|
+
<ul
|
|
726
|
+
data-menu-list=""
|
|
727
|
+
className={`absolute bottom-[4.5rem] left-6 top-[var(--menu-bar-h)] m-0 flex list-none flex-col justify-end p-0 pb-[4svh] md:left-[3vw] md:justify-center md:pb-0 ${variant === "slices" ? "md:right-[calc(25cqw+1.5rem)]" : "md:right-[45vw]"}`}
|
|
728
|
+
>
|
|
729
|
+
{items.map((item, i) => (
|
|
730
|
+
<li key={item.label}>
|
|
731
|
+
<a
|
|
732
|
+
href={item.href}
|
|
733
|
+
data-menu-link=""
|
|
734
|
+
onPointerEnter={() => pick(i)}
|
|
735
|
+
onFocus={() => pick(i)}
|
|
736
|
+
onClick={onLink}
|
|
737
|
+
className="inline-block font-display text-[min(15.5vw,9svh)] leading-[1.02] tracking-[-0.03em] md:text-[min(7.2vw,11svh)]"
|
|
738
|
+
>
|
|
739
|
+
<span className="struq-line-mask block overflow-hidden">
|
|
740
|
+
<span data-rise="" className="block" style={{ transform: "translate3d(0, 110%, 0)" }}>
|
|
741
|
+
{item.label}
|
|
742
|
+
</span>
|
|
743
|
+
</span>
|
|
744
|
+
</a>
|
|
745
|
+
</li>
|
|
746
|
+
))}
|
|
747
|
+
</ul>
|
|
748
|
+
|
|
749
|
+
<div data-frame="" aria-hidden="true" className={`absolute overflow-hidden max-md:hidden ${frameBox}`} style={{ clipPath: frameStart }}>
|
|
750
|
+
{items.map((item, i) =>
|
|
751
|
+
item.image ? (
|
|
752
|
+
<div key={item.label} data-photo={i} className="absolute inset-0" style={{ clipPath: EMPTY }}>
|
|
753
|
+
<Image
|
|
754
|
+
src={item.image.src}
|
|
755
|
+
alt=""
|
|
756
|
+
fill
|
|
757
|
+
sizes={frameSizes}
|
|
758
|
+
loading="eager"
|
|
759
|
+
unoptimized={item.image.unoptimized}
|
|
760
|
+
className="object-cover"
|
|
761
|
+
/>
|
|
762
|
+
</div>
|
|
763
|
+
) : (
|
|
764
|
+
<div key={item.label} data-photo={i} className="absolute inset-0" style={{ clipPath: EMPTY }} />
|
|
765
|
+
),
|
|
766
|
+
)}
|
|
767
|
+
</div>
|
|
768
|
+
|
|
769
|
+
<div
|
|
770
|
+
data-foot=""
|
|
771
|
+
className="absolute inset-x-0 bottom-0 flex items-baseline justify-between gap-6 px-6 pb-5 text-small text-[var(--on-menu-muted)] md:px-[3vw] md:pb-6"
|
|
772
|
+
style={{ opacity: 0 }}
|
|
773
|
+
>
|
|
774
|
+
<a href={contact.href} className="underline-offset-4 hover:underline">
|
|
775
|
+
{contact.label}
|
|
776
|
+
</a>
|
|
777
|
+
<span>
|
|
778
|
+
{place} <span className="tabular-nums">{time}</span>
|
|
779
|
+
</span>
|
|
780
|
+
</div>
|
|
781
|
+
</nav>
|
|
782
|
+
</div>
|
|
783
|
+
|
|
784
|
+
{/* The real bar: the one link and the one button, on top of everything, its ink drawn by the copies under it. */}
|
|
785
|
+
<header data-menu-real="" className="fixed inset-x-0 top-0 z-[82] text-transparent">
|
|
786
|
+
<BarFace {...face} real open={open} controls={menuId} toggleRef={toggleRef} onToggle={() => setMenu(!isOpen.current)} />
|
|
787
|
+
</header>
|
|
788
|
+
</div>
|
|
789
|
+
);
|
|
790
|
+
}
|