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).
A new "Path actions" block appears in the right-side tool options panel
whenever Pen (P) or Direct Selection (A) is active and an editable pen
layer is selected. The block surfaces the most useful anchor- / path-
level operations as buttons so they're discoverable without keyboard
shortcuts:
- Pointed corner / Curved smooth — flip the selected anchors (or all,
if none are selected) between handle-less corner and symmetric-handle
smooth, matching the Alt-click convert behavior.
- Delete anchor — bulk-delete the current anchor selection (Backspace
equivalent).
- Average — move selected anchors to their centroid (Cmd+Alt+J).
- Join endpoints — close an open path when both its endpoints are
selected (Cmd+J); button auto-disables otherwise.
- Close / Open path — toggle the path's closed flag.
- Reverse direction — flip the order anchors are drawn in.
A small hint at the top of the block reflects the current selection
size, so the user knows what an action will affect. The "Join" button
auto-disables when join would be a no-op; "Average" requires at least
two anchors. Direct Selection also gets a short panel reminding the
user how anchor editing works (multi-select via Shift / marquee, arrow
keys nudge selected anchors).
The presence of handles is now the source of truth for whether a
node is pointed or curved, so Alt-click does a full flip in either
direction:
- Anchor with any handle → drops both handles and demotes to a real
corner. The path renders straight segments through it.
- Anchor with no handles → grows symmetric Bézier handles aimed along
the chord through this anchor (using neighbouring anchors when they
exist, falling back to the single available neighbour for endpoints
of an open path). Handle length is 1/3 of the chord, so the curve
bends visibly without ballooning out.
Previously the toggle only flipped the kind flag without touching
handles, so a smooth-to-corner click left the curve looking unchanged
and a corner-with-no-handles click did nothing visible at all.
- Cmd+J closes an open path when both its endpoints are part of the
current anchor selection. No-op if the path is already closed or
the selection doesn't cover both endpoints; the user gets a hint
toast in that case.
- Cmd+Alt+J averages every selected anchor to the centroid of the
selection. Handles slide with their anchors so local curvature is
preserved. Requires at least two anchors selected.
While the pen tool's click-drag is still down (the gesture that pulls
Bézier handles out of a freshly-placed anchor), two modifiers now
match Illustrator's pen behavior:
- Hold Alt during the drag and only the outgoing handle is pulled.
The incoming side stays at the anchor, giving an asymmetric corner
that's perfect for tangent-then-corner transitions without lifting
the pen.
- Hold Space and the drag stops growing handles and starts sliding
the anchor itself, carrying any handles along. Release Space to
resume handle-pulling from the anchor's new position. Mouse delta
is tracked from the moment each mode is entered, so toggling Space
on/off mid-drag doesn't introduce drift.
- Pen tool over an existing segment now auto-inserts an anchor at the
click with no modifier (was Alt-only). Pen over an anchor deletes it
on click — Illustrator's default "Auto Add/Delete Anchor" behavior.
- Drag (past a small threshold) on an existing anchor with the Pen tool
converts the anchor on the fly: pulls symmetric handles from the
drag, promoting corner → smooth. Alt-drag pulls only the outgoing
handle for an asymmetric corner.
- Alt-click an anchor in Pen mode still toggles corner ↔ smooth (drops
or pairs handles without a drag), preserving the existing modifier.
- Alt-drag a handle in Direct Selection breaks the smooth-anchor's
handle symmetry: the node becomes a corner so the opposite handle
stays put while the dragged one moves independently.
- Endpoint-of-open-path still wins over auto-delete: hovering the start
or last anchor of an open path shows the "continue" pointer cue, and
clicking resumes drawing rather than deleting.
- Hover cursors updated: − (delete) over a deletable anchor, + (add)
over a segment in Pen mode, grab over a segment in Direct Select.
With the Pen tool, clicking an endpoint of an open path resumes
drawing from that anchor. The source path is hidden for the duration
of the draft; on commit (Enter / click-back-to-start / Escape) the
old layer is dropped and the new (longer) path takes its place,
preserving the original z-order, target, fill and stroke. Clicking
the start anchor reverses the node order so the next click extends
in the natural direction. Cancelling the draft restores the source
path unchanged.
- Multi-anchor selection. Shift-click an anchor to add/remove it from
the selection set; click without modifier selects just that anchor.
Dragging any selected anchor moves the whole set rigidly (anchors +
handles), so reshape operations affect groups in one gesture.
- Arrow keys nudge selected anchors. Direct Selection (or Pen) with at
least one anchor in the selection: ←/→/↑/↓ moves the anchors and
their handles by 1px (10px with Shift) in pixel-space; bbox refits
automatically. Falls back to the existing layer-nudge when no anchor
is selected.
- Marquee anchor box-select. In Direct Selection, drag from empty
space inside the layer to draw a dashed rectangle; on release, every
anchor whose center lies inside the box is selected. Shift+drag adds
to the existing selection.
- Drag a curve segment to bow it. Equal-split delta distributed
across the two inner control points so the cursor tracks the grabbed
point; smooth anchors on either end keep their handles mirrored. Lines
are auto-promoted to cubics on first bow.
- Alt-click a segment in Direct Selection now inserts an anchor at the
click (mirrors the Pen tool's segment-insert), and Alt-click an
anchor still toggles corner/smooth.
- Backspace deletes every selected anchor at once (bulk delete), with
the "path needs at least two anchors" guard applied to the resulting
size.
- New Direct Selection tool (A) as a separate palette button, distinct
from the regular Select tool. Anchors and handles only appear when
Pen or Direct Select is the effective tool, matching Illustrator's
V/A split.
- Cmd/Ctrl temporary toggle: holding the modifier from any non-Pen tool
flips the effective tool to Direct Selection so a quick anchor tweak
doesn't require dropping the current tool; release restores it.
- Hover cursor feedback on pen/direct-select: shows + when over the
end of an open path, - over an existing anchor (delete-adjacent),
✕ near the start to close, default crosshair otherwise.
- Shift constrains pen segments to 0/45/90 degrees while drawing and
while previewing the next click, matching Illustrator's constrain.
Foundation for the multi-anchor select + drag-segment + continue-path
endpoint work that follows.
Selecting a pen-shape layer with the Select tool now reveals its
anchors + handles for inline editing — no need to switch to the
Pen tool first. Drag anchors / handles to reshape; right-click
or Backspace to delete; Alt-click toggles corner ↔ smooth. Pen
mode keeps the richer affordances (segment-click to insert
anchor, click-to-create-new-path) since those would conflict with
the Select tool's normal layer-click behavior.
Three small changes:
- getEditablePenLayer drops the strict 'pen' tool requirement and
also accepts 'select'.
- syncPenOverlay keeps the overlay alive in select mode when a
pen-shape layer is selected; render() now re-syncs each frame
so the overlay appears / disappears as selection changes
(without requiring a tool change).
- renderPenEditHandles marks anchor + handle elements
pointer-events: all (with `cursor: pointer`) while the overlay
base stays pointer-events: none. Empty-space clicks fall
through to the layer's normal selection behavior; only clicks
on anchors / handles register as edit ops.
onPenPointerDown gains a select-mode branch that:
- Only handles anchor / handle hits; segment clicks pass through
to the layer (so dragging the path body still selects / moves
the layer as expected).
- preventDefault + stopPropagation on anchor / handle hits so
the underlying layer-drag handler doesn't also fire.
Two pen paths with the same stored stroke number rendered at very
different on-screen thicknesses — strokes scaled with layer size
because nodes lived in a 0-100 normalized viewBox. The pencil tool
solved this exact problem (see commitPencilStroke ~line 9580 with
the inline comment about pixel-space viewBox = "true on-screen
thickness, identical no matter how long the stroke is"). Switching
pen paths to the same scheme.
Pen nodes now live in a pixel-space viewBox where 1 unit = 1 px
at 96 dpi: viewBox dims = (layer.w * 96, layer.h * 96). Because
the viewBox aspect matches the layer aspect, the renderer's
sx == sy (uniform scale) AND the same stroke-width number produces
the same device-pixel thickness on every pen path regardless of
size.
Five touchpoints updated:
- finishPenPath: node remap multiplies bbox-relative coords by 96
instead of normalizing to 0-100; viewBox.w / viewBox.h written
as bbox.w*96 / bbox.h*96.
- penNodeToInch / inchToPenNode: convert via /96 (was /100 of
layer dim).
- regenPenLayerD: viewBox rewritten as (layer.w*96, layer.h*96)
on every edit. Catches user-resizes via corner handles too.
- refitPenLayerIfNeeded: bounds checked against pixel-space
viewBox; layer position/size translated via /96; node remap is
now a pure translation (no rescale, since the units stay
pixel-space).
- "Make editable path" conversion: 0-100 primitive nodes mapped
to pixel-space relative to the existing layer's dimensions.
Strokes are now consistent across pen paths of any size, and the
in-progress draft (rendered in the pen overlay's inch-space) still
matches the final committed path's visual stroke weight at the
canvas's natural 96-DPI mapping.
The previous "uniform-scale remap" commit fixed node coordinates to
land in 0-100 × 0-(100*h/w), but the renderer (buildShapeSvgInner
path branch) reads shape.viewBox and computes sx = 100/vb.w,
sy = vbH/vb.h. With shape.viewBox.h hardcoded to 100, sx ≠ sy for
non-square layers and the path got stretched again — strokes
distorted around curves after the path was committed, even though
the in-progress draft (which renders inside the pen overlay, not
the layer SVG) looked fine.
Fix is the matching half: also write shape.viewBox.h = 100*lh/lw
wherever we touch the pen data, so sx == sy and the renderer
applies a uniform scale.
- finishPenPath now sets viewBox.h = vbH from the bbox aspect.
- regenPenLayerD (called on every edit drag + insert/delete/toggle)
recomputes vbH from current layer.w/h before writing the new d
string. This catches the rare case where layer.w/h change between
refit calls without my refit running.
- The "Make editable path" conversion remaps primitiveToPenNodes /
parsePathDToNodes results (which always come in 0-100×0-100) into
the layer's actual aspect space, and writes viewBox.h to match.
Result: converting a rect → editable path on a non-square layer
keeps its proportions, and the resulting pen path edits cleanly
with uniform strokes.
Strokes now stay a constant width all the way around closed
shapes, even after the path is fully committed and re-rendered
through the standard shape-layer pipeline.
Two visual issues on top of the Phase D close-out commit:
1. **Stroke width varied along curves.** The pre-existing pipeline
stored nodes in 0-100 × 0-100 viewBox space and let the SVG
renderer pick `vbH = 100 * layer.h / layer.w` with
`preserveAspectRatio="none"`. For any non-square layer, the
0-100×0-100 node range got stretched non-uniformly into a 100×vbH
viewBox, distorting the path: strokes appeared wider where the
curve traveled more in one axis. Phase D's refit made this much
more likely because dragging anchors out of bounds routinely
produced non-square layers.
Fix in two places:
- finishPenPath: uniform-scale the bbox by `100/bbox.w` so y
range lands at exactly `100 * h/w = vbH`. The viewBox aspect
matches the layer aspect, no stretching.
- refitPenLayerIfNeeded: same uniform-scale invariant. Also
respects the layer's current vbH when deciding whether nodes
are "out of bounds" — was previously hard-coded at 100, which
wrongly fired refit on every drag in already-non-square layers.
2. **Anchor + handle glyphs were too prominent.** On a 1920px-wide
slide preview the old `dotR = 0.013 * unit` rendered ~25px
dots. Reduced to `0.0075 * unit` (~12px) and stroke from
`0.003 * unit` to `0.0018 * unit`. The in-progress draft
renderer matches.
Strokes now stay constant width all the way around a curve, and
the editing UI reads as overlay rather than as part of the artwork.
Finished the deferred Phase D pieces from PEN_TOOL_FEATURE.md.
New affordances on the editable pen layer:
- Click a segment between anchors → inserts a new anchor at the
click point via de Casteljau cubic split. Curve shape is
preserved exactly: a's hOut, b's hIn, and the new mid-anchor's
handles all derive from the split. Lines (no handles either
side) get a plain midpoint anchor with no handles. The new
anchor is selected and immediately drag-able.
- Right-click an anchor → delete it. Suppresses the browser
context menu on the pen overlay always.
- Backspace / Delete with an anchor selected → delete it.
Layer-delete still wins when no anchor is selected. The pen
tool's selected-anchor sentinel `_penSelectedAnchor` is reset
on tool change.
- Alt-click an anchor → toggle between `kind: 'corner'` (handles
move independently) and `kind: 'smooth'` (handles stay mirrored
across the anchor). Promoting to smooth re-derives the missing
handle (or re-mirrors both if independent) so the curve
actually becomes continuous. New nodes from pen click-drag
default to smooth; new nodes from a single click are corners.
- Drag an anchor or handle past the original 0–100 viewBox →
the layer's bounding rect auto-refits. New `refitPenLayerIfNeeded`
computes the bbox over all nodes + handles, translates
layer.x/y by the bbox offset (scaled to current w/h), updates
layer.w/h, then remaps every node into the new 0–100 space.
Path stays visually anchored to the canvas — no more clipping
at the layer edge.
Anchor glyphs now distinguish kind: smooth = circle (existing
look), corner = square. The selected anchor renders with an amber
stroke (#f97316) so the Backspace-to-delete target is unmistakable.
Spec updated to mark Phase D complete and the deferred items
shipped. Rotated paths are still not node-editable in v1 (the
deferral remains).
Removed three superseded spec files now that the work they
described is either shipped (under newer specs) or formally
replaced:
docs/ZAP_GATED_TEMPLATES_FEATURE.md
docs/ZAPS_NWC_FEATURE.md
docs/ZAP_GATED_PREMIUM_FEATURE.md
The history is preserved in git. The current canonical premium
plan is docs/PREMIUM_DESIGNS_FEATURE.md (which absorbed the
zap-gated+NWC infrastructure plus added the soft-gate KDF +
encrypted envelopes + purchase vault).
README's docs paragraph now lists every active spec by status —
shipped / partial / drafted — and adds the transparency-iframe
baseline stub that landed alongside the embed feature.
The embed feature is essentially complete — phases 0-6 are live
on main, Phase 7 hardening shipped alongside Phase 1. Updated
the spec to reflect that:
- Status block flipped from "Nothing in this spec is implemented
yet" to "Shipped (2026-06-01)" with date.
- Every shipped phase gets a ✅ marker on its heading.
- Phase 7 (Hardening + headers) body rewritten to record what
actually shipped (CSP frame-ancestors, SPA rewrites, error
handling, sanitization reuse) plus what's still optional
(?debug=1 surface, hard relay-count cap).
- Phase 8 (Polish) deliberately dropped — the previously-drafted
Credits overlay, postMessage handshake, and explicit
light/dark/auto theming weren't needed for the use cases the
feature is actually solving. Replaced with a clear "dropped"
note listing each item and why.
- Phase 9 (Future) reframed as the natural follow-up arcs if we
revisit (oEmbed endpoint, render-to-image, OG images,
lightweight embed.html bundle).
The Acceptance Criteria for Embed v1 stays valid as-is — every
bullet is true.
Animated pixel-art embeds (frames > 1) now respect three controls
per docs/EXTERNAL_EMBED_FEATURE.md §6.3 / §11.4:
- ?play=1 / ?play=0 — explicit auto-play toggle. If the param
isn't set, autoplay follows the visitor's prefers-reduced-motion
preference: motion-OK users get auto-play, reduced-motion users
get start-paused with a play CTA. ?play=1 forces auto-play even
in reduced-motion mode (the spec's "user opt-in" wording).
- ?fps=N — override frame rate, clamped to 1-60. Falls back to the
payload's stored fps (default 8) when not set.
- A play/pause chip in the embed footer's left side that toggles
on click. Re-renders the artwork with the new autoplay state;
the renderer's RAF self-stops when the canvas leaves the DOM, so
the swap is leak-free.
Renderer changes (renderPixelArtPreviewDOM):
- New opts.autoplay (default true) gates the requestAnimationFrame.
When false, frame 0 is drawn but no animation loop starts.
- New opts.fps (number, optional) overrides pa.fps. Defaults
unchanged for all existing callers (linked-design renderer,
feed cards, modal preview) because they don't pass either opt.
Single-frame pixel art is unaffected — the play/pause chip only
appears for frames.length > 1, and the renderer ignores autoplay
for the still-image path.
Public multi-slide deck embeds get a paginated viewer instead of
the single-thumbnail-with-badge that was hitting before (the
shared renderDeckPreviewDOM compact path).
_paintEmbed now branches on mode === 'deck' && slides > 1 into a
new _paintEmbedDeck that:
- Reads ?slide=N (1-based) from the route params for the initial
index; clamps to [0, slides.length-1].
- Renders the current slide via composeSlideForDeck +
renderDesignPayloadPreview into a .designObjectFitWrap and
fitMiniPreview-scales it.
- Adds prev / counter / next pill chips to the footer left side
(only when slides > 1 and controls=1), and the existing author
chip after them.
- Adds a fullscreen chip ⛶ + the Open-in-Artstr link to the
footer right side.
- Wires keyboard nav on document.keydown:
ArrowRight / PageDown / Space → next
ArrowLeft / PageUp → prev
Home → first; End → last
F → toggle fullscreen
Single handler is swapped via the _paintEmbedDeck._kbd sentinel
so re-renders (e.g., the refresh-error path) don't stack
listeners. Form-element targets (none today, future-proof) are
ignored so the embed doesn't eat typing.
- New _toggleEmbedFullscreen helper calls
documentElement.requestFullscreen() / exitFullscreen with a
graceful catch.
Touch swipe, autoplay (?autoplay / ?loop / ?interval), and
prefers-reduced-motion respect are deferred — they fit naturally
on top of this scaffold whenever we want them.
Both card builders (buildFeedCard + buildResultCard) had a
dedicated "Preview" button on the action row that just called
openDesignPreview(row). The whole card already has a click
handler doing exactly that, so the button was redundant — it
just added visual noise next to the Use/Fork (or premium-unlock)
CTA.
Removed the prevBtn from both builders. Existing card-click
behavior unchanged, so the preview still opens on click; the
action row now has only the reactions + the action button(s)
specific to that card state.
Swapped the embed code generator's live-preview backdrop from
the dev-time bright magenta/yellow checker to the standard
Photoshop-style light-gray/white transparency indicator
(#e5e7eb / #ffffff, 14×14 tiles). Reads as "this is where
transparency shows through" without screaming.
src/transparency-iframe.html is a tiny html+body=transparent
stub we serve at /transparency-iframe.html so dev hosts (and
future debug tools) can include it via iframe to confirm that
cross-origin iframe transparency is working in the target
browser without involving any of the Artstr embed JS.
Validated end-to-end:
- srcdoc iframe → magenta shows through ✓
- cross-origin minimal page → magenta shows through ✓
- cross-origin Artstr embed → magenta shows through the
Computer_Icon's transparent ring ✓
What looked like opaque white on the stress-test host was the
artwork's natural fill + the host's pink/yellow checker reading
as noise rather than see-through. Magenta on a flat backdrop
made the transparency unambiguous.
The generator was emitting /embed/<id> URLs (the canonical clean
path from the spec). Vercel rewrites that to / in production —
but on hosts without SPA rewrites (including local dev with
python http.server, and many static hosts users will paste into
WITHOUT Vercel-style routing), it 404s. The Embed modal's live
preview showed a "404 — File not found" error on localhost.
Switched to the ?embed=<id> query form. Universal: works on any
host that serves the index.html at /, no rewrite dependency.
The published Vercel deploy supports both forms via the existing
rewrites, so the change is backward-compatible for production
consumers.
If anyone wants the clean /embed/<id> form they can hand-edit
the snippet after copying; the embed runtime accepts all three
forms (path, hash, query) on the receive side.
openEmbedCodeDialog set modal.style.display = '' to show the
modal, which falls back to the CSS default `.modal { display:
none }` — so the dialog never appeared. Every other modal in the
app sets `'flex'` explicitly. Matching that pattern.
Also fixes the symptom "Embed button doesn't work / doesn't
copy anything" — the dialog was always present in the DOM but
invisible, so the copy buttons inside it couldn't be reached.
Per docs/EXTERNAL_EMBED_FEATURE.md §13 / §18 Phase 6 — the embed-
code generator now ships alongside Phase 1's viewer so authors
can grab iframe snippets directly from the community preview.
UX:
- New "Embed ↗" button in the preview pane's action row, between
the share-link button and Use/Fork. Hidden for private rows
(embed refuses them anyway); shown for locked-premium rows so
designs can be embedded as watermarked-preview teasers pre-
purchase.
- Click opens #embedCodeModal: a single-screen dialog with
identifier selector, size preset, options checkboxes, live
iframe preview, and the generated iframe HTML in a copy-ready
textarea + Copy URL / Copy iframe code buttons.
Controls:
- Identifier: naddr (latest version, auto-updates) | nevent
(snapshot, frozen to this exact event). naddr is the default
but falls back to nevent when the row has no d-tag.
- Size: responsive 16:9 (recommended) | square 320 / 480 | fixed
640×360 / 800×450 / 960×540 | custom WxH.
- Options: controls / author chip / "Open in Artstr" link /
credits row — each toggled via a query param (controls=0,
attr=0, open=0, credits=1).
The live-preview pane uses a pink/yellow checker background so
transparency in the embedded design shows through visually
(verifies that the iframe is actually transparent before the
user pastes the snippet on their own site).
Copy buttons use navigator.clipboard.writeText with a graceful
fallback toast on permission denial. The URL extracted from the
generated iframe's src=, so URL/iframe stay in sync.
Defensive: also setProperty('background-image', 'none', 'important')
and ('background-color', 'transparent', 'important') on both
documentElement and body. Catches any inherited background-image
that might be painting a pattern instead of a flat color.
Added a console.log('[embed] boot v3', { route }) at the top of
bootEmbedViewer so the user can confirm the new code is reaching
the iframe — if the log doesn't appear in the iframe's dev-tools
console, the browser cache hasn't refreshed yet.
Previous fix set documentElement.style.background = 'transparent'
but didn't beat the global
html, body { background: var(--bg); ... }
rule at the top of the stylesheet. --bg is #e8f4f8 (light cyan,
reads as near-white), so pixel-art transparent pixels were showing
that light cyan through instead of the host page background.
Two fixes layered:
1. CSS rule using :has() so html is targeted only when an
embed-mode body is its descendant:
html:has(body.embed-mode), body.embed-mode {
background: transparent !important;
}
:has() is supported in all current browsers (Chrome 105+,
Safari 15.4+, Firefox 121+). The !important flag beats the
global var(--bg) declaration.
2. Inline override via style.setProperty('background',
'transparent', 'important') on both documentElement and body
at the top of bootEmbedViewer. Inline + !important = highest
specificity possible, so this guarantees transparency even on
browsers/edge cases where :has() doesn't trigger immediately
on dynamic class change.
Net effect: from <html> → <body> → #embedRoot → .embedStage →
.designObjectFitWrap → renderer's pixel-art box → canvas, every
layer is transparent. Host backgrounds (color, image, gradient)
now actually show through transparent pixel-art cells.
Two follow-ups from the size/aspect stress test:
1. Retryable error states (relay query failed, event not found
on any relay) now render a "↻ Refresh" chip below the message.
Click re-runs bootEmbedViewer(route) — same boot path,
re-issues the relay race. Non-retryable errors (private,
premium, malformed identifier, wrong-payload-shape) don't show
the button because retrying wouldn't change the outcome.
_setEmbedStatus now accepts an optional { retry } callback;
only the two relay-failure call sites in bootEmbedViewer pass
it.
2. Pixel art (and any other design with transparent pixels) was
rendering on a white default background because the browser
paints document.documentElement white when nothing overrides
it. body.embed-mode set background:transparent, but the
<html> root was unaffected — leaving a white layer behind the
transparent body.
Fixed by setting document.documentElement.style.background =
'transparent' at the top of bootEmbedViewer. Combined with the
already-transparent body + embedRoot + embedStage chain, the
iframe document is now fully transparent from html down. Host
pages can place the embed over background images / colors and
the unfilled pixel-art cells show the host through.
Host-side defensive: dummy host CSS now explicitly sets
`.frame iframe { background: transparent }`. Modern browsers
default this for transparent-document iframes, but explicit is
safer across browser versions.
The grid swap fixed the squish but introduced the inverse: the
renderer's 720px box stayed at 720px without scaling down to the
iframe viewport. fitMiniPreview was firing, but something in the
grid layout path was preventing the transform: scale() from
shrinking the visible artwork.
Reverted the wrap to the same pattern the existing linked-design
renderer uses for in-app layer previews — flex centering + the
.designObjectFitWrap CSS class, which sets
.designObjectFitWrap > * { flex: 0 0 auto; max-width: none;
max-height: none; transform-origin: center center;
margin: 0 !important; }
flex: 0 0 auto on the direct child explicitly disables both
flex-grow and flex-shrink, so the renderer's fixed 720×720 box
stays at its natural size in layout. fitMiniPreview then reads
naturalW=720, wrapW=iframe-width, and scales uniformly with
correct aspect.
This is the exact pattern that works for linked-design layers in
the editor; the embed inherits the same correctness guarantees.
The embed wrap was using display:flex with align-items+justify-content
to center the rendered design. Flex's default flex-shrink:1 on a
fixed-pixel child (the renderer creates a 720×720 box) shrinks the
box horizontally to fit the wrap width on iframes narrower than
720px.
fitMiniPreview then reads naturalW from the box's inline style
(720) — not the post-flex-shrink computed width — and applies
transform: scale(min(wrapW/720, wrapH/720)). The horizontal axis
ends up scaled twice: once by flex (down to wrap width), then
again by the transform. Result: horizontal squish equal to
(wrapW / 720), visible especially on aspect-ratio:1/1 iframes
hosting square pixel art.
Switched both the public paint wrap and the locked-premium paint
wrap to `display: grid; place-items: center`. Grid centers fixed-
size children without shrinking them, so the box stays 720×720 in
layout. fitMiniPreview's scale calc is then accurate and uniform.
User feedback: the embed frame's white footer band clashes with
host site themes. Refactored so the frame is fully transparent
and every control floats as its own pill chip over the bottom
edge of the stage. Each chip carries a semi-opaque dark
backdrop + blur so it stays readable on any host background
(light, dark, branded).
Style changes:
- .embedFooter is now absolute-positioned, transparent, with
pointer-events: none so the artwork beneath stays interactive
(pixel-art animation, future deck swipe gestures).
- New .embedChip base class for every control:
- rgba(17,24,39,0.7) bg + white text
- 12px backdrop-filter blur for cross-theme legibility
- rounded-pill shape, 1px white-translucent border
- Variants:
- .embedChipUnlock — amber accent for the premium CTA
- .embedChipCounter — slightly lighter, cursor:default
- .embedChipBadge — for the top-right "🔒 PREMIUM" badge
JS changes:
- New _makeAuthorChip(pubkey) helper used by both the public and
premium paint paths.
- _paintEmbed and _paintEmbedLockedPremium drop all inline
color/background overrides — every control gets a chip class
instead.
- The locked-premium prev/next buttons are now plain chips
(were amber-on-amber buttons before, hard to read on dark
artwork). The amber accent only stays on the unlock CTA where
it's a semantic signal.
Status placeholder ("⟳ Loading…", error messages) keeps a soft
white-translucent backdrop so it's visible while there's no
artwork behind it yet.
Smoke-test target: the dummy host page (localhost:8001) now
includes a dark-themed section so chip legibility against dark
backgrounds is verifiable visually.
Per docs/EXTERNAL_EMBED_FEATURE.md §10 / §11.6 / §18 Phase 1+
(promoted from Phase 2 since it's natural to ship alongside the
public render path).
The envelope on a premium event ships an unencrypted `preview`
field:
- watermarked-image: single watermarked JPEG (`preview.dataUrl`).
- watermarked-deck: paginated watermarked slides
(`preview.slides[]`, each { dataUrl, width, height }).
The embed loads the envelope from event.content, branches into a
new `_paintEmbedLockedPremium` paint path, and renders the
preview directly. No relay re-query, no decryption attempt.
UX:
- Stage gets a dark backdrop + the watermarked preview centered.
- A small "🔒 PREMIUM" badge sits top-right of the stage so the
lock is visible without obscuring the artwork.
- Deck previews get prev/next controls in the footer with a
"Slide N of M · K in deck" counter that surfaces the deck's
full slide count (preview ships a capped sample).
- Footer's right side has "🔒 Open in Artstr to unlock ↗" in
amber — the only path to unlock. The iframe never initiates a
zap.
Phase 1's premium-refuse fallback still kicks in for envelopes
that ship without a preview field (legacy / malformed) — same
text-only placeholder as before.
Per docs/EXTERNAL_EMBED_FEATURE.md §18 Phase 1.
- parseEmbedRoute() detects three URL forms so the embed works on
Vercel-rewrite-friendly hosts AND on plain static servers:
/embed/<id> (canonical; needs SPA rewrite)
#/embed/<id> (hash; works on any host)
?embed=<id> (query; works on any host)
- Early-return at the top of init() routes embed mode to
bootEmbedViewer; skips welcome modal, slot/disc/jewel controls,
attachEvents, walletUI, autosave restore, share-route popstate.
Editor JS stays loaded (single bundle), but none of its boot
steps run.
- bootEmbedViewer() decodes the identifier via the existing
decodeNostrId helper (note / nevent / naddr / hex), races the
hint relays then user relays + DEFAULT_NOSTR_RELAYS for the
event, warms linked-design sub-trees in one prefetchBatch
round-trip, then paints via the existing
renderDesignPayloadPreview + fitMiniPreview path.
- Premium events refuse with "🔒 Premium design — open in Artstr
to unlock" placeholder; Phase 2 will render the watermarked
preview JPEG with an inline CTA.
- Private (encrypted) envelopes refuse with a 🔒 placeholder; no
decryption attempted.
- body.embed-mode CSS hides every existing top-level element
(`body.embed-mode > :not(#embedRoot) { display: none !important }`)
and shows a single #embedRoot with a stage + thin footer (author
chip + "Open in Artstr ↗"). attr / open / controls query params
toggle each piece.
- vercel.json gains /embed/:id SPA rewrites and a
Content-Security-Policy: frame-ancestors * header so the route
is iframe-able from any origin. Production behavior; dev (python
http.server) needs no headers — cross-origin framing works by
default.
Manual smoke-test paths:
http://localhost:8000/?embed=<naddr> (query form)
http://localhost:8000/#/embed/<naddr> (hash form)
http://localhost:8001/ (cross-origin
iframe host)
Updated rewrite of the earlier embed plan covering every Artstr
feature shipped since the original sketch:
- Linked sub-design resolution inside embeds (reuses
linkedDesigns.prefetchBatch + resolveLinked + disk cache, full
cycle/depth/byte safety).
- Premium boundary: watermarked preview + "Open in Artstr to
unlock" CTA; no in-iframe zap.
- Private boundary: hard refuse with 🔒 placeholder; no decrypt
attempts.
- Pixel-art animation playback (auto-play, fps override,
prefers-reduced-motion respected).
- Deck slide picker — `?slide=N` deep-links; linked-deck layers
carrying design.slideIndex project to a single-slide payload.
- Credits overlay (opt-in via `?credits=1`), mirroring the
in-app Credits panel.
- PostMessage handshake for parent-page integration (deck
control, fullscreen request, ready/error events).
- Layer-group handling falls out naturally — groups are flat
layers with groupId; the existing renderers already paint them.
- Open Graph metadata on the embed route URL itself.
- Per-event + per-tree byte caps inherited from linked-designs.
- Phase plan + acceptance criteria + risks updated.
No code; this is the planning artifact. Implementation phases
0-9 are scoped inside the doc.
Last Phase 3 polish piece per LINKED_DESIGNS_FEATURE.md §14. The
cold-start path used to fire one parallel queryRelays per linked
layer — a host with 20 distinct linked sources opened 20 sockets
to each relay in the union. Now restoreAutosave issues one
batched relay round-trip via the new linkedDesigns.prefetchBatch.
Mechanics:
1. queryRelays accepts either a single filter object (existing
callers) or an array of filters; arrays spread into the NIP-01
REQ after the sub id (OR semantics). One-line change at the
send site; zero impact on existing call sites.
2. prefetchBatch(specs) skips anything already in the in-memory
cache, inflight map, or disk cache — partial cache hits only
fetch what's missing. Distinct address-coord refs are grouped
by (kind, pubkey) into one filter each (#d set of dTags);
pinned event ids collapse into a single ids-filter. Events
bucket by id (for pinned matches) and by address coord with
latest created_at wins (for ref matches).
3. restoreAutosave's per-spec resolveLinked loop becomes a single
prefetchBatch call. The disk-cache pre-filter inside
prefetchBatch means a reload of a previously-resolved host
pays zero network cost; only newly-linked refs hit the wire.
Per-layer resolveLinked calls in the renderer still work the way
they did — this only changes the bulk warm-up path. Interactive
adds (one new linked layer at a time) keep their single-filter
fast path.
Three bugs in the freshly-shipped extract flow:
1. Group extract baked customArt.background='#ffffff' so the
extracted design lost any transparency the group had. Now
defaults to 'transparent' so the extracted design composites
cleanly into any host. Users can pick a solid fill later via
the customart background control if they want one.
2. Double-clicking a linked layer ran beginDesignObjectEdit
which loads layer.design.payload — but linked layers have no
local payload, just a ref. loadProjectFromText('{}') then
fell back to the default cover/casewrap mode with empty
layers, hence "opens the case wrap editor" and "design is
missing completely."
beginDesignObjectEdit now detects design.kind === 'linked',
pulls the resolved payload from linkedDesigns.peek, and opens
THAT for editing. The edit session is tagged with
{ isLinked, linkedContext } so finishDesignObjectEdit knows
to take the republish path on save instead of mutating
layer.design.payload (which doesn't exist for linked
layers).
3. Guard rails on the linked-edit path:
- Refuse if the resolver hasn't cached the source yet
("Refresh the layer and try again").
- Refuse if the layer is pinned ("Unpin to edit — pinned
versions are frozen").
- Refuse if the source author isn't the current user
("Only the source author can edit; fork or unlink it for
a local copy"). Soft permission — relays decide who can
replace a kind-30078 by pubkey + d-tag, so non-authors
couldn't republish even if we let them try.
On save, _republishLinkedDesignSource:
- Strips linked-design ephemera + builds attribution tags
(same as host publish).
- Publishes a fresh kind-30078 under the same author + d-tag,
which is NIP-33 in-place replace.
- clearCache(key) + seed both resolver caches from the new
signed event so every linked layer on this canvas paints the
updated version on next render.
- Refreshes the layer name from the (possibly edited) payload
title with the 🔗 prefix preserved.
finishDesignObjectEdit is now async; existing call sites are
fire-and-forget which is fine — user feedback flows through
toasts, not the return value.
Extends the per-layer "🔗 Publish + link" pattern to layer groups.
A new 🔗 button sits next to the eye-toggle in each group's
header (layerGroupBlock); clicking it publishes the group as a
single customart-mode design and replaces the group with one
linked layer at the group's bounding box.
Implementation:
- layersBounds() over the group's layers gives the bbox in inches.
- customArt.width/height = bbox in pixels (×96 DPI).
- Each layer is cloned with x/y translated so the group's
top-left becomes (0, 0); target forced to 'canvas'; groupId
stripped. Group can contain mixed layer types (shapes, text,
design objects, images) — customart is the right canvas type
for any of them.
- Strip linked-design ephemera + walk attribution like the
host-publish path so the new event ships proper p / link tags.
- d-tag = "extracted:customart:<slug>:<rand>" to avoid colliding
with the user's regular published designs.
- After publish: remove the original group layers from
state.layers, drop the group entry from state.layerGroups, add
one linked layer at the bbox position with the host's original
target (cover-front / canvas / etc.). The replacement layer's
rotation is 0 — group rotation is intrinsically per-layer.
- linkedDesigns.seed primes both caches from the signed event
so the replacement layer paints instantly.
Confirmation copy warns about publishing under the user's
identity (spec §10.3) and explains the bbox + relative-position
behavior so the conversion isn't surprising.
Mixed-target groups (e.g., one layer on cover-front, one on
cover-back) collapse to a single canvas; this matches user
expectation since "the group" only exists when its members are
selected together as a unit.
User-triggered conversion of an embedded design layer into a fresh
published kind-30078 event under the user's pubkey, replaced by a
linked layer pointing at the new event. Spec §10.3 / §11.4 — when
you decide a sub-design is reusable, one click extracts it without
re-importing by hand.
UI: new "🔗 Publish + link" button in the embedded design-layer
options panel (alongside Edit and Replace from file). Hidden for
linked layers since they're already what extract produces.
Flow:
1. Confirm with copy that warns about publishing under the user's
identity (per spec §10.3 — only do this for designs you made).
2. Clone the layer's payload, override payload.meta.title with the
layer's name, keep host category/language so the new event
discovers into the same bucket.
3. Strip linked-design ephemera + walk the linked tree for
attribution p-tags + link tags, same as the host-publish path.
4. Mint d-tag = "extracted:<mode>:<slug>:<rand>" so it can't
collide with the user's regular published designs.
5. Sign + publish via publishCommunityEvent.
6. Mutate the layer in place: design becomes { kind: 'linked',
ref, mode, pinnedEventId: null, slideIndex: 0 for decks }, name
gets the 🔗 prefix, push history.
7. Seed both resolver caches from the signed event via the new
linkedDesigns.seed(spec, sourceEvent) method — the layer paints
from cache instantly without waiting for relay propagation.
linkedDesigns.seed reuses _entryFromEvent so vault / premium /
private detection runs identically to a fresh fetch. Disk-cache
write happens too so a reload also paints instantly.
Group extraction is a natural follow-up — a group of layers would
publish as a single customart-mode payload sized to the group's
bbox. Not in this commit; ship single-layer first.
Mirror the in-memory resolver cache to localStorage so cold reloads
paint linked sources without a relay round-trip. 24-hour TTL keeps
return visits feeling instant while still propagating source edits
within a day; 4 MB LRU eviction stops a host with many linked
layers from blowing the quota.
Storage shape (artstr.linkedDesignCache.v1):
{ version: 1,
entries: {
[cacheKey]: { sourceEvent, fetchedAt }
}
}
The disk cache stores only the raw sourceEvent — premium / private
detection, vault lookup, and decrypt all re-run on every read via
the extracted _entryFromEvent helper. That means vault state,
sign-in changes, and unlocks since the last fetch are always
respected on disk-cache hits; we never serve stale "locked" state
once the user has unlocked, or stale "unlocked" state once they
clear the vault.
Refresh + Unlink continue to be the user's escape hatch:
clearCache(key) now wipes both the in-memory entry AND the disk
entry, so a manual refresh genuinely re-fetches from relays.
Eviction is LRU by fetchedAt when total content bytes exceed
LINKED_DESIGN_DISK_MAX_BYTES. Quota errors on save drop the whole
cache and continue — better to lose the optimization than to break
publishing for an unrelated localStorage write.
Two fixes for linking to slide decks:
1. Per-event cap was 256 KB — fine for slides/covers but legitimate
multi-slide decks routinely run 300-800 KB. The resolver was
returning null for those events, and the renderer fell through
to the "Linked source not found" placeholder. Raised the cap to
1 MB to match the per-tree cap; the tree cap still bounds the
worst-case host render, and a single oversize event is now
tolerated rather than dropped.
While here, added console.warn diagnostics on the resolver's
null paths (no events, empty content, cap exceeded, JSON parse
fail) so future debugging is one dev-tools tab away.
2. A linked deck normally has 5-50 slides and the user almost
always wants to embed one of them, not the multi-slide
thumbnail. New schema field design.slideIndex picks which
slide to render:
- addLinkedDesignLayerFromRow / replaceLinkedDesignLayerFromRow
default it to 0 when the source is a deck.
- renderResolved checks for a finite slideIndex on a deck
payload and projects to a stand-alone slide payload via
_deckSlideToSlidePayload before painting. The picked slide
renders the same way a normal slide-mode design would.
- The linked-layer options panel now shows a "Slide" dropdown
(id linkedDesignSlideRow) when the resolved source is a
deck. The dropdown lists every slide as "N. <name>" and
mutating it pushes history + re-renders.
- The row hides itself for non-deck sources and for decks
that haven't resolved yet — once the resolver fills the
cache, the next render reveals it.
Public/embedded layers and non-deck linked layers are unaffected.
LINKED_DESIGNS_FEATURE.md §12.1 — private sources carry a NIP-44 /
NIP-04 envelope addressed to specific pubkeys. Phase 1 treated them
as unparseable junk; Phase 2 detects them, attempts decryption, and
either renders the decrypted payload (if the viewer is in the
envelope's recipients list) or paints a 🔒 placeholder otherwise.
Resolver changes (_fetchPayload):
- After the premium branch, detect isEncryptedEnvelope(parsed) and
await decryptProjectEnvelope. On success return the decrypted
payload like a public source; on any failure (not in recipients,
no signing extension, malformed envelope) return { payload: null,
private: { state: 'locked' } } so the renderer can paint the
locked UI without re-fetching.
Renderer changes:
- New renderLockedPrivate helper paints a 🔒 + "Private design" +
"Not shared with this account" on a dark gray background. No
unlock CTA — access to a private design is granted at the
source's publish time by adding the viewer to envelope.recipients,
there's no buyer-side action to take.
- Sync peek hit handles cached.private.state === 'locked'; async
path handles entry.private.state === 'locked'.
Source authors are always in their own envelope.recipients, so the
existing self-decrypt path works for them without special-casing.
LINKED_DESIGNS_FEATURE.md §12.1 — Phase 1 silently dropped
premium / encrypted sources because the resolver's JSON.parse hit
an envelope shape and the renderer treated payload=null as
"source missing." Phase 2 detects premium events, gates rendering
on the buyer's purchase-vault entry, and surfaces an inline unlock
CTA when the viewer hasn't bought yet.
Resolver changes (_fetchPayload):
- Detect premium events via isPremiumEvent() on the source event.
- Look up findUnlockedEntry(ev.id, addressCoord); on hit, return
the cached decrypted payload as if it were public.
- For self-authored sources, run Premium.decryptPayload locally
(soft-gate KDF is deterministic from public material) and
mirror the unlock into the vault via markUnlocked so My Designs
and the feed see it too.
- Otherwise return { payload: null, premium: { state: 'locked',
envelope, addressCoord } } so the renderer can paint the locked
UI without re-fetching.
Renderer changes (renderDesignObjectIntoElement linked branch):
- New renderLockedPremium helper paints the watermarked preview
JPEG (from envelope.preview.dataUrl) at .45 opacity behind a
semi-transparent overlay with 🔒 + "Linked premium design" + an
"⚡ Unlock for X sats" button.
- Button awaits openUnlockConfirm with a synthetic row built from
the source event; after it resolves, checks the vault and if
the entry landed, clears the resolver cache for this layer's
key and re-renders. The resolver re-fetches and the next paint
shows the decrypted preview.
- Sync peek hit handles cached.premium.state === 'locked';
async path handles entry.premium.state === 'locked'.
Locked sources still contribute to attribution (sourceEvent.pubkey
is known regardless of payload visibility), but walkLinkedTree
descends no further into them — encrypted contents are opaque to
us, so nested credits stop at the locked boundary. Acceptable
Phase 2 limitation.
Private (visibility) linked designs are NOT in this commit;
adding on top as P2c if Adam wants.
The hydrator was relying on populateCommunityProfiles, which only
queries state.community.relays. Linked-design contributors are
arbitrary pubkeys whose kind-0 metadata typically lives on the
popular default relays (damus, nos.lol, primal, nostr.band), not
necessarily on the host author's narrow relay list — so the lookup
silently came back empty and the UI fell back to short-pubkey
labels.
Switch to the same _vaultRelayUnion() the linked-design resolver
already uses (user relays ∪ DEFAULT_NOSTR_RELAYS). Pick the latest
kind-0 per pubkey when multiple relays return different versions.
Write into both state.community.profiles and _feedCache.profiles
so all downstream profile-display surfaces (including the existing
"by Author" link in the preview header) benefit from the fetch.
Two fixes to the Credits panel + preview surface:
1. Anchor click routed through openProfilePage(pk) instead of
navigating to /u/<npub> — the latter 404s because the SPA only
handles those routes via JS, there's no static file behind them.
Mirrors the pattern used by the existing author link in the
preview header (preventDefault + openProfilePage).
2. Display name pulled from _feedCache.profiles / state.community
.profiles with shortPubkey() as fallback. For contributors who
aren't in the cache yet, _hydrateCreditProfiles fetches kind-0
metadata via populateCommunityProfiles in the background and
re-renders the list when names arrive. Per-pubkey inflight
tracking dedupes concurrent fetches across re-renders.
Editor panel re-renders the full panel on hydration. Preview
section rebuilds just its <ul> in place, guarded on
section.isConnected so a closed or swapped-out preview doesn't
mutate stale DOM.
Mirror the editor's Credits panel into the community/template
browser preview pane so viewers can see who contributed before
importing or unlocking. Same data, different host:
- Factored collectLinkedCredits + buildCreditsListElement out of
renderCreditsPanel so both surfaces share the implementation.
- The preview pane passes the row's payload + author pubkey;
self-attribution (the design author appearing in their own
credits) is filtered out the same way the editor filters the
current user.
- Section is appended at the bottom of the preview content; it
only renders when the previewed design has at least one linked
contributor.
Works for previews of cover, slide, deck, jewel, disc, pixelart,
and custom-art designs — the section appends after whichever
mode-specific preview renderer ran.
Per LINKED_DESIGNS_FEATURE.md §10.1, surface each distinct
contributor reachable via the host's linked layers as a clickable
@npub entry in a "🔗 Credits" panel in the metadata sidebar.
Walks state.layers + every deck slide's layers, groups distinct
linked refs by pubkey (so 5 copies of the same source count as 1
design from that creator), excludes self, and renders one entry
per contributor sorted by descending design count. Each entry
links to the /u/<npub> profile page in a new tab.
Pubkey is parsed straight from design.ref's address coord, so the
panel populates without waiting for the resolver. Fieldset is
hidden entirely when there are no linked layers — non-collaborative
designs stay clutter-free.
Wired into render() so it stays current as the user adds, removes,
pins, replaces, or unlinks layers.
Per LINKED_DESIGNS_FEATURE.md §10.2, host designs that contain
linked layers now emit:
['p', <pubkey>, <relay-hint>, 'design-contributor'] per distinct
contributor
['link', <kind:pubkey:dTag>, <relay-hint>] per distinct
linked ref
Implementation: a new _buildLinkedAttributionTags helper drives the
existing linkedDesigns.walkLinkedTree to collect contributor pubkeys
+ refs in one pass (cycle- and depth-3-safe via the resolver). The
host author's pubkey is filtered from contributors. Walker errors
degrade to empty tag arrays without failing the publish.
Tags are spliced into the public publish path and the premium
publish path. Private publishes skip attribution entirely — tags are
unencrypted, so emitting them would leak the link graph through what
is meant to be a feed-invisible event.
Encrypted sources resolve to null payloads in Phase 1, so their
authors silently don't get p-tagged — which is the right Phase 1
behavior until Phase 2 brings encrypted-link support.
Unit tests at /tmp/test_attributionTags.js (12 cases) cover dedupe,
self-filtering, malformed-pubkey rejection, empty walks, and walker
failure modes.
restoreAutosave now collects every linked layer (top-level + every
deck slide) and kicks off linkedDesigns.resolveLinked for each.
This pre-warms the cache so the first render's renderResolved fires
from a synchronous peek instead of paying the relay round-trip
serially, and duplicate refs across slides dedupe via the inflight
map.
stripLinkedDesignEphemera removes session-only fields
(lastResolvedAt, lastResolvedSize, transient fallbackPreview) from
linked layers in a published payload. Called once at the top of
publishCurrentTemplateToNostr — before the private/premium/public
branches — so every publish path is covered. fallbackPreview only
survives if design.snapshotPreview === true (a future opt-in surface
for designs that want a still preview to ship alongside the link).
Unit tests at /tmp/test_stripLinkedEphemera.js (16 cases) cover the
mutation, opt-in, deck walking, and null-tolerance paths.
renderResolved had an `if (!el.isConnected) return` guard meant to
catch the async path resuming after the layer was removed. But
renderLayers() creates `el` via document.createElement and only
appends it to the overlay *after* renderDesignObjectIntoElement
runs (line 14442). On the synchronous peek-hit path, that meant
the guard fired on every re-render — wiping the layer every time
the canvas re-rendered after the cache warmed.
Move the guard out of renderResolved (the sync caller doesn't
need it — fitWrap is a descendant of el either way, and the
caller will append el right after this function returns) and
keep it in the async closure (where it's actually needed,
because by the time the await resumes the old el may have been
discarded by a subsequent render).
Adds a dedicated layer-options panel that appears when a linked
design layer is selected, alongside the existing import flow:
- Source title + creator npub + "Last resolved <X> ago"
- 📌 Pin-to-current-version toggle (snapshots current event id)
- Refresh button: clears the layer's cache key + forces re-resolve
- Replace link… opens the community browser in 'link-replace' mode,
swapping the source on selection while keeping position + size
- Unlink to embed snapshots the resolved payload into an embedded
layer (requires a successful prior resolve)
The selection-sync block now hides the embedded-design panel and
shows the linked-design panel when design.kind === 'linked'.
User-facing entry point to create a linked design layer. Both
layer-panel sites (designer-app disc target + template-app per-
target panel) now expose a "🔗 Link from Nostr" button alongside
the existing "Import from Nostr" — embed vs link is a one-click
distinction at import time.
Mechanics:
- openLinkedDesignNostrSearch(targetOverride) mirrors the existing
embed-flow opener but stashes state._designLayerImportMode =
'link' before opening the community modal. The modal is unchanged;
the same row-click handler picks the route.
- addLinkedDesignLayerFromRow(row, target) creates a
kind: 'linked' layer with { ref, mode, pinnedEventId: null }. Ref
is built from the source event's kind + pubkey + d-tag. Refuses to
link sources without a d-tag (legacy non-replaceable events). The
initial layer dimensions mirror the embed path — uses
designObjectAspect on the freshly-clicked payload — so the layer
lands at the source's natural aspect. Layer name prefixed with
'🔗 ' so it's visually distinct in the layer list.
- tryDesignLayerImportRow now dispatches on _designLayerImportMode:
'embed' keeps the existing addDesignLayerFromPayload behaviour;
'link' routes to addLinkedDesignLayerFromRow. Resets the mode
back to 'embed' on completion or cancellation.
- openDesignLayerNostrSearch (the embed entry point) explicitly
sets mode = 'embed' on open so a previously-cancelled link
attempt doesn't pollute the next embed.
- The new addLinkedDesignLayerFromRow warms the resolver cache
with the just-fetched payload so the very first render is sync —
no placeholder flicker for the user who just clicked.
Toast copy is honest about the difference:
- embed: "Design imported as an editable object layer."
- link: "🔗 Linked — renders the latest version of the source on
each refresh."
Layer-options panel (refresh / pin / unlink / replace) for selected
linked layers is the next commit in this phase.
Implements the sync-placeholder + async-upgrade render pattern
from spec §8.1. Linked design layers now resolve their source from
relays on first render, with the safety rails wired in.
Render flow for kind: 'linked' layers:
1. Compute the cache key (address or pinned event id).
2. Walk DOM ancestors (data-linked-key markers) to detect
cycles (key already in chain) and depth-3 overshoots. Both
render an explicit visible placeholder rather than silently
no-op'ing, so the user sees why nothing painted.
3. Tag the host element with data-linked-key so deeper renders
can see this layer in their own ancestor walk.
4. Sync cache peek (linkedDesigns.peek) — if the source is
already in the session cache, render the resolved payload
immediately and skip the placeholder flicker.
5. Otherwise: show a "⟳ Loading linked design…" hint (or the
persisted fallbackPreview JPEG if the user opted in), kick
off the async resolver, and upgrade fitWrap when it returns.
Self-aborts via el.isConnected check if the host re-rendered
while we were fetching.
6. On resolve, cache lastResolvedAt + lastResolvedSize on the
layer for the upcoming layer-options panel.
7. On miss (source not on relays), show "🔗 Linked source not
found — refresh to retry" unless the layer's fallbackPreview
is already drawn.
Embedded path (kind: 'embedded' or legacy layers without kind)
unchanged — sync render from inline payload, same as before.
Two new helpers:
- _collectLinkedAncestorKeys(el): walks DOM upward collecting
data-linked-key values. Lets cycle / depth detection work
without threading a context object through every renderer.
- _sizeFromDesignPayload(payload): pulls a (w, h) hint from any
design payload (customart / deck-first-slide / pixel-art).
Used to set lastResolvedSize for the options panel.
The resolver IIFE gained peek() and keyFor() public helpers so
the renderer can do its sync cache check + key computation
without reaching for the underscore-prefixed internals.
No UI yet for creating linked layers — that's Phase 1c. With this
commit, a manually-crafted linked layer (e.g. via a debug console)
renders correctly. Cycles + depth-4 attempts render visible
"blocked" placeholders.
Adds the linkedDesigns IIFE alongside the existing purchase-vault
helpers. Resolves a layer's { ref, pinnedEventId } to its current
source payload via relay query, with all the safety rails the spec
calls out.
What's in this commit:
- _parseAddressCoord: parses NIP-01 "kind:pubkey:dTag" addresses
with hex-pubkey + numeric-kind validation. Returns null on
malformed input so a tampered host event can't crash the resolver.
- Session cache (Map keyed by address-or-eventId) with 5-minute
TTL per spec §7. Inflight-Promise dedupe so parallel resolves
for the same key share one network round-trip.
- _fetchPayload: queries the wider purchase-vault relay union
(user relays + DEFAULT_NOSTR_RELAYS) so linked sources are
reachable cross-domain. Pinned mode uses ids: [eventId] filter,
latest mode uses kinds + authors + '#d'. Picks the most-recent
event by created_at as a defensive measure for relays that
somehow returned more than one addressable match.
- Per-event content cap (256 KB / LINKED_DESIGN_MAX_EVENT_BYTES) —
oversized source events are rejected before parse.
- walkLinkedTree: recursive walker for the layer graph with
depth-3 cap, cycle-safe via visited Set keyed by cache key,
per-tree byte cap (1 MB / LINKED_DESIGN_MAX_TREE_BYTES) that
short-circuits the walk when exceeded. Decks: walks every
slide's layers too.
- walkContributors: convenience wrapper that returns the distinct
set of source pubkeys reachable through the linked-tree walk.
Backs the attribution `p`-tag emission in Phase 3, exposed now
so a Credits panel can also use it.
- clearCache: manual eviction, used by the per-layer Refresh
button + the upcoming pre-publish strip.
Internals (_parseAddressCoord, _cacheKey) are exposed so the
pure-Node unit tests in Phase 1d can hit them without a browser
environment.
No render-side wiring in this commit — that's Phase 1b. With this
commit only, calling `linkedDesigns.resolveLinked({ref: '30078:...'})
from the browser console resolves the source's payload, which is
the minimum building block for the render-time path.