@lovek4d/kickflip 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CONTEXT.md +105 -68
  2. package/README.md +1 -1
  3. package/SKILL.md +27 -21
  4. package/bin/kickflip.mjs +27 -18
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -0
  7. package/board/arch-review.mjs +4 -4
  8. package/board/bin/board.mjs +199 -79
  9. package/board/build-script.mjs +2 -1
  10. package/board/domain.mjs +9 -7
  11. package/board/finish.mjs +4 -4
  12. package/board/headless.mjs +9 -4
  13. package/board/on-host.mjs +1 -1
  14. package/board/onboarding.mjs +11 -0
  15. package/board/prototype-base.mjs +1 -0
  16. package/board/prototype-page.mjs +1 -0
  17. package/board/public/app-arch.js +1 -1
  18. package/board/public/app-asks.js +6 -6
  19. package/board/public/app-banner.js +7 -13
  20. package/board/public/app-cards.js +75 -37
  21. package/board/public/app-core.js +26 -8
  22. package/board/public/app-header.js +8 -8
  23. package/board/public/app-lanes.js +67 -58
  24. package/board/public/app-needs.js +140 -10
  25. package/board/public/app-reader.js +1 -1
  26. package/board/public/app-snapshot.js +46 -72
  27. package/board/public/app-threads.js +78 -75
  28. package/board/public/app.html +9 -4
  29. package/board/public/app.js +114 -38
  30. package/board/public/attach.js +2 -2
  31. package/board/public/dialogs.js +9 -39
  32. package/board/public/hub-kit.js +66 -4
  33. package/board/public/hub.css +2 -0
  34. package/board/public/linear-key.js +46 -35
  35. package/board/public/new-task.js +20 -39
  36. package/board/public/progress-review.html +46 -0
  37. package/board/public/progress-review.js +158 -0
  38. package/board/public/prototype-kit.js +137 -7
  39. package/board/public/prototype-switcher.js +152 -123
  40. package/board/public/prototype.html +57 -3
  41. package/board/public/router.js +7 -6
  42. package/board/public/shell.js +68 -26
  43. package/board/public/sidebar.css +1 -0
  44. package/board/public/sidebar.js +16 -30
  45. package/board/public/style.css +205 -83
  46. package/board/public/ui-cards.js +91 -65
  47. package/board/public/ui-enums.js +1 -1
  48. package/board/public/ui-header.js +27 -18
  49. package/board/public/ui-hub.js +12 -1
  50. package/board/public/ui-links.js +7 -7
  51. package/board/public/ui-order.js +12 -10
  52. package/board/public/ui-page-header.js +19 -7
  53. package/board/public/ui-patch.js +7 -33
  54. package/board/public/ui-progress-bar.js +28 -0
  55. package/board/public/ui-questions.js +47 -52
  56. package/board/public/ui-sidebar.js +34 -26
  57. package/board/public/ui-snapshot.js +43 -52
  58. package/board/public/ui-threads.js +59 -30
  59. package/board/public/ui.js +125 -18
  60. package/board/public/view-hub-settings.js +22 -67
  61. package/board/public/view-hub.js +19 -4
  62. package/board/public/view-notes.js +27 -7
  63. package/board/public/view-questions.js +205 -240
  64. package/board/public/view-settings.js +313 -76
  65. package/board/render-check.mjs +10 -0
  66. package/board/review.mjs +1 -1
  67. package/board/server.mjs +19 -20
  68. package/board/stop.mjs +1 -0
  69. package/board/store.mjs +29 -15
  70. package/board/sync.mjs +2 -0
  71. package/board/tickets.mjs +3 -3
  72. package/board/transcript.mjs +2 -0
  73. package/board/worktree.mjs +25 -20
  74. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  75. package/hooks-plugin/hooks/hooks.json +9 -0
  76. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/front-door.mjs +5 -5
  80. package/hub/github.mjs +3 -3
  81. package/hub/hub.mjs +11 -11
  82. package/hub/incoming.mjs +2 -2
  83. package/hub/linear.mjs +1 -2
  84. package/hub/live.mjs +2 -2
  85. package/hub/locks.mjs +1 -1
  86. package/hub/owner-checks.mjs +1 -1
  87. package/hub/pr-report.mjs +2 -2
  88. package/hub/presence.mjs +1 -1
  89. package/hub/push.mjs +2 -2
  90. package/hub/readiness.mjs +1 -2
  91. package/hub/review-send.mjs +1 -0
  92. package/hub/secrets.mjs +2 -0
  93. package/hub/self-restart.mjs +2 -0
  94. package/hub/sessions.mjs +2 -2
  95. package/hub/share.mjs +1 -0
  96. package/hub/slack.mjs +2 -2
  97. package/hub/stream.mjs +10 -13
  98. package/hub/try.mjs +7 -7
  99. package/hub/upgrade.mjs +1 -1
  100. package/hub/usage.mjs +3 -3
  101. package/hub/views.mjs +1 -1
  102. package/package.json +2 -1
  103. package/shared/audit.mjs +4 -0
  104. package/shared/card-prs.mjs +1 -1
  105. package/shared/fake-gh.mjs +17 -4
  106. package/shared/machine-gate.mjs +2 -0
  107. package/shared/prototype-frame.js +1 -0
  108. package/shared/service.mjs +4 -4
  109. package/shared/usage-gate.mjs +1 -1
  110. package/board/public/ui-phase-bars.js +0 -17
@@ -2,21 +2,18 @@
2
2
  // Load before any page script that asks (docs/adr/2026-11-03-0259-confirmations-are-the-browsers-own.md).
3
3
  (function () {
4
4
  // linear: Hub's /_hub/api/linear answer, else loadLinear() fetches it once Linear is the source. Linear is picked only
5
- // while Hub's key works; tapping it without one opens Hub's key modal over the page, or with keyState the key field
6
- // inline (linear-key.js,
5
+ // while Hub's key works; tapping it without one opens Hub's key modal over the page (linear-key.js,
7
6
  // docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A New board's "New project…" reads as
8
7
  // new_project, which the hub creates on Start; Board Settings creates it with createProject. change(value, valid,
9
8
  // {loaded, typing}) follows each change: loaded when only Linear's lists arrived, typing while a text field is being typed in.
10
9
  // GitHub issues needs repo(), the board's GitHub owner/name: without one, as on a board whose repo is another git
11
10
  // remote, it can't be picked and says why (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
12
11
  // With keyState(), Hub's /_hub/api/settings `linear`, as Board Settings passes it, the source row reads its live state
13
- // as a status word, and Linear tapped without a working key opens Hub's key field in keySlot, an element outside any
14
- // form, since the key field is a form of its own; keyBusy(on) follows each save of the key
15
- // (docs/adr/2026-11-04-0227-every-new-card-gets-a-ticket-in-the-boards-current-source.md).
16
- function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState, keySlot, keyBusy = () => {} } = {}) {
12
+ // as a status word (docs/adr/2026-11-04-0227-every-new-card-gets-a-ticket-in-the-boards-current-source.md).
13
+ function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState } = {}) {
17
14
  const { esc } = BoardUI;
18
15
  const draft = { prefix: 'INT', ...value };
19
- let creating = false, createError = '', destroyed = false, asking = false, noGithub = false, key = null, inline = null;
16
+ let creating = false, createError = '', destroyed = false, asking = false, noGithub = false, key = null;
20
17
  const works = () => Boolean(linear?.set && !linear.refused);
21
18
  const keyWorks = () => Boolean(key?.set && !key.refused);
22
19
  const keyWord = () => (keyWorks() ? 'linear key saved' : key.refused ? 'Linear refused the key' : 'no Linear key');
@@ -26,31 +23,6 @@
26
23
  const on = draft.source === 'linear' && keyWorks();
27
24
  return `<div class="tk-status${on ? ' on' : ''}" data-tk-status role="status"><span class="dot" aria-hidden="true"></span><b>${esc(statusWord())}</b></div>`;
28
25
  }
