@drawnagency/primitives 0.1.108 → 0.1.110

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 (216) 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-5IZ2KTMU.js → chunk-3GAG6ZXB.js} +55 -46
  9. package/dist/{chunk-PZJL2S3Z.js → chunk-3ZRQ4G5F.js} +1 -1
  10. package/dist/{chunk-FM4M4Q2W.js → chunk-4TZJZU2T.js} +23 -3
  11. package/dist/{chunk-LUAI5DWR.js → chunk-7MI3HCOF.js} +60 -23
  12. package/dist/{chunk-ZHHJVKU7.js → chunk-7WQJ57XG.js} +63 -204
  13. package/dist/{chunk-JIQBLG6Y.js → chunk-ALMEC3XN.js} +12 -13
  14. package/dist/{chunk-TZQPOR5A.js → chunk-DMCGN5AS.js} +10 -3
  15. package/dist/{chunk-D6TLUVOP.js → chunk-ELQ4C677.js} +48 -2
  16. package/dist/chunk-F2LYQPXV.js +77 -0
  17. package/dist/{chunk-5BM2Y3NJ.js → chunk-FRVO75DK.js} +14 -2
  18. package/dist/{chunk-VKAGMEKE.js → chunk-FTPETWXE.js} +1 -0
  19. package/dist/{chunk-3TO4RRHY.js → chunk-GPPVYLEI.js} +7 -0
  20. package/dist/{chunk-NLQ5F3RQ.js → chunk-HBULBDGP.js} +1 -1
  21. package/dist/{chunk-IPKZ5FU6.js → chunk-NXGIHSL5.js} +72 -55
  22. package/dist/{chunk-CPTFFLK2.js → chunk-OTDA6KWL.js} +10 -3
  23. package/dist/chunk-OUYB2GCW.js +194 -0
  24. package/dist/{chunk-72CG6AA2.js → chunk-RA5IOFPY.js} +10 -2
  25. package/dist/{chunk-GEENI3OC.js → chunk-SW6PCQQD.js} +4 -0
  26. package/dist/{chunk-BOZUEKWL.js → chunk-UVIYI4J6.js} +1 -1
  27. package/dist/{chunk-AB3XVXI3.js → chunk-VCCNKTFD.js} +37 -29
  28. package/dist/{chunk-XBYJQ5RS.js → chunk-XQMDWXD6.js} +21 -6
  29. package/dist/collab/rebase.d.ts +6 -0
  30. package/dist/collab/rebase.d.ts.map +1 -1
  31. package/dist/collab/rebase.js +12 -3
  32. package/dist/collab/recovery.d.ts +0 -20
  33. package/dist/collab/recovery.d.ts.map +1 -1
  34. package/dist/components/brandguide/ColorSwatchSettings.d.ts.map +1 -1
  35. package/dist/components/brandguide/ColorsView.d.ts.map +1 -1
  36. package/dist/components/brandguide/colors-shared.d.ts +10 -1
  37. package/dist/components/brandguide/colors-shared.d.ts.map +1 -1
  38. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  39. package/dist/components/docfile/DocFilePane.d.ts.map +1 -1
  40. package/dist/components/docfile/PdfCanvasViewer.d.ts.map +1 -1
  41. package/dist/components/docfile/doc-file-state.d.ts.map +1 -1
  42. package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
  43. package/dist/components/editor/NavGroupRow.d.ts +7 -2
  44. package/dist/components/editor/NavGroupRow.d.ts.map +1 -1
  45. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  46. package/dist/components/editor/index.js +14 -13
  47. package/dist/components/primitives/EditableRichText.d.ts.map +1 -1
  48. package/dist/components/primitives/LinkPopover.d.ts.map +1 -1
  49. package/dist/components/primitives/index.d.ts +2 -0
  50. package/dist/components/primitives/index.d.ts.map +1 -1
  51. package/dist/components/primitives/index.js +22 -13
  52. package/dist/components/primitives/useEditableCollection.d.ts.map +1 -1
  53. package/dist/components/primitives/useEditablePlainText.d.ts.map +1 -1
  54. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  55. package/dist/components/sections/Prose/Prose.d.ts.map +1 -1
  56. package/dist/components/sections/brand-guide.js +10 -10
  57. package/dist/components/sections/register-schemas.js +16 -16
  58. package/dist/components/sections/register.js +16 -16
  59. package/dist/components/shared/FontPicker.d.ts.map +1 -1
  60. package/dist/components/shared/HistoryChangesModal.d.ts.map +1 -1
  61. package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
  62. package/dist/components/shared/Navigation.d.ts.map +1 -1
  63. package/dist/components/shared/Popover.d.ts.map +1 -1
  64. package/dist/components/shell/AudienceAddForm.d.ts +1 -5
  65. package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
  66. package/dist/components/shell/AudienceRow.d.ts +2 -3
  67. package/dist/components/shell/AudienceRow.d.ts.map +1 -1
  68. package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
  69. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  70. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  71. package/dist/components/shell/SiteSettingsUsers.d.ts.map +1 -1
  72. package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
  73. package/dist/components/shell/view-branch.d.ts.map +1 -1
  74. package/dist/document/components/DocRail.d.ts.map +1 -1
  75. package/dist/document/components/DocViewer.d.ts.map +1 -1
  76. package/dist/document/components/measure.d.ts.map +1 -1
  77. package/dist/document/components/useDocPages.d.ts.map +1 -1
  78. package/dist/document/engine/blocks.d.ts.map +1 -1
  79. package/dist/document/engine/masonry.d.ts.map +1 -1
  80. package/dist/document/engine/paginate.d.ts.map +1 -1
  81. package/dist/document/engine/settings.d.ts +3 -0
  82. package/dist/document/engine/settings.d.ts.map +1 -1
  83. package/dist/document/index.js +79 -28
  84. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  85. package/dist/hooks/useDocumentUploads.d.ts +1 -0
  86. package/dist/hooks/useDocumentUploads.d.ts.map +1 -1
  87. package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
  88. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  89. package/dist/hooks/useFocusTrap.d.ts +6 -0
  90. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  91. package/dist/hooks/useMediaPipeline.d.ts +3 -2
  92. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  93. package/dist/hooks/useSanitizedHtml.d.ts +12 -0
  94. package/dist/hooks/useSanitizedHtml.d.ts.map +1 -0
  95. package/dist/index.js +20 -14
  96. package/dist/lib/block-move.d.ts.map +1 -1
  97. package/dist/lib/contrast.d.ts.map +1 -1
  98. package/dist/lib/dexie.d.ts +2 -8
  99. package/dist/lib/dexie.d.ts.map +1 -1
  100. package/dist/lib/dexie.js +21 -49
  101. package/dist/lib/index-ops.d.ts.map +1 -1
  102. package/dist/lib/index-ops.js +43 -23
  103. package/dist/lib/index.js +8 -8
  104. package/dist/lib/loader.d.ts.map +1 -1
  105. package/dist/lib/media-folders.d.ts +12 -4
  106. package/dist/lib/media-folders.d.ts.map +1 -1
  107. package/dist/lib/media-folders.js +12 -2
  108. package/dist/lib/migrate-sections-transform.js +1 -1
  109. package/dist/lib/nav-tree.d.ts.map +1 -1
  110. package/dist/lib/nav.d.ts.map +1 -1
  111. package/dist/lib/pages.d.ts.map +1 -1
  112. package/dist/lib/registry.d.ts.map +1 -1
  113. package/dist/lib/registry.js +1 -1
  114. package/dist/lib/sanitize.d.ts +6 -0
  115. package/dist/lib/sanitize.d.ts.map +1 -1
  116. package/dist/lib/timestamp.d.ts.map +1 -1
  117. package/dist/media/index.js +4 -4
  118. package/dist/media/queue.d.ts.map +1 -1
  119. package/dist/media/resolve.d.ts.map +1 -1
  120. package/dist/media/utils.d.ts.map +1 -1
  121. package/dist/media/videoPoster.d.ts.map +1 -1
  122. package/dist/schemas/audience.d.ts +5 -1
  123. package/dist/schemas/audience.d.ts.map +1 -1
  124. package/dist/schemas/audience.js +1 -1
  125. package/dist/schemas/index.js +9 -7
  126. package/dist/schemas/link.d.ts.map +1 -1
  127. package/dist/schemas/link.js +1 -1
  128. package/dist/schemas/sections.d.ts +12 -0
  129. package/dist/schemas/sections.d.ts.map +1 -1
  130. package/dist/storage/index.d.ts +1 -1
  131. package/dist/storage/index.d.ts.map +1 -1
  132. package/dist/storage/index.js +3 -1
  133. package/dist/storage/types.d.ts +9 -0
  134. package/dist/storage/types.d.ts.map +1 -1
  135. package/dist/types/database.d.ts +0 -3
  136. package/dist/types/database.d.ts.map +1 -1
  137. package/package.json +1 -1
  138. package/src/auth/cookies.ts +11 -0
  139. package/src/auth/index.ts +1 -1
  140. package/src/auth/types.ts +18 -4
  141. package/src/collab/rebase.ts +26 -1
  142. package/src/collab/recovery.ts +29 -3
  143. package/src/components/brandguide/ColorSwatchSettings.tsx +40 -13
  144. package/src/components/brandguide/ColorsEditable.tsx +7 -9
  145. package/src/components/brandguide/ColorsView.tsx +12 -11
  146. package/src/components/brandguide/colors-shared.ts +17 -1
  147. package/src/components/chat/ChatPanel.tsx +19 -0
  148. package/src/components/docfile/DocFilePane.tsx +11 -0
  149. package/src/components/docfile/PdfCanvasViewer.tsx +46 -5
  150. package/src/components/docfile/doc-file-state.ts +7 -0
  151. package/src/components/editor/IndicatorPill.tsx +5 -1
  152. package/src/components/editor/NavGroupRow.tsx +8 -3
  153. package/src/components/editor/PagesModal.tsx +10 -2
  154. package/src/components/primitives/EditableList.tsx +10 -2
  155. package/src/components/primitives/EditableRichText.tsx +4 -3
  156. package/src/components/primitives/LinkPopover.tsx +23 -1
  157. package/src/components/primitives/index.ts +5 -0
  158. package/src/components/primitives/useEditableCollection.ts +61 -41
  159. package/src/components/primitives/useEditablePlainText.ts +24 -3
  160. package/src/components/sections/IconList/index.tsx +8 -1
  161. package/src/components/sections/Prose/Prose.tsx +4 -2
  162. package/src/components/shared/FontPicker.tsx +16 -4
  163. package/src/components/shared/HistoryChangesModal.tsx +12 -1
  164. package/src/components/shared/HistoryPopover.tsx +18 -4
  165. package/src/components/shared/LinkField.tsx +1 -1
  166. package/src/components/shared/Navigation.tsx +20 -2
  167. package/src/components/shared/Popover.tsx +9 -5
  168. package/src/components/shell/AudienceAddForm.tsx +6 -26
  169. package/src/components/shell/AudienceRow.tsx +29 -39
  170. package/src/components/shell/BugReportFAB.tsx +20 -2
  171. package/src/components/shell/EditorShell.tsx +200 -50
  172. package/src/components/shell/MediaLibraryModal.tsx +52 -10
  173. package/src/components/shell/SiteSettingsUsers.tsx +3 -0
  174. package/src/components/shell/SiteSettingsViewerAccess.tsx +21 -16
  175. package/src/components/shell/view-branch.ts +4 -1
  176. package/src/document/components/DocRail.tsx +27 -2
  177. package/src/document/components/DocViewer.tsx +5 -1
  178. package/src/document/components/measure.tsx +22 -6
  179. package/src/document/components/useDocPages.ts +13 -3
  180. package/src/document/engine/blocks.ts +33 -4
  181. package/src/document/engine/masonry.ts +8 -2
  182. package/src/document/engine/paginate.ts +4 -1
  183. package/src/document/engine/settings.ts +40 -3
  184. package/src/hooks/useBuildStatus.ts +6 -0
  185. package/src/hooks/useDocumentUploads.ts +46 -3
  186. package/src/hooks/useEditorPersistence.ts +33 -19
  187. package/src/hooks/useEditorPublish.ts +83 -34
  188. package/src/hooks/useFocusTrap.ts +18 -0
  189. package/src/hooks/useMediaPipeline.ts +111 -38
  190. package/src/hooks/useSanitizedHtml.ts +29 -0
  191. package/src/lib/audience-secret.ts +1 -0
  192. package/src/lib/block-move.ts +13 -2
  193. package/src/lib/contrast.ts +14 -14
  194. package/src/lib/dexie.ts +46 -49
  195. package/src/lib/index-ops.ts +63 -20
  196. package/src/lib/loader.ts +41 -12
  197. package/src/lib/media-folders.ts +31 -5
  198. package/src/lib/migrate-sections-transform.ts +22 -2
  199. package/src/lib/nav-tree.ts +16 -2
  200. package/src/lib/nav.ts +10 -1
  201. package/src/lib/pages.ts +24 -6
  202. package/src/lib/registry.ts +3 -0
  203. package/src/lib/sanitize.ts +9 -0
  204. package/src/lib/timestamp.ts +5 -0
  205. package/src/media/queue.ts +4 -0
  206. package/src/media/resolve.ts +14 -1
  207. package/src/media/utils.ts +3 -1
  208. package/src/media/videoPoster.ts +14 -3
  209. package/src/schemas/audience.ts +10 -2
  210. package/src/schemas/link.ts +4 -1
  211. package/src/schemas/sections.ts +23 -1
  212. package/src/storage/index.ts +1 -1
  213. package/src/storage/types.ts +13 -0
  214. package/src/types/database.ts +0 -3
  215. package/dist/chunk-5THS4K5D.js +0 -123
  216. package/dist/chunk-ZSPCE6MZ.js +0 -70
