@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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// components/struq/kit/feel.ts (Struq recipe)
|
|
2
|
+
// The feel tokens: how everything on a Struq site moves, as three whole temperaments plus the values that never vary.
|
|
3
|
+
// A site picks one temperament with data-temperament="crisp|weighted|slow" on <body> (weighted when absent); any
|
|
4
|
+
// element may carry the attribute too, and the nearest one wins. Never mix values from two temperaments.
|
|
5
|
+
//
|
|
6
|
+
// - crisp: short, small travel, tight stagger, a hard curtain. A studio, a tool, a conference.
|
|
7
|
+
// - weighted: the house feel. expo.out arrivals, power3.inOut structure, power2.in exits, durations that grow with size.
|
|
8
|
+
// - slow: long, large travel, loose stagger, a soft curtain. A restaurant, ceramics, fashion.
|
|
9
|
+
//
|
|
10
|
+
// const f = feel(element); // the set for that element (SSR: weighted)
|
|
11
|
+
// gsap.from(lines, { yPercent: f.travel.text, duration: f.duration.line, ease: f.ease.enter, stagger: f.stagger.line });
|
|
12
|
+
// gsap.to(frame, { clipPath: "inset(0%)", duration: f.dur("curtain", frame.offsetHeight), ease: f.ease.curtain });
|
|
13
|
+
//
|
|
14
|
+
// CSS-only parts read the same set as custom properties (blocks.css, "--- motion ---"): --ease-enter, --dur-enter,
|
|
15
|
+
// --dur-hover-line and the rest, scoped by [data-temperament]. feelVars() returns exactly those, and a unit test holds
|
|
16
|
+
// blocks.css to it.
|
|
17
|
+
//
|
|
18
|
+
// Locked values (LOCKED) are correctness, not taste, and are the same object in every temperament: the reduced-motion
|
|
19
|
+
// rule (final state, no movement), the mask room that never clips g, j, p or y, the focus ring, the reveal trigger.
|
|
20
|
+
|
|
21
|
+
export const TEMPERAMENTS = ["crisp", "weighted", "slow"] as const;
|
|
22
|
+
export type Temperament = (typeof TEMPERAMENTS)[number];
|
|
23
|
+
export const DEFAULT_TEMPERAMENT: Temperament = "weighted";
|
|
24
|
+
|
|
25
|
+
/** What a motion is doing. Scrub is always linear: the reader's hand is the ease. */
|
|
26
|
+
export type EaseRole = "enter" | "exit" | "swap" | "curtain" | "scrub";
|
|
27
|
+
/** Durations by what moves: a block or group rising, a masked line, a leave, a structural swap, a curtain, a photo settling. */
|
|
28
|
+
export type DurationRole = "enter" | "line" | "exit" | "swap" | "curtain" | "settle";
|
|
29
|
+
|
|
30
|
+
export type Feel = {
|
|
31
|
+
name: Temperament;
|
|
32
|
+
/** GSAP ease names by role. */
|
|
33
|
+
ease: Record<EaseRole, string>;
|
|
34
|
+
/** Seconds, for an element up to GROWTH_FROM px. Use dur() for anything larger. */
|
|
35
|
+
duration: Record<DurationRole, number>;
|
|
36
|
+
/** How much longer the largest element (GROWTH_TO px and up) takes than the base: 0.3 is 30% longer. */
|
|
37
|
+
growth: number;
|
|
38
|
+
/** Travel: text in yPercent of its own line (over 100 starts fully under the mask), block in px, media as the photo's start scale. */
|
|
39
|
+
travel: { text: number; block: number; media: number };
|
|
40
|
+
/** Seconds between parts. */
|
|
41
|
+
stagger: { line: number; word: number; block: number };
|
|
42
|
+
/** Milliseconds: an underline drawing, a colour turning, a fill rising. */
|
|
43
|
+
hover: { line: number; color: number; fill: number };
|
|
44
|
+
/** Press: the sink and hover lift in px, the press in and the settle back in ms. */
|
|
45
|
+
press: { sink: number; lift: number; in: number; out: number };
|
|
46
|
+
/** ScrollTrigger scrub smoothing in seconds: pinned showpieces and in-flow scrubs. */
|
|
47
|
+
scrub: { pinned: number; flow: number };
|
|
48
|
+
/** Seconds for an element of `sizePx` (its travel or its larger side) in this role; grows with size, capped. */
|
|
49
|
+
dur: (role: DurationRole, sizePx?: number) => number;
|
|
50
|
+
locked: typeof LOCKED;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Below this size nothing grows (words, lines, buttons, small cards); at GROWTH_TO the full growth applies. */
|
|
54
|
+
export const GROWTH_FROM = 480;
|
|
55
|
+
export const GROWTH_TO = 1440;
|
|
56
|
+
|
|
57
|
+
/** One set, every temperament. Correctness, not taste. */
|
|
58
|
+
export const LOCKED = Object.freeze({
|
|
59
|
+
/** Reduced motion: every primitive shows its final state at once. No fade, no travel, no scrub. */
|
|
60
|
+
reducedMotion: Object.freeze({ query: "(prefers-reduced-motion: reduce)", behaviour: "final-state" as const }),
|
|
61
|
+
/**
|
|
62
|
+
* The room a text mask reaches beyond the line box, in em of the mask: the safest of the kit (0.3em above, 0.35em
|
|
63
|
+
* below, measured over every canon face at leading 0.82) and the live site (0.42em below). Clips only vertically.
|
|
64
|
+
*/
|
|
65
|
+
mask: Object.freeze({ top: "0.3em", bottom: "0.42em" }),
|
|
66
|
+
/** Keyboard focus: a solid ring in the accent, outside the control, never moved or clipped by it. */
|
|
67
|
+
focus: Object.freeze({ width: "2px", offset: "3px", color: "var(--accent)" }),
|
|
68
|
+
/** Where an on-enter reveal fires, and that it fires once. */
|
|
69
|
+
trigger: Object.freeze({ start: "top 85%", once: true }),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** The CSS twin of every GSAP ease the sets use, so a CSS transition moves on the same curve. */
|
|
73
|
+
export const CSS_EASE: Record<string, string> = {
|
|
74
|
+
none: "linear",
|
|
75
|
+
"expo.out": "cubic-bezier(0.16, 1, 0.3, 1)",
|
|
76
|
+
"power4.out": "cubic-bezier(0.22, 1, 0.36, 1)",
|
|
77
|
+
"power1.in": "cubic-bezier(0.12, 0, 0.39, 0)",
|
|
78
|
+
"power2.in": "cubic-bezier(0.32, 0, 0.67, 0)",
|
|
79
|
+
"power3.in": "cubic-bezier(0.5, 0, 0.75, 0)",
|
|
80
|
+
"power2.inOut": "cubic-bezier(0.65, 0, 0.35, 1)",
|
|
81
|
+
"power3.inOut": "cubic-bezier(0.76, 0, 0.24, 1)",
|
|
82
|
+
"power4.inOut": "cubic-bezier(0.83, 0, 0.17, 1)",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
type Values = Omit<Feel, "dur" | "locked" | "name">;
|
|
86
|
+
|
|
87
|
+
const VALUES: Record<Temperament, Values> = {
|
|
88
|
+
crisp: {
|
|
89
|
+
ease: { enter: "power4.out", exit: "power3.in", swap: "power4.inOut", curtain: "power4.inOut", scrub: "none" },
|
|
90
|
+
duration: { enter: 0.6, line: 0.85, exit: 0.25, swap: 0.7, curtain: 0.9, settle: 1.2 },
|
|
91
|
+
growth: 0.2,
|
|
92
|
+
travel: { text: 105, block: 24, media: 1.15 },
|
|
93
|
+
stagger: { line: 0.06, word: 0.03, block: 0.05 },
|
|
94
|
+
hover: { line: 240, color: 180, fill: 320 },
|
|
95
|
+
press: { sink: 1, lift: 1, in: 90, out: 360 },
|
|
96
|
+
scrub: { pinned: 0.6, flow: 0.25 },
|
|
97
|
+
},
|
|
98
|
+
// The house feel, as STANDARD_INVENTORY.md section 1.8 measured it in the blocks.
|
|
99
|
+
weighted: {
|
|
100
|
+
ease: { enter: "expo.out", exit: "power2.in", swap: "power3.inOut", curtain: "power4.inOut", scrub: "none" },
|
|
101
|
+
duration: { enter: 0.9, line: 1.25, exit: 0.35, swap: 1, curtain: 1.4, settle: 1.8 },
|
|
102
|
+
growth: 0.3,
|
|
103
|
+
travel: { text: 115, block: 48, media: 1.3 },
|
|
104
|
+
stagger: { line: 0.09, word: 0.045, block: 0.08 },
|
|
105
|
+
hover: { line: 360, color: 280, fill: 500 },
|
|
106
|
+
press: { sink: 1, lift: 2, in: 120, out: 600 },
|
|
107
|
+
scrub: { pinned: 1, flow: 0.4 },
|
|
108
|
+
},
|
|
109
|
+
slow: {
|
|
110
|
+
ease: { enter: "expo.out", exit: "power1.in", swap: "power2.inOut", curtain: "power2.inOut", scrub: "none" },
|
|
111
|
+
duration: { enter: 1.3, line: 1.7, exit: 0.5, swap: 1.4, curtain: 1.9, settle: 2.4 },
|
|
112
|
+
growth: 0.4,
|
|
113
|
+
travel: { text: 125, block: 72, media: 1.4 },
|
|
114
|
+
stagger: { line: 0.14, word: 0.07, block: 0.12 },
|
|
115
|
+
hover: { line: 520, color: 400, fill: 750 },
|
|
116
|
+
press: { sink: 1, lift: 3, in: 160, out: 900 },
|
|
117
|
+
scrub: { pinned: 1.5, flow: 0.6 },
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
function build(name: Temperament): Feel {
|
|
122
|
+
const values = VALUES[name];
|
|
123
|
+
const dur = (role: DurationRole, sizePx = 0) => {
|
|
124
|
+
const t = Math.min(1, Math.max(0, (sizePx - GROWTH_FROM) / (GROWTH_TO - GROWTH_FROM)));
|
|
125
|
+
return Math.round(values.duration[role] * (1 + values.growth * t) * 1000) / 1000;
|
|
126
|
+
};
|
|
127
|
+
return Object.freeze({ name, ...values, dur, locked: LOCKED });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The three sets, built once. */
|
|
131
|
+
export const FEELS: Record<Temperament, Feel> = {
|
|
132
|
+
crisp: build("crisp"),
|
|
133
|
+
weighted: build("weighted"),
|
|
134
|
+
slow: build("slow"),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** A temperament name, or weighted for anything else. */
|
|
138
|
+
export function temperamentOf(value: string | null | undefined): Temperament {
|
|
139
|
+
return (TEMPERAMENTS as readonly string[]).includes(value ?? "") ? (value as Temperament) : DEFAULT_TEMPERAMENT;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The feel for a temperament name, or for an element (its nearest [data-temperament], then <body>). With no argument
|
|
144
|
+
* it reads <body>. Server-side, and for an unknown name, it is weighted.
|
|
145
|
+
*/
|
|
146
|
+
export function feel(source?: Temperament | string | Element | null): Feel {
|
|
147
|
+
if (typeof source === "string") return FEELS[temperamentOf(source)];
|
|
148
|
+
if (typeof document === "undefined") return FEELS[DEFAULT_TEMPERAMENT];
|
|
149
|
+
const holder = source?.closest?.("[data-temperament]") ?? document.body;
|
|
150
|
+
return FEELS[temperamentOf(holder?.getAttribute("data-temperament"))];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Whether the reader asked for reduced motion. False on the server. */
|
|
154
|
+
export function prefersReduced(): boolean {
|
|
155
|
+
return typeof window !== "undefined" && window.matchMedia(LOCKED.reducedMotion.query).matches;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The custom properties blocks.css sets for a temperament (the "--- motion ---" section). */
|
|
159
|
+
export function feelVars(name: Temperament): Record<string, string> {
|
|
160
|
+
const f = FEELS[name];
|
|
161
|
+
const s = (seconds: number) => `${seconds}s`;
|
|
162
|
+
const ms = (value: number) => `${value}ms`;
|
|
163
|
+
return {
|
|
164
|
+
"--ease-enter": CSS_EASE[f.ease.enter],
|
|
165
|
+
"--ease-exit": CSS_EASE[f.ease.exit],
|
|
166
|
+
"--ease-swap": CSS_EASE[f.ease.swap],
|
|
167
|
+
"--ease-curtain": CSS_EASE[f.ease.curtain],
|
|
168
|
+
"--dur-enter": s(f.duration.enter),
|
|
169
|
+
"--dur-line": s(f.duration.line),
|
|
170
|
+
"--dur-exit": s(f.duration.exit),
|
|
171
|
+
"--dur-swap": s(f.duration.swap),
|
|
172
|
+
"--dur-curtain": s(f.duration.curtain),
|
|
173
|
+
"--dur-hover-line": ms(f.hover.line),
|
|
174
|
+
"--dur-hover-color": ms(f.hover.color),
|
|
175
|
+
"--dur-hover-fill": ms(f.hover.fill),
|
|
176
|
+
"--dur-press-in": ms(f.press.in),
|
|
177
|
+
"--dur-press-out": ms(f.press.out),
|
|
178
|
+
"--press-sink": `${f.press.sink}px`,
|
|
179
|
+
"--press-lift": `${f.press.lift}px`,
|
|
180
|
+
"--travel-block": `${f.travel.block}px`,
|
|
181
|
+
"--stagger-word": s(f.stagger.word),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -5,17 +5,20 @@ import gsap from "gsap";
|
|
|
5
5
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
6
6
|
import { SplitText } from "gsap/SplitText";
|
|
7
7
|
import { useGSAP } from "@gsap/react";
|
|
8
|
+
import { FEELS } from "./feel";
|
|
8
9
|
|
|
9
10
|
gsap.registerPlugin(ScrollTrigger, SplitText, useGSAP);
|
|
10
11
|
|
|
11
|
-
// The Struq entrance: expo.out inside the reveal band.
|
|
12
|
-
|
|
12
|
+
// The Struq entrance: the weighted (house) arrival, expo.out inside the reveal band.
|
|
13
|
+
const HOUSE = FEELS.weighted;
|
|
14
|
+
gsap.defaults({ ease: HOUSE.ease.enter, duration: HOUSE.duration.enter });
|
|
13
15
|
|
|
16
|
+
/** The house eases by role, from the weighted feel (./feel). Per-site motion reads feel() instead. */
|
|
14
17
|
export const EASE = {
|
|
15
|
-
enter:
|
|
16
|
-
exit:
|
|
17
|
-
swap:
|
|
18
|
-
curtain:
|
|
18
|
+
enter: HOUSE.ease.enter,
|
|
19
|
+
exit: HOUSE.ease.exit,
|
|
20
|
+
swap: HOUSE.ease.swap,
|
|
21
|
+
curtain: HOUSE.ease.curtain,
|
|
19
22
|
} as const;
|
|
20
23
|
|
|
21
24
|
export { gsap, ScrollTrigger, SplitText, useGSAP };
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/kit/middle/PlaceHours.tsx (Struq recipe)
|
|
3
|
+
// Where to find the business and when, given real space instead of a footer line: the middle scene for a bakery, a
|
|
4
|
+
// restaurant, a guesthouse, the town a plumber serves. Three variants, each extracted from a source block:
|
|
5
|
+
//
|
|
6
|
+
// - "timetable": the city set large on the left, the ways in as ruled rows on the right like a timetable (address,
|
|
7
|
+
// phone, then one row per run of opening hours), each only when given; with hours and a time zone a calm open marker
|
|
8
|
+
// computed from the real local time sits over the rows. From footers/footer-contact (its ruled rows and open marker;
|
|
9
|
+
// the hours logic is ./hours, shared with that footer).
|
|
10
|
+
// - "plate": a full-width photo of the street or the room opens out of the page margins to the edges, the place line
|
|
11
|
+
// large under it and the given ways in beside it. From the plaat variant of sections/about-studio.
|
|
12
|
+
// - "line": type only: one sentence about the place at moment size, the city as its accent word, a small upright photo
|
|
13
|
+
// hanging off the last line. From kit/quiet/Statement (rendered by it).
|
|
14
|
+
//
|
|
15
|
+
// <PlaceHours variant="timetable" city="Utrecht" area="Lombok, by the canal" line="Bread from four in the morning."
|
|
16
|
+
// address={{ label: "Kanaalstraat 12", href: "https://maps.example/kanaalstraat-12" }}
|
|
17
|
+
// phone={{ label: "030 123 45 67", href: "tel:+31301234567" }}
|
|
18
|
+
// hours={[{ days: [2, 3, 4, 5, 6], from: "7:00", to: "17:00" }, { days: [7], from: "8:00", to: "13:00" }]}
|
|
19
|
+
// timeZone="Europe/Amsterdam" />
|
|
20
|
+
// <PlaceHours variant="line" city="Utrecht" line="A family plumber for every street in Utrecht." image={images.column[0]} />
|
|
21
|
+
//
|
|
22
|
+
// Props
|
|
23
|
+
// - city: required, the town or city as the brief gives it.
|
|
24
|
+
// - line: required, one sentence about the place in the client's voice, naming the city (plate and line set it large
|
|
25
|
+
// with the city as the accent word; timetable sets it under the city). A city alone read as a placeholder in the lab
|
|
26
|
+
// (2026-11-15), and the sentence is copy the agent writes, never a fact it guesses.
|
|
27
|
+
// - area: optional, small, sentence case: the part of town, the street's character.
|
|
28
|
+
// - image: optional StruqImage: plate's wide photo (landscape works best), line's small upright photo.
|
|
29
|
+
// - address, phone: optional { label, href }; each a HoverLink door with a tap target of at least 44px.
|
|
30
|
+
// - hours: optional HoursRow[] ({ days: 1 Monday to 7 Sunday, from: "9:00", to: "18:00" }), printed as given.
|
|
31
|
+
// - timeZone: optional IANA zone. The open marker shows only with both hours and timeZone, and only after mount.
|
|
32
|
+
// - lang: "en" (default) or "nl": the words of the rows and the marker (labels, days, "Open now"). Times as given.
|
|
33
|
+
// - look: the site's one HoverLink look ("draw" default, "slide", "color").
|
|
34
|
+
// - variant: "line" (default), "timetable" or "plate". tone: "ground" (default), "inverse" or "field". className: root.
|
|
35
|
+
//
|
|
36
|
+
// Empty slots and fallbacks (code, not advice): no address, no phone and no hours, "timetable" renders "line"; no
|
|
37
|
+
// image, "plate" renders "line"; "line" without an image is the sentence alone, the city as its accent word. Every optional fact has no default:
|
|
38
|
+
// no hours means no hours rows, no marker and no "open daily"; a missing address is never a street, a postcode or a
|
|
39
|
+
// map pin; a missing phone is no row. resolvePlaceVariant() returns the variant that renders.
|
|
40
|
+
//
|
|
41
|
+
// Mounting: a page section of its own. The root carries data-scene="place", data-variant (the rendered one),
|
|
42
|
+
// data-energy="1" and data-middle.
|
|
43
|
+
//
|
|
44
|
+
// Motion (kit/feel.ts, data-temperament on <body>): one timeline from one trigger (./entrance): the city or the photo
|
|
45
|
+
// first, then the ways in one row at a time. The loud move is the plate opening out of the margins over
|
|
46
|
+
// dur("curtain") on the feel's curtain ease, the photo settling from the media scale: crisp is a hard curtain, slow a
|
|
47
|
+
// soft one, and slow lets the photo drift in its frame with the scroll after it settles. line moves as Statement does.
|
|
48
|
+
// Reduced motion: the composed scene. Without scripts: composed; the marker never renders on the server.
|
|
49
|
+
//
|
|
50
|
+
// Phones: the city wraps to two lines at most, then the photo, then the ways in as full-width rows, each a tap target
|
|
51
|
+
// of at least 44px.
|
|
52
|
+
import Image from "next/image";
|
|
53
|
+
import { useRef } from "react";
|
|
54
|
+
import { withAccent } from "../accent";
|
|
55
|
+
import { feel, prefersReduced } from "../feel";
|
|
56
|
+
import { gsap, useGSAP } from "../gsap";
|
|
57
|
+
import type { StruqImage } from "../images";
|
|
58
|
+
import { HoverLink } from "../motion/HoverLink";
|
|
59
|
+
import { Statement } from "../quiet/Statement";
|
|
60
|
+
import { LABEL, SHEET_GRID } from "../quiet/sheet";
|
|
61
|
+
import { entrance } from "./entrance";
|
|
62
|
+
import { hoursLines, openStatus, useNow, wallClock, type HoursLang, type HoursRow } from "./hours";
|
|
63
|
+
import { TONE_CLASS, type SceneTone } from "./ServicesList";
|
|
64
|
+
|
|
65
|
+
export type PlaceDoor = { label: string; href: string };
|
|
66
|
+
export type PlaceVariant = "timetable" | "plate" | "line";
|
|
67
|
+
|
|
68
|
+
type Props = {
|
|
69
|
+
city: string;
|
|
70
|
+
line: string;
|
|
71
|
+
area?: string;
|
|
72
|
+
image?: StruqImage;
|
|
73
|
+
address?: PlaceDoor;
|
|
74
|
+
phone?: PlaceDoor;
|
|
75
|
+
hours?: HoursRow[];
|
|
76
|
+
timeZone?: string;
|
|
77
|
+
lang?: HoursLang;
|
|
78
|
+
look?: "draw" | "slide" | "color";
|
|
79
|
+
variant?: PlaceVariant;
|
|
80
|
+
tone?: SceneTone;
|
|
81
|
+
className?: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const LABELS: Record<HoursLang, { address: string; phone: string }> = {
|
|
85
|
+
en: { address: "Address", phone: "Phone" },
|
|
86
|
+
nl: { address: "Adres", phone: "Telefoon" },
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** The variant that renders: timetable needs at least one way in, plate needs its photo. */
|
|
90
|
+
export function resolvePlaceVariant(variant: PlaceVariant, place: Pick<Props, "address" | "phone" | "hours" | "image">): PlaceVariant {
|
|
91
|
+
const facts = Boolean(place.address || place.phone || place.hours?.length);
|
|
92
|
+
if (variant === "timetable" && !facts) return "line";
|
|
93
|
+
if (variant === "plate" && !place.image) return "line";
|
|
94
|
+
return variant;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The open marker: a small ring, filled while open, and the words. Renders nothing until the time is known. */
|
|
98
|
+
function OpenMark({ hours, timeZone, lang }: { hours: HoursRow[]; timeZone: string; lang: HoursLang }) {
|
|
99
|
+
const now = useNow(true);
|
|
100
|
+
const status = now ? openStatus(hours, wallClock(now, timeZone), lang) : null;
|
|
101
|
+
return (
|
|
102
|
+
// The line keeps its height before the time is known, so the rows never move when the marker arrives.
|
|
103
|
+
<p className="m-0 mb-4 min-h-[1lh] text-small text-ink-muted">
|
|
104
|
+
{status?.text ? (
|
|
105
|
+
<span data-open-mark="" className="inline-flex items-center gap-[0.6em]">
|
|
106
|
+
<span aria-hidden data-open-dot="" data-open={status.open ? "" : undefined} />
|
|
107
|
+
<span>{status.text}</span>
|
|
108
|
+
</span>
|
|
109
|
+
) : null}
|
|
110
|
+
</p>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The ways in as ruled rows: address, phone, then each run of hours. Only what was given; nothing when nothing was. */
|
|
115
|
+
function Facts({ address, phone, hours, timeZone, lang, look, className = "" }: Pick<Props, "address" | "phone" | "hours" | "timeZone" | "look"> & { lang: HoursLang; className?: string }) {
|
|
116
|
+
const lines = hours?.length ? hoursLines(hours, lang) : [];
|
|
117
|
+
if (!address && !phone && !lines.length) return null;
|
|
118
|
+
const row = "grid grid-cols-[6.5rem_1fr] items-baseline gap-x-6 border-t border-line py-2 md:grid-cols-[8rem_1fr]";
|
|
119
|
+
const door = (d: PlaceDoor) => (
|
|
120
|
+
<HoverLink href={d.href} look={look} className="inline-flex min-h-11 items-center text-ink">
|
|
121
|
+
{d.label}
|
|
122
|
+
</HoverLink>
|
|
123
|
+
);
|
|
124
|
+
return (
|
|
125
|
+
<div className={className}>
|
|
126
|
+
{hours?.length && timeZone ? <OpenMark hours={hours} timeZone={timeZone} lang={lang} /> : null}
|
|
127
|
+
<dl className="m-0 border-b border-line text-body">
|
|
128
|
+
{address ? (
|
|
129
|
+
<div data-part="detail" className={row}>
|
|
130
|
+
<dt className="text-small text-ink-muted">{LABELS[lang].address}</dt>
|
|
131
|
+
<dd className="m-0 min-w-0">{door(address)}</dd>
|
|
132
|
+
</div>
|
|
133
|
+
) : null}
|
|
134
|
+
{phone ? (
|
|
135
|
+
<div data-part="detail" className={row}>
|
|
136
|
+
<dt className="text-small text-ink-muted">{LABELS[lang].phone}</dt>
|
|
137
|
+
<dd className="m-0 min-w-0">{door(phone)}</dd>
|
|
138
|
+
</div>
|
|
139
|
+
) : null}
|
|
140
|
+
{lines.map((l) => (
|
|
141
|
+
<div key={l.days} data-part="detail" className={row}>
|
|
142
|
+
<dt className="text-small text-ink-muted">{l.days}</dt>
|
|
143
|
+
<dd className="m-0 flex min-h-11 items-center">{l.time}</dd>
|
|
144
|
+
</div>
|
|
145
|
+
))}
|
|
146
|
+
</dl>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function useEntrance(ref: React.RefObject<HTMLElement | null>, key: string, drift = false) {
|
|
152
|
+
useGSAP(
|
|
153
|
+
() => {
|
|
154
|
+
const el = ref.current;
|
|
155
|
+
if (!el) return;
|
|
156
|
+
const stop = entrance(el);
|
|
157
|
+
const layer = el.querySelector<HTMLElement>("[data-drift]");
|
|
158
|
+
// Slow only: once settled, the photo drifts in its frame with the scroll (its layer reaches past the frame).
|
|
159
|
+
if (!drift || !layer || prefersReduced() || feel(el).name !== "slow") return stop;
|
|
160
|
+
const f = feel(el);
|
|
161
|
+
const tween = gsap.fromTo(
|
|
162
|
+
layer,
|
|
163
|
+
{ yPercent: 4 },
|
|
164
|
+
{ yPercent: -4, ease: f.ease.scrub, scrollTrigger: { trigger: layer.parentElement, start: "top bottom", end: "bottom top", scrub: f.scrub.flow } },
|
|
165
|
+
);
|
|
166
|
+
return () => {
|
|
167
|
+
stop();
|
|
168
|
+
tween.scrollTrigger?.kill();
|
|
169
|
+
tween.revert();
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
{ scope: ref, dependencies: [key] },
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function Timetable(props: Props & { tone: SceneTone; className: string; lang: HoursLang }) {
|
|
177
|
+
const { city, area, line, tone, className, lang } = props;
|
|
178
|
+
const ref = useRef<HTMLElement>(null);
|
|
179
|
+
useEntrance(ref, "timetable");
|
|
180
|
+
return (
|
|
181
|
+
<section ref={ref} data-scene="place" data-variant="timetable" data-energy="1" data-middle="" className={`py-28 text-ink md:py-40 ${TONE_CLASS[tone]} ${className}`}>
|
|
182
|
+
<div className={`items-start gap-y-12 ${SHEET_GRID}`}>
|
|
183
|
+
<div className="col-span-12 md:col-span-7">
|
|
184
|
+
<h2 data-part="heading" className="m-0 font-display text-display [overflow-wrap:break-word]">
|
|
185
|
+
{city}
|
|
186
|
+
</h2>
|
|
187
|
+
{area ? (
|
|
188
|
+
<p data-part="detail" className={`mt-6 ${LABEL}`}>
|
|
189
|
+
{area}
|
|
190
|
+
</p>
|
|
191
|
+
) : null}
|
|
192
|
+
<p data-part="detail" className="m-0 mt-8 max-w-[34ch] text-lead text-ink-muted">
|
|
193
|
+
{line}
|
|
194
|
+
</p>
|
|
195
|
+
</div>
|
|
196
|
+
<Facts {...props} lang={lang} className="col-span-12 md:col-span-4 md:col-start-9 md:pt-[0.6rem]" />
|
|
197
|
+
</div>
|
|
198
|
+
</section>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function Plate(props: Props & { image: StruqImage; tone: SceneTone; className: string; lang: HoursLang }) {
|
|
203
|
+
const { city, area, line, image, tone, className, lang } = props;
|
|
204
|
+
const ref = useRef<HTMLElement>(null);
|
|
205
|
+
useEntrance(ref, "plate", true);
|
|
206
|
+
return (
|
|
207
|
+
<section ref={ref} data-scene="place" data-variant="plate" data-energy="1" data-middle="" className={`pb-28 pt-16 text-ink md:pb-40 md:pt-24 ${TONE_CLASS[tone]} ${className}`}>
|
|
208
|
+
<div data-part="media" data-open="margins" className="relative aspect-[4/3] w-full overflow-hidden md:aspect-[21/9]">
|
|
209
|
+
{/* The photo sits on a layer larger than its frame, so its drift never shows an edge. */}
|
|
210
|
+
<div data-drift="" className="absolute inset-x-0 -bottom-[10%] -top-[10%]">
|
|
211
|
+
<Image src={image.src} alt={image.alt} fill sizes="100vw" unoptimized={image.unoptimized} className="object-cover" />
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
<div className={`mt-12 items-start gap-y-10 md:mt-20 ${SHEET_GRID}`}>
|
|
215
|
+
<h2 data-part="heading" className="col-span-12 m-0 max-w-[16ch] font-display text-display [overflow-wrap:break-word] md:col-span-7">
|
|
216
|
+
{withAccent(line, city)}
|
|
217
|
+
</h2>
|
|
218
|
+
<div className="col-span-12 md:col-span-4 md:col-start-9 md:pt-[0.6rem]">
|
|
219
|
+
<p data-part="detail" className={`mb-8 ${LABEL}`}>
|
|
220
|
+
{area ? `${area}, ${city}` : city}
|
|
221
|
+
</p>
|
|
222
|
+
<Facts {...props} lang={lang} />
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</section>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function Line(props: Props & { tone: SceneTone; className: string; lang: HoursLang }) {
|
|
230
|
+
const { city, area, line, image, tone, className, lang } = props;
|
|
231
|
+
const ref = useRef<HTMLElement>(null);
|
|
232
|
+
useEntrance(ref, "line");
|
|
233
|
+
const facts = Boolean(props.address || props.phone || props.hours?.length);
|
|
234
|
+
return (
|
|
235
|
+
<section ref={ref} data-scene="place" data-variant="line" data-energy="1" data-middle="" className={`py-28 text-ink md:py-40 ${TONE_CLASS[tone]} ${className}`}>
|
|
236
|
+
<Statement accent={city} label={area} image={image} className="py-0! md:py-0!">
|
|
237
|
+
{line}
|
|
238
|
+
</Statement>
|
|
239
|
+
{facts ? (
|
|
240
|
+
<div className={`mt-16 md:mt-24 ${SHEET_GRID}`}>
|
|
241
|
+
<Facts {...props} lang={lang} className="col-span-12 md:col-span-5 md:col-start-8" />
|
|
242
|
+
</div>
|
|
243
|
+
) : null}
|
|
244
|
+
</section>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function PlaceHours({ variant = "line", tone = "ground", lang = "en", className = "", ...place }: Props) {
|
|
249
|
+
const shown = resolvePlaceVariant(variant, place);
|
|
250
|
+
const common = { ...place, tone, lang, className };
|
|
251
|
+
if (shown === "timetable") return <Timetable {...common} />;
|
|
252
|
+
if (shown === "plate" && place.image) return <Plate {...common} image={place.image} />;
|
|
253
|
+
return <Line {...common} />;
|
|
254
|
+
}
|