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,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: () => {} }
}