// Mandala HTML invoice template — an art-directed, full-bleed design with a // deep-teal watercolor field, a gold hero total, and organic typography. // // The original PDF mandala template (`templates/mandala.ts`) painted a 1.5 MB // `mandala-bg.png` as a full-page raster. Embedding that asset as a data URI // would bloat every client bundle that imports the templates, so the // watercolor field is recreated here with layered CSS radial/conic gradients. // // The palette is *config-driven*: it derives from the shared // `--inv-primary` / `--inv-accent` / `--inv-text` variables (set by // `configToCssVars` from `InvoiceTemplateConfig`), then layers the signature // teal/gold watercolor treatment on top via `color-mix`. Changing the invoice // template config therefore changes mandala's colors; the literal fallbacks in // the `.t-mandala` block are the sensible defaults used when a config color is // unset. import { cleanLineDescription, formatInvoiceCurrency, formatInvoiceDate, formatLineQuantity, InvoiceFrame, lineQuantityHeader, } from "./shared"; import type { InvoiceTemplateProps } from "./types"; const MANDALA_CSS = ` .scribe-invoice.t-mandala { /* Config-derived base tones (fallbacks are the signature mandala defaults). */ --m-base: var(--inv-primary, #0d3b3b); --m-accent: var(--inv-accent, #e0c773); --m-ink: var(--inv-text, #f4f1ea); /* Derived watercolor palette — layered on top of the config base. */ --m-bg: var(--m-base); --m-bg-deep: color-mix(in srgb, var(--m-base) 62%, #000000); --m-cream: color-mix(in srgb, var(--m-ink) 18%, #fbfaf6); --m-mint: color-mix(in srgb, var(--m-accent) 30%, var(--m-cream)); --m-gold: var(--m-accent); --m-glow: color-mix(in srgb, var(--m-base) 58%, #ffffff); --m-panel: color-mix(in srgb, var(--m-bg-deep) 42%, transparent); --m-panel-border: color-mix(in srgb, var(--m-cream) 16%, transparent); color: var(--m-cream); background: none; border: 0; -webkit-print-color-adjust: exact; print-color-adjust: exact; min-height: 100vh; position: relative; } /* The watercolor field is painted by a fixed layer rather than by the content container. A container background is painted only across the box each page fragment occupies, so an invoice that overflows by a few lines leaves the rest of the trailing page unpainted white. Chromium repeats a fixed element on every printed page, so the field covers all of them. */ .scribe-invoice.t-mandala .si-mandala-field { position: fixed; inset: 0; z-index: 0; background-color: var(--m-bg); background-image: radial-gradient(120% 90% at 18% 12%, color-mix(in srgb, var(--m-glow) 55%, transparent) 0%, transparent 55%), radial-gradient(90% 80% at 85% 18%, color-mix(in srgb, var(--m-gold) 20%, transparent) 0%, transparent 50%), radial-gradient(120% 120% at 78% 92%, color-mix(in srgb, var(--m-bg-deep) 65%, transparent) 0%, transparent 60%), conic-gradient(from 210deg at 50% 42%, transparent 0deg, color-mix(in srgb, var(--m-glow) 26%, transparent) 90deg, transparent 180deg, color-mix(in srgb, var(--m-glow) 26%, transparent) 270deg, transparent 360deg), linear-gradient(160deg, var(--m-bg) 0%, var(--m-bg-deep) 100%); -webkit-print-color-adjust: exact; print-color-adjust: exact; } .scribe-invoice.t-mandala > *:not(.si-mandala-field) { position: relative; z-index: 1; } @page { margin: 0; } @media print { .scribe-invoice.t-mandala { padding: 48px; } } .scribe-invoice.t-mandala .si-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; margin-bottom: 36px; } .scribe-invoice.t-mandala .si-from { font-style: italic; color: var(--m-mint); font-size: 13px; line-height: 1.5; } .scribe-invoice.t-mandala .si-from strong { display: block; font-size: 15px; color: var(--m-cream); } .scribe-invoice.t-mandala .si-hero { text-align: right; } .scribe-invoice.t-mandala .si-hero-total { font-size: 52px; line-height: 1; font-weight: 700; color: var(--m-gold); letter-spacing: -0.01em; } .scribe-invoice.t-mandala .si-hero-due { margin-top: 6px; font-style: italic; font-size: 13px; color: var(--m-cream); } .scribe-invoice.t-mandala .si-panel { background: var(--m-panel); border: 1px solid var(--m-panel-border); border-radius: 14px; padding: 20px 24px; margin-bottom: 24px; break-inside: avoid; } /* The line-items panel is the one panel with no upper bound on its height, so it opts out of the atomic rule: kept whole, a long enough item list is pushed to the next page and strands most of a sheet. Individual item rows carry their own break-inside: avoid, so a split lands between rows. */ .scribe-invoice.t-mandala .si-panel-flow { break-inside: auto; } .scribe-invoice.t-mandala .si-meta-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } .scribe-invoice.t-mandala .si-meta-label { font-style: italic; font-size: 11px; color: var(--m-mint); margin-bottom: 4px; } .scribe-invoice.t-mandala .si-meta-value { font-size: 14px; color: var(--m-cream); } .scribe-invoice.t-mandala .si-meta-detail { font-size: 12px; color: var(--m-mint); white-space: pre-line; } .scribe-invoice.t-mandala .si-meta-value.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; } .scribe-invoice.t-mandala .si-items-head, .scribe-invoice.t-mandala .si-item { display: grid; /* grid-template-columns is set inline so hidden Hrs/Rate columns fully collapse (matching the Minimal template). */ gap: 12px; align-items: baseline; } .scribe-invoice.t-mandala .si-items-head { font-style: italic; font-size: 11px; color: var(--m-mint); margin-bottom: 14px; } .scribe-invoice.t-mandala .si-items-head span:not(:first-child), .scribe-invoice.t-mandala .si-item .num { text-align: right; } .scribe-invoice.t-mandala .si-item { padding: 10px 0; border-top: 1px solid var(--m-panel-border); break-inside: avoid; } .scribe-invoice.t-mandala .si-item:first-of-type { border-top: 0; } .scribe-invoice.t-mandala .si-item-name { font-size: 15px; color: var(--m-cream); } .scribe-invoice.t-mandala .si-item-project { font-style: italic; font-size: 12px; color: var(--m-mint); margin-top: 2px; } .scribe-invoice.t-mandala .num { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; color: var(--m-cream); } .scribe-invoice.t-mandala .si-breakdown { margin-top: 14px; padding-top: 12px; border-top: 1px dashed var(--m-panel-border); display: grid; gap: 6px; } .scribe-invoice.t-mandala .si-breakdown-row { display: grid; grid-template-columns: 1fr auto auto; gap: 12px; font-size: 12px; } .scribe-invoice.t-mandala .si-breakdown-row .commits { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--m-mint); } .scribe-invoice.t-mandala .si-breakdown-row .hl { font-style: italic; color: var(--m-mint); } .scribe-invoice.t-mandala .si-totals { display: grid; gap: 8px; justify-items: end; } .scribe-invoice.t-mandala .si-totals .si-row { display: grid; grid-template-columns: auto 110px; gap: 24px; font-size: 14px; } .scribe-invoice.t-mandala .si-totals .si-row span:last-child { text-align: right; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } .scribe-invoice.t-mandala .si-totals .si-grand span { font-size: 18px; font-weight: 700; } .scribe-invoice.t-mandala .si-totals .si-grand span:last-child { color: var(--m-gold); } .scribe-invoice.t-mandala .si-pay-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } .scribe-invoice.t-mandala .si-pay-label { font-size: 14px; color: var(--m-cream); margin-bottom: 4px; } .scribe-invoice.t-mandala .si-pay-detail { font-size: 12px; color: var(--m-mint); } .scribe-invoice.t-mandala .si-footer { margin-top: 28px; font-style: italic; font-size: 12px; color: var(--m-mint); } `; export function MandalaInvoiceTemplate({ invoice, config, pageSize = "letter", className, }: InvoiceTemplateProps) { const paymentMethods = config.paymentMethods ?? []; const paymentText = invoice.paymentNotes || config.paymentInstructions; const workBreakdown = invoice.workBreakdown ?? []; const showBreakdown = config.showProjectBreakdown && workBreakdown.length > 0; // Only reserve grid tracks for columns we actually render, so hidden // Hrs/Rate columns collapse instead of leaving empty gaps. const itemColumns = ["minmax(0, 1fr)"]; if (config.showHours) itemColumns.push("60px"); if (config.showRate) itemColumns.push("90px"); itemColumns.push("90px"); const itemGridColumns = itemColumns.join(" "); return (