Polish the public auth experience
CI / verify (push) Successful in 3m26s
CI / publish (push) Has been skipped

Replace the stacked sign-in card with a responsive public shell, a local monochrome compute field, restrained grain and reduced-motion-safe drift. Share the anonymous header with Learn so public navigation stays consistent, and carry the corrected spacing through registration and profile setup.
This commit is contained in:
2026-08-13 20:51:34 -07:00
parent b63ed0181f
commit 9db53cb36f
9 changed files with 574 additions and 430 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

+4 -16
View File
@@ -11,7 +11,6 @@ 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';
@@ -108,25 +107,14 @@ export function App() {
}
/**
* 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.
* The signed-out screens render outside Shell. Their shared AuthShell supplies
* the public header and its music control; keeping this boundary component
* means the auth gate does not need to know anything about that presentation.
*
* 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>
</>
);
return children;
}
/**
+37
View File
@@ -0,0 +1,37 @@
/**
* Public auth composition: generated compute field, grain and real form UI.
*
* The artwork is decoration only. Auth copy and controls stay in the normal
* document flow, so a missing image changes the mood rather than the task.
*/
import type { ReactNode } from 'react';
import { PublicHeader } from './PublicHeader';
export function AuthShell({ children, onSignIn }: { children: ReactNode; onSignIn?: () => void }) {
return (
<div className="auth-shell relative isolate flex min-h-dvh min-w-0 flex-col overflow-hidden bg-bg text-fg">
<PublicHeader current="sign-in" onSignIn={onSignIn} />
<main className="relative grid min-w-0 flex-1 lg:grid-cols-[minmax(0,1.08fr)_minmax(30rem,0.92fr)]">
<div
className="auth-visual pointer-events-none absolute inset-0 lg:relative lg:inset-auto"
aria-hidden
>
<div className="auth-field-motion absolute -inset-[4%]">
<img
src="/images/pig-compute-field.webp"
alt=""
className="size-full object-cover object-[46%_50%]"
decoding="async"
fetchPriority="high"
/>
</div>
<div className="auth-visual-vignette absolute inset-0" />
</div>
<section className="relative z-10 flex min-w-0 items-center justify-center px-4 py-6 sm:px-8 sm:py-10 lg:border-l lg:border-border/70 lg:bg-bg/90 lg:px-12 xl:px-16">
<div className="w-full min-w-0 max-w-lg">{children}</div>
</section>
</main>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
/**
* The small amount of chrome shared by every public PIG screen.
*
* These routes deliberately live outside the authenticated Shell, but they
* should still feel like the same product. Keeping the wordmark, Learn link,
* sign-in affordance and music control here prevents the auth and shared
* Learn pages from drifting into separate mini-sites.
*/
import { AudioControl } from './AudioControl';
import { PiggyLogo } from './PiggyMark';
import { cn } from './ui';
type PublicDestination = 'learn' | 'sign-in';
export function PublicHeader({
current,
onSignIn,
}: {
current?: PublicDestination;
onSignIn?: () => void;
}) {
const itemClass = (destination: PublicDestination) =>
cn(
'tap relative inline-flex items-center px-2 text-sm font-medium transition-colors',
current === destination ? 'text-fg' : 'text-muted hover:text-fg',
current === destination &&
'after:absolute after:inset-x-2 after:bottom-1 after:h-px after:bg-current',
);
return (
<header className="public-header relative z-30 border-b border-border/70 bg-bg/80 backdrop-blur-xl">
<div className="mx-auto flex h-16 w-full min-w-0 max-w-7xl items-center gap-2 px-4 sm:px-6 lg:px-8">
<a href="/" className="tap flex shrink-0 items-center rounded-lg" aria-label="PIG home">
<PiggyLogo />
</a>
<nav aria-label="Public navigation" className="ml-auto flex shrink-0 items-center gap-1">
<a
href="/learn"
aria-current={current === 'learn' ? 'page' : undefined}
className={itemClass('learn')}
>
Learn
</a>
<AudioControl className="public-header-audio" />
{onSignIn ? (
<button type="button" onClick={onSignIn} className={itemClass('sign-in')}>
Sign in
</button>
) : current === 'sign-in' ? (
<span aria-current="page" className={itemClass('sign-in')}>
Sign in
</span>
) : (
<a href="/" className={itemClass('sign-in')}>
Sign in
</a>
)}
</nav>
</div>
</header>
);
}
+76
View File
@@ -177,6 +177,82 @@
background-attachment: fixed;
}
/*
* The auth surface is intentionally monochrome even if a signed-in user has
* selected another accent. It is public brand chrome, and forcing the local
* dark token set keeps controls legible without changing the stored theme.
*/
.auth-shell {
--bg: 240 10% 3%;
--surface: 240 7% 6%;
--surface-2: 240 5% 11%;
--border: 240 4% 22%;
--fg: 0 0% 98%;
--muted: 240 5% 67%;
--accent: 0 0% 98%;
--accent-fg: 0 0% 98%;
--accent-on: 240 6% 10%;
--accent-subtle: 240 4% 16%;
color-scheme: dark;
}
.auth-field-motion {
animation: auth-field-drift 26s ease-in-out infinite alternate;
will-change: transform;
}
.auth-visual {
opacity: 0.52;
}
.auth-visual::after {
position: absolute;
inset: 0;
content: '';
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.72'/%3E%3C/svg%3E");
background-size: 180px 180px;
mix-blend-mode: soft-light;
opacity: 0.14;
}
.auth-visual-vignette {
background:
linear-gradient(to bottom, hsl(var(--bg) / 0.18), hsl(var(--bg) / 0.72) 34%, hsl(var(--bg)) 100%),
linear-gradient(to right, hsl(var(--bg) / 0.18), hsl(var(--bg) / 0.42));
}
@media (min-width: 1024px) {
.auth-visual {
opacity: 1;
}
.auth-visual-vignette {
background:
linear-gradient(to right, transparent 48%, hsl(var(--bg) / 0.28) 78%, hsl(var(--bg) / 0.72)),
linear-gradient(to bottom, hsl(var(--bg) / 0.06), transparent 18%, hsl(var(--bg) / 0.28));
}
}
@keyframes auth-field-drift {
0% {
transform: translate3d(-1.4%, -0.8%, 0) scale(1.045);
}
50% {
transform: translate3d(0.8%, 0.6%, 0) scale(1.075);
}
100% {
transform: translate3d(-0.3%, 1.1%, 0) scale(1.055);
}
}
@media (prefers-reduced-motion: reduce) {
.auth-field-motion {
animation: none;
transform: scale(1.045);
will-change: auto;
}
}
/* Tabular figures keep money and percentages from jittering as they
update, which matters on a dashboard that refreshes. */
.nums {
+24 -25
View File
@@ -9,8 +9,8 @@
import { useState } from 'react';
import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core';
import { ApiError, post, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark';
import { Button, Input } from '@/components/ui';
import { AuthShell } from '@/components/AuthShell';
import { usePageTitle } from '@/lib/title';
export function CreateProfile({
@@ -51,21 +51,22 @@ export function CreateProfile({
}
return (
<div className="flex min-h-dvh items-center justify-center bg-bg px-6 py-12">
<div className="w-full max-w-md">
<div className="mb-6 flex flex-col items-center gap-3 text-center">
<PiggyMark className="h-12 w-12 text-fg" title="pig" />
<div>
<h1 className="text-xl font-semibold tracking-tight">Set up your profile</h1>
<p className="mt-1 text-sm text-muted">
You're already authenticated. This creates a PIG profile only after workspace access is verified.
<AuthShell>
<div className="auth-panel rounded-2xl border border-border/80 bg-surface/90 p-5 shadow-2xl backdrop-blur-xl sm:p-7 lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0 lg:shadow-none lg:backdrop-blur-none">
<div className="flex flex-col gap-2">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
Private workspace
</p>
<h1 className="text-3xl font-medium tracking-[-0.035em] sm:text-4xl">
Set up your profile
</h1>
<p className="text-sm leading-6 text-muted">
You're already authenticated. This creates a PIG profile only after workspace access is
verified.
</p>
</div>
</div>
<Card>
<CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4">
<form onSubmit={submit} className="mt-6 flex flex-col gap-4">
<label className="block" htmlFor="profile-name">
<span className="mb-1 block text-sm font-medium">Your name</span>
<Input
@@ -94,7 +95,7 @@ export function CreateProfile({
<fieldset>
<legend className="mb-2 text-sm font-medium">Which team are you on?</legend>
<div className="space-y-2">
<div className="flex flex-col gap-2">
{TEAMS.map((value) => (
<label
key={value}
@@ -115,16 +116,12 @@ export function CreateProfile({
/>
<span className="min-w-0">
<span className="block text-sm font-medium">{TEAM_LABELS[value]}</span>
<span className="block text-xs text-muted">
{TEAM_DESCRIPTIONS[value]}
</span>
<span className="block text-xs text-muted">{TEAM_DESCRIPTIONS[value]}</span>
</span>
</label>
))}
</div>
<p className="mt-2 text-xs text-muted">
You can be added to more teams later.
</p>
<p className="mt-2 text-xs text-muted">You can be added to more teams later.</p>
</fieldset>
{config.inviteRequired ? (
@@ -146,15 +143,17 @@ export function CreateProfile({
</label>
) : null}
{error ? <p className="text-sm text-danger" role="alert">{error}</p> : null}
{error ? (
<p className="text-sm text-danger" role="alert">
{error}
</p>
) : null}
<Button type="submit" variant="primary" className="w-full" disabled={busy}>
{busy ? 'Creating' : 'Join the workspace'}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
</AuthShell>
);
}
+2 -17
View File
@@ -55,8 +55,7 @@ import { usePageTitle } from '@/lib/title';
import { usePiggyContext } from '@/lib/piggy-context';
import { toPiggyPageRoute } from '@pig/core';
import { Badge, Button, Card, EmptyState, Skeleton } from '@/components/ui';
import { AudioControl } from '@/components/AudioControl';
import { PiggyMark } from '@/components/PiggyMark';
import { PublicHeader } from '@/components/PublicHeader';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AddResourceDialog } from '@/components/learn/AddResourceDialog';
import { LearnAccessHero } from '@/components/learn/LearnAccessHero';
@@ -414,21 +413,7 @@ function CodeHolderView({
function AnonFrame({ children }: { children: ReactNode }) {
return (
<div className="app-canvas flex min-h-dvh min-w-0 flex-col">
<header className="mx-auto flex w-full min-w-0 max-w-5xl items-center justify-between gap-3 px-4 py-4 sm:px-6">
<span className="flex min-w-0 items-center gap-2">
<PiggyMark className="size-7 shrink-0 text-fg" aria-hidden />
<span className="min-w-0 truncate font-semibold tracking-tight">pig</span>
</span>
<span className="flex shrink-0 items-center gap-1">
{/* The anonymous page renders outside Shell, so it does not get the
app header — but it DOES get the music, and music with no way to
stop it is the worst version of this feature. */}
<AudioControl />
<Button variant="ghost" size="sm" className="shrink-0" asChild>
<a href="/">Sign in</a>
</Button>
</span>
</header>
<PublicHeader current="learn" />
<main className="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-10 px-4 pb-14 pt-2 sm:px-6 sm:pb-20">
{children}
</main>
+41 -40
View File
@@ -11,8 +11,8 @@ import { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core';
import { ApiError, getSupabase, post, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark';
import { Button, Input } from '@/components/ui';
import { AuthShell } from '@/components/AuthShell';
import { usePageTitle } from '@/lib/title';
export function Register({
@@ -66,9 +66,7 @@ export function Register({
}
onRegistered();
} catch (err) {
setError(
err instanceof ApiError ? err.message : 'Could not create your account. Try again.',
);
setError(err instanceof ApiError ? err.message : 'Could not create your account. Try again.');
} finally {
setBusy(false);
}
@@ -76,41 +74,41 @@ export function Register({
if (!config.canSelfRegister) {
return (
<Centered>
<Card>
<CardContent className="space-y-3 pt-5 text-center">
<AuthShell onSignIn={onBack}>
<div className="auth-panel flex flex-col gap-4 rounded-2xl border border-border/80 bg-surface/90 p-6 text-center shadow-2xl backdrop-blur-xl sm:p-8 lg:rounded-none lg:border-0 lg:bg-transparent lg:shadow-none lg:backdrop-blur-none">
<p className="font-medium">Registration is not open here</p>
<p className="text-sm text-muted">
This deployment cannot create accounts on its own. Ask an administrator to
provision one for you, then sign in.
This deployment cannot create accounts on its own. Ask an administrator to provision one
for you, then sign in.
</p>
<Button variant="outline" onClick={onBack} className="w-full">
Back to sign in
</Button>
</CardContent>
</Card>
</Centered>
</div>
</AuthShell>
);
}
return (
<Centered>
<div className="mb-6 flex flex-col items-center gap-3 text-center">
<PiggyMark className="h-12 w-12 text-fg" title="pig" />
<div>
<h1 className="text-xl font-semibold tracking-tight">Create your account</h1>
<p className="mt-1 text-sm text-muted">
Use an email you control. A valid PIG invite is required before the server creates workspace access.
<AuthShell onSignIn={onBack}>
<div className="auth-panel rounded-2xl border border-border/80 bg-surface/90 p-5 shadow-2xl backdrop-blur-xl sm:p-7 lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0 lg:shadow-none lg:backdrop-blur-none">
<div className="flex flex-col gap-2">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
Private workspace
</p>
<p className="mt-2 text-xs text-muted">
<h1 className="text-3xl font-medium tracking-[-0.035em] sm:text-4xl">
Create your account
</h1>
<p className="text-sm leading-6 text-muted">
Use an email you control. A valid PIG invite is required before the server creates
workspace access.
</p>
<p className="text-xs leading-5 text-muted">
This flow does not enable open registration on the shared identity provider.
</p>
</div>
</div>
<Card>
<CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4">
<form onSubmit={submit} className="mt-6 flex flex-col gap-4">
<label className="block" htmlFor="register-invite-code">
<span className="mb-1 block text-sm font-medium">Invite code</span>
<Input
@@ -128,7 +126,14 @@ export function Register({
<label className="block" htmlFor="register-name">
<span className="mb-1 block text-sm font-medium">Your name</span>
<Input id="register-name" name="name" required value={form.name} onChange={set('name')} autoComplete="name" />
<Input
id="register-name"
name="name"
required
value={form.name}
onChange={set('name')}
autoComplete="name"
/>
</label>
<label className="block" htmlFor="register-email">
@@ -169,12 +174,18 @@ export function Register({
<span className="mb-1 block text-sm font-medium">
Title <span className="font-normal text-muted">(optional)</span>
</span>
<Input id="register-title" name="title" value={form.title} onChange={set('title')} placeholder="Head of Growth" />
<Input
id="register-title"
name="title"
value={form.title}
onChange={set('title')}
placeholder="Head of Growth"
/>
</label>
<fieldset>
<legend className="mb-2 text-sm font-medium">Which team are you on?</legend>
<div className="space-y-2">
<div className="flex flex-col gap-2">
{TEAMS.map((value) => (
<label
key={value}
@@ -216,20 +227,10 @@ export function Register({
onClick={onBack}
className="tap flex w-full items-center justify-center gap-1.5 text-sm font-medium text-muted"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />
I already have an account
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />I already have an account
</button>
</form>
</CardContent>
</Card>
</Centered>
);
}
function Centered({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-dvh items-center justify-center bg-bg px-6 py-12">
<div className="w-full max-w-sm">{children}</div>
</div>
</AuthShell>
);
}
+37 -42
View File
@@ -12,10 +12,10 @@
* mistakes.
*/
import { useState } from 'react';
import { KeyRound, Mail } from 'lucide-react';
import { Mail } from 'lucide-react';
import { getSupabase, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark';
import { Button, Input } from '@/components/ui';
import { AuthShell } from '@/components/AuthShell';
import { usePageTitle } from '@/lib/title';
type Method = 'password' | 'link';
@@ -75,26 +75,17 @@ export function SignIn({
}
return (
<div className="flex min-h-dvh items-center justify-center bg-bg px-6 py-12">
<div className="w-full max-w-sm">
<div className="mb-8 flex flex-col items-center gap-3 text-center">
<PiggyMark className="h-14 w-14 text-fg" title="pig" />
<div>
<h1 className="text-2xl font-semibold lowercase tracking-tight">pig</h1>
<p className="text-sm text-muted">Prime Intellect Growth</p>
</div>
</div>
<Card>
<CardContent className="pt-5">
<AuthShell>
<div className="auth-panel rounded-2xl border border-border/80 bg-surface/90 p-5 shadow-2xl backdrop-blur-xl sm:p-7 lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0 lg:shadow-none lg:backdrop-blur-none">
{status === 'sent' ? (
<div className="space-y-3 text-center">
<Mail className="mx-auto h-8 w-8 text-accent-fg" aria-hidden />
<div>
<p className="font-medium">Check your email</p>
<p className="mt-1 text-sm text-muted">
A sign-in link is on its way to {email}. It expires shortly, so use it
soon.
<div className="flex flex-col items-center gap-4 py-4 text-center sm:py-8">
<span className="flex size-12 items-center justify-center rounded-full border border-border bg-surface-2">
<Mail className="size-5 text-accent-fg" aria-hidden />
</span>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Check your email</h1>
<p className="max-w-sm text-sm leading-6 text-muted">
A sign-in link is on its way to {email}. It expires shortly, so use it soon.
</p>
</div>
<button
@@ -107,24 +98,27 @@ export function SignIn({
</div>
) : (
<>
<div className="mb-5">
<div className="flex flex-col gap-2">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
Private workspace
</p>
<h2 className="mt-1 text-lg font-semibold">Sign in to PIG</h2>
<p className="mt-1 text-sm text-muted">
Authentication stays with the deployment's identity provider. PIG never stores your password.
<h1 className="text-3xl font-medium tracking-[-0.035em] sm:text-4xl">
Sign in to PIG
</h1>
<p className="max-w-md text-sm leading-6 text-muted sm:text-[0.9375rem]">
Authentication stays with the deployment's identity provider. PIG never stores your
password.
</p>
</div>
<div
role="tablist"
aria-label="Sign-in method"
className="mb-4 inline-flex w-full rounded-lg bg-surface-2 p-1"
className="mt-6 flex w-full border-b border-border"
>
{(
[
{ key: 'password', label: 'Password', icon: KeyRound },
{ key: 'link', label: 'Email link', icon: Mail },
{ key: 'password', label: 'Password' },
{ key: 'link', label: 'Email link' },
] as const
).map((option) => (
<button
@@ -138,17 +132,18 @@ export function SignIn({
setMessage('');
}}
className={[
'tap flex flex-1 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors',
method === option.key ? 'bg-surface text-fg shadow-sm' : 'text-muted',
'tap relative flex flex-1 items-center justify-center px-3 text-sm font-medium transition-colors',
method === option.key
? 'text-fg after:absolute after:inset-x-3 after:-bottom-px after:h-px after:bg-primary'
: 'text-muted hover:text-fg',
].join(' ')}
>
<option.icon className="h-4 w-4" aria-hidden />
{option.label}
</button>
))}
</div>
<form onSubmit={submit} className="space-y-3">
<form onSubmit={submit} className="mt-5 flex flex-col gap-4">
<label className="block" htmlFor="sign-in-email">
<span className="mb-1 block text-sm font-medium">Email</span>
<Input
@@ -186,7 +181,7 @@ export function SignIn({
<Button
type="submit"
variant="primary"
className="w-full"
className="mt-1 w-full"
disabled={status === 'busy'}
>
{status === 'busy'
@@ -205,29 +200,29 @@ export function SignIn({
) : null}
{config.canSelfRegister ? (
<div className="border-t border-border pt-3 text-center">
<p className="text-sm text-muted">
Have a PIG invite code but no account? Registration remains closed on the shared identity provider.
<div className="mt-1 border-t border-border pt-4">
<p className="text-sm leading-6 text-muted">
Have a PIG invite code but no account? Registration remains closed on the shared
identity provider.
</p>
<button
type="button"
onClick={onCreateAccount}
className="tap mt-1 text-sm font-medium text-accent-fg"
className="tap -ml-2 inline-flex items-center px-2 text-sm font-medium text-accent-fg"
>
Create an account
</button>
</div>
) : config.inviteRequired ? (
<p className="pt-1 text-center text-xs text-muted">
This workspace is invite-only. Ask an administrator to provision access; this screen never opens self-registration.
This workspace is invite-only. Ask an administrator to provision access; this
screen never opens self-registration.
</p>
) : null}
</form>
</>
)}
</CardContent>
</Card>
</div>
</div>
</AuthShell>
);
}