UI polish Phase 1: quick wins (empty-canvas state, selection badge, contrast, FA icons, preset grid, assets empty state) #6

Merged
jknapp merged 7 commits from ui-polish-phase1 into main 2026-07-13 03:22:51 +00:00
14 changed files with 440 additions and 56 deletions
+2 -1
View File
@@ -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<AppProps> = ({ whpConfig }) => {
return (
<EditorConfigProvider config={whpConfig}>
<SiteDesignProvider>
<Editor resolver={componentResolver} enabled={true}>
<Editor resolver={componentResolver} enabled={true} onRender={RenderNode}>
<PageProvider>
<SitesmithProvider>
<EditorShell />
+31 -1
View File
@@ -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 (
<div className="empty-canvas-hint">
<i className="fa fa-cubes" aria-hidden />
<span>Drag blocks from the left panel, or pick a Template to start.</span>
</div>
);
};
export const Canvas: React.FC<CanvasProps> = ({ device }) => {
const width = DEVICE_WIDTHS[device];
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
@@ -140,6 +167,7 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
<ZonePreview craftState={headerPage.craftState} zone="header" />
)}
<div style={{ position: 'relative' }}>
<Frame>
<Element
is={Container}
@@ -148,6 +176,8 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
style={frameStyle}
/>
</Frame>
{isEditingRegularPage && <EmptyCanvasHint />}
</div>
{isEditingRegularPage && (
<ZonePreview craftState={footerPage.craftState} zone="footer" />
+67
View File
@@ -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 <Editor> tree. */
let mockNodes: Record<string, { data: { nodes: string[] } }> = {};
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(<EmptyCanvasHint />);
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
container.remove();
});
test('renders the hint once ROOT exists with zero children', () => {
mockNodes = { ROOT: { data: { nodes: [] } } };
render(<EmptyCanvasHint />);
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(<EmptyCanvasHint />);
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(<EmptyCanvasHint />);
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
container.remove();
});
});
+108
View File
@@ -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 <Editor onRender> 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 = <span data-testid="inner">hello</span>;
describe('RenderNode (Editor onRender override)', () => {
test('passes render through untouched when not selected', () => {
render(<RenderNode render={rendered} />);
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(<RenderNode render={rendered} />);
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(<RenderNode render={rendered} />);
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(<RenderNode render={rendered} />);
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(<RenderNode render={rendered} />);
expect(document.querySelector('.component-indicator-parent-btn')).toBeNull();
container.remove();
nodeDom.remove();
document.querySelector('.component-indicator')?.remove();
});
});
+76
View File
@@ -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 `<Editor onRender>` 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<RenderNodeProps> = ({ render }) => {
const { actions } = useEditor();
const { id, isSelected, dom, name, parent } = useNode((node) => ({
isSelected: node.events.selected,
dom: node.dom,
name: (node.data.props?.aiName as string) || node.data.displayName,
parent: node.data.parent,
}));
const badgeRef = useRef<HTMLDivElement>(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(
<div ref={badgeRef} className="component-indicator" style={{ position: 'fixed' }}>
<span>{name}</span>
{parent && (
<button
type="button"
className="component-indicator-parent-btn"
title="Select parent"
aria-label={`Select parent of ${name}`}
onMouseDown={(e) => {
e.stopPropagation();
actions.selectNode(parent);
}}
>
<i className="fa fa-chevron-up" aria-hidden />
</button>
)}
</div>,
document.body
)}
</>
);
};
@@ -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<ContextMenuProps> = ({
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<ContextMenuProps> = ({
(e.target as HTMLElement).style.background = 'transparent';
}}
>
<span>{item.label}</span>
<span>
{item.icon && <i className={`fa fa-${item.icon}`} style={{ marginRight: 6, width: 12 }} />}
{item.label}
</span>
{item.shortcut && (
<span
style={{
+21 -19
View File
@@ -107,23 +107,39 @@ export const AssetsPanel: React.FC = () => {
{loading ? 'Uploading...' : 'Upload File'}
</button>
{/* 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. */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
{...(assets.length === 0 ? clickableProps(() => fileInputRef.current?.click()) : {})}
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 && (
<i className="fa fa-cloud-upload" aria-hidden style={{ fontSize: 28, opacity: 0.5 }} />
)}
{assets.length === 0 ? 'Drag images here or click to upload' : 'Drop files here to upload'}
</div>
{/* Error message */}
@@ -143,20 +159,6 @@ export const AssetsPanel: React.FC = () => {
)}
{/* Asset grid */}
{assets.length === 0 && !loading && (
<div
style={{
textAlign: 'center',
padding: 20,
color: 'var(--color-text-dim)',
fontSize: 12,
fontStyle: 'italic',
}}
>
No assets uploaded yet
</div>
)}
<div
style={{
display: 'grid',
@@ -296,7 +298,7 @@ export const AssetsPanel: React.FC = () => {
cursor: 'pointer',
}}
>
&#10005;
<i className="fa fa-times" aria-hidden />
</button>
</div>
) : (
@@ -328,7 +330,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'; }}
>
&#10005;
<i className="fa fa-times" aria-hidden />
</button>
)}
</div>
+1 -1
View File
@@ -394,7 +394,7 @@ export const PagesPanel: React.FC = () => {
cursor: 'pointer',
}}
>
&#10005;
<i className="fa fa-trash" aria-hidden="true" />
</button>
)}
</div>
@@ -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);
}
});
});
+25 -3
View File
@@ -84,9 +84,30 @@ 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<PresetButtonGridProps> = ({ presets, activeValue, onSelect }) => (
<div className="preset-grid">
/** 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<PresetButtonGridProps> = ({ presets, activeValue, onSelect, columns }) => {
const cols = columns ?? defaultPresetGridColumns(presets.length);
return (
<div className="preset-grid" style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
{presets.map((p) => (
<button
key={p.value}
@@ -97,7 +118,8 @@ export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, act
</button>
))}
</div>
);
);
};
interface GradientSwatchGridProps {
activeValue: string | undefined;
@@ -21,8 +21,8 @@ export const SitesmithButton: React.FC<Props> = ({ onClick }) => {
color: '#fff', border: 'none', padding: '6px 12px', borderRadius: 6, cursor: 'pointer', fontWeight: 500,
}}
>
Sitesmith
{locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 12 }}>🔒</span>}
<i className="fa fa-magic" aria-hidden style={{ marginRight: 6 }} /> Sitesmith
{locked && <i className="fa fa-lock" aria-hidden style={{ marginLeft: 6, fontSize: 12 }} />}
{capped && !locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 11, opacity: 0.85 }}>(cap)</span>}
</button>
);
@@ -103,7 +103,10 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
>
<div style={panel}>
<div style={header}>
<div style={{ fontWeight: 600, color: '#fff' }}> Sitesmith</div>
<div style={{ fontWeight: 600, color: '#fff' }}>
<i className="fa fa-magic" aria-hidden style={{ marginRight: 6 }} />
Sitesmith
</div>
{summary && summary.enabled && (
<div style={{ fontSize: 12, color: '#a1a1aa', marginLeft: 16 }}>
{summary.monthly_used} / {summary.monthly_cap} this month
@@ -136,7 +139,7 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
</button>
)
)}
<button onClick={onClose} aria-label="Close" style={closeBtn}></button>
<button onClick={onClose} aria-label="Close" style={closeBtn}><i className="fa fa-times" aria-hidden /></button>
</div>
<div style={body}>
<UpgradeBanner summary={summary} />
+1 -1
View File
@@ -248,7 +248,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
</p>
</div>
<button onClick={onClose} style={closeButtonStyle} title="Close">
&#10005;
<i className="fa fa-times" aria-hidden />
</button>
</div>
+37 -6
View File
@@ -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;
@@ -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
-------------------------------------------------------------------------- */
@@ -1225,6 +1251,8 @@ body {
Empty Canvas State
-------------------------------------------------------------------------- */
.empty-canvas-hint {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
@@ -1234,6 +1262,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 {