@@ -11,7 +11,7 @@ import { parse } from "posecode-parser";
1111import { inject } from "@vercel/analytics" ;
1212import type { Viewer } from "posecode-render" ;
1313import { buildNiceShareHash , resolveSharedSource } from "./nice-share.js" ;
14- import { createPosecodeEditor , type PosecodeEditor } from "./editor.js" ;
14+ import type { PosecodeEditor } from "./editor.js" ;
1515import { PRESETS } from "./presets.js" ;
1616import { renderWarnings } from "./warnings.js" ;
1717import llmPrompt from "../../spec/llm-authoring.md?raw" ;
@@ -21,7 +21,10 @@ inject();
2121const $ = < T extends HTMLElement > ( id : string ) : T =>
2222 document . getElementById ( id ) as T ;
2323
24- let editorApi : PosecodeEditor ; // assigned at boot, once the initial doc is known
24+ // CodeMirror is heavy too, so the editor is lazy-loaded after first paint (its
25+ // own chunk). Until that resolves, `editorApi` is null; recompile() and the
26+ // user-action handlers guard on it (a click in that ~tens-of-ms window no-ops).
27+ let editorApi : PosecodeEditor | null = null ;
2528const warnings = $ < HTMLDivElement > ( "warnings" ) ;
2629const canvas = $ < HTMLCanvasElement > ( "canvas" ) ;
2730const playpause = $ < HTMLButtonElement > ( "playpause" ) ;
@@ -156,7 +159,11 @@ function scheduleRecompile(): void {
156159
157160function recompile ( ) : void {
158161 window . clearTimeout ( debounce ) ; // cancel any pending run; we're compiling now
159- const source = editorApi . getValue ( ) ;
162+ // Captured once so control-flow narrowing survives the calls below; a null
163+ // editor (still loading) means there's nothing to compile yet.
164+ const ed = editorApi ;
165+ if ( ! ed ) return ;
166+ const source = ed . getValue ( ) ;
160167 if ( ! source . trim ( ) ) {
161168 // A deliberately blank editor (the "New" flow): a paste target, not an
162169 // error state. Show a hint instead of parse errors and stop the playback.
@@ -181,10 +188,10 @@ function recompile(): void {
181188 updateReps ( ) ;
182189 buildRibbonAndMarkers ( ) ;
183190 phaseRanges = computePhaseRanges (
184- editorApi . getValue ( ) ,
191+ ed . getValue ( ) ,
185192 ( tl ?. segments ?? [ ] ) . map ( ( s ) => s . name ) ,
186193 ) ;
187- editorApi . highlightPhase ( null ) ; // next onPhase paints the active block
194+ ed . highlightPhase ( null ) ; // next onPhase paints the active block
188195 }
189196}
190197
@@ -326,7 +333,7 @@ function loadPreset(id: string): void {
326333 if ( ! preset ) return ;
327334 currentPresetId = preset . id ;
328335 libCurrent . textContent = preset . label ;
329- editorApi . setValue ( preset . source ) ;
336+ editorApi ? .setValue ( preset . source ) ;
330337 recompile ( ) ;
331338 setMobileView ( "viewer" ) ; // on phones, jump to the figure after picking
332339}
@@ -345,10 +352,10 @@ renderLibraryList();
345352$ < HTMLButtonElement > ( "new-doc" ) . addEventListener ( "click" , ( ) => {
346353 currentPresetId = null ;
347354 libCurrent . textContent = "New movement" ;
348- editorApi . setValue ( "" ) ;
355+ editorApi ? .setValue ( "" ) ;
349356 recompile ( ) ; // swaps the status row to the blank-editor hint immediately
350357 setMobileView ( "editor" ) ; // the paste target, front and center on phones
351- editorApi . focus ( ) ;
358+ editorApi ? .focus ( ) ;
352359} ) ;
353360
354361// --- Mobile Editor/Viewer toggle (no-op visually on desktop, where both show) ---
@@ -402,6 +409,7 @@ copyBtn.addEventListener("click", () => copyPrompt(copyBtn));
402409// Snapshot the current document into a URL hash, reflect it in the address bar
403410// (so it's bookmarkable), and copy the full link to the clipboard.
404411async function shareLink ( ) : Promise < void > {
412+ if ( ! editorApi ) return ; // editor still loading; nothing to snapshot yet
405413 try {
406414 const hash = buildNiceShareHash ( editorApi . getValue ( ) ) ;
407415 const url = `${ location . origin } ${ location . pathname } ${ hash } ` ;
@@ -483,17 +491,21 @@ if (sharedSource) {
483491 libCurrent . textContent = PRESETS [ 0 ] ! . label ;
484492}
485493
486- editorApi = createPosecodeEditor ( $ ( "editor" ) , {
487- doc : initialDoc ,
488- onChange : scheduleRecompile ,
494+ // Boot the two heavyweights (CodeMirror editor + Three.js renderer) after the
495+ // shell paints, each in its own lazy chunk, so neither is on the critical path.
496+ // They load independently; recompile() self-guards until both are ready, and
497+ // whichever resolves last triggers the parse-and-animate pass.
498+
499+ // Editor: mount CodeMirror into the shell, then compile so warnings surface.
500+ void import ( "./editor.js" ) . then ( ( { createPosecodeEditor } ) => {
501+ editorApi = createPosecodeEditor ( $ ( "editor" ) , {
502+ doc : initialDoc ,
503+ onChange : scheduleRecompile ,
504+ } ) ;
505+ recompile ( ) ;
489506} ) ;
490- // Parse + surface warnings immediately so the editor is useful at first paint,
491- // even though the 3D figure appears a beat later once the renderer chunk loads.
492- recompile ( ) ;
493507
494- // Boot the renderer after first paint: dynamic import keeps Three.js off the
495- // critical path (its own chunk), mirroring the landing page. Once the viewer
496- // exists we wire its callbacks and recompile so the current doc animates.
508+ // Renderer: keep Three.js off the critical path, mirroring the landing page.
497509void import ( "posecode-render" ) . then ( ( { createViewer } ) => {
498510 viewer = createViewer ( canvas ) ;
499511 // Exposed for capture/e2e tooling (frame capture drives README GIFs).
0 commit comments