@struq/skill 0.4.3 → 0.5.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 +1 -1
- package/package.json +2 -2
- package/skills/struq/SKILL.md +145 -89
- package/skills/struq/blocks/CATALOG.md +74 -22
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +34 -12
- package/skills/struq/blocks/backgrounds/background-facets.tsx +14 -6
- package/skills/struq/blocks/backgrounds/background-silk.tsx +40 -15
- package/skills/struq/blocks/catalog.json +697 -16
- package/skills/struq/blocks/film.json +426 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +81 -55
- package/skills/struq/blocks/footers/footer-reveal.tsx +1 -1
- package/skills/struq/blocks/headers/header-bar.tsx +24 -13
- package/skills/struq/blocks/headers/menu-curtain.tsx +24 -11
- package/skills/struq/blocks/heroes/hero-framed.tsx +33 -20
- package/skills/struq/blocks/heroes/hero-index.tsx +32 -11
- package/skills/struq/blocks/heroes/hero-poster.tsx +68 -35
- package/skills/struq/blocks/heroes/hero-split.tsx +22 -9
- package/skills/struq/blocks/heroes/hero-still.tsx +80 -74
- package/skills/struq/blocks/kit/accent.ts +82 -0
- package/skills/struq/blocks/kit/blocks.css +78 -35
- package/skills/struq/blocks/kit/quiet/Close.tsx +73 -0
- package/skills/struq/blocks/kit/quiet/PhotoText.tsx +79 -0
- package/skills/struq/blocks/kit/quiet/Rows.tsx +81 -0
- package/skills/struq/blocks/kit/quiet/Statement.tsx +157 -0
- package/skills/struq/blocks/kit/quiet/index.ts +7 -0
- package/skills/struq/blocks/kit/quiet/sheet.ts +12 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +87 -36
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +116 -46
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +10 -7
- package/skills/struq/blocks/sections/contact-form.tsx +1 -1
- package/skills/struq/blocks/sections/cta-closing.tsx +3 -2
- package/skills/struq/blocks/sections/fanned-cards.tsx +14 -10
- package/skills/struq/blocks/sections/film-strip.tsx +18 -14
- package/skills/struq/blocks/sections/grid-assembly.tsx +16 -11
- package/skills/struq/blocks/sections/manifesto.tsx +3 -2
- package/skills/struq/blocks/sections/panel-accordion.tsx +3 -2
- package/skills/struq/blocks/sections/split-pair.tsx +4 -3
- package/skills/struq/blocks/showpieces/hero-opener.tsx +76 -17
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +3 -1
- package/skills/struq/blocks/transitions/zoom-through.tsx +9 -10
- package/skills/struq/canon/canon.json +1 -1
- package/skills/struq/references/craft.md +4 -0
- package/skills/struq/references/film.md +158 -0
- package/skills/struq/references/triage.md +3 -0
- package/skills/struq/scripts/blocks.mjs +89 -7
- package/skills/struq/scripts/canon.mjs +8 -1
- package/skills/struq/scripts/check.mjs +94 -11
- package/skills/struq/scripts/film-check.mjs +997 -0
- package/skills/struq/scripts/gate.mjs +146 -0
- package/skills/struq/scripts/packs.mjs +73 -12
|
@@ -1,36 +1,56 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
// preloader-gate (Struq preloader): the gate
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// The first paint is a shut gate: two bars meeting on one line, and on it a dial that counts the page in on an
|
|
4
|
+
// eased count that waits for the fonts and the hero image. Then the dial drops away, the line through the middle runs
|
|
5
|
+
// out to both edges and lights in the accent, and the gate opens on it: the top bar rises, the bottom bar falls, each
|
|
6
|
+
// carrying half of the light, onto the page's own entrance.
|
|
7
|
+
//
|
|
8
|
+
// What the dial says is the client's. By default it is neutral: one hairline ring that an accent arc fills once while
|
|
9
|
+
// the page loads, nothing written inside it, and the site's name (when given) small at the lower left. `marks` are the
|
|
10
|
+
// figures it counts through, one beat each, cut on the beat in the display face: take them from the client's own
|
|
11
|
+
// lexicon (an architect's scales, 1:500, 1:100, 1:20; a bakery's proving hours), never a countdown it does not own.
|
|
12
|
+
// `dial` picks the drawing: "ring" (the default), "leader" (two rings, a crosshair and a sweeping hand: with marks
|
|
13
|
+
// 3, 2, 1 it is a film leader, the lab's look) or "none" (the marks alone).
|
|
9
14
|
//
|
|
10
15
|
// The clock starts at the first paint, not at hydration:
|
|
11
|
-
// 0.00 the gate shut, the
|
|
16
|
+
// 0.00 the gate shut, the dial whole; the count runs (CSS until React hydrates, then GSAP)
|
|
12
17
|
// 1.15 the count holds at its last beat until the fonts and the hero image are in (at most 0.15s)
|
|
13
|
-
// 1.15 the count completes (0.26s); the
|
|
18
|
+
// 1.15 the count completes (0.26s); the dial falls back; the line runs out and lights
|
|
14
19
|
// 1.45 the gate opens (0.78s, the curtain ease); the page's entrance starts 0.1s in
|
|
15
20
|
// 2.23 done, and never later than 2.4s after the first paint.
|
|
16
21
|
//
|
|
17
|
-
// Mount it first in <body> (app/layout.tsx), before the page it covers
|
|
18
|
-
// entrance with onGateOpen() inside its useGSAP, so the hero moves while the gate opens:
|
|
22
|
+
// Mount it first in <body> (app/layout.tsx), before the page it covers:
|
|
19
23
|
//
|
|
20
24
|
// <body><PreloaderGate name="Noord, Rotterdam" />{children}</body>
|
|
21
25
|
//
|
|
26
|
+
// The hand-over is the one every frame family uses (helper in preloader-slices-uncovered.ts, which ships with it):
|
|
27
|
+
// while the gate is shut <html> carries data-curtain="closed", and the moment it starts to open the attribute goes and
|
|
28
|
+
// "curtain:lift" fires, so every Struq hero waits behind it with no wiring. A page's own entrance does the same:
|
|
29
|
+
// useGSAP((_, contextSafe) => whenUncovered(contextSafe!(() => { ...the entrance... })), { scope })
|
|
30
|
+
// onGateOpen() and preloaderCovers() still work, and fire at the same moment.
|
|
31
|
+
//
|
|
22
32
|
// It needs no `js` class or head script of its own setup: the gate renders a small inline script
|
|
23
33
|
// just before itself, which shows it (before the first paint) only when JS runs, motion is allowed
|
|
24
34
|
// and this session has not seen it; blocks.css keeps it display none otherwise. Without JS it is
|
|
25
35
|
// never painted. If the bundle never arrives, the CSS still opens it and removes it by 2.4s.
|
|
26
36
|
// `force` plays it on every load (the lab, a launch page). The tone is "deep" (a step darker than
|
|
27
37
|
// the page in every palette), "inverse" or "field".
|
|
28
|
-
import { useRef, useState, useSyncExternalStore } from "react";
|
|
38
|
+
import { useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
|
|
29
39
|
import { gsap, useGSAP } from "../kit/gsap";
|
|
40
|
+
import { closeCurtain, covered, liftCurtain, whenUncovered } from "./preloader-slices-uncovered";
|
|
41
|
+
|
|
42
|
+
export { whenUncovered };
|
|
30
43
|
|
|
31
44
|
type Props = {
|
|
32
|
-
/** The site's name, set small in the lower left while the
|
|
45
|
+
/** The gate's caption: the site's name, set small in the lower left while the dial runs. */
|
|
33
46
|
name?: string;
|
|
47
|
+
/**
|
|
48
|
+
* The figures the dial counts through, one beat each, in the client's own words (one to four characters read best).
|
|
49
|
+
* Left out, nothing is written and the arc fills the ring once.
|
|
50
|
+
*/
|
|
51
|
+
marks?: readonly string[];
|
|
52
|
+
/** The drawing: one ring (default), the film leader's two rings, crosshair and hand, or none. */
|
|
53
|
+
dial?: "ring" | "leader" | "none";
|
|
34
54
|
/** Play on every load (the lab, a launch page). Default: once per session. */
|
|
35
55
|
force?: boolean;
|
|
36
56
|
tone?: "deep" | "inverse" | "field";
|
|
@@ -81,10 +101,14 @@ const TAKEOVER = 1.4;
|
|
|
81
101
|
|
|
82
102
|
/**
|
|
83
103
|
* Runs in the HTML before the gate is parsed, so before its first paint: it shows the gate only with motion
|
|
84
|
-
* allowed and an unseen session (or force),
|
|
104
|
+
* allowed and an unseen session (or force), closes the curtain on <html> so every entrance waits from the first
|
|
105
|
+
* paint, and marks the first paint for the script that takes the clock over.
|
|
85
106
|
*/
|
|
86
107
|
const showScript = (force: boolean) =>
|
|
87
|
-
`try{if(!matchMedia("(prefers-reduced-motion: reduce)").matches&&(${force}||sessionStorage.getItem("${KEY}")!=="1")){window.__struqGateT0=performance.now();var s=document.createElement("style");s.id="${SHOW_ID}";s.textContent="[data-preloader-gate]{display:block!important}";document.head.appendChild(s)}}catch(e){}`;
|
|
108
|
+
`try{if(!matchMedia("(prefers-reduced-motion: reduce)").matches&&(${force}||sessionStorage.getItem("${KEY}")!=="1")){window.__struqGateT0=performance.now();document.documentElement.setAttribute("data-curtain","closed");var s=document.createElement("style");s.id="${SHOW_ID}";s.textContent="[data-preloader-gate]{display:block!important}";document.head.appendChild(s)}}catch(e){}`;
|
|
109
|
+
|
|
110
|
+
/** A lift owed by a gate torn down before it opened; strict mode's remount comes in the same commit and takes it back. */
|
|
111
|
+
let pendingLift = 0;
|
|
88
112
|
|
|
89
113
|
/** True while the gate is shut over the page: set an entrance's start state only then, so nothing blinks on a revisit. */
|
|
90
114
|
export function preloaderCovers(): boolean {
|
|
@@ -148,9 +172,15 @@ function useServerPass() {
|
|
|
148
172
|
);
|
|
149
173
|
}
|
|
150
174
|
|
|
151
|
-
|
|
175
|
+
/** The figure's size: a numeral or two at full size, a short word smaller, so every mark stays inside the ring. */
|
|
176
|
+
function markSize(marks: readonly string[]) {
|
|
177
|
+
const longest = Math.max(0, ...marks.map((m) => Array.from(m).length));
|
|
178
|
+
if (longest <= 2) return "text-[min(32vw,10.5rem)]";
|
|
179
|
+
if (longest <= 4) return "text-[min(17vw,5.75rem)]";
|
|
180
|
+
return "text-[min(10vw,3.5rem)]";
|
|
181
|
+
}
|
|
152
182
|
|
|
153
|
-
export function PreloaderGate({ name, force = false, tone = "deep" }: Props) {
|
|
183
|
+
export function PreloaderGate({ name, marks = [], dial = "ring", force = false, tone = "deep" }: Props) {
|
|
154
184
|
const root = useRef<HTMLDivElement>(null);
|
|
155
185
|
const [done, setDone] = useState(false);
|
|
156
186
|
const serverPass = useServerPass();
|
|
@@ -159,10 +189,24 @@ export function PreloaderGate({ name, force = false, tone = "deep" }: Props) {
|
|
|
159
189
|
() => {
|
|
160
190
|
const el = root.current;
|
|
161
191
|
if (!el) return;
|
|
192
|
+
window.clearTimeout(pendingLift);
|
|
193
|
+
// Whether this run holds the curtain closed: only a gate that plays ever closes it.
|
|
194
|
+
let closed = false;
|
|
162
195
|
const open = () => {
|
|
163
196
|
el.dataset.phase = "open";
|
|
197
|
+
if (closed) {
|
|
198
|
+
closed = false;
|
|
199
|
+
liftCurtain();
|
|
200
|
+
}
|
|
164
201
|
window.dispatchEvent(new Event(GATE_OPEN_EVENT));
|
|
165
202
|
};
|
|
203
|
+
// Torn down before it opened: let the page go (hold, curtain, entrances) unless a remount takes it back.
|
|
204
|
+
const letGo = () => {
|
|
205
|
+
if (!closed) return;
|
|
206
|
+
pendingLift = window.setTimeout(() => {
|
|
207
|
+
if (covered()) liftCurtain();
|
|
208
|
+
}, 0);
|
|
209
|
+
};
|
|
166
210
|
const finish = () => {
|
|
167
211
|
if (!force) {
|
|
168
212
|
try {
|
|
@@ -185,6 +229,9 @@ export function PreloaderGate({ name, force = false, tone = "deep" }: Props) {
|
|
|
185
229
|
const elapsed = (performance.now() - t0) / 1000;
|
|
186
230
|
const release = holdScroll();
|
|
187
231
|
window.scrollTo(0, 0);
|
|
232
|
+
// The inline script closed it before the first paint; closed again here for strict mode's second run.
|
|
233
|
+
closeCurtain();
|
|
234
|
+
closed = true;
|
|
188
235
|
|
|
189
236
|
// Hydrated late: the CSS clock is already into the exit. Let it finish, keep the hand-over on time.
|
|
190
237
|
if (elapsed >= TAKEOVER) {
|
|
@@ -197,6 +244,7 @@ export function PreloaderGate({ name, force = false, tone = "deep" }: Props) {
|
|
|
197
244
|
handover.kill();
|
|
198
245
|
end.kill();
|
|
199
246
|
release();
|
|
247
|
+
letGo();
|
|
200
248
|
};
|
|
201
249
|
}
|
|
202
250
|
|
|
@@ -269,6 +317,7 @@ export function PreloaderGate({ name, force = false, tone = "deep" }: Props) {
|
|
|
269
317
|
return () => {
|
|
270
318
|
tl.kill();
|
|
271
319
|
release();
|
|
320
|
+
letGo();
|
|
272
321
|
};
|
|
273
322
|
},
|
|
274
323
|
{ scope: root },
|
|
@@ -280,6 +329,11 @@ export function PreloaderGate({ name, force = false, tone = "deep" }: Props) {
|
|
|
280
329
|
const ring = "color-mix(in oklch, var(--on-gate) 46%, transparent)";
|
|
281
330
|
const inner = "color-mix(in oklch, var(--on-gate) 30%, transparent)";
|
|
282
331
|
const cross = "color-mix(in oklch, var(--on-gate) 24%, transparent)";
|
|
332
|
+
// One beat per mark, one beat when there are none. blocks.css derives the beat from the count for three beats; any
|
|
333
|
+
// other number is set here, on the same element, from the same count.
|
|
334
|
+
const beats = marks.length ? marks : [""];
|
|
335
|
+
const n = beats.length;
|
|
336
|
+
const count = n === 3 ? undefined : ({ "--gate-b": `min(calc(var(--gate-p) * ${n}), ${n - 0.01})` } as CSSProperties);
|
|
283
337
|
return (
|
|
284
338
|
<>
|
|
285
339
|
{serverPass ? <script dangerouslySetInnerHTML={{ __html: showScript(force) }} /> : null}
|
|
@@ -288,9 +342,11 @@ export function PreloaderGate({ name, force = false, tone = "deep" }: Props) {
|
|
|
288
342
|
data-preloader-gate=""
|
|
289
343
|
data-gate=""
|
|
290
344
|
data-tone={tone}
|
|
345
|
+
data-dial={dial}
|
|
291
346
|
data-phase="shut"
|
|
292
347
|
aria-hidden="true"
|
|
293
348
|
className="fixed inset-0 z-[100] select-none"
|
|
349
|
+
style={count}
|
|
294
350
|
>
|
|
295
351
|
{(["top", "bottom"] as const).map((side) => (
|
|
296
352
|
<div
|
|
@@ -311,37 +367,51 @@ export function PreloaderGate({ name, force = false, tone = "deep" }: Props) {
|
|
|
311
367
|
))}
|
|
312
368
|
|
|
313
369
|
<div data-leader="" className="absolute left-1/2 top-1/2 aspect-square w-[min(64vw,22rem)] -translate-x-1/2 -translate-y-1/2">
|
|
314
|
-
|
|
315
|
-
<
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
370
|
+
{dial === "none" ? null : (
|
|
371
|
+
<svg viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible" fill="none">
|
|
372
|
+
{dial === "leader" ? (
|
|
373
|
+
<>
|
|
374
|
+
<circle cx="100" cy="100" r="92" style={{ stroke: ring, strokeWidth: 0.8 }} />
|
|
375
|
+
<circle cx="100" cy="100" r="78" style={{ stroke: inner, strokeWidth: 0.7 }} />
|
|
376
|
+
<line x1="100" y1="-14" x2="100" y2="214" style={{ stroke: cross, strokeWidth: 0.7 }} />
|
|
377
|
+
</>
|
|
378
|
+
) : (
|
|
379
|
+
<circle cx="100" cy="100" r="78" style={{ stroke: ring, strokeWidth: 0.7 }} />
|
|
380
|
+
)}
|
|
381
|
+
{/* The sweep: one accent arc per beat, drawn round the ring. */}
|
|
382
|
+
{beats.map((_, i) => (
|
|
383
|
+
<circle
|
|
384
|
+
key={i}
|
|
385
|
+
data-beat=""
|
|
386
|
+
data-sweep=""
|
|
387
|
+
cx="100"
|
|
388
|
+
cy="100"
|
|
389
|
+
r="78"
|
|
390
|
+
pathLength={1}
|
|
391
|
+
strokeDasharray="1 1"
|
|
392
|
+
transform="rotate(-90 100 100)"
|
|
393
|
+
className="stroke-accent"
|
|
394
|
+
style={{ strokeWidth: 1.4, ["--i" as string]: i }}
|
|
395
|
+
/>
|
|
396
|
+
))}
|
|
397
|
+
{dial === "leader" ? (
|
|
398
|
+
<line data-hand="" x1="100" y1="100" x2="100" y2="8" className="stroke-accent" style={{ strokeWidth: 1.1 }} />
|
|
399
|
+
) : null}
|
|
400
|
+
</svg>
|
|
401
|
+
)}
|
|
336
402
|
|
|
337
|
-
{/* One
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
403
|
+
{/* One mark at a time, cut on the beat. */}
|
|
404
|
+
{marks.length ? (
|
|
405
|
+
<div
|
|
406
|
+
className={`absolute inset-0 grid place-items-center font-display ${markSize(marks)} leading-none tracking-[-0.02em] text-[var(--on-gate)] [font-variant-numeric:lining-nums]`}
|
|
407
|
+
>
|
|
408
|
+
{marks.map((mark, i) => (
|
|
409
|
+
<span key={i} data-beat="" className="whitespace-nowrap [grid-area:1/1]" style={{ ["--i" as string]: i }}>
|
|
410
|
+
{mark}
|
|
411
|
+
</span>
|
|
412
|
+
))}
|
|
413
|
+
</div>
|
|
414
|
+
) : null}
|
|
345
415
|
</div>
|
|
346
416
|
|
|
347
417
|
{name ? (
|
|
@@ -343,13 +343,16 @@ export function PreloaderStack({ photos, hero, target = "[data-stack-target]", n
|
|
|
343
343
|
</div>
|
|
344
344
|
{/* Parsed right after the frame: the first print shows (blocks.css) only once it is loaded and decoded. */}
|
|
345
345
|
<script dangerouslySetInnerHTML={{ __html: FIRST }} />
|
|
346
|
-
{/* The caption, in a mask it leaves through as the frame starts to grow
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
346
|
+
{/* The caption, in a mask it leaves through as the frame starts to grow: the shared line mask, at the
|
|
347
|
+
name's size so its room is the name's. */}
|
|
348
|
+
<div className="mt-3 md:mt-4">
|
|
349
|
+
<div className="struq-line-mask text-h3">
|
|
350
|
+
<div data-caption="" className="flex items-baseline justify-between gap-4">
|
|
351
|
+
{name ? <p className="font-display text-h3 leading-none">{name}</p> : <span />}
|
|
352
|
+
{/* The count is one number, --stack-n, shown by blocks.css: its CSS clock runs from the first paint
|
|
353
|
+
and the script takes the same number over when it hydrates. */}
|
|
354
|
+
<p data-count="" className="text-small tabular-nums lining-nums text-ink-muted" />
|
|
355
|
+
</div>
|
|
353
356
|
</div>
|
|
354
357
|
</div>
|
|
355
358
|
{/* Parsed right after the caption: it shows (blocks.css) only once its own faces are in, never in a fallback. */}
|
|
@@ -379,7 +379,7 @@ export function ContactForm({
|
|
|
379
379
|
<p
|
|
380
380
|
className={`font-display ${size === "display" ? "text-[clamp(2.5rem,1.1rem+4.6vw,6rem)] leading-[1.02]" : "text-h2"} tracking-[-0.02em]`}
|
|
381
381
|
>
|
|
382
|
-
<span className="struq-line-mask block
|
|
382
|
+
<span className="struq-line-mask block">
|
|
383
383
|
<span data-cf-rise="" className="block">
|
|
384
384
|
{firstName ? `Dank je, ${firstName}.` : "Dank je."}
|
|
385
385
|
</span>
|
|
@@ -611,7 +611,7 @@ export function CtaClosing({ variant = "photo", lines, link, image, caption, cla
|
|
|
611
611
|
const head = (extra = "", rise = true) => (
|
|
612
612
|
<h2 id={titleId} data-cc-rise={rise ? "" : undefined} data-cc-head="" className={`font-display ${extra}`}>
|
|
613
613
|
{lines.map((line, i) => (
|
|
614
|
-
<span key={i} data-cc-mask="" className="struq-line-mask block
|
|
614
|
+
<span key={i} data-cc-mask="" className="struq-line-mask block">
|
|
615
615
|
<span data-cc-line="" className="block">
|
|
616
616
|
<span data-cc-text="" className="inline-block">
|
|
617
617
|
{line}
|
|
@@ -667,7 +667,8 @@ export function CtaClosing({ variant = "photo", lines, link, image, caption, cla
|
|
|
667
667
|
</div>
|
|
668
668
|
{/* The band: decoration, the backdrop the link stands in front of. */}
|
|
669
669
|
<div aria-hidden className="pointer-events-none absolute inset-0 flex items-center motion-reduce:hidden">
|
|
670
|
-
|
|
670
|
+
{/* The mask carries the band's size, so its room (blocks.css) is measured in the band's em. */}
|
|
671
|
+
<div className="struq-line-mask w-full text-[30vw] md:text-[min(19vw,18rem)]">
|
|
671
672
|
<div data-cc-bandrow="" data-cc-rise="">
|
|
672
673
|
<div data-cc-band="" className="flex w-max">
|
|
673
674
|
{Array.from({ length: COPIES }, (_, c) => (
|
|
@@ -407,10 +407,11 @@ export function FannedCards({ title, accent, lead, items, className = "" }: Prop
|
|
|
407
407
|
// `js` in a client project and `sq-js` on struq.nl.
|
|
408
408
|
const gate =
|
|
409
409
|
"lg:motion-safe:[.js_&]:invisible lg:motion-safe:[.sq-js_&]:invisible lg:motion-safe:animate-[struq-show_0s_3s_forwards]";
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
410
|
+
// The shared line mask (blocks.css): it clips only top and bottom, with room for accents and descenders measured
|
|
411
|
+
// in its own em, so each mask carries the size of the line it holds.
|
|
412
|
+
const mask = "struq-line-mask block";
|
|
413
|
+
const size = "text-[length:calc(var(--d)*var(--fit,1))]";
|
|
414
|
+
const display = `font-display block w-fit whitespace-nowrap ${size} leading-[0.92] tracking-[-0.04em]`;
|
|
414
415
|
// A soft shadow made from the ground itself, deeper than the ground in light and dark palettes alike.
|
|
415
416
|
const shadow =
|
|
416
417
|
"0 2px 6px oklch(from var(--surface-0) calc(l * 0.3) c h / 0.3), 0 36px 64px -28px oklch(from var(--surface-0) calc(l * 0.22) c h / 0.62)";
|
|
@@ -427,13 +428,13 @@ export function FannedCards({ title, accent, lead, items, className = "" }: Prop
|
|
|
427
428
|
data-type=""
|
|
428
429
|
className={`relative z-0 px-6 lg:static lg:px-0 ${gate}`}
|
|
429
430
|
>
|
|
430
|
-
<span className={`${mask} lg:absolute lg:left-[4vw] lg:top-[5svh]`}>
|
|
431
|
+
<span className={`${mask} ${size} lg:absolute lg:left-[4vw] lg:top-[5svh]`}>
|
|
431
432
|
<span data-rise="" data-line-one="" className={display}>
|
|
432
433
|
{title}
|
|
433
434
|
</span>
|
|
434
435
|
</span>
|
|
435
436
|
{accent ? (
|
|
436
|
-
<span className={`${mask} ml-auto w-fit lg:absolute lg:bottom-[4svh] lg:right-[4vw]`}>
|
|
437
|
+
<span className={`${mask} ${size} ml-auto w-fit lg:absolute lg:bottom-[4svh] lg:right-[4vw]`}>
|
|
437
438
|
<span
|
|
438
439
|
data-line-two=""
|
|
439
440
|
className={`${display} font-accent font-light italic tracking-[-0.03em]`}
|
|
@@ -523,7 +524,7 @@ export function FannedCards({ title, accent, lead, items, className = "" }: Prop
|
|
|
523
524
|
} ${lead0 ? "lg:w-[min(34svh,26vw)]" : ""} ${gate}`}
|
|
524
525
|
style={pos as React.CSSProperties}
|
|
525
526
|
>
|
|
526
|
-
<span className={mask}>
|
|
527
|
+
<span className={`${mask} text-h3 ${lead0 ? "lg:text-[clamp(2rem,1rem_+_1.7vw,3rem)]" : ""}`}>
|
|
527
528
|
<span
|
|
528
529
|
data-cap-line=""
|
|
529
530
|
className={`font-display block text-h3 text-ink hyphens-auto ${
|
|
@@ -533,9 +534,12 @@ export function FannedCards({ title, accent, lead, items, className = "" }: Prop
|
|
|
533
534
|
{item.title}
|
|
534
535
|
</span>
|
|
535
536
|
</span>
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
537
|
+
{/* The spacing sits on a plain wrapper: the mask's own negative margins would cancel it. */}
|
|
538
|
+
<span className="mt-1.5 block">
|
|
539
|
+
<span className={`${mask} text-small`}>
|
|
540
|
+
<span data-cap-line="" className="block text-small text-ink-muted">
|
|
541
|
+
{item.text}
|
|
542
|
+
</span>
|
|
539
543
|
</span>
|
|
540
544
|
</span>
|
|
541
545
|
</div>
|
|
@@ -306,30 +306,34 @@ export function FilmStrip({ items, intro, outro, className = "" }: Props) {
|
|
|
306
306
|
{/* The title of the frame in the gate, and its metadata, each in its own mask, standing under the gate
|
|
307
307
|
(where the first frame's left edge is: 5vw, the intro's 40vw and one gap, until measured). */}
|
|
308
308
|
<div data-foot="" aria-hidden className="absolute bottom-10 left-[50.5vw] right-[5vw]">
|
|
309
|
-
|
|
309
|
+
{/* Shared line masks (blocks.css). The waiting copies stand one room down from the mask's top, where the
|
|
310
|
+
first one's text starts, since an absolute box is placed from the mask's padding edge. */}
|
|
311
|
+
<p className={`struq-line-mask relative text-[length:calc(var(--fs-t)*var(--fs-fit,1))] ${display}`}>
|
|
310
312
|
{items.map((item, j) => (
|
|
311
313
|
<span
|
|
312
314
|
key={item.title}
|
|
313
315
|
data-title=""
|
|
314
|
-
className={`block whitespace-nowrap will-change-transform ${j === 0 ? "relative" : "absolute inset-x-0 top-
|
|
316
|
+
className={`block whitespace-nowrap will-change-transform ${j === 0 ? "relative" : "absolute inset-x-0 top-[var(--struq-mask-top)]"}`}
|
|
315
317
|
style={{ transform: j === 0 ? "translate3d(0, 0%, 0)" : "translate3d(0, 140%, 0)" }}
|
|
316
318
|
>
|
|
317
319
|
{item.title}
|
|
318
320
|
</span>
|
|
319
321
|
))}
|
|
320
322
|
</p>
|
|
321
|
-
<
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
323
|
+
<div className="mt-3">
|
|
324
|
+
<p className="struq-line-mask relative text-small text-ink-muted">
|
|
325
|
+
{items.map((item, j) => (
|
|
326
|
+
<span
|
|
327
|
+
key={item.title}
|
|
328
|
+
data-meta=""
|
|
329
|
+
className={`block whitespace-nowrap ${j === 0 ? "relative" : "absolute inset-x-0 top-[var(--struq-mask-top)]"}`}
|
|
330
|
+
style={{ transform: j === 0 ? "translate3d(0, 0%, 0)" : "translate3d(0, 140%, 0)" }}
|
|
331
|
+
>
|
|
332
|
+
{item.meta ?? " "}
|
|
333
|
+
</span>
|
|
334
|
+
))}
|
|
335
|
+
</p>
|
|
336
|
+
</div>
|
|
333
337
|
</div>
|
|
334
338
|
</div>
|
|
335
339
|
|
|
@@ -460,8 +460,9 @@ export function GridAssembly({ tiles, hero, title, text, heading, line, classNam
|
|
|
460
460
|
"--r": `${cell.r} / span ${cell.rs}`,
|
|
461
461
|
}) as React.CSSProperties;
|
|
462
462
|
// Desktop: the board's cell. Phones: two columns, tall prints over two rows.
|
|
463
|
-
|
|
464
|
-
|
|
463
|
+
// The type cell does not clip: its lines clip in their own masks, with room for accents and descenders.
|
|
464
|
+
const tileClass = (tall: boolean, wide: boolean, clip = true) =>
|
|
465
|
+
`relative ${clip ? "overflow-hidden" : ""} bg-surface-2 will-change-transform md:[grid-column:var(--c)] md:[grid-row:var(--r)] ${
|
|
465
466
|
wide ? "col-span-2" : ""
|
|
466
467
|
} ${tall ? "row-span-2" : ""}`;
|
|
467
468
|
const ink = "text-on-photo group-data-[tone=dark]/ga:text-[var(--ga-dark)]";
|
|
@@ -474,7 +475,7 @@ export function GridAssembly({ tiles, hero, title, text, heading, line, classNam
|
|
|
474
475
|
>
|
|
475
476
|
<h3 className={`m-0 text-[clamp(2.75rem,1rem_+_5.4vw,8rem)] ${display}`}>
|
|
476
477
|
{heading.map((h) => (
|
|
477
|
-
<span key={h} className="
|
|
478
|
+
<span key={h} className="struq-line-mask block">
|
|
478
479
|
<span data-rise={rise ? "" : undefined} className="block">
|
|
479
480
|
{h}
|
|
480
481
|
</span>
|
|
@@ -482,9 +483,11 @@ export function GridAssembly({ tiles, hero, title, text, heading, line, classNam
|
|
|
482
483
|
))}
|
|
483
484
|
</h3>
|
|
484
485
|
{line ? (
|
|
485
|
-
<p className="m-0 mt-6
|
|
486
|
-
<span
|
|
487
|
-
{
|
|
486
|
+
<p className="m-0 mt-6 text-lead">
|
|
487
|
+
<span className="struq-line-mask block">
|
|
488
|
+
<span data-rise={rise ? "" : undefined} className="block max-w-[34ch] group-data-[side=right]/ga:ml-auto">
|
|
489
|
+
{line}
|
|
490
|
+
</span>
|
|
488
491
|
</span>
|
|
489
492
|
</p>
|
|
490
493
|
) : null}
|
|
@@ -513,20 +516,22 @@ export function GridAssembly({ tiles, hero, title, text, heading, line, classNam
|
|
|
513
516
|
{/* The type cell: the section's line, standing up as the grid locks. */}
|
|
514
517
|
<div
|
|
515
518
|
data-tile="t"
|
|
516
|
-
className={`${tileClass(false, true)} flex flex-col justify-between bg-transparent py-1 md:py-0`}
|
|
519
|
+
className={`${tileClass(false, true, false)} flex flex-col justify-between bg-transparent py-1 md:py-0`}
|
|
517
520
|
style={{ ...place(CELLS.t), ...({ "--sx": "0px", "--sy": "0px", "--sr": "0deg", "--sd": "0" } as React.CSSProperties) }}
|
|
518
521
|
>
|
|
519
522
|
<h2 className={`m-0 text-[clamp(2.25rem,1rem_+_2.9vw,5rem)] text-ink ${display}`}>
|
|
520
|
-
<span className="
|
|
523
|
+
<span className="struq-line-mask block">
|
|
521
524
|
<span data-ga-line="" className="block">
|
|
522
525
|
{title}
|
|
523
526
|
</span>
|
|
524
527
|
</span>
|
|
525
528
|
</h2>
|
|
526
529
|
{text ? (
|
|
527
|
-
<p className="m-0
|
|
528
|
-
<span
|
|
529
|
-
|
|
530
|
+
<p className="m-0 text-small text-ink-muted">
|
|
531
|
+
<span className="struq-line-mask block">
|
|
532
|
+
<span data-ga-line="" className="block max-w-[32ch]">
|
|
533
|
+
{text}
|
|
534
|
+
</span>
|
|
530
535
|
</span>
|
|
531
536
|
</p>
|
|
532
537
|
) : null}
|
|
@@ -220,8 +220,9 @@ function runWrites(el: HTMLElement, reduce: boolean) {
|
|
|
220
220
|
const to = (write * acc) / total;
|
|
221
221
|
const p = within(P, from, to);
|
|
222
222
|
const r = p * l.w;
|
|
223
|
-
// The clip reaches a
|
|
224
|
-
|
|
223
|
+
// The clip reaches a whole line past every edge it is not drawing, so ascents, accents, descents and an italic's
|
|
224
|
+
// overhang show at any leading (they need up to 0.35em; a line is at least 0.82em).
|
|
225
|
+
const pad = Math.round(l.h);
|
|
225
226
|
const right = p <= 0 ? l.box + pad : p >= 1 ? -pad : l.box - l.left - r;
|
|
226
227
|
lines[i].style.clipPath = `inset(${-pad}px ${right.toFixed(1)}px ${-pad}px ${-pad}px)`;
|
|
227
228
|
if (P >= from) {
|
|
@@ -354,12 +354,13 @@ export function PanelAccordion({ title, items, className = "" }: Props) {
|
|
|
354
354
|
className="absolute bottom-0 left-[-1px] w-[calc(100%+2px)] bg-surface-0 group-focus-visible:shadow-[inset_0_2px_0_var(--focus,var(--accent))]"
|
|
355
355
|
>
|
|
356
356
|
<div className="grid items-start px-4 pb-4 pt-3 md:px-5 md:pb-5 md:pt-4">
|
|
357
|
-
|
|
357
|
+
{/* Two shared line masks in one cell; the big one carries the name's size, so its room is the name's. */}
|
|
358
|
+
<span className="struq-line-mask block [grid-area:1/1]">
|
|
358
359
|
<span data-small="" className="text-small block whitespace-nowrap">
|
|
359
360
|
{item.title}
|
|
360
361
|
</span>
|
|
361
362
|
</span>
|
|
362
|
-
<span className="block
|
|
363
|
+
<span className="struq-line-mask block text-h3 [grid-area:1/1] md:text-[clamp(2.5rem,1rem_+_3.6vw,5.5rem)]">
|
|
363
364
|
<span data-big="" className="flex flex-col items-start gap-2 md:gap-3" style={{ transform: "translateY(115%)" }}>
|
|
364
365
|
<span
|
|
365
366
|
data-name=""
|
|
@@ -500,7 +500,7 @@ export function SplitPair({ images, word, line, captions, place, className = ""
|
|
|
500
500
|
{ scope: stage, dependencies: [named, place] },
|
|
501
501
|
);
|
|
502
502
|
|
|
503
|
-
const mask = "
|
|
503
|
+
const mask = "struq-line-mask block";
|
|
504
504
|
|
|
505
505
|
// The word and the line under it, laid out once and rendered on the ground and on each photo. Only the ground
|
|
506
506
|
// copy is the real text; the copies on the photos are pictures of it.
|
|
@@ -509,8 +509,9 @@ export function SplitPair({ images, word, line, captions, place, className = ""
|
|
|
509
509
|
<div
|
|
510
510
|
className="absolute inset-x-0 top-[calc(var(--sp-y)*100%)] flex -translate-y-1/2 justify-center text-[length:min(calc(var(--sp-base)*var(--sp-fit,1)),34svh)]"
|
|
511
511
|
>
|
|
512
|
-
{/* The mask the word rises out of, with room for accents above and descenders below
|
|
513
|
-
|
|
512
|
+
{/* The mask the word rises out of: the shared line mask, with room for accents above and descenders below
|
|
513
|
+
and the sides left open for an italic's overhang. */}
|
|
514
|
+
<span className="struq-line-mask block">
|
|
514
515
|
<span
|
|
515
516
|
data-word=""
|
|
516
517
|
className="font-display block w-fit whitespace-nowrap leading-[0.82] tracking-[-0.045em] [word-spacing:0.08em]"
|