v2 source, NB2 prompt contract, and the slice editor
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Exploded Cluster — slice editor</title>
|
||||
<style>
|
||||
:root{
|
||||
--void:#070b14; --panel:#0d1422; --line:#1e2b42;
|
||||
--cyan:#3fbaf5; --magenta:#e879f9; --ink:#c8d6ee; --dim:#6b7fa3;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--void);color:var(--ink);
|
||||
font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
display:grid;grid-template-columns:1fr 330px;height:100vh;overflow:hidden}
|
||||
@media (max-width:860px){body{grid-template-columns:1fr;grid-template-rows:44vh 1fr}}
|
||||
|
||||
#stage{position:relative;overflow:auto;display:grid;place-items:center;padding:20px;
|
||||
background:var(--void)}
|
||||
#frame{position:relative;line-height:0;box-shadow:0 0 0 1px var(--line);background:var(--void)}
|
||||
#frame img#base{display:block;max-width:100%;height:auto}
|
||||
.part{position:absolute;inset:0;background-size:100% 100%;background-repeat:no-repeat;
|
||||
will-change:transform}
|
||||
#overlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
|
||||
#hint{position:absolute;left:10px;top:10px;font-size:11px;color:var(--dim);
|
||||
background:rgba(7,11,20,.85);padding:4px 8px;border:1px solid var(--line);pointer-events:none}
|
||||
|
||||
#panel{background:var(--panel);border-left:1px solid var(--line);
|
||||
overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:13px}
|
||||
h1{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--cyan);margin:0}
|
||||
h2{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);
|
||||
margin:0 0 6px;font-weight:400}
|
||||
.row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
||||
button{background:#12203a;color:var(--ink);border:1px solid var(--line);
|
||||
padding:6px 9px;font:inherit;font-size:11px;cursor:pointer;border-radius:3px}
|
||||
button:hover{border-color:var(--cyan);color:#fff}
|
||||
button.pri{background:var(--cyan);color:#04121f;border-color:var(--cyan);font-weight:700}
|
||||
:is(button,input,textarea,select):focus-visible{outline:2px solid var(--cyan);outline-offset:2px}
|
||||
input[type=file]{font-size:11px;width:100%}
|
||||
input[type=text]{background:#08101c;border:1px solid var(--line);color:var(--ink);
|
||||
font:inherit;font-size:11px;padding:4px 6px;border-radius:3px;width:100%}
|
||||
input[type=range]{accent-color:var(--cyan);width:100%;height:18px}
|
||||
textarea{width:100%;height:100px;background:#08101c;border:1px solid var(--line);
|
||||
color:var(--ink);font:inherit;font-size:10px;padding:6px;border-radius:3px}
|
||||
.note{font-size:10px;color:var(--dim);line-height:1.45;margin:6px 0 0}
|
||||
.sep{height:1px;background:var(--line)}
|
||||
|
||||
.cut{border:1px solid var(--line);border-radius:4px;padding:8px;background:#0a1120}
|
||||
.cut.on{border-color:var(--cyan)}
|
||||
.cut h3{margin:0 0 6px;font-size:11px;font-weight:400;color:var(--ink);
|
||||
display:flex;justify-content:space-between;align-items:center}
|
||||
.cut h3 b{font-weight:400;font-size:10px;color:var(--dim);font-variant-numeric:tabular-nums}
|
||||
.sl{display:grid;grid-template-columns:34px 1fr 38px;gap:6px;align-items:center;
|
||||
font-size:10px;color:var(--dim);margin-top:3px}
|
||||
.sl output{font-variant-numeric:tabular-nums;text-align:right;color:var(--ink)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="stage">
|
||||
<div id="frame">
|
||||
<img id="base" alt="">
|
||||
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
|
||||
<div id="hint">load a hero →</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="panel">
|
||||
<h1>Slice editor</h1>
|
||||
|
||||
<div>
|
||||
<h2>1 · hero image</h2>
|
||||
<input type="file" id="file" accept="image/*">
|
||||
<p class="note">Slices apply automatically. Pick the file, hit ▶ play.</p>
|
||||
</div>
|
||||
|
||||
<div class="sep"></div>
|
||||
|
||||
<div>
|
||||
<h2>2 · cut lines</h2>
|
||||
<div id="geo" style="display:flex;flex-direction:column;gap:7px"></div>
|
||||
<p class="note">Each cut traces a slab's <em>bottom</em> edge — left corner, down to the
|
||||
point, up to the right corner. If a slab's glow gets stranded in the slice below it,
|
||||
drag that corner <em>down</em> until the glow comes back.</p>
|
||||
<div class="row" style="margin-top:7px">
|
||||
<button id="guides">guides: on</button>
|
||||
<button id="showbase">original: hidden</button>
|
||||
<button id="reset">reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sep"></div>
|
||||
|
||||
<div>
|
||||
<h2>3 · collapse & scrub</h2>
|
||||
<div class="sl"><span>spread</span>
|
||||
<input type="range" id="spread" min="0" max="20" step=".5" value="11">
|
||||
<output id="spreadv">11</output></div>
|
||||
<input type="range" id="scrub" min="0" max="1" step="0.001" value="1" style="margin-top:8px">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<span class="note" style="margin:0">collapsed</span>
|
||||
<button id="play" class="pri">▶ play</button>
|
||||
<span class="note" style="margin:0">hero</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sep"></div>
|
||||
|
||||
<div>
|
||||
<h2>4 · manifest</h2>
|
||||
<input type="text" id="scene" value="course-I-docker-layers">
|
||||
<div class="row" style="margin-top:6px">
|
||||
<button id="export" class="pri">export</button>
|
||||
<button id="copy">copy</button>
|
||||
</div>
|
||||
<textarea id="json" spellcheck="false" placeholder="manifest JSON appears here"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
const frame = $('#frame'), base = $('#base'), overlay = $('#overlay');
|
||||
const SVGNS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
/* ── geometry ──────────────────────────────────────────────────────────────
|
||||
One cut per slab, following its BOTTOM silhouette: left corner → bottom tip →
|
||||
right corner. A slab's region is bounded ABOVE by the cut of the slab in front
|
||||
of it (exactly where it's occluded) and BELOW by its own cut. Nothing is
|
||||
duplicated; nothing loses its body. */
|
||||
const DEFAULT = {
|
||||
glass:{ l:27.5, tip:45.5, r:27.0 },
|
||||
code: { l:42.5, tip:59.5, r:42.0 },
|
||||
deps: { l:57.0, tip:73.5, r:56.5 }
|
||||
};
|
||||
const X = { l:28.3, r:71.4 }; // where the slabs' side corners sit horizontally
|
||||
let GEO = structuredClone(DEFAULT);
|
||||
|
||||
/* Seam fix: regions expand UPWARD only. The overlap always lands under the slab
|
||||
painted in front of it, and collapsing moves that slab further down still — so
|
||||
the overlap can never be revealed. Expanding downward would ghost. */
|
||||
const EPS = 0.7;
|
||||
|
||||
const Vof = g => [[X.l, g.l], [50, g.tip], [X.r, g.r]];
|
||||
const up = (v,e) => v.map(p => [p[0], +(p[1]-e).toFixed(2)]);
|
||||
|
||||
/* region between two silhouettes; sides run out to the frame edge (pure void) */
|
||||
function band(top, bot){
|
||||
const p = [];
|
||||
if (top) p.push([0,top[0][1]], ...top, [100,top[2][1]]); else p.push([0,0],[100,0]);
|
||||
if (bot) p.push([100,bot[2][1]], ...[...bot].reverse(), [0,bot[0][1]]);
|
||||
else p.push([100,100],[0,100]);
|
||||
return p;
|
||||
}
|
||||
|
||||
let SPREAD = 11, parts = [], imgSrc = null, showBase = false, showGuides = true;
|
||||
|
||||
function build(){
|
||||
const g = Vof(GEO.glass), c = Vof(GEO.code), d = Vof(GEO.deps);
|
||||
parts = [
|
||||
{ name:'base · the foundation', points: band(up(d,EPS), null), dy: 0 },
|
||||
{ name:'deps · dependencies', points: band(up(c,EPS), d), dy: SPREAD },
|
||||
{ name:'code · your app', points: band(up(g,EPS), c), dy: SPREAD*2 },
|
||||
{ name:'writable · the top layer', points: band(null, g), dy: SPREAD*3 }
|
||||
];
|
||||
}
|
||||
|
||||
/* ── render ─────────────────────────────────────────────────────────────── */
|
||||
function paint(){
|
||||
const t = +$('#scrub').value;
|
||||
frame.querySelectorAll('.part').forEach((el,i) => {
|
||||
el.style.transform = `translate(0, ${(parts[i].dy*(1-t)).toFixed(3)}%)`;
|
||||
});
|
||||
}
|
||||
|
||||
function render(){
|
||||
frame.querySelectorAll('.part').forEach(n => n.remove());
|
||||
parts.forEach((p,i) => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'part';
|
||||
if (imgSrc) d.style.backgroundImage = `url(${imgSrc})`;
|
||||
d.style.clipPath = 'polygon(' + p.points.map(q => q[0]+'% '+q[1]+'%').join(',') + ')';
|
||||
d.style.zIndex = i + 1;
|
||||
frame.insertBefore(d, overlay);
|
||||
});
|
||||
base.style.opacity = imgSrc ? (showBase ? 1 : 0) : 1;
|
||||
|
||||
overlay.innerHTML = '';
|
||||
if (showGuides) ['glass','code','deps'].forEach(k => {
|
||||
const pl = document.createElementNS(SVGNS,'polyline');
|
||||
pl.setAttribute('points', Vof(GEO[k]).map(p=>p.join(',')).join(' '));
|
||||
pl.setAttribute('fill','none');
|
||||
pl.setAttribute('stroke', k==='glass' ? '#e879f9' : k==='code' ? '#3fbaf5' : '#7ee787');
|
||||
pl.setAttribute('stroke-width','.3');
|
||||
pl.setAttribute('vector-effect','non-scaling-stroke');
|
||||
pl.setAttribute('opacity','.85');
|
||||
overlay.appendChild(pl);
|
||||
});
|
||||
paint();
|
||||
}
|
||||
|
||||
/* ── controls ───────────────────────────────────────────────────────────── */
|
||||
const LABEL = { glass:['writable', 'glass slab'], code:['code','magenta slab'], deps:['deps','circuit slab'] };
|
||||
function geoUI(){
|
||||
const host = $('#geo'); host.innerHTML = '';
|
||||
Object.keys(GEO).forEach(k => {
|
||||
const box = document.createElement('div'); box.className = 'cut';
|
||||
const h = document.createElement('h3');
|
||||
h.innerHTML = `<span>${LABEL[k][0]} <b>${LABEL[k][1]}</b></span>`;
|
||||
box.appendChild(h);
|
||||
[['l','left'],['tip','point'],['r','right']].forEach(([key,lab]) => {
|
||||
const row = document.createElement('div'); row.className = 'sl';
|
||||
const nm = document.createElement('span'); nm.textContent = lab;
|
||||
const sl = document.createElement('input');
|
||||
sl.type='range'; sl.min='0'; sl.max='100'; sl.step='.25'; sl.value=GEO[k][key];
|
||||
sl.setAttribute('aria-label', `${LABEL[k][0]} ${lab} edge`);
|
||||
const out = document.createElement('output'); out.textContent = GEO[k][key];
|
||||
sl.oninput = e => { GEO[k][key] = +e.target.value; out.textContent = e.target.value;
|
||||
box.classList.add('on'); build(); render(); };
|
||||
row.append(nm, sl, out); box.appendChild(row);
|
||||
});
|
||||
host.appendChild(box);
|
||||
});
|
||||
}
|
||||
|
||||
$('#file').onchange = e => {
|
||||
const f = e.target.files[0]; if (!f) return;
|
||||
const r = new FileReader();
|
||||
r.onload = () => { imgSrc = r.result; base.src = imgSrc;
|
||||
$('#hint').textContent = 'sliced — hit ▶ play'; build(); render(); };
|
||||
r.readAsDataURL(f);
|
||||
};
|
||||
$('#spread').oninput = e => { SPREAD = +e.target.value; $('#spreadv').textContent = e.target.value;
|
||||
build(); render(); };
|
||||
$('#scrub').oninput = paint;
|
||||
$('#guides').onclick = e => { showGuides = !showGuides;
|
||||
e.target.textContent = 'guides: ' + (showGuides?'on':'off'); render(); };
|
||||
$('#showbase').onclick = e => { showBase = !showBase;
|
||||
e.target.textContent = 'original: ' + (showBase?'shown':'hidden'); render(); };
|
||||
$('#reset').onclick = () => { GEO = structuredClone(DEFAULT); geoUI(); build(); render(); };
|
||||
|
||||
$('#play').onclick = () => {
|
||||
const s = $('#scrub');
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { s.value = 1; paint(); return; }
|
||||
const t0 = performance.now(), dur = 1700;
|
||||
const step = now => {
|
||||
const t = Math.min(1, (now - t0) / dur);
|
||||
s.value = 1 - Math.pow(1 - t, 3); // easeOutCubic — settles AT hero, never past it
|
||||
paint();
|
||||
if (t < 1) requestAnimationFrame(step);
|
||||
};
|
||||
s.value = 0; paint(); requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
const manifest = () => ({
|
||||
scene: $('#scene').value,
|
||||
note: 'travel clamps at hero (t=1); regions expand upward by EPS to kill clip-path seams',
|
||||
eps: EPS, spread: SPREAD, cuts: GEO, xCorners: X,
|
||||
parts: parts.map(p => ({ name:p.name, points:p.points, rest:{dx:0, dy:p.dy} }))
|
||||
});
|
||||
$('#export').onclick = () => { $('#json').value = JSON.stringify(manifest(), null, 2); };
|
||||
$('#copy').onclick = async () => {
|
||||
$('#json').value = JSON.stringify(manifest(), null, 2);
|
||||
try { await navigator.clipboard.writeText($('#json').value); $('#copy').textContent='copied'; }
|
||||
catch { $('#json').select(); }
|
||||
setTimeout(() => $('#copy').textContent = 'copy', 1200);
|
||||
};
|
||||
|
||||
geoUI(); build(); render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user