@bitpub/cli 2.0.3 → 2.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.
@@ -61,27 +61,99 @@ svg { flex-shrink: 0; }
61
61
  /* ── Layout shell ───────────────────────────────────── */
62
62
  #app { display: flex; flex-direction: column; height: 100vh; }
63
63
 
64
- /* ── Address bar (top) ──────────────────────────────── */
64
+ /* ── Browser shell: address bar + nav controls ───────
65
+ Treated like Chrome's omnibox. The URL bar is a single rounded pill
66
+ that holds either the read-only breadcrumb (default) or a typeable
67
+ text input (on click/focus). Back/forward/home sit outside the pill,
68
+ left-aligned, exactly like a desktop browser. */
65
69
  .addressbar {
66
- height: 48px; flex-shrink: 0;
67
- display: flex; align-items: center; gap: 12px;
70
+ height: 52px; flex-shrink: 0;
71
+ display: flex; align-items: center; gap: 10px;
68
72
  padding: 0 14px;
69
73
  background: var(--bg-card);
70
74
  border-bottom: 1px solid var(--border);
71
75
  }
72
- .wordmark { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 13px; color: var(--text); white-space: nowrap; cursor: pointer; }
73
- .wordmark .mark { width: 18px; height: 18px; background: var(--accent); border-radius: 4px; position: relative; }
74
- .wordmark .mark::after { content: ""; position: absolute; inset: 4px; border-radius: 1px; background: var(--bg-card); }
75
- .wordmark .name { letter-spacing: -.01em; }
76
- .wordmark .soft { color: var(--text-subtle); font-weight: 500; }
76
+ /* Left zone holds the wordmark + nav-controls and is sized to align
77
+ the URL bar's left edge with the main content panel's left edge
78
+ below (i.e. just past the sidebar's right edge). The total width
79
+ below was tuned for a 264px sidebar: 14 (addressbar left padding)
80
+ + 240 (this zone) + 10 (gap) = 264. If the sidebar width changes,
81
+ keep this in sync.
82
+
83
+ The wordmark is pinned to the far left and the nav-controls to
84
+ the far right so the back/forward/home buttons sit flush against
85
+ the URL bar's left edge. */
86
+ .addressbar-left {
87
+ display: flex; align-items: center;
88
+ justify-content: space-between;
89
+ width: 240px; flex-shrink: 0;
90
+ }
91
+ /* The wordmark uses the same two assets as the marketing nav so the
92
+ in-app browser has consistent brand identity with bitpub.io.
93
+ Sized down for the 52px addressbar: a 20px icon + 28px wordmark
94
+ leaves comfortable vertical padding. The wordmark .name / .soft /
95
+ .mark rules are kept around (as no-ops here) for any legacy callers
96
+ of the wordmark structure. */
97
+ .wordmark { display: inline-flex; align-items: center; gap: 6px; color: var(--text); white-space: nowrap; cursor: pointer; text-decoration: none; }
98
+ .wordmark .nav-icon { display: block; height: 20px; width: auto; flex-shrink: 0; }
99
+ .wordmark .nav-wordmark { display: block; height: 28px; width: auto; flex-shrink: 0; }
100
+
101
+ /* Nav controls (back / forward / home) */
102
+ .nav-controls { display: inline-flex; align-items: center; gap: 2px; }
103
+ .nav-btn {
104
+ width: 28px; height: 28px; flex-shrink: 0;
105
+ display: inline-flex; align-items: center; justify-content: center;
106
+ border: 0; border-radius: 50%;
107
+ background: transparent; color: var(--text-muted);
108
+ cursor: pointer; transition: background .1s, color .1s;
109
+ }
110
+ .nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
111
+ .nav-btn:active:not(:disabled) { background: var(--bg-inset); }
112
+ .nav-btn:disabled { color: var(--text-subtle); opacity: .45; cursor: default; }
113
+
114
+ /* URL bar — the single pill that holds breadcrumb or typeable input */
115
+ .url-bar {
116
+ flex: 1; min-width: 0;
117
+ display: flex; align-items: center; gap: 6px;
118
+ height: 34px;
119
+ padding: 0 4px 0 10px;
120
+ background: var(--bg-inset);
121
+ border: 1px solid transparent;
122
+ border-radius: 100px;
123
+ transition: background .12s, border-color .12s, box-shadow .12s;
124
+ cursor: text;
125
+ }
126
+ .url-bar:hover { background: var(--bg-hover); }
127
+ .url-bar.editing,
128
+ .url-bar:focus-within {
129
+ background: var(--bg-card);
130
+ border-color: var(--accent);
131
+ box-shadow: 0 0 0 3px var(--accent-soft);
132
+ }
77
133
 
78
- .mode-badge { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; padding: 3px 8px; border-radius: 100px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-muted); white-space: nowrap; }
79
- .mode-badge.local { background: var(--accent-bg); border-color: rgba(229,87,51,.3); color: var(--accent-hover); }
80
- .mode-badge .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-subtle); }
81
- .mode-badge.local .dot { background: var(--accent); }
134
+ /* Site-info chip on the left edge of the URL bar (replaces the standalone
135
+ mode badge). When local-decrypted we tint it with the lock color to
136
+ echo a browser's https indicator. */
137
+ .mode-badge {
138
+ width: 22px; height: 22px; flex-shrink: 0;
139
+ display: inline-flex; align-items: center; justify-content: center;
140
+ border-radius: 50%;
141
+ background: transparent;
142
+ color: var(--text-subtle);
143
+ cursor: default;
144
+ }
145
+ .mode-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-subtle); }
146
+ .mode-badge.local { color: var(--scope-private); }
147
+ .mode-badge.local .dot { background: var(--scope-private); }
82
148
 
83
- .address { flex: 1; min-width: 0; display: flex; align-items: center; gap: 2px; font-family: var(--mono); font-size: 12px; color: var(--text-muted); overflow: hidden; }
84
- .address .seg { padding: 3px 6px; border-radius: var(--radius-sm); cursor: pointer; color: var(--accent); white-space: nowrap; transition: background .1s, color .1s; }
149
+ /* Breadcrumb (display mode of the URL bar) */
150
+ .address {
151
+ flex: 1; min-width: 0;
152
+ display: flex; align-items: center; gap: 2px;
153
+ font-family: var(--mono); font-size: 12.5px; color: var(--text-muted);
154
+ overflow: hidden; white-space: nowrap;
155
+ }
156
+ .address .seg { padding: 2px 6px; border-radius: var(--radius-sm); cursor: pointer; color: var(--accent); transition: background .1s, color .1s; }
85
157
  .address .seg:hover { background: var(--accent-soft); }
86
158
  .address .seg.scheme { color: var(--text-subtle); cursor: default; font-weight: 500; }
87
159
  .address .seg.scheme:hover { background: transparent; }
@@ -89,39 +161,34 @@ svg { flex-shrink: 0; }
89
161
  .address .seg.current:hover { background: transparent; }
90
162
  .address .sep { color: var(--text-subtle); padding: 0 1px; user-select: none; }
91
163
  .address .glob { color: var(--text-subtle); font-weight: 500; padding-left: 4px; }
92
-
93
- .live { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--text-muted); white-space: nowrap; padding: 3px 8px; border-radius: 100px; }
94
- .live .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--fresh-7d); position: relative; }
95
- .live.pulse .dot { background: var(--accent); }
96
- .live.pulse .dot::after {
97
- content: ""; position: absolute; inset: -4px; border-radius: 50%;
98
- background: var(--accent); opacity: .25;
99
- animation: pulse 1.4s ease-out infinite;
100
- }
101
- @keyframes pulse {
102
- 0% { transform: scale(.6); opacity: .35; }
103
- 100% { transform: scale(2.2); opacity: 0; }
104
- }
105
-
106
- .search-box { position: relative; width: 240px; }
107
- .search-box input { width: 100%; background: var(--bg-inset); border: 1px solid transparent; border-radius: var(--radius); padding: 5px 12px 5px 30px; color: var(--text); font-size: 12.5px; font-family: var(--font); outline: none; transition: background .15s, border-color .15s; }
108
- .search-box input::placeholder { color: var(--text-subtle); }
109
- .search-box input:focus { background: var(--bg-card); border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
110
- .search-box svg { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); color: var(--text-subtle); }
111
- .search-box .kbd { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-family: var(--mono); font-size: 10px; color: var(--text-subtle); border: 1px solid var(--border); background: var(--bg-card); border-radius: 3px; padding: 0 4px; pointer-events: none; line-height: 14px; }
112
- .search-box input:focus ~ .kbd { display: none; }
113
-
114
- /* ── Filter strip ───────────────────────────────────── */
115
- .filter-strip {
116
- height: 38px; flex-shrink: 0;
117
- display: flex; align-items: center; gap: 14px;
118
- padding: 0 14px;
119
- background: var(--bg-canvas);
120
- border-bottom: 1px solid var(--border);
121
- overflow-x: auto;
122
- }
123
- .filter-strip::-webkit-scrollbar { display: none; }
124
- .filter-label { font-size: 11px; color: var(--text-subtle); text-transform: uppercase; letter-spacing: .05em; font-weight: 600; white-space: nowrap; }
164
+ .address .placeholder { color: var(--text-subtle); font-style: italic; font-family: var(--font); font-size: 12.5px; }
165
+
166
+ /* Typeable URL input (edit mode of the URL bar) */
167
+ .address-input {
168
+ flex: 1; min-width: 0;
169
+ height: 26px;
170
+ padding: 0 6px;
171
+ background: transparent; border: 0; outline: none;
172
+ font-family: var(--mono); font-size: 12.5px; color: var(--text);
173
+ }
174
+ .address-input::placeholder { color: var(--text-subtle); font-family: var(--font); font-style: italic; }
175
+
176
+ .address-edit-btn,
177
+ .address-copy-btn {
178
+ width: 24px; height: 24px; flex-shrink: 0;
179
+ display: inline-flex; align-items: center; justify-content: center;
180
+ border: 0; border-radius: 50%;
181
+ background: transparent; color: var(--text-subtle);
182
+ cursor: pointer; transition: background .1s, color .1s;
183
+ }
184
+ .address-edit-btn { margin-left: auto; }
185
+ .address-edit-btn:hover,
186
+ .address-copy-btn:hover { background: var(--bg-hover); color: var(--text); }
187
+ .url-bar.editing .address-edit-btn,
188
+ .url-bar.editing .address-copy-btn { display: none; }
189
+ .address-copy-btn.copied { background: var(--accent-bg); color: var(--accent); }
190
+
191
+ /* ── Tag pills (reused in slice view for tag chips) ────── */
125
192
  .pill-group { display: inline-flex; gap: 4px; }
