@facturion/invoice-renderer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/render.js ADDED
@@ -0,0 +1,421 @@
1
+ // Render a "normal invoice" HTML fragment — the way a buyer, seller or
2
+ // auditor would expect a commercial document to look. Input: the simplified
3
+ // EN 16931 JSON modelled by @facturion/invoice.
4
+ //
5
+ // Sections are conditional: blocks with no data render nothing, so a bare
6
+ // invoice stays compact while a fully-specified one surfaces every field a
7
+ // traditional invoice document would carry.
8
+ //
9
+ // `t(key, vars?)` is injected — a label resolver for `view.*` document strings
10
+ // and `units.*` / `paymentMeans.*` vocabulary. `renderInvoiceDocument` supplies
11
+ // a default `t` (bundled strings + @facturion/codelists); advanced callers pass
12
+ // their own. The net/total math comes from @facturion/invoice.
13
+ import { computeTotals, lineNet } from "@facturion/invoice";
14
+
15
+ export { computeTotals, lineNet };
16
+
17
+ // Shared formatting helpers.
18
+ export function esc(s) {
19
+ return String(s ?? "")
20
+ .replace(/&/g, "&")
21
+ .replace(/</g, "&lt;")
22
+ .replace(/>/g, "&gt;")
23
+ .replace(/"/g, "&quot;");
24
+ }
25
+
26
+ export function amt(value) {
27
+ const n = Number(value);
28
+ if (!isFinite(n)) return esc(value ?? "—");
29
+ return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
30
+ }
31
+
32
+ export function curAmt(value, cur) {
33
+ // U+00A0 (no-break space) keeps the amount and its currency on one line.
34
+ return cur ? `${amt(value)} ${esc(cur)}` : amt(value);
35
+ }
36
+
37
+ function unitLabel(code, qty, t) {
38
+ if (!code) return "";
39
+ const plural = Number(qty) !== 1;
40
+ const key = "units." + code + (plural ? "_plural" : "");
41
+ const label = t(key);
42
+ return label === key ? code : label;
43
+ }
44
+
45
+ function dateRange(start, end) {
46
+ if (start && end && start !== end) return `${esc(start)} – ${esc(end)}`;
47
+ return esc(start || end || "");
48
+ }
49
+
50
+ function addressLines(addr) {
51
+ if (!addr) return [];
52
+ const lines = [];
53
+ if (addr.line1) lines.push(addr.line1);
54
+ if (addr.line2) lines.push(addr.line2);
55
+ if (addr.line3) lines.push(addr.line3);
56
+ const pcCity = [addr.post_code, addr.city].filter(Boolean).join(" ");
57
+ if (pcCity) lines.push(pcCity);
58
+ if (addr.country_subdivision) lines.push(addr.country_subdivision);
59
+ if (addr.country_code) lines.push(addr.country_code);
60
+ return lines;
61
+ }
62
+
63
+ // ── Section builders ──────────────────────────────────────────────────────
64
+
65
+ function headerBlock(invoice, t) {
66
+ const typeLabel = invoice.invoice_type_code === "381"
67
+ ? t("view.creditNote")
68
+ : t("view.invoice");
69
+
70
+ return `
71
+ <div class="inv-header">
72
+ <div class="inv-header-left">
73
+ <div class="inv-type">${esc(typeLabel)}</div>
74
+ <div class="inv-id">${esc(invoice.invoice_number)}</div>
75
+ </div>
76
+ <div class="inv-header-right">
77
+ <div><span class="inv-label">${t("view.issueDate")}</span> ${esc(invoice.issue_date)}</div>
78
+ ${invoice.due_date ? `<div><span class="inv-label">${t("view.dueDate")}</span> ${esc(invoice.due_date)}</div>` : ""}
79
+ ${invoice.currency_code ? `<div><span class="inv-label">${t("view.currency")}</span> ${esc(invoice.currency_code)}</div>` : ""}
80
+ </div>
81
+ </div>`;
82
+ }
83
+
84
+ function partyBlock(party, label, extraLines) {
85
+ if (!party) return "";
86
+ const rows = [];
87
+ if (party.name) {
88
+ rows.push(`<div class="inv-party-name">${esc(party.name)}</div>`);
89
+ }
90
+ if (party.trading_name && party.trading_name !== party.name) {
91
+ rows.push(`<div class="inv-party-trading">${esc(party.trading_name)}</div>`);
92
+ }
93
+ for (const l of addressLines(party.address)) {
94
+ rows.push(`<div>${esc(l)}</div>`);
95
+ }
96
+ if (party.vat_id) {
97
+ rows.push(`<div><span class="inv-label">VAT</span> ${esc(party.vat_id)}</div>`);
98
+ }
99
+ if (party.tax_registration_id) {
100
+ rows.push(`<div><span class="inv-label">Tax-ID</span> ${esc(party.tax_registration_id)}</div>`);
101
+ }
102
+ if (party.legal_registration_id?.id) {
103
+ rows.push(`<div><span class="inv-label">Reg</span> ${esc(party.legal_registration_id.id)}</div>`);
104
+ }
105
+ if (party.additional_legal_info) {
106
+ rows.push(`<div class="inv-party-legal">${esc(party.additional_legal_info)}</div>`);
107
+ }
108
+ if (party.contact) {
109
+ const bits = [party.contact.name, party.contact.phone, party.contact.email].filter(Boolean);
110
+ if (bits.length) {
111
+ rows.push(`<div class="inv-party-contact">${bits.map(esc).join(" · ")}</div>`);
112
+ }
113
+ }
114
+ for (const extra of extraLines || []) rows.push(extra);
115
+
116
+ return `<div class="inv-party"><div class="inv-party-label">${esc(label)}</div>${rows.join("")}</div>`;
117
+ }
118
+
119
+ function partiesBlock(invoice, t) {
120
+ const buyerExtras = invoice.buyer_reference
121
+ ? [`<div><span class="inv-label">Ref</span> ${esc(invoice.buyer_reference)}</div>`]
122
+ : [];
123
+
124
+ const primary = `
125
+ <div class="inv-parties">
126
+ ${partyBlock(invoice.seller, t("view.from"))}
127
+ ${partyBlock(invoice.buyer, t("view.to"), buyerExtras)}
128
+ </div>`;
129
+
130
+ // Secondary parties only appear when present (payee, tax rep, deliver-to).
131
+ const secondaries = [];
132
+
133
+ if (invoice.payee) {
134
+ secondaries.push(partyBlock(invoice.payee, t("view.payee")));
135
+ }
136
+ if (invoice.seller_tax_representative) {
137
+ secondaries.push(partyBlock(invoice.seller_tax_representative, t("view.taxRep")));
138
+ }
139
+ const d = invoice.delivery;
140
+ if (d && (d.name || d.address?.line1 || d.location_id?.id)) {
141
+ const extras = [];
142
+ if (d.location_id?.id) {
143
+ extras.push(`<div><span class="inv-label">ID</span> ${esc(d.location_id.id)}</div>`);
144
+ }
145
+ secondaries.push(partyBlock(
146
+ { name: d.name, address: d.address },
147
+ t("view.deliverTo"),
148
+ extras,
149
+ ));
150
+ }
151
+
152
+ const secondaryHtml = secondaries.length
153
+ ? `<div class="inv-parties-secondary">${secondaries.join("")}</div>`
154
+ : "";
155
+
156
+ return primary + secondaryHtml;
157
+ }
158
+
159
+ function referencesBlock(invoice, t) {
160
+ const refs = [];
161
+ const push = (label, value) => { if (value) refs.push([label, value]); };
162
+
163
+ push(t("view.purchaseOrder"), invoice.purchase_order_reference);
164
+ push(t("view.contract"), invoice.contract_reference);
165
+ push(t("view.project"), invoice.project_reference);
166
+ push(t("view.salesOrder"), invoice.sales_order_reference);
167
+ push(t("view.despatchAdvice"), invoice.despatch_advice_reference);
168
+ push(t("view.receivingAdvice"), invoice.receiving_advice_reference);
169
+ push(t("view.tender"), invoice.tender_or_lot_reference);
170
+ push(t("view.objectIdentifier"), invoice.object_identifier?.id);
171
+ for (const p of invoice.preceding_invoices || []) {
172
+ const val = p.issue_date ? `${p.reference} (${p.issue_date})` : p.reference;
173
+ push(t("view.preceding"), val);
174
+ }
175
+
176
+ if (refs.length === 0) return "";
177
+ const rows = refs.map(([k, v]) =>
178
+ `<div class="inv-ref"><span class="inv-ref-label">${esc(k)}</span><span class="inv-ref-value">${esc(v)}</span></div>`
179
+ ).join("");
180
+ return `<div class="inv-references">${rows}</div>`;
181
+ }
182
+
183
+ function periodBlock(invoice, t) {
184
+ const period = invoice.delivery?.period;
185
+ if (period?.start_date || period?.end_date) {
186
+ return `<div class="inv-period">
187
+ <span class="inv-label">${t("view.invoicingPeriod")}</span>
188
+ ${dateRange(period.start_date, period.end_date)}
189
+ </div>`;
190
+ }
191
+ if (invoice.delivery?.date) {
192
+ return `<div class="inv-period">
193
+ <span class="inv-label">${t("view.deliveryDate")}</span>
194
+ ${esc(invoice.delivery.date)}
195
+ </div>`;
196
+ }
197
+ return "";
198
+ }
199
+
200
+ function notesBlock(invoice, t) {
201
+ const notes = invoice.notes || [];
202
+ if (notes.length === 0) return "";
203
+ const items = notes.map(n => `<p>${esc(n.text)}</p>`).join("");
204
+ return `<div class="inv-notes">
205
+ <div class="inv-section-label">${esc(t("view.notes"))}</div>
206
+ ${items}
207
+ </div>`;
208
+ }
209
+
210
+ function linesBlock(invoice, t) {
211
+ const lines = invoice.lines || [];
212
+ const rows = lines.map(line => {
213
+ const qty = line.quantity;
214
+ const unit = line.unit_code;
215
+ const netPrice = line.price?.net_price;
216
+ const grossPrice = line.price?.gross_price;
217
+ const discount = line.price?.discount;
218
+ const taxRate = line.vat?.rate;
219
+ const total = lineNet(line);
220
+ const name = line.item?.name || "";
221
+ const desc = line.item?.description && line.item.description !== name
222
+ ? line.item.description : "";
223
+
224
+ const periodHtml = (line.period?.start_date || line.period?.end_date)
225
+ ? `<div class="inv-line-meta"><span class="inv-label">${t("view.linePeriod")}</span> ${dateRange(line.period.start_date, line.period.end_date)}</div>`
226
+ : "";
227
+ const noteHtml = line.note ? `<div class="inv-line-meta">${esc(line.note)}</div>` : "";
228
+
229
+ // Line-level allowances and charges: shown inline under the description.
230
+ const acBits = [];
231
+ for (const a of line.allowances || []) {
232
+ const label = a.reason || t("view.documentAllowance");
233
+ acBits.push(`<div class="inv-line-allowance">− ${amt(a.amount)} · ${esc(label)}</div>`);
234
+ }
235
+ for (const c of line.charges || []) {
236
+ const label = c.reason || t("view.documentCharge");
237
+ acBits.push(`<div class="inv-line-charge">+ ${amt(c.amount)} · ${esc(label)}</div>`);
238
+ }
239
+
240
+ // Show gross + discount alongside the net price when the invoice carries
241
+ // both (e.g. "100.00 / was 120.00 (-16.67%)").
242
+ const priceCell = (grossPrice != null && (discount != null || grossPrice !== netPrice))
243
+ ? `${amt(netPrice)}<div class="inv-line-price-gross">${amt(grossPrice)}</div>`
244
+ : amt(netPrice);
245
+
246
+ return `
247
+ <tr>
248
+ <td class="inv-col-id">${esc(line.id)}</td>
249
+ <td class="inv-col-desc">
250
+ <div class="inv-line-name">${esc(name)}</div>
251
+ ${desc ? `<div class="inv-line-desc">${esc(desc)}</div>` : ""}
252
+ ${periodHtml}
253
+ ${noteHtml}
254
+ ${acBits.join("")}
255
+ </td>
256
+ <td class="inv-col-num">${esc(qty)}${unit ? ` ${esc(unitLabel(unit, qty, t))}` : ""}</td>
257
+ <td class="inv-col-num">${priceCell}</td>
258
+ <td class="inv-col-num">${taxRate != null ? `${esc(taxRate)} %` : ""}</td>
259
+ <td class="inv-col-num">${amt(total)}</td>
260
+ </tr>`;
261
+ }).join("");
262
+
263
+ return `
264
+ <div class="inv-lines-wrap">
265
+ <table class="data-table inv-lines">
266
+ <thead><tr>
267
+ <th class="inv-col-id">${t("view.lineNo")}</th>
268
+ <th class="inv-col-desc">${t("view.description")}</th>
269
+ <th class="inv-col-num">${t("view.quantity")}</th>
270
+ <th class="inv-col-num">${t("view.unitPrice")}</th>
271
+ <th class="inv-col-num">${t("view.taxRate")}</th>
272
+ <th class="inv-col-num">${t("view.lineTotal")}</th>
273
+ </tr></thead>
274
+ <tbody>${rows}</tbody>
275
+ </table>
276
+ </div>`;
277
+ }
278
+
279
+ function totalsBlock(invoice, t, totals) {
280
+ const cur = invoice.currency_code;
281
+ const rows = [];
282
+
283
+ rows.push(`<tr><td>${t("view.subtotal")}</td><td class="inv-col-num">${curAmt(totals.lineExtension, cur)}</td></tr>`);
284
+
285
+ for (const a of invoice.document_allowances || []) {
286
+ const label = a.reason || t("view.documentAllowance");
287
+ rows.push(`<tr><td>${esc(label)}</td><td class="inv-col-num">− ${curAmt(a.amount, cur)}</td></tr>`);
288
+ }
289
+ for (const c of invoice.document_charges || []) {
290
+ const label = c.reason || t("view.documentCharge");
291
+ rows.push(`<tr><td>${esc(label)}</td><td class="inv-col-num">+ ${curAmt(c.amount, cur)}</td></tr>`);
292
+ }
293
+
294
+ // Per-category VAT breakdown — rate, taxable base inline, then tax amount.
295
+ // Exemption reason (BT-120) renders as a subtle sub-row so readers see the
296
+ // compliance statement right next to the 0% line.
297
+ for (const g of totals.taxSubtotals) {
298
+ const rateLabel = t("view.taxTotal", { rate: g.rate });
299
+ rows.push(`<tr class="inv-vat-row">
300
+ <td>${esc(rateLabel)}<span class="inv-vat-base"> · ${t("view.taxableAmount")} ${curAmt(g.taxable, cur)}</span></td>
301
+ <td class="inv-col-num">${curAmt(g.tax, cur)}</td>
302
+ </tr>`);
303
+ if (g.reason) {
304
+ rows.push(`<tr class="inv-vat-reason"><td colspan="2">${esc(g.reason)}</td></tr>`);
305
+ }
306
+ }
307
+
308
+ if (totals.prepaid !== 0) {
309
+ rows.push(`<tr><td>${t("view.prepaid")}</td><td class="inv-col-num">− ${curAmt(Math.abs(totals.prepaid), cur)}</td></tr>`);
310
+ }
311
+ if (totals.rounding !== 0) {
312
+ const sign = totals.rounding > 0 ? "+ " : "− ";
313
+ rows.push(`<tr><td>${t("view.rounding")}</td><td class="inv-col-num">${sign}${curAmt(Math.abs(totals.rounding), cur)}</td></tr>`);
314
+ }
315
+
316
+ rows.push(`<tr class="inv-totals-payable"><td>${t("view.payable")}</td><td class="inv-col-num">${curAmt(totals.payable, cur)}</td></tr>`);
317
+
318
+ return `<div class="inv-totals-wrap">
319
+ <table class="inv-totals"><tbody>${rows.join("")}</tbody></table>
320
+ </div>`;
321
+ }
322
+
323
+ // Resolve the payment means to a human-readable label. Prefer BT-82 free
324
+ // text; fall back to a lookup of the BT-81 (UNTDID 4461) code; fall back
325
+ // further to the raw code if the lookup misses.
326
+ function paymentMeansLabel(p, t) {
327
+ if (p.means_text) return p.means_text;
328
+ if (!p.means_code) return "";
329
+ const key = "paymentMeans." + p.means_code;
330
+ const label = t(key);
331
+ return label === key ? p.means_code : label;
332
+ }
333
+
334
+ function paymentBlock(invoice, t) {
335
+ const p = invoice.payment;
336
+ if (!p) return "";
337
+ const rows = [];
338
+
339
+ const means = paymentMeansLabel(p, t);
340
+ if (means) {
341
+ rows.push(`<div><span class="inv-label">${t("view.paymentMeans")}</span> ${esc(means)}</div>`);
342
+ }
343
+
344
+ // BG-17 credit transfers (one or more).
345
+ for (const ct of p.credit_transfers || []) {
346
+ if (ct.account_id) {
347
+ rows.push(`<div><span class="inv-label">${t("view.iban")}</span> ${esc(ct.account_id)}</div>`);
348
+ }
349
+ if (ct.service_provider_id) {
350
+ rows.push(`<div><span class="inv-label">${t("view.bic")}</span> ${esc(ct.service_provider_id)}</div>`);
351
+ }
352
+ if (ct.account_name) {
353
+ rows.push(`<div><span class="inv-label">${t("view.accountName")}</span> ${esc(ct.account_name)}</div>`);
354
+ }
355
+ }
356
+
357
+ // BG-18 payment card (masked PAN + holder name).
358
+ if (p.card) {
359
+ if (p.card.account_number) {
360
+ rows.push(`<div><span class="inv-label">${t("view.cardNumber")}</span> ${esc(p.card.account_number)}</div>`);
361
+ }
362
+ if (p.card.holder_name) {
363
+ rows.push(`<div><span class="inv-label">${t("view.cardHolder")}</span> ${esc(p.card.holder_name)}</div>`);
364
+ }
365
+ }
366
+
367
+ // BG-19 direct debit (mandate reference, creditor ID, debited account).
368
+ const dd = p.direct_debit;
369
+ if (dd) {
370
+ if (dd.mandate_reference) {
371
+ rows.push(`<div><span class="inv-label">${t("view.mandateReference")}</span> ${esc(dd.mandate_reference)}</div>`);
372
+ }
373
+ if (dd.creditor_id) {
374
+ rows.push(`<div><span class="inv-label">${t("view.creditorId")}</span> ${esc(dd.creditor_id)}</div>`);
375
+ }
376
+ if (dd.debited_account_id) {
377
+ rows.push(`<div><span class="inv-label">${t("view.debitedAccount")}</span> ${esc(dd.debited_account_id)}</div>`);
378
+ }
379
+ }
380
+
381
+ if (p.remittance_information) {
382
+ rows.push(`<div><span class="inv-label">${t("view.remittance")}</span> ${esc(p.remittance_information)}</div>`);
383
+ }
384
+
385
+ if (rows.length === 0) return "";
386
+ return `<div class="inv-payment">
387
+ <div class="inv-section-label">${esc(t("view.paymentInstructions"))}</div>
388
+ ${rows.join("")}
389
+ </div>`;
390
+ }
391
+
392
+ function paymentTermsBlock(invoice, t) {
393
+ if (!invoice.payment_terms) return "";
394
+ return `<div class="inv-payment-terms">
395
+ <span class="inv-label">${t("view.paymentTerms")}</span> ${esc(invoice.payment_terms)}
396
+ </div>`;
397
+ }
398
+
399
+ // ── Entry point ──────────────────────────────────────────────────────────
400
+
401
+ /**
402
+ * Render the invoice as an HTML fragment (no document chrome). `opts.t` is a
403
+ * label resolver: `(key, vars?) => string`, returning the key unchanged on a
404
+ * miss. See `renderInvoiceDocument` for a batteries-included wrapper.
405
+ */
406
+ export function renderInvoice(invoice, { t }) {
407
+ const totals = computeTotals(invoice);
408
+
409
+ return `
410
+ <div class="invoice-paper">
411
+ ${headerBlock(invoice, t)}
412
+ ${partiesBlock(invoice, t)}
413
+ ${referencesBlock(invoice, t)}
414
+ ${periodBlock(invoice, t)}
415
+ ${notesBlock(invoice, t)}
416
+ ${linesBlock(invoice, t)}
417
+ ${totalsBlock(invoice, t, totals)}
418
+ ${paymentBlock(invoice, t)}
419
+ ${paymentTermsBlock(invoice, t)}
420
+ </div>`;
421
+ }
@@ -0,0 +1,227 @@
1
+ /* ── Utilities ─────────────────────────────────────────────────────────────── */
2
+
3
+ /* Data table */
4
+ .data-table {
5
+ width: 100%;
6
+ border-collapse: collapse;
7
+ font-size: var(--text-sm);
8
+ }
9
+
10
+ .data-table th,
11
+ .data-table td {
12
+ text-align: left;
13
+ padding: var(--space-2) var(--space-3);
14
+ border-bottom: 1px solid var(--border);
15
+ }
16
+
17
+ .data-table th {
18
+ color: var(--text-secondary);
19
+ font-weight: 500;
20
+ font-size: var(--text-xs);
21
+ text-transform: uppercase;
22
+ letter-spacing: 0.04em;
23
+ }
24
+
25
+ .data-table td {
26
+ color: var(--text-secondary);
27
+ }
28
+
29
+ .data-table code {
30
+ font-size: var(--text-xs);
31
+ color: var(--primary-light);
32
+ }
33
+
34
+ /* Alert */
35
+ .alert {
36
+ padding: var(--space-3) var(--space-4);
37
+ border-radius: var(--radius-md);
38
+ margin-bottom: var(--space-4);
39
+ font-size: var(--text-sm);
40
+ }
41
+
42
+ .alert-info {
43
+ background: var(--info-dim);
44
+ color: var(--info);
45
+ border: 1px solid rgba(59, 130, 246, 0.2);
46
+ }
47
+
48
+ .alert-error {
49
+ background: var(--error-dim);
50
+ color: var(--error);
51
+ border: 1px solid rgba(239, 68, 68, 0.2);
52
+ }
53
+
54
+ .alert code {
55
+ display: block;
56
+ margin-top: var(--space-2);
57
+ word-break: break-all;
58
+ font-size: var(--text-xs);
59
+ }
60
+
61
+ /* Transitions for interactive elements */
62
+ .card {
63
+ transition: border-color var(--duration) var(--ease-out);
64
+ }
65
+
66
+ .btn {
67
+ transition: background var(--duration) var(--ease-out),
68
+ border-color var(--duration) var(--ease-out),
69
+ opacity var(--duration) var(--ease-out),
70
+ transform 80ms var(--ease-out);
71
+ }
72
+
73
+ .btn:active:not(:disabled) {
74
+ transform: scale(0.98);
75
+ }
76
+
77
+ /* Inline spinner (for buttons) */
78
+ @keyframes spin { to { transform: rotate(360deg); } }
79
+
80
+ .spinner {
81
+ display: inline-block;
82
+ width: 16px;
83
+ height: 16px;
84
+ border: 2px solid currentColor;
85
+ border-right-color: transparent;
86
+ border-radius: 50%;
87
+ animation: spin 0.6s linear infinite;
88
+ vertical-align: middle;
89
+ }
90
+
91
+ /* ── Info tooltip ─────────────────────────────────────────────────────────
92
+ Shared component for inline help affordances. Markup pattern:
93
+
94
+ <span class="info-tip" tabindex="0" role="button" aria-label="More info">
95
+ <span class="info-tip-icon" aria-hidden="true">i</span>
96
+ <span class="info-tip-popover" role="tooltip">…content…</span>
97
+ </span>
98
+
99
+ Hover or focus on the wrapper reveals the popover. The popover sits flush
100
+ against the icon's bottom edge so the cursor doesn't cross a non-hovered
101
+ gap on its way from the icon down into the popover (which would close it
102
+ prematurely). Content can be plain text or include rich children
103
+ (links, source notes, etc.). */
104
+ .info-tip {
105
+ position: relative;
106
+ display: inline-flex;
107
+ align-items: center;
108
+ flex-shrink: 0;
109
+ cursor: help;
110
+ outline: none;
111
+ vertical-align: middle;
112
+ }
113
+
114
+ .info-tip-icon {
115
+ display: inline-flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ width: 14px;
119
+ height: 14px;
120
+ border-radius: var(--radius-full);
121
+ background: var(--bg-elevated);
122
+ color: var(--text-muted);
123
+ font-size: 10px;
124
+ font-style: italic;
125
+ font-weight: 700;
126
+ /* Pin line-height to font-size so the character sits at the geometric
127
+ center of the circle. Without this, the line-box inherits the parent's
128
+ line-height (~1.5) and the "i" rides high inside the icon, looking
129
+ misaligned with adjacent text. */
130
+ line-height: 1;
131
+ user-select: none;
132
+ }
133
+
134
+ .info-tip:hover .info-tip-icon,
135
+ .info-tip:focus-visible .info-tip-icon {
136
+ background: var(--border);
137
+ color: var(--text);
138
+ }
139
+
140
+ /* Popover is `position: fixed` so it escapes any parent overflow/clip
141
+ context (cards, scroll containers, etc.) and can be JS-positioned in
142
+ viewport coordinates. `installInfoTipPositioning` in `$lib/info-tip.ts`
143
+ computes top/left/max-width on mouseover/focusin and on scroll/resize.
144
+ Hidden state uses opacity + visibility (not display:none) so the
145
+ popover always has measurable dimensions for the positioning math. */
146
+ .info-tip-popover {
147
+ position: fixed;
148
+ top: 0;
149
+ left: 0;
150
+ z-index: 10;
151
+ display: flex;
152
+ flex-direction: column;
153
+ gap: var(--space-2);
154
+ min-width: 240px;
155
+ max-width: 340px;
156
+ padding: var(--space-3);
157
+ background: var(--bg-card);
158
+ border: 1px solid var(--border);
159
+ border-radius: var(--radius-md);
160
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.18);
161
+ font-family: var(--font-sans);
162
+ font-size: var(--text-xs);
163
+ font-style: normal;
164
+ font-weight: 400;
165
+ line-height: 1.5;
166
+ color: var(--text);
167
+ text-align: left;
168
+ white-space: normal;
169
+ cursor: default;
170
+ opacity: 0;
171
+ visibility: hidden;
172
+ pointer-events: none;
173
+ transition: opacity 0.1s ease;
174
+ }
175
+
176
+ .info-tip:hover .info-tip-popover,
177
+ .info-tip:focus-within .info-tip-popover {
178
+ opacity: 1;
179
+ visibility: visible;
180
+ pointer-events: auto;
181
+ }
182
+
183
+ /* Subdued source line + KB link inside an info-tip popover (used by the
184
+ step-annotation tooltips). Optional. */
185
+ .info-tip-source {
186
+ color: var(--text-secondary);
187
+ }
188
+
189
+ .info-tip-link {
190
+ color: var(--primary-light);
191
+ text-decoration: none;
192
+ }
193
+
194
+ .info-tip-link:hover {
195
+ text-decoration: underline;
196
+ }
197
+
198
+ /* Site footer */
199
+ .site-footer {
200
+ display: flex;
201
+ justify-content: center;
202
+ gap: var(--space-6);
203
+ padding: var(--space-6) var(--space-4);
204
+ border-top: 1px solid var(--border);
205
+ font-size: var(--text-xs);
206
+ }
207
+
208
+ .site-footer a {
209
+ color: var(--text-secondary);
210
+ text-decoration: none;
211
+ }
212
+
213
+ .site-footer a:hover {
214
+ color: var(--text-secondary);
215
+ }
216
+
217
+ .site-footer-note {
218
+ color: var(--text-secondary);
219
+ font-style: italic;
220
+ }
221
+
222
+ /* Responsive helpers */
223
+ @media (max-width: 640px) {
224
+ #app {
225
+ padding: var(--space-4) var(--space-4);
226
+ }
227
+ }