@lovek4d/kickflip 0.5.0 → 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 (110) hide show
  1. package/CONTEXT.md +105 -68
  2. package/README.md +1 -1
  3. package/SKILL.md +27 -21
  4. package/bin/kickflip.mjs +27 -18
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -0
  7. package/board/arch-review.mjs +4 -4
  8. package/board/bin/board.mjs +199 -79
  9. package/board/build-script.mjs +2 -1
  10. package/board/domain.mjs +9 -7
  11. package/board/finish.mjs +4 -4
  12. package/board/headless.mjs +9 -4
  13. package/board/on-host.mjs +1 -1
  14. package/board/onboarding.mjs +11 -0
  15. package/board/prototype-base.mjs +1 -0
  16. package/board/prototype-page.mjs +1 -0
  17. package/board/public/app-arch.js +1 -1
  18. package/board/public/app-asks.js +6 -6
  19. package/board/public/app-banner.js +7 -13
  20. package/board/public/app-cards.js +75 -37
  21. package/board/public/app-core.js +26 -8
  22. package/board/public/app-header.js +8 -8
  23. package/board/public/app-lanes.js +67 -58
  24. package/board/public/app-needs.js +140 -10
  25. package/board/public/app-reader.js +1 -1
  26. package/board/public/app-snapshot.js +46 -72
  27. package/board/public/app-threads.js +78 -75
  28. package/board/public/app.html +9 -4
  29. package/board/public/app.js +114 -38
  30. package/board/public/attach.js +2 -2
  31. package/board/public/dialogs.js +9 -39
  32. package/board/public/hub-kit.js +66 -4
  33. package/board/public/hub.css +2 -0
  34. package/board/public/linear-key.js +46 -35
  35. package/board/public/new-task.js +20 -39
  36. package/board/public/progress-review.html +46 -0
  37. package/board/public/progress-review.js +158 -0
  38. package/board/public/prototype-kit.js +137 -7
  39. package/board/public/prototype-switcher.js +152 -123
  40. package/board/public/prototype.html +57 -3
  41. package/board/public/router.js +7 -6
  42. package/board/public/shell.js +68 -26
  43. package/board/public/sidebar.css +1 -0
  44. package/board/public/sidebar.js +16 -30
  45. package/board/public/style.css +205 -83
  46. package/board/public/ui-cards.js +91 -65
  47. package/board/public/ui-enums.js +1 -1
  48. package/board/public/ui-header.js +27 -18
  49. package/board/public/ui-hub.js +12 -1
  50. package/board/public/ui-links.js +7 -7
  51. package/board/public/ui-order.js +12 -10
  52. package/board/public/ui-page-header.js +19 -7
  53. package/board/public/ui-patch.js +7 -33
  54. package/board/public/ui-progress-bar.js +28 -0
  55. package/board/public/ui-questions.js +47 -52
  56. package/board/public/ui-sidebar.js +34 -26
  57. package/board/public/ui-snapshot.js +43 -52
  58. package/board/public/ui-threads.js +59 -30
  59. package/board/public/ui.js +125 -18
  60. package/board/public/view-hub-settings.js +22 -67
  61. package/board/public/view-hub.js +19 -4
  62. package/board/public/view-notes.js +27 -7
  63. package/board/public/view-questions.js +205 -240
  64. package/board/public/view-settings.js +313 -76
  65. package/board/render-check.mjs +10 -0
  66. package/board/review.mjs +1 -1
  67. package/board/server.mjs +19 -20
  68. package/board/stop.mjs +1 -0
  69. package/board/store.mjs +29 -15
  70. package/board/sync.mjs +2 -0
  71. package/board/tickets.mjs +3 -3
  72. package/board/transcript.mjs +2 -0
  73. package/board/worktree.mjs +25 -20
  74. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  75. package/hooks-plugin/hooks/hooks.json +9 -0
  76. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/front-door.mjs +5 -5
  80. package/hub/github.mjs +3 -3
  81. package/hub/hub.mjs +11 -11
  82. package/hub/incoming.mjs +2 -2
  83. package/hub/linear.mjs +1 -2
  84. package/hub/live.mjs +2 -2
  85. package/hub/locks.mjs +1 -1
  86. package/hub/owner-checks.mjs +1 -1
  87. package/hub/pr-report.mjs +2 -2
  88. package/hub/presence.mjs +1 -1
  89. package/hub/push.mjs +2 -2
  90. package/hub/readiness.mjs +1 -2
  91. package/hub/review-send.mjs +1 -0
  92. package/hub/secrets.mjs +2 -0
  93. package/hub/self-restart.mjs +2 -0
  94. package/hub/sessions.mjs +2 -2
  95. package/hub/share.mjs +1 -0
  96. package/hub/slack.mjs +2 -2
  97. package/hub/stream.mjs +10 -13
  98. package/hub/try.mjs +7 -7
  99. package/hub/upgrade.mjs +1 -1
  100. package/hub/usage.mjs +3 -3
  101. package/hub/views.mjs +1 -1
  102. package/package.json +2 -1
  103. package/shared/audit.mjs +4 -0
  104. package/shared/card-prs.mjs +1 -1
  105. package/shared/fake-gh.mjs +17 -4
  106. package/shared/machine-gate.mjs +2 -0
  107. package/shared/prototype-frame.js +1 -0
  108. package/shared/service.mjs +4 -4
  109. package/shared/usage-gate.mjs +1 -1
  110. package/board/public/ui-phase-bars.js +0 -17
