@struq/skill 0.4.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +27 -27
  2. package/cli/struq.mjs +2 -2
  3. package/package.json +6 -6
  4. package/skills/struq/SKILL.md +145 -89
  5. package/skills/struq/blocks/CATALOG.md +74 -22
  6. package/skills/struq/blocks/backgrounds/background-aurora.tsx +34 -12
  7. package/skills/struq/blocks/backgrounds/background-facets.tsx +14 -6
  8. package/skills/struq/blocks/backgrounds/background-silk.tsx +40 -15
  9. package/skills/struq/blocks/catalog.json +697 -16
  10. package/skills/struq/blocks/film.json +426 -0
  11. package/skills/struq/blocks/footers/footer-contact.tsx +81 -55
  12. package/skills/struq/blocks/footers/footer-reveal.tsx +1 -1
  13. package/skills/struq/blocks/headers/header-bar.tsx +24 -13
  14. package/skills/struq/blocks/headers/menu-curtain.tsx +24 -11
  15. package/skills/struq/blocks/heroes/hero-framed.tsx +33 -20
  16. package/skills/struq/blocks/heroes/hero-index.tsx +32 -11
  17. package/skills/struq/blocks/heroes/hero-poster.tsx +68 -35
  18. package/skills/struq/blocks/heroes/hero-split.tsx +22 -9
  19. package/skills/struq/blocks/heroes/hero-still.tsx +80 -74
  20. package/skills/struq/blocks/kit/accent.ts +82 -0
  21. package/skills/struq/blocks/kit/blocks.css +78 -35
  22. package/skills/struq/blocks/kit/quiet/Close.tsx +73 -0
  23. package/skills/struq/blocks/kit/quiet/PhotoText.tsx +79 -0
  24. package/skills/struq/blocks/kit/quiet/Rows.tsx +81 -0
  25. package/skills/struq/blocks/kit/quiet/Statement.tsx +157 -0
  26. package/skills/struq/blocks/kit/quiet/index.ts +7 -0
  27. package/skills/struq/blocks/kit/quiet/sheet.ts +12 -0
  28. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +87 -36
  29. package/skills/struq/blocks/preloaders/preloader-gate.tsx +116 -46
  30. package/skills/struq/blocks/preloaders/preloader-stack.tsx +10 -7
  31. package/skills/struq/blocks/sections/contact-form.tsx +1 -1
  32. package/skills/struq/blocks/sections/cta-closing.tsx +3 -2
  33. package/skills/struq/blocks/sections/fanned-cards.tsx +14 -10
  34. package/skills/struq/blocks/sections/film-strip.tsx +18 -14
  35. package/skills/struq/blocks/sections/grid-assembly.tsx +16 -11
  36. package/skills/struq/blocks/sections/manifesto.tsx +3 -2
  37. package/skills/struq/blocks/sections/panel-accordion.tsx +3 -2
  38. package/skills/struq/blocks/sections/split-pair.tsx +4 -3
  39. package/skills/struq/blocks/showpieces/hero-opener.tsx +76 -17
  40. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +3 -1
  41. package/skills/struq/blocks/transitions/zoom-through.tsx +9 -10
  42. package/skills/struq/canon/canon.json +1 -1
  43. package/skills/struq/references/craft.md +4 -0
  44. package/skills/struq/references/film.md +158 -0
  45. package/skills/struq/references/iconography.md +1 -1
  46. package/skills/struq/references/triage.md +3 -0
  47. package/skills/struq/scripts/blocks.mjs +89 -7
  48. package/skills/struq/scripts/canon.mjs +8 -1
  49. package/skills/struq/scripts/check.mjs +99 -15
  50. package/skills/struq/scripts/film-check.mjs +997 -0
  51. package/skills/struq/scripts/gate.mjs +146 -0
  52. package/skills/struq/scripts/images.mjs +64 -37
  53. package/skills/struq/scripts/packs.mjs +73 -12
