fix: add React Flow handles, fix keyboard, NodeUse active, beat captions

This commit is contained in:
lda
2026-07-05 22:53:38 +07:00 Verified
parent 597370ffcd
commit e22e2a923d
6 changed files with 74 additions and 30 deletions
@@ -194,6 +194,8 @@ export const SceneBody = ({ location, demo, selectedNodeId, selectNode, openEvid
case "architecture": case "architecture":
return ( return (
<ArchitectureScene <ArchitectureScene
scene={scene}
beat={beat}
focusPath={location.kind === "main" ? location.focusPath : []} focusPath={location.kind === "main" ? location.focusPath : []}
activeNodeId={beat.figure?.activeNodeId ?? null} activeNodeId={beat.figure?.activeNodeId ?? null}
onFocusPathChange={onFocusPathChange} onFocusPathChange={onFocusPathChange}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { ReactFlow, ReactFlowProvider, useReactFlow, type Node, type Edge, type NodeTypes } from "@xyflow/react"; import { ReactFlow, ReactFlowProvider, Handle, Position, useReactFlow, type Node, type Edge, type NodeTypes } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import type { FigureCatalogDefinition, FigureNodeKind } from "./model.js"; import type { FigureCatalogDefinition, FigureNodeKind } from "./model.js";
import { layoutFigure, NODE_WIDTH, NODE_HEIGHT, type PositionedFigure } from "./layout.js"; import { layoutFigure, NODE_WIDTH, NODE_HEIGHT, type PositionedFigure } from "./layout.js";
@@ -37,32 +37,36 @@ const FigureFlowNode = ({ data }: { data: FigureNodeData }) => {
const accessibleName = expandable ? `${data.label}, expand` : data.label; const accessibleName = expandable ? `${data.label}, expand` : data.label;
return ( return (
<button <>
type="button" <Handle type="target" position={Position.Top} id="target" />
className="figure-node" <button
data-figure-node-kind={data.kind} type="button"
data-active={data.isActive} className="figure-node"
data-expandable={expandable} data-figure-node-kind={data.kind}
data-testid={`figure-node-${data.nodeId}`} data-active={data.isActive}
aria-label={accessibleName} data-expandable={expandable}
tabIndex={-1} data-testid={`figure-node-${data.nodeId}`}
onClick={() => { aria-label={accessibleName}
data.onActivate(data.nodeId); tabIndex={data.isActive ? 0 : -1}
if (expandable) data.onExpand(data.nodeId); onClick={() => {
}} data.onActivate(data.nodeId);
onKeyDown={(event) => { if (expandable) data.onExpand(data.nodeId);
if (event.key === "Enter" && expandable) { }}
event.preventDefault(); onKeyDown={(event) => {
data.onExpand(data.nodeId); if (event.key === "Enter" && expandable) {
} event.preventDefault();
}} data.onExpand(data.nodeId);
> }
<span className="figure-node__kind">{data.kind}</span> }}
<strong className="figure-node__label">{data.label}</strong> >
<span className="figure-node__summary">{data.summary}</span> <span className="figure-node__kind">{data.kind}</span>
{expandable && <span className="figure-node__expand-affance" aria-hidden="true">&#9656;</span>} <strong className="figure-node__label">{data.label}</strong>
{data.isActive && <span className="figure-node__current-marker">Current</span>} <span className="figure-node__summary">{data.summary}</span>
</button> {expandable && <span className="figure-node__expand-affance" aria-hidden="true">&#9656;</span>}
{data.isActive && <span className="figure-node__current-marker">Current</span>}
</button>
<Handle type="source" position={Position.Bottom} id="source" />
</>
); );
}; };
@@ -35,6 +35,16 @@
display: none; display: none;
} }
.interactive-figure .react-flow__handle {
width: 6px;
height: 6px;
min-width: 6px;
min-height: 6px;
background: var(--color-editorial-muted, oklch(0.48 0.025 65));
border: none;
opacity: 0.6;
}
.figure-node { .figure-node {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -31,12 +31,35 @@ afterAll(() => {
delete (globalThis as Record<string, unknown>).DOMRect; delete (globalThis as Record<string, unknown>).DOMRect;
}); });
const mockScene = {
id: "architecture",
number: 6,
title: "Architecture Zoom",
claimClass: "implemented" as const,
evidencePointer: "Thesis System Architecture",
stageTheme: "night" as const,
view: "architecture" as const,
beats: [],
};
const mockBeat = {
id: "client",
title: "Client operations",
caption: "Human and agent clients use the same public lifecycle surface.",
chatMode: "rail" as const,
chatTheme: "light" as const,
evidenceMode: "hidden" as const,
figure: { catalogId: "system-architecture", focusPath: [] as readonly string[], activeNodeId: "client-operations" },
};
const renderArchitecture = (overrides: Partial<React.ComponentProps<typeof ArchitectureScene>> = {}) => { const renderArchitecture = (overrides: Partial<React.ComponentProps<typeof ArchitectureScene>> = {}) => {
const onFocusPathChange = overrides.onFocusPathChange ?? vi.fn(); const onFocusPathChange = overrides.onFocusPathChange ?? vi.fn();
return { return {
onFocusPathChange, onFocusPathChange,
...render( ...render(
<ArchitectureScene <ArchitectureScene
scene={overrides.scene ?? mockScene}
beat={overrides.beat ?? mockBeat}
focusPath={overrides.focusPath ?? []} focusPath={overrides.focusPath ?? []}
activeNodeId={overrides.activeNodeId ?? null} activeNodeId={overrides.activeNodeId ?? null}
onFocusPathChange={onFocusPathChange} onFocusPathChange={onFocusPathChange}
@@ -1,8 +1,11 @@
import { StageCaption } from "../StageCaption.js"; import { StageCaption } from "../StageCaption.js";
import { InteractiveFigure } from "../figures/InteractiveFigure.js"; import { InteractiveFigure } from "../figures/InteractiveFigure.js";
import { architectureCatalog } from "../figures/architecture-catalog.js"; import { architectureCatalog } from "../figures/architecture-catalog.js";
import type { SceneDefinition, SceneBeatDefinition } from "../storyboard.js";
type ArchitectureSceneProps = { type ArchitectureSceneProps = {
readonly scene: SceneDefinition;
readonly beat: SceneBeatDefinition;
readonly focusPath: readonly string[]; readonly focusPath: readonly string[];
readonly activeNodeId: string | null; readonly activeNodeId: string | null;
readonly onFocusPathChange: (path: readonly string[]) => void; readonly onFocusPathChange: (path: readonly string[]) => void;
@@ -10,14 +13,16 @@ type ArchitectureSceneProps = {
}; };
export const ArchitectureScene = ({ export const ArchitectureScene = ({
scene,
beat,
focusPath, focusPath,
activeNodeId, activeNodeId,
onFocusPathChange, onFocusPathChange,
motionDisabled, motionDisabled,
}: ArchitectureSceneProps) => ( }: ArchitectureSceneProps) => (
<> <>
<StageCaption eyebrow="Act II · implemented" title="Architecture Zoom"> <StageCaption eyebrow={`Act II · ${scene.claimClass}`} title={scene.title}>
<p>The system exposes one public lifecycle surface across all client types.</p> <p>{beat.caption}</p>
</StageCaption> </StageCaption>
<InteractiveFigure <InteractiveFigure
catalog={architectureCatalog} catalog={architectureCatalog}
@@ -140,7 +140,7 @@ export const mainScenes = defineScenes([
sceneBeat("client", "Client operations", "Human and agent clients use the same public lifecycle surface.", { figure: { catalogId: "system-architecture", focusPath: [], activeNodeId: "client-operations" } }), sceneBeat("client", "Client operations", "Human and agent clients use the same public lifecycle surface.", { figure: { catalogId: "system-architecture", focusPath: [], activeNodeId: "client-operations" } }),
sceneBeat("api", "Transport and API", "JSON-RPC reaches WorkflowApi without owning domain behavior.", { figure: { catalogId: "system-architecture", focusPath: [], activeNodeId: "application-lifecycle" } }), sceneBeat("api", "Transport and API", "JSON-RPC reaches WorkflowApi without owning domain behavior.", { figure: { catalogId: "system-architecture", focusPath: [], activeNodeId: "application-lifecycle" } }),
sceneBeat("runtime", "Runtime and providers", "The runtime resolves provider-neutral capabilities and stores lifecycle records.", { figure: { catalogId: "system-architecture", focusPath: ["runtime-providers"], activeNodeId: "configured-providers" } }), sceneBeat("runtime", "Runtime and providers", "The runtime resolves provider-neutral capabilities and stores lifecycle records.", { figure: { catalogId: "system-architecture", focusPath: ["runtime-providers"], activeNodeId: "configured-providers" } }),
sceneBeat("node-use", "NodeUse", "One callable node validates input, invokes a capability, and reduces output into state.", { evidenceMode: "peek", figure: { catalogId: "system-architecture", focusPath: ["node-use"], activeNodeId: "node-use" } }), sceneBeat("node-use", "NodeUse", "One callable node validates input, invokes a capability, and reduces output into state.", { evidenceMode: "peek", figure: { catalogId: "system-architecture", focusPath: ["node-use"], activeNodeId: "invoke-handler" } }),
], ],
}, },
{ {