@lovek4d/kickflip 0.7.0 → 0.7.2

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 (68) hide show
  1. package/CONTEXT.md +53 -49
  2. package/SKILL.md +17 -13
  3. package/bin/event-lines.mjs +1 -1
  4. package/bin/kickflip.mjs +3 -3
  5. package/board/agents.mjs +2 -2
  6. package/board/arch-review.mjs +4 -4
  7. package/board/bin/board.mjs +113 -105
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -4
  10. package/board/headless.mjs +8 -8
  11. package/board/hub-lines.mjs +1 -0
  12. package/board/onboarding.mjs +3 -3
  13. package/board/public/app-arch.js +1 -1
  14. package/board/public/app-cards.js +6 -7
  15. package/board/public/app-header.js +1 -1
  16. package/board/public/app-lanes.js +39 -62
  17. package/board/public/app-needs.js +16 -11
  18. package/board/public/app-reader.js +9 -5
  19. package/board/public/app.js +3 -3
  20. package/board/public/attach.js +18 -2
  21. package/board/public/dialogs.js +11 -13
  22. package/board/public/hub-kit.js +1 -1
  23. package/board/public/hub.css +0 -1
  24. package/board/public/linear-key.js +2 -2
  25. package/board/public/new-task.js +1 -1
  26. package/board/public/prototype-kit.js +2 -5
  27. package/board/public/router.js +1 -1
  28. package/board/public/sidebar.css +3 -3
  29. package/board/public/sidebar.js +2 -2
  30. package/board/public/style.css +43 -43
  31. package/board/public/ui-cards.js +25 -20
  32. package/board/public/ui-hub.js +1 -5
  33. package/board/public/ui-links.js +5 -5
  34. package/board/public/ui-order.js +6 -7
  35. package/board/public/ui-questions.js +17 -13
  36. package/board/public/ui-sidebar.js +1 -1
  37. package/board/public/ui-threads.js +29 -21
  38. package/board/public/ui.js +23 -6
  39. package/board/public/view-hub-settings.js +11 -19
  40. package/board/public/view-hub.js +25 -11
  41. package/board/public/view-notes.js +1 -1
  42. package/board/public/view-questions.js +18 -5
  43. package/board/public/view-settings.js +7 -7
  44. package/board/render-check.mjs +10 -10
  45. package/board/review.mjs +1 -1
  46. package/board/server.mjs +18 -19
  47. package/board/stop.mjs +1 -1
  48. package/board/store.mjs +26 -26
  49. package/board/tickets.mjs +3 -3
  50. package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
  51. package/hub/awake.mjs +1 -1
  52. package/hub/board-repo.mjs +1 -1
  53. package/hub/boards.mjs +4 -4
  54. package/hub/github.mjs +3 -3
  55. package/hub/hub.mjs +11 -11
  56. package/hub/incoming.mjs +2 -2
  57. package/hub/linear.mjs +1 -1
  58. package/hub/locks.mjs +1 -1
  59. package/hub/new-board.mjs +2 -2
  60. package/hub/owner-checks.mjs +1 -1
  61. package/hub/readiness.mjs +1 -1
  62. package/hub/review-send.mjs +1 -1
  63. package/hub/slack.mjs +1 -1
  64. package/hub/try.mjs +6 -7
  65. package/hub/views.mjs +1 -1
  66. package/package.json +1 -1
  67. package/shared/usage-gate.mjs +1 -1
  68. package/starter/server.mjs +3 -3
@@ -1,5 +1,5 @@
1
1
  // The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "View transcript"
2
- // (ui-threads.js) and a card's or a lineup's Notes (app-cards.js, app-lanes.js) open over the board in one sheet in
2
+ // (ui-threads.js) and a card's or a wave's Notes (app-cards.js, app-lanes.js) open over the board in one sheet in
3
3
  // place of log.html and notes.html, which still open on their own. It closes on its ✕, Escape, a tap on the dim area
4
4
  // and Back, through a history entry of its own that carries the board's URL, so the router's pop keeps the board.
5
5
  // A link in it closes it first, then opens through the shell.
