@lovek4d/kickflip 0.5.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CONTEXT.md +82 -46
  2. package/SKILL.md +21 -15
  3. package/bin/doctor.mjs +5 -4
  4. package/bin/install.mjs +3 -3
  5. package/bin/kickflip.mjs +12 -12
  6. package/bin/wait-events.mjs +2 -2
  7. package/board/agents.mjs +2 -2
  8. package/board/arch-review.mjs +4 -4
  9. package/board/bin/board.mjs +179 -92
  10. package/board/build-script.mjs +2 -1
  11. package/board/domain.mjs +9 -7
  12. package/board/finish.mjs +4 -4
  13. package/board/headless.mjs +9 -5
  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-cards.js +40 -47
  19. package/board/public/app-header.js +6 -7
  20. package/board/public/app-lanes.js +19 -45
  21. package/board/public/app-needs.js +36 -31
  22. package/board/public/app-snapshot.js +43 -70
  23. package/board/public/app-threads.js +42 -46
  24. package/board/public/app.html +9 -4
  25. package/board/public/app.js +78 -32
  26. package/board/public/hub-kit.js +62 -2
  27. package/board/public/hub.css +2 -0
  28. package/board/public/new-task.js +8 -6
  29. package/board/public/progress-review.html +46 -0
  30. package/board/public/progress-review.js +158 -0
  31. package/board/public/prototype-kit.js +109 -4
  32. package/board/public/prototype-switcher.js +130 -31
  33. package/board/public/prototype.html +57 -3
  34. package/board/public/router.js +7 -6
  35. package/board/public/shell.js +65 -24
  36. package/board/public/sidebar.css +6 -0
  37. package/board/public/sidebar.js +19 -69
  38. package/board/public/style.css +116 -73
  39. package/board/public/ui-cards.js +78 -57
  40. package/board/public/ui-enums.js +2 -2
  41. package/board/public/ui-header.js +23 -16
  42. package/board/public/ui-hub.js +19 -5
  43. package/board/public/ui-links.js +7 -7
  44. package/board/public/ui-order.js +1 -1
  45. package/board/public/ui-page-header.js +19 -7
  46. package/board/public/ui-patch.js +7 -33
  47. package/board/public/ui-progress-bar.js +28 -0
  48. package/board/public/ui-questions.js +25 -17
  49. package/board/public/ui-sidebar.js +31 -23
  50. package/board/public/ui-snapshot.js +44 -53
  51. package/board/public/ui-threads.js +57 -28
  52. package/board/public/ui.js +94 -18
  53. package/board/public/view-hub-settings.js +12 -58
  54. package/board/public/view-hub.js +5 -2
  55. package/board/public/view-notes.js +27 -7
  56. package/board/public/view-questions.js +90 -68
  57. package/board/public/view-settings.js +133 -15
  58. package/board/render-check.mjs +10 -0
  59. package/board/review.mjs +1 -1
  60. package/board/server.mjs +20 -19
  61. package/board/stop.mjs +1 -0
  62. package/board/store.mjs +29 -18
  63. package/board/sync.mjs +2 -0
  64. package/board/tickets.mjs +3 -3
  65. package/board/transcript.mjs +2 -2
  66. package/board/worktree.mjs +22 -21
  67. package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
  68. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  69. package/hub/board-repo.mjs +1 -1
  70. package/hub/boards.mjs +4 -4
  71. package/hub/front-door.mjs +2 -2
  72. package/hub/github.mjs +3 -3
  73. package/hub/hub.mjs +11 -11
  74. package/hub/incoming.mjs +2 -2
  75. package/hub/owner-checks.mjs +1 -1
  76. package/hub/pr-report.mjs +2 -2
  77. package/hub/review-send.mjs +1 -1
  78. package/hub/sessions.mjs +2 -2
  79. package/hub/share.mjs +1 -1
  80. package/hub/slack.mjs +2 -2
  81. package/hub/try.mjs +6 -6
  82. package/hub/upgrade.mjs +1 -1
  83. package/hub/usage.mjs +3 -3
  84. package/hub/views.mjs +1 -1
  85. package/package.json +1 -1
  86. package/shared/card-prs.mjs +1 -1
  87. package/shared/machine-gate.mjs +2 -0
  88. package/shared/prototype-frame.js +1 -0
  89. package/shared/tailscale.mjs +1 -1
  90. package/shared/usage-gate.mjs +1 -1
  91. package/board/public/ui-phase-bars.js +0 -17
@@ -1,5 +1,5 @@
1
1
  // The sidebar's behaviour on a page with <aside id="sidebar" class="sidebar"> and <div id="sidebarScrim"
2
- // class="sidebar-scrim">, after ui-phase-bars.js, ui-page-header.js and ui-sidebar.js (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md).
2
+ // class="sidebar-scrim">, after ui-progress-bar.js, ui-page-header.js and ui-sidebar.js (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md).
3
3
  // Sidebar.mount({here, data}) paints `data` or the last copy this device stored; the app shell hands it each new copy
4
4
  // through setData from its one stream (shell.js). The page header's logo (a.hub) toggles the sidebar at every
5
5
  // width, with what waits on the other sites in its accessible label. Below 900px the sidebar is a drawer: the logo or a swipe in from the
@@ -17,8 +17,8 @@
17
17
  // logo does, checkDevice reading this device's push subscription again, and keydown and pointerdown taking a framed
18
18
  // page's events (view-frame.js), as the document's own
19
19
  // A starting board's row (docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md): its seconds
