Merge issue-56 (Refs #56)

# Conflicts:
#	craft/src/editor/CanvasOverlays.tsx
#	craft/src/styles/editor.css
This commit is contained in:
shadowdao committed 2026-10-07 12:06:59 -07:00
commit d39e1ecc31
6 files changed
+529

No files matched your search

+2
View File
@@ -5,6 +5,7 @@ import { DropIndicator } from './DropIndicator';
import { RichTextToolbar } from './RichTextToolbar';
import { CommandPalette } from '../panels/command-palette/CommandPalette';
import { HistoryHost } from '../panels/history/HistoryModal';
import { SpacingHandles } from './SpacingHandles';
/**
* Single mount point for editor-wide overlays rendered next to the shell.
@@ -19,5 +20,6 @@ export const CanvasOverlays: React.FC = () => (
<RichTextToolbar />
<CommandPalette />
<HistoryHost />
<SpacingHandles />
</>
);
+114
View File
@@ -0,0 +1,114 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
import { DeviceProvider } from '../state/DeviceContext';
import { SpacingHandles } from './SpacingHandles';
import { insertBlock } from '../panels/left/blocks-registry';
import { closeContinuousBurst } from '../panels/right/styles/shared';
import type { DeviceMode } from '../types';
let h: EditorHarness;
afterEach(() => { h?.unmount(); closeContinuousBurst(); });
function seed(style: Record<string, string>, device: DeviceMode = 'desktop') {
h = renderEditorHarness({ wrap: (tree) => <DeviceProvider initialDevice={device}>{tree}</DeviceProvider> });
h.mountChild(<SpacingHandles />);
let id = '';
h.act(() => { id = insertBlock(h.query, h.actions, 'section', { parentId: 'ROOT', select: false })!; });
h.act(() => { h.actions.setProp(id, (p: any) => { p.style = { ...p.style, ...style }; }); });
// Start from a clean history so the drag's undo steps are countable.
h.act(() => { h.actions.history.clear(); });
h.act(() => { h.actions.selectNode(id); });
return id;
}
const props = (id: string) => h.query.node(id).get().data.props;
const handle = (side: string) => document.querySelector(`.spacing-handle[data-side="${side}"]`) as HTMLElement | null;
const ptr = (type: string, init: Record<string, unknown> = {}) =>
new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, ...init });
const down = (side: string, y: number, x = 0) => h.act(() => { handle(side)!.dispatchEvent(ptr('pointerdown', { clientY: y, clientX: x })); });
const move = (y: number, init: Record<string, unknown> = {}, x = 0) => h.act(() => { document.body.dispatchEvent(ptr('pointermove', { clientY: y, clientX: x, ...init })); });
const up = () => h.act(() => { document.body.dispatchEvent(ptr('pointerup')); });
const esc = () => h.act(() => { document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); });
/** Undo steps recorded since history.clear(). */
const undoAll = () => { let n = 0; while (h.query.history.canUndo()) { h.act(() => { h.actions.history.undo(); }); n++; if (n > 50) break; } return n; };
describe('SpacingHandles (real editor)', () => {
test('renders four handles for a selected Section, none when nothing/non-container is selected', () => {
const id = seed({ paddingTop: '40px' });
expect(['top', 'bottom', 'left', 'right'].every((s) => handle(s))).toBe(true);
h.act(() => { h.actions.clearEvents(); });
expect(document.querySelector('.spacing-handles')).toBeNull();
let hid = '';
h.act(() => { hid = insertBlock(h.query, h.actions, 'heading', { parentId: 'ROOT', select: true })!; });
expect(document.querySelector('.spacing-handles')).toBeNull();
expect(id).toBeTruthy();
});
test('top handle +24px => paddingTop 64px in ONE undo step', () => {
const id = seed({ paddingTop: '40px' });
down('top', 100);
move(110); move(120); move(124);
expect(document.querySelector('.spacing-readout')?.textContent).toBe('64px');
up();
expect(props(id).style.paddingTop).toBe('64px');
expect(document.querySelector('.spacing-readout')).toBeNull();
expect(undoAll()).toBe(1);
expect(props(id).style.paddingTop).toBe('40px');
});
test('bottom handle dragged up grows paddingBottom', () => {
const id = seed({ paddingBottom: '10px' });
down('bottom', 100); move(80); up();
expect(props(id).style.paddingBottom).toBe('30px');
});
test('Alt changes top and bottom; Shift changes all four', () => {
let id = seed({ paddingTop: '40px', paddingBottom: '40px', paddingLeft: '10px', paddingRight: '10px' });
down('top', 100); move(110, { altKey: true }); up();
expect(props(id).style).toMatchObject({ paddingTop: '50px', paddingBottom: '50px', paddingLeft: '10px', paddingRight: '10px' });
h.unmount();
id = seed({ paddingTop: '40px', paddingBottom: '40px', paddingLeft: '10px', paddingRight: '10px' });
down('top', 100); move(110, { shiftKey: true }); up();
expect(props(id).style).toMatchObject({ paddingTop: '50px', paddingBottom: '50px', paddingLeft: '50px', paddingRight: '50px' });
expect(undoAll()).toBe(1);
});
test('left handle uses the X axis', () => {
const id = seed({ paddingLeft: '10px' });
down('left', 0, 100); move(0, {}, 120); up();
expect(props(id).style.paddingLeft).toBe('30px');
});
test('mobile device: write lands in styleMobile, desktop style untouched', () => {
const id = seed({ paddingTop: '40px' }, 'mobile');
down('top', 100); move(124); up();
expect(props(id).styleMobile.paddingTop).toBe('64px');
expect(props(id).style.paddingTop).toBe('40px');
expect(undoAll()).toBe(1);
});
test('legacy shorthand is expanded to longhands and cleared in the same single step', () => {
const id = seed({ padding: '40px 0', paddingTop: '', paddingBottom: '', paddingLeft: '', paddingRight: '' });
down('top', 100); move(110); move(124); up();
expect(props(id).style).toMatchObject({ padding: '', paddingTop: '64px', paddingBottom: '40px', paddingLeft: '0', paddingRight: '0' });
expect(undoAll()).toBe(1);
expect(props(id).style.padding).toBe('40px 0');
});
test('Escape mid-drag restores the original and ignores further moves', () => {
const id = seed({ paddingTop: '40px', paddingBottom: '40px' });
down('top', 100); move(130, { altKey: true });
expect(props(id).style.paddingBottom).toBe('70px');
esc();
expect(props(id).style).toMatchObject({ paddingTop: '40px', paddingBottom: '40px' });
move(200); up();
expect(props(id).style.paddingTop).toBe('40px');
expect(document.querySelector('.spacing-readout')).toBeNull();
});
test('Ctrl snaps to the 8px grid', () => {
const id = seed({ paddingTop: '40px' });
down('top', 100); move(113, { ctrlKey: true }); up();
expect(props(id).style.paddingTop).toBe('56px');
});
});
+216
View File
@@ -0,0 +1,216 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useEditor } from '@craftjs/core';
import { useDevice } from '../state/DeviceContext';
import { useIsMobile } from '../hooks/useIsMobile';
import { resolveStyleForDevice, overrideFor } from '../utils/responsive-style';
import { setResponsiveStyleProp, closeContinuousBurst } from '../panels/right/styles/shared';
import { expandPaddingShorthand } from '../panels/right/styles/containerBoxModel';
import {
ALL_SIDES, PADDING_KEY, PaddingKey, PaddingSide,
computePadding, isLegacyShorthand, parsePx, readPaddingPx, sidesFor,
} from './spacing-drag';
/** Canvas containers that get padding handles (Section's inner wrapper is a Container). */
const CONTAINER_NAMES = new Set(['Section', 'Container', 'Columns', 'Background Section']);
const CONTROL_KEY = 'canvas-padding';
interface DragState {
side: PaddingSide;
/** Sides currently written + their live px value, for the band/readout. */
sides: PaddingSide[];
value: number;
}
interface Geometry {
rect: { left: number; top: number; width: number; height: number };
pad: Record<PaddingSide, number>;
}
const SIDE_AXIS: Record<PaddingSide, 'x' | 'y'> = { top: 'y', bottom: 'y', left: 'x', right: 'x' };
/**
* On-canvas padding handles (#56) for the selected Section / Container /
* Columns. Drag an edge handle to change that side's padding; Alt also moves
* the opposite side, Shift moves all four, Ctrl/Cmd snaps to 8px, Escape
* cancels. Writes go through the #45 per-device setter as one continuous
* burst (= one undo step), so tablet/mobile drags land in that device's
* override. Mounted from CanvasOverlays.
*/
export const SpacingHandles: React.FC = () => {
const { device } = useDevice();
const isMobile = useIsMobile();
const { query, actions, nodeId, isDragging, props } = useEditor((state) => {
const id = [...state.events.selected][0] as string | undefined;
const n = id ? state.nodes[id] : undefined;
const ok = !!n && CONTAINER_NAMES.has(n.data.displayName);
return {
nodeId: ok ? id! : null,
isDragging: state.events.dragged.size > 0,
// New object per edit (immer): re-measure after a padding change.
props: ok ? n!.data.props : null,
};
});
const dom: HTMLElement | null = nodeId ? query.node(nodeId).get()?.dom ?? null : null;
const [geo, setGeo] = useState<Geometry | null>(null);
const [drag, setDrag] = useState<DragState | null>(null);
const cancelRef = useRef<(() => void) | null>(null);
const measure = useCallback(() => {
if (!dom) return setGeo(null);
const r = dom.getBoundingClientRect();
const cs = typeof getComputedStyle === 'function' ? getComputedStyle(dom) : null;
const px = (v: string | undefined) => parsePx(v) ?? 0;
setGeo({
rect: { left: r.left, top: r.top, width: r.width, height: r.height },
pad: {
top: px(cs?.paddingTop), bottom: px(cs?.paddingBottom),
left: px(cs?.paddingLeft), right: px(cs?.paddingRight),
},
});
}, [dom]);
// Fixed overlay: follow the node through canvas scroll (the canvas scrolls
// inside .editor-canvas, not the window -> capture phase), resize and edits.
useLayoutEffect(() => {
measure();
if (!dom) return;
window.addEventListener('resize', measure);
document.addEventListener('scroll', measure, true);
return () => {
window.removeEventListener('resize', measure);
document.removeEventListener('scroll', measure, true);
};
}, [dom, measure, props, device]);
// Selection changed / unmounted mid-drag: abandon it without a revert.
useEffect(() => () => { cancelRef.current?.(); }, [nodeId]);
if (!nodeId || !dom || !geo || isMobile || isDragging) return null;
const startDrag = (side: PaddingSide, e: React.PointerEvent) => {
if (e.button !== 0 && e.button !== undefined) return;
e.preventDefault();
e.stopPropagation();
const axis = SIDE_AXIS[side];
const startPos = axis === 'y' ? e.clientY : e.clientX;
const nodeProps: any = query.node(nodeId).get().data.props;
const effective = resolveStyleForDevice(nodeProps, device) as Record<string, any>;
const cs = getComputedStyle(dom);
const computedOf = (s: PaddingSide) => cs[PADDING_KEY[s]];
const startValue = readPaddingPx(effective, side, computedOf(side));
// Originals (raw stored values of this device's layer) for Escape/alt-toggle revert.
const layer: Record<string, any> = device === 'desktop' ? { ...(nodeProps.style || {}) } : { ...overrideFor(nodeProps, device) };
const original = (k: string) => (layer[k] === undefined || layer[k] === null ? '' : layer[k]);
// A legacy `padding` shorthand with empty longhands: on first write expand
// it to the four longhands and clear it in the same burst (as the panel does).
const legacy = isLegacyShorthand(effective);
const expandAll = legacy && (device === 'desktop' || (layer.padding !== undefined && layer.padding !== ''));
const expanded = expandAll ? expandPaddingShorthand(effective.padding) : {};
const touched = new Set<PaddingKey>();
let shorthandCleared = false;
let finished = false;
const write = (key: string, value: string) =>
setResponsiveStyleProp(actions, nodeId, device, key, value, { continuous: true, controlKey: CONTROL_KEY });
const apply = (ev: PointerEvent) => {
const cur = axis === 'y' ? ev.clientY : ev.clientX;
const { value, sides } = computePadding({
start: startPos, current: cur, startValue, side,
alt: ev.altKey, shift: ev.shiftKey, snap: ev.ctrlKey || ev.metaKey,
});
if (expandAll && !shorthandCleared) {
write('padding', '');
shorthandCleared = true;
}
const next: Partial<Record<PaddingKey, string>> = { ...sides };
// Sides written earlier but not part of this modifier set go back to where they began.
touched.forEach((k) => { if (!next[k]) next[k] = expandAll ? (expanded[k.slice(7).toLowerCase() as PaddingSide] ?? '') : original(k); });
if (expandAll) {
// Untouched sides keep their shorthand-derived value as a longhand.
ALL_SIDES.forEach((s) => { const k = PADDING_KEY[s]; if (!next[k]) next[k] = expanded[s] ?? ''; });
}
(Object.keys(next) as PaddingKey[]).forEach((k) => { write(k, next[k]!); });
(Object.keys(sides) as PaddingKey[]).forEach((k) => touched.add(k));
setDrag({ side, sides: sidesFor(side, ev.altKey, ev.shiftKey), value });
};
const teardown = () => {
finished = true;
window.removeEventListener('pointermove', onMove, true);
window.removeEventListener('pointerup', onUp, true);
window.removeEventListener('pointercancel', onUp, true);
window.removeEventListener('keydown', onKey, true);
cancelRef.current = null;
setDrag(null);
measure();
};
const onMove = (ev: PointerEvent) => { if (!finished) apply(ev); };
const onUp = () => { teardown(); };
const onKey = (ev: KeyboardEvent) => {
if (ev.key !== 'Escape') return;
ev.preventDefault();
ev.stopPropagation();
// Restore every key this drag touched (still inside the open burst), then close it.
touched.forEach((k) => write(k, original(k)));
if (shorthandCleared) write('padding', original('padding'));
closeContinuousBurst();
teardown();
};
window.addEventListener('pointermove', onMove, true);
window.addEventListener('pointerup', onUp, true);
window.addEventListener('pointercancel', onUp, true);
window.addEventListener('keydown', onKey, true);
cancelRef.current = () => { if (!finished) teardown(); };
try { (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); } catch { /* not supported */ }
};
const { rect, pad } = geo;
// During a drag the live value is authoritative for the dragged sides.
const live = (s: PaddingSide) => (drag && drag.sides.includes(s) ? drag.value : pad[s]);
const bandStyle = (s: PaddingSide): React.CSSProperties => {
const t = live(s);
switch (s) {
case 'top': return { left: rect.left, top: rect.top, width: rect.width, height: t };
case 'bottom': return { left: rect.left, top: rect.top + rect.height - t, width: rect.width, height: t };
case 'left': return { left: rect.left, top: rect.top, width: t, height: rect.height };
default: return { left: rect.left + rect.width - t, top: rect.top, width: t, height: rect.height };
}
};
const handleStyle = (s: PaddingSide): React.CSSProperties => {
const t = live(s);
switch (s) {
case 'top': return { left: rect.left + rect.width / 2, top: rect.top + t };
case 'bottom': return { left: rect.left + rect.width / 2, top: rect.top + rect.height - t };
case 'left': return { left: rect.left + t, top: rect.top + rect.height / 2 };
default: return { left: rect.left + rect.width - t, top: rect.top + rect.height / 2 };
}
};
return (
<div className="spacing-handles" data-testid="spacing-handles">
{drag && drag.sides.map((s) => (
<div key={`band-${s}`} className="spacing-band" style={bandStyle(s)} />
))}
{ALL_SIDES.map((s) => (
<div
key={s}
className={`spacing-handle spacing-handle-${s}${drag?.side === s ? ' is-active' : ''}`}
data-side={s}
style={handleStyle(s)}
title={`Padding ${s} (Alt: opposite side, Shift: all sides, Ctrl/Cmd: snap 8px)`}
onPointerDown={(e) => startDrag(s, e)}
onMouseDown={(e) => { e.stopPropagation(); }}
/>
))}
{drag && (
<div className="spacing-readout" style={handleStyle(drag.side)}>{drag.value}px</div>
)}
</div>
);
};
+63
View File
@@ -0,0 +1,63 @@
import { describe, test, expect } from 'vitest';
import { computePadding, sidesFor, readPaddingPx, parsePx, isLegacyShorthand } from './spacing-drag';
describe('sidesFor', () => {
test('plain / alt / shift', () => {
expect(sidesFor('top', false, false)).toEqual(['top']);
expect(sidesFor('top', true, false)).toEqual(['top', 'bottom']);
expect(sidesFor('left', true, false)).toEqual(['left', 'right']);
expect(sidesFor('right', false, true).sort()).toEqual(['bottom', 'left', 'right', 'top']);
expect(sidesFor('bottom', true, true)).toHaveLength(4);
});
});
describe('computePadding', () => {
const base = { start: 100, startValue: 40 };
test('top/left grow with positive delta, bottom/right with negative', () => {
expect(computePadding({ ...base, current: 124, side: 'top' }).sides).toEqual({ paddingTop: '64px' });
expect(computePadding({ ...base, current: 124, side: 'left' }).sides).toEqual({ paddingLeft: '64px' });
expect(computePadding({ ...base, current: 76, side: 'bottom' }).sides).toEqual({ paddingBottom: '64px' });
expect(computePadding({ ...base, current: 76, side: 'right' }).sides).toEqual({ paddingRight: '64px' });
expect(computePadding({ ...base, current: 124, side: 'bottom' }).sides).toEqual({ paddingBottom: '16px' });
});
test('clamps to 0..400', () => {
expect(computePadding({ ...base, current: -500, side: 'top' }).value).toBe(0);
expect(computePadding({ ...base, current: 9999, side: 'top' }).value).toBe(400);
});
test('alt writes both opposite sides, shift all four, with the same value', () => {
expect(computePadding({ ...base, current: 110, side: 'top', alt: true }).sides).toEqual({ paddingTop: '50px', paddingBottom: '50px' });
const all = computePadding({ ...base, current: 110, side: 'left', shift: true }).sides;
expect(Object.keys(all).sort()).toEqual(['paddingBottom', 'paddingLeft', 'paddingRight', 'paddingTop']);
expect(new Set(Object.values(all))).toEqual(new Set(['50px']));
});
test('snap rounds to the 8px grid; default is 1px', () => {
expect(computePadding({ ...base, current: 113, side: 'top' }).value).toBe(53);
expect(computePadding({ ...base, current: 113, side: 'top', snap: true }).value).toBe(56);
expect(computePadding({ ...base, current: 110.4, side: 'top' }).value).toBe(50);
});
});
describe('readPaddingPx', () => {
test('longhand wins over everything', () => {
expect(readPaddingPx({ paddingTop: '12px', padding: '40px 0' }, 'top', '99px')).toBe(12);
});
test('shorthand token when longhand empty (CSS order)', () => {
expect(readPaddingPx({ padding: '40px 0' }, 'top')).toBe(40);
expect(readPaddingPx({ padding: '40px 0' }, 'left')).toBe(0);
expect(readPaddingPx({ padding: '1px 2px 3px 4px' }, 'bottom')).toBe(3);
});
test('falls back to the stubbed computed value, then 0', () => {
expect(readPaddingPx({}, 'top', '17px')).toBe(17);
expect(readPaddingPx({ paddingTop: '5%' }, 'top', '23px')).toBe(23);
expect(readPaddingPx({ padding: '2em' }, 'top', '32px')).toBe(32);
expect(readPaddingPx({}, 'top', '')).toBe(0);
expect(readPaddingPx({}, 'top')).toBe(0);
});
test('parsePx / isLegacyShorthand', () => {
expect(parsePx('12px')).toBe(12);
expect(parsePx('auto')).toBeNull();
expect(isLegacyShorthand({ padding: '4px' })).toBe(true);
expect(isLegacyShorthand({ padding: '4px', paddingTop: '1px' })).toBe(false);
expect(isLegacyShorthand({ padding: '', paddingTop: '' })).toBe(false);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { expandPaddingShorthand } from '../panels/right/styles/containerBoxModel';
/**
* Pure maths for the on-canvas padding handles (#56). No React, no DOM: the
* component feeds in pointer coordinates and gets back style strings.
*/
export type PaddingSide = 'top' | 'bottom' | 'left' | 'right';
export type PaddingKey = 'paddingTop' | 'paddingBottom' | 'paddingLeft' | 'paddingRight';
export const PADDING_KEY: Record<PaddingSide, PaddingKey> = {
top: 'paddingTop',
bottom: 'paddingBottom',
left: 'paddingLeft',
right: 'paddingRight',
};
export const ALL_SIDES: readonly PaddingSide[] = ['top', 'bottom', 'left', 'right'];
const OPPOSITE: Record<PaddingSide, PaddingSide> = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
export const PADDING_MIN = 0;
export const PADDING_MAX = 400;
/** Grid used while Ctrl/Cmd is held. */
export const PADDING_SNAP = 8;
/** Sides a drag on `side` writes: Shift = all four, Alt = it + its opposite. */
export function sidesFor(side: PaddingSide, alt: boolean, shift: boolean): PaddingSide[] {
if (shift) return [...ALL_SIDES];
if (alt) return [side, OPPOSITE[side]];
return [side];
}
export interface ComputePaddingInput {
/** Pointer coordinate on the drag axis (clientY for top/bottom, clientX for left/right) at pointerdown. */
start: number;
current: number;
/** Padding of the dragged side, in px, when the drag began. */
startValue: number;
side: PaddingSide;
alt?: boolean;
shift?: boolean;
/** Snap to the 8px grid (Ctrl/Cmd held). Default: 1px steps. */
snap?: boolean;
min?: number;
max?: number;
}
export interface ComputePaddingResult {
value: number;
sides: Partial<Record<PaddingKey, string>>;
}
/** Dragging a handle toward the content grows the padding: top/left handles
* grow with +delta, bottom/right with -delta. */
export function computePadding(i: ComputePaddingInput): ComputePaddingResult {
const min = i.min ?? PADDING_MIN;
const max = i.max ?? PADDING_MAX;
const sign = i.side === 'top' || i.side === 'left' ? 1 : -1;
let value = i.startValue + sign * (i.current - i.start);
value = i.snap ? Math.round(value / PADDING_SNAP) * PADDING_SNAP : Math.round(value);
value = Math.min(max, Math.max(min, value));
const sides: Partial<Record<PaddingKey, string>> = {};
for (const s of sidesFor(i.side, !!i.alt, !!i.shift)) sides[PADDING_KEY[s]] = `${value}px`;
return { value, sides };
}
const hasValue = (v: unknown) => v !== undefined && v !== null && v !== '';
/** "12px" / "12" / 12 → 12; anything else (%, em, calc, auto) → null. */
export function parsePx(v: unknown): number | null {
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
const m = /^\s*(-?\d*\.?\d+)(px)?\s*$/.exec(String(v ?? ''));
return m ? parseFloat(m[1]) : null;
}
/** True when the effective style holds a `padding` shorthand and no longhand. */
export function isLegacyShorthand(style: Record<string, any>): boolean {
return hasValue(style.padding) && ALL_SIDES.every((s) => !hasValue(style[PADDING_KEY[s]]));
}
/**
* Current padding of `side` in px: the effective longhand, else the matching
* token of an effective `padding` shorthand, else `computed` (the
* getComputedStyle value, for percent/em/unset), else 0.
*/
export function readPaddingPx(style: Record<string, any>, side: PaddingSide, computed?: string | null): number {
const long = parsePx(style[PADDING_KEY[side]]);
if (hasValue(style[PADDING_KEY[side]]) && long !== null) return long;
if (hasValue(style.padding) && !hasValue(style[PADDING_KEY[side]])) {
const short = parsePx(expandPaddingShorthand(style.padding)[side]);
if (short !== null) return short;
}
return parsePx(computed) ?? 0;
}
+41
View File
@@ -2971,3 +2971,44 @@ body {
.publish-check-info .publish-check-msg::before { content: '\2139\00a0'; color: #7dd3fc; }
.publish-check-fix { flex: none; background: none; border: 1px solid #94a3b8; border-radius: 4px; color: inherit; cursor: pointer; padding: 2px 10px; }
.publish-checklist-note { margin: 8px 0 0; color: #94a3b8; font-size: 12px; }
/* #56 on-canvas padding handles */
.spacing-handles {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 40;
}
.spacing-handle {
position: fixed;
pointer-events: auto;
background: var(--color-accent);
opacity: 0.35;
border-radius: 3px;
transform: translate(-50%, -50%);
transition: opacity 0.12s;
}
.spacing-handle-top,
.spacing-handle-bottom { width: 44px; height: 6px; cursor: ns-resize; }
.spacing-handle-left,
.spacing-handle-right { width: 6px; height: 44px; cursor: ew-resize; }
.spacing-handle:hover,
.spacing-handle.is-active { opacity: 0.9; }
.spacing-band {
position: fixed;
background: var(--color-accent);
opacity: 0.2;
pointer-events: none;
}
.spacing-readout {
position: fixed;
transform: translate(-50%, -140%);
padding: 1px 6px;
font-size: 11px;
font-weight: 600;
color: #fff;
background: var(--color-accent);
border-radius: 3px;
pointer-events: none;
white-space: nowrap;
}