Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/handmode.js

33.3 KiB, 16 runs

created by r2519314175:1377, 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/* handmode.js — which permission mode Daimond is in, shown where the work is
2 * watched and changed from there.
3 *
4 * One setting, three rungs, and the axis is what Daimond does WITHOUT ASKING:
5 *
6 * ask every command is put to you before it runs, and reaching the web
7 * is put to you once in each conversation
8 * guarded commands run; a turn that has read outside content loses the
9 * network and Daimond asks before reaching a page it chose
10 * bypass nothing is asked
11 *
12 * No rung moves the fence, the system-call filter, a Diamond's folders or the
13 * journal — those are the compartment and the record, and a mode that could
14 * switch one off would not be a permission mode. The wording that says so to
15 * the user lives in i18n/en.js under `permmode.`, and the wording that says it
16 * to the model lives in Rust (src/prompts.rs), so neither can drift alone.
17 *
18 * A SECOND AXIS lives in the same popover and is not a fourth rung: the rungs
19 * are one setting for the whole app, and whether ONE CHAT's commands may reach
20 * the network is that chat's own state. It is here rather than in a chip of its
21 * own because the button already claims this ground -- its hover text promised
22 * "what Daimond does without asking", which is precisely this question -- and a
23 * second permissions control beside one making that promise is two answers to
24 * one thing. See `netState`/`setNet`.
25 *
26 * Two rules about the surface, and they pull opposite ways on purpose:
27 *
28 * VISIBLE the mode is a word in the chat header, beside the model. A mode
29 * you have to remember is one you will be wrong about, and being
30 * wrong about this one matters.
31 * QUIET bypass explains itself ONCE, the first time it is chosen, and
32 * never again. A bypass that keeps interrupting is not a bypass.
33 */
34(function () {
35 'use strict';
36
37 var LS_MODE = 'daimond-permission-mode';
38 var LS_ACK = 'daimond-permission-bypass-ack';
39 // The STANDING answer to the network question: '' asks once in each chat,
40 // 'allow' and 'refuse' answer it in advance and for good.
41 //
42 // It is persisted, and that is the whole point of it. The answer itself lives on
43 // a chat's engine object, which is built per chat and does not survive a reload
44 // -- so "you can grant it and not be interrupted again" was true only of a chat
45 // you had already gone into the menu to grant it in. Reported, in those words:
46 // "I thought we got rid of this bullshit!" It was not got rid of; it was made
47 // answerable. This is what gets rid of it.
48 var LS_NET = 'daimond-net-standing';
49
50 // THIS COMPUTER's autonomous posture: whether it may finish work dispatched to
51 // it while nobody is here, reaching the web, acting on pages and running
52 // commands on its own. Device-local by design and never synced -- arming one
53 // machine must not arm another -- so it is a plain localStorage key like the
54 // rung above it, not per-chat and not per-Diamond. The reader on the other side
55 // is `autonomousPosture()` in daimond.js, which reaches this SAME key by this
56 // SAME name; the two must not drift.
57 var LS_AUTO = 'daimond-autonomous-posture';
58
59 // THIS COMPUTER's step-away posture: whether a turn it is running is handed to
60 // another awake device when this one is stepped away from — the lid closes, the
61 // tab is closed. Device-local and never synced, the same rule as the autonomous
62 // posture above. The reader on the other side is `handoffWhenAway()` in
63 // daimond.js, which reaches this SAME key by this SAME name; the two must not
64 // drift.
65 var LS_HANDOFF = 'daimond-handoff-when-away';
66
67 // ── The account-level permission POLICY that TRAVELS ─────────
68 //
69 // The rung (LS_MODE) and the scope grants below are a fact about the ACCOUNT,
70 // not about this browser: a gated tool's egress happens at the gateway on the
71 // account's own credit, so consenting to it is the account's decision, and a
72 // runner device that inherits the policy must NOT put the question again. This
73 // is the half of the permission state that rides in the sync parcel -- see
74 // `snapshotPolicy`/`adoptPolicy` here and the `perms` field in daimond.js's
75 // collectSync/applySync.
76 //
77 // WHAT STAYS MACHINE-LOCAL AND NEVER TRAVELS is read a few lines up and must
78 // stay there: LS_ACK (a per-device safety acknowledgement of bypass), LS_NET
79 // (whether a COMMAND on THIS machine keeps its network after reading a
80 // stranger's words), LS_AUTO and LS_HANDOFF (this one machine's postures).
81 // Those are machine trust, and staying on the machine is the whole of their
82 // contract -- arming one computer must not arm another.
83 var LS_MODE_AT = 'daimond-permission-mode-at'; // when the rung was last chosen here (ms).
84 var LS_SCOPES = 'daimond-permission-scopes'; // account scope grants: { id: { at, on } }.
85
86 // The scopes whose action happens at the gateway on the account. Reading the
87 // web (web_fetch/web_search) is the one gated act carried today. Named here so
88 // the snapshot is a fixed, sorted set and a scope a build does not know is
89 // dropped on the way in rather than carried as a member with no reader.
90 var ACCOUNT_SCOPES = ['reading'];
91
92 function ms(v) {
93 return (typeof v === 'number' && isFinite(v) && v > 0) ? Math.floor(v) : 0;
94 }
95
96 /// Tell the sync engine the policy moved, so the change travels without waiting
97 /// for the next turn to end. Best-effort: a device with no sync up loses
98 /// nothing, the next ordinary round carries it.
99 function nudge() {
100 try { if (window.DaimondSync && DaimondSync.nudge) DaimondSync.nudge(); }
101 catch (e) { /* sync is not up on this device */ }
102 }
103
104 /// This device's stamp for the rung, or 0 when it has never chosen one -- so a
105 /// device on the factory default never overrides another device's real choice.
106 function modeAt() {
107 var raw = 0;
108 try { raw = Number(localStorage.getItem(LS_MODE_AT) || 0); } catch (e) { raw = 0; }
109 return ms(raw);
110 }
111
112 /// The account scope grants this device holds, as { id: { at, on } }, cleaned
113 /// to the scopes this build knows and keyed for a sorted read. Absent is none.
114 function scopes() {
115 var raw = {};
116 try { raw = JSON.parse(localStorage.getItem(LS_SCOPES) || '{}') || {}; }
117 catch (e) { raw = {}; }
118 var out = {};
119 ACCOUNT_SCOPES.forEach(function (id) {
120 var r = raw[id];
121 if (r && typeof r === 'object' && ms(r.at) > 0) {
122 out[id] = { at: ms(r.at), on: r.on ? 1 : 0 };
123 }
124 });
125 return out;
126 }
127
128 function writeScopes(map) {
129 try { localStorage.setItem(LS_SCOPES, JSON.stringify(map)); }
130 catch (e) { /* private mode: nothing to persist */ }
131 }
132
133 /// Does the account's standing policy grant this scope? Read at the consent
134 /// site (egressAllowed in daimond.js): a yes here is what lets a runner skip a
135 /// prompt for a scope the user has already granted on the account.
136 function scopeGranted(id) {
137 var s = scopes()[id];
138 return !!(s && s.on);
139 }
140
141 /// Set (or clear) an account-level scope grant, stamped now, and nudge it out.
142 /// The stamp is what lets a later change on either device win the merge.
143 function grantScope(id, on) {
144 if (ACCOUNT_SCOPES.indexOf(id) < 0) return false;
145 var map = scopes();
146 // Strictly forward, even inside one millisecond: the stamp is the whole of
147 // what the freshest-wins merge compares, so two grants a tick apart must not
148 // read as equal and let the wrong one stand.
149 var at = Math.max(Date.now(), ms(map[id] && map[id].at) + 1);
150 map[id] = { at: at, on: on === false ? 0 : 1 };
151 writeScopes(map);
152 nudge();
153 return true;
154 }
155
156 /// The account-level permission policy, for the sync parcel.
157 ///
158 /// DETERMINISTIC by construction -- a fixed field order and the scopes sorted
159 /// by key -- so an unchanged policy serialises to the same bytes and the
160 /// push-skip in sync.js still holds. NOTHING HERE STAMPS: the stamps are
161 /// written when a choice is made (`set`, `grantScope`), never when the parcel
162 /// is packed, or a quiet device would re-push the same policy for ever.
163 function snapshotPolicy() {
164 var out = { v: 1, mode: current, mode_at: modeAt(), scopes: {} };
165 var src = scopes();
166 Object.keys(src).sort().forEach(function (id) { out.scopes[id] = src[id]; });
167 return out;
168 }
169
170 /// Merge an account policy from another device.
171 ///
172 /// Freshest-wins PER FACT: the rung moves only when the arriving `mode_at` is
173 /// strictly later than ours, and each scope moves only when its own `at` is.
174 /// Nothing here stamps -- a policy this device already agrees with moves
175 /// nothing, so the next parcel it packs is unchanged (the `touchSelfDevice`
176 /// trap that had two devices pushing at each other). A parcel with no `perms`
177 /// -- a device that predates this -- is a no-op.
178 function adoptPolicy(remote) {
179 if (!remote || typeof remote !== 'object') return;
180 var rmAt = ms(remote.mode_at);
181 if (rmAt > modeAt()) {
182 // A rung this build knows, later than ours, and actually different: push
183 // it into the engine (the only copy that decides anything) and, only if
184 // that took, record it. A stamp taken forward without the rung going in
185 // would strand the page saying one thing while the engine ran another.
186 if (MODES.indexOf(remote.mode) >= 0 && remote.mode !== current) {
187 if (push(remote.mode)) {
188 current = remote.mode;
189 save(remote.mode);
190 try { localStorage.setItem(LS_MODE_AT, String(rmAt)); } catch (e) { /* private */ }
191 draw();
192 if (typeof cfg.onChange === 'function') cfg.onChange(remote.mode);
193 }
194 } else {
195 // Their stamp is later but the rung is the one we already hold (or a
196 // name we do not know): take the stamp so the same record is not
197 // re-adopted on every pull, but leave the engine alone.
198 try { localStorage.setItem(LS_MODE_AT, String(rmAt)); } catch (e) { /* private */ }
199 }
200 }
201 var mine = scopes();
202 var rem = (remote.scopes && typeof remote.scopes === 'object') ? remote.scopes : {};
203 var moved = false;
204 ACCOUNT_SCOPES.forEach(function (id) {
205 var r = rem[id];
206 if (!r || typeof r !== 'object') return;
207 var rAt = ms(r.at);
208 if (rAt > 0 && rAt > ms(mine[id] && mine[id].at)) {
209 mine[id] = { at: rAt, on: r.on ? 1 : 0 };
210 moved = true;
211 }
212 });
213 if (moved) writeScopes(mine);
214 }
215
216 /// The rungs, in the order they are offered: strictest first, so the list
217 /// reads as a ladder and the last row is the one that gives most away.
218 var MODES = ['ask', 'guarded', 'bypass'];
219 var FALLBACK = 'guarded';
220
221 var cfg = {}; // { apply, confirm, notice, onChange }
222 var current = FALLBACK;
223 var pop, chip, chipTxt;
224
225 function t(k, v) {
226 return (window.DaimondI18n ? DaimondI18n.t(k, v) : k);
227 }
228
229 /// `t`, but with an English fallback for a key the tables do not carry yet.
230 ///
231 /// The autonomous-posture strings below are authored here rather than in
232 /// `i18n/en.js`, so `t` would return the key and warn. This gives the plain
233 /// English while leaving the keys ready for a translator, exactly as the
234 /// worker-consent dialogs in `daimond.js` do with their own `tOr`.
235 function tOr(k, fallback, v) {
236 var s = t(k, v);
237 if (s !== k) return s;
238 if (!v) return fallback;
239 return String(fallback).replace(/\{(\w+)\}/g, function (whole, name) {
240 return v[name] != null ? String(v[name]) : whole;
241 });
242 }
243
244 /// Does THIS computer hold the autonomous posture? Off by silence, false on any
245 /// read error, for the reason `load()` falls back to the careful rung.
246 function autoOn() {
247 try { return localStorage.getItem(LS_AUTO) === '1'; }
248 catch (e) { return false; }
249 }
250
251 /// Set it, or clear it. Off is stored as removal, so a machine that was never
252 /// armed and one that was disarmed read the same absent key.
253 function setAuto(on) {
254 try {
255 if (on) localStorage.setItem(LS_AUTO, '1');
256 else localStorage.removeItem(LS_AUTO);
257 } catch (e) { /* private mode: nothing to persist, and off is the safe read */ }
258 }
259
260 /// Does THIS computer hand its turns off when stepped away from? Off by silence,
261 /// false on any read error, the same careful default as `autoOn`.
262 function handoffOn() {
263 try { return localStorage.getItem(LS_HANDOFF) === '1'; }
264 catch (e) { return false; }
265 }
266
267 /// Set it, or clear it — removal for off, so armed-never and disarmed read alike.
268 function setHandoff(on) {
269 try {
270 if (on) localStorage.setItem(LS_HANDOFF, '1');
271 else localStorage.removeItem(LS_HANDOFF);
272 } catch (e) { /* private mode: nothing to persist, and off is the safe read */ }
273 }
274
275 function label(name) { return t('permmode.' + name); }
276 function blurb(name) { return t('permmode.' + name + '_blurb'); }
277
278 /// What this chat's network state says, as a sentence.
279 ///
280 /// A switch of LITERAL keys and not `t('permmode.net_' + state)`, because
281 /// `i18ncheck` cannot follow a key a call site builds: a composed one would
282 /// have to be declared indirect, and five sentences that no sweep can see are
283 /// five sentences that quietly stop being translated.
284 function netSays(state) {
285 switch (state) {
286 case 'open': return t('permmode.net_open');
287 case 'cut': return t('permmode.net_cut');
288 case 'allowed': return t('permmode.net_allowed');
289 case 'refused': return t('permmode.net_refused');
290 default: return '';
291 }
292 }
293
294 /// This chat's network state, or '' where no chat can be asked -- before the
295 /// engine exists, or on a surface that holds no conversation.
296 ///
297 /// Bypass is answered here rather than in the wasm, which correctly reports
298 /// `open`: the rung withholds nothing, so there is nothing to grant, and a
299 /// button offering to grant it would do nothing and say it had.
300 function netState() {
301 if (current === 'bypass') return 'bypass';
302 if (typeof cfg.netGet !== 'function') return '';
303 var s = '';
304 try { s = String(cfg.netGet() || ''); } catch (e) { s = ''; }
305 // A chat with no engine yet has read nothing, which is what a fresh one with
306 // an engine also reports. Returning '' would hide the section on exactly the
307 // new chat where somebody wants to see what they have standing.
308 return s || 'open';
309 }
310
311 /// What was saved, or the guarded rung. A stored value this build does not
312 /// know is NOT rounded to the nearest thing: it falls back, because the safe
313 /// reading of "I do not recognise that" is "give them the careful one".
314 function load() {
315 var raw = '';
316 try { raw = localStorage.getItem(LS_MODE) || ''; } catch (e) { raw = ''; }
317 return MODES.indexOf(raw) >= 0 ? raw : FALLBACK;
318 }
319
320 function save(name) {
321 try { localStorage.setItem(LS_MODE, name); } catch (e) { /* private mode */ }
322 }
323
324 /// The standing answer, or '' where the user has not given one.
325 ///
326 /// A value this build does not know falls back to asking, for the same reason a
327 /// stored rung does: the safe reading of "I do not recognise that" is the careful
328 /// one, and here the careful one is to put the question.
329 function standing() {
330 var raw = '';
331 try { raw = localStorage.getItem(LS_NET) || ''; } catch (e) { raw = ''; }
332 return (raw === 'allow' || raw === 'refuse') ? raw : '';
333 }
334
335 /// Record it, and push it into every engine that already exists.
336 ///
337 /// BOTH HALVES. Storing it alone would leave every chat already open answering
338 /// the old way until it was reloaded, and setting the engines alone would lose it
339 /// on the next reload -- which is the defect this whole thing exists to fix.
340 function setStanding(v) {
341 try { localStorage.setItem(LS_NET, v); } catch (e) { /* private mode */ }
342 if (typeof cfg.netApplyAll === 'function') {
343 try { cfg.netApplyAll(v); } catch (e) { /* one engine gone is not a failure */ }
344 }
345 draw();
346 if (pop && !pop.hidden) render();
347 }
348
349 function acked() {
350 try { return localStorage.getItem(LS_ACK) === '1'; } catch (e) { return false; }
351 }
352
353 function ack() {
354 try { localStorage.setItem(LS_ACK, '1'); } catch (e) { /* private mode */ }
355 }
356
357 /// Push the rung into the wasm, which is the only copy that decides anything.
358 ///
359 /// If it will not take — an older wasm without the setter, a name it refuses
360 /// — the JavaScript copy is put BACK to whatever the wasm still holds. A page
361 /// showing "Bypass" over an engine running guarded is worse than either.
362 function push(name) {
363 if (typeof cfg.apply !== 'function') return false;
364 try { cfg.apply(name); return true; }
365 catch (e) { return false; }
366 }
367
368 function draw() {
369 if (chipTxt) chipTxt.textContent = label(current);
370 if (chip) {
371 chip.dataset.mode = current;
372 // Accent, not alarm. Bypass is the rung many people will live in, so
373 // it is marked as "not the default" rather than scolded — and the word
374 // carries the state regardless, so nothing rests on the colour.
375 chip.classList.toggle('accent', current === 'bypass');
376 // The hover names what THIS rung does. It used to name the category --
377 // "Permission mode: what Daimond does without asking" -- which is the one
378 // thing somebody hovering a button marked Guarded can already see, while
379 // the sentence that answers them sat a click away in the popover.
380 chip.title = label(current) + ' — ' + blurb(current);
381 // A chat whose commands have lost the network says so on the button,
382 // because it is a state that changes what a command can do and nothing
383 // on screen showed it. The dot was already in the markup doing nothing.
384 var ns = netState();
385 var cut = (ns === 'cut' || ns === 'refused');
386 chip.classList.toggle('net-cut', cut);
387 chip.setAttribute('aria-label', t('permmode.chip_aria', { mode: label(current) })
388 + (cut ? ' ' + t('permmode.net_cut_mark') : ''));
389 }
390 var row = document.getElementById('astat-hand');
391 if (row) {
392 row.innerHTML = '';
393 var dot = document.createElement('span');
394 dot.className = 'astat-dot ' + (current === 'bypass' ? 'warn' : 'ok');
395 var val = document.createElement('span');
396 val.className = 'astat-val';
397 val.textContent = t('permmode.astat', { mode: label(current) });
398 row.appendChild(dot);
399 row.appendChild(val);
400 row.title = t('permmode.chip_help');
401 row.onclick = function () { open(row); };
402 }
403 if (pop && !pop.hidden) render();
404 }
405
406 /// Move to a rung. Bypass explains itself the first time and never again.
407 async function set(name) {
408 if (MODES.indexOf(name) < 0) return false;
409 if (name === current) { close(); return true; }
410 if (name === 'bypass' && !acked()) {
411 var ok = await cfg.confirm(
412 t('permmode.bypass_body'),
413 t('permmode.bypass_ok'),
414 { title: t('permmode.bypass_title'), danger: false });
415 if (!ok) { draw(); return false; }
416 ack();
417 }
418 if (!push(name)) {
419 // The engine would not take it, so nothing changed. Say so rather than
420 // drawing a mode that is not in force.
421 if (typeof cfg.notice === 'function') cfg.notice(t('permmode.failed'));
422 draw();
423 return false;
424 }
425 current = name;
426 save(name);
427 // Stamp WHEN this rung was chosen, so the account policy can travel and a
428 // later choice on either device wins the merge. Written here, where a real
429 // choice is made, and never in `snapshotPolicy` -- see the note there.
430 try { localStorage.setItem(LS_MODE_AT, String(Date.now())); } catch (e) { /* private mode */ }
431 draw();
432 close();
433 nudge();
434 if (typeof cfg.onChange === 'function') cfg.onChange(name);
435 return true;
436 }
437
438 // ── The picker ──────────────────────────────────────────────
439 // A `.pop`, like the appearance menu and the panel gallery, so it dismisses
440 // on Escape and on a click outside exactly as they do — and a radio group
441 // inside it, so the arrow keys work without a line of code.
442
443 function render() {
444 pop.innerHTML = '';
445 // The way out, in the corner every other closer in the app holds. This
446 // popover is 359px wide on a 390px phone and left 8px of screen to its
447 // right to tap: Escape and a click outside are not enough on their own.
448 if (window.DaimondCloser) {
449 pop.appendChild(DaimondCloser.head(t('permmode.title'), { onClose: close }));
450 }
451 var h = document.createElement('div');
452 h.className = 'pop-head';
453 h.textContent = t('permmode.lead');
454 pop.appendChild(h);
455 MODES.forEach(function (name) {
456 var row = document.createElement('label');
457 row.className = 'mode-row' + (name === current ? ' on' : '');
458 var r = document.createElement('input');
459 r.type = 'radio';
460 r.name = 'daimond-permission-mode';
461 r.value = name;
462 r.checked = name === current;
463 r.addEventListener('change', function () { set(name); });
464 var txt = document.createElement('span');
465 txt.className = 'mode-row-txt';
466 var nm = document.createElement('span');
467 nm.className = 'mode-row-name';
468 nm.textContent = label(name);
469 var bl = document.createElement('span');
470 bl.className = 'mode-row-blurb';
471 bl.textContent = blurb(name);
472 txt.appendChild(nm);
473 txt.appendChild(bl);
474 row.appendChild(r);
475 row.appendChild(txt);
476 pop.appendChild(row);
477 });
478 var foot = document.createElement('p');
479 foot.className = 'pop-note';
480 foot.textContent = t('permmode.never');
481 pop.appendChild(foot);
482 renderAutonomous();
483 renderStepAway();
484 renderNet();
485 }
486
487 /// This computer's autonomous posture, under a head of its own.
488 ///
489 /// It is app-wide like the rungs and device-local like them, so it sits above
490 /// the per-chat network section and below the ladder. Not a fourth rung and not
491 /// a per-chat control: it is one machine's standing decision to get on with work
492 /// dispatched to it while nobody is here. Off until it is turned on, and the
493 /// body says in plain words what a yes lets the machine do and what bounds it --
494 /// the account's provider and Daimond credit, not a dialog.
495 function renderAutonomous() {
496 var head = document.createElement('div');
497 head.className = 'pop-head';
498 head.textContent = tOr('autonomous.head', 'Work on its own when you’re away');
499 pop.appendChild(head);
500 var body = document.createElement('p');
501 body.className = 'pop-note';
502 body.textContent = tOr('autonomous.body',
503 'Let this computer finish tasks you’ve dispatched to it without asking — it '
504 + 'will reach the web, act on pages and run commands on its own. The limit '
505 + 'is your provider and Daimond credit. This is set for THIS computer only '
506 + 'and is off until you turn it on.');
507 pop.appendChild(body);
508 var row = document.createElement('label');
509 row.className = 'dlg-tick auto-row';
510 var box = document.createElement('input');
511 box.type = 'checkbox';
512 box.className = 'dlg-tick-box';
513 box.checked = autoOn();
514 box.setAttribute('aria-label', tOr('autonomous.switch', 'Work unattended on this computer'));
515 box.addEventListener('change', function () { setAuto(box.checked); });
516 var say = document.createElement('span');
517 say.textContent = tOr('autonomous.switch', 'Work unattended on this computer');
518 row.appendChild(box);
519 row.appendChild(say);
520 pop.appendChild(row);
521 }
522
523 /// This computer's step-away hand-off, under a head of its own.
524 ///
525 /// App-wide and device-local like the autonomous posture it sits beside, and a
526 /// different promise: not "work while I am gone" but "if I close this while a
527 /// turn is running, move it to a device that is awake so it finishes there and
528 /// syncs back", rather than leaving it interrupted until this one returns. Off
529 /// until it is turned on, and for THIS computer only.
530 function renderStepAway() {
531 var head = document.createElement('div');
532 head.className = 'pop-head';
533 head.textContent = tOr('handoff.head', 'Hand off when you step away');
534 pop.appendChild(head);
535 var body = document.createElement('p');
536 body.className = 'pop-note';
537 body.textContent = tOr('handoff.body',
538 'If you close this computer while a turn is running, hand it to another '
539 + 'device that is awake so it finishes there and syncs back, instead of '
540 + 'waiting for you to return. This is set for THIS computer only and is '
541 + 'off until you turn it on.');
542 pop.appendChild(body);
543 var row = document.createElement('label');
544 row.className = 'dlg-tick auto-row';
545 var box = document.createElement('input');
546 box.type = 'checkbox';
547 box.className = 'dlg-tick-box';
548 box.checked = handoffOn();
549 box.setAttribute('aria-label', tOr('handoff.switch', 'Hand my turns to another device when I step away'));
550 box.addEventListener('change', function () { setHandoff(box.checked); });
551 var say = document.createElement('span');
552 say.textContent = tOr('handoff.switch', 'Hand my turns to another device when I step away');
553 row.appendChild(box);
554 row.appendChild(say);
555 pop.appendChild(row);
556 }
557
558 /// This chat's own network, under the rungs and under a head of its own.
559 ///
560 /// The head is what carries the scope: everything above it is the whole app
561 /// and everything below it is this conversation, and without that line the
562 /// section reads as a fourth rung -- which it is not, and which would make a
563 /// per-chat state look like a setting that outlives the chat.
564 ///
565 /// Nothing is drawn where there is no chat to answer for.
566 function renderNet() {
567 var state = netState();
568 if (!state) return;
569 var head = document.createElement('div');
570 head.className = 'pop-head';
571 // It said "This chat", and that became false the moment the control below it
572 // became a STANDING answer: the sentence describes this conversation, the
573 // three choices govern every one of them. A head naming one scope over a
574 // section holding both is the kind of label that teaches somebody the wrong
575 // thing and is never corrected. It names the subject instead, and the choices
576 // say their own scope -- "Ask once per chat" is a rule about chats, not a
577 // state of one.
578 //
579 // AND THEN IT SAID "The network", WHICH IS WIDER THAN WHAT IT GOVERNS. This
580 // section answers ONE question: whether a COMMAND run on the user's machine
581 // keeps its network after the turn has read something written by somebody
582 // else. `daimond.js`'s `run_net` branch is the only reader of the standing
583 // answer, `net_step` in src/tools.rs is the only thing that consults it, and
584 // neither is on the path a page fetch takes. Every sentence under this head
585 // already said "Commands"; the head did not, and a reader who set it to
586 // "Always allow" was reasonably surprised to be asked about the next
587 // `web_fetch` anyway. Reported by the owner in those words.
588 //
589 // The label was one bug and the behaviour was another. The head is right and
590 // stays: this section really is about commands, and naming it for the whole
591 // network was how a reader came to expect it to answer for page fetches too.
592 // The rest of that report was answered on 2026-08-27, at the other door: a
593 // page fetch is now asked once per conversation instead of once per site.
594 // See the note beside `permmode.net_not_fetch` below.
595 head.textContent = t('permmode.net_head');
596 pop.appendChild(head);
597 var line = document.createElement('p');
598 line.className = 'pop-note net-now';
599 line.textContent = (state === 'bypass') ? t('permmode.net_bypass') : netSays(state);
600 pop.appendChild(line);
601 // Bypass withholds nothing, so there is nothing to grant and no control.
602 if (state === 'bypass') return;
603 // THREE CHOICES AND NOT A TOGGLE, because a toggle has no way back to the
604 // default. A two-state button would let a user leave "ask me" and never
605 // return to it -- the same no-way-back this section was built to end, rebuilt
606 // one level up. They are chips on one row rather than a second ladder: the
607 // rungs above are a policy with reasons, this is one answer with three values.
608 var now = standing();
609 var row = document.createElement('div');
610 row.className = 'net-row';
611 [['', 'permmode.net_each'],
612 ['allow', 'permmode.net_always'],
613 ['refuse', 'permmode.net_never']].forEach(function (pair) {
614 var b = document.createElement('button');
615 b.type = 'button';
616 b.className = 'chip-btn net-opt' + (pair[0] === now ? ' on' : '');
617 b.setAttribute('aria-pressed', pair[0] === now ? 'true' : 'false');
618 // Four literal keys, for the reason `netSays` is written the way it is.
619 b.textContent = pair[1] === 'permmode.net_each' ? t('permmode.net_each')
620 : pair[1] === 'permmode.net_always' ? t('permmode.net_always')
621 : t('permmode.net_never');
622 b.addEventListener('click', function () { setStanding(pair[0]); });
623 row.appendChild(b);
624 });
625 pop.appendChild(row);
626 // AND WHAT THIS IS NOT, said here rather than left to be discovered by
627 // being asked. A page Daimond fetches for itself is a different act with a
628 // different door -- `egressAllowed`, and `web_step` in src/tools.rs, which
629 // ask ONCE IN EACH CONVERSATION and then cover every site until that
630 // conversation ends.
631 //
632 // THE SENTENCE THAT STOOD HERE SAID "asks per SITE and remembers a yes for
633 // that site only", and it was true when it was written and false a day
634 // later. The owner ruled on 2026-08-27 that asking about every new site was
635 // the defect rather than the label: "I should only be asked once in a
636 // session ... for permission to access ANY (not a specific website)." The
637 // note is kept because a line here that describes the other door is exactly
638 // the kind of line that goes quietly out of date, and this one already has
639 // once.
640 //
641 // What has NOT changed is that this control does not govern that door. It
642 // answers one question -- whether a COMMAND on the user's machine keeps its
643 // network after the turn has read a stranger's words -- and a yes here is
644 // still not a yes there. `net_step` reads `net_consent` and nothing else;
645 // `web_step` reads `web_consent` and nothing else.
646 var also = document.createElement('p');
647 also.className = 'pop-note';
648 also.textContent = t('permmode.net_not_fetch');
649 pop.appendChild(also);
650 }
651
652 function open(anchor) {
653 if (!pop) return;
654 if (!pop.hidden) { close(); return; }
655 // The chip too, and not only the popover about to be drawn over it. Opening
656 // the menu is the one moment the state is certainly being read, and a button
657 // left saying the opposite of the panel hanging off it is worse than either.
658 draw();
659 render();
660 pop.hidden = false;
661 if (chip) chip.setAttribute('aria-expanded', 'true');
662 var r = (anchor || chip).getBoundingClientRect();
663 pop.style.top = (r.bottom + 6) + 'px';
664 var left = Math.min(r.left, window.innerWidth - pop.offsetWidth - 8);
665 pop.style.left = Math.max(8, left) + 'px';
666 var first = pop.querySelector('input[type=radio]:checked') || pop.querySelector('input');
667 if (first) first.focus();
668 }
669
670 function close() {
671 if (!pop || pop.hidden) return;
672 pop.hidden = true;
673 if (chip) {
674 chip.setAttribute('aria-expanded', 'false');
675 try { chip.focus(); } catch (e) { /* gone from the page */ }
676 }
677 }
678
679 /// Wire the surfaces and put the saved rung into the engine.
680 ///
681 /// `apply` is the wasm setter, which only `daimond.js` can reach: this file
682 /// is a classic script and the wasm is a module. `confirm` and `notice` are
683 /// the app's own dialog and toast, for the same reason.
684 ///
685 /// `netGet` and `netSet` reach the CURRENT chat's engine, which only the app
686 /// knows -- this file has no notion of which conversation is on screen, and
687 /// must not acquire one: a permission surface that picked its own subject
688 /// could answer for a chat the user is not looking at.
689 function init(opts) {
690 cfg = opts || {};
691 chip = document.getElementById('hand-mode-chip');
692 chipTxt = document.getElementById('hand-mode-chip-txt');
693 pop = document.getElementById('hand-mode-pop');
694 current = load();
695 // The engine is the authority. If the saved rung will not go in, the page
696 // shows the guarded one the wasm is actually still in.
697 if (!push(current)) current = FALLBACK;
698 if (chip) chip.addEventListener('click', function (e) { e.stopPropagation(); open(chip); });
699 document.addEventListener('keydown', function (e) {
700 if (e.key === 'Escape') close();
701 });
702 document.addEventListener('click', function (e) {
703 if (!pop || pop.hidden) return;
704 var path = e.composedPath ? e.composedPath() : null;
705 var inside = path ? (path.indexOf(pop) >= 0 || path.indexOf(chip) >= 0)
706 : (pop.contains(e.target) || (chip && chip.contains(e.target)));
707 if (!inside) close();
708 });
709 if (window.DaimondI18n) DaimondI18n.onChange(draw);
710 draw();
711 }
712
713 window.DaimondHandMode = {
714 init: init,
715 /// The standing answer to the network question, for `ensureApp` to put into
716 /// each new engine. '' means the engine is left to ask.
717 standingNet: standing,
718 /// Record a standing answer from somewhere other than these chips.
719 ///
720 /// The dialog is the caller. A person who answers YES has consented, in the
721 /// clearest way the app has, and asking them the same question in the next
722 /// chat treats that answer as though it had not been given -- which is what
723 /// happened, and what was reported three times. One yes is enough.
724 setStandingNet: setStanding,
725 /// Redraw the chip from what is in force NOW.
726 ///
727 /// The rung only moves when this file moves it, so the chip could always
728 /// draw itself. This chat's network cannot: a turn that reads a page marks
729 /// the chat while the popover is shut, and the mark on the button is the
730 /// only thing on screen that says so. The app calls this where the
731 /// conversation on screen changes and where a turn ends.
732 refresh: draw,
733 get: function () { return current; },
734 set: set,
735 list: function () {
736 return MODES.map(function (n) { return { name: n, label: label(n) }; });
737 },
738 // ── The account-level policy that rides the sync parcel ──────
739 /// The policy this device holds, deterministic, for collectSync's `perms`.
740 snapshotPolicy: snapshotPolicy,
741 /// Merge a policy pulled from another device. Freshest-wins per fact.
742 adoptPolicy: adoptPolicy,
743 /// Does the account's standing policy grant this scope? Read at the consent
744 /// site, so a runner does not re-ask for what the account already allows.
745 scopeGranted: scopeGranted,
746 /// Grant (or clear) an account-level scope, stamped now and nudged out.
747 grantScope: grantScope,
748 };
749})();