The Cavu Reader
cavu-page DESIGN.md
34 kB · 611 lines
Markdown
at main

Design: books on atproto via standard.site #

Status: architecture settled with standard.site team guidance, then revised July 2026 for Leaflet-first interop: every text chunk carries pub.leaflet.content (one linearDocument page per chapter; chunk = one release for serialized books); our block types (blocks/toc, blocks/verse) are injected into Leaflet's open page unions rather than wrapped in our own content type. page.cavu.content and blocks/chapterTitle are deleted. The lexicon surface is now just lexicons/page/cavu/: book (publication extension), pages (comic/fixed-layout content), blocks/toc, blocks/verse — codegen'd types match. The no-backend reader (reader/, TanStack Start, paginated

  • infinite scroll + two-page comic spreads) consumes the revised shapes, and the studio (/studio) is the only way to publish: the command-line publishers (publish-driftwood, publish-honeyguide-release, publish-release, publish-byline-book, update-cover, import-leaflet-comic) were removed in September 2026. "Implementation delta" at the end is kept as history.

Constraints #

  1. A book is split over multiple records. Sometimes a record holds one chapter, sometimes a subchapter, sometimes several chapters. Records are storage chunks sized for network limits, not units of meaning.
  2. A "chapter" is a TOC breakpoint, not a record. The start of a chapter exists at a specific point in a specific record.
  3. The TOC is multi-level; its leaf nodes are chapters. Inner nodes are groupings (parts, volumes, acts).
  4. Serialized writing and series must both be easy to subscribe to — adding chapters to a book, and adding books to a series.
  5. Two content formats from day one: EPUB-formatted text (a subset of EPUB XHTML) and comics (images for pages). Comics should consider multi-zoom-level images or image formats that support progressive zoom.
  6. Authors control layout and styling as far as possible — including odd and non-standard typesetting. Book design is part of the work.

Architecture (per standard.site team guidance) #

Thing Record Notes
Book site.standard.publication carries our extension object (spine, TOC, series link, cover)
Series site.standard.publication plain publication; books point at it
Content chunk site.standard.document one per release (a chunk may hold several chapters, one per page); main text in the content union; largely replicates book fields
Frontmatter site.standard.document one per book: cover, human-readable TOC, acknowledgments — the renderable front door for generic standard.site apps

The extension pattern #

The spine lives on the book publication as a pass-through property, not a sidecar record. standard.site explicitly blesses additional properties ("shared baseline, not a constraint"), and the ecosystem idiom — observed on real Leaflet and pckt.blog publications — is a bare key whose value is a $type-tagged object from the platform's own lexicon, alongside the standard fields kept populated as fallback (e.g. theme next to basicTheme).

We follow it with one bundled key:

{
  "$type": "site.standard.publication",
  "name": "...", "url": "...", "icon": ..., // standard fields as fallback
  "book": {
    "$type": "page.cavu.book",            // self-describing, validatable
    "spine": [                               // ordered chunk entries
      { "document": "at://...", "roles": ["frontmatter", "toc"] },
      { "document": "at://..." },
      { "document": "at://...", "roles": ["appendix"], "linear": false }
    ],
    "series": { "publication": "at://...", "index": 2 },  // optional
    "cover": { /* blob */ },                 // portrait; same blob CID as
                                             // frontmatter doc's coverImage
    "completionStatus": "ongoing"
  }
}

Spine entries are property bags, not bare AT-URIs — schema evolution (string → object later would break every record) and EPUB precedent (itemref attributes, manifest properties, landmarks). roles uses the EPUB structural-semantics vocabulary (toc marks the document holding the TOC block — readers find it with a free array scan of the record they already fetched); linear: false (absent = true, as in EPUB) marks supplementary material skipped in continuous reading.

  • One key means one collision surface, one schema def, and presence/absence of book distinguishes book publications from series publications.
  • $type inside the value keeps us in the typed regime: our lexicon validates it, codegen types it, anyone can resolve the schema over DNS.
  • Size guardrail: the publication record (~1MB ceiling) is fetched by every subscriber and indexer. Spine entries are lean property bags (the TOC with its titles lives in a document, not here) — even thousand-chapter serials stay small.
  • Blobs are CID-referenced, so the cover blob is shared with the frontmatter document's coverImage at no storage cost. Keep blobs within declared lexicon limits even though validation is lenient in the wild.