@@ -0,0 +1,73 @@
1
+ "use client";
2
+ // components/struq/kit/quiet/Close.tsx (Struq recipe)
3
+ // The page's last call: one line of type at the sheet's moment size from the grid's first column, and one link at the
4
+ // far end of the line under it, so the eye crosses the page once, from the big line to the small way out. An optional
5
+ // hairline frame around the line is the frame motif's last echo; an optional caption sits at the start of the link's
6
+ // line, a label in the margin columns beside the line.
7
+ //
8
+ // <Close line="Kom kijken in de werkplaats." accent="werkplaats" link={{ href: "/contact", label: "Maak een afspraak" }}
9
+ // caption="Open op zaterdag, 10 tot 16 uur" frame />
10
+ //
11
+ // Props
12
+ // - line: the closing sentence, plain text, short (it sets in two or three lines at moment size).
13
+ // - accent: the site's one accent word, again: where it first stands in the line it is set in the pairing's italic
14
+ // accent face (--face-accent), as in the opening and the Statement (../accent). A word the line does not hold
15
+ // changes nothing.
16
+ // - link: { href, label }, the one way out. Rendered as a plain <a>; wrap your own Link around it if you route.
17
+ // - frame: draws a hairline from --line around the line, one gutter inside it. Use it only when the site's motif is the
18
+ // frame (it echoes the hero's or the centerpiece's frame).
19
+ // - caption: small, at the start of the link's line. label: small, beside the line in the margin columns.
20
+ // - className: on the root, for example "scene-inverse" or "scene-field".
21
+ //
22
+ // Mounting: the last section before the footer. The root carries data-scene="close" and data-energy="1".
23
+ // Motion: it enters once. The line's rows rise through their masks (SplitHeadline), the caption and the link follow
24
+ // (Reveal). No pin, no scrub, no hover motion: the link is underlined at rest and stays so. Reduced motion: a short
25
+ // fade. Without scripts: the composed scene. Phones: the line, the label, then the link with the caption under it.
26
+ import { withAccent } from "../accent";
27
+ import { Reveal } from "../Reveal";
28
+ import { SplitHeadline } from "../SplitHeadline";
29
+ import { LABEL, SCENE_SPACE, SHEET_GRID } from "./sheet";
30
+
31
+ type Props = {
32
+ line: string;
33
+ accent?: string;
34
+ link: { href: string; label: string };
35
+ frame?: boolean;
36
+ caption?: string;
37
+ label?: string;
38
+ className?: string;
39
+ };
40
+
41
+ export function Close({ line, accent, link, frame = false, caption, label, className = "" }: Props) {
42
+ return (
43
+ <section data-scene="close" data-energy="1" className={`text-ink ${SCENE_SPACE} ${className}`}>
44
+ <div className={`items-start gap-y-10 md:items-baseline md:gap-y-20 ${SHEET_GRID}`}>
45
+ {/* The frame's inset is in the line's own em, so it holds the mask's room for accents and descenders at any
46
+ size and the hairline never crosses a letter. */}
47
+ <div className={`col-span-12 md:col-span-10 ${frame ? "border border-line p-[0.35em] text-sheet-moment" : ""}`}>
48
+ <SplitHeadline as="h2" on="scroll" className="m-0 text-sheet-moment">
49
+ {withAccent(line, accent)}
50
+ </SplitHeadline>
51
+ </div>
52
+ {label ? (
53
+ <Reveal y={16} className="col-span-8 md:col-span-2 md:col-start-11">
54
+ <p className={LABEL}>{label}</p>
55
+ </Reveal>
56
+ ) : null}
57
+ {/* The link first in reading order; on wide screens it takes the far end and the caption the start. */}
58
+ <Reveal
59
+ y={16}
60
+ className="col-span-12 flex flex-col items-start gap-4 md:flex-row-reverse md:items-baseline md:justify-between md:gap-10"
61
+ >
62
+ <a
63
+ href={link.href}
64
+ className="font-body text-h3 font-medium text-ink underline decoration-1 underline-offset-[0.22em]"
65
+ >
66
+ {link.label}
67
+ </a>
68
+ {caption ? <p className={`${LABEL} max-w-[40ch]`}>{caption}</p> : null}
69
+ </Reveal>
70
+ </div>
71
+ </section>
72
+ );
73
+ }
@@ -0,0 +1,79 @@
1
+ "use client";
2
+ // components/struq/kit/quiet/PhotoText.tsx (Struq recipe)
3
+ // A photograph and a short text, set on the page grid: the photo across seven columns, the heading and one or two
4
+ // paragraphs across four, one empty column between them as the breath. The text stands on the photo's foot (its last
5
+ // line on the photo's lower edge), so the two read as one composition; the caption hangs under the photo, the label at
6
+ // the far end of that same line.
7
+ //
8
+ // <PhotoText image={images.card[1]} ratio="4/3" side="left" heading="Gedraaid, niet gegoten."
9
+ // text={["Elke kom begint als een klomp van vier kilo.", "Wat scheef groeit, mag blijven."]}
10
+ // caption="De draaischijf, ochtendlicht" label="Hoofdstuk twee" />
11
+ //
12
+ // Props
13
+ // - image: a StruqImage. ratio: a CSS aspect ratio for its frame, "4/3" by default ("1/1", "4/5", "3/2" work).
14
+ // - side: "left" (default) puts the photo on the left and the text on the right; "right" mirrors it.
15
+ // - heading: a short heading, at h2 size. text: one paragraph, or two as a pair.
16
+ // - caption: small, under the photo. label: small, sentence case, at the far end of the caption's line.
17
+ // - className: on the root, for example "scene-inverse" or "scene-field".
18
+ //
19
+ // Mounting: a page section of its own. The root carries data-scene="photo-text" and data-energy="1".
20
+ // Motion: it enters once. The photo opens from its foot (MediaFrame, no parallax), the heading's lines rise through
21
+ // their masks (SplitHeadline), the paragraphs and the caption line follow (Reveal). No pin, no scrub, no hover motion.
22
+ // Reduced motion: a short fade. Without scripts: the composed scene. Phones: photo, caption line, then the text.
23
+ import { MediaFrame } from "../MediaFrame";
24
+ import { Reveal } from "../Reveal";
25
+ import { SplitHeadline } from "../SplitHeadline";
26
+ import type { StruqImage } from "../images";
27
+ import { LABEL, SCENE_SPACE, SHEET_GRID } from "./sheet";
28
+
29
+ type Props = {
30
+ image: StruqImage;
31
+ ratio?: string;
32
+ side?: "left" | "right";
33
+ heading: string;
34
+ text: string | [string, string];
35
+ caption?: string;
36
+ label?: string;
37
+ className?: string;
38
+ };
39
+
40
+ export function PhotoText({ image, ratio = "4/3", side = "left", heading, text, caption, label, className = "" }: Props) {
41
+ const left = side === "left";
42
+ const paragraphs = Array.isArray(text) ? text : [text];
43
+ return (
44
+ <section data-scene="photo-text" data-energy="1" className={`text-ink ${SCENE_SPACE} ${className}`}>
45
+ <div className={`gap-y-6 md:gap-y-5 ${SHEET_GRID}`}>
46
+ <div className={`col-span-12 md:row-start-1 md:col-span-7 ${left ? "md:col-start-1" : "md:col-start-6"}`}>
47
+ <MediaFrame image={image} ratio={ratio} parallax={false} sizes="(min-width: 768px) 58vw, 100vw" />
48
+ </div>
49
+ {caption || label ? (
50
+ <Reveal
51
+ y={12}
52
+ className={`col-span-12 flex items-baseline justify-between gap-6 md:row-start-2 md:col-span-7 ${
53
+ left ? "md:col-start-1" : "md:col-start-6"
54
+ }`}
55
+ >
56
+ {caption ? <p className={LABEL}>{caption}</p> : <span />}
57
+ {label ? <p className={`${LABEL} text-right`}>{label}</p> : null}
58
+ </Reveal>
59
+ ) : null}
60
+ <div
61
+ className={`col-span-12 mt-10 md:row-start-1 md:col-span-4 md:mt-0 md:self-end ${
62
+ left ? "md:col-start-9" : "md:col-start-1"
63
+ }`}
64
+ >
65
+ <SplitHeadline as="h2" on="scroll" className="m-0 text-h2">
66
+ {heading}
67
+ </SplitHeadline>
68
+ <Reveal y={24} className="mt-8 grid gap-5 md:mt-10">
69
+ {paragraphs.map((p) => (
70
+ <p key={p} className="m-0 max-w-[38ch] text-body text-ink-muted">
71
+ {p}
72
+ </p>
73
+ ))}
74
+ </Reveal>
75
+ </div>
76
+ </div>
77
+ </section>
78
+ );
79
+ }
@@ -0,0 +1,81 @@
1
+ "use client";
2
+ // components/struq/kit/quiet/Rows.tsx (Struq recipe)
3
+ // A short list set as hairline rows on the page grid: a term in the display face, one sentence in the body face beside
4
+ // it, a hairline from --line above every row and under the last. Never numbered: the terms carry the order. An
5
+ // optional heading opens the scene (the label beside it in the margin columns, on its first baseline) and an optional
6
+ // upright photo stands in its own column beside the rows, level with the first hairline.
7
+ //
8
+ // <Rows heading="Hoe een kom ontstaat." label="Vier stappen, zes weken" image={images.column[2]}
9
+ // rows={[{ term: "Draaien", text: "Op de schijf, uit één klomp, in een ochtend." }, ...]} />
10
+ //
11
+ // Props
12
+ // - rows: { term, text }[], three to six. term: one or two words. text: one sentence.
13
+ // - heading: optional, at h2 size. label: optional, small, sentence case, beside the heading (or the first row).
14
+ // - image: optional StruqImage, shown upright (4/5) in three columns beside the rows; the rows take eight.
15
+ // - className: on the root, for example "scene-inverse" or "scene-field".
16
+ //
17
+ // Mounting: a page section of its own. The root carries data-scene="rows" and data-energy="1". The rows sit on the
18
+ // page's own columns (subgrid), so every term starts on the same column as the heading and the blocks around it.
19
+ // Motion: it enters once. The heading's lines rise through their masks, the rows follow one after another (Reveal), the
20
+ // photo opens from its foot (MediaFrame, no parallax). No pin, no scrub, no hover motion. Reduced motion: a short
21
+ // fade. Without scripts: the composed scene. Phones: heading, label, the rows with the sentence under its term, photo.
22
+ import { MediaFrame } from "../MediaFrame";
23
+ import { Reveal } from "../Reveal";
24
+ import { SplitHeadline } from "../SplitHeadline";
25
+ import type { StruqImage } from "../images";
26
+ import { LABEL, SCENE_SPACE, SHEET_GRID } from "./sheet";
27
+
28
+ type Row = { term: string; text: string };
29
+
30
+ type Props = {
31
+ rows: Row[];
32
+ heading?: string;
33
+ label?: string;
34
+ image?: StruqImage;
35
+ className?: string;
36
+ };
37
+
38
+ export function Rows({ rows, heading, label, image, className = "" }: Props) {
39
+ const wide = !image;
40
+ return (
41
+ <section data-scene="rows" data-energy="1" className={`text-ink ${SCENE_SPACE} ${className}`}>
42
+ <div className={`items-start gap-y-10 md:gap-y-16 ${SHEET_GRID}`}>
43
+ {heading ? (
44
+ <SplitHeadline as="h2" on="scroll" className="col-span-12 m-0 text-h2 md:col-span-7 md:self-baseline">
45
+ {heading}
46
+ </SplitHeadline>
47
+ ) : null}
48
+ {label ? (
49
+ <Reveal y={16} className="col-span-8 md:col-span-2 md:col-start-11 md:self-baseline">
50
+ <p className={LABEL}>{label}</p>
51
+ </Reveal>
52
+ ) : null}
53
+ <Reveal
54
+ as="ul"
55
+ y={24}
56
+ stagger={0.07}
57
+ className={`col-span-12 m-0 grid list-none border-b border-line p-0 md:grid-cols-subgrid ${
58
+ wide ? "md:col-span-12" : "md:col-span-8"
59
+ }`}
60
+ >
61
+ {rows.map((row) => (
62
+ <li
63
+ key={row.term}
64
+ className="grid gap-y-2 border-t border-line py-6 md:col-span-full md:grid-cols-subgrid md:items-baseline md:py-8"
65
+ >
66
+ <h3 className="m-0 text-h3 md:col-span-3">{row.term}</h3>
67
+ <p className={`m-0 max-w-[48ch] text-body text-ink-muted ${wide ? "md:col-span-6 md:col-start-5" : "md:col-span-5"}`}>
68
+ {row.text}
69
+ </p>
70
+ </li>
71
+ ))}
72
+ </Reveal>
73
+ {image ? (
74
+ <div className="col-span-8 col-start-5 md:col-span-3 md:col-start-10">
75
+ <MediaFrame image={image} ratio="4/5" parallax={false} sizes="(min-width: 768px) 25vw, 66vw" />
76
+ </div>
77
+ ) : null}
78
+ </div>
79
+ </section>
80
+ );
81
+ }
@@ -0,0 +1,157 @@
1
+ "use client";
2
+ // components/struq/kit/quiet/Statement.tsx (Struq recipe)
3
+ // The type moment of a quiet scene: one sentence at the sheet's moment size, set from the page grid's first column, on
4
+ // the same left edge as the header wordmark and the heroes' type. A small label stands in the margin columns beside the
5
+ // first line, on its baseline; a small upright photo hangs off the end of the last line, so the sentence and the
6
+ // picture read as one composed plate instead of a big line in an empty room.
7
+ //
8
+ // <Statement accent="hand" label="Werkplaats Zuid, sinds 1998" image={images.column[0]}>
9
+ // Klei onthoudt elke hand die haar vormt.
10
+ // </Statement>
11
+ //
12
+ // Props
13
+ // - children: the sentence, plain text. Two to five lines at moment size on a wide screen.
14
+ // - accent: one word of the sentence, set in the pairing's italic accent face (--face-accent) where it first stands
15
+ // (whole word, any case; punctuation stays outside). The site's one accent word: the opening and the Close carry it
16
+ // too (../accent). A word the sentence does not hold changes nothing.
17
+ // - label: small, sentence case, body face. Beside the first line (wide screens), under the sentence (phones).
18
+ // - image: a StruqImage, shown upright (4/5) and small: two columns wide, five on a phone.
19
+ // - as: "h2" (default) or "p" when the page already has its heading here.
20
+ // - className: on the root, for example "scene-inverse" or "scene-field"; the tokens turn inside it.
21
+ //
22
+ // Mounting: a page section of its own, between two blocks. The root carries data-scene="statement" and
23
+ // data-energy="1", so the film check counts it as a quiet scene.
24
+ //
25
+ // Motion: it enters once. The lines rise through their masks as the sentence scrolls in (SplitHeadline), the label
26
+ // follows (Reveal), the photo opens from its foot (MediaFrame, no parallax). No pin, no scrub, no hover motion. Reduced
27
+ // motion: a short fade, nothing travels. Without scripts: the composed scene, the photo in its place on the grid.
28
+ //
29
+ // The hang: once the faces are in (document.fonts.ready) the end of the last line is measured, in whole pixels, from
30
+ // the line's mask (which never moves, so a line still rising reads where it will stand). If the photo fits between that
31
+ // end and the grid's right edge, one gutter on, it hangs there from the line's top; otherwise it keeps its place on the
32
+ // grid. Its place on the grid keeps its room either way, so the page never jumps when it moves. Measured again when
33
+ // the width changes.
34
+ import { useEffect, useRef } from "react";
35
+ import { withAccent } from "../accent";
36
+ import { ScrollTrigger } from "../gsap";
37
+ import { MediaFrame } from "../MediaFrame";
38
+ import { Reveal } from "../Reveal";
39
+ import { SplitHeadline } from "../SplitHeadline";
40
+ import type { StruqImage } from "../images";
41
+ import { LABEL, SCENE_SPACE, SHEET_GRID } from "./sheet";
42
+
43
+ type Props = {
44
+ children: string;
45
+ accent?: string;
46
+ label?: string;
47
+ image?: StruqImage;
48
+ as?: "h2" | "p";
49
+ className?: string;
50
+ };
51
+
52
+ /** Where the last line ends and where its line box starts, in viewport pixels. */
53
+ function lastLineEnd(head: HTMLElement) {
54
+ const lines = head.querySelectorAll<HTMLElement>(".struq-line");
55
+ const last = lines[lines.length - 1];
56
+ const range = document.createRange();
57
+ range.selectNodeContents(last ?? head);
58
+ const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0);
59
+ const end = rects[rects.length - 1];
60
+ if (!end) return null;
61
+ const mask = last?.parentElement;
62
+ if (!mask) return { right: end.right, top: end.top };
63
+ // A cut line may still be rising inside its mask; the mask stands still, its padding is the room above the line.
64
+ return { right: end.right, top: mask.getBoundingClientRect().top + (parseFloat(getComputedStyle(mask).paddingTop) || 0) };
65
+ }
66
+
67
+ export function Statement({ children, accent, label, image, as = "h2", className = "" }: Props) {
68
+ const grid = useRef<HTMLDivElement>(null);
69
+
70
+ useEffect(() => {
71
+ const box = grid.current;
72
+ if (!box || !image) return;
73
+ const head = box.querySelector<HTMLElement>("[data-split]");
74
+ const slot = box.querySelector<HTMLElement>("[data-hang-slot]");
75
+ const frame = box.querySelector<HTMLElement>("[data-hang-frame]");
76
+ if (!head || !slot || !frame) return;
77
+ let alive = true;
78
+ let timer = 0;
79
+
80
+ const unhang = () => {
81
+ frame.style.position = "";
82
+ frame.style.left = "";
83
+ frame.style.top = "";
84
+ frame.style.width = "";
85
+ delete box.dataset.hang;
86
+ };
87
+ const place = () => {
88
+ if (!alive) return;
89
+ unhang();
90
+ const g = box.getBoundingClientRect();
91
+ const cs = getComputedStyle(box);
92
+ const inner = Math.round(g.right - (parseFloat(cs.paddingRight) || 0) - g.left);
93
+ const gap = Math.round(parseFloat(cs.columnGap) || 0);
94
+ const w = Math.round(slot.getBoundingClientRect().width);
95
+ const end = lastLineEnd(head);
96
+ if (!end || w === 0) return;
97
+ const x = Math.round(end.right - g.left) + gap;
98
+ const y = Math.round(end.top - g.top);
99
+ if (x + w > inner) return;
100
+ // Never over the label: a one line sentence has the label beside it, a phone has it under the sentence.
101
+ const tag = box.querySelector<HTMLElement>("[data-scene-label]")?.getBoundingClientRect();
102
+ const h = Math.round((w * 5) / 4);
103
+ if (tag && tag.width > 0) {
104
+ const t = { left: tag.left - g.left, right: tag.right - g.left, top: tag.top - g.top, bottom: tag.bottom - g.top };
105
+ if (x < t.right + gap && x + w + gap > t.left && y < t.bottom + gap && y + h + gap > t.top) return;
106
+ }
107
+ frame.style.position = "absolute";
108
+ frame.style.left = `${x}px`;
109
+ frame.style.top = `${y}px`;
110
+ frame.style.width = `${w}px`;
111
+ box.dataset.hang = "1";
112
+ // The photo's entrance is triggered by where it stands, which just moved.
113
+ ScrollTrigger.refresh();
114
+ };
115
+ const later = () => {
116
+ window.clearTimeout(timer);
117
+ // After SplitText's own re-cut on a width change.
118
+ timer = window.setTimeout(place, 260);
119
+ };
120
+
121
+ (document.fonts?.ready ?? Promise.resolve()).then(() => requestAnimationFrame(place));
122
+ window.addEventListener("resize", later);
123
+ document.fonts?.addEventListener?.("loadingdone", later);
124
+ return () => {
125
+ alive = false;
126
+ window.clearTimeout(timer);
127
+ window.removeEventListener("resize", later);
128
+ document.fonts?.removeEventListener?.("loadingdone", later);
129
+ unhang();
130
+ };
131
+ }, [image, children, accent]);
132
+
133
+ return (
134
+ <section data-scene="statement" data-energy="1" className={`text-ink ${SCENE_SPACE} ${className}`}>
135
+ <div ref={grid} className={`relative items-start gap-y-10 md:items-baseline md:gap-y-12 ${SHEET_GRID}`}>
136
+ <SplitHeadline as={as} on="scroll" className="col-span-12 m-0 text-sheet-moment md:col-span-10">
137
+ {withAccent(children, accent)}
138
+ </SplitHeadline>
139
+ {label ? (
140
+ <Reveal y={16} className="col-span-6 md:col-span-2 md:col-start-11">
141
+ <p data-scene-label="" className={LABEL}>
142
+ {label}
143
+ </p>
144
+ </Reveal>
145
+ ) : null}
146
+ {image ? (
147
+ // The photo's place on the grid: it keeps its room when the photo hangs off the last line instead.
148
+ <div data-hang-slot="" className="col-span-5 col-start-8 aspect-[4/5] md:col-span-2 md:col-start-9 md:self-start">
149
+ <div data-hang-frame="" className="w-full">
150
+ <MediaFrame image={image} ratio="4/5" parallax={false} sizes="(min-width: 768px) 17vw, 40vw" />
151
+ </div>
152
+ </div>
153
+ ) : null}
154
+ </div>
155
+ </section>
156
+ );
157
+ }
@@ -0,0 +1,7 @@
1
+ // components/struq/kit/quiet/index.ts (Struq recipe)
2
+ // The composed quiet scenes, for the storyboard's energy 0 and 1 slots: a type moment, a photo with text, hairline
3
+ // rows and the page's close. Each renders its own data-scene root, stands on the sheet's grid and enters once.
4
+ export { Close } from "./Close";
5
+ export { PhotoText } from "./PhotoText";
6
+ export { Rows } from "./Rows";
7
+ export { Statement } from "./Statement";
@@ -0,0 +1,12 @@
1
+ // components/struq/kit/quiet/sheet.ts (Struq recipe)
2
+ // The sheet the quiet scenes stand on: the page's 12 column grid inside the page margin (the header wordmark's and the
3
+ // heroes' left edge) with the one gutter, and the margin label. Tokens from blocks.css only.
4
+
5
+ /** The page grid: 12 columns, the sheet's gutter, the sheet's page margin. Phones keep the 12 columns. */
6
+ export const SHEET_GRID = "grid grid-cols-12 gap-x-sheet-gutter px-sheet-margin";
7
+
8
+ /** A quiet scene's breathing room above and below. */
9
+ export const SCENE_SPACE = "py-28 md:py-40";
10
+
11
+ /** The margin label: small, body face, sentence case, muted. Beside a headline or under a photo, never above a heading. */
12
+ export const LABEL = "m-0 font-body text-sheet-label text-ink-muted";
@@ -1,12 +1,17 @@
1
1
  "use client";