20
- // tick in place each second, and a repaint sets its running bar's pulse from the clock, so a new copy never restarts
21
- // it. When the board joins, its row fades in, and a repaint during the fade carries it on from where it was.
20
+ // tick in place each second, and its running bar beats on every pulse's one clock (ui-progress-bar.js onePulse). When
21
+ // the board joins, its row fades in, and a repaint during the fade carries it on from where it was.
22
22
  (function () {
23
23
  const DATA_KEY = 'hub:sidebar';
24
24
  // app.html reads it before the first paint, so a collapsed rail never shows for a moment.
@@ -27,8 +27,7 @@
27
27
  // A phone has no keys to press, so it never shows the shortcuts sheet.
28
28
  const DESKTOP = '(hover: hover) and (pointer: fine)';
29
29
  const TWICE_MS = 1000;
30
- // style.css pulse-fill's period, and sidebar.css sb-join's length.
31
- const PULSE_MS = 1400;
30
+ // sidebar.css sb-join's length.
32
31
  const JOIN_MS = 2500;
33
32
  // paint() sets the bar's duration from this and its delay from switching.started.
34
33
  const IDLE_MS = 3000;
@@ -58,9 +57,8 @@
58
57
  if (!data) { try { data = JSON.parse(read(DATA_KEY) || 'null'); } catch { data = null; } }
59
58
  let device = null;
60
59
  let update = null;
61
- // While Restart waits for the hub: null | {version} | {version, error}, which replaces the hub's upgrade field in the row.
62
- let restart = null;
63
- const upgradeShown = () => (restart ? { state: restart.error ? 'updated' : 'restarting', ...restart } : data?.upgrade);
60
+ // The hub's version when this page booted; the row names the version only once it differs.
61
+ let bootVersion = data?.version ?? null;
64
62
  let updateHTML = '';
65
63
  const rail = matchMedia(RAIL);
66
64
  const html = document.documentElement;
@@ -73,7 +71,7 @@
73
71
  const logo = [...document.querySelectorAll('.top a.hub')].find(pageLogo);
74
72
  if (!logo) return;
75
73
  const n = othersWaiting(data, here);
76
- logo.setAttribute('aria-label', `Boards${update ? ', new version ready' : upgradeShown() ? ', update available' : ''}${n ? `, ${n} waiting on other boards` : ''}`);
74
+ logo.setAttribute('aria-label', `Boards${update ? ', new version ready' : ''}${n ? `, ${n} waiting on other boards` : ''}`);
77
75
  }
78
76
  // switching: {opened, started, timer} while switch mode shows, opened when it opened the drawer, started on performance.now().
79
77
  let switching = null;
@@ -91,7 +89,6 @@
91
89
  }
92
90
  function paintStarts() {
93
91
  const now = Date.now();
94
- for (const bar of aside.querySelectorAll('.sb-starting .phase-bars i.now')) bar.style.animationDelay = `-${now % PULSE_MS}ms`;
95
92
  for (const [route, at] of joined) {
96
93
  const row = now - at < JOIN_MS && aside.querySelector(`a.sb-row[href="/${encodeURIComponent(route)}/"]`);
97
94
  if (!row) { joined.delete(route); continue; }
@@ -104,7 +101,10 @@
104
101
  }
105
102
  // A repaint keeps the drawer's scroll, so a new copy doesn't move the list under a finger.
106
103
  function paint() {
107
- html.classList.toggle('sb-update', Boolean(update || upgradeShown()));
104
+ html.classList.toggle('sb-update', Boolean(update));
105
+ const dot = globalThis.BoardUI?.hubDot?.(data);
106
+ html.classList.toggle('sb-hub-busy', dot === 'busy');
107
+ html.classList.toggle('sb-hub-down', dot === 'down');
108
108
  if (!data) return;
109
109
  const scroll = aside.querySelector('.sb-scroll');
110
110
  const top = painted ? scroll?.scrollTop : undefined;
@@ -116,11 +116,14 @@
116
116
  bar.style.animationDelay = `${switching.started - performance.now()}ms`;
117
117
  bar.style.animationDuration = `${IDLE_MS}ms`;
118
118
  }
119
+ // A shared person's sidebar has no update row (ui-sidebar.js).
119
120
  const fresh = aside.querySelector('.sb-update');
120
- if (held) fresh.replaceWith(held);
121
- const row = updateRow(update, upgradeShown());
122
- if (!held || row !== updateHTML) (held || fresh).innerHTML = row;
123
- updateHTML = row;
121
+ if (fresh) {
122
+ if (held) fresh.replaceWith(held);
123
+ const row = updateRow(update, data.version && data.version !== bootVersion ? data.version : null);
124
+ if (!held || row !== updateHTML) (held || fresh).innerHTML = row;
125
+ updateHTML = row;
126
+ }
124
127
  const list = aside.querySelector('.sb-scroll');
125
128
  if (list) list.scrollTop = top ?? list.scrollHeight;
126
129
  paintStarts();
@@ -131,6 +134,7 @@
131
134
  if (!next) return;
132
135
  const starting = new Set(startsRunning().map((s) => s.route));
133
136
  for (const s of next.sites || []) if (starting.has(s.route)) joined.set(s.route, Date.now());
137
+ bootVersion ??= next.version ?? null;
134
138
  data = next;
135
139
  write(DATA_KEY, JSON.stringify(data));
136
140
  paint();
@@ -195,64 +199,10 @@
195
199
  if (!refreshApp.disabled && update !== 'reloading') { setUpdate('reloading'); reload(); }
196
200
  return;
197
201
  }
198
- const restartApp = e.target.closest?.('[data-app-restart]');
199
- if (restartApp) { if (!restartApp.disabled) restartHub(); return; }
200
- const upgradeApp = e.target.closest?.('[data-app-upgrade]');
201
- if (upgradeApp) { if (!upgradeApp.disabled) upgrade(upgradeApp); return; }
202
202
  if (e.target.closest?.('[data-new-board]')) { newBoard(); return; }
203
203
  const failed = e.target.closest?.('[data-failed-start]');
204
204
  if (failed) openFailed(failed.dataset.failedStart);
205
205
  });
