From 7bb981b58edcd6a622a0f0023fca815cf07d3c0a Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Mon, 28 Sep 2026 13:11:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20visual=20QA=20pass=20?= =?UTF-8?q?=E2=80=94=20unified=20page=20titles,=20CTAs,=20native=20control?= =?UTF-8?q?s,=20404=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/app/App.tsx | 10 +++- .../src/features/clickgui/screens/screens.css | 3 +- .../src/features/download/DownloadButton.tsx | 2 +- frontend/src/features/landing/CommandHero.tsx | 7 +-- frontend/src/features/landing/i18n/en.ts | 1 + frontend/src/features/landing/i18n/ru.ts | 1 + .../landing/sections/explore/FaqDownload.tsx | 2 +- .../features/landing/sections/hero/Hero.tsx | 7 ++- .../landing/sections/hero/ThemeStrip.tsx | 2 +- .../src/features/landing/styles/landing.css | 25 ++++++++++ .../showcase/components/ListingList.tsx | 36 ++++++++------- .../src/features/themes/editor/editor.css | 46 ++++++++++++++++++- .../themes/editor/fields/ColorField.tsx | 15 +++--- .../themes/editor/fields/GradientField.tsx | 2 +- frontend/src/index.css | 3 ++ frontend/src/pages/app/ConfigsPage.tsx | 18 +++++--- frontend/src/pages/app/LinkPage.tsx | 6 +-- frontend/src/pages/download/DownloadPage.tsx | 4 +- frontend/src/pages/public/NotFoundPage.tsx | 25 ++++++---- frontend/src/pages/public/auth/AuthSplit.tsx | 2 + frontend/src/pages/showcase/ShowcasePage.tsx | 6 +-- frontend/src/pages/themes/ThemesPage.tsx | 6 +-- frontend/src/shared/layout/AppShell.tsx | 6 +-- frontend/src/shared/ui/Button.tsx | 11 ++++- frontend/src/shared/ui/PageTitle.tsx | 16 +++++++ frontend/src/shared/ui/tests/ui.test.tsx | 9 ++++ 26 files changed, 198 insertions(+), 73 deletions(-) create mode 100644 frontend/src/shared/ui/PageTitle.tsx diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index e0cb955..fda517e 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -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() { diff --git a/frontend/src/features/clickgui/screens/screens.css b/frontend/src/features/clickgui/screens/screens.css index d66a342..3199e57 100644 --- a/frontend/src/features/clickgui/screens/screens.css +++ b/frontend/src/features/clickgui/screens/screens.css @@ -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; } diff --git a/frontend/src/features/download/DownloadButton.tsx b/frontend/src/features/download/DownloadButton.tsx index 80617ae..c277ff7 100644 --- a/frontend/src/features/download/DownloadButton.tsx +++ b/frontend/src/features/download/DownloadButton.tsx @@ -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 ( diff --git a/frontend/src/features/landing/CommandHero.tsx b/frontend/src/features/landing/CommandHero.tsx index 34e7006..bc15564 100644 --- a/frontend/src/features/landing/CommandHero.tsx +++ b/frontend/src/features/landing/CommandHero.tsx @@ -14,6 +14,7 @@ export function CommandHero() { return (
+ {t('cloud.eyebrow')}

{t('cloud.headlinePart1')} {' '}
@@ -22,11 +23,11 @@ export function CommandHero() {

{t('cloud.subtitle')}

{status === 'authenticated' ? ( - {t('cloud.ctaAuthenticated')} + {t('cloud.ctaAuthenticated')} ) : ( - {t('cloud.ctaAnonymous')} + {t('cloud.ctaAnonymous')} )} - + {t('cloud.ctaShowcase')}
diff --git a/frontend/src/features/landing/i18n/en.ts b/frontend/src/features/landing/i18n/en.ts index 95d85ea..16cc967 100644 --- a/frontend/src/features/landing/i18n/en.ts +++ b/frontend/src/features/landing/i18n/en.ts @@ -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.', diff --git a/frontend/src/features/landing/i18n/ru.ts b/frontend/src/features/landing/i18n/ru.ts index 69b8009..294163c 100644 --- a/frontend/src/features/landing/i18n/ru.ts +++ b/frontend/src/features/landing/i18n/ru.ts @@ -24,6 +24,7 @@ export const landingRu = { palette: 'Палитра темы', }, cloud: { + eyebrow: 'ОБЛАКО', headlinePart1: 'Твои настройки LoVisual', headlinePart2: 'на любом компьютере', subtitle: 'Четыре облачных слота, код, которым делишься с друзьями, и витрина готовых конфигов.', diff --git a/frontend/src/features/landing/sections/explore/FaqDownload.tsx b/frontend/src/features/landing/sections/explore/FaqDownload.tsx index f178a42..47c5a28 100644 --- a/frontend/src/features/landing/sections/explore/FaqDownload.tsx +++ b/frontend/src/features/landing/sections/explore/FaqDownload.tsx @@ -32,7 +32,7 @@ export function FaqDownload() {

{t('faq.downloadTitle')}

{t('faq.downloadLead')}

- + {t('faq.downloadCta')}

{t('faq.downloadNote')}

diff --git a/frontend/src/features/landing/sections/hero/Hero.tsx b/frontend/src/features/landing/sections/hero/Hero.tsx index bec9195..cf7b9ef 100644 --- a/frontend/src/features/landing/sections/hero/Hero.tsx +++ b/frontend/src/features/landing/sections/hero/Hero.tsx @@ -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')} - + {t('hero.download')} - + {t('hero.register')} - + {t('hero.downloadHint')} diff --git a/frontend/src/features/landing/sections/hero/ThemeStrip.tsx b/frontend/src/features/landing/sections/hero/ThemeStrip.tsx index b525283..6fd5652 100644 --- a/frontend/src/features/landing/sections/hero/ThemeStrip.tsx +++ b/frontend/src/features/landing/sections/hero/ThemeStrip.tsx @@ -51,7 +51,7 @@ export function ThemeStrip() {
- + {t('themeStrip.cta')} diff --git a/frontend/src/features/landing/styles/landing.css b/frontend/src/features/landing/styles/landing.css index 1299fdc..e189c6b 100644 --- a/frontend/src/features/landing/styles/landing.css +++ b/frontend/src/features/landing/styles/landing.css @@ -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; diff --git a/frontend/src/features/showcase/components/ListingList.tsx b/frontend/src/features/showcase/components/ListingList.tsx index ffaab18..5e2686b 100644 --- a/frontend/src/features/showcase/components/ListingList.tsx +++ b/frontend/src/features/showcase/components/ListingList.tsx @@ -45,14 +45,16 @@ export function ListingList({ author }: { author?: string } = {}) { return (
-
- - -
+ {!isError ? ( +
+ + +
+ ) : null} {isError ? {t('page.error')} : null} @@ -68,14 +70,16 @@ export function ListingList({ author }: { author?: string } = {}) { ) : null} -
- - -
+ {!isError ? ( +
+ + +
+ ) : null}
) } diff --git a/frontend/src/features/themes/editor/editor.css b/frontend/src/features/themes/editor/editor.css index 8ca0509..4f02e41 100644 --- a/frontend/src/features/themes/editor/editor.css +++ b/frontend/src/features/themes/editor/editor.css @@ -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; +} diff --git a/frontend/src/features/themes/editor/fields/ColorField.tsx b/frontend/src/features/themes/editor/fields/ColorField.tsx index bf11af6..9dfe7b2 100644 --- a/frontend/src/features/themes/editor/fields/ColorField.tsx +++ b/frontend/src/features/themes/editor/fields/ColorField.tsx @@ -29,19 +29,20 @@ export function ColorField({ label, value, onChange }: Props) { return ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 87cad55..4dd0087 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 { diff --git a/frontend/src/pages/app/ConfigsPage.tsx b/frontend/src/pages/app/ConfigsPage.tsx index a61f142..9d8d89a 100644 --- a/frontend/src/pages/app/ConfigsPage.tsx +++ b/frontend/src/pages/app/ConfigsPage.tsx @@ -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() {
-
-

{t('page.title')}

-

- {t('page.leadBefore')} %config save <слот> - {t('page.leadAfter')} -

-
+ + {t('page.leadBefore')} %config save <слот> + {t('page.leadAfter')} + + } + > + {t('page.title')} +

diff --git a/frontend/src/pages/app/LinkPage.tsx b/frontend/src/pages/app/LinkPage.tsx index c3cd40b..b38145c 100644 --- a/frontend/src/pages/app/LinkPage.tsx +++ b/frontend/src/pages/app/LinkPage.tsx @@ -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 (
-
-

{t('page.title')}

-

{t('page.lead')}

-
+ {t('page.title')}
    {STEPS.map((step, i) => ( diff --git a/frontend/src/pages/download/DownloadPage.tsx b/frontend/src/pages/download/DownloadPage.tsx index 5ae3fd6..fb5fb25 100644 --- a/frontend/src/pages/download/DownloadPage.tsx +++ b/frontend/src/pages/download/DownloadPage.tsx @@ -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 (
    -

    {t('page.title')}

    -

    {t('page.lead')}

    + {t('page.title')} +
    -

    {t('notFound.title')}

    -

    - {t('notFound.body')}{' '} - - {t('notFound.home')} - -

    + + {t('notFound.body')}{' '} + + {t('notFound.home')} + + + } + > + {t('notFound.title')} +
    ) } diff --git a/frontend/src/pages/public/auth/AuthSplit.tsx b/frontend/src/pages/public/auth/AuthSplit.tsx index 0091e79..5653b97 100644 --- a/frontend/src/pages/public/auth/AuthSplit.tsx +++ b/frontend/src/pages/public/auth/AuthSplit.tsx @@ -26,6 +26,8 @@ export function AuthSplit({ title, children }: { title: string; children: ReactN
    + {/* Bottom scrim keeps the caption readable over the bright pixel-art ground. */} +
diff --git a/frontend/src/pages/themes/ThemesPage.tsx b/frontend/src/pages/themes/ThemesPage.tsx index ae21c8b..8aac99d 100644 --- a/frontend/src/pages/themes/ThemesPage.tsx +++ b/frontend/src/pages/themes/ThemesPage.tsx @@ -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() { -

{t('editor.title')}

-

{t('editor.lead')}

- + {t('editor.title')} } /> ) diff --git a/frontend/src/shared/layout/AppShell.tsx b/frontend/src/shared/layout/AppShell.tsx index f53f083..8be8cae 100644 --- a/frontend/src/shared/layout/AppShell.tsx +++ b/frontend/src/shared/layout/AppShell.tsx @@ -7,7 +7,7 @@ import { TopBar } from './topbar/TopBar' export function AppShell() { const { t } = useTranslation('common') return ( - <> +
-
+
- +
) } diff --git a/frontend/src/shared/ui/Button.tsx b/frontend/src/shared/ui/Button.tsx index 501ba7f..d25f331 100644 --- a/frontend/src/shared/ui/Button.tsx +++ b/frontend/src/shared/ui/Button.tsx @@ -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 } + +/** Like ButtonLink but escapes to a plain for external/href CTAs (e.g. GitHub downloads). */ +export function ButtonAnchor({ + variant = 'primary', + className = '', + ...rest +}: AnchorHTMLAttributes & { variant?: Variant }) { + return +} diff --git a/frontend/src/shared/ui/PageTitle.tsx b/frontend/src/shared/ui/PageTitle.tsx new file mode 100644 index 0000000..7606132 --- /dev/null +++ b/frontend/src/shared/ui/PageTitle.tsx @@ -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 ( +
+

+ {children} +

+ {lead ?

{lead}

: null} +
+ ) +} diff --git a/frontend/src/shared/ui/tests/ui.test.tsx b/frontend/src/shared/ui/tests/ui.test.tsx index 0c3a687..1ccff6e 100644 --- a/frontend/src/shared/ui/tests/ui.test.tsx +++ b/frontend/src/shared/ui/tests/ui.test.tsx @@ -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(Неверный код) expect(screen.getByRole('alert')).toHaveTextContent('Неверный код') }) + +test('page title renders one h1 with an optional lead', () => { + render( + Мои конфиги, + ) + expect(screen.getByRole('heading', { level: 1, name: 'Мои конфиги' })).toBeInTheDocument() + expect(screen.getByText('Короткое описание')).toBeInTheDocument() +})