diff --git a/docs/templates/v2-layered/authoring-presets.md b/docs/templates/v2-layered/authoring-presets.md
index 387c44a0..37662ac0 100644
--- a/docs/templates/v2-layered/authoring-presets.md
+++ b/docs/templates/v2-layered/authoring-presets.md
@@ -2,9 +2,9 @@
You like the layered architecture, but the shipped presets
(`BoxedSections`, `MinimalUnderlined`, `ModernProfessional`,
-`CenteredHeadline`) don't match the design you want. This doc walks
-you through writing a new preset from scratch — **without subclassing,
-without duplicating rendering code**.
+`CenteredHeadline`, `BlueBanner`) don't match the design you want.
+This doc walks you through writing a new preset from scratch —
+**without subclassing, without duplicating rendering code**.
If you haven't read [quickstart.md](quickstart.md) and
[using-templates.md](using-templates.md), do those first.
@@ -91,6 +91,7 @@ small set of named variants.
| Variant | Visual |
|---|---|
| `SectionHeader.banner(host, title, theme)` | Pale-grey panel + centred spaced-caps inside |
+| `SectionHeader.fullWidthBanner(host, title, theme[, style])` | Full-width fill banner + centred spaced-caps inside; surrounding rules stay in preset page flow |
| `SectionHeader.underlined(host, title, theme)` | Small left spaced-caps + thin rule below |
| `SectionHeader.flat(host, title, color, theme)` | Large bold title in a given colour, no panel |
| `SectionHeader.flatSpacedCaps(host, title, color, theme, titleStyle)` | Small left spaced-caps title in a soft colour, no panel |
diff --git a/docs/templates/v2-layered/quickstart.md b/docs/templates/v2-layered/quickstart.md
index 2838b3f4..e6b23c14 100644
--- a/docs/templates/v2-layered/quickstart.md
+++ b/docs/templates/v2-layered/quickstart.md
@@ -19,8 +19,8 @@ GraphCompose's templates v2 (layered) gives you:
you can drop into a preset.
- **Presets as compositions** — a preset orchestrates widgets in a
page flow. `BoxedSections`, `MinimalUnderlined`,
- `ModernProfessional`, `CenteredHeadline` ship today; writing your
- own is ~150 lines.
+ `ModernProfessional`, `CenteredHeadline`, `BlueBanner` ship today;
+ writing your own is ~150 lines.
You hand a `CvDocument` to a preset, you get a PDF. The preset
internally composes widgets that read theme tokens that ultimately
@@ -88,7 +88,8 @@ Same data, different visual. That's the layering.
```
┌─────────────────────────────────────────────────────────────┐
│ presets/ BoxedSections, MinimalUnderlined, │
-│ ModernProfessional, CenteredHeadline │
+│ ModernProfessional, CenteredHeadline, │
+│ BlueBanner │
│ — composition of widgets in a page flow │
└─────────────────────────────────────────────────────────────┘
│ compose from widgets
diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md
index e09eafb9..142344dc 100644
--- a/docs/templates/v2-layered/using-templates.md
+++ b/docs/templates/v2-layered/using-templates.md
@@ -159,9 +159,9 @@ CvDocument doc = CvDocument.builder()
```
**Single-column presets** (`BoxedSections`, `MinimalUnderlined`,
-`ModernProfessional`, `CenteredHeadline`) render only `Slot.MAIN`.
-Sidebar content is silently dropped — switch to a multi-column preset
-to render it.
+`ModernProfessional`, `CenteredHeadline`, `BlueBanner`) render only
+`Slot.MAIN`. Sidebar content is silently dropped — switch to a
+multi-column preset to render it.
If you don't use slots at all, your sections go to `MAIN` and every
preset renders them. The slot model is opt-in.
@@ -171,7 +171,7 @@ preset renders them. The slot model is opt-in.
## Picking a preset
-Four shipped today:
+Five shipped today:
| Preset | Visual signature |
|---|---|
@@ -179,6 +179,7 @@ Four shipped today:
| `MinimalUnderlined.create()` | Centred name with thin rule, small spaced-caps section titles with accent rule, single page |
| `ModernProfessional.create()` | Right-aligned big slate-blue name, flat bright-blue bold section titles, dense single page |
| `CenteredHeadline.create()` | Centred spaced-caps name, small subheadline, full-width rules around contact and modules |
+| `BlueBanner.create()` | Centred PT-Serif name, compact Lato body, blue full-width section banners between thin rules |
Each factory has a no-arg form (uses a sensible default theme) and
a `create(CvTheme)` form (custom theme).
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md b/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md
index 1ee92c73..141632cb 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md
@@ -324,7 +324,7 @@ as DSL plumbing. Below is the current catalog.
| Variant | Visual | Used in |
|---|---|---|
-| `Headline.spacedCentered(host, name, theme)` | centred letter-spaced uppercase (`J A N E D O E`) | BoxedSections, MinimalUnderlined, CenteredHeadline |
+| `Headline.spacedCentered(host, name, theme)` | centred letter-spaced uppercase (`J A N E D O E`) | BoxedSections, MinimalUnderlined, CenteredHeadline, BlueBanner |
| `Headline.rightAligned(host, name, theme)` | right-aligned plain bold (`Jane Doe`) | ModernProfessional |
| `Headline.render(host, name, theme, align, spacedCaps)` | low-level: pick any alignment + transform | — |
@@ -338,7 +338,7 @@ as DSL plumbing. Below is the current catalog.
| Variant | Visual | Used in |
|---|---|---|
-| `ContactLine.centered(host, identity, theme)` | centred, phone → email → address → links | BoxedSections, MinimalUnderlined, CenteredHeadline |
+| `ContactLine.centered(host, identity, theme)` | centred, phone → email → address → links | BoxedSections, MinimalUnderlined, CenteredHeadline, BlueBanner |
| `ContactLine.rightAligned(host, identity, theme)` | right-aligned, address → phone → email → links | ModernProfessional |
| `ContactLine.twoRowRightAligned(host, identity, theme, bodyStyle, linkStyle, separatorStyle)` | right-aligned address/phone row plus email/link row | ModernProfessional |
| `ContactLine.render(host, identity, theme, align, order)` | low-level: pick alignment + field order | — |
@@ -352,6 +352,7 @@ change ` | ` to ` · ` or anything else.
| Variant | Visual | Used in |
|---|---|---|
| `SectionHeader.banner(host, title, theme)` | pale-grey panel with centred spaced-caps inside | BoxedSections |
+| `SectionHeader.fullWidthBanner(host, title, theme[, style])` | full-width fill banner with centred spaced-caps inside; rules around it stay in the preset page flow | BlueBanner |
| `SectionHeader.underlined(host, title, theme)` | small spaced-caps left-aligned, thin rule below | MinimalUnderlined |
| `SectionHeader.flat(host, title, color, theme)` | large bold title in a given colour, no panel | ModernProfessional |
| `SectionHeader.flatSpacedCaps(host, title, color, theme, titleStyle)` | small spaced-caps title in a soft colour, no panel | CenteredHeadline |
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java
index 45d7007b..904d3779 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java
@@ -22,6 +22,7 @@
* │ MinimalUnderlined ← another composition, same pieces │
* │ ModernProfessional ← corporate composition variant │
* │ CenteredHeadline ← classic centred headline variant │
+ * │ BlueBanner ← full-width banner composition │
* └─────────────────────────────────────────────────────────────┘
* │ compose from
* ▼
@@ -31,8 +32,8 @@
* │ Subheadline .centeredSpacedCaps │
* │ ContactLine .centered | .rightAligned │
* │ .twoRowRightAligned │
- * │ SectionHeader .banner | .underlined | .flat │
- * │ .flatSpacedCaps │
+ * │ SectionHeader .banner | .fullWidthBanner | .underlined │
+ * │ .flat | .flatSpacedCaps │
* └─────────────────────────────────────────────────────────────┘
* │ delegate to │ read tokens from
* ▼ ▼
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBanner.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBanner.java
new file mode 100644
index 00000000..8672607f
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBanner.java
@@ -0,0 +1,358 @@
+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.SectionBuilder;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.v2.components.MarkdownInline;
+import com.demcha.compose.document.templates.cv.v2.components.ParagraphRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.RowRenderer;
+import com.demcha.compose.document.templates.cv.v2.data.CvDocument;
+import com.demcha.compose.document.templates.cv.v2.data.CvEntry;
+import com.demcha.compose.document.templates.cv.v2.data.CvRow;
+import com.demcha.compose.document.templates.cv.v2.data.CvSection;
+import com.demcha.compose.document.templates.cv.v2.data.EntriesSection;
+import com.demcha.compose.document.templates.cv.v2.data.ParagraphSection;
+import com.demcha.compose.document.templates.cv.v2.data.RowStyle;
+import com.demcha.compose.document.templates.cv.v2.data.RowsSection;
+import com.demcha.compose.document.templates.cv.v2.data.Slot;
+import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
+import com.demcha.compose.document.templates.cv.v2.widgets.ContactLine;
+import com.demcha.compose.document.templates.cv.v2.widgets.Headline;
+import com.demcha.compose.document.templates.cv.v2.widgets.SectionHeader;
+import com.demcha.compose.font.FontName;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+/**
+ * v2 port of the legacy "Blue Banner" CV preset.
+ *
+ *
Visual signature: centred PT-Serif spaced-caps name, compact
+ * Lato contact row, full-width blue section banners sandwiched
+ * between thin dark-blue rules, and dense white body blocks.
+ *
+ * Most of the preset is ordinary widget composition. The body
+ * renderer is deliberately preset-local because Blue Banner differs
+ * from the shared {@code EntryRenderer}: entry titles are uppercase,
+ * dates are bold, subtitles are regular ink, and project rows render
+ * without bullets.
+ */
+public final class BlueBanner {
+
+ /** Stable template identifier. */
+ public static final String ID = "blue-banner";
+
+ /** Human-readable display name. */
+ public static final String DISPLAY_NAME = "Blue Banner";
+
+ /** Recommended page margin (in points). */
+ public static final double RECOMMENDED_MARGIN = 28.0;
+
+ private static final double BANNER_RULE_WIDTH = 500.0;
+ private static final double BANNER_RULE_HORIZONTAL_INSET = 18.0;
+
+ private static final DocumentColor BANNER_TEXT =
+ DocumentColor.rgb(22, 32, 48);
+
+ private static final List SUMMARY_KEYS =
+ List.of("summary", "professional summary", "profile");
+ private static final List EXPERIENCE_KEYS =
+ List.of("experience", "professional experience", "employment", "work");
+ private static final List EDUCATION_KEYS =
+ List.of("education", "certifications");
+ private static final List SKILL_KEYS =
+ List.of("technical skills", "skills");
+ private static final List ADDITIONAL_KEYS =
+ List.of("additional information", "additional");
+
+ private BlueBanner() {
+ }
+
+ /**
+ * Builds the preset with the Blue Banner theme.
+ */
+ public static DocumentTemplate create() {
+ return create(CvTheme.blueBanner());
+ }
+
+ /**
+ * Builds the preset with a caller-supplied theme. The caller can
+ * adjust palette, typography, spacing, or separator tokens without
+ * changing the page-flow composition.
+ */
+ public static DocumentTemplate create(CvTheme theme) {
+ Objects.requireNonNull(theme, "theme");
+ return new Template(theme);
+ }
+
+ private static final class Template implements DocumentTemplate {
+
+ private final CvTheme theme;
+
+ Template(CvTheme theme) {
+ this.theme = theme;
+ }
+
+ @Override
+ public String id() {
+ return ID;
+ }
+
+ @Override
+ public String displayName() {
+ return DISPLAY_NAME;
+ }
+
+ @Override
+ public void compose(DocumentSession document, CvDocument doc) {
+ Objects.requireNonNull(document, "document");
+ Objects.requireNonNull(doc, "doc");
+
+ DocumentTextStyle bannerTitleStyle = DocumentTextStyle.builder()
+ .fontName(theme.typography().bodyFont())
+ .size(theme.typography().sizeBanner())
+ .decoration(DocumentTextDecoration.BOLD)
+ .color(BANNER_TEXT)
+ .build();
+
+ PageFlowBuilder pageFlow = document.dsl()
+ .pageFlow()
+ .name("CvV2BlueBannerRoot")
+ .spacing(theme.spacing().pageFlowSpacing())
+ .addSection("Header", section ->
+ Headline.spacedCentered(section,
+ doc.identity().name(), theme))
+ .addSection("Contact", section ->
+ ContactLine.centered(section, doc.identity(), theme));
+
+ List sections = orderedSections(doc);
+ for (int i = 0; i < sections.size(); i++) {
+ final CvSection sec = sections.get(i);
+ final int idx = i;
+ addBannerRule(pageFlow, "BlueBannerRuleTop_" + idx, theme, 3, 1);
+ pageFlow.addSection("BlueBannerTitle_" + idx, host ->
+ SectionHeader.fullWidthBanner(host, sec.title(),
+ theme, bannerTitleStyle));
+ addBannerRule(pageFlow, "BlueBannerRuleBottom_" + idx, theme, 1, 1);
+ pageFlow.addSection("BlueBannerBody_" + idx, host ->
+ renderBody(host, sec, theme));
+ }
+
+ pageFlow.build();
+ }
+
+ private static void addBannerRule(PageFlowBuilder pageFlow, String name,
+ CvTheme theme,
+ double topMargin,
+ double bottomMargin) {
+ pageFlow.addLine(line -> line
+ .name(name)
+ .horizontal(BANNER_RULE_WIDTH)
+ .color(theme.palette().rule())
+ .thickness(theme.spacing().accentRuleWidth())
+ .margin(new DocumentInsets(
+ topMargin,
+ BANNER_RULE_HORIZONTAL_INSET,
+ bottomMargin,
+ BANNER_RULE_HORIZONTAL_INSET)));
+ }
+ }
+
+ private static void renderBody(SectionBuilder host,
+ CvSection section,
+ CvTheme theme) {
+ host.spacing(theme.spacing().sectionBodySpacing())
+ .padding(theme.spacing().sectionBodyPadding());
+
+ if (section instanceof ParagraphSection p) {
+ ParagraphRenderer.render(host, p.body(), theme);
+ } else if (section instanceof RowsSection r) {
+ renderRows(host, r, theme);
+ } else if (section instanceof EntriesSection e) {
+ for (CvEntry entry : e.entries()) {
+ renderEntry(host, entry, theme);
+ }
+ } else {
+ throw new IllegalStateException(
+ "Unknown CvSection subtype: " + section.getClass().getName());
+ }
+ }
+
+ private static void renderRows(SectionBuilder host,
+ RowsSection section,
+ CvTheme theme) {
+ if (section.style() == RowStyle.BULLETED_STACKED) {
+ for (CvRow row : section.rows()) {
+ renderPlainProjectRow(host, row, theme);
+ }
+ return;
+ }
+ for (CvRow row : section.rows()) {
+ RowRenderer.render(host, row, section.style(), theme);
+ }
+ }
+
+ private static void renderPlainProjectRow(SectionBuilder host,
+ CvRow row,
+ CvTheme theme) {
+ String label = row.label().trim();
+ String body = row.body().trim();
+ DocumentTextStyle labelStyle = theme.entryTitleStyle();
+ DocumentTextStyle bodyStyle = theme.bodyStyle();
+
+ host.addParagraph(p -> p
+ .textStyle(bodyStyle)
+ .lineSpacing(theme.typography().bodyLineSpacing())
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.top((float) theme.spacing().paragraphMarginTop()))
+ .rich(rich -> {
+ rich.style(stripBasicMarkdown(label), labelStyle);
+ if (!body.isBlank()) {
+ rich.style(" - ", bodyStyle);
+ MarkdownInline.append(rich, body, bodyStyle);
+ }
+ }));
+ }
+
+ private static void renderEntry(SectionBuilder section,
+ CvEntry entry,
+ CvTheme theme) {
+ DocumentTextStyle titleStyle = theme.entryTitleStyle();
+ DocumentTextStyle dateStyle = style(theme.typography().bodyFont(),
+ theme.typography().sizeEntryDate(),
+ DocumentTextDecoration.BOLD,
+ theme.palette().ink());
+ DocumentTextStyle subtitleStyle = style(theme.typography().bodyFont(),
+ theme.typography().sizeEntrySubtitle(),
+ DocumentTextDecoration.DEFAULT,
+ theme.palette().ink());
+ DocumentTextStyle bodyStyle = theme.bodyStyle();
+
+ section.addRow("BlueBannerEntryHeader", row -> row
+ .spacing(theme.spacing().entryHeaderRowSpacing())
+ .weights(theme.spacing().entryTitleWeight(),
+ theme.spacing().entryDateWeight())
+ .addSection("Title", titleColumn -> titleColumn
+ .padding(DocumentInsets.zero())
+ .addParagraph(p -> p
+ .text(stripBasicMarkdown(entry.title())
+ .toUpperCase(Locale.ROOT))
+ .textStyle(titleStyle)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.zero())))
+ .addSection("Date", dateColumn -> dateColumn
+ .padding(DocumentInsets.zero())
+ .addParagraph(p -> p
+ .text(stripBasicMarkdown(entry.date()))
+ .textStyle(dateStyle)
+ .align(TextAlign.RIGHT)
+ .margin(DocumentInsets.zero()))));
+
+ if (!entry.subtitle().isBlank()) {
+ section.addParagraph(p -> p
+ .text(stripBasicMarkdown(entry.subtitle()))
+ .textStyle(subtitleStyle)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.zero()));
+ }
+
+ if (!entry.body().isBlank()) {
+ renderBodyParagraph(section, entry.body(), bodyStyle,
+ theme.typography().bodyLineSpacing(),
+ DocumentInsets.top((float) theme.spacing().paragraphMarginTop()));
+ }
+ }
+
+ private static void renderBodyParagraph(SectionBuilder host,
+ String text,
+ DocumentTextStyle style,
+ double lineSpacing,
+ DocumentInsets margin) {
+ if (text == null || text.isBlank()) {
+ return;
+ }
+ host.addParagraph(p -> p
+ .textStyle(style)
+ .lineSpacing(lineSpacing)
+ .align(TextAlign.LEFT)
+ .margin(margin)
+ .rich(rich -> MarkdownInline.append(rich, text.trim(), style)));
+ }
+
+ private static DocumentTextStyle style(FontName font,
+ double size,
+ DocumentTextDecoration decoration,
+ DocumentColor color) {
+ return DocumentTextStyle.builder()
+ .fontName(font)
+ .size(size)
+ .decoration(decoration)
+ .color(color)
+ .build();
+ }
+
+ private static List orderedSections(CvDocument doc) {
+ List sections = doc.sectionsIn(Slot.MAIN);
+ List ordered = new ArrayList<>();
+ addIfPresent(ordered, findSection(sections, SUMMARY_KEYS));
+ addIfPresent(ordered, findSection(sections, EXPERIENCE_KEYS));
+ addIfPresent(ordered, findSection(sections, EDUCATION_KEYS));
+ addIfPresent(ordered, findSection(sections, SKILL_KEYS));
+ addIfPresent(ordered, findSection(sections, ADDITIONAL_KEYS));
+ for (CvSection section : sections) {
+ addIfPresent(ordered, section);
+ }
+ return List.copyOf(ordered);
+ }
+
+ private static void addIfPresent(List sections, CvSection section) {
+ if (section != null && !sections.contains(section)) {
+ sections.add(section);
+ }
+ }
+
+ private static CvSection findSection(List sections,
+ List keys) {
+ for (CvSection section : sections) {
+ String normalizedTitle = normalize(section.title());
+ for (String key : keys) {
+ if (normalizedTitle.contains(normalize(key))) {
+ return section;
+ }
+ }
+ }
+ return null;
+ }
+
+ private static String stripBasicMarkdown(String value) {
+ if (value == null) {
+ return "";
+ }
+ return value
+ .replace("**", "")
+ .replace("__", "")
+ .replace("`", "")
+ .replace("*", "")
+ .replace("_", "");
+ }
+
+ private static String normalize(String value) {
+ String safe = value == null ? "" : value;
+ StringBuilder builder = new StringBuilder(safe.length());
+ for (int i = 0; i < safe.length(); i++) {
+ char current = Character.toLowerCase(safe.charAt(i));
+ if (Character.isLetterOrDigit(current)) {
+ builder.append(current);
+ }
+ }
+ return builder.toString();
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvDecoration.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvDecoration.java
index 9a4c7aea..da00c6e2 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvDecoration.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvDecoration.java
@@ -47,4 +47,12 @@ public record CvDecoration(String bulletGlyph,
public static CvDecoration classic() {
return new CvDecoration("• ", " ", " | ");
}
+
+ /**
+ * Blue Banner keeps classic bullets but uses the tighter contact
+ * separator spacing from the legacy preset.
+ */
+ public static CvDecoration blueBanner() {
+ return new CvDecoration("• ", " ", " | ");
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvPalette.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvPalette.java
index ca8103af..c3285b04 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvPalette.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvPalette.java
@@ -56,4 +56,16 @@ public static CvPalette centeredHeadline() {
DocumentColor.rgb(188, 188, 188), // rule (#BCBCBC)
DocumentColor.rgb(220, 226, 230)); // banner (inherits classic)
}
+
+ /**
+ * Blue Banner palette: compact dark ink, blue section fills, and
+ * darker blue separator rules.
+ */
+ public static CvPalette blueBanner() {
+ return new CvPalette(
+ DocumentColor.rgb(20, 25, 35),
+ DocumentColor.rgb(85, 85, 85),
+ DocumentColor.rgb(58, 82, 118),
+ DocumentColor.rgb(112, 146, 190));
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvSpacing.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvSpacing.java
index c900d5bb..356492f3 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvSpacing.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvSpacing.java
@@ -167,4 +167,26 @@ public static CvSpacing modernProfessional() {
0.45, // entryDateWeight
2.5); // entrySeparation
}
+
+ /**
+ * Compact spacing for Blue Banner: tight body blocks, full-width
+ * title banners, and no extra artificial gap between entries.
+ */
+ public static CvSpacing blueBanner() {
+ return new CvSpacing(
+ 4, // pageFlowSpacing
+ 3, // sectionBodySpacing
+ new DocumentInsets(3, 4, 0, 4), // sectionBodyPadding
+ new DocumentInsets(8, 0, 8, 0), // headlinePadding
+ new DocumentInsets(1.5, 0, 1.5, 0), // contactPadding
+ 0.0, // bannerCornerRadius
+ 3.2, // bannerInnerPadding
+ DocumentInsets.zero(), // bannerMargin
+ 0.55, // accentRuleWidth
+ 1.2, // paragraphMarginTop
+ 8.0, // entryHeaderRowSpacing
+ 1.0, // entryTitleWeight
+ 0.4, // entryDateWeight
+ 0.0); // entrySeparation
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTheme.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTheme.java
index 4652c83e..a9828463 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTheme.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTheme.java
@@ -102,6 +102,19 @@ public static CvTheme centeredHeadline() {
CvDecoration.classic());
}
+ /**
+ * The "Blue Banner" look — PT Serif display name, Lato body,
+ * compact spacing, blue full-width section banners, and tighter
+ * pipe separators.
+ */
+ public static CvTheme blueBanner() {
+ return new CvTheme(
+ CvPalette.blueBanner(),
+ CvTypography.blueBanner(),
+ CvSpacing.blueBanner(),
+ CvDecoration.blueBanner());
+ }
+
// -- pre-built text-style helpers ------------------------------------
// Renderers ask the theme for an already-composed DocumentTextStyle
// instead of re-assembling font + size + decoration + colour every
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTypography.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTypography.java
index 7de1427b..376744db 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTypography.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTypography.java
@@ -102,4 +102,21 @@ public static CvTypography centeredHeadline() {
8.7, // body
1.45); // line spacing
}
+
+ /**
+ * Compact PT-Serif headline + Lato body scale used by the Blue
+ * Banner preset.
+ */
+ public static CvTypography blueBanner() {
+ return new CvTypography(
+ FontName.PT_SERIF, FontName.LATO,
+ 20.0, // headline
+ 7.5, // contact
+ 7.3, // banner
+ 8.0, // entry title
+ 7.7, // entry date
+ 7.45, // entry subtitle
+ 7.7, // body
+ 1.3); // line spacing
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionHeader.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionHeader.java
index f5239886..a8159447 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionHeader.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionHeader.java
@@ -17,6 +17,10 @@
*
* - {@link #banner} — pale-grey panel with centred spaced-caps
* title inside. Visual signature of {@code BoxedSections}.
+ * - {@link #fullWidthBanner} — full-width fill banner with
+ * centred spaced-caps title. Used by compact editorial presets
+ * such as {@code BlueBanner}; surrounding rules stay in the
+ * preset's page-flow composition.
* - {@link #underlined} — small left-aligned spaced-caps title
* with a thin accent rule beneath. Visual signature of
* {@code MinimalUnderlined}.
@@ -62,6 +66,41 @@ public static void banner(SectionBuilder host, String title, CvTheme theme) {
.margin(DocumentInsets.zero()));
}
+ /**
+ * Full-width filled banner with centred spaced-caps title. This
+ * variant is for presets where the section title is a solid band
+ * in the main page flow (for example {@code BlueBanner}), not a
+ * soft inset panel. Any rules above or below the band are
+ * page-flow ornaments and should be composed by the preset.
+ */
+ public static void fullWidthBanner(SectionBuilder host, String title, CvTheme theme) {
+ fullWidthBanner(host, title, theme, null);
+ }
+
+ /**
+ * Full-width filled banner with an explicit title style override.
+ *
+ * @param titleStyleOverride text style for the banner label; pass
+ * {@code null} to use
+ * {@link CvTheme#bannerStyle()}
+ */
+ public static void fullWidthBanner(SectionBuilder host, String title,
+ CvTheme theme,
+ DocumentTextStyle titleStyleOverride) {
+ DocumentTextStyle titleStyle = titleStyleOverride != null
+ ? titleStyleOverride
+ : theme.bannerStyle();
+ host.fillColor(theme.palette().banner())
+ .padding(new DocumentInsets(theme.spacing().bannerInnerPadding(),
+ 0, theme.spacing().bannerInnerPadding(), 0))
+ .margin(theme.spacing().bannerMargin())
+ .addParagraph(p -> p
+ .text(TextOrnaments.spacedUpper(title))
+ .textStyle(titleStyle)
+ .align(TextAlign.CENTER)
+ .margin(DocumentInsets.zero()));
+ }
+
/**
* Small left-aligned spaced-caps title with a thin accent rule
* beneath. Visual signature of {@code MinimalUnderlined}.
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBannerSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBannerSmokeTest.java
new file mode 100644
index 00000000..4f1d6c52
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBannerSmokeTest.java
@@ -0,0 +1,76 @@
+package com.demcha.compose.document.templates.cv.v2.presets;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.style.DocumentInsets;
+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;
+import com.demcha.compose.document.templates.cv.v2.data.EntriesSection;
+import com.demcha.compose.document.templates.cv.v2.data.ParagraphSection;
+import com.demcha.compose.document.templates.cv.v2.data.RowStyle;
+import com.demcha.compose.document.templates.cv.v2.data.RowsSection;
+import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Smoke test for the v2 BlueBanner preset. Covers the preset-local
+ * body dispatcher, especially bulletless project rows and custom
+ * uppercase timeline entries.
+ */
+class BlueBannerSmokeTest {
+
+ @Test
+ void exposes_stable_identity() {
+ DocumentTemplate template = BlueBanner.create();
+ assertThat(template.id()).isEqualTo("blue-banner");
+ assertThat(template.displayName()).isEqualTo("Blue Banner");
+ }
+
+ @Test
+ void default_factory_renders_full_document() throws Exception {
+ DocumentTemplate template = BlueBanner.create();
+ renderAndAssertNonEmpty(template, fullDocument());
+ }
+
+ @Test
+ void custom_theme_factory_renders() throws Exception {
+ DocumentTemplate template =
+ BlueBanner.create(CvTheme.blueBanner());
+ renderAndAssertNonEmpty(template, fullDocument());
+ }
+
+ private static void renderAndAssertNonEmpty(
+ DocumentTemplate template, CvDocument doc) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(420, 595)
+ .margin(DocumentInsets.of(24))
+ .create()) {
+ template.compose(session, doc);
+ assertThat(session.roots()).isNotEmpty();
+ }
+ }
+
+ private static CvDocument fullDocument() {
+ return CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name("Jane", "Doe")
+ .contact("+44 0", "j@d.com", "London")
+ .link("GitHub", "https://github.com/jane-doe")
+ .build())
+ .sections(
+ new ParagraphSection("Professional Summary", "body"),
+ RowsSection.builder("Technical Skills", RowStyle.BULLETED)
+ .row("Languages", "Java").build(),
+ EntriesSection.builder("Professional Experience")
+ .entry("Engineer", "Acme", "2020-2024", "did stuff")
+ .build(),
+ RowsSection.builder("Projects", RowStyle.BULLETED_STACKED)
+ .row("X", "desc").build(),
+ RowsSection.builder("Additional Information", RowStyle.PLAIN)
+ .row("Languages", "English").build())
+ .build();
+ }
+}
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 b4fe7583..a7dd6eb6 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
@@ -95,7 +95,10 @@ private static Stream presets() {
(Supplier>) ModernProfessional::create),
Arguments.of("centered_headline",
CenteredHeadline.RECOMMENDED_MARGIN,
- (Supplier>) CenteredHeadline::create));
+ (Supplier>) CenteredHeadline::create),
+ Arguments.of("blue_banner",
+ BlueBanner.RECOMMENDED_MARGIN,
+ (Supplier>) BlueBanner::create));
}
/**
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java
index b1e1f9f3..d8839e41 100644
--- a/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java
@@ -61,6 +61,9 @@ void sectionHeader_variants_render_without_throwing() throws Exception {
CvTheme theme = CvTheme.boxedClassic();
renderWithSection(section ->
SectionHeader.banner(section, "Professional Summary", theme));
+ renderWithSection(section ->
+ SectionHeader.fullWidthBanner(section, "Professional Summary",
+ CvTheme.blueBanner()));
renderWithSection(section ->
SectionHeader.underlined(section, "Skills", theme));
renderWithSection(section ->
diff --git a/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-0.png
new file mode 100644
index 00000000..13f82984
Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-0.png differ
diff --git a/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-1.png b/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-1.png
new file mode 100644
index 00000000..be4ceda8
Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-1.png differ