@morphika/andami 0.5.2 → 0.5.4

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 (68) 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/components/ui/NavContentLightbox.tsx +41 -4
  56. package/lib/builder/serializer/normalizers.ts +14 -0
  57. package/lib/builder/serializer/serializers.ts +27 -0
  58. package/lib/builder/store-blocks.ts +15 -5
  59. package/lib/builder/store-cover.ts +16 -6
  60. package/lib/builder/store-sections.ts +151 -51
  61. package/lib/builder/types-slices.ts +14 -0
  62. package/lib/sanity/queries.ts +48 -0
  63. package/lib/sanity/types.ts +14 -0
  64. package/lib/version.ts +1 -1
  65. package/package.json +7 -5
  66. package/sanity/schemas/objects/coverSection.ts +32 -0
  67. package/sanity/schemas/objects/parallaxSlide.ts +32 -0
  68. package/sanity/schemas/pageSectionV2.ts +32 -0
@@ -4,6 +4,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
4
4
  import { useBuilderStore } from "../../lib/builder/store";
5
5
  import { getCsrfToken } from "../../lib/csrf-client";
6
6
  import { ADMIN_ACCENT } from "../../lib/builder/constants";
7
+ import { BubbleTooltip } from "./BubbleIcons";
7
8
 
8
9
  // ============================================
9
10
  // SectionEditorBar — Top bar for custom section editor mode
@@ -134,10 +135,11 @@ export default function SectionEditorBar({ onSaveComplete }: SectionEditorBarPro
134
135
  <span className="text-[#444]">/</span>
135
136
  <button
136
137
  onClick={handleCancel}
137
- className="text-[#666] hover:text-[#aaa] transition-colors cursor-pointer truncate max-w-[140px]"
138
- title={`Back to ${pageTitle || "page"}`}
138
+ className="group/bb relative text-[#666] hover:text-[#aaa] transition-colors cursor-pointer truncate max-w-[140px]"
139
+ aria-label={`Back to ${pageTitle || "page"}`}
139
140
  >
140
141
  {pageTitle || "Page"}
142
+ <BubbleTooltip>{`Back to ${pageTitle || "page"}`}</BubbleTooltip>
141
143
  </button>
142
144
  <span className="text-[#444]">/</span>
143
145
  <span className="text-[#999] font-medium">
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
4
4
  import { SECTION_TYPE_REGISTRY, type SectionBlockType } from "../../lib/builder/types";
5
5
  import type { CustomSectionListItem } from "../../lib/sanity/types";
6
6
  import { SECTION_CARD_ICONS } from "./SectionCardIcons";
7
+ import { BubbleTooltip } from "./BubbleIcons";
7
8
 
8
9
  // ── V2 layout presets (use cascade preset names) ──
9
10
  type V2Preset = "full" | "halves" | "thirds" | "quarters" | "1/3+2/3" | "2/3+1/3";
@@ -212,8 +213,8 @@ export default function SectionTypePicker({
212
213
  }
213
214
  onClose();
214
215
  }}
215
- className="rounded-xl border border-neutral-200 bg-white p-3 hover:border-[#3580f9] hover:bg-[#3580f9]/5 transition-colors group shadow-sm"
216
- title={label}
216
+ className="group/bb relative rounded-xl border border-neutral-200 bg-white p-3 hover:border-[#3580f9] hover:bg-[#3580f9]/5 transition-colors group shadow-sm"
217
+ aria-label={label}
217
218
  >
218
219
  <div className="flex gap-1 h-6">
