Merge PR #6: UI polish Phase 1

This commit was merged in pull request #6.
This commit is contained in:
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 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 {