feat(decnet_web/theme-lab): light theme tokens + dev toggle
Adds html[data-theme="light"] block to index.css overriding the core six tokens (bg, matrix, violet, panel, border, alert), the matrix/violet/alert tints, and the foreground opacity ramp to a cream-on-ink palette anchored on #dbdad6. Glows are no-op'd — light mode trades neon haloes for hard 1px borders. Lab page gets a Dark/Light toggle that flips html.dataset.theme and persists to sessionStorage (decnet_theme_lab) — intentionally tab-scoped, not user-facing. App.tsx hydrates the same key on boot so a tab reload keeps the dev's chosen theme. The user-facing localStorage toggle ships later via Config.
This commit is contained in:
@@ -8,6 +8,19 @@
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.lab-page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lab-theme-toggle {
|
||||
display: inline-flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.theme-lab .page-header h1 {
|
||||
font-size: var(--fs-page);
|
||||
letter-spacing: var(--ls-title);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import './ThemeLab.css';
|
||||
|
||||
/* Kitchen-sink theme lab.
|
||||
@@ -314,13 +315,14 @@ const NetBoxes: React.FC = () => (
|
||||
const ThemeLab: React.FC = () => {
|
||||
return (
|
||||
<div className="fleet-root theme-lab" data-testid="theme-lab">
|
||||
<header className="page-header">
|
||||
<header className="page-header lab-page-header">
|
||||
<div className="page-title-group">
|
||||
<h1>THEME LAB</h1>
|
||||
<span className="page-sub">
|
||||
dev only · primitive zoo for theme regression
|
||||
</span>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
<Section id="swatches" title="COLOUR TOKENS">
|
||||
|
||||
57
decnet_web/src/components/ThemeLab/ThemeToggle.tsx
Normal file
57
decnet_web/src/components/ThemeLab/ThemeToggle.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
/* Dev-scoped Dark/Light theme toggle for the theme lab.
|
||||
*
|
||||
* Flips `document.documentElement.dataset.theme` and persists to
|
||||
* **sessionStorage** intentionally — the lab is a tab-scoped
|
||||
* exploration tool. Global persistence (across reloads, all users)
|
||||
* is the user-facing Config toggle that ships in Task 6. */
|
||||
|
||||
export const THEME_SESSION_KEY = 'decnet_theme_lab';
|
||||
|
||||
export type Theme = 'dark' | 'light';
|
||||
|
||||
export function readLabTheme(): Theme {
|
||||
try {
|
||||
const v = sessionStorage.getItem(THEME_SESSION_KEY);
|
||||
return v === 'light' ? 'light' : 'dark';
|
||||
} catch {
|
||||
return 'dark';
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme): void {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}
|
||||
|
||||
const ThemeToggle: React.FC = () => {
|
||||
const [theme, setTheme] = useState<Theme>(() => readLabTheme());
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
try { sessionStorage.setItem(THEME_SESSION_KEY, theme); } catch { /* ignore */ }
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<div className="lab-theme-toggle" role="group" aria-label="Theme">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn small ${theme === 'dark' ? '' : 'ghost'}`}
|
||||
onClick={() => setTheme('dark')}
|
||||
aria-pressed={theme === 'dark'}
|
||||
>
|
||||
DARK
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn small ${theme === 'light' ? '' : 'ghost'}`}
|
||||
onClick={() => setTheme('light')}
|
||||
aria-pressed={theme === 'light'}
|
||||
>
|
||||
LIGHT
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeToggle;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ThemeToggle, {
|
||||
THEME_SESSION_KEY,
|
||||
readLabTheme,
|
||||
applyTheme,
|
||||
} from '../ThemeToggle';
|
||||
|
||||
describe('ThemeToggle', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
delete document.documentElement.dataset.theme;
|
||||
});
|
||||
|
||||
it('defaults to dark and applies dark on mount', () => {
|
||||
render(<ThemeToggle />);
|
||||
expect(document.documentElement.dataset.theme).toBe('dark');
|
||||
expect(sessionStorage.getItem(THEME_SESSION_KEY)).toBe('dark');
|
||||
expect(screen.getByRole('button', { name: 'DARK' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('flips to light on click and persists to sessionStorage', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ThemeToggle />);
|
||||
await user.click(screen.getByRole('button', { name: 'LIGHT' }));
|
||||
expect(document.documentElement.dataset.theme).toBe('light');
|
||||
expect(sessionStorage.getItem(THEME_SESSION_KEY)).toBe('light');
|
||||
expect(screen.getByRole('button', { name: 'LIGHT' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('hydrates from existing sessionStorage value', () => {
|
||||
sessionStorage.setItem(THEME_SESSION_KEY, 'light');
|
||||
render(<ThemeToggle />);
|
||||
expect(document.documentElement.dataset.theme).toBe('light');
|
||||
});
|
||||
|
||||
it('readLabTheme falls back to dark on bad value', () => {
|
||||
sessionStorage.setItem(THEME_SESSION_KEY, 'banana');
|
||||
expect(readLabTheme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('applyTheme writes the html attribute', () => {
|
||||
applyTheme('light');
|
||||
expect(document.documentElement.dataset.theme).toBe('light');
|
||||
applyTheme('dark');
|
||||
expect(document.documentElement.dataset.theme).toBe('dark');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user