chore(history): squash 67 commit(s) from 2026-09-25

- feat(accounts): persist device links with opaque hashed tokens, list and revoke endpoints
- feat(frontend): app shell, routing and landing page with the chat-command hero
- feat(frontend): Cyrillic-first fonts (Unbounded, Onest, JetBrains Mono); add i18next and motion
- docs: free mod, bilingual site, one-click download, theme editor, public profiles, rich landing in plans
- feat(accounts): internal gRPC AuthenticateDevice guarded by internal key
- feat(frontend): ru/en i18n with typed per-feature dictionaries and language switch
- feat(accounts): GET /me profile endpoint
- feat(gateway): scaffold crate with config validation and health check
- feat(gateway): reverse proxy to accounts and configs services
- feat(gateway): resolve identity once from access JWT or device token via gRPC
- feat(gateway): per-route and global rate limits with Retry-After
- feat(gateway): CORS for the site origin; docs for gateway and internal contract
- feat(configs): scaffold service with schema, config validation and health check
- feat(configs): four config slots per account with list, get and save
- feat(configs): permanent share codes with regenerate and public load-by-code
- feat(accounts): GetPublicProfiles gRPC for showcase author info
- style(accounts,common): apply rustfmt to existing sources
- feat(configs): public showcase with publish, browse, detail and copy-to-slot
- feat(backend): public profile endpoint and showcase author filter
- fix(gateway): silence clippy collapsible-if and needless-ref warnings
- docs(backend): configs-service implemented; Подсистема 1 backend complete
- feat(mod): add Optimize module skeleton with OptimizeState holder
- feat(mod): gate glass blur behind Optimize no_glass knob
- feat(mod): cut MotionBlur and DoF sample counts behind lite_post knob
- feat(mod): trim procedural sky noise behind lite_sky knob
- feat(mod): drop fade gradients and digit rolls behind lean_hud knob
- docs(todo): mark Optimize module phase 9.2 complete
- refactor(mod): drop dead Renderer2D compatibility shims
- refactor(mod): prune unreachable Renderer2D overload towers
- refactor(mod): remove unused Renderer2D overloads and imports
- docs(todo): mark Renderer2D giant-splitting done (2179 to 1597)
- refactor(mod): extract shader id constants from LoVisualRenderPipelines
- docs(todo): record registry wave 2026-09-25 (Renderer2D, pipelines)
- refactor(mod): move Renderer2D instance state into base class
- refactor(mod): extract Renderer2DRounded drawing family
- refactor(mod): extract Renderer2DPath connector and chamfer family
- refactor(mod): extract Renderer2DShapes circle line and texture primitives
- refactor(mod): extract Renderer2DGlass and Renderer2DItem families
- refactor(mod): prune Renderer2D imports after facade split
- docs(todo): record Renderer2D facade inheritance split (1597 to 475)
- docs: easter eggs — .env honeypot, konami troll mode, devtools banner, IDDQD config, breakable 404 block, 418 teapot
- feat(mod): introduce surface style system core (SurfaceStyle, StyleSpec, StyleConfig, SurfaceRenderer)
- refactor(mod): delegate HudRenderUtil liquid glass draws to SurfaceRenderer (dedupe glass constants)
- refactor(mod): route bespoke glass call sites through SurfaceRenderer.plateSpec
- feat(mod): add Auto option to HUD bg effects via shared HudBgStyles resolution
- feat(mod): flat fallback for no-glass optimize mode and persist global HUD config
- feat(mod): default HUD bg effects to Auto so the global surface style drives widgets
- feat(mod): add global cycle-style hotkey with surface style notification
- feat(mod): add surface style swatch strip under the global style picker
- feat(gateway): reject ambiguous paths and answer .env probes with a honeypot
- fix(gateway): charge failed credentials against the rate limit, allow stale ones on /auth
- feat(frontend): ClickGui theme pipeline generated from the mod, live site theming
- feat(frontend): landing v2 hero — voxel/particle backdrop, live ClickGui, theme strip
- docs(todo): drop the FPS A/B measurement from phase 9.3, close phase 9
- feat(gateway): answer /coffee with a 418 teapot
- feat(frontend): land the rest of landing v2 — HUD, module wall, showcase, FAQ, footer
- feat(frontend): one-click download from GitHub releases, changelog page, release CI
- feat(frontend): theme editor with live ClickGui preview, mod-compatible export and share links
- fix(frontend): landing HUD playground now shows real mod widgets (fps, coordinates, module list, keybinds, ping)
- style(frontend): apply ClickGui glass effect to landing HUD playground widgets
- fix(frontend): prevent color field row overflow in theme editor grid
- fix(frontend): never attach stale bearer token to /auth/* requests
- fix(configs): unpublish/publish can no longer bypass moderation
- refactor(accounts): shrink auth/handlers.rs under the 250-line cap
- fix(accounts): tolerate concurrent refresh without killing every session
- fix(gateway): minor hardening from the backend review
- feat(configs): IDDQD easter egg config
This commit is contained in:
loki5512344 2026-09-25 20:22:13 +02:00
parent 72bc4c7148
commit 7f4b532f99
257 changed files with 13085 additions and 6582 deletions

View file

@ -1,7 +0,0 @@
import { createBrowserRouter, RouterProvider } from 'react-router'
const router = createBrowserRouter([{ path: '/', element: <h1>LoVisual</h1> }])
export default function App() {
return <RouterProvider router={router} />
}

20
frontend/src/app/App.tsx Normal file
View file

@ -0,0 +1,20 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createBrowserRouter, RouterProvider } from 'react-router'
import { SessionProvider } from '../features/auth/session'
import { ActiveThemeProvider } from '../features/themes/useActiveTheme'
import { routes } from './routes'
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000 } } })
const router = createBrowserRouter(routes)
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<SessionProvider>
<ActiveThemeProvider>
<RouterProvider router={router} />
</ActiveThemeProvider>
</SessionProvider>
</QueryClientProvider>
)
}

43
frontend/src/app/i18n.ts Normal file
View file

@ -0,0 +1,43 @@
import i18next, { type i18n } from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import { initReactI18next } from 'react-i18next'
import { authEn } from '../features/auth/i18n/en'
import { authRu } from '../features/auth/i18n/ru'
import { clickguiEn } from '../features/clickgui/i18n/en'
import { clickguiRu } from '../features/clickgui/i18n/ru'
import { downloadEn } from '../features/download/i18n/en'
import { downloadRu } from '../features/download/i18n/ru'
import { landingEn } from '../features/landing/i18n/en'
import { landingRu } from '../features/landing/i18n/ru'
import { themesEn } from '../features/themes/i18n/en'
import { themesRu } from '../features/themes/i18n/ru'
import { commonEn } from '../shared/i18n/common.en'
import { commonRu } from '../shared/i18n/common.ru'
/** Creates and initializes an i18next instance. Each call is independent, so tests can run in isolation. */
export function initI18n(lng?: 'ru' | 'en'): i18n {
const instance = i18next.createInstance()
instance
.use(LanguageDetector)
.use(initReactI18next)
.init({
lng,
resources: {
ru: { common: commonRu, auth: authRu, landing: landingRu, themes: themesRu, clickgui: clickguiRu, download: downloadRu },
en: { common: commonEn, auth: authEn, landing: landingEn, themes: themesEn, clickgui: clickguiEn, download: downloadEn },
},
fallbackLng: 'ru',
supportedLngs: ['ru', 'en'],
ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download'],
defaultNS: 'common',
interpolation: { escapeValue: false },
detection: { order: ['localStorage', 'navigator'], caches: ['localStorage'], lookupLocalStorage: 'lv_lang' },
})
document.documentElement.lang = instance.language
instance.on('languageChanged', (next) => {
document.documentElement.lang = next
})
return instance
}

21
frontend/src/app/i18next.d.ts vendored Normal file
View file

@ -0,0 +1,21 @@
import type { authRu } from '../features/auth/i18n/ru'
import type { clickguiRu } from '../features/clickgui/i18n/ru'
import type { downloadRu } from '../features/download/i18n/ru'
import type { landingRu } from '../features/landing/i18n/ru'
import type { themesRu } from '../features/themes/i18n/ru'
import type { commonRu } from '../shared/i18n/common.ru'
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common'
resources: {
common: typeof commonRu
auth: typeof authRu
landing: typeof landingRu
themes: typeof themesRu
clickgui: typeof clickguiRu
download: typeof downloadRu
// Later tasks extend this with their own feature namespaces.
}
}
}

View file

@ -0,0 +1,25 @@
import type { RouteObject } from 'react-router'
import { RequireAuth } from '../features/auth/RequireAuth'
import { AppShell } from '../shared/layout/AppShell'
import HomePage from '../pages/public/HomePage'
import LoginPage from '../pages/public/LoginPage'
import RegisterPage from '../pages/public/RegisterPage'
import NotFoundPage from '../pages/public/NotFoundPage'
// Code-split: the download page pulls in react-markdown, so it loads on demand.
import DownloadRoute from '../pages/download/DownloadRoute'
import ThemesRoute from '../pages/themes/ThemesRoute'
export const routes: RouteObject[] = [
{
element: <AppShell />,
children: [
{ path: '/', element: <HomePage /> },
{ path: '/login', element: <LoginPage /> },
{ path: '/register', element: <RegisterPage /> },
{ path: '/download', element: <DownloadRoute /> },
{ path: '/themes', element: <ThemesRoute /> },
{ element: <RequireAuth />, children: [] }, // Tasks 6–8 add /link, /account, /configs
{ path: '*', element: <NotFoundPage /> },
],
},
]

View file

@ -1,10 +1,12 @@
import { useTranslation } from 'react-i18next'
import { Navigate, Outlet, useLocation } from 'react-router'
import { useSession } from './session'
export function RequireAuth() {
const { t } = useTranslation('auth')
const { status } = useSession()
const location = useLocation()
if (status === 'loading') return <p className="text-frost">Проверяем вход…</p>
if (status === 'loading') return <p className="text-frost">{t('checkingSession')}</p>
if (status === 'anonymous') return <Navigate to="/login" replace state={{ from: location.pathname }} />
return <Outlet />
}

View file

@ -1,4 +1,5 @@
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation, useNavigate } from 'react-router'
import { describeError } from '../../../shared/api/errors'
import { Button } from '../../../shared/ui/Button'
@ -7,6 +8,8 @@ import { TextField } from '../../../shared/ui/TextField'
import { useSession } from '../session'
export function LoginForm() {
const { t } = useTranslation('auth')
const { t: tc } = useTranslation('common')
const { signIn } = useSession()
const navigate = useNavigate()
const from = (useLocation().state as { from?: string } | null)?.from ?? '/configs'
@ -23,7 +26,7 @@ export function LoginForm() {
await signIn(email.trim(), password)
navigate(from, { replace: true })
} catch (err) {
setError(describeError(err, { 401: 'Неверная почта или пароль.' }))
setError(describeError(tc, err, { 401: t('login.wrongCredentials') }))
} finally {
setBusy(false)
}
@ -31,9 +34,15 @@ export function LoginForm() {
return (
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
<TextField label="Почта" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<TextField
label="Пароль"
label={t('login.emailLabel')}
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
label={t('login.passwordLabel')}
type="password"
autoComplete="current-password"
value={password}
@ -41,10 +50,13 @@ export function LoginForm() {
/>
{error ? <Notice tone="error">{error}</Notice> : null}
<Button type="submit" busy={busy}>
Войти
{t('login.submit')}
</Button>
<p className="text-sm text-frost">
Нет аккаунта? <Link to="/register" className="text-ice underline-offset-4 hover:underline">Создать</Link>
{t('login.noAccount')}{' '}
<Link to="/register" className="text-ice underline-offset-4 hover:underline">
{t('login.createAccount')}
</Link>
</p>
</form>
)

View file

@ -1,4 +1,5 @@
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useNavigate } from 'react-router'
import { describeError } from '../../../shared/api/errors'
import { Button } from '../../../shared/ui/Button'
@ -8,6 +9,8 @@ import { useSession } from '../session'
import { validateEmail, validateNick, validatePassword } from '../validation'
export function RegisterForm() {
const { t } = useTranslation('auth')
const { t: tc } = useTranslation('common')
const { signUp } = useSession()
const navigate = useNavigate()
const [email, setEmail] = useState('')
@ -23,9 +26,9 @@ export function RegisterForm() {
e.preventDefault()
setError(undefined)
const emailErr = validateEmail(email)
const nickErr = validateNick(nick)
const passwordErr = validatePassword(password)
const emailErr = validateEmail(email, t)
const nickErr = validateNick(nick, t)
const passwordErr = validatePassword(password, t)
setEmailError(emailErr)
setNickError(nickErr)
setPasswordError(passwordErr)
@ -36,7 +39,7 @@ export function RegisterForm() {
await signUp(email.trim(), password, nick.trim())
navigate('/configs', { replace: true })
} catch (err) {
setError(describeError(err, { 409: 'Эта почта уже занята. Войди или используй другую.' }))
setError(describeError(tc, err, { 409: t('register.emailTaken') }))
} finally {
setBusy(false)
}
@ -45,7 +48,7 @@ export function RegisterForm() {
return (
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
<TextField
label="Почта"
label={t('register.emailLabel')}
type="email"
autoComplete="email"
value={email}
@ -53,28 +56,31 @@ export function RegisterForm() {
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
label="Ник на сайте"
label={t('register.nickLabel')}
autoComplete="nickname"
value={nick}
error={nickError}
hint={nickError ? undefined : 'Не обязательно совпадает с ником в Minecraft.'}
hint={nickError ? undefined : t('register.nickHint')}
onChange={(e) => setNick(e.target.value)}
/>
<TextField
label="Пароль"
label={t('register.passwordLabel')}
type="password"
autoComplete="new-password"
value={password}
error={passwordError}
hint={passwordError ? undefined : 'Минимум 8 символов.'}
hint={passwordError ? undefined : t('register.passwordHint')}
onChange={(e) => setPassword(e.target.value)}
/>
{error ? <Notice tone="error">{error}</Notice> : null}
<Button type="submit" busy={busy}>
Создать аккаунт
{t('register.submit')}
</Button>
<p className="text-sm text-frost">
Уже есть аккаунт? <Link to="/login" className="text-ice underline-offset-4 hover:underline">Войти</Link>
{t('register.haveAccount')}{' '}
<Link to="/login" className="text-ice underline-offset-4 hover:underline">
{t('register.login')}
</Link>
</p>
</form>
)

View file

@ -0,0 +1,36 @@
import type { Translation } from '../../../shared/i18n/common.ru'
import type { authRu } from './ru'
export const authEn = {
checkingSession: 'Checking session…',
login: {
title: 'Log in',
emailLabel: 'Email',
passwordLabel: 'Password',
submit: 'Log in',
noAccount: "Don't have an account?",
createAccount: 'Sign up',
wrongCredentials: 'Wrong email or password.',
},
register: {
title: 'New account',
subtitle: "You'll need an account for cloud configs and linking the game. No password required in the mod.",
emailLabel: 'Email',
nickLabel: 'Site nickname',
nickHint: "Doesn't have to match your Minecraft nick.",
passwordLabel: 'Password',
passwordHint: 'At least 8 characters.',
submit: 'Create account',
haveAccount: 'Already have an account?',
login: 'Log in',
emailTaken: 'That email is already taken. Log in or use another one.',
},
validation: {
emailRequired: 'Enter your email.',
emailInvalid: 'That email looks off.',
passwordShort: 'At least 8 characters.',
passwordTooLong: 'Password is too long.',
nickRequired: 'Pick a nickname.',
nickTooLong: 'No longer than 32 characters.',
},
} satisfies Translation<typeof authRu>

View file

@ -0,0 +1,33 @@
export const authRu = {
checkingSession: 'Проверяем вход…',
login: {
title: 'Вход',
emailLabel: 'Почта',
passwordLabel: 'Пароль',
submit: 'Войти',
noAccount: 'Нет аккаунта?',
createAccount: 'Создать',
wrongCredentials: 'Неверная почта или пароль.',
},
register: {
title: 'Новый аккаунт',
subtitle: 'Аккаунт нужен для облачных конфигов и привязки игры. Пароль в мод вводить не придётся.',
emailLabel: 'Почта',
nickLabel: 'Ник на сайте',
nickHint: 'Не обязательно совпадает с ником в Minecraft.',
passwordLabel: 'Пароль',
passwordHint: 'Минимум 8 символов.',
submit: 'Создать аккаунт',
haveAccount: 'Уже есть аккаунт?',
login: 'Войти',
emailTaken: 'Эта почта уже занята. Войди или используй другую.',
},
validation: {
emailRequired: 'Введи почту.',
emailInvalid: 'Похоже, в почте опечатка.',
passwordShort: 'Минимум 8 символов.',
passwordTooLong: 'Слишком длинный пароль.',
nickRequired: 'Придумай ник.',
nickTooLong: 'Не длиннее 32 символов.',
},
} as const

View file

@ -1,16 +1,18 @@
export function validateEmail(email: string): string | undefined {
import type { TFunction } from 'i18next'
export function validateEmail(email: string, t: TFunction<'auth'>): string | undefined {
const v = email.trim()
if (!v) return 'Введи почту.'
if (v.length > 254 || !/^[^@\s]+@[^@\s]+$/.test(v)) return 'Похоже, в почте опечатка.'
if (!v) return t('validation.emailRequired')
if (v.length > 254 || !/^[^@\s]+@[^@\s]+$/.test(v)) return t('validation.emailInvalid')
}
export function validatePassword(password: string): string | undefined {
if ([...password].length < 8) return 'Минимум 8 символов.'
if (new TextEncoder().encode(password).length > 256) return 'Слишком длинный пароль.'
export function validatePassword(password: string, t: TFunction<'auth'>): string | undefined {
if ([...password].length < 8) return t('validation.passwordShort')
if (new TextEncoder().encode(password).length > 256) return t('validation.passwordTooLong')
}
export function validateNick(nick: string): string | undefined {
export function validateNick(nick: string, t: TFunction<'auth'>): string | undefined {
const len = [...nick.trim()].length
if (len === 0) return 'Придумай ник.'
if (len > 32) return 'Не длиннее 32 символов.'
if (len === 0) return t('validation.nickRequired')
if (len > 32) return t('validation.nickTooLong')
}

View file

@ -0,0 +1,130 @@
import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { prefersReducedMotion } from '../../shared/motion/reducedMotion'
import { PRESETS } from '../themes/presets.generated'
import type { ThemeEntry } from '../themes/types'
import { useActiveTheme } from '../themes/useActiveTheme'
import { DEMO, useDemo } from './demo'
import { ModuleCard } from './ModuleCard'
import './parts/clickgui.css'
import { CategoryIcon, DemoCursor, LogoMark, SearchGlyph } from './parts/Glyphs'
import { CATEGORIES, QUICK_THEMES, TABS } from './parts/modules'
import { themeToCssVars } from './themeVars'
type Props = {
/** Runs the scripted cursor demo (pauses while hovered or when `paused`). */
demo?: boolean
paused?: boolean
/** Overrides the active theme — for previews. */
theme?: ThemeEntry
className?: string
}
export function ClickGuiWindow({ demo = true, paused = false, theme: override, className = '' }: Props) {
const { t } = useTranslation('clickgui')
const { theme: active, setTheme } = useActiveTheme()
const theme = override ?? active
const rootRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState(false)
const [reduced] = useState(prefersReducedMotion)
const themeIdRef = useRef(theme.id)
useEffect(() => {
themeIdRef.current = theme.id
}, [theme.id])
const { cursor, state, dispatch } = useDemo(DEMO, {
paused: !demo || paused || hovered,
reduced,
rootRef,
nextTheme: () => {
const i = QUICK_THEMES.indexOf(themeIdRef.current as (typeof QUICK_THEMES)[number])
return QUICK_THEMES[(i + 1) % QUICK_THEMES.length]
},
onTheme: (id) => setTheme(id, 'demo'),
})
const vars = useMemo(() => themeToCssVars(theme), [theme])
const category = CATEGORIES.find((c) => c.id === state.category) ?? CATEGORIES[0]
return (
<div
ref={rootRef}
role="group"
aria-label={t('windowLabel')}
data-testid="clickgui-root"
className={`lv-gui ${className}`}
style={vars}
onPointerEnter={(e) => e.pointerType === 'mouse' && setHovered(true)}
onPointerLeave={() => setHovered(false)}
>
<div className="lv-gui-bloom" aria-hidden="true" />
<div className="lv-gui-window">
<header className="lv-gui-header">
<span className="lv-gui-logo">
<LogoMark />
LoVisual
</span>
<nav className="lv-gui-tabs" aria-hidden="true">
{TABS.map((tab, i) => (
<span key={tab} className="lv-gui-tab" data-active={i === 0}>
{tab}
</span>
))}
</nav>
<span className="lv-gui-search" aria-hidden="true">
<SearchGlyph />
</span>
</header>
<div className="lv-gui-body">
<aside className="lv-gui-cats" aria-label={t('categories')}>
{CATEGORIES.map((c) => (
<button
key={c.id}
type="button"
className="lv-gui-cat"
data-active={c.id === category.id}
aria-pressed={c.id === category.id}
data-demo={`cat:${c.id}`}
aria-label={c.name}
onClick={() => dispatch({ type: 'category', id: c.id })}
>
<CategoryIcon id={c.id} />
<span className="lv-gui-cat-name">{c.name}</span>
<span className="lv-gui-cat-count">{c.modules.length}</span>
</button>
))}
<div className="lv-gui-theme-dots">
{QUICK_THEMES.map((id) => {
const preset = PRESETS.find((p) => p.id === id)
if (!preset) return null
const dot = themeToCssVars(preset)
return (
<button
key={id}
type="button"
className="lv-gui-theme-dot"
data-active={theme.id === id}
data-demo={`theme:${id}`}
aria-label={t('theme', { name: preset.name })}
style={{ '--dot': dot['--gui-header-gradient'], '--dot-accent': dot['--gui-accent'] } as CSSProperties}
onClick={() => setTheme(id, 'user')}
/>
)
})}
</div>
</aside>
<div className="lv-gui-main">
<div className="lv-gui-grid" key={category.id}>
{category.modules.map((m) => (
<ModuleCard key={m.name} module={m} state={state} dispatch={dispatch} />
))}
</div>
</div>
</div>
</div>
<DemoCursor cursor={cursor} />
</div>
)
}

View file

@ -0,0 +1,59 @@
import type { Dispatch } from 'react'
import { useTranslation } from 'react-i18next'
import type { GuiAction, GuiState } from './demo'
import { Chevron } from './parts/Glyphs'
import type { GuiModule } from './parts/modules'
import { SettingControls } from './parts/SettingControls'
type Props = { module: GuiModule; state: GuiState; dispatch: Dispatch<GuiAction> }
/** One module card: bullet title, description, divider, bind + settings chevron + status toggle. */
export function ModuleCard({ module, state, dispatch }: Props) {
const { t } = useTranslation('clickgui')
const { name, settings } = module
const on = Boolean(state.enabled[name])
const open = state.expanded === name && Boolean(settings)
return (
<article className="lv-gui-card" data-on={on} data-open={open}>
<span className="lv-gui-card-glow" aria-hidden="true" />
<h3 className="lv-gui-card-title">
<span aria-hidden="true">• </span>
{name}
</h3>
<p className="lv-gui-card-desc">{t(`desc.${name}`)}</p>
<div className="lv-gui-card-divider" aria-hidden="true" />
<div className="lv-gui-card-foot">
<span className="lv-gui-bind">
{t('bind')}: <span>{t('bindNone')}</span>
</span>
{settings ? (
<button
type="button"
className="lv-gui-expand"
aria-expanded={open}
aria-label={t('settings', { name })}
data-demo={`expand:${name}`}
onClick={() => dispatch({ type: 'expand', module: name })}
>
<Chevron open={open} />
</button>
) : (
<span className="lv-gui-bind lv-gui-nosettings">{t('noSettings')}</span>
)}
<button
type="button"
role="switch"
aria-checked={on}
aria-label={t('toggle', { name })}
data-demo={`toggle:${name}`}
className="lv-gui-toggle"
onClick={() => dispatch({ type: 'toggle', module: name })}
>
<span className="lv-gui-toggle-knob" />
</button>
</div>
{open && settings ? <SettingControls module={name} settings={settings} state={state} dispatch={dispatch} /> : null}
</article>
)
}

View file

@ -0,0 +1,175 @@
import { type Dispatch, type RefObject, useEffect, useReducer, useRef, useState } from 'react'
import { CATEGORIES, type CategoryId, settingKey } from './parts/modules'
export type DemoStep = { at: number /* ms */; action: 'move' | 'click' | 'drag' | 'theme'; target: string; value?: number | string }
export const LOOP_MS = 12_500
/** The scripted loop: pick Visuals, switch Trails on, open it, drag Length, tick Rainbow, change theme, tidy up. */
export const DEMO: DemoStep[] = [
{ at: 400, action: 'move', target: 'cat:visuals' },
{ at: 1100, action: 'click', target: 'cat:visuals' },
{ at: 1700, action: 'move', target: 'toggle:Trails' },
{ at: 2400, action: 'click', target: 'toggle:Trails' },
{ at: 3000, action: 'move', target: 'expand:Trails' },
{ at: 3600, action: 'click', target: 'expand:Trails' },
{ at: 4300, action: 'move', target: 'knob:Trails.Length' },
{ at: 4900, action: 'drag', target: 'slider:Trails.Length', value: 85 },
{ at: 6300, action: 'move', target: 'check:Trails.Rainbow' },
{ at: 6900, action: 'click', target: 'check:Trails.Rainbow' },
{ at: 7700, action: 'move', target: 'theme:next' },
{ at: 8400, action: 'theme', target: 'theme:next' },
{ at: 9400, action: 'move', target: 'expand:Trails' },
{ at: 10000, action: 'click', target: 'expand:Trails' },
{ at: 10800, action: 'move', target: 'rest' },
]
export type GuiState = {
category: CategoryId
enabled: Record<string, boolean>
expanded: string | null
values: Record<string, number | boolean | string>
dragging: boolean
}
export type GuiAction =
| { type: 'category'; id: CategoryId }
| { type: 'toggle'; module: string }
| { type: 'expand'; module: string }
| { type: 'set'; key: string; value: number | boolean | string }
| { type: 'dragging'; on: boolean }
| { type: 'reset' }
export function initialGuiState(finished = false): GuiState {
const enabled: Record<string, boolean> = {}
const values: GuiState['values'] = {}
for (const c of CATEGORIES) {
for (const m of c.modules) {
enabled[m.name] = Boolean(m.on)
for (const s of m.settings ?? []) values[settingKey(m.name, s.name)] = s.value
}
}
if (!finished) return { category: 'combat', enabled, expanded: null, values, dragging: false }
// The resting "after the demo" frame, used under reduced motion.
enabled.Trails = true
values[settingKey('Trails', 'Length')] = 85
return { category: 'visuals', enabled, expanded: 'Trails', values, dragging: false }
}
export function guiReducer(state: GuiState, action: GuiAction): GuiState {
switch (action.type) {
case 'category':
return { ...state, category: action.id, expanded: null }
case 'toggle':
return { ...state, enabled: { ...state.enabled, [action.module]: !state.enabled[action.module] } }
case 'expand':
return { ...state, expanded: state.expanded === action.module ? null : action.module }
case 'set':
return { ...state, values: { ...state.values, [action.key]: action.value } }
case 'dragging':
return { ...state, dragging: action.on }
case 'reset':
return initialGuiState()
}
}
export type Cursor = { x: number; y: number; visible: boolean; pressed: boolean; duration: number }
type Options = {
paused: boolean
reduced: boolean
rootRef: RefObject<HTMLElement | null>
/** Resolves `theme:next` to a concrete `theme:<id>` target and applies it. */
nextTheme: () => string
onTheme: (id: string) => void
}
/** Layout position of `el` inside `root`, ignoring CSS transforms (the window is tilted in 3D). */
function offsetWithin(el: HTMLElement, root: HTMLElement): { x: number; y: number } {
let x = 0
let y = 0
let node: HTMLElement | null = el
while (node && node !== root) {
x += node.offsetLeft
y += node.offsetTop
node = node.offsetParent as HTMLElement | null
}
return { x, y }
}
export function useDemo(steps: DemoStep[], opts: Options): { cursor: Cursor; state: GuiState; dispatch: Dispatch<GuiAction> } {
const [state, dispatch] = useReducer(guiReducer, opts.reduced, initialGuiState)
const [cursor, setCursor] = useState<Cursor>({ x: 0, y: 0, visible: false, pressed: false, duration: 0 })
const [cycle, setCycle] = useState(0)
const optsRef = useRef(opts)
useEffect(() => {
optsRef.current = opts
})
useEffect(() => {
if (opts.paused || opts.reduced) return
const timers: number[] = []
const later = (ms: number, fn: () => void) => timers.push(window.setTimeout(fn, ms))
let pendingTheme = ''
const locate = (target: string, fraction?: number) => {
const root = optsRef.current.rootRef.current
if (!root) return null
if (target === 'rest') return { x: root.offsetWidth * 0.86, y: root.offsetHeight * 0.9 }
const resolved = target === 'theme:next' ? (pendingTheme ||= `theme:${optsRef.current.nextTheme()}`) : target
const el = root.querySelector<HTMLElement>(`[data-demo="${resolved}"]`)
if (!el) return null
const { x, y } = offsetWithin(el, root)
const fx = fraction ?? 0.5
return { x: x + el.offsetWidth * fx, y: y + el.offsetHeight * 0.5 }
}
const move = (target: string, duration: number, fraction?: number) => {
const p = locate(target, fraction)
if (p) setCursor((c) => ({ ...c, ...p, visible: true, duration }))
}
const click = (fn: () => void) => {
setCursor((c) => ({ ...c, pressed: true }))
later(160, () => {
setCursor((c) => ({ ...c, pressed: false }))
fn()
})
}
const run = (step: DemoStep) => {
const [kind, arg] = step.target.split(':')
if (step.action === 'move') return move(step.target, 650)
if (step.action === 'theme') {
return click(() => optsRef.current.onTheme(pendingTheme.replace('theme:', '')))
}
if (step.action === 'drag') {
const [module, setting] = arg.split('.')
const value = Number(step.value ?? 0)
setCursor((c) => ({ ...c, pressed: true }))
dispatch({ type: 'dragging', on: true })
move(step.target, 900, value / 100)
dispatch({ type: 'set', key: settingKey(module, setting), value })
return later(950, () => {
setCursor((c) => ({ ...c, pressed: false }))
dispatch({ type: 'dragging', on: false })
})
}
click(() => {
if (kind === 'cat') dispatch({ type: 'category', id: arg as CategoryId })
if (kind === 'toggle') dispatch({ type: 'toggle', module: arg })
if (kind === 'expand') dispatch({ type: 'expand', module: arg })
if (kind === 'check') dispatch({ type: 'set', key: arg, value: true })
})
}
dispatch({ type: 'reset' })
const lead = cycle === 0 ? 600 : 0
for (const step of steps) later(step.at + lead, () => run(step))
later(LOOP_MS + lead, () => setCycle((n) => n + 1))
return () => timers.forEach((t) => window.clearTimeout(t))
}, [steps, opts.paused, opts.reduced, cycle])
const idle = opts.paused || opts.reduced
return { cursor: idle ? { ...cursor, visible: false, pressed: false } : cursor, state, dispatch }
}

View file

@ -0,0 +1,49 @@
import type { Translation } from '../../../shared/i18n/common.ru'
import type { clickguiRu } from './ru'
export const clickguiEn = {
windowLabel: 'Interactive replica of the LoVisual ClickGui',
bind: 'Bind',
bindNone: 'none',
toggle: 'Toggle {{name}}',
settings: '{{name}} settings',
categories: 'Categories',
theme: '{{name}} theme',
noSettings: 'No settings',
desc: {
PvpCooldowns: 'Cooldown timers for shield, pearls and totems',
Predictions: 'Flight path of pearls and arrows',
TargetESP: 'Animated marker on your current target',
DamageTint: 'A colored flash when you take damage',
NoFriendDamage: 'Stops you from hitting friends by accident',
CombatLog: 'Hit and damage log in chat',
TPSSync: 'Syncs timers to the server TPS',
ProjectilePuncher: 'Knocks away incoming projectiles',
Trails: 'A glowing trail behind the player',
JumpCircles: 'Rings on the ground when you jump',
HitBubbles: 'Damage bubbles on every hit',
AmbientParticles: 'Soft particles floating around you',
Wings: 'Animated wings on your back',
ChinaHat: 'A cone hat with a gradient',
ShaderSky: 'A shader sky with clouds',
MotionBlur: 'Blur while you move',
PostFX: 'Bloom, vignette and color grading',
KillEffect: 'A lightning strike on kills',
ThirdPerson: 'A smooth third-person camera',
AutoTool: 'Picks the right tool for you',
DeathCoords: 'Death coordinates in chat',
AutoRespawn: 'Respawn without the death screen',
ClickPearl: 'Throw a pearl with a mouse button',
LockSlot: 'Protects slots from dropping',
UseTracker: 'Counts item usage',
NoDelay: 'Removes the jump delay',
StreamerMode: 'Hides personal info on stream',
NameProtect: 'Replaces your name on screen',
HitSounds: 'Pick your own hit sound',
ItemScroller: 'Move items around faster',
MessageFilter: 'Filters chat spam',
Optimize: 'Extra FPS without losing the looks',
NoServerPack: 'Declines server resource packs',
BetterMinecraft: 'Small interface improvements',
},
} satisfies Translation<typeof clickguiRu>

View file

@ -0,0 +1,46 @@
export const clickguiRu = {
windowLabel: 'Интерактивная копия ClickGui LoVisual',
bind: 'Бинд',
bindNone: 'нет',
toggle: 'Включить {{name}}',
settings: 'Настройки {{name}}',
categories: 'Категории',
theme: 'Тема {{name}}',
noSettings: 'Без настроек',
desc: {
PvpCooldowns: 'Таймеры перезарядки щита, жемчуга и тотема',
Predictions: 'Траектория полёта жемчуга и стрел',
TargetESP: 'Анимированная метка на текущей цели',
DamageTint: 'Цветная вспышка при получении урона',
NoFriendDamage: 'Не даёт случайно ударить друга',
CombatLog: 'Лог попаданий и урона в чате',
TPSSync: 'Подстраивает таймеры под TPS сервера',
ProjectilePuncher: 'Сбивает летящие в тебя снаряды',
Trails: 'Светящийся шлейф за игроком',
JumpCircles: 'Круги на земле при прыжке',
HitBubbles: 'Пузыри с уроном при ударе',
AmbientParticles: 'Мягкие частицы вокруг тебя',
Wings: 'Анимированные крылья за спиной',
ChinaHat: 'Конусная шляпа с градиентом',
ShaderSky: 'Шейдерное небо с облаками',
MotionBlur: 'Размытие в движении',
PostFX: 'Bloom, виньетка и цветокоррекция',
KillEffect: 'Эффект молнии при убийстве',
ThirdPerson: 'Плавная камера от третьего лица',
AutoTool: 'Сам берёт нужный инструмент',
DeathCoords: 'Координаты смерти в чате',
AutoRespawn: 'Возрождение без экрана смерти',
ClickPearl: 'Жемчуг по кнопке мыши',
LockSlot: 'Защищает слоты от выброса',
UseTracker: 'Счётчик использования предметов',
NoDelay: 'Убирает задержку прыжка',
StreamerMode: 'Прячет личные данные на стриме',
NameProtect: 'Заменяет твой ник на экране',
HitSounds: 'Звук попадания на выбор',
ItemScroller: 'Быстрое перекладывание предметов',
MessageFilter: 'Фильтр спама в чате',
Optimize: 'Лишние FPS без потери красоты',
NoServerPack: 'Отказ от серверного ресурспака',
BetterMinecraft: 'Мелкие улучшения интерфейса',
},
} as const

View file

@ -0,0 +1,70 @@
import type { Cursor } from '../demo'
import type { CategoryId } from './modules'
const PATHS: Record<CategoryId, string> = {
combat: 'M14.5 3.5 20.5 3.5 20.5 9.5 10 20 7 20 4 17 4 14ZM6 13.5 10.5 18M3.5 20.5 6.5 17.5',
visuals: 'M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12ZM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z',
player: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c.8-3.8 3.9-6 7.5-6s6.7 2.2 7.5 6',
misc: 'M5 5h5v5H5ZM14 5h5v5h-5ZM5 14h5v5H5ZM14 14h5v5h-5Z',
}
export function CategoryIcon({ id }: { id: CategoryId }) {
return (
<svg viewBox="0 0 24 24" className="lv-gui-icon" aria-hidden="true">
<path d={PATHS[id]} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function Chevron({ open }: { open: boolean }) {
return (
<svg viewBox="0 0 16 16" className="lv-gui-chevron" data-open={open} aria-hidden="true">
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function LogoMark() {
return (
<svg viewBox="0 0 20 20" className="lv-gui-logo-mark" aria-hidden="true">
<path d="M10 1.5 18.5 10 10 18.5 1.5 10Z" fill="var(--gui-accent)" />
<path d="M10 5.5 14.5 10 10 14.5 5.5 10Z" fill="var(--gui-window-bg)" />
</svg>
)
}
export function SearchGlyph() {
return (
<svg viewBox="0 0 16 16" className="lv-gui-icon" aria-hidden="true">
<circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.6" />
<path d="M10.5 10.5 14 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
/** The demo's fake mouse pointer; positioned in the window's own (untransformed) coordinates. */
export function DemoCursor({ cursor }: { cursor: Cursor }) {
return (
<div
aria-hidden="true"
className="lv-gui-cursor"
data-visible={cursor.visible}
data-pressed={cursor.pressed}
style={{
transform: `translate3d(${cursor.x}px, ${cursor.y}px, 0)`,
transitionDuration: `${cursor.duration}ms, 200ms`,
}}
>
<span className="lv-gui-cursor-ring" />
<svg viewBox="0 0 20 24" width="20" height="24">
<path
d="M2 1.5v17.2l4.6-4.3 3 7 3.2-1.4-3-6.8h6.4Z"
fill="#fff"
stroke="#0b1012"
strokeWidth="1.3"
strokeLinejoin="round"
/>
</svg>
</div>
)
}

View file

@ -0,0 +1,92 @@
import type { Dispatch } from 'react'
import type { GuiAction, GuiState } from '../demo'
import { type Setting, settingKey } from './modules'
const COLOR_CYCLE = ['var(--gui-accent)', '#ff6b9a', '#f2c14e', '#7cf29c', '#9d7bff']
type Props = { module: string; settings: Setting[]; state: GuiState; dispatch: Dispatch<GuiAction> }
/** Expanded settings of a module card: sliders, checkboxes and color dots like the in-game panel. */
export function SettingControls({ module, settings, state, dispatch }: Props) {
return (
<div className="lv-gui-settings">
{settings.map((s) => {
const key = settingKey(module, s.name)
const value = state.values[key] ?? s.value
if (s.kind === 'slider') {
return <Slider key={key} id={key} setting={s} value={Number(value)} dragging={state.dragging} dispatch={dispatch} />
}
if (s.kind === 'check') {
const on = Boolean(value)
return (
<button
key={key}
type="button"
role="checkbox"
aria-checked={on}
data-demo={`check:${key}`}
className="lv-gui-check-row"
onClick={() => dispatch({ type: 'set', key, value: !on })}
>
<span>{s.name}</span>
<span className="lv-gui-check" data-on={on}>
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 6.2 5 8.6l4.5-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
</span>
</button>
)
}
const current = String(value)
const next = COLOR_CYCLE[(COLOR_CYCLE.indexOf(current) + 1) % COLOR_CYCLE.length]
return (
<button
key={key}
type="button"
className="lv-gui-check-row"
aria-label={s.name}
onClick={() => dispatch({ type: 'set', key, value: next })}
>
<span>{s.name}</span>
<span className="lv-gui-color" style={{ background: current, boxShadow: `0 0 10px ${current}` }} />
</button>
)
})}
</div>
)
}
type SliderProps = {
id: string
setting: Extract<Setting, { kind: 'slider' }>
value: number
dragging: boolean
dispatch: Dispatch<GuiAction>
}
function Slider({ id, setting, value, dragging, dispatch }: SliderProps) {
const pct = ((value - setting.min) / (setting.max - setting.min)) * 100
return (
<label className="lv-gui-slider" data-dragging={dragging}>
<span className="lv-gui-slider-head">
<span>{setting.name}</span>
<span className="lv-gui-slider-value">
{Math.round(value)}
{setting.unit ?? ''}
</span>
</span>
<span className="lv-gui-slider-track" data-demo={`slider:${id}`}>
<span className="lv-gui-slider-fill" style={{ width: `${pct}%` }} />
<span className="lv-gui-slider-knob" data-demo={`knob:${id}`} style={{ left: `${pct}%` }} />
<input
type="range"
min={setting.min}
max={setting.max}
value={value}
aria-label={setting.name}
onChange={(e) => dispatch({ type: 'set', key: id, value: Number(e.target.value) })}
/>
</span>
</label>
)
}

View file

@ -0,0 +1,220 @@
/* ClickGui replica. Colors come from themeToCssVars(); the typed @property registrations
let a theme switch cross-fade (400ms) instead of snapping. */
@property --gui-window-from { syntax: "<color>"; inherits: true; initial-value: #12191b; }
@property --gui-window-to { syntax: "<color>"; inherits: true; initial-value: #12191b; }
@property --gui-header-from { syntax: "<color>"; inherits: true; initial-value: #142226; }
@property --gui-header-to { syntax: "<color>"; inherits: true; initial-value: #142226; }
@property --gui-card-from { syntax: "<color>"; inherits: true; initial-value: #43a7c8; }
@property --gui-card-to { syntax: "<color>"; inherits: true; initial-value: #43a7c8; }
@property --gui-stroke-from { syntax: "<color>"; inherits: true; initial-value: #203030; }
@property --gui-stroke-to { syntax: "<color>"; inherits: true; initial-value: #203030; }
@property --gui-surface { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-surface-hover { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-card-off { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-text { syntax: "<color>"; inherits: true; initial-value: #fff; }
@property --gui-text-muted { syntax: "<color>"; inherits: true; initial-value: #c6d2cd; }
@property --gui-accent { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
@property --gui-accent-soft { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
@property --gui-accent-glow { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-stroke-soft { syntax: "<color>"; inherits: true; initial-value: #fff; }
.lv-gui {
position: relative;
width: 100%;
max-width: 660px;
height: 452px;
container-type: inline-size;
font-family: var(--font-sans);
color: var(--gui-text);
font-size: 13px;
line-height: 1.3;
text-align: left;
user-select: none;
transition-property: --gui-window-from, --gui-window-to, --gui-header-from, --gui-header-to, --gui-card-from,
--gui-card-to, --gui-stroke-from, --gui-stroke-to, --gui-surface, --gui-surface-hover, --gui-card-off, --gui-text,
--gui-text-muted, --gui-accent, --gui-accent-soft, --gui-accent-glow, --gui-stroke-soft;
transition-duration: 400ms;
transition-timing-function: ease;
}
.lv-gui button { font: inherit; color: inherit; cursor: pointer; }
.lv-gui-icon { width: 16px; height: 16px; flex: none; }
.lv-gui-bloom {
position: absolute;
inset: 8% 4% -2%;
border-radius: 40px;
background: radial-gradient(60% 60% at 50% 45%, var(--gui-accent-glow), transparent 70%);
filter: blur(40px);
opacity: 0.9;
pointer-events: none;
}
.lv-gui-window {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
border-radius: 12px;
border: 1px solid transparent;
overflow: hidden;
background:
linear-gradient(var(--gui-window-angle), var(--gui-window-from), var(--gui-window-to)) padding-box,
linear-gradient(135deg, color-mix(in srgb, var(--gui-accent) 55%, transparent), var(--gui-stroke-from) 35%,
var(--gui-stroke-to) 70%, color-mix(in srgb, var(--gui-accent) 30%, transparent)) border-box;
box-shadow: 0 30px 80px -20px rgb(0 0 0 / 0.75), 0 0 0 1px rgb(0 0 0 / 0.35), 0 0 42px -6px var(--gui-accent-glow);
backdrop-filter: blur(14px) saturate(1.2);
}
.lv-gui-header {
display: flex;
align-items: center;
gap: 14px;
height: 44px;
padding: 0 14px;
flex: none;
background: linear-gradient(var(--gui-header-angle), var(--gui-header-from), var(--gui-header-to));
border-bottom: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 25%, transparent);
}
.lv-gui-logo { display: flex; align-items: center; gap: 8px; font-family: var(--font-display); font-weight: 700; font-size: 13px; letter-spacing: 0.02em; }
.lv-gui-logo-mark { width: 16px; height: 16px; filter: drop-shadow(0 0 6px var(--gui-accent)); }
.lv-gui-tabs { display: none; gap: 4px; margin-left: 10px; }
.lv-gui-tab { position: relative; padding: 5px 10px; border-radius: 7px; color: var(--gui-text-muted); font-size: 12px; }
.lv-gui-tab[data-active="true"] { color: var(--gui-text); background: color-mix(in srgb, var(--gui-accent) 14%, transparent); }
.lv-gui-tab[data-active="true"]::after {
content: ""; position: absolute; left: 10px; right: 10px; bottom: -8px; height: 2px; border-radius: 2px;
background: var(--gui-accent); box-shadow: 0 0 10px var(--gui-accent);
}
.lv-gui-search {
margin-left: auto; display: flex; align-items: center; justify-content: center; width: 30px; height: 26px;
border-radius: 7px; color: var(--gui-text-muted); background: color-mix(in srgb, var(--gui-stroke-soft) 8%, transparent);
}
.lv-gui-body { display: flex; min-height: 0; flex: 1; }
.lv-gui-cats {
display: flex; flex-direction: column; gap: 3px; width: 46px; flex: none; padding: 10px 6px;
border-right: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 14%, transparent);
background: color-mix(in srgb, black 12%, transparent);
}
.lv-gui-cat {
position: relative; display: flex; align-items: center; justify-content: center; gap: 9px; height: 32px;
padding: 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--gui-text-muted) !important;
transition: background-color 200ms, color 200ms;
}
.lv-gui-cat:hover { background: var(--gui-surface-hover); }
.lv-gui-cat[data-active="true"] {
color: var(--gui-text) !important;
background: linear-gradient(90deg, color-mix(in srgb, var(--gui-accent) 24%, transparent), transparent);
}
.lv-gui-cat[data-active="true"]::before {
content: ""; position: absolute; left: -6px; top: 7px; bottom: 7px; width: 3px; border-radius: 0 3px 3px 0;
background: var(--gui-accent); box-shadow: 0 0 12px var(--gui-accent);
}
.lv-gui-cat[data-active="true"] .lv-gui-icon { color: var(--gui-accent); filter: drop-shadow(0 0 5px var(--gui-accent)); }
.lv-gui-cat-name, .lv-gui-cat-count { display: none; }
.lv-gui-cat-count { margin-left: auto; font-family: var(--font-code); font-size: 10px; opacity: 0.6; }
.lv-gui-theme-dots { margin-top: auto; display: grid; grid-template-columns: 1fr; justify-items: center; gap: 7px; padding-top: 10px; }
.lv-gui-theme-dot {
width: 16px; height: 16px; border-radius: 50%; border: 1px solid rgb(255 255 255 / 0.25); background: var(--dot);
box-shadow: inset 0 0 0 3px rgb(0 0 0 / 0.25), 0 0 0 0 var(--dot-accent); transition: box-shadow 250ms, transform 250ms;
}
.lv-gui-theme-dot::after { content: ""; display: block; margin: 4px; height: 6px; border-radius: 50%; background: var(--dot-accent); }
.lv-gui-theme-dot[data-active="true"] { box-shadow: 0 0 0 2px var(--dot-accent), 0 0 12px var(--dot-accent); transform: scale(1.1); }
.lv-gui-main {
position: relative; flex: 1; min-width: 0; overflow: hidden; padding: 10px;
mask-image: linear-gradient(to bottom, #000 82%, transparent);
}
.lv-gui-grid { display: grid; grid-template-columns: 1fr; gap: 8px; align-items: start; animation: lv-gui-in 380ms ease both; }
@keyframes lv-gui-in { from { opacity: 0; transform: translateY(8px); } }
.lv-gui-card {
position: relative; overflow: hidden; padding: 8px 9px 7px; border-radius: 6px;
background: linear-gradient(180deg, color-mix(in srgb, var(--gui-surface-hover) 70%, transparent), var(--gui-card-off)),
rgb(8 12 14 / 0.55);
border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 12%, transparent);
box-shadow: 0 2px 9px rgb(0 0 0 / 0.28);
transition: border-color 250ms, box-shadow 250ms, transform 200ms;
}
.lv-gui-card:hover { transform: translateY(-1px); border-color: color-mix(in srgb, var(--gui-stroke-soft) 26%, transparent); }
.lv-gui-card[data-on="true"] {
background: linear-gradient(var(--gui-card-angle, 180deg), var(--gui-card-from), var(--gui-card-to)), rgb(8 12 14 / 0.45);
border-color: color-mix(in srgb, var(--gui-accent) 40%, transparent);
box-shadow: 0 2px 14px rgb(0 0 0 / 0.3), 0 0 18px -6px var(--gui-accent-glow);
}
.lv-gui-card-glow {
position: absolute; left: -30px; top: -40px; width: 120px; height: 90px; border-radius: 50%;
background: radial-gradient(closest-side, var(--gui-accent-soft), transparent); opacity: 0.12; pointer-events: none;
transition: opacity 250ms;
}
.lv-gui-card[data-on="true"] .lv-gui-card-glow { opacity: 0.4; }
.lv-gui-card-title { position: relative; margin: 0; font-family: var(--font-sans); font-weight: 600; font-size: 13px; color: var(--gui-text); opacity: 0.66; transition: opacity 250ms; }
.lv-gui-card[data-on="true"] .lv-gui-card-title { opacity: 1; }
.lv-gui-card-desc { position: relative; margin: 2px 0 0; font-size: 11px; color: var(--gui-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lv-gui-card-divider { height: 1px; margin: 7px -9px 6px; background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--gui-stroke-soft) 30%, transparent), transparent); }
.lv-gui-card-foot { position: relative; display: flex; align-items: center; gap: 6px; }
.lv-gui-bind { font-size: 10px; color: var(--gui-text-muted); }
.lv-gui-bind span { font-family: var(--font-code); }
.lv-gui-nosettings { display: none; }
.lv-gui-expand {
margin-left: auto; display: flex; width: 22px; height: 18px; align-items: center; justify-content: center;
border: 0; border-radius: 5px; background: color-mix(in srgb, var(--gui-stroke-soft) 8%, transparent);
}
.lv-gui-expand:hover { background: var(--gui-surface-hover); }
.lv-gui-nosettings + .lv-gui-toggle, .lv-gui-bind + .lv-gui-toggle { margin-left: auto; }
.lv-gui-chevron { width: 12px; height: 12px; color: var(--gui-text-muted); transition: transform 250ms; }
.lv-gui-chevron[data-open="true"] { transform: rotate(180deg); color: var(--gui-accent); }
.lv-gui-toggle {
position: relative; width: 28px; height: 15px; flex: none; padding: 0; border-radius: 99px;
border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 30%, transparent); background: rgb(0 0 0 / 0.35);
transition: background-color 250ms, box-shadow 250ms, border-color 250ms;
}
.lv-gui-toggle-knob {
position: absolute; top: 2px; left: 2px; width: 9px; height: 9px; border-radius: 50%;
background: var(--gui-text-muted); transition: transform 250ms cubic-bezier(0.3, 1.4, 0.5, 1), background-color 250ms;
}
.lv-gui-toggle[aria-checked="true"] {
background: var(--gui-accent); border-color: var(--gui-accent); box-shadow: 0 0 12px var(--gui-accent-glow), 0 0 4px var(--gui-accent);
}
.lv-gui-toggle[aria-checked="true"] .lv-gui-toggle-knob { transform: translateX(13px); background: #fff; }
.lv-gui-settings { position: relative; display: flex; flex-direction: column; gap: 7px; margin-top: 8px; padding-top: 8px;
border-top: 1px dashed color-mix(in srgb, var(--gui-stroke-soft) 18%, transparent); animation: lv-gui-in 260ms ease both; }
.lv-gui-slider { display: flex; flex-direction: column; gap: 5px; font-size: 11px; color: var(--gui-text-muted); }
.lv-gui-slider-head { display: flex; justify-content: space-between; }
.lv-gui-slider-value { font-family: var(--font-code); color: var(--gui-text); }
.lv-gui-slider-track { position: relative; display: block; height: 4px; border-radius: 4px; background: rgb(0 0 0 / 0.4); }
.lv-gui-slider-fill { position: absolute; inset: 0 auto 0 0; border-radius: 4px; background: linear-gradient(90deg, var(--gui-accent-soft), var(--gui-accent)); box-shadow: 0 0 8px var(--gui-accent-glow); }
.lv-gui-slider-knob { position: absolute; top: -4px; width: 12px; height: 12px; margin-left: -6px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 3px color-mix(in srgb, var(--gui-accent) 45%, transparent), 0 0 10px var(--gui-accent); }
.lv-gui-slider[data-dragging="true"] .lv-gui-slider-fill { transition: width 900ms cubic-bezier(0.4, 0, 0.3, 1); }
.lv-gui-slider[data-dragging="true"] .lv-gui-slider-knob { transition: left 900ms cubic-bezier(0.4, 0, 0.3, 1); }
.lv-gui-slider input { position: absolute; inset: -8px 0; width: 100%; opacity: 0; cursor: pointer; margin: 0; }
.lv-gui-check-row { display: flex; align-items: center; justify-content: space-between; padding: 0; border: 0; background: none; font-size: 11px; color: var(--gui-text-muted) !important; }
.lv-gui-check { display: flex; width: 14px; height: 14px; border-radius: 4px; border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 40%, transparent); color: transparent; transition: all 200ms; }
.lv-gui-check[data-on="true"] { background: var(--gui-accent); border-color: var(--gui-accent); color: #0b1012; box-shadow: 0 0 8px var(--gui-accent-glow); }
.lv-gui-color { width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgb(255 255 255 / 0.8); }
.lv-gui-cursor {
position: absolute; left: 0; top: 0; z-index: 5; pointer-events: none; opacity: 0;
transition-property: transform, opacity; transition-timing-function: cubic-bezier(0.45, 0, 0.2, 1);
filter: drop-shadow(0 3px 5px rgb(0 0 0 / 0.5));
}
.lv-gui-cursor[data-visible="true"] { opacity: 1; }
.lv-gui-cursor svg { position: relative; margin: -2px 0 0 -2px; transition: transform 120ms; }
.lv-gui-cursor[data-pressed="true"] svg { transform: scale(0.86); }
.lv-gui-cursor-ring {
position: absolute; left: -14px; top: -14px; width: 28px; height: 28px; border-radius: 50%;
border: 2px solid var(--gui-accent); opacity: 0; transform: scale(0.4); transition: opacity 300ms, transform 300ms;
}
.lv-gui-cursor[data-pressed="true"] .lv-gui-cursor-ring { opacity: 0.9; transform: scale(1); box-shadow: 0 0 14px var(--gui-accent); }
@container (min-width: 440px) {
.lv-gui-cats { width: 128px; }
.lv-gui-cat { justify-content: flex-start; }
.lv-gui-cat-name, .lv-gui-cat-count { display: inline; }
.lv-gui-theme-dots { grid-template-columns: repeat(4, 1fr); }
.lv-gui-grid { grid-template-columns: 1fr 1fr; }
.lv-gui-nosettings { display: inline; margin-left: auto; opacity: 0.6; }
}
@container (min-width: 560px) {
.lv-gui-tabs { display: flex; }
}

View file

@ -0,0 +1,97 @@
// In-game catalogue shown by the ClickGui replica. Module, category and setting names are
// the mod's own identifiers and stay untranslated; descriptions live in the clickgui i18n.
export type Setting =
| { kind: 'slider'; name: string; min: number; max: number; value: number; unit?: string }
| { kind: 'check'; name: string; value: boolean }
| { kind: 'color'; name: string; value: string }
export type GuiModule = { name: ModuleName; on?: boolean; settings?: Setting[] }
export type CategoryId = 'combat' | 'visuals' | 'player' | 'misc'
export type Category = { id: CategoryId; name: string; modules: GuiModule[] }
export type ModuleName =
| 'PvpCooldowns' | 'Predictions' | 'TargetESP' | 'DamageTint' | 'NoFriendDamage' | 'CombatLog' | 'TPSSync'
| 'ProjectilePuncher' | 'Trails' | 'JumpCircles' | 'HitBubbles' | 'AmbientParticles' | 'Wings' | 'ChinaHat'
| 'ShaderSky' | 'MotionBlur' | 'PostFX' | 'KillEffect' | 'ThirdPerson' | 'AutoTool' | 'DeathCoords'
| 'AutoRespawn' | 'ClickPearl' | 'LockSlot' | 'UseTracker' | 'NoDelay' | 'StreamerMode' | 'NameProtect'
| 'HitSounds' | 'ItemScroller' | 'MessageFilter' | 'Optimize' | 'NoServerPack' | 'BetterMinecraft'
const slider = (name: string, value: number, max = 100, unit?: string): Setting => ({
kind: 'slider', name, min: 0, max, value, unit,
})
const check = (name: string, value: boolean): Setting => ({ kind: 'check', name, value })
const color = (name: string, value: string): Setting => ({ kind: 'color', name, value })
export const CATEGORIES: Category[] = [
{
id: 'combat',
name: 'Combat',
modules: [
{ name: 'TargetESP', on: true, settings: [slider('Size', 60), check('Pulse', true), color('Color', 'var(--gui-accent)')] },
{ name: 'PvpCooldowns', on: true },
{ name: 'Predictions', settings: [slider('Ticks', 6, 20), check('Show path', true)] },
{ name: 'DamageTint', on: true, settings: [slider('Strength', 40), color('Tint', '#ff5a6e')] },
{ name: 'NoFriendDamage' },
{ name: 'TPSSync' },
{ name: 'ProjectilePuncher' },
{ name: 'CombatLog' },
],
},
{
id: 'visuals',
name: 'Visuals',
modules: [
{ name: 'Trails', settings: [slider('Length', 45), check('Rainbow', false), color('Color', 'var(--gui-accent)')] },
{ name: 'JumpCircles', on: true, settings: [slider('Radius', 70), color('Color', 'var(--gui-accent)')] },
{ name: 'HitBubbles', on: true },
{ name: 'AmbientParticles', on: true, settings: [slider('Count', 120, 150), check('Glow', true)] },
{ name: 'Wings' },
{ name: 'ChinaHat', settings: [check('Gradient', true), color('Color', '#f2c14e')] },
{ name: 'ShaderSky', on: true },
{ name: 'MotionBlur', settings: [slider('Strength', 30)] },
{ name: 'PostFX', on: true, settings: [check('Bloom', true), slider('Vignette', 25)] },
{ name: 'KillEffect' },
],
},
{
id: 'player',
name: 'Player',
modules: [
{ name: 'ThirdPerson', on: true, settings: [slider('Distance', 4, 12, 'm')] },
{ name: 'AutoTool', on: true },
{ name: 'DeathCoords', on: true },
{ name: 'AutoRespawn' },
{ name: 'ClickPearl' },
{ name: 'LockSlot' },
{ name: 'UseTracker' },
{ name: 'NoDelay' },
],
},
{
id: 'misc',
name: 'Misc',
modules: [
{ name: 'StreamerMode', settings: [check('Hide coords', true), check('Hide IP', true)] },
{ name: 'NameProtect', on: true },
{ name: 'HitSounds', on: true, settings: [slider('Volume', 65)] },
{ name: 'ItemScroller' },
{ name: 'MessageFilter' },
{ name: 'Optimize', on: true },
{ name: 'NoServerPack' },
{ name: 'BetterMinecraft' },
],
},
]
/** The ClickGui's top tab row — in-game labels. */
export const TABS = ['Modules', 'Themes', 'Configs', 'HUD'] as const
/** Presets offered as quick dots in the category column (and cycled by the demo). */
export const QUICK_THEMES = ['classic', 'nitro', 'sunset', 'aurora'] as const
export const settingKey = (module: string, setting: string) => `${module}.${setting}`
export const findModule = (name: string) => CATEGORIES.flatMap((c) => c.modules).find((m) => m.name === name)

View file

@ -0,0 +1,47 @@
import { act, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { ActiveThemeProvider } from '../../themes/useActiveTheme'
import { ClickGuiWindow } from '../ClickGuiWindow'
import { DEMO, guiReducer, initialGuiState, LOOP_MS } from '../demo'
const renderGui = () => render(<ActiveThemeProvider applyToPage={false}><ClickGuiWindow /></ActiveThemeProvider>)
test('reduced motion shows the finished demo frame with no cursor', () => {
renderGui()
expect(screen.getByRole('button', { name: 'Visuals' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('switch', { name: 'Включить Trails' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('slider', { name: 'Length' })).toHaveValue('85')
})
test('cards are interactive: switch categories, toggle and expand', async () => {
const user = userEvent.setup()
renderGui()
await user.click(screen.getByRole('button', { name: 'Misc' }))
const toggle = screen.getByRole('switch', { name: 'Включить StreamerMode' })
expect(toggle).toHaveAttribute('aria-checked', 'false')
await user.click(toggle)
expect(toggle).toHaveAttribute('aria-checked', 'true')
await user.click(screen.getByRole('button', { name: 'Настройки StreamerMode' }))
const card = toggle.closest('article') as HTMLElement
expect(within(card).getByRole('checkbox', { name: /Hide IP/ })).toHaveAttribute('aria-checked', 'true')
})
test('the scripted demo toggles Trails and loops back to the start', () => {
vi.useFakeTimers()
vi.stubGlobal('matchMedia', (q: string) => ({ matches: !q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
renderGui()
expect(screen.getByRole('button', { name: 'Combat' })).toHaveAttribute('aria-pressed', 'true')
act(() => vi.advanceTimersByTime(3200))
expect(screen.getByRole('switch', { name: 'Включить Trails' })).toHaveAttribute('aria-checked', 'true')
act(() => vi.advanceTimersByTime(LOOP_MS))
expect(screen.getByRole('button', { name: 'Combat' })).toHaveAttribute('aria-pressed', 'true')
vi.useRealTimers()
})
test('demo timeline fits in the loop and the reducer resets', () => {
expect(Math.max(...DEMO.map((s) => s.at))).toBeLessThan(LOOP_MS)
const toggled = guiReducer(initialGuiState(), { type: 'toggle', module: 'Trails' })
expect(toggled.enabled.Trails).toBe(true)
expect(guiReducer(toggled, { type: 'reset' }).enabled.Trails).toBe(false)
})

View file

@ -0,0 +1,41 @@
import { expect, test } from 'vitest'
import { PRESETS } from '../../themes/presets.generated'
import { cssAngle, themeToCssVars } from '../themeVars'
const preset = (id: string) => {
const p = PRESETS.find((x) => x.id === id)
if (!p) throw new Error(id)
return p
}
test('maps every palette slot of Classic exactly', () => {
const vars = themeToCssVars(preset('classic'))
expect(vars['--gui-window-bg']).toBe('rgba(18, 25, 27, 0.941)')
expect(vars['--gui-header']).toBe('rgba(20, 34, 38, 0.941)')
expect(vars['--gui-stroke']).toBe('rgba(32, 48, 48, 0.376)')
expect(vars['--gui-card-off']).toBe('rgba(51, 64, 70, 0)')
expect(vars['--gui-text']).toBe('rgba(255, 255, 255, 1)')
expect(vars['--gui-text-muted']).toBe('rgba(198, 210, 205, 0.533)')
expect(vars['--gui-accent']).toBe('rgba(92, 200, 231, 1)')
expect(vars['--gui-accent-soft']).toBe('rgba(92, 200, 231, 0.502)')
expect(vars['--gui-stroke-soft']).toBe('rgba(255, 255, 255, 0.376)')
for (const key of ['--gui-surface', '--gui-surface-hover', '--gui-card-on']) expect(vars).toHaveProperty(key)
})
test('disabled gradients fall back to the flat color, enabled ones become linear-gradients', () => {
const classic = themeToCssVars(preset('classic'))
expect(classic['--gui-window-gradient']).toBe('rgba(18, 25, 27, 0.941)')
expect(classic['--gui-header-gradient']).toBe('linear-gradient(180deg, rgba(39, 52, 55, 0.941), rgba(19, 32, 36, 0.941))')
const nitro = themeToCssVars(preset('nitro'))
expect(nitro['--gui-window-gradient']).toBe('linear-gradient(135deg, rgba(54, 32, 95, 1), rgba(11, 125, 255, 1))')
expect(nitro['--gui-window-from']).toBe('rgba(54, 32, 95, 1)')
expect(nitro['--gui-window-to']).toBe('rgba(11, 125, 255, 1)')
expect(nitro['--gui-window-angle']).toBe('135deg')
})
test('mod angles convert to CSS angles', () => {
expect(cssAngle(90)).toBe(180)
expect(cssAngle(0)).toBe(90)
expect(cssAngle(300)).toBe(30)
})

View file

@ -0,0 +1,57 @@
import type { CSSProperties } from 'react'
import { argbToCss, type GradientSpec, type ThemeEntry, withAlpha } from '../themes/types'
type CssVars = CSSProperties & Record<`--${string}`, string>
/**
* The mod measures gradient angles from the +X axis with 90° pointing down (its header
* gradient is "top-lit" at 90°); CSS measures from "to top" clockwise, so 90° there is
* left→right. Mod angle + 90 gives the CSS angle.
*/
export const cssAngle = (modAngle: number) => (((modAngle + 90) % 360) + 360) % 360
/** `linear-gradient(...)` when the slot's gradient is enabled, else the flat fallback color. */
export function gradientToCss(spec: GradientSpec, flat: string): string {
if (!spec.enabled) return argbToCss(flat)
return `linear-gradient(${cssAngle(spec.angle)}deg, ${argbToCss(spec.start)}, ${argbToCss(spec.end)})`
}
const GRADIENT_SLOTS = [
['window', 'windowGradient', 'windowBg'],
['header', 'headerGradient', 'windowHeader'],
['surface', 'surfaceGradient', 'surface'],
['card', 'cardGradient', 'cardEnabled'],
['stroke', 'strokeGradient', 'windowStroke'],
] as const
/**
* Maps a mod theme to the CSS custom properties the ClickGui replica reads.
* Besides the full `--gui-<slot>-gradient` strings it emits `-from`/`-to`/`-angle` parts:
* those are registered as typed `@property`s so a theme switch cross-fades smoothly.
*/
export function themeToCssVars(entry: ThemeEntry): CssVars {
const t = entry.theme
const vars: CssVars = {
'--gui-window-bg': argbToCss(t.windowBg),
'--gui-header': argbToCss(t.windowHeader),
'--gui-stroke': argbToCss(t.windowStroke),
'--gui-surface': argbToCss(t.surface),
'--gui-surface-hover': argbToCss(t.surfaceHover),
'--gui-card-on': argbToCss(t.cardEnabled),
'--gui-card-off': argbToCss(t.cardDisabled),
'--gui-text': argbToCss(t.textPrimary),
'--gui-text-muted': argbToCss(t.textMuted),
'--gui-accent': argbToCss(t.accent),
'--gui-accent-soft': argbToCss(t.accentSoft),
'--gui-stroke-soft': argbToCss(t.strokeSoft),
'--gui-accent-glow': withAlpha(t.accent, 0.45),
}
for (const [slot, key, flat] of GRADIENT_SLOTS) {
const spec = entry[key]
vars[`--gui-${slot}-gradient`] = gradientToCss(spec, t[flat])
vars[`--gui-${slot}-from`] = argbToCss(spec.enabled ? spec.start : t[flat])
vars[`--gui-${slot}-to`] = argbToCss(spec.enabled ? spec.end : t[flat])
vars[`--gui-${slot}-angle`] = `${cssAngle(spec.angle)}deg`
}
return vars
}

View file

@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next'
import { DOWNLOAD_URL, formatSize } from './api'
import '../landing/styles/landing.css'
type Props = {
size?: 'md' | 'lg'
/** Latest release tag (e.g. `0.1.1`); with `bytes` it renders a size hint under the label. */
version?: string
bytes?: number
className?: string
}
/** The primary one-click download link. Kept query-free so the landing stays light. */
export function DownloadButton({ size = 'md', version, bytes, className = '' }: Props) {
const { t, i18n } = useTranslation('download')
const hint = version && bytes ? t('versionHint', { version, size: formatSize(bytes, i18n.language) }) : null
const pad = size === 'lg' ? 'px-7 py-4 text-lg' : ''
return (
<span className={`inline-flex flex-col items-start gap-2 ${className}`}>
<a href={DOWNLOAD_URL} download className={`lv-cta-primary ${pad}`}>
{t('button')}
</a>
<span className="font-code text-xs text-frost">{hint ?? t('hint')}</span>
</span>
)
}

View file

@ -0,0 +1,43 @@
import { useQuery } from '@tanstack/react-query'
import Markdown from 'react-markdown'
import { useTranslation } from 'react-i18next'
import { fetchReleases } from './api'
/**
* Changelog feed. Markdown bodies are rendered by react-markdown, which does not
* enable raw HTML — so a `<script>` or `onerror=` in a release body stays inert text.
*/
export function ReleaseNotes() {
const { t, i18n } = useTranslation('download')
const { data, isError } = useQuery({
queryKey: ['releases'],
queryFn: fetchReleases,
staleTime: 10 * 60 * 1000,
})
if (isError || !data || data.length === 0) {
return (
<p className="rounded-panel border border-white/10 bg-black/30 p-6 text-frost">{t('notes.empty')}</p>
)
}
const date = new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' })
return (
<ol className="flex flex-col gap-8">
{data.map((r) => (
<li key={r.tag_name} className="flex flex-col gap-2">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="font-display text-xl font-bold text-snow">{r.name || r.tag_name}</h3>
<time className="font-code text-xs text-frost" dateTime={r.published_at}>
{date.format(new Date(r.published_at))}
</time>
</div>
<div className="lv-markdown text-[0.95rem] leading-relaxed text-snow/85">
<Markdown>{r.body}</Markdown>
</div>
</li>
))}
</ol>
)
}

View file

@ -0,0 +1,45 @@
const DEFAULT_REPO = 'loki5512344/LoVisual-'
/** `owner/name` of the GitHub repository that publishes mod releases. */
export const GITHUB_REPO: string = import.meta.env.VITE_GITHUB_REPO || DEFAULT_REPO
/**
* One-click download of the newest build: GitHub redirects `releases/latest/download/<asset>`
* straight to the file, so every release must carry an asset named exactly `lovisual.jar`.
*/
export const DOWNLOAD_URL = `https://github.com/${GITHUB_REPO}/releases/latest/download/lovisual.jar`
/** A published GitHub release, narrowed to the fields the download page renders. */
export type Release = {
tag_name: string
name: string
published_at: string
body: string
html_url: string
assets: { name: string; size: number; download_count: number }[]
}
/** Ten most recent releases from the public GitHub API (unauthenticated, ~60 req/h/IP). */
export async function fetchReleases(): Promise<Release[]> {
const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=10`, {
headers: { accept: 'application/vnd.github+json' },
})
if (!res.ok) throw new Error(`GitHub API responded ${res.status}`)
return (await res.json()) as Release[]
}
/** Total bytes of `.jar` assets in a release — the download button's size hint. */
export function releaseSize(release: Release): number {
return release.assets.reduce((sum, a) => (a.name.endsWith('.jar') ? sum + a.size : sum), 0)
}
/** Human size in megabytes, locale-aware (e.g. «4,2 МБ» / "4.2 MB"). */
export function formatSize(bytes: number, locale: string): string {
const mb = bytes / (1024 * 1024)
return new Intl.NumberFormat(locale, {
style: 'unit',
unit: 'megabyte',
unitDisplay: 'short',
maximumFractionDigits: 1,
}).format(mb)
}

View file

@ -0,0 +1,29 @@
import type { Translation } from '../../../shared/i18n/common.ru'
import type { downloadRu } from './ru'
export const downloadEn = {
nav: 'Download',
button: 'Download for free',
hint: 'One .jar straight from GitHub',
versionHint: 'v{version} · {size}',
page: {
title: 'Download LoVisual',
lead: 'A free visuals mod for Minecraft 26.2 on Fabric. One file — no installers, no ads.',
reqTitle: 'What you need',
reqMc: 'Minecraft',
reqLoader: 'Fabric Loader',
reqApi: 'Fabric API',
reqOptionalTitle: 'Optional, for maximum FPS',
reqOptional: 'Sodium and Iris — the mod does not require them but plays nicely.',
installTitle: 'Install in three steps',
step1: 'Download lovisual.jar with the button above.',
step2: 'Drop the file into your mods folder (create it if it is missing).',
step3: 'Launch Minecraft with the Fabric profile — the mod is active right away.',
},
notes: {
title: "What's new",
allReleases: 'All releases on GitHub',
empty: 'The changelog will appear once the first public release is out.',
downloads: '{count} downloads',
},
} satisfies Translation<typeof downloadRu>

View file

@ -0,0 +1,26 @@
export const downloadRu = {
nav: 'Скачать',
button: 'Скачать бесплатно',
hint: 'Один .jar прямо с GitHub',
versionHint: 'v{version} · {size}',
page: {
title: 'Скачать LoVisual',
lead: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric. Один файл — без установщиков и без рекламы.',
reqTitle: 'Что понадобится',
reqMc: 'Minecraft',
reqLoader: 'Fabric Loader',
reqApi: 'Fabric API',
reqOptionalTitle: 'По желанию для максимального FPS',
reqOptional: 'Sodium и Iris — мод не требует их, но дружит с ними.',
installTitle: 'Установка в три шага',
step1: 'Скачай lovisual.jar кнопкой выше.',
step2: 'Положи файл в папку mods (создай её, если нет).',
step3: 'Запусти Minecraft с профилем Fabric — мод активен сразу.',
},
notes: {
title: 'Что нового',
allReleases: 'Все релизы на GitHub',
empty: 'Список изменений появится, когда выйдет первый публичный релиз.',
downloads: '{count} загрузок',
},
} as const

View file

@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, expect, test, vi } from 'vitest'
import { DownloadButton } from '../DownloadButton'
import { ReleaseNotes } from '../ReleaseNotes'
function withQuery(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
}
const release = (body: string) => ({
tag_name: 'v0.1.1',
name: 'v0.1.1',
published_at: '2026-09-01T00:00:00Z',
body,
html_url: 'https://github.com/x/y/releases/tag/v0.1.1',
assets: [{ name: 'lovisual.jar', size: 4_400_000, download_count: 12 }],
})
afterEach(() => vi.restoreAllMocks())
test('DownloadButton links straight to the latest lovisual.jar', () => {
const { container } = withQuery(<DownloadButton />)
const link = container.querySelector('a')
expect(link?.getAttribute('href')).toBe(
'https://github.com/loki5512344/LoVisual-/releases/latest/download/lovisual.jar',
)
expect(link?.hasAttribute('download')).toBe(true)
})
test('ReleaseNotes renders Markdown but never raw HTML from a release body', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: async () => [release('# Заголовок\n\n<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>')],
} as unknown as Response)
const { container } = withQuery(<ReleaseNotes />)
expect(await screen.findByRole('heading', { level: 1, name: 'Заголовок' })).toBeInTheDocument()
expect(container.querySelector('script')).toBeNull()
expect(container.querySelector('img')).toBeNull()
})
test('ReleaseNotes shows the fallback when the releases fetch fails', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down'))
withQuery(<ReleaseNotes />)
expect(await screen.findByText(/первый публичный релиз/)).toBeInTheDocument()
})

View file

@ -0,0 +1,66 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { prefersReducedMotion } from '../../shared/motion/reducedMotion'
import { ButtonLink } from '../../shared/ui/Button'
import { useSession } from '../auth/session'
const COMMAND = '%config load 7KQ3M9XA'
const TYPE_DELAY_MS = 60
export function CommandHero() {
const { t } = useTranslation('landing')
const { status } = useSession()
return (
<section className="relative flex flex-col gap-6 pb-16 pt-20 sm:pt-28">
<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">{t('cloud.ctaAuthenticated')}</ButtonLink>
) : (
<ButtonLink to="/register">{t('cloud.ctaAnonymous')}</ButtonLink>
)}
<ButtonLink to="/showcase" variant="quiet">
{t('cloud.ctaShowcase')}
</ButtonLink>
</div>
<ChatBar />
</section>
)
}
function ChatBar() {
const { t } = useTranslation('landing')
const [typed, setTyped] = useState(() => (prefersReducedMotion() ? COMMAND.length : 0))
const done = typed >= COMMAND.length
useEffect(() => {
if (done) return
const t = setInterval(() => setTyped((n) => Math.min(n + 1, COMMAND.length)), TYPE_DELAY_MS)
return () => clearInterval(t)
}, [done])
return (
<div
role="img"
aria-label={t('cloud.chatAriaLabel')}
className="mt-6 flex min-h-[4.5rem] w-full max-w-xl flex-col justify-end gap-1 rounded-md border border-white/5 bg-black/55 px-3 py-2 font-code text-base text-snow shadow-[0_0_40px_-12px_var(--page-accent-glow)]"
>
<p aria-hidden="true" className="whitespace-pre-wrap break-all">
{COMMAND.slice(0, typed)}
{done ? null : <span className="animate-pulse text-frost">▌</span>}
</p>
{done ? (
<p aria-hidden="true" className="text-ice">
{t('cloud.chatConfirmation')}
</p>
) : null}
</div>
)
}

View file

@ -0,0 +1,56 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
export function HowItWorks() {
const { t } = useTranslation('landing')
const slots = [
{ name: t('howItWorks.slotNamePvp'), code: '7KQ3M9XA' },
{ name: t('howItWorks.slotNameStream'), code: 'R2WD8HNC' },
{ name: t('howItWorks.slotNameLazy'), code: 'L5TB4QZE' },
{ name: null, code: null },
]
const linkSteps = [t('howItWorks.linkStep1'), t('howItWorks.linkStep2'), t('howItWorks.linkStep3')]
return (
<div className="flex flex-col gap-16 pb-20">
<section className="grid items-center gap-8 md:grid-cols-2">
<div className="flex flex-col gap-3">
<h2 className="text-[1.5625rem] leading-tight">{t('howItWorks.slotsTitle')}</h2>
<p className="max-w-[60ch] text-frost">{t('howItWorks.slotsDesc')}</p>
</div>
<ul className="grid grid-cols-2 gap-3" aria-label={t('howItWorks.slotsAriaLabel')}>
{slots.map((slot, i) => (
<li key={i} className="flex min-w-0 flex-col gap-1 rounded-panel border border-shoal bg-slate px-4 py-3">
{slot.name ? (
<>
<span className="truncate text-snow">{slot.name}</span>
<span className="font-code text-sm tracking-wider text-ice">{slot.code}</span>
</>
) : (
<span className="italic text-frost">{t('howItWorks.emptySlot')}</span>
)}
</li>
))}
</ul>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-[1.5625rem] leading-tight">{t('howItWorks.linkTitle')}</h2>
<ol className="flex max-w-[60ch] list-decimal flex-col gap-2 pl-6 marker:text-frost">
{linkSteps.map((step) => (
<li key={step} className="pl-1">
{step}
</li>
))}
</ol>
</section>
<p className="max-w-[60ch] text-frost">
{t('howItWorks.showcaseText')}{' '}
<Link to="/showcase" className="text-ice underline-offset-4 hover:underline">
{t('howItWorks.showcaseLink')}
</Link>
</p>
</div>
)
}

View file

@ -0,0 +1,138 @@
import { useEffect, useRef } from 'react'
import { prefersReducedMotion } from '../../../shared/motion/reducedMotion'
const MAX_PARTICLES = 150
const MAX_DPR = 2
const SPRITE = 64
type Particle = { x: number; y: number; z: number; vx: number; vy: number; r: number; phase: number; speed: number }
/** A soft glowing dot tinted with `color`, drawn once and stamped for every particle (cheap "bloom"). */
function makeSprite(color: string): HTMLCanvasElement {
const c = document.createElement('canvas')
c.width = c.height = SPRITE
const g = c.getContext('2d')
if (!g) return c
const h = SPRITE / 2
const grad = g.createRadialGradient(h, h, 0, h, h, h)
grad.addColorStop(0, 'rgba(255,255,255,1)')
grad.addColorStop(0.12, color)
grad.addColorStop(0.4, color.replace(/rgb\((.+)\)/, 'rgba($1, 0.25)'))
grad.addColorStop(1, 'rgba(0,0,0,0)')
g.fillStyle = grad
g.fillRect(0, 0, SPRITE, SPRITE)
return c
}
function spawn(w: number, h: number, anywhere: boolean): Particle {
const z = 0.35 + Math.random() * 0.65
return {
x: Math.random() * w,
y: anywhere ? Math.random() * h : h + 20,
z,
vx: (Math.random() - 0.5) * 0.12,
vy: -(0.08 + Math.random() * 0.3) * z,
r: (2 + Math.random() * 7) * z,
phase: Math.random() * Math.PI * 2,
speed: 0.6 + Math.random() * 1.4,
}
}
/**
* Canvas of drifting glowing particles (the mod's AmbientParticles + bloom look).
* `color` is an opaque `rgb(r, g, b)`. Pauses off-screen; a single static frame under reduced motion.
*/
export function ParticleField({ color, className = '' }: { color: string; className?: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const spriteRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
spriteRef.current = makeSprite(color)
}, [color])
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) return
const reduced = prefersReducedMotion()
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR)
let w = 0
let h = 0
let particles: Particle[] = []
let frame = 0
let visible = true
let time = 0
const pointer = { x: 0, y: 0, tx: 0, ty: 0 }
const resize = () => {
w = canvas.clientWidth
h = canvas.clientHeight
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const count = Math.min(MAX_PARTICLES, Math.round((w * h) / 8000))
particles = Array.from({ length: count }, () => spawn(w, h, true))
}
const draw = () => {
ctx.clearRect(0, 0, w, h)
ctx.globalCompositeOperation = 'lighter'
const sprite = spriteRef.current
if (!sprite) return
for (const p of particles) {
const twinkle = 0.55 + 0.45 * Math.sin(time * 0.002 * p.speed + p.phase)
const size = p.r * 4
ctx.globalAlpha = twinkle * (0.35 + p.z * 0.65)
ctx.drawImage(sprite, p.x + pointer.x * p.z - size / 2, p.y + pointer.y * p.z - size / 2, size, size)
}
ctx.globalAlpha = 1
}
const step = (now: number) => {
const dt = Math.min(48, time ? now - time : 16) / 16
time = now
pointer.x += (pointer.tx - pointer.x) * 0.04
pointer.y += (pointer.ty - pointer.y) * 0.04
for (let i = 0; i < particles.length; i++) {
const p = particles[i]
p.x += (p.vx + Math.sin(now * 0.0004 + p.phase) * 0.08) * dt
p.y += p.vy * dt
if (p.y < -30 || p.x < -30 || p.x > w + 30) particles[i] = spawn(w, h, false)
}
draw()
frame = visible ? requestAnimationFrame(step) : 0
}
resize()
if (reduced) {
draw()
return
}
const onPointer = (e: PointerEvent) => {
pointer.tx = (e.clientX / window.innerWidth - 0.5) * -30
pointer.ty = (e.clientY / window.innerHeight - 0.5) * -20
}
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize)
ro?.observe(canvas)
const io =
typeof IntersectionObserver === 'undefined'
? null
: new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting && !document.hidden
if (visible && !frame) frame = requestAnimationFrame(step)
})
io?.observe(canvas)
window.addEventListener('pointermove', onPointer, { passive: true })
frame = requestAnimationFrame(step)
return () => {
cancelAnimationFrame(frame)
ro?.disconnect()
io?.disconnect()
window.removeEventListener('pointermove', onPointer)
}
}, [])
return <canvas ref={canvasRef} aria-hidden="true" data-testid="particle-field" className={`pointer-events-none absolute inset-0 h-full w-full ${className}`} />
}

View file

@ -0,0 +1,47 @@
import { motion, useMotionValue, useSpring } from 'motion/react'
import { type ReactNode, useEffect, useRef } from 'react'
import { prefersReducedMotion } from '../../../shared/motion/reducedMotion'
const MAX_DEG = 8
const REST = { x: 4, y: -6 }
const SPRING = { stiffness: 120, damping: 16, mass: 0.6 }
const clamp = (v: number) => Math.max(-MAX_DEG, Math.min(MAX_DEG, v))
/** Pointer-driven 3D tilt (max 8°) on a spring; rests at a slight angle for depth. */
export function Tilt({ children, className = '' }: { children: ReactNode; className?: string }) {
const ref = useRef<HTMLDivElement>(null)
const rx = useMotionValue(REST.x)
const ry = useMotionValue(REST.y)
const rotateX = useSpring(rx, SPRING)
const rotateY = useSpring(ry, SPRING)
useEffect(() => {
if (prefersReducedMotion()) return
const onMove = (e: PointerEvent) => {
const el = ref.current
if (!el || e.pointerType !== 'mouse') return
const r = el.getBoundingClientRect()
const nx = (e.clientX - (r.left + r.width / 2)) / (window.innerWidth / 2)
const ny = (e.clientY - (r.top + r.height / 2)) / (window.innerHeight / 2)
ry.set(clamp(nx * MAX_DEG))
rx.set(clamp(-ny * MAX_DEG))
}
const onLeave = () => {
rx.set(REST.x)
ry.set(REST.y)
}
window.addEventListener('pointermove', onMove, { passive: true })
document.documentElement.addEventListener('pointerleave', onLeave)
return () => {
window.removeEventListener('pointermove', onMove)
document.documentElement.removeEventListener('pointerleave', onLeave)
}
}, [rx, ry])
return (
<div ref={ref} className={className} style={{ perspective: 1400 }}>
<motion.div style={{ rotateX, rotateY, transformStyle: 'preserve-3d' }}>{children}</motion.div>
</div>
)
}

View file

@ -0,0 +1,196 @@
import { useEffect, useRef } from 'react'
import { prefersReducedMotion } from '../../../shared/motion/reducedMotion'
/** Deterministic PRNG so the landscape looks the same on every visit. */
function mulberry32(seed: number) {
let a = seed
return () => {
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
type Layer = {
seed: number
block: number
/** Surface baseline as a fraction of height, and hill amplitude in blocks. */
base: number
amp: number
top: string
side: string
deep: string
trees: number
torches: number
/** Scroll parallax factor (fraction of scroll the layer lags behind). */
lag: number
}
const LAYERS: Layer[] = [
{ seed: 7, block: 10, base: 0.5, amp: 9, top: '#4a3563', side: '#3a2a52', deep: '#302445', trees: 0, torches: 0, lag: 0.42 },
{ seed: 21, block: 16, base: 0.6, amp: 6, top: '#35304f', side: '#241f38', deep: '#1c1830', trees: 0.08, torches: 0, lag: 0.28 },
{ seed: 43, block: 26, base: 0.74, amp: 4, top: '#3d6b45', side: '#1c2a26', deep: '#141c1c', trees: 0.12, torches: 0.1, lag: 0.12 },
]
function shade(hex: string, k: number): string {
const n = Number.parseInt(hex.slice(1), 16)
const f = (v: number) => Math.max(0, Math.min(255, Math.round(v * k)))
return `rgb(${f((n >> 16) & 255)}, ${f((n >> 8) & 255)}, ${f(n & 255)})`
}
function drawBlock(ctx: CanvasRenderingContext2D, x: number, y: number, s: number, color: string, k: number) {
ctx.fillStyle = shade(color, k)
ctx.fillRect(x, y, s, s)
ctx.fillStyle = 'rgba(0,0,0,0.18)'
ctx.fillRect(x, y + s - 2, s, 2)
ctx.fillRect(x + s - 1, y, 1, s)
}
function drawLayer(ctx: CanvasRenderingContext2D, w: number, h: number, layer: Layer, accent: string) {
const rnd = mulberry32(layer.seed)
const s = layer.block
const cols = Math.ceil(w / s) + 1
const p1 = rnd() * 10
const p2 = rnd() * 10
const heights = Array.from({ length: cols }, (_, i) => {
const n = Math.sin(i * 0.11 + p1) * 0.6 + Math.sin(i * 0.037 + p2) * 0.9 + Math.sin(i * 0.29 + p1 * 2) * 0.25
return Math.round(h * layer.base / s - n * layer.amp)
})
const glow: [number, number][] = []
heights.forEach((row, i) => {
const x = i * s
for (let r = row; r * s < h; r++) {
const depth = r - row
const color = depth === 0 ? layer.top : depth < 3 ? layer.side : layer.deep
drawBlock(ctx, x, r * s, s, color, 0.9 + rnd() * 0.2)
}
// A thin accent rim on the sunward edge of the surface, like the mod's bloom catching the light.
ctx.fillStyle = accent
ctx.globalAlpha = 0.28
ctx.fillRect(x, row * s, s, Math.max(1, s * 0.12))
ctx.globalAlpha = 1
const flat = heights[i + 1] === row && heights[i - 1] === row
if (flat && rnd() < layer.trees) drawTree(ctx, x, row * s, s, rnd)
else if (rnd() < layer.torches) {
ctx.fillStyle = '#6b4a2b'
ctx.fillRect(x + s * 0.42, row * s - s * 0.7, s * 0.16, s * 0.7)
ctx.fillStyle = '#ffd27a'
ctx.fillRect(x + s * 0.38, row * s - s * 0.9, s * 0.24, s * 0.24)
glow.push([x + s / 2, row * s - s * 0.8])
}
})
for (const [gx, gy] of glow) {
const g = ctx.createRadialGradient(gx, gy, 0, gx, gy, s * 3)
g.addColorStop(0, 'rgba(255, 190, 110, 0.55)')
g.addColorStop(1, 'rgba(255, 190, 110, 0)')
ctx.fillStyle = g
ctx.fillRect(gx - s * 3, gy - s * 3, s * 6, s * 6)
}
}
function drawTree(ctx: CanvasRenderingContext2D, x: number, groundY: number, s: number, rnd: () => number) {
const trunk = 2 + Math.floor(rnd() * 2)
for (let i = 1; i <= trunk; i++) drawBlock(ctx, x, groundY - i * s, s, '#3b2a22', 0.9)
const top = groundY - (trunk + 1) * s
for (let dy = 0; dy < 2; dy++) {
for (let dx = -1; dx <= 1; dx++) drawBlock(ctx, x + dx * s, top - dy * s, s, '#1f3b2e', 0.85 + rnd() * 0.3)
}
drawBlock(ctx, x, top - 2 * s, s, '#244536', 1)
}
function drawSky(ctx: CanvasRenderingContext2D, w: number, h: number) {
const rnd = mulberry32(99)
for (let i = 0; i < 90; i++) {
const size = rnd() < 0.85 ? 2 : 3
ctx.fillStyle = `rgba(255, 245, 230, ${0.25 + rnd() * 0.6})`
ctx.fillRect(Math.floor(rnd() * w), Math.floor(rnd() * h * 0.45), size, size)
}
// Square setting sun with a warm halo.
const sx = w * 0.72
const sy = h * 0.46
const halo = ctx.createRadialGradient(sx, sy, 0, sx, sy, h * 0.4)
halo.addColorStop(0, 'rgba(255, 170, 110, 0.55)')
halo.addColorStop(1, 'rgba(255, 120, 90, 0)')
ctx.fillStyle = halo
ctx.fillRect(0, 0, w, h)
const sun = Math.max(48, h * 0.1)
ctx.fillStyle = '#ffcf8a'
ctx.fillRect(sx - sun / 2, sy - sun / 2, sun, sun)
ctx.fillStyle = '#ffe7b8'
ctx.fillRect(sx - sun / 4, sy - sun / 4, sun / 2, sun / 2)
// Blocky clouds.
for (let c = 0; c < 7; c++) {
const cx = rnd() * w
const cy = h * (0.12 + rnd() * 0.3)
const u = 14
ctx.fillStyle = `rgba(${200 + rnd() * 40}, ${120 + rnd() * 40}, ${170 + rnd() * 40}, 0.2)`
const len = 4 + Math.floor(rnd() * 7)
for (let i = 0; i < len; i++) ctx.fillRect(cx + i * u, cy + (i % 3 === 0 ? -u : 0), u, u * (i % 3 === 0 ? 2 : 1))
}
}
/** Procedural blocky dusk landscape with layered scroll parallax. Purely decorative. */
export function VoxelScene({ accent, className = '' }: { accent: string; className?: string }) {
const rootRef = useRef<HTMLDivElement>(null)
const canvases = useRef<(HTMLCanvasElement | null)[]>([])
useEffect(() => {
const root = rootRef.current
if (!root) return
const paint = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const w = root.clientWidth
const h = root.clientHeight * 1.25
canvases.current.forEach((canvas, i) => {
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) return
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
if (i === 0) drawSky(ctx, w, h)
else drawLayer(ctx, w, h, LAYERS[i - 1], accent)
})
}
paint()
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(paint)
ro?.observe(root)
return () => ro?.disconnect()
}, [accent])
useEffect(() => {
if (prefersReducedMotion()) return
let frame = 0
const lags = [0.5, ...LAYERS.map((l) => l.lag)]
const onScroll = () => {
if (frame) return
frame = requestAnimationFrame(() => {
frame = 0
const y = Math.min(window.scrollY, 1200)
canvases.current.forEach((c, i) => {
if (c) c.style.transform = `translate3d(0, ${(y * lags[i]).toFixed(1)}px, 0)`
})
})
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => {
window.removeEventListener('scroll', onScroll)
cancelAnimationFrame(frame)
}
}, [])
return (
<div ref={rootRef} aria-hidden="true" className={`lv-voxel pointer-events-none absolute inset-0 overflow-hidden ${className}`}>
{[0, 1, 2, 3].map((i) => (
<canvas
key={i}
ref={(el) => {
canvases.current[i] = el
}}
className="absolute inset-x-0 top-0 h-[125%] w-full will-change-transform"
/>
))}
</div>
)
}

View file

@ -0,0 +1,26 @@
import { type RefObject, useEffect, useState } from 'react'
/**
* Tracks whether `ref` intersects the viewport. Without IntersectionObserver (old browsers,
* jsdom) the element counts as visible so content never stays hidden.
*/
export function useInView<T extends Element>(ref: RefObject<T | null>, options: { once?: boolean; rootMargin?: string } = {}): boolean {
const [inView, setInView] = useState(() => typeof IntersectionObserver === 'undefined')
const { once = false, rootMargin = '0px' } = options
useEffect(() => {
const el = ref.current
if (!el || typeof IntersectionObserver === 'undefined') return
const observer = new IntersectionObserver(
([entry]) => {
setInView(entry.isIntersecting)
if (entry.isIntersecting && once) observer.disconnect()
},
{ rootMargin },
)
observer.observe(el)
return () => observer.disconnect()
}, [ref, once, rootMargin])
return inView
}

View file

@ -0,0 +1,103 @@
import type { Translation } from '../../../shared/i18n/common.ru'
import type { landingRu } from './ru'
export const landingEn = {
hero: {
badge: 'Minecraft 26.2 · Fabric',
headlineLead: 'Visuals',
headlineAccent: 'you can see',
lead: 'A free legit mod for Minecraft 26.2: 52 visual modules, cloud configs and themes you build yourself.',
download: 'Download for free',
downloadHint: 'One .jar straight from GitHub',
register: 'Create account',
facts: {
modules: 'modules',
visuals: 'for visuals',
themes: 'built-in themes',
},
tryIt: 'This is the real interface — hover and click around',
},
themeStrip: {
eyebrow: 'Themes',
title: 'Hundreds of shades. Yours is one.',
lead: 'Every built-in theme, straight from the mod. Click one — the ClickGui and the whole site recolor on the fly.',
cta: 'Build your own theme',
current: 'Now',
swatches: 'Built-in themes',
palette: 'Theme palette',
},
cloud: {
headlinePart1: 'Your LoVisual settings',
headlinePart2: 'on any computer',
subtitle: 'Four cloud slots, a code you can share with friends, and a showcase of ready-made configs.',
ctaAuthenticated: 'My configs',
ctaAnonymous: 'Create account',
ctaShowcase: 'View showcase',
chatConfirmation: '[LoVisual] Loaded config "pvp"',
chatAriaLabel: 'Example: loading a config by code in the game chat',
},
howItWorks: {
slotsTitle: 'Four slots in the cloud',
slotsDesc:
'Save up to four sets of settings and load any of them on another computer — every slot has a code a friend can load too.',
slotsAriaLabel: 'Example slots',
emptySlot: 'empty',
slotNamePvp: 'pvp',
slotNameStream: 'stream',
slotNameLazy: 'lazy',
linkTitle: 'Linking the game',
linkStep1: 'Open the account tab in the mod — a code will appear there.',
linkStep2: 'Enter the code on the "Link game" page.',
linkStep3: "Done: the mod signs in to your account by itself, no password needed.",
showcaseText: 'Any config from the showcase can be copied into a free slot with one button.',
showcaseLink: 'Open showcase',
},
hud: {
eyebrow: 'HUD',
title: 'Arrange the widgets like in game',
lead: 'The same widgets the mod draws: FPS, coordinates, module list, keys and ping. Drag them with the mouse or arrow keys.',
caption: 'Drag it — just like in game.',
reset: 'Reset',
widgetFps: 'FPS',
widgetCoords: 'Coordinates',
widgetModules: 'Module list',
widgetKeys: 'Key presses',
widgetPing: 'Ping',
unitMs: 'ms',
dragAria: 'Draggable HUD widget "{{name}}". Arrow keys move it.',
},
wall: {
eyebrow: 'Modules',
title: '88 modules. 52 are about looks.',
lead: 'The whole category wall. Hover a visuals module — I\'ll tell you what it does.',
columnsAria: 'Module categories',
},
showcase: {
eyebrow: 'Showcase',
title: 'Ready-made configs from the community',
lead: 'The most popular showcase configs. Copy any of them into your slot with one button.',
cta: 'Open showcase',
example: 'Example',
by: 'by {{author}}',
downloads: '{{count}} downloads',
},
faq: {
eyebrow: 'FAQ',
title: 'Frequently asked',
q1: 'Is it really free?',
a1: 'Yes. The mod is free and open source: no subscriptions, no pay-to-unlock features.',
q2: 'Which Minecraft versions are supported?',
a2: 'Minecraft 26.2 on the Fabric loader.',
q3: 'Do I need an account?',
a3: 'Not for the mod itself. An account is only needed for cloud slots and the config showcase.',
q4: 'Is it legit? Will I get banned?',
a4: 'LoVisual is a visuals mod: it reads nothing from memory and sends nothing to the server. Your admin decides.',
q5: 'How do I move settings to another computer?',
a5: 'Save a config to a cloud slot and load it by code on any other PC.',
downloadTitle: 'Ready to try it?',
downloadLead: 'One .jar straight from GitHub — just drop it into your mods folder.',
downloadCta: 'Download for free',
downloadNote: 'Minecraft 26.2 · Fabric · the latest release',
releaseLink: 'Releases and versions',
},
} satisfies Translation<typeof landingRu>

View file

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

View file

@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next'
import { DOWNLOAD_URL, GITHUB_REPO } from '../../download/api'
import '../styles/landing.css'
const KEYS = ['1', '2', '3', '4', '5'] as const
export function FaqDownload() {
const { t } = useTranslation('landing')
const items = KEYS.map((k) => ({ q: t(`faq.q${k}`), a: t(`faq.a${k}`) }))
return (
<section aria-labelledby="faq-title" className="grid gap-12 py-20 sm:py-28 lg:grid-cols-2 lg:gap-16">
<div className="flex min-w-0 flex-col gap-6">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('faq.eyebrow')}</span>
<h2 id="faq-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('faq.title')}</h2>
<div className="flex flex-col divide-y divide-white/10 border-y border-white/10">
{items.map((it) => (
<details key={it.q} className="lv-faq-item group py-3">
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 text-base font-semibold text-snow">
{it.q}
<span aria-hidden="true" className="lv-faq-marker" />
</summary>
<p className="mt-2 max-w-[52ch] text-sm leading-relaxed text-frost">{it.a}</p>
</details>
))}
</div>
</div>
<div className="lv-download-band flex min-w-0 flex-col items-start justify-center gap-5 rounded-panel p-8 sm:p-10">
<h3 className="text-[1.75rem] leading-tight sm:text-[2.1rem]">{t('faq.downloadTitle')}</h3>
<p className="max-w-[42ch] text-lg text-frost">{t('faq.downloadLead')}</p>
<a href={DOWNLOAD_URL} download className="lv-cta-primary">
{t('faq.downloadCta')}
</a>
<p className="font-code text-xs text-frost">{t('faq.downloadNote')}</p>
<a
href={`https://github.com/${GITHUB_REPO}/releases`}
target="_blank"
rel="noreferrer"
className="text-sm text-ice underline-offset-4 hover:underline"
>
{t('faq.releaseLink')}
</a>
</div>
</section>
)
}

View file

@ -0,0 +1,109 @@
import { MotionConfig, motion } from 'motion/react'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { ButtonLink } from '../../../shared/ui/Button'
import { ClickGuiWindow } from '../../clickgui/ClickGuiWindow'
import { DOWNLOAD_URL } from '../../download/api'
import { PRESETS } from '../../themes/presets.generated'
import { parseArgb } from '../../themes/types'
import { useActiveTheme } from '../../themes/useActiveTheme'
import { ParticleField } from '../effects/ParticleField'
import { Tilt } from '../effects/Tilt'
import { useInView } from '../effects/useInView'
import { VoxelScene } from '../effects/VoxelScene'
import '../styles/landing.css'
const rise = (delay: number) => ({
initial: { opacity: 0, y: 24 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.7, delay, ease: [0.22, 1, 0.36, 1] as const },
})
function DownloadIcon() {
return (
<svg viewBox="0 0 20 20" className="size-5" 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>
)
}
export function Hero() {
const { t } = useTranslation('landing')
const { theme } = useActiveTheme()
const sectionRef = useRef<HTMLElement>(null)
const inView = useInView(sectionRef)
const { r, g, b } = parseArgb(theme.theme.accent)
const accent = `rgb(${r}, ${g}, ${b})`
const facts = [
{ n: '88', label: t('hero.facts.modules') },
{ n: '52', label: t('hero.facts.visuals') },
{ n: String(PRESETS.length), label: t('hero.facts.themes') },
]
return (
<MotionConfig reducedMotion="user">
<section
ref={sectionRef}
aria-labelledby="hero-title"
className="lv-bleed relative isolate flex min-h-[calc(100svh-3.75rem)] items-center overflow-hidden"
>
<VoxelScene accent={accent} />
<div aria-hidden="true" className="lv-hero-shade absolute inset-0" />
<ParticleField color={accent} />
<div className="relative mx-auto grid w-full max-w-[1200px] items-center gap-12 px-4 pb-20 pt-12 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.12fr)] lg:gap-8 lg:py-16">
<div className="flex min-w-0 flex-col items-start gap-6">
<motion.span
{...rise(0)}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1 font-code text-xs text-frost backdrop-blur"
>
<span className="lv-live-dot" aria-hidden="true" />
{t('hero.badge')}
</motion.span>
<motion.h1 {...rise(0.08)} id="hero-title" className="lv-hero-title font-display font-bold">
{t('hero.headlineLead')} <span className="lv-hero-accent">{t('hero.headlineAccent')}</span>
</motion.h1>
<motion.p {...rise(0.16)} className="max-w-[34rem] text-lg leading-relaxed text-snow/80">
{t('hero.lead')}
</motion.p>
<motion.div {...rise(0.24)} className="flex flex-wrap items-center gap-3">
<a href={DOWNLOAD_URL} download className="lv-cta-primary">
<DownloadIcon />
{t('hero.download')}
</a>
<ButtonLink to="/register" variant="quiet" className="bg-black/25 px-5 py-3 text-base backdrop-blur">
{t('hero.register')}
</ButtonLink>
</motion.div>
<motion.p {...rise(0.3)} className="-mt-2 font-code text-xs text-frost">
{t('hero.downloadHint')}
</motion.p>
<motion.dl {...rise(0.36)} className="mt-2 flex flex-wrap gap-x-8 gap-y-3">
{facts.map((f) => (
<div key={f.label} className="flex flex-col">
<dt className="order-2 text-sm text-frost">{f.label}</dt>
<dd className="lv-fact font-display text-3xl font-bold text-snow">{f.n}</dd>
</div>
))}
</motion.dl>
</div>
<motion.div
initial={{ opacity: 0, scale: 0.94, y: 30 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="flex min-w-0 flex-col items-center gap-4"
>
<Tilt className="w-full max-w-[660px]">
<ClickGuiWindow paused={!inView} />
</Tilt>
<p className="flex items-center gap-2 text-sm text-frost">
<span className="lv-live-dot" aria-hidden="true" />
{t('hero.tryIt')}
</p>
</motion.div>
</div>
</section>
</MotionConfig>
)
}

View file

@ -0,0 +1,210 @@
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../../../shared/ui/Button'
import { themeToCssVars } from '../../clickgui/themeVars'
import { parseArgb } from '../../themes/types'
import { useActiveTheme } from '../../themes/useActiveTheme'
import { VoxelScene } from '../effects/VoxelScene'
import '../styles/landing.css'
const STEP = 8
/** Fallback playfield size, used before the box is measured (and under jsdom). */
const FIELD = { w: 680, h: 380 }
const WIDGET = { w: 150, h: 56 }
type Pos = { x: number; y: number }
/** Real draggable HUD elements from the mod: features/gui/hud/draggable/impl. */
type WidgetId = 'fps' | 'coords' | 'modules' | 'keys' | 'ping'
const START: Record<WidgetId, Pos> = {
fps: { x: 20, y: 20 },
coords: { x: 20, y: 310 },
modules: { x: FIELD.w - 190, y: 20 },
ping: { x: FIELD.w - 170, y: 150 },
keys: { x: FIELD.w - 140, y: FIELD.h - 120 },
}
const clamp = (v: number, max: number) => Math.max(0, Math.min(v, max))
/** Enabled modules as they would appear in the mod's ModuleList panel. */
const MODULES_ON = ['TargetESP', 'PvpCooldowns', 'HitBubbles', 'ShaderSky']
/** Widgets read the accent through --gui-accent so it cross-fades with the active theme. */
const ACCENT = 'var(--gui-accent)'
function WidgetContent({ id, accent, unitMs }: { id: WidgetId; accent: string; unitMs: string }) {
switch (id) {
case 'fps':
return (
<div className="flex items-baseline gap-1.5">
<span className="font-code text-2xl leading-none font-bold" style={{ color: accent }}>
240
</span>
<span className="text-[0.6rem] tracking-[0.15em] text-frost uppercase">FPS</span>
</div>
)
case 'coords':
return (
<div className="font-code text-sm leading-none text-snow">
<span style={{ color: accent }}>X</span> 128&nbsp; <span style={{ color: accent }}>Y</span> 74&nbsp;{' '}
<span style={{ color: accent }}>Z</span> -204
</div>
)
case 'modules':
return (
<div className="flex flex-col items-end gap-1">
{MODULES_ON.map((m) => (
<span
key={m}
className="rounded-[5px] border-r-2 bg-black/25 px-2 py-[3px] font-code text-[0.7rem] leading-none text-snow"
style={{ borderRightColor: accent }}
>
{m}
</span>
))}
</div>
)
case 'keys': {
const cap = (k: string, pressed = false) => (
<span
key={k}
className="grid size-7 place-items-center rounded-[6px] border bg-white/5 font-code text-xs leading-none"
style={{
borderColor: pressed ? accent : 'rgb(255 255 255 / 0.12)',
color: pressed ? accent : undefined,
background: pressed ? `color-mix(in srgb, ${accent} 18%, transparent)` : undefined,
}}
>
{k}
</span>
)
return (
<div className="flex flex-col items-center gap-1">
{cap('W', true)}
<div className="flex gap-1">
{cap('A')}
{cap('S', true)}
{cap('D')}
</div>
</div>
)
}
case 'ping':
return (
<div className="flex items-center gap-2">
<span className="font-code text-sm leading-none text-snow">
32&nbsp;<span className="text-frost">{unitMs}</span>
</span>
<span aria-hidden="true" className="flex items-end gap-[3px]">
{[6, 10, 14, 18].map((h, i) => (
<span
key={h}
className="w-[4px] rounded-sm"
style={{ height: h, background: i < 2 ? accent : 'rgb(255 255 255 / 0.18)' }}
/>
))}
</span>
</div>
)
}
}
export function HudPlayground() {
const { t } = useTranslation('landing')
const { theme } = useActiveTheme()
const { r, g, b } = parseArgb(theme.theme.accent)
const fieldRef = useRef<HTMLDivElement>(null)
const drag = useRef<{ id: WidgetId; px: number; py: number; ox: number; oy: number } | null>(null)
const [pos, setPos] = useState<Record<WidgetId, Pos>>(START)
const bounds = (el: HTMLElement) => {
const field = fieldRef.current
const w = field?.clientWidth || FIELD.w
const h = field?.clientHeight || FIELD.h
return { maxX: Math.max(0, w - (el.offsetWidth || WIDGET.w)), maxY: Math.max(0, h - (el.offsetHeight || WIDGET.h)) }
}
const nudge = (id: WidgetId, dx: number, dy: number, el: HTMLElement) => {
const { maxX, maxY } = bounds(el)
setPos((prev) => ({ ...prev, [id]: { x: clamp(prev[id].x + dx, maxX), y: clamp(prev[id].y + dy, maxY) } }))
}
const onKeyDown = (id: WidgetId) => (e: React.KeyboardEvent<HTMLElement>) => {
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-STEP, 0], ArrowRight: [STEP, 0], ArrowUp: [0, -STEP], ArrowDown: [0, STEP],
}
const d = deltas[e.key]
if (!d) return
e.preventDefault()
nudge(id, d[0], d[1], e.currentTarget)
}
const onPointerDown = (id: WidgetId) => (e: React.PointerEvent<HTMLElement>) => {
e.currentTarget.setPointerCapture(e.pointerId)
drag.current = { id, px: e.clientX, py: e.clientY, ox: pos[id].x, oy: pos[id].y }
}
const onPointerMove = (e: React.PointerEvent<HTMLElement>) => {
const d = drag.current
if (!d) return
const { maxX, maxY } = bounds(e.currentTarget)
setPos((prev) => ({ ...prev, [d.id]: { x: clamp(d.ox + e.clientX - d.px, maxX), y: clamp(d.oy + e.clientY - d.py, maxY) } }))
}
const onPointerUp = () => {
drag.current = null
}
const widgets: { id: WidgetId; label: string }[] = [
{ id: 'fps', label: t('hud.widgetFps') },
{ id: 'coords', label: t('hud.widgetCoords') },
{ id: 'modules', label: t('hud.widgetModules') },
{ id: 'keys', label: t('hud.widgetKeys') },
{ id: 'ping', label: t('hud.widgetPing') },
]
return (
<section aria-labelledby="hud-title" className="grid gap-10 py-20 sm:py-28 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:gap-14">
<div className="flex min-w-0 flex-col items-start gap-4">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('hud.eyebrow')}</span>
<h2 id="hud-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('hud.title')}</h2>
<p className="max-w-[42ch] text-lg text-frost">{t('hud.lead')}</p>
<Button variant="quiet" className="mt-2" onClick={() => setPos(START)}>
{t('hud.reset')}
</Button>
</div>
<div className="min-w-0">
<div
ref={fieldRef}
style={themeToCssVars(theme)}
className="lv-hud-field relative isolate overflow-hidden rounded-panel border border-white/10"
>
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} />
<div aria-hidden="true" className="absolute inset-0 bg-black/25" />
{widgets.map((w) => (
<div
key={w.id}
role="button"
tabIndex={0}
title={w.label}
aria-label={t('hud.dragAria', { name: w.label })}
onKeyDown={onKeyDown(w.id)}
onPointerDown={onPointerDown(w.id)}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={{ left: pos[w.id].x, top: pos[w.id].y }}
className="lv-hud-widget absolute touch-none select-none"
>
<WidgetContent id={w.id} accent={ACCENT} unitMs={t('hud.unitMs')} />
</div>
))}
</div>
<p className="mt-3 flex items-center gap-2 text-sm text-frost">
<span className="lv-live-dot" aria-hidden="true" />
{t('hud.caption')}
</p>
</div>
</section>
)
}

View file

@ -0,0 +1,45 @@
import { useTranslation } from 'react-i18next'
import { CATEGORIES } from '../../clickgui/parts/modules'
import '../styles/landing.css'
/** Each column scrolls at its own pace; the list is duplicated so the loop is seamless. */
const SPEED_MS = [42, 30, 50, 36]
export function ModuleWall() {
const { t } = useTranslation('landing')
const { t: tc } = useTranslation('clickgui')
return (
<section aria-labelledby="wall-title" className="grid gap-10 py-20 sm:py-28 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] lg:gap-14">
<div className="flex min-w-0 flex-col items-start gap-4">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('wall.eyebrow')}</span>
<h2 id="wall-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('wall.title')}</h2>
<p className="max-w-[42ch] text-lg text-frost">{t('wall.lead')}</p>
</div>
<div role="list" aria-label={t('wall.columnsAria')} className="lv-wall grid min-w-0 grid-cols-2 gap-4 sm:grid-cols-4">
{CATEGORIES.map((cat, i) => (
<div key={cat.id} role="listitem" className="lv-wall-col flex min-w-0 flex-col gap-2">
<h3 className="font-code text-xs uppercase tracking-[0.18em] text-ice">{cat.name}</h3>
<div className="lv-wall-viewport relative h-[300px] overflow-hidden">
<ul className="lv-wall-track flex flex-col gap-1.5" style={{ ['--dur' as string]: `${SPEED_MS[i % SPEED_MS.length]}s` }}>
{[...cat.modules, ...cat.modules].map((m, idx) => {
const desc = cat.id === 'visuals' ? tc(`desc.${m.name}`) : undefined
return (
<li
key={`${m.name}-${idx}`}
title={desc}
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"
>
{m.name}
</li>
)
})}
</ul>
</div>
</div>
))}
</div>
</section>
)
}

View file

@ -0,0 +1,52 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { api } from '../../../shared/api/client'
import '../styles/landing.css'
type ShowcaseItem = { id: string; name: string; author: string; downloads: number }
/** Placeholder cards so the section never collapses while loading or when the API is down. */
const EXAMPLES: ShowcaseItem[] = [
{ id: 'e1', name: 'Aura / Trails', author: 'example', downloads: 128 },
{ id: 'e2', name: 'Clean PvP', author: 'example', downloads: 96 },
{ id: 'e3', name: 'Stream Safe', author: 'example', downloads: 74 },
]
export function ShowcaseTeaser() {
const { t } = useTranslation('landing')
const { data, isError } = useQuery({
queryKey: ['showcase', 'popular'],
queryFn: () => api.request<ShowcaseItem[]>('/showcase?sort=popular'),
})
const real = !isError && Array.isArray(data) ? data.slice(0, 3) : []
const cards = real.length >= 3 ? real : EXAMPLES
const isExample = real.length < 3
return (
<section aria-labelledby="showcase-title" className="grid gap-10 py-20 sm:py-28">
<div className="flex min-w-0 flex-col items-start gap-4">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('showcase.eyebrow')}</span>
<h2 id="showcase-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('showcase.title')}</h2>
<p className="max-w-[52ch] text-lg text-frost">{t('showcase.lead')}</p>
</div>
<ul className="grid min-w-0 gap-4 sm:grid-cols-3">
{cards.map((c) => (
<li key={c.id} className="lv-config-card flex flex-col gap-3 rounded-panel border border-white/10 bg-black/30 p-5">
<div className="flex items-center justify-between gap-2">
<span className="font-display text-lg font-bold text-snow">{c.name}</span>
{isExample ? <span className="lv-example-tag">{t('showcase.example')}</span> : null}
</div>
<span className="text-sm text-frost">{t('showcase.by', { author: c.author })}</span>
<span className="font-code text-xs text-ice">{t('showcase.downloads', { count: c.downloads })}</span>
</li>
))}
</ul>
<Link to="/showcase" className="text-ice underline-offset-4 hover:underline">
{t('showcase.cta')}
</Link>
</section>
)
}

View file

@ -0,0 +1,95 @@
import { motion } from 'motion/react'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { ButtonLink } from '../../../shared/ui/Button'
import { themeToCssVars } from '../../clickgui/themeVars'
import { PRESETS } from '../../themes/presets.generated'
import { argbToCss, type ThemeColors } from '../../themes/types'
import { useActiveTheme } from '../../themes/useActiveTheme'
import { useInView } from '../effects/useInView'
import '../styles/landing.css'
const PALETTE_KEYS: (keyof ThemeColors)[] = [
'windowBg', 'windowHeader', 'surface', 'cardEnabled', 'accent', 'accentSoft', 'textPrimary', 'textMuted',
]
/** Every built-in preset as a swatch; picking one re-themes the hero ClickGui and the page accent. */
export function ThemeStrip() {
const { t } = useTranslation('landing')
const { t: tt } = useTranslation('themes')
const { theme, setTheme } = useActiveTheme()
const listRef = useRef<HTMLUListElement>(null)
const inView = useInView(listRef, { once: true, rootMargin: '-40px' })
return (
<section aria-labelledby="themes-title" className="relative grid gap-10 py-20 sm:py-28 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.3fr)] lg:gap-14">
<div className="flex min-w-0 flex-col items-start gap-5">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('themeStrip.eyebrow')}</span>
<h2 id="themes-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">
{t('themeStrip.title')}
</h2>
<p className="max-w-[40ch] text-lg text-frost">{t('themeStrip.lead')}</p>
<div className="lv-theme-now mt-2 w-full max-w-sm rounded-panel p-4" style={themeToCssVars(theme)}>
<div className="flex items-baseline justify-between gap-3">
<span className="text-sm text-frost">{t('themeStrip.current')}</span>
<span className="font-display text-lg font-bold" style={{ color: 'var(--gui-accent)' }}>
{theme.name}
</span>
</div>
<ul aria-label={t('themeStrip.palette')} className="mt-3 grid grid-cols-8 gap-1.5">
{PALETTE_KEYS.map((k) => (
<li
key={k}
title={tt(`colors.${k}`)}
className="lv-chip aspect-square rounded-md"
style={{ background: argbToCss(theme.theme[k]) }}
>
<span className="sr-only">{`${tt(`colors.${k}`)}: ${theme.theme[k]}`}</span>
</li>
))}
</ul>
</div>
<ButtonLink to="/themes" className="mt-2 px-5 py-3 text-base">
{t('themeStrip.cta')}
</ButtonLink>
</div>
<ul ref={listRef} aria-label={t('themeStrip.swatches')} className="grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3">
{PRESETS.map((preset, i) => {
const vars = themeToCssVars(preset)
const active = preset.id === theme.id
return (
<motion.li
key={preset.id}
initial={{ opacity: 0, y: 16 }}
animate={inView ? { opacity: 1, y: 0 } : undefined}
transition={{ duration: 0.45, delay: (i % 6) * 0.05 }}
>
<button
type="button"
aria-pressed={active}
aria-label={tt('apply', { name: preset.name })}
onClick={() => setTheme(preset.id, 'user')}
className="lv-swatch group"
data-active={active}
style={vars}
>
<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>
</motion.li>
)
})}
</ul>
</section>
)
}

View file

@ -0,0 +1,211 @@
/* Landing-only styling that is awkward as utility classes (layered gradients, glows, keyframes). */
.lv-bleed {
width: 100vw;
margin-left: calc(50% - 50vw);
}
.lv-voxel {
background: linear-gradient(180deg, #060818 0%, #120f2e 26%, #2b1a4a 46%, #6a2d5c 62%, #c25a55 76%, #f09a5c 88%, #f7b36a 100%);
}
.lv-hero-shade {
background:
radial-gradient(80% 60% at 20% 45%, rgb(6 9 20 / 0.72), transparent 70%),
linear-gradient(180deg, rgb(13 20 22 / 0.35) 0%, transparent 25%, transparent 70%, var(--color-abyss) 100%);
}
.lv-hero-title {
font-size: clamp(2.5rem, 5.4vw, 4.6rem);
line-height: 1.02;
letter-spacing: -0.02em;
text-wrap: balance;
text-shadow: 0 4px 30px rgb(0 0 0 / 0.5);
}
.lv-hero-accent {
display: inline-block;
color: var(--color-ice);
background: linear-gradient(100deg, #fff 0%, var(--color-ice) 45%, var(--color-ice) 70%, #fff 100%);
background-size: 220% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 22px var(--page-accent-glow));
animation: lv-sheen 6s ease-in-out infinite;
}
@keyframes lv-sheen {
0%, 100% { background-position: 100% 0; }
50% { background-position: 0 0; }
}
.lv-cta-primary {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.6rem;
padding: 0.85rem 1.4rem;
border-radius: 0.6rem;
font-weight: 700;
font-size: 1rem;
color: var(--color-abyss);
background: linear-gradient(180deg, color-mix(in srgb, var(--color-ice) 75%, #fff), var(--color-ice));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-ice) 60%, #fff) inset, 0 10px 40px -8px var(--page-accent-glow),
0 0 60px -10px var(--page-accent-glow);
transition: transform 200ms ease, box-shadow 200ms ease;
}
.lv-cta-primary:hover {
transform: translateY(-2px);
box-shadow: 0 0 0 1px #fff inset, 0 16px 50px -8px var(--page-accent-glow), 0 0 80px -6px var(--page-accent-glow);
}
.lv-live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-ice);
box-shadow: 0 0 0 0 var(--color-ice);
animation: lv-pulse 2.2s ease-out infinite;
}
@keyframes lv-pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-ice) 70%, transparent); }
100% { box-shadow: 0 0 0 9px transparent; }
}
.lv-fact {
background: linear-gradient(180deg, #fff, color-mix(in srgb, var(--color-ice) 70%, #fff));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.lv-theme-now {
background: linear-gradient(var(--gui-header-angle), var(--gui-header-from), var(--gui-header-to));
border: 1px solid color-mix(in srgb, var(--gui-accent) 35%, transparent);
box-shadow: 0 0 40px -12px var(--gui-accent-glow);
transition: box-shadow 400ms, border-color 400ms;
}
.lv-chip {
border: 1px solid rgb(255 255 255 / 0.12);
background-image: none;
transition: background-color 400ms;
}
.lv-swatch {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
border-radius: 10px;
text-align: left;
cursor: pointer;
border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 18%, transparent);
background: var(--gui-window-gradient);
transition: transform 220ms cubic-bezier(0.2, 0.9, 0.3, 1.2), box-shadow 300ms, border-color 300ms;
}
.lv-swatch:hover { transform: translateY(-3px); box-shadow: 0 12px 30px -10px var(--gui-accent-glow); }
.lv-swatch[data-active="true"] {
border-color: var(--gui-accent);
box-shadow: 0 0 0 1px var(--gui-accent), 0 0 30px -4px var(--gui-accent-glow);
}
.lv-swatch-header {
display: flex;
align-items: center;
gap: 6px;
height: 22px;
padding: 0 8px;
background: var(--gui-header-gradient);
}
.lv-swatch-dot { width: 8px; height: 8px; border-radius: 2px; transform: rotate(45deg); background: var(--gui-accent); box-shadow: 0 0 8px var(--gui-accent); }
.lv-swatch-bar { height: 4px; width: 38%; border-radius: 4px; background: color-mix(in srgb, var(--gui-text) 35%, transparent); }
.lv-swatch-body { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; padding: 7px 8px 0; }
.lv-swatch-card { height: 18px; border-radius: 4px; background: var(--gui-surface-hover); border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 12%, transparent); }
.lv-swatch-card-on { background: var(--gui-card-gradient); border-color: color-mix(in srgb, var(--gui-accent) 50%, transparent); box-shadow: 0 0 10px -2px var(--gui-accent-glow); }
.lv-swatch-name { padding: 7px 9px 8px; font-weight: 600; font-size: 0.9rem; color: var(--gui-text); }
/* HUD playground: a cropped voxel field with draggable, keyboard-movable widgets. */
.lv-hud-field {
height: clamp(300px, 46vw, 380px);
background: linear-gradient(180deg, #0b0f22, #241a3d 55%, #3a2350);
box-shadow: inset 0 0 60px -20px #000;
}
/* Same glass recipe as the ClickGui window (.lv-gui-window): themed gradient fill,
accent border via the border-box trick, backdrop blur and an accent glow. */
.lv-hud-widget {
display: flex;
flex-direction: column;
padding: 0.55rem 0.7rem;
border-radius: 10px;
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 -8px var(--gui-accent-glow, transparent);
backdrop-filter: blur(12px) saturate(1.25);
cursor: grab;
outline: none;
}
.lv-hud-widget:active { cursor: grabbing; }
.lv-hud-widget:focus-visible { outline: 2px solid var(--color-ice); outline-offset: 2px; }
/* Module wall: each column is a seamless vertical marquee (list is duplicated in the DOM). */
.lv-wall-viewport { -webkit-mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent); mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent); }
.lv-wall-track { animation: lv-wall-scroll var(--dur, 40s) linear infinite; will-change: transform; }
.lv-wall-col:hover .lv-wall-track { animation-play-state: paused; }
@keyframes lv-wall-scroll {
from { transform: translateY(0); }
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-download-band {
background:
radial-gradient(120% 120% at 100% 0%, color-mix(in srgb, var(--page-accent-glow, #6cf) 22%, transparent), transparent 60%),
linear-gradient(180deg, #0e1430, #070a18);
border: 1px solid color-mix(in srgb, var(--color-ice) 22%, transparent);
}
.lv-faq-item > summary::-webkit-details-marker { display: none; }
.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); }
@media (prefers-reduced-motion: reduce) {
.lv-wall-track { animation: none; }
}
/* Download page: numbered install steps and safe Markdown changelog styling. */
.lv-step-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
flex: none;
border-radius: 999px;
background: color-mix(in srgb, var(--color-ice) 18%, transparent);
color: var(--color-ice, #6cf);
font-family: var(--font-code, monospace);
font-weight: 700;
}
.lv-markdown :where(h1, h2, h3) { font-weight: 700; color: var(--color-snow, #fff); margin: 0.6em 0 0.3em; }
.lv-markdown :where(p, ul, ol) { margin: 0.4em 0; }
.lv-markdown :where(ul, ol) { padding-left: 1.3em; }
.lv-markdown :where(li) { margin: 0.15em 0; }
.lv-markdown :where(code) { font-family: var(--font-code, monospace); background: rgb(255 255 255 / 0.08); padding: 0.1em 0.4em; border-radius: 4px; }
.lv-markdown :where(a) { color: var(--color-ice, #6cf); text-decoration: underline; text-underline-offset: 3px; }

View file

@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react'
import { expect, test, vi } from 'vitest'
import { I18nextProvider } from 'react-i18next'
import { CommandHero } from '../CommandHero'
import { Hero } from '../sections/Hero'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { initI18n } from '../../../app/i18n'
import { SessionProvider } from '../../auth/session'
import { json, mockFetch } from '../../../test/fetch'
test('reduced motion shows the full command at once', async () => {
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
const router = createMemoryRouter([{ path: '/', element: <CommandHero /> }])
render(
<QueryClientProvider client={new QueryClient()}>
<SessionProvider><RouterProvider router={router} /></SessionProvider>
</QueryClientProvider>,
)
expect(await screen.findByText('%config load 7KQ3M9XA')).toBeInTheDocument()
expect(screen.getByText('[LoVisual] Конфиг «pvp» загружен')).toBeInTheDocument()
expect(screen.getByLabelText('Пример: загрузка конфига по коду в чате игры')).toBeInTheDocument()
})
test('english locale renders the landing hero headline', async () => {
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
const i18n = initI18n('en')
const router = createMemoryRouter([{ path: '/', element: <Hero /> }])
render(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={new QueryClient()}>
<SessionProvider>
<RouterProvider router={router} />
</SessionProvider>
</QueryClientProvider>
</I18nextProvider>,
)
expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent('you can see')
})

View file

@ -0,0 +1,68 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, expect, test, vi } from 'vitest'
import { mockFetch } from '../../../test/fetch'
import { ParticleField } from '../effects/ParticleField'
import { HudPlayground } from '../sections/HudPlayground'
import { ModuleWall } from '../sections/ModuleWall'
import { ShowcaseTeaser } from '../sections/ShowcaseTeaser'
const reduceMotion = () =>
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
function withQuery(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const router = createMemoryRouter([{ path: '/', element: ui }])
return render(
<QueryClientProvider client={client}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
afterEach(() => vi.unstubAllGlobals())
test('HudWidget moves by 8px with ArrowRight', () => {
reduceMotion()
withQuery(<HudPlayground />)
const widget = screen.getByRole('button', { name: /FPS/ })
const before = Number.parseInt(widget.style.left, 10)
fireEvent.keyDown(widget, { key: 'ArrowRight' })
expect(Number.parseInt(screen.getByRole('button', { name: /FPS/ }).style.left, 10)).toBe(before + 8)
})
test('HudWidget stays inside the playfield bounds', () => {
reduceMotion()
withQuery(<HudPlayground />)
for (let i = 0; i < 200; i++) fireEvent.keyDown(screen.getByRole('button', { name: /FPS/ }), { key: 'ArrowRight' })
const left = Number.parseInt(screen.getByRole('button', { name: /FPS/ }).style.left, 10)
expect(left).toBeGreaterThanOrEqual(0)
expect(left).toBeLessThanOrEqual(680)
})
test('ModuleWall lists all four category headings', () => {
reduceMotion()
withQuery(<ModuleWall />)
for (const name of ['Combat', 'Visuals', 'Player', 'Misc']) {
expect(screen.getByRole('heading', { level: 3, name })).toBeInTheDocument()
}
})
test('ShowcaseTeaser falls back to example cards on fetch error', async () => {
reduceMotion()
mockFetch({}) // no /showcase route -> 404 -> query error
withQuery(<ShowcaseTeaser />)
const tags = await screen.findAllByText('Пример')
expect(tags).toHaveLength(3)
})
test('ParticleField schedules no animation frame under reduced motion', () => {
const ctx = new Proxy({}, { get: () => () => ({ addColorStop() {} }), set: () => true })
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
reduceMotion()
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
render(<ParticleField color="rgb(10, 20, 30)" />)
expect(raf).not.toHaveBeenCalled()
;(HTMLCanvasElement.prototype.getContext as unknown as { mockRestore: () => void }).mockRestore()
})

View file

@ -0,0 +1,70 @@
import { useId, useState } from 'react'
import { parseArgb } from '../types'
type Props = { label: string; value: string; onChange: (hex: string) => void }
const pad2 = (n: number) => n.toString(16).padStart(2, '0')
const toRgb6 = (hex: string) => {
const { r, g, b } = parseArgb(hex)
return `#${pad2(r)}${pad2(g)}${pad2(b)}`.toUpperCase()
}
/** Rebuild `#AARRGGBB` from a `#RRGGBB` picker value, keeping the current alpha. */
const withAlpha = (hex: string, rgb6: string) => `#${pad2(parseArgb(hex).a)}${rgb6.slice(1)}`.toUpperCase()
/** Rebuild `#AARRGGBB` from an alpha (0–255), keeping the current rgb. */
const withRgb = (hex: string, a: number) => `#${pad2(a)}${toRgb6(hex).slice(1)}`.toUpperCase()
/** One editable palette slot: native color picker + alpha slider + hex field. */
export function ColorField({ label, value, onChange }: Props) {
const id = useId()
// Local mirror so a partially-typed hex isn't clobbered mid-edit; reset when the value changes
// for another reason (React's "adjust state during render" pattern).
const [editing, setEditing] = useState<{ value: string; text: string }>({ value, text: value })
const text = editing.value === value ? editing.text : value
const commitText = () => {
const t = text.trim()
if (/^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) onChange(`#${t.replace(/^#/, '').toUpperCase()}`)
else setEditing({ value, text: value })
}
return (
<label htmlFor={id} className="lv-color-field flex items-center gap-2">
<span
className="lv-alpha-check size-8 shrink-0 rounded-md border border-white/15"
style={{ backgroundImage: `linear-gradient(45deg, ${value}, ${value})` }}
aria-hidden="true"
/>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-xs text-frost">{label}</span>
<span className="flex min-w-0 items-center gap-2">
<input
type="color"
aria-label={label}
value={toRgb6(value)}
onChange={(e) => onChange(withAlpha(value, e.target.value))}
className="size-7 shrink-0 cursor-pointer rounded border-0 bg-transparent p-0"
/>
<input
type="range"
min={0}
max={255}
aria-label={`${label} alpha`}
value={parseArgb(value).a}
onChange={(e) => onChange(withRgb(value, Number(e.target.value)))}
className="lv-alpha-slider h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-white/20"
/>
<input
id={id}
type="text"
spellCheck={false}
value={text}
onChange={(e) => setEditing({ value, text: e.target.value })}
onBlur={commitText}
onKeyDown={(e) => e.key === 'Enter' && commitText()}
className="lv-hex-input w-[6.25rem] shrink-0 rounded-md border border-white/15 bg-black/40 px-2 py-1 font-code text-xs text-snow"
/>
</span>
</span>
</label>
)
}

View file

@ -0,0 +1,28 @@
import { useMemo } from 'react'
import { ClickGuiWindow } from '../../clickgui/ClickGuiWindow'
import { themeToCssVars } from '../../clickgui/themeVars'
import { VoxelScene } from '../../landing/effects/VoxelScene'
import { parseArgb, type ThemeEntry } from '../types'
/** The live left pane: a themed ClickGui window plus two HUD tiles over the voxel scene. */
export function EditorPreview({ draft }: { draft: ThemeEntry }) {
const vars = useMemo(() => themeToCssVars(draft), [draft])
const { r, g, b } = parseArgb(draft.theme.accent)
return (
<div className="lv-editor-preview relative min-h-[420px] overflow-hidden rounded-panel border border-white/10">
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} className="absolute inset-0" />
<div className="relative flex h-full flex-col items-center justify-center gap-6 p-6" style={vars}>
<div className="flex w-full justify-center gap-4">
<span className="lv-hud-tile" style={{ background: 'var(--gui-card-gradient)' }}>
<b>FPS</b> 240
</span>
<span className="lv-hud-tile" style={{ background: 'var(--gui-surface-gradient)' }}>
<b>XYZ</b> 12 · 64 · -8
</span>
</div>
<ClickGuiWindow theme={draft} demo={false} className="w-full max-w-[520px]" />
</div>
</div>
)
}

View file

@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next'
import type { GradientSpec } from '../types'
import { ColorField } from './ColorField'
type Props = { title: string; value: GradientSpec; onChange: (patch: Partial<GradientSpec>) => void }
/** A gradient slot: enable toggle, start/end colors and a 0–360° angle control. */
export function GradientField({ title, value, onChange }: Props) {
const { t } = useTranslation('themes')
return (
<div className="lv-gradient-field flex flex-col gap-3 rounded-panel border border-white/10 bg-black/25 p-3">
<label className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-snow">{title}</span>
<span className="flex items-center gap-2 text-xs text-frost">
{t('editor.enabled')}
<input
type="checkbox"
aria-label={`${title} · ${t('editor.enabled')}`}
checked={value.enabled}
onChange={(e) => onChange({ enabled: e.target.checked })}
className="size-4 accent-[var(--color-ice)]"
/>
</span>
</label>
<ColorField label={t('editor.start')} value={value.start} onChange={(start) => onChange({ start })} />
{value.enabled && (
<>
<ColorField label={t('editor.end')} value={value.end} onChange={(end) => onChange({ end })} />
<label className="flex items-center gap-2 text-xs text-frost">
<span className="w-12 shrink-0">{t('editor.angle')}</span>
<input
type="range"
min={0}
max={359}
value={Math.round(value.angle)}
onChange={(e) => onChange({ angle: Number(e.target.value) })}
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-white/20"
/>
<span className="w-10 shrink-0 text-right font-code text-snow">{Math.round(value.angle)}°</span>
</label>
</>
)}
</div>
)
}

View file

@ -0,0 +1,155 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../../../shared/ui/Button'
import { PRESETS } from '../presets.generated'
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
import { EditorPreview } from './EditorPreview'
import { ColorField } from './ColorField'
import { COLOR_SLOTS, GRADIENT_SLOTS, fromProfileJson, toProfileJson, toShareHash } from './codec'
import { GradientField } from './GradientField'
import { useThemeHistory } from './useThemeHistory'
/** Pack the draft name into a slug id, mirroring the codec's `slug`. */
const nameToId = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'custom'
export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
const { t } = useTranslation('themes')
const { draft, set, reset, undo, redo, canUndo, canRedo } = useThemeHistory(initial)
const [notice, setNotice] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
const flash = useCallback((text: string) => {
setNotice(text)
clearTimeout(timer.current)
timer.current = setTimeout(() => setNotice(null), 2000)
}, [])
useEffect(() => () => clearTimeout(timer.current), [])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z') return
e.preventDefault()
if (e.shiftKey) redo()
else undo()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [undo, redo])
const setColor = (slot: keyof ThemeColors, hex: string) => set({ ...draft, theme: { ...draft.theme, [slot]: hex } })
const setGradient = (key: keyof ThemeEntry, patch: Partial<GradientSpec>) =>
set({ ...draft, [key]: { ...(draft[key] as GradientSpec), ...patch } })
const copy = async () => {
await navigator.clipboard?.writeText(toProfileJson(draft))
flash(t('editor.copied'))
}
const download = () => {
const url = URL.createObjectURL(new Blob([toProfileJson(draft)], { type: 'application/json' }))
const a = document.createElement('a')
a.href = url
a.download = `${draft.id}.json`
a.click()
URL.revokeObjectURL(url)
}
const share = async () => {
await navigator.clipboard?.writeText(`${location.origin}${location.pathname}#t=${toShareHash(draft)}`)
flash(t('editor.shared'))
}
const onFile = (file?: File) => {
if (!file) return
file.text().then((text) => {
const result = fromProfileJson(text)
if ('error' in result) flash(t('editor.importError'))
else reset(result)
})
}
return (
<div className="grid gap-8 lg:grid-cols-2">
<EditorPreview draft={draft} />
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-2">
<Button variant="quiet" onClick={undo} disabled={!canUndo}>
{t('editor.undo')}
</Button>
<Button variant="quiet" onClick={redo} disabled={!canRedo}>
{t('editor.redo')}
</Button>
<Button variant="quiet" onClick={() => reset(initial)}>
{t('editor.reset')}
</Button>
{notice && <span className="ml-auto font-code text-xs text-ice">{notice}</span>}
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-frost">{t('editor.nameLabel')}</span>
<input
value={draft.name}
onChange={(e) => set({ ...draft, name: e.target.value, id: nameToId(e.target.value) })}
className="rounded-md border border-white/15 bg-black/40 px-3 py-2 text-snow"
/>
</label>
<section className="flex flex-col gap-2">
<h3 className="text-sm font-semibold uppercase text-frost">{t('editor.presets')}</h3>
<div className="flex flex-wrap gap-2">
{PRESETS.map((p) => (
<button
key={p.id}
type="button"
onClick={() => reset(p)}
className={`lv-preset-chip ${p.id === draft.id ? 'is-active' : ''}`}
>
{p.name}
</button>
))}
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-semibold uppercase text-frost">{t('editor.colors')}</h3>
<div className="grid gap-3 sm:grid-cols-2">
{COLOR_SLOTS.map((slot) => (
<ColorField key={slot} label={t(`colors.${slot}`)} value={draft.theme[slot]} onChange={(hex) => setColor(slot, hex)} />
))}
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-semibold uppercase text-frost">{t('editor.gradients')}</h3>
<div className="grid gap-3">
{GRADIENT_SLOTS.map((g) => (
<GradientField
key={g.prefix}
title={t(`editor.gradient.${g.prefix}`)}
value={draft[g.key] as GradientSpec}
onChange={(patch) => setGradient(g.key, patch)}
/>
))}
</div>
</section>
<div className="flex flex-wrap gap-2">
<Button onClick={copy}>{t('editor.copy')}</Button>
<Button variant="quiet" onClick={download}>
{t('editor.download')}
</Button>
<Button variant="quiet" onClick={() => fileRef.current?.click()}>
{t('editor.import')}
</Button>
<Button variant="quiet" onClick={share}>
{t('editor.share')}
</Button>
<input ref={fileRef} type="file" accept="application/json,.json" className="hidden" onChange={(e) => onFile(e.target.files?.[0])} />
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,138 @@
import { PRESETS } from '../presets.generated'
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
// The mod serialises a theme as a FLAT map: `name`, the 12 color slots as
// `#AARRGGBB`, then for each of the 5 gradient slots `Enabled/Start/End/Angle`.
// See mod ThemesProfileCodec.toProfileValues — keep these lists in lockstep.
const COLOR_KEYS: (keyof ThemeColors)[] = [
'windowBg',
'windowHeader',
'windowStroke',
'surface',
'surfaceHover',
'cardEnabled',
'cardDisabled',
'textPrimary',
'textMuted',
'accent',
'accentSoft',
'strokeSoft',
]
/** The 12 editable palette slots, in the mod's profile order. */
export const COLOR_SLOTS = COLOR_KEYS
/** The 5 gradient slots: profile prefix + the matching `ThemeEntry` field. */
export const GRADIENT_SLOTS = [
{ prefix: 'window', key: 'windowGradient' },
{ prefix: 'header', key: 'headerGradient' },
{ prefix: 'surface', key: 'surfaceGradient' },
{ prefix: 'card', key: 'cardGradient' },
{ prefix: 'stroke', key: 'strokeGradient' },
] as const satisfies { prefix: string; key: keyof ThemeEntry }[]
const GRADIENT_KEYS: [string, keyof ThemeEntry][] = [
['window', 'windowGradient'],
['header', 'headerGradient'],
['surface', 'surfaceGradient'],
['card', 'cardGradient'],
['stroke', 'strokeGradient'],
]
const CLASSIC = PRESETS[0]
type Profile = Record<string, string | boolean | number>
const upper = (hex: string) => hex.toUpperCase()
/** Pack an entry into the mod's flat profile object. */
export function toProfile(entry: ThemeEntry): Profile {
const out: Profile = { name: entry.name }
for (const k of COLOR_KEYS) out[k] = upper(entry.theme[k])
for (const [prefix, key] of GRADIENT_KEYS) {
const g = entry[key] as GradientSpec
out[`${prefix}GradientEnabled`] = g.enabled
out[`${prefix}GradientStart`] = upper(g.start)
out[`${prefix}GradientEnd`] = upper(g.end)
out[`${prefix}GradientAngle`] = g.angle
}
return out
}
/** The mod's profile as an ordered JSON string (human-editable, keys match the mod exactly). */
export function toProfileJson(entry: ThemeEntry): string {
return JSON.stringify(toProfile(entry), null, 2)
}
const str = (v: unknown, fallback: string) => (typeof v === 'string' && v ? upper(v) : fallback)
const rawStr = (v: unknown, fallback: string) => (typeof v === 'string' && v.trim() ? v : fallback)
const bool = (v: unknown, fallback: boolean) => (typeof v === 'boolean' ? v : fallback)
const num = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
function readGradient(map: Profile, prefix: string, fallback: GradientSpec): GradientSpec {
return {
enabled: bool(map[`${prefix}GradientEnabled`], fallback.enabled),
start: str(map[`${prefix}GradientStart`], fallback.start),
end: str(map[`${prefix}GradientEnd`], fallback.end),
angle: num(map[`${prefix}GradientAngle`], fallback.angle),
}
}
/** Slug matching the mod's `uniqueCustomId` spirit: lowercase, non-alphanumeric → `_`. */
const slug = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'custom'
function fromProfile(map: Profile): ThemeEntry {
const theme = Object.fromEntries(
COLOR_KEYS.map((k) => [k, str(map[k], CLASSIC.theme[k])]),
) as unknown as ThemeColors
const name = rawStr(map.name, 'Custom theme')
return {
id: slug(name),
name,
builtin: false,
theme,
windowGradient: readGradient(map, 'window', CLASSIC.windowGradient),
headerGradient: readGradient(map, 'header', CLASSIC.headerGradient),
surfaceGradient: readGradient(map, 'surface', CLASSIC.surfaceGradient),
cardGradient: readGradient(map, 'card', CLASSIC.cardGradient),
strokeGradient: readGradient(map, 'stroke', CLASSIC.strokeGradient),
}
}
/** Rebuild an entry from JSON. Tolerant: missing keys fall back to Classic; bad input → error. */
export function fromProfileJson(json: string): ThemeEntry | { error: string } {
let parsed: unknown
try {
parsed = JSON.parse(json)
} catch {
return { error: 'invalidJson' }
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { error: 'invalidShape' }
return fromProfile(parsed as Profile)
}
// base64url without padding, so the share hash is URL-fragment safe.
function b64url(text: string): string {
const b64 = btoa(unescape(encodeURIComponent(text)))
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function unb64url(b64: string): string {
const pad = b64.length % 4 ? '='.repeat(4 - (b64.length % 4)) : ''
const std = b64.replace(/-/g, '+').replace(/_/g, '/') + pad
return decodeURIComponent(escape(atob(std)))
}
/** Compact profile → URL-safe hash for the `#t=` share link. */
export function toShareHash(entry: ThemeEntry): string {
return b64url(JSON.stringify(toProfile(entry)))
}
/** Decode a `#t=` hash back into an entry (or an error shape). */
export function fromShareHash(hash: string): ThemeEntry | { error: string } {
try {
return fromProfileJson(unb64url(hash))
} catch {
return { error: 'invalidHash' }
}
}

View file

@ -0,0 +1,48 @@
/* Theme editor: preview pane, HUD tiles, preset chips and color-field affordances. */
.lv-editor-preview { background: linear-gradient(180deg, #0b0f22, #070a18); }
.lv-hud-tile {
display: inline-flex;
align-items: baseline;
gap: 6px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--gui-stroke-soft, #6cf) 40%, transparent);
color: var(--gui-text, #fff);
font-family: var(--font-code, monospace);
font-size: 0.8rem;
box-shadow: 0 6px 20px -12px #000;
}
.lv-hud-tile b { color: var(--gui-accent, #6cf); font-weight: 700; }
.lv-preset-chip {
padding: 4px 12px;
border-radius: 999px;
border: 1px solid rgb(255 255 255 / 0.15);
background: rgb(255 255 255 / 0.04);
color: var(--color-frost, #9fb0c0);
font-size: 0.8rem;
transition: border-color 160ms ease, color 160ms ease;
}
.lv-preset-chip:hover { border-color: color-mix(in srgb, var(--color-ice, #6cf) 50%, transparent); color: var(--color-snow, #fff); }
.lv-preset-chip.is-active { border-color: var(--color-ice, #6cf); color: var(--color-ice, #6cf); }
/* Checkerboard behind the swatch so partial alpha is visible. */
.lv-alpha-check {
background-color: #20242b;
background-image:
linear-gradient(45deg, rgb(255 255 255 / 0.12) 25%, transparent 25%, transparent 75%, rgb(255 255 255 / 0.12) 75%),
linear-gradient(45deg, rgb(255 255 255 / 0.12) 25%, transparent 25%, transparent 75%, rgb(255 255 255 / 0.12) 75%);
background-size: 10px 10px;
background-position: 0 0, 5px 5px;
}
.lv-alpha-slider::-webkit-slider-thumb,
.lv-alpha-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--color-ice, #6cf);
border: 2px solid #0b0f22;
cursor: pointer;
}

View file

@ -0,0 +1,85 @@
import { describe, expect, test } from 'vitest'
import { PRESETS } from '../../presets.generated'
import { fromProfileJson, fromShareHash, toProfileJson, toShareHash } from '../codec'
// The mod's ThemesProfileCodec.toProfileValues writes exactly these keys, in this order.
const PROFILE_KEYS = [
'name',
'windowBg',
'windowHeader',
'windowStroke',
'surface',
'surfaceHover',
'cardEnabled',
'cardDisabled',
'textPrimary',
'textMuted',
'accent',
'accentSoft',
'strokeSoft',
'windowGradientEnabled',
'windowGradientStart',
'windowGradientEnd',
'windowGradientAngle',
'headerGradientEnabled',
'headerGradientStart',
'headerGradientEnd',
'headerGradientAngle',
'surfaceGradientEnabled',
'surfaceGradientStart',
'surfaceGradientEnd',
'surfaceGradientAngle',
'cardGradientEnabled',
'cardGradientStart',
'cardGradientEnd',
'cardGradientAngle',
'strokeGradientEnabled',
'strokeGradientStart',
'strokeGradientEnd',
'strokeGradientAngle',
]
const editable = (e: (typeof PRESETS)[number]) => ({
name: e.name,
theme: e.theme,
windowGradient: e.windowGradient,
headerGradient: e.headerGradient,
surfaceGradient: e.surfaceGradient,
cardGradient: e.cardGradient,
strokeGradient: e.strokeGradient,
})
describe('theme profile codec', () => {
test('JSON keys match the mod profile exactly', () => {
expect(Object.keys(JSON.parse(toProfileJson(PRESETS[0])))).toEqual(PROFILE_KEYS)
})
test('round-trips every preset through the profile JSON', () => {
for (const preset of PRESETS) {
const result = fromProfileJson(toProfileJson(preset))
expect('error' in result).toBe(false)
if (!('error' in result)) expect(editable(result)).toEqual(editable(preset))
}
})
test('share hash round-trips', () => {
for (const preset of PRESETS) {
const result = fromShareHash(toShareHash(preset))
if ('error' in result) throw new Error('unexpected hash error')
expect(editable(result)).toEqual(editable(preset))
}
})
test('rejects malformed JSON and non-objects', () => {
expect(fromProfileJson('{ nope')).toEqual({ error: 'invalidJson' })
expect(fromProfileJson('[1,2,3]')).toEqual({ error: 'invalidShape' })
expect(fromShareHash('%%%not-base64%%%')).toEqual({ error: 'invalidHash' })
})
test('is tolerant: missing keys fall back to Classic', () => {
const result = fromProfileJson(JSON.stringify({ name: 'Sparse' }))
if ('error' in result) throw new Error('unexpected error')
expect(result.name).toBe('Sparse')
expect(result.theme).toEqual(PRESETS[0].theme)
})
})

View file

@ -0,0 +1,18 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { expect, test } from 'vitest'
import { PRESETS } from '../../presets.generated'
import { ThemeEditor } from '../ThemeEditor'
test('editor shows the initial preset name and one toggle per gradient slot', () => {
render(<ThemeEditor initial={PRESETS[0]} />)
const name = screen.getByDisplayValue('Classic')
expect(name).toBeInTheDocument()
expect(screen.getAllByRole('checkbox', { name: /Градиент/ })).toHaveLength(5)
})
test('picking a preset chip loads it into the editor', () => {
const next = PRESETS[1]
render(<ThemeEditor initial={PRESETS[0]} />)
fireEvent.click(screen.getByRole('button', { name: next.name }))
expect(screen.getByDisplayValue(next.name)).toBeInTheDocument()
})

View file

@ -0,0 +1,85 @@
import { useCallback, useRef, useState } from 'react'
import type { ThemeEntry } from '../types'
const LIMIT = 50
/** Changes closer than this share one undo step, so a slider drag is a single revert. */
const COALESCE_MS = 500
export type ThemeHistory = {
draft: ThemeEntry
/** Live edit; coalesced with recent edits of the same interaction. */
set: (next: ThemeEntry) => void
/** Replace the draft and clear history (preset pick, import, share-link load). */
reset: (next: ThemeEntry) => void
undo: () => void
redo: () => void
canUndo: boolean
canRedo: boolean
}
/** Undo/redo ring buffer (max 50 steps) for the theme editor draft. */
export function useThemeHistory(initial: ThemeEntry): ThemeHistory {
const [present, setPresent] = useState(initial)
const presentRef = useRef(present)
const past = useRef<ThemeEntry[]>([])
const future = useRef<ThemeEntry[]>([])
const lastEdit = useRef(0)
const [flags, setFlags] = useState({ canUndo: false, canRedo: false })
const commit = useCallback((next: ThemeEntry) => {
presentRef.current = next
setPresent(next)
setFlags({ canUndo: past.current.length > 0, canRedo: future.current.length > 0 })
}, [])
const set = useCallback(
(next: ThemeEntry) => {
const now = Date.now()
if (now - lastEdit.current > COALESCE_MS) {
past.current = [...past.current, presentRef.current].slice(-LIMIT)
future.current = []
}
lastEdit.current = now
commit(next)
},
[commit],
)
const reset = useCallback(
(next: ThemeEntry) => {
past.current = []
future.current = []
lastEdit.current = 0
commit(next)
},
[commit],
)
const undo = useCallback(() => {
if (past.current.length === 0) return
future.current = [presentRef.current, ...future.current].slice(0, LIMIT)
lastEdit.current = 0
commit(past.current.at(-1) as ThemeEntry)
past.current = past.current.slice(0, -1)
setFlags({ canUndo: past.current.length > 0, canRedo: true })
}, [commit])
const redo = useCallback(() => {
if (future.current.length === 0) return
past.current = [...past.current, presentRef.current].slice(-LIMIT)
lastEdit.current = 0
commit(future.current[0] as ThemeEntry)
future.current = future.current.slice(1)
setFlags({ canUndo: true, canRedo: future.current.length > 0 })
}, [commit])
return {
draft: present,
set,
reset,
undo,
redo,
canUndo: flags.canUndo,
canRedo: flags.canRedo,
}
}

View file

@ -0,0 +1,49 @@
import type { Translation } from '../../../shared/i18n/common.ru'
import type { themesRu } from './ru'
export const themesEn = {
apply: 'Apply the {{name}} theme',
colors: {
windowBg: 'Window',
windowHeader: 'Header',
windowStroke: 'Stroke',
surface: 'Surface',
surfaceHover: 'Hover',
cardEnabled: 'Card on',
cardDisabled: 'Card off',
textPrimary: 'Text',
textMuted: 'Muted text',
accent: 'Accent',
accentSoft: 'Soft accent',
strokeSoft: 'Soft stroke',
},
editor: {
title: 'Theme editor',
lead: 'Build your own theme and bring it into the mod as a single file.',
nameLabel: 'Theme name',
presets: 'Ready presets',
colors: 'Colors',
gradients: 'Gradients',
enabled: 'Gradient',
angle: 'Angle',
start: 'Start',
end: 'End',
gradient: {
window: 'Window',
header: 'Header',
surface: 'Surface',
card: 'Card',
stroke: 'Stroke',
},
copy: 'Copy for the mod',
copied: 'Copied',
download: 'Download .json',
import: 'Import from file',
share: 'Share link',
shared: 'Link copied',
reset: 'Reset',
undo: 'Undo',
redo: 'Redo',
importError: 'Could not read the theme — the file is malformed.',
},
} satisfies Translation<typeof themesRu>

View file

@ -0,0 +1,46 @@
export const themesRu = {
apply: 'Применить тему «{{name}}»',
colors: {
windowBg: 'Фон окна',
windowHeader: 'Шапка',
windowStroke: 'Обводка',
surface: 'Поверхность',
surfaceHover: 'Наведение',
cardEnabled: 'Карточка вкл.',
cardDisabled: 'Карточка выкл.',
textPrimary: 'Текст',
textMuted: 'Приглушённый текст',
accent: 'Акцент',
accentSoft: 'Мягкий акцент',
strokeSoft: 'Мягкая обводка',
},
editor: {
title: 'Редактор тем',
lead: 'Собери свою тему и перенеси её в мод одним файлом.',
nameLabel: 'Название темы',
presets: 'Готовые пресеты',
colors: 'Цвета',
gradients: 'Градиенты',
enabled: 'Градиент',
angle: 'Угол',
start: 'Начало',
end: 'Конец',
gradient: {
window: 'Окно',
header: 'Шапка',
surface: 'Поверхность',
card: 'Карточка',
stroke: 'Обводка',
},
copy: 'Скопировать для мода',
copied: 'Скопировано',
download: 'Скачать .json',
import: 'Импорт из файла',
share: 'Ссылка на тему',
shared: 'Ссылка скопирована',
reset: 'Сбросить',
undo: 'Отменить',
redo: 'Вернуть',
importError: 'Не удалось прочитать тему — файл испорчен.',
},
} as const

View file

@ -0,0 +1,438 @@
// generated — do not edit. Source: mod/.../features/theme/presets/ThemePresets{1,2,3}.java
// Regenerate with `bun run gen:themes`.
import type { ThemeEntry } from './types'
export const PRESETS: ThemeEntry[] = [
{
id: 'classic',
name: 'Classic',
builtin: true,
theme: {
windowBg: '#F012191B',
windowHeader: '#F0142226',
windowStroke: '#60203030',
surface: '#16202523',
surfaceHover: '#28353A3E',
cardEnabled: '#5043A7C8',
cardDisabled: '#00334046',
textPrimary: '#FFFFFFFF',
textMuted: '#88C6D2CD',
accent: '#FF5CC8E7',
accentSoft: '#805CC8E7',
strokeSoft: '#60FFFFFF',
},
windowGradient: { enabled: false, start: '#F012191B', end: '#F012191B', angle: 90 },
headerGradient: { enabled: true, start: '#F0273437', end: '#F0132024', angle: 90 },
surfaceGradient: { enabled: false, start: '#16202523', end: '#16202523', angle: 90 },
cardGradient: { enabled: false, start: '#5043A7C8', end: '#5043A7C8', angle: 90 },
strokeGradient: { enabled: false, start: '#60203030', end: '#60203030', angle: 90 },
},
{
id: 'legacy',
name: 'Legacy',
builtin: true,
theme: {
windowBg: '#F0121314',
windowHeader: '#F0000205',
windowStroke: '#E1121314',
surface: '#FF171819',
surfaceHover: '#FF131315',
cardEnabled: '#503F464D',
cardDisabled: '#33191C20',
textPrimary: '#FFF5F5FF',
textMuted: '#88A5A5A5',
accent: '#FF767C94',
accentSoft: '#80767C94',
strokeSoft: '#60373746',
},
windowGradient: { enabled: false, start: '#F0121314', end: '#F0121314', angle: 90 },
headerGradient: { enabled: true, start: '#F0141619', end: '#F0000205', angle: 90 },
surfaceGradient: { enabled: false, start: '#FF171819', end: '#FF171819', angle: 90 },
cardGradient: { enabled: false, start: '#503F464D', end: '#503F464D', angle: 90 },
strokeGradient: { enabled: false, start: '#E1121314', end: '#E1121314', angle: 90 },
},
{
id: 'amber',
name: 'Amber',
builtin: true,
theme: {
windowBg: '#F019120D',
windowHeader: '#F0201711',
windowStroke: '#60C3652C',
surface: '#FF221711',
surfaceHover: '#FF2B1E16',
cardEnabled: '#50E08C3A',
cardDisabled: '#40302620',
textPrimary: '#FFFFF7EB',
textMuted: '#88E3C9A7',
accent: '#FFE69A43',
accentSoft: '#80E69A43',
strokeSoft: '#60F5D7B0',
},
windowGradient: { enabled: false, start: '#F019120D', end: '#F019120D', angle: 90 },
headerGradient: { enabled: true, start: '#F0322A24', end: '#F01E1610', angle: 90 },
surfaceGradient: { enabled: false, start: '#FF221711', end: '#FF221711', angle: 90 },
cardGradient: { enabled: false, start: '#50E08C3A', end: '#50E08C3A', angle: 90 },
strokeGradient: { enabled: false, start: '#60C3652C', end: '#60C3652C', angle: 90 },
},
{
id: 'noir',
name: 'Noir',
builtin: true,
theme: {
windowBg: '#F00C0D10',
windowHeader: '#F0121317',
windowStroke: '#60393F4A',
surface: '#FF14161B',
surfaceHover: '#FF1C1F26',
cardEnabled: '#5039424F',
cardDisabled: '#40282D35',
textPrimary: '#FFF4F4F4',
textMuted: '#88C7CBD3',
accent: '#FFE16B78',
accentSoft: '#80E16B78',
strokeSoft: '#605A6370',
},
windowGradient: { enabled: false, start: '#F00C0D10', end: '#F00C0D10', angle: 90 },
headerGradient: { enabled: true, start: '#F025262A', end: '#F0111216', angle: 90 },
surfaceGradient: { enabled: false, start: '#FF14161B', end: '#FF14161B', angle: 90 },
cardGradient: { enabled: false, start: '#5039424F', end: '#5039424F', angle: 90 },
strokeGradient: { enabled: false, start: '#60393F4A', end: '#60393F4A', angle: 90 },
},
{
id: 'purple',
name: 'Purple',
builtin: true,
theme: {
windowBg: '#F0120E1A',
windowHeader: '#F0181323',
windowStroke: '#60433A5A',
surface: '#FF1C1627',
surfaceHover: '#FF261D35',
cardEnabled: '#505E4AA0',
cardDisabled: '#40312644',
textPrimary: '#FFF5F2FF',
textMuted: '#88CFC6E6',
accent: '#FF9B6BFF',
accentSoft: '#809B6BFF',
strokeSoft: '#60705A9C',
},
windowGradient: { enabled: false, start: '#F0120E1A', end: '#F0120E1A', angle: 90 },
headerGradient: { enabled: true, start: '#F02A2635', end: '#F0171221', angle: 90 },
surfaceGradient: { enabled: false, start: '#FF1C1627', end: '#FF1C1627', angle: 90 },
cardGradient: { enabled: false, start: '#505E4AA0', end: '#505E4AA0', angle: 90 },
strokeGradient: { enabled: false, start: '#60433A5A', end: '#60433A5A', angle: 90 },
},
{
id: 'blue',
name: 'Blue',
builtin: true,
theme: {
windowBg: '#F0080F1E',
windowHeader: '#F00D1626',
windowStroke: '#60405A82',
surface: '#FF101B2B',
surfaceHover: '#FF162338',
cardEnabled: '#50406BB3',
cardDisabled: '#4023324A',
textPrimary: '#FFF0F5FF',
textMuted: '#88B2C4E6',
accent: '#FF2E64CC',
accentSoft: '#803A6FD6',
strokeSoft: '#60465F8E',
},
windowGradient: { enabled: false, start: '#F0080F1E', end: '#F0080F1E', angle: 90 },
headerGradient: { enabled: true, start: '#F0202937', end: '#F00C1524', angle: 90 },
surfaceGradient: { enabled: false, start: '#FF101B2B', end: '#FF101B2B', angle: 90 },
cardGradient: { enabled: false, start: '#50406BB3', end: '#50406BB3', angle: 90 },
strokeGradient: { enabled: false, start: '#60405A82', end: '#60405A82', angle: 90 },
},
{
id: 'azure',
name: 'Azure',
builtin: true,
theme: {
windowBg: '#F00C1220',
windowHeader: '#F0101A2A',
windowStroke: '#60324B66',
surface: '#FF121C2C',
surfaceHover: '#FF18253A',
cardEnabled: '#503F6BAA',
cardDisabled: '#40283348',
textPrimary: '#FFF2F7FF',
textMuted: '#88BBD0EA',
accent: '#FF5DA7FF',
accentSoft: '#805DA7FF',
strokeSoft: '#60507CA8',
},
windowGradient: { enabled: false, start: '#F00C1220', end: '#F00C1220', angle: 90 },
headerGradient: { enabled: true, start: '#F0232C3B', end: '#F00F1827', angle: 90 },
surfaceGradient: { enabled: false, start: '#FF121C2C', end: '#FF121C2C', angle: 90 },
cardGradient: { enabled: false, start: '#503F6BAA', end: '#503F6BAA', angle: 90 },
strokeGradient: { enabled: false, start: '#60324B66', end: '#60324B66', angle: 90 },
},
{
id: 'nitro',
name: 'Nitro',
builtin: true,
theme: {
windowBg: '#FF121624',
windowHeader: '#FF181E33',
windowStroke: '#60465580',
surface: '#FF1A1F36',
surfaceHover: '#FF232A45',
cardEnabled: '#50A24CFF',
cardDisabled: '#40303A52',
textPrimary: '#FFF3F6FF',
textMuted: '#88C7D3F3',
accent: '#FF7A5CFF',
accentSoft: '#807A5CFF',
strokeSoft: '#60829AC9',
},
windowGradient: { enabled: true, start: '#FF36205F', end: '#FF0B7DFF', angle: 45 },
headerGradient: { enabled: true, start: '#FF4B2FAF', end: '#FF24C1FF', angle: 45 },
surfaceGradient: { enabled: true, start: '#FF1A1F36', end: '#FF121728', angle: 90 },
cardGradient: { enabled: true, start: '#FF5A37D5', end: '#FF2AD1FF', angle: 45 },
strokeGradient: { enabled: true, start: '#FF6C4BFF', end: '#FF4BD0FF', angle: 45 },
},
{
id: 'sunset',
name: 'Sunset',
builtin: true,
theme: {
windowBg: '#FF1A1116',
windowHeader: '#FF211319',
windowStroke: '#605C2B2B',
surface: '#FF24151C',
surfaceHover: '#FF2E1A23',
cardEnabled: '#50FF7A45',
cardDisabled: '#40261A1A',
textPrimary: '#FFFFF2E8',
textMuted: '#88E3B9A3',
accent: '#FFFF7A45',
accentSoft: '#80FF7A45',
strokeSoft: '#60F5C1A6',
},
windowGradient: { enabled: true, start: '#FF2A1020', end: '#FFB2431F', angle: 45 },
headerGradient: { enabled: true, start: '#FF3B1626', end: '#FFC25B2A', angle: 45 },
surfaceGradient: { enabled: true, start: '#FF24151C', end: '#FF1E1116', angle: 90 },
cardGradient: { enabled: true, start: '#FFFF8A4C', end: '#FFFF4B7A', angle: 45 },
strokeGradient: { enabled: true, start: '#FFFFB27A', end: '#FFFF6A3A', angle: 45 },
},
{
id: 'aurora',
name: 'Aurora',
builtin: true,
theme: {
windowBg: '#FF0D1A18',
windowHeader: '#FF102320',
windowStroke: '#602B5B57',
surface: '#FF132623',
surfaceHover: '#FF1A312D',
cardEnabled: '#5034D399',
cardDisabled: '#40304742',
textPrimary: '#FFF1FFF9',
textMuted: '#8897E3D0',
accent: '#FF2DD9C3',
accentSoft: '#802DD9C3',
strokeSoft: '#6071B5A6',
},
windowGradient: { enabled: true, start: '#FF0B2A3C', end: '#FF2A8E6B', angle: 120 },
headerGradient: { enabled: true, start: '#FF0E3B46', end: '#FF1FB88B', angle: 120 },
surfaceGradient: { enabled: true, start: '#FF132623', end: '#FF10201D', angle: 90 },
cardGradient: { enabled: true, start: '#FF2DD9C3', end: '#FF5A7CFF', angle: 135 },
strokeGradient: { enabled: true, start: '#FF78FFE5', end: '#FF6FB1FF', angle: 120 },
},
{
id: 'mint',
name: 'Mint',
builtin: true,
theme: {
windowBg: '#FF0E1917',
windowHeader: '#FF112320',
windowStroke: '#60255B53',
surface: '#FF132521',
surfaceHover: '#FF1A2F2B',
cardEnabled: '#5035CFA8',
cardDisabled: '#40304742',
textPrimary: '#FFF1FFF9',
textMuted: '#8897E3D0',
accent: '#FF43E0B2',
accentSoft: '#8043E0B2',
strokeSoft: '#6071B5A6',
},
windowGradient: { enabled: true, start: '#FF0B2A24', end: '#FF1B3E36', angle: 120 },
headerGradient: { enabled: true, start: '#FF0F3A32', end: '#FF1A6E59', angle: 120 },
surfaceGradient: { enabled: true, start: '#FF132521', end: '#FF0F1E1B', angle: 90 },
cardGradient: { enabled: true, start: '#FF3DE3B4', end: '#FFB7F2D9', angle: 45 },
strokeGradient: { enabled: true, start: '#FF48E6B9', end: '#FF7EF0D6', angle: 120 },
},
{
id: 'peach',
name: 'Peach',
builtin: true,
theme: {
windowBg: '#FF1A1210',
windowHeader: '#FF221510',
windowStroke: '#605C3A2A',
surface: '#FF241814',
surfaceHover: '#FF2F211A',
cardEnabled: '#50F2A269',
cardDisabled: '#40382A22',
textPrimary: '#FFFFF7F0',
textMuted: '#88E3C9A7',
accent: '#FFFFA46B',
accentSoft: '#80FFB680',
strokeSoft: '#60F5D7B0',
},
windowGradient: { enabled: true, start: '#FF2A1512', end: '#FF4B241A', angle: 45 },
headerGradient: { enabled: true, start: '#FF3B1B14', end: '#FF6A301F', angle: 45 },
surfaceGradient: { enabled: true, start: '#FF241814', end: '#FF1C1310', angle: 90 },
cardGradient: { enabled: true, start: '#FFFFB47A', end: '#FFFF7FA8', angle: 45 },
strokeGradient: { enabled: true, start: '#FFFFC28E', end: '#FFFF8A5A', angle: 45 },
},
{
id: 'lilac',
name: 'Lilac',
builtin: true,
theme: {
windowBg: '#FF14131E',
windowHeader: '#FF1A1728',
windowStroke: '#60433A5A',
surface: '#FF1B172A',
surfaceHover: '#FF241D36',
cardEnabled: '#506C5CBE',
cardDisabled: '#40312644',
textPrimary: '#FFF5F2FF',
textMuted: '#88CFC6E6',
accent: '#FFB79BFF',
accentSoft: '#80C8B0FF',
strokeSoft: '#60705A9C',
},
windowGradient: { enabled: true, start: '#FF241A3B', end: '#FF1A3A55', angle: 120 },
headerGradient: { enabled: true, start: '#FF2F214A', end: '#FF3D2F6A', angle: 120 },
surfaceGradient: { enabled: true, start: '#FF1B172A', end: '#FF15121F', angle: 90 },
cardGradient: { enabled: true, start: '#FFB79BFF', end: '#FF7AD9FF', angle: 45 },
strokeGradient: { enabled: true, start: '#FFC5B0FF', end: '#FF8BB2FF', angle: 120 },
},
{
id: 'sand',
name: 'Sand',
builtin: true,
theme: {
windowBg: '#FF181510',
windowHeader: '#FF201A13',
windowStroke: '#6050432F',
surface: '#FF221B14',
surfaceHover: '#FF2C231B',
cardEnabled: '#50E8C98C',
cardDisabled: '#40322A21',
textPrimary: '#FFFFF4E8',
textMuted: '#88D8C4A2',
accent: '#FFE8C98C',
accentSoft: '#80F0D8A8',
strokeSoft: '#607A6A54',
},
windowGradient: { enabled: true, start: '#FF2A2016', end: '#FF3A2F22', angle: 45 },
headerGradient: { enabled: true, start: '#FF32261B', end: '#FF4A3A2B', angle: 45 },
surfaceGradient: { enabled: true, start: '#FF221B14', end: '#FF1A1510', angle: 90 },
cardGradient: { enabled: true, start: '#FFF2D29B', end: '#FFE8B67A', angle: 45 },
strokeGradient: { enabled: true, start: '#FFF5E0B2', end: '#FFD1A36C', angle: 45 },
},
{
id: 'dusk',
name: 'Dusk',
builtin: true,
theme: {
windowBg: '#FF141018',
windowHeader: '#FF1B1422',
windowStroke: '#60503A4A',
surface: '#FF1D1626',
surfaceHover: '#FF271B34',
cardEnabled: '#50FF875A',
cardDisabled: '#4030263B',
textPrimary: '#FFF6F1FF',
textMuted: '#88D6C9E6',
accent: '#FFFF8A4C',
accentSoft: '#80FF9B5A',
strokeSoft: '#606B5A7A',
},
windowGradient: { enabled: true, start: '#FF2B1630', end: '#FF6B2A1F', angle: 45 },
headerGradient: { enabled: true, start: '#FF3B1B3A', end: '#FF8A3B24', angle: 45 },
surfaceGradient: { enabled: true, start: '#FF1D1626', end: '#FF141018', angle: 90 },
cardGradient: { enabled: true, start: '#FF7C4BFF', end: '#FFFF9B3D', angle: 45 },
strokeGradient: { enabled: true, start: '#FFA775FF', end: '#FFFF7A3A', angle: 45 },
},
{
id: 'prism',
name: 'Prism',
builtin: true,
theme: {
windowBg: '#FF0F1220',
windowHeader: '#FF141A2B',
windowStroke: '#60435173',
surface: '#FF151C2E',
surfaceHover: '#FF1B243A',
cardEnabled: '#504B7CFF',
cardDisabled: '#4031324A',
textPrimary: '#FFF3F6FF',
textMuted: '#88C2CDEB',
accent: '#FF5AC8FA',
accentSoft: '#8074B6FF',
strokeSoft: '#606B7A9E',
},
windowGradient: { enabled: true, start: '#FF1A243A', end: '#FF2A1E4A', angle: 135 },
headerGradient: { enabled: true, start: '#FF223050', end: '#FF352060', angle: 135 },
surfaceGradient: { enabled: true, start: '#FF151C2E', end: '#FF101522', angle: 90 },
cardGradient: { enabled: true, start: '#FF59C4FF', end: '#FFB35CFF', angle: 45 },
strokeGradient: { enabled: true, start: '#FF7AD4FF', end: '#FFE36BFF', angle: 45 },
},
{
id: 'cinder',
name: 'Cinder',
builtin: true,
theme: {
windowBg: '#FF120E0F',
windowHeader: '#FF181012',
windowStroke: '#604A2F32',
surface: '#FF1A1214',
surfaceHover: '#FF23171A',
cardEnabled: '#50D24A4A',
cardDisabled: '#40281A1C',
textPrimary: '#FFFFF3F3',
textMuted: '#88E3B9B9',
accent: '#FFDC4A4A',
accentSoft: '#80E06A6A',
strokeSoft: '#606B4E52',
},
windowGradient: { enabled: true, start: '#FF1A0C0C', end: '#FF2A1416', angle: 45 },
headerGradient: { enabled: true, start: '#FF241012', end: '#FF3A191C', angle: 45 },
surfaceGradient: { enabled: true, start: '#FF1A1214', end: '#FF120E0F', angle: 90 },
cardGradient: { enabled: true, start: '#FFDC4A4A', end: '#FF401010', angle: 45 },
strokeGradient: { enabled: true, start: '#FFE26A6A', end: '#FF7A2A2A', angle: 45 },
},
{
id: 'forest',
name: 'Forest',
builtin: true,
theme: {
windowBg: '#FF101611',
windowHeader: '#FF141E15',
windowStroke: '#6043573D',
surface: '#FF161F18',
surfaceHover: '#FF1D2B20',
cardEnabled: '#5071C36C',
cardDisabled: '#40263225',
textPrimary: '#FFF3FFF1',
textMuted: '#88C5E3C1',
accent: '#FF7DD36B',
accentSoft: '#807DD36B',
strokeSoft: '#60607B58',
},
windowGradient: { enabled: true, start: '#FF1A241C', end: '#FF2B3A24', angle: 120 },
headerGradient: { enabled: true, start: '#FF1F2B21', end: '#FF2F4B2E', angle: 120 },
surfaceGradient: { enabled: true, start: '#FF161F18', end: '#FF101611', angle: 90 },
cardGradient: { enabled: true, start: '#FF7DD36B', end: '#FFC6E886', angle: 45 },
strokeGradient: { enabled: true, start: '#FF9BE07C', end: '#FF6BC26A', angle: 45 },
},
]

View file

@ -0,0 +1,54 @@
import { act, render, screen } from '@testing-library/react'
import { afterEach, expect, test, vi } from 'vitest'
import { ActiveThemeProvider, useActiveTheme } from '../useActiveTheme'
function Probe() {
const { theme, setTheme, userPicked } = useActiveTheme()
return (
<>
<p data-testid="id">{theme.id}</p>
<p data-testid="picked">{String(userPicked)}</p>
<button onClick={() => setTheme('nitro', 'user')}>user</button>
<button onClick={() => setTheme('sunset', 'demo')}>demo</button>
</>
)
}
afterEach(() => {
window.localStorage.clear()
document.documentElement.removeAttribute('style')
})
test('a user pick persists to lv_theme, re-accents the page and locks out demo switches', () => {
render(<ActiveThemeProvider><Probe /></ActiveThemeProvider>)
expect(screen.getByTestId('id')).toHaveTextContent('classic')
act(() => screen.getByText('demo').click())
expect(screen.getByTestId('id')).toHaveTextContent('sunset')
expect(window.localStorage.getItem('lv_theme')).toBeNull()
act(() => screen.getByText('user').click())
expect(screen.getByTestId('id')).toHaveTextContent('nitro')
expect(window.localStorage.getItem('lv_theme')).toBe('nitro')
expect(document.documentElement.style.getPropertyValue('--color-ice')).toBe('rgb(122, 92, 255)')
act(() => screen.getByText('demo').click())
expect(screen.getByTestId('id')).toHaveTextContent('nitro')
})
test('restores the stored theme and survives blocked storage', () => {
window.localStorage.setItem('lv_theme', 'mint')
const { unmount } = render(<ActiveThemeProvider><Probe /></ActiveThemeProvider>)
expect(screen.getByTestId('id')).toHaveTextContent('mint')
expect(screen.getByTestId('picked')).toHaveTextContent('true')
unmount()
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked')
})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked')
})
render(<ActiveThemeProvider><Probe /></ActiveThemeProvider>)
expect(screen.getByTestId('id')).toHaveTextContent('classic')
act(() => screen.getByText('user').click())
expect(screen.getByTestId('id')).toHaveTextContent('nitro')
vi.restoreAllMocks()
})

View file

@ -0,0 +1,39 @@
import { expect, test } from 'vitest'
import { PRESETS } from '../presets.generated'
import { argbToCss, parseArgb } from '../types'
const javaSources = import.meta.glob<string>(
'../../../../../mod/src/main/java/dev/loki/lovisual/features/theme/presets/ThemePresets*.java',
{ query: '?raw', import: 'default', eager: true },
)
test('generated presets include Classic with the mod accent', () => {
const classic = PRESETS.find((p) => p.id === 'classic')
expect(classic?.name).toBe('Classic')
expect(classic?.theme.accent).toBe('#FF5CC8E7')
expect(classic?.theme.windowBg).toBe('#F012191B')
// 0x334046 in Java is a 6-digit literal: alpha 00.
expect(classic?.theme.cardDisabled).toBe('#00334046')
})
test('one preset per ThemeEntry constant in the mod sources', () => {
const sources = Object.values(javaSources)
expect(sources).toHaveLength(3)
const count = sources.reduce((n, src) => n + (src.match(/static\s+final\s+ThemeEntry\s+\w+\s*=/g)?.length ?? 0), 0)
expect(count).toBeGreaterThan(0)
expect(PRESETS).toHaveLength(count)
expect(new Set(PRESETS.map((p) => p.id)).size).toBe(count)
})
test('derived header gradient matches ThemesBlending.subtleHeaderGradient', () => {
const classic = PRESETS.find((p) => p.id === 'classic')
// 0xF0142226 lightened by 8% and darkened by 6%, as the mod computes it.
expect(classic?.headerGradient).toEqual({ enabled: true, start: '#F0273437', end: '#F0132024', angle: 90 })
})
test('argbToCss converts packed ARGB', () => {
expect(argbToCss('#F012191B')).toBe('rgba(18, 25, 27, 0.941)')
expect(argbToCss('#FF5CC8E7')).toBe('rgba(92, 200, 231, 1)')
expect(argbToCss('#00334046')).toBe('rgba(51, 64, 70, 0)')
expect(parseArgb('#5CC8E7')).toEqual({ a: 255, r: 92, g: 200, b: 231 })
})

View file

@ -0,0 +1,55 @@
/** The mod's 12 palette slots (`Themes.Theme` record), each as `#AARRGGBB`. */
export type ThemeColors = {
windowBg: string
windowHeader: string
windowStroke: string
surface: string
surfaceHover: string
cardEnabled: string
cardDisabled: string
textPrimary: string
textMuted: string
accent: string
accentSoft: string
strokeSoft: string
}
/** Mirrors `Themes.GradientSpec`: when disabled the slot renders flat with `start`. */
export type GradientSpec = { enabled: boolean; start: string; end: string; angle: number }
/** Mirrors `Themes.ThemeEntry`. */
export type ThemeEntry = {
id: string
name: string
builtin: boolean
theme: ThemeColors
windowGradient: GradientSpec
headerGradient: GradientSpec
surfaceGradient: GradientSpec
cardGradient: GradientSpec
strokeGradient: GradientSpec
}
type Rgba = { r: number; g: number; b: number; a: number }
/** Parses `#AARRGGBB` (the mod's packed ARGB) or `#RRGGBB` (opaque). */
export function parseArgb(hex: string): Rgba {
const clean = hex.replace(/^#/, '')
const full = clean.length === 6 ? `FF${clean}` : clean.padStart(8, '0')
const n = Number.parseInt(full, 16) >>> 0
return { a: (n >>> 24) & 0xff, r: (n >>> 16) & 0xff, g: (n >>> 8) & 0xff, b: n & 0xff }
}
const alpha = (a: number) => String(Number((a / 255).toFixed(3)))
/** `#F012191B` → `rgba(18, 25, 27, 0.941)`. */
export function argbToCss(hex: string): string {
const { r, g, b, a } = parseArgb(hex)
return `rgba(${r}, ${g}, ${b}, ${alpha(a)})`
}
/** Same color with its alpha replaced (0–1) — for glows built from a palette slot. */
export function withAlpha(hex: string, a: number): string {
const { r, g, b } = parseArgb(hex)
return `rgba(${r}, ${g}, ${b}, ${Number(a.toFixed(3))})`
}

View file

@ -0,0 +1,72 @@
import { createContext, createElement, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { PRESETS } from './presets.generated'
import { parseArgb, type ThemeEntry, withAlpha } from './types'
const STORAGE_KEY = 'lv_theme'
const FALLBACK = PRESETS[0]
type ActiveTheme = {
theme: ThemeEntry
/** True once the visitor picked a theme themselves — demos must stop switching it then. */
userPicked: boolean
/** `source: 'user'` persists the choice; `'demo'` is a transient showcase switch. */
setTheme: (id: string, source?: 'user' | 'demo') => void
}
const ActiveThemeContext = createContext<ActiveTheme | null>(null)
function readStored(): string | null {
try {
return window.localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
}
function store(id: string) {
try {
window.localStorage.setItem(STORAGE_KEY, id)
} catch {
// Storage can be blocked (private mode, quota); the theme still applies for this visit.
}
}
const findPreset = (id: string | null) => PRESETS.find((p) => p.id === id)
/** Opaque accent for page chrome (buttons, links, focus rings). */
function pageAccent(entry: ThemeEntry): string {
const { r, g, b } = parseArgb(entry.theme.accent)
return `rgb(${r}, ${g}, ${b})`
}
export function ActiveThemeProvider({ children, applyToPage = true }: { children: ReactNode; applyToPage?: boolean }) {
const [state, setState] = useState(() => {
const stored = findPreset(readStored())
return { theme: stored ?? FALLBACK, userPicked: Boolean(stored) }
})
const setTheme = useCallback((id: string, source: 'user' | 'demo' = 'user') => {
const next = findPreset(id)
if (!next) return
if (source === 'user') store(id)
setState((prev) => {
if (source === 'demo' && prev.userPicked) return prev
return { theme: next, userPicked: prev.userPicked || source === 'user' }
})
}, [])
useEffect(() => {
if (!applyToPage) return
const root = document.documentElement.style
root.setProperty('--color-ice', pageAccent(state.theme))
root.setProperty('--page-accent-glow', withAlpha(state.theme.theme.accent, 0.35))
}, [state.theme, applyToPage])
const value = useMemo(() => ({ ...state, setTheme }), [state, setTheme])
return createElement(ActiveThemeContext.Provider, { value }, children)
}
/** The active ClickGui theme. Outside a provider it falls back to Classic with a no-op setter. */
export function useActiveTheme(): ActiveTheme {
return useContext(ActiveThemeContext) ?? { theme: FALLBACK, userPicked: false, setTheme: () => {} }
}

View file

@ -1,9 +1,7 @@
@import "tailwindcss";
@import "@fontsource-variable/inter";
@import "@fontsource/comfortaa/400.css";
@import "@fontsource/comfortaa/700.css";
@import "@fontsource/iosevka/400.css";
@import "@fontsource/iosevka/700.css";
@import "@fontsource-variable/unbounded";
@import "@fontsource-variable/onest";
@import "@fontsource-variable/jetbrains-mono";
@theme {
--color-abyss: #0d1416;
@ -14,22 +12,38 @@
--color-ice: #5cc8e7;
--color-ember: #e8835a;
--font-display: "Comfortaa", ui-rounded, system-ui, sans-serif;
--font-sans: "Inter Variable", system-ui, sans-serif;
--font-code: "Iosevka", ui-monospace, monospace;
--font-display: "Unbounded Variable", system-ui, sans-serif;
--font-sans: "Onest Variable", system-ui, sans-serif;
--font-code: "JetBrains Mono Variable", ui-monospace, monospace;
--text-hero: 3.8125rem;
--text-hero--line-height: 1.05;
--radius-panel: 10px;
}
/* The page accent follows the active ClickGui theme (useActiveTheme sets it on :root);
registering it as a color lets theme switches cross-fade instead of snapping. */
@property --color-ice {
syntax: "<color>";
inherits: true;
initial-value: #5cc8e7;
}
@property --page-accent-glow {
syntax: "<color>";
inherits: true;
initial-value: rgb(92 200 231 / 0.35);
}
@layer base {
html {
color-scheme: dark;
transition: --color-ice 400ms ease, --page-accent-glow 400ms ease;
}
body {
@apply bg-abyss text-snow font-sans antialiased;
min-height: 100dvh;
/* Full-bleed sections span 100vw; never let that turn into a horizontal scrollbar. */
overflow-x: clip;
}
h1,
h2,

View file

@ -1,7 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { initI18n } from './app/i18n'
import App from './app/App.tsx'
initI18n()
createRoot(document.getElementById('root')!).render(
<StrictMode>

View file

@ -0,0 +1,76 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { DownloadButton } from '../../features/download/DownloadButton'
import { ReleaseNotes } from '../../features/download/ReleaseNotes'
import { GITHUB_REPO, fetchReleases, releaseSize } from '../../features/download/api'
// Requirement versions mirror mod/gradle.properties (the mod's build source of truth).
const REQUIREMENTS = [
{ key: 'reqMc', value: '26.2' },
{ key: 'reqLoader', value: '0.19.4+' },
{ key: 'reqApi', value: '0.152.2+26.2' },
] as const
export default function DownloadPage() {
const { t } = useTranslation('download')
const { data } = useQuery({ queryKey: ['releases'], queryFn: fetchReleases, staleTime: 10 * 60 * 1000 })
const latest = data?.[0]
const steps = ['1', '2', '3'] as const
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-12 px-6 py-16">
<header className="flex flex-col gap-4">
<h1 className="font-display text-4xl font-black uppercase text-snow">{t('page.title')}</h1>
<p className="text-frost">{t('page.lead')}</p>
<DownloadButton
size="lg"
className="mt-2"
version={latest ? latest.tag_name.replace(/^v/, '') : undefined}
bytes={latest ? releaseSize(latest) : undefined}
/>
</header>
<section className="flex flex-col gap-3">
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('page.reqTitle')}</h2>
<ul className="grid gap-2 sm:grid-cols-3">
{REQUIREMENTS.map((r) => (
<li key={r.key} className="rounded-panel border border-white/10 bg-black/30 p-4">
<span className="block text-xs text-frost">{t(`page.${r.key}`)}</span>
<span className="font-code text-sm text-snow">{r.value}</span>
</li>
))}
</ul>
<p className="text-sm text-frost">
<strong className="text-snow">{t('page.reqOptionalTitle')}</strong> {t('page.reqOptional')}
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('page.installTitle')}</h2>
<ol className="flex flex-col gap-2">
{steps.map((s) => (
<li key={s} className="flex items-baseline gap-3">
<span className="lv-step-num">{s}</span>
<span className="text-snow/90">{t(`page.step${s}`)}</span>
</li>
))}
</ol>
</section>
<section className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('notes.title')}</h2>
<a
href={`https://github.com/${GITHUB_REPO}/releases`}
target="_blank"
rel="noreferrer"
className="text-sm text-ice underline-offset-4 hover:underline"
>
{t('notes.allReleases')}
</a>
</div>
<ReleaseNotes />
</section>
</div>
)
}

View file

@ -0,0 +1,12 @@
import { lazy, Suspense } from 'react'
// Loaded on demand so react-markdown stays out of the landing bundle.
const DownloadPage = lazy(() => import('./DownloadPage'))
export default function DownloadRoute() {
return (
<Suspense fallback={<div className="px-6 py-16 text-frost">…</div>}>
<DownloadPage />
</Suspense>
)
}

View file

@ -0,0 +1,23 @@
import { CommandHero } from '../../features/landing/CommandHero'
import { HowItWorks } from '../../features/landing/HowItWorks'
import { FaqDownload } from '../../features/landing/sections/FaqDownload'
import { Hero } from '../../features/landing/sections/Hero'
import { HudPlayground } from '../../features/landing/sections/HudPlayground'
import { ModuleWall } from '../../features/landing/sections/ModuleWall'
import { ShowcaseTeaser } from '../../features/landing/sections/ShowcaseTeaser'
import { ThemeStrip } from '../../features/landing/sections/ThemeStrip'
export default function HomePage() {
return (
<>
<Hero />
<ThemeStrip />
<HudPlayground />
<CommandHero />
<HowItWorks />
<ModuleWall />
<ShowcaseTeaser />
<FaqDownload />
</>
)
}

View file

@ -1,9 +1,11 @@
import { useTranslation } from 'react-i18next'
import { LoginForm } from '../../features/auth/forms/LoginForm'
export default function LoginPage() {
const { t } = useTranslation('auth')
return (
<section className="flex flex-col gap-6 py-12">
<h1 className="text-3xl">Вход</h1>
<h1 className="text-3xl">{t('login.title')}</h1>
<LoginForm />
</section>
)

View file

@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
export default function NotFoundPage() {
const { t } = useTranslation('common')
return (
<section className="flex flex-col gap-4 py-16">
<h1 className="text-[2.4375rem] leading-tight">{t('notFound.title')}</h1>
<p className="text-frost">
{t('notFound.body')}{' '}
<Link to="/" className="text-ice underline-offset-4 hover:underline">
{t('notFound.home')}
</Link>
</p>
</section>
)
}

View file

@ -1,12 +1,12 @@
import { useTranslation } from 'react-i18next'
import { RegisterForm } from '../../features/auth/forms/RegisterForm'
export default function RegisterPage() {
const { t } = useTranslation('auth')
return (
<section className="flex flex-col gap-6 py-12">
<h1 className="text-3xl">Новый аккаунт</h1>
<p className="max-w-sm text-frost">
Аккаунт нужен для облачных конфигов и привязки игры. Пароль в мод вводить не придётся.
</p>
<h1 className="text-3xl">{t('register.title')}</h1>
<p className="max-w-sm text-frost">{t('register.subtitle')}</p>
<RegisterForm />
</section>
)

View file

@ -0,0 +1,30 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ThemeEditor } from '../../features/themes/editor/ThemeEditor'
import { fromShareHash } from '../../features/themes/editor/codec'
import { PRESETS } from '../../features/themes/presets.generated'
import '../../features/themes/editor/editor.css'
/** `/themes` — pick a preset (or load one from a `#t=` share link) and edit it live. */
export default function ThemesPage() {
const { t } = useTranslation('themes')
const initial = useMemo(() => {
const shared = /#t=([^&]+)/.exec(window.location.hash)?.[1]
if (shared) {
const loaded = fromShareHash(shared)
if (!('error' in loaded)) return loaded
}
return PRESETS[0]
}, [])
return (
<div className="mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-6 py-12">
<header className="flex flex-col gap-2">
<h1 className="font-display text-4xl font-black uppercase text-snow">{t('editor.title')}</h1>
<p className="text-frost">{t('editor.lead')}</p>
</header>
<ThemeEditor initial={initial} />
</div>
)
}

View file

@ -0,0 +1,12 @@
import { lazy, Suspense } from 'react'
// Code-split so the editor and its CSS stay out of the landing bundle.
const ThemesPage = lazy(() => import('./ThemesPage'))
export default function ThemesRoute() {
return (
<Suspense fallback={<div className="px-6 py-16 text-frost">…</div>}>
<ThemesPage />
</Suspense>
)
}

View file

@ -68,7 +68,7 @@ export function createApiClient(
headers.set('content-type', 'application/json')
body = JSON.stringify(options.json)
}
if (accessToken) headers.set('authorization', `Bearer ${accessToken}`)
if (accessToken && !path.startsWith('/auth/')) headers.set('authorization', `Bearer ${accessToken}`)
return fetchImpl(`${baseUrl}${path}`, {
method: options.method ?? 'GET',
headers,

View file

@ -1,22 +1,24 @@
import type { TFunction } from 'i18next'
import { ApiError } from './client'
const BY_STATUS: Record<number, string> = {
400: 'Проверь введённые данные.',
401: 'Нужно войти в аккаунт.',
403: 'Недостаточно прав.',
404: 'Не найдено.',
409: 'Уже существует.',
}
const BY_STATUS = {
400: 'errors.badRequest',
401: 'errors.unauthorized',
403: 'errors.forbidden',
404: 'errors.notFound',
409: 'errors.conflict',
} as const
export function describeError(err: unknown, overrides: Partial<Record<number, string>> = {}): string {
export function describeError(t: TFunction<'common'>, err: unknown, overrides: Partial<Record<number, string>> = {}): string {
if (err instanceof ApiError) {
if (err.status === 429) {
return err.retryAfter
? `Слишком много попыток. Подожди ${err.retryAfter} с.`
: 'Слишком много попыток. Подожди немного.'
? t('errors.rateLimited', { seconds: err.retryAfter })
: t('errors.rateLimitedGeneric')
}
if (err.status >= 500) return 'Сервер не ответил. Попробуй через минуту.'
return overrides[err.status] ?? BY_STATUS[err.status] ?? 'Проверь введённые данные.'
if (err.status >= 500) return t('errors.serverError')
const key = BY_STATUS[err.status as keyof typeof BY_STATUS]
return overrides[err.status] ?? (key ? t(key) : t('errors.badRequest'))
}
return 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.'
return t('errors.network')
}

View file

@ -1,8 +1,11 @@
import { expect, test } from 'vitest'
import { initI18n } from '../../../app/i18n'
import { ApiError, createApiClient } from '../client'
import { describeError } from '../errors'
import { json, mockFetch } from '../../../test/fetch'
const t = initI18n('ru').getFixedT('ru', 'common')
test('sends json with bearer token and cookies', async () => {
const fetch = mockFetch({ 'PUT /configs/1': () => json({ ok: true }) })
const api = createApiClient()
@ -62,6 +65,15 @@ test('auth endpoints never trigger refresh', async () => {
expect(fetch).toHaveBeenCalledTimes(1)
})
test('never sends a stale bearer token on /auth/* requests', async () => {
const fetch = mockFetch({ 'POST /auth/logout': () => new Response(null, { status: 204 }) })
const api = createApiClient()
api.setAccessToken('stale')
await api.request('/auth/logout', { method: 'POST' })
const [, init] = fetch.mock.calls[0]
expect(new Headers(init?.headers).get('authorization')).toBeNull()
})
test('429 carries retry-after; 204 resolves undefined', async () => {
mockFetch({
'POST /auth/login': () => json({ error: 'too many requests' }, 429, { 'retry-after': '42' }),
@ -73,10 +85,10 @@ test('429 carries retry-after; 204 resolves undefined', async () => {
})
test('describeError covers 429, network failure and status defaults', () => {
expect(describeError(new ApiError(429, 'x', 42))).toBe('Слишком много попыток. Подожди 42 с.')
expect(describeError(new ApiError(429, 'x', null))).toBe('Слишком много попыток. Подожди немного.')
expect(describeError(new TypeError('Failed to fetch'))).toBe(
expect(describeError(t, new ApiError(429, 'x', 42))).toBe('Слишком много попыток. Подожди 42 с.')
expect(describeError(t, new ApiError(429, 'x', null))).toBe('Слишком много попыток. Подожди немного.')
expect(describeError(t, new TypeError('Failed to fetch'))).toBe(
'Нет связи с сервером. Проверь интернет и попробуй ещё раз.',
)
expect(describeError(new ApiError(404, 'x'))).toBe('Не найдено.')
expect(describeError(t, new ApiError(404, 'x'))).toBe('Не найдено.')
})

View file

@ -0,0 +1,24 @@
import { useTranslation } from 'react-i18next'
const LANGS = ['ru', 'en'] as const
export function LanguageSwitch() {
const { i18n, t } = useTranslation()
const current = i18n.language
return (
<div className="flex items-center gap-1 text-sm text-frost" role="group" aria-label="Language">
{LANGS.map((lng) => (
<button
key={lng}
type="button"
aria-pressed={current === lng}
onClick={() => i18n.changeLanguage(lng)}
className={`rounded-md px-2 py-1 transition-colors ${current === lng ? 'text-snow' : 'hover:text-snow'}`}
>
{t(`languageSwitch.${lng}`)}
</button>
))}
</div>
)
}

View file

@ -0,0 +1,52 @@
import type { commonRu, Translation } from './common.ru'
export const commonEn = {
skipToContent: 'Skip to content',
nav: {
mainNav: 'Main navigation',
showcase: 'Showcase',
myConfigs: 'My configs',
linkGame: 'Link game',
menu: 'Menu',
login: 'Log in',
},
notFound: {
title: 'Page not found',
body: 'This link may be outdated.',
home: 'Go home',
},
shareCode: {
copy: 'Copy',
copied: 'Copied',
},
languageSwitch: {
ru: 'RU',
en: 'EN',
},
footer: {
about: 'A free visuals mod for Minecraft 26.2 on Fabric.',
sectionProduct: 'Product',
sectionProject: 'Project',
github: 'GitHub',
download: 'Download',
legalTitle: 'Legal',
disclaimer: 'Not affiliated with Mojang or Microsoft.',
},
time: {
days_one: '{{count}} day',
days_few: '{{count}} days',
days_many: '{{count}} days',
days_other: '{{count}} days',
},
errors: {
badRequest: 'Check what you entered.',
unauthorized: 'You need to log in.',
forbidden: 'Not enough permissions.',
notFound: 'Not found.',
conflict: 'Already exists.',
rateLimited: 'Too many attempts. Wait {{seconds}}s.',
rateLimitedGeneric: 'Too many attempts. Wait a bit.',
serverError: "The server didn't respond. Try again in a minute.",
network: 'No connection to the server. Check your internet and try again.',
},
} satisfies Translation<typeof commonRu>

View file

@ -0,0 +1,54 @@
export const commonRu = {
skipToContent: 'Перейти к содержимому',
nav: {
mainNav: 'Основная навигация',
showcase: 'Витрина',
myConfigs: 'Мои конфиги',
linkGame: 'Привязать игру',
menu: 'Меню',
login: 'Войти',
},
notFound: {
title: 'Такой страницы нет',
body: 'Возможно, ссылка устарела.',
home: 'На главную',
},
shareCode: {
copy: 'Скопировать',
copied: 'Скопировано',
},
languageSwitch: {
ru: 'RU',
en: 'EN',
},
footer: {
about: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric.',
sectionProduct: 'Продукт',
sectionProject: 'Проект',
github: 'GitHub',
download: 'Скачать',
legalTitle: 'Правовое',
disclaimer: 'Не связано с Mojang или Microsoft.',
},
time: {
days_one: '{{count}} день',
days_few: '{{count}} дня',
days_many: '{{count}} дней',
days_other: '{{count}} дня',
},
errors: {
badRequest: 'Проверь введённые данные.',
unauthorized: 'Нужно войти в аккаунт.',
forbidden: 'Недостаточно прав.',
notFound: 'Не найдено.',
conflict: 'Уже существует.',
rateLimited: 'Слишком много попыток. Подожди {{seconds}} с.',
rateLimitedGeneric: 'Слишком много попыток. Подожди немного.',
serverError: 'Сервер не ответил. Попробуй через минуту.',
network: 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.',
},
} as const
export type Translation<T> = {
[K in keyof T]: T[K] extends string ? string : Translation<T[K]>
}

View file

@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { I18nextProvider, useTranslation } from 'react-i18next'
import { expect, test } from 'vitest'
import { initI18n } from '../../../app/i18n'
import { LanguageSwitch } from '../LanguageSwitch'
function Probe() {
const { t } = useTranslation('auth')
return <p>{t('login.submit')}</p>
}
test('switching language re-renders strings and sets <html lang>', async () => {
const user = userEvent.setup()
const i18n = initI18n('ru')
render(
<I18nextProvider i18n={i18n}>
<Probe />
<LanguageSwitch />
</I18nextProvider>,
)
expect(screen.getByText('Войти')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'EN' }))
expect(screen.getByText('Log in')).toBeInTheDocument()
expect(document.documentElement.lang).toBe('en')
})
test('russian plurals', () => {
const i18n = initI18n('ru')
expect(i18n.t('common:time.days', { count: 1 })).toBe('1 день')
expect(i18n.t('common:time.days', { count: 3 })).toBe('3 дня')
expect(i18n.t('common:time.days', { count: 5 })).toBe('5 дней')
})

View file

@ -0,0 +1,23 @@
import { useTranslation } from 'react-i18next'
import { Outlet } from 'react-router'
import { Footer } from './Footer'
import { TopBar } from './TopBar'
export function AppShell() {
const { t } = useTranslation('common')
return (
<>
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-2 focus:z-10 focus:rounded-md focus:bg-slate focus:px-3 focus:py-2"
>
{t('skipToContent')}
</a>
<TopBar />
<main id="main" className="mx-auto w-full max-w-[1120px] px-4 sm:px-6">
<Outlet />
</main>
<Footer />
</>
)
}

View file

@ -0,0 +1,49 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { DOWNLOAD_URL, GITHUB_REPO } from '../../features/download/api'
import { LanguageSwitch } from '../i18n/LanguageSwitch'
export function Footer() {
const { t } = useTranslation('common')
const product = [
{ to: '/showcase', label: t('nav.showcase') },
{ to: '/configs', label: t('nav.myConfigs') },
{ to: '/themes', label: 'Themes' },
]
const project = [
{ href: `https://github.com/${GITHUB_REPO}`, label: t('footer.github') },
{ href: DOWNLOAD_URL, label: t('footer.download') },
]
return (
<footer className="mt-8 border-t border-shoal bg-slate">
<div className="mx-auto grid w-full max-w-[1120px] gap-10 px-4 py-12 sm:px-6 md:grid-cols-[1.4fr_1fr_1fr]">
<div className="flex min-w-0 flex-col gap-3">
<Link to="/" className="font-display text-xl font-bold text-snow">LoVisual</Link>
<p className="max-w-[38ch] text-sm text-frost">{t('footer.about')}</p>
<LanguageSwitch />
</div>
<nav aria-label={t('footer.sectionProduct')} className="flex min-w-0 flex-col gap-2">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('footer.sectionProduct')}</span>
{product.map((i) => (
<Link key={i.to} to={i.to} className="text-sm text-frost hover:text-snow">{i.label}</Link>
))}
</nav>
<nav aria-label={t('footer.sectionProject')} className="flex min-w-0 flex-col gap-2">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('footer.sectionProject')}</span>
{project.map((i) => (
<a key={i.label} href={i.href} target="_blank" rel="noreferrer" className="text-sm text-frost hover:text-snow">
{i.label}
</a>
))}
</nav>
</div>
<div className="border-t border-shoal">
<p className="mx-auto w-full max-w-[1120px] px-4 py-5 text-xs text-frost sm:px-6">{t('footer.disclaimer')}</p>
</div>
</footer>
)
}

View file

@ -0,0 +1,94 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, NavLink } from 'react-router'
import { useSession } from '../../features/auth/session'
import { LanguageSwitch } from '../i18n/LanguageSwitch'
import type { Me } from '../types'
import { ButtonLink } from '../ui/Button'
export function TopBar() {
const { t } = useTranslation(['common', 'download'])
const [open, setOpen] = useState(false)
const nav = [
{ to: '/showcase', label: t('nav.showcase') },
{ to: '/configs', label: t('nav.myConfigs') },
{ to: '/link', label: t('nav.linkGame') },
]
return (
<header className="border-b border-shoal bg-slate">
<div className="mx-auto flex w-full max-w-[1120px] flex-wrap items-center gap-x-6 px-4 sm:px-6">
<Link to="/" className="py-4 font-display text-xl font-bold text-snow">
LoVisual
</Link>
<nav
id="site-nav"
aria-label={t('nav.mainNav')}
className={`${open ? 'flex' : 'hidden'} order-last w-full flex-col gap-1 pb-3 sm:order-none sm:flex sm:w-auto sm:flex-row sm:gap-6 sm:pb-0`}
>
{nav.map((item) => (
<NavLink
key={item.to}
to={item.to}
onClick={() => setOpen(false)}
className={({ isActive }) =>
`py-2 text-sm transition-colors ${isActive ? 'text-snow' : 'text-frost hover:text-snow'}`
}
>
{item.label}
</NavLink>
))}
</nav>
<div className="ml-auto flex items-center gap-3">
<ButtonLink to="/download" variant="primary">
{t('download:nav')}
</ButtonLink>
<LanguageSwitch />
<button
type="button"
aria-expanded={open}
aria-controls="site-nav"
onClick={() => setOpen((v) => !v)}
className="rounded-md px-2 py-2 text-sm text-frost hover:text-snow sm:hidden"
>
{t('nav.menu')}
</button>
<AccountButton />
</div>
</div>
</header>
)
}
function AccountButton() {
const { t } = useTranslation('common')
const { status, me } = useSession()
if (status === 'loading') return null
if (!me) {
return (
<ButtonLink to="/login" variant="quiet">
{t('nav.login')}
</ButtonLink>
)
}
return (
<Link to="/account" className="flex min-w-0 items-center gap-2 py-2 text-sm text-snow hover:text-ice">
<Avatar me={me} />
<span className="max-w-[8rem] truncate sm:max-w-[12rem]">{me.display_nick}</span>
</Link>
)
}
function Avatar({ me }: { me: Me }) {
if (me.avatar_url) {
return <img src={me.avatar_url} alt="" className="size-7 shrink-0 rounded-full object-cover" />
}
return (
<span
aria-hidden="true"
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-shoal font-display text-xs font-bold text-snow"
>
{me.display_nick.charAt(0).toUpperCase()}
</span>
)
}

View file

@ -0,0 +1,47 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { routes } from '../../../app/routes'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
test('anonymous visitor sees nav and a login link', async () => {
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
renderApp(routes, '/')
expect(await screen.findByRole('link', { name: 'Войти' })).toHaveAttribute('href', '/login')
expect(screen.getAllByRole('link', { name: 'Витрина' }).length).toBeGreaterThan(0)
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Визуалы, которые видно')
})
test('signed-in visitor sees their nick instead of login', async () => {
mockFetch({
'POST /auth/refresh': () => json({ access_token: 't' }),
'GET /me': () => json({ id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '' }),
})
renderApp(routes, '/')
expect(await screen.findByRole('link', { name: /Rider/ })).toHaveAttribute('href', '/account')
expect(screen.queryByRole('link', { name: 'Войти' })).not.toBeInTheDocument()
})
test('unknown path shows not found', async () => {
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
renderApp(routes, '/definitely-not-here')
expect(await screen.findByRole('heading', { name: 'Такой страницы нет' })).toBeInTheDocument()
})
test('menu disclosure toggles aria-expanded', async () => {
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
renderApp(routes, '/')
const toggle = await screen.findByRole('button', { name: 'Меню' })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
await userEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
})
test('footer carries the github link and the disclaimer', async () => {
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
renderApp(routes, '/')
expect(await screen.findByText('Не связано с Mojang или Microsoft.')).toBeInTheDocument()
const gh = screen.getByRole('link', { name: 'GitHub' })
expect(gh.getAttribute('href')).toMatch(/^https:\/\/github\.com\//)
})

View file

@ -0,0 +1,7 @@
/**
* True when the visitor asked for reduced motion. Unknown support (no matchMedia, e.g. jsdom)
* counts as reduced: the safe default, and it keeps tests deterministic.
*/
export function prefersReducedMotion(): boolean {
return typeof window.matchMedia !== 'function' || window.matchMedia('(prefers-reduced-motion: reduce)').matches
}

View file

@ -1,7 +1,11 @@
import type { ButtonHTMLAttributes } from 'react'
import { Link, type LinkProps } from 'react-router'
type Variant = 'primary' | 'quiet' | 'danger'
const BASE =
'inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold transition-colors'
const VARIANTS: Record<Variant, string> = {
primary: 'bg-ice text-abyss hover:bg-ice/90',
quiet: 'bg-transparent text-snow border border-shoal hover:border-frost',
@ -21,8 +25,13 @@ export function Button({
type={type}
disabled={disabled || busy}
aria-busy={busy || undefined}
className={`inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${VARIANTS[variant]} ${className}`}
className={`${BASE} disabled:cursor-not-allowed disabled:opacity-60 ${VARIANTS[variant]} ${className}`}
{...rest}
/>
)
}
/** A router link that looks like a button — for navigation CTAs. */
export function ButtonLink({ variant = 'primary', className = '', ...rest }: LinkProps & { variant?: Variant }) {
return <Link className={`${BASE} ${VARIANTS[variant]} ${className}`} {...rest} />
}

View file

@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from './Button'
export function ShareCode({ code, label }: { code: string; label?: string }) {
const { t } = useTranslation('common')
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!copied) return
@ -19,10 +21,10 @@ export function ShareCode({ code, label }: { code: string; label?: string }) {
{label ? <span className="text-sm text-frost">{label}</span> : null}
<code className="font-code text-lg tracking-widest text-ice">{code}</code>
<Button variant="quiet" onClick={copy}>
Скопировать
{t('shareCode.copy')}
</Button>
<span aria-live="polite" className="text-sm text-frost">
{copied ? 'Скопировано' : ''}
{copied ? t('shareCode.copied') : ''}
</span>
</div>
)

View file

@ -1,6 +1,12 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
import { initI18n } from '../app/i18n'
initI18n('ru')
afterEach(() => cleanup())
afterEach(() => vi.unstubAllGlobals())
// jsdom has no canvas backend; effects treat a null context as "nothing to draw".
HTMLCanvasElement.prototype.getContext = (() => null) as typeof HTMLCanvasElement.prototype.getContext

View file

@ -1,9 +0,0 @@
import { render, screen } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { expect, test } from 'vitest'
test('router renders a route', () => {
const router = createMemoryRouter([{ path: '/', element: <h1>LoVisual</h1> }])
render(<RouterProvider router={router} />)
expect(screen.getByRole('heading', { name: 'LoVisual' })).toBeInTheDocument()
})