@lovek4d/kickflip 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CONTEXT.md +42 -26
  2. package/SKILL.md +16 -12
  3. package/bin/doctor.mjs +5 -4
  4. package/bin/event-lines.mjs +1 -0
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +7 -7
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/agents.mjs +2 -2
  9. package/board/arch-review.mjs +1 -1
  10. package/board/bin/board.mjs +70 -42
  11. package/board/domain.mjs +7 -7
  12. package/board/finish.mjs +4 -4
  13. package/board/headless.mjs +8 -9
  14. package/board/onboarding.mjs +1 -1
  15. package/board/prototype-page.mjs +1 -1
  16. package/board/public/app-arch.js +2 -2
  17. package/board/public/app-banner.js +1 -1
  18. package/board/public/app-cards.js +36 -48
  19. package/board/public/app-lanes.js +6 -9
  20. package/board/public/app-needs.js +107 -26
  21. package/board/public/app-reader.js +52 -42
  22. package/board/public/app-threads.js +14 -10
  23. package/board/public/app.html +1 -1
  24. package/board/public/app.js +22 -12
  25. package/board/public/progress-review.js +4 -4
  26. package/board/public/prototype-kit.js +25 -11
  27. package/board/public/prototype-switcher.js +53 -33
  28. package/board/public/prototype.html +7 -4
  29. package/board/public/router.js +34 -6
  30. package/board/public/shell.js +23 -16
  31. package/board/public/sidebar.css +7 -2
  32. package/board/public/sidebar.js +3 -0
  33. package/board/public/style.css +69 -30
  34. package/board/public/ui-cards.js +61 -38
  35. package/board/public/ui-enums.js +12 -3
  36. package/board/public/ui-header.js +4 -6
  37. package/board/public/ui-hub.js +7 -4
  38. package/board/public/ui-links.js +1 -1
  39. package/board/public/ui-order.js +9 -6
  40. package/board/public/ui-page-header.js +1 -1
  41. package/board/public/ui-progress-bar.js +6 -7
  42. package/board/public/ui-questions.js +18 -3
  43. package/board/public/ui-sidebar.js +12 -5
  44. package/board/public/ui-snapshot.js +1 -1
  45. package/board/public/ui-threads.js +10 -12
  46. package/board/public/ui.js +7 -4
  47. package/board/public/view-hub.js +3 -1
  48. package/board/public/view-notes.js +1 -1
  49. package/board/public/view-questions.js +19 -3
  50. package/board/public/view-settings.js +60 -28
  51. package/board/server.mjs +19 -19
  52. package/board/stop.mjs +1 -1
  53. package/board/store.mjs +27 -25
  54. package/board/tickets.mjs +1 -1
  55. package/board/transcript.mjs +4 -2
  56. package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
  57. package/hub/front-door.mjs +1 -1
  58. package/hub/home.mjs +1 -1
  59. package/hub/hub.mjs +6 -6
  60. package/hub/sessions.mjs +2 -2
  61. package/hub/try.mjs +7 -7
  62. package/hub/upgrade.mjs +1 -1
  63. package/hub/usage.mjs +3 -3
  64. package/package.json +1 -1
  65. package/shared/card-prs.mjs +1 -1
  66. package/shared/machine-gate.mjs +2 -2
  67. package/shared/tailscale.mjs +1 -1
  68. package/shared/usage-gate.mjs +1 -1
@@ -3,7 +3,7 @@
3
3
  // ui.test.mjs runs them in node:vm. listen() catches clicks on links to a view the shell mounts, and back and forward.
4
4
  // A view is {kind, …}:
5
5
  // start `/`, which the shell turns into the last board
6
- // board {route, hash} /<route>/, hash such as #item=<key>&reply
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
9
  // notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<lineup slug>, and plan.html, which the board
