@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
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
3
3
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
4
4
  import { isAdminAuthenticated } from "../../../../../lib/auth";
5
- import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
5
+ import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../../lib/csrf";
6
6
  import { isBodyTooLarge, MAX_JSON_BODY_SIZE, jsonError, isValidAssetPath, isValidUploadKey, checkRateLimit } from "../../../../../lib/security";
7
7
  import { adminClient as client } from "../../../../../lib/sanity/client";
8
8
  import { decryptToken } from "../../../../../lib/security";
@@ -27,6 +27,9 @@ export async function POST(request: NextRequest) {
27
27
  if (!validateCsrf(request)) {
28
28
  return csrfErrorResponse();
29
29
  }
30
+ if (!hasJsonContentType(request)) {
31
+ return contentTypeErrorResponse();
32
+ }
30
33
  if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
31
34
  return jsonError("Request body too large", 413);
32
35
  }
@@ -1,7 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { revalidatePath } from "next/cache";
3
3
  import { isAdminAuthenticated } from "../../../../lib/auth";
4
- import { validateCsrf, csrfErrorResponse } from "../../../../lib/csrf";
4
+ import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../lib/csrf";
5
5
  import { logger } from "../../../../lib/logger";
6
6
 
7
7
  /**
@@ -23,6 +23,9 @@ export async function POST(request: NextRequest) {
23
23
  if (!validateCsrf(request)) {
24
24
  return csrfErrorResponse();
25
25
  }
26
+ if (!hasJsonContentType(request)) {
27
+ return contentTypeErrorResponse();
28
+ }
26
29
 
27
30
  try {
28
31
  const body = await request.json().catch(() => ({}));
@@ -3,7 +3,7 @@ import { revalidatePath } from "next/cache";
3
3
  import { isAdminAuthenticated } from "../../../../../lib/auth";
4
4
  import { writeClient } from "../../../../../lib/sanity/writeClient";
5
5
  import { adminClient as client } from "../../../../../lib/sanity/client";
6
- import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
6
+ import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../../lib/csrf";
7
7
  import { isBodyTooLarge, MAX_JSON_BODY_SIZE, jsonError } from "../../../../../lib/security";
8
8
  import { invalidateProviderConfigCache } from "../../../../../lib/storage";
9
9
  import { auditLog } from "../../../../../lib/audit";
@@ -36,6 +36,9 @@ export async function POST(request: NextRequest) {
36
36
  if (!validateCsrf(request)) {
37
37
  return csrfErrorResponse();
38
38
  }
39
+ if (!hasJsonContentType(request)) {
40
+ return contentTypeErrorResponse();
41
+ }
39
42
  if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
40
43
  return jsonError("Request body too large", 413);
41
44
  }
@@ -1,5 +1,5 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { adminClient } from "../../../../lib/sanity/client";
2
+ import { client } from "../../../../lib/sanity/client";
3
3
  import { customSectionByIdQuery } from "../../../../lib/sanity/queries";
4
4
  import { logger } from "../../../../lib/logger";
5
5
 
@@ -11,10 +11,9 @@ type RouteContext = { params: Promise<{ id: string }> };
11
11
  * Returns only the `section` field (PageSectionV2 data) for rendering
12
12
  * custom section instances on the public site.
13
13
  *
14
- * Uses adminClient (useCdn: false) so data is always fresh after
15
- * revalidatePath() is called from the admin PATCH endpoint.
16
- * Cache is controlled via ISR revalidation on-demand, not via
17
- * Cache-Control headers — this avoids stale CDN responses after edits.
14
+ * Uses the public `client` (useCdn: true) to hit Sanity's CDN (~2ms) instead
15
+ * of the API (~200ms). Freshness is handled by `revalidatePath()` in the
16
+ * admin PATCH endpoint, which purges this endpoint's ISR cache on save.
18
17
  */
