diff --git a/CHANGELOG.md b/CHANGELOG.md
index 89abc270..2689ccb5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -292,6 +292,24 @@ follow semantic versioning; release dates are ISO 8601.
Nine presets and every slot they compose changed; a CV written in Russian and German
now renders on all sixteen, which `RoleRoutingTest` holds by rendering one.
+- **Timeline Minimal can draw its projects in the wide column.** The preset puts them in
+ the narrow sidebar beside education and skills, which suits a list of short labels and
+ not a CV whose projects carry a paragraph each: twelve such projects take the sidebar
+ three slices where the main column takes one, and every technical line wraps its way
+ there while the wide column ends half empty.
+
+ `TimelineMinimal.Options.builder().projectsColumn(TimelineMinimal.Column.MAIN)` moves
+ them under the work history, where a reader meets the career first and the projects that
+ came out of it second. `create(Options)` and `create(BrandTheme, Options)` join the
+ existing factories and reject a null `Options`, as every other preset carrying one does.
+ The moved block is measured against the column it lands in, so the wrapping estimate
+ follows it.
+
+ The sidebar arrangement stays the default and is held to the pixel by its own baseline;
+ the variant has a baseline of its own. Which column projects belong in is a property of
+ the CV rather than of the design, which is why it is a caller's choice rather than a
+ second preset.
+
- **The three column-flow presets keep the modular promise too.** `SidebarPortrait`,
`MonogramSidebar`, and `MintEditorial` declare `ModularCvTemplate`, so
`CvTemplates.modular()` now offers a two-column design to a CV assembled at runtime
diff --git a/docs/templates/v2-layered/authoring-presets.md b/docs/templates/v2-layered/authoring-presets.md
index a54739ac..432695d9 100644
--- a/docs/templates/v2-layered/authoring-presets.md
+++ b/docs/templates/v2-layered/authoring-presets.md
@@ -154,7 +154,10 @@ Some presets also expose narrow preset-specific options when the
visual decision is structural rather than a reusable widget. Example:
`NordicClean.Options` lets authors move the skills rail to the right
and override the accent colour, rail fill, or profile-band fill
-without mutating shared `BrandTheme` defaults or changing other presets.
+without mutating shared `BrandTheme` defaults or changing other presets;
+`TimelineMinimal.Options` moves its projects out of the narrow sidebar
+for a project-heavy CV. Both keep the preset's own arrangement as the
+default, so a caller who asks for nothing gets what shipped.
---
diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md
index 638fecd6..acde6ef1 100644
--- a/docs/templates/v2-layered/using-templates.md
+++ b/docs/templates/v2-layered/using-templates.md
@@ -366,6 +366,18 @@ NordicClean.create(
.build());
```
+`TimelineMinimal` exposes one for the same reason: which column its projects
+belong in is a property of the CV, not of the design. The narrow sidebar suits
+a list of short labels, and a CV whose projects carry a paragraph each fills it
+long before the wide column fills.
+
+```java
+TimelineMinimal.create(
+ TimelineMinimal.Options.builder()
+ .projectsColumn(TimelineMinimal.Column.MAIN) // under the work history
+ .build());
+```
+
---
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvV2VisualParityTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvV2VisualParityTest.java
index fe7b0ddc..ece2cba9 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvV2VisualParityTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvV2VisualParityTest.java
@@ -123,6 +123,15 @@ private static Stream presets() {
Arguments.of("timeline_minimal",
TimelineMinimal.RECOMMENDED_MARGIN,
(Supplier>) TimelineMinimal::create),
+ // The same preset with its projects moved to the wide column —
+ // a caller's option, so it needs a baseline of its own rather
+ // than being read off the default one.
+ Arguments.of("timeline_minimal_projects_main",
+ TimelineMinimal.RECOMMENDED_MARGIN,
+ (Supplier>) () -> TimelineMinimal.create(
+ TimelineMinimal.Options.builder()
+ .projectsColumn(TimelineMinimal.Column.MAIN)
+ .build())),
Arguments.of("engineering_resume",
EngineeringResume.RECOMMENDED_MARGIN,
(Supplier>) EngineeringResume::create),
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimalProjectsColumnTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimalProjectsColumnTest.java
new file mode 100644
index 00000000..cb2a6888
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimalProjectsColumnTest.java
@@ -0,0 +1,216 @@
+package com.demcha.compose.document.templates.cv.presets;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.snapshot.LayoutSnapshot;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.core.theme.BrandTheme;
+import com.demcha.compose.document.templates.cv.CvComposedText;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.data.EntriesSection;
+import com.demcha.compose.document.templates.cv.data.ParagraphSection;
+import com.demcha.compose.document.templates.cv.data.RowStyle;
+import com.demcha.compose.document.templates.cv.data.RowsSection;
+import com.demcha.compose.document.templates.cv.data.SkillsSection;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static com.demcha.compose.document.templates.cv.CvComposedText.squash;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Timeline Minimal draws its projects in the column the caller asked for.
+ *
+ * The narrow sidebar suits a list of short labels. A CV whose projects
+ * carry a paragraph each fills it long before the wide column fills, so the
+ * projects run onto a page of their own while the main column ends half
+ * empty. Which column they belong in is a property of the CV rather than of
+ * the design, so it is the caller's to choose — and the default stays what it
+ * has always been.
+ *
+ * Placement is read off the node names the preset gives its
+ * blocks, not off reading order. Order would only be a proxy: the preset emits
+ * one body row per page, so once the document paginates a later page's sidebar
+ * follows an earlier page's main column and the proxy inverts. The names say
+ * which column drew the block whatever the pagination does.
+ */
+class TimelineMinimalProjectsColumnTest {
+
+ /** How the preset names a block: the column, then its normalised title. */
+ private static final String SIDEBAR_PROJECTS = "CvV2TimelineMinimalSidebarprojects";
+ private static final String MAIN_PROJECTS = "CvV2TimelineMinimalMainprojects";
+
+ @Test
+ void byDefaultTheProjectsAreDrawnInTheSidebar() {
+ List names = blockNames(TimelineMinimal.create());
+
+ assertThat(names)
+ .as("the sidebar draws them, and only the sidebar")
+ .contains(SIDEBAR_PROJECTS)
+ .doesNotContain(MAIN_PROJECTS);
+ }
+
+ @Test
+ void theProjectsColumnOptionMovesThemToTheMainColumn() {
+ List names = blockNames(TimelineMinimal.create(movedToMain()));
+
+ assertThat(names)
+ .as("moved, not copied — the sidebar no longer draws them")
+ .contains(MAIN_PROJECTS)
+ .doesNotContain(SIDEBAR_PROJECTS);
+ }
+
+ @Test
+ void theMovedProjectsAreDrawnAfterTheWorkHistory() {
+ List names = blockNames(TimelineMinimal.create(movedToMain()));
+
+ assertThat(names)
+ .as("a reader of the wide column meets the career first and the "
+ + "projects that came out of it second")
+ .containsSubsequence("CvV2TimelineMinimalMainprofessionalsummary",
+ "CvV2TimelineMinimalMainprofessionalexperience",
+ MAIN_PROJECTS);
+ }
+
+ @Test
+ void theOptionMovesEveryProjectExactlyOnceAndLosesNoOtherSection() {
+ String moved = compose(TimelineMinimal.create(movedToMain()));
+
+ // Counted, not sampled: a block drawn in both columns and a block
+ // dropped from the middle of the list are the two failures column
+ // pagination exists to prevent, and presence alone sees neither.
+ assertThat(CvComposedText.occurrences(moved, squash(PROJECT_DESCRIPTION)))
+ .as("every project drawn, and each of them once")
+ .isEqualTo(PROJECT_NAMES.length);
+ for (String name : PROJECT_NAMES) {
+ assertThat(moved).as("%s reaches the page", name).contains(squash(name));
+ }
+ assertThat(moved)
+ .as("and the work history, the sidebar blocks and a section no "
+ + "slot claimed are all still there")
+ .contains(squash("Principal Engineer"), squash("MSc Computer Science"),
+ squash("Java 21"), squash("Publications"),
+ squash("Deterministic layout"));
+ }
+
+ @Test
+ void nullOptionsAreRejectedRatherThanTakenAsTheDefault() {
+ // Every preset in this package carrying an Options record rejects a null
+ // one. A null arriving from a caller's configuration is a dropped value,
+ // and drawing the stock layout for it hides that until somebody asks why
+ // the projects never moved.
+ assertThatThrownBy(() -> TimelineMinimal.create(BrandTheme.timelineMinimal(), null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("options");
+ assertThatThrownBy(() -> TimelineMinimal.create((TimelineMinimal.Options) null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("options");
+ }
+
+ @Test
+ void aProjectHeavyCvNeedsFewerPagesWithTheProjectsInTheWideColumn() {
+ // The reported symptom, measured. Twelve projects is the size used
+ // because it is where the sidebar genuinely runs out of column: it takes
+ // three slices where the wide column takes one. At nine the default also
+ // reports two pages, but for a different reason — its single slice no
+ // longer fits under the masthead and is displaced whole, leaving the
+ // first page nearly blank. Asserting on that number would be measuring a
+ // different mechanism than the one this option addresses.
+ int sidebarPages = pages(TimelineMinimal.create());
+ int mainPages = pages(TimelineMinimal.create(movedToMain()));
+
+ assertThat(mainPages)
+ .as("the wide column holds what the narrow one spills "
+ + "(sidebar %d pages, main %d)", sidebarPages, mainPages)
+ .isLessThan(sidebarPages);
+ }
+
+ // -- fixtures --------------------------------------------------------
+
+ private static final String[] PROJECT_NAMES = {
+ "GraphCompose", "Ledger Sync", "Field Atlas", "Template Studio",
+ "LayoutLint", "ChromeForge", "Signal Desk", "Paper Trail",
+ "Quiet Hours", "Night Mail", "Slow Radio", "Wide Angle"};
+
+ private static final String PROJECT_DESCRIPTION =
+ "Declarative Java PDF layout engine with a semantic authoring DSL, "
+ + "slot-based templates and snapshot testing, powering production CV, "
+ + "invoice and proposal pipelines for hiring tools and billing systems.";
+
+ private static TimelineMinimal.Options movedToMain() {
+ return TimelineMinimal.Options.builder()
+ .projectsColumn(TimelineMinimal.Column.MAIN)
+ .build();
+ }
+
+ private static String compose(DocumentTemplate preset) {
+ return CvComposedText.squashedNodes(preset, projectHeavyCv());
+ }
+
+ /** Every composed block's name, in layout order. */
+ private static List blockNames(DocumentTemplate preset) {
+ try (DocumentSession session = newSession()) {
+ preset.compose(session, projectHeavyCv());
+ LayoutSnapshot snapshot = session.layoutSnapshot();
+ return snapshot.nodes().stream()
+ .map(node -> node.entityName())
+ .filter(name -> name != null && name.startsWith("CvV2TimelineMinimal"))
+ .toList();
+ }
+ }
+
+ private static int pages(DocumentTemplate preset) {
+ try (DocumentSession session = newSession()) {
+ preset.compose(session, projectHeavyCv());
+ return session.layoutGraph().totalPages();
+ }
+ }
+
+ private static DocumentSession newSession() {
+ float margin = (float) TimelineMinimal.RECOMMENDED_MARGIN;
+ return GraphCompose.document()
+ .pageSize(DocumentPageSize.A4)
+ .margin(margin, margin, margin, margin)
+ .create();
+ }
+
+ /** A CV whose projects carry a paragraph each — the shape that spills. */
+ private static CvDocument projectHeavyCv() {
+ RowsSection.Builder projects =
+ RowsSection.builder("Projects", RowStyle.BULLETED_STACKED);
+ for (String name : PROJECT_NAMES) {
+ projects.row(name, PROJECT_DESCRIPTION);
+ }
+ return CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name("Jordan", "Rivera")
+ .jobTitle("Platform Engineer")
+ .contact("+44 20 5555 1000", "jordan@example.com", "London, UK")
+ .build())
+ .sections(List.of(
+ new ParagraphSection("Professional Summary",
+ "Platform engineer with ten years on document pipelines, "
+ + "layout engines and the template systems other teams "
+ + "build on."),
+ EntriesSection.builder("Professional Experience")
+ .entry("Principal Engineer", "Acme Rendering", "2022 - 2025",
+ "Owns the rendering pipeline and its release train.")
+ .build(),
+ EntriesSection.builder("Education")
+ .entry("MSc Computer Science", "University of Manchester",
+ "2018 - 2020", "Distinction.")
+ .build(),
+ SkillsSection.builder("Technical Skills")
+ .group("Platform", "Java 21", "Kotlin")
+ .build(),
+ projects.build(),
+ RowsSection.builder("Publications", RowStyle.PLAIN)
+ .row("Deterministic layout", "JVM Summit 2024")
+ .build()))
+ .build();
+ }
+}
diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal_projects_main-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal_projects_main-page-0.png
new file mode 100644
index 00000000..68e4ab76
Binary files /dev/null and b/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal_projects_main-page-0.png differ
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java
index a6d6417f..c26c337b 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java
@@ -32,8 +32,10 @@
* templates, they are templates with a fixed idea of what a CV contains.
*
* Every lookup builds a fresh template with the preset's own default
- * theme; a caller wanting a variant calls that preset's
- * {@code create(BrandTheme)} directly. {@code CvTemplatesCoverageTest} holds
+ * theme and default layout; a caller wanting a variant calls that preset's
+ * own factory directly — {@code create(BrandTheme)}, or {@code create(Options)}
+ * where the preset offers one, as {@code NordicClean} and
+ * {@code TimelineMinimal} do. {@code CvTemplatesCoverageTest} holds
* this catalogue to the presets package, so a preset added and not
* registered fails the build rather than staying invisible to every runtime
* caller.
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java
index 4a9da2a0..b4e00866 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java
@@ -34,7 +34,8 @@
* Minimal two-column CV with a vertical timeline axis between the
* sidebar (Education / Skills / Expertise / Languages / Interests /
* References) and the main column (Professional Profile / Work
- * Experience). Visual signature ported from the v1
+ * Experience) — the default arrangement, which {@link Options} lets a
+ * caller change for the projects. Visual signature ported from the v1
* {@code TimelineMinimalCvTemplateComposer}: spaced caps name in
* Barlow Condensed, contact stack with PNG icons, all-grey palette,
* three timeline dots between four axis segments.
@@ -63,6 +64,21 @@
* and emits one row per page, letting each finished row overflow naturally.
* What it holds back for the masthead is measured from the identity, because
* the contact stack grows a row per link.
+ *
+ * Choosing the projects column
+ *
+ * The sidebar suits a list of short project labels. A CV whose projects
+ * carry a paragraph each fills it long before the wide column fills, so the
+ * default draws them onto a second page while the main column ends half
+ * empty. Which column they belong in is a property of the CV rather than of
+ * the design:
+ *
+ * {@code
+ * TimelineMinimal.create(
+ * TimelineMinimal.Options.builder()
+ * .projectsColumn(TimelineMinimal.Column.MAIN)
+ * .build());
+ * }
*/
public final class TimelineMinimal {
@@ -184,7 +200,7 @@ private TimelineMinimal() {
* @return ready-to-use template
*/
public static DocumentTemplate create() {
- return create(BrandTheme.timelineMinimal());
+ return create(BrandTheme.timelineMinimal(), Options.defaults());
}
/**
@@ -194,11 +210,134 @@ public static DocumentTemplate create() {
* @return ready-to-use template
*/
public static DocumentTemplate create(BrandTheme theme) {
+ return create(theme, Options.defaults());
+ }
+
+ /**
+ * Builds the preset with its own theme and caller-supplied options.
+ *
+ * Ambiguous against {@link #create(BrandTheme)} for a bare
+ * {@code null} literal; pass {@link Options#defaults()} rather than
+ * {@code null} to ask for the stock layout.
+ *
+ * @param options placement options
+ * @return ready-to-use template
+ * @throws NullPointerException if {@code options} is null
+ * @since 2.3.0
+ */
+ public static DocumentTemplate create(Options options) {
+ return create(BrandTheme.timelineMinimal(), options);
+ }
+
+ /**
+ * Builds the preset with a caller-supplied theme and options.
+ *
+ * @param theme active theme
+ * @param options placement options
+ * @return ready-to-use template
+ * @throws NullPointerException if either argument is null
+ * @since 2.3.0
+ */
+ public static DocumentTemplate create(BrandTheme theme, Options options) {
Objects.requireNonNull(theme, "theme");
- return new Template(theme);
+ // Rejected rather than defaulted, which is what every other preset
+ // carrying an Options record does: a null arriving from a caller's
+ // configuration is a dropped value, and rendering the stock layout for
+ // it hides that until somebody asks why the projects never moved.
+ Objects.requireNonNull(options, "options");
+ return new Template(theme, options);
+ }
+
+ /**
+ * Which of this preset's two columns a section is drawn in.
+ *
+ * Not to be confused with
+ * {@link com.demcha.compose.document.templates.cv.data.Slot}, which shares
+ * two of these names: a slot is where the author of the CV put a
+ * section, and every preset here reads {@code Slot.MAIN}. This is where
+ * this preset then draws one of them.
+ *
+ * @since 2.3.0
+ */
+ public enum Column {
+ /** The narrow left column, beside Education and Skills. */
+ SIDEBAR,
+ /** The wide right column, under the profile and the work history. */
+ MAIN
}
- private record Template(BrandTheme theme) implements DocumentTemplate {
+ /**
+ * Placement this preset accepts from the caller.
+ *
+ * The narrow column suits a list of short labels. A CV whose projects
+ * carry a paragraph each fills it faster than the wide column fills, so
+ * the projects run onto a page of their own while the main column ends
+ * half empty and every technical line wraps three times. Moving them is a
+ * property of the CV, not of the design, which is why it is a caller's
+ * choice rather than a second preset.
+ *
+ * @param projectsColumn where the projects section is drawn;
+ * {@code null} means {@link Column#SIDEBAR}
+ * @since 2.3.0
+ */
+ public record Options(Column projectsColumn) {
+
+ /** Normalises a null column to the default. */
+ public Options {
+ projectsColumn = projectsColumn == null ? Column.SIDEBAR : projectsColumn;
+ }
+
+ /**
+ * The layout this preset has always drawn: projects in the sidebar.
+ *
+ * @return the default options
+ */
+ public static Options defaults() {
+ return new Options(Column.SIDEBAR);
+ }
+
+ /**
+ * Starts a mutable builder.
+ *
+ * @return new builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Builder for {@link Options}.
+ */
+ public static final class Builder {
+ private Column projectsColumn = Column.SIDEBAR;
+
+ private Builder() {
+ }
+
+ /**
+ * Sets the column the projects section is drawn in.
+ *
+ * @param value target column
+ * @return this builder
+ */
+ public Builder projectsColumn(Column value) {
+ this.projectsColumn = value;
+ return this;
+ }
+
+ /**
+ * Builds the configured options.
+ *
+ * @return a new {@link Options}
+ */
+ public Options build() {
+ return new Options(projectsColumn);
+ }
+ }
+ }
+
+ private record Template(BrandTheme theme, Options options)
+ implements DocumentTemplate {
@Override
public String id() {
@@ -229,15 +368,29 @@ public void compose(DocumentSession document, CvDocument doc) {
CvSection summary = allocation.claim(SectionRole.SUMMARY, SUMMARY_KEYS);
CvSection experience = allocation.claim(SectionRole.EXPERIENCE, EXPERIENCE_KEYS);
- List sidebar = modules(
- module(education, "Education"),
- module(skills, "Skills"),
- module(projects, "Expertise"),
- module(additional, "Languages"));
+ // Built once and placed in one column or the other, so the
+ // default is the same block in the same list it always was.
+ ColumnPagination.Block projectsBlock = module(projects, "Expertise");
+ boolean projectsInMain = options.projectsColumn() == Column.MAIN;
+
+ List sidebar = projectsInMain
+ ? modules(module(education, "Education"),
+ module(skills, "Skills"),
+ module(additional, "Languages"))
+ : modules(module(education, "Education"),
+ module(skills, "Skills"),
+ projectsBlock,
+ module(additional, "Languages"));
List main = new ArrayList<>();
addModule(main, prose(summary, "Professional Profile"));
addModule(main, module(experience, "Work Experience"));
+ // After the work history and before the leftover tail: a reader
+ // scanning the wide column meets the career first and the
+ // projects that came out of it second.
+ if (projectsInMain) {
+ addModule(main, projectsBlock);
+ }
// Whatever no module claimed — a user's own "Awards", a second
// prose section — goes into the main column under its own
// title rather than off the page. A paragraph keeps reading as