refactor: navigate presentation by scene and beat

This commit is contained in:
lda
2026-07-04 16:42:23 +07:00 Verified
parent c6018c4a86
commit 5947ee625c
13 changed files with 446 additions and 219 deletions
+26 -19
View File
@@ -1,23 +1,30 @@
import { presentationBeats, type BeatId } from "./beats.js";
import { mainScenes, type PresentationLocation } from "./storyboard.js";
type BeatRailProps = {
readonly activeBeat: BeatId;
readonly jump: (beat: BeatId) => void;
readonly location: PresentationLocation;
readonly jump: (location: PresentationLocation) => void;
};
export const BeatRail = ({ activeBeat, jump }: BeatRailProps) => (
<nav className="beat-rail" aria-label="presentation beat rail">
{presentationBeats.map((beat) => (
<button
key={beat.id}
type="button"
data-active={beat.id === activeBeat}
aria-current={beat.id === activeBeat ? "step" : undefined}
onClick={() => jump(beat.id)}
>
<span>{beat.lifecycleStep}</span>
<small>{beat.title}</small>
</button>
))}
</nav>
);
export const BeatRail = ({ location, jump }: BeatRailProps) => {
const activeSceneId = location.kind === "main" ? location.sceneId : "positioning";
return (
<nav className="beat-rail" aria-label="presentation beat rail">
{mainScenes.map((scene) => {
const firstBeat = scene.beats[0]!;
const isActive = scene.id === activeSceneId;
return (
<button
key={scene.id}
type="button"
data-active={isActive}
aria-current={isActive ? "step" : undefined}
onClick={() => jump({ kind: "main", sceneId: scene.id, beatId: firstBeat.id })}
>
<span>{scene.number}</span>
<small>{scene.title}</small>
</button>
);
})}
</nav>
);
};
@@ -1,10 +1,10 @@
import type { EvidenceRecord } from "../app/state.js";
import { formatJson } from "./format.js";
import type { PresentationState } from "./presentation-state.js";
import type { EvidenceMode } from "./storyboard.js";
type EvidenceDrawerProps = {
readonly records: readonly EvidenceRecord[];
readonly mode: PresentationState["evidenceMode"];
readonly mode: EvidenceMode;
readonly close: () => void;
};
@@ -1,17 +1,9 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { OperatorChat } from "./OperatorChat.js";
import type { PresentationState } from "./presentation-state.js";
import { initialPresentationState } from "./presentation-state.js";
import type { AgentMessage } from "../demo/agent/events.js";
const state: PresentationState = {
beat: "intro",
selectedNodeId: null,
chatMode: "full",
evidenceMode: "hidden",
playbackMode: "replay",
};
describe("OperatorChat", () => {
it("renders standard agent message parts", () => {
const messages: ReadonlyArray<AgentMessage> = [
@@ -33,7 +25,7 @@ describe("OperatorChat", () => {
},
];
render(<OperatorChat state={state} messages={messages} />);
render(<OperatorChat state={initialPresentationState} messages={messages} />);
expect(screen.getByText("Prepare the report.")).toBeInTheDocument();
expect(screen.getByText("I will use the prepared recipe.")).toBeInTheDocument();
@@ -1,6 +1,7 @@
import { PREPARE_THESIS_REPORT_RECIPE } from "../demo/agent/recipes.js";
import type { AgentMessage, AgentMessagePart } from "../demo/agent/events.js";
import type { PresentationState } from "./presentation-state.js";
import { compositionForState } from "./presentation-state.js";
type OperatorChatProps = {
readonly state: PresentationState;
@@ -78,8 +79,9 @@ const renderPart = (
export const OperatorChat = ({ state, messages, onApprove, onDeny }: OperatorChatProps) => {
const visibleMessages = messages && messages.length > 0 ? messages : fallbackMessages(state);
const composition = compositionForState(state);
return (
<aside className="operator-chat" data-mode={state.chatMode} aria-label="scripted operator chat">
<aside className="operator-chat" data-mode={composition.chatMode} aria-label="scripted operator chat">
{visibleMessages.map((message) => (
<div key={message.id} className={`chat-message chat-message--${message.role === "user" ? "operator" : "system"}`}>
<strong>{message.role === "user" ? "Operator" : "lda.chat"}</strong>
@@ -10,17 +10,17 @@ describe("PresentationRoute", () => {
render(<PresentationRoute />);
expect(screen.getByRole("main", { name: /lda.chat presentation/i })).toBeInTheDocument();
expect(screen.getByText(/External planners propose actions/i)).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /Thesis/ })).toBeInTheDocument();
});
it("starts from a hash beat and advances with keyboard", async () => {
window.location.hash = "#interrupt-approval";
it("starts from a scene hash and advances with keyboard", async () => {
window.location.hash = "#scene/agent-handoff/request";
render(<PresentationRoute />);
expect(screen.getByText(/Human approval is a typed workflow boundary/i)).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /Agent Handoff/i })).toBeInTheDocument();
await userEvent.keyboard("{ArrowRight}");
expect(await screen.findByText(/Resuming commits the approved branch/i)).toBeInTheDocument();
expect(await screen.findByText(/The interface delegates durable work to lda\.chat/i)).toBeInTheDocument();
});
it("renders replay-first chat, beat rail, and stage caption", () => {
@@ -40,20 +40,16 @@ describe("PresentationRoute", () => {
});
it("can advance replay far enough to show a product operation block", async () => {
window.location.hash = "#scene/workflow-demo/operation";
render(<PresentationRoute />);
await userEvent.click(screen.getByRole("button", { name: /product operation/i }));
expect(await screen.findByText(/workflow.runs.start/i)).toBeInTheDocument();
});
it("shows resume and trace operation blocks for later beats", async () => {
window.location.hash = "#scene/interrupt-evidence/resume";
render(<PresentationRoute />);
await userEvent.click(screen.getByRole("button", { name: /resume output/i }));
expect(await screen.findByText(/workflow.runs.resume/i)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /trace evidence/i }));
expect(await screen.findByText(/workflow.runs.trace/i)).toBeInTheDocument();
});
it("runs the prepared agent and applies the interrupt node action", async () => {
@@ -4,12 +4,13 @@ import { createPreparedRecipeDriver, assertNever } from "../demo/agent/preparedR
import { useDemoAgent } from "../demo/agent/useDemoAgent.js";
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
import { useDemoTimeline } from "../demo/useDemoTimeline.js";
import { hashForBeat } from "./beats.js";
import { PresentationStage } from "./PresentationStage.js";
import {
initialPresentationState,
presentationReducer,
} from "./presentation-state.js";
import { hashForLocation } from "./storyboard-navigation.js";
import type { MainLocation, PresentationLocation } from "./storyboard.js";
import "./presentation.css";
const projectRecordingToEvidence = (
@@ -55,11 +56,11 @@ export const PresentationRoute = () => {
}, [agent]);
useEffect(() => {
const hash = hashForBeat(state.beat);
const hash = hashForLocation(state.location);
if (window.location.hash !== hash) {
window.history.replaceState(null, "", hash);
}
}, [state.beat]);
}, [state.location]);
useEffect(() => {
const onHashChange = () => {
@@ -119,9 +120,11 @@ export const PresentationRoute = () => {
dispatch({ type: "set_evidence_mode", mode: "open" });
break;
}
case "setBeat":
dispatch({ type: "jump_hash", hash: `#${action.beatId}` });
case "setBeat": {
const loc: MainLocation = { kind: "main", sceneId: "thesis", beatId: "title" };
dispatch({ type: "jump", location: loc });
break;
}
case "focusOperation":
case "showTraceFrame":
break;
@@ -134,6 +137,11 @@ export const PresentationRoute = () => {
}
}, [agent.pendingActions, agent.clearPendingActions, evidence.length, replayEvidence]);
const handleJump = useCallback(
(location: PresentationLocation) => dispatch({ type: "jump", location }),
[],
);
return (
<main className="presentation-route" aria-label="lda.chat presentation">
<PresentationStage
@@ -143,7 +151,7 @@ export const PresentationRoute = () => {
messages={agent.messages}
onApprove={agent.phase === "awaiting-approval" ? handleApprove : undefined}
onDeny={agent.phase === "awaiting-approval" ? handleDeny : undefined}
jump={(beatId) => dispatch({ type: "jump", beat: beatId })}
jump={handleJump}
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
clearNode={() => dispatch({ type: "clear_node" })}
openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })}
@@ -1,6 +1,5 @@
import type { EvidenceRecord } from "../app/state.js";
import type { AgentMessage } from "../demo/agent/events.js";
import { presentationBeats, type BeatId } from "./beats.js";
import { BeatRail } from "./BeatRail.js";
import { EvidenceDrawer } from "./EvidenceDrawer.js";
import { NodeSpotlight } from "./NodeSpotlight.js";
@@ -11,6 +10,8 @@ import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
import type { PresentationState } from "./presentation-state.js";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import type { DemoEventStage } from "../demo/timeline/models.js";
import { compositionForState } from "./presentation-state.js";
import { findBeat, findScene, type MainLocation, type PresentationLocation } from "./storyboard.js";
type PresentationStageProps = {
readonly state: PresentationState;
@@ -19,18 +20,19 @@ type PresentationStageProps = {
readonly messages?: ReadonlyArray<AgentMessage>;
readonly onApprove?: (() => void) | undefined;
readonly onDeny?: (() => void) | undefined;
readonly jump: (beat: BeatId) => void;
readonly jump: (location: PresentationLocation) => void;
readonly selectNode: (nodeId: string) => void;
readonly clearNode: () => void;
readonly openEvidence: () => void;
readonly closeOverlay: () => void;
};
const operationStageByBeat: Partial<Record<BeatId, DemoEventStage>> = {
"tool-call-start": "run_start",
"interrupt-approval": "interrupt",
"resume-output": "run_resume",
"trace-evidence": "trace_read",
const operationStageByBeat: Readonly<Record<string, DemoEventStage | undefined>> = {
operation: "run_start",
interrupt: "interrupt",
approval: "interrupt",
resume: "run_resume",
trace: "trace_read",
};
export const PresentationStage = ({
@@ -46,20 +48,34 @@ export const PresentationStage = ({
openEvidence,
closeOverlay,
}: PresentationStageProps) => {
const beat = presentationBeats.find((candidate) => candidate.id === state.beat) ?? presentationBeats[0]!;
const operationStage = operationStageByBeat[state.beat] ?? null;
const composition = compositionForState(state);
const location = state.location;
const scene =
location.kind === "main" ? findScene(location.sceneId) : findScene("positioning");
const beat =
location.kind === "main" && scene
? scene.beats.find((b) => b.id === location.beatId)
: scene?.beats[0];
const operationStage = location.kind === "main" ? operationStageByBeat[location.beatId] ?? null : null;
const operationEvent = operationStage
? demo.state.events.find((event) => event.stage === operationStage) ?? null
: null;
return (
<div className="presentation-stage" data-beat={state.beat}>
<div
className="presentation-stage"
data-stage-theme={composition.stageTheme}
data-chat-theme={composition.chatTheme}
data-chat-mode={composition.chatMode}
>
<OperatorChat state={state} messages={messages} onApprove={onApprove} onDeny={onDeny} />
<section className="presentation-stage__main">
<header className="presentation-stage__header">
<StageCaption eyebrow="lda.chat defense" title={beat.title}>
<p>{beat.caption}</p>
<StageCaption eyebrow="lda.chat defense" title={scene?.title ?? "Thesis"}>
<p>{beat?.caption ?? ""}</p>
</StageCaption>
<button type="button" onClick={openEvidence}>Evidence</button>
</header>
@@ -72,8 +88,8 @@ export const PresentationStage = ({
{demo.state.mode === "replay" ? "Replay" : "Live"} · {demo.state.phase}
</p>
</section>
<BeatRail activeBeat={state.beat} jump={jump} />
<EvidenceDrawer records={evidence} mode={state.evidenceMode} close={closeOverlay} />
<BeatRail location={location} jump={jump} />
<EvidenceDrawer records={evidence} mode={composition.evidenceMode} close={closeOverlay} />
</div>
);
};
@@ -1,26 +0,0 @@
import { describe, expect, it } from "vitest";
import { beatFromHash, hashForBeat, presentationBeats } from "./beats.js";
describe("presentation beats", () => {
it("has stable unique beat ids", () => {
const ids = presentationBeats.map((beat) => beat.id);
expect(new Set(ids).size).toBe(ids.length);
expect(ids).toEqual([
"intro",
"chat-request",
"tool-call-start",
"graph-reveal",
"interrupt-approval",
"resume-output",
"trace-evidence",
"boundary-wrap",
]);
});
it("maps beats to hash fragments and falls back to intro", () => {
expect(hashForBeat("interrupt-approval")).toBe("#interrupt-approval");
expect(beatFromHash("#interrupt-approval")).toBe("interrupt-approval");
expect(beatFromHash("#missing")).toBe("intro");
expect(beatFromHash("")).toBe("intro");
});
});
@@ -1,76 +0,0 @@
export type BeatId =
| "intro"
| "chat-request"
| "tool-call-start"
| "graph-reveal"
| "interrupt-approval"
| "resume-output"
| "trace-evidence"
| "boundary-wrap";
export type PresentationBeat = {
readonly id: BeatId;
readonly title: string;
readonly caption: string;
readonly lifecycleStep: string;
};
export const presentationBeats: readonly PresentationBeat[] = [
{
id: "intro",
title: "Planner vs runtime",
caption: "External planners propose actions; the workflow runtime owns deterministic execution.",
lifecycleStep: "Frame",
},
{
id: "chat-request",
title: "Operator request",
caption: "The operator asks for a thesis readiness report through a chat-like product surface.",
lifecycleStep: "Request",
},
{
id: "tool-call-start",
title: "Product operation",
caption: "The assistant invokes a prepared workflow operation instead of inventing ad-hoc script state.",
lifecycleStep: "Run",
},
{
id: "graph-reveal",
title: "Workflow graph",
caption: "The graph shows reusable structure, not a one-off tool-calling transcript.",
lifecycleStep: "Graph",
},
{
id: "interrupt-approval",
title: "Typed interrupt",
caption: "Human approval is a typed workflow boundary with explicit resume outcomes.",
lifecycleStep: "Interrupt",
},
{
id: "resume-output",
title: "Resume output",
caption: "Resuming commits the approved branch and produces report and issue-board output.",
lifecycleStep: "Resume",
},
{
id: "trace-evidence",
title: "Trace evidence",
caption: "Run records and trace frames make the execution inspectable after the fact.",
lifecycleStep: "Trace",
},
{
id: "boundary-wrap",
title: "Boundary",
caption: "lda.chat is the workflow substrate that an external or scripted agent can operate.",
lifecycleStep: "Boundary",
},
] as const;
const beatIds = new Set<BeatId>(presentationBeats.map((beat) => beat.id));
export const beatFromHash = (hash: string): BeatId => {
const id = hash.replace(/^#/, "") as BeatId;
return beatIds.has(id) ? id : "intro";
};
export const hashForBeat = (beat: BeatId): string => `#${beat}`;
@@ -1,41 +1,78 @@
import { describe, expect, it } from "vitest";
import {
compositionForState,
initialPresentationState,
presentationReducer,
} from "./presentation-state.js";
import type { MainLocation } from "./storyboard.js";
describe("presentationReducer", () => {
it("advances and rewinds scripted beats without changing playback mode", () => {
it("advances within a scene before advancing to the next scene", () => {
const advanced = presentationReducer(initialPresentationState, { type: "next" });
const rewound = presentationReducer(advanced, { type: "previous" });
expect(advanced.location).toEqual({ kind: "main", sceneId: "thesis", beatId: "substrate" });
expect(advanced.beat).toBe("chat-request");
expect(advanced.playbackMode).toBe("replay");
expect(rewound.beat).toBe("intro");
const advancedAgain = presentationReducer(advanced, { type: "next" });
expect(advancedAgain.location).toEqual({ kind: "main", sceneId: "problem", beatId: "direct-actions" });
});
it("opens node detail without changing the current beat", () => {
it("rewinds across scene boundaries", () => {
const state: MainLocation = { kind: "main", sceneId: "problem", beatId: "direct-actions" };
const rewound = presentationReducer(
{ ...initialPresentationState, location: state },
{ type: "previous" },
);
expect(rewound.location).toEqual({ kind: "main", sceneId: "thesis", beatId: "substrate" });
});
it("jumps to a specific scene and beat", () => {
const jumped = presentationReducer(initialPresentationState, {
type: "jump",
location: { kind: "main", sceneId: "workflow-demo", beatId: "graph" },
});
expect(jumped.location).toEqual({ kind: "main", sceneId: "workflow-demo", beatId: "graph" });
});
it("parses a scene hash", () => {
const state = presentationReducer(initialPresentationState, {
type: "jump_hash",
hash: "#scene/lifecycle/deployment",
});
expect(state.location).toEqual({ kind: "main", sceneId: "lifecycle", beatId: "deployment" });
});
it("falls back to default for invalid hash", () => {
const state = presentationReducer(initialPresentationState, {
type: "jump_hash",
hash: "#scene/nope/nope",
});
expect(state.location).toEqual({ kind: "main", sceneId: "thesis", beatId: "title" });
});
it("opens a discussion branch and returns to the originating beat", () => {
const positioned = presentationReducer(initialPresentationState, {
type: "jump",
location: { kind: "main", sceneId: "positioning", beatId: "lda-position" },
});
const opened = presentationReducer(positioned, {
type: "open_discussion",
branchId: "hosted-automation",
});
const closed = presentationReducer(opened, { type: "close_discussion" });
expect(opened.location).toEqual({ kind: "discussion", branchId: "hosted-automation" });
expect(closed.location).toEqual(positioned.location);
});
it("opens node detail without changing the current location", () => {
const state = presentationReducer(initialPresentationState, {
type: "select_node",
nodeId: "review_issues",
});
expect(state.beat).toBe("intro");
expect(state.location).toEqual(initialPresentationState.location);
expect(state.selectedNodeId).toBe("review_issues");
});
it("closes overlays before rewinding content", () => {
const opened = presentationReducer(initialPresentationState, {
type: "set_evidence_mode",
mode: "open",
});
const closed = presentationReducer(opened, { type: "close_overlay" });
expect(closed.evidenceMode).toBe("hidden");
expect(closed.beat).toBe("intro");
});
it("closes node spotlight before evidence drawer", () => {
it("closes overlays in priority order: node, evidence, discussion", () => {
const withNode = presentationReducer(initialPresentationState, {
type: "select_node",
nodeId: "review_issues",
@@ -44,12 +81,55 @@ describe("presentationReducer", () => {
type: "set_evidence_mode",
mode: "open",
});
const opened = presentationReducer(withEvidence, {
type: "open_discussion",
branchId: "hosted-automation",
});
expect(withEvidence.selectedNodeId).toBe("review_issues");
expect(withEvidence.evidenceMode).toBe("open");
const closed1 = presentationReducer(opened, { type: "close_overlay" });
expect(closed1.selectedNodeId).toBeNull();
expect(closed1.evidenceModeOverride).toBe("open");
expect(closed1.location.kind).toBe("discussion");
const afterEscape = presentationReducer(withEvidence, { type: "close_overlay" });
expect(afterEscape.selectedNodeId).toBeNull();
expect(afterEscape.evidenceMode).toBe("open");
const closed2 = presentationReducer(closed1, { type: "close_overlay" });
expect(closed2.evidenceModeOverride).toBeNull();
const closed3 = presentationReducer(closed2, { type: "close_overlay" });
expect(closed3.location.kind).toBe("main");
});
it("does nothing on next while a discussion branch is open", () => {
const positioned = presentationReducer(initialPresentationState, {
type: "jump",
location: { kind: "main", sceneId: "thesis", beatId: "title" },
});
const opened = presentationReducer(positioned, {
type: "open_discussion",
branchId: "direct-orchestration",
});
const nexted = presentationReducer(opened, { type: "next" });
expect(nexted.location).toEqual(opened.location);
});
it("derives act and chat composition from the current beat", () => {
const state = presentationReducer(initialPresentationState, {
type: "jump",
location: { kind: "main", sceneId: "workflow-demo", beatId: "graph" },
});
expect(compositionForState(state)).toMatchObject({
stageTheme: "night",
chatTheme: "light",
chatMode: "rail",
});
});
it("closes overlays before rewinding content", () => {
const opened = presentationReducer(initialPresentationState, {
type: "set_evidence_mode",
mode: "open",
});
const closed = presentationReducer(opened, { type: "close_overlay" });
expect(closed.evidenceModeOverride).toBeNull();
expect(closed.location).toEqual(initialPresentationState.location);
});
});
@@ -1,41 +1,118 @@
import { beatFromHash, presentationBeats, type BeatId } from "./beats.js";
import {
defaultMainLocation,
findDiscussionBranch,
findScene,
type ChatMode,
type ChatTheme,
type DiscussionBranchId,
type EvidenceMode,
type MainLocation,
type PresentationLocation,
type StageTheme,
} from "./storyboard.js";
import {
hashForLocation,
locationFromHash,
nextMainLocation,
previousMainLocation,
} from "./storyboard-navigation.js";
export type PresentationState = {
readonly beat: BeatId;
readonly location: PresentationLocation;
readonly discussionReturn: MainLocation | null;
readonly selectedNodeId: string | null;
readonly chatMode: "full" | "rail" | "hidden";
readonly evidenceMode: "hidden" | "peek" | "open";
readonly evidenceModeOverride: EvidenceMode | null;
readonly playbackMode: "replay" | "live";
readonly stageThemeOverride: StageTheme | null;
readonly chatThemeOverride: ChatTheme | null;
readonly chatModeOverride: ChatMode | null;
readonly controlsOpen: boolean;
};
export type PresentationAction =
| { readonly type: "next" }
| { readonly type: "previous" }
| { readonly type: "jump"; readonly beat: BeatId }
| { readonly type: "jump"; readonly location: PresentationLocation }
| { readonly type: "jump_hash"; readonly hash: string }
| { readonly type: "open_discussion"; readonly branchId: string }
| { readonly type: "close_discussion" }
| { readonly type: "select_node"; readonly nodeId: string }
| { readonly type: "clear_node" }
| { readonly type: "set_evidence_mode"; readonly mode: PresentationState["evidenceMode"] }
| { readonly type: "set_evidence_mode"; readonly mode: EvidenceMode }
| { readonly type: "close_overlay" }
| { readonly type: "set_playback_mode"; readonly mode: PresentationState["playbackMode"] };
| { readonly type: "set_playback_mode"; readonly mode: PresentationState["playbackMode"] }
| { readonly type: "set_stage_theme"; readonly theme: StageTheme | null }
| { readonly type: "set_chat_theme"; readonly theme: ChatTheme | null }
| { readonly type: "set_chat_mode"; readonly mode: ChatMode | null }
| { readonly type: "toggle_controls" };
export const initialPresentationState: PresentationState = {
beat: "intro",
location: defaultMainLocation,
discussionReturn: null,
selectedNodeId: null,
chatMode: "full",
evidenceMode: "hidden",
evidenceModeOverride: null,
playbackMode: "replay",
stageThemeOverride: null,
chatThemeOverride: null,
chatModeOverride: null,
controlsOpen: false,
};
const beatIndex = (beat: BeatId): number =>
presentationBeats.findIndex((candidate) => candidate.id === beat);
const compositionForLocation = (
location: PresentationLocation,
evidenceOverride: EvidenceMode | null,
stageThemeOverride: StageTheme | null,
chatThemeOverride: ChatTheme | null,
chatModeOverride: ChatMode | null,
): {
readonly stageTheme: StageTheme;
readonly chatTheme: ChatTheme;
readonly chatMode: ChatMode;
readonly evidenceMode: EvidenceMode;
} => {
if (location.kind === "discussion") {
const branch = findDiscussionBranch(location.branchId);
const parentScene = branch ? findScene(branch.parentSceneId) : findScene("positioning");
return {
stageTheme: stageThemeOverride ?? parentScene?.stageTheme ?? "paper",
chatTheme: chatThemeOverride ?? "dark",
chatMode: chatModeOverride ?? "hidden",
evidenceMode: evidenceOverride ?? "hidden",
};
}
const scene = findScene(location.sceneId);
const beat = scene?.beats.find((b) => b.id === location.beatId);
return {
stageTheme: stageThemeOverride ?? scene?.stageTheme ?? "paper",
chatTheme: chatThemeOverride ?? beat?.chatTheme ?? "dark",
chatMode: chatModeOverride ?? beat?.chatMode ?? "hidden",
evidenceMode: evidenceOverride ?? beat?.evidenceMode ?? "hidden",
};
};
const withDerivedModes = (state: PresentationState, beat: BeatId): PresentationState => ({
...state,
beat,
chatMode: beat === "intro" || beat === "chat-request" ? "full" : "rail",
evidenceMode: beat === "trace-evidence" ? "peek" : "hidden",
});
export const compositionForState = (state: PresentationState) =>
compositionForLocation(
state.location,
state.evidenceModeOverride,
state.stageThemeOverride,
state.chatThemeOverride,
state.chatModeOverride,
);
const isValidMainLocation = (location: PresentationLocation): location is MainLocation =>
location.kind === "main";
const firstBeatOfScene = (sceneId: string): MainLocation | null => {
const scene = findScene(sceneId);
if (!scene || scene.beats.length === 0) return null;
return { kind: "main", sceneId: scene.id as MainLocation["sceneId"], beatId: scene.beats[0]!.id };
};
const clampMainLocation = (location: MainLocation): MainLocation => {
const found = findScene(location.sceneId)?.beats.some((b) => b.id === location.beatId);
if (found) return location;
return defaultMainLocation;
};
export const presentationReducer = (
state: PresentationState,
@@ -43,28 +120,73 @@ export const presentationReducer = (
): PresentationState => {
switch (action.type) {
case "next": {
const next = Math.min(beatIndex(state.beat) + 1, presentationBeats.length - 1);
return withDerivedModes(state, presentationBeats[next]?.id ?? state.beat);
if (!isValidMainLocation(state.location)) return state;
const next = nextMainLocation(state.location);
return { ...state, location: next };
}
case "previous": {
const previous = Math.max(beatIndex(state.beat) - 1, 0);
return withDerivedModes(state, presentationBeats[previous]?.id ?? state.beat);
if (!isValidMainLocation(state.location)) return state;
const prev = previousMainLocation(state.location);
return { ...state, location: prev };
}
case "jump": {
if (state.location.kind === "discussion") return state;
return { ...state, location: action.location };
}
case "jump_hash": {
const parsed = locationFromHash(action.hash);
if (parsed.kind === "main") {
return { ...state, location: clampMainLocation(parsed) };
}
return { ...state, location: parsed };
}
case "open_discussion": {
const branch = findDiscussionBranch(action.branchId);
if (!branch) return state;
const returnLocation = isValidMainLocation(state.location)
? state.location
: firstBeatOfScene(branch.parentSceneId) ?? defaultMainLocation;
return {
...state,
location: { kind: "discussion", branchId: action.branchId as DiscussionBranchId },
discussionReturn: returnLocation,
};
}
case "close_discussion": {
if (state.location.kind !== "discussion") return state;
return {
...state,
location: state.discussionReturn ?? defaultMainLocation,
discussionReturn: null,
};
}
case "jump":
return withDerivedModes(state, action.beat);
case "jump_hash":
return withDerivedModes(state, beatFromHash(action.hash));
case "select_node":
return { ...state, selectedNodeId: action.nodeId };
case "clear_node":
return { ...state, selectedNodeId: null };
case "set_evidence_mode":
return { ...state, evidenceMode: action.mode };
case "close_overlay":
return { ...state, evidenceModeOverride: action.mode };
case "close_overlay": {
if (state.selectedNodeId !== null) return { ...state, selectedNodeId: null };
if (state.evidenceMode !== "hidden") return { ...state, evidenceMode: "hidden" };
if (state.evidenceModeOverride !== null) return { ...state, evidenceModeOverride: null };
if (state.location.kind === "discussion") {
return {
...state,
location: state.discussionReturn ?? defaultMainLocation,
discussionReturn: null,
};
}
return state;
}
case "set_playback_mode":
return { ...state, playbackMode: action.mode };
case "set_stage_theme":
return { ...state, stageThemeOverride: action.theme };
case "set_chat_theme":
return { ...state, chatThemeOverride: action.theme };
case "set_chat_mode":
return { ...state, chatModeOverride: action.mode };
case "toggle_controls":
return { ...state, controlsOpen: !state.controlsOpen };
}
};
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
hashForLocation,
locationFromHash,
nextMainLocation,
previousMainLocation,
} from "./storyboard-navigation.js";
import { defaultMainLocation, type MainLocation } from "./storyboard.js";
describe("storyboard navigation", () => {
it("round-trips main and discussion hashes", () => {
const main: MainLocation = { kind: "main", sceneId: "lifecycle", beatId: "deployment" };
expect(locationFromHash(hashForLocation(main))).toEqual(main);
expect(locationFromHash("#discuss/hosted-automation")).toEqual({
kind: "discussion",
branchId: "hosted-automation",
});
});
it("falls back for unknown scene, beat, and branch hashes", () => {
expect(locationFromHash("#scene/missing/nope")).toEqual(defaultMainLocation);
expect(locationFromHash("#scene/lifecycle/nope")).toEqual(defaultMainLocation);
expect(locationFromHash("#discuss/nope")).toEqual(defaultMainLocation);
});
it("advances within a scene before advancing to the next scene", () => {
expect(nextMainLocation({ kind: "main", sceneId: "thesis", beatId: "title" })).toEqual({
kind: "main",
sceneId: "thesis",
beatId: "substrate",
});
expect(nextMainLocation({ kind: "main", sceneId: "thesis", beatId: "substrate" })).toEqual({
kind: "main",
sceneId: "problem",
beatId: "direct-actions",
});
});
it("rewinds across scene boundaries", () => {
expect(previousMainLocation({ kind: "main", sceneId: "problem", beatId: "direct-actions" })).toEqual({
kind: "main",
sceneId: "thesis",
beatId: "substrate",
});
});
});
@@ -0,0 +1,60 @@
import {
defaultMainLocation,
findDiscussionBranch,
findScene,
mainScenes,
type DiscussionLocation,
type MainLocation,
type PresentationLocation,
} from "./storyboard.js";
const flattenMainLocations = (): readonly MainLocation[] =>
mainScenes.flatMap((scene) =>
scene.beats.map((beat) => ({ kind: "main" as const, sceneId: scene.id, beatId: beat.id })),
);
export const hashForLocation = (location: PresentationLocation): string =>
location.kind === "main"
? `#scene/${encodeURIComponent(location.sceneId)}/${encodeURIComponent(location.beatId)}`
: `#discuss/${encodeURIComponent(location.branchId)}`;
export const locationFromHash = (hash: string): PresentationLocation => {
const raw = hash.replace(/^#/, "");
const sceneMatch = raw.match(/^scene\/([^/]+)\/(.+)$/);
if (sceneMatch) {
const sceneId = decodeURIComponent(sceneMatch[1]!);
const beatId = decodeURIComponent(sceneMatch[2]!);
const scene = findScene(sceneId);
if (scene && scene.beats.some((b) => b.id === beatId)) {
return { kind: "main", sceneId: scene.id as MainLocation["sceneId"], beatId };
}
return defaultMainLocation;
}
const discussMatch = raw.match(/^discuss\/(.+)$/);
if (discussMatch) {
const branchId = decodeURIComponent(discussMatch[1]!);
if (findDiscussionBranch(branchId)) {
return { kind: "discussion", branchId: branchId as DiscussionLocation["branchId"] };
}
return defaultMainLocation;
}
return defaultMainLocation;
};
export const nextMainLocation = (current: MainLocation): MainLocation => {
const locations = flattenMainLocations();
const index = locations.findIndex(
(loc) => loc.sceneId === current.sceneId && loc.beatId === current.beatId,
);
if (index === -1 || index === locations.length - 1) return current;
return locations[index + 1]!;
};
export const previousMainLocation = (current: MainLocation): MainLocation => {
const locations = flattenMainLocations();
const index = locations.findIndex(
(loc) => loc.sceneId === current.sceneId && loc.beatId === current.beatId,
);
if (index <= 0) return current;
return locations[index - 1]!;
};