@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
@@ -34,6 +34,24 @@ export interface RecoveryBaseline {
34
34
  * nothing). This is deliberately NOT derived from the published mainIndex: a
35
35
  * main-based baseline would silently local-win every dirty section that already
36
36
  * exists on main — the exact lost-update this path exists to prevent. */
37
+ /** Key-order-insensitive serialization, for comparing two snapshots of the same
38
+ * section that took different routes to memory. Plain `JSON.stringify` is
39
+ * order-sensitive, and the two sides are built differently — the baseline by
40
+ * `buildCommittedBaseline` from what the save sent, the remote by the loader's
41
+ * Zod parse of the file on the branch. Any drift between those orderings marks
42
+ * an untouched section as remote-changed, which surfaces as a keep-mine /
43
+ * take-theirs dialog for a section nobody edited. */
44
+ function canonicalize(value: unknown): string {
45
+ if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
46
+ if (value && typeof value === "object") {
47
+ const entries = Object.entries(value as Record<string, unknown>)
48
+ .filter(([, v]) => v !== undefined)
49
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
50
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(",")}}`;
51
+ }
52
+ return JSON.stringify(value) ?? "null";
53
+ }
54
+
37
55
  export function deriveRecoveryInputs({ baseline, remote, dirtyRows, deletedIds }: {
38
56
  baseline: RecoveryBaseline | null;
39
57
  remote: { sections: LoadedSection[]; index: SiteIndex };
@@ -51,10 +69,10 @@ export function deriveRecoveryInputs({ baseline, remote, dirtyRows, deletedIds }
51
69
  };
52
70
  }
53
71
 
54
- const baselineById = new Map(baseline.sections.map((s) => [s.section.id, JSON.stringify(s.section)]));
72
+ const baselineById = new Map(baseline.sections.map((s) => [s.section.id, canonicalize(s.section)]));
55
73
  const remoteChangedIds = new Set(
56
74
  remote.sections
57
- .filter((s) => baselineById.has(s.section.id) && baselineById.get(s.section.id) !== JSON.stringify(s.section))
75
+ .filter((s) => baselineById.has(s.section.id) && baselineById.get(s.section.id) !== canonicalize(s.section))
58
76
  .map((s) => s.section.id),
59
77
  );
60
78
  // Safety: a dirty id that exists remotely but was never in the baseline means
@@ -137,7 +155,15 @@ export function buildCommittedBaseline({ baselineSections, fallbackSections, sav
137
155
  .filter((s) => s.section.id in sentIndex.sections)
138
156
  .map((s) => {
139
157
  const sent = sentById.get(s.section.id);
140
- return sent ? { ...s, section: { ...s.section, ...sent } } : s;
158
+ // REPLACE the content, never merge it over the old section: a save that
159
+ // dropped a key (clearing a section's options, say) left the stale key on
160
+ // the baseline, so the next fetch of that section differed from the
161
+ // baseline and the section reported as remote-changed — a keep-mine /
162
+ // take-theirs dialog for an edit this editor had just made itself.
163
+ // `{ id, ...content }` is the shell's own canonical section shape
164
+ // (onSectionChange, onAddSection).
165
+ if (!sent) return s;
166
+ return { ...s, section: { id: s.section.id, ...sent }, meta: sentIndex.sections[s.section.id] ?? s.meta };
141
167
  });
142
168
 
143
169
  const present = new Set(out.map((s) => s.section.id));
@@ -1,31 +1,53 @@
1
1
  import { useState } from "react";
2
2
  import { Input } from "../shared/Input";
3
3
  import type { ColorItem, ColorSpace } from "../sections/Colors/schema";
4
+ import { COLOR_SPACE_KEYS } from "./colors-shared";
4
5
 
5
6
  interface Props {
6
7
  color: ColorItem;
7
8
  onChange: (color: ColorItem) => void;
8
9
  }
9
10
 
10
- function mergeSpaces(spaces: ColorSpace[]): ColorSpace {
11
- return Object.assign({}, ...spaces);
11
+ const KEYS = COLOR_SPACE_KEYS;
12
+
13
+ function hasAnyValue(color: ColorItem): boolean {
14
+ return color.spaces.some((s) => KEYS.some((k) => s[k]));
12
15
  }
13
16
 
14
17
  export default function ColorSwatchSettings({ color, onChange }: Props) {
15
- const [localColor, setLocalColor] = useState(() => ({
16
- ...color,
17
- spaces: [mergeSpaces(color.spaces)],
18
- }));
19
- const space = localColor.spaces[0] || {};
18
+ const [localColor, setLocalColor] = useState(color);
19
+ const spaces = localColor.spaces.length > 0 ? localColor.spaces : [{} as ColorSpace];
20
+
21
+ // A colour can be authored as several grouped spaces (e.g. [{hex,rgb},{cmyk,pantone}])
22
+ // and the swatch renders those groups. The form shows one input per key, sourced
23
+ // from whichever space defines it, and writes back into that same space — flattening
24
+ // everything into spaces[0] would silently destroy the grouping.
25
+ const valueOf = (key: (typeof KEYS)[number]) => spaces.find((s) => s[key])?.[key] ?? "";
20
26
 
21
27
  const update = (updated: ColorItem) => {
22
28
  setLocalColor(updated);
23
- onChange(updated);
29
+ // ColorSpaceSchema requires at least one populated space. Emitting an all-empty
30
+ // colour passes here and fails much later as an unattributable save error, so
31
+ // hold it locally and tell the editor why instead.
32
+ if (hasAnyValue(updated)) onChange(updated);
24
33
  };
25
34
 
26
- const updateSpace = (key: keyof ColorSpace, value: string) => {
27
- const newSpace = { ...space, [key]: value || undefined };
28
- update({ ...localColor, spaces: [newSpace] });
35
+ const updateSpace = (key: (typeof KEYS)[number], value: string) => {
36
+ const owner = spaces.findIndex((s) => s[key] !== undefined);
37
+ const target = owner === -1 ? 0 : owner;
38
+ const next = spaces
39
+ .map((s, i) => {
40
+ if (i !== target) return s;
41
+ if (!value) {
42
+ const { [key]: _dropped, ...rest } = s;
43
+ return rest;
44
+ }
45
+ return { ...s, [key]: value };
46
+ })
47
+ // Drop a group the last of its values just left, but always keep one space
48
+ // so the inputs stay bound to something.
49
+ .filter((s, i) => i === 0 || Object.keys(s).length > 0);
50
+ update({ ...localColor, spaces: next });
29
51
  };
30
52
 
31
53
  return (
@@ -35,14 +57,19 @@ export default function ColorSwatchSettings({ color, onChange }: Props) {
35
57
  value={localColor.name || ""}
36
58
  onChange={(name) => update({ ...localColor, name })}
37
59
  />
38
- {(["hex", "rgb", "cmyk", "pantone"] as const).map((key) => (
60
+ {KEYS.map((key) => (
39
61
  <Input
40
62
  key={key}
41
63
  label={key.toUpperCase()}
42
- value={space[key] || ""}
64
+ value={valueOf(key)}
43
65
  onChange={(value) => updateSpace(key, value)}
44
66
  />
45
67
  ))}
68
+ {!hasAnyValue(localColor) && (
69
+ <p role="alert" className="mt-1 text-xs text-red-600">
70
+ Enter at least one value — this color won&rsquo;t be saved until you do.
71
+ </p>
72
+ )}
46
73
  </div>
47
74
  );
48
75
  }
@@ -5,7 +5,7 @@ import { EditableGrid } from "../primitives/EditableGrid";
5
5
  import { EditablePlainText } from "../primitives/EditablePlainText";
6
6
  import type { ReactNode } from "react";
7
7
  import ColorSwatchSettings from "./ColorSwatchSettings";
8
- import { getContrastClass, createColorItem } from "./colors-shared";
8
+ import { getContrastClass, createColorItem, orderedSpaceEntries } from "./colors-shared";
9
9
  import type { ColorItem } from "../sections/Colors/schema";
10
10
  import type { SectionContent } from "../../schemas/sections";
11
11
  import type { OpenModal } from "../../lib/registry";
@@ -140,14 +140,12 @@ function ColorSwatchEditable({
140
140
  // flex-1 so every swatch's label block grows to the tallest in its row.
141
141
  <div className="flex-1 space-y-1 bg-base-accent p-3 text-sm">
142
142
  {color.spaces.map((space, j) =>
143
- Object.entries(space).map(([key, value]) =>
144
- value ? (
145
- <div key={`${j}-${key}`} className="flex w-full justify-between">
146
- <span className="font-medium uppercase">{key}</span>
147
- <span>{value}</span>
148
- </div>
149
- ) : null
150
- )
143
+ orderedSpaceEntries(space).map(([key, value]) => (
144
+ <div key={`${j}-${key}`} className="flex w-full justify-between">
145
+ <span className="font-medium uppercase">{key}</span>
146
+ <span>{value}</span>
147
+ </div>
148
+ ))
151
149
  )}
152
150
  </div>
153
151
  )}
@@ -1,8 +1,8 @@
1
- import { useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
  import { Eye, EyeOff } from "lucide-react";
3
3
  import { cn } from "../../lib/cn";
4
4
  import { containerGridClass } from "../../lib/container-grid";
5
- import { getContrastClass } from "./colors-shared";
5
+ import { getContrastClass, orderedSpaceEntries } from "./colors-shared";
6
6
  import type { ColorItem } from "../sections/Colors/schema";
7
7
 
8
8
  export default function ColorsView({
@@ -20,6 +20,8 @@ export default function ColorsView({
20
20
  }) {
21
21
  const [expanded, setExpanded] = useState(!collapsing);
22
22
  const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
23
+ const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
24
+ useEffect(() => () => { if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); }, []);
23
25
 
24
26
  const handleCopy = (value: string, colorIndex: number) => {
25
27
  if (navigator.clipboard?.writeText) {
@@ -35,7 +37,8 @@ export default function ColorsView({
35
37
  document.body.removeChild(ta);
36
38
  }
37
39
  setCopiedIndex(colorIndex);
38
- setTimeout(() => setCopiedIndex(null), 1500);
40
+ if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
41
+ copiedTimerRef.current = setTimeout(() => setCopiedIndex(null), 1500);
39
42
  };
40
43
 
41
44
  return (
@@ -79,14 +82,12 @@ export default function ColorsView({
79
82
  // flex-1 so every swatch's label block grows to the tallest in its row.
80
83
  <div className="flex-1 space-y-1 bg-base-accent p-3 text-sm">
81
84
  {color.spaces.map((space, j) =>
82
- Object.entries(space).map(([key, value]) =>
83
- value ? (
84
- <button key={`${j}-${key}`} onClick={() => handleCopy(value, i)} className="cursor-pointer flex w-full justify-between hover:text-primary">
85
- <span className="font-medium uppercase">{key}</span>
86
- <span>{value}</span>
87
- </button>
88
- ) : null
89
- )
85
+ orderedSpaceEntries(space).map(([key, value]) => (
86
+ <button key={`${j}-${key}`} onClick={() => handleCopy(value, i)} className="cursor-pointer flex w-full justify-between hover:text-primary">
87
+ <span className="font-medium uppercase">{key}</span>
88
+ <span>{value}</span>
89
+ </button>
90
+ ))
90
91
  )}
91
92
  </div>
92
93
  )}
@@ -1,4 +1,20 @@
1
- import type { ColorItem } from "../sections/Colors/schema";
1
+ import type { ColorItem, ColorSpace } from "../sections/Colors/schema";
2
+
3
+ /**
4
+ * Canonical display order for a colour space's values. Rendering
5
+ * `Object.entries(space)` instead put them in whatever order the JSON happened
6
+ * to carry, so two swatches on the same page could list their values
7
+ * differently — and the settings form, which is always hex/rgb/cmyk/pantone,
8
+ * disagreed with the swatch it edits.
9
+ */
10
+ export const COLOR_SPACE_KEYS = ["hex", "rgb", "cmyk", "pantone"] as const;
11
+
12
+ export function orderedSpaceEntries(space: ColorSpace): [string, string][] {
13
+ return COLOR_SPACE_KEYS.flatMap((key) => {
14
+ const value = space[key];
15
+ return value ? [[key, value] as [string, string]] : [];
16
+ });
17
+ }
2
18
 
3
19
  export function getContrastClass(hex: string | undefined): string {
4
20
  if (!hex) return "text-white";
@@ -195,6 +195,13 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
195
195
  return convoIdRef.current;
196
196
  }
197
197
  const abortRef = useRef<AbortController | null>(null);
198
+ // Bumped by every run() and by anything that replaces the transcript out from
199
+ // under an in-flight one (New conversation, restore-from-history). run()'s
200
+ // post-await writes are all keyed to the generation it started with, so an
201
+ // aborted request can no longer resurrect the transcript the user just
202
+ // cleared — abort resolves `{ok: true}`, and the tail wrote back the history
203
+ // captured before the request.
204
+ const runGenRef = useRef(0);
198
205
  const composerRef = useRef<HTMLTextAreaElement>(null);
199
206
  const panelRef = useRef<HTMLDivElement>(null);
200
207
  const listRef = useRef<HTMLDivElement>(null);
@@ -355,6 +362,8 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
355
362
  }
356
363
 
357
364
  async function run(history: ChatMessage[]) {
365
+ const gen = ++runGenRef.current;
366
+ const superseded = () => runGenRef.current !== gen;
358
367
  setNotice(null);
359
368
  setCanRetry(false);
360
369
  setBusy(true);
@@ -367,6 +376,7 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
367
376
  const result = await streamChat(
368
377
  { messages: history, sessionId: sessionId(), ...(taskId ? { taskId } : {}), ...(draftChatbot !== undefined ? { draftChatbot } : {}), ...(simulateAudience ? { simulateAudience } : {}) },
369
378
  (delta) => {
379
+ if (superseded()) return;
370
380
  streamingRef.current += delta;
371
381
  const streamed = streamingRef.current;
372
382
  setMessages([...history, { role: "assistant", content: streamed }]);
@@ -375,6 +385,9 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
375
385
  );
376
386
  abortRef.current = null;
377
387
  setBusy(false);
388
+ // Everything below writes the transcript captured before the request. If it
389
+ // has since been replaced, leave it alone.
390
+ if (superseded()) return;
378
391
  if (!result.ok) {
379
392
  setMessages(history); // drop the empty assistant stub
380
393
  setNotice(ERROR_COPY[result.error] ?? ERROR_COPY.upstream_error);
@@ -404,6 +417,9 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
404
417
  // Starts fresh; the previous conversation stays in the history archive.
405
418
  function clearConversation() {
406
419
  if (busy) stop();
420
+ // Invalidate any in-flight run, so its tail can't write the old transcript
421
+ // back over the empty one.
422
+ runGenRef.current++;
407
423
  setMessages([]);
408
424
  setNotice(null);
409
425
  setCanRetry(false);
@@ -423,6 +439,9 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
423
439
 
424
440
  function restoreConversation(c: StoredConversation) {
425
441
  if (busy) stop();
442
+ // Same as clearConversation: this replaces the transcript, so an in-flight
443
+ // run must not finish by restoring the one it started from.
444
+ runGenRef.current++;
426
445
  convoIdRef.current = c.id;
427
446
  try { window.sessionStorage.setItem(CONVO_ID_KEY, c.id); } catch {}
428
447
  setMessages(c.messages);
@@ -8,6 +8,7 @@ import {
8
8
  ZoomIn,
9
9
  ZoomOut,
10
10
  } from "lucide-react";
11
+ import { useInitialFocus } from "../../hooks/useFocusTrap";
11
12
  import type { SiteDocument } from "../../lib/documents";
12
13
  import { documentFileUrl } from "../../schemas/documents";
13
14
  import { lockBodyScroll } from "../../lib/scroll-lock";
@@ -48,13 +49,22 @@ export default function DocFilePane({
48
49
  const [page, setPage] = useState(1);
49
50
  const [numPages, setNumPages] = useState(0);
50
51
  const frameRef = useRef<HTMLIFrameElement | null>(null);
52
+ const paneRef = useRef<HTMLDivElement | null>(null);
51
53
  const viewerRef = useRef<PdfCanvasViewerHandle | null>(null);
52
54
 
53
55
  useEffect(() => lockBodyScroll(), []);
54
56
 
57
+ // Focus moves into the pane on open and back to the trigger on close. It is
58
+ // aria-modal-less by design (the page behind stays reachable), so this is
59
+ // initial focus only, not a trap.
60
+ useInitialFocus(paneRef, true);
61
+
55
62
  useEffect(() => {
56
63
  const onKey = (e: KeyboardEvent) => {
57
64
  if (e.key !== "Escape") return;
65
+ // A modal opened on top of the pane owns Escape. Without this one keypress
66
+ // dismissed both — the same stacking rule Popover applies.
67
+ if (document.querySelector("[data-modal-layer]")) return;
58
68
  e.preventDefault();
59
69
  onClose();
60
70
  };
@@ -87,6 +97,7 @@ export default function DocFilePane({
87
97
 
88
98
  return (
89
99
  <div
100
+ ref={paneRef}
90
101
  role="dialog"
91
102
  aria-label={doc.title}
92
103
  data-testid="doc-file-pane"
@@ -65,6 +65,8 @@ interface PageSlot {
65
65
  pendingKey: string | null;
66
66
  task: RenderTask | null;
67
67
  textLayerStarted: boolean;
68
+ /** Kept so a page leaving the visible band can cancel it, like `task`. */
69
+ textLayer: { cancel: () => void } | null;
68
70
  }
69
71
 
70
72
  const emptySlot = (): PageSlot => ({
@@ -72,6 +74,7 @@ const emptySlot = (): PageSlot => ({
72
74
  pendingKey: null,
73
75
  task: null,
74
76
  textLayerStarted: false,
77
+ textLayer: null,
75
78
  });
76
79
 
77
80
  /**
@@ -111,6 +114,11 @@ export default function PdfCanvasViewer({
111
114
  useEffect(() => {
112
115
  let cancelled = false;
113
116
  let loadingTask: ReturnType<PdfjsModule["getDocument"]> | null = null;
117
+ // Page indices carry no identity across documents, so a set left over from
118
+ // the previous one makes the new document's first render believe those pages
119
+ // are on screen — it renders canvases for a band nobody is looking at, and
120
+ // pages that really are visible wait for the observer's first callback.
121
+ setVisible(new Set());
114
122
  void (async () => {
115
123
  try {
116
124
  const lib = await loadPdfjs();
@@ -203,7 +211,9 @@ export default function PdfCanvasViewer({
203
211
 
204
212
  const fitScale = useMemo(() => {
205
213
  if (!dims?.length || !containerWidth) return null;
206
- const maxW = Math.max(...dims.map((d) => d.w));
214
+ // reduce, not Math.max(...): the spread is an argument list, and a document
215
+ // with enough pages would blow the call-stack limit outright.
216
+ const maxW = dims.reduce((m, d) => (d.w > m ? d.w : m), 0);
207
217
  return Math.max(0.1, (containerWidth - GUTTER_PX) / maxW);
208
218
  }, [dims, containerWidth]);
209
219
  const scale = fitScale == null ? null : fitScale * zoom;
@@ -277,6 +287,7 @@ export default function PdfCanvasViewer({
277
287
  container: textDiv,
278
288
  viewport: page.getViewport({ scale: renderScale }),
279
289
  });
290
+ slot.textLayer = textLayer;
280
291
  textLayer.render().catch(() => {
281
292
  slot.textLayerStarted = false;
282
293
  });
@@ -289,6 +300,9 @@ export default function PdfCanvasViewer({
289
300
  slots.current.forEach((slot, i) => {
290
301
  if (visible.has(i)) return;
291
302
  slot.task?.cancel();
303
+ // The text layer went on streaming into a container React had already
304
+ // unmounted, and a return trip started a second one on top of it.
305
+ slot.textLayer?.cancel();
292
306
  Object.assign(slot, emptySlot());
293
307
  });
294
308
  }, [doc, dims, renderScale, visible]);
@@ -382,19 +396,46 @@ export default function PdfCanvasViewer({
382
396
  }, [doc]);
383
397
 
384
398
  const scrollRaf = useRef(0);
399
+ // Wrapper tops, snapshotted once per layout instead of read inside the rAF:
400
+ // scrolling a 300-page document forced 300 layout reads every frame. They only
401
+ // move when the page geometry does, which is `scale` and `dims`.
402
+ const offsetsRef = useRef<number[] | null>(null);
403
+ useEffect(() => {
404
+ offsetsRef.current = null;
405
+ }, [scale, dims]);
406
+
385
407
  const onScroll = () => {
386
408
  if (scrollRaf.current) return;
387
409
  scrollRaf.current = requestAnimationFrame(() => {
388
410
  scrollRaf.current = 0;
389
411
  const container = containerRef.current;
390
412
  if (!container) return;
413
+ if (!offsetsRef.current) {
414
+ // A wrapper that hasn't mounted inherits its predecessor's top, keeping
415
+ // the list non-decreasing so the search below stays valid.
416
+ let last = -1;
417
+ offsetsRef.current = wrapperRefs.current.map((el) => {
418
+ last = el?.offsetTop ?? last;
419
+ return last;
420
+ });
421
+ }
422
+ const offsets = offsetsRef.current;
391
423
  // Current page = the last one whose top has passed the upper 40% of the
392
424
  // viewport — matches how the native viewer flips its indicator.
393
425
  const threshold = container.scrollTop + container.clientHeight * 0.4;
394
- let current = 1;
395
- wrapperRefs.current.forEach((el, i) => {
396
- if (el && el.offsetTop <= threshold) current = i + 1;
397
- });
426
+ let lo = 0;
427
+ let hi = offsets.length - 1;
428
+ let found = 0;
429
+ while (lo <= hi) {
430
+ const mid = (lo + hi) >> 1;
431
+ if (offsets[mid] <= threshold) {
432
+ found = mid;
433
+ lo = mid + 1;
434
+ } else {
435
+ hi = mid - 1;
436
+ }
437
+ }
438
+ const current = found + 1;
398
439
  if (current !== currentPageRef.current) {
399
440
  currentPageRef.current = current;
400
441
  callbacks.current.onPageChange(current);
@@ -159,6 +159,13 @@ export function subscribeDocFile(cb: (sectionId: string | null) => void): () =>
159
159
  window.removeEventListener("hashchange", onHash);
160
160
  window.removeEventListener("popstate", onPop);
161
161
  unlistenRequest();
162
+ // The return-glide timer would otherwise outlive the last subscriber and
163
+ // strip a class off a document nothing is watching any more.
164
+ if (s.closingTimer !== null) {
165
+ clearTimeout(s.closingTimer);
166
+ s.closingTimer = null;
167
+ document.documentElement.classList.remove(DOC_CLOSING_CLASS);
168
+ }
162
169
  };
163
170
  // Deep link: the hash is an INPUT, read once when the first listener wires up.
164
171
  syncFromHash();
@@ -1,4 +1,4 @@
1
- import { useRef, useState, useCallback, type ReactNode } from "react";
1
+ import { useEffect, useRef, useState, useCallback, type ReactNode } from "react";
2
2
  import { cn } from "../../lib/cn";
3
3
  import { Popover } from "../shared/Popover";
4
4
 
@@ -43,6 +43,10 @@ export function IndicatorPill({
43
43
  }
44
44
  }, []);
45
45
 
46
+ // The close-delay timer outlives the pill otherwise — a hover-then-navigate
47
+ // leaves it pending with nothing to update.
48
+ useEffect(() => clearHoverTimeout, [clearHoverTimeout]);
49
+
46
50
  function handleMouseEnter() {
47
51
  clearHoverTimeout();
48
52
  if (!disabled && !clickOpen && hoverContent) {
@@ -25,8 +25,13 @@ interface Props {
25
25
  onMove: (from: NavLocation, to: NavLocation) => void;
26
26
  /** Fires when THIS group starts/stops being dragged, so the modal can collapse
27
27
  * it for the duration. Without that, its members stay rendered in place and
28
- * the drag reads as moving only the header. */
29
- onDragStateChange?: (dragging: boolean) => void;
28
+ * the drag reads as moving only the header.
29
+ *
30
+ * Takes the group id (rather than closing over it in the parent) so the
31
+ * handler can be a single stable callback: an identity that changed every
32
+ * parent render re-fired this effect in EVERY mounted row, and the
33
+ * non-dragging ones then cleared the dragging row's state. */
34
+ onDragStateChange?: (groupId: string, dragging: boolean) => void;
30
35
  }
31
36
 
32
37
  export function NavGroupRow({
@@ -44,7 +49,7 @@ export function NavGroupRow({
44
49
 
45
50
  // Surface the hook's drag state upward so the modal can collapse this group
46
51
  // while it is the one being dragged.
47
- useEffect(() => { onDragStateChange?.(isDragging); }, [isDragging, onDragStateChange]);
52
+ useEffect(() => { onDragStateChange?.(group.id, isDragging); }, [group.id, isDragging, onDragStateChange]);
48
53
 
49
54
  const label = groupDisplayLabel(group.label);
50
55
  const labelError = group.label.trim() === "" ? "Label is required." : null;
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from "react";
1
+ import { useCallback, useState, useEffect } from "react";
2
2
  import { Check, ChevronDown, Home } from "lucide-react";
3
3
  import type { SiteIndex, Page } from "../../schemas/site-config";
4
4
  import type { Audience } from "../../auth/types";
@@ -54,6 +54,14 @@ export function PagesModal(props: PagesModalProps) {
54
54
  // Groups are otherwise always expanded — there is no editor-side collapse, so
55
55
  // no collapsed-group rescue (spring-load, reveal-on-drop) is needed.
56
56
  const [draggingGroupId, setDraggingGroupId] = useState<string | null>(null);
57
+ // Stable identity, and a guarded clear. An inline arrow per row re-fired every
58
+ // NavGroupRow's drag effect on every render of this modal, and the rows that
59
+ // weren't dragging then reset the dragging one's id — so with two or more
60
+ // groups the collapse never engaged (and A/B fought over the state for the
61
+ // drag's duration). Only the row that claimed the id may release it.
62
+ const handleGroupDragState = useCallback((groupId: string, dragging: boolean) => {
63
+ setDraggingGroupId((cur) => (dragging ? groupId : cur === groupId ? null : cur));
64
+ }, []);
57
65
 
58
66
  if (deletingId) {
59
67
  const page = index.pages.find((p) => p.id === deletingId);
@@ -132,7 +140,7 @@ export function PagesModal(props: PagesModalProps) {
132
140
  onSetStartCollapsed={(v) => props.onSetGroupStartCollapsed(entry.id, v)}
133
141
  onDelete={() => props.onDeleteGroup(entry.id)}
134
142
  onMove={props.onMoveNavEntry}
135
- onDragStateChange={(dragging) => setDraggingGroupId(dragging ? entry.id : null)}
143
+ onDragStateChange={handleGroupDragState}
136
144
  />
137
145
  {expanded && (
138
146
  <div data-testid={`nav-group-members-${entry.id}`} className="ml-6 flex flex-col gap-2">
@@ -89,6 +89,14 @@ function ListRow({
89
89
  const rowRef = useRef<HTMLDivElement>(null);
90
90
  const handleRef = useRef<HTMLButtonElement>(null);
91
91
 
92
+ // onReorder's identity chains back to the editor shell's inline onChange, so it
93
+ // changes on every editor render. Listing it in the deps below re-registered the
94
+ // draggable — re-awaiting the dynamic import — on every one of those, and a
95
+ // re-registration landing mid-drag cancels the drag. Read it through a ref, as
96
+ // IconList's monitor and EditableGrid already do.
97
+ const onReorderRef = useRef(onReorder);
98
+ onReorderRef.current = onReorder;
99
+
92
100
  useEffect(() => {
93
101
  const row = rowRef.current;
94
102
  const handle = handleRef.current;
@@ -116,7 +124,7 @@ function ListRow({
116
124
  onDrop: ({ source }) => {
117
125
  const fromIndex = source.data.index as number;
118
126
  if (fromIndex !== index) {
119
- onReorder(fromIndex, index);
127
+ onReorderRef.current(fromIndex, index);
120
128
  }
121
129
  },
122
130
  });
@@ -131,7 +139,7 @@ function ListRow({
131
139
  cancelled = true;
132
140
  cleanup?.();
133
141
  };
134
- }, [id, index, onReorder, setDragState]);
142
+ }, [id, index, setDragState]);
135
143
 
136
144
  const isDropTarget = dragState.targetId === id && dragState.sourceId !== id;
137
145
 
@@ -1,7 +1,7 @@
1
1
  import { useRef, useCallback, useEffect } from "react";
2
2
  import { EditorContent } from "@tiptap/react";
3
3
  import { BubbleMenu } from "@tiptap/react/menus";
4
- import { sanitizeHtml } from "../../lib/sanitize";
4
+ import { useSanitizedHtml } from "../../hooks/useSanitizedHtml";
5
5
  import { useEditableRichText } from "./useEditableRichText";
6
6
  import { RichTextToolbar } from "./RichTextToolbar";
7
7
  import type { PresetName } from "./tiptap-presets";
@@ -32,6 +32,7 @@ export function EditableRichText({
32
32
  const { isEditorActive, editor, activate, deactivate } =
33
33
  useEditableRichText({ value, onChange, preset, placeholder });
34
34
  const wrapperRef = useRef<HTMLDivElement>(null);
35
+ const safeHtml = useSanitizedHtml(value);
35
36
  const blurTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
36
37
  const clickCoordsRef = useRef<{ left: number; top: number } | null>(null);
37
38
 
@@ -100,7 +101,7 @@ export function EditableRichText({
100
101
  return (
101
102
  <div
102
103
  className={className}
103
- dangerouslySetInnerHTML={{ __html: sanitizeHtml(value) }}
104
+ dangerouslySetInnerHTML={{ __html: safeHtml }}
104
105
  />
105
106
  );
106
107
  }
@@ -122,7 +123,7 @@ export function EditableRichText({
122
123
  onClick={handleClick}
123
124
  // Content is sanitized via DOMPurify — safe for trusted project JSON.
124
125
  // eslint-disable-next-line react/no-danger
125
- dangerouslySetInnerHTML={{ __html: sanitizeHtml(value) }}
126
+ dangerouslySetInnerHTML={{ __html: safeHtml }}
126
127
  />
127
128
  );
128
129
  }
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
  import type { Editor } from "@tiptap/core";
3
3
  import { Button } from "../shared/Button";
4
4
  import { usePages } from "../shared/PagesContext";
@@ -27,6 +27,27 @@ export function LinkPopover({ editor, onClose }: LinkPopoverProps) {
27
27
  );
28
28
  const hasLink = Boolean(existingAttrs.href);
29
29
  const headings = pageId ? getPageHeadings(pageId) : [];
30
+ const panelRef = useRef<HTMLDivElement>(null);
31
+
32
+ // Every sibling picker (IconPicker, FontPicker, SectionTypePicker) dismisses on
33
+ // an outside mousedown; this one only closed through its own buttons, so clicking
34
+ // back into the prose left it floating over the document. Escape is bound at the
35
+ // document too, because focus can be anywhere by the time the editor wants out —
36
+ // the container's own keydown only fires while focus is inside the panel.
37
+ useEffect(() => {
38
+ function onMouseDown(e: MouseEvent) {
39
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) onClose();
40
+ }
41
+ function onKeyDown(e: KeyboardEvent) {
42
+ if (e.key === "Escape") onClose();
43
+ }
44
+ document.addEventListener("mousedown", onMouseDown);
45
+ document.addEventListener("keydown", onKeyDown);
46
+ return () => {
47
+ document.removeEventListener("mousedown", onMouseDown);
48
+ document.removeEventListener("keydown", onKeyDown);
49
+ };
50
+ }, [onClose]);
30
51
 
31
52
  const handleApply = () => {
32
53
  if (mode === "internal") {
@@ -66,6 +87,7 @@ export function LinkPopover({ editor, onClose }: LinkPopoverProps) {
66
87
 
67
88
  return (
68
89
  <div
90
+ ref={panelRef}
69
91
  className="flex w-64 flex-col gap-2 rounded bg-base-accent p-3 shadow-lg"
70
92
  onMouseDown={(e) => e.stopPropagation()}
71
93
  onKeyDown={handleKeyDown}