@struq/skill 0.5.1 → 0.5.3

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 (65) hide show
  1. package/README.md +137 -100
  2. package/package.json +5 -3
  3. package/skills/struq/SKILL.md +168 -165
  4. package/skills/struq/blocks/CATALOG.md +49 -46
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +2 -1
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +5 -3
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +6 -2
  8. package/skills/struq/blocks/catalog.json +50 -5
  9. package/skills/struq/blocks/film.json +11 -7
  10. package/skills/struq/blocks/footers/footer-contact.tsx +8 -78
  11. package/skills/struq/blocks/headers/header-bar.tsx +144 -29
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +36 -19
  13. package/skills/struq/blocks/heroes/hero-poster.tsx +36 -10
  14. package/skills/struq/blocks/kit/HeroCanvas.tsx +6 -2
  15. package/skills/struq/blocks/kit/SplitHeadline.tsx +2 -1
  16. package/skills/struq/blocks/kit/accent.ts +29 -3
  17. package/skills/struq/blocks/kit/blocks.css +530 -6
  18. package/skills/struq/blocks/kit/feel.ts +183 -0
  19. package/skills/struq/blocks/kit/gsap.ts +9 -6
  20. package/skills/struq/blocks/kit/middle/PlaceHours.tsx +254 -0
  21. package/skills/struq/blocks/kit/middle/ProcessStrip.tsx +428 -0
  22. package/skills/struq/blocks/kit/middle/ServicesList.tsx +496 -0
  23. package/skills/struq/blocks/kit/middle/entrance.ts +98 -0
  24. package/skills/struq/blocks/kit/middle/hours.ts +154 -0
  25. package/skills/struq/blocks/kit/middle/index.ts +14 -0
  26. package/skills/struq/blocks/kit/motion/HoverLink.tsx +33 -0
  27. package/skills/struq/blocks/kit/motion/ImageCurtain.tsx +79 -0
  28. package/skills/struq/blocks/kit/motion/MaskWipe.tsx +91 -0
  29. package/skills/struq/blocks/kit/motion/Pressable.tsx +58 -0
  30. package/skills/struq/blocks/kit/motion/RevealText.tsx +103 -0
  31. package/skills/struq/blocks/kit/motion/Rise.tsx +77 -0
  32. package/skills/struq/blocks/kit/motion/ScrubLine.tsx +108 -0
  33. package/skills/struq/blocks/kit/motion/index.ts +11 -0
  34. package/skills/struq/blocks/kit/quiet/Close.tsx +178 -52
  35. package/skills/struq/blocks/kit/quiet/Statement.tsx +3 -2
  36. package/skills/struq/blocks/kit/subject/AppScreen.tsx +388 -0
  37. package/skills/struq/blocks/kit/subject/Document.tsx +742 -0
  38. package/skills/struq/blocks/kit/subject/Mark.tsx +44 -0
  39. package/skills/struq/blocks/kit/subject/PatternPaper.tsx +410 -0
  40. package/skills/struq/blocks/kit/subject/SectionDrawing.tsx +349 -0
  41. package/skills/struq/blocks/kit/subject/Ticket.tsx +354 -0
  42. package/skills/struq/blocks/kit/subject/build.ts +66 -0
  43. package/skills/struq/blocks/kit/subject/draw.ts +196 -0
  44. package/skills/struq/blocks/kit/subject/drawings.ts +373 -0
  45. package/skills/struq/blocks/kit/subject/format.ts +122 -0
  46. package/skills/struq/blocks/kit/subject/index.ts +11 -0
  47. package/skills/struq/blocks/kit/subject/patterns.ts +204 -0
  48. package/skills/struq/blocks/preloaders/preloader-stack.tsx +75 -22
  49. package/skills/struq/blocks/showpieces/hero-opener.tsx +55 -7
  50. package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +64 -6
  51. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +56 -3
  52. package/skills/struq/blocks/showpieces/panel-wipe.tsx +93 -11
  53. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +38 -5
  54. package/skills/struq/canon/canon.json +1 -1
  55. package/skills/struq/references/art-direction.md +51 -0
  56. package/skills/struq/scripts/blocks.mjs +56 -20
  57. package/skills/struq/scripts/canon.mjs +36 -7
  58. package/skills/struq/scripts/check.mjs +417 -11
  59. package/skills/struq/scripts/draw.mjs +49 -0
  60. package/skills/struq/scripts/film-check.mjs +28 -4
  61. package/skills/struq/scripts/gate.mjs +13 -3
  62. package/skills/struq/scripts/hand.mjs +222 -0
  63. package/skills/struq/scripts/images.mjs +12 -3
  64. package/skills/struq/scripts/look.mjs +1184 -0
  65. package/skills/struq/scripts/stamp.mjs +47 -0
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
  // header-bar (Struq header recipe): the bar a site wears on every screen when it shows its links
3
3
  // The wordmark, three or four links and one quiet action as a text link. The bar has no ground on the first screen; once
4
- // the page has scrolled it takes the ground of whatever is under it (the page's own surface, the inverse, or none over a
5
- // photo), so it reads as a window onto the page, never as a coloured strip.
4
+ // the page has scrolled it takes a solid ground in the tone of whatever is under it (the page's own surface, the
5
+ // inverse, or the photo band's dark), so body text never runs through its ink and it still reads as part of the page.
6
6
  //
7
7
  // quiet wordmark left, links right; each label rolls through its mask on hover, letter by letter. The bar hides
8
8
  // as the page scrolls down and returns as it scrolls up, scrubbed to the scroll with a short ease and
@@ -19,11 +19,19 @@
19
19
  // The bar turns its ink with what is under it, exactly at the edge, per pixel: it is drawn three times (ink for the
20
20
  // page ground, on-photo, on-inverse), each copy clipped to the stretches of the bar its own sections cover. Sections
21
21
  // opt in with data-bar-tone="photo" or data-bar-tone="inverse" (the menu curtain's contract); anything else is ground.
22
+ // Mark a section "photo" only where a photo really runs under the bar's band (a full-bleed hero); a poster or a framed
23
+ // photo with ground above it is ground. The start tone is read from the page too, never passed: before the script,
24
+ // the first section's data-bar-tone (the element right after the bar, or that element's first child) sets the ink.
25
+ // Over a "photo" section the bar reads the photo itself (kit/photo-read): the band of the photo under the bar, with
26
+ // any data-veil over it and the bar's own ground at the strength it has come in, is held against each ink, and the
27
+ // on-photo ink stays while it keeps 3:1; over a bright window the bar takes the ink (and the scrolled ground) that
28
+ // reads instead, and keeps it while that still holds 3:1, so it never flickers. A photo that cannot be read (not
29
+ // loaded yet, another origin) keeps the declared tone. No scrim is ever added for it.
22
30
  // The real bar (the links) sits on top with transparent text: one focusable bar, never three. Hover and keyboard focus
23
31
  // on a real link are mirrored onto the copies, so the focus ring is drawn in the right ink too.
24
32
  //
25
- // Mount it once at the top of the page; it is fixed and needs no wrapper:
26
- // <HeaderBar variant="quiet" name="Noord" links={[{ label: "Werk", href: "/werk" }]} startTone="photo"
33
+ // Mount it once at the top of the page, right before the first section; it is fixed and needs no wrapper:
34
+ // <HeaderBar variant="quiet" name="Noord" links={[{ label: "Werk", href: "/werk" }]}
27
35
  // action={{ label: "Plan een gesprek", href: "/contact" }} menuHref="#menu" />
28
36
  // Keep the hero's type below var(--hb-top) (set on the nearest [data-blocks]: the bar's height, or for settle the
29
37
  // bottom of the large wordmark). On phones the bar is the wordmark and one "Menu" link: pass onMenu to open a menu
@@ -34,6 +42,7 @@
34
42
  // Without JavaScript the plain bar stands in the start tone's ink, and nothing waits.
35
43
  import { useEffect, useRef, useState, type CSSProperties, type FocusEvent, type HTMLAttributes, type PointerEvent } from "react";
36
44
  import { gsap, ScrollTrigger, useGSAP } from "../kit/gsap";
45
+ import { photoLuminances } from "../kit/photo-read";
37
46
 
38
47
  export const HEADER_BAR_VARIANTS = [
39
48
  { id: "quiet", title: "De stille balk" },
@@ -47,17 +56,45 @@ export type BarTone = "ground" | "photo" | "inverse";
47
56
 
48
57
  const TONES: BarTone[] = ["ground", "photo", "inverse"];
49
58
  const TONE_INK: Record<BarTone, string> = { ground: "text-ink", photo: "text-on-photo", inverse: "text-on-inverse" };
50
- // Over a photo the scrolled bar takes the palette's own dark for bands over photos (--photo-ground: the inverse on a
51
- // light palette, the page ground on a dark one), nearly opaque, over a blur of what passes under it, so the hero's type
52
- // runs under the bar quiet and soft and the band never reads as a letterbox beside the page.
59
+ // Once the page scrolls the bar stands on a solid ground, so nothing that passes under it ever prints through its ink:
60
+ // the page's own surface, the inverse, and over a photo the palette's own dark for bands over photos (--photo-ground:
61
+ // the inverse on a light palette, the page ground on a dark one). Never a blur, a see-through tint or a shadow.
53
62
  const TONE_GROUND: Record<BarTone, string> = {
54
63
  ground: "bg-surface-0",
55
- photo: "bg-[color-mix(in_oklab,var(--photo-ground,var(--inverse))_88%,transparent)]",
64
+ photo: "bg-[var(--photo-ground,var(--inverse))]",
56
65
  inverse: "bg-inverse",
57
66
  };
58
67
  const NONE = "polygon(0px 0px, 0px 0px, 0px 0px)";
59
68
  /** The display face is waited for at most this long before the large wordmark is measured. */
60
69
  const FONT_CAP = 800;
70
+ /** The wordmark and the links against what is under them over a photo: at least this much. */
71
+ const PHOTO_RATIO = 3;
72
+
73
+ const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
74
+ const encode = (l: number) => (l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055);
75
+ const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
76
+ /** A layer of luminance `top` at opacity `a` over `under`, composited in sRGB as the browser does. */
77
+ const over = (under: number, top: number, a: number) => linear(encode(under) * (1 - a) + encode(top) * a);
78
+
79
+ /** Any CSS colour's relative luminance and alpha, through a one-pixel canvas so every colour syntax resolves. */
80
+ const colours = new Map<string, { y: number; a: number }>();
81
+ function colourOf(css: string) {
82
+ const kept = colours.get(css);
83
+ if (kept) return kept;
84
+ let out = { y: 0, a: 0 };
85
+ const cv = document.createElement("canvas");
86
+ cv.width = 1;
87
+ cv.height = 1;
88
+ const ctx = cv.getContext("2d", { willReadFrequently: true });
89
+ if (ctx) {
90
+ ctx.fillStyle = css;
91
+ ctx.fillRect(0, 0, 1, 1);
92
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
93
+ out = { y: 0.2126 * linear(r / 255) + 0.7152 * linear(g / 255) + 0.0722 * linear(b / 255), a: a / 255 };
94
+ }
95
+ colours.set(css, out);
96
+ return out;
97
+ }
61
98
 
62
99
  /**
63
100
  * settle: the wordmark's advance, estimated from its letters, so the CSS can set it large across the hero from the
@@ -274,7 +311,6 @@ export function HeaderBar({
274
311
  place = "Rotterdam",
275
312
  clock = false,
276
313
  timeZone = "Europe/Amsterdam",
277
- startTone = "ground",
278
314
  }: {
279
315
  variant?: HeaderBarVariant;
280
316
  /** The wordmark. */
@@ -294,8 +330,6 @@ export function HeaderBar({
294
330
  clock?: boolean;
295
331
  /** IANA zone for the clock. */
296
332
  timeZone?: string;
297
- /** The ground under the bar on the first paint, before the script measures it (the hero's, usually "photo"). */
298
- startTone?: BarTone;
299
333
  }) {
300
334
  const root = useRef<HTMLDivElement>(null);
301
335
  const api = useRef<{ hot: (i: number, on: boolean, from?: "left" | "right") => void; focus: (on: boolean) => void } | null>(null);
@@ -340,7 +374,6 @@ export function HeaderBar({
340
374
  const words = q("[data-hb-word]");
341
375
  const fades = q("[data-hb-fade]");
342
376
  const fills = q("[data-hb-fill]");
343
- const blur = q("[data-hb-blur]")[0] as HTMLElement | undefined;
344
377
  if (!move || !realFace || !realWord) return;
345
378
 
346
379
  let barH = realFace.offsetHeight;
@@ -408,16 +441,82 @@ export function HeaderBar({
408
441
  host.style.setProperty("--hb-top", `${Math.round(bigTop + S * wordH + barH * 0.25)}px`);
409
442
  };
410
443
 
444
+ // --- over a photo: the ink the photo under the bar asks for ---
445
+ // Each copy's ink and its scrolled ground, as the page resolves them.
446
+ const inkY = {} as Record<BarTone, number>;
447
+ const groundY = {} as Record<BarTone, number>;
448
+ const readInks = () => {
449
+ TONES.forEach((t, i) => {
450
+ inkY[t] = colourOf(getComputedStyle(copies[i]).color).y;
451
+ const g = copies[i].querySelector<HTMLElement>("[data-hb-ground]");
452
+ groundY[t] = g ? colourOf(getComputedStyle(g).backgroundColor).y : inkY[t];
453
+ });
454
+ };
455
+ // The last pick per photo section: kept while it still holds, so a band near the line never flickers.
456
+ const picked = new WeakMap<HTMLElement, BarTone>();
457
+ const photoTone = (sec: HTMLElement, top: number, bottom: number, k: number): BarTone => {
458
+ const band = { x: 0, y: top, w: window.innerWidth, h: bottom - top };
459
+ let lo = 0;
460
+ let hi = 0;
461
+ if (k < 1) {
462
+ // The photo with the most of the band under it, read as object-cover of its own box.
463
+ let img: HTMLImageElement | null = null;
464
+ let f: DOMRect | null = null;
465
+ let most = 0;
466
+ for (const im of Array.from(sec.querySelectorAll("img"))) {
467
+ const r = im.getBoundingClientRect();
468
+ const w = Math.min(r.right, band.w) - Math.max(r.left, 0);
469
+ const h = Math.min(r.bottom, bottom) - Math.max(r.top, top);
470
+ if (w > 0 && h > 0 && w * h > most) {
471
+ most = w * h;
472
+ img = im;
473
+ f = r;
474
+ }
475
+ }
476
+ if (!img || !f) return "photo";
477
+ const ys = photoLuminances(img, { x: f.left, y: f.top, w: f.width, h: f.height }, band);
478
+ if (!ys) return "photo";
479
+ lo = ys[Math.floor(ys.length * 0.1)];
480
+ hi = ys[Math.floor(ys.length * 0.9)];
481
+ // The block's own veils over the photo (data-veil), where they cover the band.
482
+ const mid = (top + bottom) / 2;
483
+ sec.querySelectorAll<HTMLElement>("[data-veil]").forEach((v) => {
484
+ const r = v.getBoundingClientRect();
485
+ if (r.top > mid || r.bottom < mid) return;
486
+ const cs = getComputedStyle(v);
487
+ const c = colourOf(cs.backgroundColor);
488
+ const a = (parseFloat(cs.opacity) || 0) * c.a;
489
+ if (a <= 0) return;
490
+ lo = over(lo, c.y, a);
491
+ hi = over(hi, c.y, a);
492
+ });
493
+ }
494
+ // Each ink over the band with its own scrolled ground at the strength it has come in, the worse end counted.
495
+ const score = (t: BarTone) => {
496
+ const a = Math.min(1, k);
497
+ return Math.min(contrast(inkY[t], over(lo, groundY[t], a)), contrast(inkY[t], over(hi, groundY[t], a)));
498
+ };
499
+ const was = picked.get(sec);
500
+ let tone: BarTone = "photo";
501
+ if (was && score(was) >= PHOTO_RATIO) tone = was;
502
+ else if (score("photo") < PHOTO_RATIO) {
503
+ for (const t of TONES) if (score(t) > score(tone)) tone = t;
504
+ }
505
+ picked.set(sec, tone);
506
+ return tone;
507
+ };
508
+
411
509
  // --- the ink, cut where the sections under the bar meet, in the bar's own coordinates ---
412
- const ink = (o: number, extent: number) => {
510
+ const ink = (o: number, extent: number, k: number) => {
413
511
  const segs: Array<[number, number, BarTone]> = [[0, extent, "ground"]];
414
512
  document.querySelectorAll<HTMLElement>("[data-bar-tone]").forEach((sec) => {
415
- const tone = sec.dataset.barTone;
416
- if ((tone !== "photo" && tone !== "inverse") || el.contains(sec)) return;
513
+ const declared = sec.dataset.barTone;
514
+ if ((declared !== "photo" && declared !== "inverse") || el.contains(sec)) return;
417
515
  const r = sec.getBoundingClientRect();
418
516
  const a = Math.max(0, r.top + o);
419
517
  const b = Math.min(extent, r.bottom + o);
420
518
  if (b <= a) return;
519
+ const tone: BarTone = declared === "photo" ? photoTone(sec, a - o, b - o, k) : declared;
421
520
  // Later sections in document order (the nested ones) paint over earlier ones.
422
521
  for (let k = segs.length - 1; k >= 0; k -= 1) {
423
522
  const [s0, s1, t] = segs[k];
@@ -437,10 +536,25 @@ export function HeaderBar({
437
536
  }
438
537
  copies[i].style.clipPath = pts.length ? `polygon(${pts.join(", ")})` : NONE;
439
538
  });
440
- // The blur sits under the copies (a clipped copy would blur nothing), cut to the photo's stretches like its ink.
441
- if (blur) blur.style.clipPath = copies[1].style.clipPath;
442
539
  };
443
540
 
541
+ // The plain bar's ink until the copies show (settle waits for its face): the tone of the section under the bar's
542
+ // middle now, read from the page's own data-bar-tone, so it can never disagree with the hero.
543
+ const startTone = (): BarTone => {
544
+ let tone: BarTone = "ground";
545
+ const h = realFace.offsetHeight;
546
+ const mid = h / 2;
547
+ document.querySelectorAll<HTMLElement>("[data-bar-tone]").forEach((sec) => {
548
+ const t = sec.dataset.barTone;
549
+ if ((t !== "photo" && t !== "inverse") || el.contains(sec)) return;
550
+ const r = sec.getBoundingClientRect();
551
+ if (r.top <= mid && r.bottom > mid) tone = t === "photo" ? photoTone(sec, Math.max(0, r.top), Math.min(h, r.bottom), 0) : t;
552
+ });
553
+ return tone;
554
+ };
555
+ readInks();
556
+ el.dataset.start = startTone();
557
+
444
558
  const paint = () => {
445
559
  const y = Math.max(0, window.scrollY);
446
560
  let o = 0;
@@ -478,7 +592,7 @@ export function HeaderBar({
478
592
  const from = variant === "settle" && S > 1 ? yFit : 0;
479
593
  const k = reduce ? (y > 2 ? 1 : 0) : Math.min(1, Math.max(0, (y - from) / barH));
480
594
  el.style.setProperty("--hb-k", k.toFixed(3));
481
- ink(o, extent);
595
+ ink(o, extent, k);
482
596
  };
483
597
 
484
598
  // quiet: the wish follows the scroll pixel for pixel, the bar eases after it; at rest it settles to a side.
@@ -585,9 +699,15 @@ export function HeaderBar({
585
699
 
586
700
  const onResize = () => {
587
701
  measure();
702
+ readInks();
588
703
  paint();
589
704
  };
590
705
  window.addEventListener("resize", onResize);
706
+ // A photo under the bar that arrives after the bar has painted: read it as soon as it is there.
707
+ const onLoad = (e: Event) => {
708
+ if (e.target instanceof HTMLImageElement && e.target.closest('[data-bar-tone="photo"]')) paint();
709
+ };
710
+ document.addEventListener("load", onLoad, true);
591
711
 
592
712
  // --- start: settle's large wordmark already stands from the first paint at the CSS estimate (its scale from the
593
713
  // estimated advance); the script waits for the display face (capped), measures the real width and eases the
@@ -620,6 +740,7 @@ export function HeaderBar({
620
740
  window.clearTimeout(rest);
621
741
  window.removeEventListener("scroll", onScroll);
622
742
  window.removeEventListener("resize", onResize);
743
+ document.removeEventListener("load", onLoad, true);
623
744
  real.removeEventListener("focusin", onFocusIn);
624
745
  real.removeEventListener("focusout", onFocusOut);
625
746
  host.style.removeProperty("--hb-top");
@@ -632,15 +753,8 @@ export function HeaderBar({
632
753
  const face = { variant, name, homeHref, links, action, menuHref, onMenu, place, clock, time };
633
754
 
634
755
  return (
635
- <div ref={root} data-header-bar={variant} data-start={startTone} style={{ "--hb-adv": advanceEm(name).toFixed(3) } as CSSProperties}>
756
+ <div ref={root} data-header-bar={variant} style={{ "--hb-adv": advanceEm(name).toFixed(3) } as CSSProperties}>
636
757
  <div data-hb-move="" className="pointer-events-none fixed inset-x-0 top-0 z-[70]">
637
- {/* Over a photo the scrolled ground is see-through at its edge: what passes under it is blurred, not sharp. */}
638
- <div
639
- aria-hidden="true"
640
- data-hb-blur=""
641
- className="absolute inset-x-0 top-0 h-[var(--hb-h)] backdrop-blur-md"
642
- style={{ clipPath: startTone === "photo" ? "none" : NONE }}
643
- />
644
758
  {/* The bar's ink over the page: one copy per ground, each clipped to the stretches it covers. */}
645
759
  {TONES.map((tone) => (
646
760
  <div
@@ -648,13 +762,14 @@ export function HeaderBar({
648
762
  aria-hidden="true"
649
763
  data-hb-copy={tone}
650
764
  className={`absolute inset-x-0 top-0 ${TONE_INK[tone]}`}
651
- style={{ clipPath: tone === startTone ? "none" : NONE }}
765
+ style={{ clipPath: NONE }}
652
766
  >
653
767
  <Face {...face} tone={tone} />
654
768
  </div>
655
769
  ))}
656
- {/* The real bar: the links, on top, their ink drawn by the copies under them once those show. */}
657
- <header data-hb-real="" className={`relative ${TONE_INK[startTone]}`}>
770
+ {/* The real bar: the links, on top, their ink drawn by the copies under them once those show. Until then its
771
+ ink is the start tone's (blocks.css, "--- header-bar ---"). */}
772
+ <header data-hb-real="" className={`relative ${TONE_INK.ground}`}>
658
773
  <Face {...face} real bind={bind} />
659
774
  </header>
660
775
  </div>
@@ -15,14 +15,19 @@
15
15
  //
16
16
  // The bar turns its ink with what is under it, exactly at the edge: it is drawn three times (ink for the page ground,
17
17
  // on-photo, on-inverse), each copy clipped to the stretch of the bar its own sections cover. Sections opt in with
18
- // data-bar-tone="photo" or data-bar-tone="inverse"; anything else is ground. The curtain carries its own copy of the
18
+ // data-bar-tone="photo" or data-bar-tone="inverse"; anything else is ground. Mark a section "photo" only where a photo
19
+ // really runs under the bar's band (a full-bleed hero); a poster or a framed photo with ground above it is ground. The
20
+ // start tone is read from the page too, never passed: before the script, the first section's data-bar-tone (the
21
+ // element right after the bar, or that element's first child) picks the ink. The curtain carries its own copy of the
19
22
  // bar, so while it travels the bar's ink splits along its edge. The real bar (the link and the button) sits on top
20
- // with transparent text: one focusable bar, never three.
23
+ // with transparent text: one focusable bar, never three. On the first screen the bar has no ground; once the page
24
+ // scrolls each copy takes a solid ground in its own tone and a hairline (no blur, no tint), so body text passing under
25
+ // the bar never prints through the name or the button.
21
26
  //
22
- // Mount it once at the top of the page; it is fixed and needs no wrapper:
23
- // <MenuCurtain variant="gate" name="Noord" items={[{ label: "Werk", href: "/werk", image }]} startTone="photo"
27
+ // Mount it once at the top of the page, right before the first section; it is fixed and needs no wrapper:
28
+ // <MenuCurtain variant="gate" name="Noord" items={[{ label: "Werk", href: "/werk", image }]}
24
29
  // contact={{ label: "hallo@noord.studio", href: "mailto:hallo@noord.studio" }} place="Rotterdam" />
25
- // Keep the hero's type out of the top var(--menu-bar-h) of the screen; the bar never gets a ground of its own.
30
+ // Keep the hero's type out of the top var(--menu-bar-h) of the screen (set on the nearest [data-blocks]).
26
31
  //
27
32
  // While open, the page under it does not move (Lenis stopped, or the root's overflow held without Lenis) and is
28
33
  // inert; Tab cycles the bar and the menu, Escape closes and gives focus back to where it was. A link to a spot on the
@@ -48,6 +53,13 @@ export type BarTone = "ground" | "photo" | "inverse";
48
53
 
49
54
  const TONES: BarTone[] = ["ground", "photo", "inverse"];
50
55
  const TONE_INK: Record<BarTone, string> = { ground: "text-ink", photo: "text-on-photo", inverse: "text-on-inverse" };
56
+ // Once the page scrolls the bar stands on a solid ground in the tone under it, so body text never prints through its
57
+ // ink: the page's surface, the inverse, and over a photo the palette's dark for bands over photos (the header bar's).
58
+ const TONE_GROUND: Record<BarTone, string> = {
59
+ ground: "bg-surface-0",
60
+ photo: "bg-[var(--photo-ground,var(--inverse))]",
61
+ inverse: "bg-inverse",
62
+ };
51
63
  const EMPTY = "inset(0px 0px 100% 0px)";
52
64
  const SLICES = [0, 1, 2, 3];
53
65
  const PANELS = [
@@ -91,6 +103,7 @@ function inertOutside(el: HTMLElement) {
91
103
  */
92
104
  function BarFace({
93
105
  real = false,
106
+ tone,
94
107
  name,
95
108
  homeHref,
96
109
  items,
@@ -100,6 +113,8 @@ function BarFace({
100
113
  onToggle,
101
114
  }: {
102
115
  real?: boolean;
116
+ /** The ink copies over the page: their ground and hairline, shown once the page scrolls. */
117
+ tone?: BarTone;
103
118
  name: string;
104
119
  homeHref: string;
105
120
  items: MenuItem[];
@@ -125,15 +140,21 @@ function BarFace({
125
140
  </>
126
141
  );
127
142
  return (
128
- <div className="flex h-[var(--menu-bar-h)] items-center justify-between gap-6 px-sheet-margin">
143
+ <div className="relative flex h-[var(--menu-bar-h)] items-center justify-between gap-6 px-sheet-margin">
144
+ {tone ? (
145
+ <>
146
+ <div data-mc-ground="" aria-hidden="true" className={`absolute inset-0 ${TONE_GROUND[tone]}`} />
147
+ <div data-mc-line="" aria-hidden="true" className="absolute inset-x-0 bottom-0 h-px bg-current" />
148
+ </>
149
+ ) : null}
129
150
  {real ? (
130
- <a href={homeHref} className={word}>
151
+ <a href={homeHref} className={`relative ${word}`}>
131
152
  {name}
132
153
  </a>
133
154
  ) : (
134
- <span className={word}>{name}</span>
155
+ <span className={`relative ${word}`}>{name}</span>
135
156
  )}
136
- <div className="grid justify-items-end">
157
+ <div className="relative grid justify-items-end">
137
158
  <ul data-menu-fallback="" className="m-0 flex list-none flex-wrap justify-end gap-x-4 gap-y-1 p-0 text-small [grid-area:1/1]">
138
159
  {items.map((item) => (
139
160
  <li key={item.label}>{real ? <a href={item.href}>{item.label}</a> : <span>{item.label}</span>}</li>
@@ -171,7 +192,6 @@ export function MenuCurtain({
171
192
  place = "Rotterdam",
172
193
  clock = false,
173
194
  timeZone = "Europe/Amsterdam",
174
- startTone = "ground",
175
195
  }: {
176
196
  variant?: MenuCurtainVariant;
177
197
  /** The wordmark. */
@@ -187,8 +207,6 @@ export function MenuCurtain({
187
207
  clock?: boolean;
188
208
  /** IANA zone for the clock. */
189
209
  timeZone?: string;
190
- /** The ground under the bar on the first paint, before the script measures it (the hero's, usually "photo"). */
191
- startTone?: BarTone;
192
210
  }) {
193
211
  const root = useRef<HTMLDivElement>(null);
194
212
  const panel = useRef<HTMLDivElement>(null);
@@ -342,6 +360,10 @@ export function MenuCurtain({
342
360
  const s = spans[TONES[i]];
343
361
  face.style.clipPath = s ? `inset(${s[0]}px 0px ${barH - s[1]}px 0px)` : EMPTY;
344
362
  });
363
+ // The ground (and its hairline) arrives over the bar's own height of scroll; under reduced motion it cuts in.
364
+ const sy = Math.max(0, window.scrollY);
365
+ const k = reduce ? (sy > 2 ? 1 : 0) : Math.min(1, sy / Math.max(1, barH));
366
+ el.style.setProperty("--mc-k", k.toFixed(3));
345
367
  };
346
368
  // On Lenis this runs inside its scroll (ScrollTrigger.update), so the split never trails the page by a frame.
347
369
  const watch = ScrollTrigger.create({ start: 0, end: "max", onUpdate: ink, onRefresh: ink });
@@ -660,13 +682,8 @@ export function MenuCurtain({
660
682
  {/* The bar's ink over the page: one copy per ground, each clipped to the stretch it covers. */}
661
683
  <div aria-hidden="true" className="pointer-events-none fixed inset-x-0 top-0 z-[79]">
662
684
  {TONES.map((tone) => (
663
- <div
664
- key={tone}
665
- data-tone-face={tone}
666
- className={`absolute inset-x-0 top-0 ${TONE_INK[tone]}`}
667
- style={{ clipPath: tone === startTone ? "none" : EMPTY }}
668
- >
669
- <BarFace {...face} />
685
+ <div key={tone} data-tone-face={tone} className={`absolute inset-x-0 top-0 ${TONE_INK[tone]}`}>
686
+ <BarFace {...face} tone={tone} />
670
687
  </div>
671
688
  ))}
672
689
  </div>
@@ -30,6 +30,10 @@
30
30
  // Phones: one word per line, the window between at the height that is left, showing the photo's own crop; the
31
31
  // entrance only (no pin, no open).
32
32
  //
33
+ // The top line keeps clear of the site's bar on every width: the sheet's top room is the bar's height (header-bar's
34
+ // --hb-top or menu-curtain's --menu-bar-h) plus a breath (blocks.css, --hp-pt), and the fit reads that room. The
35
+ // poster stands on the page ground, so the section around it is ground for the bar: never mark it data-bar-tone="photo".
36
+ //
33
37
  // Three variants of the same poster, picked with `variant`:
34
38
  // - window (the default, above): one letterbox window between the two lines.
35
39
  // - column: the two lines centred, fitted edge to edge, and a tall narrow slit to one side that cuts through them from
@@ -87,6 +91,21 @@ const sideOf = (el: HTMLElement) => {
87
91
  const pad = el.querySelector<HTMLElement>("[data-hp-sheet]");
88
92
  return pad ? parseFloat(getComputedStyle(pad).paddingLeft) || 0 : 0;
89
93
  };
94
+ /** The sheet's top and bottom room as laid out, in px: the top keeps the site's bar clear (blocks.css, --hp-pt). */
95
+ const padsOf = (el: HTMLElement) => {
96
+ const pad = el.querySelector<HTMLElement>("[data-hp-sheet]");
97
+ if (!pad) return 0;
98
+ const cs = getComputedStyle(pad);
99
+ return (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
100
+ };
101
+ /** Refits when the sheet's room changes without a resize: the site's bar measuring itself after the first fit. */
102
+ const watchRoom = (el: HTMLElement, refit: () => void) => {
103
+ const sheet = el.querySelector<HTMLElement>("[data-hp-sheet]");
104
+ if (!sheet || typeof ResizeObserver === "undefined") return () => {};
105
+ const ro = new ResizeObserver(() => refit());
106
+ ro.observe(sheet);
107
+ return () => ro.disconnect();
108
+ };
90
109
  /** The width the script-free estimates fit into: the screen inside the sheet margin on both sides. */
91
110
  const INSIDE = "(100vw - 2 * var(--spacing-sheet-margin))";
92
111
 
@@ -147,7 +166,7 @@ function PosterWindow({ image, lines, accent, place, link, className = "" }: Inn
147
166
  });
148
167
  const rows = wide ? [1, 1] : words.map((ws) => ws.length);
149
168
  const foot = el.querySelector<HTMLElement>("[data-hp-foot]")?.offsetHeight ?? 0;
150
- const free = H - foot - (wide ? H * 0.1 : 64) - H * 0.12;
169
+ const free = H - foot - padsOf(el) - (wide ? H * 0.03 : 8) - H * 0.12;
151
170
  const k = Math.min(1, free / (LEADING * (sizes[0] * rows[0] + sizes[1] * rows[1])));
152
171
  return [Math.floor(sizes[0] * k), Math.floor(sizes[1] * k)];
153
172
  };
@@ -224,6 +243,7 @@ function PosterWindow({ image, lines, accent, place, link, className = "" }: Inn
224
243
  const onFonts = () => refit();
225
244
  document.fonts?.addEventListener?.("loadingdone", onFonts);
226
245
  window.addEventListener("resize", refit);
246
+ const unwatch = watchRoom(el, refit);
227
247
 
228
248
  // The photo decoded ahead of its first show.
229
249
  for (const img of q<HTMLImageElement>("img")) {
@@ -332,6 +352,7 @@ function PosterWindow({ image, lines, accent, place, link, className = "" }: Inn
332
352
  dead = true;
333
353
  document.fonts?.removeEventListener?.("loadingdone", onFonts);
334
354
  window.removeEventListener("resize", refit);
355
+ unwatch();
335
356
  mm.revert();
336
357
  };
337
358
  },
@@ -377,7 +398,7 @@ function PosterWindow({ image, lines, accent, place, link, className = "" }: Inn
377
398
  <h1 className="sr-only">{`${lines[0]} ${lines[1]}`}</h1>
378
399
 
379
400
  {/* The poster: line, window, line, foot. The lines stand above the photo as it opens past them. */}
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]">
401
+ <div data-hp-sheet="" className="relative flex h-full flex-col px-sheet-margin pb-6 pt-[var(--hp-pt)] md:pb-[3svh]">
381
402
  <div data-hp-top="" className="relative z-10 will-change-transform">
382
403
  <p aria-hidden className="m-0">
383
404
  <span
@@ -401,7 +422,7 @@ function PosterWindow({ image, lines, accent, place, link, className = "" }: Inn
401
422
  >
402
423
  <div
403
424
  data-stack-photo=""
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"
425
+ className="absolute inset-0 md:inset-auto md:-left-sheet-margin md:top-[calc(0px_-_var(--hp-pt)_-_0.84_*_var(--hp-s1,var(--hp-e1))_-_1.2svh)] md:h-svh md:w-screen"
405
426
  >
406
427
  <Image
407
428
  src={image.src}
@@ -623,7 +644,7 @@ function PosterColumn({ image, lines, accent, place, link, className = "" }: Inn
623
644
  return width > 0 ? (100 * room) / width : 100;
624
645
  });
625
646
  const rows = wide ? [1, 1] : words.map((ws) => ws.length);
626
- const free = H - (foot?.offsetHeight ?? 0) - (wide ? H * 0.2 : 140);
647
+ const free = H - (foot?.offsetHeight ?? 0) - padsOf(el) - (wide ? H * 0.13 : 84);
627
648
  const k = Math.min(1, free / (LEADING * (sizes[0] * rows[0] + sizes[1] * rows[1])));
628
649
  return [Math.floor(sizes[0] * k), Math.floor(sizes[1] * k)];
629
650
  };
@@ -690,6 +711,7 @@ function PosterColumn({ image, lines, accent, place, link, className = "" }: Inn
690
711
  const onFonts = () => refit();
691
712
  document.fonts?.addEventListener?.("loadingdone", onFonts);
692
713
  window.addEventListener("resize", refit);
714
+ const unwatch = watchRoom(el, refit);
693
715
  decoded.then(() => !dead && grade());
694
716
 
695
717
  const masks = q("[data-hp-rise]");
@@ -775,6 +797,7 @@ function PosterColumn({ image, lines, accent, place, link, className = "" }: Inn
775
797
  dead = true;
776
798
  document.fonts?.removeEventListener?.("loadingdone", onFonts);
777
799
  window.removeEventListener("resize", refit);
800
+ unwatch();
778
801
  mm.revert();
779
802
  };
780
803
  },
@@ -813,7 +836,7 @@ function PosterColumn({ image, lines, accent, place, link, className = "" }: Inn
813
836
  data-hp-layer={copy ? "copy" : "ground"}
814
837
  aria-hidden={copy || undefined}
815
838
  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"}`}
839
+ className={`absolute inset-0 flex flex-col px-sheet-margin pb-6 pt-[var(--hp-pt)] md:pb-[3svh] ${copy ? "text-on-photo" : "text-ink"}`}
817
840
  >
818
841
  <div className="flex-1" />
819
842
  <div data-hp-l1="" className="will-change-transform">
@@ -908,7 +931,7 @@ function PosterStack({ image, lines, accent, place, link, className = "" }: Inne
908
931
  const wide = W >= 768;
909
932
  const room = W - 2 * sideOf(el);
910
933
  const widest = Math.max(1, ...wordEls.map((w) => widthAt100(w, false)));
911
- const free = H - (foot?.offsetHeight ?? 0) - (wide ? H * 0.1 : 72) - m * H * (wide ? 0.075 : 0.08);
934
+ const free = H - (foot?.offsetHeight ?? 0) - padsOf(el) - (wide ? H * 0.03 : 16) - m * H * (wide ? 0.075 : 0.08);
912
935
  return [Math.floor(Math.min((100 * room) / widest, free / (LEADING * n)))];
913
936
  };
914
937
 
@@ -930,6 +953,7 @@ function PosterStack({ image, lines, accent, place, link, className = "" }: Inne
930
953
  const onFonts = () => refit();
931
954
  document.fonts?.addEventListener?.("loadingdone", onFonts);
932
955
  window.addEventListener("resize", refit);
956
+ const unwatch = watchRoom(el, refit);
933
957
  for (const img of q("img") as HTMLImageElement[]) img.decode?.().catch(() => {});
934
958
 
935
959
  const masks = q("[data-hp-rise]");
@@ -1047,16 +1071,18 @@ function PosterStack({ image, lines, accent, place, link, className = "" }: Inne
1047
1071
  dead = true;
1048
1072
  document.fonts?.removeEventListener?.("loadingdone", onFonts);
1049
1073
  window.removeEventListener("resize", refit);
1074
+ unwatch();
1050
1075
  mm.revert();
1051
1076
  };
1052
1077
  },
1053
1078
  { scope: stage, dependencies: [lines[0], lines[1], accent] },
1054
1079
  );
1055
1080
 
1056
- // The script-free estimate: the longest word over the width, capped so the rows and their windows fit the height.
1081
+ // The script-free estimate: the longest word over the width, capped so the rows and their windows fit the height
1082
+ // left under the sheet's top room (--hp-pt, which keeps the site's bar clear).
1057
1083
  const per = (longest * EM_PER_CHAR).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)}))`;
1084
+ const est = `min(calc(${INSIDE} / ${per}), calc((96svh - var(--hp-pt) - 2rem - ${m} * 7.5svh) / ${(n * LEADING).toFixed(2)}))`;
1085
+ const estPhone = `min(calc(${INSIDE} / ${per}), calc((100svh - var(--hp-pt) - 5rem - ${m} * 8svh) / ${(n * LEADING).toFixed(2)}))`;
1060
1086
 
1061
1087
  return (
1062
1088
  <section
@@ -1068,7 +1094,7 @@ function PosterStack({ image, lines, accent, place, link, className = "" }: Inne
1068
1094
  style={{ "--hp-e": est, "--hp-p": estPhone } as React.CSSProperties}
1069
1095
  >
1070
1096
  <h1 className="sr-only">{`${lines[0]} ${lines[1]}`}</h1>
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]">
1097
+ <div data-hp-sheet="" className="relative flex h-full flex-col px-sheet-margin pb-6 pt-[var(--hp-pt)] md:pb-[3svh]">
1072
1098
  {marked.map((w, i) => (
1073
1099
  <div key={i} className="contents">
1074
1100
  <div data-hp-row="" className="relative z-10 will-change-transform">
@@ -108,7 +108,8 @@ export function HeroCanvas({ scene, image, grade = 0.25, scrim = 0, className =
108
108
 
109
109
  let renderer: import("three").WebGLRenderer;
110
110
  try {
111
- renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false, powerPreference: "high-performance" });
111
+ // alpha: a resized or discarded buffer is transparent, so the poster or the ground shows, never opaque black.
112
+ renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true, powerPreference: "high-performance" });
112
113
  } catch {
113
114
  return; // no WebGL: the poster stays, which is the designed fallback
114
115
  }
@@ -121,6 +122,8 @@ export function HeroCanvas({ scene, image, grade = 0.25, scrim = 0, className =
121
122
  slot.appendChild(canvas);
122
123
 
123
124
  const palette = readPalette(el);
125
+ // An opaque clear in the page ground: a scene that does not set its own never paints black behind itself.
126
+ renderer.setClearColor(new THREE.Color(palette.surface), 1);
124
127
  let hero: Awaited<ReturnType<CreateScene>>;
125
128
  try {
126
129
  hero = await createScene({
@@ -220,7 +223,8 @@ export function HeroCanvas({ scene, image, grade = 0.25, scrim = 0, className =
220
223
 
221
224
  const ro = new ResizeObserver(() => {
222
225
  size();
223
- if (still) draw();
226
+ // A resize clears the canvas: draw at once, in this task, even when the loop is paused off screen.
227
+ draw();
224
228
  });
225
229
  ro.observe(el);
226
230
 
@@ -7,7 +7,8 @@
7
7
  // Vis en groente, van het seizoen.
8
8
  // </SplitHeadline>
9
9
  //
10
- // Pass plain text (or text with <em>): SplitText needs text nodes, not components.
10
+ // Pass plain text (or text with <em>): SplitText needs text nodes, not components. The accent helper's no-wrap unit
11
+ // (the mark with the punctuation touching it, ./accent) is cut as one piece, so it always lands whole on one line.
11
12
  import { useRef } from "react";
12
13
  import { gsap, SplitText, useGSAP } from "./gsap";
13
14