@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
package/src/lib/dexie.ts CHANGED
@@ -25,9 +25,6 @@ interface SiteIndexRow {
25
25
 
26
26
  interface MetaRow {
27
27
  key: string;
28
- lastSavedSha: string | null;
29
- mainSha: string | null;
30
- lastSavedAt: string | null;
31
28
  siteId: string;
32
29
  }
33
30
 
@@ -63,6 +60,10 @@ interface ContentCacheRow {
63
60
  // never as "nothing here".
64
61
  mediaManifest?: MediaManifest | null;
65
62
  documentManifest?: DocumentManifest | null;
63
+ // Section files the loader could not read at this sha. Optional for rows
64
+ // written by older packages. Without it a cache-hit load dropped the warning
65
+ // banner entirely, so damaged sections were invisible until a fresh fetch.
66
+ unparseableSectionIds?: string[];
66
67
  }
67
68
 
68
69
  interface PendingMediaRow {
@@ -108,8 +109,13 @@ class EditorDatabase extends Dexie {
108
109
  preservedEdits!: Dexie.Table<PreservedEditRow, string>;
109
110
  pendingDocuments!: Dexie.Table<PendingDocumentRow, string>;
110
111
 
112
+ /** The site this connection was opened for. Authoritative when the `meta` row
113
+ * is missing — see restoreLocalChanges. */
114
+ readonly siteId: string;
115
+
111
116
  constructor(siteId: string) {
112
117
  super(`brand-portal-${siteId}`);
118
+ this.siteId = siteId;
113
119
  this.version(1).stores({
114
120
  sections: "sectionId",
115
121
  siteIndex: "key",
@@ -257,6 +263,12 @@ function getDb(): EditorDatabase {
257
263
  }
258
264
 
259
265
  export function initEditorStore(siteId: string): void {
266
+ // Re-initialising for the site we already hold is a no-op: replacing the
267
+ // instance would leave the old connection open, and an open connection on the
268
+ // previous version is exactly what makes the next schema bump fire `blocked`
269
+ // and hang the upgrade forever.
270
+ if (db?.name === `brand-portal-${siteId}`) return;
271
+ db?.close();
260
272
  db = new EditorDatabase(siteId);
261
273
  }
262
274
 
@@ -293,7 +305,10 @@ export async function restoreLocalChanges(): Promise<{
293
305
 
294
306
  if (indexRow) {
295
307
  const metaRow = await getDb().meta.get("current");
296
- const siteId = metaRow?.siteId ?? "";
308
+ // Fall back to the connection's own site, never to "". An empty siteId is
309
+ // not a site — it rides the restored index into the editor and then into the
310
+ // next save's payload, where nothing downstream can tell it from a real one.
311
+ const siteId = metaRow?.siteId || getDb().siteId;
297
312
  return {
298
313
  sections,
299
314
  siteIndex: { siteId, pages: indexRow.pages, sections: indexRow.sections, nav: indexRow.nav },
@@ -307,13 +322,17 @@ export async function restoreLocalChanges(): Promise<{
307
322
 
308
323
  export async function discardLocalChanges(): Promise<void> {
309
324
  const database = getDb();
310
- await database.transaction("rw", [database.sections, database.siteIndex, database.meta, database.siteConfig, database.pendingMedia, database.pendingMediaDeletions], async () => {
325
+ await database.transaction("rw", [database.sections, database.siteIndex, database.meta, database.siteConfig, database.pendingMedia, database.pendingMediaDeletions, database.pendingDocuments], async () => {
311
326
  await database.sections.clear();
312
327
  await database.siteIndex.clear();
313
328
  await database.meta.clear();
314
329
  await database.siteConfig.clear();
315
330
  await database.pendingMedia.clear();
316
331
  await database.pendingMediaDeletions.clear();
332
+ // Without this a staged document survives discard AND history-restore, and
333
+ // hydratePendingDocuments re-stages it on the next mount — the next save
334
+ // then commits a document the user explicitly discarded.
335
+ await database.pendingDocuments.clear();
317
336
  });
318
337
  }
319
338
 
@@ -329,13 +348,7 @@ export async function persistSiteIndex(index: SiteIndex, deletedSections: string
329
348
  deletedSections,
330
349
  updatedAt: now,
331
350
  });
332
- await database.meta.put({
333
- key: "current",
334
- lastSavedSha: null,
335
- mainSha: null,
336
- lastSavedAt: null,
337
- siteId: index.siteId,
338
- });
351
+ await database.meta.put({ key: "current", siteId: index.siteId });
339
352
  });
340
353
  }
341
354
 
@@ -403,12 +416,14 @@ export async function discardSavedChanges(
403
416
  "rw",
404
417
  [database.sections, database.siteIndex, database.meta, database.siteConfig, database.contentCache],
405
418
  async () => {
406
- for (const { sectionId, updatedAt } of savedSections) {
407
- const row = await database.sections.get(sectionId);
408
- if (row && row.updatedAt === updatedAt) {
409
- await database.sections.delete(sectionId);
410
- }
411
- }
419
+ // One bulkGet, not a get per section: these are serial round-trips inside
420
+ // an open write transaction, so a 200-section save held the sections,
421
+ // index, meta, config and contentCache tables locked for 200 of them.
422
+ const rows = await database.sections.bulkGet(savedSections.map((s) => s.sectionId));
423
+ const untouched = savedSections
424
+ .filter((s, i) => rows[i]?.updatedAt === s.updatedAt)
425
+ .map((s) => s.sectionId);
426
+ if (untouched.length > 0) await database.sections.bulkDelete(untouched);
412
427
  await database.siteIndex.clear();
413
428
  await database.meta.clear();
414
429
  await database.siteConfig.clear();
@@ -458,13 +473,7 @@ export async function persistAll(
458
473
  deletedSections: deletedSectionIds ?? [],
459
474
  updatedAt: now,
460
475
  });
461
- await database.meta.put({
462
- key: "current",
463
- lastSavedSha: null,
464
- mainSha: null,
465
- lastSavedAt: null,
466
- siteId: siteIndex.siteId,
467
- });
476
+ await database.meta.put({ key: "current", siteId: siteIndex.siteId });
468
477
  }
469
478
 
470
479
  if (siteConfig) {
@@ -478,29 +487,6 @@ export async function persistAll(
478
487
  );
479
488
  }
480
489
 
481
- export async function updateBranchShas(savedSha: string | null, mainSha: string | null): Promise<void> {
482
- const database = getDb();
483
- const existing = await database.meta.get("current");
484
- if (!existing) return;
485
- await database.meta.put({
486
- ...existing,
487
- lastSavedSha: savedSha,
488
- mainSha,
489
- lastSavedAt: new Date().toISOString(),
490
- });
491
- }
492
-
493
- export async function getBranchShas(): Promise<{ savedSha: string | null; mainSha: string | null } | null> {
494
- const row = await getDb().meta.get("current");
495
- if (!row) return null;
496
- return { savedSha: row.lastSavedSha, mainSha: row.mainSha ?? null };
497
- }
498
-
499
- export async function getDeletedSections(): Promise<string[]> {
500
- const row = await getDb().siteIndex.get("current");
501
- return row?.deletedSections ?? [];
502
- }
503
-
504
490
  /**
505
491
  * Write the content snapshot for one commit.
506
492
  *
@@ -522,6 +508,7 @@ export async function cacheContent(
522
508
  savedBranchSha?: string | null;
523
509
  changedSectionIds?: string[];
524
510
  mainIndex?: SiteIndex | null;
511
+ unparseableSectionIds?: string[];
525
512
  },
526
513
  ): Promise<void> {
527
514
  const now = new Date().toISOString();
@@ -537,6 +524,7 @@ export async function cacheContent(
537
524
  mainIndex: diff?.mainIndex ?? null,
538
525
  mediaManifest: manifests.media,
539
526
  documentManifest: manifests.documents,
527
+ unparseableSectionIds: diff?.unparseableSectionIds ?? [],
540
528
  });
541
529
  }
542
530
 
@@ -555,6 +543,7 @@ export async function getCachedContent(): Promise<{
555
543
  * value and is not null. */
556
544
  mediaManifest: MediaManifest | null;
557
545
  documentManifest: DocumentManifest | null;
546
+ unparseableSectionIds: string[];
558
547
  } | null> {
559
548
  const row = await getDb().contentCache.get("current");
560
549
  if (!row) return null;
@@ -570,6 +559,7 @@ export async function getCachedContent(): Promise<{
570
559
  mainIndex: row.mainIndex ?? row.index,
571
560
  mediaManifest: row.mediaManifest ?? null,
572
561
  documentManifest: row.documentManifest ?? null,
562
+ unparseableSectionIds: row.unparseableSectionIds ?? [],
573
563
  };
574
564
  }
575
565
 
@@ -648,13 +638,20 @@ export async function clearPendingMediaByIds(
648
638
 
649
639
  /** Snapshot a local edit that is about to be superseded (takeover or conflict
650
640
  * resolution) so it is recoverable rather than silently destroyed. */
641
+ let preservedEditSeq = 0;
642
+
651
643
  export async function preserveEdit(
652
644
  sectionId: string,
653
645
  content: SectionContent,
654
646
  reason: "takeover" | "conflict",
655
647
  ): Promise<string> {
656
648
  const preservedAt = new Date().toISOString();
657
- const id = `${sectionId}:${preservedAt}`;
649
+ // A monotonic suffix, because the ISO timestamp only has millisecond
650
+ // resolution: resolving the same section twice inside one millisecond (a
651
+ // takeover immediately followed by a conflict, or two rapid recoveries) put
652
+ // both under the same key, and the `put` destroyed the edit it was called to
653
+ // rescue.
654
+ const id = `${sectionId}:${preservedAt}:${preservedEditSeq++}`;
658
655
  await getDb().preservedEdits.put({ id, sectionId, content, reason, preservedAt });
659
656
  return id;
660
657
  }
@@ -267,11 +267,31 @@ export function applyIndexOps(
267
267
  ...base.data,
268
268
  pages: base.data.pages.map((p) => ({ ...p, order: [...p.order] })),
269
269
  sections: Object.fromEntries(Object.entries(base.data.sections).map(([k, v]) => [k, { ...v }])),
270
- ...(Array.isArray(rawNav) ? { nav: rawNav as NavEntry[] } : {}),
270
+ // COPIED, not aliased. Taking the caller's array by reference meant any nav
271
+ // op that spliced in place would mutate the caller's index — the one thing
272
+ // this function's own comment promises it never does.
273
+ ...(Array.isArray(rawNav)
274
+ ? {
275
+ nav: (rawNav as NavEntry[]).map((entry) =>
276
+ entry && typeof entry === "object" && "pageIds" in entry
277
+ ? { ...entry, pageIds: [...(entry as { pageIds: string[] }).pageIds] }
278
+ : { ...entry },
279
+ ),
280
+ }
281
+ : {}),
271
282
  };
272
283
 
273
284
  const removed = new Set<string>();
274
285
  const warnings: string[] = [];
286
+ // Audience advisories are RECORDED here during the loop and decided after it,
287
+ // against the finished index. Deciding them at op time made them wrong in both
288
+ // directions whenever a later op in the same batch moved the section, removed
289
+ // it, or changed the page's access — insert-then-widen warned about nothing,
290
+ // and widen-then-insert stayed silent about a genuine stranding.
291
+ const advisories: (
292
+ | { kind: "section"; at: string; sectionId: string; pageId: string }
293
+ | { kind: "pageAccess"; at: string; pageId: string; dropped: string[] }
294
+ )[] = [];
275
295
  const fail = (error: string): ApplyIndexOpsResult => ({ ok: false, error });
276
296
  // Whether any op in this batch changed the navigation sidecar. Section-level
277
297
  // ops must leave `nav` OFF the result: content-ops writes nav.json only when
@@ -328,12 +348,7 @@ export function applyIndexOps(
328
348
  // Same advisory as `move`: an inserted section whose audiences the
329
349
  // destination page does not allow is invisible to every audienced
330
350
  // viewer, and saying nothing would make that look like a successful save.
331
- const stranded = page.access.length > 0 ? meta.access.filter((a) => !page.access.includes(a)) : [];
332
- if (stranded.length > 0) {
333
- warnings.push(
334
- `${at}: section "${s.id}" carries audience(s) ${fmt(stranded)} that page "${op.pageId}" does not allow, so no audienced viewer will see it — clear it with setMeta, or widen the page's access.`,
335
- );
336
- }
351
+ advisories.push({ kind: "section", at, sectionId: s.id, pageId: op.pageId });
337
352
  metas.push([s.id, meta]);
338
353
  }
339
354
  page.order.splice(position, 0, ...metas.map(([id]) => id));
@@ -366,12 +381,7 @@ export function applyIndexOps(
366
381
  // change who can see content without saying so. "no audienced viewer",
367
382
  // not "everyone" — audiencePasses returns true for a viewer with no
368
383
  // audience at all (lib/pages.ts).
369
- const stranded = dest.access.length > 0 ? meta.access.filter((a) => !dest.access.includes(a)) : [];
370
- if (stranded.length > 0) {
371
- warnings.push(
372
- `${at}: section "${op.id}" carries audience(s) ${fmt(stranded)} that page "${op.pageId}" does not allow, so no audienced viewer will see it — clear it with setMeta, or widen the page's access.`,
373
- );
374
- }
384
+ advisories.push({ kind: "section", at, sectionId: op.id, pageId: op.pageId });
375
385
  continue;
376
386
  }
377
387
 
@@ -534,6 +544,16 @@ export function applyIndexOps(
534
544
  if (op.slug !== undefined && (RESERVED_SLUGS as readonly string[]).includes(op.slug)) {
535
545
  return fail(`${at}: slug "${op.slug}" is reserved by a platform route and would shadow this page. Pick another slug.`);
536
546
  }
547
+ // Shape and uniqueness inline, exactly as addPage checks them. Leaving these
548
+ // to the final IndexSchema re-validation produced a message carrying none of
549
+ // the `indexOps[i]` prefix every other failure has, so an agent could not
550
+ // tell which op in a batch was rejected.
551
+ if (op.slug !== undefined && !SLUG_RE.test(op.slug)) {
552
+ return fail(`${at}: slug "${op.slug}" is not a valid URL slug — use lowercase words joined by dashes (e.g. "brand-guide").`);
553
+ }
554
+ if (op.slug !== undefined && index.pages.some((p) => p.id !== op.pageId && p.slug === op.slug)) {
555
+ return fail(`${at}: slug "${op.slug}" is already used by another page. Slugs must be unique.`);
556
+ }
537
557
  if (op.title !== undefined) page.title = op.title;
538
558
  if (op.slug !== undefined) page.slug = op.slug;
539
559
  if (op.showInNav !== undefined) page.showInNav = op.showInNav;
@@ -541,14 +561,37 @@ export function applyIndexOps(
541
561
  if (op.access !== undefined) {
542
562
  const dropped = page.access.filter((a) => !op.access!.includes(a));
543
563
  page.access = op.access;
544
- if (dropped.length > 0 && op.access.length > 0) {
545
- const stranded = page.order.filter((id) => index.sections[id]?.access.some((a) => dropped.includes(a)));
546
- if (stranded.length > 0) {
547
- warnings.push(
548
- `${at}: section(s) ${fmt(stranded)} still carry audience(s) ${fmt(dropped)} that page "${op.pageId}" no longer allows, so no audienced viewer will see them — clear them with setMeta.`,
549
- );
550
- }
564
+ if (dropped.length > 0) advisories.push({ kind: "pageAccess", at, pageId: op.pageId, dropped });
565
+ }
566
+ }
567
+
568
+ // Advisories, decided against the finished index (see the declaration above).
569
+ for (const advisory of advisories) {
570
+ const page = index.pages.find((p) => p.id === advisory.pageId);
571
+ if (!page || page.access.length === 0) continue;
572
+
573
+ if (advisory.kind === "section") {
574
+ const meta = index.sections[advisory.sectionId];
575
+ // A later op removed the section, or moved it off this page — either way
576
+ // the stranding this op would have caused no longer exists.
577
+ if (!meta || !page.order.includes(advisory.sectionId)) continue;
578
+ const stranded = meta.access.filter((a) => !page.access.includes(a));
579
+ if (stranded.length > 0) {
580
+ warnings.push(
581
+ `${advisory.at}: section "${advisory.sectionId}" carries audience(s) ${fmt(stranded)} that page "${advisory.pageId}" does not allow, so no audienced viewer will see it — clear it with setMeta, or widen the page's access.`,
582
+ );
551
583
  }
584
+ continue;
585
+ }
586
+
587
+ // A later setPageMeta may have put one of the dropped audiences back.
588
+ const dropped = advisory.dropped.filter((a) => !page.access.includes(a));
589
+ if (dropped.length === 0) continue;
590
+ const stranded = page.order.filter((id) => index.sections[id]?.access.some((a) => dropped.includes(a)));
591
+ if (stranded.length > 0) {
592
+ warnings.push(
593
+ `${advisory.at}: section(s) ${fmt(stranded)} still carry audience(s) ${fmt(dropped)} that page "${advisory.pageId}" no longer allows, so no audienced viewer will see them — clear them with setMeta.`,
594
+ );
552
595
  }
553
596
  }
554
597
 
package/src/lib/loader.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { getSectionSchema, type Section } from "../schemas/sections";
1
+ import { getSectionSchemaForType, type Section } from "../schemas/sections";
2
2
  import { IndexSchema, type SiteIndex, type SectionMeta } from "../schemas/site-config";
3
3
  import { getAllSchemas } from "./registry";
4
4
  import { blockDepth, MAX_BLOCK_DEPTH } from "../schemas/block";
@@ -62,8 +62,12 @@ export function mergeSiteContent(
62
62
  ): SiteContent {
63
63
  const sections: LoadedSection[] = [];
64
64
 
65
+ // Validation is per-TYPE, not against a union of every registered schema.
66
+ // The union validates members in order and fully validates each miss, so a
67
+ // 200-section site with ~20 types paid O(N x M) object validations on every
68
+ // SSR load. Every section schema pins `type: z.literal(...)`, so selecting by
69
+ // type picks exactly the member the union would have matched.
65
70
  const canValidate = getAllSchemas().length >= 2;
66
- const schema = canValidate ? getSectionSchema() : null;
67
71
 
68
72
  const orderedIds = index.pages.flatMap((p) => p.order);
69
73
  for (const id of orderedIds) {
@@ -86,11 +90,15 @@ export function mergeSiteContent(
86
90
  ? { ...baseMeta, type: upgradedType }
87
91
  : baseMeta;
88
92
 
89
- if (canValidate && schema) {
90
- const result = schema.safeParse(raw);
91
- if (!result.success) {
92
- const type = (raw as Record<string, unknown>).type ?? "unknown";
93
- console.warn(`Skipping section "${id}" (type: ${type}): invalid schema`);
93
+ if (canValidate) {
94
+ const type = (raw as Record<string, unknown>)?.type;
95
+ // An unregistered type has no member in the union either, so this is the
96
+ // same outcome the whole-union parse produced — just without validating
97
+ // against every other type first.
98
+ const schema = typeof type === "string" ? getSectionSchemaForType(type) : null;
99
+ const result = schema ? schema.safeParse(raw) : null;
100
+ if (!result?.success) {
101
+ console.warn(`Skipping section "${id}" (type: ${type ?? "unknown"}): invalid schema`);
94
102
  continue;
95
103
  }
96
104
  if (blockDepth(result.data) > MAX_BLOCK_DEPTH) {
@@ -146,12 +154,33 @@ export async function loadSiteContent(contentDir: string): Promise<SiteContent>
146
154
 
147
155
  // nav.json is an optional sidecar — absent (every portal predating page
148
156
  // groups) or unreadable must degrade to a flat nav, never fail the load.
149
- let navFile: unknown;
157
+ //
158
+ // ABSENT and UNREADABLE are not the same answer, though: absent is the normal
159
+ // case and safe to overwrite, while a present-but-unparseable sidecar holds
160
+ // groups this load cannot see. Collapsing both to `undefined` reported
161
+ // navDegraded: false, so the next nav-touching save wholesale-wrote a flat
162
+ // nav.json over a file that was still hand-recoverable. Mirrors the
163
+ // GitHub-backed loader's navRaw/navFile split in @drawnagency/core.
164
+ let navRaw: string | undefined;
165
+ let readFailed = false;
150
166
  try {
151
- navFile = JSON.parse(await fs.readFile(path.join(contentDir, "nav.json"), "utf-8"));
152
- } catch {
153
- navFile = undefined;
167
+ navRaw = await fs.readFile(path.join(contentDir, "nav.json"), "utf-8");
168
+ } catch (err) {
169
+ // Only a genuinely missing file is "absent". A permissions/IO error leaves
170
+ // the sidecar's contents unknown, which must not license overwriting it.
171
+ readFailed = (err as { code?: string })?.code !== "ENOENT";
154
172
  }
155
173
 
156
- return mergeSiteContent(index, sectionFiles, navFile);
174
+ let navFile: unknown;
175
+ let parseFailed = false;
176
+ if (navRaw !== undefined) {
177
+ try {
178
+ navFile = JSON.parse(navRaw);
179
+ } catch {
180
+ parseFailed = true;
181
+ }
182
+ }
183
+
184
+ const content = mergeSiteContent(index, sectionFiles, navFile);
185
+ return readFailed || parseFailed ? { ...content, navDegraded: true } : content;
157
186
  }
@@ -195,12 +195,38 @@ export function healMediaManifest(manifest: MediaManifest): MediaManifest {
195
195
  *
196
196
  * Images union, ours winning, so local alt/folder-move edits survive; a pending
197
197
  * local deletion isn't removed from the manifest until save, so the union can't
198
- * resurrect one. Folders stay ours verbatim — deleting a folder removes it from
199
- * our map with no wire signal, so unioning would undo the delete. Mirrors the
200
- * server-side merge in core's content-ops.
198
+ * resurrect one. Mirrors the server-side merge in core's content-ops.
199
+ *
200
+ * Folders union too, minus the ones we deleted — which is why `base` (the
201
+ * manifest we diverged from, i.e. the last one we fetched) is needed: a folder
202
+ * deletion just removes the entry from our map, so without a baseline "we
203
+ * deleted it" and "they created it" are the same shape. Keeping folders ours
204
+ * verbatim, as this did, silently dropped a concurrent writer's new folders and
205
+ * then let `healMediaManifest` strip the now-dangling `folderId` off their
206
+ * images, flattening their whole organisation to root on our next save.
207
+ *
208
+ * Without `base` it degrades to the old folders-stay-ours behaviour.
201
209
  */
202
- export function rebaseMediaManifest(local: MediaManifest, remote: MediaManifest): MediaManifest {
203
- return { ...local, images: { ...remote.images, ...local.images } };
210
+ export function rebaseMediaManifest(
211
+ local: MediaManifest,
212
+ remote: MediaManifest,
213
+ base?: MediaManifest,
214
+ ): MediaManifest {
215
+ const images = { ...remote.images, ...local.images };
216
+ if (!base) return { ...local, images };
217
+
218
+ const deletedLocally = new Set(
219
+ Object.keys(base.folders).filter((id) => !(id in local.folders)),
220
+ );
221
+ const folders: Record<string, MediaFolder> = {};
222
+ for (const [id, folder] of Object.entries(remote.folders)) {
223
+ if (!deletedLocally.has(id)) folders[id] = folder;
224
+ }
225
+ // Ours last: a local rename or move of a folder we both have wins, matching
226
+ // the image rule above.
227
+ for (const [id, folder] of Object.entries(local.folders)) folders[id] = folder;
228
+
229
+ return { ...local, images, folders };
204
230
  }
205
231
 
206
232
  /**
@@ -43,7 +43,27 @@ function upgradeContainerChildren(section: Obj): unknown {
43
43
  return up;
44
44
  });
45
45
  if (!changed) return section;
46
- return { ...section, content: { ...content, children: mapped } };
46
+ // Normalize the container's OWN layout keys while we are rewriting it. A
47
+ // legacy container that never carried them emitted a content object the
48
+ // container schema then defaulted, so `transform(raw)` no longer deep-equalled
49
+ // `parse(transform(raw))` — the convergence contract this file is built on.
50
+ return {
51
+ ...section,
52
+ content: {
53
+ ...content,
54
+ layout: content?.layout ?? "grid",
55
+ columns: normalizeColumns(content?.columns),
56
+ equalizeRowHeight: content?.equalizeRowHeight ?? false,
57
+ children: mapped,
58
+ },
59
+ };
60
+ }
61
+
62
+ /** The container schema's `columns` default. Emitting `undefined` for a legacy
63
+ * grid with no columns key parsed to 1 anyway — but only AFTER the parse, which
64
+ * broke convergence. */
65
+ function normalizeColumns(columns: unknown): number {
66
+ return typeof columns === "number" && Number.isFinite(columns) ? columns : 1;
47
67
  }
48
68
 
49
69
  /** Carry only known BaseMediaRef (+ video) fields; drops render artifacts (src/srcset/alt) and any stray keys. */
@@ -98,7 +118,7 @@ function upgradeGrid(section: Obj): Obj {
98
118
  id,
99
119
  type: "container",
100
120
  content: {
101
- columns: content.columns,
121
+ columns: normalizeColumns(content.columns),
102
122
  layout: "grid",
103
123
  equalizeRowHeight: false,
104
124
  children: media.map((item, i) => mapRefItem(item, id, i)),
@@ -0,0 +1,108 @@
1
+ // Top progress bar for viewer page navigation. Viewer pages are per-request SSR
2
+ // renders, so a click can sit for a beat with no feedback beyond the browser's
3
+ // own tab spinner; this gives that wait an in-page affordance in the brand
4
+ // color.
5
+ //
6
+ // There is no client router here — the browser performs a real navigation and
7
+ // keeps painting the outgoing page until the new response commits, which is
8
+ // exactly the window the bar fills. Nothing hides it: the incoming document
9
+ // replaces it. The only teardown paths are a bfcache restore (pageshow) and the
10
+ // safety timeout for a navigation that never lands.
11
+
12
+ /** Delay before the bar appears, so a prefetched (near-instant) navigation
13
+ * never flashes it. Mirrors EditorLoading's reasoning. */
14
+ const SHOW_DELAY_MS = 200;
15
+ /** Give up if the navigation never commits (blocked, aborted, offline) rather
16
+ * than leaving a stuck bar across the top of a page the viewer is still on. */
17
+ const SAFETY_MS = 15_000;
18
+
19
+ export interface NavProgressOptions {
20
+ showDelayMs?: number;
21
+ safetyMs?: number;
22
+ }
23
+
24
+ function isModifiedClick(event: MouseEvent): boolean {
25
+ // Anything that doesn't navigate this tab: middle/right click, new tab, new
26
+ // window, download.
27
+ return event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey;
28
+ }
29
+
30
+ /** Would this click actually navigate the current tab to a different document? */
31
+ function navigatesAway(link: HTMLAnchorElement): boolean {
32
+ if (link.hasAttribute("download") || link.dataset.navProgress === "false") return false;
33
+ if (link.target && link.target !== "_self") return false;
34
+ if (!link.getAttribute("href")) return false;
35
+
36
+ let url: URL;
37
+ try {
38
+ url = new URL(link.href, window.location.href);
39
+ } catch {
40
+ return false;
41
+ }
42
+ // mailto:/tel: land here too — their origin is never the page's.
43
+ if (url.origin !== window.location.origin) return false;
44
+ // Same-document hash links, including the nav's `#doc=` document launchers.
45
+ return url.pathname !== window.location.pathname || url.search !== window.location.search;
46
+ }
47
+
48
+ /**
49
+ * Wire the bar rendered by Layout.astro. No-ops when the element is absent,
50
+ * which is how /edit opts out — Astro hoists a layout's module script to the
51
+ * page bundle regardless of the branch it was rendered in, so the element, not
52
+ * the script tag, is the gate.
53
+ *
54
+ * Returns a teardown function (used by tests; production never unwires).
55
+ */
56
+ export function initNavProgress(options?: NavProgressOptions): () => void {
57
+ const bar = document.querySelector<HTMLElement>("[data-nav-progress]");
58
+ if (!bar) return () => {};
59
+
60
+ const showDelayMs = options?.showDelayMs ?? SHOW_DELAY_MS;
61
+ const safetyMs = options?.safetyMs ?? SAFETY_MS;
62
+
63
+ let showTimer: ReturnType<typeof setTimeout> | null = null;
64
+ let safetyTimer: ReturnType<typeof setTimeout> | null = null;
65
+
66
+ // Arrow consts, not function declarations: hoisting would discard the
67
+ // null-narrowing `bar` just got from the guard above.
68
+ const reset = () => {
69
+ if (showTimer) clearTimeout(showTimer);
70
+ if (safetyTimer) clearTimeout(safetyTimer);
71
+ showTimer = null;
72
+ safetyTimer = null;
73
+ bar.classList.remove("is-active");
74
+ };
75
+
76
+ const start = () => {
77
+ reset();
78
+ showTimer = setTimeout(() => {
79
+ showTimer = null;
80
+ bar.classList.add("is-active");
81
+ }, showDelayMs);
82
+ safetyTimer = setTimeout(reset, safetyMs);
83
+ };
84
+
85
+ const onClick = (event: MouseEvent) => {
86
+ if (event.defaultPrevented || isModifiedClick(event)) return;
87
+ const target = event.target;
88
+ const link = target instanceof Element ? target.closest("a") : null;
89
+ if (!(link instanceof HTMLAnchorElement)) return;
90
+ if (!navigatesAway(link)) return;
91
+ start();
92
+ };
93
+
94
+ // A bfcache restore replays the outgoing page as it was — bar included, since
95
+ // it was mid-animation when the viewer left. Clear it.
96
+ const onPageShow = (event: PageTransitionEvent) => {
97
+ if (event.persisted) reset();
98
+ };
99
+
100
+ document.addEventListener("click", onClick);
101
+ window.addEventListener("pageshow", onPageShow);
102
+
103
+ return () => {
104
+ reset();
105
+ document.removeEventListener("click", onClick);
106
+ window.removeEventListener("pageshow", onPageShow);
107
+ };
108
+ }
@@ -40,16 +40,30 @@ export function reconcileNav(entries: NavEntry[], pages: Page[]): NavEntry[] {
40
40
  }
41
41
 
42
42
  if (entry?.kind !== "group") continue;
43
+ // `id` is the one group key with no schema default, so a group missing it
44
+ // (or carrying a non-string) cannot be repaired — spreading it through left
45
+ // an entry that NavFileSchema rejects, which is a nav the writer refuses,
46
+ // and "total" would be a lie. Dropping it spills its members to the tail
47
+ // append below: a group lost, never a page.
48
+ if (typeof entry.id !== "string" || entry.id === "") continue;
43
49
  if (seenGroupIds.has(entry.id)) continue;
44
50
  seenGroupIds.add(entry.id);
45
51
 
46
52
  const claimed = Array.isArray(entry.pageIds) ? entry.pageIds : [];
47
53
  const pageIds = claimed.filter((id) => {
48
- if (!validIds.has(id) || placed.has(id)) return false;
54
+ if (typeof id !== "string" || !validIds.has(id) || placed.has(id)) return false;
49
55
  placed.add(id);
50
56
  return true;
51
57
  });
52
- const group: NavGroupEntry = { ...entry, pageIds };
58
+ // Rebuilt key by key rather than spread: the other two keys default in the
59
+ // schema, so normalizing them here is what makes every output parseable.
60
+ const group: NavGroupEntry = {
61
+ kind: "group",
62
+ id: entry.id,
63
+ label: typeof entry.label === "string" ? entry.label : "",
64
+ startCollapsed: entry.startCollapsed === true,
65
+ pageIds,
66
+ };
53
67
  result.push(group);
54
68
  }
55
69
 
package/src/lib/nav.ts CHANGED
@@ -51,6 +51,10 @@ export function generateNavLinks(
51
51
  if (role === "h1") {
52
52
  if (content.excludeFromNav) {
53
53
  currentParent = null;
54
+ // Clear the child cursor too: leaving it pointing into the PREVIOUS
55
+ // h1's subtree made an h3 following an excluded h1 nest under that
56
+ // earlier branch instead of being dropped with its excluded parent.
57
+ currentChild = null;
54
58
  continue;
55
59
  }
56
60
  currentParent = {
@@ -62,7 +66,12 @@ export function generateNavLinks(
62
66
  currentChild = null;
63
67
  nav.push(currentParent);
64
68
  } else if (role === "h2") {
65
- if (content.excludeFromNav) continue;
69
+ // Same cursor reset as the excluded h1 above — an h3 under an excluded
70
+ // h2 belongs nowhere, not under the preceding h2.
71
+ if (content.excludeFromNav) {
72
+ currentChild = null;
73
+ continue;
74
+ }
66
75
  if (!currentParent) continue;
67
76
  currentChild = {
68
77
  href: `#${toSectionId(content.heading)}`,