feat(frontend): redesign the theme editor page

The editor's control panel is now a second ClickGui window skinned
live with the theme being edited, so every change shows in the tool
itself, not just the preview -- same "the section is a ClickGui
window" move the landing's ThemeStrip already uses, applied to a
working tool this time.

- Removed the three uppercase eyebrow-style section labels (the same
  generic-tell pattern already fixed on the landing page); colour
  slots are grouped into named fieldsets (Window/Modules/Text) instead
- Panel split into panel/{PanelHeader,PresetPicker,PaletteTabs,
  ExportActions}.tsx to stay under the file-size limit
- Preset chips got a real swatch (accent dot over a mini card
  gradient) -- previously same-hued presets like Classic/Blue/Azure
  all read as identical blue dots
- Copy/share buttons confirm inline ("Скопировано"/"Ссылка
  скопирована"); import/clipboard failures now surface a message
  instead of failing silently
- Rewrote themes copy to match the landing's voice

Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
This commit is contained in:
loki5512344 2026-09-30 10:28:59 +02:00
parent fb853a26f4
commit c2c7e8fa91
Signed by: boba
GPG key ID: 253067914055423B
13 changed files with 793 additions and 246 deletions

View file

@ -1,32 +1,27 @@
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { type ReactNode, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@shared/ui/Button'
import { themeToCssVars } from '@features/clickgui/themeVars'
import { PRESETS } from '../presets.generated'
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
import type { ThemeEntry } from '../types'
import { EditorPreview } from './EditorPreview'
import { ColorField } from './fields/ColorField'
import { COLOR_SLOTS, GRADIENT_SLOTS, fromProfileJson, toProfileJson, toShareHash } from './model/codec'
import { GradientField } from './fields/GradientField'
import { useThemeHistory } from './model/useThemeHistory'
import { ExportActions } from './panel/ExportActions'
import { PaletteTabs } from './panel/PaletteTabs'
import { PanelHeader } from './panel/PanelHeader'
import { PresetPicker } from './panel/PresetPicker'
/** 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'
/** `intro` (page title + lead) sits on top of the controls column so the whole editor fits one screen. */
/**
* `intro` (page title + lead) sits on top of the controls column so the whole editor fits one screen.
* The controls column is itself a ClickGui window painted with the draft, so every edit shows up
* in the tool you are holding, not only in the preview.
*/
export function ThemeEditor({ initial, intro }: { initial: ThemeEntry; intro?: ReactNode }) {
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), [])
const vars = useMemo(() => themeToCssVars(draft), [draft])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@ -39,43 +34,11 @@ export function ThemeEditor({ initial, intro }: { initial: ThemeEntry; intro?: R
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 pickPreset = (id: string) => {
const preset = PRESETS.find((p) => p.id === id)
if (preset) reset(preset)
}
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 grid-cols-[minmax(0,1fr)] gap-6 py-6 [grid-template-areas:'intro'_'preview'_'controls'] lg:h-[calc(100svh-var(--topbar-h))] lg:min-h-[560px] lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:grid-rows-[auto_minmax(0,1fr)] lg:gap-x-8 lg:gap-y-4 lg:py-4 lg:[grid-template-areas:'preview_intro'_'preview_controls']">
{intro && <div className="[grid-area:intro]">{intro}</div>}
@ -83,85 +46,18 @@ export function ThemeEditor({ initial, intro }: { initial: ThemeEntry; intro?: R
<EditorPreview draft={draft} onPickPreset={pickPreset} />
</div>
<div className="flex min-h-0 flex-col gap-4 [grid-area:controls]">
<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>}
<section aria-label={t('editor.panel')} className="lv-editor-panel [grid-area:controls]" style={vars}>
<PanelHeader
draft={draft}
onRename={(name) => set({ ...draft, name, id: nameToId(name) })}
history={{ undo, redo, canUndo, canRedo, reset: () => reset(initial), resetName: initial.name }}
/>
<div className="lv-editor-scroll flex min-h-0 flex-1 flex-col gap-5 p-4 lg:overflow-y-auto">
<PresetPicker activeId={draft.id} onPick={pickPreset} />
<PaletteTabs draft={draft} onChange={set} />
</div>
<div className="lv-editor-scroll flex min-h-0 flex-1 flex-col gap-6 lg:-mr-3 lg:overflow-y-auto lg:pr-3">
<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-ctl 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 lg:grid-cols-1 xl: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>
<div className="grid grid-cols-2 gap-2 border-t border-white/10 pt-4 sm:grid-cols-4 lg:grid-cols-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>
<ExportActions draft={draft} onImport={reset} />
</section>
</div>
)
}

View file

@ -1,4 +1,6 @@
/* Theme editor: preview pane, HUD tiles, preset chips and color-field affordances. */
/* Theme editor: preview pane, the draft-painted controls panel and its field affordances.
Everything inside .lv-editor-panel reads the draft's --gui-* vars (set on the panel), so the
panel is a second ClickGui window wearing the theme being built. */
.lv-editor-preview { background: linear-gradient(180deg, #0b0f22, #070a18); }
/* On desktop the editor is one viewport tall: the stage is a size container and the ClickGui
@ -8,7 +10,11 @@
.lv-editor-stage { flex: 1; min-height: 0; container-type: size; display: grid; place-items: center; }
.lv-editor-gui { width: min(100cqw, 100cqh * 264 / 330); }
}
.lv-editor-scroll { scrollbar-width: thin; scrollbar-color: rgb(255 255 255 / 0.18) transparent; }
/* On desktop the controls scroll inside the panel; the bottom edge fades so it is clear there is more. */
@media (min-width: 1024px) {
.lv-editor-scroll { mask-image: linear-gradient(to bottom, #000 calc(100% - 2.5rem), transparent); padding-bottom: 2rem; }
}
.lv-editor-scroll { scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--gui-text) 22%, transparent) transparent; }
.lv-hud-tile {
display: inline-flex;
@ -25,76 +31,161 @@
.lv-hud-tile b { color: var(--gui-accent, #6cf); font-weight: 700; }
.lv-hud-tile-card b { color: var(--gui-text, #fff); }
/* The panel: window gradient, an accent glow in the corner, the theme's own outline. */
.lv-editor-panel {
--line: color-mix(in srgb, var(--gui-text) 14%, transparent);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
border-radius: 14px;
color: var(--gui-text);
background:
radial-gradient(90% 60% at 100% 0%, color-mix(in srgb, var(--gui-accent) 16%, transparent), transparent 70%),
linear-gradient(var(--gui-window-angle, 180deg), var(--gui-window-from), var(--gui-window-to)),
var(--color-abyss);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--gui-stroke-soft) 35%, transparent), 0 30px 80px -36px var(--gui-accent-glow);
transition-property: --gui-window-from, --gui-window-to, --gui-header-from, --gui-header-to, --gui-text,
--gui-text-muted, --gui-accent, --gui-accent-soft, --gui-accent-glow, --gui-stroke-soft;
transition-duration: 400ms;
}
.lv-editor-panel :focus-visible { outline: 2px solid var(--gui-accent); outline-offset: 2px; }
.lv-editor-head {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 0.6rem 0.55rem 1rem;
background: linear-gradient(var(--gui-header-angle, 90deg), var(--gui-header-from), var(--gui-header-to));
border-bottom: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 25%, transparent);
}
.lv-editor-dot {
width: 10px;
height: 10px;
flex: none;
border-radius: 50%;
background: var(--gui-accent);
box-shadow: 0 0 12px var(--gui-accent);
}
/* The theme name is the window title; a dotted underline says it can be typed into. */
.lv-editor-name {
flex: 1;
min-width: 0;
padding: 0.15rem 0;
background: transparent;
color: var(--gui-text);
font-family: var(--font-display);
font-size: 1.2rem;
font-weight: 700;
letter-spacing: -0.01em;
border-bottom: 1px dashed color-mix(in srgb, var(--gui-text) 28%, transparent);
}
.lv-editor-name:hover { border-bottom-color: color-mix(in srgb, var(--gui-text) 55%, transparent); }
.lv-editor-panel .lv-editor-name:focus-visible { outline: none; border-bottom: 1px solid var(--gui-accent); }
.lv-editor-tool {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 8px;
color: var(--gui-text-muted);
transition: background-color 150ms, color 150ms;
}
.lv-editor-tool:hover:not(:disabled) { background: color-mix(in srgb, var(--gui-text) 10%, transparent); color: var(--gui-text); }
.lv-editor-tool:disabled { opacity: 0.35; cursor: not-allowed; }
.lv-editor-tool svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* Preset chips: a round swatch of the preset (its accent dot on its window) plus the name. */
.lv-preset-chip {
padding: 4px 12px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 11px 3px 3px;
border-radius: 999px;
border: 1px solid rgb(255 255 255 / 0.15);
background: rgb(255 255 255 / 0.04);
color: var(--color-frost, #9fb0c0);
border: 1px solid var(--line);
color: var(--gui-text-muted);
font-size: 0.8rem;
transition: border-color 160ms ease, color 160ms ease;
transition: border-color 150ms, color 150ms, background-color 150ms;
}
.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-preset-chip:hover { color: var(--gui-text); border-color: color-mix(in srgb, var(--gui-text) 32%, transparent); }
.lv-preset-chip[aria-pressed="true"] {
color: var(--gui-text);
border-color: var(--gui-accent);
background: color-mix(in srgb, var(--gui-accent) 16%, transparent);
}
/* Alpha slider: `appearance: none` on the input alone leaves the UA thumb in Chrome,
so reset it on the pseudo-elements and paint our own. */
.lv-alpha-slider::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 14px;
height: 14px;
.lv-preset-mini {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-ice, #6cf);
border: 2px solid #0b0f22;
cursor: pointer;
}
.lv-alpha-slider::-moz-range-thumb {
appearance: none;
-moz-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--color-ice, #6cf);
border: 2px solid #0b0f22;
cursor: pointer;
/* Accent dot on the preset's own enabled-card over its window, so neighbours with a similar
accent (Classic, Blue, Azure) still read as different themes. */
background: radial-gradient(circle, var(--chip-accent) 0 28%, transparent 33%), var(--chip-card), var(--chip-window);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.18);
}
/* Native color input rendered as the checkerboard swatch itself: the real picker is
transparent and fills the swatch, so a click opens it without showing UA chrome. */
.lv-color-swatch {
/* Colors / Gradients switch, shaped like the mod's Modules / Settings pill: a thumb slides under the active tab. */
.lv-editor-tabs {
position: relative;
display: block;
width: 100%;
height: 100%;
display: inline-grid;
grid-template-columns: 1fr 1fr;
align-self: flex-start;
padding: 3px;
border-radius: 999px;
background: color-mix(in srgb, var(--gui-text) 7%, transparent);
box-shadow: inset 0 0 0 1px var(--line);
}
.lv-color-swatch input[type='color'] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
border: none;
padding: 0;
}
.lv-color-swatch::after {
.lv-editor-tabs::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--color-ice, #6cf) 70%, transparent);
opacity: 0;
transition: opacity 150ms ease;
inset: 3px auto 3px 3px;
width: calc(50% - 3px);
border-radius: 999px;
background: color-mix(in srgb, var(--gui-accent) 30%, transparent);
box-shadow: inset 0 0 0 1px var(--gui-accent);
transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
}
.lv-color-swatch:focus-within::after {
opacity: 1;
.lv-editor-tabs[data-tab="gradients"]::before { transform: translateX(100%); }
.lv-editor-tabs button {
position: relative;
padding: 0.35rem 1.1rem;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
color: var(--gui-text-muted);
transition: color 200ms;
}
.lv-editor-tabs button[aria-selected="true"] { color: var(--gui-text); }
.lv-editor-group { padding: 0; margin-bottom: 0.6rem; font-size: 0.85rem; font-weight: 600; color: var(--gui-text); }
/* Footer: the ways out. Copy is filled with the theme's accent; a confirmed action flips its label. */
.lv-editor-foot {
padding: 0.75rem 1rem 0.85rem;
border-top: 1px solid var(--line);
background: rgb(0 0 0 / 0.18);
}
.lv-editor-act {
padding: 0.6rem 0.75rem;
border-radius: var(--radius-ctl);
border: 1px solid color-mix(in srgb, var(--gui-text) 20%, transparent);
color: var(--gui-text);
font-size: 0.85rem;
font-weight: 600;
transition: border-color 150ms, background-color 150ms, box-shadow 200ms;
}
.lv-editor-act:hover { border-color: color-mix(in srgb, var(--gui-text) 45%, transparent); }
.lv-editor-act[data-primary="true"] { background: var(--gui-accent); border-color: var(--gui-accent); }
.lv-editor-act[data-primary="true"]:hover { background: color-mix(in srgb, var(--gui-accent) 88%, #fff); }
.lv-editor-act[data-done="true"] {
border-color: var(--gui-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--gui-accent) 30%, transparent);
animation: lv-act-done 320ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes lv-act-done { 40% { transform: scale(0.96); } }
.lv-editor-status { font-size: 0.8rem; color: var(--gui-text-muted); }
.lv-editor-status:not(:empty) { margin-top: 0.6rem; }
.lv-editor-status[data-error="true"] { color: var(--color-ember); }
@media (prefers-reduced-motion: reduce) {
.lv-editor-panel, .lv-editor-tabs::before, .lv-switch, .lv-switch::before { transition: none; }
.lv-editor-act[data-done="true"] { animation: none; }
}

View file

@ -1,5 +1,7 @@
import { useId, useState } from 'react'
import { type CSSProperties, useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { argbToCss, parseArgb } from '../../types'
import './fields.css'
type Props = { label: string; value: string; onChange: (hex: string) => void }
@ -15,6 +17,7 @@ const withRgb = (hex: string, a: number) => `#${pad2(a)}${toRgb6(hex).slice(1)}`
/** One editable palette slot: native color picker + alpha slider + hex field. */
export function ColorField({ label, value, onChange }: Props) {
const { t: tr } = useTranslation('themes')
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).
@ -29,7 +32,7 @@ export function ColorField({ label, value, onChange }: Props) {
return (
<label htmlFor={id} className="lv-color-field flex w-full min-w-0 items-center gap-2">
<span className="lv-alpha-check size-8 shrink-0 overflow-hidden rounded-md border border-white/15">
<span className="lv-alpha-check lv-swatch-well size-9 shrink-0 overflow-hidden rounded-md">
<span className="lv-color-swatch" style={{ background: argbToCss(value) }}>
<input
type="color"
@ -41,16 +44,17 @@ export function ColorField({ label, value, onChange }: Props) {
</span>
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-xs text-frost">{label}</span>
<span className="truncate text-xs text-[var(--gui-text-muted)]">{label}</span>
<span className="flex min-w-0 items-center gap-2">
<input
type="range"
min={0}
max={255}
aria-label={`${label} alpha`}
aria-label={tr('editor.alpha', { name: label })}
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"
className="lv-alpha-slider lv-alpha-ramp h-2 min-w-0 flex-1 cursor-pointer appearance-none rounded-full"
style={{ '--ramp': toRgb6(value) } as CSSProperties}
/>
<input
id={id}
@ -60,7 +64,7 @@ export function ColorField({ label, value, onChange }: Props) {
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-ctl border border-white/15 bg-black/40 px-2 py-1 font-code text-xs text-snow"
className="lv-hex-input lv-editor-input w-[6.25rem] shrink-0 px-2 py-1 font-code text-xs"
/>
</span>
</span>

View file

@ -1,47 +1,55 @@
import { useTranslation } from 'react-i18next'
import type { GradientSpec } from '../../types'
import { cssAngle } from '@features/clickgui/themeVars'
import { argbToCss, 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. */
/**
* A gradient slot. Its title bar is the gradient itself (at the mod's angle), so every tweak
* below is visible right where it is made; the switch turns the slot flat or back.
*/
export function GradientField({ title, value, onChange }: Props) {
const { t } = useTranslation('themes')
const strip = value.enabled
? `linear-gradient(${cssAngle(value.angle)}deg, ${argbToCss(value.start)}, ${argbToCss(value.end)})`
: argbToCss(value.start)
return (
<div className="lv-gradient-field flex min-w-0 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>
<div className="lv-gradient-field" data-on={value.enabled}>
<label className="lv-gradient-strip lv-alpha-check">
<span className="lv-gradient-paint" style={{ background: strip }} aria-hidden="true" />
<span className="lv-gradient-title">{title}</span>
<input
type="checkbox"
role="switch"
aria-label={t('editor.enabledFor', { name: title })}
checked={value.enabled}
onChange={(e) => onChange({ enabled: e.target.checked })}
className="lv-switch"
/>
</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="lv-alpha-slider 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 className="flex flex-col gap-2.5 p-3">
<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-[var(--gui-text-muted)]">
<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="lv-alpha-slider lv-track h-1.5 flex-1 cursor-pointer appearance-none rounded-full"
/>
<span className="w-10 shrink-0 text-right font-code text-[var(--gui-text)]">{Math.round(value.angle)}°</span>
</label>
</>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,120 @@
/* Color and gradient fields. They sit inside .lv-editor-panel and read the draft's --gui-* vars. */
.lv-editor-input {
border-radius: 8px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--gui-text) 6%, transparent);
color: var(--gui-text);
}
.lv-editor-panel .lv-editor-input:focus-visible { outline: none; border-color: var(--gui-accent); }
.lv-swatch-well { border: 1px solid color-mix(in srgb, var(--gui-text) 24%, transparent); }
/* Checkerboard behind swatches and the alpha ramp, 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;
}
/* The alpha slider's track is the color itself fading out, so the handle's position explains itself. */
.lv-alpha-ramp {
background:
linear-gradient(90deg, transparent, var(--ramp)),
linear-gradient(45deg, #555 25%, transparent 25%, transparent 75%, #555 75%) 0 0 / 8px 8px,
linear-gradient(45deg, #555 25%, transparent 25%, transparent 75%, #555 75%) 4px 4px / 8px 8px,
#2a2e35;
box-shadow: inset 0 0 0 1px var(--line);
}
.lv-track { background: color-mix(in srgb, var(--gui-text) 18%, transparent); }
/* Slider thumbs: `appearance: none` on the input alone leaves the UA thumb in Chrome,
so reset it on the pseudo-elements and paint our own. */
.lv-alpha-slider::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--gui-text, #fff);
border: 3px solid var(--gui-accent, var(--color-ice));
cursor: pointer;
}
.lv-alpha-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--gui-text, #fff);
border: 3px solid var(--gui-accent, var(--color-ice));
cursor: pointer;
}
/* Native color input rendered as the checkerboard swatch itself: the real picker is
transparent and fills the swatch, so a click opens it without showing UA chrome. */
.lv-color-swatch { position: relative; display: block; width: 100%; height: 100%; }
.lv-color-swatch input[type='color'] { position: absolute; inset: 0; opacity: 0; cursor: pointer; border: none; padding: 0; }
.lv-color-swatch::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 2px var(--gui-accent);
opacity: 0;
transition: opacity 150ms ease;
}
.lv-color-swatch:focus-within::after { opacity: 1; }
/* Gradient slot: the title bar is painted with the gradient itself; a switched-off slot fades back. */
.lv-gradient-field {
min-width: 0;
overflow: hidden;
border-radius: 10px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--gui-text) 3%, transparent);
}
.lv-gradient-strip {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
height: 3rem;
padding: 0 0.75rem;
cursor: pointer;
}
.lv-gradient-paint { position: absolute; inset: 0; transition: opacity 200ms; }
.lv-gradient-field[data-on="false"] .lv-gradient-paint { opacity: 0.3; }
.lv-gradient-title {
position: relative;
padding: 0.15rem 0.55rem;
border-radius: 6px;
background: rgb(0 0 0 / 0.5);
color: #fff;
font-size: 0.85rem;
font-weight: 700;
}
.lv-switch {
position: relative;
flex: none;
width: 2.25rem;
height: 1.3rem;
appearance: none;
border-radius: 999px;
background: rgb(0 0 0 / 0.55);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.3);
cursor: pointer;
transition: background-color 180ms;
}
.lv-switch::before {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: calc(1.3rem - 6px);
height: calc(1.3rem - 6px);
border-radius: 50%;
background: #fff;
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.lv-switch:checked { background: var(--gui-accent); }
.lv-switch:checked::before { transform: translateX(0.95rem); }

View file

@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { parseArgb, type ThemeEntry } from '../../types'
import { fromProfileJson, toProfileJson, toShareHash } from '../model/codec'
type Props = { draft: ThemeEntry; onImport: (entry: ThemeEntry) => void }
/** Which button just worked (its label flips to the confirmation) or what to tell the player. */
type Status = { done?: 'copy' | 'share'; message?: string; error?: boolean }
/** Dark or light ink for text sitting on the theme's accent, whichever reads better. */
function inkOn(accent: string): string {
const { r, g, b } = parseArgb(accent)
const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
return luma > 0.55 ? '#0d1416' : '#fff'
}
/** The panel footer: get the theme out (clipboard, file, link) or bring one in from a file. */
export function ExportActions({ draft, onImport }: Props) {
const { t } = useTranslation('themes')
const [status, setStatus] = useState<Status>({})
const fileRef = useRef<HTMLInputElement>(null)
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
useEffect(() => () => clearTimeout(timer.current), [])
const show = (next: Status, ms = 2000) => {
setStatus(next)
clearTimeout(timer.current)
timer.current = setTimeout(() => setStatus({}), ms)
}
const toClipboard = async (text: string, done: 'copy' | 'share') => {
try {
if (!navigator.clipboard) throw new Error('no clipboard')
await navigator.clipboard.writeText(text)
show({ done, message: t(done === 'copy' ? 'editor.copied' : 'editor.shared') })
} catch {
show({ message: t('editor.clipboardError'), error: true }, 6000)
}
}
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 onFile = (file?: File) => {
if (!file) return
file.text().then((text) => {
const result = fromProfileJson(text)
if ('error' in result) {
show({ message: t('editor.importError'), error: true }, 6000)
return
}
onImport(result)
show({ message: t('editor.imported', { name: result.name }) })
})
}
return (
<div className="lv-editor-foot">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-2">
<button
type="button"
onClick={() => toClipboard(toProfileJson(draft), 'copy')}
className="lv-editor-act"
data-primary="true"
data-done={status.done === 'copy'}
style={{ color: inkOn(draft.theme.accent) }}
>
{status.done === 'copy' ? t('editor.copied') : t('editor.copy')}
</button>
<button type="button" onClick={download} className="lv-editor-act">
{t('editor.download')}
</button>
<button type="button" onClick={() => fileRef.current?.click()} className="lv-editor-act">
{t('editor.import')}
</button>
<button
type="button"
onClick={() => toClipboard(`${location.origin}${location.pathname}#t=${toShareHash(draft)}`, 'share')}
className="lv-editor-act"
data-done={status.done === 'share'}
>
{status.done === 'share' ? t('editor.shared') : t('editor.share')}
</button>
<input
ref={fileRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(e) => {
onFile(e.target.files?.[0])
e.target.value = '' // so picking the same file again still fires
}}
/>
</div>
{/* Copy and share confirm on the button itself; the line only speaks for them to screen readers. */}
<p role="status" className={`lv-editor-status ${status.done ? 'sr-only' : ''}`} data-error={status.error ?? false}>
{status.message ?? ''}
</p>
</div>
)
}

View file

@ -0,0 +1,82 @@
import { type KeyboardEvent, useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { GradientSpec, ThemeColors, ThemeEntry } from '../../types'
import { ColorField } from '../fields/ColorField'
import { GradientField } from '../fields/GradientField'
import { GRADIENT_SLOTS } from '../model/codec'
// The 12 slots grouped by the part of the menu they paint. Together they cover every
// COLOR_SLOTS entry once; the export order is still the codec's, this is display only.
const GROUPS: { key: 'window' | 'modules' | 'text'; slots: (keyof ThemeColors)[] }[] = [
{ key: 'window', slots: ['windowBg', 'windowHeader', 'windowStroke', 'strokeSoft'] },
{ key: 'modules', slots: ['surface', 'surfaceHover', 'cardEnabled', 'cardDisabled'] },
{ key: 'text', slots: ['textPrimary', 'textMuted', 'accent', 'accentSoft'] },
]
const TABS = ['colors', 'gradients'] as const
type Tab = (typeof TABS)[number]
type Props = { draft: ThemeEntry; onChange: (next: ThemeEntry) => void }
/** Flat colors and gradients behind a two-way switch shaped like the mod's own tab pill. */
export function PaletteTabs({ draft, onChange }: Props) {
const { t } = useTranslation('themes')
const [tab, setTab] = useState<Tab>('colors')
const base = useId()
const setColor = (slot: keyof ThemeColors, hex: string) => onChange({ ...draft, theme: { ...draft.theme, [slot]: hex } })
const setGradient = (key: keyof ThemeEntry, patch: Partial<GradientSpec>) =>
onChange({ ...draft, [key]: { ...(draft[key] as GradientSpec), ...patch } })
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
const next = tab === 'colors' ? 'gradients' : 'colors'
setTab(next)
document.getElementById(`${base}-${next}-tab`)?.focus()
}
return (
<div className="flex flex-col gap-4">
<div role="tablist" aria-label={t('editor.palette')} className="lv-editor-tabs" data-tab={tab} onKeyDown={onKey}>
{TABS.map((key) => (
<button
key={key}
id={`${base}-${key}-tab`}
type="button"
role="tab"
aria-selected={tab === key}
aria-controls={`${base}-${key}`}
tabIndex={tab === key ? 0 : -1}
onClick={() => setTab(key)}
>
{t(`editor.${key}`)}
</button>
))}
</div>
<div id={`${base}-colors`} role="tabpanel" aria-labelledby={`${base}-colors-tab`} hidden={tab !== 'colors'} className="flex flex-col gap-5">
{GROUPS.map((group) => (
<fieldset key={group.key} className="flex min-w-0 flex-col gap-2.5">
<legend className="lv-editor-group">{t(`editor.group.${group.key}`)}</legend>
<div className="grid gap-x-4 gap-y-2.5 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
{group.slots.map((slot) => (
<ColorField key={slot} label={t(`colors.${slot}`)} value={draft.theme[slot]} onChange={(hex) => setColor(slot, hex)} />
))}
</div>
</fieldset>
))}
</div>
<div id={`${base}-gradients`} role="tabpanel" aria-labelledby={`${base}-gradients-tab`} hidden={tab !== 'gradients'} className="flex flex-col 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>
</div>
)
}

View file

@ -0,0 +1,56 @@
import { useTranslation } from 'react-i18next'
import type { ThemeEntry } from '../../types'
type History = { undo: () => void; redo: () => void; reset: () => void; resetName: string; canUndo: boolean; canRedo: boolean }
type Props = { draft: ThemeEntry; onRename: (name: string) => void; history: History }
const ICONS = {
undo: 'M9 14 4 9l5-5M4 9h10.5a5.5 5.5 0 0 1 0 11H11',
redo: 'm15 14 5-5-5-5M20 9H9.5a5.5 5.5 0 0 0 0 11H13',
reset: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
} as const
/**
* The panel's title bar, painted with the draft's header gradient like the mod's window header.
* The theme name is the title itself and is edited in place; history controls sit on the right.
*/
export function PanelHeader({ draft, onRename, history }: Props) {
const { t } = useTranslation('themes')
const tools = [
{ key: 'undo', label: t('editor.undo'), keys: 'Control+Z', run: history.undo, enabled: history.canUndo },
{ key: 'redo', label: t('editor.redo'), keys: 'Control+Shift+Z', run: history.redo, enabled: history.canRedo },
{ key: 'reset', label: t('editor.reset', { name: history.resetName }), keys: undefined, run: history.reset, enabled: true },
] as const
return (
<div className="lv-editor-head">
<span className="lv-editor-dot" aria-hidden="true" />
<input
value={draft.name}
onChange={(e) => onRename(e.target.value)}
aria-label={t('editor.nameLabel')}
title={t('editor.nameLabel')}
spellCheck={false}
className="lv-editor-name"
/>
<div className="flex shrink-0 items-center gap-1">
{tools.map((tool) => (
<button
key={tool.key}
type="button"
onClick={tool.run}
disabled={!tool.enabled}
aria-label={tool.label}
aria-keyshortcuts={tool.keys}
title={tool.keys ? `${tool.label} (${tool.keys.replace('Control', 'Ctrl')})` : tool.label}
className="lv-editor-tool"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d={ICONS[tool.key]} />
</svg>
</button>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,45 @@
import type { CSSProperties } from 'react'
import { useTranslation } from 'react-i18next'
import { themeToCssVars } from '@features/clickgui/themeVars'
import { PRESETS } from '../../presets.generated'
// Each chip's swatch is painted with its own preset, so the list reads as a palette before anything is
// clicked. The chip's text and outline still follow the draft, like the rest of the panel.
// A flat color can only be the last background layer, so the card is always passed as a gradient.
const asLayer = (paint: string) => (paint.includes('gradient(') ? paint : `linear-gradient(${paint}, ${paint})`)
const CHIPS = PRESETS.map((p) => {
const v = themeToCssVars(p)
const vars = {
'--chip-window': v['--gui-window-gradient'],
'--chip-card': asLayer(v['--gui-card-gradient']),
'--chip-accent': v['--gui-accent'],
}
return { id: p.id, name: p.name, vars: vars as CSSProperties }
})
type Props = { activeId: string; onPick: (id: string) => void }
/** The mod's built-in themes as starting points: a tiny window swatch plus the name. */
export function PresetPicker({ activeId, onPick }: Props) {
const { t } = useTranslation('themes')
return (
<ul aria-label={t('editor.presets')} className="flex flex-wrap gap-1.5">
{CHIPS.map((chip) => (
<li key={chip.id}>
<button
type="button"
onClick={() => onPick(chip.id)}
aria-pressed={chip.id === activeId}
className="lv-preset-chip"
style={chip.vars}
>
<span className="lv-preset-mini" aria-hidden="true" />
{chip.name}
</button>
</li>
))}
</ul>
)
}

View file

@ -5,11 +5,22 @@ import { PRESETS } from '../../presets.generated'
import { ActiveThemeProvider, useActiveTheme } from '../../useActiveTheme'
import { ThemeEditor } from '../ThemeEditor'
test('editor shows the initial preset name and one toggle per gradient slot', () => {
test('editor shows the initial preset name and one switch per gradient slot behind the Gradients tab', async () => {
const user = userEvent.setup()
render(<ThemeEditor initial={PRESETS[0]} />)
const name = screen.getByDisplayValue('Classic')
expect(name).toBeInTheDocument()
expect(screen.getAllByRole('checkbox', { name: /Градиент/ })).toHaveLength(5)
expect(screen.getByRole('textbox', { name: 'Название темы' })).toHaveValue('Classic')
expect(screen.queryAllByRole('switch')).toHaveLength(0)
await user.click(screen.getByRole('tab', { name: 'Градиенты' }))
expect(screen.getByRole('tab', { name: 'Градиенты' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getAllByRole('switch', { name: /^Градиент: / })).toHaveLength(5)
})
test('section labels are plain sentence-case legends, not uppercase headings', () => {
render(<ThemeEditor initial={PRESETS[0]} />)
const panel = screen.getByRole('region', { name: 'Настройки темы' })
expect(panel.querySelectorAll('h3')).toHaveLength(0)
expect(panel.querySelector('.uppercase')).toBeNull()
expect(within(panel).getByRole('group', { name: 'Окно' })).toBeInTheDocument()
})
test('picking a preset chip loads it into the editor', () => {

View file

@ -0,0 +1,106 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { PRESETS } from '../../presets.generated'
import { ThemeEditor } from '../ThemeEditor'
import { fromShareHash, toProfileJson } from '../model/codec'
const nameBox = () => screen.getByRole('textbox', { name: 'Название темы' })
test('undo and redo walk the history from the header buttons and from Ctrl+Z / Ctrl+Shift+Z', async () => {
const user = userEvent.setup()
render(<ThemeEditor initial={PRESETS[0]} />)
const undo = screen.getByRole('button', { name: 'Отменить' })
const redo = screen.getByRole('button', { name: 'Вернуть' })
expect(undo).toBeDisabled()
// A preset pick starts a fresh history; an edit (typing, one coalesced step) is what undo reverts.
const edited = `${PRESETS[0].name} 2`
await user.type(nameBox(), ' 2')
expect(nameBox()).toHaveValue(edited)
expect(undo).toBeEnabled()
await user.click(undo)
expect(nameBox()).toHaveValue(PRESETS[0].name)
await user.click(redo)
expect(nameBox()).toHaveValue(edited)
await user.keyboard('{Control>}z{/Control}')
expect(nameBox()).toHaveValue(PRESETS[0].name)
await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}')
expect(nameBox()).toHaveValue(edited)
})
test('reset names the theme it returns to and restores it', async () => {
const user = userEvent.setup()
render(<ThemeEditor initial={PRESETS[0]} />)
await user.type(nameBox(), ' edited')
await user.click(screen.getByRole('button', { name: `Сбросить до «${PRESETS[0].name}»` }))
expect(nameBox()).toHaveValue(PRESETS[0].name)
})
test('renaming the theme updates the exported name and id', async () => {
const user = userEvent.setup()
render(<ThemeEditor initial={PRESETS[0]} />)
await user.clear(nameBox())
await user.type(nameBox(), 'My Night')
await user.click(screen.getByRole('button', { name: 'Скопировать для мода' }))
expect(JSON.parse(await navigator.clipboard.readText()).name).toBe('My Night')
})
test('copy puts the profile JSON on the clipboard and the button confirms', async () => {
const user = userEvent.setup()
render(<ThemeEditor initial={PRESETS[1]} />)
await user.click(screen.getByRole('button', { name: 'Скопировать для мода' }))
expect(await navigator.clipboard.readText()).toBe(toProfileJson(PRESETS[1]))
expect(screen.getByRole('button', { name: 'Скопировано' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Скопировано')
})
test('share copies a link whose hash decodes back to the draft', async () => {
const user = userEvent.setup()
render(<ThemeEditor initial={PRESETS[4]} />)
await user.click(screen.getByRole('button', { name: 'Скопировать ссылку' }))
const link = await navigator.clipboard.readText()
const hash = /#t=(.+)$/.exec(link)?.[1] ?? ''
expect(fromShareHash(hash)).toMatchObject({ name: PRESETS[4].name, theme: PRESETS[4].theme })
expect(screen.getByRole('button', { name: 'Ссылка скопирована' })).toBeInTheDocument()
})
test('a blocked clipboard says what to do instead', async () => {
const user = userEvent.setup()
render(<ThemeEditor initial={PRESETS[0]} />)
vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValueOnce(new Error('denied'))
await user.click(screen.getByRole('button', { name: 'Скопировать для мода' }))
expect(await screen.findByRole('status')).toHaveTextContent('Скачай .json')
expect(screen.getByRole('button', { name: 'Скопировать для мода' })).toBeInTheDocument()
})
test('download saves the profile JSON as <id>.json', async () => {
const user = userEvent.setup()
const createUrl = vi.fn(() => 'blob:theme')
Object.assign(URL, { createObjectURL: createUrl, revokeObjectURL: vi.fn() })
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
expect(this.download).toBe(`${PRESETS[0].id}.json`)
})
render(<ThemeEditor initial={PRESETS[0]} />)
await user.click(screen.getByRole('button', { name: 'Скачать .json' }))
expect(click).toHaveBeenCalledOnce()
const blob = (createUrl.mock.calls[0] as unknown as [Blob])[0]
expect(await blob.text()).toBe(toProfileJson(PRESETS[0]))
click.mockRestore()
})
test('loading a file imports a valid theme and explains a broken one', async () => {
const user = userEvent.setup()
const { container } = render(<ThemeEditor initial={PRESETS[0]} />)
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!
await user.upload(input, new File([toProfileJson(PRESETS[5])], 'theme.json', { type: 'application/json' }))
expect(await screen.findByRole('status')).toHaveTextContent(`Загружена тема «${PRESETS[5].name}»`)
expect(nameBox()).toHaveValue(PRESETS[5].name)
await user.upload(input, new File(['not json'], 'broken.json', { type: 'application/json' }))
expect(await screen.findByText(/Это не тема LoVisual/)).toBeInTheDocument()
expect(nameBox()).toHaveValue(PRESETS[5].name)
})

View file

@ -19,12 +19,20 @@ export const themesEn = {
},
editor: {
title: 'Theme editor',
lead: 'Build your own theme and bring it into the mod as a single file.',
lead: 'Start from any theme in the mod and change the colors: the menu and this panel repaint as you go.',
panel: 'Theme settings',
nameLabel: 'Theme name',
presets: 'Ready presets',
presets: 'Themes from the mod',
palette: 'What to edit',
colors: 'Colors',
gradients: 'Gradients',
enabled: 'Gradient',
group: {
window: 'Window',
modules: 'Modules',
text: 'Text and accent',
},
alpha: '{{name}}: opacity',
enabledFor: 'Gradient: {{name}}',
angle: 'Angle',
start: 'Start',
end: 'End',
@ -38,12 +46,14 @@ export const themesEn = {
copy: 'Copy for the mod',
copied: 'Copied',
download: 'Download .json',
import: 'Import from file',
share: 'Share link',
import: 'Load from file',
imported: 'Loaded the {{name}} theme',
share: 'Copy link',
shared: 'Link copied',
reset: 'Reset',
reset: 'Reset to {{name}}',
undo: 'Undo',
redo: 'Redo',
importError: 'Could not read the theme: the file is malformed.',
importError: "That isn't a LoVisual theme: the file can't be read. Use a .json downloaded from this editor.",
clipboardError: 'The browser blocked the clipboard. Download the .json and move the file instead.',
},
} satisfies Translation<typeof themesRu>

View file

@ -16,12 +16,20 @@ export const themesRu = {
},
editor: {
title: 'Редактор тем',
lead: 'Собери свою тему и перенеси её в мод одним файлом.',
lead: 'Бери любую тему мода за основу и крути цвета: меню и эта панель перекрашиваются сразу.',
panel: 'Настройки темы',
nameLabel: 'Название темы',
presets: 'Готовые пресеты',
presets: 'Темы мода',
palette: 'Что настраивать',
colors: 'Цвета',
gradients: 'Градиенты',
enabled: 'Градиент',
group: {
window: 'Окно',
modules: 'Модули',
text: 'Текст и акцент',
},
alpha: '{{name}}: прозрачность',
enabledFor: 'Градиент: {{name}}',
angle: 'Угол',
start: 'Начало',
end: 'Конец',
@ -35,12 +43,14 @@ export const themesRu = {
copy: 'Скопировать для мода',
copied: 'Скопировано',
download: 'Скачать .json',
import: 'Импорт из файла',
share: 'Ссылка на тему',
import: 'Загрузить из файла',
imported: 'Загружена тема «{{name}}»',
share: 'Скопировать ссылку',
shared: 'Ссылка скопирована',
reset: 'Сбросить',
reset: 'Сбросить до «{{name}}»',
undo: 'Отменить',
redo: 'Вернуть',
importError: 'Не удалось прочитать тему: файл испорчен.',
importError: 'Это не тема LoVisual: файл не читается. Нужен .json, скачанный из этого редактора.',
clipboardError: 'Браузер не дал доступ к буферу обмена. Скачай .json и перенеси файл.',
},
} as const