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">◉&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>