From c2c7e8fa91d50557df4291f9eeac8f90d671695f Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Wed, 30 Sep 2026 10:28:59 +0200 Subject: [PATCH] feat(frontend): redesign the theme editor page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../features/themes/editor/ThemeEditor.tsx | 152 ++----------- .../src/features/themes/editor/editor.css | 213 +++++++++++++----- .../themes/editor/fields/ColorField.tsx | 16 +- .../themes/editor/fields/GradientField.tsx | 74 +++--- .../features/themes/editor/fields/fields.css | 120 ++++++++++ .../themes/editor/panel/ExportActions.tsx | 108 +++++++++ .../themes/editor/panel/PaletteTabs.tsx | 82 +++++++ .../themes/editor/panel/PanelHeader.tsx | 56 +++++ .../themes/editor/panel/PresetPicker.tsx | 45 ++++ .../themes/editor/tests/editor.test.tsx | 19 +- .../themes/editor/tests/panel.test.tsx | 106 +++++++++ frontend/src/features/themes/i18n/en.ts | 24 +- frontend/src/features/themes/i18n/ru.ts | 24 +- 13 files changed, 793 insertions(+), 246 deletions(-) create mode 100644 frontend/src/features/themes/editor/fields/fields.css create mode 100644 frontend/src/features/themes/editor/panel/ExportActions.tsx create mode 100644 frontend/src/features/themes/editor/panel/PaletteTabs.tsx create mode 100644 frontend/src/features/themes/editor/panel/PanelHeader.tsx create mode 100644 frontend/src/features/themes/editor/panel/PresetPicker.tsx create mode 100644 frontend/src/features/themes/editor/tests/panel.test.tsx diff --git a/frontend/src/features/themes/editor/ThemeEditor.tsx b/frontend/src/features/themes/editor/ThemeEditor.tsx index 2a66141..297e0d2 100644 --- a/frontend/src/features/themes/editor/ThemeEditor.tsx +++ b/frontend/src/features/themes/editor/ThemeEditor.tsx @@ -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(null) - const fileRef = useRef(null) - const timer = useRef>(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) => - 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 (
{intro &&
{intro}
} @@ -83,85 +46,18 @@ export function ThemeEditor({ initial, intro }: { initial: ThemeEntry; intro?: R
-
-
- - - - {notice && {notice}} +
+ set({ ...draft, name, id: nameToId(name) })} + history={{ undo, redo, canUndo, canRedo, reset: () => reset(initial), resetName: initial.name }} + /> +
+ +
- -
- - -
-

{t('editor.presets')}

-
- {PRESETS.map((p) => ( - - ))} -
-
- -
-

{t('editor.colors')}

-
- {COLOR_SLOTS.map((slot) => ( - setColor(slot, hex)} /> - ))} -
-
- -
-

{t('editor.gradients')}

-
- {GRADIENT_SLOTS.map((g) => ( - setGradient(g.key, patch)} - /> - ))} -
-
- -
- -
- - - - - onFile(e.target.files?.[0])} /> -
-
+ +
) } diff --git a/frontend/src/features/themes/editor/editor.css b/frontend/src/features/themes/editor/editor.css index 4f02e41..b504b2b 100644 --- a/frontend/src/features/themes/editor/editor.css +++ b/frontend/src/features/themes/editor/editor.css @@ -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; } } diff --git a/frontend/src/features/themes/editor/fields/ColorField.tsx b/frontend/src/features/themes/editor/fields/ColorField.tsx index 304913f..b194157 100644 --- a/frontend/src/features/themes/editor/fields/ColorField.tsx +++ b/frontend/src/features/themes/editor/fields/ColorField.tsx @@ -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 (