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) { - ListEmail 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{@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. + * + *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 @@ +/** + *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.
+ * + *Don't predict. Extract.
+ * + *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); + } +}