← back to Norma
components/AgeThemeSlider.tsx
116 lines
'use client';
// Drop-in age-adaptive theme slider. Sits at the top of AppShell, persists
// choice to localStorage, sets body[data-band="..."] which the CSS in
// app/globals.css consumes (via :root + body[data-band] overrides).
//
// Source: ~/.claude/skills/age-adaptive-theme/ (v9 — 2026-05-12 validated).
// SCOPE: typography + palette adaptation only. Does NOT satisfy WCAG 2.1 AA
// without an independent audit.
import { useEffect, useState } from 'react';
import { Eye } from 'lucide-react';
interface Band {
id: string;
label: string;
tagline: string;
}
function bandFor(age: number): Band {
if (age <= 5) return { id: 'toddler', label: 'Toddler', tagline: 'Primary colors. One face good, one face sad.' };
if (age <= 9) return { id: 'kid', label: 'Elementary', tagline: 'Friendly emoji and clear names.' };
if (age <= 12) return { id: 'tween', label: 'Tween', tagline: 'Brighter palette, full info.' };
if (age <= 19) return { id: 'teen', label: 'Teen', tagline: 'Dense, dark, neon.' };
if (age <= 44) return { id: 'adult', label: 'Adult', tagline: 'Modern dashboard (default).' };
if (age <= 59) return { id: 'mature', label: 'Mature', tagline: 'Serif headings. Calmer pacing.' };
if (age <= 79) return { id: 'senior', label: 'Senior', tagline: 'High contrast, large text.' };
return { id: 'elder', label: 'Elder', tagline: 'Maximum contrast. Just the essentials.' };
}
const LS_KEY = 'norma.age-band';
const DEFAULT_AGE = 30;
export default function AgeThemeSlider() {
const [age, setAge] = useState<number>(DEFAULT_AGE);
const [mounted, setMounted] = useState(false);
const [expanded, setExpanded] = useState(false);
// Hydrate from localStorage post-mount (avoids SSR mismatch).
useEffect(() => {
setMounted(true);
try {
const stored = localStorage.getItem(LS_KEY);
if (stored) {
const n = parseInt(stored, 10);
if (!Number.isNaN(n) && n >= 3 && n <= 95) setAge(n);
}
} catch {}
}, []);
// Apply band to <body> any time age changes (after mount).
useEffect(() => {
if (!mounted) return;
const b = bandFor(age);
if (typeof document !== 'undefined') document.body.dataset.band = b.id;
try { localStorage.setItem(LS_KEY, String(age)); } catch {}
}, [age, mounted]);
const band = bandFor(age);
return (
<div
style={{
position: 'sticky', top: 0, zIndex: 100,
padding: expanded ? '12px 16px' : '6px 16px',
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
display: 'flex', alignItems: 'center', gap: 12,
fontSize: 12, color: 'var(--color-text-secondary)',
transition: 'padding 0.2s',
}}
>
<button
onClick={() => setExpanded(p => !p)}
title="Adjust theme for viewer age"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
background: 'transparent', border: 'none',
color: 'var(--color-text-secondary)', cursor: 'pointer',
fontSize: 12, padding: 0,
}}
>
<Eye size={14} />
<span style={{ fontWeight: 700, color: 'var(--color-text)' }}>{age}</span>
<span style={{ color: 'var(--color-text-muted)', fontSize: 11 }}>· {band.label}</span>
</button>
<input
type="range"
min={3}
max={95}
value={age}
onChange={e => setAge(Number(e.target.value))}
aria-label="Adjust theme for viewer age"
style={{ flex: 1, maxWidth: 360, accentColor: 'var(--color-primary)' }}
/>
{expanded && (
<div style={{
fontSize: 11, color: 'var(--color-text-muted)',
fontStyle: 'italic', flexBasis: '100%', marginTop: 4,
}}>
{band.tagline} · Slider persists; reset to 30 (adult) anytime.
<button
onClick={() => setAge(DEFAULT_AGE)}
className="btn btn-ghost btn-sm"
style={{ marginLeft: 8, fontSize: 11, padding: '2px 8px' }}
>
Reset
</button>
</div>
)}
</div>
);
}