sign-off-axi 0.4.8 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -172,7 +172,7 @@ the new page on a phone." \
172
172
 
173
173
  *Proof of a fix, with two captures. Pass, Fail, or Needs work.*
174
174
 
175
- Evidence lists passed proof only: an update whose linked run passed. A plain update like this links no run, so it waits in **Waiting on you** instead, as does a failed or unchecked run, each with its honest result and its verdicts and Dismiss, so the agent still gets an answer. Passed updates are grouped by the project named in the run's proof line, `verify-<project>: ...` (trimmed, compared without case); a proof line with no such prefix goes under **Other**.
175
+ Evidence lists every update, never in **Waiting on you**: a passed run shows a tick, and a plain update that links no run, a failed run or an unchecked run says its honest result, each with its verdicts and Dismiss on the update's own screen. **Proven today** counts every update filed today, each with its own result. Updates are grouped by the project named in the run's proof line, `verify-<project>: ...` (trimmed, compared without case); a proof line with no such prefix goes under **Other**.
176
176
 
177
177
  ### Explainer video, with chapters
178
178
 
@@ -283,6 +283,8 @@ sign-off-axi signin done \
283
283
  --key npm-login
284
284
  ```
285
285
 
286
+ To hide a card as the Dismiss button does, or bring it back: `sign-off-axi dismiss <key>` and `sign-off-axi restore <key>`.
287
+
286
288
  <picture>
287
289
  <source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-signin-dark.webp">
288
290
  <img src="docs/media/kind-signin.webp" alt="A sign-in card on top with an Open button, what it unlocks, who waits and the time left">
package/dist/cli.js CHANGED
@@ -28,8 +28,8 @@ const AGENTS = {
28
28
  'gemini-cli': { bin: 'gemini', dir: '.agents/skills', home: join(homedir(), '.gemini', 'skills') },
29
29
  };
30
30
  const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
31
- commands[18]:
32
- (none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, doctor, notify, validate, help
31
+ commands[20]:
32
+ (none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, dismiss, restore, doctor, notify, validate, help
33
33
  board: the nearest .sign-off/ board (the walk's bounds and SIGN_OFF_HOME: README, "Test runs never touch your board"), else ~/.sign-off/decisions.json; a board file given always wins
34
34
  flags: --full and --json on list, answers and wait, --events on wait, --json on status, --filer on post, raise and wait, --help on every command, -v/--version
35
35
  examples:
@@ -43,6 +43,7 @@ examples:
43
43
  sign-off-axi post .sign-off/decisions.json --type report --title "Sign-in now takes 2 steps" --about "Why the code step moved first." --view mocks/sign-in-flow/index.html --preview mocks/sign-in-flow/preview.svg
44
44
  sign-off-axi post --type signin --key npm-login --service npm --link https://example.com/login --unlocks "Publish the release" --waits "Main" --expires 15m
45
45
  sign-off-axi signin done --key npm-login
46
+ sign-off-axi dismiss ship-the-new-sign-in-screen-this-week
46
47
  sign-off-axi init
47
48
  sign-off-axi doctor
48
49
  sign-off-axi validate ask.json
@@ -248,6 +249,16 @@ Closes a sign-in card that post --type signin made: the sign-in is done, so the
248
249
  Run it when you see the sign-in worked. A card posted with --start also closes by itself when its command ends with exit 0. wait --key <key> also returns once it is done.
249
250
  examples:
250
251
  sign-off-axi signin done --key npm-login
252
+ `,
253
+ dismiss: `usage: sign-off-axi dismiss <key> [board.json]
254
+ Hides a card from the board, like its Dismiss button. The agent is not told, and an ask it waits on keeps waiting.
255
+ restore <key> [board.json] brings it back. Find the key with \`sign-off-axi list --full\`.
256
+ examples:
257
+ sign-off-axi dismiss ship-the-new-sign-in-screen-this-week
258
+ sign-off-axi restore ship-the-new-sign-in-screen-this-week
259
+ `,
260
+ restore: `usage: sign-off-axi restore <key> [board.json]
261
+ Brings back a card that dismiss hid. See \`sign-off-axi dismiss --help\`.
251
262
  `,
