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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 18:27:03 -07:00
parent 99d165b5e5
commit 516685526c
61 changed files with 13013 additions and 29 deletions
+2
View File
@@ -72,6 +72,7 @@ import { createNotionImportRoutes, NOTION_OAUTH_CALLBACK_PATH } from './routes/n
import { createGrowthRoutes } from './routes/growth';
import { createCalendarRoutes } from './routes/calendar';
import { createLearnRoutes, LEARN_ACCESS_PATH, LEARN_PUBLIC_PATH } from './routes/learn';
import { createMotionRoutes } from './routes/motion';
import { createReadGuardRoutes } from './routes/read-guards';
import { createActivityRoutes } from './routes/activities';
import { NotificationOutbox } from './services/notification-outbox';
@@ -266,6 +267,7 @@ export function createApp(
app.route('/', createGrowthRoutes(db));
app.route('/', createCalendarRoutes(db));
app.route('/', createLearnRoutes(db));
app.route('/', createMotionRoutes(db));
app.route(
'/',
createPiggyChatRoutes({
+933
View File
@@ -0,0 +1,933 @@
/**
* Motion — the HTTP surface for the library, the engagements and the loop.
*
* Thin by design. Every decision worth arguing about — who may see a private
* template, what promotion writes, how a score is computed — is in
* `services/motion.ts`, so Piggy reaches the same answers in-process without
* going through a handler. The writes are exported `MutationDefinition`
* factories in the register of `capacity-writes.ts`, which is what lets a test
* drive the rules in §7 without an HTTP server or a database.
*
* Two things here are not obvious from the endpoint table.
*
* **`motion:write` and `motion:publish` are team capabilities held on any
* team**, so they are enforced with `requireAnyTeamCapability` rather than a
* `{ capability, team }` pair. A motion template belongs to a person and a
* lineage, not to supply or demand; picking a team to check against would mean
* inventing one, and inventing one is how a research lead ends up unable to
* publish the reference architecture they are the one writing.
*
* **Creating or forking straight to `shared` needs `motion:publish`.** The
* publish endpoint is not the only way into the shared library — `POST
* /templates` and `POST /templates/:id/versions` both take a `visibility` — so
* the gate is applied in all three places rather than on the one door that
* happens to be named after it.
*/
import {
ARTIFACT_STATUSES,
DEMAND_STAGES,
ENGAGEMENT_STATUSES,
MOTION_KINDS,
MOTION_MAX_DIMENSION_SCORE,
MOTION_MIN_DIMENSION_SCORE,
MOTION_VISIBILITIES,
motionBand,
permissionGranted,
resolveWritePermissionGrants,
} from '@pig/core';
import type {
Database,
Engagement,
EngagementArtifact,
MotionTemplate,
QualificationScore,
} from '@pig/db';
import { demandDeals, engagementArtifacts, engagements, motionTemplates } from '@pig/db';
import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { requireAnyTeamCapability, type Principal } from '../lib/auth';
import {
apiError,
bodylessMutation,
MutationError,
mutation,
type ApiEnv,
type MutationDefinition,
} from '../lib/mutation';
import {
assertTemplateEditable,
assertTemplateWritable,
instantiateArtifact,
loadEngagement,
loadTemplateForWrite,
lockNewestVersion,
motionSlug,
MotionService,
newestVisibleInLineage,
ownsTemplate,
promoteArtifact,
recordScore,
versionConflict,
type MotionTransaction,
type MotionViewer,
} from '../services/motion';
// ------------------------------------------------------------------- schemas
const uuid = z.string().uuid();
const title = z.string().trim().min(1).max(200);
const summary = z.string().trim().min(1).max(2_000);
/** Markdown, and long: a playbook is a document, not a field. */
const bodyText = z.string().max(200_000);
const slug = z.string().trim().min(1).max(80);
const fields = z.record(z.unknown()).nullable();
const templateCreateSchema = z
.object({
kind: z.enum(MOTION_KINDS),
slug: slug.optional(),
title,
summary,
body: bodyText,
stage: z.enum(DEMAND_STAGES),
fields: fields.optional(),
visibility: z.enum(MOTION_VISIBILITIES).optional(),
})
.strict();
/**
* No `kind` and no `visibility`. A lineage that changes kind halfway is a
* different template wearing the same slug, and visibility moves through
* `/publish`, which is where the capability check lives.
*/
const templateUpdateSchema = z
.object({ title, summary, body: bodyText, stage: z.enum(DEMAND_STAGES), fields })
.partial()
.strict()
.refine((input) => Object.keys(input).length > 0, 'At least one change is required.');
const templateVersionSchema = z
.object({
title: title.optional(),
summary: summary.optional(),
body: bodyText.optional(),
stage: z.enum(DEMAND_STAGES).optional(),
fields: fields.optional(),
visibility: z.enum(MOTION_VISIBILITIES).optional(),
})
.strict();
const emptySchema = z.object({}).strict();
const engagementCreateSchema = z
.object({
demandDealId: uuid,
playbookTemplateId: uuid.nullable().optional(),
ownerUserId: uuid.nullable().optional(),
summary: z.string().trim().max(2_000).nullable().optional(),
})
.strict();
const engagementUpdateSchema = z
.object({
status: z.enum(ENGAGEMENT_STATUSES),
ownerUserId: uuid.nullable(),
playbookTemplateId: uuid.nullable(),
summary: z.string().trim().max(2_000).nullable(),
})
.partial()
.strict()
.refine((input) => Object.keys(input).length > 0, 'At least one change is required.');
const artifactCreateSchema = z
.object({
templateId: uuid.optional(),
kind: z.enum(MOTION_KINDS).optional(),
stage: z.enum(DEMAND_STAGES).optional(),
title: title.optional(),
body: bodyText.optional(),
fields: fields.optional(),
status: z.enum(ARTIFACT_STATUSES).optional(),
})
.strict()
.refine(
(input) =>
Boolean(input.templateId) ||
Boolean(input.kind && input.stage && input.title && input.body !== undefined),
'Supply a templateId to instantiate, or a kind, stage, title and body.',
);
const artifactUpdateSchema = z
.object({
title,
body: bodyText,
fields,
stage: z.enum(DEMAND_STAGES),
status: z.enum(ARTIFACT_STATUSES),
archived: z.boolean(),
})
.partial()
.strict()
.refine((input) => Object.keys(input).length > 0, 'At least one change is required.');
const promoteSchema = z
.object({ slug: slug.optional(), title: title.optional(), summary: summary.optional() })
.strict();
/**
* Weights and scores are integers, and the total is recomputed server-side from
* these — the client never posts a score. Bounds come from `@pig/core` so the
* scale is defined in one place.
*/
const scoreSchema = z
.object({
dimensions: z
.array(
z
.object({
id: z.string().trim().min(1).max(120),
weight: z.number().int().min(0).max(1_000),
score: z
.number()
.int()
.min(MOTION_MIN_DIMENSION_SCORE)
.max(MOTION_MAX_DIMENSION_SCORE),
})
.strict(),
)
.max(50),
frameworkTemplateId: uuid.nullable().optional(),
note: z.string().trim().max(2_000).nullable().optional(),
})
.strict();
const filtersSchema = z
.object({
kind: z.enum(MOTION_KINDS).optional(),
stage: z.enum(DEMAND_STAGES).optional(),
visibility: z.enum(MOTION_VISIBILITIES).optional(),
q: z.string().trim().max(200).optional(),
all: z.string().optional(),
})
.strict();
const engagementFiltersSchema = z
.object({ status: z.enum(ENGAGEMENT_STATUSES).optional() })
.strict();
// ------------------------------------------------------------------- helpers
function viewerOf(principal: Principal): MotionViewer {
return { userId: principal.userId, isPlatformAdmin: principal.isPlatformAdmin };
}
const authorizeWrite = {
authorize: (p: Principal) => requireAnyTeamCapability(p, 'motion:write'),
};
const authorizePublish = {
authorize: (p: Principal) => requireAnyTeamCapability(p, 'motion:publish'),
};
/**
* A template id somebody sent, checked before it is stored.
*
* `playbook_template_id` and `framework_template_id` are provenance, and they
* are returned book-wide on every engagement summary. Written raw, an id
* nobody but its owner may read becomes public through the engagement that
* cites it, and a wrong id is a foreign-key 500 rather than a 404. Reading it
* through the visibility filter makes both cases a clean not-found.
*/
async function checkedTemplateId(
tx: MotionTransaction,
principal: Principal,
id: string | null | undefined,
): Promise<void> {
if (!id) return;
await loadTemplateForWrite(tx, viewerOf(principal), id);
}
/** The same gate as `authorizePublish`, applied once the body says `shared`. */
function requirePublishFor(principal: Principal, visibility: string | undefined): void {
if (visibility === 'shared') requireAnyTeamCapability(principal, 'motion:publish');
}
/**
* The hint the detail page renders its Publish button from. It checks the
* `write` scope as well as the capability, because `requireAnyTeamCapability`
* does — a read-only API key belonging to a lead would otherwise be told it may
* publish and then be refused `insufficient_scope` by the endpoint itself.
*/
function canPublish(principal: Principal): boolean {
return (
principal.scopes.includes('write') &&
permissionGranted(resolveWritePermissionGrants(principal), 'motion:publish')
);
}
function requiredId(params: Readonly<Record<string, string>>, resource: string): string {
const id = params.id;
if (!id) throw MutationError.notFound(resource);
return id;
}
// ------------------------------------------------------------ library writes
export function motionTemplateCreateDefinition(): MutationDefinition<
typeof templateCreateSchema,
{ template: MotionTemplate }
> {
return {
schema: templateCreateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid motion template.',
async mutate({ input, principal, tx, now }) {
requirePublishFor(principal, input.visibility);
const slug = motionSlug(input.slug ?? input.title);
// Checked rather than left to `(slug, version)`, for the reason the
// engagement create gives below: a title that derives an existing slug —
// "Proposal Blocks" is one of the nine shipped starters — would otherwise
// be a 500 with `Internal error` and no way for the caller to tell what
// they collided with.
if (await lockNewestVersion(tx, slug)) {
throw new MutationError(
'template_slug_exists',
`A template lineage already uses the slug "${slug}". POST /api/motion/templates/<id>/versions to add a version to it, or supply a different slug.`,
409,
);
}
const [created] = await tx
.insert(motionTemplates)
.values({
kind: input.kind,
slug,
version: 1,
title: input.title,
summary: input.summary,
body: input.body,
fields: input.fields ?? null,
stage: input.stage,
visibility: input.visibility ?? 'private',
// Never null. The CHECK refuses an unowned private row, and an
// unowned private row is one nobody could ever read back.
ownerUserId: principal.userId,
createdAt: now,
updatedAt: now,
})
.returning()
.catch(versionConflict);
if (!created) throw new Error('Motion template insert returned no row');
return {
data: { template: created },
activity: {
type: 'note',
subject: `Added a motion template: ${created.title}`,
meta: {
action: 'motion_template.created',
templateId: created.id,
kind: created.kind,
slug: created.slug,
visibility: created.visibility,
},
},
};
},
};
}
export function motionTemplateUpdateDefinition(): MutationDefinition<
typeof templateUpdateSchema,
{ template: MotionTemplate }
> {
return {
schema: templateUpdateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid motion template change.',
async mutate({ input, params, principal, tx, now }) {
const viewer = viewerOf(principal);
const existing = await loadTemplateForWrite(
tx,
viewer,
requiredId(params, 'Motion template'),
);
assertTemplateWritable(viewer, existing);
assertTemplateEditable(existing);
const [updated] = await tx
.update(motionTemplates)
.set({
...(input.title !== undefined ? { title: input.title } : {}),
...(input.summary !== undefined ? { summary: input.summary } : {}),
...(input.body !== undefined ? { body: input.body } : {}),
...(input.stage !== undefined ? { stage: input.stage } : {}),
...(input.fields !== undefined ? { fields: input.fields } : {}),
updatedAt: now,
})
.where(eq(motionTemplates.id, existing.id))
.returning();
if (!updated) throw MutationError.notFound('Motion template');
return {
data: { template: updated },
activity: {
type: 'note',
subject: `Edited a motion template: ${updated.title}`,
meta: {
action: 'motion_template.updated',
templateId: updated.id,
fields: Object.keys(input),
},
},
};
},
};
}
/**
* A new version of a lineage — the answer to a `409 template_in_use`, and the
* "fork to private" affordance, in one endpoint.
*
* Ownership is deliberately NOT required: a shared template belongs to the
* book, and forking it into your own private draft is what a library is for.
* What is required is `motion:publish` if the fork is to land shared.
*/
export function motionTemplateVersionDefinition(): MutationDefinition<
typeof templateVersionSchema,
{ template: MotionTemplate }
> {
return {
schema: templateVersionSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid motion template version.',
async mutate({ input, params, principal, tx, now }) {
requirePublishFor(principal, input.visibility);
const viewer = viewerOf(principal);
const source = await loadTemplateForWrite(
tx,
viewer,
requiredId(params, 'Motion template'),
);
// Two different questions, deliberately answered by two queries. The
// version has to be allocated against the whole lineage or it collides
// with a private fork nobody else can see, but `supersedes_id` must name
// a row this caller could actually fetch — otherwise a fork of a shared
// v1 comes back claiming to supersede somebody's private v4, which both
// leaks that the private versions exist and records a lineage edge that
// is not the one the user made.
const newest = await lockNewestVersion(tx, source.slug);
const previous = (await newestVisibleInLineage(tx, viewer, source.slug)) ?? source;
const [created] = await tx
.insert(motionTemplates)
.values({
kind: source.kind,
slug: source.slug,
version: (newest?.version ?? source.version) + 1,
title: input.title ?? source.title,
summary: input.summary ?? source.summary,
body: input.body ?? source.body,
fields: input.fields !== undefined ? input.fields : source.fields,
stage: input.stage ?? source.stage,
// Private unless asked otherwise, so forking a shared template is
// never accidentally a publication.
visibility: input.visibility ?? 'private',
ownerUserId: principal.userId,
supersedesId: previous.id,
isSystem: false,
createdAt: now,
updatedAt: now,
})
.returning()
.catch(versionConflict);
if (!created) throw new Error('Motion template version insert returned no row');
return {
data: { template: created },
activity: {
type: 'note',
subject: `New version of ${created.title} (v${created.version})`,
meta: {
action: 'motion_template.versioned',
templateId: created.id,
supersedesId: previous.id,
slug: created.slug,
version: created.version,
},
},
};
},
};
}
export function motionTemplatePublishDefinition(): MutationDefinition<
typeof emptySchema,
{ template: MotionTemplate }
> {
return {
schema: emptySchema,
permission: authorizePublish,
invalidMessage: 'Invalid publish request.',
async mutate({ params, principal, tx, now }) {
const viewer = viewerOf(principal);
const existing = await loadTemplateForWrite(
tx,
viewer,
requiredId(params, 'Motion template'),
);
assertTemplateWritable(viewer, existing);
// Idempotent: publishing twice is the same statement made twice, and a
// 409 here would only ever fire on a double-clicked button.
const [published] =
existing.visibility === 'shared'
? [existing]
: await tx
.update(motionTemplates)
.set({ visibility: 'shared', updatedAt: now })
.where(eq(motionTemplates.id, existing.id))
.returning();
if (!published) throw MutationError.notFound('Motion template');
return {
data: { template: published },
activity: {
type: 'note',
subject: `Published to the library: ${published.title} (v${published.version})`,
meta: {
action: 'motion_template.published',
templateId: published.id,
alreadyShared: existing.visibility === 'shared',
},
},
};
},
};
}
/**
* Archive, never delete: an engagement artifact records the template it came
* from, and provenance that can vanish is not provenance.
*/
export function motionTemplateArchiveDefinition(): MutationDefinition<
typeof emptySchema,
{ id: string; archivedAt: Date | null }
> {
return {
schema: emptySchema,
permission: authorizeWrite,
invalidMessage: 'Invalid template removal.',
async mutate({ params, principal, tx, now }) {
const viewer = viewerOf(principal);
const existing = await loadTemplateForWrite(
tx,
viewer,
requiredId(params, 'Motion template'),
);
assertTemplateWritable(viewer, existing);
const [archived] = existing.archivedAt
? [existing]
: await tx
.update(motionTemplates)
.set({ archivedAt: now, updatedAt: now })
.where(eq(motionTemplates.id, existing.id))
.returning();
if (!archived) throw MutationError.notFound('Motion template');
return {
data: { id: archived.id, archivedAt: archived.archivedAt },
activity: {
type: 'note',
subject: `Archived a motion template: ${archived.title}`,
meta: {
action: 'motion_template.archived',
templateId: archived.id,
alreadyArchived: existing.archivedAt !== null,
},
},
};
},
};
}
// --------------------------------------------------------- engagement writes
export function motionEngagementCreateDefinition(): MutationDefinition<
typeof engagementCreateSchema,
{ engagement: Engagement }
> {
return {
schema: engagementCreateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid engagement.',
async mutate({ input, principal, tx, now }) {
const [deal] = await tx
.select()
.from(demandDeals)
.where(eq(demandDeals.id, input.demandDealId))
.limit(1);
if (!deal) throw MutationError.notFound('Demand deal');
await checkedTemplateId(tx, principal, input.playbookTemplateId);
// Checked rather than left to the unique constraint, so the caller gets
// the id of the engagement that already exists instead of a 500.
const [existing] = await tx
.select()
.from(engagements)
.where(eq(engagements.demandDealId, deal.id))
.limit(1);
if (existing) {
throw new MutationError(
'engagement_exists',
`This deal already has an engagement (${existing.id}).`,
409,
);
}
const [created] = await tx
.insert(engagements)
.values({
demandDealId: deal.id,
playbookTemplateId: input.playbookTemplateId ?? null,
ownerUserId: input.ownerUserId ?? principal.userId,
summary: input.summary ?? null,
openedAt: now,
createdAt: now,
updatedAt: now,
})
.returning();
if (!created) throw new Error('Engagement insert returned no row');
return {
data: { engagement: created },
activity: {
type: 'note',
subject: `Opened an engagement on ${deal.name}`,
accountId: deal.accountId,
demandDealId: deal.id,
meta: { action: 'engagement.opened', engagementId: created.id },
},
};
},
};
}
export function motionEngagementUpdateDefinition(): MutationDefinition<
typeof engagementUpdateSchema,
{ engagement: Engagement }
> {
return {
schema: engagementUpdateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid engagement change.',
async mutate({ input, params, principal, tx, now }) {
const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
await checkedTemplateId(tx, principal, input.playbookTemplateId);
const [updated] = await tx
.update(engagements)
.set({
...(input.status !== undefined ? { status: input.status } : {}),
...(input.ownerUserId !== undefined ? { ownerUserId: input.ownerUserId } : {}),
...(input.playbookTemplateId !== undefined
? { playbookTemplateId: input.playbookTemplateId }
: {}),
...(input.summary !== undefined ? { summary: input.summary } : {}),
// `closed_at` follows the status rather than being sent, so a
// reopened engagement cannot keep a close date it no longer has.
...(input.status !== undefined
? { closedAt: input.status === 'won' || input.status === 'lost' ? now : null }
: {}),
updatedAt: now,
})
.where(eq(engagements.id, engagement.id))
.returning();
if (!updated) throw MutationError.notFound('Engagement');
return {
data: { engagement: updated },
activity: {
type: 'note',
subject: `Updated the engagement on ${deal.name}`,
accountId: deal.accountId,
demandDealId: deal.id,
meta: {
action: 'engagement.updated',
engagementId: updated.id,
fields: Object.keys(input),
status: updated.status,
},
},
};
},
};
}
export function motionArtifactCreateDefinition(): MutationDefinition<
typeof artifactCreateSchema,
{ artifact: EngagementArtifact }
> {
return {
schema: artifactCreateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid engagement artefact.',
async mutate({ input, params, principal, tx, now }) {
const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
const { artifact, template } = await instantiateArtifact(
tx,
viewerOf(principal),
{ ...input, engagementId: engagement.id },
now,
);
return {
data: { artifact },
activity: {
type: 'note',
subject: template
? `Instantiated ${template.title} (v${template.version}) on ${deal.name}`
: `Added a ${artifact.kind} artefact to ${deal.name}`,
accountId: deal.accountId,
demandDealId: deal.id,
meta: {
action: 'engagement_artifact.created',
engagementId: engagement.id,
artifactId: artifact.id,
templateId: template?.id ?? null,
kind: artifact.kind,
stage: artifact.stage,
},
},
};
},
};
}
/**
* Not restricted to the author: an engagement is worked by a team, and an
* artifact nobody but its author can finish is one that stalls the week they go
* on leave. The library — which everybody copies — is what ownership guards.
*/
export function motionArtifactUpdateDefinition(): MutationDefinition<
typeof artifactUpdateSchema,
{ artifact: EngagementArtifact }
> {
return {
schema: artifactUpdateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid artefact change.',
async mutate({ input, params, tx, now }) {
const id = requiredId(params, 'Engagement artefact');
const [existing] = await tx
.select()
.from(engagementArtifacts)
.where(eq(engagementArtifacts.id, id))
.limit(1);
if (!existing) throw MutationError.notFound('Engagement artefact');
const { deal } = await loadEngagement(tx, existing.engagementId);
const [updated] = await tx
.update(engagementArtifacts)
.set({
...(input.title !== undefined ? { title: input.title } : {}),
...(input.body !== undefined ? { body: input.body } : {}),
...(input.fields !== undefined ? { fields: input.fields } : {}),
...(input.stage !== undefined ? { stage: input.stage } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.archived !== undefined ? { archivedAt: input.archived ? now : null } : {}),
updatedAt: now,
})
.where(eq(engagementArtifacts.id, existing.id))
.returning();
if (!updated) throw MutationError.notFound('Engagement artefact');
return {
data: { artifact: updated },
activity: {
type: 'note',
subject: `Updated ${updated.title} on ${deal.name}`,
accountId: deal.accountId,
demandDealId: deal.id,
meta: {
action: 'engagement_artifact.updated',
artifactId: updated.id,
engagementId: updated.engagementId,
fields: Object.keys(input),
status: updated.status,
},
},
};
},
};
}
export function motionArtifactPromoteDefinition(): MutationDefinition<
typeof promoteSchema,
{ template: MotionTemplate; artifact: EngagementArtifact }
> {
return {
schema: promoteSchema,
permission: authorizePublish,
invalidMessage: 'Invalid promotion.',
async mutate({ input, params, principal, tx, now }) {
const result = await promoteArtifact(
tx,
viewerOf(principal),
requiredId(params, 'Engagement artefact'),
input,
now,
);
return {
data: { template: result.template, artifact: result.artifact },
activity: {
type: 'note',
subject: `Promoted to the library: ${result.template.title} (v${result.template.version})`,
// The account, so the loop lands on the timeline of the deal that
// proved it rather than only in the library.
accountId: result.engagement.deal.accountId,
demandDealId: result.engagement.deal.id,
meta: {
action: 'engagement_artifact.promoted',
artifactId: result.artifact.id,
engagementId: result.engagement.engagement.id,
templateId: result.template.id,
supersedesId: result.supersedes?.id ?? null,
slug: result.template.slug,
version: result.template.version,
},
},
};
},
};
}
export function motionScoreDefinition(): MutationDefinition<
typeof scoreSchema,
{ score: QualificationScore }
> {
return {
schema: scoreSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid qualification score.',
async mutate({ input, params, principal, tx, now }) {
const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
await checkedTemplateId(tx, principal, input.frameworkTemplateId);
const score = await recordScore(tx, viewerOf(principal), engagement.id, input, now);
return {
data: { score },
activity: {
type: 'note',
// Rounded, never truncated — the same rule money follows, for the
// same reason: this line is the only score most people will read.
subject: `Qualified ${deal.name}: ${score.band} (${Math.round(score.basisPoints / 100)}%)`,
accountId: deal.accountId,
demandDealId: deal.id,
meta: {
action: 'qualification_score.recorded',
engagementId: engagement.id,
scoreId: score.id,
basisPoints: score.basisPoints,
band: score.band,
tone: motionBand(score.basisPoints).tone,
},
},
};
},
};
}
// -------------------------------------------------------------------- routes
export function createMotionRoutes(db: Database): Hono<ApiEnv> {
const routes = new Hono<ApiEnv>();
const motion = new MotionService(db);
routes.get('/api/motion', async (c) =>
c.json(await motion.overview(viewerOf(c.get('principal')))),
);
routes.get('/api/motion/templates', async (c) => {
const parsed = filtersSchema.safeParse(c.req.query());
if (!parsed.success) {
return c.json(
apiError('invalid_request', 'Invalid library filter.', parsed.error.issues),
400,
);
}
const { all, ...filters } = parsed.data;
return c.json(
await motion.listTemplates(viewerOf(c.get('principal')), { ...filters, all: all === '1' }),
);
});
routes.get('/api/motion/templates/:id', async (c) => {
const principal = c.get('principal');
const found = await motion.template(viewerOf(principal), c.req.param('id'));
// A private template belonging to somebody else answers exactly as an
// unknown id does. Anything else confirms that it exists.
if (!found) return c.json(apiError('not_found', 'Motion template not found.'), 404);
const owned = ownsTemplate(viewerOf(principal), found.template);
return c.json({
...found,
/** The server's judgement, so the page does not re-derive §7a in TSX. */
canEdit: owned && found.template.usageCount === 0,
canPublish: owned && found.template.visibility === 'private' && canPublish(principal),
});
});
routes.get('/api/motion/engagements', async (c) => {
const parsed = engagementFiltersSchema.safeParse(c.req.query());
if (!parsed.success) {
return c.json(
apiError('invalid_request', 'Invalid engagement filter.', parsed.error.issues),
400,
);
}
return c.json({ engagements: await motion.listEngagements(parsed.data.status) });
});
routes.get('/api/motion/engagements/:id', async (c) => {
const found = await motion.engagement(viewerOf(c.get('principal')), c.req.param('id'));
if (!found) return c.json(apiError('not_found', 'Engagement not found.'), 404);
return c.json(found);
});
routes.post('/api/motion/templates', mutation(db, motionTemplateCreateDefinition()));
routes.patch('/api/motion/templates/:id', mutation(db, motionTemplateUpdateDefinition()));
routes.post(
'/api/motion/templates/:id/versions',
mutation(db, motionTemplateVersionDefinition()),
);
routes.post(
'/api/motion/templates/:id/publish',
bodylessMutation(db, motionTemplatePublishDefinition()),
);
routes.delete(
'/api/motion/templates/:id',
bodylessMutation(db, motionTemplateArchiveDefinition()),
);
routes.post('/api/motion/engagements', mutation(db, motionEngagementCreateDefinition()));
routes.patch('/api/motion/engagements/:id', mutation(db, motionEngagementUpdateDefinition()));
routes.post(
'/api/motion/engagements/:id/artifacts',
mutation(db, motionArtifactCreateDefinition()),
);
routes.patch('/api/motion/artifacts/:id', mutation(db, motionArtifactUpdateDefinition()));
routes.post(
'/api/motion/artifacts/:id/promote',
mutation(db, motionArtifactPromoteDefinition()),
);
routes.post(
'/api/motion/engagements/:id/scores',
mutation(db, motionScoreDefinition()),
);
return routes;
}
+26
View File
@@ -58,6 +58,22 @@ export const READ_RULES: readonly ReadRule[] = [
{ method: 'GET', path: '/api/growth/accounts/:id', capability: 'book:read' },
{ method: 'GET', path: '/api/facts', capability: 'book:read' },
/**
* The motion library is not the cost book. A proposal block, a reference
* architecture and a qualification framework carry no supplier cost and no
* break-even, so gating them on `economics:read` would keep a research lead
* out of the reference architectures they are the ones writing.
*
* The private/shared split is NOT enforced here and could not be: this table
* is keyed on a path and knows nothing about rows. It is a real WHERE clause
* in `services/motion.ts`, which every one of these reads goes through.
*/
{ method: 'GET', path: '/api/motion', capability: 'book:read' },
{ method: 'GET', path: '/api/motion/templates', capability: 'book:read' },
{ method: 'GET', path: '/api/motion/templates/:id', capability: 'book:read' },
{ method: 'GET', path: '/api/motion/engagements', capability: 'book:read' },
{ method: 'GET', path: '/api/motion/engagements/:id', capability: 'book:read' },
{ method: 'GET', path: '/api/team', capability: 'team:read' },
/**
@@ -120,6 +136,16 @@ const PIGGY_PAGE_CAPABILITIES: Readonly<Record<PiggyPageRoute, ReadCapability>>
'/supply': 'book:read',
'/calendar': 'book:read',
'/contracts': 'book:read',
// The motion library is not the cost book. pig_get_motion_summary,
// pig_search_motion_library and pig_get_engagement return templates,
// artifacts and qualification scores — authored practice, carrying no
// supplier cost and no break-even — so the classification follows what those
// tools return rather than what the pages look like. The private/shared rule
// is a separate matter and is enforced in the query, not here: the library
// tool filters to `visibility = 'shared'` unconditionally.
'/motion': 'book:read',
'/motion/library': 'book:read',
'/motion/engagements': 'book:read',
};
const PIGGY_RECORD_CAPABILITIES: Readonly<Record<PiggyRecordType, ReadCapability>> = {
File diff suppressed because it is too large Load Diff
+725
View File
@@ -0,0 +1,725 @@
/**
* Motion — the two rules that make this a system, and the one departure that
* makes it a risk.
*
* The departure first. `permissions.ts` states that every read endpoint returns
* the whole book because no row-level filter exists anywhere in the query
* layer; motion templates are the first exception, and an exception that is
* only enforced by the UI is not an exception, it is a leak with a nice screen
* in front of it. So the first suite renders the actual predicate to SQL and
* asserts on it. It looks like a test of an implementation detail and is not:
* the WHERE clause IS the access policy, and the two ways of getting it wrong —
* dropping the owner comparison, or widening it with `OR owner IS NULL` — both
* produce a query that returns rows and reports nothing.
*
* The rest drive the exported mutation definitions against a scripted
* transaction, in the register of `activities.test.ts`. The fake answers
* queries in call order and records what ran; it is deliberately not a
* database, because a fake that pretends to run SQL is a fake that will one day
* assert a broken query works.
*/
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import { getTableName, isSQLWrapper, isTable, type SQL } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
import { motionScoreBasisPoints } from '@pig/core';
import type { Database, MotionTemplate } from '@pig/db';
import { Hono } from 'hono';
import { AuthError, type Principal } from '../src/lib/auth';
import { executeMutation, MutationError, type ApiEnv } from '../src/lib/mutation';
import {
createMotionRoutes,
motionArtifactCreateDefinition,
motionArtifactPromoteDefinition,
motionEngagementCreateDefinition,
motionEngagementUpdateDefinition,
motionScoreDefinition,
motionTemplateCreateDefinition,
motionTemplatePublishDefinition,
motionTemplateUpdateDefinition,
motionTemplateVersionDefinition,
} from '../src/routes/motion';
import { motionSlug, visibleTemplates } from '../src/services/motion';
import { onTeam, principal } from './helpers/principal';
const OWNER = '00000000-0000-4000-8000-000000000001';
const OTHER = '00000000-0000-4000-8000-0000000000aa';
const DEAL_ID = '00000000-0000-4000-8000-0000000000d1';
const ACCOUNT_ID = '00000000-0000-4000-8000-0000000000ac';
const ENGAGEMENT_ID = '00000000-0000-4000-8000-0000000000e1';
const ARTIFACT_ID = '00000000-0000-4000-8000-0000000000f1';
const TEMPLATE_ID = '00000000-0000-4000-8000-0000000000b1';
const NOW = new Date('2026-08-17T09:00:00.000Z');
/** A demand member: `motion:write`, no `motion:publish`. */
const member = principal(onTeam('demand', 'member'));
/** A demand lead: both, which is the point of the split. */
const lead = principal(onTeam('demand', 'lead'));
// --------------------------------------------------------------- the fixtures
function template(overrides: Partial<MotionTemplate> = {}): MotionTemplate {
return {
id: TEMPLATE_ID,
kind: 'poc',
slug: 'poc-plan',
version: 1,
title: 'POC plan',
summary: 'What a proof of concept must show.',
body: '# POC plan',
fields: null,
stage: 'poc',
visibility: 'private',
ownerUserId: OWNER,
supersedesId: null,
originArtifactId: null,
isSystem: false,
usageCount: 0,
archivedAt: null,
createdAt: NOW,
updatedAt: NOW,
...overrides,
};
}
const deal = { id: DEAL_ID, accountId: ACCOUNT_ID, name: 'DEMO — Northwind training' };
const engagement = { id: ENGAGEMENT_ID, demandDealId: DEAL_ID, status: 'open' };
function artifact(overrides: Record<string, unknown> = {}) {
return {
id: ARTIFACT_ID,
engagementId: ENGAGEMENT_ID,
templateId: null,
kind: 'poc',
stage: 'poc',
title: 'Northwind POC plan',
body: '# What we proved',
fields: null,
status: 'final',
authoredByUserId: OWNER,
promotedTemplateId: null,
archivedAt: null,
createdAt: NOW,
updatedAt: NOW,
...overrides,
};
}
// ------------------------------------------------------------------ the fake
interface Recorded {
events: string[];
inserted: { table: string; row: Record<string, unknown> }[];
updated: { table: string; values: Record<string, unknown> }[];
}
/**
* A transaction that answers selects from a script, in call order, and records
* every write with the table it landed in. Order-dependence is the price of not
* pretending to be Postgres, and it is what makes "no UPDATE ran" assertable.
*/
function database(script: unknown[][]): { db: Database; log: Recorded } {
const log: Recorded = { events: [], inserted: [], updated: [] };
const results = [...script];
const next = (): unknown[] => results.shift() ?? [];
const selectChain = {
from: () => selectChain,
innerJoin: () => selectChain,
leftJoin: () => selectChain,
where: () => selectChain,
orderBy: () => selectChain,
groupBy: () => selectChain,
// `limit` returns the chain rather than a promise so that `.for('update')`
// can follow it, as it does on every query in this feature that allocates
// a version number. The chain is a thenable, so `await` still ends it.
limit: () => selectChain,
for: () => selectChain,
then: (resolve: (rows: unknown[]) => unknown) => resolve(next()),
};
const name = (table: unknown): string => (isTable(table) ? getTableName(table) : 'unknown');
const tx = {
select: () => {
log.events.push('select');
return selectChain;
},
insert: (table: unknown) => ({
values: (row: Record<string, unknown>) => {
log.events.push(`insert:${name(table)}`);
log.inserted.push({ table: name(table), row });
const written = [{ id: `${name(table)}-${log.inserted.length}`, ...row }];
return {
returning: async () => written,
then: (resolve: (value: unknown) => unknown) => resolve(undefined),
};
},
}),
update: (table: unknown) => ({
set: (values: Record<string, unknown>) => ({
where: () => {
log.events.push(`update:${name(table)}`);
log.updated.push({ table: name(table), values });
return Object.assign(Promise.resolve(undefined), {
returning: async () => [{ id: `${name(table)}-updated`, ...values }],
});
},
}),
}),
};
return {
db: {
transaction: async (work: (t: unknown) => Promise<unknown>) => {
log.events.push('transaction');
return work(tx);
},
// The same scripted chain outside a transaction, so a read handler can be
// driven through the mounted routes rather than only its service.
select: tx.select,
} as unknown as Database,
log,
};
}
/** The motion routes with a principal already resolved, as `createApp` mounts them. */
function mounted(db: Database, who: Principal): Hono<ApiEnv> {
const app = new Hono<ApiEnv>();
app.use('*', async (c, next) => {
c.set('principal', who);
await next();
});
app.route('/', createMotionRoutes(db));
return app;
}
function rendered(userId: string, isPlatformAdmin = false) {
const predicate = visibleTemplates({ userId, isPlatformAdmin });
return predicate ? new PgDialect().sqlToQuery(predicate) : null;
}
// ------------------------------------------------------------------- the tests
describe('the private library filter is a WHERE clause, not an affordance', () => {
it('matches shared rows for everyone and private rows only against the viewer id', () => {
const query = rendered(OWNER);
assert.ok(query, 'a member must be filtered at all');
assert.match(query.sql, /"visibility" = \$1/);
assert.match(query.sql, /"owner_user_id" = \$3/);
assert.deepEqual(query.params, ['shared', 'private', OWNER]);
});
it('never widens to `owner_user_id IS NULL` — an orphaned private draft belongs to nobody', () => {
const query = rendered(OWNER);
// `ON DELETE SET NULL` on the owner column can produce a private row with
// no owner. Matching it here would publish every departed colleague's
// drafts to the whole workspace, and the query would look like a fix for
// rows that had "gone missing".
assert.ok(query);
assert.doesNotMatch(query.sql, /owner_user_id" is null/i);
});
it('is deliberately absent for a platform admin — an admin CAN read a private template', () => {
// Not a hole. An unfiltered query here is the decision: someone has to be
// able to answer "what is in this workspace" during an audit or a
// departure. If this ever starts returning a predicate, that was a choice
// somebody made, and this test is where they say so.
assert.equal(rendered(OTHER, true), null);
});
});
describe('a slug is the identity of a lineage', () => {
it('strips the combining mark NFKD leaves behind rather than hyphenating through a word', () => {
// `normalize('NFKD')` splits "é" into "e" plus a combining acute, and the
// `[^a-z0-9]+` rule that follows turns that mark into a separator — so
// without the strip, "Café strategy" starts the lineage `caf-e-strategy`
// and the next person authoring the same title cannot find it.
assert.equal(motionSlug('Café strategy'), 'cafe-strategy');
assert.equal(motionSlug('Proposal Blocks'), 'proposal-blocks');
// Nothing latin survives, and an empty slug would violate the NOT NULL.
assert.equal(motionSlug('日本語'), 'untitled');
});
});
describe('a used template is never edited in place', () => {
it('refuses a PATCH once usage_count is above zero, and names the versions endpoint', async () => {
const { db, log } = database([[template({ usageCount: 3 })]]);
await assert.rejects(
executeMutation(
db,
principal(onTeam('demand', 'member')),
async () => ({ body: '# Rewritten' }),
motionTemplateUpdateDefinition(),
{ id: TEMPLATE_ID },
),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'template_in_use' &&
error.status === 409 &&
error.message.includes('/versions'),
);
assert.deepEqual(log.updated, [], 'a live engagement must not have its template move underneath it');
});
it('edits freely while nobody has instantiated it — an unused template is still a draft', async () => {
const { db, log } = database([[template({ usageCount: 0 })]]);
await executeMutation(
db,
member,
async () => ({ title: 'POC plan, tightened' }),
motionTemplateUpdateDefinition(),
{ id: TEMPLATE_ID },
);
assert.equal(log.updated.length, 1);
assert.equal(log.updated[0]?.table, 'motion_templates');
assert.equal(log.updated[0]?.values.title, 'POC plan, tightened');
});
it('refuses an edit to somebody else\'s template even when it is shared — publishing is not donating', async () => {
const { db, log } = database([[template({ visibility: 'shared', ownerUserId: OTHER })]]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({ body: '# Mine now' }),
motionTemplateUpdateDefinition(),
{ id: TEMPLATE_ID },
),
(error: unknown) => error instanceof AuthError && error.code === 'not_owner',
);
assert.deepEqual(log.updated, []);
});
});
describe('publishing is a lead\'s judgement, and the owner\'s', () => {
it('refuses a member without motion:publish before the row is read at all', async () => {
const { db, log } = database([[template()]]);
await assert.rejects(
executeMutation(db, member, async () => ({}), motionTemplatePublishDefinition(), {
id: TEMPLATE_ID,
}),
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
);
assert.deepEqual(log.events, [], 'permission precedes the transaction, so nothing was queried');
});
it('refuses a lead flipping somebody else\'s private template to shared', async () => {
const { db, log } = database([[template({ ownerUserId: OTHER })]]);
await assert.rejects(
executeMutation(db, lead, async () => ({}), motionTemplatePublishDefinition(), {
id: TEMPLATE_ID,
}),
(error: unknown) => error instanceof AuthError && error.code === 'not_owner',
);
assert.deepEqual(log.updated, []);
});
it('gates creating straight into the shared library on the same capability', async () => {
// The publish endpoint is not the only door into the shared library, so a
// member who simply POSTs `visibility: 'shared'` must be refused too.
const { db, log } = database([]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({
kind: 'poc',
title: 'POC plan',
summary: 'What a POC must show.',
body: '# POC',
stage: 'poc',
visibility: 'shared',
}),
motionTemplateCreateDefinition(),
),
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
);
assert.deepEqual(log.inserted, []);
});
it('creates a private template owned by the author, because an unowned private row is unreadable', async () => {
const { db, log } = database([]);
await executeMutation(
db,
member,
async () => ({
kind: 'poc',
title: 'POC plan',
summary: 'What a POC must show.',
body: '# POC',
stage: 'poc',
}),
motionTemplateCreateDefinition(),
);
const written = log.inserted.find((row) => row.table === 'motion_templates');
assert.equal(written?.row.visibility, 'private');
assert.equal(written?.row.ownerUserId, member.userId);
assert.equal(written?.row.version, 1);
assert.equal(written?.row.slug, 'poc-plan');
});
});
describe('promotion is the loop', () => {
function promote(db: Database, input: Record<string, unknown> = {}) {
return executeMutation(
db,
lead,
async () => input,
motionArtifactPromoteDefinition(),
{ id: ARTIFACT_ID },
);
}
it('refuses to promote an artifact that is already in the library', async () => {
const { db, log } = database([[artifact({ promotedTemplateId: TEMPLATE_ID })]]);
await assert.rejects(
promote(db),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'already_promoted' &&
error.status === 409,
);
assert.deepEqual(log.inserted, [], 'a second promotion would fork the lineage silently');
});
it('refuses to promote a draft — the library is what the next deployment copies', async () => {
const { db, log } = database([[artifact({ status: 'draft' })]]);
await assert.rejects(
promote(db),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'artifact_not_final' &&
error.status === 409,
);
assert.deepEqual(log.inserted, []);
});
it('writes a shared version pointing back at the artifact that proved it', async () => {
const { db, log } = database([[artifact()], [{ engagement, deal }], []]);
const result = (await promote(db, { slug: 'poc-plan' })) as {
template: { version: number; visibility: string; originArtifactId: string };
};
assert.equal(result.template.version, 1);
assert.equal(result.template.visibility, 'shared');
assert.equal(result.template.originArtifactId, ARTIFACT_ID);
assert.equal(
log.updated.find((row) => row.table === 'engagement_artifacts')?.values.promotedTemplateId,
'motion_templates-1',
);
});
it('chains supersedes_id across three promotions of one lineage', async () => {
let previous: { id: string; version: number } | null = null;
const chain: { id: string; version: number; supersedesId: string | null }[] = [];
for (let round = 0; round < 3; round += 1) {
// The lineage is read twice and on purpose: once unfiltered for the
// version number, once through the visibility filter for the row the new
// version may claim to supersede.
const lineage = previous ? [{ ...template(), ...previous, slug: 'poc-plan' }] : [];
const { db } = database([
[artifact({ id: `${ARTIFACT_ID}-${round}` })],
[{ engagement, deal }],
lineage,
lineage,
]);
const result = (await promote(db, { slug: 'poc-plan' })) as {
template: { id: string; version: number; supersedesId: string | null };
};
chain.push({
id: result.template.id,
version: result.template.version,
supersedesId: result.template.supersedesId,
});
previous = { id: result.template.id, version: result.template.version };
}
assert.deepEqual(
chain.map((row) => row.version),
[1, 2, 3],
);
assert.equal(chain[0]?.supersedesId, null, 'the first version supersedes nothing');
assert.equal(chain[1]?.supersedesId, chain[0]?.id);
assert.equal(chain[2]?.supersedesId, chain[1]?.id);
});
it('lands the audit row on the account of the deal that proved it', async () => {
const { db, log } = database([[artifact()], [{ engagement, deal }], []]);
await promote(db, { slug: 'poc-plan' });
const activity = log.inserted.find((row) => row.table === 'activities')?.row;
assert.equal(activity?.accountId, ACCOUNT_ID);
assert.equal(activity?.demandDealId, DEAL_ID);
assert.match(String(activity?.subject), /^Promoted to the library:/);
});
});
describe('a lineage nobody can read never supplies content to one everybody can', () => {
/**
* The version number and the predecessor row are two different questions, and
* answering both from one unfiltered query is the shape of the bug these pin.
* Both suites script a lineage whose newest row is private to somebody else:
* the unfiltered read finds v4, the filtered read finds nothing.
*/
const PRIVATE_SUMMARY = 'Unreleased pricing: 40% floor, Northwind only.';
/** Alice's v4, which the unfiltered read finds and the filtered read must not. */
const hidden = () =>
template({
id: '00000000-0000-4000-8000-0000000000c4',
version: 4,
ownerUserId: OTHER,
summary: PRIVATE_SUMMARY,
});
it('promotes past a private newest version without copying its summary or naming its id', async () => {
const { db, log } = database([
[artifact()],
[{ engagement, deal }],
[hidden()],
[],
]);
await executeMutation(db, lead, async () => ({ slug: 'poc-plan' }), motionArtifactPromoteDefinition(), {
id: ARTIFACT_ID,
});
const written = log.inserted.find((row) => row.table === 'motion_templates')?.row;
// v5, because the unique constraint is on the whole lineage and a private
// fork still consumes a number.
assert.equal(written?.version, 5);
// But nothing else from that row. The summary fell back to the artifact's
// own title, and the new version supersedes nothing it cannot show.
assert.notEqual(written?.summary, PRIVATE_SUMMARY);
assert.equal(written?.summary, artifact().title);
assert.equal(written?.supersedesId, null);
});
it('forks a shared template without disclosing that private versions of it exist', async () => {
const source = template({ id: TEMPLATE_ID, visibility: 'shared', ownerUserId: OTHER });
const { db, log } = database([[source], [hidden()], []]);
await executeMutation(db, member, async () => ({}), motionTemplateVersionDefinition(), {
id: TEMPLATE_ID,
});
const written = log.inserted.find((row) => row.table === 'motion_templates')?.row;
assert.equal(written?.version, 5, 'the number is allocated against the whole lineage');
assert.equal(
written?.supersedesId,
source.id,
'the edge points at the row that was actually forked, not at a private row the forker cannot fetch',
);
});
it('refuses to promote an artifact whose source template is unreadable, rather than 404ing on the artifact', async () => {
const { db, log } = database([
[artifact({ templateId: TEMPLATE_ID })],
[{ engagement, deal }],
[],
]);
await assert.rejects(
executeMutation(db, lead, async () => ({}), motionArtifactPromoteDefinition(), {
id: ARTIFACT_ID,
}),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'source_template_unreadable' &&
error.status === 409 &&
error.message.includes('slug'),
);
assert.deepEqual(log.inserted, []);
});
});
describe('a template id somebody sent is read before it is stored', () => {
it('refuses a playbook the setter cannot read, so a private id cannot go book-wide on an engagement', async () => {
const { db, log } = database([[{ engagement, deal }], []]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({ playbookTemplateId: TEMPLATE_ID }),
motionEngagementUpdateDefinition(),
{ id: ENGAGEMENT_ID },
),
(error: unknown) => error instanceof MutationError && error.status === 404,
);
assert.deepEqual(log.updated, [], 'an unreadable id must not reach the foreign key either');
});
});
describe('a UI hint that disagrees with its endpoint is the hint that is wrong', () => {
it('does not offer publish to a read-only key, which the endpoint would refuse for scope', async () => {
const readOnlyLead = principal({ ...onTeam('demand', 'lead'), via: 'api_key', scopes: ['read'] });
const mine = template({ ownerUserId: readOnlyLead.userId });
const { db } = database([[mine], [mine]]);
const response = await mounted(db, readOnlyLead).request(`/api/motion/templates/${TEMPLATE_ID}`);
const body = (await response.json()) as { canPublish: boolean };
assert.equal(response.status, 200);
assert.equal(
body.canPublish,
false,
'the button would 403 insufficient_scope, and a button that cannot work must not be offered',
);
});
it('still offers it to the same lead on a full-scope session', async () => {
const mine = template({ ownerUserId: lead.userId });
const { db } = database([[mine], [mine]]);
const response = await mounted(db, lead).request(`/api/motion/templates/${TEMPLATE_ID}`);
const body = (await response.json()) as { canPublish: boolean };
assert.equal(body.canPublish, true);
});
});
describe('a slug collision is the caller\'s to fix, not a 500', () => {
it('answers 409 naming the versions endpoint when the title derives a slug already in use', async () => {
// "Proposal Blocks" is one of the nine shipped starter templates, so this
// is the first thing a new author trips over rather than an edge case.
const { db, log } = database([[{ version: 1 }]]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({
kind: 'proposal',
title: 'Proposal Blocks',
summary: 'Reusable proposal language.',
body: '# Blocks',
stage: 'proposal',
}),
motionTemplateCreateDefinition(),
),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'template_slug_exists' &&
error.status === 409 &&
error.message.includes('/versions'),
);
assert.deepEqual(log.inserted, []);
});
});
describe('instantiating a template', () => {
it('increments usage_count in the same transaction that writes the artifact', async () => {
const { db, log } = database([
[{ engagement, deal }],
[template({ usageCount: 2, visibility: 'shared', ownerUserId: OTHER })],
]);
await executeMutation(
db,
member,
async () => ({ templateId: TEMPLATE_ID }),
motionArtifactCreateDefinition(),
{ id: ENGAGEMENT_ID },
);
// One transaction, both writes: a count that could be committed without the
// artifact would close the template to edits for a use that never happened.
assert.equal(log.updated[0]?.table, 'motion_templates');
// And the count is incremented by the database, not by JS arithmetic on the
// row that was read a moment ago. Two people instantiating one template
// under READ COMMITTED both read 2 and both write 3, so a use is lost — and
// `usage_count` is the only thing holding §7a shut. Asserting the rendered
// SQL is what makes the difference between the two visible at all: the
// wrong version writes the literal 3 and passes every behavioural test.
const increment = log.updated[0]?.values.usageCount;
assert.ok(
isSQLWrapper(increment),
'usage_count must be written as an expression, never as a number computed in JS',
);
assert.match(new PgDialect().sqlToQuery(increment as SQL).sql, /"usage_count" \+ \$?1/);
assert.equal(log.inserted[0]?.table, 'engagement_artifacts');
assert.equal(log.inserted[0]?.row.templateId, TEMPLATE_ID);
// The body is copied, not referenced — editing the artifact must not reach
// back into the library.
assert.equal(log.inserted[0]?.row.body, template().body);
});
});
describe('one engagement per deal', () => {
it('answers 409 with the id of the engagement that already exists', async () => {
const { db, log } = database([[deal], [engagement]]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({ demandDealId: DEAL_ID }),
motionEngagementCreateDefinition(),
),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'engagement_exists' &&
error.status === 409 &&
error.message.includes(ENGAGEMENT_ID),
);
assert.deepEqual(log.inserted, []);
});
});
describe('qualification scores', () => {
it('computes the score from the dimensions rather than believing the body', async () => {
const { db, log } = database([[{ engagement, deal }]]);
const dimensions = [
{ id: 'budget', weight: 30, score: 4 },
{ id: 'urgency', weight: 20, score: 2 },
{ id: 'fit', weight: 50, score: 3 },
];
await executeMutation(
db,
member,
async () => ({ dimensions }),
motionScoreDefinition(),
{ id: ENGAGEMENT_ID },
);
const written = log.inserted.find((row) => row.table === 'qualification_scores')?.row;
assert.equal(written?.basisPoints, motionScoreBasisPoints(dimensions));
assert.equal(written?.band, 'Strategic');
assert.equal(typeof written?.basisPoints, 'number');
assert.ok(Number.isInteger(written?.basisPoints), 'a score is an integer, exactly as money is');
});
it('refuses a posted basisPoints outright — a score somebody can send is a score somebody can fix', async () => {
const { db } = database([[{ engagement, deal }]]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({ dimensions: [{ id: 'fit', weight: 1, score: 1 }], basisPoints: 10_000 }),
motionScoreDefinition(),
{ id: ENGAGEMENT_ID },
),
(error: unknown) => error instanceof MutationError && error.code === 'invalid_request',
);
});
});
+73 -1
View File
@@ -9,7 +9,7 @@
*
* Design rules, learned from tool surfaces that went wrong:
*
* **Keep it small.** Nine tools, each doing one thing. A sprawling tool list
* **Keep it small.** Ten tools, each doing one thing. A sprawling tool list
* degrades model performance more than it adds capability; anything genuinely
* niche belongs behind `pig_search` or the HTTP API.
*
@@ -22,6 +22,7 @@
* and quote back to a human. A wall of raw JSON forces the model to re-derive
* meaning that the server already knows.
*/
import { DEMAND_STAGES, MOTION_KINDS } from '@pig/core';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
@@ -542,6 +543,77 @@ export function createPigMcpServer(options: PigMcpOptions): McpServer {
},
);
// ----------------------------------------------------------- motion library
server.registerTool(
'pig_motion_library',
{
title: 'Search the motion library',
description:
'Find reusable go-to-market practice: discovery guides, qualification frameworks, POC ' +
'structures, proposal blocks, pricing inputs, reference architectures, case studies, ' +
'technical narratives and deployment playbooks, each bound to the demand stage it ' +
'serves. Use it before writing a proposal or scoping a POC from scratch — a template ' +
'with a usage count is language that has already survived a customer.',
inputSchema: {
kind: z
.enum(MOTION_KINDS)
.optional()
.describe('Restrict to one kind of template. Omit for all nine.'),
stage: z
.enum(DEMAND_STAGES)
.optional()
.describe('Restrict to templates serving one demand stage, e.g. proposal.'),
query: z.string().optional().describe('Matched against title, summary and slug'),
},
},
async (input) => {
const params = new URLSearchParams();
if (input.kind) params.set('kind', input.kind);
if (input.stage) params.set('stage', input.stage);
if (input.query) params.set('q', input.query);
const { templates, truncated } = await api.request<{
templates: {
id: string;
kind: string;
slug: string;
version: number;
title: string;
summary: string;
stage: string;
visibility: string;
usageCount: number;
updatedAt: string;
}[];
truncated: boolean;
}>(`/api/motion/templates?${params}`);
if (templates.length === 0) {
return ok(
'No template matches that request. Note that this key sees shared templates and ' +
"the caller's own private drafts, so a colleague's draft will not appear.",
);
}
const lines = [
`${templates.length}${truncated ? '+' : ''} template(s), newest version of each:\n`,
];
for (const t of templates.slice(0, 25)) {
lines.push(
`${t.title}${t.kind}, ${t.stage} stage, v${t.version}` +
`${t.visibility === 'private' ? ' [private draft]' : ''}`,
` ${t.summary}`,
// Usage is the difference between practice and a document nobody
// opened, and it is the field a model should rank on.
` Instantiated ${t.usageCount} time(s) · id: ${t.id}`,
'',
);
}
return ok(lines.join('\n'));
},
);
// ------------------------------------------------------------- log activity
server.registerTool(
+21
View File
@@ -22,6 +22,9 @@ export const PIGGY_PAGE_TOOL_NAMES = [
'pig_get_idle_capacity',
'pig_get_pipeline',
'pig_get_calendar_ahead',
'pig_get_motion_summary',
'pig_search_motion_library',
'pig_get_engagement',
] as const;
export type PiggyPageToolName = (typeof PIGGY_PAGE_TOOL_NAMES)[number];
@@ -97,6 +100,24 @@ const GUIDES: Partial<Record<PiggyPageRoute, PiggyPageGuide>> = {
label: 'the contracts list — Piggy reads its dates here, not its terms',
tool: 'pig_get_calendar_ahead',
},
'/motion': {
label: 'the Motion home — stage coverage, the shared library, and recent promotions',
tool: 'pig_get_motion_summary',
},
/*
* The label says "shared" because the tool reads nothing else, and the model
* is otherwise free to conclude that a template it cannot find is missing
* rather than private. A user asking "where is my draft?" should be told
* Piggy cannot see private drafts, not that no such template exists.
*/
'/motion/library': {
label: 'the motion library — Piggy reads the shared templates here, never a private draft',
tool: 'pig_search_motion_library',
},
'/motion/engagements': {
label: 'the engagement list — the demand deals with a motion running against them',
tool: 'pig_get_engagement',
},
'/imports': { label: 'the CSV import page', tool: 'pig_get_workspace_summary' },
'/team': { label: 'the team and permissions page', tool: 'pig_get_workspace_summary' },
'/facts': { label: 'the fact review queue', tool: 'pig_get_workspace_summary' },
+396 -1
View File
@@ -28,6 +28,8 @@
import {
CONSUMING_ALLOCATION_STATUSES,
DEMAND_OPEN_STAGES,
DEMAND_STAGES,
MOTION_KINDS,
RESERVING_ALLOCATION_STATUSES,
SUPPLY_OPEN_STAGES,
aggregateMargin,
@@ -37,19 +39,27 @@ import {
type AllocationInput,
type CalendarEvent,
type CalendarEventKind,
type DemandStage,
type MarginResult,
type MotionKind,
type PiggyPageRoute,
} from '@pig/core';
import {
accounts,
allocations,
capacityCommitments,
demandDeals,
engagementArtifacts,
engagements,
motionTemplates,
qualificationScores,
supplyDeals,
type Database,
} from '@pig/db';
import { CalendarService } from '@pig/api/src/services/calendar';
import { and, gte, inArray, isNull } from 'drizzle-orm';
import { and, desc, eq, gte, ilike, inArray, isNotNull, isNull, or, type SQL } from 'drizzle-orm';
import { z } from 'zod';
import { likeFragment } from './chat-tools';
import { piggyPageGuide, type PiggyPageToolName } from './page-routes';
import { defineTool, type AgentTool } from './provider';
@@ -161,6 +171,72 @@ function pageTool(db: Database, name: PiggyPageToolName): AgentTool {
.strict(),
execute: async ({ withinDays }) => readCalendarAhead(db, withinDays ?? 30),
});
case 'pig_get_motion_summary':
return defineTool({
name,
description:
'Read whether the go-to-market motion is repeating: which demand stages the shared ' +
'library covers and which it does not, how many live engagements sit at each stage, ' +
'the shared library by kind, and the artifacts most recently promoted back into it. ' +
'Private drafts are not visible to this tool.',
inputSchema: noInput,
execute: async () => readMotionSummary(db),
});
case 'pig_search_motion_library':
return defineTool({
name,
description:
'Search the shared motion library — discovery guides, qualification frameworks, POC ' +
'structures, proposal blocks, pricing inputs, reference architectures, case studies, ' +
'technical narratives and deployment playbooks. Returns the newest version of each ' +
'template. Private drafts are never searched, whoever is asking.',
inputSchema: z
.object({
kind: z
.enum(MOTION_KINDS)
.describe('Restrict to one kind of template. null for every kind.')
.nullish(),
stage: z
.enum(DEMAND_STAGES)
.describe('Restrict to templates serving one demand stage. null for every stage.')
.nullish(),
query: z
.string()
.trim()
.min(2)
.max(64)
.describe(
'Word or phrase matched against the title, summary and slug. null returns the ' +
'whole shared library.',
)
.nullish(),
})
.strict(),
execute: async (filter) => readMotionLibrary(db, filter),
});
case 'pig_get_engagement':
return defineTool({
name,
description:
'Read the engagements running against demand deals: which stage the deal sits at, ' +
'how many artifacts have been produced and how many are final, and the latest ' +
'qualification score with its band.',
inputSchema: z
.object({
query: z
.string()
.trim()
.min(2)
.max(64)
.describe(
'Word or phrase matched against the deal name and the engagement summary. ' +
'null returns the most recently opened engagements.',
)
.nullish(),
})
.strict(),
execute: async ({ query }) => readEngagements(db, query ?? null),
});
case 'pig_get_workspace_summary':
return defineTool({
name,
@@ -585,6 +661,325 @@ function countByState(events: readonly CalendarEvent[]): Record<string, number>
return counts;
}
// ---------------------------------------------------------------------------
// The motion
// ---------------------------------------------------------------------------
export interface MotionLibraryFilter {
kind?: MotionKind | null;
stage?: DemandStage | null;
query?: string | null;
}
/**
* Shared templates only, unconditionally — not "unless the asker owns it".
*
* Motion is the one place in PIG with a row-level access rule: a `private`
* template is readable by its owner and by a platform admin, and by nobody
* else. Piggy has no reliable notion of who is asking. The dock publishes a
* route, the relay checks a capability, and the tool then runs as the process;
* nothing reaches this query that identifies a person strongly enough to widen
* it on. So it is not widened. A model that can be argued into reading a
* colleague's private draft is a leak with the extra step of asking politely,
* and the argument would arrive as ordinary conversation the guard never sees.
*
* This is the only clause here that must not become a parameter. If private
* drafts ever need an answer, the identity has to arrive with the request and
* be enforced in `services/motion.ts` where the API already enforces it — not
* by relaxing this. `motion-tools.test.ts` fails if it is.
*/
export function motionLibraryWhere(filter: MotionLibraryFilter): SQL {
const conditions: SQL[] = [
eq(motionTemplates.visibility, 'shared'),
isNull(motionTemplates.archivedAt),
];
if (filter.kind) conditions.push(eq(motionTemplates.kind, filter.kind));
if (filter.stage) conditions.push(eq(motionTemplates.stage, filter.stage));
if (filter.query) {
const fragment = likeFragment(filter.query);
conditions.push(
or(
ilike(motionTemplates.title, fragment),
ilike(motionTemplates.summary, fragment),
ilike(motionTemplates.slug, fragment),
)!,
);
}
return and(...conditions)!;
}
interface LineageRow {
slug: string;
kind: MotionKind;
stage: DemandStage;
version: number;
title: string;
summary: string;
usageCount: number;
}
/**
* One row per lineage, newest version winning.
*
* The library counts lineages rather than rows because a template promoted
* three times is one piece of practice with a history, and counting its
* versions reports a library four times the size of the one anybody can choose
* from. The slug is the identity of the lineage — see the schema header.
*/
function newestPerSlug<Row extends { slug: string; version: number }>(rows: readonly Row[]): Row[] {
const newest = new Map<string, Row>();
for (const row of rows) {
const held = newest.get(row.slug);
if (!held || row.version > held.version) newest.set(row.slug, row);
}
return [...newest.values()];
}
function countBy<Row>(rows: readonly Row[], key: (row: Row) => string): Record<string, number> {
const counts: Record<string, number> = {};
for (const row of rows) counts[key(row)] = (counts[key(row)] ?? 0) + 1;
return counts;
}
/**
* The question the /motion page exists to answer: is the motion repeating?
*
* Coverage is reported as the stages the shared library does NOT reach, not
* only as a count, because "6 of 8 stages covered" is a figure nobody acts on
* and "nothing covers procurement or deployment" is a piece of work. The two
* closed stages are excluded throughout — a won deal has left the motion.
*/
async function readMotionSummary(db: Database): Promise<unknown> {
const [libraryRead, engagementRead, promotions] = await Promise.all([
db
.select({
slug: motionTemplates.slug,
kind: motionTemplates.kind,
stage: motionTemplates.stage,
version: motionTemplates.version,
title: motionTemplates.title,
summary: motionTemplates.summary,
usageCount: motionTemplates.usageCount,
})
.from(motionTemplates)
.where(motionLibraryWhere({}))
.limit(SCAN_LIMIT + 1),
db
.select({ stage: demandDeals.stage, dealName: demandDeals.name })
.from(engagements)
.innerJoin(demandDeals, eq(engagements.demandDealId, demandDeals.id))
.where(eq(engagements.status, 'open'))
.limit(SCAN_LIMIT + 1),
db
.select({
title: motionTemplates.title,
kind: motionTemplates.kind,
version: motionTemplates.version,
createdAt: motionTemplates.createdAt,
})
.from(motionTemplates)
.where(and(motionLibraryWhere({}), isNotNull(motionTemplates.originArtifactId)))
.orderBy(desc(motionTemplates.createdAt))
.limit(EXEMPLARS),
]);
const { rows: templateRows, truncated: libraryTruncated } = bounded(libraryRead);
const { rows: openEngagements, truncated: engagementsTruncated } = bounded(engagementRead);
const truncated = libraryTruncated || engagementsTruncated;
const lineages = newestPerSlug(templateRows);
const engagementsByStage = countBy(openEngagements, (row) => row.stage);
const uncovered = DEMAND_OPEN_STAGES.filter(
(stage) => !lineages.some((template) => template.stage === stage),
);
return {
headline:
`${atLeast(lineages.length, libraryTruncated)} shared template(s) across ` +
`${Object.keys(countBy(lineages, (row) => row.kind)).length} of ${MOTION_KINDS.length} ` +
`kind(s), and ${atLeast(openEngagements.length, engagementsTruncated)} open engagement(s). ` +
(uncovered.length === 0
? 'Every live demand stage has at least one shared template.'
: `No shared template covers ${uncovered.join(', ')}.`) +
` ${promotions.length} artifact(s) promoted back into the library recently.` +
(truncated ? ` ${TRUNCATION_NOTE}` : ''),
truncated,
sharedTemplates: lineages.length,
openEngagements: openEngagements.length,
uncoveredStages: uncovered,
stages: DEMAND_OPEN_STAGES.map((stage) => ({
stage,
openEngagements: engagementsByStage[stage] ?? 0,
sharedTemplates: lineages.filter((template) => template.stage === stage).length,
})),
libraryByKind: countBy(lineages, (row) => row.kind),
// The loop made visible: what the last few engagements gave back.
recentPromotions: promotions.map((row) => ({
title: row.title,
kind: row.kind,
version: row.version,
promotedAt: row.createdAt.toISOString(),
})),
};
}
async function readMotionLibrary(db: Database, filter: MotionLibraryFilter): Promise<unknown> {
const { rows, truncated } = bounded(
await db
.select({
slug: motionTemplates.slug,
kind: motionTemplates.kind,
stage: motionTemplates.stage,
version: motionTemplates.version,
title: motionTemplates.title,
summary: motionTemplates.summary,
usageCount: motionTemplates.usageCount,
})
.from(motionTemplates)
.where(motionLibraryWhere(filter))
.orderBy(desc(motionTemplates.updatedAt))
.limit(SCAN_LIMIT + 1),
);
const lineages = newestPerSlug(rows as LineageRow[]);
const described = [
filter.kind ? `kind ${filter.kind}` : null,
filter.stage ? `stage ${filter.stage}` : null,
filter.query ? `"${filter.query}"` : null,
].filter((part): part is string => part !== null);
const scope = described.length ? ` matching ${described.join(', ')}` : '';
return {
headline:
(lineages.length === 0
? `No shared template${scope}. Private drafts are not searched, so a template may ` +
'exist and not be visible here.'
: `${atLeast(lineages.length, truncated)} shared template(s)${scope}, newest version ` +
'of each.') + (truncated ? ` ${TRUNCATION_NOTE}` : ''),
truncated,
count: lineages.length,
byKind: countBy(lineages, (row) => row.kind),
// Most recently updated first: the practice people are actually amending.
templates: lineages.slice(0, EXEMPLARS).map((template) => ({
slug: template.slug,
kind: template.kind,
stage: template.stage,
version: template.version,
title: template.title,
summary: template.summary,
// How many engagements have instantiated it — the only evidence here of
// whether a template is practice or an unread document.
usageCount: template.usageCount,
})),
};
}
/**
* Engagements, with the two figures anyone asks for: how much has been produced
* and where qualification landed.
*
* Artifacts and scores are read only for the exemplars, so the second and third
* queries stay small however wide the book is. The engagement's playbook
* template is deliberately not joined: it may be a private draft, and naming it
* would walk round the library rule by another door.
*/
async function readEngagements(db: Database, query: string | null): Promise<unknown> {
const fragment = query ? likeFragment(query) : null;
const { rows, truncated } = bounded(
await db
.select({
id: engagements.id,
status: engagements.status,
summary: engagements.summary,
openedAt: engagements.openedAt,
stage: demandDeals.stage,
dealName: demandDeals.name,
accountName: accounts.name,
})
.from(engagements)
.innerJoin(demandDeals, eq(engagements.demandDealId, demandDeals.id))
.leftJoin(accounts, eq(demandDeals.accountId, accounts.id))
.where(
fragment
? or(ilike(demandDeals.name, fragment), ilike(engagements.summary, fragment))
: undefined,
)
.orderBy(desc(engagements.openedAt))
.limit(SCAN_LIMIT + 1),
);
const exemplars = rows.slice(0, EXEMPLARS);
const ids = exemplars.map((row) => row.id);
const [artifacts, scores] = ids.length
? await Promise.all([
db
.select({ engagementId: engagementArtifacts.engagementId, status: engagementArtifacts.status })
.from(engagementArtifacts)
.where(
and(
inArray(engagementArtifacts.engagementId, ids),
isNull(engagementArtifacts.archivedAt),
),
)
.limit(SCAN_LIMIT),
db
.select({
engagementId: qualificationScores.engagementId,
basisPoints: qualificationScores.basisPoints,
band: qualificationScores.band,
scoredAt: qualificationScores.scoredAt,
})
.from(qualificationScores)
.where(inArray(qualificationScores.engagementId, ids))
.orderBy(desc(qualificationScores.scoredAt))
.limit(SCAN_LIMIT),
])
: [[], []];
// Newest first out of the query, so the first score seen for an engagement is
// its latest; a later one must not overwrite it.
const latestScore = new Map<string, (typeof scores)[number]>();
for (const score of scores) if (!latestScore.has(score.engagementId)) latestScore.set(score.engagementId, score);
return {
headline:
(rows.length === 0
? query
? `No engagement matches "${query}".`
: 'No demand deal has an engagement running against it yet.'
: `${atLeast(rows.length, truncated)} engagement(s)${query ? ` matching "${query}"` : ''}, ` +
`of which ${rows.filter((row) => row.status === 'open').length} open.`) +
(truncated ? ` ${TRUNCATION_NOTE}` : ''),
truncated,
count: rows.length,
byStatus: countBy(rows, (row) => row.status),
byStage: countBy(rows, (row) => row.stage),
engagements: exemplars.map((row) => {
const mine = artifacts.filter((artifact) => artifact.engagementId === row.id);
const score = latestScore.get(row.id);
return {
dealName: row.dealName,
accountName: row.accountName,
stage: row.stage,
status: row.status,
summary: row.summary,
openedAt: row.openedAt.toISOString(),
artifacts: mine.length,
finalArtifacts: mine.filter((artifact) => artifact.status === 'final').length,
latestScore: score
? {
// Basis points of the maximum, so a tenth of a per cent — the
// band is the part a seller acts on.
basisPoints: score.basisPoints,
percent: `${(score.basisPoints / 100).toFixed(1)}%`,
band: score.band,
scoredAt: score.scoredAt.toISOString(),
}
: null,
};
}),
};
}
// ---------------------------------------------------------------------------
// The fallback
// ---------------------------------------------------------------------------
+211
View File
@@ -0,0 +1,211 @@
/**
* The motion page tools, and the one rule none of them may relax.
*
* Motion is the first feature in PIG with a row-level access rule: a `private`
* template belongs to its owner and to a platform admin, and to nobody else.
* Every other read in this process is book-wide, so the habit of the codebase
* is against this clause rather than for it which is exactly why it is pinned
* here rather than left to a review.
*
* Piggy cannot enforce ownership because it does not reliably know who is
* asking: the dock publishes a route and the relay checks a capability, and
* neither reaches the query. So the query is closed instead. The tests below
* assert the closed form under every filter combination a model can send,
* because the plausible-but-wrong version of this code is one where the clause
* is present in the unfiltered read and lost in a branch.
*
* The unit suite runs in CI BEFORE the migration step, against a database with
* no tables, so nothing here may execute a query. The WHERE clause is rendered
* with `PgDialect` rather than run.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import { MOTION_KINDS } from '@pig/core';
import type { Database } from '@pig/db';
import { PgDialect } from 'drizzle-orm/pg-core';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { assertPigToolBoundary } from '../src/chat';
import { createPagePigTools, motionLibraryWhere } from '../src/page-tools';
/** Schema and SQL-shape checks only: no query is executed. */
const db = {} as Database;
const dialect = new PgDialect();
function renderedWhere(filter: Parameters<typeof motionLibraryWhere>[0]): {
text: string;
params: unknown[];
} {
const query = dialect.sqlToQuery(motionLibraryWhere(filter));
return { text: query.sql, params: query.params };
}
function tool(route: '/motion' | '/motion/library' | '/motion/engagements') {
const [only, ...rest] = createPagePigTools(db, route);
assert.ok(only, `${route} has a tool`);
// One tool per page: a second is a second thing to choose wrongly, and a
// wrong choice costs one of four turns.
assert.equal(rest.length, 0);
return only;
}
// ---------------------------------------------------------------------------
// The visibility invariant. Do not relax this to "unless the user owns it".
// ---------------------------------------------------------------------------
test('the library query filters to shared templates UNCONDITIONALLY, under every filter', () => {
const filters: Parameters<typeof motionLibraryWhere>[0][] = [
{},
{ kind: 'proposal' },
{ stage: 'poc' },
{ query: 'sovereign' },
{ kind: 'playbook', stage: 'deployment', query: 'inference' },
// The nulls a schema-abiding model sends for "no filter" must not read as
// "no visibility filter either".
{ kind: null, stage: null, query: null },
];
for (const filter of filters) {
const { params } = renderedWhere(filter);
assert.ok(
params.includes('shared'),
`visibility = 'shared' is missing for ${JSON.stringify(filter)}`,
);
// Bound to the column, not merely present somewhere in the statement.
assert.match(renderedWhere(filter).text, /"visibility" = \$\d+/);
}
});
test('no filter a model can send widens the library beyond shared — private is not a parameter', () => {
const library = tool('/motion/library');
const accepts = (input: unknown) => library.inputSchema.safeParse(input).success;
// `.strict()`, so every one of these is refused rather than ignored. A tool
// that silently drops an unknown key teaches a model to keep trying.
assert.equal(accepts({ visibility: 'private' }), false);
assert.equal(accepts({ ownerUserId: '20000000-0000-4000-8000-000000000002' }), false);
assert.equal(accepts({ includePrivate: true }), false);
assert.equal(accepts({ all: '1' }), false);
assert.equal(accepts({ kind: 'proposal' }), true);
});
test('a private template is invisible even when its title is the search term', () => {
// The query filter is an AND alongside the visibility clause, never an OR
// beside it: an `or(...)` at the top level would make any matching title
// satisfy the whole WHERE and return the private row.
const { text, params } = renderedWhere({ query: 'Halcyon' });
const [visibility] = text.split('and');
assert.ok(visibility?.includes('"visibility"'), text);
assert.ok(text.startsWith('('), text);
assert.match(text, /^\("[a-z_]+"\."visibility" = \$1 and /);
assert.equal(params[0], 'shared');
});
test('archived templates are excluded from every library read', () => {
// Archiving is how deletion works here, so a query that ignores it hands the
// model practice somebody deliberately withdrew.
assert.match(renderedWhere({}).text, /"archived_at" is null/);
assert.match(renderedWhere({ kind: 'case_study' }).text, /"archived_at" is null/);
});
test('LIKE wildcards in the model-supplied query are escaped, not honoured', () => {
// Unescaped, `%` matches every shared template and the model is handed the
// first eight as though they answered the question.
assert.deepEqual(renderedWhere({ query: '%' }).params, ['shared', '%\\%%', '%\\%%', '%\\%%']);
});
// ---------------------------------------------------------------------------
// The boundary
// ---------------------------------------------------------------------------
test('every motion page tool sits inside the PIG tool boundary', () => {
const tools = [tool('/motion'), tool('/motion/library'), tool('/motion/engagements')];
assert.deepEqual(tools.map((entry) => entry.name), [
'pig_get_motion_summary',
'pig_search_motion_library',
'pig_get_engagement',
]);
// The assertion the chat provider runs on every request: a name that fails it
// takes the whole conversation down rather than one tool.
assert.doesNotThrow(() => assertPigToolBoundary(tools));
for (const entry of tools) {
assert.ok(entry.description.length > 40, `${entry.name} has a usable description`);
}
});
test('the library and engagement tools say out loud that private drafts are not read', () => {
// The description is the only place the model learns the limit, and "I found
// nothing" is a materially different answer from "I cannot see private
// drafts" to someone looking at their own.
assert.match(tool('/motion/library').description, /[Pp]rivate drafts are never searched/);
assert.match(tool('/motion').description, /[Pp]rivate drafts are not visible/);
});
// ---------------------------------------------------------------------------
// The input bounds
// ---------------------------------------------------------------------------
test('the motion summary takes no input at all', () => {
const summary = tool('/motion');
assert.equal(summary.inputSchema.safeParse({}).success, true);
assert.equal(summary.inputSchema.safeParse({ stage: 'poc' }).success, false);
});
test('the library filters accept only ontology values, and a bounded query', () => {
const library = tool('/motion/library');
const accepts = (input: unknown) => library.inputSchema.safeParse(input).success;
for (const kind of MOTION_KINDS) assert.equal(accepts({ kind }), true);
// A kind the model invented reaches the database as a cast error rather than
// a miss, so it is refused at the schema.
assert.equal(accepts({ kind: 'battlecard' }), false);
assert.equal(accepts({ stage: 'closed_won' }), true);
assert.equal(accepts({ stage: 'negotiation' }), false);
// Trimmed before the length check, so trailing whitespace cannot smuggle a
// one-character query past the floor and match the whole library.
assert.equal(accepts({ query: ' a ' }), false);
assert.equal(accepts({ query: 'x'.repeat(64) }), true);
assert.equal(accepts({ query: 'x'.repeat(65) }), false);
assert.equal(accepts({ query: 'x'.repeat(4000) }), false);
});
test('the engagement query is bounded and refuses anything it does not name', () => {
const engagement = tool('/motion/engagements');
const accepts = (input: unknown) => engagement.inputSchema.safeParse(input).success;
assert.equal(accepts({}), true);
assert.equal(accepts({ query: 'Halcyon' }), true);
assert.equal(accepts({ query: 'a' }), false);
assert.equal(accepts({ query: 'x'.repeat(65) }), false);
assert.equal(accepts({ engagementId: '20000000-0000-4000-8000-000000000002' }), false);
assert.equal(accepts({ limit: 500 }), false);
});
/**
* What the model is actually sent, rather than what the zod reads like.
*
* `zodToJsonSchema(..., { target: 'openAi' })` the exact call both inference
* paths make emits an optional field as REQUIRED and nullable, so a
* schema-abiding model sends `null` for every filter it does not want and
* `.optional()` would reject the call. A `.describe()` applied after the
* wrapper is dropped from the emitted schema entirely.
*/
test('an omitted motion filter arrives as the null the emitted schema asks for', () => {
const library = tool('/motion/library');
assert.equal(
library.inputSchema.safeParse({ kind: null, stage: null, query: null }).success,
true,
);
assert.equal(tool('/motion/engagements').inputSchema.safeParse({ query: null }).success, true);
const emitted = zodToJsonSchema(library.inputSchema, {
$refStrategy: 'none',
target: 'openAi',
}) as { properties?: Record<string, { description?: string }>; required?: string[] };
assert.deepEqual(emitted.required, ['kind', 'stage', 'query']);
for (const [parameter, shape] of Object.entries(emitted.properties ?? {})) {
assert.ok(
shape.description && shape.description.length > 10,
`${parameter} reaches the model with no description`,
);
}
});
+2
View File
@@ -38,7 +38,9 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.85.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.1.1",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.8",
"streamdown": "^2.5.0",
"tailwind-merge": "^2.6.0",
+18
View File
@@ -36,6 +36,14 @@ const Piggy = lazy(() => import('@/pages/Piggy').then(({ Piggy }) => ({ default:
const Growth = lazy(() => import('@/pages/Growth').then(({ Growth }) => ({ default: Growth })));
const Calendar = lazy(() => import('@/pages/Calendar').then(({ Calendar }) => ({ default: Calendar })));
const Learn = lazy(() => import('@/pages/Learn').then(({ Learn }) => ({ default: Learn })));
// Lazy is load-bearing for these five and not merely conventional: they are the
// only pages that pull react-markdown and remark-gfm, and an eager import would
// put a markdown parser into the entry chunk every route pays for.
const Motion = lazy(() => import('@/pages/Motion').then(({ Motion }) => ({ default: Motion })));
const MotionLibrary = lazy(() => import('@/pages/MotionLibrary').then(({ MotionLibrary }) => ({ default: MotionLibrary })));
const MotionTemplate = lazy(() => import('@/pages/MotionTemplate').then(({ MotionTemplate }) => ({ default: MotionTemplate })));
const MotionEngagements = lazy(() => import('@/pages/MotionEngagements').then(({ MotionEngagements }) => ({ default: MotionEngagements })));
const Engagement = lazy(() => import('@/pages/Engagement').then(({ Engagement }) => ({ default: Engagement })));
const queryClient = new QueryClient({
defaultOptions: {
@@ -248,6 +256,16 @@ function AppRoutes() {
the next four record routes will read.
*/}
<Route path="accounts/:id" element={<RoutePage><Account /></RoutePage>} />
{/*
Flat, in the register of the routes above nesting these under a
layout route would give Motion a chrome no other group has, and the
five pages share no shell of their own.
*/}
<Route path="motion" element={<RoutePage><Motion /></RoutePage>} />
<Route path="motion/library" element={<RoutePage><MotionLibrary /></RoutePage>} />
<Route path="motion/library/:id" element={<RoutePage><MotionTemplate /></RoutePage>} />
<Route path="motion/engagements" element={<RoutePage><MotionEngagements /></RoutePage>} />
<Route path="motion/engagements/:id" element={<RoutePage><Engagement /></RoutePage>} />
<Route path="contracts" element={<RoutePage><Contracts /></RoutePage>} />
<Route path="imports" element={<RoutePage><Imports /></RoutePage>} />
<Route path="piggy" element={<RoutePage><Piggy /></RoutePage>} />
@@ -0,0 +1,75 @@
/**
* Source with a copy button the fence chrome, in one place.
*
* Reference architectures arrive as `mermaid`, both in a template's structured
* `fields` and inside the markdown body a promoted artefact brings with it, and
* neither is rendered as a picture. Bundling mermaid means roughly two
* megabytes and evaluating author-supplied text, and the proxy allows exactly
* one inline script by hash (AGENTS.md §5), so it would drag a CSP change onto
* the deployment host too. Source with a copy button gets the reader into their
* own diagram tool in two clicks; the picture is tracked as follow-up in
* `docs/motion.md`.
*
* The copy button is here rather than in each caller because a diagram in a
* body and a diagram in a field are the same thing to the person reading it,
* and one of the two silently lacking the button is the sort of difference
* nobody reports.
*/
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui';
export function CodeBlock({
source,
language,
label,
}: {
source: string;
/** The fence's language, shown as the block's caption. Absent for a bare fence. */
language?: string | null;
/** What the copy button says it copies, for a screen reader. */
label?: string;
}) {
const [copied, setCopied] = useState(false);
const copy = async () => {
// `navigator.clipboard` is absent outside a secure context, which the LAN
// dev server is — so this branch is reached routinely, not exceptionally.
if (!navigator.clipboard) {
toast.error('The browser refused clipboard access. Select the source and copy it by hand.');
return;
}
try {
await navigator.clipboard.writeText(source);
setCopied(true);
toast.success('Source copied.');
window.setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('The browser refused clipboard access. Select the source and copy it by hand.');
}
};
return (
<div className="min-w-0 overflow-hidden rounded-lg border border-border bg-surface-2">
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-border pl-3">
<span className="min-w-0 truncate font-mono text-[11px] lowercase text-muted">
{language ?? 'source'}
</span>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => void copy()}
aria-label={label ? `Copy the ${label} source` : 'Copy the source'}
>
{copied ? <Check className="size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
<pre className="scroll-x p-3 text-xs leading-5">
<code className="font-mono">{source}</code>
</pre>
</div>
);
}
@@ -0,0 +1,637 @@
/**
* The structured half of a template, rendered per kind.
*
* `fields` is authored JSON. It is not validated on the way out of the
* database, it is edited by anyone with `motion:write`, it survives promotion
* from an engagement artefact unchanged, and it will outlive whatever shape
* this file expects today. So every access below is narrowed, every list is
* filtered to the entries that carry the property being rendered, and a shape
* this file does not recognise renders as nothing at all.
*
* That last rule is the important one. The alternative assume the shape and
* let the page throw turns one badly-typed seed row into a blank library for
* everybody, and the crash surfaces in a route far from the row that caused it.
* An omitted section is a bug someone reports; a white screen is an outage.
*
* A qualification framework's dimensions are shown with their weights and
* their 04 anchors, because the anchors are what stop a score being a vibe:
* the number only means something if two people reading the same evidence pick
* the same one.
*/
import { type ReactNode } from 'react';
import { Skull, TriangleAlert } from 'lucide-react';
import { DEMAND_STAGE_LABELS, type DemandStage, type MotionKind } from '@pig/core';
import { Badge, cn } from '@/components/ui';
import { CodeBlock } from './CodeBlock';
import { anchorScale, asRecord, number, recordList, text, textList } from './fields';
export function FieldsView({
kind,
fields,
className,
}: {
kind: MotionKind;
/** Untrusted. `unknown` on purpose — see the header. */
fields: unknown;
className?: string;
}) {
const record = asRecord(fields);
if (!record) return null;
const body = renderKind(kind, record);
if (!body) return null;
return <div className={cn('min-w-0 space-y-5', className)}>{body}</div>;
}
function renderKind(kind: MotionKind, fields: Record<string, unknown>): ReactNode {
switch (kind) {
case 'discovery':
return <DiscoveryFields fields={fields} />;
case 'qualification':
return <QualificationFields fields={fields} />;
case 'poc':
return <PocFields fields={fields} />;
case 'proposal':
return <ProposalFields fields={fields} />;
case 'pricing':
return <PricingFields fields={fields} />;
case 'architecture':
return <ArchitectureFields fields={fields} />;
case 'case_study':
return <CaseStudyFields fields={fields} />;
case 'narrative':
return <NarrativeFields fields={fields} />;
case 'playbook':
return <PlaybookFields fields={fields} />;
}
}
// ------------------------------------------------------------------ the kinds
function DiscoveryFields({ fields }: { fields: Record<string, unknown> }) {
const sections = recordList(fields.sections);
if (sections.length === 0) return null;
return (
<>
{sections.map((section, index) => {
const name = text(section.name);
if (!name) return null;
const questions = recordList(section.questions);
return (
<Section key={index} title={name}>
<Note label="Goal" value={text(section.goal)} />
<Note label="A bad answer" value={text(section.badAnswer)} tone="warning" />
{questions.length === 0 ? null : (
<ol className="min-w-0 space-y-3">
{questions.map((question, questionIndex) => {
const asked = text(question.q);
if (!asked) return null;
return (
<li key={questionIndex} className="min-w-0 rounded-lg border border-border p-3">
<p className="min-w-0 break-words font-medium leading-6">{asked}</p>
<Note label="Why" value={text(question.why)} />
<Note label="Listen for" value={text(question.listenFor)} />
</li>
);
})}
</ol>
)}
</Section>
);
})}
</>
);
}
function QualificationFields({ fields }: { fields: Record<string, unknown> }) {
const dimensions = recordList(fields.dimensions);
const bands = recordList(fields.bands);
const disqualifiers = recordList(fields.disqualifiers);
if (dimensions.length === 0 && bands.length === 0 && disqualifiers.length === 0) return null;
const weightTotal = dimensions.reduce((total, dimension) => total + (number(dimension.weight) ?? 0), 0);
return (
<>
{dimensions.length === 0 ? null : (
<Section
title="Dimensions"
// Weights need not sum to 100 — the score is a proportion of the
// maximum — so the total is shown rather than assumed.
aside={weightTotal > 0 ? `${weightTotal} weight in total` : undefined}
>
<div className="min-w-0 space-y-3">
{dimensions.map((dimension, index) => {
const name = text(dimension.name);
if (!name) return null;
const weight = number(dimension.weight);
const group = text(dimension.group);
return (
<div key={index} className="min-w-0 rounded-lg border border-border p-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 break-words font-medium leading-6">{name}</span>
{group ? <Badge tone="neutral">{group}</Badge> : null}
{weight === null ? null : (
<span className="nums whitespace-nowrap text-xs text-muted">weight {weight}</span>
)}
</div>
<Note label="Why it predicts" value={text(dimension.why)} />
<Anchors anchors={dimension.anchors} />
</div>
);
})}
</div>
</Section>
)}
{bands.length === 0 ? null : (
<Section title="What each score means">
<ul className="min-w-0 space-y-2">
{bands.map((band, index) => {
const label = text(band.label);
if (!label) return null;
const min = number(band.min);
const max = number(band.max);
return (
<li key={index} className="min-w-0">
<span className="font-medium">{label}</span>
{min === null || max === null ? null : (
<span className="nums ml-2 text-xs text-muted">
{min}{max}
</span>
)}
<Note label="Action" value={text(band.action)} />
</li>
);
})}
</ul>
</Section>
)}
{disqualifiers.length === 0 ? null : (
<Section title="Disqualifiers" icon={<Skull className="size-4" aria-hidden />}>
<ul className="min-w-0 space-y-3">
{disqualifiers.map((disqualifier, index) => {
const name = text(disqualifier.name);
if (!name) return null;
return (
<li key={index} className="min-w-0">
<p className="min-w-0 break-words font-medium leading-6">{name}</p>
<Note label="Test" value={text(disqualifier.test)} />
<Note label="Why" value={text(disqualifier.why)} />
</li>
);
})}
</ul>
</Section>
)}
</>
);
}
/** The 04 scale, extracted by the same rule the scorer reads it with. */
function Anchors({ anchors }: { anchors: unknown }) {
const rows = anchorScale(anchors);
if (rows.length === 0) return null;
return (
<dl className="mt-3 min-w-0 space-y-1.5 border-t border-border pt-3">
{rows.map((row) => (
<div key={row.score} className="flex min-w-0 gap-3">
<dt className="nums w-5 shrink-0 text-sm font-semibold text-muted">{row.score}</dt>
<dd className="min-w-0 break-words text-sm leading-6">{row.anchor}</dd>
</div>
))}
</dl>
);
}
function PocFields({ fields }: { fields: Record<string, unknown> }) {
const hypothesis = text(fields.hypothesis);
const milestones = recordList(fields.milestones);
const metrics = recordList(fields.successMetrics);
const risks = recordList(fields.risks);
if (!hypothesis && milestones.length === 0 && metrics.length === 0 && risks.length === 0) return null;
return (
<>
{hypothesis ? (
<Section title="Hypothesis">
<p className="min-w-0 break-words leading-6">{hypothesis}</p>
</Section>
) : null}
{milestones.length === 0 ? null : (
<Section title="Milestones">
<ol className="min-w-0 space-y-3">
{milestones.map((milestone, index) => {
const title = text(milestone.title);
if (!title) return null;
return (
<li key={index} className="min-w-0 rounded-lg border border-border p-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{text(milestone.week) ? (
<Badge tone="neutral">{text(milestone.week)}</Badge>
) : null}
<span className="min-w-0 break-words font-medium leading-6">{title}</span>
{/* A kill gate is the only reason a POC ends early rather
than drifting into a second quarter, so it is the one
property here that earns a colour. */}
{milestone.killGate === true ? <Badge tone="danger">Kill gate</Badge> : null}
</div>
<Note label="Exit criterion" value={text(milestone.exitCriterion)} />
<Note label="Owner" value={text(milestone.owner)} />
</li>
);
})}
</ol>
</Section>
)}
{metrics.length === 0 ? null : (
<Section title="Success metrics">
<ScrollTable
head={['Metric', 'Baseline', 'Target', 'Measured by']}
rows={metrics.map((metric) => [
text(metric.metric),
text(metric.baseline),
text(metric.target),
text(metric.measuredBy),
])}
/>
</Section>
)}
{risks.length === 0 ? null : (
<Section title="Risks" icon={<TriangleAlert className="size-4" aria-hidden />}>
<ScrollTable
head={['Risk', 'Owner', 'Mitigation']}
rows={risks.map((risk) => [text(risk.risk), text(risk.owner), text(risk.mitigation)])}
/>
</Section>
)}
</>
);
}
function ProposalFields({ fields }: { fields: Record<string, unknown> }) {
const blocks = recordList(fields.blocks);
if (blocks.length === 0) return null;
return (
<>
{blocks.map((block, index) => {
const title = text(block.title);
if (!title) return null;
const body = text(block.text);
return (
<Section key={index} title={title}>
<Note label="Use when" value={text(block.useWhen)} />
<Note label="Avoid when" value={text(block.avoidWhen)} tone="warning" />
{body ? (
// Proposal blocks are lifted verbatim into a document, so the
// whitespace the author wrote is part of the block.
<p className="min-w-0 whitespace-pre-wrap break-words rounded-lg border border-border bg-surface-2 p-3 text-sm leading-6">
{body}
</p>
) : null}
</Section>
);
})}
</>
);
}
function PricingFields({ fields }: { fields: Record<string, unknown> }) {
const inputs = recordList(fields.inputs);
const packages = recordList(fields.packages);
const tradeables = recordList(fields.tradeables);
if (inputs.length === 0 && packages.length === 0 && tradeables.length === 0) return null;
return (
<>
{inputs.length === 0 ? null : (
<Section title="Inputs">
<div className="min-w-0 space-y-3">
{inputs.map((input, index) => {
const name = text(input.name);
if (!name) return null;
const unit = text(input.unit);
return (
<div key={index} className="min-w-0 rounded-lg border border-border p-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 break-words font-medium leading-6">{name}</span>
{unit ? <Badge tone="neutral">{unit}</Badge> : null}
</div>
<Note label="Where it comes from" value={text(input.howToGet)} />
<Note label="Why it matters" value={text(input.whyItMatters)} />
</div>
);
})}
</div>
</Section>
)}
{packages.length === 0 ? null : (
<Section title="Packages">
<ScrollTable
head={['Package', 'Shape', 'Fits when', 'Fails when']}
rows={packages.map((entry) => [
text(entry.name),
text(entry.shape),
text(entry.fitsWhen),
text(entry.failsWhen),
])}
/>
</Section>
)}
{tradeables.length === 0 ? null : (
<Section title="What to trade">
<ScrollTable
head={['Give', 'Get']}
rows={tradeables.map((entry) => [text(entry.give), text(entry.get)])}
/>
</Section>
)}
</>
);
}
function ArchitectureFields({ fields }: { fields: Record<string, unknown> }) {
const architectures = recordList(fields.architectures);
if (architectures.length === 0) return null;
return (
<>
{architectures.map((architecture, index) => {
const name = text(architecture.name);
if (!name) return null;
const components = recordList(architecture.components);
return (
<Section key={index} title={name}>
<Note label="Fits when" value={text(architecture.fitsWhen)} />
<Note label="Fails when" value={text(architecture.failureMode)} tone="warning" />
<Diagram source={text(architecture.mermaid)} name={name} />
{components.length === 0 ? null : (
<ScrollTable
head={['Component', 'Run by', 'Why']}
rows={components.map((component) => [
text(component.component),
text(component.runBy),
text(component.why),
])}
/>
)}
</Section>
);
})}
</>
);
}
/**
* The diagram, as source rather than as a picture deliberately, for now. See
* `CodeBlock` for why, and for the copy button that makes it usable.
*/
function Diagram({ source, name }: { source: string | null; name: string }) {
if (!source) return null;
return <CodeBlock source={source} language="mermaid" label={`${name} diagram`} />;
}
function CaseStudyFields({ fields }: { fields: Record<string, unknown> }) {
const sections = recordList(fields.sections);
const harvest = recordList(fields.harvest);
if (sections.length === 0 && harvest.length === 0) return null;
return (
<>
{sections.length === 0 ? null : (
<Section title="Sections">
<ol className="min-w-0 space-y-3">
{sections.map((section, index) => {
const name = text(section.name);
if (!name) return null;
return (
<li key={index} className="min-w-0 rounded-lg border border-border p-3">
<p className="min-w-0 break-words font-medium leading-6">{name}</p>
<Note label="What goes here" value={text(section.prompt)} />
{/* Evidence rules are the reason a case study can be shown to
the next customer at all see AGENTS.md §4. */}
<Note label="Evidence rule" value={text(section.evidenceRule)} />
</li>
);
})}
</ol>
</Section>
)}
{harvest.length === 0 ? null : (
<Section title="When to capture it">
<ScrollTable
head={['When', 'Capture', 'Why']}
rows={harvest.map((entry) => [text(entry.when), text(entry.capture), text(entry.why)])}
/>
</Section>
)}
</>
);
}
function NarrativeFields({ fields }: { fields: Record<string, unknown> }) {
const narratives = recordList(fields.narratives);
if (narratives.length === 0) return null;
return (
<>
{narratives.map((narrative, index) => {
const audience = text(narrative.audience);
if (!audience) return null;
const body = text(narrative.text);
return (
<Section key={index} title={audience}>
<Note label="What they already believe" value={text(narrative.belief)} />
{body ? (
<p className="min-w-0 whitespace-pre-wrap break-words rounded-lg border border-border bg-surface-2 p-3 text-sm leading-6">
{body}
</p>
) : null}
<Note label="Analogy that works" value={text(narrative.analogyThatWorks)} />
<Note label="Analogy that fails" value={text(narrative.analogyThatFails)} tone="warning" />
<Note label="What they ask next" value={text(narrative.nextQuestion)} />
<Note label="Answer" value={text(narrative.answer)} />
</Section>
);
})}
</>
);
}
function PlaybookFields({ fields }: { fields: Record<string, unknown> }) {
const stages = recordList(fields.stages);
const research = asRecord(fields.researchInterface);
const promotion = recordList(fields.promotion);
if (stages.length === 0 && !research && promotion.length === 0) return null;
return (
<>
{stages.map((entry, index) => {
const stage = stageLabel(entry.stage);
if (!stage) return null;
const days = number(entry.typicalDays);
return (
<Section key={index} title={stage} aside={days === null ? undefined : `~${days} days`}>
<Note label="Entry" value={text(entry.entry)} />
<Note label="What it does" value={text(entry.does)} />
<Note label="Exit" value={text(entry.exit)} />
<Pills label="Artefacts" items={textList(entry.artifacts)} />
<Pills label="Who is involved" items={textList(entry.involves)} />
{/* Where the stage dies is the part of a playbook that gets read
twice the sequence is obvious, the failure is not. */}
<Note label="How it dies" value={text(entry.diesBy)} tone="warning" />
<Note label="Stall symptom" value={text(entry.stallSymptom)} tone="warning" />
<Note label="Unstick move" value={text(entry.unstickMove)} />
</Section>
);
})}
{research ? (
<Section title="Research interface">
<Pills label="Scope must contain" items={textList(research.scopeMustContain)} />
<Pills label="Owed back" items={textList(research.oweBack)} />
</Section>
) : null}
{promotion.length === 0 ? null : (
<Section title="What to promote, and when">
<ScrollTable
head={['Trigger', 'Promote', 'Into']}
rows={promotion.map((entry) => [text(entry.trigger), text(entry.promote), text(entry.into)])}
/>
</Section>
)}
</>
);
}
// ------------------------------------------------------------------- fragments
function Section({
title,
aside,
icon,
children,
}: {
title: string;
aside?: string;
icon?: ReactNode;
children: ReactNode;
}) {
return (
<section className="min-w-0 rounded-xl border border-border bg-surface p-4">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<h3 className="flex min-w-0 items-center gap-2 break-words font-semibold leading-snug">
{icon ? <span className="shrink-0 text-muted">{icon}</span> : null}
{title}
</h3>
{aside ? <span className="nums whitespace-nowrap text-xs text-muted">{aside}</span> : null}
</div>
<div className="mt-3 min-w-0 space-y-3">{children}</div>
</section>
);
}
/** A labelled paragraph. Renders nothing at all when the value is absent. */
function Note({
label,
value,
tone,
}: {
label: string;
value: string | null;
tone?: 'warning';
}) {
if (!value) return null;
return (
<p className="mt-2 min-w-0 break-words text-sm leading-6">
<span
className={cn(
'mr-2 text-xs font-medium uppercase tracking-wide',
tone === 'warning' ? 'text-warning' : 'text-muted',
)}
>
{label}
</span>
{value}
</p>
);
}
function Pills({ label, items }: { label: string; items: string[] }) {
if (items.length === 0) return null;
return (
<div className="mt-2 min-w-0">
<span className="text-xs font-medium uppercase tracking-wide text-muted">{label}</span>
<div className="mt-1 flex min-w-0 flex-wrap gap-1.5">
{items.map((item, index) => (
<Badge key={index} tone="neutral" className="min-w-0">
<span className="truncate">{item}</span>
</Badge>
))}
</div>
</div>
);
}
/**
* A table whose overflow stays inside its own box. Rows are dropped when every
* cell in them is empty, so a partially-authored list does not render as a
* column of blank stripes.
*/
function ScrollTable({ head, rows }: { head: string[]; rows: (string | null)[][] }) {
const present = rows.filter((row) => row.some((cell) => cell !== null));
if (present.length === 0) return null;
return (
<div className="scroll-x min-w-0 rounded-lg border border-border">
<table className="w-max min-w-full border-collapse text-left text-[13px] leading-5">
<thead className="border-b border-border bg-surface-2">
<tr>
{head.map((heading) => (
<th key={heading} className="whitespace-nowrap px-3 py-2 align-bottom font-medium text-muted">
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{present.map((row, index) => (
<tr key={index} className="transition-colors hover:bg-surface-2">
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="max-w-[28rem] px-3 py-2 align-top">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
// -------------------------------------------------------------------- narrowing
/**
* A playbook stage names a `DemandStage`, and the label comes from `@pig/core`
* so a renamed stage renames here too. An unrecognised value is shown as
* written rather than dropped: content authored ahead of an ontology change is
* still worth reading.
*/
function stageLabel(value: unknown): string | null {
const raw = text(value);
if (!raw) return null;
return DEMAND_STAGE_LABELS[raw as DemandStage] ?? raw;
}
@@ -0,0 +1,263 @@
/**
* Half of the loop: library template engagement artefact.
*
* The picker is a list rather than a grid of `TemplateCard`s, because the
* question being answered here is not "what is in the library" but "which of
* these four is the one for this stage" so the rows are dense, the stage
* filter opens pre-set to the deal's own stage, and usage is on every row.
* Usage is the only quality signal the library has: a v3 used eleven times is
* tested and a v1 used never is somebody's draft, and that distinction matters
* more when copying into a live deal than when browsing.
*
* Instantiating copies the body and fields and increments the template's
* usage count, which is what closes the template to in-place edits (§7a). That
* is stated on the dialog rather than left to be discovered later by whoever
* tries to fix a typo in it.
*/
import { useDeferredValue, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Library, RefreshCw, Search } from 'lucide-react';
import { toast } from 'sonner';
import {
DEMAND_STAGES,
DEMAND_STAGE_LABELS,
MOTION_KINDS,
MOTION_KIND_LABELS,
type DemandStage,
type MotionKind,
} from '@pig/core';
import { get, post } from '@/lib/api';
import { Badge, Button, EmptyState, Input, Skeleton } from '@/components/ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MotionKindBadge } from './MotionKindBadge';
/** `GET /api/motion/templates` — summaries, so no body and no fields. */
interface TemplateRow {
id: string;
kind: MotionKind;
slug: string;
version: number;
title: string;
summary: string;
stage: DemandStage;
visibility: 'private' | 'shared';
isSystem: boolean;
usageCount: number;
updatedAt: string;
}
export function InstantiateDialog({
engagementId,
defaultStage = null,
defaultKind = null,
open,
onOpenChange,
}: {
engagementId: string;
/** The deal's stage, so the list opens on the templates that serve it. */
defaultStage?: DemandStage | null;
/** Set when the caller is after one kind — a framework to score against. */
defaultKind?: MotionKind | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const [kind, setKind] = useState<'all' | MotionKind>(defaultKind ?? 'all');
const [stage, setStage] = useState<'all' | DemandStage>(defaultStage ?? 'all');
const [query, setQuery] = useState('');
const search = useDeferredValue(query.trim());
const templates = useQuery({
queryKey: ['motion', 'library', { kind, stage, search }],
queryFn: () => {
const params = new URLSearchParams();
if (kind !== 'all') params.set('kind', kind);
if (stage !== 'all') params.set('stage', stage);
if (search) params.set('q', search);
const suffix = params.toString();
return get<{ templates: TemplateRow[]; truncated: boolean }>(
`/api/motion/templates${suffix ? `?${suffix}` : ''}`,
);
},
enabled: open,
});
const instantiate = useMutation({
mutationFn: (templateId: string) =>
post<{ artifact: { id: string } }>(`/api/motion/engagements/${engagementId}/artifacts`, {
templateId,
}),
onSuccess: async () => {
// The prefix invalidation is deliberate: instantiating moves the
// template's usage count too, so the library and the OS home are stale
// the moment this succeeds, not only the engagement.
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success('Artefact added to the engagement');
onOpenChange(false);
},
onError: (error: Error) => toast.error(error.message),
});
const rows = templates.data?.templates ?? [];
const filtered = kind !== 'all' || stage !== 'all' || Boolean(search);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85dvh] w-[calc(100vw-1.5rem)] max-w-2xl grid-rows-[auto_auto_minmax(0,1fr)] overflow-hidden p-4 sm:p-6">
<DialogHeader className="pr-11">
<DialogTitle>Instantiate from the library</DialogTitle>
<DialogDescription>
The body and structured fields are copied into this engagement, and the template is
closed to in-place edits from here on later changes to it become a new version.
</DialogDescription>
</DialogHeader>
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_9.5rem_9.5rem]">
<div className="relative min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden />
<Input
aria-label="Search the library"
className="pl-9"
placeholder="Search title or summary"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<Select value={kind} onValueChange={(value) => setKind(value as typeof kind)}>
<SelectTrigger aria-label="Filter by kind" className="h-11 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All kinds</SelectItem>
{MOTION_KINDS.map((value) => (
<SelectItem key={value} value={value}>
{MOTION_KIND_LABELS[value]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select value={stage} onValueChange={(value) => setStage(value as typeof stage)}>
<SelectTrigger aria-label="Filter by stage" className="h-11 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All stages</SelectItem>
{DEMAND_STAGES.map((value) => (
<SelectItem key={value} value={value}>
{DEMAND_STAGE_LABELS[value]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="min-w-0 overflow-y-auto">
{templates.isLoading ? (
<div className="flex flex-col gap-2">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-20" />
))}
</div>
) : null}
{templates.isError ? (
<EmptyState
icon={<AlertTriangle />}
title="Library unavailable"
description={
templates.error instanceof Error
? templates.error.message
: 'The library could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void templates.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
) : null}
{!templates.isLoading && !templates.isError && rows.length === 0 ? (
<EmptyState
icon={<Library />}
title={filtered ? 'No template matches' : 'The library is empty'}
description={
filtered
? 'Clear a filter, or write the artefact from scratch and promote it once it has proved itself.'
: 'Nothing has been published yet. An artefact written here can be promoted into the library once it is final.'
}
/>
) : null}
{rows.length ? (
<ul className="flex min-w-0 flex-col gap-2">
{rows.map((template) => (
<li
key={template.id}
className="flex min-w-0 flex-col gap-2 rounded-xl border border-border p-3 sm:flex-row sm:items-center"
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<MotionKindBadge kind={template.kind} />
<Badge tone="neutral" className="min-w-0">
<span className="truncate">{DEMAND_STAGE_LABELS[template.stage]}</span>
</Badge>
<span className="nums whitespace-nowrap text-xs text-muted">
v{template.version} ·{' '}
{template.usageCount === 1
? 'used once'
: `used ${template.usageCount} times`}
</span>
</div>
<p className="mt-1.5 min-w-0 break-words font-medium leading-snug">
{template.title}
</p>
{template.summary ? (
<p className="mt-0.5 line-clamp-2 min-w-0 break-words text-sm leading-6 text-muted">
{template.summary}
</p>
) : null}
</div>
<Button
type="button"
variant="primary"
className="w-full shrink-0 sm:w-auto"
disabled={instantiate.isPending}
onClick={() => instantiate.mutate(template.id)}
>
Use this
</Button>
</li>
))}
</ul>
) : null}
{templates.data?.truncated ? (
<p className="mt-3 text-xs text-muted">
The library is wider than this answer. Narrow it with a kind, a stage or a search.
</p>
) : null}
</div>
</DialogContent>
</Dialog>
);
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Motion bodies, rendered as markdown.
*
* A template body is a document a discovery guide, a playbook, a narrative
* and the app had nowhere to render one: Piggy's transcript uses Streamdown,
* which is built for a half-finished token stream in a 22rem dock, not for a
* page of authored prose. This is the page-width counterpart, on plain
* `react-markdown` with GFM for the tables and task lists the content uses.
*
* Every element is styled from the map below. There is no
* `@tailwindcss/typography` in this repo and one is deliberately not being
* added for this, so there is no `prose` to fall back on and an element absent
* from the map renders with bare browser defaults.
*
* `rehypePlugins` is deliberately empty. Without `rehype-raw`, react-markdown
* does not render embedded HTML at all, and its default `urlTransform` already
* drops `javascript:` and other non-navigational protocols so the safe
* behaviour here is the behaviour of adding nothing. Template bodies are
* author-written but they are also promoted out of engagement artefacts that
* anyone with `motion:write` can edit, so they are treated as untrusted.
*/
import { isValidElement, type ComponentProps, type CSSProperties, type ReactNode } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ArrowUpRight } from 'lucide-react';
import { cn } from '@/components/ui';
import { CodeBlock } from './CodeBlock';
/** Fenced blocks carry their language as `language-<name>` on the `code` element. */
const LANGUAGE_CLASS = /language-([\w-]+)/;
export function Markdown({ content, className }: { content: string; className?: string }) {
return (
<div
className={cn(
// Block rhythm lives on the container rather than on each element, so
// the spacing between a heading and the paragraph under it does not
// depend on which of the two carries the margin.
'min-w-0 space-y-4 break-words text-sm leading-6 text-fg [&>*:first-child]:pt-0',
className,
)}
>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
{content}
</ReactMarkdown>
</div>
);
}
const MARKDOWN_COMPONENTS: Components = {
p: ({ children }) => <p className="leading-6">{children}</p>,
/*
* Headings buy their air with padding, not margin the container's
* `space-y-4` sets the gap below, and a margin above would be collapsed
* against it inconsistently. The scale stays close to body size: these are
* section headings inside a card, not the page's own title.
*/
h1: ({ children }) => <h1 className="pt-4 text-xl font-semibold tracking-tight">{children}</h1>,
h2: ({ children }) => <h2 className="pt-4 text-lg font-semibold tracking-tight">{children}</h2>,
h3: ({ children }) => <h3 className="pt-3 text-base font-semibold">{children}</h3>,
h4: ({ children }) => <h4 className="pt-2 text-sm font-semibold">{children}</h4>,
h5: ({ children }) => <h5 className="pt-2 text-sm font-medium text-muted">{children}</h5>,
h6: ({ children }) => (
<h6 className="pt-2 text-xs font-medium uppercase tracking-wide text-muted">{children}</h6>
),
ul: ({ children }) => <ul className="list-disc space-y-1.5 pl-5 marker:text-muted">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal space-y-1.5 pl-5 marker:text-muted">{children}</ol>,
// A nested list is the first *element* child of its item even when prose
// precedes it, so the parent's `space-y` never reaches it.
li: ({ children }) => <li className="leading-6 [&>ol]:mt-1.5 [&>ul]:mt-1.5">{children}</li>,
strong: ({ children }) => <strong className="font-semibold text-fg">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
del: ({ children }) => <del className="text-muted line-through">{children}</del>,
a: MarkdownLink,
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-4 text-muted [&>*+*]:mt-2">
{children}
</blockquote>
),
hr: () => <hr className="border-border" />,
img: ({ src, alt }) => (
// `referrerPolicy` so an image URL that arrived with a promoted artefact
// cannot use the referer to learn which template the reader had open.
<img
src={typeof src === 'string' ? src : undefined}
alt={alt ?? ''}
loading="lazy"
referrerPolicy="no-referrer"
className="max-w-full rounded-lg border border-border"
/>
),
/*
* The fence chrome is built entirely in `pre`, which never renders the
* `code` element react-markdown handed it it reads the language and the
* text off it instead. That is what makes the `code` entry below reachable
* only for inline code: react-markdown 10 stopped passing an `inline` flag,
* and the usual replacement guessing from the `language-` class gets a
* fenced block with no language wrong every time.
*/
pre: ({ children }) => <CodeFence>{children}</CodeFence>,
code: ({ children }) => (
<code className="rounded border border-border bg-surface-2 px-1 py-0.5 font-mono text-[0.85em]">
{children}
</code>
),
table: ({ children }) => (
// Without this the widest table on the page sets the width of the page,
// and every route scrolls sideways on a phone.
<div className="scroll-x rounded-lg border border-border">
{/* `w-max min-w-full`: fill the box when the table is narrow, spill into
the scroller rather than squash the columns when it is not. */}
<table className="w-max min-w-full border-collapse text-left text-[13px] leading-5">
{children}
</table>
</div>
),
thead: ({ children }) => <thead className="border-b border-border bg-surface-2">{children}</thead>,
tbody: ({ children }) => <tbody className="divide-y divide-border">{children}</tbody>,
// A row highlight is what lets you keep your place across a table that is
// wider than the pane and has been scrolled sideways.
tr: ({ children }) => <tr className="transition-colors hover:bg-surface-2">{children}</tr>,
th: ({ children, style }) => (
<th
className="whitespace-nowrap px-3 py-2 align-bottom font-medium text-muted"
style={alignStyle(style)}
>
{children}
</th>
),
// No `nums` here, deliberately: tabular figures on every cell render an
// author's prose column in the digit-width of a ledger.
td: ({ children, style }) => (
<td className="px-3 py-2 align-top" style={alignStyle(style)}>
{children}
</td>
),
};
/**
* Links in a template body point out of PIG a vendor's docs, a paper, a
* customer's status page so they open in a new tab rather than navigating a
* workspace someone has unsaved edits in, carry no referer, and wear a marker
* glyph so a plausible phrase cannot pass itself off as internal navigation.
*/
function MarkdownLink({ href, children }: ComponentProps<'a'>) {
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
title={href}
className="font-medium text-info underline decoration-border underline-offset-2 hover:decoration-info"
>
{children}
<ArrowUpRight className="ml-0.5 inline size-3 align-[-0.1em]" aria-hidden />
</a>
);
}
/**
* A fenced code block, built from the `code` element rather than around it.
*
* The chrome the language caption and the copy button comes from
* `CodeBlock`, so a mermaid fence inside a promoted body offers the same copy
* action a mermaid field does in `FieldsView`. They are the same thing to the
* person reading them.
*/
function CodeFence({ children }: { children: ReactNode }) {
const element = isValidElement<{ className?: string; children?: ReactNode }>(children)
? children
: null;
const language = LANGUAGE_CLASS.exec(element?.props.className ?? '')?.[1];
const text = codeText(element ? element.props.children : children);
return <CodeBlock source={text} language={language} />;
}
/**
* GFM column alignment the `---:` in a delimiter row is the one piece of
* element styling the markdown itself owns, and a currency column that
* silently reverts to the left is the difference between a readable table and
* a wall. It reaches the cell as `style.textAlign`; only that property is
* taken, so nothing else an author writes can style the page.
*/
function alignStyle(style: CSSProperties | undefined): CSSProperties | undefined {
const value = style?.textAlign;
return value === 'right' || value === 'center' || value === 'left' ? { textAlign: value } : undefined;
}
/** The fence body reaches us as React children, normally one text node deep. */
function codeText(children: ReactNode): string {
if (typeof children === 'string') return children;
if (Array.isArray(children)) return (children as ReactNode[]).map(codeText).join('');
if (isValidElement<{ children?: ReactNode }>(children)) return codeText(children.props.children);
return '';
}
@@ -0,0 +1,62 @@
/**
* Which of the nine kinds a template or artefact is.
*
* The tone is neutral for every kind, deliberately. The badge palette here is
* semantic positive, warning, danger, info mean something about a number
* and the kinds are a taxonomy, not a severity scale; colouring them would
* teach people to read "case study" as good news. The differentiation comes
* from the glyph instead, which is what lets a mixed list of artefacts be
* scanned by shape rather than read word by word.
*/
import {
BookOpen,
FileText,
FlaskConical,
Gauge,
MessagesSquare,
Network,
Route,
Search,
Tags,
type LucideIcon,
} from 'lucide-react';
import { MOTION_KIND_DESCRIPTIONS, MOTION_KIND_LABELS, type MotionKind } from '@pig/core';
import { Badge, cn } from '@/components/ui';
const KIND_ICONS: Record<MotionKind, LucideIcon> = {
discovery: Search,
qualification: Gauge,
poc: FlaskConical,
proposal: FileText,
pricing: Tags,
architecture: Network,
case_study: BookOpen,
narrative: MessagesSquare,
playbook: Route,
};
export function MotionKindBadge({
kind,
/** Glyph only, for a dense row where the label is already in the title. */
iconOnly = false,
className,
}: {
kind: MotionKind;
iconOnly?: boolean;
className?: string;
}) {
const Icon = KIND_ICONS[kind];
const label = MOTION_KIND_LABELS[kind];
return (
<Badge
tone="neutral"
// The description is the only definition of a kind most people will ever
// read, and there is nowhere else on a card to put it.
title={`${label}${MOTION_KIND_DESCRIPTIONS[kind]}`}
className={cn('min-w-0 max-w-full', className)}
>
<Icon className="size-3 shrink-0" aria-hidden />
{iconOnly ? <span className="sr-only">{label}</span> : <span className="truncate">{label}</span>}
</Badge>
);
}
@@ -0,0 +1,287 @@
/**
* Scoring a deal against a qualification framework.
*
* The arithmetic is `motionScoreBasisPoints` from `@pig/core` the same pure
* function the API calls before it writes the row. That import is the whole
* design of this component: a client that computes the total itself would
* eventually disagree with the server about a number people act on, and a
* qualification score that reads 74% while you are filling the form in and
* 71% once it is saved is worse than one the form never showed at all.
* Nothing here posts a score; it posts the dimensions and lets the server
* recompute, so the two can never diverge even if this file is wrong.
*
* Every dimension must be answered before Save enables. A partial score is not
* a smaller score the maximum shrinks with it, so answering only the three
* dimensions that went well produces a band of "Strategic" on a deal nobody
* has qualified. The running total shown while the form is incomplete is
* labelled provisional for exactly that reason.
*
* `fields` is authored JSON and arrives unvalidated, the same way it does in
* `FieldsView`: a dimension without an id cannot be posted (the API keys on
* it) and a dimension without anchors cannot be scored honestly, so both are
* dropped rather than rendered as an empty control.
*/
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
import { toast } from 'sonner';
import {
MOTION_MAX_DIMENSION_SCORE,
MOTION_MIN_DIMENSION_SCORE,
motionBand,
motionScoreBasisPoints,
type MotionDimensionScore,
} from '@pig/core';
import { post } from '@/lib/api';
import { Badge, Button, cn } from '@/components/ui';
import { Textarea } from '@/components/ui/textarea';
import { anchorScale, asRecord, number, text, type AnchorRow } from './fields';
import { percent } from './format';
/** A dimension this component was able to make sense of. */
interface ScorableDimension {
id: string;
name: string;
weight: number;
group: string | null;
why: string | null;
anchors: AnchorRow[];
}
export function QualificationScorer({
engagementId,
frameworkTemplateId,
fields,
previousBasisPoints = null,
onScored,
onCancel,
}: {
engagementId: string;
/** Recorded on the score so the framework behind it stays identifiable. */
frameworkTemplateId: string | null;
/** The framework's `fields`. Untrusted author JSON — see the header. */
fields: unknown;
/** The last score on this engagement, if there is one, for the movement. */
previousBasisPoints?: number | null;
onScored: () => void;
onCancel: () => void;
}) {
const queryClient = useQueryClient();
const dimensions = useMemo(() => scorableDimensions(fields), [fields]);
const [answers, setAnswers] = useState<Record<string, number>>({});
const [note, setNote] = useState('');
const answered: MotionDimensionScore[] = dimensions
.filter((dimension) => answers[dimension.id] !== undefined)
.map((dimension) => ({
id: dimension.id,
weight: dimension.weight,
score: answers[dimension.id] as number,
}));
const complete = dimensions.length > 0 && answered.length === dimensions.length;
const basisPoints = motionScoreBasisPoints(answered);
const band = motionBand(basisPoints);
const movement =
complete && previousBasisPoints !== null ? basisPoints - previousBasisPoints : null;
const save = useMutation({
mutationFn: () =>
post<{ score: { id: string } }>(`/api/motion/engagements/${engagementId}/scores`, {
dimensions: answered,
frameworkTemplateId,
note: note.trim() ? note.trim() : null,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success('Qualification scored');
onScored();
},
onError: (error: Error) => toast.error(error.message),
});
if (dimensions.length === 0) {
return (
<div className="mt-6 rounded-lg border border-border p-4">
<p className="font-medium">This framework has no scorable dimensions</p>
<p className="mt-1 text-sm text-muted">
A dimension needs an id, a name and at least one anchor before it can be scored. Fix
the framework in the library, then score against a new version of it.
</p>
<Button className="mt-4" type="button" variant="outline" onClick={onCancel}>
Close
</Button>
</div>
);
}
return (
<form
className="mt-5 flex min-w-0 flex-col gap-5 pb-6"
onSubmit={(event) => {
event.preventDefault();
save.mutate();
}}
>
{/* Sticky, because the reason to show a running total at all is to let
somebody see a marginal answer move the band while they are still
looking at the anchors that produced it. */}
<div className="sticky top-0 z-10 -mx-1 min-w-0 rounded-xl border border-border bg-surface p-4">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="nums text-3xl font-semibold leading-none">{percent(basisPoints)}</span>
<Badge tone={band.tone}>{band.label}</Badge>
{movement === null ? null : <Movement delta={movement} />}
</div>
<p className="mt-2 text-xs text-muted">
{complete ? (
<>
Weighted across {dimensions.length} dimensions ·{' '}
<span className="nums">{basisPoints}</span> basis points
</>
) : (
<>
Provisional {answered.length} of {dimensions.length} scored. The maximum shrinks
with the dimensions you leave blank, so this band is not the deal's band yet.
</>
)}
</p>
</div>
<div className="min-w-0 space-y-4">
{dimensions.map((dimension) => (
<fieldset key={dimension.id} className="min-w-0 rounded-xl border border-border p-3">
<legend className="flex min-w-0 flex-wrap items-center gap-2 px-1">
<span className="min-w-0 break-words font-medium leading-6">{dimension.name}</span>
{dimension.group ? <Badge tone="neutral">{dimension.group}</Badge> : null}
<span className="nums whitespace-nowrap text-xs text-muted">
{dimension.weight === 0 ? 'no weight' : `weight ${dimension.weight}`}
</span>
</legend>
{dimension.why ? (
<p className="min-w-0 break-words text-sm leading-6 text-muted">{dimension.why}</p>
) : null}
<div className="mt-2 min-w-0 space-y-1.5">
{dimension.anchors.map((anchor) => {
const selected = answers[dimension.id] === anchor.score;
return (
<label
key={anchor.score}
className={cn(
'tap flex min-h-11 min-w-0 cursor-pointer gap-3 rounded-lg border p-2.5',
selected ? 'border-brand bg-surface-2' : 'border-border hover:bg-surface-2',
)}
>
<input
type="radio"
className="sr-only"
name={`dimension-${dimension.id}`}
value={anchor.score}
checked={selected}
onChange={() =>
setAnswers((current) => ({ ...current, [dimension.id]: anchor.score }))
}
/>
<span
className={cn(
'nums flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold',
selected ? 'bg-primary text-primary-foreground' : 'bg-surface-2 text-muted',
)}
aria-hidden
>
{anchor.score}
</span>
<span className="min-w-0 break-words text-sm leading-6">{anchor.anchor}</span>
</label>
);
})}
</div>
</fieldset>
))}
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<label className="text-sm font-medium" htmlFor="qualification-note">
What moved since the last score
</label>
<Textarea
id="qualification-note"
className="min-h-24"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="The evidence behind the answers that changed."
/>
</div>
{save.isError ? <p className="text-sm text-danger">{save.error.message}</p> : null}
<div className="flex min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
disabled={!complete || save.isPending}
title={complete ? undefined : 'Score every dimension first.'}
>
Record score
</Button>
</div>
</form>
);
}
function Movement({ delta }: { delta: number }) {
if (delta === 0) {
return (
<span className="inline-flex items-center gap-1 text-xs text-muted">
<Minus className="size-3.5" aria-hidden />
Unchanged
</span>
);
}
const Icon = delta > 0 ? ArrowUpRight : ArrowDownRight;
return (
<span
className={cn(
'nums inline-flex items-center gap-1 text-xs font-medium',
delta > 0 ? 'text-positive' : 'text-danger',
)}
>
<Icon className="size-3.5" aria-hidden />
{delta > 0 ? '+' : ''}
{percent(Math.abs(delta))}
</span>
);
}
// ------------------------------------------------------------------ narrowing
function scorableDimensions(fields: unknown): ScorableDimension[] {
const record = asRecord(fields);
const raw = Array.isArray(record?.dimensions) ? record.dimensions : [];
const seen = new Set<string>();
const scorable: ScorableDimension[] = [];
for (const entry of raw) {
const dimension = asRecord(entry);
if (!dimension) continue;
const id = text(dimension.id);
const name = text(dimension.name);
if (!id || !name || seen.has(id)) continue;
const anchors = anchorScale(dimension.anchors);
if (anchors.length === 0) continue;
seen.add(id);
scorable.push({
id,
name,
// Clamped to the API's own bound rather than trusted: a weight the seed
// author typed as 1500 would otherwise fail zod after the whole form is
// filled in, which reads as the save being broken.
weight: Math.min(1_000, Math.max(0, Math.round(number(dimension.weight) ?? 0))),
group: text(dimension.group),
why: text(dimension.why),
anchors,
});
}
return scorable;
}
@@ -0,0 +1,98 @@
/**
* The demand motion, across the top.
*
* The eight open stages are the spine of the whole feature Motion binds
* artefacts to them rather than inventing a second pipeline so the rail is
* the one control that appears on the OS home, the library filter and the
* engagement workspace, and it has to mean the same thing in all three.
*
* The closed stages are absent because `DEMAND_OPEN_STAGES` is the default:
* a won or lost deal has left the motion, and a rail segment nobody can file
* work against is a segment people ask about once a quarter.
*
* It scrolls horizontally rather than wrapping. Wrapping puts `procurement`
* under `qualification` at 393px, which reads as a second row of the sequence
* starting over; a scroller keeps the order legible and keeps the overflow
* inside this box instead of dragging the page sideways.
*/
import { DEMAND_OPEN_STAGES, DEMAND_STAGE_LABELS, type DemandStage } from '@pig/core';
import { cn } from '@/components/ui';
export function StageRail({
counts,
coverage,
stages = DEMAND_OPEN_STAGES,
countLabel = 'engagements',
coverageLabel = 'templates',
activeStage,
onSelect,
className,
}: {
/** The headline figure per stage. A stage absent from the record reads as 0. */
counts: Partial<Record<DemandStage, number>>;
/** The second figure, if the caller has one — library cover, typically. */
coverage?: Partial<Record<DemandStage, number>>;
stages?: readonly DemandStage[];
countLabel?: string;
coverageLabel?: string;
activeStage?: DemandStage | null;
/** Omit to render a read-only rail: a non-interactive button is a trap. */
onSelect?: (stage: DemandStage) => void;
className?: string;
}) {
return (
<div className={cn('scroll-x min-w-0 pb-1', className)}>
<ol className="flex min-w-0 items-stretch gap-2">
{stages.map((stage) => {
const count = counts[stage] ?? 0;
const covered = coverage?.[stage];
const active = activeStage === stage;
const body = (
<>
<span className="truncate text-xs font-medium uppercase tracking-wide text-muted">
{DEMAND_STAGE_LABELS[stage]}
</span>
<span className={cn('nums text-2xl font-semibold leading-none', count === 0 && 'text-muted')}>
{count}
</span>
{covered === undefined ? null : (
// A stage with no template is the finding this rail exists to
// surface — it is where the motion stops repeating — so it is
// called out rather than shown as another grey zero.
<span className={cn('nums truncate text-xs', covered === 0 ? 'text-warning' : 'text-muted')}>
{covered === 0 ? `No ${coverageLabel}` : `${covered} ${coverageLabel}`}
</span>
)}
</>
);
const shape = cn(
'tap flex w-[8.5rem] shrink-0 flex-col justify-between gap-2 rounded-xl border p-3 text-left',
active ? 'border-brand bg-surface-2' : 'border-border bg-surface',
);
return (
<li key={stage} className="flex min-w-0">
{onSelect ? (
<button
type="button"
onClick={() => onSelect(stage)}
aria-pressed={active}
aria-label={`${DEMAND_STAGE_LABELS[stage]}: ${count} ${countLabel}`}
className={cn(shape, 'transition-colors hover:bg-surface-2')}
>
{body}
</button>
) : (
<div className={shape} aria-label={`${DEMAND_STAGE_LABELS[stage]}: ${count} ${countLabel}`}>
{body}
</div>
)}
</li>
);
})}
</ol>
</div>
);
}
@@ -0,0 +1,88 @@
/**
* One library template, as a browsable card.
*
* The three facts on it that are not the title are the ones that decide
* whether it is worth opening: who can see it, how many engagements have
* already used it, and whether it came back out of one. Usage is the closest
* thing the library has to a quality signal a v3 used eleven times is
* tested, a v1 used never is a draft somebody left and hiding it behind a
* click is what turns a library into a folder.
*/
import { Link } from 'react-router-dom';
import { Lock, Sparkles, Users } from 'lucide-react';
import { DEMAND_STAGE_LABELS } from '@pig/core';
import { Badge, Card, cn } from '@/components/ui';
import { MotionKindBadge } from './MotionKindBadge';
import type { MotionTemplateView } from './model';
import type { ReactNode } from 'react';
export function TemplateCard({
template,
to,
footer,
className,
}: {
template: MotionTemplateView;
/** Defaults to the template's own page; pass a filtered return path instead. */
to?: string;
/** Actions belonging to the calling page — instantiate, publish, fork. */
footer?: ReactNode;
className?: string;
}) {
return (
<Card className={cn('flex min-w-0 flex-col gap-3 p-4', className)}>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<MotionKindBadge kind={template.kind} />
<Badge tone="neutral" className="min-w-0">
<span className="truncate">{DEMAND_STAGE_LABELS[template.stage]}</span>
</Badge>
{template.visibility === 'private' ? (
<Badge tone="warning" title="Only you and a platform admin can see this">
<Lock className="size-3 shrink-0" aria-hidden />
Private
</Badge>
) : null}
{template.isSystem ? (
<Badge tone="neutral" title="Shipped with PIG rather than authored here">
<Sparkles className="size-3 shrink-0" aria-hidden />
Starter
</Badge>
) : null}
</div>
<div className="min-w-0">
{/* break-words, not truncate: the title is the only way to tell two
versions of the same lineage apart, and an unbroken word at 393px
is what drags the whole page sideways. */}
<h3 className="min-w-0 break-words font-semibold leading-snug">
<Link
to={to ?? `/motion/library/${template.id}`}
className="underline-offset-4 hover:text-accent-fg hover:underline"
>
{template.title}
</Link>
</h3>
{template.summary ? (
<p className="mt-1 line-clamp-2 min-w-0 break-words text-sm leading-6 text-muted">
{template.summary}
</p>
) : null}
</div>
<div className="mt-auto flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
<span className="nums whitespace-nowrap">v{template.version}</span>
<span className="nums inline-flex min-w-0 items-center gap-1 whitespace-nowrap">
<Users className="size-3 shrink-0" aria-hidden />
{template.usageCount === 1 ? 'Used once' : `Used ${template.usageCount} times`}
</span>
{template.originArtifactId ? (
// The loop, made visible. This row exists because an engagement
// proved it, which is the whole argument for the feature.
<span className="min-w-0 truncate">Promoted from an engagement</span>
) : null}
</div>
{footer ? <div className="flex min-w-0 flex-wrap items-center gap-2">{footer}</div> : null}
</Card>
);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Narrowing the authored JSON a template carries in `fields`.
*
* One rule per shape, in one place, because there are two readers of the same
* bytes and they must not disagree. `FieldsView` renders a qualification
* framework's dimensions and `QualificationScorer` turns the same dimensions
* into a form so the day whitespace-only anchors are made to render as an
* em-dash rather than vanish, a second copy of `text()` would leave the scorer
* still dropping them, and the reader would be shown five dimensions while the
* score was computed against four.
*
* Everything here is total: an unrecognised shape yields null or an empty
* list, never a throw. An omitted section is a bug someone reports; a white
* screen on one badly-typed row is an outage.
*/
import { MOTION_MAX_DIMENSION_SCORE, MOTION_MIN_DIMENSION_SCORE } from '@pig/core';
export function asRecord(value: unknown): Record<string, unknown> | null {
// `typeof null` is 'object' and an array is one too; both would satisfy a
// naive check and then read `undefined` off every property.
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
/** A non-empty string, or null. Whitespace-only is treated as absent. */
export function text(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length === 0 ? null : trimmed;
}
export function number(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
/** The entries of a list that are objects. Anything else is dropped silently. */
export function recordList(value: unknown): Record<string, unknown>[] {
if (!Array.isArray(value)) return [];
return value
.map((entry) => asRecord(entry))
.filter((entry): entry is Record<string, unknown> => entry !== null);
}
export function textList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((entry) => text(entry)).filter((entry): entry is string => entry !== null);
}
export interface AnchorRow {
score: number;
anchor: string;
}
/**
* The 04 scale, from `@pig/core` rather than from the keys present in the
* JSON, so a framework that forgot to write an anchor for 2 still offers the
* same scale everywhere it is read.
*/
export function anchorScale(anchors: unknown): AnchorRow[] {
const record = asRecord(anchors);
if (!record) return [];
const rows: AnchorRow[] = [];
for (let score = MOTION_MIN_DIMENSION_SCORE; score <= MOTION_MAX_DIMENSION_SCORE; score += 1) {
const anchor = text(record[String(score)]);
if (anchor) rows.push({ score, anchor });
}
return rows;
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Display formatting shared by every page that renders a qualification score.
*
* One formatter, because the same 6250 was reading as `63%` on the Motion home
* and `62.5%` on the engagement that page links to and the difference lands
* exactly where a band boundary does. It lived in `QualificationScorer` until
* three pages that do not otherwise touch the scorer were importing from it.
*/
/** Basis points as a percentage. Rounded, never truncated — AGENTS.md §4. */
export function percent(basisPoints: number): string {
return `${(Math.round(basisPoints / 10) / 10).toFixed(1)}%`;
}
+47
View File
@@ -0,0 +1,47 @@
/**
* The shape a template arrives in on the wire.
*
* It describes a *response*, not the server's own row, so it is declared here
* rather than imported from `@pig/db`: a column the API does not serialise must
* be absent, and one it may not have joined yet must be optional, or the
* compiler asserts a guarantee the JSON does not carry. The enums are the
* exception those come from `@pig/core`, because a kind the ontology has
* dropped should stop compiling rather than keep rendering.
*
* `fields` is the deliberate hole in the type. It is author-written JSON whose
* shape varies by kind and is not validated on the way out of the database, so
* it arrives as `unknown` and is narrowed at the point of use in `FieldsView`.
* Typing it as the shape we hope for would move a runtime crash into a place
* where nobody is looking for it.
*
* There is only one type here on purpose. Eight more were written alongside it
* and never imported, and by the time anyone read them they no longer matched
* `services/motion.ts` `EngagementDetail` promised `.artifacts` where the
* endpoint returns `.stages`. Each page declares the subset of the response it
* actually reads, which is checked against the `get<T>()` call that fetches it;
* a shared file that nothing imports is checked against nothing at all.
*/
import type { DemandStage, MotionKind, MotionVisibility } from '@pig/core';
export interface MotionTemplateView {
id: string;
kind: MotionKind;
/** Stable across versions — the identity of the lineage, not of the row. */
slug: string;
version: number;
title: string;
summary: string;
body: string;
fields: unknown;
stage: DemandStage;
visibility: MotionVisibility;
ownerUserId: string | null;
ownerName?: string | null;
supersedesId: string | null;
originArtifactId: string | null;
isSystem: boolean;
usageCount: number;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
}
+5 -1
View File
@@ -209,7 +209,11 @@ export function Stat({
: 'text-fg';
return (
<div className="card p-4">
// `min-w-0` for the reason `Card` carries it: `.card` does not, and a stat
// is always a grid child whose figure is `tabular-nums` and whose hint does
// not wrap mid-word. Without it a three-up row on a 393px phone refuses to
// shrink and the page scrolls sideways (AGENTS.md §5).
<div className="card min-w-0 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-muted">{label}</div>
<div className={cn('nums mt-1 text-2xl font-semibold leading-tight sm:text-3xl', toneClass)}>
{value}
+27 -5
View File
@@ -20,8 +20,11 @@ import {
FileSpreadsheet,
FileText,
GraduationCap,
Handshake,
LayoutDashboard,
Library,
MessageCircleMore,
Route,
Server,
Settings,
ShieldCheck,
@@ -33,7 +36,7 @@ import {
import type { Capability, Team } from '@pig/core';
import { canAny, type PermissionIdentity } from './permissions';
export const NAV_GROUPS = ['Intelligence', 'Marketplace', 'Records', 'Control'] as const;
export const NAV_GROUPS = ['Intelligence', 'Motion', 'Marketplace', 'Records', 'Control'] as const;
export type NavGroup = (typeof NAV_GROUPS)[number];
export interface NavItem {
@@ -61,6 +64,15 @@ export const NAV: NavItem[] = [
{ to: '/piggy', label: 'Piggy', icon: MessageCircleMore, group: 'Intelligence' },
{ to: '/learn', label: 'Learn', icon: GraduationCap, group: 'Intelligence' },
{ to: '/margin', label: 'Margin', icon: TrendingUp, group: 'Intelligence', primary: true },
// None of these is `primary`: the phone tab bar holds five and the ledger
// pages already hold all five. The three routes are reachable from the
// sidebar and from ⌘K, which a NavItem earns by structurally satisfying
// CommandDestination. Reads are gated on `book:read` like Accounts and
// Contracts, so no `requires` — the write capabilities are enforced per
// control on the page, not by hiding the destination.
{ to: '/motion', label: 'Motion', icon: Route, group: 'Motion' },
{ to: '/motion/library', label: 'Library', icon: Library, group: 'Motion' },
{ to: '/motion/engagements', label: 'Engagements', icon: Handshake, group: 'Motion' },
{ to: '/capacity', label: 'Capacity', icon: Server, group: 'Marketplace', primary: true },
{ to: '/demand', label: 'Demand', icon: Building2, group: 'Marketplace', primary: true },
{ to: '/supply', label: 'Supply', icon: Boxes, group: 'Marketplace', primary: true },
@@ -106,9 +118,19 @@ export function visibleNav(identity: PermissionIdentity | undefined): NavItem[]
});
}
/** The nav entry a pathname belongs to, for the header title and active state. */
/**
* The nav entry a pathname belongs to, for the header title and active state.
*
* The longest match wins, not the first. Motion is the first group whose own
* destinations nest `/motion` is a prefix of `/motion/library` and a
* first-match scan titles the library page "Motion". Longest-match is a no-op
* for every other route in the table.
*/
export function activeNavItem(items: readonly NavItem[], pathname: string): NavItem | undefined {
return items.find((item) =>
item.to === '/' ? pathname === '/' : pathname === item.to || pathname.startsWith(`${item.to}/`),
);
let match: NavItem | undefined;
for (const item of items) {
if (item.to === '/' ? pathname !== '/' : pathname !== item.to && !pathname.startsWith(`${item.to}/`)) continue;
if (!match || item.to.length > match.to.length) match = item;
}
return match;
}
+993
View File
@@ -0,0 +1,993 @@
/**
* The engagement workspace: one demand deal, worked with the motion.
*
* The stage rail is the spine, and it is the *deal's* stages rather than any
* state of the engagement's own an engagement that could disagree with its
* deal about what stage the work is at would be a second answer to a question
* that already has one.
*
* Two actions here are first-class because they are the loop: instantiate a
* template into a stage, and promote a finished artefact back into the library.
* Everything else on the page exists to make those two legible the score
* history because qualification is a movement rather than a number, and the
* per-stage grouping because "what did we write at proposal" is the question
* somebody opening this page a quarter later is actually asking.
*
* The loading, missing and failed branches are early returns rather than
* sibling conditionals. That is the detail-page pattern in this repo
* (`Account.tsx` is the same shape, 404 branch included), and it differs from
* the list pages deliberately: a detail page has nothing to render around the
* hole.
*/
import { useMemo, useState, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import {
AlertTriangle,
ArrowLeft,
ArrowUpRight,
ChevronRight,
FileText,
Gauge,
Handshake,
Library,
Lock,
RefreshCw,
Sparkles,
} from 'lucide-react';
import { toast } from 'sonner';
import {
ARTIFACT_STATUSES,
ARTIFACT_STATUS_LABELS,
DEMAND_OPEN_STAGES,
DEMAND_STAGES,
DEMAND_STAGE_LABELS,
ENGAGEMENT_STATUSES,
ENGAGEMENT_STATUS_LABELS,
toPiggyPageRoute,
type ArtifactStatus,
type DemandStage,
type EngagementStatus,
type MotionBandTone,
type MotionKind,
type MotionVisibility,
} from '@pig/core';
import { ApiError, get, patch, post, shortDate } from '@/lib/api';
import { usePageTitle } from '@/lib/title';
import { usePiggyContext } from '@/lib/piggy-context';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import {
Badge,
Button,
Card,
CardContent,
EmptyState,
Input,
Skeleton,
Stat,
cn,
} from '@/components/ui';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { FieldsView } from '@/components/motion/FieldsView';
import { InstantiateDialog } from '@/components/motion/InstantiateDialog';
import { Markdown } from '@/components/motion/Markdown';
import { MotionKindBadge } from '@/components/motion/MotionKindBadge';
import { QualificationScorer } from '@/components/motion/QualificationScorer';
import { percent } from '@/components/motion/format';
import { StageRail } from '@/components/motion/StageRail';
// --------------------------------------------------------------------- wire
interface ScoreView {
id: string;
frameworkTemplateId: string | null;
dimensions: unknown;
basisPoints: number;
band: string;
tone: MotionBandTone;
note: string | null;
scoredByUserId: string | null;
scoredAt: string;
}
interface ArtifactView {
id: string;
engagementId: string;
templateId: string | null;
kind: MotionKind;
stage: DemandStage;
title: string;
body: string;
fields: unknown;
status: ArtifactStatus;
authoredByUserId: string | null;
promotedTemplateId: string | null;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
}
interface EngagementView {
id: string;
demandDealId: string;
dealName: string | null;
/** The deal's stage. An engagement has none of its own — that is the point. */
stage: DemandStage | null;
accountId: string | null;
accountName: string | null;
status: EngagementStatus;
summary: string | null;
ownerUserId: string | null;
playbookTemplateId: string | null;
openedAt: string;
closedAt: string | null;
artifactCount: number;
latestScore: ScoreView | null;
}
interface TemplateSummary {
id: string;
kind: MotionKind;
slug: string;
version: number;
title: string;
summary: string;
stage: DemandStage;
visibility: MotionVisibility;
usageCount: number;
}
/** `GET /api/motion/engagements/:id` — `stages` is all ten, always, in order. */
interface EngagementDetail {
engagement: EngagementView;
stages: { stage: DemandStage; artifacts: ArtifactView[] }[];
scores: ScoreView[];
playbook: TemplateSummary | null;
}
const WRITE_DENIED = 'Editing this engagement needs the motion:write permission.';
const PUBLISH_DENIED = 'Promoting into the library needs the motion:publish permission.';
// --------------------------------------------------------------------- page
export function Engagement() {
const { id = '' } = useParams<{ id: string }>();
const me = useIdentity();
const mayWrite = canAny(me, 'motion:write');
const mayPublish = canAny(me, 'motion:publish');
const [stageFilter, setStageFilter] = useState<DemandStage | null>(null);
const [instantiating, setInstantiating] = useState<{ kind: MotionKind | null } | null>(null);
const [scoring, setScoring] = useState(false);
const [promoting, setPromoting] = useState<ArtifactView | null>(null);
const detail = useQuery({
queryKey: ['motion', 'engagements', id, 'detail'],
queryFn: () => get<EngagementDetail>(`/api/motion/engagements/${id}`),
enabled: Boolean(id),
retry: false,
});
const engagement = detail.data?.engagement;
usePageTitle(engagement?.dealName ?? 'Engagement');
usePiggyContext({
type: 'page',
route: toPiggyPageRoute('/motion/engagements'),
label: engagement?.dealName ? `Engagement — ${engagement.dealName}` : 'Engagement',
});
const groups = detail.data?.stages ?? [];
const counts = useMemo(() => {
const record: Partial<Record<DemandStage, number>> = {};
for (const group of groups) record[group.stage] = group.artifacts.length;
return record;
}, [groups]);
if (!canAny(me, 'book:read')) {
return (
<Restricted
icon={<Lock />}
title="This engagement is restricted"
description="An engagement sits on a demand deal, and reading the book needs the book permission. Ask a platform administrator for team membership."
/>
);
}
if (detail.isLoading) {
return (
<div className="flex flex-col gap-4">
<BackLink />
<Skeleton className="h-32" />
<Skeleton className="h-28" />
<Skeleton className="h-96" />
</div>
);
}
if (detail.error instanceof ApiError && detail.error.status === 404) {
return (
<Restricted
icon={<Handshake />}
title="No such engagement"
description="It has been removed, or the link was to an id that never existed."
/>
);
}
if (detail.error || !detail.data || !engagement) {
return (
<div className="flex flex-col gap-4">
<BackLink />
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<AlertTriangle />}
title="Engagement unavailable"
description={
detail.error instanceof Error
? detail.error.message
: 'The workspace could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void detail.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
</CardContent>
</Card>
</div>
);
}
const artifacts = groups.flatMap((group) => group.artifacts);
const promoted = artifacts.filter((artifact) => artifact.promotedTemplateId).length;
const finalised = artifacts.filter((artifact) => artifact.status === 'final').length;
const frameworks = artifacts.filter((artifact) => artifact.kind === 'qualification');
const scores = detail.data.scores;
/*
* The eight open stages always, plus any closed stage that actually holds
* work or is where the deal now sits. A rail that hides `closed_won` would
* lose the case study written after the deal landed which is precisely the
* artefact most worth promoting.
*/
const railStages = DEMAND_STAGES.filter(
(stage) =>
DEMAND_OPEN_STAGES.includes(stage) ||
(counts[stage] ?? 0) > 0 ||
stage === engagement.stage,
);
const visibleGroups = stageFilter
? groups.filter((group) => group.stage === stageFilter)
: groups.filter((group) => group.artifacts.length > 0);
return (
<div className="flex min-w-0 flex-col gap-5">
<BackLink />
<Header
engagement={engagement}
mayWrite={mayWrite}
onInstantiate={() => setInstantiating({ kind: null })}
/>
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4" aria-label="Engagement totals">
<Stat label="Artefacts" value={artifacts.length} hint="Not archived" />
<Stat label="Final" value={finalised} hint="Promotable" />
<Stat
label="Promoted"
value={promoted}
hint="Back into the library"
tone={promoted ? 'positive' : 'default'}
/>
<Stat
label="Qualification"
value={engagement.latestScore ? percent(engagement.latestScore.basisPoints) : '—'}
hint={engagement.latestScore ? engagement.latestScore.band : 'Never scored'}
tone={engagement.latestScore?.tone === 'danger' ? 'danger' : engagement.latestScore?.tone === 'warning' ? 'warning' : 'default'}
/>
</section>
<section aria-label="Artefacts by stage" className="flex min-w-0 flex-col gap-2">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<h2 className="font-semibold">Where the work sits</h2>
{stageFilter ? (
<Button variant="ghost" onClick={() => setStageFilter(null)}>
Show every stage
</Button>
) : null}
</div>
<StageRail
counts={counts}
stages={railStages}
countLabel="artefacts"
activeStage={stageFilter ?? engagement.stage}
onSelect={(stage) => setStageFilter((current) => (current === stage ? null : stage))}
/>
<p className="text-xs text-muted">
{stageFilter
? `Filtered to ${DEMAND_STAGE_LABELS[stageFilter]}.`
: engagement.stage
? `The deal is at ${DEMAND_STAGE_LABELS[engagement.stage]}. Select a stage to filter the artefacts below.`
: 'Select a stage to filter the artefacts below.'}
</p>
</section>
<Qualification
scores={scores}
frameworks={frameworks}
mayWrite={mayWrite}
onScore={() => setScoring(true)}
onInstantiateFramework={() => setInstantiating({ kind: 'qualification' })}
/>
{detail.data.playbook ? <Playbook playbook={detail.data.playbook} /> : null}
<section className="flex min-w-0 flex-col gap-3" aria-label="Artefacts">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<h2 className="font-semibold">Artefacts</h2>
<Button
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={() => setInstantiating({ kind: null })}
>
<Library aria-hidden />
Instantiate from library
</Button>
</div>
{visibleGroups.length === 0 || visibleGroups.every((group) => group.artifacts.length === 0) ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<FileText />}
title={stageFilter ? 'Nothing filed at this stage' : 'No artefacts yet'}
description={
stageFilter
? 'Instantiate a template that serves this stage, or clear the filter to see the rest.'
: 'Instantiate a discovery guide or a qualification framework from the library. What comes out of this engagement can be promoted back into it.'
}
action={
<Button
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={() => setInstantiating({ kind: null })}
>
Instantiate a template
</Button>
}
/>
</CardContent>
</Card>
) : (
visibleGroups.map((group) => (
<div key={group.stage} className="flex min-w-0 flex-col gap-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted">
{DEMAND_STAGE_LABELS[group.stage]}
</h3>
{group.artifacts.map((artifact) => (
<ArtifactPanel
key={artifact.id}
artifact={artifact}
mayWrite={mayWrite}
mayPublish={mayPublish}
onPromote={() => setPromoting(artifact)}
/>
))}
</div>
))
)}
</section>
{instantiating ? (
<InstantiateDialog
// Remounted per intent, so opening it to find a framework really does
// open on the frameworks rather than on whatever was filtered last.
key={instantiating.kind ?? 'any'}
engagementId={engagement.id}
defaultStage={engagement.stage}
defaultKind={instantiating.kind}
open
onOpenChange={(open) => {
if (!open) setInstantiating(null);
}}
/>
) : null}
<Sheet open={scoring} onOpenChange={setScoring}>
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-2xl">
<SheetHeader>
<SheetTitle>Score qualification</SheetTitle>
<SheetDescription>
Answered against the anchors, so two people reading the same evidence reach the same
number. Scores are appended, never edited the movement is the evidence that
qualification happened.
</SheetDescription>
</SheetHeader>
<ScoringPanel
engagementId={engagement.id}
frameworks={frameworks}
previousBasisPoints={scores[0]?.basisPoints ?? null}
onDone={() => setScoring(false)}
/>
</SheetContent>
</Sheet>
<PromoteSheet artifact={promoting} onClose={() => setPromoting(null)} />
</div>
);
}
// ------------------------------------------------------------------ sections
function Header({
engagement,
mayWrite,
onInstantiate,
}: {
engagement: EngagementView;
mayWrite: boolean;
onInstantiate: () => void;
}) {
const queryClient = useQueryClient();
const update = useMutation({
mutationFn: (status: EngagementStatus) =>
patch<{ engagement: { id: string } }>(`/api/motion/engagements/${engagement.id}`, { status }),
onSuccess: async (_data, status) => {
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success(`Engagement marked ${ENGAGEMENT_STATUS_LABELS[status].toLocaleLowerCase()}`);
},
onError: (error: Error) => toast.error(error.message),
});
return (
<header className="flex min-w-0 flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{engagement.stage ? (
<Badge tone="neutral" className="min-w-0">
<span className="truncate">{DEMAND_STAGE_LABELS[engagement.stage]}</span>
</Badge>
) : null}
<Badge tone={statusTone(engagement.status)}>
{ENGAGEMENT_STATUS_LABELS[engagement.status]}
</Badge>
<span className="nums text-xs text-muted">
Opened {shortDate(engagement.openedAt)}
{engagement.closedAt ? ` · closed ${shortDate(engagement.closedAt)}` : ''}
</span>
</div>
<h1 className="mt-1 min-w-0 break-words text-xl font-semibold tracking-tight sm:text-2xl">
{engagement.dealName ?? 'Untitled deal'}
</h1>
<p className="mt-1 min-w-0 truncate text-sm text-muted">
{engagement.accountId ? (
<Link
to={`/accounts/${engagement.accountId}`}
className="underline-offset-4 hover:text-accent-fg hover:underline"
>
{engagement.accountName ?? 'Unknown account'}
</Link>
) : (
(engagement.accountName ?? 'Unknown account')
)}
</p>
{engagement.summary ? (
<p className="mt-2 max-w-2xl min-w-0 break-words text-sm leading-6 text-muted">
{engagement.summary}
</p>
) : null}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Select
value={engagement.status}
disabled={!mayWrite || update.isPending}
onValueChange={(value) => update.mutate(value as EngagementStatus)}
>
<SelectTrigger
aria-label="Engagement status"
title={mayWrite ? undefined : WRITE_DENIED}
className="h-11 w-40 min-w-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ENGAGEMENT_STATUSES.map((status) => (
<SelectItem key={status} value={status}>
{ENGAGEMENT_STATUS_LABELS[status]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={onInstantiate}
>
<Library aria-hidden />
Instantiate
</Button>
</div>
</header>
);
}
/**
* The score, and what it did.
*
* The number on its own answers a question nobody asks twice. What a review
* actually needs is the direction: a deal that has moved 5,800 7,100 across
* three weeks is a different deal from one that has sat at 7,100 since it was
* opened, and the second one is the one where nothing has been learned.
*/
function Qualification({
scores,
frameworks,
mayWrite,
onScore,
onInstantiateFramework,
}: {
scores: ScoreView[];
frameworks: ArtifactView[];
mayWrite: boolean;
onScore: () => void;
onInstantiateFramework: () => void;
}) {
const latest = scores[0] ?? null;
const scorable = frameworks.length > 0;
return (
<Card className="flex min-w-0 flex-col gap-4 p-4 sm:p-5">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="font-semibold">Qualification</h2>
<p className="mt-0.5 text-sm text-muted">
Weighted dimensions, scored against anchors. The band is what gets acted on.
</p>
</div>
{scorable ? (
<Button
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={onScore}
>
<Gauge aria-hidden />
{latest ? 'Score again' : 'Score this deal'}
</Button>
) : (
<Button
variant="outline"
disabled={!mayWrite}
title={
mayWrite
? 'Scoring needs a qualification framework in this engagement.'
: WRITE_DENIED
}
onClick={onInstantiateFramework}
>
Add a framework
</Button>
)}
</div>
{latest ? (
<div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="nums text-3xl font-semibold leading-none">
{percent(latest.basisPoints)}
</span>
<Badge tone={latest.tone}>{latest.band}</Badge>
<span className="nums text-xs text-muted">scored {shortDate(latest.scoredAt)}</span>
</div>
) : (
<p className="text-sm text-muted">
{scorable
? 'Not scored yet. The framework is here; the first score is the baseline everything after is read against.'
: 'Not scored yet, and there is no framework in this engagement to score against. Instantiate one from the library.'}
</p>
)}
{scores.length > 1 ? (
<div className="min-w-0 border-t border-border pt-3">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Movement</h3>
<ol className="min-w-0 space-y-2">
{scores.map((score, index) => {
const previous = scores[index + 1];
const delta = previous ? score.basisPoints - previous.basisPoints : null;
return (
<li key={score.id} className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-3">
<span className="nums w-16 shrink-0 text-sm font-medium">
{percent(score.basisPoints)}
</span>
<span className="w-28 shrink-0">
<Badge tone={score.tone}>{score.band}</Badge>
</span>
<span
className={cn(
'nums w-20 shrink-0 text-xs font-medium',
delta === null ? 'text-muted' : delta > 0 ? 'text-positive' : delta < 0 ? 'text-danger' : 'text-muted',
)}
>
{delta === null
? 'baseline'
: delta === 0
? 'no change'
: `${delta > 0 ? '+' : ''}${percent(Math.abs(delta))}`}
</span>
<span className="min-w-0 flex-1 break-words text-xs text-muted">
{shortDate(score.scoredAt)}
{score.note ? ` · ${score.note}` : ''}
</span>
</li>
);
})}
</ol>
</div>
) : null}
</Card>
);
}
/** Which framework the scorer is filling in, when the engagement holds several. */
function ScoringPanel({
engagementId,
frameworks,
previousBasisPoints,
onDone,
}: {
engagementId: string;
frameworks: ArtifactView[];
previousBasisPoints: number | null;
onDone: () => void;
}) {
const [selected, setSelected] = useState(frameworks[0]?.id ?? '');
const framework = frameworks.find((artifact) => artifact.id === selected) ?? frameworks[0];
if (!framework) {
return (
<div className="mt-6 rounded-lg border border-border p-4">
<p className="font-medium">No framework in this engagement</p>
<p className="mt-1 text-sm text-muted">
Instantiate a qualification template first the dimensions and their anchors come from
it, and a score without them is a number two people would not agree on.
</p>
</div>
);
}
return (
<>
{frameworks.length > 1 ? (
<div className="mt-5 flex min-w-0 flex-col gap-1.5">
<Label htmlFor="scoring-framework">Framework</Label>
<Select value={framework.id} onValueChange={setSelected}>
<SelectTrigger id="scoring-framework" aria-label="Framework" className="h-11 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{frameworks.map((artifact) => (
<SelectItem key={artifact.id} value={artifact.id}>
{artifact.title}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
) : null}
<QualificationScorer
key={framework.id}
engagementId={engagementId}
// The template behind the artefact, not the artefact: the score records
// which framework produced it, and a from-scratch framework has none.
frameworkTemplateId={framework.templateId}
fields={framework.fields}
previousBasisPoints={previousBasisPoints}
onScored={onDone}
onCancel={onDone}
/>
</>
);
}
function Playbook({ playbook }: { playbook: TemplateSummary }) {
return (
<Card className="flex min-w-0 flex-col gap-2 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wide text-muted">Playbook</p>
<p className="mt-1 min-w-0 break-words font-medium">{playbook.title}</p>
<p className="mt-0.5 line-clamp-2 min-w-0 break-words text-sm text-muted">
{playbook.summary}
</p>
</div>
<Button asChild variant="outline" className="shrink-0">
<Link to={`/motion/library/${playbook.id}`}>
Open v{playbook.version}
<ArrowUpRight aria-hidden />
</Link>
</Button>
</Card>
);
}
/**
* One artefact, with the two controls that move it: its status, and promotion.
*
* Promotion is refused by the API for an artefact that is not final and for one
* already promoted, so the button is disabled with the reason on it rather than
* hidden a control that vanishes teaches nothing, and "why can I not promote
* this" is a question the status select right beside it answers.
*/
function ArtifactPanel({
artifact,
mayWrite,
mayPublish,
onPromote,
}: {
artifact: ArtifactView;
mayWrite: boolean;
mayPublish: boolean;
onPromote: () => void;
}) {
const queryClient = useQueryClient();
const update = useMutation({
mutationFn: (status: ArtifactStatus) =>
patch<{ artifact: { id: string } }>(`/api/motion/artifacts/${artifact.id}`, { status }),
onSuccess: async (_data, status) => {
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success(`Marked ${ARTIFACT_STATUS_LABELS[status].toLocaleLowerCase()}`);
},
onError: (error: Error) => toast.error(error.message),
});
const promotable = artifact.status === 'final' && !artifact.promotedTemplateId && mayPublish;
const promoteReason = artifact.promotedTemplateId
? 'Already in the library. Promote a later revision instead.'
: artifact.status !== 'final'
? 'Only a final artefact may be promoted — the library is what the next deployment copies.'
: PUBLISH_DENIED;
return (
<Card className="flex min-w-0 flex-col gap-3 p-4">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<MotionKindBadge kind={artifact.kind} />
<Badge tone={artifactTone(artifact.status)}>{ARTIFACT_STATUS_LABELS[artifact.status]}</Badge>
{artifact.promotedTemplateId ? (
<Badge tone="positive" title="This artefact became a library version">
<Sparkles className="size-3 shrink-0" aria-hidden />
In the library
</Badge>
) : null}
{artifact.templateId ? null : (
<Badge tone="neutral" title="Written here rather than instantiated">
From scratch
</Badge>
)}
</div>
<div className="min-w-0">
<h4 className="min-w-0 break-words font-semibold leading-snug">{artifact.title}</h4>
<p className="nums mt-0.5 text-xs text-muted">Updated {shortDate(artifact.updatedAt)}</p>
</div>
<details className="group min-w-0">
<summary className="tap flex cursor-pointer items-center gap-1.5 text-sm font-medium text-muted hover:text-fg">
<ChevronRight className="size-4 transition-transform group-open:rotate-90" aria-hidden />
Read it
</summary>
<div className="mt-3 min-w-0 border-t border-border pt-3">
{artifact.body ? <Markdown content={artifact.body} /> : null}
<FieldsView kind={artifact.kind} fields={artifact.fields} className="mt-4" />
</div>
</details>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Select
value={artifact.status}
disabled={!mayWrite || update.isPending}
onValueChange={(value) => update.mutate(value as ArtifactStatus)}
>
<SelectTrigger
aria-label={`Status of ${artifact.title}`}
title={mayWrite ? undefined : WRITE_DENIED}
className="h-11 w-36 min-w-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ARTIFACT_STATUSES.map((status) => (
<SelectItem key={status} value={status}>
{ARTIFACT_STATUS_LABELS[status]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{artifact.promotedTemplateId ? (
<Button asChild variant="outline">
<Link to={`/motion/library/${artifact.promotedTemplateId}`}>
Open the library version
<ArrowUpRight aria-hidden />
</Link>
</Button>
) : (
<Button
variant="primary"
disabled={!promotable}
title={promotable ? undefined : promoteReason}
onClick={onPromote}
>
<Sparkles aria-hidden />
Promote to library
</Button>
)}
</div>
{promotable ? null : (
<p className="min-w-0 break-words text-xs text-muted">{promoteReason}</p>
)}
</Card>
);
}
/**
* Promotion, with the two fields the library reads and nothing else.
*
* The lineage is not offered as a control. An artefact instantiated from a
* template lands as the next version of that template's lineage, which is what
* makes "this came from playbook v3, and v4 is what we learned" true; letting
* somebody retarget it here would break the only chain that carries the claim.
*/
function PromoteSheet({ artifact, onClose }: { artifact: ArtifactView | null; onClose: () => void }) {
return (
<Sheet open={Boolean(artifact)} onOpenChange={(open) => !open && onClose()}>
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-lg">
{artifact ? <PromoteForm key={artifact.id} artifact={artifact} onDone={onClose} /> : null}
</SheetContent>
</Sheet>
);
}
function PromoteForm({ artifact, onDone }: { artifact: ArtifactView; onDone: () => void }) {
const queryClient = useQueryClient();
const [title, setTitle] = useState(artifact.title);
const [summary, setSummary] = useState('');
const promote = useMutation({
mutationFn: () =>
post<{ template: { id: string; version: number } }>(
`/api/motion/artifacts/${artifact.id}/promote`,
{
title: title.trim() || artifact.title,
...(summary.trim() ? { summary: summary.trim() } : {}),
},
),
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success(`Promoted as v${result.template.version}`);
onDone();
},
onError: (error: Error) => toast.error(error.message),
});
return (
<>
<SheetHeader>
<SheetTitle>Promote to the library</SheetTitle>
<SheetDescription>
{artifact.templateId
? 'This lands as the next version of the lineage it came from, pointing back at both its predecessor and this artifact.'
: 'This starts a new lineage at version 1, pointing back at this artefact as the engagement that proved it.'}{' '}
Promoted templates are shared with everyone.
</SheetDescription>
</SheetHeader>
<form
className="mt-5 flex min-w-0 flex-col gap-4 pb-6"
onSubmit={(event) => {
event.preventDefault();
promote.mutate();
}}
>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="promote-title">Title</Label>
<Input
id="promote-title"
required
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="promote-summary">Summary</Label>
<Textarea
id="promote-summary"
className="min-h-24"
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder="What the next person needs to know before copying this."
/>
<p className="text-xs text-muted">
Left blank, the previous version&apos;s summary carries over.
</p>
</div>
{promote.isError ? <p className="text-sm text-danger">{promote.error.message}</p> : null}
<div className="flex min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" onClick={onDone}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={promote.isPending}>
Promote
</Button>
</div>
</form>
</>
);
}
// ------------------------------------------------------------------- helpers
function BackLink() {
return (
<Link
to="/motion/engagements"
className="tap -ml-1 inline-flex w-fit items-center gap-2 rounded-lg px-1 text-sm font-medium text-muted hover:text-fg"
>
<ArrowLeft className="size-4" aria-hidden />
All engagements
</Link>
);
}
function Restricted({
icon,
title,
description,
}: {
icon: ReactNode;
title: string;
description: string;
}) {
return (
<div className="flex flex-col gap-4">
<BackLink />
<Card>
<CardContent className="pt-5">
<EmptyState icon={icon} title={title} description={description} />
</CardContent>
</Card>
</div>
);
}
function statusTone(status: EngagementStatus): 'positive' | 'danger' | 'warning' | 'info' {
return status === 'won' ? 'positive' : status === 'lost' ? 'danger' : status === 'paused' ? 'warning' : 'info';
}
function artifactTone(status: ArtifactStatus): 'neutral' | 'info' | 'positive' {
return status === 'final' ? 'positive' : status === 'review' ? 'info' : 'neutral';
}
+452
View File
@@ -0,0 +1,452 @@
/**
* The Motion home, which exists to answer one question: is the motion actually
* repeating?
*
* Every panel here is a different way of asking it. The rail asks whether the
* library reaches the stages where work is actually sitting; the kind grid asks
* whether the nine kinds are covered or whether three of them are aspirational;
* the promotions panel asks whether anything has come *back* out of an
* engagement, which is the only evidence that the loop closed at all.
*
* So the absences are promoted rather than hidden. A stage with live
* engagements and no template is the most useful finding this page can carry
* it names the place where the next deployment will be improvised and a
* missing row rendered as a grey zero is a finding nobody reads. Gaps get a
* warning tone, a count and a link that lands on the library already filtered
* to the hole.
*
* Counting is the server's job: `/api/motion` returns all eight stages and all
* nine kinds in ontology order, zeros included, so nothing here zero-fills.
*/
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
AlertTriangle,
ArrowUpRight,
Handshake,
Library,
Lock,
RefreshCw,
Sparkles,
} from 'lucide-react';
import {
DEMAND_STAGE_LABELS,
MOTION_KIND_DESCRIPTIONS,
MOTION_KIND_LABELS,
toPiggyPageRoute,
type DemandStage,
type EngagementStatus,
type MotionKind,
} from '@pig/core';
import { StageRail } from '@/components/motion/StageRail';
import { MotionKindBadge } from '@/components/motion/MotionKindBadge';
import { percent } from '@/components/motion/format';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
EmptyState,
Skeleton,
Stat,
} from '@/components/ui';
import { get, relativeTime, shortDate } from '@/lib/api';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import { usePiggyContext } from '@/lib/piggy-context';
import { usePageTitle } from '@/lib/title';
// ------------------------------------------------------------------- shapes
interface StageCoverage {
stage: DemandStage;
engagements: number;
templates: number;
}
interface KindCoverage {
kind: MotionKind;
templates: number;
shared: number;
}
interface EngagementSummary {
id: string;
demandDealId: string;
dealName: string | null;
stage: DemandStage | null;
accountId: string | null;
accountName: string | null;
status: EngagementStatus;
summary: string | null;
openedAt: string;
artifactCount: number;
latestScore: { basisPoints: number; band: string } | null;
}
interface Promotion {
templateId: string;
slug: string;
title: string;
kind: MotionKind;
version: number;
promotedAt: string;
engagementId: string | null;
dealName: string | null;
}
interface MotionOverview {
totals: {
templates: number;
shared: number;
private: number;
engagements: number;
open: number;
promotions: number;
};
stages: StageCoverage[];
library: KindCoverage[];
engagements: EngagementSummary[];
promotions: Promotion[];
}
// --------------------------------------------------------------------- page
export function Motion() {
usePageTitle('Motion');
usePiggyContext({ type: 'page', route: toPiggyPageRoute('/motion'), label: 'Motion' });
const me = useIdentity();
const overview = useQuery({
queryKey: ['motion', 'overview'],
queryFn: () => get<MotionOverview>('/api/motion'),
enabled: canAny(me, 'book:read'),
});
if (!canAny(me, 'book:read')) {
return (
<div className="flex min-w-0 flex-col gap-5">
<PageHeader />
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Lock />}
title="Motion is restricted"
description="The library and its engagements sit behind book access. Ask a platform administrator for team membership."
/>
</CardContent>
</Card>
</div>
);
}
const data = overview.data;
// A stage carrying live work with nothing in the library behind it. Both
// halves matter: an empty stage with no template is a stage nobody is
// working, which is not a gap in the motion.
const gaps = (data?.stages ?? []).filter(
(stage) => stage.templates === 0 && stage.engagements > 0,
);
const uncovered = (data?.stages ?? []).filter((stage) => stage.templates === 0);
const empty = data ? data.totals.templates === 0 && data.totals.engagements === 0 : false;
return (
<div className="flex min-w-0 flex-col gap-5">
<PageHeader />
{overview.isLoading ? <Skeleton className="h-72" /> : null}
{overview.isError ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<AlertTriangle />}
title="Motion is unavailable"
description={
overview.error instanceof Error
? overview.error.message
: 'The motion summary could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void overview.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
</CardContent>
</Card>
) : null}
{empty ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Library />}
title="Nothing in the motion yet"
description="A motion starts as one template. Author the discovery guide you already run from memory, then instantiate it into the next deal."
action={
<Button variant="primary" asChild>
<Link to="/motion/library">Open the library</Link>
</Button>
}
/>
</CardContent>
</Card>
) : null}
{data && !empty ? (
<>
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Stat
label="Templates"
value={data.totals.templates}
hint={`${data.totals.shared} shared · ${data.totals.private} private`}
/>
<Stat
label="Open engagements"
value={data.totals.open}
hint={`${data.totals.engagements} recorded in total`}
/>
<Stat
label="Stages covered"
value={`${data.stages.length - uncovered.length}/${data.stages.length}`}
hint={uncovered.length ? 'The library stops short of the motion' : 'Every stage has a template'}
tone={gaps.length ? 'warning' : uncovered.length ? 'default' : 'positive'}
/>
<Stat
label="Promotions"
value={data.totals.promotions}
hint={data.totals.promotions ? 'Artefacts that came back as templates' : 'Nothing has closed the loop yet'}
/>
</section>
<Card>
<CardHeader>
<CardTitle>The motion, stage by stage</CardTitle>
<p className="text-sm text-muted">
Live engagements sit on the deal&rsquo;s stage. The second figure is what the
library has waiting there.
</p>
</CardHeader>
<CardContent className="flex min-w-0 flex-col gap-3">
<StageRail counts={countsByStage(data.stages)} coverage={templatesByStage(data.stages)} />
{gaps.length ? (
<div className="flex min-w-0 gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3 text-sm">
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden />
<div className="min-w-0">
<p className="font-medium">
{gaps.length === 1 ? 'One stage is' : `${gaps.length} stages are`} being worked
without a template
</p>
<p className="mt-1 text-muted">
Whatever gets written there this week is written from memory, and the next
deal starts over. Author one from the engagement that is already in it.
</p>
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
{gaps.map((gap) => (
<Link
key={gap.stage}
to={`/motion/library?stage=${gap.stage}`}
className="tap inline-flex min-h-11 min-w-0 items-center gap-1 rounded-lg bg-surface px-3 text-sm font-medium underline-offset-4 hover:underline"
>
<span className="truncate">{DEMAND_STAGE_LABELS[gap.stage]}</span>
<span className="nums shrink-0 text-muted">
{gap.engagements} live
</span>
</Link>
))}
</div>
</div>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="flex-row items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle>Library cover, by kind</CardTitle>
<p className="text-sm text-muted">
Nine kinds. A kind with nothing in it is a conversation that gets improvised
every time.
</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<Link to="/motion/library">
<Library aria-hidden />
Library
</Link>
</Button>
</CardHeader>
<CardContent className="grid min-w-0 gap-2 sm:grid-cols-2 xl:grid-cols-3">
{data.library.map((kind) => (
<KindCoverageCard key={kind.kind} coverage={kind} />
))}
</CardContent>
</Card>
<div className="grid min-w-0 gap-5 lg:grid-cols-2">
<Card className="min-w-0">
<CardHeader>
<CardTitle>What got easier</CardTitle>
<p className="text-sm text-muted">
Artefacts an engagement proved, promoted back into the library as a new version.
</p>
</CardHeader>
<CardContent className="flex min-w-0 flex-col gap-2">
{data.promotions.length === 0 ? (
<EmptyState
icon={<Sparkles />}
title="Nothing promoted yet"
description="Finalise an artefact in an engagement and promote it. That is the step that makes the next deployment cheaper than this one."
/>
) : (
data.promotions.map((promotion) => (
<PromotionRow key={promotion.templateId} promotion={promotion} />
))
)}
</CardContent>
</Card>
<Card className="min-w-0">
<CardHeader className="flex-row items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle>Open engagements</CardTitle>
<p className="text-sm text-muted">Where the library is being used right now.</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<Link to="/motion/engagements">
<Handshake aria-hidden />
All
</Link>
</Button>
</CardHeader>
<CardContent className="flex min-w-0 flex-col gap-2">
{data.engagements.length === 0 ? (
<EmptyState
icon={<Handshake />}
title="No open engagements"
description="Open one against a demand deal to file discovery, scoping and proposal work against its stages."
/>
) : (
data.engagements.map((engagement) => (
<EngagementRow key={engagement.id} engagement={engagement} />
))
)}
</CardContent>
</Card>
</div>
</>
) : null}
</div>
);
}
// ---------------------------------------------------------------- fragments
function PageHeader() {
return (
<header className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">
Go-to-market
</p>
<h1 className="mt-1 text-xl font-semibold tracking-tight sm:text-2xl">Motion</h1>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
The repeatable practice behind the ledger: what the library covers, where engagements
actually sit, and what came back out of one.
</p>
</header>
);
}
function KindCoverageCard({ coverage }: { coverage: KindCoverage }) {
const missing = coverage.templates === 0;
return (
<Link
to={`/motion/library?kind=${coverage.kind}`}
className="tap flex min-h-11 min-w-0 flex-col gap-2 rounded-xl border border-border bg-surface-2/60 p-3 hover:bg-surface-2"
>
<div className="flex min-w-0 items-center justify-between gap-2">
<MotionKindBadge kind={coverage.kind} />
{missing ? (
<Badge tone="warning" className="shrink-0">
No template
</Badge>
) : (
<span className="nums shrink-0 text-sm font-semibold">{coverage.templates}</span>
)}
</div>
<p className="min-w-0 line-clamp-2 text-xs leading-5 text-muted">
{missing ? MOTION_KIND_DESCRIPTIONS[coverage.kind] : `${coverage.shared} shared to the book`}
</p>
<span className="sr-only">{MOTION_KIND_LABELS[coverage.kind]}</span>
</Link>
);
}
function PromotionRow({ promotion }: { promotion: Promotion }) {
// The whole row is the target, as it is for `EngagementRow` in the card
// beside it. A one-line text link is about 20px tall on a phone, and two
// identically-shaped rows where only one can be tapped reads as a dead page
// rather than as a smaller hit area.
return (
<Link
to={`/motion/library/${promotion.templateId}`}
className="tap flex min-h-11 min-w-0 items-start justify-between gap-3 rounded-lg bg-surface-2 p-3 hover:bg-border/60"
>
<div className="min-w-0">
<p className="min-w-0 break-words font-medium">{promotion.title}</p>
<p className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
<MotionKindBadge kind={promotion.kind} iconOnly />
<span className="nums whitespace-nowrap">v{promotion.version}</span>
<span className="min-w-0 truncate">
{promotion.dealName ? `from ${promotion.dealName}` : 'from an engagement'}
</span>
</p>
</div>
<span className="nums shrink-0 text-xs text-muted" title={shortDate(promotion.promotedAt)}>
{relativeTime(promotion.promotedAt)}
</span>
</Link>
);
}
function EngagementRow({ engagement }: { engagement: EngagementSummary }) {
return (
<Link
to={`/motion/engagements/${engagement.id}`}
className="tap flex min-h-11 min-w-0 items-start justify-between gap-3 rounded-lg bg-surface-2 p-3 hover:bg-border/60"
>
<div className="min-w-0">
<p className="min-w-0 truncate font-medium">
{engagement.dealName ?? 'Unnamed deal'}
</p>
<p className="mt-1 min-w-0 truncate text-xs text-muted">
{engagement.accountName ?? 'Unknown account'} ·{' '}
{engagement.stage ? DEMAND_STAGE_LABELS[engagement.stage] : 'No stage'} ·{' '}
<span className="nums">{engagement.artifactCount}</span> artefact
{engagement.artifactCount === 1 ? '' : 's'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{engagement.latestScore ? (
<Badge tone="neutral" className="nums">
{percent(engagement.latestScore.basisPoints)}
</Badge>
) : null}
<ArrowUpRight className="size-4 text-muted" aria-hidden />
</div>
</Link>
);
}
function countsByStage(stages: StageCoverage[]): Partial<Record<DemandStage, number>> {
return Object.fromEntries(stages.map((stage) => [stage.stage, stage.engagements]));
}
function templatesByStage(stages: StageCoverage[]): Partial<Record<DemandStage, number>> {
return Object.fromEntries(stages.map((stage) => [stage.stage, stage.templates]));
}
+539
View File
@@ -0,0 +1,539 @@
/**
* Every engagement, one per demand deal.
*
* The list answers a different question from the Motion home: not "is the
* motion repeating" but "which deals are being worked with it, and what does
* qualification say about them now". So the columns are the ones that move
* stage, artefact count, latest score and the status filter lives in the URL
* so a view of everything still open can be pasted to somebody.
*
* `Unscored` is a stat rather than a column because it is the finding: an open
* engagement nobody has qualified is a deal being worked on instinct, and it is
* invisible in a table that only shows the scores that exist.
*/
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import {
AlertTriangle,
ChevronRight,
Handshake,
Lock,
Plus,
RefreshCw,
} from 'lucide-react';
import { toast } from 'sonner';
import {
DEMAND_STAGE_LABELS,
ENGAGEMENT_STATUSES,
ENGAGEMENT_STATUS_LABELS,
isEngagementStatus,
toPiggyPageRoute,
type DemandStage,
type EngagementStatus,
type MotionBandTone,
} from '@pig/core';
import { percent } from '@/components/motion/format';
import { get, post, shortDate } from '@/lib/api';
import { usePageTitle } from '@/lib/title';
import { usePiggyContext } from '@/lib/piggy-context';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import {
Badge,
Button,
Card,
CardContent,
EmptyState,
Skeleton,
Stat,
cn,
} from '@/components/ui';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea';
/** The score the list carries — the latest one, or none yet. */
interface LatestScore {
basisPoints: number;
band: string;
tone: MotionBandTone;
scoredAt: string;
}
/** `GET /api/motion/engagements` rows. `stage` is the deal's, not the engagement's. */
interface EngagementRow {
id: string;
demandDealId: string;
dealName: string | null;
stage: DemandStage | null;
accountId: string | null;
accountName: string | null;
status: EngagementStatus;
summary: string | null;
openedAt: string;
closedAt: string | null;
artifactCount: number;
latestScore: LatestScore | null;
}
interface DemandDealRow {
deal: { id: string; name: string; stage: DemandStage };
accountName: string | null;
}
const WRITE_DENIED = 'Opening an engagement needs the motion:write permission.';
export function MotionEngagements() {
usePageTitle('Engagements');
usePiggyContext({
type: 'page',
route: toPiggyPageRoute('/motion/engagements'),
label: 'Engagements',
});
const me = useIdentity();
const mayWrite = canAny(me, 'motion:write');
const [params, setParams] = useSearchParams();
const [opening, setOpening] = useState(false);
// The filter lives in the URL so a view of "everything still open" can be
// pasted to somebody, which is the whole reason anyone filters a list.
const statusParam = params.get('status');
const status = statusParam && isEngagementStatus(statusParam) ? statusParam : null;
const engagements = useQuery({
queryKey: ['motion', 'engagements', { status }],
queryFn: () =>
get<{ engagements: EngagementRow[] }>(
`/api/motion/engagements${status ? `?status=${status}` : ''}`,
),
// The permission short-circuit below is an early return, and a hook cannot
// sit behind one — so without this the read fires for exactly the people
// the page is about to tell they have no access, and react-query retries
// the 403 while they read the empty state.
enabled: canAny(me, 'book:read'),
});
const rows = engagements.data?.engagements ?? [];
const totals = useMemo(
() => ({
open: rows.filter((row) => row.status === 'open').length,
artifacts: rows.reduce((sum, row) => sum + row.artifactCount, 0),
unscored: rows.filter((row) => row.status === 'open' && !row.latestScore).length,
}),
[rows],
);
if (!canAny(me, 'book:read')) {
return (
<div className="flex min-w-0 flex-col gap-5">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Engagements</h1>
</header>
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Lock />}
title="Engagements are restricted"
description="Reading the book behind an engagement needs the book permission. Ask a platform administrator for team membership."
/>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-w-0 flex-col gap-5">
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Engagements</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">
One per demand deal: the artefacts it has produced, and what qualification says about
it now rather than when it was opened.
</p>
</div>
<Button
type="button"
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={() => setOpening(true)}
>
<Plus aria-hidden />
Open an engagement
</Button>
</header>
{rows.length ? (
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-3" aria-label="Engagement summary">
<Stat label="Open" value={totals.open} hint="Still in the motion" />
<Stat label="Artefacts" value={totals.artifacts} hint="Across the list below" />
<Stat
label="Unscored"
value={totals.unscored}
hint="Open, never qualified"
tone={totals.unscored ? 'warning' : 'default'}
/>
</section>
) : null}
<div className="flex min-w-0 flex-wrap items-center gap-2">
<StatusFilter
value={status}
onChange={(next) => {
const updated = new URLSearchParams(params);
if (next) updated.set('status', next);
else updated.delete('status');
setParams(updated, { replace: true });
}}
/>
</div>
{engagements.isLoading ? <Skeleton className="h-72" /> : null}
{engagements.isError ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<AlertTriangle />}
title="Engagements unavailable"
description={
engagements.error instanceof Error
? engagements.error.message
: 'The list could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void engagements.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
</CardContent>
</Card>
) : null}
{!engagements.isLoading && !engagements.isError && rows.length === 0 ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Handshake />}
title={status ? 'No engagement in this state' : 'No engagements yet'}
description={
status
? 'Clear the filter to see the rest of them.'
: 'An engagement binds the library to a demand deal. Open one on the deal you are working now, and the artefacts it produces can be promoted back.'
}
action={
status ? undefined : (
<Button
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={() => setOpening(true)}
>
Open the first one
</Button>
)
}
/>
</CardContent>
</Card>
) : null}
{rows.length ? (
<>
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Deal</TableHead>
<TableHead>Stage</TableHead>
<TableHead>Status</TableHead>
<TableHead>Artefacts</TableHead>
<TableHead>Qualification</TableHead>
<TableHead>Opened</TableHead>
<TableHead className="w-12">
<span className="sr-only">Open</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<div className="min-w-0">
<Link
to={`/motion/engagements/${row.id}`}
className="font-medium underline-offset-4 hover:text-accent-fg hover:underline"
>
{row.dealName ?? 'Untitled deal'}
</Link>
<p className="truncate text-xs text-muted">
{row.accountName ?? 'Unknown account'}
</p>
</div>
</TableCell>
<TableCell className="text-sm">
{row.stage ? DEMAND_STAGE_LABELS[row.stage] : '—'}
</TableCell>
<TableCell>
<StatusBadge status={row.status} />
</TableCell>
<TableCell className="nums text-sm">{row.artifactCount}</TableCell>
<TableCell>
<ScoreCell score={row.latestScore} />
</TableCell>
<TableCell className="nums text-sm">{shortDate(row.openedAt)}</TableCell>
<TableCell>
<Link
to={`/motion/engagements/${row.id}`}
className="tap inline-flex items-center justify-center text-muted"
aria-label={`Open ${row.dealName ?? 'engagement'}`}
>
<ChevronRight aria-hidden />
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
<div className="grid gap-3 md:hidden">
{rows.map((row) => (
<Link
key={row.id}
to={`/motion/engagements/${row.id}`}
className="card min-h-11 min-w-0 p-4"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<StatusBadge status={row.status} />
{row.stage ? (
<Badge tone="neutral" className="min-w-0">
<span className="truncate">{DEMAND_STAGE_LABELS[row.stage]}</span>
</Badge>
) : null}
</div>
<p className="mt-2 truncate font-medium">{row.dealName ?? 'Untitled deal'}</p>
<p className="mt-0.5 truncate text-sm text-muted">
{row.accountName ?? 'Unknown account'}
</p>
</div>
<ChevronRight className="shrink-0 text-muted" aria-hidden />
</div>
<div className="mt-3 flex min-w-0 items-end justify-between gap-3 border-t border-border pt-3 text-xs text-muted">
<p className="nums min-w-0">
{row.artifactCount} artifact{row.artifactCount === 1 ? '' : 's'} · opened{' '}
{shortDate(row.openedAt)}
</p>
<ScoreCell score={row.latestScore} />
</div>
</Link>
))}
</div>
</>
) : null}
<OpenEngagementSheet open={opening} onOpenChange={setOpening} />
</div>
);
}
/**
* Opening an engagement on a deal that already has one is a 409, so the deals
* that already have one are not offered. The server still refuses this is a
* courtesy, not the rule but an option that can only fail is a worse thing
* to ship than a shorter list.
*/
function OpenEngagementSheet({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
// Its own check rather than a prop. The trigger is already disabled without
// `motion:write`, but these two reads are what the sheet costs, and a query
// that fires for somebody who cannot use the result is a 403 in the network
// tab and a retry loop behind a dialog they were never meant to open.
const mayWrite = canAny(useIdentity(), 'motion:write');
const [demandDealId, setDemandDealId] = useState('');
const [summary, setSummary] = useState('');
const deals = useQuery({
queryKey: ['deals', 'demand'],
queryFn: () => get<{ deals: DemandDealRow[] }>('/api/deals/demand'),
enabled: open && mayWrite,
});
/*
* Its own unfiltered query rather than the rows the page is showing. The
* status filter is the trap: filtered to "Won", the page holds none of the
* open engagements, so every deal already running would be offered here and
* every one of them would answer 409. The key matches the page's own
* unfiltered query, so the two share a cache entry rather than racing.
*/
const allEngagements = useQuery({
queryKey: ['motion', 'engagements', { status: null }],
queryFn: () => get<{ engagements: EngagementRow[] }>('/api/motion/engagements'),
enabled: open && mayWrite,
});
const taken = new Set((allEngagements.data?.engagements ?? []).map((row) => row.demandDealId));
const available = (deals.data?.deals ?? []).filter((row) => !taken.has(row.deal.id));
const create = useMutation({
mutationFn: () =>
post<{ engagement: { id: string } }>('/api/motion/engagements', {
demandDealId,
summary: summary.trim() ? summary.trim() : null,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success('Engagement opened');
setDemandDealId('');
setSummary('');
onOpenChange(false);
},
onError: (error: Error) => toast.error(error.message),
});
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-lg">
<SheetHeader>
<SheetTitle>Open an engagement</SheetTitle>
<SheetDescription>
An engagement hangs off a demand deal rather than replacing it the deal keeps its
stage, and the engagement collects what the motion produces against it.
</SheetDescription>
</SheetHeader>
<form
className="mt-5 flex min-w-0 flex-col gap-4 pb-6"
onSubmit={(event) => {
event.preventDefault();
create.mutate();
}}
>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="engagement-deal">Demand deal</Label>
<Select value={demandDealId || undefined} onValueChange={setDemandDealId}>
<SelectTrigger id="engagement-deal" aria-label="Demand deal" className="h-11 min-w-0">
<SelectValue placeholder={deals.isLoading || allEngagements.isLoading ? 'Loading deals…' : 'Choose a deal'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{available.map((row) => (
<SelectItem key={row.deal.id} value={row.deal.id}>
{row.deal.name} · {row.accountName ?? 'Unknown account'}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{!deals.isLoading && !allEngagements.isLoading && available.length === 0 ? (
<p className="text-xs text-muted">
Every demand deal already has an engagement. Open one from the deal in Pipeline
once there is a new deal to run.
</p>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="engagement-summary">What this engagement is for</Label>
<Textarea
id="engagement-summary"
className="min-h-24"
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder="The deployment being scoped, in one or two sentences."
/>
</div>
{create.isError ? <p className="text-sm text-danger">{create.error.message}</p> : null}
<div className="flex min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!demandDealId || create.isPending}>
Open engagement
</Button>
</div>
</form>
</SheetContent>
</Sheet>
);
}
// ------------------------------------------------------------------ helpers
function StatusFilter({
value,
onChange,
}: {
value: EngagementStatus | null;
onChange: (value: EngagementStatus | null) => void;
}) {
return (
<div role="tablist" aria-label="Filter by status" className="scroll-x -mx-1 flex gap-1 px-1 pb-1">
{([null, ...ENGAGEMENT_STATUSES] as const).map((option) => (
<button
key={option ?? 'all'}
type="button"
role="tab"
aria-selected={value === option}
onClick={() => onChange(option)}
className={cn(
'tap min-h-11 shrink-0 rounded-lg px-4 text-sm font-medium transition-colors',
value === option
? 'bg-surface text-fg shadow-sm ring-1 ring-border'
: 'text-muted hover:bg-surface-2',
)}
>
{option ? ENGAGEMENT_STATUS_LABELS[option] : 'All'}
</button>
))}
</div>
);
}
function StatusBadge({ status }: { status: EngagementStatus }) {
const tone =
status === 'won' ? 'positive' : status === 'lost' ? 'danger' : status === 'paused' ? 'warning' : 'info';
return <Badge tone={tone}>{ENGAGEMENT_STATUS_LABELS[status]}</Badge>;
}
/**
* The band leads and the figure follows it.
*
* "Qualified" is what a seller acts on; 6,200 basis points is what an analyst
* checks afterwards. Showing the number alone invites the reading that 62 is
* nearly 70 and therefore nearly good, when the band boundary at 7500 is the
* only place anything changes.
*/
function ScoreCell({ score }: { score: LatestScore | null }) {
if (!score) return <span className="text-sm text-muted">Not scored</span>;
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-1.5">
<Badge tone={score.tone}>{score.band}</Badge>
<span className="nums whitespace-nowrap text-xs text-muted" title={`${score.basisPoints} basis points`}>
{percent(score.basisPoints)}
</span>
</span>
);
}
+420
View File
@@ -0,0 +1,420 @@
/**
* The library: nine kinds, filtered four ways, in a URL somebody can send.
*
* The filters live in the query string rather than in component state, which is
* a first for this app. It is not tidiness "the proposal blocks we have for
* procurement" is a thing one person tells another, and until now the only way
* to share a view was to describe the clicks. `useSearchParams` makes the view
* the address.
*
* Two rules keep that URL readable, and both are load-bearing:
* defaults are never written (an unset filter is an absent parameter, not
* `?kind=all`), and every write is `replace`, so dragging across the stage rail
* leaves one history entry rather than eight for the back button to walk.
*
* An unrecognised value is treated as absent. The API answers `400
* invalid_request` on an unknown enum, and a hand-edited URL — or one that
* outlived a kind the ontology dropped should degrade to the unfiltered
* library, not to an error page.
*
* The rail's figures come from `/api/motion` rather than from the rows on
* screen: a rail computed from the filtered result empties as you use it, which
* makes the one control that is meant to navigate the motion useless the moment
* anything is selected.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { AlertTriangle, Layers, Library, Lock, RefreshCw, Search } from 'lucide-react';
import {
DEMAND_STAGES,
MOTION_KINDS,
MOTION_KIND_LABELS,
MOTION_VISIBILITIES,
MOTION_VISIBILITY_LABELS,
isMotionKind,
isMotionVisibility,
toPiggyPageRoute,
type DemandStage,
type MotionKind,
type MotionVisibility,
} from '@pig/core';
import { StageRail } from '@/components/motion/StageRail';
import { TemplateCard } from '@/components/motion/TemplateCard';
import type { MotionTemplateView } from '@/components/motion/model';
import { Button, Card, CardContent, EmptyState, Input, Skeleton } from '@/components/ui';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { get } from '@/lib/api';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import { usePiggyContext } from '@/lib/piggy-context';
import { usePageTitle } from '@/lib/title';
// ------------------------------------------------------------------- shapes
/**
* What the list endpoint serialises: the row without `body` or `fields`.
*
* Two hundred playbooks is a megabyte of markdown nobody on this page reads, so
* the API sends the summary and the detail endpoint sends the document.
*/
type TemplateSummary = Omit<MotionTemplateView, 'body' | 'fields'>;
interface TemplateList {
templates: TemplateSummary[];
/** The scan hit its bound — the library is wider than this answer. */
truncated: boolean;
}
interface StageCoverage {
stage: DemandStage;
engagements: number;
templates: number;
}
interface MotionOverview {
stages: StageCoverage[];
}
interface Filters {
kind: MotionKind | null;
stage: DemandStage | null;
visibility: MotionVisibility | null;
q: string;
all: boolean;
}
const STAGE_PARAM = 'stage';
/** Long enough that a typed word is one request, short enough to feel live. */
const SEARCH_SETTLE_MS = 250;
// --------------------------------------------------------------------- page
export function MotionLibrary() {
usePageTitle('Library');
usePiggyContext({
type: 'page',
route: toPiggyPageRoute('/motion/library'),
label: 'Motion library',
});
const me = useIdentity();
const [params, setParams] = useSearchParams();
const filters = readFilters(params);
// The input is local and the URL follows it, rather than the other way
// round: bound directly to the query string, every keystroke is a request
// against a 400-row scan and a URL that is only copyable between words.
const [text, setText] = useState(filters.q);
const pushed = useRef(filters.q);
useEffect(() => {
if (text === filters.q) return;
const timer = setTimeout(() => {
pushed.current = text;
setParam(setParams, 'q', text);
}, SEARCH_SETTLE_MS);
return () => clearTimeout(timer);
}, [filters.q, setParams, text]);
useEffect(() => {
// The URL moved and it was not us — a pasted link, or a back navigation
// out of some other filter. Adopt it. Without this the pending input wins
// the next tick and pushes itself straight back over the address, which
// reads as a back button that does not work.
if (filters.q === pushed.current) return;
pushed.current = filters.q;
setText(filters.q);
}, [filters.q]);
const templates = useQuery({
queryKey: ['motion', 'templates', filters],
queryFn: () => get<TemplateList>(`/api/motion/templates${queryString(filters)}`),
enabled: canAny(me, 'book:read'),
});
// Shared with the Motion home, and deliberately unfiltered — see the header.
const overview = useQuery({
queryKey: ['motion', 'overview'],
queryFn: () => get<MotionOverview>('/api/motion'),
enabled: canAny(me, 'book:read'),
});
const railCounts = useMemo(
() =>
Object.fromEntries(
(overview.data?.stages ?? []).map((stage) => [stage.stage, stage.templates]),
) as Partial<Record<DemandStage, number>>,
[overview.data],
);
const filtered = Boolean(
filters.kind || filters.stage || filters.visibility || filters.q || filters.all,
);
if (!canAny(me, 'book:read')) {
return (
<div className="flex min-w-0 flex-col gap-5">
<PageHeader />
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Lock />}
title="The library is restricted"
description="Motion templates sit behind book access. Ask a platform administrator for team membership."
/>
</CardContent>
</Card>
</div>
);
}
const rows = templates.data?.templates ?? [];
return (
<div className="flex min-w-0 flex-col gap-5">
<PageHeader />
<StageRail
counts={railCounts}
countLabel="templates"
activeStage={filters.stage}
onSelect={(stage) =>
setParam(setParams, STAGE_PARAM, stage === filters.stage ? '' : stage)
}
/>
<div className="grid min-w-0 gap-3 rounded-xl border border-border bg-surface-2/60 p-3 sm:grid-cols-[minmax(0,1fr)_170px_170px]">
<div className="relative min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden />
<Input
aria-label="Search the library"
className="pl-9"
placeholder="Search title, summary or slug"
value={text}
onChange={(event) => setText(event.target.value)}
/>
</div>
<EnumSelect
aria-label="Filter by kind"
value={filters.kind ?? 'all'}
onValueChange={(value) => setParam(setParams, 'kind', value === 'all' ? '' : value)}
>
<SelectItem value="all">All kinds</SelectItem>
{MOTION_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{MOTION_KIND_LABELS[kind]}
</SelectItem>
))}
</EnumSelect>
<EnumSelect
aria-label="Filter by visibility"
value={filters.visibility ?? 'all'}
onValueChange={(value) => setParam(setParams, 'visibility', value === 'all' ? '' : value)}
>
<SelectItem value="all">Shared and private</SelectItem>
{MOTION_VISIBILITIES.map((visibility) => (
<SelectItem key={visibility} value={visibility}>
{MOTION_VISIBILITY_LABELS[visibility]}
</SelectItem>
))}
</EnumSelect>
</div>
<div className="flex min-h-11 min-w-0 flex-wrap items-center justify-between gap-2">
<p className="min-w-0 text-sm text-muted">
<strong className="nums text-fg">{rows.length}</strong>{' '}
{filters.all ? 'versions' : 'templates'} shown
{templates.data?.truncated ? ' · more exist than fit in one answer' : ''}
</p>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{/* Lineages collapse to their newest version by default. The whole
history is what you want when judging whether a template is
settled or still being rewritten every fortnight. */}
<Button
variant={filters.all ? 'secondary' : 'ghost'}
size="sm"
aria-pressed={filters.all}
onClick={() => setParam(setParams, 'all', filters.all ? '' : '1')}
>
<Layers aria-hidden />
{filters.all ? 'Newest versions' : 'Every version'}
</Button>
{filtered ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setText('');
setParams(new URLSearchParams(), { replace: true });
}}
>
Clear filters
</Button>
) : null}
</div>
</div>
{templates.isLoading ? <Skeleton className="h-64" /> : null}
{templates.isError ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<AlertTriangle />}
title="Library unavailable"
description={
templates.error instanceof Error
? templates.error.message
: 'The library could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void templates.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
</CardContent>
</Card>
) : null}
{!templates.isLoading && !templates.isError && rows.length === 0 ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Library />}
title={filtered ? 'No templates match' : 'The library is empty'}
description={
filtered
? 'Clear a filter to see the rest of the library. A private template belonging to somebody else will never appear here.'
: 'Nine kinds and nothing in them yet. Author the discovery guide you already run from memory, and instantiate it into the next deal.'
}
action={
filtered ? (
<Button
variant="outline"
onClick={() => {
setText('');
setParams(new URLSearchParams(), { replace: true });
}}
>
Clear filters
</Button>
) : undefined
}
/>
</CardContent>
</Card>
) : null}
{rows.length ? (
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{rows.map((template) => (
<TemplateCard
key={template.id}
// The card reads neither `body` nor `fields`; its prop type asks
// for them only because one interface describes both endpoints.
// Asserting beats inventing an empty body, which would be
// indistinguishable from a template somebody saved blank.
template={template as MotionTemplateView}
/>
))}
</div>
) : null}
</div>
);
}
// ---------------------------------------------------------------- fragments
function PageHeader() {
return (
<header className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">Motion</p>
<h1 className="mt-1 text-xl font-semibold tracking-tight sm:text-2xl">Library</h1>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
Discovery guides, qualification frameworks, proposal blocks and playbooks the parts of a
deployment that should not be rewritten each time. This view is linkable: send the address,
not the clicks.
</p>
</header>
);
}
type EnumSelectProps = React.ComponentProps<typeof Select> & { 'aria-label': string };
function EnumSelect({ children, 'aria-label': ariaLabel, ...props }: EnumSelectProps) {
return (
<Select {...props}>
<SelectTrigger aria-label={ariaLabel} className="h-11 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>{children}</SelectGroup>
</SelectContent>
</Select>
);
}
// ------------------------------------------------------------------ filters
/**
* Local, because `@pig/core` ships guards for the motion enums but not for the
* stage list. Declared here rather than added there: this is the only caller,
* and widening the ontology's surface for one URL parser is not the trade.
*/
function isDemandStage(value: string): value is DemandStage {
return (DEMAND_STAGES as readonly string[]).includes(value);
}
/** An unrecognised value reads as absent — see the header. */
function readFilters(params: URLSearchParams): Filters {
const kind = params.get('kind');
const stage = params.get(STAGE_PARAM);
const visibility = params.get('visibility');
return {
kind: kind && isMotionKind(kind) ? kind : null,
stage: stage && isDemandStage(stage) ? stage : null,
visibility: visibility && isMotionVisibility(visibility) ? visibility : null,
q: params.get('q')?.trim() ?? '',
all: params.get('all') === '1',
};
}
/**
* Set one parameter, dropping it when it falls back to the default.
*
* Functional form, because two filters can settle within one tick the search
* debounce firing while a stage is being pressed and reading `params` from
* the closure would make the second write discard the first.
*/
function setParam(
setParams: ReturnType<typeof useSearchParams>[1],
key: string,
value: string,
): void {
setParams(
(current) => {
const next = new URLSearchParams(current);
if (value) next.set(key, value);
else next.delete(key);
return next;
},
{ replace: true },
);
}
function queryString(filters: Filters): string {
const query = new URLSearchParams();
if (filters.kind) query.set('kind', filters.kind);
if (filters.stage) query.set(STAGE_PARAM, filters.stage);
if (filters.visibility) query.set('visibility', filters.visibility);
if (filters.q) query.set('q', filters.q);
if (filters.all) query.set('all', '1');
const rendered = query.toString();
return rendered ? `?${rendered}` : '';
}
+729
View File
@@ -0,0 +1,729 @@
/**
* One template, with its provenance stated rather than implied.
*
* The document is the obvious half of this page and the least interesting one.
* The argument for the whole feature is the chain: this is v3, it supersedes
* v2, and it exists because an artefact in a real engagement was finalised and
* promoted. A library that shows only the newest body is a folder of files
* the lineage is what makes "every deployment makes the next one easier" a
* mechanism somebody can audit.
*
* The one rule this page has to teach is §7a: a template that has been
* instantiated is never edited in place, because a live engagement must not
* have the thing it was copied from change underneath it. So a used template
* does not show a dead Save button it says what the rule is and offers the
* move that is actually available, a new version in the same lineage.
*
* `canEdit` and `canPublish` are the server's judgement, read off the detail
* response. Re-deriving them here from `usageCount` and ownership would put a
* second copy of the rule in TSX, and the two copies would drift.
*/
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
AlertTriangle,
ArrowLeft,
GitBranch,
History,
Lock,
Pencil,
RefreshCw,
Send,
Sparkles,
Users,
} from 'lucide-react';
import { toast } from 'sonner';
import {
DEMAND_STAGES,
DEMAND_STAGE_LABELS,
MOTION_KIND_DESCRIPTIONS,
MOTION_VISIBILITY_LABELS,
toPiggyPageRoute,
type DemandStage,
type MotionKind,
type MotionVisibility,
} from '@pig/core';
import { FieldsView } from '@/components/motion/FieldsView';
import { Markdown } from '@/components/motion/Markdown';
import { MotionKindBadge } from '@/components/motion/MotionKindBadge';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
EmptyState,
Input,
Skeleton,
cn,
} from '@/components/ui';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { ApiError, get, patch, post, relativeTime, shortDate } from '@/lib/api';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import { usePiggyContext } from '@/lib/piggy-context';
import { usePageTitle } from '@/lib/title';
// ------------------------------------------------------------------- shapes
interface TemplateSummary {
id: string;
kind: MotionKind;
slug: string;
version: number;
title: string;
summary: string;
stage: DemandStage;
visibility: MotionVisibility;
ownerUserId: string | null;
supersedesId: string | null;
originArtifactId: string | null;
isSystem: boolean;
usageCount: number;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
}
interface TemplateDetail extends TemplateSummary {
body: string;
fields: Record<string, unknown> | null;
}
interface TemplateDetailResponse {
template: TemplateDetail;
/** Every version of this slug the reader may see, oldest first. */
lineage: TemplateSummary[];
/** Owner or admin, and never instantiated. The §7a rule, already applied. */
canEdit: boolean;
canPublish: boolean;
}
interface EngagementOption {
id: string;
dealName: string | null;
accountName: string | null;
stage: DemandStage | null;
}
interface Promotion {
templateId: string;
engagementId: string | null;
dealName: string | null;
}
interface MotionOverview {
promotions: Promotion[];
}
interface EditState {
title: string;
summary: string;
body: string;
stage: DemandStage;
}
// --------------------------------------------------------------------- page
export function MotionTemplate() {
const { id = '' } = useParams<{ id: string }>();
const me = useIdentity();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [editing, setEditing] = useState(false);
const [engagementId, setEngagementId] = useState('');
const detail = useQuery({
queryKey: ['motion', 'templates', id, 'detail'],
queryFn: () => get<TemplateDetailResponse>(`/api/motion/templates/${id}`),
enabled: Boolean(id) && canAny(me, 'book:read'),
retry: false,
});
const engagements = useQuery({
queryKey: ['motion', 'engagements', 'open'],
queryFn: () => get<{ engagements: EngagementOption[] }>('/api/motion/engagements?status=open'),
enabled: Boolean(id) && canAny(me, 'motion:write'),
});
/*
* Read only for the provenance line. The detail response carries
* `originArtifactId` but not the engagement behind it, and the overview's
* promotion list is the one place that join already exists so a recently
* promoted template can name the deal it came out of, and an older one says
* plainly that it came from an engagement without inventing which.
*/
const overview = useQuery({
queryKey: ['motion', 'overview'],
queryFn: () => get<MotionOverview>('/api/motion'),
enabled: Boolean(detail.data?.template.originArtifactId),
});
const template = detail.data?.template;
usePageTitle(template?.title ?? 'Template');
usePiggyContext({
type: 'page',
route: toPiggyPageRoute('/motion/library'),
label: template ? template.title : 'Motion library',
});
const invalidate = async (): Promise<void> => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['motion', 'templates'] }),
queryClient.invalidateQueries({ queryKey: ['motion', 'overview'] }),
]);
};
const save = useMutation({
mutationFn: (input: EditState) => patch<{ template: TemplateDetail }>(`/api/motion/templates/${id}`, input),
onSuccess: async () => {
setEditing(false);
await invalidate();
toast.success('Template saved');
},
onError: (error: Error) => toast.error(error.message),
});
const fork = useMutation({
mutationFn: () => post<{ template: TemplateDetail }>(`/api/motion/templates/${id}/versions`, {}),
onSuccess: async ({ template: created }) => {
await invalidate();
toast.success(`Version ${created.version} drafted, private to you`);
navigate(`/motion/library/${created.id}`);
},
onError: (error: Error) => toast.error(error.message),
});
const publish = useMutation({
mutationFn: () => post<{ template: TemplateDetail }>(`/api/motion/templates/${id}/publish`, {}),
onSuccess: async () => {
await invalidate();
toast.success('Published to the book');
},
onError: (error: Error) => toast.error(error.message),
});
const instantiate = useMutation({
mutationFn: (target: string) =>
post<{ artifact: { id: string; engagementId: string } }>(
`/api/motion/engagements/${target}/artifacts`,
{ templateId: id },
),
onSuccess: async ({ artifact }) => {
await invalidate();
await queryClient.invalidateQueries({ queryKey: ['motion', 'engagements'] });
toast.success('Instantiated into the engagement');
navigate(`/motion/engagements/${artifact.engagementId}`);
},
onError: (error: Error) => toast.error(error.message),
});
if (!canAny(me, 'book:read')) {
return (
<Restricted
icon={<Lock />}
title="This template is restricted"
description="Motion templates sit behind book access. Ask a platform administrator for team membership."
/>
);
}
if (detail.isLoading) {
return (
<div className="flex min-w-0 flex-col gap-4">
<BackLink />
<Skeleton className="h-96" />
</div>
);
}
if (detail.error instanceof ApiError && detail.error.status === 404) {
return (
<Restricted
icon={<Lock />}
title="No such template"
// The same answer covers both cases on purpose, and saying so is
// kinder than a bare "not found" that reads as a broken link.
description="It has been archived, the link was to an id that never existed, or it is private to somebody else."
/>
);
}
if (detail.error || !detail.data || !template) {
return (
<div className="flex min-w-0 flex-col gap-4">
<BackLink />
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<AlertTriangle />}
title="Template unavailable"
description={
detail.error instanceof Error ? detail.error.message : 'The template could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void detail.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
</CardContent>
</Card>
</div>
);
}
const { lineage, canEdit, canPublish } = detail.data;
const mayWrite = canAny(me, 'motion:write');
const supersedes = lineage.find((version) => version.id === template.supersedesId) ?? null;
const promotion = overview.data?.promotions.find((row) => row.templateId === template.id) ?? null;
const openEngagements = engagements.data?.engagements ?? [];
return (
<div className="flex min-w-0 flex-col gap-5">
<BackLink />
<header className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<MotionKindBadge kind={template.kind} />
<Badge tone="neutral">{DEMAND_STAGE_LABELS[template.stage]}</Badge>
<Badge tone={template.visibility === 'private' ? 'warning' : 'neutral'}>
{MOTION_VISIBILITY_LABELS[template.visibility]}
</Badge>
{template.isSystem ? (
<Badge tone="neutral" title="Shipped with PIG rather than authored here">
<Sparkles className="size-3 shrink-0" aria-hidden />
Starter
</Badge>
) : null}
</div>
<h1 className="mt-2 min-w-0 break-words text-xl font-semibold tracking-tight sm:text-2xl">
{template.title}
</h1>
<p className="mt-1 max-w-2xl min-w-0 break-words text-sm leading-6 text-muted">
{template.summary}
</p>
<p className="mt-2 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
<span className="nums whitespace-nowrap">Version {template.version}</span>
<span className="nums inline-flex items-center gap-1 whitespace-nowrap">
<Users className="size-3 shrink-0" aria-hidden />
{template.usageCount === 1 ? 'Used once' : `Used ${template.usageCount} times`}
</span>
<span className="min-w-0 truncate">{MOTION_KIND_DESCRIPTIONS[template.kind]}</span>
</p>
</header>
<Provenance
template={template}
supersedes={supersedes}
engagementId={promotion?.engagementId ?? null}
dealName={promotion?.dealName ?? null}
/>
{/*
The §7a rule, said in full where the edit button would otherwise be.
A disabled control with no explanation teaches the reader that the app
is broken; this one names the reason and the move that replaces it.
*/}
{template.usageCount > 0 ? (
<div className="flex min-w-0 gap-3 rounded-xl border border-info/30 bg-info/10 p-3 text-sm">
<History className="size-4 shrink-0 text-info" aria-hidden />
<div className="min-w-0">
<p className="font-medium">This template is closed to edits</p>
<p className="mt-1 text-muted">
{template.usageCount === 1 ? 'An engagement has' : `${template.usageCount} engagements have`}{' '}
already instantiated it, and their artefacts record that they came from v
{template.version}. Changing it now would rewrite their provenance. Draft a new
version instead the lineage keeps both.
</p>
</div>
</div>
) : null}
<div className="grid min-w-0 gap-2 sm:flex sm:flex-wrap">
{canEdit ? (
<Button
variant={editing ? 'secondary' : 'outline'}
aria-pressed={editing}
onClick={() => setEditing((current) => !current)}
>
<Pencil aria-hidden />
{editing ? 'Stop editing' : 'Edit draft'}
</Button>
) : null}
<Button
variant={template.usageCount > 0 ? 'primary' : 'outline'}
disabled={!mayWrite || fork.isPending}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={() => fork.mutate()}
>
<GitBranch aria-hidden />
{template.usageCount > 0 ? 'New version' : 'Fork to a private draft'}
</Button>
{canPublish ? (
<Button variant="primary" disabled={publish.isPending} onClick={() => publish.mutate()}>
<Send aria-hidden />
Publish to the book
</Button>
) : null}
</div>
{editing && canEdit ? (
<TemplateEditor
initial={{
title: template.title,
summary: template.summary,
body: template.body,
stage: template.stage,
}}
pending={save.isPending}
onCancel={() => setEditing(false)}
onSave={(input) => save.mutate(input)}
/>
) : null}
<Card>
<CardHeader className="flex-row items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle>Use this template</CardTitle>
<p className="text-sm text-muted">
Instantiating copies the body and fields into the engagement as a draft artefact, and
records which version it came from.
</p>
</div>
</CardHeader>
<CardContent className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
{!mayWrite ? (
<p className="text-sm text-muted">{WRITE_DENIED}</p>
) : openEngagements.length === 0 ? (
<p className="text-sm text-muted">
No open engagement to instantiate into.{' '}
<Link to="/motion/engagements" className="underline underline-offset-4">
Open one against a demand deal
</Link>{' '}
first.
</p>
) : (
<>
<Select value={engagementId || undefined} onValueChange={setEngagementId}>
<SelectTrigger aria-label="Choose an engagement" className="h-11 min-w-0">
<SelectValue placeholder="Choose an engagement" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{openEngagements.map((engagement) => (
<SelectItem key={engagement.id} value={engagement.id}>
{engagement.dealName ?? 'Unnamed deal'}
{engagement.accountName ? ` · ${engagement.accountName}` : ''}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
variant="primary"
disabled={!engagementId || instantiate.isPending}
onClick={() => instantiate.mutate(engagementId)}
>
Instantiate
</Button>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>The document</CardTitle>
</CardHeader>
<CardContent>
{template.body.trim() ? (
<Markdown content={template.body} />
) : (
<p className="text-sm text-muted">This version has no body.</p>
)}
</CardContent>
</Card>
<FieldsView kind={template.kind} fields={template.fields} />
<Lineage lineage={lineage} currentId={template.id} slug={template.slug} />
</div>
);
}
// ---------------------------------------------------------------- fragments
const WRITE_DENIED = 'Authoring in the library needs the motion:write permission.';
function BackLink() {
return (
<Link
to="/motion/library"
className="tap inline-flex min-h-11 w-fit items-center gap-2 text-sm text-muted hover:text-fg"
>
<ArrowLeft className="size-4" aria-hidden />
Back to the library
</Link>
);
}
function Restricted({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<div className="flex min-w-0 flex-col gap-4">
<BackLink />
<Card>
<CardContent className="pt-5">
<EmptyState icon={icon} title={title} description={description} />
</CardContent>
</Card>
</div>
);
}
/**
* Where this version came from, in one sentence a reader can act on.
*
* Three facts, and each is absent for a legitimate reason: v1 supersedes
* nothing, an authored template was never promoted, and a promotion older than
* the overview's window is known to exist without its engagement being named.
*/
function Provenance({
template,
supersedes,
engagementId,
dealName,
}: {
template: TemplateSummary;
supersedes: TemplateSummary | null;
engagementId: string | null;
dealName: string | null;
}) {
const promoted = Boolean(template.originArtifactId);
if (!supersedes && !promoted) return null;
return (
<div className="flex min-w-0 gap-3 rounded-xl border border-border bg-surface-2/60 p-3 text-sm">
<GitBranch className="size-4 shrink-0 text-muted" aria-hidden />
<p className="min-w-0 break-words">
<span className="nums font-medium">Version {template.version}</span>
{supersedes ? (
<>
, superseding{' '}
<Link
to={`/motion/library/${supersedes.id}`}
className="underline underline-offset-4 hover:text-accent-fg"
>
v{supersedes.version}
</Link>
</>
) : null}
{promoted ? (
<>
, promoted from an artefact
{engagementId ? (
<>
{' '}on{' '}
<Link
to={`/motion/engagements/${engagementId}`}
className="underline underline-offset-4 hover:text-accent-fg"
>
{dealName ?? 'its engagement'}
</Link>
</>
) : (
' proved in an engagement'
)}
</>
) : null}
. <span className="text-muted">Created {shortDate(template.createdAt)}.</span>
</p>
</div>
);
}
function Lineage({
lineage,
currentId,
slug,
}: {
lineage: TemplateSummary[];
currentId: string;
slug: string;
}) {
return (
<Card>
<CardHeader>
<CardTitle>Version history</CardTitle>
<p className="text-sm text-muted">
Every version of <span className="font-mono text-xs">{slug}</span> you can see, oldest
first. Versions private to somebody else are not listed.
</p>
</CardHeader>
<CardContent className="flex min-w-0 flex-col gap-2">
{lineage.map((version) => (
<LineageRow
key={version.id}
version={version}
current={version.id === currentId}
/>
))}
</CardContent>
</Card>
);
}
/**
* One version in the history.
*
* The whole row navigates rather than the title alone, which was a single line
* of text and about 20px of target on a phone. The version already open is a
* plain div instead: a link to where you already are is a tap that does
* nothing, which is worse than no target at all.
*/
function LineageRow({ version, current }: { version: TemplateSummary; current: boolean }) {
const body = (
<>
<div className="min-w-0">
<p className="min-w-0 break-words font-medium">{version.title}</p>
<p className="mt-1 min-w-0 truncate text-xs text-muted">
{MOTION_VISIBILITY_LABELS[version.visibility]} ·{' '}
<span className="nums">{version.usageCount}</span> use
{version.usageCount === 1 ? '' : 's'} · {relativeTime(version.updatedAt)}
</p>
</div>
<Badge tone={current ? 'accent' : 'neutral'} className="nums shrink-0">
v{version.version}
</Badge>
</>
);
const shape = 'flex min-h-11 min-w-0 items-start justify-between gap-3 rounded-lg p-3';
if (current) {
return <div className={cn(shape, 'bg-surface-2 ring-1 ring-border')}>{body}</div>;
}
return (
<Link
to={`/motion/library/${version.id}`}
className={cn(shape, 'tap bg-surface-2/60 hover:bg-surface-2')}
>
{body}
</Link>
);
}
/**
* The draft editor, shown only while `canEdit` holds.
*
* No `kind` and no `visibility` field, matching the endpoint: a lineage that
* changes kind halfway is a different template wearing the same slug, and
* visibility moves through Publish, where the capability check lives.
*/
function TemplateEditor({
initial,
pending,
onCancel,
onSave,
}: {
initial: EditState;
pending: boolean;
onCancel(): void;
onSave(input: EditState): void;
}) {
const [form, setForm] = useState(initial);
const set = <Key extends keyof EditState>(key: Key, value: EditState[Key]) =>
setForm((current) => ({ ...current, [key]: value }));
return (
<Card>
<CardContent className="pt-5">
<form
className="flex min-w-0 flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
onSave(form);
}}
>
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="template-title">Title</Label>
<Input
id="template-title"
required
value={form.title}
onChange={(event) => set('title', event.target.value)}
/>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="template-stage">Stage it serves</Label>
<Select value={form.stage} onValueChange={(value) => set('stage', value as DemandStage)}>
<SelectTrigger id="template-stage" className="h-11 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{DEMAND_STAGES.map((stage) => (
<SelectItem key={stage} value={stage}>
{DEMAND_STAGE_LABELS[stage]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="template-summary">Summary</Label>
<Textarea
id="template-summary"
required
rows={2}
value={form.summary}
onChange={(event) => set('summary', event.target.value)}
/>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="template-body">Body</Label>
<Textarea
id="template-body"
rows={16}
className="font-mono text-xs"
value={form.body}
onChange={(event) => set('body', event.target.value)}
/>
<p className="text-xs text-muted">
Markdown, with GFM tables and task lists. Structured fields are edited through the
API for now.
</p>
</div>
<div className="flex min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={pending}>
Save draft
</Button>
</div>
</form>
</CardContent>
</Card>
);
}