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:
parent
c2c7e8fa91
commit
756ebc6d86
10 changed files with 366 additions and 74 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
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 { Button } from '@shared/ui/Button'
|
||||
import { Notice } from '@shared/ui/Notice'
|
||||
|
|
@ -52,12 +52,6 @@ export function LoginForm() {
|
|||
<Button type="submit" busy={busy}>
|
||||
{t('login.submit')}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
import { describeError } from '@shared/api/errors'
|
||||
import { Button } from '@shared/ui/Button'
|
||||
import { Notice } from '@shared/ui/Notice'
|
||||
|
|
@ -13,6 +13,7 @@ export function RegisterForm() {
|
|||
const { t: tc } = useTranslation('common')
|
||||
const { signUp } = useSession()
|
||||
const navigate = useNavigate()
|
||||
const from = (useLocation().state as { from?: string } | null)?.from ?? '/configs'
|
||||
const [email, setEmail] = useState('')
|
||||
const [nick, setNick] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
|
@ -37,7 +38,7 @@ export function RegisterForm() {
|
|||
setBusy(true)
|
||||
try {
|
||||
await signUp(email.trim(), password, nick.trim())
|
||||
navigate('/configs', { replace: true })
|
||||
navigate(from, { replace: true })
|
||||
} catch (err) {
|
||||
setError(describeError(tc, err, { 409: t('register.emailTaken') }))
|
||||
} finally {
|
||||
|
|
@ -76,12 +77,6 @@ export function RegisterForm() {
|
|||
<Button type="submit" busy={busy}>
|
||||
{t('register.submit')}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,28 +3,45 @@ import type { authRu } from './ru'
|
|||
|
||||
export const authEn = {
|
||||
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: {
|
||||
title: 'Log in',
|
||||
title: 'Welcome back',
|
||||
emailLabel: 'Email',
|
||||
passwordLabel: 'Password',
|
||||
submit: 'Log in',
|
||||
noAccount: "Don't have an account?",
|
||||
createAccount: 'Sign up',
|
||||
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: {
|
||||
title: 'New account',
|
||||
subtitle: "You'll need an account for cloud configs and linking the game. No password required in the mod.",
|
||||
emailLabel: 'Email',
|
||||
nickLabel: 'Site nickname',
|
||||
nickHint: "Doesn't have to match your Minecraft nick.",
|
||||
passwordLabel: 'Password',
|
||||
passwordHint: 'At least 8 characters.',
|
||||
submit: 'Create account',
|
||||
haveAccount: 'Already have an account?',
|
||||
login: 'Log in',
|
||||
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: {
|
||||
emailRequired: 'Enter your email.',
|
||||
|
|
|
|||
|
|
@ -1,27 +1,44 @@
|
|||
export const authRu = {
|
||||
checkingSession: 'Проверяем вход…',
|
||||
splitPitch: 'Твои настройки LoVisual на любом компьютере: облачные конфиги, коды для друзей и витрина готовых сборок.',
|
||||
tabs: {
|
||||
label: 'Вход или регистрация',
|
||||
login: 'Вход',
|
||||
register: 'Регистрация',
|
||||
},
|
||||
hintsLabel: 'Подсказки',
|
||||
next: {
|
||||
link: 'Дальше: подтвердишь код из игры.',
|
||||
configs: 'Дальше: твои облачные слоты.',
|
||||
account: 'Дальше: настройки аккаунта.',
|
||||
showcase: 'Дальше: заберёшь конфиг с витрины в свой слот.',
|
||||
},
|
||||
login: {
|
||||
title: 'Вход',
|
||||
title: 'С возвращением',
|
||||
emailLabel: 'Почта',
|
||||
passwordLabel: 'Пароль',
|
||||
submit: 'Войти',
|
||||
noAccount: 'Нет аккаунта?',
|
||||
createAccount: 'Создать',
|
||||
wrongCredentials: 'Неверная почта или пароль.',
|
||||
hints: {
|
||||
one: 'Нет аккаунта - вкладка «Регистрация» наверху',
|
||||
two: 'Пароль нужен только на сайте',
|
||||
three: 'Мод входит сам, по коду из вкладки «Аккаунт»',
|
||||
},
|
||||
},
|
||||
register: {
|
||||
title: 'Новый аккаунт',
|
||||
subtitle: 'Аккаунт нужен для облачных конфигов и привязки игры. Пароль в мод вводить не придётся.',
|
||||
emailLabel: 'Почта',
|
||||
nickLabel: 'Ник на сайте',
|
||||
nickHint: 'Не обязательно совпадает с ником в Minecraft.',
|
||||
passwordLabel: 'Пароль',
|
||||
passwordHint: 'Минимум 8 символов.',
|
||||
submit: 'Создать аккаунт',
|
||||
haveAccount: 'Уже есть аккаунт?',
|
||||
login: 'Войти',
|
||||
emailTaken: 'Эта почта уже занята. Войди или используй другую.',
|
||||
hints: {
|
||||
one: 'Аккаунт - 4 облачных слота под конфиги',
|
||||
two: 'Код слота - скинуть другу или открыть на другом компе',
|
||||
three: 'Пароль в мод вводить не придётся, он входит по коду',
|
||||
four: 'Без аккаунта мод тоже работает',
|
||||
},
|
||||
},
|
||||
validation: {
|
||||
emailRequired: 'Введи почту.',
|
||||
|
|
|
|||
51
frontend/src/features/auth/tests/pages.test.tsx
Normal file
51
frontend/src/features/auth/tests/pages.test.tsx
Normal 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')
|
||||
})
|
||||
100
frontend/src/pages/public/auth/AuthScreen.tsx
Normal file
100
frontend/src/pages/public/auth/AuthScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
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() {
|
||||
const { t } = useTranslation('auth')
|
||||
return (
|
||||
<AuthSplit title={t('login.title')}>
|
||||
<AuthScreen mode="login" title={t('login.title')} hints={HINTS.map((k) => t(`login.hints.${k}`))}>
|
||||
<LoginForm />
|
||||
</AuthSplit>
|
||||
</AuthScreen>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
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() {
|
||||
const { t } = useTranslation('auth')
|
||||
return (
|
||||
<AuthSplit title={t('register.title')}>
|
||||
<p className="mb-4 max-w-sm text-frost">{t('register.subtitle')}</p>
|
||||
<AuthScreen mode="register" title={t('register.title')} hints={HINTS.map((k) => t(`register.hints.${k}`))}>
|
||||
<RegisterForm />
|
||||
</AuthSplit>
|
||||
</AuthScreen>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
153
frontend/src/pages/public/auth/auth.css
Normal file
153
frontend/src/pages/public/auth/auth.css
Normal 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; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue