@lovek4d/kickflip 0.4.0 → 0.5.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 (51) hide show
  1. package/CONTEXT.md +32 -16
  2. package/SKILL.md +8 -6
  3. package/bin/doctor.mjs +4 -4
  4. package/bin/install.mjs +3 -3
  5. package/bin/kickflip.mjs +8 -8
  6. package/bin/wait-events.mjs +2 -2
  7. package/board/bin/board.mjs +50 -28
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -3
  10. package/board/headless.mjs +4 -3
  11. package/board/merge3.mjs +1 -0
  12. package/board/on-host.mjs +1 -1
  13. package/board/permissions.mjs +2 -2
  14. package/board/public/app-asks.js +6 -89
  15. package/board/public/app-cards.js +19 -10
  16. package/board/public/app-core.js +1 -20
  17. package/board/public/app-reader.js +174 -0
  18. package/board/public/app-threads.js +49 -35
  19. package/board/public/app.html +2 -0
  20. package/board/public/app.js +28 -16
  21. package/board/public/hub.css +1 -0
  22. package/board/public/new-task.js +121 -0
  23. package/board/public/shell.js +4 -0
  24. package/board/public/sidebar.css +1 -0
  25. package/board/public/style.css +91 -3
  26. package/board/public/ui-cards.js +46 -21
  27. package/board/public/ui-enums.js +1 -1
  28. package/board/public/ui-phase-bars.js +4 -4
  29. package/board/public/ui-questions.js +47 -18
  30. package/board/public/ui-sidebar.js +9 -3
  31. package/board/public/ui-threads.js +25 -12
  32. package/board/public/ui.js +42 -12
  33. package/board/public/view-frame.js +1 -0
  34. package/board/public/view-questions.js +45 -16
  35. package/board/public/view-settings.js +38 -19
  36. package/board/server.mjs +20 -18
  37. package/board/store.mjs +15 -13
  38. package/board/worktree.mjs +20 -20
  39. package/hub/boards.mjs +4 -4
  40. package/hub/front-door.mjs +20 -0
  41. package/hub/github.mjs +3 -3
  42. package/hub/hub.mjs +9 -12
  43. package/hub/new-board.mjs +2 -2
  44. package/hub/owner-checks.mjs +1 -0
  45. package/hub/readiness.mjs +2 -2
  46. package/hub/sessions.mjs +2 -2
  47. package/hub/try.mjs +7 -7
  48. package/package.json +1 -1
  49. package/shared/owner-check.mjs +3 -0
  50. package/shared/tailscale.mjs +1 -0
  51. package/shared/usage-gate.mjs +1 -1
@@ -1,7 +1,12 @@
1
1
  // A card's thread on the board view: loading and rendering it, and its reply box. Needs app-core.js.
2
2
  BoardView.part((B) => {
3
3
  const { $, store, state, toast, quieted, sendDraft, links } = B;
4
- const { esc, setHTML, threadMessages, attachButton, dropZone, protoButton, protoNotice, doneButton, waitOn } = BoardUI;
4
+ const { esc, setHTML, threadMessages, attachButton, dropZone, protoButton, grillButton, doneButton, waitOn, newQuestionsLine } = BoardUI;
5
+
6
+ const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more' };
7
+ // A /prototype or /grill word in a message, the last one winning, and the message without it.
8
+ const SLASH = /(^|\s)\/(grill|prototype)(?=\s|$)/g;
9
+ const slashOf = (value) => ({ flag: [...value.matchAll(SLASH)].pop()?.[2] ?? null, text: value.replace(SLASH, '$1').replace(/ {2,}/g, ' ').trim() });
5
10
 
6
11
  const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
7
12
 
@@ -48,26 +53,43 @@ BoardView.part((B) => {
48
53
  const edits = editsIn(key);
49
54
  if (!firstDrawn.has(key) && msgs.length) firstDrawn.set(key, newestTs(msgs));
50
55
  const view = viewOf(key);
51
- const sig = JSON.stringify([msgs, [...edits.keys()], view]);
56
+ const asked = newQuestionsLine(state.board?.items.find((i) => i.key === key)?.questions, seenBefore.get(key));
57
+ const lines = asked.length ? [...msgs, ...asked].sort((a, b) => String(a.ts).localeCompare(String(b.ts))) : msgs;
58
+ const sig = JSON.stringify([lines, [...edits.keys()], view]);
52
59
  if (el.dataset.sig === sig) return;
53
60
  el.dataset.sig = sig;
54
61
  // The reply form is built once per card, so a refresh never drops what is being typed.
55
62
  if (!el.querySelector('.msgs')) {
56
63
  const draft = store.get('draft:' + key, '');
57
- el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="Reply in this thread…">${esc(draft)}</textarea>`)}
64
+ el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="${esc(PLACEHOLDER)}">${esc(draft)}</textarea>`)}
65
+ <div class="flag-hint" data-hint aria-live="polite">${esc(HINT)}</div>
58
66
  <div class="pending-files" data-files></div>
59
- <div class="row">${attachButton()}${protoButton(key)}<button class="chip quick" type="button" data-quick="What's left?">What's left?</button><button class="primary" type="submit">Send</button></div></form>`;
67
+ <div class="row"><span class="tools">${attachButton()}${protoButton(key)}${grillButton(key)}</span><button class="chip quick" type="button" data-quick="What's left?">What's left?</button><button class="primary" type="submit">Send</button></div></form>`;
60
68
  BoardAttach.paint('item:' + key, B.site.root);
69
+ syncFlag(el.querySelector('form'));
61
70
  }
62
71
  syncDone(el, key);
63
- B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(msgs, links(key), edits, view)));
72
+ B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(lines, links(key), edits, view)));
64
73
  for (const id of edits.keys()) BoardAttach.paint('edit:' + id, B.site.root);
