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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user