@morphika/andami 0.1.2 → 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 (85) 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/config/index.ts +14 -43
  78. package/lib/revalidate.ts +2 -2
  79. package/lib/sanity/queries.ts +4 -3
  80. package/lib/sanity/types.ts +76 -1
  81. package/lib/setup/detect.ts +1 -1
  82. package/package.json +8 -12
  83. package/sanity/schemas/siteSettings.ts +34 -0
  84. package/styles/base.css +7 -51
  85. package/app/globals.css +0 -7
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Color Picker v2 — Barrel exports.
3
+ *
4
+ * Phase 1: Solid color picker with modal UI.
5
+ * Phase 3: Gradient components (GradientBar, GradientPreview, AngleControl,
6
+ * PositionControl, MeshCanvas, MeshPointList, StopEditor).
7
+ */
8
+
9
+ export { default as UnifiedColorPicker } from "./UnifiedColorPicker";
10
+ export { default as SaturationCanvas } from "./SaturationCanvas";
11
+ export { default as HueSlider } from "./HueSlider";
12
+ export { default as AlphaSlider } from "./AlphaSlider";
13
+ export { default as ColorInputs } from "./ColorInputs";
14
+ export { default as EyedropperButton } from "./EyedropperButton";
15
+ export { default as SwatchBar } from "./SwatchBar";
16
+
17
+ // Phase 3: Gradient components
18
+ export { default as GradientBar } from "./GradientBar";
19
+ export { default as GradientPreview } from "./GradientPreview";
20
+ export { default as AngleControl } from "./AngleControl";
21
+ export { default as PositionControl } from "./PositionControl";
22
+ export { default as MeshCanvas } from "./MeshCanvas";
23
+ export { default as MeshPointList } from "./MeshPointList";
24
+ export { default as MeshPointEditor } from "./MeshPointEditor";
25
+ export { default as StopEditor } from "./StopEditor";
26
+
27
+ // Types
28
+ export type {
29
+ ColorFormat,
30
+ HSV,
31
+ HSL,
32
+ RGB,
33
+ GradientStop,
34
+ MeshPoint,
35
+ LinearGradient,
36
+ RadialGradient,
37
+ MeshGradient,
38
+ GradientValue,
39
+ ColorField,
40
+ UnifiedColorPickerProps,
41
+ SaturationCanvasProps,
42
+ HueSliderProps,
43
+ AlphaSliderProps,
44
+ ColorInputsProps,
45
+ EyedropperButtonProps,
46
+ SwatchBarProps,
47
+ } from "./types";
48
+
49
+ // Utils
50
+ export {
51
+ hexToRGB,
52
+ rgbToHex,
53
+ hslToHex,
54
+ formatColorValue,
55
+ parseColorInput,
56
+ clamp,
57
+ hexToHSL,
58
+ hexToHSV,
59
+ hsvToHex,
60
+ hexToRgba,
61
+ isValidHex,
62
+ } from "./utils";
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Color Picker v2 — Type definitions.
3
+ *
4
+ * Gradient/ColorField types are canonical in lib/sanity/types.ts.
5
+ * Re-exported here for convenience within the color-picker module.
6
+ */
7
+
8
+ import type { ColorSwatch } from "../../../lib/sanity/types";
9
+
10
+ // Re-export canonical gradient types from lib/sanity/types
11
+ export type {
12
+ GradientStop,
13
+ MeshPoint,
14
+ LinearGradient,
15
+ RadialGradient,
16
+ MeshGradient,
17
+ GradientValue,
18
+ ColorField,
19
+ } from "../../../lib/sanity/types";
20
+
21
+ // ─── Color Formats ───
22
+
23
+ /** Supported color input/display formats */
24
+ export type ColorFormat = "hex" | "rgb" | "hsl";
25
+
26
+ // ─── HSV / HSL / RGB ───
27
+
28
+ export interface HSV {
29
+ h: number; // 0-360
30
+ s: number; // 0-100
31
+ v: number; // 0-100
32
+ }
33
+
34
+ export interface HSL {
35
+ h: number; // 0-360
36
+ s: number; // 0-100
37
+ l: number; // 0-100
38
+ }
39
+
40
+ export interface RGB {
41
+ r: number; // 0-255
42
+ g: number; // 0-255
43
+ b: number; // 0-255
44
+ }
45
+
46
+ // ─── Component Props ───
47
+
48
+ import type { ColorField } from "../../../lib/sanity/types";
49
+
50
+ /**
51
+ * Props for the unified color picker modal.
52
+ * Phase 3: value and onChange use ColorField (string | GradientValue).
53
+ */
54
+ export interface UnifiedColorPickerProps {
55
+ /** Current color value — hex string or GradientValue */
56
+ value: ColorField;
57
+ /** Callback when color is confirmed */
58
+ onChange: (value: ColorField) => void;
59
+ /** Close modal */
60
+ onClose: () => void;
61
+ /** Palette swatches from global styles */
62
+ swatches?: ColorSwatch[];
63
+ /** Label for the confirm button */
64
+ confirmLabel?: string;
65
+ /** Alpha value 0-1 (used for solid tab display) */
66
+ alpha?: number;
67
+ /** Callback when alpha changes */
68
+ onAlphaChange?: (alpha: number) => void;
69
+ /** Allow gradient tabs? false = only Solid tab visible (default false) */
70
+ allowGradients?: boolean;
71
+ /** Live preview callback — called throttled during drag interactions (Phase 4).
72
+ * Does NOT fire on confirm — use onChange for that. */
73
+ onPreview?: (value: ColorField) => void;
74
+ }
75
+
76
+ export interface SaturationCanvasProps {
77
+ hue: number;
78
+ saturation: number;
79
+ value: number;
80
+ onChange: (saturation: number, value: number) => void;
81
+ }
82
+
83
+ export interface HueSliderProps {
84
+ hue: number;
85
+ onChange: (hue: number) => void;
86
+ }
87
+
88
+ export interface AlphaSliderProps {
89
+ /** Current hex color (for the gradient display) */
90
+ color: string;
91
+ /** Alpha value 0-1 */
92
+ alpha: number;
93
+ onChange: (alpha: number) => void;
94
+ }
95
+
96
+ export interface ColorInputsProps {
97
+ hex: string;
98
+ onHexChange: (hex: string) => void;
99
+ alpha?: number;
100
+ }
101
+
102
+ export interface EyedropperButtonProps {
103
+ onColorPicked: (hex: string) => void;
104
+ }
105
+
106
+ export interface SwatchBarProps {
107
+ /** Current hex value (for active state) */
108
+ value: string;
109
+ /** Callback on swatch click */
110
+ onSelect: (hex: string) => void;
111
+ /** User palette swatches */
112
+ swatches?: ColorSwatch[];
113
+ /** Current hex to potentially add to palette */
114
+ currentColor?: string;
115
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Color Picker v2 — Utility functions.
3
+ *
4
+ * Re-exports core conversions from lib/color-utils.ts and adds
5
+ * picker-specific helpers (HSV↔HSL, RGB↔hex, format display).
6
+ */
7
+
8
+ import {
9
+ hexToHSL,
10
+ hexToHSV,
11
+ hsvToHex,
12
+ hexToRgba,
13
+ isValidHex,
14
+ } from "../../../lib/color-utils";
15
+
16
+ import type { HSV, HSL, RGB, ColorFormat } from "./types";
17
+
18
+ // ─── Re-exports ───
19
+ export { hexToHSL, hexToHSV, hsvToHex, hexToRgba, isValidHex };
20
+
21
+ // ─── RGB helpers ───
22
+
23
+ /** Parse a hex string (#RRGGBB) into RGB components. */
24
+ export function hexToRGB(hex: string): RGB {
25
+ const clean = hex.replace("#", "");
26
+ return {
27
+ r: parseInt(clean.slice(0, 2), 16) || 0,
28
+ g: parseInt(clean.slice(2, 4), 16) || 0,
29
+ b: parseInt(clean.slice(4, 6), 16) || 0,
30
+ };
31
+ }
32
+
33
+ /** Convert RGB components to hex string (#rrggbb). */
34
+ export function rgbToHex(r: number, g: number, b: number): string {
35
+ const clamp = (n: number) => Math.max(0, Math.min(255, Math.round(n)));
36
+ const toH = (n: number) => clamp(n).toString(16).padStart(2, "0");
37
+ return `#${toH(r)}${toH(g)}${toH(b)}`;
38
+ }
39
+
40
+ // ─── HSL helpers ───
41
+
42
+ /** Convert HSL to hex. */
43
+ export function hslToHex(h: number, s: number, l: number): string {
44
+ s /= 100;
45
+ l /= 100;
46
+ const c = (1 - Math.abs(2 * l - 1)) * s;
47
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
48
+ const m = l - c / 2;
49
+ let r = 0,
50
+ g = 0,
51
+ b = 0;
52
+ if (h < 60) {
53
+ r = c;
54
+ g = x;
55
+ } else if (h < 120) {
56
+ r = x;
57
+ g = c;
58
+ } else if (h < 180) {
59
+ g = c;
60
+ b = x;
61
+ } else if (h < 240) {
62
+ g = x;
63
+ b = c;
64
+ } else if (h < 300) {
65
+ r = x;
66
+ b = c;
67
+ } else {
68
+ r = c;
69
+ b = x;
70
+ }
71
+ return rgbToHex(
72
+ Math.round((r + m) * 255),
73
+ Math.round((g + m) * 255),
74
+ Math.round((b + m) * 255)
75
+ );
76
+ }
77
+
78
+ // ─── Format display ───
79
+
80
+ /**
81
+ * Format a hex color for display in the given format.
82
+ * Returns the string to show in the color input.
83
+ */
84
+ export function formatColorValue(hex: string, format: ColorFormat): string {
85
+ if (!isValidHex(hex)) return hex;
86
+ switch (format) {
87
+ case "hex":
88
+ return hex.toUpperCase();
89
+ case "rgb": {
90
+ const { r, g, b } = hexToRGB(hex);
91
+ return `${r}, ${g}, ${b}`;
92
+ }
93
+ case "hsl": {
94
+ const { h, s, l } = hexToHSL(hex);
95
+ return `${h}, ${s}%, ${l}%`;
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Parse a color input string in the given format back to hex.
102
+ * Returns null if the input is not valid for the format.
103
+ */
104
+ export function parseColorInput(
105
+ input: string,
106
+ format: ColorFormat
107
+ ): string | null {
108
+ const trimmed = input.trim();
109
+ switch (format) {
110
+ case "hex": {
111
+ let v = trimmed;
112
+ if (!v.startsWith("#")) v = "#" + v;
113
+ return isValidHex(v) ? v.toLowerCase() : null;
114
+ }
115
+ case "rgb": {
116
+ const parts = trimmed.split(/[,\s]+/).map((s) => parseInt(s, 10));
117
+ if (parts.length !== 3 || parts.some((n) => isNaN(n) || n < 0 || n > 255))
118
+ return null;
119
+ return rgbToHex(parts[0], parts[1], parts[2]);
120
+ }
121
+ case "hsl": {
122
+ const cleaned = trimmed.replace(/%/g, "");
123
+ const parts = cleaned.split(/[,\s]+/).map((s) => parseFloat(s));
124
+ if (parts.length !== 3) return null;
125
+ const [h, s, l] = parts;
126
+ if (isNaN(h) || isNaN(s) || isNaN(l)) return null;
127
+ if (h < 0 || h > 360 || s < 0 || s > 100 || l < 0 || l > 100) return null;
128
+ return hslToHex(h, s, l);
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Clamp a number to [min, max].
135
+ */
136
+ export function clamp(value: number, min: number, max: number): number {
137
+ return Math.max(min, Math.min(max, value));
138
+ }
@@ -15,6 +15,8 @@ import {
15
15
  SELECT_CLASS,
16
16
  } from "./shared";
17
17
  import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
18
+ import { serializeColorField, parseColorField, isGradient } from "../../../lib/color-utils";
19
+ import type { ColorField } from "../../../lib/sanity/types";
18
20
 
19
21
  interface Props {
20
22
  block: CoverBlock;
@@ -354,41 +356,93 @@ export default function CoverBlockEditor({ block }: Props) {
354
356
 
355
357
  {/* ========== OVERLAY ========== */}
356
358
  <SettingsSection title="Cover Effects">
357
- <SettingsField label="Overlay">
358
- <select
359
- value={block.overlay || "dark"}
360
- onChange={(e) =>
361
- update({
362
- overlay: e.target.value as CoverBlock["overlay"],
363
- })
364
- }
365
- className={SELECT_CLASS}
366
- >
367
- <option value="none">None</option>
368
- <option value="dark">Dark</option>
369
- <option value="light">Light</option>
370
- <option value="gradient-bottom">Gradient (Bottom)</option>
371
- <option value="gradient-top">Gradient (Top)</option>
372
- </select>
359
+ {/* Toggle: Custom gradient vs Preset overlay */}
360
+ <SettingsField label="Overlay Mode">
361
+ <div className="flex gap-1">
362
+ {(["preset", "custom"] as const).map((mode) => {
363
+ const isActive = mode === "custom" ? !!block.overlay_gradient : !block.overlay_gradient;
364
+ return (
365
+ <button
366
+ key={mode}
367
+ onClick={() => {
368
+ if (mode === "custom" && !block.overlay_gradient) {
369
+ // Switch to custom: initialize with a dark semi-transparent solid
370
+ update({ overlay_gradient: "#00000080" });
371
+ } else if (mode === "preset" && block.overlay_gradient) {
372
+ // Switch to preset: clear overlay_gradient
373
+ update({ overlay_gradient: undefined });
374
+ }
375
+ }}
376
+ className={`flex-1 rounded border py-1 text-[10px] capitalize transition-colors ${
377
+ isActive
378
+ ? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
379
+ : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
380
+ }`}
381
+ >
382
+ {mode === "preset" ? "Preset" : "Custom"}
383
+ </button>
384
+ );
385
+ })}
386
+ </div>
373
387
  </SettingsField>
374
388
 
375
- {block.overlay && block.overlay !== "none" && (
376
- <SettingsField label="Opacity">
377
- <div className="flex items-center gap-2">
378
- <input
379
- type="range"
380
- min={0}
381
- max={100}
382
- value={block.overlay_opacity ?? 50}
389
+ {/* Preset overlay controls */}
390
+ {!block.overlay_gradient && (
391
+ <>
392
+ <SettingsField label="Overlay">
393
+ <select
394
+ value={block.overlay || "dark"}
383
395
  onChange={(e) =>
384
- update({ overlay_opacity: parseInt(e.target.value, 10) })
396
+ update({
397
+ overlay: e.target.value as CoverBlock["overlay"],
398
+ })
385
399
  }
386
- className="flex-1 accent-[#076bff]"
387
- />
388
- <span className="text-xs text-neutral-500 w-8 text-right">
389
- {block.overlay_opacity ?? 50}%
390
- </span>
391
- </div>
400
+ className={SELECT_CLASS}
401
+ >
402
+ <option value="none">None</option>
403
+ <option value="dark">Dark</option>
404
+ <option value="light">Light</option>
405
+ <option value="gradient-bottom">Gradient (Bottom)</option>
406
+ <option value="gradient-top">Gradient (Top)</option>
407
+ </select>
408
+ </SettingsField>
409
+
410
+ {block.overlay && block.overlay !== "none" && (
411
+ <SettingsField label="Opacity">
412
+ <div className="flex items-center gap-2">
413
+ <input
414
+ type="range"
415
+ min={0}
416
+ max={100}
417
+ value={block.overlay_opacity ?? 50}
418
+ onChange={(e) =>
419
+ update({ overlay_opacity: parseInt(e.target.value, 10) })
420
+ }
421
+ className="flex-1 accent-[#076bff]"
422
+ />
423
+ <span className="text-xs text-neutral-500 w-8 text-right">
424
+ {block.overlay_opacity ?? 50}%
425
+ </span>
426
+ </div>
427
+ </SettingsField>
428
+ )}
429
+ </>
430
+ )}
431
+
432
+ {/* Custom overlay gradient (Phase 4) */}
433
+ {block.overlay_gradient && (
434
+ <SettingsField label="Overlay Color">
435
+ <ColorSwatchPicker
436
+ value={(() => {
437
+ const parsed = parseColorField(block.overlay_gradient);
438
+ return typeof parsed === "string" ? parsed : parsed;
439
+ })()}
440
+ onChange={(val: ColorField) => {
441
+ update({ overlay_gradient: serializeColorField(val) });
442
+ }}
443
+ swatches={paletteSwatches}
444
+ allowGradients
445
+ />
392
446
  </SettingsField>
393
447
  )}
394
448
  </SettingsSection>
@@ -472,7 +526,7 @@ export default function CoverBlockEditor({ block }: Props) {
472
526
  <SettingsField label="Text Color">
473
527
  <ColorSwatchPicker
474
528
  value={block.text_color || ""}
475
- onChange={(hex) => update({ text_color: hex || "#ffffff" })}
529
+ onChange={(hex) => update({ text_color: (typeof hex === "string" ? hex : "#ffffff") || "#ffffff" })}
476
530
  swatches={paletteSwatches}
477
531
  />
478
532
  </SettingsField>
@@ -232,6 +232,53 @@ function CardRatioChips({
232
232
  );
233
233
  }
234
234
 
235
+ // ============================================
236
+ // Section title icons (small, inline — matching BlockLayoutTab)
237
+ // ============================================
238
+
239
+ function GridSectionIcon() {
240
+ return (
241
+ <svg width={14} height={14} viewBox="0 0 14 14" fill="none">
242
+ <rect x="1.5" y="1.5" width="4.5" height="4.5" rx="1" stroke="currentColor" strokeWidth="0.8" fill="none" opacity="0.6" />
243
+ <rect x="8" y="1.5" width="4.5" height="4.5" rx="1" stroke="currentColor" strokeWidth="0.8" fill="none" opacity="0.6" />
244
+ <rect x="1.5" y="8" width="4.5" height="4.5" rx="1" stroke="currentColor" strokeWidth="0.8" fill="none" opacity="0.6" />
245
+ <rect x="8" y="8" width="4.5" height="4.5" rx="1" stroke="currentColor" strokeWidth="0.8" fill="none" opacity="0.6" />
246
+ </svg>
247
+ );
248
+ }
249
+
250
+ function AppearanceSectionIcon() {
251
+ return (
252
+ <svg width={14} height={14} viewBox="0 0 14 14" fill="none">
253
+ <circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="0.8" fill="none" opacity="0.5" />
254
+ <path d="M7 2 A5 5 0 0 1 7 12 Z" fill="currentColor" opacity="0.3" />
255
+ <circle cx="7" cy="7" r="1.5" fill="currentColor" opacity="0.6" />
256
+ </svg>
257
+ );
258
+ }
259
+
260
+ function VideoSectionIcon() {
261
+ return (
262
+ <svg width={14} height={14} viewBox="0 0 14 14" fill="none">
263
+ <rect x="1.5" y="3" width="8" height="8" rx="1.5" stroke="currentColor" strokeWidth="0.8" fill="none" opacity="0.5" />
264
+ <path d="M10 6 L12.5 4.5 L12.5 9.5 L10 8 Z" fill="currentColor" opacity="0.5" />
265
+ </svg>
266
+ );
267
+ }
268
+
269
+ function ProjectsSectionIcon() {
270
+ return (
271
+ <svg width={14} height={14} viewBox="0 0 14 14" fill="none">
272
+ <rect x="1.5" y="2" width="11" height="3" rx="1" stroke="currentColor" strokeWidth="0.8" fill="none" opacity="0.5" />
273
+ <rect x="1.5" y="7" width="11" height="3" rx="1" stroke="currentColor" strokeWidth="0.8" fill="none" opacity="0.5" />
274
+ <circle cx="4" cy="3.5" r="0.8" fill="currentColor" opacity="0.6" />
275
+ <circle cx="4" cy="8.5" r="0.8" fill="currentColor" opacity="0.6" />
276
+ <rect x="6" y="3" width="4" height="1" rx="0.5" fill="currentColor" opacity="0.4" />
277
+ <rect x="6" y="8" width="4" height="1" rx="0.5" fill="currentColor" opacity="0.4" />
278
+ </svg>
279
+ );
280
+ }
281
+
235
282
  // ============================================
236
283
  // Main Editor
237
284
  // ============================================
@@ -409,7 +456,7 @@ export default function ProjectGridEditor({ block }: ProjectGridEditorProps) {
409
456
  {isResponsive && <ViewportBadge />}
410
457
 
411
458
  {/* ─── Grid Settings ─── */}
412
- <SettingsSection title="Grid">
459
+ <SettingsSection title="Grid" icon={<GridSectionIcon />}>
413
460
  <SettingsField label="Columns">
414
461
  <RangeSlider
415
462
  value={effectiveColumns}
@@ -450,7 +497,7 @@ export default function ProjectGridEditor({ block }: ProjectGridEditorProps) {
450
497
  </SettingsSection>
451
498
 
452
499
  {/* ─── Appearance ─── */}
453
- <SettingsSection title="Appearance">
500
+ <SettingsSection title="Appearance" icon={<AppearanceSectionIcon />}>
454
501
  <SettingsField label="Hover">
455
502
  <SegmentedControl
456
503
  options={HOVER_EFFECT_OPTIONS}
@@ -477,7 +524,7 @@ export default function ProjectGridEditor({ block }: ProjectGridEditorProps) {
477
524
  </SettingsSection>
478
525
 
479
526
  {/* ─── Video ─── */}
480
- <SettingsSection title="Video">
527
+ <SettingsSection title="Video" icon={<VideoSectionIcon />}>
481
528
  <SettingsField label="Mode">
482
529
  <SegmentedControl
483
530
  options={VIDEO_MODE_OPTIONS}
@@ -488,7 +535,7 @@ export default function ProjectGridEditor({ block }: ProjectGridEditorProps) {
488
535
  </SettingsSection>
489
536
 
490
537
  {/* ─── Projects ─── */}
491
- <SettingsSection title={`Projects (${(block.projects || []).length})`}>
538
+ <SettingsSection title={`Projects (${(block.projects || []).length})`} icon={<ProjectsSectionIcon />}>
492
539
  {(block.projects || []).length === 0 ? (
493
540
  <p className="text-xs text-neutral-400 py-2">
494
541
  No projects selected. Add projects below.
@@ -177,7 +177,11 @@ export function useColumnDrag(): UseColumnDragReturn {
177
177
  const [insertBetween, setInsertBetween] = useState<InsertBetween | null>(null);
178
178
  const [overlayPosition, setOverlayPosition] = useState<{ x: number; y: number } | null>(null);
179
179
 
180
- // --- Mutable ref for drag state + store actions (stale closure prevention) ---
180
+ // --- Mutable ref for drag state ---
181
+ // RC-002 fix: Store actions are read directly from useBuilderStore.getState()
182
+ // at execution time instead of being synced via useEffect. This eliminates
183
+ // the stale closure window between render and effect, where rapid
184
+ // click+drag could execute stale action references.
181
185
  const dragRef = useRef({
182
186
  sectionKey: "",
183
187
  columnKey: "",
@@ -186,26 +190,18 @@ export function useColumnDrag(): UseColumnDragReturn {
186
190
  startX: 0,
187
191
  startY: 0,
188
192
  draggedEl: null as HTMLElement | null, // pointer-events guard
189
- // Store actions — updated every render via useEffect
190
- swapColumnV2: null as ((s: string, d: string, t: string) => void) | null,
191
- moveColumnToGapV2: null as ((s: string, c: string, r: number, col: number, sp: number) => void) | null,
192
- moveColumnV2: null as ((s: string, c: string, r: number, col: number) => void) | null,
193
- updateSectionV2Responsive: null as ((s: string, r: PageSectionV2["responsive"]) => void) | null,
194
193
  });
195
194
 
196
- // Keep store actions fresh in the ref
197
- const swapColumnV2 = useBuilderStore((s) => s.swapColumnV2);
198
- const moveColumnToGapV2 = useBuilderStore((s) => s.moveColumnToGapV2);
199
- const moveColumnV2 = useBuilderStore((s) => s.moveColumnV2);
200
- const updateSectionV2Responsive = useBuilderStore((s) => s.updateSectionV2Responsive);
201
-
202
- // Update ref every render — no deps array
203
- useEffect(() => {
204
- dragRef.current.swapColumnV2 = swapColumnV2;
205
- dragRef.current.moveColumnToGapV2 = moveColumnToGapV2;
206
- dragRef.current.moveColumnV2 = moveColumnV2;
207
- dragRef.current.updateSectionV2Responsive = updateSectionV2Responsive;
208
- });
195
+ /** Read a fresh store action at call time — never stale */
196
+ const getActions = () => {
197
+ const s = useBuilderStore.getState();
198
+ return {
199
+ swapColumnV2: s.swapColumnV2,
200
+ moveColumnToGapV2: s.moveColumnToGapV2,
201
+ moveColumnV2: s.moveColumnV2,
202
+ updateSectionV2Responsive: s.updateSectionV2Responsive,
203
+ };
204
+ };
209
205
 
210
206
  // --- Stable mousemove handler (empty deps — delegates to dragRef) ---
211
207
  const stableMouseMove = useCallback((e: MouseEvent) => {
@@ -373,23 +369,25 @@ export function useColumnDrag(): UseColumnDragReturn {
373
369
  }
374
370
  }
375
371
 
376
- // Execute the drop action
372
+ // Execute the drop action — read actions fresh from store (RC-002)
377
373
  if (finalTarget) {
378
- const activeViewport = useBuilderStore.getState().activeViewport;
374
+ const storeState = useBuilderStore.getState();
375
+ const activeViewport = storeState.activeViewport;
379
376
  const isResponsive = activeViewport !== "desktop";
377
+ const actions = getActions();
380
378
 
381
379
  if (finalTarget.type === "swap" && finalTarget.columnKey) {
382
380
  if (!isResponsive) {
383
- dragRef.current.swapColumnV2?.(sectionKey, columnKey, finalTarget.columnKey);
381
+ actions.swapColumnV2(sectionKey, columnKey, finalTarget.columnKey);
384
382
  } else {
385
383
  executeResponsiveSwap(
386
384
  sectionKey, columnKey, finalTarget.columnKey, activeViewport,
387
- dragRef.current.updateSectionV2Responsive!
385
+ actions.updateSectionV2Responsive
388
386
  );
389
387
  }
390
388
  } else if (finalTarget.type === "gap") {
391
389
  if (!isResponsive) {
392
- dragRef.current.moveColumnToGapV2?.(
390
+ actions.moveColumnToGapV2(
393
391
  sectionKey, columnKey,
394
392
  finalTarget.gapRow!, finalTarget.gapCol!, finalTarget.gapSpan!
395
393
  );
@@ -398,12 +396,12 @@ export function useColumnDrag(): UseColumnDragReturn {
398
396
  sectionKey, columnKey,
399
397
  finalTarget.gapRow!, finalTarget.gapCol!, finalTarget.gapSpan!,
400
398
  activeViewport,
401
- dragRef.current.updateSectionV2Responsive!
399
+ actions.updateSectionV2Responsive
402
400
  );
403
401
  }
404
402
  } else if (finalTarget.type === "insert") {
405
403
  if (!isResponsive) {
406
- dragRef.current.moveColumnV2?.(
404
+ actions.moveColumnV2(
407
405
  sectionKey, columnKey,
408
406
  finalTarget.insertRow!, finalTarget.insertCol!
409
407
  );
@@ -412,7 +410,7 @@ export function useColumnDrag(): UseColumnDragReturn {
412
410
  sectionKey, columnKey,
413
411
  finalTarget.insertRow!, finalTarget.insertCol!,
414
412
  activeViewport,
415
- dragRef.current.updateSectionV2Responsive!
413
+ actions.updateSectionV2Responsive
416
414
  );
417
415
  }
418
416
  }