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:
parent
fb853a26f4
commit
c2c7e8fa91
13 changed files with 793 additions and 246 deletions
106
frontend/src/features/themes/editor/tests/panel.test.tsx
Normal file
106
frontend/src/features/themes/editor/tests/panel.test.tsx
Normal 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)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue