@morphika/andami 0.5.2 → 0.5.3

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 (65) hide show
  1. package/README.md +27 -2
  2. package/app/admin/layout.tsx +26 -14
  3. package/app/admin/pages/[slug]/page.tsx +39 -22
  4. package/app/admin/pages/page.tsx +13 -8
  5. package/app/admin/projects/page.tsx +17 -8
  6. package/app/api/admin/assets/register/route.ts +51 -14
  7. package/app/api/admin/assets/registry/route.ts +4 -1
  8. package/app/api/admin/assets/relink/confirm/route.ts +4 -1
  9. package/app/api/admin/assets/relink/route.ts +4 -1
  10. package/app/api/admin/assets/scan/route.ts +4 -1
  11. package/app/api/admin/backups/restore-data/route.ts +4 -1
  12. package/app/api/admin/r2/connect/route.ts +4 -1
  13. package/app/api/admin/r2/delete/route.ts +4 -1
  14. package/app/api/admin/r2/rename/route.ts +4 -1
  15. package/app/api/admin/r2/upload-url/route.ts +4 -1
  16. package/app/api/admin/revalidate/route.ts +4 -1
  17. package/app/api/admin/storage/switch/route.ts +4 -1
  18. package/app/api/custom-sections/[id]/route.ts +5 -6
  19. package/components/admin/PublishToggle.tsx +2 -2
  20. package/components/admin/nav-builder/NavGridItem.tsx +4 -2
  21. package/components/admin/nav-builder/NavSettingsFields.tsx +10 -6
  22. package/components/admin/styles/ColorsEditor.tsx +7 -6
  23. package/components/admin/styles/FontsEditor.tsx +3 -1
  24. package/components/blocks/CoverSectionRenderer.tsx +7 -1
  25. package/components/blocks/SectionV2Renderer.tsx +8 -1
  26. package/components/builder/BubbleIcons.tsx +14 -0
  27. package/components/builder/CanvasMinimap.tsx +66 -49
  28. package/components/builder/CanvasToolbar.tsx +31 -41
  29. package/components/builder/SectionEditorBar.tsx +4 -2
  30. package/components/builder/SectionTypePicker.tsx +4 -2
  31. package/components/builder/SectionV2Column.tsx +13 -1
  32. package/components/builder/SettingsPanel.tsx +21 -17
  33. package/components/builder/SortableBlock.tsx +2 -2
  34. package/components/builder/SortableRow.tsx +6 -9
  35. package/components/builder/VirtualAssetGrid.tsx +8 -2
  36. package/components/builder/asset-browser/R2BrowserContent.tsx +8 -4
  37. package/components/builder/color-picker/EyedropperButton.tsx +7 -6
  38. package/components/builder/color-picker/SwatchBar.tsx +11 -6
  39. package/components/builder/color-picker/UnifiedColorPicker.tsx +11 -6
  40. package/components/builder/editors/ImageGridBlockEditor.tsx +4 -2
  41. package/components/builder/editors/MarqueeBlockEditor.tsx +3 -2
  42. package/components/builder/editors/ProjectGridEditor.tsx +12 -7
  43. package/components/builder/editors/SpacerBlockEditor.tsx +25 -23
  44. package/components/builder/editors/TextBlockEditor.tsx +19 -14
  45. package/components/builder/editors/shared.tsx +4 -2
  46. package/components/builder/live-preview/LiveImagePreview.tsx +3 -1
  47. package/components/builder/live-preview/ProjectCardWrapper.tsx +3 -1
  48. package/components/builder/live-preview/RichTextBubbleMenu.tsx +10 -6
  49. package/components/builder/live-preview/shared.tsx +5 -2
  50. package/components/builder/settings-panel/BlockLayoutTab.tsx +4 -2
  51. package/components/builder/settings-panel/ColumnV2LayoutTab.tsx +242 -0
  52. package/components/builder/settings-panel/CoverSectionSettings.tsx +4 -2
  53. package/components/builder/settings-panel/SectionV2Settings.tsx +13 -8
  54. package/components/builder/settings-panel/index.ts +1 -0
  55. package/lib/builder/serializer/normalizers.ts +14 -0
  56. package/lib/builder/serializer/serializers.ts +27 -0
  57. package/lib/builder/store-sections.ts +21 -0
  58. package/lib/builder/types-slices.ts +14 -0
  59. package/lib/sanity/queries.ts +48 -0
  60. package/lib/sanity/types.ts +14 -0
  61. package/lib/version.ts +1 -1
  62. package/package.json +7 -5
  63. package/sanity/schemas/objects/coverSection.ts +32 -0
  64. package/sanity/schemas/objects/parallaxSlide.ts +32 -0
  65. package/sanity/schemas/pageSectionV2.ts +32 -0
