Merge gitea/main into the Motion branch

Motion was written against a base five commits behind main, so the
integration is the interesting part of this commit:

- The migration is renumbered 0014 -> 0015. Main shipped
  0014_piggy_conversations, and two migrations sharing an index is a
  journal that applies one of them.
- The seed-idempotency gate keeps main's all-tables diff rather than the
  motion_templates counter this branch added; the general check subsumes
  the specific one.
- Nav gains a Motion group alongside main's new Workspace group, and
  Piggy keeps the mark main gave it.
- Stat keeps main's container-scaled figure, which already carries the
  min-w-0 this branch added for the same reason.
- Piggy's page labels keep main's refusal wording for the four pages with
  no tool of their own, and gain the three Motion routes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 18:30:45 -07:00
149 changed files with 37440 additions and 3502 deletions
+128 -38
View File
@@ -3,7 +3,7 @@
*/
import { lazy, Suspense, useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { BrowserRouter, Navigate, 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';
@@ -16,7 +16,8 @@ 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 { Badge, Button, Card, EmptyState, Section, Skeleton, Stat } from '@/components/ui';
import { PageHeader } from '@/components/ui/page-header';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Toaster } from '@/components/ui/sonner';
import { usePageTitle } from '@/lib/title';
@@ -240,7 +241,39 @@ function AppRoutes() {
return (
<Routes>
<Route element={<Shell />}>
<Route index element={<RoutePage><Overview /></RoutePage>} />
{/*
`/` is the front door, and the front door is Piggy.
--------------------------------------------------
Signing in does not navigate: the auth gate simply starts rendering
these routes at whatever address the browser is already on, which for
anyone arriving fresh is `/`. So "land on Piggy after sign-in" and
"`/` is Piggy" are the same sentence, and this is the only line that
decides it. A post-sign-in `navigate()` was rejected: it fires on one
path through the gate and not on a hard refresh, so the product would
open somewhere different depending on how you got there.
It is a redirect rather than Piggy mounted at the index, because the
workspace needs ONE address. Two paths rendering it would leave the
sidebar row unlit on `/`, the breadcrumb blank, and a shared link
ambiguous. `replace` keeps `/` out of history, so Back leaves the app
instead of bouncing between the two, and the logo — which points at
`/` and means "home" — lands on the same screen it always did, only
home is Piggy now.
Overview moves to `/overview` rather than being displaced: it is the
exec's page, it keeps its place at the top of Intelligence, it keeps
its tab on the phone, and it is one click from anywhere. What it
loses is being the thing you are shown before you have asked for
anything, which is the whole point of the change — a report is what
you open when you have a question about the business, and Piggy is
where you ask it.
Nothing else moves. Every other path is registered exactly as before,
so `/accounts/:id`, `/margin` and every bookmark and Piggy record link
into them still resolve directly, with no pass through here.
*/}
<Route index element={<Navigate to="/piggy" replace />} />
<Route path="overview" 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>} />
@@ -268,7 +301,7 @@ function AppRoutes() {
<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>} />
<Route path="piggy" element={<WorkspaceRoute><Piggy /></WorkspaceRoute>} />
<Route path="team" element={<Team />} />
<Route path="facts" element={<RoutePage><FactReview /></RoutePage>} />
<Route path="settings" element={<RoutePage><Settings /></RoutePage>} />
@@ -286,6 +319,48 @@ function RoutePage({ children }: { children: React.ReactNode }) {
);
}
/**
* A route that FILLS the content pane instead of flowing down it.
*
* Shell puts every page inside `mx-auto max-w-7xl px-4 py-5 …`, which is right
* for a document and wrong for a workspace: an agent surface with a
* conversation list, a transcript and an activity panel wants the whole pane,
* a floor it can pin a composer to, and no page scrollbar behind the two
* panels that already scroll.
*
* `absolute inset-0` is how it gets that without a second shell. SidebarInset
* is `relative` (see ui/sidebar), so this box is laid out against the content
* pane itself — full width whatever the container capped, full height whatever
* the container did not stretch to — while the capped container stays exactly
* as it is for the twelve pages that want it. Taking it out of flow is also
* what makes `overflow-hidden` safe here: the page cannot grow, so the panels
* inside must own their own scrolling, which is the contract a workspace wants
* anyway.
*
* The bottom padding is the one thing that has to be restated. An absolutely
* positioned child is laid out against its ancestor's PADDING box, so the
* inset's own tab-bar clearance does not apply to it, and without this the
* composer would sit underneath the phone tab bar — the exact control a phone
* user came here to reach. `lg` matches where the tab bar gives way.
*
* Under 500px tall the reserve is given back. A phone in landscape, or a phone
* with the keyboard up, is spending 72px of a 390px viewport on a bar it can
* reach again by turning the handset back — while the transcript, which is why
* the page exists, is measured at 40px. The tab bar itself stands down at the
* same height (Shell.tsx), so nothing lands underneath it.
*/
function WorkspaceRoute({ children }: { children: React.ReactNode }) {
return (
<div className="absolute inset-0 flex min-h-0 flex-col overflow-hidden pb-[calc(4.5rem+var(--safe-bottom))] [@media(max-height:500px)]:pb-[var(--safe-bottom)] lg:pb-0">
{/* `flex-1` on the fallback, or the spinner for a pane this tall sits up
against the header while the rest of it stays empty. */}
<Suspense fallback={<div className="flex flex-1 items-center justify-center"><RouteLoading /></div>}>
{children}
</Suspense>
</div>
);
}
function RouteLoading() {
return (
<div
@@ -329,7 +404,7 @@ function Placeholder({ title }: { title: string }) {
function Team() {
usePageTitle('Team');
const { data, isLoading, error } = useQuery({
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['team'],
queryFn: () =>
get<
@@ -347,39 +422,55 @@ function Team() {
const representedTeams = new Set((data ?? []).flatMap((person) => person.teams.map((team) => team.team))).size;
return (
/*
* The one page that never got a design pass, because it never had an owner:
* it lives inline in App.tsx rather than in `pages/`, so the wave that swept
* all thirteen routes swept past it. Measured against the rest of the
* product it carried a 30px `<h1>` where every other route is 24px, an
* accent-coloured "ACCESS MAP" eyebrow of exactly the kind the direction
* deleted from Growth (identity colour used as decoration, on a page with
* no agent in it), hand-rolled 10px/24px stat tiles instead of `Stat`, and
* a 14px section heading floating on the canvas. It is now the same three
* primitives every other page is built from and nothing else changed.
*/
<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>
<PageHeader
title="Team"
description="See who can operate each side of the compute business and where ownership is thin."
actions={
<Link
to="/settings"
className="tap inline-flex items-center rounded-lg px-1 text-sm font-medium text-accent-fg underline-offset-4 hover:underline"
>
Manage access in Settings
</Link>
}
/>
<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>
))}
{/* `grid-cols-2 gap-3 xl:grid-cols-*`, the same KPI row Overview and
Margin use. This carried `grid-cols-3 sm:max-w-xl`, which made Team
the one page whose headline figures were a different size and whose
row stopped halfway across the page. */}
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
<Stat label="People" value={data?.length ?? 0} />
<Stat label="Teams" value={representedTeams} />
<Stat label="Assignments" value={assignments} />
</div>
{error ? (
<Card>
<EmptyState title="Team unavailable" description={error instanceof Error ? error.message : 'Could not load team access.'} />
{/* Three routes rendered an honest error and then offered nothing to
do about it. A transient 500 on a page with no Try again is a page
a person has to know to reload. */}
<EmptyState
title="Team unavailable"
description={error instanceof Error ? error.message : 'Could not load team access.'}
action={
<Button type="button" variant="outline" onClick={() => void refetch()}>
Try again
</Button>
}
/>
</Card>
) : null}
@@ -396,11 +487,10 @@ function Team() {
) : 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>
<Section
title="People and permissions"
description="Roles are enforced server-side."
>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{data.map((person) => {
const initials = person.name
@@ -436,7 +526,7 @@ function Team() {
);
})}
</div>
</section>
</Section>
) : null}
</div>
);