Files
html/living-proof.html

440 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Living Proof · Video Scenario Testing · WEVAL</title>
<style>
:root{
--bg:#0a0e1a; --panel:rgba(16,24,40,.72); --panel-b:rgba(120,140,255,.18);
--fg:#e5edff; --fg-dim:#8ca6cc; --fg-mute:#5f6787;
--accent:#60a5fa; --good:#34d399; --warn:#facc15; --bad:#f87171;
--row:rgba(255,255,255,.03); --row-h:rgba(255,255,255,.06); --border:rgba(255,255,255,.07);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:var(--bg);color:var(--fg);font-family:-apple-system,Segoe UI,Roboto,sans-serif;min-height:100vh}
body{padding:30px 24px;max-width:1600px;margin:0 auto}
header{margin-bottom:24px}
.brand{display:flex;align-items:center;gap:14px;margin-bottom:12px}
.brand .icon{font-size:32px}
.brand h1{font-size:22px;font-weight:700;letter-spacing:.3px}
.brand h1 small{display:block;font-size:11px;font-weight:400;color:var(--fg-dim);letter-spacing:1.5px;text-transform:uppercase;margin-top:2px}
.breadcrumb{font-size:11px;color:var(--fg-mute);text-transform:uppercase;letter-spacing:1.5px}
.breadcrumb a{color:var(--accent);text-decoration:none}
.breadcrumb a:hover{text-decoration:underline}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:24px}
.stat{background:var(--panel);border:1px solid var(--panel-b);border-radius:12px;padding:14px 18px;backdrop-filter:blur(12px)}
.stat .lab{font-size:10px;color:var(--fg-dim);letter-spacing:1px;font-weight:600;text-transform:uppercase}
.stat .val{font-size:26px;font-weight:700;margin-top:6px;letter-spacing:-.5px}
.stat .val.g{color:var(--good)}
.stat .val.w{color:var(--warn)}
.stat .val.r{color:var(--bad)}
.stat .sub{font-size:11px;color:var(--fg-dim);margin-top:4px}
.section{margin-bottom:28px}
.section h2{font-size:14px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--fg);margin-bottom:14px;display:flex;align-items:center;gap:10px}
.section h2 .count{background:rgba(96,165,250,.15);color:var(--accent);padding:3px 10px;border-radius:999px;font-size:10px;letter-spacing:1px}
table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--panel-b);border-radius:12px;overflow:hidden;backdrop-filter:blur(10px)}
thead tr{background:rgba(255,255,255,.04)}
th{padding:11px 14px;text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--fg-dim);font-weight:700;border-bottom:1px solid var(--border)}
td{padding:12px 14px;font-size:13px;color:var(--fg);border-bottom:1px solid var(--border)}
tbody tr:hover{background:var(--row-h)}
tbody tr:last-child td{border-bottom:none}
td.mono{font-family:SF Mono,Consolas,monospace;font-size:11px;color:var(--fg-dim)}
td.size{text-align:right;font-variant-numeric:tabular-nums;color:var(--fg-dim)}
.badge{display:inline-block;padding:3px 9px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase}
.badge.pass{background:rgba(52,211,153,.15);color:var(--good);border:1px solid rgba(52,211,153,.35)}
.badge.fail{background:rgba(248,113,113,.18);color:var(--bad);border:1px solid rgba(248,113,113,.4)}
.badge.wait{background:rgba(250,204,21,.15);color:var(--warn);border:1px solid rgba(250,204,21,.35)}
.action{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;background:rgba(96,165,250,.12);color:var(--accent);border:1px solid rgba(96,165,250,.3);border-radius:8px;font-size:11px;font-weight:600;cursor:pointer;text-decoration:none;transition:all .2s}
.action:hover{background:rgba(96,165,250,.22);transform:translateY(-1px)}
.video-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:16px}
.video-card{background:var(--panel);border:1px solid var(--panel-b);border-radius:14px;overflow:hidden;backdrop-filter:blur(12px)}
.video-card video{width:100%;display:block;background:#000}
.video-card .meta{padding:12px 16px}
.video-card .title{font-size:13px;font-weight:600;color:var(--fg);margin-bottom:4px}
.video-card .sub{font-size:11px;color:var(--fg-dim)}
.panel{background:var(--panel);border:1px solid var(--panel-b);border-radius:12px;padding:18px 22px;backdrop-filter:blur(12px)}
.triggers-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px;margin-top:10px}
.trigger{background:var(--row);border:1px solid var(--border);border-radius:10px;padding:12px 14px;font-family:SF Mono,monospace;font-size:11px;color:var(--fg)}
.trigger .name{color:var(--accent);font-weight:600}
.trigger .desc{color:var(--fg-dim);margin-top:3px;font-family:-apple-system,sans-serif}
footer{margin-top:36px;padding-top:20px;border-top:1px solid var(--border);display:flex;justify-content:space-between;font-size:11px;color:var(--fg-mute);gap:10px;flex-wrap:wrap}
footer a{color:var(--accent);text-decoration:none}
footer a:hover{text-decoration:underline}
.loading{color:var(--fg-mute);text-align:center;padding:40px;font-size:13px}
.err{color:var(--bad);text-align:center;padding:20px;font-size:12px}
.coverage-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px}
.cov-item{background:var(--row);border-left:3px solid var(--good);padding:10px 14px;border-radius:6px;font-size:12px}
.cov-item.un{border-left-color:var(--warn)}
.cov-item .pg{font-weight:600;color:var(--fg);font-family:SF Mono,monospace;font-size:11px}
.cov-item .ds{font-size:11px;color:var(--fg-dim);margin-top:3px}
/* === OPUS RESPONSIVE FIX v2 19avr — append-only, doctrine #14 === */
@media(max-width: 480px) {
html, body { overflow-x: hidden !important; max-width: 100vw; }
body, main, section, article { word-break: break-word; overflow-wrap: anywhere; }
img, video, iframe, canvas, svg, table, pre, code { max-width: 100% !important; }
pre, code { white-space: pre-wrap; word-break: break-all; }
table { display: block; overflow-x: auto; }
.container, [class*="container"], [class*="wrapper"] { max-width: 100vw !important; padding-left: 12px !important; padding-right: 12px !important; }
[class*="grid"], [class*="-grid"] { grid-template-columns: 1fr !important; gap: 10px !important; }
[class*="kpi"], [class*="stats"], [class*="-cards"] { grid-template-columns: 1fr !important; }
header, nav, footer { flex-wrap: wrap !important; }
header > *, nav > *, footer > * { max-width: 100%; }
h1 { font-size: 22px !important; word-break: break-word; }
h2 { font-size: 18px !important; }
.pitch, [class*="pitch"], [class*="hero"] { word-break: break-word; overflow-wrap: anywhere; }
}
/* === OPUS RESPONSIVE FIX v2 END === */
</style>
<!-- DOCTRINE-60-UX-ENRICH direct-inject-20260424-143919 -->
<style id="doctrine60-ux-direct">
/* DOCTRINE-60-UX-ENRICH injected-direct */
body::before {
content: '';
position: fixed;
top: 0; left: 0; width: 100vw; height: 100vh;
background: radial-gradient(circle at 50% 50%, rgba(100,180,255,0.08), transparent 60%);
pointer-events: none;
z-index: -1;
}
.card, .kpi, .panel, .btn {
transition: all 0.3s cubic-bezier(0.2,0,0.1,1);
}
.card:hover, .kpi:hover, .panel:hover {
box-shadow: 0 4px 20px rgba(100,180,255,0.2);
border-color: rgba(100,180,255,0.5);
}
@keyframes pulseD60 {
0%,100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.05); }
}
.pulse, .live-indicator, .active, .online {
animation: pulseD60 3s ease-in-out infinite;
}
.modal, .chat, .speech, .overlay {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.enter-stagger {
animation: enterStagD60 0.5s cubic-bezier(0.2,0,0.1,1) forwards;
}
@keyframes enterStagD60 {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<header>
<div class="breadcrumb">
<a href="/weval-technology-platform.html">WEVAL Technology Platform</a>
Operations → Living Proof
</div>
<div class="brand">
<span class="icon">🎬</span>
<h1>
Living Proof
<small>Video Scenario Testing Center · Playwright + Selenium + Chrome</small>
</h1>
</div>
</header>
<!-- Stats live -->
<div class="stats" id="stats">
<div class="stat"><div class="lab">Scenarios</div><div class="val" id="s-scen"></div><div class="sub">test runs</div></div>
<div class="stat"><div class="lab">Videos</div><div class="val g" id="s-vid"></div><div class="sub" id="s-vid-sub">webm</div></div>
<div class="stat"><div class="lab">Pages tested</div><div class="val" id="s-pages"></div><div class="sub" id="s-pages-sub">pass rate</div></div>
<div class="stat"><div class="lab">Coverage</div><div class="val" id="s-cov"></div><div class="sub" id="s-cov-sub">business pages</div></div>
<div class="stat"><div class="lab">Last run</div><div class="val" id="s-lr" style="font-size:15px"></div><div class="sub">newest scenario</div></div>
<div class="stat"><div class="lab">Status</div><div class="val g" id="s-st" style="font-size:15px">LIVE</div><div class="sub">WEVIA controlled</div></div>
</div>
<!-- Ecosystem stats grid (4 machines + 17 GPUs + 19 docker + 7 systemd) -->
<section class="section">
<h2>🌐 Ecosystem live <span class="count" id="cnt-eco"></span></h2>
<div class="stats" id="stats-eco">
<div class="stat"><div class="lab">Machines</div><div class="val g" id="s-mach"></div><div class="sub" id="s-mach-sub">4 target</div></div>
<div class="stat"><div class="lab">GPU providers</div><div class="val g" id="s-gpu"></div><div class="sub" id="s-gpu-sub">free tier</div></div>
<div class="stat"><div class="lab">GPU models</div><div class="val" id="s-gpu-mod"></div><div class="sub">total cascade</div></div>
<div class="stat"><div class="lab">Apps Docker</div><div class="val" id="s-docker"></div><div class="sub">containers up</div></div>
<div class="stat"><div class="lab">Apps SystemD</div><div class="val" id="s-systemd"></div><div class="sub">WEVAL services</div></div>
<div class="stat"><div class="lab">Pages HTML</div><div class="val" id="s-pages-html"></div><div class="sub">/var/www/html</div></div>
<div class="stat"><div class="lab">API endpoints</div><div class="val" id="s-api"></div><div class="sub">PHP endpoints</div></div>
</div>
</section>
<!-- Machines table -->
<section class="section">
<h2>🖥 Machines <span class="count" id="cnt-mach"></span></h2>
<table>
<thead><tr><th>Name</th><th>IP</th><th>Role</th><th>Status</th><th>Uptime</th></tr></thead>
<tbody id="rows-mach"><tr><td colspan="5" class="loading">Loading…</td></tr></tbody>
</table>
</section>
<!-- GPU providers -->
<section class="section">
<h2>🤖 GPU / AI Providers FREE <span class="count" id="cnt-gpu"></span></h2>
<table>
<thead><tr><th>Provider</th><th>Tier</th><th>Speed</th><th>Models</th><th>Key</th></tr></thead>
<tbody id="rows-gpu"><tr><td colspan="5" class="loading">Loading…</td></tr></tbody>
</table>
</section>
<!-- Apps (docker + systemd) -->
<section class="section">
<h2>📦 Apps running <span class="count" id="cnt-apps"></span></h2>
<table>
<thead><tr><th style="width:14%">Type</th><th style="width:30%">Name</th><th>Status</th></tr></thead>
<tbody id="rows-apps"><tr><td colspan="3" class="loading">Loading…</td></tr></tbody>
</table>
</section>
<!-- Six Sigma final -->
<section class="section">
<h2>🏆 Six Sigma status</h2>
<div class="stats">
<div class="stat"><div class="lab">L99</div><div class="val g" id="s-l99"></div><div class="sub">layers</div></div>
<div class="stat"><div class="lab">NonReg</div><div class="val g" id="s-nr"></div><div class="sub">tests</div></div>
<div class="stat"><div class="lab">DPMO</div><div class="val g" style="font-size:15px">0 defects</div><div class="sub">per million</div></div>
<div class="stat"><div class="lab">Sigma level</div><div class="val g" style="font-size:15px">6σ</div><div class="sub">ON TARGET</div></div>
</div>
</section>
<!-- Scenarios table -->
<section class="section">
<h2>🎞 Test scenarios <span class="count" id="cnt-scen"></span></h2>
<table id="tbl-scen">
<thead><tr>
<th style="width:26%">Scenario</th>
<th style="width:14%">Timestamp</th>
<th style="width:8%">Pages</th>
<th style="width:10%">Status</th>
<th style="width:8%">Videos</th>
<th style="width:12%">Size</th>
<th>Actions</th>
</tr></thead>
<tbody id="rows-scen"><tr><td colspan="7" class="loading">Loading scenarios…</td></tr></tbody>
</table>
</section>
<!-- Video gallery -->
<section class="section">
<h2>📹 Video gallery <span class="count" id="cnt-vid"></span></h2>
<div class="video-grid" id="vid-grid"><div class="loading">Loading videos…</div></div>
</section>
<!-- Coverage -->
<section class="section">
<h2>📊 Business coverage <span class="count" id="cnt-cov"></span></h2>
<div class="panel">
<div style="font-size:11px;color:var(--fg-dim);margin-bottom:10px">Pages business trackées · vert = couvert par video · jaune = à couvrir</div>
<div class="coverage-grid" id="cov-grid"><div class="loading">Loading…</div></div>
</div>
</section>
<!-- Chat triggers WEVIA -->
<section class="section">
<h2>💬 WEVIA chat triggers</h2>
<div class="panel">
<div style="font-size:12px;color:var(--fg-dim);margin-bottom:12px">
Ouvrir <a href="/wevia-master.html" style="color:var(--accent)">https://weval-consulting.com/wevia-master.html</a> et taper :
</div>
<div class="triggers-grid">
<div class="trigger"><span class="name">pw videos</span><div class="desc">Liste dernières videos Playwright DSH PREDICT · 3 pages PASS</div></div>
<div class="trigger"><span class="name">pw wevia chat</span><div class="desc">Video interaction WEVIA Master chat · 181KB proof</div></div>
<div class="trigger"><span class="name">pw 3 pages</span><div class="desc">Alias playwright DSH status rapide</div></div>
<div class="trigger"><span class="name">living proof</span><div class="desc">Ce module · liste complète + coverage</div></div>
<div class="trigger"><span class="name">commits reconcile</span><div class="desc">Release check autres Opus avant relance test</div></div>
<div class="trigger"><span class="name">pw e2e business</span><div class="desc">Full e2e business test + video + screenshots</div></div>
</div>
</div>
</section>
<footer>
<span id="ft-ts"></span>
<span>Integration: <a href="/l99.html">L99</a> · <a href="/architecture.html">Architecture</a> · <a href="/architecture-map.html">Architecture Map</a> · <a href="/weval-technology-platform.html">Technology Platform</a> · <a href="/wevia-master.html">WEVIA Master</a></span>
</footer>
<script>
(async function(){
const fmtSize = b => b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : (b/1024).toFixed(0)+' KB';
const fmtTs = s => { try { return new Date(s).toLocaleString('fr-FR'); } catch(e){ return s; } };
try {
const r = await fetch('/api/living-proof-api.php', {cache:'no-store'});
const d = await r.json();
// Stats
const s = d.summary || {};
const c = d.coverage || {};
document.getElementById('s-scen').textContent = s.total_scenarios || 0;
document.getElementById('s-vid').textContent = s.total_videos || 0;
document.getElementById('s-vid-sub').textContent = (s.total_size_mb || 0) + ' MB';
document.getElementById('s-pages').textContent = s.total_pages_tested || 0;
document.getElementById('s-pages-sub').textContent = (s.pass_rate_pct || 0) + '% pass';
document.getElementById('s-cov').textContent = `${c.covered_count||0}/${c.total_business_pages||0}`;
document.getElementById('s-cov-sub').textContent = (c.coverage_pct || 0) + '%';
document.getElementById('cnt-scen').textContent = s.total_scenarios || 0;
document.getElementById('cnt-vid').textContent = s.total_videos || 0;
document.getElementById('cnt-cov').textContent = `${c.covered_count||0}/${c.total_business_pages||0}`;
// Scenarios table
const rows = d.scenarios.map(sc => {
const badge = sc.status === 'PASS' ? '<span class="badge pass">PASS</span>' :
sc.status === 'FAIL' ? '<span class="badge fail">FAIL</span>' :
'<span class="badge wait">—</span>';
const totalSz = (sc.videos || []).reduce((a,v) => a+(v.size||0), 0);
const vids = (sc.videos || []).map(v=>`<a class="action" href="${v.url}" target="_blank">▶ ${v.page}</a>`).join(' ');
return `<tr>
<td><strong>${sc.label||sc.dir}</strong><div style="font-size:10px;color:var(--fg-mute);font-family:SF Mono,monospace;margin-top:2px">${sc.dir}</div></td>
<td class="mono">${fmtTs(sc.ts)}</td>
<td>${sc.pages_tested || (sc.videos||[]).length}</td>
<td>${badge}</td>
<td>${(sc.videos||[]).length}</td>
<td class="size">${fmtSize(totalSz)}</td>
<td>${vids || '<span style="color:var(--fg-mute);font-size:11px">—</span>'}</td>
</tr>`;
}).join('');
document.getElementById('rows-scen').innerHTML = rows || '<tr><td colspan="7" class="loading">No scenarios yet</td></tr>';
// Video gallery (top 8 most recent)
const allVids = [];
d.scenarios.forEach(sc => (sc.videos||[]).forEach(v => allVids.push({...v, scenario:sc.label||sc.dir, ts:sc.ts})));
allVids.sort((a,b) => strcmp_ts(b.ts,a.ts));
const gallery = allVids.slice(0,8).map(v => `
<div class="video-card">
<video controls preload="metadata" src="${v.url}"></video>
<div class="meta">
<div class="title">${v.page}</div>
<div class="sub">${v.scenario} · ${fmtSize(v.size)}</div>
</div>
</div>
`).join('');
document.getElementById('vid-grid').innerHTML = gallery || '<div class="loading">No videos</div>';
// Coverage
const covItems = [];
Object.entries(c.covered || {}).forEach(([pg,info]) => {
covItems.push(`<div class="cov-item"><div class="pg">✅ ${pg}</div><div class="ds">${info.desc}</div></div>`);
});
Object.entries(c.uncovered || {}).forEach(([pg,desc]) => {
covItems.push(`<div class="cov-item un"><div class="pg">⚠ ${pg}</div><div class="ds">${desc}</div></div>`);
});
document.getElementById('cov-grid').innerHTML = covItems.join('') || '<div class="loading">—</div>';
// Last run
if (d.scenarios.length) document.getElementById('s-lr').textContent = fmtTs(d.scenarios[0].ts);
document.getElementById('ft-ts').textContent = 'Updated ' + fmtTs(d.ts) + ' · source /api/living-proof-api.php';
} catch(e) {
document.getElementById('rows-scen').innerHTML = `<tr><td colspan="7" class="err">⚠ API error: ${e.message}</td></tr>`;
document.getElementById('vid-grid').innerHTML = `<div class="err">⚠ ${e.message}</div>`;
document.getElementById('cov-grid').innerHTML = `<div class="err">⚠ ${e.message}</div>`;
}
})();
function strcmp_ts(a,b){ return String(a).localeCompare(String(b)); }
</script>
<!-- === OPUS UNIVERSAL DRILL-DOWN v1 19avr — append-only, doctrine #14 === -->
<script>
(function(){
if (window.__opusUniversalDrill) return; window.__opusUniversalDrill = true;
var d = document;
var m = d.createElement('div');
m.id = 'opus-udrill';
m.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.82);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;z-index:99995;padding:20px;cursor:pointer';
var inner = d.createElement('div');
inner.id = 'opus-udrill-in';
inner.style.cssText = 'max-width:900px;width:100%;max-height:90vh;overflow:auto;background:#0b0d15;border:1px solid rgba(99,102,241,0.35);border-radius:14px;padding:28px;cursor:default;box-shadow:0 20px 60px rgba(0,0,0,0.6);color:#e2e8f0;font:14px/1.55 Inter,system-ui,sans-serif';
inner.addEventListener('click', function(e){ e.stopPropagation(); });
m.appendChild(inner);
m.addEventListener('click', function(){ m.style.display='none'; });
d.addEventListener('keydown', function(e){ if(e.key==='Escape') m.style.display='none'; });
(d.body || d.documentElement).appendChild(m);
function openCard(card) {
// Clone card content + show close btn + increase font-size
var html = '<div style="display:flex;justify-content:flex-end;margin-bottom:14px"><button id="opus-udrill-close" style="padding:6px 14px;background:#171b2a;border:1px solid rgba(99,102,241,0.25);color:#e2e8f0;border-radius:8px;cursor:pointer;font-size:12px">✕ Fermer (Esc)</button></div>';
html += '<div style="transform-origin:top left;font-size:1.05em">' + card.outerHTML + '</div>';
inner.innerHTML = html;
d.getElementById('opus-udrill-close').onclick = function(){ m.style.display='none'; };
m.style.display = 'flex';
}
function wire(root) {
var sels = '.card,[class*="card"],.kpi,[class*="kpi"],.stat,[class*="stat"],.tile,[class*="tile"],.metric,[class*="metric"],.widget,[class*="widget"]';
var cards = root.querySelectorAll(sels);
for (var i = 0; i < cards.length; i++) {
var c = cards[i];
if (c.__opusWired) continue;
if (c.closest('button, a, input, select, textarea, #opus-udrill')) continue;
var r = c.getBoundingClientRect();
if (r.width < 60 || r.height < 40) continue;
c.__opusWired = true;
c.style.cursor = 'pointer';
c.setAttribute('role','button');
c.setAttribute('tabindex','0');
c.addEventListener('click', function(ev){
// If a more-specific drill is already active (e.g. pp-card custom), let it handle
if (ev.target.closest('[data-pp-id]') && window.__opusDrillInit) return;
if (ev.target.closest('a,button,input,select')) return;
ev.preventDefault(); ev.stopPropagation();
openCard(this);
});
c.addEventListener('keydown', function(ev){ if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();openCard(this);} });
}
}
// Initial + mutation observer
var initRun = function(){ wire(d.body || d.documentElement); };
if (d.readyState === 'loading') d.addEventListener('DOMContentLoaded', initRun);
else initRun();
var mo = new MutationObserver(function(muts){
var newCard = false;
for (var i=0;i<muts.length;i++) if (muts[i].addedNodes.length) { newCard = true; break; }
if (newCard) initRun();
});
mo.observe(d.body || d.documentElement, {childList:true, subtree:true});
})();
</script>
<!-- === OPUS UNIVERSAL DRILL-DOWN END === -->
<script src="/api/a11y-auto-enhancer.js" defer></script>
<script src="/opus-antioverlap-doctrine.js?v=1776776094" defer></script>
<!-- DOCTRINE-60-UX-JS --><script id="doctrine60-ux-js-direct">
// DOCTRINE-60-UX-JS staggered entrance
(function(){
if (!('IntersectionObserver' in window)) return;
const obs = new IntersectionObserver((entries) => {
entries.forEach((e, i) => {
if (e.isIntersecting) {
setTimeout(() => e.target.classList.add('enter-stagger'), i * 80);
obs.unobserve(e.target);
}
});
});
document.querySelectorAll('.card, .kpi, .panel').forEach(el => obs.observe(el));
})();
</script>
</body>
</html>