@struq/skill 0.4.2 → 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.
Files changed (50) hide show
  1. package/README.md +167 -59
  2. package/package.json +2 -2
  3. package/skills/struq/SKILL.md +145 -89
  4. package/skills/struq/blocks/CATALOG.md +74 -22
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +34 -12
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +14 -6
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +40 -15
  8. package/skills/struq/blocks/catalog.json +697 -16
  9. package/skills/struq/blocks/film.json +426 -0
  10. package/skills/struq/blocks/footers/footer-contact.tsx +81 -55
  11. package/skills/struq/blocks/footers/footer-reveal.tsx +1 -1
  12. package/skills/struq/blocks/headers/header-bar.tsx +24 -13
  13. package/skills/struq/blocks/headers/menu-curtain.tsx +24 -11
  14. package/skills/struq/blocks/heroes/hero-framed.tsx +33 -20
  15. package/skills/struq/blocks/heroes/hero-index.tsx +32 -11
  16. package/skills/struq/blocks/heroes/hero-poster.tsx +68 -35
  17. package/skills/struq/blocks/heroes/hero-split.tsx +22 -9
  18. package/skills/struq/blocks/heroes/hero-still.tsx +80 -74
  19. package/skills/struq/blocks/kit/accent.ts +82 -0
  20. package/skills/struq/blocks/kit/blocks.css +78 -35
  21. package/skills/struq/blocks/kit/quiet/Close.tsx +73 -0
  22. package/skills/struq/blocks/kit/quiet/PhotoText.tsx +79 -0
  23. package/skills/struq/blocks/kit/quiet/Rows.tsx +81 -0
  24. package/skills/struq/blocks/kit/quiet/Statement.tsx +157 -0
  25. package/skills/struq/blocks/kit/quiet/index.ts +7 -0
  26. package/skills/struq/blocks/kit/quiet/sheet.ts +12 -0
  27. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +87 -36
  28. package/skills/struq/blocks/preloaders/preloader-gate.tsx +116 -46
  29. package/skills/struq/blocks/preloaders/preloader-stack.tsx +10 -7
  30. package/skills/struq/blocks/sections/contact-form.tsx +1 -1
  31. package/skills/struq/blocks/sections/cta-closing.tsx +3 -2
  32. package/skills/struq/blocks/sections/fanned-cards.tsx +14 -10
  33. package/skills/struq/blocks/sections/film-strip.tsx +18 -14
  34. package/skills/struq/blocks/sections/grid-assembly.tsx +16 -11
  35. package/skills/struq/blocks/sections/manifesto.tsx +3 -2
  36. package/skills/struq/blocks/sections/panel-accordion.tsx +3 -2
  37. package/skills/struq/blocks/sections/split-pair.tsx +4 -3
  38. package/skills/struq/blocks/showpieces/hero-opener.tsx +76 -17
  39. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +3 -1
  40. package/skills/struq/blocks/transitions/zoom-through.tsx +9 -10
  41. package/skills/struq/canon/canon.json +1 -1
  42. package/skills/struq/references/craft.md +4 -0
  43. package/skills/struq/references/film.md +158 -0
  44. package/skills/struq/references/triage.md +3 -0
  45. package/skills/struq/scripts/blocks.mjs +89 -7
  46. package/skills/struq/scripts/canon.mjs +8 -1
  47. package/skills/struq/scripts/check.mjs +94 -11
  48. package/skills/struq/scripts/film-check.mjs +997 -0
  49. package/skills/struq/scripts/gate.mjs +146 -0
  50. 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