126
193
  .pill {
127
194
  display: inline-flex; align-items: center; gap: 5px;
@@ -141,10 +208,6 @@ svg { flex-shrink: 0; }
141
208
  .pill.tag .x { color: var(--text-subtle); margin-left: 2px; font-weight: 700; }
142
209
  .pill.tag:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
143
210
  .pill.tag:hover .x { color: #fff; }
144
- .filter-divider { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }
145
- .filter-count { margin-left: auto; font-size: 11.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
146
- .filter-count .n { color: var(--text); font-weight: 600; }
147
-
148
211
  /* ── Body layout ────────────────────────────────────── */
149
212
  .layout { display: flex; flex: 1; min-height: 0; }
150
213
  #sidebar { width: 264px; min-width: 264px; background: var(--bg-card); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
@@ -385,21 +448,6 @@ svg { flex-shrink: 0; }
385
448
  }
386
449
 
387
450
  /* ── Namespace panel ────────────────────────────────── */
388
- .hcu-hero {
389
- display: flex; align-items: center; gap: 10px;
390
- padding: 10px 14px;
391
- background: var(--bg-card); border: 1px solid var(--border);
392
- border-radius: var(--radius);
393
- font-family: var(--mono); font-size: 12.5px;
394
- margin-bottom: 12px;
395
- }
396
- .hcu-hero .lock { color: var(--scope-private); }
397
- .hcu-hero .uri { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
398
- .hcu-hero .uri .scheme { color: var(--text-subtle); }
399
- .hcu-hero .uri .glob { color: var(--text-subtle); padding-left: 2px; }
400
- .hcu-hero .copy-btn { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; font-size: 11px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-card); color: var(--text-muted); cursor: pointer; font-family: var(--font); transition: background .1s, color .1s; }
401
- .hcu-hero .copy-btn:hover { background: var(--bg-hover); color: var(--text); }
402
-
403
451
  .stats-strip { display: flex; align-items: center; gap: 16px; padding: 0 2px 12px; font-size: 12px; color: var(--text-muted); flex-wrap: wrap; }
404
452
  .stats-strip .s-item { display: inline-flex; align-items: center; gap: 5px; }
405
453
  .stats-strip .s-item .n { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
@@ -781,6 +829,48 @@ svg { flex-shrink: 0; }
781
829
  /* Subtle stream subtitle */
782
830
  .stream-sub { font-size: 11.5px; color: var(--text-subtle); margin: -6px 0 10px 2px; font-weight: 400; letter-spacing: 0; text-transform: none; }
783
831
 
832
+ /* ── Sandboxed app frame (slice view, text/html payloads) ────────────
833
+ When a slice's content type is HTML and the scope is private/group,
834
+ the content dispatcher renders it inside a sandboxed iframe. The
835
+ frame fills the blob container's horizontal space and grows to a
836
+ workable default height (the app can be taller; the user scrolls
837
+ the main pane to follow). */
838
+ .app-frame {
839
+ display: block;
840
+ width: 100%;
841
+ min-height: 480px;
842
+ border: 0;
843
+ background: var(--bg-page);
844
+ }
845
+
846
+ .app-badge {
847
+ display: inline-flex; align-items: center; gap: 4px;
848
+ font-size: 10.5px; font-weight: 600;
849
+ padding: 2px 8px;
850
+ border: 1px solid rgba(124, 58, 237, .25);
851
+ background: rgba(124, 58, 237, .06);
852
+ color: var(--scope-private);
853
+ border-radius: 100px;
854
+ white-space: nowrap;
855
+ cursor: help;
856
+ }
857
+ .app-badge svg { color: var(--scope-private); }
858
+
859
+ .app-public-note {
860
+ display: flex; align-items: flex-start; gap: 10px;
861
+ padding: 10px 14px;
862
+ background: var(--bg-canvas);
863
+ border-bottom: 1px solid var(--border-soft);
864
+ font-size: 12px; color: var(--text-muted);
865
+ line-height: 1.5;
866
+ }
867
+ .app-public-note svg { color: var(--text-subtle); flex-shrink: 0; margin-top: 1px; }
868
+ .app-public-note code {
869
+ font-family: var(--mono); font-size: 11.5px;
870
+ background: var(--bg-card); padding: 1px 5px; border-radius: 3px;
871
+ border: 1px solid var(--border-soft); color: var(--text);
872
+ }
873
+
784
874
  .hidden { display: none !important; }
785
875
  </style>
786
876
  </head>
@@ -799,25 +889,45 @@ svg { flex-shrink: 0; }
799
889
  </div>
800
890
  </div>
801
891
 
802
- <!-- Address bar -->
892
+ <!-- Address bar (browser-shell layout) -->
803
893
  <header class="addressbar">
804
- <div class="wordmark" onclick="goRoot()">
805
- <span class="mark"></span>
806
- <span class="name">bitpub</span>
894
+ <!-- Left zone: wordmark + nav controls. Sized to match the sidebar
895
+ width below so the URL bar starts at the same x as the main
896
+ content panel. -->
897
+ <div class="addressbar-left">
898
+ <div class="wordmark" onclick="goRoot()" title="bitpub home" role="link" aria-label="bitpub home">
899
+ <img class="nav-icon" src="/assets/tollbit-icon.png" alt="" aria-hidden="true">
900
+ <img class="nav-wordmark" src="/assets/BITPUB-dark.png" alt="BitPub">
901
+ </div>
902
+
903
+ <div class="nav-controls">
904
+ <button id="nav-back" class="nav-btn" onclick="goBack()" title="Back" disabled>
905
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M9.78 12.78a.75.75 0 01-1.06 0L4.47 8.53a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L6.06 8l3.72 3.72a.75.75 0 010 1.06z"/></svg>
906
+ </button>
907
+ <button id="nav-forward" class="nav-btn" onclick="goForward()" title="Forward" disabled>
908
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 11-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z"/></svg>
909
+ </button>
910
+ <button id="nav-home" class="nav-btn" onclick="goRoot()" title="Home">
911
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6.906.664a1.749 1.749 0 012.187 0l5.25 4.2c.415.332.657.835.657 1.367v7.019A1.75 1.75 0 0113.25 15h-3.5a.75.75 0 01-.75-.75V9.5h-2v4.75a.75.75 0 01-.75.75h-3.5A1.75 1.75 0 011 13.25V6.23c0-.531.242-1.034.657-1.366l5.25-4.2zm1.25 1.171a.25.25 0 00-.312 0l-5.25 4.2a.25.25 0 00-.094.196v7.019c0 .138.112.25.25.25H5v-4.75a.75.75 0 01.75-.75h3.5a.75.75 0 01.75.75v4.75h2.75a.25.25 0 00.25-.25V6.23a.25.25 0 00-.094-.195l-5.25-4.2z"/></svg>
912
+ </button>
913
+ </div>
807
914
  </div>
808
- <div id="mode-badge" class="mode-badge"><span class="dot"></span><span class="label">—</span></div>
809
- <div id="address" class="address"></div>
810
- <div id="live" class="live"><span class="dot"></span><span class="label">—</span></div>
811
- <div class="search-box">
812
- <input id="search" type="text" placeholder="Search slices..." oninput="handleSearch(this.value)">
813
- <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M10.68 11.74a6 6 0 01-7.922-8.982 6 6 0 018.982 7.922l3.04 3.04a.75.75 0 11-1.06 1.06l-3.04-3.04zM11.5 7a4.499 4.499 0 11-8.998 0A4.499 4.499 0 0111.5 7z"/></svg>
814
- <span class="kbd">/</span>
915
+
916
+ <div class="url-bar" id="url-bar">
917
+ <span id="mode-badge" class="mode-badge" title="Connection mode"><span class="dot"></span></span>
918
+ <div id="address" class="address" onclick="enterAddressEdit()"></div>
919
+ <input id="address-input" class="address-input hidden" type="text"
920
+ spellcheck="false" autocomplete="off"
921
+ placeholder="Enter a bitpub:// URL, /path, or search">
922
+ <button id="address-edit-btn" class="address-edit-btn" onclick="enterAddressEdit()" title="Edit address">
923
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81l-6.286 6.287a.25.25 0 00-.064.108l-.558 1.953 1.953-.558a.249.249 0 00.108-.064L11.189 6.25z"/></svg>
924
+ </button>
925
+ <button id="address-copy-btn" class="address-copy-btn" onclick="copyAddressUri(this)" title="Copy URI">
926
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"/><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"/></svg>
927
+ </button>
815
928
  </div>
816
929
  </header>
817
930
 
818
- <!-- Filter strip -->
819
- <div id="filter-strip" class="filter-strip"></div>
820
-
821
931
  <!-- Body -->
822
932
  <div class="layout">
823
933
  <aside id="sidebar">
@@ -873,6 +983,11 @@ const ICON = {
873
983
  chev: '<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor"><path d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z"/></svg>',
874
984
  lock: '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M4 4v2h-.25A1.75 1.75 0 002 7.75v5.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0014 13.25v-5.5A1.75 1.75 0 0012.25 6H12V4a4 4 0 00-8 0zm6.5 2V4a2.5 2.5 0 00-5 0v2h5z"/></svg>',
875
985
  copy: '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"/><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"/></svg>',
986
+ // Same copy glyph reused as the icon for the address-bar Copy URI
987
+ // button; kept under a distinct key so its dimensions can drift
988
+ // (currently identical to ICON.copy).
989
+ copyLg: '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"/><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"/></svg>',
990
+ check: '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.751.751 0 01.018-1.042.751.751 0 011.042-.018L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/></svg>',
876
991
  hex: '<svg class="hex" viewBox="0 0 12 12" fill="currentColor"><path d="M6 0L11.196 3V9L6 12L0.804 9V3L6 0Z"/></svg>',
877
992
  search: '<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M10.68 11.74a6 6 0 01-7.922-8.982 6 6 0 018.982 7.922l3.04 3.04a.75.75 0 11-1.06 1.06l-3.04-3.04z"/></svg>',
878
993
  info: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M0 8a8 8 0 1116 0A8 8 0 010 8zm8-6.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM6.5 7.75A.75.75 0 017.25 7h1a.75.75 0 01.75.75v2.75h.25a.75.75 0 010 1.5h-2a.75.75 0 010-1.5h.25v-2h-.25a.75.75 0 01-.75-.75zM8 6a1 1 0 100-2 1 1 0 000 2z"/></svg>',
@@ -887,6 +1002,7 @@ async function boot() {
887
1002
  const data = await fetchData();
888
1003
  if (!data) return;
889
1004
  ingestData(data);
1005
+ maybeOpenWelcomeSlice();
890
1006
  renderAll();
891
1007
  setInterval(pollData, 30000);
892
1008
  document.addEventListener('visibilitychange', () => {
@@ -913,18 +1029,43 @@ function findWelcomeSlice(slices) {
913
1029
  }
914
1030
 
915
1031
  function shouldShowWelcomePanel(slices) {
916
- // Forced via URL useful for re-opening the panel after dismissal,
917
- // or for install.sh which always opens with ?welcome=1 set.
1032
+ // The Welcome slice is now itself an HTML app (the entire welcome
1033
+ // experience is rendered from inside the slice's sandboxed iframe).
1034
+ // So we only show this fallback panel during the install.sh boot race:
1035
+ // the page was opened with ?welcome=1 *before* the welcome push landed
1036
+ // in the local cache. `maybeOpenWelcomeSlice()` auto-navigates to the
1037
+ // slice as soon as it appears, at which point this panel disappears
1038
+ // and the slice's own app takes over.
918
1039
  const params = new URLSearchParams(location.search);
919
- if (params.get('welcome') === '1') return true;
920
-
921
- // Auto: the user has only the auto-saved Welcome slice (fresh install).
922
- // We compare against S.slices (total), not the filtered list, so a
923
- // search/filter doesn't accidentally trigger the welcome state.
924
- const total = S.slices;
925
- if (total.length === 1 && WELCOME_RE.test(total[0].hcu)) return true;
926
-
927
- return false;
1040
+ if (params.get('welcome') !== '1') return false;
1041
+ const all = slices || S.slices;
1042
+ const hasWelcome = (all || []).some(s => WELCOME_RE.test(s.hcu));
1043
+ return !hasWelcome;
1044
+ }
1045
+
1046
+ /**
1047
+ * One-shot navigator: when the page is loaded with `?welcome=1` (the URL
1048
+ * install.sh opens) and the Welcome slice is already in the cache, jump
1049
+ * straight to the slice view so the user lands in the welcome HTML app
1050
+ * instead of the recent-writes overview. Idempotent — runs at most once
1051
+ * per page load.
1052
+ */
1053
+ function maybeOpenWelcomeSlice() {
1054
+ if (S.welcomeOpened) return;
1055
+ const params = new URLSearchParams(location.search);
1056
+ if (params.get('welcome') !== '1') return;
1057
+ const wel = findWelcomeSlice();
1058
+ if (!wel) return;
1059
+ S.welcomeOpened = true;
1060
+ // Mirror selectSlice() without an extra renderAll — boot is about to
1061
+ // call renderAll itself.
1062
+ const { scope } = scopeOf(wel.hcu);
1063
+ S.view = 'slice';
1064
+ S.currentScope = scope;
1065
+ S.currentPath = pathOf(wel.hcu);
1066
+ S.selectedHcu = wel.hcu;
1067
+ S.searchQuery = '';
1068
+ S.raw = false;
928
1069
  }
929
1070
 
930
1071
  function renderWelcomePanel() {
@@ -1045,13 +1186,22 @@ async function pollData() {
1045
1186
  triggerLivePulse();
1046
1187
  setTimeout(() => { S.newArrivals = new Set(); }, 2500);
1047
1188
  }
1189
+ // Boot-race recovery: if install.sh opened us with ?welcome=1 before
1190
+ // the welcome push had synced down, the slice may show up here on the
1191
+ // first poll. Take the user to it as soon as it appears.
1192
+ maybeOpenWelcomeSlice();
1048
1193
  renderAll();
1049
1194
  }
1050
1195
 
1051
1196
  function triggerLivePulse() {
1052
- $('live').classList.add('pulse');
1197
+ // Live indicator was removed from the address bar; keep this as a
1198
+ // no-op so the polling loop doesn't crash and so we can re-introduce
1199
+ // a freshness indicator later without re-wiring callers.
1200
+ const el = $('live');
1201
+ if (!el) return;
1202
+ el.classList.add('pulse');
1053
1203
  clearTimeout(triggerLivePulse._t);
1054
- triggerLivePulse._t = setTimeout(() => $('live').classList.remove('pulse'), 4000);
1204
+ triggerLivePulse._t = setTimeout(() => el.classList.remove('pulse'), 4000);
1055
1205
  }
1056
1206
 
1057
1207
  function showAuth() {
@@ -1066,7 +1216,7 @@ async function submitAuth() {
1066
1216
  sessionStorage.setItem('bitpub_key', key);
1067
1217
  $('auth-error').classList.add('hidden');
1068
1218
  const data = await fetchData();
1069
- if (data) { $('auth-overlay').classList.add('hidden'); ingestData(data); renderAll(); }
1219
+ if (data) { $('auth-overlay').classList.add('hidden'); ingestData(data); maybeOpenWelcomeSlice(); renderAll(); }
1070
1220
  else { $('auth-error').textContent = 'Invalid API key'; $('auth-error').classList.remove('hidden'); S.apiKey = null; sessionStorage.removeItem('bitpub_key'); }
1071
1221
  }
1072
1222
 
@@ -1252,30 +1402,29 @@ function clearFilter() {
1252
1402
  function renderAll() {
1253
1403
  renderModeBadge();
1254
1404
  renderAddress();
1255
- renderLive();
1256
- renderFilterStrip();
1257
1405
  renderTree();
1258
1406
  renderPanel();
1259
1407
  $('clear-filter-btn').classList.toggle('hidden', !filterActive());
1260
1408
  }
1261
1409
 
1262
1410
  function renderModeBadge() {
1411
+ // The mode badge now lives inside the URL bar as a dot-only indicator
1412
+ // (the verbose label moved into a tooltip). Tinted private-purple in
1413
+ // local-decrypted mode to echo a browser's https indicator.
1263
1414
  const el = $('mode-badge');
1264
1415
  if (S.mode === 'local') {
1265
1416
  el.className = 'mode-badge local';
1266
- el.querySelector('.label').textContent = 'Local · decrypted';
1417
+ el.setAttribute('title', 'Local cache · private slices decrypted on this machine');
1267
1418
  } else {
1268
1419
  el.className = 'mode-badge';
1269
- el.querySelector('.label').textContent = 'Remote';
1420
+ el.setAttribute('title', 'Remote · private slices appear as ciphertext');
1270
1421
  }
1271
1422
  }
1272
1423
 
1273
1424
  function renderAddress() {
1274
1425
  const el = $('address');
1275
1426
  let html = '<span class="seg scheme">bitpub://</span>';
1276
- if (S.view === 'overview') {
1277
- html += '<span class="glob">**</span>';
1278
- } else if (S.currentScope) {
1427
+ if (S.currentScope) {
1279
1428
  html += `<span class="sep">/</span><span class="seg" onclick="navigateTo('${escAttr(S.currentScope)}', [])">${esc(S.currentScope)}</span>`;
1280
1429
  const isSlice = S.view === 'slice';
1281
1430
  S.currentPath.forEach((seg, i) => {
@@ -1290,15 +1439,18 @@ function renderAddress() {
1290
1439
  html += `<span class="${className}" onclick="navigateTo('${escAttr(S.currentScope)}', ${JSON.stringify(subPath).replace(/"/g, '&quot;')})">${esc(seg)}</span>`;
1291
1440
  }
1292
1441
  });
1293
- if (!isSlice) html += '<span class="glob">/**</span>';
1294
1442
  }
1295
1443
  el.innerHTML = html;
1296
1444
  }
1297
1445
 
1298
1446
  function renderLive() {
1447
+ // Live indicator was removed from the address bar in the browser-shell
1448
+ // refactor; this is a no-op kept around so legacy callers don't crash.
1299
1449
  const el = $('live');
1450
+ if (!el) return;
1300
1451
  const label = S.lastUpdated ? `updated ${timeAgo(S.lastUpdated)}` : 'no activity';
1301
- el.querySelector('.label').textContent = label;
1452
+ const lbl = el.querySelector('.label');
1453
+ if (lbl) lbl.textContent = label;
1302
1454
  }
1303
1455
 
1304
1456
  function renderFilterStrip() {
@@ -1481,6 +1633,233 @@ function onTreeFolderClick(ev, scope, path) {
1481
1633
  navigateTo(scope, path);
1482
1634
  }
1483
1635
 
1636
+ /* ══════════════════════════════════════════════════════
1637
+ Address bar — typeable URL input + history stack
1638
+
1639
+ Navigation history is browser-style: pushHistory() snapshots the
1640
+ minimum nav state needed to re-render the right view, and back/
1641
+ forward replay snapshots without re-pushing. Initial load lands on
1642
+ "overview" with history[0] = root, so Back from the first navigation
1643
+ takes the user home rather than dead-ending.
1644
+ ══════════════════════════════════════════════════════ */
1645
+ S.history = [];
1646
+ S.historyIndex = -1;
1647
+ S.historyMuted = false; // true while replaying back/forward
1648
+
1649
+ function snapshotNav() {
1650
+ return {
1651
+ view: S.view,
1652
+ currentScope: S.currentScope,
1653
+ currentPath: S.currentPath.slice(),
1654
+ selectedHcu: S.selectedHcu,
1655
+ searchQuery: S.searchQuery,
1656
+ };
1657
+ }
1658
+
1659
+ function applyNav(snap) {
1660
+ S.view = snap.view;
1661
+ S.currentScope = snap.currentScope;
1662
+ S.currentPath = snap.currentPath.slice();
1663
+ S.selectedHcu = snap.selectedHcu;
1664
+ S.searchQuery = snap.searchQuery || '';
1665
+ S.raw = false;
1666
+ }
1667
+
1668
+ function navSnapEq(a, b) {
1669
+ return a.view === b.view
1670
+ && a.currentScope === b.currentScope
1671
+ && arrayEq(a.currentPath, b.currentPath)
1672
+ && a.selectedHcu === b.selectedHcu
1673
+ && (a.searchQuery || '') === (b.searchQuery || '');
1674
+ }
1675
+
1676
+ function pushHistory() {
1677
+ if (S.historyMuted) return;
1678
+ if (S.historyIndex < S.history.length - 1) {
1679
+ // Branching off a back-state truncates forward history, same as a browser.
1680
+ S.history = S.history.slice(0, S.historyIndex + 1);
1681
+ }
1682
+ const snap = snapshotNav();
1683
+ const top = S.history[S.history.length - 1];
1684
+ if (top && navSnapEq(top, snap)) return; // collapse no-op pushes
1685
+ S.history.push(snap);
1686
+ S.historyIndex = S.history.length - 1;
1687
+ updateNavButtons();
1688
+ }
1689
+
1690
+ function updateNavButtons() {
1691
+ const back = $('nav-back');
1692
+ const fwd = $('nav-forward');
1693
+ if (back) back.disabled = S.historyIndex <= 0;
1694
+ if (fwd) fwd.disabled = S.historyIndex >= S.history.length - 1;
1695
+ }
1696
+
1697
+ function goBack() {
1698
+ if (S.historyIndex <= 0) return;
1699
+ S.historyIndex--;
1700
+ S.historyMuted = true;
1701
+ applyNav(S.history[S.historyIndex]);
1702
+ renderAll();
1703
+ updateNavButtons();
1704
+ S.historyMuted = false;
1705
+ }
1706
+
1707
+ function goForward() {
1708
+ if (S.historyIndex >= S.history.length - 1) return;
1709
+ S.historyIndex++;
1710
+ S.historyMuted = true;
1711
+ applyNav(S.history[S.historyIndex]);
1712
+ renderAll();
1713
+ updateNavButtons();
1714
+ S.historyMuted = false;
1715
+ }
1716
+
1717
+ // Pick the "your private root" scope for leading-slash resolution. We
1718
+ // take the most common private scope in the local cache. If the user
1719
+ // has no private slices yet, leading-slash input falls through to search.
1720
+ function ownerScope() {
1721
+ const counts = {};
1722
+ for (const s of S.slices) {
1723
+ const sc = scopeOf(s.hcu);
1724
+ if (sc.type === 'private') counts[sc.scope] = (counts[sc.scope] || 0) + 1;
1725
+ }
1726
+ let best = null, bestN = 0;
1727
+ for (const k of Object.keys(counts)) {
1728
+ if (counts[k] > bestN) { best = k; bestN = counts[k]; }
1729
+ }
1730
+ return best;
1731
+ }
1732
+
1733
+ // Build the canonical bitpub:// string for the current view, used as
1734
+ // the initial value of the typeable input and the target of Copy URI.
1735
+ // We don't append wildcard suffixes here — pasting the URL back into
1736
+ // the bar still works because submitAddress strips trailing '*' chars
1737
+ // before resolving.
1738
+ function currentAddressString() {
1739
+ if (S.view === 'overview' || !S.currentScope) return 'bitpub://';
1740
+ const path = S.currentPath.join('/');
1741
+ return `bitpub://${S.currentScope}` + (path ? '/' + path : '');
1742
+ }
1743
+
1744
+ function enterAddressEdit() {
1745
+ const bar = $('url-bar');
1746
+ const inp = $('address-input');
1747
+ if (!bar || !inp) return;
1748
+ bar.classList.add('editing');
1749
+ $('address').classList.add('hidden');
1750
+ inp.classList.remove('hidden');
1751
+ inp.value = currentAddressString();
1752
+ // setTimeout lets the click-that-triggered-edit finish before we steal focus.
1753
+ setTimeout(() => { inp.focus(); inp.select(); }, 0);
1754
+ }
1755
+
1756
+ function exitAddressEdit() {
1757
+ const bar = $('url-bar');
1758
+ const inp = $('address-input');
1759
+ if (!bar || !inp) return;
1760
+ bar.classList.remove('editing');
1761
+ inp.classList.add('hidden');
1762
+ $('address').classList.remove('hidden');
1763
+ }
1764
+
1765
+ // Copies the canonical bitpub:// URL for whatever the user is currently
1766
+ // viewing. Briefly swaps the button's icon for a checkmark as feedback;
1767
+ // no-ops on the overview where the URL would just be "bitpub://".
1768
+ function copyAddressUri(btn) {
1769
+ const uri = currentAddressString();
1770
+ if (!uri || uri === 'bitpub://') return;
1771
+ const finish = () => {
1772
+ if (!btn) return;
1773
+ btn.classList.add('copied');
1774
+ btn.innerHTML = ICON.check;
1775
+ clearTimeout(copyAddressUri._t);
1776
+ copyAddressUri._t = setTimeout(() => {
1777
+ btn.classList.remove('copied');
1778
+ btn.innerHTML = ICON.copyLg;
1779
+ }, 1200);
1780
+ };
1781
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1782
+ navigator.clipboard.writeText(uri).then(finish).catch(() => {
1783
+ // Clipboard API can fail in non-secure contexts; fall back to
1784
+ // a textarea-based copy so the button still gives feedback.
1785
+ try {
1786
+ const ta = document.createElement('textarea');
1787
+ ta.value = uri; ta.style.position = 'fixed'; ta.style.opacity = '0';
1788
+ document.body.appendChild(ta); ta.select();
1789
+ document.execCommand('copy');
1790
+ document.body.removeChild(ta);
1791
+ finish();
1792
+ } catch (_) { /* swallow */ }
1793
+ });
1794
+ }
1795
+ }
1796
+
1797
+ function onAddressKeyDown(e) {
1798
+ if (e.key === 'Enter') {
1799
+ e.preventDefault();
1800
+ submitAddress($('address-input').value);
1801
+ } else if (e.key === 'Escape') {
1802
+ e.preventDefault();
1803
+ exitAddressEdit();
1804
+ }
1805
+ }
1806
+
1807
+ /**
1808
+ * Resolve a typed URL-bar string and navigate. Accepts:
1809
+ * - full `bitpub://...` URLs (verbatim)
1810
+ * - leading-slash paths (resolves against your private root)
1811
+ * - short names that match a slice tail in cache
1812
+ * - any other text → falls through to search
1813
+ *
1814
+ * Trailing wildcards (`**`, `*`) and slashes are stripped before
1815
+ * resolution; that's a convenience for users round-tripping pasted
1816
+ * breadcrumb strings from `currentAddressString()`.
1817
+ */
1818
+ function submitAddress(raw) {
1819
+ const value = (raw || '').trim();
1820
+ exitAddressEdit();
1821
+ if (!value || value === 'bitpub://') { goRoot(); return; }
1822
+
1823
+ const fullMatch = value.match(/^bitpub:\/\/([^/]+)(?:\/(.*))?$/);
1824
+ if (fullMatch) {
1825
+ const scope = fullMatch[1];
1826
+ const rest = (fullMatch[2] || '').replace(/\*+$/, '').replace(/\/+$/, '');
1827
+ const segs = rest ? rest.split('/').filter(Boolean) : [];
1828
+ const full = `bitpub://${scope}${segs.length ? '/' + segs.join('/') : ''}`;
1829
+ const exact = S.slices.find(s => s.hcu === full);
1830
+ if (exact) { selectSlice(exact.hcu); return; }
1831
+ navigateTo(scope, segs);
1832
+ return;
1833
+ }
1834
+
1835
+ if (value.startsWith('/')) {
1836
+ const owner = ownerScope();
1837
+ if (owner) {
1838
+ const rest = value.replace(/^\/+/, '').replace(/\*+$/, '').replace(/\/+$/, '');
1839
+ const segs = rest ? rest.split('/').filter(Boolean) : [];
1840
+ const full = `bitpub://${owner}${segs.length ? '/' + segs.join('/') : ''}`;
1841
+ const exact = S.slices.find(s => s.hcu === full);
1842
+ if (exact) { selectSlice(exact.hcu); return; }
1843
+ navigateTo(owner, segs);
1844
+ return;
1845
+ }
1846
+ }
1847
+
1848
+ // Try to match a slice by its URL tail (the "load forgivingly" pattern).
1849
+ const tail = value.replace(/^\/+/, '');
1850
+ const exactTail = S.slices.find(s => s.hcu.endsWith('/' + tail));
1851
+ if (exactTail) { selectSlice(exactTail.hcu); return; }
1852
+
1853
+ // Fall through: treat as search.
1854
+ S.view = 'overview';
1855
+ S.currentScope = null;
1856
+ S.currentPath = [];
1857
+ S.selectedHcu = null;
1858
+ S.searchQuery = value;
1859
+ pushHistory();
1860
+ renderAll();
1861
+ }
1862
+
1484
1863
  /* ══════════════════════════════════════════════════════
1485
1864
  Navigation
1486
1865
  ══════════════════════════════════════════════════════ */
@@ -1490,7 +1869,7 @@ function goRoot() {
1490
1869
  S.currentPath = [];
1491
1870
  S.selectedHcu = null;
1492
1871
  S.searchQuery = '';
1493
- $('search').value = '';
1872
+ pushHistory();
1494
1873
  renderAll();
1495
1874
  }
1496
1875
 
@@ -1499,6 +1878,8 @@ function navigateTo(scope, path) {
1499
1878
  S.currentScope = scope;
1500
1879
  S.currentPath = Array.isArray(path) ? path : [];
1501
1880
  S.selectedHcu = null;
1881
+ S.searchQuery = '';
1882
+ pushHistory();
1502
1883
  renderAll();
1503
1884
  }
1504
1885
 
@@ -1510,7 +1891,9 @@ function selectSlice(hcu) {
1510
1891
  S.currentScope = scope;
1511
1892
  S.currentPath = pathOf(hcu);
1512
1893
  S.selectedHcu = hcu;
1894
+ S.searchQuery = '';
1513
1895
  S.raw = false;
1896
+ pushHistory();
1514
1897
  renderAll();
1515
1898
  }
1516
1899
 
@@ -1829,10 +2212,7 @@ function renderNamespace() {
1829
2212
  node = node.children[seg];
1830
2213
  }
1831
2214
 
1832
- const pathStr = S.currentPath.length ? '/' + S.currentPath.join('/') : '';
1833
- const fullUri = `bitpub://${scope}${pathStr}/**`;
1834
2215
  const { type } = scopeOf(`bitpub://${scope}`);
1835
- const isPriv = type === 'private';
1836
2216
 
1837
2217
  // Collect slices within this subtree
1838
2218
  const subSlices = [];
@@ -1843,13 +2223,9 @@ function renderNamespace() {
1843
2223
 
1844
2224
  let html = '<div class="panel">';
1845
2225
 
1846
- // Address hero
1847
- html += `<div class="hcu-hero">
1848
- <span class="scope-dot ${type}"></span>
1849
- ${isPriv ? `<span class="lock">${ICON.lock}</span>` : ''}
1850
- <span class="uri"><span class="scheme">bitpub://</span>${esc(scope)}${esc(pathStr)}<span class="glob">/**</span></span>
1851
- <button class="copy-btn" onclick="copyText('${escAttr(fullUri)}', this)">${ICON.copy}<span>Copy</span></button>
1852
- </div>`;
2226
+ // (The URI + copy button used to live here as an `.hcu-hero`, but the
2227
+ // same info is now shown in the address bar at the top of the window
2228
+ // — keeping it in the panel was redundant.)
1853
2229
 
1854
2230
  // Stats strip
1855
2231
  const tagsInSubtree = new Set();
@@ -1954,13 +2330,9 @@ function renderSlice(sl) {
1954
2330
 
1955
2331
  let html = '<div class="panel blob-wrap">';
1956
2332
 
1957
- // Address hero chip
1958
- html += `<div class="hcu-hero">
1959
- <span class="scope-dot ${type}"></span>
1960
- ${isPriv ? `<span class="lock">${ICON.lock}</span>` : ''}
1961
- <span class="uri"><span class="scheme">bitpub://</span>${esc(sl.hcu.replace(/^bitpub:\/\//, ''))}</span>
1962
- <button class="copy-btn" onclick="copyText('${escAttr(sl.hcu)}', this)">${ICON.copy}<span>Copy URI</span></button>
1963
- </div>`;
2333
+ // (The URI + copy button used to live here as an `.hcu-hero`, but the
2334
+ // same info is now shown in the address bar at the top of the window
2335
+ // — keeping it in the panel was redundant.)
1964
2336
 
1965
2337
  // Meta strip — identity + mutability signal
1966
2338
  html += '<div class="blob-meta">';
@@ -1997,20 +2369,44 @@ function renderSlice(sl) {
1997
2369
  <span class="caveat">${ICON.info}<span>Next push to this address replaces this slice in place.</span></span>
1998
2370
  </div>`;
1999
2371
 
2000
- // Blob container
2372
+ // Content-type dispatcher. The payload's declared format is the
2373
+ // truthiest signal; we fall back to lightweight content sniffing for
2374
+ // slices written before format declarations were standardised.
2375
+ const kind = detectContentKind(sl, content);
2376
+ // Trusted-only: render HTML inline (in a sandboxed iframe) only for
2377
+ // private + group scopes. Public HTML falls back to escaped source
2378
+ // until we ship the scoped bridge + permission model. This matches
2379
+ // the v1 cut from product discussion.
2380
+ const canRenderAsApp = kind === 'html' && (type === 'private' || type === 'group');
2381
+
2001
2382
  html += '<div class="blob-container has-banner">';
2002
2383
  html += '<div class="blob-toolbar">';
2003
2384
  html += '<div class="btn-group">';
2004
- html += `<button class="btn ${!S.raw ? 'active' : ''}" onclick="toggleRaw(false)">Preview</button>`;
2005
- html += `<button class="btn ${S.raw ? 'active' : ''}" onclick="toggleRaw(true)">Raw</button>`;
2385
+ const previewLabel = canRenderAsApp ? 'App' : 'Preview';
2386
+ const sourceLabel = (kind === 'html') ? 'Source' : 'Raw';
2387
+ html += `<button class="btn ${!S.raw ? 'active' : ''}" onclick="toggleRaw(false)">${previewLabel}</button>`;
2388
+ html += `<button class="btn ${S.raw ? 'active' : ''}" onclick="toggleRaw(true)">${sourceLabel}</button>`;
2006
2389
  html += '</div>';
2007
2390
  html += '<div class="spacer"></div>';
2391
+ if (canRenderAsApp && !S.raw) {
2392
+ html += `<span class="app-badge" title="HTML is rendered in a sandboxed iframe with no network or storage access">${ICON.lock}<span>sandboxed</span></span>`;
2393
+ }
2008
2394
  html += `<button class="btn" onclick="copyContent(this)">${ICON.copy}<span>Copy content</span></button>`;
2009
2395
  html += '</div>';
2396
+
2010
2397
  if (S.raw) {
2011
2398
  html += `<pre class="content-raw">${esc(content)}</pre>`;
2012
- } else {
2399
+ } else if (canRenderAsApp) {
2400
+ html += renderHtmlAppFrame(content);
2401
+ } else if (kind === 'html') {
2402
+ html += `<div class="app-public-note">${ICON.info}
2403
+ <div>HTML apps from <code>public:</code> addresses are shown as source until the public sandbox model lands. Apps in <code>private:</code> and <code>group:</code> scopes render inline.</div>
2404
+ </div>`;
2405
+ html += `<pre class="content-raw">${esc(content)}</pre>`;
2406
+ } else if (kind === 'markdown') {
2013
2407
  html += `<div class="content-body">${md(content)}</div>`;
2408
+ } else {
2409
+ html += `<pre class="content-raw">${esc(content)}</pre>`;
2014
2410
  }
2015
2411
  html += `<div class="blob-footer">${lines} line${lines !== 1 ? 's' : ''} · ${formatBytes(sizeBytes)} · ${esc(format)} · only the latest version is retained</div>`;
2016
2412
  html += '</div>';
@@ -2019,6 +2415,71 @@ function renderSlice(sl) {
2019
2415
  $('main').innerHTML = html;
2020
2416
  }
2021
2417
 
2418
+ /**
2419
+ * Content-kind detection for the slice dispatcher. Returns one of
2420
+ * 'html' | 'markdown' | 'text'
2421
+ * Order of signals (most authoritative first):
2422
+ * 1. Declared payload format (e.g. text/html, text/markdown)
2423
+ * 2. Slice address tail (.html / .md hints — common in Pack layouts)
2424
+ * 3. Content sniffing (doctype, leading <html>, leading #-heading)
2425
+ */
2426
+ function detectContentKind(sl, content) {
2427
+ const fmt = (sl.payload.format || '').toLowerCase();
2428
+ if (/html/.test(fmt)) return 'html';
2429
+ if (/markdown|^text\/md$|md(?:$|;)/.test(fmt)) return 'markdown';
2430
+
2431
+ const tail = (pathOf(sl.hcu).pop() || '').toLowerCase();
2432
+ if (/\.html?$/.test(tail)) return 'html';
2433
+ if (/\.md$|\.markdown$/.test(tail)) return 'markdown';
2434
+
2435
+ if (/^\s*(<!doctype\s+html|<html[\s>]|<body[\s>])/i.test(content)) return 'html';
2436
+ if (/^\s*#\s/m.test(content)) return 'markdown';
2437
+
2438
+ return 'text';
2439
+ }
2440
+
2441
+ /**
2442
+ * Render an HTML payload as a sandboxed app frame.
2443
+ *
2444
+ * Security floor (v1 trusted-only model):
2445
+ * - sandbox="allow-scripts" only. Notably NOT allow-same-origin, so
2446
+ * the iframe runs in a unique opaque origin: no parent DOM access,
2447
+ * no cookies, no localStorage carried over.
2448
+ * - Strict CSP injected into <head> via <meta http-equiv="...">.
2449
+ * default-src 'none' blocks all network — no fetch, no XHR, no
2450
+ * WebSocket, no images from arbitrary hosts. Inline scripts and
2451
+ * styles are permitted (the app's code is embedded in srcdoc).
2452
+ * - <base target="_blank"> so any link the app tries to follow opens
2453
+ * in a new tab instead of breaking the user out of the Browser.
2454
+ * - referrerpolicy="no-referrer" so even allowed sub-resources can't
2455
+ * leak the user's location.
2456
+ *
2457
+ * No bridge to bitpub is exposed yet — apps in v1 can render but cannot
2458
+ * read from or write to the user's namespaces. The bridge is the next
2459
+ * milestone and will be opt-in per Pack via a declared manifest.
2460
+ */
2461
+ function renderHtmlAppFrame(html) {
2462
+ const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; base-uri 'none'; form-action 'none'">`;
2463
+ const baseTag = `<base target="_blank">`;
2464
+ const headInject = `${csp}${baseTag}`;
2465
+
2466
+ let doc;
2467
+ if (/<head[\s>]/i.test(html)) {
2468
+ // Insert our policy at the very start of the existing <head> so it
2469
+ // applies before any of the app's own resources are referenced.
2470
+ doc = html.replace(/<head([\s>])/i, `<head$1${headInject}`);
2471
+ } else if (/<html[\s>]/i.test(html)) {
2472
+ doc = html.replace(/<html([\s>][^>]*)>/i, `<html$1><head>${headInject}</head>`);
2473
+ } else {
2474
+ doc = `<!doctype html><html><head>${headInject}</head><body>${html}</body></html>`;
2475
+ }
2476
+
2477
+ // srcdoc unescapes &, <, >, ", ' before parsing the inner document,
2478
+ // so we only need to escape what would otherwise break the attribute.
2479
+ const srcdoc = doc.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
2480
+ return `<iframe class="app-frame" sandbox="allow-scripts" referrerpolicy="no-referrer" srcdoc="${srcdoc}"></iframe>`;
2481
+ }
2482
+
2022
2483
  function renderWriteGuide(sl) {
2023
2484
  const addr = sl.hcu;
2024
2485
  const ver = sl.metadata.version || 1;
@@ -2243,13 +2704,22 @@ function timeAgo(ts) {
2243
2704
  Keyboard
2244
2705
  ══════════════════════════════════════════════════════ */
2245
2706
  document.addEventListener('keydown', e => {
2707
+ // `/` focuses the URL bar — same affordance as the old search box, now
2708
+ // it's the address bar. Ignored when the user is already typing.
2246
2709
  if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
2247
- e.preventDefault(); $('search').focus();
2710
+ e.preventDefault();
2711
+ enterAddressEdit();
2248
2712
  }
2713
+ // Browser-style Alt+Left / Alt+Right for back/forward.
2714
+ if (e.altKey && e.key === 'ArrowLeft') { e.preventDefault(); goBack(); }
2715
+ if (e.altKey && e.key === 'ArrowRight') { e.preventDefault(); goForward(); }
2716
+
2249
2717
  if (e.key === 'Escape') {
2250
- if (S.searchQuery) { $('search').value = ''; S.searchQuery = ''; renderPanel(); }
2251
- else if (filterActive()) { clearFilter(); }
2252
- else if (S.view === 'slice' || S.view === 'namespace') { goRoot(); }
2718
+ // Order: bail out of address-edit > clear search > clear filter > go home.
2719
+ if (!$('address-input').classList.contains('hidden')) { exitAddressEdit(); return; }
2720
+ if (S.searchQuery) { S.searchQuery = ''; renderPanel(); return; }
2721
+ if (filterActive()) { clearFilter(); return; }
2722
+ if (S.view === 'slice' || S.view === 'namespace') { goRoot(); }
2253
2723
  }
2254
2724
  });
2255
2725
 
@@ -2257,6 +2727,11 @@ document.addEventListener('keydown', e => {
2257
2727
  Boot
2258
2728
  ══════════════════════════════════════════════════════ */
2259
2729
  $('auth-key').addEventListener('keydown', e => { if (e.key === 'Enter') submitAuth(); });
2730
+ $('address-input').addEventListener('keydown', onAddressKeyDown);
2731
+ $('address-input').addEventListener('blur', exitAddressEdit);
2732
+ // Prime history with the initial overview so the first navigation has
2733
+ // somewhere to come back to.
2734
+ pushHistory();
2260
2735
  boot();
2261
2736
  </script>
2262
2737
  </body>