@dtudury/streamo 4.0.2 → 4.0.4

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/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/dtudury/streamo/main/public/streamo.svg" alt="streamo" width="140">
3
+ </p>
4
+
1
5
  # streamo
2
6
 
3
7
  > every device is an equal author
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtudury/streamo",
3
- "version": "4.0.2",
3
+ "version": "4.0.4",
4
4
  "description": "peer-to-peer sync where your data and identity belong to you, not the server",
5
5
  "keywords": ["p2p", "peer-to-peer", "sync", "reactive", "content-addressed", "websocket", "signed", "append-only", "offline-first", "cryptographic", "identity"],
6
6
  "repository": "git@github.com:dtudury/streamo.git",
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>streamo chat</title>
7
+ <link rel="icon" type="image/svg+xml" href="/streamo.svg">
7
8
  <style>
8
9
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0 }
9
10
  :root { font-family: system-ui, sans-serif; font-size: 15px; --bg: #f5f5f5; --surface: #fff; --accent: #0070f3; --border: #ddd }
@@ -11,7 +12,9 @@
11
12
 
12
13
  /* Login */
13
14
  #login { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 2rem; width: min(360px, 90vw); display: flex; flex-direction: column; gap: .75rem }
14
- #login h1 { font-size: 1.2rem; font-weight: 600 }
15
+ #login h1 { font-size: 1.2rem; font-weight: 600; display: flex; align-items: center; gap: .5rem }
16
+ #login h1 img { width: 1.4rem; height: 1.4rem }
17
+ #chat-header img { width: 1.1rem; height: 1.1rem }
15
18
  #login input { border: 1px solid var(--border); border-radius: 6px; padding: .5rem .75rem; font-size: 1rem; width: 100% }
16
19
  #login button { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: .6rem; font-size: 1rem; cursor: pointer }
17
20
  #login button:hover { opacity: .85 }
@@ -38,7 +41,7 @@
38
41
  <body>
39
42
 
40
43
  <div id="login">
41
- <h1>streamo chat</h1>
44
+ <h1><img src="/streamo.svg" alt="">streamo chat</h1>
42
45
  <input id="username" placeholder="username" autocomplete="username">
43
46
  <input id="password" type="password" placeholder="password" autocomplete="current-password">
44
47
  <button id="join-btn">join</button>
@@ -47,7 +50,7 @@
47
50
 
48
51
  <div id="chat">
49
52
  <div id="chat-header">
50
- streamo chat <span id="my-name"></span>
53
+ <img src="/streamo.svg" alt="">streamo chat <span id="my-name"></span>
51
54
  </div>
52
55
  <div id="messages"></div>
53
56
  <div id="input-row">
@@ -4,13 +4,20 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>streamo explorer</title>
7
- <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22><text y=%2214%22 font-size=%2214%22>🌊</text></svg>">
7
+ <link rel="icon" type="image/svg+xml" href="/streamo.svg">
8
8
  <link rel="stylesheet" href="/apps/styles/proto.css">
9
9
  <style>
10
10
  body { max-width: 60rem; margin: 0 auto; padding: 2rem 1.25rem; }
11
11
 
12
12
  .header { display: flex; align-items: baseline; gap: 0.75rem; margin-bottom: 0.25rem; }
13
- .wordmark { font-size: 1.6rem; letter-spacing: -0.02em; }
13
+ .wordmark {
14
+ display: flex;
15
+ align-items: center;
16
+ gap: 0.5rem;
17
+ font-size: 1.6rem;
18
+ letter-spacing: -0.02em;
19
+ }
20
+ .wordmark img { width: 1.8rem; height: 1.8rem; }
14
21
  .crumbs { font-size: 0.85rem; color: var(--ink-dim); }
15
22
  .back { cursor: pointer; color: var(--ink-dim); font-size: 0.85rem; display: inline-block; margin-bottom: 1rem; }
16
23
  .back:hover { color: var(--ink); }
@@ -221,6 +228,19 @@
221
228
  }
222
229
  .repo-link:hover { background: var(--flash); text-decoration-style: solid; }
223
230
 
