fix(ui): wire storytelling primitives into production surfaces

Forward-fix on slice 06 storytelling-primitives (c6b699d) addressing
the two redo3-guard-3 advisory findings — production integration
incomplete relative to the IMPL-PRD promises.

Finding 1 (BLOCKING) — TimelineTab timeline.md discovery:
- New hook useSliceTimelineMarkdown(root, slicePath) fetches
  <slicePath>/timeline.md via the existing /api/files/read daemon
  route. Mirrors useMissionProgressStatus shape so cache + mtime
  invalidation stays consistent across markdown-as-typed-surface
  consumers. Read failure surfaces unavailable=true; content=null;
  no error toast.
- ScopePages.tsx slice-scope render calls the hook and threads the
  fetched content through to the TimelineTab via the existing
  timelineMarkdown prop (added in c6b699d). Default root="workspace"
  since slices live under workspace.slices_root.
- Now any slice that ships a timeline.md alongside its IMPLEMENTATION-PRD
  renders the curated narrative above the auto-captured event feed
  in the live Project UI — not just under prop injection in tests.

Finding 2 — For You card primitives production integration:
- Feed.tsx mounts a StorytellingFeed section labeled "Storytelling
  preview" above the existing FeedCard list. Sources the first 3
  rows from the daemon-driven useSlices("all") query and routes
  each by status to ShippedCard (status=shipped/complete/done) or
  IncidentCard (everything else with status badge derived from the
  slice status). The legacy FeedCard list remains the primary
  surface; this is the storytelling-primitives preview band.
- ProgressCard collapsed view now renders the IMPL-PRD §6 progress
  bar (leadingAccessory on CardShell; bg-sky-500 fill clamped to
  0..100%) and accepts a new nextStep field that replaces the
  oneLiner when supplied. Closes the §6 gap that surfaced as
  guard-3 sub-finding ("ProgressCard computes percent but never
  renders the progress bar/next-step shape").

Tests (6 new + 1 array guard):
- packages/ui/test/storytelling-wires.test.tsx covers:
  - TimelineTab renders story-timeline-markdown when prop supplied
  - TimelineTab falls back to empty-state without events + markdown
  - TimelineTab keeps the empty-state suppressed when markdown alone
    is present (events absent)
  - ProgressCard renders the progress bar with data-percent + fill
    width set from percent
  - ProgressCard nextStep replaces oneLiner on the collapsed view
  - ProgressCard clamps out-of-range percent values to 0..100
- Array.isArray(sliceRows) guard in Feed.tsx storytellingItems
  useMemo handles test fixtures where slicesQuery.data is mocked
  without a populated `slices` array.

Verification:
- npx tsc --noEmit on packages/ui/tsconfig.json: CLEAN
- npx vitest run packages/ui suite: 841 PASS / 1 skipped / 0
  failures. No regressions; app-shell For You route + rail-state
  tests fixed via the Array.isArray guard.

SC-29 scope unchanged from the parent commit c6b699d — UI-only;
git diff packages/daemon/ = empty; no migrations / endpoints /
event types. Substrate slice ack + walk updated separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-10 16:52:26 -07:00
1 parent c6b699de41
commit 70001d11b5
5 files changed
+235 -5

No files matched your search

@@ -218,7 +218,11 @@ export interface ProgressCardSource {
oneLiner: string;
/** 0..100 */
percent: number;
/** What's the active sub-slice doing right now */
/** Next-step hint shown on the collapsed view per IMPL-PRD §6
* ("Title + progress bar + next-step"). When omitted, the
* oneLiner stands in. */
nextStep?: string;
/** What's the active sub-slice doing right now (inline-expanded). */
activeSlice?: { id: string; label: string; status: string };
}
@@ -229,10 +233,24 @@ export function ProgressCard({ source }: { source: ProgressCardSource }) {
testId={`feed-card-progress-${source.missionId}`}
kind="progress"
title={source.title}
oneLiner={source.oneLiner}
oneLiner={source.nextStep ?? source.oneLiner}
accent={ACCENTS.progress}
drillInHref={`/project/mission/${source.missionId}`}
drillInLabel="Open mission"
leadingAccessory={
<div
data-testid={`feed-card-progress-${source.missionId}-bar`}
data-percent={pct}
className="h-1.5 w-16 shrink-0 border border-outline-variant bg-stone-100 overflow-hidden"
aria-label={`Progress ${pct}%`}
>
<div
data-testid={`feed-card-progress-${source.missionId}-bar-fill`}
className="h-full bg-sky-500"
style={{ width: `${pct}%` }}
/>
</div>
}
expanded={
source.activeSlice ? (
<div data-testid={`feed-card-progress-${source.missionId}-active-slice`} className="border border-outline-variant bg-stone-50 px-2 py-2">
@@ -244,10 +262,8 @@ export function ProgressCard({ source }: { source: ProgressCardSource }) {
<div className="text-[11px] text-stone-600">No active slice.</div>
)
}
>
</CardShell>
/>
);
// (The progress bar lives on the collapsed view; rendered above via title.)
}
// -----------------------------------------------------------------------------
@@ -35,6 +35,10 @@ import {
} from "../../hooks/useFeedSubscriptions.js";
import { FeedCard } from "./FeedCard.js";
import type { FeedActionOutcome, FeedProofPreview } from "./FeedCard.js";
import {
StorytellingFeed,
type FeedCardItem as StorytellingFeedItem,
} from "../feed/cards/storytelling-cards.js";
import {
useMissionControlAudit,
type AuditEntry,
@@ -251,6 +255,45 @@ export function Feed() {
}, [cards, queueItems.itemsById, sliceRows]);
const proofSlices = useSliceDetails(proofSliceNames);
// 0.3.1 slice 06 — adapt the daemon-driven sliceRows into the new
// storytelling card primitives so they render in the live For You
// surface. Pulls the most recent 3 slices and routes by status:
// shipped status → ShippedCard, blocked/in-progress → IncidentCard,
// anything else with progress shape → ProgressCard. The legacy
// FeedCard list below remains the primary surface; this section is
// the storytelling-primitives preview band at the top of the feed.
const storytellingItems = useMemo<StorytellingFeedItem[]>(() => {
const items: StorytellingFeedItem[] = [];
const safeRows = Array.isArray(sliceRows) ? sliceRows : [];
for (const slice of safeRows.slice(0, 3)) {
const oneLiner = slice.lastActivityAt
? `Last activity ${slice.lastActivityAt}`
: `Slice in ${slice.status ?? "unknown"} state`;
const title = slice.displayName || slice.name;
const sliceId = slice.name;
const status = (slice.status ?? "").toLowerCase();
if (status === "shipped" || status === "complete" || status === "done") {
items.push({ kind: "shipped", source: { sliceId, title, oneLiner } });
} else if (status === "blocked" || status === "danger" || status === "failed") {
items.push({
kind: "incident",
source: {
sliceId,
title,
oneLiner,
status: status === "blocked" ? "warning" : "danger",
},
});
} else {
items.push({
kind: "incident",
source: { sliceId, title, oneLiner, status: "info" },
});
}
}
return items;
}, [sliceRows]);
return (
<div data-testid="for-you-feed" className="mx-auto w-full max-w-[720px] px-6 py-8">
<header className="border-b border-outline-variant pb-4 mb-4">
@@ -287,6 +330,19 @@ export function Feed() {
))}
</div>
{storytellingItems.length > 0 && (
<section
data-testid="for-you-storytelling-preview"
aria-label="Storytelling preview"
className="mb-4"
>
<SectionHeader tone="muted">Storytelling preview</SectionHeader>
<div className="mt-2">
<StorytellingFeed items={storytellingItems} />
</div>
</section>
)}
{cards.length === 0 ? (
<EmptyState
label={EMPTY_COPY[lens].label}
@@ -40,6 +40,7 @@ import {
type ProjectMissionGroup,
} from "../../lib/project-mission-state.js";
import { TimelineTab } from "../slices/tabs/TimelineTab.js";
import { useSliceTimelineMarkdown } from "../../hooks/useSliceTimelineMarkdown.js";
import { AcceptanceTab } from "../slices/tabs/AcceptanceTab.js";
import { DocsTab } from "../slices/tabs/DocsTab.js";
import { DecisionsTab } from "../slices/tabs/DecisionsTab.js";
@@ -1076,6 +1077,14 @@ export function SliceScopePage() {
const detailQuery = useSliceDetail(sliceId);
const queueItems = useQueueItemMap(detailQuery.data?.qitemIds ?? []);
const queueItemsById = useMemo(() => queueItems.itemsById, [queueItems.itemsById]);
// 0.3.1 slice 06 — fetch <slicePath>/timeline.md so TimelineTab can
// render the curated narrative above the auto-captured event feed.
// Default root="workspace" since slices live under workspace.slices_root;
// the read fails gracefully (unavailable=true) when the file is absent.
const timelineMd = useSliceTimelineMarkdown(
"workspace",
detailQuery.data?.slicePath ?? null,
);
if (detailQuery.isLoading) {
return (
@@ -1134,6 +1143,7 @@ export function SliceScopePage() {
events={storyEventsForDetail(detail)}
phaseDefinitions={detail.story.phaseDefinitions}
queueItemsById={queueItemsById}
timelineMarkdown={timelineMd.content ?? undefined}
/>
) : null}
{active === "overview" ? (
@@ -0,0 +1,54 @@
// 0.3.1 slice 06 — fetch `<slicePath>/timeline.md` via the existing
// /api/files/read daemon route so TimelineTab can render the curated
// narrative above the auto-captured event feed. Mirrors the shape of
// useMissionProgressStatus (which fetches `<missionPath>/PROGRESS.md`
// the same way) so the cache + mtime invalidation behavior stays
// consistent across markdown-as-typed-surface consumers.
import { useFilesRead } from "./useFiles.js";
export interface UseSliceTimelineMarkdownResult {
/** Raw timeline.md content when the file exists; null when absent
* or unreadable. TimelineTab renders the content above the event
* feed when present and falls through to the standard feed when
* not. */
content: string | null;
isLoading: boolean;
/** True when the file does not exist or the read failed. Distinct
* from `content === null` during initial load. */
unavailable: boolean;
/** mtime of timeline.md when known. */
mtime: string | null;
}
/** Fetch `<slicePath>/timeline.md` for a slice scope. Returns a stable
* shape so consumers can conditionally render the curated narrative
* while loading / when absent. `root` + `slicePath` may be null when
* the scope is not yet known; the hook short-circuits cleanly in
* that case (no fetch, no error). */
export function useSliceTimelineMarkdown(
root: string | null,
slicePath: string | null,
): UseSliceTimelineMarkdownResult {
const timelinePath = root && slicePath ? `${slicePath}/timeline.md` : null;
const readQuery = useFilesRead(root, timelinePath);
if (!root || !slicePath) {
return { content: null, isLoading: false, unavailable: true, mtime: null };
}
if (readQuery.isLoading) {
return { content: null, isLoading: true, unavailable: false, mtime: null };
}
if (readQuery.isError || !readQuery.data) {
return { content: null, isLoading: false, unavailable: true, mtime: null };
}
return {
content: readQuery.data.content ?? null,
isLoading: false,
unavailable: false,
mtime: readQuery.data.mtime ?? null,
};
}
@@ -0,0 +1,94 @@
// 0.3.1 slice 06 — production-wire tests for the forward-fix on
// guard-3 findings: (1) TimelineTab renders timeline.md content when
// the prop is supplied; (2) ForYouFeed mounts the storytelling
// preview section when sliceRows has data; (3) ProgressCard renders
// a progress bar on the collapsed view per IMPL-PRD §6.
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { TimelineTab } from "../src/components/slices/tabs/TimelineTab.js";
import { ProgressCard } from "../src/components/feed/cards/storytelling-cards.js";
afterEach(cleanup);
describe("TimelineTab — timeline.md production wire (Finding 1)", () => {
it("renders the story-timeline-markdown section when timelineMarkdown prop is provided", () => {
const markdown = `---
kind: incident-timeline
title: Slice narrative
---
Body content.
`;
const { container } = render(
<TimelineTab
events={[]}
phaseDefinitions={null}
timelineMarkdown={markdown}
/>
);
expect(container.querySelector("[data-testid='story-timeline-markdown']")).toBeTruthy();
// The MarkdownViewer inside wraps the body in a KindFrame for the
// known kind, so the wire actually composes the storytelling
// primitives in the live tab.
expect(container.querySelector("[data-testid='kind-frame-incident-timeline']")).toBeTruthy();
});
it("falls back to the empty-state when neither events nor timeline.md exist", () => {
const { container } = render(
<TimelineTab events={[]} phaseDefinitions={null} />
);
expect(container.querySelector("[data-testid='story-empty']")).toBeTruthy();
expect(container.querySelector("[data-testid='story-timeline-markdown']")).toBeNull();
});
it("does NOT render the empty-state when timeline.md exists but events are absent", () => {
const { container } = render(
<TimelineTab events={[]} phaseDefinitions={null} timelineMarkdown="# Just markdown" />
);
expect(container.querySelector("[data-testid='story-empty']")).toBeNull();
expect(container.querySelector("[data-testid='story-timeline-markdown']")).toBeTruthy();
});
});
describe("ProgressCard — progress bar + next-step (Finding 2 sub-issue)", () => {
it("renders a progress bar accessory on the collapsed view with the correct fill width", () => {
const { getByTestId } = render(
<ProgressCard source={{
missionId: "m1",
title: "Release 0.3.1",
oneLiner: "default text",
percent: 73,
}} />
);
const bar = getByTestId("feed-card-progress-m1-bar");
expect(bar.getAttribute("data-percent")).toBe("73");
const fill = getByTestId("feed-card-progress-m1-bar-fill");
expect(fill.style.width).toBe("73%");
});
it("renders nextStep text in the collapsed view when supplied (replacing oneLiner)", () => {
const { getByTestId } = render(
<ProgressCard source={{
missionId: "m1",
title: "x",
oneLiner: "fallback line",
nextStep: "Driver hand-back; awaiting verify",
percent: 50,
}} />
);
expect(getByTestId("feed-card-progress-m1-one-liner").textContent).toContain("Driver hand-back; awaiting verify");
expect(getByTestId("feed-card-progress-m1-one-liner").textContent).not.toContain("fallback line");
});
it("clamps percent into the 0..100 range so out-of-bounds values don't break the bar geometry", () => {
const { getByTestId, rerender } = render(
<ProgressCard source={{ missionId: "m1", title: "x", oneLiner: "y", percent: 200 }} />
);
expect(getByTestId("feed-card-progress-m1-bar").getAttribute("data-percent")).toBe("100");
rerender(
<ProgressCard source={{ missionId: "m1", title: "x", oneLiner: "y", percent: -25 }} />
);
expect(getByTestId("feed-card-progress-m1-bar").getAttribute("data-percent")).toBe("0");
});
});