feat: add presentation graph spotlight

This commit is contained in:
lda
2026-07-03 05:27:27 +07:00 Verified
parent 7ab3cb0bf8
commit 045f25ae72
7 changed files with 155 additions and 1 deletions
@@ -0,0 +1,30 @@
import { presentationNodes } from "./WorkflowGraphStage.js";
type NodeSpotlightProps = {
readonly nodeId: string;
readonly close: () => void;
};
const nodeDescription = (nodeId: string): string => {
if (nodeId === "review_issues") {
return "NodeUse of a typed interrupt boundary. It exposes request and resume schemas and waits for a submitted or cancelled outcome.";
}
if (nodeId === "create_issues") {
return "NodeUse that writes selected review items into the local issue-board source.";
}
return "NodeUse in the prepared report workflow. The presentation graph is curated, but every node maps back to real workflow/run evidence.";
};
export const NodeSpotlight = ({ nodeId, close }: NodeSpotlightProps) => {
const node = presentationNodes.find((candidate) => candidate.id === nodeId);
if (!node) return null;
return (
<aside className="node-spotlight" role="dialog" aria-label={node.label}>
<button type="button" onClick={close}>Close</button>
<p>NodeUse</p>
<h2>{node.label}</h2>
<p>{nodeDescription(node.id)}</p>
</aside>
);
};
@@ -1,4 +1,5 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { PresentationRoute } from "./PresentationRoute.js";
@@ -29,4 +30,12 @@ describe("PresentationRoute", () => {
expect(screen.getByLabelText(/presentation beat rail/i)).toBeInTheDocument();
expect(screen.getByText(/Replay/)).toBeInTheDocument();
});
it("shows node spotlight when a graph node is selected", async () => {
render(<PresentationRoute />);
await userEvent.click(screen.getByRole("button", { name: /issue review/i }));
expect(screen.getByRole("dialog", { name: /issue review/i })).toBeInTheDocument();
expect(screen.getByText("NodeUse")).toBeInTheDocument();
});
});
@@ -45,12 +45,21 @@ export const PresentationRoute = () => {
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
useEffect(() => {
if (demo.state.phase === "ready") {
demo.setMode("replay");
demo.start();
}
}, [demo]);
return (
<main className="presentation-route" aria-label="lda.chat presentation">
<PresentationStage
state={state}
demo={demo}
jump={(beatId) => dispatch({ type: "jump", beat: beatId })}
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
clearNode={() => dispatch({ type: "clear_node" })}
/>
</main>
);
@@ -1,8 +1,10 @@
import { presentationBeats, type BeatId } from "./beats.js";
import { BeatRail } from "./BeatRail.js";
import { NodeSpotlight } from "./NodeSpotlight.js";
import { OperationBlock } from "./OperationBlock.js";
import { OperatorChat } from "./OperatorChat.js";
import { StageCaption } from "./StageCaption.js";
import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
import type { PresentationState } from "./presentation-state.js";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
@@ -10,9 +12,17 @@ type PresentationStageProps = {
readonly state: PresentationState;
readonly demo: DemoTimelineController;
readonly jump: (beat: BeatId) => void;
readonly selectNode: (nodeId: string) => void;
readonly clearNode: () => void;
};
export const PresentationStage = ({ state, demo, jump }: PresentationStageProps) => {
export const PresentationStage = ({
state,
demo,
jump,
selectNode,
clearNode,
}: PresentationStageProps) => {
const beat = presentationBeats.find((candidate) => candidate.id === state.beat) ?? presentationBeats[0]!;
const operationEvent =
@@ -28,6 +38,10 @@ export const PresentationStage = ({ state, demo, jump }: PresentationStageProps)
<p>{beat.caption}</p>
</StageCaption>
{operationEvent && <OperationBlock event={operationEvent} />}
<WorkflowGraphStage selectedNodeId={state.selectedNodeId} selectNode={selectNode} />
{state.selectedNodeId && (
<NodeSpotlight nodeId={state.selectedNodeId} close={clearNode} />
)}
<p className="presentation-stage__mode">
{demo.state.mode === "replay" ? "Replay" : "Live"} · {demo.state.phase}
</p>
@@ -0,0 +1,16 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
afterEach(() => cleanup());
describe("WorkflowGraphStage", () => {
it("renders curated workflow nodes and allows node selection", async () => {
const selectNode = vi.fn();
render(<WorkflowGraphStage selectedNodeId={null} selectNode={selectNode} />);
await userEvent.click(screen.getByRole("button", { name: /issue review/i }));
expect(selectNode).toHaveBeenCalledWith("review_issues");
});
});
@@ -0,0 +1,38 @@
export type PresentationNode = {
readonly id: string;
readonly label: string;
readonly kind: "node" | "interrupt" | "end";
readonly x: number;
readonly y: number;
};
export const presentationNodes: readonly PresentationNode[] = [
{ id: "read_docs", label: "Read docs", kind: "node", x: 8, y: 45 },
{ id: "build_report", label: "Build report", kind: "node", x: 30, y: 30 },
{ id: "review_issues", label: "Issue review", kind: "interrupt", x: 52, y: 45 },
{ id: "create_issues", label: "Create issues", kind: "node", x: 74, y: 30 },
{ id: "end_completed", label: "Completed", kind: "end", x: 92, y: 45 },
];
type WorkflowGraphStageProps = {
readonly selectedNodeId: string | null;
readonly selectNode: (nodeId: string) => void;
};
export const WorkflowGraphStage = ({ selectedNodeId, selectNode }: WorkflowGraphStageProps) => (
<section className="workflow-graph-stage" aria-label="workflow graph">
{presentationNodes.map((node) => (
<button
key={node.id}
type="button"
className="workflow-graph-stage__node"
data-kind={node.kind}
data-selected={selectedNodeId === node.id}
style={{ left: `${node.x}%`, top: `${node.y}%` }}
onClick={() => selectNode(node.id)}
>
{node.label}
</button>
))}
</section>
);
@@ -88,3 +88,41 @@
grid-template-columns: 1fr;
}
}
.workflow-graph-stage {
position: relative;
min-height: 16rem;
border: 1px solid oklch(0.32 0.04 250);
background: oklch(0.14 0.025 250);
overflow: hidden;
}
.workflow-graph-stage__node {
position: absolute;
transform: translate(-50%, -50%);
border: 1px solid oklch(0.44 0.055 250);
background: oklch(0.2 0.03 250);
color: inherit;
padding: 0.7rem 0.9rem;
border-radius: 0.8rem;
}
.workflow-graph-stage__node[data-kind="interrupt"] {
border-color: oklch(0.76 0.18 70);
}
.workflow-graph-stage__node[data-selected="true"] {
outline: 3px solid oklch(0.72 0.17 195);
}
.node-spotlight {
position: fixed;
right: 1.25rem;
top: 1.25rem;
width: min(28rem, calc(100vw - 2.5rem));
border: 1px solid oklch(0.42 0.05 250);
background: oklch(0.17 0.025 250);
color: inherit;
padding: 1rem;
z-index: 20;
}