Add Motion: the go-to-market operating system on top of the ledger

The ledger answers which contracted capacity is sold, to whom, at what
margin. It says nothing about the motion — the repeatable practice that
turns a customer conversation into a scoped deployment, and turns that
deployment into something the next one reuses.

Motion is deliberately not a parallel entity tree. DEMAND_STAGES already
is the motion, so Motion binds reusable artefacts to the stages of a
demand deal that already exists: an engagement hangs off one deal,
cascade deleted, one per deal by unique constraint.

Nine closed kinds, each declaring which stages it serves, and a starter
library of twelve templates covering all eight open stages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 18:27:03 -07:00
parent 99d165b5e5
commit 516685526c
61 changed files with 13013 additions and 29 deletions
@@ -0,0 +1,75 @@
/**
* Source with a copy button — the fence chrome, in one place.
*
* Reference architectures arrive as `mermaid`, both in a template's structured
* `fields` and inside the markdown body a promoted artefact brings with it, and
* neither is rendered as a picture. Bundling mermaid means roughly two
* megabytes and evaluating author-supplied text, and the proxy allows exactly
* one inline script by hash (AGENTS.md §5), so it would drag a CSP change onto
* the deployment host too. Source with a copy button gets the reader into their
* own diagram tool in two clicks; the picture is tracked as follow-up in
* `docs/motion.md`.
*
* The copy button is here rather than in each caller because a diagram in a
* body and a diagram in a field are the same thing to the person reading it,
* and one of the two silently lacking the button is the sort of difference
* nobody reports.
*/
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui';
export function CodeBlock({
source,
language,
label,
}: {
source: string;
/** The fence's language, shown as the block's caption. Absent for a bare fence. */
language?: string | null;
/** What the copy button says it copies, for a screen reader. */
label?: string;
}) {
const [copied, setCopied] = useState(false);
const copy = async () => {
// `navigator.clipboard` is absent outside a secure context, which the LAN
// dev server is — so this branch is reached routinely, not exceptionally.
if (!navigator.clipboard) {
toast.error('The browser refused clipboard access. Select the source and copy it by hand.');
return;
}
try {
await navigator.clipboard.writeText(source);
setCopied(true);
toast.success('Source copied.');
window.setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('The browser refused clipboard access. Select the source and copy it by hand.');
}
};
return (
<div className="min-w-0 overflow-hidden rounded-lg border border-border bg-surface-2">
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-border pl-3">
<span className="min-w-0 truncate font-mono text-[11px] lowercase text-muted">
{language ?? 'source'}
</span>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => void copy()}
aria-label={label ? `Copy the ${label} source` : 'Copy the source'}
>
{copied ? <Check className="size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
<pre className="scroll-x p-3 text-xs leading-5">
<code className="font-mono">{source}</code>
</pre>
</div>
);
}
@@ -0,0 +1,637 @@
/**
* The structured half of a template, rendered per kind.
*
* `fields` is authored JSON. It is not validated on the way out of the
* database, it is edited by anyone with `motion:write`, it survives promotion
* from an engagement artefact unchanged, and it will outlive whatever shape
* this file expects today. So every access below is narrowed, every list is
* filtered to the entries that carry the property being rendered, and a shape
* this file does not recognise renders as nothing at all.
*
* That last rule is the important one. The alternative — assume the shape and
* let the page throw — turns one badly-typed seed row into a blank library for
* everybody, and the crash surfaces in a route far from the row that caused it.
* An omitted section is a bug someone reports; a white screen is an outage.
*
* A qualification framework's dimensions are shown with their weights and
* their 04 anchors, because the anchors are what stop a score being a vibe:
* the number only means something if two people reading the same evidence pick
* the same one.
*/
import { type ReactNode } from 'react';
import { Skull, TriangleAlert } from 'lucide-react';
import { DEMAND_STAGE_LABELS, type DemandStage, type MotionKind } from '@pig/core';
import { Badge, cn } from '@/components/ui';
import { CodeBlock } from './CodeBlock';
import { anchorScale, asRecord, number, recordList, text, textList } from './fields';
export function FieldsView({
kind,
fields,
className,
}: {
kind: MotionKind;
/** Untrusted. `unknown` on purpose — see the header. */
fields: unknown;
className?: string;
}) {
const record = asRecord(fields);
if (!record) return null;
const body = renderKind(kind, record);
if (!body) return null;
return <div className={cn('min-w-0 space-y-5', className)}>{body}</div>;
}
function renderKind(kind: MotionKind, fields: Record<string, unknown>): ReactNode {
switch (kind) {
case 'discovery':
return <DiscoveryFields fields={fields} />;
case 'qualification':
return <QualificationFields fields={fields} />;
case 'poc':
return <PocFields fields={fields} />;
case 'proposal':
return <ProposalFields fields={fields} />;
case 'pricing':
return <PricingFields fields={fields} />;
case 'architecture':
return <ArchitectureFields fields={fields} />;
case 'case_study':
return <CaseStudyFields fields={fields} />;
case 'narrative':
return <NarrativeFields fields={fields} />;
case 'playbook':
return <PlaybookFields fields={fields} />;
}
}
// ------------------------------------------------------------------ the kinds
function DiscoveryFields({ fields }: { fields: Record<string, unknown> }) {
const sections = recordList(fields.sections);
if (sections.length === 0) return null;
return (
<>
{sections.map((section, index) => {
const name = text(section.name);
if (!name) return null;
const questions = recordList(section.questions);
return (
<Section key={index} title={name}>
<Note label="Goal" value={text(section.goal)} />
<Note label="A bad answer" value={text(section.badAnswer)} tone="warning" />
{questions.length === 0 ? null : (
<ol className="min-w-0 space-y-3">
{questions.map((question, questionIndex) => {
const asked = text(question.q);
if (!asked) return null;
return (
<li key={questionIndex} className="min-w-0 rounded-lg border border-border p-3">
<p className="min-w-0 break-words font-medium leading-6">{asked}</p>
<Note label="Why" value={text(question.why)} />
<Note label="Listen for" value={text(question.listenFor)} />
</li>
);
})}
</ol>
)}
</Section>
);
})}
</>
);
}
function QualificationFields({ fields }: { fields: Record<string, unknown> }) {
const dimensions = recordList(fields.dimensions);
const bands = recordList(fields.bands);
const disqualifiers = recordList(fields.disqualifiers);
if (dimensions.length === 0 && bands.length === 0 && disqualifiers.length === 0) return null;
const weightTotal = dimensions.reduce((total, dimension) => total + (number(dimension.weight) ?? 0), 0);
return (
<>
{dimensions.length === 0 ? null : (
<Section
title="Dimensions"
// Weights need not sum to 100 — the score is a proportion of the
// maximum — so the total is shown rather than assumed.
aside={weightTotal > 0 ? `${weightTotal} weight in total` : undefined}
>
<div className="min-w-0 space-y-3">
{dimensions.map((dimension, index) => {
const name = text(dimension.name);
if (!name) return null;
const weight = number(dimension.weight);
const group = text(dimension.group);
return (
<div key={index} className="min-w-0 rounded-lg border border-border p-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 break-words font-medium leading-6">{name}</span>
{group ? <Badge tone="neutral">{group}</Badge> : null}
{weight === null ? null : (
<span className="nums whitespace-nowrap text-xs text-muted">weight {weight}</span>
)}
</div>
<Note label="Why it predicts" value={text(dimension.why)} />
<Anchors anchors={dimension.anchors} />
</div>
);
})}
</div>
</Section>
)}
{bands.length === 0 ? null : (
<Section title="What each score means">
<ul className="min-w-0 space-y-2">
{bands.map((band, index) => {
const label = text(band.label);
if (!label) return null;
const min = number(band.min);
const max = number(band.max);
return (
<li key={index} className="min-w-0">
<span className="font-medium">{label}</span>
{min === null || max === null ? null : (
<span className="nums ml-2 text-xs text-muted">
{min}{max}
</span>
)}
<Note label="Action" value={text(band.action)} />
</li>
);
})}
</ul>
</Section>
)}
{disqualifiers.length === 0 ? null : (
<Section title="Disqualifiers" icon={<Skull className="size-4" aria-hidden />}>
<ul className="min-w-0 space-y-3">
{disqualifiers.map((disqualifier, index) => {
const name = text(disqualifier.name);
if (!name) return null;
return (
<li key={index} className="min-w-0">
<p className="min-w-0 break-words font-medium leading-6">{name}</p>
<Note label="Test" value={text(disqualifier.test)} />
<Note label="Why" value={text(disqualifier.why)} />
</li>
);
})}
</ul>
</Section>
)}
</>
);
}
/** The 04 scale, extracted by the same rule the scorer reads it with. */
function Anchors({ anchors }: { anchors: unknown }) {
const rows = anchorScale(anchors);
if (rows.length === 0) return null;
return (
<dl className="mt-3 min-w-0 space-y-1.5 border-t border-border pt-3">
{rows.map((row) => (
<div key={row.score} className="flex min-w-0 gap-3">
<dt className="nums w-5 shrink-0 text-sm font-semibold text-muted">{row.score}</dt>
<dd className="min-w-0 break-words text-sm leading-6">{row.anchor}</dd>
</div>
))}
</dl>
);
}
function PocFields({ fields }: { fields: Record<string, unknown> }) {
const hypothesis = text(fields.hypothesis);
const milestones = recordList(fields.milestones);
const metrics = recordList(fields.successMetrics);
const risks = recordList(fields.risks);
if (!hypothesis && milestones.length === 0 && metrics.length === 0 && risks.length === 0) return null;
return (
<>
{hypothesis ? (
<Section title="Hypothesis">
<p className="min-w-0 break-words leading-6">{hypothesis}</p>
</Section>
) : null}
{milestones.length === 0 ? null : (
<Section title="Milestones">
<ol className="min-w-0 space-y-3">
{milestones.map((milestone, index) => {
const title = text(milestone.title);
if (!title) return null;
return (
<li key={index} className="min-w-0 rounded-lg border border-border p-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{text(milestone.week) ? (
<Badge tone="neutral">{text(milestone.week)}</Badge>
) : null}
<span className="min-w-0 break-words font-medium leading-6">{title}</span>
{/* A kill gate is the only reason a POC ends early rather
than drifting into a second quarter, so it is the one
property here that earns a colour. */}
{milestone.killGate === true ? <Badge tone="danger">Kill gate</Badge> : null}
</div>
<Note label="Exit criterion" value={text(milestone.exitCriterion)} />
<Note label="Owner" value={text(milestone.owner)} />
</li>
);
})}
</ol>
</Section>
)}
{metrics.length === 0 ? null : (
<Section title="Success metrics">
<ScrollTable
head={['Metric', 'Baseline', 'Target', 'Measured by']}
rows={metrics.map((metric) => [
text(metric.metric),
text(metric.baseline),
text(metric.target),
text(metric.measuredBy),
])}
/>
</Section>
)}
{risks.length === 0 ? null : (
<Section title="Risks" icon={<TriangleAlert className="size-4" aria-hidden />}>
<ScrollTable
head={['Risk', 'Owner', 'Mitigation']}
rows={risks.map((risk) => [text(risk.risk), text(risk.owner), text(risk.mitigation)])}
/>
</Section>
)}
</>
);
}
function ProposalFields({ fields }: { fields: Record<string, unknown> }) {
const blocks = recordList(fields.blocks);
if (blocks.length === 0) return null;
return (
<>
{blocks.map((block, index) => {
const title = text(block.title);
if (!title) return null;
const body = text(block.text);
return (
<Section key={index} title={title}>
<Note label="Use when" value={text(block.useWhen)} />
<Note label="Avoid when" value={text(block.avoidWhen)} tone="warning" />
{body ? (
// Proposal blocks are lifted verbatim into a document, so the
// whitespace the author wrote is part of the block.
<p className="min-w-0 whitespace-pre-wrap break-words rounded-lg border border-border bg-surface-2 p-3 text-sm leading-6">
{body}
</p>
) : null}
</Section>
);
})}
</>
);
}
function PricingFields({ fields }: { fields: Record<string, unknown> }) {
const inputs = recordList(fields.inputs);
const packages = recordList(fields.packages);
const tradeables = recordList(fields.tradeables);
if (inputs.length === 0 && packages.length === 0 && tradeables.length === 0) return null;
return (
<>
{inputs.length === 0 ? null : (
<Section title="Inputs">
<div className="min-w-0 space-y-3">
{inputs.map((input, index) => {
const name = text(input.name);
if (!name) return null;
const unit = text(input.unit);
return (
<div key={index} className="min-w-0 rounded-lg border border-border p-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 break-words font-medium leading-6">{name}</span>
{unit ? <Badge tone="neutral">{unit}</Badge> : null}
</div>
<Note label="Where it comes from" value={text(input.howToGet)} />
<Note label="Why it matters" value={text(input.whyItMatters)} />
</div>
);
})}
</div>
</Section>
)}
{packages.length === 0 ? null : (
<Section title="Packages">
<ScrollTable
head={['Package', 'Shape', 'Fits when', 'Fails when']}
rows={packages.map((entry) => [
text(entry.name),
text(entry.shape),
text(entry.fitsWhen),
text(entry.failsWhen),
])}
/>
</Section>
)}
{tradeables.length === 0 ? null : (
<Section title="What to trade">
<ScrollTable
head={['Give', 'Get']}
rows={tradeables.map((entry) => [text(entry.give), text(entry.get)])}
/>
</Section>
)}
</>
);
}
function ArchitectureFields({ fields }: { fields: Record<string, unknown> }) {
const architectures = recordList(fields.architectures);
if (architectures.length === 0) return null;
return (
<>
{architectures.map((architecture, index) => {
const name = text(architecture.name);
if (!name) return null;
const components = recordList(architecture.components);
return (
<Section key={index} title={name}>
<Note label="Fits when" value={text(architecture.fitsWhen)} />
<Note label="Fails when" value={text(architecture.failureMode)} tone="warning" />
<Diagram source={text(architecture.mermaid)} name={name} />
{components.length === 0 ? null : (
<ScrollTable
head={['Component', 'Run by', 'Why']}
rows={components.map((component) => [
text(component.component),
text(component.runBy),
text(component.why),
])}
/>
)}
</Section>
);
})}
</>
);
}
/**
* The diagram, as source rather than as a picture — deliberately, for now. See
* `CodeBlock` for why, and for the copy button that makes it usable.
*/
function Diagram({ source, name }: { source: string | null; name: string }) {
if (!source) return null;
return <CodeBlock source={source} language="mermaid" label={`${name} diagram`} />;
}
function CaseStudyFields({ fields }: { fields: Record<string, unknown> }) {
const sections = recordList(fields.sections);
const harvest = recordList(fields.harvest);
if (sections.length === 0 && harvest.length === 0) return null;
return (
<>
{sections.length === 0 ? null : (
<Section title="Sections">
<ol className="min-w-0 space-y-3">
{sections.map((section, index) => {
const name = text(section.name);
if (!name) return null;
return (
<li key={index} className="min-w-0 rounded-lg border border-border p-3">
<p className="min-w-0 break-words font-medium leading-6">{name}</p>
<Note label="What goes here" value={text(section.prompt)} />
{/* Evidence rules are the reason a case study can be shown to
the next customer at all — see AGENTS.md §4. */}
<Note label="Evidence rule" value={text(section.evidenceRule)} />
</li>
);
})}
</ol>
</Section>
)}
{harvest.length === 0 ? null : (
<Section title="When to capture it">
<ScrollTable
head={['When', 'Capture', 'Why']}
rows={harvest.map((entry) => [text(entry.when), text(entry.capture), text(entry.why)])}
/>
</Section>
)}
</>
);
}
function NarrativeFields({ fields }: { fields: Record<string, unknown> }) {
const narratives = recordList(fields.narratives);
if (narratives.length === 0) return null;
return (
<>
{narratives.map((narrative, index) => {
const audience = text(narrative.audience);
if (!audience) return null;
const body = text(narrative.text);
return (
<Section key={index} title={audience}>
<Note label="What they already believe" value={text(narrative.belief)} />
{body ? (
<p className="min-w-0 whitespace-pre-wrap break-words rounded-lg border border-border bg-surface-2 p-3 text-sm leading-6">
{body}
</p>
) : null}
<Note label="Analogy that works" value={text(narrative.analogyThatWorks)} />
<Note label="Analogy that fails" value={text(narrative.analogyThatFails)} tone="warning" />
<Note label="What they ask next" value={text(narrative.nextQuestion)} />
<Note label="Answer" value={text(narrative.answer)} />
</Section>
);
})}
</>
);
}
function PlaybookFields({ fields }: { fields: Record<string, unknown> }) {
const stages = recordList(fields.stages);
const research = asRecord(fields.researchInterface);
const promotion = recordList(fields.promotion);
if (stages.length === 0 && !research && promotion.length === 0) return null;
return (
<>
{stages.map((entry, index) => {
const stage = stageLabel(entry.stage);
if (!stage) return null;
const days = number(entry.typicalDays);
return (
<Section key={index} title={stage} aside={days === null ? undefined : `~${days} days`}>
<Note label="Entry" value={text(entry.entry)} />
<Note label="What it does" value={text(entry.does)} />
<Note label="Exit" value={text(entry.exit)} />
<Pills label="Artefacts" items={textList(entry.artifacts)} />
<Pills label="Who is involved" items={textList(entry.involves)} />
{/* Where the stage dies is the part of a playbook that gets read
twice — the sequence is obvious, the failure is not. */}
<Note label="How it dies" value={text(entry.diesBy)} tone="warning" />
<Note label="Stall symptom" value={text(entry.stallSymptom)} tone="warning" />
<Note label="Unstick move" value={text(entry.unstickMove)} />
</Section>
);
})}
{research ? (
<Section title="Research interface">
<Pills label="Scope must contain" items={textList(research.scopeMustContain)} />
<Pills label="Owed back" items={textList(research.oweBack)} />
</Section>
) : null}
{promotion.length === 0 ? null : (
<Section title="What to promote, and when">
<ScrollTable
head={['Trigger', 'Promote', 'Into']}
rows={promotion.map((entry) => [text(entry.trigger), text(entry.promote), text(entry.into)])}
/>
</Section>
)}
</>
);
}
// ------------------------------------------------------------------- fragments
function Section({
title,
aside,
icon,
children,
}: {
title: string;
aside?: string;
icon?: ReactNode;
children: ReactNode;
}) {
return (
<section className="min-w-0 rounded-xl border border-border bg-surface p-4">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<h3 className="flex min-w-0 items-center gap-2 break-words font-semibold leading-snug">
{icon ? <span className="shrink-0 text-muted">{icon}</span> : null}
{title}
</h3>
{aside ? <span className="nums whitespace-nowrap text-xs text-muted">{aside}</span> : null}
</div>
<div className="mt-3 min-w-0 space-y-3">{children}</div>
</section>
);
}
/** A labelled paragraph. Renders nothing at all when the value is absent. */
function Note({
label,
value,
tone,
}: {
label: string;
value: string | null;
tone?: 'warning';
}) {
if (!value) return null;
return (
<p className="mt-2 min-w-0 break-words text-sm leading-6">
<span
className={cn(
'mr-2 text-xs font-medium uppercase tracking-wide',
tone === 'warning' ? 'text-warning' : 'text-muted',
)}
>
{label}
</span>
{value}
</p>
);
}
function Pills({ label, items }: { label: string; items: string[] }) {
if (items.length === 0) return null;
return (
<div className="mt-2 min-w-0">
<span className="text-xs font-medium uppercase tracking-wide text-muted">{label}</span>
<div className="mt-1 flex min-w-0 flex-wrap gap-1.5">
{items.map((item, index) => (
<Badge key={index} tone="neutral" className="min-w-0">
<span className="truncate">{item}</span>
</Badge>
))}
</div>
</div>
);
}
/**
* A table whose overflow stays inside its own box. Rows are dropped when every
* cell in them is empty, so a partially-authored list does not render as a
* column of blank stripes.
*/
function ScrollTable({ head, rows }: { head: string[]; rows: (string | null)[][] }) {
const present = rows.filter((row) => row.some((cell) => cell !== null));
if (present.length === 0) return null;
return (
<div className="scroll-x min-w-0 rounded-lg border border-border">
<table className="w-max min-w-full border-collapse text-left text-[13px] leading-5">
<thead className="border-b border-border bg-surface-2">
<tr>
{head.map((heading) => (
<th key={heading} className="whitespace-nowrap px-3 py-2 align-bottom font-medium text-muted">
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{present.map((row, index) => (
<tr key={index} className="transition-colors hover:bg-surface-2">
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="max-w-[28rem] px-3 py-2 align-top">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
// -------------------------------------------------------------------- narrowing
/**
* A playbook stage names a `DemandStage`, and the label comes from `@pig/core`
* so a renamed stage renames here too. An unrecognised value is shown as
* written rather than dropped: content authored ahead of an ontology change is
* still worth reading.
*/
function stageLabel(value: unknown): string | null {
const raw = text(value);
if (!raw) return null;
return DEMAND_STAGE_LABELS[raw as DemandStage] ?? raw;
}
@@ -0,0 +1,263 @@
/**
* Half of the loop: library template → engagement artefact.
*
* The picker is a list rather than a grid of `TemplateCard`s, because the
* question being answered here is not "what is in the library" but "which of
* these four is the one for this stage" — so the rows are dense, the stage
* filter opens pre-set to the deal's own stage, and usage is on every row.
* Usage is the only quality signal the library has: a v3 used eleven times is
* tested and a v1 used never is somebody's draft, and that distinction matters
* more when copying into a live deal than when browsing.
*
* Instantiating copies the body and fields and increments the template's
* usage count, which is what closes the template to in-place edits (§7a). That
* is stated on the dialog rather than left to be discovered later by whoever
* tries to fix a typo in it.
*/
import { useDeferredValue, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Library, RefreshCw, Search } from 'lucide-react';
import { toast } from 'sonner';
import {
DEMAND_STAGES,
DEMAND_STAGE_LABELS,
MOTION_KINDS,
MOTION_KIND_LABELS,
type DemandStage,
type MotionKind,
} from '@pig/core';
import { get, post } from '@/lib/api';
import { Badge, Button, EmptyState, Input, Skeleton } from '@/components/ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MotionKindBadge } from './MotionKindBadge';
/** `GET /api/motion/templates` — summaries, so no body and no fields. */
interface TemplateRow {
id: string;
kind: MotionKind;
slug: string;
version: number;
title: string;
summary: string;
stage: DemandStage;
visibility: 'private' | 'shared';
isSystem: boolean;
usageCount: number;
updatedAt: string;
}
export function InstantiateDialog({
engagementId,
defaultStage = null,
defaultKind = null,
open,
onOpenChange,
}: {
engagementId: string;
/** The deal's stage, so the list opens on the templates that serve it. */
defaultStage?: DemandStage | null;
/** Set when the caller is after one kind — a framework to score against. */
defaultKind?: MotionKind | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const [kind, setKind] = useState<'all' | MotionKind>(defaultKind ?? 'all');
const [stage, setStage] = useState<'all' | DemandStage>(defaultStage ?? 'all');
const [query, setQuery] = useState('');
const search = useDeferredValue(query.trim());
const templates = useQuery({
queryKey: ['motion', 'library', { kind, stage, search }],
queryFn: () => {
const params = new URLSearchParams();
if (kind !== 'all') params.set('kind', kind);
if (stage !== 'all') params.set('stage', stage);
if (search) params.set('q', search);
const suffix = params.toString();
return get<{ templates: TemplateRow[]; truncated: boolean }>(
`/api/motion/templates${suffix ? `?${suffix}` : ''}`,
);
},
enabled: open,
});
const instantiate = useMutation({
mutationFn: (templateId: string) =>
post<{ artifact: { id: string } }>(`/api/motion/engagements/${engagementId}/artifacts`, {
templateId,
}),
onSuccess: async () => {
// The prefix invalidation is deliberate: instantiating moves the
// template's usage count too, so the library and the OS home are stale
// the moment this succeeds, not only the engagement.
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success('Artefact added to the engagement');
onOpenChange(false);
},
onError: (error: Error) => toast.error(error.message),
});
const rows = templates.data?.templates ?? [];
const filtered = kind !== 'all' || stage !== 'all' || Boolean(search);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85dvh] w-[calc(100vw-1.5rem)] max-w-2xl grid-rows-[auto_auto_minmax(0,1fr)] overflow-hidden p-4 sm:p-6">
<DialogHeader className="pr-11">
<DialogTitle>Instantiate from the library</DialogTitle>
<DialogDescription>
The body and structured fields are copied into this engagement, and the template is
closed to in-place edits from here on later changes to it become a new version.
</DialogDescription>
</DialogHeader>
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_9.5rem_9.5rem]">
<div className="relative min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden />
<Input
aria-label="Search the library"
className="pl-9"
placeholder="Search title or summary"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<Select value={kind} onValueChange={(value) => setKind(value as typeof kind)}>
<SelectTrigger aria-label="Filter by kind" className="h-11 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All kinds</SelectItem>
{MOTION_KINDS.map((value) => (
<SelectItem key={value} value={value}>
{MOTION_KIND_LABELS[value]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select value={stage} onValueChange={(value) => setStage(value as typeof stage)}>
<SelectTrigger aria-label="Filter by stage" className="h-11 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All stages</SelectItem>
{DEMAND_STAGES.map((value) => (
<SelectItem key={value} value={value}>
{DEMAND_STAGE_LABELS[value]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="min-w-0 overflow-y-auto">
{templates.isLoading ? (
<div className="flex flex-col gap-2">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-20" />
))}
</div>
) : null}
{templates.isError ? (
<EmptyState
icon={<AlertTriangle />}
title="Library unavailable"
description={
templates.error instanceof Error
? templates.error.message
: 'The library could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void templates.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
) : null}
{!templates.isLoading && !templates.isError && rows.length === 0 ? (
<EmptyState
icon={<Library />}
title={filtered ? 'No template matches' : 'The library is empty'}
description={
filtered
? 'Clear a filter, or write the artefact from scratch and promote it once it has proved itself.'
: 'Nothing has been published yet. An artefact written here can be promoted into the library once it is final.'
}
/>
) : null}
{rows.length ? (
<ul className="flex min-w-0 flex-col gap-2">
{rows.map((template) => (
<li
key={template.id}
className="flex min-w-0 flex-col gap-2 rounded-xl border border-border p-3 sm:flex-row sm:items-center"
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<MotionKindBadge kind={template.kind} />
<Badge tone="neutral" className="min-w-0">
<span className="truncate">{DEMAND_STAGE_LABELS[template.stage]}</span>
</Badge>
<span className="nums whitespace-nowrap text-xs text-muted">
v{template.version} ·{' '}
{template.usageCount === 1
? 'used once'
: `used ${template.usageCount} times`}
</span>
</div>
<p className="mt-1.5 min-w-0 break-words font-medium leading-snug">
{template.title}
</p>
{template.summary ? (
<p className="mt-0.5 line-clamp-2 min-w-0 break-words text-sm leading-6 text-muted">
{template.summary}
</p>
) : null}
</div>
<Button
type="button"
variant="primary"
className="w-full shrink-0 sm:w-auto"
disabled={instantiate.isPending}
onClick={() => instantiate.mutate(template.id)}
>
Use this
</Button>
</li>
))}
</ul>
) : null}
{templates.data?.truncated ? (
<p className="mt-3 text-xs text-muted">
The library is wider than this answer. Narrow it with a kind, a stage or a search.
</p>
) : null}
</div>
</DialogContent>
</Dialog>
);
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Motion bodies, rendered as markdown.
*
* A template body is a document — a discovery guide, a playbook, a narrative —
* and the app had nowhere to render one: Piggy's transcript uses Streamdown,
* which is built for a half-finished token stream in a 22rem dock, not for a
* page of authored prose. This is the page-width counterpart, on plain
* `react-markdown` with GFM for the tables and task lists the content uses.
*
* Every element is styled from the map below. There is no
* `@tailwindcss/typography` in this repo and one is deliberately not being
* added for this, so there is no `prose` to fall back on and an element absent
* from the map renders with bare browser defaults.
*
* `rehypePlugins` is deliberately empty. Without `rehype-raw`, react-markdown
* does not render embedded HTML at all, and its default `urlTransform` already
* drops `javascript:` and other non-navigational protocols — so the safe
* behaviour here is the behaviour of adding nothing. Template bodies are
* author-written but they are also promoted out of engagement artefacts that
* anyone with `motion:write` can edit, so they are treated as untrusted.
*/
import { isValidElement, type ComponentProps, type CSSProperties, type ReactNode } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ArrowUpRight } from 'lucide-react';
import { cn } from '@/components/ui';
import { CodeBlock } from './CodeBlock';
/** Fenced blocks carry their language as `language-<name>` on the `code` element. */
const LANGUAGE_CLASS = /language-([\w-]+)/;
export function Markdown({ content, className }: { content: string; className?: string }) {
return (
<div
className={cn(
// Block rhythm lives on the container rather than on each element, so
// the spacing between a heading and the paragraph under it does not
// depend on which of the two carries the margin.
'min-w-0 space-y-4 break-words text-sm leading-6 text-fg [&>*:first-child]:pt-0',
className,
)}
>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
{content}
</ReactMarkdown>
</div>
);
}
const MARKDOWN_COMPONENTS: Components = {
p: ({ children }) => <p className="leading-6">{children}</p>,
/*
* Headings buy their air with padding, not margin — the container's
* `space-y-4` sets the gap below, and a margin above would be collapsed
* against it inconsistently. The scale stays close to body size: these are
* section headings inside a card, not the page's own title.
*/
h1: ({ children }) => <h1 className="pt-4 text-xl font-semibold tracking-tight">{children}</h1>,
h2: ({ children }) => <h2 className="pt-4 text-lg font-semibold tracking-tight">{children}</h2>,
h3: ({ children }) => <h3 className="pt-3 text-base font-semibold">{children}</h3>,
h4: ({ children }) => <h4 className="pt-2 text-sm font-semibold">{children}</h4>,
h5: ({ children }) => <h5 className="pt-2 text-sm font-medium text-muted">{children}</h5>,
h6: ({ children }) => (
<h6 className="pt-2 text-xs font-medium uppercase tracking-wide text-muted">{children}</h6>
),
ul: ({ children }) => <ul className="list-disc space-y-1.5 pl-5 marker:text-muted">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal space-y-1.5 pl-5 marker:text-muted">{children}</ol>,
// A nested list is the first *element* child of its item even when prose
// precedes it, so the parent's `space-y` never reaches it.
li: ({ children }) => <li className="leading-6 [&>ol]:mt-1.5 [&>ul]:mt-1.5">{children}</li>,
strong: ({ children }) => <strong className="font-semibold text-fg">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
del: ({ children }) => <del className="text-muted line-through">{children}</del>,
a: MarkdownLink,
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-4 text-muted [&>*+*]:mt-2">
{children}
</blockquote>
),
hr: () => <hr className="border-border" />,
img: ({ src, alt }) => (
// `referrerPolicy` so an image URL that arrived with a promoted artefact
// cannot use the referer to learn which template the reader had open.
<img
src={typeof src === 'string' ? src : undefined}
alt={alt ?? ''}
loading="lazy"
referrerPolicy="no-referrer"
className="max-w-full rounded-lg border border-border"
/>
),
/*
* The fence chrome is built entirely in `pre`, which never renders the
* `code` element react-markdown handed it — it reads the language and the
* text off it instead. That is what makes the `code` entry below reachable
* only for inline code: react-markdown 10 stopped passing an `inline` flag,
* and the usual replacement — guessing from the `language-` class — gets a
* fenced block with no language wrong every time.
*/
pre: ({ children }) => <CodeFence>{children}</CodeFence>,
code: ({ children }) => (
<code className="rounded border border-border bg-surface-2 px-1 py-0.5 font-mono text-[0.85em]">
{children}
</code>
),
table: ({ children }) => (
// Without this the widest table on the page sets the width of the page,
// and every route scrolls sideways on a phone.
<div className="scroll-x rounded-lg border border-border">
{/* `w-max min-w-full`: fill the box when the table is narrow, spill into
the scroller rather than squash the columns when it is not. */}
<table className="w-max min-w-full border-collapse text-left text-[13px] leading-5">
{children}
</table>
</div>
),
thead: ({ children }) => <thead className="border-b border-border bg-surface-2">{children}</thead>,
tbody: ({ children }) => <tbody className="divide-y divide-border">{children}</tbody>,
// A row highlight is what lets you keep your place across a table that is
// wider than the pane and has been scrolled sideways.
tr: ({ children }) => <tr className="transition-colors hover:bg-surface-2">{children}</tr>,
th: ({ children, style }) => (
<th
className="whitespace-nowrap px-3 py-2 align-bottom font-medium text-muted"
style={alignStyle(style)}
>
{children}
</th>
),
// No `nums` here, deliberately: tabular figures on every cell render an
// author's prose column in the digit-width of a ledger.
td: ({ children, style }) => (
<td className="px-3 py-2 align-top" style={alignStyle(style)}>
{children}
</td>
),
};
/**
* Links in a template body point out of PIG — a vendor's docs, a paper, a
* customer's status page — so they open in a new tab rather than navigating a
* workspace someone has unsaved edits in, carry no referer, and wear a marker
* glyph so a plausible phrase cannot pass itself off as internal navigation.
*/
function MarkdownLink({ href, children }: ComponentProps<'a'>) {
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
title={href}
className="font-medium text-info underline decoration-border underline-offset-2 hover:decoration-info"
>
{children}
<ArrowUpRight className="ml-0.5 inline size-3 align-[-0.1em]" aria-hidden />
</a>
);
}
/**
* A fenced code block, built from the `code` element rather than around it.
*
* The chrome — the language caption and the copy button — comes from
* `CodeBlock`, so a mermaid fence inside a promoted body offers the same copy
* action a mermaid field does in `FieldsView`. They are the same thing to the
* person reading them.
*/
function CodeFence({ children }: { children: ReactNode }) {
const element = isValidElement<{ className?: string; children?: ReactNode }>(children)
? children
: null;
const language = LANGUAGE_CLASS.exec(element?.props.className ?? '')?.[1];
const text = codeText(element ? element.props.children : children);
return <CodeBlock source={text} language={language} />;
}
/**
* GFM column alignment — the `---:` in a delimiter row — is the one piece of
* element styling the markdown itself owns, and a currency column that
* silently reverts to the left is the difference between a readable table and
* a wall. It reaches the cell as `style.textAlign`; only that property is
* taken, so nothing else an author writes can style the page.
*/
function alignStyle(style: CSSProperties | undefined): CSSProperties | undefined {
const value = style?.textAlign;
return value === 'right' || value === 'center' || value === 'left' ? { textAlign: value } : undefined;
}
/** The fence body reaches us as React children, normally one text node deep. */
function codeText(children: ReactNode): string {
if (typeof children === 'string') return children;
if (Array.isArray(children)) return (children as ReactNode[]).map(codeText).join('');
if (isValidElement<{ children?: ReactNode }>(children)) return codeText(children.props.children);
return '';
}
@@ -0,0 +1,62 @@
/**
* Which of the nine kinds a template or artefact is.
*
* The tone is neutral for every kind, deliberately. The badge palette here is
* semantic — positive, warning, danger, info mean something about a number —
* and the kinds are a taxonomy, not a severity scale; colouring them would
* teach people to read "case study" as good news. The differentiation comes
* from the glyph instead, which is what lets a mixed list of artefacts be
* scanned by shape rather than read word by word.
*/
import {
BookOpen,
FileText,
FlaskConical,
Gauge,
MessagesSquare,
Network,
Route,
Search,
Tags,
type LucideIcon,
} from 'lucide-react';
import { MOTION_KIND_DESCRIPTIONS, MOTION_KIND_LABELS, type MotionKind } from '@pig/core';
import { Badge, cn } from '@/components/ui';
const KIND_ICONS: Record<MotionKind, LucideIcon> = {
discovery: Search,
qualification: Gauge,
poc: FlaskConical,
proposal: FileText,
pricing: Tags,
architecture: Network,
case_study: BookOpen,
narrative: MessagesSquare,
playbook: Route,
};
export function MotionKindBadge({
kind,
/** Glyph only, for a dense row where the label is already in the title. */
iconOnly = false,
className,
}: {
kind: MotionKind;
iconOnly?: boolean;
className?: string;
}) {
const Icon = KIND_ICONS[kind];
const label = MOTION_KIND_LABELS[kind];
return (
<Badge
tone="neutral"
// The description is the only definition of a kind most people will ever
// read, and there is nowhere else on a card to put it.
title={`${label}${MOTION_KIND_DESCRIPTIONS[kind]}`}
className={cn('min-w-0 max-w-full', className)}
>
<Icon className="size-3 shrink-0" aria-hidden />
{iconOnly ? <span className="sr-only">{label}</span> : <span className="truncate">{label}</span>}
</Badge>
);
}
@@ -0,0 +1,287 @@
/**
* Scoring a deal against a qualification framework.
*
* The arithmetic is `motionScoreBasisPoints` from `@pig/core` — the same pure
* function the API calls before it writes the row. That import is the whole
* design of this component: a client that computes the total itself would
* eventually disagree with the server about a number people act on, and a
* qualification score that reads 74% while you are filling the form in and
* 71% once it is saved is worse than one the form never showed at all.
* Nothing here posts a score; it posts the dimensions and lets the server
* recompute, so the two can never diverge even if this file is wrong.
*
* Every dimension must be answered before Save enables. A partial score is not
* a smaller score — the maximum shrinks with it, so answering only the three
* dimensions that went well produces a band of "Strategic" on a deal nobody
* has qualified. The running total shown while the form is incomplete is
* labelled provisional for exactly that reason.
*
* `fields` is authored JSON and arrives unvalidated, the same way it does in
* `FieldsView`: a dimension without an id cannot be posted (the API keys on
* it) and a dimension without anchors cannot be scored honestly, so both are
* dropped rather than rendered as an empty control.
*/
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
import { toast } from 'sonner';
import {
MOTION_MAX_DIMENSION_SCORE,
MOTION_MIN_DIMENSION_SCORE,
motionBand,
motionScoreBasisPoints,
type MotionDimensionScore,
} from '@pig/core';
import { post } from '@/lib/api';
import { Badge, Button, cn } from '@/components/ui';
import { Textarea } from '@/components/ui/textarea';
import { anchorScale, asRecord, number, text, type AnchorRow } from './fields';
import { percent } from './format';
/** A dimension this component was able to make sense of. */
interface ScorableDimension {
id: string;
name: string;
weight: number;
group: string | null;
why: string | null;
anchors: AnchorRow[];
}
export function QualificationScorer({
engagementId,
frameworkTemplateId,
fields,
previousBasisPoints = null,
onScored,
onCancel,
}: {
engagementId: string;
/** Recorded on the score so the framework behind it stays identifiable. */
frameworkTemplateId: string | null;
/** The framework's `fields`. Untrusted author JSON — see the header. */
fields: unknown;
/** The last score on this engagement, if there is one, for the movement. */
previousBasisPoints?: number | null;
onScored: () => void;
onCancel: () => void;
}) {
const queryClient = useQueryClient();
const dimensions = useMemo(() => scorableDimensions(fields), [fields]);
const [answers, setAnswers] = useState<Record<string, number>>({});
const [note, setNote] = useState('');
const answered: MotionDimensionScore[] = dimensions
.filter((dimension) => answers[dimension.id] !== undefined)
.map((dimension) => ({
id: dimension.id,
weight: dimension.weight,
score: answers[dimension.id] as number,
}));
const complete = dimensions.length > 0 && answered.length === dimensions.length;
const basisPoints = motionScoreBasisPoints(answered);
const band = motionBand(basisPoints);
const movement =
complete && previousBasisPoints !== null ? basisPoints - previousBasisPoints : null;
const save = useMutation({
mutationFn: () =>
post<{ score: { id: string } }>(`/api/motion/engagements/${engagementId}/scores`, {
dimensions: answered,
frameworkTemplateId,
note: note.trim() ? note.trim() : null,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success('Qualification scored');
onScored();
},
onError: (error: Error) => toast.error(error.message),
});
if (dimensions.length === 0) {
return (
<div className="mt-6 rounded-lg border border-border p-4">
<p className="font-medium">This framework has no scorable dimensions</p>
<p className="mt-1 text-sm text-muted">
A dimension needs an id, a name and at least one anchor before it can be scored. Fix
the framework in the library, then score against a new version of it.
</p>
<Button className="mt-4" type="button" variant="outline" onClick={onCancel}>
Close
</Button>
</div>
);
}
return (
<form
className="mt-5 flex min-w-0 flex-col gap-5 pb-6"
onSubmit={(event) => {
event.preventDefault();
save.mutate();
}}
>
{/* Sticky, because the reason to show a running total at all is to let
somebody see a marginal answer move the band while they are still
looking at the anchors that produced it. */}
<div className="sticky top-0 z-10 -mx-1 min-w-0 rounded-xl border border-border bg-surface p-4">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="nums text-3xl font-semibold leading-none">{percent(basisPoints)}</span>
<Badge tone={band.tone}>{band.label}</Badge>
{movement === null ? null : <Movement delta={movement} />}
</div>
<p className="mt-2 text-xs text-muted">
{complete ? (
<>
Weighted across {dimensions.length} dimensions ·{' '}
<span className="nums">{basisPoints}</span> basis points
</>
) : (
<>
Provisional {answered.length} of {dimensions.length} scored. The maximum shrinks
with the dimensions you leave blank, so this band is not the deal's band yet.
</>
)}
</p>
</div>
<div className="min-w-0 space-y-4">
{dimensions.map((dimension) => (
<fieldset key={dimension.id} className="min-w-0 rounded-xl border border-border p-3">
<legend className="flex min-w-0 flex-wrap items-center gap-2 px-1">
<span className="min-w-0 break-words font-medium leading-6">{dimension.name}</span>
{dimension.group ? <Badge tone="neutral">{dimension.group}</Badge> : null}
<span className="nums whitespace-nowrap text-xs text-muted">
{dimension.weight === 0 ? 'no weight' : `weight ${dimension.weight}`}
</span>
</legend>
{dimension.why ? (
<p className="min-w-0 break-words text-sm leading-6 text-muted">{dimension.why}</p>
) : null}
<div className="mt-2 min-w-0 space-y-1.5">
{dimension.anchors.map((anchor) => {
const selected = answers[dimension.id] === anchor.score;
return (
<label
key={anchor.score}
className={cn(
'tap flex min-h-11 min-w-0 cursor-pointer gap-3 rounded-lg border p-2.5',
selected ? 'border-brand bg-surface-2' : 'border-border hover:bg-surface-2',
)}
>
<input
type="radio"
className="sr-only"
name={`dimension-${dimension.id}`}
value={anchor.score}
checked={selected}
onChange={() =>
setAnswers((current) => ({ ...current, [dimension.id]: anchor.score }))
}
/>
<span
className={cn(
'nums flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold',
selected ? 'bg-primary text-primary-foreground' : 'bg-surface-2 text-muted',
)}
aria-hidden
>
{anchor.score}
</span>
<span className="min-w-0 break-words text-sm leading-6">{anchor.anchor}</span>
</label>
);
})}
</div>
</fieldset>
))}
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<label className="text-sm font-medium" htmlFor="qualification-note">
What moved since the last score
</label>
<Textarea
id="qualification-note"
className="min-h-24"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="The evidence behind the answers that changed."
/>
</div>
{save.isError ? <p className="text-sm text-danger">{save.error.message}</p> : null}
<div className="flex min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
disabled={!complete || save.isPending}
title={complete ? undefined : 'Score every dimension first.'}
>
Record score
</Button>
</div>
</form>
);
}
function Movement({ delta }: { delta: number }) {
if (delta === 0) {
return (
<span className="inline-flex items-center gap-1 text-xs text-muted">
<Minus className="size-3.5" aria-hidden />
Unchanged
</span>
);
}
const Icon = delta > 0 ? ArrowUpRight : ArrowDownRight;
return (
<span
className={cn(
'nums inline-flex items-center gap-1 text-xs font-medium',
delta > 0 ? 'text-positive' : 'text-danger',
)}
>
<Icon className="size-3.5" aria-hidden />
{delta > 0 ? '+' : ''}
{percent(Math.abs(delta))}
</span>
);
}
// ------------------------------------------------------------------ narrowing
function scorableDimensions(fields: unknown): ScorableDimension[] {
const record = asRecord(fields);
const raw = Array.isArray(record?.dimensions) ? record.dimensions : [];
const seen = new Set<string>();
const scorable: ScorableDimension[] = [];
for (const entry of raw) {
const dimension = asRecord(entry);
if (!dimension) continue;
const id = text(dimension.id);
const name = text(dimension.name);
if (!id || !name || seen.has(id)) continue;
const anchors = anchorScale(dimension.anchors);
if (anchors.length === 0) continue;
seen.add(id);
scorable.push({
id,
name,
// Clamped to the API's own bound rather than trusted: a weight the seed
// author typed as 1500 would otherwise fail zod after the whole form is
// filled in, which reads as the save being broken.
weight: Math.min(1_000, Math.max(0, Math.round(number(dimension.weight) ?? 0))),
group: text(dimension.group),
why: text(dimension.why),
anchors,
});
}
return scorable;
}
@@ -0,0 +1,98 @@
/**
* The demand motion, across the top.
*
* The eight open stages are the spine of the whole feature — Motion binds
* artefacts to them rather than inventing a second pipeline — so the rail is
* the one control that appears on the OS home, the library filter and the
* engagement workspace, and it has to mean the same thing in all three.
*
* The closed stages are absent because `DEMAND_OPEN_STAGES` is the default:
* a won or lost deal has left the motion, and a rail segment nobody can file
* work against is a segment people ask about once a quarter.
*
* It scrolls horizontally rather than wrapping. Wrapping puts `procurement`
* under `qualification` at 393px, which reads as a second row of the sequence
* starting over; a scroller keeps the order legible and keeps the overflow
* inside this box instead of dragging the page sideways.
*/
import { DEMAND_OPEN_STAGES, DEMAND_STAGE_LABELS, type DemandStage } from '@pig/core';
import { cn } from '@/components/ui';
export function StageRail({
counts,
coverage,
stages = DEMAND_OPEN_STAGES,
countLabel = 'engagements',
coverageLabel = 'templates',
activeStage,
onSelect,
className,
}: {
/** The headline figure per stage. A stage absent from the record reads as 0. */
counts: Partial<Record<DemandStage, number>>;
/** The second figure, if the caller has one — library cover, typically. */
coverage?: Partial<Record<DemandStage, number>>;
stages?: readonly DemandStage[];
countLabel?: string;
coverageLabel?: string;
activeStage?: DemandStage | null;
/** Omit to render a read-only rail: a non-interactive button is a trap. */
onSelect?: (stage: DemandStage) => void;
className?: string;
}) {
return (
<div className={cn('scroll-x min-w-0 pb-1', className)}>
<ol className="flex min-w-0 items-stretch gap-2">
{stages.map((stage) => {
const count = counts[stage] ?? 0;
const covered = coverage?.[stage];
const active = activeStage === stage;
const body = (
<>
<span className="truncate text-xs font-medium uppercase tracking-wide text-muted">
{DEMAND_STAGE_LABELS[stage]}
</span>
<span className={cn('nums text-2xl font-semibold leading-none', count === 0 && 'text-muted')}>
{count}
</span>
{covered === undefined ? null : (
// A stage with no template is the finding this rail exists to
// surface — it is where the motion stops repeating — so it is
// called out rather than shown as another grey zero.
<span className={cn('nums truncate text-xs', covered === 0 ? 'text-warning' : 'text-muted')}>
{covered === 0 ? `No ${coverageLabel}` : `${covered} ${coverageLabel}`}
</span>
)}
</>
);
const shape = cn(
'tap flex w-[8.5rem] shrink-0 flex-col justify-between gap-2 rounded-xl border p-3 text-left',
active ? 'border-brand bg-surface-2' : 'border-border bg-surface',
);
return (
<li key={stage} className="flex min-w-0">
{onSelect ? (
<button
type="button"
onClick={() => onSelect(stage)}
aria-pressed={active}
aria-label={`${DEMAND_STAGE_LABELS[stage]}: ${count} ${countLabel}`}
className={cn(shape, 'transition-colors hover:bg-surface-2')}
>
{body}
</button>
) : (
<div className={shape} aria-label={`${DEMAND_STAGE_LABELS[stage]}: ${count} ${countLabel}`}>
{body}
</div>
)}
</li>
);
})}
</ol>
</div>
);
}
@@ -0,0 +1,88 @@
/**
* One library template, as a browsable card.
*
* The three facts on it that are not the title are the ones that decide
* whether it is worth opening: who can see it, how many engagements have
* already used it, and whether it came back out of one. Usage is the closest
* thing the library has to a quality signal — a v3 used eleven times is
* tested, a v1 used never is a draft somebody left — and hiding it behind a
* click is what turns a library into a folder.
*/
import { Link } from 'react-router-dom';
import { Lock, Sparkles, Users } from 'lucide-react';
import { DEMAND_STAGE_LABELS } from '@pig/core';
import { Badge, Card, cn } from '@/components/ui';
import { MotionKindBadge } from './MotionKindBadge';
import type { MotionTemplateView } from './model';
import type { ReactNode } from 'react';
export function TemplateCard({
template,
to,
footer,
className,
}: {
template: MotionTemplateView;
/** Defaults to the template's own page; pass a filtered return path instead. */
to?: string;
/** Actions belonging to the calling page — instantiate, publish, fork. */
footer?: ReactNode;
className?: string;
}) {
return (
<Card className={cn('flex min-w-0 flex-col gap-3 p-4', className)}>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<MotionKindBadge kind={template.kind} />
<Badge tone="neutral" className="min-w-0">
<span className="truncate">{DEMAND_STAGE_LABELS[template.stage]}</span>
</Badge>
{template.visibility === 'private' ? (
<Badge tone="warning" title="Only you and a platform admin can see this">
<Lock className="size-3 shrink-0" aria-hidden />
Private
</Badge>
) : null}
{template.isSystem ? (
<Badge tone="neutral" title="Shipped with PIG rather than authored here">
<Sparkles className="size-3 shrink-0" aria-hidden />
Starter
</Badge>
) : null}
</div>
<div className="min-w-0">
{/* break-words, not truncate: the title is the only way to tell two
versions of the same lineage apart, and an unbroken word at 393px
is what drags the whole page sideways. */}
<h3 className="min-w-0 break-words font-semibold leading-snug">
<Link
to={to ?? `/motion/library/${template.id}`}
className="underline-offset-4 hover:text-accent-fg hover:underline"
>
{template.title}
</Link>
</h3>
{template.summary ? (
<p className="mt-1 line-clamp-2 min-w-0 break-words text-sm leading-6 text-muted">
{template.summary}
</p>
) : null}
</div>
<div className="mt-auto flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
<span className="nums whitespace-nowrap">v{template.version}</span>
<span className="nums inline-flex min-w-0 items-center gap-1 whitespace-nowrap">
<Users className="size-3 shrink-0" aria-hidden />
{template.usageCount === 1 ? 'Used once' : `Used ${template.usageCount} times`}
</span>
{template.originArtifactId ? (
// The loop, made visible. This row exists because an engagement
// proved it, which is the whole argument for the feature.
<span className="min-w-0 truncate">Promoted from an engagement</span>
) : null}
</div>
{footer ? <div className="flex min-w-0 flex-wrap items-center gap-2">{footer}</div> : null}
</Card>
);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Narrowing the authored JSON a template carries in `fields`.
*
* One rule per shape, in one place, because there are two readers of the same
* bytes and they must not disagree. `FieldsView` renders a qualification
* framework's dimensions and `QualificationScorer` turns the same dimensions
* into a form — so the day whitespace-only anchors are made to render as an
* em-dash rather than vanish, a second copy of `text()` would leave the scorer
* still dropping them, and the reader would be shown five dimensions while the
* score was computed against four.
*
* Everything here is total: an unrecognised shape yields null or an empty
* list, never a throw. An omitted section is a bug someone reports; a white
* screen on one badly-typed row is an outage.
*/
import { MOTION_MAX_DIMENSION_SCORE, MOTION_MIN_DIMENSION_SCORE } from '@pig/core';
export function asRecord(value: unknown): Record<string, unknown> | null {
// `typeof null` is 'object' and an array is one too; both would satisfy a
// naive check and then read `undefined` off every property.
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
/** A non-empty string, or null. Whitespace-only is treated as absent. */
export function text(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length === 0 ? null : trimmed;
}
export function number(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
/** The entries of a list that are objects. Anything else is dropped silently. */
export function recordList(value: unknown): Record<string, unknown>[] {
if (!Array.isArray(value)) return [];
return value
.map((entry) => asRecord(entry))
.filter((entry): entry is Record<string, unknown> => entry !== null);
}
export function textList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((entry) => text(entry)).filter((entry): entry is string => entry !== null);
}
export interface AnchorRow {
score: number;
anchor: string;
}
/**
* The 04 scale, from `@pig/core` rather than from the keys present in the
* JSON, so a framework that forgot to write an anchor for 2 still offers the
* same scale everywhere it is read.
*/
export function anchorScale(anchors: unknown): AnchorRow[] {
const record = asRecord(anchors);
if (!record) return [];
const rows: AnchorRow[] = [];
for (let score = MOTION_MIN_DIMENSION_SCORE; score <= MOTION_MAX_DIMENSION_SCORE; score += 1) {
const anchor = text(record[String(score)]);
if (anchor) rows.push({ score, anchor });
}
return rows;
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Display formatting shared by every page that renders a qualification score.
*
* One formatter, because the same 6250 was reading as `63%` on the Motion home
* and `62.5%` on the engagement that page links to — and the difference lands
* exactly where a band boundary does. It lived in `QualificationScorer` until
* three pages that do not otherwise touch the scorer were importing from it.
*/
/** Basis points as a percentage. Rounded, never truncated — AGENTS.md §4. */
export function percent(basisPoints: number): string {
return `${(Math.round(basisPoints / 10) / 10).toFixed(1)}%`;
}
+47
View File
@@ -0,0 +1,47 @@
/**
* The shape a template arrives in on the wire.
*
* It describes a *response*, not the server's own row, so it is declared here
* rather than imported from `@pig/db`: a column the API does not serialise must
* be absent, and one it may not have joined yet must be optional, or the
* compiler asserts a guarantee the JSON does not carry. The enums are the
* exception — those come from `@pig/core`, because a kind the ontology has
* dropped should stop compiling rather than keep rendering.
*
* `fields` is the deliberate hole in the type. It is author-written JSON whose
* shape varies by kind and is not validated on the way out of the database, so
* it arrives as `unknown` and is narrowed at the point of use in `FieldsView`.
* Typing it as the shape we hope for would move a runtime crash into a place
* where nobody is looking for it.
*
* There is only one type here on purpose. Eight more were written alongside it
* and never imported, and by the time anyone read them they no longer matched
* `services/motion.ts` — `EngagementDetail` promised `.artifacts` where the
* endpoint returns `.stages`. Each page declares the subset of the response it
* actually reads, which is checked against the `get<T>()` call that fetches it;
* a shared file that nothing imports is checked against nothing at all.
*/
import type { DemandStage, MotionKind, MotionVisibility } from '@pig/core';
export interface MotionTemplateView {
id: string;
kind: MotionKind;
/** Stable across versions — the identity of the lineage, not of the row. */
slug: string;
version: number;
title: string;
summary: string;
body: string;
fields: unknown;
stage: DemandStage;
visibility: MotionVisibility;
ownerUserId: string | null;
ownerName?: string | null;
supersedesId: string | null;
originArtifactId: string | null;
isSystem: boolean;
usageCount: number;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
}