@@ -1,9 +1,9 @@
1
- // The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Todo's Queued section, Done's and Asks' Show more,
2
- // Done's finished-wave folds, and Todo's wave bands with their Implement, triage and card order.
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 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,19 +13,19 @@ 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);
26
26
  const words = `${n} older card${n === 1 ? '' : 's'}`;
27
27
  const fetches = older !== 0 && shown + STEP > cards.length;
28
- return cards.slice(0, shown).map((it) => B.card(it)).join('')
28
+ return waveGroups(laneId, cards.slice(0, shown))
29
29
  + (n > 0 ? `<button type="button" class="thread-more" data-lane-more="${laneId}"${fetches ? ' data-fetch' : ''}>Show more <span class="n">· ${words}</span></button>` : '');
30
30
  }
31
31
 
@@ -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
 
@@ -97,7 +96,7 @@ BoardView.part((B) => {
97
96
  if (lane.hideEmpty && !items.length) return '';
98
97
  const expanded = laneState[lane.id] ?? lane.open;
99
98
  const open = items.filter((i) => !i.fold);
100
- const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : open.map((it) => B.card(it)).join('')) + waveFolds(d, items);
99
+ const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : waveGroups(lane.id, open)) + waveFolds(d, items);
101
100
  return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
102
101
  <summary><span class="lane-name">${lane.name}</span><span class="count">${lane.id === 'done' ? doneCount(d, items) : items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
103
102
  <div class="list">${rows || '<div class="empty">Nothing here.</div>'}
@@ -106,60 +105,73 @@ BoardView.part((B) => {
106
105
  }).join('');
107
106
  }
108
107
 