206
- // Restart asks the hub to restart (POST /_hub/restart-hub), then polls until it answers with other code than it ran (its
207
- // x-hub-code) and reloads the page (docs/adr/2026-11-05-2200-the-hub-pulls-a-new-version-itself-and-offers-restart.md).
208
- // Past RESTART_TRIES polls the row goes back to Restart with how to restart by hand.
209
- const RESTART_TRIES = 120;
210
- async function restartHub() {
211
- if (restart) return;
212
- const version = data.upgrade.version;
213
- restart = { version };
214
- paint();
215
- let before, command;
216
- try {
217
- const response = await fetch('/_hub/restart-hub', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: '{}' });
218
- const body = await response.json().catch(() => ({}));
219
- if (!response.ok) throw new Error(body.error || 'The hub didn’t restart.');
220
- before = response.headers.get('x-hub-code');
221
- command = body.command;
222
- } catch (e) {
223
- restart = { version, error: e.message || 'The hub didn’t answer.' };
224
- paint();
225
- restart = null;
226
- return;
227
- }
228
- for (let i = 0; i < RESTART_TRIES; i++) {
229
- await new Promise((resolve) => setTimeout(resolve, 1000));
230
- try {
231
- const response = await fetch('/_hub/api/hub', { cache: 'no-store', headers: { accept: 'application/json' } });
232
- const code = response.headers.get('x-hub-code');
233
- if (response.ok && code && code !== before) { reload(); return; }
234
- } catch { /* the hub is down while it restarts */ }
235
- }
236
- restart = { version, error: `The hub hasn’t come back. On the host, run ${command}` };
237
- paint();
238
- restart = null;
239
- }
240
- // Update pulls again after a failed pull (docs/adr/2026-11-05-2200-the-hub-pulls-a-new-version-itself-and-offers-restart.md). The row
241
- // waits (ui.js waitOn) until the hub answers with what it did, then while that pull runs (ui-sidebar.js
242
- // updateRow), and a refusal shows as a failed update.
243
- function upgrade(button) {
244
- BoardUI.waitOn(button, (async () => {
245
- let next;
246
- try {
247
- const response = await fetch('/_hub/upgrade', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
248
- const body = await response.json().catch(() => ({}));
249
- next = response.ok ? body.upgrade : { ...data.upgrade, state: 'failed', error: body.error || 'The hub didn’t answer.' };
250
- } catch {
251
- next = { ...data.upgrade, state: 'failed', error: 'The hub didn’t answer.' };
252
- }
253
- setData({ ...data, upgrade: next });
254
- })());
255
- }
256
206
  // A failed start's sheet (docs/adr/2026-11-02-2006-a-failed-start-retries-from-its-sheet.md). Dismiss deletes its Starting
257
207
  // record; Retry starts it again, and its row reads starting… until the hub's next copy says otherwise.
258
208
  function openFailed(route) {
@@ -82,8 +82,13 @@ button.chip { border: 0; }
82
82
  .status.review { border-color: var(--color-info); background: conic-gradient(var(--color-info) 0 75%, transparent 75% 100%); }
83
83
  .sub.agent { color: var(--color-notice); }
84
84
  .sub.next { color: var(--color-notice); }
85
- .card-hold, .card-blocked, .card-action { color: var(--color-notice); }
85
+ .card-hold { color: var(--color-quiet); }
86
+ .card-blocked, .card-action { color: var(--color-notice); }
86
87
  .card-ready { color: var(--color-success); }
88
+ .card-staging { color: var(--color-notice); }
89
+ /* The footer status of a stale card, and of one the orchestrator stalled (ui-cards.js timeLine). */
90
+ .card-stale { color: var(--muted); }
91
+ .card-stalled { color: var(--color-danger); }
87
92
  .sub.next.idle { color: var(--muted); }
88
93
  .sub.refused { color: var(--color-danger); }
89
94
  .toast-stack { position: fixed; left: 50%; bottom: calc(var(--app-bottom, 0px) + 12px + env(safe-area-inset-bottom));
@@ -166,7 +171,10 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
166
171
  .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; }
167
172
  .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; }
168
173
  .wb-counts:hover .wb-tip, .wb-counts.open .wb-tip { display: block; }
169
- .wb-counts .wv-cur { font-weight: 700; color: var(--accent); }
174
+ .wb-counts .wv-cur { font-weight: 700; }
175
+ .wb-counts .wv-review { color: var(--color-info); }
176
+ .wb-counts .wv-progress { color: var(--color-notice); }
177
+ .wb-counts .wv-todo, .wb-counts .wv-done { color: var(--color-quiet); }
170
178
  .wb-lane + .wb-lane, .wb-lane + .wb-loose { border-top: 1px solid var(--line); }
171
179
  .wb-loosehead:hover { background: var(--band); }
172
180
  /* A wave card's step number, inside its id chip, and its ⋯ menu. */
@@ -186,12 +194,12 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
186
194
  .refresh #refresh { padding: 4px; margin-left: -4px; flex: none; }
187
195
  .refresh #status { font-size: 12px; min-width: 0; }
188
196
  .refresh .late { color: var(--color-notice); font-weight: 600; }
189
- .refresh .down { color: var(--color-danger); font-weight: 600; }
190
- .refresh .asleep { color: var(--muted); font-weight: 600; }
197
+ /* The header's time keeps its text; a stopped or offline board turns it and the refresh arrow red, paused usage orange. */
198
+ .refresh .down, .refresh:has(.down) #refresh { color: var(--color-danger); }
199
+ .refresh .hold, .refresh:has(.hold) #refresh { color: var(--color-notice); }
191
200
  .lane-github { margin-left: auto; font-size: 12px; font-weight: 400; color: var(--muted); text-align: right; }
192
201
  .lane-github .late { color: var(--color-notice); }
193
202
  .msg.other { background: transparent; border: 1px solid var(--line); }
194
- .status.stale { border-color: var(--color-danger); background: conic-gradient(var(--color-danger) 0 75%, transparent 75% 100%); }
195
203
  .msg .reply, .msg .small, .rep, .sent { overflow-wrap: anywhere; }
196
204
  code { overflow-wrap: anywhere; }
197
205
  /* iOS Safari zooms the page when a field under 16px takes focus, which shifts the page width. */
@@ -328,14 +336,17 @@ main { padding-bottom: calc(110px + env(safe-area-inset-bottom)); }
328
336
  }
329
337
  .working-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--color-notice); margin-right: 5px; vertical-align: 1px; }
330
338
  @media (prefers-reduced-motion: no-preference) { .working-dot { animation: pulse 1.4s ease-in-out infinite; } }
331
- form[data-reply] .row .quick { margin-left: auto; margin-right: 6px; }
332
- form[data-reply] .row [data-confirm="finish"] { margin-right: 6px; }
339
+ /* A thread box's row: What's left? at the left, the eye, fire and attach at the right, then Send, or a pair below
340
+ (docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md). */
341
+ form[data-reply] .row .quick { margin: 0; }
342
+ form[data-reply] .icons { display: inline-flex; flex: none; margin-left: auto; }
343
+ .dz.bare > textarea { padding-bottom: 12px; }
333
344
  /* A composer's send buttons (ui.js sendButton), each with its key as a small gray hint where there is a keyboard and
334
345
  room. A pair takes a row of two halves; a Todo card's reply box wraps its pair under the row. */
