@lovek4d/kickflip 0.1.0 → 0.3.1

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 (96) hide show
  1. package/CONTEXT.md +177 -78
  2. package/README.md +46 -5
  3. package/SKILL.md +58 -37
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +87 -32
  7. package/bin/statusline.mjs +4 -3
  8. package/bin/wait-events.mjs +2 -2
  9. package/board/arch-review.mjs +5 -0
  10. package/board/bin/agent.mjs +1 -1
  11. package/board/bin/board.mjs +241 -69
  12. package/board/bin/builder-stop-hook.mjs +4 -3
  13. package/board/domain.mjs +5 -3
  14. package/board/on-host.mjs +1 -0
  15. package/board/public/app-arch.js +75 -0
  16. package/board/public/app-asks.js +33 -14
  17. package/board/public/app-cards.js +242 -49
  18. package/board/public/app-core.js +39 -5
  19. package/board/public/app-header.js +24 -6
  20. package/board/public/app-lanes.js +76 -26
  21. package/board/public/app-needs.js +1 -0
  22. package/board/public/app-snapshot.js +45 -11
  23. package/board/public/app-threads.js +13 -11
  24. package/board/public/app.html +4 -1
  25. package/board/public/app.js +131 -20
  26. package/board/public/dialogs.js +114 -116
  27. package/board/public/hub-kit.js +19 -2
  28. package/board/public/hub.css +24 -18
  29. package/board/public/linear-key.js +141 -0
  30. package/board/public/router.js +6 -3
  31. package/board/public/rules-editor.js +1 -1
  32. package/board/public/shell.js +1 -1
  33. package/board/public/sidebar.css +31 -11
  34. package/board/public/sidebar.js +88 -16
  35. package/board/public/style.css +141 -46
  36. package/board/public/toasts.js +6 -4
  37. package/board/public/ui-cards.js +102 -37
  38. package/board/public/ui-header.js +17 -8
  39. package/board/public/ui-hub.js +30 -12
  40. package/board/public/ui-links.js +18 -10
  41. package/board/public/ui-order.js +18 -0
  42. package/board/public/ui-page-header.js +4 -2
  43. package/board/public/ui-questions.js +37 -16
  44. package/board/public/ui-sidebar.js +49 -11
  45. package/board/public/ui-snapshot.js +15 -9
  46. package/board/public/ui-threads.js +25 -16
  47. package/board/public/ui.js +23 -11
  48. package/board/public/view-hub-settings.js +46 -30
  49. package/board/public/view-hub.js +64 -13
  50. package/board/public/view-notes.js +7 -4
  51. package/board/public/view-polling.js +3 -3
  52. package/board/public/view-questions.js +42 -22
  53. package/board/public/view-settings.js +90 -35
  54. package/board/review.mjs +1 -0
  55. package/board/server.mjs +12 -7
  56. package/board/store.mjs +14 -6
  57. package/board/tickets.mjs +3 -3
  58. package/board/worktree.mjs +14 -13
  59. package/docs/hub-session.md +3 -3
  60. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  61. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  62. package/hub/awake.mjs +1 -0
  63. package/hub/board-repo.mjs +1 -1
  64. package/hub/boards.mjs +4 -4
  65. package/hub/close-board.mjs +1 -1
  66. package/hub/duration.mjs +1 -0
  67. package/hub/github.mjs +4 -5
  68. package/hub/home.mjs +1 -1
  69. package/hub/hub.mjs +12 -10
  70. package/hub/linear.mjs +2 -2
  71. package/hub/new-board.mjs +2 -2
  72. package/hub/pr-report.mjs +1 -0
  73. package/hub/push.mjs +2 -2
  74. package/hub/readiness.mjs +2 -0
  75. package/hub/repos.mjs +3 -2
  76. package/hub/restart-board.mjs +1 -0
  77. package/hub/routes.mjs +1 -1
  78. package/hub/schedule.mjs +1 -1
  79. package/hub/sessions.mjs +2 -2
  80. package/hub/shell.mjs +2 -1
  81. package/hub/slack.mjs +2 -0
  82. package/hub/stream.mjs +12 -12
  83. package/hub/sw.js +1 -1
  84. package/hub/trust.mjs +2 -0
  85. package/hub/try.mjs +4 -3
  86. package/hub/upgrade.mjs +1 -0
  87. package/hub/usage.mjs +3 -3
  88. package/hub/views.mjs +1 -1
  89. package/package.json +1 -1
  90. package/shared/colors.css +7 -5
  91. package/shared/fake-gh.mjs +54 -0
  92. package/shared/service.mjs +16 -16
  93. package/shared/skills.mjs +2 -0
  94. package/shared/usage-gate.mjs +1 -1
  95. package/starter/public/claude.js +3 -2
  96. package/bin/web-ts.mjs +0 -2
@@ -1,14 +1,20 @@
1
1
  // Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
2
2
  (function () {
3
- const { esc, time, replyBody, attachments, attachButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
3
+ const { esc, time, replyBody, attachments, attachButton, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
4
4
  // A short id such as A or C2 leads the label; a word id such as "inline" would repeat it. prototype-switcher.js answerLabel matches.
5
5
  const lead = (o) => (/^[A-Z][0-9]?$/i.test(o.id) ? `${o.id}. ` : '');
6
6
  const choiceLabel = (q, id) => { const o = q.options.find((x) => x.id === id); return o ? `${q.multi ? '' : lead(o)}${o.label}` : id || ''; };
7
- const option = (o, { type = 'radio', name, checked = false, numbered = true, shown = false }) => `<label class="opt"${shown ? ' aria-current="true"' : ''}><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}>
7
+ const option = (o, { type = 'radio', name, checked = false, numbered = true, shown = false, disabled = false }) => `<label class="opt"${shown ? ' aria-current="true"' : ''}><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${disabled ? ' disabled' : ''}>
8
8
  <span><span class="lbl">${numbered ? esc(lead(o)) : ''}${esc(o.label)}</span>${o.recommended ? '<span class="rec">Recommended</span>' : ''}<div class="det">${esc(o.detail || '')}</div></span></label>`;
9
9
 
10
- const noteComposer = (id, note) => `<div class="composer" data-composer="${esc(id)}"><textarea class="note" rows="2" placeholder="Note (optional)">${esc(note || '')}</textarea>
11
- <div class="note-tools"><div class="pending-files" data-files></div>${attachButton()}</div></div>`;
10
+ const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
11
+ // A question's eye sends `prototype-request {key?, question, note, choice}` for that question alone (view-questions.js);
12
+ // once sent, the eye is off with a "prototype requested" badge right after it until the round arrives.
13
+ const questionEye = (q) => q.state === 'resolved' ? ''
14
+ : protoButton('q:' + q.id, null, { label: 'Ask for a prototype instead', disabled: q.state === 'requested' })
15
+ + (q.state === 'requested' ? badge('normal', `${SPIN}prototype requested`) : '');
16
+ const noteComposer = (q, note) => `<div class="composer" data-composer="${esc('q:' + q.id)}"><textarea class="note" rows="2" placeholder="Note (optional)"${q.state === 'requested' ? ' disabled' : ''}>${esc(note || '')}</textarea>
17
+ <div class="note-tools">${attachButton()}${questionEye(q)}<div class="pending-files" data-files></div></div></div>`;
12
18
 
13
19
  const QUESTION_STATE = {
14
20
  open: { label: 'open', tone: 'normal' },
@@ -16,10 +22,17 @@
16
22
  replied: { label: 'claude replied', tone: 'ready' },
17
23
  resolved: { label: 'resolved', tone: 'quiet' },
18
24
  };
25
+ // docs/adr/0111-a-prototype-round-ends-with-a-one-take-confirm.md
26
+ // A topic's latest prototype round from /api/board ownerTopics: {n, state: making|back|closed, take}.
27
+ const roundChip = (r) => !r ? ''
28
+ : r.state === 'making' ? badge('notice', `${SPIN}Prototype round ${r.n}`)
29
+ : r.state === 'closed' ? badge('quiet', `Round ${r.n} closed · take ${esc(r.take)}`)
30
+ : badge('normal', `Prototype round ${r.n}`);
31
+ const RULE_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>';
19
32
  function answerHistory(q, links = {}) {
20
33
  if (!q.answers.length) return '';
21
34
  const body = q.answers.map((a) => `<div class="ans"><div class="muted small">You · ${esc(time(a.ts))}</div>${esc(choiceLabel(q, a.choice))}${a.note ? `<div>${linkText(a.note, links)}</div>` : ''}${attachments(a.files)}</div>
22
- ${a.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(a.reply), links)}</div>` : '<div class="muted small" style="margin-top:4px">Waiting for Claude…</div>'}`).join('');
35
+ ${a.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(a.reply), links)}</div>` : a.acted ? '' : '<div class="muted small" style="margin-top:4px">Waiting for Claude…</div>'}`).join('');
23
36
  const last = q.answers[q.answers.length - 1];
24
37
  return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
25
38
  }
@@ -68,30 +81,38 @@
68
81
  }
69
82
  function takes(q, chosen, links, fit) {
70
83
  const name = 'c-' + q.id;
84
+ const disabled = q.state === 'requested';
71
85
  if (fullTakes(q, fit)) {
72
86
  const shown = q.options.find((o) => o.id === chosen) || q.options.find((o) => o.recommended) || q.options[0];
73
- return `<div class="takes takes-full"><div class="take-choices" role="group" aria-label="Takes">${q.options.map((o) => option(o, { name, checked: chosen === o.id, shown: o === shown })).join('')}</div><article class="take" data-opt="${esc(shown.id)}">${takeBody(q, shown, links, fit, true)}</article></div>`;
87
+ return `<div class="takes takes-full"><div class="take-choices" role="group" aria-label="Takes">${q.options.map((o) => option(o, { name, checked: chosen === o.id, shown: o === shown, disabled })).join('')}</div><article class="take" data-opt="${esc(shown.id)}">${takeBody(q, shown, links, fit, true)}</article></div>`;
74
88
  }
75
- return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id })}${takeBody(q, o, links, fit)}</article>`).join('')}</div>`;
89
+ return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, disabled })}${takeBody(q, o, links, fit)}</article>`).join('')}</div>`;
76
90
  }
91
+ // A confirm links its one take alone, and says why it is asked.
92
+ const confirmLines = (q) => !q.confirm_of || q.state === 'resolved' ? ''
93
+ : `<a class="pill proto pr-open" href="${esc(withParam(q.url, 'take', q.options[0].id))}">Open the prototype →</a>${q.state === 'open' ? `<div class="pr-rule">${RULE_ICON}A pick with a note gets a one-take confirm once Claude has updated the take.</div>` : ''}`;
77
94
  // An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext;
78
95
  // fit(option id) supplies the measured grid and full-width heights.
96
+ // A requested question shows the note and pick its prototype was asked with, locked.
79
97
  function questionCard(q, { n = 0, draft = {}, links = {}, fit = () => null } = {}) {
80
98
  const last = q.answers[q.answers.length - 1];
81
- const chosen = draft.choice ?? (last ? last.choice : null);
82
- const st = QUESTION_STATE[q.state];
83
- return `<section class="q ${q.urgent && q.state === 'open' ? 'urgent' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
84
- <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(st.tone, esc(st.label))}</div>
99
+ const busy = q.state === 'requested';
100
+ const shown = busy ? q.request : draft;
101
+ const chosen = shown.choice ?? (last ? last.choice : null);
102
+ return `<section class="q ${q.urgent && q.state === 'open' ? 'urgent' : ''}${busy ? ' pr-busy' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
103
+ <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${busy ? '' : badge(QUESTION_STATE[q.state].tone, esc(QUESTION_STATE[q.state].label))}</div>
85
104
  <h3>${linkText(q.question, links)}</h3>
86
105
  ${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
87
106
  ${framed(q) ? '' : questionLink(q.url)}
88
- ${q.state === 'resolved' && q.resolved ? `<div class="small muted" style="margin-bottom:8px">Resolved: ${esc(q.resolved)} A new answer reopens it.</div>` : ''}
89
- ${inline(q) ? takes(q, chosen, links, fit) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id })).join('')}
90
- ${noteComposer('q:' + q.id, draft.note)}
107
+ ${confirmLines(q)}
108
+ ${q.state === 'resolved' && q.resolved ? `<div class="small muted" style="margin-bottom:8px">Resolved: ${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'} A new answer reopens it.</div>` : ''}
109
+ ${inline(q) ? takes(q, chosen, links, fit) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, disabled: busy })).join('')}
110
+ ${noteComposer(q, shown.note)}
91
111
  ${answerHistory(q, links)}
92
112
  </section>`;
93
113
  }
94
- const topicName = (t) => (String(t.title).match(/^([A-Z]+-\d+|[A-Z]\d+):/) || [])[1] || t.title;
114
+ // A card's pill is its ID, or for an ask (item:ask:<id>) the time it appeared; other topics keep their title.
115
+ const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) : (String(t.title).match(/^([A-Z]+-\d+|[A-Z]\d+):/) || [])[1] || t.title);
95
116
  // Topics from /api/board ownerTopics, in order. pills: unresolved topics with an open question, plus the
96
117
  // current topic wherever it stands; waiting: the other unresolved topics, which wait only on Claude.
97
118
  function topicRow(topics, current) {
@@ -126,5 +147,5 @@
126
147
  },
127
148
  };
128
149
 
129
- Object.assign(globalThis.BoardUI, { choiceLabel, option, answerHistory, questionCard, takes, topicName, topicRow, nextQuestion, sentNotice });
150
+ Object.assign(globalThis.BoardUI, { choiceLabel, option, answerHistory, questionCard, roundChip, takes, topicName, topicRow, nextQuestion, sentNotice });
130
151
  })();
@@ -120,9 +120,45 @@
120
120
  + '<p class="sc-foot">Press h twice anywhere to open this. Esc to close.</p></div>';
121
121
  }
122
122
 
123
- function updateRow(update) {
124
- return update ? `<button type="button" class="sb-row sb-update-row" data-app-reload${update === 'reloading' ? ' disabled' : ''}>${tile('up', 'var(--accent)')}`
125
- + `<span class="sb-text"><span class="sb-title">New version</span></span><span class="sb-go">${update === 'reloading' ? 'Reloading…' : 'Reload'}</span></button>` : '';
123
+ const ticketsLabel = (t) => (t.source === 'internal' ? `Internal · ${t.prefix}` : t.source === 'github' ? `GitHub issues${t.account ? ` · ${t.account}` : ''}` : `Linear · ${t.team}`);
124
+ // A board's context window: its owner session's latest percent, dimmed with its age once older than ten minutes.
125
+ const CTX_STALE_MIN = 10;
126
+ function contextWords(ctx) {
127
+ if (!ctx) return '';
128
+ const minutes = Math.floor((Date.now() - Date.parse(ctx.at)) / 60_000);
129
+ if (!(minutes >= CTX_STALE_MIN)) return `<span class="sb-ctx">${Math.round(ctx.percent)}% ctx</span>`;
130
+ const age = minutes < 60 ? `${minutes} min` : minutes < 1440 ? `${Math.floor(minutes / 60)} h` : `${Math.floor(minutes / 1440)} d`;
131
+ return `<span class="sb-ctx stale">${Math.round(ctx.percent)}% ctx · ${age} ago</span>`;
132
+ }
133
+ function askedWhen(iso, now) {
134
+ const minutes = Math.floor((now - Date.parse(iso)) / 60_000);
135
+ return minutes < 1 ? 'just now' : minutes < 60 ? `${minutes} min ago` : usageClock(iso);
136
+ }
137
+ // The sheet a failed start's row opens (sidebar.js), inside its <dialog aria-labelledby="failedStartTitle">: the error,
138
+ // what the start used and when it was asked, then Dismiss and Retry. s: a /_hub/sidebar.json starting entry. Only a
139
+ // New board start records a branch, and only that start can run again, so a row without one has no Retry.
140
+ function failedStart(s, { home, now = Date.now() } = {}) {
141
+ const fact = (name, value) => (value ? `<dt>${name}</dt><dd>${value}</dd>` : '');
142
+ return `<div class="sheet-body"><div class="sheet-head"><h2 id="failedStartTitle">${esc(s.title)} failed to start</h2><button type="button" data-failed-close aria-label="Close sheet">✕</button></div>`
143
+ + `<p class="fs-error">${esc(s.error || "Its session didn't answer within 15 minutes.")}</p>`
144
+ + `<dl class="fs-facts">${fact('Repo', s.repo && esc(repoLabel(s.repo, home)))}${fact('Branch', s.branch && `<code>${esc(s.branch)}</code>`)}`
145
+ + `${fact('Tickets', s.tickets && esc(ticketsLabel(s.tickets)))}${fact('Asked', s.askedAt && esc(askedWhen(s.askedAt, now)))}</dl>`
146
+ + '<p class="fs-refused" role="alert" hidden></p>'
147
+ + `<div class="sheet-actions fs-actions"><button type="button" data-failed-dismiss>Dismiss</button>${s.branch ? '<button type="button" class="primary" data-failed-retry>Retry</button>' : ''}</div></div>`;
148
+ }
149
+
150
+ // The New version row. update: the app's reload, null | 'ready' | 'reloading', which comes first. upgrade:
151
+ // /_hub/sidebar.json's npm update, null | {state: available | waiting | updating | failed, version, builders, error}
152
+ // (hub/upgrade.mjs; docs/adr/0161-the-hub-offers-a-newer-kickflip-from-npm.md).
153
+ function updateRow(update, upgrade) {
154
+ const button = (attr, disabled, title, sub, go) => `<button type="button" class="sb-row sb-update-row" ${attr}${disabled ? ' disabled' : ''}>${tile('up', 'var(--accent)')}`
155
+ + `<span class="sb-text"><span class="sb-title">${title}</span>${sub ? `<span class="sb-sub">${esc(sub)}</span>` : ''}</span>${go ? `<span class="sb-go">${go}</span>` : ''}</button>`;
156
+ if (update) return button('data-app-reload', update === 'reloading', 'New version', '', update === 'reloading' ? 'Reloading…' : 'Reload');
157
+ if (!upgrade) return '';
158
+ if (upgrade.state === 'waiting') return button('data-app-upgrade', true, `Update waits for ${upgrade.builders} implementer${upgrade.builders === 1 ? '' : 's'}`, '', '');
159
+ if (upgrade.state === 'updating') return button('data-app-upgrade', true, 'Update available', '', 'Updating…');
160
+ if (upgrade.state === 'failed') return button('data-app-upgrade', false, 'Update failed', upgrade.error, 'Upgrade');
161
+ return button('data-app-upgrade', false, 'Update available', '', 'Upgrade');
126
162
  }
127
163
 
128
164
  // data: /_hub/sidebar.json. here: a site's route or a hub page's path without its leading slash. device: this device's push
@@ -135,17 +171,19 @@
135
171
  const paused = data?.pause?.state === 'paused';
136
172
  const siteRow = (s) => {
137
173
  const sub = paused && s.board ? word('wait', 'Paused') + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
138
- return row({ href: siteHref(s), icon: s.icon, title: s.title, sub: sub ? `<span class="sb-status">${sub}</span>` : '', on: s.route === here, key: keys?.get(siteHref(s)) });
174
+ return row({ href: siteHref(s), icon: s.icon, title: s.title, sub: sub || s.ctx ? `<span class="sb-status">${sub}${contextWords(s.ctx)}</span>` : '', on: s.route === here, key: keys?.get(siteHref(s)) });
139
175
  };
140
176
  const boards = sites.filter((s) => s.board);
141
177
  const pages = sites.filter((s) => !s.board);
142
178
  const plus = `<span class="icon sb-plus" aria-hidden="true">${svg(ICONS.plus, 20)}</span>`;
143
- const pending = (data?.starting || []).map((s) => `<div class="sb-row sb-starting" data-starting="${esc(s.route)}">${plus}`
144
- + `<span class="sb-text"><span class="sb-title">${s.failed ? "Didn't start" : s.phase === 'cloning' ? 'Cloning…' : 'Starting…'}</span><span class="sb-sub">${esc(s.title)}${s.repo ? ` · ${esc(repoLabel(s.repo, data.home))}` : ''}${s.failed ? '' : s.phase === 'cloning' ? ' · Cloning from GitHub' : ' · Waiting for the board to answer'}</span>${s.error ? `<span class="sb-sub">${esc(s.error)}</span>` : ''}</span>`
145
- + (s.failed ? `<button type="button" class="sb-dismiss" data-dismiss-board="${esc(s.route)}" aria-label="Dismiss ${esc(s.title)}">Dismiss</button>` : '') + '</div>').join('');
179
+ const pending = (data?.starting || []).map((s) => (s.failed
180
+ ? `<button type="button" class="sb-row sb-starting sb-failed" data-starting="${esc(s.route)}" data-failed-start="${esc(s.route)}" aria-haspopup="dialog">${plus}`
181
+ + `<span class="sb-text"><span class="sb-title">${esc(s.title)}</span><span class="sb-sub">failed to start</span></span></button>`
182
+ : `<div class="sb-row sb-starting" data-starting="${esc(s.route)}">${plus}`
183
+ + `<span class="sb-text"><span class="sb-title">${s.phase === 'cloning' ? 'Cloning…' : 'Starting…'}</span><span class="sb-sub">${esc(s.title)}${s.repo ? ` · ${esc(repoLabel(s.repo, data.home))}` : ''}${s.phase === 'cloning' ? ' · Cloning from GitHub' : ' · Waiting for the board to answer'}</span></span></div>`)).join('');
146
184
  const newBoard = `<button type="button" class="sb-row sb-new" data-new-board aria-haspopup="dialog">${plus}<span class="sb-text"><span class="sb-title">New board</span></span></button>`;
147
- const turnOn = device === 'off' ? row({ href: '/_hub/#notifications', icon: tile('bell', '#ef4444'), title: 'Turn on notifications', sub: 'Off on this device', cls: 'sb-turnon' })
148
- : device === 'install' ? row({ href: '/_hub/#notifications', icon: tile('bell', '#ef4444'), title: 'Add Claude to your Home Screen', sub: 'Notifications come only to the Home Screen app', cls: 'sb-turnon' })
185
+ const turnOn = device === 'off' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Turn on notifications', sub: 'Off on this device', cls: 'sb-turnon' })
186
+ : device === 'install' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Add Claude to your Home Screen', sub: 'Notifications come only to the Home Screen app', cls: 'sb-turnon' })
149
187
  : '';
150
188
  const usage = sidebarUsage(data?.usage);
151
189
  const hub = paused ? { tone: 'wait', word: 'Paused', detail: `until ${usageClock(data.pause.resets_at)}` } : data?.hub;
@@ -165,7 +203,7 @@
165
203
  const close = `<div class="nav"><button type="button" class="sb-close" data-sidebar-close aria-label="Close boards">${svg(ICONS.close, 20)}</button></div>`;
166
204
  return header({ lead: 'logo', title: 'Kickflip', level: 2, controls: false, trail: close, logoSrc: '/_hub/kickflip.png',
167
205
  leadAttrs: { 'aria-label': 'Hide boards', title: 'Hide boards', 'data-sidebar-close': '' } })
168
- + `<div class="sb-scroll"><section class="sb-utility" aria-label="Hub"><div class="sb-update" role="status">${updateRow(update)}</div><div class="sb-group">`
206
+ + `<div class="sb-scroll"><section class="sb-utility" aria-label="Hub"><div class="sb-update" role="status">${updateRow(update, data?.upgrade)}</div><div class="sb-group">`
169
207
  + UTILITY.map((f) => row({ ...f, on: f.href.replace(/\/$/, '') === selected, sub: subs[f.href], mark: f.href === '/_hub/' ? moon : '', ...(f.href === '/_hub/' && usage ? { extra: usage, cls: 'sb-hub-usage' } : {}), key: keys?.get(f.href) })).join('')
170
208
  + `</div>${turnOn}</section>`
171
209
  + (pages.length ? `<section class="sb-pages" aria-label="Pages"><div class="sb-label">Pages</div><div class="sb-group">${pages.map(siteRow).join('')}</div></section>` : '')
@@ -173,5 +211,5 @@
173
211
  + `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
