@struq/skill 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +137 -100
  2. package/package.json +5 -3
  3. package/skills/struq/SKILL.md +168 -165
  4. package/skills/struq/blocks/CATALOG.md +49 -46
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +2 -1
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +5 -3
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +6 -2
  8. package/skills/struq/blocks/catalog.json +50 -5
  9. package/skills/struq/blocks/film.json +11 -7
  10. package/skills/struq/blocks/footers/footer-contact.tsx +8 -78
  11. package/skills/struq/blocks/headers/header-bar.tsx +144 -29
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +36 -19
  13. package/skills/struq/blocks/heroes/hero-poster.tsx +36 -10
  14. package/skills/struq/blocks/kit/HeroCanvas.tsx +6 -2
  15. package/skills/struq/blocks/kit/SplitHeadline.tsx +2 -1
  16. package/skills/struq/blocks/kit/accent.ts +29 -3
  17. package/skills/struq/blocks/kit/blocks.css +530 -6
  18. package/skills/struq/blocks/kit/feel.ts +183 -0
  19. package/skills/struq/blocks/kit/gsap.ts +9 -6
  20. package/skills/struq/blocks/kit/middle/PlaceHours.tsx +254 -0
  21. package/skills/struq/blocks/kit/middle/ProcessStrip.tsx +428 -0
  22. package/skills/struq/blocks/kit/middle/ServicesList.tsx +496 -0
  23. package/skills/struq/blocks/kit/middle/entrance.ts +98 -0
  24. package/skills/struq/blocks/kit/middle/hours.ts +154 -0
  25. package/skills/struq/blocks/kit/middle/index.ts +14 -0
  26. package/skills/struq/blocks/kit/motion/HoverLink.tsx +33 -0
  27. package/skills/struq/blocks/kit/motion/ImageCurtain.tsx +79 -0
  28. package/skills/struq/blocks/kit/motion/MaskWipe.tsx +91 -0
  29. package/skills/struq/blocks/kit/motion/Pressable.tsx +58 -0
  30. package/skills/struq/blocks/kit/motion/RevealText.tsx +103 -0
  31. package/skills/struq/blocks/kit/motion/Rise.tsx +77 -0
  32. package/skills/struq/blocks/kit/motion/ScrubLine.tsx +108 -0
  33. package/skills/struq/blocks/kit/motion/index.ts +11 -0
  34. package/skills/struq/blocks/kit/quiet/Close.tsx +178 -52
  35. package/skills/struq/blocks/kit/quiet/Statement.tsx +3 -2
  36. package/skills/struq/blocks/kit/subject/AppScreen.tsx +388 -0
  37. package/skills/struq/blocks/kit/subject/Document.tsx +742 -0
  38. package/skills/struq/blocks/kit/subject/Mark.tsx +44 -0
  39. package/skills/struq/blocks/kit/subject/PatternPaper.tsx +410 -0
  40. package/skills/struq/blocks/kit/subject/SectionDrawing.tsx +349 -0
  41. package/skills/struq/blocks/kit/subject/Ticket.tsx +354 -0
  42. package/skills/struq/blocks/kit/subject/build.ts +66 -0
  43. package/skills/struq/blocks/kit/subject/draw.ts +196 -0
  44. package/skills/struq/blocks/kit/subject/drawings.ts +373 -0
  45. package/skills/struq/blocks/kit/subject/format.ts +122 -0
  46. package/skills/struq/blocks/kit/subject/index.ts +11 -0
  47. package/skills/struq/blocks/kit/subject/patterns.ts +204 -0
  48. package/skills/struq/blocks/preloaders/preloader-stack.tsx +75 -22
  49. package/skills/struq/blocks/showpieces/hero-opener.tsx +55 -7
  50. package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +64 -6
  51. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +56 -3
  52. package/skills/struq/blocks/showpieces/panel-wipe.tsx +93 -11
  53. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +38 -5
  54. package/skills/struq/canon/canon.json +1 -1
  55. package/skills/struq/references/art-direction.md +51 -0
  56. package/skills/struq/scripts/blocks.mjs +56 -20
  57. package/skills/struq/scripts/canon.mjs +36 -7
  58. package/skills/struq/scripts/check.mjs +417 -11
  59. package/skills/struq/scripts/draw.mjs +49 -0
  60. package/skills/struq/scripts/film-check.mjs +28 -4
  61. package/skills/struq/scripts/gate.mjs +13 -3
  62. package/skills/struq/scripts/hand.mjs +222 -0
  63. package/skills/struq/scripts/images.mjs +12 -3
  64. package/skills/struq/scripts/look.mjs +1184 -0
  65. package/skills/struq/scripts/stamp.mjs +47 -0
