@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,763 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// cta-closing (Struq section recipe)
|
|
3
|
+
// The page's last screen before the footer: one line at display size in a light weight (two or three short lines) and
|
|
4
|
+
// one link that is plainly the action without being a button: large type, an arrow, and a line under it that draws
|
|
5
|
+
// when the pointer comes. Nothing else. Three compositions:
|
|
6
|
+
//
|
|
7
|
+
// - photo: a photograph opens behind the line by registration as the section arrives (a layer already at full size
|
|
8
|
+
// whose clip opens from a centred window to full bleed, never a scale). The line stands on the photo's calmest corner
|
|
9
|
+
// over a light veil, all chosen by reading the photo: the side (left or right), the height (top or bottom), the ink
|
|
10
|
+
// (light or dark) and the veil's strength are whichever need the weakest veil that holds the line at 3:1 and the
|
|
11
|
+
// link at 4.5:1, never past 0.45, on the least busy ground.
|
|
12
|
+
// - marquee: the line runs as one slow band across the full width, a faint backdrop in the muted ink drawn far toward
|
|
13
|
+
// the ground; the reader's scroll speed nudges it faster and it settles back to its own pace when the reader stops.
|
|
14
|
+
// The link stands still at the centre in front of it, in the full ink.
|
|
15
|
+
// - pointer: plain ground, the line large and still. The link is the event: hovering it brings a small framed project
|
|
16
|
+
// below and beside the pointer, into the empty ground under the link, following it within about 0.18 s, and the
|
|
17
|
+
// link's line draws. On touch the frame stands beside the link from the start; with the keyboard it opens beside the
|
|
18
|
+
// link's end on focus, hanging down from its top.
|
|
19
|
+
//
|
|
20
|
+
// <CtaClosing variant="photo" lines={["Heb je een plek", "die beter kan?"]} link={{ href: "#gesprek", label: "Plan een
|
|
21
|
+
// gesprek" }} image={images.hero[2]} />
|
|
22
|
+
//
|
|
23
|
+
// Every arrival is scrubbed to the scroll over a short stretch with 0.4 s of smoothing, so scrolling back runs it
|
|
24
|
+
// backwards. Transforms are written in whole pixels and every clip from plain numbers. Each photograph is decoded
|
|
25
|
+
// before it can show. The line is sized to fit its column with the display face it is read in (the fit waits for the
|
|
26
|
+
// fonts, capped, and runs again on resize). Start states wait behind the `.js` / `.sq-js` gate with a 3 s CSS fallback
|
|
27
|
+
// (blocks.css, "--- cta-closing ---"). Reduced motion: the composed screen; the band is set as still lines and the
|
|
28
|
+
// project frame stands beside the link without following.
|
|
29
|
+
import Image from "next/image";
|
|
30
|
+
import { useId, useRef } from "react";
|
|
31
|
+
import { gsap, ScrollTrigger, useGSAP } from "../kit/gsap";
|
|
32
|
+
import type { StruqImage } from "../kit/images";
|
|
33
|
+
import { photoLuminances } from "../kit/photo-read";
|
|
34
|
+
|
|
35
|
+
export const CTA_CLOSING_VARIANTS = [
|
|
36
|
+
{ id: "photo", title: "Foto" },
|
|
37
|
+
{ id: "marquee", title: "Band" },
|
|
38
|
+
{ id: "pointer", title: "Aanwijzer" },
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export type CtaClosingVariant = (typeof CTA_CLOSING_VARIANTS)[number]["id"];
|
|
42
|
+
|
|
43
|
+
type Props = {
|
|
44
|
+
variant?: CtaClosingVariant;
|
|
45
|
+
/** The line, as the two or three short lines it is set in. Plain text. */
|
|
46
|
+
lines: string[];
|
|
47
|
+
/** The one action. */
|
|
48
|
+
link: { href: string; label: string };
|
|
49
|
+
/** photo: the photograph behind the line. pointer: the project in the frame. Not used by marquee. */
|
|
50
|
+
image?: StruqImage;
|
|
51
|
+
/** pointer: a short caption under the project frame (its name and place). */
|
|
52
|
+
caption?: string;
|
|
53
|
+
className?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Seconds of smoothing on every scrub: a camera settling, never a delay. */
|
|
57
|
+
const SMOOTH = 0.4;
|
|
58
|
+
/** The longest the fit waits for the display face, in ms. */
|
|
59
|
+
const FONT_CAP = 1200;
|
|
60
|
+
/** A line travels this share of its own height to its place. */
|
|
61
|
+
const RISE = 1.12;
|
|
62
|
+
/** The link's soft rise, in px. */
|
|
63
|
+
const SOFT = 24;
|
|
64
|
+
/** The photo's opening window: the share of the frame shut above and below it, and at each side (wide, narrow). */
|
|
65
|
+
const WINDOW = { y: 29, wide: 35, narrow: 18 };
|
|
66
|
+
/** The largest the line is set, in px, and the smallest. */
|
|
67
|
+
const SIZE = { max: 224, min: 36 };
|
|
68
|
+
/** Contrast: display type holds 3:1, the link holds 4.5:1; the veil never goes past 0.45. */
|
|
69
|
+
const HEAD_RATIO = 3;
|
|
70
|
+
const LINK_RATIO = 4.5;
|
|
71
|
+
const MAX_VEIL = 0.45;
|
|
72
|
+
/** The band's own pace (px/s; one whole pixel a frame at 60 Hz), the share of the scroll's speed it adds, the most
|
|
73
|
+
* scroll speed it answers, and the time constant that settles it once the reader stops. */
|
|
74
|
+
const BAND = { pace: 60, gain: 0.45, fast: 2400, tau: 0.16 };
|
|
75
|
+
/** Copies of the band's line: enough to cover any screen twice. */
|
|
76
|
+
const COPIES = 4;
|
|
77
|
+
/** The project frame's follow: a time constant of 0.06 s puts it within 5% of the pointer in 0.18 s. */
|
|
78
|
+
const FOLLOW_TAU = 0.06;
|
|
79
|
+
/** How far beside the pointer the frame stands, in px. */
|
|
80
|
+
const GAP = 28;
|
|
81
|
+
|
|
82
|
+
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
|
83
|
+
/** Where p sits inside the window [from, to], as 0 to 1. */
|
|
84
|
+
const within = (p: number, from: number, to: number) => clamp01((p - from) / (to - from));
|
|
85
|
+
const out3 = gsap.parseEase("power3.out");
|
|
86
|
+
const out2 = gsap.parseEase("power2.out");
|
|
87
|
+
const inOut3 = gsap.parseEase("power3.inOut");
|
|
88
|
+
|
|
89
|
+
const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
90
|
+
const encode = (l: number) => (l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055);
|
|
91
|
+
const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
92
|
+
|
|
93
|
+
/** The relative luminance of any CSS colour, through a one-pixel canvas so every colour syntax resolves. */
|
|
94
|
+
function luminanceOf(color: string): number {
|
|
95
|
+
const cv = document.createElement("canvas");
|
|
96
|
+
cv.width = 1;
|
|
97
|
+
cv.height = 1;
|
|
98
|
+
const ctx = cv.getContext("2d", { willReadFrequently: true });
|
|
99
|
+
if (!ctx) return 0;
|
|
100
|
+
ctx.fillStyle = "#000";
|
|
101
|
+
ctx.fillStyle = color;
|
|
102
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
103
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
104
|
+
return 0.2126 * linear(r / 255) + 0.7152 * linear(g / 255) + 0.0722 * linear(b / 255);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The weakest veil (composited in sRGB, as the browser does) that brings `under` to `ratio` against `ink`, with a
|
|
109
|
+
* score: the veil itself when it reaches the ratio, one more than the cap when even the cap falls short.
|
|
110
|
+
*/
|
|
111
|
+
function veilFor(under: number, ink: number, veilS: number, ratio: number): number {
|
|
112
|
+
if (contrast(under, ink) >= ratio) return 0;
|
|
113
|
+
const s = encode(under);
|
|
114
|
+
const ok = (a: number) => contrast(linear(s * (1 - a) + veilS * a), ink) >= ratio;
|
|
115
|
+
if (!ok(MAX_VEIL)) return 1 + MAX_VEIL;
|
|
116
|
+
let lo = 0;
|
|
117
|
+
let hi = MAX_VEIL;
|
|
118
|
+
for (let i = 0; i < 14; i += 1) {
|
|
119
|
+
const mid = (lo + hi) / 2;
|
|
120
|
+
if (ok(mid)) hi = mid;
|
|
121
|
+
else lo = mid;
|
|
122
|
+
}
|
|
123
|
+
return hi;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Resolves once the photograph is loaded and decoded (or failed), never before. */
|
|
127
|
+
function decoded(img: HTMLImageElement) {
|
|
128
|
+
const loaded = img.complete
|
|
129
|
+
? Promise.resolve()
|
|
130
|
+
: new Promise<void>((res) => {
|
|
131
|
+
img.addEventListener("load", () => res(), { once: true });
|
|
132
|
+
img.addEventListener("error", () => res(), { once: true });
|
|
133
|
+
});
|
|
134
|
+
return loaded.then(() => img.decode()).catch(() => undefined);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The display face, waited for at most FONT_CAP ms. */
|
|
138
|
+
function faceReady() {
|
|
139
|
+
const fonts = typeof document !== "undefined" ? document.fonts?.ready : undefined;
|
|
140
|
+
return Promise.race([fonts ?? Promise.resolve(), new Promise((res) => window.setTimeout(res, FONT_CAP))]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Staggered windows for n items inside 0 to 1: each runs `span`, the last one ends at 1. */
|
|
144
|
+
function windows(n: number, span: number) {
|
|
145
|
+
if (n <= 1) return [[0, 1]];
|
|
146
|
+
const step = (1 - span) / (n - 1);
|
|
147
|
+
return Array.from({ length: n }, (_, i) => [i * step, i * step + span]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function CtaClosing({ variant = "photo", lines, link, image, caption, className = "" }: Props) {
|
|
151
|
+
const root = useRef<HTMLElement>(null);
|
|
152
|
+
const titleId = useId();
|
|
153
|
+
const first = image?.negativeSpace === "right" ? "right" : "left";
|
|
154
|
+
|
|
155
|
+
useGSAP(
|
|
156
|
+
() => {
|
|
157
|
+
const el = root.current;
|
|
158
|
+
if (!el) return;
|
|
159
|
+
const kind = (el.dataset.ctaClosing ?? "photo") as CtaClosingVariant;
|
|
160
|
+
const q = <T extends HTMLElement>(s: string) => Array.from(el.querySelectorAll<T>(s));
|
|
161
|
+
const masks = q("[data-cc-mask]");
|
|
162
|
+
// The band's line is words for screen readers (and still lines under reduced motion); it never rises.
|
|
163
|
+
const rows = kind === "marquee" ? [] : q("[data-cc-line]");
|
|
164
|
+
const texts = q("[data-cc-text]");
|
|
165
|
+
const softs = q("[data-cc-soft]");
|
|
166
|
+
const copy = el.querySelector<HTMLElement>("[data-cc-copy]");
|
|
167
|
+
const ink = el.querySelector<HTMLElement>("[data-cc-ink]");
|
|
168
|
+
const frame = el.querySelector<HTMLElement>("[data-cc-frame]");
|
|
169
|
+
const veil = el.querySelector<HTMLElement>("[data-cc-veil]");
|
|
170
|
+
const linkEl = el.querySelector<HTMLElement>("[data-cc-link]");
|
|
171
|
+
const bandRow = el.querySelector<HTMLElement>("[data-cc-bandrow]");
|
|
172
|
+
const band = el.querySelector<HTMLElement>("[data-cc-band]");
|
|
173
|
+
const float = el.querySelector<HTMLElement>("[data-cc-float]");
|
|
174
|
+
const pics = q("[data-cc-pic]");
|
|
175
|
+
|
|
176
|
+
// Every photograph shows only once it is decoded (the CSS holds it clear, and shows it after 3 s whatever
|
|
177
|
+
// happens). The photo variant's own clip waits on the same promise.
|
|
178
|
+
let gone = false;
|
|
179
|
+
const ready = new Map<HTMLElement, Promise<void>>();
|
|
180
|
+
for (const pic of pics) {
|
|
181
|
+
const img = pic.querySelector("img");
|
|
182
|
+
if (!img) continue;
|
|
183
|
+
img.loading = "eager";
|
|
184
|
+
const p = decoded(img).then(() => {
|
|
185
|
+
if (!gone) pic.dataset.shown = "1";
|
|
186
|
+
});
|
|
187
|
+
ready.set(pic, p);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const mm = gsap.matchMedia();
|
|
191
|
+
mm.add(
|
|
192
|
+
{
|
|
193
|
+
wide: "(min-width: 768px)",
|
|
194
|
+
reduce: "(prefers-reduced-motion: reduce)",
|
|
195
|
+
fine: "(hover: hover) and (pointer: fine)",
|
|
196
|
+
},
|
|
197
|
+
(ctx) => {
|
|
198
|
+
const { wide, reduce, fine } = ctx.conditions as { wide: boolean; reduce: boolean; fine: boolean };
|
|
199
|
+
let alive = true;
|
|
200
|
+
const undo: (() => void)[] = [];
|
|
201
|
+
|
|
202
|
+
// Progress of each scrubbed stretch, 0 to 1, and the photograph's decode gate (0 until it is decoded).
|
|
203
|
+
const prog = { photo: 0, type: 0, soft: 0, band: 0 };
|
|
204
|
+
const gate = { v: 0 };
|
|
205
|
+
let heights: number[] = [];
|
|
206
|
+
let bandH = 0;
|
|
207
|
+
let bandW = 1;
|
|
208
|
+
|
|
209
|
+
/** The line is set as large as its column allows with the face it is read in, and no taller than the
|
|
210
|
+
* screen can hold beside the link. */
|
|
211
|
+
const fit = () => {
|
|
212
|
+
if (!copy || texts.length === 0) return;
|
|
213
|
+
if (kind === "marquee" && !reduce) return;
|
|
214
|
+
el.style.setProperty("--cc-size", "100px");
|
|
215
|
+
el.dataset.fit = "1";
|
|
216
|
+
const widest = Math.max(1, ...texts.map((t) => t.offsetWidth));
|
|
217
|
+
const room = copy.clientWidth;
|
|
218
|
+
const byWidth = (room / widest) * 100 * 0.98;
|
|
219
|
+
const share = kind === "photo" ? 0.46 : kind === "pointer" ? 0.44 : 0.54;
|
|
220
|
+
const byHeight = (window.innerHeight * share) / texts.length;
|
|
221
|
+
const size = Math.max(SIZE.min, Math.min(byWidth, byHeight, SIZE.max));
|
|
222
|
+
el.style.setProperty("--cc-size", `${Math.floor(size)}px`);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const measure = () => {
|
|
226
|
+
heights = rows.map((r) => r.offsetHeight);
|
|
227
|
+
bandH = bandRow?.offsetHeight ?? 0;
|
|
228
|
+
const firstCopy = band?.querySelector<HTMLElement>("[data-cc-copyband]");
|
|
229
|
+
bandW = Math.max(1, firstCopy?.offsetWidth ?? 1);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// ---- The photo: side, ink and veil read from the photograph. ----
|
|
233
|
+
const img = frame?.querySelector("img") ?? null;
|
|
234
|
+
let photoIn = false;
|
|
235
|
+
let graded = false;
|
|
236
|
+
const grade = () => {
|
|
237
|
+
if (kind !== "photo" || !img || !veil || !ink || !frame || !photoIn) return;
|
|
238
|
+
const from = frame.getBoundingClientRect();
|
|
239
|
+
const fb = { x: from.left, y: from.top, w: from.width, h: from.height };
|
|
240
|
+
const boxes = () => {
|
|
241
|
+
const out = masks.map((m, i) => {
|
|
242
|
+
const mr = m.getBoundingClientRect();
|
|
243
|
+
const tr = texts[i]?.getBoundingClientRect() ?? mr;
|
|
244
|
+
return { x: tr.left, y: mr.top, w: tr.width, h: mr.height };
|
|
245
|
+
});
|
|
246
|
+
const lr = linkEl?.getBoundingClientRect();
|
|
247
|
+
return { lines: out, link: lr ? { x: lr.left, y: lr.top, w: lr.width, h: lr.height } : null };
|
|
248
|
+
};
|
|
249
|
+
/** The veil an ink needs where the copy stands now, and how busy the photo is under the line (the spread of
|
|
250
|
+
* its middle eight tenths, perceptual): a place is chosen by both, so the line lands on calm ground and not
|
|
251
|
+
* on a trunk or a stone edge that a percentile would let through. */
|
|
252
|
+
const score = (inkName: string, b: ReturnType<typeof boxes>) => {
|
|
253
|
+
el.dataset.ink = inkName;
|
|
254
|
+
const inkL = luminanceOf(getComputedStyle(ink).color);
|
|
255
|
+
const veilS = encode(luminanceOf(getComputedStyle(veil).backgroundColor));
|
|
256
|
+
// Light ink fears the photo's brightest twentieth under it, dark ink its darkest.
|
|
257
|
+
const at = inkName === "light" ? 0.95 : 0.05;
|
|
258
|
+
const nth = (ys: number[], share: number) => ys[Math.min(ys.length - 1, Math.floor(ys.length * share))];
|
|
259
|
+
const all: number[] = [];
|
|
260
|
+
for (const box of b.lines) {
|
|
261
|
+
const ys = photoLuminances(img, fb, box);
|
|
262
|
+
if (ys) all.push(...ys);
|
|
263
|
+
}
|
|
264
|
+
if (all.length === 0) return null;
|
|
265
|
+
all.sort((x, y) => x - y);
|
|
266
|
+
let need = veilFor(nth(all, at), inkL, veilS, HEAD_RATIO);
|
|
267
|
+
const ls = b.link ? photoLuminances(img, fb, b.link) : null;
|
|
268
|
+
if (ls) need = Math.max(need, veilFor(nth(ls, at), inkL, veilS, LINK_RATIO));
|
|
269
|
+
const spread = encode(nth(all, 0.9)) - encode(nth(all, 0.1));
|
|
270
|
+
return { need, rank: need + spread * 0.6 };
|
|
271
|
+
};
|
|
272
|
+
const wasSide = el.dataset.side ?? first;
|
|
273
|
+
const wasV = el.dataset.v ?? "bottom";
|
|
274
|
+
const wasInk = el.dataset.ink ?? "light";
|
|
275
|
+
if (!graded) {
|
|
276
|
+
// The first read chooses the side, the height and the ink, while the line is still unseen (the copy
|
|
277
|
+
// waits for it).
|
|
278
|
+
const sides = wide ? [wasSide, wasSide === "left" ? "right" : "left"] : [wasSide];
|
|
279
|
+
const levels = [wasV, wasV === "bottom" ? "top" : "bottom"];
|
|
280
|
+
let best: { side: string; v: string; ink: string; need: number; rank: number } | null = null;
|
|
281
|
+
for (const side of sides) {
|
|
282
|
+
for (const v of levels) {
|
|
283
|
+
el.dataset.side = side;
|
|
284
|
+
el.dataset.v = v;
|
|
285
|
+
const b = boxes();
|
|
286
|
+
for (const inkName of ["light", "dark"]) {
|
|
287
|
+
const s = score(inkName, b);
|
|
288
|
+
if (s === null) continue;
|
|
289
|
+
// Another choice only when it is clearly better; a tie keeps the photo's own calm side, the
|
|
290
|
+
// bottom and the light ink.
|
|
291
|
+
if (!best || s.rank < best.rank - 0.02) best = { side, v, ink: inkName, ...s };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
el.dataset.side = best?.side ?? wasSide;
|
|
296
|
+
el.dataset.v = best?.v ?? wasV;
|
|
297
|
+
el.dataset.ink = best?.ink ?? wasInk;
|
|
298
|
+
if (best) veil.style.opacity = Math.min(best.need, MAX_VEIL).toFixed(3);
|
|
299
|
+
graded = true;
|
|
300
|
+
el.dataset.graded = "1";
|
|
301
|
+
} else {
|
|
302
|
+
const s = score(wasInk, boxes());
|
|
303
|
+
el.dataset.ink = wasInk;
|
|
304
|
+
if (s !== null) veil.style.opacity = Math.min(s.need, MAX_VEIL).toFixed(3);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// ---- Painting every scrubbed state. ----
|
|
309
|
+
const paint = () => {
|
|
310
|
+
if (!alive || reduce) return;
|
|
311
|
+
const spans = windows(rows.length, 0.66);
|
|
312
|
+
rows.forEach((row, i) => {
|
|
313
|
+
const e = out3(within(prog.type, spans[i][0], spans[i][1]));
|
|
314
|
+
row.style.transform = `translate3d(0, ${Math.round((1 - e) * (heights[i] ?? 0) * RISE)}px, 0)`;
|
|
315
|
+
});
|
|
316
|
+
softs.forEach((s) => {
|
|
317
|
+
const e = out2(prog.soft);
|
|
318
|
+
s.style.transform = `translate3d(0, ${Math.round((1 - e) * SOFT)}px, 0)`;
|
|
319
|
+
s.style.opacity = e.toFixed(3);
|
|
320
|
+
});
|
|
321
|
+
if (frame && kind === "photo") {
|
|
322
|
+
// From a centred window to full bleed, and fully shut until the photograph is decoded.
|
|
323
|
+
const e = inOut3(prog.photo);
|
|
324
|
+
const wy = WINDOW.y * (1 - e);
|
|
325
|
+
const wx = (wide ? WINDOW.wide : WINDOW.narrow) * (1 - e);
|
|
326
|
+
const y = 50 - (50 - wy) * gate.v;
|
|
327
|
+
const x = 50 - (50 - wx) * gate.v;
|
|
328
|
+
frame.style.clipPath = `inset(${y.toFixed(3)}% ${x.toFixed(3)}% ${y.toFixed(3)}% ${x.toFixed(3)}%)`;
|
|
329
|
+
}
|
|
330
|
+
if (bandRow) {
|
|
331
|
+
const e = out3(prog.band);
|
|
332
|
+
bandRow.style.transform = `translate3d(0, ${Math.round((1 - e) * bandH * RISE)}px, 0)`;
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const scrub = (key: keyof typeof prog, start: string, end: string) => {
|
|
337
|
+
gsap.to(prog, {
|
|
338
|
+
[key]: 1,
|
|
339
|
+
ease: "none",
|
|
340
|
+
onUpdate: paint,
|
|
341
|
+
scrollTrigger: { trigger: el, start, end, scrub: SMOOTH },
|
|
342
|
+
});
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
if (!reduce) {
|
|
346
|
+
if (kind === "photo") {
|
|
347
|
+
scrub("photo", "top 90%", "top 10%");
|
|
348
|
+
scrub("type", "top 55%", "top 10%");
|
|
349
|
+
scrub("soft", "top 38%", "top 4%");
|
|
350
|
+
} else if (kind === "marquee") {
|
|
351
|
+
scrub("band", "top 82%", "top 28%");
|
|
352
|
+
scrub("soft", "top 55%", "top 10%");
|
|
353
|
+
} else {
|
|
354
|
+
scrub("type", "top 72%", "top 16%");
|
|
355
|
+
scrub("soft", "top 46%", "top 6%");
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The photograph: once decoded, the window opens over a moment (or at once under reduced motion), and the
|
|
360
|
+
// first read of the photo sets the side, the ink and the veil.
|
|
361
|
+
if (kind === "photo" && frame) {
|
|
362
|
+
const pic = frame.closest<HTMLElement>("[data-cc-pic]") ?? frame;
|
|
363
|
+
(ready.get(pic) ?? Promise.resolve()).then(() =>
|
|
364
|
+
faceReady().then(() => {
|
|
365
|
+
if (!alive) return;
|
|
366
|
+
photoIn = true;
|
|
367
|
+
fit();
|
|
368
|
+
measure();
|
|
369
|
+
grade();
|
|
370
|
+
if (reduce) return;
|
|
371
|
+
gsap.to(gate, { v: 1, duration: 0.45, ease: "power2.out", onUpdate: paint });
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
} else if (kind === "photo") {
|
|
375
|
+
el.dataset.graded = "1";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---- The band: a slow run at its own pace, nudged by the scroll's speed. ----
|
|
379
|
+
if (kind === "marquee" && band && !reduce) {
|
|
380
|
+
let near = false;
|
|
381
|
+
const watch = ScrollTrigger.create({
|
|
382
|
+
trigger: el,
|
|
383
|
+
start: "top bottom+=100",
|
|
384
|
+
end: "bottom top-=100",
|
|
385
|
+
onToggle: (self) => {
|
|
386
|
+
near = self.isActive;
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
near = watch.isActive;
|
|
390
|
+
let lastY = window.scrollY;
|
|
391
|
+
let v = 0;
|
|
392
|
+
let x = 0;
|
|
393
|
+
const place = () => {
|
|
394
|
+
band.style.transform = `translate3d(${Math.round(gsap.utils.wrap(-bandW, 0, x))}px, 0, 0)`;
|
|
395
|
+
};
|
|
396
|
+
const tick = (_t: number, deltaMs: number) => {
|
|
397
|
+
const y = window.scrollY;
|
|
398
|
+
const dt = Math.max(0.001, Math.min(0.1, deltaMs / 1000));
|
|
399
|
+
const raw = gsap.utils.clamp(-BAND.fast, BAND.fast, (y - lastY) / dt);
|
|
400
|
+
lastY = y;
|
|
401
|
+
// Smoothed on a short time constant: no jitter, and back to its own pace soon after the reader stops.
|
|
402
|
+
v += (Math.abs(raw) - v) * (1 - Math.exp(-dt / BAND.tau));
|
|
403
|
+
if (v < 0.5) v = 0;
|
|
404
|
+
if (!near) return;
|
|
405
|
+
x -= (BAND.pace + v * BAND.gain) * dt;
|
|
406
|
+
place();
|
|
407
|
+
};
|
|
408
|
+
gsap.ticker.add(tick);
|
|
409
|
+
undo.push(() => {
|
|
410
|
+
gsap.ticker.remove(tick);
|
|
411
|
+
watch.kill();
|
|
412
|
+
band.style.transform = "";
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---- The project frame: beside the pointer while it is over the link. ----
|
|
417
|
+
if (kind === "pointer" && float && linkEl && fine) {
|
|
418
|
+
const pic = float.querySelector<HTMLElement>("[data-cc-pic]") ?? float;
|
|
419
|
+
const open = { o: 0 };
|
|
420
|
+
let tx = 0;
|
|
421
|
+
let ty = 0;
|
|
422
|
+
let cx = 0;
|
|
423
|
+
let cy = 0;
|
|
424
|
+
let over = false;
|
|
425
|
+
let following = false;
|
|
426
|
+
let turn = 0;
|
|
427
|
+
const paintFloat = () => {
|
|
428
|
+
const e = open.o;
|
|
429
|
+
// Opens from the top down; to the right the clip stands well clear, so the caption runs on past the
|
|
430
|
+
// frame's width instead of being cut.
|
|
431
|
+
float.style.clipPath = `inset(0% -240px ${((1 - e) * 100).toFixed(3)}% 0%)`;
|
|
432
|
+
};
|
|
433
|
+
const put = () => {
|
|
434
|
+
float.style.transform = `translate3d(${Math.round(cx)}px, ${Math.round(cy)}px, 0)`;
|
|
435
|
+
};
|
|
436
|
+
/** Keeps a wanted place for the frame inside the section. */
|
|
437
|
+
const keep = (x: number, y: number) => {
|
|
438
|
+
const r = el.getBoundingClientRect();
|
|
439
|
+
tx = gsap.utils.clamp(8, Math.max(8, r.width - float.offsetWidth - 8), x);
|
|
440
|
+
ty = gsap.utils.clamp(8, Math.max(8, r.height - float.offsetHeight - 8), y);
|
|
441
|
+
};
|
|
442
|
+
/** Where the frame stands for a pointer at (px, py) in the section: just below it and a little to its
|
|
443
|
+
* right, in the empty ground under the link and never higher than the link's own foot, so it covers
|
|
444
|
+
* neither the line above nor the words the pointer is reading; to its left when the right has no room. */
|
|
445
|
+
const aim = (px: number, py: number) => {
|
|
446
|
+
const r = el.getBoundingClientRect();
|
|
447
|
+
const l = linkEl.getBoundingClientRect();
|
|
448
|
+
const fw = float.offsetWidth;
|
|
449
|
+
let x = px + GAP * 0.5;
|
|
450
|
+
if (x + fw > r.width - 8) x = px - GAP * 0.5 - fw;
|
|
451
|
+
keep(x, Math.max(py + GAP * 0.6, l.bottom - r.top + 16));
|
|
452
|
+
};
|
|
453
|
+
/** Beside the link's end, its top level with the link's, hanging down into the empty ground: for the
|
|
454
|
+
* keyboard and for reduced motion. */
|
|
455
|
+
const aimAtLink = () => {
|
|
456
|
+
const r = el.getBoundingClientRect();
|
|
457
|
+
const l = linkEl.getBoundingClientRect();
|
|
458
|
+
keep(l.right - r.left + GAP, l.top - r.top);
|
|
459
|
+
};
|
|
460
|
+
const tick = (_t: number, deltaMs: number) => {
|
|
461
|
+
const dt = Math.max(0.001, Math.min(0.1, deltaMs / 1000));
|
|
462
|
+
const k = 1 - Math.exp(-dt / FOLLOW_TAU);
|
|
463
|
+
cx += (tx - cx) * k;
|
|
464
|
+
cy += (ty - cy) * k;
|
|
465
|
+
put();
|
|
466
|
+
if (!over && open.o === 0) stop();
|
|
467
|
+
};
|
|
468
|
+
const start = () => {
|
|
469
|
+
if (following) return;
|
|
470
|
+
following = true;
|
|
471
|
+
gsap.ticker.add(tick);
|
|
472
|
+
};
|
|
473
|
+
const stop = () => {
|
|
474
|
+
if (!following) return;
|
|
475
|
+
following = false;
|
|
476
|
+
gsap.ticker.remove(tick);
|
|
477
|
+
};
|
|
478
|
+
const show = () => {
|
|
479
|
+
const id = ++turn;
|
|
480
|
+
// Only a decoded photograph is ever shown; a hover that left before it landed shows nothing.
|
|
481
|
+
(ready.get(pic) ?? Promise.resolve()).then(() => {
|
|
482
|
+
if (!alive || id !== turn || !over) return;
|
|
483
|
+
if (reduce) {
|
|
484
|
+
gsap.killTweensOf(open);
|
|
485
|
+
open.o = 1;
|
|
486
|
+
paintFloat();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
gsap.to(open, { o: 1, duration: 0.42, ease: "power3.out", onUpdate: paintFloat, overwrite: true });
|
|
490
|
+
});
|
|
491
|
+
};
|
|
492
|
+
const hide = () => {
|
|
493
|
+
turn += 1;
|
|
494
|
+
if (reduce) {
|
|
495
|
+
gsap.killTweensOf(open);
|
|
496
|
+
open.o = 0;
|
|
497
|
+
paintFloat();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// From wherever it stands: a frame still opening closes back the way it came.
|
|
501
|
+
gsap.to(open, { o: 0, duration: 0.3, ease: "power2.in", onUpdate: paintFloat, overwrite: true });
|
|
502
|
+
};
|
|
503
|
+
const onEnter = (e: PointerEvent) => {
|
|
504
|
+
if (e.pointerType !== "mouse") return;
|
|
505
|
+
over = true;
|
|
506
|
+
const r = el.getBoundingClientRect();
|
|
507
|
+
if (reduce) aimAtLink();
|
|
508
|
+
else aim(e.clientX - r.left, e.clientY - r.top);
|
|
509
|
+
// A frame that is not on screen starts where it is wanted, never flying in from its last place.
|
|
510
|
+
if (open.o === 0) {
|
|
511
|
+
cx = tx;
|
|
512
|
+
cy = ty;
|
|
513
|
+
put();
|
|
514
|
+
}
|
|
515
|
+
if (!reduce) start();
|
|
516
|
+
show();
|
|
517
|
+
};
|
|
518
|
+
const onMove = (e: PointerEvent) => {
|
|
519
|
+
if (!over || reduce) return;
|
|
520
|
+
const r = el.getBoundingClientRect();
|
|
521
|
+
aim(e.clientX - r.left, e.clientY - r.top);
|
|
522
|
+
};
|
|
523
|
+
const onLeave = (e: PointerEvent) => {
|
|
524
|
+
if (e.pointerType !== "mouse") return;
|
|
525
|
+
over = false;
|
|
526
|
+
hide();
|
|
527
|
+
};
|
|
528
|
+
let byFocus = false;
|
|
529
|
+
const onFocus = () => {
|
|
530
|
+
if (!linkEl.matches(":focus-visible")) return;
|
|
531
|
+
byFocus = true;
|
|
532
|
+
over = true;
|
|
533
|
+
aimAtLink();
|
|
534
|
+
cx = tx;
|
|
535
|
+
cy = ty;
|
|
536
|
+
put();
|
|
537
|
+
show();
|
|
538
|
+
};
|
|
539
|
+
const onBlur = () => {
|
|
540
|
+
if (!byFocus) return;
|
|
541
|
+
byFocus = false;
|
|
542
|
+
over = false;
|
|
543
|
+
hide();
|
|
544
|
+
};
|
|
545
|
+
linkEl.addEventListener("pointerenter", onEnter);
|
|
546
|
+
linkEl.addEventListener("pointermove", onMove, { passive: true });
|
|
547
|
+
linkEl.addEventListener("pointerleave", onLeave);
|
|
548
|
+
linkEl.addEventListener("focus", onFocus);
|
|
549
|
+
linkEl.addEventListener("blur", onBlur);
|
|
550
|
+
paintFloat();
|
|
551
|
+
undo.push(() => {
|
|
552
|
+
stop();
|
|
553
|
+
gsap.killTweensOf(open);
|
|
554
|
+
linkEl.removeEventListener("pointerenter", onEnter);
|
|
555
|
+
linkEl.removeEventListener("pointermove", onMove);
|
|
556
|
+
linkEl.removeEventListener("pointerleave", onLeave);
|
|
557
|
+
linkEl.removeEventListener("focus", onFocus);
|
|
558
|
+
linkEl.removeEventListener("blur", onBlur);
|
|
559
|
+
float.style.clipPath = "";
|
|
560
|
+
float.style.transform = "";
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const refit = () => {
|
|
565
|
+
fit();
|
|
566
|
+
measure();
|
|
567
|
+
grade();
|
|
568
|
+
paint();
|
|
569
|
+
};
|
|
570
|
+
const ro = new ResizeObserver(() => {
|
|
571
|
+
if (alive && el.dataset.ready) refit();
|
|
572
|
+
});
|
|
573
|
+
ro.observe(el);
|
|
574
|
+
ScrollTrigger.addEventListener("refresh", refit);
|
|
575
|
+
|
|
576
|
+
// The fit, the measures and the first paint are taken with the face the line is read in; only then is the
|
|
577
|
+
// section marked ready and the CSS gate let go.
|
|
578
|
+
faceReady().then(() => {
|
|
579
|
+
if (!alive) return;
|
|
580
|
+
fit();
|
|
581
|
+
measure();
|
|
582
|
+
paint();
|
|
583
|
+
el.dataset.ready = "1";
|
|
584
|
+
ScrollTrigger.refresh();
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
return () => {
|
|
588
|
+
alive = false;
|
|
589
|
+
ro.disconnect();
|
|
590
|
+
ScrollTrigger.removeEventListener("refresh", refit);
|
|
591
|
+
gsap.killTweensOf(gate);
|
|
592
|
+
for (const u of undo) u();
|
|
593
|
+
for (const r of rows) r.style.transform = "";
|
|
594
|
+
for (const s of softs) {
|
|
595
|
+
s.style.transform = "";
|
|
596
|
+
s.style.opacity = "";
|
|
597
|
+
}
|
|
598
|
+
if (frame) frame.style.clipPath = "";
|
|
599
|
+
if (bandRow) bandRow.style.transform = "";
|
|
600
|
+
};
|
|
601
|
+
},
|
|
602
|
+
);
|
|
603
|
+
return () => {
|
|
604
|
+
gone = true;
|
|
605
|
+
mm.revert();
|
|
606
|
+
};
|
|
607
|
+
},
|
|
608
|
+
{ scope: root, dependencies: [variant, lines.join("|")] },
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
const head = (extra = "", rise = true) => (
|
|
612
|
+
<h2 id={titleId} data-cc-rise={rise ? "" : undefined} data-cc-head="" className={`font-display ${extra}`}>
|
|
613
|
+
{lines.map((line, i) => (
|
|
614
|
+
<span key={i} data-cc-mask="" className="struq-line-mask block overflow-hidden">
|
|
615
|
+
<span data-cc-line="" className="block">
|
|
616
|
+
<span data-cc-text="" className="inline-block">
|
|
617
|
+
{line}
|
|
618
|
+
</span>
|
|
619
|
+
</span>
|
|
620
|
+
</span>
|
|
621
|
+
))}
|
|
622
|
+
</h2>
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
// The action: large type, an arrow, a faint line at rest and a full one that draws while the pointer or the focus is
|
|
626
|
+
// on it (and draws back the way it came when they leave).
|
|
627
|
+
const action = (
|
|
628
|
+
<a
|
|
629
|
+
href={link.href}
|
|
630
|
+
data-cc-link=""
|
|
631
|
+
className="group/cc relative inline-flex items-center gap-[0.42em] py-[0.2em] font-display text-[clamp(1.625rem,1.1rem+1.7vw,2.875rem)] leading-[1.1] tracking-[-0.01em]"
|
|
632
|
+
>
|
|
633
|
+
<span>{link.label}</span>
|
|
634
|
+
<svg
|
|
635
|
+
aria-hidden
|
|
636
|
+
viewBox="0 0 24 24"
|
|
637
|
+
fill="none"
|
|
638
|
+
stroke="currentColor"
|
|
639
|
+
strokeWidth="1.25"
|
|
640
|
+
strokeLinecap="round"
|
|
641
|
+
strokeLinejoin="round"
|
|
642
|
+
className="h-[0.72em] w-[0.72em] shrink-0 transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover/cc:translate-x-[0.22em] group-focus-visible/cc:translate-x-[0.22em] motion-reduce:transition-none"
|
|
643
|
+
>
|
|
644
|
+
<path d="M3 12h17M14 5.5l6.5 6.5-6.5 6.5" />
|
|
645
|
+
</svg>
|
|
646
|
+
<span aria-hidden data-cc-rule="" className="absolute inset-x-0 bottom-0 h-[2px] bg-current opacity-25" />
|
|
647
|
+
<span
|
|
648
|
+
aria-hidden
|
|
649
|
+
data-cc-rule=""
|
|
650
|
+
className="absolute inset-x-0 bottom-0 h-[2px] origin-right scale-x-0 bg-current transition-transform duration-[650ms] ease-[cubic-bezier(0.16,1,0.3,1)] group-hover/cc:origin-left group-hover/cc:scale-x-100 group-focus-visible/cc:origin-left group-focus-visible/cc:scale-x-100 motion-reduce:transition-none"
|
|
651
|
+
/>
|
|
652
|
+
</a>
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
if (variant === "marquee") {
|
|
656
|
+
const said = lines.join(" ");
|
|
657
|
+
return (
|
|
658
|
+
<section
|
|
659
|
+
ref={root}
|
|
660
|
+
data-cta-closing="marquee"
|
|
661
|
+
aria-labelledby={titleId}
|
|
662
|
+
className={`relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-surface-0 px-6 py-[14svh] text-ink md:px-[4vw] ${className}`}
|
|
663
|
+
>
|
|
664
|
+
{/* The line as words: read by screen readers, and set as still lines under reduced motion. */}
|
|
665
|
+
<div data-cc-copy="" className="w-full text-center">
|
|
666
|
+
{head("sr-only motion-reduce:not-sr-only", false)}
|
|
667
|
+
</div>
|
|
668
|
+
{/* The band: decoration, the backdrop the link stands in front of. */}
|
|
669
|
+
<div aria-hidden className="pointer-events-none absolute inset-0 flex items-center motion-reduce:hidden">
|
|
670
|
+
<div className="struq-line-mask w-full overflow-hidden">
|
|
671
|
+
<div data-cc-bandrow="" data-cc-rise="">
|
|
672
|
+
<div data-cc-band="" className="flex w-max">
|
|
673
|
+
{Array.from({ length: COPIES }, (_, c) => (
|
|
674
|
+
<span
|
|
675
|
+
key={c}
|
|
676
|
+
data-cc-copyband={c === 0 ? "" : undefined}
|
|
677
|
+
className="font-display shrink-0 whitespace-nowrap pr-[0.5em] text-[30vw] leading-[0.92] tracking-[-0.035em] md:text-[min(19vw,18rem)]"
|
|
678
|
+
>
|
|
679
|
+
{said}
|
|
680
|
+
</span>
|
|
681
|
+
))}
|
|
682
|
+
</div>
|
|
683
|
+
</div>
|
|
684
|
+
</div>
|
|
685
|
+
</div>
|
|
686
|
+
<div data-cc-soft="" className="relative z-[1] motion-reduce:mt-[6svh]">
|
|
687
|
+
{action}
|
|
688
|
+
</div>
|
|
689
|
+
</section>
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (variant === "pointer") {
|
|
694
|
+
return (
|
|
695
|
+
<section
|
|
696
|
+
ref={root}
|
|
697
|
+
data-cta-closing="pointer"
|
|
698
|
+
aria-labelledby={titleId}
|
|
699
|
+
className={`relative flex min-h-svh flex-col justify-center overflow-hidden bg-surface-0 px-6 py-[14svh] text-ink md:px-[4vw] ${className}`}
|
|
700
|
+
>
|
|
701
|
+
<div data-cc-copy="" className="w-full">
|
|
702
|
+
{head()}
|
|
703
|
+
<div data-cc-soft="" className="mt-[7svh] flex items-end gap-5">
|
|
704
|
+
{action}
|
|
705
|
+
{/* On touch the project stands beside the link from the start. */}
|
|
706
|
+
{image ? (
|
|
707
|
+
<figure className="m-0 w-[5.5rem] shrink-0 sm:w-[7rem] [@media(hover:hover)_and_(pointer:fine)]:hidden">
|
|
708
|
+
<div data-cc-pic="" className="relative aspect-[4/5] overflow-hidden bg-surface-2">
|
|
709
|
+
<Image src={image.src} alt={image.alt} fill sizes="7rem" unoptimized={image.unoptimized} className="object-cover" />
|
|
710
|
+
</div>
|
|
711
|
+
</figure>
|
|
712
|
+
) : null}
|
|
713
|
+
</div>
|
|
714
|
+
</div>
|
|
715
|
+
{/* With a fine pointer the project follows the pointer while it is over the link. Its width also answers the
|
|
716
|
+
screen's height, so the frame and its caption fit in the ground under the link (about a quarter of it). */}
|
|
717
|
+
{image ? (
|
|
718
|
+
<div
|
|
719
|
+
data-cc-float=""
|
|
720
|
+
aria-hidden
|
|
721
|
+
className="pointer-events-none absolute left-0 top-0 z-[2] hidden w-[clamp(5.5rem,min(9vw,19svh_-_3.75rem),10rem)] [@media(hover:hover)_and_(pointer:fine)]:block"
|
|
722
|
+
>
|
|
723
|
+
<div data-cc-pic="" className="relative aspect-[4/5] overflow-hidden bg-surface-2">
|
|
724
|
+
<Image src={image.src} alt="" fill sizes="10rem" unoptimized={image.unoptimized} className="object-cover" />
|
|
725
|
+
</div>
|
|
726
|
+
{caption ? <span className="mt-2 block whitespace-nowrap text-small text-ink-muted">{caption}</span> : null}
|
|
727
|
+
</div>
|
|
728
|
+
) : null}
|
|
729
|
+
</section>
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return (
|
|
734
|
+
<section
|
|
735
|
+
ref={root}
|
|
736
|
+
data-cta-closing="photo"
|
|
737
|
+
data-side={first}
|
|
738
|
+
data-v="bottom"
|
|
739
|
+
data-ink="light"
|
|
740
|
+
aria-labelledby={titleId}
|
|
741
|
+
suppressHydrationWarning
|
|
742
|
+
className={`relative flex min-h-svh flex-col overflow-hidden bg-surface-0 ${className}`}
|
|
743
|
+
>
|
|
744
|
+
{image ? (
|
|
745
|
+
<div data-cc-pic="" className="absolute inset-0">
|
|
746
|
+
<div data-cc-frame="" className="absolute inset-0">
|
|
747
|
+
<Image src={image.src} alt={image.alt} fill sizes="100vw" unoptimized={image.unoptimized} className="object-cover" />
|
|
748
|
+
<div data-cc-veil="" aria-hidden className="absolute inset-0" />
|
|
749
|
+
</div>
|
|
750
|
+
</div>
|
|
751
|
+
) : null}
|
|
752
|
+
{/* The stage holds the copy at the bottom, or at the top when the photo's read finds the top calmer. */}
|
|
753
|
+
<div data-cc-stage="" className="relative z-[1] flex min-h-svh w-full flex-col justify-end px-6 pb-[12svh] pt-[18svh] md:px-[4vw]">
|
|
754
|
+
<div data-cc-copy="" data-cc-ink="" className="w-full md:w-[56%]">
|
|
755
|
+
{head()}
|
|
756
|
+
<div data-cc-soft="" className="mt-[5svh]">
|
|
757
|
+
{action}
|
|
758
|
+
</div>
|
|
759
|
+
</div>
|
|
760
|
+
</div>
|
|
761
|
+
</section>
|
|
762
|
+
);
|
|
763
|
+
}
|