@bitpub/cli 2.0.0
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 +98 -0
- package/bin/bitpub.js +67 -0
- package/package.json +58 -0
- package/skills/bitpub/SKILL.md +325 -0
- package/src/agents-md.js +100 -0
- package/src/aliases.js +116 -0
- package/src/api.js +177 -0
- package/src/commands/alias.js +79 -0
- package/src/commands/auth.js +50 -0
- package/src/commands/browser.js +196 -0
- package/src/commands/catchup.js +109 -0
- package/src/commands/delete.js +189 -0
- package/src/commands/drop.js +22 -0
- package/src/commands/fetch.js +29 -0
- package/src/commands/find.js +175 -0
- package/src/commands/grep.js +26 -0
- package/src/commands/init.js +49 -0
- package/src/commands/list.js +241 -0
- package/src/commands/load.js +122 -0
- package/src/commands/push.js +84 -0
- package/src/commands/read.js +42 -0
- package/src/commands/recent.js +67 -0
- package/src/commands/restore.js +23 -0
- package/src/commands/save.js +255 -0
- package/src/commands/seed.js +152 -0
- package/src/commands/setup.js +312 -0
- package/src/commands/skills.js +304 -0
- package/src/commands/status.js +62 -0
- package/src/commands/sync.js +160 -0
- package/src/commands/trash.js +88 -0
- package/src/commands/update.js +155 -0
- package/src/commands/watch.js +24 -0
- package/src/commands/welcome.js +189 -0
- package/src/config.js +85 -0
- package/src/crypto.js +61 -0
- package/src/db/cache.js +373 -0
- package/src/workspace.js +377 -0
- package/static/console.html +2263 -0
|
@@ -0,0 +1,2263 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>BitPub Context Explorer</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
|
|
10
|
+
:root {
|
|
11
|
+
/* Canvas — warm neutral */
|
|
12
|
+
--bg-page: #FAF7F3;
|
|
13
|
+
--bg-card: #FFFFFF;
|
|
14
|
+
--bg-hover: #F3EDE5;
|
|
15
|
+
--bg-inset: #F1ECE4;
|
|
16
|
+
--bg-canvas: #F6F1E9;
|
|
17
|
+
|
|
18
|
+
/* Borders */
|
|
19
|
+
--border: #E6DCCC;
|
|
20
|
+
--border-soft: #EFE8DC;
|
|
21
|
+
--border-strong: #D6C9B4;
|
|
22
|
+
|
|
23
|
+
/* Text */
|
|
24
|
+
--text: #1E1A16;
|
|
25
|
+
--text-muted: #6F6762;
|
|
26
|
+
--text-subtle: #A39A92;
|
|
27
|
+
|
|
28
|
+
/* Accent — BitPub orange */
|
|
29
|
+
--accent: #E55733;
|
|
30
|
+
--accent-hover: #C8431D;
|
|
31
|
+
--accent-bg: #FCE8DE;
|
|
32
|
+
--accent-soft: rgba(229, 87, 51, .08);
|
|
33
|
+
|
|
34
|
+
/* Scope colors */
|
|
35
|
+
--scope-group: #E55733;
|
|
36
|
+
--scope-private: #7C3AED;
|
|
37
|
+
--scope-public: #0D9488;
|
|
38
|
+
|
|
39
|
+
/* Freshness tints */
|
|
40
|
+
--fresh-1h: #E55733;
|
|
41
|
+
--fresh-24h: #F0A47E;
|
|
42
|
+
--fresh-7d: #9C948D;
|
|
43
|
+
--fresh-older: #C4BBB1;
|
|
44
|
+
|
|
45
|
+
--radius: 6px;
|
|
46
|
+
--radius-sm: 4px;
|
|
47
|
+
--radius-lg: 10px;
|
|
48
|
+
|
|
49
|
+
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
|
|
50
|
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
51
|
+
|
|
52
|
+
--shadow-sm: 0 1px 0 rgba(30, 26, 22, .03);
|
|
53
|
+
--shadow-md: 0 4px 14px rgba(30, 26, 22, .06);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
html, body { height: 100%; background: var(--bg-page); color: var(--text); font-family: var(--font); font-size: 13.5px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
|
57
|
+
a { color: var(--accent); text-decoration: none; }
|
|
58
|
+
a:hover { text-decoration: underline; }
|
|
59
|
+
svg { flex-shrink: 0; }
|
|
60
|
+
|
|
61
|
+
/* ── Layout shell ───────────────────────────────────── */
|
|
62
|
+
#app { display: flex; flex-direction: column; height: 100vh; }
|
|
63
|
+
|
|
64
|
+
/* ── Address bar (top) ──────────────────────────────── */
|
|
65
|
+
.addressbar {
|
|
66
|
+
height: 48px; flex-shrink: 0;
|
|
67
|
+
display: flex; align-items: center; gap: 12px;
|
|
68
|
+
padding: 0 14px;
|
|
69
|
+
background: var(--bg-card);
|
|
70
|
+
border-bottom: 1px solid var(--border);
|
|
71
|
+
}
|
|
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; }
|
|
77
|
+
|
|
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); }
|
|
82
|
+
|
|
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; }
|
|
85
|
+
.address .seg:hover { background: var(--accent-soft); }
|
|
86
|
+
.address .seg.scheme { color: var(--text-subtle); cursor: default; font-weight: 500; }
|
|
87
|
+
.address .seg.scheme:hover { background: transparent; }
|
|
88
|
+
.address .seg.current { color: var(--text); font-weight: 600; cursor: default; }
|
|
89
|
+
.address .seg.current:hover { background: transparent; }
|
|
90
|
+
.address .sep { color: var(--text-subtle); padding: 0 1px; user-select: none; }
|
|
91
|
+
.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; }
|
|
125
|
+
.pill-group { display: inline-flex; gap: 4px; }
|
|
126
|
+
.pill {
|
|
127
|
+
display: inline-flex; align-items: center; gap: 5px;
|
|
128
|
+
padding: 3px 9px; font-size: 11.5px; font-weight: 500;
|
|
129
|
+
border-radius: 100px; border: 1px solid var(--border);
|
|
130
|
+
background: var(--bg-card); color: var(--text-muted); cursor: pointer;
|
|
131
|
+
transition: background .1s, color .1s, border-color .1s;
|
|
132
|
+
white-space: nowrap;
|
|
133
|
+
}
|
|
134
|
+
.pill:hover { background: var(--bg-hover); color: var(--text); }
|
|
135
|
+
.pill.active { background: var(--text); color: var(--bg-card); border-color: var(--text); }
|
|
136
|
+
.pill.scope .dot { width: 7px; height: 7px; border-radius: 50%; }
|
|
137
|
+
.pill.scope[data-scope="group"] .dot { background: var(--scope-group); }
|
|
138
|
+
.pill.scope[data-scope="private"] .dot { background: var(--scope-private); }
|
|
139
|
+
.pill.scope[data-scope="public"] .dot { background: var(--scope-public); }
|
|
140
|
+
.pill.tag { background: var(--accent-bg); color: var(--accent-hover); border-color: rgba(229,87,51,.2); }
|
|
141
|
+
.pill.tag .x { color: var(--text-subtle); margin-left: 2px; font-weight: 700; }
|
|
142
|
+
.pill.tag:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
|
|
143
|
+
.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
|
+
/* ── Body layout ────────────────────────────────────── */
|
|
149
|
+
.layout { display: flex; flex: 1; min-height: 0; }
|
|
150
|
+
#sidebar { width: 264px; min-width: 264px; background: var(--bg-card); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
|
|
151
|
+
#main { flex: 1; overflow-y: auto; background: var(--bg-page); }
|
|
152
|
+
#main::-webkit-scrollbar { width: 10px; }
|
|
153
|
+
#main::-webkit-scrollbar-thumb { background: transparent; border-radius: 5px; border: 3px solid transparent; background-clip: content-box; }
|
|
154
|
+
#main:hover::-webkit-scrollbar-thumb { background: var(--border); background-clip: content-box; }
|
|
155
|
+
|
|
156
|
+
/* ── Sidebar / Tree ─────────────────────────────────── */
|
|
157
|
+
.sidebar-header { padding: 12px 14px 8px; font-size: 10.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text-subtle); display: flex; align-items: center; justify-content: space-between; }
|
|
158
|
+
.sidebar-header .clear-btn { font-size: 10.5px; color: var(--text-subtle); cursor: pointer; font-weight: 500; letter-spacing: 0; text-transform: none; }
|
|
159
|
+
.sidebar-header .clear-btn:hover { color: var(--accent); }
|
|
160
|
+
.tree-wrap { flex: 1; overflow-y: auto; padding: 0 6px 12px; }
|
|
161
|
+
.tree-wrap::-webkit-scrollbar { width: 8px; }
|
|
162
|
+
.tree-wrap::-webkit-scrollbar-thumb { background: transparent; border-radius: 4px; }
|
|
163
|
+
.tree-wrap:hover::-webkit-scrollbar-thumb { background: var(--border); }
|
|
164
|
+
.tree-node { user-select: none; }
|
|
165
|
+
.tree-header, .tree-leaf { display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: var(--radius-sm); cursor: pointer; font-size: 12.5px; color: var(--text); transition: background .05s; }
|
|
166
|
+
.tree-header:hover, .tree-leaf:hover { background: var(--bg-hover); }
|
|
167
|
+
.tree-header.active, .tree-leaf.active { background: var(--accent-bg); color: var(--accent-hover); }
|
|
168
|
+
.chevron { width: 12px; height: 12px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--text-subtle); transition: transform .1s; }
|
|
169
|
+
.tree-node.expanded > .tree-header > .chevron { transform: rotate(90deg); }
|
|
170
|
+
.tree-node:not(.expanded) > .tree-children { display: none; }
|
|
171
|
+
.tree-children { padding-left: 10px; border-left: 1px solid var(--border-soft); margin-left: 13px; }
|
|
172
|
+
.tree-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
173
|
+
.tree-label.mono { font-family: var(--mono); font-size: 12px; }
|
|
174
|
+
.tree-badge { font-size: 10.5px; color: var(--text-subtle); font-variant-numeric: tabular-nums; }
|
|
175
|
+
.tree-lock { color: var(--scope-private); }
|
|
176
|
+
.scope-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
|
177
|
+
.scope-dot.group { background: var(--scope-group); }
|
|
178
|
+
.scope-dot.private { background: var(--scope-private); }
|
|
179
|
+
.scope-dot.public { background: var(--scope-public); }
|
|
180
|
+
.fresh-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
|
181
|
+
.fresh-dot.fresh { background: var(--fresh-1h); }
|
|
182
|
+
.fresh-dot.h24 { background: var(--fresh-24h); }
|
|
183
|
+
.fresh-dot.d7 { background: var(--fresh-7d); }
|
|
184
|
+
.fresh-dot.older { background: var(--fresh-older); }
|
|
185
|
+
|
|
186
|
+
/* Aggregated "N context slices" pseudo-leaf in the tree */
|
|
187
|
+
.tree-leaf.tree-summary .tree-label { color: var(--text-muted); font-style: italic; }
|
|
188
|
+
.tree-leaf.tree-summary:hover .tree-label { color: var(--text); }
|
|
189
|
+
.tree-leaf.tree-summary.active .tree-label { color: var(--accent-hover); }
|
|
190
|
+
.tree-leaf.tree-summary .tree-badge { font-family: var(--mono); }
|
|
191
|
+
|
|
192
|
+
/* ── Right pane / panels ───────────────────────────── */
|
|
193
|
+
.panel { padding: 20px 24px; max-width: 1180px; margin: 0 auto; }
|
|
194
|
+
|
|
195
|
+
/* Section headings */
|
|
196
|
+
.section-h { font-size: 12px; font-weight: 600; color: var(--text-muted); margin: 24px 0 10px; display: flex; align-items: center; gap: 8px; text-transform: uppercase; letter-spacing: .05em; }
|
|
197
|
+
.section-h:first-child { margin-top: 0; }
|
|
198
|
+
.section-h .count { font-size: 11px; font-weight: 500; background: var(--bg-inset); color: var(--text-muted); padding: 1px 7px; border-radius: 100px; letter-spacing: 0; text-transform: none; }
|
|
199
|
+
.section-h .live-hint { margin-left: auto; font-size: 10.5px; color: var(--text-subtle); letter-spacing: 0; text-transform: none; font-weight: 500; display: inline-flex; align-items: center; gap: 5px; }
|
|
200
|
+
.section-h .live-hint .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--fresh-1h); }
|
|
201
|
+
|
|
202
|
+
/* ── Stream card (overview) ─────────────────────────── */
|
|
203
|
+
.stream { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
204
|
+
.stream-row {
|
|
205
|
+
display: grid;
|
|
206
|
+
grid-template-columns: 12px auto auto minmax(0, 1fr) auto auto;
|
|
207
|
+
gap: 10px; align-items: center;
|
|
208
|
+
padding: 8px 14px;
|
|
209
|
+
border-top: 1px solid var(--border-soft);
|
|
210
|
+
cursor: pointer;
|
|
211
|
+
transition: background .05s;
|
|
212
|
+
}
|
|
213
|
+
.stream-row:first-child { border-top: none; }
|
|
214
|
+
.stream-row:hover { background: var(--bg-hover); }
|
|
215
|
+
.stream-row.new { animation: rowHighlight 2s ease-out; }
|
|
216
|
+
@keyframes rowHighlight {
|
|
217
|
+
0% { background: var(--accent-soft); }
|
|
218
|
+
100% { background: transparent; }
|
|
219
|
+
}
|
|
220
|
+
.stream-row .hcu-tail { font-family: var(--mono); font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
221
|
+
.stream-row .hcu-tail .scope-prefix { color: var(--text-subtle); }
|
|
222
|
+
.stream-row .when { font-size: 11px; color: var(--text-subtle); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
223
|
+
|
|
224
|
+
/* ── Agent chip (bot style) ─────────────────────────── */
|
|
225
|
+
.agent-chip {
|
|
226
|
+
display: inline-flex; align-items: center; gap: 5px;
|
|
227
|
+
font-family: var(--mono); font-size: 11px;
|
|
228
|
+
background: var(--bg-inset); border: 1px solid var(--border-soft);
|
|
229
|
+
border-radius: 3px; padding: 1px 6px; color: var(--text-muted);
|
|
230
|
+
white-space: nowrap;
|
|
231
|
+
}
|
|
232
|
+
.agent-chip .hex { width: 8px; height: 8px; color: var(--text-subtle); }
|
|
233
|
+
.agent-chip:hover { border-color: var(--border); color: var(--text); }
|
|
234
|
+
|
|
235
|
+
/* ── Scope pill (compact) ───────────────────────────── */
|
|
236
|
+
.scope-pill {
|
|
237
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
238
|
+
font-size: 10.5px; font-weight: 500;
|
|
239
|
+
padding: 1px 7px; border-radius: 100px;
|
|
240
|
+
border: 1px solid var(--border); background: var(--bg-card); color: var(--text-muted);
|
|
241
|
+
white-space: nowrap;
|
|
242
|
+
}
|
|
243
|
+
.scope-pill .dot { width: 6px; height: 6px; border-radius: 50%; }
|
|
244
|
+
.scope-pill.group .dot { background: var(--scope-group); }
|
|
245
|
+
.scope-pill.private .dot { background: var(--scope-private); }
|
|
246
|
+
.scope-pill.public .dot { background: var(--scope-public); }
|
|
247
|
+
.scope-pill.private { border-color: rgba(124, 58, 237, .25); }
|
|
248
|
+
.scope-pill .lock { color: var(--scope-private); }
|
|
249
|
+
|
|
250
|
+
/* ── Version chip ───────────────────────────────────── */
|
|
251
|
+
.ver-chip {
|
|
252
|
+
font-family: var(--mono); font-size: 10.5px;
|
|
253
|
+
padding: 1px 6px; border-radius: 3px;
|
|
254
|
+
background: var(--bg-inset); color: var(--text-muted);
|
|
255
|
+
white-space: nowrap;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* ── Activity heatmap ───────────────────────────────── */
|
|
259
|
+
.heatmap-card {
|
|
260
|
+
background: var(--bg-card); border: 1px solid var(--border);
|
|
261
|
+
border-radius: var(--radius); padding: 16px 18px 14px;
|
|
262
|
+
margin-bottom: 20px;
|
|
263
|
+
}
|
|
264
|
+
.heatmap-head {
|
|
265
|
+
display: flex; align-items: center; gap: 14px; margin-bottom: 14px;
|
|
266
|
+
flex-wrap: wrap;
|
|
267
|
+
}
|
|
268
|
+
.heatmap-head h4 {
|
|
269
|
+
font-size: 10.5px; font-weight: 700; color: var(--text-subtle);
|
|
270
|
+
letter-spacing: .08em; text-transform: uppercase;
|
|
271
|
+
}
|
|
272
|
+
.heatmap-head .stats {
|
|
273
|
+
display: flex; gap: 14px; font-size: 11.5px; color: var(--text-muted);
|
|
274
|
+
font-variant-numeric: tabular-nums;
|
|
275
|
+
}
|
|
276
|
+
.heatmap-head .stats .k { color: var(--text); font-weight: 600; }
|
|
277
|
+
.heatmap-head .sub {
|
|
278
|
+
margin-left: auto; font-size: 11px; color: var(--text-subtle);
|
|
279
|
+
font-style: italic;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.heatmap-grid {
|
|
283
|
+
display: flex; gap: 10px; align-items: flex-start;
|
|
284
|
+
justify-content: center;
|
|
285
|
+
overflow-x: auto; padding-bottom: 4px;
|
|
286
|
+
}
|
|
287
|
+
.heatmap-day-labels {
|
|
288
|
+
display: grid; gap: 3px;
|
|
289
|
+
padding-top: 16px;
|
|
290
|
+
font-size: 9.5px; color: var(--text-subtle);
|
|
291
|
+
font-variant-numeric: tabular-nums;
|
|
292
|
+
line-height: 1;
|
|
293
|
+
align-items: center;
|
|
294
|
+
justify-items: end;
|
|
295
|
+
}
|
|
296
|
+
.heatmap-day-labels span { visibility: hidden; }
|
|
297
|
+
.heatmap-day-labels span:nth-child(2),
|
|
298
|
+
.heatmap-day-labels span:nth-child(4),
|
|
299
|
+
.heatmap-day-labels span:nth-child(6) { visibility: visible; }
|
|
300
|
+
|
|
301
|
+
.heatmap-cols {
|
|
302
|
+
display: flex; flex-direction: column; gap: 2px;
|
|
303
|
+
}
|
|
304
|
+
.heatmap-months {
|
|
305
|
+
display: grid; grid-auto-flow: column; gap: 3px; height: 12px;
|
|
306
|
+
font-size: 9.5px; color: var(--text-subtle);
|
|
307
|
+
font-variant-numeric: tabular-nums; letter-spacing: .03em;
|
|
308
|
+
position: relative;
|
|
309
|
+
}
|
|
310
|
+
.heatmap-months .m {
|
|
311
|
+
position: absolute; top: 0; white-space: nowrap;
|
|
312
|
+
}
|
|
313
|
+
.heatmap-weeks {
|
|
314
|
+
display: grid; grid-auto-flow: column;
|
|
315
|
+
}
|
|
316
|
+
.heatmap-week {
|
|
317
|
+
display: grid;
|
|
318
|
+
}
|
|
319
|
+
.heatmap-cell {
|
|
320
|
+
border-radius: 3px;
|
|
321
|
+
background: var(--bg-inset);
|
|
322
|
+
border: 1px solid rgba(255,255,255,0.02);
|
|
323
|
+
transition: transform .08s, outline-color .1s;
|
|
324
|
+
cursor: default;
|
|
325
|
+
}
|
|
326
|
+
.heatmap-cell.l1 { background: rgba(255,107,53,0.22); }
|
|
327
|
+
.heatmap-cell.l2 { background: rgba(255,107,53,0.45); }
|
|
328
|
+
.heatmap-cell.l3 { background: rgba(255,107,53,0.72); }
|
|
329
|
+
.heatmap-cell.l4 { background: var(--accent); outline: 1px solid rgba(255,107,53,0.45); outline-offset: 1px; }
|
|
330
|
+
.heatmap-cell.placeholder { background: transparent; border-color: transparent; }
|
|
331
|
+
.heatmap-cell:hover { transform: scale(1.25); z-index: 2; }
|
|
332
|
+
|
|
333
|
+
.heatmap-legend {
|
|
334
|
+
display: flex; align-items: center; gap: 6px;
|
|
335
|
+
margin-top: 10px; font-size: 10.5px; color: var(--text-subtle);
|
|
336
|
+
font-variant-numeric: tabular-nums;
|
|
337
|
+
}
|
|
338
|
+
.heatmap-legend .cells { display: flex; gap: 3px; }
|
|
339
|
+
.heatmap-legend .cells .c {
|
|
340
|
+
width: 11px; height: 11px; border-radius: 3px; background: var(--bg-inset);
|
|
341
|
+
border: 1px solid rgba(255,255,255,0.02);
|
|
342
|
+
}
|
|
343
|
+
.heatmap-legend .cells .c.l1 { background: rgba(255,107,53,0.22); }
|
|
344
|
+
.heatmap-legend .cells .c.l2 { background: rgba(255,107,53,0.45); }
|
|
345
|
+
.heatmap-legend .cells .c.l3 { background: rgba(255,107,53,0.72); }
|
|
346
|
+
.heatmap-legend .cells .c.l4 { background: var(--accent); }
|
|
347
|
+
|
|
348
|
+
/* ── Group README ───────────────────────────────────── */
|
|
349
|
+
.readme-card {
|
|
350
|
+
background: var(--bg-card); border: 1px solid var(--border);
|
|
351
|
+
border-radius: var(--radius); padding: 16px 22px 20px;
|
|
352
|
+
margin-bottom: 14px;
|
|
353
|
+
}
|
|
354
|
+
.readme-card + .readme-card { margin-top: 12px; }
|
|
355
|
+
.readme-head {
|
|
356
|
+
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
|
357
|
+
padding-bottom: 12px; margin-bottom: 14px;
|
|
358
|
+
border-bottom: 1px solid var(--border-soft);
|
|
359
|
+
}
|
|
360
|
+
.readme-head .label {
|
|
361
|
+
font-size: 10.5px; font-weight: 700; color: var(--text-subtle);
|
|
362
|
+
letter-spacing: .08em; text-transform: uppercase;
|
|
363
|
+
}
|
|
364
|
+
.readme-head .hcu {
|
|
365
|
+
font-family: var(--mono); font-size: 11.5px; color: var(--text-muted);
|
|
366
|
+
cursor: pointer;
|
|
367
|
+
}
|
|
368
|
+
.readme-head .hcu:hover { color: var(--accent-hover); }
|
|
369
|
+
.readme-head .meta {
|
|
370
|
+
margin-left: auto; font-size: 11px; color: var(--text-subtle);
|
|
371
|
+
display: flex; align-items: center; gap: 8px;
|
|
372
|
+
}
|
|
373
|
+
.readme-body { font-size: 13.5px; line-height: 1.6; }
|
|
374
|
+
.readme-body h1, .readme-body h2, .readme-body h3 { margin: 18px 0 8px; }
|
|
375
|
+
.readme-body h1:first-child, .readme-body h2:first-child { margin-top: 0; }
|
|
376
|
+
.readme-body p { margin: 8px 0; }
|
|
377
|
+
.readme-body code { font-family: var(--mono); font-size: 12.5px; }
|
|
378
|
+
.readme-empty-hint {
|
|
379
|
+
font-size: 11.5px; color: var(--text-subtle); margin-top: 10px;
|
|
380
|
+
}
|
|
381
|
+
.readme-empty-hint code {
|
|
382
|
+
font-family: var(--mono); font-size: 11.5px;
|
|
383
|
+
background: var(--bg-inset); padding: 2px 6px; border-radius: 4px;
|
|
384
|
+
color: var(--text-muted);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/* ── 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
|
+
.stats-strip { display: flex; align-items: center; gap: 16px; padding: 0 2px 12px; font-size: 12px; color: var(--text-muted); flex-wrap: wrap; }
|
|
404
|
+
.stats-strip .s-item { display: inline-flex; align-items: center; gap: 5px; }
|
|
405
|
+
.stats-strip .s-item .n { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
406
|
+
|
|
407
|
+
.file-box { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
408
|
+
.file-row {
|
|
409
|
+
display: grid;
|
|
410
|
+
grid-template-columns: 14px minmax(0, 1.4fr) minmax(0, 1.8fr) auto auto auto auto;
|
|
411
|
+
gap: 12px; align-items: center;
|
|
412
|
+
padding: 7px 14px;
|
|
413
|
+
border-top: 1px solid var(--border-soft);
|
|
414
|
+
cursor: pointer;
|
|
415
|
+
transition: background .05s;
|
|
416
|
+
font-size: 13px;
|
|
417
|
+
}
|
|
418
|
+
.file-row:first-child { border-top: none; }
|
|
419
|
+
.file-row:hover { background: var(--bg-hover); }
|
|
420
|
+
.file-row .f-icon { color: var(--text-subtle); display: inline-flex; }
|
|
421
|
+
.file-row .f-icon.folder { color: var(--accent); }
|
|
422
|
+
.file-row .f-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-weight: 500; }
|
|
423
|
+
.file-row .f-name.mono { font-family: var(--mono); font-size: 12.5px; font-weight: 400; }
|
|
424
|
+
.file-row .f-preview { color: var(--text-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
425
|
+
.file-row .f-when { font-size: 11px; color: var(--text-subtle); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
426
|
+
|
|
427
|
+
/* ── Slice panel (blob) ─────────────────────────────── */
|
|
428
|
+
.blob-wrap { }
|
|
429
|
+
.blob-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 2px 12px; font-size: 12px; color: var(--text-muted); }
|
|
430
|
+
.blob-meta .sep { color: var(--border-strong); }
|
|
431
|
+
|
|
432
|
+
.encrypted-note {
|
|
433
|
+
display: flex; align-items: center; gap: 10px;
|
|
434
|
+
padding: 10px 14px;
|
|
435
|
+
background: rgba(124, 58, 237, .06);
|
|
436
|
+
border: 1px solid rgba(124, 58, 237, .2);
|
|
437
|
+
border-radius: var(--radius);
|
|
438
|
+
font-size: 12.5px;
|
|
439
|
+
color: var(--text);
|
|
440
|
+
margin-bottom: 12px;
|
|
441
|
+
}
|
|
442
|
+
.encrypted-note svg { color: var(--scope-private); }
|
|
443
|
+
.encrypted-note code { font-family: var(--mono); font-size: 12px; background: var(--bg-card); padding: 1px 5px; border-radius: 3px; border: 1px solid var(--border); }
|
|
444
|
+
|
|
445
|
+
.blob-container { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
446
|
+
.blob-toolbar { padding: 8px 12px; background: var(--bg-canvas); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
447
|
+
.blob-toolbar .info { font-size: 11px; color: var(--text-subtle); font-variant-numeric: tabular-nums; }
|
|
448
|
+
.blob-toolbar .spacer { flex: 1; }
|
|
449
|
+
|
|
450
|
+
.btn-group { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-card); }
|
|
451
|
+
.btn-group .btn { border: none; border-right: 1px solid var(--border); border-radius: 0; padding: 3px 10px; font-size: 11.5px; font-weight: 500; background: var(--bg-card); color: var(--text-muted); cursor: pointer; display: inline-flex; align-items: center; gap: 4px; transition: background .1s, color .1s; font-family: var(--font); }
|
|
452
|
+
.btn-group .btn:last-child { border-right: none; }
|
|
453
|
+
.btn-group .btn:hover { background: var(--bg-hover); color: var(--text); }
|
|
454
|
+
.btn-group .btn.active { background: var(--text); color: var(--bg-card); }
|
|
455
|
+
|
|
456
|
+
.btn { padding: 4px 10px; font-size: 11.5px; font-weight: 500; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--bg-card); color: var(--text-muted); cursor: pointer; display: inline-flex; align-items: center; gap: 5px; font-family: var(--font); transition: background .1s, color .1s, border-color .1s; }
|
|
457
|
+
.btn:hover { background: var(--bg-hover); color: var(--text); }
|
|
458
|
+
|
|
459
|
+
/* Rendered markdown */
|
|
460
|
+
.content-body { padding: 22px 26px; font-size: 14px; line-height: 1.65; color: var(--text); max-width: 860px; }
|
|
461
|
+
.content-body h1 { font-size: 1.8em; font-weight: 700; margin: 20px 0 12px; letter-spacing: -.01em; }
|
|
462
|
+
.content-body h2 { font-size: 1.4em; font-weight: 700; margin: 22px 0 10px; letter-spacing: -.005em; }
|
|
463
|
+
.content-body h3 { font-size: 1.15em; font-weight: 600; margin: 18px 0 8px; }
|
|
464
|
+
.content-body h4, .content-body h5, .content-body h6 { font-size: 1em; font-weight: 600; margin: 16px 0 6px; }
|
|
465
|
+
.content-body p { margin: 0 0 12px; }
|
|
466
|
+
.content-body strong { font-weight: 600; }
|
|
467
|
+
.content-body em { font-style: italic; }
|
|
468
|
+
.content-body ul, .content-body ol { padding-left: 1.6em; margin: 0 0 12px; }
|
|
469
|
+
.content-body li { margin: .2em 0; }
|
|
470
|
+
.content-body code { font-family: var(--mono); background: var(--bg-inset); padding: .1em .4em; border-radius: 3px; font-size: 88%; }
|
|
471
|
+
.content-body pre { background: var(--bg-canvas); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 12px 14px; margin: 0 0 12px; overflow-x: auto; font-size: 85%; line-height: 1.5; }
|
|
472
|
+
.content-body pre code { background: none; padding: 0; font-size: 100%; }
|
|
473
|
+
.content-body hr { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
|
|
474
|
+
.content-body a { color: var(--accent); }
|
|
475
|
+
.content-body blockquote { border-left: 3px solid var(--accent); padding: 0 0 0 12px; color: var(--text-muted); margin: 0 0 12px; }
|
|
476
|
+
|
|
477
|
+
/* Raw */
|
|
478
|
+
.content-raw { font-family: var(--mono); font-size: 12px; line-height: 1.55; color: var(--text); background: var(--bg-card); padding: 14px 18px; white-space: pre-wrap; word-break: break-word; overflow-x: auto; }
|
|
479
|
+
|
|
480
|
+
/* Tiny file info below blob */
|
|
481
|
+
.blob-footer { padding: 6px 14px; font-size: 10.5px; color: var(--text-subtle); background: var(--bg-canvas); border-top: 1px solid var(--border-soft); font-variant-numeric: tabular-nums; }
|
|
482
|
+
|
|
483
|
+
/* ── Search Results ─────────────────────────────────── */
|
|
484
|
+
.search-results { padding: 20px 24px; max-width: 920px; margin: 0 auto; }
|
|
485
|
+
.search-results .count-line { font-size: 12px; color: var(--text-muted); margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--border-soft); }
|
|
486
|
+
.result-item { padding: 12px 0; border-bottom: 1px solid var(--border-soft); cursor: pointer; transition: background .05s; padding-left: 8px; padding-right: 8px; margin-left: -8px; margin-right: -8px; border-radius: var(--radius-sm); }
|
|
487
|
+
.result-item:hover { background: var(--bg-hover); }
|
|
488
|
+
.result-hcu { font-family: var(--mono); font-size: 12.5px; color: var(--accent); margin-bottom: 5px; font-weight: 500; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
489
|
+
.result-preview { font-size: 12.5px; color: var(--text); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.5; }
|
|
490
|
+
.result-preview mark { background: var(--accent-bg); color: var(--accent-hover); border-radius: 2px; padding: 0 2px; font-weight: 600; }
|
|
491
|
+
.result-hcu mark { background: var(--accent-bg); color: var(--accent-hover); padding: 0 2px; border-radius: 2px; }
|
|
492
|
+
|
|
493
|
+
/* ── Auth Overlay ───────────────────────────────────── */
|
|
494
|
+
.auth-overlay { position: fixed; inset: 0; background: rgba(250, 247, 243, .94); backdrop-filter: blur(10px); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 20px; }
|
|
495
|
+
.auth-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 28px 30px; max-width: 380px; width: 100%; box-shadow: var(--shadow-md); }
|
|
496
|
+
.auth-card .auth-mark { display: flex; justify-content: center; margin-bottom: 14px; }
|
|
497
|
+
.auth-card .auth-mark .m { width: 36px; height: 36px; background: var(--accent); border-radius: 8px; position: relative; }
|
|
498
|
+
.auth-card .auth-mark .m::after { content: ""; position: absolute; inset: 8px; border-radius: 2px; background: var(--bg-card); }
|
|
499
|
+
.auth-card h2 { font-size: 18px; margin-bottom: 4px; font-weight: 600; text-align: center; }
|
|
500
|
+
.auth-card p { font-size: 12.5px; color: var(--text-muted); margin-bottom: 16px; text-align: center; }
|
|
501
|
+
.auth-card label { display: block; font-size: 11px; font-weight: 600; margin-bottom: 5px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; }
|
|
502
|
+
.auth-card input { width: 100%; padding: 7px 11px; background: var(--bg-page); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); font-family: var(--mono); font-size: 12.5px; outline: none; margin-bottom: 14px; transition: border-color .15s, box-shadow .15s; }
|
|
503
|
+
.auth-card input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
504
|
+
.auth-card button { width: 100%; padding: 8px 14px; background: var(--accent); color: #fff; border: none; border-radius: var(--radius-sm); font-size: 13px; font-weight: 600; cursor: pointer; font-family: var(--font); transition: background .15s; }
|
|
505
|
+
.auth-card button:hover { background: var(--accent-hover); }
|
|
506
|
+
.auth-error { color: var(--accent-hover); font-size: 12px; margin-bottom: 10px; background: var(--accent-bg); border: 1px solid rgba(229,87,51,.25); border-radius: var(--radius-sm); padding: 6px 10px; }
|
|
507
|
+
|
|
508
|
+
/* ── Welcome Panel (embedded in overview) ──────────────
|
|
509
|
+
Shown above "Recent writes" on the home view when (a) the only memory
|
|
510
|
+
in the cache is the user's auto-saved Welcome slice, or (b) the URL
|
|
511
|
+
carries ?welcome=1 (the install.sh entry point). Not a popup — sits
|
|
512
|
+
inline so the user can look around the explorer without dismissing.
|
|
513
|
+
Auto-disappears once the user has saved any non-welcome memory.
|
|
514
|
+
*/
|
|
515
|
+
.welcome-card {
|
|
516
|
+
position: relative;
|
|
517
|
+
background: var(--bg-card);
|
|
518
|
+
border: 1px solid var(--border);
|
|
519
|
+
border-radius: 12px;
|
|
520
|
+
padding: 32px 36px 24px;
|
|
521
|
+
margin-bottom: 28px;
|
|
522
|
+
animation: welcomeFade .25s ease-out;
|
|
523
|
+
}
|
|
524
|
+
@media (max-width: 720px) {
|
|
525
|
+
.welcome-card { padding: 24px 20px 18px; }
|
|
526
|
+
}
|
|
527
|
+
@keyframes welcomeFade { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
528
|
+
|
|
529
|
+
.welcome-hero { text-align: center; margin-bottom: 22px; }
|
|
530
|
+
.welcome-hero .welcome-mark {
|
|
531
|
+
width: 44px; height: 44px;
|
|
532
|
+
background: var(--accent); border-radius: 10px;
|
|
533
|
+
margin: 0 auto 14px; position: relative;
|
|
534
|
+
}
|
|
535
|
+
.welcome-hero .welcome-mark::after {
|
|
536
|
+
content: ""; position: absolute; inset: 10px; border-radius: 2px; background: var(--bg-card);
|
|
537
|
+
}
|
|
538
|
+
.welcome-hero h1 {
|
|
539
|
+
font-size: 22px; font-weight: 600; letter-spacing: -.01em;
|
|
540
|
+
margin-bottom: 6px;
|
|
541
|
+
}
|
|
542
|
+
.welcome-hero p {
|
|
543
|
+
font-size: 13.5px; color: var(--text-muted);
|
|
544
|
+
max-width: 560px; margin: 0 auto; line-height: 1.55;
|
|
545
|
+
}
|
|
546
|
+
.welcome-hero p strong { color: var(--text); font-weight: 600; }
|
|
547
|
+
|
|
548
|
+
.welcome-grid {
|
|
549
|
+
display: grid;
|
|
550
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
551
|
+
gap: 14px;
|
|
552
|
+
margin-bottom: 24px;
|
|
553
|
+
}
|
|
554
|
+
@media (max-width: 720px) {
|
|
555
|
+
.welcome-grid { grid-template-columns: 1fr; }
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
.welcome-cta {
|
|
559
|
+
text-align: left;
|
|
560
|
+
background: var(--bg-page);
|
|
561
|
+
border: 1px solid var(--border);
|
|
562
|
+
border-radius: 10px;
|
|
563
|
+
padding: 18px 18px 16px;
|
|
564
|
+
cursor: pointer;
|
|
565
|
+
font-family: var(--font); color: var(--text);
|
|
566
|
+
text-decoration: none;
|
|
567
|
+
transition: transform .12s, border-color .12s, box-shadow .12s, background .12s;
|
|
568
|
+
display: flex; flex-direction: column;
|
|
569
|
+
position: relative;
|
|
570
|
+
}
|
|
571
|
+
.welcome-cta:hover {
|
|
572
|
+
transform: translateY(-1px);
|
|
573
|
+
border-color: var(--border-strong);
|
|
574
|
+
background: var(--bg-card);
|
|
575
|
+
box-shadow: var(--shadow-md);
|
|
576
|
+
text-decoration: none;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
.welcome-cta-num {
|
|
580
|
+
width: 22px; height: 22px;
|
|
581
|
+
background: var(--accent-bg);
|
|
582
|
+
color: var(--accent-hover);
|
|
583
|
+
border-radius: 50%;
|
|
584
|
+
font-size: 11px; font-weight: 700;
|
|
585
|
+
display: flex; align-items: center; justify-content: center;
|
|
586
|
+
margin-bottom: 12px;
|
|
587
|
+
}
|
|
588
|
+
.welcome-cta h3 {
|
|
589
|
+
font-size: 14.5px; font-weight: 600;
|
|
590
|
+
margin-bottom: 6px; letter-spacing: -.005em;
|
|
591
|
+
}
|
|
592
|
+
.welcome-cta p {
|
|
593
|
+
font-size: 12.5px; color: var(--text-muted);
|
|
594
|
+
line-height: 1.5; flex: 1;
|
|
595
|
+
}
|
|
596
|
+
.welcome-cta-tag {
|
|
597
|
+
display: inline-block; align-self: flex-start;
|
|
598
|
+
margin-top: 12px;
|
|
599
|
+
font-size: 10.5px; font-weight: 600;
|
|
600
|
+
text-transform: uppercase; letter-spacing: .06em;
|
|
601
|
+
color: var(--text-subtle);
|
|
602
|
+
padding: 3px 7px;
|
|
603
|
+
background: var(--bg-inset);
|
|
604
|
+
border-radius: 100px;
|
|
605
|
+
}
|
|
606
|
+
.welcome-cta-tag.live {
|
|
607
|
+
color: var(--accent-hover);
|
|
608
|
+
background: var(--accent-bg);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
.welcome-footer {
|
|
612
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
613
|
+
gap: 12px;
|
|
614
|
+
padding-top: 18px;
|
|
615
|
+
border-top: 1px solid var(--border-soft);
|
|
616
|
+
}
|
|
617
|
+
@media (max-width: 720px) {
|
|
618
|
+
.welcome-footer { flex-direction: column-reverse; }
|
|
619
|
+
}
|
|
620
|
+
.welcome-link {
|
|
621
|
+
background: transparent; border: 0;
|
|
622
|
+
color: var(--accent); font-size: 13px; font-weight: 600;
|
|
623
|
+
cursor: pointer; padding: 6px 0;
|
|
624
|
+
font-family: var(--font);
|
|
625
|
+
}
|
|
626
|
+
.welcome-link:hover { text-decoration: underline; }
|
|
627
|
+
.welcome-link.muted { color: var(--text-subtle); font-weight: 500; }
|
|
628
|
+
.welcome-skip {
|
|
629
|
+
background: transparent; border: 1px solid var(--border);
|
|
630
|
+
color: var(--text-muted); font-size: 12.5px; font-weight: 500;
|
|
631
|
+
padding: 6px 14px; border-radius: var(--radius-sm);
|
|
632
|
+
cursor: pointer; font-family: var(--font);
|
|
633
|
+
transition: background .12s, border-color .12s;
|
|
634
|
+
}
|
|
635
|
+
.welcome-skip:hover { background: var(--bg-hover); border-color: var(--border-strong); }
|
|
636
|
+
|
|
637
|
+
.welcome-toast {
|
|
638
|
+
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
|
639
|
+
background: var(--text); color: var(--bg-card);
|
|
640
|
+
font-size: 12.5px; padding: 9px 16px;
|
|
641
|
+
border-radius: 100px;
|
|
642
|
+
box-shadow: 0 6px 20px rgba(30, 26, 22, .18);
|
|
643
|
+
z-index: 110;
|
|
644
|
+
animation: welcomeToast 2.4s ease-out forwards;
|
|
645
|
+
}
|
|
646
|
+
@keyframes welcomeToast {
|
|
647
|
+
0% { opacity: 0; transform: translate(-50%, 8px); }
|
|
648
|
+
10%, 80% { opacity: 1; transform: translate(-50%, 0); }
|
|
649
|
+
100% { opacity: 0; transform: translate(-50%, -4px); }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/* ── Empty State ────────────────────────────────────── */
|
|
653
|
+
.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 32px; text-align: center; color: var(--text-muted); }
|
|
654
|
+
.empty svg { color: var(--text-subtle); margin-bottom: 14px; opacity: .6; }
|
|
655
|
+
.empty h2 { font-size: 17px; color: var(--text); margin-bottom: 8px; font-weight: 600; }
|
|
656
|
+
.empty p { max-width: 420px; font-size: 13px; line-height: 1.6; margin-bottom: 12px; }
|
|
657
|
+
.empty code { background: var(--bg-card); border: 1px solid var(--border); padding: 2px 6px; border-radius: 3px; font-size: 12px; color: var(--text); font-family: var(--mono); }
|
|
658
|
+
|
|
659
|
+
/* ── Tooltip ────────────────────────────────────────── */
|
|
660
|
+
.tooltip { position: fixed; background: var(--text); border-radius: 4px; padding: 4px 8px; font-size: 11px; font-family: var(--mono); color: var(--bg-page); pointer-events: none; z-index: 50; max-width: 320px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-shadow: 0 4px 12px rgba(0,0,0,.12); }
|
|
661
|
+
|
|
662
|
+
/* ── Cell state (volatility) ────────────────────────── */
|
|
663
|
+
.cell-state {
|
|
664
|
+
display: inline-flex; align-items: center; gap: 5px;
|
|
665
|
+
font-size: 10.5px; font-weight: 500;
|
|
666
|
+
padding: 1px 8px; border-radius: 100px;
|
|
667
|
+
border: 1px solid var(--border); background: var(--bg-card);
|
|
668
|
+
white-space: nowrap; font-variant-numeric: tabular-nums;
|
|
669
|
+
cursor: help;
|
|
670
|
+
}
|
|
671
|
+
.cell-state .pip { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; position: relative; }
|
|
672
|
+
.cell-state.hot { color: var(--accent-hover); border-color: rgba(229,87,51,.3); background: var(--accent-bg); }
|
|
673
|
+
.cell-state.hot .pip { background: var(--accent); }
|
|
674
|
+
.cell-state.hot .pip::after {
|
|
675
|
+
content: ""; position: absolute; inset: -3px; border-radius: 50%;
|
|
676
|
+
background: var(--accent); opacity: .3;
|
|
677
|
+
animation: pulse 1.4s ease-out infinite;
|
|
678
|
+
}
|
|
679
|
+
.cell-state.active { color: var(--text); border-color: var(--border-strong); }
|
|
680
|
+
.cell-state.active .pip { background: var(--fresh-24h); }
|
|
681
|
+
.cell-state.new { color: #047857; border-color: rgba(13,148,136,.3); background: rgba(13,148,136,.08); }
|
|
682
|
+
.cell-state.new .pip { background: var(--scope-public); }
|
|
683
|
+
.cell-state.stable { color: var(--text-muted); }
|
|
684
|
+
.cell-state.stable .pip { background: var(--fresh-7d); }
|
|
685
|
+
.cell-state.dormant { color: var(--text-subtle); }
|
|
686
|
+
.cell-state.dormant .pip { background: var(--fresh-older); }
|
|
687
|
+
/* Tombstone state: visually distinct from the active ladder. Strikethrough
|
|
688
|
+
on the pill text to reinforce that this slice is in deleted state. */
|
|
689
|
+
.cell-state.deleted {
|
|
690
|
+
color: #b91c1c;
|
|
691
|
+
border-color: rgba(185,28,28,.3);
|
|
692
|
+
background: rgba(185,28,28,.06);
|
|
693
|
+
text-decoration: line-through;
|
|
694
|
+
text-decoration-color: rgba(185,28,28,.55);
|
|
695
|
+
}
|
|
696
|
+
.cell-state.deleted .pip { background: #b91c1c; text-decoration: none; }
|
|
697
|
+
|
|
698
|
+
/* ── Snapshot banner (slice view) ───────────────────── */
|
|
699
|
+
.snapshot-banner {
|
|
700
|
+
display: flex; align-items: center; gap: 10px;
|
|
701
|
+
padding: 9px 14px;
|
|
702
|
+
background: var(--bg-canvas);
|
|
703
|
+
border: 1px solid var(--border);
|
|
704
|
+
border-bottom: none;
|
|
705
|
+
border-radius: var(--radius) var(--radius) 0 0;
|
|
706
|
+
font-size: 12px; color: var(--text-muted);
|
|
707
|
+
flex-wrap: wrap;
|
|
708
|
+
}
|
|
709
|
+
.snapshot-banner .title { color: var(--text); font-weight: 600; letter-spacing: -.005em; }
|
|
710
|
+
.snapshot-banner .ver { font-family: var(--mono); color: var(--text-muted); }
|
|
711
|
+
.snapshot-banner .caveat { margin-left: auto; font-size: 11px; color: var(--text-subtle); display: inline-flex; align-items: center; gap: 5px; }
|
|
712
|
+
.snapshot-banner .caveat svg { color: var(--text-subtle); }
|
|
713
|
+
.blob-container.has-banner { border-top-left-radius: 0; border-top-right-radius: 0; }
|
|
714
|
+
|
|
715
|
+
/* ── Write guide (slice view) ───────────────────────── */
|
|
716
|
+
.write-guide {
|
|
717
|
+
margin-top: 14px;
|
|
718
|
+
background: var(--bg-card); border: 1px solid var(--border);
|
|
719
|
+
border-radius: var(--radius); overflow: hidden;
|
|
720
|
+
}
|
|
721
|
+
.write-guide summary {
|
|
722
|
+
display: flex; align-items: center; gap: 10px;
|
|
723
|
+
padding: 10px 14px; cursor: pointer;
|
|
724
|
+
font-size: 12px; font-weight: 600; color: var(--text-muted);
|
|
725
|
+
list-style: none;
|
|
726
|
+
transition: background .1s, color .1s;
|
|
727
|
+
}
|
|
728
|
+
.write-guide summary::-webkit-details-marker { display: none; }
|
|
729
|
+
.write-guide summary::before {
|
|
730
|
+
content: ""; width: 6px; height: 6px;
|
|
731
|
+
border-right: 1.5px solid var(--text-subtle);
|
|
732
|
+
border-bottom: 1.5px solid var(--text-subtle);
|
|
733
|
+
transform: rotate(-45deg); transition: transform .15s;
|
|
734
|
+
flex-shrink: 0;
|
|
735
|
+
}
|
|
736
|
+
.write-guide[open] summary::before { transform: rotate(45deg); }
|
|
737
|
+
.write-guide summary:hover { color: var(--text); background: var(--bg-hover); }
|
|
738
|
+
.write-guide summary .hint { margin-left: auto; font-size: 11px; font-weight: 500; color: var(--text-subtle); }
|
|
739
|
+
.write-guide .body { padding: 4px 16px 16px; border-top: 1px solid var(--border-soft); }
|
|
740
|
+
.write-guide .mode { margin-top: 14px; }
|
|
741
|
+
.write-guide .mode h5 { font-size: 11.5px; font-weight: 700; color: var(--text); margin-bottom: 3px; }
|
|
742
|
+
.write-guide .mode p { font-size: 11.5px; color: var(--text-muted); margin-bottom: 6px; }
|
|
743
|
+
.write-guide .mode pre {
|
|
744
|
+
background: var(--bg-canvas); border: 1px solid var(--border-soft);
|
|
745
|
+
border-radius: var(--radius-sm); padding: 8px 12px;
|
|
746
|
+
font-family: var(--mono); font-size: 11.5px; color: var(--text);
|
|
747
|
+
overflow-x: auto; white-space: pre; line-height: 1.55;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/* ── Siblings panel (slice view) ────────────────────── */
|
|
751
|
+
.siblings-panel { margin-top: 16px; }
|
|
752
|
+
.siblings-panel .heading {
|
|
753
|
+
font-size: 11px; font-weight: 700; color: var(--text-subtle);
|
|
754
|
+
text-transform: uppercase; letter-spacing: .06em;
|
|
755
|
+
margin-bottom: 8px; padding: 0 2px;
|
|
756
|
+
display: flex; align-items: center; gap: 8px;
|
|
757
|
+
}
|
|
758
|
+
.siblings-panel .heading .note { font-weight: 500; text-transform: none; letter-spacing: 0; color: var(--text-subtle); }
|
|
759
|
+
.siblings-panel .list { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
760
|
+
.siblings-panel .row {
|
|
761
|
+
display: grid; grid-template-columns: 10px minmax(0, 1fr) auto auto;
|
|
762
|
+
gap: 10px; align-items: center;
|
|
763
|
+
padding: 5px 14px; cursor: pointer;
|
|
764
|
+
border-top: 1px solid var(--border-soft);
|
|
765
|
+
font-size: 12px; transition: background .05s;
|
|
766
|
+
}
|
|
767
|
+
.siblings-panel .more {
|
|
768
|
+
padding: 6px 14px; font-size: 11.5px; color: var(--text-subtle);
|
|
769
|
+
border-top: 1px solid var(--border-soft);
|
|
770
|
+
background: var(--bg-canvas);
|
|
771
|
+
font-variant-numeric: tabular-nums;
|
|
772
|
+
}
|
|
773
|
+
.siblings-panel .row:first-child { border-top: none; }
|
|
774
|
+
.siblings-panel .row:hover { background: var(--bg-hover); }
|
|
775
|
+
.siblings-panel .row.self { background: var(--accent-soft); cursor: default; }
|
|
776
|
+
.siblings-panel .row .name { font-family: var(--mono); font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
777
|
+
.siblings-panel .row.self .name { color: var(--accent-hover); font-weight: 600; }
|
|
778
|
+
.siblings-panel .row .when { font-size: 11px; color: var(--text-subtle); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
779
|
+
.siblings-panel .row .self-label { font-size: 10.5px; color: var(--accent-hover); font-weight: 600; }
|
|
780
|
+
|
|
781
|
+
/* Subtle stream subtitle */
|
|
782
|
+
.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
|
+
|
|
784
|
+
.hidden { display: none !important; }
|
|
785
|
+
</style>
|
|
786
|
+
</head>
|
|
787
|
+
<body>
|
|
788
|
+
<div id="app">
|
|
789
|
+
<!-- Auth overlay -->
|
|
790
|
+
<div id="auth-overlay" class="auth-overlay hidden">
|
|
791
|
+
<div class="auth-card">
|
|
792
|
+
<div class="auth-mark"><div class="m"></div></div>
|
|
793
|
+
<h2>Context Explorer</h2>
|
|
794
|
+
<p>Sign in with your BitPub API key to browse your organization's agent memory.</p>
|
|
795
|
+
<div id="auth-error" class="auth-error hidden"></div>
|
|
796
|
+
<label for="auth-key">API key</label>
|
|
797
|
+
<input id="auth-key" type="password" placeholder="tb_..." autocomplete="off">
|
|
798
|
+
<button onclick="submitAuth()">Sign in</button>
|
|
799
|
+
</div>
|
|
800
|
+
</div>
|
|
801
|
+
|
|
802
|
+
<!-- Address bar -->
|
|
803
|
+
<header class="addressbar">
|
|
804
|
+
<div class="wordmark" onclick="goRoot()">
|
|
805
|
+
<span class="mark"></span>
|
|
806
|
+
<span class="name">bitpub</span>
|
|
807
|
+
</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>
|
|
815
|
+
</div>
|
|
816
|
+
</header>
|
|
817
|
+
|
|
818
|
+
<!-- Filter strip -->
|
|
819
|
+
<div id="filter-strip" class="filter-strip"></div>
|
|
820
|
+
|
|
821
|
+
<!-- Body -->
|
|
822
|
+
<div class="layout">
|
|
823
|
+
<aside id="sidebar">
|
|
824
|
+
<div class="sidebar-header">
|
|
825
|
+
<span>Namespaces</span>
|
|
826
|
+
<span id="clear-filter-btn" class="clear-btn hidden" onclick="clearFilter()">Clear</span>
|
|
827
|
+
</div>
|
|
828
|
+
<div id="tree" class="tree-wrap"></div>
|
|
829
|
+
</aside>
|
|
830
|
+
<main id="main"></main>
|
|
831
|
+
</div>
|
|
832
|
+
</div>
|
|
833
|
+
|
|
834
|
+
<div id="tooltip" class="tooltip hidden"></div>
|
|
835
|
+
|
|
836
|
+
<script>
|
|
837
|
+
/* ══════════════════════════════════════════════════════
|
|
838
|
+
State
|
|
839
|
+
══════════════════════════════════════════════════════ */
|
|
840
|
+
const S = {
|
|
841
|
+
slices: [],
|
|
842
|
+
tree: {},
|
|
843
|
+
stats: {},
|
|
844
|
+
mode: 'remote',
|
|
845
|
+
domain: '',
|
|
846
|
+
apiKey: sessionStorage.getItem('bitpub_key'),
|
|
847
|
+
raw: false,
|
|
848
|
+
searchQuery: '',
|
|
849
|
+
// Navigation
|
|
850
|
+
view: 'overview', // 'overview' | 'namespace' | 'slice' | 'search'
|
|
851
|
+
currentScope: null, // scope string when namespace/slice
|
|
852
|
+
currentPath: [], // array of path segments
|
|
853
|
+
selectedHcu: null,
|
|
854
|
+
// Filter state
|
|
855
|
+
filter: {
|
|
856
|
+
scopes: new Set(), // Set<'group'|'private'|'public'>
|
|
857
|
+
freshness: 'all', // 'all'|'1h'|'24h'|'7d'
|
|
858
|
+
tags: new Set(),
|
|
859
|
+
},
|
|
860
|
+
// Live
|
|
861
|
+
lastUpdated: null,
|
|
862
|
+
newArrivals: new Set(), // hcus just arrived
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
const $ = id => document.getElementById(id);
|
|
866
|
+
|
|
867
|
+
/* ══════════════════════════════════════════════════════
|
|
868
|
+
Icons
|
|
869
|
+
══════════════════════════════════════════════════════ */
|
|
870
|
+
const ICON = {
|
|
871
|
+
folder: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M1.75 1A1.75 1.75 0 000 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0016 13.25v-8.5A1.75 1.75 0 0014.25 3H7.5a.25.25 0 01-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75z"/></svg>',
|
|
872
|
+
file: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0113.25 16h-9.5A1.75 1.75 0 012 14.25V1.75zm1.75-.25a.25.25 0 00-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 00.25-.25V6h-2.75A1.75 1.75 0 019 4.25V1.5H3.75z"/></svg>',
|
|
873
|
+
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
|
+
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
|
+
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>',
|
|
876
|
+
hex: '<svg class="hex" viewBox="0 0 12 12" fill="currentColor"><path d="M6 0L11.196 3V9L6 12L0.804 9V3L6 0Z"/></svg>',
|
|
877
|
+
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
|
+
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>',
|
|
879
|
+
warn: '<svg width="40" height="40" viewBox="0 0 16 16" fill="currentColor"><path d="M8.22 1.754a.25.25 0 00-.44 0L1.698 13.132a.25.25 0 00.22.368h12.164a.25.25 0 00.22-.368L8.22 1.754zM6.462 1.11a1.75 1.75 0 013.076 0l6.082 11.378A1.75 1.75 0 0114.082 15H1.918a1.75 1.75 0 01-1.538-2.512L6.462 1.11zM8 5a.75.75 0 01.75.75v2.5a.75.75 0 01-1.5 0v-2.5A.75.75 0 018 5zm1 6a1 1 0 11-2 0 1 1 0 012 0z"/></svg>',
|
|
880
|
+
empty: '<svg width="40" height="40" viewBox="0 0 16 16" fill="currentColor"><path d="M0 1.75A.75.75 0 01.75 1h4.253c1.227 0 2.317.59 3 1.501A3.744 3.744 0 0111.006 1h4.245a.75.75 0 01.75.75v10.5a.75.75 0 01-.75.75h-4.507a2.25 2.25 0 00-1.591.659l-.622.621a.75.75 0 01-1.06 0l-.622-.621A2.25 2.25 0 005.258 13H.75a.75.75 0 01-.75-.75V1.75z"/></svg>',
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
/* ══════════════════════════════════════════════════════
|
|
884
|
+
Boot & data
|
|
885
|
+
══════════════════════════════════════════════════════ */
|
|
886
|
+
async function boot() {
|
|
887
|
+
const data = await fetchData();
|
|
888
|
+
if (!data) return;
|
|
889
|
+
ingestData(data);
|
|
890
|
+
renderAll();
|
|
891
|
+
setInterval(pollData, 30000);
|
|
892
|
+
document.addEventListener('visibilitychange', () => {
|
|
893
|
+
if (document.visibilityState === 'visible') pollData();
|
|
894
|
+
});
|
|
895
|
+
// Refit layout (notably the activity heatmap week-count) on resize.
|
|
896
|
+
let resizeTimer;
|
|
897
|
+
window.addEventListener('resize', () => {
|
|
898
|
+
clearTimeout(resizeTimer);
|
|
899
|
+
resizeTimer = setTimeout(renderAll, 120);
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/* ══════════════════════════════════════════════════════
|
|
904
|
+
Welcome panel (embedded in overview, not a popup)
|
|
905
|
+
Trigger: ?welcome=1 in URL OR the only memory in cache is the
|
|
906
|
+
user's auto-saved Welcome slice. Auto-disappears after the user
|
|
907
|
+
has saved any non-welcome memory.
|
|
908
|
+
══════════════════════════════════════════════════════ */
|
|
909
|
+
const WELCOME_RE = /^bitpub:\/\/private:[^/]+\/Welcome$/;
|
|
910
|
+
|
|
911
|
+
function findWelcomeSlice(slices) {
|
|
912
|
+
return (slices || S.slices).find(s => WELCOME_RE.test(s.hcu));
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
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.
|
|
918
|
+
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;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function renderWelcomePanel() {
|
|
931
|
+
return `
|
|
932
|
+
<div class="welcome-card">
|
|
933
|
+
<div class="welcome-hero">
|
|
934
|
+
<div class="welcome-mark"></div>
|
|
935
|
+
<h1>Welcome to BitPub.</h1>
|
|
936
|
+
<p>Your install worked. <strong>Your first memory is saved</strong> in your private, end-to-end-encrypted namespace. Here's what to try next.</p>
|
|
937
|
+
</div>
|
|
938
|
+
|
|
939
|
+
<div class="welcome-grid">
|
|
940
|
+
<button class="welcome-cta" type="button" onclick="welcomeAction('skill')">
|
|
941
|
+
<div class="welcome-cta-num">1</div>
|
|
942
|
+
<h3>Install a skill</h3>
|
|
943
|
+
<p>Add a curated skill from the public catalog. One click — your agent picks it up automatically. No clone, no PATH, no folder structure to figure out.</p>
|
|
944
|
+
<span class="welcome-cta-tag">coming soon</span>
|
|
945
|
+
</button>
|
|
946
|
+
|
|
947
|
+
<button class="welcome-cta" type="button" onclick="welcomeAction('share')">
|
|
948
|
+
<div class="welcome-cta-num">2</div>
|
|
949
|
+
<h3>Share with your team</h3>
|
|
950
|
+
<p>Generate a link teammates click to read your shared context. No GitHub invites, no repo access, no PRs.</p>
|
|
951
|
+
<span class="welcome-cta-tag">coming soon</span>
|
|
952
|
+
</button>
|
|
953
|
+
|
|
954
|
+
<a class="welcome-cta" href="https://github.com/tollbit/shared-memory-protocol/blob/main/COOKBOOK.md" target="_blank" rel="noopener">
|
|
955
|
+
<div class="welcome-cta-num">3</div>
|
|
956
|
+
<h3>See what you can build</h3>
|
|
957
|
+
<p>Real patterns teams have built on top of BitPub — async job queues, self-distributing tools, multi-agent pipelines.</p>
|
|
958
|
+
<span class="welcome-cta-tag live">cookbook →</span>
|
|
959
|
+
</a>
|
|
960
|
+
</div>
|
|
961
|
+
|
|
962
|
+
<div class="welcome-footer">
|
|
963
|
+
<button class="welcome-link" type="button" onclick="viewWelcomeSlice()">View your first saved memory →</button>
|
|
964
|
+
</div>
|
|
965
|
+
</div>
|
|
966
|
+
`;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function welcomeAction(kind) {
|
|
970
|
+
// Skill catalog and team-sharing flows are not built yet. Surface a
|
|
971
|
+
// friendly toast so the click feels acknowledged rather than dead.
|
|
972
|
+
const msg = kind === 'skill'
|
|
973
|
+
? 'Skill catalog is coming soon. For now, the README has the manual flow.'
|
|
974
|
+
: 'One-click team sharing is coming soon. Today, run: bitpub auth login --domain yourcompany.com';
|
|
975
|
+
showWelcomeToast(msg);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function viewWelcomeSlice() {
|
|
979
|
+
const wel = findWelcomeSlice();
|
|
980
|
+
if (wel) {
|
|
981
|
+
selectSlice(wel.hcu);
|
|
982
|
+
} else {
|
|
983
|
+
// Race: install.sh's push hasn't landed in the local cache yet.
|
|
984
|
+
// Stay on overview — the slice will appear within seconds.
|
|
985
|
+
showWelcomeToast('Your welcome memory will appear in the explorer shortly.');
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function showWelcomeToast(msg) {
|
|
990
|
+
const existing = document.querySelector('.welcome-toast');
|
|
991
|
+
if (existing) existing.remove();
|
|
992
|
+
const t = document.createElement('div');
|
|
993
|
+
t.className = 'welcome-toast';
|
|
994
|
+
t.textContent = msg;
|
|
995
|
+
document.body.appendChild(t);
|
|
996
|
+
setTimeout(() => t.remove(), 2600);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function ingestData(data) {
|
|
1000
|
+
S.slices = data.slices.map(parseSlice);
|
|
1001
|
+
S.mode = data.mode || 'remote';
|
|
1002
|
+
S.domain = data.domain || '';
|
|
1003
|
+
S.tree = buildTree(S.slices);
|
|
1004
|
+
S.stats = computeStats(S.slices);
|
|
1005
|
+
S.lastUpdated = maxTs(S.slices);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
async function fetchData(opts = {}) {
|
|
1009
|
+
try {
|
|
1010
|
+
const headers = {};
|
|
1011
|
+
if (S.apiKey) headers['x-api-key'] = S.apiKey;
|
|
1012
|
+
const base = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/';
|
|
1013
|
+
const r = await fetch(base + 'api/data', { headers });
|
|
1014
|
+
if (r.status === 401 || r.status === 403) { if (!opts.silent) showAuth(); return null; }
|
|
1015
|
+
if (!r.ok) throw new Error(r.status);
|
|
1016
|
+
return r.json();
|
|
1017
|
+
} catch (e) {
|
|
1018
|
+
if (!opts.silent) {
|
|
1019
|
+
$('main').innerHTML = `<div class="empty">${ICON.warn}
|
|
1020
|
+
<h2>Connection failed</h2>
|
|
1021
|
+
<p>Could not reach the API. Make sure the server is running.</p></div>`;
|
|
1022
|
+
}
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
async function pollData() {
|
|
1028
|
+
if (document.visibilityState !== 'visible') return;
|
|
1029
|
+
const data = await fetchData({ silent: true });
|
|
1030
|
+
if (!data) return;
|
|
1031
|
+
const oldKey = new Set(S.slices.map(s => s.hcu + '|' + (s.last_synced || '')));
|
|
1032
|
+
const nextSlices = data.slices.map(parseSlice);
|
|
1033
|
+
const arrivals = new Set();
|
|
1034
|
+
for (const s of nextSlices) {
|
|
1035
|
+
const k = s.hcu + '|' + (s.last_synced || '');
|
|
1036
|
+
if (!oldKey.has(k)) arrivals.add(s.hcu);
|
|
1037
|
+
}
|
|
1038
|
+
S.slices = nextSlices;
|
|
1039
|
+
S.mode = data.mode || S.mode;
|
|
1040
|
+
S.tree = buildTree(S.slices);
|
|
1041
|
+
S.stats = computeStats(S.slices);
|
|
1042
|
+
S.lastUpdated = maxTs(S.slices);
|
|
1043
|
+
if (arrivals.size) {
|
|
1044
|
+
S.newArrivals = arrivals;
|
|
1045
|
+
triggerLivePulse();
|
|
1046
|
+
setTimeout(() => { S.newArrivals = new Set(); }, 2500);
|
|
1047
|
+
}
|
|
1048
|
+
renderAll();
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function triggerLivePulse() {
|
|
1052
|
+
$('live').classList.add('pulse');
|
|
1053
|
+
clearTimeout(triggerLivePulse._t);
|
|
1054
|
+
triggerLivePulse._t = setTimeout(() => $('live').classList.remove('pulse'), 4000);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function showAuth() {
|
|
1058
|
+
$('auth-overlay').classList.remove('hidden');
|
|
1059
|
+
setTimeout(() => $('auth-key').focus(), 100);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async function submitAuth() {
|
|
1063
|
+
const key = $('auth-key').value.trim();
|
|
1064
|
+
if (!key) return;
|
|
1065
|
+
S.apiKey = key;
|
|
1066
|
+
sessionStorage.setItem('bitpub_key', key);
|
|
1067
|
+
$('auth-error').classList.add('hidden');
|
|
1068
|
+
const data = await fetchData();
|
|
1069
|
+
if (data) { $('auth-overlay').classList.add('hidden'); ingestData(data); renderAll(); }
|
|
1070
|
+
else { $('auth-error').textContent = 'Invalid API key'; $('auth-error').classList.remove('hidden'); S.apiKey = null; sessionStorage.removeItem('bitpub_key'); }
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/* ══════════════════════════════════════════════════════
|
|
1074
|
+
Parsing & helpers
|
|
1075
|
+
══════════════════════════════════════════════════════ */
|
|
1076
|
+
function parseSlice(s) {
|
|
1077
|
+
const meta = typeof s.metadata === 'string' ? JSON.parse(s.metadata) : (s.metadata || {});
|
|
1078
|
+
const payload = typeof s.payload === 'string' ? JSON.parse(s.payload) : (s.payload || {});
|
|
1079
|
+
// The canonical "when was this last written" timestamp. The internal field
|
|
1080
|
+
// name `last_synced` is a misnomer kept for back-compat with downstream
|
|
1081
|
+
// renderers; the *value* is now write time, not cache freshness. Order:
|
|
1082
|
+
// 1. metadata.timestamp — author-declared write time, the truth-iest
|
|
1083
|
+
// 2. updated_at — server-side row-update time, almost always equal
|
|
1084
|
+
// 3. last_synced — local cache fetch time, last-resort fallback
|
|
1085
|
+
// Reversing the prior order matters: an explicit `bitpub fetch` or
|
|
1086
|
+
// `recent --sync` must NOT make every slice look freshly written.
|
|
1087
|
+
const updated = meta.timestamp || s.updated_at || s.last_synced || null;
|
|
1088
|
+
return {
|
|
1089
|
+
hcu: s.hcu,
|
|
1090
|
+
metadata: meta,
|
|
1091
|
+
payload,
|
|
1092
|
+
last_synced: updated, // write time (legacy field name)
|
|
1093
|
+
cached_at: s.last_synced || null, // genuine cache freshness, local-only
|
|
1094
|
+
deleted_at: s.deleted_at || null,
|
|
1095
|
+
deleted_by: s.deleted_by || null,
|
|
1096
|
+
written_by: s.written_by || null,
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function scopeOf(hcu) {
|
|
1101
|
+
const m = hcu.match(/^bitpub:\/\/([^/]+)/);
|
|
1102
|
+
if (!m) return { scope: '', type: 'group' };
|
|
1103
|
+
const scope = m[1];
|
|
1104
|
+
const type = scope.startsWith('private:') ? 'private' : scope.startsWith('group:') ? 'group' : 'public';
|
|
1105
|
+
return { scope, type };
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function pathOf(hcu) {
|
|
1109
|
+
const m = hcu.match(/^bitpub:\/\/[^/]+(\/.*)?$/);
|
|
1110
|
+
return (m && m[1]) ? m[1].replace(/^\//, '').split('/') : [];
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function buildTree(slices) {
|
|
1114
|
+
const tree = {};
|
|
1115
|
+
for (const s of slices) {
|
|
1116
|
+
const { scope, type } = scopeOf(s.hcu);
|
|
1117
|
+
const segs = pathOf(s.hcu);
|
|
1118
|
+
if (!tree[scope]) tree[scope] = { type, children: {}, count: 0 };
|
|
1119
|
+
let node = tree[scope]; node.count++;
|
|
1120
|
+
for (let i = 0; i < segs.length; i++) {
|
|
1121
|
+
if (!node.children[segs[i]]) node.children[segs[i]] = { children: {}, count: 0 };
|
|
1122
|
+
node = node.children[segs[i]]; node.count++;
|
|
1123
|
+
if (i === segs.length - 1) node.slice = s;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
return tree;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function computeStats(slices) {
|
|
1130
|
+
const byScope = { group: 0, private: 0, public: 0 };
|
|
1131
|
+
const byFresh = { fresh: 0, h24: 0, d7: 0, older: 0 };
|
|
1132
|
+
const tags = {};
|
|
1133
|
+
const authors = {};
|
|
1134
|
+
let latest = null;
|
|
1135
|
+
const nsCounts = {};
|
|
1136
|
+
for (const s of slices) {
|
|
1137
|
+
byScope[scopeOf(s.hcu).type]++;
|
|
1138
|
+
byFresh[freshnessOf(s.last_synced)]++;
|
|
1139
|
+
(s.metadata.tags || []).forEach(t => { tags[t] = (tags[t] || 0) + 1; });
|
|
1140
|
+
const author = s.metadata.author_id || s.written_by || 'unknown';
|
|
1141
|
+
authors[author] = (authors[author] || 0) + 1;
|
|
1142
|
+
const ts = s.last_synced || s.metadata.timestamp;
|
|
1143
|
+
if (ts && (!latest || ts > latest)) latest = ts;
|
|
1144
|
+
const top = pathOf(s.hcu)[0];
|
|
1145
|
+
if (top) nsCounts[top] = (nsCounts[top] || 0) + 1;
|
|
1146
|
+
}
|
|
1147
|
+
return { total: slices.length, byScope, byFresh, tags, tagCount: Object.keys(tags).length, authors, authorCount: Object.keys(authors).length, latest, nsCounts };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function maxTs(slices) {
|
|
1151
|
+
let m = null;
|
|
1152
|
+
for (const s of slices) {
|
|
1153
|
+
const ts = s.last_synced || s.metadata.timestamp;
|
|
1154
|
+
if (ts && (!m || ts > m)) m = ts;
|
|
1155
|
+
}
|
|
1156
|
+
return m;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function freshnessOf(ts) {
|
|
1160
|
+
if (!ts) return 'older';
|
|
1161
|
+
const ms = Date.now() - new Date(ts).getTime();
|
|
1162
|
+
if (ms < 0) return 'fresh';
|
|
1163
|
+
if (ms < 3600e3) return 'fresh'; // <1h
|
|
1164
|
+
if (ms < 86400e3) return 'h24'; // <24h
|
|
1165
|
+
if (ms < 7 * 86400e3) return 'd7'; // <7d
|
|
1166
|
+
return 'older';
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Volatility signal: how "alive" is this cell?
|
|
1170
|
+
// Only the latest value is retained, so version count is our proxy for
|
|
1171
|
+
// how often this path has been rewritten.
|
|
1172
|
+
function cellStateOf(slice) {
|
|
1173
|
+
// Tombstones short-circuit the freshness ladder: a deleted slice is a
|
|
1174
|
+
// single state regardless of how recently it was written. Restoring it
|
|
1175
|
+
// (via `bitpub restore`) clears `deleted_at` and the slice falls back
|
|
1176
|
+
// through the normal hot/active/stable/dormant ladder.
|
|
1177
|
+
if (slice.deleted_at) {
|
|
1178
|
+
const when = timeAgo(slice.deleted_at);
|
|
1179
|
+
const ver = slice.metadata?.version || 1;
|
|
1180
|
+
return { key: 'deleted', label: `deleted · v${ver}`, hint: `tombstoned ${when}. Recover with \`bitpub restore --address ${slice.hcu}\`.` };
|
|
1181
|
+
}
|
|
1182
|
+
const ver = slice.metadata?.version || 1;
|
|
1183
|
+
const fresh = freshnessOf(slice.last_synced);
|
|
1184
|
+
const recent = fresh === 'fresh' || fresh === 'h24';
|
|
1185
|
+
const ago = slice.last_synced ? timeAgo(slice.last_synced) : 'unknown';
|
|
1186
|
+
if (recent && ver >= 5) return { key: 'hot', label: `hot · v${ver}`, hint: `rewritten ${ver}× · last change ${ago}. Expect further writes.` };
|
|
1187
|
+
if (recent && ver === 1) return { key: 'new', label: 'new', hint: `first write — never overwritten (${ago})` };
|
|
1188
|
+
if (recent) return { key: 'active', label: 'active', hint: `changed ${ago} · now on v${ver}` };
|
|
1189
|
+
if (ver === 1) return { key: 'stable', label: 'untouched', hint: `first write · never overwritten (${ago})` };
|
|
1190
|
+
if (fresh === 'd7') return { key: 'stable', label: 'stable', hint: `unchanged for ${ago} · v${ver}` };
|
|
1191
|
+
return { key: 'dormant', label: 'dormant', hint: `unchanged for ${ago} · v${ver}` };
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function renderCellState(slice) {
|
|
1195
|
+
const st = cellStateOf(slice);
|
|
1196
|
+
return `<span class="cell-state ${st.key}" title="${esc(st.hint)}"><span class="pip"></span>${esc(st.label)}</span>`;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// Siblings = slices sharing the same parent path (same scope + same path prefix, one segment shorter)
|
|
1200
|
+
function siblingsOf(slice) {
|
|
1201
|
+
const { scope } = scopeOf(slice.hcu);
|
|
1202
|
+
const segs = pathOf(slice.hcu);
|
|
1203
|
+
if (!segs.length) return [];
|
|
1204
|
+
const parent = segs.slice(0, -1).join('/');
|
|
1205
|
+
const prefix = `bitpub://${scope}/${parent}${parent ? '/' : ''}`;
|
|
1206
|
+
const out = [];
|
|
1207
|
+
for (const s of S.slices) {
|
|
1208
|
+
if (!s.hcu.startsWith(prefix)) continue;
|
|
1209
|
+
const rest = s.hcu.slice(prefix.length);
|
|
1210
|
+
if (!rest || rest.includes('/')) continue; // must be at same depth
|
|
1211
|
+
out.push(s);
|
|
1212
|
+
}
|
|
1213
|
+
out.sort((a, b) => (b.last_synced || '').localeCompare(a.last_synced || ''));
|
|
1214
|
+
return out;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/* Filter application */
|
|
1218
|
+
function passesFilter(s) {
|
|
1219
|
+
const { type } = scopeOf(s.hcu);
|
|
1220
|
+
if (S.filter.scopes.size && !S.filter.scopes.has(type)) return false;
|
|
1221
|
+
if (S.filter.freshness !== 'all') {
|
|
1222
|
+
const b = freshnessOf(s.last_synced);
|
|
1223
|
+
const order = { 'fresh': 0, 'h24': 1, 'd7': 2, 'older': 3 };
|
|
1224
|
+
const targets = { '1h': 0, '24h': 1, '7d': 2 };
|
|
1225
|
+
if (order[b] > targets[S.filter.freshness]) return false;
|
|
1226
|
+
}
|
|
1227
|
+
if (S.filter.tags.size) {
|
|
1228
|
+
const tags = s.metadata.tags || [];
|
|
1229
|
+
for (const t of S.filter.tags) if (!tags.includes(t)) return false;
|
|
1230
|
+
}
|
|
1231
|
+
return true;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function filteredSlices() {
|
|
1235
|
+
return S.slices.filter(passesFilter);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function filterActive() {
|
|
1239
|
+
return S.filter.scopes.size > 0 || S.filter.freshness !== 'all' || S.filter.tags.size > 0;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function clearFilter() {
|
|
1243
|
+
S.filter.scopes = new Set();
|
|
1244
|
+
S.filter.freshness = 'all';
|
|
1245
|
+
S.filter.tags = new Set();
|
|
1246
|
+
renderAll();
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/* ══════════════════════════════════════════════════════
|
|
1250
|
+
Top-level render
|
|
1251
|
+
══════════════════════════════════════════════════════ */
|
|
1252
|
+
function renderAll() {
|
|
1253
|
+
renderModeBadge();
|
|
1254
|
+
renderAddress();
|
|
1255
|
+
renderLive();
|
|
1256
|
+
renderFilterStrip();
|
|
1257
|
+
renderTree();
|
|
1258
|
+
renderPanel();
|
|
1259
|
+
$('clear-filter-btn').classList.toggle('hidden', !filterActive());
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function renderModeBadge() {
|
|
1263
|
+
const el = $('mode-badge');
|
|
1264
|
+
if (S.mode === 'local') {
|
|
1265
|
+
el.className = 'mode-badge local';
|
|
1266
|
+
el.querySelector('.label').textContent = 'Local · decrypted';
|
|
1267
|
+
} else {
|
|
1268
|
+
el.className = 'mode-badge';
|
|
1269
|
+
el.querySelector('.label').textContent = 'Remote';
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function renderAddress() {
|
|
1274
|
+
const el = $('address');
|
|
1275
|
+
let html = '<span class="seg scheme">bitpub://</span>';
|
|
1276
|
+
if (S.view === 'overview') {
|
|
1277
|
+
html += '<span class="glob">**</span>';
|
|
1278
|
+
} else if (S.currentScope) {
|
|
1279
|
+
html += `<span class="sep">/</span><span class="seg" onclick="navigateTo('${escAttr(S.currentScope)}', [])">${esc(S.currentScope)}</span>`;
|
|
1280
|
+
const isSlice = S.view === 'slice';
|
|
1281
|
+
S.currentPath.forEach((seg, i) => {
|
|
1282
|
+
const last = i === S.currentPath.length - 1;
|
|
1283
|
+
const isCurrent = isSlice ? last : false;
|
|
1284
|
+
const className = isCurrent ? 'seg current' : 'seg';
|
|
1285
|
+
const subPath = S.currentPath.slice(0, i + 1);
|
|
1286
|
+
html += `<span class="sep">/</span>`;
|
|
1287
|
+
if (isCurrent) {
|
|
1288
|
+
html += `<span class="${className}">${esc(seg)}</span>`;
|
|
1289
|
+
} else {
|
|
1290
|
+
html += `<span class="${className}" onclick="navigateTo('${escAttr(S.currentScope)}', ${JSON.stringify(subPath).replace(/"/g, '"')})">${esc(seg)}</span>`;
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
if (!isSlice) html += '<span class="glob">/**</span>';
|
|
1294
|
+
}
|
|
1295
|
+
el.innerHTML = html;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function renderLive() {
|
|
1299
|
+
const el = $('live');
|
|
1300
|
+
const label = S.lastUpdated ? `updated ${timeAgo(S.lastUpdated)}` : 'no activity';
|
|
1301
|
+
el.querySelector('.label').textContent = label;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function renderFilterStrip() {
|
|
1305
|
+
const strip = $('filter-strip');
|
|
1306
|
+
const counts = S.stats.byScope || {};
|
|
1307
|
+
let html = '';
|
|
1308
|
+
html += '<span class="filter-label">Scope</span>';
|
|
1309
|
+
html += '<span class="pill-group">';
|
|
1310
|
+
for (const t of ['group', 'private', 'public']) {
|
|
1311
|
+
const active = S.filter.scopes.has(t);
|
|
1312
|
+
html += `<span class="pill scope ${active ? 'active' : ''}" data-scope="${t}" onclick="toggleScope('${t}')"><span class="dot"></span>${t}<span style="opacity:.55;margin-left:4px">${counts[t] || 0}</span></span>`;
|
|
1313
|
+
}
|
|
1314
|
+
html += '</span>';
|
|
1315
|
+
html += '<span class="filter-divider"></span>';
|
|
1316
|
+
html += '<span class="filter-label">Fresh</span>';
|
|
1317
|
+
html += '<span class="pill-group">';
|
|
1318
|
+
for (const [k, lbl] of [['all', 'all'], ['1h', '< 1h'], ['24h', '< 24h'], ['7d', '< 7d']]) {
|
|
1319
|
+
const active = S.filter.freshness === k;
|
|
1320
|
+
html += `<span class="pill ${active ? 'active' : ''}" onclick="setFreshness('${k}')">${lbl}</span>`;
|
|
1321
|
+
}
|
|
1322
|
+
html += '</span>';
|
|
1323
|
+
if (S.filter.tags.size) {
|
|
1324
|
+
html += '<span class="filter-divider"></span>';
|
|
1325
|
+
html += '<span class="filter-label">Tags</span>';
|
|
1326
|
+
html += '<span class="pill-group">';
|
|
1327
|
+
for (const t of S.filter.tags) {
|
|
1328
|
+
html += `<span class="pill tag" onclick="toggleTag('${escAttr(t)}')">${esc(t)}<span class="x">×</span></span>`;
|
|
1329
|
+
}
|
|
1330
|
+
html += '</span>';
|
|
1331
|
+
}
|
|
1332
|
+
const filtered = filteredSlices();
|
|
1333
|
+
html += `<span class="filter-count"><span class="n">${filtered.length}</span> / ${S.stats.total || 0} slices</span>`;
|
|
1334
|
+
strip.innerHTML = html;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function toggleScope(t) {
|
|
1338
|
+
if (S.filter.scopes.has(t)) S.filter.scopes.delete(t); else S.filter.scopes.add(t);
|
|
1339
|
+
renderAll();
|
|
1340
|
+
}
|
|
1341
|
+
function setFreshness(k) { S.filter.freshness = k; renderAll(); }
|
|
1342
|
+
function toggleTag(t) {
|
|
1343
|
+
if (S.filter.tags.has(t)) S.filter.tags.delete(t); else S.filter.tags.add(t);
|
|
1344
|
+
renderAll();
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/* ══════════════════════════════════════════════════════
|
|
1348
|
+
Tree
|
|
1349
|
+
══════════════════════════════════════════════════════ */
|
|
1350
|
+
function renderTree() {
|
|
1351
|
+
const scopes = Object.entries(S.tree).sort((a, b) => a[0].localeCompare(b[0]));
|
|
1352
|
+
let html = '';
|
|
1353
|
+
for (const [scope, node] of scopes) {
|
|
1354
|
+
// Filter scope by current filter
|
|
1355
|
+
const counts = countsForScopeNode(scope, node);
|
|
1356
|
+
const expanded = S.currentScope === scope || !S.currentScope;
|
|
1357
|
+
const active = S.view === 'namespace' && S.currentScope === scope && !S.currentPath.length;
|
|
1358
|
+
const isPriv = node.type === 'private';
|
|
1359
|
+
html += `<div class="tree-node${expanded ? ' expanded' : ''}" data-scope="${escAttr(scope)}">
|
|
1360
|
+
<div class="tree-header${active ? ' active' : ''}" onclick="onTreeScopeClick(event, '${escAttr(scope)}')">
|
|
1361
|
+
<span class="chevron" onclick="toggleNodeEvent(event)">${ICON.chev}</span>
|
|
1362
|
+
<span class="scope-dot ${node.type}"></span>
|
|
1363
|
+
${isPriv ? `<span class="tree-lock">${ICON.lock}</span>` : ''}
|
|
1364
|
+
<span class="tree-label mono">${esc(scope)}</span>
|
|
1365
|
+
<span class="tree-badge">${counts}</span>
|
|
1366
|
+
</div>
|
|
1367
|
+
<div class="tree-children">${renderBranch(scope, node.children, [])}</div>
|
|
1368
|
+
</div>`;
|
|
1369
|
+
}
|
|
1370
|
+
if (!html) html = '<div class="empty" style="padding:32px 12px"><p>No namespaces yet</p></div>';
|
|
1371
|
+
$('tree').innerHTML = html;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function countsForScopeNode(scope, node) {
|
|
1375
|
+
// Count filtered leaves under this scope
|
|
1376
|
+
let n = 0;
|
|
1377
|
+
const walk = (nd) => {
|
|
1378
|
+
if (nd.slice && passesFilter(nd.slice)) n++;
|
|
1379
|
+
for (const c of Object.values(nd.children || {})) walk(c);
|
|
1380
|
+
};
|
|
1381
|
+
walk(node);
|
|
1382
|
+
return n;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function renderBranch(scope, children, path) {
|
|
1386
|
+
// Classify: direct leaves (aggregated) vs. subfolders (recursed into).
|
|
1387
|
+
// This keeps the tree bounded in vertical size even at high slice volume;
|
|
1388
|
+
// the aggregated "N context slices" entry navigates to the namespace view
|
|
1389
|
+
// where slices are listed reverse-chronologically.
|
|
1390
|
+
const directLeaves = [];
|
|
1391
|
+
const subfolders = [];
|
|
1392
|
+
for (const [name, node] of Object.entries(children)) {
|
|
1393
|
+
const hasSubfolders = Object.keys(node.children).length > 0;
|
|
1394
|
+
if (!hasSubfolders && node.slice) {
|
|
1395
|
+
if (passesFilter(node.slice)) directLeaves.push({ name, slice: node.slice });
|
|
1396
|
+
} else if (hasSubfolders) {
|
|
1397
|
+
subfolders.push({ name, node });
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
subfolders.sort((a, b) => a.name.localeCompare(b.name));
|
|
1401
|
+
|
|
1402
|
+
let html = '';
|
|
1403
|
+
|
|
1404
|
+
// Aggregated direct-leaf entry for this level
|
|
1405
|
+
if (directLeaves.length > 0) {
|
|
1406
|
+
const mostRecent = directLeaves.reduce(
|
|
1407
|
+
(a, b) => ((a.slice.last_synced || '') > (b.slice.last_synced || '') ? a : b)
|
|
1408
|
+
);
|
|
1409
|
+
const fresh = freshnessOf(mostRecent.slice.last_synced);
|
|
1410
|
+
const freshClass = fresh === 'fresh' ? 'fresh' : fresh === 'h24' ? 'h24' : fresh === 'd7' ? 'd7' : 'older';
|
|
1411
|
+
const pathArg = JSON.stringify(path).replace(/"/g, '"');
|
|
1412
|
+
const isActiveSummary = S.view === 'namespace' && S.currentScope === scope && arrayEq(S.currentPath, path);
|
|
1413
|
+
const when = mostRecent.slice.last_synced ? timeAgo(mostRecent.slice.last_synced) : '';
|
|
1414
|
+
html += `<div class="tree-leaf tree-summary${isActiveSummary ? ' active' : ''}" onclick="navigateTo('${escAttr(scope)}', ${pathArg})" title="View ${directLeaves.length} slice${directLeaves.length !== 1 ? 's' : ''} at this level">
|
|
1415
|
+
<span class="fresh-dot ${freshClass}"></span>
|
|
1416
|
+
<span class="tree-label">${directLeaves.length} context slice${directLeaves.length !== 1 ? 's' : ''}</span>
|
|
1417
|
+
<span class="tree-badge">${esc(when)}</span>
|
|
1418
|
+
</div>`;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// Subfolders (recursed)
|
|
1422
|
+
for (const { name, node } of subfolders) {
|
|
1423
|
+
const subPath = [...path, name];
|
|
1424
|
+
const count = countsForScopeNode(scope, node);
|
|
1425
|
+
if (filterActive() && count === 0) continue;
|
|
1426
|
+
const isActive = S.view === 'namespace' && S.currentScope === scope && arrayEq(S.currentPath, subPath);
|
|
1427
|
+
const expanded = isPathAncestor(subPath);
|
|
1428
|
+
const pathArg = JSON.stringify(subPath).replace(/"/g, '"');
|
|
1429
|
+
const indexHTML = node.slice && passesFilter(node.slice) ? renderLeaf(node.slice, '(index)') : '';
|
|
1430
|
+
html += `<div class="tree-node${expanded ? ' expanded' : ''}">
|
|
1431
|
+
<div class="tree-header${isActive ? ' active' : ''}" onclick="onTreeFolderClick(event, '${escAttr(scope)}', ${pathArg})">
|
|
1432
|
+
<span class="chevron" onclick="toggleNodeEvent(event)">${ICON.chev}</span>
|
|
1433
|
+
<span class="tree-label">${esc(name.replace(/_/g, ' '))}</span>
|
|
1434
|
+
<span class="tree-badge">${count}</span>
|
|
1435
|
+
</div>
|
|
1436
|
+
<div class="tree-children">
|
|
1437
|
+
${indexHTML}
|
|
1438
|
+
${renderBranch(scope, node.children, subPath)}
|
|
1439
|
+
</div>
|
|
1440
|
+
</div>`;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
return html;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function renderLeaf(slice, label) {
|
|
1447
|
+
const active = S.selectedHcu === slice.hcu;
|
|
1448
|
+
const fresh = freshnessOf(slice.last_synced);
|
|
1449
|
+
return `<div class="tree-leaf${active ? ' active' : ''}" onclick="selectSlice('${escAttr(slice.hcu)}')">
|
|
1450
|
+
<span class="fresh-dot ${fresh === 'fresh' ? 'fresh' : fresh === 'h24' ? 'h24' : fresh === 'd7' ? 'd7' : 'older'}"></span>
|
|
1451
|
+
<span class="tree-label">${esc(label)}</span>
|
|
1452
|
+
</div>`;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function arrayEq(a, b) {
|
|
1456
|
+
if (a.length !== b.length) return false;
|
|
1457
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
1458
|
+
return true;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function isPathAncestor(path) {
|
|
1462
|
+
if (S.view !== 'namespace' && S.view !== 'slice') return false;
|
|
1463
|
+
if (S.currentPath.length < path.length) return false;
|
|
1464
|
+
for (let i = 0; i < path.length; i++) if (S.currentPath[i] !== path[i]) return false;
|
|
1465
|
+
return true;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function toggleNodeEvent(ev) {
|
|
1469
|
+
ev.stopPropagation();
|
|
1470
|
+
const nd = ev.currentTarget.closest('.tree-node');
|
|
1471
|
+
if (nd) nd.classList.toggle('expanded');
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function onTreeScopeClick(ev, scope) {
|
|
1475
|
+
if (ev.target.closest('.chevron')) return;
|
|
1476
|
+
navigateTo(scope, []);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function onTreeFolderClick(ev, scope, path) {
|
|
1480
|
+
if (ev.target.closest('.chevron')) return;
|
|
1481
|
+
navigateTo(scope, path);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/* ══════════════════════════════════════════════════════
|
|
1485
|
+
Navigation
|
|
1486
|
+
══════════════════════════════════════════════════════ */
|
|
1487
|
+
function goRoot() {
|
|
1488
|
+
S.view = 'overview';
|
|
1489
|
+
S.currentScope = null;
|
|
1490
|
+
S.currentPath = [];
|
|
1491
|
+
S.selectedHcu = null;
|
|
1492
|
+
S.searchQuery = '';
|
|
1493
|
+
$('search').value = '';
|
|
1494
|
+
renderAll();
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function navigateTo(scope, path) {
|
|
1498
|
+
S.view = 'namespace';
|
|
1499
|
+
S.currentScope = scope;
|
|
1500
|
+
S.currentPath = Array.isArray(path) ? path : [];
|
|
1501
|
+
S.selectedHcu = null;
|
|
1502
|
+
renderAll();
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function selectSlice(hcu) {
|
|
1506
|
+
const sl = S.slices.find(s => s.hcu === hcu);
|
|
1507
|
+
if (!sl) return;
|
|
1508
|
+
const { scope } = scopeOf(hcu);
|
|
1509
|
+
S.view = 'slice';
|
|
1510
|
+
S.currentScope = scope;
|
|
1511
|
+
S.currentPath = pathOf(hcu);
|
|
1512
|
+
S.selectedHcu = hcu;
|
|
1513
|
+
S.raw = false;
|
|
1514
|
+
renderAll();
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/* ══════════════════════════════════════════════════════
|
|
1518
|
+
Panel dispatch
|
|
1519
|
+
══════════════════════════════════════════════════════ */
|
|
1520
|
+
function renderPanel() {
|
|
1521
|
+
if (S.searchQuery && S.searchQuery.length >= 2) { renderSearch(); return; }
|
|
1522
|
+
if (S.view === 'overview') { renderOverview(); return; }
|
|
1523
|
+
if (S.view === 'namespace') { renderNamespace(); return; }
|
|
1524
|
+
if (S.view === 'slice') {
|
|
1525
|
+
const sl = S.slices.find(s => s.hcu === S.selectedHcu);
|
|
1526
|
+
if (sl) renderSlice(sl); else renderOverview();
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/* ══════════════════════════════════════════════════════
|
|
1532
|
+
Overview: stream + telemetry
|
|
1533
|
+
══════════════════════════════════════════════════════ */
|
|
1534
|
+
function renderOverview() {
|
|
1535
|
+
const slices = filteredSlices();
|
|
1536
|
+
if (!slices.length && !S.slices.length) {
|
|
1537
|
+
// Truly fresh install — the welcome slice hasn't synced to the local
|
|
1538
|
+
// cache yet (or this is a --local-only install with no welcome push).
|
|
1539
|
+
// Show the welcome panel anyway if the URL flag is set, so install.sh's
|
|
1540
|
+
// ?welcome=1 entry point still lands somewhere meaningful.
|
|
1541
|
+
const welcomeIfFlagged = shouldShowWelcomePanel(slices) ? renderWelcomePanel() : '';
|
|
1542
|
+
$('main').innerHTML = `<div class="panel">${welcomeIfFlagged}<div class="empty">${ICON.empty}
|
|
1543
|
+
<h2>Your shared memory is empty</h2>
|
|
1544
|
+
<p>This is a live view of your org's agent memory — each slice is the <em>latest value</em> at its address, written by humans or agents. Pull your team's context to populate it:</p>
|
|
1545
|
+
<p><code>bitpub fetch --address "bitpub://group:yourdomain.com/**"</code></p></div></div>`;
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
const recent = [...slices].sort((a, b) => (b.last_synced || '').localeCompare(a.last_synced || '')).slice(0, 15);
|
|
1549
|
+
|
|
1550
|
+
let html = '<div class="panel">';
|
|
1551
|
+
|
|
1552
|
+
if (shouldShowWelcomePanel(slices)) {
|
|
1553
|
+
html += renderWelcomePanel();
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
html += `<div class="section-h">Recent writes <span class="count">${recent.length}</span>${S.lastUpdated ? `<span class="live-hint"><span class="dot"></span>updated ${timeAgo(S.lastUpdated)}</span>` : ''}</div>`;
|
|
1557
|
+
html += `<div class="stream-sub">The latest value at each address — slices can be overwritten in place, appended to, or joined by new slices under the same namespace.</div>`;
|
|
1558
|
+
html += '<div class="stream">';
|
|
1559
|
+
for (const sl of recent) {
|
|
1560
|
+
html += renderStreamRow(sl);
|
|
1561
|
+
}
|
|
1562
|
+
if (!recent.length) {
|
|
1563
|
+
html += '<div class="empty" style="padding:40px 20px">No slices match the current filter.</div>';
|
|
1564
|
+
}
|
|
1565
|
+
html += '</div>';
|
|
1566
|
+
|
|
1567
|
+
// Activity heatmap — one square per day, colored by last-write volume
|
|
1568
|
+
html += '<div class="section-h" style="margin-top:28px">Context activity</div>';
|
|
1569
|
+
html += renderActivityHeatmap(slices);
|
|
1570
|
+
|
|
1571
|
+
// Group READMEs — any slice at `bitpub://<scope>/README`
|
|
1572
|
+
const readmes = findReadmes(slices);
|
|
1573
|
+
if (readmes.length) {
|
|
1574
|
+
html += '<div class="section-h" style="margin-top:24px">Group README</div>';
|
|
1575
|
+
for (const sl of readmes) html += renderReadmeCard(sl);
|
|
1576
|
+
} else {
|
|
1577
|
+
html += renderReadmeHint();
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
html += '</div>';
|
|
1581
|
+
$('main').innerHTML = html;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/* ── Activity heatmap ────────────────────────────────
|
|
1585
|
+
* One square per day for the current calendar year (Jan 1 → Dec 31).
|
|
1586
|
+
* Intensity = # of slices whose *latest* write landed that day.
|
|
1587
|
+
* Caveat: the protocol only retains the latest version per address, so
|
|
1588
|
+
* this is not a true commit log — it's a "last-touched" calendar.
|
|
1589
|
+
* That's still a useful signal for heavy-context days.
|
|
1590
|
+
*
|
|
1591
|
+
* Layout: week-columns run Sun→Sat; cell pixel size is computed at
|
|
1592
|
+
* render time so the grid always fills the card width.
|
|
1593
|
+
*/
|
|
1594
|
+
function renderActivityHeatmap(slices) {
|
|
1595
|
+
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
1596
|
+
const year = today.getFullYear();
|
|
1597
|
+
const yearStart = new Date(year, 0, 1); yearStart.setHours(0, 0, 0, 0);
|
|
1598
|
+
const yearEnd = new Date(year, 11, 31); yearEnd.setHours(0, 0, 0, 0);
|
|
1599
|
+
|
|
1600
|
+
// Grid spans from the Sunday on or before Jan 1 through the Saturday
|
|
1601
|
+
// on or after Dec 31. That's exactly 53 weeks in the worst case.
|
|
1602
|
+
const gridStart = new Date(yearStart);
|
|
1603
|
+
while (gridStart.getDay() !== 0) gridStart.setDate(gridStart.getDate() - 1);
|
|
1604
|
+
const gridEnd = new Date(yearEnd);
|
|
1605
|
+
while (gridEnd.getDay() !== 6) gridEnd.setDate(gridEnd.getDate() + 1);
|
|
1606
|
+
|
|
1607
|
+
// Bucket slices by local-date key (only counting writes inside this year)
|
|
1608
|
+
const counts = {};
|
|
1609
|
+
for (const sl of slices) {
|
|
1610
|
+
if (!sl.last_synced) continue;
|
|
1611
|
+
const d = new Date(sl.last_synced); d.setHours(0, 0, 0, 0);
|
|
1612
|
+
if (d < yearStart || d > yearEnd) continue;
|
|
1613
|
+
const key = dayKey(d);
|
|
1614
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
const maxN = Math.max(1, ...Object.values(counts));
|
|
1618
|
+
const level = n => {
|
|
1619
|
+
if (!n) return 0;
|
|
1620
|
+
const r = n / maxN;
|
|
1621
|
+
if (r <= 0.25) return 1;
|
|
1622
|
+
if (r <= 0.5) return 2;
|
|
1623
|
+
if (r <= 0.75) return 3;
|
|
1624
|
+
return 4;
|
|
1625
|
+
};
|
|
1626
|
+
|
|
1627
|
+
// Build week-columns across the full year window
|
|
1628
|
+
const weeks = [];
|
|
1629
|
+
const cur = new Date(gridStart);
|
|
1630
|
+
while (cur <= gridEnd) {
|
|
1631
|
+
const col = [];
|
|
1632
|
+
for (let dow = 0; dow < 7; dow++) {
|
|
1633
|
+
const inYear = cur >= yearStart && cur <= yearEnd;
|
|
1634
|
+
if (!inYear) {
|
|
1635
|
+
col.push({ placeholder: true, date: new Date(cur) });
|
|
1636
|
+
} else {
|
|
1637
|
+
const key = dayKey(cur);
|
|
1638
|
+
const n = counts[key] || 0;
|
|
1639
|
+
col.push({ date: new Date(cur), count: n, lvl: level(n) });
|
|
1640
|
+
}
|
|
1641
|
+
cur.setDate(cur.getDate() + 1);
|
|
1642
|
+
}
|
|
1643
|
+
weeks.push(col);
|
|
1644
|
+
}
|
|
1645
|
+
const WEEKS = weeks.length; // 52 or 53
|
|
1646
|
+
|
|
1647
|
+
// Cell size: fill the container width up to a hard cap. Reserve space
|
|
1648
|
+
// for the day-label gutter (~16px + 10px gap) and card padding (~36px).
|
|
1649
|
+
// Cap cells at 15px so the grid doesn't look chunky on very wide
|
|
1650
|
+
// viewports; when cells hit the cap, the grid center-aligns.
|
|
1651
|
+
const GAP = 3, CELL_MAX = 15, CELL_MIN = 10;
|
|
1652
|
+
const mainW = ($('main') && $('main').clientWidth) || 800;
|
|
1653
|
+
const avail = Math.max(0, mainW - 16 - 10 - 36);
|
|
1654
|
+
const ideal = Math.floor((avail - (WEEKS - 1) * GAP) / WEEKS);
|
|
1655
|
+
const cell = Math.max(CELL_MIN, Math.min(CELL_MAX, ideal));
|
|
1656
|
+
|
|
1657
|
+
// Month labels — first Sunday-column whose first in-year cell lands in that month
|
|
1658
|
+
const monthSpans = [];
|
|
1659
|
+
let lastMonth = -1;
|
|
1660
|
+
weeks.forEach((col, i) => {
|
|
1661
|
+
const firstIn = col.find(c => !c.placeholder);
|
|
1662
|
+
if (!firstIn) return;
|
|
1663
|
+
const m = firstIn.date.getMonth();
|
|
1664
|
+
if (m !== lastMonth) {
|
|
1665
|
+
monthSpans.push({ i, label: firstIn.date.toLocaleDateString(undefined, { month: 'short' }) });
|
|
1666
|
+
lastMonth = m;
|
|
1667
|
+
}
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
// Summary stats
|
|
1671
|
+
const totalWrites = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
1672
|
+
const activeDays = Object.values(counts).filter(n => n > 0).length;
|
|
1673
|
+
let peakDay = null, peakN = 0;
|
|
1674
|
+
for (const [k, n] of Object.entries(counts)) if (n > peakN) { peakN = n; peakDay = k; }
|
|
1675
|
+
|
|
1676
|
+
let html = '<div class="heatmap-card">';
|
|
1677
|
+
html += '<div class="heatmap-head">';
|
|
1678
|
+
html += `<h4>${year}</h4>`;
|
|
1679
|
+
html += '<div class="stats">';
|
|
1680
|
+
html += `<span><span class="k">${totalWrites}</span> slice${totalWrites !== 1 ? 's' : ''} touched this year</span>`;
|
|
1681
|
+
html += `<span><span class="k">${activeDays}</span> active day${activeDays !== 1 ? 's' : ''}</span>`;
|
|
1682
|
+
if (peakDay) {
|
|
1683
|
+
const pd = new Date(peakDay + 'T00:00:00');
|
|
1684
|
+
html += `<span>peak <span class="k">${peakN}</span> on <span class="k">${pd.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</span></span>`;
|
|
1685
|
+
}
|
|
1686
|
+
html += '</div>';
|
|
1687
|
+
html += '<span class="sub">each square = one day, colored by slices last touched</span>';
|
|
1688
|
+
html += '</div>';
|
|
1689
|
+
|
|
1690
|
+
html += '<div class="heatmap-grid">';
|
|
1691
|
+
html += `<div class="heatmap-day-labels" style="grid-template-rows: repeat(7, ${cell}px);"><span>S</span><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span></div>`;
|
|
1692
|
+
html += '<div class="heatmap-cols">';
|
|
1693
|
+
|
|
1694
|
+
// Month label row — positions tied to the computed cell width
|
|
1695
|
+
const weekStride = cell + GAP;
|
|
1696
|
+
html += `<div class="heatmap-months" style="grid-template-columns: repeat(${WEEKS}, ${cell}px);">`;
|
|
1697
|
+
for (const m of monthSpans) {
|
|
1698
|
+
const left = m.i * weekStride;
|
|
1699
|
+
html += `<span class="m" style="left:${left}px">${esc(m.label)}</span>`;
|
|
1700
|
+
}
|
|
1701
|
+
html += '</div>';
|
|
1702
|
+
|
|
1703
|
+
// Weeks
|
|
1704
|
+
html += `<div class="heatmap-weeks" style="grid-auto-columns: ${cell}px; gap: ${GAP}px;">`;
|
|
1705
|
+
for (const col of weeks) {
|
|
1706
|
+
html += `<div class="heatmap-week" style="grid-template-rows: repeat(7, ${cell}px); gap: ${GAP}px;">`;
|
|
1707
|
+
for (const c of col) {
|
|
1708
|
+
if (c.placeholder) {
|
|
1709
|
+
html += '<div class="heatmap-cell placeholder"></div>';
|
|
1710
|
+
continue;
|
|
1711
|
+
}
|
|
1712
|
+
const dateStr = c.date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
|
1713
|
+
const tip = c.count ? `${c.count} slice${c.count !== 1 ? 's' : ''} touched · ${dateStr}` : `no writes · ${dateStr}`;
|
|
1714
|
+
html += `<div class="heatmap-cell l${c.lvl}" title="${escAttr(tip)}"></div>`;
|
|
1715
|
+
}
|
|
1716
|
+
html += '</div>';
|
|
1717
|
+
}
|
|
1718
|
+
html += '</div>'; // weeks
|
|
1719
|
+
html += '</div>'; // cols
|
|
1720
|
+
html += '</div>'; // grid
|
|
1721
|
+
|
|
1722
|
+
html += '<div class="heatmap-legend">';
|
|
1723
|
+
html += '<span>less</span>';
|
|
1724
|
+
html += '<div class="cells"><div class="c"></div><div class="c l1"></div><div class="c l2"></div><div class="c l3"></div><div class="c l4"></div></div>';
|
|
1725
|
+
html += '<span>more</span>';
|
|
1726
|
+
html += '</div>';
|
|
1727
|
+
|
|
1728
|
+
html += '</div>'; // card
|
|
1729
|
+
return html;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function dayKey(d) {
|
|
1733
|
+
// local-date YYYY-MM-DD
|
|
1734
|
+
const y = d.getFullYear();
|
|
1735
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
1736
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
1737
|
+
return `${y}-${m}-${day}`;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/* ── Group README ───────────────────────────────────
|
|
1741
|
+
* Convention: a slice at `bitpub://<scope>/README` (exact tail) is
|
|
1742
|
+
* treated as the landing page for that group/scope and rendered here.
|
|
1743
|
+
*/
|
|
1744
|
+
function findReadmes(slices) {
|
|
1745
|
+
const out = [];
|
|
1746
|
+
for (const sl of slices) {
|
|
1747
|
+
const tail = sl.hcu.replace(/^bitpub:\/\/[^/]+\//, '');
|
|
1748
|
+
if (/^readme(\.md)?$/i.test(tail)) out.push(sl);
|
|
1749
|
+
}
|
|
1750
|
+
// One README per scope — if multiple, pick the most recently written
|
|
1751
|
+
const byScope = new Map();
|
|
1752
|
+
for (const sl of out) {
|
|
1753
|
+
const { scope } = scopeOf(sl.hcu);
|
|
1754
|
+
const prev = byScope.get(scope);
|
|
1755
|
+
if (!prev || (sl.last_synced || '') > (prev.last_synced || '')) byScope.set(scope, sl);
|
|
1756
|
+
}
|
|
1757
|
+
return [...byScope.values()].sort((a, b) => {
|
|
1758
|
+
const ta = scopeOf(a.hcu).type, tb = scopeOf(b.hcu).type;
|
|
1759
|
+
const rank = t => t === 'group' ? 0 : t === 'public' ? 1 : 2;
|
|
1760
|
+
if (rank(ta) !== rank(tb)) return rank(ta) - rank(tb);
|
|
1761
|
+
return scopeOf(a.hcu).scope.localeCompare(scopeOf(b.hcu).scope);
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function renderReadmeCard(sl) {
|
|
1766
|
+
const { type, scope } = scopeOf(sl.hcu);
|
|
1767
|
+
const author = sl.metadata.author_id || sl.written_by || 'unknown';
|
|
1768
|
+
const content = sl.payload.content || '';
|
|
1769
|
+
let html = '<div class="readme-card">';
|
|
1770
|
+
html += '<div class="readme-head">';
|
|
1771
|
+
html += `<span class="label">${esc(scope)} · README</span>`;
|
|
1772
|
+
html += `<span class="hcu" onclick="selectSlice('${escAttr(sl.hcu)}')" title="Open slice">${esc(sl.hcu)}</span>`;
|
|
1773
|
+
html += '<span class="meta">';
|
|
1774
|
+
html += renderScopePill(type, scope);
|
|
1775
|
+
html += `<span>by</span>${renderAgentChip(author)}`;
|
|
1776
|
+
html += `<span>· updated ${sl.last_synced ? timeAgo(sl.last_synced) : '—'}</span>`;
|
|
1777
|
+
html += '</span>';
|
|
1778
|
+
html += '</div>';
|
|
1779
|
+
html += `<div class="readme-body content-body">${md(content)}</div>`;
|
|
1780
|
+
html += '</div>';
|
|
1781
|
+
return html;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function renderReadmeHint() {
|
|
1785
|
+
// Only show the hint if there's at least one scope visible.
|
|
1786
|
+
const scopes = Object.keys(S.tree || {});
|
|
1787
|
+
if (!scopes.length) return '';
|
|
1788
|
+
const example = scopes.find(s => s.startsWith('group:')) || scopes[0];
|
|
1789
|
+
return `<div class="readme-empty-hint">Tip: write a slice at <code>bitpub://${esc(example)}/README</code> to give this scope a landing page.</div>`;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
function renderStreamRow(sl) {
|
|
1793
|
+
const { type, scope } = scopeOf(sl.hcu);
|
|
1794
|
+
const fresh = freshnessOf(sl.last_synced);
|
|
1795
|
+
const author = sl.metadata.author_id || sl.written_by || 'unknown';
|
|
1796
|
+
const tail = sl.hcu.replace(/^bitpub:\/\/[^/]+\//, '');
|
|
1797
|
+
const isNew = S.newArrivals.has(sl.hcu);
|
|
1798
|
+
const freshClass = fresh === 'fresh' ? 'fresh' : fresh === 'h24' ? 'h24' : fresh === 'd7' ? 'd7' : 'older';
|
|
1799
|
+
return `<div class="stream-row${isNew ? ' new' : ''}" onclick="selectSlice('${escAttr(sl.hcu)}')">
|
|
1800
|
+
<span class="fresh-dot ${freshClass}" title="${fresh}"></span>
|
|
1801
|
+
${renderAgentChip(author)}
|
|
1802
|
+
${renderScopePill(type, scope)}
|
|
1803
|
+
<span class="hcu-tail"><span class="scope-prefix">${esc(scope)}/</span>${esc(tail)}</span>
|
|
1804
|
+
${renderCellState(sl)}
|
|
1805
|
+
<span class="when">${sl.last_synced ? timeAgo(sl.last_synced) : ''}</span>
|
|
1806
|
+
</div>`;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
function renderAgentChip(author) {
|
|
1810
|
+
return `<span class="agent-chip" title="written by ${esc(author)}">${ICON.hex}${esc(author)}</span>`;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
function renderScopePill(type, scope) {
|
|
1814
|
+
const isPriv = type === 'private';
|
|
1815
|
+
return `<span class="scope-pill ${type}" title="${esc(scope)}"><span class="dot"></span>${type}${isPriv ? `<span class="lock">${ICON.lock}</span>` : ''}</span>`;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/* ══════════════════════════════════════════════════════
|
|
1819
|
+
Namespace view
|
|
1820
|
+
══════════════════════════════════════════════════════ */
|
|
1821
|
+
function renderNamespace() {
|
|
1822
|
+
const scope = S.currentScope;
|
|
1823
|
+
const scopeNode = S.tree[scope];
|
|
1824
|
+
if (!scopeNode) { renderOverview(); return; }
|
|
1825
|
+
|
|
1826
|
+
let node = scopeNode;
|
|
1827
|
+
for (const seg of S.currentPath) {
|
|
1828
|
+
if (!node.children[seg]) { renderOverview(); return; }
|
|
1829
|
+
node = node.children[seg];
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
const pathStr = S.currentPath.length ? '/' + S.currentPath.join('/') : '';
|
|
1833
|
+
const fullUri = `bitpub://${scope}${pathStr}/**`;
|
|
1834
|
+
const { type } = scopeOf(`bitpub://${scope}`);
|
|
1835
|
+
const isPriv = type === 'private';
|
|
1836
|
+
|
|
1837
|
+
// Collect slices within this subtree
|
|
1838
|
+
const subSlices = [];
|
|
1839
|
+
const walk = (n) => { if (n.slice) subSlices.push(n.slice); for (const c of Object.values(n.children || {})) walk(c); };
|
|
1840
|
+
walk(node);
|
|
1841
|
+
const visible = subSlices.filter(passesFilter);
|
|
1842
|
+
const lastWrite = maxTs(visible);
|
|
1843
|
+
|
|
1844
|
+
let html = '<div class="panel">';
|
|
1845
|
+
|
|
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>`;
|
|
1853
|
+
|
|
1854
|
+
// Stats strip
|
|
1855
|
+
const tagsInSubtree = new Set();
|
|
1856
|
+
for (const s of visible) (s.metadata.tags || []).forEach(t => tagsInSubtree.add(t));
|
|
1857
|
+
html += `<div class="stats-strip">
|
|
1858
|
+
<span class="s-item"><span class="n">${visible.length}</span> slice${visible.length !== 1 ? 's' : ''}</span>
|
|
1859
|
+
<span class="s-item"><span class="n">${tagsInSubtree.size}</span> tag${tagsInSubtree.size !== 1 ? 's' : ''}</span>
|
|
1860
|
+
${lastWrite ? `<span class="s-item">last write <span class="n">${timeAgo(lastWrite)}</span></span>` : ''}
|
|
1861
|
+
</div>`;
|
|
1862
|
+
|
|
1863
|
+
// Split direct children into subfolders and direct-leaf slices
|
|
1864
|
+
const folderRows = [];
|
|
1865
|
+
const sliceRows = [];
|
|
1866
|
+
for (const [name, child] of Object.entries(node.children)) {
|
|
1867
|
+
const hasChildren = Object.keys(child.children).length > 0;
|
|
1868
|
+
if (hasChildren) {
|
|
1869
|
+
const childSub = [];
|
|
1870
|
+
const walk2 = (n) => { if (n.slice) childSub.push(n.slice); for (const c of Object.values(n.children || {})) walk2(c); };
|
|
1871
|
+
walk2(child);
|
|
1872
|
+
const visChildren = childSub.filter(passesFilter);
|
|
1873
|
+
if (!visChildren.length && filterActive()) continue;
|
|
1874
|
+
const latest = maxTs(visChildren);
|
|
1875
|
+
folderRows.push({ name, count: visChildren.length, latest });
|
|
1876
|
+
} else if (child.slice && passesFilter(child.slice)) {
|
|
1877
|
+
sliceRows.push({ name, slice: child.slice });
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
// Folders: alphabetical. Slices: reverse-chronological (newest first).
|
|
1881
|
+
folderRows.sort((a, b) => a.name.localeCompare(b.name));
|
|
1882
|
+
sliceRows.sort((a, b) => (b.slice.last_synced || '').localeCompare(a.slice.last_synced || ''));
|
|
1883
|
+
|
|
1884
|
+
// Subfolders section
|
|
1885
|
+
if (folderRows.length) {
|
|
1886
|
+
html += `<div class="section-h" style="margin-top:18px">Subfolders <span class="count">${folderRows.length}</span></div>`;
|
|
1887
|
+
html += '<div class="file-box">';
|
|
1888
|
+
for (const r of folderRows) {
|
|
1889
|
+
const subPath = [...S.currentPath, r.name];
|
|
1890
|
+
const pathArg = JSON.stringify(subPath).replace(/"/g, '"');
|
|
1891
|
+
html += `<div class="file-row" onclick="navigateTo('${escAttr(scope)}', ${pathArg})">
|
|
1892
|
+
<span class="f-icon folder">${ICON.folder}</span>
|
|
1893
|
+
<span class="f-name">${esc(r.name.replace(/_/g, ' '))}</span>
|
|
1894
|
+
<span class="f-preview">${r.count} slice${r.count !== 1 ? 's' : ''}</span>
|
|
1895
|
+
<span></span>
|
|
1896
|
+
<span></span>
|
|
1897
|
+
<span></span>
|
|
1898
|
+
<span class="f-when">${r.latest ? timeAgo(r.latest) : ''}</span>
|
|
1899
|
+
</div>`;
|
|
1900
|
+
}
|
|
1901
|
+
html += '</div>';
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// Context slices section — newest first, polled live
|
|
1905
|
+
if (sliceRows.length) {
|
|
1906
|
+
const newestWrite = sliceRows[0].slice.last_synced;
|
|
1907
|
+
html += `<div class="section-h" style="margin-top:18px">Context slices <span class="count">${sliceRows.length}</span>${newestWrite ? `<span class="live-hint"><span class="dot"></span>newest first · latest ${timeAgo(newestWrite)}</span>` : ''}</div>`;
|
|
1908
|
+
html += '<div class="file-box">';
|
|
1909
|
+
for (const r of sliceRows) {
|
|
1910
|
+
const sl = r.slice;
|
|
1911
|
+
const fresh = freshnessOf(sl.last_synced);
|
|
1912
|
+
const freshClass = fresh === 'fresh' ? 'fresh' : fresh === 'h24' ? 'h24' : fresh === 'd7' ? 'd7' : 'older';
|
|
1913
|
+
const author = sl.metadata.author_id || sl.written_by || 'unknown';
|
|
1914
|
+
const preview = (sl.payload.content || '').slice(0, 100).replace(/\s+/g, ' ').trim();
|
|
1915
|
+
const isNew = S.newArrivals.has(sl.hcu);
|
|
1916
|
+
html += `<div class="file-row${isNew ? ' new' : ''}" onclick="selectSlice('${escAttr(sl.hcu)}')">
|
|
1917
|
+
<span class="f-icon">${ICON.file}</span>
|
|
1918
|
+
<span class="f-name mono">${esc(r.name.replace(/_/g, ' '))}</span>
|
|
1919
|
+
<span class="f-preview">${esc(preview)}</span>
|
|
1920
|
+
<span class="fresh-dot ${freshClass}" title="${fresh}"></span>
|
|
1921
|
+
${renderCellState(sl)}
|
|
1922
|
+
${renderAgentChip(author)}
|
|
1923
|
+
<span class="f-when">${sl.last_synced ? timeAgo(sl.last_synced) : ''}</span>
|
|
1924
|
+
</div>`;
|
|
1925
|
+
}
|
|
1926
|
+
html += '</div>';
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
if (!folderRows.length && !sliceRows.length) {
|
|
1930
|
+
html += '<div class="file-box"><div class="empty" style="padding:40px 20px"><p>Nothing here under the current filter.</p></div></div>';
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
html += '</div>';
|
|
1934
|
+
$('main').innerHTML = html;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/* ══════════════════════════════════════════════════════
|
|
1938
|
+
Slice view
|
|
1939
|
+
══════════════════════════════════════════════════════ */
|
|
1940
|
+
function renderSlice(sl) {
|
|
1941
|
+
const { scope, type } = scopeOf(sl.hcu);
|
|
1942
|
+
const isPriv = type === 'private';
|
|
1943
|
+
const tags = sl.metadata.tags || [];
|
|
1944
|
+
const content = sl.payload.content || '';
|
|
1945
|
+
const author = sl.metadata.author_id || sl.written_by || 'unknown';
|
|
1946
|
+
const ver = sl.metadata.version || 1;
|
|
1947
|
+
const fresh = freshnessOf(sl.last_synced);
|
|
1948
|
+
const freshClass = fresh === 'fresh' ? 'fresh' : fresh === 'h24' ? 'h24' : fresh === 'd7' ? 'd7' : 'older';
|
|
1949
|
+
const sizeBytes = new Blob([content]).size;
|
|
1950
|
+
const lines = content ? content.split('\n').length : 0;
|
|
1951
|
+
const format = sl.payload.format || (content.match(/^#/m) ? 'markdown' : 'text');
|
|
1952
|
+
const isEncryptedRemote = S.mode === 'remote' && isPriv;
|
|
1953
|
+
const leafName = (pathOf(sl.hcu).pop() || sl.hcu).replace(/_/g, ' ');
|
|
1954
|
+
|
|
1955
|
+
let html = '<div class="panel blob-wrap">';
|
|
1956
|
+
|
|
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>`;
|
|
1964
|
+
|
|
1965
|
+
// Meta strip — identity + mutability signal
|
|
1966
|
+
html += '<div class="blob-meta">';
|
|
1967
|
+
html += renderScopePill(type, scope);
|
|
1968
|
+
html += renderCellState(sl);
|
|
1969
|
+
html += `<span class="fresh-dot ${freshClass}" title="${fresh}" style="width:7px;height:7px"></span><span>last write ${sl.last_synced ? timeAgo(sl.last_synced) : '—'}</span>`;
|
|
1970
|
+
html += `<span class="sep">·</span>`;
|
|
1971
|
+
html += `<span>by</span> ${renderAgentChip(author)}`;
|
|
1972
|
+
if (tags.length) {
|
|
1973
|
+
html += `<span class="sep">·</span>`;
|
|
1974
|
+
for (const t of tags) {
|
|
1975
|
+
const active = S.filter.tags.has(t);
|
|
1976
|
+
html += `<span class="pill tag${active ? ' active' : ''}" onclick="toggleTag('${escAttr(t)}')">${esc(t)}${active ? '<span class="x">×</span>' : ''}</span>`;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
html += '</div>';
|
|
1980
|
+
|
|
1981
|
+
// Write-guide + neighbors surfaced above the content so they're visible
|
|
1982
|
+
// without scrolling — long slices can push them far offscreen otherwise.
|
|
1983
|
+
html += renderWriteGuide(sl);
|
|
1984
|
+
html += renderSiblingsPanel(sl);
|
|
1985
|
+
|
|
1986
|
+
// Encrypted note when remote + private
|
|
1987
|
+
if (isEncryptedRemote) {
|
|
1988
|
+
html += `<div class="encrypted-note" style="margin-top:14px">${ICON.lock}
|
|
1989
|
+
<div>This slice is <strong>private</strong> and encrypted at rest. The server cannot decrypt it. View plaintext locally with <code>bitpub console</code>.</div>
|
|
1990
|
+
</div>`;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// Snapshot banner — reminds this is the current value, not a finished doc
|
|
1994
|
+
html += `<div class="snapshot-banner" style="margin-top:${isEncryptedRemote ? '0' : '16px'}">
|
|
1995
|
+
<span class="title">Latest value</span>
|
|
1996
|
+
<span class="ver">v${ver}</span>
|
|
1997
|
+
<span class="caveat">${ICON.info}<span>Next push to this address replaces this slice in place.</span></span>
|
|
1998
|
+
</div>`;
|
|
1999
|
+
|
|
2000
|
+
// Blob container
|
|
2001
|
+
html += '<div class="blob-container has-banner">';
|
|
2002
|
+
html += '<div class="blob-toolbar">';
|
|
2003
|
+
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>`;
|
|
2006
|
+
html += '</div>';
|
|
2007
|
+
html += '<div class="spacer"></div>';
|
|
2008
|
+
html += `<button class="btn" onclick="copyContent(this)">${ICON.copy}<span>Copy content</span></button>`;
|
|
2009
|
+
html += '</div>';
|
|
2010
|
+
if (S.raw) {
|
|
2011
|
+
html += `<pre class="content-raw">${esc(content)}</pre>`;
|
|
2012
|
+
} else {
|
|
2013
|
+
html += `<div class="content-body">${md(content)}</div>`;
|
|
2014
|
+
}
|
|
2015
|
+
html += `<div class="blob-footer">${lines} line${lines !== 1 ? 's' : ''} · ${formatBytes(sizeBytes)} · ${esc(format)} · only the latest version is retained</div>`;
|
|
2016
|
+
html += '</div>';
|
|
2017
|
+
|
|
2018
|
+
html += '</div>';
|
|
2019
|
+
$('main').innerHTML = html;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
function renderWriteGuide(sl) {
|
|
2023
|
+
const addr = sl.hcu;
|
|
2024
|
+
const ver = sl.metadata.version || 1;
|
|
2025
|
+
return `<details class="write-guide">
|
|
2026
|
+
<summary>
|
|
2027
|
+
<span>Write to this slice</span>
|
|
2028
|
+
<span class="hint">overwrite · append · safe · new slice</span>
|
|
2029
|
+
</summary>
|
|
2030
|
+
<div class="body">
|
|
2031
|
+
<div class="mode">
|
|
2032
|
+
<h5>Overwrite (default)</h5>
|
|
2033
|
+
<p>Replaces the entire value at this address. Previous content is not retained.</p>
|
|
2034
|
+
<pre>bitpub push \\
|
|
2035
|
+
--address "${esc(addr)}" \\
|
|
2036
|
+
--content "…"</pre>
|
|
2037
|
+
</div>
|
|
2038
|
+
<div class="mode">
|
|
2039
|
+
<h5>Append</h5>
|
|
2040
|
+
<p>Concatenates new content to the existing value. Good for changelogs and collaborative prose; not safe for structured formats (JSON, YAML, code).</p>
|
|
2041
|
+
<pre>bitpub push \\
|
|
2042
|
+
--address "${esc(addr)}" \\
|
|
2043
|
+
--content "…" \\
|
|
2044
|
+
--append</pre>
|
|
2045
|
+
</div>
|
|
2046
|
+
<div class="mode">
|
|
2047
|
+
<h5>Safe write (optimistic concurrency)</h5>
|
|
2048
|
+
<p>Rejects the push if someone else has written since you read. Current version is <strong>${ver}</strong>.</p>
|
|
2049
|
+
<pre>bitpub push \\
|
|
2050
|
+
--address "${esc(addr)}" \\
|
|
2051
|
+
--file ./value.md \\
|
|
2052
|
+
--expect-version ${ver}</pre>
|
|
2053
|
+
</div>
|
|
2054
|
+
<div class="mode">
|
|
2055
|
+
<h5>Push to a new slice instead</h5>
|
|
2056
|
+
<p>Don't want to touch this one? Push to a new address under the same parent namespace.</p>
|
|
2057
|
+
<pre>bitpub push \\
|
|
2058
|
+
--address "${esc(addr)}_v2" \\
|
|
2059
|
+
--file ./value.md</pre>
|
|
2060
|
+
</div>
|
|
2061
|
+
</div>
|
|
2062
|
+
</details>`;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
function renderSiblingsPanel(sl) {
|
|
2066
|
+
const sibs = siblingsOf(sl);
|
|
2067
|
+
if (sibs.length <= 1) return '';
|
|
2068
|
+
const { scope } = scopeOf(sl.hcu);
|
|
2069
|
+
const segs = pathOf(sl.hcu);
|
|
2070
|
+
const parent = segs.slice(0, -1).join('/');
|
|
2071
|
+
const parentLabel = parent ? `${scope}/${parent}` : scope;
|
|
2072
|
+
const MAX = 6;
|
|
2073
|
+
const shown = sibs.slice(0, MAX);
|
|
2074
|
+
const hidden = Math.max(0, sibs.length - MAX);
|
|
2075
|
+
const parentPath = segs.slice(0, -1);
|
|
2076
|
+
let html = `<div class="siblings-panel">
|
|
2077
|
+
<div class="heading">
|
|
2078
|
+
<span>Other slices in this namespace</span>
|
|
2079
|
+
<span class="note">under <span style="font-family:var(--mono);color:var(--text-muted)">${esc(parentLabel)}</span></span>
|
|
2080
|
+
</div>
|
|
2081
|
+
<div class="list">`;
|
|
2082
|
+
for (const s of shown) {
|
|
2083
|
+
const name = (pathOf(s.hcu).pop() || s.hcu).replace(/_/g, ' ');
|
|
2084
|
+
const isSelf = s.hcu === sl.hcu;
|
|
2085
|
+
const fresh = freshnessOf(s.last_synced);
|
|
2086
|
+
const freshClass = fresh === 'fresh' ? 'fresh' : fresh === 'h24' ? 'h24' : fresh === 'd7' ? 'd7' : 'older';
|
|
2087
|
+
if (isSelf) {
|
|
2088
|
+
html += `<div class="row self">
|
|
2089
|
+
<span class="fresh-dot ${freshClass}"></span>
|
|
2090
|
+
<span class="name">${esc(name)}</span>
|
|
2091
|
+
<span class="self-label">current</span>
|
|
2092
|
+
<span class="when">${s.last_synced ? timeAgo(s.last_synced) : ''}</span>
|
|
2093
|
+
</div>`;
|
|
2094
|
+
} else {
|
|
2095
|
+
html += `<div class="row" onclick="selectSlice('${escAttr(s.hcu)}')">
|
|
2096
|
+
<span class="fresh-dot ${freshClass}"></span>
|
|
2097
|
+
<span class="name">${esc(name)}</span>
|
|
2098
|
+
${renderCellState(s)}
|
|
2099
|
+
<span class="when">${s.last_synced ? timeAgo(s.last_synced) : ''}</span>
|
|
2100
|
+
</div>`;
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
if (hidden > 0) {
|
|
2104
|
+
const parentArg = JSON.stringify(parentPath).replace(/"/g, '"');
|
|
2105
|
+
html += `<div class="more"><a href="#" onclick="event.preventDefault(); navigateTo('${escAttr(scope)}', ${parentArg})">+ ${hidden} more — see all in this namespace →</a></div>`;
|
|
2106
|
+
}
|
|
2107
|
+
html += '</div></div>';
|
|
2108
|
+
return html;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
function toggleRaw(raw) {
|
|
2112
|
+
S.raw = raw;
|
|
2113
|
+
const sl = S.slices.find(s => s.hcu === S.selectedHcu);
|
|
2114
|
+
if (sl) renderSlice(sl);
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
function copyText(text, btn) {
|
|
2118
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
2119
|
+
if (!btn) return;
|
|
2120
|
+
const orig = btn.innerHTML;
|
|
2121
|
+
btn.innerHTML = `${ICON.copy}<span>Copied!</span>`;
|
|
2122
|
+
setTimeout(() => btn.innerHTML = orig, 1500);
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
function copyContent(btn) {
|
|
2127
|
+
const sl = S.slices.find(s => s.hcu === S.selectedHcu);
|
|
2128
|
+
if (!sl) return;
|
|
2129
|
+
copyText(sl.payload.content || '', btn);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/* ══════════════════════════════════════════════════════
|
|
2133
|
+
Search
|
|
2134
|
+
══════════════════════════════════════════════════════ */
|
|
2135
|
+
function handleSearch(query) {
|
|
2136
|
+
S.searchQuery = query;
|
|
2137
|
+
if (!query || query.length < 2) {
|
|
2138
|
+
if (S.view === 'overview') renderOverview();
|
|
2139
|
+
else if (S.view === 'namespace') renderNamespace();
|
|
2140
|
+
else if (S.view === 'slice') {
|
|
2141
|
+
const sl = S.slices.find(s => s.hcu === S.selectedHcu);
|
|
2142
|
+
if (sl) renderSlice(sl);
|
|
2143
|
+
}
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
renderSearch();
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
function renderSearch() {
|
|
2150
|
+
const q = S.searchQuery.toLowerCase();
|
|
2151
|
+
const source = filteredSlices();
|
|
2152
|
+
const results = source.filter(s =>
|
|
2153
|
+
s.hcu.toLowerCase().includes(q) ||
|
|
2154
|
+
(s.payload.content || '').toLowerCase().includes(q) ||
|
|
2155
|
+
(s.metadata.tags || []).some(t => t.toLowerCase().includes(q)) ||
|
|
2156
|
+
(s.metadata.author_id || '').toLowerCase().includes(q)
|
|
2157
|
+
);
|
|
2158
|
+
let html = `<div class="search-results">
|
|
2159
|
+
<div class="count-line"><strong>${results.length}</strong> match${results.length !== 1 ? 'es' : ''} for <strong>"${esc(S.searchQuery)}"</strong>${filterActive() ? ' within the current filter' : ''}</div>`;
|
|
2160
|
+
for (const s of results) {
|
|
2161
|
+
const { type, scope } = scopeOf(s.hcu);
|
|
2162
|
+
const short = s.hcu.replace(/^bitpub:\/\/[^/]+\//, '');
|
|
2163
|
+
const preview = (s.payload.content || '').slice(0, 220);
|
|
2164
|
+
const author = s.metadata.author_id || s.written_by || 'unknown';
|
|
2165
|
+
html += `<div class="result-item" onclick="selectSlice('${escAttr(s.hcu)}')">
|
|
2166
|
+
<div class="result-hcu"><span style="color:var(--text-subtle)">${esc(scope)}/</span>${highlightMatch(esc(short), S.searchQuery)} ${renderScopePill(type, scope)} ${renderAgentChip(author)}</div>
|
|
2167
|
+
<div class="result-preview">${highlightMatch(esc(preview), S.searchQuery)}</div>
|
|
2168
|
+
</div>`;
|
|
2169
|
+
}
|
|
2170
|
+
if (!results.length) html += '<div class="empty" style="padding:32px 8px"><p>No matches.</p></div>';
|
|
2171
|
+
html += '</div>';
|
|
2172
|
+
$('main').innerHTML = html;
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
function highlightMatch(text, query) {
|
|
2176
|
+
if (!query) return text;
|
|
2177
|
+
const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
|
2178
|
+
return text.replace(re, '<mark>$1</mark>');
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
/* ══════════════════════════════════════════════════════
|
|
2182
|
+
Markdown
|
|
2183
|
+
══════════════════════════════════════════════════════ */
|
|
2184
|
+
function md(src) {
|
|
2185
|
+
if (!src) return '';
|
|
2186
|
+
let h = src
|
|
2187
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
2188
|
+
.replace(/^```(\w*)\n([\s\S]*?)^```/gm, (_, lang, code) => `<pre><code>${code}</code></pre>`)
|
|
2189
|
+
.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>')
|
|
2190
|
+
.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>')
|
|
2191
|
+
.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>')
|
|
2192
|
+
.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>')
|
|
2193
|
+
.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>')
|
|
2194
|
+
.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>')
|
|
2195
|
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
2196
|
+
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
2197
|
+
.replace(/`([^`\n]+)`/g, '<code>$1</code>')
|
|
2198
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
|
2199
|
+
.replace(/^>\s?(.+)$/gm, '<blockquote>$1</blockquote>')
|
|
2200
|
+
.replace(/^---$/gm, '<hr>')
|
|
2201
|
+
.replace(/^\d+\.\s+(.+)$/gm, '<li>$1</li>')
|
|
2202
|
+
.replace(/^[-*]\s+(.+)$/gm, '<li>$1</li>');
|
|
2203
|
+
h = h.replace(/((?:^<li>.*<\/li>\n?)+)/gm, m => {
|
|
2204
|
+
const ordered = /<li>/.test(m) && /^\d/.test(src.split('\n').find(l => l.trim().startsWith('<li>')) || '');
|
|
2205
|
+
return ordered ? `<ol>${m}</ol>` : `<ul>${m}</ul>`;
|
|
2206
|
+
});
|
|
2207
|
+
h = h.split('\n').map(line => {
|
|
2208
|
+
if (!line.trim() || /^<[hupob]|^<li|^<hr|^<\//.test(line)) return line;
|
|
2209
|
+
return `<p>${line}</p>`;
|
|
2210
|
+
}).join('\n');
|
|
2211
|
+
h = h.replace(/<\/blockquote>\n<blockquote>/g, '\n');
|
|
2212
|
+
return h;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
/* ══════════════════════════════════════════════════════
|
|
2216
|
+
Utilities
|
|
2217
|
+
══════════════════════════════════════════════════════ */
|
|
2218
|
+
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
2219
|
+
function escAttr(s) { return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '"'); }
|
|
2220
|
+
function escBacktick(s) { return String(s).replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$'); }
|
|
2221
|
+
|
|
2222
|
+
function formatBytes(n) {
|
|
2223
|
+
if (n < 1024) return n + ' B';
|
|
2224
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
|
2225
|
+
return (n / 1024 / 1024).toFixed(1) + ' MB';
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
function timeAgo(ts) {
|
|
2229
|
+
const ms = Date.now() - new Date(ts).getTime();
|
|
2230
|
+
if (ms < 0) return 'just now';
|
|
2231
|
+
const s = Math.floor(ms / 1000);
|
|
2232
|
+
if (s < 60) return `${s}s ago`;
|
|
2233
|
+
const m = Math.floor(s / 60);
|
|
2234
|
+
if (m < 60) return `${m}m ago`;
|
|
2235
|
+
const h = Math.floor(m / 60);
|
|
2236
|
+
if (h < 24) return `${h}h ago`;
|
|
2237
|
+
const d = Math.floor(h / 24);
|
|
2238
|
+
if (d < 30) return `${d}d ago`;
|
|
2239
|
+
return new Date(ts).toLocaleDateString();
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
/* ══════════════════════════════════════════════════════
|
|
2243
|
+
Keyboard
|
|
2244
|
+
══════════════════════════════════════════════════════ */
|
|
2245
|
+
document.addEventListener('keydown', e => {
|
|
2246
|
+
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
|
|
2247
|
+
e.preventDefault(); $('search').focus();
|
|
2248
|
+
}
|
|
2249
|
+
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(); }
|
|
2253
|
+
}
|
|
2254
|
+
});
|
|
2255
|
+
|
|
2256
|
+
/* ══════════════════════════════════════════════════════
|
|
2257
|
+
Boot
|
|
2258
|
+
══════════════════════════════════════════════════════ */
|
|
2259
|
+
$('auth-key').addEventListener('keydown', e => { if (e.key === 'Enter') submitAuth(); });
|
|
2260
|
+
boot();
|
|
2261
|
+
</script>
|
|
2262
|
+
</body>
|
|
2263
|
+
</html>
|