@struq/skill 0.2.2 → 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 +23 -35
- package/package.json +3 -2
- package/skills/struq/SKILL.md +148 -212
- 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/blocks/kit/Cursor.tsx +117 -0
- package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
- package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
- package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
- package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
- package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
- package/skills/struq/blocks/kit/Reveal.tsx +64 -0
- package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
- package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/gsap.ts +21 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
- package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
- package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
- package/skills/struq/blocks/kit/scenes/types.ts +114 -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/blocks/showpieces/fill-rows.tsx +74 -0
- package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
- package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
- package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
- package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
- 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 -0
- package/skills/struq/recipes/app/globals.css +42 -0
- package/skills/struq/recipes/app/layout.tsx +30 -0
- 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 +134 -0
- package/skills/struq/scripts/check.mjs +513 -0
- package/skills/struq/scripts/images.mjs +360 -0
- package/skills/struq/scripts/log.mjs +75 -0
- package/skills/struq/scripts/packs.mjs +156 -0
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/references/anti-patterns.md +0 -265
- package/skills/struq/references/baseline-prompt.md +0 -118
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -219
- package/skills/struq/references/composition.md +0 -191
- 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/imagery.md +0 -144
- 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/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 -341
- 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 -280
- 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
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// manifesto (Struq section recipe)
|
|
3
|
+
// The studio's statement, two to four sentences, set as the page's moment of conviction. Three ways to set it:
|
|
4
|
+
//
|
|
5
|
+
// - writes: the section holds still and the statement writes itself in, line by line, as the reader scrolls. Each line
|
|
6
|
+
// opens left to right behind a thin accent caret that rides its leading edge; the next line starts where the last
|
|
7
|
+
// one ends, at the same speed (a line's share of the scroll is its own width). The finished statement then holds
|
|
8
|
+
// for a beat of scroll, the caret gone and the signature risen under it, before the page moves on.
|
|
9
|
+
// - quote: one sentence of the statement as a pull quote over a full-bleed photograph, at display size and a light
|
|
10
|
+
// weight, on the photo's measured calm side. The photograph drifts slowly inside its frame with the scroll (a layer
|
|
11
|
+
// larger than the frame, moved in whole pixels), the quote's lines rise through their masks and the attribution
|
|
12
|
+
// lands with the last of them. The veil is read line by line under the quote; a photo that needs more than the cap
|
|
13
|
+
// on both sides is reported in development (pick another), never covered by a heavier veil.
|
|
14
|
+
// - split: a short pin. The first half of the statement stands large on the left; as the reader scrolls, the second
|
|
15
|
+
// half arrives on the right one sentence at a time, each one rising through its line masks under the one before,
|
|
16
|
+
// while the left stays. The pin ends on the whole statement in two columns. Scrolling back runs every step backwards.
|
|
17
|
+
//
|
|
18
|
+
// <Manifesto variant="writes" sentences={["We bouwen voor wie er straks woont, niet voor de foto.", "..."]}
|
|
19
|
+
// by="Sanne Verhoef, oprichter van Noord" />
|
|
20
|
+
// <Manifesto variant="quote" sentences={[...]} quote={1} image={images.hero[1]} by="..." />
|
|
21
|
+
//
|
|
22
|
+
// Every move is scrubbed to the scroll (a fast flick never stacks states, going back reverses), transforms are
|
|
23
|
+
// written in whole pixels and every clip from plain numbers. Lines are cut with the face they are read in: the cut
|
|
24
|
+
// waits for the fonts (capped), and SplitText cuts again when a face lands (loadingdone) or the width changes, after
|
|
25
|
+
// which the block measures again and the pins are refreshed. The photograph is decoded before any of it shows; until
|
|
26
|
+
// then the frame is the dark of its own veil, so the light quote reads from the first frame.
|
|
27
|
+
//
|
|
28
|
+
// Start states wait behind the `.js` / `.sq-js` gate and a 3 s CSS fallback (blocks.css, "--- manifesto ---"), so a
|
|
29
|
+
// page whose scripts never run still shows the composed statement. Reduced motion: the composed statement, no pin, no
|
|
30
|
+
// caret, no drift. Phones: the same moves, split stacks its
|
|
31
|
+
// two halves in one column.
|
|
32
|
+
import Image from "next/image";
|
|
33
|
+
import { useRef } from "react";
|
|
34
|
+
import { gsap, ScrollTrigger, SplitText, useGSAP } from "../kit/gsap";
|
|
35
|
+
import type { StruqImage } from "../kit/images";
|
|
36
|
+
import { photoLuminances, type PhotoBox } from "../kit/photo-read";
|
|
37
|
+
|
|
38
|
+
export const MANIFESTO_VARIANTS = [
|
|
39
|
+
{ id: "writes", title: "Schrift" },
|
|
40
|
+
{ id: "quote", title: "Citaat" },
|
|
41
|
+
{ id: "split", title: "Tweeluik" },
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
export type ManifestoVariant = (typeof MANIFESTO_VARIANTS)[number]["id"];
|
|
45
|
+
|
|
46
|
+
type Props = {
|
|
47
|
+
variant?: ManifestoVariant;
|
|
48
|
+
/** The statement, two to four sentences, one per entry. Plain text. */
|
|
49
|
+
sentences: string[];
|
|
50
|
+
/** Who says it: a name and a role at the studio. The signature (writes, split) or the attribution (quote). */
|
|
51
|
+
by?: string;
|
|
52
|
+
/** quote: the photograph the quote stands on. */
|
|
53
|
+
image?: StruqImage;
|
|
54
|
+
/** quote: which sentence becomes the pull quote (its index). The shortest reads best. */
|
|
55
|
+
quote?: number;
|
|
56
|
+
/** quote: where the quote stands. Left out, the block reads the photo and takes the side that needs the weaker veil. */
|
|
57
|
+
side?: "left" | "right";
|
|
58
|
+
/** split: how many sentences stand on the left. Left out, the first half (rounded down, at least one). */
|
|
59
|
+
lead?: number;
|
|
60
|
+
className?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Seconds of smoothing on the rises and swaps: a camera settling, never a delay. */
|
|
64
|
+
const SMOOTH = 0.4;
|
|
65
|
+
/** The writing follows the reader more closely: the caret is where the eye is. */
|
|
66
|
+
const WRITE_SMOOTH = 0.25;
|
|
67
|
+
/** The longest a line cut waits for the display face, in ms. */
|
|
68
|
+
const FONT_CAP = 1200;
|
|
69
|
+
/** A line travels this share of its own height to its place. */
|
|
70
|
+
const RISE = 1.12;
|
|
71
|
+
/** The soft rise of a signature or an attribution, in px. */
|
|
72
|
+
const SOFT = 18;
|
|
73
|
+
/** writes: screens of scroll a line as wide as the measure takes to write, and the beat the finished statement holds. */
|
|
74
|
+
const PER_LINE = 0.3;
|
|
75
|
+
const HOLD = 0.5;
|
|
76
|
+
/** writes: the caret's share of the line height. */
|
|
77
|
+
const CARET = 0.74;
|
|
78
|
+
/** quote: the share of the frame's height the photo layer reaches past it, above and below (its drift). */
|
|
79
|
+
const DRIFT = 0.08;
|
|
80
|
+
/** quote: display type holds 3:1, the small attribution 4.5:1; the veil never passes MAX_VEIL. */
|
|
81
|
+
const QUOTE_RATIO = 3;
|
|
82
|
+
const SMALL_RATIO = 4.5;
|
|
83
|
+
const MAX_VEIL = 0.45;
|
|
84
|
+
/** split: the pin, in screens. */
|
|
85
|
+
const PIN = 1.5;
|
|
86
|
+
|
|
87
|
+
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
|
88
|
+
/** Where p sits inside the window [from, to], as 0 to 1. */
|
|
89
|
+
const within = (p: number, from: number, to: number) => clamp01((p - from) / (to - from));
|
|
90
|
+
const out2 = gsap.parseEase("power2.out");
|
|
91
|
+
const out3 = gsap.parseEase("power3.out");
|
|
92
|
+
|
|
93
|
+
/** Staggered windows for n items inside 0 to 1: each runs `span`, the last one ends at 1. */
|
|
94
|
+
function windows(n: number, span: number) {
|
|
95
|
+
if (n <= 1) return [[0, 1]];
|
|
96
|
+
const step = (1 - span) / (n - 1);
|
|
97
|
+
return Array.from({ length: n }, (_, i) => [i * step, i * step + span]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The display face, waited for at most FONT_CAP ms. */
|
|
101
|
+
function faceReady() {
|
|
102
|
+
const fonts = typeof document !== "undefined" ? document.fonts?.ready : undefined;
|
|
103
|
+
return Promise.race([fonts ?? Promise.resolve(), new Promise((res) => window.setTimeout(res, FONT_CAP))]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Resolves once the photograph is loaded and decoded (or failed), never before. */
|
|
107
|
+
function decoded(img: HTMLImageElement) {
|
|
108
|
+
const loaded = img.complete
|
|
109
|
+
? Promise.resolve()
|
|
110
|
+
: new Promise<void>((res) => {
|
|
111
|
+
img.addEventListener("load", () => res(), { once: true });
|
|
112
|
+
img.addEventListener("error", () => res(), { once: true });
|
|
113
|
+
});
|
|
114
|
+
return loaded.then(() => img.decode()).catch(() => undefined);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const setY = (node: HTMLElement, px: number) => {
|
|
118
|
+
node.style.transform = `translate3d(0, ${Math.round(px)}px, 0)`;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Contrast, for the quote over its photograph.
|
|
122
|
+
const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
123
|
+
const toSrgb = (l: number) => (l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055);
|
|
124
|
+
const ratioOf = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
125
|
+
|
|
126
|
+
/** The relative luminance of a resolved CSS colour, through a one-pixel canvas so any colour syntax reads. */
|
|
127
|
+
function luminance(color: string) {
|
|
128
|
+
const cv = document.createElement("canvas");
|
|
129
|
+
cv.width = 1;
|
|
130
|
+
cv.height = 1;
|
|
131
|
+
const ctx = cv.getContext("2d", { willReadFrequently: true });
|
|
132
|
+
if (!ctx) return 0;
|
|
133
|
+
ctx.fillStyle = "#000";
|
|
134
|
+
ctx.fillStyle = color;
|
|
135
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
136
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
137
|
+
return 0.2126 * toLinear(r / 255) + 0.7152 * toLinear(g / 255) + 0.0722 * toLinear(b / 255);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The weakest veil (composited in sRGB) that brings `under` to `target` against `ink`. Never past MAX_VEIL: when even
|
|
142
|
+
* the cap does not hold it returns Infinity, so the caller knows this photo cannot carry the quote there.
|
|
143
|
+
*/
|
|
144
|
+
function weakestVeil(under: number, ink: number, veilSrgb: number, target: number) {
|
|
145
|
+
if (ratioOf(under, ink) >= target) return 0;
|
|
146
|
+
const s = toSrgb(under);
|
|
147
|
+
const holds = (a: number) => ratioOf(toLinear(s * (1 - a) + veilSrgb * a), ink) >= target;
|
|
148
|
+
if (!holds(MAX_VEIL)) return Infinity;
|
|
149
|
+
let lo = 0;
|
|
150
|
+
let hi = MAX_VEIL;
|
|
151
|
+
for (let i = 0; i < 14; i += 1) {
|
|
152
|
+
const mid = (lo + hi) / 2;
|
|
153
|
+
if (holds(mid)) hi = mid;
|
|
154
|
+
else lo = mid;
|
|
155
|
+
}
|
|
156
|
+
return hi;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The box a run of text actually covers: its glyphs' width, over the height of the untransformed element holding it. */
|
|
160
|
+
function inkBox(node: HTMLElement): PhotoBox {
|
|
161
|
+
const range = document.createRange();
|
|
162
|
+
range.selectNodeContents(node);
|
|
163
|
+
const t = range.getBoundingClientRect();
|
|
164
|
+
const own = node.getBoundingClientRect();
|
|
165
|
+
return { x: t.left, y: own.top, w: t.width, h: own.height };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The bright end (the top tenth) of the photo under a box, or null when it cannot be read. */
|
|
169
|
+
function brightUnder(img: HTMLImageElement, frame: PhotoBox, at: PhotoBox) {
|
|
170
|
+
const ys = photoLuminances(img, frame, at);
|
|
171
|
+
return ys ? ys[Math.floor(ys.length * 0.9)] : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- writes ---------------------------------------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
function runWrites(el: HTMLElement, reduce: boolean) {
|
|
177
|
+
const text = el.querySelector<HTMLElement>("[data-mf-text]");
|
|
178
|
+
const wrap = el.querySelector<HTMLElement>("[data-mf-wrap]");
|
|
179
|
+
const caret = el.querySelector<HTMLElement>("[data-mf-caret]");
|
|
180
|
+
const soft = el.querySelector<HTMLElement>("[data-mf-soft]");
|
|
181
|
+
if (!text || !wrap || !caret) return;
|
|
182
|
+
if (reduce) {
|
|
183
|
+
el.dataset.ready = "1";
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let alive = true;
|
|
188
|
+
let split: SplitText | null = null;
|
|
189
|
+
let lines: HTMLElement[] = [];
|
|
190
|
+
// Per line: where its text starts inside the wrap (x, y), where it starts inside its own box (left), its text width,
|
|
191
|
+
// its box width and its height.
|
|
192
|
+
let m: { x: number; y: number; left: number; w: number; box: number; h: number }[] = [];
|
|
193
|
+
// The statement's total text width, in measures (wrap widths): what the pin's length is made of.
|
|
194
|
+
let span = 0;
|
|
195
|
+
const prog = { p: 0 };
|
|
196
|
+
|
|
197
|
+
const measure = () => {
|
|
198
|
+
const wr = wrap.getBoundingClientRect();
|
|
199
|
+
m = lines.map((line) => {
|
|
200
|
+
const lb = line.getBoundingClientRect();
|
|
201
|
+
const range = document.createRange();
|
|
202
|
+
range.selectNodeContents(line);
|
|
203
|
+
const tr = range.getBoundingClientRect();
|
|
204
|
+
return { x: tr.left - wr.left, y: lb.top - wr.top, left: tr.left - lb.left, w: tr.width, box: lb.width, h: lb.height };
|
|
205
|
+
});
|
|
206
|
+
span = m.reduce((a, l) => a + l.w, 0) / (wr.width || 1);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const paint = () => {
|
|
210
|
+
if (!alive || !m.length) return;
|
|
211
|
+
const write = (span * PER_LINE) / (span * PER_LINE + HOLD);
|
|
212
|
+
const total = m.reduce((a, l) => a + l.w, 0) || 1;
|
|
213
|
+
const P = prog.p;
|
|
214
|
+
let acc = 0;
|
|
215
|
+
let at = m[0];
|
|
216
|
+
let reveal = 0;
|
|
217
|
+
m.forEach((l, i) => {
|
|
218
|
+
const from = (write * acc) / total;
|
|
219
|
+
acc += l.w;
|
|
220
|
+
const to = (write * acc) / total;
|
|
221
|
+
const p = within(P, from, to);
|
|
222
|
+
const r = p * l.w;
|
|
223
|
+
// The clip reaches a quarter line past every edge it is not drawing, so ascents, descents and overhangs show.
|
|
224
|
+
const pad = Math.round(l.h * 0.25);
|
|
225
|
+
const right = p <= 0 ? l.box + pad : p >= 1 ? -pad : l.box - l.left - r;
|
|
226
|
+
lines[i].style.clipPath = `inset(${-pad}px ${right.toFixed(1)}px ${-pad}px ${-pad}px)`;
|
|
227
|
+
if (P >= from) {
|
|
228
|
+
at = l;
|
|
229
|
+
reveal = r;
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
const ch = Math.round(at.h * CARET);
|
|
233
|
+
caret.style.height = `${ch}px`;
|
|
234
|
+
// The caret rides 3 px ahead of the clip's edge, so it never stands flush against a half-cut glyph.
|
|
235
|
+
caret.style.transform = `translate3d(${Math.round(at.x + reveal + 3)}px, ${Math.round(at.y + (at.h - ch) / 2)}px, 0)`;
|
|
236
|
+
caret.style.opacity = Math.min(within(P, 0, 0.01), 1 - within(P, write, write + 0.05)).toFixed(3);
|
|
237
|
+
if (soft) {
|
|
238
|
+
const e = out2(within(P, write, write + (1 - write) * 0.55));
|
|
239
|
+
soft.style.opacity = e.toFixed(3);
|
|
240
|
+
setY(soft, (1 - e) * SOFT);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
gsap.to(prog, {
|
|
245
|
+
p: 1,
|
|
246
|
+
ease: "none",
|
|
247
|
+
onUpdate: paint,
|
|
248
|
+
scrollTrigger: {
|
|
249
|
+
trigger: el,
|
|
250
|
+
start: "top top",
|
|
251
|
+
// The pin is as long as the writing (each line its own width's share) plus the beat it holds.
|
|
252
|
+
end: () => `+=${Math.round((span * PER_LINE + HOLD) * window.innerHeight)}`,
|
|
253
|
+
pin: true,
|
|
254
|
+
scrub: WRITE_SMOOTH,
|
|
255
|
+
onRefresh: () => {
|
|
256
|
+
measure();
|
|
257
|
+
paint();
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// The statement is cut with the face it is read in. Every line's clip is shut before the gate lifts; a later cut
|
|
263
|
+
// (a face landing, a new width) measures again and refreshes the pin's length.
|
|
264
|
+
faceReady().then(() => {
|
|
265
|
+
if (!alive) return;
|
|
266
|
+
split = SplitText.create(text, {
|
|
267
|
+
type: "lines",
|
|
268
|
+
linesClass: "struq-line",
|
|
269
|
+
autoSplit: true,
|
|
270
|
+
onSplit(self) {
|
|
271
|
+
lines = self.lines as HTMLElement[];
|
|
272
|
+
measure();
|
|
273
|
+
paint();
|
|
274
|
+
requestAnimationFrame(() => {
|
|
275
|
+
if (alive) ScrollTrigger.refresh();
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
el.dataset.ready = "1";
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
return () => {
|
|
283
|
+
alive = false;
|
|
284
|
+
split?.revert();
|
|
285
|
+
caret.style.cssText = "";
|
|
286
|
+
if (soft) soft.style.cssText = "";
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- quote ----------------------------------------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
function runQuote(el: HTMLElement, reduce: boolean, fixedSide: "left" | "right" | undefined) {
|
|
293
|
+
const frame = el.querySelector<HTMLElement>("[data-mf-frame]");
|
|
294
|
+
const layer = el.querySelector<HTMLElement>("[data-mf-layer]");
|
|
295
|
+
const veil = el.querySelector<HTMLElement>("[data-mf-veil]");
|
|
296
|
+
const text = el.querySelector<HTMLElement>("[data-mf-text]");
|
|
297
|
+
const soft = el.querySelector<HTMLElement>("[data-mf-soft]");
|
|
298
|
+
const img = layer?.querySelector("img") ?? null;
|
|
299
|
+
if (!frame || !layer || !veil || !text) return;
|
|
300
|
+
|
|
301
|
+
let alive = true;
|
|
302
|
+
let split: SplitText | null = null;
|
|
303
|
+
let lines: HTMLElement[] = [];
|
|
304
|
+
let heights: number[] = [];
|
|
305
|
+
let reach = 0;
|
|
306
|
+
let drift = 0.5;
|
|
307
|
+
let read = false;
|
|
308
|
+
const prog = { type: 0 };
|
|
309
|
+
const gate = { v: 0 };
|
|
310
|
+
|
|
311
|
+
const measure = () => {
|
|
312
|
+
reach = Math.max(0, Math.round(frame.offsetHeight * DRIFT) - 1);
|
|
313
|
+
heights = lines.map((l) => l.offsetHeight);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const paint = () => {
|
|
317
|
+
if (!alive) return;
|
|
318
|
+
layer.style.opacity = gate.v.toFixed(3);
|
|
319
|
+
if (reduce) return;
|
|
320
|
+
setY(layer, (drift - 0.5) * 2 * reach);
|
|
321
|
+
const spans = windows(lines.length, 0.62);
|
|
322
|
+
lines.forEach((line, i) => {
|
|
323
|
+
const e = out3(within(prog.type, spans[i][0], spans[i][1]));
|
|
324
|
+
setY(line, (1 - e) * (heights[i] ?? 0) * RISE);
|
|
325
|
+
});
|
|
326
|
+
if (soft) {
|
|
327
|
+
// The attribution rides the quote's own scrub, its last 40%, so it lands with the quote's last line.
|
|
328
|
+
const e = out2(within(prog.type, 0.6, 1));
|
|
329
|
+
soft.style.opacity = e.toFixed(3);
|
|
330
|
+
setY(soft, (1 - e) * SOFT);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The veil from the photo itself: the weakest that gives the quote 3:1 and the attribution 4.5:1 over what is under
|
|
336
|
+
* their glyphs, never past MAX_VEIL. Once the quote is cut, every line is read where it stands (its mask, which never
|
|
337
|
+
* moves, over its own glyphs' width), so one line on a bright patch is not averaged away by the others. Unless the
|
|
338
|
+
* side was given, and only while the quote is still under its masks, the quote also takes whichever side needs the
|
|
339
|
+
* weaker veil (the photo's own calm side keeps a tie).
|
|
340
|
+
*/
|
|
341
|
+
const grade = () => {
|
|
342
|
+
if (!img || !alive) return;
|
|
343
|
+
const ink = luminance(getComputedStyle(text).color);
|
|
344
|
+
const veilSrgb = toSrgb(luminance(getComputedStyle(veil).backgroundColor));
|
|
345
|
+
const need = () => {
|
|
346
|
+
const lr = layer.getBoundingClientRect();
|
|
347
|
+
const fb = { x: lr.left, y: lr.top, w: lr.width, h: lr.height };
|
|
348
|
+
const boxes = lines.length
|
|
349
|
+
? lines.map((line) => {
|
|
350
|
+
const t = inkBox(line);
|
|
351
|
+
const mask = (line.parentElement ?? line).getBoundingClientRect();
|
|
352
|
+
return { x: t.x, y: mask.top, w: t.w, h: mask.height };
|
|
353
|
+
})
|
|
354
|
+
: [inkBox(text)];
|
|
355
|
+
let n = 0;
|
|
356
|
+
for (const box of boxes) {
|
|
357
|
+
const q = brightUnder(img, fb, box);
|
|
358
|
+
if (q === null) return null;
|
|
359
|
+
n = Math.max(n, weakestVeil(q, ink, veilSrgb, QUOTE_RATIO));
|
|
360
|
+
}
|
|
361
|
+
if (soft) {
|
|
362
|
+
const s = brightUnder(img, fb, inkBox(soft));
|
|
363
|
+
if (s !== null) n = Math.max(n, weakestVeil(s, ink, veilSrgb, SMALL_RATIO));
|
|
364
|
+
}
|
|
365
|
+
return n;
|
|
366
|
+
};
|
|
367
|
+
let n = need();
|
|
368
|
+
if (n === null) return;
|
|
369
|
+
if (!fixedSide && !reduce && prog.type === 0) {
|
|
370
|
+
const was = el.dataset.side;
|
|
371
|
+
el.dataset.side = was === "right" ? "left" : "right";
|
|
372
|
+
const other = need();
|
|
373
|
+
if (other !== null && other < n - 0.02) n = other;
|
|
374
|
+
else el.dataset.side = was;
|
|
375
|
+
}
|
|
376
|
+
if (n > MAX_VEIL && process.env.NODE_ENV !== "production") {
|
|
377
|
+
console.warn(
|
|
378
|
+
`manifesto quote: this photograph cannot carry the quote at ${QUOTE_RATIO}:1 on either side within the ${MAX_VEIL} veil. Pick a calmer photo or another crop.`,
|
|
379
|
+
img.currentSrc || img.src,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
veil.style.opacity = Math.min(n, MAX_VEIL).toFixed(3);
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
if (!reduce) {
|
|
386
|
+
// The drift follows the scroll directly (the page's own smooth scroll is the only smoothing), from the frame's top
|
|
387
|
+
// entering the screen to its bottom leaving it.
|
|
388
|
+
ScrollTrigger.create({
|
|
389
|
+
trigger: el,
|
|
390
|
+
start: "top bottom",
|
|
391
|
+
end: "bottom top",
|
|
392
|
+
onUpdate: (self) => {
|
|
393
|
+
drift = self.progress;
|
|
394
|
+
paint();
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
gsap.to(prog, {
|
|
398
|
+
type: 1,
|
|
399
|
+
ease: "none",
|
|
400
|
+
onUpdate: paint,
|
|
401
|
+
scrollTrigger: { trigger: text, start: "top 96%", end: "top 60%", scrub: SMOOTH },
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
const onRefresh = () => {
|
|
405
|
+
measure();
|
|
406
|
+
paint();
|
|
407
|
+
if (read) grade();
|
|
408
|
+
};
|
|
409
|
+
ScrollTrigger.addEventListener("refresh", onRefresh);
|
|
410
|
+
// Reduced motion has no start state in the CSS: a photograph the browser already shows stays shown.
|
|
411
|
+
if (reduce && img?.complete && img.naturalWidth) gate.v = 1;
|
|
412
|
+
measure();
|
|
413
|
+
paint();
|
|
414
|
+
|
|
415
|
+
// The photograph is decoded before any of it shows, and read with the quote set in its real face.
|
|
416
|
+
if (img) {
|
|
417
|
+
img.loading = "eager";
|
|
418
|
+
Promise.all([decoded(img), faceReady()]).then(() => {
|
|
419
|
+
if (!alive) return;
|
|
420
|
+
read = true;
|
|
421
|
+
grade();
|
|
422
|
+
gsap.to(gate, { v: 1, duration: reduce ? 0.3 : 0.5, ease: "power2.out", onUpdate: paint });
|
|
423
|
+
});
|
|
424
|
+
} else {
|
|
425
|
+
gate.v = 1;
|
|
426
|
+
paint();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (reduce) {
|
|
430
|
+
el.dataset.ready = "1";
|
|
431
|
+
} else {
|
|
432
|
+
faceReady().then(() => {
|
|
433
|
+
if (!alive) return;
|
|
434
|
+
split = SplitText.create(text, {
|
|
435
|
+
type: "lines",
|
|
436
|
+
mask: "lines",
|
|
437
|
+
linesClass: "struq-line",
|
|
438
|
+
autoSplit: true,
|
|
439
|
+
onSplit(self) {
|
|
440
|
+
lines = self.lines as HTMLElement[];
|
|
441
|
+
measure();
|
|
442
|
+
paint();
|
|
443
|
+
// The veil is read again line by line, now that the lines stand where they will be read.
|
|
444
|
+
if (read) grade();
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
el.dataset.ready = "1";
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return () => {
|
|
452
|
+
alive = false;
|
|
453
|
+
ScrollTrigger.removeEventListener("refresh", onRefresh);
|
|
454
|
+
gsap.killTweensOf(gate);
|
|
455
|
+
split?.revert();
|
|
456
|
+
layer.style.cssText = "";
|
|
457
|
+
veil.style.opacity = "";
|
|
458
|
+
if (soft) soft.style.cssText = "";
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// --- split ----------------------------------------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
function runSplit(el: HTMLElement, reduce: boolean) {
|
|
465
|
+
const left = el.querySelector<HTMLElement>("[data-mf-left]");
|
|
466
|
+
const swaps = Array.from(el.querySelectorAll<HTMLElement>("[data-mf-swap]"));
|
|
467
|
+
const soft = el.querySelector<HTMLElement>("[data-mf-soft]");
|
|
468
|
+
if (!left) return;
|
|
469
|
+
if (reduce) {
|
|
470
|
+
el.dataset.ready = "1";
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let alive = true;
|
|
475
|
+
const splits: SplitText[] = [];
|
|
476
|
+
let leftLines: HTMLElement[] = [];
|
|
477
|
+
let leftH: number[] = [];
|
|
478
|
+
const swapLines: HTMLElement[][] = swaps.map(() => []);
|
|
479
|
+
const swapH: number[][] = swaps.map(() => []);
|
|
480
|
+
const prog = { left: 0, s: 0 };
|
|
481
|
+
|
|
482
|
+
const paint = () => {
|
|
483
|
+
if (!alive) return;
|
|
484
|
+
const ls = windows(leftLines.length, 0.6);
|
|
485
|
+
leftLines.forEach((line, i) => {
|
|
486
|
+
const e = out3(within(prog.left, ls[i][0], ls[i][1]));
|
|
487
|
+
setY(line, (1 - e) * (leftH[i] ?? 0) * RISE);
|
|
488
|
+
});
|
|
489
|
+
if (soft) {
|
|
490
|
+
const e = out2(within(prog.left, 0.45, 1));
|
|
491
|
+
soft.style.opacity = e.toFixed(3);
|
|
492
|
+
setY(soft, (1 - e) * SOFT);
|
|
493
|
+
}
|
|
494
|
+
// The pin in segments, one per sentence on the right: each segment opens with its sentence rising into its masks
|
|
495
|
+
// under the one before (which stays), then holds; the last tenth and a half is the whole statement, composed.
|
|
496
|
+
const n = swaps.length;
|
|
497
|
+
const seg = 0.85 / Math.max(1, n);
|
|
498
|
+
const move = seg * 0.62;
|
|
499
|
+
swapLines.forEach((ls2, j) => {
|
|
500
|
+
const start = j * seg;
|
|
501
|
+
const inP = within(prog.s, start, start + move);
|
|
502
|
+
const ws = windows(ls2.length, 0.7);
|
|
503
|
+
ls2.forEach((line, i) => {
|
|
504
|
+
const e = out3(within(inP, ws[i][0], ws[i][1]));
|
|
505
|
+
setY(line, (1 - e) * (swapH[j][i] ?? 0) * RISE);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
gsap.to(prog, {
|
|
511
|
+
left: 1,
|
|
512
|
+
ease: "none",
|
|
513
|
+
onUpdate: paint,
|
|
514
|
+
scrollTrigger: { trigger: el, start: "top 82%", end: "top 16%", scrub: SMOOTH },
|
|
515
|
+
});
|
|
516
|
+
gsap.to(prog, {
|
|
517
|
+
s: 1,
|
|
518
|
+
ease: "none",
|
|
519
|
+
onUpdate: paint,
|
|
520
|
+
scrollTrigger: { trigger: el, start: "top top", end: () => `+=${Math.round(PIN * window.innerHeight)}`, pin: true, scrub: SMOOTH },
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const cut = (node: HTMLElement, done: (lines: HTMLElement[], heights: number[]) => void) =>
|
|
524
|
+
SplitText.create(node, {
|
|
525
|
+
type: "lines",
|
|
526
|
+
mask: "lines",
|
|
527
|
+
linesClass: "struq-line",
|
|
528
|
+
autoSplit: true,
|
|
529
|
+
onSplit(self) {
|
|
530
|
+
const lines = self.lines as HTMLElement[];
|
|
531
|
+
done(
|
|
532
|
+
lines,
|
|
533
|
+
lines.map((l) => l.offsetHeight),
|
|
534
|
+
);
|
|
535
|
+
paint();
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
faceReady().then(() => {
|
|
540
|
+
if (!alive) return;
|
|
541
|
+
splits.push(
|
|
542
|
+
cut(left, (l, h) => {
|
|
543
|
+
leftLines = l;
|
|
544
|
+
leftH = h;
|
|
545
|
+
}),
|
|
546
|
+
);
|
|
547
|
+
swaps.forEach((node, j) =>
|
|
548
|
+
splits.push(
|
|
549
|
+
cut(node, (l, h) => {
|
|
550
|
+
swapLines[j] = l;
|
|
551
|
+
swapH[j] = h;
|
|
552
|
+
}),
|
|
553
|
+
),
|
|
554
|
+
);
|
|
555
|
+
el.dataset.ready = "1";
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
return () => {
|
|
559
|
+
alive = false;
|
|
560
|
+
for (const s of splits) s.revert();
|
|
561
|
+
if (soft) soft.style.cssText = "";
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// --- the block ------------------------------------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
export function Manifesto({ variant = "writes", sentences, by, image, quote = 0, side, lead, className = "" }: Props) {
|
|
568
|
+
const root = useRef<HTMLElement>(null);
|
|
569
|
+
|
|
570
|
+
useGSAP(
|
|
571
|
+
() => {
|
|
572
|
+
const el = root.current;
|
|
573
|
+
if (!el) return;
|
|
574
|
+
const mm = gsap.matchMedia();
|
|
575
|
+
// A conditions object only runs while one of them matches, so both sides of the query are named.
|
|
576
|
+
mm.add({ reduce: "(prefers-reduced-motion: reduce)", motion: "(prefers-reduced-motion: no-preference)" }, (ctx) => {
|
|
577
|
+
const { reduce } = ctx.conditions as { reduce: boolean };
|
|
578
|
+
if (variant === "quote") return runQuote(el, reduce, side);
|
|
579
|
+
if (variant === "split") return runSplit(el, reduce);
|
|
580
|
+
return runWrites(el, reduce);
|
|
581
|
+
});
|
|
582
|
+
return () => mm.revert();
|
|
583
|
+
},
|
|
584
|
+
{ scope: root, dependencies: [variant, side] },
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
if (variant === "quote") {
|
|
588
|
+
const first = side ?? (image?.negativeSpace === "right" ? "right" : "left");
|
|
589
|
+
const said = sentences[Math.min(Math.max(0, quote), sentences.length - 1)] ?? "";
|
|
590
|
+
return (
|
|
591
|
+
<section
|
|
592
|
+
ref={root}
|
|
593
|
+
data-manifesto="quote"
|
|
594
|
+
data-side={first}
|
|
595
|
+
suppressHydrationWarning
|
|
596
|
+
className={`group/mf relative flex min-h-[max(100svh,36rem)] items-end overflow-hidden text-on-photo md:min-h-[max(100svh,40rem)] ${className}`}
|
|
597
|
+
>
|
|
598
|
+
<div data-mf-frame="" className="absolute inset-0 overflow-hidden">
|
|
599
|
+
{/* The photograph sits on a layer larger than its frame, so its drift never shows an edge. */}
|
|
600
|
+
<div data-mf-layer="" className="absolute inset-x-0 -bottom-[8%] -top-[8%]">
|
|
601
|
+
{image ? (
|
|
602
|
+
<Image src={image.src} alt={image.alt} fill sizes="100vw" unoptimized={image.unoptimized} className="object-cover" />
|
|
603
|
+
) : null}
|
|
604
|
+
</div>
|
|
605
|
+
<div data-mf-veil="" aria-hidden="true" className="absolute inset-0" />
|
|
606
|
+
</div>
|
|
607
|
+
<figure className="relative w-full px-6 pb-[9svh] md:px-[4vw] md:pb-[11svh]">
|
|
608
|
+
<div className="max-w-[16ch] text-[clamp(2.25rem,0.9rem+4.4vw,6.25rem)] group-data-[side=right]/mf:md:ml-auto">
|
|
609
|
+
<blockquote>
|
|
610
|
+
<p data-mf-text="" className="font-display leading-[1.03] tracking-[-0.022em]">
|
|
611
|
+
{`“${said}”`}
|
|
612
|
+
</p>
|
|
613
|
+
</blockquote>
|
|
614
|
+
{by ? (
|
|
615
|
+
<figcaption data-mf-soft="" className="mt-8 text-small md:mt-10">
|
|
616
|
+
{by}
|
|
617
|
+
</figcaption>
|
|
618
|
+
) : null}
|
|
619
|
+
</div>
|
|
620
|
+
</figure>
|
|
621
|
+
</section>
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (variant === "split") {
|
|
626
|
+
const n = Math.min(Math.max(1, lead ?? Math.floor(sentences.length / 2)), Math.max(1, sentences.length - 1));
|
|
627
|
+
const first = sentences.slice(0, n).join(" ");
|
|
628
|
+
const rest = sentences.slice(n);
|
|
629
|
+
return (
|
|
630
|
+
<section
|
|
631
|
+
ref={root}
|
|
632
|
+
data-manifesto="split"
|
|
633
|
+
className={`relative flex min-h-svh flex-col justify-center bg-surface-0 px-6 py-[12svh] text-ink md:h-svh md:min-h-[36rem] md:px-[4vw] md:py-[10svh] ${className}`}
|
|
634
|
+
>
|
|
635
|
+
{/* The signature closes the statement: under the right half on phones, under the left one on wide screens, where
|
|
636
|
+
the right half stands on the left half's last line. */}
|
|
637
|
+
<div className="grid gap-y-12 md:grid-cols-12 md:items-end md:gap-x-[2vw]">
|
|
638
|
+
<p
|
|
639
|
+
data-mf-text=""
|
|
640
|
+
data-mf-left=""
|
|
641
|
+
className="font-display max-w-[14ch] text-[clamp(2.5rem,min(1rem+4.6vw,10svh),6.25rem)] leading-[1.02] tracking-[-0.025em] md:col-span-7 md:row-start-1"
|
|
642
|
+
>
|
|
643
|
+
{first}
|
|
644
|
+
</p>
|
|
645
|
+
<div data-mf-right="" className="grid gap-6 md:col-span-4 md:col-start-9 md:row-start-1">
|
|
646
|
+
{rest.map((s, i) => (
|
|
647
|
+
<p
|
|
648
|
+
key={i}
|
|
649
|
+
data-mf-text=""
|
|
650
|
+
data-mf-swap=""
|
|
651
|
+
className="font-display max-w-[20ch] self-end text-[clamp(1.5rem,0.95rem+1.5vw,2.75rem)] leading-[1.14] tracking-[-0.012em]"
|
|
652
|
+
>
|
|
653
|
+
{s}
|
|
654
|
+
</p>
|
|
655
|
+
))}
|
|
656
|
+
</div>
|
|
657
|
+
{by ? (
|
|
658
|
+
<p data-mf-soft="" className="text-small text-ink-muted md:col-span-7 md:row-start-2">
|
|
659
|
+
{by}
|
|
660
|
+
</p>
|
|
661
|
+
) : null}
|
|
662
|
+
</div>
|
|
663
|
+
</section>
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return (
|
|
668
|
+
<section
|
|
669
|
+
ref={root}
|
|
670
|
+
data-manifesto="writes"
|
|
671
|
+
className={`relative flex h-svh min-h-[30rem] items-center bg-surface-0 px-6 text-ink md:px-[8vw] ${className}`}
|
|
672
|
+
>
|
|
673
|
+
<div className="w-full">
|
|
674
|
+
<div data-mf-wrap="" className="relative">
|
|
675
|
+
<p
|
|
676
|
+
data-mf-text=""
|
|
677
|
+
className="font-display max-w-[22ch] text-[clamp(2rem,min(1rem+3.4vw,7.6svh),4.75rem)] leading-[1.1] tracking-[-0.02em]"
|
|
678
|
+
>
|
|
679
|
+
{sentences.join(" ")}
|
|
680
|
+
</p>
|
|
681
|
+
<span data-mf-caret="" aria-hidden="true" className="pointer-events-none absolute left-0 top-0 block w-[2px] bg-accent" />
|
|
682
|
+
</div>
|
|
683
|
+
{by ? (
|
|
684
|
+
<p data-mf-soft="" className="mt-10 text-small text-ink-muted md:mt-14">
|
|
685
|
+
{by}
|
|
686
|
+
</p>
|
|
687
|
+
) : null}
|
|
688
|
+
</div>
|
|
689
|
+
</section>
|
|
690
|
+
);
|
|
691
|
+
}
|