From d5c09d41f2d0eeb747011ac04321d8a0a4292bff Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sun, 24 May 2026 14:34:17 +0200 Subject: [PATCH] =?UTF-8?q?v2(Phase=203):=20extract=20widgets/=20layer=20?= =?UTF-8?q?=E2=80=94=20LEGO=20bricks=20for=20preset=20authors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "concept" gap surfaced in conversation: until now, an author who wanted a preset with section titles different from BoxedSections' banner had to read another preset's private rendering methods and copy-paste. With widgets, the author picks a named visual building block and inserts it. What's new ---------- - cv/v2/widgets/Headline.java .spacedCentered — BoxedSections/MinimalUnderlined style .rightAligned — ModernProfessional style .render(...) — low-level: any (alignment, spacedCaps) combo - cv/v2/widgets/ContactLine.java .centered — BoxedSections/MinimalUnderlined style .rightAligned — ModernProfessional style .render(...) — low-level: alignment + field Order - cv/v2/widgets/SectionHeader.java .banner — pale-grey panel, centred spaced-caps .underlined — left spaced-caps + accent rule below .flat(color) — large bold colour, no panel - cv/v2/widgets/package-info.java — philosophy doc Each widget has 2–3 named factory methods plus an escape-hatch .render() with parameters. No spec record / builder for now — keep the API tight; expand when patterns repeat. Refactor -------- - BoxedSections.compose() — Headline.spacedCentered + ContactLine.centered + SectionHeader.banner - MinimalUnderlined.compose() — Headline.spacedCentered + ContactLine.centered + SectionHeader.underlined (private renderUnderlinedTitle deleted) - ModernProfessional.compose() — SectionHeader.flat(SECTION_TITLE_COLOR) (private renderHeader / renderContact kept inline — preset-specific colours don't fit widget defaults; documented as the "widget doesn't fit → inline" pattern in AUTHORS.md) Each preset's compose() shrank from ~30 lines of DSL plumbing to ~12 lines of widget calls. Reading a preset now reads as a sequence of visual decisions, not as rendering code. Backward compatibility ---------------------- - components/HeadlineRenderer → @Deprecated, delegates to Headline.spacedCentered - components/ContactRenderer → @Deprecated, delegates to ContactLine.centered - components/BannerRenderer → @Deprecated, delegates to SectionHeader.banner Old v2 callers keep compiling unchanged. @Deprecated marks the forward path so new code reaches for widgets directly. Docs ---- - AUTHORS.md gains a full "Widget cookbook" section: catalog table per widget, composing-a-preset example (12-line full compose()), when-to-add-a-widget guidance, "doesn't fit → inline" recipe, forward-looking examples (Badge, IconLabel, Divider — not yet implemented, included as illustration of where the catalog can grow). - package-info.java ASCII diagram updated to show widgets/ layer between presets/ and components/. Tests ----- - New WidgetSmokeTest (4 cases) covers every public widget variant against the default theme + the modernProfessional theme. - All 3 v2 preset PDFs render pixel-identical to baseline — proof that the widget extraction is purely a refactor. - Full mvn test: 889/889 pass. Engine and v1 surface untouched. --- .../document/templates/cv/v2/AUTHORS.md | 120 +++++++++++++++++ .../cv/v2/components/BannerRenderer.java | 31 ++--- .../cv/v2/components/ContactRenderer.java | 71 ++-------- .../cv/v2/components/HeadlineRenderer.java | 25 ++-- .../templates/cv/v2/package-info.java | 35 +++-- .../cv/v2/presets/BoxedSections.java | 12 +- .../cv/v2/presets/MinimalUnderlined.java | 34 +---- .../cv/v2/presets/ModernProfessional.java | 22 +--- .../templates/cv/v2/widgets/ContactLine.java | 124 ++++++++++++++++++ .../templates/cv/v2/widgets/Headline.java | 87 ++++++++++++ .../cv/v2/widgets/SectionHeader.java | 99 ++++++++++++++ .../templates/cv/v2/widgets/package-info.java | 59 +++++++++ .../cv/v2/widgets/WidgetSmokeTest.java | 101 ++++++++++++++ 13 files changed, 669 insertions(+), 151 deletions(-) create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/ContactLine.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Headline.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionHeader.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/package-info.java create mode 100644 src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java 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 6cba35f8..979151bc 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 @@ -310,6 +310,126 @@ sidebar content to flow inline with main. --- +## Widget cookbook — the LEGO bricks + +When you build a preset, you compose your `compose()` method from +**widgets** that live in +`com.demcha.compose.document.templates.cv.v2.widgets`. Each widget +captures one visual idea, with named variants per visual style. + +This means your preset reads as a sequence of visual decisions, not +as DSL plumbing. Below is the current catalog. + +### `Headline` — top-of-document name + +| Variant | Visual | Used in | +|---|---|---| +| `Headline.spacedCentered(host, name, theme)` | centred letter-spaced uppercase (`J A N E D O E`) | BoxedSections, MinimalUnderlined | +| `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 | — | + +### `ContactLine` — phone / email / address / links row + +| Variant | Visual | Used in | +|---|---|---| +| `ContactLine.centered(host, identity, theme)` | centred, phone → email → address → links | BoxedSections, MinimalUnderlined | +| `ContactLine.rightAligned(host, identity, theme)` | right-aligned, address → phone → email → links | ModernProfessional | +| `ContactLine.render(host, identity, theme, align, order)` | low-level: pick alignment + field order | — | + +The separator glyph comes from +`theme.decoration().contactSeparator()` — swap `CvDecoration` to +change ` | ` to ` · ` or anything else. + +### `SectionHeader` — title above a section body + +| Variant | Visual | Used in | +|---|---|---| +| `SectionHeader.banner(host, title, theme)` | pale-grey panel with centred spaced-caps inside | BoxedSections | +| `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 | + +Note that `flat` takes a `DocumentColor` argument — the section +title colour is the preset's signature accent, and the widget +deliberately surfaces it as a parameter rather than burying it in +the theme. + +### Composing a preset from widgets + +A typical preset's `compose()` becomes a sequence of widget calls: + +```java +@Override +public void compose(DocumentSession document, CvDocument doc) { + PageFlowBuilder pageFlow = document.dsl().pageFlow() + .name("MyPresetRoot") + .spacing(theme.spacing().pageFlowSpacing()) + .addSection("Headline", s -> + Headline.spacedCentered(s, doc.identity().name(), theme)) + .addSection("Contact", s -> + ContactLine.centered(s, doc.identity(), theme)); + + for (CvSection sec : doc.sectionsIn(Slot.MAIN)) { + pageFlow + .addSection("Title", s -> SectionHeader.banner(s, sec.title(), theme)) + .addSection("Body", s -> SectionDispatcher.renderBody(s, sec, theme)); + } + pageFlow.build(); +} +``` + +That's **the whole preset.** Twelve lines. No paragraph DSL, no +custom rendering helpers. + +### When the widget doesn't fit + +Widgets are optional, not mandatory. When a preset needs something +no widget covers (e.g. unusual link colours, special separator, +custom alignment combo), **inline it**: + +```java +.addSection("Contact", section -> { + DocumentTextStyle myStyle = DocumentTextStyle.builder() + .fontName(FontName.HELVETICA) + .color(DocumentColor.rgb(65, 105, 225)) + .build(); + + section.addParagraph(p -> p + .text(doc.identity().contact().email()) + .textStyle(myStyle) + .align(TextAlign.RIGHT)); +}) +``` + +Inline is fine for one-off needs. If the same inline pattern shows +up in **2+ presets**, that's the signal to extract a new widget +variant or add a parameter to an existing one. Don't pre-extract. + +### Adding a new widget — the test of when + +| Pattern repetition | Action | +|---|---| +| 1 preset only | Inline. Leave it alone. | +| 2 presets | Add a new factory method to an existing widget, OR add a parameter. | +| 3+ presets | It's its own widget. New class in `cv/v2/widgets/`. | + +### Examples of widgets we could add (not done yet) + +These don't exist today but illustrate where the catalog could +grow. **Don't write them until a real preset needs them** — +premature widgets are noise. + +- `Badge.pill(host, label, fillColor, textColor)` — labelled pill + for tags, awards, certifications. +- `IconLabel.render(host, icon, text, theme)` — small icon next + to a text label, useful for contact rows that show icons. +- `Divider.thin(host, theme)` / `.thick(...)` — horizontal rule + with configurable weight + colour. +- `TwoColumnRow.render(host, leftBuilder, rightBuilder, weights, theme)` — + generic split with weights, useful for entry headers across + presets. + +--- + ## Recipe 7 — conditional sections (data-driven) Real CVs often hide a section when there's nothing to put in it: diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/BannerRenderer.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/BannerRenderer.java index 5c463269..6371463f 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/BannerRenderer.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/BannerRenderer.java @@ -1,32 +1,29 @@ package com.demcha.compose.document.templates.cv.v2.components; import com.demcha.compose.document.dsl.SectionBuilder; -import com.demcha.compose.document.node.TextAlign; -import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; +import com.demcha.compose.document.templates.cv.v2.widgets.SectionHeader; /** - * Draws the pale-grey banner row holding a centred, letter-spaced - * uppercase section title (e.g. {@code P R O J E C T S}). - * - *

The host {@link SectionBuilder} becomes the banner panel — - * theme tokens drive the fill colour, corner radius, inner padding, - * and surrounding margin.

+ * @deprecated Use + * {@link com.demcha.compose.document.templates.cv.v2.widgets.SectionHeader#banner} + * instead — the widget groups the banner alongside its sibling + * variants ({@code underlined}, {@code flat}) so picking a section- + * title style becomes one choice in one place. Kept as a thin + * delegating shim so v2 code written before the widgets layer keeps + * compiling unchanged. */ +@Deprecated public final class BannerRenderer { private BannerRenderer() { } + /** + * @deprecated delegates to {@link SectionHeader#banner}. + */ + @Deprecated public static void render(SectionBuilder section, String title, CvTheme theme) { - section.softPanel(theme.palette().banner(), - theme.spacing().bannerCornerRadius(), - theme.spacing().bannerInnerPadding()) - .margin(theme.spacing().bannerMargin()) - .addParagraph(p -> p - .text(TextOrnaments.spacedUpper(title)) - .textStyle(theme.bannerStyle()) - .align(TextAlign.CENTER) - .margin(DocumentInsets.zero())); + SectionHeader.banner(section, title, theme); } } diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ContactRenderer.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ContactRenderer.java index 75eb328d..1c34c900 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ContactRenderer.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ContactRenderer.java @@ -1,74 +1,29 @@ package com.demcha.compose.document.templates.cv.v2.components; 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.DocumentInsets; -import com.demcha.compose.document.style.DocumentTextStyle; -import com.demcha.compose.document.templates.cv.v2.data.CvContact; 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.theme.CvTheme; - -import java.util.ArrayList; -import java.util.List; +import com.demcha.compose.document.templates.cv.v2.widgets.ContactLine; /** - * Draws the centred pipe-separated contact row under the headline: - * phone → email (clickable mailto) → address → optional labelled - * links in source order, each rendered as an active hyperlink. - * - *

Required contact fields are guaranteed non-blank by - * {@link CvContact} so the helper never needs to skip-on-empty for - * them. Optional {@link CvLink} entries are rendered if present and - * silently omitted if not — that's the whole point of the - * {@code .link(...)} builder method.

+ * @deprecated Use + * {@link com.demcha.compose.document.templates.cv.v2.widgets.ContactLine#centered} + * instead — the widget gives you named centred/right-aligned + * variants plus a configurable field order. Kept as a thin + * delegating shim so v2 code written before the widgets layer keeps + * compiling unchanged. */ +@Deprecated public final class ContactRenderer { private ContactRenderer() { } + /** + * @deprecated delegates to {@link ContactLine#centered}. + */ + @Deprecated public static void render(SectionBuilder section, CvIdentity identity, CvTheme theme) { - List parts = parts(identity); - DocumentTextStyle textStyle = theme.contactStyle(); - DocumentTextStyle separatorStyle = theme.contactSeparatorStyle(); - - section.spacing(0) - .padding(theme.spacing().contactPadding()) - .addParagraph(p -> p - .textStyle(textStyle) - .align(TextAlign.CENTER) - .margin(DocumentInsets.zero()) - .rich(rich -> { - for (int i = 0; i < parts.size(); i++) { - Part part = parts.get(i); - if (part.link() != null) { - rich.link(part.text(), part.link()); - } else { - rich.style(part.text(), textStyle); - } - if (i < parts.size() - 1) { - rich.style(theme.decoration().contactSeparator(), - separatorStyle); - } - } - })); - } - - private static List parts(CvIdentity identity) { - CvContact contact = identity.contact(); - List parts = new ArrayList<>(4 + identity.links().size()); - parts.add(new Part(contact.phone(), null)); - parts.add(new Part(contact.email(), - new DocumentLinkOptions("mailto:" + contact.email()))); - parts.add(new Part(contact.address(), null)); - for (CvLink link : identity.links()) { - parts.add(new Part(link.label(), new DocumentLinkOptions(link.url()))); - } - return parts; - } - - private record Part(String text, DocumentLinkOptions link) { + ContactLine.centered(section, identity, theme); } } diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/HeadlineRenderer.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/HeadlineRenderer.java index cf564d41..c5805e64 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/HeadlineRenderer.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/HeadlineRenderer.java @@ -1,32 +1,29 @@ package com.demcha.compose.document.templates.cv.v2.components; import com.demcha.compose.document.dsl.SectionBuilder; -import com.demcha.compose.document.node.TextAlign; -import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.templates.cv.v2.data.CvName; import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; +import com.demcha.compose.document.templates.cv.v2.widgets.Headline; /** - * Draws the centred letter-spaced uppercase name headline at the very - * top of a CV — the first thing a reader sees. + * @deprecated Use + * {@link com.demcha.compose.document.templates.cv.v2.widgets.Headline#spacedCentered} + * instead — the widget gives you a named API plus alignment + + * spaced-caps variants, while this class only ever did the + * centred-spaced-caps form. Kept as a thin delegating shim so v2 + * code written before the widgets layer keeps compiling unchanged. */ +@Deprecated public final class HeadlineRenderer { private HeadlineRenderer() { } /** - * @param section host section that will host the headline paragraph - * @param name full name to render - * @param theme active theme + * @deprecated delegates to {@link Headline#spacedCentered}. */ + @Deprecated public static void render(SectionBuilder section, CvName name, CvTheme theme) { - section.spacing(2) - .padding(theme.spacing().headlinePadding()) - .addParagraph(p -> p - .text(TextOrnaments.spacedUpper(name.full())) - .textStyle(theme.headlineStyle()) - .align(TextAlign.CENTER) - .margin(DocumentInsets.zero())); + Headline.spacedCentered(section, name, theme); } } 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 1fc7d5a7..0fa4f9e3 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 @@ -20,17 +20,27 @@ * │ presets/ │ * │ BoxedSections ← composition: data + theme + render │ * │ MinimalUnderlined ← another composition, same pieces │ + * │ ModernProfessional ← third preset, partial widget use │ * └─────────────────────────────────────────────────────────────┘ - * │ uses │ uses + * │ compose from + * ▼ + * ┌─────────────────────────────────────────────────────────────┐ + * │ widgets/ ← named visual building blocks (LEGO bricks) │ + * │ Headline .spacedCentered | .rightAligned │ + * │ ContactLine .centered | .rightAligned │ + * │ SectionHeader .banner | .underlined | .flat │ + * └─────────────────────────────────────────────────────────────┘ + * │ delegate to │ read tokens from * ▼ ▼ * ┌──────────────────────┐ ┌──────────────────────────┐ * │ components/ │ │ theme/ │ - * │ HeadlineRenderer │ reads │ CvPalette (colours) │ - * │ ContactRenderer │◀────────│ CvTypography (fonts) │ - * │ BannerRenderer │ │ CvSpacing (margins) │ - * │ RowRenderer │ │ CvDecoration (glyphs) │ - * │ EntryRenderer │ │ CvTheme (bundle) │ - * │ SectionDispatcher │ └──────────────────────────┘ + * │ RowRenderer │ reads │ CvPalette (colours) │ + * │ EntryRenderer │◀────────│ CvTypography (fonts) │ + * │ ParagraphRenderer │ │ CvSpacing (margins) │ + * │ SectionDispatcher │ │ CvDecoration (glyphs) │ + * │ ParagraphPrimitive│ │ CvTheme (bundle) │ + * │ MarkdownInline │ └──────────────────────────┘ + * │ TextOrnaments │ * └──────────────────────┘ * │ renders * ▼ @@ -38,7 +48,8 @@ * │ data/ │ * │ CvIdentity ← name, contact, optional links │ * │ CvSection ← sealed: Paragraph | Rows | Entries │ - * │ CvDocument ← identity + ordered sections │ + * │ CvDocument ← identity + Placement(slot, section) │ + * │ Slot ← MAIN | SIDEBAR | FOOTER │ * └─────────────────────────────────────────────────────────────┘ * * @@ -56,6 +67,14 @@ * touching renderers. If you want a navy theme or a different * bullet character, this is where you live. * + *
{@code widgets/}
+ *
The LEGO bricks. "Which visual building block do I + * want here — a banner, an underlined title, a right-aligned + * headline?" Each widget has 2-3 named variants and a + * lower-level entry for ad-hoc parameter combinations. This + * is where most preset code lives — picking widgets and + * composing them.
+ * *
{@code components/}
*
The reusable drawing primitives. "How is a section * row laid out?" Each renderer takes a host diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java index 33c1705f..185ca373 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java @@ -3,10 +3,10 @@ import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.dsl.PageFlowBuilder; import com.demcha.compose.document.templates.api.DocumentTemplate; -import com.demcha.compose.document.templates.cv.v2.components.BannerRenderer; -import com.demcha.compose.document.templates.cv.v2.components.ContactRenderer; -import com.demcha.compose.document.templates.cv.v2.components.HeadlineRenderer; import com.demcha.compose.document.templates.cv.v2.components.SectionDispatcher; +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.document.templates.cv.v2.data.CvDocument; import com.demcha.compose.document.templates.cv.v2.data.CvSection; import com.demcha.compose.document.templates.cv.v2.data.Slot; @@ -107,12 +107,12 @@ public void compose(DocumentSession document, CvDocument doc) { .addSection("CvV2Headline", section -> { section.accentBottom(theme.palette().rule(), theme.spacing().accentRuleWidth()); - HeadlineRenderer.render(section, doc.identity().name(), theme); + Headline.spacedCentered(section, doc.identity().name(), theme); }) .addSection("CvV2Contact", section -> { section.accentBottom(theme.palette().rule(), theme.spacing().accentRuleWidth()); - ContactRenderer.render(section, doc.identity(), theme); + ContactLine.centered(section, doc.identity(), theme); }); // Single-column preset — only renders MAIN-slot sections. @@ -123,7 +123,7 @@ public void compose(DocumentSession document, CvDocument doc) { final CvSection sec = sections.get(i); final int idx = i; pageFlow.addSection("CvV2Banner_" + idx, - host -> BannerRenderer.render(host, sec.title(), theme)); + host -> SectionHeader.banner(host, sec.title(), theme)); pageFlow.addSection("CvV2Body_" + idx, host -> SectionDispatcher.renderBody(host, sec, theme)); } diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java index 20c52adc..f7524465 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java @@ -2,18 +2,15 @@ import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.dsl.PageFlowBuilder; -import com.demcha.compose.document.node.TextAlign; -import com.demcha.compose.document.style.DocumentInsets; -import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.document.templates.api.DocumentTemplate; -import com.demcha.compose.document.templates.cv.v2.components.ContactRenderer; -import com.demcha.compose.document.templates.cv.v2.components.HeadlineRenderer; import com.demcha.compose.document.templates.cv.v2.components.SectionDispatcher; -import com.demcha.compose.document.templates.cv.v2.components.TextOrnaments; import com.demcha.compose.document.templates.cv.v2.data.CvDocument; import com.demcha.compose.document.templates.cv.v2.data.CvSection; 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 java.util.List; import java.util.Objects; @@ -102,11 +99,11 @@ public void compose(DocumentSession document, CvDocument doc) { .name("CvV2MinimalRoot") .spacing(theme.spacing().pageFlowSpacing()) .addSection("Headline", section -> - HeadlineRenderer.render(section, doc.identity().name(), theme)) + Headline.spacedCentered(section, doc.identity().name(), theme)) .addSection("Contact", section -> { section.accentBottom(theme.palette().rule(), theme.spacing().accentRuleWidth()); - ContactRenderer.render(section, doc.identity(), theme); + ContactLine.centered(section, doc.identity(), theme); }); // Single-column preset — only renders MAIN-slot sections. @@ -116,31 +113,12 @@ public void compose(DocumentSession document, CvDocument doc) { final CvSection sec = sections.get(i); final int idx = i; pageFlow.addSection("Title_" + idx, host -> - renderUnderlinedTitle(host, sec.title())); + SectionHeader.underlined(host, sec.title(), theme)); pageFlow.addSection("Body_" + idx, host -> SectionDispatcher.renderBody(host, sec, theme)); } pageFlow.build(); } - - /** - * The one piece that differs from {@link BoxedSections}: a - * small left-aligned uppercase title with a thin underline, - * in place of the centred banner panel. - */ - private void renderUnderlinedTitle( - com.demcha.compose.document.dsl.SectionBuilder host, - String title) { - DocumentTextStyle titleStyle = theme.entryTitleStyle(); - host.accentBottom(theme.palette().rule(), - theme.spacing().accentRuleWidth()) - .padding(new DocumentInsets(8, 0, 2, 0)) - .addParagraph(p -> p - .text(TextOrnaments.spacedUpper(title)) - .textStyle(titleStyle) - .align(TextAlign.LEFT) - .margin(DocumentInsets.zero())); - } } } diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessional.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessional.java index 03cf976e..b4690452 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessional.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessional.java @@ -18,6 +18,7 @@ import com.demcha.compose.document.templates.cv.v2.data.CvSection; 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.SectionHeader; import com.demcha.compose.font.FontName; import java.util.List; @@ -134,7 +135,7 @@ public void compose(DocumentSession document, CvDocument doc) { final CvSection sec = sections.get(i); final int idx = i; pageFlow.addSection("Title_" + idx, host -> - renderSectionTitle(host, sec.title())); + SectionHeader.flat(host, sec.title(), SECTION_TITLE_COLOR, theme)); pageFlow.addSection("Body_" + idx, host -> SectionDispatcher.renderBody(host, sec, theme)); } @@ -205,24 +206,5 @@ private void renderContact(SectionBuilder section, CvIdentity identity) { })); } - /** - * Flat bright-blue bold section title, left-aligned, no panel. - * This is the visual hallmark of the Modern Professional look. - */ - private void renderSectionTitle(SectionBuilder section, String title) { - DocumentTextStyle titleStyle = DocumentTextStyle.builder() - .fontName(FontName.HELVETICA_BOLD) - .size(theme.typography().sizeBanner()) - .decoration(DocumentTextDecoration.BOLD) - .color(SECTION_TITLE_COLOR) - .build(); - - section.padding(new DocumentInsets(8, 0, 2, 0)) - .addParagraph(p -> p - .text(title) - .textStyle(titleStyle) - .align(TextAlign.LEFT) - .margin(DocumentInsets.zero())); - } } } diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/ContactLine.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/ContactLine.java new file mode 100644 index 00000000..467d9283 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/ContactLine.java @@ -0,0 +1,124 @@ +package com.demcha.compose.document.templates.cv.v2.widgets; + +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.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.templates.cv.v2.data.CvContact; +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.theme.CvTheme; + +import java.util.ArrayList; +import java.util.List; + +/** + * Contact-line widget — the pipe-separated phone / email / address / + * optional links row that sits under the {@link Headline}. + * + *

Variants

+ * + *
    + *
  • {@link #centered} — centred line, phone → email → address → + * links. Used by classic / editorial presets that center the + * whole header block.
  • + *
  • {@link #rightAligned} — right-aligned line, address → phone + * → email → links. Used by modern presets where the header + * sits flush right next to the name.
  • + *
+ * + *

Email is always rendered as a clickable {@code mailto:} link; + * each optional {@link CvLink} becomes a clickable hyperlink with + * the {@code label} as the visible text. The separator glyph comes + * from {@code theme.decoration().contactSeparator()}.

+ */ +public final class ContactLine { + + private ContactLine() { + } + + /** + * Centred pipe-separated contact row. Order: phone → email → + * address → links. Visual signature of {@code BoxedSections}, + * {@code MinimalUnderlined}. + */ + public static void centered(SectionBuilder host, CvIdentity identity, CvTheme theme) { + render(host, identity, theme, TextAlign.CENTER, Order.PHONE_FIRST); + } + + /** + * Right-aligned pipe-separated contact row. Order: address → + * phone → email → links — address-first reads as the location + * label authors usually put first in this style. Visual + * signature of {@code ModernProfessional}. + */ + public static void rightAligned(SectionBuilder host, CvIdentity identity, CvTheme theme) { + render(host, identity, theme, TextAlign.RIGHT, Order.ADDRESS_FIRST); + } + + /** + * Lower-level entry. Pick the alignment and the field order + * explicitly. + */ + public static void render(SectionBuilder host, CvIdentity identity, CvTheme theme, + TextAlign alignment, Order order) { + List parts = parts(identity, order); + DocumentTextStyle textStyle = theme.contactStyle(); + DocumentTextStyle separatorStyle = theme.contactSeparatorStyle(); + String separator = theme.decoration().contactSeparator(); + + host.spacing(0) + .padding(theme.spacing().contactPadding()) + .addParagraph(p -> p + .textStyle(textStyle) + .align(alignment) + .margin(DocumentInsets.zero()) + .rich(rich -> { + for (int i = 0; i < parts.size(); i++) { + Part part = parts.get(i); + if (part.link() != null) { + rich.link(part.text(), part.link()); + } else { + rich.style(part.text(), textStyle); + } + if (i < parts.size() - 1) { + rich.style(separator, separatorStyle); + } + } + })); + } + + /** Field order in the rendered line. */ + public enum Order { + /** phone → email → address → links. */ + PHONE_FIRST, + /** address → phone → email → links. */ + ADDRESS_FIRST + } + + private static List parts(CvIdentity identity, Order order) { + CvContact c = identity.contact(); + List parts = new ArrayList<>(4 + identity.links().size()); + DocumentLinkOptions email = new DocumentLinkOptions("mailto:" + c.email()); + switch (order) { + case PHONE_FIRST -> { + parts.add(new Part(c.phone(), null)); + parts.add(new Part(c.email(), email)); + parts.add(new Part(c.address(), null)); + } + case ADDRESS_FIRST -> { + parts.add(new Part(c.address(), null)); + parts.add(new Part(c.phone(), null)); + parts.add(new Part(c.email(), email)); + } + } + for (CvLink link : identity.links()) { + parts.add(new Part(link.label(), new DocumentLinkOptions(link.url()))); + } + return parts; + } + + private record Part(String text, DocumentLinkOptions link) { + } +} diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Headline.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Headline.java new file mode 100644 index 00000000..7e4ac350 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Headline.java @@ -0,0 +1,87 @@ +package com.demcha.compose.document.templates.cv.v2.widgets; + +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.templates.cv.v2.components.TextOrnaments; +import com.demcha.compose.document.templates.cv.v2.data.CvName; +import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; + +/** + * Top-of-document headline widget — the subject's name as the page's + * largest text element. + * + *

Variants

+ * + *
    + *
  • {@link #spacedCentered} — centred letter-spaced uppercase + * (e.g. {@code J A N E D O E}). Used by classic / + * editorial presets where the name is the page's visual + * focal point.
  • + *
  • {@link #rightAligned} — right-aligned plain bold (e.g. + * {@code Jane Doe}). Used by modern / corporate presets + * where the name sits in a header bar next to contacts.
  • + *
+ * + *

Customisation

+ * + *

{@link #render} is the lower-level entry point taking + * alignment and "use spaced caps" as parameters. Any combination + * not covered by a named factory ({@code leftAligned-spacedCaps}, + * {@code centeredPlain}, …) is reachable via {@link #render}.

+ * + *

If even that isn't enough, inline a custom paragraph in the + * preset's {@code compose()} — widgets are optional helpers, not + * required wrappers.

+ */ +public final class Headline { + + private Headline() { + } + + /** + * Centred letter-spaced uppercase headline. Visual signature of + * {@code BoxedSections} and {@code MinimalUnderlined}. + */ + public static void spacedCentered(SectionBuilder host, CvName name, CvTheme theme) { + render(host, name, theme, TextAlign.CENTER, true); + } + + /** + * Right-aligned plain bold headline. Visual signature of + * {@code ModernProfessional}. + */ + public static void rightAligned(SectionBuilder host, CvName name, CvTheme theme) { + render(host, name, theme, TextAlign.RIGHT, false); + } + + /** + * Lower-level entry. Pick the alignment and whether the name + * should be transformed to spaced uppercase. Text style comes + * from {@link CvTheme#headlineStyle()}; padding from + * {@code theme.spacing().headlinePadding()}. + * + * @param host host section + * @param name name to render + * @param theme active theme + * @param alignment paragraph alignment + * @param spacedCaps if true, transforms to letter-spaced + * uppercase; if false, renders verbatim + */ + public static void render(SectionBuilder host, CvName name, CvTheme theme, + TextAlign alignment, boolean spacedCaps) { + DocumentTextStyle style = theme.headlineStyle(); + String text = spacedCaps + ? TextOrnaments.spacedUpper(name.full()) + : name.full(); + + host.spacing(2) + .padding(theme.spacing().headlinePadding()) + .addParagraph(p -> p + .text(text) + .textStyle(style) + .align(alignment) + .margin(DocumentInsets.zero())); + } +} 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 new file mode 100644 index 00000000..260ce84f --- /dev/null +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionHeader.java @@ -0,0 +1,99 @@ +package com.demcha.compose.document.templates.cv.v2.widgets; + +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.cv.v2.components.TextOrnaments; +import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; + +/** + * Section-header widget — the title drawn above each section's body. + * + *

Variants — each visually distinct, each with its own factory

+ * + *
    + *
  • {@link #banner} — pale-grey panel with centred spaced-caps + * title inside. Visual signature of {@code BoxedSections}.
  • + *
  • {@link #underlined} — small left-aligned spaced-caps title + * with a thin accent rule beneath. Visual signature of + * {@code MinimalUnderlined}.
  • + *
  • {@link #flat} — large left-aligned bold title in a given + * colour, no panel, no rule. Visual signature of + * {@code ModernProfessional}.
  • + *
+ * + *

Unlike {@link Headline} (one rendering shape, two text + * transforms), section headers are structurally + * different per variant — soft-panel vs accentBottom vs plain + * paragraph. That's why each variant gets its own factory method + * rather than a parameterised {@code render(spec)} entry point: the + * underlying DSL calls share little.

+ * + *

If you need a fourth variant (chip, numbered, gradient, …), + * add a new factory method here. The signature pattern is + * {@code (host, title, theme[, extras])} — keep it tight.

+ */ +public final class SectionHeader { + + private SectionHeader() { + } + + /** + * Pale-grey banner panel with centred spaced-caps title. Visual + * signature of {@code BoxedSections}. + */ + public static void banner(SectionBuilder host, String title, CvTheme theme) { + host.softPanel(theme.palette().banner(), + theme.spacing().bannerCornerRadius(), + theme.spacing().bannerInnerPadding()) + .margin(theme.spacing().bannerMargin()) + .addParagraph(p -> p + .text(TextOrnaments.spacedUpper(title)) + .textStyle(theme.bannerStyle()) + .align(TextAlign.CENTER) + .margin(DocumentInsets.zero())); + } + + /** + * Small left-aligned spaced-caps title with a thin accent rule + * beneath. Visual signature of {@code MinimalUnderlined}. + */ + public static void underlined(SectionBuilder host, String title, CvTheme theme) { + DocumentTextStyle titleStyle = theme.entryTitleStyle(); + host.accentBottom(theme.palette().rule(), + theme.spacing().accentRuleWidth()) + .padding(new DocumentInsets(8, 0, 2, 0)) + .addParagraph(p -> p + .text(TextOrnaments.spacedUpper(title)) + .textStyle(titleStyle) + .align(TextAlign.LEFT) + .margin(DocumentInsets.zero())); + } + + /** + * Large left-aligned bold title in a given colour. No panel, + * no rule, no transform. Visual signature of + * {@code ModernProfessional}. + * + * @param color the title colour — typically the preset's + * accent colour + */ + public static void flat(SectionBuilder host, String title, + DocumentColor color, CvTheme theme) { + DocumentTextStyle titleStyle = DocumentTextStyle.builder() + .fontName(theme.typography().headlineFont()) + .size(theme.typography().sizeBanner()) + .decoration(DocumentTextDecoration.BOLD) + .color(color) + .build(); + host.padding(new DocumentInsets(8, 0, 2, 0)) + .addParagraph(p -> p + .text(title) + .textStyle(titleStyle) + .align(TextAlign.LEFT) + .margin(DocumentInsets.zero())); + } +} diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/package-info.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/package-info.java new file mode 100644 index 00000000..90ce6ac5 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/package-info.java @@ -0,0 +1,59 @@ +/** + *

Visual widgets — reusable LEGO bricks for presets

+ * + *

Widgets sit between the raw document DSL + * (addParagraph / softPanel / accentBottom) and CV-specific + * renderers in {@code components/}. Each widget captures one + * visual idea (a headline, a contact line, a section + * header) with two or three named variants, so a preset author + * composes a page by picking widgets instead of + * writing rendering DSL by hand.

+ * + *

Three layers of customisation per widget

+ * + *
    + *
  1. Convenience factory — one line, no params + * beyond {@code (host, content, theme)}. Covers the common + * case. Example: {@code Headline.spacedCentered(host, name, theme)}.
  2. + *
  3. Lower-level render — when you need to vary + * one knob (alignment, decoration, colour). Example: + * {@code Headline.render(host, name, theme, TextAlign.RIGHT, false)}.
  4. + *
  5. Inline DSL — when no widget fits. Widgets + * are optional; the host {@link com.demcha.compose.document.dsl.SectionBuilder} + * always accepts direct {@code addParagraph} / {@code addRow} + * calls. Don't fight the widget — just bypass it.
  6. + *
+ * + *

When to add a new widget

+ * + *

Don't predict. Extract.

+ * + *
    + *
  • Same inline rendering in 1 preset → keep + * inline.
  • + *
  • Same inline rendering in 2 presets → + * extract a new factory method on an existing widget, or + * add a parameter.
  • + *
  • Same idea in 3+ presets → it's a new + * widget; give it its own file.
  • + *
+ * + *

Current widget catalog

+ * + *
    + *
  • {@link com.demcha.compose.document.templates.cv.v2.widgets.Headline} + * — top-of-document name in 2 variants + * ({@code spacedCentered}, {@code rightAligned}).
  • + *
  • {@link com.demcha.compose.document.templates.cv.v2.widgets.ContactLine} + * — pipe-separated contact + links row in 2 variants + * ({@code centered}, {@code rightAligned}).
  • + *
  • {@link com.demcha.compose.document.templates.cv.v2.widgets.SectionHeader} + * — section title in 3 variants ({@code banner}, + * {@code underlined}, {@code flat}).
  • + *
+ * + *

Each widget delegates internally to the lower-level renderers + * in {@code cv/v2/components/} where helpful, but its public face + * is the small set of factory methods above.

+ */ +package com.demcha.compose.document.templates.cv.v2.widgets; 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 new file mode 100644 index 00000000..287e639b --- /dev/null +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java @@ -0,0 +1,101 @@ +package com.demcha.compose.document.templates.cv.v2.widgets; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +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.templates.cv.v2.data.CvIdentity; +import com.demcha.compose.document.templates.cv.v2.data.CvName; +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 cv/v2/widgets layer. Each widget gets called in + * every variant exposed at the public API, against the default + * theme, to catch DSL-level regressions before they surface in the + * preset render outputs. + */ +class WidgetSmokeTest { + + @Test + void headline_variants_render_without_throwing() throws Exception { + renderWithSection(section -> { + Headline.spacedCentered(section, name(), CvTheme.boxedClassic()); + }); + renderWithSection(section -> { + Headline.rightAligned(section, name(), CvTheme.boxedClassic()); + }); + renderWithSection(section -> { + Headline.render(section, name(), CvTheme.boxedClassic(), + TextAlign.LEFT, false); + }); + } + + @Test + void contactLine_variants_render_without_throwing() throws Exception { + renderWithSection(section -> { + ContactLine.centered(section, identity(), CvTheme.boxedClassic()); + }); + renderWithSection(section -> { + ContactLine.rightAligned(section, identity(), CvTheme.boxedClassic()); + }); + renderWithSection(section -> { + ContactLine.render(section, identity(), CvTheme.boxedClassic(), + TextAlign.LEFT, ContactLine.Order.PHONE_FIRST); + }); + } + + @Test + void sectionHeader_variants_render_without_throwing() throws Exception { + CvTheme theme = CvTheme.boxedClassic(); + renderWithSection(section -> + SectionHeader.banner(section, "Professional Summary", theme)); + renderWithSection(section -> + SectionHeader.underlined(section, "Skills", theme)); + renderWithSection(section -> + SectionHeader.flat(section, "Experience", + DocumentColor.rgb(41, 128, 185), theme)); + } + + @Test + void widgets_work_against_modernProfessional_theme() throws Exception { + CvTheme theme = CvTheme.modernProfessional(); + renderWithSection(section -> Headline.rightAligned(section, name(), theme)); + renderWithSection(section -> ContactLine.rightAligned(section, identity(), theme)); + renderWithSection(section -> SectionHeader.flat(section, "Summary", + DocumentColor.rgb(0, 0, 0), theme)); + } + + private static void renderWithSection(SectionAction action) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(420, 595) + .margin(DocumentInsets.of(24)) + .create()) { + session.dsl().pageFlow() + .name("WidgetTestRoot") + .addSection("WidgetSlot", action::run) + .build(); + assertThat(session.roots()).isNotEmpty(); + } + } + + private static CvName name() { + return CvName.of("Jane", "Doe"); + } + + private static CvIdentity identity() { + return CvIdentity.builder() + .name("Jane", "Doe") + .contact("+44 0", "j@d.com", "London") + .link("LinkedIn", "https://linkedin.com/in/jane-doe") + .build(); + } + + @FunctionalInterface + private interface SectionAction { + void run(com.demcha.compose.document.dsl.SectionBuilder section); + } +}