@dtudury/streamo 4.0.2 → 4.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/index.html CHANGED
@@ -4,18 +4,60 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>streamo</title>
7
+ <link rel="icon" type="image/svg+xml" href="/streamo.svg">
7
8
  <link rel="stylesheet" href="/apps/styles/proto.css">
8
9
  <style>
10
+ html { scrollbar-gutter: stable; }
9
11
  body { max-width: 44rem; margin: 0 auto; padding: 2.5rem 1.25rem; }
10
12
 
11
- .wordmark { font-size: 2.4rem; letter-spacing: -0.02em; margin-bottom: 0.15rem; }
12
- .tagline { color: var(--ink-dim); font-size: 0.95rem; margin-bottom: 2rem; }
13
+ .wordmark {
14
+ display: inline-flex;
15
+ align-items: center;
16
+ gap: 0.6rem;
17
+ font-size: 2.4rem;
18
+ letter-spacing: -0.02em;
19
+ margin-bottom: 0.15rem;
20
+ text-decoration: none;
21
+ color: inherit;
22
+ }
23
+ .wordmark:hover { opacity: 0.85; }
24
+ .wordmark img { width: 2.6rem; height: 2.6rem; }
25
+ .tagline { color: var(--ink-dim); font-size: 0.95rem; margin-bottom: 0.4rem; }
26
+
27
+ /* "what's running here" line — small, clickable, places the abstract
28
+ claims in the next paragraph onto a real running server. */
29
+ .here {
30
+ font-size: 0.78rem;
31
+ color: var(--ink-dim);
32
+ margin-bottom: 2rem;
33
+ font-family: monospace;
34
+ }
35
+ .here a {
36
+ color: var(--ink-dim);
37
+ text-decoration: underline dotted;
38
+ }
39
+ .here a:hover { color: var(--ink); text-decoration-style: solid; }
40
+ .here .pulse {
41
+ display: inline-block;
42
+ width: 0.5rem;
43
+ height: 0.5rem;
44
+ border-radius: 50%;
45
+ background: #16a34a;
46
+ margin-right: 0.4rem;
47
+ vertical-align: 0.05em;
48
+ animation: pulse 2s ease-in-out infinite;
49
+ }
50
+ .here .pulse.err { background: #dc2626; animation: none; }
51
+ @keyframes pulse {
52
+ 0%, 100% { opacity: 0.4; }
53
+ 50% { opacity: 1; }
54
+ }
13
55
 
14
56
  .ideas {
15
57
  display: flex;
16
58
  flex-direction: column;
17
59
  gap: 0.6rem;
18
- margin-bottom: 2.5rem;
60
+ margin-bottom: 1.5rem;
19
61
  }
20
62
  .idea {
21
63
  display: flex;
@@ -27,6 +69,72 @@
27
69
  .idea-text { color: var(--ink-dim); }
28
70
  .idea-text strong { color: var(--ink); }
29
71
 
72
+ /* "try it" card — folds shut by default. When opened, derives a real
73
+ secp256k1 keypair from credentials in the browser. Concrete proof
74
+ of "your identity travels with you" instead of just claiming it. */
75
+ details.try-it {
76
+ border: 1.5px dashed var(--rule);
77
+ border-radius: var(--radius);
78
+ padding: 0.6rem 0.85rem;
79
+ margin-bottom: 2rem;
80
+ }
81
+ details.try-it[open] { border-color: var(--ink); border-style: solid; }
82
+ details.try-it > summary {
83
+ cursor: pointer;
84
+ font-size: 0.85rem;
85
+ color: var(--ink-dim);
86
+ }
87
+ details.try-it[open] > summary { color: var(--ink); margin-bottom: 0.65rem; }
88
+ details.try-it > summary::before {
89
+ content: '↳ ';
90
+ color: var(--ink-dim);
91
+ }
92
+ .try-it-note {
93
+ font-size: 0.78rem;
94
+ color: var(--ink-dim);
95
+ line-height: 1.5;
96
+ margin: 0 0 0.65rem;
97
+ }
98
+ .try-it-note strong { color: var(--ink); }
99
+ .try-it-form {
100
+ display: flex;
101
+ flex-wrap: wrap;
102
+ gap: 0.5rem;
103
+ margin-bottom: 0.65rem;
104
+ }
105
+ .try-it-form input {
106
+ flex: 1 1 8rem;
107
+ font-family: monospace;
108
+ font-size: 0.85rem;
109
+ padding: 0.35rem 0.55rem;
110
+ border: 1px solid var(--rule);
111
+ border-radius: var(--radius);
112
+ background: transparent;
113
+ color: var(--ink);
114
+ }
115
+ .try-it-form input:focus {
116
+ outline: none;
117
+ border-color: var(--ink);
118
+ }
119
+ .derived-key {
120
+ font-size: 0.78rem;
121
+ color: var(--ink-dim);
122
+ padding: 0.35rem 0.55rem;
123
+ border-radius: var(--radius);
124
+ background: rgba(0, 0, 0, 0.03);
125
+ word-break: break-all;
126
+ }
127
+ .derived-key .key-label {
128
+ display: block;
129
+ margin-bottom: 0.2rem;
130
+ font-family: monospace;
131
+ }
132
+ .derived-key .key-value {
133
+ font-family: monospace;
134
+ color: var(--ink);
135
+ }
136
+ .derived-key.computing .key-value { color: var(--ink-dim); font-style: italic; }
137
+
30
138
  hr { margin: 2rem 0; }
31
139
 
32
140
  .apps-heading {
@@ -44,7 +152,9 @@
44
152
  }
45
153
 
46
154
  .app-card {
47
- display: block;
155
+ display: flex;
156
+ flex-direction: column;
157
+ gap: 0.25rem;
48
158
  text-decoration: none;
49
159
  color: var(--ink);
50
160
  border: 1.5px solid var(--ink);
@@ -56,22 +166,81 @@
56
166
  .app-card:hover { transform: translate(-1px, -1px); box-shadow: 3px 4px 0 var(--ink); }
57
167
  .app-card:active { transform: translate(1px, 2px); box-shadow: none; }
58
168
 
59
- .app-name { font-size: 1rem; margin-bottom: 0.2rem; }
169
+ .app-name-row {
170
+ display: flex;
171
+ align-items: baseline;
172
+ gap: 0.45rem;
173
+ }
174
+ .app-glyph { font-size: 1.1rem; }
175
+ .app-name { font-size: 1rem; }
60
176
  .app-desc { font-size: 0.78rem; color: var(--ink-dim); line-height: 1.4; }
61
177
 
178
+ /* Journal — the home repo's `entries` array, rendered live. Each entry
179
+ is a signed commit; click "see all" to land on the relay'd repo
180
+ in the explorer. The homepage demonstrates streamo by being made
181
+ of it. */
182
+ .journal-heading {
183
+ font-size: 0.7rem;
184
+ text-transform: uppercase;
185
+ letter-spacing: 0.1em;
186
+ color: var(--ink-dim);
187
+ margin: 2.5rem 0 0.75rem;
188
+ }
189
+ .journal-entry {
190
+ padding: 0.75rem 0;
191
+ border-top: 1px solid var(--rule);
192
+ }
193
+ .journal-entry:last-child { border-bottom: 1px solid var(--rule); }
194
+ .journal-meta {
195
+ font-size: 0.7rem;
196
+ font-family: monospace;
197
+ margin-bottom: 0.25rem;
198
+ }
199
+ .journal-headline {
200
+ font-size: 0.95rem;
201
+ font-weight: 600;
202
+ margin-bottom: 0.3rem;
203
+ }
204
+ .journal-body {
205
+ font-size: 0.85rem;
206
+ line-height: 1.5;
207
+ color: var(--ink-dim);
208
+ }
209
+ .journal-more {
210
+ font-size: 0.78rem;
211
+ color: var(--ink-dim);
212
+ margin-top: 0.6rem;
213
+ display: inline-block;
214
+ text-decoration: underline dotted;
215
+ }
216
+ .journal-more:hover { color: var(--ink); text-decoration-style: solid; }
217
+
62
218
  .footer {
219
+ display: flex;
220
+ flex-wrap: wrap;
221
+ gap: 0.5rem;
63
222
  margin-top: 3rem;
64
- font-size: 0.75rem;
223
+ }
224
+ .footer-chip {
225
+ font-size: 0.78rem;
65
226
  color: var(--ink-dim);
227
+ text-decoration: none;
228
+ padding: 0.25rem 0.6rem;
229
+ border: 1px solid var(--rule);
230
+ border-radius: var(--radius);
66
231
  }
67
- .footer a { color: var(--ink-dim); }
232
+ .footer-chip:hover { color: var(--ink); border-color: var(--ink); }
68
233
  </style>
69
234
  </head>
70
235
  <body>
71
236
 
72
- <div class="wordmark">streamo</div>
237
+ <a class="wordmark" href="/" title="streamo home"><img src="/streamo.svg" alt="">streamo</a>
73
238
  <p class="tagline">every device is an equal author</p>
74
239
 
240
+ <p class="here" id="here">
241
+ <span class="pulse" id="pulse"></span><span id="here-text">connecting…</span>
242
+ </p>
243
+
75
244
  <div class="ideas">
76
245
  <div class="idea">
77
246
  <span class="idea-glyph">↔</span>
@@ -91,26 +260,171 @@
91
260
  </div>
92
261
  </div>
93
262
 
263
+ <details class="try-it">
264
+ <summary>try it — derive a streamo identity right here</summary>
265
+ <p class="try-it-note">
266
+ type any username and password. the keypair is computed in your browser via PBKDF2-SHA256 and is <strong>never sent</strong> anywhere. same inputs always produce the same key — that's how streamo identities travel without key files. <strong>don't use a real password</strong>; this is just a demo.
267
+ </p>
268
+ <div class="try-it-form">
269
+ <input type="text" placeholder="username" id="demo-username" autocomplete="off">
270
+ <input type="password" placeholder="password" id="demo-password" autocomplete="new-password">
271
+ </div>
272
+ <div class="derived-key" id="demo-key-row">
273
+ <span class="key-label">public key (secp256k1):</span>
274
+ <code class="key-value" id="demo-key">— enter credentials above —</code>
275
+ </div>
276
+ </details>
277
+
278
+ <p class="journal-heading">journal</p>
279
+ <div id="journal-entries">
280
+ <p class="dim" style="font-size: 0.85rem;">loading…</p>
281
+ </div>
282
+ <a id="journal-more" class="journal-more" style="display:none">see all entries in the explorer →</a>
283
+
94
284
  <hr>
95
285
 
96
286
  <p class="apps-heading">apps</p>
97
287
  <div class="app-grid">
98
288
  <a class="app-card" href="/apps/chat/">
99
- <div class="app-name">chat</div>
100
- <div class="app-desc">p2p messaging — the server is a relay, not a gatekeeper</div>
289
+ <div class="app-name-row">
290
+ <span class="app-glyph">💬</span>
291
+ <span class="app-name">chat</span>
292
+ </div>
293
+ <div class="app-desc">p2p messaging — each participant owns their own signed message stream</div>
101
294
  </a>
102
295
  <a class="app-card" href="/apps/explorer/">
103
- <div class="app-name">explorer</div>
104
- <div class="app-desc">browse repos, commit history, and value at any commit</div>
296
+ <div class="app-name-row">
297
+ <span class="app-glyph">🔍</span>
298
+ <span class="app-name">explorer</span>
299
+ </div>
300
+ <div class="app-desc">browse repos, commit history, and the bytes underneath</div>
105
301
  </a>
106
302
  </div>
107
303
  <p class="dim" style="margin-top: 1rem; font-size: 0.78rem;">
108
304
  open both side by side — watch commits roll in as you chat
109
305
  </p>
110
306
 
111
- <p class="footer">
112
- <a href="https://github.com/dtudury/streamo">github</a>
113
- </p>
307
+ <div class="footer">
308
+ <a class="footer-chip" href="https://github.com/dtudury/streamo">github</a>
309
+ <a class="footer-chip" href="https://github.com/dtudury/streamo/blob/main/design.md">design.md</a>
310
+ <a class="footer-chip" href="https://github.com/dtudury/streamo/blob/main/ROADMAP.md">roadmap</a>
311
+ <a class="footer-chip" href="https://www.npmjs.com/package/@dtudury/streamo">npm</a>
312
+ </div>
313
+
314
+ <script type="module">
315
+ import { Signer } from '/streamo/Signer.js'
316
+ import { bytesToHex } from '/streamo/utils.js'
317
+ import { Recaller } from '/streamo/utils/Recaller.js'
318
+ import { RepoRegistry } from '/streamo/RepoRegistry.js'
319
+ import { registrySync } from '/streamo/registrySync.js'
320
+ import { bridgeRegistry } from '/streamo/bridgeRegistry.js'
321
+ import { h } from '/streamo/h.js'
322
+ import { mount } from '/streamo/mount.js'
323
+
324
+ // Surface "what's running here" — server's primary key as a clickable
325
+ // link to the explorer. Replaces the abstract "no server holds
326
+ // authority" claim above with a concrete, navigable instance of it.
327
+ const pulseEl = document.getElementById('pulse')
328
+ const hereEl = document.getElementById('here-text')
329
+ let info = null
330
+ try {
331
+ info = await fetch('/api/info').then(r => r.json())
332
+ const truncKey = info.primaryKeyHex.slice(0, 12) + '…'
333
+ hereEl.innerHTML = `relaying <a href="/apps/explorer/#/repo/${info.primaryKeyHex}">${truncKey}</a> as "${info.name || 'this server'}"`
334
+ } catch (e) {
335
+ pulseEl.classList.add('err')
336
+ hereEl.textContent = 'not connected to a streamo server'
337
+ }
338
+
339
+ // Subscribe to the home repo and render its journal entries. The
340
+ // homepage IS made of streamo: each entry is a signed commit on
341
+ // the home repo, walked here at render time. Live — new entries
342
+ // appear without refresh.
343
+ if (info) {
344
+ try {
345
+ const registry = new RepoRegistry()
346
+ await registrySync(registry, location.hostname, +location.port || 80, {
347
+ filter: key => key === info.primaryKeyHex
348
+ })
349
+ const recaller = new Recaller('home')
350
+ const { dep } = bridgeRegistry(registry, recaller, 'home')
351
+ const entriesEl = document.getElementById('journal-entries')
352
+ const moreEl = document.getElementById('journal-more')
353
+ moreEl.href = `/apps/explorer/#/repo/${info.primaryKeyHex}`
354
+ const fmtDate = d => {
355
+ const date = d instanceof Date ? d : new Date(d)
356
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
357
+ }
358
+ // Replace the "loading…" placeholder with our reactive cell.
359
+ entriesEl.innerHTML = ''
360
+ mount(h`${() => {
361
+ dep()
362
+ const repo = registry.get(info.primaryKeyHex)
363
+ if (!repo) return h`<p class="dim" style="font-size: 0.85rem;">connecting…</p>`
364
+ const entries = repo.get('entries') ?? []
365
+ if (entries.length === 0) {
366
+ return h`<p class="dim" style="font-size: 0.85rem;">no entries yet — the deployer hasn't written anything to this repo.</p>`
367
+ }
368
+ // Newest-first; show up to 5.
369
+ const latest = entries.slice(-5).reverse()
370
+ if (entries.length > latest.length) {
371
+ moreEl.style.display = 'inline-block'
372
+ moreEl.textContent = `see all ${entries.length} entries in the explorer →`
373
+ } else {
374
+ moreEl.style.display = 'inline-block'
375
+ moreEl.textContent = `view in the explorer →`
376
+ }
377
+ return latest.map(e => h`
378
+ <div class="journal-entry">
379
+ <div class="journal-meta dim">${e.at != null ? fmtDate(e.at) : ''}</div>
380
+ <div class="journal-headline">${e.headline ?? '(no headline)'}</div>
381
+ <div class="journal-body">${e.body ?? ''}</div>
382
+ </div>
383
+ `)
384
+ }}`, entriesEl, recaller)
385
+ } catch (e) {
386
+ document.getElementById('journal-entries').innerHTML =
387
+ `<p class="dim" style="font-size: 0.85rem;">journal unavailable (${e.message})</p>`
388
+ }
389
+ }
390
+
391
+ // Derive-on-type for the try-it widget. Debounced because PBKDF2 is
392
+ // intentionally slow; we don't want to hammer the browser per keystroke.
393
+ const u = document.getElementById('demo-username')
394
+ const p = document.getElementById('demo-password')
395
+ const out = document.getElementById('demo-key')
396
+ const row = document.getElementById('demo-key-row')
397
+ let timer = null
398
+ let inFlight = 0
399
+ function update () {
400
+ clearTimeout(timer)
401
+ const username = u.value.trim()
402
+ const password = p.value
403
+ if (!username || !password) {
404
+ row.classList.remove('computing')
405
+ out.textContent = '— enter credentials above —'
406
+ return
407
+ }
408
+ row.classList.add('computing')
409
+ out.textContent = 'computing PBKDF2…'
410
+ const me = ++inFlight
411
+ timer = setTimeout(async () => {
412
+ try {
413
+ const signer = new Signer(username, password, 1)
414
+ const { publicKey } = await signer.keysFor('chat')
415
+ if (me !== inFlight) return // user kept typing; stale
416
+ row.classList.remove('computing')
417
+ out.textContent = '0x' + bytesToHex(publicKey)
418
+ } catch (e) {
419
+ if (me !== inFlight) return
420
+ row.classList.remove('computing')
421
+ out.textContent = `error: ${e.message}`
422
+ }
423
+ }, 300)
424
+ }
425
+ u.addEventListener('input', update)
426
+ p.addEventListener('input', update)
427
+ </script>
114
428
 
115
429
  </body>
116
430
  </html>
@@ -192,30 +192,17 @@ function reconcileSlot (start, end, newVNodes, recaller, ns = HTML_NS) {
192
192
  old.remove()
193
193
  }
194
194
 
195
- // Reinsert recycled elements and mount fresh ones, in order. For
196
- // recycled elements we re-mount: clean up the existing subtree's
197
- // watchers, clear inner DOM and any attributes set on the outer
198
- // element, then apply fresh attrs and mount fresh children. The
199
- // OUTER node identity is preserved (so document position and DOM
200
- // references survive), but inner state (focus, scroll, slot
201
- // anchors) is rebuilt a static `${value}` child captured at
202
- // first mount would otherwise be stale on every re-render.
203
- // Inputs that need focus preservation across re-renders should
204
- // be kept in their own data-keyed slot so reconcileSlot's matcher
205
- // recycles them in place separately from this outer rebuild.
195
+ // Reinsert recycled elements (recursively reconciled) and mount fresh
196
+ // ones, in order. Recursive reconcile preserves descendant DOM and
197
+ // watchers only attrs of the matched element are reset, children
198
+ // that match by data-key/tag are themselves reconciled in place. This
199
+ // is what lets a deeply nested data-keyed element (e.g. the byte
200
+ // strip's container) survive an outer-slot re-render with its
201
+ // scrollLeft, focus, and inner slot state intact.
206
202
  for (const vnode of newVNodes) {
207
203
  const recycled = vnodeToEl.get(vnode)
208
204
  if (recycled) {
209
- cleanupNode(recycled, recaller)
210
- while (recycled.firstChild) recycled.firstChild.remove()
211
- // Clear all attributes (snapshot the names — removeAttribute mutates the live list)
212
- const oldAttrNames = Array.from(recycled.attributes, a => a.name)
213
- for (const name of oldAttrNames) recycled.removeAttribute(name)
214
- for (const attr of vnode.attrs) {
215
- if (attr == null) continue
216
- applyAttr(recycled, attr, recaller)
217
- }
218
- mount(vnode.children, recycled, recaller, ns)
205
+ reconcileElement(recycled, vnode, recaller, ns)
219
206
  end.before(recycled)
220
207
  } else {
221
208
  const frag = document.createDocumentFragment()
@@ -225,6 +212,133 @@ function reconcileSlot (start, end, newVNodes, recaller, ns = HTML_NS) {
225
212
  }
226
213
  }
227
214
 
215
+ // ── Recursive reconcile ──────────────────────────────────────────────────
216
+ //
217
+ // reconcileElement updates a matched element's attributes and recursively
218
+ // reconciles its children — preserving descendant DOM (and any browser
219
+ // state on it: scrollLeft, focus, scroll positions, inner slot anchors)
220
+ // when the new vnode tree's structure agrees with the existing one.
221
+ //
222
+ // The element's OWN attr watchers are unwatched and re-applied (their
223
+ // closures may have changed across the outer render). Descendant
224
+ // watchers are NOT touched — only re-applied where their containing
225
+ // element gets reconciled itself, deeper in the recursion.
226
+ //
227
+ // Children that don't match a new vnode (by data-key for keyed elements,
228
+ // by tag-pool for unkeyed) are cleaned up and removed; new vnodes that
229
+ // don't match an existing child are fresh-mounted.
230
+
231
+ function reconcileElement (el, vnode, recaller, ns) {
232
+ // Determine child namespace, mirroring mountNode
233
+ const nsAttr = vnode.attrs.find(a => a?.name === 'xmlns')?.value
234
+ const elemNs = nsAttr
235
+ ?? (vnode.tag === 'svg' ? SVG_NS
236
+ : vnode.tag === 'foreignObject' ? HTML_NS
237
+ : ns)
238
+ // Snapshot scrollLeft/scrollTop so a hypothetical reflow during
239
+ // child reconcile doesn't lose the user's scroll position.
240
+ const scrollLeft = el.scrollLeft
241
+ const scrollTop = el.scrollTop
242
+ reconcileAttrs(el, vnode, recaller)
243
+ reconcileChildren(el, vnode.children, recaller, elemNs)
244
+ el.scrollLeft = scrollLeft
245
+ el.scrollTop = scrollTop
246
+ }
247
+
248
+ function reconcileAttrs (el, vnode, recaller) {
249
+ // Cleanup el's OWN attr watchers — descendants' watchers are NOT
250
+ // touched (they belong to elements that may themselves be matched
251
+ // and reconciled deeper in the recursion).
252
+ const fns = nodeCleanups.get(el)
253
+ if (fns) {
254
+ for (const f of fns) recaller.unwatch(f)
255
+ nodeCleanups.delete(el)
256
+ }
257
+ const oldAttrNames = Array.from(el.attributes, a => a.name)
258
+ for (const name of oldAttrNames) el.removeAttribute(name)
259
+ for (const attr of vnode.attrs) {
260
+ if (attr == null) continue
261
+ applyAttr(el, attr, recaller)
262
+ }
263
+ }
264
+
265
+ function reconcileChildren (parent, vnodeChildren, recaller, ns) {
266
+ // Flatten arrays/null in the vnode list so positional walking is clean.
267
+ const flat = []
268
+ const flatten = (v) => {
269
+ if (v == null) return
270
+ if (Array.isArray(v)) v.forEach(flatten)
271
+ else flat.push(v)
272
+ }
273
+ vnodeChildren.forEach(flatten)
274
+
275
+ // Collect existing element children (only elements are recyclable —
276
+ // text nodes, comments, and slot anchors get cleaned up + rebuilt).
277
+ const existingEls = []
278
+ for (const child of parent.childNodes) {
279
+ if (child.nodeType === Node.ELEMENT_NODE) existingEls.push(child)
280
+ }
281
+
282
+ // Same matching strategy as reconcileSlot: keyed-by-data-key first,
283
+ // unkeyed by tag pool.
284
+ const keyedMap = new Map()
285
+ const tagPool = new Map()
286
+ for (const el of existingEls) {
287
+ const key = el.getAttribute('data-key')
288
+ if (key != null) {
289
+ keyedMap.set(key, el)
290
+ } else {
291
+ const tag = el.tagName.toLowerCase()
292
+ if (!tagPool.has(tag)) tagPool.set(tag, [])
293
+ tagPool.get(tag).push(el)
294
+ }
295
+ }
296
+
297
+ const recycledEls = new Set()
298
+ const vnodeToEl = new Map()
299
+ for (const vnode of flat) {
300
+ if (!(vnode instanceof HElement)) continue
301
+ if (typeof vnode.tag === 'function') continue // function components mount fresh
302
+ const keyAttr = vnode.attrs.find(a => a?.name === 'data-key')
303
+ const keyVal = keyAttr?.value
304
+ const key = (keyVal != null && typeof keyVal !== 'function' && !Array.isArray(keyVal))
305
+ ? String(keyVal) : null
306
+ let el = null
307
+ if (key != null) {
308
+ const candidate = keyedMap.get(key)
309
+ if (candidate && !recycledEls.has(candidate)) el = candidate
310
+ } else {
311
+ const pool = tagPool.get(vnode.tag)
312
+ if (pool) el = pool.find(e => !recycledEls.has(e)) ?? null
313
+ }
314
+ if (el) {
315
+ recycledEls.add(el)
316
+ vnodeToEl.set(vnode, el)
317
+ }
318
+ }
319
+
320
+ // Detach recycled, cleanup + remove the rest (text nodes, comments,
321
+ // unmatched elements all go through cleanupNode so any watchers in
322
+ // their subtrees are released).
323
+ for (const el of recycledEls) el.remove()
324
+ while (parent.firstChild) {
325
+ const old = parent.firstChild
326
+ cleanupNode(old, recaller)
327
+ old.remove()
328
+ }
329
+
330
+ // Insert in order: recursively reconcile recycled, mount fresh otherwise.
331
+ for (const vnode of flat) {
332
+ const recycled = vnodeToEl.get(vnode)
333
+ if (recycled) {
334
+ reconcileElement(recycled, vnode, recaller, ns)
335
+ parent.appendChild(recycled)
336
+ } else {
337
+ mountNode(vnode, parent, recaller, ns)
338
+ }
339
+ }
340
+ }
341
+
228
342
  // ── Function components ───────────────────────────────────────────────────
229
343
  //
230
344
  // When an HElement's tag is a function, call it with a props object instead
@@ -0,0 +1,24 @@
1
+ <svg viewBox="0 0 680 680" xmlns="http://www.w3.org/2000/svg" fill="#1d4ed8">
2
+ <!-- streamo mark — yin-yang seam meets basketball seam, asymmetric so the
3
+ S flows in one direction (append-only). 7 named circles intersect to
4
+ define every curve in the mark; nothing is freehand, every point is
5
+ provably-related to every other point. -->
6
+ <!-- 7 circles: Ball(340,340,r280), Yang(340,200,r140), Yin(340,480,r140), -->
7
+ <!-- Happy(397.7,240.2,r302.4), Sad(370.9,370.9,r271.6), -->
8
+ <!-- Little(394,543,r70), Big(424,292,r183.2) -->
9
+
10
+ <!-- ABJ: Ball A→B, Sad B→J, Yang J→A -->
11
+ <path d="M 340,60 A 280,280 0 0,1 582,200 A 271.6,271.6 0 0,0 447,110 A 140,140 0 0,0 340,60 Z"/>
12
+
13
+ <!-- JDKL: Yang J→D, Yin D→K, Happy K→L, Sad L→J -->
14
+ <path d="M 447,110 A 140,140 0 0,1 340,340 A 140,140 0 0,0 200,465 A 302.4,302.4 0 0,1 105,319 A 271.6,271.6 0 0,1 447,110 Z"/>
15
+
16
+ <!-- BFME: Ball B→F, Happy F→M, Little M→E, Big E→B -->
17
+ <path d="M 582,200 A 280,280 0 0,1 582,480 A 302.4,302.4 0 0,1 327,535 A 70,70 0 0,1 402,474 A 183.2,183.2 0 0,0 582,200 Z"/>
18
+
19
+ <!-- GCL: Ball G→C, Happy C→L, Sad L→G -->
20
+ <path d="M 200,582 A 280,280 0 0,1 98,200 A 302.4,302.4 0 0,0 105,319 A 271.6,271.6 0 0,0 200,582 Z"/>
21
+
22
+ <!-- HIKM: Ball H→I, Yin I→K, Happy K→M, Little M→H -->
23
+ <path d="M 412,611 A 280,280 0 0,1 340,620 A 140,140 0 0,1 200,465 A 302.4,302.4 0 0,0 327,535 A 70,70 0 0,0 412,611 Z"/>
24
+ </svg>