OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / 8df8b46

0.0.39: an edge says what it is, and a post says who interacted with it
"we need to be able to see interactions?" — you could not. Every edge was the same grey thread, so the picture said something happened between two points and never what, and clicking a post told you its text and nothing about how anyone had answered it. · EDGES BY KIND. Colour is the only channel left — x, y and z are spent by the layout and by time — so each kind gets one, read from the same CSS variables the 2D view's strokes use. A repost cannot be green in one picture and grey in the other, and after somebody edits a single rule a shared variable is the only thing that holds. The legend names all four, because a colour nobody can decode is a decoration; the old single "an interaction" entry left a like and a repost indistinguishable, which is exactly what the colours now carry. · WHO, ON THE CARD. /api/node returns the reactions, reposts and reply count for a record, and the panel lists the PEOPLE — the line already told you something happened, so a bare count would repeat it and still not answer the question. Reactions are grouped by value: two people who sent different reactions did different things, and one number says they did the same one. Names where we have them, keys where we do not — inventing a name would be worse than the key. A card with nothing on it says so out loud, because an omitted row reads the same whether nobody reacted or nobody asked. ⚠ Three extra scans, paid once per CLICK on a record. Not for an actor, which has no target to scan for, and never for the graph payload — putting them there would be a scan per edge on every load. Verified in a browser: the var() indirection resolves, so --edge-reaction really does compute to the accent's hex and the 3D lines are not silently black. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8df8b46f113a4167ecde82adb5da8a2051e7e702
by plankatron <fleeky@gmail.com> · 2026-09-07T20:35:37.000Z

Changed files (6) +163 -17

lib/pages.js +19 -4
……@@ -88,6 +88,11 @@ const CSS = `
8888 color-scheme:light dark;
8989 --fg:#16181c; --dim:#5b6570; --line:#e1e4e8; --accent:#1d6fd4;
9090 --bg:#ffffff; --bg-soft:#f6f8fa;
91 /* Edge colours, declared ONCE and indirected through the palette where they follow it. Both the
92 2D view and the 3D one read these, so a repost cannot be green in one picture and grey in the
93 other after somebody edits a single rule. */
94 --edge-reaction:var(--accent); --edge-repost:#2f9c63; --edge-reply:#b96a1f;
95 --edge-follow:var(--dim); --edge-authored:var(--dim);
9196 --s1:.35rem; --s2:.7rem; --s3:1.1rem; --s4:1.8rem;
9297}
9398@media (prefers-color-scheme:dark){
……@@ -214,6 +219,11 @@ button.liked{font-weight:700;border-color:var(--accent);color:var(--accent)}
214219.legend i.gap{border:1px solid var(--dim);background:none}
215220.legend i.link{width:16px;height:0;border-radius:0;border-top:1px solid var(--dim)}
216221.legend i.weak{width:16px;height:0;border-radius:0;border-top:1px solid var(--dim);opacity:.35}
222.legend i.e{width:16px;height:0;border-radius:0;border-top:2px solid var(--dim)}
223.legend i.e-reaction{border-top-color:var(--edge-reaction)}
224.legend i.e-repost{border-top-color:var(--edge-repost)}
225.legend i.e-reply{border-top-color:var(--edge-reply)}
226.legend i.e-follow{border-top-color:var(--edge-follow)}
217227.stats{display:flex;flex-wrap:wrap;gap:6px 18px;margin:0;font-size:.82rem;color:var(--dim)}
218228/* Each label+count is ONE flex item, or a wrap splits a pair across two lines. And dd carries a
219229 40px inline-start margin by default, which pushed the last counts off the edge. */
……@@ -227,9 +237,11 @@ button.liked{font-weight:700;border-color:var(--accent);color:var(--accent)}
227237svg.graph{width:100%;height:auto;border:1px solid var(--line);border-radius:8px;background:var(--bg-soft)}
228238svg.graph text{font:11px ui-monospace,monospace;fill:var(--dim)}
229239svg.graph .e{stroke:var(--dim);stroke-width:1.2;opacity:.55}
230svg.graph .e.reaction{stroke:var(--accent)}
231svg.graph .e.repost{stroke:#3aa76d}
232svg.graph .e.follow{stroke:var(--dim)}
240svg.graph .e.reaction{stroke:var(--edge-reaction)}
241svg.graph .e.repost{stroke:var(--edge-repost)}
242svg.graph .e.reply{stroke:var(--edge-reply)}
243svg.graph .e.follow{stroke:var(--edge-follow)}
244svg.graph .e.authored{stroke:var(--edge-authored);opacity:.3}
233245/* ⛔ A dashed edge is a DIFFERENT FAILURE, not a faded one. Dash pattern rather than opacity alone,
234246 so it survives a reader who cannot distinguish the colours. */
235247svg.graph .gap{stroke-dasharray:4 3;opacity:1}
……@@ -847,7 +859,10 @@ export function explore () {
847859 '<span><i class="actor"></i> a person</span>' +
848860 '<span><i class="record"></i> a post</span>' +
849861 '<span><i class="gap"></i> held, bytes never arrived</span>' +
850 '<span><i class="link"></i> an interaction</span>' +
862 '<span><i class="e e-reaction"></i> a reaction</span>' +
863 '<span><i class="e e-repost"></i> a repost</span>' +
864 '<span><i class="e e-reply"></i> a reply</span>' +
865 '<span><i class="e e-follow"></i> a follow</span>' +
851866 // ⚠ Authorship is drawn faint because it is always true and would otherwise bury every social
852867 // edge. Saying so is the difference between a quiet line and one you assume is a failed link.
853868 '<span><i class="weak"></i> who wrote it</span>' +
lib/router.js +24 -2
……@@ -446,10 +446,32 @@ export async function route (req, res, ctx) {
446446 }
447447 const t = parseTarget(id)
448448 if (!t) return json(res, { error: 'not a node' }, 404)
449 const rows = await client.call('posts-by', { feed: t.feed }).catch(() => [])
449 /**
450 * ⛔ WHO INTERACTED, not just that something did. A line between two points says an interaction
451 * happened and cannot say what it was or who made it — so the card is where an interaction
452 * becomes readable, and the counts and the people have to reach it.
453 *
454 * ⚠ Three extra scans, paid ONCE PER CLICK on a record. They are not paid for an actor, which
455 * has no target to scan for, and never for the graph payload — putting them there would be a
456 * scan per edge on every load of the page.
457 */
458 const [rows, labels, reactions, reposts, replies] = await Promise.all([
459 client.call('posts-by', { feed: t.feed }).catch(() => []),
460 client.call('profiles').catch(() => ({})),
461 client.call('reactions', { target: t }).then((r) => (r && r.reactions) || []).catch(() => []),
462 client.call('reposts', { target: t }).then((r) => (r && r.reposts) || []).catch(() => []),
463 client.call('replies', { target: t }).catch(() => [])
464 ])
450465 const row = (rows || []).find((r) => r.collection === t.collection && r.name === t.name) || null
451 const labels = await client.call('profiles').catch(() => ({}))
466 // ⚠ Named where we can name them. A key is an answer, just a poor one — and inventing a name
467 // for a feed with no profile would be worse than showing the key.
468 const named = (a) => ({ author: a.author, name: ((labels || {})[a.author] || {}).name || null })
452469 return json(res, {
470 interactions: {
471 reactions: reactions.map((r) => Object.assign(named(r), { value: r.value || 'like' })),
472 reposts: reposts.map(named),
473 replies: Array.isArray(replies) ? replies.length : 0
474 },
453475 type: 'record',
454476 id,
455477 author: t.feed,
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface",
3 "version": "0.0.38",
3 "version": "0.0.39",
44 "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.",
55 "type": "module",
66 "main": "index.js",
static/explore.js +63 -9
……@@ -187,25 +187,39 @@ for (const p of P.values()) {
187187 pickable.push(m)
188188 if (m.userData.billboard) billboards.push(m)
189189}
190// ⚠ TWO passes. `authored` says who wrote a post and is now most of the edge set; drawing it at the
191// same weight as a reaction would bury every social edge under the structural ones. Faint for what
192// is always true, solid for what somebody did.
193const lines = (kinds, opacity) => {
190/**
191 * ⛔ ONE PASS PER KIND, so a line can say WHAT it is.
192 *
193 * Every edge was the same grey thread: the picture could tell you something happened between two
194 * points and never what. Colour is the only channel left — x, y and z are spent by the layout and
195 * by time — and it is read from the same CSS variables the 2D view's strokes use, so a repost
196 * cannot be green in one picture and grey in the other.
197 *
198 * ⚠ `authored` stays faint. It is now most of the edge set and is always true; drawn at the weight
199 * of a reaction it buries every social edge under the structural ones.
200 */
201const EDGE_STYLE = {
202 authored: ['--edge-authored', 0.14],
203 follow: ['--edge-follow', 0.4],
204 reaction: ['--edge-reaction', 0.6],
205 repost: ['--edge-repost', 0.6],
206 reply: ['--edge-reply', 0.6]
207}
208for (const kind of Object.keys(EDGE_STYLE)) {
209 const [varName, opacity] = EDGE_STYLE[kind]
194210 const pts = []
195211 for (const e of g.edges) {
196 if (!kinds(e.kind)) continue
212 if (e.kind !== kind) continue
197213 const a = P.get(e.from); const b = P.get(e.to)
198214 if (!a || !b) continue
199215 pts.push(new THREE.Vector3(a.x, a.y, a.z), new THREE.Vector3(b.x, b.y, b.z))
200216 }
201 if (!pts.length) return
217 if (!pts.length) continue
202218 scene.add(new THREE.LineSegments(
203219 new THREE.BufferGeometry().setFromPoints(pts),
204 new THREE.LineBasicMaterial({ color: DIM, transparent: true, opacity })
220 new THREE.LineBasicMaterial({ color: col(varName, '#8b98a5'), transparent: true, opacity })
205221 ))
206222}
207lines((k) => k === 'authored', 0.16)
208lines((k) => k !== 'authored', 0.45)
209223
210224// ── what is in the picture ────────────────────────────────────────────────────────────────────
211225//
……@@ -310,6 +324,46 @@ function paint (x, detail, bodyLines) {
310324 x.fillText('held, bytes never arrived —', L, y); y += 23
311325 x.fillText('this node cannot read it.', L, y); y += 23
312326 }
327
328 /**
329 * ⛔ WHO, not how many. A line between two points already says an interaction happened; the count
330 * alone would repeat that and still not answer what the picture cannot: who did it. So the names
331 * come first and the number is only there when the names run out.
332 *
333 * ⚠ Absent on a reveal, which has no `interactions` — proximity opens a post, and asking three
334 * more questions of the daemon for every post you drift past is not the same thing as clicking.
335 */
336 const inter = detail.interactions
337 if (inter) {
338 const who = (list) => list.map((r) => r.name || short(r.author))
339 const parts = []
340 if (inter.reactions.length) {
341 // ⚠ Grouped by the reaction itself. Two people who sent different reactions did different
342 // things, and folding them into one number says they did the same one.
343 const byValue = {}
344 for (const r of inter.reactions) (byValue[r.value] = byValue[r.value] || []).push(r)
345 for (const v of Object.keys(byValue)) {
346 parts.push((v === 'like' ? '\u2665' : v) + ' ' + who(byValue[v]).join(', '))
347 }
348 }
349 if (inter.reposts.length) parts.push('\u21bb ' + who(inter.reposts).join(', '))
350 if (inter.replies) parts.push(inter.replies + (inter.replies === 1 ? ' reply' : ' replies'))
351 if (parts.length) {
352 y += 6
353 x.strokeStyle = cssVar('--line', '#d7dbe0'); x.lineWidth = 1
354 x.beginPath(); x.moveTo(L, y - 12); x.lineTo(PANEL.w - L, y - 12); x.stroke()
355 x.fillStyle = dim; x.font = '13px system-ui,sans-serif'
356 for (const line of wrap(x, parts.join(' \u00b7 '), PANEL.w - L * 2, 3)) {
357 x.fillText(line, L, y); y += 18
358 }
359 } else {
360 x.fillStyle = dim; x.font = 'italic 13px system-ui,sans-serif'
361 y += 6
362 // ⚠ Said out loud. A card that simply omitted the row would read the same whether nobody had
363 // reacted or the question had never been asked.
364 x.fillText('no interactions this node can see', L, y); y += 18
365 }
366 }
313367 return y
314368}
315369
test/explore.test.js +21 -1
……@@ -78,7 +78,11 @@ test('★★ the page SAYS what the shapes mean, without JavaScript', async (t)
7878 // not an explanation — it is absent for exactly the reader who most needs it.
7979 const h = explore()
8080 const legend = h.slice(h.indexOf('class="legend"'), h.indexOf('id="scene"'))
81 for (const meaning of ['a person', 'a post', 'bytes never arrived', 'an interaction']) {
81 // ⛔ Every KIND by name. One entry saying "an interaction" told you a line meant something
82 // happened and left you unable to tell a like from a repost — which is the whole of what the
83 // colours now carry, and a colour nobody can decode is a decoration.
84 for (const meaning of ['a person', 'a post', 'bytes never arrived', 'who wrote it',
85 'a reaction', 'a repost', 'a reply', 'a follow']) {
8286 t.ok(legend.includes(meaning), 'legend explains: ' + meaning)
8387 }
8488 t.ok(legend.indexOf('<script') === -1, 'and it is markup, not a script')
……@@ -174,3 +178,19 @@ test('⚠ the reveal threshold is DERIVED from the layout, not a magic number',
174178 t.ok(/const REVEAL = RADIUS \*/.test(js), 'scaled to the cloud it is measuring')
175179 t.ok(/const RADIUS = /.test(js), 'and the cloud is measured, not assumed')
176180})
181
182test('⛔ the two views take their edge colours from ONE place', async (t) => {
183 // The 2D view hard-coded #3aa76d for a repost and the 3D view would have hard-coded a second
184 // set. Two renderings of one substrate may disagree about layout, never about which line is a
185 // repost — and after somebody edits one rule, a shared variable is the only thing that holds.
186 const page = explore()
187 const CSS = page.slice(page.indexOf('<style>') + 7, page.indexOf('</style>'))
188 const js = fs.readFileSync(join(staticDir, 'explore.js'), 'utf8')
189 for (const kind of ['reaction', 'repost', 'reply', 'follow', 'authored']) {
190 t.ok(CSS.includes('--edge-' + kind + ':'), '--edge-' + kind + ' is declared')
191 t.ok(js.includes("'--edge-" + kind + "'"), 'and the renderer reads it')
192 }
193 // ⚠ No literal hex for an edge in the renderer, which is how the second set would creep back.
194 const edges = js.slice(js.indexOf('const EDGE_STYLE'), js.indexOf('// ── what is in the picture'))
195 t.absent(/#[0-9a-f]{6}/i.test(edges.replace(/'#8b98a5'/g, '')), 'no hard-coded edge colours')
196})
test/routes.test.js +35 -0
……@@ -1093,6 +1093,41 @@ test('⛔ /api/records is gated and refuses a non-key', async (t) => {
10931093 t.is((await request('GET', '/api/records?feed=nope', { headers: { cookie } })).status, 404)
10941094})
10951095
1096test('★★ /api/node on a record says WHO interacted with it', async (t) => {
1097 reset()
1098 const A = 'ab'.repeat(32); const B = 'cd'.repeat(32); const C = 'ef'.repeat(32)
1099 answers = {
1100 'posts-by': [{ author: A, collection: 'post', name: 'p1', body: 'hi', createdAt: '2026-09-01T00:00:00Z' }],
1101 profiles: { [B]: { name: 'Bob' } },
1102 reactions: { reactions: [{ author: B, value: 'like' }, { author: C, value: '🔥' }] },
1103 reposts: { reposts: [{ author: C }] },
1104 replies: [{ author: B, name: 'r1', body: 'yes' }]
1105 }
1106 const n = JSON.parse((await request('GET', '/api/node?id=' + encodeURIComponent(A + '/post/p1'), { headers: { cookie } })).body)
1107 // ⛔ A line between two points says something happened; it cannot say what. The card is where an
1108 // interaction becomes readable, so the counts and the people have to reach it.
1109 t.is(n.interactions.reactions.length, 2)
1110 t.is(n.interactions.reactions[0].value, 'like')
1111 t.is(n.interactions.reposts.length, 1)
1112 t.is(n.interactions.replies, 1)
1113 // ⚠ Named where we can name them. A key is an answer, just a poor one — and inventing a name for
1114 // a feed with no profile would be worse than showing the key.
1115 t.is(n.interactions.reactions.find((r) => r.author === B).name, 'Bob')
1116 t.is(n.interactions.reactions.find((r) => r.author === C).name, null)
1117})
1118
1119test('⚠ an ACTOR node asks for none of that', async (t) => {
1120 reset()
1121 const A = 'ef'.repeat(32)
1122 answers = { profiles: {}, handles: { handles: [] }, holding: {} }
1123 await request('GET', '/api/node?id=' + A, { headers: { cookie } })
1124 // Three extra scans per click is the cost of the record answer; an actor has no target to scan
1125 // for, and asking anyway would spend it for nothing.
1126 t.is(called('reactions').length, 0)
1127 t.is(called('reposts').length, 0)
1128 t.is(called('replies').length, 0)
1129})
1130
10961131test('shut down', async (t) => {
10971132 await new Promise((r) => server.close(r))
10981133 await new Promise((r) => daemon.close(r))