Files
pig/apps/web/src/App.tsx
T
karti 30171767f7
CI / verify (push) Successful in 3m43s
CI / publish (push) Has been skipped
Refine the music: fades, and a mute control on every public screen
THE BUG. An anonymous visitor to / — the sign-in page, which is what the link
in an email opens — got music with ZERO mute controls. The provider moved above
the router so it covers the signed-out screens, but those render outside Shell
and therefore have no app header to host the toggle. Audio a visitor cannot
switch off is the worst version of this feature. Every unauthenticated screen
now carries the control pinned bottom-right; Learn keeps the one in its own
chrome rather than getting a second.

FADES. Volume ramps 0 -> 0.14 over 1.1s on start and back down over 0.42s on
mute, easeOutQuad so a mute feels prompt while a start feels like the room was
already there. Snapping to full volume on the first click reads as a glitch.
Measured: 0.076 at +0.4s, 0.14 at +2.2s, 0.025 at +0.25s after mute, paused by
+0.85s.

NO CHANGE TO THE TRACKS, and this reverses what I said earlier. I claimed
pig-tech would splice audibly every 28 seconds and offered to crossfade the
loop. That came from comparing 0.4-second mean levels, which measures musical
content rather than continuity. Measured properly — the wrap discontinuity
against each track's own 99.9th-percentile sample delta — all three already
loop cleanly (0.03-0.10x, i.e. quieter than their own ordinary transients), and
a folded crossfade made them WORSE (0.09-1.32x). The originals ship unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 19:59:45 -07:00

430 lines
16 KiB
TypeScript

