diff --git a/apps/books/src/logic/epub-content.test.ts b/apps/books/src/logic/epub-content.test.ts index 1bc9d7a4..75e41d89 100644 --- a/apps/books/src/logic/epub-content.test.ts +++ b/apps/books/src/logic/epub-content.test.ts @@ -21,6 +21,38 @@ describe("htmlToBlocks", () => { expect(blocks.map((b) => b.text)).toEqual(["Title", "Body"]); }); + // FB2\u2192EPUB converters (most Russian-language EPUBs, and plenty of + // others) mark chapter titles as a classed div rather than an . The + // walker used to descend past a div holding only text and emit nothing, + // so every chapter title in such a book was silently deleted. + it("reads a title-classed container as a heading", () => { + const blocks = htmlToBlocks('
Chapter One

Body

'); + expect(blocks).toEqual([ + { kind: BlockKind.Heading, text: "Chapter One" }, + { kind: BlockKind.Paragraph, text: "Body" }, + ]); + }); + + it("reads a title-classed paragraph as a heading", () => { + const blocks = htmlToBlocks('

Annotation

'); + expect(blocks).toEqual([{ kind: BlockKind.Heading, text: "Annotation" }]); + }); + + it("keeps a container's own loose text instead of dropping it", () => { + const blocks = htmlToBlocks("
Loose text

Nested

More loose
"); + expect(blocks.map((b) => b.text)).toEqual(["Loose text", "Nested", "More loose"]); + }); + + it("treats inline markup as part of the surrounding text run", () => { + const blocks = htmlToBlocks("
Hello brave world
"); + expect(blocks).toEqual([{ kind: BlockKind.Paragraph, text: "Hello brave world" }]); + }); + + it("does not double-count text claimed by a nested block", () => { + const blocks = htmlToBlocks("

Only once

"); + expect(blocks.map((b) => b.text)).toEqual(["Only once"]); + }); + it("skips script/style and empty blocks, treats li as paragraphs", () => { const blocks = htmlToBlocks( "

", @@ -52,6 +84,31 @@ describe("bookContentFrom", () => { expect(content.spine[0]?.title).toBe("Chapter 1"); }); + // The positional fallback used to count SOURCE sections, so a book whose + // cover/nav pages dropped out started at "Chapter 2" and skipped numbers. + it("numbers the fallback by kept position, not source index", () => { + const content = bookContentFrom({ title: "T", author: "" }, [ + { title: "", html: "" }, + { title: "", html: "

One

" }, + { title: "", html: "

Two

" }, + ]); + expect(content.spine.map((item) => item.title)).toEqual(["Chapter 1", "Chapter 2"]); + }); + + it("falls back to the section's own first heading before a positional name", () => { + const content = bookContentFrom({ title: "T", author: "" }, [ + { title: "", html: '
\u0413\u041b\u0410\u0412\u0410

Body

' }, + ]); + expect(content.spine[0]?.title).toBe("\u0413\u041b\u0410\u0412\u0410"); + }); + + it("prefers the navigation label over the section's heading", () => { + const content = bookContentFrom({ title: "T", author: "" }, [ + { title: "From the NCX", html: "

In the body

Body

" }, + ]); + expect(content.spine[0]?.title).toBe("From the NCX"); + }); + it("keeps one spine item even when every section is empty", () => { const content = bookContentFrom({ title: "Empty", author: "" }, [{ title: "", html: "" }]); expect(content.spine).toHaveLength(1); diff --git a/apps/books/src/logic/epub-content.ts b/apps/books/src/logic/epub-content.ts index d09e9723..810a7dd3 100644 --- a/apps/books/src/logic/epub-content.ts +++ b/apps/books/src/logic/epub-content.ts @@ -23,18 +23,78 @@ const HEADING_TAGS = new Set(["H1", "H2", "H3", "H4", "H5", "H6"]); const BLOCK_TAGS = new Set(["P", "LI", "BLOCKQUOTE", "DD", "DT", "FIGCAPTION"]); const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "HEAD"]); +/** Tags whose text belongs to the surrounding block, never to a block of their + * own. Without this list the walker treated ``/`` as generic + * containers and descended past their text, dropping it on the floor. */ +const INLINE_TAGS = new Set([ + "A", + "ABBR", + "B", + "BDI", + "BDO", + "BIG", + "BR", + "CITE", + "CODE", + "DEL", + "EM", + "FONT", + "I", + "IMG", + "INS", + "KBD", + "MARK", + "NOBR", + "Q", + "RUBY", + "S", + "SAMP", + "SMALL", + "SPAN", + "STRIKE", + "STRONG", + "SUB", + "SUP", + "TIME", + "TT", + "U", + "VAR", + "WBR", +]); + +/** + * Chapter titles are not always `

`–`

`. FB2→EPUB converters — the + * dominant source of Russian-language EPUBs, and common well beyond them — + * emit `
ГЛАВА ПЕРВАЯ
`, and Calibre-style pipelines + * emit `

