diff --git a/CHANGELOG.md b/CHANGELOG.md
index 577c24f4..0eba9453 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,11 @@ follow semantic versioning; release dates are ISO 8601.
the combined subtitle+date meta line of MintEditorial/SidebarPortrait experience
entries — the link's label text is shown instead. Titles without inline-Markdown
syntax render exactly as before, so the change is backward-compatible.
+- **Project-card titles accept inline `[text](url)` links in the `EngineeringResume`
+ and `SidebarPortrait` presets.** Their hand-rolled project cards previously emitted
+ the title as flat styled text, so a `[label](url)` there stayed literal; it now runs
+ through the same link-aware path as project rows, entry titles, and body text, with
+ the trailing `" (stack)"` run preserved. Plain titles render exactly as before.
- The fixed-layout PPTX backend ships as `@Beta` (Experimental) in its first
release: the `document.backend.fixed.pptx` packages and the
`DocumentSession` PPTX convenience methods (`toPptxBytes`, `writePptx`,
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ProjectTitleLinkTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ProjectTitleLinkTest.java
new file mode 100644
index 00000000..20a15193
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ProjectTitleLinkTest.java
@@ -0,0 +1,141 @@
+package com.demcha.compose.document.templates.cv.components;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.ExternalLinkTarget;
+import com.demcha.compose.document.node.InlineRun;
+import com.demcha.compose.document.node.InlineTextRun;
+import com.demcha.compose.document.node.ParagraphNode;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+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.RowStyle;
+import com.demcha.compose.document.templates.cv.data.RowsSection;
+import com.demcha.compose.document.templates.cv.presets.EngineeringResume;
+import com.demcha.compose.document.templates.cv.presets.SidebarPortrait;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * End-to-end wiring for inline-link CV project-card titles: a project row whose
+ * label carries {@code [label](url)} markdown renders as a clickable link in the
+ * two presets that hand-roll their project card outside the shared
+ * {@code ProjectRenderer} — {@code EngineeringResume} (bordered card in the main
+ * column) and {@code SidebarPortrait} (stacked row in the main column). Plain
+ * labels stay plain and the trailing {@code " (stack)"} run is preserved, so the
+ * change is backward-compatible.
+ *
+ *
The {@link com.demcha.compose.document.templates.core.text.MarkdownInline}
+ * link primitive and {@link ProjectLabel} split are unit-tested separately; this
+ * locks the preset wiring so a refactor cannot silently revert a project title to
+ * a flat styled run (the state before this change), which would drop the link.
+ */
+class ProjectTitleLinkTest {
+
+ private static CvDocument docWithProject(String label) {
+ return CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name("Test", "User").jobTitle("Engineer")
+ .contact("+1 555 0100", "user@example.com", "City").build())
+ .section(RowsSection.builder("Projects", RowStyle.BULLETED_STACKED)
+ .row(label, "Did meaningful work.")
+ .build())
+ .build();
+ }
+
+ /** Collects every inline text run in the composed document. */
+ private static List textRuns(DocumentTemplate template,
+ CvDocument doc) {
+ try (DocumentSession document = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4).margin(28, 28, 28, 28).create()) {
+ template.compose(document, doc);
+ List paragraphs = new ArrayList<>();
+ collectParagraphs(document.roots(), paragraphs);
+ List runs = new ArrayList<>();
+ for (ParagraphNode paragraph : paragraphs) {
+ for (InlineRun run : paragraph.inlineRuns()) {
+ if (run instanceof InlineTextRun text) {
+ runs.add(text);
+ }
+ }
+ }
+ return runs;
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static void collectParagraphs(List nodes, List out) {
+ for (DocumentNode node : nodes) {
+ if (node instanceof ParagraphNode paragraph) {
+ out.add(paragraph);
+ }
+ collectParagraphs(node.children(), out);
+ }
+ }
+
+ private static boolean isExternalLink(InlineTextRun run) {
+ return run.linkTarget() instanceof ExternalLinkTarget;
+ }
+
+ private static String uri(InlineTextRun run) {
+ return ((ExternalLinkTarget) run.linkTarget()).options().uri();
+ }
+
+ /**
+ * EngineeringResume's bordered project card: a link in the project title
+ * reaches the output, and the trailing {@code " (stack)"} run is preserved.
+ */
+ @Test
+ void engineeringResumeProjectTitleBecomesLink() {
+ List runs = textRuns(EngineeringResume.create(),
+ docWithProject("[Acme Corp](https://acme.example) (Java, PDFBox)"));
+ assertThat(runs).filteredOn(ProjectTitleLinkTest::isExternalLink)
+ .anySatisfy(run -> {
+ assertThat(run.text()).isEqualTo("Acme Corp");
+ assertThat(uri(run)).isEqualTo("https://acme.example");
+ });
+ assertThat(runs).anySatisfy(run ->
+ assertThat(run.text()).contains("Java, PDFBox"));
+ }
+
+ /**
+ * SidebarPortrait's stacked project row: a link in the project title reaches
+ * the output, and the trailing {@code " (stack)"} run is preserved.
+ */
+ @Test
+ void sidebarPortraitProjectTitleBecomesLink() {
+ List runs = textRuns(SidebarPortrait.create(),
+ docWithProject("[Acme Corp](https://acme.example) (Java, PDFBox)"));
+ assertThat(runs).filteredOn(ProjectTitleLinkTest::isExternalLink)
+ .anySatisfy(run -> {
+ assertThat(run.text()).isEqualTo("Acme Corp");
+ assertThat(uri(run)).isEqualTo("https://acme.example");
+ });
+ assertThat(runs).anySatisfy(run ->
+ assertThat(run.text()).contains("Java, PDFBox"));
+ }
+
+ /**
+ * Backward-compatible: a plain project title is not rendered as a link. The
+ * header contact block may render its own email/website links, so the control
+ * asserts specifically that the plain project title run is not linked.
+ */
+ @Test
+ void plainProjectTitleIsNotLink() {
+ assertThat(projectTitleLinked(EngineeringResume.create())).isFalse();
+ assertThat(projectTitleLinked(SidebarPortrait.create())).isFalse();
+ }
+
+ private static boolean projectTitleLinked(DocumentTemplate template) {
+ return textRuns(template, docWithProject("Acme Corp (Java, PDFBox)")).stream()
+ .filter(ProjectTitleLinkTest::isExternalLink)
+ .anyMatch(run -> run.text().equalsIgnoreCase("Acme Corp"));
+ }
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EngineeringResume.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EngineeringResume.java
index 7a7b09fb..40a190c8 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EngineeringResume.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EngineeringResume.java
@@ -477,7 +477,8 @@ private void addProjects(SectionBuilder parent, CvSection section) {
.lineSpacing(1.06)
.margin(DocumentInsets.zero())
.rich(rich -> {
- rich.style(label.title(),
+ MarkdownInline.append(rich,
+ label.title(),
projectTitleStyle());
if (!label.stack().isBlank()) {
rich.style(" (" + label.stack()
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java
index 35af1348..e57acc44 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java
@@ -754,7 +754,7 @@ private void addProjectsList(SectionBuilder section,
.lineSpacing(1.2)
.margin(DocumentInsets.top(topMargin))
.rich(rich -> {
- rich.style(label.title(), titleStyle);
+ MarkdownInline.append(rich, label.title(), titleStyle);
if (!label.stack().isBlank()) {
rich.style(" (" + label.stack() + ")",
contextStyle);