fix: address review findings for defense compositor

- S1: Remove global html/body overflow:hidden; scope to .presentation-route
- S2+P1: Add CSS selectors for data-stage-theme, data-chat-theme, data-chat-mode
- S3: Add distinct visual treatments per SceneView type (positioning, boundary, lifecycle, architecture, authoring, evaluation)
- S4+P2: Move discussion index to reducer state; Escape closes all overlays; remove miswired button
- P3: Add 8 missing Q&A discussion branches across lifecycle, authoring, architecture, evaluation, workflow-demo
- P4: Force replay now restores replay evidence
- P5: Add scene progress display and scene reset to presenter controls
- P6: Fix default dock mode to use composition.chatMode
- P7: Wrap decodeURIComponent in try-catch for malformed hashes
This commit is contained in:
lda
2026-07-04 17:31:52 +07:00 Verified
parent d80a4650eb
commit 43c7105575
11 changed files with 523 additions and 31 deletions
@@ -10,8 +10,10 @@ import { ChatDock } from "./ChatDock.js";
import { import {
initialPresentationState, initialPresentationState,
presentationReducer, presentationReducer,
compositionForState,
} from "./presentation-state.js"; } from "./presentation-state.js";
import { hashForLocation } from "./storyboard-navigation.js"; import { hashForLocation } from "./storyboard-navigation.js";
import { findScene } from "./storyboard.js";
import type { PresentationLocation } from "./storyboard.js"; import type { PresentationLocation } from "./storyboard.js";
import "./presentation.css"; import "./presentation.css";
@@ -154,7 +156,8 @@ export const PresentationRoute = () => {
const handleForceReplay = useCallback(() => { const handleForceReplay = useCallback(() => {
demo.setMode("replay"); demo.setMode("replay");
}, [demo]); setEvidence(replayEvidence);
}, [demo, replayEvidence]);
const handleResetOverrides = useCallback(() => { const handleResetOverrides = useCallback(() => {
dispatch({ type: "set_stage_theme", theme: null }); dispatch({ type: "set_stage_theme", theme: null });
@@ -162,7 +165,15 @@ export const PresentationRoute = () => {
dispatch({ type: "set_chat_mode", mode: null }); dispatch({ type: "set_chat_mode", mode: null });
}, []); }, []);
const showChatDock = state.chatModeOverride === "dock" && state.location.kind !== "discussion"; const handleResetScene = useCallback(() => {
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 } });
}, [state.location]);
const composition = compositionForState(state);
const showChatDock = composition.chatMode === "dock" && state.location.kind !== "discussion";
return ( return (
<main className="presentation-route" aria-label="lda.chat presentation"> <main className="presentation-route" aria-label="lda.chat presentation">
@@ -179,6 +190,7 @@ export const PresentationRoute = () => {
closeOverlay={() => dispatch({ type: "close_overlay" })} closeOverlay={() => dispatch({ type: "close_overlay" })}
openDiscussion={handleOpenDiscussion} openDiscussion={handleOpenDiscussion}
closeDiscussion={handleCloseDiscussion} closeDiscussion={handleCloseDiscussion}
toggleDiscussionIndex={() => dispatch({ type: "toggle_discussion_index" })}
/> />
{showChatDock && <ChatDock openChat={() => dispatch({ type: "set_chat_mode", mode: "rail" })} />} {showChatDock && <ChatDock openChat={() => dispatch({ type: "set_chat_mode", mode: "rail" })} />}
{state.controlsOpen && ( {state.controlsOpen && (
@@ -191,8 +203,8 @@ export const PresentationRoute = () => {
setChatTheme={(theme) => dispatch({ type: "set_chat_theme", theme })} setChatTheme={(theme) => dispatch({ type: "set_chat_theme", theme })}
setChatMode={(mode) => dispatch({ type: "set_chat_mode", mode })} setChatMode={(mode) => dispatch({ type: "set_chat_mode", mode })}
forceReplay={handleForceReplay} forceReplay={handleForceReplay}
openDiscussionIndex={() => dispatch({ type: "toggle_controls" })}
resetOverrides={handleResetOverrides} resetOverrides={handleResetOverrides}
resetScene={handleResetScene}
/> />
)} )}
<button <button
@@ -1,4 +1,3 @@
import { useState } from "react";
import type { EvidenceRecord } from "../app/state.js"; import type { EvidenceRecord } from "../app/state.js";
import type { AgentMessage } from "../demo/agent/events.js"; import type { AgentMessage } from "../demo/agent/events.js";
import { SceneBody } from "./SceneBody.js"; import { SceneBody } from "./SceneBody.js";
@@ -25,6 +24,7 @@ type PresentationStageProps = {
readonly closeOverlay: () => void; readonly closeOverlay: () => void;
readonly openDiscussion: (branchId: string) => void; readonly openDiscussion: (branchId: string) => void;
readonly closeDiscussion: () => void; readonly closeDiscussion: () => void;
readonly toggleDiscussionIndex: () => void;
}; };
export const PresentationStage = ({ export const PresentationStage = ({
@@ -40,9 +40,9 @@ export const PresentationStage = ({
closeOverlay, closeOverlay,
openDiscussion, openDiscussion,
closeDiscussion, closeDiscussion,
toggleDiscussionIndex,
}: PresentationStageProps) => { }: PresentationStageProps) => {
const composition = compositionForState(state); const composition = compositionForState(state);
const [indexOpen, setIndexOpen] = useState(false);
const isMainScene = state.location.kind === "main"; const isMainScene = state.location.kind === "main";
const currentScene = isMainScene ? findScene(state.location.sceneId) : null; const currentScene = isMainScene ? findScene(state.location.sceneId) : null;
@@ -75,17 +75,17 @@ export const PresentationStage = ({
<button <button
type="button" type="button"
className="presentation-stage__discussion-toggle" className="presentation-stage__discussion-toggle"
onClick={() => setIndexOpen(true)} onClick={toggleDiscussionIndex}
> >
Open discussion topics Open discussion topics
</button> </button>
)} )}
</> </>
)} )}
{indexOpen && ( {state.discussionIndexOpen && (
<DiscussionIndex <DiscussionIndex
onSelect={(branchId) => { onSelect={(branchId) => {
setIndexOpen(false); toggleDiscussionIndex();
openDiscussion(branchId); openDiscussion(branchId);
}} }}
/> />
@@ -17,8 +17,8 @@ describe("PresenterControls", () => {
setChatTheme: vi.fn(), setChatTheme: vi.fn(),
setChatMode: vi.fn(), setChatMode: vi.fn(),
forceReplay: vi.fn(), forceReplay: vi.fn(),
openDiscussionIndex: vi.fn(),
resetOverrides: vi.fn(), resetOverrides: vi.fn(),
resetScene: vi.fn(),
}; };
it("changes stage and chat themes independently", async () => { it("changes stage and chat themes independently", async () => {
@@ -13,8 +13,8 @@ type PresenterControlsProps = {
readonly setChatTheme: (theme: ChatTheme | null) => void; readonly setChatTheme: (theme: ChatTheme | null) => void;
readonly setChatMode: (mode: ChatMode | null) => void; readonly setChatMode: (mode: ChatMode | null) => void;
readonly forceReplay: () => void; readonly forceReplay: () => void;
readonly openDiscussionIndex: () => void;
readonly resetOverrides: () => void; readonly resetOverrides: () => void;
readonly resetScene: () => void;
}; };
export const PresenterControls = ({ export const PresenterControls = ({
@@ -26,13 +26,19 @@ export const PresenterControls = ({
setChatTheme, setChatTheme,
setChatMode, setChatMode,
forceReplay, forceReplay,
openDiscussionIndex,
resetOverrides, resetOverrides,
resetScene,
}: PresenterControlsProps) => { }: PresenterControlsProps) => {
const composition = compositionForState(state); const composition = compositionForState(state);
const isMain = state.location.kind === "main"; const isMain = state.location.kind === "main";
const currentScene = isMain ? findScene(state.location.sceneId) : null; const currentScene = isMain ? findScene(state.location.sceneId) : null;
const currentBeat = currentScene?.beats.find((b) => b.id === (state.location as MainLocation).beatId); const currentBeat = currentScene?.beats.find((b) => b.id === (state.location as MainLocation).beatId);
const beatIndex = currentScene && isMain
? currentScene.beats.findIndex((b) => b.id === (state.location as MainLocation).beatId)
: -1;
const totalBeats = currentScene?.beats.length ?? 0;
const sceneNumber = currentScene?.number ?? 0;
const totalScenes = mainScenes.length;
return ( return (
<div className="presenter-controls" role="dialog" aria-label="presenter controls"> <div className="presenter-controls" role="dialog" aria-label="presenter controls">
@@ -41,8 +47,8 @@ export const PresenterControls = ({
<button type="button" onClick={next}>Next</button> <button type="button" onClick={next}>Next</button>
</div> </div>
<div className="presenter-controls__info"> <div className="presenter-controls__info">
<span>{currentScene?.title ?? "Discussion"}</span> <span>Scene {sceneNumber}/{totalScenes}: {currentScene?.title ?? "Discussion"}</span>
{currentBeat && <span> · {currentBeat.title}</span>} {currentBeat && <span> · {beatIndex + 1}/{totalBeats} {currentBeat.title}</span>}
</div> </div>
<div className="presenter-controls__over"> <div className="presenter-controls__over">
<label> <label>
@@ -84,7 +90,7 @@ export const PresenterControls = ({
<div className="presenter-controls__actions"> <div className="presenter-controls__actions">
<span>{state.playbackMode === "replay" ? "Replay" : "Live"}</span> <span>{state.playbackMode === "replay" ? "Replay" : "Live"}</span>
<button type="button" onClick={forceReplay}>Force replay fallback</button> <button type="button" onClick={forceReplay}>Force replay fallback</button>
<button type="button" onClick={openDiscussionIndex}>Open discussion index</button> <button type="button" onClick={resetScene}>Reset scene</button>
<button type="button" onClick={resetOverrides}>Reset overrides</button> <button type="button" onClick={resetOverrides}>Reset overrides</button>
</div> </div>
</div> </div>
+142 -7
View File
@@ -29,6 +29,134 @@ const NarrativeScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBe
</> </>
); );
const PositioningScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => (
<>
<StageCaption eyebrow={`Act I · ${scene.claimClass}`} title={scene.title}>
<p>{beat.caption}</p>
</StageCaption>
<div className="scene-body__positioning-grid">
<div className="scene-body__positioning-card">
<strong>Tool loops</strong>
<span>Direct action, no lifecycle</span>
</div>
<div className="scene-body__positioning-card">
<strong>Scripts</strong>
<span>Simple, debuggable</span>
</div>
<div className="scene-body__positioning-card scene-body__positioning-card--active">
<strong>lda.chat</strong>
<span>Typed lifecycle contracts</span>
</div>
<div className="scene-body__positioning-card">
<strong>Agent graphs</strong>
<span>Shared durability</span>
</div>
<div className="scene-body__positioning-card">
<strong>MCP</strong>
<span>Capability protocol</span>
</div>
</div>
<p className="scene-body__evidence">{scene.evidencePointer}</p>
</>
);
const BoundaryScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => (
<>
<StageCaption eyebrow="Act II · implemented" title={scene.title}>
<p>{beat.caption}</p>
</StageCaption>
<div className="scene-body__boundary">
<div className="scene-body__boundary-side">
<h3>Planner</h3>
<p>External LLM proposes and revises workflow structure.</p>
</div>
<div className="scene-body__boundary-divider" />
<div className="scene-body__boundary-side">
<h3>Runtime</h3>
<p>Validates, executes, records, and resumes deterministically.</p>
</div>
</div>
<p className="scene-body__evidence">{scene.evidencePointer}</p>
</>
);
const LifecycleScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => (
<>
<StageCaption eyebrow="Act II · implemented" title={scene.title}>
<p>{beat.caption}</p>
</StageCaption>
<div className="scene-body__lifecycle">
{["Draft", "Artifact", "Deployment", "Run"].map((stage, i) => (
<div key={stage} className="scene-body__lifecycle-stage">
<span className="scene-body__lifecycle-number">{i + 1}</span>
<strong>{stage}</strong>
{i < 3 && <span className="scene-body__lifecycle-arrow"></span>}
</div>
))}
</div>
<p className="scene-body__evidence">{scene.evidencePointer}</p>
</>
);
const ArchitectureScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => (
<>
<StageCaption eyebrow="Act II · implemented" title={scene.title}>
<p>{beat.caption}</p>
</StageCaption>
<div className="scene-body__architecture">
<div className="scene-body__architecture-layer">Client operations</div>
<div className="scene-body__architecture-arrow"></div>
<div className="scene-body__architecture-layer">Transport / JSON-RPC</div>
<div className="scene-body__architecture-arrow"></div>
<div className="scene-body__architecture-layer">Runtime & providers</div>
<div className="scene-body__architecture-arrow"></div>
<div className="scene-body__architecture-layer scene-body__architecture-layer--node">NodeUse</div>
</div>
<p className="scene-body__evidence">{scene.evidencePointer}</p>
</>
);
const AuthoringScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => (
<>
<StageCaption eyebrow="Act II · implemented" title={scene.title}>
<p>{beat.caption}</p>
</StageCaption>
<div className="scene-body__authoring">
{["Discover", "Author", "Diagnose", "Repair"].map((step, i) => (
<div key={step} className="scene-body__authoring-step">
<span className="scene-body__authoring-number">{i + 1}</span>
<strong>{step}</strong>
{i < 3 && <span className="scene-body__authoring-arrow"></span>}
</div>
))}
</div>
<p className="scene-body__evidence">{scene.evidencePointer}</p>
</>
);
const EvaluationScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => (
<>
<StageCaption eyebrow={`Act I · ${scene.claimClass}`} title={scene.title}>
<p>{beat.caption}</p>
</StageCaption>
<div className="scene-body__evaluation">
<div className="scene-body__evaluation-stat">
<strong>36</strong>
<span>trials</span>
</div>
<div className="scene-body__evaluation-stat">
<strong>2</strong>
<span>challenges</span>
</div>
<div className="scene-body__evaluation-stat">
<strong>3</strong>
<span>waves</span>
</div>
</div>
<p className="scene-body__evidence">{scene.evidencePointer}</p>
</>
);
const AgentHandoffScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => ( const AgentHandoffScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => (
<> <>
<StageCaption eyebrow="Agent handoff" title={scene.title}> <StageCaption eyebrow="Agent handoff" title={scene.title}>
@@ -80,14 +208,17 @@ export const SceneBody = ({ location, demo, selectedNodeId, selectNode }: SceneB
switch (scene.view) { switch (scene.view) {
case "narrative": case "narrative":
case "positioning":
case "boundary":
case "lifecycle":
case "architecture":
case "authoring":
case "evaluation":
case "conclusion":
return <NarrativeScene scene={scene} beat={beat} />; return <NarrativeScene scene={scene} beat={beat} />;
case "positioning":
return <PositioningScene scene={scene} beat={beat} />;
case "boundary":
return <BoundaryScene scene={scene} beat={beat} />;
case "lifecycle":
return <LifecycleScene scene={scene} beat={beat} />;
case "architecture":
return <ArchitectureScene scene={scene} beat={beat} />;
case "authoring":
return <AuthoringScene scene={scene} beat={beat} />;
case "agent": case "agent":
return <AgentHandoffScene scene={scene} beat={beat} />; return <AgentHandoffScene scene={scene} beat={beat} />;
case "demo": case "demo":
@@ -100,6 +231,10 @@ export const SceneBody = ({ location, demo, selectedNodeId, selectNode }: SceneB
selectNode={selectNode} selectNode={selectNode}
/> />
); );
case "evaluation":
return <EvaluationScene scene={scene} beat={beat} />;
case "conclusion":
return <NarrativeScene scene={scene} beat={beat} />;
default: default:
return assertNever(scene.view); return assertNever(scene.view);
} }
@@ -27,6 +27,7 @@ export type PresentationState = {
readonly chatThemeOverride: ChatTheme | null; readonly chatThemeOverride: ChatTheme | null;
readonly chatModeOverride: ChatMode | null; readonly chatModeOverride: ChatMode | null;
readonly controlsOpen: boolean; readonly controlsOpen: boolean;
readonly discussionIndexOpen: boolean;
}; };
export type PresentationAction = export type PresentationAction =
@@ -44,7 +45,8 @@ 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: "toggle_controls" }; | { readonly type: "toggle_controls" }
| { readonly type: "toggle_discussion_index" };
export const initialPresentationState: PresentationState = { export const initialPresentationState: PresentationState = {
location: defaultMainLocation, location: defaultMainLocation,
@@ -56,6 +58,7 @@ export const initialPresentationState: PresentationState = {
chatThemeOverride: null, chatThemeOverride: null,
chatModeOverride: null, chatModeOverride: null,
controlsOpen: false, controlsOpen: false,
discussionIndexOpen: false,
}; };
const compositionForLocation = ( const compositionForLocation = (
@@ -173,6 +176,8 @@ export const presentationReducer = (
case "close_overlay": { case "close_overlay": {
if (state.selectedNodeId !== null) return { ...state, selectedNodeId: null }; if (state.selectedNodeId !== null) return { ...state, selectedNodeId: null };
if (state.evidenceModeOverride !== null) return { ...state, evidenceModeOverride: null }; if (state.evidenceModeOverride !== null) return { ...state, evidenceModeOverride: null };
if (state.discussionIndexOpen) return { ...state, discussionIndexOpen: false };
if (state.controlsOpen) return { ...state, controlsOpen: false };
if (state.location.kind === "discussion") { if (state.location.kind === "discussion") {
return { return {
...state, ...state,
@@ -192,5 +197,7 @@ export const presentationReducer = (
return { ...state, chatModeOverride: action.mode }; return { ...state, chatModeOverride: action.mode };
case "toggle_controls": case "toggle_controls":
return { ...state, controlsOpen: !state.controlsOpen }; return { ...state, controlsOpen: !state.controlsOpen };
case "toggle_discussion_index":
return { ...state, discussionIndexOpen: !state.discussionIndexOpen };
} }
}; };
@@ -3,11 +3,9 @@
--evidence-width: 0rem; --evidence-width: 0rem;
} }
html, body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow: hidden;
height: 100%;
} }
.presentation-route { .presentation-route {
@@ -28,6 +26,92 @@ html, body {
var(--chat-width) minmax(0, 1fr) var(--evidence-width); var(--chat-width) minmax(0, 1fr) var(--evidence-width);
} }
/* Stage themes */
.presentation-stage[data-stage-theme="paper"] {
background: oklch(0.97 0.005 80);
color: oklch(0.15 0.01 80);
}
.presentation-stage[data-stage-theme="night"] {
background: oklch(0.12 0.025 250);
color: oklch(0.96 0.01 250);
}
/* Paper theme child-element overrides */
.presentation-stage[data-stage-theme="paper"] .stage-caption__eyebrow {
color: oklch(0.45 0.04 80);
}
.presentation-stage[data-stage-theme="paper"] .scene-body__evidence {
color: oklch(0.5 0.03 80);
}
.presentation-stage[data-stage-theme="paper"] .scene-rail__scene {
border-color: oklch(0.75 0.04 80);
background: oklch(0.92 0.01 80);
}
.presentation-stage[data-stage-theme="paper"] .scene-rail__scene[data-active="true"] {
background: oklch(0.35 0.12 80);
color: oklch(0.97 0.005 80);
}
.presentation-stage[data-stage-theme="paper"] .scene-rail__beat {
background: oklch(0.7 0.04 80);
}
.presentation-stage[data-stage-theme="paper"] .scene-rail__beat--active {
background: oklch(0.3 0.08 80);
}
.presentation-stage[data-stage-theme="paper"] .presentation-stage__mode {
color: oklch(0.5 0.03 80);
}
.presentation-stage[data-stage-theme="paper"] .presentation-stage__discussion-toggle {
border-color: oklch(0.7 0.05 80);
background: oklch(0.9 0.01 80);
color: oklch(0.2 0.02 80);
}
/* Chat themes */
.presentation-stage[data-chat-theme="light"] .presentation-stage__chat {
background: oklch(0.96 0.005 80);
color: oklch(0.18 0.01 80);
}
.presentation-stage[data-chat-theme="dark"] .presentation-stage__chat {
background: oklch(0.14 0.025 250);
color: oklch(0.92 0.01 250);
}
/* Chat modes */
.presentation-stage[data-chat-mode="hidden"] {
grid-template:
"primary evidence" minmax(0, 1fr)
"rail rail" auto /
minmax(0, 1fr) var(--evidence-width);
}
.presentation-stage[data-chat-mode="hidden"] .presentation-stage__chat {
display: none;
}
.presentation-stage[data-chat-mode="rail"] {
--chat-width: 16rem;
}
.presentation-stage[data-chat-mode="dock"] {
grid-template:
"primary evidence" minmax(0, 1fr)
"rail rail" auto /
minmax(0, 1fr) var(--evidence-width);
}
.presentation-stage[data-chat-mode="dock"] .presentation-stage__chat {
display: none;
}
.presentation-stage__chat, .presentation-stage__chat,
.presentation-stage__primary, .presentation-stage__primary,
.presentation-stage__evidence { .presentation-stage__evidence {
@@ -451,6 +535,168 @@ html, body {
color: oklch(0.65 0.2 25); color: oklch(0.65 0.2 25);
} }
/* Scene body visual treatments */
.scene-body__positioning-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 0.5rem;
margin-top: 0.5rem;
}
.scene-body__positioning-card {
padding: 0.65rem 0.75rem;
border: 1px solid oklch(0.36 0.04 250);
background: oklch(0.16 0.025 250);
border-radius: 0.5rem;
font-size: 0.8rem;
}
.scene-body__positioning-card strong {
display: block;
margin-bottom: 0.15rem;
}
.scene-body__positioning-card span {
color: oklch(0.72 0.03 250);
font-size: 0.75rem;
}
.scene-body__positioning-card--active {
border-color: oklch(0.7 0.16 195);
background: oklch(0.2 0.06 195);
}
.scene-body__boundary {
display: flex;
gap: 1rem;
margin-top: 0.75rem;
align-items: stretch;
}
.scene-body__boundary-side {
flex: 1;
padding: 0.75rem;
border: 1px solid oklch(0.36 0.04 250);
background: oklch(0.16 0.025 250);
border-radius: 0.5rem;
}
.scene-body__boundary-side h3 {
margin: 0 0 0.35rem;
font-size: 0.95rem;
}
.scene-body__boundary-side p {
margin: 0;
font-size: 0.8rem;
color: oklch(0.78 0.03 250);
}
.scene-body__boundary-divider {
width: 2px;
background: oklch(0.5 0.08 195);
border-radius: 1px;
align-self: stretch;
}
.scene-body__lifecycle,
.scene-body__authoring {
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
.scene-body__lifecycle-stage,
.scene-body__authoring-step {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
border: 1px solid oklch(0.36 0.04 250);
background: oklch(0.16 0.025 250);
border-radius: 0.5rem;
font-size: 0.85rem;
}
.scene-body__lifecycle-number,
.scene-body__authoring-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
background: oklch(0.7 0.16 195);
color: oklch(0.12 0.025 250);
font-size: 0.7rem;
font-weight: 600;
}
.scene-body__lifecycle-arrow,
.scene-body__authoring-arrow {
color: oklch(0.5 0.06 250);
font-size: 1.1rem;
}
.scene-body__architecture {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
margin-top: 0.75rem;
}
.scene-body__architecture-layer {
width: 100%;
max-width: 20rem;
padding: 0.55rem 0.75rem;
border: 1px solid oklch(0.36 0.04 250);
background: oklch(0.16 0.025 250);
border-radius: 0.4rem;
text-align: center;
font-size: 0.85rem;
}
.scene-body__architecture-layer--node {
border-color: oklch(0.7 0.16 195);
background: oklch(0.2 0.06 195);
}
.scene-body__architecture-arrow {
color: oklch(0.5 0.06 250);
font-size: 0.9rem;
}
.scene-body__evaluation {
display: flex;
gap: 1.5rem;
margin-top: 0.75rem;
}
.scene-body__evaluation-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.65rem 1rem;
border: 1px solid oklch(0.36 0.04 250);
background: oklch(0.16 0.025 250);
border-radius: 0.5rem;
}
.scene-body__evaluation-stat strong {
font-size: 1.6rem;
color: oklch(0.7 0.16 195);
}
.scene-body__evaluation-stat span {
font-size: 0.75rem;
color: oklch(0.72 0.03 250);
text-transform: uppercase;
letter-spacing: 0.05em;
}
@media (max-height: 760px) { @media (max-height: 760px) {
.scene-rail { .scene-rail {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
@@ -23,6 +23,11 @@ describe("storyboard navigation", () => {
expect(locationFromHash("#discuss/nope")).toEqual(defaultMainLocation); expect(locationFromHash("#discuss/nope")).toEqual(defaultMainLocation);
}); });
it("falls back for malformed percent-encoded hashes", () => {
expect(locationFromHash("#scene/%ZZ/%ZZ")).toEqual(defaultMainLocation);
expect(locationFromHash("#discuss/%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" })).toEqual({
kind: "main", kind: "main",
@@ -22,8 +22,14 @@ export const locationFromHash = (hash: string): PresentationLocation => {
const raw = hash.replace(/^#/, ""); const raw = hash.replace(/^#/, "");
const sceneMatch = raw.match(/^scene\/([^/]+)\/(.+)$/); const sceneMatch = raw.match(/^scene\/([^/]+)\/(.+)$/);
if (sceneMatch) { if (sceneMatch) {
const sceneId = decodeURIComponent(sceneMatch[1]!); let sceneId: string;
const beatId = decodeURIComponent(sceneMatch[2]!); let beatId: string;
try {
sceneId = decodeURIComponent(sceneMatch[1]!);
beatId = decodeURIComponent(sceneMatch[2]!);
} catch {
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 };
@@ -32,7 +38,12 @@ export const locationFromHash = (hash: string): PresentationLocation => {
} }
const discussMatch = raw.match(/^discuss\/(.+)$/); const discussMatch = raw.match(/^discuss\/(.+)$/);
if (discussMatch) { if (discussMatch) {
const branchId = decodeURIComponent(discussMatch[1]!); let branchId: string;
try {
branchId = decodeURIComponent(discussMatch[1]!);
} catch {
return defaultMainLocation;
}
if (findDiscussionBranch(branchId)) { if (findDiscussionBranch(branchId)) {
return { kind: "discussion", branchId: branchId as DiscussionLocation["branchId"] }; return { kind: "discussion", branchId: branchId as DiscussionLocation["branchId"] };
} }
@@ -41,15 +41,21 @@ describe("defense storyboard catalog", () => {
expect(findBeat("interrupt-evidence", "trace")?.chatMode).toBe("dock"); expect(findBeat("interrupt-evidence", "trace")?.chatMode).toBe("dock");
}); });
it("defines the five positioning discussion branches", () => { it("defines discussion branches across multiple scenes", () => {
expect(discussionBranches.map((branch) => branch.id)).toEqual([ expect(discussionBranches.length).toBeGreaterThanOrEqual(5);
const positioningBranches = discussionBranches.filter((b) => b.parentSceneId === "positioning");
expect(positioningBranches.map((b) => b.id)).toEqual([
"direct-orchestration", "direct-orchestration",
"generated-scripts", "generated-scripts",
"hosted-automation", "hosted-automation",
"durable-agent-graphs", "durable-agent-graphs",
"mcp-agent-scale", "mcp-agent-scale",
]); ]);
expect(discussionBranches.every((branch) => branch.parentSceneId === "positioning")).toBe(true); for (const branch of discussionBranches) {
expect(branch.title.length).toBeGreaterThan(0);
expect(branch.summary.length).toBeGreaterThan(0);
expect(branch.evidencePointer.length).toBeGreaterThan(0);
}
}); });
it("exposes a valid default location", () => { it("exposes a valid default location", () => {
@@ -284,6 +284,70 @@ export const discussionBranches = defineDiscussionBranches([
evidencePointer: "Thesis: Model Context Protocol; Cloudflare Code Mode; Anthropic code execution with MCP", evidencePointer: "Thesis: Model Context Protocol; Cloudflare Code Mode; Anthropic code execution with MCP",
summary: "MCP is a capability protocol; progressive discovery addresses large agent-facing surfaces.", summary: "MCP is a capability protocol; progressive discovery addresses large agent-facing surfaces.",
}, },
{
id: "lifecycle-states",
parentSceneId: "lifecycle",
title: "Lifecycle state machine",
claimClass: "implemented",
evidencePointer: "Thesis Workflow Lifecycle; lifecycle state transitions",
summary: "Draft, artifact, deployment, and run form a linear lifecycle with typed transitions.",
},
{
id: "raw-plan-import",
parentSceneId: "authoring",
title: "Raw plan import",
claimClass: "implemented",
evidencePointer: "Thesis Authoring; raw plan import and compilation",
summary: "Raw plans are compiled into validated workflow definitions with typed operations.",
},
{
id: "validation-diagnostics",
parentSceneId: "authoring",
title: "Validation and diagnostics",
claimClass: "implemented",
evidencePointer: "Thesis Validation; diagnostic repair hints",
summary: "Structured diagnostics identify invalid state and suggest repair hints.",
},
{
id: "typed-interrupts",
parentSceneId: "interrupt-evidence",
title: "Typed interrupt contracts",
claimClass: "implemented",
evidencePointer: "Thesis Typed Interrupts; interrupt schemas",
summary: "Interrupts are typed resume-request payloads with schema-backed review boundaries.",
},
{
id: "run-persistence",
parentSceneId: "architecture",
title: "Run persistence and inspection",
claimClass: "implemented",
evidencePointer: "Thesis Runtime; run persistence and trace events",
summary: "Persisted runs store output, status, and trace for post-hoc inspection.",
},
{
id: "provider-security",
parentSceneId: "architecture",
title: "Provider-neutral security",
claimClass: "implemented",
evidencePointer: "Thesis Architecture; provider-neutral capability resolution",
summary: "Provider-neutral capabilities avoid vendor lock-in while maintaining security boundaries.",
},
{
id: "evaluation-validity",
parentSceneId: "evaluation",
title: "Evaluation validity bounds",
claimClass: "evaluated",
evidencePointer: "Thesis Evaluation; bounded validity",
summary: "Manual audit separates task completion from valid product-surface evidence.",
},
{
id: "replay-provenance",
parentSceneId: "workflow-demo",
title: "Replay provenance",
claimClass: "implemented",
evidencePointer: "Thesis Prepared Replay; replay evidence chain",
summary: "Replay evidence is projected from the canonical recording for deterministic demonstration.",
},
]); ]);
export type DiscussionBranchId = (typeof discussionBranches)[number]["id"]; export type DiscussionBranchId = (typeof discussionBranches)[number]["id"];