@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.
Files changed (50) hide show
  1. package/README.md +1 -1
  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
@@ -22,6 +22,10 @@
22
22
  //
23
23
  // <HeroIndex items={[{ title: "Olijfhof", place: "Alentejo", year: "2025", image: images.hero[2], href: "/werk/olijfhof" }, ...]} />
24
24
  //
25
+ // `accent` (optional) is the site's one accent word: the names are this hero's headline, and where the word first
26
+ // stands in one of them it is set in the pairing's italic accent face, inside the name's mask, as the Statement and
27
+ // the Close set the same word. Left out, or in no name, the list is exactly as it was.
28
+ //
25
29
  // The photo box carries data-stack-target, so preloader-stack and page-transition-stack land on the first photograph.
26
30
  // Keyboard: focus swaps like hover; one focus ring, on the link. Phones and touch: the list over the first photograph,
27
31
  // a tap follows the link. Reduced motion: swaps are hard cuts, no drift, the names simply stand. Without scripts: the
@@ -37,6 +41,7 @@
37
41
  // Reduced motion: the same stretches, cuts instead of wipes.
38
42
  import Image from "next/image";
39
43
  import { useRef } from "react";
44
+ import { accentLines } from "../kit/accent";
40
45
  import { ScrollTrigger, gsap, useGSAP } from "../kit/gsap";
41
46
  import type { StruqImage } from "../kit/images";
42
47
  import { photoLuminances, type PhotoBox } from "../kit/photo-read";
@@ -62,6 +67,9 @@ type Props = {
62
67
  side?: Side;
63
68
  /** Five or six projects; the first is the photograph at rest. */
64
69
  items: Item[];
70
+ /** The site's one accent word: where it first stands in the list of names it is set in the pairing's italic accent
71
+ * face (--face-accent), as the Statement and the Close set it. A word no name holds changes nothing. */
72
+ accent?: string;
65
73
  /** The composition; the first is the default. */
66
74
  variant?: HeroIndexVariant;
67
75
  className?: string;
@@ -101,6 +109,8 @@ const WIPE = 0.85;
101
109
  const RETURN = 0.45;
102
110
 
103
111
  const FINE = "(hover: hover) and (pointer: fine) and (min-width: 1024px)";
112
+ /** A name's size, set on the name and on its mask (the mask's room for accents and descenders is in its own em). */
113
+ const NAME_SIZE = "text-[min(11.5vw,7.6svh)] md:text-[min(clamp(2.75rem,1rem_+_5.2vw,7.5rem),8.4svh)]";
104
114
 
105
115
  const linear = (c: number) =>
106
116
  c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
@@ -162,8 +172,12 @@ function texture(
162
172
  return Math.sqrt(at(ys, 0.75)) - Math.sqrt(at(ys, 0.25));
163
173
  }
164
174
 
165
- export function HeroIndex({ items, side, variant = "lijst", className = "" }: Props) {
175
+ export function HeroIndex({ items, side, accent, variant = "lijst", className = "" }: Props) {
166
176
  const root = useRef<HTMLElement>(null);
177
+ const names = accentLines(
178
+ items.map((item) => item.title),
179
+ accent,
180
+ );
167
181
  // Over a photograph the names need a measured ink; in the window they stand on the ground.
168
182
  const overPhoto = variant !== "venster";
169
183
  const walks = variant === "doorlopen";
@@ -236,9 +250,12 @@ export function HeroIndex({ items, side, variant = "lijst", className = "" }: Pr
236
250
  return rows.map((row) => {
237
251
  const name = row.querySelector<HTMLElement>("[data-name]")!;
238
252
  const meta = row.querySelector<HTMLElement>("[data-meta]")!;
239
- const mask = row
240
- .querySelector<HTMLElement>(".struq-line-mask")!
241
- .getBoundingClientRect();
253
+ // Where the line stands: the mask's box less its room for accents and descenders (blocks.css), read as
254
+ // laid out, so the rise in flight does not move it.
255
+ const maskEl = row.querySelector<HTMLElement>(".struq-line-mask")!;
256
+ const box = maskEl.getBoundingClientRect();
257
+ const cs = getComputedStyle(maskEl);
258
+ const mask = { top: box.top + parseFloat(cs.paddingTop), bottom: box.bottom - parseFloat(cs.paddingBottom) };
242
259
  const range = document.createRange();
243
260
  range.selectNodeContents(name);
244
261
  const g = range.getBoundingClientRect();
@@ -745,8 +762,10 @@ export function HeroIndex({ items, side, variant = "lijst", className = "" }: Pr
745
762
  // The window stands on the side away from the list: wide screens beside it, phones above it.
746
763
  const windowBox =
747
764
  listSide === "left"
748
- ? "absolute inset-x-6 top-6 h-[38svh] overflow-hidden md:inset-x-auto md:top-[7svh] md:bottom-[7svh] md:left-[54vw] md:right-[4vw] md:h-auto"
749
- : "absolute inset-x-6 top-6 h-[38svh] overflow-hidden md:inset-x-auto md:top-[7svh] md:bottom-[7svh] md:left-[4vw] md:right-[54vw] md:h-auto";
765
+ ? // sheet-allow: the window's inner edge at 54vw leaves the list its half
766
+ "absolute inset-x-sheet-margin top-6 h-[38svh] overflow-hidden md:inset-x-auto md:top-[7svh] md:bottom-[7svh] md:left-[54vw] md:right-sheet-margin md:h-auto"
767
+ : // sheet-allow: the window's inner edge at 54vw leaves the list its half
768
+ "absolute inset-x-sheet-margin top-6 h-[38svh] overflow-hidden md:inset-x-auto md:top-[7svh] md:bottom-[7svh] md:left-sheet-margin md:right-[54vw] md:h-auto";
750
769
 
751
770
  return (
752
771
  <section
@@ -797,8 +816,8 @@ export function HeroIndex({ items, side, variant = "lijst", className = "" }: Pr
797
816
  aria-label="Werk"
798
817
  className={
799
818
  walks
800
- ? "absolute inset-0 z-10 flex flex-col items-center justify-center px-6 md:px-[4vw]"
801
- : "absolute inset-x-0 bottom-0 z-10 px-6 pb-[7svh] md:px-[4vw] md:pb-[6svh]"
819
+ ? "absolute inset-0 z-10 flex flex-col items-center justify-center px-sheet-margin"
820
+ : "absolute inset-x-0 bottom-0 z-10 px-sheet-margin pb-[7svh] md:pb-[6svh]"
802
821
  }
803
822
  >
804
823
  <ul
@@ -817,7 +836,9 @@ export function HeroIndex({ items, side, variant = "lijst", className = "" }: Pr
817
836
  href={item.href}
818
837
  className="group/row block w-fit rounded-[0.15rem] py-[0.2svh] outline-offset-[0.35rem] transition-opacity duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-current group-data-[active]/list:not-data-[current]:opacity-80"
819
838
  >
820
- <span className="struq-line-mask block overflow-x-visible overflow-y-clip">
839
+ {/* The shared line mask carries the name's size, so its room is the name's; leading 0 keeps the
840
+ line box to the name and its place line. */}
841
+ <span className={`struq-line-mask block leading-[0] ${NAME_SIZE}`}>
821
842
  <span data-rise="" className="block">
822
843
  {/* The place line stands right-aligned under its name. */}
823
844
  <span
@@ -826,9 +847,9 @@ export function HeroIndex({ items, side, variant = "lijst", className = "" }: Pr
826
847
  >
827
848
  <span
828
849
  data-name=""
829
- className="font-display whitespace-nowrap text-[min(11.5vw,7.6svh)] leading-[0.98] tracking-[-0.035em] md:text-[min(clamp(2.75rem,1rem_+_5.2vw,7.5rem),8.4svh)]"
850
+ className={`font-display whitespace-nowrap leading-[0.98] tracking-[-0.035em] ${NAME_SIZE}`}
830
851
  >
831
- {item.title}
852
+ {names[i]}
832
853
  </span>
833
854
  <span
834
855
  data-meta=""
@@ -22,6 +22,11 @@
22
22
  // <HeroPoster image={images.hero[1]} lines={["Noord bouwt", "met daglicht"]} place="Architectuur en bouw, Rotterdam"
23
23
  // link={{ label: "Bekijk het werk", href: "#werk" }} />
24
24
  //
25
+ // `accent` (optional) is the site's one accent word: where it first stands in the two lines it is set in the pairing's
26
+ // italic accent face, as the Statement and the Close set the same word. The fit measures each line with the mark in
27
+ // place and waits for the accent face as it waits for the display face, so a wider italic is fitted, never spilled.
28
+ // Left out, or not in the lines, the poster is exactly as it was.
29
+ //
25
30
  // Phones: one word per line, the window between at the height that is left, showing the photo's own crop; the
26
31
  // entrance only (no pin, no open).
27
32
  //
@@ -36,6 +41,7 @@
36
41
  // slides to registration and opens to the full screen, so the crops merge into one photograph.
37
42
  import Image from "next/image";
38
43
  import { useRef } from "react";
44
+ import { accentFaceReady, accentLines } from "../kit/accent";
39
45
  import { gsap, useGSAP } from "../kit/gsap";
40
46
  import type { StruqImage } from "../kit/images";
41
47
  import { covered, whenUncovered } from "../preloaders/preloader-slices-uncovered";
@@ -58,6 +64,9 @@ type Inner = {
58
64
  image: StruqImage;
59
65
  /** The two lines of the poster: the studio's name, or its line split in two. */
60
66
  lines: [string, string];
67
+ /** The site's one accent word: where it first stands in the lines it is set in the pairing's italic accent face
68
+ * (--face-accent), as the Statement and the Close set it. A word the lines do not hold changes nothing. */
69
+ accent?: string;
61
70
  /** The foot line: place, discipline. */
62
71
  place?: string;
63
72
  /** One link in the foot line. */
@@ -73,6 +82,13 @@ const EM_PER_CHAR = 0.66;
73
82
  const LEADING = 0.84;
74
83
 
75
84
  const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
85
+ /** The poster's side margin as laid out: the sheet margin on its padded box ([data-hp-sheet]), read in px. */
86
+ const sideOf = (el: HTMLElement) => {
87
+ const pad = el.querySelector<HTMLElement>("[data-hp-sheet]");
88
+ return pad ? parseFloat(getComputedStyle(pad).paddingLeft) || 0 : 0;
89
+ };
90
+ /** The width the script-free estimates fit into: the screen inside the sheet margin on both sides. */
91
+ const INSIDE = "(100vw - 2 * var(--spacing-sheet-margin))";
76
92
 
77
93
  export function HeroPoster({ variant = "window", ...rest }: Props) {
78
94
  if (variant === "column") return <PosterColumn {...rest} />;
@@ -80,9 +96,10 @@ export function HeroPoster({ variant = "window", ...rest }: Props) {
80
96
  return <PosterWindow {...rest} />;
81
97
  }
82
98
 
83
- function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
99
+ function PosterWindow({ image, lines, accent, place, link, className = "" }: Inner) {
84
100
  const stage = useRef<HTMLElement>(null);
85
101
  const words = lines.map((l) => l.split(" "));
102
+ const marked = markWords(words, accent);
86
103
  const longest = (ws: string[]) => Math.max(...ws.map((w) => w.length));
87
104
 
88
105
  useGSAP(
@@ -113,7 +130,7 @@ function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
113
130
  const W = el.clientWidth;
114
131
  const H = el.clientHeight;
115
132
  const wide = W >= 768;
116
- const room = W - (wide ? W * 0.08 : 48);
133
+ const room = W - 2 * sideOf(el);
117
134
  const widthOf = (w: HTMLElement) => {
118
135
  const c = w.cloneNode(true) as HTMLElement;
119
136
  Object.assign(c.style, { position: "absolute", left: "0", top: "0", visibility: "hidden", fontSize: "100px" });
@@ -198,7 +215,7 @@ function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
198
215
  };
199
216
  // With the face in, or on what there is after FONT_WAIT; and again whenever a face arrives later than that. The
200
217
  // poster counts as shown once the entrance has started (or the fallback did it), so a late face eases in.
201
- const faceIn = Promise.race([document.fonts?.ready ?? Promise.resolve(), new Promise((r) => setTimeout(r, FONT_WAIT))]);
218
+ const faceIn = faceReady(el);
202
219
  settle(frame);
203
220
  faceIn.then(() => {
204
221
  refit();
@@ -318,23 +335,23 @@ function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
318
335
  mm.revert();
319
336
  };
320
337
  },
321
- { scope: stage, dependencies: [lines[0], lines[1]] },
338
+ { scope: stage, dependencies: [lines[0], lines[1], accent] },
322
339
  );
323
340
 
324
341
  // The script-free estimate per line: the width over its characters, and on wide screens a height cap so two lines
325
342
  // and a window always fit. Phones stack one word per line, so the longest word sets it.
326
343
  const est = (i: 0 | 1) =>
327
- `min(18vw, calc(92vw / ${(lines[i].length * EM_PER_CHAR).toFixed(2)}), 30svh)`;
328
- const estPhone = (i: 0 | 1) => `calc((100vw - 48px) / ${(longest(words[i]) * EM_PER_CHAR).toFixed(2)})`;
344
+ `min(18vw, calc(${INSIDE} / ${(lines[i].length * EM_PER_CHAR).toFixed(2)}), 30svh)`;
345
+ const estPhone = (i: 0 | 1) => `calc(${INSIDE} / ${(longest(words[i]) * EM_PER_CHAR).toFixed(2)})`;
329
346
 
330
347
  // The line's box is LEADING of its size per row; the mask carries the descender room in the display em.
331
348
  const word = "font-display block w-fit whitespace-nowrap leading-[0.84] tracking-[-0.045em] text-ink";
332
- const mask = "block overflow-hidden pb-[0.24em] -mb-[0.24em]";
333
- const small = "block overflow-hidden pb-[0.15em] -mb-[0.15em]";
349
+ const mask = "struq-line-mask block";
350
+ const small = "struq-line-mask block";
334
351
  // Inline, because the kit balances display type (text-wrap), which would let a poster line wrap; and no transition,
335
352
  // or a page that transitions every property hands the fit a size from mid-change.
336
353
  const nowrap = { whiteSpace: "nowrap", textWrap: "nowrap", transition: "none" } as React.CSSProperties;
337
- const stack = (parts: string[]) =>
354
+ const stack = (parts: React.ReactNode[]) =>
338
355
  parts.map((part, i) => (
339
356
  <span key={i} data-hp-part="" className="inline-block max-md:block">
340
357
  {part}
@@ -360,7 +377,7 @@ function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
360
377
  <h1 className="sr-only">{`${lines[0]} ${lines[1]}`}</h1>
361
378
 
362
379
  {/* The poster: line, window, line, foot. The lines stand above the photo as it opens past them. */}
363
- <div className="relative flex h-full flex-col px-6 pb-6 pt-8 md:px-[4vw] md:pb-[3svh] md:pt-[4svh]">
380
+ <div data-hp-sheet="" className="relative flex h-full flex-col px-sheet-margin pb-6 pt-8 md:pb-[3svh] md:pt-[4svh]">
364
381
  <div data-hp-top="" className="relative z-10 will-change-transform">
365
382
  <p aria-hidden className="m-0">
366
383
  <span
@@ -368,7 +385,7 @@ function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
368
385
  >
369
386
  <span data-hp-rise="" data-hp-line="" className="block">
370
387
  <span data-hp-word="" className={word} style={nowrap}>
371
- {stack(words[0])}
388
+ {stack(marked[0])}
372
389
  </span>
373
390
  </span>
374
391
  </span>
@@ -384,7 +401,7 @@ function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
384
401
  >
385
402
  <div
386
403
  data-stack-photo=""
387
- className="absolute inset-0 md:inset-auto md:left-[-4vw] md:top-[calc(-4svh_-_0.84_*_var(--hp-s1,var(--hp-e1))_-_1.2svh)] md:h-svh md:w-screen"
404
+ className="absolute inset-0 md:inset-auto md:-left-sheet-margin md:top-[calc(-4svh_-_0.84_*_var(--hp-s1,var(--hp-e1))_-_1.2svh)] md:h-svh md:w-screen"
388
405
  >
389
406
  <Image
390
407
  src={image.src}
@@ -406,7 +423,7 @@ function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
406
423
  >
407
424
  <span data-hp-rise="" data-hp-line="" className="block">
408
425
  <span data-hp-word="" className={`${word} ml-auto text-right`} style={nowrap}>
409
- {stack(words[1])}
426
+ {stack(marked[1])}
410
427
  </span>
411
428
  </span>
412
429
  </span>
@@ -504,13 +521,26 @@ function sizer(el: HTMLElement, names: string[], onScreen: () => number[], shown
504
521
  };
505
522
  }
506
523
 
507
- /** The display face in, or what there is after FONT_WAIT. */
508
- const faceReady = () =>
509
- Promise.race([document.fonts?.ready ?? Promise.resolve(), new Promise((r) => setTimeout(r, FONT_WAIT))]);
524
+ /** The display face in (and the accent face, when a word is marked), or what there is after FONT_WAIT. */
525
+ const faceReady = (el: HTMLElement) =>
526
+ Promise.race([
527
+ Promise.all([document.fonts?.ready ?? Promise.resolve(), accentFaceReady(el)]),
528
+ new Promise((r) => setTimeout(r, FONT_WAIT)),
529
+ ]);
530
+
531
+ /**
532
+ * The poster's words (one list per line) with the accent marked on the first word that holds it, so a block that
533
+ * sets one word per part or per row marks the same word the line would.
534
+ */
535
+ function markWords(words: string[][], accent?: string): React.ReactNode[][] {
536
+ const flat = accentLines(words.flat(), accent);
537
+ let at = 0;
538
+ return words.map((ws) => flat.slice(at, (at += ws.length)));
539
+ }
510
540
 
511
541
  const WORD = "font-display block w-fit whitespace-nowrap leading-[0.84] tracking-[-0.045em]";
512
- const MASK = "block overflow-hidden pb-[0.24em] -mb-[0.24em]";
513
- const SMALL = "block overflow-hidden pb-[0.15em] -mb-[0.15em]";
542
+ const MASK = "struq-line-mask block";
543
+ const SMALL = "struq-line-mask block";
514
544
  // Inline, because the kit balances display type (text-wrap), which would let a poster line wrap; and no transition,
515
545
  // or a page that transitions every property hands the fit a size from mid-change.
516
546
  const NOWRAP = { whiteSpace: "nowrap", textWrap: "nowrap", transition: "none" } as React.CSSProperties;
@@ -560,9 +590,10 @@ function Foot({ place, link, copy = false }: { place?: string; link?: Inner["lin
560
590
  * first scroll (pin 150%) it opens sideways by registration while the upper line leaves left, the lower line right
561
591
  * and the foot down. Phones: one word per row, the same slit, the entrance only.
562
592
  */
563
- function PosterColumn({ image, lines, place, link, className = "" }: Inner) {
593
+ function PosterColumn({ image, lines, accent, place, link, className = "" }: Inner) {
564
594
  const stage = useRef<HTMLElement>(null);
565
595
  const words = lines.map((l) => l.split(" "));
596
+ const marked = markWords(words, accent);
566
597
  const longest = (ws: string[]) => Math.max(...ws.map((w) => w.length));
567
598
 
568
599
  useGSAP(
@@ -586,7 +617,7 @@ function PosterColumn({ image, lines, place, link, className = "" }: Inner) {
586
617
  const W = el.clientWidth;
587
618
  const H = el.clientHeight;
588
619
  const wide = W >= 768;
589
- const room = W - (wide ? W * 0.08 : 48);
620
+ const room = W - 2 * sideOf(el);
590
621
  const sizes = lineWords.map((w) => {
591
622
  const width = widthAt100(w, !wide);
592
623
  return width > 0 ? (100 * room) / width : 100;
@@ -650,7 +681,7 @@ function PosterColumn({ image, lines, place, link, className = "" }: Inner) {
650
681
  sizes.set(fit(), frame);
651
682
  decoded.then(() => !dead && grade());
652
683
  };
653
- const faceIn = faceReady();
684
+ const faceIn = faceReady(el);
654
685
  sizes.set(fit(), frame);
655
686
  faceIn.then(() => {
656
687
  refit();
@@ -747,13 +778,13 @@ function PosterColumn({ image, lines, place, link, className = "" }: Inner) {
747
778
  mm.revert();
748
779
  };
749
780
  },
750
- { scope: stage, dependencies: [lines[0], lines[1]] },
781
+ { scope: stage, dependencies: [lines[0], lines[1], accent] },
751
782
  );
752
783
 
753
- const est = (i: 0 | 1) => `min(18vw, calc(92vw / ${(lines[i].length * EM_PER_CHAR).toFixed(2)}), 26svh)`;
754
- const estPhone = (i: 0 | 1) => `calc((100vw - 48px) / ${(longest(words[i]) * EM_PER_CHAR).toFixed(2)})`;
784
+ const est = (i: 0 | 1) => `min(18vw, calc(${INSIDE} / ${(lines[i].length * EM_PER_CHAR).toFixed(2)}), 26svh)`;
785
+ const estPhone = (i: 0 | 1) => `calc(${INSIDE} / ${(longest(words[i]) * EM_PER_CHAR).toFixed(2)})`;
755
786
  // The space stands between the words, outside them: a space at the end of an inline block is dropped.
756
- const parts = (ps: string[]) =>
787
+ const parts = (ps: React.ReactNode[]) =>
757
788
  ps.map((part, i) => (
758
789
  <span key={i}>
759
790
  <span data-hp-part="" className="inline-block max-md:block">
@@ -770,7 +801,7 @@ function PosterColumn({ image, lines, place, link, className = "" }: Inner) {
770
801
  >
771
802
  <span data-hp-rise="" data-hp-line="" className="block">
772
803
  <span data-hp-word="" className={`${WORD} ${i === 1 ? "ml-auto text-right" : ""}`} style={NOWRAP}>
773
- {parts(words[i])}
804
+ {parts(marked[i])}
774
805
  </span>
775
806
  </span>
776
807
  </span>
@@ -781,7 +812,8 @@ function PosterColumn({ image, lines, place, link, className = "" }: Inner) {
781
812
  <div
782
813
  data-hp-layer={copy ? "copy" : "ground"}
783
814
  aria-hidden={copy || undefined}
784
- className={`absolute inset-0 flex flex-col px-6 pb-6 pt-8 md:px-[4vw] md:pb-[3svh] md:pt-[4svh] ${copy ? "text-on-photo" : "text-ink"}`}
815
+ data-hp-sheet=""
816
+ className={`absolute inset-0 flex flex-col px-sheet-margin pb-6 pt-8 md:pb-[3svh] md:pt-[4svh] ${copy ? "text-on-photo" : "text-ink"}`}
785
817
  >
786
818
  <div className="flex-1" />
787
819
  <div data-hp-l1="" className="will-change-transform">
@@ -845,9 +877,10 @@ function PosterColumn({ image, lines, place, link, className = "" }: Inner) {
845
877
  * Phones: the same stack, each window its own crop, the entrance only. Without scripts or under reduced motion the
846
878
  * composed poster shows with every crop in place.
847
879
  */
848
- function PosterStack({ image, lines, place, link, className = "" }: Inner) {
880
+ function PosterStack({ image, lines, accent, place, link, className = "" }: Inner) {
849
881
  const stage = useRef<HTMLElement>(null);
850
882
  const rows = lines.flatMap((l) => l.split(" ")).filter(Boolean);
883
+ const marked = accentLines(rows, accent);
851
884
  const n = rows.length;
852
885
  const m = Math.max(0, n - 1);
853
886
  const middle = Math.floor((m - 1) / 2);
@@ -873,7 +906,7 @@ function PosterStack({ image, lines, place, link, className = "" }: Inner) {
873
906
  const W = el.clientWidth;
874
907
  const H = el.clientHeight;
875
908
  const wide = W >= 768;
876
- const room = W - (wide ? W * 0.08 : 48);
909
+ const room = W - 2 * sideOf(el);
877
910
  const widest = Math.max(1, ...wordEls.map((w) => widthAt100(w, false)));
878
911
  const free = H - (foot?.offsetHeight ?? 0) - (wide ? H * 0.1 : 72) - m * H * (wide ? 0.075 : 0.08);
879
912
  return [Math.floor(Math.min((100 * room) / widest, free / (LEADING * n)))];
@@ -888,7 +921,7 @@ function PosterStack({ image, lines, place, link, className = "" }: Inner) {
888
921
  if (dead) return;
889
922
  sizes.set(fit(), frame);
890
923
  };
891
- const faceIn = faceReady();
924
+ const faceIn = faceReady(el);
892
925
  sizes.set(fit(), frame);
893
926
  faceIn.then(() => {
894
927
  refit();
@@ -1017,13 +1050,13 @@ function PosterStack({ image, lines, place, link, className = "" }: Inner) {
1017
1050
  mm.revert();
1018
1051
  };
1019
1052
  },
1020
- { scope: stage, dependencies: [lines[0], lines[1]] },
1053
+ { scope: stage, dependencies: [lines[0], lines[1], accent] },
1021
1054
  );
1022
1055
 
1023
1056
  // The script-free estimate: the longest word over the width, capped so the rows and their windows fit the height.
1024
1057
  const per = (longest * EM_PER_CHAR).toFixed(2);
1025
- const est = `min(calc(92vw / ${per}), calc((90svh - 2rem - ${m} * 7.5svh) / ${(n * LEADING).toFixed(2)}))`;
1026
- const estPhone = `min(calc((100vw - 48px) / ${per}), calc((100svh - 7rem - ${m} * 8svh) / ${(n * LEADING).toFixed(2)}))`;
1058
+ const est = `min(calc(${INSIDE} / ${per}), calc((90svh - 2rem - ${m} * 7.5svh) / ${(n * LEADING).toFixed(2)}))`;
1059
+ const estPhone = `min(calc(${INSIDE} / ${per}), calc((100svh - 7rem - ${m} * 8svh) / ${(n * LEADING).toFixed(2)}))`;
1027
1060
 
1028
1061
  return (
1029
1062
  <section
@@ -1035,8 +1068,8 @@ function PosterStack({ image, lines, place, link, className = "" }: Inner) {
1035
1068
  style={{ "--hp-e": est, "--hp-p": estPhone } as React.CSSProperties}
1036
1069
  >
1037
1070
  <h1 className="sr-only">{`${lines[0]} ${lines[1]}`}</h1>
1038
- <div className="relative flex h-full flex-col px-6 pb-6 pt-8 md:px-[4vw] md:pb-[3svh] md:pt-[4svh]">
1039
- {rows.map((w, i) => (
1071
+ <div data-hp-sheet="" className="relative flex h-full flex-col px-sheet-margin pb-6 pt-8 md:pb-[3svh] md:pt-[4svh]">
1072
+ {marked.map((w, i) => (
1040
1073
  <div key={i} className="contents">
1041
1074
  <div data-hp-row="" className="relative z-10 will-change-transform">
1042
1075
  <p aria-hidden className="m-0">
@@ -14,6 +14,11 @@
14
14
  // <HeroSplit name="Noord" lead="Wij bouwen" items={[{ word: "huizen.", image }, { word: "interieurs.", image }, ...]}
15
15
  // line="Aan de Maashaven in Rotterdam." link={{ href: "/werk", label: "Bekijk het werk" }} />
16
16
  //
17
+ // `accent` (optional) is the site's one accent word: where it first stands in the headline (the lead, then the
18
+ // cycling words) it is set in the pairing's italic accent face in every copy of the line, as the Statement and the
19
+ // Close set the same word. The fit measures with the mark in place and refits once the accent face is in, so the
20
+ // wider italic never pushes the line past its span. Left out, or not in the headline, the hero is exactly as it was.
21
+ //
17
22
  // One clock: the words (in every copy) and the photos are tweens in one looping timeline, so they can never drift; it
18
23
  // starts once every photograph is decoded and pauses off screen or in a hidden tab. The cycling words share one grid
19
24
  // cell, so a change of word never moves anything; the headline is fitted with the loaded face and refit when it lands
@@ -35,6 +40,7 @@
35
40
  // the edge passes under it; scrolling back closes it. Phones, reduced motion and no scripts: the split.
36
41
  import Image from "next/image";
37
42
  import { useRef } from "react";
43
+ import { accentFaceReady, accentLines } from "../kit/accent";
38
44
  import { ScrollTrigger, gsap, useGSAP } from "../kit/gsap";
39
45
  import type { StruqImage } from "../kit/images";
40
46
  import { photoLuminances } from "../kit/photo-read";
@@ -54,6 +60,10 @@ type Props = {
54
60
  lead: string;
55
61
  /** Two to five disciplines, each with its photograph. */
56
62
  items: Item[];
63
+ /** The site's one accent word: where it first stands in the headline (the lead, then the cycling words) it is set in
64
+ * the pairing's italic accent face (--face-accent), as the Statement and the Close set it. A word the headline does
65
+ * not hold changes nothing. */
66
+ accent?: string;
57
67
  /** One short line at the foot of the type side. */
58
68
  line?: string;
59
69
  link: { href: string; label: string };
@@ -121,8 +131,10 @@ function veilFor(under: number, ink: number, veilS: number): number {
121
131
  return hi;
122
132
  }
123
133
 
124
- export function HeroSplit({ name, lead, items, line, link, photo = "right", variant = "naad", className = "" }: Props) {
134
+ export function HeroSplit({ name, lead, items, accent, line, link, photo = "right", variant = "naad", className = "" }: Props) {
125
135
  const root = useRef<HTMLElement>(null);
136
+ // The headline is the lead and the cycling words: the accent is marked where it first stands in that order.
137
+ const [leadShown, ...wordsShown] = accentLines([lead, ...items.map((item) => item.word)], accent);
126
138
  const left = photo === "left";
127
139
  const flat = variant === "liggend";
128
140
  const opening = variant === "opening";
@@ -242,7 +254,8 @@ export function HeroSplit({ name, lead, items, line, link, photo = "right", vari
242
254
  gsap.set(rise, { y: 0, yPercent: shown ? 0 : 115 });
243
255
  el.dataset.ready = "";
244
256
  const refit = () => fit(true);
245
- document.fonts?.ready.then(() => {
257
+ // The accent face too, when a word is marked: the fit measures the italic as it will stand.
258
+ document.fonts?.ready.then(() => accentFaceReady(el)).then(() => {
246
259
  refit();
247
260
  ScrollTrigger.refresh();
248
261
  });
@@ -514,11 +527,11 @@ export function HeroSplit({ name, lead, items, line, link, photo = "right", vari
514
527
  wide.removeEventListener("change", onWide);
515
528
  };
516
529
  },
517
- { scope: root, dependencies: [variant] },
530
+ { scope: root, dependencies: [variant, accent] },
518
531
  );
519
532
 
520
533
  const mask = (child: React.ReactNode, key?: number | string) => (
521
- <span key={key} className="struq-line-mask block overflow-hidden">
534
+ <span key={key} className="struq-line-mask block">
522
535
  <span data-rise="" className="block">
523
536
  {child}
524
537
  </span>
@@ -530,12 +543,12 @@ export function HeroSplit({ name, lead, items, line, link, photo = "right", vari
530
543
  <>
531
544
  {mask(
532
545
  <span data-fit="" className="inline-block whitespace-nowrap">
533
- {lead}
546
+ {leadShown}
534
547
  </span>,
535
548
  `${copy}-lead`,
536
549
  )}
537
550
  {mask(
538
- <span data-fit="" className="struq-line-mask inline-grid overflow-hidden whitespace-nowrap align-top">
551
+ <span data-fit="" className="struq-line-mask inline-grid whitespace-nowrap align-top">
539
552
  {items.map((item, i) => (
540
553
  <span
541
554
  key={item.word}
@@ -544,7 +557,7 @@ export function HeroSplit({ name, lead, items, line, link, photo = "right", vari
544
557
  className="[grid-area:1/1]"
545
558
  style={i === 0 ? undefined : { transform: `translateY(${AWAY}%)` }}
546
559
  >
547
- {item.word}
560
+ {wordsShown[i]}
548
561
  </span>
549
562
  ))}
550
563
  </span>,
@@ -556,7 +569,7 @@ export function HeroSplit({ name, lead, items, line, link, photo = "right", vari
556
569
  // Lying down the headline stands on the seam (58% down) and the seam cuts the cycling word through its middle.
557
570
  const headClass = `font-display absolute ${
558
571
  flat ? "top-[calc(58%-1.45em)]" : "bottom-[calc(5vh+5.5rem)]"
559
- } leading-[0.92] tracking-[-0.035em] text-[length:var(--hsp-size,9vw)] px-[4vw] left-0`;
572
+ } leading-[0.92] tracking-[-0.035em] text-[length:var(--hsp-size,9vw)] px-sheet-margin left-0`;
560
573
 
561
574
  const grid = flat
562
575
  ? "md:grid-cols-1 md:grid-rows-[58fr_42fr]"
@@ -612,7 +625,7 @@ export function HeroSplit({ name, lead, items, line, link, photo = "right", vari
612
625
  Lying down, the name, the line and the link share one row at the foot. */}
613
626
  <div
614
627
  data-type=""
615
- className={`row-start-2 flex flex-col justify-between gap-8 px-6 pb-8 pt-7 md:px-[4vw] md:pb-[5vh] md:pt-[4vh] ${
628
+ className={`row-start-2 flex flex-col justify-between gap-8 px-sheet-margin pb-8 pt-7 md:pb-[5vh] md:pt-[4vh] ${
616
629
  flat ? "md:col-start-1 md:row-start-2 md:flex-row md:items-end" : `md:row-start-1 ${left ? "md:col-start-2" : "md:col-start-1"}`
617
630
  }`}
618
631
  >