@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,652 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// header-bar (Struq header recipe): the bar a site wears on every screen when it shows its links
|
|
3
|
+
// The wordmark, three or four links and one quiet action as a text link. The bar has no ground on the first screen; once
|
|
4
|
+
// the page has scrolled it takes the ground of whatever is under it (the page's own surface, the inverse, or none over a
|
|
5
|
+
// photo), so it reads as a window onto the page, never as a coloured strip.
|
|
6
|
+
//
|
|
7
|
+
// quiet wordmark left, links right; each label rolls through its mask on hover, letter by letter. The bar hides
|
|
8
|
+
// as the page scrolls down and returns as it scrolls up, scrubbed to the scroll with a short ease and
|
|
9
|
+
// settling to fully shown or fully hidden when the scroll stops; a hairline appears once the page scrolls.
|
|
10
|
+
// settle on the first screen the wordmark is set large across the hero's top at a light weight, from the first
|
|
11
|
+
// paint, rising out of its mask; the scroll shrinks it (scale geometric, path straight, whole pixels) into
|
|
12
|
+
// its place in the bar, and the links fade in beside it as it lands (a phone's "Menu" stays). The ground
|
|
13
|
+
// comes in only once it fits the bar. Scrolling back sets it large again. Hover draws a line from the side
|
|
14
|
+
// the pointer came in.
|
|
15
|
+
// split three parts: wordmark left, the links centred, and the studio's place and live local time on the right;
|
|
16
|
+
// one line glides under the hovered link, and a thin rule under the bar fills with the page's progress.
|
|
17
|
+
//
|
|
18
|
+
// The bar turns its ink with what is under it, exactly at the edge, per pixel: it is drawn three times (ink for the
|
|
19
|
+
// page ground, on-photo, on-inverse), each copy clipped to the stretches of the bar its own sections cover. Sections
|
|
20
|
+
// opt in with data-bar-tone="photo" or data-bar-tone="inverse" (the menu curtain's contract); anything else is ground.
|
|
21
|
+
// The real bar (the links) sits on top with transparent text: one focusable bar, never three. Hover and keyboard focus
|
|
22
|
+
// on a real link are mirrored onto the copies, so the focus ring is drawn in the right ink too.
|
|
23
|
+
//
|
|
24
|
+
// Mount it once at the top of the page; it is fixed and needs no wrapper:
|
|
25
|
+
// <HeaderBar variant="quiet" name="Noord" links={[{ label: "Werk", href: "/werk" }]} startTone="photo"
|
|
26
|
+
// action={{ label: "Plan een gesprek", href: "/contact" }} menuHref="#menu" />
|
|
27
|
+
// Keep the hero's type below var(--hb-top) (set on the nearest [data-blocks]: the bar's height, or for settle the
|
|
28
|
+
// bottom of the large wordmark). On phones the bar is the wordmark and one "Menu" link: pass onMenu to open a menu
|
|
29
|
+
// (the menu curtain's job) or menuHref to go to one.
|
|
30
|
+
//
|
|
31
|
+
// Lenis is never driven, only read: the bar follows the scroll the page already has. Reduced motion: no hiding and no
|
|
32
|
+
// travel (settle starts as the plain bar), the scrolled ground cuts in, and a hover underlines instead of rolling.
|
|
33
|
+
// Without JavaScript the plain bar stands in the start tone's ink, and nothing waits.
|
|
34
|
+
import { useEffect, useRef, useState, type CSSProperties, type FocusEvent, type HTMLAttributes, type PointerEvent } from "react";
|
|
35
|
+
import { gsap, ScrollTrigger, useGSAP } from "../kit/gsap";
|
|
36
|
+
|
|
37
|
+
export const HEADER_BAR_VARIANTS = [
|
|
38
|
+
{ id: "quiet", title: "De stille balk" },
|
|
39
|
+
{ id: "settle", title: "Het landende woordmerk" },
|
|
40
|
+
{ id: "split", title: "De gedeelde balk" },
|
|
41
|
+
] as const;
|
|
42
|
+
export type HeaderBarVariant = (typeof HEADER_BAR_VARIANTS)[number]["id"];
|
|
43
|
+
|
|
44
|
+
export type HeaderLink = { label: string; href: string };
|
|
45
|
+
export type BarTone = "ground" | "photo" | "inverse";
|
|
46
|
+
|
|
47
|
+
const TONES: BarTone[] = ["ground", "photo", "inverse"];
|
|
48
|
+
const TONE_INK: Record<BarTone, string> = { ground: "text-ink", photo: "text-on-photo", inverse: "text-on-inverse" };
|
|
49
|
+
// Over a photo the scrolled bar takes the palette's own dark for bands over photos (--photo-ground: the inverse on a
|
|
50
|
+
// light palette, the page ground on a dark one), nearly opaque, over a blur of what passes under it, so the hero's type
|
|
51
|
+
// runs under the bar quiet and soft and the band never reads as a letterbox beside the page.
|
|
52
|
+
const TONE_GROUND: Record<BarTone, string> = {
|
|
53
|
+
ground: "bg-surface-0",
|
|
54
|
+
photo: "bg-[color-mix(in_oklab,var(--photo-ground,var(--inverse))_88%,transparent)]",
|
|
55
|
+
inverse: "bg-inverse",
|
|
56
|
+
};
|
|
57
|
+
const NONE = "polygon(0px 0px, 0px 0px, 0px 0px)";
|
|
58
|
+
/** The display face is waited for at most this long before the large wordmark is measured. */
|
|
59
|
+
const FONT_CAP = 800;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* settle: the wordmark's advance, estimated from its letters, so the CSS can set it large across the hero from the
|
|
63
|
+
* first paint, before any script. The CSS reads it against the face actually on screen (half em, half ch, whose
|
|
64
|
+
* errors run opposite ways between a narrow serif and a wide sans: about two percent on the lab's faces); the script
|
|
65
|
+
* measures the real face and eases out what is left. Widths for a face of average width, tracking included.
|
|
66
|
+
*/
|
|
67
|
+
function advanceEm(text: string) {
|
|
68
|
+
let w = 0;
|
|
69
|
+
for (const c of Array.from(text)) {
|
|
70
|
+
if (c === " ") w += 0.28;
|
|
71
|
+
else if ("ijl.,'".includes(c)) w += 0.3;
|
|
72
|
+
else if ("ftrI".includes(c)) w += 0.42;
|
|
73
|
+
else if ("mMW".includes(c)) w += 0.95;
|
|
74
|
+
else if (c === "w") w += 0.82;
|
|
75
|
+
else if (c !== c.toLowerCase()) w += 0.8;
|
|
76
|
+
else w += 0.63;
|
|
77
|
+
}
|
|
78
|
+
return Math.max(0.5, w - 0.02 * text.length);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type FaceProps = {
|
|
82
|
+
real?: boolean;
|
|
83
|
+
variant: HeaderBarVariant;
|
|
84
|
+
name: string;
|
|
85
|
+
homeHref: string;
|
|
86
|
+
links: HeaderLink[];
|
|
87
|
+
action?: HeaderLink;
|
|
88
|
+
menuHref: string;
|
|
89
|
+
onMenu?: () => void;
|
|
90
|
+
place: string;
|
|
91
|
+
time: string | null;
|
|
92
|
+
/** The copy's ground, for the copies only. */
|
|
93
|
+
tone?: BarTone;
|
|
94
|
+
bind?: (i: number) => HTMLAttributes<HTMLElement>;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** A label set letter by letter, each letter in its own mask with its copy waiting below (quiet's roll). */
|
|
98
|
+
function Letters({ text }: { text: string }) {
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
{Array.from(text).map((c, k) => (
|
|
102
|
+
<span key={k} data-hb-ch="" style={{ "--i": k } as CSSProperties}>
|
|
103
|
+
<span>{c}</span>
|
|
104
|
+
<span>{c}</span>
|
|
105
|
+
</span>
|
|
106
|
+
))}
|
|
107
|
+
</>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The bar's face. Drawn once for real (links, transparent text once the copies show) and as aria-hidden copies in every
|
|
113
|
+
* ink, so all of them lay out alike to the pixel.
|
|
114
|
+
*/
|
|
115
|
+
function Face({ real = false, tone = "ground", variant, name, homeHref, links, action, menuHref, onMenu, place, time, bind }: FaceProps) {
|
|
116
|
+
const n = links.length;
|
|
117
|
+
const split = variant === "split";
|
|
118
|
+
// Where the full bar starts: split needs the room for three parts.
|
|
119
|
+
const wide = split ? "max-lg:hidden" : "max-md:hidden";
|
|
120
|
+
const narrow = split ? "lg:hidden" : "md:hidden";
|
|
121
|
+
const on = (i: number) => (real && bind ? bind(i) : {});
|
|
122
|
+
|
|
123
|
+
const label = (text: string) =>
|
|
124
|
+
variant === "quiet" ? (
|
|
125
|
+
<>
|
|
126
|
+
{real ? <span className="sr-only">{text}</span> : null}
|
|
127
|
+
<span aria-hidden={real || undefined} data-hb-label="" className="inline-flex">
|
|
128
|
+
<Letters text={text} />
|
|
129
|
+
</span>
|
|
130
|
+
</>
|
|
131
|
+
) : (
|
|
132
|
+
<span data-hb-label="">{text}</span>
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const link = (i: number, item: HeaderLink, isAction = false) => {
|
|
136
|
+
const cls = `relative inline-flex whitespace-nowrap py-2 text-small leading-none ${isAction ? "ml-3" : ""}`;
|
|
137
|
+
const flag = isAction ? { "data-hb-action": "" } : {};
|
|
138
|
+
return real ? (
|
|
139
|
+
<a href={item.href} data-hb-link={i} {...flag} className={cls} {...on(i)}>
|
|
140
|
+
{label(item.label)}
|
|
141
|
+
</a>
|
|
142
|
+
) : (
|
|
143
|
+
<span data-hb-link={i} {...flag} className={cls}>
|
|
144
|
+
{label(item.label)}
|
|
145
|
+
</span>
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const word = (
|
|
150
|
+
<span data-hb-wmask="" className="block">
|
|
151
|
+
<span data-hb-wroll="" className="relative block">
|
|
152
|
+
<span className="block">{name}</span>
|
|
153
|
+
<span aria-hidden="true" className="absolute left-0 block">
|
|
154
|
+
{name}
|
|
155
|
+
</span>
|
|
156
|
+
</span>
|
|
157
|
+
</span>
|
|
158
|
+
);
|
|
159
|
+
const wordCls = "relative block origin-top-left font-display text-[1.375rem] leading-none tracking-[-0.02em] [justify-self:start]";
|
|
160
|
+
|
|
161
|
+
const list = (
|
|
162
|
+
<ul data-hb-list="" className={`relative m-0 flex list-none items-center p-0 ${split ? "gap-x-8" : "gap-x-7"}`}>
|
|
163
|
+
{links.map((item, i) => (
|
|
164
|
+
<li key={item.label}>{link(i, item)}</li>
|
|
165
|
+
))}
|
|
166
|
+
{split ? <li aria-hidden="true" data-hb-mark="" className="pointer-events-none absolute left-0 h-px bg-current" /> : null}
|
|
167
|
+
</ul>
|
|
168
|
+
);
|
|
169
|
+
const act = action ? link(n, action, true) : null;
|
|
170
|
+
|
|
171
|
+
const menu = onMenu && real ? (
|
|
172
|
+
<button type="button" data-hb-link={n + 1} onClick={onMenu} className="relative inline-flex cursor-pointer bg-transparent py-2 text-small leading-none" {...on(n + 1)}>
|
|
173
|
+
{label("Menu")}
|
|
174
|
+
</button>
|
|
175
|
+
) : (
|
|
176
|
+
link(n + 1, { label: "Menu", href: menuHref })
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const Nav = real ? "nav" : "div";
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div
|
|
183
|
+
data-hb-face=""
|
|
184
|
+
className={`relative h-[var(--hb-h)] items-center gap-6 px-6 md:px-[3vw] ${split ? "grid grid-cols-[1fr_auto_1fr]" : "flex justify-between"}`}
|
|
185
|
+
>
|
|
186
|
+
{real ? null : <div data-hb-ground="" aria-hidden="true" className={`absolute inset-0 ${TONE_GROUND[tone]}`} />}
|
|
187
|
+
{real ? (
|
|
188
|
+
<a href={homeHref} data-hb-word="" data-hb-link="w" className={wordCls} {...on(-1)}>
|
|
189
|
+
{word}
|
|
190
|
+
</a>
|
|
191
|
+
) : (
|
|
192
|
+
<span data-hb-word="" data-hb-link="w" className={wordCls}>
|
|
193
|
+
{word}
|
|
194
|
+
</span>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{split ? (
|
|
198
|
+
<>
|
|
199
|
+
<Nav data-hb-fade="" {...(real ? { "aria-label": "Hoofdmenu" } : {})} className={`relative justify-self-center ${wide}`}>
|
|
200
|
+
{list}
|
|
201
|
+
</Nav>
|
|
202
|
+
<div data-hb-fade="" className={`relative flex items-center gap-8 justify-self-end ${wide}`}>
|
|
203
|
+
<span className="whitespace-nowrap text-small leading-none">
|
|
204
|
+
<span data-hb-place="">{place}</span>{" "}
|
|
205
|
+
<time data-hb-time="" data-pending={time ? undefined : ""} className="tabular-nums">
|
|
206
|
+
{time ?? "00:00"}
|
|
207
|
+
</time>
|
|
208
|
+
</span>
|
|
209
|
+
{act}
|
|
210
|
+
</div>
|
|
211
|
+
</>
|
|
212
|
+
) : (
|
|
213
|
+
<Nav data-hb-fade="" {...(real ? { "aria-label": "Hoofdmenu" } : {})} className={`relative flex items-center gap-x-7 ${wide}`}>
|
|
214
|
+
{list}
|
|
215
|
+
{act}
|
|
216
|
+
</Nav>
|
|
217
|
+
)}
|
|
218
|
+
<div className={`relative ${narrow} ${split ? "justify-self-end" : ""}`}>
|
|
219
|
+
{menu}
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
{real ? null : variant === "split" ? (
|
|
223
|
+
<div aria-hidden="true" className="absolute inset-x-0 bottom-0 h-px">
|
|
224
|
+
<div data-hb-track="" className="absolute inset-0 bg-current" />
|
|
225
|
+
<div data-hb-fill="" className="absolute inset-0 origin-left bg-current" style={{ transform: "scaleX(0)" }} />
|
|
226
|
+
</div>
|
|
227
|
+
) : variant === "quiet" ? (
|
|
228
|
+
<div data-hb-line="" aria-hidden="true" className="absolute inset-x-0 bottom-0 h-px bg-current" />
|
|
229
|
+
) : null}
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The studio's wall time: none on the server and the first paint, the real one after mount, kept on the minute. */
|
|
235
|
+
function useWallTime(on: boolean, timeZone: string) {
|
|
236
|
+
const [time, setTime] = useState<string | null>(null);
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
if (!on) return;
|
|
239
|
+
const fmt = new Intl.DateTimeFormat("nl-NL", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone });
|
|
240
|
+
let timer = 0;
|
|
241
|
+
const tick = () => {
|
|
242
|
+
const now = new Date();
|
|
243
|
+
setTime(fmt.format(now));
|
|
244
|
+
window.clearTimeout(timer);
|
|
245
|
+
timer = window.setTimeout(tick, 60_000 - (now.getSeconds() * 1000 + now.getMilliseconds()) + 40);
|
|
246
|
+
};
|
|
247
|
+
tick();
|
|
248
|
+
// Timers in a background tab are held back; on return the time is set at once.
|
|
249
|
+
const onShow = () => document.visibilityState === "visible" && tick();
|
|
250
|
+
document.addEventListener("visibilitychange", onShow);
|
|
251
|
+
return () => {
|
|
252
|
+
window.clearTimeout(timer);
|
|
253
|
+
document.removeEventListener("visibilitychange", onShow);
|
|
254
|
+
};
|
|
255
|
+
}, [on, timeZone]);
|
|
256
|
+
return time;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function HeaderBar({
|
|
260
|
+
variant = "quiet",
|
|
261
|
+
name,
|
|
262
|
+
homeHref = "/",
|
|
263
|
+
links,
|
|
264
|
+
action,
|
|
265
|
+
menuHref = "#menu",
|
|
266
|
+
onMenu,
|
|
267
|
+
place = "Rotterdam",
|
|
268
|
+
timeZone = "Europe/Amsterdam",
|
|
269
|
+
startTone = "ground",
|
|
270
|
+
}: {
|
|
271
|
+
variant?: HeaderBarVariant;
|
|
272
|
+
/** The wordmark. */
|
|
273
|
+
name: string;
|
|
274
|
+
homeHref?: string;
|
|
275
|
+
/** Three or four links. */
|
|
276
|
+
links: HeaderLink[];
|
|
277
|
+
/** The one quiet action, set as a text link after the links. */
|
|
278
|
+
action?: HeaderLink;
|
|
279
|
+
/** Where the phone's "Menu" link goes, when there is no onMenu. */
|
|
280
|
+
menuHref?: string;
|
|
281
|
+
/** Opens the site's menu (the menu curtain); the phone's "Menu" is then a button. */
|
|
282
|
+
onMenu?: () => void;
|
|
283
|
+
/** split: the place whose local time the bar keeps. */
|
|
284
|
+
place?: string;
|
|
285
|
+
timeZone?: string;
|
|
286
|
+
/** The ground under the bar on the first paint, before the script measures it (the hero's, usually "photo"). */
|
|
287
|
+
startTone?: BarTone;
|
|
288
|
+
}) {
|
|
289
|
+
const root = useRef<HTMLDivElement>(null);
|
|
290
|
+
const api = useRef<{ hot: (i: number, on: boolean, from?: "left" | "right") => void; focus: (on: boolean) => void } | null>(null);
|
|
291
|
+
const time = useWallTime(variant === "split", timeZone);
|
|
292
|
+
|
|
293
|
+
const bind = (i: number) => ({
|
|
294
|
+
onPointerEnter: (e: PointerEvent<HTMLElement>) => {
|
|
295
|
+
if (e.pointerType === "touch") return;
|
|
296
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
297
|
+
api.current?.hot(i, true, e.clientX < r.left + r.width / 2 ? "left" : "right");
|
|
298
|
+
},
|
|
299
|
+
onPointerLeave: (e: PointerEvent<HTMLElement>) => {
|
|
300
|
+
if (e.currentTarget === document.activeElement && e.currentTarget.matches(":focus-visible")) return;
|
|
301
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
302
|
+
api.current?.hot(i, false, e.clientX < r.left + r.width / 2 ? "left" : "right");
|
|
303
|
+
},
|
|
304
|
+
onFocus: (e: FocusEvent<HTMLElement>) => {
|
|
305
|
+
if (!e.currentTarget.matches(":focus-visible")) return;
|
|
306
|
+
e.currentTarget.setAttribute("data-kb", "");
|
|
307
|
+
api.current?.hot(i, true, "left");
|
|
308
|
+
api.current?.focus(true);
|
|
309
|
+
},
|
|
310
|
+
onBlur: (e: FocusEvent<HTMLElement>) => {
|
|
311
|
+
e.currentTarget.removeAttribute("data-kb");
|
|
312
|
+
api.current?.hot(i, false, "right");
|
|
313
|
+
api.current?.focus(false);
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
useGSAP(
|
|
318
|
+
() => {
|
|
319
|
+
const el = root.current;
|
|
320
|
+
if (!el) return;
|
|
321
|
+
const q = gsap.utils.selector(el);
|
|
322
|
+
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
323
|
+
const host = el.closest<HTMLElement>("[data-blocks]") ?? document.documentElement;
|
|
324
|
+
|
|
325
|
+
const move = q("[data-hb-move]")[0] as HTMLElement;
|
|
326
|
+
const copies = TONES.map((t) => q(`[data-hb-copy="${t}"]`)[0] as HTMLElement);
|
|
327
|
+
const realFace = q("[data-hb-real] [data-hb-face]")[0] as HTMLElement;
|
|
328
|
+
const realWord = q("[data-hb-real] [data-hb-word]")[0] as HTMLElement;
|
|
329
|
+
const words = q("[data-hb-word]");
|
|
330
|
+
const fades = q("[data-hb-fade]");
|
|
331
|
+
const fills = q("[data-hb-fill]");
|
|
332
|
+
const blur = q("[data-hb-blur]")[0] as HTMLElement | undefined;
|
|
333
|
+
if (!move || !realFace || !realWord) return;
|
|
334
|
+
|
|
335
|
+
let barH = realFace.offsetHeight;
|
|
336
|
+
let maxY = 1;
|
|
337
|
+
// settle: the large wordmark's scale, its two tops and the stretch of scroll it lands over
|
|
338
|
+
let S = 1;
|
|
339
|
+
let smallTop = 0;
|
|
340
|
+
let bigTop = 0;
|
|
341
|
+
let wordH = 0;
|
|
342
|
+
let D = 1;
|
|
343
|
+
// settle: the scroll at which the shrinking wordmark first fits inside the bar; the ground waits for it.
|
|
344
|
+
let yFit = 0;
|
|
345
|
+
// settle: the scale shown on the first screen, eased from the CSS estimate to the measured S once.
|
|
346
|
+
const fit = { s: 1 };
|
|
347
|
+
let fitTw: gsap.core.Tween | null = null;
|
|
348
|
+
let measured = variant !== "settle" || reduce;
|
|
349
|
+
|
|
350
|
+
// quiet: how far the bar is pushed up, wished (aim) and shown (o, eased toward the wish)
|
|
351
|
+
let aim = 0;
|
|
352
|
+
const shown = { o: 0 };
|
|
353
|
+
let lastY = window.scrollY;
|
|
354
|
+
let focusIn = false;
|
|
355
|
+
let rest = 0;
|
|
356
|
+
|
|
357
|
+
const measure = () => {
|
|
358
|
+
barH = realFace.offsetHeight;
|
|
359
|
+
maxY = Math.max(1, ScrollTrigger.maxScroll(window));
|
|
360
|
+
if (variant !== "settle") {
|
|
361
|
+
host.style.setProperty("--hb-top", `${barH}px`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const left = realWord.offsetLeft;
|
|
365
|
+
smallTop = realWord.offsetTop;
|
|
366
|
+
wordH = realWord.offsetHeight;
|
|
367
|
+
const wordW = realWord.offsetWidth;
|
|
368
|
+
if (reduce || !wordW || !wordH) {
|
|
369
|
+
fitTw?.kill();
|
|
370
|
+
S = 1;
|
|
371
|
+
fit.s = 1;
|
|
372
|
+
yFit = 0;
|
|
373
|
+
host.style.setProperty("--hb-top", `${barH}px`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// Across the hero's top, from gutter to gutter, but never taller than about four tenths of the screen.
|
|
377
|
+
S = Math.max(1, Math.min((window.innerWidth - 2 * left) / wordW, (window.innerHeight * 0.55) / wordH));
|
|
378
|
+
// A refresh while the first-paint estimate is still easing in carries the ease on to the new size, never a cut.
|
|
379
|
+
if (fitTw?.isActive()) {
|
|
380
|
+
fitTw.kill();
|
|
381
|
+
fitTw = gsap.to(fit, { s: S, duration: 0.45, ease: "power2.out", onUpdate: paint });
|
|
382
|
+
} else fit.s = S;
|
|
383
|
+
bigTop = Math.round(barH * 0.5);
|
|
384
|
+
D = Math.max(barH, Math.round(bigTop + S * wordH - barH));
|
|
385
|
+
// The wordmark's bottom shrinks with the scroll; find where it first sits inside the bar's own height.
|
|
386
|
+
const bottom = (p: number) => smallTop + (bigTop - smallTop) * (1 - p) + Math.pow(S, 1 - p) * wordH;
|
|
387
|
+
let lo = 0;
|
|
388
|
+
let hi = 1;
|
|
389
|
+
if (bottom(0) <= barH) hi = 0;
|
|
390
|
+
else
|
|
391
|
+
for (let i = 0; i < 24; i += 1) {
|
|
392
|
+
const mid = (lo + hi) / 2;
|
|
393
|
+
if (bottom(mid) > barH) lo = mid;
|
|
394
|
+
else hi = mid;
|
|
395
|
+
}
|
|
396
|
+
yFit = Math.round(hi * D);
|
|
397
|
+
host.style.setProperty("--hb-top", `${Math.round(bigTop + S * wordH + barH * 0.25)}px`);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// --- the ink, cut where the sections under the bar meet, in the bar's own coordinates ---
|
|
401
|
+
const ink = (o: number, extent: number) => {
|
|
402
|
+
const segs: Array<[number, number, BarTone]> = [[0, extent, "ground"]];
|
|
403
|
+
document.querySelectorAll<HTMLElement>("[data-bar-tone]").forEach((sec) => {
|
|
404
|
+
const tone = sec.dataset.barTone;
|
|
405
|
+
if ((tone !== "photo" && tone !== "inverse") || el.contains(sec)) return;
|
|
406
|
+
const r = sec.getBoundingClientRect();
|
|
407
|
+
const a = Math.max(0, r.top + o);
|
|
408
|
+
const b = Math.min(extent, r.bottom + o);
|
|
409
|
+
if (b <= a) return;
|
|
410
|
+
// Later sections in document order (the nested ones) paint over earlier ones.
|
|
411
|
+
for (let k = segs.length - 1; k >= 0; k -= 1) {
|
|
412
|
+
const [s0, s1, t] = segs[k];
|
|
413
|
+
if (s1 <= a || s0 >= b) continue;
|
|
414
|
+
const parts: Array<[number, number, BarTone]> = [];
|
|
415
|
+
if (s0 < a) parts.push([s0, a, t]);
|
|
416
|
+
parts.push([Math.max(s0, a), Math.min(s1, b), tone]);
|
|
417
|
+
if (s1 > b) parts.push([b, s1, t]);
|
|
418
|
+
segs.splice(k, 1, ...parts);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
TONES.forEach((tone, i) => {
|
|
422
|
+
const pts: string[] = [];
|
|
423
|
+
for (const [a, b, t] of segs) {
|
|
424
|
+
if (t !== tone || b - a <= 0) continue;
|
|
425
|
+
pts.push(`0px ${a}px`, `100% ${a}px`, `100% ${b}px`, `0px ${b}px`);
|
|
426
|
+
}
|
|
427
|
+
copies[i].style.clipPath = pts.length ? `polygon(${pts.join(", ")})` : NONE;
|
|
428
|
+
});
|
|
429
|
+
// The blur sits under the copies (a clipped copy would blur nothing), cut to the photo's stretches like its ink.
|
|
430
|
+
if (blur) blur.style.clipPath = copies[1].style.clipPath;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const paint = () => {
|
|
434
|
+
const y = Math.max(0, window.scrollY);
|
|
435
|
+
let o = 0;
|
|
436
|
+
let extent = barH + 1;
|
|
437
|
+
|
|
438
|
+
if (variant === "quiet") {
|
|
439
|
+
o = shown.o;
|
|
440
|
+
move.style.transform = `translate3d(0, ${-Math.round(o)}px, 0)`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (variant === "settle" && measured) {
|
|
444
|
+
const p = fit.s > 1 ? Math.min(1, y / D) : 1;
|
|
445
|
+
const s = Math.pow(fit.s, 1 - p);
|
|
446
|
+
const dy = Math.round((bigTop - smallTop) * (1 - p));
|
|
447
|
+
const tf = p >= 1 ? "" : `translate3d(0, ${dy}px, 0) scale(${s.toFixed(5)})`;
|
|
448
|
+
words.forEach((w) => (w.style.transform = tf));
|
|
449
|
+
extent = Math.max(extent, smallTop + dy + s * wordH + 8);
|
|
450
|
+
// The links arrive over the last quarter of the landing; a link in focus is always shown.
|
|
451
|
+
const a = focusIn ? 1 : Math.min(1, Math.max(0, (p - 0.72) / 0.28));
|
|
452
|
+
const lift = Math.round((1 - a) * 6);
|
|
453
|
+
fades.forEach((f) => {
|
|
454
|
+
f.style.opacity = String(a);
|
|
455
|
+
f.style.transform = lift ? `translate3d(0, ${lift}px, 0)` : "";
|
|
456
|
+
});
|
|
457
|
+
el.toggleAttribute("data-links-off", a < 0.5);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (variant === "split") {
|
|
461
|
+
const prog = Math.min(1, y / maxY);
|
|
462
|
+
fills.forEach((f) => (f.style.transform = `scaleX(${prog.toFixed(4)})`));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// The scrolled state (the ground under the bar, quiet's hairline) arrives over the bar's own height of scroll;
|
|
466
|
+
// settle's only once the wordmark fits inside the bar, and it is gone again before the wordmark grows out of it.
|
|
467
|
+
const from = variant === "settle" && S > 1 ? yFit : 0;
|
|
468
|
+
const k = reduce ? (y > 2 ? 1 : 0) : Math.min(1, Math.max(0, (y - from) / barH));
|
|
469
|
+
el.style.setProperty("--hb-k", k.toFixed(3));
|
|
470
|
+
ink(o, extent);
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// quiet: the wish follows the scroll pixel for pixel, the bar eases after it; at rest it settles to a side.
|
|
474
|
+
let tw: gsap.core.Tween | null = null;
|
|
475
|
+
const ease = (to: number, duration: number) => {
|
|
476
|
+
tw?.kill();
|
|
477
|
+
tw = gsap.to(shown, { o: to, duration, ease: "power3.out", onUpdate: paint });
|
|
478
|
+
};
|
|
479
|
+
const settleTo = (to: number) => {
|
|
480
|
+
aim = to;
|
|
481
|
+
ease(to, 0.32);
|
|
482
|
+
};
|
|
483
|
+
const onScroll = () => {
|
|
484
|
+
const y = Math.max(0, window.scrollY);
|
|
485
|
+
const dy = y - lastY;
|
|
486
|
+
lastY = y;
|
|
487
|
+
if (dy === 0) return;
|
|
488
|
+
if (variant === "quiet" && !reduce) {
|
|
489
|
+
const cap = Math.min(barH + 1, y);
|
|
490
|
+
aim = focusIn ? 0 : Math.min(cap, Math.max(0, aim + dy));
|
|
491
|
+
if (Math.abs(aim - shown.o) > 0.01) ease(aim, 0.18);
|
|
492
|
+
window.clearTimeout(rest);
|
|
493
|
+
rest = window.setTimeout(() => {
|
|
494
|
+
if (aim > 0 && aim < barH + 1) settleTo(aim > (barH + 1) / 2 ? Math.min(barH + 1, window.scrollY) : 0);
|
|
495
|
+
}, 160);
|
|
496
|
+
}
|
|
497
|
+
paint();
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// On Lenis this runs inside its scroll (ScrollTrigger.update), so the bar never trails the page by a frame.
|
|
501
|
+
const watch = ScrollTrigger.create({
|
|
502
|
+
start: 0,
|
|
503
|
+
end: "max",
|
|
504
|
+
onUpdate: onScroll,
|
|
505
|
+
onRefresh: () => {
|
|
506
|
+
measure();
|
|
507
|
+
paint();
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
511
|
+
|
|
512
|
+
// --- hover and focus, mirrored onto every copy ---
|
|
513
|
+
const linksOf = (i: number) => q(`[data-hb-link="${i < 0 ? "w" : i}"]`);
|
|
514
|
+
let hotCount = 0;
|
|
515
|
+
let markOn = false;
|
|
516
|
+
const hot = (i: number, on: boolean, from: "left" | "right" = "left") => {
|
|
517
|
+
const els = linksOf(i);
|
|
518
|
+
const was = els[0]?.hasAttribute("data-hot") ?? false;
|
|
519
|
+
if (was === on) return;
|
|
520
|
+
hotCount = Math.max(0, hotCount + (on ? 1 : -1));
|
|
521
|
+
els.forEach((a) => {
|
|
522
|
+
a.style.setProperty("--hb-o", from);
|
|
523
|
+
a.toggleAttribute("data-hot", on);
|
|
524
|
+
});
|
|
525
|
+
el.toggleAttribute("data-hot-any", hotCount > 0);
|
|
526
|
+
if (variant === "split" && i >= 0 && i < links.length) {
|
|
527
|
+
if (on) {
|
|
528
|
+
const a = els[els.length - 1];
|
|
529
|
+
const realLink = q(`[data-hb-real] [data-hb-link="${i}"]`)[0] as HTMLElement | undefined;
|
|
530
|
+
const src = realLink ?? a;
|
|
531
|
+
if (!markOn) {
|
|
532
|
+
el.setAttribute("data-mark-snap", "");
|
|
533
|
+
requestAnimationFrame(() => requestAnimationFrame(() => el.removeAttribute("data-mark-snap")));
|
|
534
|
+
}
|
|
535
|
+
el.style.setProperty("--hb-mx", `${src.offsetLeft}px`);
|
|
536
|
+
el.style.setProperty("--hb-mw", `${src.offsetWidth}px`);
|
|
537
|
+
el.setAttribute("data-mark", "");
|
|
538
|
+
markOn = true;
|
|
539
|
+
} else {
|
|
540
|
+
// Leaving one link for the next keeps the line; leaving the list lets it go.
|
|
541
|
+
window.setTimeout(() => {
|
|
542
|
+
if (!q("[data-hb-real] [data-hb-list] [data-hot]").length) {
|
|
543
|
+
el.removeAttribute("data-mark");
|
|
544
|
+
markOn = false;
|
|
545
|
+
}
|
|
546
|
+
}, 60);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
const focus = (on: boolean) => {
|
|
551
|
+
// Mirror the keyboard's ring.
|
|
552
|
+
q("[data-hb-link][data-focus]").forEach((a) => a.removeAttribute("data-focus"));
|
|
553
|
+
const cur = document.activeElement;
|
|
554
|
+
if (on && cur instanceof HTMLElement && el.contains(cur)) {
|
|
555
|
+
const id = cur.getAttribute("data-hb-link");
|
|
556
|
+
if (id !== null) q(`[data-hb-link="${id}"]`).forEach((a) => a.setAttribute("data-focus", ""));
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
api.current = { hot, focus };
|
|
560
|
+
|
|
561
|
+
const real = q("[data-hb-real]")[0] as HTMLElement;
|
|
562
|
+
const onFocusIn = () => {
|
|
563
|
+
focusIn = true;
|
|
564
|
+
if (variant === "quiet") settleTo(0);
|
|
565
|
+
paint();
|
|
566
|
+
};
|
|
567
|
+
const onFocusOut = (e: globalThis.FocusEvent) => {
|
|
568
|
+
if (e.relatedTarget instanceof Node && real.contains(e.relatedTarget)) return;
|
|
569
|
+
focusIn = false;
|
|
570
|
+
paint();
|
|
571
|
+
};
|
|
572
|
+
real.addEventListener("focusin", onFocusIn);
|
|
573
|
+
real.addEventListener("focusout", onFocusOut);
|
|
574
|
+
|
|
575
|
+
const onResize = () => {
|
|
576
|
+
measure();
|
|
577
|
+
paint();
|
|
578
|
+
};
|
|
579
|
+
window.addEventListener("resize", onResize);
|
|
580
|
+
|
|
581
|
+
// --- start: settle's large wordmark already stands from the first paint at the CSS estimate (its scale from the
|
|
582
|
+
// estimated advance); the script waits for the display face (capped), measures the real width and eases the
|
|
583
|
+
// estimate into it, a few pixels, while the wordmark is still rising into view. ---
|
|
584
|
+
let alive = true;
|
|
585
|
+
const start = () => {
|
|
586
|
+
if (!alive) return;
|
|
587
|
+
const shownScale = variant === "settle" ? parseFloat(getComputedStyle(realWord).scale) : NaN;
|
|
588
|
+
measure();
|
|
589
|
+
measured = true;
|
|
590
|
+
lastY = window.scrollY;
|
|
591
|
+
const target = fit.s;
|
|
592
|
+
if (variant === "settle" && !reduce && target > 1 && shownScale > 1 && Math.abs(shownScale - target) > 0.001) {
|
|
593
|
+
fit.s = shownScale;
|
|
594
|
+
fitTw = gsap.to(fit, { s: target, duration: 0.6, ease: "power2.out", onUpdate: paint });
|
|
595
|
+
}
|
|
596
|
+
paint();
|
|
597
|
+
el.setAttribute("data-ready", "");
|
|
598
|
+
};
|
|
599
|
+
if (variant === "settle" && !reduce) {
|
|
600
|
+
const fonts = document.fonts?.ready ?? Promise.resolve();
|
|
601
|
+
Promise.race([fonts, new Promise((res) => window.setTimeout(res, FONT_CAP))]).then(start);
|
|
602
|
+
} else start();
|
|
603
|
+
|
|
604
|
+
return () => {
|
|
605
|
+
alive = false;
|
|
606
|
+
fitTw?.kill();
|
|
607
|
+
tw?.kill();
|
|
608
|
+
watch.kill();
|
|
609
|
+
window.clearTimeout(rest);
|
|
610
|
+
window.removeEventListener("scroll", onScroll);
|
|
611
|
+
window.removeEventListener("resize", onResize);
|
|
612
|
+
real.removeEventListener("focusin", onFocusIn);
|
|
613
|
+
real.removeEventListener("focusout", onFocusOut);
|
|
614
|
+
host.style.removeProperty("--hb-top");
|
|
615
|
+
api.current = null;
|
|
616
|
+
};
|
|
617
|
+
},
|
|
618
|
+
{ scope: root, dependencies: [variant, links.length] },
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
const face = { variant, name, homeHref, links, action, menuHref, onMenu, place, time };
|
|
622
|
+
|
|
623
|
+
return (
|
|
624
|
+
<div ref={root} data-header-bar={variant} data-start={startTone} style={{ "--hb-adv": advanceEm(name).toFixed(3) } as CSSProperties}>
|
|
625
|
+
<div data-hb-move="" className="pointer-events-none fixed inset-x-0 top-0 z-[70]">
|
|
626
|
+
{/* Over a photo the scrolled ground is see-through at its edge: what passes under it is blurred, not sharp. */}
|
|
627
|
+
<div
|
|
628
|
+
aria-hidden="true"
|
|
629
|
+
data-hb-blur=""
|
|
630
|
+
className="absolute inset-x-0 top-0 h-[var(--hb-h)] backdrop-blur-md"
|
|
631
|
+
style={{ clipPath: startTone === "photo" ? "none" : NONE }}
|
|
632
|
+
/>
|
|
633
|
+
{/* The bar's ink over the page: one copy per ground, each clipped to the stretches it covers. */}
|
|
634
|
+
{TONES.map((tone) => (
|
|
635
|
+
<div
|
|
636
|
+
key={tone}
|
|
637
|
+
aria-hidden="true"
|
|
638
|
+
data-hb-copy={tone}
|
|
639
|
+
className={`absolute inset-x-0 top-0 ${TONE_INK[tone]}`}
|
|
640
|
+
style={{ clipPath: tone === startTone ? "none" : NONE }}
|
|
641
|
+
>
|
|
642
|
+
<Face {...face} tone={tone} />
|
|
643
|
+
</div>
|
|
644
|
+
))}
|
|
645
|
+
{/* The real bar: the links, on top, their ink drawn by the copies under them once those show. */}
|
|
646
|
+
<header data-hb-real="" className={`relative ${TONE_INK[startTone]}`}>
|
|
647
|
+
<Face {...face} real bind={bind} />
|
|
648
|
+
</header>
|
|
649
|
+
</div>
|
|
650
|
+
</div>
|
|
651
|
+
);
|
|
652
|
+
}
|