335
346
  .send-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 10px; }
336
347
  .send-actions button, [data-sends] button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
337
348
  .send-actions button { min-height: 44px; }
338
- form[data-reply] .row { flex-wrap: wrap; }
349
+ form[data-reply] .row { flex-wrap: wrap; gap: 6px; }
339
350
  form[data-reply] .row > .send-actions { flex: 0 0 100%; margin-top: 4px; }
340
351
  .key-hint { display: none; font-size: 11px; font-weight: 400; color: var(--muted); white-space: nowrap; }
341
352
  button.primary .key-hint { color: color-mix(in srgb, var(--on-accent) 62%, transparent); }
@@ -355,29 +366,18 @@ button.attach:disabled { opacity: .35; }
355
366
  pixel off its siblings (docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md). */
356
367
  .card-time .phase-bars { display: inline-flex; gap: 2px; vertical-align: middle; position: relative; top: -1px; }
357
368
  .phase-bars i { width: 9px; height: 4px; border-radius: 2px; background: var(--line); }
358
- /* A strip dims, and its running bar stops blinking, while the card waits on something other than a builder
359
- (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md). app-cards.js sweeps bars in --bar-fill. */
369
+ /* A strip dims while the card waits on something other than a builder; ui-progress-bar.js then writes its running bar
370
+ as done, so it stops blinking (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md). app-cards.js
371
+ sweeps bars in --bar-fill. */
360
372
  .phase-bars { --bar-fill: var(--color-notice); }
361
373
  .phase-bars.dim { --bar-fill: color-mix(in srgb, var(--muted) 55%, transparent); }
362
374
  .phase-bars i.done, .phase-bars i.now { background: var(--bar-fill); }
363
375
  /* A bar part way, to --p: a starting board's clone (ui-sidebar.js). */
364
376
  .phase-bars i.fill { background: linear-gradient(to right, var(--bar-fill) var(--p), var(--line) var(--p)); }
365
- @keyframes pulse-fill { 50% { background-color: color-mix(in srgb, var(--color-notice) 35%, transparent); } }
366
- @media (prefers-reduced-motion: no-preference) { .phase-bars:not(.dim) i.now { animation: pulse-fill 1.4s ease-in-out infinite; } }
367
- /* A stale build's status line, dot included, is the needs-you red, and a tap on it nudges Claude (ui-cards.js builderStatus). */
368
- /* 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
369
- shorthand stays out so .sub's 12px wins. It sets no width: a button is its own block formatting context, so a full-width one
370
- drops below the bump icons' float instead of sitting beside it as the div's line box does (board/layout.test.mjs). */
371
- button.sub { display: block; margin-inline: 0; margin-bottom: 0; padding: 0; min-height: 0; border: 0; border-radius: 0; background: none;
372
- font-family: inherit; font-weight: inherit; letter-spacing: inherit; line-height: inherit; text-align: left; appearance: none; -webkit-appearance: none; }
373
- button.sub.agent.stalled { color: var(--color-danger); }
374
- /* A tap on it calms the line to the in-progress orange, its dot sending a ripple, until the card's next status line (app-cards.js nudge). */
375
- button.sub.agent.stalled.nudging { color: var(--color-notice); cursor: default; }
376
- .nudging .working-dot { position: relative; }
377
- .nudging .working-dot::after { content: ''; position: absolute; inset: -1px; border-radius: 50%; border: 1.5px solid currentColor; opacity: 0; }
378
- @keyframes nudge-ping { from { transform: scale(1); opacity: .9; } to { transform: scale(2.6); opacity: 0; } }
379
- @media (prefers-reduced-motion: no-preference) { .nudging .working-dot::after { animation: nudge-ping 1.1s ease-out infinite; } }
380
- .stalled .working-dot { background: currentColor; }
377
+ /* The pulse dims the bar's own fill, so it pulses in its lane's colour. */
378
+ @keyframes pulse-fill { 50% { background-color: color-mix(in srgb, var(--bar-fill) 35%, transparent); } }
379
+ /* Every pulse beats on one clock (ui-progress-bar.js onePulse), so a card's running bar and its status dot blink together. */
380
+ @media (prefers-reduced-motion: no-preference) { .phase-bars i.now { animation: pulse-fill 1.4s ease-in-out infinite; } }
381
381
  .brand { display: grid; grid-template-columns: auto auto minmax(0, 1fr); column-gap: 10px; row-gap: 2px; }
382
382
  .brand .hub { grid-row: 1 / span 2; align-self: center; }
383
383
  /* The session's working line, "thinking", is the owner's blue with one font, size, weight and pulse on the card and in the thread. */
