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

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

View file

@ -0,0 +1,70 @@
import { useId, useState } from 'react'
import { parseArgb } from '../types'
type Props = { label: string; value: string; onChange: (hex: string) => void }
const pad2 = (n: number) => n.toString(16).padStart(2, '0')
const toRgb6 = (hex: string) => {
const { r, g, b } = parseArgb(hex)
return `#${pad2(r)}${pad2(g)}${pad2(b)}`.toUpperCase()
}
/** Rebuild `#AARRGGBB` from a `#RRGGBB` picker value, keeping the current alpha. */
const withAlpha = (hex: string, rgb6: string) => `#${pad2(parseArgb(hex).a)}${rgb6.slice(1)}`.toUpperCase()
/** Rebuild `#AARRGGBB` from an alpha (0–255), keeping the current rgb. */
const withRgb = (hex: string, a: number) => `#${pad2(a)}${toRgb6(hex).slice(1)}`.toUpperCase()
/** One editable palette slot: native color picker + alpha slider + hex field. */
export function ColorField({ label, value, onChange }: Props) {
const id = useId()
// Local mirror so a partially-typed hex isn't clobbered mid-edit; reset when the value changes
// for another reason (React's "adjust state during render" pattern).
const [editing, setEditing] = useState<{ value: string; text: string }>({ value, text: value })
const text = editing.value === value ? editing.text : value
const commitText = () => {
const t = text.trim()
if (/^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) onChange(`#${t.replace(/^#/, '').toUpperCase()}`)
else setEditing({ value, text: value })
}
return (
<label htmlFor={id} className="lv-color-field flex items-center gap-2">
<span
className="lv-alpha-check size-8 shrink-0 rounded-md border border-white/15"
style={{ backgroundImage: `linear-gradient(45deg, ${value}, ${value})` }}
aria-hidden="true"
/>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-xs text-frost">{label}</span>
<span className="flex min-w-0 items-center gap-2">
<input
type="color"
aria-label={label}
value={toRgb6(value)}
onChange={(e) => onChange(withAlpha(value, e.target.value))}
className="size-7 shrink-0 cursor-pointer rounded border-0 bg-transparent p-0"
/>
<input
type="range"
min={0}
max={255}
aria-label={`${label} alpha`}
value={parseArgb(value).a}
onChange={(e) => onChange(withRgb(value, Number(e.target.value)))}
className="lv-alpha-slider h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-white/20"
/>
<input
id={id}
type="text"
spellCheck={false}
value={text}
onChange={(e) => setEditing({ value, text: e.target.value })}
onBlur={commitText}
onKeyDown={(e) => e.key === 'Enter' && commitText()}
className="lv-hex-input w-[6.25rem] shrink-0 rounded-md border border-white/15 bg-black/40 px-2 py-1 font-code text-xs text-snow"
/>
</span>
</span>
</label>
)
}

View file

@ -0,0 +1,28 @@
import { useMemo } from 'react'
import { ClickGuiWindow } from '../../clickgui/ClickGuiWindow'
import { themeToCssVars } from '../../clickgui/themeVars'
import { VoxelScene } from '../../landing/effects/VoxelScene'
import { parseArgb, type ThemeEntry } from '../types'
/** The live left pane: a themed ClickGui window plus two HUD tiles over the voxel scene. */
export function EditorPreview({ draft }: { draft: ThemeEntry }) {
const vars = useMemo(() => themeToCssVars(draft), [draft])
const { r, g, b } = parseArgb(draft.theme.accent)
return (
<div className="lv-editor-preview relative min-h-[420px] overflow-hidden rounded-panel border border-white/10">
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} className="absolute inset-0" />
<div className="relative flex h-full flex-col items-center justify-center gap-6 p-6" style={vars}>
<div className="flex w-full justify-center gap-4">
<span className="lv-hud-tile" style={{ background: 'var(--gui-card-gradient)' }}>
<b>FPS</b> 240
</span>
<span className="lv-hud-tile" style={{ background: 'var(--gui-surface-gradient)' }}>
<b>XYZ</b> 12 · 64 · -8
</span>
</div>
<ClickGuiWindow theme={draft} demo={false} className="w-full max-w-[520px]" />
</div>
</div>
)
}

View file

@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next'
import type { GradientSpec } from '../types'
import { ColorField } from './ColorField'
type Props = { title: string; value: GradientSpec; onChange: (patch: Partial<GradientSpec>) => void }
/** A gradient slot: enable toggle, start/end colors and a 0–360° angle control. */
export function GradientField({ title, value, onChange }: Props) {
const { t } = useTranslation('themes')
return (
<div className="lv-gradient-field flex flex-col gap-3 rounded-panel border border-white/10 bg-black/25 p-3">
<label className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-snow">{title}</span>
<span className="flex items-center gap-2 text-xs text-frost">
{t('editor.enabled')}
<input
type="checkbox"
aria-label={`${title} · ${t('editor.enabled')}`}
checked={value.enabled}
onChange={(e) => onChange({ enabled: e.target.checked })}
className="size-4 accent-[var(--color-ice)]"
/>
</span>
</label>
<ColorField label={t('editor.start')} value={value.start} onChange={(start) => onChange({ start })} />
{value.enabled && (
<>
<ColorField label={t('editor.end')} value={value.end} onChange={(end) => onChange({ end })} />
<label className="flex items-center gap-2 text-xs text-frost">
<span className="w-12 shrink-0">{t('editor.angle')}</span>
<input
type="range"
min={0}
max={359}
value={Math.round(value.angle)}
onChange={(e) => onChange({ angle: Number(e.target.value) })}
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-white/20"
/>
<span className="w-10 shrink-0 text-right font-code text-snow">{Math.round(value.angle)}°</span>
</label>
</>
)}
</div>
)
}

View file

@ -0,0 +1,155 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../../../shared/ui/Button'
import { PRESETS } from '../presets.generated'
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
import { EditorPreview } from './EditorPreview'
import { ColorField } from './ColorField'
import { COLOR_SLOTS, GRADIENT_SLOTS, fromProfileJson, toProfileJson, toShareHash } from './codec'
import { GradientField } from './GradientField'
import { useThemeHistory } from './useThemeHistory'
/** Pack the draft name into a slug id, mirroring the codec's `slug`. */
const nameToId = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'custom'
export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
const { t } = useTranslation('themes')
const { draft, set, reset, undo, redo, canUndo, canRedo } = useThemeHistory(initial)
const [notice, setNotice] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
const flash = useCallback((text: string) => {
setNotice(text)
clearTimeout(timer.current)
timer.current = setTimeout(() => setNotice(null), 2000)
}, [])
useEffect(() => () => clearTimeout(timer.current), [])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z') return
e.preventDefault()
if (e.shiftKey) redo()
else undo()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [undo, redo])
const setColor = (slot: keyof ThemeColors, hex: string) => set({ ...draft, theme: { ...draft.theme, [slot]: hex } })
const setGradient = (key: keyof ThemeEntry, patch: Partial<GradientSpec>) =>
set({ ...draft, [key]: { ...(draft[key] as GradientSpec), ...patch } })
const copy = async () => {
await navigator.clipboard?.writeText(toProfileJson(draft))
flash(t('editor.copied'))
}
const download = () => {
const url = URL.createObjectURL(new Blob([toProfileJson(draft)], { type: 'application/json' }))
const a = document.createElement('a')
a.href = url
a.download = `${draft.id}.json`
a.click()
URL.revokeObjectURL(url)
}
const share = async () => {
await navigator.clipboard?.writeText(`${location.origin}${location.pathname}#t=${toShareHash(draft)}`)
flash(t('editor.shared'))
}
const onFile = (file?: File) => {
if (!file) return
file.text().then((text) => {
const result = fromProfileJson(text)
if ('error' in result) flash(t('editor.importError'))
else reset(result)
})
}
return (
<div className="grid gap-8 lg:grid-cols-2">
<EditorPreview draft={draft} />
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-2">
<Button variant="quiet" onClick={undo} disabled={!canUndo}>
{t('editor.undo')}
</Button>
<Button variant="quiet" onClick={redo} disabled={!canRedo}>
{t('editor.redo')}
</Button>
<Button variant="quiet" onClick={() => reset(initial)}>
{t('editor.reset')}
</Button>
{notice && <span className="ml-auto font-code text-xs text-ice">{notice}</span>}
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-frost">{t('editor.nameLabel')}</span>
<input
value={draft.name}
onChange={(e) => set({ ...draft, name: e.target.value, id: nameToId(e.target.value) })}
className="rounded-md border border-white/15 bg-black/40 px-3 py-2 text-snow"
/>
</label>
<section className="flex flex-col gap-2">
<h3 className="text-sm font-semibold uppercase text-frost">{t('editor.presets')}</h3>
<div className="flex flex-wrap gap-2">
{PRESETS.map((p) => (
<button
key={p.id}
type="button"
onClick={() => reset(p)}
className={`lv-preset-chip ${p.id === draft.id ? 'is-active' : ''}`}
>
{p.name}
</button>
))}
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-semibold uppercase text-frost">{t('editor.colors')}</h3>
<div className="grid gap-3 sm:grid-cols-2">
{COLOR_SLOTS.map((slot) => (
<ColorField key={slot} label={t(`colors.${slot}`)} value={draft.theme[slot]} onChange={(hex) => setColor(slot, hex)} />
))}
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-semibold uppercase text-frost">{t('editor.gradients')}</h3>
<div className="grid gap-3">
{GRADIENT_SLOTS.map((g) => (
<GradientField
key={g.prefix}
title={t(`editor.gradient.${g.prefix}`)}
value={draft[g.key] as GradientSpec}
onChange={(patch) => setGradient(g.key, patch)}
/>
))}
</div>
</section>
<div className="flex flex-wrap gap-2">
<Button onClick={copy}>{t('editor.copy')}</Button>
<Button variant="quiet" onClick={download}>
{t('editor.download')}
</Button>
<Button variant="quiet" onClick={() => fileRef.current?.click()}>
{t('editor.import')}
</Button>
<Button variant="quiet" onClick={share}>
{t('editor.share')}
</Button>
<input ref={fileRef} type="file" accept="application/json,.json" className="hidden" onChange={(e) => onFile(e.target.files?.[0])} />
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,138 @@
import { PRESETS } from '../presets.generated'
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
// The mod serialises a theme as a FLAT map: `name`, the 12 color slots as
// `#AARRGGBB`, then for each of the 5 gradient slots `Enabled/Start/End/Angle`.
// See mod ThemesProfileCodec.toProfileValues — keep these lists in lockstep.
const COLOR_KEYS: (keyof ThemeColors)[] = [
'windowBg',
'windowHeader',
'windowStroke',
'surface',
'surfaceHover',
'cardEnabled',
'cardDisabled',
'textPrimary',
'textMuted',
'accent',
'accentSoft',
'strokeSoft',
]
/** The 12 editable palette slots, in the mod's profile order. */
export const COLOR_SLOTS = COLOR_KEYS
/** The 5 gradient slots: profile prefix + the matching `ThemeEntry` field. */
export const GRADIENT_SLOTS = [
{ prefix: 'window', key: 'windowGradient' },
{ prefix: 'header', key: 'headerGradient' },
{ prefix: 'surface', key: 'surfaceGradient' },
{ prefix: 'card', key: 'cardGradient' },
{ prefix: 'stroke', key: 'strokeGradient' },
] as const satisfies { prefix: string; key: keyof ThemeEntry }[]
const GRADIENT_KEYS: [string, keyof ThemeEntry][] = [
['window', 'windowGradient'],
['header', 'headerGradient'],
['surface', 'surfaceGradient'],
['card', 'cardGradient'],
['stroke', 'strokeGradient'],
]
const CLASSIC = PRESETS[0]
type Profile = Record<string, string | boolean | number>
const upper = (hex: string) => hex.toUpperCase()
/** Pack an entry into the mod's flat profile object. */
export function toProfile(entry: ThemeEntry): Profile {
const out: Profile = { name: entry.name }
for (const k of COLOR_KEYS) out[k] = upper(entry.theme[k])
for (const [prefix, key] of GRADIENT_KEYS) {
const g = entry[key] as GradientSpec
out[`${prefix}GradientEnabled`] = g.enabled
out[`${prefix}GradientStart`] = upper(g.start)
out[`${prefix}GradientEnd`] = upper(g.end)
out[`${prefix}GradientAngle`] = g.angle
}
return out
}
/** The mod's profile as an ordered JSON string (human-editable, keys match the mod exactly). */
export function toProfileJson(entry: ThemeEntry): string {
return JSON.stringify(toProfile(entry), null, 2)
}
const str = (v: unknown, fallback: string) => (typeof v === 'string' && v ? upper(v) : fallback)
const rawStr = (v: unknown, fallback: string) => (typeof v === 'string' && v.trim() ? v : fallback)
const bool = (v: unknown, fallback: boolean) => (typeof v === 'boolean' ? v : fallback)
const num = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
function readGradient(map: Profile, prefix: string, fallback: GradientSpec): GradientSpec {
return {
enabled: bool(map[`${prefix}GradientEnabled`], fallback.enabled),
start: str(map[`${prefix}GradientStart`], fallback.start),
end: str(map[`${prefix}GradientEnd`], fallback.end),
angle: num(map[`${prefix}GradientAngle`], fallback.angle),
}
}
/** Slug matching the mod's `uniqueCustomId` spirit: lowercase, non-alphanumeric → `_`. */
const slug = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'custom'
function fromProfile(map: Profile): ThemeEntry {
const theme = Object.fromEntries(
COLOR_KEYS.map((k) => [k, str(map[k], CLASSIC.theme[k])]),
) as unknown as ThemeColors
const name = rawStr(map.name, 'Custom theme')
return {
id: slug(name),
name,
builtin: false,
theme,
windowGradient: readGradient(map, 'window', CLASSIC.windowGradient),
headerGradient: readGradient(map, 'header', CLASSIC.headerGradient),
surfaceGradient: readGradient(map, 'surface', CLASSIC.surfaceGradient),
cardGradient: readGradient(map, 'card', CLASSIC.cardGradient),
strokeGradient: readGradient(map, 'stroke', CLASSIC.strokeGradient),
}
}
/** Rebuild an entry from JSON. Tolerant: missing keys fall back to Classic; bad input → error. */
export function fromProfileJson(json: string): ThemeEntry | { error: string } {
let parsed: unknown
try {
parsed = JSON.parse(json)
} catch {
return { error: 'invalidJson' }
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { error: 'invalidShape' }
return fromProfile(parsed as Profile)
}
// base64url without padding, so the share hash is URL-fragment safe.
function b64url(text: string): string {
const b64 = btoa(unescape(encodeURIComponent(text)))
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function unb64url(b64: string): string {
const pad = b64.length % 4 ? '='.repeat(4 - (b64.length % 4)) : ''
const std = b64.replace(/-/g, '+').replace(/_/g, '/') + pad
return decodeURIComponent(escape(atob(std)))
}
/** Compact profile → URL-safe hash for the `#t=` share link. */
export function toShareHash(entry: ThemeEntry): string {
return b64url(JSON.stringify(toProfile(entry)))
}
/** Decode a `#t=` hash back into an entry (or an error shape). */
export function fromShareHash(hash: string): ThemeEntry | { error: string } {
try {
return fromProfileJson(unb64url(hash))
} catch {
return { error: 'invalidHash' }
}
}

View file

@ -0,0 +1,48 @@
/* Theme editor: preview pane, HUD tiles, preset chips and color-field affordances. */
.lv-editor-preview { background: linear-gradient(180deg, #0b0f22, #070a18); }
.lv-hud-tile {
display: inline-flex;
align-items: baseline;
gap: 6px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--gui-stroke-soft, #6cf) 40%, transparent);
color: var(--gui-text, #fff);
font-family: var(--font-code, monospace);
font-size: 0.8rem;
box-shadow: 0 6px 20px -12px #000;
}
.lv-hud-tile b { color: var(--gui-accent, #6cf); font-weight: 700; }
.lv-preset-chip {
padding: 4px 12px;
border-radius: 999px;
border: 1px solid rgb(255 255 255 / 0.15);
background: rgb(255 255 255 / 0.04);
color: var(--color-frost, #9fb0c0);
font-size: 0.8rem;
transition: border-color 160ms ease, color 160ms ease;
}
.lv-preset-chip:hover { border-color: color-mix(in srgb, var(--color-ice, #6cf) 50%, transparent); color: var(--color-snow, #fff); }
.lv-preset-chip.is-active { border-color: var(--color-ice, #6cf); color: var(--color-ice, #6cf); }
/* Checkerboard behind the swatch so partial alpha is visible. */
.lv-alpha-check {
background-color: #20242b;
background-image:
linear-gradient(45deg, rgb(255 255 255 / 0.12) 25%, transparent 25%, transparent 75%, rgb(255 255 255 / 0.12) 75%),
linear-gradient(45deg, rgb(255 255 255 / 0.12) 25%, transparent 25%, transparent 75%, rgb(255 255 255 / 0.12) 75%);
background-size: 10px 10px;
background-position: 0 0, 5px 5px;
}
.lv-alpha-slider::-webkit-slider-thumb,
.lv-alpha-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--color-ice, #6cf);
border: 2px solid #0b0f22;
cursor: pointer;
}

View file

@ -0,0 +1,85 @@
import { describe, expect, test } from 'vitest'
import { PRESETS } from '../../presets.generated'
import { fromProfileJson, fromShareHash, toProfileJson, toShareHash } from '../codec'
// The mod's ThemesProfileCodec.toProfileValues writes exactly these keys, in this order.
const PROFILE_KEYS = [
'name',
'windowBg',
'windowHeader',
'windowStroke',
'surface',
'surfaceHover',
'cardEnabled',
'cardDisabled',
'textPrimary',
'textMuted',
'accent',
'accentSoft',
'strokeSoft',
'windowGradientEnabled',
'windowGradientStart',
'windowGradientEnd',
'windowGradientAngle',
'headerGradientEnabled',
'headerGradientStart',
'headerGradientEnd',
'headerGradientAngle',
'surfaceGradientEnabled',
'surfaceGradientStart',
'surfaceGradientEnd',
'surfaceGradientAngle',
'cardGradientEnabled',
'cardGradientStart',
'cardGradientEnd',
'cardGradientAngle',
'strokeGradientEnabled',
'strokeGradientStart',
'strokeGradientEnd',
'strokeGradientAngle',
]
const editable = (e: (typeof PRESETS)[number]) => ({
name: e.name,
theme: e.theme,
windowGradient: e.windowGradient,
headerGradient: e.headerGradient,
surfaceGradient: e.surfaceGradient,
cardGradient: e.cardGradient,
strokeGradient: e.strokeGradient,
})
describe('theme profile codec', () => {
test('JSON keys match the mod profile exactly', () => {
expect(Object.keys(JSON.parse(toProfileJson(PRESETS[0])))).toEqual(PROFILE_KEYS)
})
test('round-trips every preset through the profile JSON', () => {
for (const preset of PRESETS) {
const result = fromProfileJson(toProfileJson(preset))
expect('error' in result).toBe(false)
if (!('error' in result)) expect(editable(result)).toEqual(editable(preset))
}
})
test('share hash round-trips', () => {
for (const preset of PRESETS) {
const result = fromShareHash(toShareHash(preset))
if ('error' in result) throw new Error('unexpected hash error')
expect(editable(result)).toEqual(editable(preset))
}
})
test('rejects malformed JSON and non-objects', () => {
expect(fromProfileJson('{ nope')).toEqual({ error: 'invalidJson' })
expect(fromProfileJson('[1,2,3]')).toEqual({ error: 'invalidShape' })
expect(fromShareHash('%%%not-base64%%%')).toEqual({ error: 'invalidHash' })
})
test('is tolerant: missing keys fall back to Classic', () => {
const result = fromProfileJson(JSON.stringify({ name: 'Sparse' }))
if ('error' in result) throw new Error('unexpected error')
expect(result.name).toBe('Sparse')
expect(result.theme).toEqual(PRESETS[0].theme)
})
})

View file

@ -0,0 +1,18 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { expect, test } from 'vitest'
import { PRESETS } from '../../presets.generated'
import { ThemeEditor } from '../ThemeEditor'
test('editor shows the initial preset name and one toggle per gradient slot', () => {
render(<ThemeEditor initial={PRESETS[0]} />)
const name = screen.getByDisplayValue('Classic')
expect(name).toBeInTheDocument()
expect(screen.getAllByRole('checkbox', { name: /Градиент/ })).toHaveLength(5)
})
test('picking a preset chip loads it into the editor', () => {
const next = PRESETS[1]
render(<ThemeEditor initial={PRESETS[0]} />)
fireEvent.click(screen.getByRole('button', { name: next.name }))
expect(screen.getByDisplayValue(next.name)).toBeInTheDocument()
})

View file

@ -0,0 +1,85 @@
import { useCallback, useRef, useState } from 'react'
import type { ThemeEntry } from '../types'
const LIMIT = 50
/** Changes closer than this share one undo step, so a slider drag is a single revert. */
const COALESCE_MS = 500
export type ThemeHistory = {
draft: ThemeEntry
/** Live edit; coalesced with recent edits of the same interaction. */
set: (next: ThemeEntry) => void
/** Replace the draft and clear history (preset pick, import, share-link load). */
reset: (next: ThemeEntry) => void
undo: () => void
redo: () => void
canUndo: boolean
canRedo: boolean
}
/** Undo/redo ring buffer (max 50 steps) for the theme editor draft. */
export function useThemeHistory(initial: ThemeEntry): ThemeHistory {
const [present, setPresent] = useState(initial)
const presentRef = useRef(present)
const past = useRef<ThemeEntry[]>([])
const future = useRef<ThemeEntry[]>([])
const lastEdit = useRef(0)
const [flags, setFlags] = useState({ canUndo: false, canRedo: false })
const commit = useCallback((next: ThemeEntry) => {
presentRef.current = next
setPresent(next)
setFlags({ canUndo: past.current.length > 0, canRedo: future.current.length > 0 })
}, [])
const set = useCallback(
(next: ThemeEntry) => {
const now = Date.now()
if (now - lastEdit.current > COALESCE_MS) {
past.current = [...past.current, presentRef.current].slice(-LIMIT)
future.current = []
}
lastEdit.current = now
commit(next)
},
[commit],
)
const reset = useCallback(
(next: ThemeEntry) => {
past.current = []
future.current = []
lastEdit.current = 0
commit(next)
},
[commit],
)
const undo = useCallback(() => {
if (past.current.length === 0) return
future.current = [presentRef.current, ...future.current].slice(0, LIMIT)
lastEdit.current = 0
commit(past.current.at(-1) as ThemeEntry)
past.current = past.current.slice(0, -1)
setFlags({ canUndo: past.current.length > 0, canRedo: true })
}, [commit])
const redo = useCallback(() => {
if (future.current.length === 0) return
past.current = [...past.current, presentRef.current].slice(-LIMIT)
lastEdit.current = 0
commit(future.current[0] as ThemeEntry)
future.current = future.current.slice(1)
setFlags({ canUndo: true, canRedo: future.current.length > 0 })
}, [commit])
return {
draft: present,
set,
reset,
undo,
redo,
canUndo: flags.canUndo,
canRedo: flags.canRedo,
}
}

View file

@ -0,0 +1,49 @@
import type { Translation } from '../../../shared/i18n/common.ru'
import type { themesRu } from './ru'
export const themesEn = {
apply: 'Apply the {{name}} theme',
colors: {
windowBg: 'Window',
windowHeader: 'Header',
windowStroke: 'Stroke',
surface: 'Surface',
surfaceHover: 'Hover',
cardEnabled: 'Card on',
cardDisabled: 'Card off',
textPrimary: 'Text',
textMuted: 'Muted text',
accent: 'Accent',
accentSoft: 'Soft accent',
strokeSoft: 'Soft stroke',
},
editor: {
title: 'Theme editor',
lead: 'Build your own theme and bring it into the mod as a single file.',
nameLabel: 'Theme name',
presets: 'Ready presets',
colors: 'Colors',
gradients: 'Gradients',
enabled: 'Gradient',
angle: 'Angle',
start: 'Start',
end: 'End',
gradient: {
window: 'Window',
header: 'Header',
surface: 'Surface',
card: 'Card',
stroke: 'Stroke',
},
copy: 'Copy for the mod',
copied: 'Copied',
download: 'Download .json',
import: 'Import from file',
share: 'Share link',
shared: 'Link copied',
reset: 'Reset',
undo: 'Undo',
redo: 'Redo',
importError: 'Could not read the theme — the file is malformed.',
},
} satisfies Translation<typeof themesRu>

View file

@ -0,0 +1,46 @@
export const themesRu = {
apply: 'Применить тему «{{name}}»',
colors: {
windowBg: 'Фон окна',
windowHeader: 'Шапка',
windowStroke: 'Обводка',
surface: 'Поверхность',
surfaceHover: 'Наведение',
cardEnabled: 'Карточка вкл.',
cardDisabled: 'Карточка выкл.',
textPrimary: 'Текст',
textMuted: 'Приглушённый текст',
accent: 'Акцент',
accentSoft: 'Мягкий акцент',
strokeSoft: 'Мягкая обводка',
},
editor: {
title: 'Редактор тем',
lead: 'Собери свою тему и перенеси её в мод одним файлом.',
nameLabel: 'Название темы',
presets: 'Готовые пресеты',
colors: 'Цвета',
gradients: 'Градиенты',
enabled: 'Градиент',
angle: 'Угол',
start: 'Начало',
end: 'Конец',
gradient: {
window: 'Окно',
header: 'Шапка',
surface: 'Поверхность',
card: 'Карточка',
stroke: 'Обводка',
},
copy: 'Скопировать для мода',
copied: 'Скопировано',
download: 'Скачать .json',
import: 'Импорт из файла',
share: 'Ссылка на тему',
shared: 'Ссылка скопирована',
reset: 'Сбросить',
undo: 'Отменить',
redo: 'Вернуть',
importError: 'Не удалось прочитать тему — файл испорчен.',
},
} as const

View file

@ -0,0 +1,438 @@
// generated — do not edit. Source: mod/.../features/theme/presets/ThemePresets{1,2,3}.java
// Regenerate with `bun run gen:themes`.
import type { ThemeEntry } from './types'
export const PRESETS: ThemeEntry[] = [
{
id: 'classic',
name: 'Classic',
builtin: true,
theme: {
windowBg: '#F012191B',
windowHeader: '#F0142226',
windowStroke: '#60203030',
surface: '#16202523',
surfaceHover: '#28353A3E',
cardEnabled: '#5043A7C8',
cardDisabled: '#00334046',
textPrimary: '#FFFFFFFF',
textMuted: '#88C6D2CD',
accent: '#FF5CC8E7',
accentSoft: '#805CC8E7',
strokeSoft: '#60FFFFFF',
},
windowGradient: { enabled: false, start: '#F012191B', end: '#F012191B', angle: 90 },
headerGradient: { enabled: true, start: '#F0273437', end: '#F0132024', angle: 90 },
surfaceGradient: { enabled: false, start: '#16202523', end: '#16202523', angle: 90 },
cardGradient: { enabled: false, start: '#5043A7C8', end: '#5043A7C8', angle: 90 },
strokeGradient: { enabled: false, start: '#60203030', end: '#60203030', angle: 90 },
},
{
id: 'legacy',
name: 'Legacy',
builtin: true,
theme: {
windowBg: '#F0121314',
windowHeader: '#F0000205',
windowStroke: '#E1121314',
surface: '#FF171819',
surfaceHover: '#FF131315',
cardEnabled: '#503F464D',
cardDisabled: '#33191C20',
textPrimary: '#FFF5F5FF',
textMuted: '#88A5A5A5',
accent: '#FF767C94',
accentSoft: '#80767C94',
strokeSoft: '#60373746',
},
windowGradient: { enabled: false, start: '#F0121314', end: '#F0121314', angle: 90 },
headerGradient: { enabled: true, start: '#F0141619', end: '#F0000205', angle: 90 },
surfaceGradient: { enabled: false, start: '#FF171819', end: '#FF171819', angle: 90 },
cardGradient: { enabled: false, start: '#503F464D', end: '#503F464D', angle: 90 },
strokeGradient: { enabled: false, start: '#E1121314', end: '#E1121314', angle: 90 },
},
{
id: 'amber',
name: 'Amber',
builtin: true,
theme: {
windowBg: '#F019120D',
windowHeader: '#F0201711',
windowStroke: '#60C3652C',
surface: '#FF221711',
surfaceHover: '#FF2B1E16',
cardEnabled: '#50E08C3A',
cardDisabled: '#40302620',
textPrimary: '#FFFFF7EB',
textMuted: '#88E3C9A7',
accent: '#FFE69A43',
accentSoft: '#80E69A43',
strokeSoft: '#60F5D7B0',
},
windowGradient: { enabled: false, start: '#F019120D', end: '#F019120D', angle: 90 },
headerGradient: { enabled: true, start: '#F0322A24', end: '#F01E1610', angle: 90 },
surfaceGradient: { enabled: false, start: '#FF221711', end: '#FF221711', angle: 90 },
cardGradient: { enabled: false, start: '#50E08C3A', end: '#50E08C3A', angle: 90 },
strokeGradient: { enabled: false, start: '#60C3652C', end: '#60C3652C', angle: 90 },
},
{
id: 'noir',
name: 'Noir',
builtin: true,
theme: {
windowBg: '#F00C0D10',
windowHeader: '#F0121317',
windowStroke: '#60393F4A',
surface: '#FF14161B',
surfaceHover: '#FF1C1F26',
cardEnabled: '#5039424F',
cardDisabled: '#40282D35',
textPrimary: '#FFF4F4F4',
textMuted: '#88C7CBD3',
accent: '#FFE16B78',
accentSoft: '#80E16B78',
strokeSoft: '#605A6370',
},
windowGradient: { enabled: false, start: '#F00C0D10', end: '#F00C0D10', angle: 90 },
headerGradient: { enabled: true, start: '#F025262A', end: '#F0111216', angle: 90 },
surfaceGradient: { enabled: false, start: '#FF14161B', end: '#FF14161B', angle: 90 },
cardGradient: { enabled: false, start: '#5039424F', end: '#5039424F', angle: 90 },
strokeGradient: { enabled: false, start: '#60393F4A', end: '#60393F4A', angle: 90 },
},
{
id: 'purple',
name: 'Purple',
builtin: true,
theme: {
windowBg: '#F0120E1A',
windowHeader: '#F0181323',
windowStroke: '#60433A5A',
surface: '#FF1C1627',
surfaceHover: '#FF261D35',
cardEnabled: '#505E4AA0',
cardDisabled: '#40312644',
textPrimary: '#FFF5F2FF',
textMuted: '#88CFC6E6',
accent: '#FF9B6BFF',
accentSoft: '#809B6BFF',
strokeSoft: '#60705A9C',
},
windowGradient: { enabled: false, start: '#F0120E1A', end: '#F0120E1A', angle: 90 },
headerGradient: { enabled: true, start: '#F02A2635', end: '#F0171221', angle: 90 },
surfaceGradient: { enabled: false, start: '#FF1C1627', end: '#FF1C1627', angle: 90 },
cardGradient: { enabled: false, start: '#505E4AA0', end: '#505E4AA0', angle: 90 },
strokeGradient: { enabled: false, start: '#60433A5A', end: '#60433A5A', angle: 90 },
},
{
id: 'blue',
name: 'Blue',
builtin: true,
theme: {
windowBg: '#F0080F1E',
windowHeader: '#F00D1626',
windowStroke: '#60405A82',
surface: '#FF101B2B',
surfaceHover: '#FF162338',
cardEnabled: '#50406BB3',
cardDisabled: '#4023324A',
textPrimary: '#FFF0F5FF',
textMuted: '#88B2C4E6',
accent: '#FF2E64CC',
accentSoft: '#803A6FD6',
strokeSoft: '#60465F8E',
},
windowGradient: { enabled: false, start: '#F0080F1E', end: '#F0080F1E', angle: 90 },
headerGradient: { enabled: true, start: '#F0202937', end: '#F00C1524', angle: 90 },
surfaceGradient: { enabled: false, start: '#FF101B2B', end: '#FF101B2B', angle: 90 },
cardGradient: { enabled: false, start: '#50406BB3', end: '#50406BB3', angle: 90 },
strokeGradient: { enabled: false, start: '#60405A82', end: '#60405A82', angle: 90 },
},
{
id: 'azure',
name: 'Azure',
builtin: true,
theme: {
windowBg: '#F00C1220',
windowHeader: '#F0101A2A',
windowStroke: '#60324B66',
surface: '#FF121C2C',
surfaceHover: '#FF18253A',
cardEnabled: '#503F6BAA',
cardDisabled: '#40283348',
textPrimary: '#FFF2F7FF',
textMuted: '#88BBD0EA',
accent: '#FF5DA7FF',
accentSoft: '#805DA7FF',
strokeSoft: '#60507CA8',
},
windowGradient: { enabled: false, start: '#F00C1220', end: '#F00C1220', angle: 90 },
headerGradient: { enabled: true, start: '#F0232C3B', end: '#F00F1827', angle: 90 },
surfaceGradient: { enabled: false, start: '#FF121C2C', end: '#FF121C2C', angle: 90 },
cardGradient: { enabled: false, start: '#503F6BAA', end: '#503F6BAA', angle: 90 },
strokeGradient: { enabled: false, start: '#60324B66', end: '#60324B66', angle: 90 },
},
{
id: 'nitro',
name: 'Nitro',
builtin: true,
theme: {
windowBg: '#FF121624',
windowHeader: '#FF181E33',
windowStroke: '#60465580',
surface: '#FF1A1F36',
surfaceHover: '#FF232A45',
cardEnabled: '#50A24CFF',
cardDisabled: '#40303A52',
textPrimary: '#FFF3F6FF',
textMuted: '#88C7D3F3',
accent: '#FF7A5CFF',
accentSoft: '#807A5CFF',
strokeSoft: '#60829AC9',
},
windowGradient: { enabled: true, start: '#FF36205F', end: '#FF0B7DFF', angle: 45 },
headerGradient: { enabled: true, start: '#FF4B2FAF', end: '#FF24C1FF', angle: 45 },
surfaceGradient: { enabled: true, start: '#FF1A1F36', end: '#FF121728', angle: 90 },
cardGradient: { enabled: true, start: '#FF5A37D5', end: '#FF2AD1FF', angle: 45 },
strokeGradient: { enabled: true, start: '#FF6C4BFF', end: '#FF4BD0FF', angle: 45 },
},
{
id: 'sunset',
name: 'Sunset',
builtin: true,
theme: {
windowBg: '#FF1A1116',
windowHeader: '#FF211319',
windowStroke: '#605C2B2B',
surface: '#FF24151C',
surfaceHover: '#FF2E1A23',
cardEnabled: '#50FF7A45',
cardDisabled: '#40261A1A',
textPrimary: '#FFFFF2E8',
textMuted: '#88E3B9A3',
accent: '#FFFF7A45',
accentSoft: '#80FF7A45',
strokeSoft: '#60F5C1A6',
},
windowGradient: { enabled: true, start: '#FF2A1020', end: '#FFB2431F', angle: 45 },
headerGradient: { enabled: true, start: '#FF3B1626', end: '#FFC25B2A', angle: 45 },
surfaceGradient: { enabled: true, start: '#FF24151C', end: '#FF1E1116', angle: 90 },
cardGradient: { enabled: true, start: '#FFFF8A4C', end: '#FFFF4B7A', angle: 45 },
strokeGradient: { enabled: true, start: '#FFFFB27A', end: '#FFFF6A3A', angle: 45 },
},
{
id: 'aurora',
name: 'Aurora',
builtin: true,
theme: {
windowBg: '#FF0D1A18',
windowHeader: '#FF102320',
windowStroke: '#602B5B57',
surface: '#FF132623',
surfaceHover: '#FF1A312D',
cardEnabled: '#5034D399',
cardDisabled: '#40304742',
textPrimary: '#FFF1FFF9',
textMuted: '#8897E3D0',
accent: '#FF2DD9C3',
accentSoft: '#802DD9C3',
strokeSoft: '#6071B5A6',
},
windowGradient: { enabled: true, start: '#FF0B2A3C', end: '#FF2A8E6B', angle: 120 },
headerGradient: { enabled: true, start: '#FF0E3B46', end: '#FF1FB88B', angle: 120 },
surfaceGradient: { enabled: true, start: '#FF132623', end: '#FF10201D', angle: 90 },
cardGradient: { enabled: true, start: '#FF2DD9C3', end: '#FF5A7CFF', angle: 135 },
strokeGradient: { enabled: true, start: '#FF78FFE5', end: '#FF6FB1FF', angle: 120 },
},
{
id: 'mint',
name: 'Mint',
builtin: true,
theme: {
windowBg: '#FF0E1917',
windowHeader: '#FF112320',
windowStroke: '#60255B53',
surface: '#FF132521',
surfaceHover: '#FF1A2F2B',
cardEnabled: '#5035CFA8',
cardDisabled: '#40304742',
textPrimary: '#FFF1FFF9',
textMuted: '#8897E3D0',
accent: '#FF43E0B2',
accentSoft: '#8043E0B2',
strokeSoft: '#6071B5A6',
},
windowGradient: { enabled: true, start: '#FF0B2A24', end: '#FF1B3E36', angle: 120 },
headerGradient: { enabled: true, start: '#FF0F3A32', end: '#FF1A6E59', angle: 120 },
surfaceGradient: { enabled: true, start: '#FF132521', end: '#FF0F1E1B', angle: 90 },
cardGradient: { enabled: true, start: '#FF3DE3B4', end: '#FFB7F2D9', angle: 45 },
strokeGradient: { enabled: true, start: '#FF48E6B9', end: '#FF7EF0D6', angle: 120 },
},
{
id: 'peach',
name: 'Peach',
builtin: true,
theme: {
windowBg: '#FF1A1210',
windowHeader: '#FF221510',
windowStroke: '#605C3A2A',
surface: '#FF241814',
surfaceHover: '#FF2F211A',
cardEnabled: '#50F2A269',
cardDisabled: '#40382A22',
textPrimary: '#FFFFF7F0',
textMuted: '#88E3C9A7',
accent: '#FFFFA46B',
accentSoft: '#80FFB680',
strokeSoft: '#60F5D7B0',
},
windowGradient: { enabled: true, start: '#FF2A1512', end: '#FF4B241A', angle: 45 },
headerGradient: { enabled: true, start: '#FF3B1B14', end: '#FF6A301F', angle: 45 },
surfaceGradient: { enabled: true, start: '#FF241814', end: '#FF1C1310', angle: 90 },
cardGradient: { enabled: true, start: '#FFFFB47A', end: '#FFFF7FA8', angle: 45 },
strokeGradient: { enabled: true, start: '#FFFFC28E', end: '#FFFF8A5A', angle: 45 },
},
{
id: 'lilac',
name: 'Lilac',
builtin: true,
theme: {
windowBg: '#FF14131E',
windowHeader: '#FF1A1728',
windowStroke: '#60433A5A',
surface: '#FF1B172A',
surfaceHover: '#FF241D36',
cardEnabled: '#506C5CBE',
cardDisabled: '#40312644',
textPrimary: '#FFF5F2FF',
textMuted: '#88CFC6E6',
accent: '#FFB79BFF',
accentSoft: '#80C8B0FF',
strokeSoft: '#60705A9C',
},
windowGradient: { enabled: true, start: '#FF241A3B', end: '#FF1A3A55', angle: 120 },
headerGradient: { enabled: true, start: '#FF2F214A', end: '#FF3D2F6A', angle: 120 },
surfaceGradient: { enabled: true, start: '#FF1B172A', end: '#FF15121F', angle: 90 },
cardGradient: { enabled: true, start: '#FFB79BFF', end: '#FF7AD9FF', angle: 45 },
strokeGradient: { enabled: true, start: '#FFC5B0FF', end: '#FF8BB2FF', angle: 120 },
},
{
id: 'sand',
name: 'Sand',
builtin: true,
theme: {
windowBg: '#FF181510',
windowHeader: '#FF201A13',
windowStroke: '#6050432F',
surface: '#FF221B14',
surfaceHover: '#FF2C231B',
cardEnabled: '#50E8C98C',
cardDisabled: '#40322A21',
textPrimary: '#FFFFF4E8',
textMuted: '#88D8C4A2',
accent: '#FFE8C98C',
accentSoft: '#80F0D8A8',
strokeSoft: '#607A6A54',
},
windowGradient: { enabled: true, start: '#FF2A2016', end: '#FF3A2F22', angle: 45 },
headerGradient: { enabled: true, start: '#FF32261B', end: '#FF4A3A2B', angle: 45 },
surfaceGradient: { enabled: true, start: '#FF221B14', end: '#FF1A1510', angle: 90 },
cardGradient: { enabled: true, start: '#FFF2D29B', end: '#FFE8B67A', angle: 45 },
strokeGradient: { enabled: true, start: '#FFF5E0B2', end: '#FFD1A36C', angle: 45 },
},
{
id: 'dusk',
name: 'Dusk',
builtin: true,
theme: {
windowBg: '#FF141018',
windowHeader: '#FF1B1422',
windowStroke: '#60503A4A',
surface: '#FF1D1626',
surfaceHover: '#FF271B34',
cardEnabled: '#50FF875A',
cardDisabled: '#4030263B',
textPrimary: '#FFF6F1FF',
textMuted: '#88D6C9E6',
accent: '#FFFF8A4C',
accentSoft: '#80FF9B5A',
strokeSoft: '#606B5A7A',
},
windowGradient: { enabled: true, start: '#FF2B1630', end: '#FF6B2A1F', angle: 45 },
headerGradient: { enabled: true, start: '#FF3B1B3A', end: '#FF8A3B24', angle: 45 },
surfaceGradient: { enabled: true, start: '#FF1D1626', end: '#FF141018', angle: 90 },
cardGradient: { enabled: true, start: '#FF7C4BFF', end: '#FFFF9B3D', angle: 45 },
strokeGradient: { enabled: true, start: '#FFA775FF', end: '#FFFF7A3A', angle: 45 },
},
{
id: 'prism',
name: 'Prism',
builtin: true,
theme: {
windowBg: '#FF0F1220',
windowHeader: '#FF141A2B',
windowStroke: '#60435173',
surface: '#FF151C2E',
surfaceHover: '#FF1B243A',
cardEnabled: '#504B7CFF',
cardDisabled: '#4031324A',
textPrimary: '#FFF3F6FF',
textMuted: '#88C2CDEB',
accent: '#FF5AC8FA',
accentSoft: '#8074B6FF',
strokeSoft: '#606B7A9E',
},
windowGradient: { enabled: true, start: '#FF1A243A', end: '#FF2A1E4A', angle: 135 },
headerGradient: { enabled: true, start: '#FF223050', end: '#FF352060', angle: 135 },
surfaceGradient: { enabled: true, start: '#FF151C2E', end: '#FF101522', angle: 90 },
cardGradient: { enabled: true, start: '#FF59C4FF', end: '#FFB35CFF', angle: 45 },
strokeGradient: { enabled: true, start: '#FF7AD4FF', end: '#FFE36BFF', angle: 45 },
},
{
id: 'cinder',
name: 'Cinder',
builtin: true,
theme: {
windowBg: '#FF120E0F',
windowHeader: '#FF181012',
windowStroke: '#604A2F32',
surface: '#FF1A1214',
surfaceHover: '#FF23171A',
cardEnabled: '#50D24A4A',
cardDisabled: '#40281A1C',
textPrimary: '#FFFFF3F3',
textMuted: '#88E3B9B9',
accent: '#FFDC4A4A',
accentSoft: '#80E06A6A',
strokeSoft: '#606B4E52',
},
windowGradient: { enabled: true, start: '#FF1A0C0C', end: '#FF2A1416', angle: 45 },
headerGradient: { enabled: true, start: '#FF241012', end: '#FF3A191C', angle: 45 },
surfaceGradient: { enabled: true, start: '#FF1A1214', end: '#FF120E0F', angle: 90 },
cardGradient: { enabled: true, start: '#FFDC4A4A', end: '#FF401010', angle: 45 },
strokeGradient: { enabled: true, start: '#FFE26A6A', end: '#FF7A2A2A', angle: 45 },
},
{
id: 'forest',
name: 'Forest',
builtin: true,
theme: {
windowBg: '#FF101611',
windowHeader: '#FF141E15',
windowStroke: '#6043573D',
surface: '#FF161F18',
surfaceHover: '#FF1D2B20',
cardEnabled: '#5071C36C',
cardDisabled: '#40263225',
textPrimary: '#FFF3FFF1',
textMuted: '#88C5E3C1',
accent: '#FF7DD36B',
accentSoft: '#807DD36B',
strokeSoft: '#60607B58',
},
windowGradient: { enabled: true, start: '#FF1A241C', end: '#FF2B3A24', angle: 120 },
headerGradient: { enabled: true, start: '#FF1F2B21', end: '#FF2F4B2E', angle: 120 },
surfaceGradient: { enabled: true, start: '#FF161F18', end: '#FF101611', angle: 90 },
cardGradient: { enabled: true, start: '#FF7DD36B', end: '#FFC6E886', angle: 45 },
strokeGradient: { enabled: true, start: '#FF9BE07C', end: '#FF6BC26A', angle: 45 },
},
]

View file

@ -0,0 +1,54 @@
import { act, render, screen } from '@testing-library/react'
import { afterEach, expect, test, vi } from 'vitest'
import { ActiveThemeProvider, useActiveTheme } from '../useActiveTheme'
function Probe() {
const { theme, setTheme, userPicked } = useActiveTheme()
return (
<>
<p data-testid="id">{theme.id}</p>
<p data-testid="picked">{String(userPicked)}</p>
<button onClick={() => setTheme('nitro', 'user')}>user</button>
<button onClick={() => setTheme('sunset', 'demo')}>demo</button>
</>
)
}
afterEach(() => {
window.localStorage.clear()
document.documentElement.removeAttribute('style')
})
test('a user pick persists to lv_theme, re-accents the page and locks out demo switches', () => {
render(<ActiveThemeProvider><Probe /></ActiveThemeProvider>)
expect(screen.getByTestId('id')).toHaveTextContent('classic')
act(() => screen.getByText('demo').click())
expect(screen.getByTestId('id')).toHaveTextContent('sunset')
expect(window.localStorage.getItem('lv_theme')).toBeNull()
act(() => screen.getByText('user').click())
expect(screen.getByTestId('id')).toHaveTextContent('nitro')
expect(window.localStorage.getItem('lv_theme')).toBe('nitro')
expect(document.documentElement.style.getPropertyValue('--color-ice')).toBe('rgb(122, 92, 255)')
act(() => screen.getByText('demo').click())
expect(screen.getByTestId('id')).toHaveTextContent('nitro')
})
test('restores the stored theme and survives blocked storage', () => {
window.localStorage.setItem('lv_theme', 'mint')
const { unmount } = render(<ActiveThemeProvider><Probe /></ActiveThemeProvider>)
expect(screen.getByTestId('id')).toHaveTextContent('mint')
expect(screen.getByTestId('picked')).toHaveTextContent('true')
unmount()
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked')
})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked')
})
render(<ActiveThemeProvider><Probe /></ActiveThemeProvider>)
expect(screen.getByTestId('id')).toHaveTextContent('classic')
act(() => screen.getByText('user').click())
expect(screen.getByTestId('id')).toHaveTextContent('nitro')
vi.restoreAllMocks()
})

View file

@ -0,0 +1,39 @@
import { expect, test } from 'vitest'
import { PRESETS } from '../presets.generated'
import { argbToCss, parseArgb } from '../types'
const javaSources = import.meta.glob<string>(
'../../../../../mod/src/main/java/dev/loki/lovisual/features/theme/presets/ThemePresets*.java',
{ query: '?raw', import: 'default', eager: true },
)
test('generated presets include Classic with the mod accent', () => {
const classic = PRESETS.find((p) => p.id === 'classic')
expect(classic?.name).toBe('Classic')
expect(classic?.theme.accent).toBe('#FF5CC8E7')
expect(classic?.theme.windowBg).toBe('#F012191B')
// 0x334046 in Java is a 6-digit literal: alpha 00.
expect(classic?.theme.cardDisabled).toBe('#00334046')
})
test('one preset per ThemeEntry constant in the mod sources', () => {
const sources = Object.values(javaSources)
expect(sources).toHaveLength(3)
const count = sources.reduce((n, src) => n + (src.match(/static\s+final\s+ThemeEntry\s+\w+\s*=/g)?.length ?? 0), 0)
expect(count).toBeGreaterThan(0)
expect(PRESETS).toHaveLength(count)
expect(new Set(PRESETS.map((p) => p.id)).size).toBe(count)
})
test('derived header gradient matches ThemesBlending.subtleHeaderGradient', () => {
const classic = PRESETS.find((p) => p.id === 'classic')
// 0xF0142226 lightened by 8% and darkened by 6%, as the mod computes it.
expect(classic?.headerGradient).toEqual({ enabled: true, start: '#F0273437', end: '#F0132024', angle: 90 })
})
test('argbToCss converts packed ARGB', () => {
expect(argbToCss('#F012191B')).toBe('rgba(18, 25, 27, 0.941)')
expect(argbToCss('#FF5CC8E7')).toBe('rgba(92, 200, 231, 1)')
expect(argbToCss('#00334046')).toBe('rgba(51, 64, 70, 0)')
expect(parseArgb('#5CC8E7')).toEqual({ a: 255, r: 92, g: 200, b: 231 })
})

View file

@ -0,0 +1,55 @@
/** The mod's 12 palette slots (`Themes.Theme` record), each as `#AARRGGBB`. */
export type ThemeColors = {
windowBg: string
windowHeader: string
windowStroke: string
surface: string
surfaceHover: string
cardEnabled: string
cardDisabled: string
textPrimary: string
textMuted: string
accent: string
accentSoft: string
strokeSoft: string
}
/** Mirrors `Themes.GradientSpec`: when disabled the slot renders flat with `start`. */
export type GradientSpec = { enabled: boolean; start: string; end: string; angle: number }
/** Mirrors `Themes.ThemeEntry`. */
export type ThemeEntry = {
id: string
name: string
builtin: boolean
theme: ThemeColors
windowGradient: GradientSpec
headerGradient: GradientSpec
surfaceGradient: GradientSpec
cardGradient: GradientSpec
strokeGradient: GradientSpec
}
type Rgba = { r: number; g: number; b: number; a: number }
/** Parses `#AARRGGBB` (the mod's packed ARGB) or `#RRGGBB` (opaque). */
export function parseArgb(hex: string): Rgba {
const clean = hex.replace(/^#/, '')
const full = clean.length === 6 ? `FF${clean}` : clean.padStart(8, '0')
const n = Number.parseInt(full, 16) >>> 0
return { a: (n >>> 24) & 0xff, r: (n >>> 16) & 0xff, g: (n >>> 8) & 0xff, b: n & 0xff }
}
const alpha = (a: number) => String(Number((a / 255).toFixed(3)))
/** `#F012191B` → `rgba(18, 25, 27, 0.941)`. */
export function argbToCss(hex: string): string {
const { r, g, b, a } = parseArgb(hex)
return `rgba(${r}, ${g}, ${b}, ${alpha(a)})`
}
/** Same color with its alpha replaced (0–1) — for glows built from a palette slot. */
export function withAlpha(hex: string, a: number): string {
const { r, g, b } = parseArgb(hex)
return `rgba(${r}, ${g}, ${b}, ${Number(a.toFixed(3))})`
}

View file

@ -0,0 +1,72 @@
import { createContext, createElement, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { PRESETS } from './presets.generated'
import { parseArgb, type ThemeEntry, withAlpha } from './types'
const STORAGE_KEY = 'lv_theme'
const FALLBACK = PRESETS[0]
type ActiveTheme = {
theme: ThemeEntry
/** True once the visitor picked a theme themselves — demos must stop switching it then. */
userPicked: boolean
/** `source: 'user'` persists the choice; `'demo'` is a transient showcase switch. */
setTheme: (id: string, source?: 'user' | 'demo') => void
}
const ActiveThemeContext = createContext<ActiveTheme | null>(null)
function readStored(): string | null {
try {
return window.localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
}
function store(id: string) {
try {
window.localStorage.setItem(STORAGE_KEY, id)
} catch {
// Storage can be blocked (private mode, quota); the theme still applies for this visit.
}
}
const findPreset = (id: string | null) => PRESETS.find((p) => p.id === id)
/** Opaque accent for page chrome (buttons, links, focus rings). */
function pageAccent(entry: ThemeEntry): string {
const { r, g, b } = parseArgb(entry.theme.accent)
return `rgb(${r}, ${g}, ${b})`
}
export function ActiveThemeProvider({ children, applyToPage = true }: { children: ReactNode; applyToPage?: boolean }) {
const [state, setState] = useState(() => {
const stored = findPreset(readStored())
return { theme: stored ?? FALLBACK, userPicked: Boolean(stored) }
})
const setTheme = useCallback((id: string, source: 'user' | 'demo' = 'user') => {
const next = findPreset(id)
if (!next) return
if (source === 'user') store(id)
setState((prev) => {
if (source === 'demo' && prev.userPicked) return prev
return { theme: next, userPicked: prev.userPicked || source === 'user' }
})
}, [])
useEffect(() => {
if (!applyToPage) return
const root = document.documentElement.style
root.setProperty('--color-ice', pageAccent(state.theme))
root.setProperty('--page-accent-glow', withAlpha(state.theme.theme.accent, 0.35))
}, [state.theme, applyToPage])
const value = useMemo(() => ({ ...state, setTheme }), [state, setTheme])
return createElement(ActiveThemeContext.Provider, { value }, children)
}
/** The active ClickGui theme. Outside a provider it falls back to Classic with a no-op setter. */
export function useActiveTheme(): ActiveTheme {
return useContext(ActiveThemeContext) ?? { theme: FALLBACK, userPicked: false, setTheme: () => {} }
}