`. Matching the class as well as the tag is + * what keeps those books' headings instead of silently flattening every + * chapter into unbroken prose. + */ +const TITLE_CLASS_RE = /(?:^|[\s_-])(?:title|subtitle|head(?:er|ing)?|chapter)\d*(?:[\s_-]|$)/i; + /** Collapse runs of whitespace (incl. newlines from the source markup) to single * spaces and trim — the reflow renderer owns visual wrapping. */ function normalizeText(raw: string): string { return raw.replace(/\s+/g, " ").trim(); } +/** Heading or paragraph, decided by tag first and then by the title-ish class + * the converter left behind. */ +function blockKindFor(node: Element): BlockKind { + if (HEADING_TAGS.has(node.tagName.toUpperCase())) return BlockKind.Heading; + return TITLE_CLASS_RE.test(node.getAttribute("class") ?? "") + ? BlockKind.Heading + : BlockKind.Paragraph; +} + /** - * Extract reflowable blocks from a section's XHTML. Headings (`h1`–`h6`) become - * {@link BlockKind.Heading}; paragraph-like blocks (`p`, `li`, `blockquote`, …) - * become {@link BlockKind.Paragraph}. The walk descends through generic - * containers (`div`/`section`/`article`) but never re-emits text already claimed - * by a nested block, so content isn't duplicated. Empty blocks drop. + * Extract reflowable blocks from a section's XHTML. Headings (`h1`–`h6`, or a + * title-classed element) become {@link BlockKind.Heading}; paragraph-like + * blocks (`p`, `li`, `blockquote`, …) become {@link BlockKind.Paragraph}. The + * walk descends through generic containers (`div`/`section`/`article`) but + * never re-emits text already claimed by a nested block, so content isn't + * duplicated — and a container's own loose text is emitted in document order + * rather than dropped. Empty blocks drop. */ export function htmlToBlocks(html: string): ContentBlock[] { const doc = new DOMParser().parseFromString(html, "text/html"); @@ -43,18 +103,35 @@ export function htmlToBlocks(html: string): ContentBlock[] { const visit = (node: Element): void => { const tag = node.tagName.toUpperCase(); if (SKIP_TAGS.has(tag)) return; - if (HEADING_TAGS.has(tag)) { + if (HEADING_TAGS.has(tag) || BLOCK_TAGS.has(tag)) { const text = normalizeText(node.textContent ?? ""); - if (text) blocks.push({ kind: BlockKind.Heading, text }); + if (text) blocks.push({ kind: blockKindFor(node), text }); return; } - if (BLOCK_TAGS.has(tag)) { - const text = normalizeText(node.textContent ?? ""); - if (text) blocks.push({ kind: BlockKind.Paragraph, text }); - return; + // Generic container. Its own text runs (bare text nodes + inline + // elements) are real content and become blocks in document order; + // nested block-level children are descended into. + let pending = ""; + const flush = (): void => { + const text = normalizeText(pending); + pending = ""; + if (text) blocks.push({ kind: blockKindFor(node), text }); + }; + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + pending += child.nodeValue ?? ""; + continue; + } + if (child.nodeType !== Node.ELEMENT_NODE) continue; + const el = child as Element; + if (INLINE_TAGS.has(el.tagName.toUpperCase())) { + pending += el.textContent ?? ""; + continue; + } + flush(); + visit(el); } - // Generic container — descend so nested headings/paragraphs are found. - for (const child of Array.from(node.children)) visit(child); + flush(); }; const root = doc.body ?? doc.documentElement; @@ -69,6 +146,17 @@ export function htmlToBlocks(html: string): ContentBlock[] { return blocks; } +/** The chapter title for a section: the EPUB's own navigation label when the + * parser resolved one, else the section's first heading block, else a + * positional fallback numbered by the section's place in the *kept* spine + * (cover/nav pages drop out before this, so the count has no holes). */ +function titleFor(section: RawSection, blocks: readonly ContentBlock[], position: number): string { + if (section.title) return section.title; + const heading = blocks.find((b) => b.kind === BlockKind.Heading); + if (heading) return heading.text; + return `Chapter ${position}`; +} + /** Build `BookContent` from EPUB metadata + the parsed spine sections. Sections * with no extractable text are dropped (cover/nav pages), but at least one * empty spine item is kept so the reader never mounts an empty book. */ @@ -77,11 +165,11 @@ export function bookContentFrom( sections: readonly RawSection[], ): BookContent { const spine: SpineItem[] = []; - sections.forEach((section, index) => { + for (const section of sections) { const blocks = htmlToBlocks(section.html); - if (blocks.length === 0) return; - spine.push({ title: section.title || `Chapter ${index + 1}`, blocks }); - }); + if (blocks.length === 0) continue; + spine.push({ title: titleFor(section, blocks, spine.length + 1), blocks }); + } if (spine.length === 0) spine.push({ title: meta.title || "Untitled", blocks: [] }); return { title: meta.title || "Untitled", diff --git a/apps/books/src/logic/epub-nav.test.ts b/apps/books/src/logic/epub-nav.test.ts new file mode 100644 index 00000000..01d133b1 --- /dev/null +++ b/apps/books/src/logic/epub-nav.test.ts @@ -0,0 +1,61 @@ +/** EPUB navigation matching — nav labels are the book's own chapter titles, + * and producers disagree about how they spell an href. */ +import { describe, expect, it } from "vitest"; +import { navigationTitles, normalizeHref, titleForHref } from "./epub-nav"; + +describe("normalizeHref", () => { + it("strips fragments, leading ./ or /, and percent-encoding", () => { + expect(normalizeHref("./content_1.html#ch1")).toBe("content_1.html"); + expect(normalizeHref("/OEBPS/content_1.html")).toBe("OEBPS/content_1.html"); + expect(normalizeHref("text/%D0%B3%D0%BB%D0%B0%D0%B2%D0%B0.html")).toBe("text/глава.html"); + }); + + it("survives a malformed percent-escape rather than throwing", () => { + expect(normalizeHref("a%zz.html")).toBe("a%zz.html"); + }); +}); + +describe("navigationTitles", () => { + it("indexes an entry under both its path and its bare filename", () => { + const titles = navigationTitles([{ href: "text/ch1.html", label: "One" }]); + expect(titleForHref(titles, "text/ch1.html")).toBe("One"); + expect(titleForHref(titles, "ch1.html")).toBe("One"); + }); + + it("collapses whitespace in the label", () => { + const titles = navigationTitles([{ href: "a.html", label: " ГЛАВА ПЕРВАЯ " }]); + expect(titleForHref(titles, "a.html")).toBe("ГЛАВА ПЕРВАЯ"); + }); + + it("matches a spine href against a nav entry that carries a fragment", () => { + const titles = navigationTitles([{ href: "ch1.html#start", label: "One" }]); + expect(titleForHref(titles, "ch1.html")).toBe("One"); + }); + + it("walks nested subitems", () => { + const titles = navigationTitles([ + { href: "part1.html", label: "Part One", subitems: [{ href: "ch2.html", label: "Two" }] }, + ]); + expect(titleForHref(titles, "ch2.html")).toBe("Two"); + }); + + // Several nav entries commonly point into one file with different + // fragments; the chapter's own top-level label must win over a sub-heading. + it("lets the first entry for a file win", () => { + const titles = navigationTitles([ + { href: "ch1.html", label: "Chapter" }, + { href: "ch1.html#s2", label: "A subsection" }, + ]); + expect(titleForHref(titles, "ch1.html")).toBe("Chapter"); + }); + + it("ignores entries with no label or no href", () => { + const titles = navigationTitles([{ href: "a.html" }, { label: "orphan" }]); + expect(titleForHref(titles, "a.html")).toBe(""); + }); + + it("returns empty for an uncovered section (a cover page, or no nav doc)", () => { + expect(titleForHref(navigationTitles([]), "cover.html")).toBe(""); + expect(titleForHref(navigationTitles([]), undefined)).toBe(""); + }); +}); diff --git a/apps/books/src/logic/epub-nav.ts b/apps/books/src/logic/epub-nav.ts new file mode 100644 index 00000000..5b42552e --- /dev/null +++ b/apps/books/src/logic/epub-nav.ts @@ -0,0 +1,74 @@ +/** + * EPUB navigation (NCX / nav-doc) → per-section chapter titles. Split out of + * the epub.js glue so the href matching — the part that actually varies between + * producers — is pure and unit-testable without an archive. + * + * The matching is deliberately forgiving: a nav entry's `href` and a spine + * item's `href` are both relative to the OPF, but producers disagree about + * `./` prefixes, percent-encoding, and fragments (`content_1.html#ch1`), so + * entries are indexed under both their normalized path and their bare + * filename. + */ + +/** One entry of an epub.js `navigation.toc` (recursive). */ +export type NavEntry = { + href?: string; + label?: string; + subitems?: readonly NavEntry[]; +}; + +function decode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +/** Strip the fragment and any leading `./` or `/`, then percent-decode. */ +export function normalizeHref(href: string): string { + const path = href.split("#")[0] ?? ""; + return decode(path.replace(/^\.?\//, "")); +} + +/** The bare filename of an href — the last-resort key when two producers + * disagree about how deep the path is. */ +function basename(path: string): string { + return path.split("/").pop() ?? path; +} + +/** + * Flatten a nav tree into a lookup of href → label. Each entry is registered + * under its normalized path AND its bare filename; the first entry to claim a + * key wins, so a chapter's own top-level label beats a deep sub-heading that + * points into the same file. + */ +export function navigationTitles(toc: readonly NavEntry[]): ReadonlyMap { + const titles = new Map(); + const walk = (entries: readonly NavEntry[]): void => { + for (const entry of entries) { + const label = (entry.label ?? "").replace(/\s+/g, " ").trim(); + const href = entry.href ?? ""; + if (label && href) { + const path = normalizeHref(href); + if (path && !titles.has(path)) titles.set(path, label); + const file = basename(path); + if (file && !titles.has(file)) titles.set(file, label); + } + if (entry.subitems?.length) walk(entry.subitems); + } + }; + walk(toc); + return titles; +} + +/** The navigation label for a spine item's href, or `""` when the book's + * navigation doesn't cover it (a cover page, or no nav document at all). */ +export function titleForHref( + titles: ReadonlyMap, + href: string | undefined, +): string { + if (!href) return ""; + const path = normalizeHref(href); + return titles.get(path) ?? titles.get(basename(path)) ?? ""; +} diff --git a/apps/books/src/logic/epub-parser.ts b/apps/books/src/logic/epub-parser.ts index c35f5878..424690ce 100644 --- a/apps/books/src/logic/epub-parser.ts +++ b/apps/books/src/logic/epub-parser.ts @@ -6,8 +6,9 @@ * with the per-app sandbox CSP) is never instantiated. * * Not unit-tested (needs epub.js + a real EPUB + a DOM) — the extraction logic - * it delegates to lives in the jsdom-tested `epub-content.ts`. Verified in the - * real shell (a real EPUB renders in the reflow reader). + * it delegates to lives in the jsdom-tested `epub-content.ts`, and the + * navigation-label matching lives in the pure, tested `epub-nav.ts`. Verified in + * the real shell (a real EPUB renders in the reflow reader). * * epub.js (a heavy CJS lib + JSZip) is **dynamically imported** so it stays out * of the app's initial bundle/module graph — it loads only when a user actually @@ -16,6 +17,7 @@ import type { BookContent } from "./content"; import { type RawSection, bookContentFrom } from "./epub-content"; +import { type NavEntry, navigationTitles, titleForHref } from "./epub-nav"; /** Parse `bytes` (an EPUB file) into the reflow reader's `BookContent`. */ export async function parseEpub(bytes: Uint8Array): Promise { @@ -29,6 +31,17 @@ export async function parseEpub(bytes: Uint8Array): Promise { await book.ready; const metadata = book.packaging?.metadata; const meta = { title: metadata?.title ?? "", author: metadata?.creator ?? "" }; + // The NCX / nav-doc labels are the book's OWN chapter titles. Without + // them every chapter fell back to a positional "Chapter N", which is + // what the TOC showed. A book with no navigation document is normal — + // the extractor then falls back to the section's first heading. + let titles: ReadonlyMap = new Map(); + try { + const navigation = (await book.loaded.navigation) as { toc?: readonly NavEntry[] } | null; + titles = navigationTitles(navigation?.toc ?? []); + } catch (error) { + console.warn("[books] epub navigation unavailable:", error); + } const sections: RawSection[] = []; for (let i = 0; ; i += 1) { const section = book.spine.get(i); @@ -45,7 +58,7 @@ export async function parseEpub(bytes: Uint8Array): Promise { | undefined; const body = doc?.querySelector?.("body"); const html = body?.innerHTML ?? doc?.innerHTML ?? ""; - sections.push({ title: "", html }); + sections.push({ title: titleForHref(titles, section.href), html }); } catch (error) { console.warn(`[books] epub section ${i} load failed:`, error); } finally { diff --git a/apps/books/src/logic/measured-pagination.test.ts b/apps/books/src/logic/measured-pagination.test.ts new file mode 100644 index 00000000..ddfbf64a --- /dev/null +++ b/apps/books/src/logic/measured-pagination.test.ts @@ -0,0 +1,176 @@ +/** Layout-measured pagination (F-500) — page breaks packed from real block + * geometry. The headline invariant is that a page never holds more than fits: + * the char-budget model it replaces overflowed 61 of 77 pages on a real EPUB, + * and `.books__page`'s `overflow: hidden` silently ate the difference. */ +import { describe, expect, it } from "vitest"; +import { BlockKind, type ContentBlock, type SpineItem, indexSpineItem } from "./content"; +import { type BlockMetrics, paginateMeasured } from "./measured-pagination"; + +const PAGE = 100; + +function para(text: string): ContentBlock { + return { kind: BlockKind.Paragraph, text }; +} + +function item(...blocks: ContentBlock[]): SpineItem { + return { title: "T", blocks }; +} + +/** Uniform metrics: every block `height` tall with a 10px trailing margin. */ +function metrics(heights: readonly number[], lines = 1): BlockMetrics[] { + return heights.map((height) => ({ height, marginBottom: 10, lines })); +} + +/** The height one page's blocks actually occupy, mirroring what the browser + * lays out — the assertion the old model could not make. */ +function occupied( + spineItem: ReturnType, + blockMetrics: readonly BlockMetrics[], + from: number, + to: number, +): number { + let total = 0; + spineItem.blocks.forEach((indexed, i) => { + if (indexed.end <= from || indexed.start >= to) return; + const m = blockMetrics[i]; + if (m) total += m.height + m.marginBottom; + }); + return total; +} + +describe("paginateMeasured", () => { + it("fills a page to the measured height and breaks before the overflowing block", () => { + // 4 blocks of 30+10=40 each: two fit (80), the third would make 120. + const spine = [indexSpineItem(item(para("aaaa"), para("bbbb"), para("cccc"), para("dddd")))]; + const pagination = paginateMeasured(spine, [metrics([30, 30, 30, 30])], PAGE); + expect(pagination.pages).toHaveLength(2); + expect(pagination.pages[0]?.range).toEqual({ + start: { spineIndex: 0, charOffset: 0 }, + end: { spineIndex: 0, charOffset: 8 }, + }); + expect(pagination.pages[1]?.range.end).toEqual({ spineIndex: 0, charOffset: 16 }); + }); + + it("never packs a page beyond the available height", () => { + const blocks = Array.from({ length: 40 }, (_, i) => para("x".repeat(i + 1))); + const heights = blocks.map((_, i) => 12 + (i % 7) * 9); + const spineItem = indexSpineItem(item(...blocks)); + const m = metrics(heights); + const pagination = paginateMeasured([spineItem], [m], PAGE); + for (const page of pagination.pages) { + const used = occupied(spineItem, m, page.range.start.charOffset, page.range.end.charOffset); + expect(used).toBeLessThanOrEqual(PAGE); + } + }); + + it("covers every character exactly once, in order", () => { + const blocks = Array.from({ length: 25 }, (_, i) => para("y".repeat(10 + i))); + const spineItem = indexSpineItem(item(...blocks)); + const pagination = paginateMeasured([spineItem], [metrics(blocks.map(() => 45))], PAGE); + let cursor = 0; + for (const page of pagination.pages) { + expect(page.range.start.charOffset).toBe(cursor); + cursor = page.range.end.charOffset; + } + expect(cursor).toBe(spineItem.length); + }); + + it("splits a block taller than a whole page across pages", () => { + // A 10-line, 500px block (50px/line) into a 100px page: 2 lines fit, + // one is kept as re-wrap headroom, so each page takes 1 line = 10 chars. + const spineItem = indexSpineItem(item(para("z".repeat(100)))); + const pagination = paginateMeasured( + [spineItem], + [[{ height: 500, marginBottom: 0, lines: 10 }]], + PAGE, + ); + expect(pagination.pages).toHaveLength(10); + expect(pagination.pages[0]?.range.end.charOffset).toBe(10); + expect(pagination.pages.at(-1)?.range.end.charOffset).toBe(100); + }); + + // A split chunk re-flows on its own; a word that straddled the cut can wrap + // it one line further than the source block measured. Filling a split page + // to the brim clipped the tail of a long verse paragraph by ~2px. + it("keeps a line of headroom on a split page", () => { + const spineItem = indexSpineItem(item(para("w".repeat(120)))); + const pagination = paginateMeasured( + [spineItem], + // 12 lines of 25px into a 100px page: 4 lines fit, 3 are used. + [[{ height: 300, marginBottom: 0, lines: 12 }]], + PAGE, + ); + expect(pagination.pages[0]?.range.end.charOffset).toBe(30); + }); + + it("closes the page in progress before splitting an over-tall block", () => { + const spineItem = indexSpineItem(item(para("aa"), para("b".repeat(50)))); + const pagination = paginateMeasured( + [spineItem], + [ + [ + { height: 20, marginBottom: 10, lines: 1 }, + { height: 400, marginBottom: 0, lines: 8 }, + ], + ], + PAGE, + ); + expect(pagination.pages[0]?.range).toEqual({ + start: { spineIndex: 0, charOffset: 0 }, + end: { spineIndex: 0, charOffset: 2 }, + }); + expect(pagination.pages[1]?.range.start.charOffset).toBe(2); + }); + + it("leaves room for a split chunk's own trailing margin", () => { + // 20 lines of 35px = 700px + a 16px margin into a 712px page: budgeting + // the full page height would fit 20 lines and render 716px. + const spineItem = indexSpineItem(item(para("v".repeat(200)))); + const pagination = paginateMeasured( + [spineItem], + [[{ height: 700, marginBottom: 16, lines: 20 }]], + 712, + ); + const first = pagination.pages[0]?.range.end.charOffset ?? 0; + // (712 - 16) / 35 = 19 lines fit, one is headroom => 18 lines of 10 chars. + expect(first).toBe(180); + }); + + it("never spans spine items — a chapter always starts a new page", () => { + const spine = [indexSpineItem(item(para("aa"))), indexSpineItem(item(para("bb")))]; + const pagination = paginateMeasured(spine, [metrics([20]), metrics([20])], PAGE); + expect(pagination.pages).toHaveLength(2); + expect(pagination.pages[0]?.range.start.spineIndex).toBe(0); + expect(pagination.pages[1]?.range.start.spineIndex).toBe(1); + for (const page of pagination.pages) { + expect(page.range.start.spineIndex).toBe(page.range.end.spineIndex); + } + }); + + it("keeps an empty spine item reachable as one empty page", () => { + const pagination = paginateMeasured([indexSpineItem(item())], [[]], PAGE); + expect(pagination.pages).toHaveLength(1); + expect(pagination.pages[0]?.range).toEqual({ + start: { spineIndex: 0, charOffset: 0 }, + end: { spineIndex: 0, charOffset: 0 }, + }); + }); + + it("reports total characters across the book", () => { + const spine = [indexSpineItem(item(para("abc"))), indexSpineItem(item(para("de")))]; + expect(paginateMeasured(spine, [metrics([20]), metrics([20])], PAGE).totalChars).toBe(5); + }); + + it("falls back to zero-height for a block it has no metrics for", () => { + const spineItem = indexSpineItem(item(para("aa"), para("bb"))); + const pagination = paginateMeasured([spineItem], [[]], PAGE); + expect(pagination.pages).toHaveLength(1); + }); + + it("still makes progress when the page height is degenerate", () => { + const spineItem = indexSpineItem(item(para("aa"), para("bb"))); + const pagination = paginateMeasured([spineItem], [metrics([50, 50])], 0); + expect(pagination.pages.length).toBeGreaterThan(0); + expect(pagination.pages.at(-1)?.range.end.charOffset).toBe(4); + }); +}); diff --git a/apps/books/src/logic/measured-pagination.ts b/apps/books/src/logic/measured-pagination.ts new file mode 100644 index 00000000..7374adaa --- /dev/null +++ b/apps/books/src/logic/measured-pagination.ts @@ -0,0 +1,126 @@ +/** + * Layout-measured pagination — page breaks derived from what the text + * ACTUALLY occupies, not from a character budget. + * + * The original model (`charBudgetPaginator`) sized a page as + * `columns × 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`'s `overflow: hidden` silently ate + * the excess. Measured against a real EPUB (a paragraph-dense Russian + * translation) 61 of 77 pages overflowed, losing 17.6% of each page on + * average and 73% at worst — text the reader could neither see nor scroll to. + * + * The renderer measures every block ONCE per (width × typography) into an + * offscreen ruler and hands the metrics here; packing them into pages is then + * pure arithmetic, so this stays unit-testable with no DOM. Locators are + * untouched — a page is still a `LocatorRange` over character offsets, so + * highlights, reading position and progress all keep working. + */ + +import { type LocatorRange, makeLocator } from "../types/locator"; +import type { IndexedSpineItem } from "./content"; +import type { Page, Pagination } from "./pagination"; + +/** The measured geometry of one rendered block. */ +export type BlockMetrics = { + /** Height of the block's box in px, excluding its bottom margin. */ + height: number; + /** The margin that follows the block in flow. `.books__page` establishes a + * block formatting context (`overflow: hidden`), so even the last block's + * margin counts toward the page's content height. */ + marginBottom: number; + /** Rendered line count — only used to split a block taller than a whole + * page, where the break must land mid-paragraph. */ + lines: number; +}; + +/** Per-spine-item block metrics, parallel to `IndexedSpineItem.blocks`. */ +export type SpineMetrics = readonly (readonly BlockMetrics[])[]; + +const FALLBACK_METRICS: BlockMetrics = { height: 0, marginBottom: 0, lines: 1 }; + +/** The char ranges one spine item's pages cover, in reading order. */ +function pageRangesForItem( + item: IndexedSpineItem, + metrics: readonly BlockMetrics[], + pageHeight: number, +): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + let pageStart = 0; + let used = 0; + + item.blocks.forEach((indexed, index) => { + const m = metrics[index] ?? FALLBACK_METRICS; + const advance = m.height + m.marginBottom; + const { start, end } = indexed; + + if (advance > pageHeight) { + // A block taller than the page must break inside itself. Close the + // page in progress, then slice the block by whole lines — the char + // offsets are proportional because a line's character count is + // near-constant within one block. + if (used > 0) ranges.push([pageStart, start]); + const lines = Math.max(1, m.lines); + const lineHeight = Math.max(1, m.height / lines); + // Every chunk is still rendered as the block it came from, so it + // carries the block's trailing margin too — the lines only get what + // is left after it. Budgeting the whole page height for lines put a + // 20-line chunk plus its 16px margin into a 712px page. + const usable = Math.max(lineHeight, pageHeight - m.marginBottom); + const linesPerPage = Math.max(1, Math.floor(usable / lineHeight)); + // Each chunk re-wraps independently, and a word that straddled the + // cut can push the chunk one line past what the whole block + // measured — so a split page keeps a line of headroom. Without it + // the tail of a long verse paragraph clipped by ~2px. + const fitLines = linesPerPage > 1 ? linesPerPage - 1 : 1; + const chars = Math.max(1, Math.ceil(((end - start) * fitLines) / lines)); + for (let offset = start; offset < end; offset += chars) { + ranges.push([offset, Math.min(offset + chars, end)]); + } + pageStart = end; + used = 0; + return; + } + + if (used > 0 && used + advance > pageHeight) { + ranges.push([pageStart, start]); + pageStart = start; + used = advance; + return; + } + used += advance; + }); + + // The tail page, plus the empty-spine-item case (a chapter with no blocks + // still gets one page so it stays reachable from the TOC). + if (used > 0 || ranges.length === 0) ranges.push([pageStart, item.length]); + return ranges; +} + +/** + * Page breaks from measured block geometry. `availableHeight` is the reading + * page's content-box height in px. Pages never span spine items (a chapter + * always starts a new page), exactly as the char-budget model did. + */ +export function paginateMeasured( + spine: readonly IndexedSpineItem[], + metrics: SpineMetrics, + availableHeight: number, +): Pagination { + const pageHeight = Math.max(1, availableHeight); + const pages: Page[] = []; + let totalChars = 0; + + spine.forEach((item, spineIndex) => { + totalChars += item.length; + for (const [from, to] of pageRangesForItem(item, metrics[spineIndex] ?? [], pageHeight)) { + const range: LocatorRange = { + start: makeLocator(spineIndex, from), + end: makeLocator(spineIndex, to), + }; + pages.push({ index: pages.length, range }); + } + }); + + return { pages, totalChars }; +} diff --git a/apps/books/src/logic/pagination.ts b/apps/books/src/logic/pagination.ts index b26a9762..6bb6c0c2 100644 --- a/apps/books/src/logic/pagination.ts +++ b/apps/books/src/logic/pagination.ts @@ -52,6 +52,19 @@ export function paginate(spine: IndexedSpineItem[], charsPerPage: number): Pagin return { pages, totalChars }; } +/** How a `ReaderState` turns content into pages. The reflow reader supplies a + * layout-measured one (`paginateMeasured` bound to freshly measured block + * geometry); the char-budget one below stays for content that has no rendered + * geometry to measure — tests, and the first paint before the ruler has run. */ +export type Paginator = (spine: IndexedSpineItem[]) => Pagination; + +/** The original character-budget strategy as a `Paginator`. Approximate by + * construction — it cannot see ragged line ends or paragraph margins — so it + * is a fallback, never the reading path. */ +export function charBudgetPaginator(charsPerPage: number): Paginator { + return (spine) => paginate(spine, charsPerPage); +} + /** True when `locator` falls within `page.range` (start-inclusive, * end-exclusive). A locator sitting exactly on a page break belongs to the * next page, not this one — `pageIndexForLocator` resolves that boundary. */ diff --git a/apps/books/src/logic/reader-state.test.ts b/apps/books/src/logic/reader-state.test.ts index fb0d9291..53f64879 100644 --- a/apps/books/src/logic/reader-state.test.ts +++ b/apps/books/src/logic/reader-state.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { makeLocator } from "../types/locator"; +import { charBudgetPaginator } from "./pagination"; import { canGoNext, canGoPrev, @@ -17,27 +18,27 @@ import { SAMPLE_BOOK_CONTENT } from "./sample-book"; describe("reader navigation", () => { it("starts on page 0 with prev disabled", () => { - const s = createReaderState(SAMPLE_BOOK_CONTENT, 50); + const s = createReaderState(SAMPLE_BOOK_CONTENT, charBudgetPaginator(50)); expect(s.pageIndex).toBe(0); expect(canGoPrev(s)).toBe(false); expect(canGoNext(s)).toBe(true); }); it("next then prev returns to the same page", () => { - const start = createReaderState(SAMPLE_BOOK_CONTENT, 50); + const start = createReaderState(SAMPLE_BOOK_CONTENT, charBudgetPaginator(50)); const back = prevPage(nextPage(start)); expect(back.pageIndex).toBe(start.pageIndex); }); it("cannot advance past the last page", () => { - let s = createReaderState(SAMPLE_BOOK_CONTENT, 50); + let s = createReaderState(SAMPLE_BOOK_CONTENT, charBudgetPaginator(50)); s = goToPage(s, pageCount(s) - 1); expect(canGoNext(s)).toBe(false); expect(nextPage(s).pageIndex).toBe(s.pageIndex); }); it("progress measures through the END of the visible page: small on the first, exactly 1 on the last", () => { - const start = createReaderState(SAMPLE_BOOK_CONTENT, 50); + const start = createReaderState(SAMPLE_BOOK_CONTENT, charBudgetPaginator(50)); // First page covers its 50-char budget of a much longer book. expect(readingProgress(start)).toBeGreaterThan(0); expect(readingProgress(start)).toBeLessThan(0.1); @@ -46,7 +47,7 @@ describe("reader navigation", () => { }); it("progress grows monotonically page over page", () => { - let s = createReaderState(SAMPLE_BOOK_CONTENT, 50); + let s = createReaderState(SAMPLE_BOOK_CONTENT, charBudgetPaginator(50)); let previous = 0; for (let i = 0; i < pageCount(s); i++) { s = goToPage(s, i); @@ -57,12 +58,12 @@ describe("reader navigation", () => { }); it("progress is 0 for an empty book", () => { - const s = createReaderState({ title: "t", author: "a", spine: [] }, 50); + const s = createReaderState({ title: "t", author: "a", spine: [] }, charBudgetPaginator(50)); expect(readingProgress(s)).toBe(0); }); it("goToLocator jumps to the page holding a locator", () => { - const s = createReaderState(SAMPLE_BOOK_CONTENT, 50); + const s = createReaderState(SAMPLE_BOOK_CONTENT, charBudgetPaginator(50)); const jumped = goToLocator(s, makeLocator(1, 0)); const loc = currentLocator(jumped); expect(loc?.spineIndex).toBe(1); @@ -71,10 +72,10 @@ describe("reader navigation", () => { describe("repaginate keeps the reader on the same words (the headline invariant)", () => { it("a smaller budget re-breaks pages but the current locator's spine survives", () => { - const start = createReaderState(SAMPLE_BOOK_CONTENT, 80); + const start = createReaderState(SAMPLE_BOOK_CONTENT, charBudgetPaginator(80)); const moved = goToLocator(start, makeLocator(1, 40)); const anchorBefore = currentLocator(moved); - const reflowed = repaginate(moved, 20); + const reflowed = repaginate(moved, charBudgetPaginator(20)); const anchorAfter = currentLocator(reflowed); // More pages after a smaller budget. expect(pageCount(reflowed)).toBeGreaterThan(pageCount(moved)); diff --git a/apps/books/src/logic/reader-state.ts b/apps/books/src/logic/reader-state.ts index 3e6076a6..e5d6f919 100644 --- a/apps/books/src/logic/reader-state.ts +++ b/apps/books/src/logic/reader-state.ts @@ -11,25 +11,23 @@ import { type BookContent, type IndexedSpineItem, indexSpine, totalLength } from import { type Page, type Pagination, + type Paginator, clampPageIndex, pageIndexForLocator, - paginate, progressAtLocator, } from "./pagination"; export type ReaderState = { spine: IndexedSpineItem[]; totalChars: number; - charsPerPage: number; pagination: Pagination; pageIndex: number; }; -export function createReaderState(content: BookContent, charsPerPage: number): ReaderState { +export function createReaderState(content: BookContent, paginator: Paginator): ReaderState { const spine = indexSpine(content); const totalChars = totalLength(spine); - const pagination = paginate(spine, charsPerPage); - return { spine, totalChars, charsPerPage, pagination, pageIndex: 0 }; + return { spine, totalChars, pagination: paginator(spine), pageIndex: 0 }; } export function currentPage(state: ReaderState): Page | null { @@ -82,13 +80,12 @@ export function readingProgress(state: ReaderState): number { return progressAtLocator(state.spine, state.totalChars, page.range.end); } -/** Re-paginate with a new chars-per-page budget (a typography change), +/** Re-paginate against a fresh `Paginator` (a typography or viewport change), * staying on the page that holds the *current* locator. This is the * preview-drop's headline invariant: change the glass, keep your place. */ -export function repaginate(state: ReaderState, charsPerPage: number): ReaderState { +export function repaginate(state: ReaderState, paginator: Paginator): ReaderState { const anchor = currentLocator(state); - const pagination = paginate(state.spine, charsPerPage); - const next: ReaderState = { ...state, charsPerPage, pagination, pageIndex: 0 }; + const next: ReaderState = { ...state, pagination: paginator(state.spine), pageIndex: 0 }; if (!anchor) return next; return goToLocator(next, anchor); } diff --git a/apps/books/src/render/chrome.ts b/apps/books/src/render/chrome.ts index 5f2c3db2..0620e030 100644 --- a/apps/books/src/render/chrome.ts +++ b/apps/books/src/render/chrome.ts @@ -13,6 +13,16 @@ import { t } from "../i18n"; export enum ReaderChord { Next = "ArrowRight", Prev = "ArrowLeft", + // A paginated reader has no scrollbar, so every gesture a reader already + // reaches for to move down a page has to turn one instead. Binding only + // the arrows left Space / PageDown / scroll doing nothing at all, which + // reads as a frozen book rather than a reader that wants ArrowRight. + NextLine = "ArrowDown", + PrevLine = "ArrowUp", + NextScreen = "PageDown", + PrevScreen = "PageUp", + NextSpace = "Space", + PrevSpace = "Shift+Space", Larger = "CmdOrCtrl+=", Smaller = "CmdOrCtrl+-", Highlights = "CmdOrCtrl+Shift+H", diff --git a/apps/books/src/render/measure-blocks.ts b/apps/books/src/render/measure-blocks.ts new file mode 100644 index 00000000..978e0564 --- /dev/null +++ b/apps/books/src/render/measure-blocks.ts @@ -0,0 +1,100 @@ +/** + * The reflow reader's ruler — measures every content block once per + * (width × typography) so pagination can pack real geometry instead of + * guessing from a character count. + * + * All blocks go into ONE offscreen `.books__page` clone and are read back in a + * single pass, so the whole book costs one layout rather than one per block. + * The clone carries the live page's classes and sits inside the same stage, so + * it inherits the `--reader-*` custom properties and measures against exactly + * the typography the reader is painting. + */ + +import { BlockKind, type IndexedSpineItem } from "../logic/content"; +import type { BlockMetrics, SpineMetrics } from "../logic/measured-pagination"; + +/** An element's content box in FRACTIONAL px. + * + * `clientWidth`/`clientHeight` round to whole pixels, and the reading column is + * `max-width: 65ch` — a fractional width. Handing the ruler the rounded 736px + * where the page is really 735.703px made it 0.3px roomier than the surface it + * models, which is enough for one word to fit that would not fit live: the + * block measured 7 lines and rendered 8, and that page clipped. Sub-pixel + * differences are not cosmetic here — they cost a whole line. */ +export function contentBox(el: HTMLElement): { width: number; height: number } { + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + const px = (value: string): number => { + const n = Number.parseFloat(value); + return Number.isFinite(n) ? n : 0; + }; + return { + width: + rect.width - + px(style.paddingLeft) - + px(style.paddingRight) - + px(style.borderLeftWidth) - + px(style.borderRightWidth), + height: + rect.height - + px(style.paddingTop) - + px(style.paddingBottom) - + px(style.borderTopWidth) - + px(style.borderBottomWidth), + }; +} + +function readMetrics(el: HTMLElement): BlockMetrics { + const style = getComputedStyle(el); + const height = el.getBoundingClientRect().height; + const lineHeight = Number.parseFloat(style.lineHeight); + const marginBottom = Number.parseFloat(style.marginBottom); + return { + height, + marginBottom: Number.isFinite(marginBottom) ? marginBottom : 0, + // `line-height: normal` reports non-numeric; one line is the safe floor + // (it only ever affects where an over-tall block splits). + lines: + Number.isFinite(lineHeight) && lineHeight > 0 ? Math.max(1, Math.round(height / lineHeight)) : 1, + }; +} + +/** Measure every block of every spine item at the live page width. Returns + * empty metrics when the page has no layout yet (a hidden window, or the + * first paint before the box is resolved) — the caller falls back to the + * character-budget paginator until a real box arrives. */ +export function measureSpineBlocks( + stage: HTMLElement, + page: HTMLElement, + spine: readonly IndexedSpineItem[], +): SpineMetrics { + const width = contentBox(page).width; + if (!(width > 0)) return spine.map(() => []); + + const ruler = document.createElement("article"); + ruler.className = `${page.className} books__ruler`; + ruler.setAttribute("aria-hidden", "true"); + ruler.style.width = `${width}px`; + + const elements: HTMLElement[][] = []; + const fragment = document.createDocumentFragment(); + for (const item of spine) { + const row: HTMLElement[] = []; + for (const indexed of item.blocks) { + const heading = indexed.block.kind === BlockKind.Heading; + const el = document.createElement(heading ? "h2" : "p"); + el.className = heading ? "books__heading" : "books__paragraph"; + el.textContent = indexed.block.text; + fragment.append(el); + row.push(el); + } + elements.push(row); + } + ruler.append(fragment); + stage.append(ruler); + try { + return elements.map((row) => row.map(readMetrics)); + } finally { + ruler.remove(); + } +} diff --git a/apps/books/src/render/reader.test.ts b/apps/books/src/render/reader.test.ts index ed37ab6e..e71ec2f3 100644 --- a/apps/books/src/render/reader.test.ts +++ b/apps/books/src/render/reader.test.ts @@ -13,6 +13,26 @@ class StubResizeObserver { let handle: ReaderHandle | null = null; +/** jsdom reports every box as 0x0, so the reader always took its + * no-geometry fallback and the MEASURED pagination path never ran under + * test. Faking a laid-out page is what exercises it. */ +function withLayoutBoxes(width: number, height: number): () => void { + // jsdom defines these on Element, not HTMLElement — patching the wrong + // prototype leaves nothing to restore and leaks the stub into every + // following test. + const proto = Element.prototype; + const w = Object.getOwnPropertyDescriptor(proto, "clientWidth"); + const h = Object.getOwnPropertyDescriptor(proto, "clientHeight"); + Object.defineProperty(proto, "clientWidth", { configurable: true, get: () => width }); + Object.defineProperty(proto, "clientHeight", { configurable: true, get: () => height }); + return () => { + if (w) Object.defineProperty(proto, "clientWidth", w); + else Reflect.deleteProperty(proto, "clientWidth"); + if (h) Object.defineProperty(proto, "clientHeight", h); + else Reflect.deleteProperty(proto, "clientHeight"); + }; +} + function scaffold(): { root: HTMLElement; right: HTMLElement } { document.body.innerHTML = `

@@ -39,6 +59,51 @@ afterEach(() => { document.body.innerHTML = ""; }); +describe("reader render with a measured page box", () => { + // The measured path is only reachable once the page reports a real box. + // Its first run happens during mount, inside `createReaderState` — so a + // binding it closes over that is declared further down the function body + // throws a TDZ ReferenceError in a real browser while every jsdom test + // stays green on the fallback. That is exactly how it shipped once. + it("mounts and paginates without touching an uninitialised binding", () => { + const restore = withLayoutBoxes(700, 600); + try { + const { root, right } = scaffold(); + handle = mountReader(root, right, SAMPLE_BOOK_CONTENT); + expect(root.querySelector(".books__page")).not.toBeNull(); + expect(root.querySelector(".books__status")?.textContent).toMatch(/\d+/); + } finally { + restore(); + } + }); + + it("leaves no ruler behind after measuring", () => { + const restore = withLayoutBoxes(700, 600); + try { + const { root, right } = scaffold(); + handle = mountReader(root, right, SAMPLE_BOOK_CONTENT); + expect(root.querySelector(".books__ruler")).toBeNull(); + } finally { + restore(); + } + }); + + it("turns the page on a wheel gesture past the threshold", () => { + const restore = withLayoutBoxes(700, 600); + try { + const { root, right } = scaffold(); + handle = mountReader(root, right, SAMPLE_BOOK_CONTENT); + const stage = root.querySelector(".books__stage"); + if (!stage) throw new Error("no stage"); + const before = root.querySelector(".books__status")?.textContent; + stage.dispatchEvent(new WheelEvent("wheel", { deltaY: 400, bubbles: true })); + expect(root.querySelector(".books__status")?.textContent).not.toBe(before); + } finally { + restore(); + } + }); +}); + describe("reader render", () => { it("paints the typography controls + the first page content + page status", () => { const { root, right } = scaffold(); diff --git a/apps/books/src/render/reader.ts b/apps/books/src/render/reader.ts index 6acf98f1..2f4bf761 100644 --- a/apps/books/src/render/reader.ts +++ b/apps/books/src/render/reader.ts @@ -19,7 +19,9 @@ import { composeHighlight, highlightsOnPage, } from "../logic/highlight-store"; +import { type SpineMetrics, paginateMeasured } from "../logic/measured-pagination"; import { slicePage } from "../logic/page-slice"; +import { type Paginator, charBudgetPaginator } from "../logic/pagination"; import { type ReaderState, canGoNext, @@ -58,6 +60,14 @@ import { paintFragment, readFragmentSelection, } from "./highlights"; +import { contentBox, measureSpineBlocks } from "./measure-blocks"; + +/** How much accumulated wheel travel turns a page, and how long the + * momentum tail after it is ignored. */ +const WHEEL_THRESHOLD_PX = 60; +const WHEEL_COOLDOWN_MS = 260; +/** Coalescing window for resize-driven re-pagination. */ +const RESIZE_DEBOUNCE_MS = 100; const FAMILY_LABELS: Record = { [ReadingFamily.System]: "typography.family.system", @@ -161,7 +171,21 @@ export function mountReader( root.append(stage, footer); let typography: TypographySettings = options.initialTypography ?? DEFAULT_TYPOGRAPHY; - let state: ReaderState = createReaderState(content, budget()); + /** Block geometry is stable for a given (width × typography), so it is + * measured once and reused across every page turn; only a resize or a + * typography change invalidates it. Declared HERE, above the first + * `makePaginator()` call: the paginator closure reads it during mount, and + * a `let` further down the body is in its temporal dead zone at that point + * (a ReferenceError in any real browser, invisible to jsdom — which + * reports every box as 0x0 and so only ever took the fallback path). */ + let measured: { key: string; metrics: SpineMetrics } | null = null; + // The ruler measures against the live custom properties, and the metrics + // cache is keyed on the typography it believes it measured — so a restored + // per-book size has to be on the element BEFORE the first measurement, or + // the cache pins geometry measured at the stylesheet default under the + // restored size's key and never re-measures. + applyTypographyVars(); + let state: ReaderState = createReaderState(content, makePaginator()); if (options.initialPosition) { state = goToLocator(state, options.initialPosition); } @@ -172,11 +196,30 @@ export function mountReader( options.newHighlightId ?? (() => `hl-${now()}-${Math.random().toString(36).slice(2, 8)}`); const highlightStore = new HighlightStore(options.highlightPort ?? {}); - function budget(): number { - const rect = stage.getBoundingClientRect(); - const w = rect.width || 600; - const h = rect.height || 700; - return charsPerPageBudget(typography, w, h); + function metricsKey(width: number): string { + const { size, leading, measure, family } = typography; + return `${width.toFixed(2)}|${size}|${leading}|${measure}|${family}`; + } + + /** The live pagination strategy. Once the page has a real box the reader + * paginates from MEASURED block heights, so a page holds exactly what + * fits; before then (a hidden window, the first paint) it falls back to + * the approximate character budget. */ + function makePaginator(): Paginator { + const { width, height } = contentBox(page); + if (!(width > 0) || !(height > 0)) { + const rect = stage.getBoundingClientRect(); + return charBudgetPaginator( + charsPerPageBudget(typography, rect.width || 600, rect.height || 700), + ); + } + const key = metricsKey(width); + return (spine) => { + if (!measured || measured.key !== key) { + measured = { key, metrics: measureSpineBlocks(stage, page, spine) }; + } + return paginateMeasured(spine, measured.metrics, height); + }; } function applyTypographyVars(): void { @@ -236,7 +279,10 @@ export function mountReader( * current locator stays put, then persists the new settings. */ function setTypography(nextTypography: TypographySettings): void { typography = nextTypography; - go((s) => repaginate(s, budget())); + // The ruler measures against the live custom properties, so the new + // glass has to be on the element before re-pagination measures it. + applyTypographyVars(); + go((s) => repaginate(s, makePaginator())); options.onTypographyChange?.(serializeTypography(typography), typography); } @@ -368,16 +414,62 @@ export function mountReader( refreshHighlightPanel(); }); + const NEXT_CHORDS: readonly ReaderChord[] = [ + ReaderChord.Next, + ReaderChord.NextLine, + ReaderChord.NextScreen, + ReaderChord.NextSpace, + ]; + const PREV_CHORDS: readonly ReaderChord[] = [ + ReaderChord.Prev, + ReaderChord.PrevLine, + ReaderChord.PrevScreen, + ReaderChord.PrevSpace, + ]; + const disposers: ShortcutDisposer[] = [ - attachShortcut(window, ReaderChord.Next, () => go(nextPage)), - attachShortcut(window, ReaderChord.Prev, () => go(prevPage)), + ...NEXT_CHORDS.map((chord) => attachShortcut(window, chord, () => go(nextPage))), + ...PREV_CHORDS.map((chord) => attachShortcut(window, chord, () => go(prevPage))), attachShortcut(window, ReaderChord.Larger, () => setTypography(stepSize(typography, 1))), attachShortcut(window, ReaderChord.Smaller, () => setTypography(stepSize(typography, -1))), attachShortcut(window, ReaderChord.Highlights, toggleHighlightPanel), ]; + // Scrolling the reading surface turns the page. A paginated reader has no + // scrollbar, so without this a wheel or two-finger swipe over the text does + // nothing whatsoever — the single most common way to discover that the + // reader looks broken. Deltas accumulate to a threshold (one trackpad + // flick is dozens of small events) and a cooldown swallows the momentum + // tail so one gesture turns one page. + let wheelDelta = 0; + let wheelCooldown = 0; + function onWheel(event: WheelEvent): void { + event.preventDefault(); + const now = Date.now(); + if (now < wheelCooldown) { + wheelDelta = 0; + return; + } + // Trackpads emit both axes; a horizontal swipe should page too. + const delta = Math.abs(event.deltaY) >= Math.abs(event.deltaX) ? event.deltaY : event.deltaX; + wheelDelta += delta; + if (Math.abs(wheelDelta) < WHEEL_THRESHOLD_PX) return; + const forward = wheelDelta > 0; + wheelDelta = 0; + wheelCooldown = now + WHEEL_COOLDOWN_MS; + go(forward ? nextPage : prevPage); + } + stage.addEventListener("wheel", onWheel, { passive: false }); + + // Re-measuring the whole book is far too costly to run on every frame of a + // window drag, so the observer coalesces a burst into one re-pagination. + let resizeTimer: ReturnType | null = null; const resize = new ResizeObserver(() => { - go((s) => repaginate(s, budget())); + if (resizeTimer !== null) clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + resizeTimer = null; + go((s) => repaginate(s, makePaginator())); + }, RESIZE_DEBOUNCE_MS); }); resize.observe(stage); @@ -386,6 +478,8 @@ export function mountReader( return { dispose() { for (const d of disposers) d(); + stage.removeEventListener("wheel", onWheel); + if (resizeTimer !== null) clearTimeout(resizeTimer); resize.disconnect(); unsubscribe(); closeSelectionMenu(); diff --git a/apps/books/src/styles.css b/apps/books/src/styles.css index 88a7e6c7..4afb447e 100644 --- a/apps/books/src/styles.css +++ b/apps/books/src/styles.css @@ -562,6 +562,21 @@ body { line-height: var(--reader-leading); } +/* Offscreen ruler the reflow reader measures block heights in (see + * render/measure-blocks.ts). Same classes as the live page so it inherits the + * identical typography; unbounded height and out of flow so measuring never + * disturbs what is painted. */ +.books__ruler { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + height: auto; + max-width: none; + overflow: visible; + visibility: hidden; + pointer-events: none; +} + .books__heading { font-size: 1.4em; font-weight: var(--text-weight-semibold); diff --git a/packages/shell/src/renderer/i18n/de.json b/packages/shell/src/renderer/i18n/de.json index abb6cb7c..284cb18f 100644 --- a/packages/shell/src/renderer/i18n/de.json +++ b/packages/shell/src/renderer/i18n/de.json @@ -20,7 +20,6 @@ "shell.settings.section.keyboard": "Tastatur", "shell.settings.section.devices": "Geräte", "shell.settings.section.sync": "Synchronisierung", - "shell.settings.section.membership": "Mitgliedschaft", "shell.settings.section.network": "Netzwerk", "shell.settings.section.security": "Sicherheit", "shell.settings.interface.controls.title": "Kopfzeilen-Steuerelemente", diff --git a/packages/shell/src/renderer/i18n/en.descriptions.known-missing.json b/packages/shell/src/renderer/i18n/en.descriptions.known-missing.json index 917067b4..e2b854ff 100644 --- a/packages/shell/src/renderer/i18n/en.descriptions.known-missing.json +++ b/packages/shell/src/renderer/i18n/en.descriptions.known-missing.json @@ -42,88 +42,6 @@ "shell.settings.section.search", "shell.settings.section.defaults", "shell.settings.section.keyboard", - "shell.settings.section.membership", - "shell.settings.membership.title", - "shell.settings.membership.subtitle", - "shell.settings.membership.cycle.label", - "shell.settings.membership.cycle.monthly", - "shell.settings.membership.cycle.yearly", - "shell.settings.membership.cycle.savings", - "shell.settings.membership.priceSuffix.monthly", - "shell.settings.membership.priceSuffix.yearly", - "shell.settings.membership.priceFree", - "shell.settings.membership.currency", - "shell.settings.membership.recommended", - "shell.settings.membership.currentPlan", - "shell.settings.membership.cta.stay", - "shell.settings.membership.cta.upgrade", - "shell.settings.membership.cta.contact", - "shell.settings.membership.tier.free.name", - "shell.settings.membership.tier.free.tagline", - "shell.settings.membership.tier.pro.name", - "shell.settings.membership.tier.pro.tagline", - "shell.settings.membership.tier.team.name", - "shell.settings.membership.tier.team.tagline", - "shell.settings.membership.tier.team.priceLabel", - "shell.settings.membership.feature.free.1", - "shell.settings.membership.feature.free.2", - "shell.settings.membership.feature.free.3", - "shell.settings.membership.feature.free.4", - "shell.settings.membership.feature.pro.1", - "shell.settings.membership.feature.pro.2", - "shell.settings.membership.feature.pro.3", - "shell.settings.membership.feature.pro.4", - "shell.settings.membership.feature.pro.5", - "shell.settings.membership.feature.pro.6", - "shell.settings.membership.feature.team.1", - "shell.settings.membership.feature.team.2", - "shell.settings.membership.feature.team.3", - "shell.settings.membership.feature.team.4", - "shell.settings.membership.feature.team.5", - "shell.settings.membership.feature.team.6", - "shell.settings.membership.compareTitle", - "shell.settings.membership.compare.feature", - "shell.settings.membership.compare.row.storage", - "shell.settings.membership.compare.row.storage.free", - "shell.settings.membership.compare.row.storage.pro", - "shell.settings.membership.compare.row.storage.team", - "shell.settings.membership.compare.row.devices", - "shell.settings.membership.compare.row.devices.free", - "shell.settings.membership.compare.row.devices.pro", - "shell.settings.membership.compare.row.devices.team", - "shell.settings.membership.compare.row.history", - "shell.settings.membership.compare.row.history.free", - "shell.settings.membership.compare.row.history.pro", - "shell.settings.membership.compare.row.history.team", - "shell.settings.membership.compare.row.ai", - "shell.settings.membership.compare.row.ai.free", - "shell.settings.membership.compare.row.ai.pro", - "shell.settings.membership.compare.row.ai.team", - "shell.settings.membership.compare.row.collab", - "shell.settings.membership.compare.row.support", - "shell.settings.membership.compare.row.support.free", - "shell.settings.membership.compare.row.support.pro", - "shell.settings.membership.compare.row.support.team", - "shell.settings.membership.compare.included", - "shell.settings.membership.compare.notIncluded", - "shell.settings.membership.trust.encrypted.title", - "shell.settings.membership.trust.encrypted.body", - "shell.settings.membership.trust.local.title", - "shell.settings.membership.trust.local.body", - "shell.settings.membership.trust.cancel.title", - "shell.settings.membership.trust.cancel.body", - "shell.settings.membership.faq.title", - "shell.settings.membership.faq.q1", - "shell.settings.membership.faq.a1", - "shell.settings.membership.faq.q2", - "shell.settings.membership.faq.a2", - "shell.settings.membership.faq.q3", - "shell.settings.membership.faq.a3", - "shell.settings.membership.faq.q4", - "shell.settings.membership.faq.a4", - "shell.settings.membership.checkout.placeholder.title", - "shell.settings.membership.checkout.placeholder.body", - "shell.settings.membership.checkout.placeholder.dismiss", "shell.whatsNew.popover.title", "shell.whatsNew.popover.dismiss", "shell.whatsNew.popover.previous", @@ -807,79 +725,6 @@ "shell.settings.backupMigration.import.running", "shell.settings.backupMigration.notify.body", "shell.settings.backupMigration.notify.title", - "shell.settings.billing.account.email", - "shell.settings.billing.account.emailUnknown", - "shell.settings.billing.account.id", - "shell.settings.billing.account.none", - "shell.settings.billing.account.refresh", - "shell.settings.billing.account.title", - "shell.settings.billing.account.unlink", - "shell.settings.billing.accountStatus.active", - "shell.settings.billing.accountStatus.disputed", - "shell.settings.billing.accountStatus.pastDue", - "shell.settings.billing.accountStatus.suspended", - "shell.settings.billing.accountStatus.title", - "shell.settings.billing.cycle.monthly", - "shell.settings.billing.cycle.yearly", - "shell.settings.billing.entitlement.active", - "shell.settings.billing.entitlement.grace", - "shell.settings.billing.entitlement.hint", - "shell.settings.billing.entitlement.title", - "shell.settings.billing.error.invalid", - "shell.settings.billing.error.notLinked", - "shell.settings.billing.error.offline", - "shell.settings.billing.error.service", - "shell.settings.billing.error.unauthorized", - "shell.settings.billing.error.unavailable", - "shell.settings.billing.feature.bundledAiCredits", - "shell.settings.billing.feature.encryptedBackup", - "shell.settings.billing.feature.hostedRelay", - "shell.settings.billing.feature.largeAttachments", - "shell.settings.billing.invoices.empty", - "shell.settings.billing.invoices.openPortal", - "shell.settings.billing.invoices.title", - "shell.settings.billing.invoices.view", - "shell.settings.billing.link.action", - "shell.settings.billing.link.hint", - "shell.settings.billing.link.label", - "shell.settings.billing.link.placeholder", - "shell.settings.billing.plan.current", - "shell.settings.billing.plan.enterprise", - "shell.settings.billing.plan.free", - "shell.settings.billing.plan.plus", - "shell.settings.billing.plan.pro", - "shell.settings.billing.plan.summary", - "shell.settings.billing.plan.team", - "shell.settings.billing.plan.title", - "shell.settings.billing.portal.manage", - "shell.settings.billing.portal.open", - "shell.settings.billing.quota.egress", - "shell.settings.billing.quota.egress.hint", - "shell.settings.billing.quota.egress.monthly", - "shell.settings.billing.quota.hostedStorage", - "shell.settings.billing.quota.hostedStorage.hint", - "shell.settings.billing.quota.overStorage", - "shell.settings.billing.quota.relay", - "shell.settings.billing.quota.relay.included", - "shell.settings.billing.quota.relay.none", - "shell.settings.billing.quota.storage", - "shell.settings.billing.quota.storage.enterprise", - "shell.settings.billing.quota.storage.free", - "shell.settings.billing.quota.storage.plus", - "shell.settings.billing.quota.storage.pro", - "shell.settings.billing.quota.storage.team", - "shell.settings.billing.quota.usedOf", - "shell.settings.billing.storageUsed", - "shell.settings.billing.storageUsed.hint", - "shell.settings.billing.unavailable", - "shell.settings.billing.unlinkConfirm.body", - "shell.settings.billing.unlinkConfirm.title", - "shell.settings.billing.upgrade.action", - "shell.settings.billing.upgrade.cycle", - "shell.settings.billing.upgrade.hint", - "shell.settings.billing.upgrade.needsAccount", - "shell.settings.billing.upgrade.plan", - "shell.settings.billing.upgrade.title", "shell.settings.data.properties.emptyHint", "shell.settings.identity.copied", "shell.settings.identity.copy", @@ -894,7 +739,6 @@ "shell.settings.search.semantic.idle", "shell.settings.search.semantic.needsConsent", "shell.settings.search.semantic.ready", - "shell.settings.section.billing", "shell.settings.webPrivacy.trust.add", "shell.settings.webPrivacy.trust.addLabel", "shell.settings.webPrivacy.trust.addPlaceholder", diff --git a/packages/shell/src/renderer/i18n/en.json b/packages/shell/src/renderer/i18n/en.json index 9fab114d..53ec89a2 100644 --- a/packages/shell/src/renderer/i18n/en.json +++ b/packages/shell/src/renderer/i18n/en.json @@ -365,162 +365,6 @@ "shell.notifications.center.clear": "Clear all", "shell.notifications.center.open": "Open {title}", "shell.notifications.center.openFailed": "Couldn't open the item", - "shell.settings.section.membership": "Membership", - "shell.settings.membership.title": "Power up your second brain.", - "shell.settings.membership.subtitle": "One workspace for every thought, file, and connection. Stay free forever, or unlock the deep features that turn Brainstorm into a daily driver.", - "shell.settings.membership.cycle.label": "Billing cycle", - "shell.settings.membership.cycle.monthly": "Monthly", - "shell.settings.membership.cycle.yearly": "Yearly", - "shell.settings.membership.cycle.savings": "Save 20%", - "shell.settings.membership.priceSuffix.monthly": "/ month", - "shell.settings.membership.priceSuffix.yearly": "/ month, billed yearly", - "shell.settings.membership.priceFree": "Free", - "shell.settings.membership.currency": "$", - "shell.settings.membership.recommended": "Most popular", - "shell.settings.membership.currentPlan": "Current plan", - "shell.settings.membership.cta.stay": "You're on this plan", - "shell.settings.membership.cta.upgrade": "Upgrade to {tier}", - "shell.settings.membership.cta.contact": "Talk to sales", - "shell.settings.membership.tier.free.name": "Free", - "shell.settings.membership.tier.free.tagline": "Everything you need to get started.", - "shell.settings.membership.tier.pro.name": "Pro", - "shell.settings.membership.tier.pro.tagline": "For knowledge workers who live in their notes.", - "shell.settings.membership.tier.team.name": "Team", - "shell.settings.membership.tier.team.tagline": "Built for groups that think together.", - "shell.settings.membership.tier.team.priceLabel": "per seat", - "shell.settings.membership.feature.free.1": "Unlimited local notes & files", - "shell.settings.membership.feature.free.2": "Block-based editor with rich text", - "shell.settings.membership.feature.free.3": "Local-first vault, fully encrypted", - "shell.settings.membership.feature.free.4": "Single device sync via local folder", - "shell.settings.membership.feature.pro.1": "Everything in Free, plus:", - "shell.settings.membership.feature.pro.2": "Unlimited end-to-end encrypted device sync", - "shell.settings.membership.feature.pro.3": "AI assistant — summaries, search, drafting", - "shell.settings.membership.feature.pro.4": "Advanced graph & relations", - "shell.settings.membership.feature.pro.5": "Version history (90 days)", - "shell.settings.membership.feature.pro.6": "Custom themes & icon packs", - "shell.settings.membership.feature.team.1": "Everything in Pro, plus:", - "shell.settings.membership.feature.team.2": "Shared team vaults with role-based access", - "shell.settings.membership.feature.team.3": "Live multiplayer editing", - "shell.settings.membership.feature.team.4": "Centralized billing & seat management", - "shell.settings.membership.feature.team.5": "Audit log & SSO (SAML / OIDC)", - "shell.settings.membership.feature.team.6": "Priority support with SLA", - "shell.settings.membership.compareTitle": "Compare plans", - "shell.settings.membership.compare.feature": "Feature", - "shell.settings.membership.compare.row.storage": "Storage", - "shell.settings.membership.compare.row.storage.free": "Unlimited (local)", - "shell.settings.membership.compare.row.storage.pro": "Unlimited (local + synced)", - "shell.settings.membership.compare.row.storage.team": "Unlimited (shared)", - "shell.settings.membership.compare.row.devices": "Synced devices", - "shell.settings.membership.compare.row.devices.free": "1", - "shell.settings.membership.compare.row.devices.pro": "Unlimited", - "shell.settings.membership.compare.row.devices.team": "Unlimited", - "shell.settings.membership.compare.row.history": "Version history", - "shell.settings.membership.compare.row.history.free": "7 days", - "shell.settings.membership.compare.row.history.pro": "90 days", - "shell.settings.membership.compare.row.history.team": "1 year", - "shell.settings.membership.compare.row.ai": "AI assistant", - "shell.settings.membership.compare.row.ai.free": "—", - "shell.settings.membership.compare.row.ai.pro": "Included", - "shell.settings.membership.compare.row.ai.team": "Included + team prompts", - "shell.settings.membership.compare.row.collab": "Realtime collaboration", - "shell.settings.membership.compare.row.support": "Support", - "shell.settings.membership.compare.row.support.free": "Community", - "shell.settings.membership.compare.row.support.pro": "Email, 24h", - "shell.settings.membership.compare.row.support.team": "Priority, SLA", - "shell.settings.membership.compare.included": "Included", - "shell.settings.membership.compare.notIncluded": "Not included", - "shell.settings.membership.trust.encrypted.title": "End-to-end encrypted", - "shell.settings.membership.trust.encrypted.body": "Your vault is sealed on your device. We never see the keys.", - "shell.settings.membership.trust.local.title": "Local-first, always", - "shell.settings.membership.trust.local.body": "Your data lives in a folder you own. Cancel anytime, keep everything.", - "shell.settings.membership.trust.cancel.title": "Cancel anytime", - "shell.settings.membership.trust.cancel.body": "No contracts. Downgrade or stop your plan in one click.", - "shell.settings.membership.faq.title": "Common questions", - "shell.settings.membership.faq.q1": "What happens to my data if I downgrade?", - "shell.settings.membership.faq.a1": "Nothing. Your vault is a folder on your disk. Pro-only features simply switch off — every note, file, and block stays where it is, fully readable.", - "shell.settings.membership.faq.q2": "Can I pay yearly?", - "shell.settings.membership.faq.a2": "Yes. Yearly billing saves you 20% compared with the monthly plan, and you can switch between cycles whenever you want.", - "shell.settings.membership.faq.q3": "How does Team billing work?", - "shell.settings.membership.faq.a3": "You're charged per active seat, prorated to the day. Add or remove teammates anytime — no minimums, no commitments.", - "shell.settings.membership.faq.q4": "Do you offer education or non-profit pricing?", - "shell.settings.membership.faq.a4": "Students, teachers, and registered non-profits get Pro free, forever. Reach out from this screen once your vault is set up.", - "shell.settings.membership.checkout.placeholder.title": "Checkout isn't live yet", - "shell.settings.membership.checkout.placeholder.body": "Brainstorm is in preview — billing wires up later this stage. We saved your selection so we can take you straight to checkout when it ships.", - "shell.settings.membership.checkout.placeholder.dismiss": "Got it", - "shell.settings.section.billing": "Billing", - "shell.settings.billing.unavailable": "Billing is unavailable — open a vault first.", - "shell.settings.billing.plan.title": "Plan", - "shell.settings.billing.plan.summary": "The plan and entitlements this device holds. Payment and plan changes happen in the account portal — never inside the app.", - "shell.settings.billing.plan.current": "Current plan", - "shell.settings.billing.plan.free": "Free", - "shell.settings.billing.plan.plus": "Plus", - "shell.settings.billing.plan.pro": "Pro", - "shell.settings.billing.plan.team": "Team", - "shell.settings.billing.plan.enterprise": "Enterprise", - "shell.settings.billing.entitlement.title": "Entitlement", - "shell.settings.billing.entitlement.hint": "Whether this device's verified entitlement is fresh. Grace means it still works but wants to refresh soon.", - "shell.settings.billing.entitlement.active": "Verified", - "shell.settings.billing.entitlement.grace": "Grace period — refresh soon", - "shell.settings.billing.quota.storage": "Cloud storage quota", - "shell.settings.billing.quota.storage.free": "None — local-first only", - "shell.settings.billing.quota.storage.plus": "20 GiB", - "shell.settings.billing.quota.storage.pro": "200 GiB", - "shell.settings.billing.quota.storage.team": "1 TiB, pooled", - "shell.settings.billing.quota.storage.enterprise": "Unmetered", - "shell.settings.billing.quota.relay": "Hosted sync relay", - "shell.settings.billing.quota.relay.none": "Not included", - "shell.settings.billing.quota.relay.included": "Included", - "shell.settings.billing.storageUsed": "Storage used on this device", - "shell.settings.billing.storageUsed.hint": "Uploads stored in this vault's encrypted asset store.", - "shell.settings.billing.quota.hostedStorage": "Hosted storage used", - "shell.settings.billing.quota.hostedStorage.hint": "Estimated from this vault's bound attachments; the hosted node's metering is authoritative.", - "shell.settings.billing.quota.usedOf": "{used} of {limit}", - "shell.settings.billing.quota.overStorage": "Over the plan's hosted storage quota — attachment uploads to hosted sync are paused. Local files and downloads are unaffected.", - "shell.settings.billing.quota.egress": "Hosted sync egress", - "shell.settings.billing.quota.egress.hint": "Served to your devices by the hosted node; a soft monthly cap.", - "shell.settings.billing.quota.egress.monthly": "Up to {limit} per month", - "shell.settings.billing.feature.hostedRelay": "Hosted relay", - "shell.settings.billing.feature.encryptedBackup": "Encrypted backup", - "shell.settings.billing.feature.largeAttachments": "Large attachments", - "shell.settings.billing.feature.bundledAiCredits": "Bundled AI credits", - "shell.settings.billing.account.title": "Account", - "shell.settings.billing.account.none": "No account is linked on this device. Brainstorm is fully usable without one — link your billing account to see your subscription and invoices here.", - "shell.settings.billing.account.email": "Email", - "shell.settings.billing.account.emailUnknown": "Not on file", - "shell.settings.billing.account.id": "Account ID", - "shell.settings.billing.account.refresh": "Refresh", - "shell.settings.billing.account.unlink": "Unlink account", - "shell.settings.billing.accountStatus.title": "Billing status", - "shell.settings.billing.accountStatus.active": "Active", - "shell.settings.billing.accountStatus.pastDue": "Payment past due", - "shell.settings.billing.accountStatus.disputed": "Payment disputed", - "shell.settings.billing.accountStatus.suspended": "Suspended", - "shell.settings.billing.link.hint": "Paste the account credential from your subscription confirmation (also shown in the account portal after checkout). It's stored encrypted in this vault and never leaves this device except to reach Brainstorm's billing service.", - "shell.settings.billing.link.label": "Account credential", - "shell.settings.billing.link.placeholder": "Paste your account credential", - "shell.settings.billing.link.action": "Link account", - "shell.settings.billing.portal.open": "Open account portal", - "shell.settings.billing.portal.manage": "Manage subscription in portal", - "shell.settings.billing.upgrade.title": "Upgrade", - "shell.settings.billing.upgrade.hint": "Opens Stripe Checkout in your browser — payment never happens inside the app.", - "shell.settings.billing.upgrade.plan": "Plan to upgrade to", - "shell.settings.billing.upgrade.cycle": "Billing cycle", - "shell.settings.billing.upgrade.action": "Upgrade via checkout", - "shell.settings.billing.upgrade.needsAccount": "Upgrading needs a linked account — link one below or start from the account portal.", - "shell.settings.billing.cycle.monthly": "Monthly", - "shell.settings.billing.cycle.yearly": "Yearly", - "shell.settings.billing.invoices.title": "Invoices", - "shell.settings.billing.invoices.empty": "No invoices yet.", - "shell.settings.billing.invoices.view": "View", - "shell.settings.billing.invoices.openPortal": "View invoices in the account portal", - "shell.settings.billing.unlinkConfirm.title": "Unlink this account?", - "shell.settings.billing.unlinkConfirm.body": "Removes the stored billing credential and cached entitlement from this device. Your subscription itself is untouched — relink any time.", - "shell.settings.billing.error.unavailable": "Billing is unavailable right now — open a vault and try again.", - "shell.settings.billing.error.notLinked": "No account is linked on this device.", - "shell.settings.billing.error.offline": "Couldn't reach the billing service — check your connection and try again.", - "shell.settings.billing.error.unauthorized": "The stored credential was rejected — it may have been rotated. Unlink and link again with a fresh credential.", - "shell.settings.billing.error.service": "The billing service had a problem — try again in a minute.", - "shell.settings.billing.error.invalid": "That didn't work — check the credential and try again.", "shell.whatsNew.popover.title": "What's new in Brainstorm", "shell.whatsNew.popover.dismiss": "Got it", "shell.whatsNew.popover.previous": "Previous", diff --git a/packages/shell/src/renderer/i18n/es.json b/packages/shell/src/renderer/i18n/es.json index a799a141..7ab7441d 100644 --- a/packages/shell/src/renderer/i18n/es.json +++ b/packages/shell/src/renderer/i18n/es.json @@ -20,7 +20,6 @@ "shell.settings.section.keyboard": "Teclado", "shell.settings.section.devices": "Dispositivos", "shell.settings.section.sync": "Sincronización", - "shell.settings.section.membership": "Membresía", "shell.settings.section.network": "Red", "shell.settings.section.security": "Seguridad", "shell.settings.interface.controls.title": "Controles de la cabecera", diff --git a/packages/shell/src/renderer/i18n/fr.json b/packages/shell/src/renderer/i18n/fr.json index 995ccf33..e0a7a1df 100644 --- a/packages/shell/src/renderer/i18n/fr.json +++ b/packages/shell/src/renderer/i18n/fr.json @@ -20,7 +20,6 @@ "shell.settings.section.keyboard": "Clavier", "shell.settings.section.devices": "Appareils", "shell.settings.section.sync": "Synchronisation", - "shell.settings.section.membership": "Adhésion", "shell.settings.section.network": "Réseau", "shell.settings.section.security": "Sécurité", "shell.settings.interface.controls.title": "Contrôles de l'en-tête", diff --git a/packages/shell/src/renderer/i18n/it.json b/packages/shell/src/renderer/i18n/it.json index 27d94cd8..988dd343 100644 --- a/packages/shell/src/renderer/i18n/it.json +++ b/packages/shell/src/renderer/i18n/it.json @@ -20,7 +20,6 @@ "shell.settings.section.keyboard": "Tastiera", "shell.settings.section.devices": "Dispositivi", "shell.settings.section.sync": "Sincronizzazione", - "shell.settings.section.membership": "Iscrizione", "shell.settings.section.network": "Rete", "shell.settings.section.security": "Sicurezza", "shell.settings.interface.controls.title": "Controlli dell'intestazione", diff --git a/packages/shell/src/renderer/i18n/pt.json b/packages/shell/src/renderer/i18n/pt.json index eb76aace..a9255a86 100644 --- a/packages/shell/src/renderer/i18n/pt.json +++ b/packages/shell/src/renderer/i18n/pt.json @@ -20,7 +20,6 @@ "shell.settings.section.keyboard": "Teclado", "shell.settings.section.devices": "Dispositivos", "shell.settings.section.sync": "Sincronização", - "shell.settings.section.membership": "Assinatura", "shell.settings.section.network": "Rede", "shell.settings.section.security": "Segurança", "shell.settings.interface.controls.title": "Controles do cabeçalho", diff --git a/packages/shell/src/renderer/i18n/t.test.ts b/packages/shell/src/renderer/i18n/t.test.ts index 8182cb02..e933aaa6 100644 --- a/packages/shell/src/renderer/i18n/t.test.ts +++ b/packages/shell/src/renderer/i18n/t.test.ts @@ -28,7 +28,7 @@ describe("t()", () => { }); it("renders literal apostrophes (not ICU quoting) in real catalog strings", () => { - expect(t("shell.settings.membership.cta.stay")).toBe("You're on this plan"); + expect(t("shell.notifications.center.empty")).toBe("You're all caught up."); }); }); diff --git a/packages/shell/src/renderer/settings/billing-section.css b/packages/shell/src/renderer/settings/billing-section.css deleted file mode 100644 index 6861297b..00000000 --- a/packages/shell/src/renderer/settings/billing-section.css +++ /dev/null @@ -1,110 +0,0 @@ -/* Settings → Billing (14.6). Rows ride the shared `.setting-row` / - * `settings__*` chrome; only the billing-specific shapes live here. */ - -.billing-section__features { - margin: 8px 0 0; - padding: 0; - list-style: none; - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.billing-section__feature { - font-size: 12px; - color: var(--color-text-secondary); - border: 1px solid var(--color-border-subtle); - border-radius: 999px; - padding: 2px 10px; - background: var(--color-surface-default); -} - -.billing-section__link-form { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 12px; -} - -.billing-section__link-row { - display: flex; - align-items: center; - gap: 8px; -} - -/* The credential field is the shared `` face (F-304) — only the - * flex-fill layout delta lives here. */ -.billing-section__link-row .text-field { - flex: 1; - min-width: 0; -} - -.billing-section__actions { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - margin-top: 12px; -} - -.billing-section__upgrade { - margin-top: 16px; - padding-top: 12px; - border-top: 1px solid var(--color-border-subtle); -} - -.billing-section__upgrade-row { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - margin-top: 8px; -} - -.billing-section__error { - margin: 8px 0 0; - font-size: 12px; - color: var(--color-state-error); -} - -.billing-section__invoices { - margin: 8px 0 0; - padding: 0; - list-style: none; - display: flex; - flex-direction: column; -} - -.billing-section__invoice { - display: flex; - align-items: center; - gap: 12px; - padding: 8px 0; - border-bottom: 1px solid var(--color-border-subtle); -} - -.billing-section__invoice:last-child { - border-bottom: none; -} - -.billing-section__invoice-date { - flex: 0 0 auto; - min-width: 108px; - color: var(--color-text-primary); - font-size: 13px; -} - -.billing-section__invoice-amount { - flex: 0 0 auto; - min-width: 88px; - font-variant-numeric: tabular-nums; - color: var(--color-text-primary); - font-size: 13px; -} - -.billing-section__invoice-status { - flex: 1 1 auto; - color: var(--color-text-tertiary); - font-size: 12px; - text-transform: capitalize; -} diff --git a/packages/shell/src/renderer/settings/billing-section.test.tsx b/packages/shell/src/renderer/settings/billing-section.test.tsx deleted file mode 100644 index 1e3b401e..00000000 --- a/packages/shell/src/renderer/settings/billing-section.test.tsx +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Settings → Billing (14.6) — SSR-rendered tests against the privileged - * `window.brainstorm.billing` bridge (same pattern as sync-section.test.tsx), - * plus the pure subcomponents rendered directly with props. - */ - -import { - BillingAccountStatus, - type BillingInvoiceView, - type BillingOverviewView, - BillingSettingsFailure, -} from "@brainstorm-os/protocol/billing-settings-types"; -import { EntitlementStatus, FeatureFlag, PlanTier } from "@brainstorm-os/protocol/billing-types"; -import { - QuotaResource, - type QuotaStateView, - inertQuotaVerdict, - quotaVerdict, -} from "@brainstorm-os/protocol/quota-types"; -import { renderToStaticMarkup } from "react-dom/server"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - BillingSection, - InvoicesGroup, - LinkForm, - LinkedAccount, - PlanGroup, - formatInvoiceAmount, - formatInvoiceDate, -} from "./billing-section"; -import { SettingsSection } from "./sections"; - -beforeEach(() => { - (globalThis as { window?: unknown }).window = { - brainstorm: { - billing: { - overview: vi.fn().mockResolvedValue(null), - refreshSummary: vi - .fn() - .mockResolvedValue({ ok: false, reason: BillingSettingsFailure.NotLinked }), - invoices: vi.fn().mockResolvedValue({ ok: false, reason: BillingSettingsFailure.NotLinked }), - }, - intents: { dispatch: vi.fn().mockResolvedValue({ handled: false, reason: "no-handler" }) }, - }, - }; -}); - -afterEach(() => { - (globalThis as { window?: unknown }).window = undefined; -}); - -const FREE_OVERVIEW: BillingOverviewView = { - account: null, - entitlement: { - plan: PlanTier.Free, - features: [], - status: EntitlementStatus.Active, - accountId: null, - }, - storageBytesUsed: 2048, - quota: { - enforced: false, - storage: inertQuotaVerdict(QuotaResource.AttachmentStorage), - egress: inertQuotaVerdict(QuotaResource.SyncEgress), - }, - portalUrl: "https://account.example.test", -}; - -const GIB = 1024 * 1024 * 1024; - -const PRO_QUOTA: QuotaStateView = { - enforced: true, - storage: quotaVerdict(PlanTier.Pro, QuotaResource.AttachmentStorage, 3 * GIB), - egress: quotaVerdict(PlanTier.Pro, QuotaResource.SyncEgress, null), -}; - -const PRO_OVERVIEW: BillingOverviewView = { - account: { id: "acct_1", email: "razor@example.com", plan: PlanTier.Pro, linkedAt: 1 }, - entitlement: { - plan: PlanTier.Pro, - features: [FeatureFlag.HostedRelay, FeatureFlag.BundledAiCredits], - status: EntitlementStatus.Grace, - accountId: "acct_1", - }, - storageBytesUsed: null, - quota: PRO_QUOTA, - portalUrl: "https://account.example.test", -}; - -describe("SettingsSection.Billing", () => { - it("declares the Billing section enum entry", () => { - expect(SettingsSection.Billing).toBe("billing"); - expect(SettingsSection.Billing).not.toBe(SettingsSection.Membership); - }); -}); - -describe("", () => { - it("renders the loading line on first synchronous paint", () => { - const html = renderToStaticMarkup(); - expect(html).toContain("settings__loading"); - }); - - it("loading copy is t-keyed (no bare i18n keys)", () => { - const html = renderToStaticMarkup(); - expect(html).not.toContain("shell.common.loading"); - }); -}); - -describe("", () => { - it("shows the Free plan, no relay, and local storage used", () => { - const html = renderToStaticMarkup(); - expect(html).toContain('data-testid="billing-plan-name"'); - expect(html).toContain("Free"); - expect(html).toContain("Not included"); - expect(html).toContain('data-testid="billing-storage-used"'); - expect(html).toContain("2 KB"); - }); - - it("shows entitlement feature chips + grace status for a paid plan", () => { - const html = renderToStaticMarkup(); - expect(html).toContain('data-testid="billing-features"'); - expect(html).toContain("Hosted relay"); - expect(html).toContain("Bundled AI credits"); - expect(html).toContain("Grace period"); - // No storage-used row when the inventory is unknown. - expect(html).not.toContain('data-testid="billing-storage-used"'); - }); - - it("never renders raw i18n keys", () => { - const html = renderToStaticMarkup(); - expect(html).not.toContain("shell.settings.billing"); - }); - - it("shows used-vs-ceiling + egress rows for a metered plan (14.7)", () => { - const html = renderToStaticMarkup(); - expect(html).toContain('data-testid="billing-quota-storage"'); - expect(html).toContain("3 GB of 200 GB"); - expect(html).toContain('data-testid="billing-quota-egress"'); - expect(html).toContain("Up to 100 GB per month"); - expect(html).not.toContain('data-testid="billing-quota-over"'); - }); - - it("shows the over-quota warning line when storage is over (14.7)", () => { - const over: BillingOverviewView = { - ...PRO_OVERVIEW, - quota: { - enforced: true, - storage: quotaVerdict(PlanTier.Pro, QuotaResource.AttachmentStorage, 201 * GIB), - egress: quotaVerdict(PlanTier.Pro, QuotaResource.SyncEgress, null), - }, - }; - const html = renderToStaticMarkup(); - expect(html).toContain('data-testid="billing-quota-over"'); - expect(html).toContain('role="alert"'); - }); - - it("renders no quota rows for the inert (unlinked) shape (14.7)", () => { - const html = renderToStaticMarkup(); - expect(html).not.toContain('data-testid="billing-quota-storage"'); - expect(html).not.toContain('data-testid="billing-quota-egress"'); - expect(html).not.toContain('data-testid="billing-quota-over"'); - }); - - it("renders no quota rows when the quota state is unavailable (14.7)", () => { - const html = renderToStaticMarkup(); - expect(html).not.toContain('data-testid="billing-quota-storage"'); - expect(html).not.toContain('data-testid="billing-quota-egress"'); - }); -}); - -describe(" (no account linked)", () => { - it("renders the credential input, link CTA, and portal button", () => { - const html = renderToStaticMarkup( - {}} onOpenPortal={() => {}} />, - ); - expect(html).toContain('data-testid="billing-link-form"'); - expect(html).toContain('type="password"'); - expect(html).toContain('data-testid="billing-link-submit"'); - expect(html).toContain('data-testid="billing-open-portal"'); - }); - - it("surfaces a link failure as an alert", () => { - const html = renderToStaticMarkup( - {}} - onOpenPortal={() => {}} - />, - ); - expect(html).toContain('role="alert"'); - expect(html).not.toContain("shell.settings.billing.error"); - }); -}); - -describe("", () => { - const account = { id: "acct_1", email: "razor@example.com", plan: PlanTier.Pro, linkedAt: 1 }; - - it("renders email, id, live billing status, and the action row", () => { - const html = renderToStaticMarkup( - {}} - onUnlink={() => {}} - onOpenPortal={() => {}} - onCheckout={() => {}} - />, - ); - expect(html).toContain("razor@example.com"); - expect(html).toContain("acct_1"); - expect(html).toContain("Payment past due"); - expect(html).toContain('data-testid="billing-checkout"'); - expect(html).toContain('data-testid="billing-unlink"'); - expect(html).toContain('data-testid="billing-open-portal"'); - }); - - it("falls back to the cached account email when the summary hasn't landed", () => { - const html = renderToStaticMarkup( - {}} - onUnlink={() => {}} - onOpenPortal={() => {}} - onCheckout={() => {}} - />, - ); - expect(html).toContain("razor@example.com"); - expect(html).toContain('data-testid="billing-summary-error"'); - }); -}); - -describe("", () => { - const invoice: BillingInvoiceView = { - id: "in_1", - amountPaidCents: 1200, - currency: "usd", - status: "paid", - hostedInvoiceUrl: "https://invoice.stripe.com/i/1", - createdMs: Date.UTC(2026, 5, 15), - }; - - it("renders rows with formatted amount + a view deep link", () => { - const html = renderToStaticMarkup( - , - ); - expect(html).toContain('data-testid="billing-invoices"'); - expect(html).toContain(formatInvoiceAmount(invoice)); - expect(html).toContain("paid"); - }); - - it("renders the empty state", () => { - const html = renderToStaticMarkup( - , - ); - expect(html).toContain('data-testid="billing-invoices-empty"'); - }); - - it("falls back to the portal link on failure", () => { - const html = renderToStaticMarkup( - , - ); - expect(html).toContain('role="status"'); - expect(html).not.toContain("shell.settings.billing.invoices.openPortal"); - }); -}); - -describe("formatting helpers", () => { - it("formats cents as currency (or a sane fallback)", () => { - const formatted = formatInvoiceAmount({ - id: "in", - amountPaidCents: 1250, - currency: "usd", - status: "paid", - hostedInvoiceUrl: null, - createdMs: 0, - }); - expect(formatted).toMatch(/12[.,]50/); - }); - - it("survives an invalid currency code", () => { - const formatted = formatInvoiceAmount({ - id: "in", - amountPaidCents: 500, - currency: "???", - status: "paid", - hostedInvoiceUrl: null, - createdMs: 0, - }); - expect(formatted).toContain("5.00"); - }); - - it("renders an em-dash for an invalid date", () => { - expect(formatInvoiceDate(Number.NaN)).toBe("—"); - }); -}); diff --git a/packages/shell/src/renderer/settings/billing-section.tsx b/packages/shell/src/renderer/settings/billing-section.tsx deleted file mode 100644 index 8f5e14e5..00000000 --- a/packages/shell/src/renderer/settings/billing-section.tsx +++ /dev/null @@ -1,646 +0,0 @@ -/** - * Settings → Billing (14.6) — the real account/billing state, next to the - * visual-only Membership pricing surface. - * - * Three groups: - * - Plan: the effective entitlement the shell holds (BillingService via - * the privileged bridge) + plan-derived quota lines + local storage use. - * - Account: link/unlink the billing-edge refresh credential, live - * summary (billing status), portal + Stripe-Checkout deep links. The - * portal owns payment — checkout/manage URLs open EXTERNALLY through - * the intent OS-handoff chokepoint (same as the updates download page); - * Stripe never renders in-product. - * - Invoices: billing-edge `/v1/account/invoices` (linked accounts only), - * each row deep-linking to Stripe's hosted invoice page. - * - * Everything is fail-soft: signed-out, offline, and revoked-credential all - * render explanatory lines, never a crash. Network I/O happens in the main - * process (`BillingAccountService`); this panel only invokes the bridge. - */ - -import { - BillingAccountStatus, - type BillingAccountSummaryView, - BillingCheckoutCycle, - BillingCheckoutPlan, - type BillingInvoiceView, - type BillingOverviewView, - BillingSettingsFailure, -} from "@brainstorm-os/protocol/billing-settings-types"; -import { EntitlementStatus, FeatureFlag, PlanTier } from "@brainstorm-os/protocol/billing-types"; -import { useCallback, useEffect, useId, useState } from "react"; -import { formatBytes } from "../format/relative-time"; -import { t } from "../i18n/t"; -import { Button, ButtonSize, ButtonVariant } from "../ui/button"; -import { ConfirmVariant, confirm } from "../ui/confirm"; -import { IconName } from "../ui/icon"; -import { openExternalUrl } from "../ui/open-external"; -import { TextField } from "../ui/text-field"; -import { SettingRow, SettingSelect } from "./settings-controls"; -import "./billing-section.css"; - -const PLAN_LABEL_KEYS: Record = { - [PlanTier.Free]: "shell.settings.billing.plan.free", - [PlanTier.Plus]: "shell.settings.billing.plan.plus", - [PlanTier.Pro]: "shell.settings.billing.plan.pro", - [PlanTier.Team]: "shell.settings.billing.plan.team", - [PlanTier.Enterprise]: "shell.settings.billing.plan.enterprise", -}; - -/** Display mirror of the control plane's per-plan storage quotas - * (`brainstorm-cloud` billing-edge §metering `quota_for`) — presentation - * only; enforcement stays server-side (14.7). */ -const PLAN_STORAGE_KEYS: Record = { - [PlanTier.Free]: "shell.settings.billing.quota.storage.free", - [PlanTier.Plus]: "shell.settings.billing.quota.storage.plus", - [PlanTier.Pro]: "shell.settings.billing.quota.storage.pro", - [PlanTier.Team]: "shell.settings.billing.quota.storage.team", - [PlanTier.Enterprise]: "shell.settings.billing.quota.storage.enterprise", -}; - -const ENTITLEMENT_STATUS_KEYS: Record = { - [EntitlementStatus.Active]: "shell.settings.billing.entitlement.active", - [EntitlementStatus.Grace]: "shell.settings.billing.entitlement.grace", -}; - -const ACCOUNT_STATUS_KEYS: Record = { - [BillingAccountStatus.Active]: "shell.settings.billing.accountStatus.active", - [BillingAccountStatus.PastDue]: "shell.settings.billing.accountStatus.pastDue", - [BillingAccountStatus.Disputed]: "shell.settings.billing.accountStatus.disputed", - [BillingAccountStatus.Suspended]: "shell.settings.billing.accountStatus.suspended", -}; - -const FEATURE_LABEL_KEYS: Record = { - [FeatureFlag.HostedRelay]: "shell.settings.billing.feature.hostedRelay", - [FeatureFlag.EncryptedBackup]: "shell.settings.billing.feature.encryptedBackup", - [FeatureFlag.LargeAttachments]: "shell.settings.billing.feature.largeAttachments", - [FeatureFlag.BundledAiCredits]: "shell.settings.billing.feature.bundledAiCredits", -}; - -const FAILURE_MESSAGE_KEYS: Record = { - [BillingSettingsFailure.Unavailable]: "shell.settings.billing.error.unavailable", - [BillingSettingsFailure.NotLinked]: "shell.settings.billing.error.notLinked", - [BillingSettingsFailure.Offline]: "shell.settings.billing.error.offline", - [BillingSettingsFailure.Unauthorized]: "shell.settings.billing.error.unauthorized", - [BillingSettingsFailure.Service]: "shell.settings.billing.error.service", - [BillingSettingsFailure.Invalid]: "shell.settings.billing.error.invalid", -}; - -export function failureMessage(reason: BillingSettingsFailure): string { - return t(FAILURE_MESSAGE_KEYS[reason]); -} - -export function formatInvoiceAmount(invoice: BillingInvoiceView): string { - const units = invoice.amountPaidCents / 100; - try { - return new Intl.NumberFormat(undefined, { - style: "currency", - currency: invoice.currency.toUpperCase(), - }).format(units); - } catch { - return `${units.toFixed(2)} ${invoice.currency.toUpperCase()}`; - } -} - -export function formatInvoiceDate(createdMs: number): string { - const date = new Date(createdMs); - if (Number.isNaN(date.getTime())) return "—"; - return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(date); -} - -/** The effective-plan group: entitlement the shell holds + quota lines. */ -export function PlanGroup({ overview }: { overview: BillingOverviewView }) { - const plan = overview.entitlement.plan; - const features = overview.entitlement.features; - return ( -
-

{t("shell.settings.billing.plan.title")}

-

{t("shell.settings.billing.plan.summary")}

- - {t(PLAN_LABEL_KEYS[plan])} - - } - /> - - {t(ENTITLEMENT_STATUS_KEYS[overview.entitlement.status])} - - } - /> - {t(PLAN_STORAGE_KEYS[plan])}} - /> - - {plan === PlanTier.Free - ? t("shell.settings.billing.quota.relay.none") - : t("shell.settings.billing.quota.relay.included")} - - } - /> - {overview.quota !== null && overview.quota.storage.limitBytes !== null && ( - - {overview.quota.storage.usedBytes !== null - ? t("shell.settings.billing.quota.usedOf", { - used: formatBytes(overview.quota.storage.usedBytes), - limit: formatBytes(overview.quota.storage.limitBytes), - }) - : formatBytes(overview.quota.storage.limitBytes)} - - } - /> - )} - {overview.quota?.storage.over && ( -

- {t("shell.settings.billing.quota.overStorage")} -

- )} - {overview.quota !== null && overview.quota.egress.limitBytes !== null && ( - - {t("shell.settings.billing.quota.egress.monthly", { - limit: formatBytes(overview.quota.egress.limitBytes), - })} - - } - /> - )} - {overview.storageBytesUsed !== null && ( - - {formatBytes(overview.storageBytesUsed)} - - } - /> - )} - {features.length > 0 && ( -
    - {features.map((feature) => ( -
  • - {t(FEATURE_LABEL_KEYS[feature])} -
  • - ))} -
- )} -
- ); -} - -/** The paste-credential link form (signed-out state). The credential is - * write-only: sent once on Link, never read back. */ -export function LinkForm({ - busy, - failure, - onLink, - onOpenPortal, -}: { - busy: boolean; - failure: BillingSettingsFailure | null; - onLink: (credential: string) => void; - onOpenPortal: () => void; -}) { - const [draft, setDraft] = useState(""); - const inputId = useId(); - return ( -
{ - e.preventDefault(); - if (draft.trim().length > 0) onLink(draft.trim()); - }} - > -

{t("shell.settings.billing.link.hint")}

- -
- - -
- {failure !== null && ( -

- {failureMessage(failure)} -

- )} -
- -
-
- ); -} - -/** The linked-account view: identity + live billing status + actions. */ -export function LinkedAccount({ - account, - summary, - summaryFailure, - busy, - onRefresh, - onUnlink, - onOpenPortal, - onCheckout, -}: { - account: NonNullable; - summary: BillingAccountSummaryView | null; - summaryFailure: BillingSettingsFailure | null; - busy: boolean; - onRefresh: () => void; - onUnlink: () => void; - onOpenPortal: () => void; - onCheckout: (plan: BillingCheckoutPlan, cycle: BillingCheckoutCycle) => void; -}) { - const [plan, setPlan] = useState(BillingCheckoutPlan.Plus); - const [cycle, setCycle] = useState(BillingCheckoutCycle.Yearly); - const email = summary?.email ?? account.email; - const billingStatus = summary?.billingStatus ?? null; - return ( -
- - {email ?? t("shell.settings.billing.account.emailUnknown")} - - } - /> - {account.id}} - /> - {billingStatus !== null && ( - - {t(ACCOUNT_STATUS_KEYS[billingStatus])} - - } - /> - )} - {summaryFailure !== null && ( -

- {failureMessage(summaryFailure)} -

- )} -
- {t("shell.settings.billing.upgrade.title")} -

{t("shell.settings.billing.upgrade.hint")}

-
- - - -
-
-
- - - -
-
- ); -} - -/** The invoices list — billing-edge-backed, linked accounts only. */ -export function InvoicesGroup({ - invoices, - failure, - portalUrl, -}: { - invoices: readonly BillingInvoiceView[] | null; - failure: BillingSettingsFailure | null; - portalUrl: string; -}) { - return ( -
-

{t("shell.settings.billing.invoices.title")}

- {failure !== null ? ( -
-

{failureMessage(failure)}

- -
- ) : invoices === null ? ( -

- {t("shell.common.loading")} -

- ) : invoices.length === 0 ? ( -

- {t("shell.settings.billing.invoices.empty")} -

- ) : ( -
    - {invoices.map((invoice) => ( -
  • - {formatInvoiceDate(invoice.createdMs)} - {formatInvoiceAmount(invoice)} - {invoice.status} - {invoice.hostedInvoiceUrl !== null && ( - - )} -
  • - ))} -
- )} -
- ); -} - -export function BillingSection() { - // undefined = first load in flight; null = no vault session / stale preload. - const [overview, setOverview] = useState(undefined); - const [summary, setSummary] = useState(null); - const [summaryFailure, setSummaryFailure] = useState(null); - const [invoices, setInvoices] = useState(null); - const [invoicesFailure, setInvoicesFailure] = useState(null); - const [linkFailure, setLinkFailure] = useState(null); - const [busy, setBusy] = useState(false); - - const loadOverview = useCallback(async (): Promise => { - const bridge = window.brainstorm?.billing; - if (typeof bridge?.overview !== "function") { - setOverview(null); - return null; - } - const next = await bridge.overview(); - setOverview(next); - return next; - }, []); - - const loadRemote = useCallback(async () => { - const bridge = window.brainstorm?.billing; - if (!bridge) return; - const [summaryResult, invoicesResult] = await Promise.all([ - bridge.refreshSummary(), - bridge.invoices(), - ]); - if (summaryResult.ok) { - setSummary(summaryResult.value); - setSummaryFailure(null); - } else { - setSummary(null); - setSummaryFailure(summaryResult.reason); - } - if (invoicesResult.ok) { - setInvoices([...invoicesResult.value]); - setInvoicesFailure(null); - } else { - setInvoices(null); - setInvoicesFailure(invoicesResult.reason); - } - }, []); - - useEffect(() => { - let live = true; - void loadOverview().then((loaded) => { - if (live && loaded?.account) void loadRemote(); - }); - return () => { - live = false; - }; - }, [loadOverview, loadRemote]); - - const onLink = useCallback( - (credential: string) => { - const bridge = window.brainstorm?.billing; - if (!bridge) return; - setBusy(true); - setLinkFailure(null); - void bridge - .link(credential) - .then(async (result) => { - if (!result.ok) { - setLinkFailure(result.reason); - return; - } - await loadOverview(); - await loadRemote(); - }) - .finally(() => setBusy(false)); - }, - [loadOverview, loadRemote], - ); - - const onUnlink = useCallback(() => { - void (async () => { - const confirmed = await confirm({ - title: t("shell.settings.billing.unlinkConfirm.title"), - body: t("shell.settings.billing.unlinkConfirm.body"), - confirmLabel: t("shell.settings.billing.account.unlink"), - confirmVariant: ConfirmVariant.Destructive, - }); - if (!confirmed) return; - setBusy(true); - try { - await window.brainstorm?.billing?.unlink(); - setSummary(null); - setSummaryFailure(null); - setInvoices(null); - setInvoicesFailure(null); - await loadOverview(); - } finally { - setBusy(false); - } - })(); - }, [loadOverview]); - - const onCheckout = useCallback((plan: BillingCheckoutPlan, cycle: BillingCheckoutCycle) => { - const bridge = window.brainstorm?.billing; - if (!bridge) return; - setBusy(true); - void bridge - .checkout(plan, cycle) - .then((result) => { - if (result.ok) { - openExternalUrl(result.value); - setSummaryFailure(null); - } else { - setSummaryFailure(result.reason); - } - }) - .finally(() => setBusy(false)); - }, []); - - const onRefresh = useCallback(() => { - void loadOverview(); - void loadRemote(); - }, [loadOverview, loadRemote]); - - if (overview === undefined) { - return ( -

- {t("shell.common.loading")} -

- ); - } - if (overview === null) { - return

{t("shell.settings.billing.unavailable")}

; - } - - const portalUrl = overview.portalUrl; - return ( -
- -
-

{t("shell.settings.billing.account.title")}

- {overview.account ? ( - openExternalUrl(portalUrl)} - onCheckout={onCheckout} - /> - ) : ( - <> -

{t("shell.settings.billing.account.none")}

- {/* The upgrade CTA exists but stays disabled until an account is - linked — checkout needs the account credential. */} -
- -
-

- {t("shell.settings.billing.upgrade.needsAccount")} -

- openExternalUrl(portalUrl)} - /> - - )} -
- {overview.account && ( - - )} -
- ); -} diff --git a/packages/shell/src/renderer/settings/membership-section.css b/packages/shell/src/renderer/settings/membership-section.css deleted file mode 100644 index 6a0cd2db..00000000 --- a/packages/shell/src/renderer/settings/membership-section.css +++ /dev/null @@ -1,544 +0,0 @@ -/* Settings → Membership — pricing UI styles. - * - * Every dimension / colour / radius / font-size pulls from a token. Cards - * sit on the existing settings glass surface; the highlighted "Pro" card - * gets a soft accent halo + gradient face so the eye lands on it first. - * Section-title / typography reuse the shell's design tokens. - */ - -.membership { - display: flex; - flex-direction: column; - gap: var(--space-7); - padding-bottom: var(--space-4); - /* Enable smooth size interpolation between fixed/intrinsic values - * (e.g. `block-size: 0` ⇄ `block-size: auto` on the FAQ disclosures). - * Scoped to the membership tree so it never leaks into other surfaces. */ - /* biome-ignore lint/correctness/noUnknownProperty: required for FAQ open/close size interpolation; supported in Chromium 129+ (Electron). */ - interpolate-size: allow-keywords; -} - -/* ── Hero ─────────────────────────────────────────────────────────── */ - -.membership__hero { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: var(--space-2); - padding: var(--space-4) var(--space-2) var(--space-2); -} - -.membership__title { - margin: 0; - font-size: var(--text-size-3xl); - line-height: var(--text-line-height-tight); - font-weight: var(--text-weight-bold); - color: var(--color-text-primary); - max-width: 32ch; - background: linear-gradient(180deg, var(--color-text-primary) 0%, var(--color-text-secondary) 100%); - -webkit-background-clip: text; - background-clip: text; - color: transparent; -} - -.membership__subtitle { - margin: 0; - font-size: var(--text-size-md); - line-height: var(--text-line-height-relaxed); - color: var(--color-text-secondary); - max-width: 56ch; -} - -/* Cycle toggle — segmented control that mirrors ` - ); -} - -function PlanCard({ - plan, - cycle, - isCurrent, - onPick, -}: { - plan: Plan; - cycle: BillingCycle; - isCurrent: boolean; - onPick: (tier: Tier) => void; -}) { - const price = cycle === BillingCycle.Yearly ? plan.yearly : plan.monthly; - const isFree = plan.monthly === 0; - const tierName = t(plan.nameKey); - const classes = ["membership__card"]; - if (plan.highlighted) classes.push("membership__card--highlight"); - if (isCurrent) classes.push("membership__card--current"); - - return ( -
- {plan.highlighted && ( - - {t("shell.settings.membership.recommended")} - - )} - {isCurrent && ( - - {t("shell.settings.membership.currentPlan")} - - )} -
- -

{tierName}

-

{t(plan.taglineKey)}

-
-
- {isFree ? ( - {t("shell.settings.membership.priceFree")} - ) : ( - <> - {t("shell.settings.membership.currency")} - {price} - - {plan.suffixOverrideKey - ? t(plan.suffixOverrideKey) - : t( - cycle === BillingCycle.Yearly - ? "shell.settings.membership.priceSuffix.yearly" - : "shell.settings.membership.priceSuffix.monthly", - )} - - - )} -
-
    - {plan.features.map((featureKey) => ( -
  • - - {t(featureKey)} -
  • - ))} -
-
- -
-
- ); -} - -function PlanCta({ - plan, - isCurrent, - tierName, - onPick, -}: { - plan: Plan; - isCurrent: boolean; - tierName: string; - onPick: (tier: Tier) => void; -}) { - if (isCurrent) { - return ( - - ); - } - const label = - plan.tier === Tier.Team - ? t("shell.settings.membership.cta.contact") - : t("shell.settings.membership.cta.upgrade", { tier: tierName }); - return ( - - ); -} - -function TrustStrip() { - return ( -
    - {TRUST_ITEMS.map((item) => ( -
  • - -
    - {t(item.titleKey)} - {t(item.bodyKey)} -
    -
  • - ))} -
- ); -} - -function CompareMatrix() { - return ( -
-

{t("shell.settings.membership.compareTitle")}

- - - - - {PLANS.map((p) => ( - - ))} - - - - {COMPARE_ROWS.map((row) => ( - - - - - - - ))} - -
{t("shell.settings.membership.compare.feature")} - {t(p.nameKey)} -
- {t(row.labelKey)} -
-
- ); -} - -function CompareCellView({ cell }: { cell: CompareCell }) { - if (cell.kind === "check") { - return ( - - - - - - ); - } - if (cell.kind === "dash") { - return ( - - — - - ); - } - return {t(cell.key)}; -} - -function FaqList() { - const ids = useMemo(() => FAQ_ENTRIES.map((_, i) => `m-faq-${i}`), []); - return ( -
-

{t("shell.settings.membership.faq.title")}

-
- {FAQ_ENTRIES.map((entry, i) => ( -
- - {t(entry.qKey)} - - -

{t(entry.aKey)}

-
- ))} -
-
- ); -} diff --git a/packages/shell/src/renderer/settings/sections.ts b/packages/shell/src/renderer/settings/sections.ts index c2416564..04346dfe 100644 --- a/packages/shell/src/renderer/settings/sections.ts +++ b/packages/shell/src/renderer/settings/sections.ts @@ -61,15 +61,6 @@ export enum SettingsSection { * seq diagnostic). Drill-down for the dashboard chip; same data, * expanded. */ Sync = "sync", - /** Plan + billing surface — visual-only pricing tiers, billing-cycle - * toggle, current-plan state, mocked checkout. Real Stripe / seat - * management arrive in v2 (see project_marketplace_mvp memory). */ - Membership = "membership", - /** 14.6 — the real account/billing state: current plan + entitlement, - * account link (billing-edge refresh credential), invoices, and - * portal / Stripe-Checkout deep links (payment never renders - * in-product). Membership above stays the pricing/compare surface. */ - Billing = "billing", /** Net-1f — Privacy → Network egress UI. Active proxy, privacy mode * (link previews + embeds), per-app egress audit (with revoke), * recent + blocked requests over the rotated audit log, preview- diff --git a/packages/shell/src/renderer/settings/settings.tsx b/packages/shell/src/renderer/settings/settings.tsx index 470cf517..79676197 100644 --- a/packages/shell/src/renderer/settings/settings.tsx +++ b/packages/shell/src/renderer/settings/settings.tsx @@ -65,14 +65,6 @@ import { FileHandlesPanel } from "./file-handles-panel"; import { GrantsPanel } from "./grants-panel"; import { SettingsHeaderActionsContext } from "./header-actions"; import { KeyboardSection } from "./keyboard-section"; -const MembershipSection = lazy(() => - import("./membership-section").then((m) => ({ default: m.MembershipSection })), -); -// 14.6 — Settings → Billing. Lazy-loaded like Membership: only reachable -// from Settings, keeps the dashboard entry chunk lean. -const BillingSection = lazy(() => - import("./billing-section").then((m) => ({ default: m.BillingSection })), -); // Net-1f — Settings → Privacy → Network panel. Lazy-loaded; the panel // pulls the virtualizer (~6 KB raw) + the proxy editor popover + every // audit-table row component (~12 KB raw together) only when the user @@ -211,18 +203,6 @@ export const SECTIONS: ReadonlyArray = [ icon: IconName.ShieldCheck, group: SettingsGroup.Account, }, - { - id: SettingsSection.Membership, - labelKey: "shell.settings.section.membership", - icon: IconName.Crown, - group: SettingsGroup.Account, - }, - { - id: SettingsSection.Billing, - labelKey: "shell.settings.section.billing", - icon: IconName.CreditCard, - group: SettingsGroup.Account, - }, { id: SettingsSection.Devices, labelKey: "shell.settings.section.devices", @@ -586,18 +566,6 @@ function renderBody( ); - case SettingsSection.Membership: - return ( - - - - ); - case SettingsSection.Billing: - return ( - - - - ); case SettingsSection.Network: return ( diff --git a/packages/shell/src/renderer/ui/icon.tsx b/packages/shell/src/renderer/ui/icon.tsx index 6958cc85..a988a98d 100644 --- a/packages/shell/src/renderer/ui/icon.tsx +++ b/packages/shell/src/renderer/ui/icon.tsx @@ -33,8 +33,6 @@ import { Cloud, CloudSlash, Copy, - CreditCard, - Crown, Cube, DeviceMobile, DotsSixVertical, @@ -47,7 +45,6 @@ import { Gear, Globe, Hash, - Heart, ImageSquare, Info, Keyboard, @@ -74,7 +71,6 @@ import { SignOut, Sparkle, SquaresFour, - Star, Storefront, Sun, Tag, @@ -145,12 +141,7 @@ export enum IconName { Download = "download", Copy = "copy", Pencil = "pencil", - // Membership / pricing glyphs (Settings → Membership). - Star = "star", - CreditCard = "credit-card", - Crown = "crown", Lightning = "lightning", - Heart = "heart", Buildings = "buildings", ShieldCheck = "shield-check", CaretDown = "caret-down", @@ -226,12 +217,7 @@ const ICON_REGISTRY: Record = { [IconName.Download]: DownloadSimple, [IconName.Copy]: Copy, [IconName.Pencil]: PencilSimple, - // Membership glyphs. - [IconName.Star]: Star, - [IconName.CreditCard]: CreditCard, - [IconName.Crown]: Crown, [IconName.Lightning]: Lightning, - [IconName.Heart]: Heart, [IconName.Buildings]: Buildings, [IconName.ShieldCheck]: ShieldCheck, [IconName.CaretDown]: CaretDown,