@struq/skill 0.4.3 → 0.5.1

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 (53) hide show
  1. package/README.md +27 -27
  2. package/cli/struq.mjs +2 -2
  3. package/package.json +6 -6
  4. package/skills/struq/SKILL.md +145 -89
  5. package/skills/struq/blocks/CATALOG.md +74 -22
  6. package/skills/struq/blocks/backgrounds/background-aurora.tsx +34 -12
  7. package/skills/struq/blocks/backgrounds/background-facets.tsx +14 -6
  8. package/skills/struq/blocks/backgrounds/background-silk.tsx +40 -15
  9. package/skills/struq/blocks/catalog.json +697 -16
  10. package/skills/struq/blocks/film.json +426 -0
  11. package/skills/struq/blocks/footers/footer-contact.tsx +81 -55
  12. package/skills/struq/blocks/footers/footer-reveal.tsx +1 -1
  13. package/skills/struq/blocks/headers/header-bar.tsx +24 -13
  14. package/skills/struq/blocks/headers/menu-curtain.tsx +24 -11
  15. package/skills/struq/blocks/heroes/hero-framed.tsx +33 -20
  16. package/skills/struq/blocks/heroes/hero-index.tsx +32 -11
  17. package/skills/struq/blocks/heroes/hero-poster.tsx +68 -35
  18. package/skills/struq/blocks/heroes/hero-split.tsx +22 -9
  19. package/skills/struq/blocks/heroes/hero-still.tsx +80 -74
  20. package/skills/struq/blocks/kit/accent.ts +82 -0
  21. package/skills/struq/blocks/kit/blocks.css +78 -35
  22. package/skills/struq/blocks/kit/quiet/Close.tsx +73 -0
  23. package/skills/struq/blocks/kit/quiet/PhotoText.tsx +79 -0
  24. package/skills/struq/blocks/kit/quiet/Rows.tsx +81 -0
  25. package/skills/struq/blocks/kit/quiet/Statement.tsx +157 -0
  26. package/skills/struq/blocks/kit/quiet/index.ts +7 -0
  27. package/skills/struq/blocks/kit/quiet/sheet.ts +12 -0
  28. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +87 -36
  29. package/skills/struq/blocks/preloaders/preloader-gate.tsx +116 -46
  30. package/skills/struq/blocks/preloaders/preloader-stack.tsx +10 -7
  31. package/skills/struq/blocks/sections/contact-form.tsx +1 -1
  32. package/skills/struq/blocks/sections/cta-closing.tsx +3 -2
  33. package/skills/struq/blocks/sections/fanned-cards.tsx +14 -10
  34. package/skills/struq/blocks/sections/film-strip.tsx +18 -14
  35. package/skills/struq/blocks/sections/grid-assembly.tsx +16 -11
  36. package/skills/struq/blocks/sections/manifesto.tsx +3 -2
  37. package/skills/struq/blocks/sections/panel-accordion.tsx +3 -2
  38. package/skills/struq/blocks/sections/split-pair.tsx +4 -3
  39. package/skills/struq/blocks/showpieces/hero-opener.tsx +76 -17
  40. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +3 -1
  41. package/skills/struq/blocks/transitions/zoom-through.tsx +9 -10
  42. package/skills/struq/canon/canon.json +1 -1
  43. package/skills/struq/references/craft.md +4 -0
  44. package/skills/struq/references/film.md +158 -0
  45. package/skills/struq/references/iconography.md +1 -1
  46. package/skills/struq/references/triage.md +3 -0
  47. package/skills/struq/scripts/blocks.mjs +89 -7
  48. package/skills/struq/scripts/canon.mjs +8 -1
  49. package/skills/struq/scripts/check.mjs +99 -15
  50. package/skills/struq/scripts/film-check.mjs +997 -0
  51. package/skills/struq/scripts/gate.mjs +146 -0
  52. package/skills/struq/scripts/images.mjs +64 -37
  53. package/skills/struq/scripts/packs.mjs +73 -12
@@ -1,13 +1,22 @@
1
1
  "use client";
2
2
  // hero-still (Struq hero recipe)
3
- // A film still with a title. One photograph edge to edge, held with a very slow push-in (six percent over twenty
4
- // seconds); the headline rises line by line through masks at display size on the photo's calm side; under the still,
5
- // on the page ground like a film's caption strip, a quiet bar with three facts on one line: the studio's place, the
6
- // live local time, one link. On scroll
7
- // the photograph moves slower than the page and the headline leaves first, so the hero hands over with depth.
3
+ // A photograph with a title. One photograph edge to edge, held with a very slow push-in (six percent over twenty
4
+ // seconds); the headline rises line by line through masks at display size on the photo's calm side; under the photo,
5
+ // on the page ground, a quiet caption bar with its facts on one line: the place, an optional middle fact, one link.
6
+ // On scroll the photograph moves slower than the page and the headline leaves first, so the hero hands over with depth.
8
7
  //
