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 { ActiveThemeProvider } from '../features/themes/useActiveTheme'
import { routes } from './routes' 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) const router = createBrowserRouter(routes)
export default function App() { export default function App() {

View file

@ -59,11 +59,12 @@
padding: 0; padding: 0;
list-style: none; list-style: none;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain; overscroll-behavior: contain;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--gui-text) 34%, transparent) color-mix(in srgb, var(--gui-text) 7%, transparent); 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-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; } .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) { export function DownloadButton({ size = 'md', version, bytes, className = '' }: Props) {
const { t, i18n } = useTranslation('download') const { t, i18n } = useTranslation('download')
const hint = version && bytes ? t('versionHint', { version, size: formatSize(bytes, i18n.language) }) : null 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 ( return (
<span className={`inline-flex flex-col items-start gap-2 ${className}`}> <span className={`inline-flex flex-col items-start gap-2 ${className}`}>

View file

@ -14,6 +14,7 @@ export function CommandHero() {
return ( return (
<section className="relative flex flex-col gap-6 pb-16 pt-20 sm:pt-28"> <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]"> <h2 className="text-[2rem] leading-[1.1] sm:text-[2.8rem]">
{t('cloud.headlinePart1')} {t('cloud.headlinePart1')}
{' '}<br className="hidden lg:inline" /> {' '}<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> <p className="max-w-[60ch] text-lg text-frost">{t('cloud.subtitle')}</p>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{status === 'authenticated' ? ( {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')} {t('cloud.ctaShowcase')}
</ButtonLink> </ButtonLink>
</div> </div>

View file

@ -27,6 +27,7 @@ export const landingEn = {
palette: 'Theme palette', palette: 'Theme palette',
}, },
cloud: { cloud: {
eyebrow: 'CLOUD',
headlinePart1: 'Your LoVisual settings', headlinePart1: 'Your LoVisual settings',
headlinePart2: 'on any computer', headlinePart2: 'on any computer',
subtitle: 'Four cloud slots, a code you can share with friends, and a showcase of ready-made configs.', 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: 'Палитра темы', palette: 'Палитра темы',
}, },
cloud: { cloud: {
eyebrow: 'ОБЛАКО',
headlinePart1: 'Твои настройки LoVisual', headlinePart1: 'Твои настройки LoVisual',
headlinePart2: 'на любом компьютере', headlinePart2: 'на любом компьютере',
subtitle: 'Четыре облачных слота, код, которым делишься с друзьями, и витрина готовых конфигов.', 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"> <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> <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> <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')} {t('faq.downloadCta')}
</a> </a>
<p className="font-code text-xs text-frost">{t('faq.downloadNote')}</p> <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 { MotionConfig, motion } from 'motion/react'
import { useRef } from 'react' import { useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ButtonLink } from '../../../../shared/ui/Button'
import { ClickGuiWindow } from '../../../clickgui/ClickGuiWindow' import { ClickGuiWindow } from '../../../clickgui/ClickGuiWindow'
import { DOWNLOAD_URL } from '../../../download/api' import { DOWNLOAD_URL } from '../../../download/api'
import { PRESETS } from '../../../themes/presets.generated' import { PRESETS } from '../../../themes/presets.generated'
@ -66,13 +65,13 @@ export function Hero() {
{t('hero.lead')} {t('hero.lead')}
</motion.p> </motion.p>
<motion.div {...rise(0.24)} className="flex flex-wrap items-center gap-3"> <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 /> <DownloadIcon />
{t('hero.download')} {t('hero.download')}
</a> </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')} {t('hero.register')}
</ButtonLink> </a>
</motion.div> </motion.div>
<motion.p {...rise(0.3)} className="-mt-2 font-code text-xs text-frost"> <motion.p {...rise(0.3)} className="-mt-2 font-code text-xs text-frost">
{t('hero.downloadHint')} {t('hero.downloadHint')}

View file

@ -51,7 +51,7 @@ export function ThemeStrip() {
</ul> </ul>
</div> </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')} {t('themeStrip.cta')}
</ButtonLink> </ButtonLink>
</div> </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); 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 { .lv-live-dot {
width: 7px; width: 7px;
height: 7px; height: 7px;

View file

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

View file

@ -47,8 +47,11 @@
background-position: 0 0, 5px 5px; background-position: 0 0, 5px 5px;
} }
.lv-alpha-slider::-webkit-slider-thumb, /* Alpha slider: `appearance: none` on the input alone leaves the UA thumb in Chrome,
.lv-alpha-slider::-moz-range-thumb { so reset it on the pseudo-elements and paint our own. */
.lv-alpha-slider::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 14px; width: 14px;
height: 14px; height: 14px;
border-radius: 50%; border-radius: 50%;
@ -56,3 +59,42 @@
border: 2px solid #0b0f22; border: 2px solid #0b0f22;
cursor: pointer; 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 ( return (
<label htmlFor={id} className="lv-color-field flex w-full min-w-0 items-center gap-2"> <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="lv-alpha-check size-8 shrink-0 overflow-hidden rounded-md border border-white/15">
<span className="block size-full" style={{ background: argbToCss(value) }} /> <span className="lv-color-swatch" 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">
<input <input
type="color" type="color"
aria-label={label} aria-label={label}
value={toRgb6(value)} value={toRgb6(value)}
onChange={(e) => onChange(withAlpha(value, e.target.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 <input
type="range" type="range"
min={0} min={0}

View file

@ -36,7 +36,7 @@ export function GradientField({ title, value, onChange }: Props) {
max={359} max={359}
value={Math.round(value.angle)} value={Math.round(value.angle)}
onChange={(e) => onChange({ angle: Number(e.target.value) })} 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> <span className="w-10 shrink-0 text-right font-code text-snow">{Math.round(value.angle)}°</span>
</label> </label>

View file

@ -40,6 +40,9 @@
--topbar-h: 4.25rem; --topbar-h: 4.25rem;
scroll-padding-top: calc(var(--topbar-h) + 1rem); scroll-padding-top: calc(var(--topbar-h) + 1rem);
color-scheme: dark; 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; transition: --color-ice 400ms ease, --page-accent-glow 400ms ease;
} }
body { body {

View file

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

View file

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

View file

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

View file

@ -1,22 +1,27 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link } from 'react-router' import { Link } from 'react-router'
import { NOT_FOUND_ART } from '../../shared/easter/art.generated' import { NOT_FOUND_ART } from '../../shared/easter/art.generated'
import { PageTitle } from '../../shared/ui/PageTitle'
export default function NotFoundPage() { export default function NotFoundPage() {
const { t } = useTranslation('common') const { t } = useTranslation('common')
return ( 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"> <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} {`${NOT_FOUND_ART}\n`}{t('notFound.console')}
{t('notFound.console')}
</pre> </pre>
<h1 className="text-[2.4375rem] leading-tight">{t('notFound.title')}</h1> <PageTitle
<p className="text-frost"> lead={
{t('notFound.body')}{' '} <>
<Link to="/" className="text-ice underline-offset-4 hover:underline"> {t('notFound.body')}{' '}
{t('notFound.home')} <Link to="/" className="text-ice underline-offset-4 hover:underline">
</Link> {t('notFound.home')}
</p> </Link>
</>
}
>
{t('notFound.title')}
</PageTitle>
</section> </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"> <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} /> <VoxelScene accent={particleColor} />
<ParticleField color={particleColor} className="mix-blend-screen" /> <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"> <div className="relative z-10 w-full p-5">
<ClickGuiWindow demo className="w-full max-w-[300px] drop-shadow-2xl" /> <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> <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 { ParticleField } from '../../features/landing/effects/ParticleField'
import { VoxelScene } from '../../features/landing/effects/VoxelScene' import { VoxelScene } from '../../features/landing/effects/VoxelScene'
import { ListingList } from '../../features/showcase/components/ListingList' import { ListingList } from '../../features/showcase/components/ListingList'
import { PageTitle } from '../../shared/ui/PageTitle'
const ACCENT = 'rgb(92, 200, 231)' const ACCENT = 'rgb(92, 200, 231)'
@ -16,10 +17,7 @@ export default function ShowcasePage() {
<ParticleField color={ACCENT} className="opacity-50" /> <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 className="relative mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-4 pt-12 sm:px-6">
<div> <PageTitle lead={t('page.lead')}>{t('page.title')}</PageTitle>
<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>
<ListingList /> <ListingList />
</div> </div>
</section> </section>

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { ThemeEditor } from '../../features/themes/editor/ThemeEditor' import { ThemeEditor } from '../../features/themes/editor/ThemeEditor'
import { fromShareHash } from '../../features/themes/editor/model/codec' import { fromShareHash } from '../../features/themes/editor/model/codec'
import { PRESETS } from '../../features/themes/presets.generated' import { PRESETS } from '../../features/themes/presets.generated'
import { PageTitle } from '../../shared/ui/PageTitle'
import '../../features/themes/editor/editor.css' import '../../features/themes/editor/editor.css'
/** `/themes` — pick a preset (or load one from a `#t=` share link) and edit it live. */ /** `/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 <ThemeEditor
initial={initial} initial={initial}
intro={ intro={
<header className="flex flex-col gap-1"> <PageTitle lead={t('editor.lead')}>{t('editor.title')}</PageTitle>
<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>
} }
/> />
) )

View file

@ -7,7 +7,7 @@ import { TopBar } from './topbar/TopBar'
export function AppShell() { export function AppShell() {
const { t } = useTranslation('common') const { t } = useTranslation('common')
return ( return (
<> <div className="flex min-h-dvh flex-col">
<a <a
href="#main" 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" 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> </a>
<KonamiEffect /> <KonamiEffect />
<TopBar /> <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 /> <Outlet />
</main> </main>
<Footer /> <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' import { Link, type LinkProps } from 'react-router'
type Variant = 'primary' | 'quiet' | 'danger' type Variant = 'primary' | 'quiet' | 'danger'
@ -35,3 +35,12 @@ export function Button({
export function ButtonLink({ variant = 'primary', className = '', ...rest }: LinkProps & { variant?: Variant }) { export function ButtonLink({ variant = 'primary', className = '', ...rest }: LinkProps & { variant?: Variant }) {
return <Link className={`${BASE} ${VARIANTS[variant]} ${className}`} {...rest} /> 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 userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest' import { expect, test, vi } from 'vitest'
import { Button } from '../Button' import { Button } from '../Button'
import { PageTitle } from '../PageTitle'
import { TextField } from '../TextField' import { TextField } from '../TextField'
import { ShareCode } from '../ShareCode' import { ShareCode } from '../ShareCode'
import { Notice } from '../Notice' import { Notice } from '../Notice'
@ -34,3 +35,11 @@ test('error notice is an alert', () => {
render(<Notice tone="error">Неверный код</Notice>) render(<Notice tone="error">Неверный код</Notice>)
expect(screen.getByRole('alert')).toHaveTextContent('Неверный код') 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()
})