diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2920c69a..5d6b10637 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,46 @@
All notable changes to GraphCompose are documented here. Versions
follow semantic versioning; release dates are ISO 8601.
+## v2.4.2 — Planned
+
+### Documentation
+
+- **A card of a preset shows the code that draws that preset.** The catalogue carries one
+ compiled block per family — the CVs' builds `BoxedSections`, the invoices' `ModernInvoice` —
+ and every other card of the family was shown it under a caption saying it came from the
+ documentation. The caption was true and the code under it still drew a different document,
+ which is what a reader copies. Those cards now carry the smallest block that draws the preset
+ they are looking at, built from what the card already states: the preset it composes and the
+ record that preset takes, written the way the preset's own runnable example writes it — the
+ no-argument `create()`, which carries the preset's theme rather than another preset's. The
+ family's block stays on the one card it composes, and the link to the page it is published on
+ stays on all of them. It is not that block with its composing line rewritten, because the
+ invoices rule that out: `ConsultingInvoice` takes `StructuredInvoiceDocumentSpec` where the
+ block builds `InvoiceDocumentSpec`, so a rewrite would hand a reader a record the preset does
+ not accept.
+
+- **The catalogue comes before the install instructions.** A visitor scrolling the home page met
+ the Maven coordinates and a format-by-format comparison before seeing anything the library
+ draws. The gallery now follows the opening block directly: choose the result, then wire the
+ library up. Every id and anchor is unchanged, so existing links and shared viewer addresses
+ still land where they did.
+
+- **The line above the catalogue says what clicking a preview does.** It promised the rendered
+ PDF; a viewer opens, which pages through the document, moves across the rest of its family and
+ links the PDF, the source and what reproducing it takes.
+
+- **The site has a link preview drawn for the shape a link preview is.** `og:image` and
+ `twitter:image` named a portrait page of one proposal, 893 by 1263, so a large-image card —
+ which is landscape — published a band cropped out of its middle: a paragraph of a document
+ nobody had asked about, under a link about the library. `SiteSocialCoverRenderer` composes a
+ cover at 1200 by 630 with GraphCompose itself, in the site's own palette, carrying the
+ wordmark, what the library does, and three documents read from the previews the catalogue
+ publishes. It states no version and no measured figure, so it outlives the release that
+ published it, and it is published under a name of its own because Slack, X and LinkedIn cache
+ a preview by URL. `twitter:card` stays `summary_large_image`, and both tags now declare the
+ size and an alt text. A document's own page keeps its own preview: there the document is the
+ subject.
+
## v2.4.1 — 2026-09-21
### Performance
diff --git a/examples/src/main/java/com/demcha/examples/support/SiteSocialCoverRenderer.java b/examples/src/main/java/com/demcha/examples/support/SiteSocialCoverRenderer.java
new file mode 100644
index 000000000..c8cb7e107
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/SiteSocialCoverRenderer.java
@@ -0,0 +1,286 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.ImageBuilder;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.dsl.ShapeBuilder;
+import com.demcha.compose.document.dsl.ShapeContainerBuilder;
+import com.demcha.compose.document.node.CanvasChild;
+import com.demcha.compose.document.node.CanvasLayerNode;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.LayerAlign;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.ClipPolicy;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.svg.SvgIcon;
+import com.demcha.compose.font.FontName;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Renders the link-preview cover the showcase site publishes as its {@code og:image}.
+ *
+ *
The home page used to hand X and Slack a portrait page of one proposal, 893 by 1263.
+ * A large-image card is landscape, so the preview showed a crop of whatever fell in the
+ * middle of that page — a paragraph of a document nobody had asked about, under a link
+ * about the library. This is a cover drawn for that shape: 1200 by 630, the size every
+ * platform crops least, with the wordmark, what the library does, and three real documents
+ * from the catalogue standing in for the rest.
+ *
+ * Nothing on it dates: no version, no measured figure, no release name. A cover that
+ * carries one has to be redrawn on the cut, and a cut that forgets publishes a stale claim
+ * to every feed that reads the page. What the page states about the release is stated in
+ * the page, where {@code web-src/data/release.json} already moves it.
+ *
+ * The three documents are read from {@code web/showcase/thumbnails/} — the previews the
+ * catalogue itself publishes, so the cover cannot show a template the site does not have,
+ * and a rerun after a render change picks the new ones up.
+ *
+ * Usage — pass an explicit output path:
+ *
+ * ./mvnw -B -ntp -f examples/pom.xml -DskipTests exec:java \
+ * -Dexec.mainClass=com.demcha.examples.support.SiteSocialCoverRenderer \
+ * -Dexec.args="<outputPng>"
+ *
+ *
+ * @author Artem Demchyshyn
+ * @since 2.4.2
+ */
+public final class SiteSocialCoverRenderer {
+
+ /** The size a large-image card is composed at; every major platform crops it least. */
+ public static final double COVER_WIDTH = 1200;
+ /** The size a large-image card is composed at; every major platform crops it least. */
+ public static final double COVER_HEIGHT = 630;
+
+ /** 72 DPI over a page declared in points writes exactly COVER_WIDTH by COVER_HEIGHT pixels. */
+ private static final int POINTS_PER_INCH = 72;
+
+ // The site's own palette, as web/styles.css declares it for the dark theme.
+ private static final DocumentColor NIGHT = DocumentColor.rgb(11, 16, 32);
+ private static final DocumentColor SURFACE = DocumentColor.rgb(17, 24, 39);
+ private static final DocumentColor GRID = DocumentColor.rgb(30, 41, 69);
+ private static final DocumentColor ON_DARK = DocumentColor.rgb(248, 250, 252);
+ private static final DocumentColor ON_DARK_MUTED = DocumentColor.rgb(148, 163, 184);
+ private static final DocumentColor ACCENT = DocumentColor.rgb(99, 102, 241);
+ private static final DocumentColor ACCENT_TEXT = DocumentColor.rgb(165, 180, 252);
+ private static final DocumentColor SHEET_EDGE = DocumentColor.rgb(203, 213, 225);
+
+ /** The documents the cover shows, as their paths under the published catalogue. */
+ private static final String[] SHOWN = {
+ "web/showcase/thumbnails/templates/cv/cv-blue-banner-v2.png",
+ "web/showcase/thumbnails/templates/invoice/invoice-modern-v2.png",
+ "web/showcase/thumbnails/templates/proposal/proposal-editorial-v2.png"
+ };
+
+ /** Each shown document, drawn at this width; the height follows the thumbnails' 320x453. */
+ private static final double SHEET_WIDTH = 210;
+ private static final double SHEET_HEIGHT = SHEET_WIDTH * 453 / 320;
+
+ /** The card around a sheet: the sheet plus 6pt of surface on every side. */
+ private static final double SHEET_CARD_WIDTH = SHEET_WIDTH + 12;
+ private static final double SHEET_CARD_HEIGHT = SHEET_HEIGHT + 12;
+
+ /**
+ * Where the fan starts and how far each sheet steps.
+ *
+ * The last card has to land inside the page: at {@code LEFT + 2 * STEP + card width}
+ * it ends at 1158 of 1200, leaving a margin that matches the one on the left. An earlier
+ * pass stepped 176 from 648 and put the third document 48 points past the right edge,
+ * which a render showed and no assertion would have.
+ */
+ private static final double FAN_LEFT = 636;
+ private static final double FAN_STEP = 150;
+
+ /** The fan, centred in the page, with the middle sheet lifted to give it a front. */
+ private static final double FAN_TOP = (COVER_HEIGHT - SHEET_CARD_HEIGHT) / 2;
+ private static final double FAN_LIFT = 28;
+
+ private SiteSocialCoverRenderer() {
+ }
+
+ /**
+ * Runs the renderer.
+ *
+ * @param args one argument — where to write the PNG
+ * @throws Exception when composition or rendering fails
+ */
+ public static void main(String[] args) throws Exception {
+ if (args.length < 1 || args[0].isBlank()) {
+ System.err.println("Usage: SiteSocialCoverRenderer ");
+ System.exit(2);
+ }
+ Path written = render(Paths.get(args[0]).toAbsolutePath().normalize());
+ System.out.println("Generated: " + written + " ("
+ + (int) COVER_WIDTH + "x" + (int) COVER_HEIGHT + ")");
+ }
+
+ /**
+ * Composes the cover and writes it as a PNG, creating parent directories as needed.
+ *
+ * @param outputPng destination file
+ * @return the written path
+ * @throws Exception when composition or rendering fails
+ */
+ public static Path render(Path outputPng) throws Exception {
+ BufferedImage image;
+ try (DocumentSession document = GraphCompose.document()
+ .pageSize(COVER_WIDTH, COVER_HEIGHT)
+ .pageBackground(NIGHT)
+ .margin(DocumentInsets.zero())
+ .create()) {
+ document.pageFlow().name("SocialCover").add(scene()).build();
+ image = document.toImage(0, POINTS_PER_INCH);
+ }
+ Files.createDirectories(outputPng.toAbsolutePath().getParent());
+ ImageIO.write(image, "png", outputPng.toFile());
+ return outputPng;
+ }
+
+ /** The cover as a single node. */
+ private static DocumentNode scene() {
+ List layers = new ArrayList<>(grid());
+
+ // The wordmark carries the mark and the name together - it is one set of paths, so
+ // the two cannot drift apart or be scaled against each other by accident.
+ layers.add(at(icon("logo").node(430), 72, 92));
+ layers.add(at(rule(96), 76, 232));
+ layers.add(at(text("Tagline", "Document generation for Java",
+ display(36, ON_DARK), 520), 74, 260));
+ layers.add(at(text("Subtitle",
+ "Compose a document once. Render it to PDF, PowerPoint or Word.",
+ body(18, ON_DARK_MUTED), 470), 76, 322));
+ layers.add(at(text("Formats", "PDF · PPTX · DOCX",
+ body(15, ACCENT_TEXT), 420), 76, 470));
+
+ // Three real documents from the catalogue, fanned so each stays readable as a shape
+ // while the group reads as a stack rather than as three unrelated pictures.
+ for (int i = 0; i < SHOWN.length; i++) {
+ boolean middle = i == SHOWN.length / 2;
+ layers.add(at(sheet(SHOWN[i]),
+ FAN_LEFT + i * FAN_STEP,
+ middle ? FAN_TOP - FAN_LIFT : FAN_TOP));
+ }
+ return new CanvasLayerNode("SocialCover", COVER_WIDTH, COVER_HEIGHT, layers,
+ ClipPolicy.CLIP_BOUNDS, DocumentInsets.zero(), DocumentInsets.zero());
+ }
+
+ /** One catalogue document, in a card with the site's own surface and edge. */
+ private static DocumentNode sheet(String thumbnail) {
+ Path file = repoRoot().resolve(thumbnail);
+ if (!Files.isRegularFile(file)) {
+ throw new IllegalStateException("the cover names a preview the catalogue does not "
+ + "publish: " + thumbnail);
+ }
+ return new ShapeContainerBuilder()
+ .name("Sheet")
+ .roundedRect(SHEET_CARD_WIDTH, SHEET_CARD_HEIGHT, 10)
+ .fillColor(SURFACE)
+ .stroke(DocumentStroke.of(SHEET_EDGE.withOpacity(0.28), 1))
+ .position(new ImageBuilder()
+ .name("Preview")
+ .source(file)
+ .size(SHEET_WIDTH, SHEET_HEIGHT)
+ .build(), 6, 6, LayerAlign.TOP_LEFT)
+ .build();
+ }
+
+ /** The faint grid the site's own hero carries, so the cover reads as part of it. */
+ private static List grid() {
+ List lines = new ArrayList<>();
+ for (double y = 0; y <= COVER_HEIGHT; y += 42) {
+ lines.add(at(new ShapeBuilder().size(COVER_WIDTH, 1)
+ .fillColor(GRID.withOpacity(0.55)).build(), 0, y));
+ }
+ for (double x = 0; x <= COVER_WIDTH; x += 42) {
+ lines.add(at(new ShapeBuilder().size(1, COVER_HEIGHT)
+ .fillColor(GRID.withOpacity(0.55)).build(), x, 0));
+ }
+ return lines;
+ }
+
+ private static DocumentNode rule(double width) {
+ return new ShapeBuilder().size(width, 4).fillColor(ACCENT).build();
+ }
+
+ private static CanvasChild at(DocumentNode node, double x, double y) {
+ return new CanvasChild(node, x, y);
+ }
+
+ private static DocumentNode text(String name, String value, DocumentTextStyle style,
+ double width) {
+ return new ParagraphBuilder()
+ .name(name)
+ .text(value)
+ .textStyle(style)
+ .align(TextAlign.LEFT)
+ .lineSpacing(1.3)
+ .margin(new DocumentInsets(0, COVER_WIDTH - width, 0, 0))
+ .build();
+ }
+
+ /**
+ * Bold display type. The weight comes from the decoration rather than from a
+ * {@code *_BOLD} font name: the standard-14 style variants are family aliases, so naming
+ * one selects the family and leaves the face at regular.
+ */
+ private static DocumentTextStyle display(double size, DocumentColor color) {
+ return DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA)
+ .decoration(DocumentTextDecoration.BOLD)
+ .size(size)
+ .color(color)
+ .build();
+ }
+
+ private static DocumentTextStyle body(double size, DocumentColor color) {
+ return DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA)
+ .size(size)
+ .color(color)
+ .build();
+ }
+
+ private static SvgIcon icon(String name) {
+ try (InputStream in = Objects.requireNonNull(
+ SiteSocialCoverRenderer.class.getResourceAsStream("/showcase/" + name + ".svg"),
+ "showcase icon missing: " + name)) {
+ return SvgIcon.parse(new String(in.readAllBytes(), StandardCharsets.UTF_8));
+ } catch (Exception e) {
+ throw new IllegalStateException("failed to load showcase icon: " + name, e);
+ }
+ }
+
+ /**
+ * The repository root, found by walking up from the working directory until the published
+ * catalogue is under it. The renderer is started from the examples module by the site
+ * tooling and from the repository root by hand, and the previews it reads live at a path
+ * relative to neither.
+ *
+ * @return the directory holding {@code web/showcase}
+ */
+ private static Path repoRoot() {
+ Path here = Paths.get("").toAbsolutePath().normalize();
+ for (Path candidate = here; candidate != null; candidate = candidate.getParent()) {
+ if (Files.isDirectory(candidate.resolve("web/showcase/thumbnails"))) {
+ return candidate;
+ }
+ }
+ throw new IllegalStateException("no web/showcase/thumbnails above " + here
+ + ": run this from the repository or the examples module");
+ }
+}
diff --git a/scripts/site/gallery-viewer.test.mjs b/scripts/site/gallery-viewer.test.mjs
index 5e928e982..9032d4edf 100644
--- a/scripts/site/gallery-viewer.test.mjs
+++ b/scripts/site/gallery-viewer.test.mjs
@@ -599,13 +599,27 @@ check("with no release context the panel names no coordinates rather than guessi
check("the panel shows the family's compiled block, as the manifest carries it", () => {
const page = viewerHarness();
- page.viewer.open({ category: "templates", group: "cv", id: "cv-blue-banner-v2" }, { history: "push" });
+ page.viewer.open({ category: "templates", group: "cv", id: "cv-boxed-sections-v2" }, { history: "push" });
page.click(page.parts.panelToggle);
const code = manifest.snippets.cv.code;
assert.ok(code && code.length > 0, "the manifest carries a CV snippet for the panel to show");
assert.ok(page.panelText().includes(code), "the block is shown as it stands, not paraphrased");
});
+check("a preset the family's block does not compose is given a block of its own", () => {
+ const page = viewerHarness();
+ page.viewer.open({ category: "templates", group: "cv", id: "cv-blue-banner-v2" }, { history: "push" });
+ page.click(page.parts.panelToggle);
+ const said = page.panelText();
+ assert.ok(!said.includes(manifest.snippets.cv.code),
+ "the family's block composes BoxedSections: shown on Blue Banner it is code that draws a "
+ + "different CV, and a caption saying so does not stop it being copied");
+ assert.ok(said.includes("BlueBanner.create()"),
+ "the block a reader can copy here has to compose the preset they are looking at");
+ assert.ok(!said.includes("BoxedSections"), "and nothing of the preset it does not");
+ assert.ok(said.includes("CvDocument"), "named with the record this preset takes");
+});
+
check("a family with no compiled block shows no snippet rather than another family's", () => {
const page = viewerHarness();
page.viewer.open({ category: "templates", group: "coverletter", id: "cover-letter" }, { history: "push" });
@@ -685,26 +699,39 @@ check("the panel model runs with no DOM at all", () => {
assert.ok(model.items.length > 0 && model.links.length > 0);
});
-check("the family's block is called the document's own only on the card of the preset it composes", () => {
- const labelOf = (id) => {
+check("the family's block is shown only on the card of the preset it composes", () => {
+ const familyBlockOf = (id) => {
const home = catalogue.get(id);
const block = catalogue.snippets[home.groupId].code;
- const listing = gallery.panelModel(home.example, catalogue, RELEASE).items.find((item) => item.code === block);
- assert.ok(listing, "fixture: the panel of " + id + " shows its family's block");
- return listing.label;
+ return gallery.panelModel(home.example, catalogue, RELEASE).items.find((item) => item.code === block);
+ };
+ const presetBlockOf = (id) => {
+ const home = catalogue.get(id);
+ return gallery.panelModel(home.example, catalogue, RELEASE).items
+ .find((item) => item.label === "Compose this preset");
};
assert.match(manifest.snippets.cv.code, /\bBoxedSections\.create\(/, "fixture: the CV block composes BoxedSections");
- assert.equal(labelOf("cv-boxed-sections-v2"), "Compose it");
- assert.equal(labelOf("cv-blue-banner-v2"), "From the docs",
- "the CV block builds BoxedSections; labelled 'Compose it' on Blue Banner it promises a document it does not build");
- assert.equal(labelOf("invoice-modern-v2"), "Compose it");
- assert.equal(labelOf("invoice-classic-v2"), "From the docs");
- assert.equal(labelOf("project-proposal-cinematic"), "From the docs", "a card that builds no preset");
-
- // Matched as a whole name: a preset called Sections does not compose BoxedSections.
+
+ assert.equal(familyBlockOf("cv-boxed-sections-v2").label, "Compose it");
+ assert.equal(familyBlockOf("invoice-modern-v2").label, "Compose it");
+ assert.equal(familyBlockOf("project-proposal-cinematic").label, "From the docs",
+ "a card that builds no preset has no preset block to be given instead");
+
+ // Every other preset of the family gets its own block rather than the family's. The caption
+ // was honest before and the code under it still drew another design, which is what a reader
+ // copies. ConsultingInvoice is why the block is not the family's with one line rewritten: it
+ // takes StructuredInvoiceDocumentSpec where that block builds InvoiceDocumentSpec.
+ for (const [id, preset] of [["cv-blue-banner-v2", "BlueBanner"], ["invoice-classic-v2", "ClassicInvoice"]]) {
+ assert.equal(familyBlockOf(id), undefined, id + " is still shown a block composing another preset");
+ assert.match(presetBlockOf(id).code, new RegExp("\\b" + preset + "\\.create\\(\\)"));
+ }
+
+ // Matched as a whole name: a preset called Sections does not compose BoxedSections, so it is
+ // given its own block rather than told the family's is its own.
const card = { id: "sections", kind: "PRESET", presetClass: "com.example.presets.Sections" };
const fixture = { snippets: { cv: { code: manifest.snippets.cv.code } }, get: () => ({ groupId: "cv", example: card }) };
- assert.equal(gallery.panelModel(card, fixture, RELEASE).items.find((item) => "code" in item).label, "From the docs");
+ assert.equal(gallery.panelModel(card, fixture, RELEASE).items.find((item) => "code" in item).label,
+ "Compose this preset");
});
check("a listing that is not on the family guide's page links the page it is on", () => {
diff --git a/web-src/pages/index.html b/web-src/pages/index.html
index bb7fab294..62473efcf 100644
--- a/web-src/pages/index.html
+++ b/web-src/pages/index.html
@@ -22,17 +22,25 @@
-
-
-
-
+
+
+
+
+
+
-
+
+
+
+
+
+
+
-
+
+