9
- // <HeroStill image={images.hero[0]} lines={["Ruimte die", "blijft."]} place="Noord, Rotterdam"
10
- // timeZone="Europe/Amsterdam" link={{ href: "/werk", label: "Bekijk het werk" }} />
8
+ // <HeroStill image={images.hero[0]} lines={["Ruimte die", "blijft."]} accent="blijft" place="Noord, Rotterdam"
9
+ // link={{ href: "/werk", label: "Bekijk het werk" }} />
10
+ //
11
+ // `accent` (optional) is the site's one accent word: where it first stands in the headline it is set in the pairing's
12
+ // italic accent face, inside its line's mask, and the Statement and the Close carry the same word. Left out, or not in
13
+ // the headline, the headline is exactly as it was.
14
+ //
15
+ // The caption's words are the client's own. `note` fills the middle with one fact from the client's trade (a scale, a
16
+ // year, a plot number); without it the caption is the place and the link, one either side. A live local clock is
17
+ // there only when asked: `clock` puts the time in `timeZone` in the middle (with `clockLabel` after it, "lokale tijd"
18
+ // by default) in place of the note. The time is never read during render: the slot holds its width empty on the server
19
+ // and the first paint, and the real time arrives after mount, renewed on the minute.
11
20
  //
12
21
  // The photo's box carries data-stack-target, so preloader-stack and page-transition-stack land on it exactly, and the
13
22
  // entrance starts only when the cover lifts (whenUncovered), at once when there is none. The push-in never re-rasters:
@@ -40,6 +49,7 @@
40
49
  // with the photograph, so it stays on its horizon as the still hands over.
41
50
  import Image from "next/image";
42
51
  import { useEffect, useRef, useState } from "react";
52
+ import { accentLines } from "../kit/accent";
43
53
  import { ScrollTrigger, gsap, useGSAP } from "../kit/gsap";
44
54
  import type { StruqImage } from "../kit/images";
45
55
  import { photoLuminances, type PhotoBox } from "../kit/photo-read";
@@ -62,11 +72,21 @@ type Inner = {
62
72
  image: StruqImage;
63
73
  /** The headline, one entry per line, display size. Two or three short lines read best. */
64
74
  lines: string[];
75
+ /** The site's one accent word: where it first stands in the headline it is set in the pairing's italic accent face
76
+ * (--face-accent), as the Statement and the Close set it. A word the headline does not hold changes nothing. */
77
+ accent?: string;
65
78
  /** The studio's place, the caption's first fact. */
66
79
  place: string;
67
- /** IANA zone for the live local time, the caption's second fact. */
80
+ /** The caption's middle fact, in the client's own words (a scale, a year, a plot number). Left out, the caption is
81
+ * the place and the link alone. A clock takes its place. */
82
+ note?: string;
83
+ /** A live local clock in the caption's middle. Off unless asked: one clock per site at most. */
84
+ clock?: boolean;
85
+ /** IANA zone for the clock. */
68
86
  timeZone?: string;
69
- /** The caption's third fact: one link. */
87
+ /** The words after the clock's time. */
88
+ clockLabel?: string;
89
+ /** The caption's last fact: one link. */
70
90
  link: { href: string; label: string };
71
91
  /** Where the headline stands. Left out, the block reads the photo and takes the side that needs the weaker veil
72
92
  * (the photo's negativeSpace breaks a tie); the first paint, and a page without scripts, use negativeSpace. */
@@ -128,7 +148,7 @@ function veilFor(under: number, ink: number, veilS: number, ratio: number): numb
128
148
  return hi;
129
149
  }
130
150
 
