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

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 { prefersReducedMotion } from '../../shared/motion/reducedMotion'
import { PRESETS } from '../themes/presets.generated'
import type { ThemeEntry } from '../themes/types'
import { useActiveTheme } from '../themes/useActiveTheme'
import { DEMO, useDemo } from './demo'
import { ModuleCard } from './ModuleCard'
import './parts/clickgui.css'
import { CategoryIcon, DemoCursor, LogoMark, SearchGlyph } from './parts/Glyphs'
import { CATEGORIES, QUICK_THEMES, TABS } from './parts/modules'
import { Backdrop } from './parts/Backdrop'
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'
type Props = {
@ -17,16 +19,43 @@ type Props = {
paused?: boolean
/** Overrides the active theme — for previews. */
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
}
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 { theme: active, setTheme } = useActiveTheme()
const theme = override ?? active
const rootRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState(false)
const [reduced] = useState(prefersReducedMotion)
const clock = useClock()
const themeIdRef = useRef(theme.id)
useEffect(() => {
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])
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 category = CATEGORIES.find((c) => c.id === state.category) ?? CATEGORIES[0]
const vars = useMemo(
() => (backdrop ? { ...themeToCssVars(theme), '--gui-backdrop-image': `url(${JSON.stringify(backdrop)})` } : themeToCssVars(theme)),
[theme, backdrop],
)
const pick = onPickTheme ?? ((id: string) => setTheme(id, 'user'))
return (
<div
@ -54,75 +86,59 @@ export function ClickGuiWindow({ demo = true, paused = false, theme: override, c
data-testid="clickgui-root"
className={`lv-gui ${className}`}
style={vars}
onPointerEnter={(e) => e.pointerType === 'mouse' && setHovered(true)}
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
>
<div className="lv-gui-bloom" aria-hidden="true" />
<div className="lv-gui-window">
<header className="lv-gui-header">
<span className="lv-gui-logo">
<LogoMark />
LoVisual
</span>
<nav className="lv-gui-tabs" aria-hidden="true">
{TABS.map((tab, i) => (
<span key={tab} className="lv-gui-tab" data-active={i === 0}>
{tab}
</span>
))}
</nav>
<span className="lv-gui-search" aria-hidden="true">
<SearchGlyph />
</span>
</header>
<div className="lv-gui-stage" data-tab={state.tab}>
<div className="lv-gui-world" aria-hidden="true">
<Backdrop />
<div className="lv-gui-photo" />
</div>
<p className="lv-gui-watermark" aria-hidden="true">
<b>LoVisual</b>·400 fps <span>0 ms</span>
</p>
<div className="lv-gui-body">
<aside className="lv-gui-cats" aria-label={t('categories')}>
{CATEGORIES.map((c) => (
<div className="lv-gui-bar">
<time className="lv-gui-clock" aria-label={t('clock')}>
{clock}
</time>
<div className="lv-gui-tabs" role="tablist" aria-label={t('tabsLabel')}>
<span className="lv-gui-tab-thumb" aria-hidden="true" />
{TABS.map((id) => (
<button
key={c.id}
key={id}
type="button"
className="lv-gui-cat"
data-active={c.id === category.id}
aria-pressed={c.id === category.id}
data-demo={`cat:${c.id}`}
aria-label={c.name}
onClick={() => dispatch({ type: 'category', id: c.id })}
role="tab"
className="lv-gui-tab"
aria-selected={state.tab === id}
data-demo={`tab:${id}`}
onClick={() => dispatch({ type: 'tab', id })}
>
<CategoryIcon id={c.id} />
<span className="lv-gui-cat-name">{c.name}</span>
<span className="lv-gui-cat-count">{c.modules.length}</span>
{t(`tabs.${id}`)}
</button>
))}
<div className="lv-gui-theme-dots">
{QUICK_THEMES.map((id) => {
const preset = PRESETS.find((p) => p.id === id)
if (!preset) return null
const dot = themeToCssVars(preset)
return (
<button
key={id}
type="button"
className="lv-gui-theme-dot"
data-active={theme.id === id}
data-demo={`theme:${id}`}
aria-label={t('theme', { name: preset.name })}
style={{ '--dot': dot['--gui-header-gradient'], '--dot-accent': dot['--gui-accent'] } as CSSProperties}
onClick={() => setTheme(id, 'user')}
/>
)
})}
</div>
</aside>
<div className="lv-gui-main">
<div className="lv-gui-grid" key={category.id}>
{category.modules.map((m) => (
<ModuleCard key={m.name} module={m} state={state} dispatch={dispatch} />
))}
</div>
</div>
</div>
{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>
<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>
<DemoCursor cursor={cursor} />
</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 { 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[] = [
{ at: 400, action: 'move', target: 'cat:visuals' },
{ at: 1100, action: 'click', target: 'cat:visuals' },
{ at: 1700, action: 'move', target: 'toggle:Trails' },
{ at: 2400, action: 'click', target: 'toggle:Trails' },
{ at: 3000, action: 'move', target: 'expand:Trails' },
{ at: 3600, action: 'click', target: 'expand:Trails' },
{ at: 4300, action: 'move', target: 'knob:Trails.Length' },
{ at: 4900, action: 'drag', target: 'slider:Trails.Length', value: 85 },
{ at: 6300, action: 'move', target: 'check:Trails.Rainbow' },
{ at: 6900, action: 'click', target: 'check:Trails.Rainbow' },
{ at: 7700, action: 'move', target: 'theme:next' },
{ at: 8400, action: 'theme', target: 'theme:next' },
{ at: 9400, action: 'move', target: 'expand:Trails' },
{ at: 10000, action: 'click', target: 'expand:Trails' },
{ at: 10800, action: 'move', target: 'rest' },
{ at: 400, action: 'move', target: 'module:AmbientParticles' },
{ at: 1100, action: 'click', target: 'module:AmbientParticles' },
{ at: 1800, action: 'move', target: 'module:DamageTint' },
{ at: 2500, action: 'click', target: 'module:DamageTint' },
{ at: 3300, action: 'move', target: 'tab:settings' },
{ at: 4000, action: 'click', target: 'tab:settings' },
{ at: 4800, action: 'move', target: 'theme:next' },
{ at: 5600, action: 'theme', target: 'theme:next' },
{ at: 7200, action: 'move', target: 'tab:modules' },
{ at: 7900, action: 'click', target: 'tab:modules' },
{ at: 8800, action: 'move', target: 'rest' },
]
export type GuiState = {
category: CategoryId
enabled: Record<string, boolean>
expanded: string | null
values: Record<string, number | boolean | string>
dragging: boolean
}
/** Modules the demo switches on — also enabled in the static reduced-motion frame. */
const DEMO_TOGGLES = ['AmbientParticles', 'DamageTint']
export type GuiAction =
| { type: 'category'; id: CategoryId }
| { type: 'toggle'; module: string }
| { type: 'expand'; module: string }
| { type: 'set'; key: string; value: number | boolean | string }
| { type: 'dragging'; on: boolean }
| { type: 'reset' }
export type GuiState = { tab: TabId; enabled: Record<string, boolean> }
export type GuiAction = { type: 'tab'; id: TabId } | { type: 'toggle'; module: string } | { type: 'reset' }
export function initialGuiState(finished = false): GuiState {
const enabled: Record<string, boolean> = {}
const values: GuiState['values'] = {}
for (const c of CATEGORIES) {
for (const m of c.modules) {
enabled[m.name] = Boolean(m.on)
for (const s of m.settings ?? []) values[settingKey(m.name, s.name)] = s.value
}
}
if (!finished) return { category: 'combat', enabled, expanded: null, values, dragging: false }
for (const c of CATEGORIES) for (const m of c.modules) enabled[m.name] = Boolean(m.on)
// The resting "after the demo" frame, used under reduced motion.
enabled.Trails = true
values[settingKey('Trails', 'Length')] = 85
return { category: 'visuals', enabled, expanded: 'Trails', values, dragging: false }
if (finished) for (const name of DEMO_TOGGLES) enabled[name] = true
return { tab: 'modules', enabled }
}
export function guiReducer(state: GuiState, action: GuiAction): GuiState {
switch (action.type) {
case 'category':
return { ...state, category: action.id, expanded: null }
case 'tab':
return { ...state, tab: action.id }
case 'toggle':
return { ...state, enabled: { ...state.enabled, [action.module]: !state.enabled[action.module] } }
case 'expand':
return { ...state, expanded: state.expanded === action.module ? null : action.module }
case 'set':
return { ...state, values: { ...state.values, [action.key]: action.value } }
case 'dragging':
return { ...state, dragging: action.on }
case 'reset':
return initialGuiState()
}
@ -84,17 +57,17 @@ type Options = {
onTheme: (id: string) => void
}
/** Layout position of `el` inside `root`, ignoring CSS transforms (the window is tilted in 3D). */
function offsetWithin(el: HTMLElement, root: HTMLElement): { x: number; y: number } {
let x = 0
let y = 0
let node: HTMLElement | null = el
while (node && node !== root) {
x += node.offsetLeft
y += node.offsetTop
node = node.offsetParent as HTMLElement | null
/** Visual position of `el` inside `root` in root-local pixels: rects see CSS transforms (the tab bar's
* `translateX(-50%)`, the screen's entrance animation) that the `offsetLeft` chain silently skips.
* `scale` undoes transforms applied by ancestors of the root (the hero's entrance scale). */
function positionWithin(el: HTMLElement, root: HTMLElement): { x: number; y: number } {
const rr = root.getBoundingClientRect()
const tr = el.getBoundingClientRect()
const scale = root.offsetWidth && rr.width ? rr.width / root.offsetWidth : 1
return {
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> } {
@ -111,29 +84,40 @@ export function useDemo(steps: DemoStep[], opts: Options): { cursor: Cursor; sta
const timers: number[] = []
const later = (ms: number, fn: () => void) => timers.push(window.setTimeout(fn, ms))
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
if (!root) return null
if (target === 'rest') return { x: root.offsetWidth * 0.86, y: root.offsetHeight * 0.9 }
const resolved = target === 'theme:next' ? (pendingTheme ||= `theme:${optsRef.current.nextTheme()}`) : target
const el = root.querySelector<HTMLElement>(`[data-demo="${resolved}"]`)
if (!el) return null
const { x, y } = offsetWithin(el, root)
const fx = fraction ?? 0.5
return { x: x + el.offsetWidth * fx, y: y + el.offsetHeight * 0.5 }
return positionWithin(el, root)
}
const move = (target: string, duration: number, fraction?: number) => {
const p = locate(target, fraction)
if (p) setCursor((c) => ({ ...c, ...p, visible: true, duration }))
const move = (target: string, duration: number) => {
const p = locate(target)
if (!p) return
travelEndsAt = performance.now() + duration
setCursor((c) => ({ ...c, ...p, visible: true, duration }))
}
const click = (fn: () => void) => {
setCursor((c) => ({ ...c, pressed: true }))
later(160, () => {
setCursor((c) => ({ ...c, pressed: false }))
fn()
/** 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 }))
later(160, () => {
setCursor((c) => ({ ...c, pressed: false }))
fn()
})
})
})
}
@ -141,25 +125,11 @@ export function useDemo(steps: DemoStep[], opts: Options): { cursor: Cursor; sta
const [kind, arg] = step.target.split(':')
if (step.action === 'move') return move(step.target, 650)
if (step.action === 'theme') {
return click(() => optsRef.current.onTheme(pendingTheme.replace('theme:', '')))
return click(step.target, () => optsRef.current.onTheme(pendingTheme.replace('theme:', '')))
}
if (step.action === 'drag') {
const [module, setting] = arg.split('.')
const value = Number(step.value ?? 0)
setCursor((c) => ({ ...c, pressed: true }))
dispatch({ type: 'dragging', on: true })
move(step.target, 900, value / 100)
dispatch({ type: 'set', key: settingKey(module, setting), value })
return later(950, () => {
setCursor((c) => ({ ...c, pressed: false }))
dispatch({ type: 'dragging', on: false })
})
}
click(() => {
if (kind === 'cat') dispatch({ type: 'category', id: arg as CategoryId })
if (kind === 'toggle') dispatch({ type: 'toggle', module: arg })
if (kind === 'expand') dispatch({ type: 'expand', module: arg })
if (kind === 'check') dispatch({ type: 'set', key: arg, value: true })
click(step.target, () => {
if (kind === 'tab') dispatch({ type: 'tab', id: arg as TabId })
if (kind === 'module') dispatch({ type: 'toggle', module: arg })
})
}

View file

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

View file

@ -1,46 +1,17 @@
export const clickguiRu = {
windowLabel: 'Интерактивная копия ClickGui LoVisual',
bind: 'Бинд',
bindNone: 'нет',
toggle: 'Включить {{name}}',
settings: 'Настройки {{name}}',
categories: 'Категории',
tabsLabel: 'Разделы ClickGui',
tabs: { modules: 'Modules', settings: 'Settings' },
clock: 'Время',
search: 'Поиск',
categories: 'Категории модулей',
module: '{{name}}: ЛКМ: включить или выключить',
theme: 'Тема {{name}}',
noSettings: 'Без настроек',
desc: {
PvpCooldowns: 'Таймеры перезарядки щита, жемчуга и тотема',
Predictions: 'Траектория полёта жемчуга и стрел',
TargetESP: 'Анимированная метка на текущей цели',
DamageTint: 'Цветная вспышка при получении урона',
NoFriendDamage: 'Не даёт случайно ударить друга',
CombatLog: 'Лог попаданий и урона в чате',
TPSSync: 'Подстраивает таймеры под TPS сервера',
ProjectilePuncher: 'Сбивает летящие в тебя снаряды',
Trails: 'Светящийся шлейф за игроком',
JumpCircles: 'Круги на земле при прыжке',
HitBubbles: 'Пузыри с уроном при ударе',
AmbientParticles: 'Мягкие частицы вокруг тебя',
Wings: 'Анимированные крылья за спиной',
ChinaHat: 'Конусная шляпа с градиентом',
ShaderSky: 'Шейдерное небо с облаками',
MotionBlur: 'Размытие в движении',
PostFX: 'Bloom, виньетка и цветокоррекция',
KillEffect: 'Эффект молнии при убийстве',
ThirdPerson: 'Плавная камера от третьего лица',
AutoTool: 'Сам берёт нужный инструмент',
DeathCoords: 'Координаты смерти в чате',
AutoRespawn: 'Возрождение без экрана смерти',
ClickPearl: 'Жемчуг по кнопке мыши',
LockSlot: 'Защищает слоты от выброса',
UseTracker: 'Счётчик использования предметов',
NoDelay: 'Убирает задержку прыжка',
StreamerMode: 'Прячет личные данные на стриме',
NameProtect: 'Заменяет твой ник на экране',
HitSounds: 'Звук попадания на выбор',
ItemScroller: 'Быстрое перекладывание предметов',
MessageFilter: 'Фильтр спама в чате',
Optimize: 'Лишние FPS без потери красоты',
NoServerPack: 'Отказ от серверного ресурспака',
BetterMinecraft: 'Мелкие улучшения интерфейса',
themes: 'Темы',
settingsNav: 'Разделы настроек',
sidebar: { hud: 'HUD', ui: 'UI', themes: 'Темы', layout: 'Расположение', account: 'Аккаунт', configs: 'Конфиги' },
hints: {
modules: { toggle: 'ЛКМ - включить/выключить модуль', settings: 'ПКМ - настройки', moduleList: 'E - редактировать ModuleList', search: 'Ctrl+F - поиск', close: 'Esc - закрыть ClickGUI', hide: 'Alt+H - скрыть подсказки' },
settings: { pick: 'ЛКМ - выбрать тему', edit: 'Edit - открыть редактор темы', wheel: 'Колесо - листать темы', close: 'Esc - закрыть ClickGUI', hide: 'Alt+H - скрыть подсказки' },
},
} 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 { CategoryId } from './modules'
const PATHS: Record<CategoryId, string> = {
combat: 'M14.5 3.5 20.5 3.5 20.5 9.5 10 20 7 20 4 17 4 14ZM6 13.5 10.5 18M3.5 20.5 6.5 17.5',
visuals: 'M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12ZM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z',
player: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c.8-3.8 3.9-6 7.5-6s6.7 2.2 7.5 6',
misc: 'M5 5h5v5H5ZM14 5h5v5h-5ZM5 14h5v5H5ZM14 14h5v5h-5Z',
}
/** The Lucide icons the mod ships in `assets/lovisual/svg/`, drawn the same way (2px stroke). */
const ICONS = {
combat: (
<>
<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 (
<svg viewBox="0 0 24 24" className="lv-gui-icon" aria-hidden="true">
<path d={PATHS[id]} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function Chevron({ open }: { open: boolean }) {
return (
<svg viewBox="0 0 16 16" className="lv-gui-chevron" data-open={open} aria-hidden="true">
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function LogoMark() {
return (
<svg viewBox="0 0 20 20" className="lv-gui-logo-mark" aria-hidden="true">
<path d="M10 1.5 18.5 10 10 18.5 1.5 10Z" fill="var(--gui-accent)" />
<path d="M10 5.5 14.5 10 10 14.5 5.5 10Z" fill="var(--gui-window-bg)" />
</svg>
)
}
export function SearchGlyph() {
return (
<svg viewBox="0 0 16 16" className="lv-gui-icon" aria-hidden="true">
<circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.6" />
<path d="M10.5 10.5 14 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<svg
viewBox="0 0 24 24"
className={className}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{ICONS[name]}
</svg>
)
}
@ -57,13 +102,7 @@ export function DemoCursor({ cursor }: { cursor: Cursor }) {
>
<span className="lv-gui-cursor-ring" />
<svg viewBox="0 0 20 24" width="20" height="24">
<path
d="M2 1.5v17.2l4.6-4.3 3 7 3.2-1.4-3-6.8h6.4Z"
fill="#fff"
stroke="#0b1012"
strokeWidth="1.3"
strokeLinejoin="round"
/>
<path d="M2 1.5v17.2l4.6-4.3 3 7 3.2-1.4-3-6.8h6.4Z" fill="#fff" stroke="#0b1012" strokeWidth="1.3" strokeLinejoin="round" />
</svg>
</div>
)

View file

@ -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
let a theme switch cross-fade (400ms) instead of snapping. */
/* ClickGui replica — the "screen": world backdrop, HUD watermark, top pill bar, search button,
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-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-to { syntax: "<color>"; inherits: true; initial-value: #142226; }
@property --gui-card-from { syntax: "<color>"; inherits: true; initial-value: #43a7c8; }
@property --gui-card-to { syntax: "<color>"; inherits: true; initial-value: #43a7c8; }
@property --gui-stroke-from { syntax: "<color>"; inherits: true; initial-value: #203030; }
@property --gui-stroke-to { syntax: "<color>"; inherits: true; initial-value: #203030; }
@property --gui-surface { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-surface-hover { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-card-off { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-stroke-soft { 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-accent { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
@property --gui-accent-soft { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
@property --gui-accent-glow { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-stroke-soft { syntax: "<color>"; inherits: true; initial-value: #fff; }
@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 {
position: relative;
width: 100%;
max-width: 660px;
height: 452px;
container-type: inline-size;
font-family: var(--font-sans);
color: var(--gui-text);
font-size: 13px;
line-height: 1.3;
text-align: left;
user-select: none;
transition-property: --gui-window-from, --gui-window-to, --gui-header-from, --gui-header-to, --gui-card-from,
--gui-card-to, --gui-stroke-from, --gui-stroke-to, --gui-surface, --gui-surface-hover, --gui-card-off, --gui-text,
--gui-text-muted, --gui-accent, --gui-accent-soft, --gui-accent-glow, --gui-stroke-soft;
transition-property: --gui-window-from, --gui-window-to, --gui-stroke-from, --gui-stroke-to, --gui-text, --gui-text-muted,
--gui-accent, --gui-accent-soft, --gui-accent-glow, --gui-panel-tint, --gui-panel-stroke, --gui-split, --gui-row-hover,
--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-timing-function: ease;
}
.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 {
position: absolute;
inset: 8% 4% -2%;
border-radius: 40px;
background: radial-gradient(60% 60% at 50% 45%, var(--gui-accent-glow), transparent 70%);
filter: blur(40px);
opacity: 0.9;
pointer-events: none;
}
.lv-gui-window {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
border-radius: 12px;
border: 1px solid transparent;
/* Narrow (< 900px): two panels, a taller screen. Wide: all four panels. */
.lv-gui-stage {
--u: calc(100cqw / 264);
position: relative;
aspect-ratio: 264 / 330;
overflow: hidden;
background:
linear-gradient(var(--gui-window-angle), var(--gui-window-from), var(--gui-window-to)) padding-box,
linear-gradient(135deg, color-mix(in srgb, var(--gui-accent) 55%, transparent), var(--gui-stroke-from) 35%,
var(--gui-stroke-to) 70%, color-mix(in srgb, var(--gui-accent) 30%, transparent)) border-box;
box-shadow: 0 30px 80px -20px rgb(0 0 0 / 0.75), 0 0 0 1px rgb(0 0 0 / 0.35), 0 0 42px -6px var(--gui-accent-glow);
backdrop-filter: blur(14px) saturate(1.2);
border-radius: 14px;
font-family: "SF Pro Display", -apple-system, "Inter", var(--font-sans), sans-serif;
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);
}
@container (min-width: 900px) {
.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;
align-items: center;
gap: 14px;
height: 44px;
padding: 0 14px;
flex: none;
background: linear-gradient(var(--gui-header-angle), var(--gui-header-from), var(--gui-header-to));
border-bottom: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 25%, transparent);
gap: calc(var(--u) * 2.2);
transform: translateX(-50%);
}
.lv-gui-logo { display: flex; align-items: center; gap: 8px; font-family: var(--font-display); font-weight: 700; font-size: 13px; letter-spacing: 0.02em; }
.lv-gui-logo-mark { width: 16px; height: 16px; filter: drop-shadow(0 0 6px var(--gui-accent)); }
.lv-gui-tabs { display: none; gap: 4px; margin-left: 10px; }
.lv-gui-tab { position: relative; padding: 5px 10px; border-radius: 7px; color: var(--gui-text-muted); font-size: 12px; }
.lv-gui-tab[data-active="true"] { color: var(--gui-text); background: color-mix(in srgb, var(--gui-accent) 14%, transparent); }
.lv-gui-tab[data-active="true"]::after {
content: ""; position: absolute; left: 10px; right: 10px; bottom: -8px; height: 2px; border-radius: 2px;
background: var(--gui-accent); box-shadow: 0 0 10px var(--gui-accent);
.lv-gui-clock {
padding: calc(var(--u) * 1.3) calc(var(--u) * 3.4);
border-radius: 999px;
background: rgb(10 10 12 / 0.88);
border: 1px solid rgb(255 255 255 / 0.12);
font-size: calc(var(--u) * 4.4);
font-weight: 600;
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 {
margin-left: auto; display: flex; align-items: center; justify-content: center; width: 30px; height: 26px;
border-radius: 7px; color: var(--gui-text-muted); background: color-mix(in srgb, var(--gui-stroke-soft) 8%, transparent);
position: absolute;
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-cats {
display: flex; flex-direction: column; gap: 3px; width: 46px; flex: none; padding: 10px 6px;
border-right: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 14%, transparent);
background: color-mix(in srgb, black 12%, transparent);
.lv-gui-screen {
position: absolute;
inset: calc(var(--u) * 50) 0 auto;
height: calc(var(--u) * 240);
animation: lv-gui-in 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.lv-gui-cat {
position: relative; display: flex; align-items: center; justify-content: center; gap: 9px; height: 32px;
padding: 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--gui-text-muted) !important;
transition: background-color 200ms, color 200ms;
}
.lv-gui-cat:hover { background: var(--gui-surface-hover); }
.lv-gui-cat[data-active="true"] {
color: var(--gui-text) !important;
background: linear-gradient(90deg, color-mix(in srgb, var(--gui-accent) 24%, transparent), transparent);
}
.lv-gui-cat[data-active="true"]::before {
content: ""; position: absolute; left: -6px; top: 7px; bottom: 7px; width: 3px; border-radius: 0 3px 3px 0;
background: var(--gui-accent); box-shadow: 0 0 12px var(--gui-accent);
}
.lv-gui-cat[data-active="true"] .lv-gui-icon { color: var(--gui-accent); filter: drop-shadow(0 0 5px var(--gui-accent)); }
.lv-gui-cat-name, .lv-gui-cat-count { display: none; }
.lv-gui-cat-count { margin-left: auto; font-family: var(--font-code); font-size: 10px; opacity: 0.6; }
.lv-gui-theme-dots { margin-top: auto; display: grid; grid-template-columns: 1fr; justify-items: center; gap: 7px; padding-top: 10px; }
.lv-gui-theme-dot {
width: 16px; height: 16px; border-radius: 50%; border: 1px solid rgb(255 255 255 / 0.25); background: var(--dot);
box-shadow: inset 0 0 0 3px rgb(0 0 0 / 0.25), 0 0 0 0 var(--dot-accent); transition: box-shadow 250ms, transform 250ms;
}
.lv-gui-theme-dot::after { content: ""; display: block; margin: 4px; height: 6px; border-radius: 50%; background: var(--dot-accent); }
.lv-gui-theme-dot[data-active="true"] { box-shadow: 0 0 0 2px var(--dot-accent), 0 0 12px var(--dot-accent); transform: scale(1.1); }
@keyframes lv-gui-in { from { opacity: 0; transform: translateY(calc(var(--u) * 8)) scale(0.97); } }
.lv-gui-main {
position: relative; flex: 1; min-width: 0; overflow: hidden; padding: 10px;
mask-image: linear-gradient(to bottom, #000 82%, transparent);
.lv-gui-hints {
position: absolute;
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; }
@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-hints li { overflow: hidden; text-overflow: ellipsis; }
.lv-gui-cursor {
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); }
@container (min-width: 440px) {
.lv-gui-cats { width: 128px; }
.lv-gui-cat { justify-content: flex-start; }
.lv-gui-cat-name, .lv-gui-cat-count { display: inline; }
.lv-gui-theme-dots { grid-template-columns: repeat(4, 1fr); }
.lv-gui-grid { grid-template-columns: 1fr 1fr; }
.lv-gui-nosettings { display: inline; margin-left: auto; opacity: 0.6; }
}
@container (min-width: 560px) {
.lv-gui-tabs { display: flex; }
@media (prefers-reduced-motion: reduce) {
.lv-gui-screen, .lv-gui-twinkle, .lv-gui-flake { animation: none; }
.lv-gui-tab-thumb { transition: none; }
}

View file

@ -1,97 +1,67 @@
// In-game catalogue shown by the ClickGui replica. Module, category and setting names are
// the mod's own identifiers and stay untranslated; descriptions live in the clickgui i18n.
// In-game catalogue shown by the ClickGui replica: the mod's `@ModuleInfo` display names grouped
// 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 =
| { 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 CategoryId = 'combat' | 'player' | 'visuals' | 'other'
export type GuiModule = { name: ModuleName; on?: boolean; settings?: Setting[] }
export type CategoryId = 'combat' | 'visuals' | 'player' | 'misc'
export type GuiModule = { name: string; on?: boolean }
export type Category = { id: CategoryId; name: string; modules: GuiModule[] }
export type ModuleName =
| 'PvpCooldowns' | 'Predictions' | 'TargetESP' | 'DamageTint' | 'NoFriendDamage' | 'CombatLog' | 'TPSSync'
| 'ProjectilePuncher' | 'Trails' | 'JumpCircles' | 'HitBubbles' | 'AmbientParticles' | 'Wings' | 'ChinaHat'
| 'ShaderSky' | 'MotionBlur' | 'PostFX' | 'KillEffect' | 'ThirdPerson' | 'AutoTool' | 'DeathCoords'
| 'AutoRespawn' | 'ClickPearl' | 'LockSlot' | 'UseTracker' | 'NoDelay' | 'StreamerMode' | 'NameProtect'
| 'HitSounds' | 'ItemScroller' | 'MessageFilter' | 'Optimize' | 'NoServerPack' | 'BetterMinecraft'
const slider = (name: string, value: number, max = 100, unit?: string): Setting => ({
kind: 'slider', name, min: 0, max, value, unit,
})
const check = (name: string, value: boolean): Setting => ({ kind: 'check', name, value })
const color = (name: string, value: string): Setting => ({ kind: 'color', name, value })
const list = (names: string[], on: string[] = []): GuiModule[] =>
[...names]
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase(), 'en'))
.map((name) => (on.includes(name) ? { name, on: true } : { name }))
export const CATEGORIES: Category[] = [
{
id: 'combat',
name: 'Combat',
modules: [
{ name: 'TargetESP', on: true, settings: [slider('Size', 60), check('Pulse', true), color('Color', 'var(--gui-accent)')] },
{ name: 'PvpCooldowns', on: true },
{ name: 'Predictions', settings: [slider('Ticks', 6, 20), check('Show path', true)] },
{ name: 'DamageTint', on: true, settings: [slider('Strength', 40), color('Tint', '#ff5a6e')] },
{ name: 'NoFriendDamage' },
{ name: 'TPSSync' },
{ name: 'ProjectilePuncher' },
{ name: 'CombatLog' },
],
},
{
id: 'visuals',
name: 'Visuals',
modules: [
{ name: 'Trails', settings: [slider('Length', 45), check('Rainbow', false), color('Color', 'var(--gui-accent)')] },
{ name: 'JumpCircles', on: true, settings: [slider('Radius', 70), color('Color', 'var(--gui-accent)')] },
{ name: 'HitBubbles', on: true },
{ name: 'AmbientParticles', on: true, settings: [slider('Count', 120, 150), check('Glow', true)] },
{ name: 'Wings' },
{ name: 'ChinaHat', settings: [check('Gradient', true), color('Color', '#f2c14e')] },
{ name: 'ShaderSky', on: true },
{ name: 'MotionBlur', settings: [slider('Strength', 30)] },
{ name: 'PostFX', on: true, settings: [check('Bloom', true), slider('Vignette', 25)] },
{ name: 'KillEffect' },
],
modules: list(
['CombatLog', 'DamageTint', 'No Friend Damage', 'Predictions', 'ProjectilePuncher', 'RubberHand', 'SpearAssist', 'TargetESP', 'TPSSync'],
['No Friend Damage', 'Predictions', 'TargetESP'],
),
},
{
id: 'player',
name: 'Player',
modules: [
{ name: 'ThirdPerson', on: true, settings: [slider('Distance', 4, 12, 'm')] },
{ name: 'AutoTool', on: true },
{ name: 'DeathCoords', on: true },
{ name: 'AutoRespawn' },
{ name: 'ClickPearl' },
{ name: 'LockSlot' },
{ name: 'UseTracker' },
{ name: 'NoDelay' },
],
modules: list(
['Auto Accept', 'Auto Leave', 'Auto Respawn', 'AutoTool', 'ClickPearl', 'Death Coords', 'FakeLag', 'Lock Slot', 'NoDelay',
'NoInteract', 'Tape Mouse', 'Third Person', 'Use Tracker'],
['Auto Accept'],
),
},
{
id: 'misc',
name: 'Misc',
modules: [
{ name: 'StreamerMode', settings: [check('Hide coords', true), check('Hide IP', true)] },
{ name: 'NameProtect', on: true },
{ name: 'HitSounds', on: true, settings: [slider('Volume', 65)] },
{ name: 'ItemScroller' },
{ name: 'MessageFilter' },
{ name: 'Optimize', on: true },
{ name: 'NoServerPack' },
{ name: 'BetterMinecraft' },
],
id: 'visuals',
name: 'Visuals',
modules: list(
['AlwaysVisibleHand', 'AmbientParticles', 'Animations', 'AspectRatio', 'AtmoDawnFog', 'BackSword', 'BlockESP', 'BlockHighlight',
'CameraClip', 'Chams', 'ChinaHat', 'Crosshair', 'CrystalHoles', 'DropESP', 'EndCrystal', 'ESP', 'FovControl', 'FPS Optimizer',
'Freecam', 'FullBright', 'Gps', 'Hitboxes', 'HitBubbles', 'HitEffect', 'InvisESP', 'Item Holder', 'Item Physic', 'JumpCircles',
'KillEffect', 'KineticLyrics', 'Light', 'Lightning Target ESP', 'MotionBlur', 'NameTags', 'NoRender', 'PostFX', 'Rain',
'ReimaginedVisual', 'ShaderSky', 'Shulker Preview', 'SoundESP', 'TazikHat', 'TotemFX', 'Trails', 'Trinket', 'Trollface Mask',
'ViewModel', 'Wet World', 'Wings', 'WorldParticles', 'WorldTweaks', 'Zoom'],
['Chams'],
),
},
{
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. */
export const TABS = ['Modules', 'Themes', 'Configs', 'HUD'] as const
/** The pill bar's two segments — in-game labels. */
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). */
export const QUICK_THEMES = ['classic', 'nitro', 'sunset', 'aurora'] as const
/** Presets the scripted demo cycles through (all sit in the first rows of the theme grid). */
export const QUICK_THEMES = ['amber', 'purple', 'blue', 'classic'] as const
export const settingKey = (module: string, setting: string) => `${module}.${setting}`
export const findModule = (name: string) => CATEGORIES.flatMap((c) => c.modules).find((m) => m.name === name)
/** 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 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 userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { PRESETS } from '../../themes/presets.generated'
import { argbToCss } from '../../themes/types'
import { ActiveThemeProvider } from '../../themes/useActiveTheme'
import { ClickGuiWindow } from '../ClickGuiWindow'
import { DEMO, guiReducer, initialGuiState, LOOP_MS } from '../demo'
const renderGui = () => render(<ActiveThemeProvider applyToPage={false}><ClickGuiWindow /></ActiveThemeProvider>)
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()
expect(screen.getByRole('button', { name: 'Visuals' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('switch', { name: 'Включить Trails' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('slider', { name: 'Length' })).toHaveValue('85')
const panels = within(screen.getByRole('list', { name: 'Категории модулей' })).getAllByRole('listitem', { name: /./ })
expect(panels).toHaveLength(4)
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()
renderGui()
await user.click(screen.getByRole('button', { name: 'Misc' }))
const toggle = screen.getByRole('switch', { name: 'Включить StreamerMode' })
expect(toggle).toHaveAttribute('aria-checked', 'false')
await user.click(toggle)
expect(toggle).toHaveAttribute('aria-checked', 'true')
await user.click(screen.getByRole('button', { name: 'Настройки StreamerMode' }))
const card = toggle.closest('article') as HTMLElement
expect(within(card).getByRole('checkbox', { name: /Hide IP/ })).toHaveAttribute('aria-checked', 'true')
const predictions = row('Predictions')
expect(predictions).toHaveAttribute('aria-pressed', 'true')
expect(predictions.querySelector('.lv-gui-row-check')).not.toBeNull()
const autoLeave = row('Auto Leave')
expect(autoLeave.querySelector('.lv-gui-row-check')).toBeNull()
await user.click(autoLeave)
expect(autoLeave).toHaveAttribute('aria-pressed', '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.stubGlobal('matchMedia', (q: string) => ({ matches: !q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
renderGui()
expect(screen.getByRole('button', { name: 'Combat' })).toHaveAttribute('aria-pressed', 'true')
act(() => vi.advanceTimersByTime(3200))
expect(screen.getByRole('switch', { name: 'Включить Trails' })).toHaveAttribute('aria-checked', 'true')
act(() => vi.advanceTimersByTime(LOOP_MS))
expect(screen.getByRole('button', { name: 'Combat' })).toHaveAttribute('aria-pressed', 'true')
expect(row('AmbientParticles')).toHaveAttribute('aria-pressed', 'false')
act(() => vi.advanceTimersByTime(2000))
expect(row('AmbientParticles')).toHaveAttribute('aria-pressed', 'true')
act(() => vi.advanceTimersByTime(3000))
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()
})
test('demo timeline fits in the loop and the reducer resets', () => {
expect(Math.max(...DEMO.map((s) => s.at))).toBeLessThan(LOOP_MS)
const toggled = guiReducer(initialGuiState(), { type: 'toggle', module: 'Trails' })
expect(toggled.enabled.Trails).toBe(true)
expect(guiReducer(toggled, { type: 'reset' }).enabled.Trails).toBe(false)
const toggled = guiReducer(initialGuiState(), { type: 'toggle', module: 'Chams' })
expect(toggled.enabled.Chams).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 { 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>
@ -46,6 +46,7 @@ export function themeToCssVars(entry: ThemeEntry): CssVars {
'--gui-stroke-soft': argbToCss(t.strokeSoft),
'--gui-accent-glow': withAlpha(t.accent, 0.45),
}
Object.assign(vars, derivedVars(entry))
for (const [slot, key, flat] of GRADIENT_SLOTS) {
const spec = entry[key]
vars[`--gui-${slot}-gradient`] = gradientToCss(spec, t[flat])
@ -55,3 +56,41 @@ export function themeToCssVars(entry: ThemeEntry): CssVars {
}
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),
}
}