feat(#90): IMP-56 u1-u19 catch-up before final close (post-u20 push fix)
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s

u1: text_overrides axis in user_overrides_io
u2: structure_overrides axis in user_overrides_io
u3: vite allowlist for new endpoints
u4: text_override_resolver
u5: Step 12 text_overrides apply in phase_z2_pipeline
u6: structure_override_resolver
u7: text_path_stamper
u8: SlideCanvas text-edit capture
u9: SlideCanvas structure-edit overlay
u10: userOverridesApi service extension
u11: designAgent types extension
u12: slidePlanUtils restore
u13: user_overrides endpoint tests
u14: user_overrides restore tests
u15: pipeline fallback tests
u16: edit-mode state + gating tests
u17: slide_base print mode CSS
u18: /api/connect endpoint (vite)
u19: /api/export endpoint (vite)

Recovery scope: 29 files (12 modified + 17 new). u20 already pushed in
9439575; this commit lands u1-u19 that were authored but not committed
before #90 was externally closed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 06:12:13 +09:00
parent 943957562f
commit 4da22adb43
29 changed files with 4937 additions and 78 deletions

View File

@@ -28,7 +28,12 @@ import {
crossedDragThreshold,
type ImageDragDirection,
} from "./slideCanvasDragMath";
import type { ImageOverridesOverride } from "../services/userOverridesApi";
import type {
ImageOverridesOverride,
StructureOverridesOverride,
StructureOverridePerZone,
} from "../services/userOverridesApi";
import StructureEditOverlay from "./StructureEditOverlay";
interface SlideCanvasProps {
slidePlan: SlidePlan | null;
@@ -73,11 +78,112 @@ interface SlideCanvasProps {
imageId: string,
geometry: { x: number; y: number; w: number; h: number }
) => void;
/** IMP-90 (#90) u13 — focusout-emitted capture; u15 debounces + PUTs. */
onTextEdit?: (capture: TextEditCapture) => void;
/** IMP-90 (#90) u14 — persisted structure overrides per zone
* (slot_order + hidden_slots). When `editMode === "structure"` the
* StructureEditOverlay reads from this to render the current state. */
structureOverrides?: StructureOverridesOverride;
/** IMP-90 (#90) u14 — emitted whenever the user reorders or hides a
* slot in structure-mode. u15 will debounce + PUT to /api/user-
* overrides; u14 only exposes the capture. SCOPE LOCK: inner shape is
* `{slot_order, hidden_slots}` only (frame swap stays on `frames` axis). */
onStructureEdit?: (zoneId: string, capture: StructureOverridePerZone) => void;
}
const SLIDE_W = 1280;
const SLIDE_H = 720;
// IMP-90 (#90) u11 — discriminated edit mode. Replaces the prior single
// `isEditMode` boolean. u11 introduces the enum + the toolbar UI surface;
// gesture gating (text contentEditable vs structure reorder vs image-zone
// drag/resize) stays unified behind `isEditMode = editMode !== 'off'` so
// existing behavior is preserved byte-identical. u12 will discriminate the
// gestures per mode (mutually exclusive). The 'off' state is the no-edit
// baseline; 'image-zone' bundles image edit (#79) + zone resize (#81)
// because both are pointer-driven canvas gestures on slide geometry.
export type EditMode = "off" | "text" | "structure" | "image-zone";
export const EDIT_MODES: ReadonlyArray<EditMode> = ["text", "structure", "image-zone"];
/** Pure helper — given the current edit mode and the user's requested mode,
* return the next mode. Clicking the active mode toggles back to 'off';
* clicking a different mode switches; explicit 'off' always exits. */
export function nextEditMode(current: EditMode, requested: EditMode): EditMode {
if (requested === "off") return "off";
return current === requested ? "off" : requested;
}
// IMP-90 (#90) u12 — per-mode gesture gating. Pure helper deriving the
// boolean gates that drive SlideCanvas's useEffect branches (designMode
// + iframe-side image click listener) and JSX conditionals (iframe
// pointer-events, zone resize/drag affordances, image overlay). The
// mapping enforces the mutually-exclusive contract from the issue body:
// text -> contentEditable + iframe pointer-events:auto only.
// structure -> nothing here; u14 will plant the structure overlay.
// image-zone -> zone resize/drag + image overlay; iframe pe:auto so
// in-iframe user-content images can be click-selected.
// off -> every gate false (baseline).
// pendingLayout fully suppresses every gate — mirrors the existing
// useEffect (line ~248) that forces editMode='off' on pendingLayout
// entry. The helper still defensively returns all-false so a stray
// pendingLayout=true with a non-'off' editMode never leaks gestures.
export interface EditModeGates {
textEditing: boolean;
imageSelection: boolean;
iframePointerAuto: boolean;
zoneGestures: boolean;
imageOverlay: boolean;
}
export function computeEditModeGates(
editMode: EditMode,
isPendingLayout: boolean
): EditModeGates {
if (isPendingLayout) {
return {
textEditing: false,
imageSelection: false,
iframePointerAuto: false,
zoneGestures: false,
imageOverlay: false,
};
}
return {
textEditing: editMode === "text",
imageSelection: editMode === "image-zone",
iframePointerAuto: editMode === "text" || editMode === "image-zone",
zoneGestures: editMode === "image-zone",
imageOverlay: editMode === "image-zone",
};
}
// IMP-90 (#90) u13 — pure helper resolving a contentEditable focusout
// target into (zoneId, textPath, value). data-text-path stamped by u8 at
// Step 13; .zone[data-zone-position] from Phase Z slide-base. Non-stamped
// targets return null so capture silently skips. u15 will debounce + PUT.
export interface TextEditCaptureTarget {
closest(selector: string): TextEditCaptureTarget | null;
getAttribute(name: string): string | null;
textContent: string | null;
}
export interface TextEditCapture {
zoneId: string;
textPath: string;
value: string;
}
export function deriveTextEditCapture(
target: TextEditCaptureTarget | null
): TextEditCapture | null {
if (!target) return null;
const lineEl = target.closest("[data-text-path]");
if (!lineEl) return null;
const textPath = lineEl.getAttribute("data-text-path");
if (!textPath) return null;
const zoneEl = lineEl.closest(".zone[data-zone-position]");
if (!zoneEl) return null;
const zoneId = zoneEl.getAttribute("data-zone-position");
if (!zoneId) return null;
return { zoneId, textPath, value: (lineEl.textContent ?? "").trim() };
}
export default function SlideCanvas({
slidePlan,
userSelection,
@@ -93,6 +199,9 @@ export default function SlideCanvas({
onZoneResize,
imageOverrides,
onImageResize,
onTextEdit,
structureOverrides,
onStructureEdit,
}: SlideCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
@@ -135,7 +244,15 @@ export default function SlideCanvas({
// HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용.
// 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업.
// pendingLayout 과 배타적 (충돌 방지).
const [isEditMode, setIsEditMode] = useState(false);
// IMP-90 (#90) u11 — `editMode` enum replaces the prior boolean. The
// `isEditMode` shim is kept ONLY for the pendingLayout coupling +
// zone-wrapper visual cues (border / hover / selected styling) that
// fire whenever any edit mode is active. u12 routes gesture-activating
// gates through `editGates` so text / structure / image-zone gestures
// are mutually exclusive.
const [editMode, setEditMode] = useState<EditMode>("off");
const isEditMode = editMode !== "off";
const editGates = computeEditModeGates(editMode, !!isPendingLayout);
const iframeRef = useRef<HTMLIFrameElement>(null);
// 편집 모드 toggle 시 iframe contentDocument 에 글벗 패턴 적용 / 해제.
@@ -159,11 +276,22 @@ export default function SlideCanvas({
const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"];
let inputHandler: ((e: Event) => void) | null = null;
// IMP-90 (#90) u13 — focusout (= bubbling blur) emits one capture per
// finished line edit; u15 will debounce + PUT.
let textEditCaptureHandler: ((e: Event) => void) | null = null;
// IMP-51 (#79) u8 — user-content image click listeners installed
// inside the iframe contentDocument. Tracked here so the cleanup
// callback can remove them when edit mode exits (or iframe reloads).
const imageClickBindings: Array<{ el: HTMLImageElement; handler: (e: Event) => void; prevCursor: string; prevOutline: string }> = [];
if (isEditMode) {
// IMP-90 (#90) u12 — text-editing gate: only the 'text' editMode
// turns designMode on + makes the editable tags contentEditable.
// The else branch tears the prior state down so leaving text mode
// (to structure / image-zone / off) immediately disables in-place
// text editing — required for mutual exclusivity vs the image-zone
// overlay's drag/resize gestures (a contentEditable cursor would
// otherwise be placed by every image click).
if (editGates.textEditing) {
doc.designMode = "on";
doc.querySelectorAll(".slide *").forEach((el) => {
if (editableTags.includes((el as HTMLElement).tagName)) {
@@ -176,11 +304,28 @@ export default function SlideCanvas({
};
doc.addEventListener("input", inputHandler);
// IMP-51 (#79) u8 — wire click → selectedImageId on every stamped
// user-content image. Selector mirrors USER_CONTENT_IMAGE_SELECTOR
// in src/image_id_stamper.py (+ requires data-image-id which the
// stamper always emits). Decorative / frame imgs lacking the role
// attribute are intentionally NOT clickable here.
textEditCaptureHandler = (ev: Event) => {
const cap = deriveTextEditCapture(
ev.target as unknown as TextEditCaptureTarget | null
);
if (cap) onTextEdit?.(cap);
};
doc.addEventListener("focusout", textEditCaptureHandler);
} else {
doc.designMode = "off";
doc.querySelectorAll("[contenteditable]").forEach((el) => {
(el as HTMLElement).removeAttribute("contenteditable");
});
}
// IMP-90 (#90) u12 — image-selection gate: only the 'image-zone'
// editMode wires the in-iframe user-content image click → selection.
// Selector mirrors USER_CONTENT_IMAGE_SELECTOR in image_id_stamper.py
// (requires data-image-id which the stamper always emits). Decorative
// / frame imgs lacking the role attribute are NOT clickable. The
// else branch clears `selectedImageId` so the React-side overlay
// never lingers on a non-image-zone edit mode.
if (editGates.imageSelection) {
const imgEls = doc.querySelectorAll<HTMLImageElement>(
'.slide img[data-image-role="user-content"][data-image-id]'
);
@@ -200,12 +345,6 @@ export default function SlideCanvas({
imageClickBindings.push({ el: imgEl, handler, prevCursor, prevOutline });
});
} else {
doc.designMode = "off";
doc.querySelectorAll("[contenteditable]").forEach((el) => {
(el as HTMLElement).removeAttribute("contenteditable");
});
// edit-mode exit also clears stale image selection so the handle
// overlay never lingers on a non-editable iframe.
setSelectedImageId(null);
}
@@ -213,19 +352,60 @@ export default function SlideCanvas({
if (inputHandler && doc) {
doc.removeEventListener("input", inputHandler);
}
if (textEditCaptureHandler && doc) {
doc.removeEventListener("focusout", textEditCaptureHandler);
}
imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => {
el.removeEventListener("click", handler);
el.style.cursor = prevCursor;
el.style.outline = prevOutline;
});
};
}, [isEditMode, finalHtmlUrl, onContentEdit]);
}, [editGates.textEditing, editGates.imageSelection, finalHtmlUrl, onContentEdit, onTextEdit]);
// pendingLayout 진입 시 편집 모드 자동 OFF (충돌 방지).
useEffect(() => {
if (isPendingLayout && isEditMode) setIsEditMode(false);
if (isPendingLayout && isEditMode) setEditMode("off");
}, [isPendingLayout, isEditMode]);
// IMP-90 (#90) u14 — discover slot keys per zone for the structure
// overlay. Source = iframe DOM `data-text-path="{slot_key}.{line_index}"`
// attributes stamped by u8 (`src/text_path_stamper.py`). Unique slot_key
// prefixes per `.zone[data-zone-position]` form the overlay's slot list.
// Discovery runs only when entering structure mode (and resets on exit
// or iframe reload) so off / text / image-zone modes never pay this
// traversal cost.
const [slotKeysByZone, setSlotKeysByZone] = useState<
Record<string, string[]>
>({});
useEffect(() => {
if (editMode !== "structure" || isPendingLayout) {
setSlotKeysByZone({});
return;
}
const doc = iframeRef.current?.contentDocument;
if (!doc) return;
const next: Record<string, string[]> = {};
doc.querySelectorAll(".zone[data-zone-position]").forEach((zEl) => {
const zoneId = (zEl as HTMLElement).getAttribute("data-zone-position");
if (!zoneId) return;
const seen = new Set<string>();
const keys: string[] = [];
zEl.querySelectorAll("[data-text-path]").forEach((lineEl) => {
const path = (lineEl as HTMLElement).getAttribute("data-text-path");
if (!path) return;
const lastDot = path.lastIndexOf(".");
const slotKey = lastDot > 0 ? path.slice(0, lastDot) : path;
if (slotKey && !seen.has(slotKey)) {
seen.add(slotKey);
keys.push(slotKey);
}
});
next[zoneId] = keys;
});
setSlotKeysByZone(next);
}, [editMode, isPendingLayout, finalHtmlUrl]);
// finalHtmlUrl 이 바뀌면 (= 다른 run / 재실행) stale 측정값 reset.
// 새 iframe 의 onLoad 가 발화하면서 measuredZones 다시 채움.
useEffect(() => {
@@ -332,28 +512,50 @@ export default function SlideCanvas({
</button>
)}
{/* 편집 모드 toggle 버튼 — normal mode + final.html 있을 때만.
글벗 패턴 차용 — designMode + contentEditable. backend 반영은 별 작업. */}
{/* IMP-90 (#90) u11 — discriminated edit-mode toolbar.
Replaces the prior single ✏ toggle. Three modes (text /
structure / image-zone) are mutually exclusive; clicking the
active mode toggles back to 'off'. Gesture gating per mode is
u12 — u11 only plants the state + UI surface, so all three
modes currently share the same `isEditMode` shim behavior. */}
{!isPendingLayout && finalHtmlUrl && (
<button
onClick={(e) => {
e.stopPropagation();
setIsEditMode((p) => !p);
}}
className={`absolute top-2 right-2 z-30 text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
isEditMode
? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200"
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
}`}
<div
data-testid="edit-mode-toolbar"
className="absolute top-2 right-2 z-30 flex gap-1"
style={{ pointerEvents: "auto" }}
title={
isEditMode
? "편집 모드 — 텍스트 클릭하여 수정. 다시 클릭하여 종료. (변경은 frontend 만, backend 반영 미구현)"
: "텍스트 직접 편집 모드 진입"
}
>
{isEditMode ? "✏ 편집 중 (클릭 종료)" : "✏ 편집"}
</button>
{EDIT_MODES.map((mode) => {
const active = editMode === mode;
const label =
mode === "text" ? "✏ 텍스트" : mode === "structure" ? "▦ 구조" : "🖼 이미지/존";
const title =
mode === "text"
? "텍스트 편집 — 텍스트 클릭하여 직접 수정"
: mode === "structure"
? "구조 편집 — slot 순서 / 숨김 변경 (u14 펜딩)"
: "이미지/존 편집 — 이미지 드래그·리사이즈 + 존 리사이즈";
return (
<button
key={mode}
type="button"
data-testid={`edit-mode-${mode}`}
aria-pressed={active}
onClick={(e) => {
e.stopPropagation();
setEditMode((prev) => nextEditMode(prev, mode));
}}
className={`text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
active
? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200"
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
}`}
title={title}
>
{label}
</button>
);
})}
</div>
)}
<div
@@ -375,7 +577,13 @@ export default function SlideCanvas({
className="w-full h-full border-0 block"
scrolling="no"
sandbox="allow-same-origin allow-scripts"
style={{ pointerEvents: isEditMode ? "auto" : "none" }}
// IMP-90 (#90) u12 — iframe pointer-events gate. 'text' needs
// pe:auto so the user can click into text fields; 'image-zone'
// needs pe:auto so user-content image clicks can reach the
// in-iframe click handler that drives `selectedImageId`.
// 'structure' and 'off' keep pe:none — structure has no
// in-iframe gesture (u14 will overlay React-side controls).
style={{ pointerEvents: editGates.iframePointerAuto ? "auto" : "none" }}
onLoad={(e) => {
// IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend
// slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상
@@ -564,9 +772,11 @@ export default function SlideCanvas({
const makeResizeHandler = (
direction: ResizeDir
) => (ev: React.MouseEvent<HTMLDivElement>) => {
// resize 는 pendingLayout OR 편집 모드 활성. 2026-05-22 demo hot-fix —
// frame partial 에 @container aspect-ratio 회전이 들어가서 fixed px 제약 사라짐.
if ((!isPendingLayout && !isEditMode) || !onZoneResize) return;
// resize 는 pendingLayout OR image-zone 편집 모드 활성. 2026-05-22
// demo hot-fix — frame partial 에 @container aspect-ratio 회전이
// 들어가서 fixed px 제약 사라짐. IMP-90 u12: text/structure 모드
// 에서는 zone resize 비활성 (mutually exclusive per editGates).
if ((!isPendingLayout && !editGates.zoneGestures) || !onZoneResize) return;
if (!measuredSlideBody) return;
ev.preventDefault();
ev.stopPropagation();
@@ -637,7 +847,10 @@ export default function SlideCanvas({
ev: React.MouseEvent<HTMLDivElement>
) => {
ev.stopPropagation();
const canDrag = !!((isPendingLayout || isEditMode) && measuredSlideBody && onZoneResize);
// IMP-90 u12: zone drag is image-zone-mode-only (text /
// structure suppress canDrag; non-zoneGestures click still
// triggers onZoneClick via the !dragged branch on mouse-up).
const canDrag = !!((isPendingLayout || editGates.zoneGestures) && measuredSlideBody && onZoneResize);
const startMouseX = ev.clientX;
const startMouseY = ev.clientY;
const startGeom = { ...localGeom };
@@ -856,12 +1069,14 @@ export default function SlideCanvas({
</div>
)}
{/* Step C : zone resize handles — 8 방향. pendingLayout OR 편집 모드 활성.
2026-05-22 demo hot-fix — frame partial 에 @container aspect-ratio 회전
들어간 후 fixed px 제약 사라져 편집 모드 resize 도 의미 있음.
{/* Step C : zone resize handles — 8 방향. pendingLayout OR image-zone
편집 모드 활성. 2026-05-22 demo hot-fix — frame partial 에 @container
aspect-ratio 회전 들어간 후 fixed px 제약 사라져 image-zone 모드 resize
도 의미 있음. IMP-90 u12: text / structure 모드에서는 zone resize
affordance 미노출 (editGates.zoneGestures = image-zone only).
edge handle (top/bottom/left/right) : 한 boundary 이동
corner handle (nw/ne/sw/se) : 두 boundary 동시. */}
{(isPendingLayout || isEditMode) && onZoneResize && (
{(isPendingLayout || editGates.zoneGestures) && onZoneResize && (
<>
{/* top edge */}
<div
@@ -941,8 +1156,10 @@ export default function SlideCanvas({
strips intercept the perimeter while the un-covered iframe
interior keeps text-edit reachability intact.
pendingLayout mode already has wrapper pointerEvents:auto,
so these surfaces are only needed in edit mode. */}
{isEditMode && !isPendingLayout && onZoneResize && (
so these surfaces are only needed in edit mode.
IMP-90 u12: image-zone-mode-only — text / structure 모드는
zone drag 안 함 (editGates.zoneGestures = false 두 모드 모두). */}
{editGates.zoneGestures && !isPendingLayout && onZoneResize && (
<>
<div
onMouseDown={handleZoneMouseDown}
@@ -987,6 +1204,39 @@ export default function SlideCanvas({
);
})}
{/* IMP-90 (#90) u14 — structure edit overlay (slot reorder +
hide). Renders only in `editMode === "structure"` over each
measured zone, positioned at the zone's top-right inside the
slide-absolute coord space. Slot keys come from u14 iframe
traversal (`slotKeysByZone`). Mutations emit through
onStructureEdit; u15 will debounce + PUT. */}
{!isPendingLayout && editMode === "structure" && finalHtmlUrl &&
slidePlan?.zones.map((zone) => {
const m = measuredZones[zone.zone_id];
if (!m) return null;
const slotKeys = slotKeysByZone[zone.zone_id] ?? [];
const current = structureOverrides?.[zone.zone_id];
return (
<div
key={`struct-${zone.id}`}
className="absolute z-30"
style={{
left: m.x * W_SCALED,
top: m.y * H_SCALED,
width: m.w * W_SCALED,
pointerEvents: "none",
}}
>
<StructureEditOverlay
zoneId={zone.zone_id}
slotKeys={slotKeys}
current={current}
onChange={onStructureEdit}
/>
</div>
);
})}
{/* ── IMP-51 (#79) u8 — user-content image edit overlay ──
Activates only in edit mode when an image_id appears in either
`imageOverrides` (u11-fed persisted axis) or `measuredImages`
@@ -1014,7 +1264,11 @@ export default function SlideCanvas({
`slideCanvasDragMath.ts` so the boundary contract Codex #16
verified is exercised directly by vitest (mirror of how IMP-54
u3 split the zone math out of SlideCanvas). */}
{!isPendingLayout && isEditMode && finalHtmlUrl && onImageResize &&
{/* IMP-90 u12: image overlay is image-zone-mode-only. text /
structure 모드에서는 image drag/resize affordance 미노출
(editGates.imageOverlay = false). pendingLayout 도 동일하게
suppress (computeEditModeGates 가 모두 false 반환). */}
{!isPendingLayout && editGates.imageOverlay && finalHtmlUrl && onImageResize &&
Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map(
([imageId]) => {
const persisted = imageOverrides?.[imageId];

View File

@@ -0,0 +1,165 @@
/**
* IMP-90 (#90) u14 — Structure edit overlay.
*
* React component + pure helpers that present a per-zone slot list with
* reorder (↑ / ↓) and hide (👁 / 🚫) affordances. Mounted by SlideCanvas
* when `editMode === "structure"`. Emits a `StructureOverridePerZone`
* tuple `{slot_order, hidden_slots}` through `onChange`; u15 will debounce
* + PUT this to `/api/user-overrides` (NOT u14 scope), and u16 reads the
* persisted axis at the next CLI generate run.
*
* SCOPE LOCK (binding contract):
* - inner shape = `{slot_order, hidden_slots}` ONLY.
* - frame swap stays on the existing `frames` axis (u6 backend resolver
* rejects frame-swap-shaped inner keys).
* - per-slot text content NEVER mutated here — `text_overrides` axis
* (u4/u5/u13) handles that exclusively.
*
* The exported pure helpers (`resolveEffectiveSlotOrder`, `moveItem`) are
* the unit's vitest surface; React rendering is NOT tested because the
* Front package devDependencies do not include jsdom / @testing-library
* (verified by u11/u12/u13 test pattern).
*/
import type {
StructureOverridePerZone,
} from "../services/userOverridesApi";
export interface StructureEditOverlayProps {
zoneId: string;
/** Discovered slot keys for this zone (e.g. from iframe DOM
* `data-text-path` prefixes). Order = backend default. */
slotKeys: ReadonlyArray<string>;
/** Current persisted override (or undefined). `slot_order` reorders the
* discovered keys; missing keys keep backend order at the tail. */
current?: StructureOverridePerZone;
/** Emitted on every user mutation. u15 wires this to autosave. */
onChange?: (zoneId: string, next: StructureOverridePerZone) => void;
}
/** Apply `slot_order` override to the discovered slot list. Unknown
* override entries are dropped; missing discovered keys are appended in
* backend order so the user never loses a slot by partial-override. */
export function resolveEffectiveSlotOrder(
slotKeys: ReadonlyArray<string>,
slotOrder?: ReadonlyArray<string> | null,
): string[] {
if (!slotOrder || slotOrder.length === 0) return [...slotKeys];
const allowed = new Set(slotKeys);
const seen = new Set<string>();
const ordered: string[] = [];
for (const k of slotOrder) {
if (typeof k === "string" && allowed.has(k) && !seen.has(k)) {
ordered.push(k);
seen.add(k);
}
}
for (const k of slotKeys) {
if (!seen.has(k)) ordered.push(k);
}
return ordered;
}
/** Move `arr[index]` by `delta` positions. Out-of-range returns a fresh
* copy of the input (defensive: caller can always treat the result as a
* new reference). */
export function moveItem<T>(
arr: ReadonlyArray<T>,
index: number,
delta: number,
): T[] {
const next = arr.slice();
const target = index + delta;
if (
index < 0 ||
index >= next.length ||
target < 0 ||
target >= next.length
) {
return next;
}
const tmp = next[index];
next[index] = next[target];
next[target] = tmp;
return next;
}
export default function StructureEditOverlay({
zoneId,
slotKeys,
current,
onChange,
}: StructureEditOverlayProps) {
const effective = resolveEffectiveSlotOrder(slotKeys, current?.slot_order);
const hidden = new Set(current?.hidden_slots ?? []);
const emit = (nextOrder: string[], nextHidden: Set<string>) => {
onChange?.(zoneId, {
slot_order: nextOrder,
hidden_slots: Array.from(nextHidden),
});
};
return (
<div
data-testid={`structure-overlay-${zoneId}`}
className="bg-white/95 border border-emerald-300 rounded shadow p-2 flex flex-col gap-1 text-[10px]"
style={{ pointerEvents: "auto" }}
>
<div className="font-bold uppercase tracking-wider text-emerald-700 mb-1">
{zoneId}
</div>
{effective.length === 0 ? (
<div className="text-slate-400 italic">slot </div>
) : (
effective.map((key, i) => (
<div
key={key}
data-testid={`slot-${zoneId}-${key}`}
className="flex items-center gap-1"
>
<span
className={`flex-1 truncate ${
hidden.has(key) ? "text-slate-400 line-through" : "text-slate-700"
}`}
>
{key}
</span>
<button
type="button"
data-testid={`slot-up-${zoneId}-${key}`}
disabled={i === 0}
onClick={() => emit(moveItem(effective, i, -1), hidden)}
className="px-1 rounded border border-slate-200 disabled:opacity-30 hover:bg-slate-100"
title="위로"
>
</button>
<button
type="button"
data-testid={`slot-down-${zoneId}-${key}`}
disabled={i === effective.length - 1}
onClick={() => emit(moveItem(effective, i, 1), hidden)}
className="px-1 rounded border border-slate-200 disabled:opacity-30 hover:bg-slate-100"
title="아래로"
>
</button>
<button
type="button"
data-testid={`slot-hide-${zoneId}-${key}`}
aria-pressed={hidden.has(key)}
onClick={() => {
const nh = new Set(hidden);
if (nh.has(key)) nh.delete(key);
else nh.add(key);
emit(effective, nh);
}}
className="px-1 rounded border border-slate-200 hover:bg-slate-100"
title={hidden.has(key) ? "표시" : "숨김"}
>
{hidden.has(key) ? "🚫" : "👁"}
</button>
</div>
))
)}
</div>
);
}

View File

@@ -75,6 +75,37 @@ export type ImageOverridesOverride = Record<string, ImageOverride>;
*/
export type ManualSectionAssignmentOverride = boolean;
/**
* IMP-56 #90 u10 — Step-22 text-edit persist axis. Keyed by `zone_id`; the
* inner mapping is `text_path` (= `{slot_key}.{line_index}`) → line value.
* The `text_path` stamp is emitted by `src/text_path_stamper.py` (u8) and
* applied at Step 13 (u9); the value is consumed by `text_override_resolver`
* (u4) and applied at Step 12 (u5). Stale paths (frame swap / layout
* regression between sessions) are tolerated by the backend resolver as
* `skipped`, NOT raised — so the on-disk axis is forward-compat with layout
* and frame churn. Mirrors Python `KNOWN_AXES` entry (u1) and Vite
* `KNOWN_USER_OVERRIDES_AXES` allowlist entry (u3).
*/
export type TextOverridesPerZone = Record<string, string>;
export type TextOverridesOverride = Record<string, TextOverridesPerZone>;
/**
* IMP-56 #90 u10 — Step-22 structure-edit persist axis. Keyed by `zone_id`;
* the inner mapping is SCOPE-LOCKED to `{slot_order, hidden_slots}` — slot
* reorder + slot hide only. Frame swap stays on the existing `frames` axis;
* the `structure_override_resolver` (u6) rejects frame-swap-shaped inner
* keys at the validate gate so Phase Z's no-AI-HTML-structure invariant
* holds across this persisted axis too. Per-slot `list[str]` line content
* is NEVER mutated by the u7 Step-12 apply — that is the `text_overrides`
* axis above. Mirrors Python `KNOWN_AXES` entry (u2) and Vite
* `KNOWN_USER_OVERRIDES_AXES` allowlist entry (u3).
*/
export type StructureOverridePerZone = {
slot_order?: string[];
hidden_slots?: string[];
};
export type StructureOverridesOverride = Record<string, StructureOverridePerZone>;
/** Full on-disk schema. All axes optional — file may carry any subset. */
export interface UserOverrides {
layout: string;
@@ -83,6 +114,8 @@ export interface UserOverrides {
zone_sections: ZoneSectionsOverride;
image_overrides: ImageOverridesOverride;
manual_section_assignment: ManualSectionAssignmentOverride;
text_overrides: TextOverridesOverride;
structure_overrides: StructureOverridesOverride;
}
/** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */

View File

@@ -227,6 +227,15 @@ export interface UserSelection {
// (Front/vite.config.ts), and `ManualSectionAssignmentOverride`
// (services/userOverridesApi.ts).
manual_section_assignment: boolean;
// IMP-56 #90 u10/u15 — Step-22 text + structure persist axes. Mirrors
// services/userOverridesApi.ts (`TextOverridesOverride` /
// `StructureOverridesOverride`). `text_overrides[zoneId][textPath] = value`
// is fed by SlideCanvas u13 focusout capture + Home u15 autosave;
// `structure_overrides[zoneId] = {slot_order, hidden_slots}` is fed by
// u14 overlay + u15 autosave. Both seeded `{}` in createInitialUserSelection
// and restored on reopen via applyPersistedNonFrameOverrides.
text_overrides: Record<string, Record<string, string>>;
structure_overrides: Record<string, { slot_order?: string[]; hidden_slots?: string[] }>;
};
}

View File

@@ -1,5 +1,11 @@
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
import type { UserOverrides } from "../services/userOverridesApi";
import type {
StructureOverridePerZone,
StructureOverridesOverride,
TextOverridesOverride,
TextOverridesPerZone,
UserOverrides,
} from "../services/userOverridesApi";
import { computeZonePositions } from "../services/designAgentApi";
// ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ────
@@ -99,9 +105,74 @@ export function applyPersistedNonFrameOverrides(
if (typeof persisted.manual_section_assignment === "boolean") {
next.manual_section_assignment = persisted.manual_section_assignment;
}
// IMP-56 (#90) u15 — layer the two Step-22 persist axes through the
// u10 extract helpers; their `_isPlainObject` + dedupe gates already
// sanitize foreign / hand-edited payloads, so reopen never poisons
// memory with non-string values or non-list slot_order entries.
next.text_overrides = extractPersistedTextOverrides(persisted);
next.structure_overrides = extractPersistedStructureOverrides(persisted);
return { ...selection, overrides: next };
}
// ─── IMP-56 #90 u10 — typed extract helpers for the two new persist axes ───
// Pure helpers that defensively sanitize Step-22 text_overrides and
// structure_overrides payloads off a `Partial<UserOverrides>` (typed by u10's
// userOverridesApi extension). They mirror the backend validation gates
// (`text_override_resolver` u4 / `structure_override_resolver` u6) on the
// frontend so a hand-edited or schema-drift payload cannot poison memory.
// Layering onto `UserSelection.overrides` arrives in u14~u16; until then
// capture / autosave / restore wiring units consume these as typed.
function _isPlainObject(x: unknown): x is Record<string, unknown> {
return !!x && typeof x === "object" && !Array.isArray(x);
}
function _dedupeStringList(arr: unknown): string[] {
if (!Array.isArray(arr)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const k of arr) {
if (typeof k === "string" && k.length > 0 && !seen.has(k)) {
seen.add(k);
out.push(k);
}
}
return out;
}
export function extractPersistedTextOverrides(
persisted: Partial<UserOverrides> | null | undefined,
): TextOverridesOverride {
const raw = persisted?.text_overrides;
if (!_isPlainObject(raw)) return {};
const out: TextOverridesOverride = {};
for (const [zoneId, perZone] of Object.entries(raw)) {
if (!zoneId || !_isPlainObject(perZone)) continue;
const safe: TextOverridesPerZone = {};
for (const [textPath, value] of Object.entries(perZone)) {
if (textPath && typeof value === "string") safe[textPath] = value;
}
out[zoneId] = safe;
}
return out;
}
export function extractPersistedStructureOverrides(
persisted: Partial<UserOverrides> | null | undefined,
): StructureOverridesOverride {
const raw = persisted?.structure_overrides;
if (!_isPlainObject(raw)) return {};
const out: StructureOverridesOverride = {};
for (const [zoneId, perZone] of Object.entries(raw)) {
if (!zoneId || !_isPlainObject(perZone)) continue;
const safe: StructureOverridePerZone = {};
if (Array.isArray(perZone.slot_order)) safe.slot_order = _dedupeStringList(perZone.slot_order);
if (Array.isArray(perZone.hidden_slots)) safe.hidden_slots = _dedupeStringList(perZone.hidden_slots);
out[zoneId] = safe;
}
return out;
}
/**
* Remap persisted frames (`unit_id` → template_id) to the in-memory
* `zone_frames` (region.id → template_id) using the freshly built
@@ -174,6 +245,12 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
// here via `saveImageOverride` (SlideCanvas drag/resize handler) and
// are seeded on reopen via `applyPersistedNonFrameOverrides`.
image_overrides: {},
// IMP-56 (#90) u15 — Step-22 axes seeded empty. Entries land here
// via `saveTextOverride` (u13 focusout capture) and
// `saveStructureOverride` (u14 overlay) and are restored on reopen
// via `applyPersistedNonFrameOverrides`.
text_overrides: {},
structure_overrides: {},
// IMP-55 (#93) u3 — bool intent marker seeded `false` so a fresh
// MDX open (no persisted file, or persisted file with axis absent)
// never forwards `overrides.zoneSections` to the backend. The marker
@@ -229,6 +306,60 @@ export function saveImageOverride(
};
}
/**
* IMP-56 (#90) u15 — record a single text-line capture (zone_id, text_path,
* value) onto the in-memory selection's `text_overrides` axis. Mirrors
* `saveImageOverride` (pure / immutable). u13's focusout capture emits one
* entry per finished edit; Home u15's handler funnels each emit through this
* helper before scheduling the debounced PUT (`saveUserOverrides` 300ms).
*/
export function saveTextOverride(
selection: UserSelection,
zoneId: string,
textPath: string,
value: string,
): UserSelection {
const prevZone = selection.overrides.text_overrides[zoneId] ?? {};
return {
...selection,
overrides: {
...selection.overrides,
text_overrides: {
...selection.overrides.text_overrides,
[zoneId]: { ...prevZone, [textPath]: value },
},
},
};
}
/**
* IMP-56 (#90) u15 — record a single structure capture (zone_id ↦
* {slot_order, hidden_slots}) onto the in-memory selection's
* `structure_overrides` axis. Scope-locked to slot reorder + hide (frame
* swap stays on the `frames` axis). u14's overlay emits one entry per
* user mutation; Home u15's handler funnels each emit through this
* helper before scheduling the debounced PUT.
*/
export function saveStructureOverride(
selection: UserSelection,
zoneId: string,
perZone: StructureOverridePerZone,
): UserSelection {
return {
...selection,
overrides: {
...selection.overrides,
structure_overrides: {
...selection.overrides.structure_overrides,
[zoneId]: {
...(perZone.slot_order !== undefined && { slot_order: [...perZone.slot_order] }),
...(perZone.hidden_slots !== undefined && { hidden_slots: [...perZone.hidden_slots] }),
},
},
},
};
}
export function saveZoneSizes(selection: UserSelection, groupId: string, sizes: number[]): UserSelection {
return {
...selection,

View File

@@ -0,0 +1,282 @@
// IMP-56 (#90) u18 — vitest coverage for the vite POST /api/connect
// middleware and its supporting mirrorDirRecursive helper.
//
// Scope:
// 1) mirrorDirRecursive (pure helper):
// - absent src → returns 0 (no-throw, no dst creation).
// - file-only src → flat copy + count.
// - nested src → recursive copy + count.
// - overwrites pre-existing dst files (cel mirror semantics).
// 2) handleConnectMirror (POST):
// - method != POST → false (chain continues; next middleware may handle).
// - invalid JSON / non-object body → 400.
// - missing run_id or slug → 400.
// - invalid run_id or slug (key gate / path traversal) → 400.
// - final.html missing → 404.
// - success without run-assets dir → 200, assets_copied: 0, html copy ok.
// - success with run-assets dir → 200, assets_copied = file count, dst dir
// populated.
// - dstSlidesDir auto-created when celRoot/public/slides missing.
//
// Tests exercise the pure handler with mock req/res — no real vite server.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
handleConnectMirror,
mirrorDirRecursive,
} from "../../vite.config";
function makeMockRes() {
const state = {
statusCode: 0,
headers: {} as Record<string, string>,
body: "",
ended: false,
};
return {
state,
res: {
writeHead(status: number, headers?: Record<string, string>) {
state.statusCode = status;
if (headers) state.headers = headers;
},
end(body?: string) {
state.body = body ?? "";
state.ended = true;
},
},
};
}
function makeMockReq(opts: {
method?: string;
}): EventEmitter & { method?: string; send: (body: string) => void } {
const ee = new EventEmitter() as EventEmitter & {
method?: string;
send: (body: string) => void;
};
ee.method = opts.method;
ee.send = (body: string) => {
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
ee.emit("end");
};
return ee;
}
function seedRun(daRoot: string, runId: string, htmlBody: string): string {
const runDir = path.join(daRoot, "data", "runs", runId, "phase_z2");
fs.mkdirSync(runDir, { recursive: true });
const html = path.join(runDir, "final.html");
fs.writeFileSync(html, htmlBody, "utf-8");
return runDir;
}
describe("mirrorDirRecursive (IMP-56 #90 u18)", () => {
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-mirror-"));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("returns 0 and does not throw when src absent", () => {
const dst = path.join(tmp, "dst");
const n = mirrorDirRecursive(path.join(tmp, "missing"), dst);
expect(n).toBe(0);
expect(fs.existsSync(dst)).toBe(false);
});
it("returns 0 when src exists but is a file (not a directory)", () => {
const srcFile = path.join(tmp, "src.txt");
fs.writeFileSync(srcFile, "x", "utf-8");
const dst = path.join(tmp, "dst");
const n = mirrorDirRecursive(srcFile, dst);
expect(n).toBe(0);
expect(fs.existsSync(dst)).toBe(false);
});
it("flat-copies file entries and returns the file count", () => {
const src = path.join(tmp, "src");
fs.mkdirSync(src);
fs.writeFileSync(path.join(src, "a.css"), "/*a*/", "utf-8");
fs.writeFileSync(path.join(src, "b.png"), "PNG", "utf-8");
const dst = path.join(tmp, "dst");
const n = mirrorDirRecursive(src, dst);
expect(n).toBe(2);
expect(fs.readFileSync(path.join(dst, "a.css"), "utf-8")).toBe("/*a*/");
expect(fs.readFileSync(path.join(dst, "b.png"), "utf-8")).toBe("PNG");
});
it("recurses into nested directories and counts only files", () => {
const src = path.join(tmp, "src");
fs.mkdirSync(path.join(src, "nested", "deep"), { recursive: true });
fs.writeFileSync(path.join(src, "root.txt"), "r", "utf-8");
fs.writeFileSync(path.join(src, "nested", "n.txt"), "n", "utf-8");
fs.writeFileSync(path.join(src, "nested", "deep", "d.txt"), "d", "utf-8");
const dst = path.join(tmp, "dst");
const n = mirrorDirRecursive(src, dst);
expect(n).toBe(3);
expect(fs.readFileSync(path.join(dst, "nested", "deep", "d.txt"), "utf-8"))
.toBe("d");
});
it("overwrites pre-existing files in dst (cel mirror semantics)", () => {
const src = path.join(tmp, "src");
fs.mkdirSync(src);
fs.writeFileSync(path.join(src, "a.css"), "NEW", "utf-8");
const dst = path.join(tmp, "dst");
fs.mkdirSync(dst);
fs.writeFileSync(path.join(dst, "a.css"), "OLD", "utf-8");
mirrorDirRecursive(src, dst);
expect(fs.readFileSync(path.join(dst, "a.css"), "utf-8")).toBe("NEW");
});
});
describe("handleConnectMirror (IMP-56 #90 u18)", () => {
let daRoot: string;
let celRoot: string;
beforeEach(() => {
daRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-da-"));
celRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-cel-"));
});
afterEach(() => {
fs.rmSync(daRoot, { recursive: true, force: true });
fs.rmSync(celRoot, { recursive: true, force: true });
});
it("returns false (next chained) when method != POST", () => {
const req = makeMockReq({ method: "GET" });
const { res, state } = makeMockRes();
const handled = handleConnectMirror(req, res, daRoot, celRoot);
expect(handled).toBe(false);
expect(state.ended).toBe(false);
});
it("returns 400 on invalid JSON body", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
const handled = handleConnectMirror(req, res, daRoot, celRoot);
expect(handled).toBe(true);
req.send("{not-json}");
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("invalid JSON");
});
it("returns 400 when body is not a JSON object (array root)", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify(["not", "an", "object"]));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("body must be a JSON object");
});
it("returns 400 when run_id or slug is missing", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "abc" })); // slug missing
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("missing run_id or slug");
});
it("returns 400 when run_id contains path traversal", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "../escape", slug: "03" }));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("invalid run_id or slug");
});
it("returns 400 when slug contains a forward slash", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "valid_id", slug: "03/etc" }));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("invalid run_id or slug");
});
it("returns 404 when final.html does not exist for run_id", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "ghost_run", slug: "03" }));
expect(state.statusCode).toBe(404);
expect(JSON.parse(state.body).error).toBe("final.html not found");
});
it("copies final.html to cel/public/slides/<slug>.html on success", () => {
seedRun(daRoot, "mdx03_run", "<html>03</html>");
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "mdx03_run", slug: "03" }));
expect(state.statusCode).toBe(200);
const dstHtml = path.join(celRoot, "public", "slides", "03.html");
expect(fs.existsSync(dstHtml)).toBe(true);
expect(fs.readFileSync(dstHtml, "utf-8")).toBe("<html>03</html>");
const body = JSON.parse(state.body);
expect(body.success).toBe(true);
expect(body.run_id).toBe("mdx03_run");
expect(body.slug).toBe("03");
expect(body.assets_copied).toBe(0);
expect(body.html_target).toBe(dstHtml);
});
it("auto-creates cel/public/slides when missing", () => {
seedRun(daRoot, "mdx04_run", "<html>04</html>");
expect(fs.existsSync(path.join(celRoot, "public", "slides"))).toBe(false);
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "mdx04_run", slug: "04" }));
expect(state.statusCode).toBe(200);
expect(fs.existsSync(path.join(celRoot, "public", "slides", "04.html"))).toBe(true);
});
it("mirrors assets/ recursively when present in the run dir", () => {
const runDir = seedRun(daRoot, "mdx05_run", "<html>05</html>");
fs.mkdirSync(path.join(runDir, "assets", "css"), { recursive: true });
fs.writeFileSync(path.join(runDir, "assets", "main.css"), "*{}", "utf-8");
fs.writeFileSync(path.join(runDir, "assets", "css", "extra.css"), "p{}", "utf-8");
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "mdx05_run", slug: "05" }));
expect(state.statusCode).toBe(200);
expect(JSON.parse(state.body).assets_copied).toBe(2);
expect(fs.readFileSync(path.join(celRoot, "public", "slides", "assets", "main.css"), "utf-8"))
.toBe("*{}");
expect(fs.readFileSync(path.join(celRoot, "public", "slides", "assets", "css", "extra.css"), "utf-8"))
.toBe("p{}");
});
it("overwrites pre-existing cel slide html (re-Connect semantics)", () => {
seedRun(daRoot, "mdx03_run", "NEW");
const dstSlidesDir = path.join(celRoot, "public", "slides");
fs.mkdirSync(dstSlidesDir, { recursive: true });
fs.writeFileSync(path.join(dstSlidesDir, "03.html"), "OLD", "utf-8");
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "mdx03_run", slug: "03" }));
expect(state.statusCode).toBe(200);
expect(fs.readFileSync(path.join(dstSlidesDir, "03.html"), "utf-8")).toBe("NEW");
});
});

View File

@@ -0,0 +1,219 @@
// IMP-90 (#90) u12 — vitest coverage for `computeEditModeGates`, the pure
// helper that drives SlideCanvas's mutually-exclusive gesture gating.
// u11 introduced the `EditMode` enum + toolbar; u12 splits the prior
// `isEditMode` shim (which fired ALL gates whenever any edit mode was
// active) into 5 per-gate booleans:
// textEditing — designMode + contentEditable (text mode only).
// imageSelection — in-iframe user-content image click listener
// (image-zone mode only).
// iframePointerAuto — iframe pointer-events:auto so in-iframe gestures
// (text caret OR image click) can reach the doc.
// text mode + image-zone mode; structure stays
// pe:none because u14 will overlay React controls.
// zoneGestures — zone resize 8-handle ring + drag perimeter strips
// + canDrag in handleZoneMouseDown
// (image-zone mode only).
// imageOverlay — React-side image edit overlay (image-zone only).
//
// Mutually-exclusive contract (from the issue body's "discriminated edit
// mode"): no editMode value enables both `textEditing` and either
// `imageSelection` or `zoneGestures` simultaneously. structure mode is
// the no-op placeholder — u14 will plant the structure overlay there.
// pendingLayout fully suppresses every gate (mirrors the existing
// useEffect that forces editMode='off' on pendingLayout entry).
//
// Scope guard: this test exercises the pure helper only — no React
// rendering, no DOM. testing-library/react is NOT in devDependencies
// (verified in Front/package.json); helper-level coverage is the
// established u11 pattern.
import { describe, it, expect } from "vitest";
import {
computeEditModeGates,
type EditMode,
type EditModeGates,
} from "../src/components/SlideCanvas";
const ALL_MODES: EditMode[] = ["off", "text", "structure", "image-zone"];
describe("computeEditModeGates (IMP-90 u12) — pendingLayout suppression", () => {
it.each<EditMode>(ALL_MODES)(
"pendingLayout=true forces every gate false (editMode=%s)",
(mode) => {
const g = computeEditModeGates(mode, true);
expect(g).toEqual<EditModeGates>({
textEditing: false,
imageSelection: false,
iframePointerAuto: false,
zoneGestures: false,
imageOverlay: false,
});
}
);
});
describe("computeEditModeGates (IMP-90 u12) — off baseline", () => {
it("editMode=off pendingLayout=false: every gate false", () => {
expect(computeEditModeGates("off", false)).toEqual<EditModeGates>({
textEditing: false,
imageSelection: false,
iframePointerAuto: false,
zoneGestures: false,
imageOverlay: false,
});
});
});
describe("computeEditModeGates (IMP-90 u12) — text mode", () => {
const g = computeEditModeGates("text", false);
it("textEditing = true (designMode + contentEditable activate)", () => {
expect(g.textEditing).toBe(true);
});
it("iframePointerAuto = true (caret needs to reach the doc)", () => {
expect(g.iframePointerAuto).toBe(true);
});
it("imageSelection = false (no in-iframe image click listener)", () => {
expect(g.imageSelection).toBe(false);
});
it("zoneGestures = false (no zone resize / drag affordances)", () => {
expect(g.zoneGestures).toBe(false);
});
it("imageOverlay = false (no React-side image overlay)", () => {
expect(g.imageOverlay).toBe(false);
});
});
describe("computeEditModeGates (IMP-90 u12) — structure mode", () => {
const g = computeEditModeGates("structure", false);
// structure mode is the u14 placeholder — no gestures here yet. All five
// gates stay false so the iframe and React overlays remain quiescent
// until u14 plants the structure overlay on the React layer.
it("every gate false (u14 will plant the structure overlay later)", () => {
expect(g).toEqual<EditModeGates>({
textEditing: false,
imageSelection: false,
iframePointerAuto: false,
zoneGestures: false,
imageOverlay: false,
});
});
});
describe("computeEditModeGates (IMP-90 u12) — image-zone mode", () => {
const g = computeEditModeGates("image-zone", false);
it("textEditing = false (contentEditable would steal image clicks)", () => {
expect(g.textEditing).toBe(false);
});
it("imageSelection = true (in-iframe img click → selectedImageId)", () => {
expect(g.imageSelection).toBe(true);
});
it("iframePointerAuto = true (so image clicks reach the doc)", () => {
expect(g.iframePointerAuto).toBe(true);
});
it("zoneGestures = true (zone resize + drag affordances visible)", () => {
expect(g.zoneGestures).toBe(true);
});
it("imageOverlay = true (React-side overlay renders the drag handles)", () => {
expect(g.imageOverlay).toBe(true);
});
});
describe("computeEditModeGates (IMP-90 u12) — mutually exclusive contract", () => {
it("text mode never co-activates image-zone gates (imageSelection / zoneGestures / imageOverlay)", () => {
const g = computeEditModeGates("text", false);
expect(g.textEditing).toBe(true);
expect(g.imageSelection).toBe(false);
expect(g.zoneGestures).toBe(false);
expect(g.imageOverlay).toBe(false);
});
it("image-zone mode never co-activates text gates (textEditing)", () => {
const g = computeEditModeGates("image-zone", false);
expect(g.imageSelection).toBe(true);
expect(g.textEditing).toBe(false);
});
it.each<EditMode>(ALL_MODES)(
"for every editMode (%s), textEditing AND zoneGestures are NEVER both true",
(mode) => {
const g = computeEditModeGates(mode, false);
expect(g.textEditing && g.zoneGestures).toBe(false);
}
);
it.each<EditMode>(ALL_MODES)(
"for every editMode (%s), textEditing AND imageOverlay are NEVER both true",
(mode) => {
const g = computeEditModeGates(mode, false);
expect(g.textEditing && g.imageOverlay).toBe(false);
}
);
it.each<EditMode>(ALL_MODES)(
"for every editMode (%s), textEditing AND imageSelection are NEVER both true",
(mode) => {
const g = computeEditModeGates(mode, false);
expect(g.textEditing && g.imageSelection).toBe(false);
}
);
});
describe("computeEditModeGates (IMP-90 u12) — iframePointerAuto coupling", () => {
// pe:auto is the iframe-side prerequisite for ANY in-iframe gesture
// (text caret OR image click). The helper must NOT advertise an
// in-iframe gate as active while pe is none, or those gestures would
// be silently swallowed by the wrapper.
it.each<EditMode>(ALL_MODES)(
"textEditing → iframePointerAuto (editMode=%s)",
(mode) => {
const g = computeEditModeGates(mode, false);
if (g.textEditing) expect(g.iframePointerAuto).toBe(true);
}
);
it.each<EditMode>(ALL_MODES)(
"imageSelection → iframePointerAuto (editMode=%s)",
(mode) => {
const g = computeEditModeGates(mode, false);
if (g.imageSelection) expect(g.iframePointerAuto).toBe(true);
}
);
});
describe("computeEditModeGates (IMP-90 u12) — referential transparency", () => {
it("multiple calls with the same inputs return equal output", () => {
const a = computeEditModeGates("image-zone", false);
const b = computeEditModeGates("image-zone", false);
const c = computeEditModeGates("image-zone", false);
expect(a).toEqual(b);
expect(b).toEqual(c);
});
it("does not mutate captured state across calls (independent invocations)", () => {
const a = computeEditModeGates("text", false);
const _b = computeEditModeGates("image-zone", false);
// a must still reflect text mode after b's call.
expect(a.textEditing).toBe(true);
expect(a.imageSelection).toBe(false);
});
});
describe("computeEditModeGates (IMP-90 u12) — gate truthtable snapshot", () => {
// Snapshot for human-readable inspection — the per-mode flag layout
// is the contract u13 (text capture) and u14 (structure overlay)
// will build against. Any change requires updating both this test
// AND the consuming gates in SlideCanvas.tsx.
it("non-pendingLayout truthtable matches the u12 contract", () => {
const rows = (["off", "text", "structure", "image-zone"] as EditMode[]).map(
(m) => ({ mode: m, ...computeEditModeGates(m, false) })
);
expect(rows).toEqual([
{ mode: "off", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false },
{ mode: "text", textEditing: true, imageSelection: false, iframePointerAuto: true, zoneGestures: false, imageOverlay: false },
{ mode: "structure", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false },
{ mode: "image-zone", textEditing: false, imageSelection: true, iframePointerAuto: true, zoneGestures: true, imageOverlay: true },
]);
});
});

