diff --git a/src/components/devtools/Timeline.tsx b/src/components/devtools/Timeline.tsx index 987f90c..4bdb8b4 100644 --- a/src/components/devtools/Timeline.tsx +++ b/src/components/devtools/Timeline.tsx @@ -1,7 +1,10 @@ import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react"; import { Check, ChevronRight, Play } from "lucide-react"; +import { Group, Panel, Separator, useDefaultLayout } from "react-resizable-panels"; import { ERAS, type Concept } from "@/lib/js-eras"; import { getProgress, isConceptDone, toggleConcept } from "@/lib/progress"; +import { panelLayoutStorage } from "@/lib/panel-layout"; +import { useMediaQuery } from "@/hooks/use-media-query"; import { CodeBlock } from "./CodeBlock"; import { CopyButton } from "./CopyButton"; @@ -59,13 +62,19 @@ export function Timeline({ const era = ERAS.find((e) => e.id === activeEra) ?? ERAS[0]!; const railRef = useRef(null); const { done, total, toggle } = useConceptProgress(); + const isWide = useMediaQuery("(min-width: 768px)"); + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "timeline-split", + panelIds: ["timeline-rail", "timeline-content"], + storage: panelLayoutStorage, + }); const onRailKeyDown = (e: KeyboardEvent) => { if (e.key !== "ArrowUp" && e.key !== "ArrowDown" && e.key !== "Home" && e.key !== "End") { return; } const buttons = Array.from( - railRef.current?.querySelectorAll('[data-era]') ?? [], + railRef.current?.querySelectorAll("[data-era]") ?? [], ); const current = buttons.findIndex((b) => b.dataset["era"] === era.id); if (current === -1 || buttons.length === 0) return; @@ -82,83 +91,107 @@ export function Timeline({ onSelectEra(id); }; - return ( -
- {/* era rail */} - - - {/* era detail */} -
-
-
-
-

{era.label}

- - {era.spec} - - {era.years} -
-

- {era.summary} -