29
- // Hub's key field in keySlot. Once a key works, Linear is picked.
30
- function openInline() {
31
- if (!inline) {
32
- keySlot.innerHTML = '<p class="tk-ask">Linear needs a key. Paste one here.</p><div class="hub-view"><main></main></div>';
33
- inline = LinearKey.mount(keySlot.querySelector('main'), { linear: key, busy: keyBusy,
34
- saved: (out) => { key = out.linear; render(); },
35
- worked: async () => {
36
- closeInline();
37
- draft.source = 'linear';
38
- await load();
39
- if (destroyed) return;
40
- pickDefaults();
41
- render(); root.querySelector('[data-tk-source="linear"]').focus(); change(read(), valid());
42
- } });
43
- keySlot.hidden = false;
44
- }
45
- keySlot.querySelector('#linearKey')?.focus({ preventScroll: true });
46
- }
47
- function closeInline() {
48
- if (!inline) return;
49
- inline.destroy();
50
- inline = null;
51
- keySlot.hidden = true;
52
- keySlot.innerHTML = '';
53
- }
54
26
  async function load() {
55
27
  linear = { loading: true };
56
28
  render();
@@ -114,13 +86,12 @@
114
86
  if (!works()) {
115
87
  draft.source = before;
116
88
  render();
117
- await keyed;
118
- if (destroyed) return;
119
- if (key) { openInline(); return; }
120
89
  asking = true;
121
90
  const added = await LinearKey.open();
122
91
  asking = false;
123
92
  if (destroyed || !added) return;
93
+ if (keyState) key = await keyState().catch(() => key);
94
+ if (destroyed) return;
124
95
  draft.source = 'linear';
125
96
  await load();
126
97
  if (destroyed) return;
@@ -149,7 +120,6 @@
149
120
  noGithub = button.dataset.tkSource === 'github' && !repo();
150
121
  if (noGithub) { render(); root.querySelector('[data-tk-source="github"]').focus(); return; }
151
122
  if (button.dataset.tkSource === 'linear') { pickLinear(); return; }
152
- closeInline();
153
123
  draft.source = button.dataset.tkSource;
154
124
  render(); root.querySelector(`[data-tk-source="${draft.source}"]`).focus(); change(read(), valid());
155
125
  };
@@ -168,13 +138,13 @@
168
138
  root.addEventListener('change', input);
169
139
  render();
170
140
  if (draft.source === 'linear' && !linear) load().then(() => { if (destroyed) return; pickDefaults(); render(); change(read(), valid(), { loaded: true }); });
171
- // Without an answer the row has no status word, and Linear without a key opens Hub's key modal as on New board.
172
- const keyed = keyState ? keyState().then((state) => { if (destroyed) return; key = state; render(); }, () => {}) : null;
141
+ // Without an answer the row has no status word.
142
+ if (keyState) keyState().then((state) => { if (destroyed) return; key = state; render(); }, () => {});
173
143
  return { read, valid, render, nameChanged() {
174
144
  const field = root.querySelector('[data-tk="newName"]');
175
145
  if (field && draft.newName === undefined) field.value = title();
176
146
  },
177
- destroy() { destroyed = true; closeInline(); root.removeEventListener('click', click); root.removeEventListener('input', input); root.removeEventListener('change', input); } };
147
+ destroy() { destroyed = true; root.removeEventListener('click', click); root.removeEventListener('input', input); root.removeEventListener('change', input); } };
178
148
  }
179
149
  function fitSheet(sheet) {
180
150
  const vv = window.visualViewport;
@@ -1,4 +1,4 @@
1
- // What the hub page and settings modals share, each through the view's B
1
+ // What the hub page and settings modals share, Board Settings' duration picker included, each through the view's B
2
2
  // (app.js BoardView.page), so unmounting the view stops it all: loading and refreshing the view's data, posting its
3
3
  // forms for JSON, and this device's notifications row.
4
4
  (function () {
@@ -113,6 +113,8 @@
113
113
  const KEPT = 'kickflip.push.endpoint';
114
114
  const kept = () => { try { return localStorage.getItem(KEPT); } catch { return null; } };
115
115
  const keep = (endpoint) => { try { if (endpoint) localStorage.setItem(KEPT, endpoint); else localStorage.removeItem(KEPT); } catch { /* storage blocked here */ } };
116
+ // The id the hub gave that subscription, so the Integrations list marks this device's line (view-hub.js).
117
+ const subscribe = (sub) => post('/_hub/push/subscribe', sub.toJSON()).then(({ id }) => { try { localStorage.setItem('kickflip.push.id', id); } catch { /* storage blocked here */ } });
116
118
  const settle = (endpoint = null) => {
117
119
  const old = kept();
118
120
  if (old && old !== endpoint) post('/_hub/push/unsubscribe', { endpoint: old }).catch(() => {});
@@ -133,7 +135,7 @@
133
135
  const sub = await reg?.pushManager.getSubscription();
134
136
  settle(sub?.toJSON().endpoint);
135
137
  if (sub) {
136
- post('/_hub/push/subscribe', sub.toJSON()).catch(() => {});
138
+ subscribe(sub).catch(() => {});
137
139
  show({ title: 'This device', action: { label: 'Test', act: 'test', quiet: true } }, true);
138
140
  } else {
139
141
  show({ title: 'This device', sub: 'Off · Claude sessions tell you here when something needs you.', action: { label: 'Turn on', act: 'on' } });
@@ -148,7 +150,7 @@
148
150
  await navigator.serviceWorker.ready;
149
151
  const { key } = await (await fetch('/_hub/push/key')).json();
150
152
  const sub = (await reg.pushManager.getSubscription()) || await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) });
151
- await post('/_hub/push/subscribe', sub.toJSON());
153
+ await subscribe(sub);
152
154
  await render();
153
155
  changed();
154
156
  } catch (err) { show({ title: 'Could not turn on notifications', sub: String(err?.message || err) }); }
@@ -167,5 +169,65 @@
167
169
  return { state: (line) => (state && on ? { ...state, sub: `On · ${line}` } : state), busy: () => busy, ready };
168
170
  }