@@ -14,6 +14,7 @@ import {
14
14
  useActiveViewport,
15
15
  INPUT_CLASS,
16
16
  } from "./shared";
17
+ import { BubbleTooltip } from "../BubbleIcons";
17
18
 
18
19
  interface Props {
19
20
  block: SpacerBlock;
@@ -79,29 +80,30 @@ export default function SpacerBlockEditor({ block }: Props) {
79
80
  onReset={() => resetOverride("height")}
80
81
  >
81
82
  <div className="flex gap-1">
82
- {HEIGHT_PRESETS.map((preset) => (
83
- <button
84
- key={preset.value}
85
- onClick={() => updateResponsive("height", preset.value)}
86
- className={`flex-1 rounded border py-1.5 text-xs transition-colors flex flex-col items-center gap-0.5 ${
87
- effectiveHeight === preset.value
88
- ? "border-[#3580f9] bg-[#3580f9]/20 text-neutral-900"
89
- : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
90
- }`}
91
- title={
92
- preset.value === "custom"
93
- ? "Custom height"
94
- : `${preset.px}px`
95
- }
96
- >
97
- <span>{preset.label}</span>
98
- {preset.value !== "custom" && (
99
- <span className="text-[8px] text-neutral-600">
100
- {preset.px}px
101
- </span>
102
- )}
103
- </button>
104
- ))}
83
+ {HEIGHT_PRESETS.map((preset) => {
84
+ const tooltip =
85
+ preset.value === "custom" ? "Custom height" : `${preset.px}px`;
86
+ return (
87
+ <button
88
+ key={preset.value}
89
+ onClick={() => updateResponsive("height", preset.value)}
90
+ className={`group/bb relative flex-1 rounded border py-1.5 text-xs transition-colors flex flex-col items-center gap-0.5 ${
91
+ effectiveHeight === preset.value
92
+ ? "border-[#3580f9] bg-[#3580f9]/20 text-neutral-900"
93
+ : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
94
+ }`}
95
+ aria-label={tooltip}
96
+ >
97
+ <span>{preset.label}</span>
98
+ {preset.value !== "custom" && (
99
+ <span className="text-[8px] text-neutral-600">
100
+ {preset.px}px
101
+ </span>
102
+ )}
103
+ <BubbleTooltip>{tooltip}</BubbleTooltip>
104
+ </button>
105
+ );
106
+ })}
105
107
  </div>
106
108
  </ResponsiveField>
107
109
 
@@ -30,6 +30,7 @@ import {
30
30
  AlignRightIcon,
31
31
  AlignJustifyIcon,
32
32
  } from "./TextAlignmentIcons";
33
+ import { BubbleTooltip } from "../BubbleIcons";
33
34
 
34
35
  // ============================================
35
36
  // Responsive style field — MUST be defined outside the editor component
@@ -292,20 +293,24 @@ export default function TextBlockEditor({ block }: { block: TextBlock }) {
292
293
 
293
294
  <ResponsiveStyleField label="Align" subProp="alignment" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("alignment")} onReset={resetStyleOverride}>
294
295
  <div className="flex gap-0.5 bg-[#f5f5f5] rounded-lg p-0.5">
295
- {alignments.map(({ value, icon }) => (
296
- <button
297
- key={value}
298
- onClick={() => updateStyleResponsive("alignment", value)}
299
- className={`flex-1 flex items-center justify-center py-[5px] rounded-md transition-all ${
300
- currentAlignment === value
301
- ? "bg-white text-neutral-900 shadow-[0_1px_3px_rgba(0,0,0,0.08)]"
302
- : "text-neutral-300 hover:text-neutral-500"
303
- }`}
304
- title={value.charAt(0).toUpperCase() + value.slice(1)}
305
- >
306
- {icon}
307
- </button>
308
- ))}
296
+ {alignments.map(({ value, icon }) => {
297
+ const label = value.charAt(0).toUpperCase() + value.slice(1);
298
+ return (
299
+ <button
300
+ key={value}
301
+ onClick={() => updateStyleResponsive("alignment", value)}
302
+ className={`group/bb relative flex-1 flex items-center justify-center py-[5px] rounded-md transition-all ${
303
+ currentAlignment === value
304
+ ? "bg-white text-neutral-900 shadow-[0_1px_3px_rgba(0,0,0,0.08)]"
305
+ : "text-neutral-300 hover:text-neutral-500"
306
+ }`}
307
+ aria-label={label}
308
+ >
309
+ {icon}
310
+ <BubbleTooltip>{label}</BubbleTooltip>
311
+ </button>
312
+ );
313
+ })}
309
314
  </div>
310
315
  </ResponsiveStyleField>
311
316
 
@@ -6,6 +6,7 @@ import { hasOverride } from "../../../lib/builder/responsive";
6
6
  import type { DeviceViewport } from "../../../lib/builder/types";
7
7
  import type { ContentBlock } from "../../../lib/sanity/types";
8
8
  import AssetBrowser from "../AssetBrowser";
9
+ import { BubbleTooltip } from "../BubbleIcons";
9
10
 
10
11
  // ============================================
11
12
  // Shared CSS classes — Framer-style design system
@@ -338,10 +339,11 @@ export function AssetPathInput({
338
339
  <button
339
340
  type="button"
340
341
  onClick={() => setBrowserOpen(true)}
341
- className="shrink-0 rounded-lg bg-[#f5f5f5] px-2.5 py-[7px] text-[11px] text-neutral-500 hover:text-neutral-900 hover:bg-[#efefef] transition-colors"
342
- title="Browse assets"
342
+ className="group/bb relative shrink-0 rounded-lg bg-[#f5f5f5] px-2.5 py-[7px] text-[11px] text-neutral-500 hover:text-neutral-900 hover:bg-[#efefef] transition-colors"
343
+ aria-label="Browse assets"
343
344
  >
344
345
  Browse
346
+ <BubbleTooltip>Browse assets</BubbleTooltip>
345
347
  </button>
346
348
  </div>
347
349
  <AssetBrowser
@@ -4,6 +4,7 @@ import { useState } from "react";
4
4
  import { adminAssetUrl, adminThumbUrl } from "../../../lib/assets";
5
5
  import { ThumbBadge } from "./shared";
6
6
  import type { ImageBlock } from "../../../lib/sanity/types";
7
+ import { BubbleTooltip } from "../BubbleIcons";
7
8
 
8
9
  export default function LiveImagePreview({ block }: { block: ImageBlock }) {
9
10
  const [imgError, setImgError] = useState(false);
@@ -81,8 +82,9 @@ export default function LiveImagePreview({ block }: { block: ImageBlock }) {
81
82
  >
82
83
  <span className="text-red-400 text-lg">{"\u26A0"}</span>
83
84
  <span className="text-red-400 text-xs">Image failed to load</span>
84
- <span className="text-red-300 text-[10px] max-w-[200px] truncate" title={block.asset_path}>
85
+ <span className="group/bb relative text-red-300 text-[10px] max-w-[200px] truncate" aria-label={block.asset_path}>
85
86
  {block.asset_path}
87
+ <BubbleTooltip>{block.asset_path}</BubbleTooltip>
86
88
  </span>
87
89
  </div>
88
90
  ) : (
@@ -13,6 +13,7 @@ import { ProjectGridCard } from "./shared";
13
13
  import { useBuilderStore } from "../../../lib/builder/store";
14
14
  import { ADMIN_BLUE, DROP_BLUE, CrossArrowIcon } from "./drag-utils";
15
15
  import type { ProjectGridItem } from "../../../lib/sanity/types";
16
+ import { BubbleTooltip } from "../BubbleIcons";
16
17
 
17
18
  // ─── Props ───────────────────────────────────────────────────────────
18
19
 
@@ -221,6 +222,7 @@ export default function ProjectCardWrapper({
221
222
  <div
222
223
  onPointerDown={handleHandleDown}
223
224
  onClick={(e) => e.stopPropagation()}
225
+ className="group/bb relative"
224
226
  style={{
225
227
  width: 48,
226
228
  height: 48,
@@ -233,10 +235,10 @@ export default function ProjectCardWrapper({
233
235
  cursor: "grab",
234
236
  transform: `scale(${invZoom})`,
235
237
  }}
236
- title="Drag to reorder"
237
238
  aria-label="Drag to reorder project"
238
239
  >
239
240
  <CrossArrowIcon size={22} color="#333" />
241
+ <BubbleTooltip>Drag to reorder</BubbleTooltip>
240
242
  </div>
241
243
  </div>
242
244
 
@@ -12,6 +12,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
12
12
  import { BubbleMenu, type Editor } from "@tiptap/react";
13
13
  import { UnifiedColorPicker } from "../color-picker";
14
14
  import { usePaletteSwatches } from "../ColorSwatchPicker";
15
+ import { BubbleTooltipAbove } from "../BubbleIcons";
15
16
  import type { ColorField } from "../../../lib/sanity/types";
16
17
 
17
18
  // ── Icon components (inline SVGs to avoid external deps) ────────────
@@ -83,9 +84,9 @@ function ToolbarButton({
83
84
  onMouseDown={(e) => e.preventDefault()}
84
85
  onClick={onClick}
85
86
  onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick(); }}
86
- title={title}
87
+ aria-label={title}
87
88
  className={`
88
- flex items-center justify-center w-7 h-7 rounded cursor-pointer select-none
89
+ group/bb relative flex items-center justify-center w-7 h-7 rounded cursor-pointer select-none
89
90
  transition-colors duration-100
90
91
  ${isActive
91
92
  ? "bg-white/20 text-white"
@@ -94,6 +95,7 @@ function ToolbarButton({
94
95
  `}
95
96
  >
96
97
  {children}
98
+ <BubbleTooltipAbove>{title}</BubbleTooltipAbove>
97
99
  </div>
98
100
  );
99
101
  }
@@ -190,11 +192,12 @@ function LinkPopover({
190
192
  tabIndex={0}
191
193
  onClick={handleRemove}
192
194
  onKeyDown={(e) => { if (e.key === "Enter") handleRemove(); }}
193
- title="Remove link"
194
- className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-400/10
195
+ aria-label="Remove link"
196
+ className="group/bb relative p-1.5 text-red-400 hover:text-red-300 hover:bg-red-400/10
195
197
  rounded transition-colors cursor-pointer select-none"
196
198
  >
197
199
  <UnlinkIcon />
200
+ <BubbleTooltipAbove>Remove link</BubbleTooltipAbove>
198
201
  </div>
199
202
  )}
200
203
  </div>
@@ -401,12 +404,13 @@ export default function RichTextBubbleMenu({ editor }: { editor: Editor }) {
401
404
  onMouseDown={(e) => e.preventDefault()}
402
405
  onClick={handleRemoveColor}
403
406
  onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleRemoveColor(); }}
404
- title="Remove color"
405
- className="flex items-center justify-center w-5 h-5 rounded cursor-pointer select-none
407
+ aria-label="Remove color"
408
+ className="group/bb relative flex items-center justify-center w-5 h-5 rounded cursor-pointer select-none
406
409
  text-neutral-400 hover:text-red-400 hover:bg-red-400/10
407
410
  transition-colors duration-100 text-[10px]"
408
411
  >
409
412
 
413
+ <BubbleTooltipAbove>Remove color</BubbleTooltipAbove>
410
414
  </div>
411
415
  )}
412
416
 
@@ -3,6 +3,7 @@
3
3
  import { useState, useEffect } from "react";
4
4
  import { useThumbStatus } from "../../../lib/contexts/ThumbStatusContext";
5
5
  import { adminAssetUrl, adminThumbUrl } from "../../../lib/assets";
6
+ import { BubbleTooltip } from "../BubbleIcons";
6
7
 
7
8
  // ============================================
8
9
  // Thumbnail status badge (builder-only)
@@ -14,10 +15,11 @@ export function ThumbBadge({ assetPath }: { assetPath: string }) {
14
15
  const status = hasThumb(assetPath);
15
16
  // undefined = not raster or unknown — don't show badge
16
17
  if (status === undefined) return null;
18
+ const label = status ? "Thumbnail available" : "No thumbnail — full resolution";
17
19
  return (
18
20
  <span
19
- title={status ? "Thumbnail available" : "No thumbnail — full resolution"}
20
- className="absolute bottom-1.5 right-1.5 z-10 flex items-center justify-center"
21
+ aria-label={label}
22
+ className="group/bb absolute bottom-1.5 right-1.5 z-10 flex items-center justify-center"
21
23
  style={{
22
24
  width: 16,
23
25
  height: 16,
@@ -38,6 +40,7 @@ export function ThumbBadge({ assetPath }: { assetPath: string }) {
38
40
  <rect x="4.25" y="5" width="1.5" height="3" rx="0.5" fill="white" />
39
41
  </svg>
40
42
  )}
43
+ <BubbleTooltip>{label}</BubbleTooltip>
41
44
  </span>
42
45
  );
43
46
  }
@@ -24,6 +24,7 @@ import {
24
24
  import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
25
25
  import { serializeColorField, parseColorField, isGradient } from "../../../lib/color-utils";
26
26
  import { TRBLInputs } from "./TRBLInputs";
27
+ import { BubbleTooltip } from "../BubbleIcons";
27
28
  import {
28
29
  getBlockLayoutValue,
29
30
  hasBlockLayoutOverride,
@@ -129,15 +130,16 @@ function AlignmentButtons<T extends string>({
129
130
  return (
130
131
  <button
131
132
  key={opt.value}
132
- title={opt.label}
133
+ aria-label={opt.label}
133
134
  onClick={() => onChange(opt.value)}
134
- className={`flex items-center justify-center w-[34px] h-[30px] rounded-md border transition-all ${
135
+ className={`group/bb relative flex items-center justify-center w-[34px] h-[30px] rounded-md border transition-all ${
135
136
  isActive
136
137
  ? "bg-[#3580f9]/10 border-[#3580f9]/30 text-[#3580f9]"
137
138
  : "bg-[#f5f5f5] border-transparent text-neutral-400 hover:bg-[#efefef] hover:text-neutral-600"
138
139
  }`}
139
140
  >
140
141
  {renderIcon(opt.value)}
142
+ <BubbleTooltip>{opt.label}</BubbleTooltip>
141
143
  </button>
142
144
  );
143
145
  })}
@@ -0,0 +1,242 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ColumnV2LayoutTab — Layout tab for a selected V2/cover/parallax-slide column.
5
+ *
6
+ * Desktop-only for now. Viewport-aware column layout would require extending
7
+ * ColumnOverride (currently position-only) — not yet implemented.
8
+ *
9
+ * Sections: Background (color/opacity/image), Border (color/width/style/sides/radius).
10
+ * Columns intentionally have no Spacing or Offset — use the parent section's
11
+ * row_gap/col_gap, or block-level padding.
12
+ */
13
+
14
+ import { useBuilderStore } from "../../../lib/builder/store";
15
+ import type { PageSectionV2, SectionColumn } from "../../../lib/sanity/types";
16
+ import {
17
+ SettingsField,
18
+ SettingsSection,
19
+ SELECT_CLASS,
20
+ AssetPathInput,
21
+ } from "../editors/shared";
22
+ import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
23
+ import { serializeColorField, parseColorField, isGradient } from "../../../lib/color-utils";
24
+ import { BackgroundIcon, BorderIcon } from "../editors/section-icons";
25
+
26
+ type LayoutField =
27
+ | "background_color" | "background_opacity" | "background_image"
28
+ | "background_size" | "background_position" | "background_repeat"
29
+ | "border_color" | "border_width" | "border_style" | "border_sides" | "border_radius";
30
+
31
+ export function ColumnV2LayoutTab({
32
+ section,
33
+ column,
34
+ }: {
35
+ section: PageSectionV2;
36
+ column: SectionColumn;
37
+ }) {
38
+ const store = useBuilderStore();
39
+ const paletteSwatches = usePaletteSwatches();
40
+ const activeViewport = store.activeViewport;
41
+
42
+ const isResponsive = activeViewport !== "desktop";
43
+
44
+ const update = (field: LayoutField, value: unknown) => {
45
+ store._pushSnapshot();
46
+ store.updateColumnV2Layout(section._key, column._key, {
47
+ [field]: value,
48
+ } as Partial<SectionColumn>);
49
+ };
50
+
51
+ const getValue = <T,>(field: LayoutField, fallback: T): T => {
52
+ const val = (column as unknown as Record<string, unknown>)[field];
53
+ return (val !== undefined && val !== null ? val : fallback) as T;
54
+ };
55
+
56
+ const handleBgPreview = (val: import("../../../lib/sanity/types").ColorField) => {
57
+ store.setColorPickerPreview({ sectionKey: section._key, field: "column_background_color", value: val });
58
+ };
59
+
60
+ const bgIsGradient = isGradient(parseColorField(getValue<string>("background_color", "")));
61
+
62
+ return (
63
+ <>
64
+ {isResponsive && (
65
+ <div className="px-4 pt-3">
66
+ <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-neutral-100 border border-neutral-200">
67
+ <span className="text-[11px] font-medium text-neutral-500">
68
+ Column layout is desktop-only for now
69
+ </span>
70
+ </div>
71
+ </div>
72
+ )}
73
+
74
+ {/* Background */}
75
+ <SettingsSection title="Background" defaultOpen icon={<BackgroundIcon />}>
76
+ <SettingsField label="Color">
77
+ <ColorSwatchPicker
78
+ value={parseColorField(getValue<string>("background_color", ""))}
79
+ onChange={(val) => {
80
+ store.clearColorPickerPreview();
81
+ update("background_color", serializeColorField(val));
82
+ }}
83
+ swatches={paletteSwatches}
84
+ allowGradients
85
+ onPreview={handleBgPreview}
86
+ />
87
+ </SettingsField>
88
+
89
+ <SettingsField label="Opacity">
90
+ <div className="flex items-center gap-2">
91
+ <input
92
+ type="range"
93
+ min={0}
94
+ max={100}
95
+ value={getValue<number>("background_opacity", 100)}
96
+ onChange={(e) => update("background_opacity", parseInt(e.target.value))}
97
+ className={`flex-1 accent-[#3580f9] ${bgIsGradient ? "opacity-40 pointer-events-none" : ""}`}
98
+ disabled={bgIsGradient}
99
+ />
100
+ <span className="text-xs text-neutral-900 w-10 text-right">
101
+ {getValue<number>("background_opacity", 100)}%
102
+ </span>
103
+ </div>
104
+ {bgIsGradient && (
105
+ <p className="text-[9px] text-neutral-400 italic mt-1">
106
+ Opacity is controlled per stop in gradient mode
107
+ </p>
108
+ )}
109
+ </SettingsField>
110
+
111
+ <SettingsField label="Image">
112
+ <AssetPathInput
113
+ value={getValue<string>("background_image", "")}
114
+ onFocus={() => store._pushSnapshot()}
115
+ onChange={(v) => update("background_image", v)}
116
+ placeholder="path/to/image.jpg"
117
+ filterType="image"
118
+ />
119
+ </SettingsField>
120
+
121
+ {getValue<string>("background_image", "") && (
122
+ <>
123
+ <SettingsField label="Size">
124
+ <select
125
+ value={getValue<string>("background_size", "cover")}
126
+ onChange={(e) => update("background_size", e.target.value)}
127
+ className={SELECT_CLASS}
128
+ >
129
+ <option value="cover">Cover</option>
130
+ <option value="contain">Contain</option>
131
+ <option value="auto">Auto</option>
132
+ </select>
133
+ </SettingsField>
134
+
135
+ <SettingsField label="Position">
136
+ <select
137
+ value={getValue<string>("background_position", "center center")}
138
+ onChange={(e) => update("background_position", e.target.value)}
139
+ className={SELECT_CLASS}
140
+ >
141
+ <option value="center center">Center</option>
142
+ <option value="top center">Top</option>
143
+ <option value="bottom center">Bottom</option>
144
+ <option value="left center">Left</option>
145
+ <option value="right center">Right</option>
146
+ </select>
147
+ </SettingsField>
148
+
149
+ <SettingsField label="Repeat">
150
+ <select
151
+ value={getValue<string>("background_repeat", "no-repeat")}
152
+ onChange={(e) => update("background_repeat", e.target.value)}
153
+ className={SELECT_CLASS}
154
+ >
155
+ <option value="no-repeat">No Repeat</option>
156
+ <option value="repeat">Repeat</option>
157
+ <option value="repeat-x">Repeat X</option>
158
+ <option value="repeat-y">Repeat Y</option>
159
+ </select>
160
+ </SettingsField>
161
+ </>
162
+ )}
163
+ </SettingsSection>
164
+
165
+ {/* Border */}
166
+ <SettingsSection title="Border" icon={<BorderIcon />}>
167
+ <SettingsField label="Color">
168
+ <ColorSwatchPicker
169
+ value={parseColorField(getValue<string>("border_color", ""))}
170
+ onChange={(val) => {
171
+ store.clearColorPickerPreview();
172
+ update("border_color", serializeColorField(val));
173
+ }}
174
+ swatches={paletteSwatches}
175
+ allowGradients
176
+ />
177
+ </SettingsField>
178
+
179
+ <SettingsField label="Width">
180
+ <div className="flex items-center gap-2">
181
+ <input
182
+ type="range"
183
+ min={0}
184
+ max={20}
185
+ value={parseInt(getValue<string>("border_width", "0"))}
186
+ onChange={(e) => update("border_width", e.target.value)}
187
+ className="flex-1 accent-[#3580f9]"
188
+ />
189
+ <span className="text-xs text-neutral-900 w-10 text-right">
190
+ {getValue<string>("border_width", "0")}px
191
+ </span>
192
+ </div>
193
+ </SettingsField>
194
+
195
+ <SettingsField label="Style">
196
+ <select
197
+ value={getValue<string>("border_style", "none")}
198
+ onChange={(e) => update("border_style", e.target.value)}
199
+ className={SELECT_CLASS}
200
+ >
201
+ <option value="none">None</option>
202
+ <option value="solid">Solid</option>
203
+ <option value="dashed">Dashed</option>
204
+ <option value="dotted">Dotted</option>
205
+ </select>
206
+ </SettingsField>
207
+
208
+ <SettingsField label="Sides">
209
+ <select
210
+ value={getValue<string>("border_sides", "all")}
211
+ onChange={(e) => update("border_sides", e.target.value)}
212
+ className={SELECT_CLASS}
213
+ >
214
+ <option value="all">All</option>
215
+ <option value="top">Top</option>
216
+ <option value="right">Right</option>
217
+ <option value="bottom">Bottom</option>
218
+ <option value="left">Left</option>
219
+ <option value="top-bottom">Top & Bottom</option>
220
+ <option value="left-right">Left & Right</option>
221
+ </select>
222
+ </SettingsField>
223
+
224
+ <SettingsField label="Radius">
225
+ <div className="flex items-center gap-2">
226
+ <input
227
+ type="range"
228
+ min={0}
229
+ max={50}
230
+ value={parseInt(getValue<string>("border_radius", "0"))}
231
+ onChange={(e) => update("border_radius", e.target.value)}
232
+ className="flex-1 accent-[#3580f9]"
233
+ />
234
+ <span className="text-xs text-neutral-900 w-10 text-right">
235
+ {getValue<string>("border_radius", "0")}px
236
+ </span>
237
+ </div>
238
+ </SettingsField>
239
+ </SettingsSection>
240
+ </>
241
+ );
242
+ }
@@ -34,6 +34,7 @@ import {
34
34
  import { AssetPathInput } from "../editors/shared";
35
35
  import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
36
36
  import StaggerSettings from "../editors/StaggerSettings";
37
+ import { BubbleTooltip } from "../BubbleIcons";
37
38
 
38
39
  const BG_POSITION_OPTIONS = [
39
40
  { value: "center center", label: "Center" },
@@ -264,10 +265,11 @@ export default function CoverSectionSettings({ section }: CoverSectionSettingsPr
264
265
  {section.cover_rows.length > 1 && (
265
266
  <button
266
267
  onClick={() => store.removeCoverRow(section._key, row._key)}
267
- className="text-neutral-300 hover:text-red-500 transition-colors text-xs shrink-0"
268
- title="Remove row"
268
+ className="group/bb relative text-neutral-300 hover:text-red-500 transition-colors text-xs shrink-0"
269
+ aria-label="Remove row"
269
270
  >
270
271
 
272
+ <BubbleTooltip>Remove row</BubbleTooltip>
271
273
  </button>
272
274
  )}
273
275
  </div>
@@ -28,6 +28,7 @@ import {
28
28
  buildStackOverride,
29
29
  buildColumnV2Overrides,
30
30
  } from "./responsive-helpers";
31
+ import { BubbleTooltip } from "../BubbleIcons";
31
32
 
32
33
  // ============================================
33
34
  // Preset definitions for the picker grid
@@ -99,14 +100,14 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
99
100
  <button
100
101
  key={preset.id}
101
102
  onClick={() => !isCustom && applyPresetV2(section._key, preset.id)}
102
- className={`flex flex-col items-center gap-1 p-2 rounded-lg border transition-all ${
103
+ className={`group/bb relative flex flex-col items-center gap-1 p-2 rounded-lg border transition-all ${
103
104
  isActive
104
105
  ? "border-[#3580f9] bg-[#3580f9]/5"
105
106
  : isCustom
106
107
  ? "border-neutral-200 bg-neutral-50 opacity-60 cursor-default"
107
108
  : "border-neutral-200 bg-white hover:border-neutral-300 hover:bg-neutral-50"
108
109
  }`}
109
- title={isCustom ? "Layout doesn't match any preset" : preset.label}
110
+ aria-label={isCustom ? "Layout doesn't match any preset" : preset.label}
110
111
  >
111
112
  {/* Visual representation */}
112
113
  <div className="flex gap-0.5 w-full h-4">
@@ -133,6 +134,7 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
133
134
  }`}>
134
135
  {preset.label}
135
136
  </span>
137
+ <BubbleTooltip>{isCustom ? "Layout doesn't match any preset" : preset.label}</BubbleTooltip>
136
138
  </button>
137
139
  );
138
140
  })}
@@ -140,8 +142,8 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
140
142
  {/* + Add Column button */}
141
143
  <button
142
144
  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-[#3580f9] hover:bg-[#3580f9]/5 group"
144
- title="Add a column (fills first gap, or adds new row below)"
145
+ className="group/bb relative flex flex-col items-center gap-1 p-2 rounded-lg border border-dashed border-neutral-300 transition-all hover:border-[#3580f9] hover:bg-[#3580f9]/5 group"
146
+ aria-label="Add a column (fills first gap, or adds new row below)"
145
147
  >
146
148
  <div className="flex items-center justify-center w-full h-4">
147
149
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-neutral-400 group-hover:text-[#3580f9] transition-colors">
@@ -151,6 +153,7 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
151
153
  <span className="text-[9px] font-medium text-neutral-400 group-hover:text-[#3580f9] transition-colors">
152
154
  Add Col
153
155
  </span>
156
+ <BubbleTooltip>Add a column (fills first gap, or adds new row below)</BubbleTooltip>
154
157
  </button>
155
158
  </div>
156
159
  );
@@ -218,22 +221,24 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
218
221
  <div className="flex gap-2">
219
222
  <button
220
223
  onClick={handleStack}
221
- className="flex-1 rounded-lg bg-[#3580f9]/8 border border-[#3580f9]/20 py-2 text-xs font-medium text-[#3580f9] hover:bg-[#3580f9]/15 transition-colors"
222
- title="Stack all columns vertically (full width, one per row)"
224
+ className="group/bb relative flex-1 rounded-lg bg-[#3580f9]/8 border border-[#3580f9]/20 py-2 text-xs font-medium text-[#3580f9] hover:bg-[#3580f9]/15 transition-colors"
225
+ aria-label="Stack all columns vertically (full width, one per row)"
223
226
  >
224
227
  Stack Columns
228
+ <BubbleTooltip>Stack all columns vertically (full width, one per row)</BubbleTooltip>
225
229
  </button>
226
230
  <button
227
231
  onClick={handleReset}
228
232
  disabled={!hasAnyOverrides}
229
- className={`flex-1 rounded-lg border py-2 text-xs font-medium transition-colors ${
233
+ className={`group/bb relative flex-1 rounded-lg border py-2 text-xs font-medium transition-colors ${
230
234
  hasAnyOverrides
231
235
  ? "bg-neutral-100 border-neutral-200 text-neutral-600 hover:bg-neutral-200"
232
236
  : "bg-neutral-50 border-neutral-100 text-neutral-300 cursor-not-allowed"
233
237
  }`}
234
- title="Reset all responsive overrides for this viewport"
238
+ aria-label="Reset all responsive overrides for this viewport"
235
239
  >
236
240
  Reset Overrides
241
+ <BubbleTooltip>Reset all responsive overrides for this viewport</BubbleTooltip>
237
242
  </button>
238
243
  </div>
239
244
  {hasAnyOverrides && (
@@ -21,5 +21,6 @@ export { useSettingsPanelSelection } from "./useSettingsPanelSelection";
21
21
  export type { SelectedBlockInfo, SelectedParallaxSlideInfo } from "./useSettingsPanelSelection";
22
22
  export { AnimationTab, getBlockHoverEffect } from "./AnimationTab";
23
23
  export { ColumnV2AnimationTab } from "./ColumnV2AnimationTab";
24
+ export { ColumnV2LayoutTab } from "./ColumnV2LayoutTab";
24
25
  export { CardEntranceSection, ENTRANCE_PRESETS, CARD_ENTRANCE_SELECT_CLASS, CARD_ENTRANCE_SLIDER_CLASS } from "./CardEntranceSection";
25
26
  export { CustomSectionSettings } from "./CustomSectionSettings";