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:
@@ -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 ?? [] };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user