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:
parent
72bc4c7148
commit
7f4b532f99
257 changed files with 13085 additions and 6582 deletions
70
frontend/src/features/themes/editor/ColorField.tsx
Normal file
70
frontend/src/features/themes/editor/ColorField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
frontend/src/features/themes/editor/EditorPreview.tsx
Normal file
28
frontend/src/features/themes/editor/EditorPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
frontend/src/features/themes/editor/GradientField.tsx
Normal file
47
frontend/src/features/themes/editor/GradientField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
155
frontend/src/features/themes/editor/ThemeEditor.tsx
Normal file
155
frontend/src/features/themes/editor/ThemeEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
138
frontend/src/features/themes/editor/codec.ts
Normal file
138
frontend/src/features/themes/editor/codec.ts
Normal 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' }
|
||||
}
|
||||
}
|
||||
48
frontend/src/features/themes/editor/editor.css
Normal file
48
frontend/src/features/themes/editor/editor.css
Normal 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;
|
||||
}
|
||||
85
frontend/src/features/themes/editor/tests/codec.test.ts
Normal file
85
frontend/src/features/themes/editor/tests/codec.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
18
frontend/src/features/themes/editor/tests/editor.test.tsx
Normal file
18
frontend/src/features/themes/editor/tests/editor.test.tsx
Normal 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()
|
||||
})
|
||||
85
frontend/src/features/themes/editor/useThemeHistory.ts
Normal file
85
frontend/src/features/themes/editor/useThemeHistory.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
49
frontend/src/features/themes/i18n/en.ts
Normal file
49
frontend/src/features/themes/i18n/en.ts
Normal 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>
|
||||
46
frontend/src/features/themes/i18n/ru.ts
Normal file
46
frontend/src/features/themes/i18n/ru.ts
Normal 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
|
||||
438
frontend/src/features/themes/presets.generated.ts
Normal file
438
frontend/src/features/themes/presets.generated.ts
Normal 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 },
|
||||
},
|
||||
]
|
||||
54
frontend/src/features/themes/tests/activeTheme.test.tsx
Normal file
54
frontend/src/features/themes/tests/activeTheme.test.tsx
Normal 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()
|
||||
})
|
||||
39
frontend/src/features/themes/tests/presets.test.ts
Normal file
39
frontend/src/features/themes/tests/presets.test.ts
Normal 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 })
|
||||
})
|
||||
55
frontend/src/features/themes/types.ts
Normal file
55
frontend/src/features/themes/types.ts
Normal 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))})`
|
||||
}
|
||||
72
frontend/src/features/themes/useActiveTheme.ts
Normal file
72
frontend/src/features/themes/useActiveTheme.ts
Normal 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: () => {} }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue