@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
@@ -40,79 +40,99 @@ export function useEditableCollection<T>({
40
40
  wrapItems(items)
41
41
  );
42
42
  const prevItemsRef = useRef(items);
43
+ // State updaters must be pure. Every mutator below used to call the parent's
44
+ // onChange from inside setWrappedItems, so React 18/19 StrictMode's dev
45
+ // double-invoke fired it twice (deleting one icon-list row emitted two
46
+ // changes), and React is free to re-run or discard an updater at any time.
47
+ // Mirroring the list here lets each mutator compute `next` outside the
48
+ // updater; the eager write keeps two mutations in one tick consistent.
49
+ const wrappedRef = useRef(wrappedItems);
50
+ wrappedRef.current = wrappedItems;
51
+
52
+ const commit = useCallback(
53
+ (next: WrappedItem<T>[]) => {
54
+ wrappedRef.current = next;
55
+ setWrappedItems(next);
56
+ onChange(next.map((w) => w.data));
57
+ },
58
+ [onChange],
59
+ );
43
60
 
44
61
  useEffect(() => {
45
62
  if (items === prevItemsRef.current) return;
46
63
  prevItemsRef.current = items;
47
64
 
48
65
  setWrappedItems((prev) => {
49
- return items.map((data, i) => {
50
- if (i < prev.length) {
51
- return { id: prev[i].id, data };
52
- }
53
- return { id: uid(), data };
66
+ // Identity first, position only for the remainder. Keyed purely by index,
67
+ // a parent-side removal or reorder re-pointed every id one slot over:
68
+ // React reused the wrong row's DOM, so the focused input, the in-progress
69
+ // edit and the drag registration all belonged to a different item than
70
+ // the one now rendered there. Pure identity would be worse in the other
71
+ // direction — an in-place edit hands back a fresh object for that row, and
72
+ // minting a new id there remounts the field the user is typing into.
73
+ const taken = new Array<boolean>(prev.length).fill(false);
74
+ const slotsByData = new Map<unknown, number[]>();
75
+ prev.forEach((w, i) => {
76
+ const bucket = slotsByData.get(w.data);
77
+ if (bucket) bucket.push(i);
78
+ else slotsByData.set(w.data, [i]);
54
79
  });
80
+
81
+ const matched = items.map((data) => {
82
+ const slot = slotsByData.get(data)?.shift();
83
+ if (slot === undefined) return null;
84
+ taken[slot] = true;
85
+ return prev[slot].id;
86
+ });
87
+
88
+ // Unmatched rows (edited in place, or genuinely new) take the surviving
89
+ // ids in their original order, which for a pure edit is the same id the
90
+ // row already had.
91
+ const spare = prev.filter((_, i) => !taken[i]).map((w) => w.id);
92
+ let next = 0;
93
+ return items.map((data, i) => ({ id: matched[i] ?? spare[next++] ?? uid(), data }));
55
94
  });
56
95
  }, [items]);
57
96
 
58
97
  const onReorder = useCallback(
59
98
  (fromIndex: number, toIndex: number) => {
60
- setWrappedItems((prev) => {
61
- const next = [...prev];
62
- const [moved] = next.splice(fromIndex, 1);
63
- next.splice(toIndex, 0, moved);
64
- onChange(next.map((w) => w.data));
65
- return next;
66
- });
99
+ const next = [...wrappedRef.current];
100
+ const [moved] = next.splice(fromIndex, 1);
101
+ next.splice(toIndex, 0, moved);
102
+ commit(next);
67
103
  },
68
- [onChange]
104
+ [commit]
69
105
  );
70
106
 
71
107
  const onAdd = useCallback(() => {
72
- const newItem = createItem();
73
- setWrappedItems((prev) => {
74
- const next = [...prev, { id: uid(), data: newItem }];
75
- onChange(next.map((w) => w.data));
76
- return next;
77
- });
78
- }, [createItem, onChange]);
108
+ commit([...wrappedRef.current, { id: uid(), data: createItem() }]);
109
+ }, [createItem, commit]);
79
110
 
80
111
  const onInsert = useCallback(
81
112
  (index: number) => {
82
- const newItem = createItem();
83
- setWrappedItems((prev) => {
84
- const next = [...prev];
85
- next.splice(index, 0, { id: uid(), data: newItem });
86
- onChange(next.map((w) => w.data));
87
- return next;
88
- });
113
+ const next = [...wrappedRef.current];
114
+ next.splice(index, 0, { id: uid(), data: createItem() });
115
+ commit(next);
89
116
  },
90
- [createItem, onChange],
117
+ [createItem, commit],
91
118
  );
92
119
 
93
120
  // Insert an externally-provided item (e.g. one dragged in from another grid)
94
121
  // at a specific index. Distinct from onInsert, which creates a blank item.
95
122
  const onInsertItem = useCallback(
96
123
  (index: number, data: T) => {
97
- setWrappedItems((prev) => {
98
- const next = [...prev];
99
- next.splice(index, 0, { id: uid(), data });
100
- onChange(next.map((w) => w.data));
101
- return next;
102
- });
124
+ const next = [...wrappedRef.current];
125
+ next.splice(index, 0, { id: uid(), data });
126
+ commit(next);
103
127
  },
104
- [onChange]
128
+ [commit]
105
129
  );
106
130
 
107
131
  const onRemove = useCallback(
108
132
  (id: string) => {
109
- setWrappedItems((prev) => {
110
- const next = prev.filter((w) => w.id !== id);
111
- onChange(next.map((w) => w.data));
112
- return next;
113
- });
133
+ commit(wrappedRef.current.filter((w) => w.id !== id));
114
134
  },
115
- [onChange]
135
+ [commit]
116
136
  );
117
137
 
118
138
  return { wrappedItems, onReorder, onAdd, onInsert, onInsertItem, onRemove };
@@ -29,16 +29,37 @@ export function useEditablePlainText({
29
29
  }
30
30
  }, [propValue]);
31
31
 
32
+ // Latest onChange, so committing on every keystroke doesn't need it in a dep
33
+ // array (the parent re-creates it inline every render).
34
+ const onChangeRef = useRef(onChange);
35
+ useEffect(() => {
36
+ onChangeRef.current = onChange;
37
+ }, [onChange]);
38
+
39
+ // Commit on every input, not only on blur — the same contract
40
+ // useEditableRichText adopted, and for the same reason: pressing ⌘S (or any
41
+ // keyboard save/publish, or an autosave flush) without leaving the field
42
+ // flushes persistence before blur ever fires, so a blur-only commit silently
43
+ // drops the in-progress edit. Deliberately NOT debounced: any debounce window
44
+ // is the same bug, just narrower.
32
45
  const handleInput = useCallback((text: string) => {
33
46
  setValue(text);
47
+ if (text !== lastPropValue.current) {
48
+ lastPropValue.current = text;
49
+ onChangeRef.current(text);
50
+ }
34
51
  }, []);
35
52
 
53
+ // Now a flush rather than the only commit: a no-op unless something changed
54
+ // `value` without routing through handleInput. Compared against the last
55
+ // COMMITTED text rather than `propValue`, so a parent that doesn't echo the
56
+ // value straight back doesn't get the same edit twice.
36
57
  const handleBlur = useCallback(() => {
37
- if (value !== propValue) {
38
- onChange(value);
58
+ if (value !== lastPropValue.current) {
39
59
  lastPropValue.current = value;
60
+ onChangeRef.current(value);
40
61
  }
41
- }, [value, propValue, onChange]);
62
+ }, [value]);
42
63
 