View File

@@ -0,0 +1,133 @@
// IMP-90 (#90) u11 — vitest coverage for the discriminated EditMode enum
// and its pure transition helper `nextEditMode`. Replaces the prior single
// `isEditMode` boolean state. u11 introduces ONLY the state surface + the
// toolbar UI; gesture gating per mode is u12 (mutually exclusive) and must
// not regress this contract.
//
// Scope (Stage 2 unit u11 contract):
// 1) EDIT_MODES is the canonical ['text','structure','image-zone'] list
// in toolbar render order. 'off' is intentionally excluded from the
// iterable because it is the implicit baseline (no button); the
// toolbar only renders the three active modes per the u11 design.
// 2) nextEditMode is a pure (current, requested) -> EditMode mapping
// with three rules:
// - requested === 'off' -> 'off' (explicit exit)
// - requested === current -> 'off' (toggle exit)
// - requested !== current && != 'off'-> requested (mode switch)
// 3) The helper is referentially transparent — no side effects, no
// React, no useState, no DOM. SlideCanvas wires it as the useState
// updater callback (`setEditMode((prev) => nextEditMode(prev, m))`),
// so covering the helper here covers every toolbar click outcome
// directly without DOM rendering. (@testing-library/react is NOT in
// devDependencies; this mirrors the imp47b_human_review_toast pattern.)
// 4) The exported EditMode type union must contain exactly the four
// members 'off' | 'text' | 'structure' | 'image-zone'. The runtime
// EDIT_MODES list intentionally excludes 'off' (see (1) above).
//
// Forward-compat note: u12 will discriminate per-mode gating but MUST NOT
// alter the (current, requested) -> next contract verified here. Any
// change to the toggle/switch/exit semantics is a scope-violation against
// the u11 binding contract.
import { describe, it, expect } from "vitest";
import {
EDIT_MODES,
nextEditMode,
type EditMode,
} from "../src/components/SlideCanvas";
describe("EDIT_MODES (IMP-90 u11 — toolbar render order)", () => {
it("contains exactly the three active modes in toolbar order", () => {
expect(EDIT_MODES).toEqual(["text", "structure", "image-zone"]);
});
it("excludes 'off' — baseline is implicit, no toolbar button", () => {
expect(EDIT_MODES).not.toContain("off" as EditMode);
});
it("has length 3", () => {
expect(EDIT_MODES.length).toBe(3);
});
});
describe("nextEditMode (IMP-90 u11 — pure transition helper)", () => {
describe("explicit 'off' request always exits", () => {
it.each<EditMode>(["off", "text", "structure", "image-zone"])(
"current=%s, requested=off -> off",
(current) => {
expect(nextEditMode(current, "off")).toBe("off");
}
);
});
describe("clicking the active mode toggles back to 'off'", () => {
it.each<EditMode>(["text", "structure", "image-zone"])(
"current=%s, requested=%s -> off",
(mode) => {
expect(nextEditMode(mode, mode)).toBe("off");
}
);
});
describe("clicking a different mode switches", () => {
const cases: Array<[EditMode, EditMode]> = [
["off", "text"],
["off", "structure"],
["off", "image-zone"],
["text", "structure"],
["text", "image-zone"],
["structure", "text"],
["structure", "image-zone"],
["image-zone", "text"],
["image-zone", "structure"],
];
it.each(cases)("current=%s, requested=%s -> requested", (current, requested) => {
expect(nextEditMode(current, requested)).toBe(requested);
});
});
it("is referentially transparent — multiple calls with same inputs return same output", () => {
const a = nextEditMode("text", "structure");
const b = nextEditMode("text", "structure");
const c = nextEditMode("text", "structure");
expect(a).toBe("structure");
expect(b).toBe("structure");
expect(c).toBe("structure");
});
it("never returns a value outside the EditMode union", () => {
const all: EditMode[] = ["off", "text", "structure", "image-zone"];
for (const current of all) {
for (const requested of all) {
const result = nextEditMode(current, requested);
expect(all).toContain(result);
}
}
});
it("preserves toggle semantics under repeated identical clicks", () => {
// off -> text -> off -> text -> off (toggle behavior)
let m: EditMode = "off";
m = nextEditMode(m, "text");
expect(m).toBe("text");
m = nextEditMode(m, "text");
expect(m).toBe("off");
m = nextEditMode(m, "text");
expect(m).toBe("text");
m = nextEditMode(m, "text");
expect(m).toBe("off");
});
it("preserves switch semantics across distinct mode clicks", () => {
// off -> text -> structure -> image-zone -> off (via toggle)
let m: EditMode = "off";
m = nextEditMode(m, "text");
expect(m).toBe("text");
m = nextEditMode(m, "structure");
expect(m).toBe("structure");
m = nextEditMode(m, "image-zone");
expect(m).toBe("image-zone");
m = nextEditMode(m, "image-zone");
expect(m).toBe("off");
});
});

