Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/improve.js

157 KiB, 190 runs

created by r2519314175:1385, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* ============================================================
2 Daimond — the Social panel (improve.js)
3 ------------------------------------------------------------
4 Where a note is written, and where the proposals made from
5 notes are read and voted on. `dev/IMPROVE_CONTRACT.md` is the
6 contract; `www/guide/social.html` is the public promise this
7 panel has to keep, and every part of this screen is called what
8 that page already calls it: a head, chips, a closer, rows,
9 and the note box.
10
11 THE PANEL IS SOCIAL AND THIS FILE IS TWO OF ITS FOUR CHIPS.
12 Decision 13 renamed the `improve` dock panel to `social` and gave
13 it Messages, People, Notes and Proposals. Notes and Proposals are
14 this file's, whole. Messages and People are empty containers with
15 an honest line in each, rendered into by other modules — so this
16 file also owns the SHELL: the chips, the view switch, and the
17 `window.DaimondSocial` those modules talk to. It is one file
18 rather than two because the shell has no life of its own: the
19 chips, the head and the i18n surface are the same head Notes and
20 Proposals already hang on.
21
22 The `/api/improve` ROUTE is unchanged. The panel was renamed, not
23 the door the forge is behind.
24
25 ── A NOTE IS A PROPOSAL NOW ────────────────────────────────
26
27 This panel used to post a note to `/api/note` and read its
28 proposals out of a file that shipped with the build. There has
29 never been a `/api/note` handler in the gateway, so every note a
30 tester pressed Send on was answered 404 and kept; and
31 `assets/proposals.json` shipped permanently empty, so the
32 proposals half drew nothing. Two carefully reasoned halves of one
33 feature that had never met.
34
35 Both now go through the one door the gateway actually has:
36
37 GET /api/improve?account=&repo=[&state=][&from=][&limit=]
38 GET /api/improve?account=&repo=&n=<number>
39 POST /api/improve?account=&repo= open one
40 POST /api/improve?account=&repo=&n=<number> comment
41 POST /api/improve?account=&repo=&n=&vote=1 vote
42
43 which the gateway forwards over loopback to the Oregami forge.
44 The compose box OPENS A PROPOSAL: the first line is its title and
45 the rest is its body. Keep still keeps, and a note that could not
46 be sent is still kept here and says so.
47
48 ── THE ONE RULE THIS FILE EXISTS TO KEEP ───────────────────
49
50 A NOTE LEAVES THIS DEVICE ONLY WHEN A PERSON PRESSES SEND ON
51 THAT ONE NOTE, AND WHAT LEAVES IS EXACTLY THE CHARACTERS ON
52 THE SCREEN AT THAT MOMENT.
53
54 Nothing about a note is queued, retried, batched or synced.
55
56 `telemetry.js` keeps the same promise from the other side and
57 the contrast is worth stating, because the two files look like
58 opposites and are not. That one makes leaking impossible by
59 SHAPE: its payload can only ever be integers, so no edit to it
60 can carry a sentence. Free text has no shape to hide behind, so
61 this file makes leaking impossible by ACT: there is no sender
62 until the press, `outgoing()` reads the screen and `split()` CUTS
63 what it read into the two fields a proposal is -- a cut, never an
64 addition, so putting the two back together with one newline gives
65 the characters that were on the screen, and dev/verify_improve.mjs
66 settles that by comparing two strings.
67
68 The old form had no envelope at all, and said so: an envelope is
69 somewhere a field can hide. A proposal has a title and a body, so
70 an envelope there must be, and what replaces the argument is a
71 check rather than a shape -- the request's FIELD SET is asserted
72 to be exactly `title`, `body` and at most `build`, so a fifth
73 field has to defeat a check rather than merely be forgotten.
74
75 ── AND WHAT PRESSING SEND MEANS IS SAID BEFORE IT ──────────
76
77 The forge refuses on a repository's `public` flag before it
78 examines any credential, and renders public repositories only,
79 so this panel can read nothing at all unless `oxedyne/daimond`
80 is public. It is. A proposal there is therefore readable by
81 ANYBODY, with NO credential, the voice name it was written
82 under included. The rule above is unchanged by that -- a note
83 still leaves only on a press -- but the press now means more
84 than it did, so `drawPublic()` puts that in the box, above the
85 button, where the person acts. It is on the screen exactly when
86 Send is, and a person with no voice is told nothing of the kind,
87 because they cannot send and it would not be true.
88
89 And a note that cannot be sent STAYS HERE AND SAYS SO. There is
90 no retry: a queue of text outlives the consent that filled it,
91 which is the failure telemetry.js refuses by not remembering
92 consent at all. Copy is offered instead.
93
94 ── WHAT GOES WITH A NOTE GOES IN THE NOTE ──────────────────
95 The guide asks a user to say which build, which panel was open,
96 whether they were on a phone, which palette they were wearing.
97 That is gathered for them and shown as one line, in the exact
98 characters that will be appended, in a row with a CLOSER on it.
99 Closing the row takes the line off the screen and off the wire --
100 and off the `build` field with it, since that field carries the
101 same characters the row's first item shows. There is no third
102 state and nothing is gathered silently.
103
104 ── A CONTROL DRAWS FROM THE ANSWER, NOT FROM A DATE ────────
105 Contract §9 puts voting on the forge, and the forge answers it:
106 `views/proposals.rs` dispatches `proposals/<n>/vote`, and both
107 the listing and the whole record carry `votes`, checked against
108 the deployed host on 2026-08-27. So the control DRAWS, and it
109 draws for the reason it was built to -- BECAUSE THE ANSWER
110 CARRIES `votes`, never because a comment here said the day had
111 come. A visible control that reaches nothing is the defect this
112 file was rewritten to remove, and a control gated on the answer
113 cannot become one.
114
115 That is the whole point of the shape, and it has now been paid
116 off once: nothing in this file changed when the forge started
117 answering. The three sentences that said otherwise -- here, at
118 `cleanProp`, and in the contract's §9 -- were prose that had gone
119 stale while the code stayed right, which is the failure mode a
120 dated claim has and a derived one does not.
121
122 AMENDMENT IS THE SAME SHAPE AGAIN, and it has now paid off
123 twice. The forge answers `POST proposals/<n>/amend` and carries
124 `mine_to_amend` beside `mine`; the gateway's door was already
125 open (`&amend=1`), and `drawAmendControl` below draws only when
126 that flag is PRESENT. Absent is not false -- see `cleanProp`.
127 The whole proposal also carries `revisions`, a LIST oldest
128 first and EMPTY rather than absent, which is not the count
129 `comments` beside it is.
130
131 Attaches two globals, `window.DaimondSocial` (the panel
132 shell) and `window.DaimondImprove` (notes and proposals).
133 ============================================================ */
134(function () {
135 'use strict';
136
137 // ── Saying things ──────────────────────────────────────────
138
139 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
140
141 /// A string with the English written at the call site as its fallback.
142 ///
143 /// `t` answers with the KEY when the table has no entry, so a panel built
144 /// against keys the locale files have not been given yet would read
145 /// "social.send" on screen. Every string this panel adds is new, and they
146 /// are routed to the catalogue separately from this file, so all of them go
147 /// through here: the English shows until the tables catch up, and not one
148 /// moment longer. trash.js keeps the same discipline for the same reason.
149 function tOr(k, fallback, v) {
150 var s = t(k, v);
151 if (s !== k) return s;
152 if (!v) return fallback;
153 return String(fallback).replace(/\{(\w+)\}/g, function (whole, name) {
154 return v[name] != null ? String(v[name]) : whole;
155 });
156 }
157
158 function tnOr(k, n, one, other, v) {
159 var s = window.DaimondI18n ? DaimondI18n.tn(k, n, v) : k;
160 if (s.indexOf(k) !== 0) return s;
161 return String(n === 1 ? one : other).replace(/\{(\w+)\}/g, function (whole, name) {
162 return v && v[name] != null ? String(v[name]) : whole;
163 });
164 }
165
166 function log(/* ...args */) {
167 try { if (window.console && console.debug) console.debug.apply(console, ['[improve]'].concat([].slice.call(arguments))); }
168 catch (e) { /* no console */ }
169 }
170
171 function el(id) { return document.getElementById(id); }
172
173 // ── The repository this panel reads ────────────────────────
174 //
175 // One account, one repository, one panel: the contract has no
176 // cross-repository listing and this is not a setting. It names Daimond's own
177 // forge repository, which is the same for every tester, so a knob for it
178 // would be a knob whose only correct value is this one.
179
180 var ACCOUNT = 'oxedyne';
181 var REPO = 'daimond';
182
183 /// How many proposals one page carries. Well inside the contract's 1..200,
184 /// and small enough that opening the panel on a large repository draws
185 /// something immediately rather than after two hundred records.
186 var PAGE = 25;
187
188 // ── The store ──────────────────────────────────────────────
189 //
190 // One key, namespaced per account by accounts.js like every other
191 // `daimond-*`. Notes are NOT in the sync parcel and are not coming: a note is
192 // a report of a moment on the device it happened on, and making them travel
193 // needs a merge rule this contract does not have.
194 //
195 // VOTES ARE NOT HERE ANY MORE. They used to be, with a queue that carried one
196 // made offline. Contract §9 puts the tally on the forge, and a copy here
197 // would be a second store of truth about one proposal -- which is exactly
198 // what §9 rejected both alternatives for. What the forge says is what is
199 // drawn.
200
201 var KEY = 'daimond-improve';
202 var MAX_NOTES = 200; // past this the oldest KEPT note goes; sent ones stay
203 var MAX_RAISED = 500; // cap on remembered posted-proposal numbers, oldest dropped
204 // What one note may be. NOT the gateway's cap, which is what this said until
205 // 2026-08-28: the improve route forwards at most 64 KiB (`MAX_BODY`,
206 // gateway/src/handlers/improve.rs:227) and no 20000 exists anywhere under
207 // `gateway/src/`. This is a client-side choice and a defensible one -- 20,000
208 // characters is a long report and well inside the door, so a note trimmed here
209 // is never a note the hop refuses -- but it was justified by a number nobody
210 // wrote, and the next reader would have moved it to match a cap it does not track.
211 var MAX_CHARS = 20000;
212
213 var _st = null;
214
215 /// A millisecond stamp, or 0. Not `n | 0`: epoch-ms is past 32 bits.
216 function ms(v) {
217 return (typeof v === 'number' && isFinite(v) && v > 0) ? Math.floor(v) : 0;
218 }
219
220 /// A whole number, or 0.
221 function whole(v) {
222 return (typeof v === 'number' && isFinite(v)) ? Math.floor(v) : 0;
223 }
224
225 /// One stored note, defended against whatever was in storage.
226 function cleanNote(r) {
227 if (!r || typeof r !== 'object') return null;
228 var text = (typeof r.text === 'string') ? r.text : '';
229 if (!text) return null;
230 return {
231 id: String(r.id || '') || ('n' + ms(r.at)),
232 at: ms(r.at) || Date.now(),
233 text: text.slice(0, MAX_CHARS),
234 mode: (r.mode === 'polish') ? 'polish' : 'verbatim',
235 build: (typeof r.build === 'string') ? r.build : '',
236 sent: ms(r.sent),
237 /// The proposal this note became, or 0. Kept so a row can name it: a
238 /// tester who sent something and was told only "Sent" has no way back
239 /// to what happened to it.
240 n: Math.max(0, whole(r.n)),
241 // Folded, which is not sent
242 //
243 // NOT `n`, and the difference is this panel's only copy of somebody's
244 // words. `n` is the proposal this note BECAME: the forge holds these
245 // exact characters, so what is here is a second copy. `into` is a
246 // proposal that js/triage.js DRAFTED from this note, usually beside
247 // others -- the forge holds the drafting, which is not what this
248 // person wrote, and where several notes were folded together it holds
249 // a fragment of each. A folded note is therefore still the only copy
250 // of what somebody wrote, and `save()` must never evict one.
251 //
252 // A LIST, because one note holding two faults is drafted into two
253 // proposals. This tree has lost user data once already to a field
254 // doing double duty; `n` doing this one as well would be the same
255 // mistake with the same consequence.
256 into: intoList(r.into),
257 };
258 }
259
260 /// The proposal numbers on a stored note's `into`, cleaned. Whole numbers
261 /// above zero, no repeats, and capped: a record that arrived from anywhere
262 /// but this file's own `fold()` is still only a list of numbers.
263 function intoList(v) {
264 if (!Array.isArray(v)) return [];
265 var out = [];
266 for (var i = 0; i < v.length && out.length < 32; i++) {
267 var n = Math.max(0, whole(v[i]));
268 if (n > 0 && out.indexOf(n) === -1) out.push(n);
269 }
270 return out;
271 }
272
273 function load() {
274 if (_st) return _st;
275 _st = { notes: [], raised: [] };
276 try {
277 var raw = JSON.parse(localStorage.getItem(KEY) || '{}') || {};
278 (Array.isArray(raw.notes) ? raw.notes : []).forEach(function (n) {
279 var c = cleanNote(n);
280 if (c) _st.notes.push(c);
281 });
282 // The proposal numbers this device has POSTED, kept apart from the notes.
283 // A posted note leaves the queue at once (`through`), so the number it
284 // became is the only trace that this device raised it -- see `recordRaised`.
285 (Array.isArray(raw.raised) ? raw.raised : []).forEach(function (n) {
286 var w = whole(n);
287 if (w > 0 && _st.raised.indexOf(w) === -1) _st.raised.push(w);
288 });
289 } catch (e) { _st = { notes: [], raised: [] }; }
290 return _st;
291 }
292
293 /// Does the forge hold this note's own characters?
294 ///
295 /// The one question `save()`'s cap turns on, and the reason it is a function
296 /// with a name. `sent` says these exact characters went to the forge as a
297 /// proposal, so a second copy here is spare. `into` says a DRAFT written from
298 /// this note went instead -- the forge holds the drafting, and where several
299 /// notes were folded into one proposal it holds a fragment of each. So a
300 /// folded note is still the only copy of what somebody wrote and is never
301 /// spare, whatever else is true of it.
302 ///
303 /// Both are tested rather than only `sent`, although no path here sets both:
304 /// a rule this file cannot afford to get wrong should not also depend on a
305 /// rule kept somewhere else.
306 function delivered(rec) {
307 return !!(rec && rec.sent && !(rec.into && rec.into.length));
308 }
309
310 /// The proposal numbers this DEVICE has raised, de-duped, whole and above zero.
311 ///
312 /// The local voice has NO NAME -- the secret is the identity and this client
313 /// never learns its own author (voice.js) -- so "raised here" cannot be matched
314 /// against a proposal's `author`. It is matched by the numbers this file already
315 /// tracks: every stored note's `into` (the proposals a draft written from it was
316 /// folded into) and its `n` (the proposal the note itself became, where a path
317 /// keeps the note). The Improve hub's "Mine" filter reads this and shows only
318 /// those numbers. A read, so nothing here writes the store.
319 function raisedProposalNumbers() {
320 var out = [];
321 var s = load();
322 s.notes.forEach(function (rec) {
323 (rec.into || []).forEach(function (n) {
324 if (n > 0 && out.indexOf(n) === -1) out.push(n);
325 });
326 if (rec.n > 0 && out.indexOf(rec.n) === -1) out.push(rec.n);
327 });
328 // Numbers from posts whose note has already left the queue.
329 (s.raised || []).forEach(function (n) {
330 if (n > 0 && out.indexOf(n) === -1) out.push(n);
331 });
332 return out;
333 }
334
335 /// Remember that this device posted proposal `n`, so the Improve hub's "Mine"
336 /// filter shows it. A verbatim post removes its note the moment the forge takes
337 /// it (`through`), so the number is the only surviving trace that THIS device
338 /// raised the proposal -- a resurrected note would be wrong (the note is spare
339 /// once the proposal exists), but the authorship fact is not spare.
340 function recordRaised(n) {
341 var num = Math.max(0, whole(n));
342 if (!num) return;
343 var s = load();
344 if (!Array.isArray(s.raised)) s.raised = [];
345 if (s.raised.indexOf(num) !== -1) return;
346 s.raised.push(num);
347 // A cap for symmetry with MAX_NOTES: drop the oldest numbers, which matter
348 // least once a proposal is long settled.
349 if (s.raised.length > MAX_RAISED) s.raised.splice(0, s.raised.length - MAX_RAISED);
350 save();
351 }
352
353 function save() {
354 var s = load();
355 // Newest first on disk as well as on screen, so a person reading the raw
356 // key finds what they just wrote at the top of it.
357 s.notes.sort(function (a, b) { return b.at - a.at || (a.id < b.id ? 1 : -1); });
358 // A cap, so a panel nobody empties cannot fill the quota.
359 //
360 // THE OLDEST SENT NOTES GO FIRST. A sent note has a copy at the other end;
361 // a kept one is the ONLY copy of what somebody wrote, and dropping it to
362 // make room for a note that has already been delivered is losing the one
363 // that mattered. Kept notes are dropped only if there is nothing else
364 // left to drop, and then oldest first like anything else.
365 //
366 // A FOLDED NOTE IS NOT A DELIVERED ONE, and `delivered()` is where that
367 // is decided rather than here, so the one question this rule turns on is
368 // asked in one place and can be proved on its own.
369 if (s.notes.length > MAX_NOTES) {
370 var over = s.notes.length - MAX_NOTES;
371 for (var i = s.notes.length - 1; i >= 0 && over > 0; i--) {
372 if (delivered(s.notes[i])) { s.notes.splice(i, 1); over--; }
373 }
374 if (over > 0) s.notes.length = MAX_NOTES;
375 }
376 try { localStorage.setItem(KEY, JSON.stringify({ v: 3, notes: s.notes, raised: s.raised || [] })); }
377 catch (e) { log('could not write the notes', e); }
378 }
379
380 // ── What goes with a note ──────────────────────────────────
381 //
382 // The guide's fourth piece of advice, gathered rather than asked for. Every
383 // item here is one the guide names by name, and each is a fact about the
384 // APP, never about the person: no user agent string, no screen fingerprint,
385 // no id of any kind.
386
387 var _build = '';
388
389 /// Read the build id once, from the same `build.json` the updater reads.
390 /// A failure leaves it empty, and the line simply does not name a build --
391 /// which is honest, where a guessed one would not be.
392 function readBuild() {
393 try {
394 return fetch('build.json', { cache: 'no-store' })
395 .then(function (r) { return r.ok ? r.json() : null; })
396 .then(function (j) { _build = (j && typeof j.build === 'string') ? j.build : ''; })
397 .catch(function () { /* no build id; the line says less */ });
398 } catch (e) { return Promise.resolve(); }
399 }
400
401 /// Which panels are open, by the label the user reads on their chips -- not
402 /// by their ids, which mean nothing to the person writing the note and
403 /// nothing to a reader who has only ever seen the screen.
404 function openPanels() {
405 try {
406 if (!window.DaimondPanels || !DaimondPanels.panels) return [];
407 return DaimondPanels.panels()
408 .filter(function (p) { return DaimondPanels.isOpen(p.id); })
409 .map(function (p) { return p.label; });
410 } catch (e) { return []; }
411 }
412
413 /// The one line that is appended to a note, in the characters that will
414 /// travel. Built here and shown verbatim; nothing is added on the way out.
415 function context() {
416 var bits = [];
417 if (_build) bits.push(tOr('social.ctx_build', 'Build {id}', { id: _build }));
418 try {
419 var loc = window.DaimondI18n ? DaimondI18n.locale() : '';
420 if (loc) bits.push(loc);
421 } catch (e) { /* no i18n */ }
422 try { bits.push(window.innerWidth + '×' + window.innerHeight); } catch (e) { /* no window */ }
423 try {
424 var coarse = window.matchMedia && window.matchMedia('(any-pointer: coarse)').matches;
425 bits.push(coarse
426 ? tOr('social.ctx_touch', 'touch')
427 : tOr('social.ctx_pointer', 'pointer'));
428 } catch (e) { /* no matchMedia */ }
429 try {
430 var theme = localStorage.getItem('daimond-theme');
431 var skin = localStorage.getItem('daimond-skin');
432 if (theme) bits.push(tOr('social.ctx_palette', 'palette {name}', { name: theme + (skin ? ' ' + skin : '') }));
433 } catch (e) { /* private mode */ }
434 var panels = openPanels();
435 if (panels.length) bits.push(tOr('social.ctx_panels', 'panels open: {list}', { list: panels.join(', ') }));
436 return bits.join(' · ');
437 }
438
439 // ── The note box ───────────────────────────────────────────
440
441 /// Whether the "What goes with it" row has been closed for this note.
442 function contextOff() {
443 var row = el('improve-with');
444 return !!(row && row.dataset.off === '1');
445 }
446
447 /// The exact characters a Send would put on the wire, right now.
448 ///
449 /// THE ONE FUNCTION THAT DECIDES WHAT LEAVES. The box's value and, when the
450 /// row is still on screen, the text that row is showing -- read off the node,
451 /// not rebuilt, so a line the user cannot see cannot be in it. `send()` calls
452 /// this, `split()` cuts the result in two, and nothing else contributes.
453 ///
454 /// THE ROW IS REDRAWN FIRST, in the same breath as the read. The line names
455 /// the palette and the panels that are open, and both of those change under a
456 /// panel that is already on screen: on a desktop the Social panel stays put
457 /// while somebody switches palette or opens something else, and a row rendered
458 /// when the panel opened then describes a screen they have left behind. Notes
459 /// went out for weeks naming a palette nobody was looking at. Redrawing here,
460 /// rather than listening for every way the app can move, is what makes that a
461 /// closed class instead of two patched instances -- there is no signal left to
462 /// forget, because the line is computed at the moment of the press, shown, and
463 /// then read off the node exactly as before. Still a read of the screen: the
464 /// characters returned are the characters the row is showing when it returns.
465 ///
466 /// CLOSING THE ROW STILL TAKES THE LINE OFF THE WIRE. The `contextOff()` line
467 /// below returns before the redraw is reached, and `drawContext()` leaves a
468 /// closed row shut in any case.
469 function outgoing() {
470 var box = el('improve-box');
471 var body = box ? String(box.value || '').trim() : '';
472 if (!body) return '';
473 if (contextOff()) return body;
474 drawContext();
475 var line = el('improve-with-text');
476 var ctx = line ? String(line.textContent || '').trim() : '';
477 return ctx ? (body + '\n\n' + ctx) : body;
478 }
479
480 /// The note, cut into the fields a proposal is made of, or null when there is
481 /// no title to make one with.
482 ///
483 /// A CUT AND NEVER AN ADDITION. `title` is the characters before the first
484 /// newline and `body` is the characters after it, both verbatim, so
485 /// `title + '\n' + body` is exactly what `outgoing()` read off the screen --
486 /// which is the property the verifier settles by comparing two strings. A
487 /// note with no newline in it is all title and an empty body.
488 ///
489 /// `build` is the sealed build identifier contract §6 asks a panel to write,
490 /// and it travels ONLY while the "What goes with it" row is on screen: those
491 /// are the same characters that row's first item shows, so closing the row
492 /// takes them off the wire here as well as out of the body.
493 function split(text) {
494 var i = text.indexOf('\n');
495 var title = (i < 0) ? text : text.slice(0, i);
496 var body = (i < 0) ? '' : text.slice(i + 1);
497 if (!title.trim()) return null;
498 return { title: title, body: body, build: contextOff() ? '' : _build };
499 }
500
501 /// Redraw the row that says what goes with the note. Left closed if the user
502 /// closed it: a row that reappeared on every keystroke would be a control
503 /// that does not stay pressed.
504 function drawContext() {
505 var row = el('improve-with'), line = el('improve-with-text');
506 if (!row || !line) return;
507 if (row.dataset.off === '1') { row.hidden = true; return; }
508 var ctx = context();
509 line.textContent = ctx;
510 row.hidden = !ctx;
511 }
512
513 // ── The voice a proposal is written with ───────────────────
514 //
515 // `voice.js` holds it, encrypted under the user's passphrase, and this panel
516 // is the only surface that has ever needed one. Without a place to GET it the
517 // whole write half would be unreachable, which is the defect this file was
518 // rewritten to remove -- so the place is here, beside the button that needs
519 // it, rather than in a settings screen a tester would have to be told about.
520 //
521 // ONE TAP, NOT A PASTE. A first voice is now GOT rather than pasted: `Get my
522 // voice` posts to `/api/voice/provision`, the gateway has the forge mint one,
523 // and the secret it answers with is handed straight to `DaimondVoice.set` and
524 // dropped in the same breath. Nothing here logs it, draws it, or keeps it past
525 // the wrap. Pasting survives only as an unobtrusive fallback for a voice the
526 // forge made elsewhere -- kept because a secret can arrive out of band, not
527 // because a first voice is got that way.
528 //
529 // WHY A TAP AND NOT SILENT. Provisioning is a forge WRITE, so it happens on a
530 // press and never on its own: the button says what it will do, and a tester
531 // who only wants to read is never surprised by a voice appearing in their name.
532
533 function voice() { return window.DaimondVoice || null; }
534
535 /// The gateway module, or null in a build without it.
536 function gw() { return window.DaimondGateway || null; }
537
538 /// Is a voice held on this device? Presence only -- reading it needs the
539 /// passphrase, and that question is asked at the moment of a request.
540 function hasVoice() {
541 var v = voice();
542 try { return !!(v && v.has()); } catch (e) { return false; }
543 }
544
545 var _voiceOpen = false; // whether the manual-paste fallback form is showing
546 var _voiceBusy = false; // a provision request is in flight
547 var _voiceAlready = false; // the forge holds a voice this device has not got yet
548
549 /// Whether a voice is held, and how to change it -- drawn in the Settings view
550 /// now, not beside the compose box. Built here rather than in the markup because
551 /// every word of it is drawn from this file anyway. `drawSettings` calls this
552 /// after it has put the section heading in place.
553 function drawVoice() {
554 var write = el('improve-settings');
555 if (!write) return;
556 var host = el('improve-voice');
557 if (!host) {
558 host = document.createElement('div');
559 // `.imp-acts` lays a row of buttons out with a sentence above them, which
560 // is exactly this row's shape, so the panel needs no new rule to be
561 // legible.
562 host.className = 'imp-acts imp-voice';
563 host.id = 'improve-voice';
564 write.appendChild(host);
565 }
566 host.innerHTML = '';
567 if (!voice()) return; // no voice.js in this build
568
569 var line = document.createElement('span');
570 line.className = 'imp-as';
571 line.id = 'improve-voice-say';
572 line.textContent = hasVoice()
573 ? tOr('social.voice_held', 'Voice held on this device, encrypted under your passphrase.')
574 : tOr('social.voice_none', 'No voice yet: you can read proposals, but only Keep a note to this device.');
575 host.appendChild(line);
576
577 // THE MANUAL-PASTE FALLBACK, shared by "Replace the voice" (a voice is
578 // held) and the unobtrusive "I already have a voice" affordance (none is).
579 // It is no longer how a FIRST voice is got -- that is one tap now -- but a
580 // voice the forge made elsewhere can still be pasted in, so the form stays.
581 if (_voiceOpen) {
582 var input = document.createElement('input');
583 input.type = 'password';
584 input.className = 'imp-box';
585 input.id = 'improve-voice-in';
586 input.autocomplete = 'off';
587 input.spellcheck = false;
588 input.placeholder = tOr('social.voice_ph', 'Paste the line the forge showed you');
589 input.setAttribute('aria-label', tOr('social.voice_ph', 'Paste the line the forge showed you'));
590 host.appendChild(input);
591 host.appendChild(button('imp-send', 'improve-voice-save', tOr('social.voice_save', 'Save the voice')));
592 host.appendChild(button('imp-keep', 'improve-voice-cancel', t('common.cancel')));
593 return;
594 }
595
596 // A VOICE IS HELD: replace it, or forget the copy on this device.
597 if (hasVoice()) {
598 host.appendChild(button('imp-note-copy', 'improve-voice-open',
599 tOr('social.voice_replace', 'Replace the voice'),
600 tOr('social.voice_help', 'The line the forge showed you. Kept encrypted on this device.')));
601 host.appendChild(button('imp-note-copy', 'improve-voice-forget',
602 tOr('social.voice_forget', 'Forget it'),
603 tOr('social.voice_forget_help', 'Remove the copy on this device.')));
604 return;
605 }
606
607 // NO VOICE HELD. One tap gets one, and there is nothing to paste.
608 //
609 // The old empty state told a reader to ask Oxedyne for an invitation link
610 // and paste 45 characters back in -- true of how the forge issues a voice,
611 // but a dead end for a person who just wants to write, which is what the
612 // owner met. Provisioning makes the voice for them: the honest instruction
613 // is now "tap the button", and the button does exactly what it says.
614
615 // THE FORGE ALREADY HOLDS ONE, on another device -- the provision call said
616 // `already`. It is not minted again here; it arrives by sync. Say so, and
617 // offer the one way out if it never comes: a re-issue, which is destructive.
618 if (_voiceAlready) {
619 var arriving = document.createElement('span');
620 arriving.className = 'imp-as';
621 arriving.id = 'improve-voice-arriving';
622 arriving.textContent = tOr('social.voice_already',
623 'Your voice is set on another device and will sync here shortly.');
624 host.appendChild(arriving);
625 host.appendChild(button('imp-note-copy', 'improve-voice-reissue',
626 tOr('social.voice_reissue', 'I lost my voice \u2014 re-issue'),
627 tOr('social.voice_reissue_help',
628 'Makes a new voice; the old one stops working everywhere. Cannot be undone.')));
629 return;
630 }
631
632 var how = document.createElement('span');
633 how.className = 'imp-as';
634 how.id = 'improve-voice-how';
635 how.textContent = tOr('social.voice_intro',
636 'A voice lets you post, reply and vote on the forge; reading needs none.',
637 { host: FORGE_HOST });
638 host.appendChild(how);
639
640 // THE PRIMARY PATH: one tap. Disabled and relabelled while the request is
641 // in flight, so a second press cannot mint a second voice.
642 var get = button('imp-send', 'improve-voice-get',
643 _voiceBusy
644 ? tOr('social.voice_getting', 'Making your voice on the forge\u2026')
645 : tOr('social.voice_get', 'Get my voice'),
646 tOr('social.voice_get_help',
647 'Makes your voice on the forge. One tap.'));
648 if (_voiceBusy) get.disabled = true;
649 host.appendChild(get);
650
651 // THE UNOBTRUSIVE FALLBACK, kept per the owner's instruction behind an "I
652 // already have a voice" affordance rather than as the primary path: for a
653 // voice the forge made elsewhere and handed over out of band.
654 host.appendChild(button('imp-keep', 'improve-voice-open',
655 tOr('social.voice_have', 'I already have a voice'),
656 tOr('social.voice_have_help',
657 'Paste a voice the forge already gave you.')));
658 }
659
660 /// Take the secret off the input and hand it to voice.js, which wraps it.
661 /// The input is emptied whatever happened: a secret left in a field is a
662 /// secret in a screenshot.
663 async function saveVoice() {
664 var input = el('improve-voice-in');
665 if (!input) return false;
666 var raw = String(input.value || '');
667 input.value = '';
668 var v = voice();
669 if (!v) return false;
670 var why = '';
671 try { why = v.check(raw); } catch (e) { why = ''; }
672 if (why) { flash(why); return false; }
673 try { await v.set(raw); }
674 catch (e) { flash(e && e.message ? String(e.message) : tOr('social.voice_failed', 'That voice could not be stored.')); return false; }
675 _voiceOpen = false;
676 _voiceAlready = false;
677 flash(tOr('social.voice_saved', 'Your voice is held here, encrypted.'));
678 render();
679 return true;
680 }
681
682 /// A Daimond-session POST that keeps the STATUS and the BODY.
683 ///
684 /// The gateway's own `post()` reduces every failure to a message string and
685 /// throws it, but this panel has to tell a 402 `pro_required` from a 200
686 /// `already` and from an ordinary failure, and those live in the status and the
687 /// body. So it goes through `DaimondGateway.gwFetch` -- the one copy of the
688 /// session rule, renew once and retry once -- and reads the answer itself.
689 ///
690 /// NOT `DaimondVoice.send`: provisioning is how a voice is GOT, so there is no
691 /// voice to carry and this is a Daimond-account call rather than a forge one.
692 async function gwPost(path, body) {
693 var g = gw();
694 if (!g || !g.gwFetch) return { ok: false, status: 0, data: null };
695 var r;
696 try {
697 r = await g.gwFetch(path, {
698 method: 'POST',
699 credentials: 'same-origin',
700 headers: {
701 'content-type': 'application/json',
702 'x-daimond-api': String(g.clientApi ? g.clientApi() : ''),
703 },
704 body: JSON.stringify(body || {}),
705 });
706 } catch (e) {
707 // A network failure throws a `TypeError` whose message is the browser's
708 // own English; it must not reach a screen this app has translated. The
709 // caller says its own sentence off `ok:false`.
710 return { ok: false, status: 0, data: null };
711 }
712 var data = null;
713 try { data = await r.json(); } catch (e) { data = null; }
714 return { ok: r.ok, status: r.status, data: data };
715 }
716
717 /// Make this device a voice, on a press.
718 ///
719 /// POST `/api/voice/provision` -- body `{}` to mint or adopt, `{reissue:true}`
720 /// to replace a voice the forge holds that this device cannot read. The gateway
721 /// forwards to the forge and answers one of:
722 ///
723 /// 200 { provisioned:true, secret:"<45>" } a voice was minted -- hold it
724 /// 200 { provisioned:true, already:true } one exists already -- it syncs
725 /// 402 { error:"pro_required" } a voice is part of Pro
726 ///
727 /// A minted secret is handed straight to `DaimondVoice.set`, which wraps it
728 /// under the passphrase, and the local reference is dropped in the same breath.
729 /// NOTHING HERE LOGS, DRAWS OR KEEPS THE SECRET -- the same rule voice.js opens
730 /// with, kept on this side of the call as well.
731 async function provision(reissue) {
732 var v = voice();
733 if (!v || _voiceBusy) return false;
734 // A voice is wrapped at rest under the passphrase, so `set()` needs an
735 // unlocked identity. Say so before a round trip that would only fail at the
736 // end of it -- and before a forge write is made that could not be held.
737 try {
738 if (window.DaimondIdentity && !DaimondIdentity.isUnlocked()) {
739 flash(tOr('voice.err.locked',
740 'Unlock Daimond first: your voice is kept encrypted under your passphrase.'));
741 return false;
742 }
743 } catch (e) { /* no identity module: let set() below speak */ }
744
745 _voiceBusy = true;
746 drawVoice();
747 var a = await gwPost('/api/voice/provision', reissue ? { reissue: true } : {});
748 _voiceBusy = false;
749
750 // A VOICE IS PART OF PRO. Say it once and hand the person to the offer that
751 // already exists -- the Pro block at the top of the Credits drawer, the same
752 // door mail.js and sync.js send a buyer to -- rather than drawing a second
753 // one here. Read off the status OR the token, so a body-less 402 still lands.
754 if (a.status === 402 || (a.data && a.data.error === 'pro_required')) {
755 flash(tOr('social.voice_pro', 'A voice is part of Daimond Pro.'));
756 try {
757 if (window.DaimondAdmin && DaimondAdmin.credits) {
758 DaimondAdmin.credits(tOr('social.voice_pitch',
759 'A voice on the forge is part of Daimond Pro.'));
760 }
761 } catch (e) { /* the drawer is absent; the sentence still stood */ }
762 drawVoice();
763 return false;
764 }
765
766 if (!a.ok || !a.data || a.data.provisioned !== true) {
767 flash(tOr('social.voice_get_failed',
768 'Could not make your voice. Try again shortly.'));
769 drawVoice();
770 return false;
771 }
772
773 // THE FORGE ALREADY HOLDS ONE and did not hand a secret back: it exists on
774 // another device and arrives by sync, not by minting a second here. Say so,
775 // and `drawVoice` offers the destructive re-issue as the one way out.
776 if (a.data.already === true || typeof a.data.secret !== 'string') {
777 _voiceAlready = true;
778 _voiceOpen = false;
779 flash(tOr('social.voice_already',
780 'Your voice is set on another device and will sync here shortly.'));
781 drawVoice();
782 return true;
783 }
784
785 // A VOICE WAS MINTED. Hold it, then drop the plaintext at once.
786 try {
787 await v.set(a.data.secret);
788 } catch (e) {
789 // `set()` throws a sentence a person can act on -- a locked identity, a
790 // wrap that refused. Never quote the value.
791 flash(e && e.message ? String(e.message)
792 : tOr('social.voice_get_failed',
793 'Could not make your voice. Try again shortly.'));
794 drawVoice();
795 return false;
796 }
797 a.data.secret = ''; // in the clear only for the wrap above
798 _voiceOpen = false;
799 _voiceAlready = false;
800 flash(tOr('social.voice_saved', 'Your voice is held here, encrypted.'));
801 render();
802 return true;
803 }
804
805 /// Re-issue a voice the forge holds but this device cannot read.
806 ///
807 /// DESTRUCTIVE, so it asks first: a re-issue mints a new voice and the old one
808 /// stops working on every device, and there is no undo. Offered only after a
809 /// provision answered `already` -- the one case where a person genuinely lost
810 /// the copy the forge made for them.
811 async function reissueVoice() {
812 var ok = true;
813 try {
814 if (window.DaimondCore && DaimondCore.confirm) {
815 ok = await DaimondCore.confirm(
816 tOr('social.voice_reissue_ask',
817 'Re-issue your voice? The old one stops working on every device, and this cannot be undone.'),
818 tOr('social.voice_reissue_do', 'Re-issue'),
819 { title: tOr('social.voice_reissue_title', 'Re-issue your voice'), danger: true });
820 }
821 } catch (e) { ok = true; }
822 if (!ok) return false;
823 return await provision(true);
824 }
825
826 /// Forget the voice on this device. It asks, because the forge cannot give it
827 /// back: a voice is minted once and shown once.
828 async function forgetVoice() {
829 var v = voice();
830 if (!v) return false;
831 var ok = true;
832 try {
833 if (window.DaimondCore && DaimondCore.confirm) {
834 ok = await DaimondCore.confirm(
835 tOr('social.voice_ask_forget',
836 'Forget your voice here? It was shown once and will not be shown again.'),
837 tOr('social.voice_forget', 'Forget it'),
838 { title: tOr('social.voice_forget', 'Forget it') });
839 }
840 } catch (e) { ok = true; }
841 if (!ok) return false;
842 try { v.clear(); } catch (e) { /* nothing was stored */ }
843 flash(tOr('social.voice_forgotten', 'The copy on this device is gone.'));
844 render();
845 return true;
846 }
847
848 /// The Send button, asked for the way the markup really carries it.
849 ///
850 /// `#improve-acts .imp-send` AND NOT `#improve-send`, which does not exist: the
851 /// markup gives that button a class and no id. This file asked for it by id,
852 /// got null, and the guard that hides it silently did nothing -- so Send was
853 /// offered on every build to every user with nothing to send as, from the day
854 /// the panel was written. A verifier that asked for the same missing id would
855 /// have agreed with it: an absent locator reports itself hidden.
856 ///
857 /// One function, because two things now hang off this element -- whether Send
858 /// is offered, and whether the sentence saying what Send does is on the screen.
859 /// Two copies of a locator are two things to get separately wrong.
860 function sendBtn() {
861 return document.querySelector('#panel-social #improve-acts .imp-send');
862 }
863
864 /// Where the forge this panel writes to is read, in public.
865 ///
866 /// The panel talks to `/api/improve` and the gateway forwards from there, so
867 /// the browser is never told this address and cannot ask for it: it is a
868 /// deployment fact, written here once. If the gateway is ever pointed at a
869 /// different forge, THIS LINE IS A LIE UNTIL IT IS CHANGED, which is the price
870 /// of naming a host at all -- and naming it is the point, because "published
871 /// online" is a claim nobody can go and check.
872 var FORGE_HOST = 'oregami.oxegen.io';
873
874 /// The line beside Send, saying what a note goes as.
875 ///
876 /// A user must never find out AFTERWARDS that something about them went too,
877 /// so this is beside the button and not in a help text. It no longer names a
878 /// handle: what the forge attributes a proposal to is the VOICE, and the
879 /// browser never learns that voice's name -- there is no name on the wire.
880 ///
881 /// WITH a voice, this line says nothing: `drawPublic()` says all of it and
882 /// more, above the button rather than under it. The two sentences said the
883 /// same thing in different words, and four grey sentences stacked under one
884 /// box is how a panel starts reading as a warning against using it.
885 function drawAs() {
886 var as = el('improve-as');
887 var send = sendBtn();
888 if (as) {
889 as.hidden = hasVoice();
890 as.textContent = hasVoice() ? ''
891 : tOr('social.novoice_set', 'No voice yet — set one in Settings to post.');
892 }
893 // Without a voice there is nothing to post AS, and the forge would refuse
894 // it. The button is hidden rather than shown-and-inert: a control that
895 // does nothing when pressed teaches people to distrust every control. The
896 // polish button rides with it -- both post, and neither can without a voice.
897 if (send) send.hidden = !hasVoice();
898 var polish = el('panel-social') ? document.querySelector('[data-act="improve-polish"]') : null;
899 if (polish) polish.hidden = !hasVoice();
900 drawPublic(send);
901 drawHint();
902 }
903
904 /// What pressing Send does to a note, said ABOVE Send.
905 ///
906 /// The forge refuses on a repository's `public` flag before it looks at any
907 /// credential and draws only public repositories, so this panel can only read
908 /// anything at all while `oxedyne/daimond` is public -- and a proposal on a
909 /// public repository is readable by ANYBODY, with NO credential of any kind,
910 /// the voice name it was written under included. That is now part of what
911 /// pressing Send means, so it belongs where the press happens: in the box,
912 /// above the button, in front of the hand on its way there. Not a help page,
913 /// not an info mark, and not a dialogue -- a dialogue is dismissed once and
914 /// then never seen by the person who most needed it.
915 ///
916 /// It is drawn here rather than written into the markup for the reason
917 /// `drawHint()` and `drawVoice()` are: `www/index.html` is another lane's file
918 /// and every other word in this box is already drawn from this one.
919 ///
920 /// IT IS ON THE SCREEN EXACTLY WHEN SEND IS. A tester with no voice cannot
921 /// send, so telling them their notes will be published would be simply false;
922 /// and it is keyed off the BUTTON rather than off `hasVoice()` read a second
923 /// time, so the sentence cannot drift away from the control it describes.
924 function drawPublic(send) {
925 var write = document.querySelector('#panel-social .imp-write');
926 var acts = el('improve-acts');
927 if (!write || !acts) return;
928 var line = el('improve-public');
929 if (!line) {
930 line = document.createElement('p');
931 line.className = 'imp-public';
932 line.id = 'improve-public';
933 write.insertBefore(line, acts); // above the buttons, under the box
934 }
935 // Terse (#6): the whole address and the "no account needed" detail sit in the
936 // Post button's tooltip; the line under the box is the one fact that must be
937 // read before pressing -- it goes out in public, under your name.
938 line.textContent = tOr('social.compose_public',
939 'Posted publicly, under your voice name — anyone can read it.');
940 line.hidden = !(send && !send.hidden);
941 }
942
943 /// The one sentence a person needs before they press Send: the first line is
944 /// the title. Drawn beside the buttons rather than in the placeholder, which
945 /// vanishes the moment anybody types.
946 function drawHint() {
947 var acts = el('improve-acts');
948 if (!acts) return;
949 var hint = el('improve-hint');
950 if (!hint) {
951 hint = document.createElement('span');
952 hint.className = 'imp-as';
953 hint.id = 'improve-hint';
954 acts.appendChild(hint);
955 }
956 hint.textContent = tOr('social.title_hint',
957 'First line is the title; what happened goes below.');
958 }
959
960 // ── The queue, and posting from it ─────────────────────────
961
962 /// Put a note in the queue, with the mode a flush will send it in. There is no
963 /// "kept" state any more -- a note is here only because it has not been sent
964 /// yet, and it leaves the moment it is.
965 function store(text, mode) {
966 var s = load();
967 var rec = {
968 id: 'n' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
969 at: Date.now(),
970 text: String(text).slice(0, MAX_CHARS),
971 // How a flush will send it: 'verbatim' posts the words as they are;
972 // 'polish' has the model rewrite it into a proposal first. Remembered so
973 // a note queued offline is sent the way its author chose when the browser
974 // comes back.
975 mode: (mode === 'polish') ? 'polish' : 'verbatim',
976 // The sealed build identifier, captured NOW -- while the "what goes with
977 // it" row is in the state the author left it. A note may sit in the queue
978 // past a reconnect, by when the live row has been reset, so the build
979 // cannot be read again at send time or a closed row would put it back.
980 build: contextOff() ? '' : _build,
981 sent: 0,
982 n: 0,
983 into: [],
984 };
985 s.notes.unshift(rec);
986 save();
987 return rec;
988 }
989
990 /// Take one note off the queue. Called when a send takes it (the note becomes a
991 /// proposal, so the copy here is spare) and when a person drops it by hand.
992 function removeNote(id) {
993 var s = load();
994 var i = s.notes.findIndex(function (n) { return n.id === String(id); });
995 if (i === -1) return false;
996 s.notes.splice(i, 1);
997 save();
998 return true;
999 }
1000
1001 /// One queued note by id, or null.
1002 function find(id) {
1003 var s = load();
1004 for (var i = 0; i < s.notes.length; i++) if (s.notes[i].id === String(id)) return s.notes[i];
1005 return null;
1006 }
1007
1008 /// Is the browser online? `navigator.onLine` is only reliably FALSE (a true can
1009 /// still fail to reach the forge), so a submit tries to send and leaves the note
1010 /// queued if it does not go -- this only stops a pointless attempt while plainly
1011 /// offline, and gates the reconnect flush.
1012 function onLine() {
1013 try { return navigator.onLine !== false; } catch (e) { return true; }
1014 }
1015
1016 /// Mark the notes a draft was written from as folded into proposal `n`.
1017 ///
1018 /// WHAT THIS DOES NOT DO IS SET `sent`, and that is the whole of it. These
1019 /// characters did not leave: a drafting of them did, and this panel is still
1020 /// holding the only copy of what the person actually wrote. `delivered()`
1021 /// reads the difference, so a folded note survives the cap that a sent one
1022 /// does not.
1023 ///
1024 /// Called by js/triage.js after the forge took a draft, and by nothing else.
1025 function fold(ids, n) {
1026 var num = Math.max(0, whole(n));
1027 if (!num || !Array.isArray(ids) || !ids.length) return 0;
1028 var s = load(), hit = 0;
1029 ids.forEach(function (id) {
1030 var rec = s.notes.find(function (x) { return x.id === String(id); });
1031 if (!rec) return;
1032 if (rec.into.indexOf(num) === -1) rec.into.push(num);
1033 hit++;
1034 });
1035 if (hit) save();
1036 return hit;
1037 }
1038
1039 function clearBox() {
1040 var box = el('improve-box');
1041 if (box) box.value = '';
1042 // AT ONCE, not on the settle timer. This runs when a note has been kept or
1043 // sent, and a draft of a note that has already gone is words the user would
1044 // find sitting in the box afterwards looking unsent.
1045 try { if (drafts()) drafts().drop(draftKey('note')); } catch (e) { /* storage blocked */ }
1046 var row = el('improve-with');
1047 if (row) delete row.dataset.off; // the next note starts with its details on
1048 drawContext();
1049 }
1050
1051 /// Open a proposal from one note's characters.
1052 ///
1053 /// THE WHOLE OF WHAT LEAVES is these three fields, and all three came out of
1054 /// `split()`, which cut what `outgoing()` read off the screen. The field set
1055 /// is asserted by dev/verify_improve.mjs, so a fourth field has to defeat a
1056 /// check rather than merely be forgotten.
1057 ///
1058 /// A failure is not retried and nothing is queued. The note is kept, the row
1059 /// says so, and Copy is there for a person who wants to carry it themselves.
1060 async function post(parts) {
1061 var f = new URLSearchParams();
1062 f.set('title', parts.title);
1063 f.set('body', parts.body);
1064 if (parts.build) f.set('build', parts.build);
1065 return await ask(route(''), {
1066 method: 'POST',
1067 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1068 body: f.toString(),
1069 });
1070 }
1071
1072 /// What a refusal leaves on the screen after a send: why it did not go, and that
1073 /// the note is still waiting in the queue.
1074 function keptAfter(a) {
1075 return saying(a) + ' ' + tOr('social.waiting_here',
1076 'Waiting to send; it will go when the forge is reachable.');
1077 }
1078
1079 /// Put one already-stored note on the wire and take the answer back into the
1080 /// record. THE ONE DOOR a note leaves by, whether the press came from the box,
1081 /// from the queue, or from a daimon that was told yes -- so a fourth caller
1082 /// cannot quietly acquire a different idea of what "sent" means. On success the
1083 /// note LEAVES the queue (#3): the forge holds the proposal, so the copy here
1084 /// is spare and there is no list for it to sit in.
1085 async function through(rec, parts) {
1086 var a = await post(parts);
1087 if (a.ok) {
1088 var prop = cleanProp(a.data);
1089 removeNote(rec.id);
1090 absorb(prop);
1091 // The note is gone; the number it became is what the hub's "Mine" filter
1092 // reads, so keep it -- otherwise a post never appears in the very hub the
1093 // `showRaised` note points the person at.
1094 if (prop) recordRaised(prop.n);
1095 }
1096 return a;
1097 }
1098
1099 // ── The compose box: two ways to post, both auto-send ──────
1100 //
1101 // One box, two verbs. "Post" sends the words as they are; "Polish & post" has
1102 // the model rewrite them into a proposal first. NEITHER keeps -- both queue the
1103 // note and immediately try to send it, so the only holding area is the queue,
1104 // and only for a note that could not go yet. A note remembers which verb made
1105 // it, so a reconnect flush sends it the way its author chose.
1106
1107 /// Post the box, in one of the two modes. Queues the note, then -- if the
1108 /// browser is online -- tries to send it at once. If the send does not go (or
1109 /// the browser is offline) the note stays in the queue and the reconnect flush
1110 /// will take it later.
1111 async function submit(mode) {
1112 var text = outgoing();
1113 if (!text) { flash(tOr('social.nothing', 'Write something first.')); return null; }
1114 // A verbatim post needs a first line to be its title; a polished one does
1115 // not, because the model writes the title.
1116 if (mode !== 'polish' && !split(text)) {
1117 flash(tOr('social.no_title', 'First line is the title — write one, then what happened.'));
1118 return null;
1119 }
1120 if (!hasVoice()) { flash(tOr('social.novoice_set', 'No voice yet — set one in Settings to post.')); return null; }
1121 var rec = store(text, mode);
1122 clearBox();
1123 render();
1124 if (onLine()) { await sendOne(rec); }
1125 render();
1126 // Raised, whether it posted or is queued -- either way it has left the box.
1127 // Point the person at the Improve hub, where it is read and settled.
1128 showRaised();
1129 return rec;
1130 }
1131
1132 /// Send one queued note, in its own mode. Verbatim goes straight through the
1133 /// door; polish runs the model first and posts what it drafted.
1134 async function sendOne(rec) {
1135 var cur = find(rec.id);
1136 if (!cur) return false; // already gone
1137 return (cur.mode === 'polish') ? await sendPolished(cur) : await sendVerbatim(cur);
1138 }
1139
1140 async function sendVerbatim(rec) {
1141 var parts = split(rec.text);
1142 if (!parts) { flash(tOr('social.no_title', 'First line is the title — write one, then what happened.')); return false; }
1143 // The build is the note's OWN, captured when it was written -- not whatever
1144 // the live "what goes with it" row happens to say now. `split` reads the live
1145 // row, so its build is overwritten here.
1146 parts.build = rec.build || '';
1147 var a = await through(rec, parts);
1148 if (!a.ok) flash(keptAfter(a));
1149 return a.ok;
1150 }
1151
1152 /// Polish one note into a proposal with the model, then post it. The drafting
1153 /// is js/triage.js's, the one place the model machinery and its metering live;
1154 /// this posts what it drafted through the same door a verbatim note leaves by.
1155 /// A failure -- no model, offline, an unreadable answer -- leaves the note in
1156 /// the queue for the next flush.
1157 async function sendPolished(rec) {
1158 var got = null;
1159 try { if (window.DaimondTriage && DaimondTriage.polish) got = await DaimondTriage.polish(rec.text); }
1160 catch (e) { got = null; }
1161 if (!got || !got.title) {
1162 flash(tOr('social.polish_wait', 'The model could not draft it just now; it is still waiting to send.'));
1163 return false;
1164 }
1165 var a = await through(rec, { title: got.title, body: got.body || '', build: rec.build || '' });
1166 if (!a.ok) flash(keptAfter(a));
1167 return a.ok;
1168 }
1169
1170 /// Send one queued note now, by id -- the queue row's own "Send now". The same
1171 /// one act the flush makes, from a press instead of from a reconnect.
1172 async function resend(id) {
1173 var rec = find(id);
1174 if (!rec) return false;
1175 if (!hasVoice()) { flash(tOr('social.novoice_set', 'No voice yet — set one in Settings to post.')); return false; }
1176 var ok = await sendOne(rec);
1177 render();
1178 return ok;
1179 }
1180
1181 // ── Draining the queue when the browser comes back ─────────
1182 //
1183 // THE NET-NEW PIECE. A note written offline (or one whose send failed) waits in
1184 // the queue; when the browser fires `online`, js/daimond.js calls this and every
1185 // waiting note is sent in the mode it was written in -- a polish note drafts on
1186 // reconnect, because the model needs the network too. One flush at a time, and
1187 // it stops the moment the browser drops again rather than throwing every note at
1188 // a dead forge.
1189
1190 var _flushing = false;
1191
1192 async function flushQueue() {
1193 if (_flushing || !onLine()) return { sent: 0, waiting: load().notes.length };
1194 if (!hasVoice()) return { sent: 0, waiting: load().notes.length };
1195 _flushing = true;
1196 var sent = 0;
1197 try {
1198 var q = load().notes.slice(); // a snapshot of ids; the list changes under us
1199 for (var i = 0; i < q.length; i++) {
1200 if (!onLine()) break;
1201 var rec = find(q[i].id);
1202 if (!rec) continue; // taken meanwhile
1203 var ok = await sendOne(rec);
1204 if (ok) sent++;
1205 }
1206 } finally {
1207 _flushing = false;
1208 render();
1209 }
1210 return { sent: sent, waiting: load().notes.length };
1211 }
1212
1213 /// Delete one note. It is only on this device, so this is the whole of it --
1214 /// which is why it asks, and why the question says so.
1215 async function drop(id) {
1216 var s = load();
1217 var i = s.notes.findIndex(function (n) { return n.id === id; });
1218 if (i === -1) return false;
1219 var ok = true;
1220 try {
1221 if (window.DaimondCore && DaimondCore.confirm) {
1222 ok = await DaimondCore.confirm(
1223 tOr('social.drop_ask', 'Delete this note? It is only here — no other copy.'),
1224 tOr('social.drop_ok', 'Delete'),
1225 { title: tOr('social.drop', 'Delete this note') });
1226 }
1227 } catch (e) { ok = true; }
1228 if (!ok) return false;
1229 s.notes.splice(i, 1);
1230 save();
1231 render();
1232 return true;
1233 }
1234
1235 /// Put a note on the clipboard, so somebody with no voice, or no gateway,
1236 /// can still carry their own report out by hand.
1237 async function copy(id) {
1238 var s = load();
1239 var rec = s.notes.find(function (n) { return n.id === id; });
1240 if (!rec) return false;
1241 try { await navigator.clipboard.writeText(rec.text); }
1242 catch (e) { flash(t('copy.failed')); return false; }
1243 flash(tOr('social.copied', 'Copied.'));
1244 return true;
1245 }
1246
1247 /// One line under the box, for the answers that are not worth a dialog.
1248 function flash(text) {
1249 var n = el('improve-say');
1250 if (!n) return;
1251 n.textContent = text;
1252 clearTimeout(flash._t);
1253 flash._t = setTimeout(function () { if (n.textContent === text) n.textContent = ''; }, 8000);
1254 }
1255
1256 /// Open the Improve hub -- the read-and-settle board (js/tracker.js), a panel of
1257 /// its own. This surface CAPTURES a proposal; the hub is where it is read and
1258 /// decided, so a confirmation and a reference both hand the reader there.
1259 function openHub() {
1260 try { if (window.DaimondPanels) DaimondPanels.show('tracker'); } catch (e) { /* no engine */ }
1261 }
1262
1263 /// Say a note was raised, with a press that opens the Improve hub. Drawn after a
1264 /// post OR a queue: either way the note has left the box and is on its way, so
1265 /// the person is pointed at where it is read. The hub word is a real affordance,
1266 /// not prose -- "see it in Improve" with Improve the button.
1267 function showRaised() {
1268 var host = el('improve-raised');
1269 if (!host) return;
1270 host.innerHTML = '';
1271 host.appendChild(document.createTextNode(
1272 tOr('social.raised_lead', 'Raised — see it in') + ' '));
1273 host.appendChild(button('imp-raised-hub', 'improve-open-hub',
1274 tOr('social.raised_hub', 'Improve')));
1275 host.hidden = false;
1276 clearTimeout(showRaised._t);
1277 showRaised._t = setTimeout(function () { if (host) host.hidden = true; }, 12000);
1278 }
1279
1280 /// Take the confirmation down. Called when a new note is being written, so it
1281 /// does not sit stale over a fresh compose.
1282 function hideRaised() {
1283 var host = el('improve-raised');
1284 if (host && !host.hidden) host.hidden = true;
1285 }
1286
1287 // ── The wire ───────────────────────────────────────────────
1288 //
1289 // One door: `/api/improve`, which the gateway forwards over loopback to the
1290 // forge. The account and the repository ride in the QUERY on both methods, so
1291 // the body stays whatever the forge reads and the proxy in between never has
1292 // to parse it.
1293 //
1294 // Every request goes through `DaimondVoice.send`, which spreads the voice
1295 // header and goes on through `DaimondGateway.gwFetch` -- the one copy of the
1296 // session rule. A read of a public repository carries no voice at all, which
1297 // `header()` answers `{}` for by design, so this same door serves both.
1298
1299 /// The route, with the repository this panel reads. Built here and nowhere
1300 /// else, and the voice is never in it: a query string is written into every
1301 /// access log it passes.
1302 function route(extra) {
1303 var q = 'account=' + encodeURIComponent(ACCOUNT) + '&repo=' + encodeURIComponent(REPO);
1304 return '/api/improve?' + q + (extra ? '&' + extra : '');
1305 }
1306
1307 /// The nine refusals the forge speaks, per contract §3.1. A client branches
1308 /// on `error` and NEVER on `said`, which is a sentence a person reads and may
1309 /// be reworded at any time.
1310 var TOKENS = {
1311 absent: 1, unvoiced: 1, unknown: 1, unpermitted: 1, throttled: 1,
1312 malformed: 1, no_proposal: 1, unsupported: 1, internal: 1,
1313 };
1314
1315 /// The three reasons a `throttled` carries. Anything else is read as none.
1316 var BECAUSE = { address: 1, voice: 1, failing: 1 };
1317
1318 /// One exchange with the forge, as this panel reads it.
1319 ///
1320 /// `{ ok: true, data }`, or `{ ok: false, why, because, status }` where `why`
1321 /// is one of the nine tokens, `gateway` for a refusal this side generated, or
1322 /// `offline` for a request that never got an answer at all. Three sources of
1323 /// refusal and one shape, because every caller has to handle all three.
1324 async function ask(path, opts) {
1325 var v = voice();
1326 var r;
1327 try {
1328 r = v ? await v.send(path, opts || {}) : await fetch(path, opts || {});
1329 } catch (e) {
1330 // `header()` throws a SENTENCE a person can act on -- a locked
1331 // identity, a voice that cannot be read under this passphrase -- and
1332 // those are worth showing. A network failure throws a `TypeError`
1333 // whose message is the browser's own English ("Failed to fetch"),
1334 // which must never reach a screen this app has translated eight ways.
1335 var mine = (e && e.name !== 'TypeError' && e.message) ? String(e.message) : '';
1336 return { ok: false, why: 'offline', said: mine };
1337 }
1338 var text = '';
1339 try { text = await r.text(); } catch (e) { text = ''; }
1340 var data = null;
1341 try { data = text ? JSON.parse(text) : null; } catch (e) { data = null; }
1342 // The forge's refusal. Told from a record by the token, and from the
1343 // gateway's own refusal by the token being one of the nine: the gateway
1344 // answers `{ok:false, error:"<a sentence>"}`, which is not a token.
1345 if (data && typeof data === 'object' && typeof data.error === 'string' && TOKENS[data.error]) {
1346 return {
1347 ok: false,
1348 why: data.error,
1349 because: (typeof data.because === 'string' && BECAUSE[data.because]) ? data.because : '',
1350 status: r.status,
1351 };
1352 }
1353 if (r.ok && data && typeof data === 'object') return { ok: true, data: data };
1354 return { ok: false, why: 'gateway', status: r.status };
1355 }
1356
1357 /// Tag a refusal with the act it refused, so `saying()` can say the sentence
1358 /// that act needs. A copy, because the caller's record is what is drawn and a
1359 /// tag written onto it in place would outlive the request that earned it.
1360 function onAmend(a) {
1361 if (!a || a.ok) return a;
1362 var out = {};
1363 Object.keys(a).forEach(function (k) { out[k] = a[k]; });
1364 out.on = 'amend';
1365 return out;
1366 }
1367
1368 /// What a refusal says on the screen.
1369 ///
1370 /// EVERY ONE OF THE NINE IS SAID. A refusal a panel swallows is a panel that
1371 /// looks broken for a reason nobody can find, and this file shipped for weeks
1372 /// telling every tester the same sentence about a 404 that was really a route
1373 /// that did not exist.
1374 ///
1375 /// `absent` covers BOTH "no such repository" and "this repository is
1376 /// private", deliberately and permanently: any wording, status or timing that
1377 /// separated the two would republish exactly what a private repository is
1378 /// withholding. So the sentence has to be TRUE IN BOTH CASES. "There is no
1379 /// such repository" is false when it is private; "This repository is private"
1380 /// leaks.
1381 ///
1382 /// None of these names which allowance ran out, either. A limit that reports
1383 /// its own state is one somebody can pace against, and a vote and a proposal
1384 /// draw on different budgets -- so a sentence about "submissions" shown to
1385 /// somebody who tapped a vote button twice is wrong as well as leaky.
1386 function saying(a) {
1387 if (!a) return tOr('social.err_offline', 'Nothing could be sent just now.');
1388 switch (a.why) {
1389 case 'absent':
1390 return tOr('social.err_absent', 'This repository is not available to you.');
1391 case 'unvoiced':
1392 return tOr('social.err_unvoiced', 'The forge was given no voice, so it refused.');
1393 case 'unknown':
1394 return tOr('social.err_unknown', 'The forge does not recognise your voice. Set it again from the line the forge printed for you.');
1395 case 'unpermitted':
1396 // The one route where this refusal has a single cause worth naming. The
1397 // forge refuses a stranger's revision with `unpermitted` and writes
1398 // nothing, so the honest sentence is who may, not that somebody may not.
1399 if (a.on === 'amend') {
1400 return tOr('social.err_amend_unpermitted',
1401 'Only the person who opened this proposal may revise it.');
1402 }
1403 return tOr('social.err_unpermitted', 'Your voice may not do that here.');
1404 case 'throttled':
1405 if (a.because === 'address') {
1406 return tOr('social.err_throttled_address', 'Too many requests from this address just now. Wait a little, then try again.');
1407 }
1408 if (a.because === 'failing') {
1409 return tOr('social.err_throttled_failing', 'Too many failing requests just now. Wait a little, then try again.');
1410 }
1411 return tOr('social.err_throttled', 'Too many requests just now. Wait a little, then try again.');
1412 case 'malformed':
1413 return tOr('social.err_malformed', 'The forge could not read what Daimond asked it. That is a fault in Daimond, not in what you wrote.');
1414 case 'no_proposal':
1415 return tOr('social.err_no_proposal', 'There is no such proposal here.');
1416 case 'unsupported':
1417 return tOr('social.err_unsupported', 'The forge does not answer that.');
1418 case 'internal':
1419 return tOr('social.err_internal', 'Something went wrong at the forge. This is not your fault.');
1420 case 'gateway':
1421 if (a.status === 401) {
1422 return tOr('social.err_session', 'Daimond is not signed in just now, so it could not reach the forge.');
1423 }
1424 if (a.status === 413) {
1425 return tOr('social.err_toolong', 'That is longer than the forge accepts. Shorten it, or send it in two.');
1426 }
1427 return tOr('social.err_gateway', 'Daimond could not reach the forge just now.');
1428 default:
1429 return a.said || tOr('social.err_offline', 'Nothing could be sent just now.');
1430 }
1431 }
1432
1433 // ── Proposals ──────────────────────────────────────────────
1434 //
1435 // Read from the forge as the panel is opened, NEWEST FIRST. `from` is a
1436 // numeric CEILING that counts DOWN and not an offset -- an offset slides as
1437 // proposals arrive, so a client paging through a growing list silently skips
1438 // or repeats records.
1439 //
1440 // THE WALK'S TERMINATION IS THE SUBTLE PART, and it is why `from=0` is never
1441 // sent from here. There is NO value of `from` that says "nothing below this":
1442 // zero is not a proposal number, so a forge either refuses it or reads it as
1443 // "no ceiling", which is BACK TO THE NEWEST. A client that pages by asking
1444 // for `lowest - 1` therefore wraps to the start and loops for ever the moment
1445 // it reaches proposal 1, with every answer along the way looking perfectly
1446 // valid and nothing anywhere reporting a fault. So the walk ends on a page
1447 // SHORTER than the limit it asked for, on proposal 1, or on a page that did
1448 // not descend -- and the last of those three holds even if the other two are
1449 // wrong, which is why it is there.
1450
1451 var _by = {}; // proposal number -> the record, listing or detail
1452 var _order = []; // the numbers, in the order they are drawn
1453 var _open = {}; // which rows are open, so a redraw does not shut them
1454 var _list = {
1455 total: 0,
1456 lowest: null, // the lowest number drawn so far, the ceiling counts down from
1457 done: false, // the walk has ended and there is nothing below
1458 loading: false,
1459 err: null, // the last refusal, drawn under the list until it is gone
1460 read: false, // whether the listing has ever been read
1461 };
1462
1463 /// The four states a proposal takes on the forge. The vocabulary is closed:
1464 /// anything else is drawn as open rather than as a state nobody has a word
1465 /// for.
1466 var STATES = { open: 1, accepted: 1, declined: 1, done: 1 };
1467
1468 /// What the forge calls its per-asker "you may amend this" flag.
1469 ///
1470 /// SETTLED, AND READ OFF THE DEPLOYED FORGE rather than guessed. Two lanes
1471 /// built this half against a forge that did not answer the flag yet, and each
1472 /// picked its own spelling -- `amend` and `may_amend` -- because a name nobody
1473 /// has published is a name everybody invents. Neither was right. The forge
1474 /// answers `mine_to_amend`, carried in `shared_dat` so it sits on the listing
1475 /// entry and on the whole proposal alike, and an unvoiced read of
1476 /// `/oxedyne/ore/proposals/20?format=json` carries no such key at all --
1477 /// confirmed against the deployed host on 2026-08-28.
1478 //
1479 // It is spelled beside `mine`, which is what it is a second of: `mine` is this
1480 // asker's vote and `mine_to_amend` is whether this proposal is this asker's to
1481 // revise. The gateway's own words are DIFFERENT WORDS on purpose -- `&amend=1`
1482 // in the query, `/amend` as the forge's route segment -- because those name the
1483 // ACT and this names a fact about the record.
1484 //
1485 // Held once so that the next rename is one line here rather than a search. The
1486 // fixtures do not get their own copy: dev/verify_triage.mjs takes the spelling
1487 // from this file, because a fixture answering the old key would keep a broken
1488 // client green.
1489 var AMEND_FLAG = 'mine_to_amend';
1490
1491 /// One proposal as it arrived, defended against a shape this build does not
1492 /// know.
1493 ///
1494 /// `mine` is the delicate one. Contract §9: it is `1`, `-1` or `null` when a
1495 /// voice was sent and ABSENT ENTIRELY when none was, so that "I have not
1496 /// voted" and "I was not asked" cannot be confused. `asked` carries that
1497 /// distinction here, because `undefined` and `null` are the same thing to
1498 /// anything that round-trips this record through JSON.
1499 function cleanProp(p) {
1500 if (!p || typeof p !== 'object') return null;
1501 var n = whole(p.number);
1502 if (n < 1) return null;
1503 var rec = {
1504 n: n,
1505 title: (typeof p.title === 'string') ? p.title : '',
1506 state: STATES[p.state] ? p.state : 'open',
1507 author: (typeof p.author === 'string') ? p.author : '',
1508 comments: Math.max(0, whole(p.comments)),
1509 opened: Math.max(0, whole(p.opened)),
1510 // PRESENCE FIRST, NUMBER SECOND, and null where the answer was silent.
1511 // `whole()` would have turned an absent `changed` into 0, and a 0 here
1512 // is a real reading: "revised at the epoch", which is older than every
1513 // proposal there is. A panel that compared it would find either every
1514 // tile stale for ever or none of them ever, and both look exactly like a
1515 // cache that is not being invalidated -- which is the week somebody
1516 // spends before finding this line. Same rule as `mine`/`asked` and as
1517 // `mine_to_amend`/`askedAmend` below.
1518 changed: (typeof p.changed === 'number') ? Math.max(0, whole(p.changed)) : null,
1519 mark: (typeof p.mark === 'string') ? p.mark : '',
1520 build: (typeof p.build === 'string') ? p.build : '',
1521 body: (typeof p.body === 'string') ? p.body : '',
1522 discussion: null,
1523 // NULL WHERE THE ANSWER CARRIED NONE, and `[]` where it carried an empty
1524 // one, which are different facts: the listing does not answer this field
1525 // at all and the whole proposal always does, so `[]` means "read, and
1526 // never revised" and `null` means "not read yet". `absorb` keeps the
1527 // list across a listing record for the reason it keeps the body.
1528 revisions: null,
1529 detail: false,
1530 votes: null, // null: the answer carried no tally at all
1531 asked: false, // whether the answer carried `mine` at all
1532 mine: null,
1533 askedAmend: false, // whether the answer carried the amend flag at all
1534 amendable: false, // and, if it did, whether this asker may amend
1535 };
1536 if (Array.isArray(p.discussion)) {
1537 rec.detail = true;
1538 rec.discussion = p.discussion.map(function (d) {
1539 return {
1540 author: (d && typeof d.author === 'string') ? d.author : '',
1541 // `said` on a discussion entry is what a person wrote, which is
1542 // not the `said` of a refusal. One word, two contracts.
1543 said: (d && typeof d.said === 'string') ? d.said : '',
1544 when: Math.max(0, whole(d && d.when)),
1545 };
1546 });
1547 }
1548 // A LIST, NOT A COUNT, and that is the whole of the care this needs. `comments`
1549 // beside it IS a count on both routes, so a reader working by analogy reaches
1550 // for `whole()` and gets `NaN` from an array -- or worse, a length that looks
1551 // like an answer. The forge answers `revisions` on the WHOLE PROPOSAL ONLY,
1552 // oldest first, EMPTY rather than absent where nothing has been amended.
1553 if (Array.isArray(p.revisions)) {
1554 rec.revisions = p.revisions.map(function (r) {
1555 return {
1556 title: (r && typeof r.title === 'string') ? r.title : '',
1557 body: (r && typeof r.body === 'string') ? r.body : '',
1558 when: Math.max(0, whole(r && r.when)),
1559 };
1560 });
1561 }
1562 if (typeof p.body === 'string') rec.detail = true;
1563 if (p.votes && typeof p.votes === 'object' && !Array.isArray(p.votes)) {
1564 rec.votes = {
1565 for: Math.max(0, whole(p.votes.for)),
1566 against: Math.max(0, whole(p.votes.against)),
1567 };
1568 }
1569 if (Object.prototype.hasOwnProperty.call(p, 'mine')) {
1570 rec.asked = true;
1571 rec.mine = (p.mine === 1 || p.mine === -1) ? p.mine : null;
1572 }
1573 // PRESENCE FIRST, BOOLEAN SECOND, and the two are kept apart for the same
1574 // reason `mine` and `asked` are. The flag is ABSENT when no voice asked --
1575 // not `false` -- so a reader that tested only its truth would draw "you
1576 // may not amend this" at a person who was never asked, and would go on
1577 // drawing it after they set a voice. `hasOwnProperty`, so a `false` that
1578 // really was answered is still an answer.
1579 //
1580 // NOTHING ABOVE COERCES IT. Every other field on this record has a
1581 // defaulting cast, which is right for a value whose absence means
1582 // nothing; it is wrong for one whose absence IS the fact.
1583 if (Object.prototype.hasOwnProperty.call(p, AMEND_FLAG)) {
1584 rec.askedAmend = true;
1585 rec.amendable = (p[AMEND_FLAG] === true);
1586 }
1587 return rec;
1588 }
1589
1590 /// May this asker revise that proposal?
1591 ///
1592 /// ONE PREDICATE FOR THE THREE PLACES THAT ASK, because the two halves have to
1593 /// be read together and any reader that forgets one of them is wrong in a way
1594 /// nothing shows: `amendable` alone offers the control to a proposal nobody was
1595 /// asked about, and `askedAmend` alone offers it to a voice that was told no.
1596 /// The exported `forge.mayAmend` is this same question by proposal number.
1597 function canAmend(rec) {
1598 return !!(rec && rec.askedAmend && rec.amendable);
1599 }
1600
1601 /// Take one record in, keeping what a listing does not carry.
1602 ///
1603 /// A listing record has no body and no discussion, so absorbing one over a
1604 /// detail that has already been read must not wipe them -- otherwise opening
1605 /// a proposal and then paging would empty it.
1606 function absorb(rec) {
1607 if (!rec) return null;
1608 var cur = _by[rec.n];
1609 if (cur && !rec.detail) {
1610 rec.body = cur.body;
1611 rec.discussion = cur.discussion;
1612 rec.revisions = cur.revisions;
1613 rec.detail = cur.detail;
1614 }
1615 if (!cur) _order.push(rec.n);
1616 _by[rec.n] = rec;
1617 return rec;
1618 }
1619
1620 /// Read a page of the listing. `more` walks downwards from what is drawn.
1621 async function loadList(more) {
1622 if (_list.loading) return false;
1623 if (more && _list.done) return false;
1624 var extra = 'limit=' + PAGE;
1625 if (more) {
1626 // Never `from=0`. See the note above: there is no value of `from` that
1627 // says "nothing below this", and zero means back to the newest.
1628 if (!(_list.lowest > 1)) { _list.done = true; drawProps(); return false; }
1629 extra += '&from=' + (_list.lowest - 1);
1630 }
1631 _list.loading = true;
1632 _list.err = null;
1633 drawProps();
1634 var a = await ask(route(extra), { method: 'GET' });
1635 _list.loading = false;
1636 if (!a.ok) { _list.err = a; drawProps(); return false; }
1637
1638 if (!more) { _by = {}; _order = []; _list.lowest = null; _list.done = false; }
1639 var raw = Array.isArray(a.data.proposals) ? a.data.proposals : [];
1640 var got = 0, lowest = null;
1641 raw.forEach(function (p) {
1642 var rec = cleanProp(p);
1643 if (!rec) return;
1644 got++;
1645 if (lowest === null || rec.n < lowest) lowest = rec.n;
1646 absorb(rec);
1647 });
1648 // `total` is the count AFTER `state` and BEFORE `from` and `limit`, so it
1649 // is how "no more" is told from "more to come".
1650 _list.total = Math.max(0, whole(a.data.total));
1651 _list.read = true;
1652
1653 // A short page is the end of the walk.
1654 if (got < PAGE) _list.done = true;
1655 // So is proposal 1: there is nothing below it to ask for.
1656 if (lowest === null || lowest <= 1) _list.done = true;
1657 // AND SO IS A PAGE THAT DID NOT DESCEND. This is the belt: a forge that
1658 // read `from` as a lower bound, or aliased a value of it back to the
1659 // newest, would hand the same page again for ever and every answer would
1660 // look valid. Stopping the moment the walk stops descending turns that
1661 // into a list that ends rather than a tab that never settles.
1662 if (more && lowest !== null && _list.lowest !== null && lowest >= _list.lowest) {
1663 _list.done = true;
1664 }
1665 if (lowest !== null && (_list.lowest === null || lowest < _list.lowest)) _list.lowest = lowest;
1666 drawProps();
1667 return true;
1668 }
1669
1670 /// Read one proposal in full, for its body and its discussion.
1671 async function loadOne(n) {
1672 var a = await ask(route('n=' + n), { method: 'GET' });
1673 if (!a.ok) { _list.err = a; drawProps(); return false; }
1674 absorb(cleanProp(a.data));
1675 drawProps();
1676 return true;
1677 }
1678
1679 // ── Voting ─────────────────────────────────────────────────
1680 //
1681 // Contract §9: one store of truth, and it is the forge. Nothing is queued
1682 // here and nothing is kept here, because a tally in two places is a tally
1683 // that disagrees with itself. Every write answers with the DETAIL SHAPE of
1684 // the record it changed, so a vote's answer carries the new tally and the
1685 // caller's own `mine` and the control is redrawn from it -- never from a
1686 // second request, and never from a guess about what the press must have done.
1687
1688 /// A vote's whole body: one field, one of three values, and nothing else.
1689 ///
1690 /// Form-encoded, like every other write on this surface. Written as a
1691 /// function that returns the exact characters rather than assembled at the
1692 /// call site, so an edit that wants to send a fifth thing has to defeat this
1693 /// rather than merely forget it -- the same discipline the old integers-only
1694 /// gate kept, in the shape the contract now asks for.
1695 ///
1696 /// A vote with no `d` at all is `malformed` at the forge and NOT a
1697 /// withdrawal, which is right: reading a lost field as an instruction to
1698 /// delete turns a dropped parameter into silent data loss. So there is no
1699 /// path here that sends one.
1700 function voteBody(d) {
1701 if (d !== 1 && d !== -1 && d !== 0) return '';
1702 return 'd=' + d;
1703 }
1704
1705 /// Cast, move, or take back a vote.
1706 ///
1707 /// Pressing the side you already chose withdraws it, which is the only way
1708 /// back and is what a pressed control that stays pressed has to offer.
1709 async function vote(n, dir) {
1710 var rec = _by[n];
1711 if (!rec || !rec.votes || !rec.asked) return false;
1712 var want = (dir === 'do') ? 1 : -1;
1713 var body = voteBody(rec.mine === want ? 0 : want);
1714 if (!body) return false;
1715 var a = await ask(route('n=' + n + '&vote=1'), {
1716 method: 'POST',
1717 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1718 body: body,
1719 });
1720 if (!a.ok) { _list.err = a; drawProps(); return false; }
1721 absorb(cleanProp(a.data));
1722 _list.err = null;
1723 drawProps();
1724 return true;
1725 }
1726
1727 /// Put one vote on the wire, and hand the forge's answer straight back. THE
1728 /// DOOR the Improve hub's card (js/tracker.js) sends a vote through, so the
1729 /// board holds neither this POST nor the pull voice it rides -- it draws its own
1730 /// row from the answer, which is the detail shape of the record it changed. `d`
1731 /// is 1, -1 or 0; anything else is refused here rather than sent, so a caller
1732 /// cannot turn a dropped value into a stray withdrawal.
1733 ///
1734 /// Kept apart from `vote` above, which draws THIS panel's own list and is where
1735 /// dev/verify_improve.mjs anchors its vote breaks: this one only puts the request
1736 /// on the wire, the same shape `say` and `post` do for a comment and a proposal.
1737 async function voteWire(n, d) {
1738 var body = voteBody(d);
1739 if (!body) return { ok: false, why: 'malformed' };
1740 return await ask(route('n=' + n + '&vote=1'), {
1741 method: 'POST',
1742 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1743 body: body,
1744 });
1745 }
1746
1747 // ── Revising one's own proposal ────────────────────────────
1748 //
1749 // THE SECOND DOOR ON THE SAME SEAM, and it was built dark for the reason the
1750 // vote control was: `drawAmendControl` draws only when the answer carried the
1751 // flag. The forge answers it now, and NOTHING IN THIS FILE CHANGED WHEN IT DID
1752 // -- which is the second time that shape has paid off here, and the argument for
1753 // gating a control on the answer rather than on a comment about a date.
1754 //
1755 // TWO QUESTIONS, KEPT APART. The forge's flag answers ONE thing -- is this
1756 // asker the person who opened the proposal -- and deliberately does not fold in
1757 // whether the proposal is still open. That second half is a UI opinion, it is
1758 // reversible, and this panel already holds `state`; asking the forge to answer
1759 // the combined question would entangle a decision anybody may change with a
1760 // fold rule that is replicated and cannot be. So the test is written as two:
1761 // `canAmend(rec) && rec.state === 'open'`.
1762
1763 var _amending = {}; // proposal number -> the row is in amend mode
1764
1765 /// Cut a proposal's own characters back into the two boxes an amendment is.
1766 ///
1767 /// The inverse of `split()` and the same rule: what goes back on the wire is
1768 /// what is on the screen, so the boxes must open holding exactly what the
1769 /// record holds and nothing composed on the way in.
1770 function amendBoxes(n) {
1771 var row = document.querySelector('.imp-prop[data-prop="' + n + '"]');
1772 if (!row) return null;
1773 var title = row.querySelector('.imp-amend-title');
1774 var body = row.querySelector('.imp-amend-body');
1775 if (!title || !body) return null;
1776 return { title: title, body: body };
1777 }
1778
1779 /// Publish a revision of one's own proposal.
1780 ///
1781 /// The same one rule as a note and a comment: what leaves is exactly the
1782 /// characters in those two boxes at the moment the button is pressed, and a
1783 /// refusal is SAID rather than queued -- the boxes keep their words so a person
1784 /// can read and copy them.
1785 ///
1786 /// The field set is `title` and `body`, and that is the whole of it. Asserted
1787 /// rather than intended, for the reason `post` states: a fifth field must have
1788 /// to defeat a check.
1789 async function amend(n) {
1790 var rec = _by[n];
1791 if (!canAmend(rec)) return false;
1792 var box = amendBoxes(n);
1793 if (!box) return false;
1794 var title = String(box.title.value || '').trim();
1795 var body = String(box.body.value || '');
1796 if (!title) { _list.err = null; flash(tOr('social.nothing', 'Write something first.')); return false; }
1797 if (!hasVoice()) { _list.err = { why: 'unvoiced' }; drawProps(); return false; }
1798 // THROUGH THE SAME DOOR THE TRIAGE PLAN USES. Two callers with their own idea
1799 // of the request shape is how two halves of a feature stop meeting, which is
1800 // what §0 of the contract records -- and this file had exactly that, twice
1801 // over, until the two `amend`s were found colliding.
1802 var a = await revise(n, { title: title, body: body });
1803 if (!a.ok) { _list.err = a; drawProps(); return false; }
1804 delete _amending[String(n)];
1805 try { if (drafts()) drafts().dropUnder(draftKey('amend', n)); } catch (e) { /* storage blocked */ }
1806 absorb(cleanProp(a.data));
1807 _list.err = null;
1808 drawProps();
1809 return true;
1810 }
1811
1812 // ── Saying something on a proposal ─────────────────────────
1813
1814 /// Add one comment to a proposal.
1815 ///
1816 /// The same one rule as a note: what leaves is exactly the characters in that
1817 /// one box, at the moment the button beside it is pressed, and a failure is
1818 /// said rather than queued. The box is emptied only when the forge took it,
1819 /// so a refusal leaves the words where the person can still read and copy
1820 /// them.
1821 async function comment(n) {
1822 var box = document.querySelector('.imp-prop[data-prop="' + n + '"] .imp-reply');
1823 if (!box) return false;
1824 var text = String(box.value || '').trim();
1825 if (!text) { _list.err = null; flash(tOr('social.nothing', 'Write something first.')); return false; }
1826 if (!hasVoice()) { _list.err = { why: 'unvoiced' }; drawProps(); return false; }
1827 var a = await say(n, text);
1828 if (!a.ok) { _list.err = a; drawProps(); return false; }
1829 box.value = '';
1830 try { if (drafts()) drafts().drop(draftKey('reply', n)); } catch (e) { /* storage blocked */ }
1831 absorb(cleanProp(a.data));
1832 _list.err = null;
1833 drawProps();
1834 return true;
1835 }
1836
1837 /// Put one comment on the wire. THE ONE DOOR a comment leaves by, whichever
1838 /// box it was read out of -- the reply box under an open proposal, or a
1839 /// drafted comment in the Notes view. A second caller with its own idea of
1840 /// the request shape is how two halves of a feature stop meeting, which is
1841 /// the fault §0 of the contract exists to record.
1842 ///
1843 /// It takes the CHARACTERS and never the element: what leaves is decided by
1844 /// whoever read the screen, and that reading happens once, at the press.
1845 async function say(n, text) {
1846 var f = new URLSearchParams();
1847 f.set('said', text);
1848 return await ask(route('n=' + n), {
1849 method: 'POST',
1850 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1851 body: f.toString(),
1852 });
1853 }
1854
1855 /// Put a revision of one proposal on the wire. THE ONE DOOR a revision leaves
1856 /// by, whichever screen read it -- the boxes under an open proposal, or a
1857 /// revision draft in the triage plan. `post` and `say` are its siblings and it
1858 /// is named apart from them for the same reason they are named apart from
1859 /// `send` and `comment`: a door takes CHARACTERS somebody else read, and the
1860 /// panel action that read them is `amend` below.
1861 ///
1862 /// IT WAS CALLED `amend` AND THAT WAS A DEFECT. Two lanes built the two halves
1863 /// of this seam a fortnight apart, each declared `async function amend` in this
1864 /// one closure, and a function declaration does not collide -- it wins. The
1865 /// later one silently replaced the earlier, so pressing "Publish the revision"
1866 /// called this with no `parts` at all and threw on the first line. Nothing in
1867 /// either lane's own tests could see it, because each half was right.
1868 ///
1869 /// The fields are a proposal's own: a revision restates the proposal, so it
1870 /// carries what opening one carries and the same cut applies -- `title` is the
1871 /// characters before the first newline, `body` the characters after it.
1872 async function revise(n, parts) {
1873 var f = new URLSearchParams();
1874 f.set('title', parts.title);
1875 f.set('body', parts.body);
1876 var a = await ask(route('n=' + n + '&amend=1'), {
1877 method: 'POST',
1878 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1879 body: f.toString(),
1880 });
1881 // A REFUSAL IS NAMED WHERE IT HAPPENED, AT THE DOOR, so both callers get the
1882 // sentence without either of them remembering to ask for it. `unpermitted` on
1883 // this route has one cause and only one -- somebody else opened this proposal
1884 // -- and `saying()` cannot know that from the token alone. Untagged it reads
1885 // "Your voice may not do that here", which is true of nine routes and useless
1886 // on this one.
1887 return a.ok ? a : onAmend(a);
1888 }
1889
1890 // ── Drawing ────────────────────────────────────────────────
1891
1892 /// The day something happened, in the language the APP is in — not the
1893 /// browser's. trash.js records why.
1894 function fmtDate(at) {
1895 var loc;
1896 try { loc = window.DaimondI18n ? DaimondI18n.locale() : undefined; }
1897 catch (e) { loc = undefined; }
1898 try { return new Date(at).toLocaleDateString(loc || undefined, { day: 'numeric', month: 'short' }); }
1899 catch (e) { return ''; }
1900 }
1901
1902 /// A forge stamp is in seconds; everything else here is in milliseconds.
1903 function fmtWhen(secs) { return secs ? fmtDate(secs * 1000) : ''; }
1904
1905 function button(cls, act, text, title) {
1906 var b = document.createElement('button');
1907 b.type = 'button';
1908 b.className = cls;
1909 if (act) b.dataset.act = act;
1910 b.textContent = text;
1911 if (title) b.title = title;
1912 return b;
1913 }
1914
1915 /// One line of text in a `div`, its text set as textContent so nothing here is
1916 /// ever markup. The queue head and the Settings sections are drawn with it.
1917 function line(cls, text) {
1918 var d = document.createElement('div');
1919 d.className = cls;
1920 d.textContent = text;
1921 return d;
1922 }
1923
1924 /// The queue: notes that could not be sent yet, newest first. Empty when there
1925 /// is nothing waiting, so the compose box sits straight above the proposals in
1926 /// the ordinary case; it fills only when a send did not go -- offline, or a
1927 /// forge that refused. Each row shows the words, how it will be sent, and offers
1928 /// a Send-now and a Delete. The reconnect flush drains it on its own.
1929 function drawQueue() {
1930 var host = el('improve-queue');
1931 if (!host) return;
1932 var q = load().notes.slice();
1933 host.innerHTML = '';
1934 if (!q.length) return;
1935
1936 host.appendChild(line('imp-asat imp-queue-head', tnOr('social.queue', q.length,
1937 'Waiting to send ({n})', 'Waiting to send ({n})', { n: q.length })));
1938
1939 q.forEach(function (n) {
1940 var row = document.createElement('div');
1941 row.className = 'imp-note imp-queue-row';
1942 row.dataset.note = n.id;
1943
1944 var text = document.createElement('div');
1945 text.className = 'imp-note-text';
1946 text.textContent = n.text;
1947 row.appendChild(text);
1948
1949 var foot = document.createElement('div');
1950 foot.className = 'imp-note-foot';
1951
1952 var state = document.createElement('span');
1953 state.className = 'imp-note-state';
1954 state.dataset.state = 'waiting';
1955 state.textContent = (n.mode === 'polish')
1956 ? tOr('social.q_polish', 'Waiting to polish and post')
1957 : tOr('social.q_verbatim', 'Waiting to post');
1958 foot.appendChild(state);
1959
1960 if (hasVoice()) {
1961 foot.appendChild(button('imp-note-send', 'improve-resend',
1962 tOr('social.send_now', 'Send now'),
1963 tOr('social.send_now_help', 'Try to send this one now, in the way it was written.')));
1964 }
1965 foot.appendChild(button('imp-note-copy', 'improve-copy', t('common.copy'), t('common.copy')));
1966
1967 try {
1968 foot.appendChild(DaimondCloser.make({
1969 name: tOr('social.drop', 'Delete this note'),
1970 cls: 'imp-note-drop',
1971 onClose: function () { drop(n.id); },
1972 }));
1973 } catch (e) {
1974 foot.appendChild(button('imp-note-drop', 'improve-drop', '×', tOr('social.drop', 'Delete this note')));
1975 }
1976
1977 row.appendChild(foot);
1978 host.appendChild(row);
1979 });
1980 }
1981
1982 /// The word a state is read as. The forge's vocabulary is
1983 /// open/accepted/declined/done; the guide's words are Open, Being done, Done
1984 /// and Declined, and those are what a reader has been promised.
1985 ///
1986 /// `accepted` reads through `social.state_taken`, which is the key those
1987 /// eight locales already hold "Being done" in. The key's name is older than
1988 /// the forge's word and renaming it would throw eight translations away to
1989 /// tidy a string nobody sees.
1990 function stateWord(s) {
1991 if (s === 'accepted') return tOr('social.state_taken', 'Being done');
1992 if (s === 'done') return tOr('social.state_done', 'Done');
1993 if (s === 'declined') return tOr('social.state_declined', 'Declined');
1994 return tOr('social.state_open', 'Open');
1995 }
1996
1997 /// The vote control, which draws ONLY when the answer carried a tally.
1998 ///
1999 /// DARK UNTIL THE ANSWER CARRIES A TALLY, which it now does. The forge dispatches
2000 /// `proposals/<n>/vote` and both the listing and the whole record carry `votes`,
2001 /// checked against the deployed host on 2026-08-27 -- so this control draws, and it
2002 /// draws BECAUSE THE ANSWER CARRIES `votes` rather than because a date arrived.
2003 /// Nothing here changed when the forge started answering, which is the whole point
2004 /// of gating a control on the answer. This comment said "the vote route is not built
2005 /// there yet" until 2026-08-28: it was the FOURTH site of the claim the file header
2006 /// records correcting three of, and it was missed because it reads as a note about
2007 /// the branch below rather than as a claim about the forge.
2008 ///
2009 /// The branch below is still right and still cheap. A record with no `votes` key at
2010 /// all draws nothing -- not a disabled button, not a zero.
2011 ///
2012 /// This is NOT the test §9 warns a client off. That warning is against
2013 /// treating an UNVOTED proposal as one with no tally: `votes` on a proposal
2014 /// nobody has voted on is `{"for":0,"against":0}`, the zero object, and it is
2015 /// drawn like any other. What is tested here is the key's ABSENCE, which
2016 /// under §9 cannot happen at all -- so the branch is unreachable against a
2017 /// conforming forge and costs nothing against one that stops being conforming.
2018 ///
2019 /// `mine` ABSENT and `mine` NULL are different and are drawn differently: the
2020 /// first says the request carried no voice, so the buttons are not offered at
2021 /// all and a line says why; the second says a voice asked and has not voted,
2022 /// which is two buttons with neither pressed. A control that drew those the
2023 /// same way would show an unvoted button to somebody who cannot vote.
2024 function drawVoteControl(p, into) {
2025 if (!p.votes) return;
2026 var box = document.createElement('div');
2027 box.className = 'imp-votes';
2028
2029 var tally = document.createElement('span');
2030 tally.className = 'imp-prop-tally';
2031 tally.textContent = tOr('social.tally', '{yes} for, {no} against',
2032 { yes: p.votes.for, no: p.votes.against });
2033 box.appendChild(tally);
2034
2035 if (!p.asked) {
2036 var line = document.createElement('span');
2037 line.className = 'imp-as';
2038 line.textContent = tOr('social.vote_novoice', 'Set a voice to vote on this.');
2039 box.appendChild(line);
2040 into.appendChild(box);
2041 return;
2042 }
2043 [['do', 1, tOr('social.do', 'Do this')], ['not', -1, tOr('social.not', 'Not this')]]
2044 .forEach(function (pair) {
2045 var b = button('imp-vote', 'improve-vote', pair[2], pair[2]);
2046 b.dataset.dir = pair[0];
2047 if (p.mine === pair[1]) {
2048 b.classList.add('on');
2049 b.setAttribute('aria-pressed', 'true');
2050 b.title = tOr('social.vote_off', 'Press again to take your vote back off.');
2051 } else {
2052 b.setAttribute('aria-pressed', 'false');
2053 }
2054 box.appendChild(b);
2055 });
2056 into.appendChild(box);
2057 }
2058
2059 /// The amend control, which draws ONLY when the answer carried the flag.
2060 ///
2061 /// DARK UNTIL THE FORGE ANSWERS, exactly as the vote control was until it did.
2062 /// The test is `=== true` and never `!p.amendable`, and the difference is the
2063 /// whole reason the forge leaves the field OUT rather than answering `false`:
2064 /// absent means no voice asked, `false` means a voice asked and is not the
2065 /// author, and a reader with no voice at all must never be shown a control that
2066 /// belongs to somebody. `null` cannot be read as an answer; a coerced `false`
2067 /// can, and would be the same bug wearing the right shape.
2068 ///
2069 /// A SETTLED PROPOSAL IS THIS SIDE'S JUDGEMENT and is asked separately. The
2070 /// forge answers who the author is, which is a replicated fact; whether a
2071 /// declined proposal may still be revised is an opinion about a screen, and
2072 /// folding the two into one flag would put a reversible decision inside a fold
2073 /// rule that cannot be reversed.
2074 function drawAmendControl(p, into) {
2075 if (!canAmend(p)) return;
2076 if (p.state !== 'open') return;
2077 if (!_amending[String(p.n)]) {
2078 var acts = document.createElement('div');
2079 acts.className = 'imp-acts';
2080 acts.appendChild(button('imp-note-copy', 'improve-amend-open',
2081 tOr('social.amend', 'Revise this'),
2082 tOr('social.amend_help', 'Replace what this proposal says; everyone sees the new words.')));
2083 into.appendChild(acts);
2084 return;
2085 }
2086 var title = document.createElement('input');
2087 title.type = 'text';
2088 title.className = 'imp-box imp-amend-title';
2089 title.value = p.title;
2090 title.setAttribute('aria-label', tOr('social.amend_title_ph', 'The one line this proposal is about'));
2091 title.placeholder = tOr('social.amend_title_ph', 'The one line this proposal is about');
2092 into.appendChild(title);
2093 try { if (drafts()) drafts().bind(title, draftKey('amend', p.n, 'title')); } catch (e) { /* storage blocked */ }
2094
2095 var body = document.createElement('textarea');
2096 body.className = 'imp-box imp-amend-body';
2097 body.rows = 4;
2098 body.value = p.body;
2099 body.setAttribute('aria-label', tOr('social.amend_body_ph', 'What happened, and what was expected instead'));
2100 body.placeholder = tOr('social.amend_body_ph', 'What happened, and what was expected instead');
2101 into.appendChild(body);
2102 try { if (drafts()) drafts().bind(body, draftKey('amend', p.n, 'body')); } catch (e) { /* storage blocked */ }
2103
2104 var row = document.createElement('div');
2105 row.className = 'imp-acts';
2106 row.appendChild(button('imp-send', 'improve-amend-save',
2107 tOr('social.amend_save', 'Publish the revision'),
2108 tOr('social.amend_save_help', 'Sends exactly these two boxes. Nothing else.')));
2109 row.appendChild(button('imp-keep', 'improve-amend-cancel', t('common.cancel')));
2110 into.appendChild(row);
2111 }
2112
2113 /// One proposal, as a row that opens what it names: a coloured dot, the
2114 /// title, and the tally as its value — the admin panel's shape exactly,
2115 /// which is what the guide's "row" entry describes.
2116 function drawProps() {
2117 var list = el('improve-props'), asAt = el('improve-asat');
2118 if (!list) return;
2119 // WHAT SOMEBODY IS HALF-WAY THROUGH TYPING SURVIVES THE REDRAW, for the
2120 // same reason an open row does: a vote or a language change redraws this
2121 // whole list, and a reply box emptied by it would take the words with it.
2122 var typed = {};
2123 list.querySelectorAll('.imp-prop').forEach(function (r) {
2124 var box = r.querySelector('.imp-reply');
2125 if (box && box.value) typed[r.dataset.prop] = box.value;
2126 });
2127 list.innerHTML = '';
2128
2129 if (asAt) {
2130 // WHAT THIS PANEL WILL NEVER DO, said on the surface rather than in a
2131 // help page. Contract §7: there is no change feed, so a tester is never
2132 // TOLD their proposal was answered -- they find out by looking. A panel
2133 // that implied otherwise, with a badge or an unread count it cannot
2134 // honour, would be promising something nothing behind it can deliver.
2135 asAt.textContent = tOr('social.live_note',
2136 'These are read from the forge as you look at them. Nothing tells you when a proposal is answered; look again to find out.');
2137 }
2138
2139 if (_list.err) {
2140 var err = document.createElement('div');
2141 err.className = 'rail-note imp-err';
2142 err.dataset.why = _list.err.why || '';
2143 err.textContent = saying(_list.err);
2144 list.appendChild(err);
2145 }
2146
2147 if (!_order.length) {
2148 var none = document.createElement('div');
2149 none.className = 'rail-note';
2150 none.textContent = _list.loading
2151 ? tOr('social.loading', 'Reading the proposals…')
2152 // NOT `social.no_props`, whose English in the catalogue says
2153 // proposals "arrive with a new build". They do not any more: they
2154 // arrive when somebody opens one, and a translated sentence that is
2155 // now false is worse than an English one that is true.
2156 : (_list.err
2157 ? tOr('social.none_shown', 'Nothing could be read just now.')
2158 : tOr('social.none_yet', 'No proposals here yet. Yours would be the first.'));
2159 list.appendChild(none);
2160 return;
2161 }
2162
2163 _order.forEach(function (n) {
2164 var p = _by[n];
2165 if (!p) return;
2166 var row = document.createElement('div');
2167 row.className = 'imp-prop';
2168 row.dataset.prop = String(p.n);
2169 row.dataset.state = p.state;
2170
2171 var head = document.createElement('button');
2172 head.type = 'button';
2173 head.className = 'imp-prop-row';
2174 head.dataset.act = 'improve-open';
2175
2176 var dot = document.createElement('span');
2177 dot.className = 'imp-dot';
2178 dot.title = stateWord(p.state);
2179 head.appendChild(dot);
2180
2181 var title = document.createElement('span');
2182 title.className = 'imp-prop-title';
2183 title.textContent = p.title;
2184 head.appendChild(title);
2185
2186 // The row's value is the tally, and there is no tally until the forge
2187 // answers one. A row that showed a zero there would be reporting a
2188 // count nothing has taken.
2189 if (p.votes) {
2190 var tally = document.createElement('span');
2191 tally.className = 'imp-prop-tally';
2192 tally.textContent = String(p.votes.for);
2193 tally.title = tOr('social.tally', '{yes} for, {no} against',
2194 { yes: p.votes.for, no: p.votes.against });
2195 head.appendChild(tally);
2196 }
2197 row.appendChild(head);
2198
2199 var body = document.createElement('div');
2200 body.className = 'imp-prop-body';
2201 // WHICH ROWS WERE OPEN SURVIVES THE REDRAW. Casting a vote redraws the
2202 // list, and the first build of this closed the proposal the user was
2203 // reading at the moment they pressed a button on it -- the answer
2204 // vanishing along with the question.
2205 body.hidden = !_open[String(p.n)];
2206 head.setAttribute('aria-expanded', body.hidden ? 'false' : 'true');
2207
2208 var says = document.createElement('p');
2209 says.className = 'imp-prop-says';
2210 says.textContent = p.detail
2211 ? p.body
2212 : tOr('social.reading', 'Reading it…');
2213 body.appendChild(says);
2214
2215 var facts = document.createElement('div');
2216 facts.className = 'imp-prop-facts';
2217 var parts = [stateWord(p.state)];
2218 if (p.author) parts.push(tOr('social.by', 'from {who}', { who: p.author }));
2219 if (p.opened) parts.push(fmtWhen(p.opened));
2220 parts.push(tnOr('social.said_n', p.comments, '{n} reply', '{n} replies', { n: p.comments }));
2221 // AN EMPTY LIST SAYS NOTHING HERE, and that is not the same as saying
2222 // nothing about it: `[]` is the forge's answer that this proposal has
2223 // never been revised, `null` is a listing record that was never asked.
2224 // Drawing "revised 0 times" would be reporting a fact nobody wanted; the
2225 // two silences differ where it matters, in `revisions` itself.
2226 if (p.revisions && p.revisions.length) {
2227 parts.push(tnOr('social.revised_n', p.revisions.length,
2228 'revised once', 'revised {n} times', { n: p.revisions.length }));
2229 }
2230 if (p.build) parts.push(tOr('social.built_on', 'written on build {build}', { build: p.build }));
2231 if (p.mark) parts.push(tOr('social.closed_by', 'closed by mark {mark}', { mark: p.mark }));
2232 facts.textContent = parts.join(' · ');
2233 body.appendChild(facts);
2234
2235 // WHERE A READER MEETS THE CASE, not in a help page. A proposal that
2236 // names a mark is one whose code has moved, and "did my note follow
2237 // it?" is the question a reader has at exactly this moment. Contract §5.
2238 if (p.mark) {
2239 var floor = document.createElement('p');
2240 floor.className = 'imp-prop-says imp-floor';
2241 floor.textContent = tOr('social.move_floor',
2242 'A note follows its content across files only when the change counts as a move — the floor is '
2243 + '64 bytes. Cut less, and the history holds a delete and an insert, so the note reports its '
2244 + 'content deleted.');
2245 body.appendChild(floor);
2246 }
2247
2248 if (p.discussion && p.discussion.length) {
2249 var disc = document.createElement('div');
2250 disc.className = 'imp-disc';
2251 p.discussion.forEach(function (d) {
2252 var one = document.createElement('div');
2253 one.className = 'imp-disc-one';
2254 var who = document.createElement('span');
2255 who.className = 'imp-note-state';
2256 who.textContent = d.author + (d.when ? ' · ' + fmtWhen(d.when) : '');
2257 var said = document.createElement('p');
2258 said.className = 'imp-prop-says';
2259 said.textContent = d.said;
2260 one.appendChild(who);
2261 one.appendChild(said);
2262 disc.appendChild(one);
2263 });
2264 body.appendChild(disc);
2265 }
2266
2267 drawVoteControl(p, body);
2268 // A REVISION CHANGES ITS TILE IN PLACE AND NEVER FLOATS TO THE TOP, and
2269 // nothing here sorts. `_order` is the walk's order, which is the forge's:
2270 // proposal number descending, with `from` a CEILING on the number rather
2271 // than a since-cursor, because an offset does not stay stable while new
2272 // proposals arrive. `changed` orders nothing, filters nothing and pages
2273 // nothing -- the forge never consults it and neither does this.
2274 //
2275 // Anybody minded to add "recently revised first" here should not: a
2276 // re-sort inside a page is right within the page and wrong across pages,
2277 // and the symptom is indistinguishable from a stale cache. It would also
2278 // be the wrong behaviour -- a revision is a correction to something
2279 // already said, not new news, so a triage pass that revised eight
2280 // proposals would bury everything genuinely new underneath them.
2281 drawAmendControl(p, body);
2282
2283 // Saying something back. Offered only with a voice, because the forge
2284 // refuses a comment without one and a box that cannot be sent is a box
2285 // that teaches people to distrust every box.
2286 if (p.detail && hasVoice()) {
2287 var reply = document.createElement('textarea');
2288 reply.className = 'imp-box imp-reply';
2289 reply.rows = 2;
2290 reply.placeholder = tOr('social.reply_ph', 'Say something about this proposal.');
2291 reply.setAttribute('aria-label', tOr('social.reply_ph', 'Say something about this proposal.'));
2292 if (typed[String(p.n)]) reply.value = typed[String(p.n)];
2293 body.appendChild(reply);
2294 // The redraw is already survived by `typed` above. THE RELOAD is what
2295 // this adds, and it is the same words and the same loss: a reply
2296 // three sentences in, and a refresh takes it. Bound after the row is
2297 // in the tree so a restored value is on screen rather than on a node
2298 // nobody has attached.
2299 try { if (drafts()) drafts().bind(reply, draftKey('reply', p.n)); } catch (e) { /* storage blocked */ }
2300 var acts = document.createElement('div');
2301 acts.className = 'imp-acts';
2302 acts.appendChild(button('imp-send', 'improve-comment',
2303 tOr('social.reply', 'Say it'),
2304 tOr('social.reply_help', 'Sends exactly this box. Nothing else.')));
2305 body.appendChild(acts);
2306 }
2307
2308 row.appendChild(body);
2309 list.appendChild(row);
2310 });
2311
2312 // The foot: how many there are, and the one control that walks downwards.
2313 var foot = document.createElement('div');
2314 foot.className = 'rail-note imp-foot';
2315 var count = document.createElement('span');
2316 count.id = 'improve-count';
2317 count.textContent = tnOr('social.count', _list.total,
2318 '{n} proposal', '{n} proposals', { n: _list.total });
2319 foot.appendChild(count);
2320 if (!_list.done) {
2321 foot.appendChild(button('imp-note-copy', 'improve-more',
2322 _list.loading ? tOr('social.loading', 'Reading the proposals…') : tOr('social.more', 'Show older')));
2323 }
2324 list.appendChild(foot);
2325 }
2326
2327 /// The Settings view: the voice this device posts with, and the drafts the
2328 /// model prepared. Both used to sit beside the compose box; the owner moved them
2329 /// out so the box is just a box (#7). `drawVoice` fills the voice section;
2330 /// "Forget this plan" clears anything the model drafted that has not been sent.
2331 function drawSettings() {
2332 var host = el('improve-settings');
2333 if (!host) return;
2334 host.innerHTML = '';
2335
2336 host.appendChild(line('imp-with-label imp-set-head', tOr('social.set_voice', 'Your voice')));
2337 drawVoice();
2338
2339 host.appendChild(line('imp-with-label imp-set-head', tOr('social.set_drafts', 'Prepared drafts')));
2340 host.appendChild(line('imp-as imp-set-note', tOr('social.set_drafts_note',
2341 'Forgets any proposals the model drafted from your notes that you have not sent. '
2342 + 'Your notes waiting to send are not touched.')));
2343 var acts = document.createElement('div');
2344 acts.className = 'imp-acts';
2345 acts.appendChild(button('imp-note-copy', 'improve-forget-plan',
2346 tOr('social.triage_clear', 'Forget this plan'),
2347 tOr('social.triage_clear_help', 'Clear the drafts. Nothing is sent.')));
2348 host.appendChild(acts);
2349 }
2350
2351 // ── The proposer's returned notes (a declined proposal comes back) ──
2352 //
2353 // A proposal this device raised may be DECLINED by an operator, and the decline
2354 // carries a one-line reason back to the proposer. It is the one thing the forge
2355 // tells a proposer without being looked at (contract §7's "look again" holds for
2356 // the proposals list, not for a refusal), because a note that was refused is
2357 // news the writer would not otherwise find. It is read here, in the capture view
2358 // beside the box the note was written in, and cleared per entry once seen.
2359 //
2360 // THE TWO DOORS SIT BEHIND ONE CONSTANT so a path firm-up is a one-line change:
2361 // the GET reads what is waiting, the ACK is the same door with `&ack=1` and a
2362 // FORM body naming the `when` keys the reader has seen. The forge derives the
2363 // caller from the voice, holds that caller's own inbox and TTL-prunes old notes
2364 // -- so this side never sends a name and never deletes whole: it acks the keys
2365 // of the notes it has shown.
2366 var RETURNED = 'returned=1';
2367
2368 var _returned = { notes: [], read: false, loading: false };
2369
2370 /// One returned note off the wire, kept to the three fields it is and no more.
2371 /// A note with no `when` is dropped: the key is what an ack names, so one that
2372 /// could not be acked would sit until the forge's TTL took it.
2373 function cleanReturned(r) {
2374 if (!r || typeof r !== 'object') return null;
2375 var when = whole(r.when);
2376 if (when < 1) return null;
2377 return {
2378 when: when,
2379 title: (typeof r.title === 'string') ? r.title : '',
2380 reason: (typeof r.reason === 'string') ? r.reason : '',
2381 };
2382 }
2383
2384 /// The returned notes an answer carries, cleaned and newest first.
2385 function cleanReturnedList(data) {
2386 var raw = (data && Array.isArray(data.returned)) ? data.returned : [];
2387 var out = [];
2388 raw.forEach(function (r) { var c = cleanReturned(r); if (c) out.push(c); });
2389 out.sort(function (a, b) { return b.when - a.when; });
2390 return out;
2391 }
2392
2393 /// Read the notes a decline left for this proposer. Needs the device's own
2394 /// voice; without one there is no inbox and the call is a no-op. A refusal is
2395 /// quiet -- the inbox is a courtesy beside the box, not the panel itself.
2396 async function loadReturned() {
2397 if (_returned.loading) return false;
2398 if (!hasVoice()) { _returned.notes = []; _returned.read = false; drawReturned(); return false; }
2399 _returned.loading = true;
2400 var a = await ask(route(RETURNED), { method: 'GET' });
2401 _returned.loading = false;
2402 if (!a.ok) { drawReturned(); return false; }
2403 _returned.notes = cleanReturnedList(a.data);
2404 _returned.read = true;
2405 drawReturned();
2406 return true;
2407 }
2408
2409 /// Acknowledge the notes named by their `when` keys: the forge drops them and
2410 /// answers the remainder, which becomes the shown list. THE BODY IS A FORM, not
2411 /// JSON -- `acked=<when>[,<when>…]`, the shape the machine surface reads. Per
2412 /// entry, never delete-whole: only keys the reader has seen are named.
2413 async function ackReturned(whens) {
2414 var keys = (Array.isArray(whens) ? whens : [whens])
2415 .map(function (w) { return whole(w); })
2416 .filter(function (w) { return w > 0; });
2417 if (!keys.length) return false;
2418 var f = new URLSearchParams();
2419 f.set('acked', keys.join(','));
2420 var a = await ask(route(RETURNED + '&ack=1'), {
2421 method: 'POST',
2422 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
2423 body: f.toString(),
2424 });
2425 if (!a.ok) { flash(saying(a)); return false; }
2426 _returned.notes = cleanReturnedList(a.data);
2427 drawReturned();
2428 return true;
2429 }
2430
2431 /// Draw the returned notes into the capture view, or nothing when there are
2432 /// none. Terse: one line per note, and a dismiss that acks its own `when` key.
2433 function drawReturned() {
2434 var host = el('improve-returned');
2435 if (!host) return;
2436 host.innerHTML = '';
2437 var notes = _returned.notes;
2438 host.hidden = !notes.length;
2439 if (!notes.length) return;
2440 notes.forEach(function (r) {
2441 var row = document.createElement('div');
2442 row.className = 'imp-returned-one';
2443 row.dataset.when = String(r.when);
2444 var said = document.createElement('p');
2445 said.className = 'imp-returned-said';
2446 said.textContent = r.reason
2447 ? tOr('social.returned_one', "Your proposal '{title}' was declined: {reason}",
2448 { title: r.title, reason: r.reason })
2449 : tOr('social.returned_bare', "Your proposal '{title}' was declined.", { title: r.title });
2450 row.appendChild(said);
2451 var x = button('imp-returned-x', 'improve-returned-dismiss',
2452 tOr('common.dismiss', 'Dismiss'),
2453 tOr('social.returned_dismiss', 'Dismiss this note.'));
2454 x.dataset.when = String(r.when);
2455 row.appendChild(x);
2456 host.appendChild(row);
2457 });
2458 }
2459
2460 function render() {
2461 bindNoteBox();
2462 drawContext();
2463 drawReturned();
2464 drawAs();
2465 drawQueue();
2466 // The approve-list draws only where its host exists; with the Notes view
2467 // gone it is dormant, but kept called so a build that restores a batch
2468 // surface needs no change here. Same for the triage row.
2469 try { if (window.DaimondApproveList) DaimondApproveList.draw(); } catch (e) { log('the approve-list would not draw', e); }
2470 try { if (window.DaimondTriage) DaimondTriage.draw(); } catch (e) { log('the triage row would not draw', e); }
2471 drawProps();
2472 drawSettings();
2473 }
2474
2475 // ── WHAT IS HALF-WRITTEN SURVIVES A RELOAD ─────────────────
2476 //
2477 // A screen refresh used to empty the note box, and the words were gone with
2478 // nothing anywhere saying anything had been lost. Reported by the owner:
2479 // "I would expect all live text input to persist."
2480 //
2481 // THIS IS NOT THE QUEUE §4 FORBIDS, and the reasoning is in `drafts.js`'s
2482 // header rather than restated here. In one line: nothing is kept for later
2483 // SENDING, because nothing here sends -- `outgoing()` still reads the box at
2484 // the moment of the press, and restoring the box is what puts the words in
2485 // front of the person who has to press it. The draft is dropped by every
2486 // path that empties the box, so a sent note is never also a draft of itself.
2487
2488 function drafts() { return window.DaimondDrafts || null; }
2489
2490 /// The key one box's draft is kept under. Namespaced by SURFACE, so that
2491 /// `dropUnder` can forget everything belonging to one proposal when it is
2492 /// gone without knowing how many boxes that proposal drew.
2493 function draftKey() {
2494 var parts = ['social'];
2495 for (var i = 0; i < arguments.length; i++) parts.push(String(arguments[i]));
2496 return parts.join('/');
2497 }
2498
2499 function bindNoteBox() {
2500 var d = drafts(), box = el('improve-box');
2501 if (d && box) d.bind(box, draftKey('note'));
2502 }
2503
2504 // ── A REFERENCE, DRAWN AS A CHIP ───────────────────────────
2505 //
2506 // Four things a message may point at — a proposal, a build, a panel, a guide
2507 // page — and the five rules the code has to keep. They are written out here
2508 // because every one of them is a rule somebody would otherwise "simplify"
2509 // away, and four of the five look like extra work until the thing they
2510 // prevent happens.
2511 //
2512 // R1 RESOLVED BY THE READER, NEVER RENDERED BY THE SENDER. What travels
2513 // is `{ kind, id, fallback_label }`. The title on screen is read from
2514 // the forge, this build's own stamp, this build's own panel table or
2515 // this build's own guide — by the reader, now. A title supplied by the
2516 // sender is a lie waiting to happen, because proposals get renamed and
2517 // closed, AND it is an injection surface: arbitrary text drawn as
2518 // though it were a forge record. `fallback_label` is drawn ONLY when
2519 // the resolution fails, as plain text, and framed as the sender's own
2520 // description rather than as the name of anything.
2521 // R2 NEVER DISCLOSE THE EXISTENCE OF WHAT THE READER CANNOT SEE. The nine
2522 // refusal wordings are `saying()`'s, unchanged and not re-worded here:
2523 // `absent` covers both "no such repository" and "it is private", which
2524 // is exactly why it must not be sharpened. A signed-out reader is
2525 // refused by the gateway before the forge is asked, so they are told to
2526 // sign in and NEVER that a thing was not found.
2527 // R3 A REFERENCE IS NOT A URL. There is no `href` in this file. Every chip
2528 // is a `<button>` that calls into this app.
2529 // R4 AT MOST FOUR PER MESSAGE. Enforced here as well as in the payload, so
2530 // a sender that got past the seal still cannot draw a fifth.
2531 // R5 RESOLUTION IS LAZY AND CACHED. Nothing is fetched until somebody
2532 // presses the chip open, and an answer is kept. `improve.rs` meters per
2533 // tester: ten proposal chips resolved eagerly on an inbox opening is
2534 // ten metered requests against that reader's OWN Improve allowance,
2535 // which could throttle them out of the Improve half of this panel
2536 // entirely. That is the whole reason R5 exists.
2537
2538 /// The four kinds. An enum, not a string test: a fifth arrives by being
2539 /// added here and nowhere else, and a `kind` this build does not know draws
2540 /// the sender's description and no control at all.
2541 // i18n-family: ref.kind_ = proposal build panel guide
2542 var REFS = { proposal: 'Proposal', build: 'Build', panel: 'Panel', guide: 'Guide' };
2543
2544 /// Resolutions already paid for, by `kind + ':' + id`. R5's cache. Kept for
2545 /// the life of the tab: a proposal's title moving under a reader who is
2546 /// looking at a chip costs nothing, and a second metered request does.
2547 var _refs = {};
2548
2549 /// The one place a reference off the wire is read. Everything else in this
2550 /// section takes the result of this and never the raw thing.
2551 ///
2552 /// A reference is three fields and there is no fourth. Anything else on the
2553 /// object is dropped here rather than ignored later, which is the difference
2554 /// between a field that cannot be smuggled and one that merely is not read.
2555 function cleanRef(r) {
2556 if (!r || typeof r !== 'object') return null;
2557 var kind = (typeof r.kind === 'string') ? r.kind : '';
2558 if (!REFS[kind]) return null;
2559 var id = (r.id == null) ? '' : String(r.id);
2560 if (!id || id.length > 128) return null;
2561 var said = (typeof r.fallback_label === 'string') ? r.fallback_label.slice(0, 200) : '';
2562 return { kind: kind, id: id, said: said };
2563 }
2564
2565 /// The references a message carries, cleaned and capped. R4.
2566 function cleanRefs(list) {
2567 if (!Array.isArray(list)) return [];
2568 var out = [];
2569 for (var i = 0; i < list.length && out.length < 4; i++) {
2570 var r = cleanRef(list[i]);
2571 if (r) out.push(r);
2572 }
2573 return out;
2574 }
2575
2576 /// Whether this reader has a session at all. Without one `improve.rs` refuses
2577 /// before the forge is asked, so a proposal chip must say "sign in" rather
2578 /// than anything about whether the proposal is there.
2579 function signedIn() {
2580 try {
2581 if (window.DaimondGateway && DaimondGateway.hasSession) return !!DaimondGateway.hasSession();
2582 if (window.DaimondIdentity && DaimondIdentity.unlocked) return !!DaimondIdentity.unlocked();
2583 } catch (e) { /* neither module in this build */ }
2584 return true; // not knowable here: let the refusal say it instead
2585 }
2586
2587 /// Resolve one reference. Answers `{ ok, title, note, act }` or
2588 /// `{ ok: false, why }` with `why` already a SENTENCE from `saying()`.
2589 async function resolve(ref) {
2590 var key = ref.kind + ':' + ref.id;
2591 if (_refs[key]) return _refs[key];
2592 var out;
2593 if (ref.kind === 'proposal') {
2594 var n = parseInt(ref.id, 10);
2595 if (!(n > 0)) out = { ok: false, why: tOr('ref.unopenable', 'There is no opening this here.') };
2596 else if (!signedIn()) out = { ok: false, why: tOr('ref.signin', 'Sign in to open this.') };
2597 else {
2598 var a = await ask(route('n=' + n), { method: 'GET' });
2599 if (!a.ok) out = { ok: false, why: saying(a) };
2600 else {
2601 var p = cleanProp(a.data);
2602 out = p
2603 ? {
2604 ok: true,
2605 title: p.title,
2606 note: tnOr('ref.said_n', p.comments, '{n} comment, public',
2607 '{n} comments, public', { n: p.comments }),
2608 act: tOr('ref.open_proposal', 'Open the proposal'),
2609 // The browse list lives in the Improve hub now (js/tracker.js),
2610 // not on this capture surface, so a proposal reference opens
2611 // there. Falls back to this panel where the hub is absent.
2612 go: function () {
2613 try {
2614 if (window.DaimondPanels && window.DaimondTracker) {
2615 DaimondPanels.show('tracker');
2616 DaimondTracker.open(p.n);
2617 return;
2618 }
2619 } catch (e) { /* no hub in this build */ }
2620 try { if (window.DaimondPanels) DaimondPanels.show('social'); } catch (e2) { /* no engine */ }
2621 show('proposals');
2622 },
2623 }
2624 : { ok: false, why: saying(null) };
2625 }
2626 }
2627 } else if (ref.kind === 'build') {
2628 // Not a request: this build's own stamp is already in hand, and the
2629 // reader's own is the only other half of the answer.
2630 var here = (_build && _build === ref.id);
2631 out = {
2632 ok: true,
2633 title: tOr('ref.build', 'Build {id}', { id: ref.id }),
2634 note: here
2635 ? tOr('ref.build_here', 'This is the build you are on.')
2636 : tOr('ref.build_other', 'You are on build {id}.', { id: _build || '?' }),
2637 act: here ? '' : tOr('ref.build_update', 'Update to it'),
2638 go: here ? null : function () {
2639 try { if (window.DaimondUpdater) DaimondUpdater.check(); } catch (e) { /* no updater */ }
2640 },
2641 };
2642 } else if (ref.kind === 'panel') {
2643 // A surface, not an object. It discloses nothing and needs no
2644 // resolution machinery -- but a panel this build does not have is
2645 // still a chip that would always fail, so it is asked for by name.
2646 var host = /^[a-z0-9_-]+$/i.test(ref.id)
2647 ? document.querySelector('[data-panel="' + ref.id + '"]')
2648 : null;
2649 out = host
2650 ? {
2651 ok: true,
2652 title: tOr('ref.panel', 'The {name} panel',
2653 { name: host.dataset.label || ref.id }),
2654 note: '',
2655 act: tOr('ref.open_panel', 'Open it'),
2656 go: function () { try { DaimondPanels.show(ref.id); } catch (e) { /* no engine */ } },
2657 }
2658 : { ok: false, why: tOr('ref.unopenable', 'There is no opening this here.') };
2659 } else {
2660 var page = /^[a-z0-9-]+\.html(#[a-z0-9-]+)?$/i.test(ref.id) ? ref.id : '';
2661 out = page
2662 ? {
2663 ok: true,
2664 title: tOr('ref.guide', 'Guide: {page}', { page: page.replace(/\.html.*$/, '') }),
2665 note: '',
2666 act: tOr('ref.open_guide', 'Open the page'),
2667 // The guide renders IN the app. "Never link out" honoured
2668 // rather than dodged: this is the same route the header's own
2669 // guide button takes.
2670 go: function () {
2671 try { if (window.DaimondWeb && DaimondWeb.guide) DaimondWeb.guide(page); }
2672 catch (e) { /* no web panel in this build */ }
2673 },
2674 }
2675 : { ok: false, why: tOr('ref.unopenable', 'There is no opening this here.') };
2676 }
2677 _refs[key] = out;
2678 return out;
2679 }
2680
2681 /// What a chip is called before anything has been read.
2682 ///
2683 /// The id is this app's own words only where it is SHAPED like an id of that
2684 /// kind: a proposal number, a build stamp, a panel this build has, a guide
2685 /// page. Anything else and the name is EMPTY -- the kind label beside it
2686 /// already says what sort of thing this is, and repeating it there says
2687 /// nothing twice. Because an id is the one field a sender fills in, and a
2688 /// hundred and twenty-eight characters of their choosing sitting where a name
2689 /// goes is R1 defeated by the back door.
2690 function refName(ref) {
2691 if (ref.kind === 'proposal') {
2692 return /^[0-9]{1,9}$/.test(ref.id)
2693 ? tOr('ref.proposal', 'Proposal #{n}', { n: ref.id }) : '';
2694 }
2695 if (ref.kind === 'build') {
2696 return /^[0-9a-f]{6,64}$/i.test(ref.id)
2697 ? tOr('ref.build', 'Build {id}', { id: ref.id }) : '';
2698 }
2699 if (ref.kind === 'panel') {
2700 var p = /^[a-z0-9_-]+$/i.test(ref.id)
2701 ? document.querySelector('[data-panel="' + ref.id + '"]') : null;
2702 return p ? tOr('ref.panel', 'The {name} panel', { name: p.dataset.label || ref.id }) : '';
2703 }
2704 return /^[a-z0-9-]+\.html(#[a-z0-9-]+)?$/i.test(ref.id)
2705 ? tOr('ref.guide', 'Guide: {page}', { page: ref.id.replace(/\.html.*$/, '') }) : '';
2706 }
2707
2708 /// One chip, SHUT until somebody opens it. The shape is `attachTile`'s: a
2709 /// kind, a name, a reason and a note, with `shut` meaning "there is no
2710 /// opening this" -- the same thing an unresolvable reference is.
2711 function refChip(ref) {
2712 var box = document.createElement('div');
2713 box.className = 'ref-chip';
2714 box.dataset.kind = ref.kind;
2715 box.dataset.ref = ref.id;
2716
2717 var kind = document.createElement('span');
2718 kind.className = 'ref-kind';
2719 kind.textContent = tOr('ref.kind_' + ref.kind, REFS[ref.kind]);
2720 box.appendChild(kind);
2721
2722 // What it is called BEFORE anything has been read: the kind and the id.
2723 // Never the sender's words -- and never a raw id either unless it is
2724 // SHAPED like an id of that kind. An id is the one field a sender fills
2725 // in, so an unrecognisable one is drawn as nothing at all rather than as
2726 // 128 characters of their choosing sitting where a name goes.
2727 var name = document.createElement('span');
2728 name.className = 'ref-name';
2729 name.textContent = refName(ref);
2730 box.appendChild(name);
2731
2732 var note = document.createElement('span');
2733 note.className = 'ref-note';
2734 box.appendChild(note);
2735
2736 var act = document.createElement('button');
2737 act.type = 'button';
2738 act.className = 'ref-act';
2739 act.textContent = tOr('ref.expand', 'Show what this is');
2740 box.appendChild(act);
2741
2742 var done = false;
2743 act.addEventListener('click', async function () {
2744 if (done) return;
2745 done = true;
2746 act.disabled = true;
2747 note.textContent = tOr('ref.reading', 'Reading it…');
2748 var r = await resolve(ref);
2749 if (!r.ok) {
2750 box.classList.add('shut');
2751 note.textContent = r.why;
2752 // R1: the sender's description, drawn only now, as plain text and
2753 // said to be theirs. `textContent` and not markup, which is the
2754 // other half of why a sender-supplied title is refused.
2755 if (ref.said) {
2756 var said = document.createElement('span');
2757 said.className = 'ref-said';
2758 said.textContent = tOr('ref.said', 'Described as: {text}', { text: ref.said });
2759 box.appendChild(said);
2760 }
2761 act.remove();
2762 return;
2763 }
2764 if (r.title) name.textContent = r.title;
2765 note.textContent = r.note || '';
2766 if (r.act && r.go) {
2767 act.disabled = false;
2768 act.textContent = r.act;
2769 act.onclick = r.go;
2770 } else act.remove();
2771 });
2772 return box;
2773 }
2774
2775 /// Draw a message's references into `host`. What a message renderer calls.
2776 function drawRefs(host, list) {
2777 if (!host) return 0;
2778 host.innerHTML = '';
2779 var refs = cleanRefs(list);
2780 refs.forEach(function (r) { host.appendChild(refChip(r)); });
2781 return refs.length;
2782 }
2783
2784 // ── The chips on the head ──────────────────────────────────
2785 //
2786 // The panel is Social. It holds five things -- Messages, People, Share,
2787 // Proposals, Settings. Note-capture MERGED INTO PROPOSALS: the standalone Notes
2788 // view is gone, its compose box now sits at the top of the Proposals view, and
2789 // Settings is the new fifth. It defaults to Proposals, which is where a person
2790 // both writes and reads.
2791 //
2792 // The count is deliberately not in the heading. A heading that names a number
2793 // goes stale the next time somebody adds a chip, and the panel is the only
2794 // honest count.
2795 //
2796 // The views are looked up by NAME rather than listed twice: a chip is a
2797 // `data-view` on the head and an element id in the table below, and a sixth
2798 // chip is one line here.
2799
2800 var VIEWS = {
2801 messages: 'social-messages',
2802 people: 'social-people',
2803 // js/share.js renders into `#social-share-list` the way post.js renders
2804 // into the messages list; this file shows and hides it and nothing more.
2805 share: 'social-share',
2806 proposals: 'improve-props-view',
2807 // This file's own, drawn by `drawSettings`: the voice and the drafts.
2808 settings: 'social-settings',
2809 };
2810
2811 var _view = 'proposals';
2812
2813 /// Callbacks a lane registers to be told its own view was opened, so it can
2814 /// read what it needs LAZILY. Ten chips resolved on panel open is ten
2815 /// requests nobody asked for.
2816 var _watch = [];
2817
2818 function show(view) {
2819 _view = VIEWS[view] ? view : 'proposals';
2820 Object.keys(VIEWS).forEach(function (v) {
2821 var e = el(VIEWS[v]);
2822 if (e) e.hidden = (v !== _view);
2823 });
2824 document.querySelectorAll('#panel-social .imp-chip').forEach(function (c) {
2825 var on = c.dataset.view === _view;
2826 c.classList.toggle('on', on);
2827 c.setAttribute('aria-pressed', on ? 'true' : 'false');
2828 });
2829 if (_view === 'proposals') {
2830 if (!_list.read && !_list.loading) loadList(false);
2831 else drawProps();
2832 } else if (_view === 'settings') {
2833 drawSettings();
2834 } else drawProps();
2835 _watch.forEach(function (f) { try { f(_view); } catch (e) { /* one lane's fault is its own */ } });
2836 }
2837
2838 /// A lane says how many rows it drew in its own view. The honest line under
2839 /// the chip goes away exactly when there is something else to read there, and
2840 /// comes back when there is not — so an emptied list never leaves a blank.
2841 function filled(view, n) {
2842 var off = el('social-' + view + '-off');
2843 if (off) off.hidden = !!(n | 0);
2844 }
2845
2846 // ── Wiring ─────────────────────────────────────────────────
2847
2848 /// The panel was opened. The listing is read again, because there is no change
2849 /// feed and looking IS how a tester finds out.
2850 function onOpen() {
2851 render();
2852 // A declined proposal's note is read on open, the same "looking is how you
2853 // find out" the proposals list keeps. Quiet without a voice or an inbox.
2854 loadReturned();
2855 if (_view === 'proposals') loadList(false);
2856 // Opening the panel is a good moment to drain anything that could not be
2857 // sent while it was shut, so a queue does not sit full when the network is
2858 // plainly back. The reconnect event is the main path; this is the belt.
2859 if (onLine()) { try { flushQueue(); } catch (e) { /* best effort */ } }
2860 _watch.forEach(function (f) { try { f(_view); } catch (e) { /* as above */ } });
2861 }
2862
2863 document.addEventListener('click', function (e) {
2864 var host = e.target && e.target.closest ? e.target.closest('#panel-social') : null;
2865 if (!host) return;
2866 var chip = e.target.closest('.imp-chip');
2867 if (chip) { e.preventDefault(); show(chip.dataset.view); return; }
2868 var b = e.target.closest('[data-act]');
2869 if (!b) return;
2870 var act = b.dataset.act;
2871 if (act === 'improve-post') { e.preventDefault(); submit('verbatim'); return; }
2872 if (act === 'improve-polish') { e.preventDefault(); submit('polish'); return; }
2873 if (act === 'improve-open-hub') { e.preventDefault(); openHub(); return; }
2874 if (act === 'improve-more') { e.preventDefault(); loadList(true); return; }
2875 if (act === 'improve-forget-plan') {
2876 e.preventDefault();
2877 try { if (window.DaimondTriage) DaimondTriage.clear(); } catch (err) { /* no triage in this build */ }
2878 try { if (window.DaimondApproveList) DaimondApproveList.clear(); } catch (err) { /* no queue */ }
2879 return;
2880 }
2881 if (act === 'improve-voice-get') { e.preventDefault(); provision(false); return; }
2882 if (act === 'improve-voice-reissue'){ e.preventDefault(); reissueVoice(); return; }
2883 if (act === 'improve-voice-open') { e.preventDefault(); _voiceOpen = true; _voiceAlready = false; drawVoice(); return; }
2884 if (act === 'improve-voice-cancel') { e.preventDefault(); _voiceOpen = false; drawVoice(); return; }
2885 if (act === 'improve-voice-save') { e.preventDefault(); saveVoice(); return; }
2886 if (act === 'improve-voice-forget') { e.preventDefault(); forgetVoice(); return; }
2887 if (act === 'improve-with-off') {
2888 e.preventDefault();
2889 var row = el('improve-with');
2890 if (row) { row.dataset.off = '1'; row.hidden = true; }
2891 return;
2892 }
2893 if (act === 'improve-returned-dismiss') {
2894 e.preventDefault();
2895 // The reader has SEEN this one: ack its own `when` key, never the whole
2896 // inbox. The forge answers the remainder, which redraws.
2897 ackReturned(Number(b.dataset.when));
2898 return;
2899 }
2900 var noteEl = b.closest('.imp-note');
2901 if (noteEl) {
2902 if (act === 'improve-copy') { e.preventDefault(); copy(noteEl.dataset.note); return; }
2903 if (act === 'improve-resend') { e.preventDefault(); resend(noteEl.dataset.note); return; }
2904 if (act === 'improve-drop') { e.preventDefault(); drop(noteEl.dataset.note); return; }
2905 }
2906 var propEl = b.closest('.imp-prop');
2907 if (propEl) {
2908 var n = Number(propEl.dataset.prop);
2909 if (act === 'improve-open') {
2910 e.preventDefault();
2911 var body = propEl.querySelector('.imp-prop-body');
2912 var shut = !body || body.hidden;
2913 if (body) body.hidden = !shut;
2914 b.setAttribute('aria-expanded', shut ? 'true' : 'false');
2915 if (shut) {
2916 _open[String(n)] = 1;
2917 // Opening it is what reads it: one proposal, one request, and
2918 // only for the one somebody asked to see.
2919 if (_by[n] && !_by[n].detail) loadOne(n);
2920 } else delete _open[String(n)];
2921 return;
2922 }
2923 if (act === 'improve-vote') { e.preventDefault(); vote(n, b.dataset.dir); return; }
2924 if (act === 'improve-comment') { e.preventDefault(); comment(n); return; }
2925 if (act === 'improve-amend-open') { e.preventDefault(); _amending[String(n)] = 1; drawProps(); return; }
2926 if (act === 'improve-amend-cancel') {
2927 e.preventDefault();
2928 delete _amending[String(n)];
2929 // Cancelling is a decision not to revise, so the revision goes with it.
2930 // A draft that survived Cancel would reappear the next time the control
2931 // was opened, which is the app arguing with a person who said no.
2932 try { if (drafts()) drafts().dropUnder(draftKey('amend', n)); } catch (err) { /* storage blocked */ }
2933 drawProps();
2934 return;
2935 }
2936 if (act === 'improve-amend-save') { e.preventDefault(); amend(n); return; }
2937 }
2938 });
2939
2940 // The row that says what goes with a note is a PREVIEW of the line, kept in
2941 // step while somebody is looking at it. What travels is redrawn and read at
2942 // the press, in `outgoing()`, so being late here costs a stale preview and
2943 // never a stale note.
2944 //
2945 // `daimond:layout` and `daimond:theme` used to be in this list and NOTHING IN
2946 // THE TREE HAS EVER DISPATCHED EITHER -- `setTheme` in daimond.js writes the
2947 // palette and says nothing, and the layout engine's `apply()` runs on every
2948 // panel open with no announcement. So the two facts most likely to move under
2949 // an open panel were the two this row never heard about, which is the defect
2950 // the redraw in `outgoing()` closes.
2951 try { window.addEventListener('resize', function () { if (!contextOff()) drawContext(); }); }
2952 catch (e) { /* no window */ }
2953
2954 // And on the way to the button. A hand coming back to this panel to press Send
2955 // passes through it, so the last state of the preview a person can read is the
2956 // state that was in force when they pressed.
2957 ['pointerdown', 'focusin'].forEach(function (ev) {
2958 try {
2959 document.addEventListener(ev, function (e) {
2960 var t = e.target;
2961 if (!t || typeof t.closest !== 'function' || !t.closest('#panel-social')) return;
2962 if (!contextOff()) drawContext();
2963 }, true);
2964 } catch (err) { /* no document */ }
2965 });
2966
2967 // Writing a fresh note takes the "Raised" confirmation down, so it never sits
2968 // stale over a compose that has moved on.
2969 try {
2970 document.addEventListener('input', function (e) {
2971 if (e.target && e.target.id === 'improve-box') hideRaised();
2972 }, true);
2973 } catch (e) { /* no document */ }
2974
2975 // Another tab wrote a note, or an account switch emptied the store.
2976 window.addEventListener('storage', function (e) {
2977 if (e.key !== KEY && !(e.key && e.key.indexOf(KEY) !== -1)) return;
2978 _st = null;
2979 render();
2980 });
2981
2982 // Say the panel's own words again in a new language. Every string on a row is
2983 // built here rather than marked up, so a language change reaches none of them
2984 // unless this surface is registered.
2985 try {
2986 DaimondI18n.surface(function () { return document.getElementById('panel-social'); },
2987 function () { render(); });
2988 } catch (e) { /* no i18n in this build */ }
2989
2990 function start() {
2991 if (!el('panel-social')) return; // this build has no Improve panel
2992 readBuild().then(function () { render(); }, function () { render(); });
2993 show('notes');
2994 render();
2995 }
2996 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
2997 else start();
2998
2999 /// The panel SHELL, which is what the app and the other lanes talk to.
3000 ///
3001 /// Separate from `DaimondImprove` below on purpose: the Social panel holds
3002 /// four things and this file owns two of them. A lane rendering into
3003 /// `#social-messages-list` or `#social-people-list` needs the chips, the
3004 /// view switch and the empty line, and has no business with a note or a
3005 /// proposal.
3006 window.DaimondSocial = {
3007 /// The panel was shown. Every view is told, so a lane can read lazily.
3008 onOpen: onOpen,
3009 /// Switch to one of `messages`, `people`, `notes`, `proposals`.
3010 show: show,
3011 /// Show the panel AND switch to a view. What a reference chip presses.
3012 open: function (view) {
3013 try { if (window.DaimondPanels) DaimondPanels.show('social'); } catch (e) { /* no engine */ }
3014 show(view);
3015 },
3016 /// Which view is showing.
3017 view: function () { return _view; },
3018 /// A lane drew `n` rows in `view`; the honest empty line follows.
3019 filled: filled,
3020 /// Be told when a view is opened, by name. Called on every switch and on
3021 /// every panel open, so a lane refreshes when somebody looks.
3022 watch: function (fn) { if (typeof fn === 'function') _watch.push(fn); },
3023 /// THE DAIMON'S DOOR ONTO THIS PANEL, which the engine binds to by this
3024 /// name (`src/wasm/social.rs`). Three methods and the split between them
3025 /// is the point; the section above `socialRead` says why.
3026 ///
3027 /// ON THE PANEL'S OWN OBJECT and not on a second global beside it. The
3028 /// first draft of this lane installed `window.DaimondSocial` afresh and
3029 /// clobbered everything above -- `Panels.show('social')` calls `onOpen()`
3030 /// with no guard on the method, so the panel threw on its first open. A
3031 /// surface has one object; what a model may do with it is part of it.
3032 read: socialRead,
3033 compose: socialCompose,
3034 commit: socialCommit,
3035 /// What is composed and not yet sent, for a verifier. A token nobody has
3036 /// approved is a publication that has not happened.
3037 drafts: function () { return Object.keys(_drafts); },
3038 };
3039
3040 /// References, for whatever renders a message. Kept here rather than in the
3041 /// module that draws messages, because a proposal reference resolves through
3042 /// THIS file's `route()`, `ask()` and `saying()` — the nine refusal wordings
3043 /// exist once, and a second copy of them is a second copy to get wrong.
3044 window.DaimondRefs = {
3045 /// Draw the references a message carries into `host`; answers how many
3046 /// were drawn, which is at most four.
3047 draw: drawRefs,
3048 /// One chip, for a caller placing them itself.
3049 chip: refChip,
3050 /// What a wire reference reduces to, for a verifier and for a sender
3051 /// that wants to know what will survive. Everything else is dropped.
3052 clean: cleanRefs,
3053 /// Resolve one, for a verifier. Cached exactly as the chip's own press is.
3054 resolve: function (r) {
3055 var c = cleanRef(r);
3056 return c ? resolve(c) : Promise.resolve({ ok: false, why: '' });
3057 },
3058 /// Forget what has been resolved, for a test that wants a cold cache.
3059 forget: function () { _refs = {}; },
3060 };
3061
3062 // ── The daimon's door onto this panel ──────────────────────
3063 //
3064 // WHY THIS EXISTS, WHICH IS NOT WHAT IT DOES. On 2026-08-24 two real daimons,
3065 // on two accounts and two different models, were each asked to do one side of
3066 // this panel's work -- one to report a defect in Daimond, one to find that
3067 // report and agree with it. Neither could reach the panel, and neither said
3068 // so. One told its user to go and open "Daimond's feedback/issue reporting
3069 // interface (typically accessible from a menu in the app)", which is this
3070 // panel, and which it had just failed to find. The other spent eighteen calls
3071 // searching the workspace for "where the Social panel stores its reports" and
3072 // finished by telling the user "The gateway isn't running." The gateway was
3073 // running.
3074 //
3075 // A working surface the model cannot reach is a surface the model will deny.
3076 // That rule is `Tool::FileShow`'s and it is written out in full in
3077 // `src/tools.rs`; this file is its second instance.
3078 //
3079 // THREE METHODS AND THE SPLIT BETWEEN THEM IS THE POINT.
3080 //
3081 // read answers what is on a view, in prose. Nobody is asked anything:
3082 // the owner ruled on 2026-08-24 that seeing is immediate, because
3083 // it is information and because a daimon that can see the panel
3084 // stops denying the panel is there.
3085 // compose works out exactly what one act would put on the wire, and mints
3086 // a token standing for those bytes. NOTHING IS SENT.
3087 // commit sends what a token holds, and only what it holds.
3088 //
3089 // COMPOSE AND COMMIT ARE TWO CALLS SO THAT CONSENT IS BOUND TO BYTES. The
3090 // user is shown what `compose` composed and answers about that; `commit`
3091 // sends that same payload. One call taking the model's arguments and asking
3092 // on the way past would mean the person approved a rendering and the app sent
3093 // a rebuild of it -- and the two part company at exactly the field somebody
3094 // would have wanted to see, the build identifier that travels with a note or
3095 // the title of the proposal a vote lands on.
3096 //
3097 // A TOKEN IS SPENT ONCE. Left spendable, a yes about one publication would be
3098 // a licence to publish it again, which is the per-host memory mistake this
3099 // panel must not repeat: what is being approved here is a payload, not a
3100 // destination.
3101 //
3102 // WHAT THIS FILE ANSWERS AND WHAT RUST ANSWERS. Every sentence about a
3103 // RECORD is composed here -- which proposals exist, their tallies, their
3104 // states, what a message says -- because this is where the record lives and
3105 // because a second renderer in Rust would disagree with the screen the user
3106 // is looking at the first time either changed. Every sentence that DECIDES
3107 // something is composed in Rust: the refusals about arguments, the refusal a
3108 // dispatched worker gets, and the question put to the user.
3109
3110 var _drafts = Object.create(null); // tokens minted by compose(), spent by commit()
3111 var _draftN = 0;
3112 var DRAFT_LIFE = 300000; // five minutes for a person to read and answer
3113
3114 /// Forget drafts nobody answered.
3115 ///
3116 /// A DECLINED DRAFT IS NEVER TOLD SO. The engine discards the token when the
3117 /// user says no and there is no message back to here, so without this a
3118 /// refused publication would sit in memory with a live handle on it for as
3119 /// long as the tab is open. Nothing can reach one -- the engine composes a
3120 /// fresh draft each time -- but "nothing can reach it" is an argument about
3121 /// today's callers, and the payload is a public post in somebody's name.
3122 function sweepDrafts() {
3123 var cut = Date.now() - DRAFT_LIFE;
3124 Object.keys(_drafts).forEach(function (k) {
3125 if (!_drafts[k] || _drafts[k].at < cut) delete _drafts[k];
3126 });
3127 }
3128
3129 /// The request, as the engine wrote it.
3130 function req(json) {
3131 try { return JSON.parse(String(json || '{}')) || {}; }
3132 catch (e) { return {}; }
3133 }
3134
3135 /// A refusal a model reads and acts on. The opening word is what the fold's
3136 /// ledger reads (see `call_outcome` in src/tools.rs), so a refusal that did
3137 /// not open with it would be booked as work that was done.
3138 function no(why) { return 'Refused: ' + why; }
3139
3140 /// Why the forge would not, said for a model rather than for the screen.
3141 ///
3142 /// The nine tokens are the contract's and are stable; `saying()` beside this
3143 /// is prose for a person, translated eight ways and reworded whenever it
3144 /// reads badly. A model branching on that would branch on a translation.
3145 function whyNot(a) {
3146 var w = (a && a.why) || 'gateway';
3147 if (w === 'unvoiced') {
3148 return 'this account has no voice on the forge, so it cannot write there. '
3149 + 'Tell the user: the Social panel has a control for setting one.';
3150 }
3151 if (w === 'unpermitted') {
3152 if (a && a.on === 'amend') {
3153 return 'only the person who opened that proposal may revise it, and this '
3154 + 'account did not open it. The forge wrote nothing.';
3155 }
3156 return 'this account\'s voice is not allowed to do that on the forge.';
3157 }
3158 if (w === 'throttled') {
3159 return 'the forge is rate-limiting this account'
3160 + (a.because ? ' (' + a.because + ')' : '') + '. Wait rather than retrying now.';
3161 }
3162 if (w === 'no_proposal' || w === 'absent') {
3163 return 'the forge has no such proposal. Read the proposals again -- the number may '
3164 + 'have been wrong.';
3165 }
3166 if (w === 'offline') return 'the request never reached the forge.';
3167 if (w === 'gateway') return 'Daimond\'s gateway would not carry it'
3168 + (a && a.status ? ' (' + a.status + ')' : '') + '.';
3169 return 'the forge answered \'' + w + '\'.';
3170 }
3171
3172 /// One proposal as a model should read it: the number first, because that is
3173 /// what every later call is aimed with.
3174 function sayProp(p, full) {
3175 var out = '#' + p.n + ' ' + (p.title || '(no title)')
3176 + ' [' + p.state + ']';
3177 if (p.votes) {
3178 out += ' ' + p.votes.for + ' for, ' + p.votes.against + ' against';
3179 if (p.asked && p.mine === 1) out += ' (this account voted for it)';
3180 if (p.asked && p.mine === -1) out += ' (this account voted against it)';
3181 }
3182 if (p.author) out += ' by ' + p.author;
3183 if (p.comments) out += ' ' + p.comments + ' comment' + (p.comments === 1 ? '' : 's');
3184 if (full && p.body) out += '\n' + p.body;
3185 if (full && p.discussion && p.discussion.length) {
3186 out += '\n--- discussion ---';
3187 p.discussion.forEach(function (d) {
3188 out += '\n' + (d.author || 'somebody') + ': ' + d.said;
3189 });
3190 }
3191 return out;
3192 }
3193
3194 /// Read one view of the panel.
3195 ///
3196 /// THE PANEL IS DRIVEN AND THEN READ, rather than a second request being made
3197 /// beside it. What the model is told is therefore what is on the user's
3198 /// screen -- which is the whole point of a daimon being able to see this at
3199 /// all, and it is also why a listing here can never drift from the listing
3200 /// somebody is looking at.
3201 async function socialRead(reqJson) {
3202 var r = req(reqJson);
3203 var view = String(r.view || 'proposals');
3204 var limit = Math.max(1, Math.min(50, r.limit | 0 || 12));
3205 if (view === 'proposals') {
3206 var ok = await loadList(false);
3207 if (!ok) return no('nothing was read: ' + whyNot(_list.err));
3208 // A PAGE IS NOT A LISTING, and the difference only shows on a busy
3209 // repository. `loadList(false)` fetches PAGE records and the panel
3210 // offers a button for the rest; a tool call has no button, so a
3211 // daimon asking for 50 was answered with 25 and never saw the older
3212 // ones at all -- while this tool's own description tells it to read
3213 // the proposals first so it does not open a second one about
3214 // something already there. It could not.
3215 //
3216 // The walk is the PANEL'S walk, called again rather than written
3217 // again: every guard on it -- never `from=0`, stop on a short page,
3218 // stop on a page that did not descend -- is why a client of this
3219 // contract does not loop for ever, and a second walk here would be a
3220 // second set of them to keep right. Bounded by the limit, which the
3221 // schema caps at 50, so it is at most two more requests.
3222 var steps = 0;
3223 while (_order.length < limit && !_list.done && steps++ < 8) {
3224 if (!(await loadList(true))) break;
3225 }
3226 var rows = _order.slice(0, limit).map(function (n) { return sayProp(_by[n], false); });
3227 if (!rows.length) {
3228 return 'Nobody has proposed anything about Daimond yet. Yours would be the '
3229 + 'first: social_send with act "propose".';
3230 }
3231 return 'What people have reported or asked for about Daimond, newest first '
3232 + '(' + _list.total + ' in all, ' + rows.length + ' shown). Read one in full '
3233 + 'with view "proposal" and its number; back one with social_send.\n\n'
3234 + rows.join('\n');
3235 }
3236 if (view === 'proposal') {
3237 var n = r.n | 0;
3238 var got = await loadOne(n);
3239 if (!got) return no('nothing was read: ' + whyNot(_list.err));
3240 var p = _by[n];
3241 if (!p) return no('the forge answered about no proposal numbered ' + n + '.');
3242 return sayProp(p, true);
3243 }
3244 if (view === 'notes') {
3245 var notes = load().notes.slice(0, limit);
3246 if (!notes.length) {
3247 return 'This device has no notes waiting to send.';
3248 }
3249 return 'Notes waiting to send on this device (' + notes.length + '):\n\n'
3250 + notes.map(function (rec) {
3251 return '[' + (rec.mode === 'polish' ? 'to polish & post' : 'to post') + '] ' + rec.text;
3252 }).join('\n\n');
3253 }
3254 if (view === 'messages') {
3255 if (!window.DaimondPost) return no('this build has no messaging.');
3256 var msgs = (DaimondPost.list() || []).slice(0, limit);
3257 var tray = (DaimondPost.tray() || []).length;
3258 if (!msgs.length) {
3259 return 'This account\'s message list is empty.'
3260 + (tray ? ' ' + tray + ' are waiting to be accepted, which only the user can do.' : '');
3261 }
3262 return 'Messages on this account (' + msgs.length + ' shown'
3263 + (tray ? ', ' + tray + ' more waiting to be accepted' : '') + '):\n\n'
3264 + msgs.map(function (m) {
3265 return (m.dir === 'out' ? 'to ' : 'from ')
3266 + (m.dir === 'out' ? (m.to || m.gid || '?') : (m.from || '?'))
3267 + ': ' + String(m.body || '').slice(0, 400);
3268 }).join('\n');
3269 }
3270 if (view === 'people') {
3271 if (!window.DaimondPost) return no('this build has no messaging.');
3272 var who = (DaimondPost.people() || []).slice(0, limit);
3273 if (!who.length) {
3274 return 'Nobody is in this account\'s directory yet, so there is nobody to write to.';
3275 }
3276 return 'People this account can reach (' + who.length + '):\n\n'
3277 + who.map(function (p) { return (p.label || '(unnamed)') + ' [' + p.state + ']'; }).join('\n');
3278 }
3279 return no('\'' + view + '\' is not one of this panel\'s views.');
3280 }
3281
3282 /// Work out what one act would publish, and mint a token standing for it.
3283 ///
3284 /// The characters in `shown` are what the user is asked about, so everything
3285 /// that would travel is in them -- including the sealed build identifier,
3286 /// which the user's own box carries and which a person approving a report in
3287 /// their name is entitled to see before it goes.
3288 async function socialCompose(reqJson) {
3289 var r = req(reqJson);
3290 var act = String(r.act || '');
3291 if (!hasVoice()) {
3292 return JSON.stringify({ refusal: no('nothing was composed: this account has no voice '
3293 + 'on the forge, so it cannot publish there. Tell the user, and say what you '
3294 + 'wanted to publish -- the Social panel has a control for setting a voice.') });
3295 }
3296 var shown = '', payload = null;
3297 if (act === 'propose') {
3298 // The build identifier travels with a note the user sends, so it travels
3299 // with this one -- and it is therefore SHOWN. Consent to a report that
3300 // silently also names the build would be consent to something the person
3301 // did not read.
3302 var build = contextOff() ? '' : _build;
3303 payload = { act: 'propose', title: String(r.title || ''), body: String(r.body || ''), build: build };
3304 shown = 'A NEW PROPOSAL at ' + FORGE_HOST + ', under this account\'s voice name.\n\n'
3305 + payload.title + '\n' + payload.body
3306 + (build ? '\n\nand the build identifier ' + build : '');
3307 } else if (act === 'vote') {
3308 var n = r.n | 0;
3309 var p = _by[n];
3310 if (!p) {
3311 var got = await loadOne(n);
3312 if (!got) return JSON.stringify({ refusal: no('nothing was composed: ' + whyNot(_list.err)) });
3313 p = _by[n];
3314 }
3315 if (!p) return JSON.stringify({ refusal: no('there is no proposal numbered ' + n + '.') });
3316 var d = (r.d | 0);
3317 payload = { act: 'vote', n: n, d: d };
3318 shown = (d === 1 ? 'A VOTE FOR' : d === -1 ? 'A VOTE AGAINST' : 'TAKING BACK THE VOTE ON')
3319 + ' proposal #' + n + ' at ' + FORGE_HOST + ', under this account\'s voice name.\n\n'
3320 + (p.title || '(no title)')
3321 + (p.votes ? '\n\nIt stands at ' + p.votes.for + ' for and ' + p.votes.against + ' against.' : '');
3322 } else if (act === 'comment') {
3323 var cn = r.n | 0;
3324 payload = { act: 'comment', n: cn, said: String(r.said || '') };
3325 shown = 'A COMMENT on proposal #' + cn + ' at ' + FORGE_HOST
3326 + ', under this account\'s voice name.\n\n' + payload.said;
3327 } else {
3328 return JSON.stringify({ refusal: no('\'' + act + '\' is not an act this panel has.') });
3329 }
3330 var token = 'd' + (++_draftN) + '-' + Math.random().toString(36).slice(2, 10);
3331 _drafts[token] = { at: Date.now(), payload: payload };
3332 sweepDrafts();
3333 return JSON.stringify({ shown: shown, token: token });
3334 }
3335
3336 /// Publish what a token holds. The token is spent whatever happens: a yes was
3337 /// a yes to ONE publication, and a failed send does not license a second
3338 /// attempt nobody was asked about.
3339 async function socialCommit(token) {
3340 sweepDrafts();
3341 var held = _drafts[String(token || '')];
3342 delete _drafts[String(token || '')];
3343 var d = held && held.payload;
3344 if (!d) {
3345 return no('nothing was published: that draft is not one this panel composed, or it '
3346 + 'has already been sent. Compose it again, and the user will be asked again.');
3347 }
3348 if (d.act === 'propose') {
3349 var text = d.body ? (d.title + '\n' + d.body) : d.title;
3350 var rec = store(text, 'verbatim');
3351 render();
3352 var a = await through(rec, { title: d.title, body: d.body, build: d.build });
3353 render();
3354 if (!a.ok) return no('nothing was published: ' + whyNot(a)
3355 + ' The note is kept on this device and nothing was retried.');
3356 return 'Published as proposal #' + rec.n + ' on the Daimond forge. It is on the '
3357 + 'user\'s Social panel now, and other people can read and vote on it.';
3358 }
3359 if (d.act === 'vote') {
3360 var body = voteBody(d.d);
3361 if (!body) return no('nothing was published: ' + d.d + ' is not a vote.');
3362 var av = await ask(route('n=' + d.n + '&vote=1'), {
3363 method: 'POST',
3364 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
3365 body: body,
3366 });
3367 if (!av.ok) { _list.err = av; drawProps(); return no('nothing was published: ' + whyNot(av)); }
3368 var pv = absorb(cleanProp(av.data));
3369 _list.err = null;
3370 drawProps();
3371 return 'The vote is cast on proposal #' + d.n + '. It now stands at '
3372 + ((pv && pv.votes) ? (pv.votes.for + ' for and ' + pv.votes.against + ' against')
3373 : 'whatever the forge reports') + '.';
3374 }
3375 if (d.act === 'comment') {
3376 var f = new URLSearchParams();
3377 f.set('said', d.said);
3378 var ac = await ask(route('n=' + d.n), {
3379 method: 'POST',
3380 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
3381 body: f.toString(),
3382 });
3383 if (!ac.ok) { _list.err = ac; drawProps(); return no('nothing was published: ' + whyNot(ac)); }
3384 absorb(cleanProp(ac.data));
3385 _list.err = null;
3386 drawProps();
3387 return 'The comment is on proposal #' + d.n + ', where everybody reading it can see it.';
3388 }
3389 return no('nothing was published: that draft names no act.');
3390 }
3391
3392 window.DaimondImprove = {
3393 onOpen: onOpen,
3394 render: render,
3395 show: show,
3396 /// The two compose verbs, and the queue. Published so a verifier drives the
3397 /// same path a person does rather than a second one written for it.
3398 /// `submit('verbatim')` posts the words as they are; `submit('polish')` has
3399 /// the model rewrite them first. Both queue then try to send.
3400 submit: submit,
3401 resend: resend,
3402 drop: drop,
3403 /// Drain the queue: send every waiting note in its own mode. Called by
3404 /// js/daimond.js on the browser's `online` event, and on panel open.
3405 flushQueue: flushQueue,
3406 /// The exact characters a Send would put on the wire right now, and the
3407 /// cut that turns them into a proposal. The verifier compares both against
3408 /// what actually left.
3409 outgoing: outgoing,
3410 split: split,
3411 /// Reading the forge, and the walk downwards through it.
3412 load: loadList,
3413 one: loadOne,
3414 /// The proposer's returned-notes inbox: read the notes a decline left, ack
3415 /// the ones seen (per `when` key, form-encoded), and the record for a test.
3416 loadReturned: loadReturned,
3417 ackReturned: ackReturned,
3418 returned: function () { return _returned.notes.slice(); },
3419 /// Voting and saying something. Both go straight to the forge; neither is
3420 /// kept here.
3421 vote: vote,
3422 comment: comment,
3423 voteBody: voteBody,
3424 /// Is a pull voice held on this device? The Improve hub (js/tracker.js) asks
3425 /// before it offers a vote or comment control, so it shows the "set a voice"
3426 /// affordance rather than a button that the forge would refuse. Presence only.
3427 hasVoice: hasVoice,
3428 /// Get this device a pull voice, on a press: the same one-tap flow the
3429 /// Settings view draws. Published so the hub can send an unvoiced reader here
3430 /// rather than growing its own copy of the provisioning path.
3431 provision: function () { return provision(false); },
3432 /// Revising one's own proposal: the PANEL ACTION, which reads the two boxes
3433 /// under the open row. `forge.amend` below is the door it sends through.
3434 amend: amend,
3435 /// What is drawn, for a verifier that wants the record rather than the
3436 /// pixels.
3437 proposal: function (n) { return _by[n] ? JSON.parse(JSON.stringify(_by[n])) : null; },
3438 listing: function () { return { total: _list.total, shown: _order.slice(), done: _list.done, err: _list.err ? _list.err.why : '' }; },
3439 /// The store, for a verifier and for an account switch.
3440 notes: function () { return load().notes.slice(); },
3441 /// Whether the forge holds this note's own characters, which is the one
3442 /// question the cap in `save()` turns on. Published so a verifier asks it
3443 /// rather than inferring it from which note survived.
3444 delivered: delivered,
3445 /// Mark notes as folded into a proposal a draft was written from. What
3446 /// js/triage.js calls after the forge took a draft, and nothing else.
3447 fold: fold,
3448 /// The proposal numbers this DEVICE raised, de-duped. The Improve hub's
3449 /// (js/tracker.js) "Mine" filter reads this: the local voice has no name, so
3450 /// "raised here" is matched by number, never by author.
3451 raisedProposalNumbers: raisedProposalNumbers,
3452 /// THE FORGE, AS THIS PANEL REACHES IT, for the module that drafts from the
3453 /// whole list of notes at once.
3454 ///
3455 /// One door and one copy of it. `route()`, `ask()` and `saying()` live
3456 /// here; the nine refusal wordings exist once, and a second module holding
3457 /// its own copy of them is a second copy to get wrong -- which is the same
3458 /// argument `DaimondRefs` is kept in this file by. Every function here
3459 /// takes CHARACTERS and returns the panel's own `{ ok, data }` or
3460 /// `{ ok: false, why }`.
3461 forge: {
3462 /// Open a proposal from a draft's own characters, already cut in two.
3463 open: post,
3464 /// Say something on one proposal.
3465 say: say,
3466 /// Vote on one proposal: `1` for, `-1` against, `0` withdraws. The door
3467 /// the Improve hub's card sends through, so the POST and the pull voice it
3468 /// carries live in one place and the two surfaces cannot drift apart about
3469 /// what a vote is. Answers with the record it changed, tally and `mine` and
3470 /// all -- the caller absorbs it into its own store.
3471 vote: voteWire,
3472 /// Revise one proposal, from characters somebody else read. The door
3473 /// `amend` sends through as well, so the panel and the triage plan
3474 /// cannot drift apart about what a revision is.
3475 amend: revise,
3476 /// What a refusal says on the screen, in the reader's language.
3477 saying: saying,
3478 /// The proposals as they are drawn, newest first. A copy, so nothing
3479 /// outside this file can move the record the panel is showing.
3480 props: function () { return _order.map(function (n) { return _by[n] ? JSON.parse(JSON.stringify(_by[n])) : null; }).filter(Boolean); },
3481 /// Read the listing, and walk downwards through it.
3482 list: loadList,
3483 /// Whether the forge has said this asker may revise proposal `n`.
3484 /// ABSENT is not false: a proposal nobody asked about answers `false`
3485 /// here and `false` from `askedAmend`, and the control is drawn on
3486 /// neither.
3487 mayAmend: function (n) { return canAmend(_by[n]); },
3488 /// Fold a forge answer into the panel's proposal store, and hand back the
3489 /// record it landed as.
3490 ///
3491 /// A door PUTS a write on the wire and answers with the DETAIL SHAPE of the
3492 /// record it changed; the panel's own send, comment and vote each absorb
3493 /// that answer, so a DOOR CALLER must too. The approve-list's batch did not,
3494 /// and so a proposal it opened never entered `_by`/`_order` -- the Proposals
3495 /// view silently omitted every one the queue sent, which is why a run of
3496 /// eight drafts showed only the one proposal some other path had absorbed.
3497 /// Defended by `cleanProp`, so an answer shaped in a way this build does not
3498 /// know is dropped rather than drawn.
3499 absorb: function (data) { return absorb(cleanProp(data)); },
3500 },
3501 // `_amending` with the rest: a row left in amend mode across an account
3502 // switch would offer somebody else's proposal with this account's boxes
3503 // already open on it.
3504 reset: function () { _st = null; _by = {}; _order = []; _open = {}; _amending = {}; _returned = { notes: [], read: false, loading: false }; _list = { total: 0, lowest: null, done: false, loading: false, err: null, read: false }; },
3505 };
3506})();