- // A film leader in the site's own voice. The first paint is the whole leader on a shut gate: two
4
- // bars meeting on one line, a dial (two rings and a crosshair) and one figure in the display face.
5
- // Only the sweep moves: the hand and an accent arc run once per figure, 3, 2, 1, on an eased count
6
- // that waits for the fonts and the hero image. Then the leader drops away, the line through the
7
- // middle runs out to both edges and lights in the accent, and the gate opens on it: the top bar
8
- // rises, the bottom bar falls, each carrying half of the light, onto the page's own entrance.
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 leader whole; the count runs (CSS until React hydrates, then GSAP)
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 leader falls back; the line runs out and lights
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, and start the hero's own
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 leader runs. */
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), and marks the first paint for the script that takes the clock over.
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
- const DIGITS = ["3", "2", "1"];
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
- <svg viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible" fill="none">
315
- <circle cx="100" cy="100" r="92" style={{ stroke: ring, strokeWidth: 0.8 }} />
316
- <circle cx="100" cy="100" r="78" style={{ stroke: inner, strokeWidth: 0.7 }} />
317
- <line x1="100" y1="-14" x2="100" y2="214" style={{ stroke: cross, strokeWidth: 0.7 }} />
318
- {/* The sweep: one accent arc per figure, drawn round the inner ring as the hand passes. */}
319
- {DIGITS.map((d, i) => (
320
- <circle
321
- key={d}
322
- data-beat=""
323
- data-sweep=""
324
- cx="100"
325
- cy="100"
326
- r="78"
327
- pathLength={1}
328
- strokeDasharray="1 1"
329
- transform="rotate(-90 100 100)"
330
- className="stroke-accent"
331
- style={{ strokeWidth: 1.4, ["--i" as string]: i }}
332
- />
333
- ))}
334
- <line data-hand="" x1="100" y1="100" x2="100" y2="8" className="stroke-accent" style={{ strokeWidth: 1.1 }} />
335
- </svg>
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 figure at a time, cut on the beat. */}
338
- <div className="absolute inset-0 grid place-items-center font-display text-[min(32vw,10.5rem)] leading-none tracking-[-0.02em] text-[var(--on-gate)] [font-variant-numeric:lining-nums]">
339
- {DIGITS.map((d, i) => (
340
- <span key={d} data-beat="" className="[grid-area:1/1]" style={{ ["--i" as string]: i }}>
341
- {d}
342
- </span>
343
- ))}
344
- </div>
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
- <div className="mt-3 overflow-hidden pb-[0.15em] md:mt-4">
348
- <div data-caption="" className="flex items-baseline justify-between gap-4">
349
- {name ? <p className="font-display text-h3 leading-none">{name}</p> : <span />}
350
- {/* The count is one number, --stack-n, shown by blocks.css: its CSS clock runs from the first paint and
351
- the script takes the same number over when it hydrates. */}
352
- <p data-count="" className="text-small tabular-nums lining-nums text-ink-muted" />
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 overflow-hidden">
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 overflow-hidden">
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
- <div className="struq-line-mask w-full overflow-hidden">
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
- // Masks clip only top and bottom, so a wide face never loses the end of a word.
411
- const mask = "struq-line-mask block overflow-x-visible overflow-y-clip";
412
- const display =
413
- "font-display block w-fit whitespace-nowrap text-[length:calc(var(--d)*var(--fit,1))] leading-[0.92] tracking-[-0.04em]";
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
- <span className={`${mask} mt-1.5`}>
537
- <span data-cap-line="" className="block text-small text-ink-muted">
538
- {item.text}
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
- <p className={`relative m-0 overflow-hidden pb-[0.14em] text-[length:calc(var(--fs-t)*var(--fs-fit,1))] ${display}`}>
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-0"}`}
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
- <p className="relative m-0 mt-3 overflow-hidden text-small text-ink-muted">
322
- {items.map((item, j) => (
323
- <span
324
- key={item.title}
325
- data-meta=""
326
- className={`block whitespace-nowrap ${j === 0 ? "relative" : "absolute inset-x-0 top-0"}`}
327
- style={{ transform: j === 0 ? "translate3d(0, 0%, 0)" : "translate3d(0, 140%, 0)" }}
328
- >
329
- {item.meta ?? " "}
330
- </span>
331
- ))}
332
- </p>
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
- const tileClass = (tall: boolean, wide: boolean) =>
464
- `relative overflow-hidden bg-surface-2 will-change-transform md:[grid-column:var(--c)] md:[grid-row:var(--r)] ${
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="block overflow-hidden pb-[0.1em] -mb-[0.1em]">
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 overflow-hidden text-lead">
486
- <span data-rise={rise ? "" : undefined} className="block max-w-[34ch] group-data-[side=right]/ga:ml-auto">
487
- {line}
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="block overflow-hidden pb-[0.1em] -mb-[0.1em]">
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 overflow-hidden text-small text-ink-muted">
528
- <span data-ga-line="" className="block max-w-[32ch]">
529
- {text}
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 quarter line past every edge it is not drawing, so ascents, descents and overhangs show.
224
- const pad = Math.round(l.h * 0.25);
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
- <span className="block overflow-hidden [grid-area:1/1]">
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 overflow-hidden pb-[0.14em] [grid-area:1/1]">
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 = "block overflow-hidden pb-[0.15em] -mb-[0.15em]";
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
- <span className="-mx-[0.1em] -mb-[0.24em] -mt-[0.08em] block overflow-hidden px-[0.1em] pb-[0.24em] pt-[0.08em]">
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]"