chore(history): squash 16 commit(s) from 2026-09-26

- chore(backend): sync Cargo.lock with gateway dev-dependency
- feat(frontend): ClickGui replica matching the in-game GUI — category panels, Modules/Settings switch, theme cards
- feat(frontend): floating glass top bar in the mod's UI language
- fix(frontend): soft night backdrop for the ClickGui, editor that fits one screen
- chore(frontend): replace em dashes in UI copy with lighter separators
- refactor(frontend): split landing sections and theme editor into subfolders (4-files-per-folder rule)
- feat(frontend): login and registration in the mod's glass panel style
- feat(frontend): link the game with a device code
- feat(frontend): account page with avatar upload and linked games
- feat(frontend): my configs with share codes and publishing
- feat(frontend): public showcase listing and detail pages
- chore(frontend): drop em dashes from visible copy
- feat(frontend): atmospheric backdrop, glass download block and ClickGui-style FAQ on the landing
- feat(frontend): Konami code, DevTools console greeting and ASCII 404 block
- feat: public player profiles at /u/:id and author link in the showcase
- fix(frontend): stop the theme editor overflowing horizontally at 375px
This commit is contained in:
loki5512344 2026-09-26 15:26:37 +02:00
parent 7f4b532f99
commit ff2edc8eb6
116 changed files with 3534 additions and 1020 deletions

1
backend/Cargo.lock generated
View file

@ -1589,6 +1589,7 @@ dependencies = [
"common", "common",
"dotenvy", "dotenvy",
"governor", "governor",
"http-body-util",
"jsonwebtoken", "jsonwebtoken",
"reqwest", "reqwest",
"serde_json", "serde_json",

View file

@ -24,6 +24,7 @@ pub struct Listing {
pub copies_count: i32, pub copies_count: i32,
pub published_at: DateTime<Utc>, pub published_at: DateTime<Utc>,
pub author: Option<Author>, pub author: Option<Author>,
pub author_id: Option<Uuid>,
} }
fn to_listing(row: ListingRow, author: Option<Author>) -> Listing { fn to_listing(row: ListingRow, author: Option<Author>) -> Listing {
@ -34,6 +35,7 @@ fn to_listing(row: ListingRow, author: Option<Author>) -> Listing {
config_name: row.name, config_name: row.name,
copies_count: row.copies_count, copies_count: row.copies_count,
published_at: row.published_at, published_at: row.published_at,
author_id: author.as_ref().map(|_| row.account_id),
author, author,
} }
} }

View file

@ -9,7 +9,7 @@
name="description" name="description"
content="Облачные конфиги, коды для друзей и витрина настроек для мода LoVisual." content="Облачные конфиги, коды для друзей и витрина настроек для мода LoVisual."
/> />
<title>LoVisual — аккаунт и облачные конфиги</title> <title>LoVisual · аккаунт и облачные конфиги</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -1,12 +1,22 @@
import i18next, { type i18n } from 'i18next' import i18next, { type i18n } from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector' import LanguageDetector from 'i18next-browser-languagedetector'
import { initReactI18next } from 'react-i18next' import { initReactI18next } from 'react-i18next'
import { accountEn } from '../features/account/i18n/en'
import { accountRu } from '../features/account/i18n/ru'
import { authEn } from '../features/auth/i18n/en' import { authEn } from '../features/auth/i18n/en'
import { authRu } from '../features/auth/i18n/ru' import { authRu } from '../features/auth/i18n/ru'
import { clickguiEn } from '../features/clickgui/i18n/en' import { clickguiEn } from '../features/clickgui/i18n/en'
import { configsEn } from '../features/configs/i18n/en'
import { clickguiRu } from '../features/clickgui/i18n/ru' import { clickguiRu } from '../features/clickgui/i18n/ru'
import { configsRu } from '../features/configs/i18n/ru'
import { deviceLinkEn } from '../features/device-link/i18n/en'
import { deviceLinkRu } from '../features/device-link/i18n/ru'
import { downloadEn } from '../features/download/i18n/en' import { downloadEn } from '../features/download/i18n/en'
import { profileEn } from '../features/profile/i18n/en'
import { profileRu } from '../features/profile/i18n/ru'
import { showcaseEn } from '../features/showcase/i18n/en'
import { downloadRu } from '../features/download/i18n/ru' import { downloadRu } from '../features/download/i18n/ru'
import { showcaseRu } from '../features/showcase/i18n/ru'
import { landingEn } from '../features/landing/i18n/en' import { landingEn } from '../features/landing/i18n/en'
import { landingRu } from '../features/landing/i18n/ru' import { landingRu } from '../features/landing/i18n/ru'
import { themesEn } from '../features/themes/i18n/en' import { themesEn } from '../features/themes/i18n/en'
@ -23,12 +33,36 @@ export function initI18n(lng?: 'ru' | 'en'): i18n {
.init({ .init({
lng, lng,
resources: { resources: {
ru: { common: commonRu, auth: authRu, landing: landingRu, themes: themesRu, clickgui: clickguiRu, download: downloadRu }, ru: {
en: { common: commonEn, auth: authEn, landing: landingEn, themes: themesEn, clickgui: clickguiEn, download: downloadEn }, common: commonRu,
auth: authRu,
landing: landingRu,
themes: themesRu,
clickgui: clickguiRu,
download: downloadRu,
deviceLink: deviceLinkRu,
account: accountRu,
configs: configsRu,
showcase: showcaseRu,
profile: profileRu,
},
en: {
common: commonEn,
auth: authEn,
landing: landingEn,
themes: themesEn,
clickgui: clickguiEn,
download: downloadEn,
deviceLink: deviceLinkEn,
account: accountEn,
configs: configsEn,
showcase: showcaseEn,
profile: profileEn,
},
}, },
fallbackLng: 'ru', fallbackLng: 'ru',
supportedLngs: ['ru', 'en'], supportedLngs: ['ru', 'en'],
ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download'], ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download', 'deviceLink', 'account', 'configs', 'showcase', 'profile'],
defaultNS: 'common', defaultNS: 'common',
interpolation: { escapeValue: false }, interpolation: { escapeValue: false },
detection: { order: ['localStorage', 'navigator'], caches: ['localStorage'], lookupLocalStorage: 'lv_lang' }, detection: { order: ['localStorage', 'navigator'], caches: ['localStorage'], lookupLocalStorage: 'lv_lang' },

View file

@ -1,7 +1,12 @@
import type { accountRu } from '../features/account/i18n/ru'
import type { authRu } from '../features/auth/i18n/ru' import type { authRu } from '../features/auth/i18n/ru'
import type { clickguiRu } from '../features/clickgui/i18n/ru' import type { clickguiRu } from '../features/clickgui/i18n/ru'
import type { configsRu } from '../features/configs/i18n/ru'
import type { deviceLinkRu } from '../features/device-link/i18n/ru'
import type { downloadRu } from '../features/download/i18n/ru' import type { downloadRu } from '../features/download/i18n/ru'
import type { landingRu } from '../features/landing/i18n/ru' import type { landingRu } from '../features/landing/i18n/ru'
import type { profileRu } from '../features/profile/i18n/ru'
import type { showcaseRu } from '../features/showcase/i18n/ru'
import type { themesRu } from '../features/themes/i18n/ru' import type { themesRu } from '../features/themes/i18n/ru'
import type { commonRu } from '../shared/i18n/common.ru' import type { commonRu } from '../shared/i18n/common.ru'
@ -15,6 +20,11 @@ declare module 'i18next' {
themes: typeof themesRu themes: typeof themesRu
clickgui: typeof clickguiRu clickgui: typeof clickguiRu
download: typeof downloadRu download: typeof downloadRu
deviceLink: typeof deviceLinkRu
account: typeof accountRu
configs: typeof configsRu
showcase: typeof showcaseRu
profile: typeof profileRu
// Later tasks extend this with their own feature namespaces. // Later tasks extend this with their own feature namespaces.
} }
} }

View file

@ -4,10 +4,16 @@ import { AppShell } from '../shared/layout/AppShell'
import HomePage from '../pages/public/HomePage' import HomePage from '../pages/public/HomePage'
import LoginPage from '../pages/public/LoginPage' import LoginPage from '../pages/public/LoginPage'
import RegisterPage from '../pages/public/RegisterPage' import RegisterPage from '../pages/public/RegisterPage'
import ProfilePage from '../pages/public/ProfilePage'
import NotFoundPage from '../pages/public/NotFoundPage' import NotFoundPage from '../pages/public/NotFoundPage'
// Code-split: the download page pulls in react-markdown, so it loads on demand. // Code-split: the download page pulls in react-markdown, so it loads on demand.
import DownloadRoute from '../pages/download/DownloadRoute' import DownloadRoute from '../pages/download/DownloadRoute'
import AccountPage from '../pages/app/AccountPage'
import LinkPage from '../pages/app/LinkPage'
import ThemesRoute from '../pages/themes/ThemesRoute' import ThemesRoute from '../pages/themes/ThemesRoute'
import ConfigsPage from '../pages/app/ConfigsPage'
import ShowcaseRoute from '../pages/showcase/ShowcaseRoute'
import ListingRoute from '../pages/showcase/ListingRoute'
export const routes: RouteObject[] = [ export const routes: RouteObject[] = [
{ {
@ -18,7 +24,17 @@ export const routes: RouteObject[] = [
{ path: '/register', element: <RegisterPage /> }, { path: '/register', element: <RegisterPage /> },
{ path: '/download', element: <DownloadRoute /> }, { path: '/download', element: <DownloadRoute /> },
{ path: '/themes', element: <ThemesRoute /> }, { path: '/themes', element: <ThemesRoute /> },
{ element: <RequireAuth />, children: [] }, // Tasks 6–8 add /link, /account, /configs { path: '/u/:id', element: <ProfilePage /> },
{ path: '/showcase', element: <ShowcaseRoute /> },
{ path: '/showcase/:id', element: <ListingRoute /> },
{
element: <RequireAuth />,
children: [
{ path: '/link', element: <LinkPage /> },
{ path: '/account', element: <AccountPage /> },
{ path: '/configs', element: <ConfigsPage /> },
],
},
{ path: '*', element: <NotFoundPage /> }, { path: '*', element: <NotFoundPage /> },
], ],
}, },

View file

@ -0,0 +1,27 @@
import { api } from '../../shared/api/client'
export type DeviceLink = { id: string; linked_at: string; last_seen: string | null }
const ALLOWED_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp'])
const MAX_BYTES = 5 * 1024 * 1024
/** Client-side avatar checks so a bad file never reaches the server. */
export function checkAvatarFile(file: File): string | undefined {
if (!ALLOWED_TYPES.has(file.type)) return 'Подойдёт PNG, JPG или WebP.'
if (file.size > MAX_BYTES) return 'Файл больше 5 МБ.'
return undefined
}
export async function uploadAvatar(file: File): Promise<{ avatar_url: string }> {
const body = new FormData()
body.set('file', file)
return api.request('/avatars', { method: 'POST', body })
}
export async function listDevices(): Promise<DeviceLink[]> {
return api.request('/device/links')
}
export async function revokeDevice(id: string): Promise<void> {
await api.request(`/device/links/${id}`, { method: 'DELETE' })
}

View file

@ -0,0 +1,49 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useId, useState, type ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Notice } from '../../../shared/ui/Notice'
import { checkAvatarFile, uploadAvatar } from '../api'
/** A label-based file picker (keyboard reachable) that uploads and refreshes the cached profile. */
export function AvatarUpload() {
const { t } = useTranslation('account')
const id = useId()
const client = useQueryClient()
const [error, setError] = useState<string>()
const mutation = useMutation({
mutationFn: uploadAvatar,
onSuccess: () => client.invalidateQueries({ queryKey: ['me'] }),
})
function onChange(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
const problem = checkAvatarFile(file)
if (problem) {
setError(problem)
return
}
setError(undefined)
mutation.mutate(file)
}
return (
<div className="flex flex-col items-center gap-2">
<label
htmlFor={id}
className="cursor-pointer rounded-md border border-shoal px-3 py-1.5 text-sm text-snow hover:border-frost"
>
{t('avatar.change')}
<input
id={id}
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={onChange}
/>
</label>
{error ? <Notice tone="error">{error}</Notice> : null}
</div>
)
}

View file

@ -0,0 +1,72 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { Button } from '../../../shared/ui/Button'
import { listDevices, revokeDevice, type DeviceLink } from '../api'
function relativeDate(iso: string): string {
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(iso))
}
function DeviceRow({ device }: { device: DeviceLink }) {
const { t } = useTranslation('account')
const client = useQueryClient()
const [confirming, setConfirming] = useState(false)
const mutation = useMutation({
mutationFn: () => revokeDevice(device.id),
onSuccess: () => client.invalidateQueries({ queryKey: ['devices'] }),
})
return (
<li className="flex items-center justify-between gap-4 rounded-(--radius-panel) border border-shoal bg-slate/60 px-4 py-3">
<div>
<p className="text-sm text-snow">{t('devices.linkedAt', { date: relativeDate(device.linked_at) })}</p>
<p className="text-sm text-frost">
{t('devices.lastSeen', { when: device.last_seen ? relativeDate(device.last_seen) : t('devices.neverSeen') })}
</p>
</div>
{confirming ? (
<div className="flex items-center gap-2">
<Button variant="danger" busy={mutation.isPending} onClick={() => mutation.mutate()}>
{t('devices.confirmRevoke')}
</Button>
<Button variant="quiet" onClick={() => setConfirming(false)}>
{t('devices.cancel')}
</Button>
</div>
) : (
<Button variant="danger" onClick={() => setConfirming(true)}>
{t('devices.revoke')}
</Button>
)}
</li>
)
}
/** ClickGui-like rows listing devices the mod linked, each with a two-step confirm to revoke. */
export function DeviceList() {
const { t } = useTranslation('account')
const query = useQuery({ queryKey: ['devices'], queryFn: listDevices })
const devices = query.data ?? []
return (
<div className="rounded-(--radius-panel) border border-shoal bg-slate/40 p-5">
<h2 className="font-display text-lg">{t('devices.heading')}</h2>
{devices.length === 0 ? (
<p className="mt-3 text-sm text-frost">
{t('devices.empty')}{' '}
<Link to="/link" className="text-ice underline-offset-4 hover:underline">
{t('devices.emptyLink')}
</Link>
</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{devices.map((device) => (
<DeviceRow key={device.id} device={device} />
))}
</ul>
)}
</div>
)
}

View file

@ -0,0 +1,44 @@
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { Button } from '../../../shared/ui/Button'
import { useSession } from '../../auth/session'
import { AvatarUpload } from './AvatarUpload'
/** The mod's profile panel: big avatar with an accent ring, nick, email and join date. */
export function ProfileCard() {
const { t } = useTranslation('account')
const { me, signOut } = useSession()
const navigate = useNavigate()
if (!me) return null
const joined = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(me.created_at))
async function handleSignOut() {
await signOut()
navigate('/')
}
return (
<div className="flex flex-col items-center gap-4 rounded-(--radius-panel) border border-shoal bg-slate/70 p-6 text-center">
<div className="relative">
<div className="flex h-24 w-24 items-center justify-center overflow-hidden rounded-full border-2 border-ice bg-abyss font-display text-3xl">
{me.avatar_url ? (
<img src={me.avatar_url} alt="" className="h-full w-full object-cover" />
) : (
<span aria-hidden="true">{me.display_nick.slice(0, 1).toUpperCase()}</span>
)}
</div>
</div>
<AvatarUpload />
<div>
<p className="font-display text-xl">{me.display_nick}</p>
<p className="text-sm text-frost">{me.email}</p>
<p className="mt-1 text-sm text-frost">{t('profile.memberSince', { date: joined })}</p>
</div>
<Button variant="quiet" onClick={handleSignOut}>
{t('profile.signOut')}
</Button>
</div>
)
}

View file

@ -0,0 +1,23 @@
import type { Translation } from '../../../shared/i18n/common.ru'
import type { accountRu } from './ru'
export const accountEn = {
profile: {
memberSince: 'With us since {{date}}',
signOut: 'Sign out',
},
avatar: {
change: 'Change avatar',
},
devices: {
heading: 'Linked games',
linkedAt: 'Linked {{date}}',
lastSeen: 'Last seen: {{when}}',
neverSeen: 'never signed in yet',
revoke: 'Unlink',
confirmRevoke: 'Really unlink?',
cancel: 'Cancel',
empty: 'No linked games yet. Link the mod on the "Link the game" page.',
emptyLink: 'Link the game',
},
} satisfies Translation<typeof accountRu>

View file

@ -0,0 +1,20 @@
export const accountRu = {
profile: {
memberSince: 'С нами с {{date}}',
signOut: 'Выйти',
},
avatar: {
change: 'Сменить аватар',
},
devices: {
heading: 'Привязанные игры',
linkedAt: 'Привязано {{date}}',
lastSeen: 'Последний вход: {{when}}',
neverSeen: 'ещё не заходил',
revoke: 'Отвязать',
confirmRevoke: 'Точно отвязать?',
cancel: 'Отмена',
empty: 'Пока ни одной игры. Привяжи мод на странице «Привязать игру».',
emptyLink: 'Привязать игру',
},
} as const

View file

@ -0,0 +1,33 @@
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { checkAvatarFile } from '../api'
import { DeviceList } from '../components/DeviceList'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
test('avatar file checks', () => {
expect(checkAvatarFile(new File(['x'], 'a.gif', { type: 'image/gif' }))).toBe('Подойдёт PNG, JPG или WebP.')
const big = new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'a.png', { type: 'image/png' })
expect(checkAvatarFile(big)).toBe('Файл больше 5 МБ.')
expect(checkAvatarFile(new File(['x'], 'a.png', { type: 'image/png' }))).toBeUndefined()
})
test('revoking a device asks for confirmation first', async () => {
const user = userEvent.setup()
let devices = [{ id: 'd1', linked_at: '2026-09-20T10:00:00Z', last_seen: null }]
const fetch = mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /device/links': () => json(devices),
'DELETE /device/links/d1': () => {
devices = []
return new Response(null, { status: 204 })
},
})
renderApp([{ path: '/', element: <DeviceList /> }])
const row = await screen.findByRole('listitem')
await user.click(within(row).getByRole('button', { name: 'Отвязать' }))
expect(fetch.mock.calls.some(([, i]) => i?.method === 'DELETE')).toBe(false)
await user.click(within(row).getByRole('button', { name: 'Точно отвязать?' }))
expect(await screen.findByText(/Пока ни одной игры/)).toBeInTheDocument()
})

View file

