@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,742 @@
1
+ "use client";
2
+ // components/struq/kit/subject/Document.tsx (Struq recipe)
3
+ // A typeset paper document of a trade: an invoice, a quote, a receipt, a packing slip, a menu or a prescription
4
+ // label, set the way a good print shop would set it. The client's mark (their logo, or a monogram drawn from their
5
+ // name), the document number, dates, the parties, line items with quantities and amounts in tabular figures aligned
6
+ // on the decimal, subtotal, the tax line with its rate, the total, the payment line and the small print. Money and
7
+ // dates go through Intl in the client's locale and currency. The builder fills data; the typesetting lives here.
8
+ //
9
+ // <Document kind="invoice" locale="nl-NL" currency="EUR" number="2026-0142"
10
+ // from={{ name: "Kraan & Zn. Installatietechniek", lines: ["Havenstraat 12", "3011 AB Rotterdam"] }}
11
+ // to={{ name: "Familie de Wit", lines: ["Lindelaan 4", "3062 KL Rotterdam"] }}
12
+ // fields={[{ label: "Factuurdatum", date: "2026-11-15" }, { label: "Vervaldatum", date: "2026-11-29" }]}
13
+ // lines={[{ item: "Knelkoppeling 15 mm vervangen", qty: 2, price: 38.5 }, { item: "Voorrijkosten", amount: 45 }]}
14
+ // tax={{ rate: 21 }} payment="Graag binnen 14 dagen op NL91 ABNA 0417 1643 00 onder vermelding van 2026-0142."
15
+ // footer="KvK 81234567, btw NL0012.34.567.B01" caption="Een factuur zoals Kraan & Zn. hem verstuurt" />
16
+ //
17
+ // Props
18
+ // - kind: "invoice" (default), "quote", "receipt", "packing-slip", "menu" or "label" (a prescription or product label).
19
+ // It sets the layout and the default title; every label can be overridden.
20
+ // - from: the issuer. name, lines (address, phone), logo (an image src; without it a monogram from the name).
21
+ // - to: optional, the other party: name, lines, label ("Billed to", "Ship to", the patient on a label).
22
+ // - number: the document number, set in tabular figures. title: overrides the kind's title ("Credit note").
23
+ // - fields: { label, value } or { label, date } pairs (dates as ISO 2026-11-15, written in the locale). Issue date,
24
+ // due date, reference, table number, refills: whatever the document carries, in the client's words.
25
+ // - headline, text: a large line and a paragraph (the drug and its instructions on a label, a menu's season).
26
+ // - lines: { item, note?, qty?, unit?, price?, amount? }[]; amount defaults to qty times price. A { heading, lines }
27
+ // entry starts a group (a menu's courses). Amounts sit in a column without symbol, aligned on the decimal.
28
+ // - tax: { rate, label?, included? }. rate in percent. included: the prices already hold it (a receipt), so the total
29
+ // stays and the tax line says how much of it is tax.
30
+ // - payment: the payment line, in the client's words. footer: the small print.
31
+ // - currency (ISO 4217, default "EUR") and locale (BCP 47, default "en-GB"). The default labels follow the locale's
32
+ // language (English or Dutch); pass labels for any other.
33
+ // - paper: "print" (default) a printed sheet in the palette's light colour and dark ink, whatever the scene;
34
+ // "scene" the scene's own surface, for a document shown inside an AppScreen.
35
+ // - edge: "plain" (default), "fold" (a crease a third of the way down), "perforated" (the payment line on a tear-off
36
+ // slip) or "torn" (a receipt's torn foot).
37
+ // - caption: a line under the sheet, in the scene's ink ("Sample invoice"). Saying it is a sample is the caller's job.
38
+ // - build: "scroll" (default) or "static".
39
+ // - className: on the figure. Width: the figure fills its column; the sheet keeps its own measure inside it.
40
+ //
41
+ // Mounting: inside any section, in a column of its own (an invoice wants six or more of twelve columns on a wide
42
+ // screen; a receipt or label four). The root is a <figure data-subject="document">.
43
+ //
44
+ // Motion: tied to the scroll. As the sheet comes up through the view its lines set in one after another and the total
45
+ // counts up from zero; it stands finished once its foot is in view. Reduced motion, build="static" or no scripts: the
46
+ // finished sheet, nothing moves.
47
+ import { useRef, type ReactNode } from "react";
48
+ import { useScrollBuild, countUp } from "./build";
49
+ import { amount, currencyDigits, dateText, FIGURES, keepUnits, languageOf, money, number as numberText, round } from "./format";
50
+ import { Mark } from "./Mark";
51
+
52
+ type Party = { name: string; lines?: string[] };
53
+ type Issuer = Party & { logo?: string };
54
+ type Recipient = Party & { label?: string };
55
+ type Field = { label: string; value?: string; date?: string };
56
+ type Line = { item: string; note?: string; qty?: number; unit?: string; price?: number; amount?: number };
57
+ type Group = { heading: string; lines: Line[] };
58
+ type Kind = "invoice" | "quote" | "receipt" | "packing-slip" | "menu" | "label";
59
+ type DocumentLabels = {
60
+ title: string;
61
+ number: string;
62
+ to: string;
63
+ item: string;
64
+ qty: string;
65
+ price: string;
66
+ amount: string;
67
+ subtotal: string;
68
+ tax: string;
69
+ total: string;
70
+ included: string;
71
+ };
72
+
73
+ type Props = {
74
+ kind?: Kind;
75
+ from: Issuer;
76
+ to?: Recipient;
77
+ number?: string;
78
+ title?: string;
79
+ fields?: Field[];
80
+ headline?: string;
81
+ text?: string;
82
+ lines?: (Line | Group)[];
83
+ tax?: { rate: number; label?: string; included?: boolean };
84
+ payment?: string;
85
+ footer?: string;
86
+ currency?: string;
87
+ locale?: string;
88
+ labels?: Partial<DocumentLabels>;
89
+ paper?: "print" | "scene";
90
+ edge?: "plain" | "fold" | "perforated" | "torn";
91
+ caption?: string;
92
+ build?: "scroll" | "static";
93
+ className?: string;
94
+ };
95
+
96
+ const TITLES: Record<string, Record<Kind, string>> = {
97
+ en: { invoice: "Invoice", quote: "Quote", receipt: "Receipt", "packing-slip": "Packing slip", menu: "Menu", label: "" },
98
+ nl: { invoice: "Factuur", quote: "Offerte", receipt: "Kassabon", "packing-slip": "Pakbon", menu: "Menukaart", label: "" },
99
+ };
100
+
101
+ const LABELS: Record<string, Omit<DocumentLabels, "title" | "to"> & { to: Record<Kind, string> }> = {
102
+ en: {
103
+ number: "No.",
104
+ to: { invoice: "Billed to", quote: "Prepared for", receipt: "Customer", "packing-slip": "Ship to", menu: "", label: "Patient" },
105
+ item: "Description",
106
+ qty: "Qty",
107
+ price: "Price",
108
+ amount: "Amount",
109
+ subtotal: "Subtotal",
110
+ tax: "VAT",
111
+ total: "Total",
112
+ included: "incl.",
113
+ },
114
+ nl: {
115
+ number: "Nr.",
116
+ to: { invoice: "Factuur aan", quote: "Opgesteld voor", receipt: "Klant", "packing-slip": "Afleveradres", menu: "", label: "Patiënt" },
117
+ item: "Omschrijving",
118
+ qty: "Aantal",
119
+ price: "Prijs",
120
+ amount: "Bedrag",
121
+ subtotal: "Subtotaal",
122
+ tax: "Btw",
123
+ total: "Totaal",
124
+ included: "incl.",
125
+ },
126
+ };
127
+
128
+ function labelsFor(kind: Kind, locale: string, own: Partial<DocumentLabels> = {}): DocumentLabels {
129
+ const lang = LABELS[languageOf(locale)] ? languageOf(locale) : "en";
130
+ const base = LABELS[lang];
131
+ return { ...base, title: TITLES[lang][kind], to: base.to[kind], ...own };
132
+ }
133
+
134
+ const isGroup = (entry: Line | Group): entry is Group => "heading" in entry && Array.isArray((entry as Group).lines);
135
+
136
+ const lineAmount = (line: Line) =>
137
+ line.amount ?? (line.price !== undefined ? (line.qty ?? 1) * line.price : undefined);
138
+
139
+ /** Small text: never under 12px. */
140
+ const SMALL = "text-[0.75rem] leading-[1.5]";
141
+ const LABEL = `${SMALL} text-ink-muted`;
142
+ const BODY = "text-[0.875rem] leading-[1.55] @lg:text-[0.9375rem]";
143
+
144
+ export function Document({
145
+ kind = "invoice",
146
+ from,
147
+ to,
148
+ number,
149
+ title,
150
+ fields = [],
151
+ headline,
152
+ text,
153
+ lines = [],
154
+ tax,
155
+ payment,
156
+ footer,
157
+ currency = "EUR",
158
+ locale = "en-GB",
159
+ labels: own,
160
+ paper = "print",
161
+ edge = "plain",
162
+ caption,
163
+ build = "scroll",
164
+ className = "",
165
+ }: Props) {
166
+ const root = useRef<HTMLElement>(null);
167
+ const labels = labelsFor(kind, locale, own);
168
+ const heading = title ?? labels.title;
169
+
170
+ const flat = lines.flatMap((entry) => (isGroup(entry) ? entry.lines : [entry]));
171
+ const priced = kind !== "packing-slip" && flat.some((line) => lineAmount(line) !== undefined);
172
+ const counted = kind !== "menu" && flat.some((line) => line.qty !== undefined);
173
+ const unitPriced = priced && counted && flat.some((line) => line.price !== undefined);
174
+ const digits = currencyDigits(locale, currency);
175
+ const subtotal = round(
176
+ flat.reduce((sum, line) => sum + (lineAmount(line) ?? 0), 0),
177
+ digits,
178
+ );
179
+ const taxAmount = tax
180
+ ? round(tax.included ? subtotal - subtotal / (1 + tax.rate / 100) : (subtotal * tax.rate) / 100, digits)
181
+ : 0;
182
+ const total = tax && !tax.included ? round(subtotal + taxAmount, digits) : subtotal;
183
+ const totals = priced && kind !== "menu";
184
+
185
+ useScrollBuild(
186
+ root,
187
+ build === "scroll",
188
+ (el, tl) => {
189
+ const rows = el.querySelectorAll("[data-doc-line]");
190
+ tl.from(el.querySelectorAll("[data-doc-head] > *"), { autoAlpha: 0, y: 10, stagger: 0.1, duration: 0.6 });
191
+ if (rows.length) tl.from(rows, { autoAlpha: 0, y: 8, stagger: 0.18, duration: 0.5 }, ">-0.1");
192
+ const sums = el.querySelectorAll("[data-doc-sum]");
193
+ if (sums.length) tl.from(sums, { autoAlpha: 0, y: 6, stagger: 0.12, duration: 0.4 }, ">-0.1");
194
+ countUp(tl, el.querySelector<HTMLElement>("[data-count]"), (v) => money(v, locale, currency), "<");
195
+ tl.from(el.querySelectorAll("[data-doc-foot]"), { autoAlpha: 0, duration: 0.4 }, ">-0.2");
196
+ },
197
+ [kind, lines.length, total],
198
+ );
199
+
200
+ const fieldValue = (field: Field) => (field.date ? dateText(field.date, locale, kind === "receipt" ? "medium" : "long") : field.value);
201
+ const fig = (value: number) => amount(value, locale, currency);
202
+ const taxLabel = tax ? `${tax.label ?? labels.tax} ${numberText(tax.rate, locale)}%` : "";
203
+
204
+ const shared = { from, to, number, heading, fields, headline: headline && keepUnits(headline), text: text && keepUnits(text), labels, locale, fieldValue, fig, payment, footer };
205
+ const sums = totals ? (
206
+ <Totals
207
+ subtotal={subtotal}
208
+ taxAmount={taxAmount}
209
+ total={total}
210
+ tax={tax}
211
+ taxLabel={taxLabel}
212
+ labels={labels}
213
+ locale={locale}
214
+ currency={currency}
215
+ compact={kind === "receipt"}
216
+ />
217
+ ) : null;
218
+
219
+ const measure =
220
+ kind === "receipt" ? "max-w-[22rem]" : kind === "label" ? "max-w-[34rem]" : kind === "menu" ? "max-w-[36rem]" : "max-w-[46rem]";
221
+
222
+ return (
223
+ <figure ref={root} data-subject="document" data-kind={kind} className={`@container m-0 ${className}`}>
224
+ <div
225
+ data-paper={paper}
226
+ data-edge={edge}
227
+ className={`subject-paper relative mx-auto w-full ${measure} ${kind === "label" ? "rounded-[0.75rem]" : ""} border border-line text-ink`}
228
+ >
229
+ {edge === "fold" ? <span aria-hidden="true" className="subject-fold" /> : null}
230
+ {kind === "receipt" ? (
231
+ <Receipt {...shared} lines={flat} counted={counted} priced={priced} sums={sums} />
232
+ ) : kind === "menu" ? (
233
+ <Menu {...shared} lines={lines} />
234
+ ) : kind === "label" ? (
235
+ <Label {...shared} />
236
+ ) : (
237
+ <Sheet
238
+ {...shared}
239
+ lines={lines}
240
+ counted={counted}
241
+ priced={priced}
242
+ unitPriced={unitPriced}
243
+ currency={currency}
244
+ sums={sums}
245
+ edge={edge}
246
+ />
247
+ )}
248
+ {edge === "torn" ? <TornEdge /> : null}
249
+ </div>
250
+ {caption ? <figcaption className={`mx-auto mt-4 w-full ${measure} font-body text-sheet-label text-ink-muted`}>{caption}</figcaption> : null}
251
+ </figure>
252
+ );
253
+ }
254
+
255
+ type Shared = {
256
+ from: Issuer;
257
+ to?: Recipient;
258
+ number?: string;
259
+ heading: string;
260
+ fields: Field[];
261
+ headline?: string;
262
+ text?: string;
263
+ labels: DocumentLabels;
264
+ locale: string;
265
+ fieldValue: (field: Field) => string | undefined;
266
+ fig: (value: number) => string;
267
+ payment?: string;
268
+ footer?: string;
269
+ };
270
+
271
+ function PartyBlock({ party, label }: { party: Recipient; label?: string }) {
272
+ return (
273
+ <div>
274
+ {label ? <p className={`m-0 ${LABEL}`}>{label}</p> : null}
275
+ <p className={`m-0 mt-1.5 font-medium ${BODY}`}>{party.name}</p>
276
+ {party.lines?.map((line) => (
277
+ <p key={line} className={`m-0 ${BODY} text-ink-muted`}>
278
+ {line}
279
+ </p>
280
+ ))}
281
+ </div>
282
+ );
283
+ }
284
+
285
+ function FieldList({ fields, fieldValue, className = "" }: { fields: Field[]; fieldValue: Shared["fieldValue"]; className?: string }) {
286
+ if (fields.length === 0) return null;
287
+ return (
288
+ <dl className={`m-0 grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 ${className}`}>
289
+ {fields.map((field) => (
290
+ <div key={field.label} className="contents">
291
+ <dt className={LABEL}>{field.label}</dt>
292
+ <dd className={`m-0 text-right ${SMALL} ${FIGURES}`}>{fieldValue(field)}</dd>
293
+ </div>
294
+ ))}
295
+ </dl>
296
+ );
297
+ }
298
+
299
+ /** Invoice, quote, packing slip: a sheet with a header, the parties, a ruled table and the sums under the amounts. */
300
+ function Sheet({
301
+ from,
302
+ to,
303
+ number,
304
+ heading,
305
+ fields,
306
+ headline,
307
+ text,
308
+ labels,
309
+ fieldValue,
310
+ fig,
311
+ locale,
312
+ payment,
313
+ footer,
314
+ lines,
315
+ counted,
316
+ priced,
317
+ unitPriced,
318
+ currency,
319
+ sums,
320
+ edge,
321
+ }: Shared & {
322
+ lines: (Line | Group)[];
323
+ counted: boolean;
324
+ priced: boolean;
325
+ unitPriced: boolean;
326
+ currency: string;
327
+ sums: ReactNode;
328
+ edge: string;
329
+ }) {
330
+ const cols = 1 + (counted ? 1 : 0) + (unitPriced ? 1 : 0) + (priced ? 1 : 0);
331
+ return (
332
+ <div className="px-[var(--pad)] pb-8 pt-7 [--pad:1.5rem] @lg:pb-12 @lg:pt-12 @lg:[--pad:3.5rem]">
333
+ <div data-doc-head="" className="flex flex-wrap items-start justify-between gap-x-10 gap-y-5">
334
+ <div className="flex items-center gap-3.5">
335
+ <Mark name={from.name} logo={from.logo} size={2.25} />
336
+ <p className="m-0 max-w-[16rem] font-display text-[1.125rem] leading-[1.2] @lg:text-[1.25rem]">{from.name}</p>
337
+ </div>
338
+ {from.lines?.length ? (
339
+ <address className={`not-italic ${SMALL} text-ink-muted @lg:text-right`}>
340
+ {from.lines.map((line) => (
341
+ <span key={line} className="block">
342
+ {line}
343
+ </span>
344
+ ))}
345
+ </address>
346
+ ) : null}
347
+ </div>
348
+
349
+ <div data-doc-head="" className="mt-12 flex flex-wrap items-end justify-between gap-x-8 gap-y-3 @lg:mt-20">
350
+ <p className="m-0 font-display text-[clamp(2.25rem,9cqi,3.75rem)] leading-[1] tracking-[-0.02em]">{heading}</p>
351
+ {number ? (
352
+ <p className={`m-0 ${BODY}`}>
353
+ <span className="text-ink-muted">{labels.number} </span>
354
+ <span className={FIGURES}>{number}</span>
355
+ </p>
356
+ ) : null}
357
+ </div>
358
+
359
+ {to || fields.length ? (
360
+ <div data-doc-head="" className="mt-8 grid gap-x-10 gap-y-6 border-t border-line pt-6 @lg:grid-cols-2">
361
+ {to ? <PartyBlock party={to} label={to.label ?? labels.to} /> : <span />}
362
+ <FieldList fields={fields} fieldValue={fieldValue} className="self-start @lg:justify-self-end" />
363
+ </div>
364
+ ) : null}
365
+
366
+ {headline || text ? (
367
+ <div data-doc-head="" className="mt-8">
368
+ {headline ? <p className="m-0 font-display text-[1.375rem] leading-[1.25]">{headline}</p> : null}
369
+ {text ? <p className={`m-0 mt-2 max-w-[52ch] ${BODY} text-ink-muted`}>{text}</p> : null}
370
+ </div>
371
+ ) : null}
372
+
373
+ {lines.length ? (
374
+ <table className={`mt-10 w-full border-collapse ${BODY} @lg:mt-14`}>
375
+ <thead>
376
+ <tr className="border-b border-[color:var(--ink)]">
377
+ <th scope="col" className={`pb-2.5 text-left font-normal ${LABEL}`}>
378
+ {labels.item}
379
+ </th>
380
+ {counted ? (
381
+ <th scope="col" className={`hidden pb-2.5 pl-4 text-right font-normal @md:table-cell ${LABEL}`}>
382
+ {labels.qty}
383
+ </th>
384
+ ) : null}
385
+ {unitPriced ? (
386
+ <th scope="col" className={`hidden pb-2.5 pl-4 text-right font-normal @lg:table-cell ${LABEL}`}>
387
+ {labels.price}
388
+ </th>
389
+ ) : null}
390
+ {priced ? (
391
+ <th scope="col" className={`pb-2.5 pl-4 text-right font-normal ${LABEL} whitespace-nowrap`}>
392
+ {labels.amount} <span className="opacity-80">{currency}</span>
393
+ </th>
394
+ ) : null}
395
+ </tr>
396
+ </thead>
397
+ <tbody>
398
+ {lines.map((entry, i) =>
399
+ isGroup(entry) ? (
400
+ <GroupRows key={`${entry.heading}-${i}`} group={entry} cols={cols} counted={counted} priced={priced} unitPriced={unitPriced} fig={fig} locale={locale} />
401
+ ) : (
402
+ <LineRow key={`${entry.item}-${i}`} line={entry} counted={counted} priced={priced} unitPriced={unitPriced} fig={fig} locale={locale} />
403
+ ),
404
+ )}
405
+ </tbody>
406
+ </table>
407
+ ) : null}
408
+
409
+ {sums}
410
+
411
+ {payment ? (
412
+ <div data-doc-foot="" className={edge === "perforated" ? "subject-slip mt-12 pt-7" : "mt-12"}>
413
+ <p className={`m-0 max-w-[60ch] ${BODY}`}>{payment}</p>
414
+ </div>
415
+ ) : null}
416
+
417
+ {footer ? (
418
+ <p data-doc-foot="" className={`m-0 mt-10 border-t border-line pt-4 ${SMALL} text-ink-muted @lg:mt-16`}>
419
+ {footer}
420
+ </p>
421
+ ) : null}
422
+ </div>
423
+ );
424
+ }
425
+
426
+ function LineRow({
427
+ line,
428
+ counted,
429
+ priced,
430
+ unitPriced,
431
+ fig,
432
+ locale,
433
+ }: {
434
+ line: Line;
435
+ counted: boolean;
436
+ priced: boolean;
437
+ unitPriced: boolean;
438
+ fig: (value: number) => string;
439
+ locale: string;
440
+ }) {
441
+ const value = lineAmount(line);
442
+ const qty = line.qty !== undefined ? `${numberText(line.qty, locale)}${line.unit ? ` ${line.unit}` : ""}` : "";
443
+ return (
444
+ <tr data-doc-line="" className="border-b border-line align-baseline">
445
+ <td className="py-3 pr-2 @lg:py-3.5">
446
+ <span className="block">{line.item}</span>
447
+ {line.note ? <span className={`block ${SMALL} text-ink-muted`}>{line.note}</span> : null}
448
+ {/* The quantity and price under the item where their columns do not fit. */}
449
+ {counted && line.qty !== undefined ? (
450
+ <span className={`block ${SMALL} text-ink-muted ${unitPriced ? "@lg:hidden" : "@md:hidden"}`}>
451
+ <span className={FIGURES}>
452
+ {qty}
453
+ {unitPriced && line.price !== undefined ? ` × ${fig(line.price)}` : ""}
454
+ </span>
455
+ </span>
456
+ ) : null}
457
+ </td>
458
+ {counted ? <td className={`hidden py-3 pl-4 text-right @md:table-cell @lg:py-3.5 ${FIGURES}`}>{qty}</td> : null}
459
+ {unitPriced ? (
460
+ <td className={`hidden py-3 pl-4 text-right text-ink-muted @lg:table-cell @lg:py-3.5 ${FIGURES}`}>
461
+ {line.price !== undefined ? fig(line.price) : ""}
462
+ </td>
463
+ ) : null}
464
+ {priced ? <td className={`py-3 pl-4 text-right @lg:py-3.5 ${FIGURES}`}>{value !== undefined ? fig(value) : ""}</td> : null}
465
+ </tr>
466
+ );
467
+ }
468
+
469
+ function GroupRows({
470
+ group,
471
+ cols,
472
+ ...rest
473
+ }: {
474
+ group: Group;
475
+ cols: number;
476
+ counted: boolean;
477
+ priced: boolean;
478
+ unitPriced: boolean;
479
+ fig: (value: number) => string;
480
+ locale: string;
481
+ }) {
482
+ return (
483
+ <>
484
+ <tr data-doc-line="">
485
+ <th scope="colgroup" colSpan={cols} className="pb-1 pt-6 text-left font-display text-[1.0625rem] font-normal">
486
+ {group.heading}
487
+ </th>
488
+ </tr>
489
+ {group.lines.map((line, i) => (
490
+ <LineRow key={`${line.item}-${i}`} line={line} {...rest} />
491
+ ))}
492
+ </>
493
+ );
494
+ }
495
+
496
+ function Totals({
497
+ subtotal,
498
+ taxAmount,
499
+ total,
500
+ tax,
501
+ taxLabel,
502
+ labels,
503
+ locale,
504
+ currency,
505
+ compact,
506
+ }: {
507
+ subtotal: number;
508
+ taxAmount: number;
509
+ total: number;
510
+ tax?: { rate: number; label?: string; included?: boolean };
511
+ taxLabel: string;
512
+ labels: DocumentLabels;
513
+ locale: string;
514
+ currency: string;
515
+ compact: boolean;
516
+ }) {
517
+ const fig = (value: number) => amount(value, locale, currency);
518
+ return (
519
+ <dl className={`m-0 ml-auto grid grid-cols-[1fr_auto] gap-x-8 ${compact ? "mt-4 w-full" : "mt-6 w-full @lg:w-[58%]"} ${BODY}`}>
520
+ {tax && !tax.included ? (
521
+ <>
522
+ <dt data-doc-sum="" className="py-1 text-ink-muted">
523
+ {labels.subtotal}
524
+ </dt>
525
+ <dd data-doc-sum="" className={`m-0 py-1 text-right ${FIGURES}`}>
526
+ {fig(subtotal)}
527
+ </dd>
528
+ <dt data-doc-sum="" className="py-1 text-ink-muted">
529
+ {taxLabel}
530
+ </dt>
531
+ <dd data-doc-sum="" className={`m-0 py-1 text-right ${FIGURES}`}>
532
+ {fig(taxAmount)}
533
+ </dd>
534
+ </>
535
+ ) : null}
536
+ <span data-doc-sum="" aria-hidden="true" className="col-span-2 mt-2 h-0 border-t border-[color:var(--ink)]" />
537
+ <dt data-doc-sum="" className="self-baseline pt-3 font-medium">
538
+ {labels.total}
539
+ </dt>
540
+ <dd data-doc-sum="" className="m-0 self-baseline pt-3 text-right">
541
+ <span
542
+ data-count={total}
543
+ className={`font-display text-[1.625rem] leading-none tracking-[-0.01em] @lg:text-[2rem] ${FIGURES}`}
544
+ >
545
+ {money(total, locale, currency)}
546
+ </span>
547
+ </dd>
548
+ {tax?.included ? (
549
+ <>
550
+ <dt data-doc-sum="" className={`pt-1.5 ${LABEL}`}>
551
+ {labels.included} {taxLabel}
552
+ </dt>
553
+ <dd data-doc-sum="" className={`m-0 pt-1.5 text-right ${LABEL} ${FIGURES}`}>
554
+ {fig(taxAmount)}
555
+ </dd>
556
+ </>
557
+ ) : null}
558
+ </dl>
559
+ );
560
+ }
561
+
562
+ /** A till receipt: narrow, centred head, dashed rules, each line's quantity under its item. */
563
+ function Receipt({
564
+ from,
565
+ to,
566
+ number,
567
+ heading,
568
+ fields,
569
+ labels,
570
+ fieldValue,
571
+ fig,
572
+ locale,
573
+ payment,
574
+ footer,
575
+ lines,
576
+ counted,
577
+ priced,
578
+ sums,
579
+ }: Shared & { lines: Line[]; counted: boolean; priced: boolean; sums: ReactNode }) {
580
+ return (
581
+ <div className="px-6 pb-9 pt-8 @sm:px-8">
582
+ <div data-doc-head="" className="flex flex-col items-center text-center">
583
+ <Mark name={from.name} logo={from.logo} size={2.5} />
584
+ <p className="m-0 mt-3 font-display text-[1.25rem] leading-[1.2]">{from.name}</p>
585
+ {from.lines?.map((line) => (
586
+ <p key={line} className={`m-0 ${SMALL} text-ink-muted`}>
587
+ {line}
588
+ </p>
589
+ ))}
590
+ </div>
591
+ <div data-doc-head="" className="subject-dashed mt-6 pt-4">
592
+ <div className="flex items-baseline justify-between gap-4">
593
+ <p className={`m-0 ${BODY} font-medium`}>{heading}</p>
594
+ {number ? <p className={`m-0 ${SMALL} ${FIGURES} text-ink-muted`}>{`${labels.number} ${number}`}</p> : null}
595
+ </div>
596
+ <FieldList fields={fields} fieldValue={fieldValue} className="mt-2" />
597
+ {to ? <p className={`m-0 mt-2 ${SMALL} text-ink-muted`}>{`${to.label ?? labels.to}: ${to.name}`}</p> : null}
598
+ </div>
599
+ <ul className="subject-dashed m-0 mt-4 list-none p-0 pt-2">
600
+ {lines.map((line, i) => {
601
+ const value = lineAmount(line);
602
+ return (
603
+ <li key={`${line.item}-${i}`} data-doc-line="" className={`grid grid-cols-[1fr_auto] gap-x-4 py-1.5 ${BODY}`}>
604
+ <span>{line.item}</span>
605
+ <span className={`text-right ${FIGURES}`}>{priced && value !== undefined ? fig(value) : ""}</span>
606
+ {counted && line.qty !== undefined && line.qty !== 1 ? (
607
+ <span className={`${SMALL} text-ink-muted ${FIGURES}`}>
608
+ {numberText(line.qty, locale)}
609
+ {line.price !== undefined ? ` × ${fig(line.price)}` : ""}
610
+ </span>
611
+ ) : null}
612
+ {line.note ? <span className={`col-span-2 ${SMALL} text-ink-muted`}>{line.note}</span> : null}
613
+ </li>
614
+ );
615
+ })}
616
+ </ul>
617
+ <div className="mt-2">{sums}</div>
618
+ {payment ? (
619
+ <p data-doc-foot="" className={`m-0 mt-5 ${SMALL} text-ink-muted`}>
620
+ {payment}
621
+ </p>
622
+ ) : null}
623
+ {footer ? (
624
+ <p data-doc-foot="" className={`m-0 mt-6 text-center ${SMALL} text-ink-muted`}>
625
+ {footer}
626
+ </p>
627
+ ) : null}
628
+ </div>
629
+ );
630
+ }
631
+
632
+ /** A menu: the house's name, the title large, courses as groups, each dish with its price on its first line. */
633
+ function Menu({ from, heading, fields, headline, text, fieldValue, fig, footer, lines }: Shared & { lines: (Line | Group)[] }) {
634
+ const groups: Group[] = [];
635
+ for (const entry of lines) {
636
+ if (isGroup(entry)) groups.push(entry);
637
+ else if (groups.length && groups[groups.length - 1].heading === "") groups[groups.length - 1].lines.push(entry);
638
+ else groups.push({ heading: "", lines: [entry] });
639
+ }
640
+ return (
641
+ <div className="px-7 pb-10 pt-9 @lg:px-14 @lg:pb-14 @lg:pt-12">
642
+ <div data-doc-head="" className="flex items-center justify-between gap-6">
643
+ <div className="flex items-center gap-3">
644
+ <Mark name={from.name} logo={from.logo} size={2} />
645
+ <p className={`m-0 ${BODY} font-medium`}>{from.name}</p>
646
+ </div>
647
+ <FieldList fields={fields} fieldValue={fieldValue} />
648
+ </div>
649
+ <div data-doc-head="" className="mt-10 @lg:mt-14">
650
+ <p className="m-0 font-display text-[clamp(2.5rem,11cqi,4rem)] leading-[0.95] tracking-[-0.02em]">{headline ?? heading}</p>
651
+ {text ? <p className={`m-0 mt-4 max-w-[44ch] ${BODY} text-ink-muted`}>{text}</p> : null}
652
+ </div>
653
+ {groups.map((group, g) => (
654
+ <section key={`${group.heading}-${g}`} className="mt-10 border-t border-line pt-5">
655
+ {group.heading ? (
656
+ <p data-doc-line="" className="m-0 font-display text-[1.25rem] leading-[1.2]">
657
+ {group.heading}
658
+ </p>
659
+ ) : null}
660
+ <ul className="m-0 mt-3 list-none p-0">
661
+ {group.lines.map((line, i) => {
662
+ const value = lineAmount(line);
663
+ return (
664
+ <li key={`${line.item}-${i}`} data-doc-line="" className="grid grid-cols-[1fr_auto] items-baseline gap-x-6 py-2.5">
665
+ <span className={`${BODY} font-medium`}>{line.item}</span>
666
+ <span className={`${BODY} ${FIGURES}`}>{value !== undefined ? fig(value) : ""}</span>
667
+ {line.note ? <span className={`col-span-1 max-w-[46ch] ${SMALL} text-ink-muted`}>{line.note}</span> : null}
668
+ </li>
669
+ );
670
+ })}
671
+ </ul>
672
+ </section>
673
+ ))}
674
+ {footer ? (
675
+ <p data-doc-foot="" className={`m-0 mt-10 border-t border-line pt-4 ${SMALL} text-ink-muted`}>
676
+ {footer}
677
+ </p>
678
+ ) : null}
679
+ </div>
680
+ );
681
+ }
682
+
683
+ /** A dispensing or product label: the issuer's band, the person it is for, the product large, the instructions. */
684
+ function Label({ from, to, number, labels, fields, headline, text, fieldValue, footer }: Shared) {
685
+ return (
686
+ <div className="px-5 pb-5 pt-4 @md:px-7 @md:pb-6 @md:pt-5">
687
+ <div data-doc-head="" className="flex items-center justify-between gap-4 border-b border-[color:var(--ink)] pb-3">
688
+ <div className="flex items-center gap-2.5">
689
+ <Mark name={from.name} logo={from.logo} size={1.75} />
690
+ <div>
691
+ <p className={`m-0 ${SMALL} font-medium text-ink`}>{from.name}</p>
692
+ {from.lines?.length ? <p className={`m-0 ${SMALL} text-ink-muted`}>{from.lines.join(", ")}</p> : null}
693
+ </div>
694
+ </div>
695
+ {number ? (
696
+ <p className={`m-0 shrink-0 ${SMALL} ${FIGURES}`}>
697
+ <span className="text-ink-muted">{labels.number} </span>
698
+ {number}
699
+ </p>
700
+ ) : null}
701
+ </div>
702
+ <div data-doc-head="" className="pt-4">
703
+ {to ? (
704
+ <p className={`m-0 ${BODY}`}>
705
+ <span className="text-ink-muted">{to.label ?? labels.to} </span>
706
+ <span className="font-medium">{to.name}</span>
707
+ </p>
708
+ ) : null}
709
+ {headline ? (
710
+ <p className="m-0 mt-2 font-display text-[clamp(1.5rem,7cqi,2.125rem)] leading-[1.1] tracking-[-0.01em]">{headline}</p>
711
+ ) : null}
712
+ {text ? <p className="m-0 mt-3 text-[1rem] font-medium leading-[1.45] @md:text-[1.0625rem]">{text}</p> : null}
713
+ </div>
714
+ {fields.length ? (
715
+ <dl data-doc-line="" className="m-0 mt-4 grid grid-cols-2 gap-x-5 gap-y-2 border-t border-line pt-3 @md:grid-cols-4">
716
+ {fields.map((field) => (
717
+ <div key={field.label}>
718
+ <dt className={LABEL}>{field.label}</dt>
719
+ <dd className={`m-0 ${SMALL} font-medium ${FIGURES}`}>{fieldValue(field)}</dd>
720
+ </div>
721
+ ))}
722
+ </dl>
723
+ ) : null}
724
+ {footer ? (
725
+ <p data-doc-foot="" className={`m-0 mt-3 border-t border-line pt-2.5 ${SMALL} text-ink-muted`}>
726
+ {footer}
727
+ </p>
728
+ ) : null}
729
+ </div>
730
+ );
731
+ }
732
+
733
+ /** A receipt's torn foot: the paper's own colour in a saw edge under the sheet. */
734
+ function TornEdge() {
735
+ const teeth = 28;
736
+ const points = Array.from({ length: teeth * 2 + 1 }, (_, i) => `${(i / (teeth * 2)) * 100},${i % 2 === 0 ? 0 : 6}`).join(" ");
737
+ return (
738
+ <svg aria-hidden="true" viewBox="0 0 100 6" preserveAspectRatio="none" className="subject-torn absolute left-[-1px] top-full block h-[0.5rem] w-[calc(100%+2px)]">
739
+ <polygon points={`0,0 ${points} 100,0`} />
740
+ </svg>
741
+ );
742
+ }