@morphika/andami 0.2.3 → 0.2.5

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.
@@ -165,6 +165,32 @@ export default function ProjectGridBlockRenderer({
165
165
  const borderRadius = block.border_radius || 0;
166
166
  const videoMode = block.video_mode || "off";
167
167
 
168
+ // ─── Allow scale hover to overflow the V2 column ───
169
+ // V2 columns have overflow:hidden (CSS Grid img protection). The project
170
+ // grid uses absolute positioning so it doesn't need that protection.
171
+ // When scale hover is active, we relax the parent column's overflow so
172
+ // the scaled card isn't clipped at the column boundary.
173
+ const hasProjects = resolvedProjects.length > 0;
174
+
175
+ useEffect(() => {
176
+ if (hoverEffect !== "scale" && hoverEffect !== "3d") return;
177
+ const el = containerRef.current;
178
+ if (!el) return;
179
+
180
+ // Walk up to find the V2 column (class starts with "sv2-col-")
181
+ let col: HTMLElement | null = el.parentElement;
182
+ while (col && !col.className?.includes("sv2-col-")) {
183
+ col = col.parentElement;
184
+ }
185
+ if (!col) return;
186
+
187
+ const prev = col.style.overflow;
188
+ col.style.overflow = "visible";
189
+ return () => {
190
+ col.style.overflow = prev;
191
+ };
192
+ }, [hoverEffect, hasProjects]);
193
+
168
194
  // ─── Build masonry items (viewport-aware per-card overrides) ───
169
195
  const masonryItems: MasonryItem[] = useMemo(() => {
170
196
  return resolvedProjects.map((proj, i) => {
@@ -226,11 +252,7 @@ export default function ProjectGridBlockRenderer({
226
252
  // animations at once (with stagger). No per-card observer needed.
227
253
  const [gridVisible, setGridVisible] = useState(false);
228
254
 
229
- // Track whether we have projects loaded needed as a dep so the
230
- // IntersectionObserver effect re-runs after the async fetch populates
231
- // resolvedProjects (on the first render the grid div isn't mounted yet
232
- // because the component returns null when resolvedProjects is empty).
233
- const hasProjects = resolvedProjects.length > 0;
255
+ // hasProjects is declared above (used by overflow + entrance effects)
234
256
 
235
257
  useEffect(() => {
236
258
  if (!entranceEnabled || gridVisible || !hasProjects) return;
@@ -250,7 +272,13 @@ export default function ProjectGridBlockRenderer({
250
272
  observer.disconnect();
251
273
  }
252
274
  },
253
- { threshold: 0.05 },
275
+ {
276
+ threshold: 0.01,
277
+ // Trigger 200px before the grid scrolls into view so cards
278
+ // start animating just before the user sees them (especially
279
+ // on mobile where the grid may sit just below the fold).
280
+ rootMargin: "200px 0px",
281
+ },
254
282
  );
255
283
 
256
284
  observer.observe(el);
@@ -336,10 +364,6 @@ export default function ProjectGridBlockRenderer({
336
364
  top: item.y,
337
365
  width: item.width,
338
366
  height: item.height,
339
- // Clip the card when hover scale-up overflows the cell bounds
340
- // so border-radius is preserved visually.
341
- overflow: "hidden",
342
- borderRadius: borderRadius > 0 ? borderRadius : undefined,
343
367
  }}
344
368
  >
345
369
  {entranceEnabled && entranceAnimConfig ? (
@@ -438,7 +462,12 @@ const ProjectCard = memo(function ProjectCard({
438
462
  const handlePlay = useCallback(() => setIsPlaying(true), []);
439
463
  const handlePause = useCallback(() => setIsPlaying(false), []);
440
464
 
441
- // Scale transform
465
+ // Scale transform — applied to the Link wrapper so the entire card
466
+ // (including its border-radius clipping shape) scales uniformly.
467
+ // The inner div keeps overflow:hidden + borderRadius for content clipping.
468
+ // Applying scale on the same element as overflow:hidden + borderRadius
469
+ // causes GPU compositing artifacts where the border-radius clips at
470
+ // pre-transform bounds, making rounded corners disappear during scale.
442
471
  const scaleTransform =
443
472
  hoverEffect === "scale" && hovered ? "scale(1.03)" : "scale(1)";
444
473
 
@@ -446,7 +475,19 @@ const ProjectCard = memo(function ProjectCard({
446
475
  <Link
447
476
  href={`/work/${project.slug}`}
448
477
  className="block"
449
- style={{ display: "block", width: "100%", height: "100%" }}
478
+ style={{
479
+ display: "block",
480
+ width: "100%",
481
+ height: "100%",
482
+ // Lift hovered card above neighbours so scale doesn't clip behind them
483
+ position: "relative",
484
+ zIndex: hovered && hoverEffect !== "none" ? 2 : undefined,
485
+ // Scale applied here (outside overflow:hidden + borderRadius) so the
486
+ // entire clipping shape scales with the content.
487
+ transition:
488
+ hoverEffect === "scale" ? "transform 300ms ease" : undefined,
489
+ transform: hoverEffect === "scale" ? scaleTransform : undefined,
490
+ }}
450
491
  onMouseEnter={handleMouseEnter}
451
492
  onMouseLeave={handleMouseLeave}
452
493
  onMouseMove={hoverEffect === "3d" ? handleMouseMove : undefined}
@@ -459,13 +500,11 @@ const ProjectCard = memo(function ProjectCard({
459
500
  height: "100%",
460
501
  overflow: "hidden",
461
502
  borderRadius: radius,
503
+ // 3D tilt still uses cardRef for per-pixel mouse tracking
462
504
  transition:
463
505
  hoverEffect === "3d"
464
506
  ? "transform 100ms ease-out"
465
- : hoverEffect === "scale"
466
- ? "transform 300ms ease"
467
- : undefined,
468
- transform: hoverEffect === "scale" ? scaleTransform : undefined,
507
+ : undefined,
469
508
  }}
470
509
  >
471
510
  {/* Thumbnail image */}
@@ -10,6 +10,7 @@ import SortableBlock from "./SortableBlock";
10
10
  import { useColumnDragContext } from "./ColumnDragContext";
11
11
  import { useColumnResize } from "./hooks/useColumnResize";
12
12
  import { InsertionLines } from "./InsertionLines";
13
+ import { isSectionBlockSection } from "../../lib/builder/types";
13
14
 
14
15
  // ============================================
15
16
  // SectionV2Canvas — Renders a V2 section in the builder
@@ -248,8 +249,8 @@ export default function SectionV2Canvas({
248
249
  );
249
250
  })}
250
251
 
251
- {/* Gap buttons: "+ Add Column" — also act as hit-test targets for custom column drag */}
252
- {!previewMode &&
252
+ {/* Gap buttons: "+ Add Column" — hidden for section-level blocks (locked layout) */}
253
+ {!previewMode && !isSectionBlockSection(section) &&
253
254
  gaps.map((gap) => {
254
255
  const isGapTarget =
255
256
  dropTarget?.type === "gap" &&
@@ -215,9 +215,12 @@ export default function SectionV2Column({
215
215
  }, [onAddBlock, column.blocks]);
216
216
 
217
217
  const hasBlocks = (column.blocks || []).length > 0;
218
- const showChrome = (isSelected || isHovered) && !isDraggedColumn;
218
+ // Section-level blocks (e.g. projectGridBlock) own the full section —
219
+ // hide all column management chrome (resize, delete, drag, span badge).
220
+ const isLockedColumn = !!singleSectionBlock;
221
+ const showChrome = (isSelected || isHovered) && !isDraggedColumn && !isLockedColumn;
219
222
  // Show faint outlines when section is hovered but not this specific column
220
- const showFaintOutline = isSectionHovered && !isHovered && !isSelected && !isDraggedColumn;
223
+ const showFaintOutline = isSectionHovered && !isHovered && !isSelected && !isDraggedColumn && !isLockedColumn;
221
224
 
222
225
  // Column-level vertical alignment from blocks' align_v settings
223
226
  const colJustify = getColumnVerticalAlign(column.blocks || []);
@@ -494,7 +497,8 @@ export default function SectionV2Column({
494
497
  </SortableContext>
495
498
 
496
499
  {/* "+" add block button below blocks — absolutely positioned to avoid disrupting flex alignment */}
497
- {hasBlocks && (
500
+ {/* Hidden for section-level blocks (e.g. projectGridBlock) that own the full column */}
501
+ {hasBlocks && !singleSectionBlock && (
498
502
  <div
499
503
  className={`absolute left-0 right-0 z-[3] transition-all ${
500
504
  showChrome ? "opacity-100" : showFaintOutline ? "opacity-30" : "opacity-0 pointer-events-none"
@@ -122,16 +122,17 @@ export default function SortableBlock({
122
122
  onMouseEnter={() => setIsHovered(true)}
123
123
  onMouseLeave={() => setIsHovered(false)}
124
124
  >
125
- {/* Hover/selection outline overlay — scales with zoom */}
125
+ {/* Hover/selection outline overlay — scales with zoom, offset inward so it doesn't overlap column outlines */}
126
126
  <div
127
- className="pointer-events-none absolute inset-0 z-[3] rounded transition-[box-shadow]"
128
- style={
129
- isSelected
127
+ className="pointer-events-none absolute z-[3] rounded transition-[box-shadow]"
128
+ style={{
129
+ inset: `${Math.max(2, Math.min(5, 3 / canvasZoom))}px`,
130
+ ...(isSelected
130
131
  ? { boxShadow: `inset 0 0 0 ${Math.max(2, Math.min(5, 3 / canvasZoom))}px ${BUILDER_ORANGE}` }
131
132
  : isHovered
132
133
  ? { boxShadow: `inset 0 0 0 ${Math.max(2, Math.min(5, 3 / canvasZoom))}px rgba(13, 150, 104, 0.4)` }
133
- : undefined
134
- }
134
+ : {}),
135
+ }}
135
136
  />
136
137
 
137
138
  {/* Floating toolbar — centered INSIDE top of block, appears on hover or when selected.
@@ -6,7 +6,7 @@ import { CSS } from "@dnd-kit/utilities";
6
6
  import { makeRowId } from "./DndWrapper";
7
7
  import { useBuilderStore } from "../../lib/builder/store";
8
8
  import { DEFAULT_GRID_WIDTH } from "../../lib/builder/constants";
9
- import { DEVICE_HEIGHTS } from "../../lib/builder/types";
9
+ import { DEVICE_HEIGHTS, isSectionBlockSection } from "../../lib/builder/types";
10
10
  import type { ReactNode } from "react";
11
11
  import type { ContentItem, PageSectionV2, CustomSectionInstance, ParallaxGroup } from "../../lib/sanity/types";
12
12
  import { isPageSectionV2, isCustomSectionInstance, isParallaxGroup } from "../../lib/sanity/types";
@@ -41,6 +41,12 @@ function getSectionLabel(item: ContentItem): string | null {
41
41
  return "Parallax Showcase";
42
42
  }
43
43
  if (isPageSectionV2(item)) {
44
+ const section = item as PageSectionV2;
45
+ if (isSectionBlockSection(section)) {
46
+ // Show the block type name instead of generic "Section"
47
+ const blockType = section.columns?.[0]?.blocks?.[0]?._type;
48
+ if (blockType === "projectGridBlock") return "Project Grid";
49
+ }
44
50
  return "Section";
45
51
  }
46
52
  return null;
@@ -78,6 +84,7 @@ export default function SortableRow({
78
84
  children,
79
85
  }: SortableRowProps) {
80
86
  const previewMode = useBuilderStore((s) => s.previewMode);
87
+ const selectBlock = useBuilderStore((s) => s.selectBlock);
81
88
  const canvasZoom = useBuilderStore((s) => s.canvasZoom);
82
89
  const activeViewport = useBuilderStore((s) => s.activeViewport);
83
90
  const gridSettings = useBuilderStore((s) => s.gridSettings);
@@ -102,6 +109,9 @@ export default function SortableRow({
102
109
  // Determine if this is a PageSectionV2
103
110
  const isV2Section = isPageSectionV2(row);
104
111
  const sectionLabel = getSectionLabel(row);
112
+ // Section-level blocks (e.g. projectGridBlock) own the full section —
113
+ // no column management UI, selecting section auto-selects the block.
114
+ const isLockedSection = isV2Section && isSectionBlockSection(row as PageSectionV2);
105
115
 
106
116
  // For sections: use section settings — viewport-aware for both V1 and V2 sections
107
117
  const resolvedSettings = useMemo(() => {
@@ -223,20 +233,28 @@ export default function SortableRow({
223
233
  className={`relative transition-[opacity,box-shadow] ${
224
234
  isDragging ? "ring-2 ring-[#93278f] ring-offset-2 ring-offset-[#0a0a0a]" : ""
225
235
  }`}
226
- onClick={(e) => { e.stopPropagation(); onSelect(); }}
236
+ onClick={(e) => {
237
+ e.stopPropagation();
238
+ if (isLockedSection) {
239
+ const blockKey = (row as PageSectionV2).columns?.[0]?.blocks?.[0]?._key;
240
+ if (blockKey) { selectBlock(blockKey); return; }
241
+ }
242
+ onSelect();
243
+ }}
227
244
  onMouseEnter={() => setIsHovered(true)}
228
245
  onMouseLeave={() => setIsHovered(false)}
229
246
  >
230
- {/* Selection/hover outline — scales with zoom */}
247
+ {/* Selection/hover outline — scales with zoom, offset outward so it doesn't overlap column/block outlines */}
231
248
  <div
232
- className="pointer-events-none absolute inset-0 z-[1] transition-[box-shadow]"
233
- style={
234
- isSelected
249
+ className="pointer-events-none absolute z-[1] transition-[box-shadow]"
250
+ style={{
251
+ inset: `${-Math.max(2, Math.min(5, 3 / canvasZoom))}px`,
252
+ ...(isSelected
235
253
  ? { boxShadow: `inset 0 0 0 ${Math.max(2, Math.min(5, 3 / canvasZoom))}px #93278f` }
236
254
  : isHovered
237
255
  ? { boxShadow: `inset 0 0 0 ${Math.max(2, Math.min(5, 3 / canvasZoom))}px rgba(147, 39, 143, 0.4)` }
238
- : undefined
239
- }
256
+ : {}),
257
+ }}
240
258
  />
241
259
 
242
260
  {/* Section toolbar — wide pill aligned top-left outside the row */}
@@ -249,7 +267,14 @@ export default function SortableRow({
249
267
  transformOrigin: "top right",
250
268
  width: "90px",
251
269
  }}
252
- onClick={(e) => { e.stopPropagation(); onSelect(); }}
270
+ onClick={(e) => {
271
+ e.stopPropagation();
272
+ if (isLockedSection) {
273
+ const blockKey = (row as PageSectionV2).columns?.[0]?.blocks?.[0]?._key;
274
+ if (blockKey) { selectBlock(blockKey); return; }
275
+ }
276
+ onSelect();
277
+ }}
253
278
  >
254
279
  {/* Main toolbar — drag + actions */}
255
280
  <div
@@ -301,8 +326,8 @@ export default function SortableRow({
301
326
  </button>
302
327
  </div>
303
328
 
304
- {/* Add column — shown for V2 sections and regular rows, hidden for closed sections (V1 PageSection) */}
305
- {(!sectionLabel || isV2Section) && (
329
+ {/* Add column — shown for V2 sections and regular rows, hidden for section blocks and closed sections */}
330
+ {(!sectionLabel || isV2Section) && !isLockedSection && (
306
331
  <button
307
332
  onClick={(e) => { e.stopPropagation(); onAddColumn(); }}
308
333
  onPointerDown={(e) => e.stopPropagation()}
@@ -6,7 +6,6 @@ import { getEffectiveValue, setResponsiveOverride } from "../../../lib/builder/r
6
6
  import type { TextBlock, ContentBlock } from "../../../lib/sanity/types";
7
7
  import type { DeviceViewport } from "../../../lib/builder/types";
8
8
  import {
9
- TextIcon,
10
9
  TypographyIcon,
11
10
  ColumnsIcon,
12
11
  } from "./section-icons";
@@ -271,8 +270,8 @@ export default function TextBlockEditor({ block }: { block: TextBlock }) {
271
270
  <>
272
271
  <ViewportBadge />
273
272
 
274
- {/* Text section: Style, Color, Align */}
275
- <SettingsSection title="Text" defaultOpen icon={<TextIcon />}>
273
+ {/* Typography section: Style, Color, Align, Size, Weight, Line height, Letter spacing, Transform */}
274
+ <SettingsSection title="Typography" defaultOpen icon={<TypographyIcon />}>
276
275
  <SettingsField label="Style">
277
276
  <TextStylePicker
278
277
  presets={presets}
@@ -309,10 +308,7 @@ export default function TextBlockEditor({ block }: { block: TextBlock }) {
309
308
  ))}
310
309
  </div>
311
310
  </ResponsiveStyleField>
312
- </SettingsSection>
313
311
 
314
- {/* Typography section: Size, Weight, Line height, Letter spacing */}
315
- <SettingsSection title="Typography" defaultOpen icon={<TypographyIcon />}>
316
312
  <ResponsiveStyleField label="Size" subProp="fontSize" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("fontSize")} onReset={resetStyleOverride}>
317
313
  <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)]">
318
314
  <input
@@ -18,6 +18,7 @@ import {
18
18
  SettingsSection,
19
19
  } from "../editors/shared";
20
20
  import { findGaps } from "../../../lib/builder/cascade";
21
+ import { isSectionBlockSection } from "../../../lib/builder/types";
21
22
  import {
22
23
  getSectionV2SettingValue,
23
24
  hasSectionV2SettingOverride,
@@ -65,6 +66,9 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
65
66
  const addColumnV2 = useBuilderStore((s) => s.addColumnV2);
66
67
  const currentPreset = section.settings.preset;
67
68
 
69
+ // Section-level blocks own the full column — hide layout presets and add column
70
+ if (isSectionBlockSection(section)) return null;
71
+
68
72
  const allPresets = currentPreset === "custom"
69
73
  ? [...PRESETS, CUSTOM_PRESET]
70
74
  : PRESETS;
@@ -80,6 +80,16 @@ export function isSectionBlockType(type: string): boolean {
80
80
  return SECTION_BLOCK_TYPES.has(type);
81
81
  }
82
82
 
83
+ /** Check if a V2 section contains a single section-level block (e.g. projectGridBlock).
84
+ * These sections behave like top-level content items: no column management,
85
+ * selecting the section auto-selects the block. */
86
+ export function isSectionBlockSection(section: { columns?: Array<{ blocks?: Array<{ _type: string }> }> }): boolean {
87
+ const cols = section.columns || [];
88
+ if (cols.length !== 1) return false;
89
+ const blocks = cols[0].blocks || [];
90
+ return blocks.length === 1 && SECTION_BLOCK_TYPES.has(blocks[0]._type);
91
+ }
92
+
83
93
  export type SectionType = "empty-v2" | "parallaxGroup" | SectionBlockType;
84
94
 
85
95
  export interface SectionTypeInfo {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphika/andami",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Visual Page Builder — core library. A reusable website builder with visual editing, CMS integration, and asset management.",
5
5
  "type": "module",
6
6
  "license": "MIT",