@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
@@ -10,43 +10,54 @@ type StoreSet = (
10
10
  ) => void;
11
11
  type StoreGet = () => BuilderStore;
12
12
 
13
+ // RC-005 fix: Shared helper for updating a block's fields across all section
14
+ // types (V1, V2, ParallaxGroup). Eliminates duplicated row/column mapping
15
+ // logic between updateBlock and updateBlockDebounced.
16
+ function applyBlockUpdate(
17
+ rows: ContentItem[],
18
+ blockKey: string,
19
+ updates: Partial<ContentBlock>
20
+ ): ContentItem[] {
21
+ const updateColumns = (cols: import("../../lib/sanity/types").SectionColumn[]) =>
22
+ cols.map((col) => ({
23
+ ...col,
24
+ blocks: col.blocks.map((b) =>
25
+ b._key === blockKey ? ({ ...b, ...updates } as ContentBlock) : b
26
+ ),
27
+ }));
28
+
29
+ return rows.map((item) => {
30
+ if (isPageSection(item)) {
31
+ const block = item.block[0];
32
+ if (block && block._key === blockKey) {
33
+ return { ...item, block: [{ ...block, ...updates } as SectionBlock] };
34
+ }
35
+ return item;
36
+ }
37
+ if (isPageSectionV2(item)) {
38
+ return { ...item, columns: updateColumns(item.columns) };
39
+ }
40
+ if (isParallaxGroup(item)) {
41
+ return {
42
+ ...item,
43
+ slides: (item as ParallaxGroup).slides.map((slide) => ({
44
+ ...slide,
45
+ columns: updateColumns(slide.columns),
46
+ })),
47
+ } as ContentItem;
48
+ }
49
+ return item;
50
+ });
51
+ }
52
+
13
53
  export function createBlockActions(set: StoreSet, get: StoreGet) {
14
54
  return {
15
55
  // ---- Block operations ----
16
56
 
17
57
  updateBlock: (blockKey: string, updates: Partial<ContentBlock>): void => {
18
58
  // Snapshot is NOT pushed here to avoid polluting undo history on every keystroke.
19
- const updateColumns = (cols: import("../../lib/sanity/types").SectionColumn[]) =>
20
- cols.map((col) => ({
21
- ...col,
22
- blocks: col.blocks.map((b) =>
23
- b._key === blockKey ? ({ ...b, ...updates } as ContentBlock) : b
24
- ),
25
- }));
26
-
27
59
  set((state) => ({
28
- rows: state.rows.map((item) => {
29
- if (isPageSection(item)) {
30
- const block = item.block[0];
31
- if (block && block._key === blockKey) {
32
- return { ...item, block: [{ ...block, ...updates } as SectionBlock] };
33
- }
34
- return item;
35
- }
36
- if (isPageSectionV2(item)) {
37
- return { ...item, columns: updateColumns(item.columns) };
38
- }
39
- if (isParallaxGroup(item)) {
40
- return {
41
- ...item,
42
- slides: (item as ParallaxGroup).slides.map((slide) => ({
43
- ...slide,
44
- columns: updateColumns(slide.columns),
45
- })),
46
- } as ContentItem;
47
- }
48
- return item;
49
- }),
60
+ rows: applyBlockUpdate(state.rows, blockKey, updates),
50
61
  isDirty: true,
51
62
  }));
52
63
  },
@@ -126,7 +137,11 @@ export function createBlockActions(set: StoreSet, get: StoreGet) {
126
137
  },
127
138
 
128
139
  moveBlock: (blockKey: string, targetSectionKey: string, targetColumnKey: string, toIndex: number): void => {
129
- const result = moveBlockInState(get().rows, blockKey, targetSectionKey, targetColumnKey, toIndex);
140
+ // RC-001 fix: Validate first without side effects, then push snapshot
141
+ // BEFORE applying mutation. This ensures undo state captures the
142
+ // pre-mutation rows, even if another mutation races in between.
143
+ const currentRows = get().rows;
144
+ const result = moveBlockInState(currentRows, blockKey, targetSectionKey, targetColumnKey, toIndex);
130
145
  if (!result) return;
131
146
 
132
147
  get()._pushSnapshot();
@@ -153,39 +168,11 @@ export function createBlockActions(set: StoreSet, get: StoreGet) {
153
168
  },
154
169
 
155
170
  // ---- Debounced block update (no undo snapshot per keystroke) ----
171
+ // RC-005 fix: Now delegates to shared applyBlockUpdate helper.
156
172
 
157
173
  updateBlockDebounced: (blockKey: string, updates: Partial<ContentBlock>): void => {
158
- const updateColumns = (cols: import("../../lib/sanity/types").SectionColumn[]) =>
159
- cols.map((col) => ({
160
- ...col,
161
- blocks: col.blocks.map((b) =>
162
- b._key === blockKey ? ({ ...b, ...updates } as ContentBlock) : b
163
- ),
164
- }));
165
-
166
174
  set((state) => ({
167
- rows: state.rows.map((item) => {
168
- if (isPageSection(item)) {
169
- const block = item.block[0];
170
- if (block && block._key === blockKey) {
171
- return { ...item, block: [{ ...block, ...updates } as SectionBlock] };
172
- }
173
- return item;
174
- }
175
- if (isPageSectionV2(item)) {
176
- return { ...item, columns: updateColumns(item.columns) };
177
- }
178
- if (isParallaxGroup(item)) {
179
- return {
180
- ...item,
181
- slides: (item as ParallaxGroup).slides.map((slide) => ({
182
- ...slide,
183
- columns: updateColumns(slide.columns),
184
- })),
185
- } as ContentItem;
186
- }
187
- return item;
188
- }),
175
+ rows: applyBlockUpdate(state.rows, blockKey, updates),
189
176
  isDirty: true,
190
177
  }));
191
178
  },
@@ -60,23 +60,40 @@ export function moveBlockInState(
60
60
  return { ...c, blocks };
61
61
  });
62
62
 
63
- // Pass 1: find and remove block from all sections (V2 + parallax slides)
64
- const rowsAfterRemove = rows.map((item) => {
63
+ // Pass 1: find and remove block from its current section (V2 + parallax slides)
64
+ // RC-006 fix: Early-exit once block is found, consistent with selectBlock()
65
+ // search pattern. Also searches PageSection V1 for completeness.
66
+ let rowsAfterRemove = rows;
67
+ for (let i = 0; i < rows.length; i++) {
68
+ const item = rows[i];
65
69
  if (isPageSectionV2(item)) {
66
- return { ...item, columns: removeFromColumns(item.columns) } as ContentItem;
67
- }
68
- if (isParallaxGroup(item)) {
70
+ const updatedCols = removeFromColumns(item.columns);
71
+ if (movedBlock) {
72
+ rowsAfterRemove = rows.map((r, idx) =>
73
+ idx === i ? { ...r, columns: updatedCols } as ContentItem : r
74
+ );
75
+ break;
76
+ }
77
+ } else if (isParallaxGroup(item)) {
69
78
  const group = item as ParallaxGroup;
70
- return {
71
- ...group,
72
- slides: group.slides.map((slide) => ({
73
- ...slide,
74
- columns: removeFromColumns(slide.columns),
75
- })),
76
- } as ContentItem;
79
+ let foundInSlide = false;
80
+ const updatedSlides = group.slides.map((slide) => {
81
+ if (foundInSlide) return slide; // skip remaining slides after block found
82
+ const updatedCols = removeFromColumns(slide.columns);
83
+ if (movedBlock) {
84
+ foundInSlide = true;
85
+ return { ...slide, columns: updatedCols };
86
+ }
87
+ return slide;
88
+ });
89
+ if (foundInSlide) {
90
+ rowsAfterRemove = rows.map((r, idx) =>
91
+ idx === i ? { ...group, slides: updatedSlides } as ContentItem : r
92
+ );
93
+ break;
94
+ }
77
95
  }
78
- return item;
79
- });
96
+ }
80
97
 
81
98
  if (!movedBlock) return null;
82
99
 
@@ -34,6 +34,47 @@ import { createSectionActions } from "./store-sections";
34
34
  import { createBlockActions } from "./store-blocks";
35
35
  import { createCanvasActions } from "./store-canvas";
36
36
 
37
+ // ============================================
38
+ // RC-003 fix: Block parent key cache — O(1) lookup in selectBlock().
39
+ // Invalidated when `rows` reference changes (immutable state).
40
+ // ============================================
41
+
42
+ interface BlockParent { rowKey: string; colKey: string | null }
43
+ let _blockParentCache: Map<string, BlockParent> | null = null;
44
+ let _blockParentCacheRowsRef: ContentItem[] | null = null;
45
+
46
+ function getBlockParentCache(rows: ContentItem[]): Map<string, BlockParent> {
47
+ if (_blockParentCache && _blockParentCacheRowsRef === rows) return _blockParentCache;
48
+
49
+ const cache = new Map<string, BlockParent>();
50
+ for (const item of rows) {
51
+ if (isPageSection(item)) {
52
+ const sBlock = Array.isArray(item.block) ? item.block[0] : undefined;
53
+ if (sBlock) cache.set(sBlock._key, { rowKey: item._key, colKey: null });
54
+ } else if (isPageSectionV2(item)) {
55
+ const v2 = item as PageSectionV2;
56
+ for (const col of v2.columns) {
57
+ for (const b of col.blocks) {
58
+ cache.set(b._key, { rowKey: item._key, colKey: col._key });
59
+ }
60
+ }
61
+ } else if (isParallaxGroup(item)) {
62
+ const group = item as ParallaxGroup;
63
+ for (const slide of group.slides) {
64
+ for (const col of slide.columns) {
65
+ for (const b of col.blocks) {
66
+ cache.set(b._key, { rowKey: slide._key, colKey: col._key });
67
+ }
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ _blockParentCache = cache;
74
+ _blockParentCacheRowsRef = rows;
75
+ return cache;
76
+ }
77
+
37
78
  // ============================================
38
79
  // Initial State
39
80
  // ============================================
@@ -90,6 +131,9 @@ const initialState: BuilderState = {
90
131
  _history: [],
91
132
  _future: [],
92
133
  _isTimeTraveling: false,
134
+
135
+ // Color picker live preview (Phase 4)
136
+ colorPickerPreview: null,
93
137
  };
94
138
 
95
139
  // ============================================
@@ -102,12 +146,13 @@ export const useBuilderStore = create<BuilderStore>((set, get) => ({
102
146
  // ---- Internal: push snapshot before mutations ----
103
147
 
104
148
  // BUG-010 fix: Snapshots now include pageSettings alongside rows
149
+ // RC-004 fix: Use functional set() for atomic read+write — prevents two
150
+ // rapid mutations from reading the same _history before either writes.
105
151
  _pushSnapshot: () => {
106
- const state = get();
107
- set({
152
+ set((state) => ({
108
153
  _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
109
154
  _future: [], // new mutation clears redo stack
110
- });
155
+ }));
111
156
  },
112
157
 
113
158
  // ---- History (Undo/Redo) ----
@@ -187,47 +232,15 @@ export const useBuilderStore = create<BuilderStore>((set, get) => ({
187
232
  }
188
233
  // BUG-002 fix: resolve parent row and column keys so SettingsPanel
189
234
  // and keyboard shortcuts always have consistent selection state.
235
+ // RC-003 fix: Use cached parent lookup — O(1) instead of O(n) iteration.
190
236
  const state = get();
191
- let parentRowKey: string | null = null;
192
- let parentColKey: string | null = null;
193
-
194
- const searchColumns = (columns: import("../../lib/sanity/types").SectionColumn[], rowKey: string): boolean => {
195
- for (const col of columns) {
196
- if (col.blocks.some((b) => b._key === key)) {
197
- parentRowKey = rowKey;
198
- parentColKey = col._key;
199
- return true;
200
- }
201
- }
202
- return false;
203
- };
204
-
205
- for (const item of state.rows) {
206
- if (isPageSection(item)) {
207
- const sBlock = Array.isArray(item.block) ? item.block[0] : undefined;
208
- if (sBlock && sBlock._key === key) {
209
- parentRowKey = item._key;
210
- break;
211
- }
212
- } else if (isPageSectionV2(item)) {
213
- if (searchColumns(item.columns, item._key)) break;
214
- } else if (isParallaxGroup(item)) {
215
- const group = item as ParallaxGroup;
216
- let found = false;
217
- for (const slide of group.slides) {
218
- // For parallax slides, the "row key" is the slide key (used as sectionKey)
219
- if (searchColumns(slide.columns, slide._key)) {
220
- found = true;
221
- break;
222
- }
223
- }
224
- if (found) break;
225
- }
226
- }
237
+ const cache = getBlockParentCache(state.rows);
238
+ const parent = cache.get(key);
239
+
227
240
  set({
228
241
  selectedBlockKey: key,
229
- selectedRowKey: parentRowKey,
230
- selectedColumnKey: parentColKey,
242
+ selectedRowKey: parent?.rowKey ?? null,
243
+ selectedColumnKey: parent?.colKey ?? null,
231
244
  selectedProjectCardKey: null,
232
245
  });
233
246
  },
@@ -325,6 +338,11 @@ export const useBuilderStore = create<BuilderStore>((set, get) => ({
325
338
  markDirty: () => set({ isDirty: true }),
326
339
  markClean: () => set({ isDirty: false }),
327
340
 
341
+ // ---- Color picker live preview (Phase 4) ----
342
+
343
+ setColorPickerPreview: (preview) => set({ colorPickerPreview: preview }),
344
+ clearColorPickerPreview: () => set({ colorPickerPreview: null }),
345
+
328
346
  // ---- Spread in action groups ----
329
347
 
330
348
  ...createSectionActions(set, get),
@@ -16,6 +16,7 @@ import type {
16
16
  SectionV2Preset,
17
17
  SectionColumn,
18
18
  PageMetadata,
19
+ ColorField,
19
20
  } from "../../lib/sanity/types";
20
21
  import { DEFAULT_BG_COLOR, DEFAULT_TEXT_COLOR, DEFAULT_GRID_WIDTH } from "./constants";
21
22
 
@@ -229,6 +230,15 @@ export interface BuilderState {
229
230
  * Populated by CustomSectionInstanceCard/ReadOnlyCustomSection on fetch.
230
231
  * Used by SortableRow to merge base settings with per-instance overrides. */
231
232
  _customSectionCache: Record<string, import("../../lib/sanity/types").SectionV2Settings>;
233
+
234
+ /** Live preview overlay from color picker — shown on canvas without persisting to Sanity.
235
+ * Set while user is dragging in the color picker; cleared on close. */
236
+ colorPickerPreview: {
237
+ blockKey?: string;
238
+ sectionKey?: string;
239
+ field: string;
240
+ value: ColorField;
241
+ } | null;
232
242
  }
233
243
 
234
244
  export interface BuilderActions {
@@ -369,6 +379,10 @@ export interface BuilderActions {
369
379
  zoomToFit: (viewportWidth: number, viewportHeight: number) => void;
370
380
  zoomToPoint: (newZoom: number, cursorX: number, cursorY: number) => void;
371
381
  zoomToFrame: (device: DeviceViewport, viewportWidth: number, viewportHeight: number) => void;
382
+
383
+ // Color picker live preview (Phase 4)
384
+ setColorPickerPreview: (preview: BuilderState["colorPickerPreview"]) => void;
385
+ clearColorPickerPreview: () => void;
372
386
  }
373
387
 
374
388
  export type BuilderStore = BuilderState & BuilderActions;
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Centralized color conversion utilities.
3
3
  *
4
+ * Phase 2: Includes bridge functions for ColorField (solid + gradient).
5
+ * Renderers use these bridge functions instead of manipulating ColorField directly.
6
+ *
4
7
  * Previously duplicated across:
5
8
  * - components/builder/ColorPicker.tsx (hexToHSL, hsvToHex, hexToHSV)
6
9
  * - components/blocks/BlockRenderer.tsx (hexToRgba)
@@ -8,6 +11,8 @@
8
11
  * - components/admin/nav-builder/nav-builder-utils.ts (hexToRgba)
9
12
  */
10
13
 
14
+ import type { ColorField, GradientValue } from "./sanity/types";
15
+
11
16
  /** Convert hex (#RRGGBB) to HSL values. */
12
17
  export function hexToHSL(hex: string): { h: number; s: number; l: number } {
13
18
  const r = parseInt(hex.slice(1, 3), 16) / 255;
@@ -114,3 +119,198 @@ export function lerpHex(a: string, b: string, t: number): string {
114
119
  const bl = clamp(bA + (bB - bA) * t);
115
120
  return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${bl.toString(16).padStart(2, "0")}`;
116
121
  }
122
+
123
+ // ─── Deprecation warning flag (Phase 4) ───
124
+ let _gradientOpacityWarned = false;
125
+
126
+ // ─── ColorField Bridge Functions (Phase 2) ───
127
+
128
+ /**
129
+ * Determines whether a ColorField value is a gradient (not a solid hex string).
130
+ */
131
+ export function isGradient(value: ColorField): value is GradientValue {
132
+ return typeof value !== "string";
133
+ }
134
+
135
+ /**
136
+ * Converts a ColorField to a valid CSS string.
137
+ * This is the single conversion point for renderers.
138
+ */
139
+ export function colorToCSS(value: ColorField): string {
140
+ if (typeof value === "string") return value;
141
+
142
+ switch (value.type) {
143
+ case "linear": {
144
+ const stops = value.stops
145
+ .map((s) => `${hexToRgba(s.color, s.alpha)} ${s.position}%`)
146
+ .join(", ");
147
+ return `linear-gradient(${value.angle}deg, ${stops})`;
148
+ }
149
+ case "radial": {
150
+ const stops = value.stops
151
+ .map((s) => `${hexToRgba(s.color, s.alpha)} ${s.position}%`)
152
+ .join(", ");
153
+ const shape = value.shape === "circle" ? "circle" : "ellipse";
154
+ return `radial-gradient(${shape} at ${value.position.x}% ${value.position.y}%, ${stops})`;
155
+ }
156
+ case "mesh": {
157
+ const layers = value.points
158
+ .map(
159
+ (p) =>
160
+ `radial-gradient(at ${p.x}% ${p.y}%, ${p.color} 0%, transparent 50%)`
161
+ )
162
+ .join(", ");
163
+ return `${layers}, ${value.background}`;
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Extracts a representative hex color from a ColorField.
170
+ * For gradients, returns the first stop's color.
171
+ * Used for: text contrast checks, swatch preview, simple validations.
172
+ */
173
+ export function resolveColorHex(value: ColorField): string {
174
+ if (typeof value === "string") return value;
175
+ switch (value.type) {
176
+ case "linear":
177
+ case "radial":
178
+ return value.stops[0]?.color ?? "#000000";
179
+ case "mesh":
180
+ return value.points[0]?.color ?? value.background;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Returns the correct CSS property object for a background color.
186
+ * - Solid: { backgroundColor: "#ff0000" }
187
+ * - Gradient: { backgroundImage: "linear-gradient(...)" }
188
+ *
189
+ * CSS requires `background-image` for gradients, not `background-color`.
190
+ */
191
+ export function colorToCSSProperty(
192
+ value: ColorField,
193
+ opacity?: number // 0-100, only applies to solids
194
+ ): Record<string, string> {
195
+ if (typeof value === "string") {
196
+ if (opacity !== undefined && opacity < 100) {
197
+ return { backgroundColor: hexToRgba(value, opacity / 100) };
198
+ }
199
+ return { backgroundColor: value };
200
+ }
201
+ // Gradients: opacity is ignored (each stop has its own alpha)
202
+ if (opacity !== undefined && !_gradientOpacityWarned) {
203
+ _gradientOpacityWarned = true;
204
+ console.warn(
205
+ "[@morphika/andami] background_opacity is ignored for gradient colors. " +
206
+ "Control opacity per stop in the gradient editor."
207
+ );
208
+ }
209
+ // Mesh gradients need separate backgroundImage + backgroundColor
210
+ // because a plain color (#hex) is not valid inside background-image.
211
+ if (value.type === "mesh") {
212
+ const layers = value.points
213
+ .map(
214
+ (p) =>
215
+ `radial-gradient(at ${p.x}% ${p.y}%, ${p.color} 0%, transparent 50%)`
216
+ )
217
+ .join(", ");
218
+ return { backgroundImage: layers, backgroundColor: value.background };
219
+ }
220
+ return { backgroundImage: colorToCSS(value) };
221
+ }
222
+
223
+ /**
224
+ * Generates a CSS override rule string for responsive overrides.
225
+ * Returns the correct rule based on color type (with !important).
226
+ */
227
+ export function colorToOverrideRule(
228
+ value: ColorField,
229
+ opacity?: number
230
+ ): string {
231
+ if (typeof value === "string") {
232
+ if (opacity !== undefined && opacity < 100) {
233
+ return `background-color:${hexToRgba(value, opacity / 100)}!important`;
234
+ }
235
+ return `background-color:${value}!important`;
236
+ }
237
+ if (opacity !== undefined && !_gradientOpacityWarned) {
238
+ _gradientOpacityWarned = true;
239
+ console.warn(
240
+ "[@morphika/andami] background_opacity is ignored for gradient colors. " +
241
+ "Control opacity per stop in the gradient editor."
242
+ );
243
+ }
244
+ // Mesh gradients need two rules: background-image for layers + background-color for base
245
+ if (value.type === "mesh") {
246
+ const layers = value.points
247
+ .map(
248
+ (p) =>
249
+ `radial-gradient(at ${p.x}% ${p.y}%, ${p.color} 0%, transparent 50%)`
250
+ )
251
+ .join(", ");
252
+ return `background-image:${layers}!important;background-color:${value.background}!important`;
253
+ }
254
+ return `background-image:${colorToCSS(value)}!important`;
255
+ }
256
+
257
+ /**
258
+ * Generates CSS for border with color.
259
+ * - Solid: standard border shorthand.
260
+ * - Gradient: border-image.
261
+ */
262
+ export function borderColorToCSS(
263
+ value: ColorField,
264
+ width: number,
265
+ style: string
266
+ ): Record<string, string> {
267
+ if (typeof value === "string") {
268
+ return { border: `${width}px ${style} ${value}` };
269
+ }
270
+ return {
271
+ border: `${width}px ${style} transparent`,
272
+ borderImage: `${colorToCSS(value)} 1`,
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Generates a CSS override rule for border-color in responsive overrides.
278
+ * - Solid: `border-color:X!important`
279
+ * - Gradient: `border-image:X 1!important;border-color:transparent!important`
280
+ */
281
+ export function borderColorToOverrideRule(value: ColorField): string {
282
+ if (typeof value === "string") {
283
+ return `border-color:${value}!important`;
284
+ }
285
+ return `border-color:transparent!important;border-image:${colorToCSS(value)} 1!important`;
286
+ }
287
+
288
+ /**
289
+ * Parse a raw Sanity field value into a typed ColorField.
290
+ * - Hex strings pass through as-is
291
+ * - JSON strings starting with "{" are parsed as GradientValue
292
+ * - Anything else falls back to "#000000"
293
+ */
294
+ export function parseColorField(raw: unknown): ColorField {
295
+ if (typeof raw === "string") {
296
+ if (raw.startsWith("{")) {
297
+ try {
298
+ return JSON.parse(raw) as GradientValue;
299
+ } catch {
300
+ return raw;
301
+ }
302
+ }
303
+ return raw;
304
+ }
305
+ return "#000000";
306
+ }
307
+
308
+ /**
309
+ * Serialize a ColorField for storage in Sanity.
310
+ * - Hex strings are stored as-is
311
+ * - Gradient objects are JSON-stringified
312
+ */
313
+ export function serializeColorField(value: ColorField): string {
314
+ if (typeof value === "string") return value;
315
+ return JSON.stringify(value);
316
+ }
package/lib/revalidate.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { csrfHeaders } from "../lib/csrf-client";
2
- import { logger } from "../lib/logger";
1
+ import { csrfHeaders } from "./csrf-client";
2
+ import { logger } from "./logger";
3
3
 
4
4
  /**
5
5
  * Trigger on-demand ISR revalidation from the admin UI.
@@ -209,7 +209,7 @@ export const publishedProjectSummariesQuery = groq`
209
209
 
210
210
  // All non-project page slugs (for generateStaticParams on /[slug])
211
211
  export const allPageSlugsQuery = groq`
212
- *[_type == "page" && page_type != "project" && draft_mode != true].slug.current
212
+ *[_type == "page" && page_type != "project" && draft_mode != true && defined(slug.current)].slug.current
213
213
  `;
214
214
 
215
215
  // Get a published project page by slug (for public /work/[slug] route)
@@ -231,7 +231,7 @@ export const publishedProjectBySlugQuery = groq`
231
231
 
232
232
  // All published project slugs (for generateStaticParams on /work/[slug])
233
233
  export const allProjectSlugsQuery = groq`
234
- *[_type == "page" && page_type == "project" && draft_mode != true].slug.current
234
+ *[_type == "page" && page_type == "project" && draft_mode != true && defined(slug.current)].slug.current
235
235
  `;
236
236
 
237
237
  // ============================================
@@ -375,7 +375,7 @@ export const siteStylesQuery = groq`
375
375
  // ============================================
376
376
 
377
377
  export const siteSettingsQuery = groq`
378
- *[_type == "siteSettings"][0] {
378
+ *[_id == "siteSettings"][0] {
379
379
  nav_items[] {
380
380
  _key,
381
381
  type,
@@ -398,6 +398,7 @@ export const siteSettingsQuery = groq`
398
398
  style_overrides
399
399
  },
400
400
  nav_design,
401
+ nav_mobile_design,
401
402
  default_title,
402
403
  default_description,
403
404
  default_og_image,