feat: persist presentation figure focus

- Add focusPath to MainLocation and FigureBeatDefinition to SceneBeatDefinition
- Canonical hash format: #scene/<scene>/<beat>/focus/<segment>/<segment>
- Add set_focus_path reducer action
- Next/previous restores destination beat's canonical Focus Path
- Discussion return preserves exact originating Focus Path
- Fix catalog cycle detection to cover disconnected subgraphs
- Add FigureCatalogIssue typed issue model
- Fix test fixtures using nonexistent edge endpoints
- Remove unsafe non-null assertions in catalog and focus modules
This commit is contained in:
lda
2026-07-05 17:58:57 +07:00 Verified
parent b44ef12ca4
commit 91748e1277
14 changed files with 212 additions and 60 deletions
@@ -174,7 +174,7 @@ export const PresentationRoute = () => {
if (state.location.kind !== "main") return;
const scene = findScene(state.location.sceneId);
if (!scene || scene.beats.length === 0) return;
dispatch({ type: "jump", location: { kind: "main", sceneId: state.location.sceneId, beatId: scene.beats[0]!.id } });
dispatch({ type: "jump", location: { kind: "main", sceneId: state.location.sceneId, beatId: scene.beats[0]!.id, focusPath: scene.beats[0]!.figure?.focusPath ?? [] } });
}, [state.location]);
const handleToggleMotion = useCallback(() => {
@@ -38,7 +38,7 @@ afterEach(() => cleanup());
describe("SceneBody", () => {
it("renders narrative metadata without mounting the demo graph", () => {
const location: PresentationLocation = { kind: "main", sceneId: "positioning", beatId: "landscape" };
const location: PresentationLocation = { kind: "main", sceneId: "positioning", beatId: "landscape", focusPath: [] };
render(
<SceneBody
location={location}
@@ -53,7 +53,7 @@ describe("SceneBody", () => {
});
it("renders the real workflow graph for demo scenes", () => {
const location: PresentationLocation = { kind: "main", sceneId: "workflow-demo", beatId: "graph" };
const location: PresentationLocation = { kind: "main", sceneId: "workflow-demo", beatId: "graph", focusPath: [] };
render(
<SceneBody
location={location}
@@ -19,7 +19,7 @@ export const SceneRail = ({ location, jump }: SceneRailProps) => {
type="button"
data-active={isActive}
aria-current={isActive ? "step" : undefined}
onClick={() => jump({ kind: "main", sceneId: scene.id as MainLocation["sceneId"], beatId: scene.beats[0]!.id })}
onClick={() => jump({ kind: "main", sceneId: scene.id as MainLocation["sceneId"], beatId: scene.beats[0]!.id, focusPath: scene.beats[0]!.figure?.focusPath ?? [] })}
className="scene-rail__scene"
>
<span className="scene-rail__number">{scene.number}</span>
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { defineFigureCatalog } from "./catalog.js";
import {
cyclicCatalog,
disconnectedCyclicCatalog,
duplicateFigureCatalog,
duplicateNodeCatalog,
unknownChildCatalog,
@@ -22,6 +23,7 @@ describe("defineFigureCatalog", () => {
["unknown edge endpoint", unknownEdgeCatalog, "unknown_edge_endpoint"],
["unknown child figure", unknownChildCatalog, "unknown_child_figure"],
["recursive child cycle", cyclicCatalog, "child_cycle"],
["disconnected child cycle", disconnectedCyclicCatalog, "child_cycle"],
])("rejects %s", (_label, catalog, code) => {
expect(() => defineFigureCatalog(catalog)).toThrow(code);
});
@@ -1,9 +1,33 @@
import type {
FigureCatalogDefinition,
FigureDefinition,
FigureNodeDefinition,
} from "./model.js";
export type FigureCatalogIssue =
| { readonly code: "duplicate_figure"; readonly figureId: string }
| { readonly code: "duplicate_node"; readonly figureId: string; readonly nodeId: string }
| { readonly code: "unknown_root_figure"; readonly figureId: string }
| { readonly code: "unknown_edge_endpoint"; readonly figureId: string; readonly endpointId: string }
| { readonly code: "unknown_child_figure"; readonly figureId: string; readonly childFigureId: string }
| { readonly code: "child_cycle"; readonly fromFigureId: string; readonly toFigureId: string };
const issueToCode = (issue: FigureCatalogIssue): string => {
switch (issue.code) {
case "duplicate_figure":
return `duplicate_figure:${issue.figureId}`;
case "duplicate_node":
return `duplicate_node:${issue.figureId}:${issue.nodeId}`;
case "unknown_root_figure":
return `unknown_root_figure:${issue.figureId}`;
case "unknown_edge_endpoint":
return `unknown_edge_endpoint:${issue.figureId}:${issue.endpointId}`;
case "unknown_child_figure":
return `unknown_child_figure:${issue.figureId}:${issue.childFigureId}`;
case "child_cycle":
return `child_cycle:${issue.fromFigureId}:${issue.toFigureId}`;
}
};
/**
* Validates and returns a figure catalog. Static authored data is validated once
* at module load; user or server payloads are not accepted through this interface.
@@ -14,18 +38,18 @@ import type {
export const defineFigureCatalog = (
catalog: FigureCatalogDefinition,
): FigureCatalogDefinition => {
const issues: string[] = [];
const issues: FigureCatalogIssue[] = [];
const figureById = new Map<string, FigureDefinition>();
for (const figure of catalog.figures) {
if (figureById.has(figure.id)) {
issues.push(`duplicate_figure:${figure.id}`);
issues.push({ code: "duplicate_figure", figureId: figure.id });
}
figureById.set(figure.id, figure);
}
if (!figureById.has(catalog.rootFigureId)) {
issues.push(`unknown_root_figure:${catalog.rootFigureId}`);
issues.push({ code: "unknown_root_figure", figureId: catalog.rootFigureId });
}
const nodeIdsByFigure = new Map<string, Set<string>>();
@@ -33,7 +57,7 @@ export const defineFigureCatalog = (
const nodeIds = new Set<string>();
for (const node of figure.nodes) {
if (nodeIds.has(node.id)) {
issues.push(`duplicate_node:${figure.id}:${node.id}`);
issues.push({ code: "duplicate_node", figureId: figure.id, nodeId: node.id });
}
nodeIds.add(node.id);
}
@@ -41,13 +65,14 @@ export const defineFigureCatalog = (
}
for (const figure of catalog.figures) {
const nodeIds = nodeIdsByFigure.get(figure.id)!;
const nodeIds = nodeIdsByFigure.get(figure.id);
if (!nodeIds) continue;
for (const edge of figure.edges) {
if (!nodeIds.has(edge.from)) {
issues.push(`unknown_edge_endpoint:${figure.id}:${edge.from}`);
issues.push({ code: "unknown_edge_endpoint", figureId: figure.id, endpointId: edge.from });
}
if (!nodeIds.has(edge.to)) {
issues.push(`unknown_edge_endpoint:${figure.id}:${edge.to}`);
issues.push({ code: "unknown_edge_endpoint", figureId: figure.id, endpointId: edge.to });
}
}
}
@@ -56,35 +81,39 @@ export const defineFigureCatalog = (
for (const node of figure.nodes) {
if (node.childFigureId !== undefined) {
if (!figureById.has(node.childFigureId)) {
issues.push(`unknown_child_figure:${figure.id}:${node.childFigureId}`);
issues.push({ code: "unknown_child_figure", figureId: figure.id, childFigureId: node.childFigureId });
}
}
}
}
const visited = new Set<string>();
const inStack = new Set<string>();
const visitChildChains = (figureId: string, path: string[]) => {
// Detect child-figure cycles from every figure, not just the root, so
// disconnected subgraphs with cycles are also caught.
const edgeVisited = new Set<string>();
const edgeInStack = new Set<string>();
const visitChildChains = (figureId: string) => {
const figure = figureById.get(figureId);
if (!figure) return;
for (const node of figure.nodes) {
if (node.childFigureId === undefined) continue;
const key = `${figureId}:${node.childFigureId}`;
if (inStack.has(key)) {
issues.push(`child_cycle:${figureId}:${node.childFigureId}`);
const edgeKey = `${figureId}:${node.childFigureId}`;
if (edgeInStack.has(edgeKey)) {
issues.push({ code: "child_cycle", fromFigureId: figureId, toFigureId: node.childFigureId });
continue;
}
if (visited.has(key)) continue;
visited.add(key);
inStack.add(key);
visitChildChains(node.childFigureId, [...path, node.childFigureId]);
inStack.delete(key);
if (edgeVisited.has(edgeKey)) continue;
edgeVisited.add(edgeKey);
edgeInStack.add(edgeKey);
visitChildChains(node.childFigureId);
edgeInStack.delete(edgeKey);
}
};
visitChildChains(catalog.rootFigureId, [catalog.rootFigureId]);
for (const figure of catalog.figures) {
visitChildChains(figure.id);
}
if (issues.length > 0) {
throw new Error(issues.join(";"));
throw new Error(issues.map(issueToCode).join(";"));
}
return catalog;
@@ -42,7 +42,8 @@ const buildBreadcrumbs = (
if (!node?.childFigureId) break;
const childFigure = findFigure(catalog, node.childFigureId);
if (!childFigure) break;
crumbs.push({ label: node.label, path: [...crumbs[crumbs.length - 1]!.path, segment] });
const prevCrumb = crumbs[crumbs.length - 1];
crumbs.push({ label: node.label, path: [...(prevCrumb?.path ?? []), segment] });
currentFigure = childFigure;
}
@@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest";
import type { FigureDefinition, FigureNodeDefinition } from "./model.js";
import { layoutFigure, type PositionedFigure } from "./layout.js";
const position = (layout: PositionedFigure, nodeId: string) =>
layout.nodes.find((n) => n.id === nodeId)!.position;
const position = (layout: PositionedFigure, nodeId: string) => {
const node = layout.nodes.find((n) => n.id === nodeId);
if (!node) throw new Error(`node ${nodeId} not found in layout`);
return node.position;
};
describe("layoutFigure", () => {
it("places layered edges from top to bottom", () => {
@@ -98,7 +98,7 @@ export const duplicateNodeCatalog: FigureCatalogDefinition = {
...overviewFigure,
nodes: [
...overviewFigure.nodes,
{ id: "client", label: "Dup", summary: "Dup", kind: "actor" as const },
{ id: "client", label: "Dup", summary: "Dup", kind: "actor" },
],
},
runtimeDetailFigure,
@@ -158,8 +158,33 @@ export const cyclicCatalog: FigureCatalogDefinition = {
],
};
const edgeA: FigureEdgeDefinition = { id: "e1", from: "a", to: "b" };
const edgeB: FigureEdgeDefinition = { id: "e2", from: "b", to: "c" };
export const disconnectedCyclicCatalog: FigureCatalogDefinition = {
rootFigureId: "architecture-overview",
figures: [
overviewFigure,
{
id: "disconnected",
title: "Disconnected",
layout: { kind: "layered" as const },
nodes: [
{ id: "x", label: "X", summary: "x", kind: "actor" as const, childFigureId: "disconnected-2" },
],
edges: [],
},
{
id: "disconnected-2",
title: "Disconnected 2",
layout: { kind: "layered" as const },
nodes: [
{ id: "y", label: "Y", summary: "y", kind: "actor" as const, childFigureId: "disconnected" },
],
edges: [],
},
],
};
const edgeA: FigureEdgeDefinition = { id: "e1", from: "client", to: "runtime" };
const edgeB: FigureEdgeDefinition = { id: "e2", from: "discover", to: "repair" };
export const layeredFigure: FigureDefinition = {
id: "layered",
@@ -9,27 +9,27 @@ import type { MainLocation } from "./storyboard.js";
describe("presentationReducer", () => {
it("advances within a scene before advancing to the next scene", () => {
const advanced = presentationReducer(initialPresentationState, { type: "next" });
expect(advanced.location).toEqual({ kind: "main", sceneId: "thesis", beatId: "substrate" });
expect(advanced.location).toEqual({ kind: "main", sceneId: "thesis", beatId: "substrate", focusPath: [] });
const advancedAgain = presentationReducer(advanced, { type: "next" });
expect(advancedAgain.location).toEqual({ kind: "main", sceneId: "problem", beatId: "direct-actions" });
expect(advancedAgain.location).toEqual({ kind: "main", sceneId: "problem", beatId: "direct-actions", focusPath: [] });
});
it("rewinds across scene boundaries", () => {
const state: MainLocation = { kind: "main", sceneId: "problem", beatId: "direct-actions" };
const state: MainLocation = { kind: "main", sceneId: "problem", beatId: "direct-actions", focusPath: [] };
const rewound = presentationReducer(
{ ...initialPresentationState, location: state },
{ type: "previous" },
);
expect(rewound.location).toEqual({ kind: "main", sceneId: "thesis", beatId: "substrate" });
expect(rewound.location).toEqual({ kind: "main", sceneId: "thesis", beatId: "substrate", focusPath: [] });
});
it("jumps to a specific scene and beat", () => {
const jumped = presentationReducer(initialPresentationState, {
type: "jump",
location: { kind: "main", sceneId: "workflow-demo", beatId: "graph" },
location: { kind: "main", sceneId: "workflow-demo", beatId: "graph", focusPath: [] },
});
expect(jumped.location).toEqual({ kind: "main", sceneId: "workflow-demo", beatId: "graph" });
expect(jumped.location).toEqual({ kind: "main", sceneId: "workflow-demo", beatId: "graph", focusPath: [] });
});
it("parses a scene hash", () => {
@@ -37,7 +37,7 @@ describe("presentationReducer", () => {
type: "jump_hash",
hash: "#scene/lifecycle/deployment",
});
expect(state.location).toEqual({ kind: "main", sceneId: "lifecycle", beatId: "deployment" });
expect(state.location).toEqual({ kind: "main", sceneId: "lifecycle", beatId: "deployment", focusPath: [] });
});
it("falls back to default for invalid hash", () => {
@@ -45,13 +45,13 @@ describe("presentationReducer", () => {
type: "jump_hash",
hash: "#scene/nope/nope",
});
expect(state.location).toEqual({ kind: "main", sceneId: "thesis", beatId: "title" });
expect(state.location).toEqual({ kind: "main", sceneId: "thesis", beatId: "title", focusPath: [] });
});
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" },
location: { kind: "main", sceneId: "positioning", beatId: "lda-position", focusPath: [] },
});
const opened = presentationReducer(positioned, {
type: "open_discussion",
@@ -101,7 +101,7 @@ describe("presentationReducer", () => {
it("does nothing on next while a discussion branch is open", () => {
const positioned = presentationReducer(initialPresentationState, {
type: "jump",
location: { kind: "main", sceneId: "thesis", beatId: "title" },
location: { kind: "main", sceneId: "thesis", beatId: "title", focusPath: [] },
});
const opened = presentationReducer(positioned, {
type: "open_discussion",
@@ -114,7 +114,7 @@ describe("presentationReducer", () => {
it("derives act and chat composition from the current beat", () => {
const state = presentationReducer(initialPresentationState, {
type: "jump",
location: { kind: "main", sceneId: "workflow-demo", beatId: "graph" },
location: { kind: "main", sceneId: "workflow-demo", beatId: "graph", focusPath: [] },
});
expect(compositionForState(state)).toMatchObject({
stageTheme: "night",
@@ -136,7 +136,7 @@ describe("presentationReducer", () => {
it("does not reopen evidence on repeated Escape after force-close", () => {
const stateAtTrace = presentationReducer(initialPresentationState, {
type: "jump",
location: { kind: "main", sceneId: "interrupt-evidence", beatId: "trace" },
location: { kind: "main", sceneId: "interrupt-evidence", beatId: "trace", focusPath: [] },
});
expect(stateAtTrace.evidenceModeOverride).toBeNull();
@@ -147,4 +147,42 @@ describe("presentationReducer", () => {
expect(secondEscape.evidenceModeOverride).toBe("hidden");
expect(secondEscape.location.kind).toBe("main");
});
it("sets focus without changing scene or beat", () => {
const focused = presentationReducer(initialPresentationState, {
type: "set_focus_path",
path: ["runtime-providers"],
});
expect(focused.location).toEqual({
...initialPresentationState.location,
focusPath: ["runtime-providers"],
});
});
it("next applies the destination beat canonical Focus Path", () => {
const manuallyFocused = presentationReducer(initialPresentationState, {
type: "jump",
location: { kind: "main", sceneId: "thesis", beatId: "title", focusPath: ["manual-explore"] },
});
const next = presentationReducer(manuallyFocused, { type: "next" });
expect(next.location).toEqual({
kind: "main",
sceneId: "thesis",
beatId: "substrate",
focusPath: [],
});
});
it("discussion return restores the exact Focus Path", () => {
const deepRuntimeState = {
...initialPresentationState,
location: { kind: "main" as const, sceneId: "architecture" as const, beatId: "runtime", focusPath: ["runtime-providers"] },
};
const opened = presentationReducer(deepRuntimeState, {
type: "open_discussion",
branchId: "provider-security",
});
expect(presentationReducer(opened, { type: "close_discussion" }).location)
.toEqual(deepRuntimeState.location);
});
});
@@ -47,6 +47,7 @@ export type PresentationAction =
| { 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: "set_focus_path"; readonly path: readonly string[] }
| { readonly type: "toggle_controls" }
| { readonly type: "toggle_discussion_index" }
| { readonly type: "toggle_motion" };
@@ -113,7 +114,8 @@ const isValidMainLocation = (location: PresentationLocation): location is MainLo
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 beat = scene.beats[0]!;
return { kind: "main", sceneId: scene.id as MainLocation["sceneId"], beatId: beat.id, focusPath: beat.figure?.focusPath ?? [] };
};
const clampMainLocation = (location: MainLocation): MainLocation => {
@@ -207,6 +209,12 @@ export const presentationReducer = (
return { ...state, chatThemeOverride: action.theme };
case "set_chat_mode":
return { ...state, chatModeOverride: action.mode };
case "set_focus_path":
if (state.location.kind !== "main") return state;
return {
...state,
location: { ...state.location, focusPath: action.path },
};
case "toggle_controls":
return { ...state, controlsOpen: !state.controlsOpen };
case "toggle_discussion_index":
@@ -9,7 +9,7 @@ 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" };
const main: MainLocation = { kind: "main", sceneId: "lifecycle", beatId: "deployment", focusPath: [] };
expect(locationFromHash(hashForLocation(main))).toEqual(main);
expect(locationFromHash("#discuss/hosted-automation")).toEqual({
kind: "discussion",
@@ -28,24 +28,47 @@ describe("storyboard navigation", () => {
expect(locationFromHash("#discuss/%ZZ")).toEqual(defaultMainLocation);
});
it("round-trips a recursive Focus Path", () => {
const location: MainLocation = {
kind: "main",
sceneId: "architecture",
beatId: "runtime",
focusPath: ["runtime-providers", "configured-providers"],
};
expect(hashForLocation(location)).toBe(
"#scene/architecture/runtime/focus/runtime-providers/configured-providers",
);
expect(locationFromHash(hashForLocation(location))).toEqual(location);
});
it("decodes escaped focus segments and rejects malformed encoding", () => {
expect(locationFromHash("#scene/architecture/runtime/focus/runtime%20providers"))
.toMatchObject({ focusPath: ["runtime providers"] });
expect(locationFromHash("#scene/architecture/runtime/focus/%ZZ"))
.toEqual(defaultMainLocation);
});
it("advances within a scene before advancing to the next scene", () => {
expect(nextMainLocation({ kind: "main", sceneId: "thesis", beatId: "title" })).toEqual({
expect(nextMainLocation({ kind: "main", sceneId: "thesis", beatId: "title", focusPath: [] })).toEqual({
kind: "main",
sceneId: "thesis",
beatId: "substrate",
focusPath: [],
});
expect(nextMainLocation({ kind: "main", sceneId: "thesis", beatId: "substrate" })).toEqual({
expect(nextMainLocation({ kind: "main", sceneId: "thesis", beatId: "substrate", focusPath: [] })).toEqual({
kind: "main",
sceneId: "problem",
beatId: "direct-actions",
focusPath: [],
});
});
it("rewinds across scene boundaries", () => {
expect(previousMainLocation({ kind: "main", sceneId: "problem", beatId: "direct-actions" })).toEqual({
expect(previousMainLocation({ kind: "main", sceneId: "problem", beatId: "direct-actions", focusPath: [] })).toEqual({
kind: "main",
sceneId: "thesis",
beatId: "substrate",
focusPath: [],
});
});
});
@@ -10,29 +10,43 @@ import {
const flattenMainLocations = (): readonly MainLocation[] =>
mainScenes.flatMap((scene) =>
scene.beats.map((beat) => ({ kind: "main" as const, sceneId: scene.id, beatId: beat.id })),
scene.beats.map((beat) => ({
kind: "main" as const,
sceneId: scene.id,
beatId: beat.id,
focusPath: beat.figure?.focusPath ?? [],
})),
);
export const hashForLocation = (location: PresentationLocation): string =>
location.kind === "main"
? `#scene/${encodeURIComponent(location.sceneId)}/${encodeURIComponent(location.beatId)}`
: `#discuss/${encodeURIComponent(location.branchId)}`;
export const hashForLocation = (location: PresentationLocation): string => {
if (location.kind === "discussion") {
return `#discuss/${encodeURIComponent(location.branchId)}`;
}
const base = `#scene/${encodeURIComponent(location.sceneId)}/${encodeURIComponent(location.beatId)}`;
if (location.focusPath.length === 0) return base;
const segments = location.focusPath.map(encodeURIComponent).join("/");
return `${base}/focus/${segments}`;
};
export const locationFromHash = (hash: string): PresentationLocation => {
const raw = hash.replace(/^#/, "");
const sceneMatch = raw.match(/^scene\/([^/]+)\/(.+)$/);
const sceneMatch = raw.match(/^scene\/([^/]+)\/([^/]+)(?:\/focus\/(.*))?$/);
if (sceneMatch) {
let sceneId: string;
let beatId: string;
let focusPath: string[] = [];
try {
sceneId = decodeURIComponent(sceneMatch[1]!);
beatId = decodeURIComponent(sceneMatch[2]!);
if (sceneMatch[3] !== undefined && sceneMatch[3] !== "") {
focusPath = sceneMatch[3]!.split("/").map(decodeURIComponent);
}
} catch {
return defaultMainLocation;
}
const scene = findScene(sceneId);
if (scene && scene.beats.some((b) => b.id === beatId)) {
return { kind: "main", sceneId: scene.id as MainLocation["sceneId"], beatId };
return { kind: "main", sceneId: scene.id as MainLocation["sceneId"], beatId, focusPath };
}
return defaultMainLocation;
}
@@ -59,7 +59,7 @@ describe("defense storyboard catalog", () => {
});
it("exposes a valid default location", () => {
expect(defaultMainLocation).toEqual({ kind: "main", sceneId: "thesis", beatId: "title" });
expect(defaultMainLocation).toEqual({ kind: "main", sceneId: "thesis", beatId: "title", focusPath: [] });
expect(findScene(defaultMainLocation.sceneId)?.number).toBe(1);
});
});
@@ -15,6 +15,12 @@ export type SceneView =
| "evaluation"
| "conclusion";
export type FigureBeatDefinition = {
readonly catalogId: string;
readonly focusPath: readonly string[];
readonly activeNodeId: string | null;
};
export type SceneBeatDefinition = {
readonly id: string;
readonly title: string;
@@ -22,6 +28,7 @@ export type SceneBeatDefinition = {
readonly chatMode: ChatMode;
readonly chatTheme: ChatTheme;
readonly evidenceMode: EvidenceMode;
readonly figure: FigureBeatDefinition | null;
};
export type SceneDefinition = {
@@ -41,7 +48,7 @@ const sceneBeat = (
id: string,
title: string,
caption: string,
options: Partial<Pick<SceneBeatDefinition, "chatMode" | "chatTheme" | "evidenceMode">> = {},
options: Partial<Pick<SceneBeatDefinition, "chatMode" | "chatTheme" | "evidenceMode" | "figure">> = {},
): SceneBeatDefinition => ({
id,
title,
@@ -49,6 +56,7 @@ const sceneBeat = (
chatMode: options.chatMode ?? "hidden",
chatTheme: options.chatTheme ?? "dark",
evidenceMode: options.evidenceMode ?? "hidden",
figure: options.figure ?? null,
});
export const mainScenes = defineScenes([
@@ -228,6 +236,7 @@ export type MainLocation = {
readonly kind: "main";
readonly sceneId: MainSceneId;
readonly beatId: string;
readonly focusPath: readonly string[];
};
export type DiscussionBranchDefinition = {
@@ -368,12 +377,12 @@ export const findBeat = (sceneId: string, beatId: string): SceneBeatDefinition |
export const findDiscussionBranch = (branchId: string): DiscussionBranchDefinition | undefined =>
discussionBranches.find((branch) => branch.id === branchId);
export const defaultMainLocation: MainLocation = { kind: "main", sceneId: "thesis", beatId: "title" };
export const defaultMainLocation: MainLocation = { kind: "main", sceneId: "thesis", beatId: "title", focusPath: [] };
export const mainLocation = (sceneId: string, beatId: string): MainLocation | null => {
const scene = findScene(sceneId);
if (!scene) return null;
const beat = scene.beats.find((b) => b.id === beatId);
if (!beat) return null;
return { kind: "main", sceneId: scene.id as MainSceneId, beatId: beat.id };
return { kind: "main", sceneId: scene.id as MainSceneId, beatId: beat.id, focusPath: beat.figure?.focusPath ?? [] };
};