19
18
  export async function GET(_request: NextRequest, context: RouteContext) {
20
19
  try {
@@ -24,7 +23,7 @@ export async function GET(_request: NextRequest, context: RouteContext) {
24
23
  return NextResponse.json({ error: "Missing section ID" }, { status: 400 });
25
24
  }
26
25
 
27
- const result = await adminClient.fetch(customSectionByIdQuery, { id });
26
+ const result = await client.fetch(customSectionByIdQuery, { id });
28
27
  if (!result?.section) {
29
28
  return NextResponse.json({ error: "Custom section not found" }, { status: 404 });
30
29
  }
@@ -96,7 +96,7 @@ export default function PublishToggle(props: PublishToggleProps) {
96
96
  cursor: "pointer",
97
97
  whiteSpace: "nowrap",
98
98
  transition: "background-color 150ms, color 150ms",
99
- backgroundColor: isDraft ? "#f59e0b" : "transparent",
99
+ backgroundColor: isDraft ? "#d97706" : "transparent",
100
100
  color: isDraft ? "#fff" : "#a3a3a3",
101
101
  ...(busy ? { opacity: 0.5 } : {}),
102
102
  }}
@@ -118,7 +118,7 @@ export default function PublishToggle(props: PublishToggleProps) {
118
118
  cursor: "pointer",
119
119
  whiteSpace: "nowrap",
120
120
  transition: "background-color 150ms, color 150ms",
121
- backgroundColor: !isDraft ? "#10b981" : "transparent",
121
+ backgroundColor: !isDraft ? "#059669" : "transparent",
122
122
  color: !isDraft ? "#fff" : "#a3a3a3",
123
123
  ...(busy ? { opacity: 0.5 } : {}),
124
124
  }}
@@ -3,6 +3,7 @@
3
3
  import { useState, useRef, useCallback, useEffect } from "react";
4
4
  import type { NavItem } from "../../../lib/sanity/types";
5
5
  import { TOTAL_COLUMNS, getMaxSpan } from "./nav-builder-utils";
6
+ import { BubbleTooltip } from "../../builder/BubbleIcons";
6
7
 
