@drawnagency/primitives 0.1.75 → 0.1.77

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 (83) hide show
  1. package/dist/chunk-2G2S36SY.js +13 -0
  2. package/dist/{chunk-7T2WQSWF.js → chunk-63BQQNZO.js} +9 -9
  3. package/dist/chunk-REM2NH7K.js +44 -0
  4. package/dist/{chunk-U4E6S4PX.js → chunk-RO4E5UJI.js} +53 -39
  5. package/dist/{chunk-NKVZGPR6.js → chunk-YP7IHMC3.js} +1 -1
  6. package/dist/collab/provider-registry.d.ts +4 -0
  7. package/dist/collab/provider-registry.d.ts.map +1 -0
  8. package/dist/collab/provider-registry.js +9 -0
  9. package/dist/collab/rebase.d.ts +37 -0
  10. package/dist/collab/rebase.d.ts.map +1 -0
  11. package/dist/collab/rebase.js +64 -0
  12. package/dist/collab/recovery.d.ts +89 -0
  13. package/dist/collab/recovery.d.ts.map +1 -0
  14. package/dist/collab/types.d.ts +93 -0
  15. package/dist/collab/types.d.ts.map +1 -0
  16. package/dist/collab/types.js +19 -0
  17. package/dist/components/editor/AudienceIndicator.d.ts +3 -1
  18. package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
  19. package/dist/components/editor/ChildBlockWrapper.d.ts +7 -0
  20. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -1
  21. package/dist/components/editor/ConflictDialog.d.ts +14 -0
  22. package/dist/components/editor/ConflictDialog.d.ts.map +1 -0
  23. package/dist/components/editor/IndicatorPill.d.ts +4 -1
  24. package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
  25. package/dist/components/editor/LockBadge.d.ts +7 -0
  26. package/dist/components/editor/LockBadge.d.ts.map +1 -0
  27. package/dist/components/editor/PresenceAvatars.d.ts +9 -0
  28. package/dist/components/editor/PresenceAvatars.d.ts.map +1 -0
  29. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  30. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  31. package/dist/components/editor/StatusIndicator.d.ts +3 -1
  32. package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
  33. package/dist/components/editor/index.js +92 -44
  34. package/dist/components/primitives/index.js +3 -3
  35. package/dist/components/sections/Container/Container.d.ts.map +1 -1
  36. package/dist/components/sections/brand-guide.js +3 -3
  37. package/dist/components/sections/register-schemas.js +9 -9
  38. package/dist/components/sections/register.js +9 -9
  39. package/dist/components/shell/EditorShell.d.ts +3 -1
  40. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  41. package/dist/hooks/useCollabSession.d.ts +23 -0
  42. package/dist/hooks/useCollabSession.d.ts.map +1 -0
  43. package/dist/hooks/useEditorPersistence.d.ts +21 -1
  44. package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
  45. package/dist/hooks/useEditorPublish.d.ts +39 -2
  46. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  47. package/dist/index.d.ts +2 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +44 -22
  50. package/dist/lib/dexie.d.ts +12 -0
  51. package/dist/lib/dexie.d.ts.map +1 -1
  52. package/dist/lib/dexie.js +27 -0
  53. package/dist/lib/index.js +12 -12
  54. package/dist/lib/registry.d.ts +10 -0
  55. package/dist/lib/registry.d.ts.map +1 -1
  56. package/dist/schemas/index.js +1 -1
  57. package/dist/types/database.d.ts +70 -0
  58. package/dist/types/database.d.ts.map +1 -1
  59. package/package.json +13 -1
  60. package/src/collab/provider-registry.ts +14 -0
  61. package/src/collab/rebase.ts +140 -0
  62. package/src/collab/recovery.ts +149 -0
  63. package/src/collab/types.ts +96 -0
  64. package/src/components/editor/AudienceIndicator.tsx +4 -1
  65. package/src/components/editor/ChildBlockWrapper.tsx +15 -1
  66. package/src/components/editor/ConflictDialog.tsx +61 -0
  67. package/src/components/editor/IndicatorPill.tsx +12 -6
  68. package/src/components/editor/LockBadge.tsx +31 -0
  69. package/src/components/editor/PresenceAvatars.tsx +53 -0
  70. package/src/components/editor/SectionWrapper.tsx +60 -29
  71. package/src/components/editor/StatusIndicator.tsx +4 -0
  72. package/src/components/sections/Container/Container.tsx +6 -4
  73. package/src/components/shell/EditorShell.tsx +561 -18
  74. package/src/hooks/useCollabSession.ts +220 -0
  75. package/src/hooks/useEditorPersistence.ts +104 -1
  76. package/src/hooks/useEditorPublish.ts +205 -63
  77. package/src/index.ts +2 -0
  78. package/src/lib/dexie.ts +44 -0
  79. package/src/lib/registry.ts +8 -0
  80. package/src/types/database.ts +55 -0
  81. package/dist/{chunk-Q3T23HVW.js → chunk-B2DLHZKC.js} +4 -4
  82. package/dist/{chunk-T7MP7ANP.js → chunk-UUCOJL7O.js} +0 -0
  83. package/dist/{chunk-ZRWGV5O6.js → chunk-XFDEUK37.js} +3 -3
