feat(frontend): visual QA pass — unified page titles, CTAs, native controls, 404 fixes

- add PageTitle and apply it on /download, /themes, /configs, /showcase, /link, 404
- reset range-slider thumbs in the theme editor; fold color picker into the swatch
- hide horizontal scrollbars in the ClickGui mock; clip html to kill 100vw pan
- pin the footer under short pages via flex AppShell; split 404 art from caption
- one landing CTA geometry (lv-cta-lg / quiet); CLOUD eyebrow; auth caption scrim
- showcase error hides sort/pager; query retry limited to 1
This commit is contained in:
loki5512344 2026-09-28 13:11:46 +02:00
parent 10d90dce93
commit 7bb981b58e
Signed by: boba
GPG key ID: 253067914055423B
26 changed files with 198 additions and 73 deletions

View file

@ -4,7 +4,15 @@ import { SessionProvider } from '../features/auth/session'
import { ActiveThemeProvider } from '../features/themes/useActiveTheme'
import { routes } from './routes'
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000 } } })
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
// One retry only: a dead backend should show its error state fast, not spam the console.
retry: 1,
},
},
})
const router = createBrowserRouter(routes)
export default function App() {

View file

@ -59,11 +59,12 @@
padding: 0;
list-style: none;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--gui-text) 34%, transparent) color-mix(in srgb, var(--gui-text) 7%, transparent);
}
.lv-gui-rows::-webkit-scrollbar { width: calc(var(--u) * 2.5); }
.lv-gui-rows::-webkit-scrollbar { width: calc(var(--u) * 2.5); height: 0; }
.lv-gui-rows::-webkit-scrollbar-track { background: color-mix(in srgb, var(--gui-text) 7%, transparent); border-radius: 9px; }
.lv-gui-rows::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--gui-text) 34%, transparent); border-radius: 9px; }

View file

@ -14,7 +14,7 @@ type Props = {
export function DownloadButton({ size = 'md', version, bytes, className = '' }: Props) {
const { t, i18n } = useTranslation('download')
const hint = version && bytes ? t('versionHint', { version, size: formatSize(bytes, i18n.language) }) : null
const pad = size === 'lg' ? 'px-7 py-4 text-lg' : ''
const pad = size === 'lg' ? 'lv-cta-lg' : ''
return (
<span className={`inline-flex flex-col items-start gap-2 ${className}`}>

View file

@ -14,6 +14,7 @@ export function CommandHero() {
return (
<section className="relative flex flex-col gap-6 pb-16 pt-20 sm:pt-28">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('cloud.eyebrow')}</span>
<h2 className="text-[2rem] leading-[1.1] sm:text-[2.8rem]">
{t('cloud.headlinePart1')}
{' '}<br className="hidden lg:inline" />
@ -22,11 +23,11 @@ export function CommandHero() {
<p className="max-w-[60ch] text-lg text-frost">{t('cloud.subtitle')}</p>
<div className="flex flex-wrap items-center gap-3">
{status === 'authenticated' ? (
<ButtonLink to="/configs">{t('cloud.ctaAuthenticated')}</ButtonLink>
<ButtonLink to="/configs" className="px-8 py-3 text-base">{t('cloud.ctaAuthenticated')}</ButtonLink>
) : (
<ButtonLink to="/register">{t('cloud.ctaAnonymous')}</ButtonLink>
<ButtonLink to="/register" className="px-8 py-3 text-base">{t('cloud.ctaAnonymous')}</ButtonLink>
)}
<ButtonLink to="/showcase" variant="quiet">
<ButtonLink to="/showcase" variant="quiet" className="px-8 py-3 text-base">
{t('cloud.ctaShowcase')}
</ButtonLink>
</div>

View file

@ -27,6 +27,7 @@ export const landingEn = {
palette: 'Theme palette',
},
cloud: {
eyebrow: 'CLOUD',
headlinePart1: 'Your LoVisual settings',
headlinePart2: 'on any computer',
subtitle: 'Four cloud slots, a code you can share with friends, and a showcase of ready-made configs.',

View file

@ -24,6 +24,7 @@ export const landingRu = {
palette: 'Палитра темы',
},
cloud: {
eyebrow: 'ОБЛАКО',
headlinePart1: 'Твои настройки LoVisual',
headlinePart2: 'на любом компьютере',
subtitle: 'Четыре облачных слота, код, которым делишься с друзьями, и витрина готовых конфигов.',

View file

@ -32,7 +32,7 @@ export function FaqDownload() {
<div className="lv-download-band lv-glass flex min-w-0 flex-col items-start justify-center gap-5 rounded-panel p-8 sm:p-10">
<h3 className="text-[1.75rem] leading-tight sm:text-[2.1rem]">{t('faq.downloadTitle')}</h3>
<p className="max-w-[42ch] text-lg text-frost">{t('faq.downloadLead')}</p>
<a href={DOWNLOAD_URL} download className="lv-cta-primary">
<a href={DOWNLOAD_URL} download className="lv-cta-primary lv-cta-lg">
{t('faq.downloadCta')}
</a>
<p className="font-code text-xs text-frost">{t('faq.downloadNote')}</p>

View file

@ -1,7 +1,6 @@
import { MotionConfig, motion } from 'motion/react'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { ButtonLink } from '../../../../shared/ui/Button'
import { ClickGuiWindow } from '../../../clickgui/ClickGuiWindow'
import { DOWNLOAD_URL } from '../../../download/api'
import { PRESETS } from '../../../themes/presets.generated'
@ -66,13 +65,13 @@ export function Hero() {
{t('hero.lead')}
</motion.p>
<motion.div {...rise(0.24)} className="flex flex-wrap items-center gap-3">
<a href={DOWNLOAD_URL} download className="lv-cta-primary">
<a href={DOWNLOAD_URL} download className="lv-cta-primary lv-cta-lg">
<DownloadIcon />
{t('hero.download')}
</a>
<ButtonLink to="/register" variant="quiet" className="bg-black/25 px-5 py-3 text-base backdrop-blur">
<a href="/register" className="lv-cta-quiet-lg">
{t('hero.register')}
</ButtonLink>
</a>
</motion.div>
<motion.p {...rise(0.3)} className="-mt-2 font-code text-xs text-frost">
{t('hero.downloadHint')}

View file

@ -51,7 +51,7 @@ export function ThemeStrip() {
</ul>
</div>
<ButtonLink to="/themes" className="mt-2 px-5 py-3 text-base">
<ButtonLink to="/themes" className="mt-2 px-8 py-3 text-base">
{t('themeStrip.cta')}
</ButtonLink>
</div>

View file

@ -58,6 +58,31 @@
box-shadow: 0 0 0 1px #fff inset, 0 16px 50px -8px var(--page-accent-glow), 0 0 80px -6px var(--page-accent-glow);
}
/* The landing's large primary variant (hero, download band, cloud section). */
.lv-cta-primary.lv-cta-lg {
padding: 1.1rem 2rem;
font-size: 1.125rem;
}
/* Quiet outline CTA matching lv-cta-lg geometry (plain class: unlayered CSS beats Tailwind utilities). */
.lv-cta-quiet-lg {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 1.1rem 2rem;
border-radius: 0.6rem;
font-weight: 600;
font-size: 1.125rem;
color: var(--color-snow);
border: 1px solid color-mix(in srgb, var(--color-ice) 45%, transparent);
transition: border-color 200ms ease, transform 200ms ease;
}
.lv-cta-quiet-lg:hover {
transform: translateY(-2px);
border-color: var(--color-ice);
}
.lv-live-dot {
width: 7px;
height: 7px;

View file

@ -45,6 +45,7 @@ export function ListingList({ author }: { author?: string } = {}) {
return (
<div className="flex flex-col gap-6">
{!isError ? (
<div className="flex gap-2" role="group" aria-label={t('page.title')}>
<Button variant={sort === 'new' ? 'primary' : 'quiet'} aria-pressed={sort === 'new'} onClick={() => setSort('new')}>
{t('page.sortNew')}
@ -53,6 +54,7 @@ export function ListingList({ author }: { author?: string } = {}) {
{t('page.sortPopular')}
</Button>
</div>
) : null}
{isError ? <Notice tone="error">{t('page.error')}</Notice> : null}
@ -68,6 +70,7 @@ export function ListingList({ author }: { author?: string } = {}) {
</ul>
) : null}
{!isError ? (
<div className="flex items-center gap-3">
<Button variant="quiet" disabled={page === 0} onClick={() => setPage(page - 1)}>
{t('page.back')}
@ -76,6 +79,7 @@ export function ListingList({ author }: { author?: string } = {}) {
{t('page.next')}
</Button>
</div>
) : null}
</div>
)
}

View file

@ -47,8 +47,11 @@
background-position: 0 0, 5px 5px;
}
.lv-alpha-slider::-webkit-slider-thumb,
.lv-alpha-slider::-moz-range-thumb {
/* Alpha slider: `appearance: none` on the input alone leaves the UA thumb in Chrome,
so reset it on the pseudo-elements and paint our own. */
.lv-alpha-slider::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
@ -56,3 +59,42 @@
border: 2px solid #0b0f22;
cursor: pointer;
}
.lv-alpha-slider::-moz-range-thumb {
appearance: none;
-moz-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--color-ice, #6cf);
border: 2px solid #0b0f22;
cursor: pointer;
}
/* Native color input rendered as the checkerboard swatch itself: the real picker is
transparent and fills the swatch, so a click opens it without showing UA chrome. */
.lv-color-swatch {
position: relative;
display: block;
width: 100%;
height: 100%;
}
.lv-color-swatch input[type='color'] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
border: none;
padding: 0;
}
.lv-color-swatch::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--color-ice, #6cf) 70%, transparent);
opacity: 0;
transition: opacity 150ms ease;
}
.lv-color-swatch:focus-within::after {
opacity: 1;
}

View file

@ -29,19 +29,20 @@ export function ColorField({ label, value, onChange }: Props) {
return (
<label htmlFor={id} className="lv-color-field flex w-full min-w-0 items-center gap-2">
<span className="lv-alpha-check size-8 shrink-0 overflow-hidden rounded-md border border-white/15" aria-hidden="true">
<span className="block size-full" style={{ background: argbToCss(value) }} />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-xs text-frost">{label}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="lv-alpha-check size-8 shrink-0 overflow-hidden rounded-md border border-white/15">
<span className="lv-color-swatch" style={{ background: argbToCss(value) }}>
<input
type="color"
aria-label={label}
value={toRgb6(value)}
onChange={(e) => onChange(withAlpha(value, e.target.value))}
className="size-7 shrink-0 cursor-pointer rounded border-0 bg-transparent p-0"
className="size-full cursor-pointer border-0 bg-transparent p-0 opacity-0"
/>
</span>
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-xs text-frost">{label}</span>
<span className="flex min-w-0 items-center gap-2">
<input
type="range"
min={0}

View file

@ -36,7 +36,7 @@ export function GradientField({ title, value, onChange }: Props) {
max={359}
value={Math.round(value.angle)}
onChange={(e) => onChange({ angle: Number(e.target.value) })}
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-white/20"
className="lv-alpha-slider h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-white/20"
/>
<span className="w-10 shrink-0 text-right font-code text-snow">{Math.round(value.angle)}°</span>
</label>

View file

@ -40,6 +40,9 @@
--topbar-h: 4.25rem;
scroll-padding-top: calc(var(--topbar-h) + 1rem);
color-scheme: dark;
/* 100vw full-bleed sections exceed the scrollbar-less client width by a few px;
clip on html too so the document can never pan horizontally. */
overflow-x: clip;
transition: --color-ice 400ms ease, --page-accent-glow 400ms ease;
}
body {

View file

@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'
import { ParticleField } from '../../features/landing/effects/ParticleField'
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
import { SlotGrid } from '../../features/configs/components/SlotGrid'
import { PageTitle } from '../../shared/ui/PageTitle'
const ACCENT = 'rgb(92, 200, 231)'
@ -16,13 +17,16 @@ export default function ConfigsPage() {
<ParticleField color={ACCENT} className="opacity-50" />
<div className="relative mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-4 pt-12 sm:px-6">
<div>
<h1 className="font-display text-3xl text-snow">{t('page.title')}</h1>
<p className="mt-2 max-w-[60ch] text-frost">
<PageTitle
lead={
<>
{t('page.leadBefore')} <code className="font-code text-ice">%config save &lt;слот&gt;</code>
{t('page.leadAfter')}
</p>
</div>
</>
}
>
{t('page.title')}
</PageTitle>
<SlotGrid />
</div>
</section>

View file

@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next'
import { LinkDeviceForm } from '../../features/device-link/LinkDeviceForm'
import { PageTitle } from '../../shared/ui/PageTitle'
const STEPS = ['one', 'two', 'three'] as const
@ -7,10 +8,7 @@ export default function LinkPage() {
const { t } = useTranslation('deviceLink')
return (
<section className="flex flex-col gap-8 py-12">
<div>
<h1 className="text-3xl">{t('page.title')}</h1>
<p className="mt-2 max-w-sm text-frost">{t('page.lead')}</p>
</div>
<PageTitle lead={t('page.lead')}>{t('page.title')}</PageTitle>
<ol className="flex flex-col gap-3 sm:flex-row sm:gap-4">
{STEPS.map((step, i) => (

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { DownloadButton } from '../../features/download/DownloadButton'
import { ReleaseNotes } from '../../features/download/ReleaseNotes'
import { GITHUB_REPO, fetchReleases, releaseSize } from '../../features/download/api'
import { PageTitle } from '../../shared/ui/PageTitle'
// Requirement versions mirror mod/gradle.properties (the mod's build source of truth).
const REQUIREMENTS = [
@ -20,8 +21,7 @@ export default function DownloadPage() {
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-12 px-6 py-16">
<header className="flex flex-col gap-4">
<h1 className="font-display text-4xl font-black uppercase text-snow">{t('page.title')}</h1>
<p className="text-frost">{t('page.lead')}</p>
<PageTitle lead={t('page.lead')}>{t('page.title')}</PageTitle>
<DownloadButton
size="lg"
className="mt-2"

View file

@ -1,22 +1,27 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { NOT_FOUND_ART } from '../../shared/easter/art.generated'
import { PageTitle } from '../../shared/ui/PageTitle'
export default function NotFoundPage() {
const { t } = useTranslation('common')
return (
<section className="flex flex-col gap-4 py-16">
<section className="flex flex-1 flex-col items-start gap-6 py-16">
<pre aria-hidden="true" className="w-fit max-w-full overflow-x-auto rounded-panel border border-white/10 bg-black/30 p-4 font-code text-xs leading-tight text-ice">
{NOT_FOUND_ART}
{t('notFound.console')}
{`${NOT_FOUND_ART}\n`}{t('notFound.console')}
</pre>
<h1 className="text-[2.4375rem] leading-tight">{t('notFound.title')}</h1>
<p className="text-frost">
<PageTitle
lead={
<>
{t('notFound.body')}{' '}
<Link to="/" className="text-ice underline-offset-4 hover:underline">
{t('notFound.home')}
</Link>
</p>
</>
}
>
{t('notFound.title')}
</PageTitle>
</section>
)
}

View file

@ -26,6 +26,8 @@ export function AuthSplit({ title, children }: { title: string; children: ReactN
<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>

View file

@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'
import { ParticleField } from '../../features/landing/effects/ParticleField'
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
import { ListingList } from '../../features/showcase/components/ListingList'
import { PageTitle } from '../../shared/ui/PageTitle'
const ACCENT = 'rgb(92, 200, 231)'
@ -16,10 +17,7 @@ export default function ShowcasePage() {
<ParticleField color={ACCENT} className="opacity-50" />
<div className="relative mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-4 pt-12 sm:px-6">
<div>
<h1 className="font-display text-3xl text-snow">{t('page.title')}</h1>
<p className="mt-2 max-w-[60ch] text-frost">{t('page.lead')}</p>
</div>
<PageTitle lead={t('page.lead')}>{t('page.title')}</PageTitle>
<ListingList />
</div>
</section>

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { ThemeEditor } from '../../features/themes/editor/ThemeEditor'
import { fromShareHash } from '../../features/themes/editor/model/codec'
import { PRESETS } from '../../features/themes/presets.generated'
import { PageTitle } from '../../shared/ui/PageTitle'
import '../../features/themes/editor/editor.css'
/** `/themes` — pick a preset (or load one from a `#t=` share link) and edit it live. */
@ -22,10 +23,7 @@ export default function ThemesPage() {
<ThemeEditor
initial={initial}
intro={
<header className="flex flex-col gap-1">
<h1 className="font-display text-3xl font-black uppercase text-snow">{t('editor.title')}</h1>
<p className="text-sm text-frost">{t('editor.lead')}</p>
</header>
<PageTitle lead={t('editor.lead')}>{t('editor.title')}</PageTitle>
}
/>
)

View file

@ -7,7 +7,7 @@ import { TopBar } from './topbar/TopBar'
export function AppShell() {
const { t } = useTranslation('common')
return (
<>
<div className="flex min-h-dvh flex-col">
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-2 focus:z-50 focus:rounded-md focus:bg-slate focus:px-3 focus:py-2"
@ -16,10 +16,10 @@ export function AppShell() {
</a>
<KonamiEffect />
<TopBar />
<main id="main" className="mx-auto w-full max-w-[1120px] px-4 pt-(--topbar-h) sm:px-6">
<main id="main" className="mx-auto flex w-full max-w-[1120px] flex-1 flex-col px-4 pt-(--topbar-h) sm:px-6">
<Outlet />
</main>
<Footer />
</>
</div>
)
}

View file

@ -1,4 +1,4 @@
import type { ButtonHTMLAttributes } from 'react'
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react'
import { Link, type LinkProps } from 'react-router'
type Variant = 'primary' | 'quiet' | 'danger'
@ -35,3 +35,12 @@ export function Button({
export function ButtonLink({ variant = 'primary', className = '', ...rest }: LinkProps & { variant?: Variant }) {
return <Link className={`${BASE} ${VARIANTS[variant]} ${className}`} {...rest} />
}
/** Like ButtonLink but escapes to a plain <a> for external/href CTAs (e.g. GitHub downloads). */
export function ButtonAnchor({
variant = 'primary',
className = '',
...rest
}: AnchorHTMLAttributes<HTMLAnchorElement> & { variant?: Variant }) {
return <a className={`${BASE} ${VARIANTS[variant]} ${className}`} {...rest} />
}

View file

@ -0,0 +1,16 @@
import type { ReactNode } from 'react'
/**
* The canonical page heading. Every top-level page (except the landing hero and the
* auth panes) uses this so titles share one size, weight and uppercase treatment.
*/
export function PageTitle({ children, lead }: { children: ReactNode; lead?: ReactNode }) {
return (
<header className="flex flex-col gap-2">
<h1 className="font-display text-3xl font-black uppercase leading-tight tracking-[0.01em] text-snow">
{children}
</h1>
{lead ? <p className="max-w-[60ch] text-frost">{lead}</p> : null}
</header>
)
}

View file

@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { Button } from '../Button'
import { PageTitle } from '../PageTitle'
import { TextField } from '../TextField'
import { ShareCode } from '../ShareCode'
import { Notice } from '../Notice'
@ -34,3 +35,11 @@ test('error notice is an alert', () => {
render(<Notice tone="error">Неверный код</Notice>)
expect(screen.getByRole('alert')).toHaveTextContent('Неверный код')
})
test('page title renders one h1 with an optional lead', () => {
render(
<PageTitle lead="Короткое описание">Мои конфиги</PageTitle>,
)
expect(screen.getByRole('heading', { level: 1, name: 'Мои конфиги' })).toBeInTheDocument()
expect(screen.getByText('Короткое описание')).toBeInTheDocument()
})