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 { LayoutProvider } from '@/lib/layout';
import { PiggyContextProvider } from '@/lib/piggy-context'; import { PiggyContextProvider } from '@/lib/piggy-context';
import { PlatformAudioProvider } from '@/lib/audio'; import { PlatformAudioProvider } from '@/lib/audio';
import { AudioControl } from '@/components/AudioControl';
import { Shell } from '@/components/Shell'; import { Shell } from '@/components/Shell';
import { SignIn } from '@/pages/SignIn'; import { SignIn } from '@/pages/SignIn';
import { CreateProfile } from '@/pages/CreateProfile'; 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 * The signed-out screens render outside Shell. Their shared AuthShell supplies
* therefore no way to stop the music. Audio a visitor cannot switch off is the * the public header and its music control; keeping this boundary component
* worst version of this feature, so every unauthenticated screen gets the * means the auth gate does not need to know anything about that presentation.
* control pinned in a corner.
* *
* Learn is not wrapped: it brings its own chrome and already hosts one. * Learn is not wrapped: it brings its own chrome and already hosts one.
*/ */
function SignedOut({ children }: { children: React.ReactNode }) { function SignedOut({ children }: { children: React.ReactNode }) {
return ( return children;
<>
{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>
</>
);
} }
/** /**
+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; 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 /* Tabular figures keep money and percentages from jittering as they
update, which matters on a dashboard that refreshes. */ update, which matters on a dashboard that refreshes. */
.nums { .nums {
+24 -25
View File
@@ -9,8 +9,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core'; import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core';
import { ApiError, post, type PublicConfig } from '@/lib/api'; import { ApiError, post, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui'; import { Button, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark'; import { AuthShell } from '@/components/AuthShell';
import { usePageTitle } from '@/lib/title'; import { usePageTitle } from '@/lib/title';
export function CreateProfile({ export function CreateProfile({
@@ -51,21 +51,22 @@ export function CreateProfile({
} }
return ( return (
<div className="flex min-h-dvh items-center justify-center bg-bg px-6 py-12"> <AuthShell>
<div className="w-full max-w-md"> <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="mb-6 flex flex-col items-center gap-3 text-center"> <div className="flex flex-col gap-2">
<PiggyMark className="h-12 w-12 text-fg" title="pig" /> <p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
<div> Private workspace
<h1 className="text-xl font-semibold tracking-tight">Set up your profile</h1> </p>
<p className="mt-1 text-sm text-muted"> <h1 className="text-3xl font-medium tracking-[-0.035em] sm:text-4xl">
You're already authenticated. This creates a PIG profile only after workspace access is verified. 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> </p>
</div> </div>
</div>
<Card> <form onSubmit={submit} className="mt-6 flex flex-col gap-4">
<CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4">
<label className="block" htmlFor="profile-name"> <label className="block" htmlFor="profile-name">
<span className="mb-1 block text-sm font-medium">Your name</span> <span className="mb-1 block text-sm font-medium">Your name</span>
<Input <Input
@@ -94,7 +95,7 @@ export function CreateProfile({
<fieldset> <fieldset>
<legend className="mb-2 text-sm font-medium">Which team are you on?</legend> <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) => ( {TEAMS.map((value) => (
<label <label
key={value} key={value}
@@ -115,16 +116,12 @@ export function CreateProfile({
/> />
<span className="min-w-0"> <span className="min-w-0">
<span className="block text-sm font-medium">{TEAM_LABELS[value]}</span> <span className="block text-sm font-medium">{TEAM_LABELS[value]}</span>
<span className="block text-xs text-muted"> <span className="block text-xs text-muted">{TEAM_DESCRIPTIONS[value]}</span>
{TEAM_DESCRIPTIONS[value]}
</span>
</span> </span>
</label> </label>
))} ))}
</div> </div>
<p className="mt-2 text-xs text-muted"> <p className="mt-2 text-xs text-muted">You can be added to more teams later.</p>
You can be added to more teams later.
</p>
</fieldset> </fieldset>
{config.inviteRequired ? ( {config.inviteRequired ? (
@@ -146,15 +143,17 @@ export function CreateProfile({
</label> </label>
) : null} ) : 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}> <Button type="submit" variant="primary" className="w-full" disabled={busy}>
{busy ? 'Creating' : 'Join the workspace'} {busy ? 'Creating' : 'Join the workspace'}
</Button> </Button>
</form> </form>
</CardContent>
</Card>
</div>
</div> </div>
</AuthShell>
); );
} }
+2 -17
View File
@@ -55,8 +55,7 @@ import { usePageTitle } from '@/lib/title';
import { usePiggyContext } from '@/lib/piggy-context'; import { usePiggyContext } from '@/lib/piggy-context';
import { toPiggyPageRoute } from '@pig/core'; import { toPiggyPageRoute } from '@pig/core';
import { Badge, Button, Card, EmptyState, Skeleton } from '@/components/ui'; import { Badge, Button, Card, EmptyState, Skeleton } from '@/components/ui';
import { AudioControl } from '@/components/AudioControl'; import { PublicHeader } from '@/components/PublicHeader';
import { PiggyMark } from '@/components/PiggyMark';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AddResourceDialog } from '@/components/learn/AddResourceDialog'; import { AddResourceDialog } from '@/components/learn/AddResourceDialog';
import { LearnAccessHero } from '@/components/learn/LearnAccessHero'; import { LearnAccessHero } from '@/components/learn/LearnAccessHero';
@@ -414,21 +413,7 @@ function CodeHolderView({
function AnonFrame({ children }: { children: ReactNode }) { function AnonFrame({ children }: { children: ReactNode }) {
return ( return (
<div className="app-canvas flex min-h-dvh min-w-0 flex-col"> <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"> <PublicHeader current="learn" />
<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>
<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"> <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} {children}
</main> </main>
+41 -40
View File
@@ -11,8 +11,8 @@ import { useState } from 'react';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core'; import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core';
import { ApiError, getSupabase, post, type PublicConfig } from '@/lib/api'; import { ApiError, getSupabase, post, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui'; import { Button, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark'; import { AuthShell } from '@/components/AuthShell';
import { usePageTitle } from '@/lib/title'; import { usePageTitle } from '@/lib/title';
export function Register({ export function Register({
@@ -66,9 +66,7 @@ export function Register({
} }
onRegistered(); onRegistered();
} catch (err) { } catch (err) {
setError( setError(err instanceof ApiError ? err.message : 'Could not create your account. Try again.');
err instanceof ApiError ? err.message : 'Could not create your account. Try again.',
);
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -76,41 +74,41 @@ export function Register({
if (!config.canSelfRegister) { if (!config.canSelfRegister) {
return ( return (
<Centered> <AuthShell onSignIn={onBack}>
<Card> <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">
<CardContent className="space-y-3 pt-5 text-center">
<p className="font-medium">Registration is not open here</p> <p className="font-medium">Registration is not open here</p>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
This deployment cannot create accounts on its own. Ask an administrator to This deployment cannot create accounts on its own. Ask an administrator to provision one
provision one for you, then sign in. for you, then sign in.
</p> </p>
<Button variant="outline" onClick={onBack} className="w-full"> <Button variant="outline" onClick={onBack} className="w-full">
Back to sign in Back to sign in
</Button> </Button>
</CardContent> </div>
</Card> </AuthShell>
</Centered>
); );
} }
return ( return (
<Centered> <AuthShell onSignIn={onBack}>
<div className="mb-6 flex flex-col items-center gap-3 text-center"> <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">
<PiggyMark className="h-12 w-12 text-fg" title="pig" /> <div className="flex flex-col gap-2">
<div> <p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
<h1 className="text-xl font-semibold tracking-tight">Create your account</h1> Private workspace
<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.
</p> </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. This flow does not enable open registration on the shared identity provider.
</p> </p>
</div> </div>
</div>
<Card> <form onSubmit={submit} className="mt-6 flex flex-col gap-4">
<CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4">
<label className="block" htmlFor="register-invite-code"> <label className="block" htmlFor="register-invite-code">
<span className="mb-1 block text-sm font-medium">Invite code</span> <span className="mb-1 block text-sm font-medium">Invite code</span>
<Input <Input
@@ -128,7 +126,14 @@ export function Register({
<label className="block" htmlFor="register-name"> <label className="block" htmlFor="register-name">
<span className="mb-1 block text-sm font-medium">Your name</span> <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>
<label className="block" htmlFor="register-email"> <label className="block" htmlFor="register-email">
@@ -169,12 +174,18 @@ export function Register({
<span className="mb-1 block text-sm font-medium"> <span className="mb-1 block text-sm font-medium">
Title <span className="font-normal text-muted">(optional)</span> Title <span className="font-normal text-muted">(optional)</span>
</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> </label>
<fieldset> <fieldset>
<legend className="mb-2 text-sm font-medium">Which team are you on?</legend> <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) => ( {TEAMS.map((value) => (
<label <label
key={value} key={value}
@@ -216,20 +227,10 @@ export function Register({
onClick={onBack} onClick={onBack}
className="tap flex w-full items-center justify-center gap-1.5 text-sm font-medium text-muted" 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 /> <ArrowLeft className="h-3.5 w-3.5" aria-hidden />I already have an account
I already have an account
</button> </button>
</form> </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> </div>
</AuthShell>
); );
} }
+37 -42
View File
@@ -12,10 +12,10 @@
* mistakes. * mistakes.
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { KeyRound, Mail } from 'lucide-react'; import { Mail } from 'lucide-react';
import { getSupabase, type PublicConfig } from '@/lib/api'; import { getSupabase, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui'; import { Button, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark'; import { AuthShell } from '@/components/AuthShell';
import { usePageTitle } from '@/lib/title'; import { usePageTitle } from '@/lib/title';
type Method = 'password' | 'link'; type Method = 'password' | 'link';
@@ -75,26 +75,17 @@ export function SignIn({
} }
return ( return (
<div className="flex min-h-dvh items-center justify-center bg-bg px-6 py-12"> <AuthShell>
<div className="w-full max-w-sm"> <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="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">
{status === 'sent' ? ( {status === 'sent' ? (
<div className="space-y-3 text-center"> <div className="flex flex-col items-center gap-4 py-4 text-center sm:py-8">
<Mail className="mx-auto h-8 w-8 text-accent-fg" aria-hidden /> <span className="flex size-12 items-center justify-center rounded-full border border-border bg-surface-2">
<div> <Mail className="size-5 text-accent-fg" aria-hidden />
<p className="font-medium">Check your email</p> </span>
<p className="mt-1 text-sm text-muted"> <div className="flex flex-col gap-1">
A sign-in link is on its way to {email}. It expires shortly, so use it <h1 className="text-2xl font-semibold tracking-tight">Check your email</h1>
soon. <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> </p>
</div> </div>
<button <button
@@ -107,24 +98,27 @@ export function SignIn({
</div> </div>
) : ( ) : (
<> <>
<div className="mb-5"> <div className="flex flex-col gap-2">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted"> <p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
Private workspace Private workspace
</p> </p>
<h2 className="mt-1 text-lg font-semibold">Sign in to PIG</h2> <h1 className="text-3xl font-medium tracking-[-0.035em] sm:text-4xl">
<p className="mt-1 text-sm text-muted"> Sign in to PIG
Authentication stays with the deployment's identity provider. PIG never stores your password. </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> </p>
</div> </div>
<div <div
role="tablist" role="tablist"
aria-label="Sign-in method" 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: 'password', label: 'Password' },
{ key: 'link', label: 'Email link', icon: Mail }, { key: 'link', label: 'Email link' },
] as const ] as const
).map((option) => ( ).map((option) => (
<button <button
@@ -138,17 +132,18 @@ export function SignIn({
setMessage(''); setMessage('');
}} }}
className={[ className={[
'tap flex flex-1 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors', 'tap relative flex flex-1 items-center justify-center px-3 text-sm font-medium transition-colors',
method === option.key ? 'bg-surface text-fg shadow-sm' : 'text-muted', 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(' ')} ].join(' ')}
> >
<option.icon className="h-4 w-4" aria-hidden />
{option.label} {option.label}
</button> </button>
))} ))}
</div> </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"> <label className="block" htmlFor="sign-in-email">
<span className="mb-1 block text-sm font-medium">Email</span> <span className="mb-1 block text-sm font-medium">Email</span>
<Input <Input
@@ -186,7 +181,7 @@ export function SignIn({
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
className="w-full" className="mt-1 w-full"
disabled={status === 'busy'} disabled={status === 'busy'}
> >
{status === 'busy' {status === 'busy'
@@ -205,29 +200,29 @@ export function SignIn({
) : null} ) : null}
{config.canSelfRegister ? ( {config.canSelfRegister ? (
<div className="border-t border-border pt-3 text-center"> <div className="mt-1 border-t border-border pt-4">
<p className="text-sm text-muted"> <p className="text-sm leading-6 text-muted">
Have a PIG invite code but no account? Registration remains closed on the shared identity provider. Have a PIG invite code but no account? Registration remains closed on the shared
identity provider.
</p> </p>
<button <button
type="button" type="button"
onClick={onCreateAccount} 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 Create an account
</button> </button>
</div> </div>
) : config.inviteRequired ? ( ) : config.inviteRequired ? (
<p className="pt-1 text-center text-xs text-muted"> <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> </p>
) : null} ) : null}
</form> </form>
</> </>
)} )}
</CardContent>
</Card>
</div>
</div> </div>
</AuthShell>
); );
} }