1.2.0 — Generic Link/Button DSL

This release adds link() and button() to every block that holds free-text content (Custom sections, paymentInfo { notes { ... } }, footer { notes/terms/customContent { ... } }), introduces a LinkStyle choice on the pay link (TEXT or BUTTON), and refactors the IR so notes/terms hold rich element lists rather than flat strings.

At a glance

Surface Status
paymentLink("https://...") single-arg DSL ✅ Unchanged — still works, label defaults to "Pay Now"
paymentLink("Pay Now", "https://...") ➕ New 2/3-arg overload with optional LinkStyle
paymentButton("Pay Now", "https://...") ➕ New convenience for BUTTON style
notes("Thank you") / terms("Net 30") (string form) ✅ Unchanged
notes { text(...); link(...) } (rich form) ➕ New lambda overload
link(text, href) inside Custom ✅ Unchanged
button(text, href) inside Custom / notes / footer ➕ New
InvoiceSection.PaymentInfo.paymentLink: String? ⚠ Breaking — now InvoiceElement.Link?
InvoiceSection.PaymentInfo.notes: String? ⚠ Breaking — now List<InvoiceElement>?
InvoiceSection.Footer.{notes, terms, customContent}: String? ⚠ Breaking — now List<InvoiceElement>?
CustomBuilder.row { } receiver ⚠ Minor — now ContentBuilder instead of CustomBuilder
Default rendered pay-link text ⚠ Behaviour change — was "Pay Online: <url>", now "Pay Now" (or your custom label)

Most user code keeps compiling because the DSL surface stayed backward-compatible. The breaking changes hit code that inspects the IR directly (e.g. tests, custom renderers, serialisation).

Added

Both are part of the shared ContentBuilder DSL, available wherever rich content is accepted:

invoice {
    paymentInfo {
        bankName("First National")
        paymentButton("Pay $1,000 Now", "https://pay.acme.com/inv-001")
        notes {
            text("Wire transfer alternative — see ")
            link("transfer policy", "https://acme.com/wire-policy")
        }
    }
    footer {
        notes {
            text("Thanks for your business! Read our ")
            link("terms", "https://acme.com/terms")
            text(" or ")
            link("contact support", "mailto:billing@acme.com")
            text(".")
        }
    }
    custom("cta") {
        button("Schedule a call", "https://cal.com/acme")
    }
}

LinkStyle enum

public enum class LinkStyle { TEXT, BUTTON }

Attached to InvoiceElement.Link.style. TEXT renders as primary-colored M3 labelLarge. BUTTON renders as an M3 Button-equivalent (filled primary container, white label, 20dp pill, 24dp/10dp padding, 14sp Medium label).

paymentInfo { paymentLink(text, href, style?) } and paymentButton(text, href)

paymentInfo {
    paymentLink("https://...")                                     // "Pay Now", TEXT (unchanged)
    paymentLink("Pay this invoice", "https://...")                 // custom label, TEXT
    paymentLink("Pay Now", "https://...", LinkStyle.BUTTON)        // BUTTON
    paymentButton("Pay $1,000 Now", "https://...")                 // BUTTON shortcut
}

Changed (breaking)

// Before
val pay: InvoiceSection.PaymentInfo = ...
val url: String? = pay.paymentLink

// After
val link: InvoiceElement.Link? = pay.paymentLink
val url: String? = link?.href
val label: String? = link?.text
val style: LinkStyle? = link?.style

PaymentInfo.notes and Footer.{notes, terms, customContent} are now List<InvoiceElement>?

// Before
val notes: String? = footer.notes

// After
val notes: List<InvoiceElement>? = footer.notes
// Plain-text consumers can flatten:
val plain: String = footer.notes
    ?.joinToString("") { (it as? InvoiceElement.Text)?.value ?: "" }
    ?: ""

The DSL string-form setters are unchanged:

footer {
    notes("Thank you for your business!")  // still works
    terms("Net 30")                         // still works
}

CustomBuilder.row { } receiver narrowed

// Signature before
public fun row(vararg weights: Float, init: CustomBuilder.() -> Unit)

// Signature after
public fun row(vararg weights: Float, init: ContentBuilder.() -> Unit)

Inside a row { } block you now have only content primitives — text(), link(), button(), etc. You cannot reference the section key.

The rendered text is now Link.text directly (default "Pay Now"), not "Pay Online: <url>". This affects visual snapshots and any test that asserted on the literal string.

Removed

  • PdfRendererTest.paymentLinkAnnotationPresent — substring check on PDF text; superseded by fidelity-test/LinkAndImageTest.pdf_paymentLinkIsAnnotation which inspects real PDAnnotationLink + PDActionURI.

Renderer behaviour matrix

Element render-compose render-pdf render-html-email
link() (TEXT) Primary M3 labelLarge text, wrapped in LocalLinkWrapper PdfLink annotation over the text bounds <a href> with primary color, weight 500, no underline
button() / paymentButton() (BUTTON) Box with primary background, 20dp pill, white label PdfLink annotation over the button bounds (rect of the Box) Bulletproof <table><tr><td> with primary background, 20px radius, 10/24 padding, white label
Inline links inside notes / terms / customContent Each element delegated to shared ElementContent composable Inherits clickability via LocalLinkWrapper Each element delegated to shared renderElement

Suggested migration path

  1. DSL-only consumers: rebuild — most code keeps working. Visual snapshot tests of pay-link rendering will need re-baselining (text changed from "Pay Online: <url>" to your label / "Pay Now").
  2. IR consumers (custom renderers, JSON serialisation, etc.): update field accesses per the patterns above.
  3. New invoices: prefer the rich notes { ... } lambda for any text that benefits from inline links — terms, support contact, policy references. Use paymentButton(...) for the primary CTA when you want a styled button; stick with paymentLink(...) for an inline text-style link.

Back to top

Copyright © 2026 Christopher Jenkins. Licensed under Apache 2.0.