Spine vs TOC (constraints 1–3) #

The EPUB 3 split, exactly:

  • Spine — ordered list of content-chunk entries ({document, roles, linear}) on the book publication object. Defines the continuous reading flow; chunk boundaries are invisible to the reader.
  • TOC — a block type (page.cavu.blocks.toc) inside a spine document, modeled on EPUB 3's navigation document: simultaneously machine-parseable structure and rendered content that sits at its natural place in the book (a printed TOC page). EPUB 2 kept the TOC as out-of-band metadata (NCX) and forced authors to duplicate a rendered TOC page; EPUB 3 unified them — navigation is content. The spine entry's toc role plays EPUB's properties="nav" manifest flag: readers fetch publication + the toc-tagged document (two reads) for full navigation.
  • TOC entries are flat and depth-annotated (flat-with-depth reconstructs any tree in one pass; no recursive lexicon refs). Leaf entries are chapters targeting (chunk AT-URI, page id) — the linearDocument page's own (standard) id field — with an optional finer block anchor; inner entries are groupings with optional targets. "Leaves must have targets" and "target pages must carry ids" are app-level validation.

Subscriptions (constraint 4) — solved natively #

site.standard.graph.subscription targets publications. Book = publication and series = publication, so both are directly subscribable:

  • New book in series → new book publication whose book.series points at the series publication.
  • New release → see below.

Chunk = release. standard.site documents are as much notification units as storage units: subscribers should get exactly one new-document event per release. So a serialized book releasing chapters in pairs puts the pair in one document — chapters are pages inside it, not documents of their own. The release ritual is one atomic com.atproto.repo.applyWrites batch, three writes:

  1. create the release document (pub.leaflet.content; one linearDocument page per chapter, pages carrying ids like ch-5; pages[0] is the release nav page with a blocks.page card per chapter when the release holds more than one).
  2. put the frontmatter/TOC document with a new leaf entry per chapter targeting (release doc, page id) (plus a grouping entry if the release is a named part).
  3. put the book publication with one new spine entry (and completionStatus flipped on the final release).

Atomicity means no window where the spine points at missing documents or new chapters are unreachable from navigation. Release-document title metadata covers the whole release ("The Second Tide & Undertow — Novella"); followers get one notification, and the document renders natively in anything leaflet-aware.

Reading progress (page.cavu.progress) #

The reader-side complement to subscriptions: the logged-in home page lists the user's site.standard.graph.subscription records, joined with their reading progress, showing progress-vs-content ("8 of 10 chapters, 2 new since you last read"). Progress is a record in the reader's own repo (the BookHive-shelf pattern: activity lives with the actor), one per book, updated in place:

  • Derived rkey. key: any; the rkey is the book publication's AT-URI with at:// stripped and / replaced by ~ (all legal rkey characters, reversible). Updates are idempotent putRecords with no read-before-write; one book's progress is a single getRecord; the whole shelf is one listRecords on the collection.
  • Position grammar, coarse to fine: document (spine doc AT-URI) → page (page id — same field for linearDocument and comic pages) → optional block index path + byte offset mirroring pub.leaflet.pages.linearDocument#position (Leaflet's own quote grammar). Fine fields are edit-fragile by nature; readers resolve what still exists and clamp the rest.
  • position vs furthest: position is the resume point, last-writer-wins across devices; furthest never regresses (clients max-merge), covering re-reads and stale-device writes.
  • Denormalized percent (integer 0–100, body-matter weighted — the bookProgress logic in book.ts), computed by the writing client, which has the book loaded. The home page renders progress bars from progress records + publication records alone; "N new chapters" = linear-spine entries after position.document — no spine-document fetches on the home page at all.
  • startedAt / finishedAt / updatedAt — shelf semantics (finishedAt marks finished regardless of percent; updatedAt doubles as last-read-at for a "continue reading" sort).
  • Public, therefore opt-in. Repo records are world-readable; writing progress must be a visible user choice, not ambient telemetry. Readers who decline keep progress in local storage only.
  • Why not buzz.bookhive.book as the progress record (checked before settling): hiveId is required and Cavu books aren't in the hive catalog; key: tid means list-and-match lookups, read-modify-write updates, and cross-device duplicate races; bookIdentifiers has no AT-URI field, so the record can't even name our book without a pass-through extension; and bookProgress (percent/currentPage/currentChapter) is display data with no resume point. It also carries the user's review and stars — rewriting it at reading cadence races their app's edits. The seams do line up for mirroring (their percent/startedAt/finishedAt/status map losslessly from our fields), so: page.cavu.progress is source of truth at reading cadence; mirror into buzz.bookhive.book at milestone cadence (start, chapter boundaries, finish) once the book has a hiveId.