231
+ /* Sticky at-view header: selector + strip + tabs travel with you as
232
+ you scroll long value trees or storage detail. Background-cover so
233
+ content scrolling underneath doesn't bleed through. */
234
+ .atview-header {
235
+ position: sticky;
236
+ top: 0;
237
+ z-index: 10;
238
+ background: var(--bg, #fefdf8);
239
+ padding-top: 0.25rem;
240
+ border-bottom: 1px solid var(--rule);
241
+ margin-bottom: 0.75rem;
242
+ }
243
+
224
244
  /* Byte stream — zoomed strip in a horizontally-scrollable container,
225
245
  click-drag-to-pan inside for "look around" navigation. */
226
246
  .byte-strip-container {
@@ -236,6 +256,45 @@
236
256
  .byte-strip-container.dragging .chunk { cursor: grabbing; }
237
257
  .byte-strip { display: block; }
238
258
 
259
+ /* Sig-coverage overlay: when hovering a sig anywhere on the page,
260
+ this rect is positioned over its [signedFrom, signedTo] byte range
261
+ on the strip. Subtle dashed band — doesn't fight the chunk colors. */
262
+ .byte-strip .sig-coverage {
263
+ fill: rgba(239, 68, 68, 0.12);
264
+ stroke: rgba(239, 68, 68, 0.6);
265
+ stroke-width: 1.5;
266
+ stroke-dasharray: 4 3;
267
+ opacity: 0;
268
+ transition: opacity 0.08s;
269
+ }
270
+ .byte-strip .sig-coverage.active { opacity: 1; }
271
+
272
+ /* "← older newer →" labels under the strip — no UI action, just
273
+ Tour-Guide orientation so the append direction is obvious. */
274
+ .strip-direction {
275
+ display: flex;
276
+ justify-content: space-between;
277
+ font-size: 0.7rem;
278
+ color: var(--ink-dim);
279
+ padding: 0 0.25rem;
280
+ margin-top: 0.15rem;
281
+ }
282
+
283
+ /* Live readout of the hovered chunk — codec, address, length. Quiet
284
+ by default; lights up when something's hovered. */
285
+ .chunk-inspector {
286
+ font-family: monospace;
287
+ font-size: 0.8rem;
288
+ padding: 0.25rem 0.5rem;
289
+ margin: 0.25rem 0 0.75rem;
290
+ border-radius: var(--radius);
291
+ transition: color 0.08s, background 0.08s;
292
+ }
293
+ .chunk-inspector.active {
294
+ color: var(--ink);
295
+ background: var(--flash);
296
+ }
297
+
239
298
  .byte-map {
240
299
  display: block;
241
300
  }
@@ -276,6 +335,38 @@
276
335
  .tv-array, .tv-object { color: #1e40af; background: rgba(59, 130, 246, 0.10); }
277
336
  .tv-duple { color: #6b21a8; background: rgba(168, 85, 247, 0.10); }
278
337
 
338
+ /* Recursive typed-value tree — used for rehydrated views. Outer levels
339
+ expand inline; un-expanded composites become drillable chips. */
340
+ .tv-tree {
341
+ font-size: 0.85rem;
342
+ line-height: 1.7;
343
+ font-family: monospace;
344
+ margin: 0.4rem 0;
345
+ }
346
+ .tv-tree-row {
347
+ padding-left: 1.5rem;
348
+ }
349
+ .tv-tree .tv-bracket {
350
+ color: var(--ink-dim);
351
+ font-weight: 600;
352
+ }
353
+ .tv-tree .tv-bracket.clickable {
354
+ cursor: pointer;
355
+ }
356
+ .tv-tree .tv-bracket.clickable:hover {
357
+ background: var(--flash);
358
+ color: var(--ink);
359
+ }
360
+ .tv-tree .tv-key {
361
+ color: var(--ink-dim);
362
+ margin-right: 0.4rem;
363
+ }
364
+ .tv-drill {
365
+ cursor: pointer;
366
+ text-decoration: underline dotted var(--ink-dim);
367
+ }
368
+ .tv-drill:hover { background: var(--flash); text-decoration-style: solid; }
369
+
279
370
  /* codec category palette — used in both the legend and the SVG fills */
280
371
  .cat-commit { fill: #f59e0b; background: #f59e0b; }
281
372
  .cat-sig { fill: #ef4444; background: #ef4444; }
@@ -344,7 +435,7 @@
344
435
  </head>
345
436
  <body>
346
437
  <div class="header">
347
- <div class="wordmark">streamo</div>
438
+ <div class="wordmark"><img src="/streamo.svg" alt="streamo">streamo</div>
348
439
  <div class="crumbs">explorer</div>
349
440
  </div>
350
441
  <div id="conn" class="conn">connecting…</div>
@@ -443,11 +443,13 @@ function AtView ({ keyHex, address }) {
443
443
  const isCommit = isCommitShape(decoded)
444
444
  const isSig = codecType === 'SIGNATURE'
445
445
 
446
- // Tabs are part of the page content (not the static header) so the
447
- // commit selector renders ABOVE the tabs. The selector is always
448
- // present (when the repo has any commits) so the UI doesn't shift
449
- // as you click between commit pages and storage drilling when
450
- // the current address isn't a commit, the summary shows "detached".
446
+ // Common header shown on every at-view: commit selector dropdown,
447
+ // byte-strip with the current chunk highlighted, then the value/
448
+ // storage tab nav. The byte-strip used to live only in the storage
449
+ // tab; promoting it lets you keep spatial context across tab and
450
+ // commit switches, and any data-addr hover (typed-tree chips,
451
+ // refs/referrers tables, kv addr links) cross-highlights and
452
+ // smooth-scrolls into view in the strip.
451
453
  const tabs = h`
452
454
  <nav class="tabs">
453
455
  <a class=${() => { dep(); return ['tab', atTab === 'value' ? 'active' : null] }}
@@ -457,24 +459,25 @@ function AtView ({ keyHex, address }) {
457
459
  </nav>
458
460
  `
459
461
  const selector = commitSelectorSection(repo, keyHex, resolvedAddr)
462
+ const bytes = byteStreamSection(repo, keyHex, resolvedAddr)
463
+ // Wrap in a sticky container so the selector + strip + tabs stay
464
+ // anchored as you scroll long value trees or storage detail.
465
+ const header = h`<div class="atview-header">${selector}${bytes}${tabs}</div>`
460
466
 
461
- // Storage tab: spatial view of where this chunk lives in the byte
462
- // stream + outgoing references + this chunk's bytes + incoming
463
- // referrers. The chunk graph from this chunk's perspective.
467
+ // Storage tab: this chunk's outgoing refs, raw bytes, and
468
+ // referrers. (byteStreamSection moved up into the header.)
464
469
  if (atTab === 'storage') {
465
470
  return h`
466
- ${selector}
467
- ${tabs}
468
- ${byteStreamSection(repo, keyHex, resolvedAddr)}
471
+ ${header}
469
472
  ${outgoingReferencesSection(repo, keyHex, resolvedAddr)}
470
473
  ${rawChunkSection(repo, resolvedAddr)}
471
474
  ${referrersSection(repo, keyHex, resolvedAddr)}
472
475
  `
473
476
  }
474
477
 
475
- // Every value-tab branch below prepends ${selector}${tabs} so the
476
- // UI is stable across navigation: the selector is always at the
477
- // top of the page when the repo has any sigs.
478
+ // Every value-tab branch below prepends ${header} so the UI is
479
+ // stable across navigation: selector + byte-strip + tabs are
480
+ // always at the top of the page when the repo has any commits.
478
481
 
479
482
  // Value tab — branches by codec.
480
483
  // Helper: render the kv-table of decoded fields for any Object/Array
@@ -543,13 +546,30 @@ function AtView ({ keyHex, address }) {
543
546
  : h`<span class="verify-badge pending">…</span><span class="dim">not yet signed — sign in flight or pending</span>`,
544
547
  covering ? 'verified' : 'unsigned'
545
548
  )
549
+ // Commit fields render with two semantic specials: dataAddress and
550
+ // parent are *byte-address pointers* (their numeric value IS a
551
+ // navigation target), so they're clickable address pills directly
552
+ // — the chunk holding the FLOAT64 value is incidental and we
553
+ // skip the chunk-address column for those rows.
554
+ const addrLink = (addr) => addr === undefined
555
+ ? h`<span class="dim">(none — first commit)</span>`
556
+ : h`<a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${addr}>@${addr}</a>`
557
+ const commitFieldsTable = h`
558
+ <table class="kv">
559
+ <tbody>
560
+ <tr><td class="mono">message</td><td>${typedValue(decoded.message)}</td></tr>
561
+ <tr><td class="mono">date</td><td>${typedValue(decoded.date)}</td></tr>
562
+ <tr><td class="mono">dataAddress</td><td>${addrLink(decoded.dataAddress)}</td></tr>
563
+ <tr><td class="mono">parent</td><td>${addrLink(decoded.parent)}</td></tr>
564
+ </tbody>
565
+ </table>
566
+ `
546
567
  return h`
547
- ${selector}
548
- ${tabs}
568
+ ${header}
549
569
  ${banner}
550
- ${refsTable()}
551
- <h3>rehydrated</h3>
552
- <pre class="value">${safeJSON(decoded)}</pre>
570
+ ${commitFieldsTable}
571
+ <h3>value <span class="dim">at <a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${decoded.dataAddress}>@${decoded.dataAddress}</a></span></h3>
572
+ ${valueTree(repo, keyHex, decoded.dataAddress)}
553
573
  ${changes
554
574
  ? h`
555
575
  <h3>changed paths <span class="dim">(${changes.length})</span></h3>
@@ -581,8 +601,7 @@ function AtView ({ keyHex, address }) {
581
601
  // Duple: explain what this tree-node IS, then show its two children.
582
602
  if (codecType === 'DUPLE') {
583
603
  return h`
584
- ${selector}
585
- ${tabs}
604
+ ${header}
586
605
  ${kindBanner('duple', h`<span class="dim">2-tuple, tree scaffolding</span>`)}
587
606
  <p class="explainer">
588
607
  A <strong>Duple</strong> is a 2-tuple — the building block streamo uses
@@ -616,8 +635,7 @@ function AtView ({ keyHex, address }) {
616
635
  'verified'
617
636
  )
618
637
  return h`
619
- ${selector}
620
- ${tabs}
638
+ ${header}
621
639
  ${banner}
622
640
  ${sigDetailBody(repo, keyHex, resolvedAddr, decoded)}
623
641
  <div class="dim" style="margin-top: 0.5rem;">switch to the <strong>storage</strong> tab above to see the raw chunk bytes, outgoing references, and what else points at this address.</div>
@@ -635,8 +653,7 @@ function AtView ({ keyHex, address }) {
635
653
  ? (isArray ? 'empty array' : 'empty object')
636
654
  : (isArray ? 'array' : 'object')
637
655
  return h`
638
- ${selector}
639
- ${tabs}
656
+ ${header}
640
657
  ${kindBanner(label, dim)}
641
658
  ${refsTable()}
642
659
  ${fieldCount > 0 ? h`
@@ -648,8 +665,7 @@ function AtView ({ keyHex, address }) {
648
665
 
649
666
  // Primitive: just show it.
650
667
  return h`
651
- ${selector}
652
- ${tabs}
668
+ ${header}
653
669
  ${kindBanner(codecType.toLowerCase())}
654
670
  <pre class="value">${safeJSON(decoded)}</pre>
655
671
  `
@@ -703,12 +719,22 @@ function byteStreamSection (repo, keyHex, currentAddress) {
703
719
  const code = repo.resolve(addr)
704
720
  if (!code || !code.length) break
705
721
  const codec = repo.footerToCodec[code.at(-1)]
706
- chunks.unshift({
722
+ const chunk = {
707
723
  address: addr,
708
724
  start: addr - code.length + 1,
709
725
  length: code.length,
710
726
  codecType: codec?.type || '?'
711
- })
727
+ }
728
+ // For sigs: precompute the byte range covered, so hover anywhere on
729
+ // the page can light up that range as an overlay band on the strip.
730
+ if (chunk.codecType === 'SIGNATURE') {
731
+ try {
732
+ const sig = repo.decode(addr)
733
+ chunk.signedFrom = sig.address
734
+ chunk.signedTo = addr - code.length
735
+ } catch {}
736
+ }
737
+ chunks.unshift(chunk)
712
738
  addr -= code.length
713
739
  }
714
740
  if (!chunks.length) return null
@@ -744,6 +770,20 @@ function byteStreamSection (repo, keyHex, currentAddress) {
744
770
  return item
745
771
  })
746
772
  const stripW = cursorX
773
+ // Map byte address → strip x. Used by the sig-coverage overlay so hover
774
+ // anywhere on the page can light up "what bytes does this sig sign".
775
+ // Stored as data attrs on the strip container so the hover handler
776
+ // can read without recomputing.
777
+ const xForByte = (byteAddr) => {
778
+ // Find the chunk containing this byte and interpolate within it.
779
+ for (const c of layout) {
780
+ if (byteAddr >= c.start && byteAddr <= c.address) {
781
+ const frac = c.length === 1 ? 0 : (byteAddr - c.start) / (c.length - 1)
782
+ return c.x + frac * c.w
783
+ }
784
+ }
785
+ return 0
786
+ }
747
787
  return h`
748
788
  <h3>byte stream <span class="dim">(${total} bytes · ${chunks.length} chunks)</span></h3>
749
789
  <div class="byte-map-legend">
@@ -756,21 +796,33 @@ function byteStreamSection (repo, keyHex, currentAddress) {
756
796
  <span class="cat-num">num</span>
757
797
  <span class="cat-var">var</span>
758
798
  </div>
759
- <div class="byte-strip-container" data-key=${`strip-${keyHex}`}>
799
+ <div class="byte-strip-container" data-key=${`strip-${keyHex}`} data-strip-w=${stripW}>
760
800
  <svg class="byte-map byte-strip" width=${stripW} height=${H} viewBox=${`0 0 ${stripW} ${H}`}>
761
801
  ${layout.map(c => {
762
802
  const cat = commitAddrs.has(c.address) ? 'commit' : codecCategory(c.codecType)
763
803
  const cls = ['chunk', `cat-${cat}`, c.address === currentAddress ? 'current' : null]
804
+ // Sigs carry their coverage range in data-attrs so hover handlers
805
+ // (anywhere on the page) can position the coverage overlay.
806
+ // Non-sigs get null which removes the attrs.
807
+ const sigFromX = c.signedFrom != null ? xForByte(c.signedFrom) : null
808
+ const sigToX = c.signedTo != null ? xForByte(c.signedTo) : null
764
809
  return h`<rect
765
810
  class=${cls}
766
811
  x=${c.x} y="0" width=${c.w} height=${H}
767
812
  data-action="open-at"
768
813
  data-keyhex=${keyHex}
769
814
  data-addr=${c.address}
815
+ data-codec=${c.codecType}
816
+ data-len=${c.length}
817
+ data-sig-from-x=${sigFromX}
818
+ data-sig-to-x=${sigToX}
770
819
  ><title>${c.codecType} @${c.address} (${c.length} bytes)</title></rect>`
771
820
  })}
821
+ <rect class="sig-coverage" x="0" y="0" width="0" height=${H} pointer-events="none"/>
772
822
  </svg>
823
+ <div class="strip-direction"><span>← older</span><span>newer →</span></div>
773
824
  </div>
825
+ <div class="chunk-inspector dim" data-key=${`inspector-${keyHex}`}>hover the strip to inspect a chunk</div>
774
826
  `
775
827
  }
776
828
 
@@ -861,38 +913,102 @@ function isDuple (v) {
861
913
  // count chips ({ N fields } / [ N elements ]) — depth-controlled
862
914
  // expansion is the next step in this thread (see THREADS.md).
863
915
  function typedValue (v, depth = 0) {
864
- if (v === null) return h`<span class="tv tv-null">null</span>`
865
- if (v === undefined) return h`<span class="tv tv-undefined">undefined</span>`
916
+ if (v === null) return h`<span class="tv tv-null" title="NULL">null</span>`
917
+ if (v === undefined) return h`<span class="tv tv-undefined" title="UNDEFINED">undefined</span>`
866
918
  if (typeof v === 'boolean') {
867
- return h`<span class=${['tv', 'tv-bool', v ? 'tv-true' : 'tv-false']}>${v ? '✓' : '✗'} ${String(v)}</span>`
919
+ return h`<span class=${['tv', 'tv-bool', v ? 'tv-true' : 'tv-false']} title=${v ? 'TRUE' : 'FALSE'}>${v ? '✓' : '✗'} ${String(v)}</span>`
868
920
  }
869
921
  if (typeof v === 'string') {
870
922
  const display = v.length > 60 ? v.slice(0, 60) + '…' : v
871
- return h`<span class="tv tv-string"><span class="tv-quote">“</span>${display}<span class="tv-quote">”</span></span>`
923
+ return h`<span class="tv tv-string" title=${v.length === 0 ? 'EMPTY_STRING' : 'STRING'}><span class="tv-quote">“</span>${display}<span class="tv-quote">”</span></span>`
872
924
  }
873
925
  if (typeof v === 'number') {
874
- return h`<span class="tv tv-num">${String(v)}</span>`
926
+ // UINT7 is the codec for non-negative integers < 128; everything else
927
+ // routes through FLOAT64. Surfacing this distinction makes "why is
928
+ // this 1 byte vs 9" tactile when you hover.
929
+ const codec = (Number.isInteger(v) && v >= 0 && v < 128) ? 'UINT7' : 'FLOAT64'
930
+ return h`<span class="tv tv-num" title=${codec}>${String(v)}</span>`
875
931
  }
876
932
  if (v instanceof Date) {
877
- return h`<span class="tv tv-date"><span class="tv-glyph">📅</span><time datetime=${v.toISOString()}>${v.toLocaleString()}</time></span>`
933
+ return h`<span class="tv tv-date" title="DATE"><span class="tv-glyph">📅</span><time datetime=${v.toISOString()}>${v.toLocaleString()}</time></span>`
878
934
  }
879
935
  if (v instanceof Uint8Array) {
880
- return h`<span class="tv tv-bytes">Uint8Array(${v.length})</span>`
936
+ return h`<span class="tv tv-bytes" title=${v.length === 0 ? 'EMPTY_UINT8ARRAY' : (v.length <= 4 ? 'WORD or UINT8ARRAY' : 'UINT8ARRAY')}>Uint8Array(${v.length})</span>`
881
937
  }
882
938
  if (isDuple(v)) {
883
- if (depth > 1) return h`<span class="tv tv-duple">Duple(…)</span>`
884
- return h`<span class="tv tv-duple">Duple(${typedValue(v.v[0], depth + 1)}, ${typedValue(v.v[1], depth + 1)})</span>`
939
+ if (depth > 1) return h`<span class="tv tv-duple" title="DUPLE">Duple(…)</span>`
940
+ return h`<span class="tv tv-duple" title="DUPLE">Duple(${typedValue(v.v[0], depth + 1)}, ${typedValue(v.v[1], depth + 1)})</span>`
885
941
  }
886
942
  if (Array.isArray(v)) {
887
- return h`<span class="tv tv-array">[ ${v.length} ${v.length === 1 ? 'element' : 'elements'} ]</span>`
943
+ return h`<span class="tv tv-array" title=${v.length === 0 ? 'EMPTY_ARRAY' : 'ARRAY'}>[ ${v.length} ${v.length === 1 ? 'element' : 'elements'} ]</span>`
888
944
  }
889
945
  if (typeof v === 'object') {
890
946
  const n = Object.keys(v).length
891
- return h`<span class="tv tv-object">{ ${n} ${n === 1 ? 'field' : 'fields'} }</span>`
947
+ return h`<span class="tv tv-object" title=${n === 0 ? 'EMPTY_OBJECT' : 'OBJECT'}>{ ${n} ${n === 1 ? 'field' : 'fields'} }</span>`
892
948
  }
893
949
  return h`<span class="tv">${String(v)}</span>`
894
950
  }
895
951
 
952
+ // Recursive typed-value tree — like typedValue, but expands composites
953
+ // inline up to `depth` levels deep. Beyond depth, composites render as
954
+ // un-expanded chips. Click a chip to expand IN PLACE (forceExpanded);
955
+ // click an expanded composite's opening bracket to collapse it back to
956
+ // a chip (forceCollapsed). Force-expand and force-collapse override
957
+ // the default depth-based decision.
958
+ //
959
+ // Default depth=3 covers `{ name, messages: [{text, at}, ...] }` —
960
+ // outer object expanded, messages array expanded, message objects
961
+ // expanded, and primitives like text/at render inline.
962
+ const forceExpanded = new Set() // `${keyHex}:${address}` → user clicked chip
963
+ const forceCollapsed = new Set() // `${keyHex}:${address}` → user clicked bracket
964
+
965
+ function valueTree (repo, keyHex, address, depth = 3) {
966
+ let value, refs
967
+ try {
968
+ value = repo.decode(address)
969
+ refs = repo.asRefs(address)
970
+ } catch {
971
+ return h`<span class="dim">(decode error @${address})</span>`
972
+ }
973
+ if (typeof value !== 'object' || value === null || value instanceof Date || value instanceof Uint8Array) {
974
+ return typedValue(value)
975
+ }
976
+ const k = `${keyHex}:${address}`
977
+ const userExpanded = forceExpanded.has(k)
978
+ const userCollapsed = forceCollapsed.has(k)
979
+ const expand = userExpanded || (!userCollapsed && depth > 0)
980
+ if (!expand) {
981
+ return h`<a class="tv-drill" data-action="expand-tree"
982
+ data-keyhex=${keyHex} data-addr=${address}
983
+ title="click to expand · drill via storage tab if you need a full at-view"
984
+ >${typedValue(value)}</a>`
985
+ }
986
+ const isArray = Array.isArray(value)
987
+ const entries = isArray
988
+ ? value.map((v, i) => [String(i), v, refs?.[i]])
989
+ : Object.entries(value).map(([k, v]) => [k, v, refs?.[k]])
990
+ if (entries.length === 0) {
991
+ return h`<span class="tv ${isArray ? 'tv-array' : 'tv-object'}">${isArray ? '[ ]' : '{ }'}</span>`
992
+ }
993
+ return h`
994
+ <div class="tv-tree ${isArray ? 'tv-tree-array' : 'tv-tree-object'}">
995
+ <span class="tv-bracket clickable" data-action="collapse-tree"
996
+ data-keyhex=${keyHex} data-addr=${address}
997
+ title="click to collapse"
998
+ >${isArray ? '[' : '{'}</span>
999
+ ${entries.map(([k, v, addr]) => h`
1000
+ <div class="tv-tree-row">
1001
+ <span class="tv-key">${k}:</span>
1002
+ ${addr !== undefined
1003
+ ? valueTree(repo, keyHex, addr, depth - 1)
1004
+ : typedValue(v)}
1005
+ </div>
1006
+ `)}
1007
+ <span class="tv-bracket">${isArray ? ']' : '}'}</span>
1008
+ </div>
1009
+ `
1010
+ }
1011
+
896
1012
  function safeGet (f) { try { return f() } catch { return undefined } }
897
1013
 
898
1014
  // Build a child→parents index for the entire repo in one pass, so we can
@@ -1065,6 +1181,18 @@ appEl.addEventListener('click', e => {
1065
1181
  el.closest('details.commit-selector')?.removeAttribute('open')
1066
1182
  return go({ kind: 'at', keyHex: el.dataset.keyhex, address: +el.dataset.addr })
1067
1183
  }
1184
+ case 'expand-tree': {
1185
+ const k = `${el.dataset.keyhex}:${el.dataset.addr}`
1186
+ forceExpanded.add(k)
1187
+ forceCollapsed.delete(k)
1188
+ return fire()
1189
+ }
1190
+ case 'collapse-tree': {
1191
+ const k = `${el.dataset.keyhex}:${el.dataset.addr}`
1192
+ forceCollapsed.add(k)
1193
+ forceExpanded.delete(k)
1194
+ return fire()
1195
+ }
1068
1196
  }
1069
1197
  })
1070
1198
 
@@ -1126,11 +1254,11 @@ appEl.addEventListener('pointerup', endDrag)
1126
1254
  appEl.addEventListener('pointercancel', endDrag)
1127
1255
 
1128
1256
  // Cross-highlight: hovering any element with data-addr highlights the
1129
- // matching chunk in the byte-map. References and referrers light up the
1130
- // chunk's position in the stream so you can SEE where it lives. If the
1131
- // hover came from somewhere other than the strip itself, smooth-scroll
1132
- // the matching chunk into view inside any byte-strip-container —
1133
- // otherwise hover-elsewhere can highlight chunks that are off-screen.
1257
+ // matching chunk in the byte-map, populates the chunk inspector below
1258
+ // the strip with codec/addr/length, and (if the hovered chunk is a
1259
+ // signature) lights up its covered byte range as an overlay band on
1260
+ // the strip. If the hover came from somewhere other than the strip
1261
+ // itself, smooth-scroll the matching chunk into view in the strip.
1134
1262
  appEl.addEventListener('mouseover', e => {
1135
1263
  const el = e.target.closest('[data-addr]')
1136
1264
  if (!el) return
@@ -1144,10 +1272,37 @@ appEl.addEventListener('mouseover', e => {
1144
1272
  }
1145
1273
  })
1146
1274
  }
1275
+ // Look up the chunk's data on the strip rect for inspector + sig coverage.
1276
+ const stripRect = matches[0]?.closest('.byte-strip-container') ? matches[0]
1277
+ : appEl.querySelector(`.byte-strip .chunk[data-addr="${addr}"]`)
1278
+ if (stripRect) {
1279
+ const codec = stripRect.dataset.codec
1280
+ const len = stripRect.dataset.len
1281
+ for (const ins of appEl.querySelectorAll('.chunk-inspector')) {
1282
+ ins.textContent = codec ? `${codec} · @${addr} · ${len} bytes` : `chunk @${addr}`
1283
+ ins.classList.remove('dim')
1284
+ ins.classList.add('active')
1285
+ }
1286
+ const fromX = stripRect.getAttribute('data-sig-from-x')
1287
+ const toX = stripRect.getAttribute('data-sig-to-x')
1288
+ if (fromX != null && toX != null) {
1289
+ const overlay = stripRect.closest('.byte-strip').querySelector('.sig-coverage')
1290
+ if (overlay) {
1291
+ overlay.setAttribute('x', fromX)
1292
+ overlay.setAttribute('width', String(parseFloat(toX) - parseFloat(fromX)))
1293
+ overlay.classList.add('active')
1294
+ }
1295
+ }
1296
+ }
1147
1297
  })
1148
1298
  appEl.addEventListener('mouseout', e => {
1149
1299
  const el = e.target.closest('[data-addr]')
1150
1300
  if (!el) return
1151
- appEl.querySelectorAll('.byte-map .chunk.hovered')
1152
- .forEach(c => c.classList.remove('hovered'))
1301
+ appEl.querySelectorAll('.byte-map .chunk.hovered').forEach(c => c.classList.remove('hovered'))
1302
+ appEl.querySelectorAll('.sig-coverage.active').forEach(o => o.classList.remove('active'))
1303
+ for (const ins of appEl.querySelectorAll('.chunk-inspector')) {
1304
+ ins.textContent = 'hover the strip to inspect a chunk'
1305
+ ins.classList.remove('active')
1306
+ ins.classList.add('dim')
1307
+ }
1153
1308
  })
package/public/index.html CHANGED
@@ -4,18 +4,56 @@
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>
9
10
  body { max-width: 44rem; margin: 0 auto; padding: 2.5rem 1.25rem; }
10
11
 
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; }
12
+ .wordmark {
13
+ display: flex;
14
+ align-items: center;
15
+ gap: 0.6rem;
16
+ font-size: 2.4rem;
17
+ letter-spacing: -0.02em;
18
+ margin-bottom: 0.15rem;
19
+ }
20
+ .wordmark img { width: 2.6rem; height: 2.6rem; }
21
+ .tagline { color: var(--ink-dim); font-size: 0.95rem; margin-bottom: 0.4rem; }
22
+
23
+ /* "what's running here" line — small, clickable, places the abstract
24
+ claims in the next paragraph onto a real running server. */
25
+ .here {
26
+ font-size: 0.78rem;
27
+ color: var(--ink-dim);
28
+ margin-bottom: 2rem;
29
+ font-family: monospace;
30
+ }
31
+ .here a {
32
+ color: var(--ink-dim);
33
+ text-decoration: underline dotted;
34
+ }
35
+ .here a:hover { color: var(--ink); text-decoration-style: solid; }
36
+ .here .pulse {
37
+ display: inline-block;
38
+ width: 0.5rem;
39
+ height: 0.5rem;
40
+ border-radius: 50%;
41
+ background: #16a34a;
42
+ margin-right: 0.4rem;
43
+ vertical-align: 0.05em;
44
+ animation: pulse 2s ease-in-out infinite;
45
+ }
46
+ .here .pulse.err { background: #dc2626; animation: none; }
47
+ @keyframes pulse {
48
+ 0%, 100% { opacity: 0.4; }
49
+ 50% { opacity: 1; }
50
+ }
13
51
 
14
52
  .ideas {
15
53
  display: flex;
16
54
  flex-direction: column;
17
55
  gap: 0.6rem;
18
- margin-bottom: 2.5rem;
56
+ margin-bottom: 1.5rem;
19
57
  }
20
58
  .idea {
21
59
  display: flex;
@@ -27,6 +65,72 @@
27
65
  .idea-text { color: var(--ink-dim); }
28
66
  .idea-text strong { color: var(--ink); }
29
67
 
68
+ /* "try it" card — folds shut by default. When opened, derives a real
69
+ secp256k1 keypair from credentials in the browser. Concrete proof
70
+ of "your identity travels with you" instead of just claiming it. */
71
+ details.try-it {
72
+ border: 1.5px dashed var(--rule);
73
+ border-radius: var(--radius);
74
+ padding: 0.6rem 0.85rem;
75
+ margin-bottom: 2rem;
76
+ }
77
+ details.try-it[open] { border-color: var(--ink); border-style: solid; }
78
+ details.try-it > summary {
79
+ cursor: pointer;
80
+ font-size: 0.85rem;
81
+ color: var(--ink-dim);
82
+ }
83
+ details.try-it[open] > summary { color: var(--ink); margin-bottom: 0.65rem; }
84
+ details.try-it > summary::before {
85
+ content: '↳ ';
86
+ color: var(--ink-dim);
87
+ }
88
+ .try-it-note {
89
+ font-size: 0.78rem;
90
+ color: var(--ink-dim);
91
+ line-height: 1.5;
92
+ margin: 0 0 0.65rem;
93
+ }
94
+ .try-it-note strong { color: var(--ink); }
95
+ .try-it-form {
96
+ display: flex;
97
+ flex-wrap: wrap;
98
+ gap: 0.5rem;
99
+ margin-bottom: 0.65rem;
100
+ }
101
+ .try-it-form input {
102
+ flex: 1 1 8rem;
103
+ font-family: monospace;
104
+ font-size: 0.85rem;
105
+ padding: 0.35rem 0.55rem;
106
+ border: 1px solid var(--rule);
107
+ border-radius: var(--radius);
108
+ background: transparent;
109
+ color: var(--ink);
110
+ }
111
+ .try-it-form input:focus {
112
+ outline: none;
113
+ border-color: var(--ink);
114
+ }
115
+ .derived-key {
116
+ font-size: 0.78rem;
117
+ color: var(--ink-dim);
118
+ padding: 0.35rem 0.55rem;
119
+ border-radius: var(--radius);
120
+ background: rgba(0, 0, 0, 0.03);
121
+ word-break: break-all;
122
+ }
123
+ .derived-key .key-label {
124
+ display: block;
125
+ margin-bottom: 0.2rem;
126
+ font-family: monospace;
127
+ }
128
+ .derived-key .key-value {
129
+ font-family: monospace;
130
+ color: var(--ink);
131
+ }
132
+ .derived-key.computing .key-value { color: var(--ink-dim); font-style: italic; }
133
+
30
134
  hr { margin: 2rem 0; }
31
135
 
32
136
  .apps-heading {
@@ -44,7 +148,9 @@
44
148
  }
45
149
 
46
150
  .app-card {
47
- display: block;
151
+ display: flex;
152
+ flex-direction: column;
153
+ gap: 0.25rem;
48
154
  text-decoration: none;
49
155
  color: var(--ink);
50
156
  border: 1.5px solid var(--ink);
@@ -56,22 +162,41 @@
56
162
  .app-card:hover { transform: translate(-1px, -1px); box-shadow: 3px 4px 0 var(--ink); }
57
163
  .app-card:active { transform: translate(1px, 2px); box-shadow: none; }
58
164
 
59
- .app-name { font-size: 1rem; margin-bottom: 0.2rem; }
165
+ .app-name-row {
166
+ display: flex;
167
+ align-items: baseline;
168
+ gap: 0.45rem;
169
+ }
170
+ .app-glyph { font-size: 1.1rem; }
171
+ .app-name { font-size: 1rem; }
60
172
  .app-desc { font-size: 0.78rem; color: var(--ink-dim); line-height: 1.4; }
61
173
 
62
174
  .footer {
175
+ display: flex;
176
+ flex-wrap: wrap;
177
+ gap: 0.5rem;
63
178
  margin-top: 3rem;
64
- font-size: 0.75rem;
179
+ }
180
+ .footer-chip {
181
+ font-size: 0.78rem;
65
182
  color: var(--ink-dim);
183
+ text-decoration: none;
184
+ padding: 0.25rem 0.6rem;
185
+ border: 1px solid var(--rule);
186
+ border-radius: var(--radius);
66
187
  }
67
- .footer a { color: var(--ink-dim); }
188
+ .footer-chip:hover { color: var(--ink); border-color: var(--ink); }
68
189
  </style>
69
190
  </head>
70
191
  <body>
71
192
 
72
- <div class="wordmark">streamo</div>
193
+ <div class="wordmark"><img src="/streamo.svg" alt="streamo">streamo</div>
73
194
  <p class="tagline">every device is an equal author</p>
74
195
 
196
+ <p class="here" id="here">
197
+ <span class="pulse" id="pulse"></span><span id="here-text">connecting…</span>
198
+ </p>
199
+
75
200
  <div class="ideas">
76
201
  <div class="idea">
77
202
  <span class="idea-glyph">↔</span>
@@ -91,26 +216,106 @@
91
216
  </div>
92
217
  </div>
93
218
 
219
+ <details class="try-it">
220
+ <summary>try it — derive a streamo identity right here</summary>
221
+ <p class="try-it-note">
222
+ 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.
223
+ </p>
224
+ <div class="try-it-form">
225
+ <input type="text" placeholder="username" id="demo-username" autocomplete="off">
226
+ <input type="password" placeholder="password" id="demo-password" autocomplete="new-password">
227
+ </div>
228
+ <div class="derived-key" id="demo-key-row">
229
+ <span class="key-label">public key (secp256k1):</span>
230
+ <code class="key-value" id="demo-key">— enter credentials above —</code>
231
+ </div>
232
+ </details>
233
+
94
234
  <hr>
95
235
 
96
236
  <p class="apps-heading">apps</p>
97
237
  <div class="app-grid">
98
238
  <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>
239
+ <div class="app-name-row">
240
+ <span class="app-glyph">💬</span>
241
+ <span class="app-name">chat</span>
242
+ </div>
243
+ <div class="app-desc">p2p messaging — each participant owns their own signed message stream</div>
101
244
  </a>
102
245
  <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>
246
+ <div class="app-name-row">
247
+ <span class="app-glyph">🔍</span>
248
+ <span class="app-name">explorer</span>
249
+ </div>
250
+ <div class="app-desc">browse repos, commit history, and the bytes underneath</div>
105
251
  </a>
106
252
  </div>
107
253
  <p class="dim" style="margin-top: 1rem; font-size: 0.78rem;">
108
254
  open both side by side — watch commits roll in as you chat
109
255
  </p>
110
256
 
111
- <p class="footer">
112
- <a href="https://github.com/dtudury/streamo">github</a>
113
- </p>
257
+ <div class="footer">
258
+ <a class="footer-chip" href="https://github.com/dtudury/streamo">github</a>
259
+ <a class="footer-chip" href="https://github.com/dtudury/streamo/blob/main/design.md">design.md</a>
260
+ <a class="footer-chip" href="https://github.com/dtudury/streamo/blob/main/ROADMAP.md">roadmap</a>
261
+ <a class="footer-chip" href="https://www.npmjs.com/package/@dtudury/streamo">npm</a>
262
+ </div>
263
+
264
+ <script type="module">
265
+ import { Signer } from '/streamo/Signer.js'
266
+ import { bytesToHex } from '/streamo/utils.js'
267
+
268
+ // Surface "what's running here" — server's primary key as a clickable
269
+ // link to the explorer. Replaces the abstract "no server holds
270
+ // authority" claim above with a concrete, navigable instance of it.
271
+ const pulseEl = document.getElementById('pulse')
272
+ const hereEl = document.getElementById('here-text')
273
+ try {
274
+ const info = await fetch('/api/info').then(r => r.json())
275
+ const truncKey = info.primaryKeyHex.slice(0, 12) + '…'
276
+ hereEl.innerHTML = `relaying <a href="/apps/explorer/#/repo/${info.primaryKeyHex}">${truncKey}</a> as "${info.name || 'this server'}"`
277
+ } catch (e) {
278
+ pulseEl.classList.add('err')
279
+ hereEl.textContent = 'not connected to a streamo server'
280
+ }
281
+
282
+ // Derive-on-type for the try-it widget. Debounced because PBKDF2 is
283
+ // intentionally slow; we don't want to hammer the browser per keystroke.
284
+ const u = document.getElementById('demo-username')
285
+ const p = document.getElementById('demo-password')
286
+ const out = document.getElementById('demo-key')
287
+ const row = document.getElementById('demo-key-row')
288
+ let timer = null
289
+ let inFlight = 0
290
+ function update () {
291
+ clearTimeout(timer)
292
+ const username = u.value.trim()
293
+ const password = p.value
294
+ if (!username || !password) {
295
+ row.classList.remove('computing')
296
+ out.textContent = '— enter credentials above —'
297
+ return
298
+ }
299
+ row.classList.add('computing')
300
+ out.textContent = 'computing PBKDF2…'
301
+ const me = ++inFlight
302
+ timer = setTimeout(async () => {
303
+ try {
304
+ const signer = new Signer(username, password, 1)
305
+ const { publicKey } = await signer.keysFor('chat')
306
+ if (me !== inFlight) return // user kept typing; stale
307
+ row.classList.remove('computing')
308
+ out.textContent = '0x' + bytesToHex(publicKey)
309
+ } catch (e) {
310
+ if (me !== inFlight) return
311
+ row.classList.remove('computing')
312
+ out.textContent = `error: ${e.message}`
313
+ }
314
+ }, 300)
315
+ }
316
+ u.addEventListener('input', update)
317
+ p.addEventListener('input', update)
318
+ </script>
114
319
 
115
320
  </body>
116
321
  </html>
@@ -0,0 +1,24 @@
1
+ <svg viewBox="0 0 680 680" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
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>