@@ -390,26 +390,36 @@ button.sub.agent.stalled.nudging { color: var(--color-notice); cursor: default;
390
390
  .msg.agent { padding: 0 10px; margin: 4px 0; font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
391
391
  .msg.agent.started { color: var(--color-notice); } .msg.agent.finished { color: var(--color-success); }
392
392
  .msg.agent .muted { font-weight: 400; }
393
- /* A planner's line, dot included, is the owner's blue, on the card and in the thread; its finish stays green
394
- (docs/adr/2026-11-03-1551-planners-and-implementers-are-two-kinds-of-agent.md). */
395
- .sub.agent.planner, .msg.builder-status.planner, .msg.agent.started.planner { color: var(--accent); }
393
+ /* A planner's line in the thread, dot included, is the owner's blue; its finish stays green
394
+ (docs/adr/2026-11-03-1551-planners-and-implementers-are-two-kinds-of-agent.md). On the card it takes the lane's colour below. */
395
+ .msg.builder-status.planner, .msg.agent.started.planner { color: var(--accent); }
396
396
  .planner .working-dot { background: currentColor; }
397
- /* 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
398
- Todo card the owner's blue, dot included; a Todo card's planner dashes are that blue, the running one pulsing
399
- (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md). */
397
+ /* A run's status line, dot included, takes its card's lane colour, a planner's too: grey in Todo, the phase-bar orange in
398
+ In Progress, blue in In Review; a Todo card's planner dashes are its grey, the running one pulsing
399
+ (docs/adr/2026-11-06-0145-every-run-takes-its-lanes-colour.md). */
400
+ .sub.agent.in-todo { color: var(--muted); }
400
401
  .sub.agent.in-progress { color: var(--color-notice); }
401
- .sub.agent.in-todo { color: var(--accent); }
402
- .sub.agent.in-progress .working-dot, .sub.agent.in-todo .working-dot { background: currentColor; }
402
+ .sub.agent.in-review { color: var(--color-info); }
403
+ .sub.agent.in-todo .working-dot, .sub.agent.in-progress .working-dot, .sub.agent.in-review .working-dot { background: currentColor; }
403
404
  /* docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md: a quiet builder and a request no planner
404
405
  has picked up are grey, after the lane colours so they win. */
405
406
  .sub.agent.quiet, .sub.asked { color: var(--muted); }
406
- .phase-bars.plan { --bar-fill: var(--accent); }
407
- @keyframes pulse-fill-plan { 50% { background-color: color-mix(in srgb, var(--accent) 35%, transparent); } }
408
- @media (prefers-reduced-motion: no-preference) { .phase-bars.plan:not(.dim) i.now { animation-name: pulse-fill-plan; } }
407
+ .sub.agent.waiting-gray { color: var(--muted); }
408
+ /* A stale build's status line is grey, its dot still (ui-cards.js builderStatus). */
409
+ .sub.agent.stale { color: var(--muted); }
410
+ .sub.agent.stale .working-dot { animation: none; }
411
+ /* The one class every paused status takes, gray: the card and thread line, the header, the hub banner. */
412
+ .paused { color: var(--muted); font-weight: 600; }
413
+ .phase-bars.in-todo { --bar-fill: var(--muted); }
409
414
  .msg.calls { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
410
415
  .msg.calls summary { cursor: pointer; color: var(--muted); list-style: none; }
411
416
  .msg.calls summary::-webkit-details-marker { display: none; }
412
417
  .msg.calls[open] summary .mark-icon { transform: rotate(90deg); }
418
+ .msg.calls.status-fold summary { color: var(--color-notice); overflow-wrap: anywhere; }
419
+ .msg.calls.status-fold.planner summary { color: var(--accent); }
420
+ /* Only a run's finished line is green: the fold's summary mirrors its last line, green once the run has finished, else the
421
+ started and status lines' orange or a planner's blue (docs/adr/2026-11-07-1600-only-a-runs-finished-line-is-green.md). */
422
+ .msg.calls.status-fold summary.finished { color: var(--color-success); }
413
423
  .msg.calls ol { margin: 4px 0; padding-left: 23px; }
414
424
  .agent-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: currentColor; margin-right: 5px; vertical-align: 1px; }
415
425
  /* Every thread status line's leading mark, icon or dot, sits centred in one 18px box, so the text after it always starts
@@ -426,7 +436,6 @@ button.sub.agent.stalled.nudging { color: var(--color-notice); cursor: default;
426
436
  .msg .reply { display: flex; flex-wrap: wrap; align-items: center; column-gap: 12px; row-gap: 2px; }
427
437
  .msg-foot .foot-status { display: inline-flex; align-items: center; }
428
438
  /* The thread's eye and fire are toggles: pressed reads as chosen, and the hint under the box says what Send posts. */
429
- form[data-reply] .tools { display: inline-flex; flex: none; }
430
439
  button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chip); }
431
440
  /* INT-30: card line order */
432
441
  .line6 { margin-top: 4px; }
@@ -444,17 +453,25 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
444
453
  .lane[open] > .list { border-radius: 0 0 10px 10px; }
445
454
  .lane > summary .count { color: var(--muted); font-weight: 400; }
446
455
  /* Each lane's sort icon and its menu, grey (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md) */