2
2
  // page-transition-gate (Struq page transition): the gate, on every navigation
3
- // The preloader's leader carried into navigation. The click shuts the gate: two bars come in from
4
- // top and bottom, each carrying one half of a slate (the dial's rings and the destination's name in
5
- // the display face), while the page recedes behind them. The halves meet on the middle line and
6
- // lock into one frame: the line lights in the accent and an accent arc runs once round the dial.
7
- // The route changes under the locked frame. Then the gate opens on that line, the name and the
8
- // rings split in two and ride away with the bars, and the new page settles forward through it while
9
- // its own entrance (onGateReveal) starts in the gap.
3
+ // The preloader's gate carried into navigation. The click shuts the gate: two bars come in from
4
+ // top and bottom, each carrying one half of a frame (the dial and the destination's name in the
5
+ // display face), while the page recedes behind them. The halves meet on the middle line and lock
6
+ // into one frame: the line lights in the accent and an accent arc runs once round the dial. The
7
+ // route changes under the locked frame. Then the gate opens on that line, the name and the dial
8
+ // split in two and ride away with the bars, and the new page settles forward through it while its
9
+ // own entrance starts in the gap.
10
+ //
11
+ // The words on the frame are the client's: the link's `label`, its text when short, else `name`.
12
+ // The dial is neutral by default, one hairline ring round the name; `dial="leader"` draws the
13
+ // preloader's film leader (two rings and a crosshair), which with the name makes a slate, the lab's
14
+ // look; `dial="none"` leaves the name alone on the line.
10
15
  //
