From 5db886dce4ca5508c2eadd909f8e47872e521b9d Mon Sep 17 00:00:00 2001 From: Yunyue Li Date: Thu, 30 Jul 2026 17:13:04 +0800 Subject: [PATCH 1/8] refactor(web): give the corrections stylesheet and the copy edits their own files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit marketing-patch.cjs was 1547 lines, of which 640 were a CSS stylesheet inside a JS template literal and 74 were four languages of prose replacements inside a JS object. Neither is code. Both now sit next to the patch as what they are: marketing-corrections.css a stylesheet — lints, diffs and reads like one marketing-copy-edits.json the copy edits, keyed by language The shape matters because of where they are going. The generating side holds one copy deck, a JSON file of {en, zh, ja, fr} strings; the edits are now a file that can be merged into it rather than sentences to be lifted out of a build script. The stylesheet is a file the design side can take as it is. The patch reads both at build time and inlines them, so nothing the reader gets changes: on all nine built pages the only diff is this stylesheet's own header comment, rewritten because it told whoever found it to go and edit somewhere else. One assertion is new. The sticky header paints a copy of the paper texture so its bottom edge stops being a tone seam, and the original now lives in a different file from the copy. So the build compares the two rather than trusting them: an export that re-tuned its texture would otherwise leave the copy quietly mismatched, and the seam would come back one shade smaller and much harder to see. Verified by perturbing one gradient stop — the build fails and prints both. Co-Authored-By: Claude Opus 5 --- src/loregraph/web/marketing-copy-edits.json | 82 ++ src/loregraph/web/marketing-corrections.css | 656 ++++++++++++++++ src/loregraph/web/marketing-patch.cjs | 790 ++------------------ src/loregraph/web/marketing/README.md | 22 +- 4 files changed, 812 insertions(+), 738 deletions(-) create mode 100644 src/loregraph/web/marketing-copy-edits.json create mode 100644 src/loregraph/web/marketing-corrections.css diff --git a/src/loregraph/web/marketing-copy-edits.json b/src/loregraph/web/marketing-copy-edits.json new file mode 100644 index 0000000..49e493d --- /dev/null +++ b/src/loregraph/web/marketing-copy-edits.json @@ -0,0 +1,82 @@ +{ + "en": [ + [ + "Every claim carries an evidence_span, a literal substring of the source. Click any relation and you land on the sentence it came from.", + "Every claim carries an evidence_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   Literal match   Multilingual   Resumable" + ] + ], + "zh-CN": [ + [ + "闭世界。字面匹配。多语种。可续跑。", + "闭世界   字面匹配   多语种   可续跑" + ], + [ + "每条断言都带一个 evidence_span,也就是原文里的一段字面文本。点开任意一条关系,就落到它出处的那一句。", + "每条断言都带一个 evidence_span,点开任意一条关系就落到它出处的那一句。" + ], + [ + "源文保留原文字。实体消解跑在", + "实体消解跑在" + ], + [ + "工程实现参考了 Splink、ComEM 与 GraphRAG:", + "读 Splink、ComEM 与 GraphRAG 定下了四件事:" + ] + ], + "ja": [ + [ + "閉世界。文字列一致。多言語。再開可能。", + "閉世界   文字列一致   多言語   再開可能" + ], + [ + "どの主張にも evidence_span、つまり原文そのままの文字列が付きます。関係をクリックすれば、その出典の一文に着きます。", + "どの主張にも evidence_span が付くので、関係をクリックすればその出典の一文に着きます。" + ], + [ + "原文はもとの文字体系のまま保つ。実体解決は", + "実体解決は" + ], + [ + "エンジニアリングは Splink・ComEM・GraphRAG を参照して設計した。", + "Splink・ComEM・GraphRAG を読んで、四つのことが決まった。" + ] + ], + "fr": [ + [ + "Monde clos. Correspondance littérale. Multilingue. Reprise possible.", + "Monde clos   Correspondance littérale   Multilingue   Reprise possible" + ], + [ + "Chaque assertion porte un evidence_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 evidence_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" + ], + [ + "L’ingénierie s’appuie sur les travaux de Splink, ComEM et GraphRAG :", + "Lire Splink, ComEM et GraphRAG a réglé quatre points :" + ], + [ + "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" + ] + ] +} diff --git a/src/loregraph/web/marketing-corrections.css b/src/loregraph/web/marketing-corrections.css new file mode 100644 index 0000000..030de6d --- /dev/null +++ b/src/loregraph/web/marketing-corrections.css @@ -0,0 +1,656 @@ +/* --------------------------------------------------------------------------- + * Corrections to the generated marketing bundle. marketing-patch.cjs inlines + * this file into every page's , after the export's own stylesheet, so it + * wins on order without needing !important. + * + * This is the file to edit — but only for what the export gets wrong. Every rule + * below fixes something measured, and each one records the measurement. Nothing + * here restyles the design for its own sake: that belongs on the generating side. + * See marketing/README.md for what that side is and where each of these fixes + * should end up once it can be made there. + * + * Two things to know before adding a rule. The Chinese and Japanese pages carry + * their own adaptation block in the export — CJK font stacks, no synthetic + * oblique, 1.2 leading — and this file is appended after it, so anything set here + * that those pages tune has to re-state the CJK value or it silently undoes it. + * And the design's ground is paper seen through a fixed texture layer, so filling + * anything with the flat paper colour makes it the one undarkened patch on the + * page. + * ------------------------------------------------------------------------ */ + +/* The hero carries two rows of furniture under the copy: three buttons, which + * need 545px to sit on one line, and three stats, which need 524px. The exported + * two-column grid gives the copy column 414px at 1200 and 466px at 1440, so it + * cannot hold either row at any desktop width — and the export's own + * flex-wrap:nowrap did not fit them, it hid the overflow: the third button and + * the third stat slid under the plate panel, which paints after them, and were + * cut in half. + * + * Releasing the wrap instead surfaced the same shortfall as a ragged 2 + 1, four + * rows of furniture where the design has two. Neither setting can win, because + * the column is too narrow either way. So the column gets the width: + * + * ≤ 700px the rows genuinely do not fit on one line — wrap them. Without + * this the stats' 524px min-content floors the whole document and + * every phone scrolls sideways, which is where this started. + * ≤ 1100px one column, the copy at full container width. Both rows fit with + * room to spare and the plate stacks beneath, which is what the + * export already does below 880. + * > 1100px two columns again, the copy floored at 560px so the rows always + * fit, the plate taking what is left. */ +@media (max-width: 1100px) { + .hero-grid { grid-template-columns: minmax(0, 1fr); } +} +@media (min-width: 1101px) { + .hero-grid { grid-template-columns: minmax(560px, 1fr) 1fr; } +} +@media (max-width: 700px) { + .hero-stats { flex-wrap: wrap; row-gap: 16px; } + .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: 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,"); + 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; } + +/* The switcher now lives in the header instead of on its own strip. It has to + * earn its 211px there, and the header was already tight: at 1281 the brand's + * second line reappears and the row needs 1345px against 1201px of content. + * + * The second line is what goes. "Apache-2.0 / evidence-anchored graphs" repeats + * the hero label sitting 120px below it and the licence line in the footer, and + * it is the single widest thing in the header at 227px. Without it the row needs + * 1100px, which clears from about 1180 up; between 1081 and 1180 the repository + * pill stands down too, the same way it already does below 1080. */ +.brand-meta { display: none; } +@media (max-width: 1240px) { + .nav-cta { display: none; } +} +.lang-switch { + display: inline-flex; + align-items: center; + font-family: var(--sans); + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-mute); + white-space: nowrap; + flex-shrink: 0; +} +.lang-switch b { color: var(--ink); font-weight: 600; } + +/* Nothing declares scroll-margin-top, and the header is sticky, so every anchor + * jump lands the target's top at viewport 0 — behind the header. On a phone all + * four section links bury the section's own rule and label under 158px of + * chrome; on desktop it is "back to top" that lands 60px under. The links were + * hidden below 1080 until this pass, so the phone case never showed. + * + * The offsets are the header's own measured heights plus 15px of air: 85px above + * 1080, 140 for the two-row band, 158 for the three-row one. */ +:target, +section[id], +[id='top'], +[id='agents'], +[id='labs'], +[id='contact'] { scroll-margin-top: 100px; } +@media (max-width: 1080px) { + :target, + section[id], + [id='top'], + [id='agents'], + [id='labs'], + [id='contact'] { scroll-margin-top: 156px; } +} +@media (max-width: 620px) { + :target, + section[id], + [id='top'], + [id='agents'], + [id='labs'], + [id='contact'] { scroll-margin-top: 174px; } +} + +/* Below 1080 the header is already two rows — brand and actions, then the + * section links. The switcher needs 228px of its own, which at 390 leaves 96px + * for the links if it shares their row and nothing at all if it shares the + * first. So it takes a third row from 620 down, right-aligned under the links. + * Three rows, one job each, and still one band fewer than the strip it + * replaced — that was two rows of its own on a phone, plus the header's. */ +@media (max-width: 1080px) { + .nav-inner { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: + 'brand . side' + 'links links lang'; + align-items: center; + row-gap: 12px; + } + .nav-inner > .brand { grid-area: brand; } + .nav-inner > .nav-side { grid-area: side; } + .nav-inner > nav { grid-area: links; min-width: 0; } + .nav-inner > .lang-switch { grid-area: lang; justify-self: end; padding-top: 11px; } +} +@media (max-width: 620px) { + .nav-inner { + grid-template-areas: + 'brand . side' + 'links links links' + 'lang lang lang'; + } + .nav-inner > .lang-switch { justify-self: start; padding-top: 0; } + /* Three rows at 22px of padding is a 178px sticky header on a 844px phone, + * 48px more sticky chrome than the two bands this replaced. The rows are the + * price of four languages and a real call to action on a 342px line; the + * padding is not. */ + .nav { padding: 12px 0; } + .nav-inner { row-gap: 10px; } +} + +/* The corpus section's 90px against every other section's 130px is not an + * inconsistency and is deliberately left alone: it wraps the dark panel, which + * carries 110px of its own, so 90 + 110 comes out ahead of a plain 130. The + * class is called .tight for that reason. + * + * The radii are a system: 50% for every ring, 999px for every pill, + * 18px for both card types, 12px for the image frame inside a card, 4px for the + * small chips, and 32px for the one big panel. Two values sit 2px off the family + * they belong to and nowhere else — an image frame at 14 and a chip at 6. */ +.lab-img { border-radius: 12px; } +.hero-art .index { border-radius: 4px; } + +/* The bibliography row moved here from the section that used to restate the rule. + * It was laid out inside a narrow copy column; here it spans the section, so it + * gets its own breathing room and the partner list can use the width. */ +.merged-bibliography { margin-top: 64px; } +.merged-bibliography .partners-text { max-width: 58ch; } +.merged-bibliography .partners { margin-top: 22px; } +.merged-bibliography .read-more { display: inline-block; margin-top: 26px; } + +/* The app entry, styled from .nav-cta's own declarations but deliberately NOT + * given that class: the export's star-count script does + * querySelector('a.nav-cta:not(.ghost)') and rewrote this button's label to + * "Star · 5" the first time it shared it. Its own class also keeps it out of the + * rule that hides .nav-cta below 1080px, which is how a phone ended up with no + * navigation and no way into the app at all. */ +.nav-app { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + border-radius: 999px; + background: var(--ink); + color: var(--paper); + font-family: var(--sans); + font-size: 13px; + font-weight: 500; + text-decoration: none; + white-space: nowrap; + flex-shrink: 0; +} +.nav-app:hover { background: #2a2620; } + +/* With the app filled and primary, the repository steps back to outlined. Its + * ★ was mustard on the dark fill; on paper that is 1.48:1, so it takes the ink + * tone instead. */ +.nav-cta-repo { + background: transparent; + color: var(--ink); + border: 1px solid rgba(21, 20, 15, 0.22); +} +.nav-cta-repo:hover { background: rgba(21, 20, 15, 0.05); } +.nav-cta-repo::after { color: var(--ink-mute); } + +/* The count is appended after the label and the ★ comes from ::after, so the + * button read "Star on GitHub 5 ★" with the number stranded between the two + * things it belongs to. Both are flex items, so order puts the star back in + * front of its own number. */ +.nav-cta::after { order: 1; } +.nav-cta [data-github-stars] { order: 2; margin-left: -12px; } + +/* The top bar is a space-between row whose children are all white-space:nowrap, + * so below ~430px the language switcher simply hung off the right edge — 117px + * past it at 360. */ +@media (max-width: 880px) { + .topbar-inner { flex-wrap: wrap; gap: 6px 14px; } + .topbar-inner .right { flex-wrap: wrap; gap: 6px 12px; } + .lang-switch { flex-wrap: wrap; } +} + +/* These four frames took their width from their content, which is one lazy + * plate. Until that plate loads its intrinsic size is 0x0, so the frame + * collapsed to 0 wide — and each frame anchors an absolutely-positioned caption, + * which then had nothing to be positioned against and printed straight over the + * body copy in the next column. That is the overlap of + * "Closed-world extraction: every claim goes back to a line in the book" across + * "It is told, explicitly, to forget the Elizabeth Bennet it already knows." + * + * Sizing them from the column instead of from the image fixes the cause: the + * frame is the right size before the plate arrives, so the caption has a box to + * sit in and the plate fades into a space already reserved for it. (The earlier + * pass was wrong to conclude no image needed dimensions — true for .lab-img and + * the card frames, which set their own aspect-ratio, false for these four.) */ +.about-art, +.capabilities-art, +.cta-art, +.testimonial-art { width: 100%; max-width: 100%; } + +/* The footer keeps three columns down to the smallest phone. Their min-content + * adds to 292px inside a 278px track once the two 40px gaps are taken, so the + * last column hung 27px off the edge at 360. */ +@media (max-width: 560px) { + .foot-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 30px 24px; } +} + +/* At 320 the header cannot hold the wordmark and two pills — 412px of content in + * 288px. The app button is the one that matters there; the repository is still + * linked from the top bar and the footer. The hero's install line does not break + * either, partly because of a non-breaking space and 0.18em of tracking. */ +@media (max-width: 400px) { + .foot-grid { grid-template-columns: minmax(0, 1fr); } + .nav-cta-repo { display: none; } + /* Uppercase tracked labels, the section rules and inline code are all + * unbreakable runs: "uv sync -> loregraph extract" and "evidence_span" are + * single tokens as far as the line breaker is concerned. */ + .meta, + .sec-rule, + .topbar-inner > span, + .topbar-inner .right > span, + .topbar-inner .right > a { white-space: normal; overflow-wrap: anywhere; letter-spacing: 0.1em; } + .sec-rule .meta-grp { flex-wrap: wrap; gap: 4px 12px; } + .code-inline { overflow-wrap: anywhere; } + + /* Everything above this fixes a named cause. This is the backstop for 320px, + * a width nothing in the export was laid out for: releasing every remaining + * nowrap (the stylesheet sets no pre / pre-wrap anywhere, so nothing depends + * on preserved whitespace) and clipping the shell, because a page that scrolls + * sideways is worse than one whose ornament is cropped. Without it each fix + * just uncovers the next unbreakable label. */ + [class] { white-space: normal; } + .shell * { overflow-wrap: anywhere; } + .shell { overflow-x: clip; } + + /* …except the short atomic marks, which that blanket rule mangled: the roman + * numeral "I." broke between the letter and the period, and the section + * counter "001 / 008" split across two lines. None of these is long enough to + * overflow anything. */ + .roman, + .dot-mark, + .sec-rule > span:last-child, + .work-rule > span:last-child, + .hero-stats .stat-label b { white-space: nowrap; } + /* 320px (an SE) leaves 288px of content, and the wordmark plus the app pill + * plus two 18px gaps plus the status dot came to exactly that or more. The dot + * is ornament and goes; the gaps tighten. */ + .nav-inner { gap: 12px; } + .nav-side { gap: 10px; } + .nav-side .status-dot { display: none; } +} + +@media (max-width: 1080px) { + /* Rotated ornament pinned 32-42px outside the art frame, with no margin to + * hang it in below desktop. Hiding it also clears a real overlap: the closing + * section's "VIII" numeral sat on top of the "LOREGRAPH · APACHE-2.0" ribbon. */ + .capabilities-art .ribbon, + .cta-art .ribbon { display: none; } +} + +/* NOTHING here repositions .about-side-note, and that is deliberate. An earlier + * pass made it position:static below 1080px to stop it hanging 20px past the + * viewport — but that 20px was measured mid-transition, while the reveal was + * still animating, and does not exist in the settled layout. The rule was + * therefore fixing nothing, and it dropped the note into .about-art's flow + * directly on top of the absolutely-positioned .about-caption: four overlapping + * text runs, "It is told, explicitly, to forget the Elizabeth Bennet it already + * knows" printed straight through "Closed-world extraction: every claim goes + * back to a line in the book." Measure the settled layout, and measure overlap + * as well as overflow. */ + +/* White on the bright coral is 2.99:1, under the 4.5:1 floor — and these are + * the two buttons carrying the page's main actions. The darker tone reads the + * same and measures 5.66:1. */ +.btn-primary, +.btn-app { + background: var(--coral-text); + box-shadow: 0 14px 26px -16px rgba(164, 77, 63, 1); +} +.btn-primary:hover, +.btn-app:hover { background: #8f4235; transform: translateY(-1px); } + +/* The other three places white type sits on the bright fill: the active filter + * pill, the card's hover mark and the section's active arrow. */ +.pill.active, +.card:hover .arrow-mark, +.work-arrows .nav-btn.active { background: var(--coral-text); border-color: var(--coral-text); } + +@media (max-width: 880px) { + /* 28px arrow buttons are well under the 44px touch minimum, and they are the + * links into the app. Grown on touch widths only; the desktop mark is a + * deliberate size in a deliberate corner. */ + .card .arrow-mark, + .lab .arrow-mark { width: 44px; height: 44px; } + .card .arrow-mark svg, + .lab .arrow-mark svg { width: 14px; height: 14px; } +} + +@media (max-width: 880px) { + /* The language switcher's four links were 13-32px wide and 15px tall. */ + .topbar-inner .lang-switch a, + .topbar-link { display: inline-flex; align-items: center; min-height: 34px; padding: 0 5px; } +} + +/* The small end of the scale ran 9 / 9.5 / 10 / 10.5 / 11 / 11.5 / 12 — seven + * sizes inside three pixels, which is not a scale, just seven components that + * fail to match. An earlier pass floored them at 11px on phones only, on the + * reasoning that the desktop scale was deliberate. It is not: 9px is below the + * legibility floor on any screen, and the two smallest are carrying real + * content, not ornament — .about-caption is the museum credit for the plate and + * .about-side-note states the closed-world rule. Same floor everywhere. */ +.topbar-inner, +.about-caption, +.card .num .tag, +.lab-img .badge, +.annot, +.annot.coord, +.brand-meta, +.coord, +.partner small, +.pill .count, +.about-side-note, +.capabilities-art .ribbon, +.cta-art .ribbon, +.hero-art .index, +.lab .num-row, +.labs-meta .meta-text, +.meta, +.credits-foot, +.plate-head, +.plate-slot, +.sec-rule, +.side-rail .rail-text, +.wire-item .wire-coord, +.wire-item .wire-role, +.wire-title span, +.work-card .small-label, +.work-rule { font-size: 11px; } + +/* The four superscript counters on the nav links go, at every width. + * + * They cannot be decoded. 04 counts the engineering cards, 05 the reading-room + * views, 08 the pipeline passes, 85 the corpus works — four different units, no + * labels, so 85 reads as the same kind of number as 04. Only four of the five + * links carry one, so it is not a system either. And every count is already + * stated where it means something: the hero's stats row, "five views" in the + * reading room, the numbered passes themselves. + * + * They also cost the nav its rhythm. Each one is absolutely positioned at + * right: -16px, outside its link's box and inside the following gap, so the + * spacing between links reads as uneven. Removing them makes the 28px gap the + * real gap, and makes the desktop nav match the phone one, where an earlier + * pass had already hidden them. */ +.nav-links a .num { display: none; } + +/* Seven section headlines came out at seven sizes — 64, 66, 68, 72, 74.9, 77.8 + * and 95px at 1440 — because each one got its own clamp() ramp: three of them + * differ only in the vw coefficient with identical endpoints. The 95px one is a + * section heading set larger than the page's own h1, which inverts the + * hierarchy. Two ramps replace the twelve: one for the hero, one for sections, + * so the h1 is the largest thing on the page and the seven sections match. */ +.hero h1, +h1.display { font-size: clamp(40px, 5.2vw, 74px); } +.section-header h2, +.about h2.display, +.capabilities h2.display, +.labs-head h2, +.method-head h2, +.work-copy h2, +.cta h2.display { font-size: clamp(30px, 4.1vw, 56px); } + +/* The closing wordmark is nowrap inside an overflow-hidden band, and its clamp + * floors at 70px — which needs 300px for "LoreGraph." against 272px of content + * at 320. The brand name was cropped to "LoreGrap". Nothing else on the page + * bleeds, so this was the floor being wrong rather than a deliberate crop. */ +.foot-mega .word { font-size: clamp(58px, 13vw, 200px); } + +/* line-height equal to font-size, so the descenders of one line and the + * ascenders of the next clear each other by 4.1px at 72px — 0.06em, which at + * display size reads as touching. + * + * The number is set by ink, not by the em box. Inter Tight descends 0.204em and + * Playfair's italic ascends 0.795em, so a line of one over a line of the other + * spends 0.999em before any gap exists at all — which is why 1.06 still left + * 3.4px between the "p" of "graph" and the "h" of "relationship". 1.12 leaves + * about 0.12em, and that is the whole reason the value looks loose for a + * display size: it is paying for the second typeface. */ +.hero h1, +h1.display, +h2.display, +.labs-head h2, +.work-copy h2, +.section-header h2 { line-height: 1.12; } + +/* Except on the Chinese and Japanese pages. Their own adaptation block sets + * .display to 1.2, because a Han or Kana glyph fills its em box where a Latin + * lowercase fills half of it, and this correction is appended after that block + * — so without saying so it would undo it. 1.2 also reaches the one heading + * that adaptation missed, the corpus h2, which carries no .display class and + * was still at 1.0. */ +html[lang='zh-CN'] .display, +html[lang='zh-CN'] .work-copy h2, +html[lang='ja'] .display, +html[lang='ja'] .work-copy h2 { line-height: 1.2; } + +/* The four steps stack number, title, copy and plate in flow, and the four + * copy blocks are 4 to 7 lines long, so the four plates landed at four + * different heights — a 63px spread across a row that is otherwise aligned to + * the pixel. The steps are already equal-height grid items; the plate just has + * to sit at the bottom of one. */ +.method-step { display: flex; flex-direction: column; } +.method-step .img { margin-top: auto; } +/* The numeral is an inline-block painted in the paper colour so it knocks a hole + * in the rule threaded across the row. Turning the step into a flex column made + * it a flex item, which stretches — so the hole became the full column width and + * 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 + * gap between them and hard against the caption's first character. Aligning the + * numeral to the caption's first baseline puts it back on a line of text. */ +.sec-rule { align-items: baseline; column-gap: 18px; } + +/* The rule is three unequal items in a space-between row — numeral, plate + * caption, counter — so the caption lands wherever the two ends leave it: 26px + * left of the band's centre at 1150, and a different amount at every width. It + * is neither centred nor on a column edge, six times down the page. A three + * column grid with equal outer tracks puts it on the centre line and keeps it + * there. The outer tracks are minmax(0, 1fr) so a long caption can still push + * into them rather than overflow. + * + * Only from 700 up. Below that the caption is wider than the third of the band + * a centred layout would leave it, the outer minmax(0, 1fr) tracks collapse to + * nothing, and the counter ends up printed over "THE MET". There is no centre + * to find on a phone — the export's own space-between, with the caption allowed + * to wrap, is the right behaviour there. */ +@media (min-width: 700px) { + .sec-rule { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + } + .sec-rule > :first-child { justify-self: start; } + .sec-rule > :last-child { justify-self: end; } + .sec-rule .meta-grp { justify-self: center; } +} + +/* This row summarises the whole section, but it sits inside the left column of + * the section's two-column grid — 495px, and its three children need exactly + * that, so every one of them is at its minimum width. The four claims wrap to + * two ragged lines and "One rule / ≥ 95%" is crushed into 58px, which is three + * words over five lines. Letting the row wrap gives the claims their own line, + * and the stamp keeps its two parts whole. */ +.about .footer-row { flex-wrap: wrap; row-gap: 16px; } +.about .footer-row > span:not(.mark) { letter-spacing: 0.08em; } +/* The stamp is a two-line stack by design — "One rule" over "≥ 95%" — but at + * 0.18em it needed 90px for the first line inside the 57px the squeezed row + * gave it, so that line broke too and the stamp came out as three. It keeps its + * two lines and stops shrinking. */ +.about .footer-row .stamp { flex: 0 0 auto; white-space: nowrap; } + +/* The five section links were never given nowrap, so whenever the header runs + * short of room they break inside the word: "Reading / room", "Quick / start". + * Above 1280 the brand's second line reappears and takes the room, which is the + * band where it showed. Links are labels, not copy — they do not wrap. The 38px + * gap pays for it; 28px is still twice the word space. */ +.nav-links a { white-space: nowrap; } +@media (min-width: 1081px) { + .nav-links { gap: 28px; } +} + +/* The five filter pills need 522px and their grid column was 488px, so + * "Catalogue" wrapped alone onto a second row while 684px of headline column + * sat half empty beside it. The headline is smaller now and needs less of the + * split, and the pills themselves come down from 18px of side padding to 14, + * which is 48px off the row. Together that fits them on one line from about + * 1200px up. Below that they still wrap — the column cannot be widened far + * enough without starving the headline — but flex-end keeps the second row + * flush right instead of ragged. */ +@media (min-width: 1101px) { + .labs-head { grid-template-columns: 1.15fr 1fr; } + .pills { gap: 8px; } + .pill { padding: 9px 14px; } +} + +/* 16 characters wide, right-aligned, absolutely positioned over the plate: five + * ragged lines averaging three words, two of them a single word. It states the + * closed-world rule, which is the section's whole argument. Widened to a + * readable measure — still right-aligned against the plate's edge, which is the + * design, but no longer a classified ad. */ +.about-side-note { max-width: 30ch; } + +/* Same shape: a full sentence set in tracked uppercase inside 28 characters, + * breaking as "READER, GRAPH," / "TIMELINE," / "INDEX AND ASK ALL READ" / "THE + * SAME GRAPH." Caps at 0.18em need roughly twice the measure of lowercase. */ +.labs-meta .meta-text { max-width: 44ch; letter-spacing: 0.08em; } + +/* Inline code is a background plus 6px of horizontal padding on an inline box, + * which does two things to the prose it sits in. It breaks across lines, so + * "loregraph ingest" shipped as a chip fragment ending mid-air; and the padding + * puts a 6px space between the chip and any punctuation that follows it, so the + * quick-start paragraph read "uv sync , then" and "loregraph extract . One" — + * a space before a comma, four times in four lines. The nowrap fixes the first; + * the second needs the punctuation pulled back over the padding, which is what + * tuck-punctuation marks up. */ +.code-inline { white-space: nowrap; } +.punct-tuck { font-style: normal; margin-left: -6px; } +.code-inline.sm + .punct-tuck { margin-left: -4px; } +/* Except at 320-400px, where "loregraph extract" and "multilingual-e5-large" + * are wider than the column and a chip that cannot break is a chip that + * overflows. The 400px block above already releases every other nowrap for the + * same reason; this rule comes after it, so it has to say so itself. */ +@media (max-width: 400px) { + .code-inline { white-space: normal; overflow-wrap: anywhere; } +} + +/* A comma set in 800-weight Inter Tight immediately after an italic Playfair + * word: "Strict about evidence, relaxed". It reads as a much heavier + * mark than the word it belongs to. Only commas are re-set — the full stops + * after an em are the coral and black terminal dots, which are deliberate. */ +.em-punct { font-family: var(--serif); font-style: italic; font-weight: 500; } + +/* The header drops all five section links below 1080px and puts nothing in + * their place: no menu, no button, no anchors — on every tablet and phone the + * only way to any section was to scroll the whole 17,000px page. They come back + * as their own row under the wordmark, scrolling sideways if they have to, + * which needs no menu state and no script. The superscript counters go: they + * are ornament, and they are what makes each link too wide. */ +@media (max-width: 1080px) { + .nav-inner { flex-wrap: wrap; row-gap: 12px; } + .nav-inner > nav { order: 3; width: 100%; } + .nav-links { + display: flex; + gap: 22px; + list-style: none; + margin: 0; + padding: 11px 0 0; + border-top: 1px solid var(--line-soft); + overflow-x: auto; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + /* Five links need 407px and a 390px phone gives the row 358px, so the last + * one is partly off the end. The fade says so; without it a half-drawn + * "Quick start" just looks broken. */ + mask-image: linear-gradient(90deg, #000 calc(100% - 40px), transparent); + -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 40px), transparent); + } + .nav-links::-webkit-scrollbar { display: none; } + .nav-links li { flex: 0 0 auto; } + .nav-links a { white-space: nowrap; } +} + +/* Below 1080px the footer hid its fourth and fifth columns outright, so every + * tablet and phone lost the repository, the issue tracker, the changelog and + * the licence. The grid already reflows to two columns at 560 and one at 400; + * it never needed to drop the content. */ +@media (max-width: 1080px) { + .foot-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 40px 32px; } + .foot-grid .foot-col:nth-child(4), + .foot-grid .foot-col:nth-child(5) { display: block; } +} diff --git a/src/loregraph/web/marketing-patch.cjs b/src/loregraph/web/marketing-patch.cjs index d4d5154..007d01d 100644 --- a/src/loregraph/web/marketing-patch.cjs +++ b/src/loregraph/web/marketing-patch.cjs @@ -55,741 +55,61 @@ function appLabel(html) { return APP_LABEL[lang] || APP_LABEL.en; } -// 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. +// Every rule the corrections stylesheet carries fixes something measured, and +// nothing in it restyles the design for its own sake. It is authored as real CSS +// in marketing-corrections.css, so it lints, diffs and reads like a stylesheet +// instead of like a 640-line string inside a build script — and so the design +// side can take the file as it is. The build inlines it into each page's , +// after the export's own stylesheet, so it wins on order without needing +// !important. +const CORRECTIONS = fs.readFileSync(path.join(__dirname, "marketing-corrections.css"), "utf8"); + // 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 +// 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 - * there, not here; this block is generated. - * ------------------------------------------------------------------------ */ - -/* The hero carries two rows of furniture under the copy: three buttons, which - * need 545px to sit on one line, and three stats, which need 524px. The exported - * two-column grid gives the copy column 414px at 1200 and 466px at 1440, so it - * cannot hold either row at any desktop width — and the export's own - * flex-wrap:nowrap did not fit them, it hid the overflow: the third button and - * the third stat slid under the plate panel, which paints after them, and were - * cut in half. - * - * Releasing the wrap instead surfaced the same shortfall as a ragged 2 + 1, four - * rows of furniture where the design has two. Neither setting can win, because - * the column is too narrow either way. So the column gets the width: - * - * ≤ 700px the rows genuinely do not fit on one line — wrap them. Without - * this the stats' 524px min-content floors the whole document and - * every phone scrolls sideways, which is where this started. - * ≤ 1100px one column, the copy at full container width. Both rows fit with - * room to spare and the plate stacks beneath, which is what the - * export already does below 880. - * > 1100px two columns again, the copy floored at 560px so the rows always - * fit, the plate taking what is left. */ -@media (max-width: 1100px) { - .hero-grid { grid-template-columns: minmax(0, 1fr); } -} -@media (min-width: 1101px) { - .hero-grid { grid-template-columns: minmax(560px, 1fr) 1fr; } -} -@media (max-width: 700px) { - .hero-stats { flex-wrap: wrap; row-gap: 16px; } - .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; } - -/* The switcher now lives in the header instead of on its own strip. It has to - * earn its 211px there, and the header was already tight: at 1281 the brand's - * second line reappears and the row needs 1345px against 1201px of content. - * - * The second line is what goes. "Apache-2.0 / evidence-anchored graphs" repeats - * the hero label sitting 120px below it and the licence line in the footer, and - * it is the single widest thing in the header at 227px. Without it the row needs - * 1100px, which clears from about 1180 up; between 1081 and 1180 the repository - * pill stands down too, the same way it already does below 1080. */ -.brand-meta { display: none; } -@media (max-width: 1240px) { - .nav-cta { display: none; } -} -.lang-switch { - display: inline-flex; - align-items: center; - font-family: var(--sans); - font-size: 11px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--ink-mute); - white-space: nowrap; - flex-shrink: 0; -} -.lang-switch b { color: var(--ink); font-weight: 600; } - -/* Nothing declares scroll-margin-top, and the header is sticky, so every anchor - * jump lands the target's top at viewport 0 — behind the header. On a phone all - * four section links bury the section's own rule and label under 158px of - * chrome; on desktop it is "back to top" that lands 60px under. The links were - * hidden below 1080 until this pass, so the phone case never showed. - * - * The offsets are the header's own measured heights plus 15px of air: 85px above - * 1080, 140 for the two-row band, 158 for the three-row one. */ -:target, -section[id], -[id='top'], -[id='agents'], -[id='labs'], -[id='contact'] { scroll-margin-top: 100px; } -@media (max-width: 1080px) { - :target, - section[id], - [id='top'], - [id='agents'], - [id='labs'], - [id='contact'] { scroll-margin-top: 156px; } -} -@media (max-width: 620px) { - :target, - section[id], - [id='top'], - [id='agents'], - [id='labs'], - [id='contact'] { scroll-margin-top: 174px; } -} - -/* Below 1080 the header is already two rows — brand and actions, then the - * section links. The switcher needs 228px of its own, which at 390 leaves 96px - * for the links if it shares their row and nothing at all if it shares the - * first. So it takes a third row from 620 down, right-aligned under the links. - * Three rows, one job each, and still one band fewer than the strip it - * replaced — that was two rows of its own on a phone, plus the header's. */ -@media (max-width: 1080px) { - .nav-inner { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - grid-template-areas: - 'brand . side' - 'links links lang'; - align-items: center; - row-gap: 12px; - } - .nav-inner > .brand { grid-area: brand; } - .nav-inner > .nav-side { grid-area: side; } - .nav-inner > nav { grid-area: links; min-width: 0; } - .nav-inner > .lang-switch { grid-area: lang; justify-self: end; padding-top: 11px; } -} -@media (max-width: 620px) { - .nav-inner { - grid-template-areas: - 'brand . side' - 'links links links' - 'lang lang lang'; - } - .nav-inner > .lang-switch { justify-self: start; padding-top: 0; } - /* Three rows at 22px of padding is a 178px sticky header on a 844px phone, - * 48px more sticky chrome than the two bands this replaced. The rows are the - * price of four languages and a real call to action on a 342px line; the - * padding is not. */ - .nav { padding: 12px 0; } - .nav-inner { row-gap: 10px; } -} - -/* The corpus section's 90px against every other section's 130px is not an - * inconsistency and is deliberately left alone: it wraps the dark panel, which - * carries 110px of its own, so 90 + 110 comes out ahead of a plain 130. The - * class is called .tight for that reason. - * - * The radii are a system: 50% for every ring, 999px for every pill, - * 18px for both card types, 12px for the image frame inside a card, 4px for the - * small chips, and 32px for the one big panel. Two values sit 2px off the family - * they belong to and nowhere else — an image frame at 14 and a chip at 6. */ -.lab-img { border-radius: 12px; } -.hero-art .index { border-radius: 4px; } - -/* The bibliography row moved here from the section that used to restate the rule. - * It was laid out inside a narrow copy column; here it spans the section, so it - * gets its own breathing room and the partner list can use the width. */ -.merged-bibliography { margin-top: 64px; } -.merged-bibliography .partners-text { max-width: 58ch; } -.merged-bibliography .partners { margin-top: 22px; } -.merged-bibliography .read-more { display: inline-block; margin-top: 26px; } - -/* The app entry, styled from .nav-cta's own declarations but deliberately NOT - * given that class: the export's star-count script does - * querySelector('a.nav-cta:not(.ghost)') and rewrote this button's label to - * "Star · 5" the first time it shared it. Its own class also keeps it out of the - * rule that hides .nav-cta below 1080px, which is how a phone ended up with no - * navigation and no way into the app at all. */ -.nav-app { - display: inline-flex; - align-items: center; - gap: 10px; - padding: 9px 16px; - border-radius: 999px; - background: var(--ink); - color: var(--paper); - font-family: var(--sans); - font-size: 13px; - font-weight: 500; - text-decoration: none; - white-space: nowrap; - flex-shrink: 0; -} -.nav-app:hover { background: #2a2620; } - -/* With the app filled and primary, the repository steps back to outlined. Its - * ★ was mustard on the dark fill; on paper that is 1.48:1, so it takes the ink - * tone instead. */ -.nav-cta-repo { - background: transparent; - color: var(--ink); - border: 1px solid rgba(21, 20, 15, 0.22); -} -.nav-cta-repo:hover { background: rgba(21, 20, 15, 0.05); } -.nav-cta-repo::after { color: var(--ink-mute); } - -/* The count is appended after the label and the ★ comes from ::after, so the - * button read "Star on GitHub 5 ★" with the number stranded between the two - * things it belongs to. Both are flex items, so order puts the star back in - * front of its own number. */ -.nav-cta::after { order: 1; } -.nav-cta [data-github-stars] { order: 2; margin-left: -12px; } - -/* The top bar is a space-between row whose children are all white-space:nowrap, - * so below ~430px the language switcher simply hung off the right edge — 117px - * past it at 360. */ -@media (max-width: 880px) { - .topbar-inner { flex-wrap: wrap; gap: 6px 14px; } - .topbar-inner .right { flex-wrap: wrap; gap: 6px 12px; } - .lang-switch { flex-wrap: wrap; } -} - -/* These four frames took their width from their content, which is one lazy - * plate. Until that plate loads its intrinsic size is 0x0, so the frame - * collapsed to 0 wide — and each frame anchors an absolutely-positioned caption, - * which then had nothing to be positioned against and printed straight over the - * body copy in the next column. That is the overlap of - * "Closed-world extraction: every claim goes back to a line in the book" across - * "It is told, explicitly, to forget the Elizabeth Bennet it already knows." - * - * Sizing them from the column instead of from the image fixes the cause: the - * frame is the right size before the plate arrives, so the caption has a box to - * sit in and the plate fades into a space already reserved for it. (The earlier - * pass was wrong to conclude no image needed dimensions — true for .lab-img and - * the card frames, which set their own aspect-ratio, false for these four.) */ -.about-art, -.capabilities-art, -.cta-art, -.testimonial-art { width: 100%; max-width: 100%; } - -/* The footer keeps three columns down to the smallest phone. Their min-content - * adds to 292px inside a 278px track once the two 40px gaps are taken, so the - * last column hung 27px off the edge at 360. */ -@media (max-width: 560px) { - .foot-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 30px 24px; } -} - -/* At 320 the header cannot hold the wordmark and two pills — 412px of content in - * 288px. The app button is the one that matters there; the repository is still - * linked from the top bar and the footer. The hero's install line does not break - * either, partly because of a non-breaking space and 0.18em of tracking. */ -@media (max-width: 400px) { - .foot-grid { grid-template-columns: minmax(0, 1fr); } - .nav-cta-repo { display: none; } - /* Uppercase tracked labels, the section rules and inline code are all - * unbreakable runs: "uv sync -> loregraph extract" and "evidence_span" are - * single tokens as far as the line breaker is concerned. */ - .meta, - .sec-rule, - .topbar-inner > span, - .topbar-inner .right > span, - .topbar-inner .right > a { white-space: normal; overflow-wrap: anywhere; letter-spacing: 0.1em; } - .sec-rule .meta-grp { flex-wrap: wrap; gap: 4px 12px; } - .code-inline { overflow-wrap: anywhere; } - - /* Everything above this fixes a named cause. This is the backstop for 320px, - * a width nothing in the export was laid out for: releasing every remaining - * nowrap (the stylesheet sets no pre / pre-wrap anywhere, so nothing depends - * on preserved whitespace) and clipping the shell, because a page that scrolls - * sideways is worse than one whose ornament is cropped. Without it each fix - * just uncovers the next unbreakable label. */ - [class] { white-space: normal; } - .shell * { overflow-wrap: anywhere; } - .shell { overflow-x: clip; } - - /* …except the short atomic marks, which that blanket rule mangled: the roman - * numeral "I." broke between the letter and the period, and the section - * counter "001 / 008" split across two lines. None of these is long enough to - * overflow anything. */ - .roman, - .dot-mark, - .sec-rule > span:last-child, - .work-rule > span:last-child, - .hero-stats .stat-label b { white-space: nowrap; } - /* 320px (an SE) leaves 288px of content, and the wordmark plus the app pill - * plus two 18px gaps plus the status dot came to exactly that or more. The dot - * is ornament and goes; the gaps tighten. */ - .nav-inner { gap: 12px; } - .nav-side { gap: 10px; } - .nav-side .status-dot { display: none; } -} - -@media (max-width: 1080px) { - /* Rotated ornament pinned 32-42px outside the art frame, with no margin to - * hang it in below desktop. Hiding it also clears a real overlap: the closing - * section's "VIII" numeral sat on top of the "LOREGRAPH · APACHE-2.0" ribbon. */ - .capabilities-art .ribbon, - .cta-art .ribbon { display: none; } -} - -/* NOTHING here repositions .about-side-note, and that is deliberate. An earlier - * pass made it position:static below 1080px to stop it hanging 20px past the - * viewport — but that 20px was measured mid-transition, while the reveal was - * still animating, and does not exist in the settled layout. The rule was - * therefore fixing nothing, and it dropped the note into .about-art's flow - * directly on top of the absolutely-positioned .about-caption: four overlapping - * text runs, "It is told, explicitly, to forget the Elizabeth Bennet it already - * knows" printed straight through "Closed-world extraction: every claim goes - * back to a line in the book." Measure the settled layout, and measure overlap - * as well as overflow. */ - -/* White on the bright coral is 2.99:1, under the 4.5:1 floor — and these are - * the two buttons carrying the page's main actions. The darker tone reads the - * same and measures 5.66:1. */ -.btn-primary, -.btn-app { - background: var(--coral-text); - box-shadow: 0 14px 26px -16px rgba(164, 77, 63, 1); -} -.btn-primary:hover, -.btn-app:hover { background: #8f4235; transform: translateY(-1px); } - -/* The other three places white type sits on the bright fill: the active filter - * pill, the card's hover mark and the section's active arrow. */ -.pill.active, -.card:hover .arrow-mark, -.work-arrows .nav-btn.active { background: var(--coral-text); border-color: var(--coral-text); } - -@media (max-width: 880px) { - /* 28px arrow buttons are well under the 44px touch minimum, and they are the - * links into the app. Grown on touch widths only; the desktop mark is a - * deliberate size in a deliberate corner. */ - .card .arrow-mark, - .lab .arrow-mark { width: 44px; height: 44px; } - .card .arrow-mark svg, - .lab .arrow-mark svg { width: 14px; height: 14px; } -} - -@media (max-width: 880px) { - /* The language switcher's four links were 13-32px wide and 15px tall. */ - .topbar-inner .lang-switch a, - .topbar-link { display: inline-flex; align-items: center; min-height: 34px; padding: 0 5px; } -} - -/* The small end of the scale ran 9 / 9.5 / 10 / 10.5 / 11 / 11.5 / 12 — seven - * sizes inside three pixels, which is not a scale, just seven components that - * fail to match. An earlier pass floored them at 11px on phones only, on the - * reasoning that the desktop scale was deliberate. It is not: 9px is below the - * legibility floor on any screen, and the two smallest are carrying real - * content, not ornament — .about-caption is the museum credit for the plate and - * .about-side-note states the closed-world rule. Same floor everywhere. */ -.topbar-inner, -.about-caption, -.card .num .tag, -.lab-img .badge, -.annot, -.annot.coord, -.brand-meta, -.coord, -.partner small, -.pill .count, -.about-side-note, -.capabilities-art .ribbon, -.cta-art .ribbon, -.hero-art .index, -.lab .num-row, -.labs-meta .meta-text, -.meta, -.credits-foot, -.plate-head, -.plate-slot, -.sec-rule, -.side-rail .rail-text, -.wire-item .wire-coord, -.wire-item .wire-role, -.wire-title span, -.work-card .small-label, -.work-rule { font-size: 11px; } - -/* The four superscript counters on the nav links go, at every width. - * - * They cannot be decoded. 04 counts the engineering cards, 05 the reading-room - * views, 08 the pipeline passes, 85 the corpus works — four different units, no - * labels, so 85 reads as the same kind of number as 04. Only four of the five - * links carry one, so it is not a system either. And every count is already - * stated where it means something: the hero's stats row, "five views" in the - * reading room, the numbered passes themselves. - * - * They also cost the nav its rhythm. Each one is absolutely positioned at - * right: -16px, outside its link's box and inside the following gap, so the - * spacing between links reads as uneven. Removing them makes the 28px gap the - * real gap, and makes the desktop nav match the phone one, where an earlier - * pass had already hidden them. */ -.nav-links a .num { display: none; } - -/* Seven section headlines came out at seven sizes — 64, 66, 68, 72, 74.9, 77.8 - * and 95px at 1440 — because each one got its own clamp() ramp: three of them - * differ only in the vw coefficient with identical endpoints. The 95px one is a - * section heading set larger than the page's own h1, which inverts the - * hierarchy. Two ramps replace the twelve: one for the hero, one for sections, - * so the h1 is the largest thing on the page and the seven sections match. */ -.hero h1, -h1.display { font-size: clamp(40px, 5.2vw, 74px); } -.section-header h2, -.about h2.display, -.capabilities h2.display, -.labs-head h2, -.method-head h2, -.work-copy h2, -.cta h2.display { font-size: clamp(30px, 4.1vw, 56px); } - -/* The closing wordmark is nowrap inside an overflow-hidden band, and its clamp - * floors at 70px — which needs 300px for "LoreGraph." against 272px of content - * at 320. The brand name was cropped to "LoreGrap". Nothing else on the page - * bleeds, so this was the floor being wrong rather than a deliberate crop. */ -.foot-mega .word { font-size: clamp(58px, 13vw, 200px); } - -/* line-height equal to font-size, so the descenders of one line and the - * ascenders of the next clear each other by 4.1px at 72px — 0.06em, which at - * display size reads as touching. - * - * The number is set by ink, not by the em box. Inter Tight descends 0.204em and - * Playfair's italic ascends 0.795em, so a line of one over a line of the other - * spends 0.999em before any gap exists at all — which is why 1.06 still left - * 3.4px between the "p" of "graph" and the "h" of "relationship". 1.12 leaves - * about 0.12em, and that is the whole reason the value looks loose for a - * display size: it is paying for the second typeface. */ -.hero h1, -h1.display, -h2.display, -.labs-head h2, -.work-copy h2, -.section-header h2 { line-height: 1.12; } - -/* Except on the Chinese and Japanese pages. Their own adaptation block sets - * .display to 1.2, because a Han or Kana glyph fills its em box where a Latin - * lowercase fills half of it, and this correction is appended after that block - * — so without saying so it would undo it. 1.2 also reaches the one heading - * that adaptation missed, the corpus h2, which carries no .display class and - * was still at 1.0. */ -html[lang='zh-CN'] .display, -html[lang='zh-CN'] .work-copy h2, -html[lang='ja'] .display, -html[lang='ja'] .work-copy h2 { line-height: 1.2; } - -/* The four steps stack number, title, copy and plate in flow, and the four - * copy blocks are 4 to 7 lines long, so the four plates landed at four - * different heights — a 63px spread across a row that is otherwise aligned to - * the pixel. The steps are already equal-height grid items; the plate just has - * to sit at the bottom of one. */ -.method-step { display: flex; flex-direction: column; } -.method-step .img { margin-top: auto; } -/* The numeral is an inline-block painted in the paper colour so it knocks a hole - * in the rule threaded across the row. Turning the step into a flex column made - * it a flex item, which stretches — so the hole became the full column width and - * 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 - * gap between them and hard against the caption's first character. Aligning the - * numeral to the caption's first baseline puts it back on a line of text. */ -.sec-rule { align-items: baseline; column-gap: 18px; } - -/* The rule is three unequal items in a space-between row — numeral, plate - * caption, counter — so the caption lands wherever the two ends leave it: 26px - * left of the band's centre at 1150, and a different amount at every width. It - * is neither centred nor on a column edge, six times down the page. A three - * column grid with equal outer tracks puts it on the centre line and keeps it - * there. The outer tracks are minmax(0, 1fr) so a long caption can still push - * into them rather than overflow. - * - * Only from 700 up. Below that the caption is wider than the third of the band - * a centred layout would leave it, the outer minmax(0, 1fr) tracks collapse to - * nothing, and the counter ends up printed over "THE MET". There is no centre - * to find on a phone — the export's own space-between, with the caption allowed - * to wrap, is the right behaviour there. */ -@media (min-width: 700px) { - .sec-rule { - display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); - } - .sec-rule > :first-child { justify-self: start; } - .sec-rule > :last-child { justify-self: end; } - .sec-rule .meta-grp { justify-self: center; } -} - -/* This row summarises the whole section, but it sits inside the left column of - * the section's two-column grid — 495px, and its three children need exactly - * that, so every one of them is at its minimum width. The four claims wrap to - * two ragged lines and "One rule / ≥ 95%" is crushed into 58px, which is three - * words over five lines. Letting the row wrap gives the claims their own line, - * and the stamp keeps its two parts whole. */ -.about .footer-row { flex-wrap: wrap; row-gap: 16px; } -.about .footer-row > span:not(.mark) { letter-spacing: 0.08em; } -/* The stamp is a two-line stack by design — "One rule" over "≥ 95%" — but at - * 0.18em it needed 90px for the first line inside the 57px the squeezed row - * gave it, so that line broke too and the stamp came out as three. It keeps its - * two lines and stops shrinking. */ -.about .footer-row .stamp { flex: 0 0 auto; white-space: nowrap; } - -/* The five section links were never given nowrap, so whenever the header runs - * short of room they break inside the word: "Reading / room", "Quick / start". - * Above 1280 the brand's second line reappears and takes the room, which is the - * band where it showed. Links are labels, not copy — they do not wrap. The 38px - * gap pays for it; 28px is still twice the word space. */ -.nav-links a { white-space: nowrap; } -@media (min-width: 1081px) { - .nav-links { gap: 28px; } -} - -/* The five filter pills need 522px and their grid column was 488px, so - * "Catalogue" wrapped alone onto a second row while 684px of headline column - * sat half empty beside it. The headline is smaller now and needs less of the - * split, and the pills themselves come down from 18px of side padding to 14, - * which is 48px off the row. Together that fits them on one line from about - * 1200px up. Below that they still wrap — the column cannot be widened far - * enough without starving the headline — but flex-end keeps the second row - * flush right instead of ragged. */ -@media (min-width: 1101px) { - .labs-head { grid-template-columns: 1.15fr 1fr; } - .pills { gap: 8px; } - .pill { padding: 9px 14px; } -} - -/* 16 characters wide, right-aligned, absolutely positioned over the plate: five - * ragged lines averaging three words, two of them a single word. It states the - * closed-world rule, which is the section's whole argument. Widened to a - * readable measure — still right-aligned against the plate's edge, which is the - * design, but no longer a classified ad. */ -.about-side-note { max-width: 30ch; } - -/* Same shape: a full sentence set in tracked uppercase inside 28 characters, - * breaking as "READER, GRAPH," / "TIMELINE," / "INDEX AND ASK ALL READ" / "THE - * SAME GRAPH." Caps at 0.18em need roughly twice the measure of lowercase. */ -.labs-meta .meta-text { max-width: 44ch; letter-spacing: 0.08em; } - -/* Inline code is a background plus 6px of horizontal padding on an inline box, - * which does two things to the prose it sits in. It breaks across lines, so - * "loregraph ingest" shipped as a chip fragment ending mid-air; and the padding - * puts a 6px space between the chip and any punctuation that follows it, so the - * quick-start paragraph read "uv sync , then" and "loregraph extract . One" — - * a space before a comma, four times in four lines. The nowrap fixes the first; - * the second needs the punctuation pulled back over the padding, which is what - * tuck-punctuation marks up. */ -.code-inline { white-space: nowrap; } -.punct-tuck { font-style: normal; margin-left: -6px; } -.code-inline.sm + .punct-tuck { margin-left: -4px; } -/* Except at 320-400px, where "loregraph extract" and "multilingual-e5-large" - * are wider than the column and a chip that cannot break is a chip that - * overflows. The 400px block above already releases every other nowrap for the - * same reason; this rule comes after it, so it has to say so itself. */ -@media (max-width: 400px) { - .code-inline { white-space: normal; overflow-wrap: anywhere; } -} - -/* A comma set in 800-weight Inter Tight immediately after an italic Playfair - * word: "Strict about evidence, relaxed". It reads as a much heavier - * mark than the word it belongs to. Only commas are re-set — the full stops - * after an em are the coral and black terminal dots, which are deliberate. */ -.em-punct { font-family: var(--serif); font-style: italic; font-weight: 500; } - -/* The header drops all five section links below 1080px and puts nothing in - * their place: no menu, no button, no anchors — on every tablet and phone the - * only way to any section was to scroll the whole 17,000px page. They come back - * as their own row under the wordmark, scrolling sideways if they have to, - * which needs no menu state and no script. The superscript counters go: they - * are ornament, and they are what makes each link too wide. */ -@media (max-width: 1080px) { - .nav-inner { flex-wrap: wrap; row-gap: 12px; } - .nav-inner > nav { order: 3; width: 100%; } - .nav-links { - display: flex; - gap: 22px; - list-style: none; - margin: 0; - padding: 11px 0 0; - border-top: 1px solid var(--line-soft); - overflow-x: auto; - scrollbar-width: none; - -webkit-overflow-scrolling: touch; - /* Five links need 407px and a 390px phone gives the row 358px, so the last - * one is partly off the end. The fade says so; without it a half-drawn - * "Quick start" just looks broken. */ - mask-image: linear-gradient(90deg, #000 calc(100% - 40px), transparent); - -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 40px), transparent); +// the ground around it, which is why the corrections give the sticky header those +// same three layers — otherwise its bottom edge is a tone seam across the page. +// +// The header's copy of the layers has to stay identical to the original, and the +// two now live in different files. So compare them rather than trusting them: if a +// future export re-tunes its texture, an un-updated copy would stop matching +// silently and bring the seam back one shade smaller and far harder to see. +function assertHeaderTextureMatchesGround(html) { + const layers = (css, rule, label) => { + const start = css.indexOf(rule); + if (start < 0) throw new Error(`marketing-patch texture-copy: no ${rule} in ${label}`); + const from = css.indexOf("background-image:", start); + const to = css.indexOf("background-size:", from); + if (from < 0 || to < 0 || to < from) { + throw new Error(`marketing-patch texture-copy: ${rule} in ${label} no longer declares background-image then background-size`); + } + return css + .slice(from + "background-image:".length, to) + .replace(/\s+/g, " ") + .replace(/;\s*$/, "") + .trim(); + }; + const ground = layers(html, "body::before {", "the export"); + const header = layers(CORRECTIONS, ".nav::after {", "marketing-corrections.css"); + if (ground !== header) { + throw new Error( + "marketing-patch texture-copy: .nav::after no longer paints the same layers as body::before.\n" + + ` body::before ${ground}\n .nav::after ${header}`, + ); } - .nav-links::-webkit-scrollbar { display: none; } - .nav-links li { flex: 0 0 auto; } - .nav-links a { white-space: nowrap; } } -/* Below 1080px the footer hid its fourth and fifth columns outright, so every - * tablet and phone lost the repository, the issue tracker, the changelog and - * the licence. The grid already reflows to two columns at 560 and one at 400; - * it never needed to drop the content. */ -@media (max-width: 1080px) { - .foot-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 40px 32px; } - .foot-grid .foot-col:nth-child(4), - .foot-grid .foot-col:nth-child(5) { display: block; } -} -`; - -// 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", - ], - ], -}; +// Exact-string copy edits, per page language, in marketing-copy-edits.json. Data +// rather than code, because that is what they are: the copy deck upstream is one +// JSON file of {en, zh, ja, fr} strings, and these pairs are edits to it that +// cannot be made there from this repo. Keeping them in the same shape means +// folding them into the deck is a merge, not a reading of a build script. +// +// 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. +const COPY_EDITS = JSON.parse( + fs.readFileSync(path.join(__dirname, "marketing-copy-edits.json"), "utf8"), +); const IMG_RE = /]*>/g; @@ -1088,9 +408,14 @@ const PATCHES = [ // "forget the Elizabeth Bennet or 孫悟空 it already knows" — so nothing is // lost by not proving it with a book title. // + // And 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. + // // 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. + // repo — see marketing-copy-edits.json, written in the same shape so that + // moving it there is a merge. Then 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 @@ -1313,7 +638,8 @@ const PATCHES = [ why: "the export has no working phone layout — the document floors at 541px wide", run(html) { if (!html.includes("")) return { html, count: 0 }; - const tag = `\n`; + assertHeaderTextureMatchesGround(html); + const tag = `\n`; return { html: html.replace("", tag + ""), count: 1 }; }, }, diff --git a/src/loregraph/web/marketing/README.md b/src/loregraph/web/marketing/README.md index 258bbdf..612e08b 100644 --- a/src/loregraph/web/marketing/README.md +++ b/src/loregraph/web/marketing/README.md @@ -15,8 +15,16 @@ change it on the generating side and re-export. Because a hand edit here would not survive that re-export, the fixes the current export still needs live in [`../marketing-patch.cjs`](../marketing-patch.cjs) and -are applied as the build copies each page. Read that file for the full list and -the measurements behind each one. In short: +are applied as the build copies each page. Two of them are large enough to be +their own files, in the shape they would take upstream: + +| File | What it is | +|---|---| +| [`../marketing-corrections.css`](../marketing-corrections.css) | the corrections stylesheet, inlined into each `` after the export's own | +| [`../marketing-copy-edits.json`](../marketing-copy-edits.json) | the copy edits, keyed by language, the same shape as the upstream copy deck | + +Read the patch file for the full list and the measurements behind each one. In +short: - **The site's own URL.** The export was built against the old GitHub Pages address, so `canonical` pointed crawlers at a stale mirror and `og:image` at a @@ -107,6 +115,12 @@ caught two mistakes while it was being written — a guard that mistook the cred pages for landing pages, and a French colon preceded by U+202F rather than a plain space. +One more assertion guards a fix that would otherwise fail invisibly: the header's +copy of the paper texture is compared against the export's own `body::before` +rather than trusted, because the two live in different files now and an export +that re-tuned its texture would leave the copy quietly mismatched — the tone seam +back, one shade smaller and much harder to see. + Two things the patch step is careful *not* to do. The Chinese and Japanese pages carry their own adaptation block — CJK font stacks, `font-synthesis-style: none` so an `` gets no synthetic oblique, and 1.2 leading because a Han glyph fills @@ -115,10 +129,6 @@ after that block, so anything they set has to re-state the CJK value or it silently undoes it. And the interpunct removal leaves two dots alone: Japanese's 中点 inside a word list and Chinese's 间隔号 inside a transliterated name. -Deliberately not patched, because it wants a decision rather than a regex: the -heading levels that skip from `h2` to `h4`, whose CSS selectors would have to -move with them. - ## Where it comes from An Open Design project holds the source and four scripts: From cdf79f315a8bbae72ddec1a8986486f90c024d4c Mon Sep 17 00:00:00 2001 From: Yunyue Li Date: Thu, 30 Jul 2026 17:13:31 +0800 Subject: [PATCH 2/8] perf(web): stop shipping 28 KB of build notes in every page's head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both stylesheets on these pages document themselves in prose — nearly every rule records the measurement behind it, and the corrections file is 70% comment by weight. That is the point of them and it belongs in the repo. It does not belong in the bytes eight pages hand every reader: 28 KB per page, 23% of everything downloaded, inside the , ahead of anything that paints. The prose stays in marketing-corrections.css and in marketing/, and the pages ship without it. English / goes 119 KB -> 92 KB, and 30 KB -> 19 KB over the wire; every page drops about a third of its compressed weight. The stripper is quote-aware rather than /\/\*[\s\S]*?\*\//g, because the header texture is an SVG data URL inside url("…") and a future rule could carry a string with /* in it — a naive strip swallows everything to the next */ and takes live rules with it. That failure is invisible: the page still builds and is simply missing a rule. So it is checked two ways. Braces are counted inside the comments as they are skipped, which makes "no rule was eaten" exact rather than a guess about size; and the strip must be idempotent, which is the quote-aware way to ask whether anything comment-shaped survived — a bare search for /* in the output would fire on the legal content: "/*" the pass deliberately preserves. Verified against the previous build with a second, independently written stripper: on all eight pages the CSS rules are identical and the markup outside \n`; return { html: html.replace("", tag + ""), count: 1 }; }, diff --git a/src/loregraph/web/marketing/README.md b/src/loregraph/web/marketing/README.md index 8f1a69d..0e09814 100644 --- a/src/loregraph/web/marketing/README.md +++ b/src/loregraph/web/marketing/README.md @@ -110,6 +110,17 @@ short: that paints. The pages now ship without it and the prose stays here. English `/` went from 119 KB to 92 KB, and from 30 KB to 19 KB over the wire. +- **Frames the shape of the plates in them.** Every plate is 1024×1024 or + 768×1024, and five of the seven frames declare the ratio their plates actually + are. Two did not, and `object-fit: cover` discarded the difference without + saying so. `.work-card .img` was 4/3 around a 3/4 plate — not a trim but a + landscape window onto a portrait picture, the plate scaled up 1.78× and 22% cut + off each end: the Botticelli sheet lost all three figures' heads and showed a + band of drapery, and the fan-shaped calligraphy was enlarged until it touched + the edges. `.lab-img` was 4/5 around the same 3/4 shape — 6% of the height, + which is exactly where the corner registration marks sit, on five plates in a + row. The frames now take the plates' ratio and `cover` has nothing left to cut. + **Every patch asserts it matched.** If a future export changes the markup out from under one, the build fails rather than shipping the page with the bug back in it — so a patch that stops matching is either already fixed upstream (delete @@ -122,13 +133,38 @@ caught two mistakes while it was being written — a guard that mistook the cred pages for landing pages, and a French colon preceded by U+202F rather than a plain space. -Two more assertions guard fixes that would otherwise fail invisibly. The header's -copy of the paper texture is compared against the export's own `body::before` -rather than trusted, because the two live in different files now and an export -that re-tuned its texture would leave the copy quietly mismatched — the tone seam -back, one shade smaller and much harder to see. And the comment stripper is -quote-aware and checked for idempotence: a bare regex would swallow a live rule -if a stylesheet ever carried `content: "/*"`, and the page would still build. +Three more assertions guard fixes that would otherwise fail invisibly. The +header's copy of the paper texture is compared against the export's own +`body::before` rather than trusted, because the two live in different files now +and an export that re-tuned its texture would leave the copy quietly mismatched — +the tone seam back, one shade smaller and much harder to see. Every plate's real +pixel dimensions are read out of its WebP header and compared with the ratio its +frame declares, because that fix depends on two things this repo does not control: +the plates come from the design project, the frames from the exported stylesheet. +A re-export that changes either fails the build with the arithmetic — "cover +throws away 44% of its height" — instead of cropping the artwork again in silence. +And the comment stripper is quote-aware and checked for idempotence: a bare regex +would swallow a live rule if a stylesheet ever carried `content: "/*"`, and the +page would still build. + +## Measured, and deliberately not fixed here + +The reading room's five plates hold objects of very different visual weight. Ink +covers 58% of the first plate's area, 49% of the second, then 28%, 23% and 22% — a +2.6× spread across one row, which is why the row reads as unfinished next to the +rest of the page. + +CSS cannot honestly close that gap. Two of the five objects are landscape — a +hanging scroll and a type specimen — inside a portrait frame, and a third is a wide +krater. Scaling any of them until its ink matched the manuscript leaf's would push +it past the frame's sides and cut the object itself rather than its mount; matching +by ink area instead of height does the same to the scroll. The fix is to re-mount +those plates larger on their sheet, upstream, where the crop takes paper. + +It is also the one correction that could not assert itself if it were done by eye. +Per-plate zoom factors are tuned to particular pictures, and a re-export would +apply the old numbers to new images with nothing to catch it. The ratio rule can be +checked, so the ratio rule is what lives here. Two things the patch step is careful *not* to do. The Chinese and Japanese pages carry their own adaptation block — CJK font stacks, `font-synthesis-style: none` From 5e101d51e1d4856d6f67345be891531bb9e863e7 Mon Sep 17 00:00:00 2001 From: Yunyue Li Date: Thu, 30 Jul 2026 18:00:25 +0800 Subject: [PATCH 6/8] refactor(web): the page stops saying three things more than once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three restatements, all structural, so the patch reads the shape rather than the words and handles all four languages without knowing any of them. The five reading-room cards each carried a status: "Shipped", "已上线", "実装済み", "Livré" — one word, five times. A column whose every cell holds the same value says nothing per row; it is a fact about the set. The cells go and the set's own annotation takes it: "Five views" becomes "Five views, all built", in each language's own word for shipped. Same claim, one fifth of the ink. If a future export gives those five cards different statuses the column starts carrying information, so the patch checks first and refuses rather than deleting it. Each plate carried a chip — Text, Graph, Time, Index, Ask — 12px above a heading reading Reader, Graph, Timeline, Index, Ask. Four are the same word twice. The other two are nearly the same, which is worse than a duplicate: the reader has to work out whether "Text" and "Reader" are one view or two. The heading names the view. "eight passes, one gate" appeared three times — hero footer, call-to-action footer, page footer — as an ornament in a metadata slot, never as a claim being made. The page states the fact twice more where it is working: the hero's stat block ("8 passes, one of them a gate") and the pipeline section's heading. Six statements of one fact on one page; the three ornaments go. Keyed off the hero footer's own copy, so the words come from the page and not from a list in the build script. And two type sizes that were one job at two values. Card body copy was 13px in the reading room, 13.5px in the pipeline steps and capability cards, 14px in the corpus cards — a half-pixel nobody reads as a distinction, recording only that four sections were drawn at four different times. The columns are fixed, so unifying at 14px barely moves the measure: 32 characters a line to 31 in the narrowest card, 39 to 36 in the widest. The section lead was 16px in three sections and 17px in one. Left alone, with the measurements in marketing/README.md: the 26-value gap scale. Seven of those values are used once and sit beside a well-used neighbour, but rounding a single-use 7px gap to 8px is a change no reader can see and seven more values that must not drift. That is a design-token decision upstream. Verified on /, /zh, /ja and /fr at 375 to 1425 px: zero badges, zero status cells, zero taglines, the annotation carries the claim in all four languages, no horizontal scroll, nothing clipped, nothing under 11px. Co-Authored-By: Claude Opus 5 --- src/loregraph/web/marketing-copy-edits.json | 16 +++++ src/loregraph/web/marketing-corrections.css | 20 ++++++ src/loregraph/web/marketing-patch.cjs | 78 +++++++++++++++++++++ src/loregraph/web/marketing/README.md | 32 +++++++++ 4 files changed, 146 insertions(+) diff --git a/src/loregraph/web/marketing-copy-edits.json b/src/loregraph/web/marketing-copy-edits.json index 49e493d..06d45fa 100644 --- a/src/loregraph/web/marketing-copy-edits.json +++ b/src/loregraph/web/marketing-copy-edits.json @@ -19,6 +19,10 @@ [ "Closed world. Literal match. Multilingual. Resumable.", "Closed world   Literal match   Multilingual   Resumable" + ], + [ + "Five views", + "Five views, all built" ] ], "zh-CN": [ @@ -37,6 +41,10 @@ [ "工程实现参考了 Splink、ComEM 与 GraphRAG:", "读 Splink、ComEM 与 GraphRAG 定下了四件事:" + ], + [ + "五个视图", + "五个视图,都已上线" ] ], "ja": [ @@ -55,6 +63,10 @@ [ "エンジニアリングは Splink・ComEM・GraphRAG を参照して設計した。", "Splink・ComEM・GraphRAG を読んで、四つのことが決まった。" + ], + [ + "五つのビュー", + "五つのビュー、すべて実装済み" ] ], "fr": [ @@ -77,6 +89,10 @@ [ "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" + ], + [ + "Cinq vues", + "Cinq vues, toutes livrées" ] ] } diff --git a/src/loregraph/web/marketing-corrections.css b/src/loregraph/web/marketing-corrections.css index 5b7f26d..4d8f6b5 100644 --- a/src/loregraph/web/marketing-corrections.css +++ b/src/loregraph/web/marketing-corrections.css @@ -676,3 +676,23 @@ html[lang='ja'] .work-copy h2 { line-height: 1.2; } * The corpus cards get taller, which is what a portrait plate shown whole is. */ .work-card .img { aspect-ratio: 3 / 4; } .lab-img { aspect-ratio: 3 / 4; } + +/* One size for the same job, twice over. + * + * Card body copy — a short paragraph inside a card — is set at three sizes + * depending on which section built the card: 13px in the reading room, 13.5px in + * the pipeline steps and the capability cards, 14px in the corpus cards. Nobody + * reads a half-pixel as a distinction, so the difference carries no meaning; it + * records that four sections were drawn at four different times. 14px is the one + * of the three the page already uses most, and the columns are fixed width, so + * the measure barely moves: the narrowest card goes from 32 characters a line to + * 31, the widest from 39 to 36. + * + * And the section lead is 16px in the hero, the corpus and the call to action, + * and 17px in one section. Same role, one outlier, 1px. */ +.lab p, +.method-step p, +.card p, +.work-card p { font-size: 14px; } + +.about .lead { font-size: 16px; } diff --git a/src/loregraph/web/marketing-patch.cjs b/src/loregraph/web/marketing-patch.cjs index c979fbe..e2eb06d 100644 --- a/src/loregraph/web/marketing-patch.cjs +++ b/src/loregraph/web/marketing-patch.cjs @@ -594,6 +594,84 @@ const PATCHES = [ return { html: out, count }; }, }, + { + name: "drop-restated-labels", + why: "a status column with one value in it, five chips restating the heading below them, and one tagline three times", + // Three things the page says more than it needs to. All three are structural, + // so this reads the shape rather than the words and works on all four + // languages without knowing any of them. + // + // 1. The five reading-room cards each carry a status: "Shipped", "已上线", + // "実装済み", "Livré" — the same word five times. A column whose every cell + // holds the same value says nothing per row; it is a fact about the set. So + // the cells go and the set's own annotation takes it, in the copy edits: + // "Five views" becomes "Five views, all built". Same claim, one fifth of + // the ink, and no column of identical values. + // + // 2. Each plate carries a chip — Text, Graph, Time, Index, Ask — and the + // heading 12px below it reads Reader, Graph, Timeline, Index, Ask. Four are + // the same word twice; the other two differ, which is worse than a + // duplicate, because the reader has to work out whether "Text" and "Reader" + // are the same view. The heading names the view. The chip goes. + // + // 3. "eight passes, one gate" appears three times, in the hero footer, the + // call to action's footer and the page footer — as an ornament in a metadata + // slot each time, never as a claim being made. The page states the fact + // twice more where it is doing work: the hero's stat block ("8 passes, one + // of them a gate") and the pipeline section's own heading. Six statements of + // one fact on one page; the three ornaments go. + run(html) { + if (!html.includes("
\d+<\/span>([^<]*)<\/span><\/div>/g)]; + if (rows.length !== 5) { + throw new Error(`marketing-patch drop-restated-labels: expected 5 .num-row cards, found ${rows.length}`); + } + const statuses = new Set(rows.map((m) => m[1])); + if (statuses.size !== 1) { + throw new Error( + `marketing-patch drop-restated-labels: the status column is no longer one value — ${[...statuses].join(", ")}. It now carries information; leave it alone.`, + ); + } + html = html.replace(/(
\d+<\/span>)[^<]*<\/span>(<\/div>)/g, (m, head, tail) => { + count++; + return head + tail; + }); + + // 2. The plate chips. + const chips = (html.match(/[^<]*<\/span>/g) || []).length; + if (chips !== 5) { + throw new Error(`marketing-patch drop-restated-labels: expected 5 plate badges, found ${chips}`); + } + html = html.replace(/[^<]*<\/span>/g, () => { + count++; + return ""; + }); + + // 3. The tagline, keyed off the hero footer's own copy so the words come from + // the page rather than from a list here. + const coord = html.match(/([^<]+)<\/span>/); + if (!coord) throw new Error("marketing-patch drop-restated-labels: no .coord in the hero footer"); + const tagline = coord[1]; + const hits = html.split(`>${tagline}<`).length - 1; + if (hits !== 3) { + throw new Error( + `marketing-patch drop-restated-labels: expected the tagline "${tagline}" 3 times, found ${hits}`, + ); + } + html = html.replace( + new RegExp(`[ \\t]*]*)?>${tagline.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`, "g"), + () => { + count++; + return ""; + }, + ); + + return { html, count }; + }, + }, { name: "drop-dead-and-duplicate-furniture", why: "five buttons that filter nothing, and five section rules that repeat the label 57px below them", diff --git a/src/loregraph/web/marketing/README.md b/src/loregraph/web/marketing/README.md index 0e09814..69a0d92 100644 --- a/src/loregraph/web/marketing/README.md +++ b/src/loregraph/web/marketing/README.md @@ -121,6 +121,25 @@ short: which is exactly where the corner registration marks sit, on five plates in a row. The frames now take the plates' ratio and `cover` has nothing left to cut. +- **Things said more than once.** Five reading-room cards each carried the same + status word — "Shipped", "已上线", "実装済み", "Livré" — a column whose every cell + holds one value, which is a fact about the set and not about any row. The set's + own annotation takes it instead: "Five views" became "Five views, all built". + Each plate also carried a chip (Text, Graph, Time, Index, Ask) 12px above a + heading reading Reader, Graph, Timeline, Index, Ask — four the same word twice + and two *nearly* the same, which is worse, because the reader has to work out + whether "Text" and "Reader" are one view or two. And "eight passes, one gate" + appeared three times as an ornament in a metadata slot, on a page that already + states the fact twice where it is doing work: the hero's stat block and the + pipeline section's own heading. + +- **One size for the same job.** Card body copy — a short paragraph inside a card + — was 13px in the reading room, 13.5px in the pipeline steps and the capability + cards, and 14px in the corpus cards. Nobody reads a half-pixel as a distinction, + so the difference carried no meaning; it recorded that four sections were drawn + at four different times. And the section lead was 16px in three sections and + 17px in one. Both are now one value. + **Every patch asserts it matched.** If a future export changes the markup out from under one, the build fails rather than shipping the page with the bug back in it — so a patch that stops matching is either already fixed upstream (delete @@ -166,6 +185,19 @@ Per-plate zoom factors are tuned to particular pictures, and a re-export would apply the old numbers to new images with nothing to catch it. The ratio rule can be checked, so the ratio rule is what lives here. +The spacing scale is undisciplined and is left alone. There are 26 distinct gap +values between 3px and 80px, seven of them used exactly once and sitting next to a +well-used neighbour — 7 beside 8, 9 beside 10, 38 beside 36 and 40, 48 beside 50. +Rounding a single-use 7px gap to 8px is a change no reader can see, and it would +add seven more values that must not drift, on a page where every change has to be +re-measured at every width in four languages. This is a design-token decision for +the generating side, not a build patch. + +The type scale's small end is also still busier than it needs to be: 13px and +13.5px both survive outside the card copy, on footer links and a "read more". Those +are different roles from card body copy, so they were not swept up with it, but +there is no reason for the page to hold both. + Two things the patch step is careful *not* to do. The Chinese and Japanese pages carry their own adaptation block — CJK font stacks, `font-synthesis-style: none` so an `` gets no synthetic oblique, and 1.2 leading because a Han glyph fills From 8ae9c596d23f7f2997e76a74019d544c6e8d42a3 Mon Sep 17 00:00:00 2001 From: Yunyue Li Date: Thu, 30 Jul 2026 18:08:31 +0800 Subject: [PATCH 7/8] fix(web): give the keyboard a way past the header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The landing pages have a header, a footer and eight sections, and no main landmark at all — so a screen reader had nothing to jump to and a skip link had nothing to point at. Tabbing in meant eleven controls before the page's own h1, on each of the four pages, every time. The eight sections are now
, and a skip link sits in front of the header as the first tab stop, in the words the app already uses for its own. The tabindex is the part that makes it work: without it the browser scrolls to the target and leaves focus on the link, so the next Tab carries on through the header and the reader is back where they started. Wrapping is safe because the sections are plain blocks in normal flow and nothing in either stylesheet selects a direct child of .shell. And the corpus section's two 46px arrow buttons go the way of the filter pills: no handler anywhere in the bundle, nothing to page through — the cards they sit under are a three-column grid, not a scroller — and the only two controls on the page with no accessible name, so a screen reader announced "button, button" and a keyboard user tabbed onto two dead ends. Labelling them would have been labelling nothing. The patch refuses to remove them if a handler ever appears. Verified: clicking the skip link focuses
, so the next Tab starts inside the content; the target lands below the sticky header rather than behind it; and no ring is drawn round the whole page. Across all four languages: 56 links and no unnamed link, no unnamed button, no image without alt text, three language links each carrying hreflang and lang, and the heading outline h1 then h2/h3 with no skips. Checked and already right, recorded in marketing/README.md so it is not re-investigated: prefers-reduced-motion is honoured in the stylesheet and again in the reveal script, and the focus ring is the browser's own — nothing sets outline: none. Measuring that ring with a scripted .focus() reports "none" and is wrong, because :focus-visible does not match a programmatic focus. Forced-colours mode is named as untested rather than claimed. Co-Authored-By: Claude Opus 5 --- src/loregraph/web/marketing-corrections.css | 36 ++++++++++ src/loregraph/web/marketing-patch.cjs | 73 +++++++++++++++++++++ src/loregraph/web/marketing/README.md | 29 ++++++++ 3 files changed, 138 insertions(+) diff --git a/src/loregraph/web/marketing-corrections.css b/src/loregraph/web/marketing-corrections.css index 4d8f6b5..0f3aa58 100644 --- a/src/loregraph/web/marketing-corrections.css +++ b/src/loregraph/web/marketing-corrections.css @@ -696,3 +696,39 @@ html[lang='ja'] .work-copy h2 { line-height: 1.2; } .work-card p { font-size: 14px; } .about .lead { font-size: 16px; } + +/* The keyboard's way past the header. The page has a header, a footer and eight + * sections and no main landmark, so there was nothing to jump to and nothing to + * point a skip link at: tabbing in meant eleven controls before the h1, on all + * four pages, every time. The patch wraps the sections in
and + * puts this in front of the header, so it is the first tab stop. + * + * Off-screen rather than display:none or a 1px clip, because it has to stay + * focusable and it has to be able to come back. :target already carries the + * sticky header's offset, so the jump lands below the chrome and not behind it. */ +.skip-link { + position: absolute; + left: 16px; + top: 8px; + z-index: 100; + translate: 0 -140%; + padding: 10px 16px; + background: var(--ink); + color: var(--paper); + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.04em; + text-decoration: none; + border-radius: 4px; + transition: translate 0.14s ease-out; +} +.skip-link:focus-visible { translate: 0 0; } +@media (prefers-reduced-motion: reduce) { + .skip-link { transition: none; } +} + +/* The skip target takes tabindex='-1' so focus actually moves there, and that + * makes it focusable — which would otherwise draw a ring around the entire page + * the first time anyone used the link. */ +main[tabindex]:focus { outline: none; } diff --git a/src/loregraph/web/marketing-patch.cjs b/src/loregraph/web/marketing-patch.cjs index e2eb06d..cbc4fd3 100644 --- a/src/loregraph/web/marketing-patch.cjs +++ b/src/loregraph/web/marketing-patch.cjs @@ -55,6 +55,15 @@ function appLabel(html) { return APP_LABEL[lang] || APP_LABEL.en; } +// The app's own wording for the same link, from landing/i18n.js — one phrase, and +// no reason for the two halves of the site to say it differently. +const SKIP_LABEL = { + en: "Skip to content", + "zh-CN": "跳到正文", + ja: "本文へスキップ", + fr: "Aller au contenu", +}; + // Every rule the corrections stylesheet carries fixes something measured, and // nothing in it restyles the design for its own sake. It is authored as real CSS // in marketing-corrections.css, so it lints, diffs and reads like a stylesheet @@ -594,6 +603,52 @@ const PATCHES = [ return { html: out, count }; }, }, + { + name: "main-landmark-and-skip-link", + why: "eleven tab stops stand between the keyboard and the page's first heading, with no way past them", + // The page has a header, a footer and eight sections, and no main landmark at + // all — so there is nothing for a screen reader to jump to and nothing for a + // skip link to point at. Tabbing in means eleven controls before the h1, on + // every one of the four pages, every time. + // + // The eight sections become the main region and a skip link goes in front of + // the header, in the same words the app already uses for its own. Wrapping is + // safe here: the sections are plain blocks in normal flow and nothing in + // either stylesheet selects a direct child of .shell. + run(html) { + if (!html.includes("
]*lang=['"]([^'"]+)/) || [])[1] || "en"; + const label = SKIP_LABEL[lang]; + if (!label) throw new Error(`marketing-patch skip-link: no label for lang '${lang}'`); + + const headerEnd = html.indexOf(""); + const footerAt = html.indexOf("