feat(studio): storyboard markdown source editor (raw + live preview)#1531
Conversation
8a61267 to
831d70b
Compare
4fbece4 to
4005657
Compare
4005657 to
9ac2b46
Compare
831d70b to
feb9ceb
Compare
miga-heygen
left a comment
There was a problem hiding this comment.
Approve with suggestions. Well-structured split into Board/Source sub-views with proper ARIA tabs.
The CodeMirror raw editor + debounced DOMPurify-sanitized live preview is the right architecture. Cmd/Ctrl+S keyboard shortcut with status indicator is good UX. The useEditableFile hook cleanly tracks dirty state.
Items worth addressing:
- No unsaved-changes guard on tab switch or navigation away — dirty edits silently discarded.
marked.parse()returnsstring | Promise<string>in marked v14+. Explicitly opt into sync:marked.parse(text, { async: false }).useMarkdownPreviewmemo deps include the fulldata.scriptobject reference, making the memo decorative — depend ondata.script?.pathanddata.script?.existsinstead.- New deps (marked, @codemirror/lang-markdown, dompurify) are well-chosen.
— Miga
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at feb9ceb. The shape is clean — marked + DOMPurify with debounced parse, Cmd/Ctrl+S to save, useEditableFile tracks dirty/saved/loading/saving/error sanely, file-tab list reconciles when the underlying file list changes. Markdown stays the canonical source of truth and the Board re-parses on save via onSaved → reload. Deliberate raw editor (not WYSIWYG) is the right call given #1528's frame-field structure.
A few non-blocking concerns and a couple of nits — none block merge given the ENV-gated iterative-shipping intent.
Concerns
- Unsaved edits lost on Board↔Source toggle.
StoryboardSourceEditoris mounted inside thesubView === "board" ? … : <StoryboardSourceEditor … />ternary inStoryboardLoaded.tsx(parent of this PR's component), so flipping back to "Board" unmounts it and discards the in-memorycontentstate. The next remount re-reads from disk. WithCmd/Ctrl+Sas the only save path and no toggle guard, a user who types into Source then clicks Board silently loses unsaved edits. Iterative-OK to defer the confirm-discard prompt, but worth either (a) a tinyif (file.dirty && !confirm("Discard unsaved markdown?"))on the toggle, or (b) hoisting theuseEditableFilestate up so Source remembers across toggles. Cheap to do now; trust-corrosive to walk back later. - Concurrent save / read-modify-write coupling with #1532.
writeProjectFileinuseFileManager.tsis last-write-wins with no etag/version. If a focus-mode voiceover edit (#1532) lands while the Source editor has stalesaved, the user's Cmd+S in Source then overwrites the focus-mode change without warning. Since #1532's focus view replaces the Source view entirely (mutually exclusive in the UI), the only window is "edit in Source, focus elsewhere via Board, focus-mode mutates STORYBOARD.md, switch back to Source which is now showing stale text." Pre-existing studio concurrency model, so not a #1531 regression — flag for the broader storyboard editing story. - Preview prose styling skips images.
PREVIEW_PROSEstylesh1-h3 / p / ul / ol / code / pre / hr / a / strong / table / th / tdbut notimg. DOMPurify allows<img>by default (with event handlers stripped, which is correct), so pasted images render with no constraints — they could blow past the right pane width. Minor; add[&_img]:max-w-full [&_img]:h-autoif you care.
Nits
StoryboardSourceEditor.tsx:140 —useMarkdownPreviewdebounces at 200ms. On large files (the PR body mentions this case explicitly) the first paint shows an empty preview for 200ms even thoughsourceis immediately available. Minor: initializedebouncedonly whensourcechanges, but seed the synchronous parse on first render so the initial paint is non-empty. (nit)StoryboardSourceEditor.tsx:140 —markedv14 may returnPromise<string>if any async extensions are registered. You guard withtypeof raw === "string" ? raw : "", which silently produces an empty preview if it ever becomes async. Either pass{ async: false }tomarked.parseor surface "preview unavailable" instead of blank. (nit)useEditableFile.save()doesn't cancel an in-flight save; mashing Cmd+S fires two PUTs. Each writes the content captured at dispatch time, so the second is usually a no-op — but ifcontentchanged between them, you can briefly write older content. Guard withif (saving) returnor capture an inflight token. (nit)StoryboardLoaded.tsx:31 —data.script?.existstoggles the SCRIPT.md tab, but if a user creates SCRIPT.md from outside the studio while Source is open, the tab list doesn't refresh until the nextreload. (nit)
Questions
- What's the intended UX for a parse error while typing partially-valid markdown? Today the preview re-renders sanitized HTML from
markedon every debounce tick —markedis forgiving, so it almost never throws, but is the intent "show whatever marked produces" or "show last-good + error banner"? Today's behavior is the former.
What I didn't verify
- Bundle-size impact of
marked+ DOMPurify added to studio. Listed for context — iterative-shipping rubric says don't blocker on this, but if studio first-load matters, lazy-importing both at the moment Source first opens would zero them out for users who never visit the Source tab.
Cross-PR coherence with #1532 covered in that review (markdown is the canonical wire format both sides agree on; surgical writers in #1532 are the right primitive for focus-mode edits and avoid the lossy re-serialize trap).
— Rames D Jusso
9ac2b46 to
f35dc0c
Compare
feb9ceb to
dbfaf4f
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
Minor blockers — hold for fix. Reviewed at feb9ceb1, re-verified at dbfaf4f6 after the stack force-pushed mid-review (the only delta is a ReDoS-protection rebase of parseStoryboard.ts from #1528 rippling through the stack — net-new #1531 content unchanged, all findings below still apply). Posted in parallel with Miga's approval-with-suggestions and Rames's comment-with-concerns above — both landed before I went to post. I agree with effectively all of their substantive findings; this review's net new contribution is (a) elevating the unsaved-edits concern from "iterative-OK" / "worth addressing" to a blocker under the band-aid bar Miguel set on this surface, and (b) one finding neither of them surfaced: an unrelated fixture mutation that is currently the CI red.
Singular puzzle, several telltale fibres — the central architecture is impeccable, but two threads run loose enough that they snag before the merge.
Where I disagree on severity (escalation of Miga/Rames's first concern)
1. Silent dirty-state loss on Board↔Source toggle and tab switch. (StoryboardSourceEditor.tsx:115-118, StoryboardLoaded.tsx:30-46)
Rames noted this on the Board↔Source toggle; Miga noted it on tab switch and navigation. Both surfaces hit the same root cause: useEditableFile(activePath, onSaved) is keyed on activePath and lives below the toggle in the component tree, so:
- Flipping
Board | Sourceun-mountsStoryboardSourceEditorentirely and discards the in-memorycontentstate — dirty buffer vapourised, no prompt, no warning. - Switching the file-tab from
STORYBOARD.mdtoSCRIPT.mdre-fires the hook'suseEffect, refetches the new file, and overwritescontent+saved. The header continues to display "Saved" for the new tab as if the previous edit never existed. - A stray browser-tab close hits the same surface — no
beforeunloadguard whiledirty.
Under Miguel's band-aid bar this maps to pattern #3 (scope gap that undercuts the PR's stated coverage). The PR description promises "on save the Board re-parses, so markdown stays the single source of truth" — that's only true if the user can actually get to save. A silent buffer-loss on a routine interaction is the user-facing-half of "single source of truth" with the lid hanging open, and trust-corrosive to walk back later once it's shipped behind the ENV flag.
Acceptable resolutions (any one):
- Hoist
useEditableFilestate up out ofStoryboardSourceEditorso the buffer survives Board↔Source toggles and tab switches; key the dirty map bypath. (Long-term fix.) - Confirm-dialog on toggle and tab switch when
dirty, plus abeforeunloadlistener while any file is dirty. (Minimum acceptable.)
Either is cheap to do in this PR. Both other reviewers' framings are correct for their respective lanes; the elevation belongs in mine because the band-aid bar is the framing I own on this surface.
One finding neither of the parallel reviews surfaced
2. Accidentally-committed fixture rewrite is the CI red. (packages/studio/fixtures/storyboard-sample/index.html)
The single failing required check (Preflight (lint + format) on preview-regression) names exactly one file:
packages/studio/fixtures/storyboard-sample/index.html (10ms)
Format issues found in above 1 files. Run without `--check` to fix.
The diff against #1530's tip shows the fixture going from the pretty-printed form authored in #1530 to a single-line collapsed form with auto-injected data-hf-id="hf-..." attributes on every <div>. That fingerprint matches the studio's own HTML writeback — almost certainly the artefact of loading the sample fixture inside the new editor during local testing and accidentally staging the result. It has zero functional connection to the markdown editor work; it's also the only thing currently keeping the stack from going green.
Under the band-aid bar this is pattern #6 (small footgun that ages into a band-aid): the fixture was deliberately authored in #1530 as the canonical demo asset for the storyboarding flow, and the rewrite both regresses readability for future devs reading the fixture and leaves the door open for the same accidental-stage to ship from any future studio-editor PR. Worth a git checkout origin/storyboard-03-frame-grid -- packages/studio/fixtures/storyboard-sample/index.html and a force-push to restore.
Where I agree with Miga and Rames (mapped to band-aid bar for the record)
marked.parse()async return type. Both Miga and Rames flagged this. Agree —marked.parse(debounced, { async: false })is the right shape, drop thetypeof raw === "string"narrowing. Pattern #4-adjacent (defensive code that silently swallows on a hypothetical-but-real future path); non-blocking today but trivially correct to do here.useMemoref-equality ondata.script(StoryboardLoaded.tsx:21-25). Miga's catch. Agree — depend ondata.script?.pathanddata.script?.exists, not the parent object reference. Everyreload()produces a newdata.scriptobject, so the current memo is decorative and re-creates thesourceFilesarray on every save, which ripples through React's tab-list keys.- Concurrent save coalescing / read-modify-write with #1532. Rames's catch. The
useFileManager.writeProjectFilelast-write-wins surface is pre-existing — agreed it's not a #1531 regression but worth flagging on the broader storyboard-editing thread. For this PR specifically, the cheap fix is theif (saving) returnguard on the innersave()so a fast Cmd+S double-tap doesn't fire two PUTs. - Preview prose styles skip
<img>. Rames's nit. Agree —[&_img]:max-w-full [&_img]:h-autokeeps a pasted image from blowing past the right pane. - Debounce initial paint. Rames's nit. Agree — seeding
debouncedwithsourceon first render avoids the 200ms blank-preview window on large files. - External SCRIPT.md creation doesn't refresh the tab list. Rames's nit. Agree, non-blocking.
Notes (non-blocking)
- DOMPurify default config blocks
<script>/on*/javascript:URLs — fits the threat model (untrusted-but-same-origin user manifest content). Worth noting that default DOMPurify does not injectrel="noopener noreferrer"on links; if a manifest's<a href="https://…">ever opens in the studio context, thewindow.openerreverse-tab-nabbing surface is open. Cheap follow-up. SourceEditorre-mounts cleanly onfilePathchange (themountEditorcallback ref destroys + re-creates theEditorView), and the loading branch un-mounts during the load window so there's no flash of stale doc. Clean.- Stack base is current main (
graphite-base/1528 = 1028f52ais reachable fromorigin/main); single-commit-per-PR; no stale-base squash hazard.
Verdict at dbfaf4f6: finding #1 is request-changes under the band-aid bar (escalation of both other reviewers' first concern to a blocker), finding #2 is request-changes because it's literally the CI red. Everything else from the agree-list is clean-up that can ride the same push.
Re-verify if HEAD moves before stamp.
Review by Via
7e227bd to
cf26e05
Compare
f35dc0c to
c5d6623
Compare
cf26e05 to
e527e2d
Compare
963b3f0 to
c3b9135
Compare
e527e2d to
920b0e3
Compare
c3b9135 to
52bfdf1
Compare
920b0e3 to
02d4c2c
Compare
miga-heygen
left a comment
There was a problem hiding this comment.
Re-review after rebase onto the updated #1530.
StoryboardSourceEditor — This is the star of the PR. The split-pane raw markdown editor + live preview is well-architected:
useEditableFileproperly tracks dirty state viacontent !== saved, with clean cancelled-effect cleanup on unmount/path change. ThereadProjectFile/writeProjectFilecalls through the shared file manager context keep the file I/O consistent with the rest of the studio.useMarkdownPreviewdebounces at 200ms (good for keystroke-heavy editing) and sanitizes via DOMPurify beforedangerouslySetInnerHTML— the right call for user-authored markdown.- Cmd/Ctrl+S shortcut wired up at the container level with
onKeyDown+preventDefault. Elegant. - The file tab selector reconciles against the current file list (
activePathfallback) so a removed/renamed file can't strand the tab. Nice defensive touch.
SourceEditor changes — The switch → Record<string, () => Extension> refactor for language extensions is cleaner and naturally extensible. Adding markdown + md detection is correct. The fallow-ignore-next-line complexity on mountEditor is fine — it's a single imperative callback, not cyclomatic complexity that needs splitting.
StoryboardLoaded — Clean Board ↔ Source sub-view toggle. The SubViewToggle component with role="tablist" / role="tab" / aria-selected is proper a11y for this pattern.
StoryboardView — Hoisting StoryboardLoaded out of StoryboardView is the right separation: view handles load states, loaded handles the experience.
Dependencies — marked, dompurify, @codemirror/lang-markdown are well-chosen for the job. No bloat.
CI green. No concerns. LGTM — ready for stamp.
— Miga
miguel-heygen
left a comment
There was a problem hiding this comment.
Reviewed — storyboard markdown source editor. Rebased, CI green. LGTM.
vanceingalls
left a comment
There was a problem hiding this comment.
Addendum to my 06-17 07:08Z review. Re-verified at HEAD 02d4c2c9, post-rebase onto the now-merged #1529 and the updated #1530 base. Both Miga (21:05Z "LGTM — ready for stamp") and Miguel (21:06Z one-liner approval, "Rebased, CI green. LGTM") have lifted on the rebase. Flagging where I still see the band-aid bar tripped — surfacing for the record so a stamper can decide on the evidence rather than the vote count.
Verdict, second pass: minor blockers — same set as yesterday, none cured by the rebase. With two approvals already on file (Miga's substantive re-review and Miguel's rebase-stamp), I am no longer in a position to call this a hold-for-fix in isolation; my dissent here is on severity, not correctness. Read these as flags for the merge-deck to weigh rather than as a unilateral block.
What yesterday's blockers map to at 02d4c2c9
1. Silent dirty-state loss on Board↔Source toggle and tab switch — STILL OPEN.
- The unmount path.
StoryboardLoaded.tsx:38-58— thesubView === "board" ? … : <StoryboardSourceEditor … />ternary survives the rebase intact. FlippingBoard | Sourceun-mountsStoryboardSourceEditorentirely;useEditableFile'scontentstate goes with it; nobeforeunload, noconfirm, no warning. Yesterday's quote on the PR description — "on save the Board re-parses, so markdown stays the single source of truth" — still has a silent escape hatch where the user's most recent typing simply disappears. - The tab-switch path.
StoryboardSourceEditor.tsx:28-58—useEffectis still keyed onpathalone. SwitchingSTORYBOARD.md → SCRIPT.mdre-fires,readProjectFile(path)resolves, and the baresetContent(text); setSaved(text)overwrites both buffers. Noif (dirty) confirm(…)guard at line 41; no preserved-per-path map. The status pill reads "Saved" on the new tab as if the previous edit had never existed. - The browser-close path. No
beforeunloadhandler anywhere in the editor; closing the tab whiledirtyis silent.
Under Miguel's band-aid bar this still maps to pattern #3 (silent scope gap that undercuts the PR's stated coverage), and that's why I held the threshold at "minor blocker" yesterday rather than "iterative-OK." Rames and Miga both surfaced one face of it on first pass; the fix has not landed.
2. Documentation/code drift in ViewModeContext — STILL OPEN. ViewModeContext.tsx:53-60 still claims the popstate listener will "reflect back/forward navigation and agent-driven URL changes," while the writer at :31-39 uses window.history.replaceState, and the MDN spec is unambiguous: popstate does not fire on replaceState (or pushState). The "agent-driven URL changes" half of that comment is a promise the code does not keep. Either the writer needs to also dispatch a synthetic popstate (or a custom event consumed by the listener), or the comment needs to drop the "agent-driven" claim and document the actual constraint (agents must call setViewMode, not push to ?view= directly). Both fixes are small; the drift is the band-aid pattern #1 surface (doc says X, code does Y).
3. ReDoS rebase note. The earlier dbfaf4f6 ReDoS-protection rebase from #1528 is still in the tree, now upstreamed via the #1528 merge — so the parser-side guards are now in main. Net new #1531 content has not regressed those guards.
Where I diverge from the two LGTMs
Miga's re-review reads the editor architecture cleanly and credits the right wins (useEditableFile shape, debounced sanitization, file-tab reconciliation, ARIA tablist semantics). I agree on each of those — the architecture is impeccable in the abstract. Miguel's stamp is a single-line "Rebased, CI green. LGTM," consistent with a rebase-pass rather than a fresh deep dive on the dirty-state findings I named yesterday — so I read his approval as endorsing the rebase, not as a deliberate severity ruling against those findings. The disagreement, where it exists, is only on whether silent loss of unsaved markdown — on a UI gesture as routine as Board↔Source toggling — clears the band-aid bar. A user types, toggles to peek at the Board, comes back, finds an empty buffer. That is exactly the trust-corrosive footprint pattern #3 was named for; my severity stays at "minor blocker," but I defer to the merge-deck on the call.
Net new finding since 06-16
None. The rebase delta is functionally a no-op over the editor surfaces (the only dbfaf4f6 → 02d4c2c9 differences in scope are the merged-base picks).
Bottom line
Minor blockers in my reading; two approvals on file say otherwise. Net new lines required to clear my findings: ~15 (a beforeunload + a confirm on toggle + a 4-line doc rewrite of the popstate comment). I'll defer to whoever drives the merge train on whether they want those before stamp or as a follow-up issue.
Review by Via
02d4c2c to
bb5beb9
Compare
|
@vanceingalls addressed your blocker at 1. Silent dirty-state loss on Board↔Source toggle and tab switch — fixed with the "minimum acceptable" resolution you outlined:
The remaining items from your agree-list ( |
The base branch was changed.
Fourth PR in the Studio storyboarding stack. Adds an in-context way to view and edit the storyboard's canonical files. - Board | Source sub-toggle inside the storyboard view (StoryboardLoaded). - StoryboardSourceEditor: raw CodeMirror markdown editor + live rendered preview (marked), with a file switcher for STORYBOARD.md and SCRIPT.md. - Loads raw file text and saves via the existing files API (GET/PUT /projects/:id/files/*); on save the Board re-parses (reload), so markdown stays the single source of truth. Cmd/Ctrl+S to save. - Deliberately raw, not WYSIWYG, so the structured frame fields can't be mangled. - SourceEditor gains markdown language support (@codemirror/lang-markdown); adds the marked dependency for preview. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bb5beb9 to
8827892
Compare
miguel-heygen
left a comment
There was a problem hiding this comment.
Re-approved after rebase. LGTM.

Storyboarding — PR 4/5: markdown source editor (raw + live preview)
An in-context way to view and edit the storyboard's canonical files, without leaving the storyboard view.
What's here
Board | Sourcesub-toggle inside the storyboard view.StoryboardSourceEditor— raw CodeMirror markdown editor (left) + live rendered preview (right), with file tabs forSTORYBOARD.mdandSCRIPT.md.markedand sanitized with DOMPurify before injection (the preview runs in the studio origin, so untrusted file content must not execute).status:,src:, frontmatter) can't be mangled.New deps
marked(preview),@codemirror/lang-markdown(highlighting),dompurify(sanitization).Stack: #1528 → #1529 → #1530 → #1531 → #1532