feat(maic-editor): slide surface — MAIC Editor v0 (epic #562)#615
Open
wyuc wants to merge 38 commits into
Open
feat(maic-editor): slide surface — MAIC Editor v0 (epic #562)#615wyuc wants to merge 38 commits into
wyuc wants to merge 38 commits into
Conversation
* feat(maic-editor): framework primitives + edit StageMode Phase 1 framework foundation for the MAIC Editor (RFC #547, tracking #560). Plumbing only — no UI consumers ship in this sub-PR; the EditShell chrome and slide surface registration land in follow-ups. - StageMode gains 'edit' alongside 'autonomous' | 'playback'; setMode resets canvas selection when leaving 'edit'. - <Stage> auto-exits 'edit' whenever the current scene becomes uneditable (no scenes / pending generation / no current scene) so a follow-up Pro toggle can never strand the user in an empty edit shell. - SceneEditorSurface contract + tiny registry under lib/edit/ so each SceneType plugs in a surface without the shell importing surfaces directly. Surfaces declare CanvasComponent, useSurfaceState(), insert palette items, floating actions, commands, and (reserved for AI) inline coach hints. - Slide kernel (lib/edit/slide-ops.ts): immutable, history-aware operations covering slide-update, element add / update / updateMany / delete / deleteMany / reorder / duplicate / align / removeProps, and text content edit. - Slide element factories (lib/edit/slide-edit-elements.ts) for default text / shape / image elements + HTML <-> plain-text helpers. - i18n: stage.editCourse + stage.doneEditing across all 6 locales, consumed by the header toggle in the next sub-PR. - Vitest coverage for the slide kernel (operations + history) and the edit-mode store transition (entry + canvas reset on exit). PBLRenderer's mode prop is widened from the literal pair to StageMode so <SceneRenderer> (which already passes StageMode) type-checks. The prop is unused inside the renderer. * style(maic-editor): apply prettier to lib/edit + tests/edit CI runs on PRs to main only, so the prettier check did not fire for this PR's target branch — applying formatting locally before the merge train reaches main avoids a follow-up style commit. * ci: also run on PRs targeting feat/maic-editor-v0 The MAIC Editor lands as a series of stacked sub-PRs against the long-lived feat/maic-editor-v0 branch. Without this entry, none of those sub-PRs get a CI gate — style/lint/type/test regressions only surface when feat/maic-editor-v0 finally merges back to main, at which point fixing them is a lot more disruptive than catching them per sub-PR. Push trigger is intentionally left main-only: nobody pushes directly to feat/maic-editor-v0, every change arrives through a PR that now runs the gate. * fix(maic-editor): address kernel review on #564 Addresses cosarah's review against #561 scope: Important: - element.align now uses the canonical lib/utils/element.ts geometry helper instead of a forked copy. The local fork ignored PPTLineElement start/end and rotation, so bounds were wrong for lines and rotated elements. - Cap slide-edit history at MAX_HISTORY = 50; drop oldest on overflow. - Narrow slide.update patch to Partial<Omit<Slide, 'elements' | 'animations'>> via a new SlideMetaPatch alias, so element / animation collections can only be mutated through their dedicated ops. - element.add throws on id collision; element.duplicate throws when idMap is missing entries or when new ids would collide with existing elements. - scene-editor-registry dev-warns on overwriting a *different* surface for the same SceneType (HMR re-register of the same instance stays silent); add unregister() for HMR cleanup and tests. Minor: - Unify on structuredClone over JSON.parse(JSON.stringify(...)); inside immer's produce, un-proxy with current() first. - Skip history push when produce returns the same content reference (true no-op detection). element.delete / deleteMany pre-check membership so their unconditional filter assignments don't break the ref-equality signal. - Drop redundant cloneSlideContent calls in undo/redo/push paths; immer's structural sharing already guarantees immutability of the produced output. createSlideEditHistory keeps its defensive clone since the initial value comes from outside immer. Coverage: - Extract auto-exit predicate into lib/edit/stage-mode.ts so the policy can be unit-tested without rendering <Stage>. - New tests: every align direction, line / rotated element align, no-op paths for update/delete/reorder/removeProps/text/align, element.add index clamping + id collision, element.duplicate default offset + contract errors, history future cleared after branching, history capped, registry register/unregister/HMR-safe re-register, and the auto-exit predicate. 371 vitest tests pass (was 335). tsc/lint/prettier/i18n/build all green locally. * fix(maic-editor): close kernel escape hatches (subagent CR follow-up) Two defense-in-depth fixes flagged by independent review after the prior commit: - element.duplicate now deep-clones the source via structuredClone(current(element)). The previous shallow spread shared nested mutable references (start/end tuples, outline, points) with the source; immer's COW would have handled most mutations but ops that operate on nested arrays in place (sort/reverse/splice) would silently leak between source and duplicate. The deep clone keeps the kernel's invariants independent of how downstream op consumers write their recipes. - slide.update gains a runtime guard that throws when patch contains elements / animations. The type-level SlideMetaPatch narrowing already forbids these keys, but the runtime guard closes the `as any` escape hatch for callers that might bypass the type system. New tests cover both paths: meta-only slide.update succeeds, an elements-containing patch throws, and a duplicated line element's start/end/points tuples are independent from the source.
* feat(maic-editor): EditShell chrome and Pro mode toggle Adds the scene-type-agnostic editor chrome (EditShell + CommandBar + FloatingToolbar + HintRail), an edit-mode sidebar, and the header Pro toggle that flips into the 'edit' StageMode from #561. No scene editor surfaces are registered yet — the next sub-PR wires up the slide surface. In this PR every scene type falls through to the i18n unsupportedScene placeholder, which is the verifiable visible behavior. - canEdit gating reuses the canonical isCurrentSceneEditable predicate shipped in #561 so the toggle and the auto-exit effect are in lock-step. - handleToggleEditMode tears down live session / engine / TTS before entering edit mode. - ChatArea slides out in edit mode for a full-width canvas. - reorderScene extracted from EditModeSidebar with unit tests; the positional-order preservation is the part worth a guard test. - i18n scoped to keys this PR's components actually reference; surface-specific keys deferred to the slide-surface PR. * test(reorder-scenes): single-element + reference-inequality cases; zh-CN newSlide distinct from addSlide CR follow-ups: - reorderScene tests now cover a 1-element array (both directions return null) and explicitly assert the returned array is a new reference, not the input. - zh-CN edit.sidebar.newSlide was duplicating the addSlide label ("新建幻灯片" both); using "未命名幻灯片" for the default new-slide title to match the English Add slide / New slide distinction.
) Course-correct on #565. EditModeSidebar was rejected by the design owner as inappropriate for Pro mode (#560 wording is "minimal top bar + slide thumbnail rail", not a file-list panel). #565 also left the playback chrome wrapped around the editor — Header / sidebar / Roundtable / ChatArea all stayed mounted with only the sidebar swapped, and EditShell's CommandBar/FloatingToolbar/HintRail were never visible since no surface registers yet. Drop EditModeSidebar + reorder-scenes helper + tests + the edit.sidebar i18n block (8 keys x 6 locales) + the CommandBar sidebar-toggle. Stage keeps Header mounted in both modes — it owns the global Pro toggle Switch, which is the entry AND exit affordance (closing the Switch exits; no separate Done-editing button). In edit mode: SceneSidebar / Roundtable / ChatArea are not mounted, and the canvas slot renders <EditShell scene> instead of <CanvasArea>. EditShell internally resolves the surface via sceneEditorRegistry; when none is registered it falls through to edit.unsupportedScene. With no surfaces registered, every scene type lands on that placeholder — the visible v0 behavior. New optional EditShell.leftRail slot reserves the spot for a redesigned slide-navigation surface; v0 ships with the slot empty. SceneRenderer is now playback-only — the mode === 'edit' branch and its sidebarCollapsed / onToggleSidebar props moved up to EditShell / Stage. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): enablement infrastructure (pre-slide-surface) Pre-requisite for the slide surface (#562). Ships the safety infrastructure so each subsequent surface PR is small and recoverable: 1. Feature flag NEXT_PUBLIC_MAIC_EDITOR_ENABLED, default OFF — gates the Pro toggle in Header. StageMode unchanged. 2. SlideContent.schemaVersion + pure idempotent migrateSlideContent / migrateScene; setScenes / addScene funnel legacy data through the migrate at the store boundary. 3. tests/edit/round-trip/ harness: apply ops -> buildPptxBlob -> JSZip parse -> assert content survived. No PPTX -> Slide reimport exists in the codebase, so the full reimport-diff shape isn't doable; per-op assertions extend the harness in #562. buildPptxBlob is now exported (hook is still the only runtime caller). 4. Per-scene slide-history persistence helpers (persist / load / has / clear, keyed maic-editor:slide-history:${sceneId}, swallow storage failures) + standalone SlideHistoryRestorePrompt dialog + 4 new i18n strings x 6 locales. Stage wiring deferred to #562. 5. Concurrency guards: isSceneEditLocked predicate (defensive; no current call path structurally hits it); localStorage-backed multi-tab edit lock with tryAcquire / refresh / release / heldByOther, stale-lock takeover after 3x heartbeat; standalone MultiTabEditConflictPrompt + 3 new i18n strings x 6 locales. Stage wiring deferred to #562. The slide-surface PR owns the edit-entry effect machinery (where the history-state lifecycle and per-tab tabId ref naturally live), so shipping half-wired dialogs here would speculatively build Stage state we know we'll restructure on contact with the surface. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(maic-editor): migrateSlideContent forward-compat — no silent downgrade CR follow-up: previously, content with schemaVersion newer than CURRENT (e.g. v2 written by a future client) was silently truncated back to the current version. Now: if schemaVersion >= CURRENT, return the content untouched. The slide may not render correctly on an older client, but its on-disk shape stays intact for the next compatible client to read. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
) (#579) PR1 of the slide-surface work (infra-first slice). Registers the slide SceneEditorSurface so EditShell lights up Pro mode for slide scenes. - SceneEditorSurface impl + sceneEditorRegistry registration; the surface owns a SlideEditHistory via the #564 kernel. - Reuse the unmodified slide renderer Canvas through a surface-owned scene context; geometry drag/resize/rotate commits funnel into element.update ops (scene-edit bridge), one gesture = one undo step. - Geometry numeric x/y/w/h/rotate popover as the precise fallback; gated off for line elements (PPTLineElement omits height/rotate). - Wire #571 infra: cross-tab edit lock + conflict prompt, slide-history persistence + restore prompt, regen-lock guard. - Renderer-commit classification: a real geometry gesture commits synchronously inside a pointer interaction; the renderer's ResizeObserver text-normalization commits with none, so it is folded into the baseline (no undo step / no persist / no spurious restore prompt on entry) instead of being staged as a user edit. - Per-op round-trip test for element.update geometry; bridge + session unit tests; edit.geometry i18n across all 6 locales. Upstream-shared changes are kept minimal and additive: an optional `controller` prop on SceneProvider (uncontrolled/playback path unchanged) so staged edits don't write through to the live stage store, and a FloatingToolbar trigger-nesting fix (it wrapped PopoverTrigger around <Tooltip>, a provider, so no popoverContent action could open). Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…nly comment, ImagePicker error log Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… floating bar Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…iew) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ty bar (C1) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… review) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tion (C2) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…4 review) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…e icons, JSX, memo Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…review) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…y i18n keys Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ger wrapped a Tooltip provider) Insert→Image was unreachable: InsertButton wrapped <PopoverTrigger asChild> around <Tooltip> (a context provider, no DOM node), so Radix's Slot bound no element. Chain both triggers onto the real <button>, exactly mirroring the PR1 fix already in FloatingToolbar's ActionButton. PR2's insert-image is the first popoverContent InsertButton consumer to exercise this path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…floating popover The ~450px single-row bar was jammed into FloatingToolbar's fixed w-72 (288px) PopoverContent and clipped. Let the popover size to content (w-auto, max-w-[92vw], Radix handles edge collision) and harden the bar row (w-max + no child shrink, fixed-width font select) so it renders as one clean line. Chrome/surface layout only — no renderer change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ng steps execCommand refocuses the editor after every command; the uncontrolled Radix popover treated that focus-shift as focus-outside and dismissed, forcing a re-open of the Text bar for each format action. Prevent onOpenAutoFocus (don't steal the canvas selection on open) and onFocusOutside (editor refocus must not dismiss); Escape and pointer-down truly outside still close it. Chrome-only, no renderer change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ders The editor's interactive ImageElement rendered elementInfo.src raw, so entering Pro mode on any slide whose image was a generation placeholder showed a broken-image icon (while playback's read-only BaseImageElement correctly resolved the placeholder to the generated objectUrl). Extract the resolution into a shared useResolvedImageSrc hook so both variants stay aligned. Strictly additive: for any non-placeholder src (legacy / direct URL / data URL) resolvedSrc === elementInfo.src and the media store is not subscribed to. Pre-existing upstream gap surfaced by PR2 as the first real-user editor consumer — same shape as the CommandBar popover-trigger fix. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Splits useResolvedImageSrc into a pure resolveImageSrc function (no hooks) wrapped by the hook, so the resolution logic can be unit-tested in vitest's plain node environment (no jsdom/RTL needed in this repo). Covers: done→objectUrl; no task→raw; pending/generating/failed→raw; done with no objectUrl→raw; cross-stage isolation; no-stageId path; non-placeholder src passes through (the additive contract). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reverses PR1's "staged edits don't write through to the live lesson" design. The slide-edit-session now writes through every history move (applyOp / user commit / ResizeObserver normalization / undo / redo) to useStageStore.updateScene as the canonical source of truth, which Dexie already auto-persists. The renderer reads from the stage store via the controller's getSnapshot. Removes the entire staging surface that has no place in a modern editor (Figma/Notion/Google Docs have no "unsaved changes" concept): - DEL lib/edit/slide-history-persistence.ts (localStorage layer) - DEL tests/edit/slide-history-persistence.test.ts - DEL components/edit/SlideHistoryRestorePrompt.tsx (restore dialog) - DROP pendingRestore field + restore() action from slide-edit-session - DROP restorePrompt branch + handlers from useSlideCanvasController - DROP edit.history.restore.* keys across all 6 locales Edits now flow: user input → renderer onUpdate → controller.updateSceneData → slide-edit-session.commitContent → writeThrough(useStageStore.updateScene) → Dexie. There is nothing "unsaved" to restore, by design. The session retains its in-memory undo/redo history (per Pro session) and the user-vs-ResizeObserver gesture classification (so reflow doesn't push undo steps). Test suite rewritten to assert write-through on every history move and no write-through on seed (the stage already has that content). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ss-platform fonts Two issues from review on #586: 1. A selected image/text element couldn't be deleted — the renderer's delete lives only in a right-click menu, undiscoverable in Pro mode. Add a Delete button to the FloatingToolbar for any single selected element (text or image), dispatching the existing element.delete op. Button-only, consistent with #560's keyboard-shortcuts deferral. 2. Switching fonts had no effect on macOS Chrome — the property bar's font list was a hardcoded SimSun/SimHei set (Windows-only system fonts the renderer never loads). Use OpenMAIC's canonical FONTS registry (configs/font.ts) — the web fonts the renderer actually loads, so a pick renders identically on every platform. Adds edit.delete × 6 locales + the parity-test key; floating-actions unit tests for the delete action. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ace (#590) * feat(maic-editor): add resolveEditingElementId text-editing policy Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(maic-editor): drop text-format floating action (moves to anchored bar) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(maic-editor): surface hooks to derive and sync editingElementId Add useResolvedSlideContent / useEditingTextElementId / useSyncEditingElementId. Realign the PR2 buildFloatingActions tests with the new behavior (text formatting moved off the FloatingToolbar) and co-locate the editing-state test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(ui): export PopoverAnchor from the popover wrapper Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(maic-editor): add useTrackedRect for element screen-rect tracking Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(maic-editor): add AnchoredTextBar selection-anchored format bar Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(maic-editor): wire anchored text bar + editing flag into SlideCanvas Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(maic-editor): draw a clean solid frame for the text element being edited Gated on the canvas store's editingElementId (default ""), so the dashed select frame is unchanged for multi-select and for any consumer that never sets the flag. Editor-path only; playback never renders Operate. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(maic-editor): drop the editor focus ring so text editing shows one frame Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style(maic-editor): prettier-format the editing-state test import Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(maic-editor): anchor the bar to the text element node, not the wrapper Code review caught that #editable-element-{id} is a zero-size absolute wrapper — measuring it would pin the bar to the canvas origin. Measure the .editable-element-text child, which carries the real geometry. Also correct the dismiss-behavior comment: the bar is purely selection-driven. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(maic-editor): modernize the text format bar UI Replace the native <select> font picker with the design-system Select, rebuild the size control as one cohesive stepper pill, swap the color "A" for a swatch chip, and unify every control to a single height and hover/ active language (violet accent, matching the editor's Pro-mode accent). Behavior and the text commands are unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(maic-editor): curate the font picker to fonts the app actually loads configs/font.ts listed 29 fonts but the app only ever loads Inter (via next/font); the other 28 had no @font-face or bundled file, so picking them silently fell back with no visible effect — and nothing but the format bar even imports the registry. Trim it to what genuinely renders; the file's comment records how to restore the rest (wire up font loading first). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(maic-editor): load the picker fonts via @fontsource The font registry listed 29 fonts the app never loaded. Wire up a curated set that genuinely renders — 思源黑/宋, 霞鹜文楷, 站酷快乐体, and 9 Latin families — via @fontsource packages (npm-managed, no font binaries in the repo; CJK faces are unicode-range-subsetted so they download lazily per glyph range). app/editor-fonts.ts registers the @font-face CSS from the root layout; configs/font.ts is now the real, honest 14-entry list. The ~14 commercial decorative Chinese fonts are intentionally left out — they need self-hosting + subsetting + a licensing review, separate work. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(maic-editor): quote font-family names so spaced/numeric ones work Picking a font whose family name has spaces or a trailing digit (e.g. "Source Sans 3") threw `Failed to execute 'check' on 'FontFaceSet'` — `document.fonts.check(\`16px ${name}\`)` needs the family quoted — and the fontname mark's toDOM emitted an invalid unquoted `font-family`, so the font silently never applied. Quote the family in both spots; the mark's parseDOM already strips quotes, so the attr still round-trips clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(maic-editor): make the editing frame pointer-events-none The clean editing frame is a purely visual full-size overlay, but it was pointer-events: auto — so it masked the text element's own move cursor, text cursor, click-to-place-caret and drag-to-move; only a thin uncovered sliver at the edges still triggered them. The dashed BorderLines it replaced are thin edge lines, so they never had this problem. Mark the frame pointer-events-none; the resize/rotate handles are separate and keep their own pointer events. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(maic-editor): drop the "font is loading" toast With @fontsource fonts and font-display: swap, a picked font swaps in smoothly on its own — the "Font is loading, please wait..." toast was noise (and fired on most CJK picks while a unicode-range chunk loaded). Remove it along with the now-unused document.fonts.check and toast import. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(maic-editor): move the delete action onto the anchored text bar A text element's contextual actions now sit together on the anchored bar — format controls + delete, hugging the element — instead of delete sitting alone in the top-center FloatingToolbar. buildFloatingActions returns nothing for text (its FloatingToolbar then renders null); non-text elements still get their delete there. Delete logic is shared via a new deleteSlideElement helper. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(maic-editor): anchor the delete action for image elements A selected image element now gets a selection-anchored bar hugging it — just a delete button (image replace/crop/flip stay in a later sub-PR) — the same way text elements do. The anchoring shell is extracted out of AnchoredTextBar into a reusable AnchoredBar, and the delete button into a shared DeleteButton; AnchoredTextBar and the new AnchoredImageBar are thin wrappers. useTrackedRect now measures .editable-element-text or .editable-element-image. buildFloatingActions returns nothing for image elements too (other element types still get their delete there). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style(maic-editor): tighten the anchored bar padding (p-2 → p-1) p-2 left a chunky white margin around the content — most visible on the image bar, a lone delete button in an oversized box. p-1 (4px, the value the FloatingToolbar used) makes both bars sit snug to their controls. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(maic-editor): anchor the delete bar for every element type The selection-anchored delete bar now covers all non-text element types (shape, line, table, chart, …), not just image — so every element's editing chrome is anchored uniformly. AnchoredImageBar becomes the type-agnostic AnchoredDeleteBar; useTrackedRect matches any .editable-element-{type} content root; buildFloatingActions is dropped — the surface no longer contributes top-center FloatingToolbar actions, everything is on an anchored bar. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(maic-editor): show legacy font names in the picker trigger When a text element's `fontname` was a value not in the curated FONTS registry (e.g. `Microsoft YaHei`, `PingFang SC`, theme defaults), the Select couldn't match it and `<SelectValue/>` rendered a blank trigger — both reviewers (cosarah Important, xuyuanwei678 #1) caught this. Add a placeholder fallback so the raw family name surfaces in the trigger. Also clean up the dead `'默认字体'` label that `text-format-bar.tsx` overrode unconditionally: introduce an optional `labelKey` field on `FontEntry`, use it for the default entry, and let the picker prefer the i18n key when present — no more by-value special case. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(maic-editor): address cr minors - `marks.ts` fontname `toDOM` rejects `"` or `\` instead of interpolating them: a hand-crafted mark with `fontname: 'X"; background:url(...);'` could otherwise close the quoted string and inject arbitrary CSS. - `AnchoredBar` gains `onOpenChange` (clears the canvas selection on Radix-initiated dismiss): silences the controlled-without-handler dev warning, and brings back Esc / SR dismissal that our focus-outside hardening had cut off. - `useSyncEditingElementId` folds two `useLayoutEffect`s into one with a cleanup; the previous unmount-only effect was structural noise. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: pin body padding-right so popovers don't reflow the page Radix Select / Popover wrap with `react-remove-scroll`, which adds a compensation `padding-right` to <body> when they open. Our <html> already reserves the scrollbar gutter (`scrollbar-gutter: stable` + `overflow-y: scroll`), so the compensation added a visible ~15px shift on every dropdown open. Pin body's padding-right with `!important` so the page stays still. (xuyuanwei678 review #2.) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(maic-editor): surface legacy font names via SelectValue children The earlier placeholder approach didn't work — Radix's `placeholder` only fires for an empty `value`, not for an unmatched non-empty one. So an element with a legacy fontname (e.g. `Microsoft YaHei`, `PingFang SC`, theme defaults) outside the curated FONTS registry still rendered a blank trigger. Render the trigger text via `SelectValue` children instead — the new `currentFontLabel` helper covers all three cases: matched → entry's i18n / fallback label, unmatched non-empty → the raw family name, empty → the default-font label. Unit tests cover each case. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(maic-editor): preventDefault on pointer-down-outside so drag/resize work The onOpenChange handler added to silence the Radix dev warning + restore Esc dismissal also fired on pointer-down-outside — i.e. on every mousedown on the selected element to drag it or grab a resize handle. That cleared the selection before the drag could start, so nothing on the canvas could be moved or resized. preventDefault on `onPointerDownOutside` (matching the existing `onFocusOutside` hardening) keeps the bar selection-driven while leaving Esc as the legitimate onOpenChange path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(maic-editor): arm-and-place insertion for text boxes Replaces the "auto-insert at a hidden default position" UX. Click `Text box` → arms text-insertion: the button takes the violet active style, and the renderer's existing ElementCreateSelection overlay turns the canvas cursor into a crosshair. On the canvas: - click → 300×60 box at the click point - drag → a box at the dragged rect Either way the new box is auto-selected (addElement defaults that on), and the surface's existing useEditingTextElementId picks it up so the AnchoredTextBar opens on it. Esc disarms; clicking the armed button again disarms (toggle). Completes the text branch in the renderer's `useInsertFromCreateSelection` (pptist scaffolding left it TODO) and bypasses the 200² square fallback in `ElementCreateSelection` for the text type (a square wouldn't suit a text box). `InsertPaletteItem` gains an `active?` field so `CommandBar` can render the armed style. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(maic-editor): render list bullets in slide text Tailwind's preflight resets `list-style` to none, so the format bar's `bulletList` toggle wrapped selected text in `<ul><li>` but no marker ever appeared — the button looked inert. Scope a list-style restoration to `.editable-element-text ul/ol/li` so bullets / numbers render in the slide text without leaking into the rest of the app. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(maic-editor): editable font-size input in the text format bar The size was a read-only `<span>` between the −/+ steppers. Replace with an `<input type=text>` that mirrors `attrs.fontsize` locally, commits on Enter / blur (clamped to [8, 96]; non-numeric reverts), and reverts on Escape. Adds the `edit.text.fontSize` aria-label key in all 6 locales. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(maic-editor): force list markers visible (defeat preflight specificity) The earlier list CSS didn't survive Tailwind's preflight (which also resets `padding: 0` on `<ul>`/`<ol>`, so with `list-style-position: outside` the markers had no room to render). Add `!important` on `list-style` and `padding-inline-start`, and broaden to also match `.prosemirror-editor ul`/ `ol`/`li` in case the markup ever nests differently than expected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(maic-editor): reset richTextAttrs when the editing element changes `richTextAttrs` is a single shared store updated by whichever ProseMirror was last focused. Switching from one text element to another visibly carried the previous element's toggle states (bold / italic / alignment / list) on the format bar for a moment — until the new element's ProseMirror took focus and repopulated the attrs. `useSyncEditingElementId` now resets the attrs to defaults whenever the editing id changes, so the bar shows a neutral state during the transition instead of stale. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(maic-editor): replace OS color dialog with a curated palette popover Clicking the text-color swatch opened the browser's native `<input type=color>` dialog — off-brand and inconsistent across platforms. Swap it for a `ColorPicker` popover: a 12-swatch grid covering the common slide-text needs (4 neutrals + warm + cool) plus a hex input for anything else. Closes on pick. Selected swatch gets the violet outline; hex input commits on Enter / blur (reverts if invalid). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(maic-editor): replace flat swatch popover with a real color picker The previous popover was a chunky 12-swatch grid plus a hex input nobody types into. Rebuild on `react-colorful` (3KB, well-tested): - SV pad + hue slider for free-form picking, with scoped CSS overrides to keep the picker tight (128px pad height) and rounded — not stock. - OS eyedropper via the EyeDropper API, feature-detected (Chrome / Edge; hidden on Safari / Firefox). - Row of 10 small (18px) common colors at the foot for one-click reach. - Current-color preview + read-only hex display. - Hex input dropped entirely — picking is meant to be tactile. Live preview while dragging; the popover closes on a swatch / eyedropper commit (not on drag). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(maic-editor): keep the color popover open while dragging the picker Each SV-pad / hue-slider drag tick fires onChange → dispatches the color command → `editorView.focus()` pulls focus out of the popover into ProseMirror. Radix's default onFocusOutside path was treating that as a dismiss, so the popover closed the instant a drag started — clicking anywhere on the picker shut it. preventDefault on `onFocusOutside` (mirrors the AnchoredBar hardening) keeps it open; the popover still closes on swatch / eyedropper commits and on outside-click / Esc. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(maic-editor): scope body padding override + gate ColorPicker mid-drag sync Two follow-ups from a self-CR on the branch: - `body { padding-right: 0 !important }` was global, overriding Radix's `react-remove-scroll` compensation for every Dialog / Sheet / Select / Popover across the app. Scope it to a `body[data-maic-editor='true']` selector; `SlideCanvas` sets the attribute while mounted. Non-editor pages get Radix's default behavior back. - `ColorPicker`'s `useEffect(() => setColor(value), [value])` mirror could race a stale `value` against the user's current pointer position mid-drag — a single late round-trip would snap the picker back. Gate the re-sync on `isDragging.current` (cleared on `pointerup`); external commits (swatch / eyedropper) still sync immediately because they fire while no drag is in flight. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(maic-editor): polish from self-CR - Gate the `richTextAttrs` reset in `useSyncEditingElementId` to only fire on element-to-element transitions (track previous editing id via a ref). The unconditional reset on the first selection briefly flashed neutral defaults (color #000, fontsize 16px) before the focusing ProseMirror repopulated the real values. - Doc-comment the text-insertion add-element asymmetry: text uses the renderer's `addElement` (because the rect math lives there and we get auto-select for free), image uses surface-side `applyOp` (its source is the ImagePicker, not a canvas gesture). Both commit through the same store, but the text lane doesn't show as a typed `element.add` op in the session history — acceptable, now explicit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(maic-editor): listen to every gesture-end channel in ColorPicker CR round-2 residual nit: the single `pointerup` listener that clears the drag-gate would silently keep the gate stuck on any browser / emulator that only emits the older mouse/touch families. Listen on all four (`mouseup`, `touchend`, `pointerup`, `pointercancel`) — belt-and-suspenders, no behavior change on the common path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(maic-editor): preserve image aspect ratio on insert `createDefaultImageElement` hardcoded the new image's box to 360×220, so anything not ~1.6:1 (which is almost everything users upload — photos, screenshots, logos) ended up squashed or stretched the moment it landed on the slide. Wrap the factory in `insertImageElement` that measures the source via `new Image()`, then dispatches `element.add` with dimensions scaled to fit MAX 600×400 while preserving the natural ratio. Load failure falls back to the factory default so insertion always succeeds. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(maic-editor): drop the now-dead addElement helper `addElement` was only ever used by the inline image-insert which became `insertImageElement`; text uses `armText` (toggle). PPTElement-typed parameter was already unused after the text refactor — removing the dead helper resolves the lint warning. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(maic-editor): drop now-unused PPTElement import in use-slide-surface After `addElement` was dropped (55a9a71), the `PPTElement` type import has no remaining consumers in this file. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…#601) * feat(maic-editor): slide nav rail + scene management (3/3) 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> * refactor(maic-editor): split Stage chrome into mode-specific roots 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> * refactor(maic-editor): cross-fade chrome roots on Pro mode toggle 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> * feat(maic-editor): drawer-style mode swap transition 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> * feat(maic-editor): Pro Switch as a shared layout element across modes 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> * refactor(maic-editor): unify chrome shell across modes 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> * feat(maic-editor): drop sidebar header + button, add insert-before-first 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> * fix(maic-editor): move Download out of settings pill, place right of 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> * feat(maic-editor): floating insert toolbar above canvas (collapsible) 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> * fix(maic-editor): auto-focus text element after toolbar insert 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> * chore(maic-editor): prettier + drop ThumbItem rename sync effect - 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> * fix(maic-editor): CR-loop pass — pointer capture, stage-scoped recycle, 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> * docs(maic-editor): CR-loop round 2 minors — sharpen JSDocs 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> * fix(maic-editor): undo restore at index 0, reset mode on classroom load Two issues from PR #601 manual-verification review: **Undo of the first slide restored it as the second.** The toast undo handler clamps `entry.index - 1` to 0 then calls `insertSceneAfter(scenes[0], entry.scene)`, which lands the entry at position 1 instead of position 0 — no scene exists before scenes[0] to anchor on. Fall back to `setScenes([entry.scene, ...live])` when `entry.index === 0` (or when the deck is empty). The store's existing non-rebalancing `deleteScene` keeps the surviving scenes at orders 2..N, so the prepended entry's original order=1 lines up naturally; StageGrid auto-selects the restored scene as current. **`mode` survived SPA navigation between classrooms.** Refresh reset mode to 'playback' via the initial store value, but switching classrooms via Next.js navigation kept the zustand singleton intact; entering Pro mode in A and then opening B left B in edit mode. `loadFromStorage` and the server-side classroom-load path both now set `mode: 'playback'` on every classroom load, normalising the SPA path to match the refresh path. Mode stays transient UI state, not persisted with the stage. e2e: delete Slide 1 → Undo → restored to position 1 (was position 2 before fix). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…e playable (#612) * feat(maic-editor): gate slide scene creation until inserted scenes are playable Editor-created slide scenes (blank insert + duplicate) ship without playback actions, so the playback engine gives them zero dwell and skips straight past them — a freshly inserted slide is effectively unplayable. Seeding default actions on new scenes is a separate change; until then, hide the two scene-creation entry points so the editor stays coherent as an in-place "fine-tune the generated deck" tool. - add lib/edit/scene-creation-enabled.ts (SCENE_CREATION_ENABLED=false) - hide inter-thumb "+" insertion zones (SlideNavRail) - hide per-slide Duplicate menu item (ThumbItem) - keep reorder / delete / rename, which are playback-safe Re-enable by flipping the flag once new scenes get default actions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(maic-editor): e2e guard for slide scene-creation gate Adds an e2e that generates a classroom (mocked), enters Pro mode, and asserts the slide rail exposes no insertion "+" zones and the per-slide overflow menu has only Rename + Delete (no Duplicate). Fails if SCENE_CREATION_ENABLED is flipped back on without removing the gate. Two stable test ids support locale-independent assertions: - slide-nav-insert (InsertionZone button) - slide-nav-more (ThumbItem overflow trigger) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(maic-editor): enable editor flag for the e2e webServer The scene-creation gate e2e needs the Pro Switch, which only renders when NEXT_PUBLIC_MAIC_EDITOR_ENABLED is on. It's a build-time NEXT_PUBLIC_* flag, so set it in the Playwright webServer env (applies to `pnpm build` in CI and `pnpm dev` locally). Fixes the e2e failure on CI. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(maic-editor): attach gate screenshot to report instead of fixed path CR: e2e-artifacts/ is not gitignored, so writing the screenshot to a fixed path left an untracked file that could be committed by accident. Use testInfo.attach so the image lands in the (ignored) Playwright report. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
pt-BR locale (added on main post-stack) lacked the 51 editor keys, so check:i18n-keys failed after rebase onto main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
editor-fonts (~23 @fontsource CSS tables) and slide-surface registration were top-level static imports in app/layout.tsx and components/stage.tsx, so flag-off classroom/playback users paid the font-face CSS + slide-edit module-init cost on every page load. Move both to a dynamic import in EditChromeRoot (mounts only when mode==='edit', which requires NEXT_PUBLIC_MAIC_EDITOR_ENABLED). Hold the EditShell render until the slide surface registers to avoid a NOOP/ read-only flash on first Pro mode paint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three confirmed issues from the rebase code-review pass: - slide-edit-session: a non-user (ResizeObserver auto-height) commit updated history.present while preserving a now-stale future, so a redo after undo silently resurrected pre-undo content. Clear future on the non-user path (present has diverged from the redo branch); past is left untouched so no spurious undo step is created. - slide-defaults: duplicateSlideScene reassigned element ids inline, leaving grouped elements pointing at the source slide's groupId. Use the existing createElementIdMap so clones get a new shared groupId. (Path is gated off today via SCENE_CREATION_ENABLED; fixes a latent defect.) - stage: wrap playback teardown on Pro-mode entry in try/catch and release the just-acquired cross-tab lock on failure, so a rejected teardown can't strand the lock with the UI stuck in playback. Adds regression tests for the redo-stale and groupId cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The .editable-element-text ul/ol/li rules used a bare selector, but that class is the playback text wrapper rendered for every classroom user — so the !important list-style overrides leaked into normal playback. Scope them to body[data-maic-editor='true'] (set only while Pro mode is mounted) so flag-off playback rendering stays unchanged; markers still show while editing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…trols The Pro mode enter/exit animation janked and the right-side header controls (settings pill + Pro Switch) drifted in width/position across the swap. Three causes, all addressed: - The flag-gating dynamic import gated EditShell behind surfaceReady, so the chrome animated in empty and content popped in once the slide-surface chunk loaded. Preload the editor chunk (fonts + surface registration) in the Pro Switch handler BEFORE flipping mode (lib/edit/preload-editor.ts), and drop the render gate — content is present when the animation starts. - Mode-swap layers and EditShell chrome layers used translateY/translateX slides; with backdrop-blur on the rail and pills that forced a per-frame backdrop-filter recompute (dropped frames) and, as transform ancestors, distorted the layoutId measurement. Switched all chrome enter animations to pure opacity fades. - HeaderControls rendered a fragment whose children were spaced by the host's flex gap (Header gap-4 vs CommandBar trailing gap-2), so the control cluster changed width/anchor between modes. Wrapped it in a self-contained gap-4 container and dropped the cross-bar layoutId morph so the cluster is pixel-stable across the swap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cosarah
reviewed
May 30, 2026
Collaborator
cosarah
left a comment
There was a problem hiding this comment.
Found three issues while reviewing/testing PR 615:
-
Image insertion is async and writes to the current slide session on load, so switching slides before the image finishes loading inserts it into the wrong slide.
-
Loaded classroom scenes bypass migrateScene, so legacy slide content from IndexedDB/server is not normalized with the new schemaVersion field.
-
surfaceStateEqual ignores insert item labels/tooltips, so Pro-mode insert toolbar text stays stale after switching languages.
…, i18n staleness Review feedback from @cosarah on the integration PR: - Image insert resolved size via Image.onload then applied the op to whatever slide session was current at callback time, so switching slides before the image loaded inserted it into the wrong slide. Bind the insert to the scene active at click time and drop the op if the session changed before onload fires. - Classroom scenes loaded from IndexedDB (loadFromStorage) and from the server API (classroom page) bypassed migrateScene, so legacy slide content was not normalized with schemaVersion. Both load paths now migrate on the way in, matching setScenes/addScene. - surfaceStateEqual compared insert-item/command id/active/disabled but not label/tooltip, so the Pro-mode insert toolbar text stayed stale after a language switch. Compare label/tooltip too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
Author
|
Thanks @cosarah — all three fixed in e1b2eb3:
CI green. PR is BEHIND |
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.
MAIC Editor — slide surface (epic #562)
Lands the first concrete MAIC Editor surface: an in-place editor for fine-tuning AI-generated slide decks. The whole surface is flag-gated behind
NEXT_PUBLIC_MAIC_EDITOR_ENABLEDand ships off by default, so merging this carries no runtime risk for existing classroom flows.This is the integration PR for the
feat/maic-editor-v0stack intomain. The branch is rebased ontomain(picks up Azure STT, Gemini 3.5 Flash, pt-BR, the orchestration #599 fix, settings/ChartElement/export fixes, the #616 pptxgenjs rollup fix, etc.).What's included
Infrastructure
StageMode+ framework primitives (feat(maic-editor): framework primitives + edit StageMode #564)EditShellchrome and Pro mode toggle (feat(maic-editor): EditShell chrome and Pro mode toggle #565), drop legacyEditModeSidebar(refactor(maic-editor): drop EditModeSidebar; clean Pro mode chrome #568)Slide surface
Scene-creation gate (MVP)
SCENE_CREATION_ENABLED = false(feat(maic-editor): gate slide scene creation until inserted scenes are playable #612), because editor-created scenes ship without playbackactionsand would be unplayable. Reorder / delete / rename stay enabled (playback-safe). Re-enabling is a one-line flip once new scenes are seeded with default actions.Post-rebase fixes (this integration PR)
After rebasing onto
main, a review pass and manual testing surfaced several issues, all fixed on the branch:edit.*/stage.*keys (pt-BR landed onmainafter this stack began, socheck:i18n-keysfailed post-rebase).editor-fonts(~23 @fontsource CSS tables) and the slide-surface registration were top-level static imports, so flag-off classroom/playback users paid the cost on every page load. Both are now dynamically imported only when Pro mode actually mounts.createElementIdMapinduplicateSlideSceneso duplicated grouped elements get a fresh shared groupId; release the cross-tab edit lock if playback teardown throws during Pro mode entry.body[data-maic-editor='true']so flag-off playback rendering of<ul>/<ol>text stays unchanged.Scope / non-goals
Verification
prettier --check,check:i18n-keys,eslint,tsc --noEmitall cleanplaywrighte2e green on the branch (14/14), including the scene-creation-gate spec with the editor flag enabledvitestgreen except a known Node-22-local-only flake intests/server/ssrf-guard.test.ts(passes on CI's Node 20; unrelated to this stack)mainwith zero conflicts; CI greenCloses #562