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
+2
View File
@@ -38,7 +38,9 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.85.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.1.1",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.8",
"streamdown": "^2.5.0",
"tailwind-merge": "^2.6.0",
+18
View File
@@ -36,6 +36,14 @@ const Piggy = lazy(() => import('@/pages/Piggy').then(({ Piggy }) => ({ default:
const Growth = lazy(() => import('@/pages/Growth').then(({ Growth }) => ({ default: Growth })));
const Calendar = lazy(() => import('@/pages/Calendar').then(({ Calendar }) => ({ default: Calendar })));
const Learn = lazy(() => import('@/pages/Learn').then(({ Learn }) => ({ default: Learn })));
// Lazy is load-bearing for these five and not merely conventional: they are the
// only pages that pull react-markdown and remark-gfm, and an eager import would
// put a markdown parser into the entry chunk every route pays for.
const Motion = lazy(() => import('@/pages/Motion').then(({ Motion }) => ({ default: Motion })));
const MotionLibrary = lazy(() => import('@/pages/MotionLibrary').then(({ MotionLibrary }) => ({ default: MotionLibrary })));
const MotionTemplate = lazy(() => import('@/pages/MotionTemplate').then(({ MotionTemplate }) => ({ default: MotionTemplate })));
const MotionEngagements = lazy(() => import('@/pages/MotionEngagements').then(({ MotionEngagements }) => ({ default: MotionEngagements })));
const Engagement = lazy(() => import('@/pages/Engagement').then(({ Engagement }) => ({ default: Engagement })));
const queryClient = new QueryClient({
defaultOptions: {
@@ -248,6 +256,16 @@ function AppRoutes() {
the next four record routes will read.
*/}
<Route path="accounts/:id" element={<RoutePage><Account /></RoutePage>} />
{/*
Flat, in the register of the routes above — nesting these under a
layout route would give Motion a chrome no other group has, and the
five pages share no shell of their own.
*/}
<Route path="motion" element={<RoutePage><Motion /></RoutePage>} />
<Route path="motion/library" element={<RoutePage><MotionLibrary /></RoutePage>} />
<Route path="motion/library/:id" element={<RoutePage><MotionTemplate /></RoutePage>} />
<Route path="motion/engagements" element={<RoutePage><MotionEngagements /></RoutePage>} />
<Route path="motion/engagements/:id" element={<RoutePage><Engagement /></RoutePage>} />
<Route path="contracts" element={<RoutePage><Contracts /></RoutePage>} />
<Route path="imports" element={<RoutePage><Imports /></RoutePage>} />
<Route path="piggy" element={<RoutePage><Piggy /></RoutePage>} />
@@ -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;
}
+5 -1
View File
@@ -209,7 +209,11 @@ export function Stat({
: 'text-fg';
return (
<div className="card p-4">
// `min-w-0` for the reason `Card` carries it: `.card` does not, and a stat
// is always a grid child whose figure is `tabular-nums` and whose hint does
// not wrap mid-word. Without it a three-up row on a 393px phone refuses to
// shrink and the page scrolls sideways (AGENTS.md §5).
<div className="card min-w-0 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-muted">{label}</div>
<div className={cn('nums mt-1 text-2xl font-semibold leading-tight sm:text-3xl', toneClass)}>
{value}
+27 -5
View File
@@ -20,8 +20,11 @@ import {
FileSpreadsheet,
FileText,
GraduationCap,
Handshake,
LayoutDashboard,
Library,
MessageCircleMore,
Route,
Server,
Settings,
ShieldCheck,
@@ -33,7 +36,7 @@ import {
import type { Capability, Team } from '@pig/core';
import { canAny, type PermissionIdentity } from './permissions';
export const NAV_GROUPS = ['Intelligence', 'Marketplace', 'Records', 'Control'] as const;
export const NAV_GROUPS = ['Intelligence', 'Motion', 'Marketplace', 'Records', 'Control'] as const;
export type NavGroup = (typeof NAV_GROUPS)[number];
export interface NavItem {
@@ -61,6 +64,15 @@ export const NAV: NavItem[] = [
{ to: '/piggy', label: 'Piggy', icon: MessageCircleMore, group: 'Intelligence' },
{ to: '/learn', label: 'Learn', icon: GraduationCap, group: 'Intelligence' },
{ to: '/margin', label: 'Margin', icon: TrendingUp, group: 'Intelligence', primary: true },
// None of these is `primary`: the phone tab bar holds five and the ledger
// pages already hold all five. The three routes are reachable from the
// sidebar and from ⌘K, which a NavItem earns by structurally satisfying
// CommandDestination. Reads are gated on `book:read` like Accounts and
// Contracts, so no `requires` — the write capabilities are enforced per
// control on the page, not by hiding the destination.
{ to: '/motion', label: 'Motion', icon: Route, group: 'Motion' },
{ to: '/motion/library', label: 'Library', icon: Library, group: 'Motion' },
{ to: '/motion/engagements', label: 'Engagements', icon: Handshake, group: 'Motion' },
{ to: '/capacity', label: 'Capacity', icon: Server, group: 'Marketplace', primary: true },
{ to: '/demand', label: 'Demand', icon: Building2, group: 'Marketplace', primary: true },
{ to: '/supply', label: 'Supply', icon: Boxes, group: 'Marketplace', primary: true },
@@ -106,9 +118,19 @@ export function visibleNav(identity: PermissionIdentity | undefined): NavItem[]
});
}
/** The nav entry a pathname belongs to, for the header title and active state. */
/**
* The nav entry a pathname belongs to, for the header title and active state.
*
* The longest match wins, not the first. Motion is the first group whose own
* destinations nest — `/motion` is a prefix of `/motion/library` — and a
* first-match scan titles the library page "Motion". Longest-match is a no-op
* for every other route in the table.
*/
export function activeNavItem(items: readonly NavItem[], pathname: string): NavItem | undefined {
return items.find((item) =>
item.to === '/' ? pathname === '/' : pathname === item.to || pathname.startsWith(`${item.to}/`),
);
let match: NavItem | undefined;
for (const item of items) {
if (item.to === '/' ? pathname !== '/' : pathname !== item.to && !pathname.startsWith(`${item.to}/`)) continue;
if (!match || item.to.length > match.to.length) match = item;
}
return match;
}
+993
View File
@@ -0,0 +1,993 @@
/**
* The engagement workspace: one demand deal, worked with the motion.
*
* The stage rail is the spine, and it is the *deal's* stages rather than any
* state of the engagement's own — an engagement that could disagree with its
* deal about what stage the work is at would be a second answer to a question
* that already has one.
*
* Two actions here are first-class because they are the loop: instantiate a
* template into a stage, and promote a finished artefact back into the library.
* Everything else on the page exists to make those two legible — the score
* history because qualification is a movement rather than a number, and the
* per-stage grouping because "what did we write at proposal" is the question
* somebody opening this page a quarter later is actually asking.
*
* The loading, missing and failed branches are early returns rather than
* sibling conditionals. That is the detail-page pattern in this repo
* (`Account.tsx` is the same shape, 404 branch included), and it differs from
* the list pages deliberately: a detail page has nothing to render around the
* hole.
*/
import { useMemo, useState, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import {
AlertTriangle,
ArrowLeft,
ArrowUpRight,
ChevronRight,
FileText,
Gauge,
Handshake,
Library,
Lock,
RefreshCw,
Sparkles,
} from 'lucide-react';
import { toast } from 'sonner';
import {
ARTIFACT_STATUSES,
ARTIFACT_STATUS_LABELS,
DEMAND_OPEN_STAGES,
DEMAND_STAGES,
DEMAND_STAGE_LABELS,
ENGAGEMENT_STATUSES,
ENGAGEMENT_STATUS_LABELS,
toPiggyPageRoute,
type ArtifactStatus,
type DemandStage,
type EngagementStatus,
type MotionBandTone,
type MotionKind,
type MotionVisibility,
} from '@pig/core';
import { ApiError, get, patch, post, shortDate } from '@/lib/api';
import { usePageTitle } from '@/lib/title';
import { usePiggyContext } from '@/lib/piggy-context';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import {
Badge,
Button,
Card,
CardContent,
EmptyState,
Input,
Skeleton,
Stat,
cn,
} from '@/components/ui';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { FieldsView } from '@/components/motion/FieldsView';
import { InstantiateDialog } from '@/components/motion/InstantiateDialog';
import { Markdown } from '@/components/motion/Markdown';
import { MotionKindBadge } from '@/components/motion/MotionKindBadge';
import { QualificationScorer } from '@/components/motion/QualificationScorer';
import { percent } from '@/components/motion/format';
import { StageRail } from '@/components/motion/StageRail';
// --------------------------------------------------------------------- wire
interface ScoreView {
id: string;
frameworkTemplateId: string | null;
dimensions: unknown;
basisPoints: number;
band: string;
tone: MotionBandTone;
note: string | null;
scoredByUserId: string | null;
scoredAt: string;
}
interface ArtifactView {
id: string;
engagementId: string;
templateId: string | null;
kind: MotionKind;
stage: DemandStage;
title: string;
body: string;
fields: unknown;
status: ArtifactStatus;
authoredByUserId: string | null;
promotedTemplateId: string | null;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
}
interface EngagementView {
id: string;
demandDealId: string;
dealName: string | null;
/** The deal's stage. An engagement has none of its own — that is the point. */
stage: DemandStage | null;
accountId: string | null;
accountName: string | null;
status: EngagementStatus;
summary: string | null;
ownerUserId: string | null;
playbookTemplateId: string | null;
openedAt: string;
closedAt: string | null;
artifactCount: number;
latestScore: ScoreView | null;
}
interface TemplateSummary {
id: string;
kind: MotionKind;
slug: string;
version: number;
title: string;
summary: string;
stage: DemandStage;
visibility: MotionVisibility;
usageCount: number;
}
/** `GET /api/motion/engagements/:id` — `stages` is all ten, always, in order. */
interface EngagementDetail {
engagement: EngagementView;
stages: { stage: DemandStage; artifacts: ArtifactView[] }[];
scores: ScoreView[];
playbook: TemplateSummary | null;
}
const WRITE_DENIED = 'Editing this engagement needs the motion:write permission.';
const PUBLISH_DENIED = 'Promoting into the library needs the motion:publish permission.';
// --------------------------------------------------------------------- page
export function Engagement() {
const { id = '' } = useParams<{ id: string }>();
const me = useIdentity();
const mayWrite = canAny(me, 'motion:write');
const mayPublish = canAny(me, 'motion:publish');
const [stageFilter, setStageFilter] = useState<DemandStage | null>(null);
const [instantiating, setInstantiating] = useState<{ kind: MotionKind | null } | null>(null);
const [scoring, setScoring] = useState(false);
const [promoting, setPromoting] = useState<ArtifactView | null>(null);
const detail = useQuery({
queryKey: ['motion', 'engagements', id, 'detail'],
queryFn: () => get<EngagementDetail>(`/api/motion/engagements/${id}`),
enabled: Boolean(id),
retry: false,
});
const engagement = detail.data?.engagement;
usePageTitle(engagement?.dealName ?? 'Engagement');
usePiggyContext({
type: 'page',
route: toPiggyPageRoute('/motion/engagements'),
label: engagement?.dealName ? `Engagement — ${engagement.dealName}` : 'Engagement',
});
const groups = detail.data?.stages ?? [];
const counts = useMemo(() => {
const record: Partial<Record<DemandStage, number>> = {};
for (const group of groups) record[group.stage] = group.artifacts.length;
return record;
}, [groups]);
if (!canAny(me, 'book:read')) {
return (
<Restricted
icon={<Lock />}
title="This engagement is restricted"
description="An engagement sits on a demand deal, and reading the book needs the book permission. Ask a platform administrator for team membership."
/>
);
}
if (detail.isLoading) {
return (
<div className="flex flex-col gap-4">
<BackLink />
<Skeleton className="h-32" />
<Skeleton className="h-28" />
<Skeleton className="h-96" />
</div>
);
}
if (detail.error instanceof ApiError && detail.error.status === 404) {
return (
<Restricted
icon={<Handshake />}
title="No such engagement"
description="It has been removed, or the link was to an id that never existed."
/>
);
}
if (detail.error || !detail.data || !engagement) {
return (
<div className="flex flex-col gap-4">
<BackLink />
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<AlertTriangle />}
title="Engagement unavailable"
description={
detail.error instanceof Error
? detail.error.message
: 'The workspace could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void detail.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
</CardContent>
</Card>
</div>
);
}
const artifacts = groups.flatMap((group) => group.artifacts);
const promoted = artifacts.filter((artifact) => artifact.promotedTemplateId).length;
const finalised = artifacts.filter((artifact) => artifact.status === 'final').length;
const frameworks = artifacts.filter((artifact) => artifact.kind === 'qualification');
const scores = detail.data.scores;
/*
* The eight open stages always, plus any closed stage that actually holds
* work or is where the deal now sits. A rail that hides `closed_won` would
* lose the case study written after the deal landed — which is precisely the
* artefact most worth promoting.
*/
const railStages = DEMAND_STAGES.filter(
(stage) =>
DEMAND_OPEN_STAGES.includes(stage) ||
(counts[stage] ?? 0) > 0 ||
stage === engagement.stage,
);
const visibleGroups = stageFilter
? groups.filter((group) => group.stage === stageFilter)
: groups.filter((group) => group.artifacts.length > 0);
return (
<div className="flex min-w-0 flex-col gap-5">
<BackLink />
<Header
engagement={engagement}
mayWrite={mayWrite}
onInstantiate={() => setInstantiating({ kind: null })}
/>
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4" aria-label="Engagement totals">
<Stat label="Artefacts" value={artifacts.length} hint="Not archived" />
<Stat label="Final" value={finalised} hint="Promotable" />
<Stat
label="Promoted"
value={promoted}
hint="Back into the library"
tone={promoted ? 'positive' : 'default'}
/>
<Stat
label="Qualification"
value={engagement.latestScore ? percent(engagement.latestScore.basisPoints) : '—'}
hint={engagement.latestScore ? engagement.latestScore.band : 'Never scored'}
tone={engagement.latestScore?.tone === 'danger' ? 'danger' : engagement.latestScore?.tone === 'warning' ? 'warning' : 'default'}
/>
</section>
<section aria-label="Artefacts by stage" className="flex min-w-0 flex-col gap-2">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<h2 className="font-semibold">Where the work sits</h2>
{stageFilter ? (
<Button variant="ghost" onClick={() => setStageFilter(null)}>
Show every stage
</Button>
) : null}
</div>
<StageRail
counts={counts}
stages={railStages}
countLabel="artefacts"
activeStage={stageFilter ?? engagement.stage}
onSelect={(stage) => setStageFilter((current) => (current === stage ? null : stage))}
/>
<p className="text-xs text-muted">
{stageFilter
? `Filtered to ${DEMAND_STAGE_LABELS[stageFilter]}.`
: engagement.stage
? `The deal is at ${DEMAND_STAGE_LABELS[engagement.stage]}. Select a stage to filter the artefacts below.`
: 'Select a stage to filter the artefacts below.'}
</p>
</section>
<Qualification
scores={scores}
frameworks={frameworks}
mayWrite={mayWrite}
onScore={() => setScoring(true)}
onInstantiateFramework={() => setInstantiating({ kind: 'qualification' })}
/>
{detail.data.playbook ? <Playbook playbook={detail.data.playbook} /> : null}
<section className="flex min-w-0 flex-col gap-3" aria-label="Artefacts">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<h2 className="font-semibold">Artefacts</h2>
<Button
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={() => setInstantiating({ kind: null })}
>
<Library aria-hidden />
Instantiate from library
</Button>
</div>
{visibleGroups.length === 0 || visibleGroups.every((group) => group.artifacts.length === 0) ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<FileText />}
title={stageFilter ? 'Nothing filed at this stage' : 'No artefacts yet'}
description={
stageFilter
? 'Instantiate a template that serves this stage, or clear the filter to see the rest.'
: 'Instantiate a discovery guide or a qualification framework from the library. What comes out of this engagement can be promoted back into it.'
}
action={
<Button
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={() => setInstantiating({ kind: null })}
>
Instantiate a template
</Button>
}
/>
</CardContent>
</Card>
) : (
visibleGroups.map((group) => (
<div key={group.stage} className="flex min-w-0 flex-col gap-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted">
{DEMAND_STAGE_LABELS[group.stage]}
</h3>
{group.artifacts.map((artifact) => (
<ArtifactPanel
key={artifact.id}
artifact={artifact}
mayWrite={mayWrite}
mayPublish={mayPublish}
onPromote={() => setPromoting(artifact)}
/>
))}
</div>
))
)}
</section>
{instantiating ? (
<InstantiateDialog
// Remounted per intent, so opening it to find a framework really does
// open on the frameworks rather than on whatever was filtered last.
key={instantiating.kind ?? 'any'}
engagementId={engagement.id}
defaultStage={engagement.stage}
defaultKind={instantiating.kind}
open
onOpenChange={(open) => {
if (!open) setInstantiating(null);
}}
/>
) : null}
<Sheet open={scoring} onOpenChange={setScoring}>
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-2xl">
<SheetHeader>
<SheetTitle>Score qualification</SheetTitle>
<SheetDescription>
Answered against the anchors, so two people reading the same evidence reach the same
number. Scores are appended, never edited the movement is the evidence that
qualification happened.
</SheetDescription>
</SheetHeader>
<ScoringPanel
engagementId={engagement.id}
frameworks={frameworks}
previousBasisPoints={scores[0]?.basisPoints ?? null}
onDone={() => setScoring(false)}
/>
</SheetContent>
</Sheet>
<PromoteSheet artifact={promoting} onClose={() => setPromoting(null)} />
</div>
);
}
// ------------------------------------------------------------------ sections
function Header({
engagement,
mayWrite,
onInstantiate,
}: {
engagement: EngagementView;
mayWrite: boolean;
onInstantiate: () => void;
}) {
const queryClient = useQueryClient();
const update = useMutation({
mutationFn: (status: EngagementStatus) =>
patch<{ engagement: { id: string } }>(`/api/motion/engagements/${engagement.id}`, { status }),
onSuccess: async (_data, status) => {
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success(`Engagement marked ${ENGAGEMENT_STATUS_LABELS[status].toLocaleLowerCase()}`);
},
onError: (error: Error) => toast.error(error.message),
});
return (
<header className="flex min-w-0 flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{engagement.stage ? (
<Badge tone="neutral" className="min-w-0">
<span className="truncate">{DEMAND_STAGE_LABELS[engagement.stage]}</span>
</Badge>
) : null}
<Badge tone={statusTone(engagement.status)}>
{ENGAGEMENT_STATUS_LABELS[engagement.status]}
</Badge>
<span className="nums text-xs text-muted">
Opened {shortDate(engagement.openedAt)}
{engagement.closedAt ? ` · closed ${shortDate(engagement.closedAt)}` : ''}
</span>
</div>
<h1 className="mt-1 min-w-0 break-words text-xl font-semibold tracking-tight sm:text-2xl">
{engagement.dealName ?? 'Untitled deal'}
</h1>
<p className="mt-1 min-w-0 truncate text-sm text-muted">
{engagement.accountId ? (
<Link
to={`/accounts/${engagement.accountId}`}
className="underline-offset-4 hover:text-accent-fg hover:underline"
>
{engagement.accountName ?? 'Unknown account'}
</Link>
) : (
(engagement.accountName ?? 'Unknown account')
)}
</p>
{engagement.summary ? (
<p className="mt-2 max-w-2xl min-w-0 break-words text-sm leading-6 text-muted">
{engagement.summary}
</p>
) : null}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Select
value={engagement.status}
disabled={!mayWrite || update.isPending}
onValueChange={(value) => update.mutate(value as EngagementStatus)}
>
<SelectTrigger
aria-label="Engagement status"
title={mayWrite ? undefined : WRITE_DENIED}
className="h-11 w-40 min-w-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ENGAGEMENT_STATUSES.map((status) => (
<SelectItem key={status} value={status}>
{ENGAGEMENT_STATUS_LABELS[status]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={onInstantiate}
>
<Library aria-hidden />
Instantiate
</Button>
</div>
</header>
);
}
/**
* The score, and what it did.
*
* The number on its own answers a question nobody asks twice. What a review
* actually needs is the direction: a deal that has moved 5,800 → 7,100 across
* three weeks is a different deal from one that has sat at 7,100 since it was
* opened, and the second one is the one where nothing has been learned.
*/
function Qualification({
scores,
frameworks,
mayWrite,
onScore,
onInstantiateFramework,
}: {
scores: ScoreView[];
frameworks: ArtifactView[];
mayWrite: boolean;
onScore: () => void;
onInstantiateFramework: () => void;
}) {
const latest = scores[0] ?? null;
const scorable = frameworks.length > 0;
return (
<Card className="flex min-w-0 flex-col gap-4 p-4 sm:p-5">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="font-semibold">Qualification</h2>
<p className="mt-0.5 text-sm text-muted">
Weighted dimensions, scored against anchors. The band is what gets acted on.
</p>
</div>
{scorable ? (
<Button
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={onScore}
>
<Gauge aria-hidden />
{latest ? 'Score again' : 'Score this deal'}
</Button>
) : (
<Button
variant="outline"
disabled={!mayWrite}
title={
mayWrite
? 'Scoring needs a qualification framework in this engagement.'
: WRITE_DENIED
}
onClick={onInstantiateFramework}
>
Add a framework
</Button>
)}
</div>
{latest ? (
<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(latest.basisPoints)}
</span>
<Badge tone={latest.tone}>{latest.band}</Badge>
<span className="nums text-xs text-muted">scored {shortDate(latest.scoredAt)}</span>
</div>
) : (
<p className="text-sm text-muted">
{scorable
? 'Not scored yet. The framework is here; the first score is the baseline everything after is read against.'
: 'Not scored yet, and there is no framework in this engagement to score against. Instantiate one from the library.'}
</p>
)}
{scores.length > 1 ? (
<div className="min-w-0 border-t border-border pt-3">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Movement</h3>
<ol className="min-w-0 space-y-2">
{scores.map((score, index) => {
const previous = scores[index + 1];
const delta = previous ? score.basisPoints - previous.basisPoints : null;
return (
<li key={score.id} className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-3">
<span className="nums w-16 shrink-0 text-sm font-medium">
{percent(score.basisPoints)}
</span>
<span className="w-28 shrink-0">
<Badge tone={score.tone}>{score.band}</Badge>
</span>
<span
className={cn(
'nums w-20 shrink-0 text-xs font-medium',
delta === null ? 'text-muted' : delta > 0 ? 'text-positive' : delta < 0 ? 'text-danger' : 'text-muted',
)}
>
{delta === null
? 'baseline'
: delta === 0
? 'no change'
: `${delta > 0 ? '+' : ''}${percent(Math.abs(delta))}`}
</span>
<span className="min-w-0 flex-1 break-words text-xs text-muted">
{shortDate(score.scoredAt)}
{score.note ? ` · ${score.note}` : ''}
</span>
</li>
);
})}
</ol>
</div>
) : null}
</Card>
);
}
/** Which framework the scorer is filling in, when the engagement holds several. */
function ScoringPanel({
engagementId,
frameworks,
previousBasisPoints,
onDone,
}: {
engagementId: string;
frameworks: ArtifactView[];
previousBasisPoints: number | null;
onDone: () => void;
}) {
const [selected, setSelected] = useState(frameworks[0]?.id ?? '');
const framework = frameworks.find((artifact) => artifact.id === selected) ?? frameworks[0];
if (!framework) {
return (
<div className="mt-6 rounded-lg border border-border p-4">
<p className="font-medium">No framework in this engagement</p>
<p className="mt-1 text-sm text-muted">
Instantiate a qualification template first the dimensions and their anchors come from
it, and a score without them is a number two people would not agree on.
</p>
</div>
);
}
return (
<>
{frameworks.length > 1 ? (
<div className="mt-5 flex min-w-0 flex-col gap-1.5">
<Label htmlFor="scoring-framework">Framework</Label>
<Select value={framework.id} onValueChange={setSelected}>
<SelectTrigger id="scoring-framework" aria-label="Framework" className="h-11 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{frameworks.map((artifact) => (
<SelectItem key={artifact.id} value={artifact.id}>
{artifact.title}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
) : null}
<QualificationScorer
key={framework.id}
engagementId={engagementId}
// The template behind the artefact, not the artefact: the score records
// which framework produced it, and a from-scratch framework has none.
frameworkTemplateId={framework.templateId}
fields={framework.fields}
previousBasisPoints={previousBasisPoints}
onScored={onDone}
onCancel={onDone}
/>
</>
);
}
function Playbook({ playbook }: { playbook: TemplateSummary }) {
return (
<Card className="flex min-w-0 flex-col gap-2 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wide text-muted">Playbook</p>
<p className="mt-1 min-w-0 break-words font-medium">{playbook.title}</p>
<p className="mt-0.5 line-clamp-2 min-w-0 break-words text-sm text-muted">
{playbook.summary}
</p>
</div>
<Button asChild variant="outline" className="shrink-0">
<Link to={`/motion/library/${playbook.id}`}>
Open v{playbook.version}
<ArrowUpRight aria-hidden />
</Link>
</Button>
</Card>
);
}
/**
* One artefact, with the two controls that move it: its status, and promotion.
*
* Promotion is refused by the API for an artefact that is not final and for one
* already promoted, so the button is disabled with the reason on it rather than
* hidden — a control that vanishes teaches nothing, and "why can I not promote
* this" is a question the status select right beside it answers.
*/
function ArtifactPanel({
artifact,
mayWrite,
mayPublish,
onPromote,
}: {
artifact: ArtifactView;
mayWrite: boolean;
mayPublish: boolean;
onPromote: () => void;
}) {
const queryClient = useQueryClient();
const update = useMutation({
mutationFn: (status: ArtifactStatus) =>
patch<{ artifact: { id: string } }>(`/api/motion/artifacts/${artifact.id}`, { status }),
onSuccess: async (_data, status) => {
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success(`Marked ${ARTIFACT_STATUS_LABELS[status].toLocaleLowerCase()}`);
},
onError: (error: Error) => toast.error(error.message),
});
const promotable = artifact.status === 'final' && !artifact.promotedTemplateId && mayPublish;
const promoteReason = artifact.promotedTemplateId
? 'Already in the library. Promote a later revision instead.'
: artifact.status !== 'final'
? 'Only a final artefact may be promoted — the library is what the next deployment copies.'
: PUBLISH_DENIED;
return (
<Card className="flex min-w-0 flex-col gap-3 p-4">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<MotionKindBadge kind={artifact.kind} />
<Badge tone={artifactTone(artifact.status)}>{ARTIFACT_STATUS_LABELS[artifact.status]}</Badge>
{artifact.promotedTemplateId ? (
<Badge tone="positive" title="This artefact became a library version">
<Sparkles className="size-3 shrink-0" aria-hidden />
In the library
</Badge>
) : null}
{artifact.templateId ? null : (
<Badge tone="neutral" title="Written here rather than instantiated">
From scratch
</Badge>
)}
</div>
<div className="min-w-0">
<h4 className="min-w-0 break-words font-semibold leading-snug">{artifact.title}</h4>
<p className="nums mt-0.5 text-xs text-muted">Updated {shortDate(artifact.updatedAt)}</p>
</div>
<details className="group min-w-0">
<summary className="tap flex cursor-pointer items-center gap-1.5 text-sm font-medium text-muted hover:text-fg">
<ChevronRight className="size-4 transition-transform group-open:rotate-90" aria-hidden />
Read it
</summary>
<div className="mt-3 min-w-0 border-t border-border pt-3">
{artifact.body ? <Markdown content={artifact.body} /> : null}
<FieldsView kind={artifact.kind} fields={artifact.fields} className="mt-4" />
</div>
</details>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Select
value={artifact.status}
disabled={!mayWrite || update.isPending}
onValueChange={(value) => update.mutate(value as ArtifactStatus)}
>
<SelectTrigger
aria-label={`Status of ${artifact.title}`}
title={mayWrite ? undefined : WRITE_DENIED}
className="h-11 w-36 min-w-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ARTIFACT_STATUSES.map((status) => (
<SelectItem key={status} value={status}>
{ARTIFACT_STATUS_LABELS[status]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{artifact.promotedTemplateId ? (
<Button asChild variant="outline">
<Link to={`/motion/library/${artifact.promotedTemplateId}`}>
Open the library version
<ArrowUpRight aria-hidden />
</Link>
</Button>
) : (
<Button
variant="primary"
disabled={!promotable}
title={promotable ? undefined : promoteReason}
onClick={onPromote}
>
<Sparkles aria-hidden />
Promote to library
</Button>
)}
</div>
{promotable ? null : (
<p className="min-w-0 break-words text-xs text-muted">{promoteReason}</p>
)}
</Card>
);
}
/**
* Promotion, with the two fields the library reads and nothing else.
*
* The lineage is not offered as a control. An artefact instantiated from a
* template lands as the next version of that template's lineage, which is what
* makes "this came from playbook v3, and v4 is what we learned" true; letting
* somebody retarget it here would break the only chain that carries the claim.
*/
function PromoteSheet({ artifact, onClose }: { artifact: ArtifactView | null; onClose: () => void }) {
return (
<Sheet open={Boolean(artifact)} onOpenChange={(open) => !open && onClose()}>
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-lg">
{artifact ? <PromoteForm key={artifact.id} artifact={artifact} onDone={onClose} /> : null}
</SheetContent>
</Sheet>
);
}
function PromoteForm({ artifact, onDone }: { artifact: ArtifactView; onDone: () => void }) {
const queryClient = useQueryClient();
const [title, setTitle] = useState(artifact.title);
const [summary, setSummary] = useState('');
const promote = useMutation({
mutationFn: () =>
post<{ template: { id: string; version: number } }>(
`/api/motion/artifacts/${artifact.id}/promote`,
{
title: title.trim() || artifact.title,
...(summary.trim() ? { summary: summary.trim() } : {}),
},
),
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success(`Promoted as v${result.template.version}`);
onDone();
},
onError: (error: Error) => toast.error(error.message),
});
return (
<>
<SheetHeader>
<SheetTitle>Promote to the library</SheetTitle>
<SheetDescription>
{artifact.templateId
? 'This lands as the next version of the lineage it came from, pointing back at both its predecessor and this artifact.'
: 'This starts a new lineage at version 1, pointing back at this artefact as the engagement that proved it.'}{' '}
Promoted templates are shared with everyone.
</SheetDescription>
</SheetHeader>
<form
className="mt-5 flex min-w-0 flex-col gap-4 pb-6"
onSubmit={(event) => {
event.preventDefault();
promote.mutate();
}}
>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="promote-title">Title</Label>
<Input
id="promote-title"
required
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="promote-summary">Summary</Label>
<Textarea
id="promote-summary"
className="min-h-24"
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder="What the next person needs to know before copying this."
/>
<p className="text-xs text-muted">
Left blank, the previous version&apos;s summary carries over.
</p>
</div>
{promote.isError ? <p className="text-sm text-danger">{promote.error.message}</p> : null}
<div className="flex min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" onClick={onDone}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={promote.isPending}>
Promote
</Button>
</div>
</form>
</>
);
}
// ------------------------------------------------------------------- helpers
function BackLink() {
return (
<Link
to="/motion/engagements"
className="tap -ml-1 inline-flex w-fit items-center gap-2 rounded-lg px-1 text-sm font-medium text-muted hover:text-fg"
>
<ArrowLeft className="size-4" aria-hidden />
All engagements
</Link>
);
}
function Restricted({
icon,
title,
description,
}: {
icon: ReactNode;
title: string;
description: string;
}) {
return (
<div className="flex flex-col gap-4">
<BackLink />
<Card>
<CardContent className="pt-5">
<EmptyState icon={icon} title={title} description={description} />
</CardContent>
</Card>
</div>
);
}
function statusTone(status: EngagementStatus): 'positive' | 'danger' | 'warning' | 'info' {
return status === 'won' ? 'positive' : status === 'lost' ? 'danger' : status === 'paused' ? 'warning' : 'info';
}
function artifactTone(status: ArtifactStatus): 'neutral' | 'info' | 'positive' {
return status === 'final' ? 'positive' : status === 'review' ? 'info' : 'neutral';
}
+452
View File
@@ -0,0 +1,452 @@
/**
* The Motion home, which exists to answer one question: is the motion actually
* repeating?
*
* Every panel here is a different way of asking it. The rail asks whether the
* library reaches the stages where work is actually sitting; the kind grid asks
* whether the nine kinds are covered or whether three of them are aspirational;
* the promotions panel asks whether anything has come *back* out of an
* engagement, which is the only evidence that the loop closed at all.
*
* So the absences are promoted rather than hidden. A stage with live
* engagements and no template is the most useful finding this page can carry —
* it names the place where the next deployment will be improvised — and a
* missing row rendered as a grey zero is a finding nobody reads. Gaps get a
* warning tone, a count and a link that lands on the library already filtered
* to the hole.
*
* Counting is the server's job: `/api/motion` returns all eight stages and all
* nine kinds in ontology order, zeros included, so nothing here zero-fills.
*/
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
AlertTriangle,
ArrowUpRight,
Handshake,
Library,
Lock,
RefreshCw,
Sparkles,
} from 'lucide-react';
import {
DEMAND_STAGE_LABELS,
MOTION_KIND_DESCRIPTIONS,
MOTION_KIND_LABELS,
toPiggyPageRoute,
type DemandStage,
type EngagementStatus,
type MotionKind,
} from '@pig/core';
import { StageRail } from '@/components/motion/StageRail';
import { MotionKindBadge } from '@/components/motion/MotionKindBadge';
import { percent } from '@/components/motion/format';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
EmptyState,
Skeleton,
Stat,
} from '@/components/ui';
import { get, relativeTime, shortDate } from '@/lib/api';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import { usePiggyContext } from '@/lib/piggy-context';
import { usePageTitle } from '@/lib/title';
// ------------------------------------------------------------------- shapes
interface StageCoverage {
stage: DemandStage;
engagements: number;
templates: number;
}
interface KindCoverage {
kind: MotionKind;
templates: number;
shared: number;
}
interface EngagementSummary {
id: string;
demandDealId: string;
dealName: string | null;
stage: DemandStage | null;
accountId: string | null;
accountName: string | null;
status: EngagementStatus;
summary: string | null;
openedAt: string;
artifactCount: number;
latestScore: { basisPoints: number; band: string } | null;
}
interface Promotion {
templateId: string;
slug: string;
title: string;
kind: MotionKind;
version: number;
promotedAt: string;
engagementId: string | null;
dealName: string | null;
}
interface MotionOverview {
totals: {
templates: number;
shared: number;
private: number;
engagements: number;
open: number;
promotions: number;
};
stages: StageCoverage[];
library: KindCoverage[];
engagements: EngagementSummary[];
promotions: Promotion[];
}
// --------------------------------------------------------------------- page
export function Motion() {
usePageTitle('Motion');
usePiggyContext({ type: 'page', route: toPiggyPageRoute('/motion'), label: 'Motion' });
const me = useIdentity();
const overview = useQuery({
queryKey: ['motion', 'overview'],
queryFn: () => get<MotionOverview>('/api/motion'),
enabled: canAny(me, 'book:read'),
});
if (!canAny(me, 'book:read')) {
return (
<div className="flex min-w-0 flex-col gap-5">
<PageHeader />
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Lock />}
title="Motion is restricted"
description="The library and its engagements sit behind book access. Ask a platform administrator for team membership."
/>
</CardContent>
</Card>
</div>
);
}
const data = overview.data;
// A stage carrying live work with nothing in the library behind it. Both
// halves matter: an empty stage with no template is a stage nobody is
// working, which is not a gap in the motion.
const gaps = (data?.stages ?? []).filter(
(stage) => stage.templates === 0 && stage.engagements > 0,
);
const uncovered = (data?.stages ?? []).filter((stage) => stage.templates === 0);
const empty = data ? data.totals.templates === 0 && data.totals.engagements === 0 : false;
return (
<div className="flex min-w-0 flex-col gap-5">
<PageHeader />
{overview.isLoading ? <Skeleton className="h-72" /> : null}
{overview.isError ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<AlertTriangle />}
title="Motion is unavailable"
description={
overview.error instanceof Error
? overview.error.message
: 'The motion summary could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void overview.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
</CardContent>
</Card>
) : null}
{empty ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Library />}
title="Nothing in the motion yet"
description="A motion starts as one template. Author the discovery guide you already run from memory, then instantiate it into the next deal."
action={
<Button variant="primary" asChild>
<Link to="/motion/library">Open the library</Link>
</Button>
}
/>
</CardContent>
</Card>
) : null}
{data && !empty ? (
<>
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Stat
label="Templates"
value={data.totals.templates}
hint={`${data.totals.shared} shared · ${data.totals.private} private`}
/>
<Stat
label="Open engagements"
value={data.totals.open}
hint={`${data.totals.engagements} recorded in total`}
/>
<Stat
label="Stages covered"
value={`${data.stages.length - uncovered.length}/${data.stages.length}`}
hint={uncovered.length ? 'The library stops short of the motion' : 'Every stage has a template'}
tone={gaps.length ? 'warning' : uncovered.length ? 'default' : 'positive'}
/>
<Stat
label="Promotions"
value={data.totals.promotions}
hint={data.totals.promotions ? 'Artefacts that came back as templates' : 'Nothing has closed the loop yet'}
/>
</section>
<Card>
<CardHeader>
<CardTitle>The motion, stage by stage</CardTitle>
<p className="text-sm text-muted">
Live engagements sit on the deal&rsquo;s stage. The second figure is what the
library has waiting there.
</p>
</CardHeader>
<CardContent className="flex min-w-0 flex-col gap-3">
<StageRail counts={countsByStage(data.stages)} coverage={templatesByStage(data.stages)} />
{gaps.length ? (
<div className="flex min-w-0 gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3 text-sm">
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden />
<div className="min-w-0">
<p className="font-medium">
{gaps.length === 1 ? 'One stage is' : `${gaps.length} stages are`} being worked
without a template
</p>
<p className="mt-1 text-muted">
Whatever gets written there this week is written from memory, and the next
deal starts over. Author one from the engagement that is already in it.
</p>
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
{gaps.map((gap) => (
<Link
key={gap.stage}
to={`/motion/library?stage=${gap.stage}`}
className="tap inline-flex min-h-11 min-w-0 items-center gap-1 rounded-lg bg-surface px-3 text-sm font-medium underline-offset-4 hover:underline"
>
<span className="truncate">{DEMAND_STAGE_LABELS[gap.stage]}</span>
<span className="nums shrink-0 text-muted">
{gap.engagements} live
</span>
</Link>
))}
</div>
</div>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="flex-row items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle>Library cover, by kind</CardTitle>
<p className="text-sm text-muted">
Nine kinds. A kind with nothing in it is a conversation that gets improvised
every time.
</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<Link to="/motion/library">
<Library aria-hidden />
Library
</Link>
</Button>
</CardHeader>
<CardContent className="grid min-w-0 gap-2 sm:grid-cols-2 xl:grid-cols-3">
{data.library.map((kind) => (
<KindCoverageCard key={kind.kind} coverage={kind} />
))}
</CardContent>
</Card>
<div className="grid min-w-0 gap-5 lg:grid-cols-2">
<Card className="min-w-0">
<CardHeader>
<CardTitle>What got easier</CardTitle>
<p className="text-sm text-muted">
Artefacts an engagement proved, promoted back into the library as a new version.
</p>
</CardHeader>
<CardContent className="flex min-w-0 flex-col gap-2">
{data.promotions.length === 0 ? (
<EmptyState
icon={<Sparkles />}
title="Nothing promoted yet"
description="Finalise an artefact in an engagement and promote it. That is the step that makes the next deployment cheaper than this one."
/>
) : (
data.promotions.map((promotion) => (
<PromotionRow key={promotion.templateId} promotion={promotion} />
))
)}
</CardContent>
</Card>
<Card className="min-w-0">
<CardHeader className="flex-row items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle>Open engagements</CardTitle>
<p className="text-sm text-muted">Where the library is being used right now.</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<Link to="/motion/engagements">
<Handshake aria-hidden />
All
</Link>
</Button>
</CardHeader>
<CardContent className="flex min-w-0 flex-col gap-2">
{data.engagements.length === 0 ? (
<EmptyState
icon={<Handshake />}
title="No open engagements"
description="Open one against a demand deal to file discovery, scoping and proposal work against its stages."
/>
) : (
data.engagements.map((engagement) => (
<EngagementRow key={engagement.id} engagement={engagement} />
))
)}
</CardContent>
</Card>
</div>
</>
) : null}
</div>
);
}
// ---------------------------------------------------------------- fragments
function PageHeader() {
return (
<header className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">
Go-to-market
</p>
<h1 className="mt-1 text-xl font-semibold tracking-tight sm:text-2xl">Motion</h1>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
The repeatable practice behind the ledger: what the library covers, where engagements
actually sit, and what came back out of one.
</p>
</header>
);
}
function KindCoverageCard({ coverage }: { coverage: KindCoverage }) {
const missing = coverage.templates === 0;
return (
<Link
to={`/motion/library?kind=${coverage.kind}`}
className="tap flex min-h-11 min-w-0 flex-col gap-2 rounded-xl border border-border bg-surface-2/60 p-3 hover:bg-surface-2"
>
<div className="flex min-w-0 items-center justify-between gap-2">
<MotionKindBadge kind={coverage.kind} />
{missing ? (
<Badge tone="warning" className="shrink-0">
No template
</Badge>
) : (
<span className="nums shrink-0 text-sm font-semibold">{coverage.templates}</span>
)}
</div>
<p className="min-w-0 line-clamp-2 text-xs leading-5 text-muted">
{missing ? MOTION_KIND_DESCRIPTIONS[coverage.kind] : `${coverage.shared} shared to the book`}
</p>
<span className="sr-only">{MOTION_KIND_LABELS[coverage.kind]}</span>
</Link>
);
}
function PromotionRow({ promotion }: { promotion: Promotion }) {
// The whole row is the target, as it is for `EngagementRow` in the card
// beside it. A one-line text link is about 20px tall on a phone, and two
// identically-shaped rows where only one can be tapped reads as a dead page
// rather than as a smaller hit area.
return (
<Link
to={`/motion/library/${promotion.templateId}`}
className="tap flex min-h-11 min-w-0 items-start justify-between gap-3 rounded-lg bg-surface-2 p-3 hover:bg-border/60"
>
<div className="min-w-0">
<p className="min-w-0 break-words font-medium">{promotion.title}</p>
<p className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
<MotionKindBadge kind={promotion.kind} iconOnly />
<span className="nums whitespace-nowrap">v{promotion.version}</span>
<span className="min-w-0 truncate">
{promotion.dealName ? `from ${promotion.dealName}` : 'from an engagement'}
</span>
</p>
</div>
<span className="nums shrink-0 text-xs text-muted" title={shortDate(promotion.promotedAt)}>
{relativeTime(promotion.promotedAt)}
</span>
</Link>
);
}
function EngagementRow({ engagement }: { engagement: EngagementSummary }) {
return (
<Link
to={`/motion/engagements/${engagement.id}`}
className="tap flex min-h-11 min-w-0 items-start justify-between gap-3 rounded-lg bg-surface-2 p-3 hover:bg-border/60"
>
<div className="min-w-0">
<p className="min-w-0 truncate font-medium">
{engagement.dealName ?? 'Unnamed deal'}
</p>
<p className="mt-1 min-w-0 truncate text-xs text-muted">
{engagement.accountName ?? 'Unknown account'} ·{' '}
{engagement.stage ? DEMAND_STAGE_LABELS[engagement.stage] : 'No stage'} ·{' '}
<span className="nums">{engagement.artifactCount}</span> artefact
{engagement.artifactCount === 1 ? '' : 's'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{engagement.latestScore ? (
<Badge tone="neutral" className="nums">
{percent(engagement.latestScore.basisPoints)}
</Badge>
) : null}
<ArrowUpRight className="size-4 text-muted" aria-hidden />
</div>
</Link>
);
}
function countsByStage(stages: StageCoverage[]): Partial<Record<DemandStage, number>> {
return Object.fromEntries(stages.map((stage) => [stage.stage, stage.engagements]));
}
function templatesByStage(stages: StageCoverage[]): Partial<Record<DemandStage, number>> {
return Object.fromEntries(stages.map((stage) => [stage.stage, stage.templates]));
}
+539
View File
@@ -0,0 +1,539 @@
/**
* Every engagement, one per demand deal.
*
* The list answers a different question from the Motion home: not "is the
* motion repeating" but "which deals are being worked with it, and what does
* qualification say about them now". So the columns are the ones that move —
* stage, artefact count, latest score — and the status filter lives in the URL
* so a view of everything still open can be pasted to somebody.
*
* `Unscored` is a stat rather than a column because it is the finding: an open
* engagement nobody has qualified is a deal being worked on instinct, and it is
* invisible in a table that only shows the scores that exist.
*/
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import {
AlertTriangle,
ChevronRight,
Handshake,
Lock,
Plus,
RefreshCw,
} from 'lucide-react';
import { toast } from 'sonner';
import {
DEMAND_STAGE_LABELS,
ENGAGEMENT_STATUSES,
ENGAGEMENT_STATUS_LABELS,
isEngagementStatus,
toPiggyPageRoute,
type DemandStage,
type EngagementStatus,
type MotionBandTone,
} from '@pig/core';
import { percent } from '@/components/motion/format';
import { get, post, shortDate } from '@/lib/api';
import { usePageTitle } from '@/lib/title';
import { usePiggyContext } from '@/lib/piggy-context';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import {
Badge,
Button,
Card,
CardContent,
EmptyState,
Skeleton,
Stat,
cn,
} from '@/components/ui';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea';
/** The score the list carries — the latest one, or none yet. */
interface LatestScore {
basisPoints: number;
band: string;
tone: MotionBandTone;
scoredAt: string;
}
/** `GET /api/motion/engagements` rows. `stage` is the deal's, not the engagement's. */
interface EngagementRow {
id: string;
demandDealId: string;
dealName: string | null;
stage: DemandStage | null;
accountId: string | null;
accountName: string | null;
status: EngagementStatus;
summary: string | null;
openedAt: string;
closedAt: string | null;
artifactCount: number;
latestScore: LatestScore | null;
}
interface DemandDealRow {
deal: { id: string; name: string; stage: DemandStage };
accountName: string | null;
}
const WRITE_DENIED = 'Opening an engagement needs the motion:write permission.';
export function MotionEngagements() {
usePageTitle('Engagements');
usePiggyContext({
type: 'page',
route: toPiggyPageRoute('/motion/engagements'),
label: 'Engagements',
});
const me = useIdentity();
const mayWrite = canAny(me, 'motion:write');
const [params, setParams] = useSearchParams();
const [opening, setOpening] = useState(false);
// The filter lives in the URL so a view of "everything still open" can be
// pasted to somebody, which is the whole reason anyone filters a list.
const statusParam = params.get('status');
const status = statusParam && isEngagementStatus(statusParam) ? statusParam : null;
const engagements = useQuery({
queryKey: ['motion', 'engagements', { status }],
queryFn: () =>
get<{ engagements: EngagementRow[] }>(
`/api/motion/engagements${status ? `?status=${status}` : ''}`,
),
// The permission short-circuit below is an early return, and a hook cannot
// sit behind one — so without this the read fires for exactly the people
// the page is about to tell they have no access, and react-query retries
// the 403 while they read the empty state.
enabled: canAny(me, 'book:read'),
});
const rows = engagements.data?.engagements ?? [];
const totals = useMemo(
() => ({
open: rows.filter((row) => row.status === 'open').length,
artifacts: rows.reduce((sum, row) => sum + row.artifactCount, 0),
unscored: rows.filter((row) => row.status === 'open' && !row.latestScore).length,
}),
[rows],
);
if (!canAny(me, 'book:read')) {
return (
<div className="flex min-w-0 flex-col gap-5">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Engagements</h1>
</header>
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Lock />}
title="Engagements are restricted"
description="Reading the book behind an engagement needs the book permission. Ask a platform administrator for team membership."
/>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-w-0 flex-col gap-5">
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Engagements</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">
One per demand deal: the artefacts it has produced, and what qualification says about
it now rather than when it was opened.
</p>
</div>
<Button
type="button"
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={() => setOpening(true)}
>
<Plus aria-hidden />
Open an engagement
</Button>
</header>
{rows.length ? (
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-3" aria-label="Engagement summary">
<Stat label="Open" value={totals.open} hint="Still in the motion" />
<Stat label="Artefacts" value={totals.artifacts} hint="Across the list below" />
<Stat
label="Unscored"
value={totals.unscored}
hint="Open, never qualified"
tone={totals.unscored ? 'warning' : 'default'}
/>
</section>
) : null}
<div className="flex min-w-0 flex-wrap items-center gap-2">
<StatusFilter
value={status}
onChange={(next) => {
const updated = new URLSearchParams(params);
if (next) updated.set('status', next);
else updated.delete('status');
setParams(updated, { replace: true });
}}
/>
</div>
{engagements.isLoading ? <Skeleton className="h-72" /> : null}
{engagements.isError ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<AlertTriangle />}
title="Engagements unavailable"
description={
engagements.error instanceof Error
? engagements.error.message
: 'The list could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void engagements.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
</CardContent>
</Card>
) : null}
{!engagements.isLoading && !engagements.isError && rows.length === 0 ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Handshake />}
title={status ? 'No engagement in this state' : 'No engagements yet'}
description={
status
? 'Clear the filter to see the rest of them.'
: 'An engagement binds the library to a demand deal. Open one on the deal you are working now, and the artefacts it produces can be promoted back.'
}
action={
status ? undefined : (
<Button
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={() => setOpening(true)}
>
Open the first one
</Button>
)
}
/>
</CardContent>
</Card>
) : null}
{rows.length ? (
<>
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Deal</TableHead>
<TableHead>Stage</TableHead>
<TableHead>Status</TableHead>
<TableHead>Artefacts</TableHead>
<TableHead>Qualification</TableHead>
<TableHead>Opened</TableHead>
<TableHead className="w-12">
<span className="sr-only">Open</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<div className="min-w-0">
<Link
to={`/motion/engagements/${row.id}`}
className="font-medium underline-offset-4 hover:text-accent-fg hover:underline"
>
{row.dealName ?? 'Untitled deal'}
</Link>
<p className="truncate text-xs text-muted">
{row.accountName ?? 'Unknown account'}
</p>
</div>
</TableCell>
<TableCell className="text-sm">
{row.stage ? DEMAND_STAGE_LABELS[row.stage] : '—'}
</TableCell>
<TableCell>
<StatusBadge status={row.status} />
</TableCell>
<TableCell className="nums text-sm">{row.artifactCount}</TableCell>
<TableCell>
<ScoreCell score={row.latestScore} />
</TableCell>
<TableCell className="nums text-sm">{shortDate(row.openedAt)}</TableCell>
<TableCell>
<Link
to={`/motion/engagements/${row.id}`}
className="tap inline-flex items-center justify-center text-muted"
aria-label={`Open ${row.dealName ?? 'engagement'}`}
>
<ChevronRight aria-hidden />
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
<div className="grid gap-3 md:hidden">
{rows.map((row) => (
<Link
key={row.id}
to={`/motion/engagements/${row.id}`}
className="card min-h-11 min-w-0 p-4"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<StatusBadge status={row.status} />
{row.stage ? (
<Badge tone="neutral" className="min-w-0">
<span className="truncate">{DEMAND_STAGE_LABELS[row.stage]}</span>
</Badge>
) : null}
</div>
<p className="mt-2 truncate font-medium">{row.dealName ?? 'Untitled deal'}</p>
<p className="mt-0.5 truncate text-sm text-muted">
{row.accountName ?? 'Unknown account'}
</p>
</div>
<ChevronRight className="shrink-0 text-muted" aria-hidden />
</div>
<div className="mt-3 flex min-w-0 items-end justify-between gap-3 border-t border-border pt-3 text-xs text-muted">
<p className="nums min-w-0">
{row.artifactCount} artifact{row.artifactCount === 1 ? '' : 's'} · opened{' '}
{shortDate(row.openedAt)}
</p>
<ScoreCell score={row.latestScore} />
</div>
</Link>
))}
</div>
</>
) : null}
<OpenEngagementSheet open={opening} onOpenChange={setOpening} />
</div>
);
}
/**
* Opening an engagement on a deal that already has one is a 409, so the deals
* that already have one are not offered. The server still refuses — this is a
* courtesy, not the rule — but an option that can only fail is a worse thing
* to ship than a shorter list.
*/
function OpenEngagementSheet({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
// Its own check rather than a prop. The trigger is already disabled without
// `motion:write`, but these two reads are what the sheet costs, and a query
// that fires for somebody who cannot use the result is a 403 in the network
// tab and a retry loop behind a dialog they were never meant to open.
const mayWrite = canAny(useIdentity(), 'motion:write');
const [demandDealId, setDemandDealId] = useState('');
const [summary, setSummary] = useState('');
const deals = useQuery({
queryKey: ['deals', 'demand'],
queryFn: () => get<{ deals: DemandDealRow[] }>('/api/deals/demand'),
enabled: open && mayWrite,
});
/*
* Its own unfiltered query rather than the rows the page is showing. The
* status filter is the trap: filtered to "Won", the page holds none of the
* open engagements, so every deal already running would be offered here and
* every one of them would answer 409. The key matches the page's own
* unfiltered query, so the two share a cache entry rather than racing.
*/
const allEngagements = useQuery({
queryKey: ['motion', 'engagements', { status: null }],
queryFn: () => get<{ engagements: EngagementRow[] }>('/api/motion/engagements'),
enabled: open && mayWrite,
});
const taken = new Set((allEngagements.data?.engagements ?? []).map((row) => row.demandDealId));
const available = (deals.data?.deals ?? []).filter((row) => !taken.has(row.deal.id));
const create = useMutation({
mutationFn: () =>
post<{ engagement: { id: string } }>('/api/motion/engagements', {
demandDealId,
summary: summary.trim() ? summary.trim() : null,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success('Engagement opened');
setDemandDealId('');
setSummary('');
onOpenChange(false);
},
onError: (error: Error) => toast.error(error.message),
});
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-lg">
<SheetHeader>
<SheetTitle>Open an engagement</SheetTitle>
<SheetDescription>
An engagement hangs off a demand deal rather than replacing it the deal keeps its
stage, and the engagement collects what the motion produces against it.
</SheetDescription>
</SheetHeader>
<form
className="mt-5 flex min-w-0 flex-col gap-4 pb-6"
onSubmit={(event) => {
event.preventDefault();
create.mutate();
}}
>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="engagement-deal">Demand deal</Label>
<Select value={demandDealId || undefined} onValueChange={setDemandDealId}>
<SelectTrigger id="engagement-deal" aria-label="Demand deal" className="h-11 min-w-0">
<SelectValue placeholder={deals.isLoading || allEngagements.isLoading ? 'Loading deals…' : 'Choose a deal'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{available.map((row) => (
<SelectItem key={row.deal.id} value={row.deal.id}>
{row.deal.name} · {row.accountName ?? 'Unknown account'}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{!deals.isLoading && !allEngagements.isLoading && available.length === 0 ? (
<p className="text-xs text-muted">
Every demand deal already has an engagement. Open one from the deal in Pipeline
once there is a new deal to run.
</p>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="engagement-summary">What this engagement is for</Label>
<Textarea
id="engagement-summary"
className="min-h-24"
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder="The deployment being scoped, in one or two sentences."
/>
</div>
{create.isError ? <p className="text-sm text-danger">{create.error.message}</p> : null}
<div className="flex min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!demandDealId || create.isPending}>
Open engagement
</Button>
</div>
</form>
</SheetContent>
</Sheet>
);
}
// ------------------------------------------------------------------ helpers
function StatusFilter({
value,
onChange,
}: {
value: EngagementStatus | null;
onChange: (value: EngagementStatus | null) => void;
}) {
return (
<div role="tablist" aria-label="Filter by status" className="scroll-x -mx-1 flex gap-1 px-1 pb-1">
{([null, ...ENGAGEMENT_STATUSES] as const).map((option) => (
<button
key={option ?? 'all'}
type="button"
role="tab"
aria-selected={value === option}
onClick={() => onChange(option)}
className={cn(
'tap min-h-11 shrink-0 rounded-lg px-4 text-sm font-medium transition-colors',
value === option
? 'bg-surface text-fg shadow-sm ring-1 ring-border'
: 'text-muted hover:bg-surface-2',
)}
>
{option ? ENGAGEMENT_STATUS_LABELS[option] : 'All'}
</button>
))}
</div>
);
}
function StatusBadge({ status }: { status: EngagementStatus }) {
const tone =
status === 'won' ? 'positive' : status === 'lost' ? 'danger' : status === 'paused' ? 'warning' : 'info';
return <Badge tone={tone}>{ENGAGEMENT_STATUS_LABELS[status]}</Badge>;
}
/**
* The band leads and the figure follows it.
*
* "Qualified" is what a seller acts on; 6,200 basis points is what an analyst
* checks afterwards. Showing the number alone invites the reading that 62 is
* nearly 70 and therefore nearly good, when the band boundary at 7500 is the
* only place anything changes.
*/
function ScoreCell({ score }: { score: LatestScore | null }) {
if (!score) return <span className="text-sm text-muted">Not scored</span>;
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-1.5">
<Badge tone={score.tone}>{score.band}</Badge>
<span className="nums whitespace-nowrap text-xs text-muted" title={`${score.basisPoints} basis points`}>
{percent(score.basisPoints)}
</span>
</span>
);
}
+420
View File
@@ -0,0 +1,420 @@
/**
* The library: nine kinds, filtered four ways, in a URL somebody can send.
*
* The filters live in the query string rather than in component state, which is
* a first for this app. It is not tidiness — "the proposal blocks we have for
* procurement" is a thing one person tells another, and until now the only way
* to share a view was to describe the clicks. `useSearchParams` makes the view
* the address.
*
* Two rules keep that URL readable, and both are load-bearing:
* defaults are never written (an unset filter is an absent parameter, not
* `?kind=all`), and every write is `replace`, so dragging across the stage rail
* leaves one history entry rather than eight for the back button to walk.
*
* An unrecognised value is treated as absent. The API answers `400
* invalid_request` on an unknown enum, and a hand-edited URL — or one that
* outlived a kind the ontology dropped — should degrade to the unfiltered
* library, not to an error page.
*
* The rail's figures come from `/api/motion` rather than from the rows on
* screen: a rail computed from the filtered result empties as you use it, which
* makes the one control that is meant to navigate the motion useless the moment
* anything is selected.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { AlertTriangle, Layers, Library, Lock, RefreshCw, Search } from 'lucide-react';
import {
DEMAND_STAGES,
MOTION_KINDS,
MOTION_KIND_LABELS,
MOTION_VISIBILITIES,
MOTION_VISIBILITY_LABELS,
isMotionKind,
isMotionVisibility,
toPiggyPageRoute,
type DemandStage,
type MotionKind,
type MotionVisibility,
} from '@pig/core';
import { StageRail } from '@/components/motion/StageRail';
import { TemplateCard } from '@/components/motion/TemplateCard';
import type { MotionTemplateView } from '@/components/motion/model';
import { Button, Card, CardContent, EmptyState, Input, Skeleton } from '@/components/ui';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { get } from '@/lib/api';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import { usePiggyContext } from '@/lib/piggy-context';
import { usePageTitle } from '@/lib/title';
// ------------------------------------------------------------------- shapes
/**
* What the list endpoint serialises: the row without `body` or `fields`.
*
* Two hundred playbooks is a megabyte of markdown nobody on this page reads, so
* the API sends the summary and the detail endpoint sends the document.
*/
type TemplateSummary = Omit<MotionTemplateView, 'body' | 'fields'>;
interface TemplateList {
templates: TemplateSummary[];
/** The scan hit its bound — the library is wider than this answer. */
truncated: boolean;
}
interface StageCoverage {
stage: DemandStage;
engagements: number;
templates: number;
}
interface MotionOverview {
stages: StageCoverage[];
}
interface Filters {
kind: MotionKind | null;
stage: DemandStage | null;
visibility: MotionVisibility | null;
q: string;
all: boolean;
}
const STAGE_PARAM = 'stage';
/** Long enough that a typed word is one request, short enough to feel live. */
const SEARCH_SETTLE_MS = 250;
// --------------------------------------------------------------------- page
export function MotionLibrary() {
usePageTitle('Library');
usePiggyContext({
type: 'page',
route: toPiggyPageRoute('/motion/library'),
label: 'Motion library',
});
const me = useIdentity();
const [params, setParams] = useSearchParams();
const filters = readFilters(params);
// The input is local and the URL follows it, rather than the other way
// round: bound directly to the query string, every keystroke is a request
// against a 400-row scan and a URL that is only copyable between words.
const [text, setText] = useState(filters.q);
const pushed = useRef(filters.q);
useEffect(() => {
if (text === filters.q) return;
const timer = setTimeout(() => {
pushed.current = text;
setParam(setParams, 'q', text);
}, SEARCH_SETTLE_MS);
return () => clearTimeout(timer);
}, [filters.q, setParams, text]);
useEffect(() => {
// The URL moved and it was not us — a pasted link, or a back navigation
// out of some other filter. Adopt it. Without this the pending input wins
// the next tick and pushes itself straight back over the address, which
// reads as a back button that does not work.
if (filters.q === pushed.current) return;
pushed.current = filters.q;
setText(filters.q);
}, [filters.q]);
const templates = useQuery({
queryKey: ['motion', 'templates', filters],
queryFn: () => get<TemplateList>(`/api/motion/templates${queryString(filters)}`),
enabled: canAny(me, 'book:read'),
});
// Shared with the Motion home, and deliberately unfiltered — see the header.
const overview = useQuery({
queryKey: ['motion', 'overview'],
queryFn: () => get<MotionOverview>('/api/motion'),
enabled: canAny(me, 'book:read'),
});
const railCounts = useMemo(
() =>
Object.fromEntries(
(overview.data?.stages ?? []).map((stage) => [stage.stage, stage.templates]),
) as Partial<Record<DemandStage, number>>,
[overview.data],
);
const filtered = Boolean(
filters.kind || filters.stage || filters.visibility || filters.q || filters.all,
);
if (!canAny(me, 'book:read')) {
return (
<div className="flex min-w-0 flex-col gap-5">
<PageHeader />
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Lock />}
title="The library is restricted"
description="Motion templates sit behind book access. Ask a platform administrator for team membership."
/>
</CardContent>
</Card>
</div>
);
}
const rows = templates.data?.templates ?? [];
return (
<div className="flex min-w-0 flex-col gap-5">
<PageHeader />
<StageRail
counts={railCounts}
countLabel="templates"
activeStage={filters.stage}
onSelect={(stage) =>
setParam(setParams, STAGE_PARAM, stage === filters.stage ? '' : stage)
}
/>
<div className="grid min-w-0 gap-3 rounded-xl border border-border bg-surface-2/60 p-3 sm:grid-cols-[minmax(0,1fr)_170px_170px]">
<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, summary or slug"
value={text}
onChange={(event) => setText(event.target.value)}
/>
</div>
<EnumSelect
aria-label="Filter by kind"
value={filters.kind ?? 'all'}
onValueChange={(value) => setParam(setParams, 'kind', value === 'all' ? '' : value)}
>
<SelectItem value="all">All kinds</SelectItem>
{MOTION_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{MOTION_KIND_LABELS[kind]}
</SelectItem>
))}
</EnumSelect>
<EnumSelect
aria-label="Filter by visibility"
value={filters.visibility ?? 'all'}
onValueChange={(value) => setParam(setParams, 'visibility', value === 'all' ? '' : value)}
>
<SelectItem value="all">Shared and private</SelectItem>
{MOTION_VISIBILITIES.map((visibility) => (
<SelectItem key={visibility} value={visibility}>
{MOTION_VISIBILITY_LABELS[visibility]}
</SelectItem>
))}
</EnumSelect>
</div>
<div className="flex min-h-11 min-w-0 flex-wrap items-center justify-between gap-2">
<p className="min-w-0 text-sm text-muted">
<strong className="nums text-fg">{rows.length}</strong>{' '}
{filters.all ? 'versions' : 'templates'} shown
{templates.data?.truncated ? ' · more exist than fit in one answer' : ''}
</p>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{/* Lineages collapse to their newest version by default. The whole
history is what you want when judging whether a template is
settled or still being rewritten every fortnight. */}
<Button
variant={filters.all ? 'secondary' : 'ghost'}
size="sm"
aria-pressed={filters.all}
onClick={() => setParam(setParams, 'all', filters.all ? '' : '1')}
>
<Layers aria-hidden />
{filters.all ? 'Newest versions' : 'Every version'}
</Button>
{filtered ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setText('');
setParams(new URLSearchParams(), { replace: true });
}}
>
Clear filters
</Button>
) : null}
</div>
</div>
{templates.isLoading ? <Skeleton className="h-64" /> : null}
{templates.isError ? (
<Card>
<CardContent className="pt-5">
<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>
}
/>
</CardContent>
</Card>
) : null}
{!templates.isLoading && !templates.isError && rows.length === 0 ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Library />}
title={filtered ? 'No templates match' : 'The library is empty'}
description={
filtered
? 'Clear a filter to see the rest of the library. A private template belonging to somebody else will never appear here.'
: 'Nine kinds and nothing in them yet. Author the discovery guide you already run from memory, and instantiate it into the next deal.'
}
action={
filtered ? (
<Button
variant="outline"
onClick={() => {
setText('');
setParams(new URLSearchParams(), { replace: true });
}}
>
Clear filters
</Button>
) : undefined
}
/>
</CardContent>
</Card>
) : null}
{rows.length ? (
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{rows.map((template) => (
<TemplateCard
key={template.id}
// The card reads neither `body` nor `fields`; its prop type asks
// for them only because one interface describes both endpoints.
// Asserting beats inventing an empty body, which would be
// indistinguishable from a template somebody saved blank.
template={template as MotionTemplateView}
/>
))}
</div>
) : null}
</div>
);
}
// ---------------------------------------------------------------- fragments
function PageHeader() {
return (
<header className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">Motion</p>
<h1 className="mt-1 text-xl font-semibold tracking-tight sm:text-2xl">Library</h1>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
Discovery guides, qualification frameworks, proposal blocks and playbooks the parts of a
deployment that should not be rewritten each time. This view is linkable: send the address,
not the clicks.
</p>
</header>
);
}
type EnumSelectProps = React.ComponentProps<typeof Select> & { 'aria-label': string };
function EnumSelect({ children, 'aria-label': ariaLabel, ...props }: EnumSelectProps) {
return (
<Select {...props}>
<SelectTrigger aria-label={ariaLabel} className="h-11 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>{children}</SelectGroup>
</SelectContent>
</Select>
);
}
// ------------------------------------------------------------------ filters
/**
* Local, because `@pig/core` ships guards for the motion enums but not for the
* stage list. Declared here rather than added there: this is the only caller,
* and widening the ontology's surface for one URL parser is not the trade.
*/
function isDemandStage(value: string): value is DemandStage {
return (DEMAND_STAGES as readonly string[]).includes(value);
}
/** An unrecognised value reads as absent — see the header. */
function readFilters(params: URLSearchParams): Filters {
const kind = params.get('kind');
const stage = params.get(STAGE_PARAM);
const visibility = params.get('visibility');
return {
kind: kind && isMotionKind(kind) ? kind : null,
stage: stage && isDemandStage(stage) ? stage : null,
visibility: visibility && isMotionVisibility(visibility) ? visibility : null,
q: params.get('q')?.trim() ?? '',
all: params.get('all') === '1',
};
}
/**
* Set one parameter, dropping it when it falls back to the default.
*
* Functional form, because two filters can settle within one tick — the search
* debounce firing while a stage is being pressed — and reading `params` from
* the closure would make the second write discard the first.
*/
function setParam(
setParams: ReturnType<typeof useSearchParams>[1],
key: string,
value: string,
): void {
setParams(
(current) => {
const next = new URLSearchParams(current);
if (value) next.set(key, value);
else next.delete(key);
return next;
},
{ replace: true },
);
}
function queryString(filters: Filters): string {
const query = new URLSearchParams();
if (filters.kind) query.set('kind', filters.kind);
if (filters.stage) query.set(STAGE_PARAM, filters.stage);
if (filters.visibility) query.set('visibility', filters.visibility);
if (filters.q) query.set('q', filters.q);
if (filters.all) query.set('all', '1');
const rendered = query.toString();
return rendered ? `?${rendered}` : '';
}
+729
View File
@@ -0,0 +1,729 @@
/**
* One template, with its provenance stated rather than implied.
*
* The document is the obvious half of this page and the least interesting one.
* The argument for the whole feature is the chain: this is v3, it supersedes
* v2, and it exists because an artefact in a real engagement was finalised and
* promoted. A library that shows only the newest body is a folder of files —
* the lineage is what makes "every deployment makes the next one easier" a
* mechanism somebody can audit.
*
* The one rule this page has to teach is §7a: a template that has been
* instantiated is never edited in place, because a live engagement must not
* have the thing it was copied from change underneath it. So a used template
* does not show a dead Save button — it says what the rule is and offers the
* move that is actually available, a new version in the same lineage.
*
* `canEdit` and `canPublish` are the server's judgement, read off the detail
* response. Re-deriving them here from `usageCount` and ownership would put a
* second copy of the rule in TSX, and the two copies would drift.
*/
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
AlertTriangle,
ArrowLeft,
GitBranch,
History,
Lock,
Pencil,
RefreshCw,
Send,
Sparkles,
Users,
} from 'lucide-react';
import { toast } from 'sonner';
import {
DEMAND_STAGES,
DEMAND_STAGE_LABELS,
MOTION_KIND_DESCRIPTIONS,
MOTION_VISIBILITY_LABELS,
toPiggyPageRoute,
type DemandStage,
type MotionKind,
type MotionVisibility,
} from '@pig/core';
import { FieldsView } from '@/components/motion/FieldsView';
import { Markdown } from '@/components/motion/Markdown';
import { MotionKindBadge } from '@/components/motion/MotionKindBadge';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
EmptyState,
Input,
Skeleton,
cn,
} from '@/components/ui';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { ApiError, get, patch, post, relativeTime, shortDate } from '@/lib/api';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import { usePiggyContext } from '@/lib/piggy-context';
import { usePageTitle } from '@/lib/title';
// ------------------------------------------------------------------- shapes
interface TemplateSummary {
id: string;
kind: MotionKind;
slug: string;
version: number;
title: string;
summary: string;
stage: DemandStage;
visibility: MotionVisibility;
ownerUserId: string | null;
supersedesId: string | null;
originArtifactId: string | null;
isSystem: boolean;
usageCount: number;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
}
interface TemplateDetail extends TemplateSummary {
body: string;
fields: Record<string, unknown> | null;
}
interface TemplateDetailResponse {
template: TemplateDetail;
/** Every version of this slug the reader may see, oldest first. */
lineage: TemplateSummary[];
/** Owner or admin, and never instantiated. The §7a rule, already applied. */
canEdit: boolean;
canPublish: boolean;
}
interface EngagementOption {
id: string;
dealName: string | null;
accountName: string | null;
stage: DemandStage | null;
}
interface Promotion {
templateId: string;
engagementId: string | null;
dealName: string | null;
}
interface MotionOverview {
promotions: Promotion[];
}
interface EditState {
title: string;
summary: string;
body: string;
stage: DemandStage;
}
// --------------------------------------------------------------------- page
export function MotionTemplate() {
const { id = '' } = useParams<{ id: string }>();
const me = useIdentity();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [editing, setEditing] = useState(false);
const [engagementId, setEngagementId] = useState('');
const detail = useQuery({
queryKey: ['motion', 'templates', id, 'detail'],
queryFn: () => get<TemplateDetailResponse>(`/api/motion/templates/${id}`),
enabled: Boolean(id) && canAny(me, 'book:read'),
retry: false,
});
const engagements = useQuery({
queryKey: ['motion', 'engagements', 'open'],
queryFn: () => get<{ engagements: EngagementOption[] }>('/api/motion/engagements?status=open'),
enabled: Boolean(id) && canAny(me, 'motion:write'),
});
/*
* Read only for the provenance line. The detail response carries
* `originArtifactId` but not the engagement behind it, and the overview's
* promotion list is the one place that join already exists — so a recently
* promoted template can name the deal it came out of, and an older one says
* plainly that it came from an engagement without inventing which.
*/
const overview = useQuery({
queryKey: ['motion', 'overview'],
queryFn: () => get<MotionOverview>('/api/motion'),
enabled: Boolean(detail.data?.template.originArtifactId),
});
const template = detail.data?.template;
usePageTitle(template?.title ?? 'Template');
usePiggyContext({
type: 'page',
route: toPiggyPageRoute('/motion/library'),
label: template ? template.title : 'Motion library',
});
const invalidate = async (): Promise<void> => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['motion', 'templates'] }),
queryClient.invalidateQueries({ queryKey: ['motion', 'overview'] }),
]);
};
const save = useMutation({
mutationFn: (input: EditState) => patch<{ template: TemplateDetail }>(`/api/motion/templates/${id}`, input),
onSuccess: async () => {
setEditing(false);
await invalidate();
toast.success('Template saved');
},
onError: (error: Error) => toast.error(error.message),
});
const fork = useMutation({
mutationFn: () => post<{ template: TemplateDetail }>(`/api/motion/templates/${id}/versions`, {}),
onSuccess: async ({ template: created }) => {
await invalidate();
toast.success(`Version ${created.version} drafted, private to you`);
navigate(`/motion/library/${created.id}`);
},
onError: (error: Error) => toast.error(error.message),
});
const publish = useMutation({
mutationFn: () => post<{ template: TemplateDetail }>(`/api/motion/templates/${id}/publish`, {}),
onSuccess: async () => {
await invalidate();
toast.success('Published to the book');
},
onError: (error: Error) => toast.error(error.message),
});
const instantiate = useMutation({
mutationFn: (target: string) =>
post<{ artifact: { id: string; engagementId: string } }>(
`/api/motion/engagements/${target}/artifacts`,
{ templateId: id },
),
onSuccess: async ({ artifact }) => {
await invalidate();
await queryClient.invalidateQueries({ queryKey: ['motion', 'engagements'] });
toast.success('Instantiated into the engagement');
navigate(`/motion/engagements/${artifact.engagementId}`);
},
onError: (error: Error) => toast.error(error.message),
});
if (!canAny(me, 'book:read')) {
return (
<Restricted
icon={<Lock />}
title="This template is restricted"
description="Motion templates sit behind book access. Ask a platform administrator for team membership."
/>
);
}
if (detail.isLoading) {
return (
<div className="flex min-w-0 flex-col gap-4">
<BackLink />
<Skeleton className="h-96" />
</div>
);
}
if (detail.error instanceof ApiError && detail.error.status === 404) {
return (
<Restricted
icon={<Lock />}
title="No such template"
// The same answer covers both cases on purpose, and saying so is
// kinder than a bare "not found" that reads as a broken link.
description="It has been archived, the link was to an id that never existed, or it is private to somebody else."
/>
);
}
if (detail.error || !detail.data || !template) {
return (
<div className="flex min-w-0 flex-col gap-4">
<BackLink />
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<AlertTriangle />}
title="Template unavailable"
description={
detail.error instanceof Error ? detail.error.message : 'The template could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void detail.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
</CardContent>
</Card>
</div>
);
}
const { lineage, canEdit, canPublish } = detail.data;
const mayWrite = canAny(me, 'motion:write');
const supersedes = lineage.find((version) => version.id === template.supersedesId) ?? null;
const promotion = overview.data?.promotions.find((row) => row.templateId === template.id) ?? null;
const openEngagements = engagements.data?.engagements ?? [];
return (
<div className="flex min-w-0 flex-col gap-5">
<BackLink />
<header className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<MotionKindBadge kind={template.kind} />
<Badge tone="neutral">{DEMAND_STAGE_LABELS[template.stage]}</Badge>
<Badge tone={template.visibility === 'private' ? 'warning' : 'neutral'}>
{MOTION_VISIBILITY_LABELS[template.visibility]}
</Badge>
{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>
<h1 className="mt-2 min-w-0 break-words text-xl font-semibold tracking-tight sm:text-2xl">
{template.title}
</h1>
<p className="mt-1 max-w-2xl min-w-0 break-words text-sm leading-6 text-muted">
{template.summary}
</p>
<p className="mt-2 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
<span className="nums whitespace-nowrap">Version {template.version}</span>
<span className="nums inline-flex items-center gap-1 whitespace-nowrap">
<Users className="size-3 shrink-0" aria-hidden />
{template.usageCount === 1 ? 'Used once' : `Used ${template.usageCount} times`}
</span>
<span className="min-w-0 truncate">{MOTION_KIND_DESCRIPTIONS[template.kind]}</span>
</p>
</header>
<Provenance
template={template}
supersedes={supersedes}
engagementId={promotion?.engagementId ?? null}
dealName={promotion?.dealName ?? null}
/>
{/*
The §7a rule, said in full where the edit button would otherwise be.
A disabled control with no explanation teaches the reader that the app
is broken; this one names the reason and the move that replaces it.
*/}
{template.usageCount > 0 ? (
<div className="flex min-w-0 gap-3 rounded-xl border border-info/30 bg-info/10 p-3 text-sm">
<History className="size-4 shrink-0 text-info" aria-hidden />
<div className="min-w-0">
<p className="font-medium">This template is closed to edits</p>
<p className="mt-1 text-muted">
{template.usageCount === 1 ? 'An engagement has' : `${template.usageCount} engagements have`}{' '}
already instantiated it, and their artefacts record that they came from v
{template.version}. Changing it now would rewrite their provenance. Draft a new
version instead the lineage keeps both.
</p>
</div>
</div>
) : null}
<div className="grid min-w-0 gap-2 sm:flex sm:flex-wrap">
{canEdit ? (
<Button
variant={editing ? 'secondary' : 'outline'}
aria-pressed={editing}
onClick={() => setEditing((current) => !current)}
>
<Pencil aria-hidden />
{editing ? 'Stop editing' : 'Edit draft'}
</Button>
) : null}
<Button
variant={template.usageCount > 0 ? 'primary' : 'outline'}
disabled={!mayWrite || fork.isPending}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={() => fork.mutate()}
>
<GitBranch aria-hidden />
{template.usageCount > 0 ? 'New version' : 'Fork to a private draft'}
</Button>
{canPublish ? (
<Button variant="primary" disabled={publish.isPending} onClick={() => publish.mutate()}>
<Send aria-hidden />
Publish to the book
</Button>
) : null}
</div>
{editing && canEdit ? (
<TemplateEditor
initial={{
title: template.title,
summary: template.summary,
body: template.body,
stage: template.stage,
}}
pending={save.isPending}
onCancel={() => setEditing(false)}
onSave={(input) => save.mutate(input)}
/>
) : null}
<Card>
<CardHeader className="flex-row items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle>Use this template</CardTitle>
<p className="text-sm text-muted">
Instantiating copies the body and fields into the engagement as a draft artefact, and
records which version it came from.
</p>
</div>
</CardHeader>
<CardContent className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
{!mayWrite ? (
<p className="text-sm text-muted">{WRITE_DENIED}</p>
) : openEngagements.length === 0 ? (
<p className="text-sm text-muted">
No open engagement to instantiate into.{' '}
<Link to="/motion/engagements" className="underline underline-offset-4">
Open one against a demand deal
</Link>{' '}
first.
</p>
) : (
<>
<Select value={engagementId || undefined} onValueChange={setEngagementId}>
<SelectTrigger aria-label="Choose an engagement" className="h-11 min-w-0">
<SelectValue placeholder="Choose an engagement" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{openEngagements.map((engagement) => (
<SelectItem key={engagement.id} value={engagement.id}>
{engagement.dealName ?? 'Unnamed deal'}
{engagement.accountName ? ` · ${engagement.accountName}` : ''}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
variant="primary"
disabled={!engagementId || instantiate.isPending}
onClick={() => instantiate.mutate(engagementId)}
>
Instantiate
</Button>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>The document</CardTitle>
</CardHeader>
<CardContent>
{template.body.trim() ? (
<Markdown content={template.body} />
) : (
<p className="text-sm text-muted">This version has no body.</p>
)}
</CardContent>
</Card>
<FieldsView kind={template.kind} fields={template.fields} />
<Lineage lineage={lineage} currentId={template.id} slug={template.slug} />
</div>
);
}
// ---------------------------------------------------------------- fragments
const WRITE_DENIED = 'Authoring in the library needs the motion:write permission.';
function BackLink() {
return (
<Link
to="/motion/library"
className="tap inline-flex min-h-11 w-fit items-center gap-2 text-sm text-muted hover:text-fg"
>
<ArrowLeft className="size-4" aria-hidden />
Back to the library
</Link>
);
}
function Restricted({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<div className="flex min-w-0 flex-col gap-4">
<BackLink />
<Card>
<CardContent className="pt-5">
<EmptyState icon={icon} title={title} description={description} />
</CardContent>
</Card>
</div>
);
}
/**
* Where this version came from, in one sentence a reader can act on.
*
* Three facts, and each is absent for a legitimate reason: v1 supersedes
* nothing, an authored template was never promoted, and a promotion older than
* the overview's window is known to exist without its engagement being named.
*/
function Provenance({
template,
supersedes,
engagementId,
dealName,
}: {
template: TemplateSummary;
supersedes: TemplateSummary | null;
engagementId: string | null;
dealName: string | null;
}) {
const promoted = Boolean(template.originArtifactId);
if (!supersedes && !promoted) return null;
return (
<div className="flex min-w-0 gap-3 rounded-xl border border-border bg-surface-2/60 p-3 text-sm">
<GitBranch className="size-4 shrink-0 text-muted" aria-hidden />
<p className="min-w-0 break-words">
<span className="nums font-medium">Version {template.version}</span>
{supersedes ? (
<>
, superseding{' '}
<Link
to={`/motion/library/${supersedes.id}`}
className="underline underline-offset-4 hover:text-accent-fg"
>
v{supersedes.version}
</Link>
</>
) : null}
{promoted ? (
<>
, promoted from an artefact
{engagementId ? (
<>
{' '}on{' '}
<Link
to={`/motion/engagements/${engagementId}`}
className="underline underline-offset-4 hover:text-accent-fg"
>
{dealName ?? 'its engagement'}
</Link>
</>
) : (
' proved in an engagement'
)}
</>
) : null}
. <span className="text-muted">Created {shortDate(template.createdAt)}.</span>
</p>
</div>
);
}
function Lineage({
lineage,
currentId,
slug,
}: {
lineage: TemplateSummary[];
currentId: string;
slug: string;
}) {
return (
<Card>
<CardHeader>
<CardTitle>Version history</CardTitle>
<p className="text-sm text-muted">
Every version of <span className="font-mono text-xs">{slug}</span> you can see, oldest
first. Versions private to somebody else are not listed.
</p>
</CardHeader>
<CardContent className="flex min-w-0 flex-col gap-2">
{lineage.map((version) => (
<LineageRow
key={version.id}
version={version}
current={version.id === currentId}
/>
))}
</CardContent>
</Card>
);
}
/**
* One version in the history.
*
* The whole row navigates rather than the title alone, which was a single line
* of text and about 20px of target on a phone. The version already open is a
* plain div instead: a link to where you already are is a tap that does
* nothing, which is worse than no target at all.
*/
function LineageRow({ version, current }: { version: TemplateSummary; current: boolean }) {
const body = (
<>
<div className="min-w-0">
<p className="min-w-0 break-words font-medium">{version.title}</p>
<p className="mt-1 min-w-0 truncate text-xs text-muted">
{MOTION_VISIBILITY_LABELS[version.visibility]} ·{' '}
<span className="nums">{version.usageCount}</span> use
{version.usageCount === 1 ? '' : 's'} · {relativeTime(version.updatedAt)}
</p>
</div>
<Badge tone={current ? 'accent' : 'neutral'} className="nums shrink-0">
v{version.version}
</Badge>
</>
);
const shape = 'flex min-h-11 min-w-0 items-start justify-between gap-3 rounded-lg p-3';
if (current) {
return <div className={cn(shape, 'bg-surface-2 ring-1 ring-border')}>{body}</div>;
}
return (
<Link
to={`/motion/library/${version.id}`}
className={cn(shape, 'tap bg-surface-2/60 hover:bg-surface-2')}
>
{body}
</Link>
);
}
/**
* The draft editor, shown only while `canEdit` holds.
*
* No `kind` and no `visibility` field, matching the endpoint: a lineage that
* changes kind halfway is a different template wearing the same slug, and
* visibility moves through Publish, where the capability check lives.
*/
function TemplateEditor({
initial,
pending,
onCancel,
onSave,
}: {
initial: EditState;
pending: boolean;
onCancel(): void;
onSave(input: EditState): void;
}) {
const [form, setForm] = useState(initial);
const set = <Key extends keyof EditState>(key: Key, value: EditState[Key]) =>
setForm((current) => ({ ...current, [key]: value }));
return (
<Card>
<CardContent className="pt-5">
<form
className="flex min-w-0 flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
onSave(form);
}}
>
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="template-title">Title</Label>
<Input
id="template-title"
required
value={form.title}
onChange={(event) => set('title', event.target.value)}
/>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="template-stage">Stage it serves</Label>
<Select value={form.stage} onValueChange={(value) => set('stage', value as DemandStage)}>
<SelectTrigger id="template-stage" className="h-11 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{DEMAND_STAGES.map((stage) => (
<SelectItem key={stage} value={stage}>
{DEMAND_STAGE_LABELS[stage]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="template-summary">Summary</Label>
<Textarea
id="template-summary"
required
rows={2}
value={form.summary}
onChange={(event) => set('summary', event.target.value)}
/>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="template-body">Body</Label>
<Textarea
id="template-body"
rows={16}
className="font-mono text-xs"
value={form.body}
onChange={(event) => set('body', event.target.value)}
/>
<p className="text-xs text-muted">
Markdown, with GFM tables and task lists. Structured fields are edited through the
API for now.
</p>
</div>
<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={pending}>
Save draft
</Button>
</div>
</form>
</CardContent>
</Card>
);
}