docs: Phase J spec — Linked Content + threaded text frames

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]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-06-04 15:36:45 -04:00
1 parent 3f5540712b
commit 79c89101c4
2 files changed
+456

No files matched your search

+34
View File
@@ -63,6 +63,40 @@ Big arc. v1 supports a tight markdown subset for prose; explicitly
deferred to Phase I: tables, footnotes, nested lists, embedded
HTML, multi-column text frames, float-around-image.
### Phase J — Linked Content + threaded text frames
Spec at `docs/BOOK_LINKED_CONTENT_FEATURE.md`. Reframes the Book
Designer around two ideas:
- **Events are linked files.** Generalizes the pin-vs-live toggle
shipped for chapters to every external reference — masters,
designed pages, illustration sets, future asset kinds. A new
`book.links[]` collection is the project's "Links panel."
- **Threaded text frames** (InDesign-style). First-class
`text-frame` layers on designed pages, threaded port-to-port; a
thread declares its content source (a linked chapter event) and
the pagination engine pours text through the frame chain.
Phasing:
- **J1** Linked Content tab + Pages overview filters to designed
pages only; one-time migration of legacy `type: 'reflow'` entries
into `links[]` + `threads[]` + auto-generated designed pages.
- **J2** First-class `text-frame` layer on `book-page:<id>` targets
(today they're master-only).
- **J3** Threading UI — in / out ports, load-cursor, break / show
thread.
- **J4** Thread-aware pagination engine — walk `thread.frames[]`
instead of cloning a master N times.
- **J5** Thread ↔ chapter assignment in the Linked Content library
(relink, embed, show usages).
- **J6** Polish — autoflow on Shift-click, overset warnings, cycle
detection, bulk relink / embed-all.
The pagination engine, PDF export, Reader Mode, Linked Designs
resolver, and NIP-23 chapter events all survive — this is a UI +
data-model refactor, not a rebuild. Manifest schema version bumps.
## Design-tool follow-ups (Illustrator parity arc)
The pen / select / pathfinder bundle (`docs/PEN_TOOL_FEATURE.md`)
+422
View File
@@ -0,0 +1,422 @@
# 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
1. **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.
2. **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.
3. **Threaded text frames** (InDesign-style) replace today's
"one master-text-frame paginates into N copies." Designed pages
carry first-class `text-frame` layers 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.
4. 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:
1. For each `pages[]` entry where `type === 'reflow'`:
- Create a `links[]` entry of kind `'chapter'` (carrying the entry's
`ref` / `pinnedEventId` / `chapterTitle`).
- Auto-generate N designed pages (one per paginated physical page)
using the entry's `masterId`. Each new page gets one `text-frame`
layer matching the master's text-frame geometry.
- Create a `threads[]` entry that chains those N frames and points
at the new `links[]` entry.
2. For each remaining `pages[]` entry (`type === 'design'`), drop the
`type` discriminator — 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:
1. User clicks the out port of frame A → cursor changes to a "loaded
text cursor" carrying that link.
2. User clicks the in port of frame B → A's `nextFrame` becomes B; B
joins the same thread.
3. 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:
1. Resolve the source link's markdown (live event or pinned snapshot).
2. 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).
3. When text runs out, leave remaining frames empty.
4. 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 `ref` with 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 specific `eventId`, 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.pages` entries with `type: 'reflow'`
into `book.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 of `pages[].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-frame` layer to work on any
`book-page:<id>` target.
- Stable `frameId` field 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-frame` layer.
- 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 `sourceLinkId` supplies the markdown (resolved from
`book.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
1. **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.
2. **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.
3. **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.
4. **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.
5. **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
new `links[]` 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 walk
`pages[]` reflow expansions, post-migration they walk
`threads[]`-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[]` and `threads[]`.
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)
1. 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.
2. The Pages overview shows only designed pages; reflow entries no
longer appear there.
3. 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.
4. The Cover view, Document settings, Masters modal, PDF export,
Reader Mode, and the existing publish flows all still work
unchanged.
5. Existing tests still pass; one new test verifies the migration is
round-trip-stable (save → reload → identical render).