108
+ // docs/adr/2026-11-05-0215-every-lane-groups-its-cards-by-wave.md
109
+ // Every lane but Todo groups its cards under a header per open wave (state.board.waveBands) that has cards there, in
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
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
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.
113
+ // A tap on the counts opens the tooltip (countsShown, the header's lane/wave); a second tap or a tap elsewhere closes it.
114
+ let countsShown = null;
115
+ const COUNT_LANES = [['todo', 'in Todo'], ['progress', 'in progress'], ['review', 'in review'], ['done', 'done']];
116
+ function waveCounts(band, laneId) {
117
+ const lanes = state.board.items.reduce((m, i) => m.set(i.key, i.lane === 'stale' ? 'progress' : i.lane), new Map());
118
+ const n = COUNT_LANES.map(([id]) => band.keys.filter((k) => lanes.get(k) === id).length);
119
+ const here = laneId === 'stale' ? 'progress' : laneId;
120
+ const id = `${laneId}/${band.slug}`;
121
+ const spelled = COUNT_LANES.map(([, word], at) => `${n[at]} ${word}`);
122
+ const tip = `<span class="wb-tip" role="tooltip">${spelled.map((t) => `<div>${t}</div>`).join('')}</span>`;
123
+ return `<button type="button" class="wb-counts${countsShown === id ? ' open' : ''}" data-counts="${esc(id)}" aria-label="${spelled.join(', ')}">${COUNT_LANES.map(([lane], at) => `<span${lane === here ? ` class="wv-cur wv-${lane}"` : ''}>${n[at]}</span>`).join('<span aria-hidden="true">/</span>')}${tip}</button>`;
124
+ }
125
+ function waveGroups(laneId, cards) {
126
+ const bands = state.board.waveBands || [];
127
+ const inBand = (b) => cards.filter((i) => b.keys.includes(i.key));
128
+ const grouped = bands.filter((b) => inBand(b).length);
129
+ if (!grouped.length) return cards.map((it) => B.card(it)).join('');
130
+ const loose = cards.filter((i) => !bands.some((b) => b.keys.includes(i.key)));
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>`;
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('')
133
+ + (loose.length ? group('wb-loose', '', 'No wave', loose.length, '', loose) : '');
134
+ }
135
+
109
136
  // docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md
110
- // Todo's wave bands (domain.mjs waveBandsOf), then its cards in no wave under Not in a wave. A band's open state lives
137
+ // Todo's wave bands (domain.mjs waveBandsOf), then its cards in no wave under No wave. A band's open state lives
111
138
  // in storage once the owner taps its head; until then only the first band whose next card is ready (a wave file's:
112
139
  // any Todo card) starts open. A band's name shows its goal as a tooltip, or a popover on a long-press. menu is the
113
140
  // card whose ⋯ menu is open, goalShown the band whose goal popover is open.
114
141
  let menu = null;
115
142
  let goalShown = null;
116
- const triaging = new Set();
117
- const triageTopic = (r) => `${r.kind}:${r.slug}`;
118
- function triageLink(r) {
119
- 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>';
120
- if (r.triage.questions) return `<span class="muted">Triage: ${B.plural(r.triage.questions, 'open question')}</span>`;
121
- return `<button type="button" class="wv-link" data-triage="${esc(triageTopic(r))}">Triage</button>`;
122
- }
123
143
  const bandStore = (slug) => FOLDS[`band:${slug}`].store;
124
144
  const bandOpen = (band, first) => store.get(bandStore(band.slug), band.slug === first);
125
145
  const LANE_WORDS = { progress: 'in progress', review: 'in review', stale: 'stale' };
126
146
  const DOTS = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>';
127
147
 
128
- // docs/adr/2026-11-04-0150-queued-sits-at-the-top-of-todo.md
129
- // Todo's Queued section, which holds the cards waiting for an implementer slot, the next to build on top, as they
130
- // arrive (domain.mjs laneOrder), then its wave bands and loose cards.
131
- function todoRows(d, shown, all) {
132
- const queued = all.filter((i) => i.queued);
133
- const head = queued.length ? `<div class="wb-loose lane-queue"><div class="wb-head wb-loosehead"><span class="lane-name">Queued</span><span class="count">${queued.length}</span></div>${queued.map((it) => B.card(it)).join('')}</div>` : '';
134
- return head + bandRows(d, shown, all.filter((i) => !i.queued));
135
- }
136
-
137
- function bandRows(d, shown, todo) {
138
- const bands = d.waveBands || [];
139
- const byKey = new Map(shown.filter((i) => !i.queued).map((i) => [i.key, i]));
140
- const banded = new Set(bands.flatMap((b) => b.keys));
148
+ // Todo's wave bands and loose cards, in the order they arrive (domain.mjs laneOrder).
149
+ function todoRows(d, shown, todo) {
150
+ const byKey = new Map(shown.map((i) => [i.key, i]));
151
+ const bands = (d.waveBands || []).filter((b) => b.keys.some((k) => byKey.get(k)?.lane === 'todo'));
152
+ const banded = new Set((d.waveBands || []).flatMap((b) => b.keys));
141
153
  const loose = todo.filter((i) => !banded.has(i.key));
142
154
  const first = bands.find((b) => (b.next ? b.next.ready : b.keys.some((k) => byKey.get(k)?.ready)))?.slug;
143
155
  if (!bands.length) return loose.map((it) => B.card(it)).join('');
144
156
  return bands.map((b) => bandHtml(b, b.keys.map((k) => byKey.get(k)).filter(Boolean), bandOpen(b, first))).join('')
145
- + (loose.length ? `<div class="wb-loose"><div class="wb-head wb-loosehead"><span class="lane-name">Not in a wave</span><span class="count">${loose.length}</span></div>${loose.map((it) => B.card(it)).join('')}</div>` : '');
157
+ + (loose.length ? `<div class="wb-loose"><div class="wb-head wb-loosehead"><span class="lane-name">No wave</span><span class="count">${loose.length}</span></div>${loose.map((it) => B.card(it)).join('')}</div>` : '');
146
158
  }
147
159
 
148
160
  function bandHtml(band, cards, open) {
149
161
  const todo = cards.filter((i) => i.lane === 'todo');
150
162
  const done = cards.filter((i) => i.lane === 'done');
151
- const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${i.queued ? 'queued' : LANE_WORDS[i.lane]}`);
152
- const progress = [`${band.merged} of ${band.total} merged`, ...now].join(' · ');
153
- const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${esc(band.title)}</span>`;
154
- const notes = band.kind === 'lineup' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes ${BoardUI.LINK_ARROW}</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
163
+ const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${LANE_WORDS[i.lane]}`);
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>`;
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>` : '';
155
167
  const next = band.next;
156
168
  const go = !next ? '' : next.ready
157
169
  ? `<button type="button" class="wv-link" data-confirm="card-build" data-key="${esc(next.key)}">Implement</button>`
158
170
  : `<span class="wb-next">next: ${esc(next.ref)} ${esc(next.word)}</span>`;
159
171
  const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
160
172
  const head = `<div class="wb-head" data-band-toggle="${esc(band.slug)}" role="button" tabindex="0" aria-expanded="${open}">${BoardUI.WAVE_ICON}`
161
- + `<span class="wb-label">${name} <span class="count">${todo.length}</span> <span class="count">· ${progress}</span></span>`
162
- + `<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>`;
163
175
  if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
164
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>` : '';
165
177
  const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
@@ -220,23 +232,14 @@ BoardView.part((B) => {
220
232
  const pb = ev.target.closest('button[data-plan]');
221
233
  if (pb) {
222
234
  if (!(await B.confirm('Claude triages these Todo cards into one or more waves and asks you about them in Needs you. Nothing is implemented until you answer. Start?'))) return;
223
- try { await waitOn(pb, B.send('plan-waves', { keys: pb.dataset.plan.split(' ') })); pb.textContent = 'Planning… its questions appear in Needs you'; pb.disabled = true; }
224
- catch { B.notice('Could not send. Is the host awake?'); }
225
- return;
226
- }
227
- const tb = ev.target.closest('button[data-triage]');
228
- if (tb) {
229
- const topic = tb.dataset.triage;
230
- const row = state.board.waveBands.find((r) => triageTopic(r) === topic);
231
- const asks = row.kind === 'lineup'
232
- ? `Claude reads the code for ${row.title}'s unstarted cards and asks you in Needs you about splitting, merging, adding or closing any. Start?`
233
- : `Claude checks ${row.title}'s unfinished cards, fixes what it can and asks you in Needs you about any that need you. Start?`;
234
- if (!(await B.confirm(asks))) return;
235
- triaging.add(topic);
236
- B.render();
237
- try { await B.send('wave-triage', { [row.kind]: row.slug }); }
238
- catch { B.notice('Could not send. Is the host awake?'); }
239
- finally { triaging.delete(topic); B.render(); }
235
+ // Spins until a board shows a grill running on one of the cards (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
236
+ const keys = pb.dataset.plan.split(' ');
237
+ BoardUI.pending.start('todo:plan', (board) => board.items.some((i) => keys.includes(i.key) && i.grilling), B.stillWaiting);
238
+ try {
239
+ const out = await waitOn(pb, B.send('plan-waves', { keys }));
240
+ BoardUI.pending.answered('todo:plan', out.board.generatedAt);
241
+ B.render();
242
+ } catch { BoardUI.pending.drop('todo:plan'); B.render(); B.notice('Could not send. Is the host awake?'); }
240
243
  return;
241
244
  }
242
245
  const show = ev.target.closest('a[data-band-show]');
@@ -264,6 +267,12 @@ BoardView.part((B) => {
264
267
  for (const type of ['pointerup', 'pointercancel']) B.on(B.root, type, endPress);
265
268
  B.on(B.root, 'contextmenu', (ev) => { if (ev.target.closest?.('[data-goal]')) ev.preventDefault(); });
266
269
  B.on(B.root, 'click', async (ev) => {
270
+ const counts = ev.target.closest?.('[data-counts]');
271
+ if (counts || countsShown) {
272
+ countsShown = counts && counts.dataset.counts !== countsShown ? counts.dataset.counts : null;
273
+ B.render();
274
+ if (counts) return;
275
+ }
267
276
  if (pressed) { pressed = false; ev.preventDefault(); return; }
268
277
  if (goalShown) { goalShown = null; B.render(); if (ev.target.closest('.wb-head')) return; }
269
278
  const dots = ev.target.closest('button[data-band-menu]');
@@ -1,8 +1,18 @@
1
- // The board view's Needs you box: one line per thing waiting on the owner (domain.mjs needsOf), led by the board
2
- // session's pending prompts, answered in place (prompts.js). Needs app-core.js.
1
+ // The board view's Needs you (domain.mjs needsOf), led by the board session's pending prompts, answered in place
2
+ // (prompts.js). It draws twice, and the stylesheet shows one by width
3
+ // (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md):
4
+ // - on a phone, #needsDeck at the top of the board: one thing at a time, a question's options drafting its answer on the tap
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
+ // - from 900px, #waiting, one line each, in the pane beside the lanes, over Waiting on you mounted there
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. 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.
12
+ // Needs app-core.js.
3
13
  BoardView.part((B) => {
4
14
  const { $, plural, state, toast } = B;
5
- const { esc, promptItem } = BoardUI;
15
+ const { esc, escText, promptItem, choiceLabel, isPrototype, getDraft, setDraft } = BoardUI;
6
16
 
7
17
  const prompts = PromptAnswers.wire(B, {
8
18
  list: () => (state.board?.needs || []).filter((line) => line.prompt).map((line) => line.prompt),
@@ -11,22 +21,142 @@ BoardView.part((B) => {
11
21
  after: () => B.loadBoard(),
12
22
  });
13
23
 
24
+ // From 900px, the pane beside the lanes, the same width the stylesheet splits the board at.
25
+ const wide = matchMedia('(min-width: 900px)');
26
+
14
27
  function renderNeeds(d) {
28
+ const needs = (d.needs || []).filter((line) => !line.prompt || prompts.shows(line.prompt));
29
+ renderList(wide.matches ? needs.filter((line) => !line.questions || line.ready || line.action) : needs);
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');
42
+ }
43
+
44
+ function renderList(needs) {
15
45
  const parts = (line) => [
16
46
  line.questions ? `<a href="${esc(line.questions.url)}">${plural(line.questions.open, 'question')}</a>` : '',
17
47
  line.ready ? `<a href="${esc(line.ready)}" target="_blank" rel="noopener">Ready on GitHub</a>` : '',
18
- 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>` : '',
19
49
  ].filter(Boolean).join(' · ');
20
- const needs = (d.needs || []).filter((line) => !line.prompt || prompts.shows(line.prompt));
21
50
  const w = $('#waiting');
22
51
  w.hidden = needs.length === 0;
23
52
  w.innerHTML = needs.length ? `<h2 style="margin:0 0 6px">Needs you · ${needs.length}</h2>
24
53
  <ul>${needs.map((line) => (line.prompt ? promptItem(line.prompt, prompts.state(line.prompt))
25
- : line.key ? `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
26
- : 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>`
27
- : line.band ? `<li><a href="#" data-band-show="${esc(line.band)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
28
- : `<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>` : '';
58
+ }
59
+
60
+ // The deck's things, in Needs you order: a prompt; each open question of a line that asks some, in its topic's
61
+ // order; any other line as itself. → [{id, what, html}]
62
+ function deckOf(d, needs) {
63
+ const items = d.items || [];
64
+ const owner = (d.owner || []).filter((q) => q.state === 'open');
65
+ const topicOf = (line) => line.topic || items.find((it) => it.key === line.key)?.questions?.topic || null;
66
+ return needs.flatMap((line) => {
67
+ if (line.prompt) return [{ id: `p:${line.prompt.id}`, what: 'Claude asks', html: `<ul class="deck-prompt">${promptItem(line.prompt, prompts.state(line.prompt))}</ul>` }];
68
+ const what = line.key ? line.title.split(':')[0] : line.title;
69
+ const topic = line.questions && topicOf(line);
70
+ const order = (d.ownerTopics || []).find((t) => t.id === topic)?.questions || [];
71
+ const asks = topic ? owner.filter((q) => q.topic === topic).sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)) : [];
72
+ if (asks.length) return asks.map((q) => ({ id: `q:${q.id}`, what, html: questionHtml(q) }));
73
+ const [label, href, attrs] = line.questions ? [`Answer ${plural(line.questions.open, 'question')}`, line.questions.url, '']
74
+ : line.ready ? ['Ready on GitHub', line.ready, ' target="_blank" rel="noopener"']
75
+ : line.action ? [line.action.label, line.action.url, '']
76
+ : line.review ? ['Open the review', line.url, ` data-review-open="${line.review}"`]
77
+ : ['Open', line.url, ''];
78
+ const title = line.key ? line.title.slice(what.length + 1).trim() : line.title;
79
+ return [{ id: `l:${line.key || line.review || line.band || line.url}`, what,
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>` }];
81
+ });
29
82
  }
83
+ const questionHtml = (q) => {
84
+ const picked = getDraft(q.id).choice;
85
+ return `<h3>${escText(q.question)}</h3>${q.context ? `<p class="ctx">${escText(q.context)}</p>` : ''}
86
+ ${isPrototype(q.url) ? `<a class="button deck-proto" href="${esc(q.url)}">View prototypes</a>` : ''}
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>`;
88
+ };
89
+
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.
91
+ let showing = null;
92
+ let deck = [];
93
+ function renderDeck(d, needs) {
94
+ const el = $('#needsDeck');
95
+ const before = deck.findIndex((x) => x.id === showing);
96
+ deck = deckOf(d, needs);
97
+ el.hidden = deck.length === 0;
98
+ if (!deck.length) { el.innerHTML = ''; showing = null; return; }
99
+ const now = deck.find((x) => x.id === showing) || deck[Math.min(Math.max(before, 0), deck.length - 1)];
100
+ showing = now.id;
101
+ const open = (d.owner || []).filter((q) => q.state === 'open').length;
102
+ const drafted = (d.owner || []).filter((q) => q.state === 'open' && 'choice' in getDraft(q.id)).length;
103
+ BoardUI.setHTML(el, `<div class="deck-head"><h2>Needs you</h2><span class="deck-n">${deck.length}</span></div>
104
+ <div class="deck-card" data-deck="${esc(now.id)}"><p class="deck-what">${escText(now.what)}</p>${now.html}</div>
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>` : ''}
106
+ ${open ? `<a class="deck-more" href="questions.html">All open questions · ${open}${drafted ? ` · ${drafted} drafted, Send there` : ''}</a>` : ''}`);
107
+ }
108
+
109
+ B.on(B.root, 'click', (ev) => {
110
+ if (!ev.target.closest?.('#needsDeck')) return;
111
+ if (ev.target.closest('[data-deck-skip]')) {
112
+ const at = deck.findIndex((x) => x.id === showing);
113
+ showing = deck[(at + 1) % deck.length].id;
114
+ renderNeeds(state.board);
115
+ return;
116
+ }
117
+ const opt = ev.target.closest('button[data-deck-pick]');
118
+ if (!opt) return;
119
+ const id = opt.closest('[data-deck]').dataset.deck.slice(2);
120
+ setDraft(id, { ...getDraft(id), choice: opt.dataset.deckPick });
121
+ const at = deck.findIndex((x) => x.id === showing);
122
+ showing = deck[(at + 1) % deck.length].id;
123
+ renderNeeds(state.board);
124
+ });
125
+
126
+ // From 900px, Waiting on you in the pane under the Needs you lines, mounted while the width lasts.
127
+ let questions = null;
128
+ function fitPane() {
129
+ if (wide.matches === Boolean(questions)) return;
130
+ if (state.board) renderNeeds(state.board);
131
+ if (questions) { questions.unmount(); questions.slot.remove(); questions = null; return; }
132
+ $('#needsPane').insertAdjacentHTML('beforeend', `<div class="view-questions needs-questions">${AppViews.questions.markup({ embedded: true })}</div>`);
133
+ const slot = $('#needsPane .needs-questions');
134
+ questions = Object.assign(AppViews.questions.create(slot, B.site.root, { embedded: true }), { slot });
135
+ questions.mount();
136
+ }
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) {
141
+ const topic = (state.board?.ownerTopics || []).find((t) => t.id === id || t.questions.includes(id));
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
+ }
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>),
149
+ // wave:<slug> a band; any other topic, or one the board doesn't show, does nothing.
150
+ B.on(B.root, 'dblclick', (ev) => {
151
+ const pill = questions && ev.target.closest?.('button[data-topic]');
152
+ if (!pill || !questions.slot.contains(pill)) return;
153
+ const id = pill.dataset.topic;
154
+ if (id.startsWith('item:')) B.focusOnBoard({ key: id.slice(5) });
155
+ else if (id.startsWith('wave:')) B.focusOnBoard({ slug: id.slice(5) });
156
+ });
157
+ fitPane();
158
+ B.on(wide, 'change', fitPane);
159
+ B.atUnmount(() => { questions?.unmount(); questions = null; });
30
160
 
31
- Object.assign(B, { renderNeeds });
161
+ Object.assign(B, { renderNeeds, focusQuestion });
32
162
  });
@@ -1,4 +1,4 @@
1
- // The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "Open the log →"
1
+ // The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "Open the log"
2
2
  // (ui-threads.js) and a card's or a lineup's Notes (app-cards.js, app-lanes.js) open over the board in one sheet in
3
3
  // place of log.html and notes.html, which still open on their own. It closes on its ✕, Escape, a tap on the dim area
4
4
  // and Back, through a history entry of its own that carries the board's URL, so the router's pop keeps the board.
@@ -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 and seen marks, which would
12
- // otherwise fail to 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,53 +48,58 @@ 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
- // Since the page last drew it, a last visit's board included, a card that changed lanes, or a work card that takes
95
- // the place of a drawn New task (its fromAsk), 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.
71
+ // Since the page last drew it, a last visit's board included, a card that changed lanes, a work card that takes
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();
110
87
  for (const it of state.board.items) {
111
88
  const was = drawn.get(it.key);
112
- const moved = was ? was.lane !== it.lane : Boolean(it.fromAsk) && drawn.has(`ask:${it.fromAsk}`);
89
+ const arrived = !was && drawn.size > 0 && it.lane === 'todo' && (it.isNew || (it.kind === 'work' && !it.fromAsk));
90
+ const moved = was ? was.lane !== it.lane : arrived || (Boolean(it.fromAsk) && drawn.has(`ask:${it.fromAsk}`));
113
91
  const updated = Boolean(was) && was.sign !== updateSign(it);
114
92
  const own = ownChanges.get(it.key);
115
93
  if (own !== undefined) { if (moved || updated || (load !== null && load > own)) ownChanges.delete(it.key); continue; }
116
- 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);
117
95
  }
118
96
  drawn = new Map(state.board.items.map((i) => [i.key, { lane: i.lane, sign: updateSign(i) }]));
119
- for (const [key, { sign, at }] of running) {
120
- 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)}"]`);
121
99
  if (!card) { running.delete(key); continue; }
122
- // Re-adding the class after a layout read restarts the animation, from the delay's point.
123
- card.classList.remove('flash', 'tint');
124
- card.style.setProperty('--sign-delay', `-${now - at}ms`);
125
- void card.offsetWidth;
126
- card.classList.add(sign);
100
+ highlight(card, now - at);
127
101
  }
128
102
  }
129
103
 
130
- Object.assign(B, { readSnapshot, saveSnapshotSoon, setView, holdForFlash, flashChanged, endFlash, sentHere, signCards });
104
+ Object.assign(B, { readCopy, saveCopySoon, setView, holdForFlash, flashChanged, endFlash, highlight, sentHere, signCards });
131
105
  });