diff --git a/src/loregraph/web/marketing-patch.cjs b/src/loregraph/web/marketing-patch.cjs
index e2f05da..5cb39ae 100644
--- a/src/loregraph/web/marketing-patch.cjs
+++ b/src/loregraph/web/marketing-patch.cjs
@@ -11,9 +11,9 @@
// quietly shipping the page with the bug back in it. A patch that stops matching
// is either fixed upstream — delete it here — or broken, and both need a human.
//
-// Anything editorial (heading levels that would need their CSS selectors moved
-// with them, alt text that is too long to hear read aloud) is deliberately NOT
-// here. Those want a decision on the generating side, not a regex.
+// Anything that needs a judgement call rather than a rule belongs on the
+// generating side, not here. What is left in that category is the copy voice
+// itself; the copy-edits patch only removes repetition it can point at.
const fs = require("fs");
const path = require("path");
@@ -58,6 +58,15 @@ function appLabel(html) {
// Every rule here fixes something measured, and nothing here restyles the design
// for its own sake. Appended after the export's own stylesheet so it wins on
// order without needing !important.
+// The page's ground is paper seen through a fixed noise-and-gradient layer that
+// body::before paints at z-index 1 under .shell's 2. Anything inside .shell that
+// fills itself with the paper colour therefore comes out lighter and flatter than
+// the ground around it. These are the exact three layers, reused so a copy cannot
+// drift from the original.
+const TEXTURE_LAYERS = `radial-gradient(circle at 12% 18%, rgba(106, 92, 56, 0.07) 0, transparent 28%),
+ radial-gradient(circle at 88% 72%, rgba(106, 92, 56, 0.06) 0, transparent 32%),
+ url("data:image/svg+xml;utf8,")`;
+
const CORRECTIONS = `
/* ---------------------------------------------------------------------------
* Build-time corrections. See src/loregraph/web/marketing-patch.cjs — edit
@@ -95,6 +104,30 @@ const CORRECTIONS = `
.hero-actions { flex-wrap: wrap; row-gap: 12px; }
}
+/* Same defect as the numerals, and a whole band of it: the sticky header fills
+ * itself with the paper colour, so at rest there is a hard tone seam along its
+ * bottom edge — flat paper above, textured ground below. It has to stay opaque,
+ * because content scrolls under it, so it takes the texture instead.
+ *
+ * background-attachment: fixed anchors all three layers to the viewport, the same
+ * frame body::before uses, so the noise tiles and both gradients line up rather
+ * than restarting inside the header. z-index: -1 inside .nav's own stacking
+ * context (position: sticky, z-index: 50) puts the overlay above the header's
+ * background and below its content, so the wordmark, the links and the filled
+ * button are not multiplied along with it. */
+.nav::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+ background-image: ${TEXTURE_LAYERS};
+ background-size: auto, auto, 240px 240px;
+ background-attachment: fixed;
+ mix-blend-mode: multiply;
+ opacity: 0.92;
+}
+
/* With the interpuncts gone, the language switcher's 7px gap was doing all the
* separating and the four links ran together. */
.lang-switch { gap: 14px; }
@@ -490,6 +523,37 @@ html[lang='ja'] .work-copy h2 { line-height: 1.2; }
* swallowed the rule. It has to keep shrinking to its own glyphs. */
.method-step .num { align-self: flex-start; }
+/* …and that hole was visible as a pale box behind every numeral.
+ *
+ * The page's ground is not the paper colour. body::before is a fixed noise-and-
+ * gradient layer at z-index 1, multiply, 0.92 — and .shell sits at z-index 2, so
+ * everything you read as "background" is paper seen through that texture, a few
+ * units darker and warmer. The numeral's knockout paints flat #efe7d2 inside
+ * .shell, above the texture, so it was the one patch of undarkened paper on the
+ * page. Nothing can match it back: the texture varies across the page, so no
+ * flat colour is right in more than one spot.
+ *
+ * So the hole goes and the rule stops short instead. Each step draws its own
+ * segment from a fixed 110px in — clear of the widest numeral, 95px, with air to
+ * spare — across the 50px gap into the next column. Segments start at the same
+ * x in every column, which is steadier than a line hugging four glyphs of
+ * different widths, and the last one stops at the grid edge. Above 880 only,
+ * which is where the export drew the rule at all. */
+.method-step .num { background: none; padding-right: 0; }
+@media (min-width: 881px) {
+ .method-grid::before { display: none; }
+ .method-step::after {
+ content: '';
+ position: absolute;
+ top: 60px;
+ right: -50px;
+ width: calc(100% - 110px);
+ height: 1px;
+ background: var(--line-soft);
+ }
+ .method-step:last-child::after { right: 0; width: calc(100% - 110px); }
+}
+
/* Each section rule is a three-part row — roman numeral, plate caption,
* counter — flexed with align-items:center. Below ~700px the caption wraps to
* two lines and the numeral centres itself against both of them, landing in the
@@ -637,6 +701,88 @@ html[lang='ja'] .work-copy h2 { line-height: 1.2; }
}
`;
+// Exact-string copy edits, per page language. Every pair must match exactly once
+// on its page or the build fails — these are sentences, and a near-miss would
+// silently ship half an edit. See the copy-edits patch for what each one is for.
+const SPAN = "evidence_span";
+// The spec strip closed four fragments with four full stops, none of them a
+// sentence — the same tic as the interpuncts, with a different glyph. Spacing
+// separates them, the way it now does everywhere else on the page.
+const GAP = " ";
+const COPY_EDITS = {
+ en: [
+ [
+ `Every claim carries an ${SPAN}, a literal substring of the source. Click any relation and you land on the sentence it came from.`,
+ `Every claim carries an ${SPAN}, so clicking any relation lands you on the sentence it came from.`,
+ ],
+ ["Source text stays in its original script. Entity resolution runs on", "Entity resolution runs on"],
+ [
+ "The engineering was researched against Splink, ComEM and GraphRAG:",
+ "Reading Splink, ComEM and GraphRAG settled four things:",
+ ],
+ ["Pride and Prejudice, 西游记, Crime and Punishment", "Pride and Prejudice, Journey to the West, Crime and Punishment"],
+ [
+ "Closed world. Literal match. Multilingual. Resumable.",
+ `Closed world${GAP}Literal match${GAP}Multilingual${GAP}Resumable`,
+ ],
+ ],
+ "zh-CN": [
+ [
+ "闭世界。字面匹配。多语种。可续跑。",
+ `闭世界${GAP}字面匹配${GAP}多语种${GAP}可续跑`,
+ ],
+
+ [
+ `每条断言都带一个 ${SPAN},也就是原文里的一段字面文本。点开任意一条关系,就落到它出处的那一句。`,
+ `每条断言都带一个 ${SPAN},点开任意一条关系就落到它出处的那一句。`,
+ ],
+ ["源文保留原文字。实体消解跑在", "实体消解跑在"],
+ ["工程实现参考了 Splink、ComEM 与 GraphRAG:", "读 Splink、ComEM 与 GraphRAG 定下了四件事:"],
+ ],
+ ja: [
+ [
+ "閉世界。文字列一致。多言語。再開可能。",
+ `閉世界${GAP}文字列一致${GAP}多言語${GAP}再開可能`,
+ ],
+
+ [
+ `どの主張にも ${SPAN}、つまり原文そのままの文字列が付きます。関係をクリックすれば、その出典の一文に着きます。`,
+ `どの主張にも ${SPAN} が付くので、関係をクリックすればその出典の一文に着きます。`,
+ ],
+ ["原文はもとの文字体系のまま保つ。実体解決は", "実体解決は"],
+ [
+ "エンジニアリングは Splink・ComEM・GraphRAG を参照して設計した。",
+ "Splink・ComEM・GraphRAG を読んで、四つのことが決まった。",
+ ],
+ ],
+ fr: [
+ [
+ "Monde clos. Correspondance littérale. Multilingue. Reprise possible.",
+ `Monde clos${GAP}Correspondance littérale${GAP}Multilingue${GAP}Reprise possible`,
+ ],
+
+ [
+ `Chaque assertion porte un ${SPAN}, une sous-chaîne littérale de la source. Cliquez sur une relation, vous atterrissez sur la phrase dont elle vient.`,
+ `Chaque assertion porte un ${SPAN}, donc un clic sur une relation vous mène à la phrase dont elle vient.`,
+ ],
+ [
+ "Le texte source reste dans son écriture d’origine. La résolution d’entités tourne sur",
+ "La résolution d’entités tourne sur",
+ ],
+ // The export sets French punctuation properly: a narrow no-break space
+ // before the colon, U+202F, not a plain one. Escaped so it stays visible
+ // here, and kept in the replacement.
+ [
+ "L\u2019ing\u00e9nierie s\u2019appuie sur les travaux de Splink, ComEM et GraphRAG\u202f:",
+ "Lire Splink, ComEM et GraphRAG a r\u00e9gl\u00e9 quatre points\u202f:",
+ ],
+ [
+ "Orgueil et Préjugés, 西游记, Crime et Châtiment",
+ "Orgueil et Préjugés, La Pérégrination vers l’Ouest, Crime et Châtiment",
+ ],
+ ],
+};
+
const IMG_RE = /]*>/g;
// The stylesheet documents itself with prose, and that prose contains tag-shaped
@@ -898,6 +1044,119 @@ const PATCHES = [
return { html: out, count };
},
},
+ {
+ name: "copy-edits",
+ why: "one 10-word clause and one 7-word clause were each on the page twice, verbatim",
+ // The page carries 594 words of body copy across 24 blocks — lean for seven
+ // sections, so the problem was never length. It was repetition, and it was
+ // measurable: two clauses appeared twice each, word for word.
+ //
+ // "claim carries an evidence_span, a literal substring of the source"
+ // hero lead + the rule section's lead
+ // "source text stays in its original script"
+ // the alias card + the reference-set card
+ //
+ // Each clause stays where it belongs and goes everywhere else. The rule
+ // section owns the definition, because defining the rule is what that
+ // section is for; the hero states the consequence instead, which also folds
+ // its third sentence into its second — those two said the same thing, once
+ // abstractly and once concretely. The reference-set card owns "original
+ // script, nothing transliterated", because that is a fact about the corpus;
+ // the alias card was using it as a run-up to its own subject.
+ //
+ // Two more, not about repetition:
+ //
+ // The engineering lead promised one thing with its colon and delivered
+ // another — "researched against Splink, ComEM and GraphRAG:" followed by
+ // four features of LoreGraph, not four points of comparison. Naming what
+ // the list is fixes the grammar without touching the hedge: the project read
+ // those papers, it does not implement them.
+ //
+ // And the reference set localised four of its five titles per language but
+ // left 西游记 in Chinese on the English and French pages, while translating
+ // Crime and Punishment out of Russian on both. Five titles, two different
+ // rules. The other three pages localise all five, so the exception joins
+ // them. The English page still demonstrates CJK where it earns its place —
+ // "forget the Elizabeth Bennet or 孫悟空 it already knows" — so nothing is
+ // lost by not proving it with a book title.
+ //
+ // This belongs in the copy deck upstream, which is one JSON file of
+ // {en, zh, ja, fr} strings. It is here because the deck lives outside this
+ // repo; move it there and delete this patch.
+ run(html) {
+ // Landing pages only. The credits pages reuse the head and the chrome but
+ // carry none of this prose — and they do have a .lead of their own, so the
+ // hero section is the marker, not that class.
+ if (!html.includes("]*lang=['"]([^'"]+)/) || [])[1] || "en";
+ const pairs = COPY_EDITS[lang];
+ if (!pairs) throw new Error(`marketing-patch copy-edits: no edits for lang '${lang}'`);
+ let out = html;
+ let count = 0;
+ for (const [from, to] of pairs) {
+ const hits = out.split(from).length - 1;
+ if (hits !== 1) {
+ throw new Error(
+ `marketing-patch copy-edits (${lang}): expected exactly 1 match, found ${hits}:\n ${from}`,
+ );
+ }
+ out = out.split(from).join(to);
+ count++;
+ }
+ return { html: out, count };
+ },
+ },
+ {
+ name: "heading-levels",
+ why: "the outline jumped h2 to h4 three times, so heading navigation gave a broken tree",
+ // Nine h4s and four h5s sit directly under an h2 with no h3 between them:
+ // the five reading-room cards, the four pipeline steps, and the four footer
+ // columns. Someone navigating by heading gets a tree with two levels missing
+ // and no way to tell whether they have skipped something.
+ //
+ // An earlier pass left this alone on the grounds that the styling is bound to
+ // the tag name, so moving the tag would mean copying its declarations into
+ // the corrections sheet — a copy that goes stale silently the next time the
+ // export changes them. That was wrong: the stylesheet is inline in the same
+ // file this patch already rewrites, so the selector moves with the tag and
+ // nothing is duplicated.
+ run(html) {
+ let count = 0;
+ const selector = (from, to, expect) => {
+ const hits = html.split(from).length - 1;
+ if (hits !== expect) {
+ throw new Error(
+ `marketing-patch heading-levels: expected ${expect} of '${from}', found ${hits}`,
+ );
+ }
+ html = html.split(from).join(to);
+ count += hits;
+ };
+ selector(".lab h4", ".lab h3", 1);
+ selector(".method-step h4", ".method-step h3", 2);
+ selector(".method-step:last-child h4", ".method-step:last-child h3", 1);
+ selector(".foot-col h5", ".foot-col h3", 1);
+
+ // Tags only — mapMarkup steps over