219
220
  {widths.map((w, i) => (
@@ -227,6 +228,7 @@ export default function SectionTypePicker({
227
228
  <p className="text-xs text-neutral-500 mt-1.5 group-hover:text-neutral-700">
228
229
  {label}
229
230
  </p>
231
+ <BubbleTooltip>{label}</BubbleTooltip>
230
232
  </button>
231
233
  ))}
232
234
  </div>
@@ -9,7 +9,11 @@ import {
9
9
  import { useBuilderStore } from "../../lib/builder/store";
10
10
  import { makeBlockId, makeColumnDroppableId } from "./DndWrapper";
11
11
  import type { SectionColumn, ContentBlock, PageSectionV2 } from "../../lib/sanity/types";
12
- import { getColumnVerticalAlign } from "../../lib/builder/layout-styles";
12
+ import {
13
+ getColumnVerticalAlign,
14
+ getBackgroundStyles,
15
+ getBorderStyles,
16
+ } from "../../lib/builder/layout-styles";
13
17
  import { isSectionBlockType } from "../../lib/builder/types";
14
18
  import { BUILDER_BLUE } from "../../lib/builder/constants";
15
19
  import { BubbleTooltip, CloseIcon, DragDropIcon } from "./BubbleIcons";
@@ -230,6 +234,12 @@ export default function SectionV2Column({
230
234
  // Column-level vertical alignment from blocks' align_v settings
231
235
  const colJustify = getColumnVerticalAlign(column.blocks || []);
232
236
 
237
+ // Column-level background + border (desktop-only — no responsive overrides).
238
+ const columnLayoutStyles: React.CSSProperties = {
239
+ ...getBackgroundStyles(column),
240
+ ...getBorderStyles(column),
241
+ };
242
+
233
243
  // ---- Preview mode ----
234
244
  if (previewMode) {
235
245
  return (
@@ -244,6 +254,7 @@ export default function SectionV2Column({
244
254
  ...(colJustify ? { justifyContent: colJustify } : {}),
245
255
  height: "100%",
246
256
  minHeight: 0,
257
+ ...columnLayoutStyles,
247
258
  }}
248
259
  >
249
260
  <SortableContext items={blockIds} strategy={verticalListSortingStrategy}>
@@ -280,6 +291,7 @@ export default function SectionV2Column({
280
291
  transition: isDraggedColumn
281
292
  ? "none"
282
293
  : "opacity 150ms, box-shadow 150ms, transform 150ms ease-out",
294
+ ...columnLayoutStyles,
283
295
  }}
284
296
  ref={setBlockDropRef}
285
297
  onClick={handleClick}
@@ -25,6 +25,7 @@ import { useBuilderStore } from "../../lib/builder/store";
25
25
  import { useSettingsPanelSelection } from "./settings-panel/useSettingsPanelSelection";
26
26
  import { AnimationTab } from "./settings-panel/AnimationTab";
27
27
  import { ColumnV2AnimationTab } from "./settings-panel/ColumnV2AnimationTab";
28
+ import { ColumnV2LayoutTab } from "./settings-panel/ColumnV2LayoutTab";
28
29
  import { CustomSectionSettings } from "./settings-panel/CustomSectionSettings";
29
30
  import {
30
31
  BlockLayoutTab,
@@ -40,6 +41,7 @@ import {
40
41
  CoverSectionSettings,
41
42
  } from "./settings-panel";
42
43
  import CoverSectionLayoutTab from "./settings-panel/CoverSectionLayoutTab";
44
+ import { BubbleTooltip } from "./BubbleIcons";
43
45
 
44
46
  type SettingsTab = "settings" | "layout" | "seo" | "animation";
45
47
 
@@ -75,11 +77,11 @@ export default function SettingsPanel() {
75
77
  }
76
78
  }, [selectionKey]);
77
79
 
78
- // Columns have Settings + Animation — fall back if Layout or SEO tab was active
80
+ // Columns have Settings + Layout + Animation — fall back if SEO tab was active
79
81
  // Parallax group header has only Settings — fall back if Layout/SEO or Animation tab was active
80
82
  // Page level has Settings + SEO + Animation — fall back if Layout tab was active
81
83
  useEffect(() => {
82
- if (isColumnOnly && (activeTab === "layout" || activeTab === "seo")) {
84
+ if (isColumnOnly && activeTab === "seo") {
83
85
  setActiveTab("settings");
84
86
  }
85
87
  if (isParallaxGroupOnly && (activeTab === "layout" || activeTab === "seo" || activeTab === "animation")) {
@@ -134,13 +136,14 @@ export default function SettingsPanel() {
134
136
  return (
135
137
  <button
136
138
  onClick={onDelete}
137
- className="p-1.5 rounded-md hover:bg-red-500/20 transition-colors group"
138
- title={deleteTitle}
139
+ className="group/bb relative p-1.5 rounded-md hover:bg-red-500/20 transition-colors"
140
+ aria-label={deleteTitle}
139
141
  >
140
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-black/40 group-hover:text-[var(--admin-error)] transition-colors">
142
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-black/40 group-hover/bb:text-[var(--admin-error)] transition-colors">
141
143
  <polyline points="3 6 5 6 21 6" />
142
144
  <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
143
145
  </svg>
146
+ <BubbleTooltip>{deleteTitle}</BubbleTooltip>
144
147
  </button>
145
148
  );
146
149
  })()}
@@ -204,11 +207,6 @@ export default function SettingsPanel() {
204
207
  });
205
208
  }
206
209
  }
207
- // Columns: remove Layout tab (keep Settings + Animation)
208
- if (isColumnOnly) {
209
- const layoutIdx = tabs.findIndex((t) => t.id === "layout");
210
- if (layoutIdx >= 0) tabs.splice(layoutIdx, 1);
211
- }
212
210
  // Parallax group header: only Settings (no Layout, no Animation)
213
211
  if (isParallaxGroupOnly) {
214
212
  const layoutIdx = tabs.findIndex((t) => t.id === "layout");
@@ -268,9 +266,11 @@ export default function SettingsPanel() {
268
266
  {selectedParallaxGroup && !selectedParallaxSlide && !selectedBlock ? (
269
267
  <ParallaxGroupSettings group={selectedParallaxGroup} />
270
268
  ) : selectedParallaxSlide && selectedColumnV2 && !selectedBlock ? (
271
- // Column inside a parallax slide — Settings or Animation tab
269
+ // Column inside a parallax slide — Settings / Layout / Animation
272
270
  activeTab === "animation" ? (
273
271
  <ColumnV2AnimationTab section={selectedParallaxSlide.virtualSection} column={selectedColumnV2} />
272
+ ) : activeTab === "layout" ? (
273
+ <ColumnV2LayoutTab section={selectedParallaxSlide.virtualSection} column={selectedColumnV2} />
274
274
  ) : (
275
275
  <ColumnV2Settings section={selectedParallaxSlide.virtualSection} column={selectedColumnV2} />
276
276
  )
@@ -320,15 +320,19 @@ export default function SettingsPanel() {
320
320
  <CoverSectionSettings section={selectedCoverSection} />
321
321
  )
322
322
  ) :
323
- /* ---- V2 Section / Column / Block routing ---- */
323
+ /* ---- V2 / Cover Section / Column / Block routing ---- */
324
324
  /* BUG-V2-003 fix: When a block inside a V2 column is selected, show BlockSettings
325
- instead of ColumnV2Settings. Block selection takes priority over column. */
326
- selectedColumnV2 && selectedSectionV2 && !selectedBlock ? (
327
- // V2 Column selected (no block) Settings or Animation tab
325
+ instead of ColumnV2Settings. Block selection takes priority over column.
326
+ Cover-column fix: use effectiveSectionV2 so columns inside a cover section
327
+ route to ColumnV2Settings instead of falling through to PageSettings. */
328
+ selectedColumnV2 && effectiveSectionV2 && !selectedBlock ? (
329
+ // V2 Column or Cover Section column selected (no block) — Settings / Layout / Animation
328
330
  activeTab === "animation" ? (
329
- <ColumnV2AnimationTab section={selectedSectionV2} column={selectedColumnV2} />
331
+ <ColumnV2AnimationTab section={effectiveSectionV2} column={selectedColumnV2} />
332
+ ) : activeTab === "layout" ? (
333
+ <ColumnV2LayoutTab section={effectiveSectionV2} column={selectedColumnV2} />
330
334
  ) : (
331
- <ColumnV2Settings section={selectedSectionV2} column={selectedColumnV2} />
335
+ <ColumnV2Settings section={effectiveSectionV2} column={selectedColumnV2} />
332
336
  )
333
337
  ) : selectedSectionV2 && !selectedBlock ? (
334
338
  // V2 Section selected — route by active tab
@@ -189,11 +189,11 @@ export default function SortableBlock({
189
189
  <>
190
190
  <div className="w-px self-stretch my-1" style={{ background: "#3580f9" }} />
191
191
  <span
192
- className="text-[10px] px-1 py-0.5"
192
+ className="group/bb relative text-[10px] px-1 py-0.5"
193
193
  style={{ color: "#3580f9" }}
194
- title={`Animation: ${block.enter_animation.preset}`}
195
194
  >
196
195
 
196
+ <BubbleTooltip>{`Animation: ${block.enter_animation.preset}`}</BubbleTooltip>
197
197
  </span>
198
198
  </>
199
199
  )}
@@ -264,7 +264,7 @@ export default function SortableRow({
264
264
  style={{
265
265
  transform: `translateX(calc(-100% - 8px)) scale(${Math.min(2, 1 / canvasZoom)})`,
266
266
  transformOrigin: "top right",
267
- width: "90px",
267
+ width: "105px",
268
268
  }}
269
269
  onClick={(e) => {
270
270
  e.stopPropagation();
@@ -341,7 +341,6 @@ export default function SortableRow({
341
341
  style={{ color: "rgba(117, 0, 213, 0.6)" }}
342
342
  onMouseEnter={(e) => { e.currentTarget.style.color = "#7500d5"; }}
343
343
  onMouseLeave={(e) => { e.currentTarget.style.color = "rgba(117, 0, 213, 0.6)"; }}
344
- title={`Add ${addColumnLabel.toLowerCase()}`}
345
344
  aria-label={`Add ${addColumnLabel.toLowerCase()}`}
346
345
  >
347
346
  <span style={{ color: "rgba(117, 0, 213, 0.4)" }}>+</span> {addColumnLabel}
@@ -356,7 +355,6 @@ export default function SortableRow({
356
355
  style={{ color: "rgba(117, 0, 213, 0.6)" }}
357
356
  onMouseEnter={(e) => { e.currentTarget.style.color = "#7500d5"; }}
358
357
  onMouseLeave={(e) => { e.currentTarget.style.color = "rgba(117, 0, 213, 0.6)"; }}
359
- title="Delete section"
360
358
  aria-label="Delete section"
361
359
  >
362
360
  <CloseIcon size={12} /> Delete
@@ -416,8 +414,7 @@ export default function SortableRow({
416
414
  style={{ color: "rgba(117, 0, 213, 0.6)" }}
417
415
  onMouseEnter={(e) => { if (!e.currentTarget.disabled) e.currentTarget.style.color = "#7500d5"; }}
418
416
  onMouseLeave={(e) => { if (!e.currentTarget.disabled) e.currentTarget.style.color = "rgba(117, 0, 213, 0.6)"; }}
419
- title={canAddRow ? "Add row" : "Cover supports up to 5 rows"}
420
- aria-label="Add row"
417
+ aria-label={canAddRow ? "Add row" : "Cover supports up to 5 rows"}
421
418
  >
422
419
  <span style={{ color: "rgba(117, 0, 213, 0.4)" }}>+</span> Row
423
420
  </button>
@@ -517,7 +514,6 @@ export default function SortableRow({
517
514
  style={{ color: "rgba(117, 0, 213, 0.6)" }}
518
515
  onMouseEnter={(e) => { e.currentTarget.style.color = "#7500d5"; }}
519
516
  onMouseLeave={(e) => { e.currentTarget.style.color = "rgba(117, 0, 213, 0.6)"; }}
520
- title="Add slide"
521
517
  aria-label="Add slide"
522
518
  >
523
519
  <span style={{ color: "rgba(117, 0, 213, 0.4)" }}>+</span> Slide
@@ -531,10 +527,11 @@ export default function SortableRow({
531
527
  {bgColor !== "transparent" && isSelected && (
532
528
  <div className="absolute top-1 right-1 z-[5]" style={{ transform: `scale(${1 / canvasZoom})`, transformOrigin: "top right" }}>
533
529
  <span
534
- className="w-4 h-4 rounded-full border-2 border-white/50 block shadow-sm"
530
+ className="group/bb relative w-4 h-4 rounded-full border-2 border-white/50 block shadow-sm"
535
531
  style={{ backgroundColor: bgColor }}
536
- title={`Background: ${bgColor}`}
537
- />
532
+ >
533
+ <BubbleTooltip>{`Background: ${bgColor}`}</BubbleTooltip>
534
+ </span>
538
535
  </div>
539
536
  )}
540
537
 
@@ -15,6 +15,7 @@
15
15
  import { useState, useEffect, useRef, useCallback, useMemo } from "react";
16
16
  import type { RegisteredAsset } from "../../lib/sanity/types";
17
17
  import { BREAKPOINTS } from "../../lib/builder/constants";
18
+ import { BubbleTooltip } from "./BubbleIcons";
18
19
 
19
20
  // ============================================
20
21
  // Types
@@ -345,10 +346,10 @@ function AssetGridItem({
345
346
  {/* Thumbnail status badge — raster images only */}
346
347
  {isImageType(asset.extension) && asset.extension !== "svg" && (
347
348
  <div
348
- className={`absolute bottom-1.5 right-1.5 w-4 h-4 rounded-full flex items-center justify-center backdrop-blur-sm ${
349
+ className={`group/bb absolute bottom-1.5 right-1.5 w-4 h-4 rounded-full flex items-center justify-center backdrop-blur-sm ${
349
350
  asset.has_thumbnail ? "bg-green-500/80" : "bg-amber-500/80"
350
351
  }`}
351
- title={
352
+ aria-label={
352
353
  asset.has_thumbnail
353
354
  ? "Thumbnail available"
354
355
  : "No thumbnail — loading full resolution"
@@ -382,6 +383,11 @@ function AssetGridItem({
382
383
  <circle cx="12" cy="16" r="0.5" fill="white" />
383
384
  </svg>
384
385
  )}
386
+ <BubbleTooltip>
387
+ {asset.has_thumbnail
388
+ ? "Thumbnail available"
389
+ : "No thumbnail — loading full resolution"}
390
+ </BubbleTooltip>
385
391
  </div>
386
392
  )}
387
393
  </div>
@@ -12,6 +12,7 @@ import { useR2Operations } from "./useR2Operations";
12
12
  import { useR2DragDrop } from "./useR2DragDrop";
13
13
  import { R2ContextMenu, type ContextMenuState } from "./R2ContextMenu";
14
14
  import { ADMIN_ACCENT, BUILDER_GREEN } from "../../../lib/builder/constants";
15
+ import { BubbleTooltip } from "../BubbleIcons";
15
16
 
16
17
  // ============================================
17
18
  // R2 Browser — Composition shell
@@ -291,18 +292,19 @@ export function R2BrowserContent({
291
292
  ))}
292
293
  </div>
293
294
  <div className="flex items-center gap-2">
294
- <button onClick={ops.openNewFolderInput} disabled={ops.actionLoading} className="inline-flex items-center gap-1.5 rounded-lg bg-neutral-100 px-3 py-1.5 text-[11px] text-neutral-700 font-medium uppercase tracking-wider hover:bg-neutral-200 transition-colors disabled:opacity-50" title="Create a new folder" type="button">
295
+ <button onClick={ops.openNewFolderInput} disabled={ops.actionLoading} className="group/bb relative inline-flex items-center gap-1.5 rounded-lg bg-neutral-100 px-3 py-1.5 text-[11px] text-neutral-700 font-medium uppercase tracking-wider hover:bg-neutral-200 transition-colors disabled:opacity-50" aria-label="Create a new folder" type="button">
295
296
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
296
297
  <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
297
298
  <line x1="12" y1="11" x2="12" y2="17" /><line x1="9" y1="14" x2="15" y2="14" />
298
299
  </svg>
299
300
  New Folder
301
+ <BubbleTooltip>Create a new folder</BubbleTooltip>
300
302
  </button>
301
303
  <button
302
304
  onClick={() => ops.fileInputRef.current?.click()}
303
305
  disabled={uploading.some((u) => u.status === "uploading" || u.status === "registering")}
304
- className="inline-flex items-center gap-1.5 rounded-lg bg-[#3580f9] px-3 py-1.5 text-[11px] text-white font-medium uppercase tracking-wider hover:bg-[#3580f9]/90 transition-colors disabled:opacity-50"
305
- title={`Upload files${currentFolder ? ` to ${currentFolder}` : ""}`}
306
+ className="group/bb relative inline-flex items-center gap-1.5 rounded-lg bg-[#3580f9] px-3 py-1.5 text-[11px] text-white font-medium uppercase tracking-wider hover:bg-[#3580f9]/90 transition-colors disabled:opacity-50"
307
+ aria-label={`Upload files${currentFolder ? ` to ${currentFolder}` : ""}`}
306
308
  type="button"
307
309
  >
308
310
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -311,6 +313,7 @@ export function R2BrowserContent({
311
313
  <line x1="12" y1="3" x2="12" y2="15" />
312
314
  </svg>
313
315
  Upload
316
+ <BubbleTooltip>{`Upload files${currentFolder ? ` to ${currentFolder}` : ""}`}</BubbleTooltip>
314
317
  </button>
315
318
  </div>
316
319
  </div>
@@ -323,8 +326,9 @@ export function R2BrowserContent({
323
326
  {u.status === "done" ? (
324
327
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={BUILDER_GREEN} strokeWidth="2.5"><polyline points="20 6 9 17 4 12" /></svg>
325
328
  ) : u.status === "error" ? (
326
- <button onClick={() => onClearUploadError?.(u.id)} title="Dismiss">
329
+ <button onClick={() => onClearUploadError?.(u.id)} className="group/bb relative" aria-label="Dismiss">
327
330
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
331
+ <BubbleTooltip>Dismiss</BubbleTooltip>
328
332
  </button>
329
333
  ) : (
330
334
  <div className="w-3.5 h-3.5 border-2 border-[#3580f9] border-t-transparent rounded-full animate-spin" />
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { useState, useCallback } from "react";
12
12
  import type { EyedropperButtonProps } from "./types";
13
+ import { BubbleTooltip } from "../BubbleIcons";
13
14
 
14
15
  /** Check if the EyeDropper API is available */
15
16
  function isEyeDropperSupported(): boolean {
@@ -39,17 +40,16 @@ export default function EyedropperButton({
39
40
  }
40
41
  }, [supported, picking, onColorPicked]);
41
42
 
43
+ const label = supported
44
+ ? "Pick a color from screen"
45
+ : "Eyedropper not supported in this browser";
42
46
  return (
43
47
  <button
44
48
  type="button"
45
49
  onClick={handleClick}
46
50
  disabled={!supported}
47
- title={
48
- supported
49
- ? "Pick a color from screen"
50
- : "Eyedropper not supported in this browser"
51
- }
52
- className={`w-10 h-10 rounded-[10px] border shrink-0 flex items-center justify-center transition-colors ${
51
+ aria-label={label}
52
+ className={`group/bb relative w-10 h-10 rounded-[10px] border shrink-0 flex items-center justify-center transition-colors ${
53
53
  supported
54
54
  ? "border-neutral-200 bg-neutral-50 text-neutral-500 cursor-pointer hover:border-neutral-300 hover:text-neutral-700"
55
55
  : "border-neutral-100 bg-neutral-50 text-neutral-300 cursor-not-allowed"
@@ -69,6 +69,7 @@ export default function EyedropperButton({
69
69
  <path d="M3 21v-3l9-9" />
70
70
  <path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.4.4a2.1 2.1 0 1 1-3 3l-3.8-3.8a2.1 2.1 0 1 1 3-3L15 6" />
71
71
  </svg>
72
+ <BubbleTooltip>{label}</BubbleTooltip>
72
73
  </button>
73
74
  );
74
75
  }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { useCallback } from "react";
12
12
  import type { SwatchBarProps } from "./types";
13
+ import { BubbleTooltip } from "../BubbleIcons";
13
14
 
14
15
  // Common neutral colors always available
15
16
  const COMMON_COLORS = [
@@ -51,14 +52,16 @@ export default function SwatchBar({
51
52
  key={s._key || `swatch-${i}`}
52
53
  type="button"
53
54
  onClick={() => handleSwatchClick(s.hex)}
54
- title={`${s.name}: ${s.hex}`}
55
- className={`w-8 h-8 rounded-lg cursor-pointer transition-all ${
55
+ aria-label={`${s.name}: ${s.hex}`}
56
+ className={`group/bb relative w-8 h-8 rounded-lg cursor-pointer transition-all ${
56
57
  value.toLowerCase() === s.hex.toLowerCase()
57
58
  ? "ring-2 ring-[#3580f9] ring-offset-1 ring-offset-white"
58
59
  : "border border-neutral-200 hover:border-neutral-400 hover:scale-110"
59
60
  }`}
60
61
  style={{ background: s.hex }}
61
- />
62
+ >
63
+ <BubbleTooltip>{`${s.name}: ${s.hex}`}</BubbleTooltip>
64
+ </button>
62
65
  ))}
63
66
  </div>
64
67
  </div>
@@ -77,14 +80,16 @@ export default function SwatchBar({
77
80
  key={c}
78
81
  type="button"
79
82
  onClick={() => handleSwatchClick(c)}
80
- title={c.toUpperCase()}
81
- className={`w-6 h-6 rounded-md cursor-pointer transition-all ${
83
+ aria-label={c.toUpperCase()}
84
+ className={`group/bb relative w-6 h-6 rounded-md cursor-pointer transition-all ${
82
85
  value.toLowerCase() === c
83
86
  ? "ring-2 ring-[#3580f9] ring-offset-1 ring-offset-white"
84
87
  : "border border-neutral-200 hover:border-neutral-400 hover:scale-110"
85
88
  }`}
86
89
  style={{ background: c }}
87
- />
90
+ >
91
+ <BubbleTooltip>{c.toUpperCase()}</BubbleTooltip>
92
+ </button>
88
93
  ))}
89
94
  </div>
90
95
  </div>
@@ -45,6 +45,7 @@ import {
45
45
  import { getPresetsForCategory } from "../../../lib/builder/gradient-presets";
46
46
  import type { GradientPresetInfo } from "../../../lib/builder/gradient-presets";
47
47
  import type { ColorSwatch } from "../../../lib/sanity/types";
48
+ import { BubbleTooltip } from "../BubbleIcons";
48
49
 
49
50
  type GradientTab = "solid" | "linear" | "radial" | "mesh";
50
51
 
@@ -554,11 +555,13 @@ export default function UnifiedColorPicker({
554
555
  <button
555
556
  key={preset.id}
556
557
  type="button"
557
- title={preset.label}
558
+ aria-label={preset.label}
558
559
  onClick={() => handlePresetSelect(preset)}
559
- className="w-8 h-8 rounded-lg border border-neutral-200 hover:border-neutral-400 transition-colors cursor-pointer shrink-0"
560
+ className="group/bb relative w-8 h-8 rounded-lg border border-neutral-200 hover:border-neutral-400 transition-colors cursor-pointer shrink-0"
560
561
  style={{ backgroundImage: colorToCSS(preset.template) }}
561
- />
562
+ >
563
+ <BubbleTooltip>{preset.label}</BubbleTooltip>
564
+ </button>
562
565
  ))}
563
566
  </div>
564
567
  )}
@@ -678,11 +681,13 @@ export default function UnifiedColorPicker({
678
681
  <button
679
682
  key={preset.id}
680
683
  type="button"
681
- title={preset.label}
684
+ aria-label={preset.label}
682
685
  onClick={() => handlePresetSelect(preset)}
683
- className="w-8 h-8 rounded-lg border border-neutral-200 hover:border-neutral-400 transition-colors cursor-pointer shrink-0"
686
+ className="group/bb relative w-8 h-8 rounded-lg border border-neutral-200 hover:border-neutral-400 transition-colors cursor-pointer shrink-0"
684
687
  style={{ backgroundImage: colorToCSS(preset.template) }}
685
- />
688
+ >
689
+ <BubbleTooltip>{preset.label}</BubbleTooltip>
690
+ </button>
686
691
  ))}
687
692
  </div>
688
693
  )}
@@ -19,6 +19,7 @@ import {
19
19
  useActiveViewport,
20
20
  SELECT_CLASS,
21
21
  } from "./shared";
22
+ import { BubbleTooltip } from "../BubbleIcons";
22
23
 
23
24
  interface Props {
24
25
  block: ImageGridBlock;
@@ -74,13 +75,14 @@ function ImageThumb({
74
75
  e.stopPropagation();
75
76
  onRemove();
76
77
  }}
77
- className="w-7 h-7 rounded-full bg-white/90 hover:bg-[var(--admin-error)] hover:text-white flex items-center justify-center transition-colors text-neutral-600"
78
- title="Remove image"
78
+ className="group/bb relative w-7 h-7 rounded-full bg-white/90 hover:bg-[var(--admin-error)] hover:text-white flex items-center justify-center transition-colors text-neutral-600"
79
+ aria-label="Remove image"
79
80
  >
80
81
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
81
82
  <polyline points="3 6 5 6 21 6" />
82
83
  <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
83
84
  </svg>
85
+ <BubbleTooltip>Remove image</BubbleTooltip>
84
86
  </button>
85
87
  </div>
86
88
  )}
@@ -37,6 +37,7 @@ import {
37
37
  TypographyIcon,
38
38
  LayoutIcon,
39
39
  } from "./section-icons";
40
+ import { BubbleTooltip } from "../BubbleIcons";
40
41
 
41
42
  // ============================================
42
43
  // Constants
@@ -271,11 +272,11 @@ function ItemRow({
271
272
  >
272
273
  <div className="flex items-center gap-2 mb-1.5">
273
274
  <span
274
- className="text-neutral-300 cursor-grab active:cursor-grabbing select-none"
275
- title="Drag to reorder"
275
+ className="group/bb relative text-neutral-300 cursor-grab active:cursor-grabbing select-none"
276
276
  aria-label="Drag handle"
277
277
  >
278
278
  ⋮⋮
279
+ <BubbleTooltip>Drag to reorder</BubbleTooltip>
279
280
  </span>
280
281
  <span className="text-[10px] font-semibold uppercase tracking-wider text-neutral-400">
281
282
  {ITEM_TYPE_LABEL[item._type]} · #{index + 1}
@@ -24,6 +24,7 @@ import {
24
24
  ViewportBadge,
25
25
  StyledCheckbox,
26
26
  } from "./shared";
27
+ import { BubbleTooltip } from "../BubbleIcons";
27
28
 
28
29
  // ============================================
29
30
  // Constants
@@ -516,7 +517,7 @@ export default function ProjectGridEditor({ block }: ProjectGridEditorProps) {
516
517
  onClick={() => selectProjectCard(isCardSelected ? null : item._key)}
517
518
  >
518
519
  {/* Drag grip */}
519
- <span className="text-neutral-300 shrink-0 cursor-grab" title="Reorder">
520
+ <span className="group/bb relative text-neutral-300 shrink-0 cursor-grab" aria-label="Reorder">
520
521
  <svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
521
522
  <circle cx="3" cy="2" r="1" />
522
523
  <circle cx="7" cy="2" r="1" />
@@ -525,6 +526,7 @@ export default function ProjectGridEditor({ block }: ProjectGridEditorProps) {
525
526
  <circle cx="3" cy="8" r="1" />
526
527
  <circle cx="7" cy="8" r="1" />
527
528
  </svg>
529
+ <BubbleTooltip>Reorder</BubbleTooltip>
528
530
  </span>
529
531
 
530
532
  {/* Project name */}
@@ -543,34 +545,37 @@ export default function ProjectGridEditor({ block }: ProjectGridEditorProps) {
543
545
  <button
544
546
  onClick={(e) => { e.stopPropagation(); moveProject(i, -1); }}
545
547
  disabled={i === 0}
546
- className="p-0.5 text-neutral-400 hover:text-neutral-700 disabled:opacity-20 transition-colors"
547
- title="Move up"
548
+ className="group/bb relative p-0.5 text-neutral-400 hover:text-neutral-700 disabled:opacity-20 transition-colors"
549
+ aria-label="Move up"
548
550
  >
549
551
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
550
552
  <polyline points="18 15 12 9 6 15" />
551
553
  </svg>
554
+ <BubbleTooltip>Move up</BubbleTooltip>
552
555
  </button>
553
556
  <button
554
557
  onClick={(e) => { e.stopPropagation(); moveProject(i, 1); }}
555
558
  disabled={i === (block.projects || []).length - 1}
556
- className="p-0.5 text-neutral-400 hover:text-neutral-700 disabled:opacity-20 transition-colors"
557
- title="Move down"
559
+ className="group/bb relative p-0.5 text-neutral-400 hover:text-neutral-700 disabled:opacity-20 transition-colors"
560
+ aria-label="Move down"
558
561
  >
559
562
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
560
563
  <polyline points="6 9 12 15 18 9" />
561
564
  </svg>
565
+ <BubbleTooltip>Move down</BubbleTooltip>
562
566
  </button>
563
567
 
564
568
  {/* Remove */}
565
569
  <button
566
570
  onClick={(e) => { e.stopPropagation(); removeProject(item._key); }}
567
- className="p-0.5 text-neutral-400 hover:text-red-500 transition-colors"
568
- title="Remove"
571
+ className="group/bb relative p-0.5 text-neutral-400 hover:text-red-500 transition-colors"
572
+ aria-label="Remove"
569
573
  >
570
574
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
571
575
  <line x1="18" y1="6" x2="6" y2="18" />
572
576
  <line x1="6" y1="6" x2="18" y2="18" />
573
577
  </svg>
578
+ <BubbleTooltip>Remove</BubbleTooltip>
574
579
  </button>
575
580
  </div>
576
581