Implement manual edit inspector (#1448)

* feat(web): tweaks palette popover with HSL hue-shift recoloring

Adds a Tweaks color-palette popover to the HTML preview toolbar.
Selecting a palette re-skins the iframe in place via a srcDoc-side
bridge that walks the DOM and shifts every chromatic paint to the
target hue while preserving each color's saturation and lightness —
pale tints stay pale, bold CTAs stay bold, just in the new color
family. Mono-noir desaturates instead of shifting.

- runtime/srcdoc: new injectPaletteBridge + paletteBridge / initialPalette options
- file-viewer-render-mode: paletteActive flips URL-load back to srcDoc so the bridge can be injected
- FileViewer: state, popover, postMessage wiring, srcDoc + useUrlLoadPreview integration
- PaletteTweaks: popover UI with Original + Coral / Electric / Acid forest / Risograph / Mono noir
- PreviewDrawOverlay: stub pass-through until the draw branch lands

* feat(web): hide finalize-design toolbar from project header

* test(e2e): skip project actions toolbar flow after toolbar removal

* Polish manual edit inspector

* Implement manual edit inspector

* Fix manual edit review regressions

* Fix FileViewer CI regressions

* Fix remaining manual edit review issues

* Flush manual edit styles before draw exit

* Restore Critique Theater styles

* Accept pixel line-height manual edits

---------

Co-authored-by: qiongyu1999 <2694684348@qq.com>
This commit is contained in:
Caprika 2026-05-13 13:25:58 +08:00 committed by GitHub
parent e3a848a33a
commit 6736310a01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1847 additions and 691 deletions

View file

@ -410,4 +410,3 @@ export function escapeHtmlAttr(value: string): string {
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

View file

@ -78,18 +78,25 @@ import type {
import { ManualEditPanel, emptyManualEditDraft, type ManualEditDraft } from './ManualEditPanel';
import {
applyManualEditPatch,
isManualEditFullHtmlDocument,
readManualEditAttributes,
readManualEditFields,
readManualEditOuterHtml,
readManualEditStyles,
} from '../edit-mode/source-patches';
import type { ManualEditBridgeMessage, ManualEditHistoryEntry, ManualEditPatch, ManualEditStyles, ManualEditTarget } from '../edit-mode/types';
import { MANUAL_EDIT_STYLE_PROPS, type ManualEditBridgeMessage, type ManualEditHistoryEntry, type ManualEditPatch, type ManualEditStyles, type ManualEditTarget } from '../edit-mode/types';
import { isRenderableSketchJson, SketchPreview } from './SketchPreview';
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
type SlideState = { active: number; count: number };
type BoardTool = 'inspect' | 'pod';
type StrokePoint = { x: number; y: number };
export type ManualEditPendingStyleSave = {
id: string;
styles: Partial<ManualEditStyles>;
label: string;
version: number;
};
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
type PreviewCanvasSize = { width: number; height: number };
type PreviewViewportPreset = {
@ -215,6 +222,57 @@ const DEPLOY_PROVIDER_OPTIONS: DeployProviderOption[] = [
},
];
function mergeManualEditInspectorStyles(
sourceStyles: ManualEditStyles,
previewStyles: ManualEditStyles,
): ManualEditStyles {
return MANUAL_EDIT_STYLE_PROPS.reduce<ManualEditStyles>((acc, key) => {
const sourceValue = sourceStyles[key]?.trim();
const previewValue = previewStyles[key]?.trim();
const value = sourceValue || previewValue || '';
acc[key] = manualEditInspectorStyleValue(key, value);
return acc;
}, {} as ManualEditStyles);
}
function manualEditInspectorStyleValue(key: keyof ManualEditStyles, value: string): string {
if (!value) return '';
if (key === 'color' || key === 'backgroundColor' || key === 'borderColor') {
return normalizeManualEditInspectorColor(value);
}
return value;
}
function normalizeManualEditInspectorColor(value: string): string {
const trimmed = value.trim();
if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed.toLowerCase();
if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
const r = trimmed[1]!, g = trimmed[2]!, b = trimmed[3]!;
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
}
const rgba = trimmed.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i);
if (!rgba) return trimmed;
if (rgba[4] !== undefined && Number(rgba[4]) === 0) return '';
const toHex = (raw: string) => Math.max(0, Math.min(255, Math.round(Number(raw))))
.toString(16)
.padStart(2, '0');
return `#${toHex(rgba[1]!)}${toHex(rgba[2]!)}${toHex(rgba[3]!)}`;
}
function manualEditPersistedValueMatchesSavedSnapshot(
key: keyof ManualEditStyles,
persistedValue: string,
savedValue: string,
): boolean {
return canonicalManualEditStyleValue(key, persistedValue) === canonicalManualEditStyleValue(key, savedValue);
}
function canonicalManualEditStyleValue(key: keyof ManualEditStyles, value: string): string {
const normalized = manualEditInspectorStyleValue(key, value).trim();
if (!normalized) return '';
return normalized.toLowerCase();
}
function getDeployProviderOption(providerId: WebDeployProviderId): DeployProviderOption {
return DEPLOY_PROVIDER_OPTIONS.find((option) => option.id === providerId) ?? DEPLOY_PROVIDER_OPTIONS[0]!;
}
@ -379,6 +437,34 @@ function previewScaleShellStyle(
};
}
function manualEditPreviewShellStyle(
viewport: PreviewViewportId,
previewScale: number,
frozenWidth: number | null,
): CSSProperties & Record<string, string | number> {
if (viewport === 'desktop' && frozenWidth) {
return {
width: `${frozenWidth / previewScale}px`,
height: `${100 / previewScale}%`,
transform: `scale(${previewScale})`,
transformOrigin: '0 0',
};
}
return previewScaleShellStyle(viewport, previewScale);
}
export function cancelManualEditPendingStyleSnapshot(
pending: ManualEditPendingStyleSave | null,
id: string,
keys: Array<keyof ManualEditStyles>,
): ManualEditPendingStyleSave | null {
if (!pending || pending.id !== id || keys.length === 0) return pending;
const nextStyles = { ...pending.styles };
for (const key of keys) delete nextStyles[key];
if (Object.keys(nextStyles).length === 0) return null;
return { ...pending, styles: nextStyles };
}
function usePreviewCanvasSize<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [size, setSize] = useState<PreviewCanvasSize | undefined>(undefined);
@ -3353,7 +3439,7 @@ function HtmlViewer({
const [inTabPresent, setInTabPresent] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [boardMode, setBoardMode] = useState(false);
const boardTool: BoardTool = 'inspect';
const [boardTool, setBoardTool] = useState<BoardTool>('inspect');
const [inspectMode, setInspectMode] = useState(false);
const [palettePopoverOpen, setPalettePopoverOpen] = useState(false);
const [selectedPalette, setSelectedPalette] = useState<PaletteId | null>(null);
@ -3364,21 +3450,31 @@ function HtmlViewer({
const [openHintBox, setOpenHintBox] = useState(true);
const [manualEditMode, setManualEditModeRaw] = useState(false);
const [manualEditFrozenSource, setManualEditFrozenSource] = useState<string | null>(null);
const [manualEditViewportWidth, setManualEditViewportWidth] = useState<number | null>(null);
const setManualEditMode = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
setManualEditModeRaw((prev) => {
const value = typeof next === 'function' ? (next as (p: boolean) => boolean)(prev) : next;
if (value !== prev && !value) setManualEditFrozenSource(null);
if (value !== prev && !value) {
setManualEditFrozenSource(null);
setManualEditViewportWidth(null);
}
return value;
});
}, []);
const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([]);
const [selectedManualEditTarget, setSelectedManualEditTarget] = useState<ManualEditTarget | null>(null);
const selectedManualEditTargetIdRef = useRef<string | null>(null);
const [manualEditDraft, setManualEditDraft] = useState<ManualEditDraft>(() => emptyManualEditDraft());
const [manualEditHistory, setManualEditHistory] = useState<ManualEditHistoryEntry[]>([]);
const [manualEditUndone, setManualEditUndone] = useState<ManualEditHistoryEntry[]>([]);
const [manualEditError, setManualEditError] = useState<string | null>(null);
const [manualEditSaving, setManualEditSaving] = useState(false);
const manualEditSavingRef = useRef(false);
const manualEditPendingStyleRef = useRef<ManualEditPendingStyleSave | null>(null);
const manualEditStyleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const manualEditPreviewVersionRef = useRef(0);
const sourceRef = useRef<string | null>(source);
const sourceFileKeyRef = useRef<string | null>(null);
const templateNameId = useId();
const templateDescriptionId = useId();
// Opt back into the legacy inline-asset srcDoc path via `?forceInline=1`
@ -3559,14 +3655,25 @@ function HtmlViewer({
}, [liveCommentTargets]);
useEffect(() => {
const sourceFileKey = `${projectId}\0${file.name}\0${liveHtml === undefined ? 'raw' : 'live'}`;
if (liveHtml !== undefined) {
sourceFileKeyRef.current = sourceFileKey;
setSource(liveHtml);
sourceRef.current = liveHtml;
return;
}
setSource(null);
const fileChanged = sourceFileKeyRef.current !== sourceFileKey;
sourceFileKeyRef.current = sourceFileKey;
if (fileChanged) {
setSource(null);
sourceRef.current = null;
}
let cancelled = false;
void fetchProjectFileText(projectId, file.name).then((text) => {
if (!cancelled) setSource(text);
if (!cancelled) {
setSource(text);
sourceRef.current = text;
}
});
return () => {
cancelled = true;
@ -3614,6 +3721,7 @@ function HtmlViewer({
const previewSource = (manualEditMode && manualEditFrozenSource !== null)
? manualEditFrozenSource
: livePreviewSource;
const manualEditPageStylesEnabled = typeof source === 'string' && isManualEditFullHtmlDocument(source);
const drawClickSelectionMode = drawOverlayOpen && drawOverlayMode === 'click' && !manualEditMode;
// When we URL-load the iframe directly, skip every in-host inlining /
// srcDoc-rebuilding step. The browser does the asset resolution itself,
@ -3694,13 +3802,21 @@ function HtmlViewer({
const win = iframeRef.current?.contentWindow;
if (!win) return;
win.postMessage({ type: 'od-edit-mode', enabled: manualEditMode }, '*');
}, [manualEditMode, srcDoc]);
postSelectedManualEditTargetToIframe(manualEditMode ? selectedManualEditTarget?.id ?? null : null);
}, [manualEditMode, selectedManualEditTarget?.id, srcDoc]);
const previewStyleToIframe = useCallback((id: string, styles: Partial<ManualEditStyles>) => {
const previewStyleToIframe = useCallback((id: string, styles: Partial<ManualEditStyles>, version: number) => {
const win = iframeRef.current?.contentWindow;
if (!win) return false;
win.postMessage({ type: 'od-edit-preview-style', id, styles, version }, '*');
return true;
}, []);
function postSelectedManualEditTargetToIframe(id: string | null) {
const win = iframeRef.current?.contentWindow;
if (!win) return;
win.postMessage({ type: 'od-edit-preview-style', id, styles }, '*');
}, []);
win.postMessage({ type: 'od-edit-selected-target', id }, '*');
}
function syncBridgeModes() {
const win = iframeRef.current?.contentWindow;
@ -3711,6 +3827,7 @@ function HtmlViewer({
mode: drawClickSelectionMode ? 'picker' : boardTool,
}, '*');
win.postMessage({ type: 'od-edit-mode', enabled: manualEditMode }, '*');
postSelectedManualEditTargetToIframe(manualEditMode ? selectedManualEditTarget?.id ?? null : null);
}
useEffect(() => {
@ -3789,12 +3906,17 @@ function HtmlViewer({
setInspectError(null);
setQueuedBoardNotes([]);
setStrokePoints([]);
setManualEditFrozenSource(null);
setManualEditViewportWidth(null);
setManualEditTargets([]);
setSelectedManualEditTarget(null);
selectedManualEditTargetIdRef.current = null;
setManualEditDraft(emptyManualEditDraft());
setManualEditHistory([]);
setManualEditUndone([]);
setManualEditError(null);
manualEditPendingStyleRef.current = null;
clearManualEditStyleTimer();
}, [file.name]);
// Selecting a new file or turning inspect off resets the panel target.
@ -3828,12 +3950,17 @@ function HtmlViewer({
}
useEffect(() => {
sourceRef.current = source;
if (source == null) return;
setManualEditDraft((current) => (
current.fullSource === source ? current : { ...current, fullSource: source }
));
}, [source]);
useEffect(() => {
selectedManualEditTargetIdRef.current = selectedManualEditTarget?.id ?? null;
}, [selectedManualEditTarget?.id]);
useEffect(() => {
const selectionMode = boardMode || drawClickSelectionMode;
if (!selectionMode) {
@ -3960,6 +4087,11 @@ function HtmlViewer({
setManualEditTargets([]);
setSelectedManualEditTarget(null);
setManualEditError(null);
manualEditPendingStyleRef.current = null;
if (manualEditStyleTimerRef.current) {
clearTimeout(manualEditStyleTimerRef.current);
manualEditStyleTimerRef.current = null;
}
return;
}
function onMessage(ev: MessageEvent) {
@ -3968,21 +4100,126 @@ function HtmlViewer({
if (!data?.type) return;
if (data.type === 'od-edit-targets' && Array.isArray(data.targets)) {
setManualEditTargets(data.targets);
// Target broadcasts can be briefly empty while the iframe/save path is
// settling; keep the user's inspector selection unless a fresh copy is
// available to update its metadata.
setSelectedManualEditTarget((current) =>
current ? data.targets.find((target) => target.id === current.id) ?? null : current,
current ? data.targets.find((target) => target.id === current.id) ?? current : current,
);
const selectedId = selectedManualEditTargetIdRef.current;
if (selectedId) setTimeout(() => postSelectedManualEditTargetToIframe(selectedId), 0);
return;
}
if (data.type === 'od-edit-select') {
selectManualEditTarget(data.target);
void selectManualEditTarget(data.target);
return;
}
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [manualEditMode, source]);
function selectManualEditTarget(target: ManualEditTarget) {
const base = source ?? '';
function nextManualEditPreviewVersion(): number {
manualEditPreviewVersionRef.current += 1;
return manualEditPreviewVersionRef.current;
}
function inspectorManualEditStyles(target: ManualEditTarget, baseSource: string): ManualEditStyles {
const inlineStyles = readManualEditStyles(baseSource, target.id);
return mergeManualEditInspectorStyles(inlineStyles, target.styles);
}
function reconcileManualEditStyleSave(
id: string,
savedStyles: Partial<ManualEditStyles>,
savedSource: string,
) {
if (id !== '__body__' && !readManualEditOuterHtml(savedSource, id)) {
setManualEditError('The selected target no longer exists in the saved source. Refreshing the preview.');
setSelectedManualEditTarget(null);
setManualEditFrozenSource(null);
setReloadKey((key) => key + 1);
return;
}
const sourceStyles = readManualEditStyles(savedSource, id);
const supersededStyles = manualEditPendingStyleRef.current?.id === id
? manualEditPendingStyleRef.current.styles
: {};
const repairStyles: Partial<ManualEditStyles> = {};
for (const key of Object.keys(savedStyles) as Array<keyof ManualEditStyles>) {
if (Object.prototype.hasOwnProperty.call(supersededStyles, key)) continue;
const sourceValue = manualEditInspectorStyleValue(key, sourceStyles[key] ?? '');
const savedValue = savedStyles[key] ?? '';
if (manualEditPersistedValueMatchesSavedSnapshot(key, sourceValue, savedValue)) continue;
repairStyles[key] = sourceValue;
}
if (Object.keys(repairStyles).length === 0) return;
previewStyleToIframe(id, repairStyles, nextManualEditPreviewVersion());
setManualEditDraft((current) => ({
...current,
styles: { ...current.styles, ...repairStyles },
}));
setManualEditError('Saved styles differed from the active preview. Reconciled the selected target from source.');
}
function scheduleManualEditStyleSave() {
if (manualEditStyleTimerRef.current) clearTimeout(manualEditStyleTimerRef.current);
manualEditStyleTimerRef.current = setTimeout(() => {
manualEditStyleTimerRef.current = null;
void flushManualEditStyleSave();
}, 1000);
}
function clearManualEditStyleTimer() {
if (!manualEditStyleTimerRef.current) return;
clearTimeout(manualEditStyleTimerRef.current);
manualEditStyleTimerRef.current = null;
}
function cancelManualEditPendingStyles(id: string, keys: Array<keyof ManualEditStyles>) {
const nextPending = cancelManualEditPendingStyleSnapshot(manualEditPendingStyleRef.current, id, keys);
if (!nextPending) {
manualEditPendingStyleRef.current = null;
clearManualEditStyleTimer();
return;
}
manualEditPendingStyleRef.current = nextPending;
}
async function handleManualEditStyleChange(id: string, styles: Partial<ManualEditStyles>, label: string) {
const version = nextManualEditPreviewVersion();
const currentPending = manualEditPendingStyleRef.current;
const pendingStyles = currentPending?.id === id
? { ...currentPending.styles, ...styles }
: styles;
const pending: ManualEditPendingStyleSave = { id, styles: pendingStyles, label, version };
manualEditPendingStyleRef.current = pending;
setManualEditError(null);
previewStyleToIframe(id, styles, version);
scheduleManualEditStyleSave();
}
async function flushManualEditStyleSave(): Promise<boolean> {
const pending = manualEditPendingStyleRef.current;
if (!pending) return true;
if (manualEditSavingRef.current) {
scheduleManualEditStyleSave();
return false;
}
manualEditPendingStyleRef.current = null;
return applyManualEdit({ id: pending.id, kind: 'set-style', styles: pending.styles }, pending.label);
}
async function exitManualEditModeAfterFlush(): Promise<boolean> {
const ok = await flushManualEditStyleSave();
if (!ok) return false;
setManualEditMode(false);
return true;
}
async function selectManualEditTarget(target: ManualEditTarget) {
if (!(await flushManualEditStyleSave())) return;
const base = sourceRef.current ?? '';
const fields = readManualEditFields(base, target.id);
setSelectedManualEditTarget(target);
setManualEditDraft({
@ -3990,7 +4227,7 @@ function HtmlViewer({
href: fields.href ?? target.fields.href ?? '',
src: fields.src ?? target.fields.src ?? '',
alt: fields.alt ?? target.fields.alt ?? '',
styles: readManualEditStyles(base, target.id),
styles: inspectorManualEditStyles(target, base),
attributesText: JSON.stringify(readManualEditAttributes(base, target.id), null, 2),
outerHtml: readManualEditOuterHtml(base, target.id) || target.outerHtml,
fullSource: base,
@ -3998,29 +4235,36 @@ function HtmlViewer({
setManualEditError(null);
}
async function applyManualEdit(patch: ManualEditPatch, label: string) {
if (manualEditSavingRef.current) return;
if (source == null) return;
async function clearManualEditTargetSelection() {
if (!(await flushManualEditStyleSave())) return;
setSelectedManualEditTarget(null);
setManualEditDraft(emptyManualEditDraft(sourceRef.current ?? ''));
setManualEditError(null);
}
async function applyManualEdit(patch: ManualEditPatch, label: string): Promise<boolean> {
if (manualEditSavingRef.current) return false;
if (sourceRef.current == null) return false;
manualEditSavingRef.current = true;
setManualEditSaving(true);
setManualEditError(null);
try {
const baseSource = source;
const baseSource = sourceRef.current;
const result = applyManualEditPatch(baseSource, patch);
if (!result.ok) {
setManualEditError(result.error ?? 'Could not apply edit.');
return;
return false;
}
if (!(await confirmManualEditHistorySource(
baseSource,
'The file changed outside manual edit mode. Refreshing before applying manual edits.',
))) return;
))) return false;
const saved = await writeProjectTextFile(projectId, file.name, result.source, {
artifactManifest: file.artifactManifest,
});
if (!saved) {
setManualEditError('Could not save the edited file.');
return;
return false;
}
const entry: ManualEditHistoryEntry = {
id: `${Date.now()}-${manualEditHistory.length}`,
@ -4031,14 +4275,20 @@ function HtmlViewer({
createdAt: Date.now(),
};
setSource(result.source);
sourceRef.current = result.source;
setInlinedSource(null);
setManualEditHistory((current) => [entry, ...current]);
setManualEditUndone([]);
setManualEditDraft((current) => ({ ...current, fullSource: result.source }));
if (patch.kind === 'set-style') {
reconcileManualEditStyleSave(patch.id, patch.styles, result.source);
}
await onFileSaved?.();
return true;
} finally {
manualEditSavingRef.current = false;
setManualEditSaving(false);
if (manualEditPendingStyleRef.current) scheduleManualEditStyleSave();
}
}
@ -4049,9 +4299,11 @@ function HtmlViewer({
});
if (persisted == null || persisted === expectedSource) return true;
setSource(persisted);
sourceRef.current = persisted;
setInlinedSource(null);
setManualEditHistory([]);
setManualEditUndone([]);
manualEditPendingStyleRef.current = null;
setManualEditDraft((current) => ({ ...current, fullSource: persisted }));
setManualEditError(message);
return false;
@ -4076,6 +4328,7 @@ function HtmlViewer({
return;
}
setSource(latest.beforeSource);
sourceRef.current = latest.beforeSource;
setInlinedSource(null);
setManualEditHistory(rest);
setManualEditUndone((current) => [latest, ...current]);
@ -4106,6 +4359,7 @@ function HtmlViewer({
return;
}
setSource(latest.afterSource);
sourceRef.current = latest.afterSource;
setInlinedSource(null);
setManualEditUndone(rest);
setManualEditHistory((current) => [latest, ...current]);
@ -4565,6 +4819,12 @@ function HtmlViewer({
setStrokePoints([]);
}
function activateBoard(tool: BoardTool) {
setBoardTool(tool);
setDrawOverlayOpen(false);
setBoardMode(true);
}
function queueCurrentDraft() {
const note = commentDraft.trim();
if (!note) return;
@ -4704,6 +4964,7 @@ function HtmlViewer({
if (state === 'failed') return t('fileViewer.deployLinkFailed');
return t('fileViewer.deployLinkPreparingLabel');
};
const boardAvailable = mode === 'preview' && source !== null;
return (
<div className="viewer html-viewer">
@ -4821,8 +5082,11 @@ function HtmlViewer({
setInspectMode(false);
setDrawOverlayOpen(false);
setMode('preview');
setManualEditViewportWidth(previewBodyRef.current?.clientWidth ?? null);
setManualEditMode(true);
return;
}
setManualEditMode((value) => !value);
void exitManualEditModeAfterFlush();
}}
>
<Icon name="edit" size={13} />
@ -4836,15 +5100,25 @@ function HtmlViewer({
aria-pressed={drawOverlayOpen}
onClick={() => {
const next = !drawOverlayOpen;
if (next) {
setManualEditMode(false);
if (!next) {
setDrawOverlayOpen(false);
return;
}
const activateDraw = () => {
setBoardMode(false);
clearBoardComposer();
setInspectMode(false);
setDrawOverlayMode('draw');
setMode('preview');
setDrawOverlayOpen(true);
};
if (manualEditMode) {
void exitManualEditModeAfterFlush().then((ok) => {
if (ok) activateDraw();
});
return;
}
setDrawOverlayOpen(next);
activateDraw();
}}
>
<Icon name="draw" size={13} />
@ -5123,13 +5397,20 @@ function HtmlViewer({
canUndo={manualEditHistory.length > 0}
canRedo={manualEditUndone.length > 0}
busy={manualEditSaving}
pageStylesEnabled={manualEditPageStylesEnabled}
onSelectTarget={selectManualEditTarget}
onDraftChange={setManualEditDraft}
onPreviewStyle={previewStyleToIframe}
onStyleChange={(id, styles, label) => {
void handleManualEditStyleChange(id, styles, label);
}}
onInvalidStyle={cancelManualEditPendingStyles}
onApplyPatch={(patch, label) => {
void applyManualEdit(patch, label);
}}
onError={setManualEditError}
onClearSelection={() => {
void clearManualEditTargetSelection();
}}
onCancelDraft={() => {
if (selectedManualEditTarget) selectManualEditTarget(selectedManualEditTarget);
}}
@ -5143,7 +5424,11 @@ function HtmlViewer({
) : null}
<div className={manualEditMode ? 'manual-edit-canvas' : 'comment-frame-clip'}>
<div
style={previewScaleShellStyle(previewViewport, previewScale)}
style={
manualEditMode
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
: previewScaleShellStyle(previewViewport, previewScale)
}
>
<PreviewDrawOverlay
active={drawOverlayOpen}

View file

@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useT } from '../i18n';
import { emptyManualEditStyles, type ManualEditHistoryEntry, type ManualEditPatch, type ManualEditStyles, type ManualEditTarget } from '../edit-mode/types';
export interface ManualEditDraft {
@ -13,8 +12,6 @@ export interface ManualEditDraft {
fullSource: string;
}
export type ManualEditTab = 'content' | 'style' | 'attributes' | 'html' | 'source';
export function emptyManualEditDraft(source = ''): ManualEditDraft {
return {
text: '', href: '', src: '', alt: '',
@ -24,22 +21,15 @@ export function emptyManualEditDraft(source = ''): ManualEditDraft {
}
export function ManualEditPanel({
targets,
selectedTarget,
draft,
history,
error,
canUndo,
canRedo,
busy = false,
onSelectTarget,
onDraftChange,
onPreviewStyle,
onApplyPatch,
onStyleChange,
onInvalidStyle,
onError,
onCancelDraft,
onUndo,
onRedo,
onClearSelection,
pageStylesEnabled = true,
}: {
targets: ManualEditTarget[];
selectedTarget: ManualEditTarget | null;
@ -49,241 +39,123 @@ export function ManualEditPanel({
canUndo: boolean;
canRedo: boolean;
busy?: boolean;
pageStylesEnabled?: boolean;
onSelectTarget: (target: ManualEditTarget) => void;
onDraftChange: (draft: ManualEditDraft) => void;
onPreviewStyle?: (id: string, styles: Partial<ManualEditStyles>) => void;
onStyleChange?: (id: string, styles: Partial<ManualEditStyles>, label: string) => void;
onInvalidStyle?: (id: string, keys: Array<keyof ManualEditStyles>) => void;
onApplyPatch: (patch: ManualEditPatch, label: string) => void;
onError: (message: string) => void;
onClearSelection: () => void;
onCancelDraft: () => void;
onUndo: () => void;
onRedo: () => void;
}) {
const t = useT();
const [tab, setTab] = useState<ManualEditTab>('style');
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => { setTab('style'); }, [selectedTarget?.id]);
// Live preview: every style change goes to the iframe via postMessage so the
// canvas updates instantly without an iframe reload. The 1.5s debounced
// source-rewrite still happens via onApplyPatch for persistence.
const lastPreviewedRef = useRef<string>('');
const lastAppliedRef = useRef<string>('');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!selectedTarget || tab !== 'style') return;
const key = JSON.stringify(draft.styles);
if (key !== lastPreviewedRef.current) {
lastPreviewedRef.current = key;
onPreviewStyle?.(selectedTarget.id, draft.styles);
}
if (key === lastAppliedRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
lastAppliedRef.current = key;
onApplyPatch({ id: selectedTarget.id, kind: 'set-style', styles: draft.styles }, `Style: ${selectedTarget.label}`);
}, 1500);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [draft.styles, selectedTarget?.id, tab, onPreviewStyle, onApplyPatch]);
useEffect(() => {
lastAppliedRef.current = JSON.stringify(draft.styles);
lastPreviewedRef.current = lastAppliedRef.current;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTarget?.id]);
const targetForInspector = selectedTarget;
const changeTargetStyle = (key: keyof ManualEditStyles, value: string) => {
const nextStyles = { ...draft.styles, [key]: value };
onDraftChange({ ...draft, styles: nextStyles });
if (!targetForInspector) return;
const normalized = normalizeManualEditStyles({ [key]: value }, {
layoutEnabled: targetForInspector.isLayoutContainer,
});
if (!normalized.ok) {
onError(normalized.error);
onInvalidStyle?.(targetForInspector.id, [key]);
return;
}
onError('');
onStyleChange?.(targetForInspector.id, normalized.styles, `Style: ${targetForInspector.label}`);
};
return (
<aside className="manual-edit-right">
<section className="manual-edit-modal cc-panel">
{targetForInspector && tab === 'style' ? (
{targetForInspector ? (
<StyleInspector
styles={draft.styles}
onChange={(styles) => onDraftChange({ ...draft, styles })}
layoutEnabled={targetForInspector.isLayoutContainer}
onClearSelection={onClearSelection}
onChange={changeTargetStyle}
/>
) : !targetForInspector ? (
<PageInspector onPreviewStyle={onPreviewStyle} onApplyPatch={onApplyPatch} />
) : tab === 'content' ? (
<ContentEditor target={targetForInspector} draft={draft} onDraftChange={onDraftChange} />
) : tab === 'attributes' ? (
<label className="manual-edit-field">
<span>{t('manualEdit.attributesJson')}</span>
<textarea className="manual-edit-code" value={draft.attributesText}
onChange={(event) => onDraftChange({ ...draft, attributesText: event.currentTarget.value })} />
</label>
) : tab === 'html' ? (
<label className="manual-edit-field">
<span>{t('manualEdit.selectedHtml')}</span>
<textarea className="manual-edit-code tall" value={draft.outerHtml}
onChange={(event) => onDraftChange({ ...draft, outerHtml: event.currentTarget.value })} />
</label>
) : tab === 'source' ? (
<label className="manual-edit-field">
<span>{t('manualEdit.fullSource')}</span>
<textarea className="manual-edit-code tall" value={draft.fullSource}
onChange={(event) => onDraftChange({ ...draft, fullSource: event.currentTarget.value })} />
</label>
<PageInspector
enabled={pageStylesEnabled}
onStyleChange={(styles) => {
const normalized = normalizeManualEditStyles(styles, { layoutEnabled: true });
if (!normalized.ok) {
onError(normalized.error);
onInvalidStyle?.('__body__', Object.keys(styles) as Array<keyof ManualEditStyles>);
return;
}
onError('');
onStyleChange?.('__body__', normalized.styles, 'Page styles');
}}
/>
) : null}
{error ? <div className="manual-edit-error">{error}</div> : null}
{targetForInspector ? (
<div className="cc-advanced">
<button type="button" className="cc-advanced-toggle" onClick={() => setAdvancedOpen((v) => !v)}>
{advancedOpen ? '▾' : '▸'} Advanced
</button>
{advancedOpen ? (
<>
<div className="manual-edit-tabs" role="tablist">
<EditTabButton label={t('manualEdit.tabStyle')} tab="style" active={tab === 'style'} onClick={setTab} />
<EditTabButton label={t('manualEdit.tabContent')} tab="content" active={tab === 'content'} onClick={setTab} />
<EditTabButton label={t('manualEdit.tabAttributes')} tab="attributes" active={tab === 'attributes'} onClick={setTab} />
<EditTabButton label={t('manualEdit.tabHtml')} tab="html" active={tab === 'html'} onClick={setTab} />
<EditTabButton label={t('manualEdit.tabSource')} tab="source" active={tab === 'source'} onClick={setTab} />
</div>
<div className="manual-edit-actions">
<button type="button" onClick={onCancelDraft} disabled={busy}>{t('common.cancel')}</button>
<button type="button" onClick={onUndo} disabled={busy || !canUndo}></button>
<button type="button" onClick={onRedo} disabled={busy || !canRedo}></button>
{tab === 'content' ? (
<button type="button" className="primary" disabled={busy}
onClick={() => onApplyPatch(contentPatch(targetForInspector, draft), `Content: ${targetForInspector.label}`)}>
{t('manualEdit.applyContent')}
</button>
) : null}
{tab === 'attributes' ? (
<button type="button" className="primary" disabled={busy}
onClick={() => {
try { onApplyPatch(parseAttributesPatch(targetForInspector.id, draft.attributesText), `Attributes: ${targetForInspector.label}`); }
catch (err) { onError(err instanceof Error ? err.message : t('manualEdit.invalidAttributes')); }
}}>
{t('manualEdit.applyAttributes')}
</button>
) : null}
{tab === 'html' ? (
<button type="button" className="primary" disabled={busy}
onClick={() => onApplyPatch({ id: targetForInspector.id, kind: 'set-outer-html', html: draft.outerHtml }, `HTML: ${targetForInspector.label}`)}>
{t('manualEdit.applyHtml')}
</button>
) : null}
{tab === 'source' ? (
<button type="button" className="primary" disabled={busy}
onClick={() => onApplyPatch({ kind: 'set-full-source', source: draft.fullSource }, 'Full source')}>
{t('manualEdit.applySource')}
</button>
) : null}
</div>
<details className="cc-disclosure">
<summary>Layers ({targets.length})</summary>
<div className="manual-edit-layer-list">
{targets.map((target) => (
<button key={target.id} type="button"
className={`manual-edit-layer-row ${targetForInspector?.id === target.id ? 'selected' : ''}`}
onClick={() => onSelectTarget(target)}>
<strong>{target.label}</strong>
<span>{target.kind} - {target.id}</span>
</button>
))}
</div>
</details>
<details className="cc-disclosure">
<summary>Changes ({history.length})</summary>
{history.length === 0 ? (
<div className="manual-edit-empty">No changes yet.</div>
) : (
<div className="manual-edit-history-list">
{history.map((entry) => (
<article key={entry.id} className="manual-edit-history-entry">
<strong>{entry.label}</strong>
<code>{manualEditPatchSummary(entry.patch)}</code>
</article>
))}
</div>
)}
</details>
</>
) : null}
</div>
) : null}
</section>
</aside>
);
}
function PageInspector({
onPreviewStyle, onApplyPatch,
enabled,
onStyleChange,
}: {
onPreviewStyle?: (id: string, styles: Partial<ManualEditStyles>) => void;
onApplyPatch: (patch: ManualEditPatch, label: string) => void;
enabled: boolean;
onStyleChange: (styles: Partial<ManualEditStyles>) => void;
}) {
const [bg, setBg] = useState('');
const [font, setFont] = useState('');
const [size, setSize] = useState('');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastAppliedRef = useRef<string>('');
useEffect(() => {
const styles: Partial<ManualEditStyles> = {
backgroundColor: bg,
fontFamily: font,
fontSize: size ? (/^\d+(\.\d+)?$/.test(size.trim()) ? `${size.trim()}px` : size.trim()) : '',
};
const key = JSON.stringify(styles);
onPreviewStyle?.('__body__', styles);
if (key === lastAppliedRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
lastAppliedRef.current = key;
onApplyPatch({ id: '__body__', kind: 'set-style', styles }, 'Page styles');
}, 1500);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bg, font, size]);
const update = (next: { bg?: string; font?: string; size?: string }) => {
if ('bg' in next) {
const value = next.bg ?? '';
setBg(value);
onStyleChange({ backgroundColor: value });
}
if ('font' in next) {
const value = next.font ?? '';
setFont(value);
onStyleChange({ fontFamily: value });
}
if ('size' in next) {
const value = next.size ?? '';
setSize(value);
onStyleChange({ fontSize: value });
}
};
return (
<div className="cc-inspector">
<Section title="PAGE">
<ColorRow label="Background" value={bg} onChange={setBg} />
<DropdownRow label="Font" value={font} onChange={setFont} options={['', 'Space Grotesk', 'Inter', 'Times', 'Arial']} placeholder="Space Grotesk" />
<UnitRow label="Base size" value={size} onChange={setSize} unit="px" />
{enabled ? (
<>
<ColorRow label="Background" value={bg} onChange={(value) => update({ bg: value })} />
<FontRow value={font} onChange={(value) => update({ font: value })} />
<UnitRow label="Base size" value={size} onChange={(value) => update({ size: value })} unit="px" autoUnit />
</>
) : (
<p className="cc-section-hint">Page styles are available only for full HTML documents.</p>
)}
</Section>
</div>
);
}
function ContentEditor({ target, draft, onDraftChange }: {
target: ManualEditTarget; draft: ManualEditDraft; onDraftChange: (draft: ManualEditDraft) => void;
}) {
const t = useT();
if (target.kind === 'image') {
return (
<>
<label className="manual-edit-field"><span>{t('manualEdit.imageUrl')}</span>
<input value={draft.src} onChange={(event) => onDraftChange({ ...draft, src: event.currentTarget.value })} />
</label>
<label className="manual-edit-field"><span>{t('manualEdit.altText')}</span>
<input value={draft.alt} onChange={(event) => onDraftChange({ ...draft, alt: event.currentTarget.value })} />
</label>
</>
);
}
return (
<>
<label className="manual-edit-field">
<span>{target.kind === 'link' ? t('manualEdit.label') : t('manualEdit.text')}</span>
<textarea value={draft.text} onChange={(event) => onDraftChange({ ...draft, text: event.currentTarget.value })} />
</label>
{target.kind === 'link' ? (
<label className="manual-edit-field"><span>{t('manualEdit.href')}</span>
<input value={draft.href} onChange={(event) => onDraftChange({ ...draft, href: event.currentTarget.value })} />
</label>
) : null}
</>
);
}
const FONT_OPTS = ['', 'Space Grotesk', 'Inter', 'Times', 'Arial', 'Roboto', 'Helvetica', 'Georgia', 'monospace'];
const FONT_OPTS = [
{ label: 'inherit', value: '' },
{ label: 'Space Grotesk', value: '"Space Grotesk", Inter, system-ui, sans-serif' },
{ label: 'Inter', value: 'Inter, system-ui, sans-serif' },
{ label: 'Times', value: '"Times New Roman", Times, serif' },
{ label: 'Arial', value: 'Arial, Helvetica, sans-serif' },
{ label: 'Roboto', value: 'Roboto, Arial, sans-serif' },
{ label: 'Helvetica', value: 'Helvetica, Arial, sans-serif' },
{ label: 'Georgia', value: 'Georgia, serif' },
{ label: 'monospace', value: 'SFMono-Regular, Consolas, "Liberation Mono", monospace' },
] as const;
const WEIGHT_OPTS = ['', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
const ALIGN_OPTS = ['', 'left', 'center', 'right', 'justify', 'start', 'end'];
const DIRECTION_OPTS = ['', 'row', 'column', 'row-reverse', 'column-reverse'];
@ -305,19 +177,127 @@ const EDITOR_SWATCH_COLORS = [
'#ec4899',
] as const;
type NormalizeResult =
| { ok: true; styles: Partial<ManualEditStyles> }
| { ok: false; error: string };
const PX_STYLE_PROPS = new Set<keyof ManualEditStyles>([
'fontSize', 'letterSpacing', 'width', 'height', 'minHeight', 'gap',
'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
'margin', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
'border', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
'borderRadius',
]);
const COLOR_STYLE_PROPS = new Set<keyof ManualEditStyles>(['color', 'backgroundColor', 'borderColor']);
const SELECT_STYLE_OPTIONS: Partial<Record<keyof ManualEditStyles, ReadonlyArray<string>>> = {
fontFamily: FONT_OPTS.map((option) => option.value),
fontWeight: WEIGHT_OPTS,
textAlign: ALIGN_OPTS,
flexDirection: DIRECTION_OPTS,
justifyContent: JUSTIFY_OPTS,
alignItems: ITEMS_OPTS,
borderStyle: BORDER_STYLE_OPTS,
};
const LAYOUT_STYLE_PROPS = new Set<keyof ManualEditStyles>(['gap', 'flexDirection', 'justifyContent', 'alignItems']);
export function normalizeManualEditStyles(
styles: Partial<ManualEditStyles>,
{ layoutEnabled }: { layoutEnabled: boolean },
): NormalizeResult {
const normalized: Partial<ManualEditStyles> = {};
for (const [rawKey, rawValue] of Object.entries(styles) as Array<[keyof ManualEditStyles, string]>) {
if (LAYOUT_STYLE_PROPS.has(rawKey) && !layoutEnabled) continue;
const value = rawValue.trim();
if (value === '') {
normalized[rawKey] = '';
continue;
}
if (PX_STYLE_PROPS.has(rawKey)) {
const px = normalizePxValue(value);
if (!px) return { ok: false, error: `${styleLabel(rawKey)} must be a number or px value.` };
normalized[rawKey] = px;
continue;
}
if (COLOR_STYLE_PROPS.has(rawKey)) {
const color = normalizeHexColor(value);
if (!color) return { ok: false, error: `${styleLabel(rawKey)} must be a hex color.` };
normalized[rawKey] = color;
continue;
}
if (rawKey === 'opacity') {
const n = Number(value);
if (!Number.isFinite(n)) return { ok: false, error: 'Opacity must be a number.' };
normalized.opacity = String(Math.max(0, Math.min(1, n)));
continue;
}
if (rawKey === 'lineHeight') {
const lineHeight = normalizeLineHeightValue(value);
if (!lineHeight) return { ok: false, error: 'Line height must be a positive number or px value.' };
normalized.lineHeight = lineHeight;
continue;
}
const options = SELECT_STYLE_OPTIONS[rawKey];
if (options) {
if (!options.includes(value)) return { ok: false, error: `${styleLabel(rawKey)} has an unsupported value.` };
normalized[rawKey] = value;
continue;
}
normalized[rawKey] = value;
}
return { ok: true, styles: normalized };
}
function normalizePxValue(value: string): string | null {
if (/^-?\d+(\.\d+)?$/.test(value)) return `${value}px`;
if (/^-?\d+(\.\d+)?px$/i.test(value)) return value.toLowerCase();
return null;
}
function normalizeLineHeightValue(value: string): string | null {
if (/^\d+(\.\d+)?$/.test(value)) {
const n = Number(value);
return n > 0 ? String(n) : null;
}
if (/^\d+(\.\d+)?px$/i.test(value)) {
const n = Number(value.slice(0, -2));
return n > 0 ? value.toLowerCase() : null;
}
return null;
}
function normalizeHexColor(value: string): string | null {
const trimmed = value.trim();
if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed.toLowerCase();
if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
const r = trimmed[1]!, g = trimmed[2]!, b = trimmed[3]!;
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
}
return null;
}
function styleLabel(key: keyof ManualEditStyles): string {
return key.replace(/[A-Z]/g, (match) => ` ${match.toLowerCase()}`);
}
function StyleInspector({
styles, onChange,
styles, layoutEnabled, onClearSelection, onChange,
}: {
styles: ManualEditStyles;
onChange: (styles: ManualEditStyles) => void;
layoutEnabled: boolean;
onClearSelection: () => void;
onChange: (key: keyof ManualEditStyles, value: string) => void;
}) {
const u = (key: keyof ManualEditStyles, value: string) => onChange({ ...styles, [key]: value });
const um = (patch: Partial<ManualEditStyles>) => onChange({ ...styles, ...patch });
const u = (key: keyof ManualEditStyles, value: string) => onChange(key, value);
return (
<div className="cc-inspector">
<div className="cc-inspector-nav">
<button type="button" className="cc-inspector-page" onClick={onClearSelection} aria-label="Show page inspector">
Page
</button>
</div>
<Section title="TYPOGRAPHY">
<DropdownRow label="Font" value={styles.fontFamily} onChange={(v) => u('fontFamily', v)} options={FONT_OPTS} placeholder="inherit" />
<FontRow value={styles.fontFamily} onChange={(v) => u('fontFamily', v)} />
<PairRow>
<UnitRow label="Size" value={styles.fontSize} onChange={(v) => u('fontSize', v)} unit="px" autoUnit />
<DropdownRow label="Weight" value={styles.fontWeight} onChange={(v) => u('fontWeight', v)} options={WEIGHT_OPTS} />
@ -339,14 +319,17 @@ function StyleInspector({
</PairRow>
</Section>
<Section title="LAYOUT">
<Section title="LAYOUT" inactive={!layoutEnabled}>
{!layoutEnabled ? (
<p className="cc-section-hint">Select a container or group to edit layout.</p>
) : null}
<PairRow>
<UnitRow label="Gap" value={styles.gap} onChange={(v) => u('gap', v)} unit="px" autoUnit />
<DropdownRow label="Direction" value={styles.flexDirection} onChange={(v) => u('flexDirection', v)} options={DIRECTION_OPTS} />
<UnitRow label="Gap" value={styles.gap} onChange={(v) => u('gap', v)} unit="px" autoUnit disabled={!layoutEnabled} />
<DropdownRow label="Direction" value={styles.flexDirection} onChange={(v) => u('flexDirection', v)} options={DIRECTION_OPTS} disabled={!layoutEnabled} />
</PairRow>
<PairRow>
<DropdownRow label="Justify" value={styles.justifyContent} onChange={(v) => u('justifyContent', v)} options={JUSTIFY_OPTS} />
<DropdownRow label="Align" value={styles.alignItems} onChange={(v) => u('alignItems', v)} options={ITEMS_OPTS} />
<DropdownRow label="Justify" value={styles.justifyContent} onChange={(v) => u('justifyContent', v)} options={JUSTIFY_OPTS} disabled={!layoutEnabled} />
<DropdownRow label="Align" value={styles.alignItems} onChange={(v) => u('alignItems', v)} options={ITEMS_OPTS} disabled={!layoutEnabled} />
</PairRow>
</Section>
@ -378,9 +361,9 @@ function StyleInspector({
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
function Section({ title, children, inactive }: { title: string; children: React.ReactNode; inactive?: boolean }) {
return (
<section className="cc-section">
<section className={`cc-section${inactive ? ' cc-section-inactive' : ''}`}>
<header className="cc-section-head">{title}</header>
<div className="cc-section-body">{children}</div>
</section>
@ -391,36 +374,50 @@ function PairRow({ children }: { children: React.ReactNode }) {
return <div className="cc-pair">{children}</div>;
}
function UnitRow({ label, value, onChange, unit, autoUnit }: {
function UnitRow({ label, value, onChange, unit, autoUnit, disabled }: {
label: string; value: string; onChange: (v: string) => void;
unit: string; autoUnit?: boolean;
unit: string; autoUnit?: boolean; disabled?: boolean;
}) {
const display = value;
const handle = (raw: string) => {
const display = unit === 'px' ? stripPxUnit(value) : value;
const step = unit === 'px' ? 1 : 0.1;
const canStep = !disabled && isNumericInput(display);
const valueFromDisplay = (raw: string) => {
const trimmed = raw.trim();
if (autoUnit && trimmed && /^-?\d+(\.\d+)?$/.test(trimmed)) onChange(`${trimmed}px`);
else onChange(raw);
if (autoUnit && trimmed && isNumericInput(trimmed)) return `${trimmed}px`;
if (autoUnit && /^-?\d+(\.\d+)?px$/i.test(trimmed)) return trimmed.toLowerCase();
return raw;
};
const handle = (raw: string) => {
const next = valueFromDisplay(raw);
if (next !== value) onChange(next);
};
const stepBy = (direction: -1 | 1) => {
if (!canStep) return;
const next = formatSteppedNumber(Number(display) + direction * step, display, step);
onChange(valueFromDisplay(next));
};
return (
<label className="cc-row">
<span className="cc-label">{label}</span>
<span className="cc-value">
<input value={display} placeholder="" onChange={(e) => onChange(e.currentTarget.value)} onBlur={(e) => handle(e.currentTarget.value)} />
<button type="button" className="cc-step" disabled={!canStep} aria-label={`${label} decrease`} onClick={() => stepBy(-1)}></button>
<input value={display} placeholder="" disabled={disabled} onChange={(e) => onChange(valueFromDisplay(e.currentTarget.value))} onBlur={(e) => handle(e.currentTarget.value)} />
<button type="button" className="cc-step" disabled={!canStep} aria-label={`${label} increase`} onClick={() => stepBy(1)}>+</button>
{unit ? <em className="cc-unit">{unit}</em> : null}
</span>
</label>
);
}
function DropdownRow({ label, value, onChange, options, placeholder }: {
function DropdownRow({ label, value, onChange, options, placeholder, disabled }: {
label: string; value: string; onChange: (v: string) => void;
options: ReadonlyArray<string>; placeholder?: string;
options: ReadonlyArray<string>; placeholder?: string; disabled?: boolean;
}) {
return (
<label className="cc-row">
<span className="cc-label">{label}</span>
<span className="cc-value cc-select">
<select value={value} onChange={(e) => onChange(e.currentTarget.value)}>
<select value={value} disabled={disabled} onChange={(e) => onChange(e.currentTarget.value)}>
{!options.includes(value) && value ? <option value={value}>{value}</option> : null}
{options.map((opt) => <option key={opt || '__'} value={opt}>{opt || (placeholder ?? '')}</option>)}
</select>
@ -430,6 +427,56 @@ function DropdownRow({ label, value, onChange, options, placeholder }: {
);
}
function FontRow({ value, onChange }: {
value: string;
onChange: (v: string) => void;
}) {
const normalizedValue = normalizeFontFamilyForSelect(value);
const customValue = normalizedValue === value ? value : '';
return (
<label className="cc-row">
<span className="cc-label">Font</span>
<span className="cc-value cc-select">
<select value={normalizedValue} onChange={(event) => onChange(event.currentTarget.value)}>
{customValue && !FONT_OPTS.some((option) => option.value === customValue) ? (
<option value={customValue}>{fontFamilyLabel(customValue)}</option>
) : null}
{FONT_OPTS.map((option) => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
</select>
<em className="cc-chevron"></em>
</span>
</label>
);
}
function normalizeFontFamilyForSelect(value: string): string {
const trimmed = value.trim();
if (!trimmed) return '';
const direct = FONT_OPTS.find((option) => option.value === trimmed);
if (direct) return direct.value;
const families = parseFontFamilies(trimmed);
const primaryFamily = families[0];
const match = FONT_OPTS.find((option) => {
if (!option.value) return false;
const optionFamilies = parseFontFamilies(option.value);
return optionFamilies[0] === primaryFamily;
});
return match?.value ?? trimmed;
}
function fontFamilyLabel(value: string): string {
return parseFontFamilies(value)[0] ?? value;
}
function parseFontFamilies(value: string): string[] {
return value
.split(',')
.map((family) => family.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
.filter(Boolean);
}
function ColorRow({ label, value, onChange, compact }: {
label: string; value: string; onChange: (v: string) => void; compact?: boolean;
}) {
@ -498,20 +545,56 @@ function QuadRow({ label, values, onChange }: {
}
function QuadCell({ axis, value, onChange }: { axis: string; value: string; onChange: (v: string) => void }) {
const display = stripPxUnit(value);
const canStep = isNumericInput(display);
const stepBy = (direction: -1 | 1) => {
if (!canStep) return;
onChange(`${formatSteppedNumber(Number(display) + direction, display, 1)}px`);
};
return (
<span className="cc-quad-cell">
<em className="cc-quad-axis">{axis}</em>
<input value={value} placeholder="0"
onChange={(e) => onChange(e.currentTarget.value)}
<button type="button" className="cc-step cc-step-quad" disabled={!canStep} aria-label={`${axis} decrease`} onClick={() => stepBy(-1)}></button>
<input value={display} placeholder="0"
onChange={(e) => {
const raw = e.currentTarget.value.trim();
if (raw === '') onChange('');
else if (isNumericInput(raw)) onChange(`${raw}px`);
else if (/^-?\d+(\.\d+)?px$/i.test(raw)) onChange(raw.toLowerCase());
else onChange(e.currentTarget.value);
}}
onBlur={(e) => {
const v = e.currentTarget.value.trim();
if (v && /^-?\d+(\.\d+)?$/.test(v)) onChange(`${v}px`);
const next = v && isNumericInput(v) ? `${v}px` : e.currentTarget.value;
if (next !== value) onChange(next);
}} />
<button type="button" className="cc-step cc-step-quad" disabled={!canStep} aria-label={`${axis} increase`} onClick={() => stepBy(1)}>+</button>
<em className="cc-quad-unit">px</em>
</span>
);
}
function stripPxUnit(value: string): string {
const match = value.trim().match(/^(-?\d+(?:\.\d+)?)px$/i);
return match?.[1] ?? value;
}
function isNumericInput(value: string): boolean {
return /^-?\d+(\.\d+)?$/.test(value.trim());
}
function formatSteppedNumber(value: number, current: string, step: number): string {
const decimals = Math.max(decimalPlaces(current), decimalPlaces(String(step)));
return decimals > 0
? value.toFixed(decimals).replace(/\.?0+$/, '')
: String(Math.round(value));
}
function decimalPlaces(value: string): number {
const match = value.match(/\.(\d+)/);
return match?.[1]?.length ?? 0;
}
function sideToProp(base: 'padding' | 'margin', side: 't' | 'r' | 'b' | 'l'): keyof ManualEditStyles {
return `${base}${sideUpper(side)}` as keyof ManualEditStyles;
}
@ -536,34 +619,8 @@ function normalizeColorForPicker(value: string): string {
return '#000000';
}
function EditTabButton({ tab, label, active, onClick }: {
tab: ManualEditTab; label: string; active: boolean; onClick: (tab: ManualEditTab) => void;
}) {
return (
<button type="button" className={active ? 'active' : ''} role="tab" aria-selected={active} onClick={() => onClick(tab)}>
{label}
</button>
);
}
function contentPatch(target: ManualEditTarget, draft: ManualEditDraft): ManualEditPatch {
if (target.kind === 'link') return { id: target.id, kind: 'set-link', text: draft.text, href: draft.href };
if (target.kind === 'image') return { id: target.id, kind: 'set-image', src: draft.src, alt: draft.alt };
return { id: target.id, kind: 'set-text', value: draft.text };
}
export function manualEditPatchSummary(patch: ManualEditPatch): string {
if (patch.kind === 'set-full-source') return JSON.stringify({ kind: patch.kind, bytes: patch.source.length });
if (patch.kind === 'set-outer-html') return JSON.stringify({ id: patch.id, kind: patch.kind, bytes: patch.html.length });
return JSON.stringify(patch);
}
function parseAttributesPatch(id: string, attributesText: string): ManualEditPatch {
const parsed = JSON.parse(attributesText) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Attributes must be a JSON object.');
}
return { id, kind: 'set-attributes',
attributes: Object.fromEntries(Object.entries(parsed).map(([key, value]) => [key, String(value)])),
};
}

View file

@ -106,7 +106,7 @@ export function buildManualEditBridge(enabled: boolean): string {
var attrs = {};
for (var i = 0; i < el.attributes.length; i++) {
var attr = el.attributes[i];
if (!attr || attr.name.indexOf('data-od-runtime') === 0) continue;
if (!attr || attr.name.indexOf('data-od-runtime') === 0 || attr.name === 'data-od-edit-selected') continue;
attrs[attr.name] = attr.value;
}
return attrs;
@ -117,6 +117,10 @@ export function buildManualEditBridge(enabled: boolean): string {
styleProps.forEach(function(prop){ styles[prop] = el.style[prop] || computed[prop] || ''; });
return styles;
}
function isLayoutContainer(el){
var display = window.getComputedStyle(el).display || '';
return display.indexOf('flex') >= 0 || display.indexOf('grid') >= 0;
}
function targetFrom(el, includeOuterHtml){
var rect = el.getBoundingClientRect();
var kind = inferKind(el);
@ -142,7 +146,8 @@ export function buildManualEditBridge(enabled: boolean): string {
fields: fields,
attributes: attrsFor(el),
styles: stylesFor(el),
outerHtml: includeOuterHtml ? (el.outerHTML || '').replace(/\\sdata-od-runtime-id="[^"]*"/g, '').replace(/\\sdata-od-source-path="[^"]*"/g, '') : ''
isLayoutContainer: isLayoutContainer(el),
outerHtml: includeOuterHtml ? (el.outerHTML || '').replace(/\\sdata-od-runtime-id="[^"]*"/g, '').replace(/\\sdata-od-source-path="[^"]*"/g, '').replace(/\\sdata-od-edit-selected="[^"]*"/g, '') : ''
};
}
function allTargets(){
@ -160,6 +165,16 @@ export function buildManualEditBridge(enabled: boolean): string {
if (!enabled) return;
window.parent.postMessage({ type: 'od-edit-targets', targets: allTargets() }, '*');
}
function clearSelectedTarget(){
var selected = document.querySelectorAll('[data-od-edit-selected]');
for (var i = 0; i < selected.length; i++) selected[i].removeAttribute('data-od-edit-selected');
}
function setSelectedTarget(id){
clearSelectedTarget();
if (!id) return;
var el = findById(id);
if (el) el.setAttribute('data-od-edit-selected', 'true');
}
function closestTarget(event){
var el = event.target;
var fallback = null;
@ -195,18 +210,24 @@ export function buildManualEditBridge(enabled: boolean): string {
}
return null;
}
function applyPreviewStyles(id, styles){
function applyPreviewStyles(id, styles, version){
var el = findById(id);
if (!el) return;
if (!el) {
window.parent.postMessage({ type: 'od-edit-preview-style-applied', id: id || '', version: Number(version) || 0, ok: false, error: 'Target not found' }, '*');
return;
}
var keys = Object.keys(styles || {});
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var value = styles[key];
var cssName = camelToKebab(key);
try {
try {
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var value = styles[key];
var cssName = camelToKebab(key);
if (typeof value !== 'string' || value.trim() === '') el.style.removeProperty(cssName);
else el.style.setProperty(cssName, value.trim());
} catch (_e) {}
}
window.parent.postMessage({ type: 'od-edit-preview-style-applied', id: id, version: Number(version) || 0, ok: true }, '*');
} catch (e) {
window.parent.postMessage({ type: 'od-edit-preview-style-applied', id: id, version: Number(version) || 0, ok: false, error: e && e.message ? String(e.message) : 'Could not apply preview styles' }, '*');
}
}
window.addEventListener('message', function(ev){
@ -214,11 +235,16 @@ export function buildManualEditBridge(enabled: boolean): string {
if (ev.data.type === 'od-edit-mode') {
enabled = !!ev.data.enabled;
document.documentElement.toggleAttribute('data-od-edit-mode', enabled);
if (!enabled) clearSelectedTarget();
if (enabled) setTimeout(postTargets, 0);
return;
}
if (ev.data.type === 'od-edit-selected-target') {
setSelectedTarget(ev.data.id || null);
return;
}
if (ev.data.type === 'od-edit-preview-style') {
applyPreviewStyles(ev.data.id, ev.data.styles || {});
applyPreviewStyles(ev.data.id, ev.data.styles || {}, ev.data.version);
return;
}
});
@ -244,5 +270,10 @@ html[data-od-edit-mode] [data-od-id],
html[data-od-edit-mode] [data-od-runtime-id] { outline: 1px dashed rgba(37, 99, 235, 0.35); outline-offset: 3px; }
html[data-od-edit-mode] [data-od-id]:hover,
html[data-od-edit-mode] [data-od-runtime-id]:hover { outline: 2px solid #2563eb; }
html[data-od-edit-mode] [data-od-edit-selected] {
outline: 2px solid #2563eb !important;
outline-offset: 4px;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.16);
}
</style>`;
}

View file

@ -113,11 +113,11 @@ function parseSource(source: string): Document | null {
}
function serializeSource(doc: Document, originalSource: string): string {
if (!isFullHtmlDocument(originalSource)) return doc.body.innerHTML;
if (!isManualEditFullHtmlDocument(originalSource)) return doc.body.innerHTML;
return `<!doctype html>\n${doc.documentElement.outerHTML}`;
}
function isFullHtmlDocument(source: string): boolean {
export function isManualEditFullHtmlDocument(source: string): boolean {
const normalized = firstSourceToken(source).slice(0, 32).toLowerCase();
return normalized.startsWith('<!doctype') || normalized.startsWith('<html');
}

View file

@ -62,6 +62,7 @@ export interface ManualEditTarget {
fields: ManualEditFields;
attributes: Record<string, string>;
styles: ManualEditStyles;
isLayoutContainer: boolean;
outerHtml: string;
}
@ -94,7 +95,18 @@ export interface ManualEditSelectMessage {
target: ManualEditTarget;
}
export type ManualEditBridgeMessage = ManualEditTargetMessage | ManualEditSelectMessage;
export interface ManualEditPreviewAppliedMessage {
type: 'od-edit-preview-style-applied';
id: string;
version: number;
ok: boolean;
error?: string;
}
export type ManualEditBridgeMessage =
| ManualEditTargetMessage
| ManualEditSelectMessage
| ManualEditPreviewAppliedMessage;
export const MANUAL_EDIT_STYLE_PROPS: readonly (keyof ManualEditStyles)[] = [
'fontFamily', 'fontSize', 'fontWeight', 'color', 'textAlign', 'lineHeight', 'letterSpacing',

View file

@ -1,108 +1,4 @@
@layer theme, base, utilities;
@import url('https://fonts.googleapis.com/css2?family=Cairo:wght@400;500;600;700&display=swap');
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
/* Open Design Tailwind token vocabulary.
Colors intentionally clear Tailwind's default palette and expose only
project-approved tokens backed by the runtime CSS variables below. Use
native Tailwind utilities for spacing and standard type sizes; use the
`text-ui-*` aliases only when matching existing compact UI text exactly.
Examples:
- `bg-panel text-text border border-border rounded-card shadow-token-sm`
- `bg-accent text-accent-foreground hover:bg-accent-hover`
- `bg-selection-overlay border-selection-outline ring-selection-outline`
The local base-layer border reset below keeps examples like
`border border-border` rendering solid borders while Preflight is omitted. */
@theme {
--color-*: initial;
/* Surfaces */
--color-bg: var(--bg);
--color-app: var(--bg-app);
--color-panel: var(--bg-panel);
--color-subtle: var(--bg-subtle);
--color-muted-surface: var(--bg-muted);
--color-elevated: var(--bg-elevated);
/* Borders */
--color-border: var(--border);
--color-border-strong: var(--border-strong);
--color-border-soft: var(--border-soft);
/* Text */
--color-text: var(--text);
--color-strong: var(--text-strong);
--color-muted: var(--text-muted);
--color-soft: var(--text-soft);
--color-faint: var(--text-faint);
/* Accent */
--color-accent: var(--accent);
--color-accent-strong: var(--accent-strong);
--color-accent-soft: var(--accent-soft);
--color-accent-tint: var(--accent-tint);
--color-accent-hover: var(--accent-hover);
--color-accent-wash: var(--accent-wash);
--color-accent-foreground: var(--accent-foreground);
/* Semantic status */
--color-success: var(--green);
--color-success-surface: var(--green-bg);
--color-success-border: var(--green-border);
--color-info: var(--blue);
--color-info-surface: var(--blue-bg);
--color-info-border: var(--blue-border);
--color-discovery: var(--purple);
--color-discovery-surface: var(--purple-bg);
--color-discovery-border: var(--purple-border);
--color-danger: var(--red);
--color-danger-surface: var(--red-bg);
--color-danger-border: var(--red-border);
--color-danger-foreground: var(--bg-panel);
--color-warning: var(--amber);
--color-warning-surface: var(--amber-bg);
--color-warning-border: var(--warning-border);
/* Interaction and overlays */
--color-focus: var(--accent);
--color-focus-ring: var(--accent-soft);
--color-overlay: var(--overlay);
--color-selection-overlay: var(--selection-overlay);
--color-selection-outline: var(--selection-outline);
--color-inspect-overlay: var(--inspect-overlay);
--color-control-hover: var(--bg-subtle);
--color-control-active: var(--bg-muted);
/* Radius */
--radius-control: var(--radius-sm);
--radius-card: var(--radius);
--radius-panel: var(--radius-lg);
--radius-token-pill: var(--radius-pill);
/* Shadows */
--shadow-token-xs: var(--shadow-xs);
--shadow-token-sm: var(--shadow-sm);
--shadow-token-md: var(--shadow-md);
--shadow-token-lg: var(--shadow-lg);
/* Fonts */
--font-sans: var(--sans);
--font-serif: var(--serif);
--font-mono: var(--mono);
/* Exact existing UI type sizes */
--text-ui-9: 9px;
--text-ui-10: 10px;
--text-ui-10_5: 10.5px;
--text-ui-11: 11px;
--text-ui-11_5: 11.5px;
--text-ui-12_5: 12.5px;
--text-ui-13: 13px;
--text-ui-13_5: 13.5px;
}
/* ============================================================
Open Design neutral product workspace
@ -133,8 +29,6 @@
--accent-soft: #f5d8cb;
--accent-tint: #fbeee5;
--accent-hover: #b45a3b;
--accent-wash: color-mix(in srgb, var(--accent) 12%, transparent);
--accent-foreground: #fff;
/* Semantic accent tints used by tool / status pills. */
--green: #1f7a3a;
@ -151,12 +45,6 @@
--red-border: #f5c6c2;
--amber: #b26200;
--amber-bg: #fff3e0;
--warning-border: color-mix(in srgb, var(--amber) 35%, transparent);
--overlay: rgba(28, 27, 26, 0.42);
--selection-overlay: rgba(22, 119, 255, 0.18);
--selection-outline: rgba(22, 119, 255, 0.55);
--inspect-overlay: rgba(37, 99, 235, 0.12);
--shadow-xs: 0 1px 0 rgba(28, 27, 26, 0.04);
--shadow-sm: 0 1px 2px rgba(28, 27, 26, 0.05), 0 1px 3px rgba(28, 27, 26, 0.04);
@ -277,20 +165,6 @@
}
}
/* Tailwind foundation policy: Preflight stays out of this slice. Retained
element/reset rules that may conflict with migrated utilities must live in
@layer base, be constrained to non-migrated scopes, or be removed before the
affected elements migrate. */
@layer base {
*,
::before,
::after,
::backdrop,
::file-selector-button {
border: 0 solid;
}
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
@ -1372,21 +1246,6 @@ code {
font-size: 11.5px;
box-shadow: var(--shadow-xs);
}
.staged-preview-trigger {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: zoom-in;
}
.staged-preview-trigger:hover .staged-name {
color: var(--accent);
text-decoration: underline;
}
.staged-chip img {
width: 28px;
height: 28px;
@ -1396,46 +1255,47 @@ code {
.staged-preview-modal {
position: fixed;
inset: 0;
z-index: 1000;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
background: rgba(0, 0, 0, 0.56);
padding: 24px;
background: rgba(17, 24, 39, 0.44);
}
.staged-preview-card {
max-width: min(920px, 92vw);
max-height: min(760px, 88vh);
display: flex;
flex-direction: column;
overflow: hidden;
gap: 10px;
width: min(720px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
padding: 12px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-panel);
box-shadow: var(--shadow-lg);
}
.staged-preview-head {
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
color: var(--text);
font-size: 12px;
font-weight: 600;
gap: 12px;
min-width: 0;
}
.staged-preview-head span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
font-size: 13px;
font-weight: 600;
}
.staged-preview-card > img {
display: block;
max-width: 100%;
max-height: calc(88vh - 48px);
max-height: min(70vh, 640px);
object-fit: contain;
background: var(--bg-subtle);
border-radius: var(--radius);
background: var(--bg);
}
.staged-comment {
border-radius: var(--radius-pill);
@ -11446,10 +11306,8 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
z-index: 1001;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 12px;
line-height: 1;
background: rgba(255, 255, 255, 0.92);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
@ -12823,7 +12681,7 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
/* ---------------------------------------------------------------------
* Critique Theater (Phase 8). Role-keyed tinting via `data-role` so the
* components hold no hex literals the lane just declares which role
* components hold no hex literals - the lane just declares which role
* it represents and the cascade picks the right hue from
* existing semantic tokens. Phase 9 will add the settings toggle that
* gates the surrounding mount points.
@ -12941,10 +12799,10 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
background: color-mix(in srgb, var(--accent) 6%, var(--bg-panel));
}
.theater-lane[data-role="designer"] { border-left-color: color-mix(in srgb, var(--accent) 60%, var(--border)); }
.theater-lane[data-role="critic"] { border-left-color: color-mix(in srgb, var(--amber) 60%, var(--border)); }
.theater-lane[data-role="brand"] { border-left-color: color-mix(in srgb, var(--purple, var(--accent)) 60%, var(--border)); }
.theater-lane[data-role="a11y"] { border-left-color: color-mix(in srgb, var(--green, var(--accent)) 60%, var(--border)); }
.theater-lane[data-role="copy"] { border-left-color: color-mix(in srgb, var(--blue, var(--accent)) 60%, var(--border)); }
.theater-lane[data-role="critic"] { border-left-color: color-mix(in srgb, var(--amber) 60%, var(--border)); }
.theater-lane[data-role="brand"] { border-left-color: color-mix(in srgb, var(--purple, var(--accent)) 60%, var(--border)); }
.theater-lane[data-role="a11y"] { border-left-color: color-mix(in srgb, var(--green, var(--accent)) 60%, var(--border)); }
.theater-lane[data-role="copy"] { border-left-color: color-mix(in srgb, var(--blue, var(--accent)) 60%, var(--border)); }
.theater-lane-head {
display: flex;
align-items: baseline;
@ -13219,6 +13077,7 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
transform: rotate(var(--burst-angle, 0deg)) translate(18px, 0) scale(0.9);
}
}
.assistant-feedback-reasons {
width: min(340px, 100%);
padding: 8px;
@ -16470,17 +16329,50 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
background: var(--bg);
}
.manual-edit-workspace > .manual-edit-canvas { grid-column: 1; grid-row: 1; }
.manual-edit-workspace > .manual-edit-right { grid-column: 2; grid-row: 1; min-height: 0; }
.manual-edit-workspace > .manual-edit-right {
grid-column: 2;
grid-row: 1;
height: 100%;
min-height: 0;
overflow: hidden;
}
/* Claude Code-style edit inspector panel. */
.cc-panel { display: flex; flex-direction: column; gap: 8px; padding: 8px 0; overflow: auto; background: var(--bg-panel); }
.cc-inspector { display: flex; flex-direction: column; gap: 12px; padding: 6px 12px; }
.cc-inspector-nav {
display: flex;
justify-content: flex-start;
margin-bottom: -4px;
}
.cc-inspector-page {
height: 24px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-panel);
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
}
.cc-inspector-page:hover:not(:disabled) {
border-color: var(--border-strong);
color: var(--text);
background: var(--bg-subtle);
}
.cc-section { display: flex; flex-direction: column; gap: 6px; }
.cc-section-inactive { opacity: 0.58; }
.cc-section-head {
font-size: 10px; font-weight: 600; letter-spacing: 0.08em;
color: var(--text-muted); padding: 4px 0 2px;
}
.cc-section-body { display: flex; flex-direction: column; gap: 4px; }
.cc-section-hint {
margin: -1px 0 2px;
color: var(--text-muted);
font-size: 11px;
line-height: 1.3;
}
.cc-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
.cc-row {
display: flex; align-items: center;
@ -16500,6 +16392,28 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
flex: 1 1 auto; display: flex; align-items: center; justify-content: flex-end;
gap: 2px; min-width: 0;
}
.cc-step {
flex: 0 0 14px;
width: 14px;
height: 18px;
border: 0;
border-radius: 3px;
padding: 0;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 11px;
line-height: 1;
cursor: pointer;
}
.cc-step:hover:not(:disabled) {
background: var(--surface-hover, rgba(0,0,0,0.06));
color: var(--text);
}
.cc-step:disabled {
opacity: 0.28;
cursor: default;
}
.cc-value > input,
.cc-value > select {
flex: 1 1 auto; min-width: 0; border: none; outline: none;
@ -16523,14 +16437,20 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
}
.cc-color > input { text-align: right; }
.cc-color-popover {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 100;
position: absolute; top: calc(100% + 6px); left: 0; z-index: 100;
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: 6px; padding: 8px; box-shadow: 0 6px 18px rgba(0,0,0,0.12);
display: flex; flex-direction: column; gap: 8px; min-width: 168px;
display: flex; flex-direction: column; gap: 8px;
width: min(168px, calc(100vw - 32px));
max-width: calc(100vw - 32px);
}
.cc-color-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 4px; }
.cc-color-compact .cc-color-popover {
left: auto;
right: 0;
}
.cc-color-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 4px; }
.cc-color-tile {
width: 22px; height: 22px; border-radius: 4px; padding: 0;
width: 100%; aspect-ratio: 1; border-radius: 4px; padding: 0;
border: 1px solid rgba(0,0,0,0.08); cursor: pointer;
}
.cc-color-native {
@ -16554,6 +16474,10 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
padding: 0 6px; height: 26px; font-size: 12px; gap: 2px;
}
.cc-quad-axis { color: var(--text-muted); font-style: normal; font-size: 10px; flex: 0 0 auto; }
.cc-step-quad {
flex-basis: 12px;
width: 12px;
}
.cc-quad-cell input {
flex: 1 1 auto; min-width: 0; border: none; outline: none;
background: transparent; font: inherit; padding: 0;
@ -16561,14 +16485,6 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
}
.cc-quad-unit { color: var(--text-muted); font-style: normal; font-size: 10px; }
.cc-advanced {
border-top: 1px solid var(--border); margin-top: 6px;
padding: 6px 12px; display: flex; flex-direction: column; gap: 6px;
}
.cc-advanced-toggle {
align-self: flex-start; background: transparent; border: none;
padding: 2px 0; font-size: 11px; color: var(--text-muted); cursor: pointer;
}
.cc-disclosure { font-size: 11px; color: var(--text-muted); }
.cc-disclosure > summary { cursor: pointer; padding: 4px 0; }
@ -16581,7 +16497,7 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
overflow: auto;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
@ -16682,6 +16598,16 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
}
.manual-edit-modal.cc-panel {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
padding-bottom: 72px;
scrollbar-gutter: stable;
}
.manual-edit-modal-head {
display: flex;
align-items: flex-start;
@ -16758,26 +16684,6 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
white-space: nowrap;
}
.manual-edit-tabs {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 5px;
padding: 0 12px 12px;
}
.manual-edit-tabs button {
min-height: 30px;
padding: 0 6px;
font-size: 11px;
text-transform: capitalize;
}
.manual-edit-tabs button.active {
color: var(--bg-panel);
border-color: var(--accent);
background: var(--accent);
}
.manual-edit-tab-body {
display: grid;
gap: 10px;
@ -16856,22 +16762,6 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
font-size: 12px;
}
.manual-edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: auto;
padding: 12px;
border-top: 1px solid var(--border);
background: var(--bg-subtle);
}
.manual-edit-actions .primary {
color: var(--bg-panel);
border-color: var(--accent);
background: var(--accent);
}
.manual-edit-changes {
flex: 1;
min-height: 0;
@ -18658,9 +18548,7 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
top: 0;
background: var(--bg-panel);
padding: 8px 10px 4px;
min-height: 32px;
z-index: 1;
border-bottom: 1px solid var(--border);
}
.palette-tweaks-anchor { position: relative; display: inline-flex; }

View file

@ -1,6 +1,8 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChatComposer } from '../../src/components/ChatComposer';
@ -92,13 +94,29 @@ describe('ChatComposer /search command', () => {
const dialog = screen.getByRole('dialog', { name: 'drawing.png' });
expect(dialog).toBeTruthy();
expect(dialog.classList.contains('staged-preview-modal')).toBe(true);
expect(dialog.querySelector('.staged-preview-card')).toBeTruthy();
expect(dialog.querySelector('.staged-preview-head')).toBeTruthy();
const previewImage = screen.getByRole('img', { name: 'drawing.png' }) as HTMLImageElement;
expect(previewImage.src).toContain('/api/projects/project-1/raw/uploads/drawing.png');
expect(dialog.querySelector('.staged-preview-card > img')).toBe(previewImage);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.queryByRole('dialog', { name: 'drawing.png' })).toBeNull();
});
it('keeps staged image preview modal styling available', () => {
const css = readFileSync(join(process.cwd(), 'src/index.css'), 'utf8');
expect(css).toContain('.staged-preview-modal');
expect(css).toContain('position: fixed;');
expect(css).toContain('.staged-preview-card');
expect(css).toContain('max-height: calc(100vh - 48px);');
expect(css).toContain('.staged-preview-head');
expect(css).toContain('.staged-preview-card > img');
expect(css).toContain('object-fit: contain;');
});
it('expands /search into a first-action research command prompt', () => {
const onSend = vi.fn();

View file

@ -0,0 +1,178 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ComponentProps } from 'react';
import type { ProjectFile } from '../../src/types';
const panelState = vi.hoisted(() => ({
props: null as ComponentProps<typeof import('../../src/components/ManualEditPanel').ManualEditPanel> | null,
}));
vi.mock('../../src/components/ManualEditPanel', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/components/ManualEditPanel')>();
return {
...actual,
ManualEditPanel: (props: ComponentProps<typeof actual.ManualEditPanel>) => {
panelState.props = props;
return <div data-testid="mock-manual-edit-panel" />;
},
};
});
import { FileViewer } from '../../src/components/FileViewer';
afterEach(() => {
cleanup();
panelState.props = null;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('FileViewer manual edit history regressions', () => {
it('flushes pending style edits before activating draw mode from manual edit', async () => {
const initialSource = '<!doctype html><html><body><h1 data-od-id="hero" style="color: #111111">Hero</h1></body></html>';
let saveResolve!: (value: Response) => void;
const saveResponse = new Promise<Response>((resolve) => {
saveResolve = resolve;
});
const savedSources: string[] = [];
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.includes('/api/projects/project-1/deployments')) {
return new Response(JSON.stringify({ deployments: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
const payload = JSON.parse(String(init.body)) as { content: string };
savedSources.push(payload.content);
return saveResponse;
}
if (url.includes('/api/projects/project-1/raw/preview.html')) {
return new Response(initialSource, { status: 200 });
}
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
vi.stubGlobal('fetch', fetchMock);
render(
<FileViewer
projectId="project-1"
file={htmlPreviewFile()}
liveHtml={initialSource}
/>,
);
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
await waitFor(() => expect(panelState.props).not.toBeNull());
act(() => {
panelState.props?.onStyleChange?.('hero', { color: '#ef4444' }, 'Style: Hero');
});
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
await waitFor(() => expect(savedSources).toHaveLength(1));
expect(savedSources[0]).toContain('rgb(239, 68, 68)');
expect(screen.getByTestId('manual-edit-mode-toggle').getAttribute('aria-pressed')).toBe('true');
expect(screen.getByTestId('draw-overlay-toggle').getAttribute('aria-pressed')).toBe('false');
await act(async () => {
saveResolve(new Response(JSON.stringify({ file: htmlPreviewFile() }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
await saveResponse;
});
await waitFor(() => expect(screen.getByTestId('manual-edit-mode-toggle').getAttribute('aria-pressed')).toBe('false'));
expect(screen.getByTestId('draw-overlay-toggle').getAttribute('aria-pressed')).toBe('true');
});
it('uses the undone source snapshot for a follow-up edit after undo', async () => {
const initialSource = '<!doctype html><html><body><h1 data-od-id="hero" style="color: #111111">Hero</h1></body></html>';
let persistedSource = initialSource;
const savedSources: string[] = [];
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.includes('/api/projects/project-1/deployments')) {
return new Response(JSON.stringify({ deployments: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
const payload = JSON.parse(String(init.body)) as { content: string };
persistedSource = payload.content;
savedSources.push(payload.content);
return new Response(JSON.stringify({ file: htmlPreviewFile() }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/projects/project-1/raw/preview.html')) {
return new Response(persistedSource, { status: 200 });
}
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
vi.stubGlobal('fetch', fetchMock);
render(
<FileViewer
projectId="project-1"
file={htmlPreviewFile()}
liveHtml={initialSource}
/>,
);
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
await waitFor(() => expect(panelState.props).not.toBeNull());
act(() => {
panelState.props?.onApplyPatch(
{ kind: 'set-style', id: 'hero', styles: { color: '#ef4444' } },
'Style: Hero',
);
});
await waitFor(() => expect(savedSources).toHaveLength(1));
expect(savedSources[0]).toContain('rgb(239, 68, 68)');
act(() => {
panelState.props?.onUndo();
});
await waitFor(() => expect(savedSources).toHaveLength(2));
expect(savedSources[1]).toBe(initialSource);
act(() => {
panelState.props?.onApplyPatch(
{ kind: 'set-style', id: 'hero', styles: { backgroundColor: '#f97316' } },
'Style: Hero',
);
});
await waitFor(() => expect(savedSources).toHaveLength(3));
expect(savedSources[2]).toContain('background-color: rgb(249, 115, 22)');
expect(savedSources[2]).not.toContain('rgb(239, 68, 68)');
});
});
function htmlPreviewFile(): ProjectFile {
return {
name: 'preview.html',
path: 'preview.html',
type: 'file',
size: 1024,
mtime: 1710000000,
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Preview',
entry: 'preview.html',
renderer: 'html',
exports: ['html'],
},
};
}

View file

@ -0,0 +1,167 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
FileViewer,
cancelManualEditPendingStyleSnapshot,
} from '../../src/components/FileViewer';
import type { ProjectFile } from '../../src/types';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('FileViewer manual edit regressions', () => {
it('removes invalid fields from pending manual edit style saves without dropping unrelated fields', () => {
expect(cancelManualEditPendingStyleSnapshot({
id: 'hero',
label: 'Style: Hero',
version: 1,
styles: { fontSize: '4px', color: '#111111' },
}, 'hero', ['fontSize'])).toEqual({
id: 'hero',
label: 'Style: Hero',
version: 1,
styles: { color: '#111111' },
});
expect(cancelManualEditPendingStyleSnapshot({
id: 'hero',
label: 'Style: Hero',
version: 1,
styles: { fontSize: '4px' },
}, 'hero', ['fontSize'])).toBeNull();
const otherTargetPending = {
id: 'hero',
label: 'Style: Hero',
version: 1,
styles: { fontSize: '4px' },
};
expect(cancelManualEditPendingStyleSnapshot(otherTargetPending, 'cta', ['fontSize'])).toBe(otherTargetPending);
});
it('does not let a pending manual edit style save survive a file switch', () => {
vi.useFakeTimers();
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
return new Response(JSON.stringify({ file: htmlPreviewFile() }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response('<!doctype html><html><body></body></html>', { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
try {
const first = htmlPreviewFile();
const second = { ...htmlPreviewFile(), name: 'second.html', path: 'second.html' };
const { rerender } = render(
<FileViewer
projectId="project-1"
file={first}
liveHtml='<!doctype html><html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
const baseSizeInput = Array.from(document.querySelectorAll('.cc-row'))
.find((row) => row.textContent?.includes('Base size'))
?.querySelector('input') as HTMLInputElement | null;
if (!baseSizeInput) throw new Error('Base size input not found');
fireEvent.change(baseSizeInput, { target: { value: '18' } });
rerender(
<FileViewer
projectId="project-1"
file={second}
liveHtml='<!doctype html><html><body><main data-od-id="second">Second</main></body></html>'
/>,
);
act(() => {
vi.advanceTimersByTime(1100);
});
expect(fetchMock).not.toHaveBeenCalledWith(
'/api/projects/project-1/files',
expect.objectContaining({ method: 'POST' }),
);
} finally {
vi.useRealTimers();
}
});
it('clears loaded source immediately on file switch without liveHtml before manual edit can save', async () => {
let secondResolve!: (value: Response) => void;
const secondFetch = new Promise<Response>((resolve) => {
secondResolve = resolve;
});
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
return new Response(JSON.stringify({ file: htmlPreviewFile() }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/projects/project-1/raw/second.html')) return secondFetch;
return new Response('<!doctype html><html><body><main data-od-id="hero">First</main></body></html>', { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
try {
const first = htmlPreviewFile();
const second = { ...htmlPreviewFile(), name: 'second.html', path: 'second.html' };
const { rerender } = render(<FileViewer projectId="project-1" file={first} />);
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/projects/project-1/raw/preview.html', {}));
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
const baseSizeInput = await waitFor(() => {
const input = Array.from(document.querySelectorAll('.cc-row'))
.find((row) => row.textContent?.includes('Base size'))
?.querySelector('input') as HTMLInputElement | null;
if (!input) throw new Error('Base size input not found');
return input;
});
fireEvent.change(baseSizeInput, { target: { value: '18' } });
rerender(<FileViewer projectId="project-1" file={second} />);
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1100));
});
expect(fetchMock).not.toHaveBeenCalledWith(
'/api/projects/project-1/files',
expect.objectContaining({ method: 'POST' }),
);
secondResolve(new Response('<!doctype html><html><body><main data-od-id="second">Second</main></body></html>', { status: 200 }));
} finally {
vi.useRealTimers();
}
});
});
function htmlPreviewFile(): ProjectFile {
return {
name: 'preview.html',
path: 'preview.html',
type: 'file',
size: 1024,
mtime: 1710000000,
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Preview',
entry: 'preview.html',
renderer: 'html',
exports: ['html'],
},
};
}

View file

@ -1,8 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Simulate } from 'react-dom/test-utils';
import { JSDOM } from 'jsdom';
import { ManualEditPanel, emptyManualEditDraft, manualEditPatchSummary } from '../../src/components/ManualEditPanel';
import { ManualEditPanel, emptyManualEditDraft, manualEditPatchSummary, normalizeManualEditStyles } from '../../src/components/ManualEditPanel';
import { emptyManualEditStyles, type ManualEditTarget } from '../../src/edit-mode/types';
const target: ManualEditTarget = {
@ -16,6 +17,7 @@ const target: ManualEditTarget = {
fields: { text: 'Original' },
attributes: { 'data-od-id': 'hero-title' },
styles: emptyManualEditStyles(),
isLayoutContainer: false,
outerHtml: '<h1 data-od-id="hero-title">Original</h1>',
};
@ -43,47 +45,371 @@ describe('ManualEditPanel', () => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
it('opens with target metadata and calls selection from the layers list', () => {
const onSelectTarget = vi.fn();
renderPanel({ onSelectTarget });
it('renders the style inspector without the advanced editor entry', () => {
renderPanel();
click(buttonByText('▸ Advanced'));
const summaries = Array.from(host.querySelectorAll('details > summary'));
const layersSummary = summaries.find((s) => s.textContent?.includes('Layers')) as HTMLElement | undefined;
if (layersSummary) {
const details = layersSummary.parentElement as HTMLDetailsElement;
act(() => { details.open = true; });
}
click(buttonByText('Hero Title'));
expect(onSelectTarget).toHaveBeenCalledWith(target);
expect(host.textContent).toContain('TYPOGRAPHY');
expect(host.textContent).not.toContain('Advanced');
expect(host.textContent).not.toContain('Content');
});
it('builds content patches from the active target', () => {
const onApplyPatch = vi.fn();
renderPanel({ onApplyPatch });
it('allows returning from an element inspector to the page inspector', () => {
const onClearSelection = vi.fn();
renderPanel({ onClearSelection });
click(buttonByText('▸ Advanced'));
click(buttonByText('Content'));
click(buttonByText('Apply Content'));
const pageButton = host.querySelector('button[aria-label="Show page inspector"]') as HTMLButtonElement | null;
if (!pageButton) throw new Error('Page inspector button not found');
expect(onApplyPatch).toHaveBeenCalledWith(
{ id: 'hero-title', kind: 'set-text', value: 'Updated copy' },
'Content: Hero Title',
act(() => {
pageButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
expect(onClearSelection).toHaveBeenCalledTimes(1);
});
it('normalizes font stacks and writes a usable font-family value', () => {
const onDraftChange = vi.fn();
const onStyleChange = vi.fn();
renderPanel({
onDraftChange,
onStyleChange,
styles: {
...emptyManualEditStyles(),
fontFamily: '"Roboto", sans-serif',
fontSize: '32px',
color: '#111111',
paddingTop: '8px',
},
});
const fontSelect = host.querySelector('select') as HTMLSelectElement | null;
if (!fontSelect) throw new Error('Font select not found');
expect(fontSelect.value).toBe('Roboto, Arial, sans-serif');
act(() => {
fontSelect.value = 'Georgia, serif';
fontSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
});
expect(onDraftChange).toHaveBeenCalledWith(expect.objectContaining({
styles: expect.objectContaining({ fontFamily: 'Georgia, serif' }),
}));
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { fontFamily: 'Georgia, serif' }, 'Style: Hero Title');
expect(onStyleChange).not.toHaveBeenCalledWith(
'hero-title',
expect.objectContaining({ fontSize: '32px', color: '#111111', paddingTop: '8px' }),
'Style: Hero Title',
);
});
it('shows invalid attribute JSON without applying a write patch', () => {
const onApplyPatch = vi.fn();
it('shows px-backed values without px in numeric inputs', () => {
renderPanel({
styles: {
...emptyManualEditStyles(),
fontSize: '32px',
},
});
const sizeRow = Array.from(host.querySelectorAll('.cc-row'))
.find((row) => row.textContent?.includes('Size'));
const sizeInput = sizeRow?.querySelector('input') as HTMLInputElement | null;
if (!sizeInput) throw new Error('Size input not found');
expect(sizeInput.value).toBe('32');
});
it('increments normal rows and quad cells with normalized values', () => {
const onStyleChange = vi.fn();
renderPanel({
onStyleChange,
styles: {
...emptyManualEditStyles(),
fontSize: '32px',
opacity: '0.5',
paddingTop: '8px',
},
});
const sizeIncrease = host.querySelector('button[aria-label="Size increase"]') as HTMLButtonElement | null;
const opacityIncrease = host.querySelector('button[aria-label="Opacity increase"]') as HTMLButtonElement | null;
const paddingTopDecrease = host.querySelector('.cc-quad button[aria-label="T decrease"]') as HTMLButtonElement | null;
if (!sizeIncrease || !opacityIncrease || !paddingTopDecrease) throw new Error('Stepper button not found');
act(() => {
sizeIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
opacityIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
paddingTopDecrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { fontSize: '33px' }, 'Style: Hero Title');
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { opacity: '0.6' }, 'Style: Hero Title');
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { paddingTop: '7px' }, 'Style: Hero Title');
});
it('does not persist an unchanged target style when the inspector opens', () => {
vi.useFakeTimers();
try {
const onApplyPatch = vi.fn();
renderPanel({ onApplyPatch });
act(() => {
vi.advanceTimersByTime(1600);
});
expect(onApplyPatch).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('normalizes valid style values before host preview/persistence', () => {
expect(normalizeManualEditStyles({
fontSize: '48',
color: '#f00',
opacity: '2',
lineHeight: '1.4',
}, { layoutEnabled: true })).toEqual({
ok: true,
styles: {
fontSize: '48px',
color: '#ff0000',
opacity: '1',
lineHeight: '1.4',
},
});
expect(normalizeManualEditStyles({ lineHeight: '49px' }, { layoutEnabled: true })).toEqual({
ok: true,
styles: { lineHeight: '49px' },
});
});
it('rejects invalid style values before host preview/persistence', () => {
expect(normalizeManualEditStyles({ color: 'tomato' }, { layoutEnabled: true })).toEqual({
ok: false,
error: 'color must be a hex color.',
});
expect(normalizeManualEditStyles({ lineHeight: '-1px' }, { layoutEnabled: true })).toEqual({
ok: false,
error: 'Line height must be a positive number or px value.',
});
});
it('treats empty values as inline style clears', () => {
expect(normalizeManualEditStyles({ fontSize: '', color: '' }, { layoutEnabled: true })).toEqual({
ok: true,
styles: { fontSize: '', color: '' },
});
});
it('does not validate unchanged computed line-height values on blur', () => {
const onError = vi.fn();
renderPanel({ onApplyPatch, onError, attributesText: '{bad' });
const onStyleChange = vi.fn();
renderPanel({
onError,
onStyleChange,
styles: {
...emptyManualEditStyles(),
lineHeight: '48.96px',
},
});
click(buttonByText('▸ Advanced'));
click(buttonByText('Attributes'));
click(buttonByText('Apply Attributes'));
const lineInput = Array.from(host.querySelectorAll('.cc-row'))
.find((row) => row.textContent?.includes('Line'))
?.querySelector('input') as HTMLInputElement | null;
if (!lineInput) throw new Error('Line input not found');
expect(onError).toHaveBeenCalled();
expect(onApplyPatch).not.toHaveBeenCalled();
act(() => {
lineInput.dispatchEvent(new dom.window.FocusEvent('blur', { bubbles: true }));
});
expect(onError).not.toHaveBeenCalled();
expect(onStyleChange).not.toHaveBeenCalled();
});
it('accepts edited computed pixel line-height values', () => {
const onError = vi.fn();
const onStyleChange = vi.fn();
renderPanel({
onError,
onStyleChange,
styles: {
...emptyManualEditStyles(),
lineHeight: '48.96px',
},
});
const lineInput = Array.from(host.querySelectorAll('.cc-row'))
.find((row) => row.textContent?.includes('Line'))
?.querySelector('input') as HTMLInputElement | null;
if (!lineInput) throw new Error('Line input not found');
act(() => {
lineInput.value = '49px';
Simulate.change(lineInput);
});
expect(onError).toHaveBeenCalledWith('');
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { lineHeight: '49px' }, 'Style: Hero Title');
});
it('does not persist unchanged page styles when no target is selected', () => {
vi.useFakeTimers();
try {
const onApplyPatch = vi.fn();
renderPanel({ onApplyPatch, selectedTarget: null });
act(() => {
vi.advanceTimersByTime(1600);
});
expect(onApplyPatch).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('emits only the changed page style field', () => {
const onStyleChange = vi.fn();
renderPanel({ onStyleChange, selectedTarget: null });
const bgSwatch = host.querySelector('button[aria-label="Pick Background"]') as HTMLButtonElement | null;
if (!bgSwatch) throw new Error('Background swatch not found');
act(() => {
bgSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
const colorTile = host.querySelector('button[aria-label="#3b82f6"]') as HTMLButtonElement | null;
if (!colorTile) throw new Error('Background color tile not found');
act(() => {
colorTile.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
expect(onStyleChange).toHaveBeenCalledWith('__body__', { backgroundColor: '#3b82f6' }, 'Page styles');
expect(onStyleChange).not.toHaveBeenCalledWith(
'__body__',
expect.objectContaining({ fontFamily: expect.any(String) }),
'Page styles',
);
expect(onStyleChange).not.toHaveBeenCalledWith(
'__body__',
expect.objectContaining({ fontSize: expect.any(String) }),
'Page styles',
);
});
it('does not emit untouched page fields when changing the page font', () => {
const onStyleChange = vi.fn();
renderPanel({ onStyleChange, selectedTarget: null });
const fontSelect = host.querySelector('.cc-row select') as HTMLSelectElement | null;
if (!fontSelect) throw new Error('Font select not found');
act(() => {
fontSelect.value = 'Georgia, serif';
fontSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
});
expect(onStyleChange).toHaveBeenCalledWith('__body__', { fontFamily: 'Georgia, serif' }, 'Page styles');
expect(onStyleChange).not.toHaveBeenCalledWith(
'__body__',
expect.objectContaining({ backgroundColor: expect.any(String) }),
'Page styles',
);
expect(onStyleChange).not.toHaveBeenCalledWith(
'__body__',
expect.objectContaining({ fontSize: expect.any(String) }),
'Page styles',
);
});
it('shows an inactive Page inspector for fragment HTML sources', () => {
const onStyleChange = vi.fn();
renderPanel({ onStyleChange, selectedTarget: null, pageStylesEnabled: false });
expect(host.textContent).toContain('Page styles are available only for full HTML documents.');
expect(host.textContent).not.toContain('Background');
expect(host.querySelector('input')).toBeNull();
expect(host.querySelector('select')).toBeNull();
expect(onStyleChange).not.toHaveBeenCalled();
});
it('keeps explicit empty page values as field-specific clears', () => {
const onStyleChange = vi.fn();
renderPanel({ onStyleChange, selectedTarget: null });
const fontSelect = host.querySelector('.cc-row select') as HTMLSelectElement | null;
if (!fontSelect) throw new Error('Font select not found');
act(() => {
fontSelect.value = '';
fontSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
});
expect(onStyleChange).toHaveBeenCalledWith('__body__', { fontFamily: '' }, 'Page styles');
expect(onStyleChange).not.toHaveBeenCalledWith(
'__body__',
expect.objectContaining({ backgroundColor: expect.any(String), fontFamily: expect.any(String) }),
'Page styles',
);
});
it('renders layout as inactive for non-layout single targets', () => {
const onStyleChange = vi.fn();
renderPanel({
onStyleChange,
styles: {
...emptyManualEditStyles(),
gap: 'normal',
flexDirection: 'row',
},
});
const layoutSection = sectionByTitle('LAYOUT');
expect(layoutSection.classList.contains('cc-section-inactive')).toBe(true);
expect(layoutSection.textContent).toContain('Select a container or group to edit layout.');
const gapInput = layoutSection.querySelector('input') as HTMLInputElement | null;
const directionSelect = layoutSection.querySelector('select') as HTMLSelectElement | null;
if (!gapInput || !directionSelect) throw new Error('Layout controls not found');
expect(gapInput.disabled).toBe(true);
expect(directionSelect.disabled).toBe(true);
expect(normalizeManualEditStyles({ gap: '12', flexDirection: 'column' }, { layoutEnabled: false })).toEqual({
ok: true,
styles: {},
});
});
it('enables layout controls for flex or grid containers', () => {
const onStyleChange = vi.fn();
renderPanel({
onStyleChange,
selectedTarget: { ...target, isLayoutContainer: true },
styles: {
...emptyManualEditStyles(),
gap: '8px',
flexDirection: 'row',
},
});
const layoutSection = sectionByTitle('LAYOUT');
expect(layoutSection.classList.contains('cc-section-inactive')).toBe(false);
expect(layoutSection.textContent).not.toContain('Select a container or group to edit layout.');
const gapInput = layoutSection.querySelector('input') as HTMLInputElement | null;
const directionSelect = layoutSection.querySelector('select') as HTMLSelectElement | null;
const gapIncrease = layoutSection.querySelector('button[aria-label="Gap increase"]') as HTMLButtonElement | null;
if (!gapInput || !directionSelect) throw new Error('Layout controls not found');
expect(gapInput.disabled).toBe(false);
expect(directionSelect.disabled).toBe(false);
if (!gapIncrease) throw new Error('Gap increase control not found');
act(() => {
gapIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
directionSelect.value = 'column';
directionSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
});
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { gap: '9px' }, 'Style: Hero Title');
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { flexDirection: 'column' }, 'Style: Hero Title');
});
it('summarizes full-source history entries without rendering the full file', () => {
@ -95,37 +421,61 @@ describe('ManualEditPanel', () => {
expect(manualEditPatchSummary({ kind: 'set-full-source', source })).not.toContain('x'.repeat(100));
});
function sectionByTitle(title: string): HTMLElement {
const section = Array.from(host.querySelectorAll('.cc-section'))
.find((candidate) => candidate.querySelector('.cc-section-head')?.textContent === title) as HTMLElement | undefined;
if (!section) throw new Error(`${title} section not found`);
return section;
}
function renderPanel({
onSelectTarget = vi.fn(),
onDraftChange = vi.fn(),
onApplyPatch = vi.fn(),
onError = vi.fn(),
onStyleChange = vi.fn(),
onInvalidStyle = vi.fn(),
onClearSelection = vi.fn(),
attributesText = '{}',
selectedTarget = target,
styles = emptyManualEditStyles(),
pageStylesEnabled = true,
}: {
onSelectTarget?: ReturnType<typeof vi.fn>;
onDraftChange?: ReturnType<typeof vi.fn>;
onApplyPatch?: ReturnType<typeof vi.fn>;
onError?: ReturnType<typeof vi.fn>;
onStyleChange?: ReturnType<typeof vi.fn>;
onInvalidStyle?: ReturnType<typeof vi.fn>;
onClearSelection?: ReturnType<typeof vi.fn>;
attributesText?: string;
}) {
selectedTarget?: ManualEditTarget | null;
styles?: ReturnType<typeof emptyManualEditStyles>;
pageStylesEnabled?: boolean;
} = {}) {
const draft = {
...emptyManualEditDraft('<html></html>'),
text: 'Updated copy',
attributesText,
styles,
outerHtml: target.outerHtml,
};
act(() => {
root.render(
<ManualEditPanel
targets={[target]}
selectedTarget={target}
selectedTarget={selectedTarget}
draft={draft}
history={[]}
error={null}
canUndo={false}
canRedo={false}
onSelectTarget={onSelectTarget}
onDraftChange={vi.fn()}
pageStylesEnabled={pageStylesEnabled}
onSelectTarget={vi.fn()}
onDraftChange={onDraftChange}
onStyleChange={onStyleChange}
onInvalidStyle={onInvalidStyle}
onApplyPatch={onApplyPatch}
onError={onError}
onClearSelection={onClearSelection}
onCancelDraft={vi.fn()}
onUndo={vi.fn()}
onRedo={vi.fn()}
@ -134,16 +484,4 @@ describe('ManualEditPanel', () => {
});
}
function buttonByText(text: string): HTMLButtonElement {
const buttons = Array.from(host.querySelectorAll('button'));
const button = buttons.find((item) => item.textContent?.includes(text));
if (!button) throw new Error(`Button not found: ${text}`);
return button as HTMLButtonElement;
}
function click(button: HTMLButtonElement): void {
act(() => {
button.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }));
});
}
});

View file

@ -0,0 +1,16 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('Critique Theater styles', () => {
it('keeps the Theater UI selectors in the global stylesheet', () => {
const css = readFileSync(join(process.cwd(), 'src/index.css'), 'utf8');
expect(css).toContain('.theater-stage');
expect(css).toContain('.theater-lane');
expect(css).toContain('.theater-score-ticker');
expect(css).toContain('.theater-interrupt');
expect(css).toContain('.theater-transcript');
expect(css.indexOf('.theater-stage')).toBeLessThan(css.indexOf('.assistant-feedback-wrap'));
});
});

View file

@ -66,4 +66,81 @@ describe('manual edit bridge target normalization', () => {
expect(bridge).toContain('if (!isSourceMappable(nodes[i])) continue;');
expect(bridge).toContain('if (isPrimaryTarget(el)) return el;');
});
it('acks live preview style patches by id and version', () => {
const bridge = buildManualEditBridge(true);
expect(bridge).toContain("type: 'od-edit-preview-style-applied'");
expect(bridge).toContain('version: Number(version) || 0, ok: true');
expect(bridge).toContain("ok: false, error: 'Target not found'");
});
it('moves the runtime selected marker between selected targets', () => {
const dom = new JSDOM(
`<main>
<h1 data-od-id="title">Title</h1>
<p data-od-id="body">Body</p>
</main>${buildManualEditBridge(true)}`,
{ runScripts: 'dangerously', url: 'http://localhost' },
);
const title = dom.window.document.querySelector('[data-od-id="title"]')!;
const body = dom.window.document.querySelector('[data-od-id="body"]')!;
dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
data: { type: 'od-edit-selected-target', id: 'title' },
}));
expect(title.getAttribute('data-od-edit-selected')).toBe('true');
expect(body.hasAttribute('data-od-edit-selected')).toBe(false);
dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
data: { type: 'od-edit-selected-target', id: 'body' },
}));
expect(title.hasAttribute('data-od-edit-selected')).toBe(false);
expect(body.getAttribute('data-od-edit-selected')).toBe('true');
dom.window.close();
});
it('clears runtime selected markers for null selection and edit-mode exit', () => {
const dom = new JSDOM(
`<main>
<h1 data-od-id="title">Title</h1>
<p data-od-id="body" data-od-edit-selected="true">Body</p>
</main>${buildManualEditBridge(true)}`,
{ runScripts: 'dangerously', url: 'http://localhost' },
);
const body = dom.window.document.querySelector('[data-od-id="body"]')!;
dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
data: { type: 'od-edit-selected-target', id: null },
}));
expect(body.hasAttribute('data-od-edit-selected')).toBe(false);
dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
data: { type: 'od-edit-selected-target', id: 'body' },
}));
expect(body.getAttribute('data-od-edit-selected')).toBe('true');
dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
data: { type: 'od-edit-mode', enabled: false },
}));
expect(body.hasAttribute('data-od-edit-selected')).toBe(false);
dom.window.close();
});
it('keeps runtime selection marker out of source-shaped target data', () => {
const bridge = buildManualEditBridge(true);
expect(bridge).toContain("attr.name === 'data-od-edit-selected'");
expect(bridge).toContain('replace(/\\sdata-od-edit-selected="[^"]*"/g, \'\')');
expect(bridge).toContain('[data-od-edit-selected]');
});
it('marks flex/grid targets as layout containers', () => {
const bridge = buildManualEditBridge(true);
expect(bridge).toContain('isLayoutContainer: isLayoutContainer(el)');
expect(bridge).toContain("display.indexOf('flex') >= 0 || display.indexOf('grid') >= 0");
});
});

View file

@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { JSDOM } from 'jsdom';
import {
applyManualEditPatch,
isManualEditFullHtmlDocument,
readManualEditAttributes,
readManualEditFields,
readManualEditOuterHtml,
@ -92,15 +93,33 @@ describe('manual edit source patches', () => {
const result = applyManualEditPatch(baseSource, {
kind: 'set-style',
id: 'card',
styles: { color: '', backgroundColor: 'blue', fontSize: '24px' },
styles: {
color: '',
backgroundColor: '#ff0000',
fontSize: '24px',
paddingTop: '12px',
marginLeft: '4px',
borderTopWidth: '2px',
borderStyle: 'solid',
borderColor: '#000000',
borderRadius: '8px',
opacity: '0.5',
},
});
expect(result.ok).toBe(true);
const styles = readManualEditStyles(result.source, 'card');
expect(styles.color).toBe('');
expect(styles.backgroundColor).toBe('blue');
expect(styles.backgroundColor).toBe('rgb(255, 0, 0)');
expect(styles.fontSize).toBe('24px');
expect(styles.padding).toBe('8px');
expect(styles.padding).toBe('12px 8px 8px');
expect(styles.paddingTop).toBe('12px');
expect(styles.marginLeft).toBe('4px');
expect(styles.borderTopWidth).toBe('2px');
expect(styles.borderStyle).toBe('solid');
expect(styles.borderColor).toBe('rgb(0, 0, 0)');
expect(styles.borderRadius).toBe('8px');
expect(styles.opacity).toBe('0.5');
});
it('applies attributes additively and preserves class/style unless explicitly updated', () => {
@ -157,6 +176,12 @@ describe('manual edit source patches', () => {
expect(result.source).not.toContain('<body');
});
it('detects full documents after leading comments and keeps fragments distinct', () => {
expect(isManualEditFullHtmlDocument('<!-- generated -->\n<!doctype html><html></html>')).toBe(true);
expect(isManualEditFullHtmlDocument('<?xml version="1.0"?>\n<html></html>')).toBe(true);
expect(isManualEditFullHtmlDocument('<main><h1>Fragment</h1></main>')).toBe(false);
});
it('preserves full documents with leading comments when saving patches', () => {
const source = [
'<!-- generated by open design -->',

View file

@ -24,7 +24,7 @@ test.beforeEach(async ({ page }) => {
}, STORAGE_KEY);
});
test('manual edit mode applies content, style, attribute, HTML, source, undo, and redo patches', async ({ page }) => {
test('manual edit inspector previews and persists page and selected element styles', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Manual edit smoke');
await seedHtmlArtifact(page, projectId, 'manual-edit.html', manualEditHtml());
@ -34,60 +34,92 @@ test('manual edit mode applies content, style, attribute, HTML, source, undo, an
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
await expect(frame.getByRole('heading', { name: 'Original Hero' })).toBeVisible();
const responsivePair = frame.locator('[data-od-id="responsive-pair"]');
await expect.poll(async () => responsivePair.evaluate((el) => getComputedStyle(el).flexDirection)).toBe('row');
await page.getByTestId('manual-edit-mode-toggle').click();
await expect.poll(async () => responsivePair.evaluate((el) => getComputedStyle(el).flexDirection)).toBe('row');
await expect(page.locator('.manual-edit-modal')).toContainText('PAGE');
await expect(page.locator('.manual-edit-tabs')).toHaveCount(0);
await expect(page.locator('.manual-edit-layer-row')).toHaveCount(0);
await inspectorRow(page, 'Background').locator('input').fill('#eef2ff');
await inspectorRow(page, 'Font').locator('select').selectOption('Georgia, serif');
await inspectorRow(page, 'Base size').locator('input').fill('18');
await expect
.poll(async () => frame.locator('body').evaluate((el) => getComputedStyle(el).backgroundColor))
.toBe('rgb(238, 242, 255)');
await expectFileSource(page, projectId, 'manual-edit.html', [
'background-color:',
'font-family: Georgia, serif',
'font-size: 18px',
'letter-spacing: 0.01em',
]);
await frame.getByRole('heading', { name: 'Original Hero' }).click();
await expect(page.locator('.manual-edit-modal')).toContainText('Hero title');
await expect(page.locator('.manual-edit-modal')).toContainText('TYPOGRAPHY');
await expect(page.locator('.manual-edit-modal')).toContainText('SIZE');
await expect(page.locator('.manual-edit-modal')).toContainText('LAYOUT');
await expect(page.locator('.manual-edit-modal')).toContainText('BOX');
const selectedTitleMarker = frame.locator('[data-od-id="hero-title"][data-od-edit-selected="true"]');
await expect(selectedTitleMarker).toHaveCount(1);
const fontSizeInput = inspectorSection(page, 'TYPOGRAPHY').locator('.cc-row').filter({ hasText: 'Size' }).locator('input');
await fontSizeInput.click();
await expect(selectedTitleMarker).toHaveCount(1);
await expect(fontSizeInput).not.toHaveValue('');
await expect(fontSizeInput).not.toHaveValue(/px/i);
await page.getByRole('button', { name: 'Show page inspector' }).click();
await expect(page.locator('.manual-edit-modal')).toContainText('PAGE');
await expect(page.locator('.manual-edit-modal')).not.toContainText('TYPOGRAPHY');
await expect(selectedTitleMarker).toHaveCount(0);
await frame.getByRole('heading', { name: 'Original Hero' }).click();
await expect(page.locator('.manual-edit-modal')).toContainText('TYPOGRAPHY');
await expect(selectedTitleMarker).toHaveCount(1);
await expect(inspectorSection(page, 'TYPOGRAPHY').locator('.cc-row').filter({ hasText: 'Color' }).locator('input')).toHaveValue(/^#[0-9a-f]{6}$/);
const lineInput = inspectorSection(page, 'TYPOGRAPHY').locator('.cc-row').filter({ hasText: 'Line' }).locator('input');
await lineInput.click();
await lineInput.blur();
await expect(page.locator('.manual-edit-error')).toHaveCount(0);
await frame.locator('body').evaluate(() => {
window.parent.postMessage({ type: 'od-edit-targets', targets: [] }, '*');
});
await expect(page.locator('.manual-edit-modal')).toContainText('TYPOGRAPHY');
await expect(page.locator('.manual-edit-modal')).not.toContainText('PAGE');
await frame.locator('body').evaluate(() => {
(window as Window & typeof globalThis & { __manualEditSmokeMarker?: string }).__manualEditSmokeMarker = 'stable-frame';
});
await page.locator('.manual-edit-modal textarea').first().fill('Edited Hero');
await page.getByRole('button', { name: 'Apply Content' }).click();
await expect(frame.getByRole('heading', { name: 'Edited Hero' })).toBeVisible();
await expectFileSource(page, projectId, 'manual-edit.html', ['Edited Hero']);
await fontSizeInput.fill('48');
await inspectorSection(page, 'TYPOGRAPHY').locator('.cc-row').filter({ hasText: 'Color' }).locator('input').fill('#ef4444');
await inspectorSection(page, 'BOX').locator('.cc-row').filter({ hasText: 'Fill' }).locator('input').fill('#f97316');
const paddingTopInput = inspectorSection(page, 'BOX').locator('.cc-quad').filter({ hasText: 'Padding' }).locator('input').first();
await paddingTopInput.fill('12');
await inspectorSection(page, 'BOX').locator('.cc-row').filter({ hasText: 'Radius' }).locator('input').fill('8');
await expect(fontSizeInput).toHaveValue('48');
await expect(paddingTopInput).toHaveValue('12');
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Style', exact: true }).click();
await page.locator('.manual-edit-field').filter({ hasText: 'Font size' }).locator('input').fill('48px');
await page.getByRole('button', { name: 'Apply Style' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['font-size: 48px']);
await page.locator('.manual-edit-layer-row').filter({ hasText: 'Primary CTA' }).click();
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Content', exact: true }).click();
const contentFields = page.locator('.manual-edit-tab-body');
await contentFields.locator('textarea').fill('Launch now');
await contentFields.locator('input').fill('/launch');
await page.getByRole('button', { name: 'Apply Content' }).click();
await expect(frame.getByRole('link', { name: 'Launch now' })).toHaveAttribute('href', /\/launch$/);
await page.locator('.manual-edit-layer-row').filter({ hasText: 'Hero image' }).click();
await contentFields.locator('input').first().fill('/edited.png');
await contentFields.locator('input').nth(1).fill('Edited alt');
await page.getByRole('button', { name: 'Apply Content' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['/edited.png', 'Edited alt']);
await page.locator('.manual-edit-layer-row').filter({ hasText: 'Hero title' }).click();
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Attributes', exact: true }).click();
await page.locator('.manual-edit-tab-body textarea').fill('{"aria-label":"Edited headline"}');
await page.getByRole('button', { name: 'Apply Attributes' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['aria-label="Edited headline"', 'font-size: 48px']);
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Html', exact: true }).click();
await page.locator('.manual-edit-tab-body textarea').fill('<h1 class="replacement">HTML Hero</h1>');
await page.getByRole('button', { name: 'Apply HTML' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['data-od-id="hero-title"', 'HTML Hero']);
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Source', exact: true }).click();
await page.locator('.manual-edit-tab-body textarea').fill(manualEditHtml().replace('Original Hero', 'Full Source Hero'));
await page.getByRole('button', { name: 'Apply Source' }).click();
await expect(frame.getByRole('heading', { name: 'Full Source Hero' })).toBeVisible();
await page.getByRole('button', { name: 'Undo' }).click();
await expect(frame.getByRole('heading', { name: 'HTML Hero' })).toBeVisible();
await page.getByRole('button', { name: 'Redo' }).click();
await expect(frame.getByRole('heading', { name: 'Full Source Hero' })).toBeVisible();
await page.getByRole('button', { name: /Tweaks/ }).click();
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible();
await frame.getByRole('heading', { name: 'Full Source Hero' }).click();
await expect(page.getByTestId('comment-popover')).toBeVisible();
const title = frame.getByRole('heading', { name: 'Original Hero' });
await expect.poll(async () => title.evaluate((el) => getComputedStyle(el).fontSize)).toBe('48px');
await expect(title).toHaveCSS('color', 'rgb(239, 68, 68)');
await expect(title).toHaveCSS('background-color', 'rgb(249, 115, 22)');
await expect(title).toHaveCSS('padding-top', '12px');
await expect(title).toHaveCSS('border-radius', '8px');
await expectFileSource(page, projectId, 'manual-edit.html', [
'font-size: 48px',
'color:',
'background-color:',
'padding-top: 12px',
'border-radius: 8px',
]);
await expectFileSourceExcludes(page, projectId, 'manual-edit.html', ['data-od-edit-selected']);
await expect(page.locator('.manual-edit-modal')).toContainText('TYPOGRAPHY');
await expect(page.locator('.manual-edit-modal')).not.toContainText('PAGE');
await expect(selectedTitleMarker).toHaveCount(1);
await expect(page.locator('.manual-edit-error')).toHaveCount(0);
await expect.poll(async () => frame.locator('body').evaluate(() => (
window as Window & typeof globalThis & { __manualEditSmokeMarker?: string }
).__manualEditSmokeMarker)).toBe('stable-frame');
await page.getByRole('button', { name: /^Share$/ }).click();
await expect(page.getByRole('menuitem', { name: /Export as PDF/ })).toBeVisible();
@ -171,13 +203,46 @@ async function expectFileSource(page: Page, projectId: string, fileName: string,
.toBe(true);
}
async function expectFileSourceExcludes(page: Page, projectId: string, fileName: string, snippets: string[]) {
await expect
.poll(async () => {
const resp = await page.request.get(`/api/projects/${projectId}/files/${fileName}`);
if (!resp.ok()) return false;
const source = await resp.text();
return snippets.every((snippet) => !source.includes(snippet));
})
.toBe(true);
}
function inspectorRow(page: Page, label: string) {
return page.locator('.manual-edit-modal .cc-row').filter({ hasText: label }).first();
}
function inspectorSection(page: Page, title: string) {
return page.locator('.manual-edit-modal .cc-section').filter({ hasText: title }).first();
}
function manualEditHtml(): string {
return `<!doctype html>
<html>
<head><meta charset="utf-8"><title>Manual Edit</title></head>
<body>
<head>
<meta charset="utf-8">
<title>Manual Edit</title>
<style>
.responsive-pair { display: flex; gap: 24px; }
.responsive-pair > div { flex: 1 1 0; min-height: 40px; }
@media (max-width: 700px) {
.responsive-pair { flex-direction: column; }
}
</style>
</head>
<body style="font-family: Inter, system-ui, sans-serif; font-size: 16px; letter-spacing: 0.01em;">
<main>
<section data-od-id="hero" data-od-label="Hero section">
<section data-od-id="responsive-pair" data-od-label="Responsive pair" class="responsive-pair">
<div data-od-id="pair-a">Left panel</div>
<div data-od-id="pair-b">Right panel</div>
</section>
<section data-od-id="hero" data-od-label="Hero section" style="display:flex;gap:8px;align-items:center;">
<h1 data-od-id="hero-title" data-od-label="Hero title">Original Hero</h1>
<a data-od-id="cta" data-od-label="Primary CTA" href="/start">Start now</a>
<img data-od-id="hero-image" data-od-label="Hero image" src="/hero.png" alt="Hero" style="width:64px;height:64px;">

View file

@ -10,42 +10,42 @@
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #1a1a1a;
background: #fafafa;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 60px 24px;
}
header {
text-align: center;
margin-bottom: 48px;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 12px;
color: #0a0a0a;
}
.subtitle {
font-size: 1.125rem;
color: #666;
}
.search-box {
position: relative;
margin-bottom: 32px;
}
.search-input {
width: 100%;
padding: 16px 48px 16px 20px;
@ -55,12 +55,12 @@
background: white;
transition: border-color 0.2s;
}
.search-input:focus {
outline: none;
border-color: #0066ff;
}
.search-icon {
position: absolute;
right: 20px;
@ -68,7 +68,7 @@
transform: translateY(-50%);
color: #999;
}
.categories {
display: flex;
gap: 12px;
@ -76,7 +76,7 @@
margin-bottom: 40px;
justify-content: center;
}
.category-btn {
padding: 10px 20px;
border: 2px solid #e0e0e0;
@ -88,24 +88,24 @@
cursor: pointer;
transition: all 0.2s;
}
.category-btn:hover {
border-color: #0066ff;
color: #0066ff;
}
.category-btn.active {
background: #0066ff;
border-color: #0066ff;
color: white;
}
.faq-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.faq-item {
background: white;
border: 1px solid #e0e0e0;
@ -113,15 +113,15 @@
overflow: hidden;
transition: box-shadow 0.2s;
}
.faq-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.faq-item.hidden {
display: none;
}
.faq-question {
width: 100%;
padding: 20px 24px;
@ -137,39 +137,39 @@
align-items: center;
gap: 16px;
}
.faq-question:hover {
color: #0066ff;
}
.faq-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
transition: transform 0.3s;
}
.faq-item.open .faq-icon {
transform: rotate(180deg);
}
.faq-answer {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
}
.faq-answer-content {
padding: 0 24px 20px;
color: #555;
font-size: 1rem;
line-height: 1.7;
}
.faq-item.open .faq-answer {
max-height: 500px;
}
.footer-cta {
margin-top: 64px;
padding: 32px;
@ -178,18 +178,18 @@
border-radius: 12px;
text-align: center;
}
.footer-cta h2 {
font-size: 1.5rem;
margin-bottom: 12px;
color: #0a0a0a;
}
.footer-cta p {
color: #666;
margin-bottom: 20px;
}
.contact-link {
display: inline-block;
padding: 12px 32px;
@ -200,18 +200,18 @@
font-weight: 600;
transition: background 0.2s;
}
.contact-link:hover {
background: #0052cc;
}
.no-results {
text-align: center;
padding: 48px 24px;
color: #999;
display: none;
}
.no-results.show {
display: block;
}
@ -223,12 +223,12 @@
<h1>Frequently Asked Questions</h1>
<p class="subtitle">Find answers to common questions about our product and services</p>
</header>
<div class="search-box" data-od-id="search">
<input
type="text"
class="search-input"
id="searchInput"
<input
type="text"
class="search-input"
id="searchInput"
placeholder="Search for answers..."
aria-label="Search FAQ"
>
@ -236,7 +236,7 @@
<path d="M9 17A8 8 0 1 0 9 1a8 8 0 0 0 0 16zM18 18l-4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<div class="categories" data-od-id="categories">
<button class="category-btn active" data-category="all">All</button>
<button class="category-btn" data-category="billing">Billing</button>
@ -244,7 +244,7 @@
<button class="category-btn" data-category="technical">Technical</button>
<button class="category-btn" data-category="general">General</button>
</div>
<div class="faq-list" data-od-id="faq-list">
<!-- Billing -->
<div class="faq-item" data-category="billing">
@ -260,7 +260,7 @@
</div>
</div>
</div>
<div class="faq-item" data-category="billing">
<button class="faq-question" aria-expanded="false">
<span>What payment methods do you accept?</span>
@ -274,7 +274,7 @@
</div>
</div>
</div>
<div class="faq-item" data-category="billing">
<button class="faq-question" aria-expanded="false">
<span>Can I get a refund?</span>
@ -288,7 +288,7 @@
</div>
</div>
</div>
<!-- Account -->
<div class="faq-item" data-category="account">
<button class="faq-question" aria-expanded="false">
@ -303,7 +303,7 @@
</div>
</div>
</div>
<div class="faq-item" data-category="account">
<button class="faq-question" aria-expanded="false">
<span>Can I change my email address?</span>
@ -317,7 +317,7 @@
</div>
</div>
</div>
<div class="faq-item" data-category="account">
<button class="faq-question" aria-expanded="false">
<span>How do I delete my account?</span>
@ -331,7 +331,7 @@
</div>
</div>
</div>
<!-- Technical -->
<div class="faq-item" data-category="technical">
<button class="faq-question" aria-expanded="false">
@ -346,7 +346,7 @@
</div>
</div>
</div>
<div class="faq-item" data-category="technical">
<button class="faq-question" aria-expanded="false">
<span>Is there a mobile app?</span>
@ -360,7 +360,7 @@
</div>
</div>
</div>
<div class="faq-item" data-category="technical">
<button class="faq-question" aria-expanded="false">
<span>How do I export my data?</span>
@ -374,7 +374,7 @@
</div>
</div>
</div>
<!-- General -->
<div class="faq-item" data-category="general">
<button class="faq-question" aria-expanded="false">
@ -389,7 +389,7 @@
</div>
</div>
</div>
<div class="faq-item" data-category="general">
<button class="faq-question" aria-expanded="false">
<span>How do I get started?</span>
@ -403,7 +403,7 @@
</div>
</div>
</div>
<div class="faq-item" data-category="general">
<button class="faq-question" aria-expanded="false">
<span>Do you offer customer support?</span>
@ -418,34 +418,34 @@
</div>
</div>
</div>
<div class="no-results" id="noResults">
<p>No questions found matching your search.</p>
</div>
<div class="footer-cta" data-od-id="footer-cta">
<h2>Still have questions?</h2>
<p>Can't find what you're looking for? Our support team is here to help.</p>
<a href="mailto:support@example.com" class="contact-link">Contact Support</a>
</div>
</div>
<script>
// Accordion functionality
const faqItems = document.querySelectorAll('.faq-item');
faqItems.forEach(item => {
const question = item.querySelector('.faq-question');
question.addEventListener('click', () => {
const isOpen = item.classList.contains('open');
// Close all items
faqItems.forEach(i => {
i.classList.remove('open');
i.querySelector('.faq-question').setAttribute('aria-expanded', 'false');
});
// Open clicked item if it was closed
if (!isOpen) {
item.classList.add('open');
@ -453,21 +453,21 @@
}
});
});
// Search functionality
const searchInput = document.getElementById('searchInput');
const noResults = document.getElementById('noResults');
searchInput.addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase();
let visibleCount = 0;
faqItems.forEach(item => {
const question = item.querySelector('.faq-question span').textContent.toLowerCase();
const answer = item.querySelector('.faq-answer-content').textContent.toLowerCase();
const matchesSearch = question.includes(searchTerm) || answer.includes(searchTerm);
const matchesCategory = item.dataset.category === currentCategory || currentCategory === 'all';
if (matchesSearch && matchesCategory) {
item.classList.remove('hidden');
visibleCount++;
@ -475,32 +475,32 @@
item.classList.add('hidden');
}
});
noResults.classList.toggle('show', visibleCount === 0);
});
// Category filtering
const categoryBtns = document.querySelectorAll('.category-btn');
let currentCategory = 'all';
categoryBtns.forEach(btn => {
btn.addEventListener('click', () => {
currentCategory = btn.dataset.category;
// Update active state
categoryBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Filter items
const searchTerm = searchInput.value.toLowerCase();
let visibleCount = 0;
faqItems.forEach(item => {
const question = item.querySelector('.faq-question span').textContent.toLowerCase();
const answer = item.querySelector('.faq-answer-content').textContent.toLowerCase();
const matchesSearch = !searchTerm || question.includes(searchTerm) || answer.includes(searchTerm);
const matchesCategory = currentCategory === 'all' || item.dataset.category === currentCategory;
if (matchesSearch && matchesCategory) {
item.classList.remove('hidden');
visibleCount++;
@ -508,7 +508,7 @@
item.classList.add('hidden');
}
});
noResults.classList.toggle('show', visibleCount === 0);
});
});