@lovek4d/kickflip 0.7.0 → 0.7.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 (59) hide show
  1. package/CONTEXT.md +43 -43
  2. package/SKILL.md +12 -10
  3. package/bin/event-lines.mjs +1 -1
  4. package/bin/kickflip.mjs +2 -2
  5. package/board/agents.mjs +2 -2
  6. package/board/arch-review.mjs +4 -4
  7. package/board/bin/board.mjs +87 -87
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -4
  10. package/board/headless.mjs +8 -8
  11. package/board/hub-lines.mjs +1 -0
  12. package/board/onboarding.mjs +1 -1
  13. package/board/public/app-arch.js +1 -1
  14. package/board/public/app-cards.js +5 -6
  15. package/board/public/app-header.js +1 -1
  16. package/board/public/app-lanes.js +37 -59
  17. package/board/public/app-needs.js +16 -11
  18. package/board/public/app-reader.js +2 -2
  19. package/board/public/app.js +2 -2
  20. package/board/public/dialogs.js +1 -1
  21. package/board/public/hub-kit.js +1 -1
  22. package/board/public/hub.css +2 -0
  23. package/board/public/linear-key.js +1 -1
  24. package/board/public/new-task.js +1 -1
  25. package/board/public/prototype-kit.js +2 -5
  26. package/board/public/router.js +1 -1
  27. package/board/public/sidebar.css +2 -2
  28. package/board/public/sidebar.js +2 -2
  29. package/board/public/style.css +20 -29
  30. package/board/public/ui-cards.js +19 -17
  31. package/board/public/ui-hub.js +1 -5
  32. package/board/public/ui-links.js +5 -5
  33. package/board/public/ui-order.js +5 -6
  34. package/board/public/ui-questions.js +12 -10
  35. package/board/public/ui-threads.js +10 -7
  36. package/board/public/ui.js +11 -2
  37. package/board/public/view-hub-settings.js +28 -31
  38. package/board/public/view-hub.js +7 -9
  39. package/board/public/view-notes.js +1 -1
  40. package/board/public/view-questions.js +18 -5
  41. package/board/public/view-settings.js +3 -3
  42. package/board/render-check.mjs +10 -10
  43. package/board/review.mjs +1 -1
  44. package/board/server.mjs +18 -19
  45. package/board/stop.mjs +1 -1
  46. package/board/store.mjs +26 -25
  47. package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
  48. package/hub/awake.mjs +1 -1
  49. package/hub/boards.mjs +4 -4
  50. package/hub/github.mjs +3 -3
  51. package/hub/hub.mjs +11 -11
  52. package/hub/incoming.mjs +2 -2
  53. package/hub/locks.mjs +1 -1
  54. package/hub/new-board.mjs +2 -2
  55. package/hub/owner-checks.mjs +1 -1
  56. package/hub/try.mjs +6 -7
  57. package/hub/views.mjs +1 -1
  58. package/package.json +1 -1
  59. package/shared/usage-gate.mjs +1 -1
@@ -0,0 +1 @@
1
+ var b=Object.defineProperty;var t=(e,o)=>b(e,"name",{value:o,configurable:!0});import w from"node:os";const h=100,P=2e3,$=/(?:~|\/)[^\s:;,()'"]*\/([^\s:;,()'"/]+)/g;function r(e,o,a=""){const d=h-e.length-a.length;let l=String(o??"").split(w.homedir()).join("~").replace(/\s+/g," ").trim();return l.length>d&&(l=l.replace($,"\u2026/$1")),l.length<=d?e+l+a:`${e}${l.slice(0,d-1).trimEnd()}\u2026${a.replace(/^\./,"")}`}t(r,"fit");const i="Held",m="Resumed",k="Blocked",s="Fault",c="move",g="questions",u="Implementation requested",f="Prototype requested",n="Marked done",y="prototype",x="owner/a-long-repo-name#10000",v="Wed, 11/30, 12:00 PM",p={"host-busy":{mark:i,text:t(()=>"Host is busy, work will resume when the host recovers","text")},"planner-held":{mark:i,text:t(({kind:e})=>`The ${e} waits and starts when the hold lifts.`,"text"),longest:{kind:y}},paused:{mark:i,text:t(({phase:e,words:o,clear:a})=>r(`Paused${e?` at ${e}`:""}: `,o,`. It resumes once ${a}.`),"text"),longest:{phase:"building",clear:"the usage pause lifts"}},"run-stopped":{mark:i,text:t(({words:e})=>e,"text"),longest:{words:"build stopped by orchestrator"}},"land-moved-first":{mark:c,text:t(({words:e})=>e,"text"),longest:{words:"land moved first by orchestrator"}},"builder-stopped":{mark:i,text:t(()=>"Stopped its builder.","text")},"land-waiting":{mark:i,text:t(({card:e})=>r("Waiting for ",e," to land"),"text")},resumed:{mark:m,text:t(({clear:e})=>`Resumed: ${e}.`,"text"),longest:{clear:"the usage pause lifted"}},"hold-ended":{mark:m,text:t(({at:e})=>`Resumed: its hold until ${e} ended.`,"text"),longest:{at:v}},unblocked:{mark:m,text:t(({lifted:e})=>r("Unblocked: ",e,"."),"text")},"unblocked-done":{mark:m,text:t(({card:e})=>r("Unblocked: ",e," is done."),"text")},"blocked-pausing":{mark:k,text:t(({blocked:e})=>r("Blocked ",e,": its builder pauses at its next stopping point and it stays In Progress."),"text")},"blocked-stays":{mark:k,text:t(({blocked:e})=>r("Blocked ",e,": it stays In Progress."),"text")},"cant-start-yet":{mark:k,text:t(({names:e})=>r("Can\u2019t start yet: waits on ",e,". Bumped to the top of Todo."),"text")},"merge-blocked":{mark:k,text:t(({blocked:e})=>r("Not merged: blocked ",e,"."),"text")},pulled:{mark:u,text:t(()=>"Pulled in: an implementer slot was free.","text")},"helper-asked":{mark:u,text:t(({agent:e,writes:o})=>r("Asked for helper ",e,o?", which writes code":""),"text"),longest:{writes:!0}},"arch-review":{mark:u,text:t(({card:e})=>r("Started an architecture review of ",e,"; its report becomes the notes"),"text")},"prototype-after":{mark:f,text:t(()=>"Prototype round asked: it moves to prototyping once every question is answered.","text")},"prototype-round":{mark:f,text:t(()=>"You asked for a prototype round, so the board moved the card to prototyping.","text")},"new-question":{mark:g,text:t(()=>"A new question on it, so it is grilling until you answer.","text")},"screen-timeout":{mark:g,text:t(()=>"Questions passed to you unscreened: the orchestrator didn\u2019t screen them in 15 minutes.","text")},"ask-moved":{mark:c,text:t(({board:e})=>r("Moved to ",e,"."),"text")},"pr-found":{mark:c,text:t(({pr:e,branch:o})=>r(`Found PR #${e} on branch `,o,"."),"text"),longest:{pr:1e5}},"ci-passed":{mark:n,text:t(({pr:e})=>`CI passed, so PR #${e} is marked ready for review.`,"text"),longest:{pr:1e5}},"worktree-remade":{mark:c,text:t(()=>"Remade its leftover worktree: it held no commits or changes.","text")},merged:{mark:n,text:t(({words:e})=>r("",e),"text")},"already-merged":{mark:n,text:t(({prs:e,many:o})=>r(`PR${o?"s":""} `,e,` ${o?"were":"was"} already merged.`),"text"),longest:{many:!0}},"ask-moved-closed":{mark:n,text:t(({board:e})=>r("Closed: its ask moved to ",e,"."),"text")},"check-passed":{mark:n,text:t(({found:e})=>r("Check passed: ",e,". Closed."),"text")},"findings-settled":{mark:n,text:t(({filed:e})=>r("Every finding is settled: ",e?`filed ${e}`:"none filed","."),"text")},"branch-merged":{mark:n,text:t(({branch:e,into:o,host:a})=>r("",`${e} is merged into ${o} on ${a}`,", so this card is Done."),"text")},"branch-gone":{mark:n,text:t(({branch:e,host:o})=>r("",`${e} is gone from ${o}`,", so this card is Done."),"text")},"prs-closed":{mark:n,text:t(({prs:e})=>r("Closed: ",e," closed unmerged."),"text"),longest:{prs:`${x}, ${x}`}},"landed-on-merge":{mark:n,text:t(({base:e})=>r("Landed on ",e," on your Merge."),"text")},"lander-handoff":{mark:u,text:t(({why:e})=>r("Not merged yet: ",e,". Its builder resumes to fix it and land it."),"text")},"builder-resumed-to-land":{mark:u,text:t(({session:e})=>r("Builder resumed to land, session ",e),"text")},"build-refused":{mark:s,fault:!0,text:t(({why:e})=>r("Couldn't start implementing: ",e),"text")},"planner-failed":{mark:s,fault:!0,text:t(({kind:e,error:o})=>r(`Couldn't start the ${e}: `,o),"text"),longest:{kind:"visual agent"}},"helper-failed":{mark:s,fault:!0,text:t(({name:e,error:o})=>r(`Couldn't start helper ${e}: `,o),"text"),longest:{name:"a-helper-name-of-24-char"}},"exited-twice":{mark:s,fault:!0,text:t(()=>"Its builder exited twice unfinished; it reads stopped until the orchestrator takes it up.","text")},"resume-failed":{mark:s,fault:!0,text:t(({error:e})=>r("Couldn't resume the builder headless: ",e),"text")},"relaunch-failed":{mark:s,fault:!0,text:t(({error:e})=>r("Couldn't relaunch the builder headless: ",e),"text")},"lander-failed":{mark:s,fault:!0,text:t(({error:e})=>r("Couldn't launch the lander; land it by hand: ",e),"text")},"rebase-failed":{mark:s,fault:!0,text:t(({pr:e,error:o})=>r(`PR #${e} conflicts, but the rebase run couldn't start: `,o),"text"),longest:{pr:1e5}},"rebase-launch-failed":{mark:s,fault:!0,text:t(({error:e})=>r("Couldn't launch the rebase run; it reads stopped: ",e),"text")},"merge-refused":{mark:s,fault:!0,text:t(({why:e})=>r("Not merged: ",e,"."),"text")},"land-failed":{mark:s,fault:!0,text:t(({why:e})=>r("Not merged: ",e,". Tap Merge again."),"text")},"push-failed":{mark:s,fault:!0,text:t(({base:e,error:o})=>r(`Landed, but ${e} didn't reach origin: `,o),"text"),longest:{base:"a-long-base-branch"}},"answers-failed":{mark:s,fault:!0,text:t(({what:e,error:o})=>r(`Answers recorded, but ${e} failed: `,o),"text"),longest:{what:"writing them into its notes"}},"notes-missing":{mark:s,fault:!0,text:t(()=>"Its notes file was missing, so the board started a new one.","text")},"notes-shared":{mark:s,fault:!0,text:t(({card:e})=>r("It shared its notes file with ",e,", so the board gave it its own copy."),"text")},"check-broken":{mark:s,fault:!0,text:t(({error:e})=>r("The owner step's check can't run, so it stopped: ",e),"text")},"run-failed":{mark:s,fault:!0,text:t(({kind:e,code:o,why:a})=>r(`${e} failed (exit ${o})`,a?`: ${a}`:""),"text"),longest:{kind:"Serve",code:255}}};function C(e,o={}){const a=p[e];if(!a)throw new Error(`no status line kind "${e}"`);const d=a.text(o);if(!d||d.length>h)throw new Error(`the "${e}" status line runs to ${d.length} characters, past ${h}: ${d}`);return d}t(C,"hubText");const T=t(e=>({mark:p[e.line]?.mark||"",fault:!!p[e.line]?.fault}),"hubLineOf"),R="hub";export{h as BRIEF_LINE_LIMIT,R as HUB,p as HUB_LINES,P as MESSAGE_LIMIT,r as fit,T as hubLineOf,C as hubText};
@@ -1,4 +1,4 @@
1
- var h=Object.defineProperty;var o=(e,i)=>h(e,"name",{value:i,configurable:!0});import l from"node:fs";import d from"node:path";import{execFileSync as c}from"node:child_process";const p="# Prepare .kickflip/build\n\nThe hub builds and serves a card's version of this repo by running one script, `.kickflip/build <wave>/<id> <worktree>`, in the card's worktree. This board has none on main yet.\n\nContract (docs/adr/2026-11-01-1846-a-boards-build-script-serves-or-builds-each-card-and-main.md):\n- Print exactly `port <n>` on stdout to serve a website on that port.\n- Or exit 0 with no port for an app build, publishing the install with `board.mjs item set --install --install-sha`.\n- The file is a regular file in the repo, executable, writable by no one but its owner.\n\nWhat this card does:\n- Look at the repo for what it builds: a website or server, an iOS or Android app, a CLI, a library with nothing to serve.\n- If it finds one type, write `.kickflip/build` for it.\n- If it finds several, ask the owner which the board should serve, write one `.kickflip/build-<target>` for each type the owner wants (for example `build-web`, `build-ios`), and set this board's Build target in Board Settings to the one picked.\n- If nothing here builds, say so and write nothing.\n",u=`# Write CONTEXT.md and the first ADR
1
+ var h=Object.defineProperty;var o=(e,i)=>h(e,"name",{value:i,configurable:!0});import l from"node:fs";import d from"node:path";import{execFileSync as c}from"node:child_process";const p="# Prepare .kickflip/build\n\nThe hub builds and serves a card's version of this repo by running one script, `.kickflip/build <file>/<id> <worktree>`, in the card's worktree. This board has none on main yet.\n\nContract (docs/adr/2026-11-01-1846-a-boards-build-script-serves-or-builds-each-card-and-main.md):\n- Print exactly `port <n>` on stdout to serve a website on that port.\n- Or exit 0 with no port for an app build, publishing the install with `board.mjs item set --install --install-sha`.\n- The file is a regular file in the repo, executable, writable by no one but its owner.\n\nWhat this card does:\n- Look at the repo for what it builds: a website or server, an iOS or Android app, a CLI, a library with nothing to serve.\n- If it finds one type, write `.kickflip/build` for it.\n- If it finds several, ask the owner which the board should serve, write one `.kickflip/build-<target>` for each type the owner wants (for example `build-web`, `build-ios`), and set this board's Build target in Board Settings to the one picked.\n- If nothing here builds, say so and write nothing.\n",u=`# Write CONTEXT.md and the first ADR
2
2
 
3
3
  Cards in this repo are grilled and built against its own words and decisions. This card writes them down.
4
4
 
@@ -65,7 +65,7 @@ BoardView.part((B) => {
65
65
  sheet.showModal();
66
66
  });
67
67
  B.on(sheet, 'click', (ev) => {
68
- if (ev.target === sheet || ev.target.closest('#archClose')) { sheet.close(); return; }
68
+ if (BoardUI.onBackdrop(ev, sheet) || ev.target.closest('#archClose')) { sheet.close(); return; }
69
69
  const open = ev.target.closest('[data-arch-open]');
70
70
  if (open) { ev.preventDefault(); openCard(open.dataset.archOpen); return; }
71
71
  if (ev.target.closest('[data-arch-run]')) run();
@@ -76,7 +76,7 @@ BoardView.part((B) => {
76
76
  loadMainLog();
77
77
  });
78
78
  B.on($('#mainLogClose'), 'click', closeMainLog);
79
- B.on(mainSheet, 'click', (ev) => { if (ev.target === mainSheet) closeMainLog(); });
79
+ B.on(mainSheet, 'click', (ev) => { if (BoardUI.onBackdrop(ev, mainSheet)) closeMainLog(); });
80
80
  B.on(mainSheet, 'cancel', (ev) => { ev.preventDefault(); closeMainLog(); });
81
81
  B.on(mainRetry, 'click', async () => {
82
82
  if (mainRetry.disabled) return;
@@ -179,7 +179,7 @@ BoardView.part((B) => {
179
179
  }
180
180
  B.on($('#holdClose'), 'click', closeHold);
181
181
  B.on(holdSheet, 'click', (ev) => {
182
- if (ev.target === holdSheet) { closeHold(); return; }
182
+ if (BoardUI.onBackdrop(ev, holdSheet)) { closeHold(); return; }
183
183
  const chip = ev.target.closest('[data-hold-for]');
184
184
  if (chip) { hold(chip.dataset.holdFor, chip.value); return; }
185
185
  const input = ev.target.closest('[data-hold-at]');
@@ -215,7 +215,7 @@ BoardView.part((B) => {
215
215
  const pr = it.pr;
216
216
  const ctx = links(it.key);
217
217
  const rows = [];
218
- rows.push(['Wave', escText(it.waveTitle)]);
218
+ if (it.wave) rows.push(['Wave', escText(it.wave.title)]);
219
219
  if (pr) {
220
220
  const ci = pr.checks ? `${pr.checks.state}${pr.checks.fail ? ` (${pr.checks.fail} failing)` : pr.checks.pending ? ` (${pr.checks.pending} running)` : ''}` : 'unknown';
221
221
  if (pr.state === 'OPEN') {
@@ -315,16 +315,15 @@ BoardView.part((B) => {
315
315
  // (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md,
316
316
  // docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md).
317
317
  const bumpedAsk = (it) => Boolean(it.isNew && it.bumpedAt);
318
- // band {step, actions} draws the card in a wave band (app-lanes.js).
319
318
  // Its open questions asked since this browser last opened it are one more unread message: the thread's "N new
320
319
  // questions" line (app-threads.js; docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
321
- function card(it, band = {}) {
320
+ function card(it) {
322
321
  const unseen = (times) => (state.openItems.has(it.key) ? 0 : unreadCount(times, seen[it.key]));
323
322
  const unread = unseen(it.othersAt) + Math.min(1, unseen(it.questions?.askedAt));
324
323
  return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
325
324
  <summary class="work">
326
325
  <span class="status ${it.lane}"></span>
327
- <div class="title-wrap">${workLines(it, { unread, top: bumpedAsk(it), ...band })}</div>
326
+ <div class="title-wrap">${workLines(it, { unread, top: bumpedAsk(it) })}</div>
328
327
  </summary>
329
328
  ${state.openItems.has(it.key) ? detail(it) : ''}
330
329
  </details>`;
@@ -51,7 +51,7 @@ BoardView.part((B) => {
51
51
  linksSheet.showModal();
52
52
  });
53
53
  B.on($('#linksClose'), 'click', closeLinks);
54
- B.on(linksSheet, 'click', (ev) => { if (ev.target === linksSheet) closeLinks(); });
54
+ B.on(linksSheet, 'click', (ev) => { if (BoardUI.onBackdrop(ev, linksSheet)) closeLinks(); });
55
55
  B.on(linksSheet, 'cancel', (ev) => { ev.preventDefault(); closeLinks(); });
56
56
  function askGitHub() {
57
57
  state.githubPending = Date.now();
@@ -1,5 +1,5 @@
1
1
  // The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Done's and Asks' Show more,
2
- // Done's finished-wave folds, and Todo's wave bands with their Implement and card order.
2
+ // Done's finished-item-file 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;
@@ -52,7 +52,7 @@ BoardView.part((B) => {
52
52
  }
53
53
 
54
54
  // A fold's open state lives in storage, not in setHTML's summary-text match, because its row's count changes.
55
- // Fold ids are wave:<slug> (board/domain.mjs waveFold), so any id maps to its own storage key.
55
+ // Fold ids are file:<slug> (board/domain.mjs fileFold), so any id maps to its own storage key.
56
56
  const FOLDS = new Proxy({}, { get: (_, id) => ({ store: `foldOpen:${String(id)}` }) });
57
57
  // A fold's header reads like a lane's: its name, then its count.
58
58
  function fold(id, list, name) {
@@ -63,12 +63,12 @@ BoardView.part((B) => {
63
63
  </details>`;
64
64
  }
65
65
 
66
- // One row per finished wave, newest card first, as Done's items arrive sorted.
67
- function waveFolds(d, items) {
68
- const slugs = [...new Set(items.filter((i) => i.fold === `wave:${i.wave}`).map((i) => i.wave))];
66
+ // One row per finished item file, newest card first, as Done's items arrive sorted.
67
+ function fileFolds(d, items) {
68
+ const slugs = [...new Set(items.filter((i) => i.fold === `file:${i.file}`).map((i) => i.file))];
69
69
  return slugs.map((slug) => {
70
- const w = d.waves.find((x) => x.slug === slug);
71
- return fold(`wave:${slug}`, items.filter((i) => i.fold === `wave:${slug}`), escText(w.title));
70
+ const w = d.itemFiles.find((x) => x.slug === slug);
71
+ return fold(`file:${slug}`, items.filter((i) => i.fold === `file:${slug}`), escText(w.title));
72
72
  }).join('');
73
73
  }
74
74
 
@@ -94,7 +94,7 @@ BoardView.part((B) => {
94
94
  const items = shown.filter((i) => i.lane === lane.id);
95
95
  const expanded = laneState[lane.id] ?? lane.open;
96
96
  const open = items.filter((i) => !i.fold);
97
- 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);
97
+ 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)) + fileFolds(d, items);
98
98
  return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
99
99
  <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>
100
100
  <div class="list">${rows || '<div class="empty">Nothing here.</div>'}
@@ -125,22 +125,30 @@ BoardView.part((B) => {
125
125
  const grouped = bands.filter((b) => inBand(b).length);
126
126
  if (!grouped.length) return cards.map((it) => B.card(it)).join('');
127
127
  const loose = cards.filter((i) => !bands.some((b) => b.keys.includes(i.key)));
128
- 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>`;
129
- return grouped.map((b) => group('wb-lane', ` data-wave="${esc(b.slug)}"`, escText(b.title), inBand(b).length, waveCounts(b, laneId), inBand(b))).join('')
130
- + (loose.length ? group('wb-loose', '', 'No wave', loose.length, '', loose) : '');
128
+ const body = (list) => list.map((it) => B.card(it)).join('');
129
+ return grouped.map((b) => groupHtml(laneId, b.slug, true, BoardUI.WAVE_ICON + groupLabel(escText(b.title), inBand(b).length) + waveCounts(b, laneId), body(inBand(b)))).join('')
130
+ + (loose.length ? groupHtml(laneId, NO_WAVE, true, groupLabel('No wave', loose.length), body(loose)) : '');
131
+ }
132
+
133
+ // The one header every lane gives every group, a wave or No wave: a tap on it folds the group, and its open state
134
+ // lives in storage under the lane and the group's slug. head is the header's content, body the cards under it.
135
+ const NO_WAVE = 'no-wave';
136
+ const groupStore = (id) => FOLDS[`band:${id}`].store;
137
+ const groupLabel = (name, n) => `<span class="wb-label"><span class="lane-name">${name}</span> <span class="count">${n}</span></span>`;
138
+ function groupHtml(laneId, slug, open, head, body) {
139
+ const id = `${laneId}/${slug}`;
140
+ const isOpen = store.get(groupStore(id), open);
141
+ const header = `<div class="wb-head" data-band-toggle="${esc(id)}" role="button" tabindex="0" aria-expanded="${isOpen}">${head}</div>`;
142
+ return `<section class="wb" data-band="${esc(slug)}" data-lane="${laneId}"${isOpen ? ' data-open' : ''}>${header}${isOpen ? `<div class="wb-body">${body}</div>` : ''}</section>`;
131
143
  }
132
144
 
133
145
  // docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md
134
146
  // Todo's wave bands (domain.mjs waveBandsOf), then its cards in no wave under No wave. A band's open state lives
135
- // in storage once the owner taps its head; until then only the first band whose next card is ready (a wave file's:
136
- // 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
137
- // card whose ⋯ menu is open, goalShown the band whose goal popover is open.
138
- let menu = null;
147
+ // in storage once the owner taps its head; until then only the first band whose next card is ready (an item file's:
148
+ // any Todo card) starts open. A band's name shows its goal as a tooltip, or a popover on a long-press. goalShown is
149
+ // the band whose goal popover is open.
139
150
  let goalShown = null;
140
- const bandStore = (slug) => FOLDS[`band:${slug}`].store;
141
- const bandOpen = (band, first) => store.get(bandStore(band.slug), band.slug === first);
142
151
  const LANE_WORDS = { progress: 'in progress', review: 'in review' };
143
- 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>';
144
152
 
145
153
  // Todo's wave bands and loose cards, in the order they arrive (domain.mjs laneOrder).
146
154
  function todoRows(d, shown, todo) {
@@ -150,8 +158,8 @@ BoardView.part((B) => {
150
158
  const loose = todo.filter((i) => !banded.has(i.key));
151
159
  const first = bands.find((b) => (b.next ? b.next.ready : b.keys.some((k) => byKey.get(k)?.ready)))?.slug;
152
160
  if (!bands.length) return loose.map((it) => B.card(it)).join('');
153
- return bands.map((b) => bandHtml(b, b.keys.map((k) => byKey.get(k)).filter(Boolean), bandOpen(b, first))).join('')
154
- + (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>` : '');
161
+ return bands.map((b) => bandHtml(b, b.keys.map((k) => byKey.get(k)).filter(Boolean), b.slug === first)).join('')
162
+ + (loose.length ? groupHtml('todo', NO_WAVE, true, groupLabel('No wave', loose.length),loose.map((it) => B.card(it)).join('')) : '');
155
163
  }
156
164
 
157
165
  function bandHtml(band, cards, open) {
@@ -160,38 +168,23 @@ BoardView.part((B) => {
160
168
  const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${LANE_WORDS[i.lane]}`);
161
169
  const progress = now.length ? ` <span class="count">· ${now.join(' · ')}</span>` : '';
162
170
  const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${escText(band.title)}</span>`;
163
- 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>` : '';
171
+ const notes = band.kind === 'wave' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
164
172
  const next = band.next;
165
173
  const go = !next ? '' : next.ready
166
174
  ? `<button type="button" class="wv-link" data-confirm="card-build" data-key="${esc(next.key)}">Implement</button>`
167
175
  : `<span class="wb-next">next: ${esc(next.ref)} ${esc(next.word)}</span>`;
168
176
  const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
169
- const head = `<div class="wb-head" data-band-toggle="${esc(band.slug)}" role="button" tabindex="0" aria-expanded="${open}">${BoardUI.WAVE_ICON}`
170
- + `<span class="wb-label">${name} <span class="count">${todo.length}</span>${progress}</span>`
171
- + waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}</span>${go}</span>${pop}</div>`;
172
- if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
177
+ const head = `${BoardUI.WAVE_ICON}<span class="wb-label">${name} <span class="count">${todo.length}</span>${progress}</span>`
178
+ + waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}</span>${go}</span>${pop}`;
173
179
  const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#selectedCard=${encodeURIComponent(i.key)}">${esc(i.ref)}</a>`).join(', ')}</div>` : '';
174
- const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
175
- const actions = (it, at) => (band.kind !== 'lineup' ? null : cardMenu(band.slug, it.key, at, todo.length));
176
- return `<section class="wb" data-band="${esc(band.slug)}" data-open>${head}<div class="wb-body">${doneRow}${todo.map((it, at) => B.card(it, { step: step(it), actions: actions(it, at) })).join('')}</div></section>`;
177
- }
178
-
179
- // A lineup card's ⋯ and, while open, its menu: Move up and Move down among the band's Todo cards. A move waits for
180
- // the server (board/server.mjs lineup-move): the tapped move waits and the other is disabled meanwhile.
181
- function cardMenu(slug, key, at, count) {
182
- const shown = menu === key;
183
- const item = (by, label) => {
184
- const off = by < 0 ? at === 0 : at === count - 1;
185
- return `<button type="button" role="menuitem" data-band-move="${by}" data-band="${esc(slug)}" data-key="${esc(key)}"${off ? ' disabled' : ''}>${label}</button>`;
186
- };
187
- return `<div class="ask-actions"><button type="button" class="ghost icon wb-dots" data-band-menu="${esc(key)}" aria-label="Card menu" aria-haspopup="menu" aria-expanded="${shown}">${DOTS}</button>`
188
- + (shown ? `<div class="wb-menu" role="menu">${item(-1, '↑ Move up')}${item(1, '↓ Move down')}</div>` : '') + '</div>';
180
+ return groupHtml('todo', band.slug, open, head, doneRow + todo.map((it) => B.card(it)).join(''));
189
181
  }
190
182
 
191
183
  // Opens the band holding card key or named slug, for a #selectedCard= link or a Needs you line.
192
184
  function openBand({ key, slug }) {
185
+ const lane = (key && state.board?.items.find((i) => i.key === key)?.lane) || 'todo';
193
186
  const band = (state.board?.waveBands || []).find((b) => b.slug === slug || b.keys.includes(key));
194
- if (band) store.set(bandStore(band.slug), true);
187
+ if (band) store.set(groupStore(`${lane}/${band.slug}`), true);
195
188
  return band;
196
189
  }
197
190
 
@@ -245,11 +238,11 @@ BoardView.part((B) => {
245
238
  if (!openBand({ slug: show.dataset.bandShow })) return;
246
239
  const s = store.get('lanes', {}); s.todo = true; store.set('lanes', s);
247
240
  B.render();
248
- $(`.wb[data-band="${CSS.escape(show.dataset.bandShow)}"]`)?.scrollIntoView({ block: 'start' });
241
+ $(`.wb[data-lane="todo"][data-band="${CSS.escape(show.dataset.bandShow)}"]`)?.scrollIntoView({ block: 'start' });
249
242
  }
250
243
  });
251
244
 
252
- // A band's ⋯ menu and its moves, its head's fold and its name's goal popover. A long-press on the name (touch only;
245
+ // A band's head's fold and its name's goal popover. A long-press on the name (touch only;
253
246
  // a mouse has the tooltip) opens the popover, and the click that ends the press neither folds the band nor closes it.
254
247
  let press = null;
255
248
  let pressed = false;
@@ -272,24 +265,9 @@ BoardView.part((B) => {
272
265
  }
273
266
  if (pressed) { pressed = false; ev.preventDefault(); return; }
274
267
  if (goalShown) { goalShown = null; B.render(); if (ev.target.closest('.wb-head')) return; }
275
- const dots = ev.target.closest('button[data-band-menu]');
276
- if (dots) { ev.preventDefault(); menu = menu === dots.dataset.bandMenu ? null : dots.dataset.bandMenu; B.render(); return; }
277
- const mv = ev.target.closest('button[data-band-move]');
278
- if (mv) {
279
- ev.preventDefault();
280
- if (mv.disabled) return;
281
- const { band, key } = mv.dataset;
282
- try { await waitOn(mv, B.send('lineup-move', { lineup: band, key, by: Number(mv.dataset.bandMove) }), { group: mv.parentNode.querySelectorAll('button') }); }
283
- catch { B.toast('Could not move it. Is the host awake?'); }
284
- menu = null;
285
- B.render();
286
- return;
287
- }
288
- if (menu && !ev.target.closest('.wb-menu')) { menu = null; B.render(); if (ev.target.closest('summary')) { ev.preventDefault(); return; } }
289
268
  const head = ev.target.closest('[data-band-toggle]');
290
269
  if (head && !ev.target.closest('a, button')) {
291
- const slug = head.dataset.bandToggle;
292
- store.set(bandStore(slug), head.getAttribute('aria-expanded') !== 'true');
270
+ store.set(groupStore(head.dataset.bandToggle), head.getAttribute('aria-expanded') !== 'true');
293
271
  B.render();
294
272
  }
295
273
  });
@@ -15,7 +15,7 @@
15
15
  // Needs app-core.js.
16
16
  BoardView.part((B) => {
17
17
  const { $, plural, state, toast } = B;
18
- const { esc, escText, promptItem, choiceLabel, isPrototype, getDraft, setDraft, noteOf, waitOn } = BoardUI;
18
+ const { esc, escText, markdown, promptItem, choiceLabel, isPrototype, getDraft, setDraft, noteOf, waitOn } = BoardUI;
19
19
 
20
20
  const prompts = PromptAnswers.wire(B, {
21
21
  list: () => (state.board?.needs || []).filter((line) => line.prompt).map((line) => line.prompt),
@@ -65,6 +65,7 @@ BoardView.part((B) => {
65
65
  // order; any other line as itself. → [{id, what, html}]
66
66
  function deckOf(d, needs) {
67
67
  const items = d.items || [];
68
+ const links = BoardUI.linkContext(items);
68
69
  const owner = (d.owner || []).filter((q) => q.state === 'open');
69
70
  const topicOf = (line) => line.topic || items.find((it) => it.key === line.key)?.questions?.topic || null;
70
71
  return needs.flatMap((line) => {
@@ -73,7 +74,7 @@ BoardView.part((B) => {
73
74
  const topic = line.questions && topicOf(line);
74
75
  const order = (d.ownerTopics || []).find((t) => t.id === topic)?.questions || [];
75
76
  const asks = topic ? owner.filter((q) => q.topic === topic).sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)) : [];
76
- if (asks.length) return asks.map((q) => ({ id: `q:${q.id}`, what, html: questionHtml(q) }));
77
+ if (asks.length) return asks.map((q) => ({ id: `q:${q.id}`, what, html: questionHtml(q, links) }));
77
78
  const [label, href, attrs] = line.questions ? [`Answer ${plural(line.questions.open, 'question')}`, line.questions.url, '']
78
79
  : line.ready ? ['Ready on GitHub', line.ready, ' target="_blank" rel="noopener"']
79
80
  : line.action ? [line.action.label, line.action.url, '']
@@ -85,14 +86,14 @@ BoardView.part((B) => {
85
86
  });
86
87
  }
87
88
  // The marked option, the question's drafted pick, reads Send “<label>”.
88
- const questionHtml = (q) => {
89
+ const questionHtml = (q, links) => {
89
90
  const marked = getDraft(q.id).choice;
90
91
  const optionHtml = (o) => {
91
92
  const armed = o.id === marked;
92
93
  const label = armed ? `Send “${esc(o.label)}”` : `${esc(choiceLabel(q, o.id))}${o.recommended ? ' <span class="rec-word">recommended</span>' : ''}`;
93
- return `<button type="button" class="${[o.recommended && 'rec', armed && 'armed'].filter(Boolean).join(' ')}" aria-pressed="${armed}" data-deck-pick="${esc(o.id)}"><span class="lbl">${label}</span>${o.detail ? `<small>${escText(o.detail)}</small>` : ''}</button>`;
94
+ return `<button type="button" class="${[o.recommended && 'rec', armed && 'armed'].filter(Boolean).join(' ')}" aria-pressed="${armed}" data-deck-pick="${esc(o.id)}"><span class="lbl">${label}</span>${o.detail ? `<div class="det">${markdown(o.detail, links)}</div>` : ''}</button>`;
94
95
  };
95
- return `<h3>${escText(q.question)}</h3>${q.context ? `<p class="ctx">${escText(q.context)}</p>` : ''}
96
+ return `<h3>${escText(q.question)}</h3>${q.context ? `<div class="ctx">${markdown(q.context, links)}</div>` : ''}
96
97
  ${isPrototype(q.url) ? `<a class="button deck-proto" href="${esc(q.url)}">View prototypes</a>` : ''}
97
98
  <div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map(optionHtml).join('')}</div>`;
98
99
  };
@@ -110,7 +111,7 @@ BoardView.part((B) => {
110
111
  showing = now.id;
111
112
  const at = deck.indexOf(now);
112
113
  const open = (d.owner || []).filter((q) => q.state === 'open').length;
113
- const drafted = (d.owner || []).filter((q) => q.state === 'open' && 'choice' in getDraft(q.id)).length;
114
+ const drafted = (d.owner || []).filter((q) => q.state === 'open' && getDraft(q.id).choice != null).length;
114
115
  BoardUI.setHTML(el, `<div class="deck-head"><h2>Needs you</h2><span class="deck-n">${deck.length}</span></div>
115
116
  <div class="deck-card" data-deck="${esc(now.id)}"><p class="deck-what">${escText(now.what)}</p>${now.html}</div>
116
117
  ${deck.length > 1 ? `<div class="deck-bar"><button type="button" class="ghost" data-deck-prev${at === 0 ? ' disabled' : ''}>← Prev</button><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-next${at === deck.length - 1 ? ' disabled' : ''}>Next →</button></div>` : ''}
@@ -156,10 +157,10 @@ BoardView.part((B) => {
156
157
  // From 900px, Waiting on you in the pane under the Needs you lines, mounted while the width lasts.
157
158
  let questions = null;
158
159
  // Two taps on a pane pill focus its topic on the board: item:<key> is a card (an ask is one, ask:<id>),
159
- // wave:<slug> a band; any other topic, or one the board doesn't show, does nothing.
160
+ // wave:<slug> or file:<slug> a band; any other topic, or one the board doesn't show, does nothing.
160
161
  function openOnBoard(id) {
161
162
  if (id.startsWith('item:')) B.focusOnBoard({ key: id.slice(5) });
162
- else if (id.startsWith('wave:')) B.focusOnBoard({ slug: id.slice(5) });
163
+ else if (id.startsWith('wave:') || id.startsWith('file:')) B.focusOnBoard({ slug: id.slice(5) });
163
164
  }
164
165
  function fitPane() {
165
166
  if (wide.matches === Boolean(questions)) return;
@@ -231,9 +232,13 @@ BoardView.part((B) => {
231
232
  else viewer.sheet = !viewer.sheet;
232
233
  paintViewer();
233
234
  });
234
- // A push to a card's thread (#item=<key>&reply, board/store.mjs pushUrl), the switcher's for a question already picked
235
- // among them, closes the viewer, so the thread it sent the owner to shows.
236
- B.on(window, 'hashchange', () => { if (/^#item=[^&]+&reply$/.test(location.hash)) closeViewer(); });
235
+ // A push to a card's thread (#selectedCard=<key>&reply, board/store.mjs threadUrl), the switcher's for a question
236
+ // already picked among them, closes the viewer, so the thread it sent the owner to shows. The older #item=<key>&reply
237
+ // reads the same.
238
+ B.on(window, 'hashchange', () => {
239
+ const at = AppRouter.fragmentOf(location.hash);
240
+ if (at.selectedCard && at.reply) closeViewer();
241
+ });
237
242
 
238
243
  fitPane();
239
244
  B.on(wide, 'change', fitPane);
@@ -1,5 +1,5 @@
1
1
  // The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "View transcript"
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
2
+ // (ui-threads.js) and a card's or a wave'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.
5
5
  // A link in it closes it first, then opens through the shell.
@@ -148,7 +148,7 @@ BoardView.part((B) => {
148
148
  else openNotes(href.slice(href.indexOf('?')));
149
149
  });
150
150
  B.on(sheet, 'click', (ev) => {
151
- if (ev.target === sheet) { close(); return; }
151
+ if (BoardUI.onBackdrop(ev, sheet)) { close(); return; }
152
152
  const act = ev.target.closest('[data-reader]')?.dataset.reader;
153
153
  if (act === 'close') close();
154
154
  else if (act === 'raw') { log.raw = !log.raw; log.text = null; drawLog(); follow(); if (log.raw) readRaw(opened); }
@@ -206,7 +206,7 @@ window.BoardView = (function () {
206
206
  }
207
207
 
208
208
  // #review=<PR number> opens that review in the Auto Review lane (app-lanes.js openReview), as a held review's push does.
209
- // #selectedCard=<wave>/<id> (or the older #item=) opens that card, and &openItemsCard=<topic id> opens that pill in the pane; push notifications link here, with &reply to land in the thread's reply box
209
+ // #selectedCard=<file>/<id> (or the older #item=) opens that card, and &openItemsCard=<topic id> opens that pill in the pane; push notifications link here, with &reply to land in the thread's reply box
210
210
  // (docs/adr/2026-10-29-1639-a-push-lands-where-the-owner-answers.md). An ask now in a work item's thread opens that item.
211
211
  let focusKey = null;
212
212
  let replyKey = null;
@@ -251,7 +251,7 @@ window.BoardView = (function () {
251
251
  if (!band && !state.board?.items.some((i) => i.key === key)) return false;
252
252
  if (key) { location.hash = AppRouter.hashWith(location.hash, { selectedCard: key }); readHash(); } else B.openBand({ slug });
253
253
  render();
254
- if (key) scrollToFocus(); else $(`section.wb[data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
254
+ if (key) scrollToFocus(); else $(`section.wb[data-lane="todo"][data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
255
255
  return true;
256
256
  }
257
257
  // A card missing from the last visit's board waits for the fresh one, which is final.
@@ -348,7 +348,7 @@
348
348
  } catch (e) { sending = false; fail(e); sync(); }
349
349
  });
350
350
  el.addEventListener('cancel', (e) => { e.preventDefault(); done(); });
351
- el.addEventListener('click', (e) => { if (e.target === el) done(); });
351
+ el.addEventListener('click', (e) => { if (BoardUI.onBackdrop(e, el)) done(); });
352
352
  el.addEventListener('close', () => {
353
353
  if (closed) return;
354
354
  closed = true;
@@ -181,7 +181,7 @@
181
181
  if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
182
182
  }
183
183
 
184
- // The one duration picker Snooze, Keep awake, Pause hub and Pause board share (hub/duration.mjs): chips that post
184
+ // The one duration picker Snooze, Pause hub and Pause board share (hub/duration.mjs): chips that post
185
185
  // `name` to `action` at once, then Custom, whose date and time posts as `name` too. While its setting is on, `on`
186
186
  // replaces it with when it ends and a button that posts `name`=off.
187
187
  function durationPicker({ id, title, icon, action, name, choices, on, button }) {
@@ -95,6 +95,8 @@
95
95
  }
96
96
 
97
97
  .hub-view .hub-settings { margin-top: 24px; }
98
+ .hub-view .hub-inline > div + div { margin-top: 16px; }
99
+ .hub-view .hub-inline + .label { margin-top: 24px; }
98
100
  .hub-view .hub-setting { width: 100%; display: flex; align-items: center; gap: 12px; padding: 14px 16px; font: inherit; text-align: left; color: var(--text); cursor: pointer; border: 1px solid var(--line); border-radius: 12px; margin: 10px 0; background: var(--panel); }
99
101
  .hub-view .hub-setting .text { flex: 1; min-width: 0; }
100
102
  .hub-view .hub-setting .title { font-weight: 600; }
@@ -136,7 +136,7 @@
136
136
  }
137
137
  dialog.addEventListener('click', (e) => {
138
138
  if (e.target.closest?.('[data-lk-done]')) { close(); return; }
139
- if (e.target !== dialog) return;
139
+ if (!BoardUI.onBackdrop(e, dialog)) return;
140
140
  const r = dialog.getBoundingClientRect();
141
141
  if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) close();
142
142
  });
@@ -75,7 +75,7 @@ globalThis.BoardNewTask = (function () {
75
75
  B.newTaskKey = newTaskKey;
76
76
  B.on(document, 'keydown', newTaskKey);
77
77
  B.on($('#sheetClose'), 'click', closeSheet);
78
- B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
78
+ B.on(sheet, 'click', (ev) => { if (BoardUI.onBackdrop(ev, sheet)) closeSheet(); });
79
79
  B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
80
80
  // Discuss has the session grill or prototype the task at once; Implement sends the ask with quick: true, and the
81
81
  // session marks the card ready (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
@@ -87,7 +87,6 @@
87
87
  usage: fake.usage(),
88
88
  hub: { tone: 'ok', word: 'healthy', detail: '3 of 3 sites up' },
89
89
  sites: fake.sites(),
90
- polling: { title: 'Checks every minute while you’re here', lines: ['Next check 12:00pm · last 11:45am, 8 PRs, no change', 'No pushes queued.'], count: 0 },
91
90
  settings: [['notifications', 'Notifications', 'Pushes on · Off on weekends · This device on'], ['quiet', 'Quiet hours', '10:00pm – 8:00am · pushes held'],
92
91
  ['codex', 'Codex share', 'Auto'], ['rules', 'Rules for every board', '1 rule'], ['closed', 'Closed boards', 'No closed boards']],
93
92
  }),
@@ -105,7 +104,7 @@
105
104
 
106
105
  function hub(data = {}) {
107
106
  const d = { ...fake.hub(), header: true, ...data };
108
- const { row, tile, nowRow, CHEVRON } = HubUI;
107
+ const { row, tile, CHEVRON } = HubUI;
109
108
  const site = (s) => row({ icon: s.icon, title: esc(s.title), sub: s.status ?? '<span class="st ok">up · listening · every link passes</span>', wrap: true, trail: CHEVRON, href: `/${s.route}/` });
110
109
  const settings = d.settings.map(([id, title, sub]) => `<button type="button" class="hub-setting" data-setting="${esc(id)}" data-kit-inert aria-haspopup="dialog"><span class="text"><span class="title">${esc(title)}</span><span class="sub">${esc(sub)}</span></span>${CHEVRON}</button>`).join('');
111
110
  const usage = d.usageCard ?? HubUI.usageCard(d.usage);
@@ -114,9 +113,7 @@
114
113
  <div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span> · ${esc(d.hub.detail)}`, wrap: true })}</div>
115
114
  <h2 class="label">Boards</h2>
116
115
  <div class="group">${d.sites.map(site).join('')}</div>
117
- <p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, events (its session and a watcher run, or the hub can wake it), daily wakes, pushes and reviewer links. One that just broke reads settling first.</p>
118
- <h2 class="label">Polling status</h2>
119
- <div class="group">${nowRow(d.polling, tile('clock', '#3b82f6'))}</div></div>
116
+ <p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, events (its session and a watcher run, or the hub can wake it), daily wakes, pushes and reviewer links. One that just broke reads settling first.</p></div>
120
117
  <section class="hub-settings"><h2 class="label">Hub settings</h2>${settings}</section>
121
118
  <div class="hub-foot"><footer>${d.sites.length} sites. Each belongs to one Claude session, which adds it with <code>/kickflip &lt;route&gt;</code>. This page refreshes every 30 seconds.<br><span class="host">mac.example.ts.net</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v0.4.2</span></footer></div></main></div>`;
122
119
  }
@@ -6,7 +6,7 @@
6
6
  // board {route, hash} /<route>/, hash such as #selectedCard=<key>&openItemsCard=<topic id>, or the older #item=<key>&reply
7
7
  // settings {route} /<route>/settings.html, a request to open the modal over that board
8
8
  // questions {route, id} /<route>/questions.html#<id>
9
- // notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<lineup slug>, and plan.html, which the board
9
+ // notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<wave slug>, and plan.html, which the board
10
10
  // redirects there
11
11
  // hub {hash} /_hub/ with a settings modal named by hash; legacy /_hub/settings opens the same modals
12
12
  // ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)),
@@ -150,11 +150,11 @@ html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-
150
150
  @starting-style { .sidebar.ks-on .ks-label-hint { opacity: 0; } }
151
151
  /* The label is the bar's containing block: left 0 is the BOARDS text's edge, and right -8px crosses the label's 8px
152
152
  side margin (.sb-label) to the rows' right edge. The bar sits in the label's 4px bottom margin. */
153
- .sidebar .ks-bar { position: absolute; left: 0; right: -8px; top: 100%; height: 2px; margin-top: 1px; background: var(--accent);
153
+ .sidebar .ks-bar { position: absolute; left: 0; right: -8px; top: 100%; height: 2px; margin-top: 1px; background: var(--color-notice);
154
154
  transform-origin: 0 50%; animation: ks-drain linear forwards; }
155
155
  @keyframes ks-drain { to { transform: scaleX(0); } }
156
156
  .sidebar .ks-u { transition: color 200ms linear; }
157
- .sidebar.ks-on .ks-u { color: var(--accent); transition: color 150ms linear; }
157
+ .sidebar.ks-on .ks-u { color: var(--color-notice); transition: color 150ms linear; }
158
158
  @starting-style { .sidebar.ks-on .ks-u { color: inherit; } }
159
159
  @media (prefers-reduced-motion: reduce) {
160
160
  .sidebar .ks-bar { animation: none; }
@@ -236,7 +236,7 @@
236
236
  }
237
237
  el.addEventListener('click', (e) => {
238
238
  if (sending) return;
239
- if (e.target === el || e.target.closest('[data-failed-close]')) { done(); return; }
239
+ if (BoardUI.onBackdrop(e, el) || e.target.closest('[data-failed-close]')) { done(); return; }
240
240
  const dismiss = e.target.closest('[data-failed-dismiss]');
241
241
  const retry = e.target.closest('[data-failed-retry]');
242
242
  if (dismiss) {
@@ -309,7 +309,7 @@
309
309
  el.innerHTML = shortcuts();
310
310
  el.addEventListener('cancel', (e) => { e.preventDefault(); closeHelp(); });
311
311
  el.addEventListener('close', closeHelp);
312
- el.addEventListener('click', (e) => { if (e.target === el) closeHelp(); });
312
+ el.addEventListener('click', (e) => { if (BoardUI.onBackdrop(e, el)) closeHelp(); });
313
313
  help = { el, previous: document.activeElement };
314
314
  document.body.appendChild(el);
315
315
  el.showModal();