11
16
  // 0.00 click: the bars close (0.38s), the page behind recedes to 0.97
12
17
  // 0.30 the line lights from the centre; the arc runs round the dial (0.26s)
@@ -23,8 +28,11 @@
23
28
  // <GateLink href="/werk" label="Werk">Bekijk het werk</GateLink>
24
29
  // const router = useGateRouter(); router.push("/contact", "Contact");
25
30
  //
26
- // A page's own entrance: set its start state only while transitionCovers() is true and play it in
27
- // onGateReveal(), so it starts as the gate opens instead of half played under the shut frame.
31
+ // The hand-over is the one every frame family uses (helper in preloaders/preloader-slices-uncovered.ts,
32
+ // which ships with it): once the frame is locked <html> carries data-curtain="closed", and as the gate
33
+ // starts to open the attribute goes and "curtain:lift" fires, so every Struq hero on the new page
34
+ // waits behind the frame with no wiring. A page's own entrance does the same with whenUncovered;
35
+ // transitionCovers() and onGateReveal() still work, and fire at the same moment.
28
36
  //
29
37
  // Back and forward get the same gate over the live page (canvases, video and a fixed header stay as
30
38
  // they are). The browser's traversal is undone at once with history.go, the gate shuts, and the
@@ -41,6 +49,9 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
41
49
  import { Suspense, useCallback, useEffect, useRef, type ComponentProps, type ReactNode } from "react";
42
50
  import { gsap, useGSAP } from "../kit/gsap";
43
51
  import { getLenis } from "../kit/SmoothScroll";
52
+ import { closeCurtain, covered, liftCurtain, whenUncovered } from "../preloaders/preloader-slices-uncovered";
53
+
54
+ export { whenUncovered };
44
55
 
45
56
  /** Fired on window when the gate starts to open on a new page. */
46
57
  export const GATE_REVEAL_EVENT = "struq:gate-reveal";
@@ -92,6 +103,10 @@ const store = {
92
103
  instant: false,
93
104
  landed: false,
94
105
  arriving: false,
106
+ /** This gate closed the shared curtain on <html> and owes the lift. */
107
+ curtain: false,
108
+ /** A lift owed by a provider torn down mid-journey; a provider that mounts again takes it back. */
109
+ pendingLift: 0,
95
110
  onCovered: null as (() => void) | null,
96
111
  gate: null as Gate | null,
97
112
  ceiling: null as gsap.core.Tween | null,
@@ -118,7 +133,7 @@ function entryKey() {
118
133
  return nav()?.currentEntry?.key ?? urlKey();
119
134
  }
120
135
 
121
- /** The slate's word for the entry `delta` steps from the page on screen. */
136
+ /** The frame's word for the entry `delta` steps from the page on screen. */
122
137
  function labelAt(delta: number) {
123
138
  const entry = nav()?.entries?.()[store.index + delta];
124
139
  return (entry && store.labels.get(entry.key)) || store.name;
@@ -187,9 +202,21 @@ function undo(delta: number) {
187
202
  });
188
203
  }
