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 */ }
|
} catch { /* fall through to default */ }
|
||||||
document.documentElement.setAttribute('data-accent', accent);
|
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);
|
const handleLogin = (newToken: string) => setToken(newToken);
|
||||||
|
|||||||
@@ -8,6 +8,19 @@
|
|||||||
gap: var(--space-8);
|
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 {
|
.theme-lab .page-header h1 {
|
||||||
font-size: var(--fs-page);
|
font-size: var(--fs-page);
|
||||||
letter-spacing: var(--ls-title);
|
letter-spacing: var(--ls-title);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import ThemeToggle from './ThemeToggle';
|
||||||
import './ThemeLab.css';
|
import './ThemeLab.css';
|
||||||
|
|
||||||
/* Kitchen-sink theme lab.
|
/* Kitchen-sink theme lab.
|
||||||
@@ -314,13 +315,14 @@ const NetBoxes: React.FC = () => (
|
|||||||
const ThemeLab: React.FC = () => {
|
const ThemeLab: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className="fleet-root theme-lab" data-testid="theme-lab">
|
<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">
|
<div className="page-title-group">
|
||||||
<h1>THEME LAB</h1>
|
<h1>THEME LAB</h1>
|
||||||
<span className="page-sub">
|
<span className="page-sub">
|
||||||
dev only · primitive zoo for theme regression
|
dev only · primitive zoo for theme regression
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<ThemeToggle />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Section id="swatches" title="COLOUR TOKENS">
|
<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);
|
--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 {
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user