@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,5 +1,6 @@
1
- /* The hub page and settings modals (view-hub.js, view-hub-settings.js, view-polling.js), whose root carries .hub-view, and the hub's
2
- down page (hub/hub.mjs), whose <html> does; served as /_hub/app/hub.css and /_hub/hub.css. Every rule is under
1
+ /* The hub page and settings modals (view-hub.js, view-hub-settings.js), whose root carries .hub-view, and the hub's
2
+ down page (hub/hub.mjs), whose <html> does, and the board's Hold sheet picker (ui-cards.js holdPicker), which shares the
3
+ duration picker's rules; served as /_hub/app/hub.css and /_hub/hub.css. Every rule is under
3
4
  .hub-view, since style.css uses the same short class names. The app shell links it after style.css, so a rule here
4
5
  wins a tie, and the header is style.css's alone, as on every view; each style.css rule that would reach these rows
5
6
  is set back here. */
@@ -27,7 +28,7 @@
27
28
  .hub-view .title { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; overflow-wrap:normal; }
28
29
  .hub-view .sub { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; overflow-wrap:normal; }
29
30
  .hub-view .sub.wrap { white-space:normal; }
30
- .hub-view .st { font-weight:600; } .hub-view .st.down { color:var(--color-danger); } .hub-view .st.wait { color:var(--color-notice); }
31
+ .hub-view .st { font-weight:600; } .hub-view button.st { background:none; border:0; padding:0; font:inherit; font-weight:600; color:inherit; cursor:pointer; text-decoration:underline dotted; text-underline-offset:3px; } .hub-view .st.down { color:var(--color-danger); } .hub-view .st.wait { color:var(--color-notice); }
31
32
  @keyframes pulse { 50% { opacity:.35; } }
32
33
  .hub-view main .icon { flex:none; width:34px; height:34px; border-radius:9px; display:grid; place-items:center; color:var(--c); background:color-mix(in srgb, var(--c) 18%, transparent); font-weight:700; font-size:16px; line-height:1; }
33
34
  .hub-view main .icon.sm { width:29px; height:29px; border-radius:7px; }
