diff --git a/examples/src/main/java/com/demcha/examples/templates/invoice/v2/ModernInvoiceV2Example.java b/examples/src/main/java/com/demcha/examples/templates/invoice/v2/ModernInvoiceV2Example.java
index b27f82d2f..b043d7772 100644
--- a/examples/src/main/java/com/demcha/examples/templates/invoice/v2/ModernInvoiceV2Example.java
+++ b/examples/src/main/java/com/demcha/examples/templates/invoice/v2/ModernInvoiceV2Example.java
@@ -4,6 +4,7 @@
import com.demcha.compose.document.api.DocumentPageSize;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.core.theme.BrandTheme;
import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec;
import com.demcha.compose.document.templates.invoice.v2.presets.ModernInvoice;
import com.demcha.examples.support.ExampleDataFactory;
@@ -36,11 +37,13 @@ public static Path generate() throws Exception {
Path outputFile = ExampleOutputPaths.prepare(
"templates/invoice", "invoice-modern-v2.pdf");
InvoiceDocumentSpec spec = ExampleDataFactory.sampleInvoice();
- DocumentTemplate template = ModernInvoice.create();
+ BrandTheme theme = BrandTheme.invoiceModern();
+ DocumentTemplate template = ModernInvoice.create(theme);
float m = (float) ModernInvoice.RECOMMENDED_MARGIN;
try (DocumentSession document = GraphCompose.document(outputFile)
.pageSize(DocumentPageSize.A4)
+ .pageBackground(theme.palette().mainFill())
.margin(m, m, m, m)
.create()) {
template.compose(document, spec);
diff --git a/src/main/java/com/demcha/compose/document/templates/core/theme/BrandTheme.java b/src/main/java/com/demcha/compose/document/templates/core/theme/BrandTheme.java
index 0f20fd036..b16768358 100644
--- a/src/main/java/com/demcha/compose/document/templates/core/theme/BrandTheme.java
+++ b/src/main/java/com/demcha/compose/document/templates/core/theme/BrandTheme.java
@@ -320,11 +320,12 @@ public static BrandTheme mintEditorial() {
}
/**
- * The "Modern Invoice" look — Helvetica, slate ink, a pale-blue
- * rounded hero panel with a blue accent strip, and light table
- * rules. The first layered invoice flavour: the invoice
- * presets read it exactly the way the CV presets read their own
- * flavours, so the two families share one theme model.
+ * The "Modern Invoice" look — Helvetica on a cream page, a soft-tan
+ * rounded hero panel with a gold accent strip, a deep-teal title and
+ * table header, and light table rules. Mirrors the cinematic business
+ * "modern" theme. The first layered invoice flavour: the
+ * invoice presets read it exactly the way the CV presets read their
+ * own flavours, so the two families share one theme model.
*
* @return a {@code BrandTheme} for the "Modern Invoice" look
*/
diff --git a/src/main/java/com/demcha/compose/document/templates/core/theme/Palette.java b/src/main/java/com/demcha/compose/document/templates/core/theme/Palette.java
index fd0ad864f..e2ab093fc 100644
--- a/src/main/java/com/demcha/compose/document/templates/core/theme/Palette.java
+++ b/src/main/java/com/demcha/compose/document/templates/core/theme/Palette.java
@@ -300,21 +300,23 @@ public static Palette mintEditorial() {
}
/**
- * Modern Invoice palette: slate body ink, grey metadata, light
- * table-border rules, and a pale-blue fill carried in the
- * {@code banner} slot for the invoice hero panel and table zebra
- * rows. The stronger accent blue used for the hero strip / status
- * is preset-local in {@code ModernInvoice} because no other v2
- * preset shares it today.
+ * Modern Invoice palette mirroring the cinematic business "modern"
+ * look: slate body ink, grey metadata, light table-border rules, a
+ * soft-tan fill in the {@code banner} slot (the hero panel + table
+ * zebra rows), and a cream {@code mainFill} used as both the page
+ * background and the table surface. The deep-teal title / table
+ * header and the gold hero accent are preset-local in
+ * {@code ModernInvoice} because the layered palette has no
+ * primary / accent slot.
*
* @return a {@code Palette} for the Modern Invoice flavour
*/
public static Palette invoiceModern() {
return new Palette(
- DocumentColor.rgb(33, 37, 41), // ink — slate body text
- DocumentColor.rgb(108, 117, 125), // muted — metadata
- DocumentColor.rgb(206, 212, 218), // rule — table borders / separators
- DocumentColor.rgb(232, 240, 254), // banner — pale-blue hero panel / zebra fill
- DocumentColor.WHITE); // mainFill — table surface
+ DocumentColor.rgb(34, 38, 50), // ink — body text (modern textPrimary)
+ DocumentColor.rgb(110, 110, 120), // muted — metadata (modern textMuted)
+ DocumentColor.rgb(212, 200, 178), // rule — table borders (modern rule)
+ DocumentColor.rgb(244, 238, 228), // banner — soft-tan hero panel / zebra (modern surfaceMuted)
+ DocumentColor.rgb(252, 248, 240)); // mainFill — cream surface + page background (modern surface)
}
}
diff --git a/src/main/java/com/demcha/compose/document/templates/core/theme/Typography.java b/src/main/java/com/demcha/compose/document/templates/core/theme/Typography.java
index b7d03ee29..d75580466 100644
--- a/src/main/java/com/demcha/compose/document/templates/core/theme/Typography.java
+++ b/src/main/java/com/demcha/compose/document/templates/core/theme/Typography.java
@@ -373,13 +373,13 @@ public static Typography mintEditorial() {
public static Typography invoiceModern() {
return new Typography(
FontName.HELVETICA_BOLD, FontName.HELVETICA,
- 22.0, // headline (invoice title)
- 9.0, // contact (unused by invoice; kept for the record shape)
- 9.5, // banner (FROM / BILL TO / column labels)
- 10.0, // entry title
- 9.5, // entry date
- 9.0, // entry subtitle (footer note)
- 9.5, // body (party blocks, table cells)
+ 28.0, // headline (invoice title — modern h1)
+ 10.0, // contact (unused by invoice; kept for the record shape)
+ 11.0, // banner (FROM / BILL TO / table header labels — modern body-bold)
+ 11.0, // entry title
+ 11.0, // entry date
+ 10.0, // entry subtitle (footer caption — modern caption)
+ 11.0, // body (party blocks, table cells — modern body)
1.3); // line spacing
}
}
diff --git a/src/main/java/com/demcha/compose/document/templates/data/invoice/package-info.java b/src/main/java/com/demcha/compose/document/templates/data/invoice/package-info.java
index 2238bc61a..f1be624f0 100644
--- a/src/main/java/com/demcha/compose/document/templates/data/invoice/package-info.java
+++ b/src/main/java/com/demcha/compose/document/templates/data/invoice/package-info.java
@@ -1,4 +1,6 @@
/**
- * Invoice document specs and supporting data records for canonical templates.
+ * Shared, render-neutral invoice document specs and supporting data
+ * records, consumed by both the cinematic builtin {@code InvoiceTemplateV2}
+ * and the layered {@code invoice.v2} presets.
*/
package com.demcha.compose.document.templates.data.invoice;
diff --git a/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoice.java b/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoice.java
index 022a2550c..99442667b 100644
--- a/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoice.java
+++ b/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoice.java
@@ -31,9 +31,11 @@
* {@link DocumentTemplate} whose {@code compose} sequences a hero panel,
* the FROM / BILL TO parties, the line-items table, and a notes /
* payment-terms footer. The visual intent is ported from the cinematic
- * {@code builtins.InvoiceTemplateV2}; this preset reads every colour,
- * font, size, and spacing value from the theme instead of a
- * {@code BusinessTheme}.
+ * {@code builtins.InvoiceTemplateV2}; the hero, party labels, table
+ * header, totals, and footer read their colours / fonts / sizes from the
+ * theme (replacing the {@code BusinessTheme} the builtin used). The
+ * line-item body cells intentionally inherit the DSL default table-cell
+ * text to stay a pixel match for the builtin — see {@code compose}.
*
* Why the parties render inline rather than through
* {@code core.identity.PartyIdentity}: an invoice carries two
@@ -64,13 +66,19 @@ public final class ModernInvoice {
private static final double TABLE_PADDING = 7.0;
/**
- * Strong accent blue for the hero accent strip and the status read
- * out. Preset-local because no other v2 preset shares it today; if a
- * second invoice preset reaches for it, promote it to a
- * {@link com.demcha.compose.document.templates.core.theme.Palette}
- * slot.
+ * Deep teal used for the invoice title and the line-items table header
+ * fill (the modern business primary). Preset-local — no other v2 preset
+ * shares it today; promote to a {@link
+ * com.demcha.compose.document.templates.core.theme.Palette} slot if a
+ * second invoice preset reaches for it.
*/
- private static final DocumentColor ACCENT = DocumentColor.rgb(41, 128, 185);
+ private static final DocumentColor PRIMARY = DocumentColor.rgb(20, 60, 75);
+
+ /**
+ * Gold accent for the hero accent strip and the status read-out (the
+ * modern business accent). Preset-local, same rationale as {@link #PRIMARY}.
+ */
+ private static final DocumentColor ACCENT = DocumentColor.rgb(196, 153, 76);
private ModernInvoice() {
}
@@ -115,21 +123,38 @@ public void compose(DocumentSession document, InvoiceDocumentSpec spec) {
Objects.requireNonNull(document, "document");
InvoiceData data = Objects.requireNonNull(spec, "spec").invoice();
- DocumentColor panelFill = theme.palette().banner();
+ DocumentColor panelFill = theme.palette().banner(); // soft tan
DocumentColor rule = theme.palette().rule();
- DocumentColor surface = theme.palette().mainFill();
+ DocumentColor surface = theme.palette().mainFill(); // cream
- DocumentTextStyle titleStyle = theme.headlineStyle();
- DocumentTextStyle labelStyle = theme.bannerStyle();
+ // Title + table header use the deep teal PRIMARY; FROM / BILL TO
+ // labels + body read from the theme; the footer note is a quiet
+ // caption. Mirrors the cinematic builtin's modern look.
+ DocumentTextStyle titleStyle = DocumentTextStyle.builder()
+ .fontName(theme.typography().headlineFont())
+ .size(theme.typography().sizeHeadline())
+ .decoration(DocumentTextDecoration.BOLD)
+ .color(PRIMARY)
+ .build();
+ DocumentTextStyle labelStyle = theme.bodyBoldStyle();
DocumentTextStyle bodyStyle = theme.bodyStyle();
+ DocumentTextStyle captionStyle = DocumentTextStyle.builder()
+ .fontName(theme.typography().bodyFont())
+ .size(theme.typography().sizeEntrySubtitle())
+ .color(theme.palette().muted())
+ .build();
+ // Line-item cells intentionally carry NO textStyle — they inherit
+ // the DSL default table-cell text, exactly as the cinematic builtin's
+ // defaultCellStyle does. That is what makes this a pixel-for-pixel
+ // match; do NOT add a textStyle here (it would break parity). The
+ // theme-driven surfaces are the hero, labels, header, totals, footer.
DocumentTableStyle bordered = DocumentTableStyle.builder()
.stroke(DocumentStroke.of(rule, 0.6))
.padding(DocumentInsets.of(TABLE_PADDING))
- .textStyle(bodyStyle)
.build();
DocumentTableStyle headerStyle = DocumentTableStyle.builder()
- .fillColor(theme.palette().ink())
+ .fillColor(PRIMARY)
.stroke(DocumentStroke.of(rule, 0.6))
.padding(DocumentInsets.of(TABLE_PADDING + 1))
.textStyle(DocumentTextStyle.builder()
@@ -261,7 +286,7 @@ public void compose(DocumentSession document, InvoiceDocumentSpec spec) {
.name("InvoiceV2ModernFooter")
.addParagraph(p -> p
.text(data.footerNote())
- .textStyle(theme.entrySubtitleStyle())
+ .textStyle(captionStyle)
.margin(new DocumentInsets(14, 0, 0, 0)))
.build();
}
diff --git a/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/InvoiceV2VisualParityTest.java b/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/InvoiceV2VisualParityTest.java
new file mode 100644
index 000000000..61d6faa75
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/InvoiceV2VisualParityTest.java
@@ -0,0 +1,119 @@
+package com.demcha.compose.document.templates.invoice.v2.presets;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.data.invoice.InvoiceData;
+import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec;
+import com.demcha.compose.testing.visual.PdfVisualRegression;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.nio.file.Path;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+
+/**
+ * Pixel-diff visual parity gate for the v2 layered invoice presets.
+ *
+ *
Each preset renders the same canonical {@link InvoiceDocumentSpec}
+ * on A4 at the preset's {@code RECOMMENDED_MARGIN}; the PDF is rasterised
+ * page-by-page and compared per-pixel against a checked-in baseline PNG.
+ * {@code ModernInvoice} reproduces the cinematic {@code InvoiceTemplateV2}
+ * look on a {@code BrandTheme}, so this gate locks that look against drift.
+ *
+ * Re-blessing baselines — after a deliberate visual
+ * change, re-run with {@code -Dgraphcompose.visual.approve=true} to
+ * overwrite the baselines, and commit the updated PNGs in the same change.
+ * Baselines live under
+ * {@code src/test/resources/visual-baselines/invoice-v2-layered/}.
+ */
+class InvoiceV2VisualParityTest {
+
+ private static final Path BASELINE_ROOT = Path.of(
+ "src", "test", "resources", "visual-baselines", "invoice-v2-layered");
+
+ // Mirrors CvV2VisualParityTest: Helvetica is the PDFBox built-in font
+ // with the widest cross-platform glyph/colour drift, so the budget is
+ // sized generously for Windows-recorded vs Linux-CI rendering.
+ private static final long PIXEL_DIFF_BUDGET = 50_000L;
+ private static final int PER_PIXEL_TOLERANCE = 8;
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("presets")
+ void rendersWithinPixelDiffBudget(String slug,
+ double margin,
+ Supplier> factory)
+ throws Exception {
+ DocumentTemplate template = factory.get();
+ float m = (float) margin;
+ byte[] pdfBytes;
+ try (DocumentSession document = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4)
+ .margin(m, m, m, m)
+ .create()) {
+ template.compose(document, canonicalInvoice());
+ pdfBytes = document.toPdfBytes();
+ }
+
+ PdfVisualRegression.standard()
+ .baselineRoot(BASELINE_ROOT)
+ .perPixelTolerance(PER_PIXEL_TOLERANCE)
+ .mismatchedPixelBudget(PIXEL_DIFF_BUDGET)
+ .assertMatchesBaseline(slug, pdfBytes);
+ }
+
+ private static Stream presets() {
+ return Stream.of(
+ Arguments.of("modern_invoice",
+ ModernInvoice.RECOMMENDED_MARGIN,
+ (Supplier>) ModernInvoice::create));
+ }
+
+ /**
+ * Canonical sample invoice — exercises the hero, both parties, a
+ * multi-row line-items table, subtotal / tax / total summary, and the
+ * notes / payment-terms footer. Kept inline so the test depends only
+ * on main + main-test code.
+ */
+ private static InvoiceDocumentSpec canonicalInvoice() {
+ return InvoiceDocumentSpec.from(InvoiceData.builder()
+ .title("Invoice")
+ .invoiceNumber("GC-2026-041")
+ .issueDate("02 Apr 2026")
+ .dueDate("16 Apr 2026")
+ .status("Pending")
+ .fromParty(from -> from
+ .name("GraphCompose Studio")
+ .addressLines("18 Layout Street", "London, UK", "EC1A 4GC")
+ .email("billing@graphcompose.dev")
+ .phone("+44 20 5555 1000")
+ .taxId("GB-99887766"))
+ .billToParty(to -> to
+ .name("Northwind Systems")
+ .addressLines("Attn: Finance Team", "410 Market Avenue", "Manchester, UK")
+ .email("ap@northwind.example")
+ .phone("+44 161 555 2200")
+ .taxId("NW-2026-01"))
+ .lineItem("Discovery workshop", "Stakeholder interviews",
+ "1", "GBP 1,450", "GBP 1,450")
+ .lineItem("Template architecture", "Reusable document flows",
+ "2", "GBP 980", "GBP 1,960")
+ .lineItem("Render QA", "Cross-platform pixel diffing",
+ "3", "GBP 320", "GBP 960")
+ .lineItem("Developer enablement", "Authoring docs + examples",
+ "1", "GBP 780", "GBP 780")
+ .summaryRow("Subtotal", "GBP 5,150")
+ .summaryRow("VAT (20%)", "GBP 1,030")
+ .totalRow("Total", "GBP 6,180")
+ .note("Please include the invoice number on your remittance advice.")
+ .note("All work was delivered as agreed during the April implementation window.")
+ .paymentTerm("Payment due within 14 calendar days.")
+ .paymentTerm("Bank transfer preferred; contact billing@graphcompose.dev for remittance details.")
+ .paymentTerm("Late payments may delay additional template customization work.")
+ .footerNote("Thank you for choosing GraphCompose for production document rendering.")
+ .build());
+ }
+}
diff --git a/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoiceSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoiceSmokeTest.java
index 86050e706..f02aafda0 100644
--- a/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoiceSmokeTest.java
+++ b/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoiceSmokeTest.java
@@ -91,7 +91,9 @@ void rendersWithExplicitTheme() throws Exception {
@Test
void readsAnyTheme() throws Exception {
- // Proves the preset reads the theme rather than assuming invoiceModern() slots.
+ // Renders under a non-invoice theme without crashing: the hero, labels,
+ // header, totals, and footer follow the theme; the line-item body cells
+ // inherit the DSL default (as in the cinematic builtin).
render(ModernInvoice.create(BrandTheme.boxedClassic()), sampleSpec());
}
diff --git a/src/test/resources/visual-baselines/invoice-v2-layered/modern_invoice-page-0.png b/src/test/resources/visual-baselines/invoice-v2-layered/modern_invoice-page-0.png
new file mode 100644
index 000000000..65ff32c5b
Binary files /dev/null and b/src/test/resources/visual-baselines/invoice-v2-layered/modern_invoice-page-0.png differ