@morphika/andami 0.1.2

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 (319) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/admin/assets.ts +4 -0
  4. package/admin/database.ts +4 -0
  5. package/admin/index.ts +6 -0
  6. package/admin/login.ts +4 -0
  7. package/admin/navigation.ts +4 -0
  8. package/admin/pages-editor.ts +4 -0
  9. package/admin/pages.ts +4 -0
  10. package/admin/projects-editor.ts +4 -0
  11. package/admin/projects.ts +4 -0
  12. package/admin/settings.ts +4 -0
  13. package/admin/setup.ts +4 -0
  14. package/admin/storage.ts +4 -0
  15. package/admin/styles.ts +4 -0
  16. package/app/(site)/[slug]/loading.tsx +20 -0
  17. package/app/(site)/[slug]/page.tsx +83 -0
  18. package/app/(site)/error.tsx +32 -0
  19. package/app/(site)/layout.tsx +53 -0
  20. package/app/(site)/loading.tsx +20 -0
  21. package/app/(site)/not-found.tsx +41 -0
  22. package/app/(site)/page.tsx +43 -0
  23. package/app/(site)/preview/page.tsx +99 -0
  24. package/app/(site)/work/[slug]/loading.tsx +23 -0
  25. package/app/(site)/work/[slug]/page.tsx +84 -0
  26. package/app/admin/assets/page.tsx +573 -0
  27. package/app/admin/database/page.tsx +302 -0
  28. package/app/admin/error.tsx +53 -0
  29. package/app/admin/layout.tsx +273 -0
  30. package/app/admin/login/page.tsx +88 -0
  31. package/app/admin/navigation/page.tsx +157 -0
  32. package/app/admin/page.tsx +17 -0
  33. package/app/admin/pages/[slug]/page.tsx +849 -0
  34. package/app/admin/pages/page.tsx +588 -0
  35. package/app/admin/projects/[slug]/page.tsx +3 -0
  36. package/app/admin/projects/page.tsx +669 -0
  37. package/app/admin/settings/page.tsx +132 -0
  38. package/app/admin/setup/page.tsx +64 -0
  39. package/app/admin/storage/page.tsx +518 -0
  40. package/app/admin/styles/page.tsx +243 -0
  41. package/app/api/admin/assets/file/route.ts +81 -0
  42. package/app/api/admin/assets/health/route.ts +170 -0
  43. package/app/api/admin/assets/register/route.ts +163 -0
  44. package/app/api/admin/assets/registry/route.ts +98 -0
  45. package/app/api/admin/assets/relink/confirm/route.ts +242 -0
  46. package/app/api/admin/assets/relink/route.ts +202 -0
  47. package/app/api/admin/assets/scan/route.ts +271 -0
  48. package/app/api/admin/auth/route.ts +160 -0
  49. package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
  50. package/app/api/admin/custom-sections/route.ts +127 -0
  51. package/app/api/admin/database/route.ts +53 -0
  52. package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
  53. package/app/api/admin/pages/[slug]/route.ts +617 -0
  54. package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
  55. package/app/api/admin/pages/route.ts +129 -0
  56. package/app/api/admin/preview/route.ts +53 -0
  57. package/app/api/admin/r2/connect/route.ts +181 -0
  58. package/app/api/admin/r2/delete/route.ts +198 -0
  59. package/app/api/admin/r2/disconnect/route.ts +42 -0
  60. package/app/api/admin/r2/rename/route.ts +265 -0
  61. package/app/api/admin/r2/status/route.ts +106 -0
  62. package/app/api/admin/r2/upload-url/route.ts +148 -0
  63. package/app/api/admin/revalidate/route.ts +55 -0
  64. package/app/api/admin/settings/route.ts +279 -0
  65. package/app/api/admin/setup/complete/route.ts +51 -0
  66. package/app/api/admin/setup/route.ts +118 -0
  67. package/app/api/admin/storage/switch/route.ts +117 -0
  68. package/app/api/admin/styles/fonts/route.ts +97 -0
  69. package/app/api/admin/styles/route.ts +304 -0
  70. package/app/api/assets/[...path]/route.ts +98 -0
  71. package/app/api/custom-sections/[id]/route.ts +43 -0
  72. package/app/api/draft-mode/disable/route.ts +10 -0
  73. package/app/api/draft-mode/enable/route.ts +26 -0
  74. package/app/api/projects/route.ts +42 -0
  75. package/app/api/styles/route.ts +88 -0
  76. package/app/favicon.ico +0 -0
  77. package/app/globals.css +7 -0
  78. package/app/layout.tsx +53 -0
  79. package/app/robots.ts +17 -0
  80. package/app/sitemap.ts +48 -0
  81. package/app/studio/[[...index]]/page.tsx +8 -0
  82. package/components/admin/MetadataEditor.tsx +173 -0
  83. package/components/admin/PublishToggle.tsx +130 -0
  84. package/components/admin/icons.tsx +40 -0
  85. package/components/admin/nav-builder/NavBuilder.tsx +182 -0
  86. package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
  87. package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
  88. package/components/admin/nav-builder/NavGridCell.tsx +48 -0
  89. package/components/admin/nav-builder/NavGridItem.tsx +189 -0
  90. package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
  91. package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
  92. package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
  93. package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
  94. package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
  95. package/components/admin/nav-builder/index.ts +10 -0
  96. package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
  97. package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
  98. package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
  99. package/components/admin/setup-wizard/DoneStep.tsx +187 -0
  100. package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
  101. package/components/admin/setup-wizard/StorageStep.tsx +308 -0
  102. package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
  103. package/components/admin/setup-wizard/index.ts +9 -0
  104. package/components/admin/styles/ColorsEditor.tsx +214 -0
  105. package/components/admin/styles/FontsEditor.tsx +258 -0
  106. package/components/admin/styles/GridLayoutEditor.tsx +292 -0
  107. package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
  108. package/components/admin/styles/TypographyEditor.tsx +266 -0
  109. package/components/admin/styles/index.ts +9 -0
  110. package/components/admin/styles/shared.tsx +68 -0
  111. package/components/blocks/BlockRenderer.tsx +404 -0
  112. package/components/blocks/ButtonBlockRenderer.tsx +52 -0
  113. package/components/blocks/CoverBlockRenderer.tsx +239 -0
  114. package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
  115. package/components/blocks/EnterAnimationWrapper.tsx +140 -0
  116. package/components/blocks/HoverAnimationWrapper.tsx +308 -0
  117. package/components/blocks/ImageBlockRenderer.tsx +61 -0
  118. package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
  119. package/components/blocks/PageBackground.tsx +28 -0
  120. package/components/blocks/PageNavAnimation.tsx +35 -0
  121. package/components/blocks/PageNavColor.tsx +24 -0
  122. package/components/blocks/PageRenderer.tsx +142 -0
  123. package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
  124. package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
  125. package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
  126. package/components/blocks/SectionRenderer.tsx +170 -0
  127. package/components/blocks/SectionV2Renderer.tsx +330 -0
  128. package/components/blocks/ShaderCanvas.tsx +392 -0
  129. package/components/blocks/SpacerBlockRenderer.tsx +17 -0
  130. package/components/blocks/TextBlockRenderer.tsx +87 -0
  131. package/components/blocks/TypewriterRichText.tsx +464 -0
  132. package/components/blocks/TypewriterWrapper.tsx +149 -0
  133. package/components/blocks/VideoBlockRenderer.tsx +304 -0
  134. package/components/blocks/index.ts +2 -0
  135. package/components/builder/AssetBrowser.tsx +2 -0
  136. package/components/builder/BlockLivePreview.tsx +101 -0
  137. package/components/builder/BlockTypePicker.tsx +178 -0
  138. package/components/builder/BuilderCanvas.tsx +354 -0
  139. package/components/builder/CanvasMinimap.tsx +200 -0
  140. package/components/builder/CanvasToolbar.tsx +202 -0
  141. package/components/builder/ColorPicker.tsx +243 -0
  142. package/components/builder/ColorSwatchPicker.tsx +274 -0
  143. package/components/builder/ColumnDragContext.tsx +51 -0
  144. package/components/builder/ColumnDragOverlay.tsx +110 -0
  145. package/components/builder/CustomSectionInstanceCard.tsx +97 -0
  146. package/components/builder/DeviceFrame.tsx +123 -0
  147. package/components/builder/DndWrapper.tsx +337 -0
  148. package/components/builder/InsertionLines.tsx +186 -0
  149. package/components/builder/ParallaxGroupCanvas.tsx +228 -0
  150. package/components/builder/ParallaxSlideHeader.tsx +113 -0
  151. package/components/builder/ReadOnlyFrame.tsx +417 -0
  152. package/components/builder/SectionEditorBar.tsx +288 -0
  153. package/components/builder/SectionTypePicker.tsx +422 -0
  154. package/components/builder/SectionV2Canvas.tsx +297 -0
  155. package/components/builder/SectionV2Column.tsx +488 -0
  156. package/components/builder/SettingsPanel.tsx +911 -0
  157. package/components/builder/SortableBlock.tsx +230 -0
  158. package/components/builder/SortableRow.tsx +362 -0
  159. package/components/builder/VirtualAssetGrid.tsx +397 -0
  160. package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
  161. package/components/builder/asset-browser/FileLightbox.tsx +116 -0
  162. package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
  163. package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
  164. package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
  165. package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
  166. package/components/builder/asset-browser/helpers.ts +88 -0
  167. package/components/builder/asset-browser/index.ts +1 -0
  168. package/components/builder/asset-browser/types.ts +49 -0
  169. package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
  170. package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
  171. package/components/builder/asset-browser/useR2Operations.ts +189 -0
  172. package/components/builder/blockStyles.tsx +295 -0
  173. package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
  174. package/components/builder/editors/CoverBlockEditor.tsx +488 -0
  175. package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
  176. package/components/builder/editors/HoverEffectPicker.tsx +209 -0
  177. package/components/builder/editors/ImageBlockEditor.tsx +206 -0
  178. package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
  179. package/components/builder/editors/ProjectGridEditor.tsx +648 -0
  180. package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
  181. package/components/builder/editors/StaggerSettings.tsx +108 -0
  182. package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
  183. package/components/builder/editors/TextBlockEditor.tsx +462 -0
  184. package/components/builder/editors/TextStylePicker.tsx +183 -0
  185. package/components/builder/editors/VideoBlockEditor.tsx +278 -0
  186. package/components/builder/editors/index.ts +10 -0
  187. package/components/builder/editors/shared.tsx +345 -0
  188. package/components/builder/hooks/useColumnDrag.ts +472 -0
  189. package/components/builder/hooks/useColumnResize.ts +221 -0
  190. package/components/builder/index.ts +12 -0
  191. package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
  192. package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
  193. package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
  194. package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
  195. package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
  196. package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
  197. package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
  198. package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
  199. package/components/builder/live-preview/index.ts +10 -0
  200. package/components/builder/live-preview/shared.tsx +153 -0
  201. package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
  202. package/components/builder/settings-panel/BlockSettings.tsx +94 -0
  203. package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
  204. package/components/builder/settings-panel/LayoutTab.tsx +310 -0
  205. package/components/builder/settings-panel/PageSettings.tsx +200 -0
  206. package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
  207. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
  208. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
  209. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
  210. package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
  211. package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
  212. package/components/builder/settings-panel/index.ts +19 -0
  213. package/components/builder/settings-panel/responsive-helpers.ts +524 -0
  214. package/components/ui/CustomCursor.tsx +118 -0
  215. package/components/ui/NavContentLightbox.tsx +152 -0
  216. package/components/ui/Navbar.tsx +582 -0
  217. package/components/ui/PortfolioTracker.tsx +87 -0
  218. package/components/ui/ScrollToTop.tsx +47 -0
  219. package/lib/animation/enter-presets.ts +147 -0
  220. package/lib/animation/enter-resolve.ts +90 -0
  221. package/lib/animation/enter-types.ts +128 -0
  222. package/lib/animation/hover-effect-presets.ts +210 -0
  223. package/lib/animation/hover-effect-types.ts +126 -0
  224. package/lib/asset-retry.ts +111 -0
  225. package/lib/assets.ts +92 -0
  226. package/lib/audit.ts +35 -0
  227. package/lib/auth-token.ts +94 -0
  228. package/lib/auth.ts +13 -0
  229. package/lib/builder/cascade-helpers.ts +51 -0
  230. package/lib/builder/cascade.ts +533 -0
  231. package/lib/builder/constants.ts +103 -0
  232. package/lib/builder/defaults.ts +182 -0
  233. package/lib/builder/history.ts +48 -0
  234. package/lib/builder/index.ts +21 -0
  235. package/lib/builder/layout-styles.ts +344 -0
  236. package/lib/builder/masonry.ts +166 -0
  237. package/lib/builder/responsive.ts +156 -0
  238. package/lib/builder/serializer.ts +845 -0
  239. package/lib/builder/store-blocks.ts +193 -0
  240. package/lib/builder/store-canvas.ts +319 -0
  241. package/lib/builder/store-helpers.ts +490 -0
  242. package/lib/builder/store-sections.ts +709 -0
  243. package/lib/builder/store.ts +333 -0
  244. package/lib/builder/templates.ts +297 -0
  245. package/lib/builder/types.ts +374 -0
  246. package/lib/builder/utils.ts +37 -0
  247. package/lib/color-utils.ts +116 -0
  248. package/lib/config/index.ts +57 -0
  249. package/lib/config/types.ts +122 -0
  250. package/lib/contexts/AssetContext.tsx +79 -0
  251. package/lib/contexts/NavAnimationContext.tsx +44 -0
  252. package/lib/contexts/NavColorContext.tsx +38 -0
  253. package/lib/contexts/PageExitContext.tsx +194 -0
  254. package/lib/contexts/ThumbStatusContext.tsx +83 -0
  255. package/lib/csrf-client.ts +34 -0
  256. package/lib/csrf.ts +68 -0
  257. package/lib/format-utils.ts +24 -0
  258. package/lib/hooks/useViewport.ts +42 -0
  259. package/lib/logger.ts +81 -0
  260. package/lib/revalidate.ts +23 -0
  261. package/lib/sanitize.ts +91 -0
  262. package/lib/sanity/client.ts +8 -0
  263. package/lib/sanity/queries.ts +486 -0
  264. package/lib/sanity/types.ts +869 -0
  265. package/lib/sanity/writeClient.ts +24 -0
  266. package/lib/security.ts +402 -0
  267. package/lib/setup/detect.ts +156 -0
  268. package/lib/shader/glsl/index.ts +27 -0
  269. package/lib/shader/glsl/pixelate.ts +51 -0
  270. package/lib/shader/glsl/rgb-shift.ts +45 -0
  271. package/lib/shader/glsl/ripple.ts +46 -0
  272. package/lib/shader/glsl/vertex.ts +14 -0
  273. package/lib/storage/index.ts +211 -0
  274. package/lib/storage/r2-adapter.ts +286 -0
  275. package/lib/storage/types.ts +125 -0
  276. package/lib/styles/provider.tsx +267 -0
  277. package/lib/thumbnails/generate.ts +151 -0
  278. package/lib/utils.ts +6 -0
  279. package/package.json +212 -0
  280. package/sanity/compose.ts +65 -0
  281. package/sanity/sanity.config.ts +126 -0
  282. package/sanity/schemas/assetRegistry.ts +301 -0
  283. package/sanity/schemas/blocks/blockLayout.ts +90 -0
  284. package/sanity/schemas/blocks/buttonBlock.ts +82 -0
  285. package/sanity/schemas/blocks/coverBlock.ts +229 -0
  286. package/sanity/schemas/blocks/imageBlock.ts +58 -0
  287. package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
  288. package/sanity/schemas/blocks/index.ts +9 -0
  289. package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
  290. package/sanity/schemas/blocks/spacerBlock.ts +41 -0
  291. package/sanity/schemas/blocks/textBlock.ts +139 -0
  292. package/sanity/schemas/blocks/videoBlock.ts +80 -0
  293. package/sanity/schemas/customSection.ts +69 -0
  294. package/sanity/schemas/customSectionInstance.ts +163 -0
  295. package/sanity/schemas/index.ts +111 -0
  296. package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
  297. package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
  298. package/sanity/schemas/objects/parallaxGroup.ts +66 -0
  299. package/sanity/schemas/objects/parallaxSlide.ts +217 -0
  300. package/sanity/schemas/objects/typewriterConfig.ts +38 -0
  301. package/sanity/schemas/page.ts +162 -0
  302. package/sanity/schemas/pageSection.ts +157 -0
  303. package/sanity/schemas/pageSectionV2.ts +269 -0
  304. package/sanity/schemas/siteSettings.ts +256 -0
  305. package/sanity/schemas/siteStyles.ts +212 -0
  306. package/site/error.ts +4 -0
  307. package/site/index.ts +8 -0
  308. package/site/not-found.ts +4 -0
  309. package/site/page.ts +4 -0
  310. package/site/preview.ts +4 -0
  311. package/site/robots.ts +4 -0
  312. package/site/sitemap.ts +4 -0
  313. package/site/work.ts +4 -0
  314. package/studio/index.ts +4 -0
  315. package/styles/admin.css +85 -0
  316. package/styles/animations.css +237 -0
  317. package/styles/base.css +148 -0
  318. package/styles/globals.css +10 -0
  319. package/tsconfig.json +25 -0