Content formats (constraint 5) #

Chunks are site.standard.document records; the format lives in the document's content open union as our $type-tagged objects, mixable within one spine:

  • Text: pub.leaflet.content — Leaflet's own wrapper, so every text document (frontmatter included) renders natively in leaflet-aware apps (see "Text content format" below); book-only blocks ride the open block unions. textContent gives indexers/previews plaintext. Target chunk size ~100–300KB.
  • Comics: page.cavu.pages — pre-paginated image content, the fixed-layout counterpart to a pub.leaflet.content text chunk (see "Comic content format" below). Zoom options:
    1. Renditions per page — blobs at multiple widths. Simple, works everywhere; start here.
    2. Progressive formats — JPEG XL / progressive JPEG decode low-res from a byte prefix; needs range-request support on blob endpoints (not guaranteed).
    3. Tiling (deep-zoom / IIIF-style) — best zoom UX, most complexity.

Chunks-as-documents also restores per-chapter interop: timeline previews, per-chunk bskyPostRef comment threads, and per-chunk labels.

Text content format (Leaflet-first) #

The ecosystem convention (confirmed on live Leaflet and pckt.blog records) is that each platform puts its own $type-tagged content wrapper in site.standard.document.content. We deliberately don't follow it for body text: a page.cavu-only wrapper would make every chapter unrenderable outside our reader. Body chunks carry pub.leaflet.content itself, so any leaflet-aware app renders them natively and our reader renders them with the same block components.

Structure conventions inside a body chunk:

  • Chapter = page. The chunk's pages array holds one pub.leaflet.pages.linearDocument per chapter. The page's own (standard, optional) id field is the chapter anchor — we require it by app-level rule (ch-5-style, human-legible). Page boundaries give our reader chapter boundaries with zero nonstandard vocabulary. A chunk never holds a partial chapter; an oversized chapter splits into more spine chunks, never into blobPages (which we honor when consuming leaflet content — the lexicon says consumers MUST — but never emit).
  • Reachability in Leaflet: the release nav page. Leaflet renders pages[0] and reaches other pages only through pub.leaflet.blocks.page portal blocks (Notion-style tree, no sequential next/prev — confirmed in their PostPages.tsx). So a multi-chapter chunk's first page is a short release landing page — the release title and one blocks.page card per chapter — and each chapter page is a portal target. Single-chapter chunks skip this; the chapter itself is pages[0]. Rule for our reader: pages targeted by TOC leaf entries are chapters in the reading flow; untargeted pages are auxiliary (nav) and skipped — our book TOC supersedes them.
  • Chapter title is content: the first block of a chapter page is a pub.leaflet.blocks.header with the chapter title as rendered in the book. Chapter-opening treatment (fresh page in paginated flows, the chapter drop, generous separation in continuous flows) is TOC-driven: our reader applies it to pages targeted by TOC leaf entries. The former page.cavu.blocks.chapterTitle block is dropped — a page boundary plus a TOC target carries the same information in standard vocabulary. Degradation in foreign renderers: an ordinary heading, which is fine. Ordinary in-chapter headings are also header; the TOC, not the heading level, distinguishes them.
  • Book blocks ride the open union. linearDocument's block union is open (no closed: true; the lexicon spec makes unions open by default), and Leaflet's published-view renderer confirms the skip behavior: its block dispatch ends in default: return null (PostContent.tsx). So page.cavu.blocks.verse (and toc, below) sit inside leaflet pages; foreign renderers show a silent hole (a chapter minus its lullaby, no indication) — accepted because a mostly-readable chapter beats an unrenderable one. Keep the document's textContent complete so skipped words still exist in previews and search. Corollary: never round-trip our documents through Leaflet's editor, which would drop unknown block types on save.
  • Sub-page anchors: standard #id facets. Leaflet's richtext facet lexicon includes an #id feature ("used for linking to a segment") — an inline anchor on a byte range, in standard vocabulary. Finer-than- page targets (deep links, the future page.cavu.position reading-position record) use (document, page id, facet id) — no nonstandard pass-through block ids needed. Not load-bearing today: chapters, the only current anchor consumers, are whole pages.