@@ -37,7 +38,7 @@
37
38
  .hub-view .badge { flex:none; min-width:22px; height:22px; padding:0 7px; border-radius:11px; background:var(--color-danger); color:#fff; font-size:12px; font-weight:600; line-height:22px; text-align:center; font-variant-numeric:tabular-nums; }
38
39
  .hub-view .chev { flex:none; color:var(--muted); font-size:1.3rem; line-height:1; }
39
40
  .hub-view .val { flex:none; color:var(--muted); font-size:13px; }
40
- .hub-view .btn { flex:none; font:inherit; font-size:13px; color:#fff; background:var(--accent); border:1px solid var(--accent); border-radius:7px; padding:6px 10px; cursor:pointer; }
41
+ .hub-view .btn { flex:none; font:inherit; font-size:13px; color:var(--on-accent); background:var(--accent); border:1px solid var(--accent); border-radius:7px; padding:6px 10px; cursor:pointer; }
41
42
  .hub-view .btn.quiet { color:var(--text); background:var(--panel); border-color:var(--line); }
42
43
  .hub-view .btn:disabled { opacity:.5; cursor:default; }
43
44
  .hub-view .btn.danger { color:var(--color-danger); }
@@ -46,22 +47,7 @@
46
47
  .hub-view .chips form { display:contents; }
47
48
  .hub-view .chips form.field { display:inline-flex; gap:6px; }
48
49
  .hub-view .chips .field input { min-height:0; padding:.2rem .5rem; width:7.5rem; }
49
- .hub-view .split { display:flex; gap:8px; justify-content:flex-end; }
50
- .hub-view .day { position:relative; padding:18px 0 20px; }
51
- .hub-view .day .bar { position:relative; height:40px; border-radius:8px; overflow:hidden; background:var(--chip); }
52
- .hub-view .day .seg { position:absolute; top:0; bottom:0; display:grid; place-items:center; overflow:hidden; border-right:2px solid var(--panel);
53
- background:color-mix(in srgb, var(--accent) var(--k), var(--chip)); }
54
- .hub-view .day .seg.held { background-image:repeating-linear-gradient(135deg, transparent 0 5px, color-mix(in srgb, var(--text) 22%, transparent) 5px 8px); }
55
- .hub-view .day .seg.sel { box-shadow:inset 0 0 0 3px var(--text); z-index:2; }
56
- .hub-view .day .seg span { font-size:11px; font-weight:600; line-height:1.5; color:var(--text); background:color-mix(in srgb, var(--panel) 85%, transparent); border-radius:4px; padding:0 4px; }
57
- .hub-view .day .pin { position:absolute; top:2px; transform:translateX(-50%); color:var(--color-notice); font-size:11px; line-height:1; }
58
- .hub-view .day .now { position:absolute; top:14px; height:48px; width:2px; margin-left:-1px; background:var(--color-danger); z-index:3; pointer-events:none; }
59
- .hub-view .day .ticks { position:absolute; left:0; right:0; bottom:0; height:16px; font-size:10px; color:var(--muted); }
60
- .hub-view .day .ticks span { position:absolute; transform:translateX(-50%); }
61
- .hub-view .day .ticks span:first-child { transform:none; } .hub-view .day .ticks span:last-child { transform:translateX(-100%); }
62
- .hub-view .legend { display:flex; gap:4px 12px; flex-wrap:wrap; font-size:11px; color:var(--muted); }
63
- .hub-view .legend i { display:inline-block; width:10px; height:10px; border-radius:2px; vertical-align:-1px; margin-right:4px; }
64
- .hub-view #day, .hub-view #window, .hub-view #quiet, .hub-view #wakes { scroll-margin-top:64px; }
50
+ .hub-view .quiet-hint { margin:6px 14px 0; color:var(--muted); font-size:12px; }
65
51
  .hub-view .field input { flex:none; font:inherit; font-size:16px; color:var(--text); background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:.3rem .6rem; min-height:2.2rem; width:8.75rem; min-width:0; text-align:right; }
66
52
  .hub-view .field input[type=number] { width:5rem; }
67
53
  .hub-view .field input.toggle { width:1.4rem; height:1.4rem; min-height:0; padding:0; accent-color:var(--accent); }
@@ -80,16 +66,17 @@
80
66
  .hub-view .hold-banner > span { flex:1; min-width:0; }
81
67
  .hub-view .hold-banner form { flex:none; margin:0; }
82
68
  .hub-view .hold-banner .btn { min-height:32px; }
83
- .hub-view .snooze-head { display:flex; align-items:center; gap:10px; }
84
- .hub-view .snooze-chips { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:6px; margin-top:10px; }
85
- .hub-view .snooze-choices { display:contents; }
86
- .hub-view .snooze-chips .btn { display:block; min-height:40px; padding:6px 4px; text-align:center; white-space:nowrap; }
87
- .hub-view .snooze-custom { position:relative; margin:0; }
88
- .hub-view .snooze-custom .btn { display:grid; place-items:center; height:100%; }
89
- .hub-view .snooze-custom input[type=datetime-local] { position:absolute; inset:0; width:100%; height:100%; min-height:0; padding:0; margin:0; border:0; background:transparent; color:transparent; opacity:.011; font-size:16px; cursor:pointer; }
90
- .hub-view .snooze-custom input::-webkit-calendar-picker-indicator { position:absolute; inset:0; width:auto; height:auto; opacity:0; cursor:pointer; }
91
- .hub-view .snooze-custom:focus-within { outline:2px solid var(--accent); outline-offset:2px; border-radius:7px; }
92
- .hub-view .snooze-on .title { font-weight:600; white-space:normal; }
69
+ .hub-view .dur-head { display:flex; align-items:center; gap:10px; }
70
+ .hub-view .dur-chips { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:6px; margin-top:10px; }
71
+ .hub-view .dur-choices { display:contents; }
72
+ .hub-view .dur-chips .btn { display:block; min-height:40px; padding:6px 4px; text-align:center; white-space:nowrap; }
73
+ .hub-view .dur-custom { position:relative; margin:0; }
74
+ .hub-view .dur-custom .btn { display:grid; place-items:center; height:100%; }
75
+ .hub-view .dur-custom input[type=datetime-local] { position:absolute; inset:0; width:100%; height:100%; min-height:0; padding:0; margin:0; border:0; background:transparent; color:transparent; opacity:.011; font-size:16px; cursor:pointer; }
76
+ .hub-view .dur-custom input::-webkit-calendar-picker-indicator { position:absolute; inset:0; width:auto; height:auto; opacity:0; cursor:pointer; }
77
+ .hub-view .dur-custom:focus-within { outline:2px solid var(--accent); outline-offset:2px; border-radius:7px; }
78
+ .hub-view .dur-on .title { font-weight:600; white-space:normal; }
79
+ .hub-view .awake-on { color:var(--color-notice); font-weight:600; }
93
80
  .hub-view code { font-family:var(--mono); font-size:.85em; white-space:nowrap; }
94
81
  .hub-view footer { color:var(--muted); font-size:12px; margin:1.8rem 2px 0; }
95
82
  .hub-view .host { font-family:var(--mono); font-size:11px; overflow-wrap:anywhere; }
@@ -110,13 +97,14 @@
110
97
  .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); }
111
98
  .hub-view .hub-setting .text { flex: 1; min-width: 0; }
112
99
  .hub-view .hub-setting .title { font-weight: 600; }
100
+ .hub-view .hub-setting:disabled { cursor: default; opacity: .6; }
113
101
  .hub-view .hub-setting .sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
114
102
  .hub-view .hub-modal .settings-body { padding-top: 2px; }
115
103
  .hub-view .hub-modal .setting-content > .label:first-child { margin-top: 14px; }
116
104
  .hub-view .hub-modal .setting-content > .group:first-child { margin-top: 16px; }
117
105
  .hub-view .hub-modal .rules-box { margin-top: 14px; }
118
106
 
119
- .hub-view .u-tone-ok { --u: var(--accent); }
107
+ .hub-view .u-tone-ok { --u: var(--muted); }
120
108
  .hub-view .u-tone-high { --u: var(--color-notice); }
121
109
  .hub-view .u-tone-full { --u: var(--color-danger); }
122
110
  .hub-view .u-tone-stale, .hub-view .u-tone-none { --u: var(--muted); }
@@ -138,7 +126,6 @@
138
126
  .hub-view .segments.is-disabled button { cursor: not-allowed; }
139
127
  .hub-view .segments.is-disabled button[aria-pressed="true"] { border-color: var(--muted); background: var(--panel); color: var(--text); }
140
128
  .hub-view .u-share-desc { margin: 8px 2px 0; color: var(--muted); font-size: 13px; }
141
- .hub-view .u-share-note { margin: 4px 2px 0; color: var(--muted); font-size: 12px; }
142
129
  .hub-view .u-share-fix { margin: 6px 2px 0; }
143
130
  .hub-view .u-share-fix code { display: inline-block; padding: 3px 7px; border-radius: 6px; background: var(--chip); color: var(--text); font-size: 12.5px; user-select: all; }
144
131
  .hub-view .lk-form { display:flex; gap:8px; align-items:center; }
@@ -150,7 +137,6 @@
150
137
  .hub-view .hold-banner.ub-going { border-color: color-mix(in srgb, var(--color-danger) 45%, transparent); background: color-mix(in srgb, var(--color-danger) 10%, var(--panel)); }
151
138
  .hub-view .hold-banner.ub-going > svg { color: var(--color-danger); }
152
139
 
153
- .hub-view .u-buffer { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
154
140
  .hub-view .ub-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
155
141
  .hub-view .ub-label { min-width: 0; }
156
142
  .hub-view .ub-label .title { display: block; font-size: 14px; }
@@ -162,3 +148,10 @@
162
148
  .hub-view .ub-step output.off { border-color: var(--line); background: var(--panel); color: var(--muted); }
163
149
  .hub-view .ub-marked .u-bar { position: relative; }
164
150
  .hub-view .ub-marked .u-bar > .ub-mark { position: absolute; top: 0; bottom: 0; width: 2px; margin-left: -1px; border-radius: 0; background: var(--text); opacity: .5; }
151
+
152
+ .hub-view .rd-fix button.link { padding: 0; font-size: inherit; }
153
+ .hub-view .site-row > .site-link { flex:1; min-width:0; display:flex; align-items:center; gap:10px; color:inherit; }
154
+ .hub-view .restart-btn { min-width:76px; min-height:34px; display:inline-flex; align-items:center; justify-content:center; gap:6px; }
155
+ /* A waiting button keeps its own look (style.css button[aria-busy="true"]), over .btn's and a step's disabled dimming. */
156
+ .hub-view button[aria-busy="true"]:disabled { opacity:1; }
157
+ .hub-view .ub-step button[aria-busy="true"]:disabled { color:var(--text); }
@@ -0,0 +1,143 @@
1
+ // Hub's Linear key slot. Hub settings › Linear mounts it, and the Tickets control (dialogs.js) opens it over Board
2
+ // settings or New board when Linear is tapped without a working key
3
+ // (docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A key saves on paste, change or Enter through
4
+ // POST /_hub/settings/linear, which checks it with Linear; linear is hub/views.mjs settingsView's `linear`.
5
+ (function () {
6
+ const FAILED = 'Could not save. Is the host awake?';
7
+ const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
8
+ const works = (linear) => Boolean(linear?.set && !linear.refused);
9
+
10
+ function slot(linear, { checking, flash }) {
11
+ const { esc, row, tile } = HubUI;
12
+ const sub = checking ? `<span class="lk-checking">${SPIN}Checking the key with Linear…</span>`
13
+ : `${linear.down ? `<span class="st down">${esc(linear.down)}</span> · ` : ''}${linear.keys
14
+ ? esc(linear.sub).replace(esc(linear.keys.label), `<a href="${esc(linear.keys.url)}" target="_blank" rel="noopener">${esc(linear.keys.label)}</a>`)
15
+ : esc(linear.sub)}`;
16
+ const remove = linear.set && !checking ? '<button type="button" class="btn quiet danger" data-linear-remove>Remove</button>' : '';
17
+ const placeholder = checking ? 'Checking…' : linear.set ? 'Paste a new key to replace it' : 'Paste a Linear API key';
18
+ return `${HubUI.flash(flash)}<div class="group">${row({ icon: tile('key', linear.refused ? 'var(--color-danger)' : 'var(--color-quiet)'), title: esc(linear.title), sub, wrap: !works(linear), trail: remove })}`
19
+ + `<form class="block lk-form"><input id="linearKey" name="key" type="password" required${checking ? ' disabled' : ''} placeholder="${placeholder}" aria-label="Linear API key" autocomplete="off" autocapitalize="off" spellcheck="false"></form></div>`
20
+ + '<p class="note">Stays on the host. Boards use it to list teams and projects, create issues and move the ones that are yours. A board can pick Linear for its tickets only while this key works.</p>';
21
+ }
22
+
23
+ // Draws the slot into root and saves from it. busy(true) starts each save and busy(false) ends it; saved(out) gets
24
+ // each answer, /_hub/api/settings's JSON, before the slot repaints; worked() follows a saved key Linear accepted.
25
+ // → {busy(), settled(): a promise that resolves once no save is out, linear(), set(linear) unless busy, destroy()}
26
+ function mount(root, { linear, saved = () => {}, worked = () => {}, busy = () => {} }) {
27
+ let current = linear, flash = null, checking = false, pending = null, destroyed = false;
28
+ const paint = () => {
29
+ const typed = root.querySelector('#linearKey')?.value || '';
30
+ root.innerHTML = slot(current, { checking, flash });
31
+ if (!checking) root.querySelector('#linearKey').value = typed;
32
+ };
33
+ function post(body) {
34
+ checking = body.has('key');
35
+ flash = null;
36
+ pending = (async () => {
37
+ try {
38
+ const r = await fetch('/_hub/settings/linear', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body });
39
+ const out = await r.json().catch(() => null);
40
+ if (destroyed) return;
41
+ if (r.ok && out) { current = out.linear; saved(out); } else flash = { text: out?.error || FAILED, error: true };
42
+ } catch { flash = { text: FAILED, error: true }; }
43
+ const key = checking;
44
+ checking = false;
45
+ pending = null;
46
+ if (destroyed) return;
47
+ busy(false);
48
+ paint();
49
+ if (key && works(current)) worked();
50
+ })();
51
+ busy(true);
52
+ paint();
53
+ return pending;
54
+ }
55
+ const save = (value) => { if (!pending && value.trim()) post(new URLSearchParams({ key: value.trim() })); };
56
+ const onPaste = (e) => {
57
+ const text = e.clipboardData?.getData('text');
58
+ if (!e.target.matches?.('#linearKey') || !text) return;
59
+ e.preventDefault();
60
+ e.target.value = text.trim();
61
+ save(e.target.value);
62
+ };
63
+ const onChange = (e) => { if (e.target.matches?.('#linearKey')) save(e.target.value); };
64
+ const onSubmit = (e) => {
65
+ if (!e.target.matches?.('.lk-form')) return;
66
+ e.preventDefault();
67
+ save(e.target.querySelector('#linearKey').value);
68
+ };
69
+ const onClick = async (e) => {
70
+ if (!e.target.closest?.('[data-linear-remove]') || pending) return;
71
+ if (await BoardDialogs.confirm('Boards on Linear fall back to internal tickets, and their cards keep the Linear tickets they have. Remove the Linear key?') && !destroyed) post(new URLSearchParams({ remove: '1' }));
72
+ };
73
+ const listeners = [['paste', onPaste], ['change', onChange], ['submit', onSubmit], ['click', onClick]];
74
+ for (const [type, fn] of listeners) root.addEventListener(type, fn);
75
+ paint();
76
+ return {
77
+ busy: () => Boolean(pending),
78
+ settled: () => pending || Promise.resolve(),
79
+ linear: () => current,
80
+ set(next) { if (!pending) { current = next; paint(); } },
81
+ destroy() { destroyed = true; for (const [type, fn] of listeners) root.removeEventListener(type, fn); },
82
+ };
83
+ }
84
+
85
+ // The footer's Done waits, disabled with a spinner in place of its word (style.css button[aria-busy="true"]), while a
86
+ // save is out; ✕, Escape and the backdrop close once it lands.
87
+ function doneBusy(buttons, busy) {
88
+ for (const b of buttons) {
89
+ if (!b.closest('.settings-footer')) continue;
90
+ b.disabled = busy;
91
+ if (busy) b.setAttribute('aria-busy', 'true'); else b.removeAttribute('aria-busy');
92
+ }
93
+ }
94
+
95
+ // The slot in a modal of its own over the page. It closes itself once a pasted key works; Done, ✕, Escape and the
96
+ // backdrop close it once no save is out. → a promise of whether the key works on closing
97
+ function open() {
98
+ const previous = document.activeElement;
99
+ const wrap = document.createElement('div');
100
+ // hub.css styles the slot under .hub-view main; style.css draws both as display: contents.
101
+ wrap.setAttribute('class', 'hub-view lk-over');
102
+ wrap.innerHTML = '<main><dialog class="settings-modal hub-modal" id="linearKeyModal" aria-labelledby="linearKeyTitle"><div class="settings-head"><div><h2 id="linearKeyTitle" tabindex="-1">Linear</h2><p class="muted small">Hub settings</p></div><button type="button" class="ghost" data-lk-done aria-label="Close Linear key">✕</button></div>'
103
+ + '<div class="set settings-body"><div class="setting-content"><p class="note">Loading…</p></div></div><div class="settings-footer"><button type="button" data-lk-done>Done</button></div></dialog></main>';
104
+ document.body.appendChild(wrap);
105
+ const dialog = wrap.querySelector('dialog');
106
+ const content = wrap.querySelector('.setting-content');
107
+ const unfit = BoardDialogs.fitSheet(dialog);
108
+ let key = null, closing = false, finished = false, resolve;
109
+ const result = new Promise((r) => { resolve = r; });
110
+ function finish() {
111
+ if (finished) return;
112
+ finished = true;
113
+ key?.destroy();
114
+ unfit();
115
+ dialog.close();
116
+ wrap.remove();
117
+ if (previous?.isConnected) previous.focus({ preventScroll: true });
118
+ resolve(works(key?.linear()));
119
+ }
120
+ function close() {
121
+ if (!key?.busy()) { finish(); return; }
122
+ if (closing) return;
123
+ closing = true;
124
+ key.settled().then(finish);
125
+ }
126
+ dialog.addEventListener('click', (e) => {
127
+ if (e.target.closest?.('[data-lk-done]')) { close(); return; }
128
+ if (e.target !== dialog) return;
129
+ const r = dialog.getBoundingClientRect();
130
+ if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) close();
131
+ });
132
+ dialog.addEventListener('cancel', (e) => { e.preventDefault(); close(); });
133
+ dialog.showModal();
134
+ wrap.querySelector('#linearKeyTitle').focus({ preventScroll: true });
135
+ fetch('/_hub/api/settings', { cache: 'no-store', headers: { accept: 'application/json' } }).then((r) => (r.ok ? r.json() : Promise.reject())).then((out) => {
136
+ if (finished) return;
137
+ key = mount(content, { linear: out.linear, worked: finish, busy: (on) => doneBusy(wrap.querySelectorAll('[data-lk-done]'), on) });
138
+ }).catch(() => { if (!finished) content.innerHTML = '<p class="note">Offline. Is the host awake?</p>'; });
139
+ return result;
140
+ }
141
+
142
+ globalThis.LinearKey = { works, mount, open, doneBusy };
143
+ })();
@@ -0,0 +1,48 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>Log</title>
7
+ <link rel="stylesheet" href="colors.css">
8
+ <link rel="stylesheet" href="style.css">
9
+ </head>
10
+ <body>
11
+ <!--header {"lead":"back","title":"Log","titleId":"ref","backHref":"./","controls":false}-->
12
+ <main>
13
+ <p class="muted" id="note"></p>
14
+ <pre class="log" id="log">Loading the log…</pre>
15
+ </main>
16
+ <style>
17
+ /* The tail of a builder's log (GET /api/log), newest at the bottom. */
18
+ pre.log { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
19
+ </style>
20
+ <script>
21
+ // Shows the tail of the log named by ?file=, last lines in view, and re-reads it every 3 seconds while it grows;
22
+ // the page stays where the reader scrolled to when they've left the bottom.
23
+ (function () {
24
+ const name = new URLSearchParams(location.search).get('file') || '';
25
+ const log = document.getElementById('log');
26
+ const note = document.getElementById('note');
27
+ document.getElementById('ref').textContent = name || 'Log';
28
+ document.title = name || 'Log';
29
+ let size = -1;
30
+ async function read() {
31
+ try {
32
+ const r = await fetch('api/log?file=' + encodeURIComponent(name), { cache: 'no-store' });
33
+ const body = await r.json();
34
+ if (!r.ok) { log.textContent = body.error || 'Could not load this log.'; return; }
35
+ if (body.size === size) return;
36
+ const atEnd = size === -1 || innerHeight + scrollY >= document.documentElement.scrollHeight - 40;
37
+ size = body.size;
38
+ log.textContent = body.text || 'The log is empty so far.';
39
+ note.textContent = body.truncated ? 'Showing the last 256 KiB.' : '';
40
+ if (atEnd) scrollTo(0, document.documentElement.scrollHeight);
41
+ } catch { /* the next read tries again */ }
42
+ }
43
+ read();
44
+ setInterval(read, 3000);
45
+ })();
46
+ </script>
47
+ </body>
48
+ </html>
@@ -1,5 +1,5 @@
1
1
  // Answering pending prompts from their Needs you lines (ui-prompts.js promptItem), on a board view and the hub page,
2
- // through the hub's POST /_hub/prompts/<id>/answer (docs/adr/0060-background-sessions-prompts-come-to-the-phone.md).
2
+ // through the hub's POST /_hub/prompts/<id>/answer (docs/adr/2026-10-30-1516-background-sessions-prompts-come-to-the-phone.md).
3
3
  // Allow once, Deny, Skip and a single question's option send on the tap. Always allow confirms first, naming its rule,
4
4
  // since it covers every later call like it until the session ends. Several questions, or one taking several picks,
5
5
  // send with Send once each has a pick.
@@ -8,13 +8,14 @@ globalThis.PromptAnswers = (function () {
8
8
  // short message; after() runs once an answer has gone. → {state(p) for promptItem, shows(p)}
9
9
  function wire(B, { list, paint, notify, after = () => {} }) {
10
10
  const picks = new Map();
11
- const sending = new Set();
11
+ // Each prompt whose answer is out, to the button tapped (ui-prompts.js promptItem's sending).
12
+ const sending = new Map();
12
13
  // Answered here, hidden until the view's next load drops them.
13
14
  const answered = new Set();
14
15
  const picksOf = (id) => { if (!picks.has(id)) picks.set(id, {}); return picks.get(id); };
15
16
 
16
- async function send(p, body) {
17
- sending.add(p.id);
17
+ async function send(p, body, tapped) {
18
+ sending.set(p.id, tapped);
18
19
  paint();
19
20
  try {
20
21
  const r = await fetch(`/_hub/prompts/${encodeURIComponent(p.id)}/answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
@@ -40,23 +41,23 @@ globalThis.PromptAnswers = (function () {
40
41
  const got = mine[i] || [];
41
42
  const label = opt.dataset.promptOpt;
42
43
  mine[i] = q.multiSelect ? (got.includes(label) ? got.filter((l) => l !== label) : [...got, label]) : [label];
43
- if (BoardUI.oneTap(p)) send(p, BoardUI.promptAnswer(p, mine));
44
+ if (BoardUI.oneTap(p)) send(p, BoardUI.promptAnswer(p, mine), `${i}:${label}`);
44
45
  else paint();
45
46
  return;
46
47
  }
47
48
  if (ev.target.closest('button[data-prompt-send]')) {
48
49
  const body = BoardUI.promptAnswer(p, picksOf(p.id));
49
- if (body) send(p, body);
50
+ if (body) send(p, body, 'send');
50
51
  return;
51
52
  }
52
53
  const choice = ev.target.closest('button[data-prompt-choice]')?.dataset.promptChoice;
53
54
  if (!choice) return;
54
55
  if (choice === 'always' && !(await BoardDialogs.confirm(`Always allow ${p.rule} for the rest of this session?`))) return;
55
- send(p, { choice });
56
+ send(p, { choice }, choice);
56
57
  });
57
58
 
58
59
  return {
59
- state: (p) => ({ picks: picks.get(p.id) || {}, sending: sending.has(p.id) }),
60
+ state: (p) => ({ picks: picks.get(p.id) || {}, sending: sending.get(p.id) ?? null }),
60
61
  shows: (p) => !answered.has(p.id),
61
62
  };
62
63
  }
@@ -1,5 +1,5 @@
1
1
  // What a prototype's take files get, on the page board/server.mjs serves from prototype.html for each topic recorded
2
- // with board.mjs prototype add (docs/adr/0089-the-board-serves-every-prototype-from-one-template.md). A take file,
2
+ // with board.mjs prototype add (docs/adr/2026-11-01-1448-the-board-serves-every-prototype-from-one-template.md). A take file,
3
3
  // public/prototypes/<topic>/<KEY>.js, is a module:
4
4
  // export function render(stage, kit) { stage.innerHTML = kit.hub(); return () => {…} } // the cleanup is optional
5
5
  // render runs on every switch to that take with a fresh, empty stage; it may be async. The stage contains its layout
@@ -39,13 +39,13 @@
39
39
  ...(claude === null ? {} : { claude: reading(...[claude, 61].flat().slice(0, 2), 2 * 60_000, 3.4, 2.6) }),
40
40
  ...(codex === null ? {} : { codex: reading(...[codex, 40].flat().slice(0, 2), 6 * 60_000, 1.2, 4.1) }),
41
41
  }),
42
- sidebar: () => ({ total: 1, sites: fake.sites(), hub: { tone: 'ok', word: 'Healthy', detail: '3 of 3 sites up' }, notifications: { state: 'on' }, usage: fake.usage() }),
42
+ sidebar: () => ({ total: 1, sites: fake.sites(), hub: { tone: 'ok', word: 'healthy', detail: '3 of 3 sites up' }, notifications: { state: 'on' }, usage: fake.usage() }),
43
43
  hub: () => ({
44
44
  usage: fake.usage(),
45
- hub: { tone: 'ok', word: 'Healthy', detail: '3 of 3 sites up' },
45
+ hub: { tone: 'ok', word: 'healthy', detail: '3 of 3 sites up' },
46
46
  sites: fake.sites(),
47
- polling: { title: 'Checks every 15 min until 6:00pm', lines: ['Next check 12:00pm · last 11:45am, 8 PRs, no change', 'No pushes queued.'], count: 0 },
48
- settings: [['notifications', 'Notifications', 'Pushes on · Off on weekends · This device on'], ['polling', 'Polling', '3 windows, 52 checks a day · held overnight'],
47
+ 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 },
48
+ settings: [['notifications', 'Notifications', 'Pushes on · Off on weekends · This device on'], ['quiet', 'Quiet hours', '10:00pm – 8:00am · pushes held'],
49
49
  ['codex', 'Codex share', 'Auto'], ['rules', 'Rules for every board', '1 rule'], ['closed', 'Closed boards', 'No closed boards']],
50
50
  }),
51
51
  items: () => [
@@ -63,7 +63,7 @@
63
63
  function hub(data = {}) {
64
64
  const d = { ...fake.hub(), header: true, ...data };
65
65
  const { row, tile, nowRow, CHEVRON } = HubUI;
66
- 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}/` });
66
+ 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}/` });
67
67
  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('');
68
68
  const usage = d.usageCard ?? HubUI.usageCard(d.usage);
69
69
  const header = d.header ? BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png', logoHref: '/_hub/', settingsHref: null }) : '';
@@ -1,4 +1,4 @@
1
- // The floating ‹ take › pill every prototype page switches its takes with (docs/adr/0015-prototypes-come-on-request.md).
1
+ // The floating ‹ take › pill every prototype page switches its takes with (docs/adr/2026-10-29-1639-prototypes-come-on-request.md).
2
2
  // Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
3
3
  // const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, initial: 'A', render(take) {…}, above: el? })
4
4
  // render runs at mount and on each switch; sw.take is the current key. ←/→ switch unless a field has focus, and the
@@ -8,7 +8,7 @@
8
8
  // PrototypeSwitcher.current is the mounted switcher, whose go(key) shows another take in place; Waiting on you uses it
9
9
  // to switch its full-width frame without a reload (view-questions.js inPlace).
10
10
  // With ?take=<key> the page shows that take alone, with no header or pill, as questions.html frames it
11
- // (docs/adr/0048-a-questions-takes-show-inline.md); a page hides its own extras under html.ps-embed. There main's
11
+ // (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md); a page hides its own extras under html.ps-embed. There main's
12
12
  // bottom padding, kept clear for the pill, shrinks to 12px whatever the page sets, since questions.html sizes the frame
13
13
  // to the page's height.
14
14
  (function () {
@@ -120,6 +120,8 @@
120
120
  let take = startTake(keys, params.get('variant'), initial);
121
121
  let question = null;
122
122
  let topics = [];
123
+ // The board's cards, which name their own question topics (ui-questions.js cardTopics).
124
+ let cards = [];
123
125
  // A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a pick made here or a
124
126
  // reload stays on the page; only the first load sends an already-picked question on to its card's thread.
125
127
  let fromPush = params.get('from') === 'push';
@@ -154,8 +156,14 @@
154
156
  let board = null;
155
157
  try {
156
158
  board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
157
- question = (board.owner || []).find((q) => q.id === qid) || null;
158
- topics = board.ownerTopics || [];
159
+ topics = board.ownerTopics;
160
+ cards = board.items || [];
161
+ // A resolved question is left out of /api/board, and its topic's resolved questions hold it
162
+ // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
163
+ const topic = topics.find((t) => t.questions.includes(qid));
164
+ question = board.owner.find((q) => q.id === qid)
165
+ || (topic && (await (await fetch(site.url('api/questions?topic=' + encodeURIComponent(topic.id)), { cache: 'no-store' })).json()).questions.find((q) => q.id === qid))
166
+ || null;
159
167
  if (!question) note('This prototype’s question is gone, so there is nothing to pick.');
160
168
  } catch { /* keep the last question shown */ }
161
169
  if (fromPush && pickedChoice(question)) {
@@ -179,7 +187,7 @@
179
187
  ui = await sharedQuestions(site);
180
188
  pills = ui.topicRow(topics, answered.topic).pills;
181
189
  const topic = topics.find((t) => t.id === answered.topic);
182
- name = topic ? ui.topicName(topic) : '';
190
+ name = topic ? ui.topicName(ui.cardTopics([topic], cards)[0]) : '';
183
191
  await site.send('owner-answer', { id: answered.id, question: answered.question, choice: state.option.id, label: answerLabel(state.option), note: text.value.trim() });
184
192
  } catch { busy = false; paint(); note('Could not send. Is the host awake?'); return; }
185
193
  try { localStorage.removeItem(draftKey(qid)); } catch { /* nothing kept */ }
@@ -44,6 +44,7 @@
44
44
  <script src="ui.js"></script>
45
45
  <script src="ui-links.js"></script>
46
46
  <script src="ui-enums.js"></script>
47
+ <script src="ui-phase-bars.js"></script>
47
48
  <script src="ui-cards.js"></script>
48
49
  <script src="ui-page-header.js"></script>
49
50
  <script src="ui-sidebar.js"></script>
@@ -1,4 +1,4 @@
1
- // The app shell's router (docs/adr/0055-one-app-for-the-whole-hub.md): every URL on the hub names a view, and a view
1
+ // The app shell's router (docs/adr/2026-10-29-2005-one-app-for-the-whole-hub.md): every URL on the hub names a view, and a view
2
2
  // names its URL, so a push link, a reload and a shared link all land on the same view. viewOf and urlOf are pure;
3
3
  // ui.test.mjs runs them in node:vm. listen() catches clicks on links to a view the shell mounts, and back and forward.
4
4
  // A view is {kind, …}:
@@ -6,9 +6,11 @@
6
6
  // board {route, hash} /<route>/, hash such as #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} /<route>/notes.html?item=<key>, and plan.html, which the board redirects there
10
- // hub {hash, w} /_hub/ with a settings modal named by hash; legacy /_hub/settings and
11
- // /_hub/settings/polling?w=<HH:MM> open the same modals (ADR 0075)
9
+ // notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<lineup slug>, and plan.html, which the board
10
+ // redirects there
11
+ // hub {hash} /_hub/ with a settings modal named by hash; legacy /_hub/settings opens the same modals
12
+ // ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)),
13
+ // and the old Polling page, /_hub/settings/polling, opens Quiet hours
12
14
  // frame {route, url} any other page under a route, a folder or an .html file: other sites, prototypes and
13
15
  // every other board page, shown in a frame
14
16
  // Any other URL, such as an image or another origin, is no view (null) and loads as itself.
@@ -28,8 +30,8 @@ globalThis.AppRouter = (function () {
28
30
  const p = u.pathname;
29
31
  if (p === '/') return { kind: 'start' };
30
32
  if (['/_hub/', '/_hub/settings', '/_hub/settings/polling'].includes(p)) {
31
- const hash = p === '/_hub/settings/polling' ? '#polling' : u.hash || (p === '/_hub/settings' ? '#notifications' : '');
32
- return { kind: 'hub', ...(hash ? { hash } : {}), ...(hash === '#polling' && u.searchParams.get('w') ? { w: u.searchParams.get('w') } : {}) };
33
+ const hash = p === '/_hub/settings/polling' ? '#quiet' : u.hash || (p === '/_hub/settings' ? '#notifications' : '');
34
+ return { kind: 'hub', ...(hash ? { hash } : {}) };
33
35
  }
34
36
  const m = /^\/([^/]+)\/(.*)$/.exec(p);
35
37
  let route = null;
@@ -45,7 +47,9 @@ globalThis.AppRouter = (function () {
45
47
  try { id = u.hash.length > 1 ? decodeURIComponent(u.hash.slice(1)) : null; } catch { id = u.hash.slice(1); }
46
48
  return { kind: 'questions', route, id };
47
49
  }
48
- if (page === 'notes.html' || page === 'plan.html') return { kind: 'notes', route, item: u.searchParams.get('item') };
50
+ if (page === 'notes.html' || page === 'plan.html') {
51
+ return u.searchParams.has('wave') ? { kind: 'notes', route, wave: u.searchParams.get('wave') } : { kind: 'notes', route, item: u.searchParams.get('item') };
52
+ }
49
53
  return frame;
50
54
  }
51
55
 
@@ -56,8 +60,8 @@ globalThis.AppRouter = (function () {
56
60
  case 'board': return r + (view.hash || '');
57
61
  case 'settings': return `${r}settings.html`;
58
62
  case 'questions': return `${r}questions.html${view.id ? `#${encodeURIComponent(view.id)}` : ''}`;
59
- case 'notes': return `${r}notes.html${view.item ? `?item=${encodeURIComponent(view.item)}` : ''}`;
60
- case 'hub': return `/_hub/${view.w ? `?w=${encodeURIComponent(view.w)}` : ''}${view.hash || ''}`;
63
+ case 'notes': return `${r}notes.html${view.wave ? `?wave=${encodeURIComponent(view.wave)}` : view.item ? `?item=${encodeURIComponent(view.item)}` : ''}`;
64
+ case 'hub': return `/_hub/${view.hash || ''}`;
61
65
  default: return view.url;
62
66
  }
63
67
  }
@@ -74,7 +78,7 @@ globalThis.AppRouter = (function () {
74
78
  return { href: u.href, pushed };
75
79
  }
76
80
 
77
- // The page tree (docs/adr/0058-back-follows-the-page-tree.md): a board, its Waiting on you, and a prototype page tied
81
+ // The page tree (docs/adr/2026-10-30-1125-back-follows-the-page-tree.md): a board, its Waiting on you, and a prototype page tied
78
82
  // to one of its questions by ?q=, framed. levelOf → 0, 1 or 2 down the tree, or null for a view outside it.
79
83
  function questionOf(view, boards) {
80
84
  if (view?.kind !== 'frame' || !boards.has(view.route)) return null;
@@ -1,5 +1,5 @@
1
1
  // The rules editor on Board Settings and on the Hub page
2
- // (docs/adr/0039-rules-live-on-settings-at-hub-and-board-level.md). Edits stay on the page until Save, as the
2
+ // (docs/adr/2026-10-29-1639-rules-live-on-settings-at-hub-and-board-level.md). Edits stay on the page until Save, as the
3
3
  // reviewers list does; Save hands {rules: [{id?, text}], base: [the ids it loaded]} to the page's save(), whose
4
4
  // server merges it (hub/rules.mjs applyRules). It brings its own CSS, colours from colors.css only, and takes the page's
5
5
  // button classes. rows, payload, dirty and render are pure, for ui.test.mjs; mount touches the DOM.
@@ -39,7 +39,7 @@
39
39
  .rules-editor .rule-body { flex:1; min-width:0; }
40
40
  .rules-editor .rule-text { overflow-wrap:anywhere; }
41
41
  .rules-editor .rule-meta { display:flex; flex-wrap:wrap; gap:6px; margin-top:4px; font-size:12px; color:var(--muted); }
42
- .rules-editor .rule-by { padding:0 6px; border-radius:6px; background:var(--chip); color:var(--color-info); }
42
+ .rules-editor .rule-by { padding:0 6px; border-radius:6px; background:var(--chip); color:var(--muted); }
43
43
  .rules-editor .rule-actions { flex:none; display:flex; gap:4px; }
44
44
  .rules-editor textarea { box-sizing:border-box; width:100%; min-width:0; resize:vertical; font:inherit; font-size:16px; color:var(--text); background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:6px 8px; }
45
45
  .rules-editor .rule-add { display:flex; align-items:flex-end; gap:6px; margin-top:8px; }
@@ -110,10 +110,9 @@
110
110
  addBox.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) { ev.preventDefault(); add(); } });
111
111
  addBox.addEventListener('input', mark);
112
112
  saveBtn.addEventListener('click', async () => {
113
- saveBtn.disabled = true;
114
- status.textContent = 'Saving…';
113
+ status.textContent = '';
115
114
  try {
116
- const r = await opts.save(payload(list));
115
+ const r = await BoardUI.waitOn(saveBtn, opts.save(payload(list)));
117
116
  list = rows(r.rules);
118
117
  editing = null;
119
118
  status.textContent = r.message || 'Saved.';