Capacity
@@ -100,12 +100,16 @@ export function Capacity() {
}
function Availability({ writable, onAllocate }: { writable: boolean; onAllocate(id: string): void }) {
- const { data, isLoading } = useQuery({
+ const { data, isLoading, error, refetch } = useQuery({
queryKey: ['availability'],
queryFn: () => get('/api/capacity/availability'),
});
- if (isLoading) return ;
+ if (isLoading) return {Array.from({ length: 5 }).map((_, index) => )}
;
+
+ if (error) {
+ return } title="Could not load capacity" description={error instanceof Error ? error.message : 'Availability is unavailable.'} />;
+ }
if (!data || data.length === 0) {
return (
@@ -146,7 +150,7 @@ function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; wri
-
{row.name}
+
{row.name}
{row.securityTier === 'secure_cloud' ? 'Secure' : 'Community'}
@@ -160,7 +164,7 @@ function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; wri
{/* Sold and held are shown as separate segments, because a full-looking
bar made mostly of unconverted holds is a lie a seller would act on. */}
-
+
{percent(soldPct)} sold
{row.heldGpuHours > 0 ? {percent(heldPct)} held : null}
- {compactNumber(row.availableGpuHours)} hrs free
+ {compactNumber(row.availableGpuHours)} hrs sellable
- Cost
- - {money(row.costPerGpuHourCents)}/hr
+ - {money(row.costPerGpuHourCents)}/GPU-hr
- Break even
-
{row.breakEvenPriceCents == null
? 'Fully sold'
: row.breakEvenPriceCents === 0
? 'Cost covered'
- : `${money(row.breakEvenPriceCents)}/hr`}
+ : `${money(row.breakEvenPriceCents)}/GPU-hr`}
Fact review
- Resolve Piggy's lower-confidence claims before anyone treats them as record truth.
+ Inspect provenance, decide whether the evidence is credible, and keep uncertain claims out of record truth.
-
0 ? 'warning' : 'positive'}>
+ 0 ? 'warning' : 'positive'} aria-live="polite">
{items.length} awaiting review
@@ -113,7 +113,7 @@ export function FactReview() {
-
Approval validates the evidence, not the CRM field.
+
Approval validates evidence only. It does not write a CRM field.
Approved facts remain separate from accounts and contacts. No value is overwritten
until PIG has a field-aware applicator with conflict handling.
diff --git a/apps/web/src/pages/Growth.tsx b/apps/web/src/pages/Growth.tsx
index a8d44e6..d16df63 100644
--- a/apps/web/src/pages/Growth.tsx
+++ b/apps/web/src/pages/Growth.tsx
@@ -17,7 +17,7 @@ import {
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { PiggyAskButton } from '@/components/PiggyChat';
-import { Badge, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
+import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
import { compactNumber, get, money, moneyExact, shortDate } from '@/lib/api';
import { usePageTitle } from '@/lib/title';
@@ -53,7 +53,7 @@ type GrowthView = 'priority' | 'expansion' | 'renewal' | 'risk' | 'idle';
export function Growth() {
usePageTitle('Growth');
const [view, setView] = useState('priority');
- const { data, isLoading } = useQuery({
+ const { data, isLoading, error, refetch } = useQuery({
queryKey: ['growth'],
queryFn: () => get('/api/growth'),
});
@@ -65,8 +65,8 @@ export function Growth() {
return data.customers;
}, [data, view]);
- if (isLoading) return ;
- if (!data) return ;
+ if (isLoading) return {Array.from({ length: 4 }).map((_, index) => )}
;
+ if (error || !data) return void refetch()}>Try again;
const deployed = data.customers.filter((row) => row.lifecycle.relationshipState === 'deployed').length;
const expansion = data.customers.filter((row) => row.lifecycle.facets.includes('expansion_candidate')).length;
@@ -74,14 +74,14 @@ export function Growth() {
const idleCost = data.idleSupply.reduce((sum, row) => sum + row.idleCostCents, 0);
return (
-
-
+
+
-
Compute growth intelligence
-
Know who to expand, renew, or protect next.
-
Deterministic signals from customer paper, deal activity, and sold or reserved capacity. Scores rank attention; they are not win or churn probabilities.
-
Rules {data.rulesetVersion} · computed {new Date(data.computedAt).toLocaleString()}
+
Growth intelligence
+
Expand, renew, or protect the right account next.
+
Deterministic signals from customer paper, deal activity, and sold or reserved capacity. Attention scores are not win or churn probabilities.
+
Rules {data.rulesetVersion} · computed {new Date(data.computedAt).toLocaleString()}
@@ -91,14 +91,7 @@ export function Growth() {
) : null}
-
-
-
+
{([
['priority', 'Priority'],
['expansion', 'Expansion'],
@@ -106,10 +99,17 @@ export function Growth() {
['risk', 'Risk'],
['idle', 'Idle supply'],
] as const).map(([value, label]) => (
- setView(value)} className={['tap min-h-11 rounded-lg px-4 text-sm font-medium transition-colors', view === value ? 'bg-surface text-fg shadow-sm' : 'text-muted'].join(' ')}>{label}
+ setView(value)} className={['tap min-h-11 shrink-0 rounded-lg px-4 text-sm font-medium transition-colors', view === value ? 'bg-surface text-fg shadow-sm ring-1 ring-border' : 'text-muted hover:bg-surface-2'].join(' ')}>{label}
))}
+
+
{view === 'idle' ?
: (
customers.length ? (
@@ -129,29 +129,30 @@ function CustomerCard({ customer }: { customer: GrowthCustomer }) {
{account.name.slice(0, 2).toUpperCase()}
-
{account.name}{account.domain ?? account.customerSegment?.replaceAll('_', ' ') ?? 'Demand account'}
-
{lifecycle.score}
attention
+
{account.name}{account.domain ?? account.customerSegment?.replaceAll('_', ' ') ?? 'Demand account'}
+
{lifecycle.score}
attention
{lifecycle.facets.map((facet) => )}
-
+
- {lifecycle.signals.slice(0, 3).map((signal) => (
+ {lifecycle.signals.slice(0, 2).map((signal) => (
ref.id).join(':')}`} className="flex gap-3 rounded-lg border border-border/70 p-3">
+{signal.weight}
{signal.explanation}
{signal.category} · {signal.sourceRefs.map((ref) => ref.type.replaceAll('_', ' ')).join(', ')}
))}
+ {lifecycle.signals.length > 2 ?
+{lifecycle.signals.length - 2} more evidence signal{lifecycle.signals.length === 3 ? '' : 's'} in the account context
: null}
{lifecycle.blockers.length ? : null}
-
Open account
+
Open account
diff --git a/apps/web/src/pages/Imports.tsx b/apps/web/src/pages/Imports.tsx
index 5a6af92..1092381 100644
--- a/apps/web/src/pages/Imports.tsx
+++ b/apps/web/src/pages/Imports.tsx
@@ -133,7 +133,7 @@ export function Imports() {
Import data
- Map a CSV or Excel table into PIG, inspect every create or update, then commit the reviewed plan atomically.
+ Stage source data, inspect every create or update, then commit the reviewed plan atomically. Nothing writes to PIG before review.
@@ -185,6 +185,7 @@ export function Imports() {
{
diff --git a/apps/web/src/pages/Margin.tsx b/apps/web/src/pages/Margin.tsx
index a48a0f1..e80c741 100644
--- a/apps/web/src/pages/Margin.tsx
+++ b/apps/web/src/pages/Margin.tsx
@@ -6,8 +6,10 @@
* is the entire value of this view.
*/
import { useQuery } from '@tanstack/react-query';
+import { ArrowRight } from 'lucide-react';
+import { Link } from 'react-router-dom';
import { compactNumber, get, money, moneyExact, percent } from '@/lib/api';
-import { Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
+import { Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
import { usePageTitle } from '@/lib/title';
interface MarginReport {
@@ -38,13 +40,25 @@ interface MarginReport {
export function Margin() {
usePageTitle('Margin');
- const { data, isLoading } = useQuery({
+ const { data, isLoading, error, refetch } = useQuery({
queryKey: ['margin'],
queryFn: () => get
('/api/capacity/margin'),
});
- if (isLoading) return ;
- if (!data || data.blocks.length === 0) {
+ if (isLoading) {
+ return {Array.from({ length: 4 }).map((_, index) => )}
;
+ }
+ if (error || !data) {
+ return (
+
+
+
+ void refetch()}>Try again
+
+
+ );
+ }
+ if (data.blocks.length === 0) {
return (
+
-
+
-
- By commitment
+
+
+
By commitment
+
Sold ratio describes contracted capacity sold, not workload utilization.
+
+
+ Capacity
+
-
+
| Commitment |
Sold |
- Free |
- Utilisation |
+ Sellable |
+ Sold ratio |
Cost/hr |
Break even |
@@ -129,22 +149,39 @@ export function Margin() {
is technically true and reads like a bug — the same fix
already applied on the capacity cards.
*/}
-
- {block.breakEvenPriceCents == null ? (
- Sold out
- ) : block.breakEvenPriceCents === 0 ? (
- Covered
- ) : (
- {moneyExact(block.breakEvenPriceCents)}
- )}
- |
+ |
))}
+
+ {data.blocks.map((block) => (
+
+
+
+
{block.name}
+
{block.gpuCount}× {block.gpuType}
+
+
{percent(block.utilisation)} sold
+
+
+ - Sold capacity
- {compactNumber(block.soldGpuHours)} hrs
+ - Sellable capacity
- {compactNumber(block.availableGpuHours)} hrs
+ - Our cost
- {moneyExact(block.costPerGpuHourCents)}/GPU-hr
+ - Break even
+
+
+ ))}
+
);
}
+
+function BreakEven({ value }: { value: number | null }) {
+ if (value == null) return Sold out;
+ if (value === 0) return Cost covered;
+ return {moneyExact(value)}/GPU-hr;
+}
diff --git a/apps/web/src/pages/Overview.tsx b/apps/web/src/pages/Overview.tsx
index cc4a316..88a4bd4 100644
--- a/apps/web/src/pages/Overview.tsx
+++ b/apps/web/src/pages/Overview.tsx
@@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, ArrowRight, Server, TrendingUp } from 'lucide-react';
import { Link } from 'react-router-dom';
import { compactNumber, get, money, percent, relativeTime } from '@/lib/api';
-import { Badge, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
+import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
import { usePageTitle } from '@/lib/title';
interface Dashboard {
@@ -46,7 +46,7 @@ interface Dashboard {
export function Overview() {
usePageTitle('Overview');
- const { data, isLoading, error } = useQuery({
+ const { data, isLoading, error, refetch } = useQuery({
queryKey: ['dashboard'],
queryFn: () => get('/api/dashboard'),
// The book does not change second to second, but it does change while
@@ -66,19 +66,25 @@ export function Overview() {
if (error || !data) {
return (
-
+
+
+
+ void refetch()}>Try again
+
+
);
}
const m = data.margin;
const marginTone = m.grossMarginCents >= 0 ? 'positive' : 'danger';
const firstName = data.me.name.split(' ')[0];
+ const idleExposureCents = data.idleAlerts.reduce((sum, alert) => sum + alert.idleCostCents, 0);
return (
-
+
{greeting()}, {firstName}
@@ -90,7 +96,7 @@ export function Overview() {
-
+
0 ? (
-
-
- Capacity you are paying for and not selling
+
+
+
+
+
Capacity you are paying for and not selling
+
Prioritized by idle cost exposure.
+
+
+ {money(idleExposureCents)}
-
+
{data.idleAlerts.map((alert) => (
{alert.name}
- {alert.gpuCount}× {alert.gpuType} · {percent(alert.utilisation)} utilised
+ {alert.gpuCount}× {alert.gpuType} · {percent(alert.utilisation)} sold
{/*
A zero break-even means the block's cost is already
covered, so any further sale is upside. Printing
@@ -154,7 +166,8 @@ export function Overview() {
Match
diff --git a/apps/web/src/pages/Piggy.tsx b/apps/web/src/pages/Piggy.tsx
index d54357c..3bee2d7 100644
--- a/apps/web/src/pages/Piggy.tsx
+++ b/apps/web/src/pages/Piggy.tsx
@@ -5,10 +5,23 @@ export function Piggy() {
usePageTitle('Piggy');
return (
-
- Piggy
- Ask across the GPU book, then inspect the PIG records behind the answer.
+
+
+ Inspection boundary.{' '}
+
+ Piggy can query scoped PIG records, but this chat cannot create or update CRM data.
+ Verify material terms against the cited records before acting.
+
+
);
diff --git a/apps/web/src/pages/Pipeline.tsx b/apps/web/src/pages/Pipeline.tsx
index e5b83da..b3b750b 100644
--- a/apps/web/src/pages/Pipeline.tsx
+++ b/apps/web/src/pages/Pipeline.tsx
@@ -1,25 +1,18 @@
/**
* The pipeline boards, for both sides of the market.
*
- * A column-per-stage board on desktop; on a phone, a stage picker and a single
- * column. A horizontally scrolling eight-column board on a 390px screen is
- * technically responsive and practically unusable — you cannot see where a
- * card is going, which is the entire point of a board.
+ * Stages remain ordered, but wrap into a scanable desktop grid instead of
+ * hiding the back half of the funnel behind a multi-screen horizontal rail.
*/
-import { useMemo, useState } from 'react';
+import { useDeferredValue, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { PermissionGrant } from '@pig/core';
-import { Pencil, Plus } from 'lucide-react';
+import { Pencil, Plus, RefreshCw, Search } from 'lucide-react';
import { get, money, relativeTime } from '@/lib/api';
-import { Badge, Button, Card, EmptyState, Skeleton } from '@/components/ui';
+import { Badge, Button, Card, EmptyState, Input, Skeleton } from '@/components/ui';
import { usePageTitle } from '@/lib/title';
import { can } from '@/lib/permissions';
-import {
- DemandDealSheet,
- SupplyDealSheet,
- type DemandDealRecord,
- type SupplyDealRecord,
-} from '@/components/RecordSheets';
+import { DemandDealSheet, SupplyDealSheet, type DemandDealRecord, type SupplyDealRecord } from '@/components/RecordSheets';
interface Board
{
stages: string[];
@@ -27,237 +20,79 @@ interface Board {
}
const STAGE_LABELS: Record = {
- qualification: 'Qualification',
- legal: 'Legal',
- scoping: 'Scoping',
- proposal: 'Proposal',
- procurement: 'Procurement',
- poc: 'POC',
- deployment: 'Deployment',
- expansion: 'Expansion',
- closed_won: 'Closed won',
- closed_lost: 'Closed lost',
- sourced: 'Sourced',
- qualifying: 'Qualifying',
- technical_diligence: 'Technical diligence',
- financial_diligence: 'Financial diligence',
- pricing: 'Pricing',
- contracting: 'Contracting',
- onboarding: 'Onboarding',
- live: 'Live',
- renewal: 'Renewal',
- churned: 'Churned',
- rejected: 'Rejected',
+ qualification: 'Qualification', legal: 'Legal', scoping: 'Scoping', proposal: 'Proposal', procurement: 'Procurement', poc: 'POC', deployment: 'Deployment', expansion: 'Expansion', closed_won: 'Closed won', closed_lost: 'Closed lost', sourced: 'Sourced', qualifying: 'Qualifying', technical_diligence: 'Technical diligence', financial_diligence: 'Financial diligence', pricing: 'Pricing', contracting: 'Contracting', onboarding: 'Onboarding', live: 'Live', renewal: 'Renewal', churned: 'Churned', rejected: 'Rejected',
};
export function DemandPipeline() {
- return (
-
- title="Demand"
- subtitle="Selling compute and post-training. Note that legal sits early — paper gates the deal rather than closing it."
- endpoint="/api/deals/demand"
- team="demand"
- renderSheet={({ open, onOpenChange, record }) => }
- renderCard={(deal, accountName) => (
- <>
- {deal.name}
- {accountName ?? 'No account'}
-
- {deal.acvCents ? (
- {money(deal.acvCents)}
- ) : null}
- {deal.productLine.replace(/_/g, ' ')}
- {/* Contract state is surfaced on the card because shipping capacity
- without executed paper is the mistake this pipeline prevents. */}
- {deal.msaExecuted ? MSA : null}
- {deal.dpaExecuted ? DPA : null}
-
- >
- )}
- />
- );
+ return
+ title="Demand" orientation="Sell-side"
+ subtitle="Selling compute and post-training. Legal sits early because paper gates delivery rather than merely closing it."
+ endpoint="/api/deals/demand" team="demand"
+ searchText={(deal, accountName) => `${deal.name} ${accountName ?? ''} ${deal.productLine}`}
+ metricLabel="Visible ACV" metricValue={(deals) => money(deals.reduce((total, deal) => total + (deal.acvCents ?? 0), 0))}
+ renderSheet={({ open, onOpenChange, record }) => }
+ renderCard={(deal, accountName) => <>{deal.name}
{accountName ?? 'No account'}
{deal.acvCents != null ? {money(deal.acvCents)} : null}{deal.productLine.replace(/_/g, ' ')}{/* Paper state prevents delivery readiness from being inferred from stage. */}{deal.msaExecuted ? MSA : null}{deal.dpaExecuted ? DPA : null}
>}
+ />;
}
export function SupplyPipeline() {
- return (
-
- title="Supply"
- subtitle="Sourcing GPU capacity. Technical and financial diligence are separate gates — accepting capacity is a two-key decision."
- endpoint="/api/deals/supply"
- team="supply"
- renderSheet={({ open, onOpenChange, record }) => }
- renderCard={(deal, accountName) => (
- <>
- {deal.name}
- {accountName ?? 'No account'}
-
- {deal.gpuCount && deal.gpuType ? (
-
- {deal.gpuCount}× {deal.gpuType}
-
- ) : null}
- {deal.targetCostPerGpuHourCents ? (
-
- {money(deal.targetCostPerGpuHourCents)}/hr target
-
- ) : null}
-
- >
- )}
- />
- );
+ return
+ title="Supply" orientation="Buy-side"
+ subtitle="Sourcing GPU capacity. Technical and financial diligence remain separate gates because accepting capacity is a two-key decision."
+ endpoint="/api/deals/supply" team="supply"
+ searchText={(deal, accountName) => `${deal.name} ${accountName ?? ''} ${deal.gpuType ?? ''}`}
+ metricLabel="GPU opportunity" metricValue={(deals) => `${deals.reduce((total, deal) => total + (deal.gpuCount ?? 0), 0).toLocaleString()} GPUs`}
+ renderSheet={({ open, onOpenChange, record }) => }
+ renderCard={(deal, accountName) => <>{deal.name}
{accountName ?? 'No account'}
{deal.gpuCount != null && deal.gpuType ? {deal.gpuCount}× {deal.gpuType} : null}{deal.targetCostPerGpuHourCents != null ? {money(deal.targetCostPerGpuHourCents)}/hr target : null}
>}
+ />;
}
-function PipelineBoard({
- title,
- subtitle,
- endpoint,
- team,
- renderCard,
- renderSheet,
-}: {
- title: string;
- subtitle: string;
- endpoint: string;
- team: 'supply' | 'demand';
+function PipelineBoard({ title, orientation, subtitle, endpoint, team, searchText, metricLabel, metricValue, renderCard, renderSheet }: {
+ title: string; orientation: string; subtitle: string; endpoint: string; team: 'supply' | 'demand';
+ searchText: (deal: T, accountName: string | null) => string; metricLabel: string; metricValue: (deals: T[]) => string;
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
renderSheet: (props: { open: boolean; onOpenChange(open: boolean): void; record?: T }) => React.ReactNode;
}) {
usePageTitle(title);
-
- const { data, isLoading } = useQuery({
- queryKey: [endpoint],
- queryFn: () => get>(endpoint),
- });
- const { data: me } = useQuery({
- queryKey: ['me'],
- queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me'),
- });
+ const boardQuery = useQuery({ queryKey: [endpoint], queryFn: () => get>(endpoint) });
+ const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me') });
const writable = can(me, 'deal:write', team);
const [sheet, setSheet] = useState<{ open: boolean; record?: T }>({ open: false });
-
const [activeStage, setActiveStage] = useState(null);
-
+ const [query, setQuery] = useState('');
+ const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase());
+ const filteredDeals = useMemo(() => !deferredQuery ? boardQuery.data?.deals ?? [] : (boardQuery.data?.deals ?? []).filter((row) => searchText(row.deal, row.accountName).toLocaleLowerCase().includes(deferredQuery)), [boardQuery.data?.deals, deferredQuery, searchText]);
const byStage = useMemo(() => {
const map = new Map();
- for (const stage of data?.stages ?? []) map.set(stage, []);
- for (const row of data?.deals ?? []) {
- map.get(row.deal.stage)?.push(row);
- }
+ for (const stage of boardQuery.data?.stages ?? []) map.set(stage, []);
+ for (const row of filteredDeals) map.get(row.deal.stage)?.push(row);
return map;
- }, [data]);
+ }, [boardQuery.data?.stages, filteredDeals]);
+ const stages = boardQuery.data?.stages ?? [];
+ const populatedStage = stages.find((stage) => (byStage.get(stage)?.length ?? 0) > 0);
+ const currentStage = activeStage && stages.includes(activeStage) ? activeStage : populatedStage ?? stages[0] ?? '';
+ const activeStageCount = stages.filter((stage) => (byStage.get(stage)?.length ?? 0) > 0).length;
+ const sheetNode = renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record });
- if (isLoading) return ;
+ if (boardQuery.isLoading) return setSheet({ open: true })} />
;
+ if (boardQuery.isError) return setSheet({ open: true })} /> void boardQuery.refetch()}>Try again} />{sheetNode}
;
+ if (!boardQuery.data || boardQuery.data.deals.length === 0) return setSheet({ open: true })} /> setSheet({ open: true })}>New {title.toLowerCase()} deal} />{sheetNode} ;
- if (!data || data.deals.length === 0) {
- return (
-
-
setSheet({ open: true })} />
-
- setSheet({ open: true })}>New {title.toLowerCase()} deal}
- />
-
- {renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record })}
-
- );
- }
-
- const stages = data.stages;
- const currentStage = activeStage ?? stages[0]!;
-
- return (
-
- );
+ return ;
}
-function DealCard({
- row,
- renderCard,
- writable,
- onEdit,
-}: {
- row: { deal: T; accountName: string | null };
- renderCard: (deal: T, accountName: string | null) => React.ReactNode;
- writable: boolean;
- onEdit(): void;
-}) {
- return (
-
- Edit deal
- {renderCard(row.deal, row.accountName)}
- {relativeTime(row.deal.updatedAt)}
-
- );
-}
-
-function Header({ title, subtitle, writable, onCreate }: { title: string; subtitle: string; writable: boolean; onCreate(): void }) {
- return (
-
- );
+function DealCard({ row, renderCard, writable, onEdit }: { row: { deal: T; accountName: string | null }; renderCard: (deal: T, accountName: string | null) => React.ReactNode; writable: boolean; onEdit(): void }) {
+ return Edit deal{renderCard(row.deal, row.accountName)}Updated {relativeTime(row.deal.updatedAt)}
;
}
+function PipelineStat({ label, value }: { label: string; value: string }) { return ; }
+function StageEmpty({ stage, filtered, compact = false }: { stage: string; filtered: boolean; compact?: boolean }) { return {filtered ? 'No matching deals' : `Nothing in ${STAGE_LABELS[stage] ?? stage}`}
; }
+function Header({ title, orientation, subtitle, writable, onCreate }: { title: string; orientation: string; subtitle: string; writable: boolean; onCreate(): void }) { return {title}
{orientation}{subtitle}
New {title.toLowerCase()} deal; }
diff --git a/apps/web/src/pages/Register.tsx b/apps/web/src/pages/Register.tsx
index 4583e50..124c5ed 100644
--- a/apps/web/src/pages/Register.tsx
+++ b/apps/web/src/pages/Register.tsx
@@ -100,7 +100,10 @@ export function Register({
Create your account
- Use any email you like — your invite code is what grants access.
+ Use an email you control. A valid PIG invite is required before the server creates workspace access.
+
+
+ This flow does not enable open registration on the shared identity provider.
@@ -108,9 +111,11 @@ export function Register({