@lovek4d/kickflip 0.1.0 → 0.4.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 (120) hide show
  1. package/CONTEXT.md +239 -104
  2. package/README.md +54 -4
  3. package/SKILL.md +63 -42
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +103 -40
  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/auto-review.mjs +2 -0
  11. package/board/bin/agent.mjs +1 -1
  12. package/board/bin/board.mjs +371 -82
  13. package/board/bin/builder-stop-hook.mjs +4 -3
  14. package/board/domain.mjs +8 -3
  15. package/board/finish.mjs +3 -0
  16. package/board/headless.mjs +3 -0
  17. package/board/on-host.mjs +1 -0
  18. package/board/permissions.mjs +2 -0
  19. package/board/public/app-arch.js +75 -0
  20. package/board/public/app-asks.js +66 -28
  21. package/board/public/app-banner.js +2 -2
  22. package/board/public/app-cards.js +354 -77
  23. package/board/public/app-core.js +40 -8
  24. package/board/public/app-header.js +33 -8
  25. package/board/public/app-lanes.js +238 -34
  26. package/board/public/app-needs.js +2 -0
  27. package/board/public/app-snapshot.js +45 -11
  28. package/board/public/app-threads.js +20 -21
  29. package/board/public/app.html +6 -2
  30. package/board/public/app.js +114 -33
  31. package/board/public/attach.js +39 -3
  32. package/board/public/dialogs.js +161 -118
  33. package/board/public/hub-kit.js +27 -7
  34. package/board/public/hub.css +26 -33
  35. package/board/public/linear-key.js +143 -0
  36. package/board/public/log.html +48 -0
  37. package/board/public/prompts.js +9 -8
  38. package/board/public/prototype-kit.js +6 -6
  39. package/board/public/prototype-switcher.js +13 -5
  40. package/board/public/prototype.html +1 -0
  41. package/board/public/router.js +14 -10
  42. package/board/public/rules-editor.js +4 -5
  43. package/board/public/shell.js +21 -7
  44. package/board/public/sidebar.css +51 -11
  45. package/board/public/sidebar.js +124 -19
  46. package/board/public/style.css +244 -56
  47. package/board/public/toasts.js +5 -3
  48. package/board/public/ui-cards.js +236 -66
  49. package/board/public/ui-enums.js +5 -4
  50. package/board/public/ui-header.js +23 -14
  51. package/board/public/ui-hub.js +34 -16
  52. package/board/public/ui-links.js +18 -10
  53. package/board/public/ui-markdown.js +1 -1
  54. package/board/public/ui-order.js +21 -0
  55. package/board/public/ui-page-header.js +13 -8
  56. package/board/public/ui-patch.js +53 -0
  57. package/board/public/ui-phase-bars.js +17 -0
  58. package/board/public/ui-prompts.js +8 -8
  59. package/board/public/ui-questions.js +35 -11
  60. package/board/public/ui-sidebar.js +83 -21
  61. package/board/public/ui-snapshot.js +30 -13
  62. package/board/public/ui-threads.js +38 -23
  63. package/board/public/ui.js +96 -16
  64. package/board/public/view-frame.js +1 -1
  65. package/board/public/view-hub-settings.js +67 -44
  66. package/board/public/view-hub.js +105 -13
  67. package/board/public/view-notes.js +8 -5
  68. package/board/public/view-questions.js +123 -52
  69. package/board/public/view-settings.js +235 -62
  70. package/board/review.mjs +1 -0
  71. package/board/server.mjs +18 -7
  72. package/board/store.mjs +15 -6
  73. package/board/tickets.mjs +3 -3
  74. package/board/worktree.mjs +20 -13
  75. package/docs/hub-session.md +4 -4
  76. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  77. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  78. package/hub/awake.mjs +1 -0
  79. package/hub/board-repo.mjs +1 -1
  80. package/hub/boards.mjs +4 -4
  81. package/hub/close-board.mjs +1 -1
  82. package/hub/duration.mjs +1 -0
  83. package/hub/github.mjs +3 -4
  84. package/hub/home.mjs +1 -1
  85. package/hub/hub.mjs +12 -10
  86. package/hub/incoming.mjs +2 -0
  87. package/hub/linear.mjs +2 -2
  88. package/hub/live.mjs +2 -2
  89. package/hub/new-board.mjs +2 -2
  90. package/hub/permission-denied-hook.mjs +2 -0
  91. package/hub/pr-report.mjs +3 -0
  92. package/hub/presence.mjs +1 -1
  93. package/hub/prompts.mjs +1 -1
  94. package/hub/push.mjs +2 -2
  95. package/hub/readiness.mjs +2 -0
  96. package/hub/repos.mjs +3 -2
  97. package/hub/restart-board.mjs +1 -0
  98. package/hub/routes.mjs +1 -1
  99. package/hub/schedule.mjs +1 -1
  100. package/hub/sessions.mjs +2 -2
  101. package/hub/shell.mjs +2 -1
  102. package/hub/slack.mjs +2 -0
  103. package/hub/stream.mjs +12 -12
  104. package/hub/sw.js +2 -2
  105. package/hub/trust.mjs +2 -0
  106. package/hub/try.mjs +8 -3
  107. package/hub/upgrade.mjs +1 -0
  108. package/hub/usage.mjs +3 -3
  109. package/hub/views.mjs +1 -1
  110. package/package.json +5 -1
  111. package/shared/card-prs.mjs +1 -0
  112. package/shared/colors.css +9 -6
  113. package/shared/fake-gh.mjs +105 -0
  114. package/shared/repos.mjs +1 -1
  115. package/shared/service.mjs +16 -16
  116. package/shared/skills.mjs +2 -0
  117. package/shared/usage-gate.mjs +1 -1
  118. package/starter/public/claude.js +3 -2
  119. package/bin/web-ts.mjs +0 -2
  120. package/board/public/view-polling.js +0 -87
@@ -1,14 +1,21 @@
1
- // The rows and tiles of the hub page and settings modals (view-hub.js, view-hub-settings.js, view-polling.js), which hub.css styles
1
+ // The rows and tiles of the hub page and settings modals (view-hub.js, view-hub-settings.js), which hub.css styles
2
2
  // under .hub-view. Pure: each lays out words hub/views.mjs wrote, and title and sub are HTML.
3
3
  (function () {
4
4
  const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
5
5
  const TILE_PATHS = {
6
+ github: '<circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/>',
7
+ codex: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3"/><path d="M13 15h4"/>',
8
+ linear: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
9
+ slack: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
10
+ tailscale: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18"/>',
11
+ notifications: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
6
12
  bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
7
13
  clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
8
14
  moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
9
15
  power: '<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/>',
10
16
  calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h18"/>',
11
17
  snooze: '<path d="M4 10h6l-6 8h6"/><path d="M14 4h6l-6 8h6"/>',
18
+ coffee: '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><path d="M6 2v2"/><path d="M10 2v2"/><path d="M14 2v2"/>',
12
19
  laptop: '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M2 19h20"/>',
13
20
  key: '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
14
21
  };
@@ -36,27 +43,28 @@
36
43
  return `<div class="hold-banner ub-banner ub-${going ? 'going' : 'paused'}" role="status"><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">${icon}</svg><span>${esc(text)}</span><form method="post" action="/_hub/settings/keep-going"><button class="btn${going ? '' : ' quiet'}" name="value" value="${going ? 'off' : 'on'}">${going ? 'Stop' : 'Keep going'}</button></form></div>`;
37
44
  }
38
45
 
39
- function bufferControl(pause) {
46
+ // builder: the word for who builds Codex's work, hub/views.mjs hubView's claude.builder.
47
+ function bufferControl(pause, builder) {
40
48
  const at = pause.pause_at;
41
49
  const off = at === 100;
42
- return `<div class="u-buffer"><div class="ub-row"><span class="ub-label"><span class="title">Pause work at</span><span class="ub-sub">${off ? 'No buffer' : `${100 - at}% buffer`}</span></span>`
50
+ return `<div class="block"><div class="ub-row"><span class="ub-label"><span class="title">Pause work at</span><span class="ub-sub">${off ? 'No buffer' : `${100 - at}% buffer`}</span></span>`
43
51
  + `<form class="segments ub-step" method="post" action="/_hub/settings/pause-at" role="group" aria-label="Pause work at"><button name="value" value="${at - 5}" aria-label="Pause 5% sooner"${at <= 50 ? ' disabled' : ''}>−</button>`
44
52
  + `<output class="${off ? 'off' : ''}" aria-live="polite">${off ? 'Off' : `${at}%`}</output>`
45
53
  + `<button name="value" value="${at + 5}" aria-label="Pause 5% later"${off ? ' disabled' : ''}>+</button></form></div>`
46
- + `<p class="u-share-desc">${off ? 'Off: work keeps going until usage runs out.' : 'Claude’s new work waits when either window passes this, and resumes at that window’s reset; Codex hands its work to Opus builders.'}</p></div>`;
54
+ + `<p class="u-share-desc">${off ? 'Off: work keeps going until usage runs out.' : `Claude’s new work waits when either window passes this, and resumes at that window’s reset; Codex hands its work to ${builder} implementers.`}</p></div>`;
47
55
  }
48
56
 
49
57
  // What polling does now, the last and next check and the queue, with Send now: hub/views.mjs pollingNow's words.
50
58
  const nowRow = (now, icon = '') => row({ icon, title: esc(now.title), sub: now.lines.map((l) => `<span class="sub wrap">${esc(l)}</span>`).join(''),
51
59
  trail: `<form method="post" action="/_hub/push/flush"><button class="btn quiet" ${now.count ? '' : 'disabled'}>Send now</button></form>` });
52
60
 
53
- // This device's notifications (hub-kit.js device): state is {title, sub, action: {label, act, quiet, disabled}} or
54
- // null while unknown, which keeps the row hidden.
61
+ // This device's notifications (hub-kit.js device): state is {title, sub, action: {label, act, quiet, busy}} or null
62
+ // while unknown, which keeps the row hidden. A busy action waits (ui.js BUSY) while it turns on or tests.
55
63
  function deviceRow(state, { compact = false } = {}) {
56
- if (!state) return `<div class="row" id="push" hidden>${tile('bell', '#ef4444')}</div>`;
64
+ if (!state) return `<div class="row" id="push" hidden>${tile('bell', 'var(--accent)')}</div>`;
57
65
  const a = state.action;
58
- const button = a ? `<button type="button" class="${a.quiet ? 'btn quiet' : 'btn'}" data-device="${esc(a.act)}"${a.disabled ? ' disabled' : ''}>${esc(a.label)}</button>` : '';
59
- return `<div class="row" id="push">${tile('bell', '#ef4444')}<span class="body"><span class="text"><span class="title">${esc(state.title)}</span>${compact ? '' : `<span class="sub wrap">${esc(state.sub)}</span>`}</span>${compact && state.title === 'This device' ? `<span class="val">${state.sub?.startsWith('On') ? 'On' : 'Off'}</span>` : ''}${button}</span></div>`;
66
+ const button = a ? `<button type="button" class="${a.quiet ? 'btn quiet' : 'btn'}" data-device="${esc(a.act)}"${a.busy ? BoardUI.BUSY : ''}>${esc(a.label)}</button>` : '';
67
+ return `<div class="row" id="push">${tile('bell', state.action?.act === 'test' ? 'var(--color-success)' : 'var(--accent)')}<span class="body"><span class="text"><span class="title">${esc(state.title)}</span>${compact ? '' : `<span class="sub wrap">${esc(state.sub)}</span>`}</span>${compact && state.title === 'This device' ? `<span class="val">${state.sub?.startsWith('On') ? 'On' : 'Off'}</span>` : ''}${button}</span></div>`;
60
68
  }
61
69
 
62
70
  // The Codex share control: codex is hub/views.mjs hubView's codex, and its form posts to /_hub/settings/codex, which
@@ -65,14 +73,24 @@
65
73
  const off = !codex.usable;
66
74
  const buttons = codex.choices.map((c) => `<button type="submit" name="value" value="${esc(c.value)}" aria-pressed="${c.on}"${off ? ' disabled' : ''}>${esc(c.label)}</button>`).join('');
67
75
  return `<div class="u-share"><form class="segments${off ? ' is-disabled' : ''}" method="post" action="/_hub/settings/codex" role="group" aria-label="Codex share"${off ? ' aria-disabled="true"' : ''}>${buttons}</form>`
68
- + `<p class="u-share-desc">${esc(codex.description)}</p>${codex.fix ? `<p class="u-share-fix"><code>${esc(codex.fix)}</code></p>` : ''}${codex.note ? `<p class="u-share-note">${esc(codex.note)}</p>` : ''}</div>`;
76
+ + `<p class="u-share-desc">${esc(codex.description)}</p>${codex.fix ? `<p class="u-share-fix"><code>${esc(codex.fix)}</code></p>` : ''}</div>`;
69
77
  }
70
78
 
71
- // data is the usage reading; codex, when given, draws Codex's block even without a reading: the Codex share control
79
+ // The Claude model control, drawn as Codex share's: claude is hub/views.mjs hubView's claude, and its form posts to
80
+ // /_hub/settings/claude-model, which view-hub.js sends.
81
+ function claudeModel(claude) {
82
+ const buttons = claude.choices.map((c) => `<button type="submit" name="value" value="${esc(c.value)}" aria-pressed="${c.on}">${esc(c.label)}</button>`).join('');
83
+ return `<div class="u-share"><form class="segments" method="post" action="/_hub/settings/claude-model" role="group" aria-label="Claude model">${buttons}</form>`
84
+ + `<p class="u-share-desc">${esc(claude.description)}</p></div>`;
85
+ }
86
+
87
+ // data is the usage reading; claude, when given, opens Claude's block with the Claude model control, even without a
88
+ // reading; codex, when given, draws Codex's block even without a reading: the Codex share control
72
89
  // first, then Codex's bars while share isn't Off.
73
- function usageCard(data, codex, pause) {
90
+ function usageCard(data, codex, pause, claude) {
74
91
  const { usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords } = BoardUI;
75
92
  const products = usageProducts(data);
93
+ if (claude && !products.includes('claude')) products.unshift('claude');
76
94
  if (codex && !products.includes('codex')) products.push('codex');
77
95
  if (!products.length) return '';
78
96
  const head = (name, tag) => `<div class="u-head"><span class="title">${name}</span>${tag ? `<span class="u-asof">${esc(tag)}</span>` : ''}</div>`;
@@ -81,13 +99,13 @@
81
99
  `<div class="u-tone-${usageTone(r[key])}${pause?.pause_at < 100 ? ' ub-marked' : ''}"><div class="u-win"><span>${label}</span><span>${usageWords(r, key)}</span></div>${usageBar(r[key]).replace('</span>', `${pause?.pause_at < 100 ? `<span class="ub-mark" style="left:${pause.pause_at}%"></span>` : ''}</span>`)}</div>`).join('');
82
100
  const blocks = products.map((p) => {
83
101
  const r = data?.[p] || { at: null };
84
- if (p !== 'codex' || !codex) return `<div class="block u-block">${head(usageNames[p], asof(r))}${windows(r)}${p === 'claude' && pause ? bufferControl(pause) : ''}</div>`;
102
+ if (p === 'claude' && claude) return `<div class="block u-block">${head(usageNames.claude, asof(r))}${claudeModel(claude)}${windows(r)}</div>`;
103
+ if (p !== 'codex' || !codex) return `<div class="block u-block">${head(usageNames[p], asof(r))}${windows(r)}</div>`;
85
104
  const bars = codex.usable && codex.share !== 'off';
86
105
  return `<div class="block u-block">${head(usageNames.codex, codex.tag || (bars ? asof(r) : ''))}${codexShare(codex)}${bars ? `<div class="u-codex-bars">${windows(r)}</div>` : ''}</div>`;
87
106
  }).join('');
88
- const source = [data?.claude && 'Claude Code’s status line, which every busy session redraws', data?.codex && 'Codex’s newest session logs'].filter(Boolean).join(', and ');
89
- const note = source ? `<p class="note">From ${source}. A window whose reset time passed reads as reset.</p>` : '';
90
- return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}</div>${note}</section>`;
107
+ const buffer = pause && products.includes('claude') ? bufferControl(pause, claude?.builder ?? 'Claude') : '';
108
+ return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}${buffer}</div></section>`;
91
109
  }