447
- /* INT-35: offline header */
448
- .view-board.offline #compose { display: none; }
449
- /* A board shared with someone else, view only (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md):
450
- shell.js marks the page .shared-view, which hides the +, every composer, Board settings, the architecture review and
451
- a card's buttons, keeping its links (Visit, Notes, Open questions). */
452
- .shared-view #compose, .shared-view [data-composer], .shared-view #settingsLink, .shared-view #archBulb,
453
- .shared-view .actions button, .shared-view button[data-confirm] { display: none !important; }
456
+ /* A board shared with someone else (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md):
457
+ shell.js marks the page .shared-view, which hides Board settings and the architecture review, and, unless the person
458
+ has Full on this board (.shared-full; docs/adr/2026-11-05-1912-a-shared-person-is-view-or-full.md), the +, every
459
+ composer and a card's buttons, keeping its links (Visit, Notes, Open questions). Merge shows disabled under Full. */
460
+ .shared-view :is(#settingsLink, #archBulb) { display: none !important; }
461
+ .shared-view:not(.shared-full) :is(#compose, [data-composer], .actions button, button[data-confirm]) { display: none !important; }
462
+ .shared-full button[data-confirm="merge"] { opacity: .45; pointer-events: none; }
454
463
  .view-only-strip { margin: 8px 0; padding: 8px 12px; border-radius: 10px; background: var(--accent-soft); font-size: 13px; }
455
464
  .view-only-strip b { color: var(--accent); }
456
- .view-board.offline main > * { opacity: .72; }
457
- .view-board.offline main, .view-board.offline > .top > :not(.nav), .view-board.offline > .top .nav > :not(.refresh) { pointer-events: none; }
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,
466
+ undimmed, and only the buttons that send, ui.js SENDS, are held, the labelled ones saying why; app-header.js stops their taps. */
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-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; }
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. */
469
+ .down-landing { min-height: 60vh; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 32px 24px; text-align: center; }
470
+ .down-landing-mark { display: block; margin-bottom: 8px; width: 70px; height: 60px; object-fit: contain; }
471
+ .down-landing-line { margin: 0; font-size: 20px; font-weight: 600; line-height: 1.3; text-wrap: balance; }
472
+ .down-landing-hint { margin: 0; font-size: 14px; color: var(--muted); }
473
+ .view-board.down main > :not(#downLanding), .view-board.down :is(#needsPane, #compose) { display: none; }
474
+ .view-settings.down .settings-body > :not(.down-landing) { display: none; }
458
475
  /* INT-28: answer banner */
459
476
  .answer-banner { display: flex; align-items: center; gap: 4px; background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
460
477
  border-radius: 10px; padding: 2px 4px 2px 0; margin-bottom: 12px; }
@@ -495,16 +512,26 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
495
512
  }
496
513
  @keyframes refresh-spin { to { transform: rotate(360deg); } }
497
514
  /* INT-55: the header's right group (the time then ↻, settings) sits one gap apart; each control paints disabled until app-header.js wires it */
498
- .top .nav { gap: 8px; }
515
+ .top .nav { gap: 2px; }
499
516
  .top .nav .refresh { flex-direction: row-reverse; gap: 2px; }
500
517
  .top .nav #refresh, .top .nav a.icon { display: inline-flex; align-items: center; justify-content: center; margin: 0; padding: 4px; border: 0; line-height: 1; }
501
518
  .top .nav #status { line-height: 1.1; white-space: nowrap; }
502
519
  .top .nav #refresh:disabled, .top .nav a.icon[aria-disabled="true"] { opacity: .35; pointer-events: none; }
503
- /* [the bulb runs an architecture review of the board's repo](docs/adr/2026-11-02-1813-the-bulb-runs-an-architecture-review-of-the-boards-repo.md): the bulb sits between refresh and the gear, and glows orange while an architecture review runs */
504
- .top .nav .arch-bulb { display: inline-flex; align-items: center; justify-content: center; margin: 0; padding: 4px; border: 0; line-height: 1; color: var(--muted); }
505
- .top .nav .arch-bulb:hover { color: var(--text); }
506
- .top .nav .arch-bulb[hidden] { display: none; }
507
- .top .nav .arch-bulb.running { color: var(--color-notice); }
520
+ /* The foot of the board (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md): the bulb and the gear, centred under the lanes.
521
+ [the bulb runs an architecture review of the board's repo](docs/adr/2026-11-02-1813-the-bulb-runs-an-architecture-review-of-the-boards-repo.md): it glows orange while an architecture review runs */
522
+ .board-foot { display: flex; justify-content: center; gap: 8px; margin: 20px 0 0; }
523
+ .board-foot .arch-bulb, .board-foot a.icon { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; margin: 0; padding: 4px; border: 0; line-height: 1; color: var(--muted); }
524
+ .board-foot :is(.arch-bulb, a.icon):hover { color: var(--text); }
525
+ .board-foot .arch-bulb[hidden] { display: none; }
526
+ .board-foot .arch-bulb.running { color: var(--color-notice); }
527
+ .board-foot a.icon[aria-disabled="true"] { opacity: .35; pointer-events: none; }
528
+ /* The pages switch below 900px: the board header's ? with the open-question count on its corner, and Waiting on you's check back to the board. */
529
+ .top .pages-switch { position: relative; display: inline-flex; align-items: center; justify-content: center; flex: none; width: auto; height: auto; margin: 0; padding: 4px; border: 0; border-radius: 0; line-height: 1; color: var(--muted); }
530
+ .top .pages-switch:hover { color: var(--text); }
531
+ .top .pages-switch:active { background: none; }
532
+ .pages-switch .q-badge { position: absolute; top: 0; right: -2px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: var(--color-notice); color: #1c1407;
533
+ font-size: 11px; font-weight: 700; line-height: 16px; text-align: center; box-shadow: 0 0 0 1.5px var(--bg); }
534
+ .pages-switch .q-badge[hidden] { display: none; }
508
535
  .arch-bulb.running svg { filter: drop-shadow(0 0 4px currentColor); }
509
536
  @media (prefers-reduced-motion: no-preference) { .arch-bulb.running svg { animation: arch-glow 1.4s ease-in-out infinite; } }
510
537
  @keyframes arch-glow { 50% { filter: drop-shadow(0 0 1px currentColor); opacity: .55; } }
@@ -514,8 +541,6 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
514
541
  .arch-last .muted { font-size: 12.5px; }
515
542
  .arch-body .arch-waits { color: var(--color-notice); }
516
543
  .arch-body .arch-run { width: 100%; min-height: 44px; font-size: 15px; }
517
- /* INT-64: offline, the header's time and refresh turn red */
518
- .view-board.offline .refresh #refresh, .refresh .link-state.offline { color: var(--color-danger); }
519
544
  .back { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 32px; height: 32px; color: var(--accent); text-decoration: none; }
520
545
  /* The lead slot, logo or back, is one 44px tap box on the page's left edge, so every page's header lines up the same
521
546
  (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md). The brand keeps that height when the rail hides the logo, so the
@@ -526,15 +551,10 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
526
551
  .top .hub + .back { margin-left: 0; }
527
552
  .top .back:active { background: var(--accent-soft); }
528
553
  .top .brand :is(h1, h2) { font-size: 17px; font-weight: 600; line-height: 22px; }
529
- /* The last visit paints dimmed until the fresh board lands; the controls ui-snapshot.js liveControls blocks rest meanwhile.
530
- A changed line, and a card that changed lanes, gets a blue border that fades once; an updated card, open body included, takes
531
- a blue tint that fades (app-snapshot.js, whose SIGNS holds each animation's length). */
554
+ /* Every change, a line, a moved card or an updated card, open body included, takes the one blue tint that fades
555
+ (app-snapshot.js highlight, whose TINT_MS is its length). */
532
556
  main > * { transition: opacity .25s ease; }
533
- .view-board[data-board="snapshot"]:not(.offline) main > * { opacity: .62; }
534
- .view-board[data-board="snapshot"] :is(.actions button, button[data-plan], button[data-bump], button[data-confirm="bump"], button[data-edit], button[data-withdraw], button[data-check-github], form[data-reply] button[type=submit], form[data-reply] button[data-quick], form[data-reply] button[data-proto], form[data-reply] button[data-confirm], .prompt button) { opacity: .45; pointer-events: none; }
535
- .flash { animation: snapshot-flash 1.6s ease-out var(--sign-delay, 0s) 1; border-radius: 6px; }
536
- @keyframes snapshot-flash { from { box-shadow: inset 0 0 0 2px var(--color-info); } to { box-shadow: inset 0 0 0 2px transparent; } }
537
- .item.tint { animation: card-tint 2.4s ease-out var(--sign-delay, 0s) 1; border-radius: 6px; }
557
+ .tint { animation: card-tint 2.4s ease-out var(--sign-delay, 0s) 1; border-radius: 6px; }
538
558
  @keyframes card-tint { 0%, 25% { background: var(--accent-soft); } 100% { background: transparent; } }
539
559
  .msg .quote { margin: 4px 0 6px; padding: 2px 0 2px 8px; border-left: 2px solid var(--line); color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; }
540
560
  /* [one board registry for every hub feature](docs/adr/2026-10-29-1639-one-board-registry-for-every-hub-feature.md): the GitHub mark opens the Hub links sheet, with a red dot while a link is broken; a new board's setup checklist */
@@ -565,6 +585,9 @@ main > * { transition: opacity .25s ease; }
565
585
  .view-settings .add input { flex: 1; min-width: 0; }
566
586
  .view-settings .add.pick { flex-wrap: wrap; }
567
587
  .view-settings .share-now { color: var(--ok); font-weight: 600; }
588
+ .view-settings .share-level { display: flex; gap: 4px; margin-top: 6px; }
589
+ .view-settings .share-level button { min-height: 32px; padding: 4px 12px; font-size: 13px; }
590
+ .view-settings .share-level button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 600; }
568
591
  .view-settings .share-now::before { content: ""; display: inline-block; width: 7px; height: 7px; margin-right: 5px; border-radius: 50%; background: currentColor; vertical-align: 1px; }
