@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,713 @@
1
+ "use client";
2
+
3
+ /**
4
+ * UnifiedColorPicker — Modal-based color picker with gradient support.
5
+ *
6
+ * Phase 1: Solid color picker with modal UI.
7
+ * Phase 3: Added gradient tabs [Solid | Linear | Radial | Mesh].
8
+ *
9
+ * Renders via createPortal to document.body.
10
+ * Tabs only visible when `allowGradients={true}`.
11
+ * Modal widens to ~580px when Mesh tab is active.
12
+ */
13
+
14
+ import { useState, useCallback, useEffect, useRef, useMemo } from "react";
15
+ import { createPortal } from "react-dom";
16
+ import SaturationCanvas from "./SaturationCanvas";
17
+ import HueSlider from "./HueSlider";
18
+ import AlphaSlider from "./AlphaSlider";
19
+ import ColorInputs from "./ColorInputs";
20
+ import EyedropperButton from "./EyedropperButton";
21
+ import SwatchBar from "./SwatchBar";
22
+ import GradientBar from "./GradientBar";
23
+ import GradientPreview from "./GradientPreview";
24
+ import AngleControl from "./AngleControl";
25
+ import PositionControl from "./PositionControl";
26
+ import StopEditor from "./StopEditor";
27
+ import MeshCanvas from "./MeshCanvas";
28
+ import MeshPointList from "./MeshPointList";
29
+ import MeshPointEditor from "./MeshPointEditor";
30
+ import type { UnifiedColorPickerProps } from "./types";
31
+ import type {
32
+ ColorField,
33
+ GradientStop,
34
+ LinearGradient,
35
+ RadialGradient,
36
+ MeshGradient,
37
+ MeshPoint,
38
+ } from "./types";
39
+ import { hexToHSV, hsvToHex, isValidHex, hexToRgba } from "./utils";
40
+ import {
41
+ isGradient,
42
+ resolveColorHex,
43
+ colorToCSS,
44
+ } from "../../../lib/color-utils";
45
+ import { getPresetsForCategory } from "../../../lib/builder/gradient-presets";
46
+ import type { GradientPresetInfo } from "../../../lib/builder/gradient-presets";
47
+ import type { ColorSwatch } from "../../../lib/sanity/types";
48
+
49
+ type GradientTab = "solid" | "linear" | "radial" | "mesh";
50
+
51
+ // ─── Default gradient values ───
52
+
53
+ function defaultLinear(): LinearGradient {
54
+ return {
55
+ type: "linear",
56
+ stops: [
57
+ { color: "#000000", alpha: 1, position: 0 },
58
+ { color: "#ffffff", alpha: 1, position: 100 },
59
+ ],
60
+ angle: 135,
61
+ };
62
+ }
63
+
64
+ function defaultRadial(): RadialGradient {
65
+ return {
66
+ type: "radial",
67
+ stops: [
68
+ { color: "#000000", alpha: 1, position: 0 },
69
+ { color: "#ffffff", alpha: 1, position: 100 },
70
+ ],
71
+ position: { x: 50, y: 50 },
72
+ shape: "ellipse",
73
+ };
74
+ }
75
+
76
+ function defaultMesh(): MeshGradient {
77
+ return {
78
+ type: "mesh",
79
+ points: [
80
+ { color: "#f1ddd5", x: 20, y: 30 },
81
+ { color: "#6f737b", x: 80, y: 40 },
82
+ { color: "#cfcbc1", x: 50, y: 80 },
83
+ ],
84
+ background: "#181818",
85
+ };
86
+ }
87
+
88
+ /** Generate the linear-gradient CSS for the GradientBar display */
89
+ function stopsToBarCSS(stops: GradientStop[]): string {
90
+ const sorted = [...stops].sort((a, b) => a.position - b.position);
91
+ const parts = sorted
92
+ .map((s) => `${hexToRgba(s.color, s.alpha)} ${s.position}%`)
93
+ .join(", ");
94
+ return `linear-gradient(to right, ${parts})`;
95
+ }
96
+
97
+ export default function UnifiedColorPicker({
98
+ value,
99
+ onChange,
100
+ onClose,
101
+ swatches = [],
102
+ confirmLabel = "Apply Color",
103
+ alpha: initialAlpha = 1,
104
+ onAlphaChange,
105
+ allowGradients = false,
106
+ onPreview,
107
+ }: UnifiedColorPickerProps) {
108
+ // ─── Determine initial tab and state from value ───
109
+ const initTab: GradientTab =
110
+ typeof value === "string"
111
+ ? "solid"
112
+ : value.type === "linear"
113
+ ? "linear"
114
+ : value.type === "radial"
115
+ ? "radial"
116
+ : "mesh";
117
+
118
+ const initHex =
119
+ typeof value === "string"
120
+ ? isValidHex(value)
121
+ ? value
122
+ : "#ffffff"
123
+ : resolveColorHex(value);
124
+
125
+ const initHsv = hexToHSV(initHex);
126
+
127
+ // ─── Solid state ───
128
+ const [hue, setHue] = useState(initHsv.h);
129
+ const [sat, setSat] = useState(initHsv.s);
130
+ const [val, setVal] = useState(initHsv.v);
131
+ const [hex, setHex] = useState(initHex);
132
+ const [alpha, setAlpha] = useState(initialAlpha);
133
+
134
+ // ─── Tab state ───
135
+ const [activeTab, setActiveTab] = useState<GradientTab>(
136
+ allowGradients ? initTab : "solid"
137
+ );
138
+
139
+ // ─── Gradient states ───
140
+ const [linearState, setLinearState] = useState<LinearGradient>(() =>
141
+ typeof value !== "string" && value.type === "linear"
142
+ ? value
143
+ : defaultLinear()
144
+ );
145
+ const [radialState, setRadialState] = useState<RadialGradient>(() =>
146
+ typeof value !== "string" && value.type === "radial"
147
+ ? value
148
+ : defaultRadial()
149
+ );
150
+ const [meshState, setMeshState] = useState<MeshGradient>(() =>
151
+ typeof value !== "string" && value.type === "mesh"
152
+ ? value
153
+ : defaultMesh()
154
+ );
155
+
156
+ const [selectedStopIndex, setSelectedStopIndex] = useState(0);
157
+ const [selectedMeshIndex, setSelectedMeshIndex] = useState(0);
158
+
159
+ const modalRef = useRef<HTMLDivElement>(null);
160
+
161
+ // ─── rAF-throttled preview dispatch (Phase 4) ───
162
+ const rafRef = useRef<number | null>(null);
163
+ const emitPreview = useCallback(
164
+ (previewValue: ColorField) => {
165
+ if (!onPreview) return;
166
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
167
+ rafRef.current = requestAnimationFrame(() => {
168
+ rafRef.current = null;
169
+ onPreview(previewValue);
170
+ });
171
+ },
172
+ [onPreview]
173
+ );
174
+
175
+ // Cleanup rAF on unmount
176
+ useEffect(() => {
177
+ return () => {
178
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
179
+ };
180
+ }, []);
181
+
182
+ // ─── Gradient presets per tab (Phase 4) ───
183
+ const linearPresets = useMemo(() => getPresetsForCategory("linear"), []);
184
+ const meshPresets = useMemo(() => getPresetsForCategory("mesh"), []);
185
+
186
+ // ─── Solid color handlers ───
187
+ // PERF-002 fix: Use refs for hue/sat/val so callbacks are stable and
188
+ // don't create new event listeners in child components on every change.
189
+ const hueRef = useRef(hue);
190
+ const satRef = useRef(sat);
191
+ const valRef = useRef(val);
192
+ hueRef.current = hue;
193
+ satRef.current = sat;
194
+ valRef.current = val;
195
+
196
+ const updateFromHSV = useCallback(
197
+ (h: number, s: number, v: number) => {
198
+ setHex(hsvToHex(h, s, v));
199
+ },
200
+ []
201
+ );
202
+
203
+ const handleSatValChange = useCallback(
204
+ (s: number, v: number) => {
205
+ setSat(s);
206
+ setVal(v);
207
+ const newHex = hsvToHex(hueRef.current, s, v);
208
+ setHex(newHex);
209
+ emitPreview(newHex);
210
+ },
211
+ [emitPreview]
212
+ );
213
+
214
+ const handleHueChange = useCallback(
215
+ (h: number) => {
216
+ setHue(h);
217
+ const newHex = hsvToHex(h, satRef.current, valRef.current);
218
+ setHex(newHex);
219
+ emitPreview(newHex);
220
+ },
221
+ [emitPreview]
222
+ );
223
+
224
+ const handleAlphaChange = useCallback(
225
+ (a: number) => {
226
+ setAlpha(a);
227
+ onAlphaChange?.(a);
228
+ },
229
+ [onAlphaChange]
230
+ );
231
+
232
+ const handleHexChange = useCallback((newHex: string) => {
233
+ if (isValidHex(newHex)) {
234
+ setHex(newHex);
235
+ const hsv = hexToHSV(newHex);
236
+ setHue(hsv.h);
237
+ setSat(hsv.s);
238
+ setVal(hsv.v);
239
+ }
240
+ }, []);
241
+
242
+ const handleEyedropper = useCallback((picked: string) => {
243
+ if (isValidHex(picked)) {
244
+ setHex(picked);
245
+ const hsv = hexToHSV(picked);
246
+ setHue(hsv.h);
247
+ setSat(hsv.s);
248
+ setVal(hsv.v);
249
+ }
250
+ }, []);
251
+
252
+ const handleSwatchSelect = useCallback((swatchHex: string) => {
253
+ if (isValidHex(swatchHex)) {
254
+ setHex(swatchHex);
255
+ const hsv = hexToHSV(swatchHex);
256
+ setHue(hsv.h);
257
+ setSat(hsv.s);
258
+ setVal(hsv.v);
259
+ }
260
+ }, []);
261
+
262
+ // ─── Linear gradient handlers ───
263
+
264
+ const handleLinearStopsChange = useCallback((stops: GradientStop[]) => {
265
+ setLinearState((prev) => {
266
+ const next = { ...prev, stops };
267
+ emitPreview(next);
268
+ return next;
269
+ });
270
+ }, [emitPreview]);
271
+
272
+ const handleLinearAngleChange = useCallback((angle: number) => {
273
+ setLinearState((prev) => {
274
+ const next = { ...prev, angle };
275
+ emitPreview(next);
276
+ return next;
277
+ });
278
+ }, [emitPreview]);
279
+
280
+ const handleLinearStopEdit = useCallback(
281
+ (stop: GradientStop) => {
282
+ setLinearState((prev) => {
283
+ const next = {
284
+ ...prev,
285
+ stops: prev.stops.map((s, i) => (i === selectedStopIndex ? stop : s)),
286
+ };
287
+ emitPreview(next);
288
+ return next;
289
+ });
290
+ },
291
+ [selectedStopIndex, emitPreview]
292
+ );
293
+
294
+ // ─── Radial gradient handlers ───
295
+
296
+ const handleRadialStopsChange = useCallback((stops: GradientStop[]) => {
297
+ setRadialState((prev) => {
298
+ const next = { ...prev, stops };
299
+ emitPreview(next);
300
+ return next;
301
+ });
302
+ }, [emitPreview]);
303
+
304
+ const handleRadialPositionChange = useCallback((x: number, y: number) => {
305
+ setRadialState((prev) => {
306
+ const next = { ...prev, position: { x, y } };
307
+ emitPreview(next);
308
+ return next;
309
+ });
310
+ }, [emitPreview]);
311
+
312
+ const handleRadialShapeChange = useCallback(
313
+ (shape: "circle" | "ellipse") => {
314
+ setRadialState((prev) => {
315
+ const next = { ...prev, shape };
316
+ emitPreview(next);
317
+ return next;
318
+ });
319
+ },
320
+ [emitPreview]
321
+ );
322
+
323
+ const handleRadialStopEdit = useCallback(
324
+ (stop: GradientStop) => {
325
+ setRadialState((prev) => {
326
+ const next = {
327
+ ...prev,
328
+ stops: prev.stops.map((s, i) => (i === selectedStopIndex ? stop : s)),
329
+ };
330
+ emitPreview(next);
331
+ return next;
332
+ });
333
+ },
334
+ [selectedStopIndex, emitPreview]
335
+ );
336
+
337
+ // ─── Mesh gradient handlers ───
338
+
339
+ const handleMeshPointsChange = useCallback((points: MeshPoint[]) => {
340
+ setMeshState((prev) => {
341
+ const next = { ...prev, points };
342
+ emitPreview(next);
343
+ return next;
344
+ });
345
+ }, [emitPreview]);
346
+
347
+ const handleMeshBackgroundChange = useCallback((bg: string) => {
348
+ setMeshState((prev) => {
349
+ const next = { ...prev, background: bg };
350
+ emitPreview(next);
351
+ return next;
352
+ });
353
+ }, [emitPreview]);
354
+
355
+ /** Update a single mesh point (from MeshPointEditor color change) */
356
+ const handleMeshPointEdit = useCallback(
357
+ (updatedPoint: MeshPoint) => {
358
+ setMeshState((prev) => {
359
+ const next = {
360
+ ...prev,
361
+ points: prev.points.map((p, i) => (i === selectedMeshIndex ? updatedPoint : p)),
362
+ };
363
+ emitPreview(next);
364
+ return next;
365
+ });
366
+ },
367
+ [selectedMeshIndex, emitPreview]
368
+ );
369
+
370
+ // ─── Preset selection (Phase 4) ───
371
+
372
+ const handlePresetSelect = useCallback(
373
+ (preset: GradientPresetInfo) => {
374
+ const tpl = structuredClone(preset.template);
375
+ switch (tpl.type) {
376
+ case "linear":
377
+ setLinearState(tpl);
378
+ setActiveTab("linear");
379
+ break;
380
+ case "radial":
381
+ setRadialState(tpl as RadialGradient);
382
+ setActiveTab("radial");
383
+ break;
384
+ case "mesh":
385
+ setMeshState(tpl as MeshGradient);
386
+ setActiveTab("mesh");
387
+ break;
388
+ }
389
+ setSelectedStopIndex(0);
390
+ setSelectedMeshIndex(0);
391
+ emitPreview(tpl);
392
+ },
393
+ [emitPreview]
394
+ );
395
+
396
+ // ─── Confirm ───
397
+
398
+ const handleConfirm = useCallback(() => {
399
+ let result: ColorField;
400
+ switch (activeTab) {
401
+ case "solid":
402
+ result = hex;
403
+ break;
404
+ case "linear":
405
+ result = linearState;
406
+ break;
407
+ case "radial":
408
+ result = radialState;
409
+ break;
410
+ case "mesh":
411
+ result = meshState;
412
+ break;
413
+ }
414
+ onChange(result);
415
+ onClose();
416
+ }, [activeTab, hex, linearState, radialState, meshState, onChange, onClose]);
417
+
418
+ // ─── Close on Escape ───
419
+ useEffect(() => {
420
+ const handleKeyDown = (e: KeyboardEvent) => {
421
+ if (e.key === "Escape") onClose();
422
+ };
423
+ window.addEventListener("keydown", handleKeyDown);
424
+ return () => window.removeEventListener("keydown", handleKeyDown);
425
+ }, [onClose]);
426
+
427
+ // Close on backdrop click
428
+ const handleBackdropClick = useCallback(
429
+ (e: React.MouseEvent) => {
430
+ if (e.target === e.currentTarget) onClose();
431
+ },
432
+ [onClose]
433
+ );
434
+
435
+ // Trap focus
436
+ useEffect(() => {
437
+ const prev = document.activeElement as HTMLElement | null;
438
+ modalRef.current?.focus();
439
+ return () => {
440
+ prev?.focus?.();
441
+ };
442
+ }, []);
443
+
444
+ if (typeof document === "undefined") return null;
445
+
446
+ const isMesh = activeTab === "mesh";
447
+ const showTabs = allowGradients;
448
+
449
+ return createPortal(
450
+ <div
451
+ className="fixed inset-0 z-[10000] flex items-center justify-center"
452
+ style={{
453
+ background: "rgba(0, 0, 0, 0.4)",
454
+ backdropFilter: "blur(4px)",
455
+ }}
456
+ onClick={handleBackdropClick}
457
+ >
458
+ <div
459
+ ref={modalRef}
460
+ tabIndex={-1}
461
+ role="dialog"
462
+ aria-label="Color picker"
463
+ className={`bg-white rounded-2xl p-6 shadow-2xl border border-neutral-200 outline-none transition-[width] duration-200 ${
464
+ isMesh ? "w-[580px]" : "w-[420px]"
465
+ }`}
466
+ style={{
467
+ boxShadow:
468
+ "0 25px 80px rgba(0,0,0,0.15), 0 8px 24px rgba(0,0,0,0.1)",
469
+ }}
470
+ >
471
+ {/* ─── Tabs ─── */}
472
+ {showTabs && (
473
+ <div className="flex gap-1 mb-5 bg-neutral-100 rounded-xl p-1">
474
+ {(["solid", "linear", "radial", "mesh"] as GradientTab[]).map(
475
+ (tab) => (
476
+ <button
477
+ key={tab}
478
+ type="button"
479
+ onClick={() => setActiveTab(tab)}
480
+ className={`flex-1 py-2 px-3 text-[13px] font-medium rounded-lg cursor-pointer transition-all capitalize ${
481
+ activeTab === tab
482
+ ? "bg-white text-neutral-900 shadow-sm"
483
+ : "bg-transparent text-neutral-400 hover:text-neutral-600"
484
+ }`}
485
+ >
486
+ {tab}
487
+ </button>
488
+ )
489
+ )}
490
+ </div>
491
+ )}
492
+
493
+ {/* ─── Tab: Solid ─── */}
494
+ {activeTab === "solid" && (
495
+ <>
496
+ <div className="mb-4">
497
+ <SaturationCanvas
498
+ hue={hue}
499
+ saturation={sat}
500
+ value={val}
501
+ onChange={handleSatValChange}
502
+ />
503
+ </div>
504
+
505
+ <div className="flex items-center gap-3 mb-3">
506
+ <span className="text-xs text-neutral-400 w-3.5 text-center shrink-0">
507
+ <svg width="12" height="12" viewBox="0 0 12 12">
508
+ <circle cx="6" cy="6" r="5" fill="none" stroke="currentColor" strokeWidth="1.5" />
509
+ <circle cx="6" cy="6" r="2" fill="currentColor" />
510
+ </svg>
511
+ </span>
512
+ <HueSlider hue={hue} onChange={handleHueChange} />
513
+ </div>
514
+
515
+ <div className="flex items-center gap-3 mb-4">
516
+ <span className="text-xs text-neutral-400 w-3.5 text-center shrink-0">
517
+ <svg width="12" height="12" viewBox="0 0 12 12">
518
+ <rect x="0" y="0" width="6" height="6" fill="currentColor" opacity="0.6" />
519
+ <rect x="6" y="6" width="6" height="6" fill="currentColor" opacity="0.6" />
520
+ <rect x="6" y="0" width="6" height="6" fill="currentColor" opacity="0.2" />
521
+ <rect x="0" y="6" width="6" height="6" fill="currentColor" opacity="0.2" />
522
+ </svg>
523
+ </span>
524
+ <AlphaSlider color={hex} alpha={alpha} onChange={handleAlphaChange} />
525
+ </div>
526
+
527
+ <div className="flex items-center gap-2 mb-4">
528
+ <div className="flex-1">
529
+ <ColorInputs hex={hex} onHexChange={handleHexChange} alpha={alpha} />
530
+ </div>
531
+ <EyedropperButton onColorPicked={handleEyedropper} />
532
+ </div>
533
+
534
+ <div className="mb-4">
535
+ <SwatchBar
536
+ value={hex}
537
+ onSelect={handleSwatchSelect}
538
+ swatches={swatches}
539
+ currentColor={hex}
540
+ />
541
+ </div>
542
+ </>
543
+ )}
544
+
545
+ {/* ─── Tab: Linear Gradient ─── */}
546
+ {activeTab === "linear" && (
547
+ <>
548
+ <GradientPreview value={linearState} height={160} />
549
+
550
+ {/* Presets row (Phase 4) */}
551
+ {linearPresets.length > 0 && (
552
+ <div className="flex gap-1.5 mb-3">
553
+ {linearPresets.map((preset) => (
554
+ <button
555
+ key={preset.id}
556
+ type="button"
557
+ title={preset.label}
558
+ onClick={() => handlePresetSelect(preset)}
559
+ className="w-8 h-8 rounded-lg border border-neutral-200 hover:border-neutral-400 transition-colors cursor-pointer shrink-0"
560
+ style={{ backgroundImage: colorToCSS(preset.template) }}
561
+ />
562
+ ))}
563
+ </div>
564
+ )}
565
+
566
+ <GradientBar
567
+ stops={linearState.stops}
568
+ onChange={handleLinearStopsChange}
569
+ selectedIndex={selectedStopIndex}
570
+ onSelect={setSelectedStopIndex}
571
+ gradientCSS={stopsToBarCSS(linearState.stops)}
572
+ />
573
+
574
+ <AngleControl
575
+ angle={linearState.angle}
576
+ onChange={handleLinearAngleChange}
577
+ />
578
+
579
+ {linearState.stops[selectedStopIndex] && (
580
+ <StopEditor
581
+ stop={linearState.stops[selectedStopIndex]}
582
+ onChange={handleLinearStopEdit}
583
+ />
584
+ )}
585
+ </>
586
+ )}
587
+
588
+ {/* ─── Tab: Radial Gradient ─── */}
589
+ {activeTab === "radial" && (
590
+ <>
591
+ <GradientPreview value={radialState} height={160} />
592
+
593
+ <GradientBar
594
+ stops={radialState.stops}
595
+ onChange={handleRadialStopsChange}
596
+ selectedIndex={selectedStopIndex}
597
+ onSelect={setSelectedStopIndex}
598
+ gradientCSS={stopsToBarCSS(radialState.stops)}
599
+ />
600
+
601
+ <div className="flex items-center gap-3 mb-4">
602
+ <PositionControl
603
+ x={radialState.position.x}
604
+ y={radialState.position.y}
605
+ onChange={handleRadialPositionChange}
606
+ />
607
+
608
+ {/* Shape toggle */}
609
+ <div className="flex flex-col gap-1.5">
610
+ <div className="text-[10px] text-neutral-400 uppercase tracking-wider">
611
+ Shape
612
+ </div>
613
+ <div className="flex gap-1 bg-neutral-100 rounded-lg p-0.5">
614
+ {(["ellipse", "circle"] as const).map((shape) => (
615
+ <button
616
+ key={shape}
617
+ type="button"
618
+ onClick={() => handleRadialShapeChange(shape)}
619
+ className={`px-2.5 py-1 text-[11px] rounded-md cursor-pointer transition-all capitalize ${
620
+ radialState.shape === shape
621
+ ? "bg-white text-neutral-900 shadow-sm"
622
+ : "bg-transparent text-neutral-400 hover:text-neutral-600"
623
+ }`}
624
+ >
625
+ {shape}
626
+ </button>
627
+ ))}
628
+ </div>
629
+ </div>
630
+ </div>
631
+
632
+ {radialState.stops[selectedStopIndex] && (
633
+ <StopEditor
634
+ stop={radialState.stops[selectedStopIndex]}
635
+ onChange={handleRadialStopEdit}
636
+ />
637
+ )}
638
+ </>
639
+ )}
640
+
641
+ {/* ─── Tab: Mesh Gradient ─── */}
642
+ {activeTab === "mesh" && (
643
+ <>
644
+ <div className="grid grid-cols-[1fr_160px] gap-4">
645
+ <div>
646
+ <MeshCanvas
647
+ points={meshState.points}
648
+ background={meshState.background}
649
+ onChange={handleMeshPointsChange}
650
+ selectedIndex={selectedMeshIndex}
651
+ onSelect={setSelectedMeshIndex}
652
+ />
653
+ </div>
654
+ <MeshPointList
655
+ points={meshState.points}
656
+ onChange={handleMeshPointsChange}
657
+ selectedIndex={selectedMeshIndex}
658
+ onSelect={setSelectedMeshIndex}
659
+ background={meshState.background}
660
+ onBackgroundChange={handleMeshBackgroundChange}
661
+ />
662
+ </div>
663
+
664
+ {/* Mesh point color editor */}
665
+ {meshState.points[selectedMeshIndex] && (
666
+ <div className="mt-3">
667
+ <MeshPointEditor
668
+ point={meshState.points[selectedMeshIndex]}
669
+ onChange={handleMeshPointEdit}
670
+ />
671
+ </div>
672
+ )}
673
+
674
+ {/* Mesh presets row (Phase 4) */}
675
+ {meshPresets.length > 0 && (
676
+ <div className="flex gap-1.5 mt-3">
677
+ {meshPresets.map((preset) => (
678
+ <button
679
+ key={preset.id}
680
+ type="button"
681
+ title={preset.label}
682
+ onClick={() => handlePresetSelect(preset)}
683
+ className="w-8 h-8 rounded-lg border border-neutral-200 hover:border-neutral-400 transition-colors cursor-pointer shrink-0"
684
+ style={{ backgroundImage: colorToCSS(preset.template) }}
685
+ />
686
+ ))}
687
+ </div>
688
+ )}
689
+ </>
690
+ )}
691
+
692
+ {/* ─── Action buttons ─── */}
693
+ <div className="flex gap-2.5 mt-4">
694
+ <button
695
+ type="button"
696
+ onClick={onClose}
697
+ className="flex-1 py-2.5 rounded-xl border border-neutral-200 bg-transparent text-neutral-500 text-sm cursor-pointer hover:border-neutral-300 hover:text-neutral-700 transition-colors"
698
+ >
699
+ Cancel
700
+ </button>
701
+ <button
702
+ type="button"
703
+ onClick={handleConfirm}
704
+ className="flex-1 py-2.5 rounded-xl border-none bg-neutral-900 text-white text-sm font-medium cursor-pointer hover:bg-neutral-800 transition-colors"
705
+ >
706
+ {confirmLabel}
707
+ </button>
708
+ </div>
709
+ </div>
710
+ </div>,
711
+ document.body
712
+ );
713
+ }