feat(ui): storytelling primitives — kind dispatcher, fenced-block grammars, TimelineTab refactor, For You card primitives

Slice 06 of release-0.3.1. Three bundled phases:

06a — MarkdownViewer extension:
- New module storytelling-primitives.ts exports extractKind +
  isFencedBlockLanguage + parsers for 5 fenced-block grammars
  (timeline / stats / risk-table / compare / slate). Every parser
  returns a discriminated union so callers can fall back to a
  labeled error box on unparseable YAML.
- New module blocks.tsx exports FencedBlockRenderer that dispatches
  each language to its specialized spatial primitive.
- New module primitives.tsx exports 7 spatial primitive components
  (TLDRSlate, DotTimeline, StatCardBand, RiskTableGrid, CompareTable,
  ActionChecklist, SummaryStrip). Honors OpenRig vellum tokens;
  square corners except the 12px status dots.
- New module kind-frame.tsx exports KindFrame — shared chrome
  (kind badge + title + meta row + optional TL;DR slate + optional
  summary strip for feature-shipped).
- MarkdownViewer.tsx wraps the body in KindFrame when frontmatter
  declares a known kind; intercepts the 5 fenced-block languages
  inside BlockRenderer. Fence-language regex extended to accept
  hyphens so risk-table parses cleanly. Unknown kinds + plain
  markdown continue to render exactly as before.
- yaml ^2.8.3 added to packages/ui/package.json (already hoisted at
  workspace root via daemon + cli).

06b — TimelineTab refactor:
- StoryTab.tsx renamed to TimelineTab.tsx via git mv. Exported
  symbol renamed; ScopePages.tsx + 3 test files updated. Internal
  data-testid prefixes (story-row-*, story-step-*, story-tab)
  preserved to keep the existing test surface stable.
- New timelineMarkdown?: string prop renders above the event feed
  via MarkdownViewer when present; the curated narrative augments,
  never replaces, the auto-captured event trail.
- Each step dot now carries data-dot-status + a color class
  derived from statusFromEventKind(event.kind) — success green,
  warning amber, danger red, info blue, muted stone.
- Empty state now points operators at <slice-dir>/timeline.md +
  the incident-timeline kind + the timeline fenced block.

06c — For You card primitives:
- New module feed/cards/storytelling-cards.tsx exports CardShell +
  5 card types (Shipped, Incident, Progress, Approval-needed,
  Concept) + StorytellingFeed composition.
- Mobile-first interaction levels: collapsed (min-h-[80px]) → tap
  to inline-expand → tap drill-in CTA (min-h-[44px]) to navigate.
- IncidentCard surfaces a status-color dot on the collapsed view
  via the shared CardShell.leadingAccessory slot. ApprovalCard
  surfaces approve/deny inline actions on the collapsed view via
  CardShell.inlineActions.
- Drill-in routes: shipped/incident/concept → /project/slice/<id>;
  progress → /project/mission/<id>; approval → source-provided
  href.

Tests (44 new):
- packages/ui/test/storytelling-primitives.test.ts — 19 pure-logic
  tests covering kind extraction + all 5 fenced-block parsers +
  graceful failure modes.
- packages/ui/test/markdown-viewer-storytelling.test.tsx — 12
  integration tests covering HG-1 (kind dispatch), HG-2 (fenced
  blocks parse + render), HG-3 (plain markdown unchanged).
- packages/ui/test/storytelling-cards.test.tsx — 13 tests covering
  HG-6 (all 5 cards), HG-7 (drill-in routing), HG-8 (≥44px touch
  targets + 80px collapsed height).

Verification (HG-1 through HG-13):
- npx tsc --noEmit on packages/ui/tsconfig.json: CLEAN
- npx vitest run packages/ui suite: 835 PASS / 1 skipped / 0
  failures. No regressions in existing markdown-viewer, slice-tabs,
  p5-slice-scope, polish-8-demo-readiness tests.
- SC-29 daemon byte-equivalent boundary: untouched. UI-only slice;
  no daemon source changes, no SQL migrations, no new endpoints,
  no new event types. git diff packages/daemon/ = empty.

Substrate slice artifacts at <work-tree>/missions/release-0.3.1/slices/06-storytelling-primitives/:
- ack.md (structured ACK with permission preflight + 4 DRIFT
  entries + HG-1 through HG-13 verification matrix)
- walk.md (founder-walk + design-reviewer packet with 9 live
  verification hooks)
- MARKDOWN-CONVENTIONS.md (operator-facing reference for the kind
  dispatcher + 5 fenced-block grammars)

Refs:
- IMPLEMENTATION-PRD.md (advisor-authored; 2026-05-10)
- research/thariq-ux-vocabulary-2026-05-10.md (UX vocabulary
  research; 4707 words)
- Demo timeline.md for plugin-primitive mission authored separately
  by advisor-lead per IMPL-PRD §9; renderer ready to consume it.

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:40:46 -07:00
1 parent a2d45687fa
commit e8e3f5b4f3
15 files changed
+1670 -33

No files matched your search

