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:
2026-05-09 03:23:50 -04:00
parent f3f7bff717
commit 47c57271e7
6 changed files with 191 additions and 1 deletions

View File

@@ -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);

View File

@@ -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">

View 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;

View File

@@ -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');
});
});