Adds a Publish button to the per-page / per-master editor bar that
mints a kind-30078 casewrap-page event for that single surface,
signs through window.nostr, publishes to user relays, and stores
the resulting naddr coord on the manifest entry / master.
- bookPagePayload(entry, role) builds the standalone payload —
layers filtered to just this entry, target prefix dropped so the
event renders cleanly through Linked Designs / embed routes.
- D-tag: `book-page:<entry-id>` or `book-master:<master-id>`, stable
across renames so re-publishes replace in place via NIP-33.
- publishBookSurface dispatches to page vs master publish based on
which mode is active.
- Bar relabels the button "Publish page" / "Re-publish page" /
"Publish master" / "Re-publish master" with the matching title
tooltip so authors see the d-tag they're about to overwrite.
- Status hint inline next to the button surfaces signing / publish
outcome.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
First chapter of Phase D — the chapter editor now has a Publish
button that mints a kind-30023 article from chapterTitle +
chapterMarkdown, signs through window.nostr, publishes to the user's
relays, and updates entry.textRef so subsequent reads come from
their own event.
- d-tag: reuses entry.textRef.dTag when the user is re-publishing
their own existing article (NIP-33 replaceability); otherwise mints
`chapter:<title-slug>` falling back to `chapter:<entry-id>`.
- Tags: d / title / published_at / t=book-chapter.
- Confirms before clobbering a foreign-author textRef with the
user's own publish.
- Own publishes default to live (textPinnedEventId = null) so future
edits to the same d-tag flow into the book automatically.
- Sourcebar status flips to "Live · d:…" with the user's own ref;
Refresh / Pin / Unlink continue to work against the new ref.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Adds a source bar to the chapter editor so authors can drop in a
naddr/nevent/note/hex id, pull the markdown via the shared relay
resolver, and choose whether the chapter follows future edits.
- Import button decodes the id with decodeNostrId, resolves the event
through _resolveEmbedEvent (same logic the embed route uses), copies
.content into chapterMarkdown, and records textRef.
- nevent imports pin to the resolved event id; naddr imports default
to live (follow d-tag).
- Pin/live toggle: pinning a live chapter resolves the current event
id and freezes it; flipping to live just clears textPinnedEventId.
- Refresh re-resolves the linked source — picks up new versions on
live links, just re-fetches the same id on pinned links.
- Unlink confirms then drops textRef / textPinnedEventId. The local
markdown stays put.
- Source-bar status chip: "Local only" / "Live · d:…" / "Pinned · …",
with short id + author preview. Disables the import input while
linked so the path is unambiguous.
- Picks up the article's NIP-23 title tag when the chapter is unnamed.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Each tile in the Pages overview now has a hover-revealed master picker
that overrides effectiveMasterIdForPage for that single entry.
- Empty selection ("Default (…)") falls through to spec.defaultMasterId.
- "No master" explicitly opts out via masterId === null.
- Otherwise persists the chosen master id on the manifest entry.
- Unified the reflow tile + pagination engine + physical-page enumerator
to call effectiveMasterIdForPage instead of the local
`p.masterId || defaultMasterId` short-circuit so the explicit-null
semantics work everywhere.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Surface book.spec.textStyles as an editable Typography section inside
the Document settings modal so authors can override the v1 markdown
stylesheet without dropping into JSON.
- Per-group schema lists the fields to surface (font/size/weight/color/
alignment/margins/etc). Adding a row in BOOK_TEXT_STYLE_FIELDS makes
the field render + persist automatically.
- renderBookTypographyEditor merges defaults under the live spec when
populating, so selects show the effective value rather than the empty
default — e.g. a default fontWeight of 700 doesn't render as 400 just
because the user hasn't customized it yet.
- Apply merges editor output over existing spec + defaults so partial
customizations don't wipe untouched fields.
- Reset button restores defaults in the editor without applying.
- After Apply, render() repaints reflow tiles and any open chapter
editor's readout refreshes — typography changes can move page breaks.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
When a master text layer renders on top of a specific page — in tile
thumbnails or the canvas underlay during book-page edit — `{page-number}`,
`{page-count}`, `{physical-page-count}`, `{book-title}`, `{book-author}`,
`{chapter-title}` and `{chapter-number}` resolve against that page's place
in the manifest.
- `enumerateBookPhysicalPages` walks the manifest into a flat physical-page
sequence, paginating reflow segments and threading chapter context.
- `bookTokenContext` returns the context for a given physical page within
a manifest entry (designed pages use indexInEntry=0).
- `substituteBookTokens` does HTML-safe string replacement; values are
escaped before insertion so a chapter title with `<` or `&` can't break
the layer markup.
- Tile thumbnails for both designed pages and reflow segments now run
master layers through `applyBookTokensToLayers` before composing.
- The canvas editor substitutes tokens on master text layers when they
appear as the page-edit underlay (and disables contenteditable so the
substituted text can't accidentally overwrite the master's source).
Master-edit mode leaves tokens literal.
- `bookPageCount` (spine-width input) now counts physical pages.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Reflow tiles in the Pages overview now show a real composited
preview: master layers underneath (text-frame outline removed —
the prose fills its place visually), then the first paginated
page of the chapter rendered as positioned text blocks on top.
- New `paintReflowPageOverlay(host, page, frame, trimW, trimH)`
helper: takes one paginated page + the master's text-frame
geometry and paints each block as an absolutely-positioned
div inside the thumb. Block styles (font, size, weight, leading,
alignment, colour, background) all come from the engine's
resolved style; coordinates convert to percent of the canvas
inch-space so the overlay tracks any thumb size.
- Tiles get a page-count suffix: "Chapter 1 · 14 pg".
- Master layers in reflow thumbs skip any text-frame layer (its
outline is meant for the editor, not the rendered output —
the prose IS the frame's content at render time).
Phase C.5.3 ships tile thumbnails; the chapter editor's right
pane stays as an HTML preview for now. A paginated-preview
toggle there lands in a later slice.
New pure function `paginateChapter(entry, book)`:
- Resolves the chapter's effective master + finds its sole
text-frame layer. No master / no frame → returns warnings,
empty pages, so the UI can hint at what's missing.
- Tokenises the markdown via `marked.lexer` (token shapes, not
HTML), then for each token builds a "block" carrying the
laid-out lines + the style metadata + the total height.
- Block kinds today: paragraph, h1-h6, blockquote, code (no
wrapping inside code blocks), list (flat "• item" / "1. item"
lines), hr. Inline runs (bold / italic / inline-code) collapse
to plain text for now — Phase I gives them per-run styles.
- Text wraps greedily into lines that fit the frame's available
width (frame.w minus 2 × frame padding minus left/right
margins from the block's style). Words too wide to fit are
emitted anyway and surfaced as a warning so the author can
spot them.
- Pages fill until adding the next block would exceed frame
height, then a new page starts. `keepWithNext` (true on h1-h6)
pushes a heading to the next page if its following block
wouldn't fit alongside it. Widow / orphan rules land in C.5.x.
- A block too tall to ever fit in the frame goes to `overflow`
with a matching warning rather than looping forever.
- Returns `{ pages, overflow, warnings, masterId, frame }`.
Pages are arrays of `{ blocks }`; each block carries
`{ kind, tokenType, lines, style, lineHeightIn, y, blockH }`
so the C.5.3 renderer can paint them straight onto a virtual
page.
The chapter editor toolbar now shows live word + page counts
("142 words · 4 pages") via the engine, plus a warning blurb +
hover-title with full details when something doesn't lay out
cleanly. Updates on every textarea keystroke (cheap path —
re-runs the engine for the active chapter only).
C.5.3 wires the engine to actually paint paginated pages: tile
thumbnails for reflow segments + an optional paginated preview
in the chapter editor's right pane.
Reflowable chapters take their first step. The Pages overview
now has a "+ Add chapter" button next to "+ Add page" / "+ Add
10 pages". Clicking it appends a `type: 'reflow'` entry to the
manifest, opens a fresh markdown editor pane, and seeds it with
"# Chapter N\\n\\nWrite your chapter here."
- Vendored `[email protected]` (~36 KB minified, MIT) as
src/vendor/marked.min.js. Loaded as a regular <script> next to
polygon-clipping and pdf-lib's future home.
- Reflow entries store `chapterMarkdown` locally; the
`textRef` / `textPinnedEventId` fields stay null until D1 maps
each chapter to a real NIP-23 article event. masterId defaults
to spec.defaultMasterId.
- New `#bookChapterEditor` two-pane view: textarea on the left
(autosaves to entry.chapterMarkdown via pushHistoryDebounced),
rendered HTML preview on the right via `marked.parse`. Mangling
+ header-id auto-generation off; <script> tags get
belt-and-braces stripped from the parse output.
- Body class `editing-book-chapter` flips the view: bookView and
the canvas chrome (sheet, palette, tools, layers panel) all
hide so the user sees only the editor. Shared
editing-book-page CSS already handled hiding canvas chrome via
the :not(.editing-book-page) selector, which I widened to also
:not(.editing-book-chapter) for #bookView.
- Toolbar: chapter title input (writes back to entry.chapterTitle
on input), "← Back to Pages" button (clears editingChapterId,
returns to the grid), and a pages-readout placeholder showing
"Pagination preview lands in Phase C.5.2".
- Clicking a reflow tile in the overview now opens the chapter
editor (was a no-op toast in B2). Esc returns to the overview
same as the page editor / master editor paths.
- syncBookChapterEditor() runs from render() so autosave restore
lands the user on the right pane without losing typed input
(textarea/title skip syncing when focused).
Phase C.5.2 wires the pagination engine and replaces the textarea
preview with a paginated stack of master-themed pages.
A new `text-frame` shape kind lets masters carry a reflow target —
the rectangle the Phase C.5 pagination engine will pour markdown
into.
- `defaultShapeData('text-frame')` returns the frame defaults:
padding 0.05 in, verticalAlign 'top', columns 1, styleOverrides
null. Matches the data-model spec in
docs/BOOK_DESIGNER_FEATURE.md.
- `buildShapeSvgInner` renders the kind as a dashed accent-blue
rectangle with a centered "Text frame" caption — non-rendering
in the final print output (transparent fill / dashed stroke);
the dashed outline is a positioning aid in the editor and gets
hidden by the engine once prose flows in.
- New "+ Text frame" button on the page-context bar, shown only
when editing a master (`editing-book-master` body class). Drops
a frame sized to the trim minus a 0.75 in margin so it reads as
a real "type area" out of the box; the user can resize / move
it like any other shape with the existing transform handles.
- `addBookTextFrameToActiveMaster()` snaps coordinates to the
canvas space (trim + bleed) so the frame lands inside the
printable area regardless of bleed.
Phase C4 ships the structural layer only — the frame doesn't yet
collect markdown. Phase C.5 vendors `marked`, adds the pagination
engine, and starts pouring chapter prose into these frames.
Pages inheriting a master now render its layers *under* their own,
in both the per-page editor and the Pages-overview tile thumbnails.
- New `effectiveMasterIdForPage(entryOrId)` resolves the page's
master: its own `masterId` override → book spec's
`defaultMasterId` → null. Setting `entry.masterId = null`
explicitly means "no master on this page" (overrides default).
- `isLayerForCurrentMode` in page-edit mode now also accepts the
page's effective-master target so those layers join the render.
- `renderLayers` uses an effective-z sort key that subtracts 1e6
from master-layer z values while in page-edit mode — guarantees
master layers DOM-precede page layers regardless of authored z,
so masters always sit beneath the page artwork.
- New body class `editing-book-master` (in addition to the
shared `editing-book-page`) lets CSS tell page-edit from
master-edit. CSS rule fades + click-throughs master layers only
in page-edit; master-edit mode keeps them fully interactive
(you're editing the master itself).
- Free-layer wrappers now carry `data-target` so the CSS selector
`[data-target^="book-master:"]` works without needing the
renderer to know about book mode.
- Pages-overview tile thumbnails composite the master layers
beneath the page's own (z forced to -1e6 + i for stable order)
so each tile shows the same composition the editor renders.
Phase C3 ships render-side composition; the per-page "Apply
master…" picker (entry.masterId override UI) is queued for the
next slice.
The Masters modal's "Edit" button (stubbed in C1) now opens the
chosen master on the existing per-page canvas. The shared
`editing-book-page` body class keeps the CSS that hides the
overview / shows the sheet + tool palette; the page-context bar
swaps copy and routing based on which surface is active.
- New `state.book.editingMasterId` field. Mutually exclusive with
`editingEntryId` — opening one clears the other.
- `openBookMaster(id)` enters master-edit mode and closes the
Masters modal so the canvas is unobstructed.
- `closeBookMaster()` clears the field and re-opens the Masters
modal — the user lands back where they came from.
- Canvas plumbing (currentDrawTarget, isLayerForCurrentMode,
templateLayerTargetsForMode, updateLayerTargetOptions) all
branch on editingMasterId vs editingEntryId so layers land on
the right surface and the layers sidebar shows the right
section header ("Master: NAME" vs "Page N").
- The Back-to-Pages bar relabels to "← Back to masters" + hides
prev/next chips when editing a master (no chapter walking on
masters). The same back button routes to the right close
function based on which mode is active.
- Esc and the same back button both work from either mode.
- updateBodyClasses() toggles `editing-book-page` on EITHER edit
field being set, so the CSS rules pick up master mode for free.
A new "Manage masters…" sidebar entry (was disabled in A2) opens
the Masters modal. The modal lists every entry in
state.book.masters and supports the usual CRUD:
- + New master: appends an entry with a generated id and the
label "Body pages". The first master created auto-promotes to
the default (state.book.spec.defaultMasterId).
- Rename: inline editing on the label input writes back through
pushHistoryDebounced so a long type doesn't spam the undo stack.
- Set default: explicit button on non-default rows; the active
default gets an accent-coloured border + "Default" badge.
- Delete: confirm + remove the entry + drop every layer under
`book-master:<id>` so the canvas doesn't carry orphans. Any
page that pointed at the deleted master falls back to the
default; if the deleted entry WAS the default, the next master
in the list inherits the role (or none, when the list empties).
- Edit (button stub, disabled): wired in C2 — it'll route to
master-edit mode where the canvas binds to `book-master:<id>`.
state.book.spec gains a new `defaultMasterId` field (a local id,
not a Nostr ref) so the spec block round-trips through autosave
without needing publish-time addresses. defaultMasterRef stays
null until D1 maps each local master to a real `casewrap-page`
event.
Esc closes the modal; click-outside-to-close too. Both match the
existing Document Settings modal lifecycle.
Each designed-page tile now renders a live preview of its current
layers, so the Pages overview reflects edits instead of showing
empty placeholders. Reflow tiles keep the striped placeholder
until C.5 wires the chapter renderer.
- New `.bookPageTileThumb` div fills the tile and hosts a
miniature canvas built via the existing
`renderCustomArtPreviewDOM` helper.
- Layers stored against `book-page:<entryId>` are cloned for the
preview with `target` swapped to `'canvas'` so the helper's
flat-canvas filter accepts them.
- Page background falls through: per-page `background` →
`spec.background` → `#ffffff`.
- Trim dimensions from `book.spec` drive the preview's canvas
aspect, so different book sizes get tiles with the right
proportions.
- Grid rebuild is skipped (grid emptied + early return) when the
overview isn't actually visible — book mode in page-edit mode
+ non-book modes both bail without paying the per-tile DOM
cost. The overview re-paints on `closeBookPage()` since render()
runs after.
position:fixed pinned the bar to the viewport center which drifted
over the sidebar header on wide layouts. Move the bar into a new
.canvasOverlayBars wrapper that shares grid-area: canvas with
#previewShell — the overlay is pointer-events:none with the bar
flipped back to pointer-events:auto. The bar is positioned absolute
within the overlay so it hovers over the canvas grid column without
being a child of the scrolling preview shell.
Switching the bars to position:fixed pinned them to the viewport
center — which on a typical wide layout drifted over the sidebar
header, looking sloppy.
New approach: move both bars into a new .canvasOverlayBars wrapper
that lives in the canvasArea grid as a sibling of #previewShell
with `grid-area: canvas`. Both grid items share the same cell and
stack; the overlay sits above with z-index 60. The bars inside it
are absolutely positioned to the overlay's top-center, so they:
- hover above the canvas-host (#previewShell)
- stay put when the canvas pans or zooms (the overlay isn't a
child of the scrolling shell)
- center on the canvas grid column, not the viewport — so the
sidebar / tool palette widths don't shift them
The overlay itself is pointer-events:none with children flipped
back to pointer-events:auto, so canvas clicks pass through except
where the actual bar pills sit.
#deckEditBar used position: absolute, which made it a child of the
scrolling #previewShell — panning or zooming the canvas dragged
the bar off-screen. Switching to position: fixed pins it to the
viewport so it stays put at top-center regardless of canvas pan or
zoom.
Both edit bars used position: absolute, which made them children
of the scrolling #previewShell — panning or zooming the canvas
dragged the bars off-screen. Switching to position: fixed pins
them to the viewport so they stay put at top-center regardless of
canvas pan / zoom. The horizontal sidebar offset at typical widths
keeps the pill visually centred over the canvas area.
The layers sidebar fell through to the case-cover sections
(Back / Spine / Front) when in book mode because
templateLayerTargetsForMode() had no book branch.
It now returns a single panel labeled "Page N" keyed to the
active entry's target (`book-page:<entryId>`), so the layers
listed and the Add-layer actions are scoped to the page the user
is editing. When no page is open (Pages overview is showing) the
helper returns an empty list and renderLayerList() naturally
renders nothing — consistent with the deck-sorter view that also
hides the layers fieldset until a slide is open.
The page-context bar landed in B2 but with plain inline styling, so
it sat as an invisible strip somewhere in the layout — Adam couldn't
find a way back to the Pages overview. Two fixes:
- Restyled #bookPageEditBar to mirror the deck-edit-bar floating
pill: absolutely positioned at the top-center of the canvas
area, accent-coloured "← Back to Pages" primary button, pill
prev/next chips with disabled states, page label in the middle.
Visible only when the editor is active (the existing
body.template-book.editing-book-page gate).
- Esc while editing a book page now also returns to the overview,
matching the slide editor's Esc-to-deck behaviour.
There's no explicit "Save" step — every edit autosaves through the
existing persistAutosave() path. Phase D1 will add an explicit
"Publish page as casewrap-page event" action; until then "Back to
Pages" is the way out, and the work is preserved on the manifest
entry's layers.
Adding a text layer on a book page kicked templateMode back to
'cover' and rendered the DVD case panels behind the book editor.
Three layer-creation helpers didn't know about book-* targets:
- applyTargetMode(target): the trailing else set templateMode =
'cover' for anything it didn't recognise. Book targets
(book-page:<id>, book-master:<id>, book-cover-*, book-spine)
now keep templateMode = 'book'.
- targetBounds(target): the switch fell through to sheetW/sheetH
for book targets, returning the wrong rectangle. Added a
book-* branch that returns the page's trim rect (inset by
bleed) for pages + masters, and the per-panel slice of the
spread for cover panels.
- updateLayerTargetOptions(): the #layerTarget <select> had no
option for the active book page. Book mode now renders a single
"Current book page" option keyed to the entry-qualified target;
if no page is open, a "No page open" stub keeps the select from
appearing broken.
Net effect: hit T, click on the page, type text — the layer lands
on the right page, the canvas stays on the book page, and the
sidebar target dropdown shows the right surface.
Clicking a designed-page tile in the Pages overview now opens that
page in the existing Custom Art canvas, bound to the entry-qualified
`book-page:<entryId>` target so layers stay scoped to one page
across the manifest.
Plumbing:
- `state.book.editingEntryId` carries the active page id. Mirrored
to the `editing-book-page` body class via updateBodyClasses().
- `currentDrawTarget()` returns `book-page:<editingEntryId>` when
the book editor is active, so new layers drop on the right page.
- `isLayerForCurrentMode()` only accepts targets prefixed with
`book-page:<editingEntryId>` (or matching master target) — pages
don't leak each other's layers, and the Pages overview shows no
canvas layers (it doesn't render the sheet anyway).
- New CSS: `body.template-book:not(.editing-book-page)` keeps the
Pages / Cover view visible and hides the sheet + tool palette.
Flipping to `.editing-book-page` restores them, mirroring the
deck-sorter's pattern for slide editing.
UI:
- `openBookPage(entryId)` enters the editor; default tool resets to
Select so users don't land mid-pen-draw.
- `closeBookPage()` (also `← Back to Pages` button) returns to the
overview.
- `gotoBookPageRelative(±1)` walks only designed-page entries —
reflow segments aren't editable on the per-page canvas, they open
in the markdown editor that ships in C.5.
- Page-context bar above the canvas shows `Page N / M [· label]`,
prev / next arrows (auto-disable at the ends), and the back button.
- Bar visibility is gated by both `template-book` and
`editing-book-page` so it doesn't leak into other modes.
Reflow tiles still render in the overview (struck through the spec
in A4) but clicking them surfaces "Reflow chapter editor lands in
Phase C.5." instead of opening the canvas. That keeps the routing
honest until C.5 lands.
- Tiles are now draggable; grabbing one and dropping on another
swaps it into that position. Visual feedback: source tile fades
to 50% opacity while dragging; the hovered drop target shows an
accent-coloured outline.
- New `moveBookPage(from, to)` splices the manifest array and
pushes a single history entry; nothing else changes (anchors
reference entry ids, so reordering doesn't break refs).
- Per-tile hover overlay now has two actions instead of one:
- `⎘` Duplicate — clones the entry with a fresh id, drops any
anchor (the duplicate sits inserted-position by default), and
appends "copy" to the label.
- `×` Delete — unchanged, plus a soft danger hover state.
- `attachBookPagesDnd()` is idempotent (guards via
`data-dnd-attached`) so re-rendering the grid doesn't pile up
listeners.
Cover view shows a live, proportional preview of the front / spine
/ back spread alongside a readout of every spec value that produced
it. Panel widths track the book.spec values exactly:
- Spine width derives via the existing bookSpineWidthInches()
helper — page count × paper thickness when spineFromPages is on,
otherwise the manual override. The summary readout marks the
derived value in accent color with a "derived: N pages × T in"
caption.
- The spread reflows automatically: change paper thickness in
Document settings or add / remove pages, the spine column
resizes, the readout updates. Same when toggling coverHasSpine
or coverHasBack — the spread becomes back+spine+front /
spine+front / back+front / front only.
- Bleed adds to each outer edge of the back and front panels; the
inner edges between panels share so no double-bleed.
- A scaled px-per-inch factor fits the spread into ~720 px wide;
scaling caps at 60 px/in so a tiny chapbook isn't huge.
- Cover view's renderBookCoverPreview() is guarded inside render()
— it only repaints when the Cover sub-view is visible, so flipping
between Pages and Cover stays cheap.
Phase A5 ships preview-only. Phase B mounts the existing cover
designer host inside #bookCoverView so users can actually paint the
panels; Phase D1 publishes the cover as its own casewrap-page event
with role:'cover-spread'. A "Replace cover from Nostr…" button is
stubbed disabled to anchor the future picker in the UI.
The book mode now shows a dedicated Pages overview replacing the
empty cover canvas. Layout mirrors the deck-sorter pattern:
- New #bookView container in the canvas area, hidden by default and
shown via `body.template-book`. Replaces #sheetWrap in book mode
(same trick the deck sorter uses).
- View-toggle bar at the top: **Pages** / **Cover** with an active
pill. Cover view is a placeholder pointing at Phase A5.
- Pages view has a header buttonRow with `+ Add page` and `+ Add 10
pages`, plus a live page-and-reflow-segment counter readout.
- Tiles render as 2:3-aspect placeholders with the page number
(driven by `book.spec.pageNumbering` — `enabled`, `startAt`,
`firstNumberedPage`, `format` all honoured) and the entry's
label. Reflow-segment tiles get a diagonal-stripe background so
they're visually distinct from designed-page tiles (already
positioned for Phase C.5 even though no reflow entries can be
created yet).
- Per-tile delete button (visible on hover) removes the entry from
the manifest; any other entry that anchored to the deleted one
(`anchor.entryId` / `anchor.after`) gets its anchor reset to
null so dangling refs don't break later phases.
- Empty state when `book.pages` is empty.
- `makeBookEntryId()` generates stable opaque ids
(`entry:<random>`) so AnchorBlocks survive reorders, matching the
data-model contract in docs/BOOK_DESIGNER_FEATURE.md.
- Hides the tool palette, canvas toolbar, and tool options panel
in book mode (nothing to draw on yet — Phase B's per-page editor
will surface them again when a page is open).
- `renderBookPagesGrid()` hooks into the main `render()` flow so
the grid stays in sync with state across history undo / autosave
restore / document-settings changes.
Adam hit a hard crash every time he clicked a layout tab on the
deployed site:
Uncaught TypeError: Cannot set properties of null (setting
'innerHTML') at rebuildClipDefs (16403) → render (11689) →
applyLayout (28513)
rebuildClipDefs() does defs.innerHTML = '' on an element found
via defsRoot.querySelector('defs'). If the SVG #globalClipDefs
container exists but its inner <defs> child has been removed (or
an early DOM-hook / browser extension injected the SVG without a
<defs> child), the query returns null and the assignment throws.
Because rebuildClipDefs is the first thing render() calls, the
crash aborts the entire render — body classes don't swap, the
canvas doesn't redraw, the layout panel doesn't update. The
top-level tab visually toggles but everything downstream stays
frozen. Exactly the "canvas stuck" report.
Guard the lookup: if no <defs> child exists, re-create one and
append it to the container, then proceed with the rebuild. The
ensureGlobalClipDefs() helper already creates a <defs> on first
call; this fix handles the rarer case where the container exists
in a malformed state.
The cover designer's BACK / SPINE / FRONT slot placeholders +
bleed / trim / fold-line / crop-mark guides were still rendering
on the canvas-host when book mode was active, because the existing
mode-switch only hides them for customart / slide / pixelart /
deck. Add a matching `body.template-book` block that hides every
`.coverOnly` element plus the disc / jewel artifacts and the
spine-text label.
Also hides the sidebar #exportFieldset / #layersFieldset /
#printExportRow until book pages exist (matches the deck-mode and
pixelart-mode treatment — they're not in the book WORKFLOW_ORDER
anyway, but the CSS gate keeps them from sneaking in).
The canvas-host now renders empty in book mode — the Pages
overview view (A4) and Cover view (A5) fill it in subsequent
commits.
Wires the full Document settings modal for book mode. Every field
from `book.spec` is editable, applies live on Apply, and persists
via the existing autosave.
- New `#bookDocumentSettingsModal` markup:
- Book size dropdown with five named presets (US Trade, US
Letter, A5, A4, Square 8×8) plus Custom; selecting a preset
locks the trim inputs to the right dimensions, Custom unlocks
them.
- Trim width / height (inches).
- Bleed (default 0.125).
- Paper thickness (in / page).
- "Derive spine width from page count × paper thickness"
checkbox; manual spine input shown only when unchecked. A
live note below shows the computed spine ("Spine = N pages ×
T in = D in").
- Cover layout toggles: include spine, include back panel.
- Page numbering: enabled toggle reveals start-at, first-
numbered-page (manifest index), and a format string field
with a `{n}` placeholder hint.
- New JS helpers:
- `BOOK_SIZE_PRESETS` table (one source of truth for the five
preset sizes).
- `openBookDocumentSettings()` / `closeBookDocumentSettings()`
matching the existing publish-confirm modal lifecycle.
- `_syncBookSettingsSpineNote()` updates the derived-spine
note on paper-thickness / from-pages toggle changes.
- `_syncBookSettingsPresetLock()` locks / unlocks trim inputs
when the preset dropdown changes.
- `applyBookDocumentSettings()` writes every field back to
`state.book.spec`, pushes a single history entry, then runs
`syncInputs()` + `render()` so the sidebar summary refreshes.
- Sidebar "Document settings…" button is now enabled (was
disabled placeholder in A2) and wired to the new modal.
- Esc-to-close hooked into the existing global Escape handler
next to publishConfirm. Click-outside-to-close handled by the
same delegation pattern other modals use.
Adds the Book button to the Template tab's layout picker
(alongside Case cover / Disc labels / Slide deck) and wires the
applyLayout('book') path so the user can switch into book mode.
- `mode-book` button + click binding via the existing applyLayout
loop (one-line list addition keeps every layout tab routed
through the same handler).
- `applyLayout('book')` calls `ensureBookState()` to lazy-init the
manifest the first time a user enters the mode.
- New `template-book` body class + CSS gating: the new
`#bookFieldset` sidebar block shows only in book mode, and the
cover / disc / deck / custom-art fieldsets hide in book mode so
the sidebar stays focused on the spec + masters affordances.
- `WORKFLOW_ORDER` learns the book column.
- `sheetW` / `sheetH` fall back to trim + bleed when
`templateMode === 'book'` so the canvas-host placeholder is
sized correctly (it stays blank in this phase; the actual Pages
overview + Cover view come in A4/A5).
- Sidebar fieldset shows a derived spec summary (trim, bleed,
paper thickness, derived spine width, page + master count)
rendered by a new `syncBookSidebarSummary()` hook into
`syncInputs()`. Book-state helpers (`bookPageCount`,
`bookSpineWidthInches`) are pure functions so future phases can
reuse them.
- Two placeholder buttons (Document settings… / Manage masters…)
are stubbed disabled with a hint pointing at the upcoming
phases, so users can see where the controls will land.
First commit of the Book Designer feature. Pure plumbing — no UI
yet. Establishes the foundation for Phase A:
- New `templateMode: 'book'` recognised by `normalizeTemplateMode`
(matches either `payload.templateMode === 'book'` or the presence
of a `payload.book` block).
- `templateTypeForMode` returns `'book'` for the new mode.
- `primaryTargetForMode` returns `'book-page'`.
- `isLayerForCurrentMode` accepts every `book-*` target
(`book-page`, `book-master`, `book-cover-front/back`, `book-spine`)
in book mode.
- `state.book` initialised to `null`; the new `ensureBookState()`
helper lazily fills it with `defaultBookState()` (empty spec,
null cover, empty masters + pages arrays).
- `defaultBookSpec()` returns the v1 defaults documented in
docs/BOOK_DESIGNER_FEATURE.md (US Trade 6×9, 0.125 bleed,
paperThickness 0.0042, spineFromPages true, page numbering on,
default master null).
- `defaultBookTextStyles()` returns the v1 markdown stylesheet
(Georgia body, Helvetica headings, monospace code, accent-blue
links, blockquote indented italic, etc.) — used by Phase C.5's
pagination engine.
- SCHEMA_VERSION bumped to 6 with a comment explaining the
additive `book` block. Existing autosave path serialises +
restores `state.book` for free via Object.assign.
No behaviour change to existing modes — Book mode is unreachable
through the UI in this commit. The next commit (A2) adds the
Template-tab entry that switches into book mode.
Final spec polish after the markdown / NIP-23 revision.
Schema consistency:
- Manifest entries now carry a stable opaque `id` (generated at
insert time). Used for anchoring designed pages around reflow
segments — anchors reference `entryId` instead of fragile array
positions or human-readable strings.
- Unified `masterRef` across designed and reflow entries (was
`inheritsMasterRef` on designed, `masterRef` on reflow). Both
default to `spec.defaultMasterRef` when null.
- Promoted `AnchorBlock`, `GatingBlock`, `TextStylesBlock`,
`TypographyObject` to named definitions in the data-model block
so the schema is documented once and referenced from prose.
- Page event `d`-tag pinned to a stable opaque id, not derived from
the page title (titles change; d-tags shouldn't).
- Added uniqueness rules: a chapter or page event can appear in at
most one manifest entry per book; every entry has a unique `id`.
New sections:
- **Chapter event example** (kind-30023 NIP-23 article) so the
full event shape is documented alongside page / master / cover.
- **Schema + compatibility** — bump to SCHEMA_VERSION 6,
additive on the project payload, with the documented fallback
for older clients.
- **Privacy** — confirms PRIVATE_PUBLISHING works across all
constituent events of a book.
- **Compatibility notes** — Linked Designs inside pages works
via existing async resolver (worth a Phase B smoke test); all
existing layer types render on book targets.
- **MVP launch path** — two reasonable launch targets:
MVP-A (designed-only books, Phases A+B+D1+D2) and MVP-B
(books with prose, MVP-A + C+C.5+E). Phases F/G/H/I are strict
enhancements that don't block either MVP.
Tightened semantics:
- **Token substitution scope** explicitly limited to (a) text
layers and (b) reflow-entry display fields. Markdown bodies of
chapter events stay portable to other NIP-23 clients — no
book-specific tokens leak in.
- PDF section: removed outdated "may flatten text to vector
paths" notes — the font allowlist + per-export warning is now
the committed approach. Reflowed prose and designed-page text
emit as real PDF text uniformly.
- PDF outline / bookmarks moved out of "limitations" — they ship
in Phase I built from `chapterTitle` and `{toc}` targets.
Cleaned Open Questions: removed answered items (TOC token is now
Phase I, Linked Designs interaction moved to Compatibility notes).
Added missing acceptance criteria for Phase F (templates +
deck-to-book import) and Phase I (CodeMirror + extended subset +
auto-TOC + cross-references).
Third revision pass adds reflowable prose as a first-class part of
the Book Designer, without giving up the fixed-layout design pages.
Architecture:
- Prose chapters live as NIP-23 long-form articles (kind-30023,
markdown body). One source, two presentations: same article event
is readable standalone on Habla.news AND laid out as designed
pages inside a book on Artstr Studio.
- Book manifest's `pages` becomes a discriminated union:
`{ type: 'design', ref }` for fixed-layout pages, and
`{ type: 'reflow', textRef, masterRef, … }` for chapter segments
that paginate dynamically.
- New `text-frame` layer kind, lives on masters. The pagination
engine pours markdown tokens into copies of the master until the
chapter is exhausted.
- New `spec.textStyles` block — a book-level stylesheet mapping
each markdown element (h1-h6 / body / blockquote / code / list /
link / hr) to typography (font, size, weight, leading, alignment,
margins, plus keepWithNext / keepTogether flow controls).
- Anchored designed pages: designed entries on the manifest use
`anchor.mode: 'before-chapter' | 'after-chapter'` so their
semantic position survives reflow-driven page-count changes.
Tradeoff mitigations baked in:
- Pagination is fiddly → tight v1 markdown subset documented;
pure-function engine for testability; "Preview pagination" before
publish; overflow toasts for too-long words / wide code blocks.
- Text drift breaks pagination → chapters pin to a specific event
id by default (safe for print); opt-in "Live" mode for
always-latest with re-paginate notices showing page-count delta;
anchor-based positioning for designed pages.
- PDF text fidelity → reflowed prose exports as real PDF text via
pdf-lib font subsetting; font allowlist with vector-path
fallback + per-export warning.
- Editor scope → plain textarea + live preview for v1; CodeMirror
upgrade deferred to Phase I; WYSIWYG never in scope.
New tokens: `{chapter-title}`, `{chapter-number}` resolve per
chapter context (useful for running headers on masters).
Phases updated:
- Phase C grows `text-frame` layer kind (structural).
- New Phase C.5 — vendor `marked`, implement pagination engine,
markdown editor pane, anchoring, pin/live tracking. Slots in
before PDF export because PDF needs it.
- Phase D1 expanded to include chapter publish as kind-30023.
- Phase E updated for real PDF text on reflowed prose.
- New Phase I — extended markdown subset (tables, footnotes,
auto-TOC via `{toc}`, cross-references) + CodeMirror editor.
Pages overview tile UX expanded: designed-page tiles show a
rendered thumbnail; reflow tiles show a stack-of-pages icon with
chapter title, master name, paginated page count, and a tiny
first-page preview.
Acceptance criteria for Phases C, C.5, D1, E updated to match.
TODO.md summary rewritten to reflect the new shape.
Apply twelve Nostr-driven revisions to the Book Designer spec:
1. **Sidecar architecture.** Pages, masters, and the cover spread
are now separate addressable kind-30078 events
(`casewrap-page`, role-discriminated). The book is a thin
`casewrap-book` manifest that references them by
`{ kind, pubkey, dTag }` — the same shape Linked Designs
already speaks. Edit one page → that page event replaces; the
manifest only changes when order / list / spec moves.
2. **Masters as Nostr objects.** Each master is its own event; one
"Body pages" master can be referenced by many books and forked
by other authors. Default master lives on the manifest spec.
3. **Cover as a sidecar event** with `role: 'cover-spread'` so it
can iterate independently and be republished as standalone
marketing art.
4. **Linked Designs is the precedent.** The resolver IIFE,
progressive fetch, localStorage disk cache, batched relay
queries, upstream-update notifications, and attribution chain
all transfer verbatim. Call this out explicitly so we don't
reinvent the wheel.
5. **Addressable refs everywhere.** Manifest entries use the
existing `{ kind, pubkey, dTag }` form (convertible to naddr at
the UI layer), not local UUIDs.
6. **Premium gating + tips.** New "Premium, tipping, gating"
section covers whole-book paywall (gate the manifest),
per-chapter paywall (gate individual page events via
PREMIUM_DESIGNS), and NIP-57 zaps at any granularity.
7. **Reader Mode** added as Phase G — the Slide Decks' Presenter
Mode counterpart, with progressive page fetch, TOC sidebar,
page-flip nav, and inline zap-to-unlock.
8. **Embed integration.** EXTERNAL_EMBED learns `?book=<naddr>`
for a flip-book iframe player; same transport + CSP rules.
9. **Per-page reactions / comments** work for free because each
page is its own addressable event (NIP-25 + kind-1). One-liner
in product goals + Reader Mode acceptance.
10. **`{page-count}` semantics pinned.** Defaults to *numbered*
pages; new `{physical-page-count}` exposes the raw total.
Plus `{book-author}` for the manifest pubkey.
11. **NIP-23 distinction.** Explicit non-goal — Book mode is
fixed-layout design, not reflowable prose; for prose use
NIP-23 long-form articles directly.
12. **Manifest schema.** Pages now carry
`{ ref, label?, inheritsMasterRef, skipNumber, gating? }`;
page-side fields that vary per-book (master, skipNumber,
label) moved off the page event onto the manifest entry.
Phase D split into D1 (publish individual page / master / cover
events) and D2 (publish the manifest); new Phase G (Reader Mode +
embed); new Phase H (premium gating). Acceptance summary,
cross-surface reactivity, and risks updated to match.
TODO.md's Book Designer summary rewritten to reflect the
sidecar plan.
New BOOK_DESIGNER_FEATURE.md drafts a top-level templateMode: 'book'
that orchestrates the existing cover designer, deck overview, and
custom-art canvas around a shared document spec (trim size, page
count, paper thickness, bleed). Spine width derives from page count
× paper thickness; the cover view goes read-only on the spine
column when in derived mode. Pages reuse the deck-sorter UI for
reordering; the per-page editor is the custom-art canvas bound to
a book-page:<id> target with prev / next page nav. Masters render
underneath each inheriting page and support {page-number} /
{page-count} / {book-title} token substitution.
PDF export ships in its own phase (E), backed by a vendored
pdf-lib (~80 KB). Single-file (cover + interior) and split (cover
PDF + interior PDF) output modes; bleed + crop-mark options;
v1 flattens text to vector for font-fidelity, with vector PDF
text as a follow-up.
Phased: A (spec + skeleton) → B (pages + editor) → C (masters) →
D (publish) → E (PDF export) → F (templates + deck-to-book
import). Non-goals: reflowable text, footnotes, automatic TOC,
CMYK / ICC, imposition, mixed page sizes in one book.
Linked from README's "Drafted-but-unbuilt" list and TODO.md.
startLayerRotate and startLayerSelectionRotate called render() on
every pointermove, which tore down the .freeLayer wrapper that owns
the rotation handle. That dropped pointerCapture mid-drag, so after
the first move event the gesture "let go" — the handle stopped
following the cursor and only a few degrees of rotation registered
before the user had to grab again.
Fixed both paths the same way the resize handler does: mutate the
layer's rotate (and centre, for multi-layer) and apply the change
directly to the affected .freeLayer's transform / position styles
inside move; only call render() on pointerup so the rest of the UI
catches up. The sidebar Rotation field updates live via
syncLayerInputs() as before.
The Shape tool's fill / stroke colour pickers and stroke-width input
shared DOM ids (shapeFillColor, shapeStrokeColor, shapeStrokeWidth)
with the selected-shape-layer edit panel's equivalents.
document.getElementById returns only the first match, so the input
listeners that drive _shapeToolOpts attached to the layer-panel
inputs (which aren't visible while the Shape tool is active), not
the tool-panel inputs. Result: changing fill / stroke colour or
stroke width on the tool panel had no effect on the next shape drawn.
Renamed the three tool-panel inputs to shapeToolFillColor /
shapeToolStrokeColor / shapeToolStrokeWidth (and their <label for>
references and the syncToolOptionInputs + input-event listeners).
A full repo scan confirms no other duplicate DOM ids remain.
PEN_TOOL_FEATURE.md gets a 2026-06-02 status block documenting
everything that landed on top of Phase D — Direct Selection split,
auto-add/auto-delete, click-vs-drag convert, Alt-click curved ↔
pointed, multi-anchor select / marquee / arrow nudge, drag-segment
bow, continue-endpoint, spacebar reposition, Alt mid-draw, handle
break, Cmd+J / Cmd+Alt+J, Path Actions side panel — plus the
adjacent design-tool features that shipped in the same arc
(Eyedropper, Align & distribute, Free Transform rotation,
Pathfinder, marquee box-select, dive-into-edit on dbl-click).
TODO.md gets a "Design-tool follow-ups" section listing the gaps:
Smart Guides, Gradients + shadows, Scissors / Outline text /
Command palette, Flip H / V (needs renderer scaleX/scaleY),
Pathfinder rotation support, pixel-art frame timeline.
- Double-click a vector shape with the Select tool active and we
flip to Direct Selection on that shape, auto-converting non-pen
primitives (rect / circle / polygon / etc.) to editable pen paths
first so anchors appear immediately — Illustrator's dive-into-
isolation behavior. Double-click is ignored if any other tool is
active or the click landed on a transform handle.
- Direct Selection on the canvas: a plain click in empty space
(anywhere the overlay covers, including beyond the layer's bbox)
now clears the layer selection entirely, not just the anchor
sub-selection. The anchor-marquee path still kicks in for real
drags. Off-canvas clicks already deselected via the previewShell
handler, so this closes the last in-canvas gap.
The 0.0075 dot / 0.0018 line ratios still rendered as ~14px dots
with thick stems on a 1920px-wide preview, which dominated short
paths. Dropped the dot radius to 0.0028 and the line width to ~0.001
so the editing UI reads as a tiny on-canvas widget (~7px) rather
than a UI element fighting the artwork. Both renderPenEditHandles
and renderPenDraft use the new sizes so live drawing and post-commit
editing stay consistent.
The hollow white arrow distinction was too subtle when both V and A
tools are side-by-side. Switched to a solid light blue (#60a5fa)
arrow so Direct Selection reads as visually distinct at a glance;
the regular Select arrow stays the default black.
The pen draft path was using a canvas-relative thickness (lineW * 2,
derived from the canvas dimensions) for its preview stroke, so the
ribbon you saw while drawing looked different from the path you got
after committing. The committed path stores its stroke width in
pixel-space (1 unit = 1 px @ 96 dpi); the overlay renders in inch
space, so the live preview now divides _penToolOpts.penStrokeWidth
by 96 — same trick the pencil draft already uses — and adds round
line-cap / line-join so curves look consistent with the committed
path.
Click-and-drag on an empty area of the canvas (with the V tool
active) now draws a marching-ants rectangle. On release, every
layer whose bounding box intersects the rectangle is selected;
Shift / Ctrl / Cmd held during the drag adds to the existing
selection instead of replacing it.
Plain clicks (no drag past the 4 px deadzone) still clear the
selection, preserving the previous "click empty area to deselect"
behaviour. Other tools own their own canvas drags (Pen creates
anchors, Hand pans, Shape drags out a primitive, Direct Select
marquees over anchors), so this only kicks in for V.
Shift+click on a layer to add or remove it from the current
selection was already wired — confirmed working through the
existing additive path in startLayerDrag.
Boolean ops on 2+ selected shape layers, backed by the vendored
polygon-clipping library (29 KB minified, MIT) loaded from
src/vendor/. Adds a new "Pathfinder" details block in the sidebar
under Align & distribute.
- Unite combines all selected shapes into one outline.
- Subtract removes upper shapes from the bottom-most shape.
- Intersect keeps only the overlapping region.
- Exclude keeps only the non-overlapping regions (XOR).
Pipeline: each selected layer's pen path is sampled to a polygon
(16 samples per Bézier segment, in world inches), the chosen op
runs, and the result is rebuilt as a new pen layer that inherits
the bottom-most input layer's z-order, target, fill and stroke —
matching Illustrator's Pathfinder convention. Non-pen primitives
(rect, circle, etc.) are auto-converted to pen paths first.
Limitations baked in:
- Rotated layers aren't supported yet (we'd need to rotate the
sampled polygon before clipping). The op refuses with a toast.
- Curves are flattened — the result is a polygon approximation
with `fill-rule: evenodd` so multi-ring outputs (donut from a
subtraction) render correctly. The renderer learned to honor
shape.fillRule for this commit.
- Multi-polygon results stay rendered correctly but only the
first polygon's outer ring is preserved in pen.nodes, so
Direct Select editing post-op is partial.
A 9th handle (round, accent-colored, sitting above the bounding box
on a thin connector line) joins the existing 8 scale handles on both
the single-layer overlay and the multi-layer selection box.
- Single layer: drag rotates the layer about its own centre, with
Shift snapping to 15° increments. The existing resize handles
already account for layer.rotate in their local-frame math, so
resizing keeps working at any rotation.
- Multi-layer selection: drag rotates each layer's centre around the
selection bbox's centre AND advances each layer's own rotation by
the same delta — the group spins as a rigid body. Pre-snapshotting
the centres + rotations keeps the math stable across long drags.
- Locked / generated-disc layers are filtered out as everywhere else.
A new "Align & distribute" details block in the layer-controls sidebar
surfaces eight ops: left / horizontal-centre / right and top / middle
/ bottom alignment, plus horizontal and vertical distribution.
- Multi-selection (2+ layers) aligns within the selection's own
bounding box, so the outer layers stay put and the rest snap into
line with them.
- Single-layer selection aligns to the layer's target bounds (page
panel / disc face / custom canvas), making it easy to centre or
pin a single asset without setting up a dummy second layer.
- Distribute requires three or more layers — the outermost two stay
pinned and the inner layers slide so consecutive centres are
evenly spaced along the chosen axis. Helpers toast a hint when the
selection is too small.
- Locked / generated-disc layers are skipped automatically.
Flip H / V were prototyped here but pulled out — they need a renderer
extension to honour per-layer scaleX/scaleY, which is a separate change.
A new ⊙ button in the tool palette and the I keyboard shortcut open
the browser's window.EyeDropper picker so the user can sample any
pixel anywhere on screen (not just our canvas) and apply the
sampled hex to a smart target:
- Selected shape layer → fill color (Shift = stroke color).
- Selected text layer → text color.
- No layer selected → pen / shape tool's default fill (or
stroke), so the next thing drawn picks up the sampled hue.
The eyedropper is a transient action rather than a persistent tool —
invoking it doesn't change the active tool, so users can sample
mid-edit without losing their cursor. Falls back to a hint toast in
browsers that don't yet support window.EyeDropper (Safari).