@morphika/andami 0.2.26 → 0.3.1

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 (61) hide show
  1. package/app/admin/pages/[slug]/page.tsx +39 -45
  2. package/app/api/admin/assets/scan/route.ts +40 -13
  3. package/app/api/admin/custom-sections/[slug]/route.ts +4 -1
  4. package/app/api/admin/custom-sections/route.ts +4 -1
  5. package/app/api/admin/pages/[slug]/route.ts +7 -1
  6. package/app/api/admin/pages/route.ts +4 -1
  7. package/app/api/admin/r2/connect/route.ts +19 -1
  8. package/app/api/admin/r2/disconnect/route.ts +3 -0
  9. package/app/api/admin/r2/rename/route.ts +52 -13
  10. package/app/api/admin/r2/upload-url/route.ts +8 -1
  11. package/app/api/admin/settings/route.ts +4 -1
  12. package/app/api/admin/styles/route.ts +4 -1
  13. package/components/admin/styles/GridLayoutEditor.tsx +46 -46
  14. package/components/blocks/BlockRenderer.tsx +11 -2
  15. package/components/blocks/CoverSectionRenderer.tsx +75 -3
  16. package/components/blocks/ImageGridBlockRenderer.tsx +17 -11
  17. package/components/blocks/ParallaxGroupRenderer.tsx +45 -10
  18. package/components/blocks/ShaderCanvas.tsx +10 -6
  19. package/components/builder/BlockCardIcons.tsx +227 -0
  20. package/components/builder/BlockTypePicker.tsx +36 -63
  21. package/components/builder/BuilderCanvas.tsx +6 -2
  22. package/components/builder/ColumnDragOverlay.tsx +3 -3
  23. package/components/builder/CoverRowResizeHandle.tsx +5 -2
  24. package/components/builder/CoverSectionCanvas.tsx +45 -52
  25. package/components/builder/DndWrapper.tsx +1 -1
  26. package/components/builder/InsertionLines.tsx +1 -1
  27. package/components/builder/ParallaxGroupCanvas.tsx +12 -71
  28. package/components/builder/SectionCardIcons.tsx +266 -0
  29. package/components/builder/SectionEditorBar.tsx +17 -12
  30. package/components/builder/SectionTypePicker.tsx +33 -137
  31. package/components/builder/SectionV2Canvas.tsx +1 -1
  32. package/components/builder/SectionV2Column.tsx +19 -30
  33. package/components/builder/SettingsPanel.tsx +8 -32
  34. package/components/builder/SortableBlock.tsx +42 -50
  35. package/components/builder/SortableRow.tsx +207 -19
  36. package/components/builder/blockStyles.tsx +53 -180
  37. package/components/builder/iconPrimitives.tsx +78 -0
  38. package/components/builder/live-preview/LiveImagePreview.tsx +16 -2
  39. package/components/builder/live-preview/LiveVideoPreview.tsx +15 -2
  40. package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
  41. package/components/builder/settings-panel/CoverSectionSettings.tsx +28 -1
  42. package/components/builder/settings-panel/SectionV2Settings.tsx +14 -14
  43. package/lib/assets.ts +17 -2
  44. package/lib/builder/constants.ts +22 -15
  45. package/lib/builder/format.ts +25 -0
  46. package/lib/builder/history.ts +0 -3
  47. package/lib/builder/layout-styles.ts +1 -1
  48. package/lib/builder/section-visibility.ts +36 -0
  49. package/lib/builder/serializer/normalizers.ts +15 -6
  50. package/lib/builder/serializer/serializers.ts +3 -3
  51. package/lib/builder/store-blocks.ts +16 -9
  52. package/lib/builder/store-cover.ts +76 -8
  53. package/lib/builder/store.ts +0 -2
  54. package/lib/builder/types.ts +1 -2
  55. package/lib/csrf.ts +31 -0
  56. package/lib/sanity/types.ts +4 -1
  57. package/lib/security.ts +50 -0
  58. package/lib/version.ts +1 -1
  59. package/package.json +1 -1
  60. package/sanity/schemas/objects/coverSection.ts +35 -3
  61. package/components/builder/ParallaxSlideHeader.tsx +0 -113
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Shared SVG primitives for the block and section card icons.
5
+ *
6
+ * Both `BlockCardIcons.tsx` and `SectionCardIcons.tsx` consume these so the
7
+ * visual language (brick background + fade + shadow + bevel) stays 1:1
8
+ * consistent. Each helper accepts an `idPrefix` / `id` so multiple icons can
9
+ * render side by side without filter/gradient/mask collisions.
10
+ */
11
+
12
+ /** Brick-rect positions — 24 rects, 4 rows, top half only. */
13
+ export const BRICK: ReadonlyArray<readonly [number, number]> = [
14
+ [18.7, 0.1], [53.5, 0.1], [88.4, 0.1], [123.2, 0.1], [158.1, 0.1], [192.9, 0.1],
15
+ [1.3, 17.6], [36.1, 17.6], [71, 17.6], [105.8, 17.6], [140.7, 17.6], [175.5, 17.6],
16
+ [18.7, 34.8], [53.5, 34.8], [88.4, 34.8], [123.2, 34.8], [158.1, 34.8], [192.9, 34.8],
17
+ [1.3, 52.3], [36.1, 52.3], [71, 52.3], [105.8, 52.3], [140.7, 52.3], [175.5, 52.3],
18
+ ];
19
+
20
+ /** Grid line pattern + right-side fade gradient defs.
21
+ *
22
+ * The fade reaches full opacity at x=200 (viewBox units) rather than at
23
+ * x=220 (the right edge). This guarantees that the rightmost ~20 viewBox
24
+ * units of the SVG are solid #F4F4F4 — eliminating sub-pixel anti-aliasing
25
+ * artifacts when the SVG is downscaled to fit a container whose width is
26
+ * slightly smaller than the full viewBox (e.g. 176px for a 220-wide viewBox).
27
+ */
28
+ export function BgDefs({ prefix }: { prefix: string }) {
29
+ return (
30
+ <>
31
+ <pattern id={`${prefix}-grid`} x="0.8" y="-0.3" width="17.5" height="17.5" patternUnits="userSpaceOnUse">
32
+ <path d="M 17.5 0 L 0 0 0 17.5" fill="none" stroke="#BABABA" strokeWidth="0.5" opacity="0.49" />
33
+ </pattern>
34
+ <linearGradient id={`${prefix}-fade`} x1="140.2" y1="60" x2="200" y2="60" gradientUnits="userSpaceOnUse">
35
+ <stop offset="0" stopColor="#F4F4F4" stopOpacity="0" />
36
+ <stop offset="1" stopColor="#F4F4F4" />
37
+ </linearGradient>
38
+ </>
39
+ );
40
+ }
41
+
42
+ /** Background body: solid fill → grid pattern → brick overlay → right-side fade. */
43
+ export function Bg({ prefix }: { prefix: string }) {
44
+ return (
45
+ <>
46
+ <rect width="220" height="120" fill="#F4F4F4" />
47
+ <rect width="220" height="120" fill={`url(#${prefix}-grid)`} />
48
+ <g fill="none" stroke="#FFFFFF" strokeWidth="0.5">
49
+ {BRICK.map(([x, y]) => (
50
+ <rect key={`${x}-${y}`} x={x} y={y} width="15.5" height="15.3" />
51
+ ))}
52
+ </g>
53
+ <rect x="140.2" y="0" width="79.8" height="120" fill={`url(#${prefix}-fade)`} />
54
+ </>
55
+ );
56
+ }
57
+
58
+ /** Vector drop-shadow filter. */
59
+ export function ShadowFilter({ id }: { id: string }) {
60
+ return (
61
+ <filter id={id} x="-30%" y="-30%" width="160%" height="160%">
62
+ <feGaussianBlur in="SourceAlpha" stdDeviation="3" />
63
+ <feOffset dy="3" />
64
+ <feComponentTransfer><feFuncA type="linear" slope="0.22" /></feComponentTransfer>
65
+ <feMerge><feMergeNode /><feMergeNode in="SourceGraphic" /></feMerge>
66
+ </filter>
67
+ );
68
+ }
69
+
70
+ /** Vertical bevel gradient: #FFFFFF → endColor (default #E6ECF6 for blocks, #EBEAEF for sections). */
71
+ export function VertBevel({ id, endColor = "#E6ECF6" }: { id: string; endColor?: string }) {
72
+ return (
73
+ <linearGradient id={id} x1="0.5" y1="0" x2="0.5" y2="1">
74
+ <stop offset="0" stopColor="#FFFFFF" />
75
+ <stop offset="1" stopColor={endColor} />
76
+ </linearGradient>
77
+ );
78
+ }
@@ -11,9 +11,23 @@ export default function LiveImagePreview({ block }: { block: ImageBlock }) {
11
11
  const [useFallback, setUseFallback] = useState(false);
12
12
 
13
13
  if (!block.asset_path) {
14
+ // Empty state: fills the column (min 240px) with a light-gray backdrop
15
+ // and a landscape placeholder glyph. Once the user picks an image the
16
+ // block sizes itself normally.
17
+ const isFill = block.width === "fill";
18
+ const wrapperStyle: React.CSSProperties = isFill
19
+ ? { position: "absolute", inset: 0 }
20
+ : { width: "100%" };
14
21
  return (
15
- <div className="border border-dashed border-neutral-300 rounded bg-neutral-50 flex items-center justify-center py-12">
16
- <span className="text-neutral-400 text-xs">No image set</span>
22
+ <div style={wrapperStyle}>
23
+ <div className="w-full h-full min-h-[240px] rounded flex flex-col items-center justify-center gap-2.5" style={{ background: "#f4f4f4" }}>
24
+ <svg width="56" height="56" viewBox="0 0 56 56" fill="none" aria-hidden="true">
25
+ <rect x="6" y="10" width="44" height="36" rx="3" stroke="#b0b5bd" strokeWidth="1.5" fill="#FFFFFF" />
26
+ <circle cx="18" cy="21" r="3" fill="#b0b5bd" />
27
+ <path d="M12 42 L22 28 L28 34 L38 22 L46 42 Z" fill="#b0b5bd" />
28
+ </svg>
29
+ <span className="text-[11px] text-neutral-500">No image yet</span>
30
+ </div>
17
31
  </div>
18
32
  );
19
33
  }
@@ -17,9 +17,22 @@ import type { VideoBlock } from "../../../lib/sanity/types";
17
17
  */
18
18
  export default function LiveVideoPreview({ block }: { block: VideoBlock }) {
19
19
  if (!block.url_or_path) {
20
+ // Empty state: fills the column (min 240px) with a light-gray backdrop
21
+ // and a centered play-button glyph. Once the user picks a video the
22
+ // block sizes itself normally.
23
+ const isFill = block.width === "fill";
24
+ const wrapperStyle: React.CSSProperties = isFill
25
+ ? { position: "absolute", inset: 0 }
26
+ : { width: "100%" };
20
27
  return (
21
- <div className="border border-dashed border-neutral-700 rounded bg-neutral-900/50 flex items-center justify-center py-12">
22
- <span className="text-neutral-600 text-xs">No video set</span>
28
+ <div style={wrapperStyle}>
29
+ <div className="w-full h-full min-h-[240px] rounded flex flex-col items-center justify-center gap-2.5" style={{ background: "#f4f4f4" }}>
30
+ <svg width="56" height="56" viewBox="0 0 56 56" fill="none" aria-hidden="true">
31
+ <circle cx="28" cy="28" r="22" fill="#FFFFFF" stroke="#b0b5bd" strokeWidth="1.5" />
32
+ <path d="M24 20 L37 28 L24 36 Z" fill="#b0b5bd" />
33
+ </svg>
34
+ <span className="text-[11px] text-neutral-500">No video yet</span>
35
+ </div>
23
36
  </div>
24
37
  );
25
38
  }
@@ -85,8 +85,8 @@ export default function ColumnV2Settings({
85
85
  <>
86
86
  {isResponsive && (
87
87
  <div className="px-4 pt-3">
88
- <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#076bff]/8 border border-[#076bff]/15">
89
- <span className="text-[11px] font-medium text-[#076bff]">
88
+ <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#4794e2]/8 border border-[#4794e2]/15">
89
+ <span className="text-[11px] font-medium text-[#4794e2]">
90
90
  Editing {activeViewport === "tablet" ? "Tablet" : "Phone"} overrides
91
91
  </span>
92
92
  </div>
@@ -98,7 +98,7 @@ export default function ColumnV2Settings({
98
98
  <span>
99
99
  Span
100
100
  {hasSpanOverride && (
101
- <span className="ml-1 text-[9px] text-[#076bff]">overridden</span>
101
+ <span className="ml-1 text-[9px] text-[#4794e2]">overridden</span>
102
102
  )}
103
103
  </span>
104
104
  }>
@@ -109,7 +109,7 @@ export default function ColumnV2Settings({
109
109
  max={gridColumns}
110
110
  value={effectiveSpan}
111
111
  onChange={(e) => handleSpanChange(parseInt(e.target.value))}
112
- className="flex-1 accent-[#076bff]"
112
+ className="flex-1 accent-[#4794e2]"
113
113
  />
114
114
  <span className="text-xs text-neutral-900 w-12 text-right font-medium">
115
115
  {effectiveSpan}/{gridColumns}
@@ -133,7 +133,7 @@ export default function ColumnV2Settings({
133
133
  <div
134
134
  key={i}
135
135
  className={`h-1.5 flex-1 rounded-full transition-colors ${
136
- isActive ? "bg-[#076bff]" : "bg-neutral-200"
136
+ isActive ? "bg-[#4794e2]" : "bg-neutral-200"
137
137
  }`}
138
138
  />
139
139
  );
@@ -22,6 +22,7 @@ import { useBuilderStore } from "../../../lib/builder/store";
22
22
  import type { CoverSection } from "../../../lib/sanity/types";
23
23
  import {
24
24
  BackgroundIcon,
25
+ NavbarColorIcon,
25
26
  OverlayIcon,
26
27
  SpacingIcon,
27
28
  GridGapsIcon,
@@ -56,6 +57,7 @@ const HEIGHT_OPTIONS = [
56
57
  { value: "100vh", label: "Full Viewport (100vh)" },
57
58
  { value: "80vh", label: "80% Viewport (80vh)" },
58
59
  { value: "50vh", label: "50% Viewport (50vh)" },
60
+ { value: "20vh", label: "20% Viewport (20vh)" },
59
61
  ];
60
62
 
61
63
  const ALIGN_OPTIONS = [
@@ -83,7 +85,8 @@ export default function CoverSectionSettings({ section }: CoverSectionSettingsPr
83
85
  const updateBg = (fields: Partial<Pick<CoverSection,
84
86
  "background_type" | "background_color" | "background_image" | "background_video" |
85
87
  "background_position" | "background_size" |
86
- "background_overlay_color" | "background_overlay_opacity"
88
+ "background_overlay_color" | "background_overlay_opacity" |
89
+ "nav_color"
87
90
  >>) => {
88
91
  store.updateCoverBackground(section._key, fields);
89
92
  };
@@ -162,6 +165,30 @@ export default function CoverSectionSettings({ section }: CoverSectionSettingsPr
162
165
  )}
163
166
  </SettingsSection>
164
167
 
168
+ {/* Navbar Color Override */}
169
+ <SettingsSection title="Navbar Color" defaultOpen={false} icon={<NavbarColorIcon />}>
170
+ <SettingsField label="Color">
171
+ <div className="flex items-center gap-2">
172
+ <ColorSwatchPicker
173
+ value={section.nav_color || ""}
174
+ onChange={(val) => updateBg({ nav_color: typeof val === "string" ? val : undefined })}
175
+ swatches={paletteSwatches}
176
+ />
177
+ {section.nav_color && (
178
+ <button
179
+ onClick={() => updateBg({ nav_color: undefined })}
180
+ className="text-[10px] text-neutral-400 hover:text-neutral-600 transition-colors shrink-0"
181
+ >
182
+ Clear
183
+ </button>
184
+ )}
185
+ </div>
186
+ </SettingsField>
187
+ <p className="text-[10px] text-neutral-400 leading-snug px-0.5">
188
+ Override the navbar text color while this cover section is on screen. Clears when the next section takes over.
189
+ </p>
190
+ </SettingsSection>
191
+
165
192
  {/* Overlay */}
166
193
  <SettingsSection title="Overlay" defaultOpen icon={<OverlayIcon />}>
167
194
  <SettingsField label="Color">
@@ -101,7 +101,7 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
101
101
  onClick={() => !isCustom && applyPresetV2(section._key, preset.id)}
102
102
  className={`flex flex-col items-center gap-1 p-2 rounded-lg border transition-all ${
103
103
  isActive
104
- ? "border-[#076bff] bg-[#076bff]/5"
104
+ ? "border-[#4794e2] bg-[#4794e2]/5"
105
105
  : isCustom
106
106
  ? "border-neutral-200 bg-neutral-50 opacity-60 cursor-default"
107
107
  : "border-neutral-200 bg-white hover:border-neutral-300 hover:bg-neutral-50"
@@ -121,7 +121,7 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
121
121
  <div
122
122
  key={i}
123
123
  className={`rounded-sm transition-colors ${
124
- isActive ? "bg-[#076bff]" : "bg-neutral-300"
124
+ isActive ? "bg-[#4794e2]" : "bg-neutral-300"
125
125
  }`}
126
126
  style={{ flex: span }}
127
127
  />
@@ -129,7 +129,7 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
129
129
  )}
130
130
  </div>
131
131
  <span className={`text-[9px] font-medium ${
132
- isActive ? "text-[#076bff]" : "text-neutral-500"
132
+ isActive ? "text-[#4794e2]" : "text-neutral-500"
133
133
  }`}>
134
134
  {preset.label}
135
135
  </span>
@@ -140,15 +140,15 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
140
140
  {/* + Add Column button */}
141
141
  <button
142
142
  onClick={handleAddColumn}
143
- className="flex flex-col items-center gap-1 p-2 rounded-lg border border-dashed border-neutral-300 transition-all hover:border-[#076bff] hover:bg-[#076bff]/5 group"
143
+ className="flex flex-col items-center gap-1 p-2 rounded-lg border border-dashed border-neutral-300 transition-all hover:border-[#4794e2] hover:bg-[#4794e2]/5 group"
144
144
  title="Add a column (fills first gap, or adds new row below)"
145
145
  >
146
146
  <div className="flex items-center justify-center w-full h-4">
147
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-neutral-400 group-hover:text-[#076bff] transition-colors">
147
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-neutral-400 group-hover:text-[#4794e2] transition-colors">
148
148
  <path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
149
149
  </svg>
150
150
  </div>
151
- <span className="text-[9px] font-medium text-neutral-400 group-hover:text-[#076bff] transition-colors">
151
+ <span className="text-[9px] font-medium text-neutral-400 group-hover:text-[#4794e2] transition-colors">
152
152
  Add Col
153
153
  </span>
154
154
  </button>
@@ -204,8 +204,8 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
204
204
  {/* Responsive info banner */}
205
205
  {isResponsive && (
206
206
  <div className="px-4 pt-3">
207
- <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#076bff]/8 border border-[#076bff]/15">
208
- <span className="text-[11px] font-medium text-[#076bff]">
207
+ <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#4794e2]/8 border border-[#4794e2]/15">
208
+ <span className="text-[11px] font-medium text-[#4794e2]">
209
209
  Editing {activeViewport === "tablet" ? "Tablet" : "Phone"} overrides
210
210
  </span>
211
211
  </div>
@@ -218,7 +218,7 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
218
218
  <div className="flex gap-2">
219
219
  <button
220
220
  onClick={handleStack}
221
- className="flex-1 rounded-lg bg-[#076bff]/8 border border-[#076bff]/20 py-2 text-xs font-medium text-[#076bff] hover:bg-[#076bff]/15 transition-colors"
221
+ className="flex-1 rounded-lg bg-[#4794e2]/8 border border-[#4794e2]/20 py-2 text-xs font-medium text-[#4794e2] hover:bg-[#4794e2]/15 transition-colors"
222
222
  title="Stack all columns vertically (full width, one per row)"
223
223
  >
224
224
  Stack Columns
@@ -237,7 +237,7 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
237
237
  </button>
238
238
  </div>
239
239
  {hasAnyOverrides && (
240
- <p className="text-[10px] text-[#076bff]/60 mt-1.5">
240
+ <p className="text-[10px] text-[#4794e2]/60 mt-1.5">
241
241
  {hasColOverrides ? "Column layout" : ""}
242
242
  {hasColOverrides && hasSettingsOverrides ? " + " : ""}
243
243
  {hasSettingsOverrides ? "settings" : ""}
@@ -266,7 +266,7 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
266
266
  <span>
267
267
  Col Gap
268
268
  {isResponsive && hasSectionV2SettingOverride(section, activeViewport, "col_gap") && (
269
- <span className="ml-1 text-[9px] text-[#076bff]">overridden</span>
269
+ <span className="ml-1 text-[9px] text-[#4794e2]">overridden</span>
270
270
  )}
271
271
  </span>
272
272
  }>
@@ -278,7 +278,7 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
278
278
  step={4}
279
279
  value={getGapValue("col_gap", 20)}
280
280
  onChange={(e) => updateSettingResponsive("col_gap", parseInt(e.target.value))}
281
- className="flex-1 accent-[#076bff]"
281
+ className="flex-1 accent-[#4794e2]"
282
282
  />
283
283
  <span className="text-xs text-neutral-900 w-12 text-right">
284
284
  {getGapValue("col_gap", 20)}px
@@ -298,7 +298,7 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
298
298
  <span>
299
299
  Row Gap
300
300
  {isResponsive && hasSectionV2SettingOverride(section, activeViewport, "row_gap") && (
301
- <span className="ml-1 text-[9px] text-[#076bff]">overridden</span>
301
+ <span className="ml-1 text-[9px] text-[#4794e2]">overridden</span>
302
302
  )}
303
303
  </span>
304
304
  }>
@@ -310,7 +310,7 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
310
310
  step={4}
311
311
  value={getGapValue("row_gap", 20)}
312
312
  onChange={(e) => updateSettingResponsive("row_gap", parseInt(e.target.value))}
313
- className="flex-1 accent-[#076bff]"
313
+ className="flex-1 accent-[#4794e2]"
314
314
  />
315
315
  <span className="text-xs text-neutral-900 w-12 text-right">
316
316
  {getGapValue("row_gap", 20)}px
package/lib/assets.ts CHANGED
@@ -23,6 +23,18 @@
23
23
 
24
24
  import { logger } from "./logger";
25
25
 
26
+ /**
27
+ * Percent-encode each slash-separated segment of a path so special characters
28
+ * (spaces, `?`, `#`, etc.) don't break the URL, while preserving the `/`
29
+ * boundaries as part of the URL path.
30
+ *
31
+ * Using encodeURIComponent on the whole path would turn `/` into `%2F`, which
32
+ * CDNs treat as a single filename segment — breaking directory-based keys.
33
+ */
34
+ function encodePath(path: string): string {
35
+ return path.split("/").map(encodeURIComponent).join("/");
36
+ }
37
+
26
38
  /**
27
39
  * Resolve a relative asset path to a full URL (public site).
28
40
  *
@@ -41,19 +53,22 @@ export function assetUrl(path: string | undefined | null): string {
41
53
 
42
54
  // #7: Normalize path — strip all leading slashes to prevent double-slash URLs
43
55
  const cleanPath = path.replace(/^\/+/, "");
56
+ // #16: Per-segment percent-encode so paths with spaces / `?` / `#` work on
57
+ // both the R2 CDN and the proxy route. Slashes are preserved.
58
+ const encodedPath = encodePath(cleanPath);
44
59
 
45
60
  // R2 direct mode: when env var is set, skip proxy entirely.
46
61
  // This is the zero-latency path — URL resolves to R2 CDN directly.
47
62
  const r2Base = process.env.NEXT_PUBLIC_R2_BUCKET_URL;
48
63
  if (r2Base) {
49
- return `${r2Base.replace(/\/+$/, "")}/${cleanPath}`;
64
+ return `${r2Base.replace(/\/+$/, "")}/${encodedPath}`;
50
65
  }
51
66
 
52
67
  // Proxy mode: route through /api/assets which handles provider detection
53
68
  // at runtime (supports provider switching without env var changes).
54
69
  const base = process.env.NEXT_PUBLIC_ASSET_BASE_URL;
55
70
  const resolvedBase = base || "/api/assets";
56
- return `${resolvedBase.replace(/\/+$/, "")}/${cleanPath}`;
71
+ return `${resolvedBase.replace(/\/+$/, "")}/${encodedPath}`;
57
72
  }
58
73
 
59
74
  /**
@@ -55,27 +55,34 @@ export const ADMIN_ERROR_DARK = "#d42f1a";
55
55
  // visual differentiation at a glance. This is a design rule that
56
56
  // MUST be followed across all builder components.
57
57
  //
58
- // BLUE (#076bff) — Columns: outlines, resize handles, drag grip,
59
- // span badge, column selection/hover chrome.
60
- // GREEN-B (#0d9668) — Blocks: "+ Add Block" buttons, block toolbar
58
+ // BLUE (#4794e2) — Columns: outlines, resize handles, drag grip,
59
+ // column selection/hover chrome.
60
+ // BLUE (#4794e2) — Blocks: "+ Add Block" buttons, block toolbar
61
61
  // pill, block selection ring, block-level actions.
62
- // BLUE (#076bff) Drop zones: gap drop targets during drag,
63
- // insertion lines, "Drop Here" labels, swap target
64
- // highlight (blue border + tinted background).
65
- // (Merged with column color for coherence.)
66
- // VIOLET (#8b5cf6) — Custom sections: saved section cards, custom
67
- // section instance badges, section editor chrome,
68
- // "Create New" custom section button.
62
+ // (Same hue as columns a future pass may split
63
+ // these into distinct hues if the shared blue is
64
+ // causing ambiguity.)
65
+ // VIOLET (#7500d5) Sections: side pill, cover/parallax accent,
66
+ // section outlines, hover/selection chrome.
67
+ // Also used for Custom Sections card chrome.
69
68
  //
70
69
  // When adding new builder UI, pick the color that matches the entity
71
70
  // being represented, not the action being performed. For example, a
72
71
  // delete button on a column is BLUE (it belongs to the column chrome),
73
- // while a delete button on a block toolbar is ORANGE.
72
+ // while the delete inside a block toolbar is BLUE too (inside the block
73
+ // pill, on hover it flashes red as a destructive cue).
74
74
 
75
- export const BUILDER_BLUE = "#076bff"; // Columns
76
- export const BUILDER_ORANGE = "#0d9668"; // Blocks (emerald was orange #e28b00)
77
- export const BUILDER_GREEN = "#22c55e"; // Drop zones
78
- export const BUILDER_VIOLET = "#8b5cf6"; // Custom sections
75
+ export const BUILDER_BLUE = "#4794e2"; // Columns (softened — was #076bff)
76
+ export const BUILDER_BLOCK = "#4794e2"; // Blocks — same hue as columns for now
77
+ export const BUILDER_VIOLET = "#7500d5"; // Sections (incl. Custom)
78
+ export const BUILDER_GREEN = "#22c55e"; // Success / confirmation cues (e.g. R2 asset check)
79
+
80
+ /**
81
+ * @deprecated Use `BUILDER_BLOCK` instead. The legacy name survived multiple
82
+ * colour migrations (orange → emerald → blue) and no longer reflects reality.
83
+ * Kept as an alias so third-party code doesn't break at the import boundary.
84
+ */
85
+ export const BUILDER_ORANGE = BUILDER_BLOCK;
79
86
 
80
87
  /**
81
88
  * Padding map for Row settings (in pixels)
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Formatting helpers for builder UI.
3
+ *
4
+ * Kept as pure functions in their own module so they can be imported from
5
+ * any builder component and unit-tested in isolation.
6
+ */
7
+
8
+ /**
9
+ * Format a row height percentage with at most one decimal place, dropping
10
+ * the trailing `.0` when the rounded value is an integer. Used by the
11
+ * Cover Section row sub-pill in `SortableRow`.
12
+ *
13
+ * Examples:
14
+ * formatRowPercent(100) -> "100"
15
+ * formatRowPercent(50) -> "50"
16
+ * formatRowPercent(33.333) -> "33.3"
17
+ * formatRowPercent(66.6667) -> "66.7"
18
+ * formatRowPercent(0) -> "0"
19
+ * formatRowPercent(NaN) -> "0" (fallback — never expected in practice)
20
+ */
21
+ export function formatRowPercent(p: number): string {
22
+ if (!Number.isFinite(p)) return "0";
23
+ const rounded = Math.round(p * 10) / 10;
24
+ return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1);
25
+ }
@@ -23,14 +23,11 @@ export interface HistoryState {
23
23
  _history: HistorySnapshot[];
24
24
  /** Future snapshots (for redo). Last element = next redo. */
25
25
  _future: HistorySnapshot[];
26
- /** Whether we're currently applying undo/redo (skip snapshot). */
27
- _isTimeTraveling: boolean;
28
26
  }
29
27
 
30
28
  export const initialHistoryState: HistoryState = {
31
29
  _history: [],
32
30
  _future: [],
33
- _isTimeTraveling: false,
34
31
  };
35
32
 
36
33
  /**
@@ -124,7 +124,7 @@ export function getBackgroundStyles(
124
124
  const imgUrl = assetBaseUrl
125
125
  ? `${assetBaseUrl.replace(/\/$/, "")}/${s.background_image}`
126
126
  : s.background_image;
127
- styles.backgroundImage = `url(${imgUrl})`;
127
+ styles.backgroundImage = `url("${imgUrl}")`;
128
128
  styles.backgroundSize = s.background_size || "cover";
129
129
  styles.backgroundPosition = s.background_position || "center center";
130
130
  styles.backgroundRepeat = s.background_repeat || "no-repeat";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Visibility helpers for scroll-driven section effects (e.g. the Cover
3
+ * Section's navbar colour override).
4
+ *
5
+ * Kept as pure functions so they're testable without mounting a renderer.
6
+ */
7
+
8
+ /**
9
+ * Fraction (0–1) of the viewport currently occupied by a section,
10
+ * computed from its top/bottom positions relative to the viewport and
11
+ * the viewport height.
12
+ *
13
+ * - 0 when the section is fully above or fully below the viewport
14
+ * - 1 when the section fully covers the viewport (top ≤ 0 and
15
+ * bottom ≥ vh)
16
+ * - fractional for partial overlaps
17
+ *
18
+ * Safe against pathological inputs: returns 0 if `viewportHeight <= 0`.
19
+ */
20
+ export function sectionVisibilityRatio(
21
+ top: number,
22
+ bottom: number,
23
+ viewportHeight: number
24
+ ): number {
25
+ if (viewportHeight <= 0) return 0;
26
+ const visible = Math.min(bottom, viewportHeight) - Math.max(top, 0);
27
+ if (visible <= 0) return 0;
28
+ return Math.min(1, visible / viewportHeight);
29
+ }
30
+
31
+ /**
32
+ * Threshold (as a fraction of the viewport) above which a Cover / Parallax
33
+ * section is considered "on screen" for the purposes of taking over the
34
+ * navbar colour.
35
+ */
36
+ export const NAV_COLOR_OVERRIDE_THRESHOLD = 0.3;
@@ -13,6 +13,7 @@ import type { EnterAnimationConfig } from "../../../lib/animation/enter-types";
13
13
 
14
14
  import { ensureKeys, normalizeBlockResponsive, SECTION_TYPE_MAP } from "./shared";
15
15
  import { migrateProjectGridV1ToV2, normalizeBlockAnimationFields } from "./migrations";
16
+ import { normalizeRowHeights } from "../store-cover";
16
17
 
17
18
  // ============================================
18
19
  // Section Normalizers
@@ -178,12 +179,20 @@ export function migrateContentItem(item: Record<string, unknown>): ContentItem {
178
179
  // CoverSection — normalize with defaults for null fields (Session 176)
179
180
  if (item._type === "coverSection") {
180
181
  const raw = item as unknown as CoverSection;
181
- const coverRows = (raw.cover_rows ?? []).map((r) => ({
182
+ const rawRows = (raw.cover_rows ?? []).map((r) => ({
182
183
  ...r,
183
184
  _key: r._key || generateKey(),
184
185
  height_percent: r.height_percent ?? 100,
185
186
  vertical_align: r.vertical_align ?? "start",
186
187
  }));
188
+ // Defend the sum-to-100 invariant against hand-edited or legacy docs:
189
+ // the schema sum-check is newer than the oldest docs that may be in
190
+ // Sanity, so a page saved before Session 178 could have drift.
191
+ const normalizedPercents = normalizeRowHeights(rawRows.map((r) => r.height_percent));
192
+ const coverRows = rawRows.map((r, i) => ({
193
+ ...r,
194
+ height_percent: normalizedPercents[i] ?? r.height_percent,
195
+ }));
187
196
  return {
188
197
  ...raw,
189
198
  _key: (raw._key as string) || generateKey(),
@@ -217,7 +226,7 @@ export function migrateContentItem(item: Record<string, unknown>): ContentItem {
217
226
  /**
218
227
  * Convert a Sanity `page` document into the builder's state shape.
219
228
  */
220
- export function documentToState(doc: Page): Omit<BuilderState, "isDirty" | "isSaving" | "saveError" | "lastSavedAt" | "selectedRowKey" | "selectedColumnKey" | "selectedBlockKey" | "_history" | "_future" | "_isTimeTraveling" | "_originalSlug" | "previewMode" | "canvasZoom" | "canvasPanX" | "canvasPanY" | "canvasTool" | "activeViewport" | "editorMode" | "customSectionSlug" | "customSectionTitle" | "savedPageState"> {
229
+ export function documentToState(doc: Page): Omit<BuilderState, "isDirty" | "isSaving" | "saveError" | "lastSavedAt" | "selectedRowKey" | "selectedColumnKey" | "selectedBlockKey" | "_history" | "_future" | "_originalSlug" | "previewMode" | "canvasZoom" | "canvasPanX" | "canvasPanY" | "canvasTool" | "activeViewport" | "editorMode" | "customSectionSlug" | "customSectionTitle" | "savedPageState"> {
221
230
  const docRecord = doc as unknown as Record<string, unknown>;
222
231
  const pageSettings = docRecord.page_settings as Record<string, unknown> | undefined;
223
232
 
@@ -254,10 +263,10 @@ export function documentToState(doc: Page): Omit<BuilderState, "isDirty" | "isSa
254
263
  text_color: (pageSettings?.text_color as string) || DEFAULT_TEXT_COLOR,
255
264
  nav_color,
256
265
  enter_animation: pageSettings?.enter_animation as PageSettings["enter_animation"],
257
- nav_entrance_animation: (pageSettings?.nav_entrance_animation as PageSettings["nav_entrance_animation"]) || undefined,
258
- nav_entrance_duration: (pageSettings?.nav_entrance_duration as number) || undefined,
259
- nav_entrance_delay: (pageSettings?.nav_entrance_delay as number) || undefined,
260
- nav_entrance_disabled: (pageSettings?.nav_entrance_disabled as boolean) || undefined,
266
+ nav_entrance_animation: (pageSettings?.nav_entrance_animation as PageSettings["nav_entrance_animation"]) ?? undefined,
267
+ nav_entrance_duration: (pageSettings?.nav_entrance_duration as number) ?? undefined,
268
+ nav_entrance_delay: (pageSettings?.nav_entrance_delay as number) ?? undefined,
269
+ nav_entrance_disabled: (pageSettings?.nav_entrance_disabled as boolean) ?? undefined,
261
270
  },
262
271
  // Grid settings are loaded separately via applyGlobalStyles(), not from the page document.
263
272
  // Provide defaults here to satisfy the type; they'll be overwritten on init.
@@ -349,9 +349,9 @@ export function stateToDocument(
349
349
  nav_color: navColor || undefined,
350
350
  enter_animation: hasEnterAnimation ? enterAnim : undefined,
351
351
  nav_entrance_animation: navEntranceAnimation || undefined,
352
- nav_entrance_duration: navEntranceDuration || undefined,
353
- nav_entrance_delay: navEntranceDelay || undefined,
354
- nav_entrance_disabled: navEntranceDisabled || undefined,
352
+ nav_entrance_duration: navEntranceDuration ?? undefined,
353
+ nav_entrance_delay: navEntranceDelay ?? undefined,
354
+ nav_entrance_disabled: navEntranceDisabled ?? undefined,
355
355
  }
356
356
  : undefined,
357
357
  draft_mode: state.draftMode,
@@ -4,6 +4,7 @@ import { isPageSectionV2, isParallaxGroup, isCoverSection } from "../../lib/sani
4
4
  import { createDefaultBlock } from "./defaults";
5
5
  import { generateKey } from "./utils";
6
6
  import { findSectionPath, updateSectionAtPath, moveBlockInState } from "./store-helpers";
7
+ import { pushSnapshot } from "./history";
7
8
 
8
9
  type StoreSet = (
9
10
  partial: Partial<BuilderState> | ((state: BuilderState) => Partial<BuilderState>)
@@ -129,15 +130,21 @@ export function createBlockActions(set: StoreSet, get: StoreGet) {
129
130
  },
130
131
 
131
132
  moveBlock: (blockKey: string, targetSectionKey: string, targetColumnKey: string, toIndex: number): void => {
132
- // RC-001 fix: Validate first without side effects, then push snapshot
133
- // BEFORE applying mutation. This ensures undo state captures the
134
- // pre-mutation rows, even if another mutation races in between.
135
- const currentRows = get().rows;
136
- const result = moveBlockInState(currentRows, blockKey, targetSectionKey, targetColumnKey, toIndex);
137
- if (!result) return;
138
-
139
- get()._pushSnapshot();
140
- set({ rows: result.rows, isDirty: true });
133
+ // Do read+validate+snapshot+set atomically inside a single functional
134
+ // update so the snapshot can never drift from the rows the move was
135
+ // computed against. Previously the snapshot was captured via get() AFTER
136
+ // the read, leaving a window where a concurrent mutation could make the
137
+ // undo target inconsistent with the move that just landed.
138
+ set((state) => {
139
+ const result = moveBlockInState(state.rows, blockKey, targetSectionKey, targetColumnKey, toIndex);
140
+ if (!result) return state;
141
+ return {
142
+ rows: result.rows,
143
+ isDirty: true,
144
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
145
+ _future: [],
146
+ };
147
+ });
141
148
  },
142
149
 
143
150
  reorderBlocks: (sectionKey: string, columnKey: string, fromIndex: number, toIndex: number): void => {