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:
+128
-38
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user