@@ -0,0 +1,462 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, type ReactNode } from "react";
4
+ import { useBuilderStore } from "../../../lib/builder/store";
5
+ import { getEffectiveValue, setResponsiveOverride } from "../../../lib/builder/responsive";
6
+ import type { TextBlock, ContentBlock } from "../../../lib/sanity/types";
7
+ import type { DeviceViewport } from "../../../lib/builder/types";
8
+ import {
9
+ SettingsSection,
10
+ SettingsField,
11
+ ViewportBadge,
12
+ ResponsiveField,
13
+ useActiveViewport,
14
+ INPUT_CLASS,
15
+ SELECT_CLASS,
16
+ } from "./shared";
17
+ import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
18
+ import TextStylePicker, {
19
+ FALLBACK_PRESETS,
20
+ buildPresetsFromStyles,
21
+ type TextStylePreset,
22
+ } from "./TextStylePicker";
23
+ import {
24
+ AlignLeftIcon,
25
+ AlignCenterIcon,
26
+ AlignRightIcon,
27
+ AlignJustifyIcon,
28
+ } from "./TextAlignmentIcons";
29
+
30
+ // ============================================
31
+ // Responsive style field — MUST be defined outside the editor component
32
+ // to avoid React treating it as a new component on every re-render,
33
+ // which causes input elements to lose focus.
34
+ // ============================================
35
+
36
+ function ResponsiveStyleField({
37
+ label,
38
+ subProp,
39
+ viewport,
40
+ isOverridden,
41
+ onReset,
42
+ children,
43
+ hint,
44
+ }: {
45
+ label: string;
46
+ subProp: string;
47
+ viewport: DeviceViewport;
48
+ isOverridden: boolean;
49
+ onReset: (subProp: string) => void;
50
+ children: ReactNode;
51
+ hint?: string;
52
+ }) {
53
+ return (
54
+ <div className="flex items-start gap-3 mb-2 last:mb-0">
55
+ <label className="text-[11px] text-neutral-400 w-[68px] min-w-[68px] shrink-0 pt-[7px] leading-tight">
56
+ {label}
57
+ {viewport !== "desktop" && !isOverridden && (
58
+ <span className="block text-[9px] text-neutral-300 italic mt-0.5">inherited</span>
59
+ )}
60
+ {isOverridden && (
61
+ <span className="block text-[9px] text-[#076bff] mt-0.5">overridden</span>
62
+ )}
63
+ </label>
64
+ <div className="flex-1 min-w-0">
65
+ {children}
66
+ {hint && <p className="text-[10px] text-neutral-400 mt-1">{hint}</p>}
67
+ {isOverridden && (
68
+ <button
69
+ onClick={() => onReset(subProp)}
70
+ className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors mt-0.5"
71
+ >
72
+ Reset
73
+ </button>
74
+ )}
75
+ </div>
76
+ </div>
77
+ );
78
+ }
79
+
80
+ // ============================================
81
+ // Main Editor
82
+ // ============================================
83
+
84
+ export default function TextBlockEditor({ block }: { block: TextBlock }) {
85
+ const store = useBuilderStore();
86
+ const viewport = useActiveViewport();
87
+ const paletteSwatches = usePaletteSwatches();
88
+ const pageTextColor = store.pageSettings.text_color || "#0a0a0a";
89
+ const [presets, setPresets] = useState<TextStylePreset[]>(FALLBACK_PRESETS);
90
+
91
+ useEffect(() => {
92
+ fetch("/api/admin/styles", { credentials: "include" })
93
+ .then((r) => r.json())
94
+ .then((data) => {
95
+ if (data.styles) {
96
+ const built = buildPresetsFromStyles(data.styles);
97
+ if (built.length > 0) setPresets(built);
98
+ }
99
+ })
100
+ .catch(() => { /* Style presets unavailable — fallback presets used */ });
101
+ }, []);
102
+
103
+ const style = block.style || {};
104
+ // Undo snapshot strategy:
105
+ // - Continuous inputs (text fields, sliders): snapshot on focus (one snapshot per edit session)
106
+ // - Discrete actions (buttons, preset picks): snapshot immediately before mutation
107
+ const snapshotOnFocus = () => store._pushSnapshot();
108
+
109
+ // === Responsive helpers for nested style sub-properties ===
110
+ // The responsive system deep-merges 1-level objects, so we store
111
+ // partial style overrides at responsive[viewport].style = { fontSize: 24 }
112
+ // and resolveBlock merges { ...block.style, ...responsive[viewport].style }.
113
+
114
+ /** Get the current responsive style overrides for the active viewport */
115
+ const getViewportStyleOverrides = (): Record<string, unknown> => {
116
+ const responsive = (block as unknown as Record<string, unknown>).responsive as
117
+ | Record<string, Record<string, unknown>>
118
+ | undefined;
119
+ if (!responsive?.[viewport]?.style) return {};
120
+ return responsive[viewport].style as Record<string, unknown>;
121
+ };
122
+
123
+ /** Check if a style sub-property has a responsive override */
124
+ const hasStyleOverride = (subProp: string): boolean => {
125
+ if (viewport === "desktop") return true;
126
+ const overrides = getViewportStyleOverrides();
127
+ return subProp in overrides;
128
+ };
129
+
130
+ /** Get effective value of a style sub-property for the active viewport */
131
+ const getEffectiveStyleValue = <T,>(subProp: string, baseValue: T): T => {
132
+ if (viewport === "desktop") return baseValue;
133
+ const overrides = getViewportStyleOverrides();
134
+ return subProp in overrides ? (overrides[subProp] as T) : baseValue;
135
+ };
136
+
137
+ /** Update a style sub-property, responsive-aware */
138
+ const updateStyleResponsive = (subProp: string, value: unknown) => {
139
+ if (viewport === "desktop") {
140
+ store.updateBlock(block._key, {
141
+ style: { ...style, [subProp]: value },
142
+ } as Partial<ContentBlock>);
143
+ } else {
144
+ // Merge into responsive[viewport].style
145
+ const existing = (block as unknown as Record<string, unknown>).responsive as
146
+ | Record<string, Record<string, unknown>>
147
+ | undefined || {};
148
+ const vpOverrides = { ...(existing[viewport] || {}) };
149
+ const styleOverrides = { ...((vpOverrides.style as Record<string, unknown>) || {}), [subProp]: value };
150
+ vpOverrides.style = styleOverrides;
151
+ store.updateBlock(block._key, {
152
+ responsive: { ...existing, [viewport]: vpOverrides },
153
+ } as Partial<ContentBlock>);
154
+ }
155
+ };
156
+
157
+ /** Update a style sub-property with debounce, responsive-aware */
158
+ const updateStyleDebouncedResponsive = (subProp: string, value: unknown) => {
159
+ if (viewport === "desktop") {
160
+ store.updateBlockDebounced(block._key, {
161
+ style: { ...style, [subProp]: value },
162
+ } as Partial<ContentBlock>);
163
+ } else {
164
+ const existing = (block as unknown as Record<string, unknown>).responsive as
165
+ | Record<string, Record<string, unknown>>
166
+ | undefined || {};
167
+ const vpOverrides = { ...(existing[viewport] || {}) };
168
+ const styleOverrides = { ...((vpOverrides.style as Record<string, unknown>) || {}), [subProp]: value };
169
+ vpOverrides.style = styleOverrides;
170
+ store.updateBlockDebounced(block._key, {
171
+ responsive: { ...existing, [viewport]: vpOverrides },
172
+ } as Partial<ContentBlock>);
173
+ }
174
+ };
175
+
176
+ /** Reset a style sub-property override */
177
+ const resetStyleOverride = (subProp: string) => {
178
+ const existing = (block as unknown as Record<string, unknown>).responsive as
179
+ | Record<string, Record<string, unknown>>
180
+ | undefined || {};
181
+ const vpOverrides = { ...(existing[viewport] || {}) };
182
+ const styleOverrides = { ...((vpOverrides.style as Record<string, unknown>) || {}) };
183
+ delete styleOverrides[subProp];
184
+ if (Object.keys(styleOverrides).length === 0) {
185
+ delete vpOverrides.style;
186
+ } else {
187
+ vpOverrides.style = styleOverrides;
188
+ }
189
+ const responsive = { ...existing };
190
+ if (Object.keys(vpOverrides).length === 0) {
191
+ delete responsive[viewport];
192
+ } else {
193
+ responsive[viewport] = vpOverrides;
194
+ }
195
+ store.updateBlock(block._key, { responsive } as Partial<ContentBlock>);
196
+ };
197
+
198
+ // === Responsive helpers for top-level properties (e.g. columns) ===
199
+ const updateResponsive = (property: string, value: unknown) => {
200
+ if (viewport === "desktop") {
201
+ store.updateBlock(block._key, { [property]: value } as Partial<ContentBlock>);
202
+ } else {
203
+ const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, value);
204
+ store.updateBlock(block._key, overrides as Partial<ContentBlock>);
205
+ }
206
+ };
207
+
208
+ const resetOverride = (property: string) => {
209
+ const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, undefined);
210
+ store.updateBlock(block._key, overrides as Partial<ContentBlock>);
211
+ };
212
+
213
+ const baseFontSizePx = (() => {
214
+ const fs = style.fontSize;
215
+ if (typeof fs === "number") return fs;
216
+ const legacyMap: Record<string, number> = { small: 12, base: 14, large: 20, xl: 24, "2xl": 32, "3xl": 48 };
217
+ return legacyMap[fs || "base"] || 14;
218
+ })();
219
+
220
+ // Responsive-aware effective values
221
+ const currentFontSizePx = getEffectiveStyleValue<number>("fontSize", baseFontSizePx);
222
+ const currentAlignment = getEffectiveStyleValue<string>("alignment", style.alignment || "left");
223
+ const currentMaxWidth = getEffectiveStyleValue<string>("maxWidth", style.maxWidth || "");
224
+ const effectiveColumns = getEffectiveValue<number>(block as ContentBlock, viewport, "columns", block.columns || 1);
225
+
226
+ const baseFontWeight = (() => {
227
+ const fw = style.fontWeight;
228
+ if (!fw) return "400";
229
+ if (!isNaN(parseInt(fw, 10))) return fw;
230
+ if (fw === "bold") return "700";
231
+ if (fw === "medium") return "500";
232
+ return "400";
233
+ })();
234
+ const currentFontWeight = getEffectiveStyleValue<string>("fontWeight", baseFontWeight);
235
+
236
+ const handleStyleSelect = (preset: TextStylePreset) => {
237
+ store._pushSnapshot();
238
+ store.updateBlock(block._key, {
239
+ textStyle: preset.key,
240
+ style: {
241
+ ...style,
242
+ fontSize: preset.fontSize,
243
+ fontWeight: preset.fontWeight,
244
+ lineHeight: preset.lineHeight,
245
+ letterSpacing: preset.letterSpacing,
246
+ textTransform: preset.textTransform as TextBlock["style"] extends undefined ? never : NonNullable<TextBlock["style"]>["textTransform"],
247
+ },
248
+ } as Partial<ContentBlock>);
249
+ };
250
+
251
+ const handleStyleClear = () => {
252
+ store._pushSnapshot();
253
+ store.updateBlock(block._key, {
254
+ textStyle: undefined,
255
+ } as Partial<ContentBlock>);
256
+ };
257
+
258
+ const alignments: { value: "left" | "center" | "right" | "justify"; icon: React.ReactNode }[] = [
259
+ { value: "left", icon: <AlignLeftIcon /> },
260
+ { value: "center", icon: <AlignCenterIcon /> },
261
+ { value: "right", icon: <AlignRightIcon /> },
262
+ { value: "justify", icon: <AlignJustifyIcon /> },
263
+ ];
264
+
265
+ return (
266
+ <>
267
+ <ViewportBadge />
268
+
269
+ {/* Text section: Style, Color, Align */}
270
+ <SettingsSection title="Text" defaultOpen>
271
+ <SettingsField label="Style">
272
+ <TextStylePicker
273
+ presets={presets}
274
+ activeKey={block.textStyle}
275
+ onSelect={handleStyleSelect}
276
+ onClear={handleStyleClear}
277
+ />
278
+ </SettingsField>
279
+
280
+ <ResponsiveStyleField label="Color" subProp="color" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("color")} onReset={resetStyleOverride}>
281
+ <ColorSwatchPicker
282
+ value={getEffectiveStyleValue<string>("color", style.color || "")}
283
+ onChange={(hex) => updateStyleResponsive("color", hex)}
284
+ swatches={paletteSwatches}
285
+ allowClear
286
+ />
287
+ </ResponsiveStyleField>
288
+
289
+ <ResponsiveStyleField label="Align" subProp="alignment" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("alignment")} onReset={resetStyleOverride}>
290
+ <div className="flex gap-0.5 bg-[#f5f5f5] rounded-lg p-0.5">
291
+ {alignments.map(({ value, icon }) => (
292
+ <button
293
+ key={value}
294
+ onClick={() => updateStyleResponsive("alignment", value)}
295
+ className={`flex-1 flex items-center justify-center py-[5px] rounded-md transition-all ${
296
+ currentAlignment === value
297
+ ? "bg-white text-neutral-900 shadow-[0_1px_3px_rgba(0,0,0,0.08)]"
298
+ : "text-neutral-300 hover:text-neutral-500"
299
+ }`}
300
+ title={value.charAt(0).toUpperCase() + value.slice(1)}
301
+ >
302
+ {icon}
303
+ </button>
304
+ ))}
305
+ </div>
306
+ </ResponsiveStyleField>
307
+ </SettingsSection>
308
+
309
+ {/* Typography section: Size, Weight, Line height, Letter spacing */}
310
+ <SettingsSection title="Typography" defaultOpen>
311
+ <ResponsiveStyleField label="Size" subProp="fontSize" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("fontSize")} onReset={resetStyleOverride}>
312
+ <div className="flex items-center gap-0 bg-[#f5f5f5] rounded-lg overflow-hidden transition-all border border-transparent focus-within:bg-white focus-within:border-[#076bff] focus-within:shadow-[0_0_0_3px_rgba(7,107,255,0.06)]">
313
+ <input
314
+ type="number"
315
+ min={1}
316
+ max={999}
317
+ value={currentFontSizePx}
318
+ onFocus={snapshotOnFocus}
319
+ onChange={(e) => {
320
+ const val = parseInt(e.target.value, 10);
321
+ if (!isNaN(val) && val > 0) {
322
+ updateStyleDebouncedResponsive("fontSize", val);
323
+ if (viewport === "desktop" && block.textStyle) {
324
+ store.updateBlockDebounced(block._key, { textStyle: undefined } as Partial<ContentBlock>);
325
+ }
326
+ }
327
+ }}
328
+ className="flex-1 min-w-0 bg-transparent border-none px-2.5 py-[7px] text-xs text-neutral-900 outline-none"
329
+ />
330
+ <span className="text-[10px] text-neutral-400 pr-2.5 shrink-0 select-none">px</span>
331
+ </div>
332
+ </ResponsiveStyleField>
333
+
334
+ <ResponsiveStyleField label="Weight" subProp="fontWeight" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("fontWeight")} onReset={resetStyleOverride}>
335
+ <select
336
+ value={currentFontWeight}
337
+ onChange={(e) => {
338
+ updateStyleResponsive("fontWeight", e.target.value);
339
+ if (viewport === "desktop" && block.textStyle) {
340
+ store.updateBlock(block._key, { textStyle: undefined } as Partial<ContentBlock>);
341
+ }
342
+ }}
343
+ className={SELECT_CLASS}
344
+ >
345
+ <option value="100">Thin (100)</option>
346
+ <option value="200">ExtraLight (200)</option>
347
+ <option value="300">Light (300)</option>
348
+ <option value="400">Regular (400)</option>
349
+ <option value="500">Medium (500)</option>
350
+ <option value="600">SemiBold (600)</option>
351
+ <option value="700">Bold (700)</option>
352
+ <option value="800">ExtraBold (800)</option>
353
+ <option value="900">Black (900)</option>
354
+ </select>
355
+ </ResponsiveStyleField>
356
+
357
+ <ResponsiveStyleField label="Line height" subProp="lineHeight" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("lineHeight")} onReset={resetStyleOverride}>
358
+ <input
359
+ type="text"
360
+ value={getEffectiveStyleValue<string>("lineHeight", style.lineHeight || "")}
361
+ onFocus={snapshotOnFocus}
362
+ onChange={(e) => {
363
+ updateStyleDebouncedResponsive("lineHeight", e.target.value);
364
+ if (viewport === "desktop" && block.textStyle) {
365
+ store.updateBlockDebounced(block._key, { textStyle: undefined } as Partial<ContentBlock>);
366
+ }
367
+ }}
368
+ placeholder="1.5"
369
+ className={INPUT_CLASS}
370
+ />
371
+ </ResponsiveStyleField>
372
+
373
+ <ResponsiveStyleField label="Spacing" subProp="letterSpacing" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("letterSpacing")} onReset={resetStyleOverride}>
374
+ <input
375
+ type="text"
376
+ value={getEffectiveStyleValue<string>("letterSpacing", style.letterSpacing || "")}
377
+ onFocus={snapshotOnFocus}
378
+ onChange={(e) => {
379
+ updateStyleDebouncedResponsive("letterSpacing", e.target.value);
380
+ if (viewport === "desktop" && block.textStyle) {
381
+ store.updateBlockDebounced(block._key, { textStyle: undefined } as Partial<ContentBlock>);
382
+ }
383
+ }}
384
+ placeholder="0, -0.02em, 2px"
385
+ className={INPUT_CLASS}
386
+ />
387
+ </ResponsiveStyleField>
388
+
389
+ <ResponsiveStyleField label="Transform" subProp="textTransform" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("textTransform")} onReset={resetStyleOverride}>
390
+ <select
391
+ value={getEffectiveStyleValue<string>("textTransform", style.textTransform || "none")}
392
+ onChange={(e) => updateStyleResponsive("textTransform", e.target.value)}
393
+ className={SELECT_CLASS}
394
+ >
395
+ <option value="none">None</option>
396
+ <option value="uppercase">UPPERCASE</option>
397
+ <option value="lowercase">lowercase</option>
398
+ <option value="capitalize">Capitalize</option>
399
+ </select>
400
+ </ResponsiveStyleField>
401
+ </SettingsSection>
402
+
403
+ {/* Columns section */}
404
+ <SettingsSection title="Columns">
405
+ <ResponsiveField
406
+ label="Columns"
407
+ block={block as ContentBlock}
408
+ property="columns"
409
+ onReset={() => resetOverride("columns")}
410
+ >
411
+ <div className="flex gap-0.5 bg-[#f5f5f5] rounded-lg p-0.5">
412
+ {[1, 2, 3, 4].map((n) => (
413
+ <button
414
+ key={n}
415
+ onClick={() => {
416
+ store._pushSnapshot();
417
+ updateResponsive("columns", n);
418
+ }}
419
+ className={`flex-1 flex items-center justify-center py-[5px] rounded-md text-xs transition-all ${
420
+ effectiveColumns === n
421
+ ? "bg-white text-neutral-900 shadow-[0_1px_3px_rgba(0,0,0,0.08)] font-medium"
422
+ : "text-neutral-400 hover:text-neutral-600"
423
+ }`}
424
+ >
425
+ {n}
426
+ </button>
427
+ ))}
428
+ </div>
429
+ </ResponsiveField>
430
+
431
+ <ResponsiveStyleField label="Max Width" subProp="maxWidth" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("maxWidth")} onReset={resetStyleOverride}>
432
+ <input
433
+ type="text"
434
+ value={currentMaxWidth}
435
+ onFocus={snapshotOnFocus}
436
+ onChange={(e) => updateStyleDebouncedResponsive("maxWidth", e.target.value)}
437
+ placeholder="none, 600px, 80%"
438
+ className={INPUT_CLASS}
439
+ />
440
+ </ResponsiveStyleField>
441
+
442
+ <ResponsiveStyleField label="Opacity" subProp="opacity" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("opacity")} onReset={resetStyleOverride}>
443
+ <div className="flex items-center gap-2">
444
+ <input
445
+ type="range"
446
+ min={0}
447
+ max={100}
448
+ value={Math.round((getEffectiveStyleValue<number>("opacity", style.opacity ?? 1)) * 100)}
449
+ onChange={(e) =>
450
+ updateStyleResponsive("opacity", parseInt(e.target.value) / 100)
451
+ }
452
+ className="flex-1 accent-[#076bff]"
453
+ />
454
+ <span className="text-xs text-neutral-900 w-10 text-right tabular-nums">
455
+ {Math.round((getEffectiveStyleValue<number>("opacity", style.opacity ?? 1)) * 100)}%
456
+ </span>
457
+ </div>
458
+ </ResponsiveStyleField>
459
+ </SettingsSection>
460
+ </>
461
+ );
462
+ }
@@ -0,0 +1,183 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import type { SiteStyles, TypographyLevel } from "../../../lib/sanity/types";
5
+
6
+ // ============================================
7
+ // Types
8
+ // ============================================
9
+
10
+ export interface TextStylePreset {
11
+ key: string;
12
+ label: string;
13
+ tag: string;
14
+ fontSize: number;
15
+ fontWeight: string;
16
+ lineHeight: string;
17
+ letterSpacing: string;
18
+ textTransform?: string;
19
+ }
20
+
21
+ // ============================================
22
+ // Preset builders
23
+ // ============================================
24
+
25
+ export const FALLBACK_PRESETS: TextStylePreset[] = [
26
+ { key: "h1", label: "Heading 1", tag: "H1", fontSize: 48, fontWeight: "700", lineHeight: "1.1", letterSpacing: "-0.02em" },
27
+ { key: "h2", label: "Heading 2", tag: "H2", fontSize: 32, fontWeight: "700", lineHeight: "1.2", letterSpacing: "-0.01em" },
28
+ { key: "h3", label: "Heading 3", tag: "H3", fontSize: 24, fontWeight: "500", lineHeight: "1.3", letterSpacing: "0" },
29
+ { key: "h4", label: "Heading 4", tag: "H4", fontSize: 18, fontWeight: "500", lineHeight: "1.4", letterSpacing: "0" },
30
+ { key: "body", label: "Body", tag: "P", fontSize: 14, fontWeight: "400", lineHeight: "1.6", letterSpacing: "0" },
31
+ { key: "small", label: "Small", tag: "P", fontSize: 12, fontWeight: "400", lineHeight: "1.5", letterSpacing: "0.02em" },
32
+ ];
33
+
34
+ function parsePxValue(val: string): number {
35
+ const match = val.match(/([\d.]+)/);
36
+ if (!match) return 14;
37
+ const num = parseFloat(match[1]);
38
+ if (val.includes("rem")) return Math.round(num * 16);
39
+ return Math.round(num);
40
+ }
41
+
42
+ export function buildPresetsFromStyles(styles: SiteStyles): TextStylePreset[] {
43
+ const typo = styles.typography;
44
+ if (!typo) return FALLBACK_PRESETS;
45
+
46
+ const levels: { key: string; label: string; tag: string; data?: TypographyLevel | null }[] = [
47
+ { key: "h1", label: "Heading 1", tag: "H1", data: typo.h1 },
48
+ { key: "h2", label: "Heading 2", tag: "H2", data: typo.h2 },
49
+ { key: "h3", label: "Heading 3", tag: "H3", data: typo.h3 },
50
+ { key: "h4", label: "Heading 4", tag: "H4", data: typo.h4 },
51
+ { key: "body", label: "Body", tag: "P", data: typo.body },
52
+ { key: "small", label: "Small", tag: "P", data: typo.small },
53
+ ];
54
+
55
+ return levels
56
+ .filter((l) => l.data)
57
+ .map((l) => ({
58
+ key: l.key,
59
+ label: l.label,
60
+ tag: l.tag,
61
+ fontSize: parsePxValue(l.data!.font_size),
62
+ fontWeight: l.data!.font_weight || "400",
63
+ lineHeight: l.data!.line_height || "1.5",
64
+ letterSpacing: l.data!.letter_spacing || "0",
65
+ textTransform: l.data!.text_transform,
66
+ }));
67
+ }
68
+
69
+ // ============================================
70
+ // Style Picker — Framer-style chip + dropdown
71
+ // ============================================
72
+
73
+ export default function TextStylePicker({
74
+ presets,
75
+ activeKey,
76
+ onSelect,
77
+ onClear,
78
+ }: {
79
+ presets: TextStylePreset[];
80
+ activeKey: string | undefined;
81
+ onSelect: (preset: TextStylePreset) => void;
82
+ onClear: () => void;
83
+ }) {
84
+ const [open, setOpen] = useState(false);
85
+ const [search, setSearch] = useState("");
86
+
87
+ const filtered = presets.filter(
88
+ (p) => p.label.toLowerCase().includes(search.toLowerCase())
89
+ );
90
+
91
+ const active = presets.find((p) => p.key === activeKey);
92
+
93
+ return (
94
+ <div className="relative">
95
+ {/* Chip trigger */}
96
+ <button
97
+ onClick={() => setOpen(!open)}
98
+ className="w-full flex items-center gap-2 rounded-lg bg-[#f5f5f5] px-2.5 py-[7px] text-xs text-neutral-900 hover:bg-[#efefef] transition-colors"
99
+ >
100
+ {active ? (
101
+ <>
102
+ <span className="flex items-center justify-center w-5 h-5 rounded bg-[#e8e8e8] text-[9px] font-semibold text-neutral-500 shrink-0">
103
+ {active.tag}
104
+ </span>
105
+ <span className="flex-1 text-left truncate">{active.label}</span>
106
+ <button
107
+ onClick={(e) => { e.stopPropagation(); onClear(); }}
108
+ className="w-[18px] h-[18px] rounded-full flex items-center justify-center text-neutral-300 hover:bg-[#e5e5e5] hover:text-neutral-500 transition-colors shrink-0 text-sm"
109
+ >
110
+ ×
111
+ </button>
112
+ </>
113
+ ) : (
114
+ <>
115
+ <span className="flex items-center justify-center w-5 h-5 rounded bg-[#e8e8e8] text-[9px] text-neutral-400 shrink-0">
116
+
117
+ </span>
118
+ <span className="flex-1 text-left text-neutral-400">Custom</span>
119
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" className="shrink-0 text-neutral-400">
120
+ <path d="M2.5 4L5 6.5L7.5 4" />
121
+ </svg>
122
+ </>
123
+ )}
124
+ </button>
125
+
126
+ {open && (
127
+ <>
128
+ <div className="fixed inset-0 z-[100]" onClick={() => { setOpen(false); setSearch(""); }} />
129
+ <div className="absolute left-0 right-0 top-full mt-1 z-[101] bg-white rounded-lg border border-neutral-200 shadow-xl overflow-hidden">
130
+ <div className="p-2 border-b border-[#f0f0f0]">
131
+ <input
132
+ type="text"
133
+ value={search}
134
+ onChange={(e) => setSearch(e.target.value)}
135
+ placeholder="Search..."
136
+ autoFocus
137
+ className="w-full rounded-md bg-[#f5f5f5] px-2 py-1.5 text-xs text-neutral-900 border-none outline-none focus:bg-white focus:ring-1 focus:ring-[#076bff]/30"
138
+ />
139
+ </div>
140
+ <div className="max-h-[220px] overflow-y-auto py-1">
141
+ {filtered.map((preset) => (
142
+ <button
143
+ key={preset.key}
144
+ onClick={() => {
145
+ onSelect(preset);
146
+ setOpen(false);
147
+ setSearch("");
148
+ }}
149
+ className={`w-full flex items-center gap-2.5 px-3 py-2 text-xs transition-colors ${
150
+ activeKey === preset.key
151
+ ? "bg-[#f5f5f5] text-neutral-900"
152
+ : "text-neutral-600 hover:bg-[#fafafa]"
153
+ }`}
154
+ >
155
+ <span className="flex items-center justify-center w-5 h-5 rounded bg-[#e8e8e8] text-[9px] font-semibold text-neutral-500 shrink-0">
156
+ {preset.tag}
157
+ </span>
158
+ <span className="flex-1 text-left">{preset.label}</span>
159
+ <span className="text-[10px] text-neutral-400">
160
+ {preset.fontSize}px / {preset.lineHeight}
161
+ </span>
162
+ </button>
163
+ ))}
164
+ {filtered.length === 0 && (
165
+ <p className="px-3 py-2 text-xs text-neutral-400 text-center">No styles found</p>
166
+ )}
167
+ </div>
168
+ {activeKey && (
169
+ <div className="border-t border-[#f0f0f0] p-1">
170
+ <button
171
+ onClick={() => { onClear(); setOpen(false); setSearch(""); }}
172
+ className="w-full text-left px-3 py-1.5 text-xs text-neutral-500 hover:text-neutral-900 hover:bg-[#fafafa] rounded transition-colors"
173
+ >
174
+ Detach style (Custom)
175
+ </button>
176
+ </div>
177
+ )}
178
+ </div>
179
+ </>
180
+ )}
181
+ </div>
182
+ );
183
+ }