@struq/skill 0.5.2 → 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 +27 -27
  2. package/package.json +2 -2
  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,354 @@
1
+ "use client";
2
+ // components/struq/kit/subject/Ticket.tsx (Struq recipe)
3
+ // Event and print collateral: a ticket with its stub and perforation, a lanyard badge, or a type-only poster on a
4
+ // strong grid. Filled from the event's own name, city, venue and dates, and only those the caller passes: nothing is
5
+ // invented. Behind the ticket and badge stands a typographic poster grid whose pattern is drawn from the letters of
6
+ // the event's name, so every event gets its own pattern and the same one on every render.
7
+ //
8
+ // <Ticket event="Dutch Design Week" city="Eindhoven" venue="Klokgebouw" dates={["2026-10-17", "2026-10-25"]}
9
+ // holder={{ name: "Sanne Visser", role: "Bezoeker" }} serial="0042" tier="Weekpas" locale="nl-NL" />
10
+ //
11
+ // Props
12
+ // - event: the event's name, set large; its letters make the pattern unless `pattern` gives others.
13
+ // - kind: "ticket" (default), "badge" or "poster".
14
+ // - city, venue, time ("Doors 19:30"), edition ("12th edition"), tier ("Day pass", "Speaker"): each only when the
15
+ // caller has it. serial: the ticket's own number, on the stub in tabular figures.
16
+ // - dates: ISO dates (2026-10-17). One date, or the first and last of a run; written in the locale. Never guessed.
17
+ // - holder: { name, role?, org? }: the person a ticket admits or a badge names.
18
+ // - stock: "field" (default) the palette's loud colour, "paper" the palette's light colour, "ink" its dark one.
19
+ // - pattern: optional letters for the pattern instead of the event's own.
20
+ // - labels: { admit?, serial? }: the two fixed words ("Admit", "No."); English or Dutch by locale otherwise.
21
+ // - locale (default "en-GB"), caption (a line under it, in the scene's ink), build ("scroll" default, or "static").
22
+ // - className: on the figure.
23
+ //
24
+ // Mounting: inside any section. A ticket wants six or more of twelve columns on a wide screen and turns upright on a
25
+ // phone, its stub under a perforation across; a badge or a poster wants four. The root is a <figure
26
+ // data-subject="ticket">.
27
+ //
28
+ // Motion: tied to the scroll and quiet. The poster grid's cells fill in a seeded order as the piece comes up, the
29
+ // ticket rises a little and settles to its slight tilt. Reduced motion, build="static" or no scripts: the finished
30
+ // piece.
31
+ import { useRef, type CSSProperties } from "react";
32
+ import { useScrollBuild } from "./build";
33
+ import { dateParts, FIGURES, languageOf, lettersOf, seed } from "./format";
34
+
35
+ type Holder = { name: string; role?: string; org?: string };
36
+
37
+ type Props = {
38
+ event: string;
39
+ kind?: "ticket" | "badge" | "poster";
40
+ city?: string;
41
+ venue?: string;
42
+ dates?: string[];
43
+ time?: string;
44
+ edition?: string;
45
+ tier?: string;
46
+ serial?: string;
47
+ holder?: Holder;
48
+ stock?: "field" | "paper" | "ink";
49
+ pattern?: string;
50
+ labels?: { admit?: string; serial?: string };
51
+ locale?: string;
52
+ caption?: string;
53
+ build?: "scroll" | "static";
54
+ className?: string;
55
+ };
56
+
57
+ const WORDS: Record<string, { admit: string; serial: string }> = {
58
+ en: { admit: "Admit", serial: "No." },
59
+ nl: { admit: "Toegang", serial: "Nr." },
60
+ };
61
+
62
+ const SMALL = "text-[0.75rem] leading-[1.45]";
63
+
64
+ /** A seeded generator (mulberry32), so a pattern is the same on the server and in the browser. */
65
+ function random(start: number) {
66
+ let a = start;
67
+ return () => {
68
+ a = (a + 0x6d2b79f5) | 0;
69
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
70
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
71
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
72
+ };
73
+ }
74
+
75
+ /**
76
+ * The letters of a name set on a grid, one per cell, read left to right like a line of type. "loud": solid letters,
77
+ * some cells turned (an ink cell with the ground's letter), a few in outline. "quiet": outline letters only, on the
78
+ * grid's hairlines, for a ground that something else stands on.
79
+ */
80
+ function LetterGrid({
81
+ text,
82
+ cols,
83
+ rows,
84
+ loud,
85
+ fit = "slice",
86
+ className = "",
87
+ style,
88
+ }: {
89
+ text: string;
90
+ cols: number;
91
+ rows: number;
92
+ loud: boolean;
93
+ fit?: "slice" | "meet";
94
+ className?: string;
95
+ style?: CSSProperties;
96
+ }) {
97
+ const letters = lettersOf(text);
98
+ const next = random(seed(text));
99
+ const cell = 10;
100
+ const cells = Array.from({ length: cols * rows }, (_, i) => {
101
+ const roll = next();
102
+ return { i, ch: letters[i % Math.max(letters.length, 1)] ?? "", mode: loud ? (roll < 0.26 ? "turn" : roll < 0.4 ? "line" : "solid") : "line" };
103
+ });
104
+ return (
105
+ <svg
106
+ aria-hidden="true"
107
+ viewBox={`0 0 ${cols * cell} ${rows * cell}`}
108
+ preserveAspectRatio={fit === "meet" ? "xMinYMid meet" : "xMidYMid slice"}
109
+ className={`block ${className}`}
110
+ style={style}
111
+ >
112
+ {loud ? null : (
113
+ <g style={{ stroke: "var(--line)", strokeWidth: 0.08 }}>
114
+ {Array.from({ length: cols + 1 }, (_, c) => (
115
+ <line key={`c${c}`} x1={c * cell} y1={0} x2={c * cell} y2={rows * cell} />
116
+ ))}
117
+ {Array.from({ length: rows + 1 }, (_, r) => (
118
+ <line key={`r${r}`} x1={0} y1={r * cell} x2={cols * cell} y2={r * cell} />
119
+ ))}
120
+ </g>
121
+ )}
122
+ {cells.map(({ i, ch, mode }) => {
123
+ const x = (i % cols) * cell;
124
+ const y = Math.floor(i / cols) * cell;
125
+ return (
126
+ <g key={i} data-ticket-cell="">
127
+ {mode === "turn" ? <rect x={x} y={y} width={cell} height={cell} style={{ fill: "var(--ink)" }} /> : null}
128
+ <text
129
+ x={x + cell / 2}
130
+ y={y + cell * 0.5 + 3.05}
131
+ textAnchor="middle"
132
+ className="font-display"
133
+ style={{
134
+ fontSize: 8.6,
135
+ fontWeight: 600,
136
+ fill: mode === "turn" ? "var(--surface-0)" : mode === "solid" ? "var(--ink)" : "none",
137
+ stroke: mode === "line" ? (loud ? "var(--ink)" : "color-mix(in oklab, var(--ink) 26%, transparent)") : "none",
138
+ strokeWidth: mode === "line" ? 0.14 : 0,
139
+ }}
140
+ >
141
+ {ch}
142
+ </text>
143
+ </g>
144
+ );
145
+ })}
146
+ </svg>
147
+ );
148
+ }
149
+
150
+ const STOCK: Record<NonNullable<Props["stock"]>, { className: string; paper?: "print" }> = {
151
+ field: { className: "scene-field" },
152
+ ink: { className: "scene-inverse" },
153
+ paper: { className: "subject-paper", paper: "print" },
154
+ };
155
+
156
+ /** One date, or the first and last of a run, as large day figures with the month under them. */
157
+ function Dates({ dates, locale, size }: { dates: string[]; locale: string; size: string }) {
158
+ const first = dateParts(dates[0], locale);
159
+ const last = dates.length > 1 ? dateParts(dates[dates.length - 1], locale) : null;
160
+ if (!first) return null;
161
+ const month = last && last.month !== first.month ? `${first.month} / ${last.month}` : first.month;
162
+ const year = last && last.year !== first.year ? `${first.year} / ${last.year}` : first.year;
163
+ return (
164
+ <div>
165
+ <p className={`m-0 flex items-center gap-[0.18em] font-display leading-none tracking-[-0.02em] ${size} ${FIGURES}`}>
166
+ <span>{first.day}</span>
167
+ {last ? (
168
+ <>
169
+ <span aria-hidden="true" className="inline-block h-[0.06em] w-[0.5em] translate-y-[0.02em] bg-[color:var(--ink)]" />
170
+ <span className="sr-only"> / </span>
171
+ <span>{last.day}</span>
172
+ </>
173
+ ) : null}
174
+ </p>
175
+ <p className={`m-0 mt-1.5 ${SMALL} ${FIGURES}`}>
176
+ {last ? `${month} ${year}` : `${first.weekday} ${month} ${year}`}
177
+ </p>
178
+ </div>
179
+ );
180
+ }
181
+
182
+ export function Ticket({
183
+ event,
184
+ kind = "ticket",
185
+ city,
186
+ venue,
187
+ dates,
188
+ time,
189
+ edition,
190
+ tier,
191
+ serial,
192
+ holder,
193
+ stock = "field",
194
+ pattern,
195
+ labels,
196
+ locale = "en-GB",
197
+ caption,
198
+ build = "scroll",
199
+ className = "",
200
+ }: Props) {
201
+ const root = useRef<HTMLElement>(null);
202
+ const words = { ...(WORDS[languageOf(locale)] ?? WORDS.en), ...labels };
203
+ const source = pattern ?? event;
204
+ const look = STOCK[stock];
205
+ const where = [venue, city].filter(Boolean).join(", ");
206
+
207
+ useScrollBuild(
208
+ root,
209
+ build === "scroll",
210
+ (el, tl) => {
211
+ const cells = Array.from(el.querySelectorAll("[data-ticket-ground] [data-ticket-cell]"));
212
+ const order = random(seed(source));
213
+ for (let i = cells.length - 1; i > 0; i -= 1) {
214
+ const j = Math.floor(order() * (i + 1));
215
+ [cells[i], cells[j]] = [cells[j], cells[i]];
216
+ }
217
+ if (cells.length) tl.from(cells, { autoAlpha: 0, stagger: { each: 0.03 }, duration: 0.3 });
218
+ const piece = el.querySelector("[data-ticket-piece]");
219
+ if (piece) tl.from(piece, { y: 40, autoAlpha: 0, duration: 0.8, ease: "power2.out" }, 0.15);
220
+ const poster = el.querySelectorAll("[data-ticket-poster] [data-ticket-cell]");
221
+ if (poster.length) tl.from(poster, { autoAlpha: 0, stagger: { each: 0.025, from: "random" }, duration: 0.3 }, 0);
222
+ },
223
+ [event, kind],
224
+ { start: "top 85%", end: "center 55%" },
225
+ );
226
+
227
+ if (kind === "poster") {
228
+ return (
229
+ <figure ref={root} data-subject="ticket" data-kind="poster" className={`@container m-0 ${className}`}>
230
+ <div
231
+ data-paper={look.paper}
232
+ className={`${look.className} mx-auto grid aspect-[1/1.414] w-full max-w-[34rem] grid-rows-[auto_1fr_auto] overflow-hidden p-[6cqi] text-ink`}
233
+ >
234
+ <div className="grid grid-cols-4 gap-x-[3cqi] border-b-[0.35cqi] border-[color:var(--ink)] pb-[2.5cqi]">
235
+ <p className={`col-span-2 m-0 ${SMALL} font-medium`}>{edition ?? ""}</p>
236
+ <p className={`col-span-2 m-0 text-right ${SMALL} ${FIGURES}`}>{serial ? `${words.serial} ${serial}` : ""}</p>
237
+ </div>
238
+ <div data-ticket-poster="" className="relative min-h-0 py-[4cqi]">
239
+ <LetterGrid text={source} cols={4} rows={4} loud className="h-full w-full" />
240
+ </div>
241
+ <div className="grid grid-cols-4 gap-x-[3cqi] gap-y-[3cqi] border-t-[0.35cqi] border-[color:var(--ink)] pt-[3cqi]">
242
+ <p className="col-span-4 m-0 font-display text-[clamp(1.75rem,10cqi,3.5rem)] leading-[0.92] tracking-[-0.02em]">{event}</p>
243
+ {dates?.length ? (
244
+ <div className="col-span-2">
245
+ <Dates dates={dates} locale={locale} size="text-[clamp(1.5rem,8cqi,2.75rem)]" />
246
+ </div>
247
+ ) : null}
248
+ <div className={`col-span-2 ${SMALL}`}>
249
+ {time ? <p className={`m-0 ${FIGURES}`}>{time}</p> : null}
250
+ {venue ? <p className="m-0">{venue}</p> : null}
251
+ {city ? <p className="m-0 font-medium">{city}</p> : null}
252
+ </div>
253
+ </div>
254
+ </div>
255
+ {caption ? <figcaption className="mx-auto mt-4 max-w-[34rem] font-body text-sheet-label text-ink-muted">{caption}</figcaption> : null}
256
+ </figure>
257
+ );
258
+ }
259
+
260
+ const ground = (
261
+ <div data-ticket-ground="" aria-hidden="true" className="absolute inset-0">
262
+ <LetterGrid text={source} cols={kind === "badge" ? 5 : 9} rows={kind === "badge" ? 6 : 5} loud={false} className="h-full w-full" />
263
+ </div>
264
+ );
265
+
266
+ if (kind === "badge") {
267
+ return (
268
+ <figure ref={root} data-subject="ticket" data-kind="badge" className={`@container m-0 ${className}`}>
269
+ <div className="relative mx-auto w-full max-w-[34rem] overflow-hidden px-[10cqi] py-[8cqi]">
270
+ {ground}
271
+ <div
272
+ data-ticket-piece=""
273
+ data-paper={look.paper}
274
+ className={`${look.className} relative mx-auto flex aspect-[5/7] w-full max-w-[19rem] rotate-[-2.5deg] flex-col overflow-hidden rounded-[0.875rem] text-ink`}
275
+ >
276
+ <span aria-hidden="true" className="subject-slot mx-auto mt-[5%] h-[0.875rem] w-[3.5rem] shrink-0 rounded-full" />
277
+ <div className="flex items-baseline justify-between gap-3 px-[8%] pt-[7%]">
278
+ <p className={`m-0 ${SMALL} font-medium`}>{event}</p>
279
+ {edition ? <p className={`m-0 shrink-0 ${SMALL}`}>{edition}</p> : null}
280
+ </div>
281
+ <div className="mt-[6%] h-[24%] border-y border-[color:var(--ink)]">
282
+ <LetterGrid text={source} cols={6} rows={2} loud className="h-full w-full" />
283
+ </div>
284
+ <div className="flex flex-1 flex-col justify-end px-[8%] pb-[8%]">
285
+ {holder ? (
286
+ <>
287
+ <p className="m-0 font-display text-[clamp(1.75rem,9cqi,2.5rem)] leading-[0.98] tracking-[-0.015em]">{holder.name}</p>
288
+ {holder.role || holder.org ? (
289
+ <p className={`m-0 mt-2 ${SMALL}`}>{[holder.role, holder.org].filter(Boolean).join(", ")}</p>
290
+ ) : null}
291
+ </>
292
+ ) : null}
293
+ <div className={`mt-[7%] flex items-end justify-between gap-3 border-t border-[color:var(--ink)] pt-2.5 ${SMALL}`}>
294
+ <span>{[tier, city].filter(Boolean).join(", ")}</span>
295
+ {serial ? <span className={FIGURES}>{`${words.serial} ${serial}`}</span> : null}
296
+ </div>
297
+ </div>
298
+ </div>
299
+ </div>
300
+ {caption ? <figcaption className="mx-auto mt-4 max-w-[34rem] font-body text-sheet-label text-ink-muted">{caption}</figcaption> : null}
301
+ </figure>
302
+ );
303
+ }
304
+
305
+ return (
306
+ <figure ref={root} data-subject="ticket" data-kind="ticket" className={`@container m-0 ${className}`}>
307
+ <div className="relative overflow-hidden px-[6cqi] py-[9cqi] @xl:px-[7cqi] @xl:py-[8cqi]">
308
+ {ground}
309
+ <div
310
+ data-ticket-piece=""
311
+ data-paper={look.paper}
312
+ className={`${look.className} subject-ticket relative mx-auto grid w-full max-w-[44rem] text-ink @xl:rotate-[-2deg] @xl:grid-cols-[72%_28%]`}
313
+ >
314
+ <div className="flex flex-col gap-6 p-6 @xl:gap-8 @xl:p-8">
315
+ <div className="flex items-baseline justify-between gap-4">
316
+ <p className={`m-0 ${SMALL}`}>{tier ?? words.admit}</p>
317
+ {edition ? <p className={`m-0 ${SMALL}`}>{edition}</p> : null}
318
+ </div>
319
+ <p className="m-0 font-display text-[clamp(2rem,8.5cqi,3.75rem)] leading-[0.92] tracking-[-0.02em]">{event}</p>
320
+ <div className="mt-auto grid grid-cols-2 items-end gap-x-6 gap-y-5 border-t border-[color:var(--ink)] pt-4">
321
+ {dates?.length ? <Dates dates={dates} locale={locale} size="text-[clamp(1.75rem,6cqi,2.5rem)]" /> : <span />}
322
+ <div className={SMALL}>
323
+ {time ? <p className={`m-0 ${FIGURES}`}>{time}</p> : null}
324
+ {where ? <p className="m-0">{where}</p> : null}
325
+ </div>
326
+ {holder ? (
327
+ <div className="col-span-2">
328
+ <p className={`m-0 ${SMALL} opacity-80`}>{words.admit}</p>
329
+ <p className="m-0 font-display text-[1.375rem] leading-[1.15]">{holder.name}</p>
330
+ {holder.role || holder.org ? <p className={`m-0 ${SMALL}`}>{[holder.role, holder.org].filter(Boolean).join(", ")}</p> : null}
331
+ </div>
332
+ ) : null}
333
+ </div>
334
+ </div>
335
+ <div className="subject-perforation flex h-[7rem] items-center justify-between gap-4 p-6 @xl:h-auto @xl:flex-col @xl:items-stretch @xl:p-7">
336
+ <div className="h-full w-[6rem] shrink-0 overflow-hidden @xl:h-auto @xl:w-full @xl:flex-1">
337
+ <LetterGrid text={source} cols={3} rows={2} loud fit="meet" className="h-full w-full" />
338
+ </div>
339
+ <div className="text-right @xl:text-left">
340
+ {serial ? (
341
+ <p className="m-0 flex items-baseline justify-end gap-2 @xl:block">
342
+ <span className={`block ${SMALL}`}>{words.serial}</span>
343
+ <span className={`block font-display text-[clamp(1.75rem,5cqi,2.25rem)] leading-none ${FIGURES}`}>{serial}</span>
344
+ </p>
345
+ ) : null}
346
+ {city ? <p className={`m-0 mt-2 ${SMALL} font-medium`}>{city}</p> : null}
347
+ </div>
348
+ </div>
349
+ </div>
350
+ </div>
351
+ {caption ? <figcaption className="mt-4 font-body text-sheet-label text-ink-muted">{caption}</figcaption> : null}
352
+ </figure>
353
+ );
354
+ }
@@ -0,0 +1,66 @@
1
+ // components/struq/kit/subject/build.ts (Struq recipe)
2
+ // The subject pieces' one motion: a build tied to the scroll. A piece sets itself as it scrolls through the reveal
3
+ // band (lines set in, a total counts up, an outline draws) and stands finished once its foot is in view; scrolling
4
+ // back takes it apart again. Under reduced motion, with `on` false or without scripts nothing moves: the piece is
5
+ // rendered finished and stays so. GSAP only through ../gsap.
6
+ //
7
+ // useScrollBuild(ref, build === "scroll", (root, tl) => {
8
+ // tl.from(root.querySelectorAll("[data-line]"), { autoAlpha: 0, y: 8, stagger: 0.2 });
9
+ // });
10
+ import type { RefObject } from "react";
11
+ import { gsap, useGSAP } from "../gsap";
12
+
13
+ export type Timeline = ReturnType<typeof gsap.timeline>;
14
+
15
+ export function useScrollBuild(
16
+ ref: RefObject<HTMLElement | null>,
17
+ on: boolean,
18
+ make: (root: HTMLElement, tl: Timeline) => void,
19
+ deps: unknown[] = [],
20
+ range: { start?: string; end?: string } = {},
21
+ ) {
22
+ useGSAP(
23
+ () => {
24
+ const root = ref.current;
25
+ if (!root || !on) return;
26
+ const mm = gsap.matchMedia();
27
+ mm.add("(prefers-reduced-motion: no-preference)", () => {
28
+ // A counting figure's own text comes back when the build is taken down (a resize, reduced motion turned on).
29
+ const counts = Array.from(root.querySelectorAll<HTMLElement>("[data-count]")).map((el) => [el, el.textContent] as const);
30
+ const tl = gsap.timeline({
31
+ defaults: { ease: "none", duration: 1 },
32
+ scrollTrigger: { trigger: root, start: range.start ?? "top 82%", end: range.end ?? "bottom 78%", scrub: 0.6 },
33
+ });
34
+ make(root, tl);
35
+ return () => {
36
+ for (const [el, text] of counts) el.textContent = text;
37
+ };
38
+ });
39
+ return () => mm.revert();
40
+ },
41
+ { scope: ref, dependencies: [on, ...deps], revertOnUpdate: true },
42
+ );
43
+ }
44
+
45
+ /**
46
+ * A figure that counts up inside a build: from zero to its `data-count` value, written with `format` on every step.
47
+ * The element is rendered with its final text, so without the build it already reads right.
48
+ */
49
+ export function countUp(tl: Timeline, el: HTMLElement | null, format: (value: number) => string, at?: number | string) {
50
+ if (!el) return;
51
+ const target = Number(el.dataset.count ?? "0");
52
+ const state = { v: 0 };
53
+ el.textContent = format(0);
54
+ tl.fromTo(
55
+ state,
56
+ { v: 0 },
57
+ {
58
+ v: target,
59
+ ease: "power2.out",
60
+ onUpdate: () => {
61
+ el.textContent = format(state.v);
62
+ },
63
+ },
64
+ at,
65
+ );
66
+ }
@@ -0,0 +1,196 @@
1
+ // components/struq/kit/subject/draw.ts (Struq recipe)
2
+ // The drawing pieces' geometry and pen: outlines made of straight and cubic segments in real units (cm, mm), sampled
3
+ // to points, offset outward by a true even distance per segment (a seam allowance, a hem allowance, nothing on a
4
+ // fold), points and normals along a segment (a notch), and a pen that keeps every line weight and hatch spacing in
5
+ // screen pixels whatever size the drawing renders at.
6
+ //
7
+ // const piece = { start: [0, 8.5], segs: [{ to: [7, 0], c1: [3.9, 8.5], c2: [7, 4.7] }, ...] };
8
+ // outlinePath(piece) -> "M0 8.5 C3.9 8.5 7 4.7 7 0 ..."
9
+ // offsetPath(piece, 1.5) -> the cut line, 1.5 out from every segment (0 where a segment is a fold)
10
+ // const px = usePen(svgRef, 60) // screen pixels per unit of a 60 unit wide drawing
11
+ import { useEffect, useState, type RefObject } from "react";
12
+
13
+ export type Pt = readonly [number, number];
14
+
15
+ /** One edge of an outline: a line to `to`, or a cubic through c1 and c2 when both are given. */
16
+ export type Seg = {
17
+ to: Pt;
18
+ c1?: Pt;
19
+ c2?: Pt;
20
+ /** This edge's own allowance; the outline's default when left out. */
21
+ allowance?: number;
22
+ /** The edge lies on a fold: no allowance, the cut line is the fold. */
23
+ fold?: boolean;
24
+ };
25
+
26
+ export type Outline = { start: Pt; segs: Seg[] };
27
+
28
+ const r = (n: number) => Math.round(n * 1000) / 1000;
29
+
30
+ /** The SVG path of a closed outline. */
31
+ export function outlinePath(outline: Outline): string {
32
+ const parts = [`M${r(outline.start[0])} ${r(outline.start[1])}`];
33
+ for (const seg of outline.segs) {
34
+ parts.push(
35
+ seg.c1 && seg.c2
36
+ ? `C${r(seg.c1[0])} ${r(seg.c1[1])} ${r(seg.c2[0])} ${r(seg.c2[1])} ${r(seg.to[0])} ${r(seg.to[1])}`
37
+ : `L${r(seg.to[0])} ${r(seg.to[1])}`,
38
+ );
39
+ }
40
+ return `${parts.join(" ")} Z`;
41
+ }
42
+
43
+ /** An open path through points. */
44
+ export function polyPath(points: readonly Pt[], close = false): string {
45
+ return points.map((p, i) => `${i === 0 ? "M" : "L"}${r(p[0])} ${r(p[1])}`).join(" ") + (close ? " Z" : "");
46
+ }
47
+
48
+ function cubic(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
49
+ const u = 1 - t;
50
+ const a = u * u * u;
51
+ const b = 3 * u * u * t;
52
+ const c = 3 * u * t * t;
53
+ const d = t * t * t;
54
+ return [a * p0[0] + b * c1[0] + c * c2[0] + d * p3[0], a * p0[1] + b * c1[1] + c * c2[1] + d * p3[1]];
55
+ }
56
+
57
+ function cubicTangent(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
58
+ const u = 1 - t;
59
+ const x = 3 * u * u * (c1[0] - p0[0]) + 6 * u * t * (c2[0] - c1[0]) + 3 * t * t * (p3[0] - c2[0]);
60
+ const y = 3 * u * u * (c1[1] - p0[1]) + 6 * u * t * (c2[1] - c1[1]) + 3 * t * t * (p3[1] - c2[1]);
61
+ return [x, y];
62
+ }
63
+
64
+ /** Where each segment starts. */
65
+ function starts(outline: Outline): Pt[] {
66
+ return outline.segs.map((_, i) => (i === 0 ? outline.start : outline.segs[i - 1].to));
67
+ }
68
+
69
+ /** The outline as points, each with the allowance of the edge that leaves it. */
70
+ function sample(outline: Outline, allowance: number, steps = 28) {
71
+ const from = starts(outline);
72
+ const pts: Pt[] = [];
73
+ const dist: number[] = [];
74
+ outline.segs.forEach((seg, i) => {
75
+ const d = seg.fold ? 0 : (seg.allowance ?? allowance);
76
+ const p0 = from[i];
77
+ if (seg.c1 && seg.c2) {
78
+ for (let k = 0; k < steps; k += 1) {
79
+ pts.push(cubic(p0, seg.c1, seg.c2, seg.to, k / steps));
80
+ dist.push(d);
81
+ }
82
+ } else {
83
+ pts.push(p0);
84
+ dist.push(d);
85
+ }
86
+ });
87
+ return { pts, dist };
88
+ }
89
+
90
+ function signedArea(pts: readonly Pt[]): number {
91
+ let a = 0;
92
+ for (let i = 0; i < pts.length; i += 1) {
93
+ const p = pts[i];
94
+ const q = pts[(i + 1) % pts.length];
95
+ a += p[0] * q[1] - q[0] * p[1];
96
+ }
97
+ return a / 2;
98
+ }
99
+
100
+ /** The outward side of a closed outline: +1 or -1, applied to the right-hand normal (dy, -dx). */
101
+ export function outwardSign(outline: Outline): number {
102
+ return signedArea(sample(outline, 0, 8).pts) >= 0 ? 1 : -1;
103
+ }
104
+
105
+ /**
106
+ * The outline offset outward, each edge by its own allowance: every edge's offset line, and at every vertex the
107
+ * meeting point of the two lines either side (a mitre), bevelled where a sharp corner would throw the mitre further
108
+ * than three allowances. Curves are sampled finely, so the offset of a curve is a true even distance from it.
109
+ */
110
+ export function offsetPoints(outline: Outline, allowance: number): Pt[] {
111
+ const { pts, dist } = sample(outline, allowance);
112
+ const n = pts.length;
113
+ const sign = signedArea(pts) >= 0 ? 1 : -1;
114
+ const lines = pts.map((p, i) => {
115
+ const q = pts[(i + 1) % n];
116
+ const dx = q[0] - p[0];
117
+ const dy = q[1] - p[1];
118
+ const len = Math.hypot(dx, dy) || 1;
119
+ const nx = (sign * dy) / len;
120
+ const ny = (sign * -dx) / len;
121
+ const d = dist[i];
122
+ return { p: [p[0] + nx * d, p[1] + ny * d] as Pt, q: [q[0] + nx * d, q[1] + ny * d] as Pt, dx: dx / len, dy: dy / len };
123
+ });
124
+ const out: Pt[] = [];
125
+ const limit = 3 * Math.max(allowance, ...dist);
126
+ for (let i = 0; i < n; i += 1) {
127
+ const a = lines[(i - 1 + n) % n];
128
+ const b = lines[i];
129
+ const cross = a.dx * b.dy - a.dy * b.dx;
130
+ if (Math.abs(cross) < 1e-6) {
131
+ out.push(b.p);
132
+ continue;
133
+ }
134
+ const t = ((b.p[0] - a.p[0]) * b.dy - (b.p[1] - a.p[1]) * b.dx) / cross;
135
+ const hit: Pt = [a.p[0] + a.dx * t, a.p[1] + a.dy * t];
136
+ if (Math.hypot(hit[0] - pts[i][0], hit[1] - pts[i][1]) > limit) {
137
+ out.push(a.q, b.p);
138
+ } else {
139
+ out.push(hit);
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /** The cut line: the outline offset outward by its allowances, as a closed path. */
146
+ export function offsetPath(outline: Outline, allowance: number): string {
147
+ return polyPath(offsetPoints(outline, allowance), true);
148
+ }
149
+
150
+ /** A point on segment `index` at t (0 to 1), with the outward unit normal there. */
151
+ export function pointOn(outline: Outline, index: number, t: number): { p: Pt; n: Pt } {
152
+ const seg = outline.segs[index];
153
+ const p0 = index === 0 ? outline.start : outline.segs[index - 1].to;
154
+ const sign = outwardSign(outline);
155
+ let p: Pt;
156
+ let tan: Pt;
157
+ if (seg.c1 && seg.c2) {
158
+ p = cubic(p0, seg.c1, seg.c2, seg.to, t);
159
+ tan = cubicTangent(p0, seg.c1, seg.c2, seg.to, Math.min(Math.max(t, 0.001), 0.999));
160
+ } else {
161
+ p = [p0[0] + (seg.to[0] - p0[0]) * t, p0[1] + (seg.to[1] - p0[1]) * t];
162
+ tan = [seg.to[0] - p0[0], seg.to[1] - p0[1]];
163
+ }
164
+ const len = Math.hypot(tan[0], tan[1]) || 1;
165
+ return { p, n: [(sign * tan[1]) / len, (sign * -tan[0]) / len] };
166
+ }
167
+
168
+ /** The bounding box of points. */
169
+ export function bounds(points: readonly Pt[]): { x: number; y: number; w: number; h: number } {
170
+ const xs = points.map((p) => p[0]);
171
+ const ys = points.map((p) => p[1]);
172
+ const x = Math.min(...xs);
173
+ const y = Math.min(...ys);
174
+ return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
175
+ }
176
+
177
+ /**
178
+ * Screen pixels per drawing unit, for a drawing `width` units wide inside `ref`. Starts from an assumed render width
179
+ * (so the server and the first paint agree), then follows the real width. Every line weight divides by it, so a
180
+ * hairline stays a hairline on a phone and on a wide screen.
181
+ */
182
+ export function usePen(ref: RefObject<Element | null>, width: number, assumedPx = 640): number {
183
+ const [px, setPx] = useState(assumedPx / width);
184
+ useEffect(() => {
185
+ const el = ref.current;
186
+ if (!el || typeof ResizeObserver === "undefined") return;
187
+ const measure = (w: number) => {
188
+ if (w > 0) setPx(Math.round((w / width) * 1000) / 1000);
189
+ };
190
+ measure(el.getBoundingClientRect().width);
191
+ const observer = new ResizeObserver((entries) => measure(entries[0]?.contentRect.width ?? 0));
192
+ observer.observe(el);
193
+ return () => observer.disconnect();
194
+ }, [ref, width]);
195
+ return px;
196
+ }