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

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