569
592
  .view-settings .share-reach { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0 4px; }
570
593
  .view-settings .share-reach p { flex: 1 1 220px; margin: 0; }
@@ -579,6 +602,7 @@ main > * { transition: opacity .25s ease; }
579
602
  .view-settings label.who .sub { display: block; margin-top: 2px; }
580
603
  .view-settings input.toggle { flex: none; width: 1.4rem; height: 1.4rem; margin: 0; accent-color: var(--accent); }
581
604
  .view-settings input.count { flex: none; width: 4.5em; text-align: center; }
605
+ .view-settings label.who select { flex: none; max-width: 45%; min-height: 36px; }
582
606
  /* A settings switch waiting on the server (ui.js waitOn) keeps its face, disabled, with a spinner beside it
583
607
  (docs/adr/2026-11-03-1921-board-settings-grant-a-boards-session-permissions.md) */
584
608
  .view-settings .toggle-slot { flex: none; display: inline-flex; align-items: center; gap: 8px; }
@@ -599,6 +623,14 @@ main > * { transition: opacity .25s ease; }
599
623
  .view-settings .close-board-warning:has(li) { border: 1px solid var(--color-danger); padding: 0 12px; border-radius: 10px; margin-bottom: 14px; }
600
624
  .view-questions .q { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin: 12px 0; }
601
625
  .view-questions .q.urgent { border-color: var(--color-notice); }
626
+ /* docs/adr/2026-11-05-2331-a-sent-answer-is-resolved-in-place-until-the-page-reloads.md
627
+ A requested question takes an accent tint, with its border drawn inside the card as well, so the card keeps its size.
628
+ A resolved question has no border or tint of its own; its Resolved badge marks it
629
+ (docs/adr/2026-11-06-1200-a-resolved-question-drops-its-green-border-and-tint.md). */
630
+ .view-questions .q.pr-busy { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 9%, var(--panel)); box-shadow: inset 0 0 0 1px var(--accent); }
631
+ .view-questions .q.pr-busy .opt { opacity: .55; cursor: default; }
632
+ .view-questions .q.pr-busy .opt:has(input:checked) { opacity: 1; }
633
+ .view-questions .q.resolved .sent-line { margin: 6px 0; font-size: 13px; color: var(--color-success); }
602
634
  .view-questions .q h3 { font-size: 15px; margin: 6px 0 4px; line-height: 1.3; }
603
635
  .view-questions .ctx { color: var(--muted); font-size: 13px; margin-bottom: 8px; }
604
636
  .view-questions .opt { display: grid; grid-template-columns: 22px 1fr; gap: 6px; padding: 8px; border: 1px solid var(--line);
@@ -620,20 +652,17 @@ main > * { transition: opacity .25s ease; }
620
652
  .view-questions .topics button { min-width: 0; border: 1px solid var(--line); border-radius: 999px; padding: 4px 11px; font-size: 13px;
621
653
  background: var(--panel); color: var(--text); white-space: normal; text-align: left; overflow-wrap: anywhere; }
622
654
  .view-questions .topics button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
655
+ .view-questions .topics button.progress { border-color: var(--line); background: color-mix(in srgb, var(--color-notice) 12%, var(--panel)); }
656
+ .view-questions .topics button.progress[aria-pressed="true"] { border-color: var(--accent); background: color-mix(in srgb, var(--color-notice) 12%, var(--accent-soft)); }
623
657
  @keyframes pill-arrive { from { opacity: 0; transform: translateX(-12px); box-shadow: 0 0 0 3px var(--accent); } to { opacity: 1; transform: none; box-shadow: 0 0 0 0 transparent; } }
624
658
  @media (prefers-reduced-motion: no-preference) { .view-questions .topics button.arrived { animation: pill-arrive .6s ease-out; } }
625
659
  .view-questions .topics .n { color: var(--muted); margin-left: 4px; }
626
660
  .view-questions .topichead { margin: 2px 0 6px; }
627
661
  .view-questions details.done > summary { cursor: pointer; color: var(--muted); font-size: 13px; margin: 16px 0 4px; }
628
- .view-questions details.done .trow { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
629
- .view-questions details.done button.ghost { font-size: 14px; text-align: left; padding: 6px 2px; }
630
- .view-questions details.done .trow button { min-width: 0; }
631
- .view-questions details.done .trow span { flex: none; }
632
662
  .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; }
633
663
  .view-questions details.resolved-group > summary::-webkit-details-marker { display: none; }
634
664
  .view-questions details.resolved-group > summary .caret { transition: transform .15s; }
635
665
  .view-questions details.resolved-group[open] > summary .caret { transform: rotate(90deg); }
636
- .view-questions details.resolved-group > summary .names { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
637
666
  .view-questions .q.resolved button.chip { cursor: pointer; font: inherit; font-size: 12px; min-height: 28px; }
638
667
  .view-questions .q.resolved button.chip:disabled { cursor: default; }
639
668
  .view-questions .q.resolved .resolved-line { margin: 8px 0; }
@@ -647,10 +676,12 @@ main > * { transition: opacity .25s ease; }
647
676
  .view-questions .bar .send-hint { margin-left: auto; }
648
677
  /* The + rides above the sticky Send bar rather than over its Send. */
649
678
  .view-questions .bar #qstatus { min-width: 0; }
650
- /* Typing with the keyboard up: one compact row docked on the keyboard, and the + out of the way (view-questions.js dock). */
651
- .view-questions.kb-up .bar { position: fixed; left: 0; right: 0; bottom: var(--kb, 0px); z-index: 7; padding: 6px 16px; box-shadow: 0 -6px 14px rgba(0,0,0,.12); }
652
- .view-questions.kb-up .bar .send-hint { display: none; }
653
679
  .view-questions .bar #qstatus.sent { color: var(--color-success); font-weight: 600; }
680
+ /* With no open question, the moon and its line sit at the centre of what the page shows between its top and the nav,
681
+ or of what the desktop pane has left under its Needs you lines. */
682
+ .view-questions .q-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: var(--muted); text-align: center;
683
+ min-height: calc(var(--vh, 100dvh) - var(--app-bottom, 0px) - max(16px, env(safe-area-inset-top))); }
684
+ .view-questions .q-empty p { margin: 0; font-size: 15px; }
654
685
  .view-questions .solo { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 4px 0; }
655
686
  /* docs/adr/2026-11-02-1332-a-prototype-round-ends-with-a-one-take-confirm.md */
656
687
  .view-questions .topichead { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; }
@@ -721,7 +752,7 @@ main > * { transition: opacity .25s ease; }
721
752
  position: fixed; top: auto; bottom: 0; left: 0; right: 0; z-index: 7;
722
753
  border-bottom: 0; border-top: 1px solid var(--line); padding-top: 4px; padding-bottom: max(4px, env(safe-area-inset-bottom));
723
754
  }
