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