7
8
  interface NavGridItemProps {
8
9
  item: NavItem;
@@ -155,8 +156,8 @@ export default function NavGridItem({
155
156
  e.stopPropagation();
156
157
  onDelete(item._key);
157
158
  }}
158
- className="absolute -top-2 -left-2 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transition-colors shadow-sm"
159
- title="Remove item"
159
+ className="group/bb absolute -top-2 -left-2 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transition-colors shadow-sm"
160
+ aria-label="Remove item"
160
161
  >
161
162
  <svg width="10" height="10" viewBox="0 0 10 10">
162
163
  <path
@@ -166,6 +167,7 @@ export default function NavGridItem({
166
167
  strokeLinecap="round"
167
168
  />
168
169
  </svg>
170
+ <BubbleTooltip>Remove item</BubbleTooltip>
169
171
  </button>
170
172
  )}
171
173
 
@@ -12,6 +12,7 @@ import {
12
12
  StyleIcon,
13
13
  BackgroundIcon,
14
14
  } from "../../builder/editors/section-icons";
15
+ import { BubbleTooltip } from "../../builder/BubbleIcons";
15
16
 
16
17
  // ── Nav viewport type for responsive overrides ──
17
18
  export type NavViewport = "desktop" | "tablet" | "phone";
@@ -329,12 +330,13 @@ export function ColorInput({
329
330
  {value && (
330
331
  <button
331
332
  onClick={() => onChange("")}
332
- className="text-neutral-400 hover:text-neutral-600 shrink-0"
333
- title="Clear"
333
+ className="group/bb relative text-neutral-400 hover:text-neutral-600 shrink-0"
334
+ aria-label="Clear"
334
335
  >
335
336
  <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
336
337
  <path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
337
338
  </svg>
339
+ <BubbleTooltip>Clear</BubbleTooltip>
338
340
  </button>
339
341
  )}
340
342
  </div>
@@ -407,8 +409,8 @@ export function ViewportSwitcher({
407
409
  <button
408
410
  key={opt.value}
409
411
  onClick={() => onChange(opt.value)}
410
- title={opt.label}
411
- className={`flex-1 px-2 py-[5px] text-[10px] font-medium rounded-md transition-all flex items-center justify-center gap-1 ${
412
+ aria-label={opt.label}
413
+ className={`group/bb relative flex-1 px-2 py-[5px] text-[10px] font-medium rounded-md transition-all flex items-center justify-center gap-1 ${
412
414
  isActive
413
415
  ? isNonDesktop
414
416
  ? "text-[#3580f9] bg-blue-50 shadow-[0_1px_2px_rgba(53, 128, 249,0.08)]"
@@ -417,6 +419,7 @@ export function ViewportSwitcher({
417
419
  }`}
418
420
  >
419
421
  {opt.icon}
422
+ <BubbleTooltip>{opt.label}</BubbleTooltip>
420
423
  </button>
421
424
  );
422
425
  })}
@@ -501,12 +504,13 @@ export function ResponsiveField({
501
504
  {isOverridden && onReset && (
502
505
  <button
503
506
  onClick={onReset}
504
- className="shrink-0 text-[#3580f9]/60 hover:text-[#3580f9] transition-colors"
505
- title="Reset to desktop value"
507
+ className="group/bb relative shrink-0 text-[#3580f9]/60 hover:text-[#3580f9] transition-colors"
508
+ aria-label="Reset to desktop value"
506
509
  >
507
510
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
508
511
  <polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
509
512
  </svg>
513
+ <BubbleTooltip>Reset to desktop value</BubbleTooltip>
510
514
  </button>
511
515
  )}
512
516
  </div>
@@ -5,6 +5,7 @@ import ColorPicker, { isValidHex } from "../../../components/builder/ColorPicker
5
5
  import { invalidatePaletteCache } from "../../../components/builder/ColorSwatchPicker";
6
6
  import type { SiteStyles, ColorSwatch } from "../../../lib/sanity/types";
7
7
  import { Section, SaveButton } from "./shared";
8
+ import { BubbleTooltip } from "../../builder/BubbleIcons";
8
9
 
9
10
  function getContrastColor(hex: string): string {
10
11
  if (!isValidHex(hex)) return "#000";
@@ -119,14 +120,14 @@ export function ColorsEditor({
119
120
  <div className="absolute inset-0 bg-black/15 flex items-center justify-center gap-1.5">
120
121
  <button
121
122
  onClick={(e) => { e.stopPropagation(); setEditingIndex(i); setPickerOpen(true); }}
122
- className="w-6 h-6 rounded-full bg-white/90 flex items-center justify-center text-[11px] cursor-pointer hover:bg-white transition-colors border-none"
123
- title="Edit color"
124
- >✎</button>
123
+ className="group/bb relative w-6 h-6 rounded-full bg-white/90 flex items-center justify-center text-[11px] cursor-pointer hover:bg-white transition-colors border-none"
124
+ aria-label="Edit color"
125
+ >✎<BubbleTooltip>Edit color</BubbleTooltip></button>
125
126
  <button
126
127
  onClick={(e) => { e.stopPropagation(); removeSwatch(i); }}
127
- className="w-6 h-6 rounded-full bg-white/90 flex items-center justify-center text-[13px] text-red-500 cursor-pointer hover:bg-white transition-colors border-none"
128
- title="Remove"
129
- >×</button>
128
+ className="group/bb relative w-6 h-6 rounded-full bg-white/90 flex items-center justify-center text-[13px] text-red-500 cursor-pointer hover:bg-white transition-colors border-none"
129
+ aria-label="Remove"
130
+ >×<BubbleTooltip>Remove</BubbleTooltip></button>
130
131
  </div>
131
132
  )}
132
133
  </div>
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
4
4
  import { csrfHeaders } from "../../../lib/csrf-client";
5
5
  import type { FontFamily, FontVariant } from "../../../lib/sanity/types";
6
6
  import { Section, SaveButton } from "./shared";
7
+ import { BubbleTooltip } from "../../builder/BubbleIcons";
7
8
 
8
9
  export function FontsEditor({ fonts, onSave, saving }: { fonts: FontFamily[]; onSave: (fonts: FontFamily[]) => void; saving: boolean }) {
9
10
  const [localFonts, setLocalFonts] = useState<FontFamily[]>(fonts);
@@ -204,8 +205,9 @@ export function FontsEditor({ fonts, onSave, saving }: { fonts: FontFamily[]; on
204
205
  <option value="normal">Normal</option>
205
206
  <option value="italic">Italic</option>
206
207
  </select>
207
- <span className="text-neutral-400 truncate flex-1" title={v.original_filename}>
208
+ <span className="group/bb relative text-neutral-400 truncate flex-1" aria-label={v.original_filename}>
208
209
  {v.original_filename}
210
+ <BubbleTooltip>{v.original_filename}</BubbleTooltip>
209
211
  </span>
210
212
  {!font.is_builtin && (
211
213
  <button
@@ -21,7 +21,7 @@ import type { EnterAnimationConfig } from "../../lib/animation/enter-types";
21
21
  import { resolveEnterAnimation } from "../../lib/animation/enter-resolve";
22
22
  import BlockRenderer from "./BlockRenderer";
23
23
  import EnterAnimationWrapper from "./EnterAnimationWrapper";
24
- import { getBlockAlignmentStyles, hasBlockAlignment, getColumnVerticalAlign } from "../../lib/builder/layout-styles";
24
+ import { getBlockAlignmentStyles, hasBlockAlignment, getColumnVerticalAlign, getBackgroundStyles, getBorderStyles } from "../../lib/builder/layout-styles";
25
25
  import { assetUrl } from "../../lib/assets";
26
26
  import { BREAKPOINTS } from "../../lib/builder/constants";
27
27
  import { normalizeRowHeights } from "../../lib/builder/store-cover";
@@ -305,6 +305,11 @@ export default function CoverSectionRenderer({ section, pageEnterAnimation }: Co
305
305
  const alignSelf = rowAlign === "center" ? "center" : rowAlign === "end" ? "end" : "start";
306
306
  const colJustify = getColumnVerticalAlign(col.blocks || []);
307
307
 
308
+ const colLayoutStyles = {
309
+ ...getBackgroundStyles(col, process.env.NEXT_PUBLIC_ASSET_BASE_URL),
310
+ ...getBorderStyles(col),
311
+ };
312
+
308
313
  const columnContent = (
309
314
  <div
310
315
  key={col._key}
@@ -319,6 +324,7 @@ export default function CoverSectionRenderer({ section, pageEnterAnimation }: Co
319
324
  height: "100%",
320
325
  minWidth: 0,
321
326
  overflow: "hidden",
327
+ ...colLayoutStyles,
322
328
  }}
323
329
  >
324
330
  {(col.blocks || []).map((block) => {
@@ -24,7 +24,7 @@ import type { EnterAnimationConfig } from "../../lib/animation/enter-types";
24
24
  import { resolveEnterAnimation } from "../../lib/animation/enter-resolve";
25
25
  import BlockRenderer from "./BlockRenderer";
26
26
  import EnterAnimationWrapper from "./EnterAnimationWrapper";
27
- import { getRowLayoutStyles, getBlockAlignmentStyles, hasBlockAlignment, getColumnVerticalAlign, hexToRgba } from "../../lib/builder/layout-styles";
27
+ import { getRowLayoutStyles, getBlockAlignmentStyles, hasBlockAlignment, getColumnVerticalAlign, getBackgroundStyles, getBorderStyles, hexToRgba } from "../../lib/builder/layout-styles";
28
28
  import { parseColorField, colorToOverrideRule, borderColorToOverrideRule } from "../../lib/color-utils";
29
29
  import { BREAKPOINTS } from "../../lib/builder/constants";
30
30
 
@@ -261,6 +261,12 @@ export default function SectionV2Renderer({ section, pageEnterAnimation }: Secti
261
261
  // Column-level vertical alignment from blocks' align_v settings
262
262
  const colJustify = getColumnVerticalAlign(col.blocks || []);
263
263
 
264
+ // Column-level background + border (desktop-only).
265
+ const colLayoutStyles = {
266
+ ...getBackgroundStyles(col, process.env.NEXT_PUBLIC_ASSET_BASE_URL),
267
+ ...getBorderStyles(col),
268
+ };
269
+
264
270
  const columnContent = (
265
271
  <div
266
272
  key={col._key}
@@ -275,6 +281,7 @@ export default function SectionV2Renderer({ section, pageEnterAnimation }: Secti
275
281
  height: "100%",
276
282
  minWidth: 0,
277
283
  overflow: "hidden",
284
+ ...colLayoutStyles,
278
285
  }}
279
286
  >
280
287
  {(col.blocks || []).map((block) => {
@@ -88,3 +88,17 @@ export function BubbleTooltip({ children }: { children: ReactNode }) {
88
88
  </span>
89
89
  );
90
90
  }
91
+
92
+ /** Same styling as BubbleTooltip but positioned ABOVE the trigger.
93
+ * For bottom-anchored toolbars (e.g. the floating canvas toolbar). */
94
+ export function BubbleTooltipAbove({ children }: { children: ReactNode }) {
95
+ return (
96
+ <span
97
+ role="tooltip"
98
+ className="pointer-events-none absolute left-1/2 bottom-full z-50 mb-1.5 whitespace-nowrap rounded-md border border-white/10 bg-[#2a2d33] px-2.5 py-1.5 text-[11px] font-medium text-white shadow-lg opacity-0 transition-[opacity,margin] duration-150 ease-out group-hover/bb:opacity-100 group-hover/bb:mb-3"
99
+ style={{ transform: "translateX(-50%)" }}
100
+ >
101
+ {children}
102
+ </span>
103
+ );
104
+ }
@@ -3,6 +3,7 @@
3
3
  import { useCallback, useMemo, useRef, useState } from "react";
4
4
  import { useBuilderStore } from "../../lib/builder/store";
5
5
  import { DEVICE_WIDTHS } from "../../lib/builder/types";
6
+ import { BubbleTooltipAbove } from "./BubbleIcons";
6
7
 
7
8
  // ============================================
8
9
  // CanvasMinimap — Shows viewport position in canvas
@@ -121,80 +122,96 @@ export default function CanvasMinimap({
121
122
  return (
122
123
  <button
123
124
  onClick={() => setIsCollapsed(false)}
124
- className="absolute bottom-6 right-4 z-40 w-8 h-8 flex items-center justify-center rounded-lg bg-[#1a1a1a]/80 text-white/60 hover:text-white hover:bg-[#1a1a1a] transition-colors shadow-lg backdrop-blur-sm"
125
- title="Show minimap"
125
+ className="group/bb absolute bottom-6 right-4 z-40 w-8 h-8 flex items-center justify-center rounded-lg bg-[#1a1a1a]/80 text-white/60 hover:text-white hover:bg-[#1a1a1a] transition-colors shadow-lg backdrop-blur-sm"
126
126
  aria-label="Show minimap"
127
127
  >
128
128
  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
129
129
  <rect x="1" y="1" width="12" height="12" rx="1.5" stroke="currentColor" strokeWidth="1.2" />
130
130
  <rect x="3" y="3" width="4" height="3" rx="0.5" stroke="currentColor" strokeWidth="0.8" opacity="0.6" />
131
131
  </svg>
132
+ <BubbleTooltipAbove>Show minimap</BubbleTooltipAbove>
132
133
  </button>
133
134
  );
134
135
  }
135
136
 
136
137
  return (
137
138
  <div
138
- className="absolute bottom-6 right-4 z-40 rounded-lg overflow-hidden shadow-lg backdrop-blur-sm"
139
+ className="absolute bottom-6 right-4 z-40 rounded-lg overflow-hidden shadow-xl backdrop-blur-sm"
139
140
  style={{
140
141
  width: MINIMAP_WIDTH,
141
- height: MINIMAP_HEIGHT,
142
- backgroundColor: "rgba(26, 26, 26, 0.85)",
143
- border: "1px solid rgba(255, 255, 255, 0.1)",
142
+ backgroundColor: "rgba(26, 26, 26, 0.92)",
143
+ border: "1px solid rgba(255, 255, 255, 0.08)",
144
144
  userSelect: "none",
145
145
  }}
146
- onMouseDown={handleMinimapMouseDown}
147
- onMouseMove={handleMouseMove}
148
- onMouseUp={handleMouseUp}
149
- onMouseLeave={handleMouseUp}
150
146
  >
151
- {/* Close/collapse button */}
152
- <button
153
- onClick={(e) => {
154
- e.stopPropagation();
155
- setIsCollapsed(true);
147
+ {/* Header — label + close button. Matches the toolbar's dark pill aesthetic. */}
148
+ <div
149
+ className="flex items-center justify-between px-2 py-1"
150
+ style={{
151
+ borderBottom: "1px solid rgba(255, 255, 255, 0.06)",
152
+ background: "rgba(255, 255, 255, 0.02)",
156
153
  }}
157
- className="absolute top-1 right-1 z-50 w-4 h-4 flex items-center justify-center rounded text-white/40 hover:text-white/80 transition-colors"
158
- title="Hide minimap"
159
- aria-label="Hide minimap"
160
154
  >
161
- <svg width="8" height="8" viewBox="0 0 8 8" fill="none">
162
- <path d="M1 1L7 7M7 1L1 7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
163
- </svg>
164
- </button>
165
-
166
- {/* Device frame outlines */}
167
- {frames.map((f, i) => (
155
+ <span className="text-[9px] font-semibold tracking-[0.08em] uppercase text-white/45">
156
+ Overview
157
+ </span>
158
+ <button
159
+ onClick={() => setIsCollapsed(true)}
160
+ className="group/bb relative w-4 h-4 flex items-center justify-center rounded text-white/40 hover:text-white/90 hover:bg-white/10 transition-colors"
161
+ aria-label="Hide minimap"
162
+ >
163
+ <svg width="8" height="8" viewBox="0 0 8 8" fill="none">
164
+ <path d="M1 1L7 7M7 1L1 7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
165
+ </svg>
166
+ <BubbleTooltipAbove>Hide minimap</BubbleTooltipAbove>
167
+ </button>
168
+ </div>
169
+
170
+ {/* Content area — all pan/drag interactions live here (not the header). */}
171
+ <div
172
+ className="relative"
173
+ style={{
174
+ width: MINIMAP_WIDTH,
175
+ height: MINIMAP_HEIGHT,
176
+ }}
177
+ onMouseDown={handleMinimapMouseDown}
178
+ onMouseMove={handleMouseMove}
179
+ onMouseUp={handleMouseUp}
180
+ onMouseLeave={handleMouseUp}
181
+ >
182
+ {/* Device frame outlines */}
183
+ {frames.map((f, i) => (
184
+ <div
185
+ key={i}
186
+ className="absolute"
187
+ style={{
188
+ left: f.x,
189
+ top: 8 * scale,
190
+ width: f.w,
191
+ height: (contentEstimateHeight - 40) * scale,
192
+ border: `1px solid ${f.active ? "rgba(53, 128, 249, 0.6)" : "rgba(255, 255, 255, 0.15)"}`,
193
+ borderRadius: 2,
194
+ backgroundColor: f.active ? "rgba(53, 128, 249, 0.08)" : "rgba(255, 255, 255, 0.03)",
195
+ }}
196
+ />
197
+ ))}
198
+
199
+ {/* Viewport indicator */}
168
200
  <div
169
- key={i}
170
201
  className="absolute"
171
202
  style={{
172
- left: f.x,
173
- top: 8 * scale,
174
- width: f.w,
175
- height: (contentEstimateHeight - 40) * scale,
176
- border: `1px solid ${f.active ? "rgba(53, 128, 249, 0.6)" : "rgba(255, 255, 255, 0.15)"}`,
203
+ left: Math.max(0, viewX),
204
+ top: Math.max(0, viewY),
205
+ width: Math.min(viewW, MINIMAP_WIDTH),
206
+ height: Math.min(viewH, MINIMAP_HEIGHT),
207
+ border: "1.5px solid rgba(237, 56, 33, 0.7)",
177
208
  borderRadius: 2,
178
- backgroundColor: f.active ? "rgba(53, 128, 249, 0.08)" : "rgba(255, 255, 255, 0.03)",
209
+ backgroundColor: "rgba(237, 56, 33, 0.06)",
210
+ cursor: isDragging ? "grabbing" : "grab",
211
+ pointerEvents: "none",
179
212
  }}
180
213
  />
181
- ))}
182
-
183
- {/* Viewport indicator */}
184
- <div
185
- className="absolute"
186
- style={{
187
- left: Math.max(0, viewX),
188
- top: Math.max(0, viewY),
189
- width: Math.min(viewW, MINIMAP_WIDTH),
190
- height: Math.min(viewH, MINIMAP_HEIGHT),
191
- border: "1.5px solid rgba(237, 56, 33, 0.7)",
192
- borderRadius: 2,
193
- backgroundColor: "rgba(237, 56, 33, 0.06)",
194
- cursor: isDragging ? "grabbing" : "grab",
195
- pointerEvents: "none",
196
- }}
197
- />
214
+ </div>
198
215
  </div>
199
216
  );
200
217
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useRef, useState, useEffect, useCallback } from "react";
4
4
  import { useBuilderStore } from "../../lib/builder/store";
5
+ import { BubbleTooltipAbove } from "./BubbleIcons";
5
6
 
6
7
  // ============================================
7
8
  // CanvasToolbar — Floating mini toolbar
@@ -72,66 +73,68 @@ export default function CanvasToolbar({ viewportRef, onAnimatedAction }: CanvasT
72
73
  const zoomPercent = Math.round(zoom * 100);
73
74
 
74
75
  return (
75
- <div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-px rounded-full bg-[#1a1a1a] shadow-xl px-1 py-1"
76
+ <div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-0.5 rounded-full bg-[#1a1a1a] shadow-xl px-1.5 py-1.5"
76
77
  style={{ userSelect: "none" }}
77
78
  >
78
- {/* Select tool — Figma-style filled cursor */}
79
+ {/* Select tool — Figma-style filled cursor.
80
+ Active state uses the app's primary blue so selection signals
81
+ match the rest of the builder (block/column accents). */}
79
82
  <button
80
83
  onClick={() => setCanvasTool("select")}
81
- className={`flex items-center justify-center w-8 h-8 rounded-full transition-colors ${
84
+ className={`group/bb relative flex items-center justify-center w-8 h-8 rounded-full transition-colors ${
82
85
  tool === "select"
83
- ? "bg-white/15 text-white"
86
+ ? "bg-[#3580f9] text-white"
84
87
  : "text-neutral-400 hover:text-white hover:bg-white/10"
85
88
  }`}
86
- title="Select tool (V)"
87
89
  aria-label="Select tool"
88
90
  >
89
91
  <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round">
90
92
  <path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z" />
91
93
  </svg>
94
+ <BubbleTooltipAbove>Select <span className="ml-1 text-white/55">V</span></BubbleTooltipAbove>
92
95
  </button>
93
96
 
94
- {/* Hand tool — Figma-style */}
97
+ {/* Hand tool — tabler hand-stop (outline) */}
95
98
  <button
96
99
  onClick={() => setCanvasTool("hand")}
97
- className={`flex items-center justify-center w-8 h-8 rounded-full transition-colors ${
100
+ className={`group/bb relative flex items-center justify-center w-8 h-8 rounded-full transition-colors ${
98
101
  tool === "hand"
99
- ? "bg-white/15 text-white"
102
+ ? "bg-[#3580f9] text-white"
100
103
  : "text-neutral-400 hover:text-white hover:bg-white/10"
101
104
  }`}
102
- title="Hand tool (H)"
103
105
  aria-label="Hand tool"
104
106
  >
105
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
106
- <path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2" />
107
- <path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2" />
108
- <path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8" />
109
- <path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15" />
107
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
108
+ <path d="M8 13v-7.5a1.5 1.5 0 0 1 3 0v6.5" />
109
+ <path d="M11 5.5v-2a1.5 1.5 0 1 1 3 0v8.5" />
110
+ <path d="M14 5.5a1.5 1.5 0 0 1 3 0v6.5" />
111
+ <path d="M17 7.5a1.5 1.5 0 0 1 3 0v8.5a6 6 0 0 1 -6 6h-2h.208a6 6 0 0 1 -5.012 -2.7a69.74 69.74 0 0 1 -.196 -.3c-.312 -.479 -1.407 -2.388 -3.286 -5.728a1.5 1.5 0 0 1 .536 -2.022a1.867 1.867 0 0 1 2.28 .28l1.47 1.47" />
110
112
  </svg>
113
+ <BubbleTooltipAbove>Hand <span className="ml-1 text-white/55">H</span></BubbleTooltipAbove>
111
114
  </button>
112
115
 
113
116
  {/* Divider */}
114
- <div className="w-px h-5 bg-white/20 mx-1" />
117
+ <div className="w-px h-5 bg-white/15 mx-2" />
115
118
 
116
119
  {/* Zoom out */}
117
120
  <button
118
121
  onClick={handleZoomOut}
119
- className="flex items-center justify-center w-7 h-8 text-neutral-400 hover:text-white transition-colors text-sm"
120
- title="Zoom out (Ctrl + −)"
122
+ className="group/bb relative flex items-center justify-center w-7 h-8 text-neutral-400 hover:text-white transition-colors text-sm"
121
123
  aria-label="Zoom out"
122
124
  >
123
125
 
126
+ <BubbleTooltipAbove>Zoom out <span className="ml-1 text-white/55">Ctrl −</span></BubbleTooltipAbove>
124
127
  </button>
125
128
 
126
129
  {/* Zoom percentage (clickable dropdown) */}
127
130
  <div className="relative" ref={presetsRef}>
128
131
  <button
129
132
  onClick={() => setShowPresets((v) => !v)}
130
- className="flex items-center justify-center min-w-[48px] h-8 text-white text-[11px] font-medium hover:bg-white/10 rounded-md transition-colors px-1"
131
- title="Click to select zoom level"
133
+ className="group/bb relative flex items-center justify-center min-w-[48px] h-8 text-white text-[11px] font-medium hover:bg-white/10 rounded-md transition-colors px-1"
132
134
  aria-label="Select zoom level"
133
135
  >
134
136
  {zoomPercent}%
137
+ <BubbleTooltipAbove>Zoom level</BubbleTooltipAbove>
135
138
  </button>
136
139
 
137
140
  {showPresets && (
@@ -156,40 +159,27 @@ export default function CanvasToolbar({ viewportRef, onAnimatedAction }: CanvasT
156
159
  {/* Zoom in */}
157
160
  <button
158
161
  onClick={handleZoomIn}
159
- className="flex items-center justify-center w-7 h-8 text-neutral-400 hover:text-white transition-colors text-sm"
160
- title="Zoom in (Ctrl + +)"
162
+ className="group/bb relative flex items-center justify-center w-7 h-8 text-neutral-400 hover:text-white transition-colors text-sm"
161
163
  aria-label="Zoom in"
162
164
  >
163
165
  +
166
+ <BubbleTooltipAbove>Zoom in <span className="ml-1 text-white/55">Ctrl +</span></BubbleTooltipAbove>
164
167
  </button>
165
168
 
166
169
  {/* Divider */}
167
- <div className="w-px h-5 bg-white/20 mx-1" />
170
+ <div className="w-px h-5 bg-white/15 mx-2" />
168
171
 
169
- {/* Fit */}
172
+ {/* Fit — tabler zoom (magnifying glass, outline) */}
170
173
  <button
171
174
  onClick={handleFit}
172
- className="flex items-center justify-center w-8 h-8 rounded-full text-neutral-400 hover:text-white hover:bg-white/10 transition-colors"
173
- title="Zoom to fit (Ctrl + 0)"
175
+ className="group/bb relative flex items-center justify-center w-8 h-8 rounded-full text-neutral-400 hover:text-white hover:bg-white/10 transition-colors"
174
176
  aria-label="Zoom to fit"
175
177
  >
176
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
177
- <rect
178
- x="2"
179
- y="2"
180
- width="10"
181
- height="10"
182
- rx="1.5"
183
- stroke="currentColor"
184
- strokeWidth="1.5"
185
- />
186
- <path
187
- d="M5 2V0M9 2V0M5 14V12M9 14V12M0 5H2M0 9H2M12 5H14M12 9H14"
188
- stroke="currentColor"
189
- strokeWidth="1"
190
- opacity="0.5"
191
- />
178
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
179
+ <path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" />
180
+ <path d="M21 21l-6 -6" />
192
181
  </svg>
182
+ <BubbleTooltipAbove>Zoom to fit <span className="ml-1 text-white/55">Ctrl 0</span></BubbleTooltipAbove>
193
183
  </button>
194
184
  </div>
195
185
  );