lightDOORwsMs / mesh.html
Jesse Daniel Brown
lightDOORwsMs - a place made of light, with DOORS instead of windows
8470c3f
Raw History Blame Contribute Delete
17.5 kB
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>The light mesh — you keep the colour, not the trace</title>
<style>
:root{--bg:#0a0812;--fg:#efeaf2;--dim:#8d8496;--line:#221c2c;--gold:#e8c07a;
--ok:#7fe0a0;--no:#ff8b96}
*{box-sizing:border-box}html,body{height:100%}
body{margin:0;background:radial-gradient(70% 55% at 50% 0%,#171029 0%,#0a0812 62%);
color:var(--fg);font:14px/1.6 ui-monospace,"SF Mono",Menlo,Consolas,monospace}
main{max-width:740px;margin:0 auto;padding:24px 18px 80px}
h1{font:600 22px/1.3 ui-serif,Georgia,serif;margin:0 0 4px}
.sub{color:var(--dim);margin:0 0 18px;font-size:13px}
button{background:var(--gold);color:#120d18;border:0;border-radius:11px;
padding:14px 24px;font:700 14px ui-monospace,monospace;cursor:pointer;
margin:4px 6px 4px 0}
button.ghost{background:transparent;color:var(--fg);border:1px solid var(--line)}
button:disabled{opacity:.4;cursor:default}
h2{font-size:11px;letter-spacing:1.5px;text-transform:uppercase;color:var(--dim);
border-top:1px solid var(--line);padding-top:13px;margin:22px 0 9px;font-weight:400}
.row{display:flex;justify-content:space-between;gap:12px;padding:2px 0;font-size:13px}
.k{color:var(--dim)} .ok{color:var(--ok)} .no{color:var(--no)} .acc{color:var(--gold)}
textarea{width:100%;min-height:68px;background:rgba(255,255,255,.03);color:var(--fg);
border:1px solid var(--line);border-radius:10px;padding:10px;
font:11px/1.45 ui-monospace,monospace}
canvas{width:100%;height:230px;display:block;border:1px solid var(--line);
border-radius:11px;background:#07050e;margin:10px 0}
#log{background:rgba(255,255,255,.02);border:1px solid var(--line);border-radius:10px;
padding:10px;height:170px;overflow:auto;white-space:pre-wrap;font-size:11px;
color:#c6bdd0}
input{width:100%;background:rgba(255,255,255,.03);color:var(--fg);
border:1px solid var(--line);border-radius:10px;padding:10px;
font:13px ui-monospace,monospace;margin-top:8px}
#held{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0}
.chip{height:20px;width:52px;border-radius:5px;border:1px solid var(--line)}
p{color:#cdc4d6} b{color:#fff} small{color:var(--dim)}
</style>
<main>
<h1>The light mesh</h1>
<p class="sub">Join by knowing <b>one</b>. Keep the <b>colour</b>, drop the trace.
No server, no directory, no list of who exists.</p>
<div>
<button id="open">◉&nbsp; OPEN</button>
<button id="join" class="ghost">join someone's</button>
</div>
<h2>you</h2>
<div class="row"><span class="k">your colour</span><span id="me" class="acc">—</span></div>
<div class="row"><span class="k">boats connected</span><span id="np" class="ok">0</span></div>
<div class="row"><span class="k">colours held</span><span id="nh" class="acc">0</span></div>
<div class="row"><span class="k">rooms open</span><span id="nr">0</span></div>
<div class="row"><span class="k">introductions</span><span id="ni">0</span></div>
<div class="row"><span class="k">traces kept</span><span id="nt" class="ok">0</span></div>
<div class="row"><span class="k">server in the path</span><span class="ok">none</span></div>
<div id="held"></div>
<canvas id="cv"></canvas>
<div id="stage" style="display:none">
<h2 id="lbl">your trace — give it to one person</h2>
<textarea id="mine" readonly></textarea>
<button id="copy" class="ghost">copy</button>
<h2>theirs — paste here</h2>
<textarea id="theirs" placeholder="paste their trace"></textarea>
<button id="take" class="ghost">take it</button>
<button id="mint" class="ghost">new trace for somebody else</button>
</div>
<h2>the rooms</h2>
<input id="room" value="light" placeholder="room name — a label, not a port">
<div id="log">nothing yet.</div>
<input id="msg" placeholder="say something in this room" disabled>
<h2>tracking and addressing are different things</h2>
<p><b>Tracking</b> is keeping a record of where something is. <b>Addressing</b> is
knowing what it is. You only need the first when the name was handed out by
somebody — when the name <i>is</i> the thing, there is nothing to look up.</p>
<h2>the trace, and the colour</h2>
<p>The <b>trace</b> is the block that passes at the meeting. It is a <b>one-time
introduction</b> and it expires the moment it is used. <b>This page throws it away
on contact</b> — the counter above stays at zero on purpose.</p>
<p>The <b>colour</b> is what you keep. Absorb it and it is yours: no contact list,
no directory, no entry to delete. <b>Your network is not a list of who you know —
it is the set of colours you carry.</b></p>
<p><b>And the colour outlives the connection.</b> If a link drops nobody is lost;
you still hold their colour, and any boat that also holds it can put you back
together. Break every link at once and it reassembles, because what survived was
never the wiring.</p>
<h2>all the boats are the same boat</h2>
<p>Every one runs this identical page. There is no host version and no client
version. The only thing that differs is the colour, and the colour is the sha-256
of its own block — <b>two brown stops, banded 16…240, luminance-clamped
32…224.</b></p>
<p>Photons are the same way, and it is measured rather than assumed: two identical
photons meeting a beamsplitter always leave together, never one each way. <b>If you
could tell them apart by anything at all, the effect disappears.</b> No serial
number, no registry, nowhere to put one. If two boats ever produced the same block
they would <i>be</i> the same boat.</p>
<p><small>The first link is still made by hand. Everything after it travels the
links that already exist.</small></p>
</main>
<script>
/* ---------------------------------------------------------------------------
Keep the colour, drop the trace. A boat names itself; nobody assigns.
--------------------------------------------------------------------------- */
const ICE={iceServers:[{urls:["stun:stun.l.google.com:19302",
"stun:stun1.l.google.com:19302"]}]};
const $=id=>document.getElementById(id);
const peers=new Map(); /* colour -> {pc, ctl, rooms} */
const seen=new Set(); /* message ids already passed on */
const pending=new Map(); /* colour -> pc awaiting an answer */
let MYID=null, intros=0, traces=0, isHost=false, bootPc=null;
/* the colours you carry, and they survive a reload */
let held=new Set();
try{ held=new Set(JSON.parse(localStorage.getItem("mesh.colours")||"[]")); }catch(e){}
const absorb=c=>{ if(!c||c===MYID) return; held.add(c);
try{ localStorage.setItem("mesh.colours",JSON.stringify([...held])); }catch(e){}
paintHeld(); };
const log=t=>{const l=$("log");
if(l.textContent==="nothing yet.")l.textContent="";
l.textContent+=t+"\n"; l.scrollTop=l.scrollHeight;};
async function sha(s){
const d=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(s));
return [...new Uint8Array(d)].map(x=>x.toString(16).padStart(2,"0")).join("");
}
const cl=(v,a,b)=>Math.max(a,Math.min(b,v));
function stops(h){
const p=i=>cl(parseInt((h+"000000000000").substr(i,2),16)||16,16,240);
const lum=c=>0.2126*c[0]+0.7152*c[1]+0.0722*c[2];
const fix=c=>{const L=lum(c);return (L>=32&&L<=224)?c:
c.map(x=>Math.round(cl(x*128/Math.max(1,L),0,255)));};
return [fix([p(0),p(2),p(4)]),fix([p(6),p(8),p(10)])];
}
function paintHeld(){
$("nh").textContent=held.size;
$("held").innerHTML=[...held].map(c=>{const [a,b]=stops(c);
return "<div class=chip title='"+c+"' style=\"background:linear-gradient("+
"90deg,rgb("+a+"),rgb("+b+"))\"></div>";}).join("");
}
const stat=()=>{ $("np").textContent=peers.size; $("ni").textContent=intros;
$("nt").textContent=traces;
const rs=new Set(); for(const p of peers.values()) for(const k of p.rooms.keys()) rs.add(k);
$("nr").textContent=rs.size; };
/* ---- reduction ---- */
function reduce(d){
const s=d.sdp, g=re=>{const m=s.match(re);return m?m[1]:"";};
const c=[...s.matchAll(/a=candidate:(\S+ \d+ \S+ \d+ \S+ \d+ typ \S+)/g)]
.map(m=>m[1].replace(/ generation.*$/,""));
return [d.type==="offer"?"O":"A",g(/a=ice-ufrag:(\S+)/),g(/a=ice-pwd:(\S+)/),
g(/a=fingerprint:sha-256 (\S+)/).replace(/:/g,""),g(/a=setup:(\S+)/),
c.join("~")].join("|");
}
function expand(str){
const [t,uf,pw,fp,su,c]=str.trim().split("|");
const cands=(c||"").split("~").filter(Boolean)
.map(x=>"a=candidate:"+x+" generation 0").join("\r\n");
return {type:t==="O"?"offer":"answer", sdp:
"v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\n"+
"a=extmap-allow-mixed\r\na=msid-semantic: WMS\r\n"+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\n"+
(cands?cands+"\r\n":"")+"a=ice-ufrag:"+uf+"\r\na=ice-pwd:"+pw+"\r\n"+
"a=ice-options:trickle\r\na=fingerprint:sha-256 "+fp.match(/.{2}/g).join(":")+
"\r\na=setup:"+su+"\r\na=mid:0\r\na=sctp-port:5000\r\n"+
"a=max-message-size:262144\r\n"};
}
const gather=pc=>new Promise(r=>{
if(pc.iceGatheringState==="complete") return r();
pc.onicecandidate=e=>{ if(!e.candidate) r(); };
setTimeout(r,3000);
});
const send=(c,t)=>{ try{ if(c&&c.readyState==="open") c.send(t); }catch(e){} };
/* ---- a boat names itself; nothing is registered until it does ---- */
function greet(pc,ctl){
const hello=()=>send(ctl,"IAM|id="+MYID+"|json=0");
ctl.onopen=hello; if(ctl.readyState==="open") hello();
ctl.onmessage=e=>{
const m=e.data.match(/^IAM\|id=([0-9a-f]+)/);
if(m && !peers.has(m[1])) attach(m[1],pc,ctl);
};
}
function attach(id,pc,ctl){
const rec={pc,ctl,rooms:new Map()};
peers.set(id,rec);
absorb(id); /* the colour is kept */
dropTraces(); /* the trace is not */
ctl.onmessage=e=>onCtl(id,e.data);
ctl.onclose=()=>{ peers.delete(id); stat();
log("— "+id.slice(0,8)+" unlinked (colour kept) —"); };
pc.ondatachannel=e=>{
if(e.channel.label.startsWith("room:")){
const nm=e.channel.label.slice(5);
e.channel.onmessage=ev=>onRoom(id,nm,ev.data);
rec.rooms.set(nm,e.channel); stat();
}
};
openRoom(id,$("room").value.trim()||"light");
stat(); log("+ "+id.slice(0,8)+" — colour absorbed");
const others=[...peers.keys()].filter(x=>x!==id);
if(others.length) send(ctl,"HELLO|id="+MYID+"|peers="+others.join(",")+"|json=0");
}
function dropTraces(){
$("mine").value=""; $("theirs").value=""; traces=0; stat();
}
function openRoom(pid,name){
const rec=peers.get(pid); if(!rec||rec.rooms.has(name)) return;
const c=rec.pc.createDataChannel("room:"+name);
c.onmessage=e=>onRoom(pid,name,e.data);
rec.rooms.set(name,c); stat();
}
/* ---- introductions travel the links that exist ---- */
async function onCtl(from,row){
const f={}; row.split("|").forEach(kv=>{const i=kv.indexOf("=");
if(i>0) f[kv.slice(0,i)]=kv.slice(i+1);});
const tag=row.split("|")[0];
if(tag==="HELLO"){
for(const p of (f.peers||"").split(",").filter(Boolean)){
if(p===MYID||peers.has(p)||pending.has(p)) continue;
const pc=new RTCPeerConnection(ICE);
const ctl=pc.createDataChannel("ctl");
greet(pc,ctl);
await pc.setLocalDescription(await pc.createOffer());
await gather(pc);
pending.set(p,pc);
send(peers.get(from).ctl,
"INTRO|to="+p+"|from="+MYID+"|sdp="+reduce(pc.localDescription)+"|json=0");
intros++; stat(); log("→ asking for an introduction to "+p.slice(0,8));
}
}
else if(tag==="INTRO"){
if(f.to===MYID){
const pc=new RTCPeerConnection(ICE);
pc.ondatachannel=e=>{ if(e.channel.label==="ctl") greet(pc,e.channel); };
await pc.setRemoteDescription(expand(f.sdp));
await pc.setLocalDescription(await pc.createAnswer());
await gather(pc);
send(peers.get(from).ctl,
"BACK|to="+f.from+"|from="+MYID+"|sdp="+reduce(pc.localDescription)+"|json=0");
intros++; stat(); log("← introduced to "+f.from.slice(0,8));
} else if(peers.has(f.to)) send(peers.get(f.to).ctl,row);
}
else if(tag==="BACK"){
if(f.to===MYID && pending.has(f.from)){
const pc=pending.get(f.from);
await pc.setRemoteDescription(expand(f.sdp));
pending.delete(f.from);
} else if(peers.has(f.to)) send(peers.get(f.to).ctl,row);
}
}
/* ---- rooms ---- */
function onRoom(from,name,row){
const f={}; row.split("|").forEach(kv=>{const i=kv.indexOf("=");
if(i>0) f[kv.slice(0,i)]=kv.slice(i+1);});
if(!f.mid || seen.has(f.mid)) return;
seen.add(f.mid); absorb(f.from);
if(name===($("room").value.trim()||"light"))
log(f.from.slice(0,8)+" · "+name+" · "+f.body);
for(const [pid,rec] of peers){ if(pid===from) continue;
const c=rec.rooms.get(name); if(c) send(c,row); }
pulse(from);
}
async function say(text){
const name=$("room").value.trim()||"light";
const mid=(await sha(MYID+name+text+Object.keys(seen).length+text.length)).slice(0,8);
seen.add(mid);
const row="MSG|room="+name+"|from="+MYID+"|mid="+mid+"|body="+text+"|json=0";
for(const [pid,rec] of peers){ openRoom(pid,name); send(rec.rooms.get(name),row); }
log("you · "+name+" · "+text);
}
/* ---- drawing ---- */
const cv=$("cv"), g=cv.getContext("2d"); let W=0,H=0,t=0,pulses=[];
function fit(){ const r=cv.getBoundingClientRect();
W=cv.width=Math.max(2,r.width*2); H=cv.height=Math.max(2,r.height*2); }
addEventListener("resize",fit); setTimeout(fit,80);
const pulse=id=>pulses.push({id,t:0});
function frame(){
t+=0.016;
g.fillStyle="#07050e"; g.fillRect(0,0,W,H);
const ids=[MYID,...peers.keys()].filter(Boolean);
const n=Math.max(1,ids.length), R=Math.min(W,H)*0.32, cx=W/2, cy=H/2, pos={};
ids.forEach((id,i)=>{
if(i===0){ pos[id]=[cx,cy]; return; }
const a=t*0.15+(i-1)*(2*Math.PI/Math.max(1,n-1));
pos[id]=[cx+Math.cos(a)*R, cy+Math.sin(a)*R];
});
g.lineWidth=2;
for(const id of ids.slice(1)){
const [,b]=stops(id);
const gr=g.createLinearGradient(cx,cy,pos[id][0],pos[id][1]);
gr.addColorStop(0,"rgba(255,255,255,.10)"); gr.addColorStop(1,"rgb("+b+")");
g.strokeStyle=gr; g.globalAlpha=.55;
g.beginPath(); g.moveTo(cx,cy); g.lineTo(...pos[id]); g.stroke();
}
g.globalAlpha=1;
for(const id of ids){
const [a,b]=stops(id), p=pos[id], r=id===MYID?16:12;
const gr=g.createRadialGradient(p[0],p[1],0,p[0],p[1],r*2.6);
gr.addColorStop(0,"rgb("+a+")"); gr.addColorStop(1,"rgba(0,0,0,0)");
g.globalAlpha=.6; g.fillStyle=gr;
g.beginPath(); g.arc(p[0],p[1],r*2.6,0,6.2832); g.fill();
g.globalAlpha=1; g.fillStyle="rgb("+b+")";
g.beginPath(); g.arc(p[0],p[1],r,0,6.2832); g.fill();
g.fillStyle="rgba(240,235,245,.8)"; g.font="18px ui-monospace,monospace";
g.textAlign="center"; g.fillText(id.slice(0,6), p[0], p[1]+r+22);
}
pulses=pulses.filter(p=>{ p.t+=0.03; return p.t<1; });
for(const p of pulses){ const q=pos[p.id]; if(!q) continue;
g.strokeStyle="rgba(232,192,122,"+(1-p.t)+")"; g.lineWidth=2;
g.beginPath(); g.arc(q[0],q[1],12+p.t*40,0,6.2832); g.stroke(); }
if(ids.length<=1){
g.fillStyle="rgba(141,132,150,.8)"; g.font="20px ui-monospace,monospace";
g.textAlign="center"; g.fillText("one boat. give your trace to somebody.",cx,cy+90); }
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
/* ---- the one link made by hand ---- */
async function emit(desc){
const small=reduce(desc);
$("mine").value=small; traces=1; stat();
const h=await sha(small);
if(!MYID){ MYID=h.slice(0,16); $("me").textContent=MYID; }
}
$("open").onclick=async()=>{
isHost=true; $("open").disabled=true; $("join").disabled=true;
$("stage").style.display="block";
bootPc=new RTCPeerConnection(ICE);
greet(bootPc,bootPc.createDataChannel("ctl"));
await bootPc.setLocalDescription(await bootPc.createOffer());
await gather(bootPc);
await emit(bootPc.localDescription);
log("give that trace to one person.");
};
$("join").onclick=()=>{
isHost=false; $("open").disabled=true; $("join").disabled=true;
$("stage").style.display="block";
$("lbl").textContent="your trace — appears after you take theirs";
log("paste their trace, then take it.");
};
$("take").onclick=async()=>{
const raw=$("theirs").value.trim(); if(!raw) return;
let d; try{ d=expand(raw); }catch(e){ log("not a trace."); return; }
if(isHost){ await bootPc.setRemoteDescription(d); log("taken. opening…"); }
else{
bootPc=new RTCPeerConnection(ICE);
bootPc.ondatachannel=e=>{ if(e.channel.label==="ctl") greet(bootPc,e.channel); };
await bootPc.setRemoteDescription(d);
await bootPc.setLocalDescription(await bootPc.createAnswer());
await gather(bootPc);
await emit(bootPc.localDescription);
log("your trace is above. send it back.");
}
};
$("mint").onclick=async()=>{
isHost=true;
bootPc=new RTCPeerConnection(ICE);
greet(bootPc,bootPc.createDataChannel("ctl"));
await bootPc.setLocalDescription(await bootPc.createOffer());
await gather(bootPc);
const small=reduce(bootPc.localDescription);
$("mine").value=small; traces=1; stat();
$("lbl").textContent="a fresh trace — one use, for one person";
log("new trace minted. the old one is spent.");
};
$("copy").onclick=()=>{ $("mine").select(); document.execCommand("copy"); };
$("msg").onkeydown=e=>{ if(e.key!=="Enter") return;
const v=e.target.value.trim(); if(!v||!peers.size) return;
say(v); e.target.value=""; };
setInterval(()=>{ $("msg").disabled = peers.size===0; },600);
paintHeld(); stat();
</script>