0xffd6563c…95fesent to0x72842d09…a7a6·#25,495,182·view on Etherscan
const ageF=clamp(st.now/spec.LIFESPAN,0,1);
const m=maturity(spec,st), density=densityOf(spec,st,m), rho=consistency(st);
const v=clamp(st.vitality!=null?st.vitality:vitalityOf(spec,st),0,1), dead=st.diedAt>0;
const sen=Math.max(smooth(spec.senFrom||0.80,1.0,ageF), dead?1:0);
const reproOK=m>=(spec.REPRO_THETA||0.4); // juveniles don't flower/fruit (§3.1)
const rewards=reproOK?clamp(v*(0.25+0.75*Math.sqrt(rho)),0,1):0;
const dormancy=dormancyOf(spec,st);
// per-seed off-centre placement: every plant rooted dead-centre felt templated, so nudge the base
// x by a seed-stable, bell-weighted amount (mostly small, occasional larger) — the whole plant +
// its companions shift together; the full-width ground line stays, so it just sits left/right of
// centre. Bounded so wide canopies don't clip. Species can opt out / override via spec.basePlace.
const baseDX = (spec.basePlace===false ? 0 : gauss(rng('placeX',st.seed)) * (spec.basePlace||45));
const env={ageF,m,density,structure:structureOf(spec,st),rho,rewards,reproOK,v,dead,sen,dormancy,
season:seasonOf(st.now),browny:clamp(Math.max(1-v,sen),0,1),now:st.now,W,H,baseX:BASEX+baseDX,baseY:BASEY};
env.pollinators=pollinatorsOf(spec,st,env);
return env;
}
function renderPlant(spec, st){
const env=computeEnv(spec,st);
const C=makeCanvas();
spec.draw(st,C,env);
let soil='';
if(!spec.noSoil){ // species can opt out of the drawn ground line
const soilC=mix((spec.PAL&&spec.PAL.soil)||'#6f5128','#9a9180',0.25);
for(let i=0;i<30;i++){ const x=40+i*((W-80)/29), yy=BASEY+16+Math.sin(i*1.7)*3;
soil+=glyph('.',x,yy,GLY,soilC,1.1); } }
const sc=schemeFor(st.seed);
const plant=C.resolve(sc, rng('struct',st.artSeed), spec.LW||DEFLW, spec.schemeExempt||['flowers'], spec.LS||DEFLS);
// ecosystem visitors: drawn ABOVE the plant in negative space; never the plant body (§6.3)
let poll='';
if(env.pollinators.length && !spec.placesPollinators){ // species may place its own instead
placePollinators(C, env.pollinators, st); // anchor to this species' actual blooms
for(const p of env.pollinators) poll+=drawPollinator(p,env);
}
const inner=soil+plant+(poll?`<g id="lyr-pollinators">${poll}</g>`:'');
return framed(BACKDROPS[backdropFor(st.seed)], inner);
}
// ---------- common stage / traits ----------
function defaultStage(spec, st, env){
if(st.diedAt>0) return 'Sealed';
const m=env.m, age=env.ageF;
if(age>0.85) return 'Senescent';
if(m<0.12) return 'Sprout'; if(m<0.4) return 'Young'; if(m<0.62) return 'Developing';
if(m<0.82) return 'Mature'; return 'Full';
}
function commonTraits(spec, st, env){
const stage = spec.stageName ? spec.stageName(st,env) : defaultStage(spec,st,env);
const rows = [
['Backdrop', BACKDROP_NAMES[backdropFor(st.seed)]],
['Scheme', SchemeName(schemeFor(st.seed))],
['Stage', stage],
['Vitality', Math.round(env.v*100)+'%'],
['Devotion', st.entryCount>=20?'High':st.entryCount>=8?'Medium':'Low'],
['Never wilted', st.everWilted?'No':'Yes'],
];
const ps=env.pollinators||[]; // earned via public love (>10 notes, in season)
// exact tokenURI attribute names — match ART_SPEC §8 / CONTRACT_SPEC §8.5 so the contract emits these
const cap=s=>s.charAt(0).toUpperCase()+s.slice(1);
rows.push(['Pollinator', ps.length ? [...new Set(ps.map(p=>p.type))].map(cap).join('/') : 'None']);
rows.push(['Pollinator Count', String(ps.length)]);
const morph=ps.map(p=>p.morph).find(Boolean); // rare collectible variant, if any
rows.push(['Pollinator Morph', morph?cap(morph):'None']);
return rows;
}
// ============================================================
// mount(spec): inject chrome + wire the controller
// ============================================================
function mount(spec){
document.title='Seedling · '+spec.title;
const accent=(spec.PAL&&spec.PAL.accent)||'#7FA66B';
document.body.innerHTML=`
<style>
:root{ --bg:#11140f; --panel:#1b1f17; --line:#2c3322; --ink:#e9e6d8; --muted:#9aa088; --accent:${accent}; }
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink);
font:14px/1.5 ui-sans-serif,system-ui,sans-serif;display:flex;min-height:100vh}
.stage{flex:1;display:flex;align-items:center;justify-content:center;padding:22px}
.stage svg{height:min(90vh,880px);width:auto;box-shadow:0 20px 60px rgba(0,0,0,.45)}
.panel{width:340px;background:var(--panel);border-left:1px solid var(--line);padding:20px;overflow-y:auto;display:flex;flex-direction:column;gap:16px}
h1{font-size:15px;margin:0;letter-spacing:.05em;text-transform:uppercase}
h1 small{display:block;color:var(--muted);font-size:11px;letter-spacing:.12em;margin-top:4px}
.group{border:1px solid var(--line);border-radius:11px;padding:13px}
.group h2{font-size:10.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--muted);margin:0 0 9px}
textarea{width:100%;min-height:52px;resize:vertical;background:#0e120b;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px;font:inherit}
.btns{display:flex;flex-wrap:wrap;gap:7px;margin-top:9px}
button{background:#23291b;color:var(--ink);border:1px solid var(--line);padding:8px 11px;border-radius:8px;cursor:pointer;font:inherit;transition:.15s}
button:hover{border-color:var(--accent);background:#2b3322}
button.primary{background:var(--accent);color:#10140c;border-color:var(--accent);font-weight:600}
button:disabled{opacity:.4;cursor:not-allowed}
.stat{display:flex;justify-content:space-between;align-items:center;margin:6px 0;font-size:13px}
.stat b{font-variant-numeric:tabular-nums}
.bar{height:7px;background:#0e120b;border-radius:5px;overflow:hidden;margin-top:3px}
.bar>i{display:block;height:100%;border-radius:5px;transition:width .35s}
.meta{display:grid;grid-template-columns:1fr 1fr;gap:5px 12px;font-size:12.5px}
.pill{display:inline-block;padding:2px 9px;border-radius:20px;font-size:11px;background:#0e120b;border:1px solid var(--line);color:var(--muted)}
.pill.alive{color:#9fd68a;border-color:#3c5a30}.pill.dead{color:#d98a8a;border-color:#5a3030}
.traits{display:flex;flex-wrap:wrap;gap:6px}.traits .t{font-size:11px;padding:3px 8px;border-radius:6px;background:#0e120b;border:1px solid var(--line)}
.note{color:var(--muted);font-size:11px;line-height:1.5}
a.dl{color:var(--accent);font-size:12px;text-decoration:none}
</style>
<div class="stage"><div id="art"></div></div>
<div class="panel">
<h1>Seedling <small>${spec.title} · ${spec.sub||'v1'}</small></h1>
<div class="group"><h2>Write an entry (waters the plant)</h2>
<textarea id="entry" placeholder="Write today's thought… what you write shapes the plant."></textarea>
<div class="btns"><button class="primary" id="write">✎ Write entry</button>
<button id="w7">+1 week</button><button id="w30">+1 month</button></div>
${spec.note?`<div class="note" style="margin-top:8px">${spec.note}</div>`:''}
</div>
<div class="group"><h2>The three inputs</h2>
<div class="stat"><span>Maturity</span><b id="mVal">0%</b></div><div class="bar"><i id="mBar" style="width:0%;background:var(--accent)"></i></div>
<div class="stat"><span>Vitality</span><b id="vVal">100%</b></div><div class="bar"><i id="vBar" style="width:100%;background:#E8A200"></i></div>
<div class="stat"><span>Age</span><b id="aVal">0%</b></div><div class="bar"><i id="aBar" style="width:0%;background:#c47a4a"></i></div>
</div>
<div class="group"><h2>State</h2>
<div class="meta"><span>Stage</span><b id="stage">—</b><span>Entries</span><b id="entries">0</b>
<span>Age</span><b id="age">0 d</b><span>Season</span><b id="season">—</b>
<span>Status</span><span id="status" class="pill alive">alive</span></div>
</div>
<div class="group"><h2>Live traits</h2><div class="traits" id="traits"></div></div>
<div class="btns"><button id="play" disabled>▶ Memorial</button><button id="reset">↺ New seed</button></div>
<a class="dl" id="download" href="#">⤓ Download SVG</a>
</div>`;
const LIFESPAN=spec.LIFESPAN, DECAY=spec.DECAY||1/21;
let st, history;
const $=id=>document.getElementById(id);
function fresh(seed){ st={seed,artSeed:seed,entryCount:0,now:0,lastWatered:0,vitality:1,
wateredEpochs:0,lastWaterWeek:-1,diedAt:0,everWilted:false}; history=[snap()]; }
function snap(){ return {now:st.now,entryCount:st.entryCount,vitality:st.vitality,
wateredEpochs:st.wateredEpochs,artSeed:st.artSeed,diedAt:st.diedAt}; }
function paint(s){ $('art').innerHTML=renderPlant(spec, s||st); }
function refresh(){ paint();
const env=computeEnv(spec,st), age=clamp(st.now/LIFESPAN,0,1);
$('mVal').textContent=Math.round(env.m*100)+'%'; $('mBar').style.width=(env.m*100)+'%';
$('vVal').textContent=Math.round(st.vitality*100)+'%'; $('vBar').style.width=(st.vitality*100)+'%';
$('aVal').textContent=Math.round(age*100)+'%'; $('aBar').style.width=(age*100)+'%';
$('stage').textContent=spec.stageName?spec.stageName(st,env):defaultStage(spec,st,env);
$('entries').textContent=st.entryCount; $('age').textContent=Math.round(st.now)+' d';
$('season').textContent=seasonOf(st.now);
const dead=st.diedAt>0, status=$('status');
status.textContent=dead?('sealed · '+st.deathCause):'alive'; status.className='pill '+(dead?'dead':'alive');
$('play').disabled=!dead; ['write','w7','w30'].forEach(id=>$(id).disabled=dead);
const tw=$('traits'); tw.innerHTML='';
const tr=commonTraits(spec,st,env).concat(spec.traits?spec.traits(st,env):[]);
tr.forEach(([k,val])=>{const e=document.createElement('span');e.className='t';e.innerHTML=`<b>${k}</b> ${val}`;tw.appendChild(e);});
const blob=new Blob([renderPlant(spec,st)],{type:'image/svg+xml'});
$('download').href=URL.createObjectURL(blob); $('download').download='seedling-'+spec.title.toLowerCase().replace(/\W+/g,'-')+'.svg';
}
function checkDeath(){ if(st.diedAt>0)return;
// §3.2: deathTime monotonic in watered weeks — never moves earlier, so no retroactive death.
const dt=deathTimeOf(spec,st);
if(st.now>=dt){ st.diedAt=st.now;
st.deathCause = spec.MONOCARPIC ? 'MonocarpicBloom'
: (consistency(st)<0.6 ? 'CareShortened' : 'FullTerm'); } }
function writeEntry(){ if(st.diedAt>0)return;
const txt=$('entry').value.trim()||('e'+(st.entryCount+1)+'-'+Math.random());
st.artSeed=hashStr(st.artSeed+'|'+txt+'|'+st.entryCount).toString(16).padStart(8,'0');
st.entryCount++; st.lastWatered=st.now; st.vitality=1;
const wk=Math.floor(st.now/EPOCH); // distinct watered weeks (anti-gaming)
if(wk!==st.lastWaterWeek){ st.wateredEpochs++; st.lastWaterWeek=wk; }
$('entry').value=''; history.push(snap()); refresh(); }
function advance(d){ if(st.diedAt>0)return;
st.now+=d; st.vitality=vitalityOf(spec,st); // §3.1 exponential recency decay
if(st.vitality<0.25)st.everWilted=true;
checkDeath(); history.push(snap()); refresh(); }
let raf=null;
function memorial(){ if(history.length<2)return; cancelAnimationFrame(raf);
const F=history,n=F.length,DUR=5200,t0=performance.now();
(function step(t){ const p=clamp((t-t0)/DUR,0,1), fp=p*(n-1), i=Math.floor(fp), f=fp-i;
const A=F[i],B=F[Math.min(i+1,n-1)];
paint({seed:st.seed,artSeed:(f<0.5?A.artSeed:B.artSeed),entryCount:lerp(A.entryCount,B.entryCount,f),
now:lerp(A.now,B.now,f),vitality:lerp(A.vitality,B.vitality,f),
wateredEpochs:lerp(A.wateredEpochs,B.wateredEpochs,f),lastWatered:lerp(A.now,B.now,f),
diedAt:(p>=1?st.diedAt:0)});
if(p<1)raf=requestAnimationFrame(step); else refresh(); })(t0); }
function newSeed(){ fresh('0x'+Math.floor(Math.random()*0xffffffff).toString(16).padStart(8,'0')); refresh(); }
$('write').onclick=writeEntry; $('w7').onclick=()=>advance(7); $('w30').onclick=()=>advance(30);
$('reset').onclick=newSeed; $('play').onclick=memorial;
$('entry').addEventListener('keydown',e=>{if(e.key==='Enter'&&(e.metaKey||e.ctrlKey))writeEntry();});
// expose for headless/preview harnesses
window.__seedling={spec,renderPlant,maturity,computeEnv,get st(){return st;},set st(x){st=x;},refresh,fresh,schemeFor,settingName:null};
newSeed();
// URL params for gallery/preview: ?seed=..&e=entries&d=days&v=vitality&bare(hide panel)
const q=new URLSearchParams(location.search);
if(q.has('e')||q.has('seed')){
fresh(q.get('seed')||st.seed);
st.entryCount=q.has('e')?+q.get('e'):20; st.now=q.has('d')?+q.get('d'):220;
st.lastWatered=st.now; st.vitality=q.has('v')?+q.get('v'):0.92;
const weeks=Math.max(1,Math.floor(st.now/EPOCH)); // assume ≈1 watering/week up to entryCount
st.wateredEpochs=q.has('w')?+q.get('w'):clamp(st.entryCount,0,weeks);
st.lastWaterWeek=Math.floor(st.now/EPOCH);
st.notes=q.has('notes')?+q.get('notes'):0; // public notes → pollinators (§6.4)
if(st.now>=deathTimeOf(spec,st)){st.diedAt=st.now;st.deathCause='FullTerm';}
refresh();
}
if(q.has('bare')){ document.querySelector('.panel').style.display='none';
const stg=document.querySelector('.stage'); stg.style.padding='0';
document.querySelector('.stage svg').style.height='100vh'; }
// Studio/export support: when embedded in an iframe, PUSH the rendered SVG to the parent via
// postMessage. Origin-independent — works on file:// and in Safari, where the parent reading this
// iframe's contentDocument cross-origin throws SecurityError. token echoes ?_= so the parent can
// match this exact frame; harmless when no parent is listening.
try{ if(window.parent && window.parent!==window){
const tok=q.get('_')||'';
const emit=()=>{ const sv=document.querySelector('#art svg');
if(sv) window.parent.postMessage({seedlingSVG:1, token:tok, svg:sv.outerHTML}, '*'); };
emit(); requestAnimationFrame(emit);
} }catch(e){}
}
// ---------- exports ----------
const __api={ EPOCH,K_DEV,clamp,lerp,smooth,rng,hashStr,seasonOf,
consistency,maturity,structureOf,densityOf,densityCap,deathTimeOf,
vitalityOf,dormancyOf,pollinatorsOf,drawPollinator,computeEnv };
// headless (Node simulation harness):
if(typeof module!=='undefined'&&module.exports) module.exports=__api;
// browser namespace (so self-contained species can converge onto the corrected math
// without colliding with their own local helpers — see e.g. sunflower.html):
if(typeof window!=='undefined') window.SeedlingCore=__api;
;
/* boot.js */
/* ============================================================
Seedling — on-chain engine BOOT entrypoint.
Concatenated LAST into the engine bundle (after species-params.js,
seedling-core.js, and the per-species registry files). Defines the
single symbol the on-chain renderer calls:
SeedlingRenderer.boot(state)
`state` is the JSON object SeedlingRenderer.sol injects, mirroring the
clone's DiaryState (all times in SECONDS, seed/artSeed as 0x-hex):
{ tokenId, species, seed, artSeed, plantedAt, lastWatered,
diedAt, entryCount, wateredEpochs, deathTime }
boot() maps that to the engine's `st` (which counts in DAYS, EPOCH=7),
then renders + loops the life-cycle animation into the page.
============================================================ */
(function () {
var DAY = 86400;
var EPOCH_DAYS = 7;
// species index -> spec-extension ({ title, PAL, LW, LS, draw, stageName, … }),
// populated by the bundled bundle/species/*.js files (extracted from the prototypes).
var REGISTRY = {};
function registerSpecies(idx, ext) { REGISTRY[idx] = ext; }
// contract seconds -> engine st (DAYS). Mirrors the known-good prototype harness st.
function toEngineState(state, nowSec) {
var planted = Number(state.plantedAt) || 0;
var ageDays = Math.max(0, (nowSec - planted) / DAY);
var deathDays = planted > 0 ? Math.max(1, (Number(state.deathTime) - planted) / DAY) : 365;
var lastWaterDays = Number(state.lastWatered) > planted ? (Number(state.lastWatered) - planted) / DAY : ageDays;
var st = {
seed: state.seed,
artSeed: state.artSeed || state.seed,
entryCount: Number(state.entryCount) || 0,
wateredEpochs: Number(state.wateredEpochs) || 0,
now: ageDays,
lastWatered: lastWaterDays,
lastWaterWeek: Math.floor(lastWaterDays / EPOCH_DAYS),
vitality: 1,
notes: 0,
diedAt: 0,
deathCause: null,
};
if (Number(state.diedAt) > 0) {
st.diedAt = Math.max(0, (Number(state.diedAt) - planted) / DAY);
st.deathCause = "FullTerm";
}
return { st: st, deathDays: deathDays };
}
// Each species file registers its FULLY-RESOLVED spec (the prototype already merges
// SEEDLING_toSpec(SP) before registering), so boot just hands it to the engine as-is.
function specFor(species) {
return REGISTRY[species] || null;
}
function mountTarget() {
var el = document.getElementById("art");
if (!el) {
el = document.createElement("div");
el.id = "art";
document.body.appendChild(el);
}
// Fill the frame; the injected SVG is styled (below) to letterbox into ANY aspect.
// OpenSea renders animation_url in a SQUARE, but the art is portrait (720×980) — so the
// whole plant must fit via preserveAspectRatio=meet instead of overflowing → no scroll/crop.
// Pin to the viewport with position:fixed;inset:0 — using 100vw/100vh triggers a scrollbar
// feedback loop (they ignore scrollbar width), which is why the page could be scrolled.
el.style.cssText = "position:fixed;inset:0;overflow:hidden";
if (!document.getElementById("seedling-fit")) {
var s = document.createElement("style");
s.id = "seedling-fit";
// Lock the page to the viewport so it NEVER scrolls (html/body height:100% + overflow:hidden);
// the SVG fits ANY screen via preserveAspectRatio=meet; leftover space is TRANSPARENT (host bg).
s.textContent =
"html,body{margin:0;height:100%;overflow:hidden;background:transparent}#art{background:transparent}#art svg{display:block;width:100%;height:100%}";
document.head.appendChild(s);
}
return el;
}
// Honest placeholder until a species' draw() is registered (extraction is the remaining
// art work). Keeps the bundle runnable + the on-chain pipeline demonstrable today.
function placeholder(target, species, st) {
target.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600" style="width:100%;height:100%">' +
'<rect width="600" height="600" fill="#f3efe6"/>' +
'<text x="300" y="290" text-anchor="middle" font-family="Georgia,serif" font-size="26" fill="#2c3a2c">Seedling engine</text>' +
'<text x="300" y="330" text-anchor="middle" font-family="monospace" font-size="16" fill="#5a6a5a">species ' +
species + " · entries " + st.entryCount + "</text></svg>";
}
function boot(state) {
var target = mountTarget();
if (typeof setFrame === "function") setFrame("square"); // default frame = 1000×1000 square (OpenSea 1:1)
var spec = specFor(Number(state.species));
var planted = Number(state.plantedAt) || 0;
var nowSec = planted > 0 ? Math.floor(Date.now() / 1000) : 0;
var mapped = toEngineState(state, nowSec || planted);
if (!spec || typeof renderPlant !== "function") {
placeholder(target, Number(state.species), mapped.st);
return;
}
// Pre-reveal teaser (Bamboo): hide real traits + disable full-life. Absent flag ⇒ revealed.
var revealed = !(state.revealed === false || state.revealed === "false" || state.revealed === 0 || state.revealed === "0");
var deathDays = mapped.deathDays, deathSec = planted + deathDays * DAY;
var growSpan = Math.max(1, (nowSec || planted) - planted); // sprout → now (seconds)
// ── helpers ─────────────────────────────────────────────────────────────
function clampN(v, a, b) { return v < a ? a : v > b ? b : v; }
function smooth(p) { return p * p * (3 - 2 * p); }
function esc(s) { return ("" + s).replace(/[&<>"]/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; }); }
function seasonName(day) { var d = ((day % 365) + 365) % 365; return d < 60 ? "Winter" : d < 152 ? "Spring" : d < 244 ? "Summer" : d < 335 ? "Autumn" : "Winter"; }
var DIMS = { square: { W: 1000, H: 1000, BX: 500, BY: 925 }, portrait: { W: 720, H: 980, BX: 360, BY: 905 } };
// one coherent moonlight (plant keeps its colour, cooled + a silver highlight on near-black)
var NIGHT_SKY = { sheet: "#05060a", panel: "#0a0d13" }, MOON = [206, 216, 238], _gc = {};
function _h2r(h) { h = ("" + h).replace("#", ""); if (h.length === 3) h = h.charAt(0) + h.charAt(0) + h.charAt(1) + h.charAt(1) + h.charAt(2) + h.charAt(2); var n = parseInt(h, 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
function _2h(r, g, b) { function c(v) { v = v < 0 ? 0 : v > 255 ? 255 : Math.round(v); var s = v.toString(16); return s.length < 2 ? "0" + s : s; } return "#" + c(r) + c(g) + c(b); }
function _r2h(r, g, b) { r /= 255; g /= 255; b /= 255; var mx = Math.max(r, g, b), mn = Math.min(r, g, b), h = 0, s = 0, l = (mx + mn) / 2; if (mx !== mn) { var d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60; } return [h, s, l]; }
function _h2rgb(h, s, l) { h = (((h % 360) + 360) % 360) / 360; if (s === 0) { var v = l * 255; return [v, v, v]; } var q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q; function f(t) { t = (t % 1 + 1) % 1; return t < 1 / 6 ? p + (q - p) * 6 * t : t < 1 / 2 ? q : t < 2 / 3 ? p + (q - p) * (2 / 3 - t) * 6 : p; } return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255]; }
function _mix(a, b, t) { return a + (b - a) * t; }
function nightGrade(hex) {
if (_gc[hex] !== undefined) return _gc[hex];
var rgb; try { rgb = _h2r(hex); } catch (e) { return hex; }
var a = _r2h(rgb[0], rgb[1], rgb[2]), h = a[0], s = a[1], l = a[2];
h = h + (((218 - h + 540) % 360) - 180) * 0.1; s = s * 0.88; l = clampN(l * 1.06 + 0.02, 0, 0.97);
var c = _h2rgb(h, s, l), g = 0.3 * Math.max(0, (l - 0.58) / 0.42);
var out = _2h(_mix(c[0], MOON[0], g), _mix(c[1], MOON[1], g), _mix(c[2], MOON[2], g)); _gc[hex] = out; return out;
}
// ── overlays (grid / traits / help / hint) ───────────────────────────────
function mk(css) { var d = document.createElement("div"); d.style.cssText = css; document.body.appendChild(d); return d; }
var OV = "position:fixed;inset:0;pointer-events:none;z-index:2;";
var gridEl = mk(OV + "display:none;background-size:40px 40px;background-image:linear-gradient(rgba(150,165,120,.16) 1px,transparent 1px),linear-gradient(90deg,rgba(150,165,120,.16) 1px,transparent 1px)");
var infoEl = mk(OV + "display:none;padding:16px;font:13px/1.7 ui-monospace,Menlo,monospace;color:#e7ecdf;text-shadow:0 1px 3px rgba(0,0,0,.6)");
var helpEl = mk(OV + "display:none;align-items:center;justify-content:center;background:rgba(6,8,12,.82);color:#e8eaf0;font:13px/1.85 ui-monospace,Menlo,monospace");
var hintEl = mk(OV + "display:flex;align-items:flex-end;justify-content:center;padding-bottom:14px;color:rgba(120,132,108,.9);font:12px ui-monospace,Menlo,monospace;transition:opacity 1s");
hintEl.textContent = "click, then press ? for controls";
var KEYS = [["S", "reset to default"], ["P", "portrait ↔ square"], ["W", "fu