@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.
- package/README.md +137 -100
- package/package.json +5 -3
- package/skills/struq/SKILL.md +168 -165
- package/skills/struq/blocks/CATALOG.md +49 -46
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +2 -1
- package/skills/struq/blocks/backgrounds/background-facets.tsx +5 -3
- package/skills/struq/blocks/backgrounds/background-silk.tsx +6 -2
- package/skills/struq/blocks/catalog.json +50 -5
- package/skills/struq/blocks/film.json +11 -7
- package/skills/struq/blocks/footers/footer-contact.tsx +8 -78
- package/skills/struq/blocks/headers/header-bar.tsx +144 -29
- package/skills/struq/blocks/headers/menu-curtain.tsx +36 -19
- package/skills/struq/blocks/heroes/hero-poster.tsx +36 -10
- package/skills/struq/blocks/kit/HeroCanvas.tsx +6 -2
- package/skills/struq/blocks/kit/SplitHeadline.tsx +2 -1
- package/skills/struq/blocks/kit/accent.ts +29 -3
- package/skills/struq/blocks/kit/blocks.css +530 -6
- package/skills/struq/blocks/kit/feel.ts +183 -0
- package/skills/struq/blocks/kit/gsap.ts +9 -6
- package/skills/struq/blocks/kit/middle/PlaceHours.tsx +254 -0
- package/skills/struq/blocks/kit/middle/ProcessStrip.tsx +428 -0
- package/skills/struq/blocks/kit/middle/ServicesList.tsx +496 -0
- package/skills/struq/blocks/kit/middle/entrance.ts +98 -0
- package/skills/struq/blocks/kit/middle/hours.ts +154 -0
- package/skills/struq/blocks/kit/middle/index.ts +14 -0
- package/skills/struq/blocks/kit/motion/HoverLink.tsx +33 -0
- package/skills/struq/blocks/kit/motion/ImageCurtain.tsx +79 -0
- package/skills/struq/blocks/kit/motion/MaskWipe.tsx +91 -0
- package/skills/struq/blocks/kit/motion/Pressable.tsx +58 -0
- package/skills/struq/blocks/kit/motion/RevealText.tsx +103 -0
- package/skills/struq/blocks/kit/motion/Rise.tsx +77 -0
- package/skills/struq/blocks/kit/motion/ScrubLine.tsx +108 -0
- package/skills/struq/blocks/kit/motion/index.ts +11 -0
- package/skills/struq/blocks/kit/quiet/Close.tsx +178 -52
- package/skills/struq/blocks/kit/quiet/Statement.tsx +3 -2
- package/skills/struq/blocks/kit/subject/AppScreen.tsx +388 -0
- package/skills/struq/blocks/kit/subject/Document.tsx +742 -0
- package/skills/struq/blocks/kit/subject/Mark.tsx +44 -0
- package/skills/struq/blocks/kit/subject/PatternPaper.tsx +410 -0
- package/skills/struq/blocks/kit/subject/SectionDrawing.tsx +349 -0
- package/skills/struq/blocks/kit/subject/Ticket.tsx +354 -0
- package/skills/struq/blocks/kit/subject/build.ts +66 -0
- package/skills/struq/blocks/kit/subject/draw.ts +196 -0
- package/skills/struq/blocks/kit/subject/drawings.ts +373 -0
- package/skills/struq/blocks/kit/subject/format.ts +122 -0
- package/skills/struq/blocks/kit/subject/index.ts +11 -0
- package/skills/struq/blocks/kit/subject/patterns.ts +204 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +75 -22
- package/skills/struq/blocks/showpieces/hero-opener.tsx +55 -7
- package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +64 -6
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +56 -3
- package/skills/struq/blocks/showpieces/panel-wipe.tsx +93 -11
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +38 -5
- package/skills/struq/canon/canon.json +1 -1
- package/skills/struq/references/art-direction.md +51 -0
- package/skills/struq/scripts/blocks.mjs +56 -20
- package/skills/struq/scripts/canon.mjs +36 -7
- package/skills/struq/scripts/check.mjs +417 -11
- package/skills/struq/scripts/draw.mjs +49 -0
- package/skills/struq/scripts/film-check.mjs +28 -4
- package/skills/struq/scripts/gate.mjs +13 -3
- package/skills/struq/scripts/hand.mjs +222 -0
- package/skills/struq/scripts/images.mjs +12 -3
- package/skills/struq/scripts/look.mjs +1184 -0
- 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
|
|
5
|
-
// photo), so
|
|
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" }]}
|
|
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
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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-[
|
|
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
|
|
416
|
-
if ((
|
|
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}
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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 }]}
|
|
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
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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(-
|
|
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.
|
|
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-
|
|
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.
|
|
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((
|
|
1059
|
-
const estPhone = `min(calc(${INSIDE} / ${per}), calc((100svh -
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|