← 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>