feat(atlas): UX sprint — progressive disclosure (Plain default + transitions)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 18s
CD / Deploy via GitOps (push) Has been skipped

Makes the atlas self-explanatory. Every stage/node gains a plain-language layer
(plain_title + plain "what happens" + jargon-free node text); the previous
technical copy demotes to a subtitle + on-demand detail. A Plain⇄Technical
toggle (default Plain, persisted) flips the whole atlas. Biggest win: every
transition arrow is now LABELLED with "what must be true to advance" (the gated-
flow story that was invisible), gate hops (human @04, CI @06) styled distinctly.
Spine repositioned into a uniform header band so labels never collide with copy.

Copy grounded in a fresh-eyes UX review (docs/UX-REVIEW.md, reviewer≠implementer).
Data model: plain_title/plain/trans_label/trans on Stage, plain on Node — guarded
by a test (every stage has plain_title + a transition). Live overlays unchanged.

Verified: build/vet/lint(0)/test green; Plain render screenshot-checked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 08:22:21 +02:00
co-authored by Claude Opus 4.8
parent 1f7c9ab1eb
commit 5048450b79
5 changed files with 349 additions and 44 deletions
+57 -8
View File
@@ -59,6 +59,15 @@
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}
body.plain .stagehead{min-height:188px}
.stage .node:first-of-type{margin-top:24px}
.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}
@@ -123,6 +132,7 @@
<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>
@@ -145,6 +155,7 @@
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>
@@ -163,6 +174,7 @@
// 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=[];
let MODE = localStorage.getItem('atlas-mode') || 'plain'; // 'plain' | 'technical'
const track=document.getElementById('track');
let stageEls=[];
@@ -174,14 +186,22 @@ function renderAtlas(){
const mesh=document.createElement('div');mesh.className='host mesh mono';mesh.textContent=NS;sub.appendChild(mesh);
stageEls=[];
track.querySelectorAll('.stage').forEach(el=>el.remove());
const plain = MODE==='plain';
STAGES.forEach(s=>{
const st=document.createElement('div');st.className='stage '+s.cls;
let h=`<div class="no mono">${s.no}</div><h2>${s.title}</h2><div class="path mono">${s.path||''}</div>`;
s.nodes.forEach(n=>{
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>`:'';
let inner=`<div class="t">${pill}${n.t}</div><div class="d">${n.d}</div>`;
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>`;
const body = plain ? (n.plain||n.d||'') : (n.d||'');
let inner=`<div class="t">${pill}${n.t}</div>`+(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>`;
h+=`<div class="node ${n.cls||''}">${inner}</div>`;
});
@@ -207,13 +227,16 @@ function renderTimeline(){
const spine=document.getElementById('spine'), spinePath=document.getElementById('spinePath'),
loopPath=document.getElementById('loopPath'), loopLbl=document.getElementById('loopLbl'),
pulse=document.getElementById('pulse');
const RAILY=70;
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);
@@ -223,7 +246,22 @@ function build(){
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='feedback bus · outcome → goal';
loopLbl.textContent=(STAGES[8]&&STAGES[8].trans_label?STAGES[8].trans_label+' · ':'')+'feedback bus';
// 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();
}
function run(ts){
@@ -246,6 +284,16 @@ function replay(){cancelAnimationFrame(raf);t0=null;build();if(!mobile)raf=reque
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{
@@ -265,6 +313,7 @@ async function init(){
'<div class="path mono">/api/atlas.json failed to load</div></div>');
return;
}
applyMode();
renderAtlas();
renderTimeline();
replay();