@@ -21,9 +21,12 @@ BoardView.part((B) => {
21
21
  let log = null;
22
22
  let stopReading = null;
23
23
 
24
- // A row of the transcript (transcript.mjs); a call with its output opens it on a tap of its line.
25
- function rowHtml(r, i) {
24
+ // A row of the transcript (transcript.mjs); a call with its output opens it on a tap of its line. What Claude says,
25
+ // its text rows and its result, is markdown in compact styles, linking the board's refs
26
+ // (docs/adr/2026-11-09-1453-orchestrator-messages-replies-and-transcript-text-are-markdown.md).
27
+ function rowHtml(r, i, links) {
26
28
  const tag = r.label ? `<span class="reader-tag">${esc(r.label)}</span>` : '';
29
+ if (r.kind.split(' ')[0] === 'text') return `<div class="reader-row ${r.kind} md">${tag}${markdown(r.body, links)}</div>`;
27
30
  if (r.out === undefined) return `<div class="reader-row ${r.kind}">${tag}${esc(r.body)}</div>`;
28
31
  const on = log.opened.has(i);
29
32
  return `<div class="reader-row tool call" data-call="${i}"><span class="reader-caret">${on ? '▾' : '▸'}</span>${tag}${esc(r.body)}</div>${on ? `<div class="reader-out">${esc(r.out)}</div>` : ''}`;
@@ -42,10 +45,11 @@ BoardView.part((B) => {
42
45
  }
43
46
  function drawLog() {
44
47
  const { rows, stream, raw, text } = log;
48
+ const links = linkContext(state.board?.items || [], null);
45
49
  body.innerHTML = !rows.length ? '<p class="muted reader-empty">The log is empty so far.</p>'
46
50
  : !stream ? `<pre class="reader-text">${esc(rows.map((r) => `${r.body}\n`).join(''))}</pre>`
47
51
  : raw ? (text === null ? '<p class="muted">Loading…</p>' : `<pre class="reader-raw">${esc(text)}</pre>`)
48
- : rows.map(rowHtml).join('');
52
+ : rows.map((r, i) => rowHtml(r, i, links)).join('');
49
53
  drawTools();
50
54
  drawMore();
51
55
  }
@@ -148,7 +152,7 @@ BoardView.part((B) => {
148
152
  else openNotes(href.slice(href.indexOf('?')));
149
153
  });
150
154
  B.on(sheet, 'click', (ev) => {
151
- if (ev.target === sheet) { close(); return; }
155
+ if (BoardUI.onBackdrop(ev, sheet)) { close(); return; }
152
156
  const act = ev.target.closest('[data-reader]')?.dataset.reader;
153
157
  if (act === 'close') close();
154
158
  else if (act === 'raw') { log.raw = !log.raw; log.text = null; drawLog(); follow(); if (log.raw) readRaw(opened); }
@@ -128,7 +128,7 @@ window.BoardView = (function () {
128
128
  board: null,
129
129
  fresh: null,
130
130
  openItems: new Set(store.get('openItems', [])),
131
- // The cards open when the page loaded: Done and Asks hide those past their cap until the owner opens one this visit.
131
+ // The cards open when the page loaded: Done hides those past their cap until the owner opens one this visit.
132
132
  restored: new Set(store.get('openItems', [])),
133
133
  threadCache: new Map(),
134
134
  edits: new Map(),
@@ -206,7 +206,7 @@ window.BoardView = (function () {
206
206
  }
207
207
 
208
208
  // #review=<PR number> opens that review in the Auto Review lane (app-lanes.js openReview), as a held review's push does.
209
- // #selectedCard=<wave>/<id> (or the older #item=) opens that card, and &openItemsCard=<topic id> opens that pill in the pane; push notifications link here, with &reply to land in the thread's reply box
209
+ // #selectedCard=<file>/<id> (or the older #item=) opens that card, and &openItemsCard=<topic id> opens that pill in the pane; push notifications link here, with &reply to land in the thread's reply box
210
210
  // (docs/adr/2026-10-29-1639-a-push-lands-where-the-owner-answers.md). An ask now in a work item's thread opens that item.
211
211
  let focusKey = null;
212
212
  let replyKey = null;
@@ -251,7 +251,7 @@ window.BoardView = (function () {
251
251
  if (!band && !state.board?.items.some((i) => i.key === key)) return false;
252
252
  if (key) { location.hash = AppRouter.hashWith(location.hash, { selectedCard: key }); readHash(); } else B.openBand({ slug });
253
253
  render();
254
- if (key) scrollToFocus(); else $(`section.wb[data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
254
+ if (key) scrollToFocus(); else $(`section.wb[data-lane="todo"][data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
255
255
  return true;
256
256
  }
257
257
  // A card missing from the last visit's board waits for the fresh one, which is final.
@@ -12,6 +12,7 @@
12
12
  // painted also gets a bubbling attach-change event with detail {id}
13
13
  window.BoardAttach = (function () {
14
14
  const LIMIT = 20 * 1024 * 1024;
15
+ const OVER = 'Over 20 MB';
15
16
  const state = new Map();
16
17
  const listeners = [];
17
18
  let seq = 0;
@@ -32,10 +33,17 @@ window.BoardAttach = (function () {
32
33
  const paintAll = () => { for (const c of document.querySelectorAll('[data-composer]')) paint(c.dataset.composer, siteOf(c)); };
33
34
 
34
35
  function upload(site, id, file) {
35
- const p = { id: 'f' + ++seq, name: file.name || 'file', type: file.type || 'application/octet-stream', status: 'uploading', progress: 0 };
36
+ const p = { id: 'f' + ++seq, name: file.name || 'file', type: file.type || 'application/octet-stream', status: 'uploading', progress: 0, source: file };
36
37
  if (/^image\//.test(p.type)) p.preview = URL.createObjectURL(file);
37
38
  state.set(keyOf(site, id), [...list(site, id), p]);
38
39
  if (file.size > LIMIT) { p.status = 'error'; p.error = 'Over 20 MB'; paint(id, site); return; }
40
+ send(site, id, p);
41
+ }
42
+
43
+ // Sends p's File and paints it uploading; a retry from an error chip runs it again.
44
+ function send(site, id, p) {
45
+ const file = p.source;
46
+ p.status = 'uploading'; p.progress = 0; delete p.error;
39
47
  paint(id, site);
40
48
  const xhr = new XMLHttpRequest();
41
49
  p.xhr = xhr;
@@ -54,6 +62,11 @@ window.BoardAttach = (function () {
54
62
  xhr.send(file);
55
63
  }
56
64
 
65
+ function retry(site, id, fileId) {
66
+ const p = list(site, id).find((x) => x.id === fileId);
67
+ if (p && p.status === 'error' && p.error !== OVER) send(site, id, p);
68
+ }
69
+
57
70
  function remove(site, id, fileId) {
58
71
  const p = list(site, id).find((x) => x.id === fileId);
59
72
  if (!p) return;
@@ -107,7 +120,10 @@ window.BoardAttach = (function () {
107
120
  document.addEventListener('click', (ev) => {
108
121
  const b = ev.target.closest && ev.target.closest('[data-unattach]');
109
122
  const c = b && b.closest('[data-composer]');
110
- if (c) { ev.preventDefault(); remove(siteOf(c), c.dataset.composer, b.dataset.unattach); }
123
+ if (c) { ev.preventDefault(); remove(siteOf(c), c.dataset.composer, b.dataset.unattach); return; }
124
+ const r = ev.target.closest && ev.target.closest('[data-retry]');
125
+ const rc = r && r.closest('[data-composer]');
126
+ if (rc) { ev.preventDefault(); retry(siteOf(rc), rc.dataset.composer, r.dataset.retry); }
111
127
  });
112
128
 
113
129
  return {
@@ -10,9 +10,9 @@
10
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).
11
11
  // With keyState(), Hub's /_hub/api/settings `linear`, as Board Settings passes it, the source row reads its live state
12
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 } = {}) {
13
+ function ticketsControl(root, { value = { source: 'none' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState } = {}) {
14
14
  const { esc } = BoardUI;
15
- const draft = { prefix: 'INT', ...value };
15
+ const draft = { ...value };
16
16
  let creating = false, createError = '', destroyed = false, asking = false, noGithub = false, key = null;
17
17
  const works = () => Boolean(linear?.set && !linear.refused);
18
18
  const keyWorks = () => Boolean(key?.set && !key.refused);
@@ -31,12 +31,12 @@
31
31
  const workspace = () => linear?.url_key ?? linear?.workspace ?? draft.workspace;
32
32
  const newName = () => (draft.newName ?? title()).trim();
33
33
  const ready = () => works() && !linear.error;
34
- const read = () => draft.source === 'internal' ? { source: 'internal', prefix: draft.prefix || 'INT' }
34
+ const read = () => draft.source === 'none' ? { source: 'none' }
35
35
  : draft.source === 'github' ? { source: 'github', ...(draft.account ? { account: draft.account } : {}) }
36
36
  : !ready() ? { source: 'linear', workspace: draft.workspace, team: draft.team, project: draft.project }
37
37
  : draft.project === '__new' && isNew ? { source: 'linear', workspace: workspace(), team: draft.team, new_project: newName() }
38
38
  : { source: 'linear', workspace: workspace(), team: draft.team, project: draft.project };
39
- const valid = () => draft.source === 'internal' ? /^[A-Z]{2,6}$/.test(draft.prefix || 'INT')
39
+ const valid = () => draft.source === 'none' ? true
40
40
  : draft.source === 'github' ? Boolean(repo())
41
41
  : Boolean(ready() && draft.team && draft.project && (draft.project !== '__new' || (isNew && newName())));
42
42
  const pickDefaults = () => {
@@ -58,17 +58,17 @@
58
58
  const team = esc(draft.team || '');
59
59
  const note = draft.project === '__new'
60
60
  ? (isNew ? `<p class="muted small">Start creates the project in the ${team} team on Linear.</p>` : `<p class="muted small">Creates the project in the ${team} team on Linear.</p>`)
61
- : `<p class="muted small">A new card gets a ${team} issue in ${esc(project?.name || draft.project || '')}, in the ${esc(linear.workspace || workspace())} workspace from Hub’s key. Its badge opens it in Linear.</p>`;
61
+ : `<p class="muted small">A task gets a ${team} issue in ${esc(project?.name || draft.project || '')}, in the ${esc(linear.workspace || workspace())} workspace from Hub’s key, when you answer yes to its ticket question. Its badge opens it in Linear.</p>`;
62
62
  return `<div class="tk-grid"><label class="tk-field"><span>Team</span><select data-tk="team">${(linear.teams || []).map((t) => `<option value="${esc(t.key)}"${t.key === draft.team ? ' selected' : ''}>${esc(t.key)} · ${esc(t.name)}</option>`).join('')}</select></label><label class="tk-field"><span>Project</span><select data-tk="project">${(linear.projects || []).filter((p) => p.team === draft.team).map((p) => `<option value="${esc(p.id)}"${p.id === draft.project ? ' selected' : ''}>${esc(p.name)}</option>`).join('')}<option value="__new"${draft.project === '__new' ? ' selected' : ''}>New project…</option></select></label></div>${draft.project === '__new' ? `<div class="tk-new"><input data-tk="newName" value="${esc(draft.newName ?? title())}" aria-label="New Linear project name" placeholder="Project name" maxlength="80" autocomplete="off">${isNew ? '' : `<button type="button" data-tk-create${creating ? BoardUI.BUSY : ''}>Create</button>`}</div>` : ''}${createError ? `<p class="muted small" role="alert">${esc(createError)}</p>` : ''}${note}`;
63
63
  }
64
64
  function render() {
65
65
  const off = !repo();
66
- const segment = `<div class="tk-seg" role="group" aria-label="Ticket source">${[['linear', 'Linear'], ['github', 'GitHub issues'], ['internal', 'Internal']].map(([source, name]) => `<button type="button" data-tk-source="${source}" aria-pressed="${draft.source === source}"${source === 'github' && off ? ' aria-disabled="true"' : ''}>${name}</button>`).join('')}</div>`;
66
+ const segment = `<div class="tk-seg" role="group" aria-label="Ticket source">${[['linear', 'Linear'], ['github', 'GitHub issues'], ['none', 'None']].map(([source, name]) => `<button type="button" data-tk-source="${source}" aria-pressed="${draft.source === source}"${source === 'github' && off ? ' aria-disabled="true"' : ''}>${name}</button>`).join('')}</div>`;
67
67
  const why = `<p class="muted small" role="status">GitHub issues need a repo on GitHub, and ${isNew ? 'the repo chosen above' : 'this board’s repo'} isn’t on GitHub.</p>`;
68
68
  let detail;
69
69
  if (draft.source === 'github' && off) detail = why;
70
- else if (draft.source === 'internal') detail = `<label class="tk-field"><span>Prefix</span><input data-tk="prefix" value="${esc(draft.prefix)}" placeholder="INT" maxlength="6" pattern="[A-Z]{2,6}" title="2–6 uppercase letters" autocapitalize="characters" autocomplete="off" spellcheck="false"></label><p class="muted small" data-tk-example>Cards number ${esc(draft.prefix || 'INT')}-1, ${esc(draft.prefix || 'INT')}-2… on this board. The badge opens the card.</p>`;
71
- else if (draft.source === 'github') detail = `<div class="who tk-static"><span>Issues in ${esc(repo())}<span class="muted small sub">${isNew ? 'The repo chosen above' : 'The board’s repo, set above'}. A new card opens an issue there${draft.account ? ` as gh account ${esc(draft.account)}` : ''}; its badge reads #41.</span></span></div>`;
70
+ else if (draft.source === 'none') detail = '<p class="muted small">Every card is a task, numbered T-1, T-2… on this board, with no ticket. Its badge reads Task.</p>';
71
+ else if (draft.source === 'github') detail = `<div class="who tk-static"><span>Issues in ${esc(repo())}<span class="muted small sub">${isNew ? 'The repo chosen above' : 'The board’s repo, set above'}. A task opens an issue there${draft.account ? ` as gh account ${esc(draft.account)}` : ''} when you answer yes to its ticket question; its badge then reads #41.</span></span></div>`;
72
72
  else detail = linearDetail();
73
73
  root.innerHTML = `${status()}${segment}<div class="tk-detail">${noGithub && off && draft.source !== 'github' ? why : ''}${detail}</div>`;
74
74
  if (draft.source === 'linear' && ready()) {
@@ -126,11 +126,9 @@
126
126
  const input = (e) => {
127
127
  const field = e.target.dataset.tk;
128
128
  if (!field || (e.type === 'input' && e.target.tagName === 'SELECT')) return;
129
- draft[field] = field === 'prefix' ? e.target.value.toUpperCase() : e.target.value;
130
- if (field === 'prefix') e.target.value = draft.prefix;
129
+ draft[field] = e.target.value;
131
130
  if (field === 'team') draft.project = isNew ? '__new' : linear.projects?.find((p) => p.team === draft.team)?.id;
132
131
  if (field === 'team' || field === 'project') { createError = ''; render(); }
133
- if (field === 'prefix') root.querySelector('[data-tk-example]').textContent = `Cards number ${draft.prefix || 'INT'}-1, ${draft.prefix || 'INT'}-2… on this board. The badge opens the card.`;
134
132
  change(read(), valid(), { typing: e.type === 'input' });
135
133
  };
136
134
  root.addEventListener('click', click);
@@ -190,7 +188,7 @@
190
188
  el.setAttribute('class', 'sheet');
191
189
  el.setAttribute('aria-labelledby', 'newBoardTitle');
192
190
  const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
193
- const draft = { name: '', purpose: '', github: '', remote: '', branch: '', tickets: { source: 'internal', prefix: 'INT' } };
191
+ const draft = { name: '', purpose: '', github: '', remote: '', branch: '', tickets: { source: 'none' } };
194
192
  let tickets;
195
193
  let repos = [], signedIn = false, pasted, selected, edited = false, sending = false, closed = false, generation = 0, lookupTimer, linear;
196
194
  const controller = new AbortController();
@@ -348,7 +346,7 @@
348
346
  } catch (e) { sending = false; fail(e); sync(); }
349
347
  });
350
348
  el.addEventListener('cancel', (e) => { e.preventDefault(); done(); });
351
- el.addEventListener('click', (e) => { if (e.target === el) done(); });
349
+ el.addEventListener('click', (e) => { if (BoardUI.onBackdrop(e, el)) done(); });
352
350
  el.addEventListener('close', () => {
353
351
  if (closed) return;
354
352
  closed = true;
@@ -181,7 +181,7 @@
181
181
  if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
182
182
  }
183
183
 
184
- // The one duration picker Snooze, Keep awake, Pause hub and Pause board share (hub/duration.mjs): chips that post
184
+ // The one duration picker Snooze, Pause hub and Pause board share (hub/duration.mjs): chips that post
185
185
  // `name` to `action` at once, then Custom, whose date and time posts as `name` too. While its setting is on, `on`
186
186
  // replaces it with when it ends and a button that posts `name`=off.
187
187
  function durationPicker({ id, title, icon, action, name, choices, on, button }) {
@@ -76,7 +76,6 @@
76
76
  .hub-view .dur-custom input::-webkit-calendar-picker-indicator { position:absolute; inset:0; width:auto; height:auto; opacity:0; cursor:pointer; }
77
77
  .hub-view .dur-custom:focus-within { outline:2px solid var(--accent); outline-offset:2px; border-radius:7px; }
78
78
  .hub-view .dur-on .title { font-weight:600; white-space:normal; }
79
- .hub-view .awake-on { color:var(--color-notice); font-weight:600; }
80
79
  .hub-view code { font-family:var(--mono); font-size:.85em; white-space:nowrap; }
81
80
  .hub-view footer { color:var(--muted); font-size:12px; margin:1.8rem 2px 0; }
82
81
  .hub-view .host { font-family:var(--mono); font-size:11px; overflow-wrap:anywhere; }
@@ -1,4 +1,4 @@
1
- // Hub's token slots: Linear's key and Slack's user token. Hub settings › Linear and › Slack mount them, and the Tickets
1
+ // Hub's token slots: Linear's key and Slack's user token. The Hub settings modals for Linear and Slack mount them, and the Tickets
2
2
  // control (dialogs.js) opens Linear's over Board settings or New board when Linear is tapped without a working key
3
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
4
  // through POST /_hub/settings/<kind>, which checks it with the service; state is hub/views.mjs settingsView's `<kind>`.
@@ -136,7 +136,7 @@
136
136
  }
137
137
  dialog.addEventListener('click', (e) => {
138
138
  if (e.target.closest?.('[data-lk-done]')) { close(); return; }
139
- if (e.target !== dialog) return;
139
+ if (!BoardUI.onBackdrop(e, dialog)) return;
140
140
  const r = dialog.getBoundingClientRect();
141
141
  if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) close();
142
142
  });
@@ -75,7 +75,7 @@ globalThis.BoardNewTask = (function () {
75
75
  B.newTaskKey = newTaskKey;
76
76
  B.on(document, 'keydown', newTaskKey);
77
77
  B.on($('#sheetClose'), 'click', closeSheet);
78
- B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
78
+ B.on(sheet, 'click', (ev) => { if (BoardUI.onBackdrop(ev, sheet)) closeSheet(); });
79
79
  B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
80
80
  // Discuss has the session grill or prototype the task at once; Implement sends the ask with quick: true, and the
81
81
  // session marks the card ready (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
@@ -87,7 +87,6 @@
87
87
  usage: fake.usage(),
88
88
  hub: { tone: 'ok', word: 'healthy', detail: '3 of 3 sites up' },
89
89
  sites: fake.sites(),
90
- polling: { title: 'Checks every minute while you’re here', lines: ['Next check 12:00pm · last 11:45am, 8 PRs, no change', 'No pushes queued.'], count: 0 },
91
90
  settings: [['notifications', 'Notifications', 'Pushes on · Off on weekends · This device on'], ['quiet', 'Quiet hours', '10:00pm – 8:00am · pushes held'],
92
91
  ['codex', 'Codex share', 'Auto'], ['rules', 'Rules for every board', '1 rule'], ['closed', 'Closed boards', 'No closed boards']],
93
92
  }),
@@ -105,7 +104,7 @@
105
104
 
106
105
  function hub(data = {}) {
107
106
  const d = { ...fake.hub(), header: true, ...data };
108
- const { row, tile, nowRow, CHEVRON } = HubUI;
107
+ const { row, tile, CHEVRON } = HubUI;
109
108
  const site = (s) => row({ icon: s.icon, title: esc(s.title), sub: s.status ?? '<span class="st ok">up · listening · every link passes</span>', wrap: true, trail: CHEVRON, href: `/${s.route}/` });
110
109
  const settings = d.settings.map(([id, title, sub]) => `<button type="button" class="hub-setting" data-setting="${esc(id)}" data-kit-inert aria-haspopup="dialog"><span class="text"><span class="title">${esc(title)}</span><span class="sub">${esc(sub)}</span></span>${CHEVRON}</button>`).join('');
111
110
  const usage = d.usageCard ?? HubUI.usageCard(d.usage);
@@ -114,9 +113,7 @@
114
113
  <div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span> · ${esc(d.hub.detail)}`, wrap: true })}</div>
115
114
  <h2 class="label">Boards</h2>
116
115
  <div class="group">${d.sites.map(site).join('')}</div>
117
- <p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, events (its session and a watcher run, or the hub can wake it), daily wakes, pushes and reviewer links. One that just broke reads settling first.</p>
118
- <h2 class="label">Polling status</h2>
119
- <div class="group">${nowRow(d.polling, tile('clock', '#3b82f6'))}</div></div>
116
+ <p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, events (its session and a watcher run, or the hub can wake it), daily wakes, pushes and reviewer links. One that just broke reads settling first.</p></div>
120
117
  <section class="hub-settings"><h2 class="label">Hub settings</h2>${settings}</section>
121
118
  <div class="hub-foot"><footer>${d.sites.length} sites. Each belongs to one Claude session, which adds it with <code>/kickflip &lt;route&gt;</code>. This page refreshes every 30 seconds.<br><span class="host">mac.example.ts.net</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v0.4.2</span></footer></div></main></div>`;
122
119
  }
@@ -6,7 +6,7 @@
6
6
  // board {route, hash} /<route>/, hash such as #selectedCard=<key>&openItemsCard=<topic id>, or the older #item=<key>&reply
7
7
  // settings {route} /<route>/settings.html, a request to open the modal over that board
8
8
  // questions {route, id} /<route>/questions.html#<id>
9
- // notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<lineup slug>, and plan.html, which the board
9
+ // notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<wave slug>, and plan.html, which the board
10
10
  // redirects there
11
11
  // hub {hash} /_hub/ with a settings modal named by hash; legacy /_hub/settings opens the same modals
12
12
  // ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)),
@@ -21,7 +21,7 @@
21
21
  @media (hover: hover) { .sidebar .sb-close:hover { background: var(--chip); color: var(--text); } }
22
22
  .sidebar .sb-label { margin: 12px 8px 4px; font-size: 11.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
23
23
  .sidebar .sb-group { display: flex; flex-direction: column; gap: 2px; }
24
- .sidebar .sb-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding-bottom: max(12px, env(safe-area-inset-bottom)); display: flex; flex-direction: column; }
24
+ .sidebar .sb-scroll { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; overflow-wrap: anywhere; overscroll-behavior: contain; padding-bottom: max(12px, env(safe-area-inset-bottom)); display: flex; flex-direction: column; }
25
25
  .sidebar .sb-scroll > section { flex: none; }
26
26
  .sidebar .sb-utility { padding-top: 6px; }
27
27
  .sidebar .sb-boards { margin-top: auto; }
@@ -150,11 +150,11 @@ html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-
150
150
  @starting-style { .sidebar.ks-on .ks-label-hint { opacity: 0; } }
151
151
  /* The label is the bar's containing block: left 0 is the BOARDS text's edge, and right -8px crosses the label's 8px
152
152
  side margin (.sb-label) to the rows' right edge. The bar sits in the label's 4px bottom margin. */
153
- .sidebar .ks-bar { position: absolute; left: 0; right: -8px; top: 100%; height: 2px; margin-top: 1px; background: var(--accent);
153
+ .sidebar .ks-bar { position: absolute; left: 0; right: -8px; top: 100%; height: 2px; margin-top: 1px; background: var(--color-notice);
154
154
  transform-origin: 0 50%; animation: ks-drain linear forwards; }
155
155
  @keyframes ks-drain { to { transform: scaleX(0); } }
156
156
  .sidebar .ks-u { transition: color 200ms linear; }
157
- .sidebar.ks-on .ks-u { color: var(--accent); transition: color 150ms linear; }
157
+ .sidebar.ks-on .ks-u { color: var(--color-notice); transition: color 150ms linear; }
158
158
  @starting-style { .sidebar.ks-on .ks-u { color: inherit; } }
159
159
  @media (prefers-reduced-motion: reduce) {
160
160
  .sidebar .ks-bar { animation: none; }
@@ -236,7 +236,7 @@
236
236
  }
237
237
  el.addEventListener('click', (e) => {
238
238
  if (sending) return;
239
- if (e.target === el || e.target.closest('[data-failed-close]')) { done(); return; }
239
+ if (BoardUI.onBackdrop(e, el) || e.target.closest('[data-failed-close]')) { done(); return; }
240
240
  const dismiss = e.target.closest('[data-failed-dismiss]');
241
241
  const retry = e.target.closest('[data-failed-retry]');
242
242
  if (dismiss) {
@@ -309,7 +309,7 @@
309
309
  el.innerHTML = shortcuts();
310
310
  el.addEventListener('cancel', (e) => { e.preventDefault(); closeHelp(); });
311
311
  el.addEventListener('close', closeHelp);
312
- el.addEventListener('click', (e) => { if (e.target === el) closeHelp(); });
312
+ el.addEventListener('click', (e) => { if (BoardUI.onBackdrop(e, el)) closeHelp(); });
313
313
  help = { el, previous: document.activeElement };
314
314
  document.body.appendChild(el);
315
315
  el.showModal();
@@ -93,8 +93,10 @@ button.chip { border: 0; }
93
93
  /* The footer status of a stale card, and of one the orchestrator stalled (ui-cards.js timeLine). */
94
94
  .card-stale { color: var(--muted); }
95
95
  .card-stalled { color: var(--color-danger); }
96
- /* A bumped-out card's footer status, and a bumped ready card's while the host holds new work (ui-cards.js timeLine). */
97
- .card-bumped-out, .card-host-held { color: var(--muted); }
96
+ /* A bumped ready card's footer status while the host holds new work, and a paused build's (ui-cards.js timeLine);
97
+ a build still pausing reads orange. */
98
+ .card-host-held, .card-paused { color: var(--muted); }
99
+ .card-pausing { color: var(--color-notice); }
98
100
  .sub.next.idle { color: var(--muted); }
99
101
  .sub.refused { color: var(--color-danger); }
100
102
  .toast-stack { position: fixed; left: 50%; bottom: calc(var(--app-bottom, 0px) + 12px + env(safe-area-inset-bottom));
@@ -139,7 +141,7 @@ button.chip { border: 0; }
139
141
  .thread-more:active { background: var(--accent-soft); }
140
142
  .thread-more:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
141
143
  .thread-more .n { color: var(--muted); font-weight: 400; }
142
- /* [answered asks have their own section below Done, and Done and Asks show 10 cards at a time](docs/adr/2026-11-03-0259-answered-asks-have-their-own-section-below-done.md): Done's and Asks' Show more, the thread's look as a row of the lane's list */
144
+ /* [answered asks fold into the one Done list](docs/adr/2026-11-09-1430-answered-asks-fold-into-the-one-done-list.md): Done's Show more, the thread's look as a row of the lane's list */
143
145
  .list > .thread-more { width: 100%; margin: 0; border-top: 1px solid var(--line); border-radius: 0; }
144
146
 
145
147
  @media (max-width: 560px) {
@@ -155,7 +157,7 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
155
157
  .wv-link:disabled { color: var(--muted); cursor: default; }
156
158
  .wv-busy { display: inline-flex; align-items: center; gap: 6px; color: var(--color-notice); }
157
159
  /* docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md: each wave is a lane-style band inside Todo's list. */
158
- .wb + .wb, .wb-loose { border-top: 1px solid var(--line); }
160
+ .wb + .wb { border-top: 1px solid var(--line); }
159
161
  .wb-head { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; min-height: 34px; padding: 5px 12px;
160
162
  background: var(--band); font-size: 13px; font-weight: 500; cursor: pointer; user-select: none; }
161
163
  .wb-head:hover { background: color-mix(in srgb, var(--band), var(--text) 5%); }
@@ -173,7 +175,6 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
173
175
  background: var(--panel); box-shadow: 0 6px 18px rgba(0,0,0,.18); color: var(--text); font-weight: 400; line-height: 1.35; overflow-wrap: anywhere; cursor: default; }
174
176
  .wb-done { padding: 8px 12px; font-size: 12.5px; color: var(--muted); }
175
177
  .wb-done a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
176
- .wb-loosehead { cursor: default; }
177
178
  .wb-counts { position: relative; border: 0; background: none; padding: 4px 0; font: inherit; cursor: help; color: var(--muted); font-weight: 400; font-variant-numeric: tabular-nums; white-space: nowrap; }
178
179
  .wb-tip { display: none; position: absolute; right: 0; top: 100%; z-index: 6; margin-top: 2px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); box-shadow: 0 6px 18px rgba(0,0,0,.18); color: var(--text); font-weight: 400; text-align: left; }
179
180
  .wb-counts:hover .wb-tip, .wb-counts.open .wb-tip { display: block; }
@@ -181,18 +182,6 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
181
182
  .wb-counts .wv-review { color: var(--color-info); }
182
183
  .wb-counts .wv-progress { color: var(--color-notice); }
183
184
  .wb-counts .wv-todo, .wb-counts .wv-done { color: var(--color-quiet); }
184
- .wb-lane + .wb-lane, .wb-lane + .wb-loose { border-top: 1px solid var(--line); }
185
- .wb-loosehead:hover { background: var(--band); }
186
- /* A wave card's step number, inside its id chip, and its ⋯ menu. */
187
- .wn { color: var(--muted); font-variant-numeric: tabular-nums; padding-right: 5px; margin-right: 4px; border-right: 1px solid var(--line); }
188
- .wb .item, .wb .item > summary .title-wrap { position: relative; }
189
- .wb-dots[aria-expanded="true"] { color: var(--text); background: var(--chip); }
190
- .wb-dots svg { display: block; }
191
- .wb-menu { position: absolute; right: 0; top: calc(100% + 4px); z-index: 5; display: grid; min-width: 160px; padding: 4px;
192
- border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: 0 6px 18px rgba(0,0,0,.18); }
193
- .wb-menu button { border: 0; background: none; text-align: left; padding: 8px 10px; min-height: 0; border-radius: 6px; font: inherit; font-size: 13px; color: var(--text); cursor: pointer; }
194
- .wb-menu button:hover:not(:disabled) { background: var(--chip); }
195
- .wb-menu button:disabled { color: var(--muted); cursor: default; }
196
185
  .lane[data-lane="todo"] > .list:has(.wb) { overflow: visible; }
197
186
  .nav a.icon { display: inline-flex; align-items: center; padding: 4px; color: var(--muted); }
198
187
  .nav a.icon:hover { color: var(--text); }
@@ -218,9 +207,6 @@ code { overflow-wrap: anywhere; }
218
207
  /* A file input draws its "Choose File" text at the 16px coarse-pointer size, wider than the button; iOS Safari widens the page to fit it. */
219
208
  .attach input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; padding: 0; border: 0; font-size: 0; }
220
209
  .row > .attach + .muted { flex: 1; min-width: 0; }
221
- /* The ⌘↵ / Ctrl+↵ hint left of Send: only where there is a keyboard to press it on. */
222
- .send-hint { display: none; color: var(--muted); font-size: 12px; white-space: nowrap; }
223
- @media (hover: hover) and (pointer: fine) { .send-hint { display: inline; } }
224
210
  /* A composer's text box (BoardUI.dropZone), its attach button inside at the bottom right with the text kept clear of it:
225
211
  while files are dragged over the composer, attach.js marks it .dragging and the box gets a dashed accent outline
226
212
  with Drop to attach over it. */
@@ -317,7 +303,7 @@ main { padding-bottom: calc(110px + env(safe-area-inset-bottom)); }
317
303
  }
318
304
  /* The hub lives at the tailnet root, outside this site, so its link is root-absolute. */
319
305
  .brand { align-items: center; }
320
- .hub { display: inline-flex; flex: none; }
306
+ a.hub { display: inline-flex; flex: none; }
321
307
  .hub img { width: 32px; height: auto; display: block; image-rendering: pixelated; }
322
308
  @keyframes pulse { 50% { opacity: .35; } }
323
309
  /* A lane's folds are inset boxes in its list, so they read as part of that lane, not as more lanes. */
@@ -350,7 +336,7 @@ form[data-reply] .icons { display: inline-flex; flex: none; margin-left: auto; }
350
336
  /* A composer's send buttons (ui.js sendButton), each with its key as a small gray hint where there is a keyboard and
351
337
  room. A pair takes a row of two halves; a Todo card's reply box wraps its pair under the row. */
352
338
  .send-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 10px; }
353
- .send-actions button, [data-sends] button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
339
+ .send-actions button, [data-sends] button, .view-questions .bar button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
354
340
  .send-actions button { min-height: 44px; }
355
341
  form[data-reply] .row { flex-wrap: wrap; gap: 6px; }
356
342
  form[data-reply] .row > .send-actions { flex: 0 0 100%; margin-top: 4px; }
@@ -392,12 +378,18 @@ button.attach:disabled { opacity: .35; }
392
378
  /* A New card's "waiting", until the orchestrator picks it up: grey, its dot still. */
393
379
  .pickup-wait { color: var(--muted); font-size: 12px; font-weight: 600; }
394
380
  .pickup-wait .working-dot { background: currentColor; animation: none; }
395
- .msg.link { padding: 0 10px; margin: 4px 0; font-size: 13px; }
396
- .msg.builder-status { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--color-notice); overflow-wrap: anywhere; }
381
+ /* Every status line shares this spacing and gutter: the mark sits in the first 23px, and wrapped text hangs under the
382
+ first line's text, never under the mark. A fold's summary hangs the same way inside its box. Each kind keeps only its colour. */
383
+ .status-line { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
384
+ div.status-line { padding-left: 33px; text-indent: -23px; }
385
+ .status-line > summary { padding-left: 23px; text-indent: -23px; }
386
+ .status-line > ol { text-indent: 0; }
387
+ .msg.builder-status { color: var(--color-notice); }
397
388
  .msg.builder-status .working-dot { animation: none; }
398
- .msg.request { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
389
+ .msg.request { color: var(--muted); }
399
390
  .msg.request.readied { color: var(--color-success); }
400
- .msg.agent { padding: 0 10px; margin: 4px 0; font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
391
+ .msg.request.hub.fault { color: var(--color-danger); }
392
+ .msg.agent { font-weight: 600; }
401
393
  .msg.agent.started { color: var(--color-notice); } .msg.agent.finished { color: var(--color-success); }
402
394
  .msg.agent .muted { font-weight: 400; }
403
395
  /* A planner's line in the thread, dot included, is the owner's blue; its finish stays green
@@ -420,7 +412,6 @@ button.attach:disabled { opacity: .35; }
420
412
  .sub.agent.stale .working-dot { animation: none; }
421
413
  /* The one class every paused status takes, gray: the card and thread line, the header, the hub banner. */
422
414
  .paused { color: var(--muted); font-weight: 600; }
423
- .msg.calls { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
424
415
  .msg.calls summary { cursor: pointer; color: var(--muted); list-style: none; }
425
416
  .msg.calls summary::-webkit-details-marker { display: none; }
426
417
  .msg.calls[open] summary .mark-icon { transform: rotate(90deg); }
@@ -473,7 +464,7 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
473
464
  .view-only-strip b { color: var(--accent); }
474
465
  /* A board out of reach (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md): it reads as before,
475
466
  undimmed, and only the buttons that send, ui.js SENDS, are held, the labelled ones saying why; app-header.js stops their taps. */
476
- .offline :is(form button[type="submit"], .actions button, button[data-confirm], button[data-deck-pick], button[data-review-answer], button[data-prompt-send], button[data-prompt-choice], button[data-quick], button[data-card-bump], button[data-bump], button[data-withdraw], button[data-band-move], button[data-grill], button[data-proto], button[data-visual], button[data-plan], button[data-main-pull], button[data-arch-run], button[data-try], button[data-reopen]) { opacity: .55; cursor: not-allowed; }
467
+ .offline :is(form button[type="submit"], .actions button, button[data-confirm], button[data-deck-pick], button[data-review-answer], button[data-prompt-send], button[data-prompt-choice], button[data-quick], button[data-card-bump], button[data-bump], button[data-withdraw], button[data-grill], button[data-proto], button[data-visual], button[data-plan], button[data-main-pull], button[data-arch-run], button[data-try], button[data-reopen]) { opacity: .55; cursor: not-allowed; }
477
468
  /* With no copy to show, the landing takes the board's place: the kickflip mark and two lines, as hub/front-door.mjs's page has them. */
478
469
  .down-landing { min-height: 60vh; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 32px 24px; text-align: center; }
479
470
  .down-landing-mark { display: block; margin-bottom: 8px; width: 70px; height: 60px; object-fit: contain; }
@@ -653,9 +644,20 @@ main > * { transition: opacity .25s ease; }
653
644
  .view-questions .opt .lbl { font-weight: 600; }
654
645
  .view-questions .opt .rec { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
655
646
  .view-questions .opt .det { color: var(--muted); font-size: 13px; }
656
- .view-questions .opt .suggested { margin-left: 6px; white-space: nowrap; }
657
- .view-questions .opt .suggested-why { margin-top: 4px; color: var(--muted); font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; }
658
- .view-questions .opt .suggested-why a { color: var(--accent); }
647
+ /* A question's context and an option's detail take markdown at body size: headings drawn as bold lines, blocks tight
648
+ (docs/adr/2026-11-09-1500-a-questions-descriptions-are-markdown-and-its-text-is-one-brief-line.md). So does any .md
649
+ box: the orchestrator's thread messages, markdown replies and View transcript's text rows
650
+ (docs/adr/2026-11-09-1453-orchestrator-messages-replies-and-transcript-text-are-markdown.md), whose blocks break the
651
+ lines, not the text's own newlines. */
652
+ :is(.msg .body, .reader-row).md { white-space: normal; }
653
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) { overflow-wrap: anywhere; }
654
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(p, ul, ol, blockquote, pre, table) { margin: 0 0 6px; }
655
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(h1, h2, h3, h4, h5, h6) { font-size: inherit; font-weight: 600; margin: 6px 0 2px; }
656
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(ul, ol) { padding-left: 18px; }
657
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(pre, code) { white-space: pre-wrap; font-size: 12px; }
658
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) table { display: block; max-width: 100%; overflow-x: hidden; }
659
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) > :last-child { margin-bottom: 0; }
660
+ .view-questions .opt .suggested-why { margin-top: 4px; color: var(--color-info); font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; }
659
661
  .view-questions .note { margin-top: 4px; }
660
662
  .view-questions .ans { background: var(--accent-soft); border-radius: 9px; padding: 7px 10px; margin-top: 8px; font-size: 13px; }
661
663
  .view-questions .rep { background: var(--claude); border-radius: 9px; padding: 7px 10px; margin-top: 6px; font-size: 13px; }
@@ -687,7 +689,6 @@ main > * { transition: opacity .25s ease; }
687
689
  .view-questions .q.resolved .reask-line { margin-top: 10px; }
688
690
  .view-questions .bar { position: sticky; bottom: var(--app-bottom, 0px); padding: 10px 0 max(10px, env(safe-area-inset-bottom)); background: var(--bg); z-index: 2;
689
691
  display: flex; justify-content: space-between; align-items: center; gap: 8px; border-top: 1px solid var(--line); }
690
- .view-questions .bar .send-hint { margin-left: auto; }
691
692
  /* The + rides above the sticky Send bar rather than over its Send. */
692
693
  .view-questions .bar #qstatus { min-width: 0; }
693
694
  .view-questions .bar #qstatus.sent { color: var(--color-success); font-weight: 600; }
@@ -739,18 +740,18 @@ main > * { transition: opacity .25s ease; }
739
740
  .view-notes .plan li { margin: 3px 0; }
740
741
  .view-notes .plan li.nested { margin-left: 20px; }
741
742
  .view-notes .plan code { font-size: 12.5px; background: var(--chip); border-radius: 4px; padding: 0 3px; }
742
- .view-notes .plan pre { overflow-x: auto; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
743
- .view-notes .plan pre code { background: none; padding: 0; overflow-wrap: normal; }
743
+ .view-notes .plan pre { white-space: pre-wrap; overflow-x: hidden; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
744
+ .view-notes .plan pre code { background: none; padding: 0; }
744
745
  .view-notes .plan blockquote { border-left: 3px solid var(--line); padding-left: 10px; color: var(--muted); }
745
746
  .view-notes .plan hr { border: 0; border-top: 1px solid var(--line); margin: 16px 0; }
746
747
  .view-notes .plan a { color: var(--accent); }
747
- .view-notes .md-table { overflow-x: auto; margin: 10px 0; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
748
- .view-notes .md-table table { border-collapse: collapse; font-size: 13px; line-height: 1.4; }
749
- .view-notes .md-table th, .view-notes .md-table td { text-align: left; vertical-align: top; padding: 6px 8px; border-bottom: 1px solid var(--line); min-width: 5em; overflow-wrap: normal; }
748
+ .view-notes .md-table { overflow-x: hidden; margin: 10px 0; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
749
+ .view-notes .md-table table { width: 100%; border-collapse: collapse; font-size: 13px; line-height: 1.4; }
750
+ .view-notes .md-table th, .view-notes .md-table td { text-align: left; vertical-align: top; padding: 6px 8px; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
750
751
  .view-notes .md-table th { background: var(--bg); font-weight: 600; }
751
752
  .view-notes .md-table tr:last-child td { border-bottom: 0; }
752
- .view-notes .md-table .wide { min-width: 14em; }
753
- .view-notes .md-table .wider { min-width: 24em; }
753
+ /* The sidebar and every pane never scroll sideways: content wraps at the pane's width, overflow-x hidden is the last guard. */
754
+ .view-questions, .view-notes, .reader-sheet, .pane-viewer { overflow-x: hidden; }
754
755
  /* A framed page (view-frame.js) fills the space beside the header, and scrolls inside its frame. */
755
756
  .view-frame { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
756
757
  .view-frame > .top { flex: none; }
@@ -777,9 +778,7 @@ main > * { transition: opacity .25s ease; }
777
778
  .sheet.keyboard-sheet { --sheet-limit: max(80px, calc(var(--vh, 100dvh) + var(--kb, 0px) - max(0px, calc(var(--app-bottom, 0px) - var(--kb, 0px))) - max(12px, env(safe-area-inset-top)))); }
778
779
  }
779
780
 
780
- /* Typing, a text field focused (shell.js sets :root.typing): the floating + dims and stops taking taps, and the page stays as it is. */
781
- :root.typing .fab { opacity: .35; pointer-events: none; }
782
- /* On a phone the + hides while typing, so it never sits over the keyboard's field. */
781
+ /* Typing, a text field focused (shell.js sets :root.typing). Only a phone changes: the + hides, so it never sits over the keyboard's field; wider screens keep it as it is. */
783
782
  @media (max-width: 699px) {
784
783
  :root.typing .fab { display: none; }
785
784
  /* The board page bottom bar hides too; --app-bottom stays, so the page does not move. */
@@ -951,7 +950,7 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
951
950
  .deck-opts button.rec { border-color: var(--accent); }
952
951
  .deck-opts button.armed { background: var(--color-notice); border-color: var(--color-notice); color: #000; font-weight: 600; }
953
952
  .deck-opts .rec-word { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
954
- .deck-opts small { display: block; color: var(--muted); font-size: 12.5px; }
953
+ .deck-opts .det { color: var(--muted); font-size: 12.5px; font-weight: 400; }
955
954
  .deck-prompt { margin: 0; padding: 0; list-style: none; }
956
955
  .deck-prompt li.prompt { padding: 8px 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
957
956
  .deck-acts { display: flex; flex-wrap: wrap; gap: 8px; }
@@ -1005,3 +1004,4 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
1005
1004
  }
1006
1005
  /* The Send bar is the last thing on its page: only the phone nav under it is left clear, so its background runs to the edge. */
1007
1006
  body .view-questions main { padding-bottom: var(--app-bottom, 0px); }
1007
+ .pfile.retry { cursor: pointer; }