43
64
  return {
44
65
  value,
@@ -80,7 +80,14 @@ export default defineSection({
80
80
  showLabel={options?.showLabel as boolean}
81
81
  stackText={options?.stackText as boolean}
82
82
  dodont={options?.dodont as "do" | "dont" | undefined}
83
- onChange={onChange ? (c) => onChange(c as typeof content) : undefined}
83
+ // IconList rebuilds its options from the four props above, which inside a
84
+ // container are childDefaults merged with the child's own options — writing
85
+ // that back would materialise the container's defaults onto the child (it
86
+ // then stops following later default changes) and drop any option key
87
+ // outside those four. Only items change here, so keep the stored options.
88
+ onChange={
89
+ onChange ? (c) => onChange({ ...(c as typeof content), options: content.options }) : undefined
90
+ }
84
91
  />
85
92
  ),
86
93
  defaults: () => ({
@@ -1,5 +1,5 @@
1
1
  import { EditableRichText } from "../../primitives/EditableRichText";
2
- import { sanitizeHtml } from "../../../lib/sanitize";
2
+ import { useSanitizedHtml } from "../../../hooks/useSanitizedHtml";
3
3
  import type { SectionContent } from "../../../schemas/sections";
4
4
 
5
5
  interface Props {
@@ -8,6 +8,8 @@ interface Props {
8
8
  }
9
9
 
10
10
  export default function Prose({ body, onChange }: Props) {
11
+ const safeHtml = useSanitizedHtml(body);
12
+
11
13
  if (onChange) {
12
14
  return (
13
15
  <div className="prose-content">
@@ -29,7 +31,7 @@ export default function Prose({ body, onChange }: Props) {
29
31
  return (
30
32
  <div
31
33
  className="prose-content"
32
- dangerouslySetInnerHTML={{ __html: sanitizeHtml(body) }}
34
+ dangerouslySetInnerHTML={{ __html: safeHtml }}
33
35
  />
34
36
  );
35
37
  }
@@ -22,17 +22,21 @@ function displayValue(value: string): string {
22
22
  return value === "system-ui" ? "System Default" : value;
23
23
  }
24
24
 
25
- function loadFontPreview(families: string[]) {
26
- if (families.length === 0) return;
25
+ // Returns the element it added, so the picker can take it back down. These
26
+ // stylesheets exist only for the dropdown's own preview list — the site's
27
+ // selected fonts load from site-config's `googleFontsUrl`, not from here.
28
+ function loadFontPreview(families: string[]): HTMLLinkElement | null {
29
+ if (families.length === 0) return null;
27
30
  const params = families.map((f) => `family=${f.replace(/ /g, "+")}:wght@400;700`).join("&");
28
31
  const href = `https://fonts.googleapis.com/css2?${params}&display=swap`;
29
32
 
30
- if (document.querySelector(`link[href="${href}"]`)) return;
33
+ if (document.querySelector(`link[href="${href}"]`)) return null;
31
34
 
32
35
  const link = document.createElement("link");
33
36
  link.rel = "stylesheet";
34
37
  link.href = href;
35
38
  document.head.appendChild(link);
39
+ return link;
36
40
  }
37
41
 
38
42
  export function FontPicker({ label, value, onChange }: FontPickerProps) {
@@ -47,6 +51,7 @@ export function FontPicker({ label, value, onChange }: FontPickerProps) {
47
51
  const loadedRef = useRef(new Set<string>());
48
52
  const batchRef = useRef<string[]>([]);
49
53
  const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
54
+ const previewLinksRef = useRef<HTMLLinkElement[]>([]);
50
55
  const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
51
56
  const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; width: number }>({
52
57
  top: 0,
@@ -84,16 +89,23 @@ export function FontPicker({ label, value, onChange }: FontPickerProps) {
84
89
  clearTimeout(timerRef.current);
85
90
  timerRef.current = setTimeout(() => {
86
91
  const batch = batchRef.current.splice(0);
87
- if (batch.length > 0) loadFontPreview(batch);
92
+ if (batch.length === 0) return;
93
+ const link = loadFontPreview(batch);
94
+ if (link) previewLinksRef.current.push(link);
88
95
  }, 100);
89
96
  }, []);
90
97
 
91
98
  const observerRef = useRef<IntersectionObserver | undefined>(undefined);
92
99
 
93
100
  useEffect(() => {
101
+ const links = previewLinksRef.current;
94
102
  return () => {
95
103
  clearTimeout(timerRef.current);
96
104
  clearTimeout(debounceRef.current);
105
+ // Scrolling the ~1,500-font list loads a batch every 100ms, and every one
106
+ // of those left a <link> (and its Google Fonts request) behind for the
107
+ // rest of the session.
108
+ for (const link of links.splice(0)) link.remove();
97
109
  };
98
110
  }, []);
99
111
 
@@ -58,7 +58,18 @@ interface HistoryChangesModalProps {
58
58
  }
59
59
 
60
60
  // Commit ranges are immutable, so a fetched summary stays valid for the session.
61
+ // Bounded with FIFO eviction: each entry holds every changed section's title, and
62
+ // browsing a long history otherwise accumulated them all until reload.
61
63
  const cache = new Map<string, ChangeSummary>();
64
+ const CACHE_LIMIT = 50;
65
+
66
+ function cacheSummary(key: string, summary: ChangeSummary): void {
67
+ if (cache.size >= CACHE_LIMIT) {
68
+ const oldest = cache.keys().next().value;
69
+ if (oldest !== undefined) cache.delete(oldest);
70
+ }
71
+ cache.set(key, summary);
72
+ }
62
73
 
63
74
  /**
64
75
  * Bump whenever the response shape or the meaning of a field changes.
@@ -133,7 +144,7 @@ export function HistoryChangesModal({
133
144
  const res = await fetch(url);
134
145
  if (!res.ok) throw new Error(String(res.status));
135
146
  const data = (await res.json()) as ChangeSummary;
136
- cache.set(url, data);
147
+ cacheSummary(url, data);
137
148
  if (live) setSummary(data);
138
149
  } catch {
139
150
  if (live) setError(true);
@@ -80,25 +80,39 @@ export function HistoryPopover({ onSelectCommit, hasUnsavedChanges = false }: Hi
80
80
  }), []);
81
81
 
82
82
  useEffect(() => {
83
+ const controller = new AbortController();
83
84
  let live = true;
84
85
  (async () => {
85
86
  const all: HistoryCommit[] = [];
86
87
  try {
87
88
  for (let page = 1; page <= MAX_PAGES; page++) {
88
- const res = await fetch(`/api/history?page=${page}&per_page=${PER_PAGE}`);
89
+ const res = await fetch(`/api/history?page=${page}&per_page=${PER_PAGE}`, {
90
+ signal: controller.signal,
91
+ });
89
92
  if (!res.ok) throw new Error(`Failed to load history: ${res.status}`);
90
93
  const data = (await res.json()) as { items: HistoryCommit[] };
91
94
  all.push(...data.items);
95
+ // Render each page as it lands. These requests are sequential, so waiting
96
+ // for all ten left the popover on "Loading history…" for the whole run —
97
+ // and the first page is already more than fills the visible tree.
98
+ if (live) {
99
+ setCommits([...all]);
100
+ setLoading(false);
101
+ }
92
102
  if (data.items.length < PER_PAGE) break;
93
103
  }
94
- if (live) setCommits(all);
95
104
  } catch (err) {
96
- if (live) setError(err instanceof Error ? err.message : "Failed to load history");
105
+ if (!live || controller.signal.aborted) return;
106
+ // A later page failing is not worth throwing away the pages that landed:
107
+ // the error view replaces the tree entirely. Keep what we have instead.
108
+ if (all.length === 0) setError(err instanceof Error ? err.message : "Failed to load history");
97
109
  } finally {
98
110
  if (live) setLoading(false);
99
111
  }
100
112
  })();
101
- return () => { live = false; };
113
+ // Closing the popover unmounts this; without the abort the remaining pages
114
+ // kept fetching against GitHub's rate limit for nothing.
115
+ return () => { live = false; controller.abort(); };
102
116
  }, []);
103
117
 
104
118
  const groups = useMemo(() => buildHistoryTree(commits, new Date()), [commits]);
@@ -110,12 +110,27 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
110
110
  }
111
111
  }, [identity?.kind]);
112
112
 
113
+ // The transition-suppression class is removed on a timer; unmounting mid-flight
114
+ // (a navigation right after a toggle) would otherwise leave `theme-changing`
115
+ // pinned on <html>, killing every transition on the page.
116
+ const themeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
117
+ useEffect(() => () => {
118
+ if (themeTimerRef.current) {
119
+ clearTimeout(themeTimerRef.current);
120
+ document.documentElement.classList.remove("theme-changing");
121
+ }
122
+ }, []);
123
+
113
124
  const handleThemeToggle = useCallback((next: boolean) => {
114
125
  const root = document.documentElement;
115
126
  root.classList.add("theme-changing");
116
127
  root.classList.toggle("dark", next);
117
128
  localStorage.setItem("theme", next ? "dark" : "light");
118
- setTimeout(() => root.classList.remove("theme-changing"), 200);
129
+ if (themeTimerRef.current) clearTimeout(themeTimerRef.current);
130
+ themeTimerRef.current = setTimeout(() => {
131
+ themeTimerRef.current = null;
132
+ root.classList.remove("theme-changing");
133
+ }, 200);
119
134
  }, []);
120
135
 
121
136
  // The active page can live in any tier (editors navigate to hidden and
@@ -299,7 +314,10 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
299
314
  </button>
300
315
  ) : (
301
316
  <a
302
- href={`#doc=${doc.sectionId}`}
317
+ // Encoded, like openDocFile's own hash write — readHash decodes, so an
318
+ // unencoded writer and a decoding reader disagree on any id that needs
319
+ // escaping.
320
+ href={`#doc=${encodeURIComponent(doc.sectionId)}`}
303
321
  onClick={() => setIsOpen(false)}
304
322
  className="block w-full rounded-inner px-3 py-2 text-left text-sm font-bold text-base-contrast transition-colors hover:text-primary"
305
323
  >
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
2
2
  import { createPortal } from "react-dom";
3
- import { useFocusTrap } from "../../hooks/useFocusTrap";
3
+ import { useInitialFocus } from "../../hooks/useFocusTrap";
4
4
  import { cn } from "../../lib/cn";
5
5
 
6
6
  interface PopoverProps {
@@ -35,10 +35,14 @@ export function Popover({
35
35
  const [resolvedAlign, setResolvedAlign] = useState<"start" | "end">("start");
36
36
  const [fixedPos, setFixedPos] = useState<{ left: number; top?: number; bottom?: number } | null>(null);
37
37
 
38
- // In portal mode the panel isn't rendered until it's been measured, so the
39
- // trap must activate on that second commit — useFocusTrap bails on a null ref
40
- // and only re-runs when `active` changes.
41
- useFocusTrap(panelRef, isOpen && (!portal || fixedPos !== null));
38
+ // Focus the first control on open and restore it on close, but do NOT trap
39
+ // Tab: this surface declares aria-modal="false", and trapping left a keyboard
40
+ // user with no way out of a popover that isn't modal. Dismissal stays with
41
+ // Escape and outside-mousedown, both below.
42
+ // In portal mode the panel isn't rendered until it's been measured, so this
43
+ // activates on that second commit — the hook bails on a null ref and only
44
+ // re-runs when `active` changes.
45
+ useInitialFocus(panelRef, isOpen && (!portal || fixedPos !== null));
42
46
 
43
47
  // Track the anchor while portalled: the panel is out of normal flow, so it
44
48
  // has to be repositioned on resize and on any ancestor's scroll.
@@ -1,24 +1,19 @@
1
1
  import { useState, type FormEvent } from "react";
2
2
  import { AudienceColorSchema } from "../../schemas/audience";
3
3
  import { Input } from "../shared/Input";
4
- import { PasswordInput } from "../shared/PasswordInput";
5
4
  import { Button } from "../shared/Button";
6
5
  import { ColorPicker } from "../shared/ColorPicker";
7
6
 
8
7
  interface Props {
9
- onSubmit: (data: { displayName: string; color: string | null; password: string }) => Promise<void>;
8
+ onSubmit: (data: { displayName: string; color: string | null }) => Promise<void>;
10
9
  onCancel: () => void;
11
- /** Credential mode: an audience has no password of its own — access comes from
12
- * the named sign-ins added to it afterwards, so the field is hidden. */
13
- requirePassword?: boolean;
14
10
  }
15
11
 
16
12
  const DEFAULT_COLOR = "#3b82f6";
17
13
 
18
- export function AudienceAddForm({ onSubmit, onCancel, requirePassword = true }: Props) {
14
+ export function AudienceAddForm({ onSubmit, onCancel }: Props) {
19
15
  const [name, setName] = useState("");
20
16
  const [color, setColor] = useState(DEFAULT_COLOR);
21
- const [password, setPassword] = useState("");
22
17
  const [error, setError] = useState<string | null>(null);
23
18
  const [submitting, setSubmitting] = useState(false);
24
19
 
@@ -35,14 +30,9 @@ export function AudienceAddForm({ onSubmit, onCancel, requirePassword = true }:
35
30
  setError("Invalid color");
36
31
  return;
37
32
  }
38
- if (requirePassword && password.length === 0) {
39
- setError("Password is required");
40
- return;
41
- }
42
-
43
33
  setSubmitting(true);
44
34
  try {
45
- await onSubmit({ displayName: name, color, password });
35
+ await onSubmit({ displayName: name, color });
46
36
  } catch (e) {
47
37
  setError(e instanceof Error ? e.message : "Add failed");
48
38
  } finally {
@@ -64,19 +54,9 @@ export function AudienceAddForm({ onSubmit, onCancel, requirePassword = true }:
64
54
  value={name}
65
55
  onChange={setName}
66
56
  />
67
- {requirePassword ? (
68
- <PasswordInput
69
- label="Password"
70
- value={password}
71
- onChange={setPassword}
72
- autoComplete="new-password"
73
- showToggle
74
- />
75
- ) : (
76
- <p className="text-[11px] text-base-contrast-light">
77
- Add sign-ins to this audience once it exists — that is what grants access.
78
- </p>
79
- )}
57
+ <p className="text-[11px] text-base-contrast-light">
58
+ Add sign-ins to this audience once it exists — that is what grants access.
59
+ </p>
80
60
  </div>
81
61
  </div>
82
62
  {error && <p className="text-xs text-red-600">{error}</p>}
@@ -1,7 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import type { Audience } from "../../auth/types";
3
3
  import { Input } from "../shared/Input";
4
- import { PasswordInput } from "../shared/PasswordInput";
5
4
  import { Button } from "../shared/Button";
6
5
  import { ColorPicker } from "../shared/ColorPicker";
7
6
  import { CredentialList } from "./CredentialList";
@@ -11,10 +10,10 @@ interface Props {
11
10
  audience: Audience;
12
11
  isDefault: boolean;
13
12
  canMutate: boolean;
14
- onSave: (patch: { displayName?: string; color?: string | null; password?: string }) => Promise<void>;
13
+ onSave: (patch: { displayName?: string; color?: string | null }) => Promise<void>;
15
14
  onDelete: () => Promise<void>;
16
- /** Named sign-ins for this audience. Undefined in `.env`/audience mode, where
17
- * the row keeps its single audience-level password field. */
15
+ /** Named sign-ins for this audience. Undefined in `.env` mode, whose audiences
16
+ * come from environment variables and cannot be mutated at all. */
18
17
  credentials?: ViewerCredential[];
19
18
  credentialActions?: {
20
19
  create: (data: { username: string; label: string | null; password: string }) => Promise<void>;
@@ -35,21 +34,18 @@ export function AudienceRow({
35
34
  credentials,
36
35
  credentialActions,
37
36
  }: Props) {
38
- // Credential mode: access comes from named sign-ins, so the row's own password
39
- // field is gone. The two are never shown together.
40
37
  const credentialMode = Boolean(credentials && credentialActions);
41
38
  const [isEditing, setIsEditing] = useState(false);
42
39
  const [draftDisplayName, setDraftDisplayName] = useState(audience.displayName);
43
40
  const [draftColor, setDraftColor] = useState(audience.color ?? PLACEHOLDER_COLOR);
44
- const [draftPassword, setDraftPassword] = useState("");
45
41
  const [error, setError] = useState<string | null>(null);
42
+ const [confirmingRemove, setConfirmingRemove] = useState(false);
46
43
  const [saving, setSaving] = useState(false);
47
44
 
48
45
  function startEdit() {
49
46
  setIsEditing(true);
50
47
  setDraftColor(audience.color ?? PLACEHOLDER_COLOR);
51
48
  setDraftDisplayName(audience.displayName);
52
- setDraftPassword("");
53
49
  setError(null);
54
50
  }
55
51
 
@@ -61,7 +57,7 @@ export function AudienceRow({
61
57
  async function save() {
62
58
  setError(null);
63
59
  setSaving(true);
64
- const patch: { displayName?: string; color?: string | null; password?: string } = {};
60
+ const patch: { displayName?: string; color?: string | null } = {};
65
61
  if (draftDisplayName.trim().length === 0) {
66
62
  setError("Name is required");
67
63
  setSaving(false);
@@ -73,9 +69,6 @@ export function AudienceRow({
73
69
  if (draftColor !== (audience.color ?? PLACEHOLDER_COLOR)) {
74
70
  patch.color = draftColor;
75
71
  }
76
- if (draftPassword.length > 0) {
77
- patch.password = draftPassword;
78
- }
79
72
  if (Object.keys(patch).length === 0) {
80
73
  setIsEditing(false);
81
74
  setSaving(false);
@@ -91,14 +84,11 @@ export function AudienceRow({
91
84
  }
92
85
  }
93
86
 
87
+ // In-app confirmation rather than window.confirm: the native dialog is
88
+ // unstyled, blocks the whole tab, and is suppressible by the browser — the
89
+ // same reason SiteSettingsUsers confirms revocation inline.
94
90
  async function remove() {
95
- const signIns = audience.credentialCount;
96
- if (!confirm(
97
- `Remove audience "${audience.displayName}"?` +
98
- (signIns > 0
99
- ? `\n\nIts ${signIns === 1 ? "sign-in" : `${signIns} sign-ins`} will be removed too, locking out anyone using them.`
100
- : ""),
101
- )) return;
91
+ setConfirmingRemove(false);
102
92
  try {
103
93
  await onDelete();
104
94
  } catch (e) {
@@ -117,21 +107,6 @@ export function AudienceRow({
117
107
  value={draftDisplayName}
118
108
  onChange={setDraftDisplayName}
119
109
  />
120
- {!credentialMode && (
121
- <>
122
- <PasswordInput
123
- label="New password"
124
- value={draftPassword}
125
- onChange={setDraftPassword}
126
- autoComplete="new-password"
127
- placeholder={audience.hasPassword ? "Leave blank to keep current" : "Required"}
128
- showToggle
129
- />
130
- <span className="block text-[11px] text-base-contrast-light">
131
- Passwords are hashed — the existing one can't be recovered, only replaced.
132
- </span>
133
- </>
134
- )}
135
110
  </div>
136
111
  </div>
137
112
  {error && <p className="text-xs text-red-600">{error}</p>}
@@ -179,24 +154,39 @@ export function AudienceRow({
179
154
  {audience.credentialCount === 1 ? "1 sign-in" : `${audience.credentialCount} sign-ins`}
180
155
  </span>
181
156
  ) : audience.hasPassword ? (
157
+ // `.env` mode only. Its audiences are read-only here — the password
158
+ // lives in a VIEWER_<NAME>_PASSWORD environment variable.
182
159
  <span
183
160
  aria-label="Password is set"
184
- title="Passwords are hashed and can't be shown. Edit to set a new one."
161
+ title="Set by an environment variable; it can't be read or changed from here."
185
162
  className="text-xs tracking-widest text-base-contrast-light"
186
163
  >
187
164
  ••••
188
165
  </span>
189
- ) : canMutate ? (
190
- <Button variant="ghost" onClick={startEdit}>Set password</Button>
191
166
  ) : null}
192
167
  {canMutate && (
193
168
  <Button variant="ghost" onClick={startEdit}>Edit</Button>
194
169
  )}
195
- {canMutate && !isDefault && (
196
- <Button variant="ghost" tone="destructive" onClick={remove}>Remove</Button>
170
+ {canMutate && !isDefault && !confirmingRemove && (
171
+ <Button variant="ghost" tone="destructive" onClick={() => setConfirmingRemove(true)}>Remove</Button>
197
172
  )}
198
173
  </div>
199
174
 
175
+ {confirmingRemove && (
176
+ <div role="alertdialog" aria-label={`Remove ${audience.displayName}`} className="mt-2 rounded border border-red-200 bg-red-50 px-3 py-2 dark:border-red-900/50 dark:bg-red-950/30">
177
+ <p className="text-sm text-red-800 dark:text-red-200">
178
+ Remove &ldquo;{audience.displayName}&rdquo;?
179
+ {audience.credentialCount > 0 && (
180
+ <> Its {audience.credentialCount === 1 ? "sign-in" : `${audience.credentialCount} sign-ins`} go with it, locking out anyone using them.</>
181
+ )}
182
+ </p>
183
+ <div className="mt-2 flex justify-end gap-2">
184
+ <Button variant="secondary" onClick={() => setConfirmingRemove(false)}>Cancel</Button>
185
+ <Button variant="destructive" onClick={remove}>Remove</Button>
186
+ </div>
187
+ </div>
188
+ )}
189
+
200
190
  {credentials && credentialActions && (
201
191
  <CredentialList
202
192
  audienceName={audience.displayName}