65
74
  B.flashChanged?.(el);
66
- // A thread's request is answered by a later thread line, so only a pending one changes the eye.
67
- const req = msgs.filter((m) => m.kind === 'prototype-request').pop();
68
- const proto = req && (req.state === 'waiting' || req.state === 'working') ? { pending: req.eventId } : null;
69
- const pb = el.querySelector('form [data-proto]');
70
- if (pb && pb.dataset.protoPending !== proto?.pending) pb.outerHTML = protoButton(key, proto);
75
+ }
76
+
77
+ // docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
78
+ // The composer's eye asks for a prototype and its fire for a grill: each is a toggle, one at a time, that Send carries
79
+ // with whatever is typed, as /prototype or /grill in the text does. Nothing typed sends the bare request.
80
+ const PLACEHOLDER = 'Reply in this thread… (/prototype or /grill asks for one)';
81
+ const HINT = 'Tap the eye or the fire to request a prototype or a grill';
82
+ const flagOf = (f) => f.dataset.flag || slashOf(f.querySelector('textarea').value).flag;
83
+ function syncFlag(f) {
84
+ const chosen = f.dataset.flag || null;
85
+ f.querySelector('button[data-proto]')?.setAttribute('aria-pressed', String(chosen === 'prototype'));
86
+ f.querySelector('button[data-grill]')?.setAttribute('aria-pressed', String(chosen === 'grill'));
87
+ const hint = f.querySelector('[data-hint]');
88
+ if (!hint) return;
89
+ const flag = flagOf(f);
90
+ const typed = slashOf(f.querySelector('textarea').value).text || BoardAttach.files(f.dataset.composer, B.site.root).length;
91
+ hint.classList.toggle('on', Boolean(flag));
92
+ hint.textContent = !flag ? HINT : typed ? `posts your message, with ${flag} requested in its footer` : `posts one gray line: ${flag} requested`;
71
93
  }
72
94
 
73
95
  // A load while one for the same thread runs shares it, so a card the page inserts open isn't fetched twice; force
@@ -99,7 +121,9 @@ BoardView.part((B) => {
99
121
 
100
122
  B.on(B.root, 'input', (ev) => {
101
123
  const f = ev.target.closest('form[data-reply]');
102
- if (!f || ev.target.tagName !== 'TEXTAREA' || quieted('draft:' + f.dataset.reply, ev.target)) return;
124
+ if (!f || ev.target.tagName !== 'TEXTAREA') return;
125
+ syncFlag(f);
126
+ if (quieted('draft:' + f.dataset.reply, ev.target)) return;
103
127
  store.set('draft:' + f.dataset.reply, ev.target.value);
104
128
  });
105
129
  B.on(B.root, 'click', async (ev) => {
@@ -109,28 +133,13 @@ BoardView.part((B) => {
109
133
  try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick }))); loadThread(key, { force: true }); }
110
134
  catch { toast('Could not send'); }
111
135
  });
