Skip to content

书架封面从 58 MB 降到 4 MB - #17

Merged
YunyueLi merged 9 commits into
mainfrom
claude/landing-patch-sources
Jul 30, 2026
Merged

书架封面从 58 MB 降到 4 MB#17
YunyueLi merged 9 commits into
mainfrom
claude/landing-patch-sources

Conversation

@YunyueLi

Copy link
Copy Markdown
Owner

线上实测,书架第一屏 28 张封面共 58.2 MB。其中一张单张 20.9 MB,另有四张超过 2 MB,还有一张 11 MB——最后这张值得说,因为它已经是「缩略图」了,只不过生成宽度是 3840px。卡片实际画出来是 212×297,第一张卡的图是 1867×3016。

现在 LG_COVER_IMAGES 里每个地址在载入时改写成 500px 的 Wikimedia 缩略图。同样这 28 张变成 3.95 MB,全部 85 张加起来 10.9 MB,平均 131 KB;85 个地址逐个 HEAD 验过,全部可用。

宽度不能随便选。缩略图服务从 2025 年起只接受一份白名单,别的宽度直接 400——那四个 3840px 的地址就是这么来的,当时大概是从文件页上直接复制的。逐个宽度探过:120、250、330、500、1280 可用;150、180、200、220、256、300、320、400、440、480、512、600、640、660、800、880、1024、1200 全部 400。500 能覆盖二倍屏下的卡片,也够三维书架画封面贴图用,而且是小尺寸里最大的一档,以后卡片放大也不必立刻跳到 1280。

原本已经是缩略图的五个地址保留它们生成出来的文件名(其中一个是 .webp 渲染成 .png),只改数字。不是 Wikimedia 上传地址的原样返回。

验证过:85 个 img 全部带 500px 缩略图、全部懒加载,第一张封面 500×857 装在 212×297 的框里;书架模式仍然正常载入 three、五张装帧材质和画布,合计 1.77 MB。

🤖 Generated with Claude Code

YunyueLi and others added 9 commits July 30, 2026 17:13
…ir own files

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 <noreply@anthropic.com>
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 <head>, 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 <style> is
byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Open Jane Eyre in the reader or the index and the view came down with the error
boundary: "Cannot read properties of undefined (reading 'text')" and the same for
'id'. Both views read straight into the first passage and the first entity, and a
book with neither has no first of either.

This is not an edge case. 82 of the 85 works on the shelf have never been through
the pipeline — the library is a manifest, and two of its books carry a real
extracted graph. So two of the app's seven views crashed on 96% of its catalogue,
and the crash was reachable in one click from the library grid.

The Ask view had the same bug and already carries the fix: it renders an empty
state instead of reading conv.q. This is that, shared, and it distinguishes two
facts the reader cannot otherwise tell apart — a work that has not been extracted,
and a work whose graph is still arriving.

Split each view in two rather than returning early, because the guard has to run
before the hooks: an early return past them changes the hook count between the
empty render and the loaded one, which React rejects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things on the critical path that the app's first screen does not use. The
landing page now makes "open the app" its primary button, so this is what that
button cost: 12.5 MB of JavaScript and a 1.8 MB stylesheet, before a pixel.

1. A book's graph now arrives when the book is opened. data-exports.js was a
   generated 11 MB script — 90% of the bundle, parsed before the first paint —
   holding the full extracted graphs of two books out of 85. The app opens on the
   library grid, which draws neither. The generator now writes the shelf metadata
   (11 KB, still in the bundle, all the grid needs) and one file per book under
   assets/exports/, fetched by LG_LOAD_BOOK when that book becomes active. No
   loading state was needed: 82 of the 85 books have no graph at all and every
   view already renders them, so a book whose file is in flight looks exactly
   like one of those — which is also why the previous commit had to come first.

   Bundle: 12,467 KB -> 1,264 KB. 417 KB over the wire. Alice's graph is 0.5 MB
   gzipped and 西游记's is 2.5 MB, paid by whoever opens them.

2. Three webfont families, not seven. The request also asked for Noto Serif SC,
   TC, JP and KR at four weights each: 1828 @font-face rules, a 1.79 MB stylesheet
   (466 KB over the wire, 1.4 s) blocking render in the <head> — and not one
   element on either page named any of them. Verified in the browser: 1927 faces
   declared, one font file actually fetched. The app sets CJK in the platform
   serif, which is what .serif's own fallback has always done; the Noto names
   appeared only in the 3-D shelf's canvas font string, which now names platform
   serifs too. 1.79 MB -> 36 KB, 466 KB -> 2 KB over the wire.

3. three.js is fetched when a shelf renders. 670 KB in the <head> of every load
   for one mode of one view — and the library opens in grid mode. The flat CSS
   shelf covers the wait, which is the same thing it covers when WebGL is missing:
   one code path, no new state.

4. The library grid's 85 cover scans are lazy. They were 85 cross-origin requests
   to Wikimedia before the first paint, for 81 images nobody had scrolled to. The
   skeleton the grid already draws for a pending scan is what laziness looks like.

DOMContentLoaded on a local server: 2113 ms -> 85 ms.