@@ -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]);
@@ -35,7 +35,7 @@ export function LinkField({ label, value, onChange }: Props) {
35
35
  <div className="flex flex-col gap-3">
36
36
  <FormLabel>{label}</FormLabel>
37
37
  <div className="flex gap-2" role="tablist">
38
- {(["external", "internal"] as const).map((kind) => (
38
+ {(["internal", "external"] as const).map((kind) => (
39
39
  <button
40
40
  key={kind}
41
41
  type="button"
@@ -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}
@@ -6,6 +6,8 @@ import Fab from "../shared/Fab";
6
6
 
7
7
  type Category = "Visual" | "Unexpected Behavior" | "Media / Media Library" | "Save / Publish" | "Feature Request" | "Other";
8
8
 
9
+ const MAX_IMAGES = 5;
10
+
9
11
  const CATEGORIES: Category[] = [
10
12
  "Visual",
11
13
  "Unexpected Behavior",
@@ -38,8 +40,11 @@ export function BugReportFAB() {
38
40
  const [isDragOver, setIsDragOver] = useState(false);
39
41
  const [isSubmitting, setIsSubmitting] = useState(false);
40
42
  const [submitError, setSubmitError] = useState<string | null>(null);
43
+ const [imageNotice, setImageNotice] = useState<string | null>(null);
41
44
  const [descriptionError, setDescriptionError] = useState<string | null>(null);
42
45
  const [toast, setToast] = useState<string | null>(null);
46
+ const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
47
+ useEffect(() => () => { if (toastTimerRef.current) clearTimeout(toastTimerRef.current); }, []);
43
48
  const capturedContextRef = useRef<CapturedContext | null>(null);
44
49
  const dropZoneRef = useRef<HTMLDivElement>(null);
45
50
 
@@ -87,11 +92,22 @@ export function BugReportFAB() {
87
92
  name: file.name,
88
93
  })),
89
94
  );
90
- setImages((prev) => [...prev, ...newPreviews].slice(0, 5));
95
+ setImages((prev) => {
96
+ const next = [...prev, ...newPreviews];
97
+ // The cap keeps the FIRST five, so a sixth drop silently threw away the
98
+ // shot the reporter had just taken. Say what happened instead.
99
+ if (next.length > MAX_IMAGES) {
100
+ setImageNotice(`Only ${MAX_IMAGES} screenshots can be attached — ${next.length - MAX_IMAGES} were not added.`);
101
+ return next.slice(0, MAX_IMAGES);
102
+ }
103
+ setImageNotice(null);
104
+ return next;
105
+ });
91
106
  }, [fileToDataUri]);
92
107
 
93
108
  const handleRemoveImage = useCallback((index: number) => {
94
109
  setImages((prev) => prev.filter((_, i) => i !== index));
110
+ setImageNotice(null);
95
111
  }, []);
96
112
 
97
113
  const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -171,7 +187,8 @@ export function BugReportFAB() {
171
187
 
172
188
  handleClose();
173
189
  setToast("Bug report submitted");
174
- setTimeout(() => setToast(null), 3000);
190
+ if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
191
+ toastTimerRef.current = setTimeout(() => setToast(null), 3000);
175
192
  } catch (err) {
176
193
  setSubmitError(err instanceof Error ? err.message : "Submission failed");
177
194
  } finally {
@@ -287,6 +304,7 @@ export function BugReportFAB() {
287
304
  Drop images here or paste from clipboard
288
305
  </p>
289
306
  </div>
307
+ {imageNotice && <p className="mt-1 text-xs text-red-600">{imageNotice}</p>}
290
308
 
291
309
  {/* Thumbnail previews */}
292
310
  {images.length > 0 && (