diff --git a/e2e/tests/header.spec.js b/e2e/tests/header.spec.js
index 6ea2d60a..68a8a6b7 100644
--- a/e2e/tests/header.spec.js
+++ b/e2e/tests/header.spec.js
@@ -163,22 +163,56 @@ test('HDR-5: the "Your diagrams" breadcrumb returns to the hub', async ({
});
// ──────────────────────────────────────────────────────────────────────────────
-// HDR-2: title edit Escape cancels / reverts.
+// HDR-2: title edit Escape cancels / reverts (#826).
//
-// NOT IMPLEMENTED (verified): the header title is a controlled bound to
-// setTitle on every keystroke (AppHeader.tsx → TextInput; editorStore.setTitle). There
-// is no draft snapshot and no Escape/onKeyDown handler, so Escape cannot revert — a
-// live probe typing "TempEscapeTitle" then Escape leaves the field unchanged. Asserting
-// a revert would be asserting behavior the product does not have. Marked fixme until a
-// revert-on-Escape affordance is added (a draft buffer committed on Enter/blur and
-// discarded on Escape).
-test.fixme(
- 'HDR-2: pressing Escape reverts an in-progress title edit',
- async () => {
- // Intentionally empty: the revert behavior does not exist yet (see comment above).
- // Implement once the title field gains a draft buffer + Escape→revert handler.
- },
-);
+// The title field now edits a local DRAFT buffer (AppHeader.tsx): keystrokes update the
+// draft only; Enter/blur COMMIT it via onTitleChange (= editorStore.setTitle); Escape
+// DISCARDS the draft back to the last committed value WITHOUT committing. So:
+// - type a new title, press Escape ⇒ the field reverts to the prior committed value;
+// - then type again and press Enter ⇒ the field commits the new value.
+// Both halves are asserted so this is a real revert-AND-commit check, not a half-test.
+// ──────────────────────────────────────────────────────────────────────────────
+test('HDR-2: pressing Escape reverts an in-progress title edit; Enter commits', async ({
+ page,
+}) => {
+ await gotoFresh(page);
+
+ const title = titleInput(page);
+ await expect(title).toBeVisible();
+ // Starter title is "Untitled" — the committed baseline we expect Escape to restore.
+ await expect(title).toHaveValue('Untitled');
+
+ // Type a throwaway title (draft only — not yet committed).
+ await setTitle(page, 'TempEscapeTitle');
+ await expect(title).toHaveValue('TempEscapeTitle');
+
+ // Escape reverts the draft to the last committed value — no commit happened.
+ await page.keyboard.press('Escape');
+ await expect(title).toHaveValue('Untitled');
+
+ // And Enter still commits a real edit: type a new value, Enter, value persists.
+ const COMMITTED = 'EnterCommitsTitle';
+ await setTitle(page, COMMITTED);
+ await page.keyboard.press('Enter');
+ await expect(title).toHaveValue(COMMITTED);
+
+ // Prove Enter committed into the actual item (not just the field): save + open the hub
+ // and read the persisted card title — the COMMITTED value rode through, the reverted
+ // TempEscapeTitle did not.
+ await page.locator('[data-testid="header-menu"]').click();
+ await page.locator('[data-testid="header-save"]').click();
+ const noticeCancel = page.locator('[data-testid="confirm-cancel"]');
+ await expect(noticeCancel).toBeVisible();
+ await noticeCancel.click();
+ await expect(noticeCancel).toBeHidden();
+
+ await gotoHome(page);
+ await expect(page.locator('[data-testid="home-grid"]')).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.getByText(COMMITTED, { exact: true })).toBeVisible();
+ await expect(page.getByText('TempEscapeTitle', { exact: true })).toHaveCount(0);
+});
// ──────────────────────────────────────────────────────────────────────────────
// HDR-3: an unsaved marker appears on edit and clears after Save.
diff --git a/e2e/tests/preview.spec.js b/e2e/tests/preview.spec.js
index 09674118..55b0ab4a 100644
--- a/e2e/tests/preview.spec.js
+++ b/e2e/tests/preview.spec.js
@@ -256,81 +256,72 @@ test('PRV-4: console eval runs JS in the preview iframe and echoes the result',
});
// ──────────────────────────────────────────────────────────────────────────────
-// PRV-6: With auto-preview OFF, editing the DSL does NOT re-render until a manual
-// refresh (toggling auto-preview back ON re-fires the render — the only
-// manual re-render trigger in the no-rail UI).
+// PRV-6: With auto-preview OFF, editing the DSL does NOT re-render until the manual
+// Refresh control fires (#824).
// ──────────────────────────────────────────────────────────────────────────────
-// PRODUCT BUG (fixme until wired): this test is a FALSE-GREEN. `settings.autoPreview`
-// is never passed to PreviewFrame — the two call sites in AppRoot.tsx (~918 and ~1350)
-// omit `autoPreview={settings.autoPreview}`, so PreviewFrame falls back to its default
-// `autoPreview=true` (PreviewFrame.tsx:47) and ALWAYS re-renders on the 500ms debounce.
-// The Settings toggle is therefore a dead control for rendering: it flips the stored
-// setting but never gates the preview. The old assertions happened to pass only because
-// they fired inside the debounce window (before the always-on re-render landed) — they
-// would pass identically with auto-preview ON, so they prove nothing.
-// To enable this test:
-// 1. Wire the prop: pass `autoPreview={settings.autoPreview}` to PreviewFrame at
-// AppRoot.tsx ~918 and ~1350 so the toggle actually gates rendering.
-// 2. Make the staleness assertion debounce-proof: after editing with auto-preview
-// OFF, wait > PREVIEW_DEBOUNCE (the 500ms PreviewFrame debounce) BEFORE asserting
-// the preview is NOT re-rendered, so a real always-on render would be caught.
-test.fixme(
- 'PRV-6: with auto-preview OFF, editing does not re-render until auto-preview is re-enabled',
- async ({ page }) => {
- // The render path depends on the iframe's srcdoc evaluating @zenuml/core; assert
- // it against the local app (it works the same against any host the app serves,
- // but the render timing is most stable locally — keep parity with smoke/dsl specs
- // which run on both, so no skip needed here).
-
- // Step 1 — establish a known baseline render with a distinctive participant.
- const BASELINE = 'PrvBaseline\nUser\nUser->PrvBaseline: before';
- await typeDsl(page, BASELINE);
- await expect(mountLocator(page)).toContainText('PrvBaseline', {
- timeout: 15_000,
- });
-
- // Step 2 — turn Auto-preview OFF via Settings (the Radix Switch toggles to
- // unchecked). This stops PreviewFrame's debounced re-render effect.
- await openViaHeaderMenu(page, 'header-settings');
- await expect(page.locator('[data-testid="settings-modal"]')).toBeVisible();
- const autoPreviewSwitch = page.locator(
- '[data-testid="setting-autoPreview"]',
- );
- await expect(autoPreviewSwitch).toHaveAttribute('aria-checked', 'true');
- await autoPreviewSwitch.click();
- await expect(autoPreviewSwitch).toHaveAttribute('aria-checked', 'false');
- await page.keyboard.press('Escape');
- await expect(page.locator('[data-testid="settings-modal"]')).toBeHidden();
-
- // Step 3 — edit the DSL to a NEW distinctive participant. With auto-preview off,
- // the preview must stay STALE: still showing the baseline, never the new token.
- const EDITED = 'PrvUpdated\nUser\nUser->PrvUpdated: after';
- await typeDsl(page, EDITED);
- await expect(editorLocator(page)).toContainText('PrvUpdated');
-
- // Give the debounce window time to fire IF it were going to (it must not). Then
- // assert the preview is unchanged: baseline still rendered, new token absent.
- await expect(mountLocator(page)).toContainText('PrvBaseline');
- await expect(mountLocator(page)).not.toContainText('PrvUpdated');
-
- // Step 4 — manual refresh: re-enable Auto-preview. The PreviewFrame effect's dep
- // array includes `autoPreview`, so flipping it back ON re-posts the render and
- // the preview catches up to the edited DSL.
- await openViaHeaderMenu(page, 'header-settings');
- await expect(page.locator('[data-testid="settings-modal"]')).toBeVisible();
- await autoPreviewSwitch.click();
- await expect(autoPreviewSwitch).toHaveAttribute('aria-checked', 'true');
- await page.keyboard.press('Escape');
- await expect(page.locator('[data-testid="settings-modal"]')).toBeHidden();
-
- await expect(mountLocator(page)).toContainText('PrvUpdated', {
- timeout: 15_000,
- });
- await expect(mountLocator(page)).not.toContainText('PrvBaseline', {
- timeout: 15_000,
- });
- },
+// WIRED (#824): `autoPreview={settings.autoPreview}` is now passed to PreviewFrame
+// (AppRoot.tsx), so the Settings toggle gates the debounced re-render. With it OFF, a
+// RendererHeader "Refresh" control (data-testid="renderer-refresh") appears and calls
+// PreviewFrame's imperative render() to re-render on demand. This test is DEBOUNCE-PROOF:
+// after editing with auto-preview OFF it waits LONGER than PREVIEW_DEBOUNCE (+ margin)
+// BEFORE asserting the preview is stale, so a regression to always-on rendering is caught
+// (the old false-green fired inside the debounce window and proved nothing).
+//
+// Gated off the staging gate (PW_BASE_URL): it depends on local render timing, like the
+// other render-timing specs (PRV-4 / production-build).
+test.skip(
+ !!process.env.PW_BASE_URL,
+ 'PRV-6 relies on local render timing (debounce window); not run on the staging gate',
);
+test('PRV-6: with auto-preview OFF, editing does not re-render until manual Refresh', async ({
+ page,
+}) => {
+ // Step 1 — establish a known baseline render with a distinctive participant.
+ const BASELINE = 'PrvBaseline\nUser\nUser->PrvBaseline: before';
+ await typeDsl(page, BASELINE);
+ await expect(mountLocator(page)).toContainText('PrvBaseline', {
+ timeout: 15_000,
+ });
+
+ // Step 2 — turn Auto-preview OFF via Settings (the Radix Switch toggles to
+ // unchecked). This stops PreviewFrame's debounced re-render effect and reveals the
+ // RendererHeader Refresh control.
+ await openViaHeaderMenu(page, 'header-settings');
+ await expect(page.locator('[data-testid="settings-modal"]')).toBeVisible();
+ const autoPreviewSwitch = page.locator('[data-testid="setting-autoPreview"]');
+ await expect(autoPreviewSwitch).toHaveAttribute('aria-checked', 'true');
+ await autoPreviewSwitch.click();
+ await expect(autoPreviewSwitch).toHaveAttribute('aria-checked', 'false');
+ await page.keyboard.press('Escape');
+ await expect(page.locator('[data-testid="settings-modal"]')).toBeHidden();
+
+ // The manual Refresh control is now present (auto-preview OFF ⇒ AppRoot wires onRefresh).
+ const refresh = page.locator('[data-testid="renderer-refresh"]');
+ await expect(refresh).toBeVisible();
+
+ // Step 3 — edit the DSL to a NEW distinctive participant. With auto-preview off,
+ // the preview must stay STALE: still showing the baseline, never the new token.
+ const EDITED = 'PrvUpdated\nUser\nUser->PrvUpdated: after';
+ await typeDsl(page, EDITED);
+ await expect(editorLocator(page)).toContainText('PrvUpdated');
+
+ // DEBOUNCE-PROOF: wait WELL PAST the 500ms PreviewFrame debounce (+ generous margin)
+ // so an always-on re-render would have landed by now if the toggle were dead. Then
+ // assert the preview is unchanged: baseline still rendered, new token absent.
+ await page.waitForTimeout(2000); // > PREVIEW_DEBOUNCE (500ms) + margin
+ await expect(mountLocator(page)).toContainText('PrvBaseline');
+ await expect(mountLocator(page)).not.toContainText('PrvUpdated');
+
+ // Step 4 — click the manual Refresh control. PreviewFrame.render() re-posts a render
+ // of the CURRENT (edited) code, so the preview catches up to the edited DSL.
+ await refresh.click();
+ await expect(mountLocator(page)).toContainText('PrvUpdated', {
+ timeout: 15_000,
+ });
+ await expect(mountLocator(page)).not.toContainText('PrvBaseline', {
+ timeout: 15_000,
+ });
+});
// ──────────────────────────────────────────────────────────────────────────────
// PRV-8: The renderer header shows a 100% zoom indicator.
diff --git a/e2e/tests/settings.spec.js b/e2e/tests/settings.spec.js
index 00ab6ef5..5259fbb9 100644
--- a/e2e/tests/settings.spec.js
+++ b/e2e/tests/settings.spec.js
@@ -206,26 +206,64 @@ test('SET-3: font-family change applies live to .cm-content', async ({
});
// ──────────────────────────────────────────────────────────────────────────────
-// SET-4: indent-unit change → auto-indent width. DEFERRED.
+// SET-4: indent-size change → auto-indent width (#825).
//
-// The Indent-size Select renders but is NOT wired to the editor: AppRoot threads
-// only theme/fontSize/fontFamily/keymap into (AppRoot.tsx ~1268), and
-// NOTHING in web/src sets CM6's `indentUnit` facet from settings.indentSize. The DSL
-// block body indents a FIXED 2 spaces via delimitedIndent (zenumlLanguage.ts). So
-// selecting indent=4 produces no observable change in the editor — there is no honest
-// assertion to make today. Marked fixme rather than faked.
+// settings.indentSize/indentWith are now threaded into (AppRoot.tsx), which
+// sets CM6's `indentUnit` facet from them. The DSL grammar's delimitedIndent reads that
+// facet (delimitedStrategy = baseIndent + context.unit), so changing indent size produces
+// a REAL, observable change in auto-indent width: pressing Enter inside an `A.run() { … }`
+// block indents the new body line by exactly one indent unit.
+//
+// We compute the observable effect from the DOC TEXT, not a CSS/pixel proxy: after Enter
+// the cursor sits at the body's indent, so inserting a sentinel char and reading its
+// column (leading-whitespace length on that line) yields the applied indent width. At the
+// DEFAULT indent=2 the body indents 2; after switching to indent=4 it indents 4.
// ──────────────────────────────────────────────────────────────────────────────
-test.fixme(
- 'SET-4: indent-unit change (4) changes auto-indent width in a block',
- async ({ page }) => {
- // Wiring gap: settings.indentSize is not connected to the CM6 indentUnit facet,
- // so the editor always indents blocks by 2 spaces regardless of this setting.
- // Implement once AppRoot passes indentSize/indentWith into and the
- // editor sets indentUnit from it; then: set indent=4, type `A.run() {`, Enter,
- // assert the new line is indented 4 spaces.
- await gotoFresh(page);
- },
-);
+test('SET-4: indent-size change (2 → 4) changes auto-indent width in a DSL block', async ({
+ page,
+}) => {
+ await gotoFresh(page);
+ const content = dslContent(page);
+ await expect(content).toBeVisible();
+
+ // Reads the leading-space count of the line the cursor lands on after Enter-in-block.
+ // Type the opener, Enter (auto-indents the new line one unit), then a sentinel; read
+ // back the doc and measure the sentinel line's indent. Returns the indent width.
+ async function blockIndentWidth() {
+ await content.click();
+ await page.keyboard.press(selectAll);
+ await page.keyboard.press('Delete');
+ // `A.run() {` is a StatementBraceBlock opener; closeBrackets auto-inserts the `}`.
+ await content.pressSequentially('A.run() {');
+ await page.keyboard.press('Enter'); // auto-indent the body line by one unit
+ await content.pressSequentially('X'); // sentinel marks the indented column
+ // Read the indent width straight from the rendered CM6 lines. CM6 renders each
+ // document line as a `.cm-line` whose textContent includes the leading whitespace,
+ // so the sentinel line's leading-space count IS the applied auto-indent width.
+ const indent = await page.evaluate(() => {
+ const line = Array.from(
+ document.querySelectorAll('[data-testid="dsl-editor"] .cm-line'),
+ )
+ .map((n) => n.textContent || '')
+ .find((l) => l.includes('X'));
+ const m = line ? line.match(/^(\s*)X/) : null;
+ return m ? m[1].length : -1;
+ });
+ return indent;
+ }
+
+ // Default indent = 2 → the block body indents 2 columns.
+ expect(await blockIndentWidth()).toBe(2);
+
+ // Switch indent size to 4 (live compartment reconfigure — no reload).
+ await openSettings(page);
+ await chooseSetting(page, 'setting-indentSize', '4');
+ await page.keyboard.press('Escape');
+ await expect(page.locator('[data-testid="settings-modal"]')).toBeHidden();
+
+ // Now the same Enter-in-block indents the body 4 columns — the setting took effect.
+ expect(await blockIndentWidth()).toBe(4);
+});
// ──────────────────────────────────────────────────────────────────────────────
// SET-5: a behavior toggle takes observable effect.
diff --git a/web/src/app/AppRoot.tsx b/web/src/app/AppRoot.tsx
index 8a9443ec..6053709e 100644
--- a/web/src/app/AppRoot.tsx
+++ b/web/src/app/AppRoot.tsx
@@ -920,6 +920,7 @@ export function AppRoot() {
code={item.js}
css={item.cssMode === 'css' ? item.css : transpiledCss}
stickyOffset={stickyOffset}
+ autoPreview={settings.autoPreview}
/>
) : (
@@ -1265,7 +1266,7 @@ export function AppRoot() {
DSL
-
+
{/* CSS pane → collapsible strip. Re-keyed per page so the collapsed
default re-derives from the new page's CSS emptiness (matches the
@@ -1306,7 +1307,7 @@ export function AppRoot() {
>
}
>
-
+
)
@@ -1330,6 +1331,9 @@ export function AppRoot() {
) : (
previewRef.current?.render()}
pageTabs={
{
await waitFor(() => expect(screen.getByTestId('header-title')).toHaveFocus());
});
- it('editing header-title calls onTitleChange', async () => {
+ // #826: the title field now edits a local DRAFT and commits on Enter/blur (not on
+ // every keystroke), so the user can revert with Escape. Editing then pressing Enter
+ // commits the typed title via onTitleChange.
+ it('editing header-title and pressing Enter commits onTitleChange', async () => {
const onTitleChange = vi.fn();
render();
const input = screen.getByTestId('header-title');
await userEvent.clear(input);
await userEvent.type(input, 'New Title');
- expect(onTitleChange).toHaveBeenCalled();
+ await userEvent.keyboard('{Enter}');
+ expect(onTitleChange).toHaveBeenCalledWith('New Title');
+ });
+
+ // #826: blur also commits (commit-on-exit), so clicking away from a renamed field
+ // saves it.
+ it('editing header-title and blurring commits onTitleChange', async () => {
+ const onTitleChange = vi.fn();
+ render();
+ const input = screen.getByTestId('header-title');
+ await userEvent.clear(input);
+ await userEvent.type(input, 'Blurred Title');
+ input.blur();
+ expect(onTitleChange).toHaveBeenCalledWith('Blurred Title');
+ });
+
+ // #826: Escape reverts the in-progress edit WITHOUT committing — the field returns to
+ // the last committed value and onTitleChange is never fired.
+ it('pressing Escape reverts the title edit and does not commit', async () => {
+ const onTitleChange = vi.fn();
+ render();
+ const input = screen.getByTestId('header-title') as HTMLInputElement;
+ expect(input.value).toBe('My Diagram');
+ await userEvent.clear(input);
+ await userEvent.type(input, 'TempEscapeTitle');
+ expect(input.value).toBe('TempEscapeTitle');
+ await userEvent.keyboard('{Escape}');
+ // Reverted to the committed value; never committed.
+ expect(input.value).toBe('My Diagram');
+ expect(onTitleChange).not.toHaveBeenCalled();
});
// ---- Save-state indicator (replaces the Save button) ----
diff --git a/web/src/components/header/AppHeader.tsx b/web/src/components/header/AppHeader.tsx
index 333e37ec..3eeb467f 100644
--- a/web/src/components/header/AppHeader.tsx
+++ b/web/src/components/header/AppHeader.tsx
@@ -1,4 +1,4 @@
-import { useRef, useState, type ReactNode } from 'react';
+import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
Button,
TextInput,
@@ -177,6 +177,50 @@ export function AppHeader({
el.select();
};
+ // #826: title edit with a DRAFT buffer so Escape can revert without committing.
+ // The field edits a local `draft`; the committed title (`title` prop, owned by the
+ // editor store) only changes on Enter/blur. Escape discards the draft back to the
+ // last committed value. While the user is NOT editing, the field mirrors the prop —
+ // so external title changes (rename via the doc menu, switching diagrams) still flow
+ // through. We track editing via focus, and a ref holds the value to revert TO on
+ // Escape (captured at focus time) so a mid-edit prop change can't desync the revert.
+ const [draft, setDraft] = useState(title);
+ const editingTitle = useRef(false);
+ const committedTitleRef = useRef(title);
+ committedTitleRef.current = title;
+ // When not actively editing, keep the field in sync with the committed prop. During
+ // an edit we leave `draft` alone so keystrokes aren't clobbered by a re-render.
+ useEffect(() => {
+ if (!editingTitle.current) setDraft(title);
+ }, [title]);
+
+ const commitTitle = (value: string) => {
+ // Only fire onTitleChange when the value actually differs from what's committed,
+ // so a no-op blur/Enter doesn't mark the diagram dirty.
+ if (value !== committedTitleRef.current) {
+ // Optimistically advance the committed ref so a follow-up blur re-syncs to the
+ // just-committed value (the `title` prop updates async via the store, one turn
+ // later). Without this, blur's re-sync would read the STALE prop and briefly
+ // revert the field before the prop catches up.
+ committedTitleRef.current = value;
+ onTitleChange(value);
+ }
+ };
+ const handleTitleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ commitTitle(draft);
+ // Keep editing flag set through the synchronous commit; blur (below) clears it.
+ e.currentTarget.blur();
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ // Revert: discard the draft, restore the last committed value, do NOT commit.
+ setDraft(committedTitleRef.current);
+ editingTitle.current = false;
+ e.currentTarget.blur();
+ }
+ };
+
return (
<>
onTitleChange(e.target.value)}
+ onFocus={() => {
+ editingTitle.current = true;
+ }}
+ onChange={(e) => setDraft(e.target.value)}
+ onKeyDown={handleTitleKeyDown}
+ onBlur={() => {
+ // Commit on blur (commit-on-exit), then leave edit mode. Escape's blur
+ // already reverted the draft, so this commit is a no-op there (the draft
+ // equals the committed value → commitTitle skips onTitleChange).
+ if (editingTitle.current) {
+ editingTitle.current = false;
+ commitTitle(draft);
+ }
+ // Re-sync to the authoritative committed value once editing ends.
+ setDraft(committedTitleRef.current);
+ }}
/>