-
+ + + + ); + })} + + + ); -
- {era.concepts.map((concept) => ( - toggle(concept.id)} - onToggle={() => onToggleConcept(expandedConcept === concept.id ? null : concept.id)} - onRun={onRun} - /> - ))} + const contentBody = ( +
+
+
+
+

{era.label}

+ + {era.spec} + + {era.years}
-
-
+

+ {era.summary} +

+ + +
+ {era.concepts.map((concept) => ( + toggle(concept.id)} + onToggle={() => onToggleConcept(expandedConcept === concept.id ? null : concept.id)} + onRun={onRun} + /> + ))} +
+
+
+ ); + + if (isWide) { + return ( + + + {railBody} + + + + {contentBody} + + + ); + } + + return ( +
+ {railBody} + {contentBody}
); } diff --git a/src/components/devtools/Workbench.tsx b/src/components/devtools/Workbench.tsx index 6508cb0..a504ae8 100644 --- a/src/components/devtools/Workbench.tsx +++ b/src/components/devtools/Workbench.tsx @@ -1,7 +1,23 @@ import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; -import { Play, Pause, SkipBack, SkipForward, RotateCcw, Square, Zap, Terminal, Trash2, Link2, Check, ImageDown } from "lucide-react"; +import { + Play, + Pause, + SkipBack, + SkipForward, + RotateCcw, + Square, + Zap, + Terminal, + Trash2, + Link2, + Check, + ImageDown, +} from "lucide-react"; +import { Group, Panel, Separator, useDefaultLayout } from "react-resizable-panels"; import { useSandbox } from "@/hooks/use-sandbox"; import { useIsMobile } from "@/hooks/use-mobile"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { panelLayoutStorage } from "@/lib/panel-layout"; import { usePresence } from "@/hooks/use-presence"; import { deriveState, describeEvent } from "@/lib/event-loop"; import { LoopDiagram } from "./LoopDiagram"; @@ -28,11 +44,19 @@ export default function Workbench({ code, onCodeChange, view }: Props) { const [speed, setSpeed] = useState(1); const [consoleClearSeq, setConsoleClearSeq] = useState(0); const [copiedLink, setCopiedLink] = useState(false); - const [imageOutcome, setImageOutcome] = useState<"idle" | "working" | "shared" | "copied" | "saved">("idle"); + const [imageOutcome, setImageOutcome] = useState< + "idle" | "working" | "shared" | "copied" | "saved" + >("idle"); const logRef = useRef(null); const linkResetTimer = useRef | null>(null); const imageResetTimer = useRef | null>(null); const isMobile = useIsMobile(); + const isDesktop = useMediaQuery("(min-width: 1024px)"); + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "workbench-split", + panelIds: ["workbench-editor", "workbench-output"], + storage: panelLayoutStorage, + }); useEffect(() => { if (mode === "run") setCursor(trace.length); @@ -153,254 +177,278 @@ export default function Workbench({ code, onCodeChange, view }: Props) { const hasVisibleLogs = state.logs.some((l) => l.seq > consoleClearSeq); - return ( -
- {/* editor column */} -
-
+ const editorBody = ( + <> +
+ + +
+ {(["run", "step"] as const).map((m) => ( + + ))} +
+ + + + + + + + + + sandbox: {status} + + ⌘/Ctrl⏎ run · ⌘/CtrlK clear + + +
+ +
+ + loading editor… +
+ } + > + + +
+ + {canStep && ( +
- -
- {(["run", "step"] as const).map((m) => ( - - ))} -
- - - - - - - sandbox: {status} - - ⌘/Ctrl⏎ run · ⌘/CtrlK clear - + setCursor(Number(e.target.value))} + aria-label="Execution step" + className="h-1 flex-1 cursor-pointer appearance-none rounded-full bg-accent accent-primary" + /> + + {cursor} / {trace.length} -
- -
- - loading editor… -
- } +
+ )} - {canStep && ( -
- - - - - setCursor(Number(e.target.value))} - aria-label="Execution step" - className="h-1 flex-1 cursor-pointer appearance-none rounded-full bg-accent accent-primary" - /> - - {cursor} / {trace.length} + {canStep && ( +
+ step>{" "} + {lastEvent ? describeEvent(lastEvent) : "before first instruction"} + {lastEvent && ( + + · at {lastEvent.t.toFixed(1)}ms + {cursor > 1 && prevEvent ? ` (+${(lastEvent.t - prevEvent.t).toFixed(1)}ms)` : ""} - -
- )} + )} +
+ )} + + ); - {canStep && ( -
- step>{" "} - {lastEvent ? describeEvent(lastEvent) : "before first instruction"} - {lastEvent && ( - - · at {lastEvent.t.toFixed(1)}ms - {cursor > 1 && prevEvent - ? ` (+${(lastEvent.t - prevEvent.t).toFixed(1)}ms)` - : ""} - - )} -
- )} + const outputBody = ( + <> +
+ {view === "console" ? : } + {view === "console" ? "Console output" : "Event loop — live instrumentation"} + {recording && recording…} + {view === "console" ? ( + + ) : null}
- {/* output column */} -
-
- {view === "console" ? : } - {view === "console" ? "Console output" : "Event loop — live instrumentation"} - {recording && recording…} - {view === "console" ? ( - - ) : null} -
+ {view === "console" ? ( + + ) : ( + + )} + + ); - {view === "console" ? ( - - ) : ( - - )} + if (isDesktop) { + return ( +
+ + +
{editorBody}
+
+ + +
{outputBody}
+
+
+
+ ); + } + return ( +
+
{editorBody}
+
{outputBody}
); @@ -423,56 +471,63 @@ function ConsolePanel({ const visibleLogs = logs.filter((log) => log.seq > clearAfterSeq); const cleared = clearAfterSeq > 0; const hidden = Math.max(0, visibleLogs.length - MAX_RENDERED_LOGS); - const shown = hidden > 0 ? visibleLogs.slice(visibleLogs.length - MAX_RENDERED_LOGS) : visibleLogs; + const shown = + hidden > 0 ? visibleLogs.slice(visibleLogs.length - MAX_RENDERED_LOGS) : visibleLogs; return ( <>

fetch is mocked in this sandbox — no network access.

-
- - {visibleLogs.length === 0 && logs.length === 0 && traceLength === 0 && ( -

- > nothing logged yet — hit Run. -

- )} - {visibleLogs.length === 0 && logs.length === 0 && traceLength > 0 && status !== "running" && !cleared && ( -

- > executed with no console output. -

- )} - {cleared && visibleLogs.length === 0 && logs.length > 0 && ( -

- > console cleared — rerun to capture more output. -

- )} - {hidden > 0 && ( -

- output truncated — {hidden} earlier line{hidden === 1 ? "" : "s"} hidden (showing last{" "} - {MAX_RENDERED_LOGS}) -

- )} - {shown.map((log) => ( -
- - {log.level === "error" ? "✕" : log.level === "warn" ? "▲" : "›"} - -
{log.text}
-
- ))} -
+
+ {visibleLogs.length === 0 && logs.length === 0 && traceLength === 0 && ( +

+ > nothing logged yet — hit Run. +

+ )} + {visibleLogs.length === 0 && + logs.length === 0 && + traceLength > 0 && + status !== "running" && + !cleared && ( +

+ > executed with no console output. +

+ )} + {cleared && visibleLogs.length === 0 && logs.length > 0 && ( +

+ > console cleared — rerun to capture more + output. +

+ )} + {hidden > 0 && ( +

+ output truncated — {hidden} earlier line{hidden === 1 ? "" : "s"} hidden (showing last{" "} + {MAX_RENDERED_LOGS}) +

+ )} + {shown.map((log) => ( +
+ + {log.level === "error" ? "✕" : log.level === "warn" ? "▲" : "›"} + +
{log.text}
+
+ ))} +
); - } function LoopPanel({ @@ -487,9 +542,7 @@ function LoopPanel({ return (
- - view - + view
{(["text", "diagram"] as const).map((v) => (