174
212
  }
175
213
 
176
- Object.assign(globalThis.BoardUI ||= {}, { sidebar, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords });
214
+ Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords });
177
215
  })();
@@ -1,15 +1,15 @@
1
1
  // The last board this device showed, which the board page paints dimmed until the fresh board lands, and what the
2
2
  // page may do meanwhile (docs/adr/0028-the-board-opens-on-the-last-visit.md). Needs ui.js.
3
3
  (function () {
4
- const VERSION = 1;
5
4
  // Characters, one board's most; app-snapshot.js keeps every board's within three of these.
6
5
  const SNAPSHOT_MAX = 900000;
7
6
  const ACTIVE_LANES = ['review', 'stale', 'progress', 'todo'];
8
7
 
9
8
  // threads maps item keys to thread messages (app.js state.threadCache). Threads of `keep` go in first, then the
10
- // rest while they fit under max; threads of items no longer on the board are dropped. Null when the board alone won't fit.
11
- function packSnapshot(board, threads, { savedAt, keep = [], max = SNAPSHOT_MAX } = {}) {
12
- const head = `{"v":${VERSION},"savedAt":${JSON.stringify(savedAt)},"board":${JSON.stringify(board)},"threads":{`;
9
+ // rest while they fit under max; threads of items no longer on the board are dropped. build is the saving page's
10
+ // (app.html data-build). Null when the board alone won't fit.
11
+ function packSnapshot(board, threads, { build, savedAt, keep = [], max = SNAPSHOT_MAX } = {}) {
12
+ const head = `{"build":${JSON.stringify(build)},"savedAt":${JSON.stringify(savedAt)},"board":${JSON.stringify(board)},"threads":{`;
13
13
  if (head.length + 2 > max) return null;
14
14
  const onBoard = new Set(board.items.map((i) => i.key));
15
15
  let out = head;
@@ -24,11 +24,12 @@
24
24
  return `${out}}}`;
25
25
  }
26
26
 
27
- // → {savedAt, board, threads: {key: messages}}, or null for anything packSnapshot didn't write.
28
- function unpackSnapshot(raw) {
27
+ // → {savedAt, board, threads: {key: messages}}, or null for anything packSnapshot didn't write under this build: code
28
+ // from another build may read the board's shape differently, and a paint that throws stops the live load.
29
+ function unpackSnapshot(raw, build) {
29
30
  let s;
30
31
  try { s = JSON.parse(raw); } catch { return null; }
31
- if (s?.v !== VERSION || !Array.isArray(s.board?.items) || typeof s.savedAt !== 'string' || !Number.isFinite(Date.parse(s.savedAt))) return null;
32
+ if (s?.build !== build || !Array.isArray(s.board?.items) || typeof s.savedAt !== 'string' || !Number.isFinite(Date.parse(s.savedAt))) return null;
32
33
  const threads = Object.fromEntries(Object.entries(s.threads && typeof s.threads === 'object' ? s.threads : {}).filter(([, m]) => Array.isArray(m)));
33
34
  return { savedAt: s.savedAt, board: s.board, threads };
34
35
  }
@@ -42,7 +43,7 @@
42
43
  // What the board page takes taps on. view is 'snapshot' while the last visit shows, 'fresh' once a board load landed,
43
44
  // and null before either. blocked selects the controls that send to the host and wait for the fresh board; style.css's
44
45
  // INT-61 block dims the same list.
45
- const SNAPSHOT_BLOCKED = ['.actions button', 'button[data-plan]', 'button[data-bump]', 'button[data-confirm="bump"]', 'button[data-edit]', 'button[data-withdraw]',
46
+ const SNAPSHOT_BLOCKED = ['.actions button', 'button[data-plan]', 'button[data-bump]', 'button[data-card-bump]', 'button[data-edit]', 'button[data-withdraw]',
46
47
  'button[data-check-github]', 'form[data-reply] button[type=submit]', 'form[data-reply] button[data-quick]', 'form[data-reply] button[data-proto]', 'form[data-reply] button[data-confirm]', '.prompt button'].join(', ');
47
48
  const liveControls = (view) => {
48
49
  const fresh = view !== 'snapshot';
@@ -52,5 +53,10 @@
52
53
  // before and after map a line's id to its text; the ids of the lines that are new or read differently flash once.
53
54
  const changedLines = (before, after) => Object.keys(after).filter((id) => globalThis.BoardUI.record(before, id) !== after[id]);
54
55
 
55
- Object.assign(globalThis.BoardUI, { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, SNAPSHOT_MAX });
56
+ // What counts as a card's update: a new thread line, a status or Next line change, or a PR or CI change
57
+ // (docs/adr/0135-an-updated-card-takes-a-brief-tint.md). Two board items read the same exactly when it is unchanged.
58
+ const updateSign = (it) => JSON.stringify([it.threadCount ?? 0, it.status ?? null, it.builder?.progress ?? null, it.working ?? null, it.waitsOn ?? null,
59
+ it.next ?? null, it.ask?.state ?? null, it.pr ? [it.pr.state, it.pr.isDraft, it.pr.reviewDecision, it.pr.checks?.state ?? null] : null]);
60
+
61
+ Object.assign(globalThis.BoardUI, { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, updateSign, SNAPSHOT_MAX });
56
62
  })();
@@ -27,29 +27,37 @@
27
27
  // A message being edited shows its edit box in place of its text; app-asks.js handles the form.
28
28
  const editForm = (id, text) => `<form class="edit-form" data-edit-form="${esc(id)}"><textarea rows="3">${esc(text)}</textarea>
29
29
  <div class="row"><span></span><span><button type="button" data-edit-cancel>Cancel</button> <button class="primary" type="submit">Save</button></span></div></form>`;
30
- const line = (m, links, edits) => (m.kind === 'reply'
30
+ // batched: an answer followed by another of the owner's answers in its state, which carries the batch's Waiting for Claude.
31
+ const line = (m, links, edits, batched = false) => (m.kind === 'reply'
31
32
  ? `<div class="msg claude reply-msg"><div class="who">Claude · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
32
33
  : m.kind === 'ask-link' ? `<div class="msg link"><a href="${esc(m.link)}">${esc(m.text)} →</a></div>`
33
- : m.kind === 'status' ? `<div class="msg builder-status"><span class="working-dot" aria-hidden="true"></span>${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
34
- : m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}"><span class="agent-dot" aria-hidden="true"></span>${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
34
+ : m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}"><span class="working-dot" aria-hidden="true"></span>${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
35
+ : m.kind === 'calls' ? `<details class="msg calls"><summary>${esc(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
36
+ : m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}"><span class="agent-dot" aria-hidden="true"></span>${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
35
37
  ${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
36
38
  <div class="who">${m.who === 'love' ? 'You' : m.who === 'claude' ? 'Claude' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
37
39
  <div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
38
40
  ${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
39
- ${m.eventId ? msgFooter(m) : ''}
41
+ ${m.eventId && !batched ? msgFooter(m) : ''}
40
42
  </div>`);
41
43
 
42
44
  const STEP = 3;
43
- const NOT_MESSAGE = new Set(['status', 'agent', 'ask-link']);
45
+ const UNREAD_CAP = 10;
46
+ const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link']);
44
47
  const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
45
- // The badge's unread test (domain.mjs othersAt, ui-cards.js unreadCount).
46
- const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && m.who !== 'love' && String(m.ts) > seenAt;
48
+ // Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount).
49
+ const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
50
+ const batchedAt = (lines, i) => {
51
+ const next = lines.slice(i + 1).find(isMessage);
52
+ return lines[i].kind === 'owner-answer' && next?.kind === 'owner-answer' && stateOf(next) === stateOf(lines[i]);
53
+ };
54
+ // The window opens from the oldest unread message, at most UNREAD_CAP messages back; a message being edited always shows.
47
55
  function threadWindow(lines, { seenAt = null, more = 0, upto = null }, edits) {
48
56
  const counted = lines.flatMap((m, i) => (isMessage(m) && (!upto || String(m.ts) <= upto) ? [i] : []));
49
57
  const firstUnread = lines.findIndex((m) => unread(m, seenAt));
50
58
  const unreadSpan = firstUnread < 0 ? 0 : counted.filter((i) => i >= firstUnread).length;
51
- const want = Math.max(STEP, unreadSpan) + STEP * more;
52
- const pinned = lines.findIndex((m) => m.eventId && (edits.has(m.eventId) || ['waiting', 'working'].includes(stateOf(m))));
59
+ const want = Math.max(STEP, Math.min(UNREAD_CAP, unreadSpan)) + STEP * more;
60
+ const pinned = lines.findIndex((m) => m.eventId && edits.has(m.eventId));
53
61
  const start = Math.min(counted.length > want ? counted[counted.length - want] : 0, pinned < 0 ? Infinity : pinned);
54
62
  const hidden = lines.filter((m, i) => i < start && isMessage(m)).length;
55
63
  return { start: hidden ? start : 0, hidden };
@@ -65,16 +73,17 @@
65
73
  const { start, hidden } = view ? threadWindow(lines, view, edits) : { start: 0, hidden: 0 };
66
74
  const shown = lines.slice(start);
67
75
  if (start && shown[0].kind === 'reply' && !shown[0].quote) shown[0] = { ...shown[0], quote: lines[start - 1].text || '' };
68
- return (hidden ? moreButton(hidden) : '') + shown.map((m) => line(m, links, edits)).join('');
76
+ return (hidden ? moreButton(hidden) : '') + shown.map((m, i) => line(m, links, edits, batchedAt(lines, start + i))).join('');
69
77
  }
70
78
 
71
- // The reply form's Done, just left of Send, on every card not in Done: an ask's sends ask-done, a work card's or
72
- // follow-up's card-done, which reads "Closing…" until the session sets its status
73
- // (docs/adr/0035-an-ask-can-wait-in-progress-until-the-owner-marks-it-done.md).
74
- function doneButton(it, sending = false) {
79
+ // The reply form's Done sends ask-done or card-done; the board applies it before the session follows up.
80
+ // docs/adr/0106-the-board-applies-the-owners-hold-resume-and-done-at-once.md
81
+ // Orange while it clears an ask's Action needed, primary while a reopened card waits only on it
82
+ // (docs/adr/0136-a-card-waiting-only-to-be-closed-has-no-action-needed.md).
83
+ function doneButton(it) {
75
84
  if (!it?.finishable) return '';
76
- const closing = it.kind !== 'ask' && (sending || it.closing);
77
- return `<button type="button"${it.resolvedBy === BUTTON.markDone ? ' class="resolves"' : ''} data-confirm="finish" data-key="${esc(it.key)}"${closing ? ' disabled' : ''}>${closing ? 'Closing…' : 'Done'}</button>`;
85
+ const tone = it.resolvedBy === BUTTON.markDone ? 'resolves' : it.awaitsDone ? 'closes' : '';
86
+ return `<button type="button"${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}">Done</button>`;
78
87
  }
79
88
 
80
89
  Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton });
@@ -2,8 +2,6 @@
2
2
  // string and touches no DOM or storage: ui.test.mjs runs these files in node:vm, where neither exists. Load this first.
3
3
  // fetchJSON is the side pages' one way to read the board's API.
4
4
  (function () {
5
- // A bare ↗ can draw as an emoji tile on iOS; U+FE0E asks for its text glyph.
6
- const LINK_ARROW = '↗\uFE0E';
7
5
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
8
6
  const record = (records, key) => (Object.hasOwn(records || {}, key) ? records[key] : undefined);
9
7
  const time = (iso) => new Date(iso).toLocaleString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: '2-digit' });
@@ -26,6 +24,14 @@
26
24
  const day = d.toDateString() === now.toDateString() ? '' : `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })}, `;
27
25
  return `ETA about ${day}${clock(d)}`;
28
26
  }
27
+ // A card's last line: the time only today, with the day before today, and with the year too past 365 days.
28
+ function cardTime(iso, now = new Date()) {
29
+ const d = new Date(iso);
30
+ const at = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
31
+ if (d.toDateString() === now.toDateString()) return at;
32
+ const day = d.toLocaleDateString([], { month: 'numeric', day: 'numeric', ...(now - d > 365 * 86400000 ? { year: 'numeric' } : {}) });
33
+ return `${day}, ${at}`;
34
+ }
29
35
  const replyBody = (reply) => reply.html ?? esc(JSON.stringify(reply.json));
30
36
 
31
37
  // Attachments are uploads from POST /api/upload: {name, url, path, type, size}. Only uploads/ URLs become links.
@@ -43,14 +49,16 @@
43
49
  }
44
50
  const CLIP = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
45
51
  const attachButton = () => `<label class="attach" title="Attach files"><input type="file" multiple data-attach aria-label="Attach files">${CLIP}</label>`;
46
- const icon = (body, size = 18) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
52
+ const icon = (body, size = 18, attrs = '') => `<svg${attrs} width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
53
+ // An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
54
+ const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
47
55
  const EYE = icon('<path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/>');
48
56
  const TRASH = icon('<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>');
49
57
  const ELLIPSIS = icon('<circle cx="5" cy="12" r="1" fill="currentColor"/><circle cx="12" cy="12" r="1" fill="currentColor"/><circle cx="19" cy="12" r="1" fill="currentColor"/>');
50
58
  // Asks Claude for throwaway prototypes of one item (docs/adr/0015-prototypes-come-on-request.md).
51
- function protoButton(id, state = null) {
52
- if (state?.pending) return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" data-proto-pending="${esc(state.pending)}" aria-label="Prototypes are being made" title="Prototypes are being made">${ELLIPSIS}</button>`;
53
- return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="Ask for prototypes" title="Ask for prototypes">${EYE}</button>`;
59
+ function protoButton(id, state = null, { label = 'Ask for prototypes', disabled = false } = {}) {
60
+ if (state?.pending) return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" data-proto-pending="${esc(state.pending)}" aria-label="Prototypes are being made" title="Prototypes are being made"${disabled ? ' disabled' : ''}>${ELLIPSIS}</button>`;
61
+ return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}"${disabled ? ' disabled' : ''}>${EYE}</button>`;
54
62
  }
55
63
  // latestBump is the data.id of the newest ask-bump event.
56
64
  const protoNotice = (eventId, latestBump) => (latestBump === eventId
@@ -61,10 +69,14 @@
61
69
  // Deletes or edits a pending ask or thread message; app-asks.js handles [data-withdraw] and [data-edit] on one path.
62
70
  const withdrawButton = (id) => `<button class="ghost icon" data-withdraw="${esc(id)}" aria-label="Delete" title="Delete">${TRASH}</button>`;
63
71
  const editButton = (id) => `<button class="ghost icon" data-edit="${esc(id)}" aria-label="Edit" title="Edit">${PENCIL}</button>`;
64
- // A waiting ask's bump, which app-asks.js sends as ask-bump {id}. A work card's is the same icon, which app-cards.js
65
- // confirms before it sends card-bump {key} (docs/adr/0019-a-card-that-hasnt-started-can-be-bumped.md).
66
- const bumpButton = (it, top) => `<button class="ghost icon" data-bump="${esc(it.id)}" aria-label="Bump to top" title="Bump to top"${top ? ' disabled' : ''}>${BUMP}</button>`;
67
- const cardBumpButton = (key, top) => `<button class="ghost icon" data-confirm="bump" data-key="${esc(key)}" aria-label="Bump to top" title="Bump to top"${top ? ' disabled' : ''}>${BUMP}</button>`;
72
+ // A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange once bumped and disabled on the latest
73
+ // bump. A work card's is the same icon: app-cards.js sends card-bump {key} with no confirm, and on a bumped card, where
74
+ // it is filled orange, card-unbump {key} (docs/adr/0019-a-card-that-hasnt-started-can-be-bumped.md,
75
+ // docs/adr/0145-build-on-a-blocked-card-queues-it.md).
76
+ const bumpButton = (it, top) => `<button class="ghost icon${it.bumpedAt ? ' bumped' : ''}" data-bump="${esc(it.id)}" aria-label="Bump to top" title="Bump to top"${top ? ' disabled' : ''}>${BUMP}</button>`;
77
+ const cardBumpButton = (key, bumped) => (bumped
78
+ ? `<button class="ghost icon bumped" data-card-bump="${esc(key)}" data-bumped aria-pressed="true" aria-label="Unbump" title="Bumped. Tap to unbump">${BUMP}</button>`
79
+ : `<button class="ghost icon" data-card-bump="${esc(key)}" aria-pressed="false" aria-label="Bump to top" title="Bump to top">${BUMP}</button>`);
68
80
  const workingLine = '<span class="working-dot" aria-hidden="true"></span>Working on it';
69
81
  // html is markup the caller has escaped; href, title and attrs values are escaped here.
70
82
  const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready']);
@@ -229,7 +241,7 @@
229
241
 
230
242
  globalThis.BoardUI = {
231
243
  LINK_ARROW,
232
- esc, record, time, ago, since, etaLine, replyBody, setHTML, fetchJSON, RESTARTING, restartNotice,
244
+ esc, record, time, ago, since, clock, etaLine, cardTime, replyBody, setHTML, fetchJSON, RESTARTING, restartNotice,
233
245
  attachments, attachButton, pendingFiles, protoButton, protoNotice, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
234
246
  };
235
247
  })();