diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5f1b14c..d9d4c4bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,24 @@ follow semantic versioning; release dates are ISO 8601.
template surface, including its isolated theme tokens, visual
regression baselines, and reusable `Subheadline` /
`SectionHeader.flatSpacedCaps` widget support.
+- Added the **Mint Editorial** template set: a two-page, two-column
+ editorial CV preset `MintEditorial` (centred spaced-caps masthead with
+ a full-width mint accent rule; sidebar contact / interests / education /
+ expertise / skill-bars / social beside a profile / experience / awards /
+ references main column) and its paired `MintEditorialLetter`, both on
+ `CvTheme.mintEditorial()` and with visual regression baselines.
+- Added two reusable `cv/v2/widgets`: `SkillBar` (data-driven proficiency
+ bar — spaced-caps label above a track with a level-positioned marker;
+ no bar when the level is absent) and `IconTextRow` (inline icon + text
+ row, optionally a single click target), with `WidgetSmokeTest` coverage.
+- Added optional proficiency levels to `SkillGroup` via the new
+ `CvSkill` record and `SkillsSection.Builder.leveledGroup(...)`. Fully
+ backward-compatible: name-only skills carry no level and every existing
+ name-based renderer is unaffected.
+- Added `MintEditorial.Options` (and a matching `MintEditorialLetter.Options`)
+ — an additive masthead colour API (accent, rule, name, and an optional
+ full-width page-1 header band) whose defaults reproduce the stock render
+ exactly, so the committed look and the parity baselines are unchanged.
### Public API
diff --git a/docs/templates/v2-layered/authoring-presets.md b/docs/templates/v2-layered/authoring-presets.md
index b932bda4..dd73154c 100644
--- a/docs/templates/v2-layered/authoring-presets.md
+++ b/docs/templates/v2-layered/authoring-presets.md
@@ -107,6 +107,24 @@ in `com.demcha.compose.document.templates.widgets`.
| `SectionHeader.upperRule(host, title, theme, titleStyle, ruleColor, ruleWidth)` | Uppercase label with short rule below |
| `SectionHeader.spacedCapsRule(host, title, theme, titleStyle, ruleColor, ruleWidth, ruleThickness, ruleMargin)` | Spaced-caps label with short rule below |
+### `SkillBar` — data-driven proficiency bar
+
+| Variant | Visual |
+|---|---|
+| `SkillBar.render(host, skill, trackWidth, theme)` | Spaced-caps skill label above a thin track with a level-positioned marker; renders the label with **no bar** when `skill.level()` is absent |
+
+Reads the level from `CvSkill.level()` (`[0, 1]`); used by the Mint
+Editorial skills sidebar.
+
+### `IconTextRow` — inline icon + text row
+
+| Variant | Visual |
+|---|---|
+| `IconTextRow.render(host, icon, iconSize, text, style, link, margin)` | A glyph image followed by a label on one baseline; the whole row is a single click target when a `link` is supplied |
+
+Used for the icon-led contact and social rows in sidebar CV layouts
+(Mint Editorial).
+
### Higher-order CV widgets
| Widget | Visual |
diff --git a/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvMintEditorialCustomExample.java b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvMintEditorialCustomExample.java
new file mode 100644
index 00000000..fd2a9e79
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvMintEditorialCustomExample.java
@@ -0,0 +1,61 @@
+package com.demcha.examples.templates.cv.v2;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.v2.data.CvDocument;
+import com.demcha.compose.document.templates.cv.v2.presets.MintEditorial;
+import com.demcha.examples.support.ExampleDataFactory;
+import com.demcha.examples.support.ExampleOutputPaths;
+
+import java.nio.file.Path;
+
+/**
+ * Renders the Mint Editorial CV against the rich "Rose Harris" showcase
+ * dataset with a single custom colour via
+ * {@link MintEditorial.Options} — a soft kraft-paper header band that fills
+ * the whole masthead zone from the top page edge down to the mint rule. Only
+ * {@code headerBandColor} is set; everything else stays default, so the dark
+ * name, mint tagline, and mint full-width rule are unchanged and read cleanly
+ * on the light tan band. This demonstrates the colour-customisation API; the
+ * default-coloured render lives in {@code CvMintEditorialExample} and is left
+ * untouched.
+ *
+ *
Output:
+ * {@code examples/target/generated-pdfs/templates/cv/cv-mint-editorial-v2-custom.pdf}.
+ */
+public final class CvMintEditorialCustomExample {
+
+ private CvMintEditorialCustomExample() {
+ }
+
+ public static Path generate() throws Exception {
+ Path outputFile = ExampleOutputPaths.prepare(
+ "templates/cv", "cv-mint-editorial-v2-custom.pdf");
+ CvDocument doc = ExampleDataFactory.mintEditorialShowcaseCv();
+
+ // Set ONLY the header band colour — a soft warm kraft-paper tan. Every
+ // other knob stays default: dark ink name, mint tagline, mint
+ // full-width rule. The dark name reads cleanly on the light tan band.
+ MintEditorial.Options options = MintEditorial.Options.builder()
+ .headerBandColor(DocumentColor.rgb(228, 217, 198)) // kraft-paper tan band
+ .build();
+ DocumentTemplate template = MintEditorial.create(options);
+
+ float m = (float) MintEditorial.RECOMMENDED_MARGIN;
+ try (DocumentSession document = GraphCompose.document(outputFile)
+ .pageSize(DocumentPageSize.A4)
+ .margin(m, m, m, m)
+ .create()) {
+ template.compose(document, doc);
+ document.buildPdf();
+ }
+ return outputFile;
+ }
+
+ public static void main(String[] args) throws Exception {
+ System.out.println("Generated: " + generate());
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/coverletter/v2/presets/MintEditorialLetter.java b/src/main/java/com/demcha/compose/document/templates/coverletter/v2/presets/MintEditorialLetter.java
index bd404f49..5e356fcf 100644
--- a/src/main/java/com/demcha/compose/document/templates/coverletter/v2/presets/MintEditorialLetter.java
+++ b/src/main/java/com/demcha/compose/document/templates/coverletter/v2/presets/MintEditorialLetter.java
@@ -2,7 +2,13 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.dsl.ShapeBuilder;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.ParagraphNode;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.ClipPolicy;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentTextDecoration;
@@ -11,6 +17,7 @@
import com.demcha.compose.document.templates.coverletter.v2.components.LetterBody;
import com.demcha.compose.document.templates.coverletter.v2.data.CoverLetterDocument;
import com.demcha.compose.document.templates.cv.v2.components.CvTextStyles;
+import com.demcha.compose.document.templates.cv.v2.components.TextOrnaments;
import com.demcha.compose.document.templates.cv.v2.data.CvIdentity;
import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
import com.demcha.compose.document.templates.cv.v2.widgets.Headline;
@@ -30,6 +37,11 @@
* CV, so the CV and the letter read as one matched set. The CV's two-column
* sidebar grids are a CV-body concern and are intentionally not part of the
* letter.
+ *
+ * The same {@link Options} colour knobs as the paired
+ * {@link com.demcha.compose.document.templates.cv.v2.presets.MintEditorial}
+ * preset recolour the letter masthead (accent, rule, name, optional header
+ * band), with identical defaults so the matched set stays in sync.
*/
public final class MintEditorialLetter {
@@ -42,33 +54,140 @@ public final class MintEditorialLetter {
/** Recommended symmetric page margin (in points). Matches the CV preset. */
public static final double RECOMMENDED_MARGIN = 48.0;
+ // Banded-masthead canvas geometry — mirrors the CV preset so the matched
+ // set's masthead is identical, and reproduces the letter's own DEFAULT
+ // (bandless) masthead positions so the banded render only adds a fill
+ // behind an otherwise-unchanged masthead.
+
+ /** Canvas flow footprint (points) — matches the CV masthead footprint. */
+ private static final double MASTHEAD_CANVAS_HEIGHT = 143.76;
+
+ /** Canvas-local y (points) of the masthead name — matches the default top. */
+ private static final double MASTHEAD_NAME_Y = 48.0;
+
+ /** Canvas-local y (points) of the masthead tagline — matches the default top. */
+ private static final double MASTHEAD_TAGLINE_Y = 87.4;
+
+ /** Canvas-local y (points) of the masthead rule — matches the default top. */
+ private static final double MASTHEAD_RULE_Y = 123.76;
+
private MintEditorialLetter() {
}
- /** Builds the letter with its Mint Editorial theme. */
+ /** Builds the letter with its Mint Editorial theme and default colours. */
public static DocumentTemplate create() {
- return create(CvTheme.mintEditorial());
+ return create(CvTheme.mintEditorial(), Options.defaults());
}
/**
- * Builds the letter with a caller-supplied theme (share the paired CV's
- * theme instance for a guaranteed visual match).
+ * Builds the letter with a caller-supplied theme and default colours
+ * (share the paired CV's theme instance for a guaranteed visual match).
*/
public static DocumentTemplate create(CvTheme theme) {
+ return create(theme, Options.defaults());
+ }
+
+ /** Builds the letter with its Mint Editorial theme and explicit colours. */
+ public static DocumentTemplate create(Options options) {
+ return create(CvTheme.mintEditorial(), options);
+ }
+
+ /**
+ * Builds the letter with a caller-supplied theme and explicit colour
+ * {@link Options}. Pass the same {@code Options} as the paired CV preset
+ * so the recoloured masthead matches.
+ */
+ public static DocumentTemplate create(CvTheme theme,
+ Options options) {
Objects.requireNonNull(theme, "theme");
- return new Template(theme);
+ Objects.requireNonNull(options, "options");
+ return new Template(theme, options);
+ }
+
+ /**
+ * Mint Editorial letter masthead colour knobs — same shape and defaults as
+ * {@code MintEditorial.Options}. Every {@code null} field reproduces the
+ * stock render.
+ *
+ * @param accentColor mint accent for the tagline; {@code null} →
+ * {@code theme.palette().banner()}
+ * @param ruleColor masthead rule colour; {@code null} → the resolved
+ * {@code accentColor}
+ * @param nameColor masthead name colour; {@code null} →
+ * {@code theme.palette().ink()}
+ * @param headerBandColor optional full-width band behind the masthead;
+ * {@code null} → no band
+ */
+ public record Options(DocumentColor accentColor,
+ DocumentColor ruleColor,
+ DocumentColor nameColor,
+ DocumentColor headerBandColor) {
+
+ public static Options defaults() {
+ return new Options(null, null, null, null);
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private DocumentColor accentColor;
+ private DocumentColor ruleColor;
+ private DocumentColor nameColor;
+ private DocumentColor headerBandColor;
+
+ private Builder() {
+ }
+
+ public Builder accentColor(DocumentColor value) {
+ this.accentColor = value;
+ return this;
+ }
+
+ public Builder ruleColor(DocumentColor value) {
+ this.ruleColor = value;
+ return this;
+ }
+
+ public Builder nameColor(DocumentColor value) {
+ this.nameColor = value;
+ return this;
+ }
+
+ public Builder headerBandColor(DocumentColor value) {
+ this.headerBandColor = value;
+ return this;
+ }
+
+ public Options build() {
+ return new Options(accentColor, ruleColor, nameColor,
+ headerBandColor);
+ }
+ }
}
private static final class Template implements DocumentTemplate {
private final CvTheme theme;
private final DocumentColor accent;
+ private final DocumentColor ruleColor;
+ private final DocumentColor nameColor;
+ private final DocumentColor headerBandColor;
- Template(CvTheme theme) {
+ Template(CvTheme theme, Options options) {
this.theme = theme;
- // Same accent source as the paired CV preset — the palette
- // banner slot carries the mint accent.
- this.accent = theme.palette().banner();
+ // Same accent source + Options defaults as the paired CV preset.
+ this.accent = options.accentColor() != null
+ ? options.accentColor()
+ : theme.palette().banner();
+ this.ruleColor = options.ruleColor() != null
+ ? options.ruleColor()
+ : this.accent;
+ this.nameColor = options.nameColor() != null
+ ? options.nameColor()
+ : theme.palette().ink();
+ this.headerBandColor = options.headerBandColor();
}
@Override
@@ -97,13 +216,19 @@ public void compose(DocumentSession document, CoverLetterDocument doc) {
.spacing(theme.spacing().pageFlowSpacing());
flow.addSection("CoverLetterV2MintEditorialHeader",
- section -> addMasthead(section, doc.identity()));
- flow.addLine(line -> line
- .name("CoverLetterV2MintEditorialHeaderRule")
- .horizontal(pageWidth)
- .color(accent)
- .thickness(theme.spacing().accentRuleWidth())
- .margin(new DocumentInsets(8, -ruleBleed, 14, -ruleBleed)));
+ section -> addMasthead(section, doc.identity(), pageWidth,
+ ruleBleed));
+ if (headerBandColor == null) {
+ // Stock masthead rule. In banded mode the rule is drawn flush at
+ // the band's bottom edge inside the header canvas, so the
+ // separate flow rule is suppressed (no doubled line).
+ flow.addLine(line -> line
+ .name("CoverLetterV2MintEditorialHeaderRule")
+ .horizontal(pageWidth)
+ .color(ruleColor)
+ .thickness(theme.spacing().accentRuleWidth())
+ .margin(new DocumentInsets(8, -ruleBleed, 14, -ruleBleed)));
+ }
flow.addSection("CoverLetterV2MintEditorialBody", host ->
LetterBody.render(host, doc, theme));
@@ -113,17 +238,93 @@ public void compose(DocumentSession document, CoverLetterDocument doc) {
/**
* Centred spaced-caps name + mint accent tagline — the identical
- * masthead the {@code MintEditorial} CV preset renders, so the
- * matched set never forks the header treatment.
+ * masthead the {@code MintEditorial} CV preset renders, so the matched
+ * set never forks the header treatment. With default colours and no
+ * band the output is byte-identical to the pre-Options render.
*/
- private void addMasthead(SectionBuilder section, CvIdentity identity) {
- Headline.spacedCentered(section, identity.name(), theme);
+ private void addMasthead(SectionBuilder section, CvIdentity identity,
+ double pageWidth, double ruleBleed) {
+ if (headerBandColor != null) {
+ addBandedMasthead(section, identity, pageWidth, ruleBleed);
+ } else {
+ addPlainMasthead(section, identity);
+ }
+ }
+
+ private void addPlainMasthead(SectionBuilder section, CvIdentity identity) {
+ // Style-override variant so only the name colour can change.
+ Headline.render(section, identity.name(), theme,
+ TextAlign.CENTER, true, mastheadNameStyle());
String jobTitle = identity.jobTitle();
if (jobTitle != null && !jobTitle.isBlank()) {
Subheadline.centeredSpacedCaps(section, jobTitle, taglineStyle());
}
}
+ /**
+ * Banded masthead: the whole masthead zone is one
+ * {@code CanvasLayerNode} (controlled absolute placement), mirroring the
+ * paired CV preset. The band fills the canvas (page top edge → rule),
+ * the canvas is bled to the page edges via negative margins, and the
+ * rule sits flush at the band's bottom edge. The canvas reserves only
+ * {@value #MASTHEAD_CANVAS_HEIGHT}pt of flow, so the band adds no extra
+ * flow height. See {@code MintEditorial.addBandedHeader} for the full
+ * rationale (including why the band fills the canvas rather than
+ * overflowing a child upward).
+ */
+ private void addBandedMasthead(SectionBuilder section,
+ CvIdentity identity, double pageWidth,
+ double ruleBleed) {
+ double canvasH = MASTHEAD_CANVAS_HEIGHT;
+ double ruleThickness = theme.spacing().accentRuleWidth();
+ double bandHeight = MASTHEAD_RULE_Y + ruleThickness;
+
+ DocumentNode band = new ShapeBuilder()
+ .name("CoverLetterV2MintEditorialHeaderBand")
+ .size(pageWidth, bandHeight)
+ .fillColor(headerBandColor)
+ .build();
+ DocumentNode rule = new ShapeBuilder()
+ .name("CoverLetterV2MintEditorialHeaderRule")
+ .size(pageWidth, ruleThickness)
+ .fillColor(ruleColor)
+ .build();
+ ParagraphNode name = new ParagraphBuilder()
+ .name("CoverLetterV2MintEditorialHeaderName")
+ .text(TextOrnaments.spacedUpper(identity.name().full()))
+ .textStyle(mastheadNameStyle())
+ .align(TextAlign.CENTER)
+ .build();
+ String jobTitle = identity.jobTitle();
+ ParagraphNode tagline = jobTitle != null && !jobTitle.isBlank()
+ ? new ParagraphBuilder()
+ .name("CoverLetterV2MintEditorialHeaderTagline")
+ .text(TextOrnaments.spacedUpper(jobTitle))
+ .textStyle(taglineStyle())
+ .align(TextAlign.CENTER)
+ .build()
+ : null;
+
+ section.addCanvas(pageWidth, canvasH, canvas -> {
+ canvas.name("CoverLetterV2MintEditorialHeaderCanvas")
+ .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .margin(new DocumentInsets(-ruleBleed, -ruleBleed, 0,
+ -ruleBleed))
+ .position(band, 0.0, 0.0)
+ .position(rule, 0.0, MASTHEAD_RULE_Y)
+ .position(name, 0.0, MASTHEAD_NAME_Y);
+ if (tagline != null) {
+ canvas.position(tagline, 0.0, MASTHEAD_TAGLINE_Y);
+ }
+ });
+ }
+
+ private DocumentTextStyle mastheadNameStyle() {
+ return CvTextStyles.of(theme.typography().headlineFont(),
+ theme.typography().sizeHeadline(),
+ DocumentTextDecoration.DEFAULT, nameColor);
+ }
+
private DocumentTextStyle taglineStyle() {
return CvTextStyles.of(theme.typography().headlineFont(),
theme.typography().sizeContact(),
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorial.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorial.java
index 4f455289..053422e1 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorial.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorial.java
@@ -1,12 +1,17 @@
package com.demcha.compose.document.templates.cv.v2.presets;
import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.PageFlowBuilder;
import com.demcha.compose.document.dsl.ParagraphBuilder;
import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.dsl.ShapeBuilder;
import com.demcha.compose.document.image.DocumentImageData;
import com.demcha.compose.document.node.DocumentLinkOptions;
+import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.node.ParagraphNode;
+import com.demcha.compose.document.node.ShapeNode;
import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.ClipPolicy;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentStroke;
@@ -172,6 +177,35 @@ public final class MintEditorial {
/** Expertise badge edge length (points). */
private static final double BADGE_SIZE = 36.0;
+ // Banded-masthead canvas geometry. These values reproduce the DEFAULT
+ // (bandless) masthead flow positions exactly — name baseline, tagline
+ // baseline, and rule y — measured from the stock render at the canonical
+ // 48pt page margin. The canvas top is bled to the page top edge (y=0) via a
+ // negative margin, so a canvas-local y equals the page-absolute top edge in
+ // points; the constants therefore equal the default page-absolute tops. The
+ // band fills the canvas from y=0 down to the rule's bottom, and the masthead
+ // (name/tagline/rule) renders at the same positions whether or not a band is
+ // present — the band only adds a tan fill behind it.
+ //
+ // These are package-private (not private) so MintEditorialSmokeTest can
+ // assert them against the live default masthead positions — see its
+ // band_constants_match_default_masthead guard, which fails if a future
+ // typography / spacing / margin change moves the masthead and silently
+ // invalidates these hand-measured coordinates.
+
+ /** Canvas flow footprint (points) — sized so the page-1 row starts at the
+ * same y as the default render. */
+ static final double MASTHEAD_CANVAS_HEIGHT = 143.76;
+
+ /** Canvas-local y (points) of the masthead name — matches the default top. */
+ static final double MASTHEAD_NAME_Y = 48.0;
+
+ /** Canvas-local y (points) of the masthead tagline — matches the default top. */
+ static final double MASTHEAD_TAGLINE_Y = 87.4;
+
+ /** Canvas-local y (points) of the masthead rule — matches the default top. */
+ static final double MASTHEAD_RULE_Y = 123.76;
+
private static final String ICON_ROOT = "/templates/cv/mint-editorial/icons/";
private static final Map ICON_CACHE = new ConcurrentHashMap<>();
@@ -195,30 +229,133 @@ public final class MintEditorial {
private MintEditorial() {
}
- /** Builds the preset with its Mint Editorial theme. */
+ /** Builds the preset with its Mint Editorial theme and default colours. */
public static DocumentTemplate create() {
- return create(CvTheme.mintEditorial());
+ return create(CvTheme.mintEditorial(), Options.defaults());
}
/**
- * Builds the preset with a caller-supplied theme (share the paired
- * cover letter's theme instance for a guaranteed visual match).
+ * Builds the preset with a caller-supplied theme and default colours
+ * (share the paired cover letter's theme instance for a guaranteed
+ * visual match).
*/
public static DocumentTemplate create(CvTheme theme) {
+ return create(theme, Options.defaults());
+ }
+
+ /**
+ * Builds the preset with its Mint Editorial theme and explicit colour
+ * {@link Options}.
+ */
+ public static DocumentTemplate create(Options options) {
+ return create(CvTheme.mintEditorial(), options);
+ }
+
+ /**
+ * Builds the preset with a caller-supplied theme and explicit colour
+ * {@link Options}. Use this to recolour the masthead (accent, rule,
+ * name, optional header band) without forking the theme.
+ */
+ public static DocumentTemplate create(CvTheme theme,
+ Options options) {
Objects.requireNonNull(theme, "theme");
- return new Template(theme);
+ Objects.requireNonNull(options, "options");
+ return new Template(theme, options);
+ }
+
+ /**
+ * Mint Editorial masthead colour knobs. Every {@code null} field falls
+ * back to a default that reproduces the stock render exactly, so an
+ * {@link #defaults()} instance leaves the committed look unchanged.
+ *
+ * @param accentColor mint accent used for the centred tagline and
+ * every spaced-caps section heading; {@code null}
+ * → {@code theme.palette().banner()}
+ * @param ruleColor full-width masthead rule colour, independent of
+ * the accent; {@code null} → the resolved
+ * {@code accentColor} (so unset = today's look)
+ * @param nameColor masthead name text colour; {@code null} →
+ * {@code theme.palette().ink()}
+ * @param headerBandColor optional full-page-width colour band painted
+ * behind the masthead on page 1 only;
+ * {@code null} → no band (white header)
+ */
+ public record Options(DocumentColor accentColor,
+ DocumentColor ruleColor,
+ DocumentColor nameColor,
+ DocumentColor headerBandColor) {
+
+ public static Options defaults() {
+ return new Options(null, null, null, null);
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private DocumentColor accentColor;
+ private DocumentColor ruleColor;
+ private DocumentColor nameColor;
+ private DocumentColor headerBandColor;
+
+ private Builder() {
+ }
+
+ public Builder accentColor(DocumentColor value) {
+ this.accentColor = value;
+ return this;
+ }
+
+ public Builder ruleColor(DocumentColor value) {
+ this.ruleColor = value;
+ return this;
+ }
+
+ public Builder nameColor(DocumentColor value) {
+ this.nameColor = value;
+ return this;
+ }
+
+ public Builder headerBandColor(DocumentColor value) {
+ this.headerBandColor = value;
+ return this;
+ }
+
+ public Options build() {
+ return new Options(accentColor, ruleColor, nameColor,
+ headerBandColor);
+ }
+ }
}
private static final class Template implements DocumentTemplate {
private final CvTheme theme;
+ /** Accent for the tagline + section headings (defaults to mint). */
private final DocumentColor accent;
-
- Template(CvTheme theme) {
+ /** Masthead rule colour (defaults to the accent). */
+ private final DocumentColor ruleColor;
+ /** Masthead name colour (defaults to ink). */
+ private final DocumentColor nameColor;
+ /** Optional page-1 header band; null = no band (white header). */
+ private final DocumentColor headerBandColor;
+
+ Template(CvTheme theme, Options options) {
this.theme = theme;
// Mint carries its accent in the palette banner slot — single
- // source shared with the paired cover letter.
- this.accent = theme.palette().banner();
+ // source shared with the paired cover letter. Each Options knob
+ // defaults to the value that reproduces the stock render.
+ this.accent = options.accentColor() != null
+ ? options.accentColor()
+ : theme.palette().banner();
+ this.ruleColor = options.ruleColor() != null
+ ? options.ruleColor()
+ : this.accent;
+ this.nameColor = options.nameColor() != null
+ ? options.nameColor()
+ : theme.palette().ink();
+ this.headerBandColor = options.headerBandColor();
}
@Override
@@ -268,18 +405,25 @@ public void compose(DocumentSession document, CvDocument doc) {
List experiencePage2 = experienceEntries.stream()
.skip(EXPERIENCE_PAGE_ONE).toList();
- document.dsl()
+ PageFlowBuilder flow = document.dsl()
.pageFlow()
.name("CvV2MintEditorialRoot")
.spacing(theme.spacing().pageFlowSpacing())
.addSection("CvV2MintEditorialHeader",
- section -> addHeader(section, identity))
- .addLine(line -> line
- .name("CvV2MintEditorialHeaderRule")
- .horizontal(pageWidth)
- .color(accent)
- .thickness(theme.spacing().accentRuleWidth())
- .margin(new DocumentInsets(8, -ruleBleed, 14, -ruleBleed)))
+ section -> addHeader(section, identity, pageWidth, ruleBleed));
+ if (headerBandColor == null) {
+ // Stock masthead rule, full page width. When a band is present
+ // the rule is drawn flush at the band's bottom edge inside the
+ // header canvas instead (see addBandedHeader), so the separate
+ // flow rule is suppressed to avoid a doubled line.
+ flow.addLine(line -> line
+ .name("CvV2MintEditorialHeaderRule")
+ .horizontal(pageWidth)
+ .color(ruleColor)
+ .thickness(theme.spacing().accentRuleWidth())
+ .margin(new DocumentInsets(8, -ruleBleed, 14, -ruleBleed)));
+ }
+ flow
.addRow("CvV2MintEditorialPageOne", row -> {
row.spacing(COLUMN_GAP).weights(SIDEBAR_WEIGHT, MAIN_WEIGHT);
row.addSection("CvV2MintEditorialPageOneSidebar", sidebar -> {
@@ -314,17 +458,121 @@ public void compose(DocumentSession document, CvDocument doc) {
// -- Header --------------------------------------------------------
- private void addHeader(SectionBuilder section, CvIdentity identity) {
- // Headline.spacedCentered sets the section's spacing + padding;
- // it renders the centred spaced-caps name as the page's loudest
- // element. The tagline follows in the mint accent.
- Headline.spacedCentered(section, identity.name(), theme);
+ private void addHeader(SectionBuilder section, CvIdentity identity,
+ double pageWidth, double ruleBleed) {
+ if (headerBandColor != null) {
+ addBandedHeader(section, identity, pageWidth, ruleBleed);
+ } else {
+ addPlainHeader(section, identity);
+ }
+ }
+
+ /**
+ * Stock masthead: centred spaced-caps name (in {@code nameColor}) over
+ * a centred accent tagline, no background band. When
+ * {@code nameColor == theme.palette().ink()} the explicit style equals
+ * {@code theme.headlineStyle()}, so the default render is byte-identical
+ * to the pre-Options output.
+ */
+ private void addPlainHeader(SectionBuilder section, CvIdentity identity) {
+ // Render the name through Headline's style-override variant so only
+ // the colour can change — alignment, spaced-caps transform, font and
+ // size all stay exactly as Headline.spacedCentered produced them.
+ Headline.render(section, identity.name(), theme,
+ TextAlign.CENTER, true, mastheadNameStyle());
String jobTitle = identity.jobTitle();
if (jobTitle != null && !jobTitle.isBlank()) {
Subheadline.centeredSpacedCaps(section, jobTitle, taglineStyle());
}
}
+ /**
+ * Banded masthead (page 1 only): the whole masthead zone is one
+ * {@code CanvasLayerNode} (controlled absolute placement, the v1.6
+ * free-canvas primitive) with {@link ClipPolicy#OVERFLOW_VISIBLE}. The
+ * canvas reserves only {@value #MASTHEAD_CANVAS_HEIGHT}pt in the flow —
+ * its declared height — so the masthead footprint stays small and the
+ * dense page-1 row still fits, keeping the document at two pages.
+ *
+ * Inside the canvas (origin top-left, y down):
+ *
+ * - the band rectangle is positioned at {@code (-ruleBleed,
+ * -ruleBleed)} and sized {@code pageWidth × (ruleBleed +
+ * canvasHeight)}, so — because overflow is visible — it bleeds up
+ * to the top page edge, out to both side edges (full width), and
+ * down to the canvas bottom;
+ * - a thin rule sits flush at the band's bottom edge, reading as the
+ * band's underline (the separate flow rule is suppressed in this
+ * mode, see {@code compose});
+ * - the centred name (in {@code nameColor}) and tagline (in
+ * {@code accentColor}) render on top.
+ *
+ *
+ * The band consumes no extra flow height because the canvas footprint
+ * is fixed and the overflow draws outside it. Page 2 is untouched: the
+ * header canvas is the first page-flow child, so it lives on page 1
+ * only — no {@code pageBackgrounds} (which would repeat on page 2).
+ */
+ private void addBandedHeader(SectionBuilder section, CvIdentity identity,
+ double pageWidth, double ruleBleed) {
+ double canvasH = MASTHEAD_CANVAS_HEIGHT;
+ double ruleThickness = theme.spacing().accentRuleWidth();
+ // Band runs from the top page edge down to the rule's bottom edge.
+ double bandHeight = MASTHEAD_RULE_Y + ruleThickness;
+
+ // The masthead (name / tagline / rule) is positioned at the EXACT
+ // default flow coordinates, so the banded and bandless renders share
+ // identical masthead geometry — only the tan fill behind it is new.
+ // The band fills the canvas from y=0 to the rule bottom; no child
+ // overflows the canvas upward (which would overdraw the row beneath
+ // on the PDF backend) — instead the canvas itself is bled to the page
+ // edges via negative margins.
+ DocumentNode band = new ShapeBuilder()
+ .name("CvV2MintEditorialHeaderBand")
+ .size(pageWidth, bandHeight)
+ .fillColor(headerBandColor)
+ .build();
+ DocumentNode rule = new ShapeBuilder()
+ .name("CvV2MintEditorialHeaderRule")
+ .size(pageWidth, ruleThickness)
+ .fillColor(ruleColor)
+ .build();
+ ParagraphNode name = new ParagraphBuilder()
+ .name("CvV2MintEditorialHeaderName")
+ .text(TextOrnaments.spacedUpper(identity.name().full()))
+ .textStyle(mastheadNameStyle())
+ .align(TextAlign.CENTER)
+ .build();
+ String jobTitle = identity.jobTitle();
+ boolean hasTagline = jobTitle != null && !jobTitle.isBlank();
+ ParagraphNode tagline = hasTagline
+ ? new ParagraphBuilder()
+ .name("CvV2MintEditorialHeaderTagline")
+ .text(TextOrnaments.spacedUpper(jobTitle))
+ .textStyle(taglineStyle())
+ .align(TextAlign.CENTER)
+ .build()
+ : null;
+
+ section.addCanvas(pageWidth, canvasH, canvas -> {
+ canvas.name("CvV2MintEditorialHeaderCanvas")
+ .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ // Bleed the whole canvas to the top + side page edges so
+ // the band reaches y=0 and both side edges.
+ .margin(new DocumentInsets(-ruleBleed, -ruleBleed, 0,
+ -ruleBleed))
+ // Band fills the masthead zone from the page top edge to
+ // the rule's bottom; rule + name + tagline sit at their
+ // default flow positions on top.
+ .position(band, 0.0, 0.0)
+ .position(rule, 0.0, MASTHEAD_RULE_Y)
+ .position(name, 0.0, MASTHEAD_NAME_Y);
+ if (tagline != null) {
+ canvas.position(tagline, 0.0, MASTHEAD_TAGLINE_Y);
+ }
+ });
+ }
+
// -- Sidebar: Contact ---------------------------------------------
private void addContact(SectionBuilder section, CvIdentity identity) {
@@ -513,6 +761,23 @@ private void addProfile(SectionBuilder section, CvSection profile) {
// -- Main: Experience ---------------------------------------------
+ /**
+ * Renders the Experience block with Mint's bespoke entry layout:
+ * a spaced-caps job title, a single {@code subtitle | date} meta line,
+ * a prose paragraph, and — uniquely for this preset — any trailing
+ * markdown bullet lines as a real bullet list.
+ *
+ * This is rendered locally rather than through the shared
+ * {@code EntryCompactRenderer} / {@code RichParagraphRenderer} because
+ * Mint's entry shape does not match those renderers: the title is
+ * transformed to letter-spaced uppercase, the subtitle and date are
+ * fused into one {@code "Company | Location | 2010 - Present"} meta line
+ * (the shared renderers keep them as separate title-row columns), and
+ * the body is split into prose + highlight bullets via
+ * {@link #splitBody(String)} — a transform no shared renderer performs.
+ * Bodies with no bullet lines (the canonical sample) take the plain
+ * single-paragraph path and are unaffected.
+ */
private void addExperience(SectionBuilder section, String title,
List entries) {
if (entries.isEmpty()) {
@@ -750,6 +1015,18 @@ private DocumentTableCell gridCell(String text, DocumentTableStyle style) {
// -- Style factories ----------------------------------------------
+ /**
+ * Masthead name style. With the default {@code nameColor} (ink) this is
+ * identical to {@code theme.headlineStyle()} — headline font, headline
+ * size, default decoration — so the stock render is unchanged; only the
+ * colour differs when a caller overrides {@code nameColor}.
+ */
+ private DocumentTextStyle mastheadNameStyle() {
+ return CvTextStyles.of(theme.typography().headlineFont(),
+ theme.typography().sizeHeadline(),
+ DocumentTextDecoration.DEFAULT, nameColor);
+ }
+
private DocumentTextStyle taglineStyle() {
return CvTextStyles.of(theme.typography().headlineFont(),
theme.typography().sizeContact(),
@@ -837,6 +1114,12 @@ private static List entriesOf(CvSection section) {
* {@code "* "}). Bodies with no bullet lines yield the whole text as
* prose and an empty bullet list, so the canonical sample renders
* exactly as before.
+ *
+ * Preset-local because the shared CV body renderers
+ * ({@code RichParagraphRenderer}, {@code EntryCompactRenderer}) treat the
+ * entry body as one rich paragraph and never break trailing bullet lines
+ * out into a list. This split is what lets a Mint experience entry show a
+ * paragraph followed by highlight bullets (see {@link Template#addExperience}).
*/
private static BodyParts splitBody(String body) {
if (body == null || body.isBlank()) {
diff --git a/src/test/java/com/demcha/compose/document/templates/coverletter/v2/presets/CoverLetterV2VisualParityTest.java b/src/test/java/com/demcha/compose/document/templates/coverletter/v2/presets/CoverLetterV2VisualParityTest.java
index bb796752..c61ba18c 100644
--- a/src/test/java/com/demcha/compose/document/templates/coverletter/v2/presets/CoverLetterV2VisualParityTest.java
+++ b/src/test/java/com/demcha/compose/document/templates/coverletter/v2/presets/CoverLetterV2VisualParityTest.java
@@ -114,7 +114,10 @@ private static Stream presets() {
(Supplier>) MonogramSidebarLetter::create),
Arguments.of("timeline_minimal",
TimelineMinimalLetter.RECOMMENDED_MARGIN,
- (Supplier>) TimelineMinimalLetter::create));
+ (Supplier>) TimelineMinimalLetter::create),
+ Arguments.of("mint-editorial-letter",
+ MintEditorialLetter.RECOMMENDED_MARGIN,
+ (Supplier>) MintEditorialLetter::create));
}
/**
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java
index addd9e78..0b6220be 100644
--- a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java
@@ -131,7 +131,10 @@ private static Stream presets() {
(Supplier>) MonogramSidebar::create),
Arguments.of("sidebar_portrait",
SidebarPortrait.RECOMMENDED_MARGIN,
- (Supplier>) SidebarPortrait::create));
+ (Supplier>) SidebarPortrait::create),
+ Arguments.of("mint_editorial",
+ MintEditorial.RECOMMENDED_MARGIN,
+ (Supplier>) MintEditorial::create));
}
/**
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorialSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorialSmokeTest.java
index ffa43d51..3901fcf1 100644
--- a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorialSmokeTest.java
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorialSmokeTest.java
@@ -4,6 +4,7 @@
import com.demcha.compose.document.api.DocumentPageSize;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.layout.LayoutGraph;
+import com.demcha.compose.document.layout.PlacedFragment;
import com.demcha.compose.document.templates.api.DocumentTemplate;
import com.demcha.compose.document.templates.cv.v2.data.CvDocument;
import com.demcha.compose.document.templates.cv.v2.data.CvIdentity;
@@ -14,11 +15,13 @@
import com.demcha.compose.document.templates.cv.v2.data.RowsSection;
import com.demcha.compose.document.templates.cv.v2.data.SkillsSection;
import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
+import com.demcha.compose.document.style.DocumentColor;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
/**
* Smoke test for the v2 Mint Editorial preset. Covers stable identity,
@@ -64,6 +67,101 @@ void custom_theme_factory_renders() throws Exception {
}
}
+ @Test
+ void custom_colour_options_render_two_pages() throws Exception {
+ // Dark header band + white name + a contrasting rule and accent —
+ // exercises every Options knob at once. Still a clean two-page render.
+ MintEditorial.Options options = MintEditorial.Options.builder()
+ .headerBandColor(DocumentColor.rgb(24, 24, 24))
+ .nameColor(DocumentColor.WHITE)
+ .ruleColor(DocumentColor.rgb(220, 120, 90))
+ .accentColor(DocumentColor.rgb(139, 207, 190))
+ .build();
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4)
+ .margin(48, 48, 48, 48)
+ .create()) {
+ MintEditorial.create(options).compose(session, fullDocument());
+ assertThat(session.roots()).isNotEmpty();
+ assertThat(session.layoutGraph().totalPages()).isEqualTo(2);
+ }
+ }
+
+ @Test
+ void default_options_equal_no_options() {
+ // The default Options factory must leave the stock surface identity
+ // intact (and, by the parity gate, the stock render).
+ DocumentTemplate withDefaults =
+ MintEditorial.create(MintEditorial.Options.defaults());
+ assertThat(withDefaults.id()).isEqualTo("mint-editorial");
+ assertThat(withDefaults.displayName()).isEqualTo("Mint Editorial");
+ }
+
+ @Test
+ void band_constants_match_default_masthead() throws Exception {
+ // Guard: the banded masthead reuses hand-measured MASTHEAD_* constants
+ // to place the name/tagline/rule at the SAME positions the default
+ // (bandless) flow produces. This test ties MASTHEAD_RULE_Y to the real
+ // default rule y, so any future typography / margin / spacing change
+ // that moves the masthead fails here and signals the constants must be
+ // re-measured.
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4)
+ .margin(48, 48, 48, 48)
+ .create()) {
+ MintEditorial.create().compose(session, fullDocument());
+ LayoutGraph layout = session.layoutGraph();
+ PlacedFragment rule = layout.fragments().stream()
+ .filter(f -> f.pageIndex() == 0)
+ .filter(f -> f.path().contains("CvV2MintEditorialHeaderRule"))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError(
+ "default masthead rule fragment not found"));
+ // PlacedFragment.y is the PDF bottom-left origin (y grows up);
+ // convert to the top-down page-edge coordinate the constant uses.
+ double pageHeight = session.canvas().height();
+ double ruleTop = pageHeight - (rule.y() + rule.height());
+ assertThat(ruleTop)
+ .as("default rule top must equal MASTHEAD_RULE_Y (re-measure "
+ + "the MASTHEAD_* band constants if this drifts)")
+ .isCloseTo(MintEditorial.MASTHEAD_RULE_Y, within(0.5));
+ }
+ }
+
+ @Test
+ void banded_and_bandless_place_first_row_identically() throws Exception {
+ // Complementary guard: the band must not shift the body. The first
+ // page-1 content row must start at the same y with and without a band.
+ double bandless = firstPageOneRowTop(MintEditorial.create());
+ double banded = firstPageOneRowTop(MintEditorial.create(
+ MintEditorial.Options.builder()
+ .headerBandColor(DocumentColor.rgb(228, 217, 198))
+ .build()));
+ assertThat(banded)
+ .as("banded masthead must place the first row at the same y as "
+ + "the bandless masthead")
+ .isCloseTo(bandless, within(0.5));
+ }
+
+ private static double firstPageOneRowTop(DocumentTemplate template)
+ throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4)
+ .margin(48, 48, 48, 48)
+ .create()) {
+ template.compose(session, fullDocument());
+ LayoutGraph layout = session.layoutGraph();
+ double pageHeight = session.canvas().height();
+ return layout.fragments().stream()
+ .filter(f -> f.pageIndex() == 0)
+ .filter(f -> f.path().contains("CvV2MintEditorialPageOne"))
+ .mapToDouble(f -> pageHeight - (f.y() + f.height()))
+ .min()
+ .orElseThrow(() -> new AssertionError(
+ "page-1 row fragments not found"));
+ }
+ }
+
@Test
void renders_with_awards_and_references_grids() throws Exception {
try (DocumentSession session = GraphCompose.document()
diff --git a/src/test/resources/visual-baselines/coverletter-v2-layered/mint-editorial-letter-page-0.png b/src/test/resources/visual-baselines/coverletter-v2-layered/mint-editorial-letter-page-0.png
new file mode 100644
index 00000000..58d297b6
Binary files /dev/null and b/src/test/resources/visual-baselines/coverletter-v2-layered/mint-editorial-letter-page-0.png differ
diff --git a/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-0.png
new file mode 100644
index 00000000..6d16b68d
Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-0.png differ
diff --git a/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-1.png b/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-1.png
new file mode 100644
index 00000000..f747e92b
Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-1.png differ