Verified: every view for a demo book (pap), a never-extracted book (jane), the
small export (alice) and the large one (xyj) — no crashes, the right empty states,
and after the fetch the counts match the generator's own report exactly (477
entities, 1010 edges, 42 chunks for alice). Shelf mode loads three and renders its
canvas; grid mode never requests it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every collage plate on the page 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 discards the difference without saying so.

.work-card .img was 4/3 around a 3/4 plate. That is not a trim, it is 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;
the fan-shaped calligraphy was enlarged until it touched the frame edges. In both
the mount, the margins and the registration marks — the reason these are plates and
not stock photographs — were outside the frame.

.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.

Both frames now take the plates' ratio, so cover has nothing left to cut. The
corpus cards get taller, which is what a portrait plate shown whole is.

The fix depends on two things this repo does not control — the plates come from the
design project and the frames from the exported stylesheet — so the build now reads
each plate's real dimensions out of its WebP header and compares them with the
ratio its frame declares. A re-export that changes either side fails with the
arithmetic rather than cropping the artwork again in silence. Verified by putting
4/3 back: the build stops and prints "cover throws away 44% of its height".

Not fixed, and recorded in marketing/README.md with the measurements: the five
reading-room plates carry ink over 58 / 49 / 28 / 23 / 22% of their area, and no
amount of CSS closes a 2.6× spread when two of the objects are landscape inside a
portrait frame. Scaling them to match cuts the object rather than its mount. That
belongs in the plate generator.

Swept /, /zh, /ja, /fr at 305 to 1425 px: no horizontal scroll, nothing clipped,
every plate frame 0.750.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <main id='main' tabindex='-1'>, 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 <main>, 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 <noreply@anthropic.com>
The sticky header fills itself with the paper colour, so at rest its bottom edge
was a hard tone seam — flat paper above, textured ground below. The fix I shipped
for that gave it a copy of the ground's three layers with
background-attachment: fixed, on the reasoning that this anchors them to the
viewport the way body::before's are.

That reasoning was wrong, and Chrome hid it. .nav carries transform: translateY(0)
and will-change: transform for its show-on-scroll-up slide, which makes it a
containing block, and inside one `fixed` attachment is defined to resolve against
the element rather than the viewport. WebKit does exactly that: measured on an
iPhone 16 Pro simulator, the two radial gradients were laid out across the header's
own 1206x255 box, so the header ran 6 levels LIGHTER than the ground at the left
edge and 5 levels DARKER at the right — the seam replaced by a gradient running the
wrong way across the page.

So the header takes the ground's colour rather than its recipe. #eae2cc is the mean
of 300 samples of pure ground down the page's margins on that phone. The ground's
own gradients swing about 4 levels either side of it, so a flat fill sits inside the
variation the eye already reads as texture: worst step across the boundary goes from
6.2 levels to 4.5, and the reversal is gone. Losing the grain inside an 85px band
costs nothing — the grain is one level.

The texture-copy assertion goes with it. It was guarding a mechanism that does not
work, and one fewer painted layer needs no guard.

Also checked on that phone, and working: the fade that says the navigation row
scrolls sideways (ink darkness 18.7 to 221.3 over the last 40px, exactly as
declared), the punctuation tucked against the inline code chips, the plate frames,
the CJK adaptation on /zh with all five nav links fitting, the reading room and the
quick start. Firefox and Android are still untested and marketing/README.md says so.

Reported separately: the Claude Code iOS Simulator integration refuses to start on
this machine and wants `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`,
which needs a password. These measurements were taken by driving simctl directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured on the live site, first screen of the library: 28 covers, 58.2 MB. One of
them 20.9 MB on its own, four more over 2 MB, and one 11 MB — that last is
instructive, because it is already a "thumbnail", generated at 3840px wide. The
cards draw these at 212x297. The first card's scan is 1867x3016.

Every URL in LG_COVER_IMAGES is now rewritten to a 500px Wikimedia thumbnail at
load. The same 28 covers come to 3.95 MB, and all 85 in the library would be
10.9 MB — mean 131 KB, verified by HEAD against the service, all 85 resolving.

The width is not a free choice. Since 2025 the thumbnailer serves an allow-list and
400s on anything else, which is how the four 3840px URLs got there: someone took
what the file page offered. Probed one width at a time: 120, 250, 330, 500 and 1280
are served; 150, 180, 200, 220, 256, 300, 320, 400, 440, 480, 512, 600, 640, 660,
800, 880, 1024 and 1200 all 400. 500 covers the card at 2x and the cover texture the
3-D shelf paints, and it is the largest of the small sizes, so a bigger card later
does not immediately need 1280.

The five URLs that were already thumbnails keep their generated filename — one is a
.webp rendered to .png — and only the number changes. Anything that is not a
Wikimedia upload URL is handed back untouched.

Verified: all 85 img tags carry a 500px thumb, all lazy, first cover 500x857 in a
212x297 box; and shelf mode still loads three, its five binding materials and its
canvas, at 1.77 MB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@YunyueLi
YunyueLi merged commit 5e11e1e into main Jul 30, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant