@drawnagency/primitives 0.1.109 → 0.1.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/dist/auth/cookies.d.ts +8 -0
  2. package/dist/auth/cookies.d.ts.map +1 -1
  3. package/dist/auth/index.d.ts +1 -1
  4. package/dist/auth/index.d.ts.map +1 -1
  5. package/dist/auth/index.js +3 -1
  6. package/dist/auth/types.d.ts +11 -4
  7. package/dist/auth/types.d.ts.map +1 -1
  8. package/dist/{chunk-WXBHP4JY.js → chunk-3GAG6ZXB.js} +55 -46
  9. package/dist/{chunk-FM4M4Q2W.js → chunk-4TZJZU2T.js} +23 -3
  10. package/dist/{chunk-5PVRACZY.js → chunk-7MI3HCOF.js} +60 -23
  11. package/dist/{chunk-WBDPDGFA.js → chunk-7WQJ57XG.js} +6 -31
  12. package/dist/{chunk-LQHYVPBS.js → chunk-ALMEC3XN.js} +11 -12
  13. package/dist/{chunk-TZQPOR5A.js → chunk-DMCGN5AS.js} +10 -3
  14. package/dist/{chunk-ZKVKNUJC.js → chunk-ELQ4C677.js} +1 -1
  15. package/dist/{chunk-5BM2Y3NJ.js → chunk-FRVO75DK.js} +14 -2
  16. package/dist/{chunk-VKAGMEKE.js → chunk-FTPETWXE.js} +1 -0
  17. package/dist/{chunk-3TO4RRHY.js → chunk-GPPVYLEI.js} +7 -0
  18. package/dist/{chunk-NLQ5F3RQ.js → chunk-HBULBDGP.js} +1 -1
  19. package/dist/{chunk-67ZOQCUD.js → chunk-NXGIHSL5.js} +67 -50
  20. package/dist/{chunk-CPTFFLK2.js → chunk-OTDA6KWL.js} +10 -3
  21. package/dist/{chunk-XIDE4TDF.js → chunk-OUYB2GCW.js} +1 -1
  22. package/dist/{chunk-72CG6AA2.js → chunk-RA5IOFPY.js} +10 -2
  23. package/dist/{chunk-GEENI3OC.js → chunk-SW6PCQQD.js} +4 -0
  24. package/dist/{chunk-BOZUEKWL.js → chunk-UVIYI4J6.js} +1 -1
  25. package/dist/{chunk-AB3XVXI3.js → chunk-VCCNKTFD.js} +37 -29
  26. package/dist/{chunk-XBYJQ5RS.js → chunk-XQMDWXD6.js} +21 -6
  27. package/dist/collab/rebase.d.ts +6 -0
  28. package/dist/collab/rebase.d.ts.map +1 -1
  29. package/dist/collab/rebase.js +12 -3
  30. package/dist/collab/recovery.d.ts +0 -20
  31. package/dist/collab/recovery.d.ts.map +1 -1
  32. package/dist/components/brandguide/ColorSwatchSettings.d.ts.map +1 -1
  33. package/dist/components/brandguide/ColorsView.d.ts.map +1 -1
  34. package/dist/components/brandguide/colors-shared.d.ts +10 -1
  35. package/dist/components/brandguide/colors-shared.d.ts.map +1 -1
  36. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  37. package/dist/components/docfile/DocFilePane.d.ts.map +1 -1
  38. package/dist/components/docfile/PdfCanvasViewer.d.ts.map +1 -1
  39. package/dist/components/docfile/doc-file-state.d.ts.map +1 -1
  40. package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
  41. package/dist/components/editor/NavGroupRow.d.ts +7 -2
  42. package/dist/components/editor/NavGroupRow.d.ts.map +1 -1
  43. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  44. package/dist/components/editor/index.js +13 -12
  45. package/dist/components/primitives/EditableRichText.d.ts.map +1 -1
  46. package/dist/components/primitives/LinkPopover.d.ts.map +1 -1
  47. package/dist/components/primitives/index.js +14 -12
  48. package/dist/components/primitives/useEditableCollection.d.ts.map +1 -1
  49. package/dist/components/primitives/useEditablePlainText.d.ts.map +1 -1
  50. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  51. package/dist/components/sections/Prose/Prose.d.ts.map +1 -1
  52. package/dist/components/sections/brand-guide.js +8 -8
  53. package/dist/components/sections/register-schemas.js +14 -14
  54. package/dist/components/sections/register.js +14 -14
  55. package/dist/components/shared/FontPicker.d.ts.map +1 -1
  56. package/dist/components/shared/HistoryChangesModal.d.ts.map +1 -1
  57. package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
  58. package/dist/components/shared/Navigation.d.ts.map +1 -1
  59. package/dist/components/shared/Popover.d.ts.map +1 -1
  60. package/dist/components/shell/AudienceAddForm.d.ts +1 -5
  61. package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
  62. package/dist/components/shell/AudienceRow.d.ts +2 -3
  63. package/dist/components/shell/AudienceRow.d.ts.map +1 -1
  64. package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
  65. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  66. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  67. package/dist/components/shell/SiteSettingsUsers.d.ts.map +1 -1
  68. package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
  69. package/dist/components/shell/view-branch.d.ts.map +1 -1
  70. package/dist/document/components/DocRail.d.ts.map +1 -1
  71. package/dist/document/components/DocViewer.d.ts.map +1 -1
  72. package/dist/document/components/measure.d.ts.map +1 -1
  73. package/dist/document/components/useDocPages.d.ts.map +1 -1
  74. package/dist/document/engine/blocks.d.ts.map +1 -1
  75. package/dist/document/engine/masonry.d.ts.map +1 -1
  76. package/dist/document/engine/paginate.d.ts.map +1 -1
  77. package/dist/document/engine/settings.d.ts +3 -0
  78. package/dist/document/engine/settings.d.ts.map +1 -1
  79. package/dist/document/index.js +79 -28
  80. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  81. package/dist/hooks/useDocumentUploads.d.ts +1 -0
  82. package/dist/hooks/useDocumentUploads.d.ts.map +1 -1
  83. package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
  84. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  85. package/dist/hooks/useFocusTrap.d.ts +6 -0
  86. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  87. package/dist/hooks/useMediaPipeline.d.ts +3 -2
  88. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  89. package/dist/hooks/useSanitizedHtml.d.ts +12 -0
  90. package/dist/hooks/useSanitizedHtml.d.ts.map +1 -0
  91. package/dist/index.js +19 -13
  92. package/dist/lib/block-move.d.ts.map +1 -1
  93. package/dist/lib/contrast.d.ts.map +1 -1
  94. package/dist/lib/dexie.d.ts +2 -8
  95. package/dist/lib/dexie.d.ts.map +1 -1
  96. package/dist/lib/dexie.js +21 -49
  97. package/dist/lib/index-ops.d.ts.map +1 -1
  98. package/dist/lib/index-ops.js +43 -23
  99. package/dist/lib/index.js +8 -8
  100. package/dist/lib/loader.d.ts.map +1 -1
  101. package/dist/lib/media-folders.d.ts +12 -4
  102. package/dist/lib/media-folders.d.ts.map +1 -1
  103. package/dist/lib/media-folders.js +12 -2
  104. package/dist/lib/migrate-sections-transform.js +1 -1
  105. package/dist/lib/nav-progress.d.ts +14 -0
  106. package/dist/lib/nav-progress.d.ts.map +1 -0
  107. package/dist/lib/nav-tree.d.ts.map +1 -1
  108. package/dist/lib/nav.d.ts.map +1 -1
  109. package/dist/lib/pages.d.ts.map +1 -1
  110. package/dist/lib/registry.d.ts.map +1 -1
  111. package/dist/lib/registry.js +1 -1
  112. package/dist/lib/sanitize.d.ts +6 -0
  113. package/dist/lib/sanitize.d.ts.map +1 -1
  114. package/dist/lib/timestamp.d.ts.map +1 -1
  115. package/dist/media/index.js +4 -4
  116. package/dist/media/queue.d.ts.map +1 -1
  117. package/dist/media/resolve.d.ts.map +1 -1
  118. package/dist/media/utils.d.ts.map +1 -1
  119. package/dist/media/videoPoster.d.ts.map +1 -1
  120. package/dist/schemas/audience.d.ts +5 -1
  121. package/dist/schemas/audience.d.ts.map +1 -1
  122. package/dist/schemas/audience.js +1 -1
  123. package/dist/schemas/index.js +8 -6
  124. package/dist/schemas/sections.d.ts +12 -0
  125. package/dist/schemas/sections.d.ts.map +1 -1
  126. package/dist/storage/index.d.ts +1 -1
  127. package/dist/storage/index.d.ts.map +1 -1
  128. package/dist/storage/index.js +3 -1
  129. package/dist/storage/types.d.ts +9 -0
  130. package/dist/storage/types.d.ts.map +1 -1
  131. package/dist/types/database.d.ts +0 -3
  132. package/dist/types/database.d.ts.map +1 -1
  133. package/package.json +1 -1
  134. package/src/auth/cookies.ts +11 -0
  135. package/src/auth/index.ts +1 -1
  136. package/src/auth/types.ts +18 -4
  137. package/src/collab/rebase.ts +26 -1
  138. package/src/collab/recovery.ts +29 -3
  139. package/src/components/brandguide/ColorSwatchSettings.tsx +40 -13
  140. package/src/components/brandguide/ColorsEditable.tsx +7 -9
  141. package/src/components/brandguide/ColorsView.tsx +12 -11
  142. package/src/components/brandguide/colors-shared.ts +17 -1
  143. package/src/components/chat/ChatPanel.tsx +19 -0
  144. package/src/components/docfile/DocFilePane.tsx +11 -0
  145. package/src/components/docfile/PdfCanvasViewer.tsx +46 -5
  146. package/src/components/docfile/doc-file-state.ts +7 -0
  147. package/src/components/editor/IndicatorPill.tsx +5 -1
  148. package/src/components/editor/NavGroupRow.tsx +8 -3
  149. package/src/components/editor/PagesModal.tsx +10 -2
  150. package/src/components/primitives/EditableList.tsx +10 -2
  151. package/src/components/primitives/EditableRichText.tsx +4 -3
  152. package/src/components/primitives/LinkPopover.tsx +23 -1
  153. package/src/components/primitives/useEditableCollection.ts +61 -41
  154. package/src/components/primitives/useEditablePlainText.ts +24 -3
  155. package/src/components/sections/IconList/index.tsx +8 -1
  156. package/src/components/sections/Prose/Prose.tsx +4 -2
  157. package/src/components/shared/FontPicker.tsx +16 -4
  158. package/src/components/shared/HistoryChangesModal.tsx +12 -1
  159. package/src/components/shared/HistoryPopover.tsx +18 -4
  160. package/src/components/shared/Navigation.tsx +20 -2
  161. package/src/components/shared/Popover.tsx +9 -5
  162. package/src/components/shell/AudienceAddForm.tsx +6 -26
  163. package/src/components/shell/AudienceRow.tsx +29 -39
  164. package/src/components/shell/BugReportFAB.tsx +20 -2
  165. package/src/components/shell/EditorShell.tsx +200 -50
  166. package/src/components/shell/MediaLibraryModal.tsx +52 -10
  167. package/src/components/shell/SiteSettingsUsers.tsx +3 -0
  168. package/src/components/shell/SiteSettingsViewerAccess.tsx +21 -16
  169. package/src/components/shell/view-branch.ts +4 -1
  170. package/src/document/components/DocRail.tsx +27 -2
  171. package/src/document/components/DocViewer.tsx +5 -1
  172. package/src/document/components/measure.tsx +22 -6
  173. package/src/document/components/useDocPages.ts +13 -3
  174. package/src/document/engine/blocks.ts +33 -4
  175. package/src/document/engine/masonry.ts +8 -2
  176. package/src/document/engine/paginate.ts +4 -1
  177. package/src/document/engine/settings.ts +40 -3
  178. package/src/hooks/useBuildStatus.ts +6 -0
  179. package/src/hooks/useDocumentUploads.ts +46 -3
  180. package/src/hooks/useEditorPersistence.ts +33 -19
  181. package/src/hooks/useEditorPublish.ts +83 -34
  182. package/src/hooks/useFocusTrap.ts +18 -0
  183. package/src/hooks/useMediaPipeline.ts +111 -38
  184. package/src/hooks/useSanitizedHtml.ts +29 -0
  185. package/src/lib/audience-secret.ts +1 -0
  186. package/src/lib/block-move.ts +13 -2
  187. package/src/lib/contrast.ts +14 -14
  188. package/src/lib/dexie.ts +46 -49
  189. package/src/lib/index-ops.ts +63 -20
  190. package/src/lib/loader.ts +41 -12
  191. package/src/lib/media-folders.ts +31 -5
  192. package/src/lib/migrate-sections-transform.ts +22 -2
  193. package/src/lib/nav-progress.ts +108 -0
  194. package/src/lib/nav-tree.ts +16 -2
  195. package/src/lib/nav.ts +10 -1
  196. package/src/lib/pages.ts +24 -6
  197. package/src/lib/registry.ts +3 -0
  198. package/src/lib/sanitize.ts +9 -0
  199. package/src/lib/timestamp.ts +5 -0
  200. package/src/media/queue.ts +4 -0
  201. package/src/media/resolve.ts +14 -1
  202. package/src/media/utils.ts +3 -1
  203. package/src/media/videoPoster.ts +14 -3
  204. package/src/schemas/audience.ts +10 -2
  205. package/src/schemas/sections.ts +23 -1
  206. package/src/storage/index.ts +1 -1
  207. package/src/storage/types.ts +13 -0
  208. package/src/types/database.ts +0 -3
@@ -253,11 +253,27 @@ export function useEditorPublish({
253
253
  async function gatherDocumentPayload(
254
254
  pending: { id: string; entry: DocumentManifestEntry }[],
255
255
  manifest: DocumentManifest,
256
- ): Promise<{ uploads: DocumentUploadPayload[]; entries: { id: string; entry: DocumentManifestEntry }[] }> {
256
+ ): Promise<{
257
+ uploads: DocumentUploadPayload[];
258
+ entries: { id: string; entry: DocumentManifestEntry }[];
259
+ /** Staged here, but already on the branch — nothing to send, yet still to
260
+ * be promoted out of `pendingDocuments`. */
261
+ alreadyCommitted: string[];
262
+ }> {
257
263
  const uploads: DocumentUploadPayload[] = [];
258
264
  const entries: { id: string; entry: DocumentManifestEntry }[] = [];
265
+ const alreadyCommitted: string[] = [];
259
266
  for (const { id, entry } of pending) {
260
- if (manifest[id]) continue; // already committed by another writer
267
+ if (manifest[id]) {
268
+ // Committed by another writer (a peer, or the MCP connector) while it
269
+ // was staged locally. Dropping it outright meant it never reached
270
+ // committedDocumentIds, so onDocumentsPublished never fired for it and
271
+ // pendingDocuments kept it — hasPendingEdits() then stayed true
272
+ // FOREVER: every Save posted a no-op and the dirty indicator never
273
+ // cleared. It is committed, so treat it as committed.
274
+ alreadyCommitted.push(id);
275
+ continue;
276
+ }
261
277
  if (entry.storage === "git") {
262
278
  const blob = await getPendingDocumentBlob(id);
263
279
  if (!blob) continue;
@@ -265,7 +281,7 @@ export function useEditorPublish({
265
281
  }
266
282
  entries.push({ id, entry });
267
283
  }
268
- return { uploads, entries };
284
+ return { uploads, entries, alreadyCommitted };
269
285
  }
270
286
 
271
287
  async function performSave(args: {
@@ -501,7 +517,10 @@ export function useEditorPublish({
501
517
  // replace nav.json) — the save succeeded, but part of it was refused, and
502
518
  // the editor believing it landed is exactly the silence the guard exists
503
519
  // to break.
504
- if (responseData.warnings?.length) args.onSaveWarnings?.(responseData.warnings);
520
+ // Always reported, empty included: a later clean save left the previous
521
+ // save's banner on screen, so a warning the user had already dealt with
522
+ // read as still true.
523
+ args.onSaveWarnings?.(responseData.warnings ?? []);
505
524
 
506
525
  return {
507
526
  sha: responseData.sha,
@@ -512,7 +531,11 @@ export function useEditorPublish({
512
531
  // Read AFTER the final request: committedDocuments is complete and the
513
532
  // deletions (which ride only that request) have landed.
514
533
  committedDocumentManifest: documentManifestToSend(),
515
- committedDocumentIds,
534
+ // Merged only into the RETURN value, never into the local
535
+ // committedDocumentIds — that array's length gates leadChunksCommitted,
536
+ // and an already-committed document advanced no draft head this save,
537
+ // so counting it there would wrongly suppress 409 auto-recovery.
538
+ committedDocumentIds: [...gatheredDocs.alreadyCommitted, ...committedDocumentIds],
516
539
  committedDocumentDeletions: args.pendingDocumentDeletions,
517
540
  };
518
541
  } catch (error) {
@@ -719,6 +742,11 @@ export function useEditorPublish({
719
742
  }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, recoverConflict, onBaselineCommitted, saveChunkBudget, getEditGeneration, pendingDocumentDeletions]);
720
743
 
721
744
  const handlePublish = useCallback(async () => {
745
+ // Same in-flight guard every save path has. Without it a double-click fired
746
+ // two promotions: the second raced the first's branch delete and came back
747
+ // as a bare "Publish failed" for a publish that had actually succeeded.
748
+ if (inFlightRef.current) return;
749
+ inFlightRef.current = true;
722
750
  setPublishAction("publishing");
723
751
  setPublishFeedback(null);
724
752
 
@@ -749,6 +777,7 @@ export function useEditorPublish({
749
777
  console.error("Publish failed:", error);
750
778
  showFeedback("Publish failed", 5000);
751
779
  } finally {
780
+ inFlightRef.current = false;
752
781
  setPublishAction("idle");
753
782
  }
754
783
  }, [onShasUpdated, showFeedback, onPublishComplete]);
@@ -762,15 +791,51 @@ export function useEditorPublish({
762
791
 
763
792
  const hasLocalEdits = await hasPendingEdits();
764
793
 
765
- let savedSections: { sectionId: string; updatedAt: string; content: SectionContent }[] = [];
766
- let uploadedItems: MediaItem[] = [];
767
794
  let sentIndex: SiteIndex | null = null;
768
795
  let committedManifest: MediaManifest | null = null;
769
796
  let committedDocumentManifest: DocumentManifest | null = null;
770
- let committedDocumentIds: string[] = [];
771
- let committedDocumentDeletions: string[] = [];
797
+ let baselineSections: LoadedSection[] | null = null;
772
798
  if (hasLocalEdits) {
773
- ({ savedSections, uploadedItems, sentIndex, committedManifest, committedDocumentManifest, committedDocumentIds, committedDocumentDeletions } = await saveWithConflictRecovery());
799
+ const saved = await saveWithConflictRecovery();
800
+ sentIndex = saved.sentIndex;
801
+ committedManifest = saved.committedManifest;
802
+ committedDocumentManifest = saved.committedDocumentManifest;
803
+
804
+ // Local save bookkeeping runs HERE, before the publish request — not
805
+ // after it. When it sat below the publish, a publish failure left
806
+ // savedShaRef at the pre-save head with every dirty row still queued:
807
+ // the next save re-sent everything against a stale baseVersion (a
808
+ // guaranteed 409) and re-uploaded the media lead chunks.
809
+ onShasUpdated(saved.sha, null);
810
+ try {
811
+ // C1: read the previous clean baseline BEFORE commitSavedRows clears
812
+ // the content cache, then re-prime it with the COMMITTED snapshot.
813
+ const prevBaseline = await getCachedContent();
814
+ await commitSavedRows(saved.savedSections, saved.uploadedItems);
815
+ baselineSections = buildCommittedBaseline({
816
+ baselineSections: prevBaseline?.sections ?? null,
817
+ fallbackSections: sections,
818
+ savedRows: saved.savedSections,
819
+ sentIndex: saved.sentIndex,
820
+ });
821
+ // Both sidecars ride the same write as the sha they belong to —
822
+ // omitting them is what blanked freshly uploaded images ("No Image")
823
+ // and freshly committed documents ("File missing") on a cache hit.
824
+ await cacheContent(saved.sha, baselineSections, saved.sentIndex, siteConfig!, {
825
+ media: saved.committedManifest,
826
+ documents: saved.committedDocumentManifest,
827
+ });
828
+ finishSave(saved.uploadedItems, saved.savedSections.map((r) => r.sectionId));
829
+ if (saved.committedDocumentIds.length > 0) onDocumentsPublished?.(saved.committedDocumentIds);
830
+ if (saved.committedDocumentDeletions.length > 0) onDocumentsDeleted?.(saved.committedDocumentDeletions);
831
+ } catch (cleanupError) {
832
+ // The save COMMITTED; only the local bookkeeping failed. Publishing on
833
+ // top of unknown local state would compound it — stop and tell the
834
+ // editor to reload.
835
+ console.error("Save succeeded but local cleanup failed:", cleanupError);
836
+ showFeedback("Saved, but the local cache couldn't be updated — reload the editor before publishing.", 8000);
837
+ return;
838
+ }
774
839
  }
775
840
 
776
841
  setPublishAction("publishing");
@@ -791,35 +856,19 @@ export function useEditorPublish({
791
856
  }
792
857
  const { sha } = publishData;
793
858
 
794
- if (hasLocalEdits) {
859
+ if (hasLocalEdits && baselineSections && sentIndex) {
795
860
  try {
796
- // C1 parity with handleSave: read the previous clean baseline before
797
- // the invalidation, then re-cache the COMMITTED snapshot (sent content
798
- // + sent index — never the live view state, which may carry mid-flight
799
- // edits) against the PUBLISHED sha; the draft branch is gone after
800
- // publish, so this is the snapshot a later cache hit restores.
801
- const prevBaseline = await getCachedContent();
802
- await commitSavedRows(savedSections, uploadedItems);
803
- const baselineSections = buildCommittedBaseline({
804
- baselineSections: prevBaseline?.sections ?? null,
805
- fallbackSections: sections,
806
- savedRows: savedSections,
807
- sentIndex: sentIndex!,
808
- });
809
- // Both sidecars ride the same write as the sha they belong to: publish
810
- // deleted the draft, so main's head IS this sha and the next load hits
811
- // this row. Omitting them here is what blanked freshly uploaded images
812
- // ("No Image", while the published site was correct) and freshly
813
- // committed documents ("File missing").
814
- await cacheContent(sha, baselineSections, sentIndex!, siteConfig!, {
861
+ // Re-key the SAME committed snapshot at the published sha: the draft
862
+ // branch is gone after publish, so this is what a later cache hit
863
+ // restores. Everything else was already committed above — but both
864
+ // sidecars have to ride this write too, or the re-keyed row is the
865
+ // one that blanks images and documents.
866
+ await cacheContent(sha, baselineSections, sentIndex, siteConfig!, {
815
867
  media: committedManifest!,
816
868
  documents: committedDocumentManifest!,
817
869
  });
818
- finishSave(uploadedItems, savedSections.map((s) => s.sectionId));
819
- if (committedDocumentIds.length > 0) onDocumentsPublished?.(committedDocumentIds);
820
- if (committedDocumentDeletions.length > 0) onDocumentsDeleted?.(committedDocumentDeletions);
821
870
  } catch (cleanupError) {
822
- console.error("Publish succeeded but local cleanup failed:", cleanupError);
871
+ console.error("Publish succeeded but the cache re-key failed:", cleanupError);
823
872
  onShasUpdated(null, sha);
824
873
  onPublishComplete?.();
825
874
  showFeedback("Published, but the local cache couldn't be updated — reload the editor.", 8000);
@@ -55,3 +55,21 @@ export function useFocusTrap(ref: RefObject<HTMLElement | null>, active: boolean
55
55
  };
56
56
  }, [active, ref]);
57
57
  }
58
+
59
+ /**
60
+ * Focus the first control on open, hand focus back on close — and nothing else.
61
+ * For non-modal surfaces (`aria-modal="false"`), where trapping Tab contradicts
62
+ * the declaration and leaves a keyboard user with no way out.
63
+ */
64
+ export function useInitialFocus(ref: RefObject<HTMLElement | null>, active: boolean) {
65
+ useEffect(() => {
66
+ if (!active || !ref.current) return;
67
+ const previouslyFocused = document.activeElement as HTMLElement | null;
68
+ ref.current.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)?.focus();
69
+ return () => {
70
+ if (previouslyFocused && document.body.contains(previouslyFocused)) {
71
+ previouslyFocused.focus();
72
+ }
73
+ };
74
+ }, [active, ref]);
75
+ }
@@ -87,12 +87,29 @@ export function useMediaPipeline({
87
87
  // the ref the error branch below would close over the first render's handler.
88
88
  const onUploadErrorRef = useRef(onUploadError);
89
89
  onUploadErrorRef.current = onUploadError;
90
+ const onUploadNoticeRef = useRef(onUploadNotice);
91
+ onUploadNoticeRef.current = onUploadNotice;
90
92
 
91
93
  // Read inside uploadDocument's async body, where a state snapshot taken at
92
94
  // render time would be stale across the hash await.
93
95
  const pendingDocumentDeletionsRef = useRef<string[]>(pendingDocumentDeletions);
94
96
  pendingDocumentDeletionsRef.current = pendingDocumentDeletions;
95
97
 
98
+ // The two delete handlers below used to run their reference-repair pass INSIDE
99
+ // a setSections updater, calling markSectionDirty (a Dexie write) and
100
+ // setDirtySectionIds from it. State updaters must be pure — StrictMode
101
+ // double-invokes them in dev, and React may re-run or discard them at any
102
+ // time. Reading the list from a ref lets the pass run outside the updater.
103
+ const sectionsRef = useRef<LoadedSection[]>(sections);
104
+ sectionsRef.current = sections;
105
+
106
+ // handleMediaUpload's dedup check runs inside an `await` loop, where the
107
+ // `pendingMediaItems` state captured at render time goes stale. Mirroring the
108
+ // hashes here means the check reads whatever the latest render committed
109
+ // rather than the snapshot the batch started with.
110
+ const pendingHashesRef = useRef<Set<string>>(new Set());
111
+ pendingHashesRef.current = new Set(pendingMediaItems.map((i) => i.hash));
112
+
96
113
  /** Re-adding a document that is deleted-but-unsaved un-dooms it rather than
97
114
  * letting the upload dedup swallow it — the twin of the image pipeline's
98
115
  * `isDeleted` escape below. The ref is updated eagerly so two re-adds in one
@@ -118,6 +135,9 @@ export function useMediaPipeline({
118
135
  const uploadTargetsRef = useRef<Map<string, string | null>>(new Map());
119
136
  const hiddenUploadsRef = useRef<Map<string, boolean>>(new Map());
120
137
  const destroyedRef = useRef(false);
138
+ // Queue-teardown bookkeeping for the media-config-change notice below.
139
+ const prevMediaConfigKeyRef = useRef<string | null>(null);
140
+ const droppedOnTeardownRef = useRef(0);
121
141
 
122
142
  // Shared persistence path for every pending media item — the processing
123
143
  // queue's small-file finalize AND the direct-to-bucket large-video upload
@@ -167,6 +187,25 @@ export function useMediaPipeline({
167
187
  useEffect(() => {
168
188
  if (!siteConfig) return;
169
189
  destroyedRef.current = false;
190
+ // Changing the media settings (sizes/quality/max size) rebuilds the queue and
191
+ // everything still in it is dropped on the floor. Silently, until now: the
192
+ // files just stopped existing, mid-progress-bar. Decided HERE rather than in
193
+ // the cleanup because only this side can see whether the key actually
194
+ // changed — the effect also re-runs on an incidental identity change, and
195
+ // that teardown drops nothing the user cares about.
196
+ if (
197
+ prevMediaConfigKeyRef.current !== null &&
198
+ prevMediaConfigKeyRef.current !== mediaConfigKey &&
199
+ droppedOnTeardownRef.current > 0
200
+ ) {
201
+ const dropped = droppedOnTeardownRef.current;
202
+ onUploadNoticeRef.current?.(
203
+ `${dropped} upload${dropped === 1 ? "" : "s"} stopped because the image settings changed — add ${dropped === 1 ? "it" : "them"} again.`,
204
+ );
205
+ setProcessingItems([]);
206
+ }
207
+ droppedOnTeardownRef.current = 0;
208
+ prevMediaConfigKeyRef.current = mediaConfigKey;
170
209
  const mediaConfig = siteConfig.media;
171
210
  const queue = new ProcessingQueue({
172
211
  sizes: mediaConfig.sizes,
@@ -258,7 +297,20 @@ export function useMediaPipeline({
258
297
  void finalize(localUrls, blobsMap, width, height);
259
298
  },
260
299
  () => {
261
- void finalize(localUrls, blobsMap, result.width, result.height);
300
+ // The worker cannot decode a video, so it emits width/height 0 and
301
+ // poster generation is the ONLY source of real dimensions. Falling
302
+ // back to result.width/height therefore persisted a 0x0 item, which
303
+ // MediaItemSchema (positive ints) then rejected on every subsequent
304
+ // save — the editor could save nothing at all until the pending item
305
+ // was removed by hand, and the error named the manifest, not the
306
+ // video. Drop the file with a per-file error instead.
307
+ for (const url of Object.values(localUrls)) URL.revokeObjectURL(url);
308
+ uploadTargetsRef.current.delete(event.item.hash);
309
+ hiddenUploadsRef.current.delete(event.item.hash);
310
+ uploadCallbacksRef.current.delete(event.item.hash);
311
+ onUploadErrorRef.current?.(
312
+ `"${event.item.originalName}" couldn't be read — the browser can't decode this video. Try re-encoding it as H.264 MP4.`,
313
+ );
262
314
  },
263
315
  );
264
316
  } else {
@@ -274,6 +326,8 @@ export function useMediaPipeline({
274
326
  queueRef.current = queue;
275
327
  return () => {
276
328
  destroyedRef.current = true;
329
+ const { active, queued } = queue.getStatus();
330
+ droppedOnTeardownRef.current = active + queued;
277
331
  queue.destroy();
278
332
  uploadCallbacksRef.current = new Map();
279
333
  uploadTargetsRef.current = new Map();
@@ -409,6 +463,8 @@ export function useMediaPipeline({
409
463
  if (!queue) return;
410
464
 
411
465
  let alreadyExisting = 0;
466
+ // Hashes enqueued by THIS batch — see the dedup check below.
467
+ const enqueuedThisBatch = new Set<string>();
412
468
  for (const file of files) {
413
469
  // Documents never touch the rasterizer: their bytes must reach the repo
414
470
  // or the bucket untransformed. Branch before every image-shaped gate
@@ -444,12 +500,17 @@ export function useMediaPipeline({
444
500
 
445
501
  const isDeleted = pendingDeletions.includes(hash);
446
502
  const existsInManifest = hash in mediaManifest.images;
447
- const existsInPending = pendingMediaItems.some((i) => i.hash === hash);
503
+ // `enqueuedThisBatch` is what catches the same file dropped twice in ONE
504
+ // batch: nothing has re-rendered yet, so neither the state snapshot nor
505
+ // the ref has heard about the first copy, and both used to reach
506
+ // queue.add — producing duplicate React keys in MediaLibraryModal.
507
+ const existsInPending = pendingHashesRef.current.has(hash) || enqueuedThisBatch.has(hash);
448
508
  if ((existsInManifest || existsInPending) && !isDeleted) {
449
509
  alreadyExisting++;
450
510
  continue;
451
511
  }
452
512
 
513
+ enqueuedThisBatch.add(hash);
453
514
  uploadTargetsRef.current.set(hash, targetFolderId);
454
515
 
455
516
  let kind: "image" | "animated" | "video" = "image";
@@ -465,7 +526,7 @@ export function useMediaPipeline({
465
526
  : `${alreadyExisting} files already exist in the library and were left where they are.`,
466
527
  );
467
528
  }
468
- }, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, onUploadNotice, uploadLargeVideo, documents]);
529
+ }, [siteConfig, mediaManifest, pendingDeletions, onUploadError, onUploadNotice, uploadLargeVideo, documents]);
469
530
 
470
531
  const handleMediaDelete = useCallback(async (ids: string[]) => {
471
532
  const idSet = new Set(ids);
@@ -498,18 +559,21 @@ export function useMediaPipeline({
498
559
  });
499
560
  setPendingDeletions((prev) => [...prev, ...ids]);
500
561
 
501
- setSections((prev) =>
502
- prev.map((loaded) => {
503
- const referenced = collectImageIds(loaded.section);
504
- const hasRef = ids.some((id) => referenced.has(id));
505
- if (!hasRef) return loaded;
506
-
507
- const cleared = clearImageIds(loaded.section, idSet);
508
- markSectionDirty(loaded.section.id, cleared as SectionContent);
509
- setDirtySectionIds((prev) => new Set(prev).add(loaded.section.id));
510
- return { ...loaded, section: { id: loaded.section.id, ...(cleared as Record<string, unknown>) } as typeof loaded.section };
511
- }),
512
- );
562
+ const clearedIds: string[] = [];
563
+ const repaired = sectionsRef.current.map((loaded) => {
564
+ const referenced = collectImageIds(loaded.section);
565
+ const hasRef = ids.some((id) => referenced.has(id));
566
+ if (!hasRef) return loaded;
567
+
568
+ const cleared = clearImageIds(loaded.section, idSet);
569
+ clearedIds.push(loaded.section.id);
570
+ markSectionDirty(loaded.section.id, cleared as SectionContent);
571
+ return { ...loaded, section: { id: loaded.section.id, ...(cleared as Record<string, unknown>) } as typeof loaded.section };
572
+ });
573
+ setSections(repaired);
574
+ if (clearedIds.length > 0) {
575
+ setDirtySectionIds((prev) => new Set([...prev, ...clearedIds]));
576
+ }
513
577
 
514
578
  if (siteConfig?.logo && onSiteConfigChange) {
515
579
  const logoIds = collectImageIds(siteConfig.logo);
@@ -541,17 +605,20 @@ export function useMediaPipeline({
541
605
  setPendingDocumentDeletions((prev) => [...prev, ...committedIds.filter((id) => !prev.includes(id))]);
542
606
  }
543
607
 
544
- setSections((prev) =>
545
- prev.map((loaded) => {
546
- const { changed, section } = clearDocumentRefs(loaded.section, idSet);
547
- if (!changed) return loaded;
548
- // A nested hit returns the rewritten CONTAINER — the section that owns
549
- // the child's bytes, and so the one that gets dirtied and saved.
550
- markSectionDirty(section.id, section as SectionContent);
551
- setDirtySectionIds((dirty) => new Set(dirty).add(section.id));
552
- return { ...loaded, section };
553
- }),
554
- );
608
+ const repairedIds: string[] = [];
609
+ const repaired = sectionsRef.current.map((loaded) => {
610
+ const { changed, section } = clearDocumentRefs(loaded.section, idSet);
611
+ if (!changed) return loaded;
612
+ // A nested hit returns the rewritten CONTAINER — the section that owns
613
+ // the child's bytes, and so the one that gets dirtied and saved.
614
+ repairedIds.push(section.id);
615
+ markSectionDirty(section.id, section as SectionContent);
616
+ return { ...loaded, section };
617
+ });
618
+ setSections(repaired);
619
+ if (repairedIds.length > 0) {
620
+ setDirtySectionIds((dirty) => new Set([...dirty, ...repairedIds]));
621
+ }
555
622
 
556
623
  setLocalChangesExist(true);
557
624
  }, [documents, documentManifest, markSectionDirty, setSections, setDirtySectionIds, setLocalChangesExist]);
@@ -695,22 +762,27 @@ export function useMediaPipeline({
695
762
  // instead of stringifying every section and regex-scanning it per known id.
696
763
  // Counts every referenced imageId; the consumer (MediaLibraryModal) only looks
697
764
  // up manifest/pending ids, so extra keys for any dangling references are inert.
698
- const referenceCountMap = useMemo((): Record<string, number> => {
765
+ //
766
+ // Deliberately a THUNK, not a useMemo. As a memo it recursed through every
767
+ // section plus the whole siteConfig on every keystroke, for a consumer (the
768
+ // media modal) that is closed almost all of the time. EditorShell calls this
769
+ // when the modal opens.
770
+ const computeReferenceCountMap = useCallback((): Record<string, number> => {
699
771
  const counts = new Map<string, number>();
700
- for (const { section } of sections) tallyImageIds(section, counts);
772
+ for (const { section } of sectionsRef.current) tallyImageIds(section, counts);
701
773
  if (siteConfig) tallyImageIds(siteConfig, counts);
702
774
  return Object.fromEntries(counts);
703
- }, [sections, siteConfig]);
775
+ }, [siteConfig]);
704
776
 
705
- // The document twin of referenceCountMap, feeding the media modal's document
706
- // usage pills and its delete confirmation. Unlike images, documents are never
707
- // referenced by siteConfig — only by `document` sections, nested ones
708
- // included (tallyDocumentIds recurses).
709
- const documentReferenceCountMap = useMemo((): Record<string, number> => {
777
+ // The document twin, feeding the media modal's document usage pills and its
778
+ // delete confirmation. Unlike images, documents are never referenced by
779
+ // siteConfig — only by `document` sections, nested ones included
780
+ // (tallyDocumentIds recurses).
781
+ const computeDocumentReferenceCountMap = useCallback((): Record<string, number> => {
710
782
  const counts = new Map<string, number>();
711
- for (const { section } of sections) tallyDocumentIds(section, counts);
783
+ for (const { section } of sectionsRef.current) tallyDocumentIds(section, counts);
712
784
  return Object.fromEntries(counts);
713
- }, [sections]);
785
+ }, []);
714
786
 
715
787
  // --- Unified enqueue ---
716
788
 
@@ -820,8 +892,8 @@ export function useMediaPipeline({
820
892
  handleFolderDelete,
821
893
  handleMediaMove,
822
894
  handleFolderUpload,
823
- referenceCountMap,
824
- documentReferenceCountMap,
895
+ computeReferenceCountMap,
896
+ computeDocumentReferenceCountMap,
825
897
  contextValue,
826
898
  enqueueFile,
827
899
  pendingDocuments: documents.pendingDocuments,
@@ -829,6 +901,7 @@ export function useMediaPipeline({
829
901
  documentUploadProgress: documents.documentUploadProgress,
830
902
  uploadDocument: documents.uploadDocument,
831
903
  publishDocuments: documents.publishDocuments,
904
+ unstageAllDocuments: documents.unstageAllDocuments,
832
905
  hydratePendingDocuments: documents.hydratePendingDocuments,
833
906
  };
834
907
  }
@@ -0,0 +1,29 @@
1
+ import { useEffect, useState } from "react";
2
+ import { ensureSanitizer, isSanitizerReady, sanitizeHtml } from "../lib/sanitize";
3
+
4
+ /**
5
+ * Sanitized HTML for `dangerouslySetInnerHTML`, withheld until DOMPurify has
6
+ * loaded. `sanitizeHtml` is synchronous and returns its input unchanged while
7
+ * the dynamic import is in flight, so calling it directly injects raw stored
8
+ * markup on first paint — the editor loads section JSON straight from GitHub and
9
+ * never passes it through core's server-side sanitizer. Returning "" until the
10
+ * purifier resolves is the same gate ChatPanel applies to streamed markdown.
11
+ *
12
+ * On the server this is a straight pass-through (see `isSanitizerReady`).
13
+ */
14
+ export function useSanitizedHtml(html: string): string {
15
+ const [ready, setReady] = useState(isSanitizerReady);
16
+
17
+ useEffect(() => {
18
+ if (ready) return;
19
+ let alive = true;
20
+ void ensureSanitizer().then(() => {
21
+ if (alive) setReady(true);
22
+ });
23
+ return () => {
24
+ alive = false;
25
+ };
26
+ }, [ready]);
27
+
28
+ return ready ? sanitizeHtml(html) : "";
29
+ }
@@ -97,3 +97,4 @@ export async function openAudiencePassword(sealed: string | null | undefined): P
97
97
  return null;
98
98
  }
99
99
  }
100
+
@@ -51,7 +51,15 @@ function defaultNewId(): string {
51
51
  : `block-${Math.random().toString(36).slice(2)}`;
52
52
  }
53
53
 
54
- const DEFAULT_CHILD_META = (type: string): SectionMeta => ({ type, status: "draft", access: [] });
54
+ // A container's children carry no status/access of their own — they inherit the
55
+ // container's. Promoting one to the page root therefore inherits that container's
56
+ // meta: defaulting to `status: "draft"` silently pulled a block that viewers
57
+ // could see out of the published site, with nothing in the editor saying so.
58
+ const promotedChildMeta = (type: string, source: SectionMeta | undefined): SectionMeta => ({
59
+ type,
60
+ status: source?.status ?? "draft",
61
+ access: [...(source?.access ?? [])],
62
+ });
55
63
 
56
64
  function isContainerSection(section: Section): boolean {
57
65
  return Array.isArray((section.content as { children?: unknown })?.children);
@@ -141,7 +149,10 @@ export function applyBlockMove(
141
149
 
142
150
  if (toContainerId === ROOT_CONTAINER_ID) {
143
151
  const { layout: _dropLayout, ...rootBlock } = moving as Section & { layout?: unknown };
144
- const meta = DEFAULT_CHILD_META(rootBlock.type);
152
+ const meta = promotedChildMeta(
153
+ rootBlock.type,
154
+ sections.find((l) => l.section.id === fromContainerId)?.meta,
155
+ );
145
156
  const rootOrder = nextIndex.pages.find((p) => p.id === rootPageId)?.order ?? [];
146
157
  nextIndex = addSectionToPage(nextIndex, rootPageId, rootBlock.id, meta,
147
158
  visibleInsertPosition(rootOrder, visibleRootIds(nextIndex, rootPageId, nextSections), toIndex));
@@ -1,3 +1,14 @@
1
+ /** Hex → sRGB bytes, expanding the 3-digit form. */
2
+ function toRgb(hex: string): [number, number, number] {
3
+ const h = hex.replace("#", "");
4
+ const x = h.length === 3 ? h[0] + h[0] + h[1] + h[1] + h[2] + h[2] : h;
5
+ return [
6
+ parseInt(x.slice(0, 2), 16),
7
+ parseInt(x.slice(2, 4), 16),
8
+ parseInt(x.slice(4, 6), 16),
9
+ ];
10
+ }
11
+
1
12
  export function deriveContrast(hex: string): string {
2
13
  const h = hex.replace("#", "");
3
14
  const expanded = h.length === 3
@@ -15,25 +26,14 @@ export function deriveContrast(hex: string): string {
15
26
  * uses). Returns near-black for light backgrounds, white for dark ones.
16
27
  */
17
28
  export function contrastColor(hex: string): "#1a1a1a" | "#ffffff" {
18
- const h = hex.replace("#", "");
19
- const r = parseInt(h.slice(0, 2), 16);
20
- const g = parseInt(h.slice(2, 4), 16);
21
- const b = parseInt(h.slice(4, 6), 16);
29
+ // Through toRgb so a 3-digit hex expands first. Slicing "fff" directly gave
30
+ // b = NaN, the comparison came out false, and #fff got white text on white.
31
+ const [r, g, b] = toRgb(hex);
22
32
  return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? "#1a1a1a" : "#ffffff";
23
33
  }
24
34
 
25
35
  // --- WCAG 2.x contrast (relative-luminance based) -------------------------
26
36
 
27
- function toRgb(hex: string): [number, number, number] {
28
- const h = hex.replace("#", "");
29
- const x = h.length === 3 ? h[0] + h[0] + h[1] + h[1] + h[2] + h[2] : h;
30
- return [
31
- parseInt(x.slice(0, 2), 16),
32
- parseInt(x.slice(2, 4), 16),
33
- parseInt(x.slice(4, 6), 16),
34
- ];
35
- }
36
-
37
37
  /** WCAG relative luminance of an sRGB hex color (0 = black, 1 = white). */
38
38
  export function relativeLuminance(hex: string): number {
39
39
  const [r, g, b] = toRgb(hex).map((c) => {