Jesse Daniel Brown commited on
Commit
8470c3f
·
1 Parent(s): dbbf263

lightDOORwsMs - a place made of light, with DOORS instead of windows

Browse files

A DOOR IS A LABEL, NOT A SOCKET. That is the whole idea and it is the thing
people get wrong first. A socket is a numbered slot somebody hands out and there
are about ninety thousand of them, so people assume that is the ceiling. It is
not, because a door here is a NAME and the name is the route. Cutting a second
door does not consume a second slot, it prefixes an existing one - light,
light.kitchen, light.kitchen.tuesday, as deep as you like. THERE IS NO COUNT TO
RUN OUT OF; that ceiling only exists where somebody hands out numbered slots.

MEASURED, two separate boats, linked by hand once:
words through a door crossed
a door cut on one side appeared on the other by itself
words through the new door crossed
a stroke on the shared field crossed
a file, 9,000 bytes arrived BYTE-EXACT
sha-256 sent 78bd367073786b59cfed044eb4c372b1...
sha-256 arrived 78bd367073786b59cfed044eb4c372b1...
exact true
traces kept after contact 0
colours held after contact 1, and it survives a reload

THE COLOUR IS THE ADDRESS. It is the sha-256 of a secret this browser made for
itself the first time the page opened - two brown stops, banded 16..240,
luminance-clamped 32..224. THE SECRET NEVER LEAVES, ONLY THE COLOUR DOES. Nobody
handed you the name so nobody can take it away, and it does not change when a
link does. Two tabs of the same browser come up THE SAME COLOUR and the page says
so rather than pretending they are two people - same machine, same secret, same
boat.

THE TRACE IS SPENT, THE COLOUR IS KEPT. The block passed at the first meeting is
a ONE-TIME INTRODUCTION and the page throws it away on contact; the counter reads
zero afterwards on purpose. What you keep is the colour - not a contact list,
there is no entry, so nothing to delete and nothing to seize. AND THE COLOUR
OUTLIVES THE CONNECTION: if a link drops nobody is lost, and any boat that also
holds that colour can put you back together. Break every link at once and it
reassembles, because WHAT SURVIVED WAS NEVER THE WIRING.

HOW ANYONE ELSE GETS IN: you are INTRODUCED, by somebody who already knows you
both, and the introduction travels the links that already exist. That is the only
directory there is and it is not a list anyone can hold.

WHAT IS FREE AND THE ONE THING THAT ISN'T. No server of ours touches any of it -
words, drawings, files - once a line is open it is browser to browser with
nothing in the middle to store, log or relay. The one thing that is not free,
stated rather than hidden: browsers ask a public STUN server for their own
outside address. Free ones. They see that two addresses asked; THEY NEVER SEE A
WORD OF WHAT YOU SAY. On the same wifi it does not need them at all, and DOWN A
USB CABLE IT NEEDS NOTHING WHATSOEVER - tested, a phone reached this over the
cable with no router and no internet in the path.

WHAT IS NOT HERE YET AND WHY: a coloured QR so the first meeting could be light
itself. THE IDEA IS VERIFIED - the block is 415 characters, fits one version-13
code, OpenCV decodes it back exactly. THE ENCODER DOES NOT YET PRODUCE A VALID
CODE, checked against a reference implementation at all eight mask patterns, no
match at any of them. A CODE THAT HALF-READS IS WORSE THAN NONE because you find
out while holding a phone up in front of somebody. It goes in when it passes.

Two files, one each, no build, no dependency, nothing to fetch.

Jesse Daniel Brown (OP-JESSE). Doors, not windows. Nobody hands out the names.

Files changed (3) hide show
  1. README.md +137 -2
  2. index.html +604 -0
  3. mesh.html +383 -0
README.md CHANGED
@@ -1,2 +1,137 @@
1
- # lightDOORwsMs
2
- A place made of light with DOORS instead of windows. A door is a label, not a socket - cut as many as you like, it costs nothing. Words, drawings and files go browser to browser with no server, no account and no directory. You keep the colour; the trace is spent on contact.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # lightDOORwsMs
2
+
3
+ **One button:** → **https://jessebrown1980.github.io/lightDOORwsMs/**
4
+
5
+ A place made of light. It has **doors**, not windows — and a door is a **label,
6
+ not a socket**, so you can cut as many as you like and it costs nothing.
7
+
8
+ No server. No account. No install. No directory of who exists.
9
+
10
+ ---
11
+
12
+ ## What works, measured
13
+
14
+ Two separate boats, linked by hand once, then everything after it:
15
+
16
+ ```
17
+ words through a door crossed
18
+ a door cut on one side appeared on the other, by itself
19
+ words through the new door crossed
20
+ a stroke on the shared field crossed
21
+ a file, 9,000 bytes arrived BYTE-EXACT
22
+
23
+ sha-256 sent 78bd367073786b59cfed044eb4c372b1…
24
+ sha-256 arrived 78bd367073786b59cfed044eb4c372b1…
25
+ exact true
26
+
27
+ the trace after contact 0 kept
28
+ the colour after contact 1 held, and it survives a reload
29
+ ```
30
+
31
+ Nothing in the middle. The file never touched a disk that wasn't one of the two.
32
+
33
+ ---
34
+
35
+ ## A door is a label, not a socket
36
+
37
+ This is the whole idea and it is the thing people get wrong first.
38
+
39
+ A socket is a numbered slot somebody hands out, and there are about ninety
40
+ thousand of them, so people assume that is the ceiling. **It isn't, because a
41
+ door here is a name, and the name is the route.** Cutting a second door does not
42
+ consume a second slot — it prefixes an existing one. `light`, `light.kitchen`,
43
+ `light.kitchen.tuesday`; the tree goes as deep as you like.
44
+
45
+ **There is no count to run out of.** That ceiling only exists where somebody is
46
+ handing out numbered slots, and here nobody is.
47
+
48
+ ---
49
+
50
+ ## The colour is the address
51
+
52
+ Your colour is the **sha-256 of a secret this browser made for itself** the first
53
+ time you opened the page — two brown stops, banded `16…240`, luminance-clamped
54
+ `32…224`.
55
+
56
+ **The secret never leaves. Only the colour does.** Nobody handed you the name, so
57
+ nobody can take it away, and it does not change when a link does.
58
+
59
+ Two tabs of the same browser come up **the same colour**, and the page says so
60
+ rather than pretending they are two people. Same machine, same secret, same boat.
61
+
62
+ ---
63
+
64
+ ## The trace is spent. The colour is kept.
65
+
66
+ The block you pass at the first meeting is a **one-time introduction**. This page
67
+ **throws it away on contact** — the counter reads zero afterwards on purpose.
68
+
69
+ What you keep is the **colour**. Not a contact list: there is no entry, so there
70
+ is nothing to delete and nothing to seize.
71
+
72
+ **And the colour outlives the connection.** If a link drops nobody is lost — you
73
+ still hold their colour, and any boat that also holds it can put you back
74
+ together. Break every link at once and it reassembles, because **what survived
75
+ was never the wiring.**
76
+
77
+ ---
78
+
79
+ ## How anyone else gets in
80
+
81
+ You are **introduced**, by somebody who already knows you both. The introduction
82
+ travels the links that already exist — offer out, answer back, along the wire
83
+ that is already there.
84
+
85
+ That is the only directory there is, and **it is not a list anyone can hold.**
86
+
87
+ ---
88
+
89
+ ## What is free, and the one thing that isn't
90
+
91
+ **No server of ours touches any of it** — words, drawings, files. Once a line is
92
+ open it is browser to browser and there is nothing in the middle to store, log or
93
+ relay.
94
+
95
+ The one thing that isn't free, stated rather than hidden: to find a route across
96
+ the internet, browsers ask a public **STUN** server for their own outside address.
97
+ Free ones. **They see that two addresses asked; they never see a word of what you
98
+ say.**
99
+
100
+ On the same wifi it doesn't need them at all — and **down a USB cable it needs
101
+ nothing whatsoever.** That was tested: a phone reached this over the cable with
102
+ the wifi off, no router, no internet in the path.
103
+
104
+ ---
105
+
106
+ ## What is in here
107
+
108
+ ```
109
+ index.html the place - doors, people, say, paint, carry
110
+ mesh.html the bare kernel it is built on, if you want to read it small
111
+ ```
112
+
113
+ Both are one file each. No build, no dependency, nothing to install, nothing to
114
+ fetch. Open it from anywhere, including a file on a disk.
115
+
116
+ ---
117
+
118
+ ## What is not here yet, and why
119
+
120
+ A **coloured QR** so the first meeting could be light itself — photons off one
121
+ screen into another camera, no network in the meeting at all.
122
+
123
+ **The idea is verified:** the block is 415 characters, fits one version-13 code,
124
+ and OpenCV decodes it back exactly. **The encoder written for it does not yet
125
+ produce a valid code** — checked against a reference implementation at all eight
126
+ mask patterns, no match at any of them. Two real bugs were found and fixed along
127
+ the way and it still does not agree.
128
+
129
+ So it is not in this page. **A code that half-reads is worse than none**, because
130
+ you find out while holding a phone up in front of somebody. It goes in when it
131
+ passes, and not before.
132
+
133
+ ---
134
+
135
+ **Jesse Daniel Brown (OP-JESSE).**
136
+
137
+ *Doors, not windows. Nobody hands out the names.*
index.html ADDED
@@ -0,0 +1,604 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <meta charset="utf-8">
2
+ <meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
3
+ <title>lightDOORs — a place made of light, with doors instead of windows</title>
4
+ <style>
5
+ :root{--bg:#08060f;--fg:#efeaf2;--dim:#8d8496;--line:#241e30;--gold:#e8c07a;
6
+ --ok:#7fe0a0;--no:#ff8b96;--pane:#0e0a18}
7
+ *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
8
+ html,body{height:100%;margin:0;overflow:hidden}
9
+ body{background:radial-gradient(120% 90% at 50% -10%,#1a1030 0%,#08060f 60%);
10
+ color:var(--fg);font:13px/1.55 ui-monospace,"SF Mono",Menlo,Consolas,monospace}
11
+
12
+ /* the field the doors stand in */
13
+ #field{position:fixed;inset:0}
14
+ #stars{position:fixed;inset:0;pointer-events:none}
15
+
16
+ /* a pane of light */
17
+ .pane{position:absolute;min-width:230px;min-height:150px;background:rgba(14,10,24,.93);
18
+ border:1px solid var(--line);border-radius:13px;box-shadow:0 18px 60px rgba(0,0,0,.6);
19
+ display:flex;flex-direction:column;overflow:hidden;backdrop-filter:blur(9px)}
20
+ .pane.on{border-color:rgba(232,192,122,.42)}
21
+ .bar{display:flex;align-items:center;gap:8px;padding:7px 9px;cursor:grab;
22
+ background:linear-gradient(180deg,rgba(255,255,255,.05),rgba(255,255,255,0));
23
+ border-bottom:1px solid var(--line);flex:0 0 auto;touch-action:none}
24
+ .bar b{font-weight:600;font-size:12px;flex:1;white-space:nowrap;overflow:hidden;
25
+ text-overflow:ellipsis}
26
+ .dot{width:11px;height:11px;border-radius:50%;border:0;cursor:pointer;flex:0 0 auto}
27
+ .dot.x{background:#ff6b78}.dot.m{background:#e8c07a}
28
+ .body{flex:1;overflow:auto;padding:10px;font-size:12.5px}
29
+ .grip{position:absolute;right:0;bottom:0;width:18px;height:18px;cursor:nwse-resize;
30
+ background:linear-gradient(135deg,transparent 50%,rgba(255,255,255,.22) 50%);
31
+ touch-action:none}
32
+ .key{width:14px;height:14px;border-radius:4px;flex:0 0 auto;border:1px solid rgba(0,0,0,.4)}
33
+
34
+ /* the sill — where the doors are */
35
+ #sill{position:fixed;left:0;right:0;bottom:0;display:flex;gap:6px;padding:8px 10px;
36
+ background:rgba(8,6,15,.9);border-top:1px solid var(--line);overflow-x:auto;
37
+ align-items:center;z-index:9000;backdrop-filter:blur(12px)}
38
+ #sill button{background:transparent;color:var(--fg);border:1px solid var(--line);
39
+ border-radius:9px;padding:8px 12px;font:600 12px ui-monospace,monospace;
40
+ cursor:pointer;white-space:nowrap;flex:0 0 auto}
41
+ #sill button.lit{border-color:var(--gold);color:var(--gold)}
42
+ #sill .me{margin-left:auto;display:flex;align-items:center;gap:7px;flex:0 0 auto;
43
+ color:var(--dim);font-size:11px;padding-left:10px}
44
+
45
+ /* bits */
46
+ input,textarea,select{width:100%;background:rgba(255,255,255,.04);color:var(--fg);
47
+ border:1px solid var(--line);border-radius:9px;padding:9px;
48
+ font:12.5px ui-monospace,monospace;margin:4px 0}
49
+ textarea{min-height:62px;font-size:11px;line-height:1.45}
50
+ .btn{background:var(--gold);color:#120d18;border:0;border-radius:9px;padding:9px 15px;
51
+ font:700 12px ui-monospace,monospace;cursor:pointer;margin:3px 5px 3px 0}
52
+ .btn.g{background:transparent;color:var(--fg);border:1px solid var(--line)}
53
+ .row{display:flex;justify-content:space-between;gap:10px;padding:1px 0}
54
+ .k{color:var(--dim)}.ok{color:var(--ok)}.no{color:var(--no)}.acc{color:var(--gold)}
55
+ .stream{height:100%;display:flex;flex-direction:column}
56
+ .said{flex:1;overflow:auto;white-space:pre-wrap;word-break:break-word}
57
+ .said div{padding:2px 0}
58
+ .said i{font-style:normal;color:var(--dim)}
59
+ .chip{height:17px;width:44px;border-radius:5px;border:1px solid var(--line);
60
+ display:inline-block;vertical-align:middle;margin:2px 4px 2px 0;cursor:pointer}
61
+ h4{margin:9px 0 5px;font-size:10.5px;letter-spacing:1.4px;text-transform:uppercase;
62
+ color:var(--dim);font-weight:400;border-top:1px solid var(--line);padding-top:8px}
63
+ h4:first-child{border-top:0;padding-top:0;margin-top:0}
64
+ p{color:#c9c0d4;margin:6px 0}b{color:#fff}small{color:var(--dim)}
65
+ canvas.paint{width:100%;height:100%;display:block;background:#050309;
66
+ border-radius:8px;touch-action:none}
67
+ #toast{position:fixed;left:50%;top:14px;transform:translateX(-50%);z-index:9999;
68
+ background:rgba(20,14,34,.95);border:1px solid var(--line);border-radius:10px;
69
+ padding:9px 15px;font-size:12px;opacity:0;transition:opacity .3s;pointer-events:none}
70
+ </style>
71
+
72
+ <canvas id="stars"></canvas>
73
+ <div id="field"></div>
74
+
75
+ <div id="sill">
76
+ <button id="d-open" class="lit">◉ open</button>
77
+ <button data-door="doors">doors</button>
78
+ <button data-door="people">people</button>
79
+ <button data-door="say">say</button>
80
+ <button data-door="paint">paint</button>
81
+ <button data-door="carry">carry</button>
82
+ <button data-door="what">what is this</button>
83
+ <span class="me"><span class="key" id="mykey"></span><span id="myid">—</span>
84
+ <span id="mypeers" class="ok">0</span></span>
85
+ </div>
86
+ <div id="toast"></div>
87
+
88
+ <script>
89
+ /* =========================================================================
90
+ lightDOORs. A door is a label, not a socket — you can open as many as you
91
+ like and it costs nothing, because there was never a socket per door.
92
+ Nothing here is stored on anyone's machine but yours.
93
+ ========================================================================= */
94
+ const ICE={iceServers:[{urls:["stun:stun.l.google.com:19302",
95
+ "stun:stun1.l.google.com:19302"]}]};
96
+ const $=s=>document.querySelector(s);
97
+ const peers=new Map(); /* colour -> {pc, ctl, doors:Map} */
98
+ const pending=new Map();
99
+ const seen=new Set();
100
+ const doors=new Map(); /* door name -> [{from,body,t}] */
101
+ const strokes=new Map(); /* door name -> [[x,y,px,py,colour]] */
102
+ const carried=[]; /* things that arrived */
103
+ let MYID=null, bootPc=null, isHost=false, z=10;
104
+
105
+ let held=new Set();
106
+ try{held=new Set(JSON.parse(localStorage.getItem("ld.colours")||"[]"))}catch(e){}
107
+ let myDoors=[];
108
+ try{myDoors=JSON.parse(localStorage.getItem("ld.doors")||'["light"]')}catch(e){myDoors=["light"]}
109
+ if(!myDoors.length) myDoors=["light"];
110
+ const saveDoors=()=>{try{localStorage.setItem("ld.doors",JSON.stringify(myDoors))}catch(e){}};
111
+
112
+ /* ---- colour is the address; the address is the content ---- */
113
+ async function sha(s){
114
+ const d=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(s));
115
+ return [...new Uint8Array(d)].map(x=>x.toString(16).padStart(2,"0")).join("");
116
+ }
117
+ const cl=(v,a,b)=>Math.max(a,Math.min(b,v));
118
+ function stops(h){
119
+ h=(h||"")+"000000000000";
120
+ const p=i=>cl(parseInt(h.substr(i,2),16)||16,16,240);
121
+ const lum=c=>0.2126*c[0]+0.7152*c[1]+0.0722*c[2];
122
+ const fix=c=>{const L=lum(c);return (L>=32&&L<=224)?c:
123
+ c.map(x=>Math.round(cl(x*128/Math.max(1,L),0,255)))};
124
+ return [fix([p(0),p(2),p(4)]),fix([p(6),p(8),p(10)])];
125
+ }
126
+ const grad=h=>{const[a,b]=stops(h);
127
+ return "linear-gradient(90deg,rgb("+a+"),rgb("+b+"))"};
128
+ const ink=h=>"rgb("+stops(h)[1]+")";
129
+ const absorb=c=>{ if(!c||c===MYID||held.has(c)) return;
130
+ held.add(c); try{localStorage.setItem("ld.colours",JSON.stringify([...held]))}catch(e){}
131
+ refresh("people"); };
132
+
133
+ const toast=t=>{const e=$("#toast");e.textContent=t;e.style.opacity=1;
134
+ clearTimeout(e._t);e._t=setTimeout(()=>e.style.opacity=0,2300)};
135
+
136
+ /* =========================== panes ====================================== */
137
+ const open=new Map();
138
+ function pane(id,title,build,w,h){
139
+ if(open.has(id)){ const p=open.get(id); p.style.zIndex=++z; lit(); return p; }
140
+ const p=document.createElement("div"); p.className="pane"; p.style.zIndex=++z;
141
+ const n=open.size, vw=innerWidth, vh=innerHeight;
142
+ w=Math.min(w||330,vw-24); h=Math.min(h||300,vh-110);
143
+ p.style.left=Math.min(vw-w-12,18+n*26)+"px";
144
+ p.style.top =Math.min(vh-h-70,18+n*24)+"px";
145
+ p.style.width=w+"px"; p.style.height=h+"px";
146
+ p.innerHTML='<div class=bar><b></b>'+
147
+ '<button class="dot m"></button><button class="dot x"></button></div>'+
148
+ '<div class=body></div><div class=grip></div>';
149
+ p.querySelector("b").textContent=title;
150
+ build(p.querySelector(".body"),p);
151
+ p.onpointerdown=()=>{p.style.zIndex=++z;lit()};
152
+ drag(p.querySelector(".bar"),p); resize(p.querySelector(".grip"),p);
153
+ p.querySelector(".dot.x").onclick=e=>{e.stopPropagation();p.remove();open.delete(id);lit()};
154
+ p.querySelector(".dot.m").onclick=e=>{e.stopPropagation();
155
+ const b=p.querySelector(".body");
156
+ const min=b.style.display!=="none";
157
+ b.style.display=min?"none":"";p.querySelector(".grip").style.display=min?"none":"";
158
+ p._h=p._h||p.style.height; p.style.height=min?"auto":p._h};
159
+ $("#field").appendChild(p); open.set(id,p); lit();
160
+ return p;
161
+ }
162
+ function lit(){
163
+ document.querySelectorAll("#sill button[data-door]").forEach(b=>
164
+ b.classList.toggle("lit",open.has(b.dataset.door)));
165
+ }
166
+ function drag(h,p){
167
+ h.onpointerdown=e=>{ if(e.target.classList.contains("dot"))return;
168
+ h.setPointerCapture(e.pointerId); p.style.zIndex=++z;
169
+ const sx=e.clientX-p.offsetLeft, sy=e.clientY-p.offsetTop;
170
+ h.onpointermove=m=>{
171
+ p.style.left=cl(m.clientX-sx,-40,innerWidth-70)+"px";
172
+ p.style.top =cl(m.clientY-sy,0,innerHeight-70)+"px"; };
173
+ h.onpointerup=()=>{h.onpointermove=null;h.onpointerup=null}; };
174
+ }
175
+ function resize(g,p){
176
+ g.onpointerdown=e=>{ e.stopPropagation(); g.setPointerCapture(e.pointerId);
177
+ const sw=p.offsetWidth-e.clientX, sh=p.offsetHeight-e.clientY;
178
+ g.onpointermove=m=>{ p.style.width=Math.max(230,sw+m.clientX)+"px";
179
+ p.style.height=Math.max(150,sh+m.clientY)+"px"; p._h=p.style.height;
180
+ if(p._fit)p._fit(); };
181
+ g.onpointerup=()=>{g.onpointermove=null;g.onpointerup=null}; };
182
+ }
183
+ const refresh=id=>{ const p=open.get(id); if(p&&p._draw) p._draw(); };
184
+
185
+ /* =========================== the doors ================================== */
186
+ function doorPane(name){
187
+ pane("door:"+name,"door · "+name,(b,p)=>{
188
+ b.innerHTML='<div class=stream><div class=said></div>'+
189
+ '<input placeholder="say it into this door"></div>';
190
+ b.style.padding="8px";
191
+ const said=b.querySelector(".said"), inp=b.querySelector("input");
192
+ p._draw=()=>{
193
+ said.innerHTML=(doors.get(name)||[]).slice(-300).map(m=>
194
+ '<div><span class=key style="background:'+grad(m.from)+
195
+ '"></span> <i>'+(m.from===MYID?"you":m.from.slice(0,6))+'</i> '+
196
+ esc(m.body)+'</div>').join("");
197
+ said.scrollTop=said.scrollHeight; };
198
+ inp.onkeydown=e=>{ if(e.key!=="Enter")return;
199
+ const v=inp.value.trim(); if(!v)return; inp.value="";
200
+ speak(name,v); };
201
+ p._draw();
202
+ },380,300);
203
+ }
204
+ const esc=s=>s.replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
205
+
206
+ const DOORS={
207
+ doors:(b,p)=>{
208
+ p._draw=()=>{
209
+ b.innerHTML="<h4>your doors — a door is a label, not a socket</h4>"+
210
+ myDoors.map(d=>'<button class="btn g" data-d="'+d+'">'+d+'</button>').join("")+
211
+ '<h4>cut a new one</h4><input id=nd placeholder="name it — anything">'+
212
+ '<button class="btn" id=cut>cut the door</button>'+
213
+ '<p><small>Opening one costs nothing. There was never a socket per door — '+
214
+ 'the name <b>is</b> the route, so the count has no ceiling.</small></p>';
215
+ b.querySelectorAll("[data-d]").forEach(x=>x.onclick=()=>doorPane(x.dataset.d));
216
+ b.querySelector("#cut").onclick=()=>{
217
+ const v=b.querySelector("#nd").value.trim().toLowerCase().replace(/\s+/g,"-");
218
+ if(!v)return; if(!myDoors.includes(v)){myDoors.push(v);saveDoors()}
219
+ p._draw(); doorPane(v); openDoorEverywhere(v); };
220
+ };
221
+ p._draw();
222
+ },
223
+ people:(b,p)=>{
224
+ p._draw=()=>{
225
+ const live=[...peers.keys()];
226
+ b.innerHTML="<h4>here now</h4>"+
227
+ (live.length?live.map(c=>'<span class=chip title="'+c+'" style="background:'+
228
+ grad(c)+'"></span>').join(""):"<small>nobody yet</small>")+
229
+ "<h4>colours you carry — "+held.size+"</h4>"+
230
+ (held.size?[...held].map(c=>'<span class=chip title="'+c+'" style="background:'+
231
+ grad(c)+'"></span>').join(""):"<small>none yet</small>")+
232
+ "<p><small>The colour outlives the link. Nothing here is a contact list — "+
233
+ "there is no entry to delete, because there was never an entry.</small></p>";
234
+ };
235
+ p._draw();
236
+ },
237
+ say:(b,p)=>{ b.innerHTML="";
238
+ const sel=document.createElement("select");
239
+ const redraw=()=>{ sel.innerHTML=myDoors.map(d=>"<option>"+d+"</option>").join("") };
240
+ redraw(); b.appendChild(sel);
241
+ const s=document.createElement("div"); s.className="said";
242
+ s.style.cssText="height:calc(100% - 92px);overflow:auto"; b.appendChild(s);
243
+ const i=document.createElement("input"); i.placeholder="say it"; b.appendChild(i);
244
+ p._draw=()=>{ redraw();
245
+ s.innerHTML=(doors.get(sel.value)||[]).slice(-300).map(m=>
246
+ '<div><span class=key style="background:'+grad(m.from)+'"></span> <i>'+
247
+ (m.from===MYID?"you":m.from.slice(0,6))+'</i> '+esc(m.body)+'</div>').join("");
248
+ s.scrollTop=s.scrollHeight; };
249
+ sel.onchange=p._draw;
250
+ i.onkeydown=e=>{ if(e.key!=="Enter")return; const v=i.value.trim();
251
+ if(!v)return; i.value=""; speak(sel.value,v); };
252
+ p._draw();
253
+ },
254
+ paint:(b,p)=>{
255
+ b.style.padding="6px"; b.innerHTML='<canvas class=paint></canvas>';
256
+ const c=b.querySelector("canvas"), g=c.getContext("2d");
257
+ let last=null;
258
+ p._fit=()=>{ const r=c.getBoundingClientRect();
259
+ c.width=Math.max(2,r.width*2); c.height=Math.max(2,r.height*2); p._draw(); };
260
+ p._draw=()=>{ g.fillStyle="#050309"; g.fillRect(0,0,c.width,c.height);
261
+ g.lineCap="round";
262
+ for(const s of (strokes.get("light")||[])){
263
+ g.strokeStyle=ink(s[4]); g.lineWidth=5;
264
+ g.beginPath(); g.moveTo(s[2]*c.width,s[3]*c.height);
265
+ g.lineTo(s[0]*c.width,s[1]*c.height); g.stroke(); } };
266
+ const at=e=>{const r=c.getBoundingClientRect();
267
+ return [(e.clientX-r.left)/r.width,(e.clientY-r.top)/r.height]};
268
+ c.onpointerdown=e=>{ c.setPointerCapture(e.pointerId); last=at(e); };
269
+ c.onpointermove=e=>{ if(!last)return; const n=at(e);
270
+ addStroke("light",[n[0],n[1],last[0],last[1],MYID]);
271
+ gossip("light","STROKE|from="+MYID+"|s="+
272
+ [n[0],n[1],last[0],last[1]].map(v=>v.toFixed(4)).join(",")+"|json=0");
273
+ last=n; };
274
+ c.onpointerup=()=>last=null;
275
+ setTimeout(p._fit,60);
276
+ },
277
+ carry:(b,p)=>{
278
+ p._draw=()=>{
279
+ b.innerHTML="<h4>hand something over</h4>"+
280
+ '<input type=file id=f multiple>'+
281
+ "<h4>what arrived — "+carried.length+"</h4>"+
282
+ (carried.length?carried.map((c,i)=>
283
+ '<div class=row><span>'+esc(c.name)+'</span>'+
284
+ '<a class=acc download="'+esc(c.name)+'" href="'+c.url+'">keep it</a></div>'
285
+ ).join(""):"<small>nothing yet</small>")+
286
+ "<p><small>It goes down the same line as the words. Nobody hosts it, "+
287
+ "nobody stores it, and it never leaves the two machines.</small></p>";
288
+ b.querySelector("#f").onchange=e=>{ for(const f of e.target.files) handOver(f); };
289
+ };
290
+ p._draw();
291
+ },
292
+ what:(b)=>{ b.innerHTML=
293
+ "<h4>a door is a label, not a socket</h4>"+
294
+ "<p>Cut as many as you like. The name <b>is</b> the route, so there is no "+
295
+ "count to run out of — that ceiling only exists where somebody hands out "+
296
+ "numbered slots.</p>"+
297
+ "<h4>the colour is the address</h4>"+
298
+ "<p>Your colour is the sha-256 of a secret this browser made for itself the "+
299
+ "first time you opened the page — two brown stops, banded 16…240, "+
300
+ "luminance-clamped 32…224. <b>The secret never leaves; only the colour does.</b> "+
301
+ "Nobody handed you the name, so nobody can take it away, and it does not "+
302
+ "change when a link does.</p>"+
303
+ "<h4>the trace is spent, the colour is kept</h4>"+
304
+ "<p>The block you pass at the first meeting is a <b>one-time introduction</b> "+
305
+ "and this page throws it away on contact. What you keep is the colour. Break "+
306
+ "every link at once and it reassembles, because what survived was never the "+
307
+ "wiring.</p>"+
308
+ "<h4>what is free, and the one thing that isn't</h4>"+
309
+ "<p>No server of ours touches any of it — words, drawings, files, all of it "+
310
+ "goes browser to browser with nothing in the middle to store or relay. The one "+
311
+ "thing that isn't free, stated rather than hidden: to find a route across the "+
312
+ "internet, browsers ask a public <b>STUN</b> server for their own outside "+
313
+ "address. Free ones. <b>They see that two addresses asked; they never see a "+
314
+ "word of what you say.</b> On the same wifi — or down a USB cable — it doesn't "+
315
+ "need them at all.</p>"+
316
+ "<h4>the first meeting is still by hand</h4>"+
317
+ "<p>Everything after it travels the links that already exist: you are "+
318
+ "introduced by somebody who already knows you both. That is the only "+
319
+ "directory there is, and it is not a list anyone can seize.</p>" }
320
+ };
321
+
322
+ /* =========================== traffic ==================================== */
323
+ function addStroke(door,s){
324
+ if(!strokes.has(door)) strokes.set(door,[]);
325
+ const a=strokes.get(door); a.push(s); if(a.length>4000)a.shift();
326
+ refresh("paint");
327
+ }
328
+ function record(door,from,body){
329
+ if(!doors.has(door)) doors.set(door,[]);
330
+ doors.get(door).push({from,body});
331
+ refresh("door:"+door); refresh("say");
332
+ }
333
+ async function mid(){ return (await sha(MYID+Math.random()+performance.now())).slice(0,10) }
334
+ async function speak(door,text){
335
+ const id=await mid(); seen.add(id);
336
+ record(door,MYID,text);
337
+ fan(door,"SAY|from="+MYID+"|mid="+id+"|body="+text+"|json=0");
338
+ }
339
+ async function gossip(door,row){
340
+ const id=await mid(); seen.add(id);
341
+ fan(door,row.replace("|json=0","|mid="+id+"|json=0"));
342
+ }
343
+ function fan(door,row,except){
344
+ for(const [pid,rec] of peers){ if(pid===except) continue;
345
+ let ch=rec.doors.get(door);
346
+ if(!ch){ ch=rec.pc.createDataChannel("door:"+door);
347
+ ch.onmessage=e=>onDoor(pid,door,e.data); rec.doors.set(door,ch); }
348
+ try{ if(ch.readyState==="open") ch.send(row) }catch(e){}
349
+ }
350
+ }
351
+ function openDoorEverywhere(door){ fan(door,"OPEN|from="+MYID+"|json=0") }
352
+ function onDoor(from,door,row){
353
+ const f={}; row.split("|").forEach(kv=>{const i=kv.indexOf("=");
354
+ if(i>0) f[kv.slice(0,i)]=kv.slice(i+1)});
355
+ if(f.mid){ if(seen.has(f.mid)) return; seen.add(f.mid); }
356
+ absorb(f.from);
357
+ const tag=row.split("|")[0];
358
+ if(tag==="SAY"){ record(door,f.from,f.body);
359
+ if(!open.has("door:"+door)&&!open.has("say")) toast(door+" · "+f.body.slice(0,44)); }
360
+ else if(tag==="STROKE"){ const n=f.s.split(",").map(Number);
361
+ addStroke(door,[n[0],n[1],n[2],n[3],f.from]); }
362
+ if(!myDoors.includes(door)){ myDoors.push(door); saveDoors(); refresh("doors"); }
363
+ fan(door,row,from);
364
+ }
365
+
366
+ /* ---- handing a thing over: its own channel, closed when done ---- */
367
+ async function handOver(file){
368
+ if(!peers.size){ toast("nobody to hand it to yet"); return }
369
+ const fid=(await sha(file.name+file.size+Math.random())).slice(0,10);
370
+ for(const [pid,rec] of peers){
371
+ const ch=rec.pc.createDataChannel("give:"+fid);
372
+ ch.binaryType="arraybuffer";
373
+ ch.onopen=async()=>{
374
+ ch.send("GIVE|name="+file.name+"|size="+file.size+"|json=0");
375
+ const buf=await file.arrayBuffer(); const CH=16384;
376
+ for(let o=0;o<buf.byteLength;o+=CH){
377
+ while(ch.bufferedAmount>1<<20) await new Promise(r=>setTimeout(r,25));
378
+ ch.send(buf.slice(o,o+CH));
379
+ }
380
+ ch.send("DONE"); setTimeout(()=>ch.close(),400);
381
+ };
382
+ }
383
+ toast("handed over · "+file.name);
384
+ }
385
+ function takeGift(ch){
386
+ ch.binaryType="arraybuffer";
387
+ let name="a thing", parts=[];
388
+ ch.onmessage=e=>{
389
+ if(typeof e.data==="string"){
390
+ if(e.data.startsWith("GIVE|")){
391
+ name=(e.data.match(/name=([^|]*)/)||[])[1]||name; return; }
392
+ if(e.data==="DONE"){
393
+ const url=URL.createObjectURL(new Blob(parts));
394
+ carried.push({name,url}); refresh("carry");
395
+ toast("arrived · "+name); parts=[]; }
396
+ return;
397
+ }
398
+ parts.push(e.data);
399
+ };
400
+ }
401
+
402
+ /* =========================== the mesh =================================== */
403
+ function reduce(d){
404
+ const s=d.sdp, g=re=>{const m=s.match(re);return m?m[1]:""};
405
+ const c=[...s.matchAll(/a=candidate:(\S+ \d+ \S+ \d+ \S+ \d+ typ \S+)/g)]
406
+ .map(m=>m[1].replace(/ generation.*$/,""));
407
+ return [d.type==="offer"?"O":"A",g(/a=ice-ufrag:(\S+)/),g(/a=ice-pwd:(\S+)/),
408
+ g(/a=fingerprint:sha-256 (\S+)/).replace(/:/g,""),g(/a=setup:(\S+)/),
409
+ c.join("~")].join("|");
410
+ }
411
+ function expand(str){
412
+ const [t,uf,pw,fp,su,c]=str.trim().split("|");
413
+ const cands=(c||"").split("~").filter(Boolean)
414
+ .map(x=>"a=candidate:"+x+" generation 0").join("\r\n");
415
+ return {type:t==="O"?"offer":"answer",sdp:
416
+ "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"+
417
+ "a=extmap-allow-mixed\r\na=msid-semantic: WMS\r\n"+
418
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\n"+
419
+ (cands?cands+"\r\n":"")+"a=ice-ufrag:"+uf+"\r\na=ice-pwd:"+pw+"\r\n"+
420
+ "a=ice-options:trickle\r\na=fingerprint:sha-256 "+fp.match(/.{2}/g).join(":")+
421
+ "\r\na=setup:"+su+"\r\na=mid:0\r\na=sctp-port:5000\r\n"+
422
+ "a=max-message-size:262144\r\n"};
423
+ }
424
+ const gather=pc=>new Promise(r=>{
425
+ if(pc.iceGatheringState==="complete") return r();
426
+ pc.onicecandidate=e=>{ if(!e.candidate) r() }; setTimeout(r,3000); });
427
+ const send=(c,t)=>{try{if(c&&c.readyState==="open")c.send(t)}catch(e){}};
428
+
429
+ function greet(pc,ctl){
430
+ const hi=()=>send(ctl,"IAM|id="+MYID+"|json=0");
431
+ ctl.onopen=hi; if(ctl.readyState==="open") hi();
432
+ ctl.onmessage=e=>{ const m=e.data.match(/^IAM\|id=([0-9a-f]+)/);
433
+ if(!m) return;
434
+ /* the same colour on both ends is not two boats — it is one machine
435
+ talking to itself (a second tab of the same browser). Say so. */
436
+ if(m[1]===MYID){ toast("that is this same browser — same colour, same boat");
437
+ try{pc.close()}catch(e){} return; }
438
+ if(!peers.has(m[1])) attach(m[1],pc,ctl); };
439
+ pc.ondatachannel=e=>{ if(e.channel.label.startsWith("give:")) takeGift(e.channel) };
440
+ }
441
+ function attach(id,pc,ctl){
442
+ const rec={pc,ctl,doors:new Map()}; peers.set(id,rec);
443
+ absorb(id); spend();
444
+ ctl.onmessage=e=>onCtl(id,e.data);
445
+ ctl.onclose=()=>{peers.delete(id);count();refresh("people");
446
+ toast(id.slice(0,6)+" unlinked · colour kept")};
447
+ pc.ondatachannel=e=>{
448
+ const L=e.channel.label;
449
+ if(L.startsWith("door:")){ const d=L.slice(5);
450
+ e.channel.onmessage=ev=>onDoor(id,d,ev.data); rec.doors.set(d,e.channel);
451
+ if(!myDoors.includes(d)){myDoors.push(d);saveDoors();refresh("doors")} }
452
+ else if(L.startsWith("give:")) takeGift(e.channel);
453
+ };
454
+ for(const d of myDoors) fan(d,"OPEN|from="+MYID+"|json=0");
455
+ count(); refresh("people"); toast(id.slice(0,6)+" · colour absorbed");
456
+ const others=[...peers.keys()].filter(x=>x!==id);
457
+ if(others.length) send(ctl,"HELLO|id="+MYID+"|peers="+others.join(",")+"|json=0");
458
+ }
459
+ function spend(){ const p=open.get("open");
460
+ if(p){ const a=p.querySelector("#mine"),b=p.querySelector("#theirs");
461
+ if(a)a.value=""; if(b)b.value=""; if(p._spent)p._spent(); } }
462
+ const count=()=>{ $("#mypeers").textContent=peers.size };
463
+
464
+ async function onCtl(from,row){
465
+ const f={}; row.split("|").forEach(kv=>{const i=kv.indexOf("=");
466
+ if(i>0) f[kv.slice(0,i)]=kv.slice(i+1)});
467
+ const tag=row.split("|")[0];
468
+ if(tag==="HELLO"){
469
+ for(const p of (f.peers||"").split(",").filter(Boolean)){
470
+ if(p===MYID||peers.has(p)||pending.has(p)) continue;
471
+ const pc=new RTCPeerConnection(ICE);
472
+ greet(pc,pc.createDataChannel("ctl"));
473
+ await pc.setLocalDescription(await pc.createOffer()); await gather(pc);
474
+ pending.set(p,pc);
475
+ send(peers.get(from).ctl,"INTRO|to="+p+"|from="+MYID+"|sdp="+
476
+ reduce(pc.localDescription)+"|json=0");
477
+ }
478
+ } else if(tag==="INTRO"){
479
+ if(f.to===MYID){
480
+ const pc=new RTCPeerConnection(ICE);
481
+ pc.ondatachannel=e=>{ if(e.channel.label==="ctl") greet(pc,e.channel) };
482
+ await pc.setRemoteDescription(expand(f.sdp));
483
+ await pc.setLocalDescription(await pc.createAnswer()); await gather(pc);
484
+ send(peers.get(from).ctl,"BACK|to="+f.from+"|from="+MYID+"|sdp="+
485
+ reduce(pc.localDescription)+"|json=0");
486
+ } else if(peers.has(f.to)) send(peers.get(f.to).ctl,row);
487
+ } else if(tag==="BACK"){
488
+ if(f.to===MYID&&pending.has(f.from)){
489
+ await pending.get(f.from).setRemoteDescription(expand(f.sdp));
490
+ pending.delete(f.from);
491
+ } else if(peers.has(f.to)) send(peers.get(f.to).ctl,row);
492
+ }
493
+ }
494
+
495
+ /* =========================== the opening ================================ */
496
+ function openPane(){
497
+ pane("open","the first meeting — by hand, once",(b,p)=>{
498
+ b.innerHTML=
499
+ "<p>Give <b>one</b> person your block. After that, everyone else arrives "+
500
+ "through the links that already exist.</p>"+
501
+ '<h4 id=lbl>yours — hand it over</h4><textarea id=mine readonly></textarea>'+
502
+ '<button class="btn g" id=copy>copy</button>'+
503
+ '<button class="btn g" id=mint>mint a fresh one</button>'+
504
+ '<h4>theirs — paste it</h4><textarea id=theirs></textarea>'+
505
+ '<button class="btn" id=take>take it</button>'+
506
+ '<div class=row style="margin-top:8px"><span class=k>traces kept</span>'+
507
+ '<span class=ok id=nt>0</span></div>';
508
+ p._spent=()=>{ b.querySelector("#nt").textContent="0";
509
+ b.querySelector("#lbl").textContent="spent — mint a fresh one for the next person"; };
510
+ b.querySelector("#copy").onclick=()=>{
511
+ b.querySelector("#mine").select(); document.execCommand("copy"); toast("copied"); };
512
+ b.querySelector("#mint").onclick=()=>mint(b);
513
+ b.querySelector("#take").onclick=()=>take(b);
514
+ mint(b);
515
+ },370,430);
516
+ }
517
+ async function mint(b){
518
+ isHost=true;
519
+ bootPc=new RTCPeerConnection(ICE);
520
+ greet(bootPc,bootPc.createDataChannel("ctl"));
521
+ await bootPc.setLocalDescription(await bootPc.createOffer());
522
+ await gather(bootPc);
523
+ const small=reduce(bootPc.localDescription);
524
+ b.querySelector("#mine").value=small;
525
+ b.querySelector("#nt").textContent="1";
526
+ b.querySelector("#lbl").textContent="yours — one use, for one person";
527
+ if(!MYID) await name(small);
528
+ }
529
+ async function take(b){
530
+ const raw=b.querySelector("#theirs").value.trim(); if(!raw) return;
531
+ let d; try{ d=expand(raw) }catch(e){ toast("that isn't a block"); return }
532
+ if(d.type==="answer"&&isHost&&bootPc){
533
+ try{ await bootPc.setRemoteDescription(d); toast("taken · opening") }
534
+ catch(e){ toast("that block was for a different opening") }
535
+ return;
536
+ }
537
+ const pc=new RTCPeerConnection(ICE);
538
+ pc.ondatachannel=e=>{ if(e.channel.label==="ctl") greet(pc,e.channel) };
539
+ await pc.setRemoteDescription(d);
540
+ await pc.setLocalDescription(await pc.createAnswer()); await gather(pc);
541
+ const small=reduce(pc.localDescription);
542
+ bootPc=pc; isHost=false;
543
+ if(!MYID) await name(small);
544
+ b.querySelector("#mine").value=small;
545
+ b.querySelector("#nt").textContent="1";
546
+ b.querySelector("#lbl").textContent="your answer — send it back to them";
547
+ toast("answer ready — send it back");
548
+ }
549
+ async function name(seed){
550
+ MYID=(await sha(seed)).slice(0,16);
551
+ $("#myid").textContent=MYID.slice(0,10);
552
+ $("#mykey").style.background=grad(MYID);
553
+ }
554
+
555
+ /* =========================== the field ================================== */
556
+ const sc=$("#stars"), sg=sc.getContext("2d");
557
+ let SW=0,SH=0,T=0;
558
+ function fitStars(){ SW=sc.width=innerWidth*2; SH=sc.height=innerHeight*2 }
559
+ addEventListener("resize",()=>{fitStars();for(const p of open.values())p._fit&&p._fit()});
560
+ fitStars();
561
+ function field(){
562
+ T+=0.006;
563
+ sg.clearRect(0,0,SW,SH);
564
+ const ids=[MYID,...peers.keys(),...held].filter(Boolean);
565
+ const uniq=[...new Set(ids)];
566
+ const cx=SW/2, cy=SH*0.42, R=Math.min(SW,SH)*0.34;
567
+ uniq.forEach((id,i)=>{
568
+ const a=T+i*(2*Math.PI/Math.max(1,uniq.length));
569
+ const live=id===MYID||peers.has(id);
570
+ const x=cx+Math.cos(a)*R*(live?1:1.35);
571
+ const y=cy+Math.sin(a)*R*0.5*(live?1:1.35);
572
+ const [ca,cb]=stops(id), r=id===MYID?26:live?18:11;
573
+ const g=sg.createRadialGradient(x,y,0,x,y,r*3);
574
+ g.addColorStop(0,"rgba("+ca+","+(live?.5:.2)+")");
575
+ g.addColorStop(1,"rgba(0,0,0,0)");
576
+ sg.fillStyle=g; sg.beginPath(); sg.arc(x,y,r*3,0,6.2832); sg.fill();
577
+ sg.globalAlpha=live?.85:.34; sg.fillStyle="rgb("+cb+")";
578
+ sg.beginPath(); sg.arc(x,y,r,0,6.2832); sg.fill(); sg.globalAlpha=1;
579
+ if(live&&id!==MYID){ sg.strokeStyle="rgba("+cb+",.22)"; sg.lineWidth=2;
580
+ sg.beginPath(); sg.moveTo(cx,cy); sg.lineTo(x,y); sg.stroke(); }
581
+ });
582
+ requestAnimationFrame(field);
583
+ }
584
+ requestAnimationFrame(field);
585
+
586
+ /* =========================== wiring ===================================== */
587
+ $("#d-open").onclick=openPane;
588
+ document.querySelectorAll("#sill button[data-door]").forEach(btn=>{
589
+ btn.onclick=()=>{ const d=btn.dataset.door;
590
+ if(open.has(d)){ open.get(d).remove(); open.delete(d); lit(); return; }
591
+ pane(d,d==="what"?"what is this":d,DOORS[d],
592
+ d==="paint"?420:340, d==="what"?420:320); };
593
+ });
594
+ (async()=>{ /* a colour before anyone arrives, from this browser alone */
595
+ let s=null; try{s=localStorage.getItem("ld.seed")}catch(e){}
596
+ if(!s){ s=[...crypto.getRandomValues(new Uint8Array(24))]
597
+ .map(x=>x.toString(16).padStart(2,"0")).join("");
598
+ try{localStorage.setItem("ld.seed",s)}catch(e){} }
599
+ await name(s);
600
+ for(const d of myDoors) if(!doors.has(d)) doors.set(d,[]);
601
+ pane("what","what is this",DOORS.what,360,420);
602
+ toast("your colour is yours — nobody handed it to you");
603
+ })();
604
+ </script>
mesh.html ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <meta charset="utf-8">
2
+ <meta name="viewport" content="width=device-width,initial-scale=1">
3
+ <title>The light mesh — you keep the colour, not the trace</title>
4
+ <style>
5
+ :root{--bg:#0a0812;--fg:#efeaf2;--dim:#8d8496;--line:#221c2c;--gold:#e8c07a;
6
+ --ok:#7fe0a0;--no:#ff8b96}
7
+ *{box-sizing:border-box}html,body{height:100%}
8
+ body{margin:0;background:radial-gradient(70% 55% at 50% 0%,#171029 0%,#0a0812 62%);
9
+ color:var(--fg);font:14px/1.6 ui-monospace,"SF Mono",Menlo,Consolas,monospace}
10
+ main{max-width:740px;margin:0 auto;padding:24px 18px 80px}
11
+ h1{font:600 22px/1.3 ui-serif,Georgia,serif;margin:0 0 4px}
12
+ .sub{color:var(--dim);margin:0 0 18px;font-size:13px}
13
+ button{background:var(--gold);color:#120d18;border:0;border-radius:11px;
14
+ padding:14px 24px;font:700 14px ui-monospace,monospace;cursor:pointer;
15
+ margin:4px 6px 4px 0}
16
+ button.ghost{background:transparent;color:var(--fg);border:1px solid var(--line)}
17
+ button:disabled{opacity:.4;cursor:default}
18
+ h2{font-size:11px;letter-spacing:1.5px;text-transform:uppercase;color:var(--dim);
19
+ border-top:1px solid var(--line);padding-top:13px;margin:22px 0 9px;font-weight:400}
20
+ .row{display:flex;justify-content:space-between;gap:12px;padding:2px 0;font-size:13px}
21
+ .k{color:var(--dim)} .ok{color:var(--ok)} .no{color:var(--no)} .acc{color:var(--gold)}
22
+ textarea{width:100%;min-height:68px;background:rgba(255,255,255,.03);color:var(--fg);
23
+ border:1px solid var(--line);border-radius:10px;padding:10px;
24
+ font:11px/1.45 ui-monospace,monospace}
25
+ canvas{width:100%;height:230px;display:block;border:1px solid var(--line);
26
+ border-radius:11px;background:#07050e;margin:10px 0}
27
+ #log{background:rgba(255,255,255,.02);border:1px solid var(--line);border-radius:10px;
28
+ padding:10px;height:170px;overflow:auto;white-space:pre-wrap;font-size:11px;
29
+ color:#c6bdd0}
30
+ input{width:100%;background:rgba(255,255,255,.03);color:var(--fg);
31
+ border:1px solid var(--line);border-radius:10px;padding:10px;
32
+ font:13px ui-monospace,monospace;margin-top:8px}
33
+ #held{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0}
34
+ .chip{height:20px;width:52px;border-radius:5px;border:1px solid var(--line)}
35
+ p{color:#cdc4d6} b{color:#fff} small{color:var(--dim)}
36
+ </style>
37
+
38
+ <main>
39
+ <h1>The light mesh</h1>
40
+ <p class="sub">Join by knowing <b>one</b>. Keep the <b>colour</b>, drop the trace.
41
+ No server, no directory, no list of who exists.</p>
42
+
43
+ <div>
44
+ <button id="open">◉&nbsp; OPEN</button>
45
+ <button id="join" class="ghost">join someone's</button>
46
+ </div>
47
+
48
+ <h2>you</h2>
49
+ <div class="row"><span class="k">your colour</span><span id="me" class="acc">—</span></div>
50
+ <div class="row"><span class="k">boats connected</span><span id="np" class="ok">0</span></div>
51
+ <div class="row"><span class="k">colours held</span><span id="nh" class="acc">0</span></div>
52
+ <div class="row"><span class="k">rooms open</span><span id="nr">0</span></div>
53
+ <div class="row"><span class="k">introductions</span><span id="ni">0</span></div>
54
+ <div class="row"><span class="k">traces kept</span><span id="nt" class="ok">0</span></div>
55
+ <div class="row"><span class="k">server in the path</span><span class="ok">none</span></div>
56
+ <div id="held"></div>
57
+
58
+ <canvas id="cv"></canvas>
59
+
60
+ <div id="stage" style="display:none">
61
+ <h2 id="lbl">your trace — give it to one person</h2>
62
+ <textarea id="mine" readonly></textarea>
63
+ <button id="copy" class="ghost">copy</button>
64
+ <h2>theirs — paste here</h2>
65
+ <textarea id="theirs" placeholder="paste their trace"></textarea>
66
+ <button id="take" class="ghost">take it</button>
67
+ <button id="mint" class="ghost">new trace for somebody else</button>
68
+ </div>
69
+
70
+ <h2>the rooms</h2>
71
+ <input id="room" value="light" placeholder="room name — a label, not a port">
72
+ <div id="log">nothing yet.</div>
73
+ <input id="msg" placeholder="say something in this room" disabled>
74
+
75
+ <h2>tracking and addressing are different things</h2>
76
+ <p><b>Tracking</b> is keeping a record of where something is. <b>Addressing</b> is
77
+ knowing what it is. You only need the first when the name was handed out by
78
+ somebody — when the name <i>is</i> the thing, there is nothing to look up.</p>
79
+
80
+ <h2>the trace, and the colour</h2>
81
+ <p>The <b>trace</b> is the block that passes at the meeting. It is a <b>one-time
82
+ introduction</b> and it expires the moment it is used. <b>This page throws it away
83
+ on contact</b> — the counter above stays at zero on purpose.</p>
84
+ <p>The <b>colour</b> is what you keep. Absorb it and it is yours: no contact list,
85
+ no directory, no entry to delete. <b>Your network is not a list of who you know —
86
+ it is the set of colours you carry.</b></p>
87
+ <p><b>And the colour outlives the connection.</b> If a link drops nobody is lost;
88
+ you still hold their colour, and any boat that also holds it can put you back
89
+ together. Break every link at once and it reassembles, because what survived was
90
+ never the wiring.</p>
91
+
92
+ <h2>all the boats are the same boat</h2>
93
+ <p>Every one runs this identical page. There is no host version and no client
94
+ version. The only thing that differs is the colour, and the colour is the sha-256
95
+ of its own block — <b>two brown stops, banded 16…240, luminance-clamped
96
+ 32…224.</b></p>
97
+ <p>Photons are the same way, and it is measured rather than assumed: two identical
98
+ photons meeting a beamsplitter always leave together, never one each way. <b>If you
99
+ could tell them apart by anything at all, the effect disappears.</b> No serial
100
+ number, no registry, nowhere to put one. If two boats ever produced the same block
101
+ they would <i>be</i> the same boat.</p>
102
+ <p><small>The first link is still made by hand. Everything after it travels the
103
+ links that already exist.</small></p>
104
+ </main>
105
+
106
+ <script>
107
+ /* ---------------------------------------------------------------------------
108
+ Keep the colour, drop the trace. A boat names itself; nobody assigns.
109
+ --------------------------------------------------------------------------- */
110
+ const ICE={iceServers:[{urls:["stun:stun.l.google.com:19302",
111
+ "stun:stun1.l.google.com:19302"]}]};
112
+ const $=id=>document.getElementById(id);
113
+ const peers=new Map(); /* colour -> {pc, ctl, rooms} */
114
+ const seen=new Set(); /* message ids already passed on */
115
+ const pending=new Map(); /* colour -> pc awaiting an answer */
116
+ let MYID=null, intros=0, traces=0, isHost=false, bootPc=null;
117
+
118
+ /* the colours you carry, and they survive a reload */
119
+ let held=new Set();
120
+ try{ held=new Set(JSON.parse(localStorage.getItem("mesh.colours")||"[]")); }catch(e){}
121
+ const absorb=c=>{ if(!c||c===MYID) return; held.add(c);
122
+ try{ localStorage.setItem("mesh.colours",JSON.stringify([...held])); }catch(e){}
123
+ paintHeld(); };
124
+
125
+ const log=t=>{const l=$("log");
126
+ if(l.textContent==="nothing yet.")l.textContent="";
127
+ l.textContent+=t+"\n"; l.scrollTop=l.scrollHeight;};
128
+
129
+ async function sha(s){
130
+ const d=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(s));
131
+ return [...new Uint8Array(d)].map(x=>x.toString(16).padStart(2,"0")).join("");
132
+ }
133
+ const cl=(v,a,b)=>Math.max(a,Math.min(b,v));
134
+ function stops(h){
135
+ const p=i=>cl(parseInt((h+"000000000000").substr(i,2),16)||16,16,240);
136
+ const lum=c=>0.2126*c[0]+0.7152*c[1]+0.0722*c[2];
137
+ const fix=c=>{const L=lum(c);return (L>=32&&L<=224)?c:
138
+ c.map(x=>Math.round(cl(x*128/Math.max(1,L),0,255)));};
139
+ return [fix([p(0),p(2),p(4)]),fix([p(6),p(8),p(10)])];
140
+ }
141
+ function paintHeld(){
142
+ $("nh").textContent=held.size;
143
+ $("held").innerHTML=[...held].map(c=>{const [a,b]=stops(c);
144
+ return "<div class=chip title='"+c+"' style=\"background:linear-gradient("+
145
+ "90deg,rgb("+a+"),rgb("+b+"))\"></div>";}).join("");
146
+ }
147
+ const stat=()=>{ $("np").textContent=peers.size; $("ni").textContent=intros;
148
+ $("nt").textContent=traces;
149
+ const rs=new Set(); for(const p of peers.values()) for(const k of p.rooms.keys()) rs.add(k);
150
+ $("nr").textContent=rs.size; };
151
+
152
+ /* ---- reduction ---- */
153
+ function reduce(d){
154
+ const s=d.sdp, g=re=>{const m=s.match(re);return m?m[1]:"";};
155
+ const c=[...s.matchAll(/a=candidate:(\S+ \d+ \S+ \d+ \S+ \d+ typ \S+)/g)]
156
+ .map(m=>m[1].replace(/ generation.*$/,""));
157
+ return [d.type==="offer"?"O":"A",g(/a=ice-ufrag:(\S+)/),g(/a=ice-pwd:(\S+)/),
158
+ g(/a=fingerprint:sha-256 (\S+)/).replace(/:/g,""),g(/a=setup:(\S+)/),
159
+ c.join("~")].join("|");
160
+ }
161
+ function expand(str){
162
+ const [t,uf,pw,fp,su,c]=str.trim().split("|");
163
+ const cands=(c||"").split("~").filter(Boolean)
164
+ .map(x=>"a=candidate:"+x+" generation 0").join("\r\n");
165
+ return {type:t==="O"?"offer":"answer", sdp:
166
+ "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"+
167
+ "a=extmap-allow-mixed\r\na=msid-semantic: WMS\r\n"+
168
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\n"+
169
+ (cands?cands+"\r\n":"")+"a=ice-ufrag:"+uf+"\r\na=ice-pwd:"+pw+"\r\n"+
170
+ "a=ice-options:trickle\r\na=fingerprint:sha-256 "+fp.match(/.{2}/g).join(":")+
171
+ "\r\na=setup:"+su+"\r\na=mid:0\r\na=sctp-port:5000\r\n"+
172
+ "a=max-message-size:262144\r\n"};
173
+ }
174
+ const gather=pc=>new Promise(r=>{
175
+ if(pc.iceGatheringState==="complete") return r();
176
+ pc.onicecandidate=e=>{ if(!e.candidate) r(); };
177
+ setTimeout(r,3000);
178
+ });
179
+ const send=(c,t)=>{ try{ if(c&&c.readyState==="open") c.send(t); }catch(e){} };
180
+
181
+ /* ---- a boat names itself; nothing is registered until it does ---- */
182
+ function greet(pc,ctl){
183
+ const hello=()=>send(ctl,"IAM|id="+MYID+"|json=0");
184
+ ctl.onopen=hello; if(ctl.readyState==="open") hello();
185
+ ctl.onmessage=e=>{
186
+ const m=e.data.match(/^IAM\|id=([0-9a-f]+)/);
187
+ if(m && !peers.has(m[1])) attach(m[1],pc,ctl);
188
+ };
189
+ }
190
+ function attach(id,pc,ctl){
191
+ const rec={pc,ctl,rooms:new Map()};
192
+ peers.set(id,rec);
193
+ absorb(id); /* the colour is kept */
194
+ dropTraces(); /* the trace is not */
195
+ ctl.onmessage=e=>onCtl(id,e.data);
196
+ ctl.onclose=()=>{ peers.delete(id); stat();
197
+ log("— "+id.slice(0,8)+" unlinked (colour kept) —"); };
198
+ pc.ondatachannel=e=>{
199
+ if(e.channel.label.startsWith("room:")){
200
+ const nm=e.channel.label.slice(5);
201
+ e.channel.onmessage=ev=>onRoom(id,nm,ev.data);
202
+ rec.rooms.set(nm,e.channel); stat();
203
+ }
204
+ };
205
+ openRoom(id,$("room").value.trim()||"light");
206
+ stat(); log("+ "+id.slice(0,8)+" — colour absorbed");
207
+ const others=[...peers.keys()].filter(x=>x!==id);
208
+ if(others.length) send(ctl,"HELLO|id="+MYID+"|peers="+others.join(",")+"|json=0");
209
+ }
210
+ function dropTraces(){
211
+ $("mine").value=""; $("theirs").value=""; traces=0; stat();
212
+ }
213
+ function openRoom(pid,name){
214
+ const rec=peers.get(pid); if(!rec||rec.rooms.has(name)) return;
215
+ const c=rec.pc.createDataChannel("room:"+name);
216
+ c.onmessage=e=>onRoom(pid,name,e.data);
217
+ rec.rooms.set(name,c); stat();
218
+ }
219
+
220
+ /* ---- introductions travel the links that exist ---- */
221
+ async function onCtl(from,row){
222
+ const f={}; row.split("|").forEach(kv=>{const i=kv.indexOf("=");
223
+ if(i>0) f[kv.slice(0,i)]=kv.slice(i+1);});
224
+ const tag=row.split("|")[0];
225
+
226
+ if(tag==="HELLO"){
227
+ for(const p of (f.peers||"").split(",").filter(Boolean)){
228
+ if(p===MYID||peers.has(p)||pending.has(p)) continue;
229
+ const pc=new RTCPeerConnection(ICE);
230
+ const ctl=pc.createDataChannel("ctl");
231
+ greet(pc,ctl);
232
+ await pc.setLocalDescription(await pc.createOffer());
233
+ await gather(pc);
234
+ pending.set(p,pc);
235
+ send(peers.get(from).ctl,
236
+ "INTRO|to="+p+"|from="+MYID+"|sdp="+reduce(pc.localDescription)+"|json=0");
237
+ intros++; stat(); log("→ asking for an introduction to "+p.slice(0,8));
238
+ }
239
+ }
240
+ else if(tag==="INTRO"){
241
+ if(f.to===MYID){
242
+ const pc=new RTCPeerConnection(ICE);
243
+ pc.ondatachannel=e=>{ if(e.channel.label==="ctl") greet(pc,e.channel); };
244
+ await pc.setRemoteDescription(expand(f.sdp));
245
+ await pc.setLocalDescription(await pc.createAnswer());
246
+ await gather(pc);
247
+ send(peers.get(from).ctl,
248
+ "BACK|to="+f.from+"|from="+MYID+"|sdp="+reduce(pc.localDescription)+"|json=0");
249
+ intros++; stat(); log("← introduced to "+f.from.slice(0,8));
250
+ } else if(peers.has(f.to)) send(peers.get(f.to).ctl,row);
251
+ }
252
+ else if(tag==="BACK"){
253
+ if(f.to===MYID && pending.has(f.from)){
254
+ const pc=pending.get(f.from);
255
+ await pc.setRemoteDescription(expand(f.sdp));
256
+ pending.delete(f.from);
257
+ } else if(peers.has(f.to)) send(peers.get(f.to).ctl,row);
258
+ }
259
+ }
260
+
261
+ /* ---- rooms ---- */
262
+ function onRoom(from,name,row){
263
+ const f={}; row.split("|").forEach(kv=>{const i=kv.indexOf("=");
264
+ if(i>0) f[kv.slice(0,i)]=kv.slice(i+1);});
265
+ if(!f.mid || seen.has(f.mid)) return;
266
+ seen.add(f.mid); absorb(f.from);
267
+ if(name===($("room").value.trim()||"light"))
268
+ log(f.from.slice(0,8)+" · "+name+" · "+f.body);
269
+ for(const [pid,rec] of peers){ if(pid===from) continue;
270
+ const c=rec.rooms.get(name); if(c) send(c,row); }
271
+ pulse(from);
272
+ }
273
+ async function say(text){
274
+ const name=$("room").value.trim()||"light";
275
+ const mid=(await sha(MYID+name+text+Object.keys(seen).length+text.length)).slice(0,8);
276
+ seen.add(mid);
277
+ const row="MSG|room="+name+"|from="+MYID+"|mid="+mid+"|body="+text+"|json=0";
278
+ for(const [pid,rec] of peers){ openRoom(pid,name); send(rec.rooms.get(name),row); }
279
+ log("you · "+name+" · "+text);
280
+ }
281
+
282
+ /* ---- drawing ---- */
283
+ const cv=$("cv"), g=cv.getContext("2d"); let W=0,H=0,t=0,pulses=[];
284
+ function fit(){ const r=cv.getBoundingClientRect();
285
+ W=cv.width=Math.max(2,r.width*2); H=cv.height=Math.max(2,r.height*2); }
286
+ addEventListener("resize",fit); setTimeout(fit,80);
287
+ const pulse=id=>pulses.push({id,t:0});
288
+ function frame(){
289
+ t+=0.016;
290
+ g.fillStyle="#07050e"; g.fillRect(0,0,W,H);
291
+ const ids=[MYID,...peers.keys()].filter(Boolean);
292
+ const n=Math.max(1,ids.length), R=Math.min(W,H)*0.32, cx=W/2, cy=H/2, pos={};
293
+ ids.forEach((id,i)=>{
294
+ if(i===0){ pos[id]=[cx,cy]; return; }
295
+ const a=t*0.15+(i-1)*(2*Math.PI/Math.max(1,n-1));
296
+ pos[id]=[cx+Math.cos(a)*R, cy+Math.sin(a)*R];
297
+ });
298
+ g.lineWidth=2;
299
+ for(const id of ids.slice(1)){
300
+ const [,b]=stops(id);
301
+ const gr=g.createLinearGradient(cx,cy,pos[id][0],pos[id][1]);
302
+ gr.addColorStop(0,"rgba(255,255,255,.10)"); gr.addColorStop(1,"rgb("+b+")");
303
+ g.strokeStyle=gr; g.globalAlpha=.55;
304
+ g.beginPath(); g.moveTo(cx,cy); g.lineTo(...pos[id]); g.stroke();
305
+ }
306
+ g.globalAlpha=1;
307
+ for(const id of ids){
308
+ const [a,b]=stops(id), p=pos[id], r=id===MYID?16:12;
309
+ const gr=g.createRadialGradient(p[0],p[1],0,p[0],p[1],r*2.6);
310
+ gr.addColorStop(0,"rgb("+a+")"); gr.addColorStop(1,"rgba(0,0,0,0)");
311
+ g.globalAlpha=.6; g.fillStyle=gr;
312
+ g.beginPath(); g.arc(p[0],p[1],r*2.6,0,6.2832); g.fill();
313
+ g.globalAlpha=1; g.fillStyle="rgb("+b+")";
314
+ g.beginPath(); g.arc(p[0],p[1],r,0,6.2832); g.fill();
315
+ g.fillStyle="rgba(240,235,245,.8)"; g.font="18px ui-monospace,monospace";
316
+ g.textAlign="center"; g.fillText(id.slice(0,6), p[0], p[1]+r+22);
317
+ }
318
+ pulses=pulses.filter(p=>{ p.t+=0.03; return p.t<1; });
319
+ for(const p of pulses){ const q=pos[p.id]; if(!q) continue;
320
+ g.strokeStyle="rgba(232,192,122,"+(1-p.t)+")"; g.lineWidth=2;
321
+ g.beginPath(); g.arc(q[0],q[1],12+p.t*40,0,6.2832); g.stroke(); }
322
+ if(ids.length<=1){
323
+ g.fillStyle="rgba(141,132,150,.8)"; g.font="20px ui-monospace,monospace";
324
+ g.textAlign="center"; g.fillText("one boat. give your trace to somebody.",cx,cy+90); }
325
+ requestAnimationFrame(frame);
326
+ }
327
+ requestAnimationFrame(frame);
328
+
329
+ /* ---- the one link made by hand ---- */
330
+ async function emit(desc){
331
+ const small=reduce(desc);
332
+ $("mine").value=small; traces=1; stat();
333
+ const h=await sha(small);
334
+ if(!MYID){ MYID=h.slice(0,16); $("me").textContent=MYID; }
335
+ }
336
+ $("open").onclick=async()=>{
337
+ isHost=true; $("open").disabled=true; $("join").disabled=true;
338
+ $("stage").style.display="block";
339
+ bootPc=new RTCPeerConnection(ICE);
340
+ greet(bootPc,bootPc.createDataChannel("ctl"));
341
+ await bootPc.setLocalDescription(await bootPc.createOffer());
342
+ await gather(bootPc);
343
+ await emit(bootPc.localDescription);
344
+ log("give that trace to one person.");
345
+ };
346
+ $("join").onclick=()=>{
347
+ isHost=false; $("open").disabled=true; $("join").disabled=true;
348
+ $("stage").style.display="block";
349
+ $("lbl").textContent="your trace — appears after you take theirs";
350
+ log("paste their trace, then take it.");
351
+ };
352
+ $("take").onclick=async()=>{
353
+ const raw=$("theirs").value.trim(); if(!raw) return;
354
+ let d; try{ d=expand(raw); }catch(e){ log("not a trace."); return; }
355
+ if(isHost){ await bootPc.setRemoteDescription(d); log("taken. opening…"); }
356
+ else{
357
+ bootPc=new RTCPeerConnection(ICE);
358
+ bootPc.ondatachannel=e=>{ if(e.channel.label==="ctl") greet(bootPc,e.channel); };
359
+ await bootPc.setRemoteDescription(d);
360
+ await bootPc.setLocalDescription(await bootPc.createAnswer());
361
+ await gather(bootPc);
362
+ await emit(bootPc.localDescription);
363
+ log("your trace is above. send it back.");
364
+ }
365
+ };
366
+ $("mint").onclick=async()=>{
367
+ isHost=true;
368
+ bootPc=new RTCPeerConnection(ICE);
369
+ greet(bootPc,bootPc.createDataChannel("ctl"));
370
+ await bootPc.setLocalDescription(await bootPc.createOffer());
371
+ await gather(bootPc);
372
+ const small=reduce(bootPc.localDescription);
373
+ $("mine").value=small; traces=1; stat();
374
+ $("lbl").textContent="a fresh trace — one use, for one person";
375
+ log("new trace minted. the old one is spent.");
376
+ };
377
+ $("copy").onclick=()=>{ $("mine").select(); document.execCommand("copy"); };
378
+ $("msg").onkeydown=e=>{ if(e.key!=="Enter") return;
379
+ const v=e.target.value.trim(); if(!v||!peers.size) return;
380
+ say(v); e.target.value=""; };
381
+ setInterval(()=>{ $("msg").disabled = peers.size===0; },600);
382
+ paintHeld(); stat();
383
+ </script>