From 32c703025fe0cfc0ac0fcef5f8d2f27736bf0450 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 22 Sep 2026 00:14:48 +0100 Subject: [PATCH] fix(web): give a preset card its own code, lead with the catalogue, and draw a link preview for the shape a link preview is Four things a visitor met on the showcase home page. A card of a preset showed its family's compiled block - the CVs' builds BoxedSections, the invoices' ModernInvoice - captioned as coming 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 the preset and the record the card already states, in the form the preset's own runnable example uses: the no-argument create(), which carries the preset's theme rather than another's. Not the family block with its composing line rewritten - ConsultingInvoice takes StructuredInvoiceDocumentSpec where that block builds InvoiceDocumentSpec, so a rewrite would hand a reader a record the preset does not accept. The catalogue was below the Maven coordinates and a format-by-format comparison, so the page asked a visitor to install something before showing them anything it draws. It now follows the opening block. Ids and anchors are unchanged, so existing links and shared viewer addresses still land where they did. The line above it promised the rendered PDF on a click; a viewer opens. og:image and twitter:image named a portrait page of one proposal, 893x1263. A large-image card is landscape, so X published a band cropped from its middle. SiteSocialCoverRenderer composes a cover at 1200x630 with GraphCompose itself, in the site's palette, carrying the wordmark, what the library does, and three documents read from the previews the catalogue publishes. No version, no figure - it outlives the release that published it. Published under a name of its own, because the platforms cache a preview by URL. --- CHANGELOG.md | 40 +++ .../support/SiteSocialCoverRenderer.java | 286 ++++++++++++++++++ scripts/site/gallery-viewer.test.mjs | 57 +++- web-src/pages/index.html | 82 ++--- .../social/graphcompose-cover-2026-09.png | Bin 0 -> 131326 bytes web/gallery-viewer.js | 52 +++- web/index.html | 230 +++++++------- .../cover-letter-blue-banner-v2/index.html | 11 + .../cover-letter-boxed-sections-v2/index.html | 11 + .../index.html | 11 + .../cover-letter-classic-serif-v2/index.html | 11 + .../cover-letter-compact-mono-v2/index.html | 11 + .../cover-letter-editorial-blue-v2/index.html | 11 + .../index.html | 11 + .../cover-letter-executive-v2/index.html | 11 + .../cover-letter-mint-editorial-v2/index.html | 11 + .../index.html | 11 + .../index.html | 11 + .../cover-letter-nordic-clean-v2/index.html | 11 + .../cover-letter-panel-v2/index.html | 11 + .../index.html | 11 + .../index.html | 11 + web/templates/cv/cv-blue-banner-v2/index.html | 18 +- .../cv/cv-centered-headline-v2/index.html | 18 +- .../cv/cv-charcoal-gold-v2/index.html | 18 +- .../cv/cv-classic-serif-v2/index.html | 18 +- .../cv/cv-compact-mono-v2/index.html | 18 +- .../cv/cv-editorial-blue-v2/index.html | 18 +- .../cv/cv-engineering-resume-v2/index.html | 18 +- web/templates/cv/cv-executive-v2/index.html | 18 +- .../cv/cv-midnight-navy-v2/index.html | 18 +- .../cv/cv-minimal-underlined-v2/index.html | 18 +- .../cv/cv-mint-editorial-v2-custom/index.html | 18 +- .../cv/cv-mint-editorial-v2/index.html | 18 +- .../cv/cv-modern-professional-v2/index.html | 18 +- .../cv/cv-monogram-sidebar-v2/index.html | 18 +- .../cv/cv-navy-sidebar-v2/index.html | 18 +- .../cv/cv-nordic-clean-v2/index.html | 18 +- web/templates/cv/cv-orange-ops-v2/index.html | 18 +- web/templates/cv/cv-panel-v2/index.html | 18 +- .../cv/cv-professional-sidebar-v2/index.html | 18 +- .../cv/cv-serif-headline-v2/index.html | 18 +- .../cv/cv-sidebar-portrait-v2/index.html | 18 +- .../cv/cv-slate-orange-v2/index.html | 18 +- web/templates/cv/cv-teal-pulse-v2/index.html | 18 +- .../cv/cv-terracotta-rail-v2/index.html | 18 +- .../cv/cv-timeline-minimal-v2/index.html | 18 +- web/templates/cv/cv-violet-grid-v2/index.html | 18 +- .../invoice/invoice-classic-v2/index.html | 50 +-- .../invoice/invoice-consulting-v2/index.html | 50 +-- .../invoice/invoice-luma-studio-v2/index.html | 50 +-- .../invoice/invoice-payments-v2/index.html | 50 +-- .../invoice/invoice-workspace-v2/index.html | 50 +-- .../proposal/proposal-editorial-v2/index.html | 48 +-- .../proposal/proposal-northline-v2/index.html | 48 +-- .../receipt/receipt-modern/index.html | 11 + 56 files changed, 916 insertions(+), 821 deletions(-) create mode 100644 examples/src/main/java/com/demcha/examples/support/SiteSocialCoverRenderer.java create mode 100644 web/assets/social/graphcompose-cover-2026-09.png 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 @@ - - - - + + + + + + - + + + + + + + - + +