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
link() and button() everywhere
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)
PaymentInfo.paymentLink: String? → InvoiceElement.Link?
// 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.
Pay-link default text
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 byfidelity-test/LinkAndImageTest.pdf_paymentLinkIsAnnotationwhich inspects realPDAnnotationLink+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
- 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"). - IR consumers (custom renderers, JSON serialisation, etc.): update field accesses per the patterns above.
- New invoices: prefer the rich
notes { ... }lambda for any text that benefits from inline links — terms, support contact, policy references. UsepaymentButton(...)for the primary CTA when you want a styled button; stick withpaymentLink(...)for an inline text-style link.