the_shape_of_words / static /index.html
resakemal's picture
Move shape chime to on shape choice
d85c21c
Raw
History Blame Contribute Delete
84.5 kB
<!-- Story → Shapes — exploration mode prototype (standalone, stubbed model) -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Shape of Words</title>
<style>
:root{
--ink:#11131A; --ink-2:#1A1D27; --mist:#9AA3B2; --mist-2:#5E6678;
--paper:#ECE7DB; --line:#2A2E3A; --accent:#E8A33D; --accent-soft:#C98A2E;
--glow:#FFFFFF;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{
background:
radial-gradient(120% 90% at 50% -10%, #1C2030 0%, var(--ink) 55%, #0C0E14 100%)
no-repeat fixed;
background-color:#0C0E14;
color:var(--paper); font-family:"Iowan Old Style","Palatino Linotype",Georgia,serif;
-webkit-font-smoothing:antialiased; min-height:100%;
display:flex; flex-direction:column; align-items:center;
}
.wrap{width:min(960px,94vw); padding:30px 0 80px}
header{display:flex; align-items:baseline; justify-content:space-between; margin-bottom:6px}
h1{font-size:26px; font-weight:600; letter-spacing:.3px; margin:0}
h1 em{font-style:italic; color:var(--accent)}
.sub{font-family:ui-monospace,"SF Mono",Menlo,monospace; font-size:11px;
letter-spacing:.18em; text-transform:uppercase; color:var(--mist-2)}
.progress{font-family:ui-monospace,monospace; font-size:11px; color:var(--mist-2);
letter-spacing:.1em; margin-top:2px}
/* story column */
.story{margin:22px 0 0; min-height:40px}
.beat{padding:9px 0; border-bottom:1px solid var(--line); display:flex; gap:12px;
align-items:flex-start; opacity:0; transform:translateY(6px);
animation:rise .5s ease forwards}
@keyframes rise{to{opacity:1; transform:none}}
.beat .n{font-family:ui-monospace,monospace; font-size:11px; color:var(--mist-2);
padding-top:3px; min-width:22px}
.beat .txt{font-size:17px; line-height:1.5}
.beat.ai .txt{color:var(--mist)} .beat.ai .txt::before{content:"✎ "; color:var(--accent-soft)}
/* the toy's comment */
.comment{font-style:italic; color:var(--accent); font-size:15px; min-height:22px;
margin:14px 2px; transition:opacity .3s; opacity:0}
.comment.show{opacity:1}
.thinking{display:inline-block; color:var(--mist-2); font-style:normal;
font-family:ui-monospace,monospace; font-size:12px; letter-spacing:.1em}
/* input */
.inputrow{display:flex; gap:10px; margin-top:18px}
textarea{flex:1; resize:none; background:var(--ink-2); color:var(--paper);
border:1px solid var(--line); border-radius:10px; padding:13px 15px;
font-family:inherit; font-size:16px; line-height:1.4; outline:none;
transition:border-color .2s}
textarea:focus{border-color:var(--accent-soft)}
.btns{display:flex; flex-direction:column; gap:8px}
button{font-family:ui-monospace,monospace; font-size:12px; letter-spacing:.08em;
border:1px solid var(--line); background:var(--ink-2); color:var(--paper);
padding:0 16px; height:46px; border-radius:10px; cursor:pointer;
transition:all .15s; white-space:nowrap}
button:hover{border-color:var(--accent); color:var(--accent)}
button.primary{background:var(--accent); color:#1A1206; border-color:var(--accent); font-weight:600}
button.primary:hover{background:var(--accent-soft); color:#1A1206}
button:disabled{opacity:.35; cursor:default; border-color:var(--line); color:var(--mist-2)}
.hint{font-family:ui-monospace,monospace; font-size:11px; color:var(--mist-2);
margin-top:8px; letter-spacing:.04em}
.hintrow{display:flex; align-items:center; justify-content:space-between; gap:12px}
.textbtn{background:none; border:none; color:var(--mist-2); font-family:ui-monospace,monospace;
font-size:11px; letter-spacing:.06em; cursor:pointer; padding:6px 4px; height:auto;
transition:color .15s}
.textbtn:hover{color:var(--accent); border:none}
/* shape choice */
.choice{margin-top:22px; display:none; gap:18px; align-items:stretch}
.choice.show{display:flex}
.card{flex:1; background:var(--ink-2); border:1px solid var(--line); border-radius:14px;
padding:16px; text-align:center; cursor:pointer; transition:all .18s}
.card:hover{border-color:var(--accent); transform:translateY(-2px)}
.card svg{width:100%; height:150px; display:block}
.card .cap{font-family:ui-monospace,monospace; font-size:11px; letter-spacing:.1em;
text-transform:uppercase; color:var(--mist-2); margin-top:8px}
.card.freeform{display:flex; flex-direction:column; align-items:center; justify-content:center;
color:var(--mist-2); font-family:ui-monospace,monospace; font-size:12px}
/* reveal scene */
.scene{margin-top:26px; display:none}
.scene.show{display:block}
.canvas-wrap{position:relative; width:100%; aspect-ratio:1/1; background:
radial-gradient(130% 100% at 50% 0%, #14171F, #0A0C11); border:1px solid var(--line);
border-radius:16px; overflow:hidden}
.canvas-wrap svg{position:absolute; inset:0; width:100%; height:100%}
.canvas-wrap svg path.blooming{ transition: fill 0.65s ease-in-out, filter 0.15s; }
.legend{margin-top:14px; display:flex; flex-direction:column; gap:6px; max-width:420px}
.legend-head{font-family:ui-monospace,monospace; font-size:10px; letter-spacing:.12em;
text-transform:uppercase; color:var(--mist-2); margin-bottom:2px}
.chip{font-size:13px; padding:7px 10px; border:1px solid var(--line); border-radius:9px;
cursor:default; transition:all .15s; display:flex; align-items:center; gap:9px}
.chip:hover{border-color:var(--accent)}
.chip .dot{width:11px; height:11px; border-radius:50%; flex:none}
.chip .chip-label{flex:1}
.chip.ai{font-style:italic; color:var(--mist)}
.chip .tag{font-family:ui-monospace,monospace; font-size:9px; color:var(--accent-soft)}
.layerbtns{display:flex; gap:4px; flex:none}
.layerbtns .lb{height:24px; width:26px; padding:0; font-size:10px; line-height:1;
border-radius:6px; color:var(--mist-2)}
.layerbtns .lb:hover:not(:disabled){border-color:var(--accent); color:var(--accent)}
.layerbtns .lb:disabled{opacity:.25}
.layerbtns .lb.rot{ font-size:13px; width:28px; }
/* toggle switch */
.toggle{width:46px; height:26px; border-radius:14px; background:var(--line);
border:1px solid var(--line); padding:0; position:relative; cursor:pointer;
transition:background .18s; flex:none}
.toggle .knob{position:absolute; top:2px; left:2px; width:20px; height:20px;
border-radius:50%; background:var(--mist); transition:left .18s,background .18s}
.toggle[aria-checked="true"]{background:var(--accent-soft); border-color:var(--accent)}
.toggle[aria-checked="true"] .knob{left:22px; background:#1A1206}
.toggle:hover{border-color:var(--accent)}
/* hidden shape's layer row */
.chip.hidden-shape{opacity:.45}
.chip.hidden-shape .chip-label{text-decoration:line-through}
.lb.eye{font-size:12px}
.scenebtns{margin-top:14px; display:flex; gap:10px; flex-wrap:wrap}
.paintbox{margin-top:28px; padding-top:22px; border-top:1px solid var(--line)}
.paintbox-label{font-family:ui-monospace,monospace; font-size:10px; letter-spacing:.14em;
text-transform:uppercase; color:var(--mist-2); margin-bottom:10px}
.paint-row{display:flex; gap:10px; align-items:center; margin-bottom:10px; flex-wrap:wrap}
#paintTheme{flex:1; background:var(--ink-2); color:var(--paper); border:1px solid var(--line);
border-radius:10px; padding:12px 14px; font-family:inherit; font-size:15px; outline:none}
#paintTheme:focus{border-color:var(--accent-soft)}
.toggle-pill{height:30px; padding:0 14px; border-radius:16px; font-family:ui-monospace,monospace;
font-size:11px; letter-spacing:.05em}
.toggle-pill[data-mode="strong"]{border-color:var(--accent); color:var(--accent)}
.paint-hint{font-style:italic}
.paint-result{margin-top:14px; min-height:0}
.paint-result img{width:100%; border-radius:14px; border:1px solid var(--line); display:block}
.paint-result .painting-wait{font-family:ui-monospace,monospace; font-size:12px; color:var(--mist-2);
padding:30px; text-align:center; letter-spacing:.08em}
.paint-result .painting-actions{margin-top:10px; display:flex; gap:10px}
/* settings modal */
.overlay{position:fixed; inset:0; background:rgba(8,10,16,.72); backdrop-filter:blur(3px);
display:none; align-items:center; justify-content:center; z-index:50}
.overlay.show{display:flex}
.modal{width:min(380px,90vw); background:var(--ink-2); border:1px solid var(--line);
border-radius:16px; padding:24px}
.modal h2{margin:0 0 4px; font-size:20px; font-weight:600}
.modal p.note{margin:0 0 20px; font-size:13px; color:var(--mist-2); line-height:1.5}
.setting{margin:18px 0}
.setting .lbl{font-family:ui-monospace,monospace; font-size:11px; letter-spacing:.12em;
text-transform:uppercase; color:var(--mist); display:block; margin-bottom:8px}
.stepper{display:flex; align-items:center; gap:0}
.stepper button{width:46px; height:46px; padding:0; font-size:20px; border-radius:0}
.stepper button:first-child{border-radius:10px 0 0 10px}
.stepper button:last-child{border-radius:0 10px 10px 0}
.stepper .val{flex:1; text-align:center; height:46px; line-height:46px; font-size:20px;
background:var(--ink); border-top:1px solid var(--line); border-bottom:1px solid var(--line)}
.setting .range{font-family:ui-monospace,monospace; font-size:10px; color:var(--mist-2); margin-top:6px}
.setting .range.locked{color:var(--accent-soft)}
.modal .actions{display:flex; gap:10px; margin-top:24px}
.modal .actions button{flex:1; height:44px}
/* share-card popup */
.card-overlay{z-index:60}
.card-overlay .card-modal{width:min(540px,92vw); max-height:94vh; background:transparent;
border:none; padding:0; display:flex; flex-direction:column; align-items:center; gap:14px}
.card-overlay .card-img{width:100%; height:auto; max-height:82vh; object-fit:contain;
border-radius:14px; box-shadow:0 16px 56px rgba(0,0,0,.6); display:block}
.card-overlay .card-actions{display:flex; gap:10px}
.card-overlay .card-actions button{height:44px; padding:0 22px}
/* custom hover/tap tooltip — shows a shape's originating story beat */
.tooltip{position:fixed; z-index:80; pointer-events:none; max-width:min(300px,80vw);
background:var(--ink-2); color:var(--paper); border:1px solid var(--line);
border-radius:9px; padding:9px 12px; box-shadow:0 8px 28px rgba(0,0,0,.5);
opacity:0; transform:translateY(4px); transition:opacity .12s, transform .12s}
.tooltip.show{opacity:1; transform:none}
.tooltip .tt-head{font-family:ui-monospace,monospace; font-size:10px; letter-spacing:.12em;
text-transform:uppercase; color:var(--accent-soft); margin-bottom:4px}
.tooltip .tt-body{font-size:14px; line-height:1.45; font-style:italic; color:var(--paper)}
.tooltip.companion .tt-head{color:var(--mist-2)}
.tooltip.companion .tt-body{font-style:normal; color:var(--mist)}
</style>
<div class="wrap">
<header>
<div>
<h1>The Shape of Words</h1>
<div class="progress" id="progress">beat 0 of 5 · shapes 0</div>
</div>
<div class="sub" style="display:flex;align-items:center;gap:14px">
<span>exploration</span>
<button id="gear" title="Settings" style="height:30px;padding:0 12px">⚙ Settings</button>
</div>
</header>
<div class="story" id="story"></div>
<div class="comment" id="comment"></div>
<div class="choice" id="choice"></div>
<div class="inputrow" id="inputrow">
<textarea id="input" rows="2" placeholder="Tell the next part of your story… (up to ~3 sentences)"></textarea>
<div class="btns">
<button class="primary" id="send">Tell</button>
<button id="pen" title="Let the story continue itself">Pass the pen</button>
</div>
</div>
<div class="hintrow">
<div class="hint" id="hint">A story unfolds one beat at a time. Some beats will become shapes.</div>
<button id="midReset" class="textbtn" style="display:none">↺ Start over</button>
</div>
<div class="scene" id="scene">
<div class="canvas-wrap"><svg id="sceneSvg" viewBox="0 0 1000 1000"></svg></div>
<div class="legend" id="legend"></div>
<div class="scenebtns">
<button id="save">↓ Save image</button>
<button id="cardBtn" class="primary">✦ Share card</button>
<button id="replayBtn" title="Replay the colour + sound reveal">↻ Replay reveal</button>
<button id="reset">Reset arrangement</button>
<button id="restart">New story</button>
</div>
<div id="cardResult" class="paint-result"></div>
<div class="paintbox">
<div class="paintbox-label">Paint this story</div>
<div class="paint-row">
<input id="paintTheme" type="text" placeholder="style — e.g. 'misty watercolor', 'oil impasto', 'neon dusk'">
<button id="paintBtn" class="primary">✦ Paint this</button>
<button id="surpriseBtn" title="Pick a random style and paint">🎲 Surprise me</button>
</div>
<div id="paintResult" class="paint-result"></div>
<div id="paintCardResult"></div>
</div>
</div>
</div>
<div class="overlay" id="overlay">
<div class="modal">
<h2>Settings</h2>
<p class="note">Changes apply to your next story.</p>
<div class="setting">
<span class="lbl">Story beats</span>
<div class="stepper">
<button id="beatsDown"></button>
<div class="val" id="beatsVal">5</div>
<button id="beatsUp">+</button>
</div>
<div class="range" id="beatsRange">3 to 7 beats</div>
</div>
<div class="setting">
<span class="lbl">Shapes</span>
<div class="stepper">
<button id="shapesDown"></button>
<div class="val" id="shapesVal">3</div>
<button id="shapesUp">+</button>
</div>
<div class="range" id="shapesRange">3 to 3</div>
</div>
<div class="setting">
<span class="lbl">Companion shapes</span>
<button id="companionToggle" class="toggle" role="switch" aria-checked="true">
<span class="knob"></span>
</button>
<div class="range">the AI adds its own shapes at the end</div>
</div>
<div class="setting">
<span class="lbl">Reveal sound</span>
<button id="soundToggle" class="toggle" role="switch" aria-checked="true">
<span class="knob"></span>
</button>
<div class="range">chimes &amp; atmosphere during the colour reveal</div>
</div>
<div class="actions">
<button id="settingsCancel">Cancel</button>
<button class="primary" id="settingsApply">Apply</button>
</div>
</div>
</div>
<script>
// ============================================================================
// PORT of the Python pipeline: mappings (affect->geometry, affect->color) +
// renderer (geometry->points) + layout engine (>=30% visibility).
// ============================================================================
const clamp=(x,a,b)=>Math.max(a,Math.min(b,x));
const DEBUG=true;
function flog(tag, ...args){ if(DEBUG) console.log(`%c[${tag}]`,"color:#E8A33D;font-weight:bold",...args); }
function affectToGeometry(v,a,d){
const threat=1-v;
// Gesture vector (gx right+, gy down+ in screen space): the form's posture.
// valence -> vertical: pleasant rises (up), unpleasant droops (down)
// dominance -> horizontal: commanding surges right, timid retreats left
// arousal -> amplifies the lean (calm/neutral beats stay balanced)
const amp=0.4+0.9*a;
return {
spikiness: clamp(0.62*a+0.45*threat*a,0,1),
compactness: clamp(0.35+0.40*v-0.25*a,0,1),
segmentability: clamp(0.18+0.45*a+0.18*threat*a,0,1),
symmetry: clamp(0.45+0.35*d+0.20*v-0.30*a,0,1),
scale: clamp(0.62+0.63*d,0.5,1.3),
gx: (d-0.5)*amp,
gy: (0.5-v)*amp
};
}
// Resolve a gesture vector into a lean magnitude + direction for the radial bulge.
function gestureLeanPhi(gx,gy){
return { lean: Math.min(0.5, Math.hypot(gx||0, gy||0)), phi: Math.atan2(gy||0, gx||0) };
}
// OKLCH hue interpolation (degrees), short way around the circle.
function lerpHueDeg(h0,h1,t){ let d=((h1-h0+180)%360+360)%360-180; return ((h0+d*t)%360+360)%360; }
function affectToColor(v,a,d){
// OKLCH (perceptually-uniform lightness): equal L reads as equal brightness
// across hues, unlike HSV value (yellow looked far brighter than blue).
// Same four emotion-corner anchors, now in OKLCH degrees.
// Cool edge curved (v**1.6) so green only wins at genuinely high valence;
// keeps low-arousal sad beats blue, not green. Warm edge stays linear.
const lowA=lerpHueDeg(264,145,Math.pow(v,1.6)), highA=lerpHueDeg(29,85,v);
const H=lerpHueDeg(lowA,highA,a);
const L=clamp(0.48+0.40*v-0.10*d,0.40,0.90); // valence -> lightness
const C=clamp(0.040+0.13*a+0.03*d,0,0.18); // arousal -> chroma
return oklch2rgb(L,C,H);
}
function oklch2rgb(L,C,H){
const h=H*Math.PI/180, a=C*Math.cos(h), b=C*Math.sin(h);
const l_=L+0.3963377774*a+0.2158037573*b;
const m_=L-0.1055613458*a-0.0638541728*b;
const s_=L-0.0894841775*a-1.2914855480*b;
const l=l_*l_*l_, m=m_*m_*m_, s=s_*s_*s_;
let r= 4.0767416621*l-3.3077115913*m+0.2309699292*s;
let g=-1.2684380046*l+2.6097574011*m-0.3413193965*s;
let bl=-0.0041960863*l-0.7034186147*m+1.7076147010*s;
const gm=x=>{ x=x<0?0:x>1?1:x; return x<=0.0031308?12.92*x:1.055*Math.pow(x,1/2.4)-0.055; };
return `rgb(${Math.round(gm(r)*255)},${Math.round(gm(g)*255)},${Math.round(gm(bl)*255)})`;
}
// ---- Material system ----
// Model picks one; renderer applies deterministically via SVG filter + opacity.
// Opacity is derived from material, not scored separately (avoids double-encoding
// dominance and keeps the reveal bloom clean — only fill color changes on bloom).
// `filter` (SVG) is used for the LIVE interactive view only. enamel/metal are
// pure color shifts (matColor) so they need no filter and export identically.
// paper/chalk/ink keep an SVG filter live, but the EXPORT bakes their texture on
// canvas (bakeMaterials) — SVG filters don't survive img→canvas rasterization.
const MAT_STYLES = {
paper: { opacity: 0.90, filter: "url(#mat-paper)", stroke: "rgba(255,255,255,.18)", sw: 3 },
ink: { opacity: 0.68, filter: "url(#mat-ink)", stroke: "rgba(20,20,50,.30)", sw: 2 },
glass: { opacity: 0.46, filter: null, stroke: "rgba(255,255,255,.55)", sw: 4 },
enamel: { opacity: 1.00, filter: null, stroke: "rgba(255,255,255,.42)", sw: 3 },
chalk: { opacity: 0.80, filter: "url(#mat-chalk)", stroke: "rgba(200,200,200,.28)", sw: 2 },
metal: { opacity: 0.96, filter: null, stroke: "rgba(140,155,200,.38)", sw: 3 },
};
function matStyle(mat){ return MAT_STYLES[mat] || MAT_STYLES.paper; }
// enamel/metal as color math (mirrors the old feColorMatrix filters), applied to
// the fill so it survives export with no filter dependency.
function _rgbNums(s){ const m=(s.match(/-?\d+\.?\d*/g)||[]).map(Number); return [m[0]||0,m[1]||0,m[2]||0]; }
function _rgbStr(r,g,b){ return `rgb(${Math.round(clamp(r,0,255))},${Math.round(clamp(g,0,255))},${Math.round(clamp(b,0,255))})`; }
function matColor(col, mat){
if(mat==="metal"){ const [r,g,b]=_rgbNums(col);
return _rgbStr(0.72*r+0.06*255, 0.78*g+0.07*255, 0.88*b+0.09*255); } // cool darken
if(mat==="enamel"){ const [r,g,b]=_rgbNums(col); const l=0.2126*r+0.7152*g+0.0722*b;
return _rgbStr(l+(r-l)*1.45, l+(g-l)*1.45, l+(b-l)*1.45); } // saturate ×1.45
return col;
}
// cached grayscale noise tile for canvas-baked paper/chalk grain
let _noiseTile=null;
function noiseTile(){
if(_noiseTile) return _noiseTile;
const N=256, c=document.createElement("canvas"); c.width=c.height=N;
const x=c.getContext("2d"), img=x.createImageData(N,N), d=img.data;
for(let i=0;i<d.length;i+=4){ const v=Math.random()*255; d[i]=d[i+1]=d[i+2]=v; d[i+3]=255; }
x.putImageData(img,0,0); _noiseTile=c; return c;
}
// Bake paper/chalk/ink texture onto the export canvas. ctx is the 2000px canvas;
// f scales 1000-unit shape polygons up to canvas pixels.
function bakeMaterials(ctx, f){
const order=[...state.shapes.keys()].sort((a,b)=>state.shapes[a].depth-state.shapes[b].depth);
for(const i of order){
const s=state.shapes[i]; if(s.hidden) continue;
const mat=s.material||"paper";
if(mat!=="paper" && mat!=="chalk" && mat!=="ink") continue;
const poly=shapePolygon(s).map(p=>[p[0]*f,p[1]*f]);
ctx.save();
ctx.beginPath();
poly.forEach((p,k)=> k ? ctx.lineTo(p[0],p[1]) : ctx.moveTo(p[0],p[1]));
ctx.closePath(); ctx.clip();
if(mat==="ink"){
// inner pooling: a thick blurred dark stroke, clipped to inside the shape
ctx.globalCompositeOperation="source-over"; ctx.globalAlpha=0.6;
ctx.strokeStyle="rgba(6,6,18,1)"; ctx.lineWidth=46*f/2; ctx.lineJoin="round";
try{ ctx.filter="blur(20px)"; }catch(e){}
ctx.beginPath();
poly.forEach((p,k)=> k ? ctx.lineTo(p[0],p[1]) : ctx.moveTo(p[0],p[1]));
ctx.closePath(); ctx.stroke(); ctx.filter="none";
} else {
const xs=poly.map(p=>p[0]), ys=poly.map(p=>p[1]);
const x0=Math.min(...xs), y0=Math.min(...ys), x1=Math.max(...xs), y1=Math.max(...ys);
const pat=ctx.createPattern(noiseTile(),"repeat");
ctx.fillStyle=pat;
if(mat==="paper"){ ctx.globalCompositeOperation="multiply"; ctx.globalAlpha=0.22; }
else { ctx.globalCompositeOperation="screen"; ctx.globalAlpha=0.30; } // chalk
ctx.fillRect(x0,y0,x1-x0,y1-y0);
}
ctx.restore();
}
ctx.globalCompositeOperation="source-over"; ctx.globalAlpha=1;
}
// SVG filter inner XML — injected into <defs> in both the live SVG and canvas clone.
// glass has no filter (effect is purely opacity + stroke).
const MAT_FILTER_SVG = `
<filter id="mat-paper" x="-2%" y="-2%" width="104%" height="104%" color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.75 0.80" numOctaves="4" seed="7" result="noise"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.92 0 0 0 0 0.89 0 0 0 0 0.84 0 0 0 0.38 0" in="noise" result="warmNoise"/>
<feBlend in="SourceGraphic" in2="warmNoise" mode="multiply" result="textured"/>
<feComposite in="textured" in2="SourceAlpha" operator="in"/>
</filter>
<filter id="mat-ink" x="-5%" y="-5%" width="110%" height="110%" color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceAlpha" stdDeviation="14" result="softA"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.02 0 0 0 0 0.02 0 0 0 0 0.06 0 0 0 0.65 0" in="softA" result="poolDark"/>
<feMerge><feMergeNode in="SourceGraphic"/><feMergeNode in="poolDark"/></feMerge>
</filter>
<filter id="mat-chalk" x="-2%" y="-2%" width="104%" height="104%" color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="1.10 0.90" numOctaves="3" seed="13" result="noise"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.96 0 0 0 0 0.94 0 0 0 0 0.92 0 0 0 0.45 0" in="noise" result="dustNoise"/>
<feBlend in="SourceGraphic" in2="dustNoise" mode="screen" result="lightened"/>
<feComposite in="lightened" in2="SourceAlpha" operator="in"/>
</filter>`;
// (enamel/metal are color-only — handled by matColor, no SVG filter needed)
// deterministic per-key jitter (mirrors the python _stable_unit)
function stableUnit(n,key){
let out=[],h=0; for(let i=0;i<key.length;i++){h=(h*31+key.charCodeAt(i))>>>0;}
for(let k=0;k<n;k++){ h=(1103515245*h+12345)>>>0; out.push((h>>>8)/16777216); }
return out;
}
// Close a ring of points into a smooth SVG path via corner-aware Catmull-Rom →
// cubic Bézier. `spk` (spikiness, the arousal-driven angularity axis) decides how
// the linework reads: calm → flowing rounded curves; tense → crisp, straight-
// flanked points. Spike tips (sharp turns) stay as hard corners so they don't
// melt; the smooth arcs in between curve. This is what gives the forms a crafted
// line quality instead of a faceted plot of r(theta).
function ringToPath(pts, spk){
const n=pts.length; if(n<3) return "";
spk=Math.max(0,Math.min(1,spk||0));
// flank handle length: standard Catmull-Rom (1/6) when calm, tighter (straighter
// edges between teeth) when spiky.
const t=(1/6)*(1-0.6*spk);
// a vertex whose turn is sharper than this cosine is a hard corner. Calm shapes
// catch almost nothing (all flows); spiky shapes keep every tooth tip crisp.
const cornerCos=-0.2+0.9*spk;
const corner=new Array(n);
for(let i=0;i<n;i++){
const a=pts[(i-1+n)%n], b=pts[i], c=pts[(i+1)%n];
const v1x=b[0]-a[0], v1y=b[1]-a[1], v2x=c[0]-b[0], v2y=c[1]-b[1];
const m1=Math.hypot(v1x,v1y)||1e-9, m2=Math.hypot(v2x,v2y)||1e-9;
corner[i]=((v1x*v2x+v1y*v2y)/(m1*m2))<cornerCos;
}
const f=v=>v.toFixed(1);
let d="M"+f(pts[0][0])+" "+f(pts[0][1]);
for(let i=0;i<n;i++){
const p0=pts[(i-1+n)%n], p1=pts[i], p2=pts[(i+1)%n], p3=pts[(i+2)%n];
const t1=corner[i]?0:t, t2=corner[(i+1)%n]?0:t; // drop handles at corners
const c1x=p1[0]+(p2[0]-p0[0])*t1, c1y=p1[1]+(p2[1]-p0[1])*t1;
const c2x=p2[0]-(p3[0]-p1[0])*t2, c2y=p2[1]-(p3[1]-p1[1])*t2;
d+="C"+f(c1x)+" "+f(c1y)+" "+f(c2x)+" "+f(c2y)+" "+f(p2[0])+" "+f(p2[1]);
}
return d+"Z";
}
// geometry -> SVG path points (radial r(theta), mirrors renderer2.geom_to_points)
function geomToPath(g,key,N=120,cx=500,cy=500,R=300){
const spk=g.spikiness,cmp=g.compactness,seg=g.segmentability,sym=g.symmetry;
const u=stableUnit(6,key);
const baseR=(0.16+0.16*cmp), aspect=1+(1-cmp)*1.2;
const L=Math.round(1+seg*4);
const spikes=Math.round(5+4*spk); // 5..9 bold teeth (matches python)
const {lean:glean, phi:gphi}=gestureLeanPhi(g.gx, g.gy);
let pts=[];
for(let i=0;i<N;i++){
const th=2*Math.PI*i/N;
let elong=1/Math.sqrt(Math.cos(th)**2/aspect+Math.sin(th)**2*aspect);
let r=baseR*elong;
if(L>=2){
let lobe=Math.pow(Math.cos(L*th)*0.5+0.5,1.5);
r=r*(1-0.55*seg)+r*0.55*seg*lobe; r=r*(1+0.25*seg*(lobe-0.5));
}
if(spk>0){
// triangle wave -> sharp pointed tips (matches python renderer2)
let tri=(2/Math.PI)*Math.asin(Math.sin(spikes*th));
let peak=Math.pow(Math.abs(tri),1+1.4*spk);
let tooth=peak*2-1;
let amp=0.34*Math.pow(spk,1.6);
r=r*(1+amp*tooth);
}
if(sym<1){
let j=0; for(let k=0;k<3;k++){ j+=u[k+3]*Math.sin((1+k)*th+u[k]*6.283); }
j/=3; r=r*(1+(1-sym)*0.62*j);
}
// gesture: pool mass toward the thrust direction → off-center, leaning form
if(glean>0.001) r=r*(1+glean*Math.cos(th-gphi));
r=Math.max(r,0.03);
pts.push([cx+r*Math.cos(th)*R/0.34, cy+r*Math.sin(th)*R/0.34]);
}
return ringToPath(pts, spk);
}
function geomToPathSegmented(segments, weights, key, N=120, cx=500, cy=500, R=300){
const n=segments.length;
const total=weights.reduce((a,b)=>a+b,0);
const normW=weights.map(w=>w/total);
// anchor angles clockwise from top
const anchors=[];
let cum=0;
for(let i=0;i<n;i++){
anchors.push(-Math.PI/2 + 2*Math.PI*(cum+normW[i]/2));
cum+=normW[i];
}
const keys=["spikiness","compactness","segmentability","symmetry","scale"];
function interpAt(th){
const vals={}; keys.forEach(k=>vals[k]=0);
let tw=0;
for(let i=0;i<n;i++){
let diff=((th-anchors[i])+Math.PI)%(2*Math.PI)-Math.PI;
const dist=Math.abs(diff);
const w=dist<Math.PI ? Math.cos(dist*Math.PI/2)+1.0 : 0;
keys.forEach(k=>vals[k]+=w*segments[i][k]);
tw+=w;
}
if(tw<1e-9)tw=1e-9;
keys.forEach(k=>vals[k]/=tw);
return vals;
}
const u=stableUnit(6,key);
// mean values for discrete decisions
let meanSpk=0,meanSeg=0,meanSym=0,meanScale=0,meanGx=0,meanGy=0;
for(let i=0;i<n;i++){
meanSpk+=normW[i]*segments[i].spikiness;
meanSeg+=normW[i]*segments[i].segmentability;
meanSym+=normW[i]*segments[i].symmetry;
meanScale+=normW[i]*segments[i].scale;
meanGx+=normW[i]*(segments[i].gx||0);
meanGy+=normW[i]*(segments[i].gy||0);
}
const L=Math.round(1+meanSeg*4);
const spikes=Math.round(5+4*meanSpk);
const {lean:glean, phi:gphi}=gestureLeanPhi(meanGx, meanGy); // whole-shape posture
let pts=[];
for(let i=0;i<N;i++){
// th_render: same angular convention as geomToPath (starts right, ccw)
// — all rendering math (elong, lobes, spikes, jitter) uses this, so shapes
// are orientation-consistent with single-essence shapes.
const th=2*Math.PI*i/N;
// th_anchor: offset by -π/2 so segment anchors are clockwise from top.
// Used ONLY for the interpolation lookup, not for rendering math.
const th_a=th - Math.PI/2;
const g=interpAt(th_a);
const spk=g.spikiness, cmp=g.compactness, seg=g.segmentability, sym=g.symmetry;
const aspect=1+(1-cmp)*1.2;
let elong=1/Math.sqrt(Math.cos(th)**2/aspect+Math.sin(th)**2*aspect);
let r=(0.16+0.16*cmp)*elong;
if(L>=2){
let lobe=Math.pow(Math.cos(L*th)*0.5+0.5,1.5);
const depth=0.55*seg;
r=r*(1-depth)+r*depth*lobe; r=r*(1+0.25*seg*(lobe-0.5));
}
if(spikes>0){
let tri=(2/Math.PI)*Math.asin(Math.sin(spikes*th));
let peak=Math.pow(Math.abs(tri),1+1.4*spk);
let tooth=peak*2-1;
let amp=0.34*Math.pow(spk,1.6);
r=r*(1+amp*tooth);
}
let j=0; for(let k=0;k<3;k++) j+=u[k+3]*Math.sin((1+k)*th+u[k]*6.283);
j/=3; r=r*(1+(1-sym)*0.62*j);
if(glean>0.001) r=r*(1+glean*Math.cos(th-gphi)); // gesture lean
r=Math.max(r,0.03)*g.scale;
pts.push([cx+r*Math.cos(th)*R/0.34, cy+r*Math.sin(th)*R/0.34]);
}
return ringToPath(pts, meanSpk);
}
// ============================================================================
// STUBBED MODEL — replace this with a real llama.cpp call later.
// Heuristic affect judgment from keywords, so the prototype is playable now.
// ============================================================================
const LEX={
sharp:[0.15,0.85,0.7],blade:[0.1,0.85,0.75],fury:[0.1,0.9,0.8],storm:[0.2,0.85,0.8],
fire:[0.35,0.85,0.75],scream:[0.1,0.95,0.7],crash:[0.2,0.85,0.7],dark:[0.25,0.5,0.5],
calm:[0.8,0.15,0.4],quiet:[0.7,0.2,0.35],soft:[0.8,0.2,0.4],snow:[0.75,0.2,0.4],
gentle:[0.8,0.2,0.4],still:[0.7,0.15,0.4],warm:[0.8,0.4,0.5],bloom:[0.85,0.4,0.5],
sad:[0.2,0.25,0.3],rain:[0.35,0.3,0.35],empty:[0.25,0.2,0.3],alone:[0.25,0.3,0.3],
vast:[0.55,0.55,0.95],mountain:[0.55,0.5,0.9],loom:[0.3,0.6,0.85],tiny:[0.5,0.4,0.1],
joy:[0.9,0.8,0.6],laugh:[0.9,0.8,0.6],dance:[0.85,0.8,0.6],bright:[0.85,0.6,0.55]
};
const VOICE_REACT=["Ooh.","Go on…","I didn't expect that.","Mm.","And then?","Tell me more.","Hm, a turn."];
const VOICE_SHAPE=["Here are two shapes your beat could take — which one fits?",
"Two forms for that moment. Pick the one that feels right.",
"Your words could become either of these."];
// ============================================================================
// BACKEND BRIDGE — calls the Gradio Server API endpoints (Ollama-backed model).
// Falls back to the local stub if the backend is unreachable, so this file also
// works opened standalone (no server) for quick UI testing.
// ============================================================================
let BACKEND_OK = true; // optimistic; we do NOT permanently latch it off
// gradio.Server may expose endpoints at /call/<name> or /gradio_api/call/<name>.
// Try both; remember whichever works.
let CALL_BASE = null;
const CALL_BASES = ["/gradio_api/call", "/call"];
async function gradioCall(name, payload){
const bases = CALL_BASE ? [CALL_BASE] : CALL_BASES;
let lastErr;
for(const base of bases){
try{
const post = await fetch(`${base}/${name}`, {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ data: payload })
});
if(!post.ok) throw new Error(`POST ${name} ${post.status}`);
const { event_id } = await post.json();
const res = await fetch(`${base}/${name}/${event_id}`);
if(!res.ok) throw new Error(`GET ${name} ${res.status}`);
const text = await res.text();
const data = parseSSE(text, name);
CALL_BASE = base; // remember the working prefix
flog("api←", name, data);
return data;
}catch(e){ lastErr = e; }
}
throw lastErr;
}
// Parse a Gradio SSE stream: find the `event: complete` block and read its data.
function parseSSE(text, name){
const blocks = text.split(/\n\n+/);
let completeData = null, lastData = null;
for(const b of blocks){
const lines = b.split("\n");
const ev = lines.find(l=>l.startsWith("event:"));
const dl = lines.find(l=>l.startsWith("data:"));
if(!dl) continue;
const payload = dl.slice(5).trim();
if(payload==="null" || payload==="") continue;
lastData = payload;
if(ev && ev.includes("complete")) completeData = payload;
}
const chosen = completeData || lastData;
if(!chosen) throw new Error(`no data from ${name}`);
const data = JSON.parse(chosen);
return Array.isArray(data) ? data[0] : data;
}
async function apiJudgeBeat(text, story, mode, pacing){
try{ const r = await gradioCall("judge_beat",[text, story, mode, pacing||null]); BACKEND_OK=true; return r; }
catch(e){ console.warn("judge_beat backend failed (this call), using stub:",e); BACKEND_OK=false; return stubModel(text,story); }
}
async function apiJudgeBeatSegmented(text, story, pacing){
try{
const r=await gradioCall("judge_beat_segmented",[text, story, pacing||null]);
BACKEND_OK=true; return r;
}catch(e){
console.warn("judge_beat_segmented failed, falling back to judge_beat:",e);
return null; // caller falls back to single-essence path
}
}
async function apiContinue(story){
try{ const r=await gradioCall("continue_story",[story]); BACKEND_OK=true; return r.sentence||r; }
catch(e){ console.warn("continue backend failed (this call), using stub:",e); BACKEND_OK=false; return stubContinue(story); }
}
async function apiReveal(story, labels){
try{ const r=await gradioCall("reveal",[story, labels]); BACKEND_OK=true; return r; }
catch(e){ console.warn("reveal backend failed (this call), using stub:",e); BACKEND_OK=false; return null; }
}
async function apiTitle(story){
try{ const r=await gradioCall("title_story",[story]); BACKEND_OK=true; return r.title||r; }
catch(e){ console.warn("title backend failed, using stub:",e); return stubTitle(); }
}
function stubTitle(){
const t=["The Shape of It","Quiet and Loud","What the Shapes Knew","A Story in Forms","Between the Beats"];
return t[Math.floor(Math.random()*t.length)];
}
async function apiPaint(compositionPngDataUrl, theme, labels, mode){
// painting has no stub fallback (needs the image model); returns base64 PNG or null
try{ const r=await gradioCall("paint",[compositionPngDataUrl, theme, labels, mode]); return r.image||null; }
catch(e){ console.warn("paint backend failed:",e); return null; }
}
function storyText(){ return state.beats.map(b=>b.text).join(" "); }
// Full story context with per-beat status flags for the model.
// Format: numbered beats tagged [has shape], [no shape], or [unjudged].
// The current beat being judged is passed separately as "New beat".
function storyContext(upToExclusive){
const n = upToExclusive!=null ? upToExclusive : state.beats.length;
if(n===0) return "(none yet)";
return state.beats.slice(0,n).map((b,i)=>{
const tag = b.hasShape ? "[has shape]" : "[no shape]";
return `${i+1}. ${b.text} ${tag}`;
}).join("\n");
}
function stubModel(text, story){
const words=text.toLowerCase().split(/[^a-z]+/).filter(Boolean);
let v=[],a=[],d=[];
for(const w of words){ if(LEX[w]){v.push(LEX[w][0]);a.push(LEX[w][1]);d.push(LEX[w][2]);} }
const avg=arr=>arr.length?arr.reduce((x,y)=>x+y,0)/arr.length:null;
let V=avg(v),A=avg(a),D=avg(d);
// fallback: derive mild affect from length/punctuation if no lexicon hits
if(V===null){ const ex=(text.match(/!/g)||[]).length;
V=0.5; A=clamp(0.3+0.15*ex+words.length*0.01,0,1); D=0.45; }
// jitter a touch so repeated words aren't identical
const r=stableUnit(3,text); V=clamp(V+(r[0]-0.5)*0.08,0,1);
A=clamp(A+(r[1]-0.5)*0.08,0,1); D=clamp(D+(r[2]-0.5)*0.08,0,1);
// deserves a shape if affect is distinct (far from neutral) — pacing-ish
const intensity=Math.abs(V-0.5)+Math.abs(A-0.4)+Math.abs(D-0.5);
const deserves = intensity>0.35;
const label=words.filter(w=>w.length>3).slice(0,2).join(" ")||"a beat";
return {valence:V,arousal:A,dominance:D,deserves_shape:deserves,
label, comment:VOICE_REACT[Math.floor(r[2]*VOICE_REACT.length)], material:"paper"};
}
function stubContinue(story){
const conts=[
"And then the light shifted, and nothing was where it had been.",
"Far off, something answered. The sound did not stop.",
"The quiet did not last. A shape moved at the edge of the room, and then another.",
"What came next, no one had planned for.",
"A door opened that should have stayed shut, and cold air poured through."
];
return conts[Math.floor(Math.random()*conts.length)];
}
// ============================================================================
// GAME STATE + LOOP
// ============================================================================
const DEFAULT_WARMUP=1; // first shape eligible by beat 2 (early "oh!" moment)
const MIN_BEATS=3;
const MAX_BEATS=10;
const MIN_SHAPES=2;
const SHAPES_DIFF=1;
let USE_SEGMENTS = true; // phase-2 flag; set true to enable per-segment essence
// Mutable config, set via Settings. shapesMax derives from beats: max(MIN_SHAPES, n-2).
// Default: 6 beats / 4 shapes — full loop, long enough for reveal to matter.
let cfg={ beats:6, shapes:4, companions:true };
function shapesMaxFor(n){ return Math.max(MIN_SHAPES, n-SHAPES_DIFF); }
function shapesLockedFor(n){ return shapesMaxFor(n)===MIN_SHAPES; } // no room to move when n<=5
let N_BEATS=cfg.beats, BUDGET_MAX=cfg.shapes;
let state={beats:[],shapes:[],shapesLeft:BUDGET_MAX,pending:null};
const $=id=>document.getElementById(id);
function setProgress(){ $("progress").textContent=
`beat ${state.beats.length} of ${N_BEATS} · shapes ${state.shapes.length}`; }
// Settings is only available before a story starts or after Reset/New story.
function setGearState(progressing=null){
if(progressing == null) progressing = state.beats.length>0;
const g=$("gear");
g.disabled = progressing;
g.title = progressing ? "Finish or reset your story to change settings" : "Settings";
// mid-story "Start over" shows only while actively telling a story (not in the reveal)
const inReveal = $("scene").classList.contains("show");
$("midReset").style.display = (progressing && !inReveal) ? "block" : "none";
}
function addBeat(text,isAI){
state.beats.push({text,isAI,hasShape:false});
const el=document.createElement("div");
el.className="beat"+(isAI?" ai":"");
el.innerHTML=`<div class="n">${String(state.beats.length).padStart(2,"0")}</div><div class="txt">${text}</div>`;
$("story").appendChild(el);
setProgress();
setGearState();
}
function showVoice(t){ const e=$("comment"); e.innerHTML=t; e.classList.add("show"); }
function thinking(){ const e=$("comment"); e.innerHTML='<span class="thinking">…feeling that…</span>'; e.classList.add("show"); }
function pondering(){ const e=$("comment"); e.innerHTML='<span class="thinking">…imagining the next story beat…</span>'; e.classList.add("show"); }
function arranging(){ const e=$("comment"); e.innerHTML='<span class="thinking">…finishing up…</span>'; e.classList.add("show"); }
function shapeSVG(affect,key,greyscale){
const g=affectToGeometry(affect.valence,affect.arousal,affect.dominance);
const path=geomToPath(g,key);
const col=greyscale?"#8A8F9C":affectToColor(affect.valence,affect.arousal,affect.dominance);
return {svg:`<svg viewBox="0 0 1000 1000"><path d="${path}" fill="${col}" stroke="rgba(255,255,255,.25)" stroke-width="3"/></svg>`,
geom:g, color:affectToColor(affect.valence,affect.arousal,affect.dominance)};
}
function shapeSVGSegment(affect, key, greyscale, segments, segWeights){
let path;
if(segments && segments.length >= 2){
// convert each segment's affect to geometry, then render segmented
const geoms = segments.map(s=>affectToGeometry(s.valence,s.arousal,s.dominance));
path = geomToPathSegmented(geoms, segWeights||segments.map(s=>s.words||1), key);
} else {
const g=affectToGeometry(affect.valence,affect.arousal,affect.dominance);
path = geomToPath(g,key);
}
const col=greyscale?"#8A8F9C":affectToColor(affect.valence,affect.arousal,affect.dominance);
return {svg:`<svg viewBox="0 0 1000 1000"><path d="${path}" fill="${col}" stroke="rgba(255,255,255,.25)" stroke-width="3"/></svg>`,
geom:null, color:affectToColor(affect.valence,affect.arousal,affect.dominance)};
}
function offerChoice(text,judg){
const c=$("choice"); c.innerHTML=""; c.classList.add("show");
Sound.tick(); // shape choices appeared (neutral — does not leak the hidden color)
$("send").disabled=true; $("pen").disabled=true;
const segs = judg && judg.segments;
const segWts = segs ? segs.map(s=>s.words||1) : null;
// aggregate affect: use top-level if present, else weighted mean of segments
let affect;
if(Number.isFinite(judg.valence) && Number.isFinite(judg.arousal) && Number.isFinite(judg.dominance)){
affect = {valence:judg.valence, arousal:judg.arousal, dominance:judg.dominance};
} else if(segs && segs.length){
const tot = segWts.reduce((a,b)=>a+b,0) || segs.length;
affect = {
valence: segs.reduce((a,s,i)=>a+s.valence *(segWts[i]||1),0)/tot,
arousal: segs.reduce((a,s,i)=>a+s.arousal *(segWts[i]||1),0)/tot,
dominance: segs.reduce((a,s,i)=>a+s.dominance*(segWts[i]||1),0)/tot,
};
} else {
affect = {valence:0.5, arousal:0.4, dominance:0.5}; // safe fallback
}
const own = shapeSVGSegment(affect, text+"own", true, segs, segWts);
// essence = distilled (push toward extremes a little)
const ess={valence:clamp(affect.valence+(affect.valence-0.5)*0.5,0,1),
arousal:clamp(affect.arousal+(affect.arousal-0.4)*0.5,0,1),
dominance:affect.dominance};
const essr=shapeSVG(ess,text+"ess",true);
const mk=(title,obj,aff,comment)=>{
const card=document.createElement("div"); card.className="card";
card.innerHTML=obj.svg+`<div class="cap">${title}</div>`;
card.onclick=()=>commit(aff,text,comment); return card;
};
c.appendChild(mk("the essence",essr,ess,judg.comment));
c.appendChild(mk("your beat",own,affect,judg.comment));
const ff=document.createElement("div"); ff.className="card freeform";
ff.innerHTML="<div>✦</div><div style='margin-top:8px'>freeform<br>(surprise me)</div>";
ff.onclick=()=>{ const ra={valence:Math.random(),arousal:Math.random(),dominance:Math.random()};
commit(ra,text,judg.comment); };
c.appendChild(ff);
}
function commit(affect,text,comment){
flog("commit", state.pending?state.pending.label:"a beat", affect);
// mark the beat that earned this shape
if(state.pendingBeatIdx!=null && state.beats[state.pendingBeatIdx]){
state.beats[state.pendingBeatIdx].hasShape=true;
}
state.shapes.push({
affect, key:text+state.shapes.length,
label:state.pending?state.pending.label:"a beat",
// the original story beat (the user's 1-3 sentence input) this shape was
// born from — shown in the hover/tap tooltip
beatText: (state.pendingBeatIdx!=null && state.beats[state.pendingBeatIdx])
? state.beats[state.pendingBeatIdx].text : text,
isAI:false,
fromAIBeat: !!state.pendingIsAI,
segments: state.pending?._segments||null,
segWeights: state.pending?._segWeights||null,
material: state.pending?.material || "paper",
});
state.shapesLeft--;
$("choice").classList.remove("show"); $("choice").innerHTML="";
state.pending=null; state.pendingBeatIdx=null;
$("send").disabled=false; $("pen").disabled=false;
setProgress();
showVoice(comment);
if(state.beats.length>=N_BEATS) reveal();
}
async function handleInput(text,isAI){
if(!text.trim()) return;
// guardrail: user (non-AI) input limited to 1–3 sentences
if(!isAI){
const sentences = text.trim().split(/[.!?]+(?:\s|$)/).filter(s=>s.trim().length);
if(sentences.length > 3){
$("hint").textContent = "Try to keep each beat to 1–3 sentences.";
$("hint").style.color = "var(--accent)";
return;
}
$("hint").textContent = "A story unfolds one beat at a time. Some beats will become shapes.";
$("hint").style.color = ""; // reset on valid input
}
$("input").value=""; $("input").disabled=true;
$("send").disabled=true; $("pen").disabled=true;
addBeat(text,isAI); // instant non-verbal ack (beat joins story)
thinking();
// story so far = all beats including the new one, with status flags.
// "New beat" is the last one just pushed; prior beats carry [has shape]/[no shape].
const beatIdx = state.beats.length - 1; // index of the beat just added
const priorCtx = storyContext(beatIdx); // all beats except the new one, with flags
const pacing = {
shapes_placed: state.shapes.length,
shapes_left: state.shapesLeft,
beats_left: N_BEATS - state.beats.length
};
const warmup = Math.max(0, Math.min(DEFAULT_WARMUP, N_BEATS - BUDGET_MAX));
const inWarmup = state.beats.length <= warmup;
let judg;
if(inWarmup || state.shapesLeft<=0){
judg = await apiJudgeBeat(text, priorCtx, "exploration");
judg.deserves_shape = false;
flog("beat", isAI?"(AI)":"(user)", text, "| warmup/no-budget → no shape");
} else if(USE_SEGMENTS){
const sjudg = await apiJudgeBeatSegmented(text, priorCtx, pacing);
if(sjudg && sjudg.segments && sjudg.segments.length >= 2){
judg = sjudg;
flog("beat", "segmented →", sjudg.segments.map(s=>s.phrase), sjudg.deserves_shape);
} else {
judg = await apiJudgeBeat(text, priorCtx, "exploration", pacing);
flog("beat", "segmented fallback → single", judg);
}
} else {
judg = await apiJudgeBeat(text, priorCtx, "exploration", pacing);
flog("beat", isAI?"(AI)":"(user)", text, "→", judg, "| backend:", BACKEND_OK?"on":"STUB");
}
const canShape = !inWarmup && state.shapesLeft>0
&& (judg.deserves_shape || (N_BEATS-state.beats.length)<=state.shapesLeft);
if(canShape){
state.pending=judg;
state.pendingBeatIdx=beatIdx; // remember which beat this shape belongs to
state.pendingIsAI=isAI;
state.pending._segments = judg.segments||null;
state.pending._segWeights = judg.segments ? judg.segments.map(s=>s.words||1) : null;
showVoice(VOICE_SHAPE[Math.floor(Math.random()*VOICE_SHAPE.length)]);
offerChoice(text,judg);
} else {
showVoice(judg.comment);
if(state.beats.length>=N_BEATS) reveal();
}
if(!state.pending){ $("send").disabled=false; $("pen").disabled=false; }
$("input").disabled=false; $("input").focus();
}
// ---- layout engine (port): place shapes, enforce >=30% visibility ----
const ANCHORS={ "upper-left":[.28,.74],"upper":[.5,.78],"upper-right":[.72,.74],
"left":[.24,.5],"center":[.5,.5],"right":[.76,.5],
"lower-left":[.28,.26],"lower":[.5,.22],"lower-right":[.72,.26] };
const REGIONS=Object.keys(ANCHORS);
// ============================================================================
// REVEAL MODEL — STUB. Replace with the real llama.cpp reveal call (see
// prompt_reveal.md + reveal.gbnf). Returns: { coherence, companions[], layout[] }.
// The real model finds UNSPOKEN beats; this stub uses the spec's COMPLEMENTARY
// fallback (a counterpoint to the story's average mood) and assigns layout roles
// from affect (dominant=large/high). Plumbing is real; judgment is placeholder.
// ============================================================================
function revealModel(shapes, beats){
const n=shapes.length;
const avg=k=>shapes.reduce((s,x)=>s+x.affect[k],0)/n;
const aV=avg("valence"),aA=avg("arousal"),aD=avg("dominance");
// companion: a gentle counterpoint to the story's average (complementary fallback)
const companions = cfg.companions ? [{
affect:{ valence:clamp(1-aV,0,1), arousal:clamp(0.5-(aA-0.5),0,1), dominance:clamp(1-aD,0,1) },
label: aA>0.5 ? "a quiet after" : "a stirring beneath"
}] : [];
// layout roles for all shapes (existing + companions), by affect:
// dominant -> larger + upper/central; calm/low-dominance -> lower + smaller.
const all=[...shapes, ...companions];
const layout=all.map((s,i)=>{
const d=s.affect.dominance, a=s.affect.arousal;
// region: high dominance pulls up/center, low sinks down
let region;
if(d>0.7) region = a>0.6 ? "upper" : "center";
else if(d<0.35) region = ["lower-left","lower","lower-right"][i%3];
else region = ["left","center","right","upper-left","upper-right"][i%5];
return {
ref: i<n ? `existing:${i}` : `companion:${i-n}`,
region,
scale: clamp(0.35+0.55*d,0,1), // dominance -> size
rotation: Math.round((stableUnit(1,(s.key||s.label)+"rot")[0]-0.5)*40),
depth: i
};
});
return { coherence:0.85, companions, layout };
}
const DELAY_DURATION = 1000
const INTERVAL_DURATION = 700
async function reveal(){
if(state.revealed) return; // guard: never reveal/add companions twice
state.revealed=true;
Sound.resume(); // unlock audio within the user gesture that finished the story
arranging()
$("inputrow").style.display="none"; $("hint").style.display="none";
// $("comment").classList.remove("show"); $("choice").classList.remove("show");
$("choice").classList.remove("show");
// try the backend reveal; fall back to the local stub on failure
let rv = null;
if(cfg.companions || true){
const labels = state.shapes.map(s=>s.label);
const br = await apiReveal(storyText(), labels);
if(br){
// normalize backend response -> local rv shape
const companions = cfg.companions ? (br.companions||[]).map(c=>{
const a = c.affect || c; // accept either {affect:{...}} or flat {valence,...}
return { affect:{ valence:+a.valence, arousal:+a.arousal, dominance:+a.dominance }, label:c.label||"a beat" };
}).filter(c=>Number.isFinite(c.affect.valence)) : [];
rv = { coherence: br.coherence ?? 0.85, companions, layout: br.layout||[] };
}
}
if(!rv) rv = revealModel(state.shapes, state.beats); // local stub fallback
flog("reveal", "source:", BACKEND_OK?"backend":"stub", "| coherence:", rv.coherence,
"| companions:", rv.companions.map(c=>c.label), "| layout:", rv.layout);
// append companion shapes (marked isAI) to state
rv.companions.forEach((c,k)=>{
state.shapes.push({ affect:c.affect, key:"companion"+k+c.label, label:c.label, isCompanion:true, material: c.material || "glass" });
});
// apply layout roles (model-driven placement)
const coh=rv.coherence, spread=1+(1-coh)*0.6;
rv.layout.forEach((role,i)=>{
const s=state.shapes[i]; if(!s) return;
s.region=role.region;
s.scaleRole=role.scale;
s.rot=role.rotation;
s.depth=role.depth;
const a=ANCHORS[role.region]||[0.5,0.5];
s.cx=clamp(0.5+(a[0]-0.5)*spread,0.14,0.86);
s.cy=clamp(0.5+(a[1]-0.5)*spread,0.14,0.86);
s.rad=0.16+0.13*s.scaleRole;
});
// any shape without a role (safety) gets a default
state.shapes.forEach((s,i)=>{ if(s.cx===undefined){
s.region="center"; s.scaleRole=0.5; s.rot=0; s.depth=i;
s.cx=0.5; s.cy=0.5; s.rad=0.22; } });
enforceVisibility();
// ── reveal ritual ──
// 1. Shapes enter greyscale (the "settle" moment)
drawScene({greyscale:true});
buildLegend();
$("comment").classList.remove("show");
$("scene").classList.add("show");
setGearState();
// Scroll the canvas into view so the full composition is visible before colours bloom
requestAnimationFrame(() =>
document.querySelector(".canvas-wrap").scrollIntoView({ behavior: "smooth", block: "start" })
);
// 2. After a breath, colors bloom through each shape in story order
bloomColors(DELAY_DURATION, INTERVAL_DURATION);
}
function shapePolygon(s){
const d = s.segments && s.segments.length>=2
? geomToPathSegmented(
s.segments.map(sg=>affectToGeometry(sg.valence,sg.arousal,sg.dominance)),
s.segWeights||s.segments.map(sg=>sg.words||1),
s.key)
: geomToPath(affectToGeometry(s.affect.valence,s.affect.arousal,s.affect.dominance), s.key);
// Path is now cubic Béziers ("M x y C c1 c2 end C c1 c2 end …Z"). Extract only
// the on-curve points (the M anchor + each C's endpoint) for hit-test/clipping.
const nums=d.replace(/[A-Za-z]/g," ").trim().split(/\s+/).map(Number).filter(v=>!isNaN(v));
const pts=[];
if(nums.length>=2){
pts.push([nums[0],nums[1]]);
for(let i=2;i+5<nums.length;i+=6) pts.push([nums[i+4],nums[i+5]]);
// the final C endpoint closes back to the M anchor — drop the duplicate
if(pts.length>1){ const a=pts[0],b=pts[pts.length-1];
if(Math.abs(a[0]-b[0])<0.05 && Math.abs(a[1]-b[1])<0.05) pts.pop(); }
}
const th=(s.rot||0)*Math.PI/180, c=Math.cos(th), sn=Math.sin(th);
const sc=(s.rad/0.3);
return pts.map(([x,y])=>{
let lx=(x-500)/1000, ly=(y-500)/1000;
let rx=lx*c-ly*sn, ry=lx*sn+ly*c;
return [(s.cx+rx*sc)*1000,(s.cy+ry*sc)*1000];
});
}
function polyArea(p){let a=0;for(let i=0;i<p.length;i++){const j=(i+1)%p.length;a+=p[i][0]*p[j][1]-p[j][0]*p[i][1];}return Math.abs(a)/2;}
function pointIn(poly,x,y){let i,j,c=false;for(i=0,j=poly.length-1;i<poly.length;j=i++){
if(((poly[i][1]>y)!=(poly[j][1]>y))&&(x<(poly[j][0]-poly[i][0])*(y-poly[i][1])/(poly[j][1]-poly[i][1])+poly[i][0]))c=!c;}return c;}
function visFraction(idx,polys){ // sample-based visibility under higher-depth shapes
const me=polys[idx]; let inside=0,vis=0;
const xs=me.map(p=>p[0]),ys=me.map(p=>p[1]);
const x0=Math.min(...xs),x1=Math.max(...xs),y0=Math.min(...ys),y1=Math.max(...ys);
for(let gx=0;gx<14;gx++)for(let gy=0;gy<14;gy++){
const x=x0+(x1-x0)*(gx+.5)/14, y=y0+(y1-y0)*(gy+.5)/14;
if(!pointIn(me,x,y))continue; inside++;
let covered=false;
for(let j=0;j<polys.length;j++){ if(!state.shapes[j].hidden && state.shapes[j].depth>state.shapes[idx].depth && pointIn(polys[j],x,y)){covered=true;break;} }
if(!covered)vis++;
}
return inside?vis/inside:1;
}
function enforceVisibility(){
for(let it=0;it<40;it++){
const polys=state.shapes.map(shapePolygon);
let worst=-1,worstV=1;
state.shapes.forEach((s,i)=>{ if(s.hidden){s._vis=1;return;} const f=visFraction(i,polys); s._vis=f; if(f<worstV){worstV=f;worst=i;}});
if(worstV>=0.20||worst<0)break; // 0.20 floor: allow juicy overlap, legibility via layer panel
const above=state.shapes.filter(s=>s.depth>state.shapes[worst].depth);
if(!above.length){state.shapes[worst].depth=Math.max(...state.shapes.map(s=>s.depth))+1;continue;}
const ax=above.reduce((a,s)=>a+s.cx,0)/above.length, ay=above.reduce((a,s)=>a+s.cy,0)/above.length;
let dx=state.shapes[worst].cx-ax,dy=state.shapes[worst].cy-ay,nrm=Math.hypot(dx,dy)||1;
state.shapes[worst].cx=clamp(state.shapes[worst].cx+dx/nrm*0.03,0.12,0.88); // smaller step
state.shapes[worst].cy=clamp(state.shapes[worst].cy+dy/nrm*0.03,0.12,0.88);
}
}
let dragIdx=-1,dragOff=[0,0];
const Tooltip=(()=>{
let el=null, head=null, body=null;
function ensure(){
if(el) return;
el=document.createElement("div"); el.className="tooltip";
head=document.createElement("div"); head.className="tt-head";
body=document.createElement("div"); body.className="tt-body";
el.appendChild(head); el.appendChild(body);
document.body.appendChild(el);
}
function showFor(s, x, y){
ensure();
if(s.isCompanion){
el.classList.add("companion");
head.textContent="✦ AI added";
head.style.display="";
body.textContent=`${s.label} — added to complete the scene`;
body.style.display="";
} else {
const beat=s.beatText||"";
if(!beat && !s.label) return;
el.classList.remove("companion");
head.textContent=s.label||"";
head.style.display=s.label?"":"none";
body.textContent=beat;
body.style.display=beat?"":"none";
}
move(x,y);
el.classList.add("show");
}
function move(x,y){
if(!el) return;
const pad=14, w=el.offsetWidth, h=el.offsetHeight;
let nx=x+pad, ny=y+pad;
if(nx+w>window.innerWidth-8) nx=x-w-pad; // flip left near right edge
if(ny+h>window.innerHeight-8) ny=y-h-pad; // flip up near bottom edge
el.style.left=Math.max(8,nx)+"px";
el.style.top =Math.max(8,ny)+"px";
}
function hide(){ if(el) el.classList.remove("show"); }
return { showFor, move, hide };
})();
function drawScene(opts={}){
const greyscale = opts.greyscale || false;
const svg=$("sceneSvg"); svg.innerHTML="";
// inject material filter defs so filter="url(#mat-*)" references resolve
const defsEl=document.createElementNS("http://www.w3.org/2000/svg","defs");
defsEl.innerHTML=MAT_FILTER_SVG;
svg.appendChild(defsEl);
const order=[...state.shapes.keys()].sort((a,b)=>state.shapes[a].depth-state.shapes[b].depth);
for(const i of order){
const s=state.shapes[i];
if(s.hidden) continue;
const col = greyscale ? "#8A8F9C"
: matColor(affectToColor(s.affect.valence,s.affect.arousal,s.affect.dominance), s.material);
const ms = matStyle(s.material || "paper");
const poly=shapePolygon(s);
// Re-bezier the placed/rotated polygon so the on-screen edge has the same
// corner-aware curve quality as the previews (spikiness keyed). Catmull-Rom
// is affine-covariant, so smoothing after the transform is correct.
const spk=affectToGeometry(s.affect.valence,s.affect.arousal,s.affect.dominance).spikiness;
const pathStr=ringToPath(poly, spk);
const el=document.createElementNS("http://www.w3.org/2000/svg","path");
el.setAttribute("d",pathStr);
el.setAttribute("fill",col);
el.setAttribute("fill-opacity", ms.opacity);
el.setAttribute("stroke", ms.stroke);
el.setAttribute("stroke-width", String(ms.sw));
// store originals so highlight() can restore them on pointer-leave
el.setAttribute("data-stroke", ms.stroke);
el.setAttribute("data-sw", String(ms.sw));
if(ms.filter) el.setAttribute("filter", ms.filter);
el.setAttribute("data-i",i); el.style.cursor="grab";
el.addEventListener("pointerdown",startDrag);
el.addEventListener("pointerenter",e=>{ highlight(i,true); Tooltip.showFor(s,e.clientX,e.clientY); });
el.addEventListener("pointermove",e=>Tooltip.move(e.clientX,e.clientY));
el.addEventListener("pointerleave",()=>{ highlight(i,false); Tooltip.hide(); });
el.addEventListener("pointercancel",()=>Tooltip.hide());
svg.appendChild(el);
}
}
// Bloom: animate each shape's fill from greyscale to its real color, story order,
// staggered. Shapes are indexed by data-i; we iterate in beat/story order (0,1,2…).
// ---- Audio: gentle synthesized chimes that sonify the reveal ----
// Each shape's color bloom rings a soft bell whose PITCH = the beat's valence
// (sad → low, pleasant → high), quantized to a pentatonic ladder so any story's
// sequence is consonant. The reveal becomes a melody of the emotional arc.
// Pure Web Audio (no asset files); lazily created on first user gesture.
const Sound=(()=>{
let ctx=null, master=null;
let on=(localStorage.getItem("soundOn")??"1")==="1";
// ~2-octave pentatonic ladder (A minor pentatonic): consonant in any order
const SCALE=[220,261.63,293.66,329.63,392,440,523.25,587.33,659.25,784,880];
function ensure(){
if(ctx||typeof window==="undefined") return;
try{
ctx=new (window.AudioContext||window.webkitAudioContext)();
master=ctx.createGain(); master.gain.value=0.16; master.connect(ctx.destination);
}catch(e){ ctx=null; }
}
function resume(){ ensure(); if(ctx&&ctx.state==="suspended") ctx.resume(); }
function note(v){ const i=Math.round((v||0)*(SCALE.length-1));
return SCALE[Math.max(0,Math.min(SCALE.length-1,i))]; }
// soft bell: sine fundamental + quiet octave shimmer; arousal brightens timbre
function chime(f, arousal=0.4){
if(!on) return; ensure(); if(!ctx) return;
if(ctx.state==="suspended") ctx.resume();
const t=ctx.currentTime;
const g=ctx.createGain();
g.gain.setValueAtTime(0.0001,t);
g.gain.exponentialRampToValueAtTime(0.9,t+0.012); // quick attack
g.gain.exponentialRampToValueAtTime(0.0001,t+1.6); // long bell decay
const lp=ctx.createBiquadFilter(); lp.type="lowpass";
lp.frequency.value=1200+2600*(arousal||0); // energized = brighter
g.connect(lp); lp.connect(master);
const o1=ctx.createOscillator(); o1.type="sine"; o1.frequency.value=f; o1.connect(g);
const o2=ctx.createOscillator(); o2.type="sine"; o2.frequency.value=f*2.005;
const g2=ctx.createGain(); g2.gain.value=0.22; o2.connect(g2); g2.connect(g);
o1.start(t); o2.start(t); o1.stop(t+1.7); o2.stop(t+1.7);
}
// short, NEUTRAL tick when a shape is born during the story. Deliberately not
// pitched to valence — the color/mood stays hidden until the reveal.
function tick(){
if(!on) return; ensure(); if(!ctx) return;
if(ctx.state==="suspended") ctx.resume();
const t=ctx.currentTime;
const g=ctx.createGain();
g.gain.setValueAtTime(0.0001,t);
g.gain.exponentialRampToValueAtTime(0.4,t+0.008);
g.gain.exponentialRampToValueAtTime(0.0001,t+0.45); // short
const lp=ctx.createBiquadFilter(); lp.type="lowpass"; lp.frequency.value=1800;
g.connect(lp); lp.connect(master);
const o=ctx.createOscillator(); o.type="sine"; o.frequency.value=523.25; o.connect(g);
o.start(t); o.stop(t+0.5);
}
// soft low drone (root + fifth) that swells under the whole reveal for atmosphere
function pad(dur=4){
if(!on) return; ensure(); if(!ctx) return;
if(ctx.state==="suspended") ctx.resume();
const t=ctx.currentTime, hold=Math.max(1.4,dur-1.4);
const g=ctx.createGain();
g.gain.setValueAtTime(0.0001,t);
g.gain.exponentialRampToValueAtTime(0.32,t+1.4); // slow swell in
g.gain.setValueAtTime(0.32,t+hold);
g.gain.exponentialRampToValueAtTime(0.0001,t+dur); // fade out
const lp=ctx.createBiquadFilter(); lp.type="lowpass";
lp.frequency.setValueAtTime(380,t);
lp.frequency.linearRampToValueAtTime(900,t+dur); // slow filter sweep = movement
g.connect(lp); lp.connect(master);
[[110,0.6],[110*1.5,0.3],[110*2.005,0.25]].forEach(([f,v])=>{
const o=ctx.createOscillator(); o.type="sine"; o.frequency.value=f;
const og=ctx.createGain(); og.gain.value=v; o.connect(og); og.connect(g);
o.start(t); o.stop(t+dur+0.1);
});
}
function toggle(){ on=!on; localStorage.setItem("soundOn",on?"1":"0"); if(on) resume(); return on; }
return { chime, tick, pad, note, resume, toggle, isOn:()=>on };
})();
function bloomColors(delay=400, interval=600){
// story order = shape index order (shapes were pushed in the order they were committed)
const n=state.shapes.length;
// atmospheric pad under the whole sequence (lasts through the last bloom's decay)
const visible=state.shapes.filter(s=>!s.hidden).length;
// Sound.pad(delay/1000 + visible*interval/1000 + 1.8);
Sound.pad(delay/1000 + visible*interval/1000 + 1);
state.shapes.forEach((s,i)=>{
if(s.hidden) return;
const realCol=matColor(affectToColor(s.affect.valence,s.affect.arousal,s.affect.dominance), s.material);
setTimeout(()=>{
const svg=$("sceneSvg");
const el=[...svg.children].find(p=>{
const di=p.getAttribute("data-i");
return di!==null && di!=="" && Number(di)===i;
});
if(!el) return;
el.classList.add("blooming");
// force a reflow so the transition fires (browser needs to see the old fill first)
el.getBoundingClientRect();
el.setAttribute("fill", realCol);
// ring this beat's note in sync with its color blooming
Sound.chime(Sound.note(s.affect.valence), s.affect.arousal);
}, delay + i * interval);
});
}
function highlight(i,on){
const svg=$("sceneSvg");
[...svg.children].forEach(p=>{ if(+p.getAttribute("data-i")===i){
p.setAttribute("stroke",on?"#FFFFFF":(p.getAttribute("data-stroke")||"rgba(255,255,255,.25)"));
p.setAttribute("stroke-width",on?"7":(p.getAttribute("data-sw")||"3"));
// CSS filter for hover glow; clear (not "none") so SVG filter attr stays active when off
p.style.filter=on?"drop-shadow(0 0 12px rgba(255,255,255,.5))":""; }});
[...document.querySelectorAll(".chip")].forEach(ch=>{
if(+ch.getAttribute("data-i")===i) ch.style.borderColor=on?"#fff":"";});
}
function startDrag(e){ dragIdx=+e.target.getAttribute("data-i");
const r=$("sceneSvg").getBoundingClientRect();
dragOff=[ (e.clientX-r.left)/r.width-state.shapes[dragIdx].cx,
(e.clientY-r.top)/r.height-state.shapes[dragIdx].cy ];
e.target.setPointerCapture(e.pointerId); e.target.style.cursor="grabbing"; }
$("sceneSvg")&&$("sceneSvg").addEventListener("pointermove",e=>{
if(dragIdx<0)return; const r=$("sceneSvg").getBoundingClientRect();
state.shapes[dragIdx].cx=clamp((e.clientX-r.left)/r.width-dragOff[0],0.1,0.9);
state.shapes[dragIdx].cy=clamp((e.clientY-r.top)/r.height-dragOff[1],0.1,0.9);
drawScene(); });
window.addEventListener("pointerup",()=>{dragIdx=-1; Tooltip.hide();});
function normalizeDepths(){
// re-pack depths to a clean 0..n-1 sequence by current order (stable)
const order=[...state.shapes.keys()].sort((a,b)=>state.shapes[a].depth-state.shapes[b].depth);
order.forEach((idx,rank)=>{ state.shapes[idx].depth=rank; });
}
function moveLayer(i,dir){
// dir = +1 -> forward (toward front/higher depth), -1 -> backward
normalizeDepths();
const cur=state.shapes[i].depth;
const tgt=cur+dir;
if(tgt<0||tgt>state.shapes.length-1) return; // already at an end
const other=state.shapes.findIndex(s=>s.depth===tgt); // adjacent layer
state.shapes[other].depth=cur; state.shapes[i].depth=tgt; // swap
drawScene(); buildLegend();
}
function buildLegend(){
const L=$("legend"); L.innerHTML="";
const head=document.createElement("div");
head.className="legend-head"; head.textContent="Layers — front at top";
L.appendChild(head);
// chips ordered front-most first (highest depth at top, like a layers panel)
const order=[...state.shapes.keys()].sort((a,b)=>state.shapes[b].depth-state.shapes[a].depth);
order.forEach((i,rank)=>{
const s=state.shapes[i];
const col=affectToColor(s.affect.valence,s.affect.arousal,s.affect.dominance);
const chip=document.createElement("div");
chip.setAttribute("data-i",i);
const tag = s.isCompanion ? ` <span class="tag">✦ AI added</span>`
: s.fromAIBeat ? ` <span class="tag" style="color:var(--mist-2)">from AI's turn</span>`
: "";
chip.className="chip"+(s.isCompanion?" ai":"");
const atFront=rank===0, atBack=rank===order.length-1;
chip.innerHTML=
`<span class="dot" style="background:${col}"></span>`
+`<span class="chip-label">${s.label}</span>`
+tag
+`<span class="layerbtns">`
+`<button class="lb eye" data-eye="${i}" title="${s.hidden?'Show':'Hide'} shape">${s.hidden?'🚫':'👁'}</button>`
+`<button class="lb rot" data-rotl="${i}" title="Rotate left 15°">↺</button>`
+`<button class="lb rot" data-rotr="${i}" title="Rotate right 15°">↻</button>`
+`<button class="lb" data-up="${i}" ${atFront||s.hidden?"disabled":""} title="Bring forward">▲</button>`
+`<button class="lb" data-down="${i}" ${atBack||s.hidden?"disabled":""} title="Send back">▼</button>`
+`</span>`;
if(s.hidden) chip.classList.add("hidden-shape");
chip.addEventListener("pointerenter",()=>highlight(i,true));
chip.addEventListener("pointerleave",()=>highlight(i,false));
// custom tooltip on the shape name only — leaves the layer buttons' own
// (Rotate / Hide / …) tooltips intact
const lbl=chip.querySelector(".chip-label"); lbl.style.cursor="help";
lbl.addEventListener("pointerenter",e=>Tooltip.showFor(s,e.clientX,e.clientY));
lbl.addEventListener("pointermove",e=>Tooltip.move(e.clientX,e.clientY));
lbl.addEventListener("pointerleave",()=>Tooltip.hide());
L.appendChild(chip);
});
// wire the layer buttons
L.querySelectorAll(".lb[data-up]").forEach(b=>b.onclick=e=>{e.stopPropagation();moveLayer(+b.getAttribute("data-up"),+1);});
L.querySelectorAll(".lb[data-down]").forEach(b=>b.onclick=e=>{e.stopPropagation();moveLayer(+b.getAttribute("data-down"),-1);});
L.querySelectorAll(".lb[data-eye]").forEach(b=>b.onclick=e=>{e.stopPropagation();toggleHidden(+b.getAttribute("data-eye"));});
// rotation buttons
L.querySelectorAll(".lb[data-rotl]").forEach(b=>b.onclick=e=>{
e.stopPropagation();
const i=+b.getAttribute("data-rotl");
state.shapes[i].rot=(state.shapes[i].rot||0)-15;
drawScene();
});
L.querySelectorAll(".lb[data-rotr]").forEach(b=>b.onclick=e=>{
e.stopPropagation();
const i=+b.getAttribute("data-rotr");
state.shapes[i].rot=(state.shapes[i].rot||0)+15;
drawScene();
});
}
function toggleHidden(i){
state.shapes[i].hidden=!state.shapes[i].hidden;
drawScene(); buildLegend();
}
let savedLayout=null;
let _cachedTitle=null;
$("reset").onclick=()=>{ if(savedLayout) state.shapes.forEach((s,i)=>{s.cx=savedLayout[i].cx;s.cy=savedLayout[i].cy;s.depth=savedLayout[i].depth;}); drawScene(); buildLegend(); };
function doRestart(){
N_BEATS=cfg.beats; BUDGET_MAX=cfg.shapes;
state={beats:[],shapes:[],shapesLeft:BUDGET_MAX,pending:null};
savedLayout=null;
$("story").innerHTML=""; $("choice").classList.remove("show"); $("choice").innerHTML="";
$("comment").classList.remove("show"); $("comment").innerHTML="";
$("scene").classList.remove("show"); $("legend").innerHTML="";
if($("cardResult")) $("cardResult").innerHTML="";
if($("paintResult")) $("paintResult").innerHTML="";
if($("paintCardResult")) $("paintCardResult").innerHTML="";
_cachedTitle=null;
$("inputrow").style.display="flex"; $("hint").style.display="block";
$("input").value=""; setProgress(); setGearState();
$("send").disabled=false; $("pen").disabled=false;
$("input").disabled=false; $("input").focus();
}
$("restart").onclick=doRestart;
$("midReset").onclick=()=>{
if(state.beats.length===0 || confirm("Start over? Your current story will be cleared.")) doRestart();
};
// ---- render the current scene (incl. drags/hidden) to a canvas, then callback ----
function renderSceneToCanvas(cb, onerr, opts){
opts=opts||{};
const src=$("sceneSvg");
const SZE=1000;
const clone=src.cloneNode(true);
clone.setAttribute("xmlns","http://www.w3.org/2000/svg");
clone.setAttribute("width",SZE); clone.setAttribute("height",SZE);
clone.setAttribute("viewBox","0 0 1000 1000");
// Strip live SVG filters — they don't rasterize reliably through img→canvas.
// paper/chalk/ink texture is re-baked on the canvas below (bakeMaterials);
// enamel/metal are already baked into the fill color (matColor), so the flat
// SVG is the correct base layer.
clone.querySelectorAll("[filter]").forEach(el=>el.removeAttribute("filter"));
// opts.transparent: skip the background so the shapes can float on whatever
// surface they're composited onto (used by the share card).
if(!opts.transparent){
const defs=document.createElementNS("http://www.w3.org/2000/svg","defs");
defs.innerHTML=`<radialGradient id="bg" cx="50%" cy="0%" r="130%">
<stop offset="0%" stop-color="#14171F"/><stop offset="100%" stop-color="#0A0C11"/></radialGradient>`;
clone.insertBefore(defs,clone.firstChild);
const bg=document.createElementNS("http://www.w3.org/2000/svg","rect");
bg.setAttribute("x",0);bg.setAttribute("y",0);bg.setAttribute("width",1000);bg.setAttribute("height",1000);
bg.setAttribute("fill","url(#bg)");
clone.insertBefore(bg,defs.nextSibling);
}
const svgText=new XMLSerializer().serializeToString(clone);
const blob=new Blob([svgText],{type:"image/svg+xml;charset=utf-8"});
const url=URL.createObjectURL(blob);
const img=new Image();
img.onload=()=>{
const scale=2;
const canvas=document.createElement("canvas");
canvas.width=SZE*scale; canvas.height=SZE*scale;
const ctx=canvas.getContext("2d");
ctx.drawImage(img,0,0,canvas.width,canvas.height);
bakeMaterials(ctx, canvas.width/SZE); // paper/chalk grain + ink pooling
URL.revokeObjectURL(url);
cb(canvas);
};
img.onerror=()=>{ URL.revokeObjectURL(url); (onerr||(()=>alert("Could not render image in this browser.")))(); };
img.src=url;
}
// ---- save the current scene (including drag changes) as a PNG ----
$("save").onclick=()=>{
renderSceneToCanvas(canvas=>{
canvas.toBlob(b=>{
const a=document.createElement("a");
a.href=URL.createObjectURL(b);
a.download="story-shapes.png";
a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
},"image/png");
}, ()=>alert("Could not export image in this browser. Try a different browser."));
};
// ---- share card: model-titled card with the shape artwork + story + watermark ----
function wrapText(ctx, text, maxWidth){
const words=(text||"").split(/\s+/).filter(Boolean); const lines=[]; let line="";
for(const w of words){ const test=line?line+" "+w:w;
if(ctx.measureText(test).width>maxWidth && line){ lines.push(line); line=w; }
else line=test; }
if(line) lines.push(line); return lines;
}
async function _getTitle(){
if(_cachedTitle) return _cachedTitle;
let title;
try{ title=await apiTitle(storyText()); }catch(e){ title=stubTitle(); }
_cachedTitle=title||"A Story in Shapes";
return _cachedTitle;
}
// The poster caption: the named essences, not the raw prose — semicolon-joined.
function essenceText(){
const labels=state.shapes.filter(s=>!s.hidden).map(s=>s.label).filter(Boolean);
return labels.length ? labels.join("; ") : storyText();
}
// Floating, closeable popup that shows a finished card + Save / Close.
function showCardPopup(dataUrl, filename){
const ov=document.createElement("div"); ov.className="overlay show card-overlay";
const modal=document.createElement("div"); modal.className="card-modal";
const im=new Image(); im.src=dataUrl; im.className="card-img"; im.alt="share card";
const actions=document.createElement("div"); actions.className="card-actions";
const dl=document.createElement("button"); dl.className="primary"; dl.textContent="↓ Save card";
dl.onclick=()=>{ const a=document.createElement("a"); a.href=dataUrl; a.download=filename; a.click(); };
const close=document.createElement("button"); close.textContent="Close";
const done=()=>{ ov.remove(); document.removeEventListener("keydown",onkey); };
close.onclick=done;
ov.addEventListener("pointerdown",e=>{ if(e.target===ov) done(); });
function onkey(e){ if(e.key==="Escape") done(); }
document.addEventListener("keydown",onkey);
actions.appendChild(dl); actions.appendChild(close);
modal.appendChild(im); modal.appendChild(actions);
ov.appendChild(modal); document.body.appendChild(ov);
}
function _buildCard(title, artImg, floatArt, statusEl, filename, triggerBtn){
const W=1080, H=1500, m=70;
const c=document.createElement("canvas"); c.width=W; c.height=H;
const x=c.getContext("2d");
// background wash
const g=x.createLinearGradient(0,0,0,H);
g.addColorStop(0,"#1C2030"); g.addColorStop(0.5,"#11131A"); g.addColorStop(1,"#0C0E14");
x.fillStyle=g; x.fillRect(0,0,W,H);
// title
x.textAlign="center"; x.fillStyle="#E8A33D";
x.font="600 64px 'Iowan Old Style','Palatino Linotype',Georgia,serif";
const titleLines=wrapText(x,title,W-2*m);
let ty=128;
for(const l of titleLines){ x.fillText(l,W/2,ty); ty+=72; }
// hairline rule tying the title to the piece
const hy=ty+2;
x.strokeStyle="rgba(232,163,61,.55)"; x.lineWidth=2;
x.beginPath(); x.moveTo(W/2-60,hy); x.lineTo(W/2+60,hy); x.stroke();
// artwork — square, fit to width
const aw=W-2*m, ax=m, ay=hy+44;
if(floatArt){
x.drawImage(artImg,ax,ay,aw,aw); // transparent: shapes float on the wash
}else{
x.save(); // framed: rounded clip for polish
const r=22; const X=ax,Y=ay,Wd=aw,Hd=aw;
x.beginPath();
x.moveTo(X+r,Y); x.arcTo(X+Wd,Y,X+Wd,Y+Hd,r); x.arcTo(X+Wd,Y+Hd,X,Y+Hd,r);
x.arcTo(X,Y+Hd,X,Y,r); x.arcTo(X,Y,X+Wd,Y,r); x.closePath();
x.clip(); x.drawImage(artImg,X,Y,Wd,Hd); x.restore();
}
// essences — centered, italic, semicolon-joined, capped with an ellipsis
x.textAlign="center"; x.fillStyle="#9AA3B2";
x.font="italic 30px 'Iowan Old Style','Palatino Linotype',Georgia,serif";
const lines=wrapText(x,essenceText(),W-2*m);
let sy=ay+aw+66; const cap=Math.min(lines.length,4);
for(let i=0;i<cap;i++){
let line=lines[i];
if(i===cap-1&&lines.length>cap) line=line.replace(/\s*\S*$/,"…");
x.fillText(line,W/2,sy); sy+=44;
}
// watermark
x.textAlign="center"; x.fillStyle="#5E6678";
x.font="22px ui-monospace,'SF Mono',Menlo,monospace";
x.fillText("made with The Shape of Words",W/2,H-50);
if(statusEl) statusEl.innerHTML="";
if(triggerBtn) triggerBtn.disabled=false;
showCardPopup(c.toDataURL("image/png"), filename);
}
async function buildShareCard(){
const btn=$("cardBtn"), res=$("cardResult");
btn.disabled=true;
const alreadyHad=!!_cachedTitle;
res.innerHTML=`<div class="painting-wait">${alreadyHad?"composing card…":"naming your story…"}</div>`;
const title=await _getTitle();
res.innerHTML=`<div class="painting-wait">composing card…</div>`;
renderSceneToCanvas(art=>{
_buildCard(title,art,true,res,"story-shapes-card.png",btn);
}, ()=>{ res.innerHTML=`<div class="painting-wait">Could not compose the card.</div>`; btn.disabled=false; }, {transparent:true});
}
$("cardBtn").onclick=buildShareCard;
// replay the whole colour + sound reveal over the current arrangement
function replayReveal(){
if(!state.revealed) return;
$("scene").scrollIntoView({behavior:"smooth", block:"start"});
Sound.resume();
drawScene({greyscale:true}); // settle back to greyscale...
bloomColors(300, INTERVAL_DURATION); // ...then bloom colour + chimes again
}
$("replayBtn").onclick=replayReveal;
// ---- paint: send the composition to FLUX.2 Klein img2img and show the result ----
const PAINT_STYLES=["Art Print","Cut Paper","Ink & Wash","Neon Glass","Oil Impasto","Risograph","Dream Poster"];
function doPaint(){
const result=$("paintResult");
// result.innerHTML=`<div class="painting-wait">painting… (first run loads the model, ~30s)</div>`;
result.innerHTML=`<div class="painting-wait">painting…</div>`;
$("paintBtn").disabled=true; $("surpriseBtn").disabled=true;
renderSceneToCanvas(canvas=>{
const b64 = canvas.toDataURL("image/png");
const theme = $("paintTheme").value || "";
const labels = state.shapes.filter(s=>!s.hidden).map(s=>s.label);
const mode = "strong";
apiPaint(b64, theme, labels, mode).then(img=>{
$("paintBtn").disabled=false; $("surpriseBtn").disabled=false;
if(!img){ result.innerHTML=`<div class="painting-wait">Painting unavailable — is the image model running?</div>`; return; }
result.innerHTML="";
const el=new Image();
// smooth-scroll the finished painting into view (onload = layout settled)
el.onload=()=>result.scrollIntoView({behavior:"smooth", block:"center"});
el.src="data:image/png;base64,"+img;
result.appendChild(el);
const actions=document.createElement("div"); actions.className="painting-actions";
const dl=document.createElement("button"); dl.textContent="↓ Save painting";
dl.onclick=()=>{ const a=document.createElement("a"); a.href=el.src; a.download="story-painting.png"; a.click(); };
actions.appendChild(dl);
const paintCardBtn=document.createElement("button"); paintCardBtn.className="primary"; paintCardBtn.textContent="✦ Share card";
paintCardBtn.onclick=async()=>{
paintCardBtn.disabled=true;
const pcr=$("paintCardResult");
const alreadyHad=!!_cachedTitle;
pcr.innerHTML=`<div class="painting-wait">${alreadyHad?"composing card…":"naming your story…"}</div>`;
const title=await _getTitle();
pcr.innerHTML=`<div class="painting-wait">composing card…</div>`;
_buildCard(title,el,false,pcr,"story-painting-card.png",paintCardBtn);
};
actions.appendChild(paintCardBtn); result.appendChild(actions);
});
}, ()=>{ $("paintBtn").disabled=false; $("surpriseBtn").disabled=false; result.innerHTML=`<div class="painting-wait">Could not capture the composition.</div>`; });
}
$("paintBtn").onclick=doPaint;
$("surpriseBtn").onclick=()=>{
const style=PAINT_STYLES[Math.floor(Math.random()*PAINT_STYLES.length)];
$("paintTheme").value=style; // show the chosen style in the field
doPaint();
};
$("send").onclick=()=>handleInput($("input").value,false);
$("input").addEventListener("keydown",e=>{ if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();handleInput($("input").value,false);} });
$("pen").onclick=async()=>{
setGearState(true)
$("input").value=""; $("input").disabled=true;
$("send").disabled=true; $("pen").disabled=true;
pondering();
const c=await apiContinue(storyText());
await handleInput(c,true);
};
// ---- settings ----
let draft={beats:cfg.beats, shapes:cfg.shapes, companions:cfg.companions};
function renderSettings(){
$("beatsVal").textContent=draft.beats;
// re-clamp shapes to the valid range for the current beats draft
const mx=shapesMaxFor(draft.beats), locked=shapesLockedFor(draft.beats);
draft.shapes=clamp(draft.shapes,MIN_SHAPES,mx);
$("shapesVal").textContent=draft.shapes;
$("beatsRange").textContent=`${MIN_BEATS} to ${MAX_BEATS} beats`;
const sr=$("shapesRange");
if(locked){ sr.textContent=`locked at ${MIN_SHAPES} (needs ${MIN_SHAPES+SHAPES_DIFF+1}+ beats for more)`; sr.classList.add("locked"); }
else { sr.textContent=`${MIN_SHAPES} to ${mx}`; sr.classList.remove("locked"); }
// disable steppers at bounds
$("beatsDown").disabled=draft.beats<=MIN_BEATS; $("beatsUp").disabled=draft.beats>=MAX_BEATS;
$("shapesDown").disabled=draft.shapes<=MIN_SHAPES; $("shapesUp").disabled=draft.shapes>=mx;
$("companionToggle").setAttribute("aria-checked", draft.companions ? "true" : "false");
$("soundToggle").setAttribute("aria-checked", Sound.isOn() ? "true" : "false");
}
function openSettings(){ draft={beats:cfg.beats,shapes:cfg.shapes,companions:cfg.companions}; renderSettings(); $("overlay").classList.add("show"); }
function closeSettings(){ $("overlay").classList.remove("show"); }
$("gear").onclick=openSettings;
$("settingsCancel").onclick=closeSettings;
$("overlay").addEventListener("pointerdown",e=>{ if(e.target===$("overlay")) closeSettings(); });
$("beatsDown").onclick=()=>{ draft.beats=clamp(draft.beats-1,MIN_BEATS,MAX_BEATS); renderSettings(); };
$("beatsUp").onclick =()=>{ draft.beats=clamp(draft.beats+1,MIN_BEATS,MAX_BEATS); renderSettings(); };
$("shapesDown").onclick=()=>{ draft.shapes=clamp(draft.shapes-1,MIN_SHAPES,shapesMaxFor(draft.beats)); renderSettings(); };
$("shapesUp").onclick =()=>{ draft.shapes=clamp(draft.shapes+1,MIN_SHAPES,shapesMaxFor(draft.beats)); renderSettings(); };
$("companionToggle").onclick=()=>{ draft.companions=!draft.companions; renderSettings(); };
// sound applies immediately (not tied to "next story"); preview a note when enabling
$("soundToggle").onclick=()=>{ const on=Sound.toggle(); renderSettings();
if(on) Sound.chime(Sound.note(0.7), 0.4); };
$("settingsApply").onclick=()=>{
cfg.beats=draft.beats; cfg.shapes=clamp(draft.shapes,MIN_SHAPES,shapesMaxFor(draft.beats));
cfg.companions=draft.companions;
closeSettings();
if(state.beats.length===0){
N_BEATS=cfg.beats; BUDGET_MAX=cfg.shapes;
state={beats:[],shapes:[],shapesLeft:BUDGET_MAX,pending:null};
setProgress();
}
};
setProgress();
setGearState();
const _reveal=reveal;
reveal=async function(){ await _reveal(); savedLayout=state.shapes.map(s=>({cx:s.cx,cy:s.cy,depth:s.depth})); };
</script>