252
263
  report: `usage: sign-off-axi report init <folder>
253
264
  Writes a report page to fill: <folder>/index.html and its card picture <folder>/preview.svg. A file already there is kept.
@@ -502,7 +513,7 @@ function filerOf(values, session = false) {
502
513
  const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
503
514
  : `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
504
515
  /** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
505
- const waits = (d) => !d.dismissed && awaitsVerdict(d.type, d.answer) && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
516
+ const waits = (d) => d.type !== 'evidence' && !d.dismissed && awaitsVerdict(d.type, d.answer) && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
506
517
  /** The board's decisions with their latest answer; a missing board file is an empty board.
507
518
  * A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
508
519
  function board(file, say = warn) {
@@ -543,7 +554,7 @@ function list(args) {
543
554
  ];
544
555
  const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
545
556
  if (values.full) {
546
- return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
557
+ return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : d.type === 'evidence' ? 'shown' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
547
558
  recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
548
559
  }
549
560
  return toon({
@@ -972,6 +983,22 @@ function signin(args) {
972
983
  return toon({ board: tilde(file), closed: [{ key: d.key, status: saved ? 'done' : 'already done', service: d.title }] }, [`Run \`sign-off-axi list${on}\` to see what still waits on the user`]);
973
984
  });
974
985
  }
986
+ /** dismiss|restore <key> [board.json]: the board's Dismiss and Undo, for an agent. The same dismissed log the board writes, for a key that is on the board. */
987
+ function dismiss(restoring) {
988
+ const cmd = restoring ? 'restore' : 'dismiss';
989
+ return (args) => {
990
+ const at = args.findIndex((a) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json')), key = args[at];
991
+ if (at < 0)
992
+ throw fail(`${cmd} needs a card key`, 'VALIDATION_ERROR', `sign-off-axi ${cmd} <key> [board.json]`);
993
+ const { file, on } = parse(cmd, args.filter((_, i) => i !== at), {});
994
+ return locked(`${file}.lock`, () => {
995
+ if (!isKey(key) || !board(file).some((x) => x.key === key))
996
+ throw fail(`no card with key ${key} on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi list${on} --full\` to see the keys`);
997
+ appendDismissed(file, [key], !restoring);
998
+ return toon({ board: tilde(file), [restoring ? 'restored' : 'dismissed']: [key] }, [`Run \`sign-off-axi ${restoring ? 'dismiss' : 'restore'}${on} ${key}\` to undo`]);
999
+ });
1000
+ };
1001
+ }
975
1002
  /** For a program that feeds the board: the --from file, checked like any post, becomes the whole board, items and top-level fields. Answers stay.
976
1003
  * With a filer it replaces only that filer's items (and items with no filer it names by key); every other item and top-level field stays. */
977
1004
  function replace(file, named, on, from, filer) {
@@ -1101,7 +1128,7 @@ function postMedia(file, named, on, values, type, filer, message) {
1101
1128
  if (asMessage(had) !== canon({ ...message, media: sources.length ? sources.map((src) => sha256(readFileSync(src))) : undefined })) {
1102
1129
  throw fail(`key ${own} already holds a different message on ${tilde(file)}`, 'CONFLICT', 'Post it under a new key');
1103
1130
  }
1104
- return toon({ board: tilde(file), posted: [{ key: own, number: had.number, media: sources.length, title: had.title, status: 'already posted' }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`]);
1131
+ return toon({ board: tilde(file), posted: [{ key: own, number: had.number, media: sources.length, title: had.title, status: 'already posted' }] }, explainer ? [`Run \`sign-off-axi open${on}\` so the user can give a verdict`] : []);
1105
1132
  }
1106
1133
  const answers = readAnswers(file);
1107
1134
  if (message && own && Object.hasOwn(answers, own) && !awaitsVerdict(message.kind, answers[own]))
@@ -1157,7 +1184,7 @@ function postMedia(file, named, on, values, type, filer, message) {
1157
1184
  notifyEntered(file, named, before);
1158
1185
  if (explainer)
1159
1186
  return toon({ board: tilde(file), raised: [{ key, status: 'raised', title }] }, [`Run \`sign-off-axi open${on}\` so the user can watch and answer`, waitFor(on, filer, key)]);
1160
- return toon({ board: tilde(file), posted: [{ key, number, media: media.length, title, ...(message && { status: 'posted' }) }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`, waitFor(on, filer, key)]);
1187
+ return toon({ board: tilde(file), posted: [{ key, number, media: media.length, title, ...(message && { status: 'posted' }) }] });
1161
1188
  });
1162
1189
  }
1163
1190
  /** post --type report: the flags as one report message, posted as post --kind report posts it. A report with no page is refused before anything is read. */
@@ -1708,10 +1735,12 @@ const tokenMissing = (n) => n.muxr
1708
1735
  } })()
1709
1736
  : n.token_env && !process.env[n.token_env] ? `${n.token_env} is not set` : undefined;
1710
1737
  const NOTIFY_SENDER = `void (async()=>{const delays=[2000,10000,60000],f=process.env.SIGN_OFF_NOTIFY_TOKEN_FILE;for(let i=0;i<4;i++){try{const t=f?require('node:fs').readFileSync(f,'utf8').trim():process.env.SIGN_OFF_NOTIFY_TOKEN;if((await fetch(process.env.SIGN_OFF_NOTIFY_URL,{method:'POST',headers:{'content-type':'application/json',...(t&&{authorization:'Bearer '+t})},body:process.env.SIGN_OFF_NOTIFY_BODY,signal:AbortSignal.timeout(10000)})).ok)return}catch{}if(i<3)await new Promise(r=>setTimeout(r,delays[i]))}require('node:fs').appendFileSync(process.env.SIGN_OFF_NOTIFY_LOG,new Date().toISOString()+' notification send failed for card '+process.env.SIGN_OFF_NOTIFY_ID+'\\n')})();`;
1711
- /** The cards waiting on the user before a write: a card waiting after the write but not before it has entered Waiting on you. */
1738
+ /** Cards worth a notice: those waiting on the user, and a new evidence update (shown, never waiting). */
1739
+ const notifies = (d) => waits(d) || (d.type === 'evidence' && !d.dismissed);
1740
+ /** The cards that would notify before a write: one that notifies after the write but not before it is new to the user. */
1712
1741
  function waitingKeys(file) {
1713
1742
  try {
1714
- return new Set(board(file, () => { }).filter(waits).map((d) => d.key));
1743
+ return new Set(board(file, () => { }).filter(notifies).map((d) => d.key));
1715
1744
  }
1716
1745
  catch {
1717
1746
  return undefined;
@@ -1725,7 +1754,7 @@ function notifyEntered(file, named, before) {
1725
1754
  const so = dirname(named), notify = before ? readSetup(so).notify : undefined;
1726
1755
  if (!before || !notify || tokenMissing(notify))
1727
1756
  return;
1728
- const { items } = readItems(file), fresh = board(file, () => { }).filter((d) => waits(d) && !before.has(d.key) && !items.some((i) => i?.key === d.key && i.announced));
1757
+ const { items } = readItems(file), fresh = board(file, () => { }).filter((d) => notifies(d) && !before.has(d.key) && !items.some((i) => i?.key === d.key && i.announced));
1729
1758
  if (!fresh.length)
1730
1759
  return;
1731
1760
  const at = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
@@ -2788,7 +2817,7 @@ await runAxiCli({
2788
2817
  topLevelHelp: TOP_HELP,
2789
2818
  getCommandHelp: (cmd) => HELP[cmd],
2790
2819
  home: axi('list', list),
2791
- commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), signin: axi('signin', signin), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
2820
+ commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), signin: axi('signin', signin), dismiss: axi('dismiss', dismiss(false)), restore: axi('restore', dismiss(true)), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
2792
2821
  if (rest.length || (name && !HELP[name]))
2793
2822
  throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
2794
2823
  return name ? HELP[name] : TOP_HELP;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "One review board for evidence, quick yes/no decisions and retro changes.",
5
5
  "type": "module",
6
6
  "bin": { "sign-off-axi": "dist/cli.js" },
package/web/index.html CHANGED
@@ -414,6 +414,7 @@
414
414
  margin-top: 1rem; padding: .75rem 0 .5rem; background: var(--bg); }
415
415
  /* Answer takes the row on one line; when a long label leaves no room, Skip and Later move under it rather than squeeze it. */
416
416
  .decide-bar > * { white-space: nowrap; }
417
+ .decide-bar > .silent-line { white-space: normal; min-width: 0; } /* the stay-quiet line wraps; nowrap pushed it past a phone's edge in a wider font */
417
418
  .decide-bar .answer { flex: 1 1 auto; background: var(--action); color: var(--on-accent); font-weight: 400; }
418
419
  .decide-bar .later { display: inline-flex; flex: none; align-items: center; justify-content: center; min-height: 2.75rem; padding: 0 0.75rem; color: var(--accent); background: transparent; text-decoration: none; }
419
420
  .decide-bar .skip { flex: none; padding: 0 0.75rem; color: var(--muted); background: transparent; }
@@ -437,8 +438,9 @@
437
438
  .ev-card .t { font-size: 1.1875rem; font-weight: 600; line-height: 1.32; letter-spacing: -0.012em; }
438
439
  .ev-group h2 { font-size: 1.375rem; line-height: 1.27; font-weight: 700; letter-spacing: -0.012em; }
439
440
  .ev-card .m { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; color: var(--muted); font-size: 0.9375rem; }
440
- .ev-ok { display: inline-flex; align-items: center; gap: 0.25rem; color: var(--ok); }
441
- .ev-ok svg { width: 0.9375rem; height: 0.9375rem; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
441
+ .ev-ok, .ev-bad, .ev-warn { display: inline-flex; align-items: center; gap: 0.25rem; color: var(--ok); }
442
+ .ev-bad { color: var(--danger); } .ev-warn { color: var(--warn); } .ev-none { color: var(--muted); }
443
+ .ev-ok svg, .ev-bad svg, .ev-warn svg { width: 0.9375rem; height: 0.9375rem; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
442
444
  /* One update: the whole capture on its tile, what it proves in a line, what the check recorded, then its verdict. */
443
445
  #reader .ev-scroll { flex: 1; min-height: 0; overflow: auto; padding: 1rem 1.25rem 3rem; }
444
446
  .ev-item { display: grid; gap: 1rem; max-width: 42.5rem; margin: 0 auto; }
@@ -1180,9 +1182,10 @@
1180
1182
  }
1181
1183
  /** One update on the Evidence screen: its capture as the hero, what it proves and how it went; the whole card opens the update. */
1182
1184
  function evCard(d) {
1185
+ const result = RESULT[d.evidenceCard?.result];
1183
1186
  return el('a', { class: 'ev-card', href: `#evidence/${d.key}`, id: `item-${d.key}` }, el('span', { class: 'ev-media' }, ...hero(d)),
1184
1187
  el('span', { class: 't', dir: 'auto' }, headline(d)),
1185
- el('span', { class: 'm' }, el('span', { class: 'ev-ok' }, mark('ok'), 'Passed'), d.filed ? el('span', {}, stamp(d.filed)) : ''));
1188
+ el('span', { class: 'm' }, result ? el('span', { class: `ev-${result[0]}` }, mark(result[0]), result[2]) : el('span', { class: 'ev-none' }, 'Not run'), d.filed ? el('span', {}, stamp(d.filed)) : ''));
1186
1189
  }
1187
1190
  /** Evidence as its own screen: a card per passed update, grouped under the project its check names, in the order each project first shows;
1188
1191
  * proof that names no project goes under Other, last. An item put in this area that is not an update (a page, a diagram) keeps the card it always had. */
@@ -1368,13 +1371,10 @@
1368
1371
  const icon = (a) => { const s = el('span', { class: `icon i-${a}`, 'aria-hidden': 'true' }); s.innerHTML = `<svg viewBox="0 0 24 24"><path d="${ICONS[a]}"/></svg>`; return s; };
1369
1372
  // A design with only screen notes (answer 'screens') still waits for its design-wide verdict.
1370
1373
  const isAnswered = (d) => Object.hasOwn(state.answers, d.key) && !(d.type === 'design' && state.answers[d.key].answer === 'screens');
1371
- /** Each item lives in one area: answered items, then an evidence update that is not passed proof (it waits on you with its honest result), then the area the item names, then evidence updates, then what is only to read (explainers, pages with nothing to answer), then the open asks.
1374
+ /** Each item lives in one area: answered items, then the area the item names, then evidence updates (never an ask, whatever their result), then what is only to read (explainers, pages with nothing to answer), then the open asks.
1372
1375
  * A plan has no options but waits for its send-back. */
1373
- /** An update the Evidence list shows: passed proof only; every other update (no run, a failed or unfinished check) waits on you. */
1374
- const listed = (d) => d.evidenceCard?.result === 'RUN PASS';
1375
1376
  const area = (d) => {
1376
- const a = isAnswered(d) ? 'answered' : d.type === 'evidence' && !listed(d) ? 'waiting' : d.area ?? (d.type === 'evidence' ? 'evidence'
1377
- : state.readonly || d.type === 'explainer' || !(d.options.length || d.reply || d.type === 'signin' || d.type === 'plan' || d.kind === 'design') ? 'reports' : 'waiting');
1377
+ const a = isAnswered(d) ? 'answered' : d.type === 'evidence' ? 'evidence' : d.area ?? (state.readonly || d.type === 'explainer' || !(d.options.length || d.reply || d.type === 'signin' || d.type === 'plan' || d.kind === 'design') ? 'reports' : 'waiting');
1378
1378
  // Reports lists only what explains: a page to tap through, or a narrated video. Text alone (a receipt, a status line) is a note, folded under it.
1379
1379
  return a === 'reports' && !state.readonly && d.type !== 'explainer' && !reads(d) ? 'notes' : a;
1380
1380
  };
@@ -1431,7 +1431,7 @@
1431
1431
  const n = by.waiting.length, proven = filedToday(by.evidence).length, fresh = filedToday(by.reports).length;
1432
1432
  const soonest = by.waiting.map((d) => d.silent_by).filter(Boolean).sort((x, y) => Date.parse(x) - Date.parse(y))[0];
1433
1433
  const lines = [[n ? `${n} ${n === 1 ? 'decision needs' : 'decisions need'} you.` : 'Nothing needs you.', 1], [soonest && `The soonest closes at ${when(soonest)}.`],
1434
- [proven && `${proven} ${proven === 1 ? 'change' : 'changes'} shipped with proof.`, 1], [fresh && `${fresh} new ${fresh === 1 ? 'report is' : 'reports are'} waiting.`]];
1434
+ [proven && `${proven} ${proven === 1 ? 'update' : 'updates'} filed.`, 1], [fresh && `${fresh} new ${fresh === 1 ? 'report is' : 'reports are'} waiting.`]];
1435
1435
  if (lastSeen === undefined) try { lastSeen = localStorage.getItem(seenKey()); } catch { lastSeen = null; }
1436
1436
  return el('p', { class: 'card glance' }, lastSeen && !Number.isNaN(Date.parse(lastSeen)) ? el('span', { class: 'seen' }, `Since you last looked at ${when(lastSeen)}`) : '', ...lines.filter(([t]) => t).map(([t, strong]) => el(strong ? 'b' : 'span', {}, `${t} `)));
1437
1437
  }
@@ -1471,9 +1471,9 @@
1471
1471
  return el('div', { class: 'search', role: 'search', ...(query === null && { hidden: '' }) }, box, out);
1472
1472
  }
1473
1473
 
1474
- /** Proven today on Today: a swipeable row of today's passed updates, each with its capture; a wide screen sets them three across. */
1474
+ /** Proven today on Today: a swipeable row of today's updates, each with its own result and capture; a wide screen sets them three across. */
1475
1475
  function proven(items) {
1476
- const fresh = items.filter((d) => d.type === 'evidence' && d.filed && isToday(d.filed));
1476
+ const fresh = filedToday(items);
1477
1477
  return el('section', { class: 'i-evidence wide' }, el('div', { class: 'sec-head' }, el('h2', {}, 'Proven today'), items.length ? el('a', { href: '#evidence' }, 'See all') : ''),
1478
1478
  fresh.length ? el('div', { class: 'ev-scroll-row' }, ...fresh.slice(0, 3).map((d) => withX(d, evCard(d)))) : el('p', { class: 'empty' }, 'Nothing proven today yet.'));
1479
1479
  }
@@ -1975,7 +1975,9 @@
1975
1975
  });
1976
1976
  };
1977
1977
  if (d.type === 'signin') {
1978
- const signinPage = el('div', { class: 'decide' }, row(d, { x: false }), el('div', { class: 'decide-bar' }, skip || '', el('a', { class: 'later', href: '#waiting' }, 'Later'), xBtn(d, 'Dismiss')), decideLists(d, queue));
1978
+ const gone = xBtn(d, 'Dismiss');
1979
+ if (gone) gone.addEventListener('click', () => (location.hash = to));
1980
+ const signinPage = el('div', { class: 'decide' }, row(d, { x: false }), el('div', { class: 'decide-bar' }, skip || '', el('a', { class: 'later', href: '#waiting' }, 'Later'), gone), decideLists(d, queue));
1979
1981
  if (!state.readonly) swipe(signinPage);
1980
1982
  return signinPage;
1981
1983
  }
@@ -2077,7 +2079,7 @@
2077
2079
  ? 'How the tools work, step by step. Tap one to open it.' : 'Explainers you asked for, or that a decision needs.')
2078
2080
  : at === 'answered' && by.answered.length ? el('p', { class: 'lede' }, (() => { const n = by.answered.filter((d) => isToday(state.answers[d.key].at)).length; return `${n} ${n === 1 ? 'answer' : 'answers'} today. Each one has gone back to its agent.`; })())
2079
2081
  : at === 'designs' && by.designs.length ? el('p', { class: 'lede' }, 'Designs to look over. Approve one, or say what should change.')
2080
- : at === 'evidence' && by.evidence.length ? el('p', { class: 'lede' }, 'Passed proof that a change works, newest first.') : '', at === 'decide' ? '' : bulkBar(bulk));
2082
+ : at === 'evidence' && by.evidence.length ? el('p', { class: 'lede' }, 'Updates that show a change works, newest first.') : '', at === 'decide' ? '' : bulkBar(bulk));
2081
2083
  document.getElementById('board').replaceChildren(top, ...(at === 'waiting' && !state.readonly ? [filters(by.waiting), waitingPage(shown, hidden)]
2082
2084
  : at === 'decide' ? [decideAt < 0 ? (key ? el('p', { class: 'empty' }, 'Nothing to decide for that question.') : el('div', { class: 'decide' }, decideLists(null, decideQueue)))
2083
2085
  : decidePage(decideQueue[decideAt], decideQueue.length > 1 ? decideQueue[(decideAt + 1) % decideQueue.length] : undefined, decideQueue)]