The frontmatter/TOC document is leaflet content too — page.cavu.content is deleted; there is no page.cavu text wrapper left. Its single linearDocument page is:

  • a level-1 header — the book title (title page, not a chapter);
  • the injected page.cavu.blocks.toc block — flat depth-annotated entries targeting (document, page id[, facet anchor]) — which our reader parses for navigation UI and renders as the printed TOC page.

The TOC is deliberately a cavu-only structure with no in-page fallback: Leaflet skips the block and shows a plain title page, which is fine — leaflet-aware apps already navigate the publication through their own chrome (chronological post feed, postsList, prev/next), so a hand-maintained card list would just duplicate that. The document's textContent carries a plaintext TOC and description a real blurb for preview cards.

Should a work someday need blocks whose omission would misrepresent it beyond tolerance, the answer is a new content type added to the spine's open content union — additive, not a revival of the wrapper.

Book-only blocks, now three:

  • page.cavu.blocks.verse — plaintext with significant newlines (\n = line break, \n\n = stanza break) + the same Leaflet facets. Mirrors pub.leaflet.blocks.text's shape so rich-text tooling carries over. Renderers keep stanzas together and style verse distinctly. (Survey basis: The Driftwood Library, 8 chapters, ~18k words — prose, quoted embedded tales, dialogue, and one lullaby in quatrains; verse was the only structure with no Leaflet block and no faithful markdown representation.)
  • page.cavu.blocks.toc — the table of contents as content, per EPUB 3's navigation-document unification.
  • page.cavu.blocks.screenplay — one screenplay element (scene heading, action, character, parenthetical, dialogue, lyric, transition, centered) as plaintext + facets, with an optional sceneNumber and a dual flag for dual dialogue. A script is a flat run of these — the paragraph-type model of Fountain and Final Draft — inside ordinary chapter pages, one page per act. See "Screenplays" below.

Title vs heading convention: a chunk document's title is metadata — self-contained for browser tabs, previews, feed cards, and subscription notifications, covering the whole release ("The Second Tide & Undertow — The Driftwood Library"). Chapter titles are content: header blocks opening each chapter page. The frontmatter document opens with a level-1 header of the book title (title page, not a chapter).

Importer note: markdown sources don't mark verse explicitly — detection is heuristic (runs of short hard-broken lines) or author-annotated.

Screenplays #

A book whose page.cavu.book form is screenplay is a script, set to the US industry standard: Courier Prime throughout, the standard element indents (cue 2.2in in, parenthetical 1.6in, dialogue 1in and 3.5in wide, transitions flush right), and in PDF the Letter script page exactly — 54 lines, scene numbers in both margins, page numbers top right, (MORE)/(CONT'D) where a speech crosses a page. Screen and EPUB keep the indents but fit them to the reading column. The source format is Fountain; # sections become chapters (acts).

form sits beside styling rather than in it: it changes what the importer reads and how every renderer sets the body, not a preference. Under it, the faces, paragraph style, justification, chapter drop and initial are fixed by convention; only page size and chapter break remain the author's.

This deliberately stretches the tolerance rule above. A screenplay rendered by a foreign Leaflet reader is a silent hole — act titles and nothing else — which is past "mostly readable". The rule's answer would be a new content type, but that buys nothing here: no foreign renderer can set a script either way, and a new type would duplicate every chapter-shaped path (TOC anchors, the reading route, bookState, exports, KOReader XPointer mapping, highlights, the studio block editor) for no gain in interop. textContent stays complete, so previews and search still see the whole script.

Leaflet's pub.leaflet.blocks.html was considered and rejected as the storage: it renders author HTML in a sandboxed iframe of fixed height (16–1600px), which clips a scene, can't flow through paginated columns, is opaque to highlights and KOReader positions, ignores the reader's theme, and gives the PDF nothing to break (MORE)/(CONT'D) from. It remains a possible additive Leaflet fallback — a per-scene HTML copy the Cavu reader skips — without touching records already published.

