feat: add contribution closing map
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user