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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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