Comic content format #

page.cavu.pages is the pre-paginated counterpart to a pub.leaflet.content text chunk, modeled on EPUB fixed-layout: an ordered array of page objects, each one image blob + aspectRatio (reusing pub.leaflet.blocks.image#aspectRatio)

  • alt (ideally a panel/dialogue transcript) + optional id + optional spread hint. Same chunking rules as text — a chunk is usually a chapter or issue, boundaries invisible to the reader — and both content types mix freely in one spine (a prose foreword before comic chapters).

Survey of a real Leaflet comic (aavia-comic.leaflet.pub, 18 pages) found one site.standard.document per page with a single pub.leaflet.blocks.image block (~2.5MB webp, 2480×3508) — fine for a feed-style site, but no spine order, no spreads, no reading direction. Importing that shape means converting it into chapter-sized page.cavu.pages chunks (grouping "Prologue p.N"-style titles into chapters, re-uploading blobs); a one-off script once did this, and a studio comic import would have to do the same.

Two-page spreads. Print-comic conventions, EPUB's vocabulary:

  • pageProgression: ltr | rtl on page.cavu.book (EPUB's spine-level page-progression-direction; absent = ltr, rtl for manga) decides which slot of a spread comes first in reading order and which way pages turn.
  • Per-page spread: left | right | center mirrors EPUB's page-spread-* properties. left/right pin a page to a physical side — renderers insert a blank slot when the natural flow disagrees, exactly as print does (e.g. right on page 1 makes it open alone like a proper recto); center gives a page the whole spread. Absent means pages fill spreads in reading order. Pages wider than tall are center regardless — a double-page spread drawn as one image.

Anchor grammar (resolves the former open question): the page id is the anchor — TOC entries and intra-book links target (document AT-URI, page id), the same grammar as linearDocument page ids in text chunks. One grammar for both content types.

Zoom, deferred: single full-resolution blob per page for now (maxSize 20MB; the wild runs ~2.5MB/page). The lexicon can grow an optional per-page renditions array additively when needed.

Ecosystem: byline.pub and BookHive #

byline.pub publishes CC0, venue-neutral pub.byline.* lexicons (vendored in lexicons/pub/byline/) for the catalog layer: book is author-signed listing metadata — title/subtitle/tagline, tiered descriptions, cover + coverAlt, genres/tags, publication status (draft/forthcoming/published/outOfPrint as token defs), editions (format, ISBN-10/13, retailer links, audiobook duration), external identifiers (ASIN, Goodreads, and hiveBookUri — an AT-URI into BookHive's buzz.bookhive.catalogBook, revealing a third ecosystem player for reader tracking/reviews), plus series, profile, and comment (reader comments in the commenter's own repo, strongRef to the book, threaded via parent).

No content anywhere — no spine, no text. Byline answers "what is this book and where do you get it"; we answer "here is the book, read it." The integration seam:

  • Don't duplicate the catalog. page.cavu.book stays a reading-format object; editions/retailers/genres belong in a companion pub.byline.book record. A Cavu book can be its own byline listing where the "edition" is natively readable on atproto.
  • Correlate, both directions. Their identifiers pattern (hiveBookUri) is the template: an identifier/link pointing at our book publication is the natural ask to byline; a listing AT-URI on page.cavu.book pointing at the pub.byline.book record is ours.
  • Validation: their series design matches ours exactly ("Book points at the series; the series record never points back"), with two ideas worth stealing: denormalized series name on the membership object (render without a fetch) and their contributor def (name required, DID optional — friendlier than site.standard's DID-required contributor for crediting off-protocol co-authors/narrators).
  • Social layer for free: pub.byline.comment (book-level, threaded) + standard.site's per-chunk bskyPostRef cover book and chapter discussion without us minting a comment lexicon.

Adopted into page.cavu.book (baked-in byline dependency, deliberate): contributors reuses pub.byline.defs#contributor; listing AT-URI points at the companion pub.byline.book catalog record; coverAlt added; seriesRef.name denormalized.

BookHive (buzz.bookhive.*, vendored in lexicons/buzz/bookhive/) is the reader-shelf layer: book is a record in the reader's repo — status tokens (wantToRead/reading/finished/abandoned), 1–10 stars, review text, started/finished dates, and a bookProgress object (percent / currentPage / currentChapter) — correlated to the catalog via bookIdentifiers and hiveBookUri. buzz is their threaded comment record; catalogBook is their scraped canonical catalog (in the bookhive.buzz repo, keyed by hive id). Implications for our reader:

  • Write buzz.bookhive.book records on the user's behalf (status, stars, review, coarse progress) so Cavu reading activity shows up on BookHive shelves — interop instead of a competing shelf lexicon.
  • Their bookProgress is page-granularity; our fine-grained record is page.cavu.progress (see "Reading progress"), with coarse sync mirrored into bookProgress.
  • Getting Cavu books into their catalog (so readers can shelve them) goes through their hive-id pipeline — same conversation as the byline identifier ask.

Author styling (constraint 6) #

Semantics are the floor; styling is progressive enhancement. Records must render legibly with zero author styling (other apps, screen readers, reader-mode); above that floor, the author owns presentation in escalating layers:

  1. Book theme — page.cavu.theme-style object on the publication (the theme/basicTheme pattern): typography with embedded font blobs (EPUB's book-identity feature), palette, page background, paragraph style (indent vs spaced), justification, chapter-drop depth, scene-break ornament. Covers the design identity of most books entirely.
  2. Block attributes — declarative, enumerable hints on the block wrapper: alignment (Leaflet precedent), inset, size variant. Safely ignorable by simple renderers.
  3. Author stylesheet — a CSS blob on the publication + a class hook on block wrappers. The "odd typesetting" unlock. Safer for us than publisher CSS ever was for EPUB reading systems: atproto structurally forbids external fetches (fonts/images are blobs), and our reader renders into a container we control — sanitize with a property allowlist and let authors go wild.
  4. Freeform layout content types — for concrete poetry and House-of-Leaves pages: a positioned-canvas content type (pub.leaflet.pages.canvas is precedent) or SVG pages with textContent fallback, mixable into the spine chapter by chapter via the open content union.

Reader-side policy, exactly one: reader-mode always available — user accessibility overrides author art on demand (EPUB's "publisher defaults" toggle, done deliberately). Author styling is on by default.

Presentation defaults documented in block descriptions (e.g. the chapter drop) are what the absence of author styling produces — the floor, not a ceiling.

URL resolution & hosting (standard.site verification) #

How https URLs and records point at each other, per the standard.site verification spec and Bluesky's shipped integration (researched July 2026):

  • Documents: per-page <link rel="site.standard.document" href="at://…"> in the page head. Fully path-scoped. There is also a rel="site.standard.publication" link tag, but the spec marks it "a hint only — always confirm via the .well-known endpoint". The reader emits both.
  • Publications: /.well-known/site.standard.publication returns the publication's AT-URI as plaintext — and it is path-capable ("Non-root Publications"): a publication whose url is https://example.com/books/foo verifies at https://example.com/.well-known/site.standard.publication/books/foo. Live precedent: atproto.com/blog. The publication url field is format: uri; nothing forbids a path, and Leaflet's own resolver longest-prefix-matches path-based publication urls.
  • So: no subdomain-per-book. One reader domain can host many books at paths. Leaflet mints a subdomain per publication, but that's their product choice (and matches atproto's strictly host-scoped identity layers — handles and did:web reject paths), not a spec requirement. Caveat: non-root support is newer spec text, and Bluesky's appview keeps a hardcoded subpath-friendly domain allowlist (packages/bsky/src/util/standard-site.ts) for document-URL validation — serve both the path-suffixed well-known and the hint link tags for maximum indexer compatibility.
  • Verification is bidirectional and consumer-side (indexers like docs.surf, Bluesky's cardyb link-card service): record's url + path must equal the page URL, and the well-known must return the record's AT-URI. Consequence for a shared reader domain: only affirm publications whose record url actually points at us — echoing an AT-URI for every path would falsely claim every book on the network. Known race: records published before the site deploys need indexer re-verification.

Open questions #

  • Series ordering — is index on the book→series link enough, or does the series publication want its own ordered list (and what does a series "TOC" look like for generic apps)?
  • Version pinning / editions — spine references are floating AT-URIs; strongRefs would pin immutable editions at the cost of an update fan-out on every typo fix.
  • Comic zoom mechanics — PDS blob size limits in the wild, range-request support on getBlob/CDNs, JPEG XL client support; when a per-page renditions array earns its complexity.
  • Verse detection on import — heuristic vs author annotation in markdown sources.

Implementation delta (July 2026 revision → code) #

Done: lexicons match the revision (page.cavu.content and blocks/chapterTitle deleted; blocks/toc entries target (document, page[, facet anchor]); scripts/codegen.ts roots updated and src/lexicons regenerated — pub.leaflet.content, both page types, and the full block set are now typed). Still to apply — these files currently import the deleted page/cavu/content types and fail typecheck:

  1. Reader: render pub.leaflet.content chunks (multi-page linearDocument, portal-block cards, blobPages honoring, open-union verse/toc); TOC-driven chapter-opening treatment keyed on (document, page id); skip TOC-untargeted (nav) pages in the reading flow; drop the chapterTitle render case (Blocks.tsx); update book.ts content discrimination and docs.tsx prose.
  2. Importers: publish-driftwood.ts/publish-honeyguide.ts emit pub.leaflet.content — one linearDocument page per chapter, nav page first when a chunk holds several chapters, headers instead of chapterTitle blocks; the frontmatter doc becomes leaflet content (title header + injected toc block). import-leaflet-comic.ts and smoke.ts also import deleted types. Done for the web importer (July 28): reader/src/routes/import.tsx (+ lib/markdown.ts, lib/publish.ts) is a browser importer — cover + markdown files in, atomic applyWrites of the revised shapes, same-name re-import replacement — and writes a buzz.bookhive.book shelf record with BookHive's deterministic hiveId (bk_ + sha256base64(objectHash({title, author} lowercased))[:20], ohash@1 — verified byte-identical against their production catalog). Auth is atproto OAuth with granular scopes at two grant levels (lib/oauth.ts + lib/useAuth.ts, @atproto/oauth-client-browser): the /login reader grant is atproto include:site.standard.authSocial repo:buzz.bookhive.book?action=create&action=update (subscriptions/recommends + shelf progress tracking); the publisher grant — asked for only via "Publish my work" on /login (the logged-in profile view) or the upgrade prompt on /import — swaps in include:site.standard.authFull and adds blob:image/*. Client metadata (served /oauth/client-metadata.json; loopback client id in dev — browse via http://127.0.0.1:3000) declares the union of both scopes and redirect URIs /login + /import. DPoP sessions persist in IndexedDB. This covers delta item 4's OAuth login; the logged-in home page joining subscriptions × publications × shelf progress is still to build. The script importers remained pre-revision until they were removed (September 2026) in favor of the studio.
  3. New: release-pair.ts — the three-write serialized-release batch (create release doc, put TOC doc, put publication).
  4. Subscription flow (pre-launch requirement): reader login (atproto OAuth), subscribe/unsubscribe writing site.standard.graph.subscription records, a logged-in home page joining subscriptions × publications × page.cavu.progress (progress bars, "N new chapters", continue reading), and opt-in progress writing from the reading views.