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:
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user