feat(maic-editor): nav rail, scene management, Pro mode chrome rework#601
Draft
wyuc wants to merge 13 commits into
Draft
feat(maic-editor): nav rail, scene management, Pro mode chrome rework#601wyuc wants to merge 13 commits into
wyuc wants to merge 13 commits into
Conversation
PR3a Phase 1 ships the Pro mode left rail + slide-level management, the last user-visible block of #562. Closes the gap where Pro mode locked the user on the current scene with no way to navigate or manage the deck. SlideNavRail (Studio Editor aesthetic, mirrors playback `SceneSidebar` visually — index badge + title above an aspect-video thumbnail card — so the two sidebars read as the same component family across mode toggle): - Vertical thumbnail strip via `motion.dev` `Reorder.Group` with drag-to-reorder. `Reorder.Item layout="position"` keeps the layout animation on y-axis only; width changes from rail resize don't fight. - Drag-to-resize handle on the right edge writes `style.width` directly on the DOM during the gesture and commits to settings store only on mouse-up; matches playback drag feel exactly and skips the per-frame `persist` serialization that would otherwise burn the frame budget at 60 Hz. - Collapsed and expanded modes; width and collapsed flag persist in `useSettingsStore` (`editRailWidth`, `editRailCollapsed`). - All scene types are first-class — slides render a live `ThumbnailSlide` (now with optional `size` prop → self-measures via `ResizeObserver` when omitted, so the rail width is the single source of truth), non-slide scenes render the same stylised mockups playback `SceneSidebar` uses (extracted to `SceneThumbnailContent`). Slide management: - `+ Add` in the rail header inserts a blank slide after the current scene; new store action `useStageStore.insertSceneAfter` validates stage id, migrates the scene, splices, rebalances `order`, and triggers `debouncedSave`. - Three-dot menu per tile: Rename / Duplicate / Delete. Rename also reachable via double-click on the title; Enter commits, Escape cancels, blur commits, empty input reverts. - Duplicate deep-clones slide content with fresh element IDs (avoid React key collisions) and a `(copy)` title suffix. - Delete uses a toast with Undo action; deleted scene is held in a small `useDeletedSceneRecycle` zustand store and re-inserted at its original index on Undo. Deck-empty guard at the rail layer. - Inter-thumb `InsertionZone` reveals a violet `+` badge on hover, right-anchored, with a popup motion (`cubic-bezier(0.34,1.56,0.64,1)`) + drop shadow + `z-20` so it lifts above the active tile's violet ring. Zero layout shift. Chrome bar: - `HeaderControls` (settings pill + Pro Switch) extracted from `Header` so Pro mode can mount it in the CommandBar's trailing slot — single top chrome bar in Pro mode instead of stacking Header + CommandBar. - Back-to-home button in CommandBar mirrors the playback Header's leftmost button. i18n: new `edit.nav.*` namespace across en-US / zh-CN / zh-TW / ja-JP / ar-SA / ru-RU. Tests: vitest for `insertSceneAfter`, `useDeletedSceneRecycle`, `createBlankSlideScene` / `duplicateSlideScene`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug-driven architectural rework. Two symptoms motivated this:
1. Switching from a slide scene to a non-slide one (interactive / quiz
/ pbl) flickered the entire edit chrome — CommandBar and SlideNavRail
remounted along with the canvas. Root cause: EditShell returned a
different component type (EditShellWithSurface vs EditShellReadOnly)
based on whether a SceneEditorSurface was registered for the scene
type, so React reconciled the change as an unmount/remount of the
whole subtree.
2. `components/stage.tsx` had grown to 1391 lines — playback engine
state, chat / TTS / discussion wiring, presentation/fullscreen,
keyboard handling, AND the edit-mode dispatcher all in one place.
Any change to mode coordination meant touching this god component.
Changes:
- New `NOOP_SURFACE` (`lib/edit/noop-surface.tsx`) — a no-op
SceneEditorSurface used as a fallback when a scene type has no
registered editor surface. `SurfaceState.history` is now optional so
read-only surfaces can omit undo/redo cleanly. EditShell falls back
to NOOP for unregistered types.
- EditShell now mounts a single Frame across all scene types. Surface
state is published from a child `SurfaceStateRunner` keyed by
`scene.type` (so it remounts only when the runner's hook signature
changes — rules-of-hooks compliant), with a custom shallow
equality so the chrome doesn't re-render every render cycle for
reference-fresh state objects. Result: slide ↔ interactive no longer
remounts the CommandBar or the leftRail.
- `stage.tsx` → 113 lines. Mode dispatch + cross-tab edit-lock
coordination + Pro-Switch toggle wiring + multi-tab conflict prompt
only. Everything else moved into one of two new components:
- `PlaybackChromeRoot` (`components/edit/PlaybackChromeRoot.tsx`):
owns the entire playback / autonomous chrome — PlaybackEngine,
chat, discussion TTS, presentation mode, keyboard shortcuts,
SceneSidebar, Header, CanvasArea, Roundtable, ChatArea,
AlertDialog. Exposes `teardown()` via forwardRef so the toggle can
`await` SSE / engine / TTS shutdown before unmounting it.
- `EditChromeRoot` (`components/edit/EditChromeRoot.tsx`): the Pro
mode chrome wrapper — EditShell + SlideNavRail + HeaderControls
trailing slot. Owns `body[data-maic-editor]` lifecycle (lifted from
SlideCanvas so it covers read-only Pro-mode scene types too).
- New `StageGrid` (`components/edit/StageGrid.tsx`) — CSS-Grid named-
slot layout shell with top / left / center / right / bottom areas
for the Pro mode chrome. Future right panel (properties / AI) and
bottom timeline plug in as props with no structural code change.
EditShell's Frame now uses StageGrid internally.
- Deleted `components/edit/SlideTransitionBridge.tsx` (dead code from
the original A3 transition plan that this rework supersedes).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wrap the chrome-root dispatch in `AnimatePresence mode="wait"` with a
180 ms opacity fade-out / fade-in. The outgoing root fully exits before
the incoming one mounts, so:
- The single-canvasStore-writer guarantee from the chrome split is
preserved (ScreenCanvas and Editor/Canvas never coexist).
- Mode toggle reads as a smooth fade instead of a hard cut.
Stage's outer wrapper now carries the stable `bg-gray-50 dark:bg-gray-900`
background so neither root reveals raw page colour while it passes
through opacity 0. `initial={false}` skips the entry animation on first
mount so the initial playback render is instant.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pro toggle was a hard cut — playback chrome vanished, edit chrome popped into place. Now wraps the swap in `AnimatePresence` with the two chrome roots layered via `absolute inset-0` so they coexist for ~280ms: - Edit chrome enters from above (`translateY: -32 → 0`) + fades in, giving a "drawer drops down" feel that matches the inner CommandBar/leftRail stagger choreography. - Playback chrome cross-fades opacity-only; no transform so its active slide canvas stays put underneath while edit drops over it. Both roots keep rendering during the overlap, so `canvasStore`'s scale writer doesn't briefly read zero and snap the slide to a stale size when one root exits ahead of the other. Duration 280ms / `CHROME_EASE` matches the inner Frame timing source. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Pro Switch is the click anchor for the mode swap, but it lives in two different positions: the 80px playback Header (top-right) vs the 56px edit CommandBar trailing slot (also top-right but at a different y and with different padding). After the click the switch "jumped" — it visibly moved + restyled — which felt unsmooth even though the chrome itself was cross-fading. Tag the Pro Switch label (and the settings pill) with `motion.layoutId` so motion treats them as shared elements across the AnimatePresence swap. During the ~280ms transition, motion measures both instances and morphs position + size between them — the user's click target slides into its new home instead of teleporting. Same easing source as the chrome cross-fade so the two animations stay locked together. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pro Switch + settings pill + download icon are the user's mode-toggle
"anchors" — they need to sit at the same screen pixel across
playback ↔ edit. They didn't, because the two chromes had different
shapes:
Playback: SceneSidebar (left, full height) | Header (h-20, 80px)
on top of CanvasArea + Roundtable
Edit: CommandBar (h-14, 56px, FULL width) on top of a row of
(SlideNavRail | content), so the rail sat *below* the bar
So when the user clicked Pro, the bar collapsed by 24px AND the rail
shifted down by 56px AND the right-side controls re-styled (compact
variant) — three simultaneous moves. layoutId masked some of it but
the underlying structure was wrong.
Unify the shells:
- `StageGrid` template flipped from `top top top / left center right /
bottom bottom bottom` to `left top top / left center right / left
bottom bottom`. The left column now spans all rows so the sidebar
always reaches the absolute top edge, matching playback exactly.
- `CommandBar` grows h-14 → h-20 + px-5 → px-8, identical to playback
Header.
- `EditChromeRoot` drops the `variant="compact"` flag on
`HeaderControls` so the settings pill renders at the same h-9 pill
it does in playback.
- `SlideNavRail` header replaces the "SCENES" label with the OpenMAIC
logo (click → home), matching `SceneSidebar`'s shape so the sidebar
top reads as the same component family in both modes.
- Download / Export dropdown moves out of `Header` and into
`HeaderControls` so it's present in both playback and edit chrome at
the same right-cluster position (was previously playback-only).
`Header.tsx` slimmed accordingly. Net effect: the right-edge cluster
(EN, theme, settings, download, Pro Switch) lives at the same screen
pixel across modes; the cross-fade transition only animates the
*contents* inside the bars + sidebar lists, not the bar/rail
positions themselves.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…rst zone The header `+` was a duplicate affordance — every gap between thumbs already has its own `InsertionZone`. Remove the header button; insert flows entirely through the gap zones now (with hover-popup + and right-anchored visual). Add one extra `InsertionZone` rendered BEFORE the first thumb so the top padding of the rail is also clickable / hoverable. Insert-before- first is implemented inline via `setScenes([blank, ...scenes])` because the `insertSceneAfter` store API only handles insertion after an existing anchor. `PlusCircle` import dropped (no longer used). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…Pro Switch Download isn't a settings function (it's an export/share action), so it shouldn't sit inside the pill that hosts language/theme/settings. Move it back to a standalone button on the right side of the Pro Switch — both in playback and edit chrome. Right cluster now reads: [ EN · theme · settings ] [ PRO switch ] [ Download ] Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Text box / Image / future shape buttons no longer share CommandBar with global stage controls (back / undo / redo / title / settings / Pro Switch / Download). Insert is a content-creation action, not a stage-navigation one — mixing them blurred the chrome's role. Lift insert items into a new `FloatingInsertToolbar` that floats centered ~12px above the slide canvas card. Default expanded; collapse arrow tucks it into a small chevron handle at the same anchor. State persists in `settings.editInsertToolbarCollapsed`. Reuses the existing `InsertButton` (extracted from CommandBar into a sibling module so both surfaces — the now-removed CommandBar slot and the floating bar — can share styling). CommandBar drops its `insertItems` prop / middle slot entirely; right-side controls collapse to a single `flex shrink-0` cluster matching playback Header's shape. i18n: `edit.insert.expandToolbar` / `collapseToolbar` across 6 locales. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Inserting a Text box via the FloatingInsertToolbar + click/drag on the canvas left the user one click short — the new element was selected and the AnchoredTextBar opened, but the ProseMirror editor never received focus, so the first keystroke went nowhere and the user had to click inside the element again before typing. `useEditingTextElementId` already mirrors the surface's editing-target choice into `canvasStore.editingElementId`. Have `ProsemirrorEditor` watch that flag in an effect: whenever its own elementId becomes the editing target (insert, programmatic selection, etc.) and it doesn't already have focus, push focus into the view. `hasFocus()` guard keeps this from re-focusing on every re-render of an already-active editor. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- prettier --write on three files touched by the recent edits.
- ThumbItem: drop the `useEffect(() => { if (!renaming) setDraft(...) })`
external-title sync that tripped `react-hooks/set-state-in-effect`.
Idle display now reads from `scene.title` directly (derived rather
than mirrored); `startRename` seeds `draft` at session start and
`cancelRename` resets it so the next session starts clean. Rename
e2e still passes the menu + double-click + Escape paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…e, equality docs PR #601 reviewer feedback. **Drag handle uses Pointer Events with `setPointerCapture`** so the rail no longer gets stuck in "still dragging" state when the cursor leaves the window, the OS reclaims focus, or a tab interrupt suppresses the mouseup that the old document-bound mousemove/mouseup pair relied on. The handle's onPointerMove/Up/Cancel are now bound directly on the element; capture guarantees event delivery for the lifetime of the gesture. Drag tracking e2e still PASS (1 px cursor lock). **Toast Undo guards stage identity** before re-inserting the deleted scene. If the user navigated to a different stage while the toast was up, the recycle entry belongs to the previous stage and `insertSceneAfter` would reject it on stage-id mismatch — silently losing the deleted scene. New check drops the undo cleanly when stage ids don't match. The `stageId` field was already captured on RecycleEntry; just wasn't consulted. **`surfaceStateEqual` extended** to compare per-item `id` / `disabled` / `label` on `floatingActions` (was length-only) and per-item `id`/`severity`/`message` on `hints` (was length-only). Today's slide surface returns `floatingActions: []` and `hints: []` so this is dormant, but PR3b's z-order actions land in `floatingActions` — pinning the equality semantics now keeps a future state field from silently going stale in the chrome. SurfaceState gets a maintenance note cross-linking to the equality function. **Header.tsx mode guard comment** updated. The `mode !== 'edit'` guard around the title block isn't dead — it covers the ~280ms AnimatePresence exit window where playback chrome is still rendering its exit animation while mode has flipped to 'edit'. Without the guard, this title would briefly stack on top of the incoming EditChromeRoot's CommandBar title during the cross-fade. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Round-2 reviewer flagged two doc-only refinements: - `surfaceStateEqual`: clarify that callback identity (`onInvoke`, `popoverContent`) is intentionally NOT compared, and that today's safety comes from slide-surface returning `floatingActions: []` rather than the per-item compare covering callbacks. A future surface that emits closure-capturing actions must fold its own change signal into the comparison or the stale callback fires at click time. - `setPointerCapture` catch: spell out that this is paranoia, not a real fallback — if capture genuinely fails the gesture still tracks for in-window moves but out-of-window `pointerup` won't route here. Acceptable degradation; the catch exists only because the spec permits an `InvalidPointerId` throw that browsers we ship to don't actually emit on same-pointer `pointerdown`. No functional changes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the deferred work from #560 / #562 — Phase 1 slide surface Pro mode now has a
working left rail with full slide management, plus an architectural rework of the
Stage chrome that lifts two long-standing UX bugs.
What's in
Slide nav rail + scene management
SlideNavRail— vertical thumbnail strip via motion.devReorder.Group, drag-to-reorder, drag-to-resize, collapse toggle. All scene types render first-class
(slide gets
ThumbnailSlide, non-slide gets playbackSceneSidebarmockups viathe extracted
SceneThumbnailContent).+ Addbetween every thumb plus one above the first. Per-tile menu has Rename /Duplicate / Delete; Rename also responds to double-click; Enter commits, Esc
cancels. Delete uses a toast Undo restoring at the original index.
useStageStore.insertSceneAfter— validates stage id, splices,rebalances
order, triggersdebouncedSave.settings store on mouseup, so 60 Hz drag never serializes the (large) persisted
object.
Pro mode chrome rework
stage.tsx(was 1391 LOC) is now a 113-line dispatcher: mode + cross-tab edit-locktwo independent roots:
PlaybackChromeRoot— every bit of playback chrome and engine state. Exposesteardown()via forwardRef so the toggle can await SSE / engine / TTS shutdownbefore unmounting.
EditChromeRoot— Pro mode chrome (EditShell + SlideNavRail + HeaderControlstrailing). Owns
body[data-maic-editor]lifecycle.StageGridis a CSS-Grid named-slot shell (top / left / right / bottom / center)with the left column spanning all rows, so the sidebar reaches the absolute top
edge in both modes.
Header 80 px top-right of content, content fills. Mode toggle cross-fades only the
contents.
motion.layoutIdon the Pro Switch + settings pill morphs them between modes sothe click target never teleports. Drawer-style swap (
AnimatePresence, 280 msCHROME_EASE) layered viaabsolute inset-0keepscanvasStoreto a singlewriter across the transition.
Two bugs the rework solves
EditShellreturned twodifferent component types (
WithSurfacevsReadOnly) so React unmounted thewhole subtree (CommandBar + leftRail + SlideNavRail) on every scene-type change.
Fixed via a
NOOP_SURFACEfallback inlib/edit/noop-surface.tsxand aSurfaceStateRunnerkeyed byscene.typethat publishes surface state up to astable Frame.
motion.asidewas running motion'selement-tracking pipeline every frame even with
animate={false}. Replaced withplain
<aside>+ CSS transition, direct-DOM width write during the gesture,Reorder.Item layout="position"so reorder still animates but parent-drivenresizes don't fight it.
Other UX
collapsible pill that floats centered above the canvas. State persists in
settings.editInsertToolbarCollapsed.ProsemirrorEditorauto-focuses when its element becomes the editing target —inserting a Text box no longer requires a second click before typing.
[language · theme · settings] [PRO] [Download]in both modes (Download was previously playback-only and lived insidethe settings pill).
Verification
pnpm test: 480 / 489 pass. The 9 failures intests/server/ssrf-guard.test.tsreproduce on
feat/maic-editor-v0(base) — pre-existing, unrelated.pnpm check: prettier clean.pnpm lint: 0 errors, 7 pre-existing warnings.text auto-focus, drag tracking precision (1 px cursor lock), mode swap
(slide↔slide, slide↔interactive, scene-type-swap chrome stability).
Out of scope / follow-ups
Editor/CanvasvsScreenCanvasunification — would let the canvas survive amode swap without remount. Separate slide-renderer refactor.
StageGrid, plug in once surface contracts grow.Test plan
cluster (settings pill, PRO, Download) shouldn't jump.
thumb — chrome stays mounted; canvas shows playback renderer + read-only
badge.
cancels.
element, type immediately.
under the active card's violet ring.