112
- B.on(B.root, 'click', async (ev) => {
113
- const b = ev.target.closest('form[data-reply] button[data-proto]');
136
+ B.on(B.root, 'click', (ev) => {
137
+ const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill]');
114
138
  if (!b) return;
115
- const key = b.dataset.proto;
116
- const pending = b.dataset.protoPending;
117
- if (pending) {
118
- let latest = null;
119
- try { latest = (await B.site.events({ kind: 'ask-bump', limit: 1 }))[0]?.data?.id ?? null; } catch {}
120
- const n = protoNotice(pending, latest);
121
- if (!n.bump) { B.notice(n.text); return; }
122
- if (!(await B.confirm(n.text))) return;
123
- await B.bumpAsk(pending, b);
124
- return;
125
- }
126
- if (!(await B.confirm('Ask Claude for prototypes of this item?'))) return;
127
- try {
128
- await waitOn(b, B.sentHere(key, () => B.send('prototype-request', { key })));
129
- b.disabled = true;
130
- b.title = b.ariaLabel = 'Prototypes asked for';
131
- toast('Asked Claude for prototypes');
132
- loadThread(key, { force: true });
133
- } catch { toast('Could not send'); }
139
+ const f = b.closest('form');
140
+ const kind = b.hasAttribute('data-proto') ? 'prototype' : 'grill';
141
+ if (f.dataset.flag === kind) delete f.dataset.flag; else f.dataset.flag = kind;
142
+ syncFlag(f);
134
143
  });
135
144
  B.on(B.root, 'submit', async (ev) => {
136
145
  const f = ev.target.closest('form[data-reply]');
@@ -138,15 +147,19 @@ BoardView.part((B) => {
138
147
  ev.preventDefault();
139
148
  const ta = f.querySelector('textarea');
140
149
  ta.blur();
141
- const text = ta.value.trim();
150
+ const flag = flagOf(f);
151
+ const text = flag ? slashOf(ta.value).text : ta.value.trim();
142
152
  const id = f.dataset.composer;
143
153
  const files = BoardAttach.files(id, B.site.root);
144
- if ((!text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
154
+ if ((!flag && !text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
145
155
  const key = f.dataset.reply;
156
+ const data = { key, ...(text || !flag ? { text } : {}), ...(files.length ? { files } : {}) };
146
157
  try {
147
158
  await waitOn(f.querySelector('button[type=submit]'), sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
148
- () => B.sentHere(key, () => B.send('item-message', { key, text, ...(files.length ? { files } : {}) }))));
159
+ () => B.sentHere(key, () => B.send(REQUEST_EVENT[flag] || 'item-message', data))));
149
160
  BoardAttach.clear(id, B.site.root);
161
+ delete f.dataset.flag;
162
+ syncFlag(f);
150
163
  loadThread(f.dataset.reply, { force: true });
151
164
  } catch { B.notice('Could not send. Is the host awake?'); }
152
165
  });
@@ -154,6 +167,7 @@ BoardView.part((B) => {
154
167
  B.on(B.root, 'attach-change', (ev) => {
155
168
  const b = ev.target.querySelector('button[type=submit]');
156
169
  if (b) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
170
+ if (ev.target.matches?.('form[data-reply]')) syncFlag(ev.target);
157
171
  });
158
172
 
159
173
  Object.assign(B, {
@@ -64,6 +64,7 @@ window.BoardBoot = (function () {
64
64
  <script src="/_hub/app/linear-key.js"></script>
65
65
  <script src="/_hub/app/sidebar.js"></script>
66
66
  <script src="/_hub/app/app.js"></script>
67
+ <script src="/_hub/app/new-task.js"></script>
67
68
  <script src="/_hub/app/app-core.js"></script>
68
69
  <script src="/_hub/app/app-threads.js"></script>
69
70
  <script src="/_hub/app/app-cards.js"></script>
@@ -72,6 +73,7 @@ window.BoardBoot = (function () {
72
73
  <script src="/_hub/app/app-needs.js"></script>
73
74
  <script src="/_hub/app/app-header.js"></script>
74
75
  <script src="/_hub/app/app-arch.js"></script>
76
+ <script src="/_hub/app/app-reader.js"></script>
75
77
  <script src="/_hub/app/app-banner.js"></script>
76
78
  <script src="/_hub/app/app-snapshot.js"></script>
77
79
  <script src="/_hub/app/view-settings.js"></script>
@@ -86,7 +86,26 @@ window.BoardView = (function () {
86
86
  }
87
87
  toasts.toast(text);
88
88
  };
89
- const B = { root, site, route, toast, $, $$, on, every, later, sleep, watch, atUnmount, alive: () => alive, own: (off) => (alive ? own(off) : off()) };
89
+ const store = storeFor(route);
90
+ // iOS commits a pending predictive-text word after Send clears the box, which would refill it and resave the draft.
91
+ // So Send blurs first, and input is dropped while the send runs and for a moment after it succeeds.
92
+ const quiet = new Map();
93
+ function quieted(draftKey, ta) {
94
+ const until = quiet.get(draftKey) || 0;
95
+ if (until <= Date.now()) return false;
96
+ if (until !== Infinity) ta.value = '';
97
+ return true;
98
+ }
99
+ async function sendDraft(draftKey, ta, current, send) {
100
+ ta.blur();
101
+ quiet.set(draftKey, Infinity);
102
+ try { await send(); } catch (e) { quiet.delete(draftKey); store.set(draftKey, (current() || ta).value); throw e; }
103
+ store.set(draftKey, '');
104
+ const now = current();
105
+ if (now) now.value = '';
106
+ quiet.set(draftKey, Date.now() + 1000);
107
+ }
108
+ const B = { root, site, route, store, quieted, sendDraft, toast, $, $$, on, every, later, sleep, watch, atUnmount, alive: () => alive, own: (off) => (alive ? own(off) : off()) };
90
109
  return {
91
110
  B,
92
111
  start() { alive = true; },
@@ -100,7 +119,7 @@ window.BoardView = (function () {
100
119
 
101
120
  function create(root, siteRoot, opts = {}) {
102
121
  const { B, start: begin, stop } = base(root, siteRoot);
103
- const store = storeFor(B.route);
122
+ const { store } = B;
104
123
  // board is the last /api/board; githubPending the time of a refresh's unanswered check-github request; fresh the
105
124
  // header's time, ui-header.js nextFresh's {at, sig}; edits each open edit box by its message's event id,
106
125
  // {thread, data, text}, data being what ask-edited sends besides the text.
@@ -158,6 +177,7 @@ window.BoardView = (function () {
158
177
  // Renders open threads from state.threadCache; loadBoard and opening a card load them.
159
178
  function render(load = null) {
160
179
  const d = state.board;
180
+ BoardUI.pending.settle(d);
161
181
  const title = $(':scope > .top h1');
162
182
  if (d.title && title && title.textContent !== d.title) title.textContent = d.title;
163
183
  if (d.title && document.title !== d.title) document.title = d.title;
@@ -360,20 +380,7 @@ window.BoardView = (function () {
360
380
  <div id="tryMain"></div>
361
381
  <div id="lanes"></div>
362
382
  </main>
363
- <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>
364
- <dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
365
- <form id="askForm" data-composer="ask">
366
- <div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
367
- ${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
368
- <div class="pending-files" data-files></div>
369
- <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>
370
- <p class="muted small ask-choice-help" id="askChoiceHelp" aria-live="polite"></p>
371
- <div class="row">
372
- <span class="muted small" id="askStatus"></span>
373
- <button type="submit" class="primary">Send</button>
374
- </div>
375
- </form>
376
- </dialog>
383
+ ${BoardNewTask.markup()}
377
384
  <dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
378
385
  <div class="sheet-body">
379
386
  <div class="sheet-head"><h2 id="mainLogTitle"></h2><button type="button" class="ghost" id="mainLogClose" aria-label="Close">✕</button></div>
@@ -396,6 +403,11 @@ window.BoardView = (function () {
396
403
  <div id="archBody" class="arch-body"></div>
397
404
  </div>
398
405
  </dialog>
406
+ <dialog id="readerSheet" class="sheet reader-sheet" aria-labelledby="readerTitle">
407
+ <div class="reader-head"><h2 id="readerTitle"></h2><span class="reader-ref"></span><span class="reader-tools"></span><button type="button" class="ghost" data-reader="close" aria-label="Close">✕</button></div>
408
+ <div class="reader-body" tabindex="0"></div>
409
+ <button type="button" class="reader-new" data-reader="end" hidden></button>
410
+ </dialog>
399
411
  <dialog id="linksSheet" class="sheet" aria-labelledby="linksTitle">
400
412
  <div class="sheet-body">
401
413
  <div class="sheet-head"><h2 id="linksTitle">Hub links</h2><button type="button" class="ghost" id="linksClose" aria-label="Close">✕</button></div>
@@ -87,6 +87,7 @@
87
87
  .hub-view .remembered { padding:0 12px 10px; color:var(--muted); font-size:12px; }
88
88
  .hub-view .remembered p { margin:0; }
89
89
  .hub-view .remembered ul { margin:4px 0 0; padding-left:18px; }
90
+ .hub-view :is(.hub-modal, .sheet) code { white-space: pre-wrap; overflow-wrap: anywhere; }
90
91
  .hub-view .remembered code { white-space:pre-wrap; overflow-wrap:anywhere; }
91
92
 
92
93
  @media (max-width: 899px) {
@@ -0,0 +1,121 @@
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.
5
+ globalThis.BoardNewTask = (function () {
6
+ 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
+ <dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
8
+ <form id="askForm" data-composer="ask">
9
+ <div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
10
+ ${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
11
+ <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>
18
+ </form>
19
+ </dialog>`;
20
+
21
+ function wire(B) {
22
+ const { $, store, toast, quieted, sendDraft } = B;
23
+ const { attachButton, waitOn } = BoardUI;
24
+ const send = (kind, data) => (B.send ? B.send(kind, data) : B.site.send(kind, data));
25
+ // The ask box is a bottom sheet: a <dialog> opened from the + button. Closing keeps the draft.
26
+ const sheet = $('#sheet');
27
+ const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
28
+ if (!$('#askForm .row .attach')) $('#askForm .row').insertAdjacentHTML('afterbegin', attachButton());
29
+ // Files picked before the app shell switched away from this board are still attached, so they show again.
30
+ 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.
34
+ B.on($('#askText'), 'keydown', (ev) => {
35
+ if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.isComposing) return;
36
+ ev.preventDefault();
37
+ $('#askForm').requestSubmit();
38
+ });
39
+ $('#askText').value = store.get('draft:ask', '');
40
+ B.on($('#askText'), 'input', (e) => { if (!quieted('draft:ask', e.target)) store.set('draft:ask', e.target.value); });
41
+ let stopFitting = null;
42
+ const stopFit = () => { stopFitting?.(); stopFitting = null; };
43
+ B.on(sheet, 'close', stopFit);
44
+ B.atUnmount(stopFit);
45
+ function openSheet() {
46
+ if (sheet.open) return;
47
+ stopFit();
48
+ stopFitting = BoardDialogs.fitSheet(sheet);
49
+ sheet.classList.remove('closing');
50
+ sheet.showModal();
51
+ $('#askText').focus();
52
+ $('#compose').setAttribute('aria-expanded', 'true');
53
+ }
54
+ function closeSheet() {
55
+ if (!sheet.open || sheet.classList.contains('closing')) return;
56
+ $('#compose').setAttribute('aria-expanded', 'false');
57
+ if (reduceMotion.matches) { sheet.close(); return; }
58
+ sheet.classList.add('closing');
59
+ B.later(180, () => { sheet.classList.remove('closing'); sheet.close(); });
60
+ }
61
+ B.on($('#compose'), 'click', openSheet);
62
+ // n pressed twice opens the sheet, as the + button's tooltip says, unless a field or another dialog has focus. With
63
+ // nothing focused the key goes to <body>, outside the view, so this listens on the document, and a framed page's
64
+ // own document is given to the same handler (view-frame.js). A key switch mode used (sidebar.js) comes
65
+ // defaultPrevented and doesn't count.
66
+ const TWICE_MS = 500;
67
+ let lastN = -Infinity;
68
+ const newTaskKey = (ev) => {
69
+ if (ev.defaultPrevented) { lastN = -Infinity; return; }
70
+ if (ev.key !== 'n' || ev.metaKey || ev.ctrlKey || ev.altKey || ev.repeat || ev.isComposing) return;
71
+ 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; }
73
+ if (ev.timeStamp - lastN > TWICE_MS) { lastN = ev.timeStamp; return; }
74
+ lastN = -Infinity;
75
+ ev.preventDefault();
76
+ openSheet();
77
+ };
78
+ B.newTaskKey = newTaskKey;
79
+ B.on(document, 'keydown', newTaskKey);
80
+ B.on($('#sheetClose'), 'click', closeSheet);
81
+ B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
82
+ 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
+ });
102
+ B.on($('#askForm'), 'submit', async (ev) => {
103
+ ev.preventDefault();
104
+ $('#askText').blur();
105
+ const text = $('#askText').value.trim();
106
+ const files = BoardAttach.files('ask', B.site.root);
107
+ if ((!text && !files.length) || BoardAttach.busy('ask', B.site.root)) return;
108
+ $('#askStatus').textContent = '';
109
+ try {
110
+ await waitOn($('#askForm button[type="submit"]'), sendDraft('draft:ask', $('#askText'), () => $('#askText'), () => send('ask', { text, ...(files.length ? { files } : {}), ...(quick ? { quick: true } : {}) })));
111
+ BoardAttach.clear('ask', B.site.root);
112
+ choose(false);
113
+ $('#askStatus').textContent = '';
114
+ closeSheet();
115
+ toast('Sent');
116
+ } catch { $('#askStatus').textContent = 'Could not send'; }
117
+ });
118
+ }
119
+
120
+ return { markup, wire };
121
+ })();
@@ -200,7 +200,11 @@ window.AppShell = (function () {
200
200
  if (boards().has(route)) { try { localStorage.setItem(LAST_KEY, route); } catch { /* no storage: `/` opens the start board */ } }
201
201
  sidebar.setHere(hub ? new URL(AppRouter.urlOf(view), location.href).pathname.slice(1) : route);
202
202
  scrollTo(0, 0);
203
+ // Every other page of a board has the board's + and its sheet too; the hub's own pages aren't a board's.
204
+ const plus = !board && boards().has(route);
205
+ if (plus) root.insertAdjacentHTML('beforeend', BoardNewTask.markup());
203
206
  v.mount();
207
+ if (plus) BoardNewTask.wire(v.B);
204
208
  if (modal) showSettings();
205
209
  }
206
210
 
@@ -84,6 +84,7 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
84
84
  #failedStartSheet { inset: 0; margin: auto; height: fit-content; max-height: calc(100dvh - 48px); }
85
85
  }
86
86
  .sidebar .sb-row .icon.sb-plus { color: var(--muted); background: var(--chip); }
87
+ .sidebar .sb-row .icon.sb-start-icon { color: var(--accent); }
87
88
 
88
89
  #newBoardSheet .nb-form { min-width: 0; }
89
90
  #newBoardSheet .sheet-head { margin-bottom: 8px; }
@@ -257,6 +257,10 @@ main { padding-bottom: calc(110px + env(safe-area-inset-bottom)); }
257
257
  .sheet::backdrop { background: rgba(0,0,0,.35); touch-action: none; }
258
258
  /* A phone sheet sits above the bottom bar, which already pads the safe area (--app-bottom). */
259
259
  .sheet form { padding: 10px 16px max(12px, calc(env(safe-area-inset-bottom) - var(--app-bottom, 0px))); }
260
+ /* Modals never scroll sideways: content wraps at the modal's width, and overflow-x hidden is only the last guard. */
261
+ .sheet, .settings-modal { overflow-x: hidden; overflow-wrap: anywhere; }
262
+ .settings-body { overflow-x: hidden; overflow-wrap: anywhere; }
263
+ .sheet input, .sheet select, .sheet textarea, .settings-modal input, .settings-modal select, .settings-modal textarea { max-width: 100%; min-width: 0; }
260
264
  /* A sheet's last row of buttons stays in view at the foot of its scrolling content. Sticking stops at the scroll
261
265
  area's bottom padding, so the shadow covers what scrolls through that padding. */
262
266
  .sheet-actions { position: sticky; bottom: 0; z-index: 1; padding-top: 8px; background: var(--panel); box-shadow: 0 40px 0 var(--panel); }
@@ -343,7 +347,11 @@ button.attach:disabled { opacity: .35; }
343
347
  @keyframes pulse-fill { 50% { background-color: color-mix(in srgb, var(--color-notice) 35%, transparent); } }
344
348
  @media (prefers-reduced-motion: no-preference) { .phase-bars:not(.dim) i.now { animation: pulse-fill 1.4s ease-in-out infinite; } }
345
349
  /* A stale build's status line, dot included, is the needs-you red, and a tap on it nudges Claude (ui-cards.js builderStatus). */
346
- button.sub.agent.stalled { display: block; width: 100%; padding: 0; border: 0; border-radius: 0; background: none; text-align: left; color: var(--color-danger); }
350
+ /* A status line that is a button is drawn as the div it replaces: this one reset, then only a colour per state. The font
351
+ shorthand stays out so .sub's 12px wins. */
352
+ button.sub { display: block; width: 100%; margin-inline: 0; margin-bottom: 0; padding: 0; min-height: 0; border: 0; border-radius: 0; background: none;
353
+ font-family: inherit; font-weight: inherit; letter-spacing: inherit; line-height: inherit; text-align: left; appearance: none; -webkit-appearance: none; }
354
+ button.sub.agent.stalled { color: var(--color-danger); }
347
355
  .stalled .working-dot { background: currentColor; }
348
356
  .brand { display: grid; grid-template-columns: auto auto minmax(0, 1fr); column-gap: 10px; row-gap: 2px; }
349
357
  .brand .hub { grid-row: 1 / span 2; align-self: center; }
@@ -361,10 +369,39 @@ button.sub.agent.stalled { display: block; width: 100%; padding: 0; border: 0; b
361
369
  (docs/adr/2026-11-03-1551-planners-and-implementers-are-two-kinds-of-agent.md). */
362
370
  .sub.agent.planner, .msg.builder-status.planner, .msg.agent.started.planner { color: var(--accent); }
363
371
  .planner .working-dot { background: currentColor; }
372
+ /* Every status line on an In Progress card is the phase-bar orange, a planner's too, and every build or plan line on a
373
+ Todo card the owner's blue, dot included; a Todo card's planner dashes are that blue, the running one pulsing
374
+ (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md). */
375
+ .sub.agent.in-progress { color: var(--color-notice); }
376
+ .sub.agent.in-todo { color: var(--accent); }
377
+ .sub.agent.in-progress .working-dot, .sub.agent.in-todo .working-dot { background: currentColor; }
378
+ .phase-bars.plan { --bar-fill: var(--accent); }
379
+ @keyframes pulse-fill-plan { 50% { background-color: color-mix(in srgb, var(--accent) 35%, transparent); } }
380
+ @media (prefers-reduced-motion: no-preference) { .phase-bars.plan:not(.dim) i.now { animation-name: pulse-fill-plan; } }
364
381
  .msg.calls { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
365
- .msg.calls summary { cursor: pointer; color: var(--muted); }
366
- .msg.calls ol { margin: 4px 0; padding-left: 20px; }
382
+ .msg.calls summary { cursor: pointer; color: var(--muted); list-style: none; }
383
+ .msg.calls summary::-webkit-details-marker { display: none; }
384
+ .msg.calls[open] summary .mark-icon { transform: rotate(90deg); }
385
+ .msg.calls ol { margin: 4px 0; padding-left: 23px; }
367
386
  .agent-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: currentColor; margin-right: 5px; vertical-align: 1px; }
387
+ /* Every thread status line's leading mark, icon or dot, sits centred in one 18px box, so the text after it always starts
388
+ at the same place; the box is 12px tall so line heights stay
389
+ (docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md). */
390
+ .mark { display: inline-block; position: relative; width: 18px; height: 12px; margin-right: 5px; vertical-align: -1px; flex: none; }
391
+ .mark > * { position: absolute; inset: 0; margin: auto; }
392
+ .mark .working-dot, .mark .agent-dot { margin: auto; vertical-align: 0; }
393
+ .mark-icon { width: 12px; height: 12px; }
394
+ /* New questions' plain question mark is a size up from its siblings. */
395
+ .mark-icon.big { width: 18px; height: 18px; }
396
+ .msg.new-questions a { color: inherit; }
397
+ /* A message's footer: the tap it came from, then Claude's pick-up to its right. */
398
+ .msg .reply { display: flex; flex-wrap: wrap; align-items: center; column-gap: 12px; row-gap: 2px; }
399
+ .msg-foot .foot-status { display: inline-flex; align-items: center; }
400
+ /* The thread's eye and fire are toggles: pressed reads as chosen, and the hint under the box says what Send posts. */
401
+ form[data-reply] .tools { display: inline-flex; flex: none; }
402
+ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chip); }
403
+ .flag-hint { margin: 4px 2px 0; min-height: 1.2em; color: var(--muted); font-size: 12px; }
404
+ .flag-hint.on { color: var(--accent); }
368
405
  /* INT-30: card line order */
369
406
  .line6 { margin-top: 4px; }
370
407
  .card-time time { font-variant-numeric: tabular-nums; }
@@ -527,6 +564,9 @@ main > * { transition: opacity .25s ease; }
527
564
  .view-questions .opt .lbl { font-weight: 600; }
528
565
  .view-questions .opt .rec { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
529
566
  .view-questions .opt .det { color: var(--muted); font-size: 13px; }
567
+ .view-questions .opt .suggested { margin-left: 6px; white-space: nowrap; }
568
+ .view-questions .opt .suggested-why { margin-top: 4px; color: var(--muted); font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; }
569
+ .view-questions .opt .suggested-why a { color: var(--accent); }
530
570
  .view-questions .note { margin-top: 4px; }
531
571
  .view-questions .ans { background: var(--accent-soft); border-radius: 9px; padding: 7px 10px; margin-top: 8px; font-size: 13px; }
532
572
  .view-questions .rep { background: var(--claude); border-radius: 9px; padding: 7px 10px; margin-top: 6px; font-size: 13px; }
@@ -543,9 +583,24 @@ main > * { transition: opacity .25s ease; }
543
583
  .view-questions details.done button.ghost { font-size: 14px; text-align: left; padding: 6px 2px; }
544
584
  .view-questions details.done .trow button { min-width: 0; }
545
585
  .view-questions details.done .trow span { flex: none; }
586
+ .view-questions details.resolved-group > summary { list-style: none; min-height: 44px; display: flex; align-items: center; gap: 8px; font-size: 14px; margin: 12px 0; }
587
+ .view-questions details.resolved-group > summary::-webkit-details-marker { display: none; }
588
+ .view-questions details.resolved-group > summary .caret { transition: transform .15s; }
589
+ .view-questions details.resolved-group[open] > summary .caret { transform: rotate(90deg); }
590
+ .view-questions details.resolved-group > summary .names { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
591
+ .view-questions .q.resolved button.chip { cursor: pointer; font: inherit; font-size: 12px; min-height: 28px; }
592
+ .view-questions .q.resolved button.chip:disabled { cursor: default; }
593
+ .view-questions .q.resolved .resolved-line { margin: 8px 0; }
594
+ .view-questions .q.resolved .opt { cursor: default; }
595
+ .view-questions .q.resolved .opt .det { display: none; }
596
+ .view-questions .q.resolved .opt:has(input:checked) .det { display: block; }
597
+ .view-questions .q.resolved .opt:not(:has(input:checked)) { opacity: .6; }
598
+ .view-questions .q.resolved .reask-line { margin-top: 10px; }
546
599
  .view-questions .bar { position: sticky; bottom: var(--app-bottom, 0px); padding: 10px 0 max(10px, env(safe-area-inset-bottom)); background: var(--bg);
547
600
  display: flex; justify-content: space-between; align-items: center; gap: 8px; border-top: 1px solid var(--line); }
548
601
  .view-questions .bar .send-hint { margin-left: auto; }
602
+ /* The + rides above the sticky Send bar rather than over its Send. */
603
+ .view-questions:has(.bar) .fab { bottom: calc(var(--app-bottom, 0px) + var(--fab-edge) + 72px); }
549
604
  .view-questions .bar #qstatus { min-width: 0; }
550
605
  .view-questions .bar #qstatus.sent { color: var(--color-success); font-weight: 600; }
551
606
  .view-questions .solo { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 4px 0; }
@@ -735,6 +790,39 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
735
790
  .main-log-actions { display: flex; justify-content: flex-end; margin-top: 12px; }
736
791
  .main-log-actions button { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; padding: 6px 18px; }
737
792
  #mainLogReason { color: var(--color-notice); overflow-wrap: anywhere; }
793
+ /* The reader sheet (app-reader.js): a builder's log or notes, 85% of the height from the bottom. Its body scrolls
794
+ down only: every line wraps (#252). */
795
+ .reader-sheet { height: 85dvh; max-height: 85dvh; padding: 0; overflow: hidden; }
796
+ .reader-sheet[open] { display: flex; flex-direction: column; }
797
+ .reader-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 14px; border-bottom: 1px solid var(--line); }
798
+ .reader-head h2 { flex: none; margin: 0; font-size: 15px; }
799
+ .reader-ref { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
800
+ .reader-tools { flex: none; display: inline-flex; align-items: center; gap: 6px; }
801
+ .reader-tools:empty { display: none; }
802
+ .reader-tools button { height: 32px; padding: 0 12px; border-radius: 999px; font-size: 13px; }
803
+ .reader-tools button[aria-pressed="true"] { color: var(--accent); background: var(--chip); }
804
+ .reader-status { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--muted); }
805
+ .reader-status::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
806
+ .reader-status.on { color: var(--accent); }
807
+ .reader-status.on::before { background: var(--accent); animation: reader-pulse 1.4s ease-in-out infinite; }
808
+ @keyframes reader-pulse { 50% { opacity: .35; } }
809
+ .reader-body { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 6px 0 max(12px, env(safe-area-inset-bottom)); }
810
+ .reader-body > .muted { padding: 8px 14px; margin: 0; }
811
+ .reader-row { padding: 5px 14px; font-size: 13.5px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; }
812
+ .reader-row.text { padding-top: 9px; }
813
+ .reader-row.end { margin: 6px 14px 0; padding: 9px 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--chip); }
814
+ .reader-row.sys, .reader-row.out { color: var(--muted); font-size: 12.5px; }
815
+ .reader-row.out { padding-left: 28px; font: 11.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
816
+ .reader-row.tool { font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
817
+ .reader-tag { display: inline-block; margin-right: 6px; padding: 0 6px; border-radius: 6px; background: var(--chip); color: var(--accent); font-size: 11px; }
818
+ .reader-raw, .reader-text { margin: 0; padding: 2px 14px 8px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
819
+ .reader-md { padding: 4px 16px 12px; overflow-wrap: anywhere; line-height: 1.5; }
820
+ .reader-md h1 { font-size: 17px; margin: 10px 0 6px; }
821
+ .reader-md h2, .reader-md h3 { font-size: 15px; }
822
+ .reader-md pre, .reader-md code { white-space: pre-wrap; overflow-wrap: anywhere; }
823
+ .reader-md table { display: block; max-width: 100%; overflow-x: hidden; }
824
+ .reader-new { position: absolute; left: 50%; bottom: max(14px, env(safe-area-inset-bottom)); transform: translateX(-50%); border: 0; border-radius: 999px; padding: 7px 14px; background: var(--accent); color: var(--on-accent); font-size: 13px; box-shadow: 0 4px 14px rgba(0,0,0,.25); }
825
+ .reader-new[hidden] { display: none; }
738
826
 
739
827
  /* The Auto Review lane (ui-cards.js reviewLane): its cards read like the board's, and Posted earlier's lines like a fold's. */
740
828
  .rv-card, .rv-line { border-top: 1px solid var(--line); }