131
- /** The time in a zone as HH:MM, the same five characters at every minute. */
151
+ /** The time in a zone as HH:MM, the same five characters at every minute. Only ever called after mount. */
132
152
  function timeIn(zone: string) {
133
153
  return new Intl.DateTimeFormat("nl-NL", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: zone }).format(
134
154
  new Date(),
@@ -183,24 +203,12 @@ export function inkOver(img: HTMLImageElement, frame: PhotoBox, boxes: PhotoBox[
183
203
  export function HeroStill({ variant = "still", ...rest }: Props) {
184
204
  if (variant === "frame") return <StillFrame {...rest} />;
185
205
  if (variant === "horizon") return <StillHorizon {...rest} />;
186
- return <StillFilm {...rest} />;
206
+ return <StillBleed {...rest} />;
187
207
  }
188
208
 
189
- function StillFilm({ image, lines, place, timeZone = "Europe/Amsterdam", link, side, className = "" }: Inner) {
209
+ function StillBleed({ image, lines, accent, side, className = "", ...caption }: Inner) {
190
210
  const root = useRef<HTMLElement>(null);
191
211
  const first = side ?? (image.negativeSpace === "right" ? "right" : "left");
192
- const [time, setTime] = useState(() => timeIn(timeZone));
193
-
194
- // The clock: renewed on each minute, in tabular figures, so nothing around it ever moves.
195
- useEffect(() => {
196
- let tick = 0;
197
- const next = () => {
198
- setTime(timeIn(timeZone));
199
- tick = window.setTimeout(next, 60_000 - (Date.now() % 60_000) + 50);
200
- };
201
- next();
202
- return () => window.clearTimeout(tick);
203
- }, [timeZone]);
204
212
 
205
213
  useGSAP(
206
214
  (_, contextSafe) => {
@@ -346,7 +354,7 @@ function StillFilm({ image, lines, place, timeZone = "Europe/Amsterdam", link, s
346
354
  );
347
355
 
348
356
  const mask = (child: React.ReactNode, key?: number) => (
349
- <span key={key} className="struq-line-mask block overflow-hidden">
357
+ <span key={key} className="struq-line-mask block">
350
358
  <span data-rise="" className="block">
351
359
  {child}
352
360
  </span>
@@ -384,43 +392,28 @@ function StillFilm({ image, lines, place, timeZone = "Europe/Amsterdam", link, s
384
392
 
385
393
  <div
386
394
  data-head=""
387
- className="absolute inset-x-0 bottom-[5vh] px-6 group-data-[side=right]/hs:flex group-data-[side=right]/hs:justify-end group-data-[side=right]/hs:text-right md:bottom-[6vh] md:px-[4vw]"
395
+ className="absolute inset-x-0 bottom-[5vh] px-sheet-margin group-data-[side=right]/hs:flex group-data-[side=right]/hs:justify-end group-data-[side=right]/hs:text-right md:bottom-[6vh]"
388
396
  >
389
397
  <h1 className="font-display text-[clamp(3rem,13.5vw,4.75rem)] leading-[0.92] tracking-[-0.035em] md:text-[clamp(3.5rem,8vw,9.5rem)]">
390
- {lines.map((line, i) => mask(line, i))}
398
+ {accentLines(lines, accent).map((line, i) => mask(line, i))}
391
399
  </h1>
392
400
  </div>
393
401
  </div>
394
402
 
395
- {/* The caption bar, on the page ground under the still like a film's caption strip: three facts on one line (two
396
- on phones), the clock in tabular figures at a fixed width. On the ground its ink holds on any photograph. */}
397
- <div data-bar="" className="text-small relative bg-surface-0 px-6 pb-4 pt-3.5 text-ink md:px-[4vw] md:pb-5 md:pt-4">
398
- <div>
399
- {mask(
400
- <span className="grid grid-cols-[1fr_auto] gap-x-6 gap-y-1 md:grid-cols-[1fr_auto_1fr]">
401
- <span>{place}</span>
402
- <span className="tabular-nums lining-nums md:text-center">
403
- <time suppressHydrationWarning className="inline-block min-w-[5ch] text-right">
404
- {time}
405
- </time>{" "}
406
- lokale tijd
407
- </span>
408
- <a href={link.href} className="col-span-2 underline decoration-1 underline-offset-4 md:col-span-1 md:justify-self-end">
409
- {link.label}
410
- </a>
411
- </span>,
412
- )}
413
- </div>
414
- </div>
403
+ <Caption {...caption} />
415
404
  </section>
416
405
  );
417
406
  }
418
407
 
419
- // The frame and the horizon share what follows: the clock, the caption strip, the line masks and the entrance.
408
+ // All three variants share what follows: the clock, the caption strip, the line masks; the frame and the horizon
409
+ // share the entrance.
420
410
 
421
- function useClock(timeZone: string) {
422
- const [time, setTime] = useState(() => timeIn(timeZone));
411
+ /** The time in `timeZone` as HH:MM, only when `on`: empty on the server and the first paint, set after mount and
412
+ * renewed on each minute. */
413
+ function useClock(on: boolean, timeZone: string) {
414
+ const [time, setTime] = useState("");
423
415
  useEffect(() => {
416
+ if (!on) return;
424
417
  let tick = 0;
425
418
  const next = () => {
426
419
  setTime(timeIn(timeZone));
@@ -428,33 +421,46 @@ function useClock(timeZone: string) {
428
421
  };
429
422
  next();
430
423
  return () => window.clearTimeout(tick);
431
- }, [timeZone]);
424
+ }, [on, timeZone]);
432
425
  return time;
433
426
  }
434
427
 
435
428
  const lineMask = (child: React.ReactNode, key?: number) => (
436
- <span key={key} className="struq-line-mask block overflow-hidden">
429
+ <span key={key} className="struq-line-mask block">
437
430
  <span data-rise="" className="block">
438
431
  {child}
439
432
  </span>
440
433
  </span>
441
434
  );
442
435
 
443
- /** The caption strip on the page ground, as the still has it: place, the live local time, one link. */
444
- function Caption({ place, time, link }: { place: string; time: string; link: Inner["link"] }) {
436
+ type CaptionProps = Pick<Inner, "place" | "note" | "clock" | "timeZone" | "clockLabel" | "link">;
437
+
438
+ /**
439
+ * The caption strip on the page ground: the place, a middle fact (the clock when asked, else the note), one link, on
440
+ * one line (two on phones). The clock is in tabular figures at a fixed width, so nothing around it ever moves. With no
441
+ * middle fact the place and the link stand at either end. On the ground its ink holds on any photograph.
442
+ */
443
+ function Caption({ place, note, clock = false, timeZone = "Europe/Amsterdam", clockLabel = "lokale tijd", link }: CaptionProps) {
444
+ const time = useClock(clock, timeZone);
445
+ const middle = clock ? (
446
+ <span className="tabular-nums lining-nums md:text-center">
447
+ <time className="inline-block min-w-[5ch] text-right">{time}</time>
448
+ {clockLabel ? ` ${clockLabel}` : null}
449
+ </span>
450
+ ) : note ? (
451
+ <span className="md:text-center">{note}</span>
452
+ ) : null;
445
453
  return (
446
- <div data-bar="" className="text-small relative bg-surface-0 px-6 pb-4 pt-3.5 text-ink md:px-[4vw] md:pb-5 md:pt-4">
454
+ <div data-bar="" className="text-small relative bg-surface-0 px-sheet-margin pb-4 pt-3.5 text-ink md:pb-5 md:pt-4">
447
455
  <div>
448
456
  {lineMask(
449
- <span className="grid grid-cols-[1fr_auto] gap-x-6 gap-y-1 md:grid-cols-[1fr_auto_1fr]">
457
+ <span className={`grid grid-cols-[1fr_auto] gap-x-6 gap-y-1 ${middle ? "md:grid-cols-[1fr_auto_1fr]" : ""}`}>
450
458
  <span>{place}</span>
451
- <span className="tabular-nums lining-nums md:text-center">
452
- <time suppressHydrationWarning className="inline-block min-w-[5ch] text-right">
453
- {time}
454
- </time>{" "}
455
- lokale tijd
456
- </span>
457
- <a href={link.href} className="col-span-2 underline decoration-1 underline-offset-4 md:col-span-1 md:justify-self-end">
459
+ {middle}
460
+ <a
461
+ href={link.href}
462
+ className={`underline decoration-1 underline-offset-4 ${middle ? "col-span-2 md:col-span-1 md:justify-self-end" : "justify-self-end"}`}
463
+ >
458
464
  {link.label}
459
465
  </a>
460
466
  </span>,
@@ -546,9 +552,8 @@ const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
546
552
  * preloader lands on the frame with the photo laid as the still, and opening the frame on the first scroll is a clip
547
553
  * from whole-pixel numbers to the full bleed, never a scale. The headline leaves upward ahead of the opening edge.
548
554
  */
549
- function StillFrame({ image, lines, place, timeZone = "Europe/Amsterdam", link, className = "" }: Inner) {
555
+ function StillFrame({ image, lines, accent, className = "", ...caption }: Inner) {
550
556
  const root = useRef<HTMLElement>(null);
551
- const time = useClock(timeZone);
552
557
 
553
558
  useGSAP(
554
559
  (_, contextSafe) => {
@@ -670,14 +675,14 @@ function StillFrame({ image, lines, place, timeZone = "Europe/Amsterdam", link,
670
675
  </div>
671
676
  <div
672
677
  data-head=""
673
- className="absolute inset-x-6 bottom-[4svh] z-10 will-change-transform md:bottom-[3svh] md:left-[4vw] md:right-auto md:max-w-[34vw]"
678
+ className="absolute inset-x-sheet-margin bottom-[4svh] z-10 will-change-transform md:bottom-[3svh] md:right-auto md:max-w-[34vw]"
674
679
  >
675
680
  <h1 className="font-display text-[clamp(3rem,13.5vw,4.75rem)] leading-[0.92] tracking-[-0.035em] md:text-[clamp(3rem,5.4vw,7.5rem)]">
676
- {lines.map((line, i) => lineMask(line, i))}
681
+ {accentLines(lines, accent).map((line, i) => lineMask(line, i))}
677
682
  </h1>
678
683
  </div>
679
684
  </div>
680
- <Caption place={place} time={time} link={link} />
685
+ <Caption {...caption} />
681
686
  </section>
682
687
  );
683
688
  }
@@ -688,12 +693,13 @@ function StillFrame({ image, lines, place, timeZone = "Europe/Amsterdam", link,
688
693
  * when the photo cannot be read). Each half takes the ink that needs the weaker veil over what is under its own
689
694
  * glyphs, over its own veil band that ends at the horizon; the side is measured the same way unless it is given.
690
695
  */
691
- function StillHorizon({ image, lines, place, timeZone = "Europe/Amsterdam", link, side, horizon, className = "" }: Inner) {
696
+ function StillHorizon({ image, lines, accent, side, horizon, className = "", ...caption }: Inner) {
692
697
  const root = useRef<HTMLElement>(null);
693
- const time = useClock(timeZone);
694
698
  const first = side ?? (image.negativeSpace === "right" ? "right" : "left");
695
- const up = lines.slice(0, Math.ceil(lines.length / 2));
696
- const down = lines.slice(up.length);
699
+ // The accent is found over the whole headline, then the lines are halved: one mark, on whichever side it falls.
700
+ const shown = accentLines(lines, accent);
701
+ const up = shown.slice(0, Math.ceil(lines.length / 2));
702
+ const down = shown.slice(up.length);
697
703
 
698
704
  useGSAP(
699
705
  (_, contextSafe) => {
@@ -810,7 +816,7 @@ function StillHorizon({ image, lines, place, timeZone = "Europe/Amsterdam", link
810
816
  );
811
817
 
812
818
  const head =
813
- "absolute inset-x-6 font-display text-[clamp(3rem,13.5vw,4.75rem)] leading-[0.92] tracking-[-0.035em] group-data-[side=right]/hs:text-right md:inset-x-[4vw] md:text-[clamp(3.5rem,8vw,9.5rem)]";
819
+ "absolute inset-x-sheet-margin font-display text-[clamp(3rem,13.5vw,4.75rem)] leading-[0.92] tracking-[-0.035em] group-data-[side=right]/hs:text-right md:text-[clamp(3.5rem,8vw,9.5rem)]";
814
820
 
815
821
  return (
816
822
  <section
@@ -856,7 +862,7 @@ function StillHorizon({ image, lines, place, timeZone = "Europe/Amsterdam", link
856
862
  ) : null}
857
863
  </div>
858
864
  </div>
859
- <Caption place={place} time={time} link={link} />
865
+ <Caption {...caption} />
860
866
  </section>
861
867
  );
862
868
  }
@@ -0,0 +1,82 @@
1
+ // components/struq/accent.ts (Struq recipe)
2
+ // The site's one accent word: the same word, set in the pairing's italic accent face (--face-accent), in the opening
3
+ // headline and again in the last scene. Every block that takes an `accent` prop marks it with this one helper, so the
4
+ // word looks the same wherever it stands.
5
+ //
6
+ // withAccent("Waar valt het licht?", "licht") -> Waar valt het <em>licht</em>?
7
+ // accentLines(["Ruimte die", "blijft."], "blijft") -> ["Ruimte die", <>...<em>blijft</em>.</>]
8
+ //
9
+ // The match: the first time the word stands in the text as a whole word, case-insensitive, with the text's own casing
10
+ // kept. Punctuation attached to the word (or passed with it, "vuur.") stays outside the mark. A word the text does not
11
+ // hold changes nothing and throws nothing: the text comes back as it went in.
12
+ //
13
+ // The mark is a plain inline <em>: it stays a text-level element, so SplitText carries it into whichever line it lands
14
+ // on when it cuts (and cuts again on a resize), and it sits inside the line's mask (.struq-line-mask), whose room
15
+ // above and below the line holds the accent face's tallest and deepest glyph. Its size and leading are the line's
16
+ // own; only the face and the slant change. A block that fits its type to a width measures with the mark in place, and
17
+ // waits for the accent face with accentFaceReady before it trusts a measure.
18
+ import { createElement, Fragment, type ReactNode } from "react";
19
+
20
+ /** The accent's classes: the pairing's accent face (blocks.css --font-accent), italic. Nothing else changes. */
21
+ export const ACCENT_CLASS = "font-accent italic";
22
+
23
+ export type AccentParts = { before: string; word: string; after: string };
24
+
25
+ /** The word itself: the accent with any punctuation or space around it taken off. */
26
+ function coreOf(accent: string) {
27
+ return accent.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
28
+ }
29
+
30
+ /** Where the accent stands in the text, split around it; null when the text does not hold it. */
31
+ export function findAccent(text: string, accent?: string): AccentParts | null {
32
+ const word = accent ? coreOf(accent) : "";
33
+ if (!word || !text) return null;
34
+ const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35
+ const match = new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, "iu").exec(text);
36
+ if (!match) return null;
37
+ return {
38
+ before: text.slice(0, match.index),
39
+ word: match[0],
40
+ after: text.slice(match.index + match[0].length),
41
+ };
42
+ }
43
+
44
+ function marked({ before, word, after }: AccentParts): ReactNode {
45
+ return createElement(Fragment, null, before, createElement("em", { className: ACCENT_CLASS, "data-accent": "" }, word), after);
46
+ }
47
+
48
+ /** The text with its accent word (the first time it occurs) in the accent face; the text itself when it is absent. */
49
+ export function withAccent(text: string, accent?: string): ReactNode {
50
+ const parts = findAccent(text, accent);
51
+ return parts ? marked(parts) : text;
52
+ }
53
+
54
+ /**
55
+ * A headline given as lines (or as words, for a block that sets one word per row): the first line that holds the
56
+ * accent gets the mark, every other line comes back as the same string.
57
+ */
58
+ export function accentLines(lines: readonly string[], accent?: string): ReactNode[] {
59
+ let done = false;
60
+ return lines.map((line) => {
61
+ if (done) return line;
62
+ const parts = findAccent(line, accent);
63
+ if (!parts) return line;
64
+ done = true;
65
+ return marked(parts);
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Resolves once the accent face of every mark under `root` is loaded (or failed), so a fit measures the italic as it
71
+ * will stand. Resolves at once when there is no mark, no font loading API, or nothing to wait for.
72
+ */
73
+ export function accentFaceReady(root: ParentNode | null | undefined): Promise<void> {
74
+ if (!root || typeof document === "undefined" || !document.fonts?.load) return Promise.resolve();
75
+ const loads = Array.from(root.querySelectorAll<HTMLElement>("[data-accent]")).map((mark) => {
76
+ const cs = getComputedStyle(mark);
77
+ return document.fonts
78
+ .load(`${cs.fontStyle} ${cs.fontWeight} 16px ${cs.fontFamily}`, mark.textContent || undefined)
79
+ .catch(() => []);
80
+ });
81
+ return Promise.all(loads).then(() => undefined);
82
+ }
@@ -27,7 +27,7 @@
27
27
  }
28
28
 
29
29
  @theme {
30
- /* Type roles: text-display, text-h2, text-h3, text-lead, text-body, text-small. */
30
+ /* Type roles: text-display, text-h2, text-h3, text-lead, text-body, text-small; the sheet's below. */
31
31
  --text-display: clamp(2.875rem, 1.2rem + 6.86vw, 8rem);
32
32
  --text-display--line-height: 0.95;
33
33
  --text-display--letter-spacing: -0.025em;
@@ -42,6 +42,18 @@
42
42
  --text-body--line-height: 1.65;
43
43
  --text-small: 0.875rem;
44
44
  --text-small--line-height: 1.5;
45
+ /* The sheet (the film grammar's type and grid), prefixed so it never meets struq.nl's own --step-*, --space-* or
46
+ --gutter. text-sheet-moment: poster-scale type, two or three per page, about 20vw on a phone easing to 12.5vw
47
+ at 1920 (3.5rem floor, 15rem cap). text-sheet-label: the small label in the margin column or under a photo,
48
+ body face, sentence case. px-sheet-margin (and mx-, pl-, ...): the page margin, the header wordmark's and the
49
+ heroes' left edge. gap-x-sheet-gutter: the gutter of the 12 column grid. */
50
+ --text-sheet-moment: clamp(3.5rem, 1.7rem + 12vw, 15rem);
51
+ --text-sheet-moment--line-height: 0.9;
52
+ --text-sheet-moment--letter-spacing: -0.03em;
53
+ --text-sheet-label: clamp(0.8125rem, 0.78rem + 0.12vw, 0.9375rem);
54
+ --text-sheet-label--line-height: 1.45;
55
+ --spacing-sheet-margin: clamp(1.5rem, 4vw, 5rem);
56
+ --spacing-sheet-gutter: clamp(1rem, 0.6rem + 1.6vw, 2.5rem);
45
57
  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
46
58
  --ease-in-out-quint: cubic-bezier(0.83, 0, 0.17, 1);
47
59
  }
@@ -167,10 +179,48 @@
167
179
  @keyframes struq-fade { to { opacity: 1; } }
168
180
  @keyframes struq-open { to { clip-path: inset(0 0 0 0); } }
169
181
 
170
- /* SplitText masks: room for descenders inside each line's clip. */
171
- [data-blocks] .struq-line-mask {
172
- padding-block: 0.06em 0.16em;
173
- margin-block: -0.06em -0.16em;
182
+ /* The one line mask (SplitText's `mask: "lines"` with linesClass "struq-line", `mask: "chars"` with charsClass
183
+ "struq-char", and every hand-rolled mask: a .struq-line-mask whose direct child is the part that rises).
184
+ Room: the clip reaches 0.3em above the line box and 0.35em below it. Measured on every face Struq ships (Fraunces,
185
+ Newsreader, Bodoni Moda, Sora, Urbanist, Big Shoulders, Unbounded, Syne, Schibsted Grotesk, Instrument Serif, all
186
+ weights and italics, hhea and win metrics) at leading 0.82, the tightest in the canon: the tallest accent stands
187
+ 0.30em above the line (Syne A-ring), the deepest descender 0.34em below it (Unbounded C-cedilla). Leading above
188
+ 0.82 needs less. The negative margin gives the room back, so the layout is the line box alone.
189
+ The rising child carries the same room, so its own box is as tall as the clip: a start of yPercent 100 or more
190
+ puts every ascent and accent below the clip edge, whatever the face and leading.
191
+ The clip is padding, not overflow-clip-margin, which WebKit has not shipped: padding moves the clip edge in every
192
+ engine. Masks and their children paint no background, so the overlap with the next line's room hides nothing; the
193
+ next line's clip only clips the next line. Only the vertical axis clips (overflow: clip lets the other axis stay
194
+ visible, where hidden would not), so an italic j or f overhanging the line's start or end keeps its ink; engines
195
+ without overflow: clip (Safari before 16) clip both axes, as before. !important because SplitText writes
196
+ overflow: clip on every mask inline. The room is registered as a length, so it resolves in the mask's own em and
197
+ the child inherits it in pixels, whatever the child's own size. */
198
+ @property --struq-mask-top {
199
+ syntax: "<length>";
200
+ inherits: true;
201
+ initial-value: 0px;
202
+ }
203
+ @property --struq-mask-bottom {
204
+ syntax: "<length>";
205
+ inherits: true;
206
+ initial-value: 0px;
207
+ }
208
+ [data-blocks] :is(.struq-line-mask, .struq-char-mask) {
209
+ --struq-mask-top: 0.3em;
210
+ --struq-mask-bottom: 0.35em;
211
+ padding-block: var(--struq-mask-top) var(--struq-mask-bottom);
212
+ margin-block: calc(var(--struq-mask-top) * -1) calc(var(--struq-mask-bottom) * -1);
213
+ overflow: hidden !important;
214
+ }
215
+ @supports (overflow: clip) {
216
+ [data-blocks] :is(.struq-line-mask, .struq-char-mask) {
217
+ overflow-x: visible !important;
218
+ overflow-y: clip !important;
219
+ }
220
+ }
221
+ [data-blocks] :is(.struq-line-mask, .struq-char-mask) > * {
222
+ padding-block: var(--struq-mask-top) var(--struq-mask-bottom);
223
+ margin-block: calc(var(--struq-mask-top) * -1) calc(var(--struq-mask-bottom) * -1);
174
224
  }
175
225
 
176
226
  /* --- preloader-gate and page-transition-gate --- */
@@ -466,8 +516,8 @@
466
516
  [data-blocks] [data-hero-still] [data-hs-slot] {
467
517
  position: absolute;
468
518
  top: 4svh;
469
- left: 1.5rem;
470
- right: 1.5rem;
519
+ left: var(--spacing-sheet-margin);
520
+ right: var(--spacing-sheet-margin);
471
521
  bottom: 42%;
472
522
  overflow: hidden;
473
523
  }
@@ -479,7 +529,7 @@
479
529
  [data-blocks] [data-hero-still] [data-hs-slot] {
480
530
  top: 7svh;
481
531
  left: 42vw;
482
- right: 4vw;
532
+ right: var(--spacing-sheet-margin);
483
533
  bottom: 3svh;
484
534
  }
485
535
  [data-blocks] [data-hero-still] [data-hs-photo] {
@@ -667,8 +717,8 @@
667
717
  opacity: 1;
668
718
  }
669
719
  [data-blocks] [data-bg-facets] [data-copy] {
670
- left: 24px;
671
- right: 24px;
720
+ left: var(--spacing-sheet-margin);
721
+ right: var(--spacing-sheet-margin);
672
722
  bottom: 9svh;
673
723
  }
674
724
  [data-blocks] [data-bg-facets] h1 {
@@ -676,7 +726,7 @@
676
726
  }
677
727
  @media (min-width: 768px) {
678
728
  [data-blocks] [data-bg-facets] [data-copy] {
679
- left: 6vw;
729
+ left: var(--spacing-sheet-margin);
680
730
  right: auto;
681
731
  width: min(46vw, 44rem);
682
732
  }
@@ -1151,13 +1201,15 @@
1151
1201
  [data-blocks] [data-footer-contact] [data-fc-part] {
1152
1202
  display: inline-block;
1153
1203
  white-space: nowrap;
1154
- clip-path: inset(0 -0.3em);
1155
1204
  }
1205
+ /* Each character is a shared mask (.struq-char-mask): top-aligned, because a clipping inline-block sits on its bottom
1206
+ edge instead of its text's baseline. The roll carries the mask's room in either nesting, so its copy waits one
1207
+ room lower than its own box and lands exactly where the character stood. */
1156
1208
  [data-blocks] [data-footer-contact] [data-fc-ch] {
1157
1209
  display: inline-block;
1158
1210
  position: relative;
1211
+ vertical-align: top;
1159
1212
  white-space: pre;
1160
- clip-path: inset(0 -0.2em);
1161
1213
  }
1162
1214
  [data-blocks] [data-footer-contact] [data-fc-enter] {
1163
1215
  display: block;
@@ -1165,6 +1217,8 @@
1165
1217
  [data-blocks] [data-footer-contact] [data-fc-roll] {
1166
1218
  display: block;
1167
1219
  position: relative;
1220
+ padding-block: var(--struq-mask-top) var(--struq-mask-bottom);
1221
+ margin-block: calc(var(--struq-mask-top) * -1) calc(var(--struq-mask-bottom) * -1);
1168
1222
  transition: transform 0.75s var(--ease-out-expo) var(--d, 0s);
1169
1223
  }
1170
1224
  [data-blocks] [data-footer-contact] [data-fc-roll] > span {
@@ -1173,7 +1227,7 @@
1173
1227
  [data-blocks] [data-footer-contact] [data-fc-roll] > [data-fc-copy] {
1174
1228
  position: absolute;
1175
1229
  left: 0;
1176
- top: 100%;
1230
+ top: calc(100% + var(--struq-mask-top));
1177
1231
  }
1178
1232
  [data-blocks] [data-footer-contact] [data-fc-roll-host]:is(:hover, :focus-visible) [data-fc-roll] {
1179
1233
  transform: translateY(-100%);
@@ -1394,13 +1448,9 @@ html:not(.sq-js) [data-blocks] [data-header-bar] [data-hb-move] {
1394
1448
  55svh over its line, at the top the script will use;
1395
1449
  the links wait for the landing. The script takes over with the measured face and eases out the difference. If the
1396
1450
  script never runs the plain bar returns after 3s. The wordmark rises out of its mask from 0s, script or not. */
1451
+ /* --hb-gx: the bar's side padding, the sheet's page margin, so the estimate spans what the script measures. */
1397
1452
  [data-blocks] [data-header-bar] {
1398
- --hb-gx: 1.5rem;
1399
- }
1400
- @media (min-width: 768px) {
1401
- [data-blocks] [data-header-bar] {
1402
- --hb-gx: 3vw;
1403
- }
1453
+ --hb-gx: var(--spacing-sheet-margin);
1404
1454
  }
1405
1455
  @media (prefers-reduced-motion: no-preference) {
1406
1456
  .sq-js [data-blocks] [data-header-bar="settle"]:not([data-ready]) [data-hb-word] {
@@ -1424,7 +1474,7 @@ html:not(.sq-js) [data-blocks] [data-header-bar] [data-hb-move] {
1424
1474
  }
1425
1475
  @keyframes hb-rise {
1426
1476
  from {
1427
- translate: 0 calc(100% + 0.22em);
1477
+ translate: 0 100%;
1428
1478
  }
1429
1479
  }
1430
1480
  /* Keyboard focus: drawn on the copies in their own ink (the real links' text is transparent once they show). */
@@ -1452,29 +1502,22 @@ html:not(.sq-js) [data-blocks] [data-header-bar] [data-hb-move] {
1452
1502
  opacity: 0.2;
1453
1503
  }
1454
1504
  /* The wordmark rolls whole (its letters keep their kerning, even set large): the name slides up out of its mask and its
1455
- copy rises in from below. The mask has room for descenders; the copy waits a little lower than the line. */
1456
- [data-blocks] [data-header-bar] [data-hb-wmask] {
1457
- overflow: hidden;
1458
- padding-block: 0.08em 0.14em;
1459
- margin-block: -0.08em -0.14em;
1460
- }
1505
+ copy rises in from below. The mask is the shared line mask (.struq-line-mask): the roll carries its room, so the copy
1506
+ waits one room below the roll's box and a roll of -100% lands it where the name stood. */
1461
1507
  [data-blocks] [data-header-bar] [data-hb-wroll] > span:last-child {
1462
- top: calc(100% + 0.22em);
1508
+ top: calc(100% + var(--struq-mask-top));
1463
1509
  }
1464
1510
  [data-blocks] [data-header-bar] [data-hb-wroll] {
1465
1511
  transition: transform 0.6s var(--ease-out-expo);
1466
1512
  }
1467
1513
  [data-blocks] [data-header-bar] [data-hb-word][data-hot] [data-hb-wroll] {
1468
- transform: translateY(calc(-100% - 0.22em));
1514
+ transform: translateY(-100%);
1469
1515
  }
1470
- /* quiet: each letter in its own mask, rolling after the one before it. */
1516
+ /* quiet: each letter in its own mask (.struq-char-mask), rolling after the one before it, by the same sums. */
1471
1517
  [data-blocks] [data-header-bar] [data-hb-ch] {
1472
1518
  position: relative;
1473
1519
  display: inline-block;
1474
- overflow: hidden;
1475
1520
  white-space: pre;
1476
- padding-block: 0.15em;
1477
- margin-block: -0.15em;
1478
1521
  }
1479
1522
  [data-blocks] [data-header-bar] [data-hb-ch] > span {
1480
1523
  display: block;
@@ -1483,10 +1526,10 @@ html:not(.sq-js) [data-blocks] [data-header-bar] [data-hb-move] {
1483
1526
  [data-blocks] [data-header-bar] [data-hb-ch] > span:last-child {
1484
1527
  position: absolute;
1485
1528
  left: 0;
1486
- top: calc(100% + 0.15em);
1529
+ top: calc(100% + var(--struq-mask-top));
1487
1530
  }
1488
1531
  [data-blocks] [data-header-bar] [data-hb-link][data-hot] [data-hb-ch] > span {
1489
- transform: translateY(calc(-100% - 0.3em));
1532
+ transform: translateY(-100%);
1490
1533
  }
1491
1534
  /* The action: a text link with a faint rule under it at rest; quiet's rule comes up to the full ink on hover. */
1492
1535
  [data-blocks] [data-header-bar] [data-hb-action]::before {