feat: add presentation graph spotlight
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user