Files
mathias 6967d12d1d
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 18s
CD / Deploy via GitOps (push) Has been skipped
feat(atlas): weight replay pacing on CI/CD stages by real job durations (#5)
TDD: Job.Seconds() + StageSeconds() derive real CI/CD dwell time from the
latest run's per-job created_at/updated_at (last job in pipeline order =
deploy/stage 07, everything before it = CI/stage 06).

Frontend: weightedSpineDist() redistributes the pixel-time-budget the
06/07 segments already had, splitting it by real CI:CD duration ratio
instead of raw pixel width. Falls back to the exact prior constant-speed
sweep when no run data is available (weights default to segment pixel
length) or when a job is skipped (0 duration) — no behavior change for
stages 00-05/08, which still have no live timing source (same gap as #5's
ledger item).

Verified: real duration values flow through /api/atlas.json (ci_duration_s:
18 observed against a real run), full page screenshot confirms no visual
regression.
2026-07-20 23:22:53 +02:00

426 lines
21 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CAD Atlas · From Signal to Pod</title>
<style>
:root{
--bg:#080b12; --panel:#0e1420; --panel2:#121a2b; --line:#1e2b44;
--ink:#cdd9ef; --dim:#7f92b5; --mono:#8fb4ff;
--blue:#4aa8ff; --amber:#f5b942; --coral:#ff7a5c; --green:#4ad07a; --violet:#9b8cff; --gold:#e6c15a;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{
background:
radial-gradient(circle at 1px 1px,#16213a 1px,transparent 0) 0 0/26px 26px,
var(--bg);
color:var(--ink);
font:14px/1.5 ui-sans-serif,system-ui,Segoe UI,Roboto,sans-serif;
-webkit-font-smoothing:antialiased;
}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
header{
position:sticky;top:0;z-index:30;backdrop-filter:blur(8px);
background:linear-gradient(180deg,rgba(8,11,18,.94),rgba(8,11,18,.72));
border-bottom:1px solid var(--line);
display:flex;align-items:baseline;gap:16px;flex-wrap:wrap;padding:14px 22px;
}
header h1{font-size:19px;margin:0}
header h1 b{color:var(--blue)}
header .sub{color:var(--dim);font-size:12px}
header .sub em{color:var(--amber);font-style:normal}
.controls{margin-left:auto;display:flex;gap:8px}
button{
font:inherit;font-size:12px;color:var(--ink);cursor:pointer;
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:7px 13px;display:inline-flex;align-items:center;gap:7px;
}
button:hover{border-color:var(--blue)}
button.on{border-color:var(--amber);color:var(--amber)}
button .dot{width:7px;height:7px;border-radius:50%;background:var(--blue)}
button.on .dot{background:var(--amber)}
.substrate{display:flex;gap:10px;flex-wrap:wrap;align-items:center;
padding:10px 22px;border-bottom:1px solid var(--line);background:var(--panel)}
.substrate .lbl{color:var(--dim);font-size:11px;letter-spacing:1.5px;margin-right:4px}
.host{border:1px solid var(--line);border-radius:8px;padding:6px 11px;
background:var(--panel2);font-size:12px;display:flex;gap:8px;align-items:center}
.host b{color:var(--blue)}
.host .k{color:var(--dim);font-size:11px}
.host.mesh{border-style:dashed;color:var(--dim)}
.tl{display:none;gap:5px;align-items:center;flex-wrap:wrap;
padding:8px 22px;border-bottom:1px solid var(--line);background:var(--panel)}
.tl .lbl{color:var(--dim);font-size:11px;letter-spacing:1.5px;margin-right:4px}
.rundot{width:22px;height:16px;border-radius:4px;border:1px solid rgba(0,0,0,.35);
display:inline-flex;align-items:center;justify-content:center;
font-size:9px;color:#08121f;font-weight:600}
/* progressive disclosure */
.tech-sub{color:var(--dim);font-size:11px;font-family:ui-monospace,SFMono-Regular,monospace;margin:1px 0 5px}
.plain-what{color:var(--ink);font-size:12.5px;line-height:1.45;margin-bottom:2px;opacity:.92}
.translabel{fill:var(--mono);font-size:10px;font-family:ui-monospace,SFMono-Regular,monospace}
.translabel-gate{fill:var(--gold);font-weight:700}
.stagehead{min-height:64px}
@media(min-width:821px){
body.plain .stagehead{min-height:188px}
.stage .node:first-of-type{margin-top:24px}
}
.trans-row{display:none}
@media(max-width:820px){
.trans-row{display:block;margin:0 0 10px 6px;padding:7px 12px;border-left:3px solid var(--mono);
background:var(--panel);border-radius:0 8px 8px 0;font-size:12px;color:var(--dim);line-height:1.45}
.trans-row .tr-lbl{display:block;color:var(--mono);font-weight:600;margin-bottom:2px}
.trans-row-gate{border-left-color:var(--gold)}
.trans-row-gate .tr-lbl{color:var(--gold)}
}
/* Plain view hides the jargon-dense infra ribbon + technical footer */
body.plain .substrate{display:none}
body.plain footer{display:none}
.legend{display:flex;gap:16px;flex-wrap:wrap;align-items:center;
padding:7px 22px;border-bottom:1px solid var(--line);background:var(--bg);font-size:11px;color:var(--dim)}
.legend .lbl{letter-spacing:1.5px;margin-right:2px}
.legend .lg{display:inline-flex;align-items:center;gap:6px}
.legend .lg i{width:11px;height:11px;border-radius:3px;display:inline-block;border:1px solid rgba(0,0,0,.35)}
.legend .dash{width:18px;border-top:2px dashed var(--violet);display:inline-block}
/* overview rail — see the whole 9-stage shape + jump to any stage */
.rail{display:flex;gap:4px;flex-wrap:wrap;align-items:center;
padding:8px 22px;border-bottom:1px solid var(--line);background:var(--panel)}
.rail .lbl{color:var(--dim);letter-spacing:1.5px;margin-right:4px;font-size:11px}
.railchip{font:inherit;font-size:11px;cursor:pointer;color:var(--ink);
background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:4px 9px}
.railchip:hover{border-color:var(--blue)}
.railchip b{color:var(--dim);font-weight:600;margin-right:3px}
.railchip.gate{border-color:rgba(245,185,66,.5)} .railchip.telos{border-color:rgba(155,140,255,.5)}
.railchip.exec{border-color:rgba(255,122,92,.5)} .railchip.cd{border-color:rgba(74,208,122,.5)}
.railarr{color:var(--dim);font-size:10px}
/* Plain view: drop decorative node pills so colour is reserved for live status (keyed in the legend) */
body.plain .node .pill{display:none}
.scroll{overflow-x:auto;padding:24px 22px 20px}
.track{position:relative;display:flex;align-items:flex-start;min-width:max-content}
svg.spine{position:absolute;left:0;top:0;z-index:0;pointer-events:none;overflow:visible}
.pulse{position:absolute;top:0;left:0;width:13px;height:13px;border-radius:50%;
background:var(--amber);box-shadow:0 0 15px 4px rgba(245,185,66,.75);
transform:translate(-6.5px,-6.5px);z-index:6}
.stage{width:300px;flex:0 0 300px;padding:0 14px;position:relative;z-index:2}
.stage .no{color:var(--dim);font-size:11px;letter-spacing:2px}
.stage h2{font-size:17px;margin:6px 0 2px}
.stage .path{color:var(--mono);font-size:11.5px;margin-bottom:6px;min-height:16px}
.node{display:block;border:1px solid var(--line);border-radius:11px;background:var(--panel);
padding:12px 13px;margin-top:12px;position:relative;color:inherit;text-decoration:none;
transition:border-color .25s,box-shadow .25s,transform .25s}
.node .t{font-weight:600;margin-bottom:3px;display:flex;align-items:center;gap:7px}
.node .d{color:var(--dim);font-size:12px}
.node .tag{display:inline-block;font-size:10.5px;padding:2px 7px;border-radius:20px;
border:1px solid var(--line);color:var(--dim);margin:6px 6px 0 0}
.pill{width:8px;height:8px;border-radius:50%;flex:0 0 8px}
.stage.telos h2{color:var(--violet)}
.stage.gate h2{color:var(--amber)}
.stage.exec h2{color:var(--coral)}
.stage.cd h2{color:var(--green)}
.node.win{border-color:var(--coral);box-shadow:0 0 0 1px rgba(255,122,92,.25)}
.node.gateway{border-color:var(--amber);background:linear-gradient(180deg,rgba(245,185,66,.06),transparent)}
.node.council{border-color:var(--violet)}
.node.bridge{border-color:var(--blue);border-style:dashed}
.node.oath{border-color:var(--gold);border-style:dashed;background:linear-gradient(180deg,rgba(230,193,90,.05),transparent)}
.stage.active h2{text-shadow:0 0 18px currentColor}
.stage.active .node{border-color:var(--amber);
box-shadow:0 0 0 1px rgba(245,185,66,.35),0 10px 30px -12px rgba(245,185,66,.4);transform:translateY(-2px)}
.risk{display:flex;gap:6px;margin-top:8px;flex-wrap:wrap}
.risk span{font-size:10.5px;padding:2px 7px;border-radius:6px;border:1px solid var(--line)}
.risk .lo{color:var(--green);border-color:rgba(74,208,122,.4)}
.risk .md{color:var(--amber);border-color:rgba(245,185,66,.4)}
.risk .hi{color:var(--coral);border-color:rgba(255,122,92,.4)}
.gatebtns{display:flex;gap:8px;margin-top:10px}
.gatebtns .g{flex:1;text-align:center;padding:8px;border-radius:8px;font-size:12px;border:1px solid}
.gatebtns .ok{border-color:rgba(74,208,122,.5);color:var(--green)}
.gatebtns .no{border-color:rgba(255,122,92,.5);color:var(--coral)}
footer{padding:14px 22px 30px;color:var(--dim);font-size:11px;border-top:1px solid var(--line)}
footer code{color:var(--mono)}
/* ---- responsive: stack vertically, drop the SVG spine ---- */
@media(max-width:820px){
.scroll{overflow-x:visible;padding:16px}
.track{flex-direction:column;min-width:0}
svg.spine,.pulse{display:none}
.stage{width:100%;flex:auto;padding:0;border-left:2px solid var(--line);margin-left:6px;
padding-left:16px;padding-bottom:8px}
.stage.telos{border-color:var(--violet)} .stage.gate{border-color:var(--amber)}
.stage.exec{border-color:var(--coral)} .stage.cd{border-color:var(--green)}
}
</style>
</head>
<body>
<header>
<h1><b>CAD</b> Atlas · From Signal to Pod</h1>
<span class="sub mono">one human gate · everything up- and downstream is agents · <em id="ver">dev</em></span>
<div class="controls">
<button id="mode" class="on">View · <span id="modeState">Plain</span></button>
<button id="replay"><span class="dot"></span> Replay</button>
<button id="slowmo">Slow-mo · <span id="slowState">off</span></button>
</div>
</header>
<div class="substrate" id="substrate"><span class="lbl mono">SUBSTRATE</span></div>
<div class="tl mono" id="timeline"></div>
<div class="legend mono">
<span class="lbl">KEY</span>
<span class="lg"><i style="background:var(--green)"></i>passed / ready</span>
<span class="lg"><i style="background:var(--amber)"></i>running / waiting</span>
<span class="lg"><i style="background:var(--coral)"></i>failed / blocked</span>
<span class="lg"><i style="background:var(--gold)"></i>governance gate — must pass to advance</span>
<span class="lg"><span class="dash"></span> feedback loop · partly manual</span>
</div>
<div class="rail mono" id="rail"></div>
<div class="scroll">
<div class="track" id="track">
<svg class="spine" id="spine">
<defs>
<marker id="arw" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="#33507f"></path>
</marker>
</defs>
<path id="spinePath" fill="none" stroke="#33507f" stroke-width="2"
marker-mid="url(#arw)" marker-end="url(#arw)"></path>
<path id="loopPath" fill="none" stroke="#9b8cff" stroke-width="1.6"
stroke-dasharray="5 5" opacity=".7"></path>
<text id="loopLbl" fill="#9b8cff" font-size="11"
font-family="ui-monospace,monospace" opacity=".85"></text>
<g id="translabels"></g>
</svg>
<div class="pulse" id="pulse"></div>
</div>
</div>
<footer class="mono">
CAD → CI → CD · intent→specify→dispatch · build→test→validate · deploy→ship.
Dashed violet = feedback bus (stage 08 → TELOS: deploy outcome scored vs originating goal).
Data served from <code>/api/atlas.json</code> (authored <code>atlas.json</code> + CI stage from the live <code>cd.yml</code> + substrate from the live cluster nodes).
Phase C: <code>Gitea run API</code> live (latest run on the CI stage). Pending: <code>assessor-loop</code> ledger · <code>session_log</code> · per-job status · Flux events.
</footer>
<script>
// Single source of truth: /api/atlas.json (Go app — authored atlas.json + CI
// stage from the live cd.yml + substrate from the live cluster). These start
// empty and are filled by init()'s fetch; on failure the page shows an error
// banner rather than stale inline data.
let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[], CI_DUR=0, CD_DUR=0;
let MODE = localStorage.getItem('atlas-mode') || 'plain'; // 'plain' | 'technical'
const track=document.getElementById('track');
let stageEls=[];
function renderAtlas(){
const sub=document.getElementById('substrate');
sub.querySelectorAll('.host').forEach(el=>el.remove());
SUBSTRATE.forEach(h=>{const el=document.createElement('div');el.className='host';
el.innerHTML=`<b>${h.n}</b><span class="k mono">${h.k}</span>`;sub.appendChild(el);});
const mesh=document.createElement('div');mesh.className='host mesh mono';mesh.textContent=NS;sub.appendChild(mesh);
stageEls=[];
track.querySelectorAll('.stage,.trans-row').forEach(el=>el.remove());
const plain = MODE==='plain';
STAGES.forEach((s,idx)=>{
const st=document.createElement('div');st.className='stage '+(s.cls||'');
const head = plain
? `<div class="no mono">${s.no}</div><h2>${s.plain_title||s.title}</h2>`+
`<div class="tech-sub">${s.title}</div>`+(s.plain?`<div class="plain-what">${s.plain}</div>`:'')
: `<div class="no mono">${s.no}</div><h2>${s.title}</h2><div class="path mono">${s.path||''}</div>`;
let h = `<div class="stagehead">${head}</div>`;
(s.nodes||[]).forEach(n=>{
const pill=n.pill?`<span class="pill" style="background:${n.pill}"></span>`:'';
const body = plain ? (n.plain||n.d||'') : (n.d||'');
const ntitle = (plain && n.plain_t) ? n.plain_t : n.t;
const nsub = (plain && n.plain_t && n.plain_t!==n.t) ? `<div class="tech-sub">${n.t}</div>` : '';
let inner=`<div class="t">${pill}${ntitle}</div>${nsub}`+(body?`<div class="d">${body}</div>`:'');
if(!plain){
if(n.tags&&n.tags.length)inner+=n.tags.map(t=>`<span class="tag mono">${t}</span>`).join('');
if(n.risk)inner+=`<div class="risk mono"><span class="lo">LOW · auto</span><span class="md">MED · ntfy gate</span><span class="hi">HIGH · blocked</span></div>`;
}
if(n.gate)inner+=`<div class="gatebtns mono"><div class="g ok">✓ approve</div><div class="g no">✕ reject</div></div>`;
const tag = n.url ? 'a' : 'div';
const link = n.url ? ` href="${n.url}" target="_blank" rel="noopener"` : '';
h+=`<${tag} class="node ${n.cls||''}"${link}>${inner}</${tag}>`;
});
st.innerHTML=h;track.appendChild(st);stageEls.push(st);
// stacked-layout transition row (shown on mobile where the SVG spine is hidden)
if(s.trans_label){
const tr=document.createElement('div');
tr.className='trans-row'+((idx===4||idx===6)?' trans-row-gate':'');
tr.innerHTML=`<span class="tr-lbl">${(idx===4||idx===6)?'🔒 ':''}${s.trans_label}</span>${s.trans?' '+s.trans:''}`;
track.appendChild(tr);
}
});
renderRail();
}
function renderRail(){
const rail=document.getElementById('rail');
rail.innerHTML='<span class="lbl">PIPELINE</span>';
STAGES.forEach((s,i)=>{
const c=document.createElement('button');
c.className='railchip'+(s.cls?' '+s.cls:'');
c.innerHTML=`<b>${(s.no||'').replace('STAGE ','')}</b>${s.short||s.plain_title||s.title}`;
c.onclick=()=>{ if(stageEls[i]) stageEls[i].scrollIntoView({behavior:'smooth',inline:'center',block:'nearest'}); };
rail.appendChild(c);
if(i<STAGES.length-1){const a=document.createElement('span');a.className='railarr';a.textContent='→';rail.appendChild(a);}
});
}
function renderTimeline(){
const tl=document.getElementById('timeline');
if(!TIMELINE.length){tl.style.display='none';return;}
tl.innerHTML='<span class="lbl">RECENT RUNS</span>';
TIMELINE.forEach(d=>{
const c=d.state==='success'?'var(--green)':(d.state==='failure'?'var(--coral)':'var(--amber)');
const el=document.createElement('span');
el.className='rundot';el.style.background=c;
el.title='run #'+d.number+' · '+d.state;el.textContent=d.number;
tl.appendChild(el);
});
tl.style.display='flex';
}
/* ---- geometry ---- */
const spine=document.getElementById('spine'), spinePath=document.getElementById('spinePath'),
loopPath=document.getElementById('loopPath'), loopLbl=document.getElementById('loopLbl'),
pulse=document.getElementById('pulse');
let RAILY=70;
let cs=[],loopY=0,spineLen=0,loopLen=0,slow=false,raf=null,t0=null,mobile=false;
function build(){
mobile=window.matchMedia('(max-width:820px)').matches;
if(mobile)return;
cs=stageEls.map(s=>s.offsetLeft+s.offsetWidth/2);
// spine sits in the band between the (uniform) stage headers and the first node
const firstTops=stageEls.map(s=>{const n=s.querySelector('.node');return n?s.offsetTop+n.offsetTop:120;});
RAILY=Math.max(66, Math.min(...firstTops)-16);
const maxBottom=Math.max(...stageEls.map(s=>s.offsetTop+s.offsetHeight));
loopY=maxBottom+40;
spine.setAttribute('width',track.scrollWidth);
spine.setAttribute('height',loopY+70);
track.style.minHeight=(loopY+70)+'px';
spinePath.setAttribute('d','M '+cs[0]+' '+RAILY+cs.slice(1).map(x=>' L '+x+' '+RAILY).join(''));
const lastX=cs[cs.length-1], telosX=cs[1];
loopPath.setAttribute('d',`M ${lastX} ${RAILY} L ${lastX} ${loopY} L ${telosX} ${loopY} L ${telosX} ${RAILY}`);
loopLbl.setAttribute('x',(telosX+lastX)/2-90);loopLbl.setAttribute('y',loopY-8);
loopLbl.textContent=(STAGES[8]&&STAGES[8].trans_label?STAGES[8].trans_label+' · ':'')+'feedback bus · partly manual today';
// transition labels on the spine — the "what must be true to advance" story.
// Always visible (default layer). Gate hops (04→05 human, 06→07 CI) in gold.
const tg=document.getElementById('translabels'); tg.innerHTML='';
for(let i=0;i<cs.length-1;i++){
const s=STAGES[i]; if(!s||!s.trans_label) continue;
const gate = (i===4||i===6);
const t=document.createElementNS('http://www.w3.org/2000/svg','text');
t.setAttribute('x',(cs[i]+cs[i+1])/2);t.setAttribute('y',RAILY-9);
t.setAttribute('text-anchor','middle');
t.setAttribute('class',gate?'translabel translabel-gate':'translabel');
t.textContent=(gate?'🔒 ':'')+s.trans_label;
const ttl=document.createElementNS('http://www.w3.org/2000/svg','title');
ttl.textContent=s.trans||''; t.appendChild(ttl);
tg.appendChild(t);
}
spineLen=spinePath.getTotalLength();loopLen=loopPath.getTotalLength();
}
// weightedSpineDist maps f (0..1 time-progress across the spine) to an arc-length
// distance. Default weight per stage-to-stage segment is its own pixel length —
// reduces to the old constant-pixel-speed sweep. When a real run's CI/CD durations
// are known, the pixel-time-budget already held by the 06(CI)/07(CD) segments is
// re-split by their real relative duration instead of by raw pixel width — so the
// reel visits stage 06 vs 07 at speeds proportional to how long they actually took.
function weightedSpineDist(f){
const n=cs.length-1;
if(n<=0)return 0;
const segLens=[];for(let i=0;i<n;i++)segLens.push(cs[i+1]-cs[i]);
const weights=segLens.slice();
if(CI_DUR>0&&CD_DUR>0&&n>=7){
const budget=weights[5]+weights[6], tot=CI_DUR+CD_DUR;
weights[5]=budget*CI_DUR/tot; weights[6]=budget*CD_DUR/tot;
}
const totalW=weights.reduce((a,b)=>a+b,0);
const target=f*totalW;
let acc=0;
for(let i=0;i<n;i++){
if(target<=acc+weights[i]||i===n-1){
const local=weights[i]>0?(target-acc)/weights[i]:0;
const segStart=cs[i]-cs[0];
return Math.min(spineLen,Math.max(0,segStart+segLens[i]*Math.min(1,Math.max(0,local))));
}
acc+=weights[i];
}
return spineLen;
}
function run(ts){
if(mobile)return;
if(!t0)t0=ts;
const dur=slow?16000:7000;
const p=Math.min((ts-t0)/dur,1);
const spineFrac=spineLen/(spineLen+loopLen);
let dist,onLoop;
if(p<spineFrac){
dist=weightedSpineDist(p/spineFrac); onLoop=false;
}else{
dist=spineLen+loopLen*((p-spineFrac)/(1-spineFrac)); onLoop=true;
}
let pt=onLoop?loopPath.getPointAtLength(dist-spineLen):spinePath.getPointAtLength(dist);
pulse.style.left=pt.x+'px';pulse.style.top=pt.y+'px';
pulse.style.background=onLoop?'var(--violet)':'var(--amber)';
pulse.style.boxShadow=onLoop?'0 0 15px 4px rgba(155,140,255,.75)':'0 0 15px 4px rgba(245,185,66,.75)';
let act=onLoop?1:0; // during loop, highlight TELOS
if(!onLoop)cs.forEach((c,i)=>{if(pt.x>=c-150)act=i;});
stageEls.forEach((s,i)=>s.classList.toggle('active',i===act));
if(p<1)raf=requestAnimationFrame(run);
}
function replay(){cancelAnimationFrame(raf);t0=null;build();if(!mobile)raf=requestAnimationFrame(run);}
document.getElementById('replay').onclick=replay;
document.getElementById('slowmo').onclick=e=>{slow=!slow;e.currentTarget.classList.toggle('on',slow);
document.getElementById('slowState').textContent=slow?'on':'off';replay();};
function applyMode(){
document.getElementById('modeState').textContent = MODE==='plain'?'Plain':'Technical';
document.getElementById('mode').classList.toggle('on', MODE==='plain');
document.body.classList.toggle('plain', MODE==='plain');
}
document.getElementById('mode').onclick=()=>{
MODE = MODE==='plain' ? 'technical' : 'plain';
localStorage.setItem('atlas-mode',MODE);
applyMode(); renderAtlas(); replay();
};
window.addEventListener('resize',()=>{clearTimeout(window._r);window._r=setTimeout(replay,150);});
async function init(){
try{
const r=await fetch('/api/atlas.json');
if(!r.ok) throw new Error('atlas.json '+r.status);
const data=await r.json();
if(Array.isArray(data.substrate)) SUBSTRATE=data.substrate;
if(typeof data.ns==='string') NS=data.ns;
if(Array.isArray(data.stages)) STAGES=data.stages;
if(Array.isArray(data.timeline)) TIMELINE=data.timeline;
if(typeof data.ci_duration_s==='number') CI_DUR=data.ci_duration_s;
if(typeof data.cd_duration_s==='number') CD_DUR=data.cd_duration_s;
if(data.version) document.getElementById('ver').textContent=data.version;
}catch(e){
console.error('atlas: failed to load /api/atlas.json —',e);
document.getElementById('track').insertAdjacentHTML('beforeend',
'<div class="stage"><div class="no mono">ERROR</div>'+
'<h2 style="color:var(--coral)">Data unavailable</h2>'+
'<div class="path mono">/api/atlas.json failed to load</div></div>');
return;
}
applyMode();
renderAtlas();
renderTimeline();
replay();
}
window.addEventListener('load',init);
</script>
</body>
</html>