Books EPUB reader: measured pagination + real page turns; and take the paid surfaces out of Settings - #510
Merged
Conversation
An owner-supplied EPUB (a RuLit FB2 conversion) rendered with text cut off
mid-sentence and nothing responding to a scroll. Four separate defects, two
of which compound into "the pages don't show and don't switch".
Pagination guessed instead of measuring. `charsPerPageBudget` sized a page as
`columns x lines` of SOLID text; prose is not solid -- every paragraph ends on
a ragged part-empty line and adds a bottom margin -- so the budget over-filled
the page and `.books__page { overflow: hidden }` silently ate the rest, with no
scrollbar and no indication. Rendering the real extracted content in Chromium
against the app's own CSS: 61 of 77 pages overflowed, 17.6% of each page
unreadable on average, 73% at worst. Now `paginateMeasured` packs real block
geometry, measured once per (width x typography) into an offscreen
`.books__ruler` (the whole book costs one layout). `ReaderState` takes a
`Paginator` seam; `charBudgetPaginator` remains the pre-layout fallback.
Locators are untouched, so highlights, reading position and progress survive.
Same book after: 0 clipped pages.
Nothing you would instinctively do turned a page. Only the footer buttons and
the arrows were bound -- no wheel, swipe, Space or PageDown -- and a paginated
reader has no scrollbar to fall back on, so scrolling to reach the clipped text
was a no-op. Adds Space / Shift+Space / PageDown / PageUp / ArrowDown / ArrowUp
plus scroll-to-turn (threshold + momentum cooldown, both axes).
Every chapter heading was deleted. `htmlToBlocks` counted only `<h1>`-`<h6>`
and descended containers via `node.children`, so a `<div class="title2">`
holding just text emitted nothing. The walk now keeps a container's own text
runs and reads a title-classed element as a heading.
The TOC read "Chapter 2" through "Chapter 20". `parseEpub` never looked at the
NCX, and the positional fallback counted source sections so a dropped cover
page skewed every number. New `epub-nav.ts` maps nav labels onto spine hrefs;
titles prefer the book's own label, then its first heading, then a position
counted over the kept spine.
Verified by driving the real reader in real Chromium over the real file: 3407
page renders across 6 viewports x 5 font sizes, 0 clipped, 0 errors. Two
constraints that surfaced only there, and that any future measuring surface
inherits: the ruler needs a FRACTIONAL content box (`clientWidth` rounds
735.703px to 736px, and that 0.3px fits one more word per line -- a block
measured 7 lines and rendered 8), and a split chunk still carries its block's
trailing margin, so its line budget is `pageHeight - marginBottom`.
Note for reviewers: jsdom reports every box as 0x0, so the measured path took
its fallback and 316 green tests hid a TDZ ReferenceError that would have
crashed every EPUB open. The new reader tests stub the boxes to reach it.
Tests 285 -> 316.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2K9twZ3vdD8LqCv1QbLuW
Nothing is for sale yet, so the app should not ask. Removes the two
commercial Settings sections and everything that only existed to serve them:
- Settings -> Membership: the pricing tiers, billing-cycle toggle,
current-plan state and mocked checkout (visual-only; it never charged).
- Settings -> Billing: plan + entitlement readout, account link, invoices
and the portal / Stripe-Checkout deep links.
Also gone: their `SettingsSection` members, lazy imports, nav entries and
render cases; 158 catalog strings across en + the five locale stubs and the
grandfathered description list; and four glyphs (Star, CreditCard, Crown,
Heart) that the Membership card was the only user of. `IconName` is a closed
enum behind a `Record<IconName, ...>` with no string lookup, so dropping them
cannot orphan a stored icon -- and the same glyphs stay user-pickable through
`icon-packs.ts`, which is a separate registry.
Deliberately NOT removed: `main/billing/*`, the `account.db` entitlement
table, `protocol/billing-types` and the `FeatureFlag` plumbing. That layer is
already dormant -- `BillingService` synthesises `freeEntitlement()` for every
install and no feature is gated -- so leaving it costs nothing and keeps this
a one-commit revert when there is something to sell. Settings -> Account still
holds Identity, so the group is not left empty.
`refreshMembership` in sharing/live-sync is collab membership, an unrelated
concept, and is untouched.
One test moved rather than deleted: `t.test.ts` pinned its literal-apostrophe
assertion to a membership string; it now uses `shell.notifications.center.empty`.
renderer 798 tests green, typecheck (packages + apps) clean, biome + all 11
lint gates clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2K9twZ3vdD8LqCv1QbLuW
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two independent changes, batched into one PR per the one-PR-per-repo rule. They touch disjoint files and can be reviewed in either order.
1.
fix(books)— the EPUB reader was throwing away a fifth of every pageAn owner-supplied EPUB (a RuLit FB2 conversion) rendered with text cut off mid-sentence and nothing responding to a scroll. Four defects; two of them compound into "the pages don't show and don't switch".
paginateMeasuredpacks real measured block geometryepub-nav.tsmaps NCX labels onto spine hrefsWhy it clipped.
charsPerPageBudgetsized a page ascolumns × linesof solid text. Prose is not solid — every paragraph ends on a ragged part-empty line and adds a bottom margin — so the budget over-filled the page and.books__page { overflow: hidden }ate the rest, with no scrollbar and no indication. Rendering the real extracted content in Chromium against the app's own CSS: 61 of 77 pages overflowed, 17.6% of each page unreadable on average, 73% at worst.Block geometry is now measured once per (width × typography) into an offscreen
.books__ruler— the whole book costs one layout — andReaderStatetakes aPaginatorseam.charBudgetPaginatorstays as the pre-layout fallback. Locators are untouched, so highlights, reading position and progress survive the swap.Verification. The real reader driven in real Chromium over the real file: 3407 page renders across 6 viewports × 5 font sizes, 0 clipped, 0 errors (was 61/77).
Two things reviewers should know
Both were found only by giving the page a real layout box, and both are lessons for any future measuring surface:
0x0, so the measured path took its fallback and 316 green tests hid aReferenceError: Cannot access 'measured' before initializationthat would have crashed every EPUB open in the real app. The new reader tests stubElement.prototypeboxes to reach it.clientWidthrounds 735.703px → 736px. That 0.3px let one more word fit per line, so a block measured 7 lines and rendered 8, and a page clipped. Measurement now uses fractional content boxes.Tests 285 → 316.
2.
chore(shell)— take the paid surfaces out of Settings for nowOwner call: nothing is for sale yet, so the app should not ask.
Removes Settings → Membership (pricing tiers, billing-cycle toggle, mocked checkout — it never charged) and Settings → Billing (plan/entitlement readout, account link, invoices, portal + Stripe-Checkout deep links), plus their
SettingsSectionmembers, lazy imports, nav entries, render cases, 158 catalog strings across six locales and the grandfathered description list, and four glyphs the Membership card was the only user of.Deliberately NOT removed:
main/billing/*, theaccount.dbentitlement table,protocol/billing-typesand theFeatureFlagplumbing. That layer is already dormant —BillingServicesynthesisesfreeEntitlement()for every install, no feature is gated, and I confirmed it has no timers, no auto-refresh and makes no network call. Leaving it keeps this a one-commit revert when there is something to sell.refreshMembershipin sharing/live-sync is collab membership, an unrelated concept, and is untouched. Settings → Account still holds Identity, so the group is not left empty.Gates
tscclean; biome + all 11 lint gates clean; Books app buildsTwo pre-existing failures on clean
main, neither caused here and neither fixed here:tools/mcp-server/tests/i18n-descriptions.test.ts— 8 undescribed settings keys (devices/join + settings groups)packages/shell/src/main/integration/stress.test.ts— an FTS p50 budget that only fails under CPU contention; passes in isolationDocs land in the harness PR (plan
9.21.8/9.21.9/14.0, frictionF-503…F-506).🤖 Generated with Claude Code
https://claude.ai/code/session_01U2K9twZ3vdD8LqCv1QbLuW