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:
@@ -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 0–4 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 0–4 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 0–4 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;
|
||||
}
|
||||
@@ -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)}%`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user