feat(frontend): landing redesign, mock/fake-data cleanup, Safari fix

Landing:
- Rewrote all landing copy (ru/en): removed the tracked-caps eyebrow
  chrome pattern everywhere, active-voice player-facing text
- ThemeStrip: full-bleed live-recolored ClickGui window instead of a
  boxed swatch panel; HudPlayground: full-size real HUD screen
- ShowcaseTeaser: dropped the fake "example" fallback cards, shows an
  honest empty state when there aren't 3 real configs yet
- Fixed VoxelScene parallax leaking outside the hero into the HUD
  section

Data accuracy: the module list/counts advertised InvisESP, BlockESP
and FakeLag — all three were removed from the mod earlier this
session as cheat modules. Corrected the catalogue and the 88/52
module-count claims to the real 86/50.

Design QOL pass: removed the same eyebrow-label chrome from the site
footer; replaced plausible-looking fake share codes (howItWorks demo
slots, the chat command typing demo) with an explicit placeholder
string so they read as illustration, not fabricated real data.

Perf: promoted .lv-lower-bg onto its own compositor layer
(will-change/translateZ) — a full-height, multi-gradient, masked,
continuously-animated background layer was repainting on every
scroll tick in Safari.

Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
This commit is contained in:
loki5512344 2026-09-29 17:22:02 +02:00
parent 3f679b83f6
commit 9d423456af
Signed by: boba
GPG key ID: 253067914055423B
20 changed files with 830 additions and 538 deletions

View file

@ -26,7 +26,7 @@ export const CATEGORIES: Category[] = [
id: 'player',
name: 'Player',
modules: list(
['Auto Accept', 'Auto Leave', 'Auto Respawn', 'AutoTool', 'ClickPearl', 'Death Coords', 'FakeLag', 'Lock Slot', 'NoDelay',
['Auto Accept', 'Auto Leave', 'Auto Respawn', 'AutoTool', 'ClickPearl', 'Death Coords', 'Lock Slot', 'NoDelay',
'NoInteract', 'Tape Mouse', 'Third Person', 'Use Tracker'],
['Auto Accept'],
),
@ -35,9 +35,9 @@ export const CATEGORIES: Category[] = [
id: 'visuals',
name: 'Visuals',
modules: list(
['AlwaysVisibleHand', 'AmbientParticles', 'Animations', 'AspectRatio', 'AtmoDawnFog', 'BackSword', 'BlockESP', 'BlockHighlight',
['AlwaysVisibleHand', 'AmbientParticles', 'Animations', 'AspectRatio', 'AtmoDawnFog', 'BackSword', 'BlockHighlight',
'CameraClip', 'Chams', 'ChinaHat', 'Crosshair', 'CrystalHoles', 'DropESP', 'EndCrystal', 'ESP', 'FovControl', 'FPS Optimizer',
'Freecam', 'FullBright', 'Gps', 'Hitboxes', 'HitBubbles', 'HitEffect', 'InvisESP', 'Item Holder', 'Item Physic', 'JumpCircles',
'Freecam', 'FullBright', 'Gps', 'Hitboxes', 'HitBubbles', 'HitEffect', 'Item Holder', 'Item Physic', 'JumpCircles',
'KillEffect', 'KineticLyrics', 'Light', 'Lightning Target ESP', 'MotionBlur', 'NameTags', 'NoRender', 'PostFX', 'Rain',
'ReimaginedVisual', 'ShaderSky', 'Shulker Preview', 'SoundESP', 'TazikHat', 'TotemFX', 'Trails', 'Trinket', 'Trollface Mask',
'ViewModel', 'Wet World', 'Wings', 'WorldParticles', 'WorldTweaks', 'Zoom'],

View file

@ -1,26 +1,27 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { prefersReducedMotion } from '@shared/motion/reducedMotion'
import { ButtonLink } from '@shared/ui/Button'
import { useSession } from '../auth/session'
import { useInView } from './effects/useInView'
import '@features/landing/styles/sections.css'
const COMMAND = '%config load 7KQ3M9XA'
// A placeholder code, not a plausible real one — this types out a UI demo, not live data.
const COMMAND = '%config load ••••••••'
const TYPE_DELAY_MS = 60
/** Cloud configs, told through the one thing a player actually does: a command in the game chat. */
export function CommandHero() {
const { t } = useTranslation('landing')
const { status } = useSession()
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" />
<span className="text-ice">{t('cloud.headlinePart2')}</span>
</h2>
<p className="max-w-[60ch] text-lg text-frost">{t('cloud.subtitle')}</p>
<section aria-labelledby="cloud-title" className="relative flex flex-col gap-8 pb-16 pt-12 sm:pt-20">
<ChatBar />
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:gap-14">
<h2 id="cloud-title" className="text-[1.75rem] leading-[1.1] sm:text-[2.4rem]">{t('cloud.title')}</h2>
<div className="flex min-w-0 flex-col items-start gap-5">
<p className="max-w-[52ch] text-lg text-frost">{t('cloud.subtitle')}</p>
<div className="flex flex-wrap items-center gap-3">
{status === 'authenticated' ? (
<ButtonLink to="/configs" className="px-8 py-3 text-base">{t('cloud.ctaAuthenticated')}</ButtonLink>
@ -31,7 +32,8 @@ export function CommandHero() {
{t('cloud.ctaShowcase')}
</ButtonLink>
</div>
<ChatBar />
</div>
</div>
</section>
)
}
@ -40,28 +42,30 @@ function ChatBar() {
const { t } = useTranslation('landing')
const [typed, setTyped] = useState(() => (prefersReducedMotion() ? COMMAND.length : 0))
const done = typed >= COMMAND.length
// Typing starts once the chat is on screen, so the player actually sees the command go in.
const ref = useRef<HTMLDivElement>(null)
const inView = useInView(ref, { once: true, rootMargin: '-80px' })
useEffect(() => {
if (done) return
if (done || !inView) return
const t = setInterval(() => setTyped((n) => Math.min(n + 1, COMMAND.length)), TYPE_DELAY_MS)
return () => clearInterval(t)
}, [done])
}, [done, inView])
return (
<div
ref={ref}
role="img"
aria-label={t('cloud.chatAriaLabel')}
className="mt-6 flex min-h-[4.5rem] w-full max-w-xl flex-col justify-end gap-1 rounded-md border border-white/5 bg-black/55 px-3 py-2 font-code text-base text-snow shadow-[0_0_40px_-12px_var(--page-accent-glow)]"
className="lv-chat flex min-h-[5.5em] w-full flex-col justify-end gap-1 px-4 py-3 font-code text-snow sm:px-6"
>
<p aria-hidden="true" className="whitespace-pre-wrap break-all">
{COMMAND.slice(0, typed)}
{done ? null : <span className="animate-pulse text-frost">▌</span>}
</p>
{done ? (
<p aria-hidden="true" className="text-ice">
<p aria-hidden="true" className={done ? 'text-ice' : 'invisible'}>
{t('cloud.chatConfirmation')}
</p>
) : null}
</div>
)
}

View file

@ -1,45 +1,56 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import '@features/landing/styles/sections.css'
export function HowItWorks() {
const { t } = useTranslation('landing')
// Placeholder-style codes (not plausible real share codes) — this is an illustration of
// the slot/code concept, not a real config, so it shouldn't look like fabricated data.
const placeholderCode = t('howItWorks.slotCodePlaceholder')
const slots = [
{ name: t('howItWorks.slotNamePvp'), code: '7KQ3M9XA' },
{ name: t('howItWorks.slotNameStream'), code: 'R2WD8HNC' },
{ name: t('howItWorks.slotNameLazy'), code: 'L5TB4QZE' },
{ name: t('howItWorks.slotNamePvp'), code: placeholderCode },
{ name: t('howItWorks.slotNameStream'), code: placeholderCode },
{ name: t('howItWorks.slotNameLazy'), code: placeholderCode },
{ name: null, code: null },
]
const linkSteps = [t('howItWorks.linkStep1'), t('howItWorks.linkStep2'), t('howItWorks.linkStep3')]
return (
<div className="flex flex-col gap-16 pb-20">
<section className="grid items-center gap-8 md:grid-cols-2">
<section className="grid items-center gap-8 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div className="flex flex-col gap-3">
<h2 className="text-[1.5625rem] leading-tight">{t('howItWorks.slotsTitle')}</h2>
<p className="max-w-[60ch] text-frost">{t('howItWorks.slotsDesc')}</p>
<p className="max-w-[46ch] text-frost">{t('howItWorks.slotsDesc')}</p>
</div>
<ul className="grid grid-cols-2 gap-3" aria-label={t('howItWorks.slotsAriaLabel')}>
{/* The four slots drawn as a Minecraft hotbar; the first one is the "selected" slot. */}
<ul className="grid grid-cols-2 gap-2 sm:grid-cols-4" aria-label={t('howItWorks.slotsAriaLabel')}>
{slots.map((slot, i) => (
<li key={i} className="flex min-w-0 flex-col gap-1 rounded-panel border border-shoal bg-slate px-4 py-3">
<li
key={i}
data-selected={i === 0}
data-empty={!slot.name}
className="lv-hotbar-slot flex aspect-[4/3] min-w-0 flex-col justify-end gap-1 p-3 sm:aspect-square"
>
{slot.name ? (
<>
<span className="truncate text-snow">{slot.name}</span>
<span className="font-code text-sm tracking-wider text-ice">{slot.code}</span>
<span className="truncate font-display text-lg font-bold text-snow">{slot.name}</span>
<span className="font-code text-xs tracking-wider text-ice">{slot.code}</span>
</>
) : (
<span className="italic text-frost">{t('howItWorks.emptySlot')}</span>
<span className="text-frost">{t('howItWorks.emptySlot')}</span>
)}
</li>
))}
</ul>
</section>
<section className="flex flex-col gap-4">
<section className="flex flex-col gap-6">
<h2 className="text-[1.5625rem] leading-tight">{t('howItWorks.linkTitle')}</h2>
<ol className="flex max-w-[60ch] list-decimal flex-col gap-2 pl-6 marker:text-frost">
{linkSteps.map((step) => (
<li key={step} className="pl-1">
{step}
<ol className="grid gap-6 md:grid-cols-3">
{linkSteps.map((step, i) => (
<li key={step} className="flex gap-4 border-t border-white/10 pt-4">
<span aria-hidden="true" className="font-display text-3xl font-bold leading-none text-ice">{i + 1}</span>
<span className="text-snow/90">{step}</span>
</li>
))}
</ol>
@ -47,7 +58,7 @@ export function HowItWorks() {
<p className="max-w-[60ch] text-frost">
{t('howItWorks.showcaseText')}{' '}
<Link to="/showcase" className="text-ice underline-offset-4 hover:underline">
<Link to="/showcase" className="text-ice underline underline-offset-4">
{t('howItWorks.showcaseLink')}
</Link>
</p>

View file

@ -130,8 +130,12 @@ function drawSky(ctx: CanvasRenderingContext2D, w: number, h: number) {
}
}
/** Procedural blocky dusk landscape with layered scroll parallax. Purely decorative. */
export function VoxelScene({ accent, className = '' }: { accent: string; className?: string }) {
/**
* Procedural blocky dusk landscape with layered scroll parallax. Purely decorative.
* The parallax is tuned for the top of the page (it follows window.scrollY); scenes further down
* pass `parallax={false}`, or the layers slide out of their frame by the time it is on screen.
*/
export function VoxelScene({ accent, className = '', parallax = true }: { accent: string; className?: string; parallax?: boolean }) {
const rootRef = useRef<HTMLDivElement>(null)
const canvases = useRef<(HTMLCanvasElement | null)[]>([])
@ -160,7 +164,7 @@ export function VoxelScene({ accent, className = '' }: { accent: string; classNa
}, [accent])
useEffect(() => {
if (prefersReducedMotion()) return
if (!parallax || prefersReducedMotion()) return
let frame = 0
const lags = [0.5, ...LAYERS.map((l) => l.lag)]
const onScroll = () => {
@ -178,7 +182,7 @@ export function VoxelScene({ accent, className = '' }: { accent: string; classNa
window.removeEventListener('scroll', onScroll)
cancelAnimationFrame(frame)
}
}, [])
}, [parallax])
return (
<div ref={rootRef} aria-hidden="true" className={`lv-voxel pointer-events-none absolute inset-0 overflow-hidden ${className}`}>

View file

@ -6,59 +6,55 @@ export const landingEn = {
badge: 'Minecraft 26.2 · Fabric',
headlineLead: 'Visuals',
headlineAccent: "you'll fall in love with",
lead: 'A free legit mod for Minecraft 26.2: 52 visual modules, cloud configs and themes you build yourself.',
lead: 'A free Minecraft 26.2 mod: wings, particles, your own HUD, weather and fifty-odd other visual modules. Not a cheat, just pretty.',
download: 'Download for free',
downloadHint: 'One .jar straight from GitHub',
downloadHint: 'One .jar from GitHub: drop it in mods and play',
register: 'Create account',
facts: {
modules: 'modules',
visuals: 'for visuals',
themes: 'built-in themes',
},
tryIt: 'This is the real interface: hover and click around',
tryIt: "It's not a picture: toggle modules, open the settings",
},
themeStrip: {
eyebrow: 'Themes',
title: 'Hundreds of shades. Yours is one.',
lead: 'Every built-in theme, straight from the mod. Click one: the ClickGui and the whole site recolor on the fly.',
title: 'Repaint the menu. And the site while you’re at it.',
lead: 'Every theme the mod ships with. Click any of them: the menu up top, this block and the whole site change color.',
cta: 'Build your own theme',
current: 'Now',
current: 'Active now',
swatches: 'Built-in themes',
palette: 'Theme palette',
palette: 'Theme colors',
},
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.',
title: 'Your friend has a sick config? One command in chat.',
subtitle: 'Every cloud slot has its own code. Send it to a friend or type it yourself on another PC: the settings load right in game, no files, no zips.',
ctaAuthenticated: 'My configs',
ctaAnonymous: 'Create account',
ctaShowcase: 'View showcase',
ctaShowcase: "See other people's configs",
chatConfirmation: '[LoVisual] Loaded config "pvp"',
chatAriaLabel: 'Example: loading a config by code in the game chat',
},
howItWorks: {
slotsTitle: 'Four slots in the cloud',
slotsDesc:
'Save up to four sets of settings and load any of them on another computer: every slot has a code a friend can load too.',
slotsTitle: 'Four slots for every mood',
slotsDesc: 'One for PvP, one for streaming, one for the days you can’t be bothered. Each has its own code.',
slotsAriaLabel: 'Example slots',
emptySlot: 'empty',
slotNamePvp: 'pvp',
slotNameStream: 'stream',
slotNameLazy: 'lazy',
linkTitle: 'Linking the game',
linkStep1: 'Open the account tab in the mod, a code will appear there.',
linkStep2: 'Enter the code on the "Link game" page.',
linkStep3: "Done: the mod signs in to your account by itself, no password needed.",
showcaseText: 'Any config from the showcase can be copied into a free slot with one button.',
slotCodePlaceholder: '••••••••',
linkTitle: 'How to link the game to your account',
linkStep1: 'Open the Account tab in the mod: your code is there.',
linkStep2: 'Paste it on the "Link game" page.',
linkStep3: "That's it. From now on the mod signs in by itself, no password.",
showcaseText: 'Like someone’s config on the showcase? One button and it’s in your free slot.',
showcaseLink: 'Open showcase',
},
hud: {
eyebrow: 'HUD',
title: 'Arrange the widgets like in game',
lead: 'The same widgets the mod draws: FPS, coordinates, module list, keys and ping. Drag them with the mouse or arrow keys.',
caption: 'Drag it, just like in game.',
reset: 'Reset',
title: 'Move the HUD right here',
lead: 'FPS, coordinates, module list, keys and ping: the same widgets the mod draws. Grab them with the mouse or push them with the arrow keys.',
caption: 'In game the HUD moves exactly like this.',
reset: 'Put it back',
widgetFps: 'FPS',
widgetCoords: 'Coordinates',
widgetModules: 'Module list',
@ -68,37 +64,34 @@ export const landingEn = {
dragAria: 'Draggable HUD widget "{{name}}". Arrow keys move it.',
},
wall: {
eyebrow: 'Modules',
title: '88 modules. 52 are about looks.',
lead: 'The whole category wall. Hover a visuals module: I\'ll tell you what it does.',
title: '86 modules. 50 are about looks.',
lead: 'The full list, same as the in-game menu. Hover a column and it stops, so you can actually read it.',
columnsAria: 'Module categories',
},
showcase: {
eyebrow: 'Showcase',
title: 'Ready-made configs from the community',
lead: 'The most popular showcase configs. Copy any of them into your slot with one button.',
cta: 'Open showcase',
example: 'Example',
title: 'What other players run',
lead: 'The most copied configs on the showcase. See one you like? Grab it into your slot.',
cta: 'Whole showcase',
empty: 'Nothing here yet. Be the first to share a config.',
by: 'by {{author}}',
downloads: '{{count}} downloads',
copies: 'copies',
},
faq: {
eyebrow: 'FAQ',
title: 'Frequently asked',
title: 'What people usually ask',
q1: 'Is it really free?',
a1: 'Yes. The mod is free and open source: no subscriptions, no pay-to-unlock features.',
q2: 'Which Minecraft versions are supported?',
a1: 'Yes. Open source, no subscriptions, nothing to pay for.',
q2: 'Which version does it run on?',
a2: 'Minecraft 26.2 on the Fabric loader.',
q3: 'Do I need an account?',
a3: 'Not for the mod itself. An account is only needed for cloud slots and the config showcase.',
q4: 'Is it legit? Will I get banned?',
a4: 'LoVisual is a visuals mod: it reads nothing from memory and sends nothing to the server. Your admin decides.',
q5: 'How do I move settings to another computer?',
a5: 'Save a config to a cloud slot and load it by code on any other PC.',
downloadTitle: 'Ready to try it?',
downloadLead: 'One .jar straight from GitHub, just drop it into your mods folder.',
a3: 'No, the mod works without one. You only need it for cloud slots and the showcase.',
q4: "It's not a cheat? Will I get banned?",
a4: 'LoVisual only changes what you see: it doesn’t touch memory and sends nothing to the server. Every server has its own rules though, so the admin has the final say.',
q5: 'How do I move my settings to another PC?',
a5: 'Save your config to a cloud slot, then load it on the other PC with the slot’s code.',
downloadTitle: 'Grab it and jump in',
downloadLead: 'One .jar from GitHub. Drop it in your mods folder, launch the game with Fabric, open the menu.',
downloadCta: 'Download for free',
downloadNote: 'Minecraft 26.2 · Fabric · the latest release',
releaseLink: 'Releases and versions',
downloadNote: 'Latest release for Minecraft 26.2 on Fabric',
releaseLink: 'All versions on GitHub',
},
} satisfies Translation<typeof landingRu>

View file

@ -3,59 +3,55 @@ export const landingRu = {
badge: 'Minecraft 26.2 · Fabric',
headlineLead: 'Визуалы,',
headlineAccent: 'в которые влюбляешься',
lead: 'Бесплатный легит-мод для Minecraft 26.2: 52 визуальных модуля, облачные конфиги и темы, которые ты собираешь сам.',
lead: 'Бесплатный мод на Minecraft 26.2: крылья, партиклы, свой HUD, погода и ещё полсотни визуальных модулей. Не чит, просто красиво.',
download: 'Скачать бесплатно',
downloadHint: 'Один .jar прямо с GitHub',
downloadHint: 'Один .jar с GitHub: кидаешь в mods и играешь',
register: 'Создать аккаунт',
facts: {
modules: 'модулей',
visuals: 'про визуал',
themes: 'встроенных тем',
},
tryIt: 'Это настоящий интерфейс: наведи и покликай',
tryIt: 'Это не картинка: включай модули, открывай настройки',
},
themeStrip: {
eyebrow: 'Темы',
title: 'Сотни оттенков. Твой один.',
lead: 'Все встроенные темы прямо из мода. Нажми на любую: ClickGui и весь сайт перекрасятся на лету.',
title: 'Перекрась меню. И сайт заодно.',
lead: 'Здесь все темы, которые есть в моде. Жми на любую: перекрасится меню наверху, этот блок и вообще весь сайт.',
cta: 'Собрать свою тему',
current: 'Сейчас',
current: 'Сейчас включена',
swatches: 'Встроенные темы',
palette: 'Палитра темы',
palette: 'Цвета темы',
},
cloud: {
eyebrow: 'ОБЛАКО',
headlinePart1: 'Твои настройки LoVisual',
headlinePart2: 'на любом компьютере',
subtitle: 'Четыре облачных слота, код, которым делишься с друзьями, и витрина готовых конфигов.',
title: 'Увидел у друга крутой конфиг? Одна команда в чат.',
subtitle: 'У каждого облачного слота свой код. Скинь его другу или вбей сам на другом компе: настройки подтянутся прямо в игре, без файлов и архивов.',
ctaAuthenticated: 'Мои конфиги',
ctaAnonymous: 'Создать аккаунт',
ctaShowcase: 'Смотреть витрину',
ctaShowcase: 'Смотреть чужие конфиги',
chatConfirmation: '[LoVisual] Конфиг «pvp» загружен',
chatAriaLabel: 'Пример: загрузка конфига по коду в чате игры',
},
howItWorks: {
slotsTitle: 'Четыре слота в облаке',
slotsDesc:
'Сохрани до четырёх наборов настроек и загружай любой из них на другом компьютере: у каждого слота есть код, по которому его может загрузить и друг.',
slotsTitle: 'Четыре слота на все случаи',
slotsDesc: 'Один под PvP, второй под стрим, третий на день, когда лень что-то включать. У каждого свой код.',
slotsAriaLabel: 'Пример слотов',
emptySlot: 'пусто',
slotNamePvp: 'pvp',
slotNameStream: 'стрим',
slotNameLazy: 'ленивый',
linkTitle: 'Привязка игры',
linkStep1: 'В моде открой вкладку аккаунта, там появится код.',
linkStep2: 'Введи код на странице «Привязать игру».',
linkStep3: 'Готово: мод входит в аккаунт сам, пароль ему не нужен.',
showcaseText: 'Любой конфиг из витрины можно скопировать в свободный слот одной кнопкой.',
slotCodePlaceholder: '••••••••',
linkTitle: 'Как привязать игру к аккаунту',
linkStep1: 'Открой в моде вкладку «Аккаунт»: там будет код.',
linkStep2: 'Вставь его на странице «Привязать игру».',
linkStep3: 'Всё. Дальше мод заходит в аккаунт сам, пароль ему не нужен.',
showcaseText: 'Понравился чужой конфиг на витрине? Одна кнопка, и он у тебя в свободном слоте.',
showcaseLink: 'Открыть витрину',
},
hud: {
eyebrow: 'HUD',
title: 'Расставь элементы как в игре',
lead: 'Те же виджеты, что рисует мод: FPS, координаты, список модулей, клавиши и пинг. Таскай их мышкой или стрелками.',
caption: 'Перетащи так же, как в игре.',
reset: 'Сбросить',
title: 'Двигай HUD прямо тут',
lead: 'FPS, координаты, список модулей, клавиши и пинг: это те же виджеты, что рисует мод. Хватай мышкой или гоняй стрелками.',
caption: 'В игре HUD двигается точно так же.',
reset: 'Вернуть как было',
widgetFps: 'FPS',
widgetCoords: 'Координаты',
widgetModules: 'Список модулей',
@ -65,37 +61,34 @@ export const landingRu = {
dragAria: 'Перетаскиваемый HUD-виджет «{{name}}». Стрелки: двигать.',
},
wall: {
eyebrow: 'Модули',
title: '88 модулей. 52 про красоту.',
lead: 'Вся витрина категорий. Наведи на визуальный модуль: расскажу, что он делает.',
title: '86 модулей. 50 про красоту.',
lead: 'Весь список, как в игровом меню. Наведи на колонку, и она остановится: можно спокойно прочитать.',
columnsAria: 'Категории модулей',
},
showcase: {
eyebrow: 'Витрина',
title: 'Готовые конфиги от сообщества',
lead: 'Самые популярные конфиги витрины. Любой можно скопировать в свой слот одной кнопкой.',
cta: 'Открыть витрину',
example: 'Пример',
title: 'Что ставят другие',
lead: 'Самые копируемые конфиги с витрины. Понравился какой-то? Забирай в свой слот.',
cta: 'Вся витрина',
empty: 'Пока пусто. Будь первым, кто поделится конфигом.',
by: 'от {{author}}',
downloads: '{{count}} загрузок',
copies: 'копий',
},
faq: {
eyebrow: 'Вопросы',
title: 'Частые вопросы',
title: 'Что обычно спрашивают',
q1: 'Это правда бесплатно?',
a1: 'Да. Мод бесплатный и с открытым кодом: без подписок и без доната за функции.',
q2: 'Какие версии Minecraft поддерживаются?',
a1: 'Да. Код открыт, подписок нет, платить за функции не надо.',
q2: 'На какой версии работает?',
a2: 'Minecraft 26.2 на загрузчике Fabric.',
q3: 'Нужен ли аккаунт?',
a3: 'Для самого мода: нет. Аккаунт нужен только для облачных слотов и витрины конфигов.',
q4: 'Это легитно? Забанят ли?',
a4: 'LoVisual, визуальный мод: он ничего не читает из памяти и не отправляет на сервер. Решает админ сервера.',
q5: 'Как перенести настройки на другой компьютер?',
a5: 'Сохрани конфиг в облачный слот и загрузи его по коду на любом другом ПК.',
downloadTitle: 'Готов попробовать?',
downloadLead: 'Один .jar прямо с GitHub, просто положи его в папку mods.',
q3: 'Аккаунт обязателен?',
a3: 'Нет, мод работает и без него. Аккаунт нужен только для облачных слотов и витрины.',
q4: 'Это не чит? Не забанят?',
a4: 'LoVisual меняет только то, что видишь ты: не лезет в память и ничего не шлёт на сервер. Но правила у каждого сервера свои, последнее слово за админом.',
q5: 'Как перенести настройки на другой комп?',
a5: 'Сохрани конфиг в облачный слот, а на другом компе загрузи его по коду слота.',
downloadTitle: 'Качай и заходи в игру',
downloadLead: 'Один .jar с GitHub. Кидаешь его в папку mods, запускаешь игру через Fabric, открываешь меню.',
downloadCta: 'Скачать бесплатно',
downloadNote: 'Minecraft 26.2 · Fabric · самый свежий релиз',
releaseLink: 'Список релизов и версий',
downloadNote: 'Последний релиз для Minecraft 26.2 на Fabric',
releaseLink: 'Все версии на GitHub',
},
} as const

View file

@ -1,50 +1,61 @@
import { useTranslation } from 'react-i18next'
import { themeToCssVars } from '@features/clickgui/themeVars'
import { useActiveTheme } from '@features/themes/useActiveTheme'
import { DOWNLOAD_URL, GITHUB_REPO } from '@features/download/api'
import '@features/landing/styles/landing.css'
import '@features/landing/styles/sections.css'
const KEYS = ['1', '2', '3', '4', '5'] as const
function DownloadIcon() {
return (
<svg viewBox="0 0 20 20" className="size-6" aria-hidden="true">
<path d="M10 3v10m0 0-4-4m4 4 4-4M4 16h12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
/** Plain FAQ list, then the page closes on a full-width download band in the page accent. */
export function FaqDownload() {
const { t } = useTranslation('landing')
const { theme } = useActiveTheme()
const items = KEYS.map((k) => ({ q: t(`faq.q${k}`), a: t(`faq.a${k}`) }))
return (
<section aria-labelledby="faq-title" style={themeToCssVars(theme)} className="grid gap-12 py-20 sm:py-28 lg:grid-cols-2 lg:gap-16">
<div className="flex min-w-0 flex-col gap-6">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('faq.eyebrow')}</span>
<>
<section aria-labelledby="faq-title" className="grid gap-8 py-20 sm:py-24 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] lg:gap-14">
<h2 id="faq-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('faq.title')}</h2>
<div className="flex flex-col gap-2">
<div className="flex min-w-0 flex-col">
{items.map((it) => (
<details key={it.q} className="lv-faq-item lv-glass">
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 text-base font-semibold text-snow">
<details key={it.q} className="lv-faq-item group border-t border-white/10 last:border-b">
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 py-5 text-lg font-semibold text-snow">
{it.q}
<span aria-hidden="true" className="lv-faq-marker" />
</summary>
<p className="mt-2 max-w-[52ch] text-sm leading-relaxed text-frost">{it.a}</p>
<p className="max-w-[58ch] pb-5 leading-relaxed text-frost">{it.a}</p>
</details>
))}
</div>
</div>
</section>
<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>
<section aria-labelledby="download-title" className="lv-bleed lv-download-final">
<div className="mx-auto flex max-w-[1120px] flex-col items-start gap-6 px-4 py-24 sm:px-6 sm:py-32">
<h2 id="download-title" className="lv-download-title">{t('faq.downloadTitle')}</h2>
<p className="max-w-[46ch] text-lg text-snow/85">{t('faq.downloadLead')}</p>
<div className="flex flex-wrap items-center gap-x-6 gap-y-4">
<a href={DOWNLOAD_URL} download className="lv-cta-primary lv-cta-lg">
<DownloadIcon />
{t('faq.downloadCta')}
</a>
<p className="font-code text-xs text-frost">{t('faq.downloadNote')}</p>
<a
href={`https://github.com/${GITHUB_REPO}/releases`}
target="_blank"
rel="noreferrer"
className="text-sm text-ice underline-offset-4 hover:underline"
className="text-snow underline underline-offset-4"
>
{t('faq.releaseLink')}
</a>
</div>
<p className="text-sm text-frost">{t('faq.downloadNote')}</p>
</div>
</section>
</>
)
}

View file

@ -5,164 +5,41 @@ import { themeToCssVars } from '@features/clickgui/themeVars'
import { parseArgb } from '@features/themes/types'
import { useActiveTheme } from '@features/themes/useActiveTheme'
import { VoxelScene } from '../../effects/VoxelScene'
import { bounds, clamp, fitToField, type Layout, startLayout, type WidgetId } from './hud/layout'
import { WidgetContent } from './hud/WidgetContent'
import '@features/landing/styles/landing.css'
import '@features/landing/styles/sections.css'
const STEP = 8
/** Fallback playfield size, used before the box is measured (and under jsdom). */
const FIELD = { w: 680, h: 380 }
const WIDGET = { w: 150, h: 56 }
type Pos = { x: number; y: number }
/** Real draggable HUD elements from the mod: features/gui/hud/draggable/impl. */
type WidgetId = 'fps' | 'coords' | 'modules' | 'keys' | 'ping'
const START: Record<WidgetId, Pos> = {
fps: { x: 20, y: 20 },
coords: { x: 20, y: 310 },
modules: { x: FIELD.w - 190, y: 20 },
ping: { x: FIELD.w - 170, y: 150 },
keys: { x: FIELD.w - 140, y: FIELD.h - 120 },
}
const clamp = (v: number, max: number) => Math.max(0, Math.min(v, max))
/** Pull every widget back inside the measured field (mount, resize, reset). */
function fitToField(
state: Record<WidgetId, Pos>,
field: HTMLDivElement | null,
widgets: Partial<Record<WidgetId, HTMLElement | null>>,
): Record<WidgetId, Pos> {
const w = field?.clientWidth || FIELD.w
const h = field?.clientHeight || FIELD.h
const next = { ...state }
let changed = false
for (const id of Object.keys(next) as WidgetId[]) {
const el = widgets[id]
const maxX = Math.max(0, w - (el?.offsetWidth || WIDGET.w))
const maxY = Math.max(0, h - (el?.offsetHeight || WIDGET.h))
const x = clamp(next[id].x, maxX)
const y = clamp(next[id].y, maxY)
if (x !== next[id].x || y !== next[id].y) {
next[id] = { x, y }
changed = true
}
}
return changed ? next : state
}
/** Enabled modules as they would appear in the mod's ModuleList panel. */
const MODULES_ON = ['TargetESP', 'PvpCooldowns', 'HitBubbles', 'ShaderSky']
/** Widgets read the accent through --gui-accent so it cross-fades with the active theme. */
const ACCENT = 'var(--gui-accent)'
function WidgetContent({ id, accent, unitMs }: { id: WidgetId; accent: string; unitMs: string }) {
switch (id) {
case 'fps':
return (
<div className="flex items-baseline gap-1.5">
<span className="font-code text-2xl leading-none font-bold" style={{ color: accent }}>
240
</span>
<span className="text-[0.6rem] tracking-[0.15em] text-frost uppercase">FPS</span>
</div>
)
case 'coords':
return (
<div className="font-code text-sm leading-none text-snow">
<span style={{ color: accent }}>X</span> 128&nbsp; <span style={{ color: accent }}>Y</span> 74&nbsp;{' '}
<span style={{ color: accent }}>Z</span> -204
</div>
)
case 'modules':
return (
<div className="flex flex-col items-end gap-1">
{MODULES_ON.map((m) => (
<span
key={m}
className="rounded-[5px] border-r-2 bg-black/25 px-2 py-[3px] font-code text-[0.7rem] leading-none text-snow"
style={{ borderRightColor: accent }}
>
{m}
</span>
))}
</div>
)
case 'keys': {
const cap = (k: string, pressed = false) => (
<span
key={k}
className="grid size-7 place-items-center rounded-[6px] border bg-white/5 font-code text-xs leading-none"
style={{
borderColor: pressed ? accent : 'rgb(255 255 255 / 0.12)',
color: pressed ? accent : undefined,
background: pressed ? `color-mix(in srgb, ${accent} 18%, transparent)` : undefined,
}}
>
{k}
</span>
)
return (
<div className="flex flex-col items-center gap-1">
{cap('W', true)}
<div className="flex gap-1">
{cap('A')}
{cap('S', true)}
{cap('D')}
</div>
</div>
)
}
case 'ping':
return (
<div className="flex items-center gap-2">
<span className="font-code text-sm leading-none text-snow">
32&nbsp;<span className="text-frost">{unitMs}</span>
</span>
<span aria-hidden="true" className="flex items-end gap-[3px]">
{[6, 10, 14, 18].map((h, i) => (
<span
key={h}
className="w-[4px] rounded-sm"
style={{ height: h, background: i < 2 ? accent : 'rgb(255 255 255 / 0.18)' }}
/>
))}
</span>
</div>
)
}
}
type Drag = { id: WidgetId; px: number; py: number; ox: number; oy: number }
/**
* A full game screen with the mod's real HUD widgets on it. Holding a widget switches the
* screen into "edit mode" (grid overlay, lifted widget), the same feedback the in-game editor gives.
*/
export function HudPlayground() {
const { t } = useTranslation('landing')
const { theme } = useActiveTheme()
const { r, g, b } = parseArgb(theme.theme.accent)
const fieldRef = useRef<HTMLDivElement>(null)
const widgetRefs = useRef<Partial<Record<WidgetId, HTMLElement | null>>>({})
const drag = useRef<{ id: WidgetId; px: number; py: number; ox: number; oy: number } | null>(null)
const [pos, setPos] = useState<Record<WidgetId, Pos>>(START)
const bounds = (el: HTMLElement) => {
const field = fieldRef.current
const w = field?.clientWidth || FIELD.w
const h = field?.clientHeight || FIELD.h
return { maxX: Math.max(0, w - (el.offsetWidth || WIDGET.w)), maxY: Math.max(0, h - (el.offsetHeight || WIDGET.h)) }
}
const drag = useRef<Drag | null>(null)
const [pos, setPos] = useState<Layout>(() => startLayout(null, {}))
const [held, setHeld] = useState<WidgetId | null>(null)
useLayoutEffect(() => {
const fit = () => setPos((prev) => fitToField(prev, fieldRef.current, widgetRefs.current))
fit()
setPos(startLayout(fieldRef.current, widgetRefs.current))
const field = fieldRef.current
if (!field || typeof ResizeObserver === 'undefined') return
const ro = new ResizeObserver(fit)
const ro = new ResizeObserver(() => setPos((prev) => fitToField(prev, fieldRef.current, widgetRefs.current)))
ro.observe(field)
return () => ro.disconnect()
}, [])
const nudge = (id: WidgetId, dx: number, dy: number, el: HTMLElement) => {
const { maxX, maxY } = bounds(el)
setPos((prev) => ({ ...prev, [id]: { x: clamp(prev[id].x + dx, maxX), y: clamp(prev[id].y + dy, maxY) } }))
const move = (id: WidgetId, x: number, y: number, el: HTMLElement) => {
const { maxX, maxY } = bounds(fieldRef.current, el)
setPos((prev) => ({ ...prev, [id]: { x: clamp(x, maxX), y: clamp(y, maxY) } }))
}
const onKeyDown = (id: WidgetId) => (e: React.KeyboardEvent<HTMLElement>) => {
@ -172,23 +49,23 @@ export function HudPlayground() {
const d = deltas[e.key]
if (!d) return
e.preventDefault()
nudge(id, d[0], d[1], e.currentTarget)
move(id, pos[id].x + d[0], pos[id].y + d[1], e.currentTarget)
}
const onPointerDown = (id: WidgetId) => (e: React.PointerEvent<HTMLElement>) => {
e.currentTarget.setPointerCapture(e.pointerId)
drag.current = { id, px: e.clientX, py: e.clientY, ox: pos[id].x, oy: pos[id].y }
setHeld(id)
}
const onPointerMove = (e: React.PointerEvent<HTMLElement>) => {
const d = drag.current
if (!d) return
const { maxX, maxY } = bounds(e.currentTarget)
setPos((prev) => ({ ...prev, [d.id]: { x: clamp(d.ox + e.clientX - d.px, maxX), y: clamp(d.oy + e.clientY - d.py, maxY) } }))
if (d) move(d.id, d.ox + e.clientX - d.px, d.oy + e.clientY - d.py, e.currentTarget)
}
const onPointerUp = () => {
drag.current = null
setHeld(null)
}
const widgets: { id: WidgetId; label: string }[] = [
@ -200,24 +77,20 @@ export function HudPlayground() {
]
return (
<section aria-labelledby="hud-title" className="grid gap-10 py-20 sm:py-28 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:gap-14">
<div className="flex min-w-0 flex-col items-start gap-4">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('hud.eyebrow')}</span>
<h2 id="hud-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('hud.title')}</h2>
<p className="max-w-[42ch] text-lg text-frost">{t('hud.lead')}</p>
<Button variant="quiet" className="mt-2" onClick={() => setPos(fitToField(START, fieldRef.current, widgetRefs.current))}>
{t('hud.reset')}
</Button>
<section aria-labelledby="hud-title" className="flex flex-col gap-8 py-20 sm:py-28">
<div className="grid items-end gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-14">
<h2 id="hud-title" className="text-[2.2rem] leading-[1.05] tracking-[-0.02em] sm:text-[3.2rem]">{t('hud.title')}</h2>
<p className="max-w-[46ch] text-lg text-frost">{t('hud.lead')}</p>
</div>
<div className="min-w-0">
<div
ref={fieldRef}
style={themeToCssVars(theme)}
className="lv-hud-field relative isolate overflow-hidden rounded-panel border border-white/10"
className="lv-hud-field lv-hud-screen relative isolate overflow-hidden"
>
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} />
<div aria-hidden="true" className="absolute inset-0 bg-black/25" />
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} parallax={false} />
<div aria-hidden="true" className="absolute inset-0 bg-black/20" />
<div aria-hidden="true" className="lv-hud-grid" data-on={held !== null} />
{widgets.map((w) => (
<div
key={w.id}
@ -228,21 +101,28 @@ export function HudPlayground() {
tabIndex={0}
title={w.label}
aria-label={t('hud.dragAria', { name: w.label })}
data-held={held === w.id}
onKeyDown={onKeyDown(w.id)}
onPointerDown={onPointerDown(w.id)}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
style={{ left: pos[w.id].x, top: pos[w.id].y }}
className="lv-hud-widget absolute touch-none select-none"
>
<WidgetContent id={w.id} accent={ACCENT} unitMs={t('hud.unitMs')} />
<WidgetContent id={w.id} unitMs={t('hud.unitMs')} />
</div>
))}
</div>
<p className="mt-3 flex items-center gap-2 text-sm text-frost">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm text-frost">
<span className="lv-live-dot" aria-hidden="true" />
{t('hud.caption')}
</p>
<Button variant="quiet" onClick={() => setPos(startLayout(fieldRef.current, widgetRefs.current))}>
{t('hud.reset')}
</Button>
</div>
</section>
)

View file

@ -9,23 +9,28 @@ export function ModuleWall() {
const { t } = useTranslation('landing')
return (
<section aria-labelledby="wall-title" className="grid gap-10 py-20 sm:py-28 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] lg:gap-14">
<div className="flex min-w-0 flex-col items-start gap-4">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('wall.eyebrow')}</span>
<h2 id="wall-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('wall.title')}</h2>
<p className="max-w-[42ch] text-lg text-frost">{t('wall.lead')}</p>
<section aria-labelledby="wall-title" className="flex flex-col gap-10 py-20 sm:py-28">
<div className="grid items-end gap-5 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)] lg:gap-14">
<h2 id="wall-title" className="text-[2.4rem] leading-[1] tracking-[-0.03em] sm:text-[4rem]">{t('wall.title')}</h2>
<p className="max-w-[40ch] text-lg text-frost">{t('wall.lead')}</p>
</div>
<div role="list" aria-label={t('wall.columnsAria')} className="lv-wall grid min-w-0 grid-cols-2 gap-4 sm:grid-cols-4">
{CATEGORIES.map((cat, i) => (
<div key={cat.id} role="listitem" className="lv-wall-col flex min-w-0 flex-col gap-2">
<h3 className="font-code text-xs uppercase tracking-[0.18em] text-ice">{cat.name}</h3>
<div className="lv-wall-viewport relative h-[300px] overflow-hidden">
<div role="list" aria-label={t('wall.columnsAria')} className="lv-wall grid min-w-0 grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-4">
{CATEGORIES.map((cat, i) => {
// Visuals is what the mod is about, so its column carries the theme accent.
const featured = cat.id === 'visuals'
return (
<div key={cat.id} role="listitem" className="lv-wall-col flex min-w-0 flex-col gap-3">
<div className={`flex items-baseline justify-between gap-2 border-b pb-2 ${featured ? 'border-ice' : 'border-white/10'}`}>
<h3 className={`text-lg ${featured ? 'text-ice' : 'text-snow'}`}>{cat.name}</h3>
<span className="font-code text-sm text-frost">{cat.modules.length}</span>
</div>
<div className="lv-wall-viewport relative h-[340px] overflow-hidden">
<ul className="lv-wall-track flex flex-col gap-1.5" style={{ ['--dur' as string]: `${SPEED_MS[i % SPEED_MS.length]}s` }}>
{[...cat.modules, ...cat.modules].map((m, idx) => (
<li
key={`${m.name}-${idx}`}
className="truncate rounded-md border border-white/5 bg-black/30 px-2.5 py-1.5 text-sm text-snow/90 hover:border-ice/40"
className={`truncate rounded-md px-2.5 py-1.5 text-sm ${featured ? 'bg-ice/10 text-snow' : 'bg-black/30 text-snow/80'}`}
>
{m.name}
</li>
@ -33,7 +38,8 @@ export function ModuleWall() {
</ul>
</div>
</div>
))}
)
})}
</div>
</section>
)

View file

@ -2,50 +2,47 @@ import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { browse } from '@features/showcase/api'
import type { Listing } from '@features/showcase/types'
import '@features/landing/styles/landing.css'
/** Placeholder cards so the section never collapses while loading or when the API is down. */
const EXAMPLES: Listing[] = [
{ id: 'e1', title: 'Aura / Trails', config_name: 'aura', description: '', copies_count: 128, published_at: '', author: { nick: 'example', avatar_url: null } },
{ id: 'e2', title: 'Clean PvP', config_name: 'pvp', description: '', copies_count: 96, published_at: '', author: { nick: 'example', avatar_url: null } },
{ id: 'e3', title: 'Stream Safe', config_name: 'stream', description: '', copies_count: 74, published_at: '', author: { nick: 'example', avatar_url: null } },
]
import '@features/landing/styles/sections.css'
/** The three most copied configs as a plain list with the copy count as the figure that matters. */
export function ShowcaseTeaser() {
const { t } = useTranslation('landing')
const { data, isError } = useQuery({
queryKey: ['showcase', 'popular', 0, null],
queryFn: () => browse('popular', 0),
})
const real = !isError && data ? data.items.slice(0, 3) : []
const cards = real.length >= 3 ? real : EXAMPLES
const isExample = real.length < 3
const rows = !isError && data ? data.items.slice(0, 3) : []
return (
<section aria-labelledby="showcase-title" className="grid gap-10 py-20 sm:py-28">
<section aria-labelledby="showcase-title" className="grid gap-10 py-20 sm:py-24 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] lg:gap-14">
<div className="flex min-w-0 flex-col items-start gap-4">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('showcase.eyebrow')}</span>
<h2 id="showcase-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('showcase.title')}</h2>
<p className="max-w-[52ch] text-lg text-frost">{t('showcase.lead')}</p>
<p className="max-w-[40ch] text-lg text-frost">{t('showcase.lead')}</p>
<Link to="/showcase" className="mt-2 text-ice underline underline-offset-4">
{t('showcase.cta')}
</Link>
</div>
<ul className="grid min-w-0 gap-4 sm:grid-cols-3">
{cards.map((c) => (
<li key={c.id} className="lv-config-card flex flex-col gap-3 rounded-panel border border-white/10 bg-black/30 p-5">
<div className="flex items-center justify-between gap-2">
<span className="font-display text-lg font-bold text-snow">{c.title}</span>
{isExample ? <span className="lv-example-tag">{t('showcase.example')}</span> : null}
{rows.length === 0 ? (
<p className="text-lg text-frost">{t('showcase.empty')}</p>
) : (
<ul className="flex min-w-0 flex-col">
{rows.map((c) => (
<li key={c.id} className="lv-list-row grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 py-5">
<div className="flex min-w-0 flex-col gap-1">
<span className="flex items-center gap-2 truncate font-display text-lg font-bold text-snow sm:text-xl">
{c.title}
</span>
{c.author ? <span className="text-sm text-frost">{t('showcase.by', { author: c.author.nick })}</span> : null}
</div>
<span className="text-sm text-frost">{t('showcase.by', { author: c.author?.nick ?? t('showcase.example') })}</span>
<span className="font-code text-xs text-ice">{t('showcase.downloads', { count: c.copies_count })}</span>
<span className="flex flex-col items-end">
<span className="font-display text-2xl font-bold text-ice">{c.copies_count}</span>
<span className="text-xs text-frost">{t('showcase.copies')}</span>
</span>
</li>
))}
</ul>
<Link to="/showcase" className="text-ice underline-offset-4 hover:underline">
{t('showcase.cta')}
</Link>
)}
</section>
)
}

View file

@ -0,0 +1,79 @@
import type { WidgetId } from './layout'
/** Enabled modules as they would appear in the mod's ModuleList panel. */
const MODULES_ON = ['TargetESP', 'PvpCooldowns', 'HitBubbles', 'ShaderSky']
/** Widgets read the accent through --gui-accent so it cross-fades with the active theme. */
const ACCENT = 'var(--gui-accent)'
function KeyCap({ k, pressed = false }: { k: string; pressed?: boolean }) {
return (
<span
className="grid size-7 place-items-center rounded-[6px] border bg-white/5 font-code text-xs leading-none"
style={{
borderColor: pressed ? ACCENT : 'rgb(255 255 255 / 0.12)',
color: pressed ? ACCENT : undefined,
background: pressed ? `color-mix(in srgb, ${ACCENT} 18%, transparent)` : undefined,
}}
>
{k}
</span>
)
}
export function WidgetContent({ id, unitMs }: { id: WidgetId; unitMs: string }) {
switch (id) {
case 'fps':
return (
<div className="flex items-baseline gap-1.5">
<span className="font-code text-2xl leading-none font-bold" style={{ color: ACCENT }}>240</span>
<span className="text-[0.65rem] text-frost">FPS</span>
</div>
)
case 'coords':
return (
<div className="font-code text-sm leading-none text-snow">
<span style={{ color: ACCENT }}>X</span> 128&nbsp; <span style={{ color: ACCENT }}>Y</span> 74&nbsp;{' '}
<span style={{ color: ACCENT }}>Z</span> -204
</div>
)
case 'modules':
return (
<div className="flex flex-col items-end gap-1">
{MODULES_ON.map((m) => (
<span
key={m}
className="rounded-[5px] border-r-2 bg-black/25 px-2 py-[3px] font-code text-[0.7rem] leading-none text-snow"
style={{ borderRightColor: ACCENT }}
>
{m}
</span>
))}
</div>
)
case 'keys':
return (
<div className="flex flex-col items-center gap-1">
<KeyCap k="W" pressed />
<div className="flex gap-1">
<KeyCap k="A" />
<KeyCap k="S" pressed />
<KeyCap k="D" />
</div>
</div>
)
case 'ping':
return (
<div className="flex items-center gap-2">
<span className="font-code text-sm leading-none text-snow">
32&nbsp;<span className="text-frost">{unitMs}</span>
</span>
<span aria-hidden="true" className="flex items-end gap-[3px]">
{[6, 10, 14, 18].map((h, i) => (
<span key={h} className="w-[4px] rounded-sm" style={{ height: h, background: i < 2 ? ACCENT : 'rgb(255 255 255 / 0.18)' }} />
))}
</span>
</div>
)
}
}

View file

@ -0,0 +1,60 @@
/** Fallback playfield size, used before the box is measured (and under jsdom). */
export const FIELD = { w: 680, h: 380 }
const WIDGET = { w: 150, h: 56 }
export type Pos = { x: number; y: number }
/** Real draggable HUD elements from the mod: features/gui/hud/draggable/impl. */
export type WidgetId = 'fps' | 'coords' | 'modules' | 'keys' | 'ping'
export type Layout = Record<WidgetId, Pos>
/**
* Starting layout as fractions of the field (0 = left/top edge, 1 = right/bottom edge, measured
* against the space the widget can move in), so the default HUD fills any screen size the way
* it would in game: FPS top-left, module list top-right, coords bottom-left, keys bottom-right.
*/
const ANCHORS: Record<WidgetId, Pos> = {
fps: { x: 0.03, y: 0.05 },
modules: { x: 0.97, y: 0.05 },
ping: { x: 0.97, y: 0.5 },
coords: { x: 0.03, y: 0.95 },
keys: { x: 0.8, y: 0.95 },
}
export const clamp = (v: number, max: number) => Math.max(0, Math.min(v, max))
type Widgets = Partial<Record<WidgetId, HTMLElement | null>>
function room(field: HTMLElement | null, el: HTMLElement | null | undefined) {
const w = field?.clientWidth || FIELD.w
const h = field?.clientHeight || FIELD.h
return { maxX: Math.max(0, w - (el?.offsetWidth || WIDGET.w)), maxY: Math.max(0, h - (el?.offsetHeight || WIDGET.h)) }
}
/** Default layout placed inside the measured field. */
export function startLayout(field: HTMLElement | null, widgets: Widgets): Layout {
const out = {} as Layout
for (const id of Object.keys(ANCHORS) as WidgetId[]) {
const { maxX, maxY } = room(field, widgets[id])
out[id] = { x: Math.round(ANCHORS[id].x * maxX), y: Math.round(ANCHORS[id].y * maxY) }
}
return out
}
/** Pull every widget back inside the measured field (resize). */
export function fitToField(state: Layout, field: HTMLElement | null, widgets: Widgets): Layout {
const next = { ...state }
let changed = false
for (const id of Object.keys(next) as WidgetId[]) {
const { maxX, maxY } = room(field, widgets[id])
const x = clamp(next[id].x, maxX)
const y = clamp(next[id].y, maxY)
if (x !== next[id].x || y !== next[id].y) {
next[id] = { x, y }
changed = true
}
}
return changed ? next : state
}
/** Movement bounds for one widget element inside the field. */
export const bounds = (field: HTMLElement | null, el: HTMLElement) => room(field, el)

View file

@ -33,8 +33,8 @@ export function Hero() {
const { r, g, b } = parseArgb(theme.theme.accent)
const accent = `rgb(${r}, ${g}, ${b})`
const facts = [
{ n: '88', label: t('hero.facts.modules') },
{ n: '52', label: t('hero.facts.visuals') },
{ n: '86', label: t('hero.facts.modules') },
{ n: '50', label: t('hero.facts.visuals') },
{ n: String(PRESETS.length), label: t('hero.facts.themes') },
]

View file

@ -1,80 +1,125 @@
import { motion } from 'motion/react'
import { useRef } from 'react'
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { type MouseEvent, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ButtonLink } from '@shared/ui/Button'
import { prefersReducedMotion } from '@shared/motion/reducedMotion'
import { themeToCssVars } from '@features/clickgui/themeVars'
import { PRESETS } from '@features/themes/presets.generated'
import { argbToCss, type ThemeColors } from '@features/themes/types'
import { useActiveTheme } from '@features/themes/useActiveTheme'
import { useInView } from '../../effects/useInView'
import '@features/landing/styles/landing.css'
import '@features/landing/styles/sections.css'
const PALETTE_KEYS: (keyof ThemeColors)[] = [
'windowBg', 'windowHeader', 'surface', 'cardEnabled', 'accent', 'accentSoft', 'textPrimary', 'textMuted',
]
/** Every built-in preset as a swatch; picking one re-themes the hero ClickGui and the page accent. */
/** Where the last click landed, so the repaint wave starts under the pointer. */
type Wave = { key: number; x: number; y: number }
/**
* The whole section is a blown-up ClickGui window painted with the active theme: header strip,
* window gradient, accent glow. Picking a swatch repaints it (and the hero GUI, and the page
* accent) with a wave that starts at the clicked swatch.
*/
export function ThemeStrip() {
const { t } = useTranslation('landing')
const { t: tt } = useTranslation('themes')
const { theme, setTheme } = useActiveTheme()
const listRef = useRef<HTMLUListElement>(null)
const inView = useInView(listRef, { once: true, rootMargin: '-40px' })
const sectionRef = useRef<HTMLElement>(null)
const [wave, setWave] = useState<Wave | null>(null)
const pick = (id: string) => (e: MouseEvent<HTMLButtonElement>) => {
setTheme(id, 'user')
const box = sectionRef.current?.getBoundingClientRect()
if (!box || prefersReducedMotion()) return
const b = e.currentTarget.getBoundingClientRect()
setWave({ key: Date.now(), x: b.left + b.width / 2 - box.left, y: b.top + b.height / 2 - box.top })
}
return (
<section aria-labelledby="themes-title" className="relative grid gap-10 py-20 sm:py-28 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.3fr)] lg:gap-14">
<div className="flex min-w-0 flex-col items-start gap-5">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('themeStrip.eyebrow')}</span>
<h2 id="themes-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">
{t('themeStrip.title')}
</h2>
<p className="max-w-[40ch] text-lg text-frost">{t('themeStrip.lead')}</p>
<div className="lv-theme-now mt-2 w-full max-w-sm rounded-panel p-4" style={themeToCssVars(theme)}>
<div className="flex items-baseline justify-between gap-3">
<span className="text-sm text-frost">{t('themeStrip.current')}</span>
<span className="font-display text-lg font-bold" style={{ color: 'var(--gui-accent)' }}>
{theme.name}
</span>
</div>
<ul aria-label={t('themeStrip.palette')} className="mt-3 grid grid-cols-8 gap-1.5">
{PALETTE_KEYS.map((k) => (
<li
key={k}
title={tt(`colors.${k}`)}
className="lv-chip aspect-square rounded-md"
style={{ background: argbToCss(theme.theme[k]) }}
<MotionConfig reducedMotion="user">
<section
ref={sectionRef}
aria-labelledby="themes-title"
style={themeToCssVars(theme)}
className="lv-bleed lv-paint relative isolate overflow-hidden"
>
<AnimatePresence>
{wave ? (
<motion.span
key={wave.key}
aria-hidden="true"
className="lv-paint-wave"
style={{ left: wave.x, top: wave.y }}
initial={{ scale: 0, opacity: 0.8 }}
animate={{ scale: 1, opacity: [0.8, 0.6, 0] }}
transition={{ duration: 0.9, ease: 'easeOut' }}
onAnimationComplete={() => setWave((w) => (w?.key === wave.key ? null : w))}
/>
) : null}
</AnimatePresence>
<div className="lv-paint-header" aria-hidden="true">
<div className="mx-auto flex h-full max-w-[1120px] items-center gap-3 px-4 sm:px-6">
<span className="lv-swatch-dot" />
<span className="font-display text-sm font-bold" style={{ color: 'var(--gui-text)' }}>LoVisual</span>
<span className="text-sm" style={{ color: 'var(--gui-text-muted)' }}>/ {theme.name}</span>
</div>
</div>
<div className="relative mx-auto grid max-w-[1120px] gap-10 px-4 pb-20 pt-14 sm:px-6 sm:pb-24 sm:pt-20">
<div className="grid items-end gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-14">
<div className="flex min-w-0 flex-col items-start gap-5">
<h2 id="themes-title" className="lv-paint-title">{t('themeStrip.title')}</h2>
<p className="max-w-[44ch] text-lg" style={{ color: 'var(--gui-text-muted)' }}>{t('themeStrip.lead')}</p>
<ButtonLink to="/themes" className="mt-1 px-8 py-3 text-base">{t('themeStrip.cta')}</ButtonLink>
</div>
<div className="flex min-w-0 flex-col gap-3">
<p className="text-sm" style={{ color: 'var(--gui-text-muted)' }}>{t('themeStrip.current')}</p>
<div className="relative h-[clamp(3.6rem,9vw,7.5rem)] overflow-hidden" aria-live="polite">
<AnimatePresence mode="popLayout" initial={false}>
<motion.p
key={theme.id}
className="lv-paint-name"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '-100%' }}
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
>
{theme.name}
</motion.p>
</AnimatePresence>
</div>
<ul aria-label={t('themeStrip.palette')} className="grid grid-cols-8 overflow-hidden rounded-ctl ring-1 ring-white/10">
{PALETTE_KEYS.map((k) => (
<li key={k} title={tt(`colors.${k}`)} className="lv-paint-chip" style={{ background: argbToCss(theme.theme[k]) }}>
<span className="sr-only">{`${tt(`colors.${k}`)}: ${theme.theme[k]}`}</span>
</li>
))}
</ul>
<p aria-hidden="true" className="hidden grid-cols-8 font-code text-[0.7rem] sm:grid" style={{ color: 'var(--gui-text-muted)' }}>
{PALETTE_KEYS.map((k) => (
<span key={k} className="truncate">{theme.theme[k].slice(3)}</span>
))}
</p>
</div>
</div>
<ButtonLink to="/themes" className="mt-2 px-8 py-3 text-base">
{t('themeStrip.cta')}
</ButtonLink>
</div>
<ul ref={listRef} aria-label={t('themeStrip.swatches')} className="grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3">
{PRESETS.map((preset, i) => {
const vars = themeToCssVars(preset)
<ul aria-label={t('themeStrip.swatches')} className="grid min-w-0 grid-cols-3 gap-2 sm:gap-3 lg:grid-cols-6">
{PRESETS.map((preset) => {
const active = preset.id === theme.id
return (
<motion.li
key={preset.id}
initial={{ opacity: 0, y: 16 }}
animate={inView ? { opacity: 1, y: 0 } : undefined}
transition={{ duration: 0.45, delay: (i % 6) * 0.05 }}
>
<li key={preset.id}>
<button
type="button"
aria-pressed={active}
aria-label={tt('apply', { name: preset.name })}
onClick={() => setTheme(preset.id, 'user')}
className="lv-swatch group"
onClick={pick(preset.id)}
className="lv-swatch"
data-active={active}
style={vars}
style={themeToCssVars(preset)}
>
<span className="lv-swatch-header" aria-hidden="true">
<span className="lv-swatch-dot" />
@ -86,10 +131,12 @@ export function ThemeStrip() {
</span>
<span className="lv-swatch-name">{preset.name}</span>
</button>
</motion.li>
</li>
)
})}
</ul>
</div>
</section>
</MotionConfig>
)
}

View file

@ -104,18 +104,6 @@
-webkit-text-fill-color: transparent;
}
.lv-theme-now {
background: linear-gradient(var(--gui-header-angle), var(--gui-header-from), var(--gui-header-to));
border: 1px solid color-mix(in srgb, var(--gui-accent) 35%, transparent);
box-shadow: 0 0 40px -12px var(--gui-accent-glow);
transition: box-shadow 400ms, border-color 400ms;
}
.lv-chip {
border: 1px solid rgb(255 255 255 / 0.12);
background-image: none;
transition: background-color 400ms;
}
.lv-swatch {
position: relative;
display: flex;
@ -151,9 +139,7 @@
/* HUD playground: a cropped voxel field with draggable, keyboard-movable widgets. */
.lv-hud-field {
height: clamp(300px, 46vw, 380px);
background: linear-gradient(180deg, #0b0f22, #241a3d 55%, #3a2350);
box-shadow: inset 0 0 60px -20px #000;
}
/* Same glass recipe as the ClickGui window (.lv-gui-window): themed gradient fill,
accent border via the border-box trick, backdrop blur and an accent glow. */
@ -174,6 +160,7 @@
backdrop-filter: blur(12px) saturate(1.25);
cursor: grab;
outline: none;
transition: transform 150ms ease, box-shadow 150ms ease;
}
.lv-hud-widget:active { cursor: grabbing; }
.lv-hud-widget:focus-visible { outline: 2px solid var(--color-ice); outline-offset: 2px; }
@ -187,44 +174,14 @@
to { transform: translateY(-50%); }
}
.lv-example-tag {
font-family: var(--font-code, monospace);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--color-frost, #9fb0c0);
border: 1px solid rgb(255 255 255 / 0.15);
border-radius: 999px;
padding: 2px 8px;
}
.lv-config-card { transition: transform 220ms ease, border-color 220ms ease; }
.lv-config-card:hover { transform: translateY(-3px); border-color: color-mix(in srgb, var(--color-ice) 40%, transparent); }
.lv-glass {
border: 1px solid transparent;
background:
linear-gradient(var(--gui-window-angle, 180deg),
color-mix(in srgb, var(--gui-window-from, #12191b) 72%, transparent),
color-mix(in srgb, var(--gui-window-to, #12191b) 72%, transparent)) padding-box,
linear-gradient(135deg, color-mix(in srgb, var(--gui-accent, #6cf) 55%, transparent),
var(--gui-stroke-from, #2a3a3f) 35%, var(--gui-stroke-to, #2a3a3f) 70%,
color-mix(in srgb, var(--gui-accent, #6cf) 30%, transparent)) border-box;
box-shadow: 0 16px 36px -18px rgb(0 0 0 / 0.8), 0 0 26px -10px var(--gui-accent-glow, transparent);
backdrop-filter: blur(12px) saturate(1.25);
}
.lv-download-band { position: relative; overflow: hidden; }
.lv-download-band::before {
content: ""; position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(120% 120% at 100% 0%, color-mix(in srgb, var(--gui-accent, #6cf) 26%, transparent), transparent 60%);
}
.lv-download-band > * { position: relative; }
.lv-faq-item { border-radius: 10px; padding: 0.85rem 1rem; transition: box-shadow 200ms ease; }
.lv-faq-item:hover, .lv-faq-item[open] { box-shadow: 0 16px 36px -18px rgb(0 0 0 / 0.8), 0 0 30px -6px var(--gui-accent-glow, transparent); }
/* Atmosphere behind everything below the first screen: accent aurora, faint grid, drifting motes. */
.lv-lower { position: relative; isolation: isolate; }
.lv-lower-bg {
position: absolute; inset: 0 calc(50% - 50vw); z-index: -1; pointer-events: none;
/* Safari repaints this whole (very tall) layered-gradient + mask element on every scroll
tick unless it's hinted onto its own compositor layer — this promotes it once instead. */
transform: translateZ(0);
will-change: transform;
background:
radial-gradient(60% 30% at 15% 12%, color-mix(in srgb, var(--page-accent-glow, #6cf) 30%, transparent), transparent 70%),
radial-gradient(55% 28% at 88% 45%, color-mix(in srgb, var(--color-ember) 14%, transparent), transparent 70%),
@ -252,11 +209,13 @@
radial-gradient(1px 1px at 26% 90%, #fff, transparent);
background-size: 100% 900px; opacity: 0.55;
animation: lv-motes 40s linear infinite alternate;
will-change: transform;
}
@keyframes lv-motes { from { transform: translateY(0); } to { transform: translateY(-40px); } }
@media (prefers-reduced-motion: reduce) { .lv-lower-bg::after { animation: none; } }
.lv-faq-item > summary::-webkit-details-marker { display: none; }
.lv-faq-item[open] > summary { color: var(--color-ice); }
.lv-faq-marker { width: 10px; height: 10px; border-right: 2px solid var(--color-ice); border-bottom: 2px solid var(--color-ice); transform: rotate(45deg); transition: transform 200ms ease; }
.lv-faq-item[open] .lv-faq-marker { transform: rotate(225deg); }

View file

@ -0,0 +1,119 @@
/* Landing sections below the hero: the theme "paint room", the HUD screen, the cloud chat,
the module wall, the showcase list and the closing download band. */
/* Theme section: a ClickGui window blown up to page width. Every color is a registered
--gui-* property, so a theme pick cross-fades the whole band. */
.lv-paint {
color: var(--gui-text);
background:
radial-gradient(70% 90% at 85% 30%, color-mix(in srgb, var(--gui-accent) 30%, transparent), transparent 70%),
radial-gradient(50% 60% at 5% 100%, color-mix(in srgb, var(--gui-accent-soft) 22%, transparent), transparent 70%),
linear-gradient(var(--gui-window-angle, 180deg), var(--gui-window-from), var(--gui-window-to));
border-block: 1px solid color-mix(in srgb, var(--gui-accent) 40%, transparent);
transition: --gui-accent 500ms ease, --gui-accent-soft 500ms ease, --gui-window-from 500ms ease,
--gui-window-to 500ms ease, --gui-header-from 500ms ease, --gui-header-to 500ms ease,
--gui-text 500ms ease, --gui-text-muted 500ms ease;
}
.lv-paint-header {
height: 2.75rem;
background: linear-gradient(var(--gui-header-angle, 90deg), var(--gui-header-from), var(--gui-header-to));
border-bottom: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 20%, transparent);
}
.lv-paint-title {
font-size: clamp(2rem, 4.2vw, 3.2rem);
line-height: 1.05;
letter-spacing: -0.02em;
text-wrap: balance;
color: var(--gui-text);
}
.lv-paint-name {
position: absolute;
inset: 0;
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(3.4rem, 9vw, 7.5rem);
line-height: 1;
letter-spacing: -0.04em;
/* Lifted toward the theme's text color so it still reads on saturated window gradients (Nitro). */
color: color-mix(in srgb, var(--gui-accent) 60%, var(--gui-text));
}
.lv-paint-chip { height: 2.75rem; transition: background-color 500ms ease; }
.lv-paint-wave {
position: absolute;
z-index: -1;
width: 180vmax;
height: 180vmax;
margin: -90vmax 0 0 -90vmax;
border-radius: 50%;
pointer-events: none;
/* A bright ring that sweeps out from the clicked swatch while the colors cross-fade behind it. */
background: radial-gradient(closest-side, transparent 55%, var(--gui-accent) 78%, transparent 100%);
}
.lv-paint .lv-swatch { box-shadow: 0 10px 30px -18px rgb(0 0 0 / 0.9); }
.lv-paint .lv-swatch[data-active="true"] {
transform: translateY(-4px);
box-shadow: 0 0 0 2px var(--gui-accent), 0 18px 40px -12px var(--gui-accent-glow);
}
/* HUD section: a big game screen; the heading sits in the bar above it like a window title. */
.lv-hud-screen {
height: clamp(460px, 72vh, 640px);
border-radius: var(--radius-panel);
box-shadow: inset 0 0 60px -20px #000, 0 40px 120px -40px var(--page-accent-glow), 0 0 0 1px rgb(255 255 255 / 0.08);
}
/* Edit-mode grid, like the in-game HUD editor: only visible while a widget is held. */
.lv-hud-grid {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
transition: opacity 180ms ease;
background-image:
linear-gradient(color-mix(in srgb, var(--gui-accent) 22%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in srgb, var(--gui-accent) 22%, transparent) 1px, transparent 1px);
background-size: 32px 32px;
background-position: center;
}
.lv-hud-grid[data-on="true"] { opacity: 1; }
.lv-hud-widget[data-held="true"] {
transform: scale(1.06);
box-shadow: 0 24px 50px -16px rgb(0 0 0 / 0.9), 0 0 40px -4px var(--gui-accent-glow);
z-index: 2;
}
/* Cloud: the game chat line itself carries the section. */
.lv-chat {
background: rgb(0 0 0 / 0.55);
border-left: 3px solid var(--color-ice);
font-size: clamp(1.05rem, 2.6vw, 1.9rem);
line-height: 1.35;
}
/* Minecraft hotbar slots: bevelled inset squares, the selected one outlined in the accent. */
.lv-hotbar-slot {
background: rgb(0 0 0 / 0.45);
box-shadow: inset 2px 2px 0 rgb(0 0 0 / 0.55), inset -2px -2px 0 rgb(255 255 255 / 0.08);
border: 2px solid rgb(255 255 255 / 0.1);
}
.lv-hotbar-slot[data-selected="true"] {
border-color: var(--color-ice);
box-shadow: inset 2px 2px 0 rgb(0 0 0 / 0.55), 0 0 30px -8px var(--page-accent-glow);
}
.lv-hotbar-slot[data-empty="true"] { border-style: dashed; }
/* Showcase: a divided list, not a card grid. */
.lv-list-row { border-top: 1px solid rgb(255 255 255 / 0.08); }
.lv-list-row:last-child { border-bottom: 1px solid rgb(255 255 255 / 0.08); }
/* Closing download band: painted in the page accent, full width. */
.lv-download-final {
background:
radial-gradient(80% 120% at 50% 120%, color-mix(in srgb, var(--color-ice) 55%, transparent), transparent 70%),
linear-gradient(180deg, transparent, color-mix(in srgb, var(--color-ice) 14%, transparent));
border-top: 1px solid color-mix(in srgb, var(--color-ice) 35%, transparent);
}
.lv-download-title {
font-size: clamp(2.4rem, 7vw, 5.5rem);
line-height: 1;
letter-spacing: -0.03em;
text-wrap: balance;
}

View file

@ -0,0 +1,103 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, expect, test, vi } from 'vitest'
import { ActiveThemeProvider } from '@features/themes/useActiveTheme'
import { landingEn } from '../i18n/en'
import { landingRu } from '../i18n/ru'
import { VoxelScene } from '../effects/VoxelScene'
import { HudPlayground } from '../sections/explore/HudPlayground'
import { ModuleWall } from '../sections/explore/ModuleWall'
import { ThemeStrip } from '../sections/hero/ThemeStrip'
const reduceMotion = () =>
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
localStorage.clear()
})
function withTheme(ui: React.ReactElement) {
const router = createMemoryRouter([{ path: '/', element: <ActiveThemeProvider applyToPage={false}>{ui}</ActiveThemeProvider> }])
return render(<RouterProvider router={router} />)
}
test('picking a swatch repaints the theme section: pressed state and the big theme name follow', () => {
reduceMotion()
withTheme(<ThemeStrip />)
const section = screen.getByRole('region', { name: 'Перекрась меню. И сайт заодно.' })
expect(within(section).getByText('Amber', { selector: 'p' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Nitro/ }))
expect(screen.getByRole('button', { name: /Nitro/ })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: /Amber/ })).toHaveAttribute('aria-pressed', 'false')
expect(within(section).getByText('Nitro', { selector: 'p' })).toBeInTheDocument()
// The band itself is painted from the picked theme's variables.
expect(section.style.getPropertyValue('--gui-accent')).not.toBe('')
})
test('holding a HUD widget switches the screen into edit mode until it is released', () => {
reduceMotion()
withTheme(<HudPlayground />)
const widget = screen.getByRole('button', { name: /FPS/ })
widget.setPointerCapture = () => {}
const grid = document.querySelector('.lv-hud-grid')
expect(grid).toHaveAttribute('data-on', 'false')
fireEvent.pointerDown(widget, { pointerId: 1, clientX: 10, clientY: 10 })
expect(widget).toHaveAttribute('data-held', 'true')
expect(grid).toHaveAttribute('data-on', 'true')
fireEvent.pointerUp(widget, { pointerId: 1 })
expect(widget).toHaveAttribute('data-held', 'false')
expect(grid).toHaveAttribute('data-on', 'false')
})
test('"Put it back" restores the HUD after moving a widget', () => {
reduceMotion()
withTheme(<HudPlayground />)
const start = screen.getByRole('button', { name: /FPS/ }).style.left
fireEvent.keyDown(screen.getByRole('button', { name: /FPS/ }), { key: 'ArrowRight' })
expect(screen.getByRole('button', { name: /FPS/ }).style.left).not.toBe(start)
fireEvent.click(screen.getByRole('button', { name: 'Вернуть как было' }))
expect(screen.getByRole('button', { name: /FPS/ }).style.left).toBe(start)
})
test('module wall shows how many modules each category has; Visuals has the 52 the headline promises', () => {
reduceMotion()
withTheme(<ModuleWall />)
const visuals = screen.getByRole('heading', { level: 3, name: 'Visuals' }).parentElement as HTMLElement
expect(within(visuals).getByText('50')).toBeInTheDocument()
})
test('a VoxelScene with parallax off never listens to page scroll', () => {
const spy = vi.spyOn(window, 'addEventListener')
vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener() {}, removeEventListener() {} }))
render(<VoxelScene accent="rgb(1, 2, 3)" parallax={false} />)
expect(spy.mock.calls.some(([type]) => type === 'scroll')).toBe(false)
})
/** Every .tsx under the landing feature, tests excluded. */
function landingSources(dir = 'src/features/landing'): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name)
if (statSync(path).isDirectory()) return name === 'tests' ? [] : landingSources(path)
return path.endsWith('.tsx') ? [path] : []
})
}
test('no section carries a tracked-out all-caps eyebrow label', () => {
for (const file of landingSources()) {
expect(readFileSync(file, 'utf8'), file).not.toMatch(/uppercase[^"']*tracking-\[|tracking-\[[^"']*uppercase/)
}
})
test('the dictionaries no longer carry eyebrow labels in either language', () => {
for (const dict of [landingRu, landingEn]) {
for (const section of Object.values(dict)) expect(Object.keys(section)).not.toContain('eyebrow')
}
})

View file

@ -18,7 +18,7 @@ test('reduced motion shows the full command at once', async () => {
<SessionProvider><RouterProvider router={router} /></SessionProvider>
</QueryClientProvider>,
)
expect(await screen.findByText('%config load 7KQ3M9XA')).toBeInTheDocument()
expect(await screen.findByText('%config load ••••••••')).toBeInTheDocument()
expect(screen.getByText('[LoVisual] Конфиг «pvp» загружен')).toBeInTheDocument()
expect(screen.getByLabelText('Пример: загрузка конфига по коду в чате игры')).toBeInTheDocument()
})
@ -39,3 +39,23 @@ test('english locale renders the landing hero headline', async () => {
)
expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent("you'll fall in love with")
})
test('cloud section speaks in player terms in both languages', async () => {
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
for (const [lng, title] of [
['ru', 'Увидел у друга крутой конфиг? Одна команда в чат.'],
['en', 'Your friend has a sick config? One command in chat.'],
] as const) {
const router = createMemoryRouter([{ path: '/', element: <CommandHero /> }])
const { unmount } = render(
<I18nextProvider i18n={initI18n(lng)}>
<QueryClientProvider client={new QueryClient()}>
<SessionProvider><RouterProvider router={router} /></SessionProvider>
</QueryClientProvider>
</I18nextProvider>,
)
expect(await screen.findByRole('heading', { level: 2 })).toHaveTextContent(title)
unmount()
}
})

View file

@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, expect, test, vi } from 'vitest'
import { mockFetch } from '@test/fetch'
import { json, mockFetch } from '@test/fetch'
import { ParticleField } from '../effects/ParticleField'
import { HudPlayground } from '../sections/explore/HudPlayground'
import { ModuleWall } from '../sections/explore/ModuleWall'
@ -49,12 +49,18 @@ test('ModuleWall lists all four category headings', () => {
}
})
test('ShowcaseTeaser falls back to example cards on fetch error', async () => {
test('ShowcaseTeaser shows an honest empty state on fetch error', async () => {
reduceMotion()
mockFetch({}) // no /showcase route -> 404 -> query error
withQuery(<ShowcaseTeaser />)
const tags = await screen.findAllByText('Пример')
expect(tags).toHaveLength(3)
expect(await screen.findByText('Пока пусто. Будь первым, кто поделится конфигом.')).toBeInTheDocument()
})
test('ShowcaseTeaser shows an honest empty state when there are 0 real items', async () => {
reduceMotion()
mockFetch({ 'GET /showcase': () => json({ items: [], next_cursor: null }) })
withQuery(<ShowcaseTeaser />)
expect(await screen.findByText('Пока пусто. Будь первым, кто поделится конфигом.')).toBeInTheDocument()
})
test('ParticleField schedules no animation frame under reduced motion', () => {

View file

@ -25,14 +25,14 @@ export function Footer() {
</div>
<nav aria-label={t('footer.sectionProduct')} className="flex min-w-0 flex-col gap-2">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('footer.sectionProduct')}</span>
<span className="font-display text-sm font-bold text-snow">{t('footer.sectionProduct')}</span>
{product.map((i) => (
<Link key={i.to} to={i.to} className="text-sm text-frost hover:text-snow">{i.label}</Link>
))}
</nav>
<nav aria-label={t('footer.sectionProject')} className="flex min-w-0 flex-col gap-2">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('footer.sectionProject')}</span>
<span className="font-display text-sm font-bold text-snow">{t('footer.sectionProject')}</span>
{project.map((i) => (
<a key={i.label} href={i.href} target="_blank" rel="noreferrer" className="text-sm text-frost hover:text-snow">
{i.label}