/**
* Application root: routing, data fetching, and the auth gate.
*/
import { lazy, Suspense, useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { ApiError, get, getSupabase, loadPublicConfig, patch, type PublicConfig } from '@/lib/api';
import { ThemeProvider } from '@/lib/theme';
import { IdentityProvider, useIdentityQuery } from '@/lib/identity';
import { LayoutProvider } from '@/lib/layout';
import { PiggyContextProvider } from '@/lib/piggy-context';
import { PlatformAudioProvider } from '@/lib/audio';
import { AudioControl } from '@/components/AudioControl';
import { Shell } from '@/components/Shell';
import { SignIn } from '@/pages/SignIn';
import { CreateProfile } from '@/pages/CreateProfile';
import { Register } from '@/pages/Register';
import { PiggyMark } from '@/components/PiggyMark';
import { Badge, Card, EmptyState, Skeleton } from '@/components/ui';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Toaster } from '@/components/ui/sonner';
import { usePageTitle } from '@/lib/title';
const Overview = lazy(() => import('@/pages/Overview').then(({ Overview }) => ({ default: Overview })));
const Capacity = lazy(() => import('@/pages/Capacity').then(({ Capacity }) => ({ default: Capacity })));
const DemandPipeline = lazy(() => import('@/pages/Pipeline').then(({ DemandPipeline }) => ({ default: DemandPipeline })));
const SupplyPipeline = lazy(() => import('@/pages/Pipeline').then(({ SupplyPipeline }) => ({ default: SupplyPipeline })));
const Settings = lazy(() => import('@/pages/Settings').then(({ Settings }) => ({ default: Settings })));
const Accounts = lazy(() => import('@/pages/Accounts').then(({ Accounts }) => ({ default: Accounts })));
const Margin = lazy(() => import('@/pages/Margin').then(({ Margin }) => ({ default: Margin })));
const FactReview = lazy(() => import('@/pages/FactReview').then(({ FactReview }) => ({ default: FactReview })));
const Contracts = lazy(() => import('@/pages/Contracts').then(({ Contracts }) => ({ default: Contracts })));
const Imports = lazy(() => import('@/pages/Imports').then(({ Imports }) => ({ default: Imports })));
const Piggy = lazy(() => import('@/pages/Piggy').then(({ Piggy }) => ({ default: Piggy })));
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 })));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
// Refetching every time a phone user switches apps and comes back is
// wasteful on cellular; the interval on the dashboard covers freshness.
refetchOnWindowFocus: false,
retry: (failureCount, error) => {
// Retrying an auth failure just produces the same failure slower.
if (error instanceof ApiError && (error.needsSignIn || error.needsProfile)) return false;
return failureCount < 2;
},
},
},
});
export function App() {
const [config, setConfig] = useState<PublicConfig | null>(null);
const [configError, setConfigError] = useState<string | null>(null);
useEffect(() => {
loadPublicConfig()
.then(setConfig)
.catch((error: unknown) =>
setConfigError(error instanceof Error ? error.message : 'Could not reach the server.'),
);
}, []);
if (configError) {
return (
<Centered>
<EmptyState
title="Cannot reach PIG"
description={configError}
/>
</Centered>
);
}
if (!config) return <Splash />;
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider
onPersist={(prefs) => {
// Fire and forget: a failed preference save must never block the UI,
// and the local copy already applied the change.
void patch('/api/me/preferences', prefs).catch(() => {});
}}
>
{/*
Above the router, so the music survives navigation AND covers the
anonymous Learn page — a share-code visitor gets the same platform
character as a member. It never plays unbidden: the browser refuses
audio until the page has had a real gesture, so it begins when
someone actually starts using the page, and the header control mutes
it for good on that device.
*/}
<PlatformAudioProvider>
<BrowserRouter>
<AuthGate config={config} />
</BrowserRouter>
</PlatformAudioProvider>
{/* Inside ThemeProvider: the host reads the resolved light/dark value. */}
<Toaster />
</ThemeProvider>
</QueryClientProvider>
);
}
/**
* The signed-out screens render outside Shell, so they get no app header — and
* therefore no way to stop the music. Audio a visitor cannot switch off is the
* worst version of this feature, so every unauthenticated screen gets the
* control pinned in a corner.
*
* Learn is not wrapped: it brings its own chrome and already hosts one.
*/
function SignedOut({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<div
className="fixed right-3 z-50 rounded-full border border-border bg-surface/85 shadow-sm backdrop-blur"
style={{ bottom: 'calc(0.75rem + var(--safe-bottom))' }}
>
<AudioControl />
</div>
</>
);
}
/**
* Decides what to show based on *why* a request failed.
*
* The distinction between "not signed in" and "signed in but not a member" is
* the one that matters: sending an invited-but-unprovisioned user back to a
* login screen they have already completed is an infuriating loop, and it is
* the default behaviour if both are treated as "auth error".
*/
function AuthGate({ config }: { config: PublicConfig }) {
// Which unauthenticated screen to show. Kept in state rather than a route so
// that a half-filled registration form is not lost to an accidental Back.
const [showRegister, setShowRegister] = useState(false);
const { data, isLoading, error, refetch } = useIdentityQuery();
// Adopt the server's stored appearance once we know who this is. Appearance
// only: sidebar-collapsed and dock-open are per-device and live in
// localStorage, deliberately (see lib/layout.tsx).
useEffect(() => {
if (!data) return;
void get<{ themeMode?: string; accentColor?: string }>('/api/me/profile')
.then((profile) => {
if (!profile) return;
const host = window as unknown as { __pigAdoptTheme?: (p: unknown) => void };
host.__pigAdoptTheme?.(profile);
})
.catch(() => {});
}, [data]);
// React to sign-in and sign-out without a page reload.
useEffect(() => {
const supabase = getSupabase();
if (!supabase) return;
const { data: subscription } = supabase.auth.onAuthStateChange(() => {
void refetch();
});
return () => subscription.subscription.unsubscribe();
}, [refetch]);
if (isLoading) return <Splash />;
if (error instanceof ApiError) {
if (error.needsSignIn) {
/*
* Learn is the one route reachable without an account. It gates itself on
* a share code, and the API only ever serves it platform-track rows — so
* sending a code-holder to the sign-in screen would make the code
* unusable, which is the whole point of having one.
*
* Rendered outside Shell deliberately: the page uses no identity, layout
* or dock hook, and there is no member to build a workspace chrome for.
*/
if (window.location.pathname === '/learn') {
return (
<RoutePage>
<Learn />
</RoutePage>
);
}
return (
<SignedOut>
{showRegister ? (
<Register
config={config}
onBack={() => setShowRegister(false)}
onRegistered={() => {
setShowRegister(false);
void refetch();
}}
/>
) : (
<SignIn config={config} onCreateAccount={() => setShowRegister(true)} />
)}
</SignedOut>
);
}
// Authenticated but not a member. This is a step in the flow, not an
// error — sending them back to a login screen they have already completed
// would be a loop with no exit.
if (error.needsProfile) {
return (
<SignedOut>
<CreateProfile config={config} onCreated={() => void refetch()} />
</SignedOut>
);
}
}
if (error || !data) {
return (
<Centered>
<EmptyState
title="Something went wrong"
description={error instanceof Error ? error.message : 'Unknown error.'}
/>
</Centered>
);
}
return (
<IdentityProvider identity={data}>
<LayoutProvider>
<PiggyContextProvider>
<AppRoutes />
</PiggyContextProvider>
</LayoutProvider>
</IdentityProvider>
);
}
function AppRoutes() {
return (
<Routes>
<Route element={<Shell />}>
<Route index element={<RoutePage><Overview /></RoutePage>} />
<Route path="margin" element={<RoutePage><Margin /></RoutePage>} />
<Route path="growth" element={<RoutePage><Growth /></RoutePage>} />
<Route path="calendar" element={<RoutePage><Calendar /></RoutePage>} />
<Route path="learn" element={<RoutePage><Learn /></RoutePage>} />
<Route path="capacity" element={<RoutePage><Capacity /></RoutePage>} />
<Route path="demand" element={<RoutePage><DemandPipeline /></RoutePage>} />
<Route path="supply" element={<RoutePage><SupplyPipeline /></RoutePage>} />
<Route path="accounts" element={<RoutePage><Accounts /></RoutePage>} />
<Route path="contracts" element={<RoutePage><Contracts /></RoutePage>} />
<Route path="imports" element={<RoutePage><Imports /></RoutePage>} />
<Route path="piggy" element={<RoutePage><Piggy /></RoutePage>} />
<Route path="team" element={<Team />} />
<Route path="facts" element={<RoutePage><FactReview /></RoutePage>} />
<Route path="settings" element={<RoutePage><Settings /></RoutePage>} />
<Route path="*" element={<Placeholder title="Not found" />} />
</Route>
</Routes>
);
}
function RoutePage({ children }: { children: React.ReactNode }) {
return (
<Suspense fallback={<RouteLoading />}>
{children}
</Suspense>
);
}
function RouteLoading() {
return (
<div
className="flex min-h-[50dvh] items-center justify-center"
role="status"
aria-live="polite"
>
<div className="flex flex-col items-center gap-3 text-sm text-muted">
<PiggyMark className="h-9 w-9 animate-pulse text-fg" aria-hidden />
<span>Loading view</span>
</div>
</div>
);
}
function Splash() {
return (
<Centered>
<PiggyMark className="h-12 w-12 animate-pulse text-fg" title="Loading pig" />
</Centered>
);
}
function Centered({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-dvh items-center justify-center bg-bg px-6">
<div className="w-full max-w-md">{children}</div>
</div>
);
}
function Placeholder({ title }: { title: string }) {
usePageTitle(title);
return (
<EmptyState
title={title}
description="That page does not exist or may have moved. Use Search to return to a workspace."
/>
);
}
function Team() {
usePageTitle('Team');
const { data, isLoading, error } = useQuery({
queryKey: ['team'],
queryFn: () =>
get<
{
id: string;
name: string;
email: string;
title: string | null;
teams: { team: string; role: string }[];
}[]
>('/api/team'),
});
const assignments = (data ?? []).reduce((total, person) => total + person.teams.length, 0);
const representedTeams = new Set((data ?? []).flatMap((person) => person.teams.map((team) => team.team))).size;
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">Access map</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">Team</h1>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
See who can operate each side of the compute business and where ownership is thin.
</p>
</div>
<Link
to="/settings"
className="tap inline-flex items-center self-start rounded-lg px-1 text-sm font-medium text-accent-fg underline-offset-4 hover:underline sm:self-auto"
>
Manage access in Settings
</Link>
</header>
<div className="grid grid-cols-3 gap-2 sm:max-w-xl sm:gap-3">
{[
['People', data?.length ?? 0],
['Teams', representedTeams],
['Assignments', assignments],
].map(([label, value]) => (
<Card key={label} className="p-3 sm:p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted sm:text-xs">{label}</p>
<p className="nums mt-1 text-2xl font-semibold">{value}</p>
</Card>
))}
</div>
{error ? (
<Card>
<EmptyState title="Team unavailable" description={error instanceof Error ? error.message : 'Could not load team access.'} />
</Card>
) : null}
{isLoading ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{[0, 1, 2].map((key) => <Skeleton key={key} className="h-40 rounded-2xl" />)}
</div>
) : null}
{!isLoading && !error && data?.length === 0 ? (
<Card>
<EmptyState title="No team members yet" description="Invite and assign the first operator from Settings." />
</Card>
) : null}
{!isLoading && !error && data?.length ? (
<section aria-labelledby="team-members-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="team-members-heading" className="text-sm font-semibold">People and permissions</h2>
<span className="text-xs text-muted">Roles are enforced server-side</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{data.map((person) => {
const initials = person.name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('');
return (
<Card key={person.id} className="p-4 sm:p-5">
<div className="flex min-w-0 items-center gap-3">
<Avatar className="size-11 border border-border">
<AvatarFallback className="bg-accent-subtle text-sm font-semibold text-accent-fg">
{initials || 'P'}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-semibold">{person.name}</p>
<p className="truncate text-sm text-muted">{person.title || 'Team member'}</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-1.5">
{person.teams.map((membership) => (
<Badge key={`${membership.team}:${membership.role}`} tone="accent">
{membership.team} · {membership.role}
</Badge>
))}
</div>
{person.teams.length === 0 ? (
<p className="mt-4 text-sm text-warning">No operational team assigned</p>
) : null}
</Card>
);
})}
</div>
</section>
) : null}
</div>
);
}