View File

@@ -0,0 +1,255 @@
// IMP-56 (#90) u19 — vitest coverage for the vite POST /api/export
// middleware and its supporting inlineAssetsAsDataUrls helper.
//
// Scope:
// 1) inlineAssetsAsDataUrls (pure helper):
// - no url(assets/...) refs → passthrough.
// - single PNG ref → inlined as base64 data: URL with image/png mime.
// - multiple refs → all inlined.
// - SVG ref → image/svg+xml mime.
// - missing asset file → left as-is (no throw, no rewrite).
// - data:/http:/ URLs (non-asset) → untouched.
// 2) handleExportStandalone (POST):
// - method != POST → false (chain continues; next middleware may handle).
// - invalid JSON / non-object body → 400.
// - missing run_id → 400.
// - invalid run_id (key gate / path traversal) → 400.
// - final.html missing → 404.
// - success → 200 with Content-Disposition: attachment; filename=...,
// Content-Type: text/html; charset=utf-8, body = inlined HTML.
//
// Tests exercise the pure handler with mock req/res — no real vite server.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
handleExportStandalone,
inlineAssetsAsDataUrls,
} from "../../vite.config";
function makeMockRes() {
const state = {
statusCode: 0,
headers: {} as Record<string, string>,
body: "",
ended: false,
};
return {
state,
res: {
writeHead(status: number, headers?: Record<string, string>) {
state.statusCode = status;
if (headers) state.headers = headers;
},
end(body?: string) {
state.body = body ?? "";
state.ended = true;
},
},
};
}
function makeMockReq(opts: {
method?: string;
}): EventEmitter & { method?: string; send: (body: string) => void } {
const ee = new EventEmitter() as EventEmitter & {
method?: string;
send: (body: string) => void;
};
ee.method = opts.method;
ee.send = (body: string) => {
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
ee.emit("end");
};
return ee;
}
function seedRun(
daRoot: string,
runId: string,
htmlBody: string,
assets?: Record<string, Buffer | string>,
): string {
const runDir = path.join(daRoot, "data", "runs", runId, "phase_z2");
fs.mkdirSync(runDir, { recursive: true });
const html = path.join(runDir, "final.html");
fs.writeFileSync(html, htmlBody, "utf-8");
if (assets) {
for (const [rel, buf] of Object.entries(assets)) {
const dst = path.join(runDir, "assets", rel);
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.writeFileSync(dst, buf);
}
}
return runDir;
}
describe("inlineAssetsAsDataUrls (IMP-56 #90 u19)", () => {
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-inline-"));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("returns html unchanged when no url(assets/...) refs are present", () => {
const html = "<html><style>body{color:red;}</style><body>hi</body></html>";
expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html);
});
it("inlines a single PNG asset as a base64 data: URL with image/png mime", () => {
fs.mkdirSync(path.join(tmp, "frame_x"), { recursive: true });
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
fs.writeFileSync(path.join(tmp, "frame_x", "a.png"), pngBytes);
const html = "background: url(assets/frame_x/a.png);";
const out = inlineAssetsAsDataUrls(html, tmp);
expect(out).toContain(`url("data:image/png;base64,${pngBytes.toString("base64")}")`);
expect(out).not.toContain("url(assets/frame_x/a.png)");
});
it("inlines multiple refs across the same HTML body", () => {
fs.mkdirSync(path.join(tmp, "f"), { recursive: true });
fs.writeFileSync(path.join(tmp, "f", "one.png"), Buffer.from("ONE"));
fs.writeFileSync(path.join(tmp, "f", "two.png"), Buffer.from("TWO"));
const html = "a{background:url(assets/f/one.png)} b{background:url(assets/f/two.png)}";
const out = inlineAssetsAsDataUrls(html, tmp);
expect(out).toContain(`data:image/png;base64,${Buffer.from("ONE").toString("base64")}`);
expect(out).toContain(`data:image/png;base64,${Buffer.from("TWO").toString("base64")}`);
});
it("uses image/svg+xml mime for .svg refs", () => {
fs.mkdirSync(path.join(tmp, "f"), { recursive: true });
fs.writeFileSync(path.join(tmp, "f", "icon.svg"), "<svg/>", "utf-8");
const html = "url(assets/f/icon.svg)";
const out = inlineAssetsAsDataUrls(html, tmp);
expect(out).toContain("data:image/svg+xml;base64,");
});
it("leaves the ref untouched when the asset file is missing", () => {
const html = "url(assets/missing/file.png)";
const out = inlineAssetsAsDataUrls(html, tmp);
expect(out).toBe(html);
});
it("does not touch data: or http(s): url() values (only matches assets/...)", () => {
const html =
"x{background:url(data:image/png;base64,AAA)} " +
"y{background:url(https://cdn.x/a.png)}";
expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html);
});
it("handles quoted url(...) refs (single and double quotes)", () => {
fs.mkdirSync(path.join(tmp, "q"), { recursive: true });
fs.writeFileSync(path.join(tmp, "q", "k.png"), Buffer.from("K"));
const html =
"a{background:url('assets/q/k.png')} b{background:url(\"assets/q/k.png\")}";
const out = inlineAssetsAsDataUrls(html, tmp);
const data = `data:image/png;base64,${Buffer.from("K").toString("base64")}`;
expect(out.split(data).length - 1).toBe(2);
});
});
describe("handleExportStandalone (IMP-56 #90 u19)", () => {
let daRoot: string;
beforeEach(() => {
daRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-da-"));
});
afterEach(() => {
fs.rmSync(daRoot, { recursive: true, force: true });
});
it("returns false (next chained) when method != POST", () => {
const req = makeMockReq({ method: "GET" });
const { res, state } = makeMockRes();
const handled = handleExportStandalone(req, res, daRoot);
expect(handled).toBe(false);
expect(state.ended).toBe(false);
});
it("returns 400 on invalid JSON body", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
const handled = handleExportStandalone(req, res, daRoot);
expect(handled).toBe(true);
req.send("{nope");
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("invalid JSON");
});
it("returns 400 when body is not a JSON object (array root)", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify(["x"]));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("body must be a JSON object");
});
it("returns 400 when run_id is missing", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify({}));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("missing run_id");
});
it("returns 400 when run_id contains path traversal", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify({ run_id: "../escape" }));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("invalid run_id");
});
it("returns 404 when final.html does not exist for run_id", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify({ run_id: "ghost_run" }));
expect(state.statusCode).toBe(404);
expect(JSON.parse(state.body).error).toBe("final.html not found");
});
it("returns 200 with text/html body + Content-Disposition on success", () => {
seedRun(daRoot, "mdx03_run", "<html><body>03</body></html>");
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify({ run_id: "mdx03_run" }));
expect(state.statusCode).toBe(200);
expect(state.headers["Content-Type"]).toBe("text/html; charset=utf-8");
expect(state.headers["Content-Disposition"]).toBe(
'attachment; filename="mdx03_run.html"',
);
expect(state.body).toBe("<html><body>03</body></html>");
});
it("inlines assets in final.html when run dir has assets/", () => {
const pngBytes = Buffer.from("PNGDATA");
seedRun(
daRoot,
"mdx05_run",
"<html><body><div style=\"background: url(assets/f/x.png)\"></div></body></html>",
{ "f/x.png": pngBytes },
);
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify({ run_id: "mdx05_run" }));
expect(state.statusCode).toBe(200);
expect(state.body).toContain(
`data:image/png;base64,${pngBytes.toString("base64")}`,
);
expect(state.body).not.toContain("url(assets/f/x.png)");
});
});

View File

@@ -0,0 +1,150 @@
// IMP-90 (#90) u14 — vitest coverage for the pure helpers exported by
// `StructureEditOverlay`. The React component itself is not rendered
// (jsdom / @testing-library NOT in Front devDependencies — verified in
// `Front/package.json`); we test the deterministic pieces that drive its
// JSX: `resolveEffectiveSlotOrder` (effective-order resolution under
// override) and `moveItem` (immutable reorder primitive).
//
// Upstream / downstream contracts (verified by prior units):
// - u2 KNOWN_AXES += structure_overrides (Python backend).
// - u3 vite allowlist += structure_overrides.
// - u6 structure_override_resolver — inner shape locked to
// {slot_order, hidden_slots}; frame swap REJECTED to existing
// frames axis.
// - u10 typed-client `StructureOverridePerZone` + extract helper.
// - u15 (next) will debounce + PUT the emitted capture.
//
// u14 scope: pure helpers only. React render path is verified by Codex
// auditor via static read of the JSX (no runtime test possible without
// jsdom). Tests below are intentionally side-effect-free.
import { describe, it, expect } from "vitest";
import {
resolveEffectiveSlotOrder,
moveItem,
} from "../src/components/StructureEditOverlay";
// ─────────────────────────────────────────────────────────────────────
// resolveEffectiveSlotOrder
// ─────────────────────────────────────────────────────────────────────
describe("resolveEffectiveSlotOrder — no override", () => {
it("returns a fresh copy of the discovered keys when slotOrder is undefined", () => {
const discovered = ["a", "b", "c"];
const out = resolveEffectiveSlotOrder(discovered, undefined);
expect(out).toEqual(["a", "b", "c"]);
expect(out).not.toBe(discovered);
});
it("returns a fresh copy when slotOrder is null", () => {
const out = resolveEffectiveSlotOrder(["a", "b"], null);
expect(out).toEqual(["a", "b"]);
});
it("returns a fresh copy when slotOrder is empty []", () => {
const out = resolveEffectiveSlotOrder(["a", "b"], []);
expect(out).toEqual(["a", "b"]);
});
it("handles empty discovered list (no slots in zone)", () => {
expect(resolveEffectiveSlotOrder([], undefined)).toEqual([]);
expect(resolveEffectiveSlotOrder([], ["x"])).toEqual([]);
});
});
describe("resolveEffectiveSlotOrder — full override", () => {
it("reorders all discovered keys per slotOrder", () => {
expect(
resolveEffectiveSlotOrder(["a", "b", "c"], ["c", "a", "b"]),
).toEqual(["c", "a", "b"]);
});
it("is idempotent when slotOrder matches discovered order", () => {
expect(
resolveEffectiveSlotOrder(["a", "b", "c"], ["a", "b", "c"]),
).toEqual(["a", "b", "c"]);
});
});
describe("resolveEffectiveSlotOrder — partial / drift override", () => {
it("appends missing discovered keys in backend order at the tail", () => {
// user reordered b -> first, but c was added later by backend.
expect(
resolveEffectiveSlotOrder(["a", "b", "c"], ["b", "a"]),
).toEqual(["b", "a", "c"]);
});
it("drops override entries that no longer exist in discovered keys", () => {
// user had slot 'x' before; backend dropped it.
expect(
resolveEffectiveSlotOrder(["a", "b"], ["x", "a", "b"]),
).toEqual(["a", "b"]);
});
it("dedupes duplicate entries within slotOrder", () => {
expect(
resolveEffectiveSlotOrder(["a", "b", "c"], ["a", "a", "b"]),
).toEqual(["a", "b", "c"]);
});
it("dedupe + drop + append all together (stress)", () => {
expect(
resolveEffectiveSlotOrder(
["a", "b", "c", "d"],
["d", "x", "d", "a", "ghost"],
),
).toEqual(["d", "a", "b", "c"]);
});
it("ignores non-string entries in slotOrder", () => {
const bogus = ["a", null as unknown as string, undefined as unknown as string, "b"];
expect(resolveEffectiveSlotOrder(["a", "b"], bogus)).toEqual(["a", "b"]);
});
});
// ─────────────────────────────────────────────────────────────────────
// moveItem
// ─────────────────────────────────────────────────────────────────────
describe("moveItem — happy paths", () => {
it("moves index 0 down by 1 (swap with index 1)", () => {
expect(moveItem(["a", "b", "c"], 0, 1)).toEqual(["b", "a", "c"]);
});
it("moves index 2 up by 1 (swap with index 1)", () => {
expect(moveItem(["a", "b", "c"], 2, -1)).toEqual(["a", "c", "b"]);
});
it("moves across larger delta (swap with target)", () => {
expect(moveItem(["a", "b", "c", "d"], 0, 2)).toEqual(["c", "b", "a", "d"]);
});
});
describe("moveItem — bounds", () => {
it("no-op (fresh copy) when moving first up", () => {
const src = ["a", "b", "c"];
const out = moveItem(src, 0, -1);
expect(out).toEqual(["a", "b", "c"]);
expect(out).not.toBe(src);
});
it("no-op when moving last down", () => {
expect(moveItem(["a", "b", "c"], 2, 1)).toEqual(["a", "b", "c"]);
});
it("no-op when index negative", () => {
expect(moveItem(["a", "b"], -1, 1)).toEqual(["a", "b"]);
});
it("no-op when index past end", () => {
expect(moveItem(["a", "b"], 5, -1)).toEqual(["a", "b"]);
});
it("no-op when target falls out of range from large delta", () => {
expect(moveItem(["a", "b", "c"], 1, 99)).toEqual(["a", "b", "c"]);
});
it("no-op on empty array (any index)", () => {
expect(moveItem<string>([], 0, 1)).toEqual([]);
});
});
describe("moveItem — immutability", () => {
it("never mutates the input array", () => {
const src = ["a", "b", "c"];
moveItem(src, 0, 1);
expect(src).toEqual(["a", "b", "c"]);
});
it("returns a new reference even when no-op", () => {
const src = ["a", "b"];
expect(moveItem(src, 0, -1)).not.toBe(src);
});
it("preserves T-typed values (number array)", () => {
expect(moveItem([1, 2, 3], 0, 1)).toEqual([2, 1, 3]);
});
});

View File

@@ -0,0 +1,259 @@
// IMP-90 (#90) u13 — vitest coverage for `deriveTextEditCapture`, the pure
// helper that resolves a contentEditable focusout target into the
// (zone_id, text_path, value) capture tuple emitted by SlideCanvas.
//
// Upstream contract (verified by prior units):
// - u8 `src/text_path_stamper.py` stamps `data-text-path="{slot_key}.{
// line_index}"` on every rendered text-line opening tag at Step 13.
// - u9 wires the stamper into `render_slide` so the final.html consumed
// by SlideCanvas's iframe carries those attributes.
// - Phase Z slide-base wraps every zone in `.zone[data-zone-position]`
// (verified at SlideCanvas.tsx onLoad measure block).
//
// u13 scope: derive the capture tuple from any descendant of a stamped
// line, OR the stamped line itself. Non-stamped targets (slide-base
// title/footer, decorative spans outside the zone tree) return null so
// the focusout handler silently skips them — never crashes.
//
// Forward-compat note: u15 will debounce + PUT the capture; u15 MUST NOT
// alter the (target) -> {zoneId, textPath, value} | null contract verified
// here. Any change to the resolution semantics is a scope-violation
// against the u13 binding contract.
//
// jsdom is NOT in devDependencies (verified in Front/package.json); this
// test mocks `TextEditCaptureTarget` with structurally-typed objects per
// the established u11/u12 pure-helper pattern.
import { describe, it, expect } from "vitest";
import {
deriveTextEditCapture,
type TextEditCapture,
type TextEditCaptureTarget,
} from "../src/components/SlideCanvas";
// --- minimal closest-aware mock builders -----------------------------
// Each node only needs to know which selectors it matches and its
// parent chain — `closest` is implemented by walking parent pointers.
interface MockNodeSpec {
matches: string[];
attrs?: Record<string, string>;
text?: string | null;
parent?: MockNode | null;
}
interface MockNode extends TextEditCaptureTarget {
matches(sel: string): boolean;
parent: MockNode | null;
}
function makeNode(spec: MockNodeSpec): MockNode {
const node: MockNode = {
parent: spec.parent ?? null,
matches(sel: string) {
return spec.matches.includes(sel);
},
closest(sel: string): TextEditCaptureTarget | null {
let cur: MockNode | null = node;
while (cur) {
if (cur.matches(sel)) return cur;
cur = cur.parent;
}
return null;
},
getAttribute(name: string): string | null {
return spec.attrs?.[name] ?? null;
},
textContent: spec.text === undefined ? null : spec.text,
};
return node;
}
// Canonical zone + line scaffold used across happy-path tests.
// `null` for any field is preserved verbatim so edge cases (missing attr /
// null textContent) can exercise the helper's defensive branches.
function makeZoneLineScaffold(opts: {
zoneId?: string | null;
textPath?: string | null;
lineText?: string | null;
}) {
const zone = makeNode({
matches: [".zone[data-zone-position]"],
attrs: opts.zoneId === null ? {} : { "data-zone-position": opts.zoneId ?? "top" },
});
const line = makeNode({
matches: ["[data-text-path]"],
attrs:
opts.textPath === null
? {}
: { "data-text-path": opts.textPath ?? "row_1_left_body.0" },
text: opts.lineText === undefined ? "hello world" : opts.lineText,
parent: zone,
});
return { zone, line };
}
describe("deriveTextEditCapture (IMP-90 u13) — null inputs / non-stamped", () => {
it("returns null when target is null", () => {
expect(deriveTextEditCapture(null)).toBeNull();
});
it("returns null when no ancestor has data-text-path (e.g., slide title)", () => {
const title = makeNode({
matches: [".slide-title"],
text: "Phase Z 슬라이드",
});
expect(deriveTextEditCapture(title)).toBeNull();
});
it("returns null when the stamped line has no enclosing zone", () => {
// Decorative line stamped by the future u8 but rendered outside a
// zone (e.g., footer pill). u13 silently skips — caller never sees
// a half-resolved capture.
const orphanLine = makeNode({
matches: ["[data-text-path]"],
attrs: { "data-text-path": "footer.0" },
text: "결론",
});
expect(deriveTextEditCapture(orphanLine)).toBeNull();
});
});
describe("deriveTextEditCapture (IMP-90 u13) — happy path", () => {
it("resolves (zoneId, textPath, value) when target IS the stamped line", () => {
const { line } = makeZoneLineScaffold({
zoneId: "top",
textPath: "row_1_left_body.0",
lineText: "분석 결과",
});
expect(deriveTextEditCapture(line)).toEqual<TextEditCapture>({
zoneId: "top",
textPath: "row_1_left_body.0",
value: "분석 결과",
});
});
it("walks up to the stamped line when target is a nested descendant", () => {
const { zone, line } = makeZoneLineScaffold({
zoneId: "bottom_l",
textPath: "left_body.2",
lineText: "wrapped",
});
// emulate a SPAN inside the stamped line (e.g., bold inline span)
const innerSpan = makeNode({
matches: ["span.highlight"],
text: "ignored — closest walks to the line",
parent: line,
});
void zone;
expect(deriveTextEditCapture(innerSpan)).toEqual<TextEditCapture>({
zoneId: "bottom_l",
textPath: "left_body.2",
value: "wrapped",
});
});
it("preserves the line's textContent without HTML normalization", () => {
const { line } = makeZoneLineScaffold({
zoneId: "primary",
textPath: "headline.0",
lineText: " spaced inner words ",
});
// u13 trims outer whitespace but does NOT collapse interior whitespace
// — value mirrors what user typed, modulo blur-edge trim.
expect(deriveTextEditCapture(line)?.value).toBe("spaced inner words");
});
it("returns empty string when textContent is null (edge: empty line)", () => {
const { line } = makeZoneLineScaffold({
zoneId: "top",
textPath: "row_1_left_body.0",
lineText: null,
});
expect(deriveTextEditCapture(line)?.value).toBe("");
});
it("returns empty string when textContent is whitespace-only", () => {
const { line } = makeZoneLineScaffold({
zoneId: "top",
textPath: "row_1_left_body.0",
lineText: " \n \t ",
});
expect(deriveTextEditCapture(line)?.value).toBe("");
});
});
describe("deriveTextEditCapture (IMP-90 u13) — missing attribute defensiveness", () => {
it("returns null when data-text-path attribute is absent on the matched line", () => {
// Should not happen with the u8 stamper, but a downstream mutation
// (e.g., user pasting a fresh element) could create a stamped-class
// node without the actual attribute. u13 stays defensive.
const zone = makeNode({
matches: [".zone[data-zone-position]"],
attrs: { "data-zone-position": "top" },
});
const lineNoPath = makeNode({
matches: ["[data-text-path]"],
attrs: {},
text: "hello",
parent: zone,
});
expect(deriveTextEditCapture(lineNoPath)).toBeNull();
});
it("returns null when data-zone-position attribute is absent on the matched zone", () => {
const zoneNoId = makeNode({
matches: [".zone[data-zone-position]"],
attrs: {},
});
const line = makeNode({
matches: ["[data-text-path]"],
attrs: { "data-text-path": "row_1_left_body.0" },
text: "hello",
parent: zoneNoId,
});
expect(deriveTextEditCapture(line)).toBeNull();
});
});
describe("deriveTextEditCapture (IMP-90 u13) — referential transparency", () => {
it("multiple calls with the same target return equal captures", () => {
const { line } = makeZoneLineScaffold({
zoneId: "top",
textPath: "row_1_left_body.0",
lineText: "stable",
});
const a = deriveTextEditCapture(line);
const b = deriveTextEditCapture(line);
expect(a).toEqual(b);
expect(a).not.toBe(b); // fresh objects each call (caller-friendly)
});
it("does not mutate the target element (attrs / parent / textContent unchanged)", () => {
const { line, zone } = makeZoneLineScaffold({
zoneId: "top",
textPath: "row_1_left_body.0",
lineText: "immutable",
});
deriveTextEditCapture(line);
expect(line.getAttribute("data-text-path")).toBe("row_1_left_body.0");
expect(line.textContent).toBe("immutable");
expect(zone.getAttribute("data-zone-position")).toBe("top");
});
});
describe("deriveTextEditCapture (IMP-90 u13) — zone id pass-through", () => {
// u13 does not validate the zone id shape — Phase Z slide-base owns the
// canonical zone position vocabulary, and u15 / pipeline-side resolver
// (u4) re-validate downstream. u13 just forwards whatever the stamped
// DOM declared.
const ZONE_IDS = ["top", "bottom_l", "bottom_r", "primary", "secondary"];
it.each(ZONE_IDS)("preserves zone id '%s' verbatim", (zid) => {
const { line } = makeZoneLineScaffold({
zoneId: zid,
textPath: `${zid}.0`,
lineText: "x",
});
const cap = deriveTextEditCapture(line);
expect(cap?.zoneId).toBe(zid);
expect(cap?.textPath).toBe(`${zid}.0`);
});
});

