From 138e1a82739367e2e15047fd24c3d2b34aa64188 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 20:14:10 -0700 Subject: [PATCH 1/7] Bump muted/dim text contrast and block label legibility - --color-text-muted #71717a -> #8b8b96 (~4.9:1 on surface) - --color-text-dim #52525b -> #6e6e78 (~3.3:1, decorative-only text) - .block-item-label 10px -> 11px, .block-item-icon 18px -> 20px for hierarchy in the Blocks panel tile grid Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/styles/editor.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 409499e..2937a34 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -16,8 +16,8 @@ --color-border: #2d2d3a; --color-border-light: #3f3f46; --color-text: #e4e4e7; - --color-text-muted: #71717a; - --color-text-dim: #52525b; + --color-text-muted: #8b8b96; + --color-text-dim: #6e6e78; --color-accent: #3b82f6; --color-accent-hover: #2563eb; --color-accent-subtle: rgba(59, 130, 246, 0.12); @@ -704,7 +704,7 @@ body { } .block-item-icon { - font-size: 18px; + font-size: 20px; color: var(--color-text-muted); transition: color var(--transition-fast); } @@ -714,7 +714,7 @@ body { } .block-item-label { - font-size: 10px; + font-size: 11px; font-weight: 500; color: var(--color-text-muted); text-align: center; From eeb0660d83a6a1c83474c5118a1194f4261d73d6 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 20:14:29 -0700 Subject: [PATCH 2/7] Replace emoji-as-icons in editor chrome with Font Awesome Unicode emoji/glyphs (Sitesmith's sparkle, lock, close X) render as tofu on systems without an emoji font. Swap for the FA4 glyphs the rest of the chrome already uses: - SitesmithButton/SitesmithModal: sparkle -> fa-magic, lock -> fa-lock - ContextMenu "Ask Sitesmith" entry: sparkle -> fa-magic (via new optional MenuItem.icon field) - TemplateModal/SitesmithModal close buttons, PagesPanel delete, AssetsPanel delete/cancel: ✕ -> fa-times / fa-trash Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/panels/context-menu/ContextMenu.tsx | 10 ++++++++-- craft/src/panels/left/AssetsPanel.tsx | 4 ++-- craft/src/panels/left/PagesPanel.tsx | 2 +- craft/src/panels/sitesmith/SitesmithButton.tsx | 4 ++-- craft/src/panels/sitesmith/SitesmithModal.tsx | 7 +++++-- craft/src/panels/topbar/TemplateModal.tsx | 2 +- 6 files changed, 19 insertions(+), 10 deletions(-) diff --git a/craft/src/panels/context-menu/ContextMenu.tsx b/craft/src/panels/context-menu/ContextMenu.tsx index b1eb0b8..fa9371d 100644 --- a/craft/src/panels/context-menu/ContextMenu.tsx +++ b/craft/src/panels/context-menu/ContextMenu.tsx @@ -16,6 +16,8 @@ interface ContextMenuProps { interface MenuItem { label: string; + /** Font Awesome icon suffix (e.g. 'magic' for fa-magic), rendered before the label. */ + icon?: string; shortcut?: string; action: () => void; danger?: boolean; @@ -189,7 +191,8 @@ export const ContextMenu: React.FC = ({ const items: MenuItem[] = [ { - label: '✨ Ask Sitesmith', + label: 'Ask Sitesmith', + icon: 'magic', action: askSitesmith, disabled: isRoot, dividerAfter: true, @@ -291,7 +294,10 @@ export const ContextMenu: React.FC = ({ (e.target as HTMLElement).style.background = 'transparent'; }} > - {item.label} + + {item.icon && } + {item.label} + {item.shortcut && ( { cursor: 'pointer', }} > - ✕ + ) : ( @@ -328,7 +328,7 @@ export const AssetsPanel: React.FC = () => { onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }} onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }} > - ✕ + )} diff --git a/craft/src/panels/left/PagesPanel.tsx b/craft/src/panels/left/PagesPanel.tsx index a7e90a4..8b36fde 100644 --- a/craft/src/panels/left/PagesPanel.tsx +++ b/craft/src/panels/left/PagesPanel.tsx @@ -394,7 +394,7 @@ export const PagesPanel: React.FC = () => { cursor: 'pointer', }} > - ✕ + )} diff --git a/craft/src/panels/sitesmith/SitesmithButton.tsx b/craft/src/panels/sitesmith/SitesmithButton.tsx index c25e81d..6bc306a 100644 --- a/craft/src/panels/sitesmith/SitesmithButton.tsx +++ b/craft/src/panels/sitesmith/SitesmithButton.tsx @@ -21,8 +21,8 @@ export const SitesmithButton: React.FC = ({ onClick }) => { color: '#fff', border: 'none', padding: '6px 12px', borderRadius: 6, cursor: 'pointer', fontWeight: 500, }} > - ✨ Sitesmith - {locked && 🔒} + Sitesmith + {locked && } {capped && !locked && (cap)} ); diff --git a/craft/src/panels/sitesmith/SitesmithModal.tsx b/craft/src/panels/sitesmith/SitesmithModal.tsx index 6bb9ac7..56e793b 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.tsx @@ -103,7 +103,10 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { >
-
✨ Sitesmith
+
+ + Sitesmith +
{summary && summary.enabled && (
{summary.monthly_used} / {summary.monthly_cap} this month @@ -136,7 +139,7 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { ) )} - +
diff --git a/craft/src/panels/topbar/TemplateModal.tsx b/craft/src/panels/topbar/TemplateModal.tsx index 93b2227..75f3330 100644 --- a/craft/src/panels/topbar/TemplateModal.tsx +++ b/craft/src/panels/topbar/TemplateModal.tsx @@ -248,7 +248,7 @@ export const TemplateModal: React.FC = ({ open, onClose }) =

