style(auth): migrate auth + language switcher to semantic color tokens

Swaps hardcoded brand-/white-/neutral- utilities for the accent / surface
/ fg / line / fg-muted tokens so light-mode and theme overrides behave
correctly. Also moves focus rings from `focus:` to `focus-visible:`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-04-22 23:03:24 +02:00
parent 665f450878
commit 12bb585081
3 changed files with 190 additions and 141 deletions
@@ -15,8 +15,8 @@ export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
role="group" role="group"
aria-label="Language" aria-label="Language"
className={ className={
'inline-flex items-center rounded-full border border-white/10 bg-white/5 p-0.5 text-[11px] font-medium ' + 'inline-flex items-center rounded-full border border-line bg-surface-2 p-0.5 text-[11px] font-medium ' +
(compact ? '' : 'backdrop-blur') (compact ? '' : '')
} }
> >
{SUPPORTED_LOCALES.map((locale) => { {SUPPORTED_LOCALES.map((locale) => {
@@ -30,10 +30,10 @@ export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
if (!active) void changeLocale(locale); if (!active) void changeLocale(locale);
}} }}
className={ className={
'cursor-pointer rounded-full px-2.5 py-1 transition focus:outline-none focus:ring-2 focus:ring-brand-400/40 ' + 'cursor-pointer rounded-full px-2.5 py-1 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active (active
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40' ? 'bg-accent/15 text-accent ring-1 ring-accent/30'
: 'text-neutral-400 hover:text-neutral-200') : 'text-fg-muted hover:text-fg')
} }
> >
{LABELS[locale]} {LABELS[locale]}
+5 -5
View File
@@ -29,15 +29,15 @@ export function AuthCallbackPage() {
if (state.kind === 'done') return <Navigate to="/chats" replace />; if (state.kind === 'done') return <Navigate to="/chats" replace />;
return ( return (
<main className="flex min-h-screen items-center justify-center bg-ink-950 p-6"> <main className="flex min-h-screen items-center justify-center bg-surface p-6 text-fg">
{state.kind === 'pending' ? ( {state.kind === 'pending' ? (
<div className="flex items-center gap-3 text-neutral-400"> <div className="flex items-center gap-3 text-fg-muted">
<SpinnerIcon className="h-5 w-5 text-brand-400" /> <SpinnerIcon className="h-5 w-5 text-accent" />
<span className="text-sm font-medium">{t('common:finalising_session')}</span> <span className="text-sm font-medium">{t('common:finalising_session')}</span>
</div> </div>
) : ( ) : (
<div className="flex max-w-md items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-4 text-sm text-rose-100"> <div className="flex max-w-md items-start gap-3 rounded-lg border border-rose-500/25 bg-rose-500/10 p-4 text-sm text-rose-800 dark:text-rose-100">
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" /> <AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-600 dark:text-rose-400" />
<p className="min-w-0 flex-1 break-words">{state.message}</p> <p className="min-w-0 flex-1 break-words">{state.message}</p>
</div> </div>
)} )}
+180 -131
View File
@@ -84,112 +84,109 @@ export function AuthPage() {
[mode, email, username, inviteCode, i18n, t], [mode, email, username, inviteCode, i18n, t],
); );
// Already signed in? Bounce to chats. Guards take it from here.
if (session) return <Navigate to="/chats" replace />; if (session) return <Navigate to="/chats" replace />;
return ( return (
<Shell> <main className="relative min-h-screen overflow-hidden bg-surface text-fg">
<BrandPanel /> <ShellBackground />
<FormCard <div className="relative z-10 grid min-h-screen grid-cols-1 lg:grid-cols-[1fr_minmax(440px,520px)]">
mode={mode} <BrandSection />
onModeChange={setMode} <FormSection
email={email} mode={mode}
onEmailChange={setEmail} onModeChange={setMode}
username={username} email={email}
onUsernameChange={setUsername} onEmailChange={setEmail}
usernameValid={usernameValid} username={username}
inviteCode={inviteCode} onUsernameChange={setUsername}
onInviteChange={setInviteCode} usernameValid={usernameValid}
ui={ui} inviteCode={inviteCode}
onSubmit={handleSubmit} onInviteChange={setInviteCode}
/> ui={ui}
</Shell> onSubmit={handleSubmit}
); />
}
// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------
function Shell({ children }: { children: React.ReactNode }) {
return (
<main className="relative min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
<BackgroundStage />
<div className="relative z-10 grid min-h-screen w-full grid-cols-1 gap-0 lg:grid-cols-[minmax(0,1fr)_minmax(440px,560px)]">
{children}
</div> </div>
</main> </main>
); );
} }
function BackgroundStage() { // ---------------------------------------------------------------------------
// Shell background — ambient blobs spread across the full viewport. Anchored
// to percentages so they stay in the same relative position regardless of
// monitor width (works as well on 1440 as on 3440 ultrawide).
// ---------------------------------------------------------------------------
function ShellBackground() {
return ( return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0"> <div aria-hidden="true" className="pointer-events-none absolute inset-0 hidden dark:block">
<div className="bg-grid absolute inset-0 opacity-[0.28]" /> <div className="bg-grid absolute inset-0 opacity-[0.12]" />
<div className="absolute -left-40 top-[22%] h-[560px] w-[560px] -translate-y-1/2 rounded-full bg-brand-500/30 blur-3xl animate-blob-a xl:h-[680px] xl:w-[680px] 2xl:h-[820px] 2xl:w-[820px]" /> <div className="absolute left-[15%] top-[18%] h-[520px] w-[520px] -translate-x-1/2 rounded-full bg-accent/25 blur-3xl" />
<div className="absolute left-[38%] top-[60%] h-[520px] w-[520px] -translate-y-1/2 rounded-full bg-fuchsia-500/20 blur-3xl animate-blob-b xl:h-[640px] xl:w-[640px] 2xl:h-[780px] 2xl:w-[780px]" /> <div className="absolute left-[35%] top-[70%] h-[480px] w-[480px] -translate-x-1/2 rounded-full bg-fuchsia-500/15 blur-3xl" />
<div className="absolute -right-32 top-[12%] h-[420px] w-[420px] rounded-full bg-indigo-500/20 blur-3xl animate-blob-b xl:h-[520px] xl:w-[520px]" /> <div className="absolute left-[60%] top-[30%] h-[440px] w-[440px] -translate-x-1/2 rounded-full bg-indigo-500/15 blur-3xl" />
<div className="absolute -right-20 bottom-0 h-[460px] w-[460px] rounded-full bg-rose-500/10 blur-3xl animate-blob-a xl:h-[560px] xl:w-[560px]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_40%,rgba(5,5,7,0.45)_100%)]" />
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-ink-950/80" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_40%,rgba(5,5,7,0.65)_100%)]" />
</div> </div>
); );
} }
function BrandPanel() { // ---------------------------------------------------------------------------
// Brand section — edge-to-edge panel on the left. Inner content constrained
// so it stays readable on ultrawide screens; ambient background fills the rest.
// ---------------------------------------------------------------------------
function BrandSection() {
const { t } = useTranslation(['auth', 'common']); const { t } = useTranslation(['auth', 'common']);
return ( return (
<section className="relative hidden lg:block"> <section className="relative hidden lg:flex">
<div className="grid h-full grid-rows-[auto_1fr_auto] px-10 py-10 xl:px-14 xl:py-14 2xl:px-20 2xl:py-16"> <DecorativeBubbles />
<header className="flex items-center justify-between gap-3"> <div className="relative mx-auto grid h-full w-full max-w-2xl grid-rows-[auto_1fr_auto] gap-8 px-10 py-10 xl:max-w-3xl xl:px-14 xl:py-14">
<div className="flex items-center gap-3"> <header className="flex items-center gap-3">
<LogoMark className="h-9 w-9" /> <LogoMark className="h-9 w-9" />
<span className="font-display text-lg font-semibold tracking-tight"> <span className="font-display text-lg font-semibold tracking-tight text-fg">
{t('common:app_name')} {t('common:app_name')}
</span> </span>
</div>
<LanguageSwitcher />
</header> </header>
<div className="flex items-center"> <div className="flex flex-col justify-center">
<div className="w-full max-w-xl animate-fade-in xl:max-w-2xl 2xl:max-w-3xl"> <span className="inline-flex w-fit items-center gap-2 rounded-full border border-line bg-surface-2/70 px-3 py-1 text-xs font-medium text-fg-muted backdrop-blur">
<p className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-neutral-300 backdrop-blur"> <ShieldIcon className="h-3.5 w-3.5 text-emerald-500 dark:text-emerald-400" />
<ShieldIcon className="h-3.5 w-3.5 text-emerald-400" /> {t('auth:brand.badge')}
{t('auth:brand.badge')} </span>
</p>
<h1 className="mt-6 font-display text-4xl font-semibold leading-[1.05] tracking-tight text-white xl:text-5xl 2xl:text-6xl">
{t('auth:brand.title_line_1')}
<br />
{t('auth:brand.title_line_2')}
</h1>
<p className="mt-5 max-w-lg text-base leading-relaxed text-neutral-400 xl:text-lg 2xl:max-w-xl">
{t('auth:brand.subtitle')}
</p>
<dl className="mt-10 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:mt-12 xl:gap-5 2xl:grid-cols-3"> <h1 className="mt-6 font-display text-4xl font-semibold leading-[1.05] tracking-tight text-fg xl:text-5xl 2xl:text-6xl">
<Feature {t('auth:brand.title_line_1')}
icon={<LockIcon className="h-5 w-5 text-brand-300" />} <br />
title={t('auth:brand.feature_zk_title')} <span className="text-accent">{t('auth:brand.title_line_2')}</span>
desc={t('auth:brand.feature_zk_desc')} </h1>
/>
<Feature <p className="mt-5 max-w-xl text-base leading-relaxed text-fg-muted xl:text-lg">
icon={<SparklesIcon className="h-5 w-5 text-brand-300" />} {t('auth:brand.subtitle')}
title={t('auth:brand.feature_selfhost_title')} </p>
desc={t('auth:brand.feature_selfhost_desc')}
/> <ul className="mt-10 grid max-w-2xl gap-3 sm:grid-cols-1 xl:mt-12 xl:gap-4">
<Feature <FeatureRow
icon={<ShieldIcon className="h-5 w-5 text-brand-300" />} icon={<LockIcon className="h-4 w-4" />}
title={t('auth:brand.feature_invite_title')} title={t('auth:brand.feature_zk_title')}
desc={t('auth:brand.feature_invite_desc')} desc={t('auth:brand.feature_zk_desc')}
/> />
</dl> <FeatureRow
</div> icon={<SparklesIcon className="h-4 w-4" />}
title={t('auth:brand.feature_selfhost_title')}
desc={t('auth:brand.feature_selfhost_desc')}
/>
<FeatureRow
icon={<ShieldIcon className="h-4 w-4" />}
title={t('auth:brand.feature_invite_title')}
desc={t('auth:brand.feature_invite_desc')}
/>
</ul>
</div> </div>
<footer className="flex items-center justify-between text-xs text-neutral-500"> <footer className="flex items-center justify-between gap-3 text-xs text-fg-muted">
<span>v0.1.0 · {t('common:dev_build')}</span> <span className="font-mono">v0.1.0 · {t('common:dev_build')}</span>
<span className="inline-flex items-center gap-1.5 text-neutral-600"> <span className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" /> <span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-500 opacity-60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
{t('common:local_stack_online')} {t('common:local_stack_online')}
</span> </span>
</footer> </footer>
@@ -198,19 +195,49 @@ function BrandPanel() {
); );
} }
function Feature({ icon, title, desc }: { icon: React.ReactNode; title: string; desc: string }) { // Subtle chat-bubble silhouettes in the far background — gives the brand side
// product context without competing with the typographic content.
function DecorativeBubbles() {
return ( return (
<div className="rounded-xl border border-white/5 bg-white/5 p-4 backdrop-blur"> <div
<div className="flex items-center gap-2"> aria-hidden="true"
{icon} className="pointer-events-none absolute inset-0 hidden overflow-hidden opacity-60 xl:block"
<dt className="text-sm font-semibold text-white">{title}</dt> >
</div> <div className="absolute right-[8%] top-[22%] h-16 w-44 rounded-2xl rounded-bl-sm border border-line bg-surface-2/40 backdrop-blur-sm" />
<dd className="mt-1.5 text-sm text-neutral-400">{desc}</dd> <div className="absolute right-[18%] top-[42%] h-12 w-32 rounded-2xl rounded-br-sm border border-accent/20 bg-accent/10 backdrop-blur-sm" />
<div className="absolute right-[6%] top-[58%] h-14 w-40 rounded-2xl rounded-bl-sm border border-line bg-surface-2/40 backdrop-blur-sm" />
</div> </div>
); );
} }
interface FormCardProps { function FeatureRow({
icon,
title,
desc,
}: {
icon: React.ReactNode;
title: string;
desc: string;
}) {
return (
<li className="flex items-start gap-3">
<span className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/20">
{icon}
</span>
<div className="min-w-0 flex-1 pt-1">
<p className="text-sm font-semibold text-fg xl:text-base">{title}</p>
<p className="mt-0.5 text-xs leading-relaxed text-fg-muted xl:text-sm">{desc}</p>
</div>
</li>
);
}
// ---------------------------------------------------------------------------
// Form section — fixed-width panel on the right. Full viewport height,
// bg-surface-2 for visual distinction from the brand side.
// ---------------------------------------------------------------------------
interface FormSectionProps {
mode: Mode; mode: Mode;
onModeChange: (m: Mode) => void; onModeChange: (m: Mode) => void;
email: string; email: string;
@@ -224,7 +251,7 @@ interface FormCardProps {
onSubmit: (e: React.FormEvent) => void; onSubmit: (e: React.FormEvent) => void;
} }
function FormCard({ function FormSection({
mode, mode,
onModeChange, onModeChange,
email, email,
@@ -236,7 +263,7 @@ function FormCard({
onInviteChange, onInviteChange,
ui, ui,
onSubmit, onSubmit,
}: FormCardProps) { }: FormSectionProps) {
const { t } = useTranslation(['auth']); const { t } = useTranslation(['auth']);
const busy = ui.kind === 'sending'; const busy = ui.kind === 'sending';
const emailId = useId(); const emailId = useId();
@@ -249,22 +276,30 @@ function FormCard({
const ctaSendingKey = mode === 'signup' ? 'auth:signup.cta_sending' : 'auth:login.cta_sending'; const ctaSendingKey = mode === 'signup' ? 'auth:signup.cta_sending' : 'auth:login.cta_sending';
return ( return (
<section className="relative flex items-center justify-center px-5 py-10 sm:px-8 lg:px-10 xl:px-16"> <section className="relative flex flex-col border-t border-line bg-surface-2/80 backdrop-blur-xl lg:border-l lg:border-t-0">
<div className="absolute left-6 right-6 top-6 flex items-center justify-between lg:hidden"> {/* Mobile-only header strip */}
<div className="flex items-center justify-between border-b border-line px-5 py-4 lg:hidden">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<LogoMark className="h-7 w-7" /> <LogoMark className="h-7 w-7" />
<span className="font-display text-base font-semibold tracking-tight">ChatApp</span> <span className="font-display text-base font-semibold tracking-tight text-fg">
ChatApp
</span>
</div> </div>
<LanguageSwitcher compact /> <LanguageSwitcher compact />
</div> </div>
<div className="w-full max-w-md animate-slide-up"> {/* Desktop-only top strip — LanguageSwitcher in the corner */}
<div className="rounded-2xl border border-white/10 bg-ink-900/70 p-6 shadow-glow backdrop-blur-xl sm:p-8"> <div className="hidden items-center justify-end px-8 pt-8 lg:flex">
<LanguageSwitcher />
</div>
<div className="flex flex-1 items-center">
<div className="mx-auto w-full max-w-md px-6 py-8 sm:px-8 lg:px-10 lg:py-10">
<header className="mb-6"> <header className="mb-6">
<h2 className="font-display text-2xl font-semibold tracking-tight text-white"> <h2 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t(titleKey)} {t(titleKey)}
</h2> </h2>
<p className="mt-1.5 text-sm text-neutral-400">{t(subtitleKey)}</p> <p className="mt-1.5 text-sm text-fg-muted">{t(subtitleKey)}</p>
</header> </header>
<Segmented mode={mode} onChange={onModeChange} /> <Segmented mode={mode} onChange={onModeChange} />
@@ -285,7 +320,7 @@ function FormCard({
placeholder={t('auth:fields.email_placeholder')} placeholder={t('auth:fields.email_placeholder')}
value={email} value={email}
onChange={(e) => onEmailChange(e.target.value)} onChange={(e) => onEmailChange(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40" className={INPUT_CLASS}
/> />
} }
/> />
@@ -316,7 +351,7 @@ function FormCard({
value={username} value={username}
onChange={(e) => onUsernameChange(e.target.value)} onChange={(e) => onUsernameChange(e.target.value)}
pattern={USERNAME_PATTERN.source} pattern={USERNAME_PATTERN.source}
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40" className={INPUT_CLASS}
/> />
} }
/> />
@@ -336,7 +371,7 @@ function FormCard({
required required
value={inviteCode} value={inviteCode}
onChange={(e) => onInviteChange(e.target.value)} onChange={(e) => onInviteChange(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm font-mono text-white transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40" className={INPUT_CLASS + ' font-mono'}
/> />
} }
/> />
@@ -347,7 +382,7 @@ function FormCard({
type="submit" type="submit"
disabled={busy} disabled={busy}
aria-busy={busy} aria-busy={busy}
className="group inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60" className="group inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-accent-fg transition hover:bg-accent/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-2 disabled:cursor-not-allowed disabled:opacity-60"
> >
{busy ? ( {busy ? (
<> <>
@@ -367,25 +402,30 @@ function FormCard({
</form> </form>
<Footer mode={mode} onModeChange={onModeChange} /> <Footer mode={mode} onModeChange={onModeChange} />
</div>
<p className="mt-6 text-center text-xs text-neutral-500">{t('auth:legal_note')}</p> <p className="mt-6 text-center text-xs text-fg-muted">
{t('auth:legal_note')}
</p>
</div>
</div> </div>
</section> </section>
); );
} }
const INPUT_CLASS =
'w-full rounded-lg border border-line bg-surface-3 py-2.5 pl-10 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30';
function Segmented({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) { function Segmented({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
const { t } = useTranslation(['auth']); const { t } = useTranslation(['auth']);
return ( return (
<div <div
role="tablist" role="tablist"
aria-label="Authentication mode" aria-label="Authentication mode"
className="relative grid grid-cols-2 rounded-lg border border-white/10 bg-ink-800 p-1 text-sm" className="relative grid grid-cols-2 rounded-lg border border-line bg-surface-3 p-1 text-sm"
> >
<div <div
aria-hidden="true" aria-hidden="true"
className="absolute bottom-1 top-1 w-[calc(50%-4px)] rounded-md bg-brand-500/20 ring-1 ring-brand-400/40 transition-transform duration-200" className="absolute bottom-1 top-1 w-[calc(50%-4px)] rounded-md bg-accent/15 ring-1 ring-accent/30 transition-transform duration-200"
style={{ transform: 'translateX(' + (mode === 'signup' ? '0%' : 'calc(100% + 4px)') + ')' }} style={{ transform: 'translateX(' + (mode === 'signup' ? '0%' : 'calc(100% + 4px)') + ')' }}
/> />
<SegmentButton active={mode === 'signup'} onClick={() => onChange('signup')}> <SegmentButton active={mode === 'signup'} onClick={() => onChange('signup')}>
@@ -415,7 +455,7 @@ function SegmentButton({
onClick={onClick} onClick={onClick}
className={ className={
'relative z-10 cursor-pointer rounded-md px-3 py-2 font-medium transition focus:outline-none ' + 'relative z-10 cursor-pointer rounded-md px-3 py-2 font-medium transition focus:outline-none ' +
(active ? 'text-white' : 'text-neutral-400 hover:text-neutral-200') (active ? 'text-fg' : 'text-fg-muted hover:text-fg')
} }
> >
{children} {children}
@@ -440,17 +480,27 @@ function Field({
}) { }) {
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<label htmlFor={id} className="block text-xs font-medium uppercase tracking-wide text-neutral-400"> <label
htmlFor={id}
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted"
>
{label} {label}
</label> </label>
<div className="relative"> <div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500"> <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-fg-muted">
{icon} {icon}
</span> </span>
{input} {input}
</div> </div>
{hint && ( {hint && (
<p className={'text-xs ' + (invalid ? 'text-rose-400' : 'text-neutral-500')}>{hint}</p> <p
className={
'text-[11px] leading-snug ' +
(invalid ? 'text-rose-600 dark:text-rose-400' : 'text-fg-muted')
}
>
{hint}
</p>
)} )}
</div> </div>
); );
@@ -462,14 +512,14 @@ function StatusBanner({ ui }: { ui: UiState }) {
return ( return (
<div <div
role="status" role="status"
className="flex items-start gap-3 rounded-lg border border-emerald-500/20 bg-emerald-500/10 p-3.5 text-sm text-emerald-100" className="flex items-start gap-3 rounded-lg border border-emerald-500/25 bg-emerald-500/10 p-3.5 text-sm text-emerald-800 dark:text-emerald-100"
> >
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-400" /> <CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<p className="break-words font-medium"> <p className="break-words font-medium">
{t('auth:sent_banner', { email: ui.email })} {t('auth:sent_banner', { email: ui.email })}
</p> </p>
<p className="break-words text-xs text-emerald-200/80"> <p className="break-words text-xs text-emerald-700/90 dark:text-emerald-200/80">
<InbucketHint /> <InbucketHint />
</p> </p>
</div> </div>
@@ -481,9 +531,9 @@ function StatusBanner({ ui }: { ui: UiState }) {
return ( return (
<div <div
role="alert" role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3.5 text-sm text-rose-100" className="flex items-start gap-3 rounded-lg border border-rose-500/25 bg-rose-500/10 p-3.5 text-sm text-rose-800 dark:text-rose-100"
> >
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" /> <AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-600 dark:text-rose-400" />
<p className="min-w-0 flex-1 break-words">{ui.message}</p> <p className="min-w-0 flex-1 break-words">{ui.message}</p>
</div> </div>
); );
@@ -503,7 +553,7 @@ function InbucketHint() {
href="http://127.0.0.1:54324" href="http://127.0.0.1:54324"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="underline underline-offset-2 hover:text-white" className="underline underline-offset-2 hover:text-emerald-900 dark:hover:text-white"
> >
Inbucket Inbucket
</a> </a>
@@ -527,7 +577,6 @@ function OtpForm({ email }: { email: string }) {
setError(null); setError(null);
try { try {
await verifyMagicLinkOtp(supabase, email, token); await verifyMagicLinkOtp(supabase, email, token);
// Session updates via Supabase subscription; AuthPage Navigate redirects.
} catch (err: unknown) { } catch (err: unknown) {
const code = extractErrorCode(err); const code = extractErrorCode(err);
setError( setError(
@@ -543,10 +592,10 @@ function OtpForm({ email }: { email: string }) {
} }
return ( return (
<div className="mt-4 space-y-2 rounded-lg border border-white/10 bg-ink-800/50 p-4"> <div className="mt-4 space-y-2 rounded-lg border border-line bg-surface-3 p-4">
<label <label
htmlFor={inputId} htmlFor={inputId}
className="block text-xs font-medium uppercase tracking-wide text-neutral-400" className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted"
> >
{t('auth:otp_label')} {t('auth:otp_label')}
</label> </label>
@@ -567,14 +616,14 @@ function OtpForm({ email }: { email: string }) {
}} }}
placeholder={t('auth:otp_placeholder')} placeholder={t('auth:otp_placeholder')}
autoFocus autoFocus
className="w-full rounded-lg border border-white/10 bg-ink-900 px-3 py-3 text-center font-mono text-xl tracking-[0.4em] text-white placeholder-neutral-600 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40" className="w-full rounded-lg border border-line bg-surface-2 px-3 py-3 text-center font-mono text-xl tracking-[0.4em] text-fg placeholder-fg-muted/50 transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/> />
<p className="text-xs text-neutral-500">{t('auth:otp_hint')}</p> <p className="text-[11px] text-fg-muted">{t('auth:otp_hint')}</p>
{error && ( {error && (
<p <p
role="alert" role="alert"
className="break-words rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200" className="break-words rounded-md border border-rose-500/25 bg-rose-500/10 px-3 py-2 text-xs text-rose-800 dark:text-rose-200"
> >
{error} {error}
</p> </p>
@@ -585,7 +634,7 @@ function OtpForm({ email }: { email: string }) {
disabled={busy || token.length !== 6} disabled={busy || token.length !== 6}
onClick={(e) => void handleVerify(e)} onClick={(e) => void handleVerify(e)}
aria-busy={busy} aria-busy={busy}
className="mt-1 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-4 py-2.5 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60" className="mt-1 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-sm font-semibold text-accent-fg transition hover:bg-accent/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
> >
{busy && <SpinnerIcon className="h-4 w-4" />} {busy && <SpinnerIcon className="h-4 w-4" />}
<span>{t(busy ? 'auth:otp_cta_loading' : 'auth:otp_cta')}</span> <span>{t(busy ? 'auth:otp_cta_loading' : 'auth:otp_cta')}</span>
@@ -601,13 +650,13 @@ function Footer({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) =>
mode === 'signup' ? 'auth:footer_switch_to_login' : 'auth:footer_switch_to_signup'; mode === 'signup' ? 'auth:footer_switch_to_login' : 'auth:footer_switch_to_signup';
return ( return (
<div className="mt-6 flex items-center justify-between border-t border-white/5 pt-5 text-xs text-neutral-500"> <div className="mt-6 flex items-center justify-between border-t border-line pt-5 text-xs text-fg-muted">
<span> <span>
{t(promptKey)}{' '} {t(promptKey)}{' '}
<button <button
type="button" type="button"
onClick={() => onModeChange(mode === 'signup' ? 'login' : 'signup')} onClick={() => onModeChange(mode === 'signup' ? 'login' : 'signup')}
className="cursor-pointer font-medium text-brand-300 underline-offset-2 hover:text-brand-200 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-400/40 focus:ring-offset-2 focus:ring-offset-ink-900" className="cursor-pointer font-semibold text-accent underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-2"
> >
{t(switchKey)} {t(switchKey)}
</button> </button>
@@ -616,7 +665,7 @@ function Footer({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) =>
href="http://127.0.0.1:54323" href="http://127.0.0.1:54323"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="hover:text-neutral-300" className="hover:text-fg"
> >
{t('auth:footer_studio')} {t('auth:footer_studio')}
</a> </a>