@@ -16,6 +16,9 @@ interface IndicatorPillProps {
16
16
  className?: string;
17
17
  buttonClassName?: string;
18
18
  ariaLabel?: string;
19
+ /** Display-only mode: dots/label still render but the popovers never open
20
+ * (e.g. while another editor holds the section lock). */
21
+ disabled?: boolean;
19
22
  }
20
23
 
21
24
  export function IndicatorPill({
@@ -26,6 +29,7 @@ export function IndicatorPill({
26
29
  className,
27
30
  buttonClassName,
28
31
  ariaLabel,
32
+ disabled,
29
33
  }: IndicatorPillProps) {
30
34
  const [hoverOpen, setHoverOpen] = useState(false);
31
35
  const [clickOpen, setClickOpen] = useState(false);
@@ -41,7 +45,7 @@ export function IndicatorPill({
41
45
 
42
46
  function handleMouseEnter() {
43
47
  clearHoverTimeout();
44
- if (!clickOpen && hoverContent) {
48
+ if (!disabled && !clickOpen && hoverContent) {
45
49
  setHoverOpen(true);
46
50
  }
47
51
  }
@@ -54,7 +58,7 @@ export function IndicatorPill({
54
58
  }
55
59
 
56
60
  function handleClick() {
57
- if (!clickContent) return;
61
+ if (disabled || !clickContent) return;
58
62
  setHoverOpen(false);
59
63
  clearHoverTimeout();
60
64
  setClickOpen((v) => !v);
@@ -72,15 +76,17 @@ export function IndicatorPill({
72
76
  onClick={handleClick}
73
77
  onMouseEnter={handleMouseEnter}
74
78
  onMouseLeave={handleMouseLeave}
75
- aria-haspopup={clickContent ? "true" : undefined}
76
- aria-expanded={clickContent ? clickOpen : undefined}
79
+ aria-haspopup={!disabled && clickContent ? "true" : undefined}
80
+ aria-expanded={!disabled && clickContent ? clickOpen : undefined}
81
+ aria-disabled={disabled || undefined}
77
82
  aria-label={ariaLabel}
78
83
  className={cn(
79
84
  // align-middle keeps the inline-flex pill centred in its line box regardless of
80
85
  // whether it leads with a status/audience dot — without it, the dot-less variant's
81
86
  // text baseline sits ~2px lower than the dotted variant, misaligning the two pills.
82
- "cursor-pointer inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium align-middle",
83
- "border border-base-200 hover:bg-base-accent",
87
+ "inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium align-middle",
88
+ "border border-base-200",
89
+ disabled ? "cursor-default opacity-60" : "cursor-pointer hover:bg-base-accent",
84
90
  buttonClassName,
85
91
  )}
86
92
  >
@@ -0,0 +1,31 @@
1
+ import { Lock } from "lucide-react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ interface LockBadgeProps {
5
+ email: string;
6
+ onTakeover?: () => void;
7
+ }
8
+
9
+ // Reuses the "modified" status tokens (an amber pair defined in base.css) so the
10
+ // lock badge sits in the same visual family as the other editor status pills
11
+ // rather than introducing raw amber-* literals.
12
+ export function LockBadge({ email, onTakeover }: LockBadgeProps) {
13
+ return (
14
+ <span className="pointer-events-auto inline-flex items-center gap-1.5 rounded-full bg-status-modified-bg px-2 py-0.5 text-xs font-medium text-status-modified-text align-middle">
15
+ <Lock size={12} strokeWidth={2.5} aria-hidden="true" />
16
+ <span className="whitespace-nowrap">{email} is editing</span>
17
+ {onTakeover && (
18
+ <button
19
+ type="button"
20
+ onClick={onTakeover}
21
+ className={cn(
22
+ "cursor-pointer rounded px-1.5 py-0.5 font-semibold",
23
+ "bg-status-modified-text/10 hover:bg-status-modified-text/20",
24
+ )}
25
+ >
26
+ Take over
27
+ </button>
28
+ )}
29
+ </span>
30
+ );
31
+ }
@@ -0,0 +1,53 @@
1
+ import type { CollabPeer, CollabStatus } from "../../collab/types";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ // Vivid -600 fills read as distinct badge colors and keep white text legible
5
+ // in both light and dark editor chrome.
6
+ const AVATAR_COLORS = [
7
+ "bg-sky-600",
8
+ "bg-emerald-600",
9
+ "bg-amber-600",
10
+ "bg-fuchsia-600",
11
+ "bg-rose-600",
12
+ ];
13
+
14
+ function initials(email: string): string {
15
+ return email.slice(0, 2).toUpperCase();
16
+ }
17
+
18
+ function colorFor(userId: string): string {
19
+ let h = 0;
20
+ for (const c of userId) h = (h * 31 + c.charCodeAt(0)) >>> 0;
21
+ return AVATAR_COLORS[h % AVATAR_COLORS.length];
22
+ }
23
+
24
+ interface PresenceAvatarsProps {
25
+ peers: CollabPeer[];
26
+ selfUserId: string | null;
27
+ status: CollabStatus;
28
+ }
29
+
30
+ export function PresenceAvatars({ peers, selfUserId, status }: PresenceAvatarsProps) {
31
+ const others = peers.filter((p) => p.userId !== selfUserId);
32
+ if (status !== "connected" || others.length === 0) return null;
33
+
34
+ return (
35
+ <div className="flex -space-x-2" aria-label="Editors online">
36
+ {others.map((p) => (
37
+ <span
38
+ key={p.userId}
39
+ title={p.focusSectionId ? `${p.email} — editing` : p.email}
40
+ className={cn(
41
+ // border-base-accent matches the toolbar surface behind the row in
42
+ // both themes (white in light, dark panel in dark), giving a clean
43
+ // ring where the avatars overlap.
44
+ "flex h-7 w-7 items-center justify-center rounded-full border-2 border-base-accent text-xs font-semibold text-white",
45
+ colorFor(p.userId),
46
+ )}
47
+ >
48
+ {initials(p.email)}
49
+ </span>
50
+ ))}
51
+ </div>
52
+ );
53
+ }
@@ -7,6 +7,7 @@ import { DeleteButton } from "./DeleteButton";
7
7
  import { SettingsButton } from "./SettingsButton";
8
8
  import { StatusIndicator } from "./StatusIndicator";
9
9
  import { AudienceIndicator } from "./AudienceIndicator";
10
+ import { LockBadge } from "./LockBadge";
10
11
  import { SettingsForm } from "./SettingsForm";
11
12
  import { useEditorContext } from "../shell/EditorContext";
12
13
  import { useEditorModal } from "../shell/EditorModalContext";
@@ -38,8 +39,12 @@ export function SectionWrapper({
38
39
  mainStatus,
39
40
  contentDiffersFromMain,
40
41
  isLocalOnly,
42
+ lockedBy,
43
+ onTakeover,
44
+ onEditIntent,
41
45
  children,
42
46
  }: WrapperProps) {
47
+ const isLocked = !!lockedBy;
43
48
  const { isEditMode, showAllChrome } = useEditorContext();
44
49
  const { openModal } = useEditorModal();
45
50
  const [isActive, setIsActive] = useState(false);
@@ -66,7 +71,11 @@ export function SectionWrapper({
66
71
  useEffect(() => {
67
72
  const block = blockRef.current;
68
73
  if (!block) return;
69
- const onFocusIn = () => setIsActive(true);
74
+ const onFocusIn = () => {
75
+ setIsActive(true);
76
+ // Fire-and-forget lock request; denial arrives via `lockedBy` in props.
77
+ if (!isLocked && onEditIntent) void onEditIntent();
78
+ };
70
79
  const onFocusOut = (e: FocusEvent) => {
71
80
  if (!block.contains(e.relatedTarget as Node)) setIsActive(false);
72
81
  };
@@ -76,7 +85,10 @@ export function SectionWrapper({
76
85
  block.removeEventListener("focusin", onFocusIn);
77
86
  block.removeEventListener("focusout", onFocusOut);
78
87
  };
79
- }, []);
88
+ // Caller contract: pass a memoized (useCallback) onEditIntent — it is a
89
+ // dependency here, so an inline closure re-binds these listeners on every
90
+ // render of the shell.
91
+ }, [isLocked, onEditIntent]);
80
92
 
81
93
  useEffect(() => {
82
94
  const block = blockRef.current;
@@ -164,7 +176,11 @@ export function SectionWrapper({
164
176
  cancelled = true;
165
177
  cleanup?.();
166
178
  };
167
- }, [sectionId, index, containerId, isContainerBlock, isEditMode]);
179
+ // isLocked: the DragHandle unmounts while locked, so DnD registration must
180
+ // be torn down on lock-acquire (cleanup above) and re-run against the fresh
181
+ // handle node on lock-release — without it, a locked-then-released section's
182
+ // registration stays bound to the old unmounted handle and reorder breaks.
183
+ }, [sectionId, index, containerId, isContainerBlock, isEditMode, isLocked]);
168
184
 
169
185
  function handleSettingsClick() {
170
186
  if (!onSectionChange) return;
@@ -229,6 +245,7 @@ export function SectionWrapper({
229
245
  className={cn(
230
246
  "relative",
231
247
  isDragging && "opacity-50",
248
+ isLocked && "opacity-60",
232
249
  isActive && "outline outline-2 outline-primary/50 rounded-sm",
233
250
  !isActive &&
234
251
  showAllChrome &&
@@ -242,7 +259,7 @@ export function SectionWrapper({
242
259
  {/* Plus button — left gutter on xl+, hidden below. hover:z-[60] lifts the
243
260
  whole slot (a z-30 stacking context the tooltip can't escape) above the
244
261
  inline section-type picker (z-50) while the tooltip is showing. */}
245
- {onRequestInsert && !isDragging && chromeReady && (
262
+ {!isLocked && onRequestInsert && !isDragging && chromeReady && (
246
263
  <div
247
264
  className={cn(
248
265
  "absolute -left-16 bottom-full z-30 hover:z-[60] mb-1 hidden xl:block transition-opacity",
@@ -254,35 +271,40 @@ export function SectionWrapper({
254
271
  )}
255
272
 
256
273
  {/* Drag handle + section label */}
257
- <div
258
- className={cn(
259
- "absolute left-0 bottom-full z-30 hover:z-[60] mb-1 flex items-center gap-1.5 transition-opacity",
260
- alwaysShow ? "opacity-100" : "opacity-0 group-hover:opacity-100",
261
- )}
262
- >
263
- <div className="relative -ml-[34px] flex flex-col items-center">
264
- <DragHandle ref={handleRef} />
265
- {onRequestInsert && !isDragging && chromeReady && (
266
- // Below xl the gutter can't fit the plus beside the handle, so it
267
- // hangs below it (absolute → no row-height contribution: the handle
268
- // stays put and the pair stacks DOWN instead of centering).
269
- <div className="absolute top-full xl:hidden">
270
- <InsertButton index={index} onInsert={onRequestInsert} />
271
- </div>
274
+ {!isLocked && (
275
+ <div
276
+ className={cn(
277
+ "absolute left-0 bottom-full z-30 hover:z-[60] mb-1 flex items-center gap-1.5 transition-opacity",
278
+ alwaysShow ? "opacity-100" : "opacity-0 group-hover:opacity-100",
272
279
  )}
280
+ >
281
+ <div className="relative -ml-[34px] flex flex-col items-center">
282
+ <DragHandle ref={handleRef} />
283
+ {onRequestInsert && !isDragging && chromeReady && (
284
+ // Below xl the gutter can't fit the plus beside the handle, so it
285
+ // hangs below it (absolute → no row-height contribution: the handle
286
+ // stays put and the pair stacks DOWN instead of centering).
287
+ <div className="absolute top-full xl:hidden">
288
+ <InsertButton index={index} onInsert={onRequestInsert} />
289
+ </div>
290
+ )}
291
+ </div>
292
+ <span className="pointer-events-none select-none whitespace-nowrap text-sm capitalize text-base-contrast-light/80">
293
+ {definition.label}
294
+ </span>
273
295
  </div>
274
- <span className="pointer-events-none select-none whitespace-nowrap text-sm capitalize text-base-contrast-light/80">
275
- {definition.label}
276
- </span>
277
- </div>
296
+ )}
278
297
 
279
298
  {/* Status + settings + delete — top-right, above content */}
280
299
  <div
281
300
  className={cn(
282
301
  "pointer-events-none absolute right-0 bottom-full z-30 mb-1 flex items-center gap-2 transition-opacity",
283
- alwaysShow ? "opacity-100" : "opacity-0 group-hover:opacity-100",
302
+ // A lock is important state — keep the cluster visible without hover.
303
+ alwaysShow || isLocked ? "opacity-100" : "opacity-0 group-hover:opacity-100",
284
304
  )}
285
305
  >
306
+ {lockedBy && <LockBadge email={lockedBy.email} onTakeover={onTakeover} />}
307
+
286
308
  <div className="pointer-events-auto">
287
309
  <StatusIndicator
288
310
  mainStatus={mainStatus ?? null}
@@ -291,6 +313,7 @@ export function SectionWrapper({
291
313
  isLocalOnly={isLocalOnly ?? false}
292
314
  status={status as "draft" | "live" | "archived"}
293
315
  dirty={dirty}
316
+ disabled={isLocked}
294
317
  onChange={(s) => onStatusChange?.(s)}
295
318
  />
296
319
  </div>
@@ -298,11 +321,12 @@ export function SectionWrapper({
298
321
  <AudienceIndicator
299
322
  access={access}
300
323
  audiences={audiences}
324
+ disabled={isLocked}
301
325
  onChange={(newAccess) => onAccessChange?.(newAccess)}
302
326
  />
303
327
  </div>
304
328
 
305
- {onAddChild && (
329
+ {!isLocked && onAddChild && (
306
330
  <button
307
331
  type="button"
308
332
  onClick={onAddChild}
@@ -313,11 +337,11 @@ export function SectionWrapper({
313
337
  </button>
314
338
  )}
315
339
 
316
- {hasSettings && (
340
+ {!isLocked && hasSettings && (
317
341
  <SettingsButton onClick={handleSettingsClick} />
318
342
  )}
319
343
 
320
- {onMoveSection && (
344
+ {!isLocked && onMoveSection && (
321
345
  <button
322
346
  type="button"
323
347
  onClick={onMoveSection}
@@ -328,7 +352,7 @@ export function SectionWrapper({
328
352
  </button>
329
353
  )}
330
354
 
331
- {onDelete && <DeleteButton onDelete={onDelete} />}
355
+ {!isLocked && onDelete && <DeleteButton onDelete={onDelete} />}
332
356
  </div>
333
357
 
334
358
  {/* Drop indicator — centered in the section's own padding-top */}
@@ -345,7 +369,14 @@ export function SectionWrapper({
345
369
  <div className="absolute right-0 bottom-0 left-0 z-10 h-0.5 bg-primary" />
346
370
  )}
347
371
 
348
- {children}
372
+ {isLocked ? (
373
+ // React 19 supports the boolean `inert` prop — blocks focus, pointer,
374
+ // and edits to another editor's locked content. Only wrapped when
375
+ // locked so the unlocked DOM is unchanged.
376
+ <div inert>{children}</div>
377
+ ) : (
378
+ children
379
+ )}
349
380
  </div>
350
381
  );
351
382
  }
@@ -91,6 +91,8 @@ interface StatusIndicatorProps {
91
91
  status: Status;
92
92
  dirty?: boolean;
93
93
  onChange: (status: Status) => void;
94
+ /** Display-only mode — the pill still shows status but the picker never opens. */
95
+ disabled?: boolean;
94
96
  }
95
97
 
96
98
  export function StatusIndicator({
@@ -101,6 +103,7 @@ export function StatusIndicator({
101
103
  status,
102
104
  dirty,
103
105
  onChange,
106
+ disabled,
104
107
  }: StatusIndicatorProps) {
105
108
  const display = deriveStatusDisplay({ mainStatus, savedStatus, contentDiffers, isLocalOnly });
106
109
 
@@ -117,6 +120,7 @@ export function StatusIndicator({
117
120
  label={labelText}
118
121
  ariaLabel={display.description}
119
122
  buttonClassName="text-base-contrast-light"
123
+ disabled={disabled}
120
124
  clickContent={(onClose) => (
121
125
  <ul role="radiogroup" aria-label="Section status" className="w-full overflow-hidden py-1">
122
126
  {STATUSES.map((s) => {
@@ -40,6 +40,8 @@ interface ContainerChildProps {
40
40
  hasSettings: boolean;
41
41
  mergedOptions: Record<string, unknown>;
42
42
  isEditMode: boolean;
43
+ /** First cell only: shift the control rail below the section's stacked "+" (below xl). */
44
+ clearSectionChrome?: boolean;
43
45
  ChildComp: ChildComponent;
44
46
  onDelete: () => void;
45
47
  onDuplicate: () => void;
@@ -65,6 +67,7 @@ function ContainerChild({
65
67
  hasSettings,
66
68
  mergedOptions,
67
69
  isEditMode,
70
+ clearSectionChrome,
68
71
  ChildComp,
69
72
  onDelete,
70
73
  onDuplicate,
@@ -97,6 +100,7 @@ function ContainerChild({
97
100
  closestEdge={closestEdge}
98
101
  isDragging={isDragging}
99
102
  hasSettings={hasSettings}
103
+ clearSectionChrome={clearSectionChrome}
100
104
  dragHandleRef={handleRef}
101
105
  onDelete={onDelete}
102
106
  onDuplicate={onDuplicate}
@@ -328,6 +332,7 @@ export function Container({ content, isEditMode, onChange, openModal }: Containe
328
332
  hasSettings={hasSettings}
329
333
  mergedOptions={mergedOptions}
330
334
  isEditMode={isEditMode}
335
+ clearSectionChrome={index === 0}
331
336
  ChildComp={def.component as ChildComponent}
332
337
  onDelete={() => emit(trimTrailingSpacers(removeChildAt(container, index).container))}
333
338
  onDuplicate={() => emit(duplicateChildAt(container, index, newId))}
@@ -348,10 +353,7 @@ export function Container({ content, isEditMode, onChange, openModal }: Containe
348
353
  const emptyCols = occupied < columns ? columns - occupied : 0;
349
354
  return (
350
355
  <div className="@container">
351
- {/* Below xl the section's "+" stacks under the drag handle and hangs down
352
- into the left gutter, where the first child's control rail would collide
353
- with it — push children down until the gutter is side-by-side again. */}
354
- <div className={cn(gridClass, "pt-8 xl:pt-0")}>
356
+ <div className={gridClass}>
355
357
  {children.map(renderEditChild)}
356
358
  {Array.from({ length: emptyCols }, (_, i) => (
357
359
  <EmptyColumnCell