From b3e5009aecb616fb85ba44e5812b23ebc3e97566 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 20:14:38 -0700 Subject: [PATCH 3/7] Fix preset-grid orphan row for 5/6-item preset sets PresetButtonGrid rendered every preset set into a fixed 4-column .preset-grid, so 5-item sets (RADIUS_PRESETS, SPACING_PRESETS, IMAGE_RADIUS_PRESETS, FONT_WEIGHTS, NavStylePanel's GAP_PRESETS) wrapped a single lone button onto its own row, and the 6-item TEXT_SIZES split unevenly (4+2). PresetButtonGrid now derives a column count from presets.length via defaultPresetGridColumns() -- 5-item sets get a single row of 5, 6-item sets split into two even rows of 3, and anything else keeps the classic 4-column grid -- with an optional `columns` prop for explicit overrides. This fixes every existing call site automatically rather than threading an explicit count through each one. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../styles/PresetButtonGrid.columns.test.ts | 38 +++++++++++++++ craft/src/panels/right/styles/shared.tsx | 48 ++++++++++++++----- 2 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 craft/src/panels/right/styles/PresetButtonGrid.columns.test.ts diff --git a/craft/src/panels/right/styles/PresetButtonGrid.columns.test.ts b/craft/src/panels/right/styles/PresetButtonGrid.columns.test.ts new file mode 100644 index 0000000..415a75e --- /dev/null +++ b/craft/src/panels/right/styles/PresetButtonGrid.columns.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { defaultPresetGridColumns } from './shared'; +import { RADIUS_PRESETS, SPACING_PRESETS, IMAGE_RADIUS_PRESETS, FONT_WEIGHTS, TEXT_SIZES, FONT_FAMILIES } from '../../../constants/presets'; + +/* + * Regression: 5-item preset sets (RADIUS_PRESETS, SPACING_PRESETS, + * IMAGE_RADIUS_PRESETS, FONT_WEIGHTS, and NavStylePanel's ad-hoc + * GAP_PRESETS) left a lone orphan button on its own row under the old + * fixed 4-column .preset-grid. PresetButtonGrid now derives a column + * count from the preset length instead. + */ +describe('defaultPresetGridColumns', () => { + it('gives 5-item sets their own single row (no orphan)', () => { + expect(defaultPresetGridColumns(RADIUS_PRESETS.length)).toBe(5); + expect(defaultPresetGridColumns(SPACING_PRESETS.length)).toBe(5); + expect(defaultPresetGridColumns(IMAGE_RADIUS_PRESETS.length)).toBe(5); + expect(defaultPresetGridColumns(FONT_WEIGHTS.length)).toBe(5); + expect(defaultPresetGridColumns(5)).toBe(5); // NavStylePanel's GAP_PRESETS + }); + + it('splits 6-item sets into two even rows of 3 (was 4+2 uneven)', () => { + expect(defaultPresetGridColumns(TEXT_SIZES.length)).toBe(3); + }); + + it('keeps the classic 4-column grid for sets that already divide evenly', () => { + expect(defaultPresetGridColumns(FONT_FAMILIES.length)).toBe(4); // 8 items + expect(defaultPresetGridColumns(4)).toBe(4); + expect(defaultPresetGridColumns(3)).toBe(4); + }); + + it('never leaves a single orphan on the final row for any count 1-12', () => { + for (let n = 1; n <= 12; n++) { + const cols = defaultPresetGridColumns(n); + const isLoneOrphan = n > cols && n % cols === 1; + expect(isLoneOrphan).toBe(false); + } + }); +}); diff --git a/craft/src/panels/right/styles/shared.tsx b/craft/src/panels/right/styles/shared.tsx index 96f860a..953f948 100644 --- a/craft/src/panels/right/styles/shared.tsx +++ b/craft/src/panels/right/styles/shared.tsx @@ -84,20 +84,42 @@ interface PresetButtonGridProps { presets: { label: string; value: string }[]; activeValue: string | undefined; onSelect: (value: string) => void; + /** Explicit column count. When omitted, a column count is derived from + * `presets.length` (see `defaultPresetGridColumns`) so odd-sized preset + * sets (5, 6, ...) don't leave a lone orphan button dangling on its own + * row under the fixed 4-column grid. */ + columns?: number; } -export const PresetButtonGrid: React.FC = ({ presets, activeValue, onSelect }) => ( -
- {presets.map((p) => ( - - ))} -
-); + +/** Picks a column count that avoids a single orphan on the last row. + * 4-or-fewer presets keep the classic single row of 4. 5 gets its own + * row (5 cols). 6 splits into two even rows of 3. Anything else falls + * back to a 4- or 3-column grid depending on which divides evenly. */ +export function defaultPresetGridColumns(count: number): number { + if (count <= 4) return 4; + if (count === 5) return 5; + if (count === 6) return 3; + if (count % 4 === 0) return 4; + if (count % 3 === 0) return 3; + return 4; +} + +export const PresetButtonGrid: React.FC = ({ presets, activeValue, onSelect, columns }) => { + const cols = columns ?? defaultPresetGridColumns(presets.length); + return ( +
+ {presets.map((p) => ( + + ))} +
+ ); +}; interface GradientSwatchGridProps { activeValue: string | undefined; From 458069afb63222c84a9288e1e059a420576513af Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 20:14:58 -0700 Subject: [PATCH 4/7] Show empty-canvas hint on a page with no components yet .empty-canvas-hint existed in editor.css but was never rendered anywhere. Wire it up in Canvas.tsx: an EmptyCanvasHint component reads Craft's ROOT node via useEditor and shows the hint once ROOT exists with zero children, hiding again the instant something is dropped in or while a drag is in progress. It's absolutely positioned over the Frame with pointer-events: none so it never intercepts clicks/drops meant for the underlying (empty) canvas -- scoped to regular page editing only, not the header/footer editing mode. Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/editor/Canvas.tsx | 48 +++++++++++++--- craft/src/editor/EmptyCanvasHint.test.tsx | 67 +++++++++++++++++++++++ craft/src/styles/editor.css | 5 ++ 3 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 craft/src/editor/EmptyCanvasHint.test.tsx diff --git a/craft/src/editor/Canvas.tsx b/craft/src/editor/Canvas.tsx index a4a2ca5..25a46af 100644 --- a/craft/src/editor/Canvas.tsx +++ b/craft/src/editor/Canvas.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useRef, useEffect } from 'react'; -import { Frame, Element } from '@craftjs/core'; +import { Frame, Element, useEditor } from '@craftjs/core'; import { Container } from '../components/layout/Container'; import { usePages } from '../state/PageContext'; import { DeviceMode } from '../types'; @@ -93,6 +93,33 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote ); }; +/** + * First-run hint shown over the canvas drop area once the current page's + * root node exists and has no children yet. Hidden the instant something + * is dropped in, and while a drag is in progress (so it never fights the + * drop-target UI). `pointer-events: none` (see .empty-canvas-hint in + * editor.css) keeps it from intercepting clicks/drops meant for the + * underlying empty canvas. + */ +export const EmptyCanvasHint: React.FC = () => { + const { isEmpty, isDragging } = useEditor((state) => { + const root = state.nodes['ROOT']; + return { + isEmpty: !!root && root.data.nodes.length === 0, + isDragging: state.events.dragged.size > 0, + }; + }); + + if (!isEmpty || isDragging) return null; + + return ( +
+ + Drag blocks from the left panel, or pick a Template to start. +
+ ); +}; + export const Canvas: React.FC = ({ device }) => { const width = DEVICE_WIDTHS[device]; const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages(); @@ -140,14 +167,17 @@ export const Canvas: React.FC = ({ device }) => { )} - - - +
+ + + + {isEditingRegularPage && } +
{isEditingRegularPage && ( diff --git a/craft/src/editor/EmptyCanvasHint.test.tsx b/craft/src/editor/EmptyCanvasHint.test.tsx new file mode 100644 index 0000000..e1c28b8 --- /dev/null +++ b/craft/src/editor/EmptyCanvasHint.test.tsx @@ -0,0 +1,67 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core's + useEditor so we can drive editor state without a real tree. */ +let mockNodes: Record = {}; +let mockDraggedSize = 0; + +vi.mock('@craftjs/core', () => ({ + useEditor: (collect: (state: any) => any) => + collect({ + nodes: mockNodes, + events: { dragged: { size: mockDraggedSize } }, + }), +})); + +import { EmptyCanvasHint } from './Canvas'; + +let container: HTMLDivElement; +let root: Root; + +function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render(ui); + }); +} + +beforeEach(() => { + mockNodes = {}; + mockDraggedSize = 0; +}); + +describe('EmptyCanvasHint', () => { + test('renders nothing before ROOT has mounted (no root node yet)', () => { + render(); + expect(container.querySelector('.empty-canvas-hint')).toBeNull(); + container.remove(); + }); + + test('renders the hint once ROOT exists with zero children', () => { + mockNodes = { ROOT: { data: { nodes: [] } } }; + render(); + expect(container.querySelector('.empty-canvas-hint')).not.toBeNull(); + expect(container.textContent).toContain('Drag blocks from the left panel'); + container.remove(); + }); + + test('hides once the page has content', () => { + mockNodes = { ROOT: { data: { nodes: ['node-1'] } } }; + render(); + expect(container.querySelector('.empty-canvas-hint')).toBeNull(); + container.remove(); + }); + + test('hides while a drag is in progress, even on an empty root', () => { + mockNodes = { ROOT: { data: { nodes: [] } } }; + mockDraggedSize = 1; + render(); + expect(container.querySelector('.empty-canvas-hint')).toBeNull(); + container.remove(); + }); +}); diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 2937a34..e2754b2 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -1225,6 +1225,8 @@ body { Empty Canvas State -------------------------------------------------------------------------- */ .empty-canvas-hint { + position: absolute; + inset: 0; display: flex; flex-direction: column; align-items: center; @@ -1234,6 +1236,9 @@ body { font-size: 13px; text-align: center; gap: 12px; + /* Overlays the drop area without intercepting drags/clicks meant for the + underlying (empty) canvas root -- see Canvas.tsx. */ + pointer-events: none; } .empty-canvas-hint i { From 05e00c572db795bcacfb826ba10192612e29562f Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 20:15:08 -0700 Subject: [PATCH 5/7] Show component-indicator selection badge in the canvas .component-indicator existed in editor.css but was never rendered anywhere. Add RenderNode.tsx as a Craft.js override and wire it in App.tsx: for the currently-selected node (excluding ROOT) it portals a floating badge showing the node's displayName plus a "select parent" chevron wired to actions.selectNode(parentId). Every other node's render passes through untouched (a Fragment, no extra DOM), and the badge portals to document.body positioned via getBoundingClientRect rather than wrapping nodes in extra DOM, so it can't perturb canvas layout and never appears in toHtml export. Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/App.tsx | 3 +- craft/src/editor/RenderNode.test.tsx | 108 +++++++++++++++++++++++++++ craft/src/editor/RenderNode.tsx | 76 +++++++++++++++++++ craft/src/styles/editor.css | 30 +++++++- 4 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 craft/src/editor/RenderNode.test.tsx create mode 100644 craft/src/editor/RenderNode.tsx diff --git a/craft/src/App.tsx b/craft/src/App.tsx index 323ba61..bd6ec11 100644 --- a/craft/src/App.tsx +++ b/craft/src/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Editor } from '@craftjs/core'; import { EditorShell } from './editor/EditorShell'; +import { RenderNode } from './editor/RenderNode'; import { componentResolver } from './components/resolver'; import { WhpConfig } from './types'; import { EditorConfigProvider } from './state/EditorConfigContext'; @@ -23,7 +24,7 @@ export const App: React.FC = ({ whpConfig }) => { return ( - + diff --git a/craft/src/editor/RenderNode.test.tsx b/craft/src/editor/RenderNode.test.tsx new file mode 100644 index 0000000..6984d4d --- /dev/null +++ b/craft/src/editor/RenderNode.test.tsx @@ -0,0 +1,108 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core + so RenderNode (the override) can be driven without a + real Editor tree. document.body doubles as the portal target, same as + the component itself uses. */ +let mockNode: { + id: string; + selected: boolean; + dom: HTMLElement | null; + displayName: string; + parent: string | null; +}; +const selectNodeSpy = vi.fn(); + +vi.mock('@craftjs/core', () => ({ + useEditor: () => ({ actions: { selectNode: selectNodeSpy } }), + useNode: (collect?: (node: any) => any) => { + const node = { + events: { selected: mockNode.selected }, + dom: mockNode.dom, + data: { custom: {}, displayName: mockNode.displayName, parent: mockNode.parent }, + }; + return { id: mockNode.id, ...(collect ? collect(node) : {}) }; + }, +})); + +import { RenderNode } from './RenderNode'; + +let container: HTMLDivElement; +let root: Root; +let nodeDom: HTMLElement; + +function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render(ui); + }); +} + +beforeEach(() => { + nodeDom = document.createElement('div'); + document.body.appendChild(nodeDom); + mockNode = { id: 'node-1', selected: false, dom: nodeDom, displayName: 'Heading', parent: 'ROOT' }; + selectNodeSpy.mockClear(); +}); + +const rendered = hello; + +describe('RenderNode (Editor onRender override)', () => { + test('passes render through untouched when not selected', () => { + render(); + expect(container.querySelector('[data-testid="inner"]')).not.toBeNull(); + expect(document.querySelector('.component-indicator')).toBeNull(); + container.remove(); + nodeDom.remove(); + }); + + test('shows the badge with the displayName when selected', () => { + mockNode.selected = true; + render(); + const badge = document.querySelector('.component-indicator'); + expect(badge).not.toBeNull(); + expect(badge?.textContent).toContain('Heading'); + container.remove(); + nodeDom.remove(); + document.querySelector('.component-indicator')?.remove(); + }); + + test('never shows a badge for ROOT even if "selected"', () => { + mockNode.selected = true; + mockNode.id = 'ROOT'; + render(); + expect(document.querySelector('.component-indicator')).toBeNull(); + container.remove(); + nodeDom.remove(); + }); + + test('chevron click selects the parent node', () => { + mockNode.selected = true; + mockNode.parent = 'parent-42'; + render(); + const chevron = document.querySelector('.component-indicator-parent-btn') as HTMLElement; + expect(chevron).not.toBeNull(); + act(() => { + chevron.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + }); + expect(selectNodeSpy).toHaveBeenCalledWith('parent-42'); + container.remove(); + nodeDom.remove(); + document.querySelector('.component-indicator')?.remove(); + }); + + test('no chevron when there is no parent', () => { + mockNode.selected = true; + mockNode.parent = null; + render(); + expect(document.querySelector('.component-indicator-parent-btn')).toBeNull(); + container.remove(); + nodeDom.remove(); + document.querySelector('.component-indicator')?.remove(); + }); +}); diff --git a/craft/src/editor/RenderNode.tsx b/craft/src/editor/RenderNode.tsx new file mode 100644 index 0000000..ca96216 --- /dev/null +++ b/craft/src/editor/RenderNode.tsx @@ -0,0 +1,76 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useEditor, useNode } from '@craftjs/core'; + +interface RenderNodeProps { + render: React.ReactElement; +} + +/** + * Craft.js `` override -- wraps every node's render output. + * For the currently-selected node it portals a floating badge (component + * displayName + a "select parent" chevron) positioned over the node's real + * DOM element. Non-selected nodes (the overwhelming majority) and ROOT pass + * straight through as a Fragment, so this never touches layout, never + * appears in `toHtml` export (that walks the Craft node tree, not this + * portal), and doesn't wrap every node in extra DOM. + */ +export const RenderNode: React.FC = ({ render }) => { + const { actions } = useEditor(); + const { id, isSelected, dom, name, parent } = useNode((node) => ({ + isSelected: node.events.selected, + dom: node.dom, + name: node.data.custom?.displayName || node.data.displayName, + parent: node.data.parent, + })); + + const badgeRef = useRef(null); + const active = isSelected && id !== 'ROOT' && !!dom; + + const updatePosition = useCallback(() => { + if (!dom || !badgeRef.current) return; + const rect = dom.getBoundingClientRect(); + const badgeHeight = 22; + badgeRef.current.style.left = `${Math.max(rect.left, 0)}px`; + badgeRef.current.style.top = `${Math.max(rect.top - badgeHeight, 0)}px`; + }, [dom]); + + useEffect(() => { + if (!active) return; + updatePosition(); + window.addEventListener('resize', updatePosition); + document.addEventListener('scroll', updatePosition, true); + return () => { + window.removeEventListener('resize', updatePosition); + document.removeEventListener('scroll', updatePosition, true); + }; + }, [active, updatePosition]); + + if (!active) return <>{render}; + + return ( + <> + {render} + {createPortal( +
+ {name} + {parent && ( + + )} +
, + document.body + )} + + ); +}; diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index e2754b2..89f389c 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -1074,12 +1074,16 @@ body { outline-offset: -1px; } -/* Component indicator badge */ +/* Component indicator badge (floats over the selected node via a portal -- + position/top/left are set inline per-node, see RenderNode.tsx) */ .component-indicator { position: absolute; top: -22px; left: 0; - padding: 2px 8px; + display: flex; + align-items: center; + gap: 6px; + padding: 2px 6px 2px 8px; font-size: 10px; font-weight: 600; color: #ffffff; @@ -1090,6 +1094,28 @@ body { z-index: 10; } +.component-indicator-parent-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + padding: 0; + border: none; + border-radius: 2px; + background: rgba(255, 255, 255, 0.2); + color: #ffffff; + font-size: 8px; + line-height: 1; + cursor: pointer; + pointer-events: auto; + transition: background var(--transition-fast); +} + +.component-indicator-parent-btn:hover { + background: rgba(255, 255, 255, 0.4); +} + /* -------------------------------------------------------------------------- Scrollbar Styling -------------------------------------------------------------------------- */ From ab28ad8f2cbf265eca756be3948f9a6fca48d1b8 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 20:15:15 -0700 Subject: [PATCH 6/7] Merge assets-panel empty state into one dropzone The Assets panel used to show a small always-visible dropzone plus a separate italic "No assets uploaded yet" line stacked underneath it when there were no assets -- two redundant messages for one state. Replace both with a single tall dropzone (icon + "Drag images here or click to upload") that also opens the file picker on click; it collapses back to the original slim "Drop files here to upload" bar once assets exist. Upload/drag-drop behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/panels/left/AssetsPanel.tsx | 38 +++++++++++++++------------ 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/craft/src/panels/left/AssetsPanel.tsx b/craft/src/panels/left/AssetsPanel.tsx index e77455b..bc63160 100644 --- a/craft/src/panels/left/AssetsPanel.tsx +++ b/craft/src/panels/left/AssetsPanel.tsx @@ -107,23 +107,41 @@ export const AssetsPanel: React.FC = () => { {loading ? 'Uploading...' : 'Upload File'} - {/* Drop zone */} + {/* Drop zone -- a single element that doubles as the empty state. + Previously this was a small always-visible dropzone PLUS a + separate italic "No assets uploaded yet" line stacked underneath + it when empty; merged into one tall dropzone (icon + copy, + click-or-drag) so the empty state isn't two redundant messages. + Once assets exist it collapses back to a slim persistent drop + target above the grid. */}
fileInputRef.current?.click() : undefined} + role={assets.length === 0 ? 'button' : undefined} + tabIndex={assets.length === 0 ? 0 : undefined} style={{ - padding: 20, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + padding: assets.length === 0 ? '36px 20px' : 16, border: `2px dashed ${isDragOver ? 'var(--color-accent)' : 'var(--color-border)'}`, borderRadius: 'var(--radius-md)', background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent', textAlign: 'center', color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)', fontSize: 11, + cursor: assets.length === 0 ? 'pointer' : 'default', transition: 'all var(--transition-fast)', }} > - Drop files here to upload + {assets.length === 0 && ( + + )} + {assets.length === 0 ? 'Drag images here or click to upload' : 'Drop files here to upload'}
{/* Error message */} @@ -143,20 +161,6 @@ export const AssetsPanel: React.FC = () => { )} {/* Asset grid */} - {assets.length === 0 && !loading && ( -
- No assets uploaded yet -
- )} -
Date: Sun, 12 Jul 2026 20:22:11 -0700 Subject: [PATCH 7/7] fix(builder): phase-1 polish a11y follow-ups (dropzone keyboard, badge name, icon aria) --- craft/src/editor/RenderNode.tsx | 2 +- craft/src/panels/left/AssetsPanel.tsx | 4 +--- craft/src/panels/left/PagesPanel.tsx | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/craft/src/editor/RenderNode.tsx b/craft/src/editor/RenderNode.tsx index ca96216..df567cc 100644 --- a/craft/src/editor/RenderNode.tsx +++ b/craft/src/editor/RenderNode.tsx @@ -20,7 +20,7 @@ export const RenderNode: React.FC = ({ render }) => { const { id, isSelected, dom, name, parent } = useNode((node) => ({ isSelected: node.events.selected, dom: node.dom, - name: node.data.custom?.displayName || node.data.displayName, + name: (node.data.props?.aiName as string) || node.data.displayName, parent: node.data.parent, })); diff --git a/craft/src/panels/left/AssetsPanel.tsx b/craft/src/panels/left/AssetsPanel.tsx index bc63160..034b2ac 100644 --- a/craft/src/panels/left/AssetsPanel.tsx +++ b/craft/src/panels/left/AssetsPanel.tsx @@ -118,9 +118,7 @@ export const AssetsPanel: React.FC = () => { onDrop={handleDrop} onDragOver={handleDragOver} onDragLeave={handleDragLeave} - onClick={assets.length === 0 ? () => fileInputRef.current?.click() : undefined} - role={assets.length === 0 ? 'button' : undefined} - tabIndex={assets.length === 0 ? 0 : undefined} + {...(assets.length === 0 ? clickableProps(() => fileInputRef.current?.click()) : {})} style={{ display: 'flex', flexDirection: 'column', diff --git a/craft/src/panels/left/PagesPanel.tsx b/craft/src/panels/left/PagesPanel.tsx index 8b36fde..02f1abe 100644 --- a/craft/src/panels/left/PagesPanel.tsx +++ b/craft/src/panels/left/PagesPanel.tsx @@ -394,7 +394,7 @@ export const PagesPanel: React.FC = () => { cursor: 'pointer', }} > - +