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

@@ -182,6 +182,17 @@ function App() {
}
} catch { /* fall through to default */ }
document.documentElement.setAttribute('data-accent', accent);
/* Lab theme persists in sessionStorage so a tab reload keeps the
* dev's chosen theme without leaking to other tabs or users. The
* production user-facing toggle (localStorage `decnet_theme`)
* arrives with the Config-page setting in a later task. */
try {
const labTheme = sessionStorage.getItem('decnet_theme_lab');
if (labTheme === 'light' || labTheme === 'dark') {
document.documentElement.setAttribute('data-theme', labTheme);
}
} catch { /* ignore */ }
}, []);
const handleLogin = (newToken: string) => setToken(newToken);

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

View File

@@ -108,6 +108,58 @@ html[data-accent="violet"] {
--accent-glow: var(--violet-glow);
}
/* ── Light theme ────────────────────────────────────────
* Cream-on-ink. Activated by setting `data-theme="light"`
* on <html>; back-compat aliases (--background-color,
* --text-color, etc.) re-resolve through the cascade since
* they reference --bg/--matrix/--violet/etc. via var().
*
* Glows are removed (light mode = hard 1px borders, not
* neon haloes) and the scangrid line goes ink-on-cream. */
html[data-theme="light"] {
--bg: #dbdad6;
--matrix: #047857;
--violet: #6b21a8;
--panel: #c9c7c2;
--border: #1a1a1a;
--alert: #b91c1c;
--fg-1: var(--matrix);
--fg-2: rgba(4, 120, 87, 0.80);
--fg-3: rgba(4, 120, 87, 0.60);
--fg-4: rgba(4, 120, 87, 0.40);
--matrix-tint-5: rgba(4, 120, 87, 0.05);
--matrix-tint-10: rgba(4, 120, 87, 0.10);
--matrix-tint-30: rgba(4, 120, 87, 0.25);
--violet-tint-10: rgba(107, 33, 168, 0.10);
--alert-tint-10: rgba(185, 28, 28, 0.10);
/* Light mode trades neon glow for hard borders — keep the
* vars defined so .btn:hover etc. don't break, just no-op them. */
--matrix-glow: none;
--matrix-green-glow: none;
--violet-glow: none;
--matrix-glow-lg: none;
--shadow-panel: 0 1px 0 rgba(0, 0, 0, 0.08);
--grid-line: rgba(0, 0, 0, 0.06);
}
html[data-theme="light"][data-accent="violet"] {
--accent: var(--violet);
--accent-tint-10: var(--violet-tint-10);
--accent-tint-30: rgba(107, 33, 168, 0.25);
--accent-glow: none;
}
html[data-theme="light"]:not([data-accent="violet"]) {
--accent: var(--matrix);
--accent-tint-10: var(--matrix-tint-10);
--accent-tint-30: var(--matrix-tint-30);
--accent-glow: none;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;