@ -3,6 +3,7 @@ import type { authRu } from './ru'
export const authEn = { export const authEn = {
checkingSession: 'Checking session…', checkingSession: 'Checking session…',
splitPitch: 'Your LoVisual setup, on any computer: cloud configs, share codes for friends, and a showcase of ready-made builds.',
login: { login: {
title: 'Log in', title: 'Log in',
emailLabel: 'Email', emailLabel: 'Email',

View file

@ -1,5 +1,6 @@
export const authRu = { export const authRu = {
checkingSession: 'Проверяем вход…', checkingSession: 'Проверяем вход…',
splitPitch: 'Твои настройки LoVisual на любом компьютере: облачные конфиги, коды для друзей и витрина готовых сборок.',
login: { login: {
title: 'Вход', title: 'Вход',
emailLabel: 'Почта', emailLabel: 'Почта',

View file

@ -1,14 +1,16 @@
import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { prefersReducedMotion } from '../../shared/motion/reducedMotion' import { prefersReducedMotion } from '../../shared/motion/reducedMotion'
import { PRESETS } from '../themes/presets.generated'
import type { ThemeEntry } from '../themes/types' import type { ThemeEntry } from '../themes/types'
import { useActiveTheme } from '../themes/useActiveTheme' import { useActiveTheme } from '../themes/useActiveTheme'
import { DEMO, useDemo } from './demo' import { DEMO, useDemo } from './demo'
import { ModuleCard } from './ModuleCard'
import './parts/clickgui.css' import './parts/clickgui.css'
import { CategoryIcon, DemoCursor, LogoMark, SearchGlyph } from './parts/Glyphs' import { Backdrop } from './parts/Backdrop'
import { CATEGORIES, QUICK_THEMES, TABS } from './parts/modules' import { DemoCursor, Icon } from './parts/Glyphs'
import { QUICK_THEMES, TABS } from './parts/modules'
import { ModulesScreen } from './screens/ModulesScreen'
import './screens/screens.css'
import { SettingsScreen } from './screens/SettingsScreen'
import { themeToCssVars } from './themeVars' import { themeToCssVars } from './themeVars'
type Props = { type Props = {
@ -17,16 +19,43 @@ type Props = {
paused?: boolean paused?: boolean
/** Overrides the active theme — for previews. */ /** Overrides the active theme — for previews. */
theme?: ThemeEntry theme?: ThemeEntry
/**
* Controlled theme picking: when set, theme cards in Settings call this instead of switching the
* site-wide theme (the /themes editor loads the preset into its draft).
*/
onPickTheme?: (id: string) => void
/** Image URL painted over the procedural backdrop (e.g. a real in-game F1 screenshot). */
backdrop?: string
className?: string className?: string
} }
export function ClickGuiWindow({ demo = true, paused = false, theme: override, className = '' }: Props) { const HINTS = {
modules: ['toggle', 'settings', 'moduleList', 'search', 'close', 'hide'],
settings: ['pick', 'edit', 'wheel', 'close', 'hide'],
} as const
function useClock(): string {
const format = () => new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
const [now, setNow] = useState(format)
useEffect(() => {
const id = window.setInterval(() => setNow(format()), 15_000)
return () => window.clearInterval(id)
}, [])
return now
}
/**
* A faithful replica of the in-game ClickGui: the top pill bar (clock + Modules/Settings switch),
* the four glass category panels, and the Settings → Темы page whose cards re-theme everything.
*/
export function ClickGuiWindow({ demo = true, paused = false, theme: override, onPickTheme, backdrop, className = '' }: Props) {
const { t } = useTranslation('clickgui') const { t } = useTranslation('clickgui')
const { theme: active, setTheme } = useActiveTheme() const { theme: active, setTheme } = useActiveTheme()
const theme = override ?? active const theme = override ?? active
const rootRef = useRef<HTMLDivElement>(null) const rootRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
const [reduced] = useState(prefersReducedMotion) const [reduced] = useState(prefersReducedMotion)
const clock = useClock()
const themeIdRef = useRef(theme.id) const themeIdRef = useRef(theme.id)
useEffect(() => { useEffect(() => {
themeIdRef.current = theme.id themeIdRef.current = theme.id
@ -40,11 +69,14 @@ export function ClickGuiWindow({ demo = true, paused = false, theme: override, c
const i = QUICK_THEMES.indexOf(themeIdRef.current as (typeof QUICK_THEMES)[number]) const i = QUICK_THEMES.indexOf(themeIdRef.current as (typeof QUICK_THEMES)[number])
return QUICK_THEMES[(i + 1) % QUICK_THEMES.length] return QUICK_THEMES[(i + 1) % QUICK_THEMES.length]
}, },
onTheme: (id) => setTheme(id, 'demo'), onTheme: (id) => (onPickTheme ? onPickTheme(id) : setTheme(id, 'demo')),
}) })
const vars = useMemo(() => themeToCssVars(theme), [theme]) const vars = useMemo(
const category = CATEGORIES.find((c) => c.id === state.category) ?? CATEGORIES[0] () => (backdrop ? { ...themeToCssVars(theme), '--gui-backdrop-image': `url(${JSON.stringify(backdrop)})` } : themeToCssVars(theme)),
[theme, backdrop],
)
const pick = onPickTheme ?? ((id: string) => setTheme(id, 'user'))
return ( return (
<div <div
@ -54,75 +86,59 @@ export function ClickGuiWindow({ demo = true, paused = false, theme: override, c
data-testid="clickgui-root" data-testid="clickgui-root"
className={`lv-gui ${className}`} className={`lv-gui ${className}`}
style={vars} style={vars}
onPointerEnter={(e) => e.pointerType === 'mouse' && setHovered(true)} onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)} onPointerLeave={() => setHovered(false)}
> >
<div className="lv-gui-bloom" aria-hidden="true" /> <div className="lv-gui-stage" data-tab={state.tab}>
<div className="lv-gui-window"> <div className="lv-gui-world" aria-hidden="true">
<header className="lv-gui-header"> <Backdrop />
<span className="lv-gui-logo"> <div className="lv-gui-photo" />
<LogoMark /> </div>
LoVisual <p className="lv-gui-watermark" aria-hidden="true">
</span> <b>LoVisual</b>·400 fps <span>0 ms</span>
<nav className="lv-gui-tabs" aria-hidden="true"> </p>
{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"> <div className="lv-gui-bar">
<aside className="lv-gui-cats" aria-label={t('categories')}> <time className="lv-gui-clock" aria-label={t('clock')}>
{CATEGORIES.map((c) => ( {clock}
<button </time>
key={c.id} <div className="lv-gui-tabs" role="tablist" aria-label={t('tabsLabel')}>
type="button" <span className="lv-gui-tab-thumb" aria-hidden="true" />
className="lv-gui-cat" {TABS.map((id) => (
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 <button
key={id} key={id}
type="button" type="button"
className="lv-gui-theme-dot" role="tab"
data-active={theme.id === id} className="lv-gui-tab"
data-demo={`theme:${id}`} aria-selected={state.tab === id}
aria-label={t('theme', { name: preset.name })} data-demo={`tab:${id}`}
style={{ '--dot': dot['--gui-header-gradient'], '--dot-accent': dot['--gui-accent'] } as CSSProperties} onClick={() => dispatch({ type: 'tab', id })}
onClick={() => setTheme(id, 'user')} >
/> {t(`tabs.${id}`)}
) </button>
})}
</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>
{state.tab === 'modules' && (
<button type="button" className="lv-gui-search" aria-label={t('search')}>
<Icon name="search" className="lv-gui-search-icon" />
</button>
)}
<div className="lv-gui-screen" key={state.tab}>
{state.tab === 'modules' ? (
<ModulesScreen state={state} dispatch={dispatch} />
) : (
<SettingsScreen activeId={theme.id} onPick={pick} />
)}
</div> </div>
<ul className="lv-gui-hints" aria-hidden="true">
{HINTS[state.tab].map((k) => (
<li key={k}>{t(`hints.${state.tab}.${k}` as `hints.modules.toggle`)}</li>
))}
</ul>
</div> </div>
<DemoCursor cursor={cursor} /> <DemoCursor cursor={cursor} />
</div> </div>

View file

@ -1,59 +0,0 @@
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

@ -1,73 +1,46 @@
import { type Dispatch, type RefObject, useEffect, useReducer, useRef, useState } from 'react' import { type Dispatch, type RefObject, useEffect, useReducer, useRef, useState } from 'react'
import { CATEGORIES, type CategoryId, settingKey } from './parts/modules' import { CATEGORIES, type TabId } from './parts/modules'
export type DemoStep = { at: number /* ms */; action: 'move' | 'click' | 'drag' | 'theme'; target: string; value?: number | string } export type DemoStep = { at: number /* ms */; action: 'move' | 'click' | 'theme'; target: string }
export const LOOP_MS = 12_500 export const LOOP_MS = 11_000
/** The scripted loop: pick Visuals, switch Trails on, open it, drag Length, tick Rainbow, change theme, tidy up. */ /** The scripted loop: toggle two modules, open Settings, pick a theme card, come back to Modules. */
export const DEMO: DemoStep[] = [ export const DEMO: DemoStep[] = [
{ at: 400, action: 'move', target: 'cat:visuals' }, { at: 400, action: 'move', target: 'module:AmbientParticles' },
{ at: 1100, action: 'click', target: 'cat:visuals' }, { at: 1100, action: 'click', target: 'module:AmbientParticles' },
{ at: 1700, action: 'move', target: 'toggle:Trails' }, { at: 1800, action: 'move', target: 'module:DamageTint' },
{ at: 2400, action: 'click', target: 'toggle:Trails' }, { at: 2500, action: 'click', target: 'module:DamageTint' },
{ at: 3000, action: 'move', target: 'expand:Trails' }, { at: 3300, action: 'move', target: 'tab:settings' },
{ at: 3600, action: 'click', target: 'expand:Trails' }, { at: 4000, action: 'click', target: 'tab:settings' },
{ at: 4300, action: 'move', target: 'knob:Trails.Length' }, { at: 4800, action: 'move', target: 'theme:next' },
{ at: 4900, action: 'drag', target: 'slider:Trails.Length', value: 85 }, { at: 5600, action: 'theme', target: 'theme:next' },
{ at: 6300, action: 'move', target: 'check:Trails.Rainbow' }, { at: 7200, action: 'move', target: 'tab:modules' },
{ at: 6900, action: 'click', target: 'check:Trails.Rainbow' }, { at: 7900, action: 'click', target: 'tab:modules' },
{ at: 7700, action: 'move', target: 'theme:next' }, { at: 8800, action: 'move', target: 'rest' },
{ 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 = { /** Modules the demo switches on — also enabled in the static reduced-motion frame. */
category: CategoryId const DEMO_TOGGLES = ['AmbientParticles', 'DamageTint']
enabled: Record<string, boolean>
expanded: string | null
values: Record<string, number | boolean | string>
dragging: boolean
}
export type GuiAction = export type GuiState = { tab: TabId; enabled: Record<string, boolean> }
| { type: 'category'; id: CategoryId }
| { type: 'toggle'; module: string } export type GuiAction = { type: 'tab'; id: TabId } | { type: 'toggle'; module: string } | { type: 'reset' }
| { type: 'expand'; module: string }
| { type: 'set'; key: string; value: number | boolean | string }
| { type: 'dragging'; on: boolean }
| { type: 'reset' }
export function initialGuiState(finished = false): GuiState { export function initialGuiState(finished = false): GuiState {
const enabled: Record<string, boolean> = {} 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 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. // The resting "after the demo" frame, used under reduced motion.
enabled.Trails = true if (finished) for (const name of DEMO_TOGGLES) enabled[name] = true
values[settingKey('Trails', 'Length')] = 85 return { tab: 'modules', enabled }
return { category: 'visuals', enabled, expanded: 'Trails', values, dragging: false }
} }
export function guiReducer(state: GuiState, action: GuiAction): GuiState { export function guiReducer(state: GuiState, action: GuiAction): GuiState {
switch (action.type) { switch (action.type) {
case 'category': case 'tab':
return { ...state, category: action.id, expanded: null } return { ...state, tab: action.id }
case 'toggle': case 'toggle':
return { ...state, enabled: { ...state.enabled, [action.module]: !state.enabled[action.module] } } 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': case 'reset':
return initialGuiState() return initialGuiState()
} }
@ -84,17 +57,17 @@ type Options = {
onTheme: (id: string) => void onTheme: (id: string) => void
} }
/** Layout position of `el` inside `root`, ignoring CSS transforms (the window is tilted in 3D). */ /** Visual position of `el` inside `root` in root-local pixels: rects see CSS transforms (the tab bar's
function offsetWithin(el: HTMLElement, root: HTMLElement): { x: number; y: number } { * `translateX(-50%)`, the screen's entrance animation) that the `offsetLeft` chain silently skips.
let x = 0 * `scale` undoes transforms applied by ancestors of the root (the hero's entrance scale). */
let y = 0 function positionWithin(el: HTMLElement, root: HTMLElement): { x: number; y: number } {
let node: HTMLElement | null = el const rr = root.getBoundingClientRect()
while (node && node !== root) { const tr = el.getBoundingClientRect()
x += node.offsetLeft const scale = root.offsetWidth && rr.width ? rr.width / root.offsetWidth : 1
y += node.offsetTop return {
node = node.offsetParent as HTMLElement | null x: (tr.left - rr.left) / scale + (tr.width / scale) * 0.62,
y: (tr.top - rr.top) / scale + (tr.height / scale) * 0.5,
} }
return { x, y }
} }
export function useDemo(steps: DemoStep[], opts: Options): { cursor: Cursor; state: GuiState; dispatch: Dispatch<GuiAction> } { export function useDemo(steps: DemoStep[], opts: Options): { cursor: Cursor; state: GuiState; dispatch: Dispatch<GuiAction> } {
@ -111,55 +84,52 @@ export function useDemo(steps: DemoStep[], opts: Options): { cursor: Cursor; sta
const timers: number[] = [] const timers: number[] = []
const later = (ms: number, fn: () => void) => timers.push(window.setTimeout(fn, ms)) const later = (ms: number, fn: () => void) => timers.push(window.setTimeout(fn, ms))
let pendingTheme = '' let pendingTheme = ''
/** When the pointer finishes its current travel; a press must not fire before that. */
let travelEndsAt = 0
const locate = (target: string, fraction?: number) => { const locate = (target: string) => {
const root = optsRef.current.rootRef.current const root = optsRef.current.rootRef.current
if (!root) return null if (!root) return null
if (target === 'rest') return { x: root.offsetWidth * 0.86, y: root.offsetHeight * 0.9 } 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 resolved = target === 'theme:next' ? (pendingTheme ||= `theme:${optsRef.current.nextTheme()}`) : target
const el = root.querySelector<HTMLElement>(`[data-demo="${resolved}"]`) const el = root.querySelector<HTMLElement>(`[data-demo="${resolved}"]`)
if (!el) return null if (!el) return null
const { x, y } = offsetWithin(el, root) return positionWithin(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 move = (target: string, duration: number) => {
const p = locate(target, fraction) const p = locate(target)
if (p) setCursor((c) => ({ ...c, ...p, visible: true, duration })) if (!p) return
travelEndsAt = performance.now() + duration
setCursor((c) => ({ ...c, ...p, visible: true, duration }))
} }
const click = (fn: () => void) => { /** Press `target` only once the pointer is actually on it: timers trail the CSS travel, and a
* target that moved or unmounted would mean a click on air. */
const click = (target: string, fn: () => void) => {
later(Math.max(0, travelEndsAt - performance.now()) + 60, () => {
const p = locate(target)
if (!p) return
setCursor((c) => ({ ...c, ...p, visible: true, duration: 0 }))
later(60, () => {
setCursor((c) => ({ ...c, pressed: true })) setCursor((c) => ({ ...c, pressed: true }))
later(160, () => { later(160, () => {
setCursor((c) => ({ ...c, pressed: false })) setCursor((c) => ({ ...c, pressed: false }))
fn() fn()
}) })
})
})
} }
const run = (step: DemoStep) => { const run = (step: DemoStep) => {
const [kind, arg] = step.target.split(':') const [kind, arg] = step.target.split(':')
if (step.action === 'move') return move(step.target, 650) if (step.action === 'move') return move(step.target, 650)
if (step.action === 'theme') { if (step.action === 'theme') {
return click(() => optsRef.current.onTheme(pendingTheme.replace('theme:', ''))) return click(step.target, () => optsRef.current.onTheme(pendingTheme.replace('theme:', '')))
} }
if (step.action === 'drag') { click(step.target, () => {
const [module, setting] = arg.split('.') if (kind === 'tab') dispatch({ type: 'tab', id: arg as TabId })
const value = Number(step.value ?? 0) if (kind === 'module') dispatch({ type: 'toggle', module: arg })
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 })
}) })
} }

View file

@ -3,47 +3,18 @@ import type { clickguiRu } from './ru'
export const clickguiEn = { export const clickguiEn = {
windowLabel: 'Interactive replica of the LoVisual ClickGui', windowLabel: 'Interactive replica of the LoVisual ClickGui',
bind: 'Bind', tabsLabel: 'ClickGui sections',
bindNone: 'none', tabs: { modules: 'Modules', settings: 'Settings' },
toggle: 'Toggle {{name}}', clock: 'Time',
settings: '{{name}} settings', search: 'Search',
categories: 'Categories', categories: 'Module categories',
module: '{{name}}: LMB toggles it',
theme: '{{name}} theme', theme: '{{name}} theme',
noSettings: 'No settings', themes: 'Themes',
desc: { settingsNav: 'Settings sections',
PvpCooldowns: 'Cooldown timers for shield, pearls and totems', sidebar: { hud: 'HUD', ui: 'UI', themes: 'Themes', layout: 'Layout', account: 'Account', configs: 'Configs' },
Predictions: 'Flight path of pearls and arrows', hints: {
TargetESP: 'Animated marker on your current target', modules: { toggle: 'LMB - toggle module', settings: 'RMB - settings', moduleList: 'E - edit ModuleList', search: 'Ctrl+F - search', close: 'Esc - close ClickGUI', hide: 'Alt+H - hide hints' },
DamageTint: 'A colored flash when you take damage', settings: { pick: 'LMB - pick a theme', edit: 'Edit - open the theme editor', wheel: 'Wheel - scroll themes', close: 'Esc - close ClickGUI', hide: 'Alt+H - hide hints' },
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> } satisfies Translation<typeof clickguiRu>

View file

@ -1,46 +1,17 @@
export const clickguiRu = { export const clickguiRu = {
windowLabel: 'Интерактивная копия ClickGui LoVisual', windowLabel: 'Интерактивная копия ClickGui LoVisual',
bind: 'Бинд', tabsLabel: 'Разделы ClickGui',
bindNone: 'нет', tabs: { modules: 'Modules', settings: 'Settings' },
toggle: 'Включить {{name}}', clock: 'Время',
settings: 'Настройки {{name}}', search: 'Поиск',
categories: 'Категории', categories: 'Категории модулей',
module: '{{name}}: ЛКМ: включить или выключить',
theme: 'Тема {{name}}', theme: 'Тема {{name}}',
noSettings: 'Без настроек', themes: 'Темы',
desc: { settingsNav: 'Разделы настроек',
PvpCooldowns: 'Таймеры перезарядки щита, жемчуга и тотема', sidebar: { hud: 'HUD', ui: 'UI', themes: 'Темы', layout: 'Расположение', account: 'Аккаунт', configs: 'Конфиги' },
Predictions: 'Траектория полёта жемчуга и стрел', hints: {
TargetESP: 'Анимированная метка на текущей цели', modules: { toggle: 'ЛКМ - включить/выключить модуль', settings: 'ПКМ - настройки', moduleList: 'E - редактировать ModuleList', search: 'Ctrl+F - поиск', close: 'Esc - закрыть ClickGUI', hide: 'Alt+H - скрыть подсказки' },
DamageTint: 'Цветная вспышка при получении урона', settings: { pick: 'ЛКМ - выбрать тему', edit: 'Edit - открыть редактор темы', wheel: 'Колесо - листать темы', close: 'Esc - закрыть ClickGUI', hide: 'Alt+H - скрыть подсказки' },
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 } as const

View file

@ -0,0 +1,128 @@
import { memo } from 'react'
/**
* The world behind the ClickGui: a night sky over a frozen lake, drawn as one small SVG
* (no images). Horizon at ~46%; the lake starts as dark as the sky and brightens towards the
* viewer, with a mist band over the shoreline, so there is no hard edge behind the panels.
* A real screenshot can replace it: set `--gui-backdrop-image` (or ClickGuiWindow's `backdrop`),
* and `.lv-gui-photo` covers this scene.
*/
/** Deterministic pseudo-random sequence, so the sky is identical on every render and in SSR. */
function rng(seed: number) {
let s = seed
return () => {
s = (s * 16807) % 2147483647
return (s - 1) / 2147483646
}
}
const W = 560
const HORIZON = 150
const rand = rng(7)
const STARS = Array.from({ length: 80 }, () => ({
x: rand() * W,
y: rand() * (HORIZON - 30),
r: rand() < 0.12 ? 0.9 : 0.45 + rand() * 0.35,
o: 0.35 + rand() * 0.6,
twinkle: rand() < 0.2,
}))
const FLAKES = Array.from({ length: 26 }, () => ({ x: rand() * W, y: rand() * 300, s: 0.8 + rand() * 1.1, d: 9 + rand() * 9 }))
/** A Minecraft spruce: a trunk and stacked needle tiers that alternate wide/narrow, snow on each tier. */
function Spruce({ x, base, h }: { x: number; base: number; h: number }) {
const u = h / 14
const tiers = [5, 3.5, 4.5, 3, 3.5, 2, 2.5, 1, 1]
return (
<g>
<rect x={x - u / 2} y={base - 2 * u} width={u} height={2 * u} fill="#1a1210" />
{tiers.map((half, i) => {
const y = base - (i + 2) * u - u * 0.35
return (
<g key={i}>
<rect x={x - half * u} y={y} width={half * 2 * u} height={u * 1.05} fill={i % 2 ? '#0c1b1d' : '#0f2224'} />
{i % 2 === 0 && <rect x={x - half * u + u * 0.6} y={y} width={half * 2 * u - u * 1.6} height={u * 0.4} fill="#d4dcf3" opacity={0.55} />}
</g>
)
})}
</g>
)
}
/** Stepped (voxel) ridge: one block column every `step` units. */
function ridge(from: number, to: number, step: number, base: number, heights: number[]) {
let d = `M${from} ${HORIZON + 6}`
for (let x = from, i = 0; x < to; x += step, i++) d += ` V${base - heights[i % heights.length]} H${Math.min(x + step, to)}`
return `${d} V${HORIZON + 6} Z`
}
const TREES = [
{ x: 14, base: 142, h: 62 }, { x: 44, base: 140, h: 48 }, { x: 70, base: 144, h: 36 }, { x: 100, base: 147, h: 22 },
{ x: 268, base: 148, h: 14 }, { x: 281, base: 148, h: 10 },
{ x: 440, base: 147, h: 22 }, { x: 488, base: 141, h: 44 }, { x: 516, base: 139, h: 58 }, { x: 548, base: 142, h: 50 },
]
export const Backdrop = memo(function Backdrop() {
return (
<svg className="lv-gui-backdrop" viewBox={`0 0 ${W} 324`} preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<defs>
<linearGradient id="lvgb-sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#020309" />
<stop offset="0.55" stopColor="#060b1b" />
<stop offset="1" stopColor="#141d3d" />
</linearGradient>
<linearGradient id="lvgb-lake" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#121a36" />
<stop offset="0.3" stopColor="#262f5a" />
<stop offset="0.7" stopColor="#434c84" />
<stop offset="1" stopColor="#5d67a3" />
</linearGradient>
<linearGradient id="lvgb-mist" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#7d8ad0" stopOpacity="0" />
<stop offset="0.5" stopColor="#7d8ad0" stopOpacity="0.22" />
<stop offset="1" stopColor="#7d8ad0" stopOpacity="0" />
</linearGradient>
<radialGradient id="lvgb-moon" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stopColor="#dfe6ff" stopOpacity="0.28" />
<stop offset="1" stopColor="#dfe6ff" stopOpacity="0" />
</radialGradient>
<linearGradient id="lvgb-glint" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#c9d3ff" stopOpacity="0.16" />
<stop offset="1" stopColor="#c9d3ff" stopOpacity="0" />
</linearGradient>
</defs>
<rect width={W} height={HORIZON + 6} fill="url(#lvgb-sky)" />
{STARS.map((s, i) => (
<circle key={i} cx={s.x} cy={s.y} r={s.r} fill="#fff" opacity={s.o} className={s.twinkle ? 'lv-gui-twinkle' : undefined} style={{ animationDelay: `${(i % 7) * 0.6}s` }} />
))}
<circle cx={452} cy={44} r={46} fill="url(#lvgb-moon)" />
<rect x={445} y={37} width={14} height={14} fill="#eef2ff" opacity={0.92} />
<rect x={448} y={40} width={4} height={3} fill="#c9d0ea" opacity={0.7} />
<path d={ridge(0, W, 14, HORIZON, [18, 24, 30, 26, 20, 14, 10, 8, 12, 16, 22, 28, 34, 30, 24, 18, 12, 10, 14, 20, 26, 30, 24, 20, 16, 22, 28, 34, 38, 32, 26, 20, 16, 12, 10, 14, 18, 24, 28, 22])} fill="#1a2349" />
<path d={ridge(0, W, 14, HORIZON, [18, 24, 30, 26, 20, 14, 10, 8, 12, 16, 22, 28, 34, 30, 24, 18, 12, 10, 14, 20, 26, 30, 24, 20, 16, 22, 28, 34, 38, 32, 26, 20, 16, 12, 10, 14, 18, 24, 28, 22])} fill="none" stroke="#9aa6d8" strokeWidth="0.6" opacity="0.35" />
<path d={ridge(0, 120, 8, HORIZON, [10, 12, 12, 9, 7, 5, 5, 4, 3, 3, 2, 2, 1, 1, 0])} fill="#0d1530" />
<path d={ridge(380, W, 8, HORIZON, [0, 1, 2, 3, 4, 6, 6, 8, 9, 10, 10, 12, 12, 11, 13, 12, 12, 11, 12, 13, 12, 12, 11])} fill="#0d1530" />
{TREES.map((t, i) => (
<Spruce key={i} {...t} />
))}
<rect y={HORIZON} width={W} height={324 - HORIZON} fill="url(#lvgb-lake)" />
<g stroke="#dfe6ff" strokeWidth="0.5">
{[6, 14, 25, 40, 60, 86, 118, 156].map((dy, i) => (
<line key={dy} x1="0" x2={W} y1={HORIZON + dy} y2={HORIZON + dy} opacity={0.02 + i * 0.008} />
))}
</g>
<rect x={430} y={HORIZON} width={44} height={120} fill="url(#lvgb-glint)" />
<rect y={HORIZON - 22} width={W} height={44} fill="url(#lvgb-mist)" />
<g fill="#fff">
{FLAKES.map((f, i) => (
<rect key={i} className="lv-gui-flake" x={f.x} y={f.y} width={f.s} height={f.s} opacity={0.55} style={{ animationDuration: `${f.d}s`, animationDelay: `${-i * 1.3}s` }} />
))}
</g>
</svg>
)
})

View file

@ -1,43 +1,88 @@
import type { ReactNode } from 'react'
import type { Cursor } from '../demo' import type { Cursor } from '../demo'
import type { CategoryId } from './modules'
const PATHS: Record<CategoryId, string> = { /** The Lucide icons the mod ships in `assets/lovisual/svg/`, drawn the same way (2px stroke). */
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', const ICONS = {
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', combat: (
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', <polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5" />
} <path d="M13 19 19 13M16 16l4 4M19 21l2-2" />
<polyline points="14.5 6.5 18 3 21 3 21 6 17.5 9.5" />
<path d="M5 14l4 4M7 17l-3 3M3 19l2 2" />
</>
),
player: (
<>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</>
),
visuals: (
<>
<path d="m17 14 3 3.3a1 1 0 0 1-.7 1.7H4.7a1 1 0 0 1-.7-1.7L7 14h-.3a1 1 0 0 1-.7-1.7L9 9h-.2A1 1 0 0 1 8 7.3L12 3l4 4.3a1 1 0 0 1-.8 1.7H15l3 3.3a1 1 0 0 1-.7 1.7H17Z" />
<path d="M12 22v-3" />
</>
),
other: (
<>
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
<circle cx="5" cy="12" r="1" />
</>
),
check: <path d="M20 6 9 17l-5-5" />,
search: (
<>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-4.2-4.2" />
</>
),
themes: (
<>
<path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z" />
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
</>
),
layout: (
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18M9 12h12M15 12v9" />
</>
),
account: (
<>
<path d="M11.5 15H7a4 4 0 0 0-4 4v2" />
<path d="M21.378 16.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z" />
<circle cx="10" cy="7" r="4" />
</>
),
configs: (
<>
<path d="M10.3 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.98a2 2 0 0 1 1.69.9l.66 1.2A2 2 0 0 0 12 6h8a2 2 0 0 1 2 2v3.3" />
<circle cx="18" cy="18" r="3" />
<path d="m14.3 19.5.9-.4M15.2 16.9l-.9-.4M16.9 15.2l-.4-.9M16.9 20.8l-.4.9M19.1 15.2l.4-.9M19.5 21.7l-.4-.9M20.8 16.9l.9-.4M20.8 19.1l.9.4" />
</>
),
} satisfies Record<string, ReactNode>
export function CategoryIcon({ id }: { id: CategoryId }) { export type IconName = keyof typeof ICONS
export function Icon({ name, className = 'lv-gui-icon' }: { name: IconName; className?: string }) {
return ( return (
<svg viewBox="0 0 24 24" className="lv-gui-icon" aria-hidden="true"> <svg
<path d={PATHS[id]} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /> viewBox="0 0 24 24"
</svg> className={className}
) fill="none"
} stroke="currentColor"
strokeWidth="2"
export function Chevron({ open }: { open: boolean }) { strokeLinecap="round"
return ( strokeLinejoin="round"
<svg viewBox="0 0 16 16" className="lv-gui-chevron" data-open={open} aria-hidden="true"> aria-hidden="true"
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /> >
</svg> {ICONS[name]}
)
}
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> </svg>
) )
} }
@ -57,13 +102,7 @@ export function DemoCursor({ cursor }: { cursor: Cursor }) {
> >
<span className="lv-gui-cursor-ring" /> <span className="lv-gui-cursor-ring" />
<svg viewBox="0 0 20 24" width="20" height="24"> <svg viewBox="0 0 20 24" width="20" height="24">
<path <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" />
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> </svg>
</div> </div>
) )

View file

@ -1,92 +0,0 @@
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

@ -1,197 +1,197 @@
/* ClickGui replica. Colors come from themeToCssVars(); the typed @property registrations /* ClickGui replica — the "screen": world backdrop, HUD watermark, top pill bar, search button,
let a theme switch cross-fade (400ms) instead of snapping. */ hint lines and the demo cursor. Everything is sized in the mod's logical units: --u is one
logical pixel of the in-game layout (panel = 115×240u), derived from the container width.
Colors come from themeToCssVars(); typed @property registrations make a theme switch
cross-fade (400ms) instead of snapping. */
@property --gui-window-from { syntax: "<color>"; inherits: true; initial-value: #12191b; } @property --gui-window-from { syntax: "<color>"; inherits: true; initial-value: #12191b; }
@property --gui-window-to { syntax: "<color>"; inherits: true; initial-value: #12191b; } @property --gui-window-to { syntax: "<color>"; inherits: true; initial-value: #12191b; }
@property --gui-stroke-from { syntax: "<color>"; inherits: true; initial-value: #203030; }
@property --gui-stroke-to { syntax: "<color>"; inherits: true; initial-value: #203030; }
@property --gui-header-from { syntax: "<color>"; inherits: true; initial-value: #142226; } @property --gui-header-from { syntax: "<color>"; inherits: true; initial-value: #142226; }
@property --gui-header-to { 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-from { syntax: "<color>"; inherits: true; initial-value: #43a7c8; }
@property --gui-card-to { 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-surface-hover { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-card-off { syntax: "<color>"; inherits: true; initial-value: transparent; } @property --gui-stroke-soft { syntax: "<color>"; inherits: true; initial-value: #fff; }
@property --gui-text { syntax: "<color>"; inherits: true; initial-value: #fff; } @property --gui-text { syntax: "<color>"; inherits: true; initial-value: #fff; }
@property --gui-text-muted { syntax: "<color>"; inherits: true; initial-value: #c6d2cd; } @property --gui-text-muted { syntax: "<color>"; inherits: true; initial-value: #c6d2cd; }
@property --gui-accent { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; } @property --gui-accent { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
@property --gui-accent-soft { 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-accent-glow { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-stroke-soft { syntax: "<color>"; inherits: true; initial-value: #fff; } @property --gui-panel-tint { syntax: "<color>"; inherits: true; initial-value: #1d2224; }
@property --gui-panel-stroke { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-split { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-row-hover { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-row-glow { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-row-text { syntax: "<color>"; inherits: true; initial-value: #c6d2cd; }
@property --gui-tab-left { syntax: "<color>"; inherits: true; initial-value: #151c1e; }
@property --gui-tab-right { syntax: "<color>"; inherits: true; initial-value: #151c1e; }
@property --gui-tab-active-left { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
@property --gui-tab-active-right { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
@property --gui-menu-left { syntax: "<color>"; inherits: true; initial-value: #12191b; }
@property --gui-menu-right { syntax: "<color>"; inherits: true; initial-value: #12191b; }
.lv-gui { .lv-gui {
position: relative; position: relative;
width: 100%; width: 100%;
max-width: 660px;
height: 452px;
container-type: inline-size; container-type: inline-size;
font-family: var(--font-sans);
color: var(--gui-text); color: var(--gui-text);
font-size: 13px;
line-height: 1.3;
text-align: left; text-align: left;
user-select: none; user-select: none;
transition-property: --gui-window-from, --gui-window-to, --gui-header-from, --gui-header-to, --gui-card-from, transition-property: --gui-window-from, --gui-window-to, --gui-stroke-from, --gui-stroke-to, --gui-text, --gui-text-muted,
--gui-card-to, --gui-stroke-from, --gui-stroke-to, --gui-surface, --gui-surface-hover, --gui-card-off, --gui-text, --gui-accent, --gui-accent-soft, --gui-accent-glow, --gui-panel-tint, --gui-panel-stroke, --gui-split, --gui-row-hover,
--gui-text-muted, --gui-accent, --gui-accent-soft, --gui-accent-glow, --gui-stroke-soft; --gui-row-glow, --gui-row-text, --gui-tab-left, --gui-tab-right, --gui-tab-active-left, --gui-tab-active-right,
--gui-menu-left, --gui-menu-right;
transition-duration: 400ms; transition-duration: 400ms;
transition-timing-function: ease; transition-timing-function: ease;
} }
.lv-gui button { font: inherit; color: inherit; cursor: pointer; } .lv-gui button { font: inherit; color: inherit; cursor: pointer; }
.lv-gui-icon { width: 16px; height: 16px; flex: none; } .lv-gui-icon { width: 1em; height: 1em; flex: none; }
.lv-gui-bloom { /* Narrow (< 900px): two panels, a taller screen. Wide: all four panels. */
position: absolute; .lv-gui-stage {
inset: 8% 4% -2%; --u: calc(100cqw / 264);
border-radius: 40px; position: relative;
background: radial-gradient(60% 60% at 50% 45%, var(--gui-accent-glow), transparent 70%); aspect-ratio: 264 / 330;
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; overflow: hidden;
background: border-radius: 14px;
linear-gradient(var(--gui-window-angle), var(--gui-window-from), var(--gui-window-to)) padding-box, font-family: "SF Pro Display", -apple-system, "Inter", var(--font-sans), sans-serif;
linear-gradient(135deg, color-mix(in srgb, var(--gui-accent) 55%, transparent), var(--gui-stroke-from) 35%, box-shadow: 0 30px 80px -24px rgb(0 0 0 / 0.8), 0 0 0 1px rgb(255 255 255 / 0.07), 0 0 60px -20px var(--gui-accent-glow);
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); @container (min-width: 900px) {
backdrop-filter: blur(14px) saturate(1.2); .lv-gui-stage { --u: calc(100cqw / 560); aspect-ratio: 560 / 324; }
} }
.lv-gui-header { /* The world: Backdrop.tsx draws a night lake in SVG; `--gui-backdrop-image` (e.g. a real F1
screenshot) paints over it when set. */
.lv-gui-world { position: absolute; inset: 0; background: #020309; }
.lv-gui-backdrop { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.lv-gui-photo { position: absolute; inset: 0; background: var(--gui-backdrop-image, none) center / cover no-repeat; }
.lv-gui-twinkle { animation: lv-gui-twinkle 3.2s ease-in-out infinite alternate; }
.lv-gui-flake { animation: lv-gui-snow 12s linear infinite; }
@keyframes lv-gui-twinkle { to { opacity: 0.15; } }
@keyframes lv-gui-snow {
0% { transform: translate(0, -20px); opacity: 0; }
15%, 80% { opacity: 0.55; }
100% { transform: translate(-14px, 70px); opacity: 0; }
}
.lv-gui-watermark {
position: absolute;
z-index: 2;
left: calc(var(--u) * 3);
top: calc(var(--u) * 3);
margin: 0;
padding: calc(var(--u) * 1) calc(var(--u) * 2.2);
border-radius: calc(var(--u) * 1.6);
background: rgb(22 22 26 / 0.82);
font-size: calc(var(--u) * 5.4);
font-weight: 500;
white-space: nowrap;
}
.lv-gui-watermark b { color: var(--gui-accent); font-weight: 600; }
.lv-gui-watermark span { margin-left: calc(var(--u) * 1.4); }
/* ClickGuiTabBar: clock pill + a two-segment switch whose accent thumb slides between tabs. */
.lv-gui-bar {
position: absolute;
z-index: 3;
top: calc(var(--u) * 4);
left: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: calc(var(--u) * 2.2);
height: 44px; transform: translateX(-50%);
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-clock {
.lv-gui-logo-mark { width: 16px; height: 16px; filter: drop-shadow(0 0 6px var(--gui-accent)); } padding: calc(var(--u) * 1.3) calc(var(--u) * 3.4);
.lv-gui-tabs { display: none; gap: 4px; margin-left: 10px; } border-radius: 999px;
.lv-gui-tab { position: relative; padding: 5px 10px; border-radius: 7px; color: var(--gui-text-muted); font-size: 12px; } background: rgb(10 10 12 / 0.88);
.lv-gui-tab[data-active="true"] { color: var(--gui-text); background: color-mix(in srgb, var(--gui-accent) 14%, transparent); } border: 1px solid rgb(255 255 255 / 0.12);
.lv-gui-tab[data-active="true"]::after { font-size: calc(var(--u) * 4.4);
content: ""; position: absolute; left: 10px; right: 10px; bottom: -8px; height: 2px; border-radius: 2px; font-weight: 600;
background: var(--gui-accent); box-shadow: 0 0 10px var(--gui-accent); font-variant-numeric: tabular-nums;
} }
.lv-gui-tabs {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
height: calc(var(--u) * 12);
border-radius: 999px;
background: linear-gradient(90deg, var(--gui-tab-left), var(--gui-tab-right));
border: 1px solid color-mix(in srgb, var(--gui-text) 14%, transparent);
box-shadow: 0 calc(var(--u) * 2) calc(var(--u) * 7) rgb(0 0 0 / 0.35);
}
.lv-gui-tab {
position: relative;
min-width: calc(var(--u) * 33);
padding: 0 calc(var(--u) * 5);
border: 0;
background: none;
color: var(--gui-text-muted) !important;
font-size: calc(var(--u) * 5) !important;
font-weight: 600 !important;
transition: color 250ms;
}
.lv-gui-tab:hover { color: color-mix(in srgb, var(--gui-text-muted), var(--gui-text) 55%) !important; }
.lv-gui-tab[aria-selected="true"] { color: var(--gui-text) !important; }
.lv-gui-tab-thumb {
position: absolute;
top: calc(var(--u) * 1.2);
bottom: calc(var(--u) * 1.2);
left: calc(var(--u) * 1.2);
width: calc(50% - var(--u) * 2.4);
border-radius: 999px;
background: linear-gradient(90deg, var(--gui-tab-active-left), var(--gui-tab-active-right));
border: 1px solid color-mix(in srgb, var(--gui-accent) 80%, white);
box-shadow: 0 0 calc(var(--u) * 5) color-mix(in srgb, var(--gui-accent-soft) 45%, transparent);
transition: left 380ms cubic-bezier(0.22, 1, 0.36, 1);
}
.lv-gui-stage[data-tab="settings"] .lv-gui-tab-thumb { left: calc(50% + var(--u) * 1.2); }
.lv-gui-search { .lv-gui-search {
margin-left: auto; display: flex; align-items: center; justify-content: center; width: 30px; height: 26px; position: absolute;
border-radius: 7px; color: var(--gui-text-muted); background: color-mix(in srgb, var(--gui-stroke-soft) 8%, transparent); z-index: 2;
top: calc(var(--u) * 26);
right: calc(var(--u) * 10);
display: grid;
place-items: center;
width: calc(var(--u) * 20);
height: calc(var(--u) * 20);
border-radius: calc(var(--u) * 6);
border: 1px solid rgb(255 255 255 / 0.16);
background: rgb(8 8 10 / 0.42);
backdrop-filter: blur(calc(var(--u) * 4));
transition: background-color 200ms;
} }
.lv-gui-search:hover { background: rgb(40 40 46 / 0.55); }
.lv-gui-search-icon { width: calc(var(--u) * 9); height: calc(var(--u) * 9); color: var(--gui-text); }
.lv-gui-body { display: flex; min-height: 0; flex: 1; } .lv-gui-screen {
.lv-gui-cats { position: absolute;
display: flex; flex-direction: column; gap: 3px; width: 46px; flex: none; padding: 10px 6px; inset: calc(var(--u) * 50) 0 auto;
border-right: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 14%, transparent); height: calc(var(--u) * 240);
background: color-mix(in srgb, black 12%, transparent); animation: lv-gui-in 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
} }
.lv-gui-cat { @keyframes lv-gui-in { from { opacity: 0; transform: translateY(calc(var(--u) * 8)) scale(0.97); } }
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 { .lv-gui-hints {
position: relative; flex: 1; min-width: 0; overflow: hidden; padding: 10px; position: absolute;
mask-image: linear-gradient(to bottom, #000 82%, transparent); left: calc(var(--u) * 8);
right: calc(var(--u) * 8);
bottom: calc(var(--u) * 5);
margin: 0;
padding: 0;
list-style: none;
columns: 2;
column-gap: calc(var(--u) * 8);
font-size: calc(var(--u) * 5.6);
line-height: 1.4;
color: rgb(255 255 255 / 0.78);
text-shadow: 0 1px 2px rgb(0 0 0 / 0.6);
white-space: nowrap;
} }
.lv-gui-grid { display: grid; grid-template-columns: 1fr; gap: 8px; align-items: start; animation: lv-gui-in 380ms ease both; } .lv-gui-hints li { overflow: hidden; text-overflow: ellipsis; }
@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 { .lv-gui-cursor {
position: absolute; left: 0; top: 0; z-index: 5; pointer-events: none; opacity: 0; position: absolute; left: 0; top: 0; z-index: 5; pointer-events: none; opacity: 0;
@ -207,14 +207,7 @@
} }
.lv-gui-cursor[data-pressed="true"] .lv-gui-cursor-ring { opacity: 0.9; transform: scale(1); box-shadow: 0 0 14px var(--gui-accent); } .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) { @media (prefers-reduced-motion: reduce) {
.lv-gui-cats { width: 128px; } .lv-gui-screen, .lv-gui-twinkle, .lv-gui-flake { animation: none; }
.lv-gui-cat { justify-content: flex-start; } .lv-gui-tab-thumb { transition: none; }
.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

@ -1,97 +1,67 @@
// In-game catalogue shown by the ClickGui replica. Module, category and setting names are // In-game catalogue shown by the ClickGui replica: the mod's `@ModuleInfo` display names grouped
// the mod's own identifiers and stay untranslated; descriptions live in the clickgui i18n. // the way `ModulesMenuCategory` groups them (MISC is shown as "Other"), sorted like the in-game
// list. Names are the mod's own labels and stay untranslated.
export type Setting = export type CategoryId = 'combat' | 'player' | 'visuals' | 'other'
| { 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 GuiModule = { name: string; on?: boolean }
export type CategoryId = 'combat' | 'visuals' | 'player' | 'misc'
export type Category = { id: CategoryId; name: string; modules: GuiModule[] } export type Category = { id: CategoryId; name: string; modules: GuiModule[] }
export type ModuleName = const list = (names: string[], on: string[] = []): GuiModule[] =>
| 'PvpCooldowns' | 'Predictions' | 'TargetESP' | 'DamageTint' | 'NoFriendDamage' | 'CombatLog' | 'TPSSync' [...names]
| 'ProjectilePuncher' | 'Trails' | 'JumpCircles' | 'HitBubbles' | 'AmbientParticles' | 'Wings' | 'ChinaHat' .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase(), 'en'))
| 'ShaderSky' | 'MotionBlur' | 'PostFX' | 'KillEffect' | 'ThirdPerson' | 'AutoTool' | 'DeathCoords' .map((name) => (on.includes(name) ? { name, on: true } : { name }))
| '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[] = [ export const CATEGORIES: Category[] = [
{ {
id: 'combat', id: 'combat',
name: 'Combat', name: 'Combat',
modules: [ modules: list(
{ name: 'TargetESP', on: true, settings: [slider('Size', 60), check('Pulse', true), color('Color', 'var(--gui-accent)')] }, ['CombatLog', 'DamageTint', 'No Friend Damage', 'Predictions', 'ProjectilePuncher', 'RubberHand', 'SpearAssist', 'TargetESP', 'TPSSync'],
{ name: 'PvpCooldowns', on: true }, ['No Friend Damage', 'Predictions', 'TargetESP'],
{ 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', id: 'player',
name: 'Player', name: 'Player',
modules: [ modules: list(
{ name: 'ThirdPerson', on: true, settings: [slider('Distance', 4, 12, 'm')] }, ['Auto Accept', 'Auto Leave', 'Auto Respawn', 'AutoTool', 'ClickPearl', 'Death Coords', 'FakeLag', 'Lock Slot', 'NoDelay',
{ name: 'AutoTool', on: true }, 'NoInteract', 'Tape Mouse', 'Third Person', 'Use Tracker'],
{ name: 'DeathCoords', on: true }, ['Auto Accept'],
{ name: 'AutoRespawn' }, ),
{ name: 'ClickPearl' },
{ name: 'LockSlot' },
{ name: 'UseTracker' },
{ name: 'NoDelay' },
],
}, },
{ {
id: 'misc', id: 'visuals',
name: 'Misc', name: 'Visuals',
modules: [ modules: list(
{ name: 'StreamerMode', settings: [check('Hide coords', true), check('Hide IP', true)] }, ['AlwaysVisibleHand', 'AmbientParticles', 'Animations', 'AspectRatio', 'AtmoDawnFog', 'BackSword', 'BlockESP', 'BlockHighlight',
{ name: 'NameProtect', on: true }, 'CameraClip', 'Chams', 'ChinaHat', 'Crosshair', 'CrystalHoles', 'DropESP', 'EndCrystal', 'ESP', 'FovControl', 'FPS Optimizer',
{ name: 'HitSounds', on: true, settings: [slider('Volume', 65)] }, 'Freecam', 'FullBright', 'Gps', 'Hitboxes', 'HitBubbles', 'HitEffect', 'InvisESP', 'Item Holder', 'Item Physic', 'JumpCircles',
{ name: 'ItemScroller' }, 'KillEffect', 'KineticLyrics', 'Light', 'Lightning Target ESP', 'MotionBlur', 'NameTags', 'NoRender', 'PostFX', 'Rain',
{ name: 'MessageFilter' }, 'ReimaginedVisual', 'ShaderSky', 'Shulker Preview', 'SoundESP', 'TazikHat', 'TotemFX', 'Trails', 'Trinket', 'Trollface Mask',
{ name: 'Optimize', on: true }, 'ViewModel', 'Wet World', 'Wings', 'WorldParticles', 'WorldTweaks', 'Zoom'],
{ name: 'NoServerPack' }, ['Chams'],
{ name: 'BetterMinecraft' }, ),
], },
{
id: 'other',
name: 'Other',
modules: list(
['Balancer', 'BetterMinecraft', 'ClickGUI', 'DefineTarget', 'FakePlayer', 'HitSounds', 'Item Scroller', 'MessageFilter',
'Name Protect', 'No Server Pack', 'NoSound', 'Optimize', 'PvpCooldowns', 'ShitESP', 'Streamer Mode'],
['ClickGUI'],
),
}, },
] ]
/** The ClickGui's top tab row — in-game labels. */ /** The pill bar's two segments — in-game labels. */
export const TABS = ['Modules', 'Themes', 'Configs', 'HUD'] as const export const TABS = ['modules', 'settings'] as const
export type TabId = (typeof TABS)[number]
/** Presets offered as quick dots in the category column (and cycled by the demo). */ /** Presets the scripted demo cycles through (all sit in the first rows of the theme grid). */
export const QUICK_THEMES = ['classic', 'nitro', 'sunset', 'aurora'] as const export const QUICK_THEMES = ['amber', 'purple', 'blue', 'classic'] as const
export const settingKey = (module: string, setting: string) => `${module}.${setting}` /** The settings sidebar, top to bottom; `null` draws a divider. Only "themes" has a page. */
export const SIDEBAR = ['hud', 'ui', 'themes', null, 'layout', 'account', null, 'configs'] as const
export const findModule = (name: string) => CATEGORIES.flatMap((c) => c.modules).find((m) => m.name === name) export type SidebarId = Exclude<(typeof SIDEBAR)[number], null>

View file

@ -0,0 +1,56 @@
import type { Dispatch } from 'react'
import { useTranslation } from 'react-i18next'
import type { GuiAction, GuiState } from '../demo'
import { Icon } from '../parts/Glyphs'
import { type Category, CATEGORIES } from '../parts/modules'
type Props = { state: GuiState; dispatch: Dispatch<GuiAction> }
/** `ModulesMenuScreen`: the four liquid-glass category panels, side by side. */
export function ModulesScreen({ state, dispatch }: Props) {
const { t } = useTranslation('clickgui')
return (
<div className="lv-gui-panels" role="list" aria-label={t('categories')}>
{CATEGORIES.map((c) => (
<Panel key={c.id} category={c} state={state} dispatch={dispatch} />
))}
</div>
)
}
function Panel({ category, state, dispatch }: { category: Category } & Props) {
const { t } = useTranslation('clickgui')
const titleId = `lv-gui-panel-${category.id}`
return (
<section role="listitem" className="lv-gui-panel" data-category={category.id} aria-labelledby={titleId}>
<header className="lv-gui-panel-head">
<h3 id={titleId} className="lv-gui-panel-title">
{category.name}
</h3>
<Icon name={category.id} className="lv-gui-panel-icon" />
</header>
<ul className="lv-gui-rows">
{category.modules.map((m) => {
const on = Boolean(state.enabled[m.name])
return (
<li key={m.name}>
<button
type="button"
className="lv-gui-row"
data-on={on}
data-demo={`module:${m.name}`}
aria-pressed={on}
title={t('module', { name: m.name })}
onClick={() => dispatch({ type: 'toggle', module: m.name })}
onContextMenu={(e) => e.preventDefault()}
>
<span className="lv-gui-row-name">{m.name}</span>
{on && <Icon name="check" className="lv-gui-row-check" />}
</button>
</li>
)
})}
</ul>
</section>
)
}

View file

@ -0,0 +1,103 @@
import type { CSSProperties } from 'react'
import { useTranslation } from 'react-i18next'
import { PRESETS } from '../../themes/presets.generated'
import { argbToCss, type GradientSpec, type ThemeColors, type ThemeEntry } from '../../themes/types'
import { Icon } from '../parts/Glyphs'
import { SIDEBAR } from '../parts/modules'
type Props = { activeId: string; onPick: (id: string) => void }
/** `MenuScreen` on its "Темы" page: icon sidebar + a two-column grid of theme cards. */
export function SettingsScreen({ activeId, onPick }: Props) {
const { t } = useTranslation('clickgui')
return (
<div className="lv-gui-menu">
<nav className="lv-gui-side" aria-label={t('settingsNav')}>
{SIDEBAR.map((id, i) =>
id === null ? (
<span key={`split-${i}`} className="lv-gui-side-split" />
) : (
<span
key={id}
className="lv-gui-side-item"
data-active={id === 'themes'}
aria-current={id === 'themes' ? 'page' : undefined}
aria-label={t(`sidebar.${id}`)}
role="img"
>
{id === 'hud' || id === 'ui' ? t(`sidebar.${id}`) : <Icon name={id} className="lv-gui-side-icon" />}
</span>
),
)}
</nav>
<div className="lv-gui-menu-body">
<header className="lv-gui-menu-head">
<Icon name="themes" className="lv-gui-menu-head-icon" />
<h3>{t('themes')}</h3>
<span className="lv-gui-menu-search" aria-hidden="true">
{t('search')}
<i />
<Icon name="search" className="lv-gui-menu-search-icon" />
</span>
</header>
<div className="lv-gui-themes" role="listbox" aria-label={t('themes')}>
{PRESETS.map((p) => (
<ThemeCard key={p.id} entry={p} selected={p.id === activeId} onPick={onPick} />
))}
</div>
</div>
</div>
)
}
/** `ThemeCardRenderer.fillSwatchPalette`: the 12 chips, each a start→end pair of palette slots. */
const CHIPS: [keyof ThemeColors, keyof ThemeColors][] = [
['windowBg', 'windowHeader'],
['cardEnabled', 'accentSoft'],
['windowHeader', 'windowStroke'],
['surface', 'surfaceHover'],
['surfaceHover', 'windowHeader'],
['accent', 'accentSoft'],
['accentSoft', 'strokeSoft'],
['windowStroke', 'strokeSoft'],
['strokeSoft', 'textMuted'],
['textPrimary', 'textMuted'],
['textMuted', 'surface'],
['windowBg', 'accentSoft'],
]
const chipGradient = (from: string, to: string, spec?: GradientSpec) => {
const [a, b] = spec?.enabled ? [spec.start, spec.end] : [from, to]
return `linear-gradient(135deg, ${argbToCss(a)}, ${argbToCss(b)})`
}
function ThemeCard({ entry, selected, onPick }: { entry: ThemeEntry; selected: boolean; onPick: (id: string) => void }) {
const { t } = useTranslation('clickgui')
const c = entry.theme
const style = {
'--card-accent': argbToCss(c.accent),
'--card-accent-soft': argbToCss(c.accentSoft),
'--card-bg': argbToCss(c.windowBg),
} as CSSProperties
return (
<button
type="button"
role="option"
aria-selected={selected}
aria-label={t('theme', { name: entry.name })}
className="lv-gui-theme"
data-selected={selected}
data-demo={`theme:${entry.id}`}
style={style}
onClick={() => onPick(entry.id)}
>
<span className="lv-gui-theme-name">{entry.name}</span>
<span className="lv-gui-theme-chips" aria-hidden="true">
{CHIPS.map(([from, to], i) => {
const spec = i === 0 ? entry.windowGradient : i === 1 ? entry.cardGradient : undefined
return <i key={i} style={{ background: chipGradient(c[from], c[to], spec) }} />
})}
</span>
</button>
)
}

View file

@ -0,0 +1,248 @@
/* The two ClickGui pages in the mod's logical units (--u, see parts/clickgui.css).
Modules: ModulesMenuScreen — PANEL_W 115, PANEL_H 240, gap 14, HEADER_H 24, SEPARATOR_H 4,
MODULE_ROW_H 20, radius 10, text inset 10. Settings: MenuScreen on the Темы page. */
.lv-gui-panels {
display: flex;
justify-content: center;
gap: calc(var(--u) * 14);
height: 100%;
}
.lv-gui-panel[data-category="player"], .lv-gui-panel[data-category="other"] { display: none; }
@container (min-width: 900px) {
.lv-gui-panel[data-category="player"], .lv-gui-panel[data-category="other"] { display: block; }
}
/* Liquid glass: near-opaque at the top, fading to a light veil so the world shows through. */
.lv-gui-panel {
position: relative;
flex: none;
width: calc(var(--u) * 115);
height: 100%;
overflow: hidden;
border-radius: calc(var(--u) * 10);
border: 1px solid var(--gui-panel-stroke);
background:
linear-gradient(180deg, rgb(255 255 255 / 0.05), transparent 12%),
linear-gradient(180deg,
color-mix(in srgb, var(--gui-panel-tint) 92%, black) 0%,
color-mix(in srgb, var(--gui-panel-tint) 78%, transparent) 22%,
color-mix(in srgb, var(--gui-panel-tint) 46%, transparent) 55%,
color-mix(in srgb, var(--gui-panel-tint) 22%, transparent) 100%);
box-shadow: 0 calc(var(--u) * 5) calc(var(--u) * 18) rgb(0 0 0 / 0.28), inset 0 1px 0 rgb(255 255 255 / 0.1),
inset 0 -1px 0 rgb(255 255 255 / 0.06);
backdrop-filter: blur(calc(var(--u) * 5)) saturate(1.3);
}
.lv-gui-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
height: calc(var(--u) * 24);
padding: calc(var(--u) * 1) calc(var(--u) * 10) 0;
}
.lv-gui-panel-head::after {
content: "";
position: absolute;
left: calc(var(--u) * 8);
right: calc(var(--u) * 8);
top: calc(var(--u) * 24);
height: max(1px, calc(var(--u) * 0.5));
background: var(--gui-split);
}
.lv-gui-panel-title { margin: 0; font-family: inherit; font-size: calc(var(--u) * 9); font-weight: 600; letter-spacing: 0.005em; color: var(--gui-text); }
.lv-gui-panel-icon { width: calc(var(--u) * 8); height: calc(var(--u) * 8); color: color-mix(in srgb, var(--gui-row-text) 90%, transparent); }
.lv-gui-rows {
position: absolute;
inset: calc(var(--u) * 27.5) calc(var(--u) * 3) calc(var(--u) * 0.5) 0;
margin: 0;
padding: 0;
list-style: none;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--gui-text) 34%, transparent) color-mix(in srgb, var(--gui-text) 7%, transparent);
}
.lv-gui-rows::-webkit-scrollbar { width: calc(var(--u) * 2.5); }
.lv-gui-rows::-webkit-scrollbar-track { background: color-mix(in srgb, var(--gui-text) 7%, transparent); border-radius: 9px; }
.lv-gui-rows::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--gui-text) 34%, transparent); border-radius: 9px; }
.lv-gui-row {
position: relative;
display: flex;
align-items: center;
width: calc(var(--u) * 108);
height: calc(var(--u) * 20);
padding: 0 calc(var(--u) * 17) 0 calc(var(--u) * 10);
border: 0;
background: none;
font-size: calc(var(--u) * 8) !important;
font-weight: 400;
color: color-mix(in srgb, var(--gui-row-text), var(--gui-text) 25%) !important;
transition: color 250ms, padding 250ms;
}
.lv-gui-row::before {
content: "";
position: absolute;
inset: calc(var(--u) * 2) calc(var(--u) * 3);
border-radius: calc(var(--u) * 5);
background: radial-gradient(90% 140% at 50% 50%, var(--gui-row-glow), transparent 75%), var(--gui-row-hover);
opacity: 0;
transition: opacity 220ms;
}
.lv-gui-row::after {
content: "";
position: absolute;
left: calc(var(--u) * 3);
right: calc(var(--u) * 3);
bottom: 0;
height: 1px;
background: color-mix(in srgb, var(--gui-text) 5%, transparent);
}
.lv-gui-row:hover::before { opacity: 0.8; }
.lv-gui-row[data-on="true"] { padding-left: calc(var(--u) * 12); color: var(--gui-text) !important; }
.lv-gui-row[data-on="true"]::before { opacity: 0.45; }
.lv-gui-row[data-on="true"]:hover::before { opacity: 1; }
.lv-gui-row-name { position: relative; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.lv-gui-row-check {
position: absolute;
right: calc(var(--u) * 11);
top: calc(var(--u) * 7);
width: calc(var(--u) * 6);
height: calc(var(--u) * 6);
color: var(--gui-text);
stroke-width: 2.4;
animation: lv-gui-check 260ms ease both;
}
@keyframes lv-gui-check { from { opacity: 0; transform: translateX(calc(var(--u) * 2)) scale(0.6); } }
/* Settings → Темы: a centered window with an icon sidebar and the theme-card grid. */
.lv-gui-menu {
position: absolute;
left: 50%;
top: 50%;
display: grid;
grid-template-columns: calc(var(--u) * 30) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
width: calc(var(--u) * 250);
height: calc(var(--u) * 226);
transform: translate(-50%, -50%);
overflow: hidden;
border-radius: calc(var(--u) * 5);
border: 1px solid color-mix(in srgb, var(--gui-text) 13%, transparent);
background: linear-gradient(90deg, var(--gui-menu-left), var(--gui-menu-right));
box-shadow: 0 calc(var(--u) * 6) calc(var(--u) * 22) rgb(0 0 0 / 0.45);
backdrop-filter: blur(calc(var(--u) * 6)) saturate(1.2);
}
@container (min-width: 900px) {
.lv-gui-menu { width: calc(var(--u) * 336); height: calc(var(--u) * 196); }
}
.lv-gui-side {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: calc(var(--u) * 4);
border-right: 1px solid color-mix(in srgb, var(--gui-text) 9%, transparent);
background: rgb(0 0 0 / 0.12);
}
.lv-gui-side-item {
display: grid;
place-items: center;
width: calc(var(--u) * 15);
height: calc(var(--u) * 15);
border-radius: 50%;
font-size: calc(var(--u) * 4.4);
font-weight: 500;
color: var(--gui-text);
}
.lv-gui-side-item[data-active="true"] { background: color-mix(in srgb, var(--gui-text) 9%, transparent); }
.lv-gui-side-icon { width: calc(var(--u) * 6.4); height: calc(var(--u) * 6.4); }
.lv-gui-side-split { width: calc(var(--u) * 16); height: 1px; background: color-mix(in srgb, var(--gui-text) 12%, transparent); }
.lv-gui-menu-body { display: flex; flex-direction: column; min-width: 0; padding: calc(var(--u) * 5) calc(var(--u) * 4) 0 calc(var(--u) * 6); }
.lv-gui-menu-head { display: flex; align-items: center; gap: calc(var(--u) * 2.6); height: calc(var(--u) * 14); flex: none; }
.lv-gui-menu-head h3 { margin: 0; font-family: inherit; font-size: calc(var(--u) * 6.4); font-weight: 500; }
.lv-gui-menu-head-icon { width: calc(var(--u) * 6.2); height: calc(var(--u) * 6.2); }
.lv-gui-menu-search {
display: flex;
align-items: center;
gap: calc(var(--u) * 2.4);
margin-left: auto;
padding: calc(var(--u) * 1.4) calc(var(--u) * 3);
border-radius: calc(var(--u) * 2);
background: rgb(0 0 0 / 0.22);
font-size: calc(var(--u) * 5.6);
color: var(--gui-text-muted);
}
.lv-gui-menu-search i { width: 1px; height: calc(var(--u) * 5.4); background: color-mix(in srgb, var(--gui-text) 22%, transparent); }
.lv-gui-menu-search-icon { width: calc(var(--u) * 5); height: calc(var(--u) * 5); }
.lv-gui-themes {
display: grid;
grid-template-columns: 1fr 1fr;
grid-auto-rows: calc(var(--u) * 31);
gap: calc(var(--u) * 4);
flex: 1;
min-height: 0;
margin-top: calc(var(--u) * 2);
padding: calc(var(--u) * 1) calc(var(--u) * 5) calc(var(--u) * 5) calc(var(--u) * 0.5);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--gui-text) 30%, transparent) transparent;
}
.lv-gui-theme {
position: relative;
overflow: hidden;
padding: 0;
border: 0;
border-radius: calc(var(--u) * 2.2);
text-align: left;
background:
radial-gradient(120% 110% at 0% 0%, color-mix(in srgb, var(--card-accent) 16%, transparent), transparent 70%),
linear-gradient(180deg, color-mix(in srgb, var(--gui-panel-tint) 88%, white 4%), color-mix(in srgb, var(--card-bg) 70%, black));
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.05);
transition: box-shadow 300ms, transform 200ms;
}
.lv-gui-theme:hover { transform: translateY(-1px); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.14); }
.lv-gui-theme::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: calc(var(--u) * 10.5);
height: 1px;
background: linear-gradient(90deg, rgb(255 255 255 / 0.14), rgb(255 255 255 / 0.05));
}
.lv-gui-theme[data-selected="true"] {
background:
linear-gradient(160deg, color-mix(in srgb, var(--card-accent) 34%, transparent), color-mix(in srgb, var(--card-accent-soft) 12%, transparent)),
linear-gradient(180deg, color-mix(in srgb, var(--gui-panel-tint) 88%, white 4%), color-mix(in srgb, var(--card-bg) 70%, black));
box-shadow: inset 0 0 0 1px var(--card-accent), 0 0 calc(var(--u) * 7) color-mix(in srgb, var(--card-accent) 30%, transparent);
}
.lv-gui-theme[data-selected="true"]::before { background: linear-gradient(90deg, var(--card-accent), var(--card-accent-soft)); }
.lv-gui-theme-name {
position: absolute;
left: calc(var(--u) * 4);
top: calc(var(--u) * 2.4);
font-size: calc(var(--u) * 5.3);
font-weight: 500;
color: #fff;
}
.lv-gui-theme[data-selected="true"] .lv-gui-theme-name { color: color-mix(in srgb, #fff 76%, var(--card-accent)); }
.lv-gui-theme-chips {
position: absolute;
inset: calc(var(--u) * 11) calc(var(--u) * 4) 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.lv-gui-theme-chips i {
width: calc(var(--u) * 5.4);
height: calc(var(--u) * 5.4);
border-radius: calc(var(--u) * 1.3);
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 0.12);
transition: transform 200ms;
}
.lv-gui-theme-chips i:hover { transform: scale(1.18); }

View file

@ -1,47 +1,95 @@
import { act, render, screen, within } from '@testing-library/react' import { act, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest' import { expect, test, vi } from 'vitest'
import { PRESETS } from '../../themes/presets.generated'
import { argbToCss } from '../../themes/types'
import { ActiveThemeProvider } from '../../themes/useActiveTheme' import { ActiveThemeProvider } from '../../themes/useActiveTheme'
import { ClickGuiWindow } from '../ClickGuiWindow' import { ClickGuiWindow } from '../ClickGuiWindow'
import { DEMO, guiReducer, initialGuiState, LOOP_MS } from '../demo' import { DEMO, guiReducer, initialGuiState, LOOP_MS } from '../demo'
const renderGui = () => render(<ActiveThemeProvider applyToPage={false}><ClickGuiWindow /></ActiveThemeProvider>) const renderGui = () => render(<ActiveThemeProvider applyToPage={false}><ClickGuiWindow /></ActiveThemeProvider>)
const row = (name: string) => screen.getByRole('button', { name: new RegExp(`^${name}$`) })
test('reduced motion shows the finished demo frame with no cursor', () => { test('the Modules screen shows the four in-game category panels', () => {
renderGui() renderGui()
expect(screen.getByRole('button', { name: 'Visuals' })).toHaveAttribute('aria-pressed', 'true') const panels = within(screen.getByRole('list', { name: 'Категории модулей' })).getAllByRole('listitem', { name: /./ })
expect(screen.getByRole('switch', { name: 'Включить Trails' })).toHaveAttribute('aria-checked', 'true') expect(panels).toHaveLength(4)
expect(screen.getByRole('slider', { name: 'Length' })).toHaveValue('85') for (const title of ['Combat', 'Player', 'Visuals', 'Other']) expect(screen.getByRole('heading', { name: title })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Modules' })).toHaveAttribute('aria-selected', 'true')
}) })
test('cards are interactive: switch categories, toggle and expand', async () => { test('enabled modules carry a check mark; clicking a row toggles it', async () => {
const user = userEvent.setup() const user = userEvent.setup()
renderGui() renderGui()
await user.click(screen.getByRole('button', { name: 'Misc' })) const predictions = row('Predictions')
const toggle = screen.getByRole('switch', { name: 'Включить StreamerMode' }) expect(predictions).toHaveAttribute('aria-pressed', 'true')
expect(toggle).toHaveAttribute('aria-checked', 'false') expect(predictions.querySelector('.lv-gui-row-check')).not.toBeNull()
await user.click(toggle) const autoLeave = row('Auto Leave')
expect(toggle).toHaveAttribute('aria-checked', 'true') expect(autoLeave.querySelector('.lv-gui-row-check')).toBeNull()
await user.click(screen.getByRole('button', { name: 'Настройки StreamerMode' })) await user.click(autoLeave)
const card = toggle.closest('article') as HTMLElement expect(autoLeave).toHaveAttribute('aria-pressed', 'true')
expect(within(card).getByRole('checkbox', { name: /Hide IP/ })).toHaveAttribute('aria-checked', 'true') expect(autoLeave.querySelector('.lv-gui-row-check')).not.toBeNull()
}) })
test('the scripted demo toggles Trails and loops back to the start', () => { test('reduced motion shows the finished demo frame', () => {
renderGui()
expect(row('AmbientParticles')).toHaveAttribute('aria-pressed', 'true')
expect(row('DamageTint')).toHaveAttribute('aria-pressed', 'true')
})
test('Settings lists every preset as a theme card, and picking one re-themes the GUI', async () => {
const user = userEvent.setup()
renderGui()
await user.click(screen.getByRole('tab', { name: 'Settings' }))
expect(screen.getByRole('heading', { name: 'Темы' })).toBeInTheDocument()
expect(screen.getAllByRole('option')).toHaveLength(PRESETS.length)
const nitro = PRESETS.find((p) => p.id === 'nitro')!
await user.click(screen.getByRole('option', { name: 'Тема Nitro' }))
expect(screen.getByRole('option', { name: 'Тема Nitro' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByTestId('clickgui-root').style.getPropertyValue('--gui-accent')).toBe(argbToCss(nitro.theme.accent))
await user.click(screen.getByRole('tab', { name: 'Modules' }))
expect(screen.getByRole('heading', { name: 'Combat' })).toBeInTheDocument()
})
test('the scripted demo toggles modules, visits Settings and loops back', () => {
vi.useFakeTimers() vi.useFakeTimers()
vi.stubGlobal('matchMedia', (q: string) => ({ matches: !q.includes('reduce'), addEventListener() {}, removeEventListener() {} })) vi.stubGlobal('matchMedia', (q: string) => ({ matches: !q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
renderGui() renderGui()
expect(screen.getByRole('button', { name: 'Combat' })).toHaveAttribute('aria-pressed', 'true') expect(row('AmbientParticles')).toHaveAttribute('aria-pressed', 'false')
act(() => vi.advanceTimersByTime(3200)) act(() => vi.advanceTimersByTime(2000))
expect(screen.getByRole('switch', { name: 'Включить Trails' })).toHaveAttribute('aria-checked', 'true') expect(row('AmbientParticles')).toHaveAttribute('aria-pressed', 'true')
act(() => vi.advanceTimersByTime(LOOP_MS)) act(() => vi.advanceTimersByTime(3000))
expect(screen.getByRole('button', { name: 'Combat' })).toHaveAttribute('aria-pressed', 'true') expect(screen.getByRole('tab', { name: 'Settings' })).toHaveAttribute('aria-selected', 'true')
act(() => vi.advanceTimersByTime(LOOP_MS + 600 - 5000 + 500)) // into the next cycle, before its first click
expect(screen.getByRole('tab', { name: 'Modules' })).toHaveAttribute('aria-selected', 'true')
expect(row('AmbientParticles')).toHaveAttribute('aria-pressed', 'false')
vi.unstubAllGlobals()
vi.useRealTimers() vi.useRealTimers()
}) })
test('demo timeline fits in the loop and the reducer resets', () => { test('demo timeline fits in the loop and the reducer resets', () => {
expect(Math.max(...DEMO.map((s) => s.at))).toBeLessThan(LOOP_MS) expect(Math.max(...DEMO.map((s) => s.at))).toBeLessThan(LOOP_MS)
const toggled = guiReducer(initialGuiState(), { type: 'toggle', module: 'Trails' }) const toggled = guiReducer(initialGuiState(), { type: 'toggle', module: 'Chams' })
expect(toggled.enabled.Trails).toBe(true) expect(toggled.enabled.Chams).toBe(false)
expect(guiReducer(toggled, { type: 'reset' }).enabled.Trails).toBe(false) const settings = guiReducer(toggled, { type: 'tab', id: 'settings' })
expect(guiReducer(settings, { type: 'reset' })).toEqual(initialGuiState())
})
test('controlled mode: onPickTheme receives the card and the site theme stays put; backdrop is swappable', async () => {
const user = userEvent.setup()
const onPickTheme = vi.fn()
render(
<ActiveThemeProvider applyToPage={false}>
<ClickGuiWindow demo={false} onPickTheme={onPickTheme} backdrop="/shots/f1.png" />
</ActiveThemeProvider>,
)
const root = screen.getByTestId('clickgui-root')
expect(root.style.getPropertyValue('--gui-backdrop-image')).toBe('url("/shots/f1.png")')
await user.click(screen.getByRole('tab', { name: 'Settings' }))
const before = screen.getByRole('option', { selected: true })
const accent = root.style.getPropertyValue('--gui-accent')
await user.click(screen.getByRole('option', { name: 'Тема Legacy' }))
expect(onPickTheme).toHaveBeenCalledWith('legacy')
expect(screen.getByRole('option', { selected: true })).toBe(before)
expect(root.style.getPropertyValue('--gui-accent')).toBe(accent)
}) })

View file

@ -1,5 +1,5 @@
import type { CSSProperties } from 'react' import type { CSSProperties } from 'react'
import { argbToCss, type GradientSpec, type ThemeEntry, withAlpha } from '../themes/types' import { argbToCss, type GradientSpec, parseArgb, type ThemeEntry, withAlpha } from '../themes/types'
type CssVars = CSSProperties & Record<`--${string}`, string> type CssVars = CSSProperties & Record<`--${string}`, string>
@ -46,6 +46,7 @@ export function themeToCssVars(entry: ThemeEntry): CssVars {
'--gui-stroke-soft': argbToCss(t.strokeSoft), '--gui-stroke-soft': argbToCss(t.strokeSoft),
'--gui-accent-glow': withAlpha(t.accent, 0.45), '--gui-accent-glow': withAlpha(t.accent, 0.45),
} }
Object.assign(vars, derivedVars(entry))
for (const [slot, key, flat] of GRADIENT_SLOTS) { for (const [slot, key, flat] of GRADIENT_SLOTS) {
const spec = entry[key] const spec = entry[key]
vars[`--gui-${slot}-gradient`] = gradientToCss(spec, t[flat]) vars[`--gui-${slot}-gradient`] = gradientToCss(spec, t[flat])
@ -55,3 +56,41 @@ export function themeToCssVars(entry: ThemeEntry): CssVars {
} }
return vars return vars
} }
type Argb = { r: number; g: number; b: number; a: number }
/** `ClickGuiDrawHelpers.mixColor`: per-channel lerp of two packed ARGB colors, alpha included. */
function mix(from: Argb, to: Argb, t: number): Argb {
const l = (x: number, y: number) => Math.round(x * (1 - t) + y * t)
return { r: l(from.r, to.r), g: l(from.g, to.g), b: l(from.b, to.b), a: l(from.a, to.a) }
}
const opaque = (c: Argb): Argb => ({ ...c, a: 255 })
const css = (c: Argb, alpha = c.a) => `rgba(${c.r}, ${c.g}, ${c.b}, ${Number((alpha / 255).toFixed(3))})`
/**
* Colors the in-game renderers derive from the palette at draw time — `ModulesMenuStyle.syncTheme`
* (category panels) and `ClickGuiTabBar.drawTabs` (the Modules/Settings pill) — so the replica
* gets the very same tints instead of approximating them in CSS.
*/
function derivedVars(entry: ThemeEntry): CssVars {
const p = Object.fromEntries(Object.entries(entry.theme).map(([k, v]) => [k, parseArgb(v)])) as Record<
keyof ThemeEntry['theme'],
Argb
>
const accent = opaque(p.accent)
return {
'--gui-panel-tint': css(mix(opaque(p.surface), accent, 0.07)),
'--gui-panel-stroke': css(mix(opaque(p.strokeSoft), accent, 0.155), 76),
'--gui-split': css(mix(opaque(p.strokeSoft), accent, 0.12), 34),
'--gui-row-hover': css(mix(opaque(p.surfaceHover), accent, 0.125), 34),
'--gui-row-glow': css(mix(opaque(p.accentSoft), accent, 0.42), 92),
'--gui-row-text': css(opaque(p.textMuted), 210),
'--gui-tab-left': css(mix(p.surface, p.windowBg, 0.34)),
'--gui-tab-right': css(mix(p.surfaceHover, p.windowBg, 0.22)),
'--gui-tab-active-left': css(mix(p.accentSoft, p.accent, 0.18)),
'--gui-tab-active-right': css(mix(p.accent, p.textPrimary, 0.08)),
'--gui-menu-left': css(mix(p.windowBg, p.surface, 0.18), 215),
'--gui-menu-right': css(mix(p.windowBg, p.windowHeader, 0.3), 215),
}
}

View file

@ -0,0 +1,18 @@
import { api } from '../../shared/api/client'
import type { SlotSummary } from './types'
export function listSlots(): Promise<SlotSummary[]> {
return api.request('/configs')
}
export function regenerateCode(slot: number): Promise<{ share_code: string }> {
return api.request(`/configs/${slot}/regenerate-code`, { method: 'POST' })
}
export function publish(slot: number, title: string, description: string): Promise<{ listing_id: string }> {
return api.request(`/configs/${slot}/publish`, { method: 'POST', json: { title, description } })
}
export function unpublish(slot: number): Promise<void> {
return api.request(`/configs/${slot}/publish`, { method: 'DELETE' })
}

View file

@ -0,0 +1,73 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../../../shared/ui/Button'
import { TextField } from '../../../shared/ui/TextField'
import { publish } from '../api'
const MAX_DESCRIPTION = 500
export function PublishForm({
slot,
onCancel,
onPublished,
}: {
slot: number
onCancel: () => void
onPublished: () => void
}) {
const { t } = useTranslation('configs')
const client = useQueryClient()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [touched, setTouched] = useState(false)
const mutation = useMutation({
mutationFn: () => publish(slot, title.trim(), description),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['configs'] })
onPublished()
},
})
const invalid = touched && title.trim().length === 0
function submit() {
setTouched(true)
if (title.trim().length === 0 || title.trim().length > 64 || description.length > MAX_DESCRIPTION) return
mutation.mutate()
}
return (
<div className="flex flex-col gap-3 rounded-md border border-shoal bg-abyss/60 p-3">
<TextField
label={t('publishForm.title')}
value={title}
onChange={(e) => setTitle(e.target.value)}
error={invalid ? t('publishForm.titleRequired') : undefined}
maxLength={64}
/>
<div className="flex flex-col gap-1.5">
<label htmlFor="publish-description" className="text-sm text-frost">
{t('publishForm.description')}
</label>
<textarea
id="publish-description"
value={description}
onChange={(e) => setDescription(e.target.value.slice(0, MAX_DESCRIPTION))}
rows={3}
className="rounded-md border border-shoal bg-abyss px-3 py-2 text-snow focus:border-ice"
/>
<span className="self-end text-xs text-frost">{t('publishForm.counter', { count: description.length })}</span>
</div>
<div className="flex gap-2">
<Button variant="primary" busy={mutation.isPending} onClick={submit}>
{t('publishForm.submit')}
</Button>
<Button variant="quiet" onClick={onCancel}>
{t('publishForm.cancel')}
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query'
import { listSlots } from '../api'
import type { SlotSummary } from '../types'
import { SlotPanel } from './SlotPanel'
const SLOTS = [1, 2, 3, 4] as const
export function SlotGrid() {
const { data } = useQuery({ queryKey: ['configs'], queryFn: listSlots })
const bySlot = new Map<number, SlotSummary>((data ?? []).map((s) => [s.slot, s]))
return (
<ul className="grid grid-cols-1 gap-4 min-[640px]:grid-cols-2">
{SLOTS.map((slot) => (
<li key={slot}>
<SlotPanel slot={slot} data={bySlot.get(slot) ?? null} />
</li>
))}
</ul>
)
}

View file

@ -0,0 +1,124 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../../../shared/ui/Button'
import { Notice } from '../../../shared/ui/Notice'
import { ShareCode } from '../../../shared/ui/ShareCode'
import { regenerateCode, unpublish } from '../api'
import type { SlotSummary } from '../types'
import { PublishForm } from './PublishForm'
function formatDate(iso: string): string {
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(iso))
}
export function SlotPanel({ slot, data }: { slot: 1 | 2 | 3 | 4; data: SlotSummary | null }) {
const { t } = useTranslation('configs')
const client = useQueryClient()
const titleId = useId()
const [confirmingCode, setConfirmingCode] = useState(false)
const [confirmingUnpublish, setConfirmingUnpublish] = useState(false)
const [publishing, setPublishing] = useState(false)
const [notice, setNotice] = useState<string | null>(null)
const regen = useMutation({
mutationFn: () => regenerateCode(slot),
onSuccess: (res) => {
client.setQueryData<SlotSummary[]>(['configs'], (prev) =>
(prev ?? []).map((s) => (s.slot === slot ? { ...s, share_code: res.share_code } : s)),
)
setConfirmingCode(false)
},
})
const unpub = useMutation({
mutationFn: () => unpublish(slot),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['configs'] })
setConfirmingUnpublish(false)
setNotice(t('slot.unpublished'))
},
})
return (
<article
aria-labelledby={titleId}
className="flex flex-col gap-3 rounded-(--radius-panel) border border-shoal bg-slate/60 px-4 py-4 shadow-[inset_0_1px_0_rgb(255_255_255/0.05)]"
>
<div className="flex items-center gap-2">
<span aria-hidden="true" className="size-2 rounded-full bg-ice shadow-[0_0_6px_var(--color-ice)]" />
<h3 id={titleId} className="font-display text-lg text-snow">
{t('slot.heading', { slot })}
</h3>
</div>
{!data ? (
<div className="flex flex-col gap-1">
<p className="text-frost">{t('slot.empty')}</p>
<p className="font-code text-sm text-frost/80">{t('slot.emptyHint', { cmd: `%config save ${slot}` })}</p>
</div>
) : (
<div className="flex flex-col gap-3">
<p className="font-display text-xl text-snow">{data.name}</p>
<p className="text-sm text-frost">{t('slot.updated', { when: formatDate(data.updated_at) })}</p>
<ShareCode code={data.share_code} label={t('slot.codeLabel')} />
<p className="font-code text-xs text-frost/80">{t('slot.codeHint', { cmd: `%config load ${data.share_code}` })}</p>
{notice ? <Notice tone="info">{notice}</Notice> : null}
<div className="flex flex-wrap items-center gap-2">
{confirmingCode ? (
<>
<span className="text-sm text-ember">{t('slot.newCodeConfirm')}</span>
<Button variant="danger" busy={regen.isPending} onClick={() => regen.mutate()}>
{t('slot.change')}
</Button>
<Button variant="quiet" onClick={() => setConfirmingCode(false)}>
{t('slot.cancel')}
</Button>
</>
) : (
<Button variant="quiet" onClick={() => setConfirmingCode(true)}>
{t('slot.newCode')}
</Button>
)}
{!publishing &&
(data.published ? (
confirmingUnpublish ? (
<>
<span className="text-sm text-ember">{t('slot.unpublishConfirm')}</span>
<Button variant="danger" busy={unpub.isPending} onClick={() => unpub.mutate()}>
{t('slot.confirmUnpublish')}
</Button>
<Button variant="quiet" onClick={() => setConfirmingUnpublish(false)}>
{t('slot.cancel')}
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirmingUnpublish(true)}>
{t('slot.unpublish')}
</Button>
)
) : (
<Button variant="primary" onClick={() => setPublishing(true)}>
{t('slot.publish')}
</Button>
))}
</div>
{publishing ? (
<PublishForm
slot={slot}
onCancel={() => setPublishing(false)}
onPublished={() => {
setPublishing(false)
setNotice(t('slot.published'))
}}
/>
) : null}
</div>
)}
</article>
)
}

View file

@ -0,0 +1,33 @@
export const configsEn = {
page: {
title: 'My configs',
leadBefore: 'Configs are saved from the mod with',
leadAfter: '. This is where you get codes and publish.',
},
slot: {
heading: 'Slot {{slot}}',
empty: 'Empty',
emptyHint: 'Save a config here from the mod: {{cmd}}',
updated: 'Updated {{when}}',
codeLabel: 'Code for friends',
codeHint: 'A friend types this in-game: {{cmd}}',
newCode: 'New code',
newCodeConfirm: 'The old code will stop working. Change it?',
change: 'Change',
cancel: 'Cancel',
publish: 'Publish',
unpublish: 'Remove from showcase',
unpublishConfirm: 'The config will disappear from the showcase. Remove it?',
confirmUnpublish: 'Remove',
published: 'Published. The config is now visible in the showcase.',
unpublished: 'Removed from the showcase.',
},
publishForm: {
title: 'Showcase title',
description: 'Description',
counter: '{{count}}/500',
submit: 'Publish',
cancel: 'Cancel',
titleRequired: 'Title is required',
},
} as const

View file

@ -0,0 +1,33 @@
export const configsRu = {
page: {
title: 'Мои конфиги',
leadBefore: 'Конфиги сохраняются из мода командой',
leadAfter: '. Здесь коды и публикация.',
},
slot: {
heading: 'Слот {{slot}}',
empty: 'Пусто',
emptyHint: 'Сохрани сюда конфиг из мода: {{cmd}}',
updated: 'Обновлён {{when}}',
codeLabel: 'Код для друзей',
codeHint: 'Друг вводит в игре: {{cmd}}',
newCode: 'Новый код',
newCodeConfirm: 'Старый код перестанет работать. Сменить?',
change: 'Сменить',
cancel: 'Отмена',
publish: 'Опубликовать',
unpublish: 'Убрать с витрины',
unpublishConfirm: 'Конфиг исчезнет с витрины. Убрать?',
confirmUnpublish: 'Убрать',
published: 'Опубликовано. Конфиг виден на витрине.',
unpublished: 'Убрано с витрины.',
},
publishForm: {
title: 'Название на витрине',
description: 'Описание',
counter: '{{count}}/500',
submit: 'Опубликовать',
cancel: 'Отмена',
titleRequired: 'Название обязательно',
},
} as const

View file

@ -0,0 +1,64 @@
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { SlotGrid } from '../components/SlotGrid'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
const filled = { slot: 2, name: 'pvp', updated_at: '2026-09-24T10:00:00Z', share_code: '7KQ3M9XA', published: false }
test('always shows four slots', async () => {
mockFetch({ 'POST /auth/refresh': () => json({}, 401), 'GET /configs': () => json([filled]) })
renderApp([{ path: '/', element: <SlotGrid /> }])
expect(await screen.findByText('7KQ3M9XA')).toBeInTheDocument()
expect(screen.getAllByRole('article')).toHaveLength(4)
expect(screen.getAllByText('Пусто')).toHaveLength(3)
})
test('regenerating a code needs confirmation', async () => {
const user = userEvent.setup()
let code = '7KQ3M9XA'
mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /configs': () => json([{ ...filled, share_code: code }]),
'POST /configs/2/regenerate-code': () => {
code = 'NEWC0DE2'
return json({ share_code: code })
},
})
renderApp([{ path: '/', element: <SlotGrid /> }])
const panel = (await screen.findByText('pvp')).closest('article') as HTMLElement
await user.click(within(panel).getByRole('button', { name: 'Новый код' }))
expect(within(panel).getByText(/Старый код перестанет работать/)).toBeInTheDocument()
await user.click(within(panel).getByRole('button', { name: 'Сменить' }))
expect(await within(panel).findByText('NEWC0DE2')).toBeInTheDocument()
})
test('publish requires a title and sends the form', async () => {
const user = userEvent.setup()
const fetch = mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /configs': () => json([filled]),
'POST /configs/2/publish': () => json({ listing_id: 'l1' }),
})
renderApp([{ path: '/', element: <SlotGrid /> }])
const panel = (await screen.findByText('pvp')).closest('article') as HTMLElement
await user.click(within(panel).getByRole('button', { name: 'Опубликовать' }))
await user.click(within(panel).getByRole('button', { name: 'Опубликовать' }))
expect(within(panel).getByLabelText('Название на витрине')).toHaveAttribute('aria-invalid', 'true')
await user.type(within(panel).getByLabelText('Название на витрине'), 'Лучший PvP')
await user.click(within(panel).getByRole('button', { name: 'Опубликовать' }))
expect(await within(panel).findByText(/Опубликовано/)).toBeInTheDocument()
const body = fetch.mock.calls.find(([u]) => String(u).endsWith('/publish'))?.[1]?.body
expect(JSON.parse(String(body))).toEqual({ title: 'Лучший PvP', description: '' })
})
test('published slot offers to remove from showcase', async () => {
mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /configs': () => json([{ ...filled, published: true }]),
})
renderApp([{ path: '/', element: <SlotGrid /> }])
const panel = (await screen.findByText('pvp')).closest('article') as HTMLElement
expect(within(panel).getByRole('button', { name: 'Убрать с витрины' })).toBeInTheDocument()
})

View file

@ -0,0 +1,7 @@
export type SlotSummary = {
slot: 1 | 2 | 3 | 4
name: string
updated_at: string
share_code: string
published: boolean
}

View file

@ -0,0 +1,62 @@
import { useMutation } from '@tanstack/react-query'
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { describeError } from '../../shared/api/errors'
import { Button } from '../../shared/ui/Button'
import { Notice } from '../../shared/ui/Notice'
import { confirmDevice, normalizeUserCode } from './api'
/** The device-code confirmation form: styled like typing a command into the in-game chat bar. */
export function LinkDeviceForm() {
const { t } = useTranslation('deviceLink')
const { t: tc } = useTranslation('common')
const [code, setCode] = useState('')
const [error, setError] = useState<string>()
const mutation = useMutation({
mutationFn: (userCode: string) => confirmDevice(userCode),
onError: (err) => setError(describeError(tc, err, { 404: t('form.expired') })),
})
function submit(e: FormEvent) {
e.preventDefault()
setError(undefined)
mutation.mutate(normalizeUserCode(code))
}
if (mutation.isSuccess) {
return <Notice tone="info">{t('form.success')}</Notice>
}
return (
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
<div className="flex flex-col gap-1.5">
<label htmlFor="device-code" className="text-sm text-frost">
{t('form.codeLabel')}
</label>
<div className="flex items-center gap-2 rounded-md border border-shoal bg-abyss px-3 py-2 focus-within:border-ice">
<span aria-hidden="true" className="font-code text-ice">
%
</span>
<input
id="device-code"
autoComplete="off"
autoCapitalize="characters"
inputMode="text"
maxLength={9}
placeholder="ABCD-1234"
value={code}
onChange={(e) => setCode(e.target.value)}
className="w-full bg-transparent font-code text-lg uppercase tracking-widest text-snow placeholder:text-frost/40 focus:outline-none"
/>
<span aria-hidden="true" className="font-code animate-pulse text-ice">
▌
</span>
</div>
</div>
{error ? <Notice tone="error">{error}</Notice> : null}
<Button type="submit" busy={mutation.isPending}>
{t('form.submit')}
</Button>
</form>
)
}

View file

@ -0,0 +1,13 @@
import { api } from '../../shared/api/client'
/** Uppercases, strips spaces, and inserts the dash after 4 chars if missing (`abcd1234` -> `ABCD-1234`). */
export function normalizeUserCode(raw: string): string {
const cleaned = raw.trim().toUpperCase().replace(/\s+/g, '').replace(/-/g, '')
if (cleaned.length <= 4) return cleaned
return `${cleaned.slice(0, 4)}-${cleaned.slice(4, 8)}`
}
/** Confirms a device code the mod displayed, linking that game session to the signed-in account. */
export async function confirmDevice(userCode: string): Promise<void> {
await api.request('/device/confirm', { method: 'POST', json: { user_code: userCode } })
}

View file

@ -0,0 +1,20 @@
import type { Translation } from '../../../shared/i18n/common.ru'
import type { deviceLinkRu } from './ru'
export const deviceLinkEn = {
page: {
title: 'Link the game',
lead: "No password needed in the mod: it shows a short code, and you confirm it here.",
},
form: {
codeLabel: 'Code from the game',
submit: 'Link',
success: 'Game linked. You can go back to Minecraft, the mod is already signed in.',
expired: "Code not found or expired. Codes last 10 minutes, grab a new one in the mod.",
},
steps: {
one: 'Open the ClickGui in-game and switch to the account tab.',
two: 'The mod shows a short code, type it here.',
three: 'Confirm, the mod signs itself into your account.',
},
} satisfies Translation<typeof deviceLinkRu>

View file

@ -0,0 +1,17 @@
export const deviceLinkRu = {
page: {
title: 'Привязать игру',
lead: 'Пароль в мод вводить не нужно: мод показывает короткий код, ты подтверждаешь его здесь.',
},
form: {
codeLabel: 'Код из игры',
submit: 'Привязать',
success: 'Игра привязана. Можно возвращаться в Minecraft, мод уже вошёл в аккаунт.',
expired: 'Код не найден или устарел. Коды живут 10 минут, возьми новый в моде.',
},
steps: {
one: 'Открой ClickGui в игре и включи вкладку аккаунта.',
two: 'Мод покажет короткий код, набери его здесь.',
three: 'Подтверди, мод сам войдёт в твой аккаунт.',
},
} as const

View file

@ -0,0 +1,38 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { LinkDeviceForm } from '../LinkDeviceForm'
import { normalizeUserCode } from '../api'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
test('normalizes typed codes', () => {
expect(normalizeUserCode(' abcd1234 ')).toBe('ABCD-1234')
expect(normalizeUserCode('abcd-1234')).toBe('ABCD-1234')
})
test('confirms a code and shows success', async () => {
const user = userEvent.setup()
const fetch = mockFetch({
'POST /auth/refresh': () => json({}, 401),
'POST /device/confirm': () => new Response(null, { status: 200 }),
})
renderApp([{ path: '/', element: <LinkDeviceForm /> }])
await user.type(await screen.findByLabelText('Код из игры'), 'abcd1234')
await user.click(screen.getByRole('button', { name: 'Привязать' }))
expect(await screen.findByText(/Игра привязана/)).toBeInTheDocument()
const body = fetch.mock.calls.find(([u]) => String(u).endsWith('/device/confirm'))?.[1]?.body
expect(body).toBe('{"user_code":"ABCD-1234"}')
})
test('expired code explains what to do', async () => {
const user = userEvent.setup()
mockFetch({
'POST /auth/refresh': () => json({}, 401),
'POST /device/confirm': () => json({ error: 'unknown or expired user_code' }, 404),
})
renderApp([{ path: '/', element: <LinkDeviceForm /> }])
await user.type(await screen.findByLabelText('Код из игры'), 'ZZZZ-ZZZZ')
await user.click(screen.getByRole('button', { name: 'Привязать' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Коды живут 10 минут')
})

View file

@ -8,17 +8,17 @@ export const downloadEn = {
versionHint: 'v{version} · {size}', versionHint: 'v{version} · {size}',
page: { page: {
title: 'Download LoVisual', title: 'Download LoVisual',
lead: 'A free visuals mod for Minecraft 26.2 on Fabric. One file — no installers, no ads.', lead: 'A free visuals mod for Minecraft 26.2 on Fabric. One file: no installers, no ads.',
reqTitle: 'What you need', reqTitle: 'What you need',
reqMc: 'Minecraft', reqMc: 'Minecraft',
reqLoader: 'Fabric Loader', reqLoader: 'Fabric Loader',
reqApi: 'Fabric API', reqApi: 'Fabric API',
reqOptionalTitle: 'Optional, for maximum FPS', reqOptionalTitle: 'Optional, for maximum FPS',
reqOptional: 'Sodium and Iris — the mod does not require them but plays nicely.', reqOptional: 'Sodium and Iris: the mod does not require them but plays nicely.',
installTitle: 'Install in three steps', installTitle: 'Install in three steps',
step1: 'Download lovisual.jar with the button above.', step1: 'Download lovisual.jar with the button above.',
step2: 'Drop the file into your mods folder (create it if it is missing).', 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.', step3: 'Launch Minecraft with the Fabric profile, the mod is active right away.',
}, },
notes: { notes: {
title: "What's new", title: "What's new",

View file

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

View file

@ -16,7 +16,7 @@ export function CommandHero() {
<section className="relative flex flex-col gap-6 pb-16 pt-20 sm:pt-28"> <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]"> <h2 className="text-[2rem] leading-[1.1] sm:text-[2.8rem]">
{t('cloud.headlinePart1')} {t('cloud.headlinePart1')}
{' '}— <br className="hidden lg:inline" /> {' '}<br className="hidden lg:inline" />
<span className="text-ice">{t('cloud.headlinePart2')}</span> <span className="text-ice">{t('cloud.headlinePart2')}</span>
</h2> </h2>
<p className="max-w-[60ch] text-lg text-frost">{t('cloud.subtitle')}</p> <p className="max-w-[60ch] text-lg text-frost">{t('cloud.subtitle')}</p>

View file

@ -1,47 +0,0 @@
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

@ -15,12 +15,12 @@ export const landingEn = {
visuals: 'for visuals', visuals: 'for visuals',
themes: 'built-in themes', themes: 'built-in themes',
}, },
tryIt: 'This is the real interface — hover and click around', tryIt: 'This is the real interface: hover and click around',
}, },
themeStrip: { themeStrip: {
eyebrow: 'Themes', eyebrow: 'Themes',
title: 'Hundreds of shades. Yours is one.', 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.', lead: 'Every built-in theme, straight from the mod. Click one: the ClickGui and the whole site recolor on the fly.',
cta: 'Build your own theme', cta: 'Build your own theme',
current: 'Now', current: 'Now',
swatches: 'Built-in themes', swatches: 'Built-in themes',
@ -39,14 +39,14 @@ export const landingEn = {
howItWorks: { howItWorks: {
slotsTitle: 'Four slots in the cloud', slotsTitle: 'Four slots in the cloud',
slotsDesc: 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.', 'Save up to four sets of settings and load any of them on another computer: every slot has a code a friend can load too.',
slotsAriaLabel: 'Example slots', slotsAriaLabel: 'Example slots',
emptySlot: 'empty', emptySlot: 'empty',
slotNamePvp: 'pvp', slotNamePvp: 'pvp',
slotNameStream: 'stream', slotNameStream: 'stream',
slotNameLazy: 'lazy', slotNameLazy: 'lazy',
linkTitle: 'Linking the game', linkTitle: 'Linking the game',
linkStep1: 'Open the account tab in the mod — a code will appear there.', linkStep1: 'Open the account tab in the mod, a code will appear there.',
linkStep2: 'Enter the code on the "Link game" page.', linkStep2: 'Enter the code on the "Link game" page.',
linkStep3: "Done: the mod signs in to your account by itself, no password needed.", 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.', showcaseText: 'Any config from the showcase can be copied into a free slot with one button.',
@ -56,7 +56,7 @@ export const landingEn = {
eyebrow: 'HUD', eyebrow: 'HUD',
title: 'Arrange the widgets like in game', 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.', 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.', caption: 'Drag it, just like in game.',
reset: 'Reset', reset: 'Reset',
widgetFps: 'FPS', widgetFps: 'FPS',
widgetCoords: 'Coordinates', widgetCoords: 'Coordinates',
@ -69,7 +69,7 @@ export const landingEn = {
wall: { wall: {
eyebrow: 'Modules', eyebrow: 'Modules',
title: '88 modules. 52 are about looks.', title: '88 modules. 52 are about looks.',
lead: 'The whole category wall. Hover a visuals module — I\'ll tell you what it does.', lead: 'The whole category wall. Hover a visuals module: I\'ll tell you what it does.',
columnsAria: 'Module categories', columnsAria: 'Module categories',
}, },
showcase: { showcase: {
@ -95,7 +95,7 @@ export const landingEn = {
q5: 'How do I move settings to another computer?', 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.', a5: 'Save a config to a cloud slot and load it by code on any other PC.',
downloadTitle: 'Ready to try it?', downloadTitle: 'Ready to try it?',
downloadLead: 'One .jar straight from GitHub — just drop it into your mods folder.', downloadLead: 'One .jar straight from GitHub, just drop it into your mods folder.',
downloadCta: 'Download for free', downloadCta: 'Download for free',
downloadNote: 'Minecraft 26.2 · Fabric · the latest release', downloadNote: 'Minecraft 26.2 · Fabric · the latest release',
releaseLink: 'Releases and versions', releaseLink: 'Releases and versions',

View file

@ -12,12 +12,12 @@ export const landingRu = {
visuals: 'про визуал', visuals: 'про визуал',
themes: 'встроенных тем', themes: 'встроенных тем',
}, },
tryIt: 'Это настоящий интерфейс — наведи и покликай', tryIt: 'Это настоящий интерфейс: наведи и покликай',
}, },
themeStrip: { themeStrip: {
eyebrow: 'Темы', eyebrow: 'Темы',
title: 'Сотни оттенков. Твой — один.', title: 'Сотни оттенков. Твой один.',
lead: 'Все встроенные темы прямо из мода. Нажми на любую — ClickGui и весь сайт перекрасятся на лету.', lead: 'Все встроенные темы прямо из мода. Нажми на любую: ClickGui и весь сайт перекрасятся на лету.',
cta: 'Собрать свою тему', cta: 'Собрать свою тему',
current: 'Сейчас', current: 'Сейчас',
swatches: 'Встроенные темы', swatches: 'Встроенные темы',
@ -36,14 +36,14 @@ export const landingRu = {
howItWorks: { howItWorks: {
slotsTitle: 'Четыре слота в облаке', slotsTitle: 'Четыре слота в облаке',
slotsDesc: slotsDesc:
'Сохрани до четырёх наборов настроек и загружай любой из них на другом компьютере — у каждого слота есть код, по которому его может загрузить и друг.', 'Сохрани до четырёх наборов настроек и загружай любой из них на другом компьютере: у каждого слота есть код, по которому его может загрузить и друг.',
slotsAriaLabel: 'Пример слотов', slotsAriaLabel: 'Пример слотов',
emptySlot: 'пусто', emptySlot: 'пусто',
slotNamePvp: 'pvp', slotNamePvp: 'pvp',
slotNameStream: 'стрим', slotNameStream: 'стрим',
slotNameLazy: 'ленивый', slotNameLazy: 'ленивый',
linkTitle: 'Привязка игры', linkTitle: 'Привязка игры',
linkStep1: 'В моде открой вкладку аккаунта — там появится код.', linkStep1: 'В моде открой вкладку аккаунта, там появится код.',
linkStep2: 'Введи код на странице «Привязать игру».', linkStep2: 'Введи код на странице «Привязать игру».',
linkStep3: 'Готово: мод входит в аккаунт сам, пароль ему не нужен.', linkStep3: 'Готово: мод входит в аккаунт сам, пароль ему не нужен.',
showcaseText: 'Любой конфиг из витрины можно скопировать в свободный слот одной кнопкой.', showcaseText: 'Любой конфиг из витрины можно скопировать в свободный слот одной кнопкой.',
@ -53,7 +53,7 @@ export const landingRu = {
eyebrow: 'HUD', eyebrow: 'HUD',
title: 'Расставь элементы как в игре', title: 'Расставь элементы как в игре',
lead: 'Те же виджеты, что рисует мод: FPS, координаты, список модулей, клавиши и пинг. Таскай их мышкой или стрелками.', lead: 'Те же виджеты, что рисует мод: FPS, координаты, список модулей, клавиши и пинг. Таскай их мышкой или стрелками.',
caption: 'Перетащи — так же, как в игре.', caption: 'Перетащи так же, как в игре.',
reset: 'Сбросить', reset: 'Сбросить',
widgetFps: 'FPS', widgetFps: 'FPS',
widgetCoords: 'Координаты', widgetCoords: 'Координаты',
@ -61,12 +61,12 @@ export const landingRu = {
widgetKeys: 'Нажатия клавиш', widgetKeys: 'Нажатия клавиш',
widgetPing: 'Пинг', widgetPing: 'Пинг',
unitMs: 'мс', unitMs: 'мс',
dragAria: 'Перетаскиваемый HUD-виджет «{{name}}». Стрелки — двигать.', dragAria: 'Перетаскиваемый HUD-виджет «{{name}}». Стрелки: двигать.',
}, },
wall: { wall: {
eyebrow: 'Модули', eyebrow: 'Модули',
title: '88 модулей. 52 — про красоту.', title: '88 модулей. 52 про красоту.',
lead: 'Вся витрина категорий. Наведи на визуальный модуль — расскажу, что он делает.', lead: 'Вся витрина категорий. Наведи на визуальный модуль: расскажу, что он делает.',
columnsAria: 'Категории модулей', columnsAria: 'Категории модулей',
}, },
showcase: { showcase: {
@ -86,13 +86,13 @@ export const landingRu = {
q2: 'Какие версии Minecraft поддерживаются?', q2: 'Какие версии Minecraft поддерживаются?',
a2: 'Minecraft 26.2 на загрузчике Fabric.', a2: 'Minecraft 26.2 на загрузчике Fabric.',
q3: 'Нужен ли аккаунт?', q3: 'Нужен ли аккаунт?',
a3: 'Для самого мода — нет. Аккаунт нужен только для облачных слотов и витрины конфигов.', a3: 'Для самого мода: нет. Аккаунт нужен только для облачных слотов и витрины конфигов.',
q4: 'Это легитно? Забанят ли?', q4: 'Это легитно? Забанят ли?',
a4: 'LoVisual — визуальный мод: он ничего не читает из памяти и не отправляет на сервер. Решает админ сервера.', a4: 'LoVisual, визуальный мод: он ничего не читает из памяти и не отправляет на сервер. Решает админ сервера.',
q5: 'Как перенести настройки на другой компьютер?', q5: 'Как перенести настройки на другой компьютер?',
a5: 'Сохрани конфиг в облачный слот и загрузи его по коду на любом другом ПК.', a5: 'Сохрани конфиг в облачный слот и загрузи его по коду на любом другом ПК.',
downloadTitle: 'Готов попробовать?', downloadTitle: 'Готов попробовать?',
downloadLead: 'Один .jar прямо с GitHub — просто положи его в папку mods.', downloadLead: 'Один .jar прямо с GitHub, просто положи его в папку mods.',
downloadCta: 'Скачать бесплатно', downloadCta: 'Скачать бесплатно',
downloadNote: 'Minecraft 26.2 · Fabric · самый свежий релиз', downloadNote: 'Minecraft 26.2 · Fabric · самый свежий релиз',
releaseLink: 'Список релизов и версий', releaseLink: 'Список релизов и версий',

View file

@ -1,21 +1,24 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DOWNLOAD_URL, GITHUB_REPO } from '../../download/api' import { themeToCssVars } from '../../../clickgui/themeVars'
import '../styles/landing.css' import { useActiveTheme } from '../../../themes/useActiveTheme'
import { DOWNLOAD_URL, GITHUB_REPO } from '../../../download/api'
import '../../styles/landing.css'
const KEYS = ['1', '2', '3', '4', '5'] as const const KEYS = ['1', '2', '3', '4', '5'] as const
export function FaqDownload() { export function FaqDownload() {
const { t } = useTranslation('landing') const { t } = useTranslation('landing')
const { theme } = useActiveTheme()
const items = KEYS.map((k) => ({ q: t(`faq.q${k}`), a: t(`faq.a${k}`) })) const items = KEYS.map((k) => ({ q: t(`faq.q${k}`), a: t(`faq.a${k}`) }))
return ( return (
<section aria-labelledby="faq-title" className="grid gap-12 py-20 sm:py-28 lg:grid-cols-2 lg:gap-16"> <section aria-labelledby="faq-title" style={themeToCssVars(theme)} className="grid gap-12 py-20 sm:py-28 lg:grid-cols-2 lg:gap-16">
<div className="flex min-w-0 flex-col gap-6"> <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> <span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('faq.eyebrow')}</span>
<h2 id="faq-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('faq.title')}</h2> <h2 id="faq-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('faq.title')}</h2>
<div className="flex flex-col divide-y divide-white/10 border-y border-white/10"> <div className="flex flex-col gap-2">
{items.map((it) => ( {items.map((it) => (
<details key={it.q} className="lv-faq-item group py-3"> <details key={it.q} className="lv-faq-item lv-glass">
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 text-base font-semibold text-snow"> <summary className="flex cursor-pointer list-none items-center justify-between gap-4 text-base font-semibold text-snow">
{it.q} {it.q}
<span aria-hidden="true" className="lv-faq-marker" /> <span aria-hidden="true" className="lv-faq-marker" />
@ -26,7 +29,7 @@ export function FaqDownload() {
</div> </div>
</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"> <div className="lv-download-band lv-glass flex min-w-0 flex-col items-start justify-center gap-5 rounded-panel p-8 sm:p-10">
<h3 className="text-[1.75rem] leading-tight sm:text-[2.1rem]">{t('faq.downloadTitle')}</h3> <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> <p className="max-w-[42ch] text-lg text-frost">{t('faq.downloadLead')}</p>
<a href={DOWNLOAD_URL} download className="lv-cta-primary"> <a href={DOWNLOAD_URL} download className="lv-cta-primary">

View file

@ -1,11 +1,11 @@
import { useRef, useState } from 'react' import { useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '../../../shared/ui/Button' import { Button } from '../../../../shared/ui/Button'
import { themeToCssVars } from '../../clickgui/themeVars' import { themeToCssVars } from '../../../clickgui/themeVars'
import { parseArgb } from '../../themes/types' import { parseArgb } from '../../../themes/types'
import { useActiveTheme } from '../../themes/useActiveTheme' import { useActiveTheme } from '../../../themes/useActiveTheme'
import { VoxelScene } from '../effects/VoxelScene' import { VoxelScene } from '../../effects/VoxelScene'
import '../styles/landing.css' import '../../styles/landing.css'
const STEP = 8 const STEP = 8
/** Fallback playfield size, used before the box is measured (and under jsdom). */ /** Fallback playfield size, used before the box is measured (and under jsdom). */
@ -26,6 +26,31 @@ const START: Record<WidgetId, Pos> = {
const clamp = (v: number, max: number) => Math.max(0, Math.min(v, max)) const clamp = (v: number, max: number) => Math.max(0, Math.min(v, max))
/** Pull every widget back inside the measured field (mount, resize, reset). */
function fitToField(
state: Record<WidgetId, Pos>,
field: HTMLDivElement | null,
widgets: Partial<Record<WidgetId, HTMLElement | null>>,
): Record<WidgetId, Pos> {
const w = field?.clientWidth || FIELD.w
const h = field?.clientHeight || FIELD.h
const next = { ...state }
let changed = false
for (const id of Object.keys(next) as WidgetId[]) {
const el = widgets[id]
const maxX = Math.max(0, w - (el?.offsetWidth || WIDGET.w))
const maxY = Math.max(0, h - (el?.offsetHeight || WIDGET.h))
const x = clamp(next[id].x, maxX)
const y = clamp(next[id].y, maxY)
if (x !== next[id].x || y !== next[id].y) {
next[id] = { x, y }
changed = true
}
}
return changed ? next : state
}
/** Enabled modules as they would appear in the mod's ModuleList panel. */ /** Enabled modules as they would appear in the mod's ModuleList panel. */
const MODULES_ON = ['TargetESP', 'PvpCooldowns', 'HitBubbles', 'ShaderSky'] const MODULES_ON = ['TargetESP', 'PvpCooldowns', 'HitBubbles', 'ShaderSky']
@ -114,6 +139,7 @@ export function HudPlayground() {
const { theme } = useActiveTheme() const { theme } = useActiveTheme()
const { r, g, b } = parseArgb(theme.theme.accent) const { r, g, b } = parseArgb(theme.theme.accent)
const fieldRef = useRef<HTMLDivElement>(null) const fieldRef = useRef<HTMLDivElement>(null)
const widgetRefs = useRef<Partial<Record<WidgetId, HTMLElement | null>>>({})
const drag = useRef<{ id: WidgetId; px: number; py: number; ox: number; oy: number } | null>(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 [pos, setPos] = useState<Record<WidgetId, Pos>>(START)
@ -124,6 +150,16 @@ export function HudPlayground() {
return { maxX: Math.max(0, w - (el.offsetWidth || WIDGET.w)), maxY: Math.max(0, h - (el.offsetHeight || WIDGET.h)) } return { maxX: Math.max(0, w - (el.offsetWidth || WIDGET.w)), maxY: Math.max(0, h - (el.offsetHeight || WIDGET.h)) }
} }
useLayoutEffect(() => {
const fit = () => setPos((prev) => fitToField(prev, fieldRef.current, widgetRefs.current))
fit()
const field = fieldRef.current
if (!field || typeof ResizeObserver === 'undefined') return
const ro = new ResizeObserver(fit)
ro.observe(field)
return () => ro.disconnect()
}, [])
const nudge = (id: WidgetId, dx: number, dy: number, el: HTMLElement) => { const nudge = (id: WidgetId, dx: number, dy: number, el: HTMLElement) => {
const { maxX, maxY } = bounds(el) const { maxX, maxY } = bounds(el)
setPos((prev) => ({ ...prev, [id]: { x: clamp(prev[id].x + dx, maxX), y: clamp(prev[id].y + dy, maxY) } })) setPos((prev) => ({ ...prev, [id]: { x: clamp(prev[id].x + dx, maxX), y: clamp(prev[id].y + dy, maxY) } }))
@ -169,7 +205,7 @@ export function HudPlayground() {
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('hud.eyebrow')}</span> <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> <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> <p className="max-w-[42ch] text-lg text-frost">{t('hud.lead')}</p>
<Button variant="quiet" className="mt-2" onClick={() => setPos(START)}> <Button variant="quiet" className="mt-2" onClick={() => setPos(fitToField(START, fieldRef.current, widgetRefs.current))}>
{t('hud.reset')} {t('hud.reset')}
</Button> </Button>
</div> </div>
@ -185,6 +221,9 @@ export function HudPlayground() {
{widgets.map((w) => ( {widgets.map((w) => (
<div <div
key={w.id} key={w.id}
ref={(el) => {
widgetRefs.current[w.id] = el
}}
role="button" role="button"
tabIndex={0} tabIndex={0}
title={w.label} title={w.label}

View file

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

View file

@ -1,25 +1,24 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link } from 'react-router' import { Link } from 'react-router'
import { api } from '../../../shared/api/client' import { browse } from '../../../showcase/api'
import '../styles/landing.css' import type { Listing } from '../../../showcase/types'
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. */ /** Placeholder cards so the section never collapses while loading or when the API is down. */
const EXAMPLES: ShowcaseItem[] = [ const EXAMPLES: Listing[] = [
{ id: 'e1', name: 'Aura / Trails', author: 'example', downloads: 128 }, { id: 'e1', title: 'Aura / Trails', config_name: 'aura', description: '', copies_count: 128, published_at: '', author: { nick: 'example', avatar_url: null } },
{ id: 'e2', name: 'Clean PvP', author: 'example', downloads: 96 }, { id: 'e2', title: 'Clean PvP', config_name: 'pvp', description: '', copies_count: 96, published_at: '', author: { nick: 'example', avatar_url: null } },
{ id: 'e3', name: 'Stream Safe', author: 'example', downloads: 74 }, { id: 'e3', title: 'Stream Safe', config_name: 'stream', description: '', copies_count: 74, published_at: '', author: { nick: 'example', avatar_url: null } },
] ]
export function ShowcaseTeaser() { export function ShowcaseTeaser() {
const { t } = useTranslation('landing') const { t } = useTranslation('landing')
const { data, isError } = useQuery({ const { data, isError } = useQuery({
queryKey: ['showcase', 'popular'], queryKey: ['showcase', 'popular', 0, null],
queryFn: () => api.request<ShowcaseItem[]>('/showcase?sort=popular'), queryFn: () => browse('popular', 0),
}) })
const real = !isError && Array.isArray(data) ? data.slice(0, 3) : [] const real = !isError && data ? data.items.slice(0, 3) : []
const cards = real.length >= 3 ? real : EXAMPLES const cards = real.length >= 3 ? real : EXAMPLES
const isExample = real.length < 3 const isExample = real.length < 3
@ -35,11 +34,11 @@ export function ShowcaseTeaser() {
{cards.map((c) => ( {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"> <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"> <div className="flex items-center justify-between gap-2">
<span className="font-display text-lg font-bold text-snow">{c.name}</span> <span className="font-display text-lg font-bold text-snow">{c.title}</span>
{isExample ? <span className="lv-example-tag">{t('showcase.example')}</span> : null} {isExample ? <span className="lv-example-tag">{t('showcase.example')}</span> : null}
</div> </div>
<span className="text-sm text-frost">{t('showcase.by', { author: c.author })}</span> <span className="text-sm text-frost">{t('showcase.by', { author: c.author?.nick ?? t('showcase.example') })}</span>
<span className="font-code text-xs text-ice">{t('showcase.downloads', { count: c.downloads })}</span> <span className="font-code text-xs text-ice">{t('showcase.downloads', { count: c.copies_count })}</span>
</li> </li>
))} ))}
</ul> </ul>

View file

@ -1,17 +1,16 @@
import { MotionConfig, motion } from 'motion/react' import { MotionConfig, motion } from 'motion/react'
import { useRef } from 'react' import { useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ButtonLink } from '../../../shared/ui/Button' import { ButtonLink } from '../../../../shared/ui/Button'
import { ClickGuiWindow } from '../../clickgui/ClickGuiWindow' import { ClickGuiWindow } from '../../../clickgui/ClickGuiWindow'
import { DOWNLOAD_URL } from '../../download/api' import { DOWNLOAD_URL } from '../../../download/api'
import { PRESETS } from '../../themes/presets.generated' import { PRESETS } from '../../../themes/presets.generated'
import { parseArgb } from '../../themes/types' import { parseArgb } from '../../../themes/types'
import { useActiveTheme } from '../../themes/useActiveTheme' import { useActiveTheme } from '../../../themes/useActiveTheme'
import { ParticleField } from '../effects/ParticleField' import { ParticleField } from '../../effects/ParticleField'
import { Tilt } from '../effects/Tilt' import { useInView } from '../../effects/useInView'
import { useInView } from '../effects/useInView' import { VoxelScene } from '../../effects/VoxelScene'
import { VoxelScene } from '../effects/VoxelScene' import '../../styles/landing.css'
import '../styles/landing.css'
const rise = (delay: number) => ({ const rise = (delay: number) => ({
initial: { opacity: 0, y: 24 }, initial: { opacity: 0, y: 24 },
@ -45,13 +44,13 @@ export function Hero() {
<section <section
ref={sectionRef} ref={sectionRef}
aria-labelledby="hero-title" aria-labelledby="hero-title"
className="lv-bleed relative isolate flex min-h-[calc(100svh-3.75rem)] items-center overflow-hidden" className="lv-bleed relative isolate -mt-(--topbar-h) flex min-h-svh items-center overflow-hidden pt-(--topbar-h)"
> >
<VoxelScene accent={accent} /> <VoxelScene accent={accent} />
<div aria-hidden="true" className="lv-hero-shade absolute inset-0" /> <div aria-hidden="true" className="lv-hero-shade absolute inset-0" />
<ParticleField color={accent} /> <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="relative mx-auto grid w-full max-w-[1120px] items-center gap-12 px-4 pb-20 pt-12 sm:px-6 lg:py-16">
<div className="flex min-w-0 flex-col items-start gap-6"> <div className="flex min-w-0 flex-col items-start gap-6">
<motion.span <motion.span
{...rise(0)} {...rise(0)}
@ -94,9 +93,7 @@ export function Hero() {
transition={{ duration: 0.9, delay: 0.2, ease: [0.22, 1, 0.36, 1] }} 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" className="flex min-w-0 flex-col items-center gap-4"
> >
<Tilt className="w-full max-w-[660px]">
<ClickGuiWindow paused={!inView} /> <ClickGuiWindow paused={!inView} />
</Tilt>
<p className="flex items-center gap-2 text-sm text-frost"> <p className="flex items-center gap-2 text-sm text-frost">
<span className="lv-live-dot" aria-hidden="true" /> <span className="lv-live-dot" aria-hidden="true" />
{t('hero.tryIt')} {t('hero.tryIt')}

View file

@ -1,13 +1,13 @@
import { motion } from 'motion/react' import { motion } from 'motion/react'
import { useRef } from 'react' import { useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ButtonLink } from '../../../shared/ui/Button' import { ButtonLink } from '../../../../shared/ui/Button'
import { themeToCssVars } from '../../clickgui/themeVars' import { themeToCssVars } from '../../../clickgui/themeVars'
import { PRESETS } from '../../themes/presets.generated' import { PRESETS } from '../../../themes/presets.generated'
import { argbToCss, type ThemeColors } from '../../themes/types' import { argbToCss, type ThemeColors } from '../../../themes/types'
import { useActiveTheme } from '../../themes/useActiveTheme' import { useActiveTheme } from '../../../themes/useActiveTheme'
import { useInView } from '../effects/useInView' import { useInView } from '../../effects/useInView'
import '../styles/landing.css' import '../../styles/landing.css'
const PALETTE_KEYS: (keyof ThemeColors)[] = [ const PALETTE_KEYS: (keyof ThemeColors)[] = [
'windowBg', 'windowHeader', 'surface', 'cardEnabled', 'accent', 'accentSoft', 'textPrimary', 'textMuted', 'windowBg', 'windowHeader', 'surface', 'cardEnabled', 'accent', 'accentSoft', 'textPrimary', 'textMuted',

View file

@ -174,12 +174,61 @@
.lv-config-card { transition: transform 220ms ease, border-color 220ms ease; } .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-config-card:hover { transform: translateY(-3px); border-color: color-mix(in srgb, var(--color-ice) 40%, transparent); }
.lv-download-band { .lv-glass {
border: 1px solid transparent;
background: background:
radial-gradient(120% 120% at 100% 0%, color-mix(in srgb, var(--page-accent-glow, #6cf) 22%, transparent), transparent 60%), linear-gradient(var(--gui-window-angle, 180deg),
linear-gradient(180deg, #0e1430, #070a18); color-mix(in srgb, var(--gui-window-from, #12191b) 72%, transparent),
border: 1px solid color-mix(in srgb, var(--color-ice) 22%, transparent); color-mix(in srgb, var(--gui-window-to, #12191b) 72%, transparent)) padding-box,
linear-gradient(135deg, color-mix(in srgb, var(--gui-accent, #6cf) 55%, transparent),
var(--gui-stroke-from, #2a3a3f) 35%, var(--gui-stroke-to, #2a3a3f) 70%,
color-mix(in srgb, var(--gui-accent, #6cf) 30%, transparent)) border-box;
box-shadow: 0 16px 36px -18px rgb(0 0 0 / 0.8), 0 0 26px -10px var(--gui-accent-glow, transparent);
backdrop-filter: blur(12px) saturate(1.25);
} }
.lv-download-band { position: relative; overflow: hidden; }
.lv-download-band::before {
content: ""; position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(120% 120% at 100% 0%, color-mix(in srgb, var(--gui-accent, #6cf) 26%, transparent), transparent 60%);
}
.lv-download-band > * { position: relative; }
.lv-faq-item { border-radius: 10px; padding: 0.85rem 1rem; transition: box-shadow 200ms ease; }
.lv-faq-item:hover, .lv-faq-item[open] { box-shadow: 0 16px 36px -18px rgb(0 0 0 / 0.8), 0 0 30px -6px var(--gui-accent-glow, transparent); }
/* Atmosphere behind everything below the first screen: accent aurora, faint grid, drifting motes. */
.lv-lower { position: relative; isolation: isolate; }
.lv-lower-bg {
position: absolute; inset: 0 calc(50% - 50vw); z-index: -1; pointer-events: none;
background:
radial-gradient(60% 30% at 15% 12%, color-mix(in srgb, var(--page-accent-glow, #6cf) 30%, transparent), transparent 70%),
radial-gradient(55% 28% at 88% 45%, color-mix(in srgb, var(--color-ember) 14%, transparent), transparent 70%),
radial-gradient(60% 30% at 20% 82%, color-mix(in srgb, var(--page-accent-glow, #6cf) 24%, transparent), transparent 70%),
linear-gradient(180deg, #0d1416, #0a0f1f 40%, #120d24 75%, #0d1416);
-webkit-mask-image: linear-gradient(180deg, transparent, #000 6%, #000 94%, transparent);
mask-image: linear-gradient(180deg, transparent, #000 6%, #000 94%, transparent);
}
.lv-lower-bg::before {
content: ""; position: absolute; inset: 0; opacity: 0.5;
background-image:
linear-gradient(rgb(255 255 255 / 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.035) 1px, transparent 1px);
background-size: 48px 48px;
-webkit-mask-image: radial-gradient(80% 60% at 50% 50%, #000, transparent);
mask-image: radial-gradient(80% 60% at 50% 50%, #000, transparent);
}
.lv-lower-bg::after {
content: ""; position: absolute; inset: 0;
background-image:
radial-gradient(1.5px 1.5px at 12% 20%, var(--color-ice), transparent),
radial-gradient(1.5px 1.5px at 78% 34%, var(--color-ice), transparent),
radial-gradient(1px 1px at 44% 58%, #fff, transparent),
radial-gradient(1.5px 1.5px at 90% 76%, var(--color-ice), transparent),
radial-gradient(1px 1px at 26% 90%, #fff, transparent);
background-size: 100% 900px; opacity: 0.55;
animation: lv-motes 40s linear infinite alternate;
}
@keyframes lv-motes { from { transform: translateY(0); } to { transform: translateY(-40px); } }
@media (prefers-reduced-motion: reduce) { .lv-lower-bg::after { animation: none; } }
.lv-faq-item > summary::-webkit-details-marker { display: none; } .lv-faq-item > 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-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; }

View file

@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'
import { expect, test, vi } from 'vitest' import { expect, test, vi } from 'vitest'
import { I18nextProvider } from 'react-i18next' import { I18nextProvider } from 'react-i18next'
import { CommandHero } from '../CommandHero' import { CommandHero } from '../CommandHero'
import { Hero } from '../sections/Hero' import { Hero } from '../sections/hero/Hero'
import { createMemoryRouter, RouterProvider } from 'react-router' import { createMemoryRouter, RouterProvider } from 'react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { initI18n } from '../../../app/i18n' import { initI18n } from '../../../app/i18n'

View file

@ -4,9 +4,9 @@ import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, expect, test, vi } from 'vitest' import { afterEach, expect, test, vi } from 'vitest'
import { mockFetch } from '../../../test/fetch' import { mockFetch } from '../../../test/fetch'
import { ParticleField } from '../effects/ParticleField' import { ParticleField } from '../effects/ParticleField'
import { HudPlayground } from '../sections/HudPlayground' import { HudPlayground } from '../sections/explore/HudPlayground'
import { ModuleWall } from '../sections/ModuleWall' import { ModuleWall } from '../sections/explore/ModuleWall'
import { ShowcaseTeaser } from '../sections/ShowcaseTeaser' import { ShowcaseTeaser } from '../sections/explore/ShowcaseTeaser'
const reduceMotion = () => const reduceMotion = () =>
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} })) vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
@ -44,7 +44,7 @@ test('HudWidget stays inside the playfield bounds', () => {
test('ModuleWall lists all four category headings', () => { test('ModuleWall lists all four category headings', () => {
reduceMotion() reduceMotion()
withQuery(<ModuleWall />) withQuery(<ModuleWall />)
for (const name of ['Combat', 'Visuals', 'Player', 'Misc']) { for (const name of ['Combat', 'Player', 'Visuals', 'Other']) {
expect(screen.getByRole('heading', { level: 3, name })).toBeInTheDocument() expect(screen.getByRole('heading', { level: 3, name })).toBeInTheDocument()
} }
}) })

View file

@ -0,0 +1,13 @@
import { api } from '../../shared/api/client'
export type PublicProfile = {
id: string
display_nick: string
avatar_url: string | null
created_at: string
badges: string[]
}
export function getProfile(id: string): Promise<PublicProfile> {
return api.request(`/users/${id}`)
}

View file

@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ApiError } from '../../../shared/api/client'
import { Notice } from '../../../shared/ui/Notice'
import { ListingList } from '../../showcase/components/ListingList'
import { getProfile } from '../api'
export function PublicProfileView({ id }: { id: string }) {
const { t, i18n } = useTranslation('profile')
const { data, error, isPending } = useQuery({ queryKey: ['profile', id], queryFn: () => getProfile(id), retry: false })
if (isPending) return <p className="text-frost">…</p>
if (error || !data) {
const missing = error instanceof ApiError && error.status === 404
return <Notice tone="error">{missing ? t('notFound') : t('error')}</Notice>
}
const joined = new Intl.DateTimeFormat(i18n.language, { dateStyle: 'long' }).format(new Date(data.created_at))
return (
<div className="flex flex-col gap-10">
<div className="flex items-center gap-5 rounded-(--radius-panel) border border-shoal bg-slate/70 p-6">
<div className="flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-ice bg-abyss font-display text-3xl">
{data.avatar_url ? (
<img src={data.avatar_url} alt="" className="h-full w-full object-cover" />
) : (
<span aria-hidden="true">{data.display_nick.slice(0, 1).toUpperCase()}</span>
)}
</div>
<div className="min-w-0">
<h1 className="truncate font-display text-2xl">{data.display_nick}</h1>
<p className="mt-1 text-sm text-frost">{t('memberSince', { date: joined })}</p>
{data.badges.includes('early') ? (
<span className="mt-2 inline-block rounded-full border border-ice/40 px-3 py-0.5 font-code text-xs text-ice">
{t('badgeEarly')}
</span>
) : null}
</div>
</div>
<section className="flex flex-col gap-4">
<h2 className="font-display text-xl">{t('configs')}</h2>
<ListingList author={id} />
</section>
</div>
)
}

View file

@ -0,0 +1,7 @@
export const profileEn = {
memberSince: 'On LoVisual since {{date}}',
badgeEarly: 'One of the first 1000',
configs: "Player's configs",
notFound: 'No such player.',
error: 'Could not load the profile. Try again later.',
}

View file

@ -0,0 +1,7 @@
export const profileRu = {
memberSince: 'В LoVisual с {{date}}',
badgeEarly: 'Один из первых 1000',
configs: 'Конфиги игрока',
notFound: 'Такого игрока нет.',
error: 'Не удалось загрузить профиль. Попробуй позже.',
}

View file

@ -0,0 +1,27 @@
import { screen } from '@testing-library/react'
import { expect, test } from 'vitest'
import { json, mockFetch } from '../../../test/fetch'
import { renderApp } from '../../../test/render'
import { PublicProfileView } from '../components/PublicProfileView'
const profile = { id: 'u1', display_nick: 'loki', avatar_url: null, created_at: '2026-01-05T10:00:00Z', badges: ['early'] }
test('shows nick, early badge and the player configs', async () => {
mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /users/u1': () => json(profile),
'GET /showcase': () => json({ items: [], page: 0, has_more: false }),
})
renderApp([{ path: '/', element: <PublicProfileView id="u1" /> }])
expect(await screen.findByText('loki')).toBeInTheDocument()
expect(screen.getByText('Один из первых 1000')).toBeInTheDocument()
})
test('unknown player shows not found', async () => {
mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /users/nope': () => json({ error: 'no such account' }, 404),
})
renderApp([{ path: '/', element: <PublicProfileView id="nope" /> }])
expect(await screen.findByText('Такого игрока нет.')).toBeInTheDocument()
})

View file

@ -0,0 +1,16 @@
import { api } from '../../shared/api/client'
import type { ListingDetail, ShowcasePage } from './types'
export function browse(sort: 'new' | 'popular', page: number, author?: string): Promise<ShowcasePage> {
const params = new URLSearchParams({ sort, page: String(page) })
if (author) params.set('author', author)
return api.request(`/showcase?${params.toString()}`)
}
export function getListing(id: string): Promise<ListingDetail> {
return api.request(`/showcase/${id}`)
}
export function copyToSlot(id: string, slot: number): Promise<unknown> {
return api.request(`/showcase/${id}/copy`, { method: 'POST', json: { slot } })
}

View file

@ -0,0 +1,75 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation } from 'react-router'
import { Notice } from '../../../shared/ui/Notice'
import { ApiError } from '../../../shared/api/client'
import { useSession } from '../../auth/session'
import { listSlots } from '../../configs/api'
import { copyToSlot } from '../api'
const SLOTS = [1, 2, 3, 4] as const
export function CopyToSlot({ listingId }: { listingId: string }) {
const { t } = useTranslation('showcase')
const { status } = useSession()
const location = useLocation()
const client = useQueryClient()
const [error, setError] = useState<string | null>(null)
const [copiedSlot, setCopiedSlot] = useState<number | null>(null)
const { data: slots } = useQuery({ queryKey: ['configs'], queryFn: listSlots, enabled: status === 'authenticated' })
const occupied = new Set((slots ?? []).map((s) => s.slot))
const mutation = useMutation({
mutationFn: (slot: number) => copyToSlot(listingId, slot),
onSuccess: (_data, slot) => {
setError(null)
setCopiedSlot(slot)
client.invalidateQueries({ queryKey: ['configs'] })
},
onError: (err) => {
setError(err instanceof ApiError && err.status === 409 ? t('copy.conflict') : t('page.error'))
},
})
if (status !== 'authenticated') {
return (
<div className="flex flex-col gap-2">
<p className="text-frost">{t('copy.loginPrompt')}</p>
<Link to="/login" state={{ from: location.pathname }} className="text-ice hover:underline">
{t('copy.login')}
</Link>
</div>
)
}
return (
<div className="flex flex-col gap-3">
<h2 className="font-display text-lg text-snow">{t('copy.heading')}</h2>
{copiedSlot ? (
<Notice tone="info">
{t('copy.success', { slot: copiedSlot })}{' '}
<Link to="/configs" className="text-ice hover:underline">
{t('copy.openConfigs')}
</Link>
</Notice>
) : null}
{error ? <Notice tone="error">{error}</Notice> : null}
<div role="group" aria-label={t('copy.heading')} className="inline-flex overflow-hidden rounded-md border border-shoal">
{SLOTS.map((slot) => (
<button
key={slot}
type="button"
disabled={occupied.has(slot) || mutation.isPending}
title={occupied.has(slot) ? t('copy.occupied') : undefined}
onClick={() => mutation.mutate(slot)}
className="border-r border-shoal px-4 py-2 text-sm font-semibold text-snow last:border-r-0 hover:bg-ice/10 disabled:cursor-not-allowed disabled:text-frost/50 disabled:hover:bg-transparent"
>
{t('copy.slot', { slot })}
</button>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,53 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { Notice } from '../../../shared/ui/Notice'
import { getListing } from '../api'
import { copiesText } from '../copies'
import { CopyToSlot } from './CopyToSlot'
function formatDate(iso: string): string {
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(iso))
}
export function ListingDetail({ id }: { id: string }) {
const { t, i18n } = useTranslation('showcase')
const { data, isError, isPending } = useQuery({ queryKey: ['listing', id], queryFn: () => getListing(id) })
if (isPending) return null
if (isError || !data) {
return (
<div className="flex flex-col gap-4">
<Notice tone="error">{t('detail.notFound')}</Notice>
<Link to="/showcase" className="text-ice hover:underline">
{t('detail.back')}
</Link>
</div>
)
}
return (
<article className="flex flex-col gap-4 rounded-(--radius-panel) border border-shoal bg-slate/60 px-5 py-6 shadow-[inset_0_1px_0_rgb(255_255_255/0.05)]">
<Link to="/showcase" className="text-sm text-ice hover:underline">
{t('detail.back')}
</Link>
<h1 className="font-display text-3xl text-snow">{data.title}</h1>
<p className="font-code text-sm text-ice">{data.config_name}</p>
<p className="whitespace-pre-line text-frost">{data.description}</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-frost">
{data.author && data.author_id ? (
<Link to={`/u/${data.author_id}`} className="text-ice underline-offset-4 hover:underline">
{data.author.nick}
</Link>
) : (
<span>{data.author ? data.author.nick : t('item.authorHidden')}</span>
)}
<span aria-hidden="true">·</span>
<span>{formatDate(data.published_at)}</span>
<span aria-hidden="true">·</span>
<span>{copiesText(t, i18n.language, data.copies_count)}</span>
</div>
<CopyToSlot listingId={data.id} />
</article>
)
}

View file

@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { copiesText } from '../copies'
import type { Listing } from '../types'
function formatDate(iso: string): string {
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(iso))
}
export function ListingItem({ listing }: { listing: Listing }) {
const { t, i18n } = useTranslation('showcase')
const copies = copiesText(t, i18n.language, listing.copies_count)
return (
<article className="flex flex-col gap-2 rounded-(--radius-panel) border border-shoal bg-slate/60 px-4 py-4 shadow-[inset_0_1px_0_rgb(255_255_255/0.05)] transition-colors hover:border-ice/60">
<Link to={`/showcase/${listing.id}`} className="flex flex-col gap-2">
<h3 className="font-display text-lg text-snow">{listing.title}</h3>
<p className="font-code text-xs text-ice">{listing.config_name}</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-frost">
<span>{listing.author ? listing.author.nick : t('item.authorHidden')}</span>
<span aria-hidden="true">·</span>
<span>{copies}</span>
<span aria-hidden="true">·</span>
<span>{formatDate(listing.published_at)}</span>
</div>
</Link>
</article>
)
}

View file

@ -0,0 +1,81 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useSearchParams } from 'react-router'
import { Button } from '../../../shared/ui/Button'
import { Notice } from '../../../shared/ui/Notice'
import { browse } from '../api'
import { ListingItem } from './ListingItem'
function readSort(params: URLSearchParams): 'new' | 'popular' {
return params.get('sort') === 'popular' ? 'popular' : 'new'
}
function readPage(params: URLSearchParams): number {
const n = Number(params.get('page'))
return Number.isInteger(n) && n >= 0 ? n : 0
}
export function ListingList({ author }: { author?: string } = {}) {
const { t } = useTranslation('showcase')
const [params, setParams] = useSearchParams()
const sort = readSort(params)
const page = readPage(params)
const { data, isError, isPending } = useQuery({
queryKey: ['showcase', sort, page, author ?? null],
queryFn: () => browse(sort, page, author),
})
function setSort(next: 'new' | 'popular') {
setParams((p) => {
const q = new URLSearchParams(p)
q.set('sort', next)
q.set('page', '0')
return q
})
}
function setPage(next: number) {
setParams((p) => {
const q = new URLSearchParams(p)
q.set('page', String(next))
return q
})
}
return (
<div className="flex flex-col gap-6">
<div className="flex gap-2" role="group" aria-label={t('page.title')}>
<Button variant={sort === 'new' ? 'primary' : 'quiet'} aria-pressed={sort === 'new'} onClick={() => setSort('new')}>
{t('page.sortNew')}
</Button>
<Button variant={sort === 'popular' ? 'primary' : 'quiet'} aria-pressed={sort === 'popular'} onClick={() => setSort('popular')}>
{t('page.sortPopular')}
</Button>
</div>
{isError ? <Notice tone="error">{t('page.error')}</Notice> : null}
{!isError && !isPending && data && data.items.length === 0 ? <Notice tone="info">{t('page.empty')}</Notice> : null}
{data && data.items.length > 0 ? (
<ul className="grid grid-cols-1 gap-4 min-[720px]:grid-cols-2">
{data.items.map((item) => (
<li key={item.id}>
<ListingItem listing={item} />
</li>
))}
</ul>
) : null}
<div className="flex items-center gap-3">
<Button variant="quiet" disabled={page === 0} onClick={() => setPage(page - 1)}>
{t('page.back')}
</Button>
<Button variant="quiet" disabled={!data?.has_more} onClick={() => setPage(page + 1)}>
{t('page.next')}
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,17 @@
import type { TFunction } from 'i18next'
const RU_PLURAL = new Intl.PluralRules('ru')
/** Russian pluralization for "Скопировали N раз/раза/раз", exported for a focused unit test. */
export function copiesLabel(count: number): string {
const form = RU_PLURAL.select(count)
const word = form === 'one' ? 'раз' : form === 'few' ? 'раза' : 'раз'
return `Скопировали ${count} ${word}`
}
export function copiesText(t: TFunction<'showcase'>, lang: string, count: number): string {
if (lang === 'ru') return copiesLabel(count)
const form = RU_PLURAL.select(count)
const key = form === 'one' ? 'item.copiesOne' : form === 'few' ? 'item.copiesFew' : 'item.copiesMany'
return t(key, { count })
}

View file

@ -0,0 +1,32 @@
export const showcaseEn = {
page: {
title: 'Showcase',
lead: 'Configs shared by other players. Like one? Copy it into your own slot.',
sortNew: 'Newest',
sortPopular: 'Popular',
back: 'Back',
next: 'Next',
empty: 'The showcase is empty for now. Publish your config on the My configs page.',
error: 'The showcase is unavailable. Try refreshing the page later.',
},
item: {
authorHidden: 'Author hidden',
copiesOne: 'Copied {{count}} time',
copiesFew: 'Copied {{count}} times',
copiesMany: 'Copied {{count}} times',
},
detail: {
back: 'Back to showcase',
notFound: 'This config does not exist or is hidden.',
},
copy: {
heading: 'Copy to my slot',
slot: 'Slot {{slot}}',
occupied: 'Occupied',
loginPrompt: 'Log in to copy this config to your account',
login: 'Log in',
success: 'Copied to slot {{slot}}',
openConfigs: 'Open my configs',
conflict: 'That slot is already taken. Pick a free one or clear a slot in the mod.',
},
} as const

View file

@ -0,0 +1,32 @@
export const showcaseRu = {
page: {
title: 'Витрина',
lead: 'Конфиги, которыми поделились игроки. Понравился, скопируй в свой слот.',
sortNew: 'Новые',
sortPopular: 'Популярные',
back: 'Назад',
next: 'Дальше',
empty: 'На витрине пока пусто. Опубликуй свой конфиг на странице «Мои конфиги».',
error: 'Витрина недоступна. Попробуй обновить страницу позже.',
},
item: {
authorHidden: 'Автор скрыт',
copiesOne: 'Скопировали {{count}} раз',
copiesFew: 'Скопировали {{count}} раза',
copiesMany: 'Скопировали {{count}} раз',
},
detail: {
back: 'Назад к витрине',
notFound: 'Такого конфига нет или он скрыт.',
},
copy: {
heading: 'Скопировать себе',
slot: 'Слот {{slot}}',
occupied: 'Занят',
loginPrompt: 'Войди, чтобы скопировать конфиг к себе',
login: 'Войти',
success: 'Скопировано в слот {{slot}}',
openConfigs: 'Открыть мои конфиги',
conflict: 'Этот слот уже занят. Выбери свободный или освободи слот в моде.',
},
} as const

View file

@ -0,0 +1,87 @@
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { copiesLabel } from '../copies'
import { ListingList } from '../components/ListingList'
import { CopyToSlot } from '../components/CopyToSlot'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
const listing = {
id: 'l1',
title: 'Aura / Trails',
description: 'Мой любимый набор',
config_name: 'pvp',
copies_count: 3,
published_at: '2026-09-20T10:00:00Z',
author: { nick: 'loki', avatar_url: null },
}
test('russian plural for copies', () => {
expect(copiesLabel(1)).toBe('Скопировали 1 раз')
expect(copiesLabel(3)).toBe('Скопировали 3 раза')
expect(copiesLabel(5)).toBe('Скопировали 5 раз')
expect(copiesLabel(21)).toBe('Скопировали 21 раз')
})
test('list renders items with author and plural', async () => {
mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /showcase': () => json({ items: [listing], page: 0, has_more: false }),
})
renderApp([{ path: '/', element: <ListingList /> }])
expect(await screen.findByText('Aura / Trails')).toBeInTheDocument()
expect(screen.getByText('loki')).toBeInTheDocument()
expect(screen.getByText('Скопировали 3 раза')).toBeInTheDocument()
})
test('sort toggle updates the query and refetches', async () => {
const user = userEvent.setup()
const fetch = mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /showcase': () => json({ items: [listing], page: 0, has_more: false }),
})
renderApp([{ path: '/', element: <ListingList /> }], '/?sort=new')
await screen.findByText('Aura / Trails')
await user.click(screen.getByRole('button', { name: 'Популярные' }))
expect(fetch.mock.calls.some(([u]) => String(u).includes('sort=popular'))).toBe(true)
})
test('anonymous copy shows the login prompt', async () => {
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
renderApp([{ path: '/', element: <CopyToSlot listingId="l1" /> }])
expect(await screen.findByText('Войди, чтобы скопировать конфиг к себе')).toBeInTheDocument()
})
test('authenticated copy disables occupied slots and posts the slot', async () => {
const user = userEvent.setup()
const fetch = mockFetch({
'POST /auth/refresh': () => json({ access_token: 't' }),
'GET /me': () => json({ id: 'u1', email: 'a@b.c', nick: 'loki', avatar_url: null }),
'GET /configs': () => json([{ slot: 1, name: 'x', updated_at: '2026-09-20T10:00:00Z', share_code: 'A', published: false }]),
'POST /showcase/l1/copy': () => json({}, 201),
})
renderApp([{ path: '/', element: <CopyToSlot listingId="l1" /> }])
const slot1 = await screen.findByRole('button', { name: 'Слот 1' })
await waitFor(() => expect(slot1).toBeDisabled())
const slot2 = screen.getByRole('button', { name: 'Слот 2' })
await user.click(slot2)
expect(await screen.findByText('Скопировано в слот 2')).toBeInTheDocument()
const body = fetch.mock.calls.find(([u]) => String(u).endsWith('/copy'))?.[1]?.body
expect(JSON.parse(String(body))).toEqual({ slot: 2 })
})
test('409 shows the conflict message', async () => {
const user = userEvent.setup()
mockFetch({
'POST /auth/refresh': () => json({ access_token: 't' }),
'GET /me': () => json({ id: 'u1', email: 'a@b.c', nick: 'loki', avatar_url: null }),
'GET /configs': () => json([]),
'POST /showcase/l1/copy': () => json({ error: 'target slot is not empty' }, 409),
})
renderApp([{ path: '/', element: <CopyToSlot listingId="l1" /> }])
const slot1 = await screen.findByRole('button', { name: 'Слот 1' })
await user.click(slot1)
const panel = slot1.closest('div') as HTMLElement
expect(await within(panel.parentElement as HTMLElement).findByText(/Этот слот уже занят/)).toBeInTheDocument()
})

View file

@ -0,0 +1,16 @@
export type Author = { nick: string; avatar_url: string | null }
export type Listing = {
id: string
title: string
description: string
config_name: string
copies_count: number
published_at: string
author: Author | null
author_id?: string | null
}
export type ListingDetail = Listing & { data: unknown }
export type ShowcasePage = { items: Listing[]; page: number; has_more: boolean }

View file

@ -4,24 +4,32 @@ import { themeToCssVars } from '../../clickgui/themeVars'
import { VoxelScene } from '../../landing/effects/VoxelScene' import { VoxelScene } from '../../landing/effects/VoxelScene'
import { parseArgb, type ThemeEntry } from '../types' import { parseArgb, type ThemeEntry } from '../types'
/** The live left pane: a themed ClickGui window plus two HUD tiles over the voxel scene. */ type Props = {
export function EditorPreview({ draft }: { draft: ThemeEntry }) { draft: ThemeEntry
/** A theme card clicked inside the preview's Settings screen: load it into the draft, not the site. */
onPickPreset: (id: string) => void
}
/** The live left pane: a themed ClickGui window plus two HUD tiles over the voxel scene, sized to fit its column. */
export function EditorPreview({ draft, onPickPreset }: Props) {
const vars = useMemo(() => themeToCssVars(draft), [draft]) const vars = useMemo(() => themeToCssVars(draft), [draft])
const { r, g, b } = parseArgb(draft.theme.accent) const { r, g, b } = parseArgb(draft.theme.accent)
return ( return (
<div className="lv-editor-preview relative min-h-[420px] overflow-hidden rounded-panel border border-white/10"> <div className="lv-editor-preview relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-panel border border-white/10">
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} className="absolute inset-0" /> <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="relative flex min-h-0 flex-1 flex-col gap-3 p-3 sm:p-4" style={vars}>
<div className="flex w-full justify-center gap-4"> <div className="flex shrink-0 justify-center gap-3">
<span className="lv-hud-tile" style={{ background: 'var(--gui-card-gradient)' }}> <span className="lv-hud-tile lv-hud-tile-card" style={{ background: 'var(--gui-card-gradient)' }}>
<b>FPS</b> 240 <b>FPS</b> 240
</span> </span>
<span className="lv-hud-tile" style={{ background: 'var(--gui-surface-gradient)' }}> <span className="lv-hud-tile" style={{ background: 'var(--gui-surface-gradient)' }}>
<b>XYZ</b> 12 · 64 · -8 <b>XYZ</b> 12 · 64 · -8
</span> </span>
</div> </div>
<ClickGuiWindow theme={draft} demo={false} className="w-full max-w-[520px]" /> <div className="lv-editor-stage">
<ClickGuiWindow theme={draft} demo={false} onPickTheme={onPickPreset} className="lv-editor-gui" />
</div>
</div> </div>
</div> </div>
) )

View file

@ -1,18 +1,19 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '../../../shared/ui/Button' import { Button } from '../../../shared/ui/Button'
import { PRESETS } from '../presets.generated' import { PRESETS } from '../presets.generated'
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types' import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
import { EditorPreview } from './EditorPreview' import { EditorPreview } from './EditorPreview'
import { ColorField } from './ColorField' import { ColorField } from './fields/ColorField'
import { COLOR_SLOTS, GRADIENT_SLOTS, fromProfileJson, toProfileJson, toShareHash } from './codec' import { COLOR_SLOTS, GRADIENT_SLOTS, fromProfileJson, toProfileJson, toShareHash } from './model/codec'
import { GradientField } from './GradientField' import { GradientField } from './fields/GradientField'
import { useThemeHistory } from './useThemeHistory' import { useThemeHistory } from './model/useThemeHistory'
/** Pack the draft name into a slug id, mirroring the codec's `slug`. */ /** 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' const nameToId = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'custom'
export function ThemeEditor({ initial }: { initial: ThemeEntry }) { /** `intro` (page title + lead) sits on top of the controls column so the whole editor fits one screen. */
export function ThemeEditor({ initial, intro }: { initial: ThemeEntry; intro?: ReactNode }) {
const { t } = useTranslation('themes') const { t } = useTranslation('themes')
const { draft, set, reset, undo, redo, canUndo, canRedo } = useThemeHistory(initial) const { draft, set, reset, undo, redo, canUndo, canRedo } = useThemeHistory(initial)
const [notice, setNotice] = useState<string | null>(null) const [notice, setNotice] = useState<string | null>(null)
@ -61,6 +62,11 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
flash(t('editor.shared')) flash(t('editor.shared'))
} }
const pickPreset = (id: string) => {
const preset = PRESETS.find((p) => p.id === id)
if (preset) reset(preset)
}
const onFile = (file?: File) => { const onFile = (file?: File) => {
if (!file) return if (!file) return
file.text().then((text) => { file.text().then((text) => {
@ -71,10 +77,13 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
} }
return ( return (
<div className="grid gap-8 lg:grid-cols-2"> <div className="grid grid-cols-[minmax(0,1fr)] gap-6 py-6 [grid-template-areas:'intro'_'preview'_'controls'] lg:h-[calc(100svh-var(--topbar-h))] lg:min-h-[560px] lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:grid-rows-[auto_minmax(0,1fr)] lg:gap-x-8 lg:gap-y-4 lg:py-4 lg:[grid-template-areas:'preview_intro'_'preview_controls']">
<EditorPreview draft={draft} /> {intro && <div className="[grid-area:intro]">{intro}</div>}
<div className="flex min-h-0 flex-col [grid-area:preview]">
<EditorPreview draft={draft} onPickPreset={pickPreset} />
</div>
<div className="flex flex-col gap-6"> <div className="flex min-h-0 flex-col gap-4 [grid-area:controls]">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button variant="quiet" onClick={undo} disabled={!canUndo}> <Button variant="quiet" onClick={undo} disabled={!canUndo}>
{t('editor.undo')} {t('editor.undo')}
@ -88,6 +97,7 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
{notice && <span className="ml-auto font-code text-xs text-ice">{notice}</span>} {notice && <span className="ml-auto font-code text-xs text-ice">{notice}</span>}
</div> </div>
<div className="lv-editor-scroll flex min-h-0 flex-1 flex-col gap-6 lg:-mr-3 lg:overflow-y-auto lg:pr-3">
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="text-xs text-frost">{t('editor.nameLabel')}</span> <span className="text-xs text-frost">{t('editor.nameLabel')}</span>
<input <input
@ -115,7 +125,7 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<h3 className="text-sm font-semibold uppercase text-frost">{t('editor.colors')}</h3> <h3 className="text-sm font-semibold uppercase text-frost">{t('editor.colors')}</h3>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
{COLOR_SLOTS.map((slot) => ( {COLOR_SLOTS.map((slot) => (
<ColorField key={slot} label={t(`colors.${slot}`)} value={draft.theme[slot]} onChange={(hex) => setColor(slot, hex)} /> <ColorField key={slot} label={t(`colors.${slot}`)} value={draft.theme[slot]} onChange={(hex) => setColor(slot, hex)} />
))} ))}
@ -136,7 +146,9 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
</div> </div>
</section> </section>
<div className="flex flex-wrap gap-2"> </div>
<div className="grid grid-cols-2 gap-2 border-t border-white/10 pt-4 sm:grid-cols-4 lg:grid-cols-2">
<Button onClick={copy}>{t('editor.copy')}</Button> <Button onClick={copy}>{t('editor.copy')}</Button>
<Button variant="quiet" onClick={download}> <Button variant="quiet" onClick={download}>
{t('editor.download')} {t('editor.download')}

View file

@ -1,6 +1,15 @@
/* Theme editor: preview pane, HUD tiles, preset chips and color-field affordances. */ /* Theme editor: preview pane, HUD tiles, preset chips and color-field affordances. */
.lv-editor-preview { background: linear-gradient(180deg, #0b0f22, #070a18); } .lv-editor-preview { background: linear-gradient(180deg, #0b0f22, #070a18); }
/* On desktop the editor is one viewport tall: the stage is a size container and the ClickGui
(narrow layout, 264:330) takes the largest width that still fits its height. */
.lv-editor-gui { margin-inline: auto; }
@media (min-width: 1024px) {
.lv-editor-stage { flex: 1; min-height: 0; container-type: size; display: grid; place-items: center; }
.lv-editor-gui { width: min(100cqw, 100cqh * 264 / 330); }
}
.lv-editor-scroll { scrollbar-width: thin; scrollbar-color: rgb(255 255 255 / 0.18) transparent; }
.lv-hud-tile { .lv-hud-tile {
display: inline-flex; display: inline-flex;
align-items: baseline; align-items: baseline;
@ -14,6 +23,7 @@
box-shadow: 0 6px 20px -12px #000; box-shadow: 0 6px 20px -12px #000;
} }
.lv-hud-tile b { color: var(--gui-accent, #6cf); font-weight: 700; } .lv-hud-tile b { color: var(--gui-accent, #6cf); font-weight: 700; }
.lv-hud-tile-card b { color: var(--gui-text, #fff); }
.lv-preset-chip { .lv-preset-chip {
padding: 4px 12px; padding: 4px 12px;

View file

@ -1,5 +1,5 @@
import { useId, useState } from 'react' import { useId, useState } from 'react'
import { parseArgb } from '../types' import { argbToCss, parseArgb } from '../../types'
type Props = { label: string; value: string; onChange: (hex: string) => void } type Props = { label: string; value: string; onChange: (hex: string) => void }
@ -28,12 +28,10 @@ export function ColorField({ label, value, onChange }: Props) {
} }
return ( return (
<label htmlFor={id} className="lv-color-field flex items-center gap-2"> <label htmlFor={id} className="lv-color-field flex w-full min-w-0 items-center gap-2">
<span <span className="lv-alpha-check size-8 shrink-0 overflow-hidden rounded-md border border-white/15" aria-hidden="true">
className="lv-alpha-check size-8 shrink-0 rounded-md border border-white/15" <span className="block size-full" style={{ background: argbToCss(value) }} />
style={{ backgroundImage: `linear-gradient(45deg, ${value}, ${value})` }} </span>
aria-hidden="true"
/>
<span className="flex min-w-0 flex-1 flex-col gap-1"> <span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-xs text-frost">{label}</span> <span className="truncate text-xs text-frost">{label}</span>
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">

View file

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { GradientSpec } from '../types' import type { GradientSpec } from '../../types'
import { ColorField } from './ColorField' import { ColorField } from './ColorField'
type Props = { title: string; value: GradientSpec; onChange: (patch: Partial<GradientSpec>) => void } type Props = { title: string; value: GradientSpec; onChange: (patch: Partial<GradientSpec>) => void }
@ -9,7 +9,7 @@ export function GradientField({ title, value, onChange }: Props) {
const { t } = useTranslation('themes') const { t } = useTranslation('themes')
return ( return (
<div className="lv-gradient-field flex flex-col gap-3 rounded-panel border border-white/10 bg-black/25 p-3"> <div className="lv-gradient-field flex min-w-0 flex-col gap-3 rounded-panel border border-white/10 bg-black/25 p-3">
<label className="flex items-center justify-between gap-2"> <label className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-snow">{title}</span> <span className="text-sm font-semibold text-snow">{title}</span>
<span className="flex items-center gap-2 text-xs text-frost"> <span className="flex items-center gap-2 text-xs text-frost">

View file

@ -1,5 +1,5 @@
import { PRESETS } from '../presets.generated' import { PRESETS } from '../../presets.generated'
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types' import type { GradientSpec, ThemeColors, ThemeEntry } from '../../types'
// The mod serialises a theme as a FLAT map: `name`, the 12 color slots as // 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`. // `#AARRGGBB`, then for each of the 5 gradient slots `Enabled/Start/End/Angle`.

View file

@ -1,5 +1,5 @@
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import type { ThemeEntry } from '../types' import type { ThemeEntry } from '../../types'
const LIMIT = 50 const LIMIT = 50
/** Changes closer than this share one undo step, so a slider drag is a single revert. */ /** Changes closer than this share one undo step, so a slider drag is a single revert. */

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest' import { describe, expect, test } from 'vitest'
import { PRESETS } from '../../presets.generated' import { PRESETS } from '../../presets.generated'
import { fromProfileJson, fromShareHash, toProfileJson, toShareHash } from '../codec' import { fromProfileJson, fromShareHash, toProfileJson, toShareHash } from '../model/codec'
// The mod's ThemesProfileCodec.toProfileValues writes exactly these keys, in this order. // The mod's ThemesProfileCodec.toProfileValues writes exactly these keys, in this order.
const PROFILE_KEYS = [ const PROFILE_KEYS = [

View file

@ -1,6 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest' import { expect, test } from 'vitest'
import { PRESETS } from '../../presets.generated' import { PRESETS } from '../../presets.generated'
import { ActiveThemeProvider, useActiveTheme } from '../../useActiveTheme'
import { ThemeEditor } from '../ThemeEditor' import { ThemeEditor } from '../ThemeEditor'
test('editor shows the initial preset name and one toggle per gradient slot', () => { test('editor shows the initial preset name and one toggle per gradient slot', () => {
@ -16,3 +18,24 @@ test('picking a preset chip loads it into the editor', () => {
fireEvent.click(screen.getByRole('button', { name: next.name })) fireEvent.click(screen.getByRole('button', { name: next.name }))
expect(screen.getByDisplayValue(next.name)).toBeInTheDocument() expect(screen.getByDisplayValue(next.name)).toBeInTheDocument()
}) })
function SiteTheme() {
return <output aria-label="site theme">{useActiveTheme().theme.id}</output>
}
test('a theme card inside the preview loads that preset into the draft, not the site', async () => {
const user = userEvent.setup()
render(
<ActiveThemeProvider applyToPage={false}>
<SiteTheme />
<ThemeEditor initial={PRESETS[0]} />
</ActiveThemeProvider>,
)
const gui = screen.getByTestId('clickgui-root')
await user.click(within(gui).getByRole('tab', { name: 'Settings' }))
await user.click(within(gui).getByRole('option', { name: 'Тема Nitro' }))
expect(screen.getByDisplayValue('Nitro')).toBeInTheDocument()
expect(within(gui).getByRole('option', { name: 'Тема Nitro' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('status', { name: 'site theme' })).toHaveTextContent(PRESETS[0].id)
expect(window.localStorage.getItem('lv_theme')).toBeNull()
})

View file

@ -44,6 +44,6 @@ export const themesEn = {
reset: 'Reset', reset: 'Reset',
undo: 'Undo', undo: 'Undo',
redo: 'Redo', redo: 'Redo',
importError: 'Could not read the theme — the file is malformed.', importError: 'Could not read the theme: the file is malformed.',
}, },
} satisfies Translation<typeof themesRu> } satisfies Translation<typeof themesRu>

View file

@ -41,6 +41,6 @@ export const themesRu = {
reset: 'Сбросить', reset: 'Сбросить',
undo: 'Отменить', undo: 'Отменить',
redo: 'Вернуть', redo: 'Вернуть',
importError: 'Не удалось прочитать тему — файл испорчен.', importError: 'Не удалось прочитать тему: файл испорчен.',
}, },
} as const } as const

View file

@ -36,6 +36,9 @@
@layer base { @layer base {
html { html {
/* Height reserved for the floating top bar (TopBar): its top margin plus the bar itself. */
--topbar-h: 4.25rem;
scroll-padding-top: calc(var(--topbar-h) + 1rem);
color-scheme: dark; color-scheme: dark;
transition: --color-ice 400ms ease, --page-accent-glow 400ms ease; transition: --color-ice 400ms ease, --page-accent-glow 400ms ease;
} }
@ -64,3 +67,7 @@
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
/* Konami easter egg: the whole page cycles through the hue wheel for a few seconds. */
html.lv-konami { animation: lv-konami-hue 1.4s linear infinite; }
@keyframes lv-konami-hue { to { filter: hue-rotate(360deg); } }

View file

@ -2,9 +2,11 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import { initI18n } from './app/i18n' import { initI18n } from './app/i18n'
import { printConsoleGreeting } from './shared/easter/consoleGreeting'
import App from './app/App.tsx' import App from './app/App.tsx'
initI18n() initI18n()
printConsoleGreeting(navigator.language)
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View file

@ -0,0 +1,11 @@
import { DeviceList } from '../../features/account/components/DeviceList'
import { ProfileCard } from '../../features/account/components/ProfileCard'
export default function AccountPage() {
return (
<section className="grid grid-cols-1 gap-6 py-12 min-[900px]:grid-cols-[320px_1fr]">
<ProfileCard />
<DeviceList />
</section>
)
}

View file

@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next'
import { ParticleField } from '../../features/landing/effects/ParticleField'
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
import { SlotGrid } from '../../features/configs/components/SlotGrid'
const ACCENT = 'rgb(92, 200, 231)'
export default function ConfigsPage() {
const { t } = useTranslation('configs')
return (
<section className="lv-bleed relative isolate -mt-(--topbar-h) flex min-h-svh flex-col gap-8 overflow-hidden pt-(--topbar-h) pb-16">
<div aria-hidden="true" className="absolute inset-0 -z-10 opacity-40">
<VoxelScene accent={ACCENT} />
</div>
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-abyss/70" />
<ParticleField color={ACCENT} className="opacity-50" />
<div className="relative mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-4 pt-12 sm:px-6">
<div>
<h1 className="font-display text-3xl text-snow">{t('page.title')}</h1>
<p className="mt-2 max-w-[60ch] text-frost">
{t('page.leadBefore')} <code className="font-code text-ice">%config save &lt;слот&gt;</code>
{t('page.leadAfter')}
</p>
</div>
<SlotGrid />
</div>
</section>
)
}

View file

@ -0,0 +1,35 @@
import { useTranslation } from 'react-i18next'
import { LinkDeviceForm } from '../../features/device-link/LinkDeviceForm'
const STEPS = ['one', 'two', 'three'] as const
export default function LinkPage() {
const { t } = useTranslation('deviceLink')
return (
<section className="flex flex-col gap-8 py-12">
<div>
<h1 className="text-3xl">{t('page.title')}</h1>
<p className="mt-2 max-w-sm text-frost">{t('page.lead')}</p>
</div>
<ol className="flex flex-col gap-3 sm:flex-row sm:gap-4">
{STEPS.map((step, i) => (
<li
key={step}
className="flex flex-1 items-start gap-3 rounded-(--radius-panel) border border-shoal bg-slate/60 px-4 py-3"
>
<span
aria-hidden="true"
className="flex h-6 w-6 flex-none items-center justify-center rounded-full bg-ice font-code text-sm font-bold text-abyss"
>
{i + 1}
</span>
<p className="text-sm text-frost">{t(`steps.${step}`)}</p>
</li>
))}
</ol>
<LinkDeviceForm />
</section>
)
}

View file

@ -1,23 +1,26 @@
import { CommandHero } from '../../features/landing/CommandHero' import { CommandHero } from '../../features/landing/CommandHero'
import { HowItWorks } from '../../features/landing/HowItWorks' import { HowItWorks } from '../../features/landing/HowItWorks'
import { FaqDownload } from '../../features/landing/sections/FaqDownload' import { FaqDownload } from '../../features/landing/sections/explore/FaqDownload'
import { Hero } from '../../features/landing/sections/Hero' import { Hero } from '../../features/landing/sections/hero/Hero'
import { HudPlayground } from '../../features/landing/sections/HudPlayground' import { HudPlayground } from '../../features/landing/sections/explore/HudPlayground'
import { ModuleWall } from '../../features/landing/sections/ModuleWall' import { ModuleWall } from '../../features/landing/sections/explore/ModuleWall'
import { ShowcaseTeaser } from '../../features/landing/sections/ShowcaseTeaser' import { ShowcaseTeaser } from '../../features/landing/sections/explore/ShowcaseTeaser'
import { ThemeStrip } from '../../features/landing/sections/ThemeStrip' import { ThemeStrip } from '../../features/landing/sections/hero/ThemeStrip'
export default function HomePage() { export default function HomePage() {
return ( return (
<> <>
<Hero /> <Hero />
<ThemeStrip /> <ThemeStrip />
<div className="lv-lower">
<div aria-hidden="true" className="lv-lower-bg" />
<HudPlayground /> <HudPlayground />
<CommandHero /> <CommandHero />
<HowItWorks /> <HowItWorks />
<ModuleWall /> <ModuleWall />
<ShowcaseTeaser /> <ShowcaseTeaser />
<FaqDownload /> <FaqDownload />
</div>
</> </>
) )
} }

View file

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

View file

@ -1,10 +1,15 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link } from 'react-router' import { Link } from 'react-router'
import { NOT_FOUND_ART } from '../../shared/easter/art.generated'
export default function NotFoundPage() { export default function NotFoundPage() {
const { t } = useTranslation('common') const { t } = useTranslation('common')
return ( return (
<section className="flex flex-col gap-4 py-16"> <section className="flex flex-col gap-4 py-16">
<pre aria-hidden="true" className="w-fit max-w-full overflow-x-auto rounded-panel border border-white/10 bg-black/30 p-4 font-code text-xs leading-tight text-ice">
{NOT_FOUND_ART}
{t('notFound.console')}
</pre>
<h1 className="text-[2.4375rem] leading-tight">{t('notFound.title')}</h1> <h1 className="text-[2.4375rem] leading-tight">{t('notFound.title')}</h1>
<p className="text-frost"> <p className="text-frost">
{t('notFound.body')}{' '} {t('notFound.body')}{' '}

View file

@ -0,0 +1,11 @@
import { useParams } from 'react-router'
import { PublicProfileView } from '../../features/profile/components/PublicProfileView'
export default function ProfilePage() {
const { id = '' } = useParams()
return (
<section className="py-12">
<PublicProfileView id={id} />
</section>
)
}

View file

@ -1,13 +1,13 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RegisterForm } from '../../features/auth/forms/RegisterForm' import { RegisterForm } from '../../features/auth/forms/RegisterForm'
import { AuthSplit } from './auth/AuthSplit'
export default function RegisterPage() { export default function RegisterPage() {
const { t } = useTranslation('auth') const { t } = useTranslation('auth')
return ( return (
<section className="flex flex-col gap-6 py-12"> <AuthSplit title={t('register.title')}>
<h1 className="text-3xl">{t('register.title')}</h1> <p className="mb-4 max-w-sm text-frost">{t('register.subtitle')}</p>
<p className="max-w-sm text-frost">{t('register.subtitle')}</p>
<RegisterForm /> <RegisterForm />
</section> </AuthSplit>
) )
} }

View file

@ -0,0 +1,36 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { ClickGuiWindow } from '../../../features/clickgui/ClickGuiWindow'
import { ParticleField } from '../../../features/landing/effects/ParticleField'
import { VoxelScene } from '../../../features/landing/effects/VoxelScene'
import { useActiveTheme } from '../../../features/themes/useActiveTheme'
import { parseArgb } from '../../../features/themes/types'
/** Two-pane auth layout: a ClickGui-styled glass form panel, and a decorative scene (hidden < 900px). */
export function AuthSplit({ title, children }: { title: string; children: ReactNode }) {
const { t } = useTranslation('auth')
const { theme } = useActiveTheme()
const { r, g, b } = parseArgb(theme.theme.accent)
const particleColor = `rgb(${r}, ${g}, ${b})`
return (
<div className="flex min-h-[calc(100vh-var(--topbar-h)-4rem)] items-center justify-center py-10 min-[900px]:justify-between min-[900px]:gap-12">
<div className="w-full max-w-md rounded-(--radius-panel) border border-shoal bg-slate/80 shadow-[0_0_60px_-20px_var(--page-accent-glow,transparent)] backdrop-blur">
<div className="flex items-center gap-2 border-b border-shoal px-5 py-4">
<span aria-hidden="true" className="h-2.5 w-2.5 rounded-full bg-ice" />
<h1 className="font-display text-xl">{title}</h1>
</div>
<div className="px-5 py-6">{children}</div>
</div>
<div className="relative hidden min-[900px]:flex min-[900px]:h-[520px] min-[900px]:w-[440px] min-[900px]:shrink-0 min-[900px]:items-end min-[900px]:overflow-hidden min-[900px]:rounded-(--radius-panel) min-[900px]:border min-[900px]:border-shoal">
<VoxelScene accent={particleColor} />
<ParticleField color={particleColor} className="mix-blend-screen" />
<div className="relative z-10 w-full p-5">
<ClickGuiWindow demo className="w-full max-w-[300px] drop-shadow-2xl" />
<p className="mt-4 max-w-[300px] font-display text-sm text-snow drop-shadow">{t('splitPitch')}</p>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,21 @@
import { useParams } from 'react-router'
import { ParticleField } from '../../features/landing/effects/ParticleField'
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
import { ListingDetail } from '../../features/showcase/components/ListingDetail'
const ACCENT = 'rgb(92, 200, 231)'
export default function ListingPage() {
const { id } = useParams<{ id: string }>()
return (
<section className="lv-bleed relative isolate -mt-(--topbar-h) flex min-h-svh flex-col gap-8 overflow-hidden pt-(--topbar-h) pb-16">
<div aria-hidden="true" className="absolute inset-0 -z-10 opacity-40">
<VoxelScene accent={ACCENT} />
</div>
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-abyss/70" />
<ParticleField color={ACCENT} className="opacity-50" />
<div className="relative mx-auto w-full max-w-[720px] px-4 pt-12 sm:px-6">{id ? <ListingDetail id={id} /> : null}</div>
</section>
)
}

View file

@ -0,0 +1,11 @@
import { lazy, Suspense } from 'react'
const ListingPage = lazy(() => import('./ListingPage'))
export default function ListingRoute() {
return (
<Suspense fallback={<div className="px-6 py-16 text-frost">…</div>}>
<ListingPage />
</Suspense>
)
}

View file

@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next'
import { ParticleField } from '../../features/landing/effects/ParticleField'
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
import { ListingList } from '../../features/showcase/components/ListingList'
const ACCENT = 'rgb(92, 200, 231)'
export default function ShowcasePage() {
const { t } = useTranslation('showcase')
return (
<section className="lv-bleed relative isolate -mt-(--topbar-h) flex min-h-svh flex-col gap-8 overflow-hidden pt-(--topbar-h) pb-16">
<div aria-hidden="true" className="absolute inset-0 -z-10 opacity-40">
<VoxelScene accent={ACCENT} />
</div>
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-abyss/70" />
<ParticleField color={ACCENT} className="opacity-50" />
<div className="relative mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-4 pt-12 sm:px-6">
<div>
<h1 className="font-display text-3xl text-snow">{t('page.title')}</h1>
<p className="mt-2 max-w-[60ch] text-frost">{t('page.lead')}</p>
</div>
<ListingList />
</div>
</section>
)
}

View file

@ -0,0 +1,12 @@
import { lazy, Suspense } from 'react'
// Loaded on demand: the showcase pages pull in extra query/routing glue not needed on the landing.
const ShowcasePage = lazy(() => import('./ShowcasePage'))
export default function ShowcaseRoute() {
return (
<Suspense fallback={<div className="px-6 py-16 text-frost">…</div>}>
<ShowcasePage />
</Suspense>
)
}

View file

@ -1,7 +1,7 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ThemeEditor } from '../../features/themes/editor/ThemeEditor' import { ThemeEditor } from '../../features/themes/editor/ThemeEditor'
import { fromShareHash } from '../../features/themes/editor/codec' import { fromShareHash } from '../../features/themes/editor/model/codec'
import { PRESETS } from '../../features/themes/presets.generated' import { PRESETS } from '../../features/themes/presets.generated'
import '../../features/themes/editor/editor.css' import '../../features/themes/editor/editor.css'
@ -19,12 +19,14 @@ export default function ThemesPage() {
}, []) }, [])
return ( return (
<div className="mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-6 py-12"> <ThemeEditor
<header className="flex flex-col gap-2"> initial={initial}
<h1 className="font-display text-4xl font-black uppercase text-snow">{t('editor.title')}</h1> intro={
<p className="text-frost">{t('editor.lead')}</p> <header className="flex flex-col gap-1">
<h1 className="font-display text-3xl font-black uppercase text-snow">{t('editor.title')}</h1>
<p className="text-sm text-frost">{t('editor.lead')}</p>
</header> </header>
<ThemeEditor initial={initial} /> }
</div> />
) )
} }

Some files were not shown because too many files have changed in this diff Show more