diff --git a/docs/editor.md b/docs/editor.md
index 8ed3fdb9b..e06249e9a 100644
--- a/docs/editor.md
+++ b/docs/editor.md
@@ -392,7 +392,7 @@ Selectors are pure reads. Mutations go through actions (`useEditorStore.getState
`CanvasRoot` switches between two rendering surfaces based on `canvasView`:
-- **Design mode** (`canvasView === 'design'`): `CanvasRoot` → `CanvasTransformLayer` → `BreakpointFrame` → `IframeFrameSurface` → `NodeRenderer`. Each breakpoint gets its own iframe rendered side-by-side inside the pan/zoom transform layer. The author sees all breakpoints at once and can zoom in/out. The canvas opens at 50% (`INITIAL_ZOOM`) so several frames fit in view; reset (Cmd/Ctrl+0, the toolbar % button) goes to 100% (`RESET_ZOOM`).
+- **Design mode** (`canvasView === 'design'`): `CanvasRoot` → `CanvasTransformLayer` → `BreakpointFrame` → `IframeFrameSurface` → `NodeRenderer`. Each breakpoint gets its own iframe rendered side-by-side inside the pan/zoom transform layer. The author sees all breakpoints at once and can zoom in/out. The canvas opens at 50% (`INITIAL_ZOOM`) so several frames fit in view; reset (Cmd/Ctrl+0, the toolbar % button) zooms to 100% (`RESET_ZOOM`) around the visible canvas center, preserving the author's current work area.
- **Live mode** (`canvasView === 'live'`): `CanvasRoot` → `CanvasLiveSurface` → `IframeFrameSurface` → `NodeRenderer`. A single real-size frame at 100% width (optionally clamped to a selected breakpoint's width) scrolls normally. The toolbar zoom controls pin to 100% and disable with the reason in their tooltip ("Live mode always shows 100% zoom.") — the stored design-canvas zoom is preserved for the return to design mode. Resizable with side handles. Because the live frame is flush with the top of the canvas surface, both chrome controls — `CanvasNotch` (top-center) and `CanvasModeToggle` (top-left) — render in **peek** mode: they park above the top edge and roll down on hover/`:focus-within`, so they do not overlay the page's own header. In design mode they are always pinned.
Both modes use the same `IframeFrameSurface` and the same `NodeRenderer` — they are fully editable (click-to-select, properties panel, structural edits all work). The only difference is the layout wrapper. They also share the loading treatment: while the page is hydrating, design mode renders a `CanvasFrameSkeletonFrame` per breakpoint and live mode renders the same `CanvasFrameSkeleton` inside its single frame's width model.
diff --git a/src/__tests__/canvas/canvasSlice.test.ts b/src/__tests__/canvas/canvasSlice.test.ts
index 2d4f7ff2f..8194281cb 100644
--- a/src/__tests__/canvas/canvasSlice.test.ts
+++ b/src/__tests__/canvas/canvasSlice.test.ts
@@ -6,7 +6,7 @@
* - setPan clamps to [±MAX_PAN]
* - zoomTo clamps both zoom and the resulting pan values
* - zoomIn / zoomOut step through ZOOM_STEPS
- * - resetView restores defaults
+ * - zoomTo restores 100% around the requested viewport origin
*
* Security note (Contribution #435, Security Auditor review message #1270):
* Clamping MUST happen inside the setter, not at call sites. The agent tool
@@ -22,7 +22,6 @@ import { useEditorStore } from '@site/store/store'
import {
MIN_ZOOM,
MAX_ZOOM,
- INITIAL_ZOOM,
RESET_ZOOM,
MAX_PAN,
clampZoom,
@@ -322,33 +321,3 @@ describe('zoomIn / zoomOut', () => {
expect(canvas().zoom).toBe(MAX_ZOOM)
})
})
-
-// ---------------------------------------------------------------------------
-// resetView
-// ---------------------------------------------------------------------------
-
-describe('resetView', () => {
- it('resets zoom to RESET_ZOOM (100%), not the 50% initial design zoom', () => {
- useEditorStore.setState({ zoom: 2.5 })
- useEditorStore.getState().resetView()
- expect(canvas().zoom).toBe(RESET_ZOOM)
- expect(INITIAL_ZOOM).toBe(0.5)
- })
-
- it('resets pan to (0, 0)', () => {
- useEditorStore.setState({ panX: 3000, panY: -1500 })
- useEditorStore.getState().resetView()
- const { panX, panY } = canvas()
- expect(panX).toBe(0)
- expect(panY).toBe(0)
- })
-
- it('resets from extreme (clamped) values', () => {
- useEditorStore.setState({ zoom: MAX_ZOOM, panX: MAX_PAN, panY: -MAX_PAN })
- useEditorStore.getState().resetView()
- const { zoom, panX, panY } = canvas()
- expect(zoom).toBe(RESET_ZOOM)
- expect(panX).toBe(0)
- expect(panY).toBe(0)
- })
-})
diff --git a/src/__tests__/canvas/useCanvasWheelSync.test.tsx b/src/__tests__/canvas/useCanvasWheelSync.test.tsx
index e07fed7bd..2b699fea8 100644
--- a/src/__tests__/canvas/useCanvasWheelSync.test.tsx
+++ b/src/__tests__/canvas/useCanvasWheelSync.test.tsx
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { act, cleanup as cleanupRender, fireEvent, render, screen } from '@testing-library/react'
import { useEditorStore } from '@site/store/store'
import { RESET_ZOOM } from '@site/canvas/math'
+import { CANVAS_RESET_ZOOM_EVENT } from '@site/canvas/canvasEvents'
import {
isCanvasPointerPanActive,
isCanvasSpacePanActive,
@@ -72,6 +73,66 @@ afterEach(() => {
})
describe('useCanvas wheel pan sync', () => {
+ it('resets zoom around the visible canvas center without jumping to the page top', () => {
+ useEditorStore.setState({ zoom: 2, panX: -800, panY: -1200 })
+ render()
+
+ const root = screen.getByTestId('test-canvas-root')
+ const layer = screen.getByTestId('test-transform-layer')
+ Object.defineProperty(root, 'getBoundingClientRect', {
+ configurable: true,
+ value: () => ({ width: 800, height: 600, top: 0, left: 0 }),
+ })
+
+ fireEvent.keyDown(document, { metaKey: true, key: '0' })
+
+ expect(useEditorStore.getState()).toMatchObject({
+ zoom: RESET_ZOOM,
+ panX: -200,
+ panY: -450,
+ })
+ expect(layer.style.transform).toBe('translate(-200px, -450px) scale(1)')
+ })
+
+ it('resets from the live transform when the toolbar is clicked during a pending gesture commit', async () => {
+ render()
+
+ const root = screen.getByTestId('test-canvas-root')
+ const layer = screen.getByTestId('test-transform-layer')
+ Object.defineProperty(root, 'getBoundingClientRect', {
+ configurable: true,
+ value: () => ({ width: 800, height: 600, top: 0, left: 0 }),
+ })
+
+ dispatchWheel(root, {
+ ctrlKey: true,
+ deltaY: -100,
+ clientX: 400,
+ clientY: 300,
+ })
+ await act(async () => {
+ await nextAnimationFrame()
+ })
+
+ expect(useEditorStore.getState().zoom).toBe(RESET_ZOOM)
+ expect(layer.style.transform).not.toBe('translate(0px, 0px) scale(1)')
+
+ document.dispatchEvent(new Event(CANVAS_RESET_ZOOM_EVENT))
+
+ expect(useEditorStore.getState()).toMatchObject({
+ zoom: RESET_ZOOM,
+ panX: 0,
+ panY: 0,
+ })
+ expect(layer.style.transform).toBe('translate(0px, 0px) scale(1)')
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 120))
+ })
+
+ expect(useEditorStore.getState().zoom).toBe(RESET_ZOOM)
+ })
+
it('maps shift-wheel mouse scrolling to horizontal canvas pan', async () => {
render()
diff --git a/src/__tests__/toolbar/toolbar.test.ts b/src/__tests__/toolbar/toolbar.test.ts
index cececd737..197c59074 100644
--- a/src/__tests__/toolbar/toolbar.test.ts
+++ b/src/__tests__/toolbar/toolbar.test.ts
@@ -23,9 +23,10 @@
import { describe, it, expect, beforeEach } from 'bun:test'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
-import { cleanup, render } from '@testing-library/react'
+import { cleanup, fireEvent, render } from '@testing-library/react'
import { ZoomControls } from '@site/toolbar/ZoomControls'
import { useEditorStore } from '@site/store/store'
+import { CANVAS_RESET_ZOOM_EVENT } from '@site/canvas/canvasEvents'
// ─── Guideline #224 constants ─────────────────────────────────────────────────
@@ -104,6 +105,27 @@ describe('ZoomControls — live mode', () => {
})
})
+describe('ZoomControls — reset dispatch', () => {
+ it('routes the percentage reset through the canvas event', () => {
+ useEditorStore.setState({ canvasView: 'design', zoom: 2 })
+ let resetCount = 0
+ const onReset = () => {
+ resetCount += 1
+ }
+ document.addEventListener(CANVAS_RESET_ZOOM_EVENT, onReset)
+
+ try {
+ const { container } = render(React.createElement(ZoomControls))
+ fireEvent.click(container.querySelectorAll('button')[1])
+
+ expect(resetCount).toBe(1)
+ } finally {
+ document.removeEventListener(CANVAS_RESET_ZOOM_EVENT, onReset)
+ cleanup()
+ }
+ })
+})
+
// ---------------------------------------------------------------------------
// 2 — UndoRedoButtons — WCAG aria-disabled pattern (Guideline #224)
// ---------------------------------------------------------------------------
diff --git a/src/admin/pages/site/canvas/canvasEvents.ts b/src/admin/pages/site/canvas/canvasEvents.ts
new file mode 100644
index 000000000..4a27c54d9
--- /dev/null
+++ b/src/admin/pages/site/canvas/canvasEvents.ts
@@ -0,0 +1,2 @@
+/** Events shared by the canvas surface and controls rendered outside it. */
+export const CANVAS_RESET_ZOOM_EVENT = 'instatic:canvas-reset-zoom'
diff --git a/src/admin/pages/site/hooks/useCanvas.ts b/src/admin/pages/site/hooks/useCanvas.ts
index 7c534214a..d26efc818 100644
--- a/src/admin/pages/site/hooks/useCanvas.ts
+++ b/src/admin/pages/site/hooks/useCanvas.ts
@@ -28,8 +28,10 @@ import {
clampZoom,
clampPan,
incrementalScaleFromPinchMovement,
+ RESET_ZOOM,
} from '@site/canvas/math'
import { panToCenterBreakpointFrame } from '@site/canvas/canvasDomGeometry'
+import { CANVAS_RESET_ZOOM_EVENT } from '@site/canvas/canvasEvents'
import {
CANVAS_DRAG_PAN_BUTTONS,
isCanvasPointerPanActive,
@@ -100,7 +102,20 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
const setCanvasTransform = useEditorStore((s) => s.setCanvasTransform)
const zoomIn = useEditorStore((s) => s.zoomIn)
const zoomOut = useEditorStore((s) => s.zoomOut)
- const resetView = useEditorStore((s) => s.resetView)
+
+ /**
+ * Resolve the current canvas viewport center, in canvas-local coords.
+ * Keyboard reset uses this as its zoom origin so the user's current work
+ * area stays in view while changing the scale.
+ */
+ // Exception #1: referenced by resetCanvasZoom, which is itself an effect
+ // dependency; exhaustive-deps needs this geometry helper to stay stable.
+ const getViewportCenter = useCallback((): { x: number; y: number } | null => {
+ const el = canvasRootRef.current
+ if (!el) return null
+ const rect = el.getBoundingClientRect()
+ return { x: rect.width / 2, y: rect.height / 2 }
+ }, [canvasRootRef])
// ─── DOM write helper ─────────────────────────────────────────────────────
@@ -203,11 +218,29 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
}
// Exception #1: referenced in the Cmd/Ctrl+0 reset shortcut's useEffect dep array.
- const resetCanvasView = useCallback(() => {
- resetView()
- transformRef.current = { zoom: 1, panX: 0, panY: 0 }
- applyTransformToDOM(transformRef.current, true)
- }, [resetView, applyTransformToDOM])
+ const resetCanvasZoom = useCallback(() => {
+ if (commitTimerRef.current) {
+ clearTimeout(commitTimerRef.current)
+ commitTimerRef.current = null
+ }
+
+ const current = transformRef.current
+ const center = getViewportCenter()
+ const next = center
+ ? applyZoom(current.zoom, RESET_ZOOM, center.x, center.y, current.panX, current.panY)
+ : { zoom: RESET_ZOOM, panX: current.panX, panY: current.panY }
+ const committed = {
+ zoom: next.zoom,
+ panX: clampPan(next.panX),
+ panY: clampPan(next.panY),
+ }
+
+ // Update the ref before the store so the external-store subscriber sees
+ // the values already applied and does not issue a second DOM write.
+ transformRef.current = committed
+ setCanvasTransform(committed.zoom, committed.panX, committed.panY)
+ applyTransformToDOM(committed, true)
+ }, [applyTransformToDOM, getViewportCenter, setCanvasTransform])
/**
* Pan the canvas so the given breakpoint's frame is horizontally centered and
@@ -286,6 +319,20 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
}
}, [])
+ // The toolbar lives outside CanvasRoot, so it routes reset requests through
+ // this hook. Keeping the request here ensures the live transform ref and
+ // its pending debounced store commit are handled as one operation.
+ useEffect(() => {
+ function onResetZoom() {
+ if (enabled) resetCanvasZoom()
+ }
+
+ document.addEventListener(CANVAS_RESET_ZOOM_EVENT, onResetZoom)
+ return () => {
+ document.removeEventListener(CANVAS_RESET_ZOOM_EVENT, onResetZoom)
+ }
+ }, [enabled, resetCanvasZoom])
+
// ─── Browser-style reset shortcut ─────────────────────────────────────────
useEffect(() => {
@@ -300,29 +347,17 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
) return
e.preventDefault()
- resetCanvasView()
+ resetCanvasZoom()
}
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
}
- }, [resetCanvasView])
+ }, [resetCanvasZoom])
// ─── Keyboard shortcuts ───────────────────────────────────────────────────
- /**
- * Resolve the current canvas viewport center, in canvas-local coords.
- * Used as the zoom origin for keyboard +/− shortcuts so the zoom is
- * anchored to the middle of the visible area, not the document top-left.
- */
- const getViewportCenter = (): { x: number; y: number } | null => {
- const el = canvasRootRef.current
- if (!el) return null
- const rect = el.getBoundingClientRect()
- return { x: rect.width / 2, y: rect.height / 2 }
- }
-
const handleKeyDown = (e: React.KeyboardEvent) => {
// Don't intercept typing — let inputs and contenteditables consume keys.
const target = e.target as HTMLElement | null
@@ -346,11 +381,11 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
else zoomOut()
} else if ((e.metaKey || e.ctrlKey) && e.key === '0') {
e.preventDefault()
- resetCanvasView()
+ resetCanvasZoom()
} else if (e.key === '1' && e.shiftKey) {
// `Shift+1` → Reset to 100% zoom (legacy muscle-memory shortcut).
e.preventDefault()
- resetCanvasView()
+ resetCanvasZoom()
}
// Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z handled by App-level listener
}
@@ -358,7 +393,7 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
// ─── External zoom/pan sync ───────────────────────────────────────────────
//
// ZoomControls (toolbar + buttons) and any other external caller update the
- // Zustand store directly via zoomIn/zoomOut/resetView. The DOM transform
+ // Zustand store directly via zoomIn/zoomOut/zoomTo. The DOM transform
// layer is NOT subscribed to the store via React state (intentional perf
// design — avoids re-renders during 60fps gestures). Without this
// subscription those external actions only update the zoom indicator number
@@ -379,6 +414,10 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
([zoom, panX, panY]) => {
const cur = transformRef.current
if (zoom !== cur.zoom || panX !== cur.panX || panY !== cur.panY) {
+ if (commitTimerRef.current) {
+ clearTimeout(commitTimerRef.current)
+ commitTimerRef.current = null
+ }
transformRef.current = { zoom, panX, panY }
// External transform updates (toolbar buttons, +/− keys, agent
// tools, undo/redo) animate to the new value. Continuous gestures
diff --git a/src/admin/pages/site/store/slices/canvasSlice.ts b/src/admin/pages/site/store/slices/canvasSlice.ts
index 7956668ae..edcc25893 100644
--- a/src/admin/pages/site/store/slices/canvasSlice.ts
+++ b/src/admin/pages/site/store/slices/canvasSlice.ts
@@ -1,7 +1,6 @@
import type { EditorStoreSliceCreator } from '@site/store/types'
import {
INITIAL_ZOOM,
- RESET_ZOOM,
clampZoom,
clampPan,
nearestZoomStep,
@@ -99,7 +98,6 @@ interface CanvasSlice {
toggleBreakpointCollapsed: (id: string) => void
/** Mount or release the agent's one-shot offscreen snapshot frame. */
setAgentSnapshotCaptureRequest: (request: AgentSnapshotCaptureRequest | null) => void
- resetView: () => void
/**
* Step zoom up to the next preset level. When `originX`/`originY` are
* provided (in viewport-space, relative to the canvas root), the pan is
@@ -167,8 +165,6 @@ export const createCanvasSlice: EditorStoreSliceCreator = (set, get
agentSnapshotCaptureRequest,
}),
- resetView: () => set({ zoom: RESET_ZOOM, panX: 0, panY: 0 }),
-
zoomIn: (originX, originY) => {
const { zoom, panX, panY, zoomTo } = get()
const next = nearestZoomStep(zoom, 1)
diff --git a/src/admin/pages/site/toolbar/ZoomControls.tsx b/src/admin/pages/site/toolbar/ZoomControls.tsx
index 90a62bd92..e69b1599d 100644
--- a/src/admin/pages/site/toolbar/ZoomControls.tsx
+++ b/src/admin/pages/site/toolbar/ZoomControls.tsx
@@ -22,6 +22,7 @@
*/
import { useEditorStore } from '@site/store/store'
+import { CANVAS_RESET_ZOOM_EVENT } from '@site/canvas/canvasEvents'
import { MinusIcon } from 'pixel-art-icons/icons/minus'
import { PlusIcon } from 'pixel-art-icons/icons/plus'
import { Button } from '@ui/components/Button'
@@ -51,7 +52,6 @@ export function ZoomControls() {
const isLive = useEditorStore((s) => s.canvasView === 'live')
const zoomIn = useEditorStore((s) => s.zoomIn)
const zoomOut = useEditorStore((s) => s.zoomOut)
- const resetView = useEditorStore((s) => s.resetView)
const handleZoomIn = () => {
const center = getCanvasCenter()
@@ -65,6 +65,10 @@ export function ZoomControls() {
else zoomOut()
}
+ const handleResetZoom = () => {
+ document.dispatchEvent(new Event(CANVAS_RESET_ZOOM_EVENT))
+ }
+
// The live frame renders real-size regardless of the stored design-canvas
// zoom, which is preserved for the return to design view.
const pct = isLive ? 100 : Math.round(zoom * 100)
@@ -97,7 +101,7 @@ export function ZoomControls() {
aria-label={isLive ? LIVE_ZOOM_REASON : `Current zoom ${pct}%. Click to reset to 100%.`}
tooltip={isLive ? LIVE_ZOOM_REASON : 'Reset to 100% (Cmd/Ctrl+0)'}
disabled={isLive}
- onClick={resetView}
+ onClick={handleResetZoom}
numeric
className={styles.zoomPct}
>