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
@@ -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: [],
});
});
});