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:
parent
3f679b83f6
commit
9d423456af
20 changed files with 830 additions and 538 deletions
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,39 @@
|
||||||
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>
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<ButtonLink to="/register" className="px-8 py-3 text-base">{t('cloud.ctaAnonymous')}</ButtonLink>
|
|
||||||
)}
|
|
||||||
<ButtonLink to="/showcase" variant="quiet" className="px-8 py-3 text-base">
|
|
||||||
{t('cloud.ctaShowcase')}
|
|
||||||
</ButtonLink>
|
|
||||||
</div>
|
|
||||||
<ChatBar />
|
<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>
|
||||||
|
) : (
|
||||||
|
<ButtonLink to="/register" className="px-8 py-3 text-base">{t('cloud.ctaAnonymous')}</ButtonLink>
|
||||||
|
)}
|
||||||
|
<ButtonLink to="/showcase" variant="quiet" className="px-8 py-3 text-base">
|
||||||
|
{t('cloud.ctaShowcase')}
|
||||||
|
</ButtonLink>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}`}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<a href={DOWNLOAD_URL} download className="lv-cta-primary lv-cta-lg">
|
<p className="max-w-[46ch] text-lg text-snow/85">{t('faq.downloadLead')}</p>
|
||||||
{t('faq.downloadCta')}
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-4">
|
||||||
</a>
|
<a href={DOWNLOAD_URL} download className="lv-cta-primary lv-cta-lg">
|
||||||
<p className="font-code text-xs text-frost">{t('faq.downloadNote')}</p>
|
<DownloadIcon />
|
||||||
<a
|
{t('faq.downloadCta')}
|
||||||
href={`https://github.com/${GITHUB_REPO}/releases`}
|
</a>
|
||||||
target="_blank"
|
<a
|
||||||
rel="noreferrer"
|
href={`https://github.com/${GITHUB_REPO}/releases`}
|
||||||
className="text-sm text-ice underline-offset-4 hover:underline"
|
target="_blank"
|
||||||
>
|
rel="noreferrer"
|
||||||
{t('faq.releaseLink')}
|
className="text-snow underline underline-offset-4"
|
||||||
</a>
|
>
|
||||||
</div>
|
{t('faq.releaseLink')}
|
||||||
</section>
|
</a>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-frost">{t('faq.downloadNote')}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 <span style={{ color: accent }}>Y</span> 74 {' '}
|
|
||||||
<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 <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,49 +77,52 @@ 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 lv-hud-screen relative isolate overflow-hidden"
|
||||||
className="lv-hud-field relative isolate overflow-hidden rounded-panel border border-white/10"
|
>
|
||||||
>
|
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} parallax={false} />
|
||||||
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} />
|
<div aria-hidden="true" className="absolute inset-0 bg-black/20" />
|
||||||
<div aria-hidden="true" className="absolute inset-0 bg-black/25" />
|
<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}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
widgetRefs.current[w.id] = el
|
widgetRefs.current[w.id] = el
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
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 })}
|
||||||
onKeyDown={onKeyDown(w.id)}
|
data-held={held === w.id}
|
||||||
onPointerDown={onPointerDown(w.id)}
|
onKeyDown={onKeyDown(w.id)}
|
||||||
onPointerMove={onPointerMove}
|
onPointerDown={onPointerDown(w.id)}
|
||||||
onPointerUp={onPointerUp}
|
onPointerMove={onPointerMove}
|
||||||
style={{ left: pos[w.id].x, top: pos[w.id].y }}
|
onPointerUp={onPointerUp}
|
||||||
className="lv-hud-widget absolute touch-none select-none"
|
onPointerCancel={onPointerUp}
|
||||||
>
|
style={{ left: pos[w.id].x, top: pos[w.id].y }}
|
||||||
<WidgetContent id={w.id} accent={ACCENT} unitMs={t('hud.unitMs')} />
|
className="lv-hud-widget absolute touch-none select-none"
|
||||||
</div>
|
>
|
||||||
))}
|
<WidgetContent id={w.id} unitMs={t('hud.unitMs')} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 flex items-center gap-2 text-sm text-frost">
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,31 +9,37 @@ 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 (
|
||||||
<ul className="lv-wall-track flex flex-col gap-1.5" style={{ ['--dur' as string]: `${SPEED_MS[i % SPEED_MS.length]}s` }}>
|
<div key={cat.id} role="listitem" className="lv-wall-col flex min-w-0 flex-col gap-3">
|
||||||
{[...cat.modules, ...cat.modules].map((m, idx) => (
|
<div className={`flex items-baseline justify-between gap-2 border-b pb-2 ${featured ? 'border-ice' : 'border-white/10'}`}>
|
||||||
<li
|
<h3 className={`text-lg ${featured ? 'text-ice' : 'text-snow'}`}>{cat.name}</h3>
|
||||||
key={`${m.name}-${idx}`}
|
<span className="font-code text-sm text-frost">{cat.modules.length}</span>
|
||||||
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"
|
</div>
|
||||||
>
|
<div className="lv-wall-viewport relative h-[340px] overflow-hidden">
|
||||||
{m.name}
|
<ul className="lv-wall-track flex flex-col gap-1.5" style={{ ['--dur' as string]: `${SPEED_MS[i % SPEED_MS.length]}s` }}>
|
||||||
</li>
|
{[...cat.modules, ...cat.modules].map((m, idx) => (
|
||||||
))}
|
<li
|
||||||
</ul>
|
key={`${m.name}-${idx}`}
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<span className="text-sm text-frost">{t('showcase.by', { author: c.author?.nick ?? t('showcase.example') })}</span>
|
<span className="flex items-center gap-2 truncate font-display text-lg font-bold text-snow sm:text-xl">
|
||||||
<span className="font-code text-xs text-ice">{t('showcase.downloads', { count: c.copies_count })}</span>
|
{c.title}
|
||||||
</li>
|
</span>
|
||||||
))}
|
{c.author ? <span className="text-sm text-frost">{t('showcase.by', { author: c.author.nick })}</span> : null}
|
||||||
</ul>
|
</div>
|
||||||
|
<span className="flex flex-col items-end">
|
||||||
<Link to="/showcase" className="text-ice underline-offset-4 hover:underline">
|
<span className="font-display text-2xl font-bold text-ice">{c.copies_count}</span>
|
||||||
{t('showcase.cta')}
|
<span className="text-xs text-frost">{t('showcase.copies')}</span>
|
||||||
</Link>
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 <span style={{ color: ACCENT }}>Y</span> 74 {' '}
|
||||||
|
<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 <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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
frontend/src/features/landing/sections/explore/hud/layout.ts
Normal file
60
frontend/src/features/landing/sections/explore/hud/layout.ts
Normal 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)
|
||||||
|
|
@ -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') },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,142 @@
|
||||||
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>
|
>
|
||||||
|
<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-theme-now mt-2 w-full max-w-sm rounded-panel p-4" style={themeToCssVars(theme)}>
|
<div className="lv-paint-header" aria-hidden="true">
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="mx-auto flex h-full max-w-[1120px] items-center gap-3 px-4 sm:px-6">
|
||||||
<span className="text-sm text-frost">{t('themeStrip.current')}</span>
|
<span className="lv-swatch-dot" />
|
||||||
<span className="font-display text-lg font-bold" style={{ color: 'var(--gui-accent)' }}>
|
<span className="font-display text-sm font-bold" style={{ color: 'var(--gui-text)' }}>LoVisual</span>
|
||||||
{theme.name}
|
<span className="text-sm" style={{ color: 'var(--gui-text-muted)' }}>/ {theme.name}</span>
|
||||||
</span>
|
|
||||||
</div>
|
</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]) }}
|
|
||||||
>
|
|
||||||
<span className="sr-only">{`${tt(`colors.${k}`)}: ${theme.theme[k]}`}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ButtonLink to="/themes" className="mt-2 px-8 py-3 text-base">
|
<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">
|
||||||
{t('themeStrip.cta')}
|
<div className="grid items-end gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-14">
|
||||||
</ButtonLink>
|
<div className="flex min-w-0 flex-col items-start gap-5">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<ul ref={listRef} aria-label={t('themeStrip.swatches')} className="grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
{PRESETS.map((preset, i) => {
|
<p className="text-sm" style={{ color: 'var(--gui-text-muted)' }}>{t('themeStrip.current')}</p>
|
||||||
const vars = themeToCssVars(preset)
|
<div className="relative h-[clamp(3.6rem,9vw,7.5rem)] overflow-hidden" aria-live="polite">
|
||||||
const active = preset.id === theme.id
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
return (
|
<motion.p
|
||||||
<motion.li
|
key={theme.id}
|
||||||
key={preset.id}
|
className="lv-paint-name"
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ y: '100%' }}
|
||||||
animate={inView ? { opacity: 1, y: 0 } : undefined}
|
animate={{ y: 0 }}
|
||||||
transition={{ duration: 0.45, delay: (i % 6) * 0.05 }}
|
exit={{ y: '-100%' }}
|
||||||
>
|
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
|
||||||
<button
|
>
|
||||||
type="button"
|
{theme.name}
|
||||||
aria-pressed={active}
|
</motion.p>
|
||||||
aria-label={tt('apply', { name: preset.name })}
|
</AnimatePresence>
|
||||||
onClick={() => setTheme(preset.id, 'user')}
|
</div>
|
||||||
className="lv-swatch group"
|
<ul aria-label={t('themeStrip.palette')} className="grid grid-cols-8 overflow-hidden rounded-ctl ring-1 ring-white/10">
|
||||||
data-active={active}
|
{PALETTE_KEYS.map((k) => (
|
||||||
style={vars}
|
<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="lv-swatch-header" aria-hidden="true">
|
</li>
|
||||||
<span className="lv-swatch-dot" />
|
))}
|
||||||
<span className="lv-swatch-bar" />
|
</ul>
|
||||||
</span>
|
<p aria-hidden="true" className="hidden grid-cols-8 font-code text-[0.7rem] sm:grid" style={{ color: 'var(--gui-text-muted)' }}>
|
||||||
<span className="lv-swatch-body" aria-hidden="true">
|
{PALETTE_KEYS.map((k) => (
|
||||||
<span className="lv-swatch-card" />
|
<span key={k} className="truncate">{theme.theme[k].slice(3)}</span>
|
||||||
<span className="lv-swatch-card lv-swatch-card-on" />
|
))}
|
||||||
</span>
|
</p>
|
||||||
<span className="lv-swatch-name">{preset.name}</span>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</motion.li>
|
|
||||||
)
|
<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) => {
|
||||||
</ul>
|
const active = preset.id === theme.id
|
||||||
</section>
|
return (
|
||||||
|
<li key={preset.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-label={tt('apply', { name: preset.name })}
|
||||||
|
onClick={pick(preset.id)}
|
||||||
|
className="lv-swatch"
|
||||||
|
data-active={active}
|
||||||
|
style={themeToCssVars(preset)}
|
||||||
|
>
|
||||||
|
<span className="lv-swatch-header" aria-hidden="true">
|
||||||
|
<span className="lv-swatch-dot" />
|
||||||
|
<span className="lv-swatch-bar" />
|
||||||
|
</span>
|
||||||
|
<span className="lv-swatch-body" aria-hidden="true">
|
||||||
|
<span className="lv-swatch-card" />
|
||||||
|
<span className="lv-swatch-card lv-swatch-card-on" />
|
||||||
|
</span>
|
||||||
|
<span className="lv-swatch-name">{preset.name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</MotionConfig>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
||||||
|
|
|
||||||
119
frontend/src/features/landing/styles/sections.css
Normal file
119
frontend/src/features/landing/styles/sections.css
Normal 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;
|
||||||
|
}
|
||||||
103
frontend/src/features/landing/tests/interactions.test.tsx
Normal file
103
frontend/src/features/landing/tests/interactions.test.tsx
Normal 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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue