@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,44 @@
1
+ // components/struq/kit/subject/Mark.tsx (Struq recipe)
2
+ // The client's mark on a subject piece: their own logo when the caller has one, otherwise a small monogram drawn from
3
+ // their name (two initials in the display face, set in a solid disc of the accent). Never a stock icon.
4
+ //
5
+ // <Mark name="Atelier Oude Delft" /> -> a disc with "AO"
6
+ // <Mark name="Kraan & Zn." logo="/logo.svg" /> -> the logo, at the mark's height
7
+ //
8
+ // Props
9
+ // - name: the client's name; the monogram's letters and the logo's alt text.
10
+ // - logo: optional image src. Shown at the mark's height, its own width.
11
+ // - size: the mark's height in rem. Default 2.5.
12
+ // - shape: "round" (default) or "square" for the monogram.
13
+ import type { CSSProperties } from "react";
14
+ import { initials } from "./format";
15
+
16
+ type Props = {
17
+ name: string;
18
+ logo?: string;
19
+ size?: number;
20
+ shape?: "round" | "square";
21
+ className?: string;
22
+ };
23
+
24
+ export function Mark({ name, logo, size = 2.5, shape = "round", className = "" }: Props) {
25
+ const box: CSSProperties = { height: `${size}rem` };
26
+ if (logo) {
27
+ // eslint-disable-next-line @next/next/no-img-element
28
+ return <img src={logo} alt={name} style={box} className={`block w-auto ${className}`} />;
29
+ }
30
+ return (
31
+ <span
32
+ aria-hidden="true"
33
+ // Never under 12px: a small mark sets its letters in the body face at 12px, a large one in the display face.
34
+ style={{ ...box, width: `${size}rem`, fontSize: `${Math.max(size * 0.4, 0.75)}rem` }}
35
+ className={`inline-grid shrink-0 place-items-center bg-accent leading-none text-accent-ink ${
36
+ size >= 2 ? "font-display tracking-[-0.02em]" : "font-body font-medium tracking-[-0.01em]"
37
+ } ${
38
+ shape === "round" ? "rounded-full" : "rounded-[0.2em]"
39
+ } ${className}`}
40
+ >
41
+ <span className="translate-y-[0.03em]">{initials(name)}</span>
42
+ </span>
43
+ );
44
+ }
@@ -0,0 +1,410 @@
1
+ "use client";
2
+ // components/struq/kit/subject/PatternPaper.tsx (Struq recipe)
3
+ // A sewing pattern piece on pattern paper, drawn as a pattern maker draws it: a light centimetre grid, the piece from
4
+ // a named block (bodice front and back, sleeve, skirt panel, trouser front) with its true anatomy (necklines and
5
+ // armholes scoop inward), the stitching line dashed, the cut line a true even offset of it (computed per edge, the
6
+ // hem deeper, nothing on a fold), notches as snips into the allowance (one front, two back), the darts with their
7
+ // apex, the grainline arrow, the fold bracket ("CF, place on fold") where the piece is cut on the fold, and the
8
+ // piece's label block: style name, piece, size, how many to cut, the allowance included.
9
+ //
10
+ // <PatternPaper piece="bodice-front" style="The Ines blouse" size="38" number="1" locale="en-GB" />
11
+ //
12
+ // Props
13
+ // - piece: "bodice-front", "bodice-back", "sleeve", "skirt-panel" or "trouser-front" (patterns.ts).
14
+ // - style: the pattern's name, set largest in the label block. size: the size this sheet is graded to.
15
+ // - number: optional piece number ("1"). name: overrides the piece's own name ("Front").
16
+ // - allowance: the seam allowance in centimetres, default the block's own (1.5); hems keep their own depth.
17
+ // - cut: overrides the cutting line ("Cut 2 in lining").
18
+ // - labels: { fold?, cutFold?, cutPair?, seam?, size?, grain?, front?, back? }: the fixed words; English or Dutch
19
+ // by locale otherwise. seam may hold {n} for the allowance ("{n} cm seam allowance included").
20
+ // - notes: at most three named features, [{ word: "Notch", seg: 3, t: 0.4, on: "cut" }]: a small numeral sits on the
21
+ // cut edge ("cut") or the stitching line ("stitch") at that point of the outline, and the words are listed as a key
22
+ // under the sheet. Words never sit on the drawing, so nothing overprints at any width (trial-prim, 2026-11-15: hand
23
+ // callouts collided at 390). Further notes are dropped. Do not add callouts to this file; use notes.
24
+ // - locale (default "en-GB"), caption (a line under the sheet, in the scene's ink), build ("scroll" default, or
25
+ // "static"), className (on the figure).
26
+ //
27
+ // Mounting: inside any section, in five or more of twelve columns on a wide screen. The sheet is printed paper in the
28
+ // palette's light colour in every scene. The trouser front lies on its side on a wide sheet and stands upright on a
29
+ // narrow one. The root is a <figure data-subject="pattern">.
30
+ //
31
+ // Motion: tied to the scroll. The cut line draws itself around the piece as it comes up through the view, then the
32
+ // stitching line, the marks and the label set in. Reduced motion, build="static" or no scripts: the finished sheet.
33
+ import { useRef } from "react";
34
+ import { useScrollBuild } from "./build";
35
+ import { bounds, offsetPoints, outlinePath, pointOn, polyPath, usePen, type Pt } from "./draw";
36
+ import { languageOf, number as numberText } from "./format";
37
+ import { PATTERN_PIECES, turned, type PatternTemplate, type PieceName } from "./patterns";
38
+
39
+ type PatternLabels = {
40
+ fold: string;
41
+ cutFold: string;
42
+ cutPair: string;
43
+ seam: string;
44
+ size: string;
45
+ grain: string;
46
+ front: string;
47
+ back: string;
48
+ };
49
+
50
+ export type PatternNote = { word: string; seg: number; t: number; on?: "cut" | "stitch" };
51
+
52
+ const MAX_NOTES = 3;
53
+
54
+ type Props = {
55
+ piece: PieceName;
56
+ style: string;
57
+ size?: string;
58
+ number?: string;
59
+ name?: string;
60
+ allowance?: number;
61
+ cut?: string;
62
+ labels?: Partial<PatternLabels>;
63
+ locale?: string;
64
+ caption?: string;
65
+ notes?: PatternNote[];
66
+ build?: "scroll" | "static";
67
+ className?: string;
68
+ };
69
+
70
+ const WORDS: Record<string, PatternLabels & { names: Record<PieceName, string> }> = {
71
+ en: {
72
+ fold: "place on fold",
73
+ cutFold: "Cut 1 on fold",
74
+ cutPair: "Cut 2",
75
+ seam: "{n} cm seam allowance included",
76
+ size: "Size",
77
+ grain: "Straight grain",
78
+ front: "CF",
79
+ back: "CB",
80
+ names: {
81
+ "bodice-front": "Bodice front",
82
+ "bodice-back": "Bodice back",
83
+ sleeve: "Sleeve",
84
+ "skirt-panel": "Skirt front",
85
+ "trouser-front": "Trouser front",
86
+ },
87
+ },
88
+ nl: {
89
+ fold: "aan de stofvouw",
90
+ cutFold: "Knip 1x aan de stofvouw",
91
+ cutPair: "Knip 2x",
92
+ seam: "Inclusief {n} cm naadtoeslag",
93
+ size: "Maat",
94
+ grain: "Draadrichting",
95
+ front: "MV",
96
+ back: "MA",
97
+ names: {
98
+ "bodice-front": "Voorpand",
99
+ "bodice-back": "Achterpand",
100
+ sleeve: "Mouw",
101
+ "skirt-panel": "Rok voorpand",
102
+ "trouser-front": "Broek voorpand",
103
+ },
104
+ },
105
+ };
106
+
107
+ const SMALL = "text-[0.75rem] leading-[1.4]";
108
+
109
+ export function PatternPaper({
110
+ piece,
111
+ style,
112
+ size,
113
+ number,
114
+ name,
115
+ allowance,
116
+ cut,
117
+ labels,
118
+ locale = "en-GB",
119
+ caption,
120
+ notes: noteList,
121
+ build = "scroll",
122
+ className = "",
123
+ }: Props) {
124
+ const root = useRef<HTMLElement>(null);
125
+ const base = PATTERN_PIECES[piece];
126
+ const notes = (noteList ?? []).filter((n) => base.outline.segs[n.seg]).slice(0, MAX_NOTES);
127
+ const words = WORDS[languageOf(locale)] ?? WORDS.en;
128
+ const text: PatternLabels = { ...words, ...labels };
129
+ const sa = allowance ?? base.allowance;
130
+ const block = {
131
+ style,
132
+ piece: name ?? words.names[piece],
133
+ number,
134
+ size: size ? `${text.size} ${size}` : undefined,
135
+ cut: cut ?? (base.fold ? text.cutFold : text.cutPair),
136
+ seam: text.seam.replace("{n}", numberText(sa, locale)),
137
+ };
138
+
139
+ useScrollBuild(
140
+ root,
141
+ build === "scroll",
142
+ (el, tl) => {
143
+ const cuts = el.querySelectorAll<SVGPathElement>("[data-pattern-cut]");
144
+ tl.fromTo(cuts, { strokeDasharray: 1, strokeDashoffset: 1 }, { strokeDashoffset: 0, duration: 2 });
145
+ tl.from(el.querySelectorAll("[data-pattern-stitch]"), { autoAlpha: 0, duration: 0.6 }, "-=0.5");
146
+ tl.from(el.querySelectorAll("[data-pattern-mark]"), { autoAlpha: 0, duration: 0.4, stagger: 0.05 }, "-=0.3");
147
+ tl.from(el.querySelectorAll("[data-pattern-label]"), { autoAlpha: 0, y: 8, duration: 0.6, stagger: 0.1 }, "-=0.4");
148
+ },
149
+ [piece, sa],
150
+ { start: "top 80%", end: "bottom 85%" },
151
+ );
152
+
153
+ return (
154
+ <figure ref={root} data-subject="pattern" data-piece={piece} className={`@container m-0 ${className}`}>
155
+ <div data-paper="print" className="subject-paper mx-auto w-full max-w-[44rem] border border-line text-ink">
156
+ {base.long ? (
157
+ <>
158
+ <div className="@2xl:hidden">
159
+ <Sheet template={base} allowance={sa} text={text} block={block} notes={notes} />
160
+ </div>
161
+ <div className="hidden @2xl:block">
162
+ <Sheet template={turned(base)} allowance={sa} text={text} block={block} notes={notes} />
163
+ </div>
164
+ </>
165
+ ) : (
166
+ <Sheet template={base} allowance={sa} text={text} block={block} notes={notes} />
167
+ )}
168
+ </div>
169
+ {notes.length || caption ? (
170
+ <figcaption className="mx-auto mt-4 max-w-[44rem] font-body text-sheet-label text-ink-muted">
171
+ {notes.length ? (
172
+ <ol data-pattern-key="" className="m-0 flex list-none flex-wrap gap-x-6 gap-y-1 p-0 text-ink">
173
+ {notes.map((n, i) => (
174
+ <li key={`${n.word}-${i}`} className="flex gap-2">
175
+ <span className="subject-figures text-ink-muted">{i + 1}</span>
176
+ {n.word}
177
+ </li>
178
+ ))}
179
+ </ol>
180
+ ) : null}
181
+ {caption ? <p className={notes.length ? "m-0 mt-2" : "m-0"}>{caption}</p> : null}
182
+ </figcaption>
183
+ ) : null}
184
+ </figure>
185
+ );
186
+ }
187
+
188
+ type Block = { style: string; piece: string; number?: string; size?: string; cut: string; seam: string };
189
+
190
+ function Sheet({
191
+ template,
192
+ allowance,
193
+ text,
194
+ block,
195
+ notes,
196
+ }: {
197
+ template: PatternTemplate;
198
+ allowance: number;
199
+ text: PatternLabels;
200
+ block: Block;
201
+ notes: PatternNote[];
202
+ }) {
203
+ const svg = useRef<SVGSVGElement>(null);
204
+ const { outline } = template;
205
+ const cutPts = offsetPoints(outline, allowance);
206
+ const box = bounds(cutPts);
207
+ const margin = 2.5;
208
+ const left = template.fold ? 4.2 : margin;
209
+ const vb = {
210
+ x: Math.floor(box.x - left),
211
+ y: Math.floor(box.y - margin),
212
+ w: 0,
213
+ h: 0,
214
+ };
215
+ vb.w = Math.ceil(box.x + box.w + margin) - vb.x;
216
+ vb.h = Math.ceil(box.y + box.h + margin + 1) - vb.y;
217
+ const px = usePen(svg, vb.w, 600);
218
+ const u = (pixels: number) => pixels / px;
219
+ const at = (p: Pt) => ({ left: `${((p[0] - vb.x) / vb.w) * 100}%`, top: `${((p[1] - vb.y) / vb.h) * 100}%` });
220
+
221
+ const segAllowance = (i: number) => (outline.segs[i].fold ? 0 : (outline.segs[i].allowance ?? allowance));
222
+ const snip = (seg: number, t: number, shift = 0) => {
223
+ const { p, n } = pointOn(outline, seg, t);
224
+ const d = segAllowance(seg);
225
+ const along: Pt = [-n[1] * shift, n[0] * shift];
226
+ const reach = Math.min(0.9, d * 0.6);
227
+ const a: Pt = [p[0] + n[0] * d + along[0], p[1] + n[1] * d + along[1]];
228
+ const b: Pt = [p[0] + n[0] * (d - reach) + along[0], p[1] + n[1] * (d - reach) + along[1]];
229
+ return polyPath([a, b]);
230
+ };
231
+
232
+ const arrow = (tip: Pt, from: Pt) => {
233
+ const dx = tip[0] - from[0];
234
+ const dy = tip[1] - from[1];
235
+ const len = Math.hypot(dx, dy) || 1;
236
+ const ux = dx / len;
237
+ const uy = dy / len;
238
+ const l = u(9);
239
+ const w = u(4);
240
+ return polyPath([
241
+ [tip[0] - ux * l - uy * w, tip[1] - uy * l + ux * w],
242
+ tip,
243
+ [tip[0] - ux * l + uy * w, tip[1] - uy * l - ux * w],
244
+ ]);
245
+ };
246
+
247
+ // Each note marks a point on the cut edge or the stitching line; its numeral sits just inside the piece, and its
248
+ // word lives in the key under the sheet.
249
+ const marks = notes.map((note) => {
250
+ const { p, n } = pointOn(outline, note.seg, note.t);
251
+ const out = note.on === "stitch" ? 0 : segAllowance(note.seg);
252
+ const point: Pt = [p[0] + n[0] * out, p[1] + n[1] * out];
253
+ const inside = out + 1.4;
254
+ return { point, label: [p[0] + n[0] * (out - inside), p[1] + n[1] * (out - inside)] as Pt };
255
+ });
256
+
257
+ const [g0, g1] = template.grain;
258
+ const grainAngle = (Math.atan2(g1[1] - g0[1], g1[0] - g0[0]) * 180) / Math.PI;
259
+ const upright = Math.abs(grainAngle) > 45 && Math.abs(grainAngle) < 135;
260
+ const grainMid: Pt = [(g0[0] + g1[0]) / 2, (g0[1] + g1[1]) / 2];
261
+
262
+ const ink = "var(--ink)";
263
+ const minor = Array.from({ length: vb.w + 1 }, (_, i) => vb.x + i);
264
+ const rows = Array.from({ length: vb.h + 1 }, (_, i) => vb.y + i);
265
+
266
+ return (
267
+ <div className="relative">
268
+ <svg
269
+ ref={svg}
270
+ viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
271
+ className="block h-auto w-full"
272
+ role="img"
273
+ aria-label={`${block.style}, ${block.piece}`}
274
+ >
275
+ <g style={{ stroke: "color-mix(in oklab, var(--ink) 9%, transparent)", strokeWidth: u(0.6) }}>
276
+ {minor.map((x) => (
277
+ <line key={`x${x}`} x1={x} y1={vb.y} x2={x} y2={vb.y + vb.h} style={x % 5 === 0 ? { stroke: "color-mix(in oklab, var(--ink) 17%, transparent)", strokeWidth: u(0.9) } : undefined} />
278
+ ))}
279
+ {rows.map((y) => (
280
+ <line key={`y${y}`} x1={vb.x} y1={y} x2={vb.x + vb.w} y2={y} style={y % 5 === 0 ? { stroke: "color-mix(in oklab, var(--ink) 17%, transparent)", strokeWidth: u(0.9) } : undefined} />
281
+ ))}
282
+ </g>
283
+
284
+ <path data-pattern-cut="" pathLength={1} d={polyPath(cutPts, true)} fill="none" style={{ stroke: ink, strokeWidth: u(1.7), strokeLinejoin: "miter" }} />
285
+ <path
286
+ data-pattern-stitch=""
287
+ d={outlinePath(outline)}
288
+ fill="none"
289
+ style={{ stroke: ink, strokeWidth: u(0.9), strokeDasharray: `${u(6)} ${u(4)}` }}
290
+ />
291
+
292
+ <g data-pattern-mark="" style={{ stroke: ink, strokeWidth: u(1.2), fill: "none", strokeLinecap: "round" }}>
293
+ {template.notches.flatMap((notch) =>
294
+ notch.count === 2
295
+ ? [<path key={`${notch.seg}-${notch.t}-a`} d={snip(notch.seg, notch.t, -0.32)} />, <path key={`${notch.seg}-${notch.t}-b`} d={snip(notch.seg, notch.t, 0.32)} />]
296
+ : [<path key={`${notch.seg}-${notch.t}`} d={snip(notch.seg, notch.t)} />],
297
+ )}
298
+ </g>
299
+
300
+ {template.darts.map((dart) => {
301
+ const a = pointOn(outline, dart.seg, dart.t1).p;
302
+ const b = pointOn(outline, dart.seg, dart.t2).p;
303
+ return (
304
+ <g key={`${dart.seg}-${dart.t1}`} data-pattern-mark="">
305
+ <path d={polyPath([a, dart.apex, b])} fill="none" style={{ stroke: ink, strokeWidth: u(0.9), strokeDasharray: `${u(6)} ${u(4)}` }} />
306
+ <path d={snip(dart.seg, dart.t1)} style={{ stroke: ink, strokeWidth: u(1.2) }} />
307
+ <path d={snip(dart.seg, dart.t2)} style={{ stroke: ink, strokeWidth: u(1.2) }} />
308
+ <circle cx={dart.apex[0]} cy={dart.apex[1]} r={u(2.6)} style={{ fill: ink }} />
309
+ </g>
310
+ );
311
+ })}
312
+
313
+ <g data-pattern-mark="" style={{ stroke: ink, strokeWidth: u(1), fill: "none", strokeLinejoin: "round", strokeLinecap: "round" }}>
314
+ <path d={polyPath([g0, g1])} />
315
+ <path d={arrow(g0, g1)} />
316
+ <path d={arrow(g1, g0)} />
317
+ </g>
318
+
319
+ {template.fold ? (
320
+ <g data-pattern-mark="" style={{ stroke: ink, strokeWidth: u(1), fill: "none", strokeLinejoin: "round", strokeLinecap: "round" }}>
321
+ <path
322
+ d={polyPath([
323
+ [template.fold.from[0] - u(2), template.fold.from[1]],
324
+ [template.fold.from[0] - 2.2, template.fold.from[1]],
325
+ [template.fold.to[0] - 2.2, template.fold.to[1]],
326
+ [template.fold.to[0] - u(2), template.fold.to[1]],
327
+ ])}
328
+ />
329
+ <path d={arrow([template.fold.from[0] - u(2), template.fold.from[1]], [template.fold.from[0] - 2.2, template.fold.from[1]])} />
330
+ <path d={arrow([template.fold.to[0] - u(2), template.fold.to[1]], [template.fold.to[0] - 2.2, template.fold.to[1]])} />
331
+ </g>
332
+ ) : null}
333
+
334
+ {marks.length ? (
335
+ <g data-pattern-mark="" style={{ fill: ink }}>
336
+ {marks.map((m, i) => (
337
+ <circle key={i} cx={m.point[0]} cy={m.point[1]} r={u(3)} />
338
+ ))}
339
+ </g>
340
+ ) : null}
341
+
342
+ <g data-pattern-mark="" style={{ stroke: ink, strokeWidth: u(1.2), fill: "none" }}>
343
+ <path d={polyPath([[vb.x + vb.w - 1.5 - 5, vb.y + vb.h - 1.4], [vb.x + vb.w - 1.5, vb.y + vb.h - 1.4]])} />
344
+ <path d={polyPath([[vb.x + vb.w - 6.5, vb.y + vb.h - 1.4 - u(4)], [vb.x + vb.w - 6.5, vb.y + vb.h - 1.4 + u(4)]])} />
345
+ <path d={polyPath([[vb.x + vb.w - 1.5, vb.y + vb.h - 1.4 - u(4)], [vb.x + vb.w - 1.5, vb.y + vb.h - 1.4 + u(4)]])} />
346
+ </g>
347
+ </svg>
348
+
349
+ <div aria-hidden="true" className="pointer-events-none absolute inset-0">
350
+ {marks.map((m, i) => (
351
+ <span
352
+ key={i}
353
+ data-pattern-mark=""
354
+ className={`absolute ${SMALL} subject-figures font-medium`}
355
+ style={{ ...at(m.label), transform: "translate(-50%, -50%)" }}
356
+ >
357
+ {i + 1}
358
+ </span>
359
+ ))}
360
+ {template.fold ? (
361
+ <span
362
+ data-pattern-mark=""
363
+ className={`absolute whitespace-nowrap ${SMALL} font-medium`}
364
+ style={{
365
+ ...at([template.fold.from[0] - 2.2, (template.fold.from[1] + template.fold.to[1]) / 2]),
366
+ // Centred on the bracket, turned to read upward, then moved off it to the left by half its own height.
367
+ transform: "translate(-50%, -50%) rotate(-90deg) translateY(calc(-50% - 0.45rem))",
368
+ }}
369
+ >
370
+ {`${template.fold.side === "CF" ? text.front : text.back}, ${text.fold}`}
371
+ </span>
372
+ ) : null}
373
+ <span
374
+ data-pattern-mark=""
375
+ className={`absolute whitespace-nowrap ${SMALL} text-ink-muted`}
376
+ style={{
377
+ ...at(grainMid),
378
+ transform: upright
379
+ ? "translate(-50%, -50%) rotate(-90deg) translateY(calc(50% + 0.4rem))"
380
+ : "translate(-50%, calc(-100% - 0.3rem))",
381
+ }}
382
+ >
383
+ {text.grain}
384
+ </span>
385
+ <span
386
+ data-pattern-mark=""
387
+ className={`absolute whitespace-nowrap ${SMALL} text-ink-muted subject-figures`}
388
+ style={{ ...at([vb.x + vb.w - 4, vb.y + vb.h - 1.4]), transform: "translate(-50%, calc(-100% - 0.35rem))" }}
389
+ >
390
+ 5 cm
391
+ </span>
392
+ </div>
393
+
394
+ <div
395
+ data-pattern-label=""
396
+ className="absolute"
397
+ style={{ ...at(template.label.at), width: `${(template.label.width / vb.w) * 100}%` }}
398
+ >
399
+ <p className="m-0 font-display text-[clamp(1rem,3.6cqi,1.625rem)] leading-[1.1] tracking-[-0.01em]">{block.style}</p>
400
+ <p className={`m-0 mt-1.5 ${SMALL} font-medium`}>
401
+ {block.number ? <span className="subject-figures">{`${block.number} `}</span> : null}
402
+ {block.piece}
403
+ </p>
404
+ {block.size ? <p className={`m-0 ${SMALL} subject-figures`}>{block.size}</p> : null}
405
+ <p className={`m-0 ${SMALL}`}>{block.cut}</p>
406
+ <p className={`m-0 ${SMALL} text-ink-muted`}>{block.seam}</p>
407
+ </div>
408
+ </div>
409
+ );
410
+ }