feat(frontend): landing redesign, mock/fake-data cleanup, Safari fix
Landing: - Rewrote all landing copy (ru/en): removed the tracked-caps eyebrow chrome pattern everywhere, active-voice player-facing text - ThemeStrip: full-bleed live-recolored ClickGui window instead of a boxed swatch panel; HudPlayground: full-size real HUD screen - ShowcaseTeaser: dropped the fake "example" fallback cards, shows an honest empty state when there aren't 3 real configs yet - Fixed VoxelScene parallax leaking outside the hero into the HUD section Data accuracy: the module list/counts advertised InvisESP, BlockESP and FakeLag — all three were removed from the mod earlier this session as cheat modules. Corrected the catalogue and the 88/52 module-count claims to the real 86/50. Design QOL pass: removed the same eyebrow-label chrome from the site footer; replaced plausible-looking fake share codes (howItWorks demo slots, the chat command typing demo) with an explicit placeholder string so they read as illustration, not fabricated real data. Perf: promoted .lv-lower-bg onto its own compositor layer (will-change/translateZ) — a full-height, multi-gradient, masked, continuously-animated background layer was repainting on every scroll tick in Safari. Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
This commit is contained in:
parent
3f679b83f6
commit
9d423456af
20 changed files with 830 additions and 538 deletions
|
|
@ -5,164 +5,41 @@ import { themeToCssVars } from '@features/clickgui/themeVars'
|
|||
import { parseArgb } from '@features/themes/types'
|
||||
import { useActiveTheme } from '@features/themes/useActiveTheme'
|
||||
import { VoxelScene } from '../../effects/VoxelScene'
|
||||
import { bounds, clamp, fitToField, type Layout, startLayout, type WidgetId } from './hud/layout'
|
||||
import { WidgetContent } from './hud/WidgetContent'
|
||||
import '@features/landing/styles/landing.css'
|
||||
import '@features/landing/styles/sections.css'
|
||||
|
||||
const STEP = 8
|
||||
/** Fallback playfield size, used before the box is measured (and under jsdom). */
|
||||
const FIELD = { w: 680, h: 380 }
|
||||
const WIDGET = { w: 150, h: 56 }
|
||||
|
||||
type Pos = { x: number; y: number }
|
||||
/** Real draggable HUD elements from the mod: features/gui/hud/draggable/impl. */
|
||||
type WidgetId = 'fps' | 'coords' | 'modules' | 'keys' | 'ping'
|
||||
|
||||
const START: Record<WidgetId, Pos> = {
|
||||
fps: { x: 20, y: 20 },
|
||||
coords: { x: 20, y: 310 },
|
||||
modules: { x: FIELD.w - 190, y: 20 },
|
||||
ping: { x: FIELD.w - 170, y: 150 },
|
||||
keys: { x: FIELD.w - 140, y: FIELD.h - 120 },
|
||||
}
|
||||
|
||||
const clamp = (v: number, max: number) => Math.max(0, Math.min(v, max))
|
||||
|
||||
/** Pull every widget back inside the measured field (mount, resize, reset). */
|
||||
function fitToField(
|
||||
state: Record<WidgetId, Pos>,
|
||||
field: HTMLDivElement | null,
|
||||
widgets: Partial<Record<WidgetId, HTMLElement | null>>,
|
||||
): Record<WidgetId, Pos> {
|
||||
const w = field?.clientWidth || FIELD.w
|
||||
const h = field?.clientHeight || FIELD.h
|
||||
const next = { ...state }
|
||||
let changed = false
|
||||
for (const id of Object.keys(next) as WidgetId[]) {
|
||||
const el = widgets[id]
|
||||
const maxX = Math.max(0, w - (el?.offsetWidth || WIDGET.w))
|
||||
const maxY = Math.max(0, h - (el?.offsetHeight || WIDGET.h))
|
||||
const x = clamp(next[id].x, maxX)
|
||||
const y = clamp(next[id].y, maxY)
|
||||
if (x !== next[id].x || y !== next[id].y) {
|
||||
next[id] = { x, y }
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : state
|
||||
}
|
||||
|
||||
|
||||
/** Enabled modules as they would appear in the mod's ModuleList panel. */
|
||||
const MODULES_ON = ['TargetESP', 'PvpCooldowns', 'HitBubbles', 'ShaderSky']
|
||||
|
||||
/** Widgets read the accent through --gui-accent so it cross-fades with the active theme. */
|
||||
const ACCENT = 'var(--gui-accent)'
|
||||
|
||||
function WidgetContent({ id, accent, unitMs }: { id: WidgetId; accent: string; unitMs: string }) {
|
||||
switch (id) {
|
||||
case 'fps':
|
||||
return (
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="font-code text-2xl leading-none font-bold" style={{ color: accent }}>
|
||||
240
|
||||
</span>
|
||||
<span className="text-[0.6rem] tracking-[0.15em] text-frost uppercase">FPS</span>
|
||||
</div>
|
||||
)
|
||||
case 'coords':
|
||||
return (
|
||||
<div className="font-code text-sm leading-none text-snow">
|
||||
<span style={{ color: accent }}>X</span> 128 <span style={{ color: accent }}>Y</span> 74 {' '}
|
||||
<span style={{ color: accent }}>Z</span> -204
|
||||
</div>
|
||||
)
|
||||
case 'modules':
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{MODULES_ON.map((m) => (
|
||||
<span
|
||||
key={m}
|
||||
className="rounded-[5px] border-r-2 bg-black/25 px-2 py-[3px] font-code text-[0.7rem] leading-none text-snow"
|
||||
style={{ borderRightColor: accent }}
|
||||
>
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
case 'keys': {
|
||||
const cap = (k: string, pressed = false) => (
|
||||
<span
|
||||
key={k}
|
||||
className="grid size-7 place-items-center rounded-[6px] border bg-white/5 font-code text-xs leading-none"
|
||||
style={{
|
||||
borderColor: pressed ? accent : 'rgb(255 255 255 / 0.12)',
|
||||
color: pressed ? accent : undefined,
|
||||
background: pressed ? `color-mix(in srgb, ${accent} 18%, transparent)` : undefined,
|
||||
}}
|
||||
>
|
||||
{k}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{cap('W', true)}
|
||||
<div className="flex gap-1">
|
||||
{cap('A')}
|
||||
{cap('S', true)}
|
||||
{cap('D')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'ping':
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-code text-sm leading-none text-snow">
|
||||
32 <span className="text-frost">{unitMs}</span>
|
||||
</span>
|
||||
<span aria-hidden="true" className="flex items-end gap-[3px]">
|
||||
{[6, 10, 14, 18].map((h, i) => (
|
||||
<span
|
||||
key={h}
|
||||
className="w-[4px] rounded-sm"
|
||||
style={{ height: h, background: i < 2 ? accent : 'rgb(255 255 255 / 0.18)' }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
type Drag = { id: WidgetId; px: number; py: number; ox: number; oy: number }
|
||||
|
||||
/**
|
||||
* A full game screen with the mod's real HUD widgets on it. Holding a widget switches the
|
||||
* screen into "edit mode" (grid overlay, lifted widget), the same feedback the in-game editor gives.
|
||||
*/
|
||||
export function HudPlayground() {
|
||||
const { t } = useTranslation('landing')
|
||||
const { theme } = useActiveTheme()
|
||||
const { r, g, b } = parseArgb(theme.theme.accent)
|
||||
const fieldRef = useRef<HTMLDivElement>(null)
|
||||
const widgetRefs = useRef<Partial<Record<WidgetId, HTMLElement | null>>>({})
|
||||
const drag = useRef<{ id: WidgetId; px: number; py: number; ox: number; oy: number } | null>(null)
|
||||
const [pos, setPos] = useState<Record<WidgetId, Pos>>(START)
|
||||
|
||||
const bounds = (el: HTMLElement) => {
|
||||
const field = fieldRef.current
|
||||
const w = field?.clientWidth || FIELD.w
|
||||
const h = field?.clientHeight || FIELD.h
|
||||
return { maxX: Math.max(0, w - (el.offsetWidth || WIDGET.w)), maxY: Math.max(0, h - (el.offsetHeight || WIDGET.h)) }
|
||||
}
|
||||
const drag = useRef<Drag | null>(null)
|
||||
const [pos, setPos] = useState<Layout>(() => startLayout(null, {}))
|
||||
const [held, setHeld] = useState<WidgetId | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const fit = () => setPos((prev) => fitToField(prev, fieldRef.current, widgetRefs.current))
|
||||
fit()
|
||||
setPos(startLayout(fieldRef.current, widgetRefs.current))
|
||||
const field = fieldRef.current
|
||||
if (!field || typeof ResizeObserver === 'undefined') return
|
||||
const ro = new ResizeObserver(fit)
|
||||
const ro = new ResizeObserver(() => setPos((prev) => fitToField(prev, fieldRef.current, widgetRefs.current)))
|
||||
ro.observe(field)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const nudge = (id: WidgetId, dx: number, dy: number, el: HTMLElement) => {
|
||||
const { maxX, maxY } = bounds(el)
|
||||
setPos((prev) => ({ ...prev, [id]: { x: clamp(prev[id].x + dx, maxX), y: clamp(prev[id].y + dy, maxY) } }))
|
||||
const move = (id: WidgetId, x: number, y: number, el: HTMLElement) => {
|
||||
const { maxX, maxY } = bounds(fieldRef.current, el)
|
||||
setPos((prev) => ({ ...prev, [id]: { x: clamp(x, maxX), y: clamp(y, maxY) } }))
|
||||
}
|
||||
|
||||
const onKeyDown = (id: WidgetId) => (e: React.KeyboardEvent<HTMLElement>) => {
|
||||
|
|
@ -172,23 +49,23 @@ export function HudPlayground() {
|
|||
const d = deltas[e.key]
|
||||
if (!d) return
|
||||
e.preventDefault()
|
||||
nudge(id, d[0], d[1], e.currentTarget)
|
||||
move(id, pos[id].x + d[0], pos[id].y + d[1], e.currentTarget)
|
||||
}
|
||||
|
||||
const onPointerDown = (id: WidgetId) => (e: React.PointerEvent<HTMLElement>) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
drag.current = { id, px: e.clientX, py: e.clientY, ox: pos[id].x, oy: pos[id].y }
|
||||
setHeld(id)
|
||||
}
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent<HTMLElement>) => {
|
||||
const d = drag.current
|
||||
if (!d) return
|
||||
const { maxX, maxY } = bounds(e.currentTarget)
|
||||
setPos((prev) => ({ ...prev, [d.id]: { x: clamp(d.ox + e.clientX - d.px, maxX), y: clamp(d.oy + e.clientY - d.py, maxY) } }))
|
||||
if (d) move(d.id, d.ox + e.clientX - d.px, d.oy + e.clientY - d.py, e.currentTarget)
|
||||
}
|
||||
|
||||
const onPointerUp = () => {
|
||||
drag.current = null
|
||||
setHeld(null)
|
||||
}
|
||||
|
||||
const widgets: { id: WidgetId; label: string }[] = [
|
||||
|
|
@ -200,49 +77,52 @@ export function HudPlayground() {
|
|||
]
|
||||
|
||||
return (
|
||||
<section aria-labelledby="hud-title" className="grid gap-10 py-20 sm:py-28 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:gap-14">
|
||||
<div className="flex min-w-0 flex-col items-start gap-4">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('hud.eyebrow')}</span>
|
||||
<h2 id="hud-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('hud.title')}</h2>
|
||||
<p className="max-w-[42ch] text-lg text-frost">{t('hud.lead')}</p>
|
||||
<Button variant="quiet" className="mt-2" onClick={() => setPos(fitToField(START, fieldRef.current, widgetRefs.current))}>
|
||||
{t('hud.reset')}
|
||||
</Button>
|
||||
<section aria-labelledby="hud-title" className="flex flex-col gap-8 py-20 sm:py-28">
|
||||
<div className="grid items-end gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-14">
|
||||
<h2 id="hud-title" className="text-[2.2rem] leading-[1.05] tracking-[-0.02em] sm:text-[3.2rem]">{t('hud.title')}</h2>
|
||||
<p className="max-w-[46ch] text-lg text-frost">{t('hud.lead')}</p>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
ref={fieldRef}
|
||||
style={themeToCssVars(theme)}
|
||||
className="lv-hud-field relative isolate overflow-hidden rounded-panel border border-white/10"
|
||||
>
|
||||
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} />
|
||||
<div aria-hidden="true" className="absolute inset-0 bg-black/25" />
|
||||
{widgets.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
ref={(el) => {
|
||||
widgetRefs.current[w.id] = el
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={w.label}
|
||||
aria-label={t('hud.dragAria', { name: w.label })}
|
||||
onKeyDown={onKeyDown(w.id)}
|
||||
onPointerDown={onPointerDown(w.id)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
style={{ left: pos[w.id].x, top: pos[w.id].y }}
|
||||
className="lv-hud-widget absolute touch-none select-none"
|
||||
>
|
||||
<WidgetContent id={w.id} accent={ACCENT} unitMs={t('hud.unitMs')} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 flex items-center gap-2 text-sm text-frost">
|
||||
<div
|
||||
ref={fieldRef}
|
||||
style={themeToCssVars(theme)}
|
||||
className="lv-hud-field lv-hud-screen relative isolate overflow-hidden"
|
||||
>
|
||||
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} parallax={false} />
|
||||
<div aria-hidden="true" className="absolute inset-0 bg-black/20" />
|
||||
<div aria-hidden="true" className="lv-hud-grid" data-on={held !== null} />
|
||||
{widgets.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
ref={(el) => {
|
||||
widgetRefs.current[w.id] = el
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={w.label}
|
||||
aria-label={t('hud.dragAria', { name: w.label })}
|
||||
data-held={held === w.id}
|
||||
onKeyDown={onKeyDown(w.id)}
|
||||
onPointerDown={onPointerDown(w.id)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
style={{ left: pos[w.id].x, top: pos[w.id].y }}
|
||||
className="lv-hud-widget absolute touch-none select-none"
|
||||
>
|
||||
<WidgetContent id={w.id} unitMs={t('hud.unitMs')} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="flex items-center gap-2 text-sm text-frost">
|
||||
<span className="lv-live-dot" aria-hidden="true" />
|
||||
{t('hud.caption')}
|
||||
</p>
|
||||
<Button variant="quiet" onClick={() => setPos(startLayout(fieldRef.current, widgetRefs.current))}>
|
||||
{t('hud.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue