← back to Cncp Failures Mockups

public/v7-age-adaptive.html

699 lines

<!doctype html><html><head>
<meta charset="utf-8"><title>v7 — Age-adaptive (3 → 95)</title>
<style>
  /* The 8 age bands. Each rewrites the page's design system. */
  :root {
    --bg:#0a0c12; --panel:#161a26; --fg:#e6eef8; --muted:#7c8aa5; --border:#252b3d;
    --accent:#7c3aed; --ok:#22c55e; --warn:#f59e0b; --bad:#ef4444;
    --font:'-apple-system',BlinkMacSystemFont,'SF Pro Text',system-ui,sans-serif;
    --font-mono:ui-monospace,Menlo,monospace;
    --font-size-body:13px; --font-size-h:16px; --font-size-small:11px;
    --gap:14px; --radius:8px; --padding:12px;
    --vibe:'modern';
  }
  /* AGE 3-5 — Sesame Street primary colors, emoji-first, huge tap targets */
  body[data-band="toddler"] {
    --bg:#fff9e6; --panel:#fff; --fg:#222; --muted:#666; --border:#f4d000;
    --accent:#ff3b30; --ok:#00b34a; --warn:#ffcc00; --bad:#ff3b30;
    --font:'Marker Felt','Comic Sans MS',sans-serif;
    --font-size-body:24px; --font-size-h:48px; --font-size-small:18px;
    --gap:24px; --radius:24px; --padding:24px;
  }
  /* AGE 6-9 — Elementary, playful but legible */
  body[data-band="kid"] {
    --bg:#fff; --panel:#fef9c3; --fg:#1f2937; --muted:#4b5563; --border:#fbbf24;
    --accent:#3b82f6; --ok:#16a34a; --warn:#f59e0b; --bad:#dc2626;
    --font:'Comic Sans MS','Chalkboard SE',sans-serif;
    --font-size-body:18px; --font-size-h:30px; --font-size-small:14px;
    --gap:18px; --radius:14px; --padding:18px;
  }
  /* AGE 10-12 — Tween, structured, still color */
  body[data-band="tween"] {
    --bg:#fef3c7; --panel:#fff; --fg:#1f2937; --muted:#6b7280; --border:#fbbf24;
    --accent:#6366f1; --ok:#16a34a; --warn:#f59e0b; --bad:#dc2626;
    --font:'Avenir',-apple-system,sans-serif;
    --font-size-body:15px; --font-size-h:22px; --font-size-small:12px;
    --gap:14px; --radius:10px; --padding:14px;
  }
  /* AGE 13-19 — Teen / neon dark mode */
  body[data-band="teen"] {
    --bg:#0a0014; --panel:#1a0030; --fg:#f0f0ff; --muted:#8a7fb0; --border:#2a0050;
    --accent:#ff00ff; --ok:#00ff88; --warn:#ffaa00; --bad:#ff0066;
    --font:'JetBrains Mono','SF Mono',ui-monospace,monospace;
    --font-size-body:12px; --font-size-h:18px; --font-size-small:10px;
    --gap:10px; --radius:4px; --padding:10px;
  }
  /* AGE 20-44 — Adult, modern default */
  body[data-band="adult"] {
    --bg:#0a0c12; --panel:#161a26; --fg:#e6eef8; --muted:#7c8aa5; --border:#252b3d;
    --accent:#7c3aed; --ok:#22c55e; --warn:#f59e0b; --bad:#ef4444;
    --font-size-body:13px; --font-size-h:18px; --font-size-small:11px;
    --gap:14px; --radius:8px; --padding:12px;
  }
  /* AGE 45-59 — Mature, slightly larger and calmer */
  body[data-band="mature"] {
    --bg:#f8f6f1; --panel:#fff; --fg:#1c1917; --muted:#57534e; --border:#d6d3d1;
    --accent:#7c2d12; --ok:#15803d; --warn:#b45309; --bad:#991b1b;
    --font:'Georgia','Iowan Old Style',serif;
    --font-size-body:15px; --font-size-h:22px; --font-size-small:13px;
    --gap:16px; --radius:6px; --padding:16px;
  }
  /* AGE 60-79 — Senior, hi-contrast, large readable */
  body[data-band="senior"] {
    --bg:#fff; --panel:#fff; --fg:#000; --muted:#333; --border:#000;
    --accent:#0050a0; --ok:#006400; --warn:#a05000; --bad:#a00000;
    --font:'Helvetica Neue',Helvetica,Arial,sans-serif;
    --font-size-body:20px; --font-size-h:30px; --font-size-small:16px;
    --gap:20px; --radius:6px; --padding:20px;
  }
  /* AGE 80+ — Elder, max contrast, essentials only */
  body[data-band="elder"] {
    --bg:#000; --panel:#000; --fg:#fff; --muted:#cccccc; --border:#fff;
    --accent:#ffff00; --ok:#00ff00; --warn:#ffff00; --bad:#ff0000;
    --font:'Helvetica Neue',Helvetica,Arial,sans-serif;
    --font-size-body:26px; --font-size-h:42px; --font-size-small:20px;
    --gap:28px; --radius:0; --padding:28px;
  }

  * { box-sizing:border-box; }
  body { margin:0; background:var(--bg); color:var(--fg); font-family:var(--font); font-size:var(--font-size-body); min-height:100vh; padding:0 0 60px; transition: background 0.4s, color 0.4s; }

  /* Slider bar — always visible at top */
  .slider-bar { position:sticky; top:0; z-index:10; padding:14px 24px; background:var(--panel); border-bottom:2px solid var(--border); display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
  .slider-bar .age-num { font-weight:700; font-size:1.6em; color:var(--accent); min-width:80px; text-align:center; }
  .slider-bar .age-band-label { color:var(--muted); font-size:0.9em; text-transform:uppercase; letter-spacing:1.5px; }
  .slider-bar input[type=range] { flex:1; min-width:280px; accent-color:var(--accent); height:12px; }
  .slider-bar .tagline { color:var(--muted); font-size:0.85em; flex-basis:100%; margin-top:6px; }

  .container { padding:24px; }
  h1 { font-size:var(--font-size-h); margin:0 0 8px; }
  .sub { color:var(--muted); font-size:var(--font-size-small); margin-bottom:var(--gap); }

  .grid { display:grid; gap:var(--gap); grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); }
  body[data-band="toddler"] .grid, body[data-band="senior"] .grid, body[data-band="elder"] .grid { grid-template-columns:1fr; }
  body[data-band="kid"] .grid { grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); }

  .card { background:var(--panel); border:2px solid var(--border); border-radius:var(--radius); padding:var(--padding); display:flex; flex-direction:column; gap:8px; }
  .card .name { font-family:var(--font-mono); font-weight:600; word-break:break-all; }
  body[data-band="toddler"] .card .name, body[data-band="kid"] .card .name { font-family:var(--font); }
  .card .last { color:var(--muted); font-size:var(--font-size-small); white-space:pre-wrap; line-height:1.4; max-height:4.2em; overflow:hidden; }
  .card .action { color:var(--accent); font-style:italic; font-size:var(--font-size-small); }
  body[data-band="senior"] .card .action, body[data-band="elder"] .card .action { font-style:normal; font-weight:700; }

  /* Severity ribbon — meaning shifts across ages */
  .sev-badge { display:inline-block; padding:4px 10px; border-radius:var(--radius); font-weight:700; font-size:var(--font-size-small); text-transform:uppercase; letter-spacing:1px; }
  .sev-badge.high { background:var(--bad); color:#fff; }
  .sev-badge.medium { background:var(--warn); color:#000; }
  .sev-badge.low { background:var(--ok); color:#fff; }
  body[data-band="senior"] .sev-badge, body[data-band="elder"] .sev-badge { padding:8px 14px; }

  /* Hermes panel — collapses progressively */
  .hermes { margin-top:var(--gap); padding:var(--padding); background:var(--panel); border:2px solid var(--border); border-radius:var(--radius); }
  .hermes h2 { font-size:1.1em; margin:0 0 8px; }
  .hermes .stat-line { color:var(--muted); font-size:var(--font-size-small); }
  body[data-band="elder"] .hermes, body[data-band="toddler"] .hermes { display:none; }
  body[data-band="kid"] .hermes .stat-line, body[data-band="senior"] .hermes .recent-feed { display:none; }

  /* Toddler-mode: feeling face for each card */
  .face { font-size:64px; line-height:1; }
  body:not([data-band="toddler"]) .face { display:none; }
  body[data-band="toddler"] .name { display:none; }
  body[data-band="toddler"] .last, body[data-band="toddler"] .action, body[data-band="toddler"] .meta, body[data-band="toddler"] .footer { display:none; }
  body[data-band="toddler"] .toddler-label { display:block; font-size:1.4em; text-align:center; margin-top:8px; font-weight:700; }
  body:not([data-band="toddler"]) .toddler-label { display:none; }

  /* Kid mode: simple emoji + name only */
  .kid-icon { font-size:36px; }
  body:not([data-band="kid"]) .kid-icon { display:none; }

  /* Elder mode: just one row of essential info */
  body[data-band="elder"] .card .last, body[data-band="elder"] .card .meta, body[data-band="elder"] .card .footer { display:none; }
  body[data-band="elder"] .card { padding:32px; }
  body[data-band="elder"] .card .name { font-size:0.7em; font-weight:400; color:var(--muted); }
  body[data-band="elder"] .card .action { font-size:1.4em; line-height:1.3; }
  body[data-band="elder"] .container h1 { font-size:1.5em; }

  /* Teen mode: scan-lines */
  body[data-band="teen"] { background-image: linear-gradient(rgba(255,0,255,0.03) 1px, transparent 1px); background-size: 100% 4px; }

  .meta { color:var(--muted); font-size:var(--font-size-small); display:flex; gap:8px; flex-wrap:wrap; }
  .footer { display:flex; gap:8px; justify-content:space-between; align-items:center; }
  button.action-btn { background:var(--accent); color:#fff; border:none; padding:6px 12px; border-radius:var(--radius); font-size:var(--font-size-small); cursor:pointer; font-family:var(--font); font-weight:600; transition: transform 0.1s; }
  button.action-btn:hover { transform: translateY(-1px); filter: brightness(1.1); }
  button.action-btn:active { transform: translateY(0); filter: brightness(0.95); }
  body[data-band="senior"] button.action-btn, body[data-band="elder"] button.action-btn { padding:18px 28px; font-size:1.2em; }
  body[data-band="toddler"] button.action-btn { padding:22px 32px; font-size:1.4em; border-radius:32px; }

  /* Band quick-jump pills */
  .pills { display:flex; gap:6px; flex-wrap:wrap; margin-top:6px; flex-basis:100%; }
  .pill-btn { background:transparent; border:1px solid var(--border); color:var(--muted); padding:4px 10px; border-radius:14px; cursor:pointer; font-size:11px; text-transform:uppercase; letter-spacing:1px; font-family:var(--font); transition: all 0.15s; }
  .pill-btn:hover { color:var(--fg); border-color:var(--accent); }
  .pill-btn.active { background:var(--accent); color:#fff; border-color:var(--accent); }

  /* Detail modal — opens on any band's primary action */
  .modal-back { position:fixed; inset:0; background:rgba(0,0,0,0.6); display:none; align-items:center; justify-content:center; z-index:100; padding:24px; }
  .modal-back.open { display:flex; }
  .modal { background:var(--panel); color:var(--fg); border:2px solid var(--border); border-radius:var(--radius); max-width:560px; width:100%; padding:var(--padding); position:relative; max-height:85vh; overflow:auto; }
  body[data-band="toddler"] .modal, body[data-band="elder"] .modal { max-width:480px; }
  body[data-band="elder"] .modal { border-width:3px; }
  .modal h3 { margin:0 0 12px; }
  .modal .close-x { position:absolute; top:8px; right:14px; background:none; border:none; color:var(--muted); font-size:24px; cursor:pointer; }
  .modal-row { display:grid; grid-template-columns:90px 1fr; gap:10px; padding:6px 0; border-bottom:1px solid var(--border); font-size:var(--font-size-body); }
  .modal-row:last-child { border-bottom:none; }
  .modal-row .k { color:var(--muted); text-transform:uppercase; font-size:0.75em; letter-spacing:1px; }
  .modal-actions { display:flex; gap:10px; margin-top:14px; flex-wrap:wrap; }
  .toddler-celebrate { font-size:120px; text-align:center; line-height:1; animation: pop 0.5s ease; }
  @keyframes pop { 0% { transform:scale(0); } 70% { transform:scale(1.2); } 100% { transform:scale(1); } }
  /* hide certain modal pieces per band */
  body[data-band="toddler"] .modal-row, body[data-band="kid"] .modal-row.adult-only { display:none; }
  body[data-band="elder"] .modal-row.adult-only { display:none; }

  /* Toast for transient feedback (mark resolved, dismissed, etc.) */
  .toast { position:fixed; bottom:24px; left:50%; transform:translateX(-50%); background:var(--accent); color:#fff; padding:14px 24px; border-radius:var(--radius); font-weight:600; box-shadow: 0 8px 24px rgba(0,0,0,0.3); z-index:200; opacity:0; transition: opacity 0.25s, bottom 0.25s; pointer-events:none; }
  .toast.show { opacity:1; bottom:40px; }
  body[data-band="toddler"] .toast { font-size:1.6em; padding:24px 36px; }
  body[data-band="elder"] .toast { font-size:1.4em; padding:20px 30px; }
</style>
</head><body data-band="toddler">

<div class="slider-bar">
  <div>
    <div class="age-num"><span id="ageDisplay">3</span> yr</div>
    <div class="age-band-label" id="bandLabel">toddler</div>
  </div>
  <input id="ageSlider" type="range" min="3" max="95" value="3" />
  <button id="voiceToggle" class="pill-btn" onclick="toggleVoice()" title="Toggle voice narration" style="margin-left:auto;">🔊 voice on</button>
  <div class="tagline" id="tagline">Primary colors, huge taps. One face = good. One face = sad.</div>
  <div class="pills" id="bandPills"></div>
</div>

<!-- Detail modal — populated dynamically when a card action fires -->
<div class="modal-back" id="modalBack" onclick="if(event.target===this) closeModal()">
  <div class="modal" id="modal">
    <button class="close-x" onclick="closeModal()" aria-label="Close">×</button>
    <div id="modalBody"></div>
  </div>
</div>

<!-- Toast for transient feedback -->
<div class="toast" id="toast"></div>

<!-- First-visit age prompt (skipped on returning visits via localStorage) -->
<div class="modal-back" id="ageGate" style="display:none; align-items:center; justify-content:center; background:rgba(0,0,0,0.85);">
  <div class="modal" style="max-width:560px; text-align:center; padding:32px 28px;">
    <h2 style="margin:0 0 6px; font-size:24px;">Welcome to the age-adaptive UI</h2>
    <div style="color:var(--muted); font-size:14px; margin-bottom:22px; line-height:1.5;">
      This page changes the entire interface — colors, type, density, copy, voice — based on the viewer's age.<br>
      <span style="color:#9cb6dd;">How old are you?</span> (You can change this any time with the slider.)
    </div>
    <input id="ageGateSlider" type="range" min="3" max="95" value="30" style="width:100%; accent-color:var(--accent); margin-bottom:8px;" />
    <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
      <span style="font-size:11px; color:var(--muted);">3</span>
      <div style="font-size:48px; font-weight:700; color:var(--accent); line-height:1;"><span id="ageGateNum">30</span> <span style="font-size:18px; color:var(--muted); font-weight:400;">yr</span></div>
      <span style="font-size:11px; color:var(--muted);">95</span>
    </div>
    <div id="ageGateBand" style="color:#9cb6dd; font-size:13px; text-transform:uppercase; letter-spacing:2px; margin-bottom:4px;">adult</div>
    <div id="ageGateTagline" style="color:var(--muted); font-size:12px; margin-bottom:22px; min-height:32px;">Modern dashboard. Sort + density + full Hermes panel.</div>
    <div style="display:flex; gap:10px; justify-content:center; flex-wrap:wrap; margin-bottom:14px;" id="ageGatePresets"></div>
    <div style="display:flex; gap:10px; justify-content:center;">
      <button class="action-btn" onclick="acceptAge()" style="padding:12px 32px; font-size:15px;">Use this age</button>
      <button class="action-btn" onclick="acceptAge(30, true)" style="background:transparent; color:var(--muted); border:1px solid var(--border); padding:12px 24px; font-size:13px;">Skip (use 30)</button>
    </div>
    <div style="font-size:10px; color:var(--muted); margin-top:14px;">Saved on this device. Clear in browser data to ask again.</div>
  </div>
</div>

<div class="container">
  <h1 id="hed">Failures</h1>
  <div class="sub" id="dek">A reading of what's going wrong, tuned to your age.</div>
  <div class="grid" id="grid"></div>

  <div class="hermes">
    <h2>Hermes 3 <span style="font-size:0.7em; color:var(--muted);">(the helper)</span></h2>
    <div class="stat-line" id="hStats"></div>
    <div class="recent-feed" id="hRecent" style="margin-top:8px;"></div>
  </div>
</div>

<script>
// Age band rules — keep in sync with CSS [data-band=].
function bandFor(age) {
  if (age <= 5)  return { id:'toddler', label:'toddler', tagline:'Primary colors, huge taps. One face = good. One face = sad.', hed:'Things to fix!' };
  if (age <= 9)  return { id:'kid',     label:'elementary', tagline:'Friendly emoji and clear names. Easy actions.', hed:'Stuff to fix' };
  if (age <= 12) return { id:'tween',   label:'tween', tagline:'Brighter palette, more info per card, still simple actions.', hed:'Open issues' };
  if (age <= 19) return { id:'teen',    label:'teen', tagline:'Dense, dark, neon. Mono fonts. Power-user nav.', hed:'> FAILURES' };
  if (age <= 44) return { id:'adult',   label:'adult', tagline:'Modern dashboard. Sort + density + full Hermes panel.', hed:'Failures' };
  if (age <= 59) return { id:'mature',  label:'mature', tagline:'Serif typeface, calmer palette, larger touch targets.', hed:'Open Failures' };
  if (age <= 79) return { id:'senior',  label:'senior', tagline:'High contrast. Larger text. Clear primary actions.', hed:'What needs your attention' };
  return            { id:'elder',   label:'elder', tagline:'Black & white, maximum text size, only the essentials. One issue at a time.', hed:'Today' };
}

const slider = document.getElementById('ageSlider');
const ageDisplay = document.getElementById('ageDisplay');
const bandLabel = document.getElementById('bandLabel');
const tagline = document.getElementById('tagline');
const hed = document.getElementById('hed');

let _lastSpokenBand = null;
function applyBand(age) {
  const b = bandFor(age);
  document.body.dataset.band = b.id;
  ageDisplay.textContent = age;
  bandLabel.textContent = b.label;
  tagline.textContent = b.tagline;
  hed.textContent = b.hed;
  renderPills(b.id);
  renderGrid(b);
  // Voice greeting on band CHANGE (skip on initial render + don't spam every slider tick).
  if (_lastSpokenBand !== b.id) {
    _lastSpokenBand = b.id;
    const greetings = {
      toddler: 'Hi friend! Tap the face!',
      kid:     `Hi! You're in kid mode. Click a card to learn more.`,
      tween:   `Tween view. Open any item for details.`,
      teen:    `Teen mode. Dense and dark. Click open for the full record.`,
      adult:   `Adult dashboard. Click open for full details and actions.`,
      mature:  `Mature view. Calmer pacing, larger type.`,
      senior:  `Senior view. High contrast and large buttons. Click view details when ready.`,
      elder:   `Elder view. One item at a time. Click done when you've handled it.`
    };
    speak(greetings[b.id] || '', b.id);
  }
}

slider.addEventListener('input', e => applyBand(Number(e.target.value)));

// Quick-jump pills — one per band, snap slider to a representative age in that band.
const BAND_PRESETS = [
  { id:'toddler', age:3,  label:'Toddler' },
  { id:'kid',     age:7,  label:'Kid' },
  { id:'tween',   age:11, label:'Tween' },
  { id:'teen',    age:16, label:'Teen' },
  { id:'adult',   age:30, label:'Adult' },
  { id:'mature',  age:52, label:'Mature' },
  { id:'senior',  age:70, label:'Senior' },
  { id:'elder',   age:88, label:'Elder' }
];
function renderPills(activeId) {
  document.getElementById('bandPills').innerHTML = BAND_PRESETS.map(b =>
    `<button class="pill-btn${b.id===activeId?' active':''}" data-band="${b.id}" onclick="jumpToBand(${b.age})">${b.label}</button>`
  ).join('');
}
function jumpToBand(age) {
  slider.value = age;
  applyBand(age);
}

let _cachedData = null;
async function loadData() {
  const r = await fetch('/data' + location.search);
  _cachedData = await r.json();
  applyBand(Number(slider.value));
}

function emojiFor(severity, status) {
  if (status === 'resolved') return '✅';
  if (status === 'dismissed') return '😴';
  if (severity === 'high')   return '🚨';
  if (severity === 'medium') return '⚠️';
  if (severity === 'low')    return '🟢';
  return '❓';
}
function faceFor(severity, status) {
  if (status === 'resolved' || status === 'dismissed') return '😊';
  if (severity === 'high')   return '😟';
  if (severity === 'medium') return '😐';
  if (severity === 'low')    return '🙂';
  return '😶';
}
function plainLabel(f) {
  if (f.severity === 'high')   return 'Something is BROKEN!';
  if (f.severity === 'medium') return 'Something needs a look';
  if (f.severity === 'low')    return 'Everything is OK';
  return 'Not sure yet';
}

function renderGrid(b) {
  if (!_cachedData) return;
  const grid = document.getElementById('grid');
  let items = (_cachedData.failures || []).filter(f => f.status === 'triage' || f.status === 'investigating');

  // Information density shrinks with age extremes
  if (b.id === 'toddler') items = items.slice(0, 3);
  else if (b.id === 'kid') items = items.slice(0, 4);
  else if (b.id === 'elder') items = items.slice(0, 1);
  else if (b.id === 'senior') items = items.slice(0, 3);

  grid.innerHTML = items.map((f, idx) => {
    const sevClass = f.severity || 'medium';
    const ageBand = b.id;
    if (ageBand === 'toddler') {
      // entire card is the action — tap the face to celebrate
      return `
        <div class="card" onclick="onAction('${b.id}','${f.id}', this)" style="cursor:pointer;">
          <div class="face">${faceFor(f.severity, f.status)}</div>
          <div class="toddler-label">${plainLabel(f)}</div>
        </div>`;
    }
    if (ageBand === 'kid') {
      return `
        <div class="card">
          <div style="display:flex; align-items:center; gap:12px;">
            <div class="kid-icon">${emojiFor(f.severity, f.status)}</div>
            <div>
              <div style="font-weight:700;">${f.processName}</div>
              <div class="last">${(f.lastLines||'').split('\\n')[0].slice(0,80)}</div>
            </div>
          </div>
          <button class="action-btn" onclick="onAction('${b.id}','${f.id}', this)">Tell me more</button>
        </div>`;
    }
    if (ageBand === 'elder') {
      return `
        <div class="card">
          <div class="action">${f.suggestedAction || 'Click here when ready.'}</div>
          <div class="name">${f.processName}</div>
          <button class="action-btn" onclick="onAction('${b.id}','${f.id}', this)">Done</button>
        </div>`;
    }
    if (ageBand === 'senior') {
      return `
        <div class="card">
          <div style="display:flex; gap:10px; align-items:center; margin-bottom:8px;">
            <span class="sev-badge ${sevClass}">${f.severity || 'check'}</span>
            <span style="font-weight:600;">${f.processName}</span>
          </div>
          <div class="last">${(f.lastLines||'').split('\\n')[0].slice(0,120)}</div>
          ${f.suggestedAction ? `<div class="action">${f.suggestedAction}</div>` : ''}
          <button class="action-btn" onclick="onAction('${b.id}','${f.id}', this)">View details</button>
        </div>`;
    }
    // adult / mature / tween / teen — full standard card with band-specific styling via CSS vars
    return `
      <div class="card">
        <div class="meta">
          <span class="sev-badge ${sevClass}">${f.severity || '—'}</span>
          <span>${f.source}</span>
          <span>×${f.eventCount}</span>
        </div>
        <div class="name">${f.processName}</div>
        <div class="last">${(f.lastLines||'').split('\\n').filter(Boolean)[0]?.slice(0,120) || ''}</div>
        ${f.suggestedAction ? `<div class="action">→ ${f.suggestedAction}</div>` : ''}
        <div class="footer">
          <span style="font-size:var(--font-size-small); color:var(--muted);">${new Date(f.lastSeen||f.created).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span>
          <button class="action-btn" onclick="onAction('${b.id}','${f.id}', this)">Open</button>
        </div>
      </div>`;
  }).join('');
  if (items.length === 0) grid.innerHTML = '<div class="card" style="text-align:center; padding:40px;">All clear!</div>';

  // Hermes summary
  const h = _cachedData.hermes ? _cachedData.hermes.last24h : null;
  if (h) {
    document.getElementById('hStats').innerHTML = b.id === 'kid'
      ? `Hermes checked <b>${h.total}</b> things today and answered <b>${h.ok}</b> right.`
      : `${h.total} calls · ${h.byType.classify} cls · ${h.byType['idea-chat']} idea · ${(h.avgLatencyMs/1000).toFixed(1)}s avg · ${h.fail} failed`;
    const recent = (_cachedData.hermes.recent || []).slice(0, b.id==='senior'?2:b.id==='mature'?3:5);
    document.getElementById('hRecent').innerHTML = recent.map(c => {
      const ok = c.ok ? '✓' : '✗';
      const t = new Date(c.ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
      return `<div style="font-size:var(--font-size-small); color:var(--muted); margin-top:2px;">${ok} ${t} ${c.type} · ${(c.target||'').slice(0,40)}</div>`;
    }).join('');
  } else {
    document.getElementById('hStats').textContent = '';
    document.getElementById('hRecent').textContent = '';
  }
}

// ===== VOICE — per-band TTS via browser Web Speech API =====
// Each band picks a voice by ordered name preferences, plus pitch + rate, so the same toast
// sounds different at every age. Falls back to the default voice if none of the preferences
// are installed on the user's OS.
const BAND_VOICES = {
  toddler: { prefs:['Princess','Junior','Kathy','Samantha','Karen'], pitch:1.6, rate:1.05, volume:1 },
  kid:     { prefs:['Karen','Samantha','Tessa','Moira','Veena'],     pitch:1.35, rate:1.0,  volume:1 },
  tween:   { prefs:['Samantha','Allison','Karen','Tessa'],            pitch:1.15, rate:1.05, volume:1 },
  teen:    { prefs:['Daniel','Aaron','Fred','Tom','Alex'],            pitch:1.0,  rate:1.08, volume:1 },
  adult:   { prefs:['Samantha','Alex','Allison','Karen'],             pitch:1.0,  rate:1.0,  volume:1 },
  mature:  { prefs:['Veena','Moira','Tessa','Samantha','Karen'],      pitch:0.98, rate:0.95, volume:1 },
  senior:  { prefs:['Alex','Tom','Daniel','Aaron','Fred'],            pitch:0.95, rate:0.88, volume:1 },
  elder:   { prefs:['Bruce','Tom','Fred','Alex'],                     pitch:0.85, rate:0.78, volume:1 }
};
let _voiceCache = null;
let _voiceEnabled = true;
let _selectedVoices = {};   // band -> SpeechSynthesisVoice

function loadVoices() {
  if (!('speechSynthesis' in window)) return [];
  _voiceCache = window.speechSynthesis.getVoices();
  // Match each band's preferences against installed voices
  _selectedVoices = {};
  for (const [band, cfg] of Object.entries(BAND_VOICES)) {
    let chosen = null;
    for (const pref of cfg.prefs) {
      chosen = _voiceCache.find(v => v.name === pref || v.name.startsWith(pref + ' '));
      if (chosen) break;
    }
    _selectedVoices[band] = chosen || _voiceCache.find(v => v.lang.startsWith('en')) || _voiceCache[0] || null;
  }
  return _voiceCache;
}
// Voices populate async on first page load.
if ('speechSynthesis' in window) {
  loadVoices();
  window.speechSynthesis.onvoiceschanged = loadVoices;
}

// Speak prefers Steve's cloned voice via /api/voice (ElevenLabs + per-band ffmpeg pitch shift).
// Falls back to browser Web Speech if /api/voice errors or times out.
let _currentAudio = null;
async function speak(text, band) {
  if (!_voiceEnabled || !text) return;
  // Cancel anything in flight
  if (_currentAudio) { try { _currentAudio.pause(); _currentAudio.src = ''; } catch {} _currentAudio = null; }
  if (window.speechSynthesis) window.speechSynthesis.cancel();

  const url = `/api/voice?band=${encodeURIComponent(band)}&text=${encodeURIComponent(text)}`;
  try {
    // HEAD-style probe to make sure server returns audio (not JSON error) — keep cheap.
    const audio = new Audio(url);
    audio.preload = 'auto';
    _currentAudio = audio;
    audio.onerror = () => {
      console.warn('[voice] /api/voice errored — falling back to Web Speech');
      _currentAudio = null;
      speakWebSpeech(text, band);
    };
    await audio.play().catch(e => {
      console.warn('[voice] audio.play rejected — fallback:', e.message);
      _currentAudio = null;
      speakWebSpeech(text, band);
    });
  } catch (e) {
    console.warn('[voice] threw — fallback:', e.message);
    speakWebSpeech(text, band);
  }
}

// Fallback path — original Web Speech behavior in case Steve voice is unavailable.
function speakWebSpeech(text, band) {
  if (!_voiceEnabled || !('speechSynthesis' in window) || !text) return;
  window.speechSynthesis.cancel();
  const cfg = BAND_VOICES[band] || BAND_VOICES.adult;
  const u = new SpeechSynthesisUtterance(text);
  if (_selectedVoices[band]) u.voice = _selectedVoices[band];
  u.pitch = cfg.pitch;
  u.rate = cfg.rate;
  u.volume = cfg.volume;
  window.speechSynthesis.speak(u);
}

function toggleVoice() {
  _voiceEnabled = !_voiceEnabled;
  const btn = document.getElementById('voiceToggle');
  if (btn) btn.textContent = _voiceEnabled ? '🔊 voice on' : '🔇 voice off';
  if (!_voiceEnabled) {
    if (_currentAudio) { try { _currentAudio.pause(); } catch {} _currentAudio = null; }
    if (window.speechSynthesis) window.speechSynthesis.cancel();
  } else {
    speak('voice on', bandFor(Number(slider.value)).id);
  }
}

// ===== UI HOOKS — every band's primary action wired here. =====
// In-memory state for the demo (no API writes); persists for the page session.
const _localActions = new Map();   // failureId -> { resolved, dismissed, refined, opened, celebrated }

function toast(msg, ms = 1800) {
  const el = document.getElementById('toast');
  el.textContent = msg;
  el.classList.add('show');
  setTimeout(() => el.classList.remove('show'), ms);
  speak(msg, bandFor(Number(slider.value)).id);
}

function openModal(html) {
  document.getElementById('modalBody').innerHTML = html;
  document.getElementById('modalBack').classList.add('open');
  // ESC to close
  document.addEventListener('keydown', _escHandler);
}
function closeModal() {
  document.getElementById('modalBack').classList.remove('open');
  document.removeEventListener('keydown', _escHandler);
}
function _escHandler(e) { if (e.key === 'Escape') closeModal(); }

function findFailure(id) {
  return (_cachedData && _cachedData.failures || []).find(f => f.id === id);
}

function detailHTML(f, band) {
  const sev = f.severity || '—';
  const cat = f.category || '—';
  // Per-band copy — same data, different tone.
  let title, subtitle, primary, secondary;
  if (band === 'kid') {
    title = `${emojiFor(f.severity, f.status)} ${f.processName}`;
    subtitle = plainLabel(f);
    primary = 'Okay, got it';
    secondary = '';
  } else if (band === 'senior' || band === 'mature') {
    title = `${f.processName}`;
    subtitle = `Severity: ${sev}. ${f.suggestedAction || ''}`;
    primary = 'Mark resolved';
    secondary = 'Close';
  } else {
    title = `${f.processName}`;
    subtitle = `${f.source} · ${f.eventCount} events · severity ${sev} · ${cat}`;
    primary = 'Mark resolved';
    secondary = 'Open in CNCP';
  }
  return `
    <h3>${title}</h3>
    <div style="color:var(--muted); margin-bottom:14px;">${subtitle}</div>
    <div class="modal-row"><div class="k">Process</div><div>${f.processName}</div></div>
    <div class="modal-row adult-only"><div class="k">Source</div><div>${f.source}</div></div>
    <div class="modal-row adult-only"><div class="k">Severity</div><div>${sev}</div></div>
    <div class="modal-row adult-only"><div class="k">Category</div><div>${cat}</div></div>
    <div class="modal-row adult-only"><div class="k">Events</div><div>${f.eventCount}</div></div>
    <div class="modal-row"><div class="k">Action</div><div>${f.suggestedAction || '—'}</div></div>
    <div class="modal-row adult-only"><div class="k">Last output</div><div style="font-family:var(--font-mono); font-size:0.9em; white-space:pre-wrap; max-height:140px; overflow:auto;">${(f.lastLines||'').slice(0,800)}</div></div>
    <div class="modal-actions">
      <button class="action-btn" onclick="markResolved('${f.id}')">${primary}</button>
      ${secondary ? `<button class="action-btn" style="background:transparent; color:var(--fg); border:1px solid var(--border);" onclick="${secondary==='Close' ? 'closeModal()' : `window.open('http://127.0.0.1:3333/','_blank'); closeModal();`}">${secondary}</button>` : ''}
    </div>`;
}

function markResolved(id) {
  _localActions.set(id, { ...(_localActions.get(id)||{}), resolved: true });
  closeModal();
  const band = bandFor(Number(slider.value)).id;
  const msg = band === 'kid' ? '✓ Marked all good!'
            : band === 'toddler' ? '🎉 Yay!'
            : band === 'elder' ? 'Done. Thank you.'
            : 'Resolved · row will move to Resolved bucket';
  toast(msg);
  renderGrid(bandFor(Number(slider.value)));
}

function onAction(band, id, btn) {
  const f = findFailure(id);
  if (!f) { toast('item not found'); return; }
  if (band === 'toddler') {
    _localActions.set(id, { ...(_localActions.get(id)||{}), celebrated: true });
    openModal(`<div class="toddler-celebrate">🎉</div><div style="text-align:center; font-size:1.3em; font-weight:700; margin-top:14px;">Good job!</div><div class="modal-actions" style="justify-content:center;"><button class="action-btn" onclick="closeModal()">Okay!</button></div>`);
    speak('Good job!', 'toddler');
    return;
  }
  if (band === 'elder') {
    openModal(`
      <h3 style="font-size:1.4em;">Mark as done?</h3>
      <div style="margin:14px 0; font-size:1.15em;">${f.suggestedAction || f.processName}</div>
      <div class="modal-actions">
        <button class="action-btn" onclick="markResolved('${id}')">Yes, done</button>
        <button class="action-btn" style="background:transparent; color:var(--fg); border:2px solid var(--border);" onclick="closeModal()">Not yet</button>
      </div>`);
    speak(`Mark as done? ${f.suggestedAction || f.processName}`, 'elder');
    return;
  }
  // all other bands → detail modal + spoken summary
  openModal(detailHTML(f, band));
  // Band-tuned spoken summary
  const blurbs = {
    kid:    `Looking at ${f.processName}. ${plainLabel(f)}`,
    tween:  `${f.processName}. Severity ${f.severity || 'unset'}.`,
    teen:   `${f.processName}. ${f.severity || '?'} severity. ${f.eventCount} events.`,
    adult:  `${f.processName}. ${f.severity || 'unset'} severity. ${f.suggestedAction || ''}`,
    mature: `${f.processName}. Severity ${f.severity || 'unset'}. ${f.suggestedAction || ''}`,
    senior: `${f.processName}. ${f.severity || 'unset'} priority. ${f.suggestedAction || 'No action required.'}`
  };
  speak(blurbs[band] || f.processName, band);
}

// Re-render needs to respect _localActions so resolved items show as resolved.
const _origRenderGrid = renderGrid;
renderGrid = function(b) {
  // mutate the cached data locally to reflect _localActions before rendering
  if (_cachedData && Array.isArray(_cachedData.failures)) {
    _cachedData.failures.forEach(f => {
      const local = _localActions.get(f.id);
      if (local && local.resolved && f.status === 'triage') f.status = 'resolved';
    });
  }
  _origRenderGrid(b);
};

// ===== Age-on-login gate =====
const AGE_PREF_KEY = 'v7-age-pref';
function showAgeGate() {
  const gate = document.getElementById('ageGate');
  if (!gate) return;
  gate.style.display = 'flex';
  const g = document.getElementById('ageGateSlider');
  const num = document.getElementById('ageGateNum');
  const bandEl = document.getElementById('ageGateBand');
  const taglineEl = document.getElementById('ageGateTagline');
  const presetsEl = document.getElementById('ageGatePresets');

  presetsEl.innerHTML = BAND_PRESETS.map(p =>
    `<button class="pill-btn" onclick="document.getElementById('ageGateSlider').value=${p.age}; document.getElementById('ageGateSlider').dispatchEvent(new Event('input'));">${p.label}</button>`
  ).join('');

  function update() {
    const a = Number(g.value);
    const b = bandFor(a);
    num.textContent = a;
    bandEl.textContent = b.label;
    taglineEl.textContent = b.tagline;
  }
  g.addEventListener('input', update);
  update();
}

function acceptAge(forced, skip) {
  const age = forced || Number(document.getElementById('ageGateSlider').value);
  try { localStorage.setItem(AGE_PREF_KEY, String(age)); } catch {}
  document.getElementById('ageGate').style.display = 'none';
  slider.value = age;
  applyBand(age);
}

// On load: read saved age pref, or show gate.
(function initAgePref(){
  let saved = null;
  try { saved = localStorage.getItem(AGE_PREF_KEY); } catch {}
  if (saved && !isNaN(Number(saved))) {
    slider.value = saved;
    // applyBand will run when loadData → renderGrid finishes; set body immediately too.
    document.body.dataset.band = bandFor(Number(saved)).id;
  } else {
    // first-time visit
    setTimeout(showAgeGate, 250);
  }
})();

loadData();
</script>
</body></html>