@lovek4d/kickflip 0.5.3 → 0.6.0

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.
Files changed (85) hide show
  1. package/CONTEXT.md +69 -37
  2. package/SKILL.md +19 -13
  3. package/bin/kickflip.mjs +12 -12
  4. package/bin/wait-events.mjs +2 -2
  5. package/board/agents.mjs +2 -2
  6. package/board/arch-review.mjs +4 -4
  7. package/board/bin/board.mjs +156 -83
  8. package/board/build-script.mjs +2 -1
  9. package/board/domain.mjs +9 -7
  10. package/board/finish.mjs +4 -4
  11. package/board/headless.mjs +9 -5
  12. package/board/onboarding.mjs +11 -0
  13. package/board/prototype-base.mjs +1 -0
  14. package/board/prototype-page.mjs +1 -0
  15. package/board/public/app-arch.js +1 -1
  16. package/board/public/app-cards.js +27 -13
  17. package/board/public/app-header.js +6 -7
  18. package/board/public/app-lanes.js +15 -38
  19. package/board/public/app-needs.js +36 -31
  20. package/board/public/app-snapshot.js +43 -70
  21. package/board/public/app-threads.js +38 -46
  22. package/board/public/app.html +9 -4
  23. package/board/public/app.js +78 -32
  24. package/board/public/hub-kit.js +62 -2
  25. package/board/public/hub.css +2 -0
  26. package/board/public/new-task.js +8 -6
  27. package/board/public/progress-review.html +46 -0
  28. package/board/public/progress-review.js +158 -0
  29. package/board/public/prototype-kit.js +109 -4
  30. package/board/public/prototype-switcher.js +123 -29
  31. package/board/public/prototype.html +57 -3
  32. package/board/public/router.js +7 -6
  33. package/board/public/shell.js +64 -24
  34. package/board/public/sidebar.css +1 -0
  35. package/board/public/sidebar.js +16 -69
  36. package/board/public/style.css +108 -57
  37. package/board/public/ui-cards.js +61 -46
  38. package/board/public/ui-enums.js +1 -1
  39. package/board/public/ui-header.js +25 -16
  40. package/board/public/ui-hub.js +12 -1
  41. package/board/public/ui-links.js +7 -7
  42. package/board/public/ui-page-header.js +19 -7
  43. package/board/public/ui-patch.js +7 -33
  44. package/board/public/ui-progress-bar.js +28 -0
  45. package/board/public/ui-questions.js +25 -17
  46. package/board/public/ui-sidebar.js +25 -23
  47. package/board/public/ui-snapshot.js +43 -52
  48. package/board/public/ui-threads.js +57 -28
  49. package/board/public/ui.js +92 -17
  50. package/board/public/view-hub-settings.js +12 -58
  51. package/board/public/view-hub.js +2 -1
  52. package/board/public/view-notes.js +27 -7
  53. package/board/public/view-questions.js +90 -68
  54. package/board/public/view-settings.js +133 -15
  55. package/board/render-check.mjs +10 -0
  56. package/board/review.mjs +1 -1
  57. package/board/server.mjs +19 -19
  58. package/board/stop.mjs +1 -0
  59. package/board/store.mjs +27 -18
  60. package/board/sync.mjs +2 -0
  61. package/board/tickets.mjs +3 -3
  62. package/board/worktree.mjs +22 -21
  63. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  64. package/hub/board-repo.mjs +1 -1
  65. package/hub/boards.mjs +4 -4
  66. package/hub/front-door.mjs +1 -1
  67. package/hub/github.mjs +3 -3
  68. package/hub/hub.mjs +11 -11
  69. package/hub/incoming.mjs +2 -2
  70. package/hub/owner-checks.mjs +1 -1
  71. package/hub/pr-report.mjs +2 -2
  72. package/hub/review-send.mjs +1 -1
  73. package/hub/sessions.mjs +2 -2
  74. package/hub/share.mjs +1 -1
  75. package/hub/slack.mjs +2 -2
  76. package/hub/try.mjs +7 -7
  77. package/hub/upgrade.mjs +1 -1
  78. package/hub/usage.mjs +3 -3
  79. package/hub/views.mjs +1 -1
  80. package/package.json +1 -1
  81. package/shared/card-prs.mjs +1 -1
  82. package/shared/machine-gate.mjs +2 -0
  83. package/shared/prototype-frame.js +1 -0
  84. package/shared/usage-gate.mjs +1 -1
  85. package/board/public/ui-phase-bars.js +0 -17
@@ -3,7 +3,7 @@
3
3
  // app-core.js and app-threads.js.
4
4
  BoardView.part((B) => {
5
5
  const { $, store, state, markSeen, seen, toast, links } = B;
6
- const { esc, ago, REVIEW, reviewAge, unreadCount, workLines, BUILD_PHASES, phaseStrip, phaseMotion, heldUntilWords, holdPicker, linkText, mergeButton, installButton, tryButton, tryWords, waitOn, prName, BUTTON, LINK_ARROW } = BoardUI;
6
+ const { esc, escText, ago, REVIEW, reviewAge, unreadCount, workLines, BUILD_PHASES, phaseStrip, phaseMotion, heldUntilWords, holdPicker, linkText, mergeButton, installButton, tryButton, tryWords, waitOn, prName, BUTTON, LINK_ARROW } = BoardUI;
7
7
 
8
8
  const mainSheet = $('#mainLogSheet');
9
9
  const mainRetry = $('#mainLogRetry');
@@ -41,7 +41,7 @@ BoardView.part((B) => {
41
41
  if (!mainSheet.open || mainSheet.classList.contains('closing') || !main) return;
42
42
  const words = tryWords(main.kind);
43
43
  $('#mainLogTitle').textContent = `Main’s ${words.failed.toLowerCase()}`;
44
- $('#mainLogSource').innerHTML = `<code>.kickflip/build</code> stopped at main@<code>${esc(main.sha)}</code> · last lines of <code>data/try/main.log</code>`;
44
+ $('#mainLogSource').innerHTML = `<code>${esc(main.script)}</code> stopped at main@<code>${esc(main.sha)}</code> · last lines of <code>data/try/main.log</code>`;
45
45
  // Only a load begun after the POST resolved can settle this retry.
46
46
  const fresh = !postingMain && load !== null && (followMainAfter === null || load >= followMainAfter);
47
47
  if (fresh) {
@@ -215,7 +215,7 @@ BoardView.part((B) => {
215
215
  const pr = it.pr;
216
216
  const ctx = links(it.key);
217
217
  const rows = [];
218
- rows.push(['Wave', esc(it.waveTitle)]);
218
+ rows.push(['Wave', escText(it.waveTitle)]);
219
219
  if (pr) {
220
220
  const ci = pr.checks ? `${pr.checks.state}${pr.checks.fail ? ` (${pr.checks.fail} failing)` : pr.checks.pending ? ` (${pr.checks.pending} running)` : ''}` : 'unknown';
221
221
  if (pr.state === 'OPEN') {
@@ -275,8 +275,8 @@ BoardView.part((B) => {
275
275
  : openPRs(it).length > 1
276
276
  ? `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join(', ')} in turn if they are still approved, green and clean.`
277
277
  : `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join('')} if it is still approved, green and clean.`),
278
- 'card-build': (it, ref) => (it.blockedBy?.length
279
- ? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done? It is bumped to the top of Todo until then.`
278
+ 'card-build': (it, ref) => (it.blocked
279
+ ? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${[it.blockedBy.length && `${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done`, it.blockedUntil].filter(Boolean).join(' and ')}? It is bumped to the top of Todo until then.`
280
280
  : `${it.held ? `Unhold and implement ${ref}?` : `Implement ${ref}?`} Claude starts its implementer now with the notes as they stand.`),
281
281
  finish: (it, ref) => (it.kind === 'ask' ? `Mark this task done? "${it.title}" moves to Done.`
282
282
  : it.ownerStep ? `Done with "${it.ownerStep}"? ${ref} moves to Done and its ticket closes.`
@@ -297,16 +297,25 @@ BoardView.part((B) => {
297
297
  const ask = (act, label, cls = '') => `<button${cls ? ` class="${cls}"` : ''} data-confirm="${act}" data-key="${key}">${label}</button>`;
298
298
  const hold = it.kind !== 'work' || !it.holdable ? ''
299
299
  : it.held ? ask('resume', 'Unhold', 'unhold') : ask('hold', 'Hold');
300
+ // A Todo card's Close, right of Hold; an In Review card's Done is in its thread box
301
+ // (docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md).
302
+ const close = it.lane === 'todo' && it.finishable ? ask('finish', 'Close') : '';
300
303
  // A Todo card's Implement is in its thread box (app-threads.js; docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md).
301
- return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${mergeButton(it)}${hold}${actionButton(it)}${tryButton(it)}${notesButton(it)}`)}</div>`;
304
+ return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${hold}${close}${notesButton(it)}${tryButton(it)}${actionButton(it)}${prototypesButton(it)}${mergeButton(it)}`)}</div>`;
302
305
  }
303
306
 
304
307
  // board/domain.mjs actionButtonOf picks the button and resolvedBy its colour (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
305
308
  const ACTION_BUTTONS = {
306
- [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Open questions</a>`,
307
- [BUTTON.markDone]: (it) => `<button${classes(it, BUTTON.markDone)} data-confirm="finish" data-key="${esc(it.key)}">Mark as done</button>`,
309
+ [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Questions</a>`,
308
310
  };
309
311
  const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
312
+ // docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md
313
+ // On any card with prototypes (board/domain.mjs prototypesOf), Done ones too, so old rounds stay a tap away: it opens
314
+ // the viewer on the latest round, solid orange while a round waits on the owner's pick.
315
+ const prototypesButton = (it) => {
316
+ const latest = it.prototypes?.rounds.at(-1);
317
+ return latest ? `<a class="button${it.prototypes.open ? ' resolves' : ''}" href="${esc(latest.url)}">Prototypes</a>` : '';
318
+ };
310
319
 
311
320
  // The latest bump among New cards takes no second bump (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md).
312
321
  const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
@@ -358,7 +367,7 @@ BoardView.part((B) => {
358
367
  // After lanes redraw: compares each card's reached count to its last, then enters, fills or leaves its strip. An
359
368
  // entering strip grows in one bar at a time, then sweeps each reached bar.
360
369
  function moveStrips(items, before) {
361
- const next = new Map(items.map((it) => [it.key, phaseStrip(it)?.reached ?? null]));
370
+ const next = new Map(items.map((it) => [it.key, phaseStrip(it)?.done ?? null]));
362
371
  const still = reduceMotion.matches || !drawn;
363
372
  for (const [key, reached] of next) {
364
373
  const kind = still ? null : phaseMotion(phases.get(key), reached);
@@ -392,6 +401,7 @@ BoardView.part((B) => {
392
401
  const key = el.dataset.key;
393
402
  if (el.open) {
394
403
  state.openItems.add(key);
404
+ state.restored.delete(key);
395
405
  const it = state.board.items.find((i) => i.key === key) || state.archived.cards.find((i) => i.key === key);
396
406
  markSeen(it);
397
407
  B.renderBanner(state.board);
@@ -457,10 +467,14 @@ BoardView.part((B) => {
457
467
  catch (e) { B.failed(e); }
458
468
  });
459
469
 
460
- B.on(B.root, 'click', async (ev) => {
470
+ B.on(B.root, 'click', (ev) => {
461
471
  const b = ev.target.closest('button[data-confirm]');
462
472
  if (!b) return;
463
473
  ev.preventDefault();
474
+ confirmTap(b);
475
+ });
476
+ // A data-confirm button's tap: Hold's sheet, or its confirm, then its event. The thread box's Done comes here on ⌥↵ too.
477
+ async function confirmTap(b) {
464
478
  const { key, confirm: act } = b.dataset;
465
479
  if (b.classList.contains('nudging')) return;
466
480
  const it = state.board.items.find((i) => i.key === key);
@@ -490,7 +504,7 @@ BoardView.part((B) => {
490
504
  if (pend) { BoardUI.pending.drop(pend); B.render(); }
491
505
  B.failed(e);
492
506
  }
493
- if (act === 'finish') B.syncDone(key);
507
+ if (act === 'finish') B.syncSends(key);
494
508
  return;
495
509
  }
496
510
  // A stalled line's nudge calms it to orange with a ripple, not a spinner, until a board shows the card's status line
@@ -504,7 +518,7 @@ BoardView.part((B) => {
504
518
  BoardUI.pending.answered(id, out.board.generatedAt); toast('Nudge sent');
505
519
  } catch { BoardUI.pending.drop(id); toast('Could not send. Is the host awake?'); }
506
520
  B.render();
507
- });
521
+ }
508
522
 
509
- Object.assign(B, { captureStrips, moveStrips, detail, card });
523
+ Object.assign(B, { captureStrips, moveStrips, detail, card, confirmTap });
510
524
  });
@@ -85,27 +85,26 @@ BoardView.part((B) => {
85
85
  state.link = 'online';
86
86
  const controls = { wired: false, boardSettled: false, reloading: false };
87
87
  function applyControls() {
88
- const on = headerControls({ link: state.link, ...controls });
88
+ const on = headerControls(controls);
89
89
  $('#refresh').disabled = !on.refresh;
90
90
  $('#refresh').classList.toggle('spinning', on.spin);
91
91
  const gear = $('#settingsLink');
92
92
  if (on.settings) { gear.removeAttribute('aria-disabled'); gear.removeAttribute('tabindex'); } else { gear.setAttribute('aria-disabled', 'true'); gear.tabIndex = -1; }
93
93
  }
94
94
  const headerWired = () => { controls.wired = true; applyControls(); };
95
- // style.css dims the offline view from .view-board.offline.
95
+ // Offline, the board stays as it was and reads as before; only the header time turns red, and style.css holds the
96
+ // buttons that send from .view-board.offline (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
96
97
  function setLink(link) {
97
- const was = state.link;
98
98
  state.link = link;
99
99
  if (link !== 'reconnecting') controls.boardSettled = true;
100
- const off = link !== 'online';
101
- B.root.classList.toggle('offline', off);
102
- if (link === 'offline' && was === 'online') toast('Offline. Use refresh in the header to retry.');
100
+ B.root.classList.toggle('offline', link !== 'online');
103
101
  applyControls();
104
102
  renderStatus();
105
103
  }
104
+ BoardUI.holdSends(B.on, B.root, () => state.link !== 'online', () => B.toast(BoardUI.WAITING));
106
105
  const linkUp = () => setLink('online');
107
106
  const linkDown = () => { if (state.link !== 'reconnecting') setLink('offline'); };
108
- B.on($('.nav'), 'click', (ev) => {
107
+ B.on($('#settingsLink'), 'click', (ev) => {
109
108
  if (ev.target.closest('a[aria-disabled="true"]')) { ev.preventDefault(); ev.stopImmediatePropagation(); }
110
109
  }, true);
111
110
  // A load can return in milliseconds, so the spin and "reconnecting…" hold long enough to read.
@@ -1,9 +1,9 @@
1
1
  // The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Done's and Asks' Show more,
2
- // Done's finished-wave folds, and Todo's wave bands with their Implement, triage and card order.
2
+ // Done's finished-wave folds, and Todo's wave bands with their Implement and card order.
3
3
  // Needs app-core.js and app-cards.js.
4
4
  BoardView.part((B) => {
5
5
  const { $, store, state } = B;
6
- const { esc, waitOn } = BoardUI;
6
+ const { esc, escText, waitOn } = BoardUI;
7
7
  const LANES = [
8
8
  { id: 'review', name: 'In Review', open: true },
9
9
  { id: 'stale', name: 'Stale', open: true, hideEmpty: true },
@@ -13,13 +13,13 @@ BoardView.part((B) => {
13
13
  { id: 'asks', name: 'Asks', open: false, capped: true },
14
14
  ];
15
15
  // docs/adr/2026-11-03-0259-answered-asks-have-their-own-section-below-done.md
16
- // A capped lane shows STEP cards, and each Show more tap adds STEP more, kept in memory per lane. A card the owner has
17
- // open stays shown past the cap. Show more says how many are left (docs/adr/2026-10-29-1639-lists-never-cut-silently.md).
16
+ // A capped lane shows STEP cards, and each Show more tap adds STEP more, kept in memory per lane. A card the owner opened
17
+ // this visit stays shown past the cap; one open from before a reload does not (state.restored). Show more says how many are left (docs/adr/2026-10-29-1639-lists-never-cut-silently.md).
18
18
  // older is how many cards wait past `cards` in the archive; the tap that would show past `cards` fetches them first.
19
19
  const STEP = 10;
20
20
  const moreTaps = new Map();
21
21
  function cappedRows(laneId, cards, older = 0) {
22
- const lastOpen = cards.findLastIndex((it) => state.openItems.has(it.key));
22
+ const lastOpen = cards.findLastIndex((it) => state.openItems.has(it.key) && !state.restored.has(it.key));
23
23
  const shown = Math.max(STEP * (1 + (moreTaps.get(laneId) || 0)), lastOpen + 1);
24
24
  const left = Math.max(0, cards.length - shown);
25
25
  const n = left + (older || 0);
@@ -55,11 +55,11 @@ BoardView.part((B) => {
55
55
  // A fold's open state lives in storage, not in setHTML's summary-text match, because its row's count changes.
56
56
  // Fold ids are wave:<slug> (board/domain.mjs waveFold), so any id maps to its own storage key.
57
57
  const FOLDS = new Proxy({}, { get: (_, id) => ({ store: `foldOpen:${String(id)}` }) });
58
- // A fold's header reads like a lane's: its name, then its count, then an optional note.
59
- function fold(id, list, name, note = '') {
58
+ // A fold's header reads like a lane's: its name, then its count.
59
+ function fold(id, list, name) {
60
60
  if (!list.length) return '';
61
61
  return `<details class="fold" data-fold="${esc(id)}" ${store.get(FOLDS[id].store, false) ? 'open' : ''}>
62
- <summary><span class="lane-name">${name}</span><span class="count">${list.length}</span>${note ? `<span class="count">· ${note}</span>` : ''}</summary>
62
+ <summary><span class="lane-name">${name}</span><span class="count">${list.length}</span></summary>
63
63
  <div class="fold-list">${list.map((it) => B.card(it)).join('')}</div>
64
64
  </details>`;
65
65
  }
@@ -69,8 +69,7 @@ BoardView.part((B) => {
69
69
  const slugs = [...new Set(items.filter((i) => i.fold === `wave:${i.wave}`).map((i) => i.wave))];
70
70
  return slugs.map((slug) => {
71
71
  const w = d.waves.find((x) => x.slug === slug);
72
- const { dropped } = w.finished;
73
- return fold(`wave:${slug}`, items.filter((i) => i.fold === `wave:${slug}`), esc(w.title), dropped ? `${dropped} dropped` : '');
72
+ return fold(`wave:${slug}`, items.filter((i) => i.fold === `wave:${slug}`), escText(w.title));
74
73
  }).join('');
75
74
  }
76
75
 
@@ -110,7 +109,7 @@ BoardView.part((B) => {
110
109
  // Every lane but Todo groups its cards under a header per open wave (state.board.waveBands) that has cards there, in
111
110
  // band order, then the cards in no wave under No wave. A lane with no card in a wave lists its cards plainly. A header
112
111
  // counts its wave's cards in Todo, In Progress (Stale included), In Review and Done as x/y/k/z, from every lane, with
113
- // the lane's own number bold in its colour and a tooltip that spells the counts out.
112
+ // the lane's own number bold in that lane's colour (style.css: review blue, progress orange, Todo and Done gray) and a tooltip that spells the counts out.
114
113
  // A tap on the counts opens the tooltip (countsShown, the header's lane/wave); a second tap or a tap elsewhere closes it.
115
114
  let countsShown = null;
116
115
  const COUNT_LANES = [['todo', 'in Todo'], ['progress', 'in progress'], ['review', 'in review'], ['done', 'done']];
@@ -130,7 +129,7 @@ BoardView.part((B) => {
130
129
  if (!grouped.length) return cards.map((it) => B.card(it)).join('');
131
130
  const loose = cards.filter((i) => !bands.some((b) => b.keys.includes(i.key)));
132
131
  const group = (cls, data, name, n, counts, list) => `<section class="${cls}"${data}><div class="wb-head wb-loosehead">${counts ? BoardUI.WAVE_ICON : ''}<span class="wb-label"><span class="lane-name">${name}</span> <span class="count">${n}</span></span>${counts}</div>${list.map((it) => B.card(it)).join('')}</section>`;
133
- return grouped.map((b) => group('wb-lane', ` data-wave="${esc(b.slug)}"`, esc(b.title), inBand(b).length, waveCounts(b, laneId), inBand(b))).join('')
132
+ return grouped.map((b) => group('wb-lane', ` data-wave="${esc(b.slug)}"`, escText(b.title), inBand(b).length, waveCounts(b, laneId), inBand(b))).join('')
134
133
  + (loose.length ? group('wb-loose', '', 'No wave', loose.length, '', loose) : '');
135
134
  }
136
135
 
@@ -141,13 +140,6 @@ BoardView.part((B) => {
141
140
  // card whose ⋯ menu is open, goalShown the band whose goal popover is open.
142
141
  let menu = null;
143
142
  let goalShown = null;
144
- const triaging = new Set();
145
- const triageTopic = (r) => `${r.kind}:${r.slug}`;
146
- function triageLink(r) {
147
- if (r.triage.running || triaging.has(triageTopic(r))) return '<span class="wv-busy" role="status"><span class="try-spin" aria-hidden="true"></span>triaging…</span>';
148
- if (r.triage.questions) return `<span class="muted">Triage: ${B.plural(r.triage.questions, 'open question')}</span>`;
149
- return `<button type="button" class="wv-link" data-triage="${esc(triageTopic(r))}">Triage</button>`;
150
- }
151
143
  const bandStore = (slug) => FOLDS[`band:${slug}`].store;
152
144
  const bandOpen = (band, first) => store.get(bandStore(band.slug), band.slug === first);
153
145
  const LANE_WORDS = { progress: 'in progress', review: 'in review', stale: 'stale' };
@@ -169,8 +161,8 @@ BoardView.part((B) => {
169
161
  const todo = cards.filter((i) => i.lane === 'todo');
170
162
  const done = cards.filter((i) => i.lane === 'done');
171
163
  const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${LANE_WORDS[i.lane]}`);
172
- const progress = [`${band.merged} of ${band.total} merged`, ...now].join(' · ');
173
- const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${esc(band.title)}</span>`;
164
+ const progress = now.length ? ` <span class="count">· ${now.join(' · ')}</span>` : '';
165
+ const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${escText(band.title)}</span>`;
174
166
  const notes = band.kind === 'lineup' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
175
167
  const next = band.next;
176
168
  const go = !next ? '' : next.ready
@@ -178,8 +170,8 @@ BoardView.part((B) => {
178
170
  : `<span class="wb-next">next: ${esc(next.ref)} ${esc(next.word)}</span>`;
179
171
  const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
180
172
  const head = `<div class="wb-head" data-band-toggle="${esc(band.slug)}" role="button" tabindex="0" aria-expanded="${open}">${BoardUI.WAVE_ICON}`
181
- + `<span class="wb-label">${name} <span class="count">${todo.length}</span> <span class="count">· ${progress}</span></span>`
182
- + waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}${triageLink(band)}</span>${go}</span>${pop}</div>`;
173
+ + `<span class="wb-label">${name} <span class="count">${todo.length}</span>${progress}</span>`
174
+ + waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}</span>${go}</span>${pop}</div>`;
183
175
  if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
184
176
  const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#item=${encodeURIComponent(i.key)}">${esc(i.ref)}</a>`).join(', ')}</div>` : '';
185
177
  const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
@@ -250,21 +242,6 @@ BoardView.part((B) => {
250
242
  } catch { BoardUI.pending.drop('todo:plan'); B.render(); B.notice('Could not send. Is the host awake?'); }
251
243
  return;
252
244
  }
253
- const tb = ev.target.closest('button[data-triage]');
254
- if (tb) {
255
- const topic = tb.dataset.triage;
256
- const row = state.board.waveBands.find((r) => triageTopic(r) === topic);
257
- const asks = row.kind === 'lineup'
258
- ? `Claude reads the code for ${row.title}'s unstarted cards and asks you in Needs you about splitting, merging, adding or closing any. Start?`
259
- : `Claude checks ${row.title}'s unfinished cards, fixes what it can and asks you in Needs you about any that need you. Start?`;
260
- if (!(await B.confirm(asks))) return;
261
- triaging.add(topic);
262
- B.render();
263
- try { await B.send('wave-triage', { [row.kind]: row.slug }); }
264
- catch { B.notice('Could not send. Is the host awake?'); }
265
- finally { triaging.delete(topic); B.render(); }
266
- return;
267
- }
268
245
  const show = ev.target.closest('a[data-band-show]');
269
246
  if (show) {
270
247
  ev.preventDefault();
@@ -5,12 +5,14 @@
5
5
  // (the draft Send on the questions page sends) and a prompt's buttons answering it, Skip to the next, a dot for each thing waiting, and All open questions at its foot;
6
6
  // - from 900px, #waiting, one line each, in the pane beside the lanes, over Waiting on you mounted there
7
7
  // (view-questions.js, embedded) while the width lasts. Waiting on you shows the questions there, so #waiting leaves out
8
- // a line that only asks questions, and with nothing else left the orange section is gone. A link into those
9
- // questions, wherever it sits on the board, picks its topic's pill there instead of opening the page.
8
+ // a line that only asks questions, and with nothing else left the orange section is gone. Every questions URL lands
9
+ // here at that width (shell.js go, focusQuestion), and a pill's double-click focuses its card on the board
10
+ // (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md).
11
+ // Below 900px the header's ? opens Waiting on you, with the open-question count as its badge.
10
12
  // Needs app-core.js.
11
13
  BoardView.part((B) => {
12
14
  const { $, plural, state, toast } = B;
13
- const { esc, promptItem, choiceLabel, isPrototype, getDraft, setDraft } = BoardUI;
15
+ const { esc, escText, promptItem, choiceLabel, isPrototype, getDraft, setDraft } = BoardUI;
14
16
 
15
17
  const prompts = PromptAnswers.wire(B, {
16
18
  list: () => (state.board?.needs || []).filter((line) => line.prompt).map((line) => line.prompt),
@@ -26,22 +28,33 @@ BoardView.part((B) => {
26
28
  const needs = (d.needs || []).filter((line) => !line.prompt || prompts.shows(line.prompt));
27
29
  renderList(wide.matches ? needs.filter((line) => !line.questions || line.ready || line.action) : needs);
28
30
  renderDeck(d, needs);
31
+ renderAsk(d);
32
+ }
33
+
34
+ // The header's ? below 900px, which opens Waiting on you, carries the open-question count as its badge.
35
+ function renderAsk(d) {
36
+ const open = (d.owner || []).filter((q) => q.state === 'open').length;
37
+ const ask = $('#questionsLink');
38
+ const badge = ask.querySelector('.q-badge');
39
+ badge.hidden = !open;
40
+ badge.textContent = open ? String(open) : '';
41
+ ask.setAttribute('aria-label', open ? `Open questions, ${open} open` : 'Open questions');
29
42
  }
30
43
 
31
44
  function renderList(needs) {
32
45
  const parts = (line) => [
33
46
  line.questions ? `<a href="${esc(line.questions.url)}">${plural(line.questions.open, 'question')}</a>` : '',
34
47
  line.ready ? `<a href="${esc(line.ready)}" target="_blank" rel="noopener">Ready on GitHub</a>` : '',
35
- line.action ? `<a href="${esc(line.action.url)}">${esc(line.action.label)}</a>` : '',
48
+ line.action ? `<a href="${esc(line.action.url)}">${escText(line.action.label)}</a>` : '',
36
49
  ].filter(Boolean).join(' · ');
37
50
  const w = $('#waiting');
38
51
  w.hidden = needs.length === 0;
39
52
  w.innerHTML = needs.length ? `<h2 style="margin:0 0 6px">Needs you · ${needs.length}</h2>
40
53
  <ul>${needs.map((line) => (line.prompt ? promptItem(line.prompt, prompts.state(line.prompt))
41
- : line.key ? `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
42
- : line.review ? `<li><a href="${esc(line.url)}" data-review-open="${line.review}">${esc(line.title)}</a> <span class="muted small">· review held for you</span></li>`
43
- : line.band ? `<li><a href="#" data-band-show="${esc(line.band)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
44
- : `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">${plural(line.questions.open, 'question')}</span></li>`)).join('')}</ul>` : '';
54
+ : line.key ? `<li><a href="${esc(line.url)}">${escText(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
55
+ : line.review ? `<li><a href="${esc(line.url)}" data-review-open="${line.review}">${escText(line.title)}</a> <span class="muted small">· review held for you</span></li>`
56
+ : line.band ? `<li><a href="#" data-band-show="${esc(line.band)}">${escText(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
57
+ : `<li><a href="${esc(line.url)}">${escText(line.title)}</a> <span class="muted small">${plural(line.questions.open, 'question')}</span></li>`)).join('')}</ul>` : '';
45
58
  }
46
59
 
47
60
  // The deck's things, in Needs you order: a prompt; each open question of a line that asks some, in its topic's
@@ -64,14 +77,14 @@ BoardView.part((B) => {
64
77
  : ['Open', line.url, ''];
65
78
  const title = line.key ? line.title.slice(what.length + 1).trim() : line.title;
66
79
  return [{ id: `l:${line.key || line.review || line.band || line.url}`, what,
67
- html: `<h3>${line.key ? `<a href="${esc(line.url)}">${esc(title)}</a>` : esc(title)}</h3>${line.review ? '<p class="ctx">review held for you</p>' : ''}<div class="deck-acts"><a class="button primary" href="${esc(href)}"${attrs}>${esc(label)}</a></div>` }];
80
+ html: `<h3>${line.key ? `<a href="${esc(line.url)}">${escText(title)}</a>` : escText(title)}</h3>${line.review ? '<p class="ctx">review held for you</p>' : ''}<div class="deck-acts"><a class="button primary" href="${esc(href)}"${attrs}>${esc(label)}</a></div>` }];
68
81
  });
69
82
  }
70
83
  const questionHtml = (q) => {
71
84
  const picked = getDraft(q.id).choice;
72
- return `<h3>${esc(q.question)}</h3>${q.context ? `<p class="ctx">${esc(q.context)}</p>` : ''}
85
+ return `<h3>${escText(q.question)}</h3>${q.context ? `<p class="ctx">${escText(q.context)}</p>` : ''}
73
86
  ${isPrototype(q.url) ? `<a class="button deck-proto" href="${esc(q.url)}">View prototypes</a>` : ''}
74
- <div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map((o) => `<button type="button" class="${o.recommended ? 'rec' : ''}" aria-pressed="${o.id === picked}" data-deck-pick="${esc(o.id)}"><span class="lbl">${esc(choiceLabel(q, o.id))}${o.recommended ? ' <span class="rec-word">recommended</span>' : ''}</span>${o.detail ? `<small>${esc(o.detail)}</small>` : ''}</button>`).join('')}</div>`;
87
+ <div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map((o) => `<button type="button" class="${o.recommended ? 'rec' : ''}" aria-pressed="${o.id === picked}" data-deck-pick="${esc(o.id)}"><span class="lbl">${esc(choiceLabel(q, o.id))}${o.recommended ? ' <span class="rec-word">recommended</span>' : ''}</span>${o.detail ? `<small>${escText(o.detail)}</small>` : ''}</button>`).join('')}</div>`;
75
88
  };
76
89
 
77
90
  // The thing on screen, by id, so an arrival or a repaint keeps it; once it is gone the deck shows what took its place.
@@ -88,7 +101,7 @@ BoardView.part((B) => {
88
101
  const open = (d.owner || []).filter((q) => q.state === 'open').length;
89
102
  const drafted = (d.owner || []).filter((q) => q.state === 'open' && 'choice' in getDraft(q.id)).length;
90
103
  BoardUI.setHTML(el, `<div class="deck-head"><h2>Needs you</h2><span class="deck-n">${deck.length}</span></div>
91
- <div class="deck-card" data-deck="${esc(now.id)}"><p class="deck-what">${esc(now.what)}</p>${now.html}</div>
104
+ <div class="deck-card" data-deck="${esc(now.id)}"><p class="deck-what">${escText(now.what)}</p>${now.html}</div>
92
105
  ${deck.length > 1 ? `<div class="deck-bar"><span class="deck-dots" aria-hidden="true">${deck.map((x) => `<i${x === now ? ' class="on"' : ''}></i>`).join('')}</span><button type="button" class="ghost" data-deck-skip>Skip →</button></div>` : ''}
93
106
  ${open ? `<a class="deck-more" href="questions.html">All open questions · ${open}${drafted ? ` · ${drafted} drafted, Send there` : ''}</a>` : ''}`);
94
107
  }
@@ -121,25 +134,17 @@ BoardView.part((B) => {
121
134
  questions = Object.assign(AppViews.questions.create(slot, B.site.root, { embedded: true }), { slot });
122
135
  questions.mount();
123
136
  }
124
- // From 900px, a link into open questions for a topic the pane holds picks that topic's pill there, rather than
125
- // navigating to the page. It runs before the router's listener, which skips a prevented click.
126
- B.on(B.root, 'click', (ev) => {
127
- if (ev.defaultPrevented || ev.button || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey || !questions) return;
128
- const a = ev.target.closest?.('a[href]');
129
- if (!a || (a.target && a.target !== '_self')) return;
130
- const home = new URL(`${B.site.root}/`, location.href);
131
- const url = new URL(a.getAttribute('href'), home);
132
- if (url.pathname !== new URL('questions.html', home).pathname || url.hash.length < 2) return;
133
- const id = decodeURIComponent(url.hash.slice(1));
137
+ // The shell's one rule for a questions URL from 900px (shell.js go): it shows the board and asks here for the question
138
+ // or topic id, or null for none. The pane opens its topic, resolved or not, and focuses its pill → false while there
139
+ // is no pane or it hasn't loaded, so the shell asks again.
140
+ function focusQuestion(id) {
134
141
  const topic = (state.board?.ownerTopics || []).find((t) => t.id === id || t.questions.includes(id));
135
- const pill = topic && questions.slot.querySelector(`button[data-topic="${CSS.escape(topic.id)}"]`);
136
- if (!pill) return;
137
- ev.preventDefault();
138
- pill.click();
139
- const picked = questions.slot.querySelector(`button[data-topic="${CSS.escape(topic.id)}"]`);
140
- picked.focus();
141
- picked.scrollIntoView({ block: 'nearest' });
142
- });
142
+ if (!questions?.B.openTopic(topic?.id ?? id)) return false;
143
+ const pill = questions.slot.querySelector('.topics button[aria-pressed="true"]');
144
+ pill?.focus();
145
+ pill?.scrollIntoView({ block: 'nearest' });
146
+ return true;
147
+ }
143
148
  // A double-click on a pane pill focuses its topic on the board: item:<key> is a card (an ask is one, ask:<id>),
144
149
  // wave:<slug> a band; any other topic, or one the board doesn't show, does nothing.
145
150
  B.on(B.root, 'dblclick', (ev) => {
@@ -153,5 +158,5 @@ BoardView.part((B) => {
153
158
  B.on(wide, 'change', fitPane);
154
159
  B.atUnmount(() => { questions?.unmount(); questions = null; });
155
160
 
156
- Object.assign(B, { renderNeeds });
161
+ Object.assign(B, { renderNeeds, focusQuestion });
157
162
  });
@@ -1,72 +1,41 @@
1
- // The last visit's board on the board view: reading and saving it, holding the controls that send until the fresh
2
- // board lands, flashing the lines the fresh board changed (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md), and
3
- // the sign on a card that changed lanes or was updated (docs/adr/2026-11-02-2354-an-updated-card-takes-a-brief-tint.md).
1
+ // The board view's saved copy: reading it from IndexedDB and saving it there with its change number
2
+ // (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md), flashing the lines the catch-up changed, and the sign
3
+ // on a card that changed lanes or was updated (docs/adr/2026-11-02-2354-an-updated-card-takes-a-brief-tint.md).
4
4
  // Needs app-core.js and ui-snapshot.js; app.js paints it.
5
5
  BoardView.part((B) => {
6
6
  const { $$, state } = B;
7
- const { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, updateSign, SNAPSHOT_MAX } = BoardUI;
8
- // The hub serves every site from one origin, so each board keeps its own key.
9
- const KEY = 'boardSnapshot:' + B.site.url('');
10
- // Every board's snapshots together stay within three at ui-snapshot.js's cap, the boards saved least recently
11
- // dropping first, so they leave room in the browser's 5 MB of storage for the drafts, which would otherwise fail to
12
- // save.
13
- const BUDGET = 3 * SNAPSHOT_MAX;
14
- const ORDER = 'boardSnapshots';
7
+ const { copyOf, boardCopy, copyStore, preloadKeys, changedLines, updateSign } = BoardUI;
8
+ // The hub serves every site from one origin, so each board keeps its own key in the one store.
9
+ const KEY = B.site.url('');
15
10
  const BUILD = document.documentElement.dataset.build;
16
11
 
17
- function readSnapshot() {
18
- let raw = null;
19
- try { raw = localStorage.getItem(KEY); } catch {}
20
- return raw ? unpackSnapshot(raw, BUILD) : null;
21
- }
22
- // Makes room for this board's raw snapshot.
23
- function keepRecent(raw) {
24
- let order = [];
25
- try { order = JSON.parse(localStorage.getItem(ORDER) || '[]'); } catch {}
26
- const stored = [];
27
- for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k?.startsWith('boardSnapshot:') && k !== KEY) stored.push(k); }
28
- const recent = [...order.filter((k) => stored.includes(k)), ...stored.filter((k) => !order.includes(k))];
29
- let used = raw.length;
30
- const kept = [KEY];
31
- for (const k of recent) {
32
- const size = localStorage.getItem(k)?.length || 0;
33
- if (used + size <= BUDGET) { used += size; kept.push(k); } else localStorage.removeItem(k);
34
- }
35
- localStorage.setItem(ORDER, JSON.stringify(kept));
36
- }
12
+ const copies = (mode, fn) => copyStore('boards', mode, fn);
13
+
14
+ // → {savedAt, board, threads} or null. anyBuild reads a copy another build saved too, which only a board out of reach
15
+ // paints, guarded (app.js showDown).
16
+ const readCopy = ({ anyBuild = false } = {}) => boardCopy(KEY, anyBuild ? null : BUILD);
17
+
18
+ // Saves the board on screen. app.html's boot reads copyVersion before any script loads, so its first board request
19
+ // asks since the copy's number; it names no number when the save failed.
37
20
  let saveTimer = null;
38
- function saveSnapshot() {
21
+ async function saveCopy() {
39
22
  saveTimer?.();
40
23
  saveTimer = null;
41
24
  if (state.view !== 'fresh' || !state.board) return;
42
- const raw = packSnapshot(state.board, state.threadCache, {
43
- build: BUILD,
44
- savedAt: state.board.generatedAt || new Date().toISOString(),
45
- keep: [...state.openItems, ...preloadKeys(state.board.items, [])],
46
- });
25
+ const copy = copyOf(state.board, state.threadCache, { build: BUILD, savedAt: state.board.generatedAt || new Date().toISOString() });
47
26
  try {
48
- if (!raw) { localStorage.removeItem(KEY); return; }
49
- keepRecent(raw);
50
- localStorage.setItem(KEY, raw);
51
- } catch { try { localStorage.removeItem(KEY); } catch {} }
27
+ await copies('readwrite', (store) => store.put(copy, KEY));
28
+ B.store.set('copyVersion', { build: BUILD, version: copy.board.version });
29
+ } catch { B.store.set('copyVersion', null); }
52
30
  }
53
- const saveSnapshotSoon = () => { if (!saveTimer) saveTimer = B.later(1000, saveSnapshot); };
54
- B.on(window, 'pagehide', () => { if (saveTimer) saveSnapshot(); });
55
- // The app shell paints a board it comes back to from this, live when nothing changed since (shell.js).
56
- B.atUnmount(saveSnapshot);
31
+ const saveCopySoon = () => { if (!saveTimer) saveTimer = B.later(1000, saveCopy); };
32
+ B.on(window, 'pagehide', () => { if (saveTimer) saveCopy(); });
33
+ B.atUnmount(saveCopy);
57
34
 
58
- // view is ui-snapshot.js liveControls' view; style.css dims the snapshot and its held controls from the view root's data-board.
35
+ // view is null until a board paints, then 'fresh'; a guarded paint that failed sets it back to null.
59
36
  function setView(view) {
60
37
  state.view = view;
61
- B.root.dataset.board = view;
62
- }
63
- for (const type of ['click', 'submit']) {
64
- B.on(B.root, type, (ev) => {
65
- const { blocked } = liveControls(state.view);
66
- if (!blocked) return;
67
- const control = type === 'submit' ? ev.target.querySelector?.('button[type=submit]') : ev.target.closest?.(blocked);
68
- if (control?.matches(blocked)) { ev.preventDefault(); ev.stopImmediatePropagation(); }
69
- }, true);
38
+ if (view) B.root.dataset.board = view; else delete B.root.dataset.board;
70
39
  }
71
40
 
72
41
  // The lines a fresh board can change: each lane count, Needs you line and thread message. A card shows its own sign below.
@@ -79,31 +48,39 @@ BoardView.part((B) => {
79
48
  }
80
49
  const texts = (els) => Object.fromEntries([...els].map(([id, el]) => [id, el.textContent.replace(/\s+/g, ' ').trim()]));
81
50
  let before = null;
51
+ // The one highlight: every changed line, moved card and updated card takes the full tint of style.css .tint. Re-adding
52
+ // the class after a layout read restarts the animation, from the delay's point.
53
+ function highlight(el, elapsed = 0) {
54
+ el.classList.remove('tint');
55
+ el.style.setProperty('--sign-delay', `-${elapsed}ms`);
56
+ void el.offsetWidth;
57
+ el.classList.add('tint');
58
+ }
82
59
  const holdForFlash = () => { before = texts(lines()); };
83
60
  function flashChanged() {
84
61
  if (!before || state.view !== 'fresh') return;
85
62
  const els = lines();
86
63
  const after = texts(els);
87
64
  for (const id of changedLines(before, after)) {
88
- els.get(id).classList.add('flash');
65
+ highlight(els.get(id));
89
66
  before[id] = after[id];
90
67
  }
91
68
  }
92
69
  const endFlash = () => { before = null; };
93
70
 
94
71
  // Since the page last drew it, a last visit's board included, a card that changed lanes, a work card that takes
95
- // the place of a drawn New task (its fromAsk), or a work card or New task that joins Todo at the bottom, flashes once; any other card whose updateSign changed takes a brief
96
- // tint on its title row. Neither shows for a change the owner sent from this page: sentHere(key, send) holds the
97
- // card's next change unsigned until the first board load started after send settles.
72
+ // the place of a drawn New task (its fromAsk), a work card or New task that joins Todo at the bottom, or any other
73
+ // card whose updateSign changed takes the same tint. It does not show for a change the owner sent from this page:
74
+ // sentHere(key, send) holds the card's next change unsigned until the first board load started after send settles.
98
75
  let drawn = new Map();
99
76
  const ownChanges = new Map();
100
77
  async function sentHere(key, send) {
101
78
  ownChanges.set(key, Infinity);
102
79
  try { return await send(); } finally { ownChanges.set(key, B.boardLoad || 0); }
103
80
  }
104
- // Each sign's style.css animation length. A lane repaint resets a card's classes, so a sign still running is put back
81
+ // The tint's style.css animation length. A lane repaint resets a card's classes, so a tint still running is put back
105
82
  // as far into its animation as it was.
106
- const SIGNS = { flash: 1600, tint: 2400 };
83
+ const TINT_MS = 2400;
107
84
  const running = new Map();
108
85
  function signCards(load = null) {
109
86
  const now = Date.now();
@@ -114,19 +91,15 @@ BoardView.part((B) => {
114
91
  const updated = Boolean(was) && was.sign !== updateSign(it);
115
92
  const own = ownChanges.get(it.key);
116
93
  if (own !== undefined) { if (moved || updated || (load !== null && load > own)) ownChanges.delete(it.key); continue; }
117
- if ((moved || updated) && state.view === 'fresh') running.set(it.key, { sign: moved ? 'flash' : 'tint', at: now });
94
+ if ((moved || updated) && state.view === 'fresh') running.set(it.key, now);
118
95
  }
119
96
  drawn = new Map(state.board.items.map((i) => [i.key, { lane: i.lane, sign: updateSign(i) }]));
120
- for (const [key, { sign, at }] of running) {
121
- const card = now - at < SIGNS[sign] && B.root.querySelector(`details.item[data-key="${CSS.escape(key)}"]`);
97
+ for (const [key, at] of running) {
98
+ const card = now - at < TINT_MS && B.root.querySelector(`details.item[data-key="${CSS.escape(key)}"]`);
122
99
  if (!card) { running.delete(key); continue; }
123
- // Re-adding the class after a layout read restarts the animation, from the delay's point.
124
- card.classList.remove('flash', 'tint');
125
- card.style.setProperty('--sign-delay', `-${now - at}ms`);
126
- void card.offsetWidth;
127
- card.classList.add(sign);
100
+ highlight(card, now - at);
128
101
  }
129
102
  }
130
103
 
131
- Object.assign(B, { readSnapshot, saveSnapshotSoon, setView, holdForFlash, flashChanged, endFlash, sentHere, signCards });
104
+ Object.assign(B, { readCopy, saveCopySoon, setView, holdForFlash, flashChanged, endFlash, highlight, sentHere, signCards });
132
105
  });