diff --git a/src/index.html b/src/index.html
index 517380f..5697578 100644
--- a/src/index.html
+++ b/src/index.html
@@ -10531,6 +10531,36 @@
return summary;
}
+ // Manifest dirty tracking. After a successful manifest publish
+ // we snapshot the serialised payload onto state.book.lastPublishedManifestSerial;
+ // isBookManifestDirty() compares the current serialisation to
+ // that snapshot. Used by the sidebar hint so the user knows when
+ // the manifest needs another publish.
+ function isBookManifestDirty() {
+ const book = state.book;
+ if (!book) return false;
+ const snapshot = book.lastPublishedManifestSerial;
+ if (!snapshot) {
+ // No snapshot but we have refs → counted as dirty so the
+ // sidebar prods the user to publish for the first time.
+ const hasAnyRef = !!book.cover?.ref
+ || (book.masters || []).some(m => m.ref)
+ || (book.pages || []).some(p => p.ref || p.textRef);
+ return hasAnyRef;
+ }
+ try {
+ const current = JSON.stringify(bookManifestPayload());
+ return current !== snapshot;
+ } catch {
+ return false;
+ }
+ }
+ function markBookManifestClean() {
+ const book = state.book;
+ if (!book) return;
+ try { book.lastPublishedManifestSerial = JSON.stringify(bookManifestPayload()); } catch {}
+ }
+
// Build the casewrap-book manifest payload. Lean — references
// sibling page / master / cover / chapter events via {kind, pubkey,
// dTag} coords instead of inlining their layers. Phase D1 hands
@@ -11157,11 +11187,24 @@
? `derived from ${pageCount} page${pageCount === 1 ? '' : 's'}`
: 'manual';
const fmt = (n) => Number(n).toFixed(n % 1 === 0 ? 0 : 3);
+ // Manifest dirty hint — sidecars publish independently, so the
+ // manifest can be "out of sync" with the per-page events. Tell
+ // the user explicitly so they don't think Publish-page was the
+ // last step.
+ let dirtyHint = '';
+ try {
+ if (isBookManifestDirty()) {
+ dirtyHint = '
⚠ Manifest has unsaved changes — Publish to update the casewrap-book event.';
+ } else if (state.book.lastPublishedManifestSerial) {
+ dirtyHint = '
✓ Manifest is in sync with the published casewrap-book event.';
+ }
+ } catch {}
el.innerHTML = ''
+ `${s.bookSize || 'Custom'} · ${fmt(s.trimW)} × ${fmt(s.trimH)} in`
+ `
Bleed ${fmt(s.bleed)} in · Paper ${fmt(s.paperThickness)} in/page`
+ `
Spine ${fmt(spineW)} in (${spineNote})`
- + `
${pageCount} page${pageCount === 1 ? '' : 's'} · ${(state.book.masters || []).length} master${(state.book.masters || []).length === 1 ? '' : 's'}`;
+ + `
${pageCount} page${pageCount === 1 ? '' : 's'} · ${(state.book.masters || []).length} master${(state.book.masters || []).length === 1 ? '' : 's'}`
+ + dirtyHint;
syncBookVisibilityChip();
}
// Sync the Public/Private chip in the book sidebar from
@@ -21648,6 +21691,9 @@
const signed = await publishEncryptedDesign(payload);
const label = isEditing() ? `Updated "${state.meta.title}" (private)` : `Private "${state.meta.title}"`;
await finalizePublishedTemplateEvent(signed, { label });
+ if (state.templateMode === 'book' && typeof markBookManifestClean === 'function') {
+ try { markBookManifestClean(); } catch {}
+ }
if (isEditing()) exitEditMode();
return signed;
}
@@ -21677,6 +21723,12 @@
const signed = await publishCommunityEvent(evt);
const label = isEditing() ? `Updated "${state.meta.title}"` : `Template "${state.meta.title}"`;
await finalizePublishedTemplateEvent(signed, { label });
+ // Book manifest: snapshot the published serialisation so the
+ // sidebar's dirty hint flips to "Saved" until the user makes
+ // another manifest-affecting change.
+ if (state.templateMode === 'book' && typeof markBookManifestClean === 'function') {
+ try { markBookManifestClean(); } catch {}
+ }
if (isEditing()) exitEditMode();
return signed;
}
@@ -29098,6 +29150,25 @@
else if (row.mode === 'customart' || row.mode === 'slide') renderCustomArtPreviewDOM(previewWrap, payload, { compact: true });
else if (row.mode === 'deck') renderDeckPreviewDOM(previewWrap, payload, { compact: true });
else if (row.mode === 'pixelart') renderPixelArtPreviewDOM(previewWrap, payload, { compact: true });
+ else if (row.mode === 'book') {
+ // Book manifests don't ship layer data — render a
+ // chapbook-shaped card with title + entry counts so the
+ // feed has something to display instead of "Preview
+ // unavailable".
+ const spec = payload.book?.spec || {};
+ const pages = payload.book?.pages || [];
+ const designed = pages.filter(p => p.type === 'design').length;
+ const reflow = pages.filter(p => p.type === 'reflow').length;
+ const trimLabel = spec.bookSize || `${spec.trimW || 6} × ${spec.trimH || 9} in`;
+ previewWrap.style.cssText = 'width:100%;height:100%;display:flex;align-items:center;justify-content:center;padding:14px;box-sizing:border-box;background:linear-gradient(135deg,#fef3c7 0%,#fde68a 50%,#f59e0b 100%)';
+ previewWrap.innerHTML = `
+