View File

@@ -305,23 +305,37 @@ describe("handleGetUserOverrides (IMP-52 u3)", () => {
// IMP-52 u4 — PUT endpoint coverage
// ---------------------------------------------------------------------------
describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4)", () => {
describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4 + IMP-56 #90 u3 allowlist sync)", () => {
it("matches the Python KNOWN_AXES tuple in src/user_overrides_io.py", () => {
// The on-disk schema is shared with backend pipeline fallback (u2).
// Any drift here means a PUT could write an axis that the Python
// load() ignores, or vice-versa, silently losing user overrides.
// Mirror is Python-minus-`slide_css` (known IMP-45 #74 gap — the
// frontend never writes slide_css). IMP-55 #93 u1 adds the bool
// `manual_section_assignment` axis as a first-class allowlist entry.
// IMP-56 #90 u3 closes the prior `slide_css` gap (IMP-45 #74) and
// pre-wires `text_overrides` (IMP-56 #90 u1) +
// `structure_overrides` (IMP-56 #90 u2) — full 9-axis mirror of the
// Python tuple, same order.
expect(KNOWN_USER_OVERRIDES_AXES).toEqual([
"layout",
"zone_geometries",
"zone_sections",
"frames",
"image_overrides",
"slide_css",
"manual_section_assignment",
"text_overrides",
"structure_overrides",
]);
});
it("includes the 3 axes added by IMP-56 #90 u3 (allowlist sync)", () => {
// Spot-check the diff in addition to the full-equality assertion so a
// future edit that drops one of the new axes fails with a localized
// error rather than a 9-vs-N tuple-diff that obscures intent.
expect(KNOWN_USER_OVERRIDES_AXES).toContain("slide_css");
expect(KNOWN_USER_OVERRIDES_AXES).toContain("text_overrides");
expect(KNOWN_USER_OVERRIDES_AXES).toContain("structure_overrides");
expect(KNOWN_USER_OVERRIDES_AXES.length).toBe(9);
});
});
describe("mergeUserOverrides (IMP-55 #93 u1) — manual_section_assignment bool axis", () => {

View File

@@ -35,6 +35,8 @@ import {
deriveUserOverridesKey,
remapPersistedFramesToZoneFrames,
saveImageOverride,
saveTextOverride,
saveStructureOverride,
} from "../src/utils/slidePlanUtils";
// ─── Fixtures ───────────────────────────────────────────────────────────────
@@ -59,6 +61,11 @@ function makeSelection(overrides?: Partial<UserSelection["overrides"]>): UserSel
// pre-existing fixture matches the `createInitialUserSelection` seed
// and stays compile-clean after u3 widened the type.
manual_section_assignment: false,
// IMP-56 (#90) u15 — keep the fixture in sync with the two Step-22
// persist axes declared on `UserSelection.overrides`. Empty by
// default so pre-existing cases retain their shape.
text_overrides: {},
structure_overrides: {},
...overrides,
},
};
@@ -550,3 +557,150 @@ describe("manual_section_assignment axis — applyPersistedNonFrameOverrides (IM
expect(next.overrides.manual_section_assignment).toBe(true);
});
});
// ─── IMP-56 (#90) u15 — text_overrides + structure_overrides axes ───────────
// Pure helpers wired by Home.tsx into the SlideCanvas u13 focusout capture
// (text) and u14 structure overlay emit (structure). Tests cover:
// • saveTextOverride / saveStructureOverride immutability + merge semantics
// • createInitialUserSelection seeding the two new axes empty
// • applyPersistedNonFrameOverrides layering via the u10 extract helpers
describe("text_overrides axis — saveTextOverride (IMP-56 u15)", () => {
it("records a fresh (zoneId, textPath, value) tuple", () => {
const sel = makeSelection();
const next = saveTextOverride(sel, "top", "row_1_left_body.0", "분석 결과");
expect(next.overrides.text_overrides).toEqual({
top: { "row_1_left_body.0": "분석 결과" },
});
});
it("merges within the same zone without erasing prior text_paths", () => {
const sel = makeSelection({
text_overrides: { top: { "row_1_left_body.0": "기존" } },
});
const next = saveTextOverride(sel, "top", "row_1_left_body.1", "신규");
expect(next.overrides.text_overrides.top).toEqual({
"row_1_left_body.0": "기존",
"row_1_left_body.1": "신규",
});
});
it("overwrites the same textPath value within a zone", () => {
const sel = makeSelection({
text_overrides: { top: { "headline.0": "v1" } },
});
const next = saveTextOverride(sel, "top", "headline.0", "v2");
expect(next.overrides.text_overrides.top).toEqual({ "headline.0": "v2" });
});
it("does not mutate the input selection (immutable contract)", () => {
const sel = makeSelection({
text_overrides: { top: { "headline.0": "before" } },
});
saveTextOverride(sel, "top", "headline.0", "after");
expect(sel.overrides.text_overrides).toEqual({
top: { "headline.0": "before" },
});
});
it("seeds an empty text_overrides on a fresh selection", () => {
const sel = createInitialUserSelection();
expect(sel.overrides.text_overrides).toEqual({});
});
});
describe("structure_overrides axis — saveStructureOverride (IMP-56 u15)", () => {
it("records a fresh (zoneId → {slot_order, hidden_slots}) tuple", () => {
const sel = makeSelection();
const next = saveStructureOverride(sel, "top", {
slot_order: ["b", "a"],
hidden_slots: ["c"],
});
expect(next.overrides.structure_overrides).toEqual({
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
});
});
it("replaces an existing zone entry verbatim (no merge within zone)", () => {
const sel = makeSelection({
structure_overrides: { top: { slot_order: ["a", "b"], hidden_slots: [] } },
});
const next = saveStructureOverride(sel, "top", {
slot_order: ["b", "a"],
hidden_slots: ["a"],
});
expect(next.overrides.structure_overrides.top).toEqual({
slot_order: ["b", "a"],
hidden_slots: ["a"],
});
});
it("keeps unrelated zones intact when updating one zone", () => {
const sel = makeSelection({
structure_overrides: {
top: { slot_order: ["x"], hidden_slots: [] },
bottom_l: { slot_order: ["y"], hidden_slots: ["z"] },
},
});
const next = saveStructureOverride(sel, "top", {
slot_order: ["x", "x2"],
hidden_slots: [],
});
expect(next.overrides.structure_overrides.bottom_l).toEqual({
slot_order: ["y"],
hidden_slots: ["z"],
});
});
it("does not mutate the input perZone object after save", () => {
const sel = makeSelection();
const perZone = { slot_order: ["a"], hidden_slots: ["b"] };
const next = saveStructureOverride(sel, "top", perZone);
perZone.slot_order.push("MUTATED");
expect(next.overrides.structure_overrides.top.slot_order).toEqual(["a"]);
});
it("seeds an empty structure_overrides on a fresh selection", () => {
const sel = createInitialUserSelection();
expect(sel.overrides.structure_overrides).toEqual({});
});
});
describe("Step-22 axes — applyPersistedNonFrameOverrides restore (IMP-56 u15)", () => {
it("layers persisted text_overrides through the u10 extract helper", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
text_overrides: {
top: { "row_1_left_body.0": "복원" },
},
});
expect(next.overrides.text_overrides).toEqual({
top: { "row_1_left_body.0": "복원" },
});
});
it("layers persisted structure_overrides through the u10 extract helper", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
structure_overrides: {
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
},
});
expect(next.overrides.structure_overrides).toEqual({
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
});
});
it("drops non-object payloads silently (no throw, axis stays empty)", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
text_overrides: "garbage" as unknown as Record<string, Record<string, string>>,
structure_overrides: ["bad"] as unknown as Record<
string,
{ slot_order?: string[]; hidden_slots?: string[] }
>,
});
expect(next.overrides.text_overrides).toEqual({});
expect(next.overrides.structure_overrides).toEqual({});
});
});

View File

@@ -219,25 +219,36 @@ function vitePluginStorageProxy(): Plugin {
export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/;
// The six in-scope axes — mirror of KNOWN_AXES in
// src/user_overrides_io.py minus `slide_css` (known gap, IMP-45 #74 —
// the Python side persists it for backend consumption; the Vite PUT does
// not write it because the frontend never mutates the slide-level CSS
// override). Any payload key outside this allowlist is silently dropped
// by the PUT handler (u4) so the on-disk schema cannot drift from the
// backend pipeline (u2) contract. Foreign top-level keys already on disk
// are preserved verbatim (see mergeUserOverrides).
// The nine in-scope axes — full mirror of KNOWN_AXES in
// src/user_overrides_io.py. Order matches the Python tuple verbatim so
// a side-by-side audit reads as a no-op. Any payload key outside this
// allowlist is silently dropped by the PUT handler (u4) so the on-disk
// schema cannot drift from the backend pipeline (u2) contract. Foreign
// top-level keys already on disk are preserved verbatim (see
// mergeUserOverrides).
// IMP-51 (#79) u2: added `image_overrides` (image_id → {x,y,w,h}
// percent-of-slide coordinates).
// IMP-55 (#93) u1: added `manual_section_assignment` (bool intent marker
// — drag-drop sets true, layout apply/cancel sets false).
// IMP-56 (#90) u3: allowlist sync — closes the prior `slide_css` gap
// (IMP-45 #74; the Step-22 slide CSS edit path will write it from the
// frontend) and pre-wires `text_overrides` (IMP-56 #90 u1, keyed by
// {zone_id: {text_path: value}}) + `structure_overrides` (IMP-56 #90 u2,
// keyed by {zone_id: {slot_order, hidden_slots}} — scope LOCKED to slot
// reorder + hide; frame swap stays on the existing `frames` axis to
// preserve Phase Z's no-AI-HTML-structure invariant) so the Step-22
// capture path (u10~u17) can PUT either axis without a follow-on
// allowlist edit.
export const KNOWN_USER_OVERRIDES_AXES = [
"layout",
"zone_geometries",
"zone_sections",
"frames",
"image_overrides",
"slide_css",
"manual_section_assignment",
"text_overrides",
"structure_overrides",
] as const;
export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number];
@@ -506,6 +517,211 @@ export function handlePutUserOverrides(
return true;
}
// =============================================================================
// IMP-56 (#90) u18 — POST /api/connect : cel astro dev mirror copy.
//
// Body: {"run_id": "<id>", "slug": "<mdx-stem>"}.
// • Copies <DESIGN_AGENT_ROOT>/data/runs/<run_id>/phase_z2/final.html →
// <CEL_PROJECT_ROOT>/public/slides/<slug>.html (overwrite).
// • If <run_dir>/phase_z2/assets/ exists, mirrors its contents into
// <CEL_PROJECT_ROOT>/public/slides/assets/ (overwrite copy, recursive).
// • run_id and slug are validated through the existing
// isValidUserOverridesKey gate so path-traversal payloads are rejected.
// =============================================================================
export function mirrorDirRecursive(srcDir: string, dstDir: string): number {
if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) return 0;
if (!fs.existsSync(dstDir)) fs.mkdirSync(dstDir, { recursive: true });
let count = 0;
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
const srcPath = path.join(srcDir, entry.name);
const dstPath = path.join(dstDir, entry.name);
if (entry.isDirectory()) {
count += mirrorDirRecursive(srcPath, dstPath);
} else if (entry.isFile()) {
fs.copyFileSync(srcPath, dstPath);
count += 1;
}
}
return count;
}
export function handleConnectMirror(
req: PutReqLike,
res: ResLike,
designAgentRoot: string,
celRoot: string,
): boolean {
if (req.method !== "POST") return false;
let body = "";
req.on("data", (chunk: Buffer | string) => {
body += typeof chunk === "string" ? chunk : chunk.toString();
});
req.on("end", () => {
let parsed: unknown;
try {
parsed = body.length > 0 ? JSON.parse(body) : {};
} catch {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid JSON" }));
return;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "body must be a JSON object" }));
return;
}
const { run_id, slug } = parsed as { run_id?: unknown; slug?: unknown };
if (typeof run_id !== "string" || typeof slug !== "string") {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "missing run_id or slug" }));
return;
}
if (!isValidUserOverridesKey(run_id) || !isValidUserOverridesKey(slug)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid run_id or slug" }));
return;
}
const runDir = path.join(designAgentRoot, "data", "runs", run_id, "phase_z2");
const srcHtml = path.join(runDir, "final.html");
if (!fs.existsSync(srcHtml)) {
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "final.html not found" }));
return;
}
const dstSlidesDir = path.join(celRoot, "public", "slides");
if (!fs.existsSync(dstSlidesDir)) fs.mkdirSync(dstSlidesDir, { recursive: true });
const dstHtml = path.join(dstSlidesDir, `${slug}.html`);
try {
fs.copyFileSync(srcHtml, dstHtml);
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: `copy failed: ${String(err)}` }));
return;
}
const assetsCopied = mirrorDirRecursive(
path.join(runDir, "assets"),
path.join(dstSlidesDir, "assets"),
);
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ success: true, run_id, slug, html_target: dstHtml, assets_copied: assetsCopied }));
});
req.on("error", () => {
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "request error" }));
});
return true;
}
// =============================================================================
// IMP-56 (#90) u19 — POST /api/export : standalone HTML download.
//
// Body: {"run_id": "<id>"}.
// • Reads <DESIGN_AGENT_ROOT>/data/runs/<run_id>/phase_z2/final.html.
// • Inlines every `url(assets/<frame>/<file>)` reference (the only
// external dep emitted by the Phase Z2 render path — verified by grep
// against templates/phase_z2/slide_base.html and a representative run)
// as a base64 data URL so the emitted HTML is portable (file:// open
// or any external host, no co-located assets/ dir required). Mirrors
// u18 validation: isValidUserOverridesKey gate for path-traversal
// rejection; final.html missing → 404.
// • Response: 200 text/html with Content-Disposition: attachment so the
// browser triggers a download with `<run_id>.html` filename. Raw HTML
// body (NOT JSON-wrapped) — the BottomActions wiring (u20) will pipe
// the response body straight into a Blob → a[download] click chain
// mirroring the existing serializeSlidePlan JSON download flow.
// =============================================================================
export function inlineAssetsAsDataUrls(html: string, assetsRoot: string): string {
// Match `url(assets/<rel-path>)` (with optional single/double quotes,
// optional surrounding whitespace). The Phase Z2 render path emits
// `url(assets/<frame>/<file>.png)` verbatim into inline `style="..."`
// custom-property declarations (see slide_base.html `--card-frame-bg`
// etc.) — there is no `<link rel="stylesheet">` or `<img src>` external
// ref to handle. Keeping the matcher narrow avoids accidentally
// rewriting `data:` / `http(s):` / sibling-path URLs that the render
// path does not produce.
const URL_RE = /url\(\s*(['"]?)assets\/([^)'"]+)\1\s*\)/g;
return html.replace(URL_RE, (match, _quote: string, rel: string) => {
const filePath = path.join(assetsRoot, rel);
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return match;
const ext = path.extname(filePath).toLowerCase().slice(1);
const mime =
ext === "png" ? "image/png" :
ext === "jpg" || ext === "jpeg" ? "image/jpeg" :
ext === "svg" ? "image/svg+xml" :
ext === "webp" ? "image/webp" :
ext === "gif" ? "image/gif" :
"application/octet-stream";
const buf = fs.readFileSync(filePath);
return `url("data:${mime};base64,${buf.toString("base64")}")`;
});
}
export function handleExportStandalone(
req: PutReqLike,
res: ResLike,
designAgentRoot: string,
): boolean {
if (req.method !== "POST") return false;
let body = "";
req.on("data", (chunk: Buffer | string) => {
body += typeof chunk === "string" ? chunk : chunk.toString();
});
req.on("end", () => {
let parsed: unknown;
try {
parsed = body.length > 0 ? JSON.parse(body) : {};
} catch {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid JSON" }));
return;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "body must be a JSON object" }));
return;
}
const { run_id } = parsed as { run_id?: unknown };
if (typeof run_id !== "string") {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "missing run_id" }));
return;
}
if (!isValidUserOverridesKey(run_id)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid run_id" }));
return;
}
const runDir = path.join(designAgentRoot, "data", "runs", run_id, "phase_z2");
const srcHtml = path.join(runDir, "final.html");
if (!fs.existsSync(srcHtml)) {
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "final.html not found" }));
return;
}
let html: string;
try {
html = fs.readFileSync(srcHtml, "utf-8");
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: `read failed: ${String(err)}` }));
return;
}
const inlined = inlineAssetsAsDataUrls(html, path.join(runDir, "assets"));
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Content-Disposition": `attachment; filename="${run_id}.html"`,
});
res.end(inlined);
});
req.on("error", () => {
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "request error" }));
});
return true;
}
// =============================================================================
// Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출
//
@@ -514,14 +730,19 @@ export function handlePutUserOverrides(
// GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path}
// GET /api/user-overrides/{key} → data/user_overrides/{key}.json (IMP-52 u3)
// PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4)
// POST /api/connect → cel mirror (IMP-56 #90 u18)
// POST /api/export → standalone HTML download (IMP-56 #90 u19)
//
// 환경 변수 (선택) :
// DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent
// CEL_PROJECT_ROOT cel astro dev repo root. default = D:/ad-hoc/cel
// =============================================================================
function vitePluginPhaseZApi(): Plugin {
const DESIGN_AGENT_ROOT =
process.env.DESIGN_AGENT_ROOT || "D:\\ad-hoc\\kei\\design_agent";
const CEL_PROJECT_ROOT =
process.env.CEL_PROJECT_ROOT || "D:\\ad-hoc\\cel";
const UPLOADS_DIR = path.join(DESIGN_AGENT_ROOT, "samples", "uploads");
const RUNS_DIR = path.join(DESIGN_AGENT_ROOT, "data", "runs");
@@ -801,6 +1022,27 @@ function vitePluginPhaseZApi(): Plugin {
next();
});
// ── POST /api/connect → cel astro public/slides mirror ──
// IMP-56 (#90) u18 — see handleConnectMirror docblock for body shape +
// copy semantics. Logic lives in the pure helper so vitest can drive
// it without booting vite.
server.middlewares.use("/api/connect", (req, res, next) => {
if (handleConnectMirror(req, res, DESIGN_AGENT_ROOT, CEL_PROJECT_ROOT)) return;
next();
});
// ── POST /api/export → standalone HTML download ──
// IMP-56 (#90) u19 — see handleExportStandalone docblock for body
// shape + inline-asset semantics. Logic lives in the pure helper
// (handleExportStandalone + inlineAssetsAsDataUrls) so vitest can
// drive it without booting vite. The response is raw text/html
// (Content-Disposition: attachment); the u20 BottomActions wiring
// will turn the response body into a Blob → a[download] click.
server.middlewares.use("/api/export", (req, res, next) => {
if (handleExportStandalone(req, res, DESIGN_AGENT_ROOT)) return;
next();
});
// ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ──
server.middlewares.use("/data/runs", (req, res, next) => {
if (req.method !== "GET") return next();