169
171
 
170
- globalThis.HubKit = { live, forms, device };
172
+ // datetime-local speaks the device's clock; the picker's hidden field carries its UTC ISO value to the server.
173
+ const localInput = (d) => {
174
+ const pad = (n) => String(n).padStart(2, '0');
175
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
176
+ };
177
+ function customBounds(input, reset = false) {
178
+ const now = Date.now();
179
+ input.min = localInput(new Date(now + 60_000));
180
+ input.max = localInput(new Date(now + 30 * 86_400_000));
181
+ if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
182
+ }
183
+
184
+ // The one duration picker Snooze, Keep awake, Pause hub and Pause board share (hub/duration.mjs): chips that post
185
+ // `name` to `action` at once, then Custom, whose date and time posts as `name` too. While its setting is on, `on`
186
+ // replaces it with when it ends and a button that posts `name`=off.
187
+ function durationPicker({ id, title, icon, action, name, choices, on, button }) {
188
+ const { esc } = HubUI;
189
+ if (on) return `<div class="row dur-on">${icon}<span class="body"><span class="text title">${esc(on.title)}</span><form method="post" action="${action}"><button class="btn quiet" name="${name}" value="off">${button}</button></form></span></div>`;
190
+ if (!choices) return '';
191
+ const chips = choices.map((c) => `<button class="btn quiet" name="${name}" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
192
+ return `<div class="block" role="group" aria-labelledby="${id}Title"><div class="dur-head">${icon}<span class="title" id="${id}Title">${title}</span></div><div class="dur-chips"><form class="dur-choices" method="post" action="${action}">${chips}</form><form class="dur-custom" method="post" action="${action}"><input type="hidden" name="${name}"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-dur-at required aria-label="Custom ${title.toLowerCase()} until a date and time"></form></div></div>`;
193
+ }
194
+
195
+ // Wires every duration picker under B.root: Custom opens its picker, and closing it submits its form with the time
196
+ // chosen. → painted(), which a view calls after drawing a picker to set Custom's bounds and suggested time.
197
+ function durationPickers(B) {
198
+ let openPicker = null;
199
+ B.on(B.root, 'click', (e) => {
200
+ const input = e.target.closest?.('[data-dur-at]');
201
+ if (!input) return;
202
+ openPicker = input;
203
+ customBounds(input, input.value === input.defaultValue);
204
+ try { input.showPicker(); } catch { /* iOS opens its wheel on the tap itself. */ }
205
+ });
206
+ // A chip's pointerdown comes before Custom's focusout; without this reset Custom posts first and the chip's post is dropped.
207
+ B.on(B.root, 'pointerdown', (e) => {
208
+ const input = e.target.closest?.('.dur-choices button') && e.target.closest('.dur-chips')?.querySelector('[data-dur-at]');
209
+ if (!input) return;
210
+ openPicker = null;
211
+ input.value = input.defaultValue;
212
+ });
213
+ // Closing an opened picker accepts its suggested time; an unchanged field reached by Tab does nothing.
214
+ const saveCustom = (input) => {
215
+ if (!input.value || (input !== openPicker && input.value === input.defaultValue) || !input.checkValidity()) return;
216
+ const at = new Date(input.value);
217
+ if (Number.isNaN(at.getTime())) return;
218
+ input.form.querySelector('input[type="hidden"]').value = at.toISOString();
219
+ input.form.requestSubmit();
220
+ openPicker = null;
221
+ input.defaultValue = input.value;
222
+ };
223
+ B.on(B.root, 'change', (e) => {
224
+ if (e.target.matches?.('[data-dur-at]') && document.activeElement !== e.target) saveCustom(e.target);
225
+ });
226
+ B.on(B.root, 'focusout', (e) => {
227
+ if (e.target.matches?.('[data-dur-at]')) saveCustom(e.target);
228
+ });
229
+ return { painted: () => { for (const input of B.root.querySelectorAll('[data-dur-at]')) customBounds(input, true); } };
230
+ }
231
+
232
+ globalThis.HubKit = { live, forms, device, durationPicker, durationPickers };
171
233
  })();
@@ -135,6 +135,8 @@
135
135
  .hub-view .lk-form input:focus { outline:2px solid var(--accent-soft); border-color:var(--accent); }
136
136
 
137
137
  .hub-view .ub-banner > button { flex: none; min-height: 32px; }
138
+ .hub-view .hold-banner.ub-paused { border-color: var(--line); background: var(--panel); }
139
+ .hub-view .hold-banner.ub-paused > svg { color: var(--muted); }
138
140
  .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)); }
139
141
  .hub-view .hold-banner.ub-going > svg { color: var(--color-danger); }
140
142
 
@@ -1,44 +1,55 @@
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`.
1
+ // Hub's token slots: Linear's key and Slack's user token. Hub settings › Linear and › Slack mount them, and the Tickets
2
+ // control (dialogs.js) opens Linear's over Board 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 token saves on paste, change or Enter
4
+ // through POST /_hub/settings/<kind>, which checks it with the service; state is hub/views.mjs settingsView's `<kind>`.
5
5
  (function () {
6
6
  const FAILED = 'Could not save. Is the host awake?';
7
7
  const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
8
- const works = (linear) => Boolean(linear?.set && !linear.refused);
8
+ const works = (state) => Boolean(state?.set && !state.refused);
9
+ const KINDS = {
10
+ linear: { field: 'key', input: 'linearKey', checking: 'Checking the key with Linear…', first: 'Paste a Linear API key', replace: 'Paste a new key to replace it', label: 'Linear API key',
11
+ 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.',
12
+ confirm: 'Boards on Linear fall back to internal tickets, and their cards keep the Linear tickets they have. Remove the Linear key?' },
13
+ slack: { field: 'token', input: 'slackToken', checking: 'Checking the token with Slack…', first: 'Paste a Slack user token', replace: 'Paste a new token to replace it', label: 'Slack user token',
14
+ note: 'Stays on the host. The hub sends your reviewers their PR links, git_merged reactions and weekday digests as you, in your DMs with them.',
15
+ confirm: 'Reviewer links, reactions and digests stop until a token is back. Remove the Slack token?' },
16
+ };
9
17
 
10
- function slot(linear, { checking, flash }) {
18
+ function slot(kind, state, { checking, flash }) {
11
19
  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>';
20
+ const k = KINDS[kind];
21
+ const sub = checking ? `<span class="lk-checking">${SPIN}${k.checking}</span>`
22
+ : `${state.down ? `<span class="st down">${esc(state.down)}</span> · ` : ''}${state.keys
23
+ ? esc(state.sub).replace(esc(state.keys.label), `<a href="${esc(state.keys.url)}" target="_blank" rel="noopener">${esc(state.keys.label)}</a>`)
24
+ : esc(state.sub)}`;
25
+ const remove = state.set && !checking ? `<button type="button" class="btn quiet danger" data-${kind}-remove>Remove</button>` : '';
26
+ const placeholder = checking ? 'Checking…' : state.set ? k.replace : k.first;
27
+ return `${HubUI.flash(flash)}<div class="group">${row({ icon: tile('key', state.refused ? 'var(--color-danger)' : 'var(--color-quiet)'), title: esc(state.title), sub, wrap: !works(state), trail: remove })}`
28
+ + `<form class="block lk-form"><input id="${k.input}" name="${k.field}" type="password" required${checking ? ' disabled' : ''} placeholder="${placeholder}" aria-label="${k.label}" autocomplete="off" autocapitalize="off" spellcheck="false"></form></div>`
29
+ + `<p class="note">${k.note}</p>`;
21
30
  }
22
31
 
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;
32
+ // Draws kind's slot into root and saves from it. busy(true) starts each save and busy(false) ends it; saved(out) gets
33
+ // each answer, /_hub/api/settings's JSON, before the slot repaints; worked() follows a saved token the service accepted.
34
+ // → {busy(), settled(): a promise that resolves once no save is out, state(), set(state) unless busy, destroy()}
35
+ function mount(root, { kind = 'linear', state, saved = () => {}, worked = () => {}, busy = () => {} }) {
36
+ const k = KINDS[kind];
37
+ const input = `#${k.input}`;
38
+ let current = state, flash = null, checking = false, pending = null, destroyed = false;
28
39
  const paint = () => {
29
- const typed = root.querySelector('#linearKey')?.value || '';
30
- root.innerHTML = slot(current, { checking, flash });
31
- if (!checking) root.querySelector('#linearKey').value = typed;
40
+ const typed = root.querySelector(input)?.value || '';
41
+ root.innerHTML = slot(kind, current, { checking, flash });
42
+ if (!checking) root.querySelector(input).value = typed;
32
43
  };
33
44
  function post(body) {
34
- checking = body.has('key');
45
+ checking = body.has(k.field);
35
46
  flash = null;
36
47
  pending = (async () => {
37
48
  try {
38
- const r = await fetch('/_hub/settings/linear', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body });
49
+ const r = await fetch(`/_hub/settings/${kind}`, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body });
39
50
  const out = await r.json().catch(() => null);
40
51
  if (destroyed) return;
41
- if (r.ok && out) { current = out.linear; saved(out); } else flash = { text: out?.error || FAILED, error: true };
52
+ if (r.ok && out) { current = out[kind]; saved(out); } else flash = { text: out?.error || FAILED, error: true };
42
53
  } catch { flash = { text: FAILED, error: true }; }
43
54
  const key = checking;
44
55
  checking = false;
@@ -52,23 +63,23 @@
52
63
  paint();
53
64
  return pending;
54
65
  }
55
- const save = (value) => { if (!pending && value.trim()) post(new URLSearchParams({ key: value.trim() })); };
66
+ const save = (value) => { if (!pending && value.trim()) post(new URLSearchParams({ [k.field]: value.trim() })); };
56
67
  const onPaste = (e) => {
57
68
  const text = e.clipboardData?.getData('text');
58
- if (!e.target.matches?.('#linearKey') || !text) return;
69
+ if (!e.target.matches?.(input) || !text) return;
59
70
  e.preventDefault();
60
71
  e.target.value = text.trim();
61
72
  save(e.target.value);
62
73
  };
63
- const onChange = (e) => { if (e.target.matches?.('#linearKey')) save(e.target.value); };
74
+ const onChange = (e) => { if (e.target.matches?.(input)) save(e.target.value); };
64
75
  const onSubmit = (e) => {
65
76
  if (!e.target.matches?.('.lk-form')) return;
66
77
  e.preventDefault();
67
- save(e.target.querySelector('#linearKey').value);
78
+ save(e.target.querySelector(input).value);
68
79
  };
69
80
  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' }));
81
+ if (!e.target.closest?.(`[data-${kind}-remove]`) || pending) return;
82
+ if (await BoardDialogs.confirm(k.confirm) && !destroyed) post(new URLSearchParams({ remove: '1' }));
72
83
  };
73
84
  const listeners = [['paste', onPaste], ['change', onChange], ['submit', onSubmit], ['click', onClick]];
74
85
  for (const [type, fn] of listeners) root.addEventListener(type, fn);
@@ -76,7 +87,7 @@
76
87
  return {
77
88
  busy: () => Boolean(pending),
78
89
  settled: () => pending || Promise.resolve(),
79
- linear: () => current,
90
+ state: () => current,
80
91
  set(next) { if (!pending) { current = next; paint(); } },
81
92
  destroy() { destroyed = true; for (const [type, fn] of listeners) root.removeEventListener(type, fn); },
82
93
  };
@@ -115,7 +126,7 @@
115
126
  dialog.close();
116
127
  wrap.remove();
117
128
  if (previous?.isConnected) previous.focus({ preventScroll: true });
118
- resolve(works(key?.linear()));
129
+ resolve(works(key?.state()));
119
130
  }
120
131
  function close() {
121
132
  if (!key?.busy()) { finish(); return; }
@@ -134,7 +145,7 @@
134
145
  wrap.querySelector('#linearKeyTitle').focus({ preventScroll: true });
135
146
  fetch('/_hub/api/settings', { cache: 'no-store', headers: { accept: 'application/json' } }).then((r) => (r.ok ? r.json() : Promise.reject())).then((out) => {
136
147
  if (finished) return;
137
- key = mount(content, { linear: out.linear, worked: finish, busy: (on) => doneBusy(wrap.querySelectorAll('[data-lk-done]'), on) });
148
+ key = mount(content, { state: out.linear, worked: finish, busy: (on) => doneBusy(wrap.querySelectorAll('[data-lk-done]'), on) });
138
149
  }).catch(() => { if (!finished) content.innerHTML = '<p class="note">Offline. Is the host awake?</p>'; });
139
150
  return result;
140
151
  }
@@ -1,7 +1,6 @@
1
- // The + button and the sheet that sends a new task as an ask event, on every page of a board: the board view's markup
2
- // (app.js) and each other view of a board (shell.js) put BoardNewTask.markup() in their root and wire it with
3
- // BoardNewTask.wire(B) once mounted. A board's view sends through B.send, which lands the board the ask changed; any
4
- // other view sends through its site. B.newTaskKey(ev) is the n-twice handler, which a framed page's document also feeds.
1
+ // The + button and the sheet that sends a new task as an ask event, on the board's main page alone: the board view's markup
2
+ // (app.js) puts BoardNewTask.markup() in its root and app-asks.js wires it with BoardNewTask.wire(B). No other page
3
+ // opts in. The ask goes through B.send, which lands the board the ask changed. B.newTaskKey(ev) is the n-twice handler, which a framed page's document also feeds.
5
4
  globalThis.BoardNewTask = (function () {
6
5
  const markup = () => `<button id="compose" class="fab" type="button" aria-label="New task" title="New task (press n twice)" aria-haspopup="dialog" aria-expanded="false"><svg width="35" height="35" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
7
6
  <dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
@@ -9,32 +8,30 @@ globalThis.BoardNewTask = (function () {
9
8
  <div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
10
9
  ${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
11
10
  <div class="pending-files" data-files></div>
12
- <div class="tk-seg ask-seg" role="group" aria-label="Before building"><button type="button" id="askDiscuss" aria-pressed="true">Discuss</button><button type="button" id="askQuick" aria-pressed="false">Quick build</button></div>
13
- <p class="muted small ask-choice-help" id="askChoiceHelp" aria-live="polite"></p>
14
- <div class="row">
15
- <span class="muted small" id="askStatus"></span>
16
- <button type="submit" class="primary">Send</button>
17
- </div>
11
+ <div class="send-actions">${BoardUI.sendButton('Implement', 1, ' data-intent="quick"')}${BoardUI.sendButton('Discuss', 0, ' data-intent="discuss"')}</div>
12
+ <p class="muted small" id="askStatus" aria-live="polite"></p>
18
13
  </form>
19
14
  </dialog>`;
20
15
 
21
16
  function wire(B) {
17
+ // A View person looks only (shell.js marks the page shared-view without shared-full, and style.css hides the +), so
18
+ // n twice opens nothing.
19
+ const html = document.documentElement.classList;
20
+ if (html.contains('shared-view') && !html.contains('shared-full')) return;
22
21
  const { $, store, toast, quieted, sendDraft } = B;
23
- const { attachButton, waitOn } = BoardUI;
22
+ const { waitOn, keyedSend } = BoardUI;
24
23
  const send = (kind, data) => (B.send ? B.send(kind, data) : B.site.send(kind, data));
25
24
  // The ask box is a bottom sheet: a <dialog> opened from the + button. Closing keeps the draft.
26
25
  const sheet = $('#sheet');
27
26
  const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
28
- if (!$('#askForm .row .attach')) $('#askForm .row').insertAdjacentHTML('afterbegin', attachButton());
29
27
  // Files picked before the app shell switched away from this board are still attached, so they show again.
30
28
  BoardAttach.paint('ask', B.site.root);
31
- const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
32
- if (!$('#askForm .send-hint')) $('#askForm button[type=submit]').insertAdjacentHTML('beforebegin', `<span class="send-hint" aria-hidden="true">${isMac ? '⌘↵' : 'Ctrl+↵'}</span>`);
33
- // ⌘↵ (Ctrl+↵ off a Mac) sends the ask from its box.
29
+ // ⌘↵ sends the ask as Discuss and ⌥↵ as Implement (ui.js keyedSend).
34
30
  B.on($('#askText'), 'keydown', (ev) => {
35
- if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.isComposing) return;
31
+ const button = keyedSend(ev, $('#askForm'));
32
+ if (!button) return;
36
33
  ev.preventDefault();
37
- $('#askForm').requestSubmit();
34
+ $('#askForm').requestSubmit(button);
38
35
  });
39
36
  $('#askText').value = store.get('draft:ask', '');
40
37
  B.on($('#askText'), 'input', (e) => { if (!quieted('draft:ask', e.target)) store.set('draft:ask', e.target.value); });
@@ -69,7 +66,7 @@ globalThis.BoardNewTask = (function () {
69
66
  if (ev.defaultPrevented) { lastN = -Infinity; return; }
70
67
  if (ev.key !== 'n' || ev.metaKey || ev.ctrlKey || ev.altKey || ev.repeat || ev.isComposing) return;
71
68
  const field = ev.target.closest && ev.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
72
- if (field || document.querySelector('dialog[open]') || B.root.classList.contains('offline')) { lastN = -Infinity; return; }
69
+ if (field || document.querySelector('dialog[open]') || B.root.classList.contains('down')) { lastN = -Infinity; return; }
73
70
  if (ev.timeStamp - lastN > TWICE_MS) { lastN = ev.timeStamp; return; }
74
71
  lastN = -Infinity;
75
72
  ev.preventDefault();
@@ -80,36 +77,20 @@ globalThis.BoardNewTask = (function () {
80
77
  B.on($('#sheetClose'), 'click', closeSheet);
81
78
  B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
82
79
  B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
83
- // Discuss or Quick build, above Send: Discuss, the default, has the session grill or prototype the task at once; Quick
84
- // build sends the ask with quick: true, and the session marks the card ready. Each send goes back to Discuss
85
- // (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
86
- const CHOICE_HELP = {
87
- discuss: 'Discuss: Claude grills or prototypes it first, then waits for you.',
88
- quick: 'Quick build: for changes you’re sure about; Claude marks it ready and builds.',
89
- };
90
- let quick = false;
91
- function choose(isQuick) {
92
- quick = isQuick;
93
- $('#askDiscuss').setAttribute('aria-pressed', String(!quick));
94
- $('#askQuick').setAttribute('aria-pressed', String(quick));
95
- $('#askChoiceHelp').textContent = CHOICE_HELP[quick ? 'quick' : 'discuss'];
96
- }
97
- choose(false);
98
- B.on(B.root, 'click', (ev) => {
99
- const b = ev.target.closest('#askDiscuss, #askQuick');
100
- if (b) choose(b.id === 'askQuick');
101
- });
80
+ // Discuss has the session grill or prototype the task at once; Implement sends the ask with quick: true, and the
81
+ // session marks the card ready (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
102
82
  B.on($('#askForm'), 'submit', async (ev) => {
103
83
  ev.preventDefault();
104
84
  $('#askText').blur();
105
85
  const text = $('#askText').value.trim();
106
86
  const files = BoardAttach.files('ask', B.site.root);
87
+ const button = ev.submitter;
88
+ const quick = button.dataset.intent === 'quick';
107
89
  if ((!text && !files.length) || BoardAttach.busy('ask', B.site.root)) return;
108
90
  $('#askStatus').textContent = '';
109
91
  try {
110
- await waitOn($('#askForm button[type="submit"]'), sendDraft('draft:ask', $('#askText'), () => $('#askText'), () => send('ask', { text, ...(files.length ? { files } : {}), ...(quick ? { quick: true } : {}) })));
92
+ await waitOn(button, sendDraft('draft:ask', $('#askText'), () => $('#askText'), () => send('ask', { text, ...(files.length ? { files } : {}), ...(quick ? { quick: true } : {}) })));
111
93
  BoardAttach.clear('ask', B.site.root);
112
- choose(false);
113
94
  $('#askStatus').textContent = '';
114
95
  closeSheet();
115
96
  toast('Sent');
@@ -0,0 +1,46 @@
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>Progress bars</title>
7
+ <link rel="stylesheet" href="colors.css">
8
+ <link rel="stylesheet" href="style.css">
9
+ <style>
10
+ /* The progress-bar review (progress-review.js): the board's lanes, each card one bar or status line drawn by the real
11
+ renderers, its colour the lane's --lane, which the lane's colour pick sets; the open card's editor beneath. */
12
+ .pr-lanes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
13
+ @media (max-width: 799px) { .pr-lanes { grid-template-columns: minmax(0, 1fr); } }
14
+ .pr-lane > h2 { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 0 0 6px; font-size: 13px; color: var(--lane); border-bottom: 2px solid var(--lane); padding-bottom: 4px; }
15
+ .pr-lane input[type="color"] { width: 32px; height: 24px; padding: 0; border: 1px solid var(--line); border-radius: 6px; background: none; }
16
+ .pr-lane .sub.agent:not(.waiting-gray, .quiet) { color: var(--lane); }
17
+ .pr-lane .phase-bars:not(.dim) { --bar-fill: var(--lane); }
18
+ .pr-lane .item[data-open] > summary { outline: 2px solid var(--accent); outline-offset: -2px; }
19
+ .pr-lane .card-time .sb-steps .phase-bars { gap: 2px; }
20
+ .pr-editor { margin-top: 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); padding: 12px; }
21
+ .pr-editor h2 { margin: 0 0 8px; font-size: 15px; }
22
+ .pr-editor label { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
23
+ .pr-editor :where(input, select) { font-size: 16px; }
24
+ .pr-editor ol { margin: 8px 0 0; padding: 0; list-style: none; }
25
+ .pr-editor li { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
26
+ .pr-editor li.over { outline: 2px dashed var(--accent); }
27
+ .pr-editor .grip { cursor: grab; color: var(--muted); }
28
+ .pr-send { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
29
+ </style>
30
+ </head>
31
+ <body>
32
+ <!--header {"lead":"back","title":"Progress bars","backHref":"./","controls":false}-->
33
+ <main>
34
+ <p class="muted">Every progress bar and status line the board draws, in the lane it shows in. Tap one to rename it, move it to another lane or rename and reorder its steps; pick each lane's colour; then Send.</p>
35
+ <div id="review"></div>
36
+ </main>
37
+ <script src="ui.js"></script>
38
+ <script src="ui-links.js"></script>
39
+ <script src="ui-enums.js"></script>
40
+ <script src="ui-progress-bar.js"></script>
41
+ <script src="ui-cards.js"></script>
42
+ <script src="ui-sidebar.js"></script>
43
+ <script src="claude.js"></script>
44
+ <script src="progress-review.js"></script>
45
+ </body>
46
+ </html>