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:
parent
7f4b532f99
commit
ff2edc8eb6
116 changed files with 3534 additions and 1020 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
128
frontend/src/features/clickgui/parts/Backdrop.tsx
Normal file
128
frontend/src/features/clickgui/parts/Backdrop.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
56
frontend/src/features/clickgui/screens/ModulesScreen.tsx
Normal file
56
frontend/src/features/clickgui/screens/ModulesScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
frontend/src/features/clickgui/screens/SettingsScreen.tsx
Normal file
103
frontend/src/features/clickgui/screens/SettingsScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
248
frontend/src/features/clickgui/screens/screens.css
Normal file
248
frontend/src/features/clickgui/screens/screens.css
Normal 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); }
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue