File size: 17,453 Bytes
8470c3f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 | <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">◉ 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>
|