mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
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:
1 parent
a2d45687fa
commit
e8e3f5b4f3
15 files changed
+1670
-33
No files matched your search
@@ -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}
|
||||
|
||||
+71
-9
@@ -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"><slice-dir>/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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user