Fix twenty findings from the Motion review
Each was raised by a reviewer and then survived an independent attempt to refute it. The four that mattered most: - A third of the starter library was invisible. Three templates authored `fields` shapes no renderer read — decisions, blockingSet, checks, steps and the rest — so about forty records rendered as no DOM at all, in the library and again on the engagement that instantiated them. Nothing failed: a renderer returns null for a key set it does not recognise, and a header-plus-body page looks like a template written that way. FieldsView now reads every key the seeds carry. - "Add a framework" opened a picker that could never match, because the dialog was seeded with both the forced kind and the deal's stage, and qualification serves only the qualification stage. The stage is now dropped when MOTION_KIND_STAGES says the pair is incoherent. - Piggy reported the promotion count as an exact figure capped at 8, against a tile showing the true count beside it. It is now counted in SQL, and all three motion tools carry a ResultScope whose denominator is shared lineages — never rows, never private drafts. - No Motion test went through createApp, so the whole feature could be unmounted with a green suite. That is the AGENTS.md §5 trap that already cost this project read-guards.ts and learn.ts. Also: both sides of the instantiate/edit race now lock, so a template cannot be rewritten under an artefact that has copied it; concurrent engagement opens queue on the deal row and get the 409 the handler already promised rather than a 500; latestScore uses DISTINCT ON instead of losing engagements past a 200-row cap; the migration adds the scored_by_user_id foreign key the schema declares; and the demo clear refunds usage_count for engagements it reaches by cascade, which otherwise left starter templates permanently un-editable. Verified on a fresh database: 16 migrations apply and re-apply as a no-op, both seeds idempotent, usage_count back to zero after --clear. 564 unit tests pass. Every Motion route measures zero horizontal overflow at 393 and 1440 in both themes, and all twelve seeded field trees are asserted onto the screen by scripts/motion-fields-check.mjs. One thing left open deliberately: the shipped qualification scorecard's five bands and MOTION_BANDS' four are calibrated differently. The framework's table is now titled as its own guidance rather than the product's verdict, which removes the contradiction on screen. Making the framework's calibration authoritative over the persisted band column is a product decision nobody has made. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,9 +9,9 @@
|
||||
*/
|
||||
import { Fragment } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Link, useMatch, useResolvedPath } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { NAV_GROUP_HEADING, NAV_GROUPS, visibleNav, type NavItem } from '@/lib/nav';
|
||||
import { activeNavItem, NAV_GROUP_HEADING, NAV_GROUPS, visibleNav, type NavItem } from '@/lib/nav';
|
||||
import { AccountSwitcher } from './AccountSwitcher';
|
||||
import { Button, Label } from './ui';
|
||||
import {
|
||||
@@ -33,6 +33,16 @@ export function AppSidebar() {
|
||||
const identity = useIdentity();
|
||||
const items = visibleNav(identity);
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
const { pathname } = useLocation();
|
||||
/*
|
||||
* One winner for the whole rail, decided here rather than by each row asking
|
||||
* the router about itself. Motion is the first group whose destinations nest
|
||||
* — `/motion` is a prefix of `/motion/library` — and a per-row match lit both
|
||||
* of those at once, so the sidebar and the header disagreed about which page
|
||||
* you were on. `activeNavItem` is the same longest-match helper the header
|
||||
* titles with, which is what keeps them from ever disagreeing again.
|
||||
*/
|
||||
const current = activeNavItem(items, pathname);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -75,7 +85,7 @@ export function AppSidebar() {
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{groupItems.map((item) => (
|
||||
<NavItemRow key={item.to} item={item} />
|
||||
<NavItemRow key={item.to} item={item} isActive={current?.to === item.to} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
@@ -103,14 +113,11 @@ export function AppSidebar() {
|
||||
);
|
||||
}
|
||||
|
||||
function NavItemRow({ item }: { item: NavItem }) {
|
||||
// `asChild` renders the row *as* the link rather than wrapping one, so there is
|
||||
// a single focusable element per row. Active state arrives as a prop because it
|
||||
// is a question about the whole table — see the caller.
|
||||
function NavItemRow({ item, isActive }: { item: NavItem; isActive: boolean }) {
|
||||
const { setOpenMobile, isMobile } = useSidebar();
|
||||
// `asChild` renders the row *as* the link rather than wrapping one, so there
|
||||
// is a single focusable element per row. Active state is asked of the router
|
||||
// instead of compared against a pathname, so `/demand/abc` still lights
|
||||
// Demand and `/` does not light everything.
|
||||
const resolved = useResolvedPath(item.to);
|
||||
const isActive = useMatch({ path: resolved.pathname, end: item.to === '/' }) !== null;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
|
||||
@@ -38,10 +38,10 @@ export function FieldsView({
|
||||
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>;
|
||||
// Emptiness is decided inside each kind's renderer, not here: `renderKind`
|
||||
// hands back an element, and an element is truthy however little it draws.
|
||||
// A guard at this level could only ever read as one and never fire.
|
||||
return <div className={cn('min-w-0 space-y-5', className)}>{renderKind(kind, record)}</div>;
|
||||
}
|
||||
|
||||
function renderKind(kind: MotionKind, fields: Record<string, unknown>): ReactNode {
|
||||
@@ -71,7 +71,11 @@ function renderKind(kind: MotionKind, fields: Record<string, unknown>): ReactNod
|
||||
|
||||
function DiscoveryFields({ fields }: { fields: Record<string, unknown> }) {
|
||||
const sections = recordList(fields.sections);
|
||||
if (sections.length === 0) return null;
|
||||
const decisions = recordList(fields.decisions);
|
||||
const blockingSet = recordList(fields.blockingSet);
|
||||
if (sections.length === 0 && decisions.length === 0 && blockingSet.length === 0) return null;
|
||||
|
||||
const blockingCount = decisions.filter((decision) => decision.blocking === true).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -101,6 +105,84 @@ function DiscoveryFields({ fields }: { fields: Record<string, unknown> }) {
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
|
||||
{decisions.length === 0 ? null : (
|
||||
<Section
|
||||
title="Decisions"
|
||||
// A decision brief is read to find what is still open, and a blocking
|
||||
// row is a different object from the rest: it ends the deal rather
|
||||
// than delaying it, so the count leads.
|
||||
aside={blockingCount > 0 ? `${blockingCount} of ${decisions.length} blocking` : undefined}
|
||||
>
|
||||
<div className="min-w-0 space-y-3">
|
||||
{decisions.map((decision, index) => {
|
||||
const question = text(decision.question);
|
||||
if (!question) return null;
|
||||
const area = text(decision.area);
|
||||
const id = text(decision.id);
|
||||
const options = recordList(decision.options);
|
||||
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">
|
||||
{area ? <Badge tone="neutral">{area}</Badge> : null}
|
||||
{decision.blocking === true ? <Badge tone="danger">Blocking</Badge> : null}
|
||||
{/* The blocking set below names decisions by id, so the id
|
||||
is content here rather than a React key. */}
|
||||
{id ? <span className="whitespace-nowrap text-xs text-muted">{id}</span> : null}
|
||||
</div>
|
||||
<p className="mt-2 min-w-0 break-words font-medium leading-6">{question}</p>
|
||||
<Note label="Answered by" value={text(decision.answeredBy)} />
|
||||
{options.length === 0 ? null : (
|
||||
<ul className="mt-3 min-w-0 space-y-3 border-t border-border pt-3">
|
||||
{options.map((option, optionIndex) => {
|
||||
const choice = text(option.option);
|
||||
if (!choice) return null;
|
||||
const verdict = text(option.verdict);
|
||||
const escalatesTo = text(option.escalatesTo);
|
||||
return (
|
||||
<li key={optionIndex} className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{verdict ? <Badge tone={verdictTone(verdict)}>{verdict}</Badge> : null}
|
||||
{/* Who answers follows the option chosen, not the
|
||||
topic: a lead who settles a weights question
|
||||
off the cuff has priced nothing. */}
|
||||
{escalatesTo ? (
|
||||
<span className="whitespace-nowrap text-xs text-muted">
|
||||
escalates to {escalatesTo}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1.5 min-w-0 break-words text-sm leading-6">{choice}</p>
|
||||
<Note label="Costs" value={text(option.costs)} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{blockingSet.length === 0 ? null : (
|
||||
<Section title="The blocking set" icon={<Skull className="size-4" aria-hidden />}>
|
||||
<ul className="min-w-0 space-y-3">
|
||||
{blockingSet.map((entry, index) => {
|
||||
const item = text(entry.item);
|
||||
if (!item) return null;
|
||||
return (
|
||||
<li key={index} className="min-w-0">
|
||||
<p className="min-w-0 break-words font-medium leading-6">{item}</p>
|
||||
<Pills label="Decisions" items={textList(entry.decisionIds)} />
|
||||
<Note label="Why it kills the deal" value={text(entry.whyItKills)} tone="warning" />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -147,7 +229,14 @@ function QualificationFields({ fields }: { fields: Record<string, unknown> }) {
|
||||
)}
|
||||
|
||||
{bands.length === 0 ? null : (
|
||||
<Section title="What each score means">
|
||||
// Not "what each score means": these bands are the framework author's
|
||||
// own calibration on their own scale, and `MOTION_BANDS` is the
|
||||
// product's — four bands in basis points, shown as a badge on the same
|
||||
// page. The shipped scorecard's five bands genuinely disagree with it
|
||||
// at 7500 (Strategic, against "do not start compute"), so a heading
|
||||
// that read as the product's verdict put two opposite instructions
|
||||
// about one number on one screen.
|
||||
<Section title="What this framework says to do at each score">
|
||||
<ul className="min-w-0 space-y-2">
|
||||
{bands.map((band, index) => {
|
||||
const label = text(band.label);
|
||||
@@ -308,7 +397,30 @@ 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;
|
||||
const budgetSources = recordList(fields.budgetSources);
|
||||
const forecast = asRecord(fields.computeForecast);
|
||||
const questionnaire = recordList(fields.questionnaireMap);
|
||||
const justification = asRecord(fields.soleSourceJustification);
|
||||
const steps = recordList(fields.steps);
|
||||
const championHomework = recordList(fields.championHomework);
|
||||
if (
|
||||
inputs.length === 0 &&
|
||||
packages.length === 0 &&
|
||||
tradeables.length === 0 &&
|
||||
budgetSources.length === 0 &&
|
||||
!forecast &&
|
||||
questionnaire.length === 0 &&
|
||||
!justification &&
|
||||
steps.length === 0 &&
|
||||
championHomework.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Serial is the number the champion's own calendar produces if nobody runs
|
||||
// anything beside anything else, and it is what makes the `parallelWith`
|
||||
// column below worth reading.
|
||||
const serialDays = steps.reduce((total, step) => total + (number(step.typicalDays) ?? 0), 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -356,6 +468,130 @@ function PricingFields({ fields }: { fields: Record<string, unknown> }) {
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{budgetSources.length === 0 ? null : (
|
||||
<Section title="Where the money comes from">
|
||||
<div className="min-w-0 space-y-3">
|
||||
{budgetSources.map((entry, index) => {
|
||||
const source = text(entry.source);
|
||||
if (!source) return null;
|
||||
const days = number(entry.typicalDays);
|
||||
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">{source}</span>
|
||||
{entry.fastest === true ? <Badge tone="positive">Fastest</Badge> : null}
|
||||
{days === null ? null : (
|
||||
<span className="nums whitespace-nowrap text-xs text-muted">~{days} days</span>
|
||||
)}
|
||||
</div>
|
||||
<Note label="Approvers" value={text(entry.approvers)} />
|
||||
{/* Speed and durability are different questions and the
|
||||
fastest source is routinely the least durable one, so
|
||||
neither is shown without the other. */}
|
||||
<Note label="Durability" value={text(entry.durability)} />
|
||||
<Note label="Durability test" value={text(entry.durabilityTest)} />
|
||||
<Note label="Watch for" value={text(entry.watchFor)} tone="warning" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{forecast ? (
|
||||
<Section title="Compute forecast">
|
||||
<Note label="Audience" value={text(forecast.audience)} />
|
||||
<ScrollTable
|
||||
head={['Line', 'Low', 'Expected', 'High']}
|
||||
rows={recordList(forecast.lines).map((line) => [
|
||||
text(line.line),
|
||||
text(line.low),
|
||||
text(line.expected),
|
||||
text(line.high),
|
||||
])}
|
||||
/>
|
||||
<Note label="Ceiling rule" value={text(forecast.ceilingRule)} />
|
||||
{/* Cost is charged against the full commitment, not the hours that
|
||||
sold — AGENTS.md §4 — so the rule travels with the forecast that
|
||||
tempts a reader to model it the other way. */}
|
||||
<Note label="Commitment rule" value={text(forecast.commitmentRule)} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{questionnaire.length === 0 ? null : (
|
||||
<Section title="AI vendor questionnaire">
|
||||
<ScrollTable
|
||||
head={['Topic', 'Answered from']}
|
||||
rows={questionnaire.map((entry) => [text(entry.topic), text(entry.source)])}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{justification ? (
|
||||
<Section title="Sole-source justification">
|
||||
<Note label="When it is needed" value={text(justification.whenNeeded)} />
|
||||
{recordList(justification.paragraphs).map((paragraph, index) => {
|
||||
const heading = text(paragraph.heading);
|
||||
if (!heading) return null;
|
||||
const draft = text(paragraph.draft);
|
||||
return (
|
||||
<div key={index} className="mt-3 min-w-0">
|
||||
<p className="min-w-0 break-words text-sm font-medium leading-6">{heading}</p>
|
||||
{draft ? (
|
||||
// The champion pastes this into their own requisition, so
|
||||
// the whitespace the author wrote is part of the paragraph.
|
||||
<p className="mt-1.5 min-w-0 whitespace-pre-wrap break-words rounded-lg border border-border bg-surface-2 p-3 text-sm leading-6">
|
||||
{draft}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Note label="Note" value={text(justification.note)} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{steps.length === 0 ? null : (
|
||||
<Section
|
||||
title="The critical path"
|
||||
aside={serialDays > 0 ? `${serialDays} days end to end, serially` : undefined}
|
||||
>
|
||||
<ol className="min-w-0 space-y-3">
|
||||
{steps.map((entry, index) => {
|
||||
const step = text(entry.step);
|
||||
if (!step) return null;
|
||||
const days = number(entry.typicalDays);
|
||||
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">
|
||||
<span className="min-w-0 break-words font-medium leading-6">{step}</span>
|
||||
{days === null ? null : (
|
||||
<span className="nums whitespace-nowrap text-xs text-muted">~{days} days</span>
|
||||
)}
|
||||
</div>
|
||||
<Note label="Produces" value={text(entry.produces)} />
|
||||
<Note label="Runs beside" value={text(entry.parallelWith)} />
|
||||
{/* Procurement never says no, it goes quiet, and the stated
|
||||
reason for the silence is almost never the real one — so
|
||||
the symptom and the move are the part that gets read. */}
|
||||
<Note label="Stall symptom" value={text(entry.stallSymptom)} tone="warning" />
|
||||
<Note label="Unstick move" value={text(entry.unstickMove)} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{championHomework.length === 0 ? null : (
|
||||
<Section title="The champion's homework">
|
||||
<ScrollTable
|
||||
head={['What they do', 'Why']}
|
||||
rows={championHomework.map((entry) => [text(entry.task), text(entry.why)])}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -473,7 +709,21 @@ 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;
|
||||
const checks = recordList(fields.checks);
|
||||
const fallbacks = recordList(fields.fallbacks);
|
||||
const firstThirtyDays = recordList(fields.firstThirtyDays);
|
||||
if (
|
||||
stages.length === 0 &&
|
||||
!research &&
|
||||
promotion.length === 0 &&
|
||||
checks.length === 0 &&
|
||||
fallbacks.length === 0 &&
|
||||
firstThirtyDays.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gateCount = checks.filter((check) => check.gate === true).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -504,6 +754,79 @@ function PlaybookFields({ fields }: { fields: Record<string, unknown> }) {
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{checks.length === 0 ? null : (
|
||||
<Section
|
||||
title="Readiness checks"
|
||||
// A checklist nobody can fail is a document. The gates are what make
|
||||
// this one a gate, so their share is stated before the list.
|
||||
aside={gateCount > 0 ? `${gateCount} of ${checks.length} are gates` : undefined}
|
||||
>
|
||||
<ul className="min-w-0 space-y-3">
|
||||
{checks.map((entry, index) => {
|
||||
const check = text(entry.check);
|
||||
if (!check) return null;
|
||||
const area = text(entry.area);
|
||||
const id = text(entry.id);
|
||||
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">
|
||||
{area ? <Badge tone="neutral">{area}</Badge> : null}
|
||||
{/* A gate stops the traffic ramp rather than being work
|
||||
somebody catches up on, which is the same reason a
|
||||
POC's kill gate is the one property that earns colour. */}
|
||||
{entry.gate === true ? <Badge tone="danger">Gate</Badge> : null}
|
||||
{/* The fallbacks and the first thirty days name checks by
|
||||
id, so the id is content rather than a React key. */}
|
||||
{id ? <span className="whitespace-nowrap text-xs text-muted">{id}</span> : null}
|
||||
</div>
|
||||
<p className="mt-2 min-w-0 break-words leading-6">{check}</p>
|
||||
<Note label="Evidence" value={text(entry.evidence)} />
|
||||
<Note label="Owner" value={text(entry.owner)} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{fallbacks.length === 0 ? null : (
|
||||
<Section title="When it fails" icon={<TriangleAlert className="size-4" aria-hidden />}>
|
||||
<ul className="min-w-0 space-y-3">
|
||||
{fallbacks.map((entry, index) => {
|
||||
const failure = text(entry.failure);
|
||||
if (!failure) 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">{failure}</p>
|
||||
{/* A response nobody can trigger is not a fallback, so how
|
||||
the failure is detected is shown before what to do. */}
|
||||
<Note label="Detection" value={text(entry.detection)} />
|
||||
<Note label="Response" value={text(entry.response)} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{firstThirtyDays.length === 0 ? null : (
|
||||
<Section title="The first thirty days">
|
||||
<ol className="min-w-0 space-y-3">
|
||||
{firstThirtyDays.map((entry, index) => {
|
||||
const when = text(entry.when);
|
||||
if (!when) 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">{when}</p>
|
||||
<Note label="Watch" value={text(entry.watch)} />
|
||||
<Note label="Escalate if" value={text(entry.escalateIf)} tone="warning" />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{promotion.length === 0 ? null : (
|
||||
<Section title="What to promote, and when">
|
||||
<ScrollTable
|
||||
@@ -624,6 +947,18 @@ function ScrollTable({ head, rows }: { head: string[]; rows: (string | null)[][]
|
||||
|
||||
// -------------------------------------------------------------------- narrowing
|
||||
|
||||
/**
|
||||
* A decision option's verdict colours its badge. The text stays as the author
|
||||
* wrote it and an unrecognised verdict keeps the neutral tone, so a brief that
|
||||
* adds a fourth one still reads rather than losing its options to a colour
|
||||
* lookup that matched nothing.
|
||||
*/
|
||||
function verdictTone(verdict: string): 'positive' | 'danger' | 'neutral' {
|
||||
if (verdict === 'recommended') return 'positive';
|
||||
if (verdict === 'avoid') return 'danger';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
Reference in New Issue
Block a user