| … | … | @@ -88,6 +88,11 @@ const CSS = ` |
| 88 | 88 | color-scheme:light dark; |
| 89 | 89 | --fg:#16181c; --dim:#5b6570; --line:#e1e4e8; --accent:#1d6fd4; |
| 90 | 90 | --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); | |
| 91 | 96 | --s1:.35rem; --s2:.7rem; --s3:1.1rem; --s4:1.8rem; |
| 92 | 97 | } |
| 93 | 98 | @media (prefers-color-scheme:dark){ |
| … | … | @@ -214,6 +219,11 @@ button.liked{font-weight:700;border-color:var(--accent);color:var(--accent)} |
| 214 | 219 | .legend i.gap{border:1px solid var(--dim);background:none} |
| 215 | 220 | .legend i.link{width:16px;height:0;border-radius:0;border-top:1px solid var(--dim)} |
| 216 | 221 | .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)} | |
| 217 | 227 | .stats{display:flex;flex-wrap:wrap;gap:6px 18px;margin:0;font-size:.82rem;color:var(--dim)} |
| 218 | 228 | /* Each label+count is ONE flex item, or a wrap splits a pair across two lines. And dd carries a |
| 219 | 229 | 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)} |
| 227 | 237 | svg.graph{width:100%;height:auto;border:1px solid var(--line);border-radius:8px;background:var(--bg-soft)} |
| 228 | 238 | svg.graph text{font:11px ui-monospace,monospace;fill:var(--dim)} |
| 229 | 239 | svg.graph .e{stroke:var(--dim);stroke-width:1.2;opacity:.55} |
| 230 | svg.graph .e.reaction{stroke:var(--accent)} | |
| 231 | svg.graph .e.repost{stroke:#3aa76d} | |
| 232 | svg.graph .e.follow{stroke:var(--dim)} | |
| 240 | svg.graph .e.reaction{stroke:var(--edge-reaction)} | |
| 241 | svg.graph .e.repost{stroke:var(--edge-repost)} | |
| 242 | svg.graph .e.reply{stroke:var(--edge-reply)} | |
| 243 | svg.graph .e.follow{stroke:var(--edge-follow)} | |
| 244 | svg.graph .e.authored{stroke:var(--edge-authored);opacity:.3} | |
| 233 | 245 | /* ⛔ A dashed edge is a DIFFERENT FAILURE, not a faded one. Dash pattern rather than opacity alone, |
| 234 | 246 | so it survives a reader who cannot distinguish the colours. */ |
| 235 | 247 | svg.graph .gap{stroke-dasharray:4 3;opacity:1} |
| … | … | @@ -847,7 +859,10 @@ export function explore () { |
| 847 | 859 | '<span><i class="actor"></i> a person</span>' + |
| 848 | 860 | '<span><i class="record"></i> a post</span>' + |
| 849 | 861 | '<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>' + | |
| 851 | 866 | // ⚠ Authorship is drawn faint because it is always true and would otherwise bury every social |
| 852 | 867 | // edge. Saying so is the difference between a quiet line and one you assume is a failed link. |
| 853 | 868 | '<span><i class="weak"></i> who wrote it</span>' + |
| … | … | @@ -446,10 +446,32 @@ export async function route (req, res, ctx) { |
| 446 | 446 | } |
| 447 | 447 | const t = parseTarget(id) |
| 448 | 448 | 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 | ]) | |
| 450 | 465 | 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 }) | |
| 452 | 469 | 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 | }, | |
| 453 | 475 | type: 'record', |
| 454 | 476 | id, |
| 455 | 477 | author: t.feed, |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface", |
| 3 | "version": "0.0.38", | |
| 3 | "version": "0.0.39", | |
| 4 | 4 | "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -187,25 +187,39 @@ for (const p of P.values()) { |
| 187 | 187 | pickable.push(m) |
| 188 | 188 | if (m.userData.billboard) billboards.push(m) |
| 189 | 189 | } |
| 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. | |
| 193 | const 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 | */ | |
| 201 | const 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 | } | |
| 208 | for (const kind of Object.keys(EDGE_STYLE)) { | |
| 209 | const [varName, opacity] = EDGE_STYLE[kind] | |
| 194 | 210 | const pts = [] |
| 195 | 211 | for (const e of g.edges) { |
| 196 | if (!kinds(e.kind)) continue | |
| 212 | if (e.kind !== kind) continue | |
| 197 | 213 | const a = P.get(e.from); const b = P.get(e.to) |
| 198 | 214 | if (!a || !b) continue |
| 199 | 215 | pts.push(new THREE.Vector3(a.x, a.y, a.z), new THREE.Vector3(b.x, b.y, b.z)) |
| 200 | 216 | } |
| 201 | if (!pts.length) return | |
| 217 | if (!pts.length) continue | |
| 202 | 218 | scene.add(new THREE.LineSegments( |
| 203 | 219 | 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 }) | |
| 205 | 221 | )) |
| 206 | 222 | } |
| 207 | lines((k) => k === 'authored', 0.16) | |
| 208 | lines((k) => k !== 'authored', 0.45) | |
| 209 | 223 | |
| 210 | 224 | // ── what is in the picture ──────────────────────────────────────────────────────────────────── |
| 211 | 225 | // |
| … | … | @@ -310,6 +324,46 @@ function paint (x, detail, bodyLines) { |
| 310 | 324 | x.fillText('held, bytes never arrived —', L, y); y += 23 |
| 311 | 325 | x.fillText('this node cannot read it.', L, y); y += 23 |
| 312 | 326 | } |
| 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 | } | |
| 313 | 367 | return y |
| 314 | 368 | } |
| 315 | 369 |
| … | … | @@ -78,7 +78,11 @@ test('★★ the page SAYS what the shapes mean, without JavaScript', async (t) |
| 78 | 78 | // not an explanation — it is absent for exactly the reader who most needs it. |
| 79 | 79 | const h = explore() |
| 80 | 80 | 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']) { | |
| 82 | 86 | t.ok(legend.includes(meaning), 'legend explains: ' + meaning) |
| 83 | 87 | } |
| 84 | 88 | 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', |
| 174 | 178 | t.ok(/const REVEAL = RADIUS \*/.test(js), 'scaled to the cloud it is measuring') |
| 175 | 179 | t.ok(/const RADIUS = /.test(js), 'and the cloud is measured, not assumed') |
| 176 | 180 | }) |
| 181 | ||
| 182 | test('⛔ 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 | }) |
| … | … | @@ -1093,6 +1093,41 @@ test('⛔ /api/records is gated and refuses a non-key', async (t) => { |
| 1093 | 1093 | t.is((await request('GET', '/api/records?feed=nope', { headers: { cookie } })).status, 404) |
| 1094 | 1094 | }) |
| 1095 | 1095 | |
| 1096 | test('★★ /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 | ||
| 1119 | test('⚠ 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 | ||
| 1096 | 1131 | test('shut down', async (t) => { |
| 1097 | 1132 | await new Promise((r) => server.close(r)) |
| 1098 | 1133 | await new Promise((r) => daemon.close(r)) |