@@ -45,6 +45,7 @@ globalThis.AppRouter = (function () {
45
45
  if (page === 'questions.html') {
46
46
  let id = null;
47
47
  try { id = u.hash.length > 1 ? decodeURIComponent(u.hash.slice(1)) : null; } catch { id = u.hash.slice(1); }
48
+ if (id?.startsWith('openItemsCard=')) id = id.slice('openItemsCard='.length);
48
49
  return { kind: 'questions', route, id };
49
50
  }
50
51
  if (page === 'notes.html' || page === 'plan.html') {
@@ -80,10 +81,10 @@ globalThis.AppRouter = (function () {
80
81
 
81
82
  // The page tree (docs/adr/2026-10-30-1125-back-follows-the-page-tree.md): a board, its Waiting on you, and a prototype page tied
82
83
  // to one of its questions by ?q=, framed. levelOf → 0, 1 or 2 down the tree, or null for a view outside it.
84
+ // A board's prototype page, which from 900px opens in the board's right pane (shell.js go).
85
+ const isPrototype = (view, boards) => view?.kind === 'frame' && boards.has(view.route) && /^\/[^/]+\/prototype-[\w-]+\.html$/.test(new URL(view.url, 'http://hub').pathname);
83
86
  function questionOf(view, boards) {
84
- if (view?.kind !== 'frame' || !boards.has(view.route)) return null;
85
- const u = new URL(view.url, 'http://hub');
86
- return /^\/[^/]+\/prototype-[\w-]+\.html$/.test(u.pathname) ? u.searchParams.get('q') || null : null;
87
+ return isPrototype(view, boards) ? new URL(view.url, 'http://hub').searchParams.get('q') || null : null;
87
88
  }
88
89
  // wide: from 900px Waiting on you is the board's pane, so a prototype's parent is the board itself.
89
90
  function levelOf(view, { boards = new Set(), wide = false } = {}) {
@@ -96,7 +97,7 @@ globalThis.AppRouter = (function () {
96
97
  function parentsOf(view, { boards = new Set(), card = null, wide = false } = {}) {
97
98
  const q = questionOf(view, boards);
98
99
  if (!q && view?.kind !== 'questions') return [];
99
- const board = { kind: 'board', route: view.route, hash: card ? `#item=${encodeURIComponent(card)}` : '' };
100
+ const board = { kind: 'board', route: view.route, hash: card ? `#selectedCard=${encodeURIComponent(card)}` : '' };
100
101
  return q && !wide ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
101
102
  }
102
103
 
@@ -224,5 +225,32 @@ globalThis.AppRouter = (function () {
224
225
  return { push: (href, from) => write('push', href, from), replace: (href) => write('replace', href), up };
225
226
  }
226
227
 
227
- return { viewOf, urlOf, mounted, entryOf, listen };
228
+ // A board's fragment is &-joined keys: selectedCard=<key> (the focused card), openItemsCard=<topic id> (the selected
229
+ // open-items pill), review=<PR number>, and a bare reply. The older #item=<key> reads as selectedCard.
230
+ // → {selectedCard, openItemsCard, review, reply}, each null or false when absent.
231
+ function fragmentOf(hash) {
232
+ const out = { selectedCard: null, openItemsCard: null, review: null, reply: false };
233
+ const decode = (v) => { try { return decodeURIComponent(v); } catch { return v; } };
234
+ for (const part of String(hash).replace(/^#/, '').split('&')) {
235
+ const eq = part.indexOf('=');
236
+ const [k, v] = eq < 0 ? [part, ''] : [part.slice(0, eq), part.slice(eq + 1)];
237
+ if (k === 'selectedCard' || k === 'item') out.selectedCard = decode(v) || null;
238
+ else if (k === 'openItemsCard') out.openItemsCard = decode(v) || null;
239
+ else if (k === 'review' && /^\d+$/.test(v)) out.review = Number(v);
240
+ else if (k === 'reply') out.reply = true;
241
+ }
242
+ return out;
243
+ }
244
+ // hash with the named keys of changes replaced (null drops one) → the new fragment, '' for none. A #review= link is
245
+ // alone in its fragment, so a change to a card or pill replaces it; reply is spent once the card is shown.
246
+ function hashWith(hash, changes) {
247
+ const now = fragmentOf(hash);
248
+ const next = { selectedCard: now.selectedCard, openItemsCard: now.openItemsCard, ...changes };
249
+ const parts = [];
250
+ if (next.selectedCard) parts.push(`selectedCard=${encodeURIComponent(next.selectedCard)}`);
251
+ if (next.openItemsCard) parts.push(`openItemsCard=${encodeURIComponent(next.openItemsCard)}`);
252
+ return parts.length ? '#' + parts.join('&') : '';
253
+ }
254
+
255
+ return { viewOf, urlOf, mounted, entryOf, isPrototype, listen, fragmentOf, hashWith };
228
256
  })();
@@ -35,6 +35,10 @@ window.AppShell = (function () {
35
35
  channel.port1.onmessage = done;
36
36
  try { controller.postMessage({ forget: 'shell' }, [channel.port2]); } catch { done(); }
37
37
  });
38
+ // Only this reload starts at the top: iOS put the old offset back once the page had grown and left that area unpainted until a scroll
39
+ // (docs/adr/2026-11-06-1100-the-shell-turns-off-scroll-restoration.md). pagehide gives ordinary reloads their restore back.
40
+ history.scrollRestoration = 'manual';
41
+ addEventListener('pagehide', () => { history.scrollRestoration = 'auto'; }, { once: true });
38
42
  setTimeout(() => location.reload(), 0);
39
43
  }
40
44
 
@@ -44,9 +48,6 @@ window.AppShell = (function () {
44
48
  }
45
49
 
46
50
  async function start() {
47
- // The shell sends every view to the top itself, so the browser's own scroll restore is off: after a reload iOS put the old
48
- // offset back once the page had grown, and left that area unpainted until a scroll (docs/adr/2026-11-06-1100-the-shell-turns-off-scroll-restoration.md).
49
- history.scrollRestoration = 'manual';
50
51
  // A frame that lands on a view's URL, such as a framed page's link back to its card, shows it in the shell around it.
51
52
  if (outer()?.open(location.href, { frames: false })) return;
52
53
  // The same URL and scope as hub-kit.js's push registration, so the one registration and its push subscription stay.
@@ -245,24 +246,30 @@ window.AppShell = (function () {
245
246
  // Board and Waiting on you are a board's two pages (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md):
246
247
  // from 900px the board shows Waiting on you in its pane, so every view of the questions page, a link, a push, a
247
248
  // hand-off, a history entry or a cold load, shows the board at its own URL and opens the question's topic there (id,
248
- // or null for none). A board just shown takes a moment to fill its pane, so the pane is asked again until it has.
249
- function focusPane(id, tries = 50) {
250
- if (current?.view.kind !== 'board' || current.v.B.focusQuestion?.(id) || tries <= 0) return;
251
- setTimeout(() => focusPane(id, tries - 1), 100);
249
+ // or null for none). A board's prototype page does the same and opens in the pane
250
+ // (docs/adr/2026-11-07-2030-on-desktop-the-prototype-viewer-opens-in-the-right-pane.md).
251
+ const inPane = (view) => wide.matches && (view?.kind === 'questions' || AppRouter.isPrototype(view, boards()));
252
+ // ask(B) → true once the board's pane did what the view asks. A board just shown takes a moment to fill its pane, so
253
+ // the pane is asked again until it has.
254
+ const paneAsk = (view, href) => (view.kind === 'questions' ? (B) => B.focusQuestion?.(view.id) : (B) => B.openViewer?.(href));
255
+ function askPane(ask, tries = 50) {
256
+ if (current?.view.kind !== 'board' || ask(current.v.B) || tries <= 0) return;
257
+ setTimeout(() => askPane(ask, tries - 1), 100);
252
258
  }
253
259
  function go(view, href, how, from) {
254
260
  if (!AppRouter.mounted(view)) return false;
255
- if (view.kind === 'questions' && wide.matches) {
261
+ if (inPane(view)) {
262
+ const ask = paneAsk(view, href);
256
263
  const board = { kind: 'board', route: view.route, hash: '' };
257
264
  const boardHref = new URL(AppRouter.urlOf(board), location.href).href;
258
265
  if (how === 'pop') history.replaceState(history.state, '', boardHref);
259
- // On its own board it only opens the topic, so the URL, history and an open card stay as they are.
266
+ // On its own board it only asks the pane, so the URL, history and an open card stay as they are.
260
267
  else if (current?.view.kind === 'board' && current.route === view.route && !settings) {
261
- focusPane(view.id);
268
+ askPane(ask);
262
269
  sidebar.close();
263
270
  return true;
264
271
  }
265
- return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown) focusPane(view.id); return shown; });
272
+ return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown) askPane(ask); return shown; });
266
273
  }
267
274
  if (view.kind === 'settings') {
268
275
  if (current?.view.kind === 'board' && current.route === view.route) {
@@ -316,15 +323,15 @@ window.AppShell = (function () {
316
323
  if (!AppRouter.mounted(first)) { location.replace('/_hub/'); return; }
317
324
 
318
325
  let pane = null;
319
- if (first.kind === 'questions' && wide.matches) {
320
- pane = { id: first.id };
326
+ if (inPane(first)) {
327
+ pane = paneAsk(first, location.href);
321
328
  first = { kind: 'board', route: first.route, hash: '' };
322
329
  history.replaceState(history.state, '', AppRouter.urlOf(first));
323
330
  }
324
331
  router = AppRouter.listen({ boards, go, wide: () => wide.matches });
325
- // Widening past 900px on Waiting on you swaps to the board with that topic open in the pane.
332
+ // Widening past 900px on Waiting on you or a prototype swaps to the board with it open in the pane.
326
333
  wide.addEventListener('change', () => {
327
- if (wide.matches && current?.view.kind === 'questions') go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'replace');
334
+ if (current && inPane(current.view)) go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'replace');
328
335
  });
329
336
  opener = (url, { replace = false, frames = true } = {}) => {
330
337
  const view = AppRouter.viewOf(url, { boards: boards() });
@@ -341,7 +348,7 @@ window.AppShell = (function () {
341
348
  navigator.serviceWorker?.startMessages?.();
342
349
 
343
350
  show(first);
344
- if (pane) focusPane(pane.id);
351
+ if (pane) askPane(pane);
345
352
  openStream();
346
353
  addEventListener('visibilitychange', () => { if (!document.hidden) openStream(); });
347
354
  addEventListener('pageshow', openStream);
@@ -41,6 +41,11 @@
41
41
  html.sb-update body.has-sidebar > .top .hub, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
42
42
  html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub::after {
43
43
  content: ''; position: absolute; width: 10px; height: 10px; top: 3px; right: 3px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg); }
44
+ /* A busy hub gets an orange dot on the logo and a broken one a red dot, in the update dot's place (red, then orange, then blue). */
45
+ html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > .top .hub, html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
46
+ html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > .top .hub::after, html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > :not(.sidebar) > .top .hub::after {
47
+ content: ''; position: absolute; width: 10px; height: 10px; top: 3px; right: 3px; border-radius: 50%; background: var(--color-notice); box-shadow: 0 0 0 2px var(--bg); }
48
+ html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-sidebar > :not(.sidebar) > .top .hub::after { background: var(--color-danger); }
44
49
  .sidebar .sb-text { flex: 1; min-width: 0; }
45
50
  .sidebar .sb-title { display: block; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
46
51
  .sidebar .sb-moon { display: inline-block; margin-left: 5px; color: var(--muted); vertical-align: -1px; }
@@ -168,12 +173,12 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
168
173
  .sidebar .sb-ctx.stale { opacity: .6; }
169
174
  .sidebar .lcs { order: -1; flex: 1 1 100%; min-width: 0; display: flex; flex-wrap: wrap; gap: 4px; }
170
175
  .sidebar .lc { flex: none; font-size: 11px; font-weight: 600; border-radius: 6px; padding: 0 6px; line-height: 18px; color: var(--tone); background: color-mix(in srgb, var(--tone) 14%, transparent); }
171
- .sidebar .lc-review { --tone: var(--accent); } .sidebar .lc-progress { --tone: var(--color-notice); } .sidebar .lc-todo { --tone: var(--muted); }
176
+ .sidebar .lc-needs { --tone: var(--color-danger); } .sidebar .lc-review { --tone: var(--accent); } .sidebar .lc-progress { --tone: var(--color-notice); } .sidebar .lc-todo { --tone: var(--muted); }
172
177
  .sidebar .dot { flex: none; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--tone); }
173
178
  @keyframes sb-pulse { 50% { opacity: .35; } }
174
179
  @media (prefers-reduced-motion: no-preference) { .sidebar .dot.pulse { animation: sb-pulse 1.4s ease-in-out infinite; } }
175
180
  .sidebar .word { flex: none; font-weight: 600; color: var(--tone); }
176
- .sidebar .t-down, .sidebar .t-needs { --tone: var(--color-danger); }
181
+ .sidebar .t-down { --tone: var(--color-danger); }
177
182
  .sidebar .t-starting { --tone: var(--color-notice); }
178
183
  .sidebar .t-stopped, .sidebar .t-asleep { --tone: var(--muted); }
179
184
  .sidebar .t-working { --tone: var(--color-notice); }
@@ -102,6 +102,9 @@
102
102
  // A repaint keeps the drawer's scroll, so a new copy doesn't move the list under a finger.
103
103
  function paint() {
104
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');
105
108
  if (!data) return;
106
109
  const scroll = aside.querySelector('.sb-scroll');
107
110
  const top = painted ? scroll?.scrollTop : undefined;
@@ -84,8 +84,17 @@ button.chip { border: 0; }
84
84
  .sub.next { color: var(--color-notice); }
85
85
  .card-hold { color: var(--color-quiet); }
86
86
  .card-blocked, .card-action { color: var(--color-notice); }
87
- .card-ready { color: var(--color-success); }
88
- .card-staging { color: var(--color-notice); }
87
+ /* docs/adr/2026-11-11-1200-every-lane-has-phases.md: a card's phase word, toned by who acts next (ui-cards.js PHASE_LOOK). */
88
+ .card-phase.ph-quiet { color: var(--color-quiet); }
89
+ .card-phase.ph-info { color: var(--color-info); }
90
+ .card-phase.ph-notice { color: var(--color-notice); }
91
+ .card-phase.ph-success { color: var(--color-success); }
92
+ .card-phase.ph-merged { color: var(--color-merged); }
93
+ /* The footer status of a stale card, and of one the orchestrator stalled (ui-cards.js timeLine). */
94
+ .card-stale { color: var(--muted); }
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); }
89
98
  .sub.next.idle { color: var(--muted); }
90
99
  .sub.refused { color: var(--color-danger); }
91
100
  .toast-stack { position: fixed; left: 50%; bottom: calc(var(--app-bottom, 0px) + 12px + env(safe-area-inset-bottom));
@@ -197,7 +206,6 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
197
206
  .lane-github { margin-left: auto; font-size: 12px; font-weight: 400; color: var(--muted); text-align: right; }
198
207
  .lane-github .late { color: var(--color-notice); }
199
208
  .msg.other { background: transparent; border: 1px solid var(--line); }
200
- .status.stale { border-color: var(--color-danger); background: conic-gradient(var(--color-danger) 0 75%, transparent 75% 100%); }
201
209
  .msg .reply, .msg .small, .rep, .sent { overflow-wrap: anywhere; }
202
210
  code { overflow-wrap: anywhere; }
203
211
  /* iOS Safari zooms the page when a field under 16px takes focus, which shifts the page width. */
@@ -376,29 +384,19 @@ button.attach:disabled { opacity: .35; }
376
384
  @keyframes pulse-fill { 50% { background-color: color-mix(in srgb, var(--bar-fill) 35%, transparent); } }
377
385
  /* Every pulse beats on one clock (ui-progress-bar.js onePulse), so a card's running bar and its status dot blink together. */
378
386
  @media (prefers-reduced-motion: no-preference) { .phase-bars i.now { animation: pulse-fill 1.4s ease-in-out infinite; } }
379
- /* A stale build's status line, dot included, is the needs-you red, and a tap on it nudges Claude (ui-cards.js builderStatus). */
380
- /* 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
381
- 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
382
- drops below the bump icons' float instead of sitting beside it as the div's line box does (board/layout.test.mjs). */
383
- button.sub { display: block; margin-inline: 0; margin-bottom: 0; padding: 0; min-height: 0; border: 0; border-radius: 0; background: none;
384
- font-family: inherit; font-weight: inherit; letter-spacing: inherit; line-height: inherit; text-align: left; appearance: none; -webkit-appearance: none; }
385
- button.sub.agent.stalled { color: var(--color-danger); }
386
- /* 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). */
387
- button.sub.agent.stalled.nudging { color: var(--color-notice); cursor: default; }
388
- .nudging .working-dot { position: relative; }
389
- .nudging .working-dot::after { content: ''; position: absolute; inset: -1px; border-radius: 50%; border: 1.5px solid currentColor; opacity: 0; }
390
- @keyframes nudge-ping { from { transform: scale(1); opacity: .9; } to { transform: scale(2.6); opacity: 0; } }
391
- @media (prefers-reduced-motion: no-preference) { .nudging .working-dot::after { animation: nudge-ping 1.1s ease-out infinite; } }
392
- .stalled .working-dot { background: currentColor; }
393
387
  .brand { display: grid; grid-template-columns: auto auto minmax(0, 1fr); column-gap: 10px; row-gap: 2px; }
394
388
  .brand .hub { grid-row: 1 / span 2; align-self: center; }
395
389
  /* 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. */
396
390
  .thinking { color: var(--accent); font-size: 12px; font-weight: 600; }
397
391
  .thinking .working-dot { background: currentColor; }
392
+ /* A New card's "waiting", until the orchestrator picks it up: grey, its dot still. */
393
+ .pickup-wait { color: var(--muted); font-size: 12px; font-weight: 600; }
394
+ .pickup-wait .working-dot { background: currentColor; animation: none; }
398
395
  .msg.link { padding: 0 10px; margin: 4px 0; font-size: 13px; }
399
396
  .msg.builder-status { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--color-notice); overflow-wrap: anywhere; }
400
397
  .msg.builder-status .working-dot { animation: none; }
401
398
  .msg.request { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
399
+ .msg.request.readied { color: var(--color-success); }
402
400
  .msg.agent { padding: 0 10px; margin: 4px 0; font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
403
401
  .msg.agent.started { color: var(--color-notice); } .msg.agent.finished { color: var(--color-success); }
404
402
  .msg.agent .muted { font-weight: 400; }
@@ -417,9 +415,11 @@ button.sub.agent.stalled.nudging { color: var(--color-notice); cursor: default;
417
415
  has picked up are grey, after the lane colours so they win. */
418
416
  .sub.agent.quiet, .sub.asked { color: var(--muted); }
419
417
  .sub.agent.waiting-gray { color: var(--muted); }
418
+ /* A stale build's status line is grey, its dot still (ui-cards.js builderStatus). */
419
+ .sub.agent.stale { color: var(--muted); }
420
+ .sub.agent.stale .working-dot { animation: none; }
420
421
  /* The one class every paused status takes, gray: the card and thread line, the header, the hub banner. */
421
422
  .paused { color: var(--muted); font-weight: 600; }
422
- .phase-bars.in-todo { --bar-fill: var(--muted); }
423
423
  .msg.calls { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
424
424
  .msg.calls summary { cursor: pointer; color: var(--muted); list-style: none; }
425
425
  .msg.calls summary::-webkit-details-marker { display: none; }
@@ -521,7 +521,7 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
521
521
  }
522
522
  @keyframes refresh-spin { to { transform: rotate(360deg); } }
523
523
  /* 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 */
524
- .top .nav { gap: 8px; }
524
+ .top .nav { gap: 2px; }
525
525
  .top .nav .refresh { flex-direction: row-reverse; gap: 2px; }
526
526
  .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; }
527
527
  .top .nav #status { line-height: 1.1; white-space: nowrap; }
@@ -592,7 +592,7 @@ main > * { transition: opacity .25s ease; }
592
592
  .view-settings .who.new { border-color: var(--accent); }
593
593
  .view-settings .add { display: flex; gap: 6px; margin-top: 8px; }
594
594
  .view-settings .add input { flex: 1; min-width: 0; }
595
- .view-settings .add.pick { flex-wrap: wrap; }
595
+ .view-settings .add.pick { flex-direction: column; }
596
596
  .view-settings .share-now { color: var(--ok); font-weight: 600; }
597
597
  .view-settings .share-level { display: flex; gap: 4px; margin-top: 6px; }
598
598
  .view-settings .share-level button { min-height: 32px; padding: 4px 12px; font-size: 13px; }
@@ -601,7 +601,11 @@ main > * { transition: opacity .25s ease; }
601
601
  .view-settings .share-reach { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0 4px; }
602
602
  .view-settings .share-reach p { flex: 1 1 220px; margin: 0; }
603
603
  .view-settings .share-reach button { flex: none; }
604
- .view-settings .pick-col { flex: 1 1 140px; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
604
+ .view-settings .login-row { display: flex; gap: 6px; }
605
+ .view-settings .login-row input { flex: 1; min-width: 0; }
606
+ .view-settings .chip { display: inline-flex; align-items: center; align-self: flex-start; gap: 6px; padding: 4px 6px 4px 12px; border-radius: 14px; border: 1px solid var(--accent); font-size: 14px; }
607
+ .view-settings .chip button { padding: 0 6px; min-height: 0; }
608
+ .view-settings .problem { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 9px 10px; border: 1px dashed var(--line); border-radius: 9px; }
605
609
  .view-settings .pick-list { max-height: 160px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
606
610
  .view-settings .pick-list button { text-align: left; }
607
611
  .view-settings .pick-list button[aria-pressed="true"] { border-color: var(--accent); }
@@ -633,12 +637,12 @@ main > * { transition: opacity .25s ease; }
633
637
  .view-questions .q { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin: 12px 0; }
634
638
  .view-questions .q.urgent { border-color: var(--color-notice); }
635
639
  /* docs/adr/2026-11-05-2331-a-sent-answer-is-resolved-in-place-until-the-page-reloads.md
636
- A requested question takes an accent tint and a sent or resolved one a green tint, each with its border drawn inside
637
- the card as well, so the card keeps its size. */
640
+ A requested question takes an accent tint, with its border drawn inside the card as well, so the card keeps its size.
641
+ A resolved question has no border or tint of its own; its Resolved badge marks it
642
+ (docs/adr/2026-11-06-1200-a-resolved-question-drops-its-green-border-and-tint.md). */
638
643
  .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); }
639
644
  .view-questions .q.pr-busy .opt { opacity: .55; cursor: default; }
640
645
  .view-questions .q.pr-busy .opt:has(input:checked) { opacity: 1; }
641
- .view-questions .q.resolved { border-color: var(--color-success); background: color-mix(in srgb, var(--color-success) 9%, var(--panel)); box-shadow: inset 0 0 0 1px var(--color-success); }
642
646
  .view-questions .q.resolved .sent-line { margin: 6px 0; font-size: 13px; color: var(--color-success); }
643
647
  .view-questions .q h3 { font-size: 15px; margin: 6px 0 4px; line-height: 1.3; }
644
648
  .view-questions .ctx { color: var(--muted); font-size: 13px; margin-bottom: 8px; }
@@ -659,7 +663,8 @@ main > * { transition: opacity .25s ease; }
659
663
  .view-questions .q details > summary { cursor: pointer; color: var(--muted); font-size: 13px; }
660
664
  .view-questions .topics { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 0 8px; margin-bottom: 4px; }
661
665
  .view-questions .topics button { min-width: 0; border: 1px solid var(--line); border-radius: 999px; padding: 4px 11px; font-size: 13px;
662
- background: var(--panel); color: var(--text); white-space: normal; text-align: left; overflow-wrap: anywhere; }
666
+ background: var(--panel); color: var(--text); display: inline-flex; max-width: 100%; text-align: left; }
667
+ .view-questions .topics .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
663
668
  .view-questions .topics button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
664
669
  .view-questions .topics button.progress { border-color: var(--line); background: color-mix(in srgb, var(--color-notice) 12%, var(--panel)); }
665
670
  .view-questions .topics button.progress[aria-pressed="true"] { border-color: var(--accent); background: color-mix(in srgb, var(--color-notice) 12%, var(--accent-soft)); }
@@ -680,7 +685,7 @@ main > * { transition: opacity .25s ease; }
680
685
  .view-questions .q.resolved .opt:has(input:checked) .det { display: block; }
681
686
  .view-questions .q.resolved .opt:not(:has(input:checked)) { opacity: .6; }
682
687
  .view-questions .q.resolved .reask-line { margin-top: 10px; }
683
- .view-questions .bar { position: sticky; bottom: var(--app-bottom, 0px); padding: 10px 0 max(10px, env(safe-area-inset-bottom)); background: var(--bg);
688
+ .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;
684
689
  display: flex; justify-content: space-between; align-items: center; gap: 8px; border-top: 1px solid var(--line); }
685
690
  .view-questions .bar .send-hint { margin-left: auto; }
686
691
  /* The + rides above the sticky Send bar rather than over its Send. */
@@ -757,6 +762,8 @@ main > * { transition: opacity .25s ease; }
757
762
  :root:has(body > .top, body.has-sidebar > :not(.sidebar) > .top) {
758
763
  --app-bottom: calc(49px + max(4px, env(safe-area-inset-bottom)));
759
764
  }
765
+ /* The nav under the bar already pads the safe area (--app-bottom), so the gap under Send stays the 10px above it. */
766
+ .view-questions .bar { padding-bottom: 10px; }
760
767
  body > .top, body.has-sidebar > :not(.sidebar) > .top {
761
768
  position: fixed; top: auto; bottom: 0; left: 0; right: 0; z-index: 7;
762
769
  border-bottom: 0; border-top: 1px solid var(--line); padding-top: 4px; padding-bottom: max(4px, env(safe-area-inset-bottom));
@@ -770,8 +777,14 @@ main > * { transition: opacity .25s ease; }
770
777
  .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)))); }
771
778
  }
772
779
 
773
- /* Typing, a text field focused (shell.js sets :root.typing): the floating + is disabled and the page stays as it is. */
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. */
774
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. */
783
+ @media (max-width: 699px) {
784
+ :root.typing .fab { display: none; }
785
+ /* The board page bottom bar hides too; --app-bottom stays, so the page does not move. */
786
+ :root.typing body > .top, :root.typing body.has-sidebar > :not(.sidebar) > .top { display: none; }
787
+ }
775
788
 
776
789
  .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));
777
790
  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)));
@@ -885,6 +898,9 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
885
898
  .reader-row.sys, .reader-row.out { color: var(--muted); font-size: 12.5px; }
886
899
  .reader-row.out { padding-left: 28px; font: 11.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
887
900
  .reader-row.tool { font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
901
+ .reader-row.call { cursor: pointer; }
902
+ .reader-caret { display: inline-block; width: 10px; margin-right: 4px; color: var(--muted); }
903
+ .reader-out { margin: 2px 14px 4px 28px; padding: 6px 8px; border-left: 2px solid var(--line); color: var(--muted); font: 11.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
888
904
  .reader-tag { display: inline-block; margin-right: 6px; padding: 0 6px; border-radius: 6px; background: var(--chip); color: var(--accent); font-size: 11px; }
889
905
  .reader-raw, .reader-text { margin: 0; padding: 2px 14px 8px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
890
906
  .reader-md { padding: 4px 16px 12px; overflow-wrap: anywhere; line-height: 1.5; }
@@ -933,13 +949,14 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
933
949
  .deck-opts { display: flex; flex-direction: column; gap: 6px; }
934
950
  .deck-opts button { text-align: left; border-radius: 10px; padding: 10px 12px; font-size: 14px; }
935
951
  .deck-opts button.rec { border-color: var(--accent); }
952
+ .deck-opts button.armed { background: var(--color-notice); border-color: var(--color-notice); color: #000; font-weight: 600; }
936
953
  .deck-opts .rec-word { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
937
954
  .deck-opts small { display: block; color: var(--muted); font-size: 12.5px; }
938
955
  .deck-prompt { margin: 0; padding: 0; list-style: none; }
939
956
  .deck-prompt li.prompt { padding: 8px 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
940
957
  .deck-acts { display: flex; flex-wrap: wrap; gap: 8px; }
941
958
  .deck-bar { display: flex; align-items: center; gap: 10px; padding: 0 14px 10px; }
942
- .deck-dots { flex: 1; display: flex; flex-wrap: wrap; gap: 5px; }
959
+ .deck-dots { flex: 1; display: flex; flex-wrap: wrap; justify-content: center; gap: 5px; }
943
960
  .deck-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--line); }
944
961
  .deck-dots i.on { background: var(--color-notice); }
945
962
  .deck-bar button { border-radius: 999px; padding: 5px 12px; font-size: 13px; }
@@ -953,16 +970,38 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
953
970
  .needs-deck { display: none; }
954
971
  /* Waiting on you is the board's pane here, so neither page shows the switch to the other. */
955
972
  .top .pages-switch { display: none; }
973
+ .top .nav a.pages-switch { display: none; }
956
974
  /* 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. */
957
975
  .view-board .fab { right: calc(50% - var(--sidebar-w, 0px) / 2 + var(--fab-edge)); }
958
976
  body.has-sidebar { --sidebar-w: 272px; }
959
977
  html.sb-collapsed body.has-sidebar { --sidebar-w: 0px; }
960
- .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); }
978
+ .needs-pane { display: block; position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 12px 16px 0; border-left: 1px solid var(--line); }
961
979
  .needs-pane .needs-questions main { padding: 0; max-width: none; }
962
980
  .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; }
963
981
  .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; }
964
982
  .needs-pane .waiting { margin: 0 0 12px; }
983
+ .needs-pane * { min-width: 0; overflow-wrap: anywhere; }
984
+ .needs-pane pre, .needs-pane code { white-space: pre-wrap; }
985
+ .needs-pane :is(.deck-opts, .row, .actions, .btns) { flex-wrap: wrap; }
986
+ /* A prototype in the pane (app-needs.js openViewer): a top bar over the framed page, whose footer ends the pane; ⤢
987
+ (.widened) lays the pane over the lanes, from the sidebar's edge. */
988
+ .needs-pane.viewing { display: flex; flex-direction: column; padding: 0; overflow: hidden; }
989
+ .needs-pane.viewing > :not(.pane-viewer) { display: none; }
990
+ .needs-pane.viewing.widened { position: fixed; top: 56px; left: var(--sidebar-w, 0px); right: 0; bottom: 0; height: auto; z-index: 7; border-left: 0; background: var(--bg); }
991
+ .pane-viewer { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
992
+ .pv-top { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--panel); }
993
+ .pv-top .pv-grow { flex: 1; }
994
+ .pv-top button { min-height: 36px; border-radius: 8px; font-weight: 600; }
995
+ .pv-top .pv-back { border: 0; background: transparent; color: var(--text); padding: 6px 8px; }
996
+ .pv-top .pv-back:hover { background: var(--line); }
997
+ .pv-top .pv-widen { width: 36px; padding: 0; font-size: 17px; }
998
+ .pv-top .pv-widen[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
999
+ .pv-top .ps-chip { border-radius: 999px; padding: 0 14px; }
1000
+ .pv-frame { flex: 1; min-height: 0; width: 100%; border: 0; display: block; background: var(--panel); }
1001
+ .pane-viewer .ps-sheet { position: absolute; left: 8px; right: 8px; top: 56px; z-index: 2; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); box-shadow: 0 8px 30px rgba(0,0,0,.25); }
1002
+ .pane-viewer .ps-sheet h3 { margin: 0 0 6px; font-size: 14px; }
1003
+ .pane-viewer .ps-sheet button { display: flex; justify-content: space-between; gap: 12px; width: 100%; border: 0; border-top: 1px solid var(--line); border-radius: 0; background: transparent; color: var(--text); padding: 10px 2px; text-align: left; }
1004
+ .pane-viewer .ps-sheet button span + span { color: var(--muted); }
965
1005
  }
966
1006
  /* 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. */
967
1007
  body .view-questions main { padding-bottom: var(--app-bottom, 0px); }
968
- .msg.calls.status-fold summary .fold-log { margin-left: 6px; color: var(--accent); text-decoration: underline; }