feat(frontend): full redesign of login/register

Owner's complaint: the two-pane layout's decorative right-side panel
("Твои настройки LoVisual на любом компьютере...") made no sense next
to a login form. Dropped the whole two-pane concept, not just its
visuals.

Login/register now render as the mod's own in-game account screen:
night-lake backdrop (the same Backdrop the landing hero uses), a
clock + Вход/Регистрация tab switcher styled like the mod's own
ClickGui tabs, a single glass panel on the mod's own category
colours, and a real mod-style hint row ("X - Y") instead of marketing
copy. What an account actually gets you is stated as plain hints
(4 cloud slots, share a slot's code, the mod signs itself in by code,
no account required to just use the mod) instead of a sales pitch.

- AuthSplit.tsx removed; AuthScreen.tsx + auth.css replace it
- LoginForm/RegisterForm: dropped the redundant login<->register text
  link now that the tab switcher does that job; RegisterForm now
  honours the original `from` redirect (previously always went to
  /configs even when registration started from /link)
- Rewrote auth i18n copy to match the site's established voice

Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
This commit is contained in:
loki5512344 2026-09-30 10:30:53 +02:00
parent c2c7e8fa91
commit 756ebc6d86
Signed by: boba
GPG key ID: 253067914055423B
10 changed files with 366 additions and 74 deletions

View file

@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link, useLocation, useNavigate } from 'react-router' import { useLocation, useNavigate } from 'react-router'
import { describeError } from '@shared/api/errors' import { describeError } from '@shared/api/errors'
import { Button } from '@shared/ui/Button' import { Button } from '@shared/ui/Button'
import { Notice } from '@shared/ui/Notice' import { Notice } from '@shared/ui/Notice'
@ -52,12 +52,6 @@ export function LoginForm() {
<Button type="submit" busy={busy}> <Button type="submit" busy={busy}>
{t('login.submit')} {t('login.submit')}
</Button> </Button>
<p className="text-sm text-frost">
{t('login.noAccount')}{' '}
<Link to="/register" className="text-ice underline-offset-4 hover:underline">
{t('login.createAccount')}
</Link>
</p>
</form> </form>
) )
} }

View file

@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link, useNavigate } from 'react-router' import { useLocation, useNavigate } from 'react-router'
import { describeError } from '@shared/api/errors' import { describeError } from '@shared/api/errors'
import { Button } from '@shared/ui/Button' import { Button } from '@shared/ui/Button'
import { Notice } from '@shared/ui/Notice' import { Notice } from '@shared/ui/Notice'
@ -13,6 +13,7 @@ export function RegisterForm() {
const { t: tc } = useTranslation('common') const { t: tc } = useTranslation('common')
const { signUp } = useSession() const { signUp } = useSession()
const navigate = useNavigate() const navigate = useNavigate()
const from = (useLocation().state as { from?: string } | null)?.from ?? '/configs'
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [nick, setNick] = useState('') const [nick, setNick] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@ -37,7 +38,7 @@ export function RegisterForm() {
setBusy(true) setBusy(true)
try { try {
await signUp(email.trim(), password, nick.trim()) await signUp(email.trim(), password, nick.trim())
navigate('/configs', { replace: true }) navigate(from, { replace: true })
} catch (err) { } catch (err) {
setError(describeError(tc, err, { 409: t('register.emailTaken') })) setError(describeError(tc, err, { 409: t('register.emailTaken') }))
} finally { } finally {
@ -76,12 +77,6 @@ export function RegisterForm() {
<Button type="submit" busy={busy}> <Button type="submit" busy={busy}>
{t('register.submit')} {t('register.submit')}
</Button> </Button>
<p className="text-sm text-frost">
{t('register.haveAccount')}{' '}
<Link to="/login" className="text-ice underline-offset-4 hover:underline">
{t('register.login')}
</Link>
</p>
</form> </form>
) )
} }

View file

@ -3,28 +3,45 @@ import type { authRu } from './ru'
export const authEn = { export const authEn = {
checkingSession: 'Checking session…', checkingSession: 'Checking session…',
splitPitch: 'Your LoVisual setup, on any computer: cloud configs, share codes for friends, and a showcase of ready-made builds.', tabs: {
label: 'Log in or sign up',
login: 'Log in',
register: 'Sign up',
},
hintsLabel: 'Hints',
next: {
link: 'Next: you confirm the code from the game.',
configs: 'Next: your cloud slots.',
account: 'Next: your account settings.',
showcase: 'Next: the showcase config goes into your slot.',
},
login: { login: {
title: 'Log in', title: 'Welcome back',
emailLabel: 'Email', emailLabel: 'Email',
passwordLabel: 'Password', passwordLabel: 'Password',
submit: 'Log in', submit: 'Log in',
noAccount: "Don't have an account?",
createAccount: 'Sign up',
wrongCredentials: 'Wrong email or password.', wrongCredentials: 'Wrong email or password.',
hints: {
one: 'No account - the "Sign up" tab up top',
two: 'The password is for the site only',
three: 'The mod signs in by itself, with a code from its Account tab',
},
}, },
register: { register: {
title: 'New account', title: 'New account',
subtitle: "You'll need an account for cloud configs and linking the game. No password required in the mod.",
emailLabel: 'Email', emailLabel: 'Email',
nickLabel: 'Site nickname', nickLabel: 'Site nickname',
nickHint: "Doesn't have to match your Minecraft nick.", nickHint: "Doesn't have to match your Minecraft nick.",
passwordLabel: 'Password', passwordLabel: 'Password',
passwordHint: 'At least 8 characters.', passwordHint: 'At least 8 characters.',
submit: 'Create account', submit: 'Create account',
haveAccount: 'Already have an account?',
login: 'Log in',
emailTaken: 'That email is already taken. Log in or use another one.', emailTaken: 'That email is already taken. Log in or use another one.',
hints: {
one: 'Account - 4 cloud slots for your configs',
two: 'Slot code - send it to a friend or load it on another PC',
three: 'No password in the mod, it signs in with a code',
four: 'The mod works without an account too',
},
}, },
validation: { validation: {
emailRequired: 'Enter your email.', emailRequired: 'Enter your email.',

View file

@ -1,27 +1,44 @@
export const authRu = { export const authRu = {
checkingSession: 'Проверяем вход…', checkingSession: 'Проверяем вход…',
splitPitch: 'Твои настройки LoVisual на любом компьютере: облачные конфиги, коды для друзей и витрина готовых сборок.', tabs: {
label: 'Вход или регистрация',
login: 'Вход',
register: 'Регистрация',
},
hintsLabel: 'Подсказки',
next: {
link: 'Дальше: подтвердишь код из игры.',
configs: 'Дальше: твои облачные слоты.',
account: 'Дальше: настройки аккаунта.',
showcase: 'Дальше: заберёшь конфиг с витрины в свой слот.',
},
login: { login: {
title: 'Вход', title: 'С возвращением',
emailLabel: 'Почта', emailLabel: 'Почта',
passwordLabel: 'Пароль', passwordLabel: 'Пароль',
submit: 'Войти', submit: 'Войти',
noAccount: 'Нет аккаунта?',
createAccount: 'Создать',
wrongCredentials: 'Неверная почта или пароль.', wrongCredentials: 'Неверная почта или пароль.',
hints: {
one: 'Нет аккаунта - вкладка «Регистрация» наверху',
two: 'Пароль нужен только на сайте',
three: 'Мод входит сам, по коду из вкладки «Аккаунт»',
},
}, },
register: { register: {
title: 'Новый аккаунт', title: 'Новый аккаунт',
subtitle: 'Аккаунт нужен для облачных конфигов и привязки игры. Пароль в мод вводить не придётся.',
emailLabel: 'Почта', emailLabel: 'Почта',
nickLabel: 'Ник на сайте', nickLabel: 'Ник на сайте',
nickHint: 'Не обязательно совпадает с ником в Minecraft.', nickHint: 'Не обязательно совпадает с ником в Minecraft.',
passwordLabel: 'Пароль', passwordLabel: 'Пароль',
passwordHint: 'Минимум 8 символов.', passwordHint: 'Минимум 8 символов.',
submit: 'Создать аккаунт', submit: 'Создать аккаунт',
haveAccount: 'Уже есть аккаунт?',
login: 'Войти',
emailTaken: 'Эта почта уже занята. Войди или используй другую.', emailTaken: 'Эта почта уже занята. Войди или используй другую.',
hints: {
one: 'Аккаунт - 4 облачных слота под конфиги',
two: 'Код слота - скинуть другу или открыть на другом компе',
three: 'Пароль в мод вводить не придётся, он входит по коду',
four: 'Без аккаунта мод тоже работает',
},
}, },
validation: { validation: {
emailRequired: 'Введи почту.', emailRequired: 'Введи почту.',

View file

@ -0,0 +1,51 @@
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { routes } from '@app/routes'
import { renderApp } from '@test/render'
import { json, mockFetch } from '@test/fetch'
const me = { id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '2026-09-24T00:00:00Z' }
const anon = { 'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401) }
test('the tab bar switches between login and register', async () => {
const user = userEvent.setup()
mockFetch(anon)
renderApp(routes, '/login')
expect(await screen.findByRole('heading', { level: 1, name: 'С возвращением' })).toBeInTheDocument()
const tabs = screen.getByRole('navigation', { name: 'Вход или регистрация' })
expect(within(tabs).getByRole('link', { name: 'Вход' })).toHaveAttribute('aria-current', 'page')
await user.click(within(tabs).getByRole('link', { name: 'Регистрация' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Новый аккаунт' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Регистрация' })).toHaveAttribute('aria-current', 'page')
expect(screen.getByRole('list', { name: 'Подсказки' })).toHaveTextContent('4 облачных слота')
})
test('a redirect from /link says what comes next and survives the tab switch', async () => {
const user = userEvent.setup()
mockFetch(anon)
renderApp(routes, '/link')
expect(await screen.findByText('Дальше: подтвердишь код из игры.')).toBeInTheDocument()
await user.click(screen.getByRole('link', { name: 'Регистрация' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Новый аккаунт' })).toBeInTheDocument()
expect(screen.getByText('Дальше: подтвердишь код из игры.')).toBeInTheDocument()
})
test('registering after a redirect lands back where the visitor was going', async () => {
const user = userEvent.setup()
mockFetch({
...anon,
'POST /auth/register': () => json({}, 201),
'POST /auth/login': () => json({ access_token: 't' }),
'GET /me': () => json(me),
})
const { router } = renderApp(routes, '/link')
await user.click(await screen.findByRole('link', { name: 'Регистрация' }))
await user.type(await screen.findByLabelText('Почта'), 'a@b.c')
await user.type(screen.getByLabelText('Ник на сайте'), 'Rider')
await user.type(screen.getByLabelText('Пароль'), 'correct-horse')
await user.click(screen.getByRole('button', { name: 'Создать аккаунт' }))
await screen.findByLabelText('Код из игры')
expect(router.state.location.pathname).toBe('/link')
})

View file

@ -0,0 +1,100 @@
import { MotionConfig, motion } from 'motion/react'
import { type ReactNode, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation } from 'react-router'
import { Backdrop } from '@features/clickgui/parts/Backdrop'
import '@features/clickgui/parts/clickgui.css'
import { themeToCssVars } from '@features/clickgui/themeVars'
import { useActiveTheme } from '@features/themes/useActiveTheme'
import './auth.css'
type Mode = 'login' | 'register'
/** Where RequireAuth (or a "log in to copy" link) was sending the visitor, if anywhere we can name. */
function nextStep(from: string | undefined): 'link' | 'configs' | 'account' | 'showcase' | undefined {
if (!from) return undefined
if (from.startsWith('/link')) return 'link'
if (from.startsWith('/configs')) return 'configs'
if (from.startsWith('/account')) return 'account'
if (from.startsWith('/showcase')) return 'showcase'
}
function useClock(): string {
const format = () => new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
const [now, setNow] = useState(format)
useEffect(() => {
const id = window.setInterval(() => setNow(format()), 15_000)
return () => window.clearInterval(id)
}, [])
return now
}
/**
* The login and register pages as the mod's menu opened on an account tab: the ClickGui tab bar
* switches between the two pages, one glass panel holds the form, and the hint row along the
* bottom says what an account actually does (in the mod's own "key - action" voice).
*/
export function AuthScreen({ mode, title, hints, children }: { mode: Mode; title: string; hints: readonly string[]; children: ReactNode }) {
const { t } = useTranslation('auth')
const { theme } = useActiveTheme()
const location = useLocation()
const clock = useClock()
// Carry the redirect target across the tab switch, so registering from /link still ends on /link.
const state = location.state as { from?: string } | null
const step = nextStep(state?.from)
return (
<MotionConfig reducedMotion="user">
<section aria-labelledby="auth-title" className="lv-auth-screen" style={themeToCssVars(theme)}>
<div className="lv-auth-world" aria-hidden="true">
<Backdrop />
</div>
<div className="lv-auth-bar">
<time className="lv-auth-clock" aria-hidden="true">
{clock}
</time>
<nav aria-label={t('tabs.label')} className="lv-auth-tabs">
{(['login', 'register'] as const).map((m) => (
<Link
key={m}
to={`/${m}`}
state={state}
replace
aria-current={m === mode ? 'page' : undefined}
className="lv-auth-tab"
>
{m === mode ? (
<motion.span
layoutId="lv-auth-thumb"
className="lv-auth-thumb"
transition={{ duration: 0.38, ease: [0.22, 1, 0.36, 1] }}
/>
) : null}
<span className="lv-auth-tab-text">{t(`tabs.${m}`)}</span>
</Link>
))}
</nav>
</div>
<div className="lv-auth-panel">
<header className="lv-auth-head">
<h1 id="auth-title" className="lv-auth-title">
{title}
</h1>
</header>
<div className="lv-auth-body">
{step ? <p className="lv-auth-next">{t(`next.${step}`)}</p> : null}
{children}
</div>
</div>
<ul className="lv-auth-hints" aria-label={t('hintsLabel')}>
{hints.map((h) => (
<li key={h}>{h}</li>
))}
</ul>
</section>
</MotionConfig>
)
}

View file

@ -1,38 +0,0 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { ClickGuiWindow } from '@features/clickgui/ClickGuiWindow'
import { ParticleField } from '@features/landing/effects/ParticleField'
import { VoxelScene } from '@features/landing/effects/VoxelScene'
import { useActiveTheme } from '@features/themes/useActiveTheme'
import { parseArgb } from '@features/themes/types'
/** Two-pane auth layout: a ClickGui-styled glass form panel, and a decorative scene (hidden < 900px). */
export function AuthSplit({ title, children }: { title: string; children: ReactNode }) {
const { t } = useTranslation('auth')
const { theme } = useActiveTheme()
const { r, g, b } = parseArgb(theme.theme.accent)
const particleColor = `rgb(${r}, ${g}, ${b})`
return (
<div className="flex min-h-[calc(100vh-var(--topbar-h)-4rem)] items-center justify-center py-10 min-[900px]:justify-between min-[900px]:gap-12">
<div className="w-full max-w-md rounded-(--radius-panel) border border-shoal bg-slate/80 shadow-[0_0_60px_-20px_var(--page-accent-glow,transparent)] backdrop-blur">
<div className="flex items-center gap-2 border-b border-shoal px-5 py-4">
<span aria-hidden="true" className="h-2.5 w-2.5 rounded-full bg-ice" />
<h1 className="font-display text-xl">{title}</h1>
</div>
<div className="px-5 py-6">{children}</div>
</div>
<div className="relative hidden min-[900px]:flex min-[900px]:h-[520px] min-[900px]:w-[440px] min-[900px]:shrink-0 min-[900px]:items-end min-[900px]:overflow-hidden min-[900px]:rounded-(--radius-panel) min-[900px]:border min-[900px]:border-shoal">
<VoxelScene accent={particleColor} />
<ParticleField color={particleColor} className="mix-blend-screen" />
{/* Bottom scrim keeps the caption readable over the bright pixel-art ground. */}
<div aria-hidden="true" className="absolute inset-x-0 bottom-0 z-[5] h-2/5 bg-gradient-to-t from-abyss/90 to-transparent" />
<div className="relative z-10 w-full p-5">
<ClickGuiWindow demo className="w-full max-w-[300px] drop-shadow-2xl" />
<p className="mt-4 max-w-[300px] font-display text-sm text-snow drop-shadow">{t('splitPitch')}</p>
</div>
</div>
</div>
)
}

View file

@ -1,12 +1,14 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { LoginForm } from '@features/auth/forms/LoginForm' import { LoginForm } from '@features/auth/forms/LoginForm'
import { AuthSplit } from './AuthSplit' import { AuthScreen } from './AuthScreen'
const HINTS = ['one', 'two', 'three'] as const
export default function LoginPage() { export default function LoginPage() {
const { t } = useTranslation('auth') const { t } = useTranslation('auth')
return ( return (
<AuthSplit title={t('login.title')}> <AuthScreen mode="login" title={t('login.title')} hints={HINTS.map((k) => t(`login.hints.${k}`))}>
<LoginForm /> <LoginForm />
</AuthSplit> </AuthScreen>
) )
} }

View file

@ -1,13 +1,14 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RegisterForm } from '@features/auth/forms/RegisterForm' import { RegisterForm } from '@features/auth/forms/RegisterForm'
import { AuthSplit } from './AuthSplit' import { AuthScreen } from './AuthScreen'
const HINTS = ['one', 'two', 'three', 'four'] as const
export default function RegisterPage() { export default function RegisterPage() {
const { t } = useTranslation('auth') const { t } = useTranslation('auth')
return ( return (
<AuthSplit title={t('register.title')}> <AuthScreen mode="register" title={t('register.title')} hints={HINTS.map((k) => t(`register.hints.${k}`))}>
<p className="mb-4 max-w-sm text-frost">{t('register.subtitle')}</p>
<RegisterForm /> <RegisterForm />
</AuthSplit> </AuthScreen>
) )
} }

View file

@ -0,0 +1,153 @@
/* Login / register: the page is the mod's own menu, opened on an account tab. The world
backdrop, the ClickGuiTabBar pill, one liquid-glass panel and the hint row are the same
pieces the in-game ClickGui draws, painted with the active theme's --gui-* colors
(see features/clickgui/parts/clickgui.css for the registered properties). */
.lv-auth-screen {
position: relative;
isolation: isolate;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 1.5rem;
min-height: max(640px, calc(100svh - var(--topbar-h) - 3.5rem));
margin-block: 1rem 2.5rem;
padding: 1rem 1rem 1.25rem;
overflow: hidden;
border-radius: 14px;
color: var(--gui-text);
box-shadow: 0 40px 120px -40px var(--gui-accent-glow), 0 0 0 1px rgb(255 255 255 / 0.07);
}
@media (min-width: 640px) {
.lv-auth-screen { padding: 1.25rem 1.75rem 1.5rem; }
}
.lv-auth-world { position: absolute; inset: 0; z-index: -1; background: #020309; }
/* Keeps the lake from out-shouting the panel; the accent pools under it like the in-game glow. */
.lv-auth-world::after {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(60% 55% at 50% 55%, color-mix(in srgb, var(--gui-accent) 18%, transparent), transparent 70%),
linear-gradient(180deg, rgb(2 3 9 / 0.2), rgb(2 3 9 / 0.55));
}
/* ClickGuiTabBar at page scale. */
.lv-auth-bar { display: flex; justify-content: center; align-items: center; gap: 0.6rem; }
.lv-auth-clock {
padding: 0.35rem 0.9rem;
border-radius: 999px;
background: rgb(10 10 12 / 0.88);
border: 1px solid rgb(255 255 255 / 0.12);
font-size: 0.85rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.lv-auth-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0.25rem;
border-radius: 999px;
background: linear-gradient(90deg, var(--gui-tab-left), var(--gui-tab-right));
border: 1px solid color-mix(in srgb, var(--gui-text) 14%, transparent);
box-shadow: 0 6px 22px rgb(0 0 0 / 0.35);
}
.lv-auth-tab {
position: relative;
min-width: 7.5rem;
padding: 0.45rem 1.1rem;
border-radius: 999px;
text-align: center;
font-size: 0.9rem;
font-weight: 600;
color: var(--gui-text-muted);
transition: color 250ms;
}
.lv-auth-tab:hover { color: color-mix(in srgb, var(--gui-text-muted), var(--gui-text) 55%); }
.lv-auth-tab[aria-current="page"] { color: var(--gui-text); }
.lv-auth-tab-text { position: relative; }
.lv-auth-thumb {
position: absolute;
inset: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--gui-tab-active-left), var(--gui-tab-active-right));
border: 1px solid color-mix(in srgb, var(--gui-accent) 80%, white);
box-shadow: 0 0 18px color-mix(in srgb, var(--gui-accent-soft) 45%, transparent);
}
/* The one panel: same liquid glass as the mod's category panels, grown to hold a form. */
.lv-auth-panel {
align-self: center;
justify-self: center;
width: min(100%, 27rem);
border-radius: 14px;
border: 1px solid var(--gui-panel-stroke);
background:
linear-gradient(180deg, rgb(255 255 255 / 0.05), transparent 12%),
linear-gradient(180deg,
color-mix(in srgb, var(--gui-panel-tint) 94%, black) 0%,
color-mix(in srgb, var(--gui-panel-tint) 86%, transparent) 45%,
color-mix(in srgb, var(--gui-panel-tint) 72%, transparent) 100%);
box-shadow: 0 24px 60px rgb(0 0 0 / 0.45), inset 0 1px 0 rgb(255 255 255 / 0.1);
backdrop-filter: blur(14px) saturate(1.3);
}
.lv-auth-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.1rem 1.5rem 1rem;
border-bottom: 1px solid var(--gui-split);
}
.lv-auth-title { font-size: 1.35rem; line-height: 1.2; letter-spacing: -0.01em; color: var(--gui-text); }
.lv-auth-body { display: flex; flex-direction: column; gap: 1.1rem; padding: 1.25rem 1.5rem 1.5rem; }
.lv-auth-next { display: flex; align-items: baseline; gap: 0.5rem; font-size: 0.9rem; color: var(--gui-text-muted); }
.lv-auth-next::before {
content: "";
flex: none;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--gui-accent);
box-shadow: 0 0 10px var(--gui-accent);
transform: translateY(-0.05rem);
}
/* Shared TextField / link colors re-pointed at the theme inside the panel. Unlayered, so these
win over the Tailwind utilities on the shared components. */
.lv-auth-panel label, .lv-auth-panel form p { color: var(--gui-text-muted); }
.lv-auth-panel input {
background: color-mix(in srgb, var(--gui-window-from) 70%, black);
border-color: color-mix(in srgb, var(--gui-text) 14%, transparent);
color: var(--gui-text);
transition: border-color 150ms, box-shadow 150ms;
}
.lv-auth-panel input:focus {
border-color: var(--gui-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--gui-accent) 22%, transparent);
outline: none;
}
.lv-auth-panel input[aria-invalid="true"] { border-color: var(--color-ember); }
.lv-auth-panel form p[id$="-error"] { color: var(--color-ember); }
.lv-auth-panel button[type="submit"] { padding-block: 0.7rem; font-size: 0.95rem; }
/* The in-game hint row: plain lines along the bottom edge of the screen. */
.lv-auth-hints {
display: grid;
gap: 0.2rem 2rem;
margin: 0;
padding: 0;
list-style: none;
font-size: 0.85rem;
line-height: 1.45;
color: rgb(255 255 255 / 0.78);
text-shadow: 0 1px 2px rgb(0 0 0 / 0.6);
}
@media (min-width: 768px) {
.lv-auth-hints { grid-template-columns: 1fr 1fr; }
}
/* Phones: the tab switch needs the whole bar. */
@media (max-width: 479px) {
.lv-auth-clock { display: none; }
.lv-auth-tab { min-width: 0; }
}