@@ -0,0 +1,496 @@
1
+ "use client";
2
+ // components/struq/kit/middle/ServicesList.tsx (Struq recipe)
3
+ // What the business does, as named offers with one sentence each: the middle scene for a plumber's repairs and
4
+ // renovations, a firm's areas of law, a studio's classes, an app's jobs. Never an icon grid, never numbered. Three
5
+ // variants, each extracted from a source block:
6
+ //
7
+ // - "rows": large hairline rows; hover or focus floods a row with the accent from its foot, the name turns to the
8
+ // accent's ink and nudges in; the sentence beside it always shows and lifts with the flood. From
9
+ // showpieces/fill-rows. Three to six services.
10
+ // - "panels": three to five tall photo panels side by side; hover or focus opens one wide while the others narrow,
11
+ // its name rising through a mask with its sentence. From sections/panel-accordion. Needs a photo per service.
12
+ // - "pair": two services only: two halves side by side, each a photo, the name at h2 and its sentences, a rule
13
+ // drawing between them with the scroll. From kit/quiet/PhotoText and the rule of kit/motion/ScrubLine.
14
+ //
15
+ // <ServicesList variant="rows" heading="What we fix, and what we build." services={[
16
+ // { name: "Repairs", text: "Leaks, blockages and boilers, the same week you call." },
17
+ // { name: "Bathroom renovations", text: "From the first drawing to the last tile, one team.", image: images.card[0] },
18
+ // ]} />
19
+ //
20
+ // Props
21
+ // - services: { name, text, image?, href? }[]. name: the offer in one to three words. text: one sentence (pair takes
22
+ // two). image: a StruqImage. href: makes the row or panel a link; without it the row is not a link, still focusable
23
+ // so its hover state is reachable by keyboard.
24
+ // - variant: "rows" (default), "panels" or "pair".
25
+ // - heading: optional, at h2, opens the scene. Without it the services open the section.
26
+ // - tone: "ground" (default), "inverse" or "field": the scene's ground (scene-inverse, scene-field in blocks.css).
27
+ // - className: on the root.
28
+ //
29
+ // Empty slots and fallbacks (code, not advice): "panels" with any service missing its photo renders "rows"; "pair"
30
+ // with other than two services renders "rows"; one service is not a list (use kit/quiet/Statement), given one it
31
+ // renders as a single row. A pair half without a photo is its name and sentences alone. No price, "from" figure,
32
+ // duration or guarantee: there is no slot for one. resolveServicesVariant() returns the variant that will render.
33
+ //
34
+ // Mounting: a page section of its own. The root carries data-scene="services", data-variant (the rendered one),
35
+ // data-energy="1" and data-middle. Energy 1: no pin.
36
+ //
37
+ // Motion (kit/feel.ts, data-temperament on <body>): one timeline from one trigger (./entrance): the heading's lines
38
+ // rise, then the photos open from their foot, then the rows or halves rise one after another. The loud move is the
39
+ // hover: rows flood over the feel's --dur-hover-fill on --ease-enter, the sentence following a quarter of that later
40
+ // (crisp a quick fill, slow a soft flood with the sentence drifting in after); panels open over duration.swap on the
41
+ // enter ease and retarget from wherever they are. Pair's rule is scrubbed (ScrubLine). Reduced motion: the composed
42
+ // scene, hover states cut in, panels open as hard cuts. Without scripts: the composed scene, every panel a link.
43
+ //
44
+ // Phones: rows show every sentence under its name; panels stack as short rows that open on tap (a tap
45
+ // on the open one follows its link); pair stacks, the rule drawn across between the halves.
46
+ import Image from "next/image";
47
+ import { useRef } from "react";
48
+ import { feel, LOCKED, prefersReduced } from "../feel";
49
+ import { gsap, ScrollTrigger, useGSAP } from "../gsap";
50
+ import type { StruqImage } from "../images";
51
+ import { ScrubLine } from "../motion/ScrubLine";
52
+ import { SHEET_GRID } from "../quiet/sheet";
53
+ import { entrance } from "./entrance";
54
+
55
+ export type Service = { name: string; text: string; image?: StruqImage; href?: string };
56
+ export type ServicesVariant = "rows" | "panels" | "pair";
57
+ export type SceneTone = "ground" | "inverse" | "field";
58
+
59
+ type Props = {
60
+ services: Service[];
61
+ variant?: ServicesVariant;
62
+ heading?: string;
63
+ tone?: SceneTone;
64
+ className?: string;
65
+ };
66
+
67
+ /** The scene classes in blocks.css for each tone. */
68
+ export const TONE_CLASS: Record<SceneTone, string> = { ground: "bg-surface-0", inverse: "scene-inverse", field: "scene-field" };
69
+
70
+ /** The variant that renders for these services: panels needs a photo each, pair needs exactly two. */
71
+ export function resolveServicesVariant(variant: ServicesVariant, services: readonly Service[]): ServicesVariant {
72
+ if (variant === "panels" && (services.length < 2 || services.some((s) => !s.image))) return "rows";
73
+ if (variant === "pair" && services.length !== 2) return "rows";
74
+ return variant;
75
+ }
76
+
77
+ /** The scene's entrance, shared by rows and pair. */
78
+ function useEntrance(ref: React.RefObject<HTMLElement | null>, key: string) {
79
+ useGSAP(
80
+ () => {
81
+ const el = ref.current;
82
+ if (!el) return;
83
+ return entrance(el);
84
+ },
85
+ { scope: ref, dependencies: [key] },
86
+ );
87
+ }
88
+
89
+ function Heading({ heading, className = "" }: { heading?: string; className?: string }) {
90
+ if (!heading) return null;
91
+ return (
92
+ <h2 data-part="heading" className={`m-0 max-w-[18ch] font-display text-h2 ${className}`}>
93
+ {heading}
94
+ </h2>
95
+ );
96
+ }
97
+
98
+ // --- rows -----------------------------------------------------------------------------------------------------------
99
+
100
+ type VariantProps<S> = { services: S; heading?: string; tone: SceneTone; className: string };
101
+
102
+ function Rows({ services, heading, tone, className }: VariantProps<Service[]>) {
103
+ const ref = useRef<HTMLElement>(null);
104
+ useEntrance(ref, `rows:${services.length}`);
105
+ return (
106
+ <section ref={ref} data-scene="services" data-variant="rows" data-energy="1" data-middle="" className={`py-28 text-ink md:py-40 ${TONE_CLASS[tone]} ${className}`}>
107
+ {heading ? (
108
+ <div className={`mb-12 md:mb-20 ${SHEET_GRID}`}>
109
+ <Heading heading={heading} className="col-span-12 md:col-span-8" />
110
+ </div>
111
+ ) : null}
112
+ <ul className="m-0 list-none border-t border-line p-0">
113
+ {services.map((service) => {
114
+ const inner = (
115
+ <>
116
+ <span aria-hidden data-flood="" className="absolute inset-0 origin-bottom bg-accent" />
117
+ <span data-flood-name="" className="relative block font-display text-[clamp(2.25rem,9vw,3.5rem)] leading-[1] tracking-[-0.03em] md:text-[5.5vw]">
118
+ {service.name}
119
+ </span>
120
+ <span data-flood-text="" className="relative block max-w-[34ch] text-body text-ink-muted">
121
+ {service.text}
122
+ </span>
123
+ </>
124
+ );
125
+ const cls = "group relative grid gap-3 overflow-hidden px-sheet-margin py-8 md:grid-cols-[1fr_auto] md:items-end md:gap-x-[4vw] md:py-[3vw]";
126
+ return (
127
+ <li key={service.name} data-part="detail" className="border-b border-line">
128
+ {service.href ? (
129
+ <a href={service.href} data-flood-row="" className={cls}>
130
+ {inner}
131
+ </a>
132
+ ) : (
133
+ <div tabIndex={0} data-flood-row="" className={cls}>
134
+ {inner}
135
+ </div>
136
+ )}
137
+ </li>
138
+ );
139
+ })}
140
+ </ul>
141
+ </section>
142
+ );
143
+ }
144
+
145
+ // --- panels ---------------------------------------------------------------------------------------------------------
146
+
147
+ /** The open panel's share of the row on wide screens, and the beat before the first opens on its own (s). */
148
+ const OPEN_SHARE = 0.5;
149
+ const FIRST = 0.2;
150
+
151
+ /** 0 below `a`, 1 above `b`, smooth between. */
152
+ function smooth(x: number, a: number, b: number) {
153
+ const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
154
+ return t * t * (3 - 2 * t);
155
+ }
156
+
157
+ function Panels({ services, heading, tone, className }: VariantProps<Service[]>) {
158
+ const root = useRef<HTMLElement>(null);
159
+
160
+ useGSAP(
161
+ () => {
162
+ const el = root.current;
163
+ if (!el) return;
164
+ const track = el.querySelector<HTMLElement>("[data-track]");
165
+ const panels = Array.from(el.querySelectorAll<HTMLElement>("[data-panel]"));
166
+ const n = panels.length;
167
+ if (!track || n < 2) return entrance(el);
168
+ const f = feel(el);
169
+ const caps = panels.map((p) => p.querySelector("[data-cap]") as HTMLElement);
170
+ const smalls = panels.map((p) => p.querySelector("[data-small]") as HTMLElement);
171
+ const bigs = panels.map((p) => p.querySelector("[data-big]") as HTMLElement);
172
+ const names = panels.map((p) => p.querySelector("[data-name]") as HTMLElement);
173
+ const imgs = panels.map((p) => p.querySelector("img") as HTMLImageElement | null);
174
+ const reduce = prefersReduced();
175
+ const phoneQuery = window.matchMedia("(max-width: 767px)");
176
+ const touch = window.matchMedia("(hover: none)").matches;
177
+
178
+ // The numbers everything is drawn from: each panel's openness (they add up to one) and its arrival.
179
+ const open: Record<string, number> = {};
180
+ panels.forEach((_, i) => (open[`a${i}`] = 1 / n));
181
+ const arrive = panels.map(() => ({ v: reduce ? 0 : 1 }));
182
+ let current = -1;
183
+ let dirty = true;
184
+ const mark = () => {
185
+ dirty = true;
186
+ };
187
+
188
+ let phone = false;
189
+ let across = 0;
190
+ let big = 0;
191
+ let small = 0;
192
+ let gap = 8;
193
+ let capS = 0;
194
+ let capB: number[] = [];
195
+ const measure = () => {
196
+ phone = phoneQuery.matches;
197
+ gap = phone ? 6 : 8;
198
+ if (phone) {
199
+ across = track.clientWidth;
200
+ big = Math.round(Math.min(window.innerHeight * 0.52, across * 1.2));
201
+ small = 104;
202
+ track.style.height = `${big + (n - 1) * (small + gap)}px`;
203
+ } else {
204
+ track.style.height = "";
205
+ const along = track.clientWidth;
206
+ across = track.clientHeight;
207
+ big = Math.round(along * OPEN_SHARE);
208
+ small = (along - big - (n - 1) * gap) / (n - 1);
209
+ }
210
+ panels.forEach((p) => {
211
+ Object.assign(p.style, {
212
+ position: "absolute",
213
+ left: "0px",
214
+ top: "0px",
215
+ flex: "none",
216
+ width: `${phone ? across : big}px`,
217
+ height: `${phone ? big : across}px`,
218
+ });
219
+ });
220
+ caps.forEach((c) => Object.assign(c.style, { top: "0px", bottom: "auto" }));
221
+ // The open name at display size, fitted to the open window whatever the face.
222
+ const room = (phone ? across : big) - (phone ? 32 : 40);
223
+ names.forEach((nm) => {
224
+ nm.style.fontSize = "";
225
+ const w = nm.scrollWidth;
226
+ if (w > room) {
227
+ const fs = parseFloat(getComputedStyle(nm).fontSize) || 16;
228
+ nm.style.fontSize = `${Math.floor(((fs * room) / w) * 10) / 10}px`;
229
+ }
230
+ });
231
+ const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
232
+ capS = Math.round(rem * (phone ? 3.25 : 4.75));
233
+ capB = caps.map((c) => Math.max(capS, Math.floor(c.getBoundingClientRect().height) - 1));
234
+ mark();
235
+ };
236
+
237
+ const paint = () => {
238
+ let pos = 0;
239
+ panels.forEach((p, i) => {
240
+ const a = open[`a${i}`];
241
+ const size = small + a * (big - small);
242
+ const lead = Math.round(pos);
243
+ const end = Math.round(pos + size);
244
+ pos += size + gap;
245
+ const at = Math.round((lead + end) / 2 - big / 2);
246
+ const before = lead - at;
247
+ const after = at + big - end;
248
+ const rise = arrive[i].v;
249
+ const capH = Math.round(capS + ((capB[i] ?? capS) - capS) * smooth(a, 0.3, 0.62));
250
+ if (phone) {
251
+ const top = before + Math.round(rise * (end - lead));
252
+ p.style.transform = `translate3d(0, ${at}px, 0)`;
253
+ p.style.clipPath = `inset(${top}px 0px ${after}px 0px)`;
254
+ caps[i].style.transform = `translate3d(0, ${big - after - capH}px, 0)`;
255
+ } else {
256
+ const top = Math.round(rise * across);
257
+ p.style.transform = `translate3d(${at}px, 0, 0)`;
258
+ p.style.clipPath = `inset(${top}px ${after}px 0px ${before}px)`;
259
+ caps[i].style.transform = `translate3d(${before}px, ${across - capH}px, 0)`;
260
+ }
261
+ // The small name sinks out as the display name rises in, in the same window of openness.
262
+ const roll = smooth(a, 0.36, 0.62);
263
+ smalls[i].style.transform = `translate3d(0, ${roll * 115}%, 0)`;
264
+ bigs[i].style.transform = `translate3d(0, ${(1 - roll) * 115}%, 0)`;
265
+ });
266
+ };
267
+
268
+ let tween: gsap.core.Tween | null = null;
269
+ const openTo = (i: number) => {
270
+ if (i === current) return;
271
+ current = i;
272
+ panels.forEach((p, k) => p.toggleAttribute("data-open", k === i));
273
+ const target: Record<string, number> = {};
274
+ panels.forEach((_, k) => (target[`a${k}`] = k === i ? 1 : 0));
275
+ tween?.kill();
276
+ if (reduce) {
277
+ Object.assign(open, target);
278
+ mark();
279
+ return;
280
+ }
281
+ // From wherever the panels are now: an overtaken open turns back, it never restarts.
282
+ tween = gsap.to(open, { ...target, duration: f.duration.swap * 0.85, ease: f.ease.enter, onUpdate: mark });
283
+ };
284
+
285
+ const tick = () => {
286
+ if (!dirty) return;
287
+ dirty = false;
288
+ paint();
289
+ };
290
+ el.dataset.motion = "";
291
+ measure();
292
+ paint();
293
+ document.fonts?.ready.then(() => {
294
+ if (el.isConnected) measure();
295
+ });
296
+ gsap.ticker.add(tick);
297
+
298
+ // Every photograph is decoded before the panels may arrive; they arrive after the heading, from the scene's
299
+ // one trigger, then the first one opens on its own.
300
+ const decoded = Promise.all(imgs.map((img) => img?.decode().catch(() => undefined)));
301
+ const land = () => {
302
+ if (reduce) return;
303
+ decoded.then(() => {
304
+ gsap.to(arrive, {
305
+ v: 0,
306
+ duration: f.duration.enter,
307
+ ease: f.ease.enter,
308
+ stagger: f.stagger.line,
309
+ onUpdate: mark,
310
+ onComplete: () => {
311
+ gsap.delayedCall(FIRST, () => {
312
+ if (current === -1) openTo(0);
313
+ });
314
+ },
315
+ });
316
+ });
317
+ };
318
+ if (reduce) openTo(0);
319
+ const stop = el.querySelector('[data-part="heading"]')
320
+ ? entrance(el, (tl, _f, at) => tl.call(land, [], at.media))
321
+ : (() => {
322
+ el.dataset.ready = "1";
323
+ const t = ScrollTrigger.create({ trigger: track, start: LOCKED.trigger.start, once: true, onEnter: land });
324
+ return () => t.kill();
325
+ })();
326
+
327
+ const onEnter = (e: PointerEvent) => {
328
+ if (e.pointerType === "touch") return;
329
+ openTo(panels.indexOf(e.currentTarget as HTMLElement));
330
+ };
331
+ const onFocus = (e: FocusEvent) => openTo(panels.indexOf(e.currentTarget as HTMLElement));
332
+ // A tap opens a closed panel first; a tap on the open one follows its link.
333
+ const onClick = (e: MouseEvent) => {
334
+ const i = panels.indexOf(e.currentTarget as HTMLElement);
335
+ if ((touch || phone) && i !== current) {
336
+ e.preventDefault();
337
+ openTo(i);
338
+ }
339
+ };
340
+ panels.forEach((p) => {
341
+ p.addEventListener("pointerenter", onEnter);
342
+ p.addEventListener("focus", onFocus);
343
+ p.addEventListener("click", onClick);
344
+ });
345
+ const resize = new ResizeObserver(() => measure());
346
+ resize.observe(track);
347
+ phoneQuery.addEventListener("change", measure);
348
+
349
+ return () => {
350
+ stop();
351
+ tween?.kill();
352
+ gsap.killTweensOf(arrive);
353
+ gsap.ticker.remove(tick);
354
+ resize.disconnect();
355
+ phoneQuery.removeEventListener("change", measure);
356
+ panels.forEach((p) => {
357
+ p.removeEventListener("pointerenter", onEnter);
358
+ p.removeEventListener("focus", onFocus);
359
+ p.removeEventListener("click", onClick);
360
+ p.removeAttribute("style");
361
+ p.removeAttribute("data-open");
362
+ });
363
+ caps.forEach((c) => c.removeAttribute("style"));
364
+ track.style.height = "";
365
+ delete el.dataset.motion;
366
+ names.forEach((nm) => (nm.style.fontSize = ""));
367
+ };
368
+ },
369
+ { scope: root, dependencies: [services.length] },
370
+ );
371
+
372
+ return (
373
+ <section
374
+ ref={root}
375
+ data-scene="services"
376
+ data-variant="panels"
377
+ data-energy="1"
378
+ data-middle=""
379
+ className={`flex min-h-svh flex-col px-sheet-margin py-[7vh] text-ink ${TONE_CLASS[tone]} ${className}`}
380
+ >
381
+ <Heading heading={heading} className="mb-[5vh]" />
382
+ {/* The panels sit at full width and are clipped into place, so the track clips too: without it the closed panels
383
+ widened the page (lab check, 2026-11-15). */}
384
+ <div data-track="" className="relative flex flex-1 flex-col gap-1.5 overflow-hidden md:min-h-[60svh] md:flex-row md:gap-2">
385
+ {services.map((service) => {
386
+ const body = (
387
+ <>
388
+ {service.image ? (
389
+ <Image
390
+ src={service.image.src}
391
+ alt={service.image.alt}
392
+ fill
393
+ loading="eager"
394
+ sizes="(min-width: 768px) 50vw, 100vw"
395
+ unoptimized={service.image.unoptimized}
396
+ className="object-cover"
397
+ />
398
+ ) : null}
399
+ {/* The name on the scene's ground at the window's foot; focus draws the accent along its top. */}
400
+ <div data-cap="" className="absolute bottom-0 left-[-1px] w-[calc(100%+2px)] bg-surface-0 group-focus-visible:shadow-[inset_0_2px_0_var(--accent)]">
401
+ <div className="grid items-start px-4 pb-4 pt-3 md:px-5 md:pb-5 md:pt-4">
402
+ <span className="struq-line-mask block [grid-area:1/1]">
403
+ <span data-small="" className="block whitespace-nowrap text-small">
404
+ {service.name}
405
+ </span>
406
+ </span>
407
+ <span className="struq-line-mask block text-h3 [grid-area:1/1] md:text-[clamp(2.5rem,1rem_+_3.6vw,5.5rem)]">
408
+ <span data-big="" className="flex flex-col items-start gap-2 md:gap-3" style={{ transform: "translateY(115%)" }}>
409
+ <span data-name="" className="block whitespace-nowrap font-display text-h3 leading-[0.95] tracking-[-0.02em] md:text-[clamp(2.5rem,1rem_+_3.6vw,5.5rem)]">
410
+ {service.name}
411
+ </span>
412
+ <span className="max-w-[40ch] text-small text-ink-muted">{service.text}</span>
413
+ </span>
414
+ </span>
415
+ </div>
416
+ </div>
417
+ </>
418
+ );
419
+ // The panel clips, so a ring outside it would be cut: focus draws the accent along the band's top instead.
420
+ const cls = "group relative block h-[26svh] overflow-hidden outline-none md:h-auto md:flex-1";
421
+ return service.href ? (
422
+ <a key={service.name} href={service.href} data-panel="" className={cls}>
423
+ {body}
424
+ </a>
425
+ ) : (
426
+ <div key={service.name} tabIndex={0} data-panel="" className={cls}>
427
+ {body}
428
+ </div>
429
+ );
430
+ })}
431
+ </div>
432
+ </section>
433
+ );
434
+ }
435
+
436
+ // --- pair -----------------------------------------------------------------------------------------------------------
437
+
438
+ function Half({ service, sizes }: { service: Service; sizes: string }) {
439
+ const name = service.href ? (
440
+ <a href={service.href} data-hover-link="draw" className="text-ink">
441
+ {service.name}
442
+ </a>
443
+ ) : (
444
+ service.name
445
+ );
446
+ return (
447
+ <div className="grid content-start gap-8 md:gap-10">
448
+ {service.image ? (
449
+ <div data-part="media" data-open="bottom" className="relative aspect-[4/3] overflow-hidden">
450
+ <div className="absolute inset-0">
451
+ <Image src={service.image.src} alt={service.image.alt} fill sizes={sizes} unoptimized={service.image.unoptimized} className="object-cover" />
452
+ </div>
453
+ </div>
454
+ ) : null}
455
+ <div>
456
+ <h3 data-part="heading" className="m-0 font-display text-h2">
457
+ {name}
458
+ </h3>
459
+ <p data-part="detail" className="m-0 mt-6 max-w-[38ch] text-body text-ink-muted md:mt-8">
460
+ {service.text}
461
+ </p>
462
+ </div>
463
+ </div>
464
+ );
465
+ }
466
+
467
+ function Pair({ services, heading, tone, className }: VariantProps<[Service, Service]>) {
468
+ const ref = useRef<HTMLElement>(null);
469
+ useEntrance(ref, "pair");
470
+ const sizes = "(min-width: 768px) 42vw, 100vw";
471
+ return (
472
+ <section ref={ref} data-scene="services" data-variant="pair" data-energy="1" data-middle="" className={`py-28 text-ink md:py-40 ${TONE_CLASS[tone]} ${className}`}>
473
+ <div className={`items-stretch gap-y-12 ${SHEET_GRID}`}>
474
+ {heading ? <Heading heading={heading} className="col-span-12 mb-4 md:col-span-8 md:mb-12" /> : null}
475
+ <div className="col-span-12 md:col-span-5">
476
+ <Half service={services[0]} sizes={sizes} />
477
+ </div>
478
+ {/* The rule between the halves: drawn down on wide screens, across on phones. */}
479
+ <div aria-hidden className="col-span-12 md:col-span-2 md:col-start-6 md:flex md:justify-center">
480
+ <ScrubLine axis="x" className="text-line md:hidden" />
481
+ <ScrubLine axis="y" className="hidden h-full text-line md:block" />
482
+ </div>
483
+ <div className="col-span-12 md:col-span-5 md:col-start-8">
484
+ <Half service={services[1]} sizes={sizes} />
485
+ </div>
486
+ </div>
487
+ </section>
488
+ );
489
+ }
490
+
491
+ export function ServicesList({ services, variant = "rows", heading, tone = "ground", className = "" }: Props) {
492
+ const shown = resolveServicesVariant(variant, services);
493
+ if (shown === "panels") return <Panels services={services} heading={heading} tone={tone} className={className} />;
494
+ if (shown === "pair") return <Pair services={services as [Service, Service]} heading={heading} tone={tone} className={className} />;
495
+ return <Rows services={services} heading={heading} tone={tone} className={className} />;
496
+ }
@@ -0,0 +1,98 @@
1
+ // components/struq/kit/middle/entrance.ts (Struq recipe)
2
+ // The one entrance every middle scene shares: its parts in one timeline, fired once by one ScrollTrigger on the
3
+ // scene's root, in the locked order heading, then media, then details. A scene marks its parts and calls this from
4
+ // its own useGSAP; it is not a wrapper and decides nothing about layout.
5
+ //
6
+ // <h2 data-part="heading">...</h2> lines rise through the shared descender-safe mask (RevealText's move)
7
+ // <div data-part="media" data-open="bottom"> opens from its foot (MaskWipe's move); data-open="margins" opens out of
8
+ // <img .../> the page margins to the edges (the about-studio plate); the first child
9
+ // </div> settles from the feel's media scale
10
+ // <p data-part="detail">...</p> rises a block's travel from clear (Rise's move), one after another
11
+ //
12
+ // const stop = entrance(root, (tl, f, at) => tl.call(openFirstPanel, [], at.end));
13
+ //
14
+ // Every number comes from the feel of the root (kit/feel.ts, its nearest data-temperament). The root carries
15
+ // data-middle; blocks.css holds the parts in their start state before the build (the `.js` / `.sq-js` gate, with a
16
+ // 3 s fallback) and the build waits for the faces, capped, so lines are cut in the face they are read in. Reduced
17
+ // motion: nothing moves, the parts stand composed. Returns the cleanup.
18
+ import { feel, LOCKED, prefersReduced, type Feel } from "../feel";
19
+ import { gsap, ScrollTrigger, SplitText } from "../gsap";
20
+ import { riseFrom, riseTo } from "../motion/Rise";
21
+
22
+ /** Where each group starts in the timeline, in seconds, and where the timeline ends. */
23
+ export type EntranceMarks = { heading: number; media: number; details: number; end: number };
24
+
25
+ /** The longest the build waits for the display face, in ms. */
26
+ const FONT_CAP = 1200;
27
+ /** data-open="margins": the share of the width the plate starts inside, per side. */
28
+ const MARGIN = 6;
29
+
30
+ function faceReady() {
31
+ const fonts = typeof document !== "undefined" ? document.fonts?.ready : undefined;
32
+ return Promise.race([fonts ?? Promise.resolve(), new Promise((res) => window.setTimeout(res, FONT_CAP))]);
33
+ }
34
+
35
+ export function entrance(root: HTMLElement, extend?: (tl: gsap.core.Timeline, f: Feel, at: EntranceMarks) => void): () => void {
36
+ if (prefersReduced()) {
37
+ root.dataset.ready = "1";
38
+ return () => {};
39
+ }
40
+ const f = feel(root);
41
+ let alive = true;
42
+ let tl: gsap.core.Timeline | null = null;
43
+ let trigger: ScrollTrigger | null = null;
44
+ const splits: SplitText[] = [];
45
+
46
+ faceReady().then(() => {
47
+ if (!alive) return;
48
+ const heads = Array.from(root.querySelectorAll<HTMLElement>('[data-part="heading"]'));
49
+ const media = Array.from(root.querySelectorAll<HTMLElement>('[data-part="media"]'));
50
+ const details = Array.from(root.querySelectorAll<HTMLElement>('[data-part="detail"]'));
51
+ const lines: Element[] = [];
52
+ for (const head of heads) {
53
+ let first = true;
54
+ splits.push(
55
+ SplitText.create(head, {
56
+ type: "lines",
57
+ mask: "lines",
58
+ linesClass: "struq-line",
59
+ autoSplit: true,
60
+ onSplit(self) {
61
+ // A later re-cut (a width change, a face landing) sets new lines at rest.
62
+ if (!first) return;
63
+ first = false;
64
+ lines.push(...self.lines);
65
+ },
66
+ }),
67
+ );
68
+ }
69
+
70
+ tl = gsap.timeline({ paused: true });
71
+ if (lines.length) {
72
+ tl.fromTo(lines, { yPercent: f.travel.text }, { yPercent: 0, duration: f.duration.line, ease: f.ease.enter, stagger: f.stagger.line }, 0);
73
+ }
74
+ const mediaAt = lines.length ? Math.round(f.duration.line * 0.35 * 1000) / 1000 : 0;
75
+ media.forEach((m, i) => {
76
+ const size = m.offsetHeight;
77
+ const at = mediaAt + i * f.stagger.block * 2;
78
+ const closed = m.dataset.open === "margins" ? `inset(0% ${MARGIN}% 0% ${MARGIN}%)` : "inset(100% 0% 0% 0%)";
79
+ tl!.fromTo(m, { clipPath: closed }, { clipPath: "inset(0% 0% 0% 0%)", duration: f.dur("curtain", size), ease: f.ease.curtain }, at);
80
+ const photo = m.firstElementChild;
81
+ if (photo) tl!.fromTo(photo, { scale: f.travel.media }, { scale: 1, duration: f.dur("settle", size), ease: f.ease.enter }, at);
82
+ });
83
+ const detailsAt = media.length ? mediaAt + f.duration.enter * 0.5 : mediaAt;
84
+ if (details.length) tl.fromTo(details, riseFrom(f), { ...riseTo(f), stagger: f.stagger.block }, detailsAt);
85
+ extend?.(tl, f, { heading: 0, media: mediaAt, details: detailsAt, end: tl.duration() });
86
+
87
+ root.dataset.ready = "1";
88
+ const play = () => tl?.play();
89
+ trigger = ScrollTrigger.create({ trigger: root, start: LOCKED.trigger.start, once: LOCKED.trigger.once, onEnter: play });
90
+ });
91
+
92
+ return () => {
93
+ alive = false;
94
+ trigger?.kill();
95
+ tl?.revert();
96
+ for (const split of splits) split.revert();
97
+ };
98
+ }