feat: add contribution closing map

This commit is contained in:
lda
2026-07-10 18:42:54 +07:00 Verified
parent 7ddb67486e
commit 3e2cf41b65
5 changed files with 337 additions and 0 deletions
@@ -0,0 +1,79 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { ConclusionScene } from "./ConclusionScene.js";
const scene = {
id: "conclusion",
number: 15,
title: "Contribution and future work",
claimClass: "future-work" as const,
evidencePointer: "Thesis conclusion",
stageTheme: "night" as const,
view: "conclusion" as const,
beats: [],
};
const beat = (id: string) => ({
id,
title: id,
caption: `Caption for ${id}`,
chatMode: "hidden" as const,
chatTheme: "dark" as const,
evidencePresentation: "hidden" as const,
figure: null,
});
afterEach(() => cleanup());
describe("ConclusionScene", () => {
it.each(["limits", "future", "conclusion", "questions"])("preserves the labelled contribution boundary for %s", (beatId) => {
render(<ConclusionScene scene={scene} beat={beat(beatId)} />);
const map = screen.getByRole("region", { name: "thesis contribution boundary" });
expect(map).toHaveAttribute("data-conclusion-beat", beatId);
for (const label of [
"External planner",
"Typed workflow substrate",
"Deterministic runtime",
"Persisted, inspectable evidence",
]) {
expect(screen.getByText(label)).toBeInTheDocument();
}
});
it("emphasizes all three explicit non-claims in the limits beat", () => {
render(<ConclusionScene scene={scene} beat={beat("limits")} />);
const nonClaims = screen.getByRole("list", { name: "explicit non-claims" });
expect(nonClaims.querySelectorAll("li")).toHaveLength(3);
expect(nonClaims).toHaveTextContent("Not a production sandbox");
expect(nonClaims).toHaveTextContent("Not a scheduler");
expect(nonClaims).toHaveTextContent("Not a broad agent benchmark");
expect(nonClaims).toHaveAttribute("data-emphasis", "limits");
});
it("exposes five labelled future-work icon branches", () => {
render(<ConclusionScene scene={scene} beat={beat("future")} />);
const future = screen.getByRole("list", { name: "future work layers" });
expect(future.querySelectorAll("li")).toHaveLength(5);
expect(future.querySelectorAll("svg")).toHaveLength(5);
for (const text of [
"Agent interface",
"Security and credentials",
"Hosted operations",
"Controlled evaluation",
"Runtime expansion",
"Chat or planner loop over wf operations",
"Secrets, RBAC, sandboxing, policy",
"Scheduling, daemon lifecycle, monitoring",
"Frozen prompts, more trials, independent audit",
"Transactional stores, debugging, providers",
]) {
expect(future).toHaveTextContent(text);
}
});
it("states the closing boundary and recedes future branches", () => {
render(<ConclusionScene scene={scene} beat={beat("conclusion")} />);
expect(screen.getByText("Planner proposes; runtime executes.")).toBeInTheDocument();
expect(screen.getByRole("list", { name: "future work layers" })).toHaveAttribute("data-state", "receded");
});
});
@@ -0,0 +1,79 @@
import type { FC } from "react";
import { Bot, CalendarClock, ChartNoAxesCombined, ShieldCheck, Workflow, type LucideProps } from "lucide-react";
import { StageCaption } from "../StageCaption.js";
import type { SceneBeatDefinition, SceneDefinition } from "../storyboard.js";
import {
contributionNodes,
futureWorkBranches,
isConclusionBeatId,
nonClaims,
type FutureWorkIcon,
} from "./conclusion-model.js";
type ConclusionSceneProps = {
readonly scene: SceneDefinition;
readonly beat: SceneBeatDefinition;
};
const futureWorkIcons: Record<FutureWorkIcon, FC<LucideProps>> = {
agent: Bot,
security: ShieldCheck,
schedule: CalendarClock,
evaluation: ChartNoAxesCombined,
runtime: Workflow,
};
export const ConclusionScene: FC<ConclusionSceneProps> = ({ scene, beat }) => {
const beatId = isConclusionBeatId(beat.id) ? beat.id : "conclusion";
const futureState = beatId === "conclusion" ? "receded" : "visible";
return (
<>
<StageCaption eyebrow={`Act IV · ${scene.claimClass}`} title={scene.title}>
<p>{beat.caption}</p>
</StageCaption>
<section className="conclusion-map" aria-label="thesis contribution boundary" data-conclusion-beat={beatId}>
<div className="conclusion-map__flow" aria-label="contribution flow">
{contributionNodes.map((node, index) => (
<div
className={`conclusion-map__node conclusion-map__node--${node.id}`}
data-node-id={node.id}
data-emphasis={node.id === "substrate" ? "substrate" : "neutral"}
key={node.id}
>
<span className="conclusion-map__node-index">0{index + 1}</span>
<strong>{node.label}</strong>
{node.id === "evidence" && <small>saved traces and receipts</small>}
</div>
))}
</div>
<ul
className="conclusion-map__non-claims"
aria-label="explicit non-claims"
data-emphasis={beatId === "limits" ? "limits" : "neutral"}
>
{nonClaims.map((claim) => <li key={claim}>{claim}</li>)}
</ul>
<ul className="conclusion-map__future" aria-label="future work layers" data-state={futureState}>
{futureWorkBranches.map((branch) => {
const Icon = futureWorkIcons[branch.icon];
return (
<li key={branch.id} data-future-work-id={branch.id}>
<Icon aria-hidden="true" />
<span>
<strong>{branch.label}</strong>
<small>{branch.example}</small>
</span>
</li>
);
})}
</ul>
<p className="conclusion-map__statement">Planner proposes; runtime executes.</p>
</section>
<p className="scene-body__evidence">{scene.evidencePointer}</p>
</>
);
};
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { contributionNodes, futureWorkBranches, isConclusionBeatId, nonClaims } from "./conclusion-model.js";
describe("conclusion model", () => {
it("defines the stable contribution boundary", () => {
expect(contributionNodes).toEqual([
{ id: "planner", label: "External planner" },
{ id: "substrate", label: "Typed workflow substrate" },
{ id: "runtime", label: "Deterministic runtime" },
{ id: "evidence", label: "Persisted, inspectable evidence" },
]);
});
it("keeps the closing claims bounded", () => {
expect(nonClaims).toEqual(["Not a production sandbox", "Not a scheduler", "Not a broad agent benchmark"]);
expect(isConclusionBeatId("limits")).toBe(true);
expect(isConclusionBeatId("not-a-beat")).toBe(false);
});
it("names five distinct future-work layers with labelled examples", () => {
expect(futureWorkBranches).toEqual([
{ id: "agent-interface", label: "Agent interface", example: "Chat or planner loop over wf operations", icon: "agent" },
{ id: "security", label: "Security and credentials", example: "Secrets, RBAC, sandboxing, policy", icon: "security" },
{ id: "scheduling", label: "Hosted operations", example: "Scheduling, daemon lifecycle, monitoring", icon: "schedule" },
{ id: "evaluation", label: "Controlled evaluation", example: "Frozen prompts, more trials, independent audit", icon: "evaluation" },
{ id: "runtime", label: "Runtime expansion", example: "Transactional stores, debugging, providers", icon: "runtime" },
]);
expect(futureWorkBranches).toHaveLength(5);
expect(new Set(futureWorkBranches.map((branch) => branch.id)).size).toBe(5);
expect(new Set(futureWorkBranches.map((branch) => branch.icon)).size).toBe(5);
});
});
@@ -0,0 +1,22 @@
export type ConclusionBeatId = "limits" | "future" | "conclusion" | "questions";
export type FutureWorkIcon = "agent" | "security" | "schedule" | "evaluation" | "runtime";
export const contributionNodes = [
{ id: "planner", label: "External planner" },
{ id: "substrate", label: "Typed workflow substrate" },
{ id: "runtime", label: "Deterministic runtime" },
{ id: "evidence", label: "Persisted, inspectable evidence" },
] as const;
export const nonClaims = ["Not a production sandbox", "Not a scheduler", "Not a broad agent benchmark"] as const;
export const futureWorkBranches = [
{ id: "agent-interface", label: "Agent interface", example: "Chat or planner loop over wf operations", icon: "agent" },
{ id: "security", label: "Security and credentials", example: "Secrets, RBAC, sandboxing, policy", icon: "security" },
{ id: "scheduling", label: "Hosted operations", example: "Scheduling, daemon lifecycle, monitoring", icon: "schedule" },
{ id: "evaluation", label: "Controlled evaluation", example: "Frozen prompts, more trials, independent audit", icon: "evaluation" },
{ id: "runtime", label: "Runtime expansion", example: "Transactional stores, debugging, providers", icon: "runtime" },
] as const satisfies readonly { readonly id: string; readonly label: string; readonly example: string; readonly icon: FutureWorkIcon }[];
export const isConclusionBeatId = (value: string): value is ConclusionBeatId =>
value === "limits" || value === "future" || value === "conclusion" || value === "questions";
@@ -260,6 +260,131 @@
font-size: 0.8rem; font-size: 0.8rem;
} }
.conclusion-map {
display: grid;
gap: 0.8rem;
margin: 0.25rem 0 0.5rem;
padding: clamp(0.8rem, 2vw, 1.3rem);
border: 1px solid var(--stage-line);
border-radius: 0.45rem;
background: linear-gradient(135deg, oklch(0.17 0.03 250), oklch(0.13 0.025 250));
}
.conclusion-map__flow {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: center;
}
.conclusion-map__node {
position: relative;
display: grid;
min-height: 5.5rem;
align-content: center;
gap: 0.3rem;
padding: 0.75rem 1rem;
border: 1px solid var(--stage-line);
background: oklch(0.19 0.03 250 / 88%);
}
.conclusion-map__node:not(:last-child)::after {
position: absolute;
top: 50%;
right: -0.85rem;
z-index: 1;
width: 1.7rem;
border-top: 1px solid var(--text-muted);
content: "→";
color: var(--text-muted);
font-size: 1rem;
line-height: 0;
text-align: right;
}
.conclusion-map__node[data-emphasis="substrate"] {
border-color: var(--accent-cyan);
box-shadow: inset 0 0 0 1px oklch(0.72 0.17 195 / 20%);
}
.conclusion-map__node-index,
.conclusion-map__non-claims,
.conclusion-map__future small {
color: var(--text-muted);
font: 0.68rem/1.3 "IBM Plex Mono", monospace;
}
.conclusion-map__node strong { font-size: 1rem; }
.conclusion-map__node small { color: var(--text-secondary); font-size: 0.72rem; }
.conclusion-map__non-claims,
.conclusion-map__future {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.conclusion-map__non-claims {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.conclusion-map__non-claims li {
padding: 0.55rem 0.7rem;
border: 1px dashed var(--stage-line);
color: var(--text-secondary);
}
.conclusion-map__non-claims[data-emphasis="limits"] li { border-color: var(--accent-amber); }
.conclusion-map__future {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.55rem;
}
.conclusion-map__future li {
display: flex;
gap: 0.5rem;
align-items: flex-start;
min-width: 0;
padding: 0.65rem;
border-top: 1px solid var(--stage-line);
transition: opacity 180ms ease, transform 180ms ease;
}
.conclusion-map__future svg { flex: 0 0 auto; width: 1.1rem; height: 1.1rem; color: var(--accent-cyan); }
.conclusion-map__future span { display: grid; gap: 0.2rem; }
.conclusion-map__future strong { font-size: 0.8rem; }
.conclusion-map__future small { color: var(--text-secondary); font-family: "Source Sans 3", sans-serif; font-size: 0.76rem; }
.conclusion-map__future[data-state="receded"] li { opacity: 0.42; transform: translateY(0.15rem); }
.conclusion-map__statement {
margin: 0;
padding-top: 0.65rem;
border-top: 1px solid var(--stage-line);
color: var(--text-primary);
font: 1.15rem/1.2 "Newsreader", serif;
text-align: center;
}
@media (max-width: 900px) {
.conclusion-map__future { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.conclusion-map__flow { grid-template-columns: 1fr; gap: 0.85rem; }
.conclusion-map__node:not(:last-child)::after {
top: auto;
right: 50%;
bottom: -0.85rem;
width: auto;
border-top: 0;
content: "↓";
}
.conclusion-map__non-claims,
.conclusion-map__future { grid-template-columns: 1fr; }
}
.scene-body__discussion-links { .scene-body__discussion-links {
flex-shrink: 0; flex-shrink: 0;
display: grid; display: grid;