724
- .top .nav #refresh, .top .nav a.icon, .top .nav .arch-bulb { min-width: 44px; min-height: 44px; }
755
+ .top .nav #refresh, .top .nav a.icon, .top .pages-switch { min-width: 44px; min-height: 44px; }
725
756
  body > main, body.has-sidebar > :not(.sidebar) > main, body.has-sidebar > :not(.sidebar) > .split > main { padding-top: max(16px, env(safe-area-inset-top)); padding-bottom: calc(96px + var(--app-bottom, 0px) + env(safe-area-inset-bottom)); }
726
757
  .view-frame:not(.own-header) { padding-bottom: var(--app-bottom, 0px); }
727
758
  .sheet { bottom: max(0px, calc(var(--app-bottom, 0px) - var(--kb, 0px)));
@@ -730,6 +761,9 @@ main > * { transition: opacity .25s ease; }
730
761
  .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)))); }
731
762
  }
732
763
 
764
+ /* Typing, a text field focused (shell.js sets :root.typing): the floating + is disabled and the page stays as it is. */
765
+ :root.typing .fab { opacity: .35; pointer-events: none; }
766
+
733
767
  .settings-modal { position: fixed; inset: max(12px, env(safe-area-inset-top)) 0 calc(12px + max(var(--kb, 0px), var(--app-bottom, 0px))); margin: auto; width: min(680px, calc(100% - 24px));
734
768
  height: fit-content; max-width: none; max-height: max(120px, calc(var(--vh, 100dvh) - max(0px, calc(var(--app-bottom, 0px) - var(--kb, 0px))) - 24px - env(safe-area-inset-top)));
735
769
  padding: 0; border: 1px solid var(--line); border-radius: 18px; background: var(--panel); color: var(--text);
@@ -903,15 +937,24 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
903
937
  .deck-more { display: flex; align-items: center; justify-content: space-between; min-height: 46px; padding: 10px 14px; border-top: 1px solid color-mix(in srgb, var(--color-notice) 28%, var(--line));
904
938
  background: color-mix(in srgb, var(--color-notice) 14%, var(--panel)); color: var(--text); font-weight: 600; font-size: 14px; text-decoration: none; }
905
939
  .deck-more::after { content: '›'; color: var(--accent); font-size: 18px; }
940
+ .deck-more:hover { text-decoration: none; }
906
941
  @media (min-width: 900px) {
907
942
  .split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
908
943
  .split > main { max-width: none; margin: 0; }
909
944
  .needs-deck { display: none; }
945
+ /* Waiting on you is the board's pane here, so neither page shows the switch to the other. */
946
+ .top .pages-switch { display: none; }
947
+ .top .nav a.pages-switch { display: none; }
910
948
  /* The board's + sits at the bottom right of the left pane: the pane ends at the middle of the space right of the sidebar. */
911
949
  .view-board .fab { right: calc(50% - var(--sidebar-w, 0px) / 2 + var(--fab-edge)); }
912
950
  body.has-sidebar { --sidebar-w: 272px; }
913
951
  html.sb-collapsed body.has-sidebar { --sidebar-w: 0px; }
914
- .needs-pane { display: block; position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; overscroll-behavior: contain; padding: 12px 16px 24px; border-left: 1px solid var(--line); }
952
+ .needs-pane { display: block; position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; overscroll-behavior: contain; padding: 12px 16px 0; border-left: 1px solid var(--line); }
915
953
  .needs-pane .needs-questions main { padding: 0; max-width: none; }
954
+ .needs-pane:has(.q-empty), .needs-pane .needs-questions:has(.q-empty), .needs-pane .needs-questions:has(.q-empty) main, .needs-pane #page:has(.q-empty) { display: flex; flex-direction: column; }
955
+ .needs-pane .needs-questions:has(.q-empty), .needs-pane .needs-questions:has(.q-empty) main, .needs-pane #page:has(.q-empty), .needs-pane .q-empty { flex: 1; min-height: 0; }
916
956
  .needs-pane .waiting { margin: 0 0 12px; }
917
957
  }
958
+ /* 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. */
959
+ body .view-questions main { padding-bottom: var(--app-bottom, 0px); }
960
+ .msg.calls.status-fold summary .fold-log { margin-left: 6px; color: var(--accent); text-decoration: underline; }