92
110
 
93
111
  globalThis.HubUI = { esc, tile, CHEVRON, row, flash, holdBanner, pauseBanner, nowRow, deviceRow, usageCard };
@@ -3,22 +3,28 @@
3
3
  // the ui-*.js files that render text.
4
4
  (function () {
5
5
  const { esc, record } = globalThis.BoardUI;
6
- // The Linear id shape store.mjs accepts for a follow-up, which an internal ref shares.
6
+ // The Linear id shape, which an internal ref shares.
7
7
  const LINEAR_ID = /^([A-Z][A-Z0-9]*)-\d+$/;
8
8
  const LINEAR_ISSUE = /^https:\/\/linear\.app\/[^/]+\/issue\//;
9
+ // A GitHub ticket's ref (board/domain.mjs refOf).
10
+ const ISSUE = /^#\d+$/;
9
11
  const prUrl = (repo, n) => `https://github.com/${repo}/pull/${n}`;
10
12
 
11
- // What a renderer needs to link text shown on item `key` (null for none): each ref to its card's key, the issue url
12
- // prefix of each Linear team the board's cards link to (none on a board without a workspace), that item's repo for
13
- // a bare #n, and `base`, which a page other than the board sets to './' so a card link opens the board. A ref shaped
14
- // like a Linear id, such as INT-<n>, links from anywhere; a wave's own short id, such as B1, links only from that
15
- // wave's cards, since a plan for another wave reuses those names.
13
+ // What a renderer needs to link text shown on item `key` (null for none): each ref, and each card id that differs
14
+ // from its ref, to its card's key, the issue url prefix of each Linear team the board's cards link to (none on a
15
+ // board without a workspace), that item's repo for a bare #n that is no card's ref, and `base`, which a page other
16
+ // than the board sets to './' so a card link opens the board. A ref shaped like a Linear id, such as INT-<n>, or a
17
+ // ticket #n links from anywhere; a wave's own short id, such as B1, links only from that wave's cards, since a plan
18
+ // for another wave reuses those names.
16
19
  function linkContext(items = [], key = null, { base = '' } = {}) {
17
20
  const wave = key && !key.startsWith('ask:') ? key.slice(0, key.indexOf('/')) : null;
18
21
  const byRef = new Map();
19
22
  for (const i of items) {
20
- if (i.kind === 'ask' || !i.ref || (!LINEAR_ID.test(i.ref) && i.wave !== wave)) continue;
21
- byRef.set(i.ref, [...(byRef.get(i.ref) || []), i]);
23
+ if (i.kind === 'ask') continue;
24
+ for (const ref of new Set([i.ref, i.id])) {
25
+ if (!ref || (!LINEAR_ID.test(ref) && !ISSUE.test(ref) && i.wave !== wave)) continue;
26
+ byRef.set(ref, [...(byRef.get(ref) || []), i]);
27
+ }
22
28
  }
23
29
  const refs = Object.create(null);
24
30
  for (const [ref, list] of byRef) {
@@ -62,11 +68,13 @@
62
68
  return anyLink(url, url) + esc(raw.slice(url.length));
63
69
  }
64
70
 
71
+ const cardLink = (ref, ctx) => `<a href="${esc(`${ctx.base || ''}#item=${encodeURIComponent(record(ctx.refs, ref))}`)}">${esc(ref)}</a>`;
72
+
65
73
  // The longest hyphen-prefix of a word that is a known ref or a Linear id, then the rest as text.
66
74
  function wordLink(word, ctx) {
67
75
  for (let w = word; w; w = w.includes('-') ? w.slice(0, w.lastIndexOf('-')) : '') {
68
76
  const rest = esc(word.slice(w.length));
69
- if (record(ctx.refs, w)) return `<a href="${esc(`${ctx.base || ''}#item=${encodeURIComponent(record(ctx.refs, w))}`)}">${esc(w)}</a>${rest}`;
77
+ if (record(ctx.refs, w)) return cardLink(w, ctx) + rest;
70
78
  const team = LINEAR_ID.exec(w)?.[1];
71
79
  const prefix = team && team !== 'INT' && record(ctx.teams, team);
72
80
  if (prefix) return out(prefix + w, w) + rest;
@@ -90,7 +98,7 @@
90
98
  else if (name) {
91
99
  const repo = owner ? `${owner}/${name}` : record(ctx.repos, name);
92
100
  html += repo ? out(prUrl(repo, n), all) : esc(all);
93
- } else if (bare) html += ctx.repo && !colour(bare) ? out(prUrl(ctx.repo, bare), all) : esc(all);
101
+ } else if (bare) html += record(ctx.refs, all) ? cardLink(all, ctx) : ctx.repo && !colour(bare) ? out(prUrl(ctx.repo, bare), all) : esc(all);
94
102
  else html += wordLink(word, ctx);
95
103
  }
96
104
  return html + esc(s.slice(at));
@@ -1,4 +1,4 @@
1
- // A small Markdown renderer for Notes (view-notes.js; docs/adr/0024-a-full-plan-opens-as-a-page-on-the-site.md). Needs ui.js and ui-links.js.
1
+ // A small Markdown renderer for Notes (view-notes.js; docs/adr/2026-10-29-1639-a-full-plan-opens-as-a-page-on-the-site.md). Needs ui.js and ui-links.js.
2
2
  (function () {
3
3
  const { esc, linkHtml } = globalThis.BoardUI;
4
4
  // Raw HTML is escaped first, so a link's href can only break out through its scheme.
@@ -0,0 +1,21 @@
1
+ // The board's order of its items, each lane together, which board/domain.mjs sorts the board by and the page sorts a
2
+ // tap it draws at once by, so the card lands where the server will put it
3
+ // (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, queued, bumpedAt, sortAt, order and key.
4
+ // Needs nothing else: board/domain.mjs runs this file in a vm context.
5
+ (function () {
6
+ const LANE_ORDER = { review: 0, stale: 1, progress: 2, todo: 3, done: 4, asks: 5 };
7
+ // New cards lead Todo, oldest first, so the top one is the next taken (docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md);
8
+ // then bumped cards, the latest bump on top (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md); then the rest
9
+ // newest first by sortAt, a card with no time last, the last added to its wave file on top. Todo's Queued cards
10
+ // lead it, bumped first and then oldest first, so the top one is the next to build
11
+ // (docs/adr/2026-11-04-0150-queued-sits-at-the-top-of-todo.md).
12
+ function laneOrder(a, b) {
13
+ const bumped = (i) => i.bumpedAt || '';
14
+ const unsorted = (i) => (i.isNew ? 0 : 1);
15
+ const queued = (i) => (i.queued ? 1 : 0);
16
+ const at = (i) => String(i.sortAt || '');
17
+ const age = a.isNew || a.queued ? at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
18
+ return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || queued(b) - queued(a) || unsorted(a) - unsorted(b) || bumped(b).localeCompare(bumped(a)) || age || b.order - a.order || a.key.localeCompare(b.key);
19
+ }
20
+ Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
21
+ })();
@@ -6,9 +6,11 @@
6
6
  const ARROW = icon(24, '<path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/>');
7
7
  const REFRESH = icon(18, '<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>');
8
8
  const GEAR = icon(18, '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>');
9
+ const MOON = icon(16, '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>');
10
+ const BULB = icon(18, '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>');
9
11
  const id = (v) => (v ? ` id="${esc(v)}"` : '');
10
12
 
11
- // Where the back arrow goes (docs/adr/0048-a-questions-takes-show-inline.md): 'back', one step back in history, when
13
+ // Where the back arrow goes (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md): 'back', one step back in history, when
12
14
  // the page before was on this hub; else 'href', its backHref. A page a push opened always takes its backHref. idx is
13
15
  // the entry's index in history.state, set by an in-app router: when present, it alone says whether a step back exists.
14
16
  function backStep({ pushed = false, referrer = '', href, depth = 1, idx }) {
@@ -39,24 +41,27 @@
39
41
  }
40
42
 
41
43
  // lead is 'logo' (the kickflip, which toggles the sidebar, sidebar.js; logoHref, the hub's page, without it)
42
- // or 'back' (the arrow, which follows backStep, with backHref the work item or the board, beside a phone-only logo). The controls (time and refresh, gear) paint disabled with refresh spinning until the
43
- // page's script turns them on (docs/adr/0021-the-board-shows-github-checks-and-watcher-health.md). With controls
44
- // false, trail (HTML) takes their place. leadAttrs adds to or replaces the lead link's attributes, and level is the
45
- // title's heading level, for a header that isn't the page's own, such as the sidebar's (ui-sidebar.js).
44
+ // or 'back' (the arrow, which follows backStep, with backHref the work item or the board, after the logo). The controls (time and refresh, gear) paint disabled with refresh spinning until the
45
+ // page's script turns them on (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md). With controls
46
+ // false, trail (HTML) takes their place. bulb puts the architecture review's bulb between them (app-arch.js). leadAttrs adds to or replaces the lead link's attributes, and level is the
47
+ // title's heading level, for a header that isn't the page's own, such as the sidebar's (ui-sidebar.js). moon puts the
48
+ // away moon right of the title, hidden until app-header.js renderStatus shows it (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md).
46
49
  function header({ lead = 'logo', title = '', titleId, backHref = './', backLabel = 'Back to the board', backId, logoSrc = 'kickflip.png',
47
- controls = true, trail = '', time = '…', settingsHref = 'settings.html', logoHref = '/_hub/', leadAttrs = {}, level = 1 } = {}) {
50
+ controls = true, trail = '', time = '…', bulb = false, moon = false, settingsHref = 'settings.html', logoHref = '/_hub/', leadAttrs = {}, level = 1 } = {}) {
48
51
  const attrs = (base) => Object.entries({ ...base, ...leadAttrs }).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
49
52
  const logo = (cls = 'hub') => `<a class="${cls}" href="${esc(logoHref)}"${attrs({ 'aria-label': 'Boards' })}><img src="${esc(logoSrc)}" alt=""></a>`;
50
53
  const first = lead === 'back'
51
- ? `<a class="back" href="${esc(backHref)}"${id(backId)}${attrs({ 'aria-label': backLabel })}>${ARROW}</a>${logo('hub phone-logo')}`
54
+ ? `${logo('hub')}<a class="back" href="${esc(backHref)}"${id(backId)}${attrs({ 'aria-label': backLabel })}>${ARROW}</a>`
52
55
  : logo();
53
56
  const nav = controls ? `<nav class="nav">`
54
57
  + `<span class="refresh"><button class="ghost spinning" id="refresh" type="button" disabled aria-label="Refresh" title="Refresh">${REFRESH}</button><span class="muted" id="status">${esc(time)}</span></span>`
58
+ + (bulb ? `<button class="ghost icon arch-bulb" id="archBulb" type="button" aria-label="Architecture review" title="Architecture review" aria-haspopup="dialog">${BULB}</button>` : '')
55
59
  + (settingsHref ? `<a href="${esc(settingsHref)}" id="settingsLink" class="ghost icon" aria-label="Settings" title="Settings" aria-disabled="true" tabindex="-1" aria-haspopup="dialog">${GEAR}</a>` : '')
56
60
  + `</nav>` : trail;
57
61
  const script = lead === 'back' ? `<script>(${wireBack})(${backStep});</script>` : '';
58
62
  const h = `h${[1, 2, 3, 4, 5, 6].includes(level) ? level : 1}`;
59
- return `<header class="top"><div class="brand">${first}<${h}${id(titleId)}>${esc(title)}</${h}></div>${nav}</header>${script}`;
63
+ const away = moon ? `<span class="away-moon" role="img" aria-label="Away" aria-hidden="true">${MOON}</span>` : '';
64
+ return `<header class="top"><div class="brand">${first}<${h}${id(titleId)}>${esc(title)}</${h}>${away}</div>${nav}</header>${script}`;
60
65
  }
61
66
 
62
67
  Object.assign(globalThis.BoardUI ||= {}, { header, backStep });
@@ -0,0 +1,53 @@
1
+ // The board's per-card patches (docs/adr/2026-11-04-0332-the-board-sends-per-card-patches.md): server.mjs makes one
2
+ // from the board a page holds to the board now, and the page applies it to the board it holds. Cards patch by key,
3
+ // questions and topics by id, each sent whole when it changed or dropped when gone, with the list's new order when
4
+ // that changed; every other part of the board comes whole when it changed.
5
+ // Needs nothing else: board/server.mjs runs this file in a vm context.
6
+ (function () {
7
+ const LISTS = { items: 'key', owner: 'id', ownerTopics: 'id' };
8
+ const OWN = new Set(['version', 'generatedAt', ...Object.keys(LISTS)]);
9
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
10
+
11
+ function listPatch(from, to, id) {
12
+ const was = new Map(from.map((r) => [r[id], r]));
13
+ const now = new Set(to.map((r) => r[id]));
14
+ const set = to.filter((r) => !was.has(r[id]) || !same(was.get(r[id]), r));
15
+ const drop = from.map((r) => r[id]).filter((k) => !now.has(k));
16
+ if (!set.length && !drop.length && same(from.map((r) => r[id]), to.map((r) => r[id]))) return null;
17
+ const placed = [...from.map((r) => r[id]).filter((k) => now.has(k)), ...to.map((r) => r[id]).filter((k) => !was.has(k))];
18
+ const order = to.map((r) => r[id]);
19
+ return { set, drop, ...(same(placed, order) ? {} : { order }) };
20
+ }
21
+
22
+ // from and to are page boards, each with its version. → {since, version, generatedAt, set, unset, lists}
23
+ function boardPatch(from, to) {
24
+ const set = {};
25
+ for (const [k, v] of Object.entries(to)) if (!OWN.has(k) && (!(k in from) || !same(from[k], v))) set[k] = v;
26
+ const unset = Object.keys(from).filter((k) => !OWN.has(k) && !(k in to));
27
+ const lists = {};
28
+ for (const [name, id] of Object.entries(LISTS)) {
29
+ const p = listPatch(from[name] || [], to[name] || [], id);
30
+ if (p) lists[name] = p;
31
+ }
32
+ return { since: from.version, version: to.version, generatedAt: to.generatedAt, set, unset, lists };
33
+ }
34
+
35
+ // → a new board, leaving board as it was, or null when the patch was made against another version.
36
+ function applyPatch(board, patch) {
37
+ if (!board || board.version !== patch.since) return null;
38
+ const next = { ...board, ...patch.set, version: patch.version, generatedAt: patch.generatedAt };
39
+ for (const k of patch.unset) delete next[k];
40
+ for (const [name, p] of Object.entries(patch.lists)) {
41
+ const id = LISTS[name];
42
+ const gone = new Set(p.drop);
43
+ const byId = new Map((board[name] || []).filter((r) => !gone.has(r[id])).map((r) => [r[id], r]));
44
+ const fresh = p.set.filter((r) => !byId.has(r[id]));
45
+ for (const r of p.set) byId.set(r[id], r);
46
+ const order = p.order || [...(board[name] || []).map((r) => r[id]).filter((k) => !gone.has(k)), ...fresh.map((r) => r[id])];
47
+ next[name] = order.map((k) => byId.get(k));
48
+ }
49
+ return next;
50
+ }
51
+
52
+ Object.assign(globalThis.BoardUI ||= {}, { boardPatch, applyPatch });
53
+ })();
@@ -0,0 +1,17 @@
1
+ // The phase bars: one bar per phase, those reached filled, a running one pulsing. An In Progress card draws the five
2
+ // build phases (ui-cards.js) and a starting board's sidebar row its three start steps (ui-sidebar.js)
3
+ // (docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md). It needs nothing else, since
4
+ // hub/hub.mjs renders the shell's first sidebar with it in a vm context. style.css draws the bars.
5
+ (function () {
6
+ const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
7
+ // strip: {reached, running, dim, label, fill}, or null for no bars. reached counts the phases reached, the last of them
8
+ // running when running is set; fill, a percent, fills the next bar that far.
9
+ function phaseBars(strip, phases) {
10
+ if (!strip) return '';
11
+ const { reached, running, dim, label, fill } = strip;
12
+ const bar = (i) => (i === reached - 1 && running ? '<i class="now"></i>' : i < reached ? '<i class="done"></i>'
13
+ : i === reached && fill !== undefined ? `<i class="fill" style="--p:${fill}%"></i>` : '<i class=""></i>');
14
+ return `<span class="phase-bars${dim ? ' dim' : ''}" role="img" aria-label="${esc(label)}" title="${esc(label)}">${phases.map((_, i) => bar(i)).join('')}</span>`;
15
+ }
16
+ Object.assign(globalThis.BoardUI ||= {}, { phaseBars });
17
+ })();
@@ -18,21 +18,21 @@
18
18
  // Whether one tap on an option answers: a single question that takes one pick.
19
19
  const oneTap = (p) => p.questions?.length === 1 && !p.questions[0].multiSelect;
20
20
 
21
- // sending: true while the answer goes, which disables the line's buttons.
22
- function promptItem(p, { picks = {}, sending = false } = {}) {
23
- const off = sending ? ' disabled' : '';
21
+ // sending: while the answer goes, the tapped button: a choice ('allow', 'always', 'deny'), 'send', or an option as
22
+ // '<question index>:<label>'. It waits (ui.js BUSY) and the line's other buttons are disabled.
23
+ function promptItem(p, { picks = {}, sending = null } = {}) {
24
+ const off = (tapped) => (sending === null ? '' : sending === tapped ? BoardUI.BUSY : ' disabled');
24
25
  const where = [ago(p.at), p.cwd].filter(Boolean).map(esc).join(' · ');
25
26
  const head = `<div class="prompt-head"><b>${esc(p.questions ? 'Question' : p.tool)}</b><span class="muted small">${where}</span></div>`;
26
- const status = sending ? '<span class="muted small">Sending…</span>' : '';
27
27
  if (p.questions) {
28
28
  const questions = p.questions.map((q, i) => `<div class="prompt-q"><p>${q.header ? `<span class="chip">${esc(q.header)}</span> ` : ''}${esc(q.question)}</p>
29
- <div class="prompt-options" role="group" aria-label="${esc(q.question)}">${q.options.map((o) => `<button type="button" data-prompt-q="${i}" data-prompt-opt="${esc(o.label)}" aria-pressed="${(picks[i] || []).includes(o.label)}"${off}><span class="lbl">${esc(o.label)}</span>${o.description ? `<span class="det">${esc(o.description)}</span>` : ''}</button>`).join('')}</div></div>`).join('');
30
- const send = oneTap(p) ? '' : `<button type="button" class="primary" data-prompt-send${promptAnswer(p, picks) && !sending ? '' : ' disabled'}>Send</button>`;
31
- return `<li class="prompt" data-prompt="${esc(p.id)}">${head}${questions}<div class="prompt-actions">${send}<button type="button" class="deny" data-prompt-choice="deny"${off}>Skip</button>${status}</div></li>`;
29
+ <div class="prompt-options" role="group" aria-label="${esc(q.question)}">${q.options.map((o) => `<button type="button" data-prompt-q="${i}" data-prompt-opt="${esc(o.label)}" aria-pressed="${(picks[i] || []).includes(o.label)}"${off(`${i}:${o.label}`)}><span class="lbl">${esc(o.label)}</span>${o.description ? `<span class="det">${esc(o.description)}</span>` : ''}</button>`).join('')}</div></div>`).join('');
30
+ const send = oneTap(p) ? '' : `<button type="button" class="primary" data-prompt-send${off('send') || (promptAnswer(p, picks) ? '' : ' disabled')}>Send</button>`;
31
+ return `<li class="prompt" data-prompt="${esc(p.id)}">${head}${questions}<div class="prompt-actions">${send}<button type="button" class="deny" data-prompt-choice="deny"${off('deny')}>Skip</button></div></li>`;
32
32
  }
33
33
  const detail = p.detail ? `<pre class="prompt-detail">${esc(p.detail)}</pre>` : `<p class="prompt-what">Wants to ${esc(p.action)}</p>`;
34
34
  const rule = p.rule ? `<p class="muted small prompt-rule">Always allow adds <code>${esc(p.rule)}</code> until the session ends.</p>` : '';
35
- return `<li class="prompt" data-prompt="${esc(p.id)}">${head}${detail}<div class="prompt-actions"><button type="button" class="primary" data-prompt-choice="allow"${off}>Allow once</button><button type="button" data-prompt-choice="always"${off}>Always allow</button><button type="button" class="deny" data-prompt-choice="deny"${off}>Deny</button>${status}</div>${rule}</li>`;
35
+ return `<li class="prompt" data-prompt="${esc(p.id)}">${head}${detail}<div class="prompt-actions"><button type="button" class="primary" data-prompt-choice="allow"${off('allow')}>Allow once</button><button type="button" data-prompt-choice="always"${off('always')}>Always allow</button><button type="button" class="deny" data-prompt-choice="deny"${off('deny')}>Deny</button></div>${rule}</li>`;
36
36
  }
37
37
 
38
38
  Object.assign(globalThis.BoardUI, { promptItem, promptAnswer, oneTap });
@@ -1,14 +1,18 @@
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, dropZone, 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
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' : ''}>
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, which resolves it
12
+ // (view-questions.js); a resolved question has none.
13
+ const questionEye = (q) => q.state === 'resolved' ? '' : protoButton('q:' + q.id, null, { label: 'Ask for a prototype instead' });
14
+ const noteComposer = (q, note) => `<div class="composer" data-composer="${esc('q:' + q.id)}">${dropZone(`<textarea class="note" rows="2" placeholder="Note (optional)">${esc(note || '')}</textarea>`)}
15
+ <div class="note-tools">${attachButton()}${questionEye(q)}<div class="pending-files" data-files></div></div></div>`;
12
16
 
13
17
  const QUESTION_STATE = {
14
18
  open: { label: 'open', tone: 'normal' },
@@ -16,10 +20,17 @@
16
20
  replied: { label: 'claude replied', tone: 'ready' },
17
21
  resolved: { label: 'resolved', tone: 'quiet' },
18
22
  };
23
+ // docs/adr/2026-11-02-1332-a-prototype-round-ends-with-a-one-take-confirm.md
24
+ // A topic's latest prototype round from /api/board ownerTopics: {n, state: making|back|closed, take}.
25
+ const roundChip = (r) => !r ? ''
26
+ : r.state === 'making' ? badge('normal', `${SPIN}Prototype round ${r.n}`)
27
+ : r.state === 'closed' ? badge('quiet', `Round ${r.n} closed · take ${esc(r.take)}`)
28
+ : badge('normal', `Prototype round ${r.n}`);
29
+ 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
30
  function answerHistory(q, links = {}) {
20
31
  if (!q.answers.length) return '';
21
32
  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('');
33
+ ${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
34
  const last = q.answers[q.answers.length - 1];
24
35
  return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
25
36
  }
@@ -34,7 +45,7 @@
34
45
  : isPrototype(url) ? `<a class="pill proto" href="${esc(url)}">See the prototypes →</a>`
35
46
  : `<div class="small" style="margin-bottom:8px"><a href="${esc(url)}"${opensHere(url) ? '' : ' target="_blank" rel="noopener"'}>${esc(url.replace(/^https:\/\/(linear\.app\/[^/]+\/issue|github\.com)\//, ''))} ${LINK_ARROW}</a></div>`;
36
47
 
37
- // A question's takes show inline (docs/adr/0048-a-questions-takes-show-inline.md): an option with `diagram` draws
48
+ // A question's takes show inline (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md): an option with `diagram` draws
38
49
  // it with its `practice`, and on a question whose url is a prototype page any other option shows that page's take.
39
50
  const framed = (q) => isPrototype(q.url) && q.options.some((o) => !o.diagram);
40
51
  const inline = (q) => isPrototype(q.url) || q.options.some((o) => o.diagram);
@@ -74,24 +85,37 @@
74
85
  }
75
86
  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>`;
76
87
  }
88
+ // A confirm links its one take alone, and says why it is asked.
89
+ const confirmLines = (q) => !q.confirm_of || q.state === 'resolved' ? ''
90
+ : `<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
91
  // An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext;
78
92
  // fit(option id) supplies the measured grid and full-width heights.
79
93
  function questionCard(q, { n = 0, draft = {}, links = {}, fit = () => null } = {}) {
80
94
  const last = q.answers[q.answers.length - 1];
81
95
  const chosen = draft.choice ?? (last ? last.choice : null);
82
- const st = QUESTION_STATE[q.state];
83
96
  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>
97
+ <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, esc(QUESTION_STATE[q.state].label))}</div>
85
98
  <h3>${linkText(q.question, links)}</h3>
86
99
  ${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
87
100
  ${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>` : ''}
101
+ ${confirmLines(q)}
102
+ ${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>` : ''}
89
103
  ${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)}
104
+ ${noteComposer(q, draft.note)}
91
105
  ${answerHistory(q, links)}
92
106
  </section>`;
93
107
  }
94
- const topicName = (t) => (String(t.title).match(/^([A-Z]+-\d+|[A-Z]\d+):/) || [])[1] || t.title;
108
+ // A card's pill is its ID, or for an ask (item:ask:<id>) the time it appeared; other topics keep their title.
109
+ const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) : (String(t.title).match(/^([A-Z]+-\d+|[A-Z]\d+):/) || [])[1] || t.title);
110
+ // /api/board's ownerTopics, each card's topic named from its card, which the board sends in its place while the card
111
+ // is on the board (board/server.mjs pageBoard). cards: the board's items.
112
+ function cardTopics(topics, cards) {
113
+ const byKey = new Map(cards.map((c) => [c.key, c]));
114
+ return topics.map((t) => {
115
+ const c = t.title === undefined && byKey.get(t.card);
116
+ return c ? { ...t, title: `${c.ref}: ${c.title}`, summary: 'Questions about this work item.', ...(c.kind === 'ask' ? { askedAt: c.ask.askedAt } : {}) } : t;
117
+ });
118
+ }
95
119
  // Topics from /api/board ownerTopics, in order. pills: unresolved topics with an open question, plus the
96
120
  // current topic wherever it stands; waiting: the other unresolved topics, which wait only on Claude.
97
121
  function topicRow(topics, current) {
@@ -126,5 +150,5 @@
126
150
  },
127
151
  };
128
152
 
129
- Object.assign(globalThis.BoardUI, { choiceLabel, option, answerHistory, questionCard, takes, topicName, topicRow, nextQuestion, sentNotice });
153
+ Object.assign(globalThis.BoardUI, { choiceLabel, option, answerHistory, questionCard, roundChip, takes, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
130
154
  })();