diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index 814d0fa5..bba561b9 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -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); diff --git a/decnet_web/src/components/ThemeLab/ThemeLab.css b/decnet_web/src/components/ThemeLab/ThemeLab.css index c076daa4..0a4cebdc 100644 --- a/decnet_web/src/components/ThemeLab/ThemeLab.css +++ b/decnet_web/src/components/ThemeLab/ThemeLab.css @@ -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); diff --git a/decnet_web/src/components/ThemeLab/ThemeLab.tsx b/decnet_web/src/components/ThemeLab/ThemeLab.tsx index 3a51dc98..dd3b7a0b 100644 --- a/decnet_web/src/components/ThemeLab/ThemeLab.tsx +++ b/decnet_web/src/components/ThemeLab/ThemeLab.tsx @@ -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 (
-
+

THEME LAB

dev only · primitive zoo for theme regression
+
diff --git a/decnet_web/src/components/ThemeLab/ThemeToggle.tsx b/decnet_web/src/components/ThemeLab/ThemeToggle.tsx new file mode 100644 index 00000000..2a219274 --- /dev/null +++ b/decnet_web/src/components/ThemeLab/ThemeToggle.tsx @@ -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(() => readLabTheme()); + + useEffect(() => { + applyTheme(theme); + try { sessionStorage.setItem(THEME_SESSION_KEY, theme); } catch { /* ignore */ } + }, [theme]); + + return ( +
+ + +
+ ); +}; + +export default ThemeToggle; diff --git a/decnet_web/src/components/ThemeLab/__tests__/ThemeToggle.test.tsx b/decnet_web/src/components/ThemeLab/__tests__/ThemeToggle.test.tsx new file mode 100644 index 00000000..afcfca4f --- /dev/null +++ b/decnet_web/src/components/ThemeLab/__tests__/ThemeToggle.test.tsx @@ -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(); + 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(); + 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(); + 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'); + }); +}); diff --git a/decnet_web/src/index.css b/decnet_web/src/index.css index a8e53f63..5813c0b5 100644 --- a/decnet_web/src/index.css +++ b/decnet_web/src/index.css @@ -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 ; 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;