189
204
 
190
- /** The cover reached the locked frame: run what waits on it, and open if the page has already landed. */
205
+ /** The gate starts to open on the new page: the curtain lifts, so every entrance waiting on it starts in the gap. */
206
+ function revealed() {
207
+ if (store.curtain) {
208
+ store.curtain = false;
209
+ liftCurtain();
210
+ }
211
+ window.dispatchEvent(new Event(GATE_REVEAL_EVENT));
212
+ }
213
+
214
+ /** The cover reached the locked frame: close the curtain, run what waits on it, open if the page has already landed. */
191
215
  function coverDone() {
192
216
  store.phase = "covered";
217
+ // Closed before the route is asked for, so the page that mounts under the frame finds it closed.
218
+ closeCurtain();
219
+ store.curtain = true;
193
220
  const next = store.onCovered;
194
221
  store.onCovered = null;
195
222
  next?.();
@@ -247,7 +274,7 @@ function arrive() {
247
274
  const gate = store.gate;
248
275
  if (!gate || gate.reduce || store.instant) {
249
276
  store.arriving = false;
250
- window.dispatchEvent(new Event(GATE_REVEAL_EVENT));
277
+ revealed();
251
278
  finish();
252
279
  return;
253
280
  }
@@ -289,6 +316,8 @@ function finish() {
289
316
  store.phase = "idle";
290
317
  store.instant = false;
291
318
  store.passing = false;
319
+ // Never leave a closed curtain behind.
320
+ if (store.curtain) revealed();
292
321
  store.release?.();
293
322
  getLenis()?.start();
294
323
  if (store.after !== 0) {
@@ -298,7 +327,7 @@ function finish() {
298
327
  }
299
328
  }
300
329
 
301
- /** Starts a journey: the label goes on the slate, the gate shuts, `then` runs on the locked frame. */
330
+ /** Starts a journey: the label goes on the frame, the gate shuts, `then` runs on the locked frame. */
302
331
  function begin(kind: "push" | "pop", label: string, then: (() => void) | null) {
303
332
  store.kind = kind;
304
333
  store.from = store.rendered || urlKey();
@@ -416,7 +445,7 @@ export function useGateRouter() {
416
445
  };
417
446
  }
418
447
 
419
- /** The name the slate shows for a link: its `label`, else its text when short, else the site's name. */
448
+ /** The name the frame shows for a link: its `label`, else its text when short, else the site's name. */
420
449
  function linkLabel(a: HTMLAnchorElement, label?: string) {
421
450
  if (label) return label;
422
451
  const text = a.textContent?.trim() ?? "";
@@ -491,31 +520,41 @@ function UrlWatch({ onUrl }: { onUrl: (key: string) => void }) {
491
520
  return null;
492
521
  }
493
522
 
494
- /** One half of the slate: the full screen's leader, clipped by its bar, so the halves meet into one frame. */
495
- function Slate({ side }: { side: "top" | "bottom" }) {
523
+ type Dial = "ring" | "leader" | "none";
524
+
525
+ /** One half of the frame: the full screen's dial and name, clipped by its bar, so the halves meet into one frame. */
526
+ function Frame({ side, dial }: { side: "top" | "bottom"; dial: Dial }) {
496
527
  const ring = "color-mix(in oklch, var(--on-gate) 46%, transparent)";
497
528
  const inner = "color-mix(in oklch, var(--on-gate) 30%, transparent)";
498
529
  const cross = "color-mix(in oklch, var(--on-gate) 24%, transparent)";
499
530
  return (
500
531
  <div className={`absolute inset-x-0 h-[200%] ${side === "top" ? "top-0" : "bottom-0"}`}>
501
532
  <div className="absolute left-1/2 top-1/2 aspect-square w-[min(64vw,22rem)] -translate-x-1/2 -translate-y-1/2">
502
- <svg viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible" fill="none">
503
- <circle cx="100" cy="100" r="92" style={{ stroke: ring, strokeWidth: 0.8 }} />
504
- <circle cx="100" cy="100" r="78" style={{ stroke: inner, strokeWidth: 0.7 }} />
505
- <line x1="100" y1="-14" x2="100" y2="214" style={{ stroke: cross, strokeWidth: 0.7 }} />
506
- <circle
507
- data-arc=""
508
- cx="100"
509
- cy="100"
510
- r="78"
511
- pathLength={1}
512
- strokeDasharray="1 1"
513
- strokeDashoffset="1"
514
- transform="rotate(-90 100 100)"
515
- className="stroke-accent"
516
- style={{ strokeWidth: 1.4 }}
517
- />
518
- </svg>
533
+ {dial === "none" ? null : (
534
+ <svg viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible" fill="none">
535
+ {dial === "leader" ? (
536
+ <>
537
+ <circle cx="100" cy="100" r="92" style={{ stroke: ring, strokeWidth: 0.8 }} />
538
+ <circle cx="100" cy="100" r="78" style={{ stroke: inner, strokeWidth: 0.7 }} />
539
+ <line x1="100" y1="-14" x2="100" y2="214" style={{ stroke: cross, strokeWidth: 0.7 }} />
540
+ </>
541
+ ) : (
542
+ <circle cx="100" cy="100" r="78" style={{ stroke: ring, strokeWidth: 0.7 }} />
543
+ )}
544
+ <circle
545
+ data-arc=""
546
+ cx="100"
547
+ cy="100"
548
+ r="78"
549
+ pathLength={1}
550
+ strokeDasharray="1 1"
551
+ strokeDashoffset="1"
552
+ transform="rotate(-90 100 100)"
553
+ className="stroke-accent"
554
+ style={{ strokeWidth: 1.4 }}
555
+ />
556
+ </svg>
557
+ )}
519
558
  <div className="absolute inset-0 grid place-items-center">
520
559
  <span
521
560
  data-gate-label=""
@@ -529,11 +568,14 @@ function Slate({ side }: { side: "top" | "bottom" }) {
529
568
 
530
569
  export function PageTransitionGate({
531
570
  name = "",
571
+ dial = "ring",
532
572
  tone = "deep",
533
573
  children,
534
574
  }: {
535
- /** The site's name: the slate's word when a journey has no label of its own. */
575
+ /** The site's name: the frame's word when a journey has no label of its own. */
536
576
  name?: string;
577
+ /** The drawing round the name: one ring (default), the film leader's two rings and crosshair (a slate), or none. */
578
+ dial?: Dial;
537
579
  tone?: "deep" | "inverse" | "field";
538
580
  /** Rendered as is, never wrapped: put the page content in GateStage. */
539
581
  children?: ReactNode;
@@ -547,6 +589,7 @@ export function PageTransitionGate({
547
589
  () => {
548
590
  const el = root.current;
549
591
  if (!el) return;
592
+ window.clearTimeout(store.pendingLift);
550
593
  const q = gsap.utils.selector(el);
551
594
  const [top, bottom] = q("[data-bar]");
552
595
  const lights = q("[data-light]");
@@ -608,7 +651,7 @@ export function PageTransitionGate({
608
651
  })
609
652
  .to(top, { yPercent: -100, duration: OPEN, ease: "power4.inOut" }, 0)
610
653
  .to(bottom, { yPercent: 100, duration: OPEN, ease: "power4.inOut" }, 0)
611
- .call(() => window.dispatchEvent(new Event(GATE_REVEAL_EVENT)), [], 0.04);
654
+ .call(revealed, [], 0.04);
612
655
  // The page comes forward through the opening gate; its own tween, so the gate is free once open.
613
656
  const page = stage();
614
657
  if (page) {
@@ -649,6 +692,14 @@ export function PageTransitionGate({
649
692
  window.removeEventListener("scroll", record);
650
693
  history.scrollRestoration = previous;
651
694
  if (store.gate === gate) store.gate = null;
695
+ // Torn down with the curtain closed: lift it unless a provider mounts again and picks the journey up.
696
+ if (store.curtain) {
697
+ store.pendingLift = window.setTimeout(() => {
698
+ if (store.gate || !store.curtain) return;
699
+ store.curtain = false;
700
+ if (covered()) liftCurtain();
701
+ }, 0);
702
+ }
652
703
  };
653
704
  },
654
705
  { scope: root },
@@ -686,7 +737,7 @@ export function PageTransitionGate({
686
737
  data-bar={side}
687
738
  className={`absolute inset-x-0 h-1/2 overflow-hidden bg-[var(--gate)] ${side === "top" ? "top-0" : "bottom-0 shadow-[0_-1px_0_0_var(--gate)]"}`}
688
739
  >
689
- <Slate side={side} />
740
+ <Frame side={side} dial={dial} />
690
741
  {/* The leading edge carries a hairline, so the bar reads on a page as dark as itself; when the frame
691
742
  locks, the accent runs along it from the centre. */}
692
743
  <div