diff --git a/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvPanelExample.java b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvPanelExample.java
new file mode 100644
index 00000000..688c4ba0
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvPanelExample.java
@@ -0,0 +1,49 @@
+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.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.v2.data.CvDocument;
+import com.demcha.compose.document.templates.cv.v2.presets.Panel;
+import com.demcha.examples.support.ExampleDataFactory;
+import com.demcha.examples.support.ExampleOutputPaths;
+
+import java.nio.file.Path;
+
+/**
+ * Renders the v2 Panel CV preset against the shared grouped skills
+ * sample data — pale-teal header card with centred Poppins masthead,
+ * full-width Profile panel, two-column row pairing Skills + Education
+ * on the left with Experience + Projects on the right, and a closing
+ * Additional panel.
+ *
+ *
Output:
+ * {@code examples/target/generated-pdfs/templates/cv/cv-panel-v2.pdf}.
+ */
+public final class CvPanelExample {
+
+ private CvPanelExample() {
+ }
+
+ public static Path generate() throws Exception {
+ Path outputFile = ExampleOutputPaths.prepare(
+ "templates/cv", "cv-panel-v2.pdf");
+ CvDocument doc = ExampleDataFactory.sampleCvDocumentV2();
+ DocumentTemplate template = Panel.create();
+
+ float m = (float) Panel.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/cv/v2/presets/Panel.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/Panel.java
new file mode 100644
index 00000000..959c57bd
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/Panel.java
@@ -0,0 +1,508 @@
+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.DocumentLinkOptions;
+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.DocumentStroke;
+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.CvTextStyles;
+import com.demcha.compose.document.templates.cv.v2.components.EntryCompactRenderer;
+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.components.SectionLookup;
+import com.demcha.compose.document.templates.cv.v2.components.SkillsRenderer;
+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.CvIdentity;
+import com.demcha.compose.document.templates.cv.v2.data.CvLink;
+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.SkillsSection;
+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.widgets.CardWidget;
+import com.demcha.compose.font.FontName;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+/**
+ * v2 port of the legacy "Panel" CV preset.
+ *
+ * Panel-led CV. The page is composed of four full-width cards of
+ * equal width, all sharing the same shell (rounded corner, thin teal
+ * stroke):
+ *
+ * - Header card — pale-teal fill, centred Poppins
+ * UPPERCASE name, optional job title, centred meta + link
+ * line.
+ * - Profile card — full-width white card with
+ * UPPERCASE teal title, accent strip, and the summary
+ * paragraph.
+ * - Two-column row — left card stacks
+ * Skills + Education, right card stacks
+ * Experience + Projects. Each side is one card with
+ * internal sub-modules separated by a small vertical gap, so the
+ * page reads as four panels of consistent width.
+ * - Additional card — full-width closer with the
+ * same shell as Profile.
+ *
+ *
+ * The preset stays a thin orchestrator. Every visual shell goes
+ * through {@link CardWidget}; the module title + accent strip pair is
+ * preset-local because no other v2 preset places the tick
+ * below the title. Body rendering uses a preset-local
+ * dispatcher (functionally equivalent to
+ * {@code SectionDispatcher.renderBody}) that draws
+ * {@link EntriesSection} headers as a single "title - date" paragraph
+ * via {@link EntryCompactRenderer#titleDateBody} instead of the
+ * standard {@code EntryRenderer}'s 2-column Row — the engine bans
+ * nested horizontal rows, and the side cards sit inside the
+ * {@code flow.addRow}.
+ */
+public final class Panel {
+
+ /** Stable template identifier. */
+ public static final String ID = "panel";
+
+ /** Human-readable display name. */
+ public static final String DISPLAY_NAME = "Panel";
+
+ /** Recommended page margin (in points) — matches V1 ProductLeader. */
+ public static final double RECOMMENDED_MARGIN = 18.0;
+
+ /** V1 ProductLeader deep navy used for the masthead text. */
+ private static final DocumentColor HEADER_TEXT =
+ DocumentColor.rgb(20, 44, 66);
+
+ /** V1 ProductLeader teal accent used for module titles + links. */
+ private static final DocumentColor ACCENT =
+ DocumentColor.rgb(0, 128, 128);
+
+ /** V1 ProductLeader white panel fill. */
+ private static final DocumentColor PANEL_FILL = DocumentColor.WHITE;
+
+ /** Width of the accent strip drawn under each module title. */
+ private static final double ACCENT_STRIP_WIDTH = 54.0;
+
+ /**
+ * Stroke thickness shared by every Panel card (header, profile,
+ * side modules, additional). Keeping a single value here is the
+ * only knob that makes all panels render with visually identical
+ * borders — diverging this between the header and the modules
+ * leaks straight into the visible card outline width.
+ */
+ private static final double PANEL_STROKE_THICKNESS = 0.45;
+
+ private static final List SUMMARY_KEYS =
+ List.of("summary", "professional summary", "profile");
+ private static final List SKILL_KEYS =
+ List.of("technical skills", "skills");
+ private static final List EDUCATION_KEYS =
+ List.of("education", "certifications");
+ private static final List EXPERIENCE_KEYS =
+ List.of("experience", "professional experience", "employment", "work");
+ private static final List PROJECT_KEYS =
+ List.of("projects", "project");
+ private static final List ADDITIONAL_KEYS =
+ List.of("additional information", "additional");
+
+ private Panel() {
+ }
+
+ /**
+ * Builds the preset with its Panel theme.
+ */
+ public static DocumentTemplate create() {
+ return create(CvTheme.panel());
+ }
+
+ /**
+ * Builds the preset with a caller-supplied theme.
+ */
+ 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");
+
+ // Pre-compute card widths so every panel renders at an
+ // identical outer width regardless of content. Sections in
+ // the v2 engine fit content min-width by default, so the
+ // longest line in (say) Skills would otherwise push that
+ // card wider than Education or Additional. The widthAnchor
+ // spacer below pins each card's content area to the
+ // pre-computed target.
+ double innerWidth = document.canvas().innerWidth();
+ double gap = theme.spacing().pageFlowSpacing();
+ double cardPadding = theme.spacing().bannerInnerPadding();
+ double fullCardContentWidth = innerWidth - 2 * cardPadding;
+ double sideCardContentWidth =
+ (innerWidth - gap) / 2 - 2 * cardPadding;
+
+ List sections = doc.sectionsIn(Slot.MAIN);
+ PageFlowBuilder flow = document.dsl()
+ .pageFlow()
+ .name("CvV2PanelRoot")
+ .spacing(gap);
+
+ CvSection summary = SectionLookup.firstMatching(sections, SUMMARY_KEYS);
+ CvSection skills = SectionLookup.firstMatching(sections, SKILL_KEYS);
+ CvSection education = SectionLookup.firstMatching(sections, EDUCATION_KEYS);
+ CvSection experience = SectionLookup.firstMatching(sections, EXPERIENCE_KEYS);
+ CvSection projects = SectionLookup.firstMatching(sections, PROJECT_KEYS);
+ CvSection additional = SectionLookup.firstMatching(sections, ADDITIONAL_KEYS);
+
+ addHeader(flow, doc.identity(), fullCardContentWidth);
+ addFullWidthPanel(flow, "Profile", "Profile", summary,
+ fullCardContentWidth);
+
+ // Left column = three separate cards (Skills, Education,
+ // Additional). Right column = two separate cards (Experience,
+ // Projects). Every card is anchored to sideCardContentWidth
+ // so all panels in each column are visually identical width.
+ boolean leftHasContent = hasContent(skills) || hasContent(education)
+ || hasContent(additional);
+ boolean rightHasContent = hasContent(experience) || hasContent(projects);
+ if (leftHasContent || rightHasContent) {
+ flow.addRow("CvV2PanelStacked", row -> row
+ .spacing(gap)
+ .weights(1.0, 1.0)
+ .addSection("CvV2PanelStackedLeft", left -> {
+ left.spacing(gap);
+ addSidePanel(left, "Skills", "Skills",
+ skills, sideCardContentWidth);
+ addSidePanel(left, "Education", "Education",
+ education, sideCardContentWidth);
+ addSidePanel(left, "Additional", "Additional",
+ additional, sideCardContentWidth);
+ })
+ .addSection("CvV2PanelStackedRight", right -> {
+ right.spacing(gap);
+ addSidePanel(right, "Experience", "Experience",
+ experience, sideCardContentWidth);
+ addSidePanel(right, "Projects", "Projects",
+ projects, sideCardContentWidth);
+ }));
+ }
+
+ flow.build();
+ }
+
+ private void addHeader(PageFlowBuilder flow, CvIdentity identity,
+ double anchorWidth) {
+ CardWidget.render(flow, "CvV2PanelHeader",
+ headerStyle(),
+ card -> {
+ widthAnchor(card, anchorWidth);
+ // Inline the name paragraph instead of going through
+ // Headline.uppercaseCentered — that widget calls
+ // host.padding(theme.spacing.headlinePadding()) which
+ // overrides the card's padding set by CardWidget,
+ // making the header outline visibly wider than the
+ // panel cards below it.
+ card.addParagraph(paragraph -> paragraph
+ .text(identity.name().full()
+ .toUpperCase(Locale.ROOT))
+ .textStyle(nameStyle())
+ .align(TextAlign.CENTER)
+ .margin(DocumentInsets.zero()));
+ if (!identity.jobTitle().isBlank()) {
+ card.addParagraph(paragraph -> paragraph
+ .text(identity.jobTitle())
+ .textStyle(headerBodyStyle())
+ .align(TextAlign.CENTER)
+ .margin(DocumentInsets.zero()));
+ }
+ String contact = joinPipe(identity.contact().address(),
+ identity.contact().phone());
+ if (!contact.isBlank()) {
+ card.addParagraph(paragraph -> paragraph
+ .text(contact)
+ .textStyle(headerMetaStyle())
+ .align(TextAlign.CENTER)
+ .margin(DocumentInsets.zero()));
+ }
+ addLinkRow(card, identity);
+ });
+ }
+
+ private void addLinkRow(SectionBuilder section, CvIdentity identity) {
+ boolean hasEmail = !identity.contact().email().isBlank();
+ boolean hasLinks = !identity.links().isEmpty();
+ if (!hasEmail && !hasLinks) {
+ return;
+ }
+ DocumentTextStyle bodyStyle = headerMetaStyle();
+ DocumentTextStyle linkStyle = headerLinkStyle();
+ section.addParagraph(paragraph -> paragraph
+ .textStyle(bodyStyle)
+ .align(TextAlign.CENTER)
+ .margin(DocumentInsets.zero())
+ .rich(rich -> {
+ boolean first = true;
+ String email = identity.contact().email();
+ if (!email.isBlank()) {
+ rich.with(email, linkStyle,
+ new DocumentLinkOptions("mailto:" + email));
+ first = false;
+ }
+ for (CvLink link : identity.links()) {
+ if (link.label().isBlank()) {
+ continue;
+ }
+ if (!first) {
+ rich.style(" | ", bodyStyle);
+ }
+ first = false;
+ if (link.url().isBlank()) {
+ rich.style(link.label(), bodyStyle);
+ } else {
+ rich.with(link.label(), linkStyle,
+ new DocumentLinkOptions(link.url()));
+ }
+ }
+ }));
+ }
+
+ /**
+ * Renders a full-width top-level card (Profile). Shares the
+ * exact same shell as {@link #addSidePanel} and
+ * {@link #headerStyle} so every panel on the page draws with
+ * the same outline width and corner radius.
+ */
+ private void addFullWidthPanel(PageFlowBuilder flow, String name,
+ String title, CvSection section,
+ double anchorWidth) {
+ if (!hasContent(section)) {
+ return;
+ }
+ CardWidget.render(flow, "CvV2Panel" + name + "Card",
+ panelStyle(),
+ card -> {
+ widthAnchor(card, anchorWidth);
+ renderModuleBody(card, title, section);
+ });
+ }
+
+ /**
+ * Renders one panel card inside a left/right column section.
+ * Each section in a side column gets its own card so the
+ * column reads as a stack of separately-bordered panels of
+ * identical width but varying height.
+ */
+ private void addSidePanel(SectionBuilder column, String name,
+ String title, CvSection section,
+ double anchorWidth) {
+ if (!hasContent(section)) {
+ return;
+ }
+ CardWidget.render(column, "CvV2Panel" + name + "Card",
+ panelStyle(),
+ card -> {
+ widthAnchor(card, anchorWidth);
+ renderModuleBody(card, title, section);
+ });
+ }
+
+ /**
+ * Anchors a card's content min-width to the given target. Sections
+ * in the v2 engine default to fit-content widths inside columns;
+ * adding a zero-height spacer of the exact target width forces the
+ * card's content area to that width so every panel renders at the
+ * same outer width regardless of how long its longest paragraph is.
+ */
+ private void widthAnchor(SectionBuilder card, double width) {
+ card.spacer(width, 0.0);
+ }
+
+ private static boolean hasContent(CvSection section) {
+ return section != null && SectionLookup.hasContent(section);
+ }
+
+ private void renderModuleBody(SectionBuilder card, String title,
+ CvSection section) {
+ card.addParagraph(paragraph -> paragraph
+ .text(title.toUpperCase(Locale.ROOT))
+ .textStyle(moduleTitleStyle())
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.zero()))
+ .addShape(shape -> shape
+ .name("CvV2PanelAccent_"
+ + SectionLookup.normalize(title))
+ .size(ACCENT_STRIP_WIDTH,
+ theme.spacing().accentRuleWidth())
+ .fillColor(ACCENT)
+ .cornerRadius(
+ theme.spacing().accentRuleWidth() / 2.0)
+ .margin(DocumentInsets.zero()));
+ renderCardBody(card, section);
+ }
+
+ /**
+ * Preset-local body dispatcher. Functionally equivalent to
+ * {@link com.demcha.compose.document.templates.cv.v2.components.SectionDispatcher}
+ * except that {@link EntriesSection} entries are drawn through
+ * {@link EntryCompactRenderer#titleDateBody} (single-paragraph
+ * "title - date" header) instead of the standard
+ * {@code EntryRenderer}'s two-column Row header. The engine
+ * bans nested horizontal rows; since every Panel module card
+ * may sit inside the page-level 2-column {@code flow.addRow},
+ * the entry header must stay row-free here.
+ */
+ private void renderCardBody(SectionBuilder card, CvSection section) {
+ if (section instanceof ParagraphSection paragraph) {
+ ParagraphRenderer.render(card, paragraph.body(), theme);
+ } else if (section instanceof SkillsSection skills) {
+ SkillsRenderer.render(card, skills, theme);
+ } else if (section instanceof RowsSection rows) {
+ boolean stackedNeedsSeparator =
+ rows.style() == RowStyle.BULLETED_STACKED;
+ for (int i = 0; i < rows.rows().size(); i++) {
+ if (i > 0 && stackedNeedsSeparator) {
+ card.spacer(0, theme.spacing().entrySeparation());
+ }
+ RowRenderer.render(card, rows.rows().get(i),
+ rows.style(), theme);
+ }
+ } else if (section instanceof EntriesSection entries) {
+ for (int i = 0; i < entries.entries().size(); i++) {
+ if (i > 0) {
+ card.spacer(0, theme.spacing().entrySeparation());
+ }
+ CvEntry entry = entries.entries().get(i);
+ EntryCompactRenderer.titleDateBody(card, entry,
+ theme.entryTitleStyle(),
+ theme.entryDateStyle(),
+ theme.entrySubtitleStyle(),
+ theme.bodyStyle(),
+ " - ",
+ 1.0,
+ DocumentInsets.zero(),
+ DocumentInsets.zero(),
+ DocumentInsets.top(theme.spacing().paragraphMarginTop()),
+ theme.typography().bodyLineSpacing(),
+ false);
+ }
+ }
+ }
+
+ /**
+ * Shared shell for every module card (Profile, Skills,
+ * Education, Additional, Experience, Projects). White fill,
+ * the same stroke/corner as the header, and the
+ * {@code bannerInnerPadding} the theme exposes.
+ */
+ private CardWidget.Style panelStyle() {
+ return CardWidget.Style.builder()
+ .spacing(theme.spacing().sectionBodySpacing())
+ .padding(DocumentInsets.of(
+ theme.spacing().bannerInnerPadding()))
+ .fillColor(PANEL_FILL)
+ .stroke(DocumentStroke.of(theme.palette().rule(),
+ PANEL_STROKE_THICKNESS))
+ .cornerRadius(theme.spacing().bannerCornerRadius())
+ .build();
+ }
+
+ /**
+ * Header card shell. Same outline (stroke, padding, corner) as
+ * {@link #panelStyle()} — only the fill is tinted teal so the
+ * masthead reads as a distinct band while still being visually
+ * the same width as every other panel below it.
+ */
+ private CardWidget.Style headerStyle() {
+ return CardWidget.Style.builder()
+ .spacing(4)
+ .padding(DocumentInsets.of(
+ theme.spacing().bannerInnerPadding()))
+ .fillColor(theme.palette().banner())
+ .stroke(DocumentStroke.of(theme.palette().rule(),
+ PANEL_STROKE_THICKNESS))
+ .cornerRadius(theme.spacing().bannerCornerRadius())
+ .build();
+ }
+
+ private DocumentTextStyle nameStyle() {
+ return CvTextStyles.of(FontName.POPPINS,
+ theme.typography().sizeHeadline(),
+ DocumentTextDecoration.BOLD,
+ HEADER_TEXT);
+ }
+
+ private DocumentTextStyle headerBodyStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeBody(),
+ DocumentTextDecoration.DEFAULT,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle headerMetaStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeContact(),
+ DocumentTextDecoration.DEFAULT,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle headerLinkStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeContact(),
+ DocumentTextDecoration.UNDERLINE,
+ ACCENT);
+ }
+
+ private DocumentTextStyle moduleTitleStyle() {
+ return CvTextStyles.of(FontName.POPPINS,
+ theme.typography().sizeBanner(),
+ DocumentTextDecoration.BOLD,
+ ACCENT);
+ }
+
+ private static String joinPipe(String... parts) {
+ StringBuilder sb = new StringBuilder();
+ for (String part : parts) {
+ if (part == null || part.isBlank()) {
+ continue;
+ }
+ if (sb.length() > 0) {
+ sb.append(" | ");
+ }
+ sb.append(part.trim());
+ }
+ return sb.toString();
+ }
+ }
+}
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 0c9bc4e6..c3e96546 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
@@ -123,6 +123,23 @@ public static CvPalette editorialBlue() {
DocumentColor.rgb(193, 201, 211));
}
+ /**
+ * Panel palette ported from the v1 {@code PanelCvTemplateComposer}
+ * (ProductLeader tokens): body slate ink, slightly lighter slate
+ * for italic subtitles, the pale teal stroke used by every panel
+ * border, and the pale teal header card fill. The deeper header
+ * navy (rgb(20,44,66)), teal accent (rgb(0,128,128)), and white
+ * panel fill are preset-local because they are the fifth/sixth/
+ * seventh tokens — other v2 presets do not share them today.
+ */
+ public static CvPalette panel() {
+ return new CvPalette(
+ DocumentColor.rgb(54, 68, 84), // ink — V1 BODY_TEXT/HEADER_META slate
+ DocumentColor.rgb(105, 117, 132), // muted — slightly lighter slate
+ DocumentColor.rgb(179, 214, 211), // rule — V1 PANEL_STROKE pale teal
+ DocumentColor.rgb(231, 246, 244)); // banner — V1 HEADER_FILL pale teal
+ }
+
/**
* Executive palette ported from the v1 {@code ExecutiveSlateCvTemplate}:
* mid-slate body ink, soft muted slate for italic subtitles, the
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 732e5d27..477a6279 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
@@ -279,6 +279,30 @@ public static CvSpacing editorialBlue() {
3.0); // entrySeparation
}
+ /**
+ * Spacing for the Panel preset: card-led layout that has to fit
+ * Header / Profile / two-column row / Additional on one A4 page,
+ * so paddings and inter-card gaps are tight by design. Corner
+ * radius and accent rule width match the V1 ProductLeader tokens.
+ */
+ public static CvSpacing panel() {
+ return new CvSpacing(
+ 6, // pageFlowSpacing (tight inter-card gap)
+ 3, // sectionBodySpacing (inside a card)
+ DocumentInsets.zero(), // sectionBodyPadding (the card supplies its own padding)
+ DocumentInsets.zero(), // headlinePadding
+ DocumentInsets.zero(), // contactPadding
+ 7.0, // bannerCornerRadius (V1 CORNER_RADIUS)
+ 8.0, // bannerInnerPadding (compact card padding)
+ DocumentInsets.zero(), // bannerMargin
+ 2.2, // accentRuleWidth (V1 ACCENT_HEIGHT)
+ 1.0, // paragraphMarginTop
+ 8.0, // entryHeaderRowSpacing
+ 1.0, // entryTitleWeight
+ 0.45, // entryDateWeight
+ 2.0); // entrySeparation
+ }
+
/**
* Spacing for the Executive preset: generous executive feel with
* an 8pt page-flow rhythm, compact module bodies, and a 1.1pt
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 13f6adf9..9d3e59ab 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
@@ -167,6 +167,21 @@ public static CvTheme editorialBlue() {
CvDecoration.classic());
}
+ /**
+ * The "Panel" look — Poppins headlines + Lato body, pale teal
+ * header card and module panels with thin teal stroke, deep navy
+ * masthead text, and teal section headings with a small accent
+ * strip beneath each title. Visual signature ported from the v1
+ * {@code PanelCvTemplateComposer} (ProductLeader tokens).
+ */
+ public static CvTheme panel() {
+ return new CvTheme(
+ CvPalette.panel(),
+ CvTypography.panel(),
+ CvSpacing.panel(),
+ CvDecoration.classic());
+ }
+
/**
* The "Executive" look — Poppins masthead + Lato body, deep slate
* primary, warm bronze accent on module headings and contact
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 015f7d89..c3c84669 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
@@ -191,6 +191,26 @@ public static CvTypography editorialBlue() {
1.45); // line spacing
}
+ /**
+ * Poppins headline + Lato body scale ported from the v1
+ * {@code PanelCvTemplateComposer} (ProductLeader tokens): a 22pt
+ * uppercase name in the tinted header card, a 10.4pt section-title
+ * slot for the teal module headings, and a 9.4pt body with 1.2
+ * line spacing tuned for the dense card layout.
+ */
+ public static CvTypography panel() {
+ return new CvTypography(
+ FontName.POPPINS, FontName.LATO,
+ 22.0, // headline (centered uppercase name)
+ 8.9, // contact (V1 META_SIZE = body - 0.5)
+ 10.4, // banner / module title (V1 SECTION_SIZE)
+ 9.4, // entry title
+ 9.4, // entry date
+ 9.0, // entry subtitle (italic)
+ 9.4, // body (V1 BODY_SIZE)
+ 1.2); // line spacing
+ }
+
/**
* Poppins headline + Lato body scale ported from the v1
* {@code ExecutiveSlateCvTemplate}: a 24pt uppercase masthead, a
diff --git a/src/main/java/com/demcha/compose/document/templates/widgets/CardWidget.java b/src/main/java/com/demcha/compose/document/templates/widgets/CardWidget.java
index 30c52007..34e4cb5b 100644
--- a/src/main/java/com/demcha/compose/document/templates/widgets/CardWidget.java
+++ b/src/main/java/com/demcha/compose/document/templates/widgets/CardWidget.java
@@ -1,5 +1,6 @@
package com.demcha.compose.document.templates.widgets;
+import com.demcha.compose.document.dsl.PageFlowBuilder;
import com.demcha.compose.document.dsl.SectionBuilder;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentCornerRadius;
@@ -31,21 +32,47 @@ public static void render(SectionBuilder parent,
Style safeStyle = style == null ? Style.builder().build() : style;
parent.addSection(name, card -> {
- card.spacing(safeStyle.spacing())
- .padding(safeStyle.padding());
- if (safeStyle.fillColor() != null) {
- card.fillColor(safeStyle.fillColor());
- }
- if (safeStyle.stroke() != null) {
- card.stroke(safeStyle.stroke());
- }
- if (safeStyle.cornerRadius() != null) {
- card.cornerRadius(safeStyle.cornerRadius());
- }
+ applyStyle(card, safeStyle);
+ content.accept(card);
+ });
+ }
+
+ /**
+ * Top-level overload — renders the card as a page-flow section so
+ * presets can place full-width cards directly under
+ * {@link PageFlowBuilder} without wrapping them in a parent
+ * section. Visual shell behaves identically to the
+ * {@link #render(SectionBuilder, String, Style, Consumer)}
+ * variant.
+ */
+ public static void render(PageFlowBuilder flow,
+ String name,
+ Style style,
+ Consumer content) {
+ Objects.requireNonNull(flow, "flow");
+ Objects.requireNonNull(content, "content");
+ Style safeStyle = style == null ? Style.builder().build() : style;
+
+ flow.addSection(name, card -> {
+ applyStyle(card, safeStyle);
content.accept(card);
});
}
+ private static void applyStyle(SectionBuilder card, Style style) {
+ card.spacing(style.spacing())
+ .padding(style.padding());
+ if (style.fillColor() != null) {
+ card.fillColor(style.fillColor());
+ }
+ if (style.stroke() != null) {
+ card.stroke(style.stroke());
+ }
+ if (style.cornerRadius() != null) {
+ card.cornerRadius(style.cornerRadius());
+ }
+ }
+
/**
* Visual shell options for {@link CardWidget}.
*/
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 6e791f64..a08b259c 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
@@ -114,7 +114,10 @@ private static Stream presets() {
(Supplier>) CompactMono::create),
Arguments.of("executive",
Executive.RECOMMENDED_MARGIN,
- (Supplier>) Executive::create));
+ (Supplier>) Executive::create),
+ Arguments.of("panel",
+ Panel.RECOMMENDED_MARGIN,
+ (Supplier>) Panel::create));
}
/**
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/PanelSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/PanelSmokeTest.java
new file mode 100644
index 00000000..05410d14
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/PanelSmokeTest.java
@@ -0,0 +1,90 @@
+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.data.SkillsSection;
+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 Panel preset. Covers the header card with
+ * optional job title + link row, plus the two-column row composition
+ * fed through {@link com.demcha.compose.document.templates.cv.v2.components.SectionLookup}
+ * and {@link com.demcha.compose.document.templates.cv.v2.components.SectionDispatcher}.
+ */
+class PanelSmokeTest {
+
+ @Test
+ void exposes_stable_identity() {
+ DocumentTemplate template = Panel.create();
+ assertThat(template.id()).isEqualTo("panel");
+ assertThat(template.displayName()).isEqualTo("Panel");
+ }
+
+ @Test
+ void default_factory_renders_full_document() throws Exception {
+ renderAndAssertNonEmpty(Panel.create(), fullDocument());
+ }
+
+ @Test
+ void custom_theme_factory_renders() throws Exception {
+ renderAndAssertNonEmpty(Panel.create(CvTheme.panel()), fullDocument());
+ }
+
+ private static void renderAndAssertNonEmpty(
+ DocumentTemplate template,
+ CvDocument doc) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(420, 595)
+ .margin(DocumentInsets.of(18))
+ .create()) {
+ template.compose(session, doc);
+ assertThat(session.roots()).isNotEmpty();
+ }
+ }
+
+ private static CvDocument fullDocument() {
+ return CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name("Jane", "Doe")
+ .jobTitle("Product Lead")
+ .contact("+44 0", "j@d.com", "London")
+ .link("LinkedIn", "https://linkedin.com/in/jane-doe")
+ .build())
+ .sections(
+ new ParagraphSection("Professional Summary",
+ "Builds **reliable** product platforms."),
+ SkillsSection.builder("Technical Skills")
+ .group("Languages", "Java 21", "Kotlin")
+ .group("Testing", "JUnit 5", "AssertJ")
+ .build(),
+ EntriesSection.builder("Education & Certifications")
+ .entry("MSc Computer Science",
+ "University of Manchester",
+ "2019-2021",
+ "Distinction.")
+ .build(),
+ RowsSection.builder("Projects", RowStyle.BULLETED_STACKED)
+ .row("GraphCompose (Java, PDFBox)",
+ "Declarative PDF layout engine.")
+ .build(),
+ EntriesSection.builder("Professional Experience")
+ .entry("Lead", "Acme", "2021-2024",
+ "Built rendering services.")
+ .build(),
+ RowsSection.builder("Additional Information", RowStyle.PLAIN)
+ .row("Languages", "English, German")
+ .build())
+ .build();
+ }
+}
diff --git a/src/test/resources/visual-baselines/cv-v2-layered/panel-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/panel-page-0.png
new file mode 100644
index 00000000..a2af93a3
Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/panel-page-0.png differ