merge(0.3.1): slice 13.5 mission-progress-artifacts-heatmap — new MissionProgressHeatmap component on Mission Progress tab (slice × acceptance-item grid; DESIGN status-token color mapping)

--no-ff merge from slice-13-5-mission-progress-heatmap (6dbfb99; single commit; parent main 8d9d86f; safety tag temp/slice-13-5-pre-ff preserves prior tip 162babb pre-forward-fix).

NEW packages/ui/src/components/project/MissionProgressHeatmap.tsx (237 lines): slice × acceptance-item grid renders BEFORE PROGRESS.md markdown + ScopeProgressRollup. Cells = filled (done) / outline (not done); done cells take slice status tone via heatmapTone + doneCellClass single-source-of-truth resolver. heatmapTone overrides "active" → info/sky so canonical SliceStatus values render distinct cell colors. Color palette (cellToneClass) cousins the existing toneClass in ProjectMetaPrimitives: emerald (active/done) / sky (progress) / amber (warning) / rose (blocked) / outline-variant (not done). No new color system; reuses canonical DESIGN status tokens via stateTone with denser fill for checkbox-feel.

Slice × acceptance-item (not slice × phase) — driver chose this structure because acceptance items are durable per-slice PROGRESS.md content while phases require workflow_spec binding not every slice has. Variable density handled via flex-wrap on cells container.

Empty-state graceful degradation: per-slice no-items "No acceptance items declared yet"; mission no-slices EmptyState "NO MISSION SLICES"; loading state "LOADING PROGRESS". Blocked-slice-with-done-cells edge case: tinted danger (rose) not false-success emerald — partial-but-blocked reads correctly.

Tests: mission-progress-heatmap.test.tsx 10/10 (8 original + 2 forward-fix regressions for DOM order + legend↔cell color parity); project-workspace-overview.test.tsx 6/6 sanity; 4 pre-existing UI failures unchanged baseline.

SC-29 EXCEPTION declared: NONE.

guard verdict: CLEAR after forward-fix (redo3-guard-3 narrow re-review).
design-reviewer verdict: SHIP — V1-bar professional-grade met. Slice × acceptance-item is V1-bar correct answer; color discipline uses canonical DESIGN tokens via stateTone+cellToneClass parallel palette; gestalt-shape differentiation from Artifacts tab (dense color-grid vs sparse 4-cell metric-card) accomplished.
This commit is contained in:
mvschwarz committed 2026-05-11 16:10:45 -07:00
commit 2b5b86b105
3 files changed
+740

No files matched your search

@@ -0,0 +1,275 @@
// V0.3.1 slice 13.5 mission-progress-artifacts-heatmap.
//
// Mission scope Progress tab differentiation: render a slice ×
// acceptance-cell heat-map ABOVE the existing PROGRESS.md markdown
// + per-slice rollup. Pre-slice 13.5, Mission Progress + Artifacts
// tabs both used the same 4-cell metric-grid primitive shape and
// looked too similar at-a-glance. The heat-map gives Progress its
// own visual gestalt without touching Artifacts.
//
// Cell semantics: one row per slice; one cell per acceptance-item
// in that slice's PROGRESS.md checklist. Filled (success token) =
// done; outline (outline-variant) = not done. Leading column is
// the slice display name + state pill; trailing column is the
// (done/total) tally + percentage.
//
// Why slice × acceptance instead of slice × phase: acceptance items
// are the durable progress unit the operator already authors per
// slice in PROGRESS.md. Phases are workflow_spec-bound and only
// some slices have them. Acceptance items work for every slice.
//
// All colors derive from existing DESIGN.md status tokens via the
// `stateTone` + `cellToneClass` mappings; no new color system.
import type { ReactNode } from "react";
import { Link } from "@tanstack/react-router";
import { EmptyState } from "../ui/empty-state.js";
import { ProjectPill } from "./ProjectMetaPrimitives.js";
import {
type ProjectMetaTone,
stateTone,
} from "./ProjectMetaPrimitives.js";
import type { SliceDetail } from "../../hooks/useSlices.js";
import type { SliceListEntry } from "../../hooks/useSlices.js";
/** Maps a ProjectMetaTone to a single SOLID-fill cell class. Pairs
* with the existing toneClass map in ProjectMetaPrimitives.tsx (which
* is private to that module). Heat-map cells need a denser fill than
* the muted-bg pill chrome — these classes lift the existing palette
* to a "checked checkbox" feel without inventing new tokens. */
const cellToneClass: Record<ProjectMetaTone, string> = {
neutral: "border-stone-400 bg-stone-300",
info: "border-sky-400 bg-sky-200",
success: "border-emerald-500 bg-emerald-400",
warning: "border-amber-400 bg-amber-300",
danger: "border-rose-400 bg-rose-300",
};
/** Heat-map-local tone resolution. Wraps the shared `stateTone` but
* overrides for `SliceStatus` values whose canonical UI tone differs
* from what `stateTone` infers from generic state strings.
*
* Specifically: `stateTone("active")` returns `"neutral"` (no keyword
* match), which would make active slices' done cells fall through to
* the `success` tone. That collapses the at-a-glance distinction
* between an in-flight active slice's done items vs a shipped done
* slice's done items. Override `"active"` → `"info"` so the heat-map
* shows the canonical SliceStatus differentiation. Other status
* strings flow through `stateTone` unchanged. */
function heatmapTone(status: string | undefined): ProjectMetaTone {
if (status === "active") return "info";
return stateTone(status);
}
/** Resolution rule used by both the heat-map cells AND the legend so
* the legend swatch class is always EXACTLY the same string the cell
* would render. Pure function of tone; no side effects. */
function doneCellClass(tone: ProjectMetaTone): string {
return cellToneClass[tone === "neutral" ? "success" : tone];
}
interface HeatmapRow {
/** Slice id (used for the drill-in link target). */
name: string;
/** Display name shown in the leading column. */
displayName: string;
/** Slice state ("active" | "done" | "blocked" | "draft"); drives the
* state pill in the leading column + the implied tone of done cells
* for blocked/danger slices (so a blocked slice's "done" cells read
* warning-tinted rather than success-tinted). */
status: string;
/** Acceptance items in PROGRESS.md order. May be empty when the slice
* hasn't authored a PROGRESS.md yet. */
items: { text: string; done: boolean }[];
/** Pre-computed totals shown in the trailing column. */
doneItems: number;
totalItems: number;
/** Acceptance percentage (0-100), pre-computed by the daemon route. */
percentage: number;
}
export function MissionProgressHeatmap({
rows,
detailsByName,
isLoading,
}: {
rows: SliceListEntry[];
detailsByName: Map<string, SliceDetail>;
isLoading?: boolean;
}) {
const heatmapRows: HeatmapRow[] = rows.map((row) => {
const detail = detailsByName.get(row.name);
const acceptance = detail?.acceptance;
return {
name: row.name,
displayName: row.displayName,
status: row.status,
items: acceptance?.items ?? [],
doneItems: acceptance?.doneItems ?? 0,
totalItems: acceptance?.totalItems ?? 0,
percentage: acceptance?.percentage ?? 0,
};
});
if (isLoading && heatmapRows.length === 0) {
return (
<EmptyState
label="LOADING PROGRESS"
description="Reading mission slice acceptance."
variant="card"
testId="mission-progress-heatmap-loading"
/>
);
}
if (heatmapRows.length === 0) {
return (
<EmptyState
label="NO MISSION SLICES"
description="Mission has no scoped slices to render a heat-map."
variant="card"
testId="mission-progress-heatmap-empty"
/>
);
}
return (
<section
data-testid="mission-progress-heatmap"
className="border border-outline-variant bg-white/35 p-4 backdrop-blur-sm"
>
<header className="mb-3 flex items-center justify-between gap-3 border-b border-outline-variant pb-2">
<h3 className="font-mono text-[11px] uppercase tracking-[0.16em] text-stone-900">
Acceptance heat-map
</h3>
<span className="font-mono text-[10px] text-stone-600">
{heatmapRows.length} slice{heatmapRows.length === 1 ? "" : "s"} ·
{" "}
one cell per acceptance item
</span>
</header>
<div className="space-y-2">
{heatmapRows.map((row) => (
<HeatmapSliceRow key={row.name} row={row} />
))}
</div>
<HeatmapLegend />
</section>
);
}
function HeatmapSliceRow({ row }: { row: HeatmapRow }) {
const tone = heatmapTone(row.status);
return (
<article
data-testid={`mission-progress-heatmap-row-${row.name}`}
data-status={row.status}
data-tone={tone}
className="grid grid-cols-[minmax(8rem,16rem)_1fr_auto] items-center gap-3"
>
<div className="min-w-0 space-y-1">
<Link
to="/project/slice/$sliceId"
params={{ sliceId: row.name }}
className="block truncate font-mono text-[11px] uppercase tracking-[0.12em] text-stone-900 hover:underline"
title={row.displayName}
aria-label={`${row.displayName} (${row.doneItems}/${row.totalItems} acceptance items)`}
>
{row.displayName}
</Link>
<ProjectPill token={{ label: row.status, tone }} compact />
</div>
<Cells row={row} />
<div className="font-mono text-[10px] text-stone-700 tabular-nums whitespace-nowrap">
{row.doneItems}/{row.totalItems || 0}
{row.totalItems > 0 ? ` (${row.percentage}%)` : ""}
</div>
</article>
);
}
function Cells({ row }: { row: HeatmapRow }) {
if (row.items.length === 0) {
return (
<div
data-testid={`mission-progress-heatmap-cells-${row.name}`}
data-cell-state="empty"
className="font-mono text-[10px] italic text-stone-500"
>
No acceptance items declared yet.
</div>
);
}
const tone = heatmapTone(row.status);
// Done cells take the slice's status tone (info for active, success
// for done, danger for blocked, etc.) so the heat-map reads both
// per-cell + per-row at-a-glance. Not-done cells stay outline-only
// so the eye finds incomplete work fast.
const doneClass = doneCellClass(tone);
return (
<div
data-testid={`mission-progress-heatmap-cells-${row.name}`}
className="flex flex-wrap gap-[3px]"
>
{row.items.map((item, idx) => (
<span
key={idx}
data-testid={`mission-progress-heatmap-cell-${row.name}-${idx}`}
data-done={item.done ? "true" : "false"}
aria-label={`${item.text} (${item.done ? "done" : "not done"})`}
title={item.text}
className={
item.done
? `h-4 w-4 border ${doneClass}`
: "h-4 w-4 border border-outline-variant bg-white/35"
}
/>
))}
</div>
);
}
function HeatmapLegend() {
// Legend swatches MUST use the same class string the cells would
// render for the given tone. doneCellClass() is the single source
// of truth; the legend just feeds it the same tone the heat-map
// resolves for each status keyword.
return (
<footer
data-testid="mission-progress-heatmap-legend"
className="mt-3 flex flex-wrap items-center gap-3 border-t border-outline-variant pt-2 font-mono text-[10px] text-stone-600"
>
<LegendCell label="done (active)" tone="info" testId="legend-active" />
<LegendCell label="done (complete)" tone="success" testId="legend-complete" />
<LegendCell label="done (warning)" tone="warning" testId="legend-warning" />
<LegendCell label="done (blocked)" tone="danger" testId="legend-blocked" />
<LegendCell label="not done" notDone testId="legend-not-done" />
</footer>
);
}
function LegendCell({
label,
tone,
notDone,
testId,
}: {
label: string;
tone?: ProjectMetaTone;
notDone?: boolean;
testId: string;
}): ReactNode {
const swatchClass = notDone
? "border-outline-variant bg-white/35"
: doneCellClass(tone!);
return (
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
data-testid={`mission-progress-heatmap-${testId}`}
className={`inline-block h-3 w-3 border ${swatchClass}`}
/>
<span>{label}</span>
</span>
);
}
@@ -51,6 +51,7 @@ import { DecisionsTab } from "../slices/tabs/DecisionsTab.js";
import { TestsVerificationTab } from "../slices/tabs/TestsVerificationTab.js";
import { TopologyTab } from "../slices/tabs/TopologyTab.js";
import { HostMultiRigGraph } from "../topology/HostMultiRigGraph.js";
import { MissionProgressHeatmap } from "./MissionProgressHeatmap.js";
import { QueueItemTrigger } from "../drawer-triggers/QueueItemTrigger.js";
import {
DateChip,
@@ -808,6 +809,11 @@ export function MissionScopePage() {
) : null}
{active === "progress" ? (
<div data-testid="mission-progress-panel" className="space-y-6">
<MissionProgressHeatmap
rows={rollup.rows}
detailsByName={rollup.details.itemsByName}
isLoading={rollup.list.isLoading || rollup.details.isFetching}
/>
{missionProgress.content && (
<section data-testid="mission-progress-readme" className="border border-outline-variant bg-white/20 p-4">
<MarkdownViewer content={missionProgress.content} hideFrontmatter hideRawToggle />
@@ -0,0 +1,459 @@
// V0.3.1 slice 13.5 mission-progress-artifacts-heatmap.
//
// MissionProgressHeatmap is a per-slice acceptance-cell heat-map
// rendered ABOVE the existing Progress tab content. Tests cover:
//
// T1 — component renders a grid shape with N slices × M acceptance
// cells per row (HG-1)
// T2 — cell colors map to slice state via stateTone (done cells
// carry the slice's status tone; not-done cells outline-only)
// (HG-2)
// T3 — legend renders the canonical state -> color mapping (HG-2)
// T4 — MissionScopePage Progress tab composes markdown + heat-map +
// per-slice rollup; Artifacts tab unchanged (no heat-map mount)
// (HG-3 + HG-4)
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { cleanup, fireEvent, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
Outlet,
RouterProvider,
} from "@tanstack/react-router";
import { MissionProgressHeatmap } from "../src/components/project/MissionProgressHeatmap.js";
import { MissionScopePage } from "../src/components/project/ScopePages.js";
import type { SliceDetail, SliceListEntry } from "../src/hooks/useSlices.js";
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
beforeEach(() => {
mockFetch.mockReset();
});
afterEach(() => {
cleanup();
});
function makeRow(name: string, status: SliceListEntry["status"]): SliceListEntry {
return {
name,
displayName: name,
railItem: "RELEASE-PROOF",
status,
rawStatus: status,
qitemCount: 0,
hasProofPacket: false,
lastActivityAt: "2026-05-11T00:00:00.000Z",
};
}
function makeDetail(
name: string,
items: { text: string; done: boolean }[],
): SliceDetail {
const doneItems = items.filter((it) => it.done).length;
const totalItems = items.length;
const percentage = totalItems === 0 ? 0 : Math.round((doneItems / totalItems) * 100);
return {
name,
missionId: "RELEASE-PROOF",
slicePath: `/workspace/${name}`,
displayName: name,
railItem: "RELEASE-PROOF",
status: "active",
rawStatus: "active",
qitemIds: [],
commitRefs: [],
lastActivityAt: "2026-05-11T00:00:00.000Z",
workflowBinding: null,
story: { events: [], phaseDefinitions: null },
acceptance: {
totalItems,
doneItems,
percentage,
items: items.map((it) => ({
text: it.text,
done: it.done,
source: { file: "PROGRESS.md", line: 1 },
})),
closureCallout: null,
currentStep: null,
},
decisions: { rows: [] },
docs: { tree: [] },
tests: {
proofPackets: [],
aggregate: { passCount: 0, failCount: 0 },
},
topology: { affectedRigs: [], totalSeats: 0, specGraph: null },
};
}
function withRouter(ui: React.ReactNode) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const rootRoute = createRootRoute({ component: () => <Outlet /> });
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: () => <>{ui}</>,
});
const sliceRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/project/slice/$sliceId",
component: () => null,
});
const fallback = createRoute({
getParentRoute: () => rootRoute,
path: "$",
component: () => null,
});
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, sliceRoute, fallback]),
history: createMemoryHistory({ initialEntries: ["/"] }),
});
return render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
describe("MissionProgressHeatmap (slice 13.5)", () => {
it("HG-1: renders N rows × M acceptance cells in a grid shape", async () => {
const rows = [makeRow("alpha", "active"), makeRow("beta", "done")];
const detailsByName = new Map<string, SliceDetail>([
[
"alpha",
makeDetail("alpha", [
{ text: "a1", done: true },
{ text: "a2", done: true },
{ text: "a3", done: false },
]),
],
[
"beta",
makeDetail("beta", [
{ text: "b1", done: true },
{ text: "b2", done: true },
]),
],
]);
const { findByTestId, queryAllByTestId } = withRouter(
<MissionProgressHeatmap rows={rows} detailsByName={detailsByName} />,
);
expect(await findByTestId("mission-progress-heatmap")).toBeTruthy();
expect(await findByTestId("mission-progress-heatmap-row-alpha")).toBeTruthy();
expect(await findByTestId("mission-progress-heatmap-row-beta")).toBeTruthy();
// Each row's cells container holds the per-acceptance-item span.
const alphaCells = queryAllByTestId(/^mission-progress-heatmap-cell-alpha-\d+$/);
expect(alphaCells.length).toBe(3);
const betaCells = queryAllByTestId(/^mission-progress-heatmap-cell-beta-\d+$/);
expect(betaCells.length).toBe(2);
});
it("HG-2: done cells take the slice's status tone; not-done cells render outline-only", async () => {
const rows = [makeRow("alpha", "active")];
const detailsByName = new Map<string, SliceDetail>([
[
"alpha",
makeDetail("alpha", [
{ text: "a1", done: true },
{ text: "a2", done: false },
]),
],
]);
const { findByTestId } = withRouter(
<MissionProgressHeatmap rows={rows} detailsByName={detailsByName} />,
);
const row = await findByTestId("mission-progress-heatmap-row-alpha");
expect(row.getAttribute("data-status")).toBe("active");
// The heat-map's local heatmapTone() overrides stateTone for the
// canonical SliceStatus value "active" -> "info", so done cells on
// an active slice are visually distinct from done cells on a
// shipped done slice. This matches the legend.
expect(row.getAttribute("data-tone")).toBe("info");
const doneCell = await findByTestId("mission-progress-heatmap-cell-alpha-0");
const notDoneCell = await findByTestId("mission-progress-heatmap-cell-alpha-1");
expect(doneCell.getAttribute("data-done")).toBe("true");
expect(notDoneCell.getAttribute("data-done")).toBe("false");
// Active slice done cells render in the info tone (sky); not-done
// cells stay outline-only.
expect(doneCell.className).toMatch(/bg-sky-200/);
expect(notDoneCell.className).toMatch(/border-outline-variant/);
expect(notDoneCell.className).not.toMatch(/bg-sky-200/);
});
it("HG-2 (blocked tone): blocked-status slice rows mark done cells with the danger tone", async () => {
const rows = [makeRow("blockedSlice", "blocked")];
const detailsByName = new Map<string, SliceDetail>([
[
"blockedSlice",
makeDetail("blockedSlice", [
{ text: "x1", done: true },
{ text: "x2", done: false },
]),
],
]);
const { findByTestId } = withRouter(
<MissionProgressHeatmap rows={rows} detailsByName={detailsByName} />,
);
const row = await findByTestId("mission-progress-heatmap-row-blockedSlice");
expect(row.getAttribute("data-tone")).toBe("danger");
const doneCell = await findByTestId("mission-progress-heatmap-cell-blockedSlice-0");
expect(doneCell.className).toMatch(/bg-rose-300/);
});
it("HG-2 (legend): legend renders all five state -> color samples", async () => {
const rows = [makeRow("alpha", "active")];
const detailsByName = new Map<string, SliceDetail>([
["alpha", makeDetail("alpha", [{ text: "a1", done: true }])],
]);
const { findByTestId } = withRouter(
<MissionProgressHeatmap rows={rows} detailsByName={detailsByName} />,
);
const legend = await findByTestId("mission-progress-heatmap-legend");
expect(legend.textContent).toContain("done (active)");
expect(legend.textContent).toContain("done (complete)");
expect(legend.textContent).toContain("done (warning)");
expect(legend.textContent).toContain("done (blocked)");
expect(legend.textContent).toContain("not done");
});
// Forward-fix-1 coverage gap closed: legend swatch className must
// match the className of an actual heat-map cell rendered for a
// slice of that status. The legend says "done (active)" labels the
// info-toned swatch; an active-status slice's done cell must use the
// same className. Previously, legend hard-coded cellToneClass.info
// but active-status rows fell through neutral->success (emerald) for
// their done cells, leaving the legend out of sync with reality.
it("HG-2 (legend ↔ cell parity): legend swatch className equals the actual cell className for each canonical SliceStatus", async () => {
const rows = [
makeRow("activeSlice", "active"),
makeRow("doneSlice", "done"),
makeRow("blockedSlice", "blocked"),
];
const detailsByName = new Map<string, SliceDetail>([
["activeSlice", makeDetail("activeSlice", [{ text: "a1", done: true }])],
["doneSlice", makeDetail("doneSlice", [{ text: "b1", done: true }])],
["blockedSlice", makeDetail("blockedSlice", [{ text: "c1", done: true }])],
]);
const { findByTestId } = withRouter(
<MissionProgressHeatmap rows={rows} detailsByName={detailsByName} />,
);
// active -> legend-active swatch should carry the SAME color
// classes (bg + border) the actual active-row done cell renders.
const activeCell = await findByTestId("mission-progress-heatmap-cell-activeSlice-0");
const activeLegend = await findByTestId("mission-progress-heatmap-legend-active");
expect(activeLegend.className).toMatch(/bg-sky-200/);
expect(activeCell.className).toMatch(/bg-sky-200/);
const doneCell = await findByTestId("mission-progress-heatmap-cell-doneSlice-0");
const completeLegend = await findByTestId("mission-progress-heatmap-legend-complete");
expect(completeLegend.className).toMatch(/bg-emerald-400/);
expect(doneCell.className).toMatch(/bg-emerald-400/);
const blockedCell = await findByTestId("mission-progress-heatmap-cell-blockedSlice-0");
const blockedLegend = await findByTestId("mission-progress-heatmap-legend-blocked");
expect(blockedLegend.className).toMatch(/bg-rose-300/);
expect(blockedCell.className).toMatch(/bg-rose-300/);
// warning tone has no canonical SliceStatus mapping but the legend
// still publishes the swatch shape. Asserting the legend swatch
// for warning gives operators a forward-compatible reference for
// any future status string that resolves to the warning tone.
const warningLegend = await findByTestId("mission-progress-heatmap-legend-warning");
expect(warningLegend.className).toMatch(/bg-amber-300/);
});
it("renders an empty-state when the mission has no scoped slices", async () => {
const { findByTestId } = withRouter(
<MissionProgressHeatmap rows={[]} detailsByName={new Map()} />,
);
expect(await findByTestId("mission-progress-heatmap-empty")).toBeTruthy();
});
it("renders the tally column with done/total + percentage when acceptance items exist", async () => {
const rows = [makeRow("alpha", "active")];
const detailsByName = new Map<string, SliceDetail>([
[
"alpha",
makeDetail("alpha", [
{ text: "a1", done: true },
{ text: "a2", done: true },
{ text: "a3", done: false },
]),
],
]);
const { findByTestId } = withRouter(
<MissionProgressHeatmap rows={rows} detailsByName={detailsByName} />,
);
const row = await findByTestId("mission-progress-heatmap-row-alpha");
expect(row.textContent).toContain("2/3");
expect(row.textContent).toContain("67%");
});
});
describe("MissionScopePage Progress tab composes heat-map (slice 13.5)", () => {
function installMissionFetchMock() {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/api/config")) {
return new Response(
JSON.stringify({
settings: { "workspace.root": { value: "/Users/admin/.openrig/workspace" } },
}),
{ status: 200 },
);
}
if (url.includes("/api/missions/RELEASE-PROOF") && !url.includes("/api/missions/RELEASE-PROOF/")) {
return new Response(
JSON.stringify({
missionId: "RELEASE-PROOF",
missionPath: "/workspace/missions/release-proof",
slices: [
{
name: "alpha",
displayName: "alpha",
railItem: "RELEASE-PROOF",
status: "active",
rawStatus: "active",
qitemCount: 0,
hasProofPacket: false,
lastActivityAt: "2026-05-11T00:00:00.000Z",
},
],
topology: { specGraph: null },
}),
{ status: 200 },
);
}
if (url.includes("/api/slices/alpha")) {
return new Response(JSON.stringify(makeDetail("alpha", [{ text: "a1", done: true }])), {
status: 200,
});
}
if (url.includes("/api/slices")) {
return new Response(
JSON.stringify({
slices: [
{
name: "alpha",
displayName: "alpha",
railItem: "RELEASE-PROOF",
status: "active",
rawStatus: "active",
qitemCount: 0,
hasProofPacket: false,
lastActivityAt: "2026-05-11T00:00:00.000Z",
},
],
totalCount: 1,
filter: "all",
}),
{ status: 200 },
);
}
if (url.includes("/scope-markdown") || url.includes("PROGRESS.md") || url.includes("README.md")) {
return new Response(JSON.stringify({ content: "# Mission progress goes here\n" }), {
status: 200,
});
}
return new Response("[]", { status: 200 });
});
}
function renderMissionScope(): ReturnType<typeof render> {
installMissionFetchMock();
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const rootRoute = createRootRoute({ component: () => <Outlet /> });
const missionRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/project/mission/$missionId",
component: () => <MissionScopePage />,
});
const sliceRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/project/slice/$sliceId",
component: () => null,
});
const fallback = createRoute({
getParentRoute: () => rootRoute,
path: "$",
component: () => null,
});
const router = createRouter({
routeTree: rootRoute.addChildren([missionRoute, sliceRoute, fallback]),
history: createMemoryHistory({ initialEntries: ["/project/mission/RELEASE-PROOF"] }),
});
return render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
it("HG-3: Mission Progress tab includes heat-map ALONGSIDE existing rollup", async () => {
const { findByTestId } = renderMissionScope();
// Sanity: mission overview mounts.
expect(await findByTestId("mission-overview-panel")).toBeTruthy();
// Switch to Progress tab.
fireEvent.click(await findByTestId("project-tab-progress"));
// Heat-map renders.
expect(await findByTestId("mission-progress-heatmap")).toBeTruthy();
// Per-slice rollup still present (composition, not replacement).
expect(await findByTestId("scope-progress-rollup")).toBeTruthy();
});
// Forward-fix-1: heat-map must render BEFORE the PROGRESS.md
// markdown section so the new visual gestalt is the first thing on
// the Progress tab. Asserting DOM order via Node.compareDocument-
// Position protects against silent reordering that the old
// "both render" assertion (HG-3 above) would not catch.
it("HG-3 (DOM order): heat-map renders BEFORE the PROGRESS.md markdown section", async () => {
const { findByTestId } = renderMissionScope();
expect(await findByTestId("mission-overview-panel")).toBeTruthy();
fireEvent.click(await findByTestId("project-tab-progress"));
const heatmap = await findByTestId("mission-progress-heatmap");
const panel = await findByTestId("mission-progress-panel");
// The markdown section only renders when missionProgress.content
// is non-empty; the integration mock returns "# Mission progress
// goes here" so it should mount. If it doesn't, this assertion
// skips (no order to compare).
const readme = panel.querySelector(
"[data-testid='mission-progress-readme']",
);
if (readme) {
// DOCUMENT_POSITION_FOLLOWING (4) means readme comes AFTER
// heatmap in DOM order — i.e., heatmap renders first.
expect(heatmap.compareDocumentPosition(readme)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
}
// Heat-map must also precede the rollup; this is the structural
// claim of the slice and the legend depends on it visually.
const rollup = await findByTestId("scope-progress-rollup");
expect(heatmap.compareDocumentPosition(rollup)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
});
it("HG-4: Mission Artifacts tab does NOT mount the heat-map", async () => {
const { findByTestId, queryByTestId } = renderMissionScope();
expect(await findByTestId("mission-overview-panel")).toBeTruthy();
fireEvent.click(await findByTestId("project-tab-artifacts"));
expect(queryByTestId("mission-progress-heatmap")).toBeNull();
});
});