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:
parent
10d90dce93
commit
7bb981b58e
26 changed files with 198 additions and 73 deletions
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export const landingRu = {
|
|||
palette: 'Палитра темы',
|
||||
},
|
||||
cloud: {
|
||||
eyebrow: 'ОБЛАКО',
|
||||
headlinePart1: 'Твои настройки LoVisual',
|
||||
headlinePart2: 'на любом компьютере',
|
||||
subtitle: 'Четыре облачных слота, код, которым делишься с друзьями, и витрина готовых конфигов.',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue