` summary row **inside** the item table — the one that sits under the last line item and shares its columns. It is not the totals area (§9), and the two are gated by different flags: this row by `showTotalsRow`, the totals area by `hideTotals`. A document can show either, both, or neither.
+
+It summarises only quantity and amount. Every other visible column emits an empty `| ` purely to keep the columns aligned.
+
+```json
+{
+ "group": "8. Summarised totals (item-table footer)",
+ "fields": [
+ {
+ "name": "Summary row",
+ "path": null,
+ "visibility": "showTotalsRow — an opt-in of its own, INDEPENDENT of hideTotals, which gates only the totals area (§9)",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": null
+ },
+ {
+ "name": "Summary row label",
+ "path": null,
+ "visibility": "showTotalsRow — sits in the description column",
+ "labelVariable": "Total",
+ "defaultLabel": null,
+ "valueVariable": null
+ },
+ {
+ "name": "Quantity total",
+ "path": "items[].quantity",
+ "visibility": "showTotalsRow AND the quantity column visible",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "sum of {items[n].quantity}, computed in the template — the payload aggregates carry money only, never a unit count"
+ },
+ {
+ "name": "Amount total",
+ "path": "finalTotal.subTotal",
+ "visibility": "showTotalsRow AND the amount column visible",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{finalTotal.subTotal}"
+ },
+ {
+ "name": "Remaining columns",
+ "path": null,
+ "visibility": "showTotalsRow",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "empty cell — one blank | per remaining visible column, to preserve alignment"
+ }
+ ]
+}
+```
+
+**The summary row label is hardcoded and has no key in the payload.** Do not reach for `{customLabels.total}` here: that is the grand-total label of the **totals area** (§9), the row beside the final payable figure. Both happen to read "Total" on a default account, which is exactly why the mix-up survives review — an account that renames its grand total to "Net Payable" would print "Net Payable" under the description column of the item table. When auditing, confirm which of the two elements a `customLabels.total` reference is feeding.
+
+## 9. Totals
+
+The totals area **below** the item table — subtotal, tax rows, round-off, grand total, amount in words. `finalTotal` is the required aggregate; `totals` is optional and may be absent. Prefer `finalTotal.*` and say so if the template reads `totals.*`.
+
+```json
+{
+ "group": "9. Totals",
+ "fields": [
+ {
+ "name": "Totals breakdown block",
+ "path": null,
+ "visibility": "hideTotals false (root; advanceOptions.hideTotals also declared — check which the template reads)",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": null
+ },
+ {
+ "name": "Sub Total Label Text",
+ "path": "columns[amount].label, or customLabels.subTotal",
+ "visibility": "Always",
+ "labelVariable": null,
+ "defaultLabel": "Sub Total",
+ "valueVariable": "{columns[amount].label}, or {customLabels.subTotal}"
+ },
+ {
+ "name": "Sub Total",
+ "path": "finalTotal.subTotal",
+ "visibility": "Always",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{finalTotal.subTotal}"
+ },
+ {
+ "name": "Discount Label Text",
+ "path": "columns[discount].label",
+ "visibility": "column discount not hidden (discountEnabled)",
+ "labelVariable": null,
+ "defaultLabel": "Discount",
+ "valueVariable": "{columns[discount].label}"
+ },
+ {
+ "name": "Discount",
+ "path": "finalTotal.discount",
+ "visibility": "column discount not hidden (discountEnabled)",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{finalTotal.discount}"
+ },
+ {
+ "name": "IGST Label Text",
+ "path": "columns[igst].label",
+ "visibility": "mapped.visibility.showIgst = {isIgst} true OR {taxName} != GST",
+ "labelVariable": null,
+ "defaultLabel": "IGST",
+ "valueVariable": "{columns[igst].label}"
+ },
+ {
+ "name": "IGST",
+ "path": "finalTotal.igst",
+ "visibility": "mapped.visibility.showIgst",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{finalTotal.igst}"
+ },
+ {
+ "name": "CGST Label Text",
+ "path": "columns[cgst].label",
+ "visibility": "mapped.visibility.showCgstSgst = !showIgst AND {taxName} = GST",
+ "labelVariable": null,
+ "defaultLabel": "CGST — forced by normalization",
+ "valueVariable": "CGST"
+ },
+ {
+ "name": "CGST",
+ "path": "finalTotal.cgst",
+ "visibility": "mapped.visibility.showCgstSgst",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{finalTotal.cgst}"
+ },
+ {
+ "name": "SGST / UTGST Label Text",
+ "path": "columns[sgst].label",
+ "visibility": "mapped.visibility.showCgstSgst",
+ "labelVariable": null,
+ "defaultLabel": "SGST",
+ "valueVariable": "UTGST when {isUtgst} true, otherwise {columns[sgst].label}"
+ },
+ {
+ "name": "SGST / UTGST",
+ "path": "finalTotal.sgst",
+ "visibility": "mapped.visibility.showCgstSgst",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{finalTotal.sgst}"
+ },
+ {
+ "name": "Cess Label Text",
+ "path": "cesses[n].label",
+ "visibility": "mapped.visibility.showSummaryCess = some {isApplied} AND a non-zero cess total",
+ "labelVariable": null,
+ "defaultLabel": "Cess",
+ "valueVariable": "{cesses[n].label}"
+ },
+ {
+ "name": "Cess",
+ "path": "cesses[n].amount",
+ "visibility": "mapped.visibility.showSummaryCess",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{cesses[n].amount}"
+ },
+ {
+ "name": "Additional charges Label Text",
+ "path": "additionalCharges[n].label",
+ "visibility": "Array non-empty — sits ABOVE the grand total, after the tax rows",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{additionalCharges[n].label} — every entry in array order"
+ },
+ {
+ "name": "Additional charges",
+ "path": "additionalCharges[n].amount",
+ "visibility": "Array non-empty — sits ABOVE the grand total, after the tax rows",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{additionalCharges[n].amount} × {additionalCharges[n].multiplier} — the amount is stored UNSIGNED, the multiplier carries the sign (-1 = deduction, rendered in parentheses). {amountType} FIXED_AMOUNT vs a percentage variant. Every entry in array order"
+ },
+ {
+ "name": "Round off",
+ "path": "finalTotal.totalRoundOff",
+ "visibility": "Non-zero",
+ "labelVariable": "Round Off",
+ "defaultLabel": null,
+ "valueVariable": "{finalTotal.totalRoundOff}, {finalTotal.amountRoundOff}"
+ },
+ {
+ "name": "Total Label Text",
+ "path": "customLabels.total",
+ "visibility": "Always",
+ "labelVariable": null,
+ "defaultLabel": "Total",
+ "valueVariable": "{customLabels.total}, falling back to the total column's own label — then \" ({currency})\" appended UNLESS owner.configuration.experimental.hideCurrencyCode is true (the suffix is gated, not unconditional)"
+ },
+ {
+ "name": "Total",
+ "path": "finalTotal.total",
+ "visibility": "Always",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{finalTotal.total}"
+ },
+ {
+ "name": "Total in words Label Text",
+ "path": "customLabels.totalInWords",
+ "visibility": "hideTotalInWords false AND the words value resolves non-empty",
+ "labelVariable": null,
+ "defaultLabel": "IN WORDS",
+ "valueVariable": "{customLabels.totalInWords}"
+ },
+ {
+ "name": "Total in words",
+ "path": "customLabels.totalInWordsValue, computed fallback",
+ "visibility": "hideTotalInWords false — NOT gated on the stored value existing: the platform renderer COMPUTES the words from finalTotal.total (shared amountInWords widget, language from locale) and prints {customLabels.totalInWordsValue} only as an override. A template that prints only the stored key drops the words on every payload that omits it, and prints STALE words when the stored string disagrees with finalTotal.total — check both. The implementation is src/widgets/shared/amountInWords.ts, but the {{amountInWords}} Handlebars helper is registered only by src/widgets/tax-summary and src/widgets/hsn-summary — a template that imports neither has no such helper, so verify the registration reaches it rather than assuming the shared module is enough",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{customLabels.totalInWordsValue} else amountInWords({finalTotal.total}, {currency}, locale-language)"
+ },
+ {
+ "name": "Extra total fields Label Text",
+ "path": "extraTotalFields[n].label",
+ "visibility": "Array non-empty — sits BELOW the grand total, above the settlement rows",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{extraTotalFields[n].label} — bold; every entry in array order"
+ },
+ {
+ "name": "Extra total fields",
+ "path": "extraTotalFields[n].value",
+ "visibility": "Array non-empty — sits BELOW the grand total, above the settlement rows",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{extraTotalFields[n].value} — FREE TEXT, not a money figure: do NOT pass it through formatCurrency. Every entry in array order"
+ },
+ {
+ "name": "Late payment fee",
+ "path": "latePaymentFee.finalAmount",
+ "visibility": "{latePaymentFee.enabled} AND {.isApplied} — the platform renderer does NOT check showInInvoice for this row",
+ "labelVariable": "Late Payment Fee",
+ "defaultLabel": null,
+ "valueVariable": "{latePaymentFee.finalAmount}"
+ },
+ {
+ "name": "Tax under RCM",
+ "path": "finalTotal.igst, else finalTotal.cgst + finalTotal.sgst",
+ "visibility": "{reverseCharge} true AND {isExpenditure} true AND the tax figure non-zero — a settlement-band row on expenditure documents; finalTotal.rcmTax also ships on the payload",
+ "labelVariable": "Tax under RCM",
+ "defaultLabel": null,
+ "valueVariable": "{finalTotal.igst} || ({finalTotal.cgst} + {finalTotal.sgst})"
+ },
+ {
+ "name": "Dual-currency grand total",
+ "path": "conversionRates[businessCurrency]",
+ "visibility": "businessCurrency present AND businessCurrency != {currency} — the grand total repeats in the business's home currency beneath the document-currency figure (the item Amount cell does the same)",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{finalTotal.total} × {conversionRates[businessCurrency]} in businessCurrency"
+ },
+ {
+ "name": "Settled amount",
+ "path": "balance.settledAmount",
+ "visibility": "Non-zero — settlement row",
+ "labelVariable": "Settled Amount",
+ "defaultLabel": null,
+ "valueVariable": "{balance.settledAmount}"
+ },
+ {
+ "name": "TDS Amount Withheld",
+ "path": "balance.tds",
+ "visibility": "{balance.tds} exists — settlement row, sits BELOW the grand total",
+ "labelVariable": "TDS Amount Withheld",
+ "defaultLabel": null,
+ "valueVariable": "{balance.tds} — rendered as a deduction: ($1,000.0000)"
+ },
+ {
+ "name": "Amount Paid",
+ "path": "DERIVED: balance.paid + balance.transactionCharge",
+ "visibility": "{balance.paid} exists — settlement row, sits BELOW the grand total",
+ "labelVariable": "Amount Paid",
+ "defaultLabel": null,
+ "valueVariable": "sum of {balance.paid} + {balance.transactionCharge} — NOT a payload field; rendered as a deduction: ($205.0000)"
+ },
+ {
+ "name": "Amount Received",
+ "path": "balance.paid",
+ "visibility": "{balance.paid} exists — indented child row, smaller and lighter",
+ "labelVariable": "Amount Received",
+ "defaultLabel": null,
+ "valueVariable": "{balance.paid} — positive, not parenthesised"
+ },
+ {
+ "name": "Transaction Charge",
+ "path": "balance.transactionCharge",
+ "visibility": "{balance.transactionCharge} exists — indented child row, smaller and lighter",
+ "labelVariable": "Transaction Charge",
+ "defaultLabel": null,
+ "valueVariable": "{balance.transactionCharge} — positive, not parenthesised"
+ },
+ {
+ "name": "Due amount Label Text",
+ "path": "customLabels.dueAmount",
+ "visibility": "{balance.due} > 0 AND {balance.due} ≠ {finalTotal.total} — settlement row, absent when fully settled AND when fully unpaid (due still equal to the grand total)",
+ "labelVariable": null,
+ "defaultLabel": "Due Amount",
+ "valueVariable": "{customLabels.dueAmount}"
+ },
+ {
+ "name": "Due amount",
+ "path": "balance.due",
+ "visibility": "{balance.due} > 0 AND {balance.due} ≠ {finalTotal.total}",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{balance.due}"
+ },
+ {
+ "name": "Credit",
+ "path": "balance.credit",
+ "visibility": "Non-zero — settlement row",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{balance.credit}"
+ }
+ ]
+}
+```
+
+### Row order
+
+The block is three bands separated by the grand total. Audit them in this order — a record in the wrong band is a real defect, not a cosmetic one:
+
+```
+ Amount columns[amount].label + finalTotal.subTotal
+ IGST (18%) tax rows — showIgst / showCgstSgst
+ Discounts ($20.00) additionalCharges[] — signed by multiplier
+ Extra Charges $10.00
+ Shipping Fee $50.00
+ Round Off finalTotal.totalRoundOff
+ ──────────────────────────────
+ Total (USD) $1,220.00 customLabels.total + (currency)
+ ──────────────────────────────
+ Company's DL NO. Custom … extraTotalFields[] — free text, bold label
+ TDS Amount Withheld ($1,000) ┐
+ Amount Paid ($205) │ settlement — balance.*
+ Amount Received $200 │
+ Transaction Charge $5 │
+ Due Amount $15 ┘
+```
+
+`Total in words` is not in this box at all — it is a sibling element rendered to its left.
+
+**`additionalCharges` sit above the grand total and are part of the arithmetic.** `subTotal + tax + Σ(amount × multiplier) = finalTotal.total`. Omitting them leaves the printed total underivable from the printed rows — on the reference document, `1000 + 180 = 1180` against a stated total of `1220`.
+
+**`extraTotalFields` sit below the grand total and are not arithmetic at all** — arbitrary label/value text pairs (`"Company's DL NO." = "Custom Field Value"`), bold label, plain value. Never format them as currency.
+
+### The settlement rows
+
+`balance.*` renders as a distinct group **below** the grand total, visually separated from the rows above it. Do not audit these as ordinary total lines:
+
+- **The deductions are drawn in accounting parentheses** — `TDS Amount Withheld` and `Amount Paid` print as `($1,000.0000)` and `($205.0000)`. **`Due Amount` is not a deduction**: it prints positive and unparenthesised (`$15.0000`), as do both child rows. A record whose `valueVariable` is a `balance.*` figure is not simply "the number formatted".
+- **`Amount Paid` is derived, not a field.** It is `balance.paid + balance.transactionCharge`, with the two components repeated beneath it as indented child rows (`Amount Received`, `Transaction Charge`) in smaller, lighter type. There is no payload key holding 205.
+- **Their labels are hardcoded, not `customLabels`.** `TDS Amount Withheld`, `Amount Paid`, `Amount Received` and `Transaction Charge` are all fixed strings in the markup. A payload carrying `customLabels.paidAmount: "Paid Amount"` still renders `Amount Paid`, and `TDS Amount Withheld` has no `customLabels` key at all. Only `Due Amount` reads its override (`customLabels.dueAmount`).
+- **The paid-block rows share an outer gate, then gate on their own key** — the platform renderer
+ wraps TDS / Amount Paid / Amount Received / Transaction Charge in
+ `billType !== "CREDITNOTE" && balance.paid` truthy, so **TDS does not print on a document with no
+ payment received**, whatever `balance.tds` says. Credit is billType-routed: on ordinary documents
+ a non-zero `balance.credit` prints (negated); on a CREDITNOTE the credit/due pair renders its own
+ variant; on a DEBITNOTE credit is suppressed. None of this reads `paymentOptions.meta.allowTDS`:
+
+| Row | Gate |
+|---|---|
+| TDS Amount Withheld | paid-block gate AND `balance.tds` truthy |
+| Amount Paid | paid-block gate |
+| Amount Received | paid-block gate |
+| Transaction Charge | paid-block gate AND `balance.transactionCharge` truthy |
+| Settled Amount | `balance.settledAmount` truthy |
+| Due Amount | `balance.due` > 0 AND `balance.due` ≠ the grand total |
+| Credit | `balance.credit` truthy AND billType rules above |
+
+ So a payment recorded with no transaction charge shows `Amount Paid` and `Amount Received` but drops `Transaction Charge`, a fully-settled document drops the Due row, and a TDS-only document with zero received shows nothing at all.
+
+- **Due Amount also hides on a fully-unpaid document.** `balance.due` still equal to the grand total
+ means nothing has been paid, withheld or credited — the row would only restate the Total line
+ above it, so it does not print. `due > 0` alone is not the gate: Due appears only once the due
+ figure has diverged from `finalTotal.total`.
+
+- **Settlement figures live in two payload places.** The platform renderer reads `balance.*`;
+ normalization's `mapped.payments` reads `totalConversions[currency]` (the only reliable source on
+ conversion-settled documents). On most payloads they agree — when they disagree, report the
+ disagreement and say which one the audited template reads.
+
+### Composed labels
+
+Two rows in this block build their label from more than one source — an audit that reports only the base key is incomplete:
+
+| Row | Renders | Composed from |
+|---|---|---|
+| Tax row | `IGST (18%)` | `{columns[igst].label}` + the tax rate in parentheses |
+| Cess row | `Health Cess (5%)` | `{cesses[n].cessName}` + rate — the platform computes the breakup per applied cess and prints only rows with amount > 0 |
+| Grand total | `Total (USD)` | `{customLabels.total}` (falling back to the total column label) + `{currency}` in parentheses — suffix suppressed when `owner.configuration.experimental.hideCurrencyCode` is true |
+
+### Decimal places
+
+`{subUnitLength}` sets the fraction digits and is frequently **not** 2 — a document with `subUnitLength: 4` prints `$1,000.0000` throughout. Read it from the payload rather than assuming currency defaults, and pass it to `formatCurrency` positionally.
+
+## 10. Summary blocks
+
+Each summary needs **both** a configuration opt-in and rows to put in it — configuration alone renders a bare header strip.
+
+```json
+{
+ "group": "10. Summary blocks",
+ "fields": [
+ {
+ "name": "Tax Summary",
+ "path": "taxSummary.taxList",
+ "visibility": "mapped.visibility.showTaxTable = advanceOptions.taxSummaryView in TABLE/BOTH AND list non-empty",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{taxSummary.taxList[n].taxableValue}, {.cgst}, {.sgst}, {.igst}, {.utgst}, {.cessAmount}"
+ },
+ {
+ "name": "HSN Summary",
+ "path": "hsnSummary.hsnList",
+ "visibility": "mapped.visibility.showHsnSummary = hsnSummaryEnabled AND list non-empty — hsnSummaryEnabled is the business toggle advanceOptions.showHsnSummary (bridge alias, checked first) falling back to showHSNSummaryInInvoice, and is separate from the per-column hsn gate",
+ "labelVariable": "HSN Summary",
+ "defaultLabel": null,
+ "valueVariable": "{hsnSummary.hsnList[n].hsn} + the TaxSummary fields, grouped per {hsnView}"
+ },
+ {
+ "name": "Stock Summary",
+ "path": "batchSummary[]",
+ "visibility": "{stockSummaryConfig.isEnabled} true AND array non-empty — the platform renderer reads stockSummaryConfig, NOT advanceOptions; column set and order come from {stockSummaryConfig.batchSummaryColumns[]} (isHidden per column), falling back to the platform's default batch column list",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{batchSummary[n].itemName}, {.batchName}, {.quantity}, {.warehouse}, {.manufacturingDate}, {.expiryDate} — per the configured columns"
+ },
+ {
+ "name": "Payments table Label Text",
+ "path": "customLabels.paymentRecord",
+ "visibility": "mapped.visibility.showPaymentsTable = showPaymentsTable AND allPayments non-empty",
+ "labelVariable": null,
+ "defaultLabel": "Payment Record",
+ "valueVariable": "{customLabels.paymentRecord}"
+ },
+ {
+ "name": "Payments table",
+ "path": "allPayments[], fallback payments[]",
+ "visibility": "mapped.visibility.showPaymentsTable",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{allPayments[n].paymentDate}, {.paymentMethod}, {.amount}"
+ },
+ {
+ "name": "Details block heading",
+ "path": "customLabels.invoiceDetails",
+ "visibility": "{customLabels.invoiceDetails} non-empty — guarded heading, hidden outright when the override is empty",
+ "labelVariable": null,
+ "defaultLabel": "Invoice Details",
+ "valueVariable": "{customLabels.invoiceDetails}"
+ }
+ ]
+}
+```
+
+`taxSummary`/`hsnSummary` arrive either as a flat array or nested under `taxList`/`hsnList`; normalization handles both, so audit the nested form.
+
+## 11. Payment info
+
+```json
+{
+ "group": "11. Payment info",
+ "fields": [
+ {
+ "name": "Bank + UPI section",
+ "path": null,
+ "visibility": "mapped.visibility.showBankUpiSection = billType not in CREDITNOTE/DEBITNOTE AND status != CANCELED AND (bank or UPI shown)",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": null
+ },
+ {
+ "name": "Bank account",
+ "path": "bankAccount",
+ "visibility": "mapped.visibility.showBankAccount = (not expenditure, or invoiceAccepted ACCEPTED) AND paymentOptions.accountTransfer AND {accountNo} present (normalization also accepts accountNumber — check which the template reads)",
+ "labelVariable": "Bank Details",
+ "defaultLabel": null,
+ "valueVariable": "Platform value keys: {bankAccount.name} (account holder — NOT accountHolderName), {.accountNo}, {.sortCode}, {.ifsc}, {.iban}, {.swift}, {.accountType}. Row labels are customLabels-driven with translated fallbacks: {customLabels.accountHolderName} → \"Account Name\", {customLabels.ifsc} → \"IFSC\", {customLabels.iban} → \"IBAN\", {customLabels.swiftCode} → \"SWIFT Code\" — hardcoding them is a finding. Each row guards on its own value"
+ },
+ {
+ "name": "UPI",
+ "path": "mapped.upi.id",
+ "visibility": "mapped.visibility.showUpi = same expenditure gate AND paymentOptions.upi AND id present",
+ "labelVariable": "UPI",
+ "defaultLabel": null,
+ "valueVariable": "{upi.upi} / {upi.vpa} / {upi.upiId} — QR from {upi.qr}/{upi.qrCode}/{bankAccount.qrCode}, else built from the id"
+ },
+ {
+ "name": "Online payment CTA",
+ "path": null,
+ "visibility": "onlinePaymentsEnabled AND domesticPaymentDisabled",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{paymentOptions}",
+ "unverified": true
+ },
+ {
+ "name": "Partial payment",
+ "path": null,
+ "visibility": "paymentOptions.meta.allowPartialPayment",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": null,
+ "unverified": true
+ }
+ ]
+}
+```
+
+`bankName` and `accountHolderName` are the contract keys. Payloads frequently carry `bank` and `name` instead — templates reading the contract names then render blank rows. Always check both. `template.upiShrink` is layout, not visibility.
+
+## 12. Footer
+
+```json
+{
+ "group": "12. Footer",
+ "fields": [
+ {
+ "name": "Terms Label Text",
+ "path": "terms[g].label, else customLabels.terms",
+ "visibility": "per group, {terms[g].terms} non-empty",
+ "labelVariable": null,
+ "defaultLabel": "Terms and Conditions",
+ "valueVariable": "{terms[g].label}, else {customLabels.terms}"
+ },
+ {
+ "name": "Terms",
+ "path": "terms[g].terms[n]",
+ "visibility": "per group, {terms[g].terms} non-empty",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{terms[g].terms[n]} — every group and every clause in array order"
+ },
+ {
+ "name": "Notes Label Text",
+ "path": "customLabels.notes",
+ "visibility": "{notes} non-empty AND {customLabels.notes} non-empty — guarded heading, hidden outright when the override is empty",
+ "labelVariable": null,
+ "defaultLabel": "Additional Notes",
+ "valueVariable": "{customLabels.notes} — the platform widget's own fallback is \"Additional Notes\", not \"Notes\""
+ },
+ {
+ "name": "Notes",
+ "path": "notes",
+ "visibility": "Non-empty",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{notes} — may contain markdown"
+ },
+ {
+ "name": "Attachments Label Text",
+ "path": "customLabels.attachment",
+ "visibility": "Array non-empty AND {customLabels.attachment} non-empty — guarded heading, hidden outright when the override is empty",
+ "labelVariable": null,
+ "defaultLabel": "Attachments",
+ "valueVariable": "{customLabels.attachment}"
+ },
+ {
+ "name": "Attachments",
+ "path": "attachments[n]",
+ "visibility": "Array non-empty",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{attachments[n]} — display name derived from the URL filename"
+ },
+ {
+ "name": "Signature Label Text",
+ "path": "customLabels.signature",
+ "visibility": "signature image present AND {customLabels.signature} non-empty — guarded heading, hidden outright when the override is empty",
+ "labelVariable": null,
+ "defaultLabel": "Authorized Signatory",
+ "valueVariable": "{customLabels.signature}"
+ },
+ {
+ "name": "Signature",
+ "path": "signature",
+ "visibility": "See §14 (Signature image / Digital signature flow) — the gate depends on signatureMethod, not just non-empty {signature}, and has three states when DIGITAL",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "see §14"
+ },
+ {
+ "name": "Contact line Label Text",
+ "path": "customLabels.contact, .contactEmail, .contactPhone",
+ "visibility": "mapped.visibility.contactStrip",
+ "labelVariable": null,
+ "defaultLabel": "For any enquiry, reach out via",
+ "valueVariable": "{customLabels.contact} → \"For any enquiry, reach out via\"; {customLabels.contactEmail} → \"email at\"; {customLabels.contactPhone} → \"call on\" (platform fallbacks); a joining comma prints between email and phone when both exist"
+ },
+ {
+ "name": "Contact line",
+ "path": "contact.*",
+ "visibility": "mapped.visibility.contactStrip = {contact.email} or {contact.phone} present",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{contact.email}, {contact.phone}"
+ },
+ {
+ "name": "Custom footers Label Text",
+ "path": "customFooters[n].label",
+ "visibility": "Array non-empty, per-entry {value} non-empty",
+ "labelVariable": null,
+ "defaultLabel": "{customFooters[n].defaultValue}",
+ "valueVariable": "{customFooters[n].label} — every entry in array order"
+ },
+ {
+ "name": "Custom footers",
+ "path": "customFooters[n].value",
+ "visibility": "Array non-empty, per-entry {value} non-empty",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{customFooters[n].value} — every entry in array order"
+ },
+ {
+ "name": "Footers Label Text",
+ "path": "footers[n].label",
+ "visibility": "Array non-empty",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{footers[n].label} — every entry in array order"
+ },
+ {
+ "name": "Footers",
+ "path": "footers[n].value",
+ "visibility": "Array non-empty",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{footers[n].value} — every entry in array order"
+ },
+ {
+ "name": "Created by",
+ "path": "creator.name",
+ "visibility": "advanceOptions.showCreatorInInvoice",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "{creator.name}",
+ "unverified": true
+ }
+ ]
+}
+```
+
+## 13. Compliance fields — e-invoice (India), ZATCA (Saudi), LHDN (Malaysia)
+
+Regulatory elements are not optional chrome: a market template that drops them produces a
+non-compliant document. Audit this group on **every** document — when the payload carries none of
+these keys, the completeness rule still applies: record whether the template *would* render the
+block if it arrived. Invoices from the same account will carry them, and "QR renders nowhere" is a
+compliance failure, not a cosmetic gap.
+
+**The platform renders the compliance QRs as independent elements, not one slot:** the IRN QR
+(`irn.qrCode`, suppressed when cancelled), `zatcaQrCode` and `lhdnQrCode` each render off their own
+value and can coexist; only `documentQr` is a true fallback, shown when none of the other three is
+present. Normalization's `mapped.qr.top` collapses this to a single value by precedence — an
+approximation that is only equivalent while at most one QR key is populated. A UPI QR is a separate
+element again (`mapped.qr.upi`); never merge them.
+
+```json
+{
+ "group": "13. Compliance",
+ "fields": [
+ {
+ "name": "IRN / e-way bill block (India)",
+ "path": "irn.Irn, irn.AckNo, irn.AckDt, irn.EwbNo, irn.EwbDt, irn.EwbValidTill, irn.CancelDate, irn.ewayCancelDate",
+ "visibility": "(irn.Irn or irn.EwbNo present) AND placed per {irnPosition}: IN_INVOICE_DETAILS (default, the platform's 'old method' — rows inside the invoice-details block) or ABOVE_LINEITEMS (recommended — its own table above the item table). The contract also declares BELOW_LINEITEMS, which the platform's config does not offer. Then PER-FIELD opt-ins from the owner's config: einvoiceConfig.irnNumber / .irnAcknowledgementNumber / .irnAcknowledgementDate / .irnCancelledDate and ewayConfig.billNumber / .billDate / .billValidTillDate / .billCancelledDate — each row needs its flag AND its value",
+ "labelVariable": "IRN / Ack No / Ack Date / E-way Bill / E-way Date / Valid Till (translated)",
+ "defaultLabel": null,
+ "valueVariable": "raw identifiers; AckDt / EwbDt / EwbValidTill are IST-encoded timestamps — format them in the UTC zone (do NOT re-shift by ownerOffset) or the printed date drifts a day"
+ },
+ {
+ "name": "IRN QR (India)",
+ "path": "irn.qrCode (host-resolved image data-URL), heads the mapped.qr.top chain via qrCode",
+ "visibility": "irn.qrCode present AND NOT cancelled. The host resolves it BEFORE the payload reaches the template: owner.configuration.showSignedIrnQr true → encodes irn.SignedQRCode (the signed payload), else irn.qr — the template never encodes it. Placement per owner.configuration.experimental.qrCodePlacement: IN_DOCUMENT_DETAILS or BESIDE_DOCUMENT_TITLE",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "render via the qrSrc shared helper"
+ },
+ {
+ "name": "IRN cancelled state",
+ "path": "irn.CancelDate (+ irn.AckDt, irn.Status)",
+ "visibility": "The platform treats the IRN as cancelled when CancelDate is set and falls after AckDt (mapped.irn.isCancelled uses CancelDate alone). Cancellation SUPPRESSES the IRN QR and the Ack No / Ack Date rows but KEEPS the IRN number row — plus the irnCancelledDate row where einvoiceConfig.irnCancelledDate opts in",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "state; the cancelled-date row prints irn.CancelDate"
+ },
+ {
+ "name": "ZATCA (Saudi) — phases 1 & 2",
+ "path": "zatcaQrCode + owner.configuration.isZatcaBusiness + signedPDF",
+ "visibility": "{zatcaQrCode} non-empty — renders unconditionally when present, alongside any IRN QR. Phase 1's printed requirement is this TLV QR; under phase 2 the payload delivers the QR from the cleared/signed XML through the same key, so the template's job is unchanged. Clearance metadata (clearanceStatus, generatedOn, validationResults) is app-side only — never expect it printed",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "QR via qrSrc. Bilingual Arabic/English labels come from customLabels (see Traps) — a Saudi-market template hardcoding English-only labels is a finding"
+ },
+ {
+ "name": "LHDN / MyInvois (Malaysia)",
+ "path": "lhdnQrCode + einvoiceGeneratedStatus",
+ "visibility": "{lhdnQrCode} non-empty — the MyInvois validation QR is the printed artifact. acceptedUuid / longId / dateTimeIssued / eInvoiceStatus are app-side only. A Malaysia document also needs the classification column visible (owner country MY); msic NEVER renders as a column even though LHDN uses MSIC codes — the MSIC lives on the business profile",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "QR via qrSrc"
+ },
+ {
+ "name": "e-invoice generation state",
+ "path": "einvoiceGeneratedStatus",
+ "visibility": "State key — drives app behavior, never printed directly",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": null
+ }
+ ]
+}
+```
+
+## 14. Images & QR codes
+
+Every image and QR on the document, gathered in one place because they were previously scattered
+across §1/§7/§11/§13 as thin single-line records with no visibility rule, sizing, or fallback
+chain. Two structural facts govern all of them:
+
+- **QR images arrive PRE-RESOLVED.** `irn.qrCode`, `zatcaQrCode`, `lhdnQrCode`, `documentQr`, and
+ the UPI QR are encoded to image data-URLs (`qrcode.toDataURL(...)`) by the host **before** the
+ payload reaches the template — none of them are raw asset URLs. Do **not** pass them through
+ `assetUrl`/`getOptimizedImage`-style asset resolution; render them as a direct ` `.
+ `qrSrc` is the one exception: it exists specifically to also accept a raw `upi://…` intent string
+ (for payloads that ship one instead of a pre-rendered QR) and encode it client-side — call it on
+ the UPI path, never on the compliance QRs, which are always already images.
+- **Ordinary asset images** (logo, signature, item photos) route through
+ `getOptimizedImage`/`getSrcSet`-equivalent resizing with a `{w, h}` box and a responsive `srcSet`.
+ A template that drops straight to the raw payload URL loses the optimization/CDN pass — report it,
+ and prefer a shared image widget over a bare ` `.
+
+```json
+{
+ "group": "14. Images & QR codes",
+ "fields": [
+ {
+ "name": "Logo",
+ "path": "logo",
+ "visibility": "assetUrl(logo) non-empty",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "advanceOptions.useOriginalLogo true → raw {logo} URL, no resize, no srcSet; false (default) → resized to 220×120 with a 440×240/220×120/180×100 responsive srcSet"
+ },
+ {
+ "name": "Watermark",
+ "path": "template.watermark",
+ "visibility": "{template.watermark.logo} non-empty",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "NOT an element — a CSS background pattern: --watermark-logo url(), --watermark-opacity = {opacity}/100 (0-100 scale, default 0.1), --watermark-rotate = {rotation}deg (default 0), --watermark-scale = {scale} (default 1), --watermark-repeated-pattern = repeat-y when {repeatedPatterns} else no-repeat. A template rendering this as a plain has the wrong mechanism entirely"
+ },
+ {
+ "name": "Line item thumbnail",
+ "path": "items[n].thumbnail",
+ "visibility": "advanceOptions.showThumbnailAsColumn true AND {items[n].thumbnail} non-empty",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "resized to 100×100 with a 200×200/100×100 srcSet; the full-size original is the link href for opening in a new tab"
+ },
+ {
+ "name": "Line item images",
+ "path": "items[n].images[]",
+ "visibility": "Array non-empty",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "each resized to 100×100 with a 200×200/100×100 srcSet; each links to its own full-size original (unsized href)"
+ },
+ {
+ "name": "Line item original images",
+ "path": "items[n].originalImages[]",
+ "visibility": "Array non-empty — a SEPARATE field from images[], not a size variant of it",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "each resized to 1000w with a 2000w/1000w srcSet; each links to its own unsized full-size original"
+ },
+ {
+ "name": "Signature image",
+ "path": "signature",
+ "visibility": "{signatureMethod} !== DIGITAL AND {signature} non-empty. There is NO billedBy.signature fallback in the reference — a template implementing one is engine-specific behavior, flag it as unverified against the platform rather than assumed-correct",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "resized to 240×120 with a 240×120/180×100 srcSet"
+ },
+ {
+ "name": "Digital signature flow",
+ "path": "signatureMethod, documentSignatureRequest.status, .signers[0].signerName, share.pdf",
+ "visibility": "signatureMethod === DIGITAL drives THREE mutually exclusive states, none of which is the plain signature image above: (1) awaiting — documentSignatureRequest.status !== SIGNED → an invisible 160pt×90pt placeholder (kept in the DOM so the PDF pipeline can measure its position) plus a visible 'Awaiting Digital Signature' banner; (2) signed — status === SIGNED → a 'Digitally signed' banner with the signer's name and a link to share.pdf; (3) neither → nothing renders. A template that only checks {signature} non-empty MISSES this entire flow — every digitally-signed or pending document renders wrong",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "banner text + optional signer name + optional verify-PDF link; no image in the signed/awaiting states"
+ },
+ {
+ "name": "UPI QR",
+ "path": "upi.qr (host pre-resolved), fallback client-built from upi.upi/.vpa/.upiId",
+ "visibility": "mapped.visibility.showUpi",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "PREFER the pre-resolved image. When building client-side from the id, the platform's own intent string is NOT just \"upi://pay?pa={id}\" — it also carries pn (payee name), am (amount, capped at 100000, OMITTED — not zero — when the business allows partial QR payment), and tn (narration, truncated to 50 chars). A client-side fallback missing pn/am/tn produces a materially thinner QR (no payee name shown, no prefilled amount) than the platform ships — note this gap explicitly rather than treating a bare pa-only intent as equivalent. Render at 127px fixed width to match reference sizing"
+ },
+ {
+ "name": "Document / compliance QRs (IRN, ZATCA, LHDN, generic)",
+ "path": "irn.qrCode, zatcaQrCode, lhdnQrCode, documentQr — see §13 for the precedence/coexistence rules",
+ "visibility": "see §13",
+ "labelVariable": null,
+ "defaultLabel": null,
+ "valueVariable": "direct — these are ALREADY data-URLs; do not run them through asset resizing/optimization helpers, that pipeline does not apply to data URIs and doing so is itself a finding"
+ }
+ ]
+}
+```
+
+## Reporting payload defects
+
+Keep defects in the audited *data* separate from render rules — a record describes what the template does, a defect describes what this document happens to contain. Put them in a closing section and say whether the renderer neutralises each one. Recurring kinds:
+
+- **A column label that contradicts its key** (`columns[cgst].label` = "IGST"). The renderer forces `CGST`/`UTGST`, so this does **not** reach the page — report it as bad stored config, not a render bug.
+- **A total populated against its own flag** (`finalTotal.igst` non-zero while `isIgst` is false and the HSN summary shows zero IGST; or `finalTotal.cgst`/`sgst` populated on a `GLOBAL` document). Read `mapped.visibility.showIgst` / `showCgstSgst`, never the presence of a total.
+- **A stale `customLabels.taxName`** disagreeing with `invoice.taxName` (`"GST"` stored against a `"VAT"` document). No render consequence — totals rows label from `mapped.columns` — so report it as config drift, not a broken label.
+- **Identifier inconsistencies** — a GSTIN state prefix disagreeing with `gstState`, a short `pincode`. These surface as IRN/e-invoice errors, not render errors.
+- **Markdown inside a value** (an item `description` holding a table). Renders as raw pipes unless the template pipes it through the markdown partial.
+- **Unresolvable lookup keys** — a custom `unit` key that must resolve through `owner.configuration.units`; the raw key prints if the resolver misses.
+- **Secrets in the payload** (`owner.einvoice.password` in plain text). Report, do not echo the value.
+- **A missing flag is not `false`.** If `owner.configuration.experimental` carries no `fieldVisibility`, GSTIN/PAN have no stored value — the opt-out convention means they show. Say "not set → default" rather than "hidden".
+- **Template-side, not payload: unformatted tax columns.** When `gstRate`/`igst`/`cgst`/`sgst`/`total` are visible but the item-cell `switch` has no case for them, they fall to the generic fallback and print as raw numbers with no currency or `%`. See *Taxation paths* — it only surfaces on documents where the tax columns are actually shown, which in practice means the global path. Report it separately from data defects: this one is a template bug and the payload is correct.
+- **Empty `customLabels` overrides.** These are Label Text records whose variable resolves to `""`. List them separately — "falls back to default" is only true where the markup supplies one:
+
+```json
+{
+ "group": "Empty label overrides",
+ "fields": [
+ {
+ "key": "billedTo",
+ "field": "Billed To header",
+ "rendered": "guarded heading → nothing renders"
+ },
+ {
+ "key": "shippedFrom",
+ "field": "Shipped From header",
+ "rendered": "guarded heading → nothing renders"
+ },
+ {
+ "key": "total",
+ "field": "Grand total row",
+ "rendered": "unguarded → blank label"
+ }
+ ]
+}
+```
diff --git a/.agent/skills/design-to-template/SKILL.md b/.agent/skills/design-to-template/SKILL.md
index 339f289..7f7c829 100644
--- a/.agent/skills/design-to-template/SKILL.md
+++ b/.agent/skills/design-to-template/SKILL.md
@@ -21,7 +21,7 @@ Look at the image and identify these sections (most invoice/document templates f
| **Document info** | Invoice number, date, due date, PO number | `invoiceNumber`, `invoiceDateUserInput`, `formattedDueDate` |
| **Billed to** | Client name, address, tax ID | `billedTo.name`, `billedTo.street`, `billedTo.gstin` |
| **Status** | Paid/Unpaid/Overdue tag | Use `{{> InvoiceStatus}}` widget |
-| **Line items** | Table with item name, quantity, rate, amount | `{{#each items}}...{{/each}}` |
+| **Line items** | The table's *visual* treatment only — borders, header fill, row density, serial column, thumbnails. Not its column set or header wording | `{{#each items}}` over `{{#each mapped.columns}}` — columns, labels and order come from the account (§4c) |
| **Totals** | Subtotal, tax, discount, total | `totals.subTotal`, `totals.total`, `totals.igst`, etc. |
| **Notes** | Free text area for notes | `{{> MarkdownViewer (prepareMarkdownViewerData notes)}}` |
| **Terms** | List of terms and conditions | `{{#each terms}}...{{/each}}` |
@@ -67,6 +67,93 @@ If the design uses a primary accent color, map it to `--ceres-primary-color` so
Always provide a fallback value that matches the design.
+### 4a. Typography scale — 13px base, hard 10px floor
+
+Declare six font sizes as custom properties on the template's root shell and reference them with `var(...)` everywhere. Override the same six inside `@media print` on the same selector.
+
+**Naming is fixed: `---font-size-`, with the steps `xs`, `s`, `base`, `m`, `lg`, `xl`.** Spell out `font-size`, not `fs`. Do not invent `sm`, `md` or `xxl` — six steps, those six names. The prefix is a short template-specific tag: `--bie-font-size-base` for `basic-invoice-example`, `--dt-font-size-base` for `default-template`, `--tpl-font-size-base` in the examples below.
+
+Either `px` or `pt` is acceptable, but be consistent within a template and remember the floor below is a **px** figure — `1pt = 4/3 px`, so the smallest legal `pt` step is `7.5pt`.
+
+**The `base` step is `13px`. Always — every template, and the same value in print as on screen.** Body copy is the one size a reader spends the whole document in; it does not get retuned per design, and it does not change between the screen preview and the printed PDF. Size the other five steps around it, not the reverse.
+
+**No step may ever be below `10px` — screen or print.** Below that, the fine print that carries legal and tax meaning stops being reliably legible in a PDF.
+
+Those two rules together determine where print density comes from: **take it out of the steps above the base only.** The base is pinned, `xs`/`s` sit at or near the floor, so the space is recovered by compressing `m`/`lg`/`xl`. The older blanket "-2px everywhere for print" does not survive contact with either rule.
+
+```css
+.my-template {
+ --tpl-font-size-xs: 10px; /* floor */
+ --tpl-font-size-s: 11px;
+ --tpl-font-size-base: 13px; /* pinned */
+ --tpl-font-size-m: 15px;
+ --tpl-font-size-lg: 18px;
+ --tpl-font-size-xl: 28px;
+ line-height: 1.5;
+}
+
+@media print {
+ .my-template {
+ --tpl-font-size-xs: 10px; /* at the floor, unchanged */
+ --tpl-font-size-s: 11px; /* fine print stays readable */
+ --tpl-font-size-base: 13px; /* pinned — same as screen */
+ --tpl-font-size-m: 14px; /* density comes from here up */
+ --tpl-font-size-lg: 16px;
+ --tpl-font-size-xl: 24px;
+ }
+}
+```
+
+A 10px floor makes dense tables wider and wrap more. That is the intended trade: reduce the column count or widen the cell, do not shrink the type below the floor.
+
+**Enforce it with a test**, not just review — the values that break the floor are custom-property definitions inside `@media print`, which never appear in a rendered DOM as resolved `font-size` declarations and so are invisible to snapshot tests. The pattern: read the template's `styles.css` as text, collect every px-valued `font-size` and `--*-font-size-*` declaration, and assert none is below 10. Include a guard asserting the parser found declarations at all, so a refactor cannot make the check pass vacuously. (Match `--*-font-size-*` only — that is the mandated spelling, and no template here uses `--*-fs-*`.)
+
+A template gets exactly one test file. Put these checks in their own `describe` inside the template's **existing** `tests/.test.ts`; do not open a separate CSS test file.
+
+### 4b. Spacing — multiples of 4px
+
+**Every spacing value is a multiple of 4px**: `padding`, `margin`, `gap`, `row-gap`, `column-gap`, and any offset. `4 8 12 16 20 24 28 32`. Not `6px`, not `10px`, not `18px` — reading a value off a design and transcribing it literally is how those get in.
+
+Round to the nearest step. On a tie, pick the tighter one for dense areas like table cells: `padding: 8px 6px` becomes `8px 4px`, not `8px 8px`.
+
+This does not apply to values that are not spacing — `1px` borders, `letter-spacing`, `max-width` on a logo, `line-height` (set `1.5` once on the root shell rather than per selector).
+
+**Enforce it with a test alongside the font-size checks** — the same read of `styles.css` as text, collecting `padding`/`margin`/`gap`/offset declarations, asserting every px value is divisible by 4, and asserting the parser found at least one such declaration so the check cannot pass vacuously.
+
+### 4c. Item table column widths — data-driven, so size for that
+
+The item table's visible columns are decided at render time by the account's own `columns[]`, not by the design. Size the table for that, or it will look right on the reference document and wrong on the next one.
+
+**Never give a serial/index column a share of the table width.** It holds one or two characters and must shrink to its content, always. The same goes for quantity, rate, discount and money columns — they size to their content; only the text columns absorb slack.
+
+```css
+.tpl-table {
+ width: 100%;
+ table-layout: auto;
+}
+
+/* Shrink-to-fit: a browser cannot honour a width below the content's minimum,
+ so `1%` collapses the column to exactly what it needs and no further. */
+.tpl-col-sno,
+.tpl-col-qty,
+.tpl-col-rate,
+.tpl-col-amount {
+ width: 1%;
+ white-space: nowrap;
+}
+
+/* The text columns take every remaining pixel. */
+.tpl-col-item {
+ width: auto;
+}
+```
+
+**Avoid `table-layout: fixed` with per-column percentages.** It is the trap this repo has already hit: the percentages are authored against the design's column set, and the moment normalization hides a column — `hsn` on a `MERGE` document, the tax columns on a quotation, `discount` when the document has none — the declared widths no longer sum to 100%. The leftover is then redistributed across *every* column, so a `width: 24px` serial column silently inflates to a large share of the table. A width that is only correct while every column happens to be visible is not a width.
+
+**`width: 1%` and `nowrap` must reach the body cells, not just the header.** If the `` carries the sizing class and the ` | ` carries only an alignment class, a table-wide `overflow-wrap: anywhere` will break a money figure across two lines to satisfy the squeeze. Either put the column class on both, or set `white-space: nowrap` on the numeric cells and let text columns opt back in with `white-space: normal`.
+
+Check the result on a document whose column set differs from the design's — one with the tax columns visible, one with them hidden.
+
### 5. Handle print styles
Designs often look different on screen versus print. Add these print rules:
@@ -97,6 +184,7 @@ Use this checklist:
- [ ] Does it look good when printed (Ctrl+P)?
- [ ] Are all widget imports present in `index.ts`?
- [ ] Does `npm run build:template --template=my-template` succeed?
+- [ ] Is the rendered document eyeballed against the design, on screen and in print preview? Required — see `snapshot-testing` for the intended baseline harness and its status.
## Layout patterns catalog
diff --git a/.agent/skills/navigate-codebase/SKILL.md b/.agent/skills/navigate-codebase/SKILL.md
index 537d999..701671f 100644
--- a/.agent/skills/navigate-codebase/SKILL.md
+++ b/.agent/skills/navigate-codebase/SKILL.md
@@ -59,20 +59,21 @@ ceres/
## Cross-repo: how Lydia uses Ceres
-Ceres runs inside an iframe in Lydia. These are the key files in the Lydia repo:
+Ceres runs inside an iframe in Lydia. These are the key files, and they live in the **`lydia`
+repo, not this one** — cited `repo:path`, so a path with no prefix is always local.
-### src/components/utils/iframeUtils.js
+### lydia:src/components/utils/iframeUtils.js
- `buildIframeSrc()` builds the iframe URL with `?template=&apiUrl=&isLydiaMode=1`
- `hasCustomLayout()` checks if the user has a custom template applied
- `triggerIframePrint()` sends a `lydia:print` postMessage to Ceres
- `postTemplateUpdate()` sends `lydia:template-update` with style changes
-### src/components/hooks/useIframeHeight.js
+### lydia:src/components/hooks/useIframeHeight.js
- Listens for `ceres:content-height` messages from Ceres
- Sets the iframe height with a small buffer on first load
- Intercepts Ctrl+P to trigger printing inside the iframe
-### src/components/widgets/IframeRenderer.jsx
+### lydia:src/components/widgets/IframeRenderer.jsx
- React component that renders the iframe
- Uses `getIframeProps()` to decide if an iframe is needed
- Uses `useIframeHeight()` for sizing
@@ -95,7 +96,7 @@ Ceres runs inside an iframe in Lydia. These are the key files in the Lydia repo:
## System templates vs custom templates
-Lydia has its own React-based templates in `src/components/template/` (like `quotation/default.js`). These are "system templates" that handle most users.
+Lydia has its own React-based templates in `lydia:src/components/template/` (like `quotation/default.js`). These are "system templates" that handle most users.
When a user wants a custom look, they get a "custom template" rendered by Ceres inside an iframe.
diff --git a/.agent/skills/scaffold-template/SKILL.md b/.agent/skills/scaffold-template/SKILL.md
index c90926c..840ad32 100644
--- a/.agent/skills/scaffold-template/SKILL.md
+++ b/.agent/skills/scaffold-template/SKILL.md
@@ -148,7 +148,10 @@ The API response has these fields you can use in your template:
1. Build: `npm run build:template --template=my-cool-invoice`
2. Test: Open `dist/index.html?template=my-cool-invoice&apiUrl=YOUR_BASE64_URL`
-3. If snapshot testing is set up: `npm run test:snapshots:update`
+3. Verify the visuals — required, not conditional: open the built page from step 2 and check the
+ rendered document against the design, on screen and in the print preview, before handing the
+ template over. Automated baseline diffing is not wired up in this repo yet; see
+ `.agent/skills/snapshot-testing/SKILL.md` for the intended setup and its current status.
## Reference
diff --git a/.agent/skills/snapshot-testing/SKILL.md b/.agent/skills/snapshot-testing/SKILL.md
index b562525..57b5425 100644
--- a/.agent/skills/snapshot-testing/SKILL.md
+++ b/.agent/skills/snapshot-testing/SKILL.md
@@ -1,10 +1,32 @@
---
name: snapshot-testing
-description: Manage visual regression tests for Ceres templates
+description: Manage visual regression tests for Ceres templates. Intended as the last step of creating a new template, and whenever a change touches template.hbs or styles.css — read it for the intended setup; the harness is not wired up in this repo yet.
---
# Snapshot Testing
+
+
+> **Harness status: not wired up.** The baseline diffing this file describes does not exist in this
+> repo yet: there are no `test:snapshots` / `test:snapshots:update` scripts and no `__snapshots__/`
+> directory, so nothing compares today's render against a committed one. Every command in this file
+> describes the intended setup, not a working one. Until it is built, verify a new template or an
+> hbs/CSS change by building it and reviewing the rendered document yourself, on screen and in the
+> browser's print preview — delete this note once the harness runs.
+
+## When to run this — required, not optional
+
+**Every new template gets snapshot baselines before it is handed over.** A template with binding
+tests but no baselines is unverified visually: a binding test proves a value reached the DOM,
+never that the page it landed on is laid out correctly. Nothing else in the workflow looks at the
+rendered document.
+
+Also run it on **any** change to `template.hbs` or `styles.css` — that is what a regression test is
+for. Review the diff image before updating a baseline; `npm run test:snapshots:update` on an
+unexamined diff silently blesses the regression it was meant to catch.
+
+At least two samples per template, per Tips below.
+
## What snapshots do
Snapshots catch visual regressions. They take a screenshot of a rendered template and compare it to a saved baseline. If the screenshots differ, the test fails and shows you a diff.
diff --git a/.agent/skills/verify-skills.sh b/.agent/skills/verify-skills.sh
new file mode 100755
index 0000000..25c75b1
--- /dev/null
+++ b/.agent/skills/verify-skills.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+# Citation gate for .agent/skills/*/SKILL.md
+#
+# A skill is documentation an agent acts on without checking, so every concrete
+# claim it makes has to resolve: a local path must exist in the tree, an npm
+# script must be defined in package.json, a named sibling skill must exist, and
+# a payload field must carry the canonical runtime's name rather than a fork's
+# drifted one.
+#
+# Conventions this enforces:
+# * Paths are cited `repo:path`. An unprefixed path is local and must exist.
+# A prefixed one (`lydia:src/...`) is another repo's and is not checked here.
+# * A skill describing tooling that is not built yet declares itself with
+#
+# and is then exempt from the npm-script check. Use it only alongside a
+# visible status note; it is not a way to keep a stale command list.
+#
+# Run from the repo root: bash .agent/skills/verify-skills.sh
+set -uo pipefail
+cd "$(git rev-parse --show-toplevel)"
+
+fail=0
+bad() { printf 'FAIL %s\n' "$1"; fail=1; }
+
+skills=$(find .agent/skills -name SKILL.md | sort)
+skill_dirs=$(find .agent/skills -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
+# Template and widget directory names collide with skill naming (`default-template`),
+# so they are excluded from the sibling-skill check rather than reported.
+not_skills=$( { ls src/templates 2>/dev/null; ls src/widgets 2>/dev/null; } | sort -u)
+
+echo "== 1. every local path resolves (repo-prefixed paths are cross-repo, skipped)"
+for f in $skills; do
+ while read -r p; do
+ [ -n "$p" ] || continue
+ case "$p" in *my-template*|*'<'*) continue;; esac # illustrative
+ grep -q "[A-Za-z0-9_-]:$p" "$f" && continue # repo:path, cross-repo
+ [ -e "$p" ] || bad "$f cites missing local path: $p"
+ done < <(grep -hoE '(^|[^:A-Za-z0-9_-])(src|scripts|tests|schemas)/[A-Za-z0-9_./-]+' "$f" \
+ | grep -oE '(src|scripts|tests|schemas)/[A-Za-z0-9_./-]+' | sort -u)
+done
+
+echo "== 2. every npm script referenced is defined"
+defined=$(jq -r '.scripts|keys[]' package.json | sort)
+for f in $skills; do
+ grep -q 'verify-skills: aspirational-scripts' "$f" && continue
+ while read -r s; do
+ [ -n "$s" ] || continue
+ grep -qx "$s" <<<"$defined" || bad "$f tells you to run an undefined script: npm run $s"
+ done < <(grep -hoE 'npm run [a-z:-]+' "$f" | awk '{print $3}' | sort -u)
+done
+
+echo "== 3. every sibling skill named actually exists"
+for f in $skills; do
+ own=$(basename "$(dirname "$f")")
+ while read -r s; do
+ [ -n "$s" ] || continue
+ [ "$s" = "$own" ] && continue
+ grep -qx "$s" <<<"$not_skills" && continue
+ grep -qx "$s" <<<"$skill_dirs" || bad "$f names a nonexistent skill: $s"
+ done < <(grep -hoE '\.agent/skills/[a-z0-9-]+|`[a-z][a-z0-9]+(-[a-z0-9]+)+`' "$f" \
+ | sed 's|.*\.agent/skills/||' | tr -d '`' \
+ | grep -E -- '-(template|tests|mapping|check|build|codebase|testing|contract)$' | sort -u)
+done
+
+echo "== 4. canonical payload field names — fork drift must not be taught"
+for f in $skills; do
+ while read -r l; do bad "$f teaches a fork-drifted field name: $l"; done \
+ < <(grep -nE 'invoice\.(igst|utgst)\b' "$f")
+done
+
+echo "== 5. un-extracted render tooling must not be referenced"
+for f in $skills; do
+ while read -r l; do bad "$f references un-extracted render tooling: $l"; done \
+ < <(grep -nE 'render-check|npm run render|scripts/render(Lint)?\.mjs|\.ceres-render' "$f")
+done
+
+echo "== 6. frontmatter shape and name/directory agreement"
+for f in $skills; do
+ head -1 "$f" | grep -qx -- '---' || bad "$f has no opening frontmatter fence"
+ grep -qE '^description: ' "$f" || bad "$f has no description:"
+ n=$(grep -E '^name: ' "$f" | head -1 | sed 's/^name: *//')
+ d=$(basename "$(dirname "$f")")
+ [ -n "$n" ] || bad "$f has no name:"
+ [ -z "$n" ] || [ "$n" = "$d" ] || bad "$f declares name: '$n' but sits in '$d'"
+done
+
+echo
+if [ "$fail" -eq 0 ]; then echo "OK: skills verified"; else echo "skills verification FAILED"; fi
+exit "$fail"
| |