@@ -8399,6 +8421,9 @@
label.textContent = p.label || 'Designed page';
}
tile.appendChild(label);
+ // Click the tile body to enter the per-page editor. Action
+ // buttons (delete/dup) stop propagation so they don't open.
+ tile.addEventListener('click', () => openBookPage(p.id));
grid.appendChild(tile);
});
attachBookPagesDnd();
@@ -8443,6 +8468,80 @@
});
}
+ // ---- Per-page editor (Phase B2) -----------------------------
+ //
+ // Click a tile in the Pages overview → open that page in the
+ // Custom Art canvas, bound to `book-page:`. The canvas
+ // is the existing #sheetWrap; in book mode we normally hide it
+ // but flip the `editing-book-page` body class to re-show it when
+ // a page is open. A small page-context bar above the canvas
+ // shows "Page N / M" + prev / next / Back to Pages.
+ function openBookPage(entryId) {
+ const book = ensureBookState();
+ const idx = book.pages.findIndex(p => p.id === entryId);
+ if (idx < 0) return;
+ const entry = book.pages[idx];
+ // Phase B2 supports designed pages only; reflow segments open
+ // in the chapter / markdown editor in C.5.
+ if (entry.type !== 'design') {
+ toastError?.('Reflow chapter editor lands in Phase C.5.');
+ return;
+ }
+ book.editingEntryId = entryId;
+ document.body.classList.add('editing-book-page');
+ clearLayerSelection?.();
+ // Default the editor tool to Select so users land on a familiar
+ // pointer; the V/A split + tool palette still works as ever.
+ try { setEditorTool('select'); } catch {}
+ syncBookPageEditBar();
+ syncInputs();
+ render();
+ }
+ function closeBookPage() {
+ const book = state.book;
+ if (book) book.editingEntryId = null;
+ document.body.classList.remove('editing-book-page');
+ clearLayerSelection?.();
+ syncBookPageEditBar();
+ syncInputs();
+ render();
+ }
+ function gotoBookPageRelative(delta) {
+ const book = state.book;
+ if (!book || !book.editingEntryId) return;
+ // Walk only designed-page entries — reflow segments aren't
+ // editable on the per-page canvas yet.
+ const designedIdxs = book.pages
+ .map((p, i) => p.type === 'design' ? i : -1)
+ .filter(i => i >= 0);
+ const here = book.pages.findIndex(p => p.id === book.editingEntryId);
+ const inDesigned = designedIdxs.indexOf(here);
+ if (inDesigned < 0) return;
+ const nextDesignedIdx = designedIdxs[inDesigned + delta];
+ if (nextDesignedIdx == null) return;
+ const nextEntry = book.pages[nextDesignedIdx];
+ openBookPage(nextEntry.id);
+ }
+ function syncBookPageEditBar() {
+ const bar = document.getElementById('bookPageEditBar');
+ const label = document.getElementById('bookPageEditLabel');
+ const prev = document.getElementById('bookPageEditPrev');
+ const next = document.getElementById('bookPageEditNext');
+ if (!bar) return;
+ const book = state.book;
+ const editingId = book?.editingEntryId;
+ const editing = !!editingId && state.templateMode === 'book';
+ bar.style.display = editing ? '' : 'none';
+ if (!editing) return;
+ const pages = book.pages;
+ const designed = pages.filter(p => p.type === 'design');
+ const idx = designed.findIndex(p => p.id === editingId);
+ const total = designed.length;
+ if (label) label.textContent = `Page ${idx + 1} / ${total}${(pages[idx]?.label || '') ? ' · ' + pages[idx].label : ''}`;
+ if (prev) prev.disabled = idx <= 0;
+ if (next) next.disabled = idx >= total - 1;
+ }
+
function setBookSubView(view) {
const pages = document.getElementById('bookPagesView');
const cover = document.getElementById('bookCoverView');
@@ -8566,11 +8665,19 @@
if (mode === 'jewel') return t.startsWith('jewel-');
if (mode === 'cover') return t === 'cover-trim' || t === 'back' || t === 'spine' || t === 'front';
// Book mode targets are scoped to whichever surface the editor
- // is bound to (a specific book-page, master, or cover panel).
- // The active-target plumbing for Phase B will narrow this further
- // by entry id; for now all book-* targets belong to book mode.
- if (mode === 'book') return t === 'book-page' || t === 'book-master'
- || t === 'book-cover-front' || t === 'book-cover-back' || t === 'book-spine';
+ // is bound to. Targets are entry-id qualified
+ // (`book-page:`, `book-master:`) so multiple
+ // pages can coexist in `state.layers` without leaking onto each
+ // other. The active editing entry's id is what we filter on.
+ if (mode === 'book') {
+ const editId = state.book?.editingEntryId;
+ if (editId) {
+ return t === `book-page:${editId}` || t === `book-master:${editId}`;
+ }
+ // No page being edited yet — Pages overview is showing, so no
+ // canvas layers should render.
+ return false;
+ }
return true;
}
@@ -9838,6 +9945,8 @@
document.body.classList.toggle('template-slide', state.templateMode === 'slide');
document.body.classList.toggle('template-deck', state.templateMode === 'deck');
document.body.classList.toggle('template-book', state.templateMode === 'book');
+ document.body.classList.toggle('editing-book-page',
+ state.templateMode === 'book' && !!state.book?.editingEntryId);
document.body.classList.toggle('editing-deck-slide', !!state.deck && state.deck.editingIndex !== null);
document.body.classList.toggle('hide-guides', !state.showGuides);
document.body.classList.toggle('hide-bleed', !state.showBleed);
@@ -10310,6 +10419,12 @@
}
function currentDrawTarget() {
if (state.appMode === 'designer') return 'designer-disc';
+ // Book mode: when a specific page is open in the editor, new
+ // layers land on that page's entry-qualified target so they
+ // stay scoped to one page across the manifest.
+ if (state.templateMode === 'book' && state.book?.editingEntryId) {
+ return `book-page:${state.book.editingEntryId}`;
+ }
return primaryTargetForMode(state.templateMode || 'cover');
}
@@ -12540,6 +12655,7 @@
updateBodyClasses();
renderDeckSorter();
renderBookPagesGrid();
+ syncBookPageEditBar();
// Only repaint the cover preview when its view is the active
// sub-view — saves work on every render() in book mode.
if (document.getElementById('bookCoverView')?.style.display !== 'none'
@@ -31465,6 +31581,9 @@
on('bookAddTenBtn', 'click', addBookTenPages);
on('bookViewTogglePages', 'click', () => setBookSubView('pages'));
on('bookViewToggleCover', 'click', () => setBookSubView('cover'));
+ on('bookPageEditBack', 'click', closeBookPage);
+ on('bookPageEditPrev', 'click', () => gotoBookPageRelative(-1));
+ on('bookPageEditNext', 'click', () => gotoBookPageRelative(1));
// Book Designer — Document settings modal.
on('bookDocumentSettingsBtn', 'click', openBookDocumentSettings);
From 01ecb8a85bfe1eede7070156b6d8947c2d4bce4c Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 2 Jun 2026 14:55:42 -0400
Subject: [PATCH 09/64] book: stop layer-creation paths from bouncing book mode
to cover
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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:, book-master:, 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
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Master pages
+
Masters are reusable page templates — running headers, page numbers, margin guides, background art. Pages that inherit a master render its layers underneath their own. The default master is applied to new pages automatically; per-page overrides come from the Pages overview.
+
+
+
+
+
+
+
+
@@ -8138,6 +8175,11 @@
},
bookSize: 'US Trade 6×9',
defaultMasterRef: null,
+ // Local id of the master applied to new pages on creation.
+ // Until D1 publishes masters as Nostr events, this is how the
+ // book remembers which master is "default" (defaultMasterRef
+ // gets filled in at publish time).
+ defaultMasterId: null,
textStyles: defaultBookTextStyles(),
};
}
@@ -8294,6 +8336,148 @@
render();
}
+ // Stable opaque id for masters. Same shape as page entry ids
+ // so id collisions across pages and masters stay impossible.
+ function makeBookMasterId() {
+ return 'master:' + Math.random().toString(36).slice(2, 10);
+ }
+
+ // Add a fresh master entry. Phase C ships masters as local-only
+ // — no Nostr publish yet, so the entry carries just an id +
+ // label. Layers for the master live in state.layers under
+ // `book-master:`. The first master created becomes the
+ // default automatically.
+ function addBookMaster(label) {
+ const book = ensureBookState();
+ pushHistory('book: add master');
+ const m = { id: makeBookMasterId(), label: label || 'Body pages' };
+ book.masters.push(m);
+ if (!book.spec.defaultMasterId) book.spec.defaultMasterId = m.id;
+ renderBookMastersList();
+ syncInputs();
+ render();
+ return m;
+ }
+ function renameBookMaster(id, label) {
+ const m = state.book?.masters?.find(x => x.id === id);
+ if (!m) return;
+ pushHistoryDebounced(`master:${id}:rename`, 'book: rename master', 400);
+ m.label = label || '';
+ renderBookMastersList();
+ render();
+ }
+ function deleteBookMaster(id) {
+ const book = state.book;
+ if (!book) return;
+ const idx = book.masters.findIndex(m => m.id === id);
+ if (idx < 0) return;
+ pushHistory('book: delete master');
+ book.masters.splice(idx, 1);
+ // Page entries that referenced this master drop back to default.
+ for (const p of book.pages) {
+ if (p.masterId === id) p.masterId = null;
+ }
+ // Drop the layers that lived on this master — they're orphans now.
+ state.layers = state.layers.filter(l => l.target !== `book-master:${id}`);
+ if (book.spec.defaultMasterId === id) {
+ // Promote next remaining master to default, if any.
+ book.spec.defaultMasterId = book.masters[0]?.id || null;
+ }
+ renderBookMastersList();
+ syncInputs();
+ render();
+ }
+ function setBookDefaultMaster(id) {
+ const book = state.book;
+ if (!book) return;
+ if (book.spec.defaultMasterId === id) return;
+ pushHistory('book: set default master');
+ book.spec.defaultMasterId = id;
+ renderBookMastersList();
+ render();
+ }
+
+ // Masters modal lifecycle.
+ function openBookMastersModal() {
+ const modal = document.getElementById('bookMastersModal');
+ if (!modal) return;
+ ensureBookState();
+ renderBookMastersList();
+ modal.style.display = 'flex';
+ modal.setAttribute('aria-hidden', 'false');
+ if (typeof bringModalToFront === 'function') bringModalToFront(modal);
+ }
+ function closeBookMastersModal() {
+ const modal = document.getElementById('bookMastersModal');
+ if (!modal) return;
+ modal.style.display = 'none';
+ modal.setAttribute('aria-hidden', 'true');
+ }
+ function renderBookMastersList() {
+ const wrap = document.getElementById('bookMastersList');
+ if (!wrap) return;
+ wrap.innerHTML = '';
+ const book = state.book;
+ const masters = book?.masters || [];
+ if (!masters.length) {
+ const empty = document.createElement('p');
+ empty.className = 'hint';
+ empty.style.margin = '0';
+ empty.textContent = 'No masters yet. Add one to start sharing layers across pages.';
+ wrap.appendChild(empty);
+ return;
+ }
+ const defaultId = book.spec?.defaultMasterId;
+ masters.forEach(m => {
+ const row = document.createElement('div');
+ row.className = 'bookMasterRow' + (m.id === defaultId ? ' isDefault' : '');
+ const label = document.createElement('div');
+ label.className = 'bookMasterRowLabel';
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.value = m.label || '';
+ input.placeholder = 'Master name';
+ input.addEventListener('input', () => renameBookMaster(m.id, input.value));
+ label.appendChild(input);
+ row.appendChild(label);
+ if (m.id === defaultId) {
+ const badge = document.createElement('span');
+ badge.className = 'bookMasterRowDefault';
+ badge.textContent = 'Default';
+ row.appendChild(badge);
+ }
+ const actions = document.createElement('div');
+ actions.className = 'bookMasterRowActions';
+ const editBtn = document.createElement('button');
+ editBtn.type = 'button';
+ editBtn.title = 'Edit master layers (Phase C2)';
+ editBtn.textContent = 'Edit';
+ editBtn.disabled = true; // C2 wires this
+ actions.appendChild(editBtn);
+ if (m.id !== defaultId) {
+ const defBtn = document.createElement('button');
+ defBtn.type = 'button';
+ defBtn.title = 'Set as default for new pages';
+ defBtn.textContent = 'Set default';
+ defBtn.addEventListener('click', () => setBookDefaultMaster(m.id));
+ actions.appendChild(defBtn);
+ }
+ const delBtn = document.createElement('button');
+ delBtn.type = 'button';
+ delBtn.className = 'danger';
+ delBtn.title = 'Remove master + its layers';
+ delBtn.textContent = '×';
+ delBtn.addEventListener('click', () => {
+ if (confirm(`Delete master "${m.label || 'untitled'}" and any layers on it?`)) {
+ deleteBookMaster(m.id);
+ }
+ });
+ actions.appendChild(delBtn);
+ row.appendChild(actions);
+ wrap.appendChild(row);
+ });
+ }
+
// Stable opaque id for manifest entries. The id stays put across
// reorders, so anchor refs (`anchor.entryId`) survive shuffling
// the page list. Format mirrors Linked Designs' provisional ids.
@@ -31711,6 +31895,16 @@
on('bookPageEditPrev', 'click', () => gotoBookPageRelative(-1));
on('bookPageEditNext', 'click', () => gotoBookPageRelative(1));
+ // Book Designer — Masters modal.
+ on('bookManageMastersBtn', 'click', openBookMastersModal);
+ on('bookMastersCloseBtn', 'click', closeBookMastersModal);
+ on('bookMastersDoneBtn', 'click', closeBookMastersModal);
+ on('bookMastersAddBtn', 'click', () => addBookMaster('Body pages'));
+ const _bookMastersModal = $('bookMastersModal');
+ if (_bookMastersModal) _bookMastersModal.addEventListener('click', (e) => {
+ if (e.target === _bookMastersModal) closeBookMastersModal();
+ });
+
// Book Designer — Document settings modal.
on('bookDocumentSettingsBtn', 'click', openBookDocumentSettings);
on('bookDocumentSettingsCloseBtn', 'click', closeBookDocumentSettings);
@@ -32026,6 +32220,8 @@
if (pubConfirmM && pubConfirmM.getAttribute('aria-hidden') === 'false') { closePublishConfirmModal(); return; }
const bookDocM = $('bookDocumentSettingsModal');
if (bookDocM && bookDocM.getAttribute('aria-hidden') === 'false') { closeBookDocumentSettings(); return; }
+ const bookMastersM = $('bookMastersModal');
+ if (bookMastersM && bookMastersM.getAttribute('aria-hidden') === 'false') { closeBookMastersModal(); return; }
// Esc while editing a book page returns to the Pages overview.
if (state.templateMode === 'book' && state.book?.editingEntryId) {
closeBookPage();
From 5796f5d5bc2353ad9471d58467dc0cf0f8064bec Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 2 Jun 2026 15:20:10 -0400
Subject: [PATCH 16/64] book(C2): master-edit mode (canvas binds to
book-master:)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
src/index.html | 137 +++++++++++++++++++++++++++++++++++--------------
1 file changed, 99 insertions(+), 38 deletions(-)
diff --git a/src/index.html b/src/index.html
index 5065f6f..0de60d0 100644
--- a/src/index.html
+++ b/src/index.html
@@ -8450,9 +8450,9 @@
actions.className = 'bookMasterRowActions';
const editBtn = document.createElement('button');
editBtn.type = 'button';
- editBtn.title = 'Edit master layers (Phase C2)';
+ editBtn.title = 'Edit master layers on the canvas';
editBtn.textContent = 'Edit';
- editBtn.disabled = true; // C2 wires this
+ editBtn.addEventListener('click', () => openBookMaster(m.id));
actions.appendChild(editBtn);
if (m.id !== defaultId) {
const defBtn = document.createElement('button');
@@ -8753,10 +8753,9 @@
return;
}
book.editingEntryId = entryId;
+ book.editingMasterId = null; // only one editor open at a time
document.body.classList.add('editing-book-page');
clearLayerSelection?.();
- // Default the editor tool to Select so users land on a familiar
- // pointer; the V/A split + tool palette still works as ever.
try { setEditorTool('select'); } catch {}
syncBookPageEditBar();
syncInputs();
@@ -8771,6 +8770,35 @@
syncInputs();
render();
}
+ // Master-edit mode: opens a master on the same canvas as the
+ // per-page editor. Same body class so the CSS that hides
+ // Pages overview / shows the sheet + tool palette applies; the
+ // edit-bar label and Back button swap to master-flavored
+ // copy. Closing returns to the Masters modal.
+ function openBookMaster(masterId) {
+ const book = ensureBookState();
+ const m = book.masters.find(x => x.id === masterId);
+ if (!m) return;
+ book.editingMasterId = masterId;
+ book.editingEntryId = null;
+ closeBookMastersModal();
+ document.body.classList.add('editing-book-page');
+ clearLayerSelection?.();
+ try { setEditorTool('select'); } catch {}
+ syncBookPageEditBar();
+ syncInputs();
+ render();
+ }
+ function closeBookMaster() {
+ const book = state.book;
+ if (book) book.editingMasterId = null;
+ document.body.classList.remove('editing-book-page');
+ clearLayerSelection?.();
+ syncBookPageEditBar();
+ syncInputs();
+ render();
+ openBookMastersModal();
+ }
function gotoBookPageRelative(delta) {
const book = state.book;
if (!book || !book.editingEntryId) return;
@@ -8792,17 +8820,31 @@
const label = document.getElementById('bookPageEditLabel');
const prev = document.getElementById('bookPageEditPrev');
const next = document.getElementById('bookPageEditNext');
+ const back = document.getElementById('bookPageEditBack');
if (!bar) return;
const book = state.book;
- const editingId = book?.editingEntryId;
- const editing = !!editingId && state.templateMode === 'book';
- bar.style.display = editing ? '' : 'none';
- if (!editing) return;
- const pages = book.pages;
- const designed = pages.filter(p => p.type === 'design');
- const idx = designed.findIndex(p => p.id === editingId);
+ const inBook = state.templateMode === 'book';
+ const editingPage = inBook && !!book?.editingEntryId;
+ const editingMaster = inBook && !!book?.editingMasterId;
+ bar.style.display = (editingPage || editingMaster) ? '' : 'none';
+ if (!editingPage && !editingMaster) return;
+ if (editingMaster) {
+ const m = book.masters.find(x => x.id === book.editingMasterId);
+ if (label) label.textContent = `Master: ${m?.label || 'Untitled'}`;
+ if (back) back.textContent = '← Back to masters';
+ if (prev) prev.style.display = 'none';
+ if (next) next.style.display = 'none';
+ return;
+ }
+ // editingPage path
+ if (prev) prev.style.display = '';
+ if (next) next.style.display = '';
+ if (back) back.textContent = '← Back to Pages';
+ const designed = book.pages.filter(p => p.type === 'design');
+ const idx = designed.findIndex(p => p.id === book.editingEntryId);
const total = designed.length;
- if (label) label.textContent = `Page ${idx + 1} / ${total}${(pages[idx]?.label || '') ? ' · ' + pages[idx].label : ''}`;
+ const entry = designed[idx];
+ if (label) label.textContent = `Page ${idx + 1} / ${total}${(entry?.label || '') ? ' · ' + entry.label : ''}`;
if (prev) prev.disabled = idx <= 0;
if (next) next.disabled = idx >= total - 1;
}
@@ -8930,17 +8972,18 @@
if (mode === 'jewel') return t.startsWith('jewel-');
if (mode === 'cover') return t === 'cover-trim' || t === 'back' || t === 'spine' || t === 'front';
// Book mode targets are scoped to whichever surface the editor
- // is bound to. Targets are entry-id qualified
- // (`book-page:`, `book-master:`) so multiple
- // pages can coexist in `state.layers` without leaking onto each
- // other. The active editing entry's id is what we filter on.
+ // is bound to. Targets are id-qualified (`book-page:`
+ // for pages, `book-master:` for masters) so multiple
+ // surfaces can coexist in `state.layers` without leaking onto
+ // each other.
if (mode === 'book') {
- const editId = state.book?.editingEntryId;
- if (editId) {
- return t === `book-page:${editId}` || t === `book-master:${editId}`;
+ if (state.book?.editingMasterId) {
+ return t === `book-master:${state.book.editingMasterId}`;
}
- // No page being edited yet — Pages overview is showing, so no
- // canvas layers should render.
+ if (state.book?.editingEntryId) {
+ return t === `book-page:${state.book.editingEntryId}`;
+ }
+ // Pages overview (no surface open) → no canvas layers render.
return false;
}
return true;
@@ -8972,9 +9015,14 @@
{ key: 'canvas', label: 'Canvas' }
];
if (state.templateMode === 'book') {
- // Book mode: the layers panel scopes to whichever page is
- // open in the per-page editor. No page open → no panels
- // (Pages overview shows tiles, not layer rows).
+ // Book mode: the layers panel scopes to whichever surface is
+ // open in the editor. Pages overview / masters modal show no
+ // canvas layers, so an empty list is correct there.
+ const masterId = state.book?.editingMasterId;
+ if (masterId) {
+ const m = (state.book?.masters || []).find(x => x.id === masterId);
+ return [{ key: `book-master:${masterId}`, label: `Master: ${m?.label || 'Untitled'}` }];
+ }
const editId = state.book?.editingEntryId;
if (editId) {
const idx = (state.book?.pages || []).findIndex(p => p.id === editId);
@@ -10223,7 +10271,7 @@
document.body.classList.toggle('template-deck', state.templateMode === 'deck');
document.body.classList.toggle('template-book', state.templateMode === 'book');
document.body.classList.toggle('editing-book-page',
- state.templateMode === 'book' && !!state.book?.editingEntryId);
+ state.templateMode === 'book' && (!!state.book?.editingEntryId || !!state.book?.editingMasterId));
document.body.classList.toggle('editing-deck-slide', !!state.deck && state.deck.editingIndex !== null);
document.body.classList.toggle('hide-guides', !state.showGuides);
document.body.classList.toggle('hide-bleed', !state.showBleed);
@@ -10696,11 +10744,12 @@
}
function currentDrawTarget() {
if (state.appMode === 'designer') return 'designer-disc';
- // Book mode: when a specific page is open in the editor, new
- // layers land on that page's entry-qualified target so they
- // stay scoped to one page across the manifest.
- if (state.templateMode === 'book' && state.book?.editingEntryId) {
- return `book-page:${state.book.editingEntryId}`;
+ // Book mode: new layers land on the active editing surface
+ // (per-page or per-master) so they stay scoped correctly
+ // across the manifest.
+ if (state.templateMode === 'book') {
+ if (state.book?.editingMasterId) return `book-master:${state.book.editingMasterId}`;
+ if (state.book?.editingEntryId) return `book-page:${state.book.editingEntryId}`;
}
return primaryTargetForMode(state.templateMode || 'cover');
}
@@ -29240,15 +29289,18 @@
select.innerHTML = ``;
} else if (state.templateMode === 'book') {
// Book mode: the only target a new layer should land on is
- // the currently-open page (Phase B). Other surfaces (cover,
- // masters) get their own routes in A5/C/D1. If no page is
- // open, fall back to a stub so the layers panel doesn't
- // appear broken.
+ // whichever surface is currently open in the editor — a
+ // specific page or a master. Pages overview / Masters modal
+ // show no canvas layers, so a stub keeps the select sane.
+ const masterId = state.book?.editingMasterId;
const editId = state.book?.editingEntryId;
- if (editId) {
+ if (masterId) {
+ const m = (state.book?.masters || []).find(x => x.id === masterId);
+ select.innerHTML = ``;
+ } else if (editId) {
select.innerHTML = ``;
} else {
- select.innerHTML = ``;
+ select.innerHTML = ``;
}
} else {
select.innerHTML = `
@@ -31891,7 +31943,11 @@
on('bookAddTenBtn', 'click', addBookTenPages);
on('bookViewTogglePages', 'click', () => setBookSubView('pages'));
on('bookViewToggleCover', 'click', () => setBookSubView('cover'));
- on('bookPageEditBack', 'click', closeBookPage);
+ on('bookPageEditBack', 'click', () => {
+ // Same back-button serves both per-page and per-master modes.
+ if (state.book?.editingMasterId) closeBookMaster();
+ else closeBookPage();
+ });
on('bookPageEditPrev', 'click', () => gotoBookPageRelative(-1));
on('bookPageEditNext', 'click', () => gotoBookPageRelative(1));
@@ -32222,7 +32278,12 @@
if (bookDocM && bookDocM.getAttribute('aria-hidden') === 'false') { closeBookDocumentSettings(); return; }
const bookMastersM = $('bookMastersModal');
if (bookMastersM && bookMastersM.getAttribute('aria-hidden') === 'false') { closeBookMastersModal(); return; }
- // Esc while editing a book page returns to the Pages overview.
+ // Esc while editing a book surface returns to the right home:
+ // master-edit → masters modal, page-edit → Pages overview.
+ if (state.templateMode === 'book' && state.book?.editingMasterId) {
+ closeBookMaster();
+ return;
+ }
if (state.templateMode === 'book' && state.book?.editingEntryId) {
closeBookPage();
return;
From f28f0d92e8e959e007bd3d9b8dd415900ddd0198 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 2 Jun 2026 15:23:13 -0400
Subject: [PATCH 17/64] book(C3): master-layer underlay on page editor +
composited thumbnails
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
src/index.html | 75 +++++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 68 insertions(+), 7 deletions(-)
diff --git a/src/index.html b/src/index.html
index 0de60d0..2bb22f6 100644
--- a/src/index.html
+++ b/src/index.html
@@ -1581,6 +1581,15 @@
position: absolute; top: 6px; left: 8px;
font-size: .72rem; color: var(--muted); font-weight: 600;
}
+ /* Master layers shown as an underlay while editing a page —
+ click-through and slightly faded so the page layers above
+ are obviously the editable ones. Master-edit mode (the
+ master IS the editing target) keeps them opaque +
+ interactive via the :not(.editing-book-master) guard. */
+ body.editing-book-page:not(.editing-book-master) .freeLayer[data-target^="book-master:"] {
+ pointer-events: none;
+ opacity: 0.55;
+ }
.bookMasterRow {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border: 1px solid var(--line);
@@ -8336,6 +8345,19 @@
render();
}
+ // Resolve a page's effective master — its own `masterId`
+ // override, falling back to the book spec's default master.
+ function effectiveMasterIdForPage(entryOrId) {
+ const book = state.book;
+ if (!book) return null;
+ const entry = (typeof entryOrId === 'string')
+ ? book.pages.find(p => p.id === entryOrId)
+ : entryOrId;
+ if (!entry) return book.spec?.defaultMasterId || null;
+ if (entry.masterId === null) return null; // explicit "no master"
+ return entry.masterId || book.spec?.defaultMasterId || null;
+ }
+
// Stable opaque id for masters. Same shape as page entry ids
// so id collisions across pages and masters stay impossible.
function makeBookMasterId() {
@@ -8627,9 +8649,20 @@
thumb.className = 'bookPageTileThumb';
tile.appendChild(thumb);
const pageTarget = `book-page:${p.id}`;
- const pageLayers = (state.layers || [])
- .filter(l => l.target === pageTarget && !l.hidden)
- .map(l => ({ ...l, target: 'canvas' })); // remap for preview helper
+ const masterId = effectiveMasterIdForPage(p);
+ const masterTarget = masterId ? `book-master:${masterId}` : null;
+ // Composite master layers first (so they render below
+ // the page's own layers) then the page's own. Both get
+ // their target remapped to 'canvas' so the preview
+ // helper's flat-canvas filter accepts them.
+ const masterLayers = masterTarget
+ ? (state.layers || []).filter(l => l.target === masterTarget && !l.hidden)
+ : [];
+ const pageLayers = (state.layers || []).filter(l => l.target === pageTarget && !l.hidden);
+ const composed = [
+ ...masterLayers.map((l, i) => ({ ...l, target: 'canvas', z: -1e6 + i })),
+ ...pageLayers.map(l => ({ ...l, target: 'canvas' })),
+ ];
try {
renderCustomArtPreviewDOM(thumb, {
customArt: {
@@ -8637,7 +8670,7 @@
height: trimH * 96,
background: p.background || book.spec?.background || '#ffffff',
},
- layers: pageLayers,
+ layers: composed,
}, { compact: true });
} catch (err) {
console.warn('book page thumb render failed', err);
@@ -8975,13 +9008,18 @@
// is bound to. Targets are id-qualified (`book-page:`
// for pages, `book-master:` for masters) so multiple
// surfaces can coexist in `state.layers` without leaking onto
- // each other.
+ // each other. Page-edit mode also pulls in the page's
+ // effective master so its layers render underneath (the
+ // renderer marks them non-interactive via CSS).
if (mode === 'book') {
if (state.book?.editingMasterId) {
return t === `book-master:${state.book.editingMasterId}`;
}
if (state.book?.editingEntryId) {
- return t === `book-page:${state.book.editingEntryId}`;
+ if (t === `book-page:${state.book.editingEntryId}`) return true;
+ const masterId = effectiveMasterIdForPage(state.book.editingEntryId);
+ if (masterId && t === `book-master:${masterId}`) return true;
+ return false;
}
// Pages overview (no surface open) → no canvas layers render.
return false;
@@ -10270,8 +10308,16 @@
document.body.classList.toggle('template-slide', state.templateMode === 'slide');
document.body.classList.toggle('template-deck', state.templateMode === 'deck');
document.body.classList.toggle('template-book', state.templateMode === 'book');
+ // 'editing-book-page' is the shared "canvas is open in book
+ // mode" class — covers both page-edit and master-edit so the
+ // sheet + tool palette unhide. The more specific
+ // 'editing-book-master' class lets CSS target master-edit
+ // alone (e.g. don't fade master layers when *they're* the
+ // editing target).
document.body.classList.toggle('editing-book-page',
state.templateMode === 'book' && (!!state.book?.editingEntryId || !!state.book?.editingMasterId));
+ document.body.classList.toggle('editing-book-master',
+ state.templateMode === 'book' && !!state.book?.editingMasterId);
document.body.classList.toggle('editing-deck-slide', !!state.deck && state.deck.editingIndex !== null);
document.body.classList.toggle('hide-guides', !state.showGuides);
document.body.classList.toggle('hide-bleed', !state.showBleed);
@@ -18122,7 +18168,18 @@
const designerOverlay = $('designerLayerOverlay');
overlay.innerHTML = '';
if (designerOverlay) designerOverlay.innerHTML = '';
- const ordered = [...state.layers].sort((a, b) => (a.z || 0) - (b.z || 0));
+ // In book page-edit mode, master layers render *under* the
+ // page's own layers regardless of their z values. We offset
+ // their effective sort key by a large negative number so they
+ // always come first in DOM order (which equals lowest visual
+ // stack).
+ const _bookPageEdit = state.templateMode === 'book' && !!state.book?.editingEntryId;
+ const effectiveZ = (layer) => {
+ const z = Number(layer.z) || 0;
+ if (_bookPageEdit && String(layer.target || '').startsWith('book-master:')) return z - 1e6;
+ return z;
+ };
+ const ordered = [...state.layers].sort((a, b) => effectiveZ(a) - effectiveZ(b));
for (const layer of ordered) {
const isDesignerLayer = layer.target === 'designer-disc';
@@ -18139,6 +18196,10 @@
const el = document.createElement('div');
el.className = `freeLayer${layerSelected ? ' selected' : ''}${multiSelected ? ' multiSelected' : ''}`;
el.dataset.layerId = layer.id;
+ // Surface the layer's target on the wrapper so CSS can
+ // style master underlays (.freeLayer[data-target^="book-master:"])
+ // without needing the renderer to know about book mode.
+ if (layer.target) el.dataset.target = layer.target;
if (layer.clip && layer.clip.kind !== 'none' && resolveClipSourceShape(layer, state.layers)) {
el.style.clipPath = `url(#${clipIdForLayer(layer)})`;
}
From 4e75db99b8321b11c09f04540fc669a1a5ef906c Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 2 Jun 2026 21:12:19 -0400
Subject: [PATCH 18/64] book(C4): text-frame layer kind (structural rectangle
on masters)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
src/index.html | 60 +++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 59 insertions(+), 1 deletion(-)
diff --git a/src/index.html b/src/index.html
index 2bb22f6..e32cb26 100644
--- a/src/index.html
+++ b/src/index.html
@@ -4416,6 +4416,7 @@
+
@@ -8409,6 +8410,41 @@
syncInputs();
render();
}
+ // Add a text-frame layer (Phase C4 structural placeholder) to
+ // the master currently open in the editor. Sized to fill the
+ // trim with a small inset so it reads as a "type area" out of
+ // the box; the user can resize / move like any other shape.
+ function addBookTextFrameToActiveMaster() {
+ const book = state.book;
+ if (!book || !book.editingMasterId) return;
+ const target = `book-master:${book.editingMasterId}`;
+ const s = book.spec;
+ const trimW = Number(s.trimW) || 6;
+ const trimH = Number(s.trimH) || 9;
+ const bleed = Number(s.bleed) || 0;
+ // Type area = trim minus a 0.75 in margin all around (a
+ // reasonable book-page default; the user can drag it tighter
+ // or wider). Coordinates are inches in the canvas space; the
+ // canvas covers (trim + 2 × bleed) in each axis.
+ const margin = 0.75;
+ const x = bleed + margin;
+ const y = bleed + margin;
+ const w = Math.max(0.5, trimW - margin * 2);
+ const h = Math.max(0.5, trimH - margin * 2);
+ pushHistory('book: add text frame');
+ addLayer({
+ id: makeLayerId(),
+ type: 'shape',
+ name: 'Text frame',
+ target,
+ x, y, w, h,
+ rotate: 0, opacity: 1, z: nextLayerZ(),
+ shape: defaultShapeData('text-frame'),
+ fill: { type: 'none' },
+ stroke: { type: 'none', color: '#60a5fa', width: 0, dash: 'solid' },
+ });
+ }
+
function setBookDefaultMaster(id) {
const book = state.book;
if (!book) return;
@@ -8861,17 +8897,20 @@
const editingMaster = inBook && !!book?.editingMasterId;
bar.style.display = (editingPage || editingMaster) ? '' : 'none';
if (!editingPage && !editingMaster) return;
+ const tfBtn = document.getElementById('bookMasterAddTextFrame');
if (editingMaster) {
const m = book.masters.find(x => x.id === book.editingMasterId);
if (label) label.textContent = `Master: ${m?.label || 'Untitled'}`;
if (back) back.textContent = '← Back to masters';
if (prev) prev.style.display = 'none';
if (next) next.style.display = 'none';
+ if (tfBtn) tfBtn.style.display = '';
return;
}
// editingPage path
if (prev) prev.style.display = '';
if (next) next.style.display = '';
+ if (tfBtn) tfBtn.style.display = 'none';
if (back) back.textContent = '← Back to Pages';
const designed = book.pages.filter(p => p.type === 'design');
const idx = designed.findIndex(p => p.id === book.editingEntryId);
@@ -17082,10 +17121,19 @@
if (kind === 'star') { out.points = 5; out.innerRadiusRatio = 0.45; }
if (kind === 'line') { out.x1 = 0; out.y1 = 50; out.x2 = 100; out.y2 = 50; out.strokeWidth = 6; }
if (kind === 'path') {
- // Default heart shape so a fresh path layer is immediately useful.
out.d = 'M 50 90 C 0 60 0 20 30 10 C 40 5 50 20 50 30 C 50 20 60 5 70 10 C 100 20 100 60 50 90 Z';
out.viewBox = { x: 0, y: 0, w: 100, h: 100 };
}
+ if (kind === 'text-frame') {
+ // Phase C4 — structural layer only. The pagination engine
+ // in C.5 pours markdown into this frame; until then it
+ // renders as a labelled dashed rectangle on the master so
+ // users can position + size it like any other shape.
+ out.padding = 0.05;
+ out.verticalAlign = 'top';
+ out.columns = 1;
+ out.styleOverrides = null;
+ }
return out;
}
@@ -17531,6 +17579,15 @@
let body = '';
if (kind === 'rect') {
body = `
`;
+ } else if (kind === 'text-frame') {
+ // Phase C4 structural rectangle. Dashed accent outline +
+ // centered "Text frame" label; transparent fill. The
+ // pagination engine in C.5 will replace this render path
+ // for frames that have markdown poured into them — at that
+ // point the dashed outline only renders in the editor.
+ const dash = Math.max(0.8, Math.min(4, 100 / 40));
+ body = `
`
+ + `
Text frame`;
} else if (kind === 'rounded-rect') {
const r = Math.max(0, Math.min(50, Number(shape.cornerRadius) || 0));
body = `
`;
@@ -32011,6 +32068,7 @@
});
on('bookPageEditPrev', 'click', () => gotoBookPageRelative(-1));
on('bookPageEditNext', 'click', () => gotoBookPageRelative(1));
+ on('bookMasterAddTextFrame', 'click', () => addBookTextFrameToActiveMaster());
// Book Designer — Masters modal.
on('bookManageMastersBtn', 'click', openBookMastersModal);
From f41e72e942b09df4ee1948924d7867bd3c0380ff Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 2 Jun 2026 21:17:38 -0400
Subject: [PATCH 19/64] book(C.5.1): vendor marked + Add chapter button +
markdown editor
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 `marked@14.1.4` (~36 KB minified, MIT) as
src/vendor/marked.min.js. Loaded as a regular
+