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', id: 'player',
name: 'Player', name: 'Player',
modules: list( 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'], 'NoInteract', 'Tape Mouse', 'Third Person', 'Use Tracker'],
['Auto Accept'], ['Auto Accept'],
), ),
@ -35,9 +35,9 @@ export const CATEGORIES: Category[] = [
id: 'visuals', id: 'visuals',
name: 'Visuals', name: 'Visuals',
modules: list( 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', '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', 'KillEffect', 'KineticLyrics', 'Light', 'Lightning Target ESP', 'MotionBlur', 'NameTags', 'NoRender', 'PostFX', 'Rain',
'ReimaginedVisual', 'ShaderSky', 'Shulker Preview', 'SoundESP', 'TazikHat', 'TotemFX', 'Trails', 'Trinket', 'Trollface Mask', 'ReimaginedVisual', 'ShaderSky', 'Shulker Preview', 'SoundESP', 'TazikHat', 'TotemFX', 'Trails', 'Trinket', 'Trollface Mask',
'ViewModel', 'Wet World', 'Wings', 'WorldParticles', 'WorldTweaks', 'Zoom'], '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 { useTranslation } from 'react-i18next'
import { prefersReducedMotion } from '@shared/motion/reducedMotion' import { prefersReducedMotion } from '@shared/motion/reducedMotion'
import { ButtonLink } from '@shared/ui/Button' import { ButtonLink } from '@shared/ui/Button'
import { useSession } from '../auth/session' 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 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() { export function CommandHero() {
const { t } = useTranslation('landing') const { t } = useTranslation('landing')
const { status } = useSession() const { status } = useSession()
return ( return (
<section className="relative flex flex-col gap-6 pb-16 pt-20 sm:pt-28"> <section aria-labelledby="cloud-title" className="relative flex flex-col gap-8 pb-16 pt-12 sm:pt-20">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('cloud.eyebrow')}</span> <ChatBar />
<h2 className="text-[2rem] leading-[1.1] sm:text-[2.8rem]"> <div className="grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:gap-14">
{t('cloud.headlinePart1')} <h2 id="cloud-title" className="text-[1.75rem] leading-[1.1] sm:text-[2.4rem]">{t('cloud.title')}</h2>
{' '}<br className="hidden lg:inline" /> <div className="flex min-w-0 flex-col items-start gap-5">
<span className="text-ice">{t('cloud.headlinePart2')}</span> <p className="max-w-[52ch] text-lg text-frost">{t('cloud.subtitle')}</p>
</h2>
<p className="max-w-[60ch] text-lg text-frost">{t('cloud.subtitle')}</p>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{status === 'authenticated' ? ( {status === 'authenticated' ? (
<ButtonLink to="/configs" className="px-8 py-3 text-base">{t('cloud.ctaAuthenticated')}</ButtonLink> <ButtonLink to="/configs" className="px-8 py-3 text-base">{t('cloud.ctaAuthenticated')}</ButtonLink>
@ -31,7 +32,8 @@ export function CommandHero() {
{t('cloud.ctaShowcase')} {t('cloud.ctaShowcase')}
</ButtonLink> </ButtonLink>
</div> </div>
<ChatBar /> </div>
</div>
</section> </section>
) )
} }
@ -40,28 +42,30 @@ function ChatBar() {
const { t } = useTranslation('landing') const { t } = useTranslation('landing')
const [typed, setTyped] = useState(() => (prefersReducedMotion() ? COMMAND.length : 0)) const [typed, setTyped] = useState(() => (prefersReducedMotion() ? COMMAND.length : 0))
const done = typed >= COMMAND.length 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(() => { useEffect(() => {
if (done) return if (done || !inView) return
const t = setInterval(() => setTyped((n) => Math.min(n + 1, COMMAND.length)), TYPE_DELAY_MS) const t = setInterval(() => setTyped((n) => Math.min(n + 1, COMMAND.length)), TYPE_DELAY_MS)
return () => clearInterval(t) return () => clearInterval(t)
}, [done]) }, [done, inView])
return ( return (
<div <div
ref={ref}
role="img" role="img"
aria-label={t('cloud.chatAriaLabel')} 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"> <p aria-hidden="true" className="whitespace-pre-wrap break-all">
{COMMAND.slice(0, typed)} {COMMAND.slice(0, typed)}
{done ? null : <span className="animate-pulse text-frost">▌</span>} {done ? null : <span className="animate-pulse text-frost">▌</span>}
</p> </p>
{done ? ( <p aria-hidden="true" className={done ? 'text-ice' : 'invisible'}>
<p aria-hidden="true" className="text-ice">
{t('cloud.chatConfirmation')} {t('cloud.chatConfirmation')}
</p> </p>
) : null}
</div> </div>
) )
} }

View file

@ -1,45 +1,56 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link } from 'react-router' import { Link } from 'react-router'
import '@features/landing/styles/sections.css'
export function HowItWorks() { export function HowItWorks() {
const { t } = useTranslation('landing') 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 = [ const slots = [
{ name: t('howItWorks.slotNamePvp'), code: '7KQ3M9XA' }, { name: t('howItWorks.slotNamePvp'), code: placeholderCode },
{ name: t('howItWorks.slotNameStream'), code: 'R2WD8HNC' }, { name: t('howItWorks.slotNameStream'), code: placeholderCode },
{ name: t('howItWorks.slotNameLazy'), code: 'L5TB4QZE' }, { name: t('howItWorks.slotNameLazy'), code: placeholderCode },
{ name: null, code: null }, { name: null, code: null },
] ]
const linkSteps = [t('howItWorks.linkStep1'), t('howItWorks.linkStep2'), t('howItWorks.linkStep3')] const linkSteps = [t('howItWorks.linkStep1'), t('howItWorks.linkStep2'), t('howItWorks.linkStep3')]
return ( return (
<div className="flex flex-col gap-16 pb-20"> <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"> <div className="flex flex-col gap-3">
<h2 className="text-[1.5625rem] leading-tight">{t('howItWorks.slotsTitle')}</h2> <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> </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) => ( {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 ? ( {slot.name ? (
<> <>
<span className="truncate text-snow">{slot.name}</span> <span className="truncate font-display text-lg font-bold text-snow">{slot.name}</span>
<span className="font-code text-sm tracking-wider text-ice">{slot.code}</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> </li>
))} ))}
</ul> </ul>
</section> </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> <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"> <ol className="grid gap-6 md:grid-cols-3">
{linkSteps.map((step) => ( {linkSteps.map((step, i) => (
<li key={step} className="pl-1"> <li key={step} className="flex gap-4 border-t border-white/10 pt-4">
{step} <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> </li>
))} ))}
</ol> </ol>
@ -47,7 +58,7 @@ export function HowItWorks() {
<p className="max-w-[60ch] text-frost"> <p className="max-w-[60ch] text-frost">
{t('howItWorks.showcaseText')}{' '} {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')} {t('howItWorks.showcaseLink')}
</Link> </Link>
</p> </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 rootRef = useRef<HTMLDivElement>(null)
const canvases = useRef<(HTMLCanvasElement | null)[]>([]) const canvases = useRef<(HTMLCanvasElement | null)[]>([])
@ -160,7 +164,7 @@ export function VoxelScene({ accent, className = '' }: { accent: string; classNa
}, [accent]) }, [accent])
useEffect(() => { useEffect(() => {
if (prefersReducedMotion()) return if (!parallax || prefersReducedMotion()) return
let frame = 0 let frame = 0
const lags = [0.5, ...LAYERS.map((l) => l.lag)] const lags = [0.5, ...LAYERS.map((l) => l.lag)]
const onScroll = () => { const onScroll = () => {
@ -178,7 +182,7 @@ export function VoxelScene({ accent, className = '' }: { accent: string; classNa
window.removeEventListener('scroll', onScroll) window.removeEventListener('scroll', onScroll)
cancelAnimationFrame(frame) cancelAnimationFrame(frame)
} }
}, []) }, [parallax])
return ( return (
<div ref={rootRef} aria-hidden="true" className={`lv-voxel pointer-events-none absolute inset-0 overflow-hidden ${className}`}> <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', badge: 'Minecraft 26.2 · Fabric',
headlineLead: 'Visuals', headlineLead: 'Visuals',
headlineAccent: "you'll fall in love with", 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', download: 'Download for free',
downloadHint: 'One .jar straight from GitHub', downloadHint: 'One .jar from GitHub: drop it in mods and play',
register: 'Create account', register: 'Create account',
facts: { facts: {
modules: 'modules', modules: 'modules',
visuals: 'for visuals', visuals: 'for visuals',
themes: 'built-in themes', 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: { themeStrip: {
eyebrow: 'Themes', title: 'Repaint the menu. And the site while you’re at it.',
title: 'Hundreds of shades. Yours is one.', lead: 'Every theme the mod ships with. Click any of them: the menu up top, this block and the whole site change color.',
lead: 'Every built-in theme, straight from the mod. Click one: the ClickGui and the whole site recolor on the fly.',
cta: 'Build your own theme', cta: 'Build your own theme',
current: 'Now', current: 'Active now',
swatches: 'Built-in themes', swatches: 'Built-in themes',
palette: 'Theme palette', palette: 'Theme colors',
}, },
cloud: { cloud: {
eyebrow: 'CLOUD', title: 'Your friend has a sick config? One command in chat.',
headlinePart1: 'Your LoVisual settings', 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.',
headlinePart2: 'on any computer',
subtitle: 'Four cloud slots, a code you can share with friends, and a showcase of ready-made configs.',
ctaAuthenticated: 'My configs', ctaAuthenticated: 'My configs',
ctaAnonymous: 'Create account', ctaAnonymous: 'Create account',
ctaShowcase: 'View showcase', ctaShowcase: "See other people's configs",
chatConfirmation: '[LoVisual] Loaded config "pvp"', chatConfirmation: '[LoVisual] Loaded config "pvp"',
chatAriaLabel: 'Example: loading a config by code in the game chat', chatAriaLabel: 'Example: loading a config by code in the game chat',
}, },
howItWorks: { howItWorks: {
slotsTitle: 'Four slots in the cloud', slotsTitle: 'Four slots for every mood',
slotsDesc: slotsDesc: 'One for PvP, one for streaming, one for the days you can’t be bothered. Each has its own code.',
'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.',
slotsAriaLabel: 'Example slots', slotsAriaLabel: 'Example slots',
emptySlot: 'empty', emptySlot: 'empty',
slotNamePvp: 'pvp', slotNamePvp: 'pvp',
slotNameStream: 'stream', slotNameStream: 'stream',
slotNameLazy: 'lazy', slotNameLazy: 'lazy',
linkTitle: 'Linking the game', slotCodePlaceholder: '••••••••',
linkStep1: 'Open the account tab in the mod, a code will appear there.', linkTitle: 'How to link the game to your account',
linkStep2: 'Enter the code on the "Link game" page.', linkStep1: 'Open the Account tab in the mod: your code is there.',
linkStep3: "Done: the mod signs in to your account by itself, no password needed.", linkStep2: 'Paste it on the "Link game" page.',
showcaseText: 'Any config from the showcase can be copied into a free slot with one button.', 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', showcaseLink: 'Open showcase',
}, },
hud: { hud: {
eyebrow: 'HUD', title: 'Move the HUD right here',
title: 'Arrange the widgets like in game', 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.',
lead: 'The same widgets the mod draws: FPS, coordinates, module list, keys and ping. Drag them with the mouse or arrow keys.', caption: 'In game the HUD moves exactly like this.',
caption: 'Drag it, just like in game.', reset: 'Put it back',
reset: 'Reset',
widgetFps: 'FPS', widgetFps: 'FPS',
widgetCoords: 'Coordinates', widgetCoords: 'Coordinates',
widgetModules: 'Module list', widgetModules: 'Module list',
@ -68,37 +64,34 @@ export const landingEn = {
dragAria: 'Draggable HUD widget "{{name}}". Arrow keys move it.', dragAria: 'Draggable HUD widget "{{name}}". Arrow keys move it.',
}, },
wall: { wall: {
eyebrow: 'Modules', title: '86 modules. 50 are about looks.',
title: '88 modules. 52 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.',
lead: 'The whole category wall. Hover a visuals module: I\'ll tell you what it does.',
columnsAria: 'Module categories', columnsAria: 'Module categories',
}, },
showcase: { showcase: {
eyebrow: 'Showcase', title: 'What other players run',
title: 'Ready-made configs from the community', lead: 'The most copied configs on the showcase. See one you like? Grab it into your slot.',
lead: 'The most popular showcase configs. Copy any of them into your slot with one button.', cta: 'Whole showcase',
cta: 'Open showcase', empty: 'Nothing here yet. Be the first to share a config.',
example: 'Example',
by: 'by {{author}}', by: 'by {{author}}',
downloads: '{{count}} downloads', copies: 'copies',
}, },
faq: { faq: {
eyebrow: 'FAQ', title: 'What people usually ask',
title: 'Frequently asked',
q1: 'Is it really free?', q1: 'Is it really free?',
a1: 'Yes. The mod is free and open source: no subscriptions, no pay-to-unlock features.', a1: 'Yes. Open source, no subscriptions, nothing to pay for.',
q2: 'Which Minecraft versions are supported?', q2: 'Which version does it run on?',
a2: 'Minecraft 26.2 on the Fabric loader.', a2: 'Minecraft 26.2 on the Fabric loader.',
q3: 'Do I need an account?', q3: 'Do I need an account?',
a3: 'Not for the mod itself. An account is only needed for cloud slots and the config showcase.', a3: 'No, the mod works without one. You only need it for cloud slots and the showcase.',
q4: 'Is it legit? Will I get banned?', q4: "It's not a cheat? Will I get banned?",
a4: 'LoVisual is a visuals mod: it reads nothing from memory and sends nothing to the server. Your admin decides.', 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 settings to another computer?', q5: 'How do I move my settings to another PC?',
a5: 'Save a config to a cloud slot and load it by code on any other PC.', a5: 'Save your config to a cloud slot, then load it on the other PC with the slot’s code.',
downloadTitle: 'Ready to try it?', downloadTitle: 'Grab it and jump in',
downloadLead: 'One .jar straight from GitHub, just drop it into your mods folder.', downloadLead: 'One .jar from GitHub. Drop it in your mods folder, launch the game with Fabric, open the menu.',
downloadCta: 'Download for free', downloadCta: 'Download for free',
downloadNote: 'Minecraft 26.2 · Fabric · the latest release', downloadNote: 'Latest release for Minecraft 26.2 on Fabric',
releaseLink: 'Releases and versions', releaseLink: 'All versions on GitHub',
}, },
} satisfies Translation<typeof landingRu> } satisfies Translation<typeof landingRu>

View file

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

View file

@ -1,50 +1,61 @@
import { useTranslation } from 'react-i18next' 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 { DOWNLOAD_URL, GITHUB_REPO } from '@features/download/api'
import '@features/landing/styles/landing.css' import '@features/landing/styles/landing.css'
import '@features/landing/styles/sections.css'
const KEYS = ['1', '2', '3', '4', '5'] as const 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() { export function FaqDownload() {
const { t } = useTranslation('landing') const { t } = useTranslation('landing')
const { theme } = useActiveTheme()
const items = KEYS.map((k) => ({ q: t(`faq.q${k}`), a: t(`faq.a${k}`) })) const items = KEYS.map((k) => ({ q: t(`faq.q${k}`), a: t(`faq.a${k}`) }))
return ( 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"> <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">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('faq.eyebrow')}</span>
<h2 id="faq-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('faq.title')}</h2> <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) => ( {items.map((it) => (
<details key={it.q} className="lv-faq-item lv-glass"> <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 text-base font-semibold text-snow"> <summary className="flex cursor-pointer list-none items-center justify-between gap-4 py-5 text-lg font-semibold text-snow">
{it.q} {it.q}
<span aria-hidden="true" className="lv-faq-marker" /> <span aria-hidden="true" className="lv-faq-marker" />
</summary> </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> </details>
))} ))}
</div> </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"> <section aria-labelledby="download-title" className="lv-bleed lv-download-final">
<h3 className="text-[1.75rem] leading-tight sm:text-[2.1rem]">{t('faq.downloadTitle')}</h3> <div className="mx-auto flex max-w-[1120px] flex-col items-start gap-6 px-4 py-24 sm:px-6 sm:py-32">
<p className="max-w-[42ch] text-lg text-frost">{t('faq.downloadLead')}</p> <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"> <a href={DOWNLOAD_URL} download className="lv-cta-primary lv-cta-lg">
<DownloadIcon />
{t('faq.downloadCta')} {t('faq.downloadCta')}
</a> </a>
<p className="font-code text-xs text-frost">{t('faq.downloadNote')}</p>
<a <a
href={`https://github.com/${GITHUB_REPO}/releases`} href={`https://github.com/${GITHUB_REPO}/releases`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="text-sm text-ice underline-offset-4 hover:underline" className="text-snow underline underline-offset-4"
> >
{t('faq.releaseLink')} {t('faq.releaseLink')}
</a> </a>
</div> </div>
<p className="text-sm text-frost">{t('faq.downloadNote')}</p>
</div>
</section> </section>
</>
) )
} }

View file

@ -5,164 +5,41 @@ import { themeToCssVars } from '@features/clickgui/themeVars'
import { parseArgb } from '@features/themes/types' import { parseArgb } from '@features/themes/types'
import { useActiveTheme } from '@features/themes/useActiveTheme' import { useActiveTheme } from '@features/themes/useActiveTheme'
import { VoxelScene } from '../../effects/VoxelScene' 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/landing.css'
import '@features/landing/styles/sections.css'
const STEP = 8 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 } type Drag = { id: WidgetId; px: number; py: number; ox: number; oy: 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>
)
}
}
/**
* 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() { export function HudPlayground() {
const { t } = useTranslation('landing') const { t } = useTranslation('landing')
const { theme } = useActiveTheme() const { theme } = useActiveTheme()
const { r, g, b } = parseArgb(theme.theme.accent) const { r, g, b } = parseArgb(theme.theme.accent)
const fieldRef = useRef<HTMLDivElement>(null) const fieldRef = useRef<HTMLDivElement>(null)
const widgetRefs = useRef<Partial<Record<WidgetId, HTMLElement | 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 drag = useRef<Drag | null>(null)
const [pos, setPos] = useState<Record<WidgetId, Pos>>(START) const [pos, setPos] = useState<Layout>(() => startLayout(null, {}))
const [held, setHeld] = useState<WidgetId | null>(null)
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)) }
}
useLayoutEffect(() => { useLayoutEffect(() => {
const fit = () => setPos((prev) => fitToField(prev, fieldRef.current, widgetRefs.current)) setPos(startLayout(fieldRef.current, widgetRefs.current))
fit()
const field = fieldRef.current const field = fieldRef.current
if (!field || typeof ResizeObserver === 'undefined') return 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) ro.observe(field)
return () => ro.disconnect() return () => ro.disconnect()
}, []) }, [])
const nudge = (id: WidgetId, dx: number, dy: number, el: HTMLElement) => { const move = (id: WidgetId, x: number, y: number, el: HTMLElement) => {
const { maxX, maxY } = bounds(el) const { maxX, maxY } = bounds(fieldRef.current, el)
setPos((prev) => ({ ...prev, [id]: { x: clamp(prev[id].x + dx, maxX), y: clamp(prev[id].y + dy, maxY) } })) setPos((prev) => ({ ...prev, [id]: { x: clamp(x, maxX), y: clamp(y, maxY) } }))
} }
const onKeyDown = (id: WidgetId) => (e: React.KeyboardEvent<HTMLElement>) => { const onKeyDown = (id: WidgetId) => (e: React.KeyboardEvent<HTMLElement>) => {
@ -172,23 +49,23 @@ export function HudPlayground() {
const d = deltas[e.key] const d = deltas[e.key]
if (!d) return if (!d) return
e.preventDefault() 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>) => { const onPointerDown = (id: WidgetId) => (e: React.PointerEvent<HTMLElement>) => {
e.currentTarget.setPointerCapture(e.pointerId) e.currentTarget.setPointerCapture(e.pointerId)
drag.current = { id, px: e.clientX, py: e.clientY, ox: pos[id].x, oy: pos[id].y } 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 onPointerMove = (e: React.PointerEvent<HTMLElement>) => {
const d = drag.current const d = drag.current
if (!d) return if (d) move(d.id, d.ox + e.clientX - d.px, d.oy + e.clientY - d.py, e.currentTarget)
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) } }))
} }
const onPointerUp = () => { const onPointerUp = () => {
drag.current = null drag.current = null
setHeld(null)
} }
const widgets: { id: WidgetId; label: string }[] = [ const widgets: { id: WidgetId; label: string }[] = [
@ -200,24 +77,20 @@ export function HudPlayground() {
] ]
return ( 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"> <section aria-labelledby="hud-title" className="flex flex-col gap-8 py-20 sm:py-28">
<div className="flex min-w-0 flex-col items-start gap-4"> <div className="grid items-end gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-14">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('hud.eyebrow')}</span> <h2 id="hud-title" className="text-[2.2rem] leading-[1.05] tracking-[-0.02em] sm:text-[3.2rem]">{t('hud.title')}</h2>
<h2 id="hud-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('hud.title')}</h2> <p className="max-w-[46ch] text-lg text-frost">{t('hud.lead')}</p>
<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>
</div> </div>
<div className="min-w-0">
<div <div
ref={fieldRef} ref={fieldRef}
style={themeToCssVars(theme)} 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})`} /> <VoxelScene accent={`rgb(${r}, ${g}, ${b})`} parallax={false} />
<div aria-hidden="true" className="absolute inset-0 bg-black/25" /> <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) => ( {widgets.map((w) => (
<div <div
key={w.id} key={w.id}
@ -228,21 +101,28 @@ export function HudPlayground() {
tabIndex={0} tabIndex={0}
title={w.label} title={w.label}
aria-label={t('hud.dragAria', { name: w.label })} aria-label={t('hud.dragAria', { name: w.label })}
data-held={held === w.id}
onKeyDown={onKeyDown(w.id)} onKeyDown={onKeyDown(w.id)}
onPointerDown={onPointerDown(w.id)} onPointerDown={onPointerDown(w.id)}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
style={{ left: pos[w.id].x, top: pos[w.id].y }} style={{ left: pos[w.id].x, top: pos[w.id].y }}
className="lv-hud-widget absolute touch-none select-none" 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>
))} ))}
</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" /> <span className="lv-live-dot" aria-hidden="true" />
{t('hud.caption')} {t('hud.caption')}
</p> </p>
<Button variant="quiet" onClick={() => setPos(startLayout(fieldRef.current, widgetRefs.current))}>
{t('hud.reset')}
</Button>
</div> </div>
</section> </section>
) )

View file

@ -9,23 +9,28 @@ export function ModuleWall() {
const { t } = useTranslation('landing') const { t } = useTranslation('landing')
return ( 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"> <section aria-labelledby="wall-title" className="flex flex-col gap-10 py-20 sm:py-28">
<div className="flex min-w-0 flex-col items-start gap-4"> <div className="grid items-end gap-5 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)] lg:gap-14">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('wall.eyebrow')}</span> <h2 id="wall-title" className="text-[2.4rem] leading-[1] tracking-[-0.03em] sm:text-[4rem]">{t('wall.title')}</h2>
<h2 id="wall-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('wall.title')}</h2> <p className="max-w-[40ch] text-lg text-frost">{t('wall.lead')}</p>
<p className="max-w-[42ch] text-lg text-frost">{t('wall.lead')}</p>
</div> </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"> <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) => ( {CATEGORIES.map((cat, i) => {
<div key={cat.id} role="listitem" className="lv-wall-col flex min-w-0 flex-col gap-2"> // Visuals is what the mod is about, so its column carries the theme accent.
<h3 className="font-code text-xs uppercase tracking-[0.18em] text-ice">{cat.name}</h3> const featured = cat.id === 'visuals'
<div className="lv-wall-viewport relative h-[300px] overflow-hidden"> 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` }}> <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) => ( {[...cat.modules, ...cat.modules].map((m, idx) => (
<li <li
key={`${m.name}-${idx}`} 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} {m.name}
</li> </li>
@ -33,7 +38,8 @@ export function ModuleWall() {
</ul> </ul>
</div> </div>
</div> </div>
))} )
})}
</div> </div>
</section> </section>
) )

View file

@ -2,50 +2,47 @@ import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link } from 'react-router' import { Link } from 'react-router'
import { browse } from '@features/showcase/api' import { browse } from '@features/showcase/api'
import type { Listing } from '@features/showcase/types' import '@features/landing/styles/sections.css'
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 } },
]
/** The three most copied configs as a plain list with the copy count as the figure that matters. */
export function ShowcaseTeaser() { export function ShowcaseTeaser() {
const { t } = useTranslation('landing') const { t } = useTranslation('landing')
const { data, isError } = useQuery({ const { data, isError } = useQuery({
queryKey: ['showcase', 'popular', 0, null], queryKey: ['showcase', 'popular', 0, null],
queryFn: () => browse('popular', 0), queryFn: () => browse('popular', 0),
}) })
const real = !isError && data ? data.items.slice(0, 3) : [] const rows = !isError && data ? data.items.slice(0, 3) : []
const cards = real.length >= 3 ? real : EXAMPLES
const isExample = real.length < 3
return ( 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"> <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> <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> </div>
<ul className="grid min-w-0 gap-4 sm:grid-cols-3"> {rows.length === 0 ? (
{cards.map((c) => ( <p className="text-lg text-frost">{t('showcase.empty')}</p>
<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"> <ul className="flex min-w-0 flex-col">
<span className="font-display text-lg font-bold text-snow">{c.title}</span> {rows.map((c) => (
{isExample ? <span className="lv-example-tag">{t('showcase.example')}</span> : null} <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> </div>
<span className="text-sm text-frost">{t('showcase.by', { author: c.author?.nick ?? t('showcase.example') })}</span> <span className="flex flex-col items-end">
<span className="font-code text-xs text-ice">{t('showcase.downloads', { count: c.copies_count })}</span> <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> </li>
))} ))}
</ul> </ul>
)}
<Link to="/showcase" className="text-ice underline-offset-4 hover:underline">
{t('showcase.cta')}
</Link>
</section> </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 { r, g, b } = parseArgb(theme.theme.accent)
const accent = `rgb(${r}, ${g}, ${b})` const accent = `rgb(${r}, ${g}, ${b})`
const facts = [ const facts = [
{ n: '88', label: t('hero.facts.modules') }, { n: '86', label: t('hero.facts.modules') },
{ n: '52', label: t('hero.facts.visuals') }, { n: '50', label: t('hero.facts.visuals') },
{ n: String(PRESETS.length), label: t('hero.facts.themes') }, { n: String(PRESETS.length), label: t('hero.facts.themes') },
] ]

View file

@ -1,80 +1,125 @@
import { motion } from 'motion/react' import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { useRef } from 'react' import { type MouseEvent, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ButtonLink } from '@shared/ui/Button' import { ButtonLink } from '@shared/ui/Button'
import { prefersReducedMotion } from '@shared/motion/reducedMotion'
import { themeToCssVars } from '@features/clickgui/themeVars' import { themeToCssVars } from '@features/clickgui/themeVars'
import { PRESETS } from '@features/themes/presets.generated' import { PRESETS } from '@features/themes/presets.generated'
import { argbToCss, type ThemeColors } from '@features/themes/types' import { argbToCss, type ThemeColors } from '@features/themes/types'
import { useActiveTheme } from '@features/themes/useActiveTheme' import { useActiveTheme } from '@features/themes/useActiveTheme'
import { useInView } from '../../effects/useInView'
import '@features/landing/styles/landing.css' import '@features/landing/styles/landing.css'
import '@features/landing/styles/sections.css'
const PALETTE_KEYS: (keyof ThemeColors)[] = [ const PALETTE_KEYS: (keyof ThemeColors)[] = [
'windowBg', 'windowHeader', 'surface', 'cardEnabled', 'accent', 'accentSoft', 'textPrimary', 'textMuted', '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() { export function ThemeStrip() {
const { t } = useTranslation('landing') const { t } = useTranslation('landing')
const { t: tt } = useTranslation('themes') const { t: tt } = useTranslation('themes')
const { theme, setTheme } = useActiveTheme() const { theme, setTheme } = useActiveTheme()
const listRef = useRef<HTMLUListElement>(null) const sectionRef = useRef<HTMLElement>(null)
const inView = useInView(listRef, { once: true, rootMargin: '-40px' }) 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 ( 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"> <MotionConfig reducedMotion="user">
<div className="flex min-w-0 flex-col items-start gap-5"> <section
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('themeStrip.eyebrow')}</span> ref={sectionRef}
<h2 id="themes-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]"> aria-labelledby="themes-title"
{t('themeStrip.title')} style={themeToCssVars(theme)}
</h2> className="lv-bleed lv-paint relative isolate overflow-hidden"
<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]) }}
> >
<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> <span className="sr-only">{`${tt(`colors.${k}`)}: ${theme.theme[k]}`}</span>
</li> </li>
))} ))}
</ul> </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> </div>
<ButtonLink to="/themes" className="mt-2 px-8 py-3 text-base"> <ul aria-label={t('themeStrip.swatches')} className="grid min-w-0 grid-cols-3 gap-2 sm:gap-3 lg:grid-cols-6">
{t('themeStrip.cta')} {PRESETS.map((preset) => {
</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)
const active = preset.id === theme.id const active = preset.id === theme.id
return ( return (
<motion.li <li key={preset.id}>
key={preset.id}
initial={{ opacity: 0, y: 16 }}
animate={inView ? { opacity: 1, y: 0 } : undefined}
transition={{ duration: 0.45, delay: (i % 6) * 0.05 }}
>
<button <button
type="button" type="button"
aria-pressed={active} aria-pressed={active}
aria-label={tt('apply', { name: preset.name })} aria-label={tt('apply', { name: preset.name })}
onClick={() => setTheme(preset.id, 'user')} onClick={pick(preset.id)}
className="lv-swatch group" className="lv-swatch"
data-active={active} data-active={active}
style={vars} style={themeToCssVars(preset)}
> >
<span className="lv-swatch-header" aria-hidden="true"> <span className="lv-swatch-header" aria-hidden="true">
<span className="lv-swatch-dot" /> <span className="lv-swatch-dot" />
@ -86,10 +131,12 @@ export function ThemeStrip() {
</span> </span>
<span className="lv-swatch-name">{preset.name}</span> <span className="lv-swatch-name">{preset.name}</span>
</button> </button>
</motion.li> </li>
) )
})} })}
</ul> </ul>
</div>
</section> </section>
</MotionConfig>
) )
} }

View file

@ -104,18 +104,6 @@
-webkit-text-fill-color: transparent; -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 { .lv-swatch {
position: relative; position: relative;
display: flex; display: flex;
@ -151,9 +139,7 @@
/* HUD playground: a cropped voxel field with draggable, keyboard-movable widgets. */ /* HUD playground: a cropped voxel field with draggable, keyboard-movable widgets. */
.lv-hud-field { .lv-hud-field {
height: clamp(300px, 46vw, 380px);
background: linear-gradient(180deg, #0b0f22, #241a3d 55%, #3a2350); 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, /* 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. */ accent border via the border-box trick, backdrop blur and an accent glow. */
@ -174,6 +160,7 @@
backdrop-filter: blur(12px) saturate(1.25); backdrop-filter: blur(12px) saturate(1.25);
cursor: grab; cursor: grab;
outline: none; outline: none;
transition: transform 150ms ease, box-shadow 150ms ease;
} }
.lv-hud-widget:active { cursor: grabbing; } .lv-hud-widget:active { cursor: grabbing; }
.lv-hud-widget:focus-visible { outline: 2px solid var(--color-ice); outline-offset: 2px; } .lv-hud-widget:focus-visible { outline: 2px solid var(--color-ice); outline-offset: 2px; }
@ -187,44 +174,14 @@
to { transform: translateY(-50%); } 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. */ /* Atmosphere behind everything below the first screen: accent aurora, faint grid, drifting motes. */
.lv-lower { position: relative; isolation: isolate; } .lv-lower { position: relative; isolation: isolate; }
.lv-lower-bg { .lv-lower-bg {
position: absolute; inset: 0 calc(50% - 50vw); z-index: -1; pointer-events: none; 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: background:
radial-gradient(60% 30% at 15% 12%, color-mix(in srgb, var(--page-accent-glow, #6cf) 30%, transparent), transparent 70%), 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%), 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); radial-gradient(1px 1px at 26% 90%, #fff, transparent);
background-size: 100% 900px; opacity: 0.55; background-size: 100% 900px; opacity: 0.55;
animation: lv-motes 40s linear infinite alternate; animation: lv-motes 40s linear infinite alternate;
will-change: transform;
} }
@keyframes lv-motes { from { transform: translateY(0); } to { transform: translateY(-40px); } } @keyframes lv-motes { from { transform: translateY(0); } to { transform: translateY(-40px); } }
@media (prefers-reduced-motion: reduce) { .lv-lower-bg::after { animation: none; } } @media (prefers-reduced-motion: reduce) { .lv-lower-bg::after { animation: none; } }
.lv-faq-item > summary::-webkit-details-marker { display: 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-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); } .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> <SessionProvider><RouterProvider router={router} /></SessionProvider>
</QueryClientProvider>, </QueryClientProvider>,
) )
expect(await screen.findByText('%config load 7KQ3M9XA')).toBeInTheDocument() expect(await screen.findByText('%config load ••••••••')).toBeInTheDocument()
expect(screen.getByText('[LoVisual] Конфиг «pvp» загружен')).toBeInTheDocument() expect(screen.getByText('[LoVisual] Конфиг «pvp» загружен')).toBeInTheDocument()
expect(screen.getByLabelText('Пример: загрузка конфига по коду в чате игры')).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") 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createMemoryRouter, RouterProvider } from 'react-router' import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, expect, test, vi } from 'vitest' import { afterEach, expect, test, vi } from 'vitest'
import { mockFetch } from '@test/fetch' import { json, mockFetch } from '@test/fetch'
import { ParticleField } from '../effects/ParticleField' import { ParticleField } from '../effects/ParticleField'
import { HudPlayground } from '../sections/explore/HudPlayground' import { HudPlayground } from '../sections/explore/HudPlayground'
import { ModuleWall } from '../sections/explore/ModuleWall' 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() reduceMotion()
mockFetch({}) // no /showcase route -> 404 -> query error mockFetch({}) // no /showcase route -> 404 -> query error
withQuery(<ShowcaseTeaser />) withQuery(<ShowcaseTeaser />)
const tags = await screen.findAllByText('Пример') expect(await screen.findByText('Пока пусто. Будь первым, кто поделится конфигом.')).toBeInTheDocument()
expect(tags).toHaveLength(3) })
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', () => { test('ParticleField schedules no animation frame under reduced motion', () => {

View file

@ -25,14 +25,14 @@ export function Footer() {
</div> </div>
<nav aria-label={t('footer.sectionProduct')} className="flex min-w-0 flex-col gap-2"> <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) => ( {product.map((i) => (
<Link key={i.to} to={i.to} className="text-sm text-frost hover:text-snow">{i.label}</Link> <Link key={i.to} to={i.to} className="text-sm text-frost hover:text-snow">{i.label}</Link>
))} ))}
</nav> </nav>
<nav aria-label={t('footer.sectionProject')} className="flex min-w-0 flex-col gap-2"> <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) => ( {project.map((i) => (
<a key={i.label} href={i.href} target="_blank" rel="noreferrer" className="text-sm text-frost hover:text-snow"> <a key={i.label} href={i.href} target="_blank" rel="noreferrer" className="text-sm text-frost hover:text-snow">
{i.label} {i.label}