refactor(decnet_web/Config): extract AppearanceTab
APPEARANCE panel — accent-color picker — into its own tab. State is local since no other tab cares about the value; localStorage persistence + the document.documentElement[data-accent] mirror move along with it. - New Config/tabs/AppearanceTab.tsx - AppearanceTab.test.tsx covers the matrix default, reading the saved accent from localStorage on mount, and the click-to-flip flow writing both localStorage and the html data-accent attr.
This commit is contained in:
35
decnet_web/src/components/Config/tabs/AppearanceTab.test.tsx
Normal file
35
decnet_web/src/components/Config/tabs/AppearanceTab.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { renderWithRouter } from '../../../test/renderWithRouter';
|
||||||
|
import { AppearanceTab } from './AppearanceTab';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.removeAttribute('data-accent');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AppearanceTab', () => {
|
||||||
|
it('starts with the matrix accent by default', () => {
|
||||||
|
renderWithRouter(<AppearanceTab />);
|
||||||
|
expect(screen.getByText('● MATRIX')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('○ VIOLET')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the saved accent from localStorage on mount', () => {
|
||||||
|
localStorage.setItem('decnet_tweaks', JSON.stringify({ accent: 'violet' }));
|
||||||
|
renderWithRouter(<AppearanceTab />);
|
||||||
|
expect(screen.getByText('● VIOLET')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switching to violet writes localStorage + the data-accent attribute', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithRouter(<AppearanceTab />);
|
||||||
|
await user.click(screen.getByText('○ VIOLET'));
|
||||||
|
|
||||||
|
expect(screen.getByText('● VIOLET')).toBeInTheDocument();
|
||||||
|
expect(document.documentElement.getAttribute('data-accent')).toBe('violet');
|
||||||
|
const stored = JSON.parse(localStorage.getItem('decnet_tweaks') ?? '{}');
|
||||||
|
expect(stored.accent).toBe('violet');
|
||||||
|
});
|
||||||
|
});
|
||||||
78
decnet_web/src/components/Config/tabs/AppearanceTab.tsx
Normal file
78
decnet_web/src/components/Config/tabs/AppearanceTab.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useToast } from '../../Toasts/useToast';
|
||||||
|
|
||||||
|
const TWEAKS_KEY = 'decnet_tweaks';
|
||||||
|
|
||||||
|
export type Accent = 'matrix' | 'violet';
|
||||||
|
|
||||||
|
const loadInitialAccent = (): Accent => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(TWEAKS_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed?.accent === 'violet') return 'violet';
|
||||||
|
}
|
||||||
|
} catch { /* noop */ }
|
||||||
|
return 'matrix';
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistAccent = (value: Accent): void => {
|
||||||
|
let existing: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(TWEAKS_KEY);
|
||||||
|
if (raw) existing = JSON.parse(raw) ?? {};
|
||||||
|
} catch { existing = {}; }
|
||||||
|
localStorage.setItem(TWEAKS_KEY, JSON.stringify({ ...existing, accent: value }));
|
||||||
|
document.documentElement.setAttribute('data-accent', value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** APPEARANCE tab — accent-color picker. State is owned here because
|
||||||
|
* no other Config tab cares about the choice; it persists to
|
||||||
|
* localStorage and flips the data-accent attribute on <html>. */
|
||||||
|
export const AppearanceTab: React.FC = () => {
|
||||||
|
const [accent, setAccent] = useState<Accent>(loadInitialAccent);
|
||||||
|
const { push: pushToast } = useToast();
|
||||||
|
|
||||||
|
const handleAccentChange = (value: Accent) => {
|
||||||
|
setAccent(value);
|
||||||
|
persistAccent(value);
|
||||||
|
pushToast({ text: `ACCENT · ${value.toUpperCase()}`, icon: 'check-circle', tone: 'violet' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="config-panel">
|
||||||
|
<div className="config-field">
|
||||||
|
<span className="config-label">ACCENT COLOR</span>
|
||||||
|
<p style={{ fontSize: '0.75rem', opacity: 0.5, margin: '4px 0 12px' }}>
|
||||||
|
Swaps the UI accent (nav bars, hover glows, chip borders) between matrix-green and electric-violet. Persists per-browser.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
{(['matrix', 'violet'] as const).map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAccentChange(value)}
|
||||||
|
className="save-btn"
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
letterSpacing: '1.5px',
|
||||||
|
borderColor: accent === value
|
||||||
|
? (value === 'violet' ? 'var(--violet)' : 'var(--matrix)')
|
||||||
|
: 'var(--border)',
|
||||||
|
color: accent === value
|
||||||
|
? (value === 'violet' ? 'var(--violet)' : 'var(--matrix)')
|
||||||
|
: 'var(--matrix)',
|
||||||
|
opacity: accent === value ? 1 : 0.6,
|
||||||
|
background: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{accent === value ? '● ' : '○ '}
|
||||||
|
{value.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user