+2 -1
View File
@@ -32,7 +32,8 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^3.4.19"
"tailwindcss": "^3.4.19",
"yaml": "^2.8.3"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.0",
@@ -0,0 +1,398 @@
// 0.3.1 slice 06 — For You storytelling card primitives.
//
// Mobile-first cards for the For You feed: collapsed (~80px) → tap
// for inline-expanded preview → tap "Open" for drill-in to the
// existing center route. Each card type carries its own data shape
// + accent color but shares the CardShell chrome so the feed reads
// as a rhythm of comparable tiles.
//
// Tap target = entire card surface; all actionable elements ≥44px
// touch target per IMPL-PRD §6 + HG-8.
import { useState, type ReactNode } from "react";
// -----------------------------------------------------------------------------
// CardShell — shared chrome (header, accent stripe, collapse toggle)
// -----------------------------------------------------------------------------
export interface CardShellProps {
testId: string;
kind: CardKind;
title: string;
oneLiner: string;
accent: CardAccent;
expanded: ReactNode;
drillInHref?: string;
drillInLabel?: string;
/** Inline action row rendered at the top-right of the collapsed
* view (e.g. Approve / Deny on the Approval-needed card). */
inlineActions?: ReactNode;
/** Optional accessory rendered to the left of the title — currently
* used by IncidentCard to show a status dot on the collapsed view. */
leadingAccessory?: ReactNode;
}
export type CardKind = "shipped" | "incident" | "progress" | "approval" | "concept";
interface CardAccent {
stripe: string;
pill: string;
ink: string;
label: string;
}
export const ACCENTS: Record<CardKind, CardAccent> = {
shipped: { stripe: "border-l-emerald-600", pill: "bg-emerald-50 border-emerald-300", ink: "text-emerald-800", label: "SHIPPED" },
incident: { stripe: "border-l-red-600", pill: "bg-red-50 border-red-300", ink: "text-red-800", label: "INCIDENT" },
progress: { stripe: "border-l-sky-600", pill: "bg-sky-50 border-sky-300", ink: "text-sky-800", label: "PROGRESS" },
approval: { stripe: "border-l-amber-600", pill: "bg-amber-50 border-amber-300", ink: "text-amber-800", label: "APPROVAL NEEDED" },
concept: { stripe: "border-l-violet-600", pill: "bg-violet-50 border-violet-300", ink: "text-violet-800", label: "CONCEPT" },
};
export function CardShell({ testId, kind, title, oneLiner, accent, expanded, drillInHref, drillInLabel, inlineActions, leadingAccessory }: CardShellProps) {
const [open, setOpen] = useState(false);
return (
<article
data-testid={testId}
data-card-kind={kind}
data-expanded={open}
className={`border border-outline-variant border-l-4 ${accent.stripe} bg-white/85 hard-shadow`}
>
<div
role="button"
tabIndex={0}
data-testid={`${testId}-toggle`}
onClick={() => setOpen((o) => !o)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((o) => !o); } }}
className="flex w-full min-h-[80px] cursor-pointer items-start gap-3 px-3 py-3 text-left"
aria-expanded={open}
>
<div className="flex-1 min-w-0">
<div
data-testid={`${testId}-pill`}
className={`inline-block border px-2 py-0.5 font-mono text-[8px] uppercase tracking-[0.18em] ${accent.pill} ${accent.ink}`}
>
{accent.label}
</div>
<h3 data-testid={`${testId}-title`} className="mt-1 flex items-center gap-2 text-[13px] font-bold text-stone-900">
{leadingAccessory}
<span className="truncate">{title}</span>
</h3>
<p data-testid={`${testId}-one-liner`} className="mt-0.5 text-[11px] leading-relaxed text-stone-700 line-clamp-2">
{oneLiner}
</p>
</div>
{inlineActions && (
<div data-testid={`${testId}-inline-actions`} className="flex shrink-0 items-center gap-2" onClick={(e) => e.stopPropagation()}>
{inlineActions}
</div>
)}
</div>
{open && (
<div data-testid={`${testId}-expanded`} className="border-t border-outline-variant px-3 py-3">
{expanded}
{drillInHref && (
<div className="mt-3 flex justify-end">
<a
data-testid={`${testId}-drill-in`}
href={drillInHref}
className="inline-block min-h-[44px] border border-stone-700 bg-stone-700 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.12em] text-white hover:bg-stone-800"
onClick={(e) => e.stopPropagation()}
>
{drillInLabel ?? "Open"} →
</a>
</div>
)}
</div>
)}
</article>
);
}
// -----------------------------------------------------------------------------
// ShippedCard — feature-shipped narrative
// -----------------------------------------------------------------------------
export interface ShippedCardSource {
sliceId: string;
title: string;
oneLiner: string;
/** Optional numbered sections preview (rendered in inline-expanded). */
sections?: Array<{ number: number; heading: string; summary: string }>;
}
export function ShippedCard({ source }: { source: ShippedCardSource }) {
return (
<CardShell
testId={`feed-card-shipped-${source.sliceId}`}
kind="shipped"
title={source.title}
oneLiner={source.oneLiner}
accent={ACCENTS.shipped}
drillInHref={`/project/slice/${source.sliceId}`}
expanded={
source.sections && source.sections.length > 0 ? (
<ol className="space-y-2 text-[11px]">
{source.sections.slice(0, 3).map((s) => (
<li key={s.number} data-testid={`feed-card-shipped-${source.sliceId}-section-${s.number}`} className="flex gap-2">
<span className="font-mono text-[10px] font-bold text-stone-500">{s.number}.</span>
<div>
<div className="font-semibold text-stone-900">{s.heading}</div>
<div className="text-stone-700">{s.summary}</div>
</div>
</li>
))}
</ol>
) : (
<div className="text-[11px] text-stone-600">{source.oneLiner}</div>
)
}
/>
);
}
// -----------------------------------------------------------------------------
// IncidentCard — incident-timeline summary
// -----------------------------------------------------------------------------
export interface IncidentCardSource {
sliceId: string;
title: string;
oneLiner: string;
status: "success" | "warning" | "danger" | "info" | "muted";
/** Up to 3 timeline entries to preview in inline-expanded. */
recentEntries?: Array<{ time: string; title: string; status: "success" | "warning" | "danger" | "info" | "muted" }>;
}
const STATUS_DOT: Record<IncidentCardSource["status"], string> = {
success: "bg-emerald-500",
warning: "bg-amber-500",
danger: "bg-red-500",
info: "bg-sky-500",
muted: "bg-stone-400",
};
export function IncidentCard({ source }: { source: IncidentCardSource }) {
return (
<CardShell
testId={`feed-card-incident-${source.sliceId}`}
kind="incident"
title={source.title}
oneLiner={source.oneLiner}
accent={ACCENTS.incident}
drillInHref={`/project/slice/${source.sliceId}`}
drillInLabel="Open timeline"
leadingAccessory={
<span
data-testid={`feed-card-incident-${source.sliceId}-dot`}
className={`inline-block h-3 w-3 rounded-full shrink-0 ${STATUS_DOT[source.status]}`}
aria-hidden="true"
/>
}
expanded={
source.recentEntries && source.recentEntries.length > 0 ? (
<ul className="space-y-2 text-[11px]">
{source.recentEntries.slice(0, 3).map((e, i) => (
<li key={i} data-testid={`feed-card-incident-${source.sliceId}-entry-${i}`} className="flex items-baseline gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${STATUS_DOT[e.status]} shrink-0`} aria-hidden="true" />
<span className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500 shrink-0">{e.time}</span>
<span className="text-stone-900">{e.title}</span>
</li>
))}
</ul>
) : (
<div className="text-[11px] text-stone-600">No recent entries.</div>
)
}
/>
);
}
// -----------------------------------------------------------------------------
// ProgressCard — mission/slice progress
// -----------------------------------------------------------------------------
export interface ProgressCardSource {
missionId: string;
title: string;
oneLiner: string;
/** 0..100 */
percent: number;
/** What's the active sub-slice doing right now */
activeSlice?: { id: string; label: string; status: string };
}
export function ProgressCard({ source }: { source: ProgressCardSource }) {
const pct = Math.max(0, Math.min(100, Math.round(source.percent)));
return (
<CardShell
testId={`feed-card-progress-${source.missionId}`}
kind="progress"
title={source.title}
oneLiner={source.oneLiner}
accent={ACCENTS.progress}
drillInHref={`/project/mission/${source.missionId}`}
drillInLabel="Open mission"
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">
<div className="font-mono text-[8px] uppercase tracking-[0.18em] text-stone-500">Active slice</div>
<div className="mt-1 text-[12px] font-semibold text-stone-900">{source.activeSlice.label}</div>
<div className="mt-0.5 font-mono text-[9px] text-stone-600">status: {source.activeSlice.status}</div>
</div>
) : (
<div className="text-[11px] text-stone-600">No active slice.</div>
)
}
>
</CardShell>
);
// (The progress bar lives on the collapsed view; rendered above via title.)
}
// -----------------------------------------------------------------------------
// ApprovalCard — queue items with tier: human-gate
// -----------------------------------------------------------------------------
export interface ApprovalCardSource {
qitemId: string;
title: string;
oneLiner: string;
/** Body preview shown when inline-expanded. */
bodyPreview?: string;
/** Drill-in destination (queue detail or slice). */
drillInHref?: string;
onApprove?: () => void;
onDeny?: () => void;
}
export function ApprovalCard({ source }: { source: ApprovalCardSource }) {
return (
<CardShell
testId={`feed-card-approval-${source.qitemId}`}
kind="approval"
title={source.title}
oneLiner={source.oneLiner}
accent={ACCENTS.approval}
drillInHref={source.drillInHref}
drillInLabel="Open detail"
inlineActions={
<>
{source.onApprove && (
<button
type="button"
data-testid={`feed-card-approval-${source.qitemId}-approve`}
onClick={source.onApprove}
className="min-h-[44px] min-w-[44px] border border-emerald-700 bg-emerald-600 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-white"
>
Approve
</button>
)}
{source.onDeny && (
<button
type="button"
data-testid={`feed-card-approval-${source.qitemId}-deny`}
onClick={source.onDeny}
className="min-h-[44px] min-w-[44px] border border-stone-400 bg-white px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-stone-700"
>
Deny
</button>
)}
</>
}
expanded={
source.bodyPreview ? (
<pre data-testid={`feed-card-approval-${source.qitemId}-body`} className="overflow-x-auto whitespace-pre-wrap break-words bg-stone-50 p-2 font-mono text-[10px] text-stone-800">
{source.bodyPreview}
</pre>
) : (
<div className="text-[11px] text-stone-600">No body preview.</div>
)
}
/>
);
}
// -----------------------------------------------------------------------------
// ConceptCard — kind: concept-explainer
// -----------------------------------------------------------------------------
export interface ConceptCardSource {
sliceId: string;
title: string;
oneLiner: string;
/** Optional thumbnail URL for the diagram. */
thumbnailUrl?: string;
/** Optional comparison preview (just rendered as text rows in v0). */
comparePreview?: Array<{ label: string; valueOld: string; valueNew: string }>;
}
export function ConceptCard({ source }: { source: ConceptCardSource }) {
return (
<CardShell
testId={`feed-card-concept-${source.sliceId}`}
kind="concept"
title={source.title}
oneLiner={source.oneLiner}
accent={ACCENTS.concept}
drillInHref={`/project/slice/${source.sliceId}`}
drillInLabel="Open concept"
expanded={
source.comparePreview && source.comparePreview.length > 0 ? (
<table className="w-full border-collapse text-[11px]">
<thead>
<tr className="bg-stone-50">
<th className="border border-outline-variant px-2 py-1 text-left font-mono text-[8px] uppercase tracking-[0.12em] text-stone-600"></th>
<th className="border border-outline-variant px-2 py-1 text-left font-mono text-[8px] uppercase tracking-[0.12em] text-stone-600">Old</th>
<th className="border border-outline-variant px-2 py-1 text-left font-mono text-[8px] uppercase tracking-[0.12em] text-stone-600">New</th>
</tr>
</thead>
<tbody>
{source.comparePreview.slice(0, 3).map((row, i) => (
<tr key={i}>
<td className="border border-outline-variant px-2 py-1 font-mono text-[10px] font-semibold text-stone-700">{row.label}</td>
<td className="border border-outline-variant px-2 py-1 text-stone-700">{row.valueOld}</td>
<td className="border border-outline-variant px-2 py-1 text-stone-900">{row.valueNew}</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="text-[11px] text-stone-600">{source.oneLiner}</div>
)
}
/>
);
}
// -----------------------------------------------------------------------------
// StorytellingFeed — vertical stack of cards, mobile-first
// -----------------------------------------------------------------------------
export type FeedCardItem =
| { kind: "shipped"; source: ShippedCardSource }
| { kind: "incident"; source: IncidentCardSource }
| { kind: "progress"; source: ProgressCardSource }
| { kind: "approval"; source: ApprovalCardSource }
| { kind: "concept"; source: ConceptCardSource };
export function StorytellingFeed({ items }: { items: FeedCardItem[] }) {
if (items.length === 0) {
return (
<div data-testid="storytelling-feed-empty" className="border border-dashed border-outline-variant bg-white/35 p-4 font-mono text-[10px] text-stone-500">
No items in the feed.
</div>
);
}
return (
<div data-testid="storytelling-feed" className="flex flex-col gap-4">
{items.map((item, i) => {
if (item.kind === "shipped") return <ShippedCard key={i} source={item.source} />;
if (item.kind === "incident") return <IncidentCard key={i} source={item.source} />;
if (item.kind === "progress") return <ProgressCard key={i} source={item.source} />;
if (item.kind === "approval") return <ApprovalCard key={i} source={item.source} />;
return <ConceptCard key={i} source={item.source} />;
})}
</div>
);
}
@@ -24,6 +24,9 @@
import { useMemo, useState } from "react";
import { SyntaxHighlight } from "./SyntaxHighlight.js";
import { extractKind, isFencedBlockLanguage } from "./storytelling-primitives.js";
import { FencedBlockRenderer } from "./blocks.js";
import { KindFrame } from "./kind-frame.js";
export interface MarkdownViewerProps {
content: string;
@@ -84,16 +87,36 @@ export function MarkdownViewer({ content, assetBasePath, hideFrontmatter = false
{content}
</pre>
) : (
<div className="space-y-3" data-testid="markdown-viewer-rendered">
{parsed.blocks.map((block, idx) => (
<BlockRenderer key={idx} block={block} assetBasePath={assetBasePath} />
))}
</div>
<RenderedBody parsed={parsed} assetBasePath={assetBasePath} />
)}
</article>
);
}
/** 0.3.1 slice 06 — kind-aware rendered body. When the frontmatter
* declares a known `kind:`, the body is wrapped in a KindFrame so
* the viewer presents a header chrome + optional TL;DR slate + the
* rest of the body. Unknown kind or missing frontmatter falls
* through to the plain block flow — the same rendering that's been
* in place since UI Enhancement Pack v0. Fenced-block grammars
* (timeline / stats / risk-table / compare / slate) are intercepted
* inside BlockRenderer regardless of whether a kind is set so the
* primitives are usable from any markdown surface. */
function RenderedBody({ parsed, assetBasePath }: { parsed: ParsedDocument; assetBasePath?: string }) {
const kind = extractKind(parsed.frontmatter);
const body = (
<div className="space-y-3" data-testid="markdown-viewer-rendered">
{parsed.blocks.map((block, idx) => (
<BlockRenderer key={idx} block={block} assetBasePath={assetBasePath} />
))}
</div>
);
if (kind && parsed.frontmatter) {
return <KindFrame kind={kind} frontmatter={parsed.frontmatter}>{body}</KindFrame>;
}
return body;
}
interface ParsedDocument {
frontmatter: Record<string, string> | null;
blocks: Block[];
@@ -115,8 +138,9 @@ function parseMarkdown(content: string): ParsedDocument {
while (i < lines.length) {
const line = lines[i]!;
// Fenced code block.
const fence = line.match(/^```(\w*)\s*$/);
// Fenced code block. Language matcher accepts hyphens so 0.3.1
// slice 06 fenced-block grammars like `risk-table` parse cleanly.
const fence = line.match(/^```([\w-]*)\s*$/);
if (fence) {
const language = fence[1] || null;
const isMermaid = language?.toLowerCase() === "mermaid";
@@ -246,6 +270,12 @@ function BlockRenderer({ block, assetBasePath }: { block: Block; assetBasePath?:
return <p data-testid="md-paragraph" className="text-[12px] leading-relaxed text-stone-800">{renderInline(block.text, assetBasePath)}</p>;
}
if (block.type === "code") {
// 0.3.1 slice 06 — intercept fenced-block grammars before the
// generic SyntaxHighlight fallback. Unknown languages fall
// through to SyntaxHighlight, preserving the original behavior.
if (isFencedBlockLanguage(block.language)) {
return <FencedBlockRenderer language={block.language} text={block.text} />;
}
if (block.isMermaid) {
return (
<div data-testid="md-mermaid-placeholder" className="border border-amber-300 bg-amber-50 p-3">
@@ -0,0 +1,71 @@
// 0.3.1 slice 06 — fenced-block renderers. Each block language
// (timeline / stats / risk-table / compare / slate) parses its body
// and dispatches to the corresponding spatial primitive. Parser
// failures fall back to a plain code block so the source stays
// inspectable.
import {
parseTimelineBlock,
parseStatsBlock,
parseRiskTableBlock,
parseCompareBlock,
parseSlateBlock,
type FencedBlockLanguage,
} from "./storytelling-primitives.js";
import {
TLDRSlate,
DotTimeline,
StatCardBand,
RiskTableGrid,
CompareTable,
} from "./primitives.js";
interface FencedBlockProps {
language: FencedBlockLanguage;
text: string;
}
/** Dispatch a fenced block to its specialized renderer. Returns null
* when the language is not a known fenced-block grammar (the caller
* then falls through to the regular code-block renderer). When the
* language IS known but the body fails to parse, a plain code block
* is rendered so the source stays inspectable. */
export function FencedBlockRenderer({ language, text }: FencedBlockProps): React.ReactElement | null {
if (language === "timeline") {
const r = parseTimelineBlock(text);
if (!r.ok) return <FallbackCode language={language} text={text} reason={r.reason} />;
return <DotTimeline entries={r.entries} testId={`fenced-block-${language}`} />;
}
if (language === "stats") {
const r = parseStatsBlock(text);
if (!r.ok) return <FallbackCode language={language} text={text} reason={r.reason} />;
return <StatCardBand entries={r.entries} testId={`fenced-block-${language}`} />;
}
if (language === "risk-table") {
const r = parseRiskTableBlock(text);
if (!r.ok) return <FallbackCode language={language} text={text} reason={r.reason} />;
return <RiskTableGrid entries={r.entries} testId={`fenced-block-${language}`} />;
}
if (language === "compare") {
const r = parseCompareBlock(text);
if (!r.ok) return <FallbackCode language={language} text={text} reason={r.reason} />;
return <CompareTable columns={r.columns} rows={r.rows} testId={`fenced-block-${language}`} />;
}
if (language === "slate") {
const r = parseSlateBlock(text);
if (!r.ok) return <FallbackCode language={language} text={text} reason={r.reason} />;
return <TLDRSlate testId={`fenced-block-${language}`}>{r.text}</TLDRSlate>;
}
return null;
}
function FallbackCode({ language, text, reason }: { language: string; text: string; reason: string }) {
return (
<div data-testid={`fenced-block-${language}-fallback`} className="my-3 border border-amber-300 bg-amber-50/60 p-3">
<div className="mb-2 font-mono text-[8px] uppercase tracking-[0.18em] text-amber-700">
{language} block (fallback: {reason})
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-[10px] text-stone-800">{text}</pre>
</div>
);
}
@@ -0,0 +1,70 @@
// 0.3.1 slice 06 — KindFrame.
//
// When a markdown file declares a known `kind:` in its frontmatter,
// MarkdownViewer wraps the body in a KindFrame: a slim header chrome
// (kind badge + title + meta row) plus optional TL;DR slate composed
// from a `tldr:` frontmatter field. The body itself is rendered by
// the existing block flow, with fenced-block grammars (timeline /
// stats / risk-table / compare / slate) intercepted into spatial
// primitives. The other 4 kinds beyond the 3 fully-realized layouts
// share this chrome — the visual difference is the accent color and
// the kind label.
import { TLDRSlate, SummaryStrip } from "./primitives.js";
import type { KindName } from "./storytelling-primitives.js";
interface KindFrameProps {
kind: KindName;
frontmatter: Record<string, string>;
children: React.ReactNode;
}
const KIND_ACCENTS: Record<KindName, { ink: string; pill: string; label: string }> = {
"incident-timeline": { ink: "text-red-800", pill: "bg-red-50 border-red-300", label: "INCIDENT TIMELINE" },
"progress": { ink: "text-sky-800", pill: "bg-sky-50 border-sky-300", label: "PROGRESS" },
"feature-shipped": { ink: "text-emerald-800", pill: "bg-emerald-50 border-emerald-300", label: "FEATURE SHIPPED" },
"implementation-plan": { ink: "text-violet-800", pill: "bg-violet-50 border-violet-300", label: "IMPLEMENTATION PLAN" },
"concept-explainer": { ink: "text-amber-800", pill: "bg-amber-50 border-amber-300", label: "CONCEPT EXPLAINER" },
"pr-writeup": { ink: "text-stone-800", pill: "bg-stone-50 border-stone-300", label: "PR WRITEUP" },
"post-mortem": { ink: "text-stone-900", pill: "bg-stone-100 border-stone-400", label: "POST-MORTEM" },
};
export function KindFrame({ kind, frontmatter, children }: KindFrameProps): React.ReactElement {
const accent = KIND_ACCENTS[kind];
const title = frontmatter.title;
const status = frontmatter.status;
const author = frontmatter.author;
const date = frontmatter.authored || frontmatter.date;
const tldr = frontmatter.tldr;
const summary = frontmatter.summary;
return (
<section data-testid={`kind-frame-${kind}`} data-kind={kind} className="my-2">
<header className="mb-3 border-b border-outline-variant pb-2">
<div
data-testid={`kind-badge-${kind}`}
className={`inline-block border px-2 py-0.5 font-mono text-[9px] uppercase tracking-[0.18em] ${accent.pill} ${accent.ink}`}
>
{accent.label}
</div>
{title && (
<h1 data-testid="kind-frame-title" className="mt-2 text-[16px] font-bold text-stone-900">
{title}
</h1>
)}
{(status || author || date) && (
<div data-testid="kind-frame-meta" className="mt-1 flex flex-wrap items-center gap-3 font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500">
{status && <span data-testid="kind-frame-meta-status">status: {status}</span>}
{author && <span data-testid="kind-frame-meta-author">{author}</span>}
{date && <span data-testid="kind-frame-meta-date">{date}</span>}
</div>
)}
</header>
{tldr && <TLDRSlate testId="kind-frame-tldr">{tldr}</TLDRSlate>}
{kind === "feature-shipped" && summary && (
<SummaryStrip label="SHIPPED" body={summary} testId="kind-frame-summary" />
)}
<div data-testid="kind-frame-body">{children}</div>
</section>
);
}
@@ -0,0 +1,224 @@
// 0.3.1 slice 06 — spatial primitives used by kind layouts + fenced
// block renderers. Honors OpenRig vellum tokens (no Thariq palette
// literals); square corners except the 12px status dots; tactical
// drafting aesthetic per project_v1_professional_grade_ship_gate.
import type {
TimelineEntry,
TimelineStatus,
StatsEntry,
RiskTableEntry,
RiskLevel,
CompareRow,
} from "./storytelling-primitives.js";
// -----------------------------------------------------------------------------
// Status → token mapping (shared across primitives)
// -----------------------------------------------------------------------------
const STATUS_TOKENS: Record<TimelineStatus, { dot: string; ink: string; label: string }> = {
success: { dot: "bg-emerald-600", ink: "text-emerald-800", label: "SUCCESS" },
warning: { dot: "bg-amber-500", ink: "text-amber-800", label: "WARNING" },
danger: { dot: "bg-red-600", ink: "text-red-800", label: "DANGER" },
info: { dot: "bg-sky-600", ink: "text-sky-800", label: "INFO" },
muted: { dot: "bg-stone-500", ink: "text-stone-700", label: "MUTED" },
};
const RISK_TOKENS: Record<RiskLevel, { ink: string; label: string }> = {
low: { ink: "text-emerald-800", label: "LOW" },
med: { ink: "text-amber-800", label: "MED" },
high: { ink: "text-red-800", label: "HIGH" },
};
// -----------------------------------------------------------------------------
// TLDRSlate — dark vellum slab; sits at top of a kind layout
// -----------------------------------------------------------------------------
export function TLDRSlate({ children, testId = "primitive-tldr-slate" }: { children: React.ReactNode; testId?: string }) {
return (
<div
data-testid={testId}
className="my-4 border border-stone-900 bg-stone-900 px-4 py-3 text-[12px] leading-relaxed text-stone-50 hard-shadow"
>
<div className="mb-1 font-mono text-[8px] uppercase tracking-[0.18em] text-stone-400">TL;DR</div>
<div>{children}</div>
</div>
);
}
// -----------------------------------------------------------------------------
// DotTimeline — vertical dot+line track for ordered events
// -----------------------------------------------------------------------------
export function DotTimeline({ entries, testId = "primitive-dot-timeline" }: { entries: TimelineEntry[]; testId?: string }) {
if (entries.length === 0) return null;
return (
<ol data-testid={testId} className="my-4 list-none space-y-0 border-l border-outline-variant pl-0">
{entries.map((e, i) => {
const tokens = STATUS_TOKENS[e.status];
const isLast = i === entries.length - 1;
return (
<li key={i} className="relative pl-9 pb-4" data-testid={`primitive-dot-timeline-entry-${i}`}>
{!isLast && (
<div className="absolute left-[13px] top-6 bottom-0 w-px bg-outline-variant" aria-hidden="true" />
)}
<div
className={`absolute left-[7px] top-2 h-3 w-3 rounded-full border border-outline-variant ${tokens.dot}`}
data-status={e.status}
aria-hidden="true"
/>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0">
<span className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500">{e.time}</span>
<span className={`font-mono text-[8px] uppercase tracking-[0.12em] ${tokens.ink}`}>{tokens.label}</span>
</div>
<div className="mt-1 text-[12px] font-semibold text-stone-900">{e.title}</div>
{e.body && <div className="mt-1 text-[11px] leading-relaxed text-stone-700">{e.body}</div>}
</li>
);
})}
</ol>
);
}
// -----------------------------------------------------------------------------
// StatCardBand — horizontal row of label/value/trend cards
// -----------------------------------------------------------------------------
const TREND_GLYPH = { up: "↑", flat: "—", down: "↓" } as const;
const TREND_INK = { up: "text-emerald-700", flat: "text-stone-500", down: "text-red-700" } as const;
export function StatCardBand({ entries, testId = "primitive-stat-card-band" }: { entries: StatsEntry[]; testId?: string }) {
if (entries.length === 0) return null;
return (
<div data-testid={testId} className="my-4 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{entries.map((e, i) => (
<div
key={i}
data-testid={`primitive-stat-card-${i}`}
className="border border-outline-variant bg-white/45 px-3 py-2 hard-shadow"
>
<div className="font-mono text-[8px] uppercase tracking-[0.18em] text-stone-500">{e.label}</div>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-[18px] font-bold text-stone-900">{e.value}</span>
{e.trend && (
<span className={`font-mono text-[10px] ${TREND_INK[e.trend]}`} data-trend={e.trend}>
{TREND_GLYPH[e.trend]}
</span>
)}
</div>
</div>
))}
</div>
);
}
// -----------------------------------------------------------------------------
// RiskTableGrid — risk × probability × impact × mitigation grid
// -----------------------------------------------------------------------------
export function RiskTableGrid({ entries, testId = "primitive-risk-table-grid" }: { entries: RiskTableEntry[]; testId?: string }) {
if (entries.length === 0) return null;
return (
<div data-testid={testId} className="my-4 overflow-x-auto">
<table className="w-full border-collapse border border-outline-variant text-[11px]">
<thead>
<tr className="bg-stone-50">
<th className="border border-outline-variant px-2 py-1 text-left font-mono text-[9px] uppercase tracking-[0.12em] text-stone-600">Risk</th>
<th className="border border-outline-variant px-2 py-1 text-left font-mono text-[9px] uppercase tracking-[0.12em] text-stone-600">Prob</th>
<th className="border border-outline-variant px-2 py-1 text-left font-mono text-[9px] uppercase tracking-[0.12em] text-stone-600">Impact</th>
<th className="border border-outline-variant px-2 py-1 text-left font-mono text-[9px] uppercase tracking-[0.12em] text-stone-600">Mitigation</th>
</tr>
</thead>
<tbody>
{entries.map((e, i) => {
const prob = RISK_TOKENS[e.probability];
const imp = RISK_TOKENS[e.impact];
return (
<tr key={i} data-testid={`primitive-risk-row-${i}`}>
<td className="border border-outline-variant px-2 py-1 text-stone-900">{e.risk}</td>
<td className={`border border-outline-variant px-2 py-1 font-mono text-[9px] uppercase tracking-[0.12em] ${prob.ink}`}>{prob.label}</td>
<td className={`border border-outline-variant px-2 py-1 font-mono text-[9px] uppercase tracking-[0.12em] ${imp.ink}`}>{imp.label}</td>
<td className="border border-outline-variant px-2 py-1 text-stone-700">{e.mitigation}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// -----------------------------------------------------------------------------
// CompareTable — n-column comparison table
// -----------------------------------------------------------------------------
export function CompareTable({ columns, rows, testId = "primitive-compare-table" }: { columns: string[]; rows: CompareRow[]; testId?: string }) {
if (columns.length === 0) return null;
return (
<div data-testid={testId} className="my-4 overflow-x-auto">
<table className="w-full border-collapse border border-outline-variant text-[11px]">
<thead>
<tr className="bg-stone-50">
<th className="border border-outline-variant px-2 py-1 text-left font-mono text-[9px] uppercase tracking-[0.12em] text-stone-600"></th>
{columns.map((c, i) => (
<th key={i} className="border border-outline-variant px-2 py-1 text-left font-mono text-[9px] uppercase tracking-[0.12em] text-stone-600">{c}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} data-testid={`primitive-compare-row-${i}`}>
<td className="border border-outline-variant px-2 py-1 font-mono text-[10px] font-semibold text-stone-700">{row.label}</td>
{columns.map((_c, j) => (
<td key={j} className="border border-outline-variant px-2 py-1 text-stone-800">{row.values[j] ?? ""}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
// -----------------------------------------------------------------------------
// ActionChecklist — list of [done?] tasks
// -----------------------------------------------------------------------------
export function ActionChecklist({ items, testId = "primitive-action-checklist" }: { items: Array<{ done: boolean; text: string }>; testId?: string }) {
if (items.length === 0) return null;
return (
<ul data-testid={testId} className="my-4 list-none space-y-1 pl-0">
{items.map((item, i) => (
<li
key={i}
data-testid={`primitive-action-checklist-item-${i}`}
className="flex items-baseline gap-2 text-[12px]"
>
<span
data-done={item.done}
className={`mt-0.5 inline-block h-3 w-3 shrink-0 border ${item.done ? "border-emerald-700 bg-emerald-600" : "border-outline-variant bg-white"}`}
aria-hidden="true"
/>
<span className={item.done ? "text-stone-500 line-through" : "text-stone-900"}>{item.text}</span>
</li>
))}
</ul>
);
}
// -----------------------------------------------------------------------------
// SummaryStrip — single-line summary row at top of a feature-shipped layout
// -----------------------------------------------------------------------------
export function SummaryStrip({
label,
body,
testId = "primitive-summary-strip",
}: { label: string; body: string; testId?: string }) {
return (
<div data-testid={testId} className="my-3 border-l-4 border-emerald-600 bg-emerald-50/60 px-3 py-2">
<div className="font-mono text-[8px] uppercase tracking-[0.18em] text-emerald-800">{label}</div>
<div className="mt-1 text-[12px] text-stone-900">{body}</div>
</div>
);
}
@@ -0,0 +1,226 @@
// 0.3.1 slice 06 — storytelling-primitives foundation.
//
// Pure-logic helpers used by the MarkdownViewer kind-dispatcher and the
// fenced-block renderers. Each parser returns a discriminated union
// ({ ok: true, ... } | { ok: false, reason: string }) so the caller can
// fall back to a plain code block when a body fails to parse. Graceful
// degradation is load-bearing — a markdown file with full convention
// must still read cleanly when the renderer doesn't recognize a kind
// or a fenced-block language.
import { parse as parseYaml } from "yaml";
// -----------------------------------------------------------------------------
// Kind dispatch
// -----------------------------------------------------------------------------
export const KNOWN_KINDS = [
"incident-timeline",
"progress",
"feature-shipped",
"implementation-plan",
"concept-explainer",
"pr-writeup",
"post-mortem",
] as const;
export type KindName = typeof KNOWN_KINDS[number];
const KIND_SET = new Set<string>(KNOWN_KINDS);
/** Pull a known `kind:` value out of the frontmatter. Returns null when
* the frontmatter is absent, the field is missing, or the value is
* not in the curated set — the viewer then falls back to plain
* markdown rendering. */
export function extractKind(frontmatter: Record<string, string> | null): KindName | null {
if (!frontmatter) return null;
const raw = frontmatter["kind"];
if (!raw) return null;
return KIND_SET.has(raw) ? (raw as KindName) : null;
}
// -----------------------------------------------------------------------------
// Fenced-block grammars
// -----------------------------------------------------------------------------
export const FENCED_BLOCK_LANGUAGES = ["timeline", "stats", "risk-table", "compare", "slate"] as const;
export type FencedBlockLanguage = typeof FENCED_BLOCK_LANGUAGES[number];
const FENCED_BLOCK_SET = new Set<string>(FENCED_BLOCK_LANGUAGES);
export function isFencedBlockLanguage(language: string | null | undefined): language is FencedBlockLanguage {
return typeof language === "string" && FENCED_BLOCK_SET.has(language);
}
// --- timeline -----
export type TimelineStatus = "success" | "warning" | "danger" | "info" | "muted";
const TIMELINE_STATUSES = new Set<TimelineStatus>(["success", "warning", "danger", "info", "muted"]);
export interface TimelineEntry {
time: string;
status: TimelineStatus;
title: string;
body: string;
}
export type TimelineParseResult =
| { ok: true; entries: TimelineEntry[] }
| { ok: false; reason: string };
export function parseTimelineBlock(body: string): TimelineParseResult {
let parsed: unknown;
try { parsed = parseYaml(body); } catch (err) {
return { ok: false, reason: `YAML parse error: ${err instanceof Error ? err.message : String(err)}` };
}
if (!Array.isArray(parsed)) {
return { ok: false, reason: "expected a YAML list of entries" };
}
const entries: TimelineEntry[] = [];
for (const item of parsed) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const r = item as Record<string, unknown>;
entries.push({
time: stringField(r.time),
status: TIMELINE_STATUSES.has(r.status as TimelineStatus) ? (r.status as TimelineStatus) : "info",
title: stringField(r.title),
body: stringField(r.body),
});
}
return { ok: true, entries };
}
// --- stats -----
export type StatsTrend = "up" | "flat" | "down";
const STATS_TRENDS = new Set<StatsTrend>(["up", "flat", "down"]);
export interface StatsEntry {
label: string;
value: string;
trend?: StatsTrend;
}
export type StatsParseResult =
| { ok: true; entries: StatsEntry[] }
| { ok: false; reason: string };
export function parseStatsBlock(body: string): StatsParseResult {
let parsed: unknown;
try { parsed = parseYaml(body); } catch (err) {
return { ok: false, reason: `YAML parse error: ${err instanceof Error ? err.message : String(err)}` };
}
if (!Array.isArray(parsed)) {
return { ok: false, reason: "expected a YAML list of entries" };
}
const entries: StatsEntry[] = [];
for (const item of parsed) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const r = item as Record<string, unknown>;
const trendRaw = r.trend as StatsTrend | undefined;
entries.push({
label: stringField(r.label),
value: stringField(r.value),
...(STATS_TRENDS.has(trendRaw as StatsTrend) ? { trend: trendRaw as StatsTrend } : {}),
});
}
return { ok: true, entries };
}
// --- risk-table -----
export type RiskLevel = "low" | "med" | "high";
const RISK_LEVELS = new Set<RiskLevel>(["low", "med", "high"]);
export interface RiskTableEntry {
risk: string;
probability: RiskLevel;
impact: RiskLevel;
mitigation: string;
}
export type RiskTableParseResult =
| { ok: true; entries: RiskTableEntry[] }
| { ok: false; reason: string };
export function parseRiskTableBlock(body: string): RiskTableParseResult {
let parsed: unknown;
try { parsed = parseYaml(body); } catch (err) {
return { ok: false, reason: `YAML parse error: ${err instanceof Error ? err.message : String(err)}` };
}
if (!Array.isArray(parsed)) {
return { ok: false, reason: "expected a YAML list of entries" };
}
const entries: RiskTableEntry[] = [];
for (const item of parsed) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const r = item as Record<string, unknown>;
entries.push({
risk: stringField(r.risk),
probability: RISK_LEVELS.has(r.probability as RiskLevel) ? (r.probability as RiskLevel) : "med",
impact: RISK_LEVELS.has(r.impact as RiskLevel) ? (r.impact as RiskLevel) : "med",
mitigation: stringField(r.mitigation),
});
}
return { ok: true, entries };
}
// --- compare -----
export interface CompareRow {
label: string;
values: string[];
}
export type CompareParseResult =
| { ok: true; columns: string[]; rows: CompareRow[] }
| { ok: false; reason: string };
export function parseCompareBlock(body: string): CompareParseResult {
let parsed: unknown;
try { parsed = parseYaml(body); } catch (err) {
return { ok: false, reason: `YAML parse error: ${err instanceof Error ? err.message : String(err)}` };
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, reason: "expected a YAML mapping with columns + rows" };
}
const r = parsed as Record<string, unknown>;
if (!Array.isArray(r.columns)) {
return { ok: false, reason: "missing columns array" };
}
const columns = (r.columns as unknown[]).map(stringField);
const rowsRaw = Array.isArray(r.rows) ? (r.rows as unknown[]) : [];
const rows: CompareRow[] = [];
for (const row of rowsRaw) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const rr = row as Record<string, unknown>;
rows.push({
label: stringField(rr.label),
values: Array.isArray(rr.values) ? (rr.values as unknown[]).map(stringField) : [],
});
}
return { ok: true, columns, rows };
}
// --- slate -----
export type SlateParseResult =
| { ok: true; text: string }
| { ok: false; reason: string };
export function parseSlateBlock(body: string): SlateParseResult {
const trimmed = body.trim();
if (trimmed.length === 0) return { ok: false, reason: "empty slate body" };
return { ok: true, text: trimmed };
}
// -----------------------------------------------------------------------------
// Internals
// -----------------------------------------------------------------------------
function stringField(v: unknown): string {
if (v == null) return "";
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return "";
}
@@ -6,7 +6,7 @@
//
// V1 attempt-3 Phase 5 P5-2: SliceScopePage tab content piping.
// Per code-map AFTER tree fold mapping from Phase 5 dispatch:
// - StoryTab → story tab (preserved; events + phaseDefinitions props)
// - TimelineTab → story tab (preserved; events + phaseDefinitions props)
// - TestsVerificationTab → tests tab (preserved; tests prop)
// - TopologyTab → topology tab (preserved; topology prop)
// - AcceptanceTab → progress tab (FOLDED; canon-7 progress is acceptance + currentStep)
@@ -39,7 +39,7 @@ import {
projectSliceMeta,
type ProjectMissionGroup,
} from "../../lib/project-mission-state.js";
import { StoryTab } from "../slices/tabs/StoryTab.js";
import { TimelineTab } from "../slices/tabs/TimelineTab.js";
import { AcceptanceTab } from "../slices/tabs/AcceptanceTab.js";
import { DocsTab } from "../slices/tabs/DocsTab.js";
import { DecisionsTab } from "../slices/tabs/DecisionsTab.js";
@@ -386,7 +386,7 @@ function ScopeStoryRollup({
}
return (
<div data-testid="scope-story-rollup">
<StoryTab events={events} phaseDefinitions={null} queueItemsById={queueItemsById} />
<TimelineTab events={events} phaseDefinitions={null} queueItemsById={queueItemsById} />
</div>
);
}
@@ -1130,7 +1130,7 @@ export function SliceScopePage() {
onSelect={(id) => setActive(id as SliceTab)}
>
{active === "story" ? (
<StoryTab
<TimelineTab
events={storyEventsForDetail(detail)}
phaseDefinitions={detail.story.phaseDefinitions}
queueItemsById={queueItemsById}
@@ -1,8 +1,17 @@
// Slice Story View v1.
// 0.3.1 slice 06 — TimelineTab (rename + augmentation of the prior
// StoryTab). Shows the slice lifecycle as a newest-first connected
// step tree. The dot column is colored per derived event status so
// the thread reads at a glance. When the parent passes a
// `timelineMarkdown` blob (typically loaded from `<slice-dir>/timeline.md`
// via /api/files/read), it renders above the event feed via
// MarkdownViewer so the curated narrative augments — never replaces —
// the auto-captured event trail.
//
// Shows the slice lifecycle as a newest-first connected step tree. Each step
// keeps the spec-defined phase chip and expandable detail from the prior flat
// timeline, while adding visible flow cues for event-to-event handoff reading.
// Internal data-testid prefixes (`story-row-*`, `story-step-*`,
// `story-tab`, etc.) are preserved to keep the existing test surface
// backward-compatible; the exported symbol + filename + dot accents
// + optional markdown header are what changed at the slice-06
// boundary.
import { useMemo, useState } from "react";
import type {
@@ -20,6 +29,29 @@ import {
eventToken,
type ProjectToken,
} from "../../project/ProjectMetaPrimitives.js";
import { MarkdownViewer } from "../../markdown/MarkdownViewer.js";
/** Derive a visual status from an event kind so the dot column reads
* at a glance. Conservative mapping: only kinds that clearly signal
* success/failure/warning get a strong color; everything else stays
* neutral info-blue or muted. */
export type TimelineDotStatus = "success" | "warning" | "danger" | "info" | "muted";
export function statusFromEventKind(kind: string): TimelineDotStatus {
const k = kind.toLowerCase();
if (k.includes("complete") || k.includes("shipped") || k.includes("handed_off") || k.includes("merged") || k.includes("done")) return "success";
if (k.includes("fail") || k.includes("error") || k.includes("blocked") || k.includes("rejected")) return "danger";
if (k.includes("warn") || k.includes("attention") || k.includes("flagged")) return "warning";
if (k.includes("created") || k.includes("started") || k.includes("in_progress") || k.includes("in-progress") || k.includes("transition") || k.includes("edited") || k.includes("updated")) return "info";
return "muted";
}
const DOT_TOKENS: Record<TimelineDotStatus, string> = {
success: "bg-emerald-500",
warning: "bg-amber-500",
danger: "bg-red-500",
info: "bg-sky-500",
muted: "bg-stone-300",
};
const PHASE_PALETTE: string[] = [
"bg-amber-100 text-amber-800 border-amber-300",
@@ -34,14 +66,19 @@ const PHASE_PALETTE: string[] = [
const UNTAGGED_CLASS = "bg-stone-100 text-stone-700 border-stone-300";
const PAGE_SIZE = 12;
export function StoryTab({
export function TimelineTab({
events,
phaseDefinitions,
queueItemsById,
timelineMarkdown,
}: {
events: StoryEvent[];
phaseDefinitions: PhaseDefinition[] | null;
queueItemsById?: Map<string, QueueItemDetail>;
/** Optional `<slice-dir>/timeline.md` content; rendered above the
* event feed when present. Parent owns the fetch via
* /api/files/read. */
timelineMarkdown?: string;
}) {
const [page, setPage] = useState(0);
const phaseMeta = useMemo(() => {
@@ -66,12 +103,35 @@ export function StoryTab({
const safePage = Math.min(page, totalPages - 1);
const pagedEvents = sortedEvents.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE);
if (events.length === 0) {
return <div className="p-4 font-mono text-[10px] text-stone-400" data-testid="story-empty">No events captured for this slice yet.</div>;
const hasMarkdown = typeof timelineMarkdown === "string" && timelineMarkdown.trim().length > 0;
const hasEvents = events.length > 0;
if (!hasEvents && !hasMarkdown) {
return (
<div
data-testid="story-empty"
className="border border-dashed border-outline-variant bg-white/35 p-4 font-mono text-[10px] leading-relaxed text-stone-500"
>
<div className="mb-1 uppercase tracking-[0.12em] text-stone-400">No timeline yet</div>
<div className="text-stone-700">
Author one at <span className="font-mono text-stone-900">&lt;slice-dir&gt;/timeline.md</span> with frontmatter
<span className="font-mono text-stone-900"> kind: incident-timeline</span> and a <span className="font-mono text-stone-900">```timeline```</span> fenced block.
</div>
</div>
);
}
return (
<div data-testid="story-tab" className="space-y-0">
<div data-testid="story-tab" className="space-y-3">
{hasMarkdown && (
<div
data-testid="story-timeline-markdown"
className="border border-outline-variant bg-white/35 p-3 backdrop-blur-sm"
>
<MarkdownViewer content={timelineMarkdown!} hideFrontmatter hideRawToggle />
</div>
)}
{hasEvents && (
<div data-testid="story-step-tree" data-order="newest-first" className="relative border border-outline-variant bg-white/35 p-3 backdrop-blur-sm">
{pagedEvents.map((event, idx) => (
<StoryStepCard
@@ -83,6 +143,7 @@ export function StoryTab({
/>
))}
</div>
)}
{totalPages > 1 ? (
<div className="mt-3 flex items-center justify-between border border-outline-variant bg-white/35 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.12em] text-stone-600">
<button
@@ -227,7 +288,8 @@ function StoryStepCard({
)}
<div
data-testid={`story-step-dot-${event.kind}`}
className="absolute left-[5px] top-3.5 flex h-5 w-5 items-center justify-center border border-outline-variant bg-white text-stone-600 shadow-[1px_1px_0_rgba(46,52,46,0.12)]"
data-dot-status={statusFromEventKind(event.kind)}
className={`absolute left-[5px] top-3.5 flex h-5 w-5 items-center justify-center border border-outline-variant text-white shadow-[1px_1px_0_rgba(46,52,46,0.12)] ${DOT_TOKENS[statusFromEventKind(event.kind)]}`}
>
{StepIcon ? <StepIcon className="h-3 w-3" strokeWidth={1.7} /> : null}
</div>
@@ -0,0 +1,171 @@
// 0.3.1 slice 06 — MarkdownViewer storytelling integration: kind
// dispatcher + fenced-block grammars wired through the existing
// rendered-mode flow. Verifies the HG-1, HG-2, HG-3 acceptance gates
// from IMPLEMENTATION-PRD §10.
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { MarkdownViewer } from "../src/components/markdown/MarkdownViewer.js";
afterEach(cleanup);
describe("HG-1 — kind dispatcher", () => {
it("wraps the body in a KindFrame when frontmatter declares a known kind", () => {
const content = `---
kind: incident-timeline
title: Plugin primitive narrative
status: in-flight
---
# Background
Paragraph here.`;
const { container } = render(<MarkdownViewer content={content} hideFrontmatter />);
expect(container.querySelector("[data-testid='kind-frame-incident-timeline']")).toBeTruthy();
expect(container.querySelector("[data-testid='kind-badge-incident-timeline']")?.textContent).toContain("INCIDENT TIMELINE");
expect(container.querySelector("[data-testid='kind-frame-title']")?.textContent).toContain("Plugin primitive narrative");
expect(container.querySelector("[data-testid='kind-frame-meta-status']")?.textContent).toContain("in-flight");
// Body still rendered inside the frame.
expect(container.querySelector("[data-testid='md-heading-1']")?.textContent).toContain("Background");
});
it("renders a feature-shipped kind with its summary strip when frontmatter.summary is present", () => {
const content = `---
kind: feature-shipped
title: Plugin primitive shipped
summary: Plugin support landed in 0.3.1 with G-Stack and Obra Superpowers packs.
---
Body content.`;
const { container } = render(<MarkdownViewer content={content} hideFrontmatter />);
expect(container.querySelector("[data-testid='kind-frame-feature-shipped']")).toBeTruthy();
expect(container.querySelector("[data-testid='kind-frame-summary']")?.textContent).toContain("Plugin support landed");
});
it("falls through to plain markdown when kind is unknown (graceful degradation)", () => {
const content = `---
kind: not-a-real-kind
title: Just text
---
Body content.`;
const { container } = render(<MarkdownViewer content={content} hideFrontmatter />);
expect(container.querySelector("[data-testid^='kind-frame']")).toBeNull();
expect(container.querySelector("[data-testid='markdown-viewer-rendered']")).toBeTruthy();
});
it("falls through to plain markdown when no frontmatter is present at all", () => {
const { container } = render(<MarkdownViewer content="Just a paragraph." hideFrontmatter />);
expect(container.querySelector("[data-testid^='kind-frame']")).toBeNull();
expect(container.querySelector("[data-testid='md-paragraph']")?.textContent).toContain("Just a paragraph.");
});
});
describe("HG-2 — fenced-block grammars", () => {
it("renders a ```timeline``` block as a DotTimeline primitive", () => {
const content = `---
kind: incident-timeline
---
\`\`\`timeline
- time: "2026-05-09 14:02"
status: success
title: Plugin primitive design landed
body: DESIGN.md authored
- time: "2026-05-09 16:30"
status: warning
title: Hooks pivot
body: Decision to rip hooks scaffolding
\`\`\`
`;
const { container } = render(<MarkdownViewer content={content} hideFrontmatter />);
expect(container.querySelector("[data-testid='fenced-block-timeline']")).toBeTruthy();
expect(container.querySelector("[data-testid='primitive-dot-timeline-entry-0']")?.textContent).toContain("Plugin primitive design landed");
expect(container.querySelectorAll("[data-testid^='primitive-dot-timeline-entry-']").length).toBe(2);
});
it("renders a ```stats``` block as a StatCardBand primitive", () => {
const content = `\`\`\`stats
- label: Slices in 0.3.1
value: 10
trend: up
- label: Driver seats
value: 3
\`\`\`
`;
const { container } = render(<MarkdownViewer content={content} hideFrontmatter />);
expect(container.querySelector("[data-testid='fenced-block-stats']")).toBeTruthy();
expect(container.querySelector("[data-testid='primitive-stat-card-0']")?.textContent).toContain("10");
expect(container.querySelector("[data-testid='primitive-stat-card-0']")?.textContent).toContain("↑");
});
it("renders a ```risk-table``` block as a RiskTableGrid primitive", () => {
const content = `\`\`\`risk-table
- risk: Hooks may not fire reliably
probability: low
impact: high
mitigation: Phase 3b dogfood
\`\`\`
`;
const { container } = render(<MarkdownViewer content={content} hideFrontmatter />);
expect(container.querySelector("[data-testid='fenced-block-risk-table']")).toBeTruthy();
expect(container.querySelector("[data-testid='primitive-risk-row-0']")?.textContent).toContain("Hooks may not fire reliably");
});
it("renders a ```compare``` block as a CompareTable primitive", () => {
const content = `\`\`\`compare
columns: [Old approach, New approach]
rows:
- label: Editability
values: [Hard, Easy]
\`\`\`
`;
const { container } = render(<MarkdownViewer content={content} hideFrontmatter />);
expect(container.querySelector("[data-testid='fenced-block-compare']")).toBeTruthy();
expect(container.querySelector("[data-testid='primitive-compare-row-0']")?.textContent).toContain("Easy");
});
it("renders a ```slate``` block as a TLDRSlate primitive", () => {
const content = "```slate\nTL;DR text. Three sentences. Renders dark.\n```\n";
const { container } = render(<MarkdownViewer content={content} hideFrontmatter />);
expect(container.querySelector("[data-testid='fenced-block-slate']")).toBeTruthy();
expect(container.querySelector("[data-testid='fenced-block-slate']")?.textContent).toContain("TL;DR text");
});
it("falls back to a labeled error box when a fenced block fails to parse", () => {
const content = "```timeline\n[broken yaml\n```\n";
const { container } = render(<MarkdownViewer content={content} hideFrontmatter />);
expect(container.querySelector("[data-testid='fenced-block-timeline-fallback']")).toBeTruthy();
expect(container.querySelector("[data-testid='fenced-block-timeline-fallback']")?.textContent).toContain("fallback");
});
it("leaves unknown fenced-block languages (e.g. python) to the standard SyntaxHighlight renderer", () => {
const content = "```python\nprint('hello')\n```\n";
const { container } = render(<MarkdownViewer content={content} hideFrontmatter />);
expect(container.querySelector("[data-testid^='fenced-block-']")).toBeNull();
// Standard code-block render path still fires.
expect(container.textContent).toContain("print");
});
});
describe("HG-3 — plain markdown still renders identically when no convention is used", () => {
it("renders headings, lists, paragraphs, tables, and plain code blocks without any kind frame or fenced primitives", () => {
const content = `# Heading
A paragraph.
- list item one
- list item two
\`\`\`
plain code block
\`\`\`
`;
const { container } = render(<MarkdownViewer content={content} hideFrontmatter />);
expect(container.querySelector("[data-testid^='kind-frame']")).toBeNull();
expect(container.querySelector("[data-testid^='fenced-block-']")).toBeNull();
expect(container.querySelector("[data-testid='md-heading-1']")).toBeTruthy();
expect(container.querySelector("[data-testid='md-list-ul']")).toBeTruthy();
expect(container.querySelector("[data-testid='md-paragraph']")).toBeTruthy();
});
});
+1 -1
View File
@@ -149,7 +149,7 @@ describe("SliceScopePage P5-2 tab content piping", () => {
expect(await findByTestId("slice-scope-error")).toBeTruthy();
});
it("default landing tab is 'story'; mounts StoryTab with events + phaseDefinitions", async () => {
it("default landing tab is 'story'; mounts TimelineTab with events + phaseDefinitions", async () => {
const { container, findByTestId } = renderSliceScope({
sliceId: "idea-ledger",
detail: makeDetail(),
@@ -12,8 +12,8 @@ describe("polish-8 demo-readiness source guards", () => {
expect(src).toContain("bg-stone-950/65");
});
it("StoryTab renders a newest-first connected step tree", () => {
const src = read("../src/components/slices/tabs/StoryTab.tsx");
it("TimelineTab renders a newest-first connected step tree", () => {
const src = read("../src/components/slices/tabs/TimelineTab.tsx");
expect(src).toContain("data-testid=\"story-step-tree\"");
expect(src).toContain("data-order=\"newest-first\"");
expect(src).toContain("story-step-connector");
+9 -9
View File
@@ -1,8 +1,8 @@
// Slice Story View v1 — focused tab tests for the 4 v1 dimensions.
//
// Pins:
// - StoryTab: spec-driven phase grouping (no v0 hardcoded legacy enum)
// - StoryTab: untagged events render with neutral palette + label
// - TimelineTab: spec-driven phase grouping (no v0 hardcoded legacy enum)
// - TimelineTab: untagged events render with neutral palette + label
// - AcceptanceTab: Current Step panel renders when bound; absent when not
// - AcceptanceTab: PROGRESS.md checkbox view still renders alongside
// - TopologyTab: spec graph renders nodes + edges + isCurrent/isEntry/
@@ -14,7 +14,7 @@ import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { createTestRouter } from "./helpers/test-router.js";
import { StoryTab } from "../src/components/slices/tabs/StoryTab.js";
import { TimelineTab } from "../src/components/slices/tabs/TimelineTab.js";
import { DrawerSelectionContext } from "../src/components/AppShell.js";
import { AcceptanceTab } from "../src/components/slices/tabs/AcceptanceTab.js";
import { TopologyTab } from "../src/components/slices/tabs/TopologyTab.js";
@@ -56,14 +56,14 @@ function renderStory(ui: ReactNode) {
);
}
describe("PL-slice-story-view-v1 StoryTab", () => {
describe("PL-slice-story-view-v1 TimelineTab", () => {
it("groups events by spec-declared phase labels (NOT v0 hardcoded legacy names)", () => {
const events = [
event({ kind: "queue.created", phase: "discovery", qitemId: "q-d" }),
event({ kind: "queue.handed_off", phase: "delivery", qitemId: "q-x" }),
event({ kind: "transition.in-progress", phase: "qa", qitemId: "q-q" }),
];
renderStory(<StoryTab events={events} phaseDefinitions={SPEC_PHASES} />);
renderStory(<TimelineTab events={events} phaseDefinitions={SPEC_PHASES} />);
// Each row's phase chip uses the spec-declared label (which equals
// the actor_role for v1's projector default).
expect(screen.getByTestId("story-row-phase-queue.created").textContent).toBe("discovery-router");
@@ -73,7 +73,7 @@ describe("PL-slice-story-view-v1 StoryTab", () => {
it("untagged events render with the neutral 'untagged' label + stone palette", () => {
const events = [event({ kind: "doc.edited", phase: null, qitemId: null })];
renderStory(<StoryTab events={events} phaseDefinitions={SPEC_PHASES} />);
renderStory(<TimelineTab events={events} phaseDefinitions={SPEC_PHASES} />);
const chip = screen.getByTestId("story-row-phase-doc.edited");
expect(chip.textContent).toBe("untagged");
expect(chip.getAttribute("data-phase-id")).toBe("untagged");
@@ -87,7 +87,7 @@ describe("PL-slice-story-view-v1 StoryTab", () => {
// loses its workflow_instance binding — events were tagged at fetch
// time, definitions weren't.)
const events = [event({ kind: "queue.created", phase: "step-x" })];
renderStory(<StoryTab events={events} phaseDefinitions={null} />);
renderStory(<TimelineTab events={events} phaseDefinitions={null} />);
expect(screen.getByTestId("story-row-phase-queue.created").textContent).toBe("step-x");
});
@@ -96,7 +96,7 @@ describe("PL-slice-story-view-v1 StoryTab", () => {
event({ kind: "old.event", ts: "2026-05-04T00:00:00.000Z", summary: "older" }),
event({ kind: "new.event", ts: "2026-05-04T01:00:00.000Z", summary: "newer" }),
];
renderStory(<StoryTab events={events} phaseDefinitions={null} />);
renderStory(<TimelineTab events={events} phaseDefinitions={null} />);
expect(screen.getByTestId("story-step-tree").getAttribute("data-order")).toBe("newest-first");
const rows = screen.getAllByTestId(/story-row-/);
expect(rows[0]?.getAttribute("data-testid")).toBe("story-row-new.event");
@@ -117,7 +117,7 @@ describe("PL-slice-story-view-v1 StoryTab", () => {
body: "Implement the observability body-first story view.\nInclude acceptance evidence.",
};
renderStory(
<StoryTab
<TimelineTab
events={[
event({
kind: "queue.created",
@@ -0,0 +1,181 @@
// 0.3.1 slice 06 — For You storytelling card primitives test surface.
// Covers IMPL-PRD HG-6 (5 card primitives ship), HG-7 (drill-in
// routing), HG-8 (mobile-first; ≥44px touch targets). Visual proof
// per phone-viewport is the design-reviewer's gate; this suite gates
// the structural shape (testid surface, collapsed-vs-expanded
// behavior, drill-in href shape, touch-target sizing).
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, fireEvent } from "@testing-library/react";
import {
ShippedCard,
IncidentCard,
ProgressCard,
ApprovalCard,
ConceptCard,
StorytellingFeed,
type FeedCardItem,
} from "../src/components/feed/cards/storytelling-cards.js";
afterEach(cleanup);
describe("HG-6 — 5 card primitives ship", () => {
it("ShippedCard renders collapsed; tap toggles inline-expanded preview", () => {
const { container, getByTestId } = render(
<ShippedCard source={{
sliceId: "s1",
title: "Plugin primitive shipped",
oneLiner: "Plugin support landed across G-Stack and Obra Superpowers.",
sections: [
{ number: 1, heading: "DESIGN landed", summary: "DESIGN.md authored" },
{ number: 2, heading: "Forensic research", summary: "Codex plugin confirmation" },
{ number: 3, heading: "Implementation", summary: "Plugin tree authored" },
],
}} />
);
expect(getByTestId("feed-card-shipped-s1-title").textContent).toContain("Plugin primitive shipped");
expect(container.querySelector("[data-testid='feed-card-shipped-s1-expanded']")).toBeNull();
fireEvent.click(getByTestId("feed-card-shipped-s1-toggle"));
expect(getByTestId("feed-card-shipped-s1-expanded")).toBeTruthy();
expect(getByTestId("feed-card-shipped-s1-section-1").textContent).toContain("DESIGN landed");
});
it("IncidentCard exposes a status dot + recent timeline entries when inline-expanded", () => {
const { getByTestId } = render(
<IncidentCard source={{
sliceId: "s2",
title: "Plugin primitive narrative",
oneLiner: "Mission narrative in flight",
status: "warning",
recentEntries: [
{ time: "14:02", title: "DESIGN landed", status: "success" },
{ time: "16:30", title: "Hooks pivot", status: "warning" },
],
}} />
);
expect(getByTestId("feed-card-incident-s2-dot")).toBeTruthy();
fireEvent.click(getByTestId("feed-card-incident-s2-toggle"));
expect(getByTestId("feed-card-incident-s2-entry-0").textContent).toContain("DESIGN landed");
expect(getByTestId("feed-card-incident-s2-entry-1").textContent).toContain("Hooks pivot");
});
it("ProgressCard exposes an active-slice preview when inline-expanded", () => {
const { getByTestId } = render(
<ProgressCard source={{
missionId: "m1",
title: "Release 0.3.1",
oneLiner: "10 slices in flight",
percent: 60,
activeSlice: { id: "06", label: "06-storytelling-primitives", status: "in-flight" },
}} />
);
fireEvent.click(getByTestId("feed-card-progress-m1-toggle"));
expect(getByTestId("feed-card-progress-m1-active-slice").textContent).toContain("06-storytelling-primitives");
});
it("ApprovalCard surfaces approve/deny inline actions on the collapsed view", () => {
const onApprove = vi.fn();
const onDeny = vi.fn();
const { getByTestId } = render(
<ApprovalCard source={{
qitemId: "q1",
title: "Approve plugin pack install?",
oneLiner: "G-Stack pack requested by user",
bodyPreview: "Body preview content here",
onApprove,
onDeny,
drillInHref: "/queue/q1",
}} />
);
fireEvent.click(getByTestId("feed-card-approval-q1-approve"));
fireEvent.click(getByTestId("feed-card-approval-q1-deny"));
expect(onApprove).toHaveBeenCalledOnce();
expect(onDeny).toHaveBeenCalledOnce();
});
it("ConceptCard surfaces a compare-preview table when inline-expanded", () => {
const { getByTestId } = render(
<ConceptCard source={{
sliceId: "s3",
title: "Storytelling primitives",
oneLiner: "Markdown-first rich rendering",
comparePreview: [
{ label: "Editability", valueOld: "HTML editor", valueNew: "Any text editor" },
{ label: "Diffability", valueOld: "Hard", valueNew: "Trivial" },
],
}} />
);
fireEvent.click(getByTestId("feed-card-concept-s3-toggle"));
expect(getByTestId("feed-card-concept-s3-expanded").textContent).toContain("Editability");
expect(getByTestId("feed-card-concept-s3-expanded").textContent).toContain("Trivial");
});
});
describe("HG-7 — card drill-in routes correctly", () => {
it("ShippedCard drill-in points at /project/slice/<id>", () => {
const { getByTestId } = render(
<ShippedCard source={{ sliceId: "abc", title: "x", oneLiner: "y" }} />
);
fireEvent.click(getByTestId("feed-card-shipped-abc-toggle"));
expect(getByTestId("feed-card-shipped-abc-drill-in").getAttribute("href")).toBe("/project/slice/abc");
});
it("ProgressCard drill-in points at /project/mission/<id>", () => {
const { getByTestId } = render(
<ProgressCard source={{ missionId: "release-0.3.1", title: "x", oneLiner: "y", percent: 50 }} />
);
fireEvent.click(getByTestId("feed-card-progress-release-0.3.1-toggle"));
expect(getByTestId("feed-card-progress-release-0.3.1-drill-in").getAttribute("href")).toBe("/project/mission/release-0.3.1");
});
it("ApprovalCard drill-in honors the source-provided href (queue detail or slice)", () => {
const { getByTestId } = render(
<ApprovalCard source={{ qitemId: "q9", title: "x", oneLiner: "y", drillInHref: "/queue/q9" }} />
);
fireEvent.click(getByTestId("feed-card-approval-q9-toggle"));
expect(getByTestId("feed-card-approval-q9-drill-in").getAttribute("href")).toBe("/queue/q9");
});
});
describe("HG-8 — mobile-first touch targets", () => {
it("inline action buttons (approve/deny) carry min-h-[44px] for ≥44px tap targets", () => {
const { getByTestId } = render(
<ApprovalCard source={{ qitemId: "q1", title: "x", oneLiner: "y", onApprove: () => {}, onDeny: () => {} }} />
);
expect(getByTestId("feed-card-approval-q1-approve").className).toMatch(/min-h-\[44px\]/);
expect(getByTestId("feed-card-approval-q1-deny").className).toMatch(/min-h-\[44px\]/);
});
it("drill-in CTA carries min-h-[44px] for ≥44px tap target", () => {
const { getByTestId } = render(
<ShippedCard source={{ sliceId: "s", title: "x", oneLiner: "y" }} />
);
fireEvent.click(getByTestId("feed-card-shipped-s-toggle"));
expect(getByTestId("feed-card-shipped-s-drill-in").className).toMatch(/min-h-\[44px\]/);
});
it("collapsed card surface carries min-h-[80px] for the IMPL-PRD §6 ~80px height target", () => {
const { getByTestId } = render(
<ShippedCard source={{ sliceId: "s", title: "x", oneLiner: "y" }} />
);
expect(getByTestId("feed-card-shipped-s-toggle").className).toMatch(/min-h-\[80px\]/);
});
});
describe("StorytellingFeed — composition", () => {
it("composes a vertical stack of cards from a mixed-kind item list", () => {
const items: FeedCardItem[] = [
{ kind: "shipped", source: { sliceId: "a", title: "A", oneLiner: "x" } },
{ kind: "incident", source: { sliceId: "b", title: "B", oneLiner: "x", status: "info" } },
{ kind: "progress", source: { missionId: "m", title: "M", oneLiner: "x", percent: 50 } },
];
const { getByTestId, container } = render(<StorytellingFeed items={items} />);
expect(getByTestId("storytelling-feed")).toBeTruthy();
expect(container.querySelectorAll("[data-testid^='feed-card-']").length).toBeGreaterThanOrEqual(3);
});
it("renders an empty-state when there are no items", () => {
const { getByTestId } = render(<StorytellingFeed items={[]} />);
expect(getByTestId("storytelling-feed-empty").textContent).toContain("No items");
});
});
@@ -0,0 +1,203 @@
// 0.3.1 slice 06 — pure-logic tests for the storytelling-primitives
// foundation: kind extraction, fenced-block parsing, kind dispatch
// table. React rendering tests live alongside in storytelling-*.test.tsx.
import { describe, it, expect } from "vitest";
import {
extractKind,
KNOWN_KINDS,
parseTimelineBlock,
parseStatsBlock,
parseRiskTableBlock,
parseCompareBlock,
parseSlateBlock,
type KindName,
} from "../src/components/markdown/storytelling-primitives.js";
describe("extractKind", () => {
it("returns null when frontmatter is null", () => {
expect(extractKind(null)).toBeNull();
});
it("returns null when frontmatter has no kind field", () => {
expect(extractKind({ title: "hello" })).toBeNull();
});
it("returns the kind name when frontmatter declares a known kind", () => {
expect(extractKind({ kind: "incident-timeline" })).toBe("incident-timeline");
expect(extractKind({ kind: "progress" })).toBe("progress");
expect(extractKind({ kind: "feature-shipped" })).toBe("feature-shipped");
});
it("returns null for an unknown kind value (so the renderer falls back to plain markdown)", () => {
expect(extractKind({ kind: "not-a-real-kind" })).toBeNull();
});
it("covers all 7 declared kinds from the IMPL-PRD §3", () => {
expect(KNOWN_KINDS).toEqual([
"incident-timeline",
"progress",
"feature-shipped",
"implementation-plan",
"concept-explainer",
"pr-writeup",
"post-mortem",
] satisfies KindName[]);
});
});
describe("parseTimelineBlock", () => {
it("parses a list of entries with time/status/title/body", () => {
const body = `- time: "2026-05-09 14:02"
status: success
title: Plugin primitive design landed
body: DESIGN.md authored at substrate
- time: "2026-05-09 16:30"
status: warning
title: Hooks pivot
body: Decision to rip hooks scaffolding`;
const result = parseTimelineBlock(body);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.entries).toHaveLength(2);
expect(result.entries[0]!.status).toBe("success");
expect(result.entries[0]!.title).toBe("Plugin primitive design landed");
expect(result.entries[1]!.status).toBe("warning");
}
});
it("returns ok:false on unparseable YAML so the caller can fall back to a plain code block", () => {
const body = `- time: 2026-05-09
status: [broken`;
const result = parseTimelineBlock(body);
expect(result.ok).toBe(false);
});
it("returns ok:false when the body is not a YAML list (kind mismatch)", () => {
const body = `title: not-a-list-of-entries`;
const result = parseTimelineBlock(body);
expect(result.ok).toBe(false);
});
it("normalizes an unknown status string to 'info' (graceful)", () => {
const body = `- time: "now"
status: bogus
title: x
body: y`;
const result = parseTimelineBlock(body);
expect(result.ok).toBe(true);
if (result.ok) expect(result.entries[0]!.status).toBe("info");
});
});
describe("parseStatsBlock", () => {
it("parses a list of label/value/trend entries", () => {
const body = `- label: Slices in 0.3.1
value: 10
trend: up
- label: Driver seats
value: 3`;
const result = parseStatsBlock(body);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.entries).toHaveLength(2);
expect(result.entries[0]!.label).toBe("Slices in 0.3.1");
expect(result.entries[0]!.value).toBe("10");
expect(result.entries[0]!.trend).toBe("up");
// trend omitted on second entry → undefined
expect(result.entries[1]!.trend).toBeUndefined();
}
});
it("coerces numeric values into strings for display", () => {
const body = `- label: x
value: 42`;
const result = parseStatsBlock(body);
expect(result.ok).toBe(true);
if (result.ok) expect(result.entries[0]!.value).toBe("42");
});
it("returns ok:false on unparseable input", () => {
const result = parseStatsBlock("[broken");
expect(result.ok).toBe(false);
});
});
describe("parseRiskTableBlock", () => {
it("parses risk/probability/impact/mitigation entries", () => {
const body = `- risk: Hooks may not fire reliably
probability: low
impact: high
mitigation: Phase 3b dogfood
- risk: Convention complexity grows
probability: med
impact: med
mitigation: Curated set at v0`;
const result = parseRiskTableBlock(body);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.entries).toHaveLength(2);
expect(result.entries[0]!.probability).toBe("low");
expect(result.entries[0]!.impact).toBe("high");
}
});
it("normalizes invalid probability/impact values to 'med'", () => {
const body = `- risk: x
probability: extreme
impact: bogus
mitigation: y`;
const result = parseRiskTableBlock(body);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.entries[0]!.probability).toBe("med");
expect(result.entries[0]!.impact).toBe("med");
}
});
});
describe("parseCompareBlock", () => {
it("parses columns + rows with values aligned to columns", () => {
const body = `columns: [Old approach, New approach]
rows:
- label: Editability
values: [Easy with HTML editor, Easy with any text editor]
- label: Diffability
values: [Hard, Trivial]`;
const result = parseCompareBlock(body);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.columns).toEqual(["Old approach", "New approach"]);
expect(result.rows).toHaveLength(2);
expect(result.rows[0]!.label).toBe("Editability");
expect(result.rows[0]!.values).toEqual(["Easy with HTML editor", "Easy with any text editor"]);
}
});
it("returns ok:false when columns array is missing", () => {
const body = `rows: []`;
const result = parseCompareBlock(body);
expect(result.ok).toBe(false);
});
});
describe("parseSlateBlock", () => {
it("returns the raw body as a slate string (no YAML structure needed)", () => {
const body = "TL;DR text. Three sentences. Renders as dark vellum slab.";
const result = parseSlateBlock(body);
expect(result.ok).toBe(true);
if (result.ok) expect(result.text).toBe(body);
});
it("trims surrounding whitespace", () => {
const body = " TL;DR. \n\n";
const result = parseSlateBlock(body);
expect(result.ok).toBe(true);
if (result.ok) expect(result.text).toBe("TL;DR.");
});
it("returns ok:false when the body is empty (caller falls back to code block)", () => {
const result = parseSlateBlock(" \n ");
expect(result.ok).toBe(false);
});
});