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; if (state.location.kind !== "main") return;
const scene = findScene(state.location.sceneId); const scene = findScene(state.location.sceneId);
if (!scene || scene.beats.length === 0) return; 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]); }, [state.location]);
const handleToggleMotion = useCallback(() => { const handleToggleMotion = useCallback(() => {
@@ -38,7 +38,7 @@ afterEach(() => cleanup());
describe("SceneBody", () => { describe("SceneBody", () => {
it("renders narrative metadata without mounting the demo graph", () => { 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( render(
<SceneBody <SceneBody
location={location} location={location}
@@ -53,7 +53,7 @@ describe("SceneBody", () => {
}); });
it("renders the real workflow graph for demo scenes", () => { 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( render(
<SceneBody <SceneBody
location={location} location={location}
@@ -19,7 +19,7 @@ export const SceneRail = ({ location, jump }: SceneRailProps) => {
type="button" type="button"
data-active={isActive} data-active={isActive}
aria-current={isActive ? "step" : undefined} 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" className="scene-rail__scene"
> >
<span className="scene-rail__number">{scene.number}</span> <span className="scene-rail__number">{scene.number}</span>
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { defineFigureCatalog } from "./catalog.js"; import { defineFigureCatalog } from "./catalog.js";
import { import {
cyclicCatalog, cyclicCatalog,
disconnectedCyclicCatalog,
duplicateFigureCatalog, duplicateFigureCatalog,
duplicateNodeCatalog, duplicateNodeCatalog,
unknownChildCatalog, unknownChildCatalog,
@@ -22,6 +23,7 @@ describe("defineFigureCatalog", () => {
["unknown edge endpoint", unknownEdgeCatalog, "unknown_edge_endpoint"], ["unknown edge endpoint", unknownEdgeCatalog, "unknown_edge_endpoint"],
["unknown child figure", unknownChildCatalog, "unknown_child_figure"], ["unknown child figure", unknownChildCatalog, "unknown_child_figure"],
["recursive child cycle", cyclicCatalog, "child_cycle"], ["recursive child cycle", cyclicCatalog, "child_cycle"],
["disconnected child cycle", disconnectedCyclicCatalog, "child_cycle"],
])("rejects %s", (_label, catalog, code) => { ])("rejects %s", (_label, catalog, code) => {
expect(() => defineFigureCatalog(catalog)).toThrow(code); expect(() => defineFigureCatalog(catalog)).toThrow(code);
}); });
@@ -1,9 +1,33 @@
import type { import type {
FigureCatalogDefinition, FigureCatalogDefinition,
FigureDefinition, FigureDefinition,
FigureNodeDefinition,
} from "./model.js"; } 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 * 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. * at module load; user or server payloads are not accepted through this interface.
@@ -14,18 +38,18 @@ import type {
export const defineFigureCatalog = ( export const defineFigureCatalog = (
catalog: FigureCatalogDefinition, catalog: FigureCatalogDefinition,
): FigureCatalogDefinition => { ): FigureCatalogDefinition => {
const issues: string[] = []; const issues: FigureCatalogIssue[] = [];
const figureById = new Map<string, FigureDefinition>(); const figureById = new Map<string, FigureDefinition>();
for (const figure of catalog.figures) { for (const figure of catalog.figures) {
if (figureById.has(figure.id)) { if (figureById.has(figure.id)) {
issues.push(`duplicate_figure:${figure.id}`); issues.push({ code: "duplicate_figure", figureId: figure.id });
} }
figureById.set(figure.id, figure); figureById.set(figure.id, figure);
} }
if (!figureById.has(catalog.rootFigureId)) { 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>>(); const nodeIdsByFigure = new Map<string, Set<string>>();
@@ -33,7 +57,7 @@ export const defineFigureCatalog = (
const nodeIds = new Set<string>(); const nodeIds = new Set<string>();
for (const node of figure.nodes) { for (const node of figure.nodes) {
if (nodeIds.has(node.id)) { 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); nodeIds.add(node.id);
} }
@@ -41,13 +65,14 @@ export const defineFigureCatalog = (
} }
for (const figure of catalog.figures) { 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) { for (const edge of figure.edges) {
if (!nodeIds.has(edge.from)) { 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)) { 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) { for (const node of figure.nodes) {
if (node.childFigureId !== undefined) { if (node.childFigureId !== undefined) {
if (!figureById.has(node.childFigureId)) { 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>(); // Detect child-figure cycles from every figure, not just the root, so
const inStack = new Set<string>(); // disconnected subgraphs with cycles are also caught.
const visitChildChains = (figureId: string, path: string[]) => { const edgeVisited = new Set<string>();
const edgeInStack = new Set<string>();
const visitChildChains = (figureId: string) => {
const figure = figureById.get(figureId); const figure = figureById.get(figureId);
if (!figure) return; if (!figure) return;
for (const node of figure.nodes) { for (const node of figure.nodes) {
if (node.childFigureId === undefined) continue; if (node.childFigureId === undefined) continue;
const key = `${figureId}:${node.childFigureId}`; const edgeKey = `${figureId}:${node.childFigureId}`;
if (inStack.has(key)) { if (edgeInStack.has(edgeKey)) {
issues.push(`child_cycle:${figureId}:${node.childFigureId}`); issues.push({ code: "child_cycle", fromFigureId: figureId, toFigureId: node.childFigureId });
continue; continue;
} }
if (visited.has(key)) continue; if (edgeVisited.has(edgeKey)) continue;
visited.add(key); edgeVisited.add(edgeKey);
inStack.add(key); edgeInStack.add(edgeKey);
visitChildChains(node.childFigureId, [...path, node.childFigureId]); visitChildChains(node.childFigureId);
inStack.delete(key); edgeInStack.delete(edgeKey);
} }
}; };
visitChildChains(catalog.rootFigureId, [catalog.rootFigureId]); for (const figure of catalog.figures) {
visitChildChains(figure.id);
}
if (issues.length > 0) { if (issues.length > 0) {
throw new Error(issues.join(";")); throw new Error(issues.map(issueToCode).join(";"));
} }
return catalog; return catalog;
@@ -42,7 +42,8 @@ const buildBreadcrumbs = (
if (!node?.childFigureId) break; if (!node?.childFigureId) break;
const childFigure = findFigure(catalog, node.childFigureId); const childFigure = findFigure(catalog, node.childFigureId);
if (!childFigure) break; 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; currentFigure = childFigure;
} }
@@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest";
import type { FigureDefinition, FigureNodeDefinition } from "./model.js"; import type { FigureDefinition, FigureNodeDefinition } from "./model.js";
import { layoutFigure, type PositionedFigure } from "./layout.js"; import { layoutFigure, type PositionedFigure } from "./layout.js";
const position = (layout: PositionedFigure, nodeId: string) => const position = (layout: PositionedFigure, nodeId: string) => {
layout.nodes.find((n) => n.id === nodeId)!.position; 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", () => { describe("layoutFigure", () => {
it("places layered edges from top to bottom", () => { it("places layered edges from top to bottom", () => {
@@ -98,7 +98,7 @@ export const duplicateNodeCatalog: FigureCatalogDefinition = {
...overviewFigure, ...overviewFigure,
nodes: [ nodes: [
...overviewFigure.nodes, ...overviewFigure.nodes,
{ id: "client", label: "Dup", summary: "Dup", kind: "actor" as const }, { id: "client", label: "Dup", summary: "Dup", kind: "actor" },
], ],
}, },
runtimeDetailFigure, runtimeDetailFigure,
@@ -158,8 +158,33 @@ export const cyclicCatalog: FigureCatalogDefinition = {
], ],
}; };
const edgeA: FigureEdgeDefinition = { id: "e1", from: "a", to: "b" }; export const disconnectedCyclicCatalog: FigureCatalogDefinition = {
const edgeB: FigureEdgeDefinition = { id: "e2", from: "b", to: "c" }; 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 = { export const layeredFigure: FigureDefinition = {
id: "layered", id: "layered",
@@ -9,27 +9,27 @@ import type { MainLocation } from "./storyboard.js";
describe("presentationReducer", () => { describe("presentationReducer", () => {
it("advances within a scene before advancing to the next scene", () => { it("advances within a scene before advancing to the next scene", () => {
const advanced = presentationReducer(initialPresentationState, { type: "next" }); 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" }); 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", () => { 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( const rewound = presentationReducer(
{ ...initialPresentationState, location: state }, { ...initialPresentationState, location: state },
{ type: "previous" }, { 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", () => { it("jumps to a specific scene and beat", () => {
const jumped = presentationReducer(initialPresentationState, { const jumped = presentationReducer(initialPresentationState, {
type: "jump", 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", () => { it("parses a scene hash", () => {
@@ -37,7 +37,7 @@ describe("presentationReducer", () => {
type: "jump_hash", type: "jump_hash",
hash: "#scene/lifecycle/deployment", 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", () => { it("falls back to default for invalid hash", () => {
@@ -45,13 +45,13 @@ describe("presentationReducer", () => {
type: "jump_hash", type: "jump_hash",
hash: "#scene/nope/nope", 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", () => { it("opens a discussion branch and returns to the originating beat", () => {
const positioned = presentationReducer(initialPresentationState, { const positioned = presentationReducer(initialPresentationState, {
type: "jump", type: "jump",
location: { kind: "main", sceneId: "positioning", beatId: "lda-position" }, location: { kind: "main", sceneId: "positioning", beatId: "lda-position", focusPath: [] },
}); });
const opened = presentationReducer(positioned, { const opened = presentationReducer(positioned, {
type: "open_discussion", type: "open_discussion",
@@ -101,7 +101,7 @@ describe("presentationReducer", () => {
it("does nothing on next while a discussion branch is open", () => { it("does nothing on next while a discussion branch is open", () => {
const positioned = presentationReducer(initialPresentationState, { const positioned = presentationReducer(initialPresentationState, {
type: "jump", type: "jump",
location: { kind: "main", sceneId: "thesis", beatId: "title" }, location: { kind: "main", sceneId: "thesis", beatId: "title", focusPath: [] },
}); });
const opened = presentationReducer(positioned, { const opened = presentationReducer(positioned, {
type: "open_discussion", type: "open_discussion",
@@ -114,7 +114,7 @@ describe("presentationReducer", () => {
it("derives act and chat composition from the current beat", () => { it("derives act and chat composition from the current beat", () => {
const state = presentationReducer(initialPresentationState, { const state = presentationReducer(initialPresentationState, {
type: "jump", type: "jump",
location: { kind: "main", sceneId: "workflow-demo", beatId: "graph" }, location: { kind: "main", sceneId: "workflow-demo", beatId: "graph", focusPath: [] },
}); });
expect(compositionForState(state)).toMatchObject({ expect(compositionForState(state)).toMatchObject({
stageTheme: "night", stageTheme: "night",
@@ -136,7 +136,7 @@ describe("presentationReducer", () => {
it("does not reopen evidence on repeated Escape after force-close", () => { it("does not reopen evidence on repeated Escape after force-close", () => {
const stateAtTrace = presentationReducer(initialPresentationState, { const stateAtTrace = presentationReducer(initialPresentationState, {
type: "jump", type: "jump",
location: { kind: "main", sceneId: "interrupt-evidence", beatId: "trace" }, location: { kind: "main", sceneId: "interrupt-evidence", beatId: "trace", focusPath: [] },
}); });
expect(stateAtTrace.evidenceModeOverride).toBeNull(); expect(stateAtTrace.evidenceModeOverride).toBeNull();
@@ -147,4 +147,42 @@ describe("presentationReducer", () => {
expect(secondEscape.evidenceModeOverride).toBe("hidden"); expect(secondEscape.evidenceModeOverride).toBe("hidden");
expect(secondEscape.location.kind).toBe("main"); 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_stage_theme"; readonly theme: StageTheme | null }
| { readonly type: "set_chat_theme"; readonly theme: ChatTheme | null } | { readonly type: "set_chat_theme"; readonly theme: ChatTheme | null }
| { readonly type: "set_chat_mode"; readonly mode: ChatMode | 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_controls" }
| { readonly type: "toggle_discussion_index" } | { readonly type: "toggle_discussion_index" }
| { readonly type: "toggle_motion" }; | { readonly type: "toggle_motion" };
@@ -113,7 +114,8 @@ const isValidMainLocation = (location: PresentationLocation): location is MainLo
const firstBeatOfScene = (sceneId: string): MainLocation | null => { const firstBeatOfScene = (sceneId: string): MainLocation | null => {
const scene = findScene(sceneId); const scene = findScene(sceneId);
if (!scene || scene.beats.length === 0) return null; 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 => { const clampMainLocation = (location: MainLocation): MainLocation => {
@@ -207,6 +209,12 @@ export const presentationReducer = (
return { ...state, chatThemeOverride: action.theme }; return { ...state, chatThemeOverride: action.theme };
case "set_chat_mode": case "set_chat_mode":
return { ...state, chatModeOverride: action.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": case "toggle_controls":
return { ...state, controlsOpen: !state.controlsOpen }; return { ...state, controlsOpen: !state.controlsOpen };
case "toggle_discussion_index": case "toggle_discussion_index":
@@ -9,7 +9,7 @@ import { defaultMainLocation, type MainLocation } from "./storyboard.js";
describe("storyboard navigation", () => { describe("storyboard navigation", () => {
it("round-trips main and discussion hashes", () => { 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(hashForLocation(main))).toEqual(main);
expect(locationFromHash("#discuss/hosted-automation")).toEqual({ expect(locationFromHash("#discuss/hosted-automation")).toEqual({
kind: "discussion", kind: "discussion",
@@ -28,24 +28,47 @@ describe("storyboard navigation", () => {
expect(locationFromHash("#discuss/%ZZ")).toEqual(defaultMainLocation); 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", () => { 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", kind: "main",
sceneId: "thesis", sceneId: "thesis",
beatId: "substrate", beatId: "substrate",
focusPath: [],
}); });
expect(nextMainLocation({ kind: "main", sceneId: "thesis", beatId: "substrate" })).toEqual({ expect(nextMainLocation({ kind: "main", sceneId: "thesis", beatId: "substrate", focusPath: [] })).toEqual({
kind: "main", kind: "main",
sceneId: "problem", sceneId: "problem",
beatId: "direct-actions", beatId: "direct-actions",
focusPath: [],
}); });
}); });
it("rewinds across scene boundaries", () => { 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", kind: "main",
sceneId: "thesis", sceneId: "thesis",
beatId: "substrate", beatId: "substrate",
focusPath: [],
}); });
}); });
}); });
@@ -10,29 +10,43 @@ import {
const flattenMainLocations = (): readonly MainLocation[] => const flattenMainLocations = (): readonly MainLocation[] =>
mainScenes.flatMap((scene) => 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 => export const hashForLocation = (location: PresentationLocation): string => {
location.kind === "main" if (location.kind === "discussion") {
? `#scene/${encodeURIComponent(location.sceneId)}/${encodeURIComponent(location.beatId)}` return `#discuss/${encodeURIComponent(location.branchId)}`;
: `#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 => { export const locationFromHash = (hash: string): PresentationLocation => {
const raw = hash.replace(/^#/, ""); const raw = hash.replace(/^#/, "");
const sceneMatch = raw.match(/^scene\/([^/]+)\/(.+)$/); const sceneMatch = raw.match(/^scene\/([^/]+)\/([^/]+)(?:\/focus\/(.*))?$/);
if (sceneMatch) { if (sceneMatch) {
let sceneId: string; let sceneId: string;
let beatId: string; let beatId: string;
let focusPath: string[] = [];
try { try {
sceneId = decodeURIComponent(sceneMatch[1]!); sceneId = decodeURIComponent(sceneMatch[1]!);
beatId = decodeURIComponent(sceneMatch[2]!); beatId = decodeURIComponent(sceneMatch[2]!);
if (sceneMatch[3] !== undefined && sceneMatch[3] !== "") {
focusPath = sceneMatch[3]!.split("/").map(decodeURIComponent);
}
} catch { } catch {
return defaultMainLocation; return defaultMainLocation;
} }
const scene = findScene(sceneId); const scene = findScene(sceneId);
if (scene && scene.beats.some((b) => b.id === beatId)) { 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; return defaultMainLocation;
} }
@@ -59,7 +59,7 @@ describe("defense storyboard catalog", () => {
}); });
it("exposes a valid default location", () => { 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); expect(findScene(defaultMainLocation.sceneId)?.number).toBe(1);
}); });
}); });
@@ -15,6 +15,12 @@ export type SceneView =
| "evaluation" | "evaluation"
| "conclusion"; | "conclusion";
export type FigureBeatDefinition = {
readonly catalogId: string;
readonly focusPath: readonly string[];
readonly activeNodeId: string | null;
};
export type SceneBeatDefinition = { export type SceneBeatDefinition = {
readonly id: string; readonly id: string;
readonly title: string; readonly title: string;
@@ -22,6 +28,7 @@ export type SceneBeatDefinition = {
readonly chatMode: ChatMode; readonly chatMode: ChatMode;
readonly chatTheme: ChatTheme; readonly chatTheme: ChatTheme;
readonly evidenceMode: EvidenceMode; readonly evidenceMode: EvidenceMode;
readonly figure: FigureBeatDefinition | null;
}; };
export type SceneDefinition = { export type SceneDefinition = {
@@ -41,7 +48,7 @@ const sceneBeat = (
id: string, id: string,
title: string, title: string,
caption: string, caption: string,
options: Partial<Pick<SceneBeatDefinition, "chatMode" | "chatTheme" | "evidenceMode">> = {}, options: Partial<Pick<SceneBeatDefinition, "chatMode" | "chatTheme" | "evidenceMode" | "figure">> = {},
): SceneBeatDefinition => ({ ): SceneBeatDefinition => ({
id, id,
title, title,
@@ -49,6 +56,7 @@ const sceneBeat = (
chatMode: options.chatMode ?? "hidden", chatMode: options.chatMode ?? "hidden",
chatTheme: options.chatTheme ?? "dark", chatTheme: options.chatTheme ?? "dark",
evidenceMode: options.evidenceMode ?? "hidden", evidenceMode: options.evidenceMode ?? "hidden",
figure: options.figure ?? null,
}); });
export const mainScenes = defineScenes([ export const mainScenes = defineScenes([
@@ -228,6 +236,7 @@ export type MainLocation = {
readonly kind: "main"; readonly kind: "main";
readonly sceneId: MainSceneId; readonly sceneId: MainSceneId;
readonly beatId: string; readonly beatId: string;
readonly focusPath: readonly string[];
}; };
export type DiscussionBranchDefinition = { export type DiscussionBranchDefinition = {
@@ -368,12 +377,12 @@ export const findBeat = (sceneId: string, beatId: string): SceneBeatDefinition |
export const findDiscussionBranch = (branchId: string): DiscussionBranchDefinition | undefined => export const findDiscussionBranch = (branchId: string): DiscussionBranchDefinition | undefined =>
discussionBranches.find((branch) => branch.id === branchId); 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 => { export const mainLocation = (sceneId: string, beatId: string): MainLocation | null => {
const scene = findScene(sceneId); const scene = findScene(sceneId);
if (!scene) return null; if (!scene) return null;
const beat = scene.beats.find((b) => b.id === beatId); const beat = scene.beats.find((b) => b.id === beatId);
if (!beat) return null; 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 ?? [] };
}; };