Drafts the next architectural arc for the Book Designer around two ideas: - Events are linked files. Generalizes the pin-vs-live toggle shipped for chapters to every external reference. A new book.links[] collection becomes the project's 'Links panel' (chapters, masters, pages, illustration sets, future asset kinds). - Threaded text frames (InDesign-style). First-class text-frame layers on designed pages, threaded port-to-port; a thread declares a content source (a linked chapter event) and the pagination engine pours text through the frame chain. The current 'reflow entries in book.pages' model collapses into links[] + threads[] + auto-generated designed pages — a one-time migration on load. Pagination engine, PDF export, Reader Mode, Linked Designs resolver, and NIP-23 chapter events all survive. Phasing J1..J6 captured in the spec + TODO. No code yet — this is just the design doc and arc placement. Co-Authored-By: Claude Opus 4.7 <[email protected]>
18 KiB
Book Designer — Linked Content + Threaded Text Frames
Status: Drafted, not yet implemented. This document describes the
next architectural arc for the Book Designer (the "Phase J" follow-on
to BOOK_DESIGNER_FEATURE.md). The current implementation works but
mixes content and layout in ways that don't scale to design-driven
typesetting; this spec lays out the upgrade.
TL;DR
- Events are linked files. A book references Nostr events by coordinate the same way an InDesign project references images, text files, and master spreads — by link, not by embedded copy. This generalizes the pin-vs-live toggle already shipped for chapters to every external asset the book references.
- Linked Content library replaces the current "chapters mixed into the Pages overview" model. A dedicated tab lists every external event the book links: chapter articles, shared masters, designed pages from other books, illustration sets.
- Threaded text frames (InDesign-style) replace today's
"one master-text-frame paginates into N copies." Designed pages
carry first-class
text-framelayers with stable ids; the author threads frames port-to-port across pages; a thread declares its content source (a linked chapter event) and the pagination engine pours text through the chain. - The Pages overview filters to designed pages only — no more 250 thumbnails for a 5-chapter book.
Vision
The Book Designer should feel like InDesign with a Nostr-native distribution model. Authors design pages bespoke (cover, frontmatter, chapter-start spreads, full-bleed illustrations) and link content (chapter text, shared masters, designed pages, illustration sets) by referencing Nostr events. Text flows through user-arranged frames the same way it flows through linked frames in InDesign. The book manifest stays small — references, not payloads — and updates to any linked event propagate to every book that links it.
Mental model — events as linked files
| Nostr event kind | InDesign equivalent | Already shipped |
|---|---|---|
kind:30023 NIP-23 article (chapter markdown) |
.txt / .md placed file |
✅ chapter import + pin/live toggle |
kind:30078 casewrap-page, role:'cover-spread' |
Cover-spread master library | ✅ |
kind:30078 casewrap-page, role:'master' |
Master page | ✅ |
kind:30078 casewrap-page, role:'interior' |
Designed page from another book | ✅ Phase F3 import |
| Linked design layer (any flavor) | Placed .psd / .ai |
✅ Linked Designs spec |
kind:30078 casewrap-book manifest |
The .indd project file |
✅ |
The pattern is consistent. What's missing is treating all of these as first-class entries in a Links panel the way pro tools do — with status (live / pinned / encrypted / broken), update notifications, and relink / embed actions.
Current vs proposed data model
Today
book {
spec: { trim, bleed, spineFromPages, ... }
cover: { ref }
masters: [{ ref, label }, ...]
pages: [
{ type: 'design', id, ref, masterId, anchor, gating, ... },
{ type: 'reflow', id, chapterMarkdown, chapterTitle, ref, pinnedEventId, masterId, ... },
...
]
}
Reflow entries carry inline chapterMarkdown (when locally edited) or
a ref to a NIP-23 article event. Mixed into the same ordered list as
designed pages. The pagination engine clones the entry's masterId
into N physical pages and pours markdown into the master's first
text-frame layer.
Proposed
book {
spec: { ... }
cover: { ref }
masters: [{ ref, label }, ...]
pages: [ // designed pages only
{ id, ref, masterId, anchor, gating, ... },
...
]
links: [ // the "Links panel" — every external reference
{
id, // local link id (stable across renames)
kind: 'chapter' | 'master' | 'page' | 'design' | 'image',
ref: { kind, pubkey, dTag, relays, encrypted },
mode: 'live' | 'pinned', // generalizes the chapter pin/live toggle
pinnedEventId: string | null,
label: string, // user-friendly display name
lastResolvedAt: number, // for "X minutes ago" hints
lastEventId: string, // for "update available" detection
},
...
]
threads: [ // text flows
{
id,
sourceLinkId, // points to a links[] entry of kind 'chapter'
frames: [ // ordered chain of frame refs
{ pageId, frameId },
...
],
overflow: 'truncate' | 'autoflow', // policy when text exceeds frames
},
...
]
}
Reflow entries disappear from book.pages. The chapter content lives
in a links[] entry; the flow path lives in a threads[] entry; each
designed page can host any number of text-frame layers.
Migration
Existing books need a one-time migration on load:
- For each
pages[]entry wheretype === 'reflow':- Create a
links[]entry of kind'chapter'(carrying the entry'sref/pinnedEventId/chapterTitle). - Auto-generate N designed pages (one per paginated physical page)
using the entry's
masterId. Each new page gets onetext-framelayer matching the master's text-frame geometry. - Create a
threads[]entry that chains those N frames and points at the newlinks[]entry.
- Create a
- For each remaining
pages[]entry (type === 'design'), drop thetypediscriminator — every entry is now a designed page.
Migration runs once at load when an old-schema book is detected (by
the presence of any type: 'reflow' entry). The original
chapterMarkdown field is preserved on the link entry so locally-
edited (not-yet-published) chapters survive.
Threaded text frames
Frame layer
A new layer type (or an upgrade of the existing text-frame master
layer) with this shape:
{
id,
type: 'text-frame',
target: 'book-page:<pageId>',
x, y, w, h, rotate, opacity, z,
frameId, // stable id, unique within the page
threadId: string | null, // which thread this frame belongs to
// No content of its own — content flows in from the thread.
}
Stable frameId survives layer reorders so threads don't break when
the user fiddles with z-order. The wrapper id is the layer's
identity in state.layers; frameId is the thread-addressable id.
Threading UI
In InDesign every frame has two ports:
- In port — small square top-left. Visible state:
- Empty: nothing flowing in (head of a thread or unlinked frame).
- Arrow ▶: text is flowing in from a previous frame.
- Out port — small square bottom-right. Visible state:
- Empty: end of thread, no overflow.
- ▶: linked to another frame; mouse over to see which.
- Red ⊞: overset — text wants more frames but the chain ends.
Linking:
- User clicks the out port of frame A → cursor changes to a "loaded text cursor" carrying that link.
- User clicks the in port of frame B → A's
nextFramebecomes B; B joins the same thread. - Esc cancels the load.
Hover over either port to see the thread (highlight every frame in the chain). Right-click for "Break thread" (split the chain at this frame) or "Remove from thread" (yank this frame out, re-link its predecessor to its successor).
Pagination engine changes
Today: clone master N times, fill each master's first text-frame.
New: for each thread, walk frames[] in order:
- Resolve the source link's markdown (live event or pinned snapshot).
- Run the existing pagination engine, but instead of producing pages, produce frame fills — each frame in the chain gets one page-worth of blocks (with layout for that frame's geometry).
- When text runs out, leave remaining frames empty.
- When frames run out with text remaining, mark the last frame as overset (red ⊞ + warning hint).
The existing pagination engine (greedy line breaking, block model, widow/orphan rules) doesn't change — only the "what's the frame geometry" and "what's the page boundary" inputs do.
Multiple threads per book
A book may have many threads:
- Main prose thread (Chapter 1 → 2 → 3, flowing through Chapters 1-50 of designed pages).
- Sidebar thread (recurring callout text in a sidebar frame).
- Front-matter thread (the colophon + acknowledgements).
Each thread is independent. A frame belongs to at most one thread.
Auto-flow
When the loaded text cursor (carrying a chapter waiting to be placed) is Shift-clicked on an empty area, generate enough new designed pages to fit the remaining text, using the active master as the page template. This is InDesign's autoflow. UX:
- Place chapter on frame A: text fills A, overflow indicator appears.
- User Shift-clicks the out port: engine generates new pages with the master template's text frames, threads them all to A, fills.
Without autoflow you can still drag-create new frames manually and thread them — autoflow is the convenience layer.
Linked Content library tab
A new top-level tab in book mode (alongside Pages and Cover). UI:
┌─ Linked Content ─────────────────────────────────┐
│ [+ Add chapter from Nostr] [+ From JSON] [↻] │
├──────────────────────────────────────────────────┤
│ 📄 Chapter — "Introduction" live · ✏ │
│ naddr1... · author: Adam · 1.2 KB · 12m ago │
│ │
│ 📄 Chapter — "Body" [encrypted] pinned · ✏ │
│ naddr1... · author: Adam · 8.4 KB · 1h ago │
│ │
│ 🎨 Master — "Standard interior" live · ✏ │
│ naddr1... · author: Adam · 0.4 KB · just now│
│ │
│ ⚠ Chapter — "Footnotes" [unresolved] live · ↻ │
│ Couldn't fetch from your relays. │
└──────────────────────────────────────────────────┘
Per-entry actions:
- Edit — for chapters: open the markdown editor pane. For masters / pages: open the surface in the canvas (existing flows).
- Toggle pin / live — same widget as today's chapter pin toggle.
- Relink — replace this entry's
refwith a different event coordinate (paste a new naddr). - Embed — copy the resolved payload inline into the manifest so the book is offline-safe. Loses the auto-update behavior. Reversible by adding back as a link.
- Show usages — highlight every thread / layer that references this link.
- Remove — only allowed when no thread or layer uses this link.
Status badges:
- 🟢
live— resolves to the latest event on each load. - 🟡
pinned— locked to a specificeventId, ignores republishes. - 🔒
encrypted— private (NIP-44 / NIP-04 encrypted to the author's pubkey). - ⚠
unresolved— last fetch failed. - ⬆
update available— pinned, but the live version has changed.
Phasing
Phase J1 — Linked Content tab + Pages filter (the foundation)
Scope. Pure UI / data refactor; no new rendering capability.
- Add a new tab in book mode: "Linked Content" or "📎 Links."
- Move the "Add chapter" affordances out of the Pages overview.
- Filter the Pages overview to designed pages only.
- Migration: convert legacy
book.pagesentries withtype: 'reflow'intobook.links[]+book.threads[]entries with auto-generated designed pages (one per paginated page, each holding a single text-frame matching the master's frame geometry). - The pagination engine, PDF export, and Reader Mode continue to work
by reading from
threads[]instead ofpages[].type === 'reflow'.
What survives unchanged. PDF export pipeline, NIP-23 chapter event format, the Linked Designs resolver, the disk cache, the manifest publish flow (apart from the new collections).
What changes. book.pages becomes homogeneous. book.links and
book.threads collections appear in the manifest payload (schema
version bumps). The Pages overview drops its reflow-tile rendering
branch.
Phase J2 — First-class text-frame layer on designed pages
- Promote the existing master-only
text-framelayer to work on anybook-page:<id>target. - Stable
frameIdfield independent of layer id. - Sidebar tool: "+ Text frame" alongside the existing Pen / Shape / Text tools, only enabled in book-page edit mode.
- For now: a placed frame is unlinked (no thread).
- Render unlinked frames as a thin dashed outline + the placeholder text "Empty frame — link to a thread to fill."
Phase J3 — Threading UI
- Render in / out ports on every
text-framelayer. - Implement the load-cursor flow: click out port → cursor loads → click in port to link.
- Visual indicators: arrows on linked ports, red ⊞ on overset.
- Right-click menu: Break thread / Remove from thread / Show thread.
- Show-thread overlay: dim everything except the thread's frames.
Phase J4 — Thread-aware pagination
- Refactor the engine: walk
thread.frames[]instead of cloning the master N times. - The thread's
sourceLinkIdsupplies the markdown (resolved frombook.links[id]per its mode/pinnedEventId). - Frame-by-frame pagination — each frame gets one chunk of the layout based on its own width / height (frames in a thread can have different sizes; the engine recalculates line breaks per frame).
- Update the per-tile thumbnail painter and the PDF export to follow the same thread walk.
- Backwards compat: a master that has one text-frame is still useful as a template — autoflow uses it.
Phase J5 — Thread ↔ chapter assignment + Linked Content library
- Wire the Linked Content tab to the new data model.
- Per-link actions: edit / pin-live toggle / relink / embed / remove.
- Loaded-cursor source: "Place chapter into frame…" creates a new thread or joins an existing one.
Phase J6 — Polish
- Autoflow (Shift-click loaded cursor → generate pages from master).
- Overset warnings + a small "X frames overflow" badge on the Pages overview.
- "Show overflow text" inspector for an overset thread.
- Bulk relink / embed all / update-available chips.
- Cycle detection (frame A → B → A is rejected at link time).
What's deferred / out of scope
- Multi-column text frames. Single-column only in v1. Phase K.
- Wrap-around-image text wrapping. Phase K.
- First-line drop caps as a thread-level style. Phase K.
- Floating frames anchored to text positions. Phase L.
- Master frames (frames that exist on a master and replicate to every page using that master, each with its own thread membership) — needs a separate spec.
- Variable-data publishing (mail-merge from a Nostr list event) — interesting but separate arc.
Open questions
- Frame ownership inside masters. If a master defines a frame and a page using that master places a different frame at the same geometry, who wins? Proposal: master-frames are visible-only on the page (not editable); the page can ignore the master entirely by adding its own text-frame layer.
- Thread persistence on master swap. If a thread runs through 10 pages and the user changes one page's master, does the thread break? Proposal: no — threads track frames, not masters. Changing a master may move the frame visually but the thread holds.
- Encrypted chapter resolution latency. Decryption happens on resolve, which is async. The Linked Content tab should show a spinner state ("decrypting…") and gracefully degrade if the extension lacks NIP-44.
- Embed-all size cap. Embedding every linked event into the manifest can push it past relay size limits. Need a guardrail and a clear "manifest too large — keep some links?" prompt.
- Update notifications. Live links should pull periodically (or on demand via the existing ↻ Refresh button). Per-tab polling feels wrong; better to check on book open + an explicit refresh.
How this interacts with shipped features
- Linked Designs (
docs/LINKED_DESIGNS_FEATURE.md) — designed page layers that already use the same pin-vs-live pattern. The newlinks[]panel surfaces them alongside chapters; nothing in Linked Designs has to change. - Premium pages (Phase H1) — gating moves to the page event itself (already addressable), not the thread. A thread can span free + premium pages; the locked panes show the premium-unlock UI.
- Embed mode (
docs/EXTERNAL_EMBED_FEATURE.md) and Reader Mode (Phase G) — both consume the resolved physical-page sequence the manifest produces. They don't care whether content came from a thread or a reflow entry; pre-migration they walkpages[]reflow expansions, post-migration they walkthreads[]-driven layouts. The resolver handles the seam. - PDF export — same.
- Casewrap-book manifest schema — bumps. Old manifests parse
cleanly (reflow entries pass through the migrator); new ones
require a reader that understands
links[]andthreads[]. Forward compat: a v1 reader seeing a v2 manifest should warn ("This book uses a newer schema") rather than crash.
Acceptance criteria for Phase J1 (the first ship)
- A book opened with legacy reflow entries auto-migrates to the new shape without data loss; the rendered output (PDF, Reader Mode, thumbnails) is pixel-identical before and after migration.
- The Pages overview shows only designed pages; reflow entries no longer appear there.
- The Linked Content tab exists, lists every chapter the book references, supports the existing pin-vs-live toggle, lets the user open the chapter editor.
- The Cover view, Document settings, Masters modal, PDF export, Reader Mode, and the existing publish flows all still work unchanged.
- Existing tests still pass; one new test verifies the migration is round-trip-stable (save → reload → identical render).