Compare commits

..
Author SHA1 Message Date
shadowdao 72f85a97e5 fix(builder): phase-1 polish a11y follow-ups (dropzone keyboard, badge name, icon aria) 2026-07-12 20:22:11 -07:00
shadowdaoandClaude Opus 4.8 ab28ad8f2c 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) <noreply@anthropic.com>
2026-07-12 20:15:15 -07:00
shadowdaoandClaude Opus 4.8 05e00c572d 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 <Editor onRender> 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) <noreply@anthropic.com>
2026-07-12 20:15:08 -07:00
shadowdaoandClaude Opus 4.8 458069afb6 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) <noreply@anthropic.com>
2026-07-12 20:14:58 -07:00
shadowdaoandClaude Opus 4.8 b3e5009aec 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) <noreply@anthropic.com>
2026-07-12 20:14:38 -07:00
shadowdaoandClaude Opus 4.8 eeb0660d83 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: &#10005; -> fa-times / fa-trash

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:14:29 -07:00
shadowdaoandClaude Opus 4.8 138e1a8273 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) <noreply@anthropic.com>
2026-07-12 20:14:10 -07:00
jknapp 1b12b79a0d Merge PR #5: image placeholder + Gallery/safeImageUrl fix 2026-07-13 02:54:24 +00:00
14 changed files with 440 additions and 56 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Editor } from '@craftjs/core'; import { Editor } from '@craftjs/core';
import { EditorShell } from './editor/EditorShell'; import { EditorShell } from './editor/EditorShell';
import { RenderNode } from './editor/RenderNode';
import { componentResolver } from './components/resolver'; import { componentResolver } from './components/resolver';
import { WhpConfig } from './types'; import { WhpConfig } from './types';
import { EditorConfigProvider } from './state/EditorConfigContext'; import { EditorConfigProvider } from './state/EditorConfigContext';
@@ -23,7 +24,7 @@ export const App: React.FC<AppProps> = ({ whpConfig }) => {
return ( return (
<EditorConfigProvider config={whpConfig}> <EditorConfigProvider config={whpConfig}>
<SiteDesignProvider> <SiteDesignProvider>
<Editor resolver={componentResolver} enabled={true}> <Editor resolver={componentResolver} enabled={true} onRender={RenderNode}>
<PageProvider> <PageProvider>
<SitesmithProvider> <SitesmithProvider>
<EditorShell /> <EditorShell />
+39 -9
View File
@@ -1,5 +1,5 @@
import React, { useMemo, useRef, useEffect } from 'react'; 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 { Container } from '../components/layout/Container';
import { usePages } from '../state/PageContext'; import { usePages } from '../state/PageContext';
import { DeviceMode } from '../types'; 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 }) => { export const Canvas: React.FC<CanvasProps> = ({ device }) => {
const width = DEVICE_WIDTHS[device]; const width = DEVICE_WIDTHS[device];
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages(); const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
@@ -140,14 +167,17 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
<ZonePreview craftState={headerPage.craftState} zone="header" /> <ZonePreview craftState={headerPage.craftState} zone="header" />
)} )}
<Frame> <div style={{ position: 'relative' }}>
<Element <Frame>
is={Container} <Element
canvas is={Container}
tag={frameTag} canvas
style={frameStyle} tag={frameTag}
/> style={frameStyle}
</Frame> />
</Frame>
{isEditingRegularPage && <EmptyCanvasHint />}
</div>
{isEditingRegularPage && ( {isEditingRegularPage && (
<ZonePreview craftState={footerPage.craftState} zone="footer" /> <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 { interface MenuItem {
label: string; label: string;
/** Font Awesome icon suffix (e.g. 'magic' for fa-magic), rendered before the label. */
icon?: string;
shortcut?: string; shortcut?: string;
action: () => void; action: () => void;
danger?: boolean; danger?: boolean;
@@ -189,7 +191,8 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const items: MenuItem[] = [ const items: MenuItem[] = [
{ {
label: '✨ Ask Sitesmith', label: 'Ask Sitesmith',
icon: 'magic',
action: askSitesmith, action: askSitesmith,
disabled: isRoot, disabled: isRoot,
dividerAfter: true, dividerAfter: true,
@@ -291,7 +294,10 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
(e.target as HTMLElement).style.background = 'transparent'; (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 && ( {item.shortcut && (
<span <span
style={{ style={{
+21 -19
View File
@@ -107,23 +107,39 @@ export const AssetsPanel: React.FC = () => {
{loading ? 'Uploading...' : 'Upload File'} {loading ? 'Uploading...' : 'Upload File'}
</button> </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 <div
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
{...(assets.length === 0 ? clickableProps(() => fileInputRef.current?.click()) : {})}
style={{ 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)'}`, border: `2px dashed ${isDragOver ? 'var(--color-accent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-md)', borderRadius: 'var(--radius-md)',
background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent', background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent',
textAlign: 'center', textAlign: 'center',
color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)', color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)',
fontSize: 11, fontSize: 11,
cursor: assets.length === 0 ? 'pointer' : 'default',
transition: 'all var(--transition-fast)', 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> </div>
{/* Error message */} {/* Error message */}
@@ -143,20 +159,6 @@ export const AssetsPanel: React.FC = () => {
)} )}
{/* Asset grid */} {/* 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 <div
style={{ style={{
display: 'grid', display: 'grid',
@@ -296,7 +298,7 @@ export const AssetsPanel: React.FC = () => {
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
&#10005; <i className="fa fa-times" aria-hidden />
</button> </button>
</div> </div>
) : ( ) : (
@@ -328,7 +330,7 @@ export const AssetsPanel: React.FC = () => {
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }} onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }}
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }} onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }}
> >
&#10005; <i className="fa fa-times" aria-hidden />
</button> </button>
)} )}
</div> </div>
+1 -1
View File
@@ -394,7 +394,7 @@ export const PagesPanel: React.FC = () => {
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
&#10005; <i className="fa fa-trash" aria-hidden="true" />
</button> </button>
)} )}
</div> </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);
}
});
});
+35 -13
View File
@@ -84,20 +84,42 @@ interface PresetButtonGridProps {
presets: { label: string; value: string }[]; presets: { label: string; value: string }[];
activeValue: string | undefined; activeValue: string | undefined;
onSelect: (value: string) => void; 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.
{presets.map((p) => ( * 4-or-fewer presets keep the classic single row of 4. 5 gets its own
<button * row (5 cols). 6 splits into two even rows of 3. Anything else falls
key={p.value} * back to a 4- or 3-column grid depending on which divides evenly. */
className={`preset-btn ${String(activeValue) === p.value ? 'active' : ''}`} export function defaultPresetGridColumns(count: number): number {
onClick={() => onSelect(p.value)} if (count <= 4) return 4;
> if (count === 5) return 5;
{p.label} if (count === 6) return 3;
</button> if (count % 4 === 0) return 4;
))} if (count % 3 === 0) return 3;
</div> 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}
className={`preset-btn ${String(activeValue) === p.value ? 'active' : ''}`}
onClick={() => onSelect(p.value)}
>
{p.label}
</button>
))}
</div>
);
};
interface GradientSwatchGridProps { interface GradientSwatchGridProps {
activeValue: string | undefined; 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, color: '#fff', border: 'none', padding: '6px 12px', borderRadius: 6, cursor: 'pointer', fontWeight: 500,
}} }}
> >
✨ Sitesmith <i className="fa fa-magic" aria-hidden style={{ marginRight: 6 }} /> Sitesmith
{locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 12 }}>🔒</span>} {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>} {capped && !locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 11, opacity: 0.85 }}>(cap)</span>}
</button> </button>
); );
@@ -103,7 +103,10 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
> >
<div style={panel}> <div style={panel}>
<div style={header}> <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 && ( {summary && summary.enabled && (
<div style={{ fontSize: 12, color: '#a1a1aa', marginLeft: 16 }}> <div style={{ fontSize: 12, color: '#a1a1aa', marginLeft: 16 }}>
{summary.monthly_used} / {summary.monthly_cap} this month {summary.monthly_used} / {summary.monthly_cap} this month
@@ -136,7 +139,7 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
</button> </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>
<div style={body}> <div style={body}>
<UpgradeBanner summary={summary} /> <UpgradeBanner summary={summary} />
+1 -1
View File
@@ -248,7 +248,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
</p> </p>
</div> </div>
<button onClick={onClose} style={closeButtonStyle} title="Close"> <button onClick={onClose} style={closeButtonStyle} title="Close">
&#10005; <i className="fa fa-times" aria-hidden />
</button> </button>
</div> </div>
+37 -6
View File
@@ -16,8 +16,8 @@
--color-border: #2d2d3a; --color-border: #2d2d3a;
--color-border-light: #3f3f46; --color-border-light: #3f3f46;
--color-text: #e4e4e7; --color-text: #e4e4e7;
--color-text-muted: #71717a; --color-text-muted: #8b8b96;
--color-text-dim: #52525b; --color-text-dim: #6e6e78;
--color-accent: #3b82f6; --color-accent: #3b82f6;
--color-accent-hover: #2563eb; --color-accent-hover: #2563eb;
--color-accent-subtle: rgba(59, 130, 246, 0.12); --color-accent-subtle: rgba(59, 130, 246, 0.12);
@@ -704,7 +704,7 @@ body {
} }
.block-item-icon { .block-item-icon {
font-size: 18px; font-size: 20px;
color: var(--color-text-muted); color: var(--color-text-muted);
transition: color var(--transition-fast); transition: color var(--transition-fast);
} }
@@ -714,7 +714,7 @@ body {
} }
.block-item-label { .block-item-label {
font-size: 10px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: var(--color-text-muted); color: var(--color-text-muted);
text-align: center; text-align: center;
@@ -1074,12 +1074,16 @@ body {
outline-offset: -1px; 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 { .component-indicator {
position: absolute; position: absolute;
top: -22px; top: -22px;
left: 0; left: 0;
padding: 2px 8px; display: flex;
align-items: center;
gap: 6px;
padding: 2px 6px 2px 8px;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: #ffffff; color: #ffffff;
@@ -1090,6 +1094,28 @@ body {
z-index: 10; 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 Scrollbar Styling
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@@ -1225,6 +1251,8 @@ body {
Empty Canvas State Empty Canvas State
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
.empty-canvas-hint { .empty-canvas-hint {
position: absolute;
inset: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -1234,6 +1262,9 @@ body {
font-size: 13px; font-size: 13px;
text-align: center; text-align: center;
gap: 12px; 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 { .empty-canvas-hint i {