@morphika/andami 0.1.3 → 0.1.5

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 (84) hide show
  1. package/app/(site)/[slug]/page.tsx +2 -2
  2. package/app/(site)/layout.tsx +1 -0
  3. package/app/(site)/page.tsx +2 -2
  4. package/app/(site)/preview/page.tsx +4 -4
  5. package/app/(site)/work/[slug]/page.tsx +2 -2
  6. package/app/admin/layout.tsx +2 -2
  7. package/app/admin/login/page.tsx +5 -5
  8. package/app/admin/navigation/page.tsx +255 -157
  9. package/app/api/admin/assets/relink/confirm/route.ts +1 -1
  10. package/app/api/admin/pages/[slug]/route.ts +1 -1
  11. package/app/api/admin/settings/route.ts +40 -15
  12. package/app/api/admin/setup/complete/route.ts +1 -1
  13. package/app/api/admin/setup/route.ts +6 -3
  14. package/components/admin/index.ts +7 -0
  15. package/components/admin/nav-builder/NavGeneralSettings.tsx +11 -15
  16. package/components/admin/nav-builder/NavItemSettings.tsx +29 -5
  17. package/components/admin/nav-builder/NavLivePreview.tsx +4 -1
  18. package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -0
  19. package/components/admin/nav-builder/NavMobileSettings.tsx +223 -0
  20. package/components/admin/nav-builder/index.ts +2 -0
  21. package/components/blocks/BlockRenderer.tsx +65 -13
  22. package/components/blocks/ButtonBlockRenderer.tsx +29 -6
  23. package/components/blocks/CoverBlockRenderer.tsx +36 -14
  24. package/components/blocks/ImageBlockRenderer.tsx +5 -3
  25. package/components/blocks/ImageGridBlockRenderer.tsx +13 -6
  26. package/components/blocks/PageRenderer.tsx +4 -2
  27. package/components/blocks/ProjectGridBlockRenderer.tsx +18 -3
  28. package/components/blocks/SectionRenderer.tsx +9 -8
  29. package/components/blocks/SectionV2Renderer.tsx +8 -8
  30. package/components/blocks/SpacerBlockRenderer.tsx +4 -2
  31. package/components/blocks/TextBlockRenderer.tsx +9 -4
  32. package/components/builder/BuilderCanvas.tsx +10 -4
  33. package/components/builder/ColorPicker.tsx +51 -243
  34. package/components/builder/ColorSwatchPicker.tsx +214 -274
  35. package/components/builder/DndWrapper.tsx +5 -2
  36. package/components/builder/SectionV2Canvas.tsx +15 -4
  37. package/components/builder/asset-browser/useAssetBrowser.ts +9 -1
  38. package/components/builder/color-picker/AlphaSlider.tsx +141 -0
  39. package/components/builder/color-picker/AngleControl.tsx +138 -0
  40. package/components/builder/color-picker/ColorInputs.tsx +105 -0
  41. package/components/builder/color-picker/EyedropperButton.tsx +74 -0
  42. package/components/builder/color-picker/GradientBar.tsx +222 -0
  43. package/components/builder/color-picker/GradientPreview.tsx +53 -0
  44. package/components/builder/color-picker/HueSlider.tsx +124 -0
  45. package/components/builder/color-picker/MeshCanvas.tsx +172 -0
  46. package/components/builder/color-picker/MeshPointEditor.tsx +133 -0
  47. package/components/builder/color-picker/MeshPointList.tsx +200 -0
  48. package/components/builder/color-picker/PositionControl.tsx +158 -0
  49. package/components/builder/color-picker/SaturationCanvas.tsx +142 -0
  50. package/components/builder/color-picker/StopEditor.tsx +178 -0
  51. package/components/builder/color-picker/SwatchBar.tsx +93 -0
  52. package/components/builder/color-picker/UnifiedColorPicker.tsx +713 -0
  53. package/components/builder/color-picker/index.ts +62 -0
  54. package/components/builder/color-picker/types.ts +115 -0
  55. package/components/builder/color-picker/utils.ts +138 -0
  56. package/components/builder/editors/CoverBlockEditor.tsx +86 -32
  57. package/components/builder/editors/ProjectGridEditor.tsx +51 -4
  58. package/components/builder/hooks/useColumnDrag.ts +25 -27
  59. package/components/builder/settings-panel/BlockLayoutTab.tsx +29 -7
  60. package/components/builder/settings-panel/LayoutTab.tsx +382 -310
  61. package/components/builder/settings-panel/PageSettings.tsx +6 -4
  62. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
  63. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +392 -312
  64. package/components/builder/settings-panel/SectionV2Settings.tsx +65 -35
  65. package/components/ui/Navbar.tsx +95 -25
  66. package/components/ui/PortfolioTracker.tsx +3 -3
  67. package/lib/assets.ts +1 -1
  68. package/lib/auth.ts +1 -1
  69. package/lib/builder/gradient-presets.ts +128 -0
  70. package/lib/builder/layout-styles.ts +16 -10
  71. package/lib/builder/serializer.ts +1 -0
  72. package/lib/builder/store-blocks.ts +48 -61
  73. package/lib/builder/store-helpers.ts +31 -14
  74. package/lib/builder/store.ts +59 -41
  75. package/lib/builder/types.ts +14 -0
  76. package/lib/color-utils.ts +200 -0
  77. package/lib/revalidate.ts +2 -2
  78. package/lib/sanity/queries.ts +4 -3
  79. package/lib/sanity/types.ts +76 -1
  80. package/lib/setup/detect.ts +1 -1
  81. package/package.json +8 -2
  82. package/sanity/schemas/siteSettings.ts +34 -0
  83. package/styles/base.css +3 -3
  84. package/app/globals.css +0 -7
@@ -1,274 +1,214 @@
1
- "use client";
2
-
3
- /**
4
- * ColorSwatchPicker — Dropdown color picker used in block editors.
5
- * Shows the user's palette swatches + common colors + custom picker.
6
- *
7
- * Used in: SettingsPanel (row/block bg, border), TextBlockEditor (text color),
8
- * CoverBlockEditor (text color), and any future color field.
9
- *
10
- * The dropdown uses a portal + fixed positioning so it is never clipped
11
- * by ancestor `overflow: auto/hidden` containers (e.g. SettingsPanel).
12
- */
13
-
14
- import { useState, useRef, useEffect, useCallback } from "react";
15
- import { createPortal } from "react-dom";
16
- import ColorPicker, { isValidHex } from "./ColorPicker";
17
- import type { ColorSwatch } from "../../lib/sanity/types";
18
-
19
- // Common neutral colors always available
20
- const COMMON_COLORS = [
21
- "#ffffff", "#f5f5f5", "#e5e5e5", "#a3a3a3",
22
- "#525252", "#262626", "#171717", "#000000",
23
- ];
24
-
25
- interface ColorSwatchPickerProps {
26
- /** Current color value (hex string or empty) */
27
- value: string;
28
- /** Callback when color changes */
29
- onChange: (hex: string) => void;
30
- /** Palette swatches from global styles */
31
- swatches?: ColorSwatch[];
32
- /** Optional label */
33
- label?: string;
34
- /** Allow clearing the color */
35
- allowClear?: boolean;
36
- /** Compact inline mode (no popover, just the swatch row) */
37
- inline?: boolean;
38
- }
39
-
40
- export default function ColorSwatchPicker({
41
- value,
42
- onChange,
43
- swatches = [],
44
- label,
45
- allowClear = true,
46
- inline = false,
47
- }: ColorSwatchPickerProps) {
48
- const [open, setOpen] = useState(false);
49
- const [customOpen, setCustomOpen] = useState(false);
50
- const containerRef = useRef<HTMLDivElement>(null);
51
- const portalRef = useRef<HTMLDivElement>(null);
52
- const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
53
-
54
- // Compute dropdown position from trigger button's bounding rect
55
- useEffect(() => {
56
- if (!open || !containerRef.current) return;
57
- const rect = containerRef.current.getBoundingClientRect();
58
- // Position below the trigger, right-aligned
59
- const panelWidth = 244; // min-w-[220px] + padding
60
- let left = rect.right - panelWidth;
61
- // Clamp to viewport left edge
62
- if (left < 8) left = 8;
63
- setDropdownPos({ top: rect.bottom + 4, left });
64
- }, [open]);
65
-
66
- // Close on outside click — check both container and portal
67
- useEffect(() => {
68
- if (!open) return;
69
- const handler = (e: MouseEvent) => {
70
- const target = e.target as Node;
71
- const inContainer = containerRef.current?.contains(target);
72
- const inPortal = portalRef.current?.contains(target);
73
- if (!inContainer && !inPortal) {
74
- setOpen(false);
75
- setCustomOpen(false);
76
- }
77
- };
78
- document.addEventListener("mousedown", handler);
79
- return () => document.removeEventListener("mousedown", handler);
80
- }, [open]);
81
-
82
- // Close on scroll of any ancestor (reposition would be complex; just close)
83
- useEffect(() => {
84
- if (!open) return;
85
- const handler = () => {
86
- if (!containerRef.current) return;
87
- // Recompute position on scroll instead of closing
88
- const rect = containerRef.current.getBoundingClientRect();
89
- const panelWidth = 244;
90
- let left = rect.right - panelWidth;
91
- if (left < 8) left = 8;
92
- setDropdownPos({ top: rect.bottom + 4, left });
93
- };
94
- // Listen on capture phase to catch scrolls on any ancestor
95
- window.addEventListener("scroll", handler, { capture: true, passive: true });
96
- return () => window.removeEventListener("scroll", handler, { capture: true });
97
- }, [open]);
98
-
99
- const handleSelect = useCallback((hex: string) => {
100
- onChange(hex);
101
- if (!inline) {
102
- setOpen(false);
103
- setCustomOpen(false);
104
- }
105
- }, [onChange, inline]);
106
-
107
- const handleClear = useCallback(() => {
108
- onChange("");
109
- setOpen(false);
110
- }, [onChange]);
111
-
112
- // ─── Trigger button (the colored swatch + hex label) ───
113
- const trigger = (
114
- <button
115
- type="button"
116
- onClick={() => setOpen(!open)}
117
- className="flex items-center gap-2 px-1.5 py-1 rounded-lg border border-neutral-200 bg-white hover:border-neutral-300 transition-colors cursor-pointer w-full"
118
- >
119
- <div
120
- className="w-6 h-6 rounded-md border border-neutral-200 shrink-0"
121
- style={{
122
- background: value && isValidHex(value) ? value : "transparent",
123
- backgroundImage: !value ? "linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%), linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%)" : undefined,
124
- backgroundSize: !value ? "6px 6px" : undefined,
125
- backgroundPosition: !value ? "0 0, 3px 3px" : undefined,
126
- }}
127
- />
128
- <span className="text-[11px] text-neutral-500 font-mono truncate">
129
- {value ? value.toUpperCase() : "None"}
130
- </span>
131
- </button>
132
- );
133
-
134
- // ─── Dropdown panel ───
135
- const panel = (
136
- <div className="bg-white rounded-xl border border-neutral-200 p-3 shadow-xl min-w-[220px]">
137
-
138
- {/* User palette swatches */}
139
- {swatches.length > 0 && (
140
- <div className="mb-3">
141
- <div className="text-[9px] text-neutral-400 uppercase tracking-widest mb-1.5">Palette</div>
142
- <div className="flex flex-wrap gap-1.5">
143
- {swatches.map((s, i) => (
144
- <button
145
- key={s._key || i}
146
- onClick={() => handleSelect(s.hex)}
147
- title={`${s.name}: ${s.hex}`}
148
- className={`w-7 h-7 rounded-lg cursor-pointer transition-all ${
149
- value === s.hex
150
- ? "ring-2 ring-[#076bff] ring-offset-1 ring-offset-white"
151
- : "border border-neutral-200 hover:border-neutral-400"
152
- }`}
153
- style={{ background: s.hex }}
154
- />
155
- ))}
156
- </div>
157
- </div>
158
- )}
159
-
160
- {/* Common colors */}
161
- <div className="mb-3">
162
- <div className="text-[9px] text-neutral-400 uppercase tracking-widest mb-1.5">Common</div>
163
- <div className="flex flex-wrap gap-1.5">
164
- {COMMON_COLORS.map((c) => (
165
- <button
166
- key={c}
167
- onClick={() => handleSelect(c)}
168
- title={c}
169
- className={`w-7 h-7 rounded-lg cursor-pointer transition-all ${
170
- value === c
171
- ? "ring-2 ring-[#076bff] ring-offset-1 ring-offset-white"
172
- : "border border-neutral-200 hover:border-neutral-400"
173
- }`}
174
- style={{ background: c }}
175
- />
176
- ))}
177
- </div>
178
- </div>
179
-
180
- {/* Custom color toggle */}
181
- {!customOpen ? (
182
- <button
183
- onClick={() => setCustomOpen(true)}
184
- className="w-full py-1.5 rounded-lg border border-dashed border-neutral-300 text-neutral-400 text-[10px] uppercase tracking-widest cursor-pointer hover:border-neutral-400 hover:text-neutral-600 transition-colors"
185
- >
186
- Custom color
187
- </button>
188
- ) : (
189
- <div className="mt-1">
190
- <ColorPicker
191
- color={value || "#ffffff"}
192
- onChange={(hex) => handleSelect(hex)}
193
- onClose={() => setCustomOpen(false)}
194
- confirmLabel="Apply"
195
- />
196
- </div>
197
- )}
198
-
199
- {/* Clear button */}
200
- {allowClear && value && (
201
- <button
202
- onClick={handleClear}
203
- className="w-full mt-2 py-1.5 rounded-lg border border-neutral-200 text-neutral-400 text-[10px] uppercase tracking-widest cursor-pointer hover:border-red-300 hover:text-red-500 transition-colors"
204
- >
205
- Clear color
206
- </button>
207
- )}
208
- </div>
209
- );
210
-
211
- return (
212
- <div ref={containerRef} className="relative">
213
- {label && (
214
- <label className="text-[10px] text-neutral-500 uppercase tracking-wider block mb-1">
215
- {label}
216
- </label>
217
- )}
218
- {trigger}
219
- {open && typeof document !== "undefined" &&
220
- createPortal(
221
- <div
222
- ref={portalRef}
223
- className="fixed z-[9999]"
224
- style={{ top: dropdownPos.top, left: dropdownPos.left }}
225
- >
226
- {panel}
227
- </div>,
228
- document.body
229
- )
230
- }
231
- </div>
232
- );
233
- }
234
-
235
- // ─── Hook: fetch palette swatches from admin styles API ───
236
-
237
- let cachedSwatches: ColorSwatch[] | null = null;
238
- let cachePromise: Promise<ColorSwatch[]> | null = null;
239
-
240
- export function usePaletteSwatches(): ColorSwatch[] {
241
- const [swatches, setSwatches] = useState<ColorSwatch[]>(cachedSwatches || []);
242
-
243
- useEffect(() => {
244
- if (cachedSwatches) {
245
- setSwatches(cachedSwatches);
246
- return;
247
- }
248
-
249
- if (!cachePromise) {
250
- cachePromise = fetch("/api/admin/styles")
251
- .then((res) => (res.ok ? res.json() : { styles: { colors: { swatches: [] } } }))
252
- .then((data) => {
253
- const s = data?.styles?.colors?.swatches || [];
254
- cachedSwatches = s;
255
- return s;
256
- })
257
- .catch(() => {
258
- /* Color palette unavailable — use empty swatches */
259
- cachedSwatches = [];
260
- return [];
261
- });
262
- }
263
-
264
- cachePromise.then((s) => setSwatches(s));
265
- }, []);
266
-
267
- return swatches;
268
- }
269
-
270
- /** Invalidate the cached swatches (call after saving palette) */
271
- export function invalidatePaletteCache() {
272
- cachedSwatches = null;
273
- cachePromise = null;
274
- }
1
+ "use client";
2
+
3
+ /**
4
+ * ColorSwatchPicker — Trigger component for the color picker.
5
+ *
6
+ * Shows a colored swatch + hex/gradient label. On click, opens the
7
+ * UnifiedColorPicker as a centered modal (Color Picker v2).
8
+ *
9
+ * Phase 3: Supports `ColorField` (string | GradientValue) for value/onChange.
10
+ * - When `allowGradients={true}`, gradient tabs appear in the modal.
11
+ * - The trigger swatch renders gradients as a mini CSS preview.
12
+ * - Backward compatible: string-only usage still works (default behavior).
13
+ *
14
+ * Used in: SettingsPanel (row/block bg, border), TextBlockEditor (text color),
15
+ * CoverBlockEditor (text color), BlockLayoutTab, SectionV2LayoutTab,
16
+ * PageSettings, ParallaxSlideSettings, and any future color field.
17
+ */
18
+
19
+ import { useState, useCallback, useEffect } from "react";
20
+ import { UnifiedColorPicker } from "./color-picker";
21
+ import {
22
+ isValidHex,
23
+ isGradient,
24
+ colorToCSS,
25
+ resolveColorHex,
26
+ serializeColorField,
27
+ } from "../../lib/color-utils";
28
+ import type { ColorSwatch, ColorField } from "../../lib/sanity/types";
29
+
30
+ interface ColorSwatchPickerProps {
31
+ /** Current color value (hex string, empty string, or GradientValue) */
32
+ value: ColorField | string;
33
+ /** Callback when color changes */
34
+ onChange: (value: ColorField) => void;
35
+ /** Palette swatches from global styles */
36
+ swatches?: ColorSwatch[];
37
+ /** Optional label */
38
+ label?: string;
39
+ /** Allow clearing the color */
40
+ allowClear?: boolean;
41
+ /** Allow gradient tabs in the picker (default false) */
42
+ allowGradients?: boolean;
43
+ /** Live preview callback — called during drag in the picker (Phase 4). */
44
+ onPreview?: (value: ColorField) => void;
45
+ /** @deprecated Inline mode is no longer supported in v2. Ignored. */
46
+ inline?: boolean;
47
+ }
48
+
49
+ export default function ColorSwatchPicker({
50
+ value,
51
+ onChange,
52
+ swatches = [],
53
+ label,
54
+ allowClear = true,
55
+ allowGradients = false,
56
+ onPreview,
57
+ }: ColorSwatchPickerProps) {
58
+ const [open, setOpen] = useState(false);
59
+
60
+ const handleOpen = useCallback(() => {
61
+ setOpen(true);
62
+ }, []);
63
+
64
+ const handleClose = useCallback(() => {
65
+ setOpen(false);
66
+ }, []);
67
+
68
+ const handleChange = useCallback(
69
+ (newValue: ColorField) => {
70
+ onChange(newValue);
71
+ },
72
+ [onChange]
73
+ );
74
+
75
+ const handleClear = useCallback(() => {
76
+ onChange("");
77
+ setOpen(false);
78
+ }, [onChange]);
79
+
80
+ // Determine display values for the trigger
81
+ const isGrad = typeof value !== "string" && isGradient(value as ColorField);
82
+ const displayHex = isGrad
83
+ ? resolveColorHex(value as ColorField)
84
+ : typeof value === "string"
85
+ ? value
86
+ : "";
87
+ const displayLabel = isGrad
88
+ ? (value as { type: string }).type.charAt(0).toUpperCase() +
89
+ (value as { type: string }).type.slice(1)
90
+ : displayHex
91
+ ? displayHex.toUpperCase()
92
+ : "None";
93
+
94
+ // Build trigger swatch style
95
+ const swatchStyle: React.CSSProperties = isGrad
96
+ ? {
97
+ backgroundImage: colorToCSS(value as ColorField),
98
+ }
99
+ : {
100
+ background:
101
+ typeof value === "string" && value && isValidHex(value)
102
+ ? value
103
+ : "transparent",
104
+ backgroundImage:
105
+ !value
106
+ ? "linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%), linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%)"
107
+ : undefined,
108
+ backgroundSize: !value ? "6px 6px" : undefined,
109
+ backgroundPosition: !value ? "0 0, 3px 3px" : undefined,
110
+ };
111
+
112
+ // Picker value: ensure we pass a valid ColorField
113
+ const pickerValue: ColorField =
114
+ isGrad
115
+ ? (value as ColorField)
116
+ : typeof value === "string" && isValidHex(value)
117
+ ? value
118
+ : "#ffffff";
119
+
120
+ return (
121
+ <div className="relative">
122
+ {label && (
123
+ <label className="text-[10px] text-neutral-500 uppercase tracking-wider block mb-1">
124
+ {label}
125
+ </label>
126
+ )}
127
+
128
+ {/* Trigger button */}
129
+ <button
130
+ type="button"
131
+ onClick={handleOpen}
132
+ className="flex items-center gap-2 px-1.5 py-1 rounded-lg border border-neutral-200 bg-white hover:border-neutral-300 transition-colors cursor-pointer w-full"
133
+ >
134
+ <div
135
+ className="w-6 h-6 rounded-md border border-neutral-200 shrink-0"
136
+ style={swatchStyle}
137
+ />
138
+ <span className="text-[11px] text-neutral-500 font-mono truncate">
139
+ {displayLabel}
140
+ </span>
141
+ </button>
142
+
143
+ {/* Clear button (inline, below trigger) */}
144
+ {allowClear && value && (
145
+ <button
146
+ type="button"
147
+ onClick={handleClear}
148
+ className="w-full mt-1 py-1 rounded-md border border-neutral-200 text-neutral-400 text-[9px] uppercase tracking-widest cursor-pointer hover:border-red-300 hover:text-red-500 transition-colors"
149
+ >
150
+ Clear
151
+ </button>
152
+ )}
153
+
154
+ {/* Modal picker */}
155
+ {open && (
156
+ <UnifiedColorPicker
157
+ value={pickerValue}
158
+ onChange={handleChange}
159
+ onClose={handleClose}
160
+ swatches={swatches}
161
+ confirmLabel="Apply Color"
162
+ allowGradients={allowGradients}
163
+ onPreview={onPreview}
164
+ />
165
+ )}
166
+ </div>
167
+ );
168
+ }
169
+
170
+ // ─── Hook: fetch palette swatches from admin styles API ───
171
+
172
+ let cachedSwatches: ColorSwatch[] | null = null;
173
+ let cachePromise: Promise<ColorSwatch[]> | null = null;
174
+
175
+ export function usePaletteSwatches(): ColorSwatch[] {
176
+ const [swatches, setSwatches] = useState<ColorSwatch[]>(
177
+ cachedSwatches || []
178
+ );
179
+
180
+ useEffect(() => {
181
+ if (cachedSwatches) {
182
+ setSwatches(cachedSwatches);
183
+ return;
184
+ }
185
+
186
+ if (!cachePromise) {
187
+ cachePromise = fetch("/api/admin/styles")
188
+ .then((res) =>
189
+ res.ok
190
+ ? res.json()
191
+ : { styles: { colors: { swatches: [] } } }
192
+ )
193
+ .then((data) => {
194
+ const s = data?.styles?.colors?.swatches || [];
195
+ cachedSwatches = s;
196
+ return s;
197
+ })
198
+ .catch(() => {
199
+ cachedSwatches = [];
200
+ return [];
201
+ });
202
+ }
203
+
204
+ cachePromise.then((s) => setSwatches(s));
205
+ }, []);
206
+
207
+ return swatches;
208
+ }
209
+
210
+ /** Invalidate the cached swatches (call after saving palette) */
211
+ export function invalidatePaletteCache() {
212
+ cachedSwatches = null;
213
+ cachePromise = null;
214
+ }
@@ -65,7 +65,10 @@ function parseId(id: string) {
65
65
  // Drag Overlay Components
66
66
  // ============================================
67
67
 
68
- function RowDragOverlay({ rowKey }: { rowKey: string }) {
68
+ // PERF-001 fix: Memoize RowDragOverlay subscribes to s.rows which changes
69
+ // on any content mutation. Without memo, DragOverlay re-renders on every
70
+ // zoom/pan change because DndWrapper re-renders (canvasZoom subscription).
71
+ const RowDragOverlay = memo(function RowDragOverlay({ rowKey }: { rowKey: string }) {
69
72
  const rows = useBuilderStore((s) => s.rows);
70
73
  const item = rows.find((r) => r._key === rowKey);
71
74
  if (!item) return null;
@@ -114,7 +117,7 @@ function RowDragOverlay({ rowKey }: { rowKey: string }) {
114
117
  }
115
118
 
116
119
  return null;
117
- }
120
+ });
118
121
 
119
122
  const BlockDragOverlay = memo(function BlockDragOverlay({ blockKey, rowKey }: { blockKey: string; rowKey: string }) {
120
123
  const rows = useBuilderStore((s) => s.rows);
@@ -101,18 +101,29 @@ export default function SectionV2Canvas({
101
101
  // Measure container width for pixel position calculation.
102
102
  // Uses ResizeObserver so it updates when the grid resizes (zoom change, window resize)
103
103
  // and is also immediately available when a drag starts (no stale 0 value).
104
+ // PERF-003 fix: Debounce ResizeObserver callback to avoid excessive re-renders
105
+ // during continuous resize events (zoom drag, window resize).
104
106
  useEffect(() => {
105
107
  const el = gridContainerRef.current;
106
108
  if (!el) return;
109
+ let rafId: number | null = null;
107
110
  const ro = new ResizeObserver((entries) => {
108
- for (const entry of entries) {
109
- setContainerWidth(entry.contentRect.width);
110
- }
111
+ // Coalesce via rAF at most one setState per frame
112
+ if (rafId !== null) cancelAnimationFrame(rafId);
113
+ rafId = requestAnimationFrame(() => {
114
+ rafId = null;
115
+ for (const entry of entries) {
116
+ setContainerWidth(entry.contentRect.width);
117
+ }
118
+ });
111
119
  });
112
120
  ro.observe(el);
113
121
  // Set initial value immediately
114
122
  setContainerWidth(el.getBoundingClientRect().width);
115
- return () => ro.disconnect();
123
+ return () => {
124
+ ro.disconnect();
125
+ if (rafId !== null) cancelAnimationFrame(rafId);
126
+ };
116
127
  }, []);
117
128
 
118
129
  // Use preview columns during resize, otherwise responsive-aware effective columns
@@ -23,10 +23,15 @@ export function useAssetBrowser(onScanComplete?: (result: Record<string, unknown
23
23
  const [uploading, setUploading] = useState<UploadingFile[]>([]);
24
24
  const [r2Available, setR2Available] = useState(false);
25
25
  const uploadCleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
26
+ // LEAK-001 fix: Mounted guard prevents setState calls from timer callbacks
27
+ // that fire during the unmount cleanup race window.
28
+ const isMountedRef = useRef(true);
26
29
 
27
30
  // Clean up pending timer on unmount
28
31
  useEffect(() => {
32
+ isMountedRef.current = true;
29
33
  return () => {
34
+ isMountedRef.current = false;
30
35
  if (uploadCleanupTimer.current) clearTimeout(uploadCleanupTimer.current);
31
36
  };
32
37
  }, []);
@@ -297,10 +302,13 @@ export function useAssetBrowser(onScanComplete?: (result: Record<string, unknown
297
302
  onUploadComplete?.();
298
303
 
299
304
  // Clear completed uploads after a short delay (with cleanup on unmount)
305
+ // LEAK-001 fix: Guard prevents setState after unmount.
300
306
  if (uploadCleanupTimer.current) clearTimeout(uploadCleanupTimer.current);
301
307
  uploadCleanupTimer.current = setTimeout(() => {
302
308
  uploadCleanupTimer.current = null;
303
- setUploading((prev) => prev.filter((u) => u.status !== "done"));
309
+ if (isMountedRef.current) {
310
+ setUploading((prev) => prev.filter((u) => u.status !== "done"));
311
+ }
304
312
  }, 2000);
305
313
  }, [fetchAssets, onUploadComplete]);
306
314