@dtudury/streamo 3.0.0 → 4.0.1
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 +38 -23
- package/package.json +3 -3
- package/public/apps/chat/main.js +22 -3
- package/public/apps/explorer/index.html +215 -6
- package/public/apps/explorer/main.js +859 -155
- package/public/streamo/Addressifier.js +9 -0
- package/public/streamo/CodecRegistry.js +95 -0
- package/public/streamo/Repo.js +20 -2
- package/public/streamo/Signer.js +10 -0
- package/public/streamo/Streamo.js +43 -9
- package/public/streamo/chat-cli.js +20 -4
- package/public/streamo/codecs.js +49 -9
- package/public/streamo/registrySync.js +11 -0
- package/public/streamo/utils/Recaller.js +11 -0
package/README.md
CHANGED
|
@@ -11,18 +11,35 @@ Streamo is a peer-to-peer sync library built around a simple promise: **no serve
|
|
|
11
11
|
- **Every write is provably yours** — commits are signed with your keypair and append-only. History is permanent and can't be forged; peers reject unsigned or mis-signed data.
|
|
12
12
|
- **Content-addressed** — data is identified by what it is, not where it lives. The same value always lands at the same address; deduplication and diffing are structural.
|
|
13
13
|
|
|
14
|
-
##
|
|
14
|
+
## three ways to use streamo
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
There are basically three audiences. Pick the one that's you:
|
|
17
|
+
|
|
18
|
+
**1. As a library** — `npm install @dtudury/streamo` and import the pieces
|
|
19
|
+
you want. See the *javascript api* section below for what's exported.
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
**2. As a CLI** — `npx @dtudury/streamo --help` runs the streamo CLI
|
|
22
|
+
without cloning anything. You bring credentials, point at files or peers,
|
|
23
|
+
and get a personal streamo node. See the *cli* section.
|
|
24
|
+
|
|
25
|
+
**3. As a reference / contributor** — clone this repo, then:
|
|
21
26
|
|
|
22
27
|
```bash
|
|
23
|
-
|
|
28
|
+
npm install
|
|
29
|
+
npm run dev # starts the all-in-one demo on port 8080
|
|
24
30
|
```
|
|
25
31
|
|
|
32
|
+
`npm run dev` runs the chat-room server (`public/apps/chat/server.js`) with
|
|
33
|
+
the checked-in dev credentials in `.env.dev`. That one server hosts the
|
|
34
|
+
homepage, chat app, **and** the repo explorer at `localhost:8080`. Modify
|
|
35
|
+
any file, refresh, see the change.
|
|
36
|
+
|
|
37
|
+
For production deployment, your real `.env.prod` lives only on the
|
|
38
|
+
production host, and `npm run prod` boots the same server against that
|
|
39
|
+
env.
|
|
40
|
+
|
|
41
|
+
`npm test` runs the test suite.
|
|
42
|
+
|
|
26
43
|
## cli
|
|
27
44
|
|
|
28
45
|
```bash
|
|
@@ -177,31 +194,29 @@ For hot-reloading, `componentKey(prefix, address)` and `defineComponent(name, fn
|
|
|
177
194
|
| `s3Sync` | replicate chunks to S3-compatible object storage |
|
|
178
195
|
| `stateFileSync` | write repo state as JSON on every change |
|
|
179
196
|
|
|
180
|
-
##
|
|
197
|
+
## what `npm run dev` actually starts
|
|
181
198
|
|
|
182
|
-
The chat server
|
|
183
|
-
|
|
199
|
+
The chat-room server. It's the all-in-one demo: the homepage, chat app,
|
|
200
|
+
and repo explorer are all served by the same process on port 8080. The
|
|
201
|
+
"server" is just another streamo node — it holds the room's member list
|
|
202
|
+
in its own repo and auto-accepts anyone who announces to it. Its public
|
|
203
|
+
key is the room address. No special authority, no hidden state.
|
|
184
204
|
|
|
185
|
-
|
|
186
|
-
# start the all-in-one demo server
|
|
187
|
-
STREAMO_NAME=my-chat STREAMO_USERNAME=relay STREAMO_PASSWORD=secret \
|
|
188
|
-
node public/apps/chat/server.js
|
|
205
|
+
Useful URLs once it's running:
|
|
189
206
|
|
|
190
|
-
|
|
191
|
-
|
|
207
|
+
- `http://localhost:8080/` — homepage with app cards
|
|
208
|
+
- `http://localhost:8080/apps/chat/` — chat
|
|
209
|
+
- `http://localhost:8080/apps/explorer/` — repo explorer (leave it open in
|
|
210
|
+
another tab to watch commits roll in as you chat)
|
|
192
211
|
|
|
193
|
-
|
|
194
|
-
open http://localhost:8080/apps/chat/
|
|
212
|
+
To join chat from a terminal instead of the browser:
|
|
195
213
|
|
|
196
|
-
|
|
197
|
-
# as you chat
|
|
198
|
-
open http://localhost:8080/apps/explorer/
|
|
199
|
-
|
|
200
|
-
# join chat from the terminal
|
|
214
|
+
```bash
|
|
201
215
|
node public/streamo/chat-cli.js alice secret localhost 8080
|
|
202
216
|
```
|
|
203
217
|
|
|
204
|
-
Each participant owns their own message stream.
|
|
218
|
+
Each participant owns their own message stream. Same data structure,
|
|
219
|
+
different transport.
|
|
205
220
|
|
|
206
221
|
## tests
|
|
207
222
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dtudury/streamo",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"description": "peer-to-peer sync where your data and identity belong to you, not the server",
|
|
5
5
|
"keywords": ["p2p", "peer-to-peer", "sync", "reactive", "content-addressed", "websocket", "signed", "append-only", "offline-first", "cryptographic", "identity"],
|
|
6
6
|
"repository": "git@github.com:dtudury/streamo.git",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"test": "node --test",
|
|
39
|
-
"
|
|
40
|
-
"
|
|
39
|
+
"dev": "node public/apps/chat/server.js --env-file .env.dev",
|
|
40
|
+
"prod": "node public/apps/chat/server.js --env-file .env.prod"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/public/apps/chat/main.js
CHANGED
|
@@ -13,8 +13,10 @@ function fmt (ts) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
function Msg ({ name, text, at, mine }) {
|
|
16
|
+
// +at coerces both Date and number to ms — stable key across old (number)
|
|
17
|
+
// and new (Date) message records as we transition.
|
|
16
18
|
return h`
|
|
17
|
-
<div class=${['msg', mine ? 'mine' : 'theirs']} data-key=${at}>
|
|
19
|
+
<div class=${['msg', mine ? 'mine' : 'theirs']} data-key=${+at}>
|
|
18
20
|
${!mine ? h`<div class="sender">${name}</div>` : null}
|
|
19
21
|
<div class="text">${text}</div>
|
|
20
22
|
<div class="time">${fmt(at)}</div>
|
|
@@ -43,16 +45,31 @@ joinBtn.onclick = async () => {
|
|
|
43
45
|
const myKey = bytesToHex(publicKey)
|
|
44
46
|
const registry = new RepoRegistry()
|
|
45
47
|
|
|
48
|
+
// Track who we've already announced ourselves back to, so we don't
|
|
49
|
+
// ping-pong forever. Without this set, every peer-back ricochets into
|
|
50
|
+
// another peer-back and so on.
|
|
51
|
+
const announcedTo = new Set()
|
|
46
52
|
const session = await registrySync(registry, location.hostname, Number(location.port) || 80, {
|
|
47
53
|
filter: k => k === rootKey,
|
|
48
54
|
follow: (keyHex, repo, subscribe) => {
|
|
49
55
|
for (const memberKey of repo.get('members') ?? []) subscribe(memberKey)
|
|
50
56
|
},
|
|
51
|
-
|
|
57
|
+
// When a peer announces, subscribe to them AND announce ourselves
|
|
58
|
+
// back so they learn we exist — this makes peer discovery work
|
|
59
|
+
// through pure real-time fan-out, no server-side member tracking
|
|
60
|
+
// required. Late-joiner sees us, we see late-joiner.
|
|
61
|
+
onAnnounce: key => {
|
|
62
|
+
session.subscribe(key)
|
|
63
|
+
if (!announcedTo.has(key)) {
|
|
64
|
+
announcedTo.add(key)
|
|
65
|
+
session.announce(myKey, rootKey)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
52
68
|
})
|
|
53
69
|
|
|
54
70
|
const myRepo = await registry.open(myKey)
|
|
55
71
|
myRepo.attachSigner(signer, 'chat')
|
|
72
|
+
myRepo.defaultMessage = `joined as ${username} (web)`
|
|
56
73
|
|
|
57
74
|
session.interest(rootKey)
|
|
58
75
|
session.announce(myKey, rootKey)
|
|
@@ -112,7 +129,9 @@ joinBtn.onclick = async () => {
|
|
|
112
129
|
if (!text) return
|
|
113
130
|
inputEl.value = ''
|
|
114
131
|
const messages = myRepo.get('messages') ?? []
|
|
115
|
-
|
|
132
|
+
const preview = text.length > 50 ? text.slice(0, 50).trim() + '…' : text
|
|
133
|
+
myRepo.defaultMessage = `"${preview}" (web)`
|
|
134
|
+
myRepo.set({ name: username, messages: [...messages, { text, at: new Date() }] })
|
|
116
135
|
}
|
|
117
136
|
|
|
118
137
|
sendBtn.onclick = sendMessage
|
|
@@ -31,9 +31,11 @@
|
|
|
31
31
|
.row + .row { border-top-color: var(--rule); }
|
|
32
32
|
.row:hover + .row { border-top-color: transparent; }
|
|
33
33
|
|
|
34
|
-
/*
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
/* signed-commit + unsigned-commit + commit + signature rows share the same
|
|
35
|
+
column template so the page doesn't visually jitter as you scan a mixed
|
|
36
|
+
list. cols: kind | message | date | addr. */
|
|
37
|
+
.row.signed-commit, .row.unsigned-commit,
|
|
38
|
+
.row.commit, .row.signature { grid-template-columns: 6rem 1fr 14rem 6rem; }
|
|
37
39
|
|
|
38
40
|
.row .mono { font-size: 0.85rem; }
|
|
39
41
|
.row .when { font-size: 0.78rem; color: var(--ink-dim); }
|
|
@@ -49,8 +51,115 @@
|
|
|
49
51
|
text-align: center;
|
|
50
52
|
align-self: center;
|
|
51
53
|
}
|
|
52
|
-
.row.commit .kind
|
|
53
|
-
.row.signature .kind
|
|
54
|
+
.row.commit .kind { color: var(--accent); border-color: var(--accent); }
|
|
55
|
+
.row.signature .kind { color: var(--warn); border-color: var(--warn); }
|
|
56
|
+
.row.signed-commit .kind { color: #16a34a; border-color: #16a34a; }
|
|
57
|
+
.row.signed-commit.unsigned .kind { color: var(--ink-dim); border-color: var(--ink-dim); }
|
|
58
|
+
|
|
59
|
+
/* HEAD card — the most-recent signed commit, prominent and self-orienting. */
|
|
60
|
+
.row.signed-commit.head-card {
|
|
61
|
+
border: 1.5px solid #16a34a;
|
|
62
|
+
background: rgba(22, 163, 74, 0.05);
|
|
63
|
+
padding: 0.85rem;
|
|
64
|
+
}
|
|
65
|
+
.row.signed-commit.head-card .msg { font-size: 1rem; font-weight: 500; }
|
|
66
|
+
|
|
67
|
+
/* Detached card — same layout as the head-card but neutral styling.
|
|
68
|
+
Shown as the selector summary when the current address isn't a sig
|
|
69
|
+
(you've drilled into raw memory). The dropdown body is still the
|
|
70
|
+
way back — pick a real commit and you re-attach. */
|
|
71
|
+
.row.signed-commit.detached-card {
|
|
72
|
+
border: 1.5px dashed var(--rule);
|
|
73
|
+
background: transparent;
|
|
74
|
+
padding: 0.85rem;
|
|
75
|
+
cursor: pointer;
|
|
76
|
+
}
|
|
77
|
+
.row.signed-commit.detached-card .kind {
|
|
78
|
+
color: var(--ink-dim);
|
|
79
|
+
border-color: var(--ink-dim);
|
|
80
|
+
}
|
|
81
|
+
.row.signed-commit.detached-card .msg { font-size: 0.95rem; }
|
|
82
|
+
|
|
83
|
+
/* Commit selector: a real dropdown widget. Summary = currently-selected
|
|
84
|
+
commit (HEAD by default), styled as the green head-card. Body =
|
|
85
|
+
full list of signed commits, with the selected one marked. */
|
|
86
|
+
details.commit-selector { margin: 0.5rem 0 1rem; }
|
|
87
|
+
details.commit-selector > summary {
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
list-style: none;
|
|
90
|
+
padding: 0;
|
|
91
|
+
}
|
|
92
|
+
details.commit-selector > summary::-webkit-details-marker { display: none; }
|
|
93
|
+
details.commit-selector > summary::marker { display: none; }
|
|
94
|
+
details.commit-selector > summary::after {
|
|
95
|
+
content: '▾';
|
|
96
|
+
float: right;
|
|
97
|
+
margin: 0.5rem 0.85rem;
|
|
98
|
+
color: #16a34a;
|
|
99
|
+
font-size: 0.85rem;
|
|
100
|
+
}
|
|
101
|
+
details.commit-selector[open] > summary::after { content: '▴'; }
|
|
102
|
+
details.commit-selector .dropdown-body {
|
|
103
|
+
margin-top: 0.25rem;
|
|
104
|
+
border: 1px solid var(--rule);
|
|
105
|
+
border-radius: var(--radius);
|
|
106
|
+
padding: 0.25rem;
|
|
107
|
+
}
|
|
108
|
+
details.commit-selector .dropdown-body .row { padding: 0.45rem 0.6rem; }
|
|
109
|
+
details.commit-selector .dropdown-body .row.selected {
|
|
110
|
+
background: rgba(22, 163, 74, 0.07);
|
|
111
|
+
}
|
|
112
|
+
details.commit-selector .dropdown-body .row.selected .kind::after {
|
|
113
|
+
content: ' ●';
|
|
114
|
+
color: #16a34a;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Tucked-away secondary "storage chunks" list at the bottom of the
|
|
118
|
+
repo view — a click away when you want to see the bytes underneath. */
|
|
119
|
+
details.other-storage {
|
|
120
|
+
margin: 1.25rem 0 0.5rem;
|
|
121
|
+
border-top: 1px solid var(--rule);
|
|
122
|
+
padding-top: 0.5rem;
|
|
123
|
+
}
|
|
124
|
+
details.other-storage > summary {
|
|
125
|
+
cursor: pointer;
|
|
126
|
+
font-size: 0.85rem;
|
|
127
|
+
color: var(--ink-dim);
|
|
128
|
+
padding: 0.35rem 0.25rem;
|
|
129
|
+
}
|
|
130
|
+
details.other-storage[open] > summary { color: var(--ink); }
|
|
131
|
+
|
|
132
|
+
/* "What this is" banner — top of every value tab. Default neutral
|
|
133
|
+
border for storage codecs; green .verified for commits or sigs
|
|
134
|
+
backed by a valid signature; dim .unsigned for commits awaiting
|
|
135
|
+
a signature. */
|
|
136
|
+
.kind-banner {
|
|
137
|
+
display: flex; align-items: center; gap: 0.5rem;
|
|
138
|
+
padding: 0.65rem 0.85rem; margin: 0.5rem 0 1rem;
|
|
139
|
+
border: 1.5px solid var(--rule); border-radius: var(--radius);
|
|
140
|
+
}
|
|
141
|
+
.kind-banner.verified {
|
|
142
|
+
border-color: #16a34a;
|
|
143
|
+
background: rgba(22, 163, 74, 0.06);
|
|
144
|
+
}
|
|
145
|
+
.kind-banner.unsigned { border-style: dashed; }
|
|
146
|
+
.kind-banner .kind-label {
|
|
147
|
+
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em;
|
|
148
|
+
font-weight: 600; color: var(--ink-dim);
|
|
149
|
+
}
|
|
150
|
+
.kind-banner.verified .kind-label { color: #16a34a; }
|
|
151
|
+
.commit-card {
|
|
152
|
+
padding: 0.6rem 0.85rem; margin: 0.4rem 0;
|
|
153
|
+
border: 1px solid var(--rule); border-radius: var(--radius);
|
|
154
|
+
}
|
|
155
|
+
.commit-card .commit-msg { font-size: 0.95rem; margin-bottom: 0.25rem; }
|
|
156
|
+
.commit-card .commit-meta { font-size: 0.8rem; }
|
|
157
|
+
|
|
158
|
+
.verify-badge { font-weight: 700; padding-left: 0.35em; font-size: 0.95em; }
|
|
159
|
+
.verify-badge.valid { color: #16a34a; }
|
|
160
|
+
.verify-badge.invalid { color: #dc2626; }
|
|
161
|
+
.verify-badge.pending { color: var(--ink-dim); font-weight: 400; }
|
|
162
|
+
.verify-badge.error { color: #ca8a04; }
|
|
54
163
|
|
|
55
164
|
.empty { color: var(--ink-dim); padding: 0.5rem 0.75rem; font-size: 0.9rem; }
|
|
56
165
|
|
|
@@ -87,11 +196,111 @@
|
|
|
87
196
|
h3 { font-size: 0.9rem; font-weight: 600; margin: 1.25rem 0 0.5rem; }
|
|
88
197
|
h3 .dim { font-weight: 400; font-size: 0.85rem; }
|
|
89
198
|
|
|
199
|
+
.explainer {
|
|
200
|
+
font-size: 0.85rem;
|
|
201
|
+
line-height: 1.55;
|
|
202
|
+
color: var(--ink-dim);
|
|
203
|
+
border-left: 2px solid var(--rule);
|
|
204
|
+
padding: 0.4rem 0 0.4rem 0.85rem;
|
|
205
|
+
margin: 0.6rem 0 0.9rem;
|
|
206
|
+
}
|
|
207
|
+
.explainer strong { color: var(--ink); }
|
|
208
|
+
|
|
90
209
|
.conn { font-size: 0.75rem; color: var(--ink-dim); margin-bottom: 1.5rem; }
|
|
91
210
|
.conn.ok { color: #16a34a; }
|
|
92
211
|
.conn.err { color: #dc2626; }
|
|
93
212
|
|
|
94
|
-
.keyfull { font-
|
|
213
|
+
.keyfull { font-size: 0.78rem; color: var(--ink-dim); word-break: break-all; }
|
|
214
|
+
.keyfull .mono { font-family: monospace; }
|
|
215
|
+
.repo-link {
|
|
216
|
+
font-family: monospace;
|
|
217
|
+
color: var(--accent);
|
|
218
|
+
cursor: pointer;
|
|
219
|
+
text-decoration: underline dotted;
|
|
220
|
+
}
|
|
221
|
+
.repo-link:hover { background: var(--flash); text-decoration-style: solid; }
|
|
222
|
+
|
|
223
|
+
/* Byte stream — zoomed strip in a horizontally-scrollable container,
|
|
224
|
+
click-drag-to-pan inside for "look around" navigation. */
|
|
225
|
+
.byte-strip-container {
|
|
226
|
+
width: 100%;
|
|
227
|
+
overflow-x: auto;
|
|
228
|
+
background: #faf9f4;
|
|
229
|
+
border: 1.5px solid var(--rule);
|
|
230
|
+
border-radius: var(--radius);
|
|
231
|
+
margin: 0.4rem 0 1rem;
|
|
232
|
+
cursor: grab;
|
|
233
|
+
}
|
|
234
|
+
.byte-strip-container.dragging { cursor: grabbing; user-select: none; }
|
|
235
|
+
.byte-strip-container.dragging .chunk { cursor: grabbing; }
|
|
236
|
+
.byte-strip { display: block; }
|
|
237
|
+
|
|
238
|
+
.byte-map {
|
|
239
|
+
display: block;
|
|
240
|
+
}
|
|
241
|
+
.byte-map .chunk {
|
|
242
|
+
cursor: pointer;
|
|
243
|
+
stroke: rgba(0, 0, 0, 0.15);
|
|
244
|
+
stroke-width: 0.4;
|
|
245
|
+
transition: stroke-width 0.08s, fill-opacity 0.08s;
|
|
246
|
+
}
|
|
247
|
+
.byte-map .chunk:hover { stroke: var(--ink); stroke-width: 1.5; }
|
|
248
|
+
.byte-map .chunk.current { stroke: var(--ink); stroke-width: 2; }
|
|
249
|
+
.byte-map .chunk.hovered { fill-opacity: 0.55; }
|
|
250
|
+
|
|
251
|
+
/* codec category palette — used in both the legend and the SVG fills */
|
|
252
|
+
.cat-commit { fill: #f59e0b; background: #f59e0b; }
|
|
253
|
+
.cat-sig { fill: #ef4444; background: #ef4444; }
|
|
254
|
+
.cat-composite { fill: #3b82f6; background: #3b82f6; }
|
|
255
|
+
.cat-duple { fill: #a855f7; background: #a855f7; }
|
|
256
|
+
.cat-string { fill: #10b981; background: #10b981; }
|
|
257
|
+
.cat-bytes { fill: #84cc16; background: #84cc16; }
|
|
258
|
+
.cat-num { fill: #64748b; background: #64748b; }
|
|
259
|
+
.cat-var { fill: #fbbf24; background: #fbbf24; }
|
|
260
|
+
.cat-other { fill: #cbd5e1; background: #cbd5e1; }
|
|
261
|
+
|
|
262
|
+
.byte-map-legend {
|
|
263
|
+
display: flex;
|
|
264
|
+
gap: 0.5rem;
|
|
265
|
+
flex-wrap: wrap;
|
|
266
|
+
font-size: 0.7rem;
|
|
267
|
+
color: var(--ink-dim);
|
|
268
|
+
margin-top: 0.5rem;
|
|
269
|
+
}
|
|
270
|
+
.byte-map-legend span {
|
|
271
|
+
display: inline-flex;
|
|
272
|
+
align-items: center;
|
|
273
|
+
gap: 0.3rem;
|
|
274
|
+
padding: 0.05rem 0.45rem 0.05rem 0.3rem;
|
|
275
|
+
border-radius: 999px;
|
|
276
|
+
color: #fff;
|
|
277
|
+
font-weight: 500;
|
|
278
|
+
letter-spacing: 0.02em;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* Tab strip — hand-drawn underline aesthetic to match proto.css */
|
|
282
|
+
.tabs {
|
|
283
|
+
display: flex;
|
|
284
|
+
gap: 1.25rem;
|
|
285
|
+
border-bottom: 1.5px solid var(--rule);
|
|
286
|
+
margin: 1.25rem 0 1rem;
|
|
287
|
+
}
|
|
288
|
+
.tab {
|
|
289
|
+
padding: 0.45rem 0.1rem;
|
|
290
|
+
margin-bottom: -1.5px;
|
|
291
|
+
border-bottom: 2px solid transparent;
|
|
292
|
+
cursor: pointer;
|
|
293
|
+
font-size: 0.85rem;
|
|
294
|
+
color: var(--ink-dim);
|
|
295
|
+
letter-spacing: 0.04em;
|
|
296
|
+
text-transform: lowercase;
|
|
297
|
+
}
|
|
298
|
+
.tab:hover { color: var(--ink); }
|
|
299
|
+
.tab.active {
|
|
300
|
+
color: var(--ink);
|
|
301
|
+
border-bottom-color: var(--ink);
|
|
302
|
+
font-weight: 600;
|
|
303
|
+
}
|
|
95
304
|
|
|
96
305
|
pre.value {
|
|
97
306
|
font-family: monospace;
|