@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.
- package/README.md +27 -27
- package/cli/struq.mjs +2 -2
- package/package.json +6 -6
- package/skills/struq/SKILL.md +145 -89
- package/skills/struq/blocks/CATALOG.md +74 -22
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +34 -12
- package/skills/struq/blocks/backgrounds/background-facets.tsx +14 -6
- package/skills/struq/blocks/backgrounds/background-silk.tsx +40 -15
- package/skills/struq/blocks/catalog.json +697 -16
- package/skills/struq/blocks/film.json +426 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +81 -55
- package/skills/struq/blocks/footers/footer-reveal.tsx +1 -1
- package/skills/struq/blocks/headers/header-bar.tsx +24 -13
- package/skills/struq/blocks/headers/menu-curtain.tsx +24 -11
- package/skills/struq/blocks/heroes/hero-framed.tsx +33 -20
- package/skills/struq/blocks/heroes/hero-index.tsx +32 -11
- package/skills/struq/blocks/heroes/hero-poster.tsx +68 -35
- package/skills/struq/blocks/heroes/hero-split.tsx +22 -9
- package/skills/struq/blocks/heroes/hero-still.tsx +80 -74
- package/skills/struq/blocks/kit/accent.ts +82 -0
- package/skills/struq/blocks/kit/blocks.css +78 -35
- package/skills/struq/blocks/kit/quiet/Close.tsx +73 -0
- package/skills/struq/blocks/kit/quiet/PhotoText.tsx +79 -0
- package/skills/struq/blocks/kit/quiet/Rows.tsx +81 -0
- package/skills/struq/blocks/kit/quiet/Statement.tsx +157 -0
- package/skills/struq/blocks/kit/quiet/index.ts +7 -0
- package/skills/struq/blocks/kit/quiet/sheet.ts +12 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +87 -36
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +116 -46
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +10 -7
- package/skills/struq/blocks/sections/contact-form.tsx +1 -1
- package/skills/struq/blocks/sections/cta-closing.tsx +3 -2
- package/skills/struq/blocks/sections/fanned-cards.tsx +14 -10
- package/skills/struq/blocks/sections/film-strip.tsx +18 -14
- package/skills/struq/blocks/sections/grid-assembly.tsx +16 -11
- package/skills/struq/blocks/sections/manifesto.tsx +3 -2
- package/skills/struq/blocks/sections/panel-accordion.tsx +3 -2
- package/skills/struq/blocks/sections/split-pair.tsx +4 -3
- package/skills/struq/blocks/showpieces/hero-opener.tsx +76 -17
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +3 -1
- package/skills/struq/blocks/transitions/zoom-through.tsx +9 -10
- package/skills/struq/canon/canon.json +1 -1
- package/skills/struq/references/craft.md +4 -0
- package/skills/struq/references/film.md +158 -0
- package/skills/struq/references/iconography.md +1 -1
- package/skills/struq/references/triage.md +3 -0
- package/skills/struq/scripts/blocks.mjs +89 -7
- package/skills/struq/scripts/canon.mjs +8 -1
- package/skills/struq/scripts/check.mjs +99 -15
- package/skills/struq/scripts/film-check.mjs +997 -0
- package/skills/struq/scripts/gate.mjs +146 -0
- package/skills/struq/scripts/images.mjs +64 -37
- 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
|
|
4
|
-
// top and bottom, each carrying one half of a
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
27
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
495
|
-
|
|
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
|
-
|
|
503
|
-
<
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
|
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(
|
|
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
|
-
<
|
|
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
|