@struq/skill 0.3.0 → 0.4.0

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