@lovek4d/kickflip 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CONTEXT.md +69 -37
  2. package/SKILL.md +19 -13
  3. package/bin/kickflip.mjs +12 -12
  4. package/bin/wait-events.mjs +2 -2
  5. package/board/agents.mjs +2 -2
  6. package/board/arch-review.mjs +4 -4
  7. package/board/bin/board.mjs +156 -83
  8. package/board/build-script.mjs +2 -1
  9. package/board/domain.mjs +9 -7
  10. package/board/finish.mjs +4 -4
  11. package/board/headless.mjs +9 -5
  12. package/board/onboarding.mjs +11 -0
  13. package/board/prototype-base.mjs +1 -0
  14. package/board/prototype-page.mjs +1 -0
  15. package/board/public/app-arch.js +1 -1
  16. package/board/public/app-cards.js +27 -13
  17. package/board/public/app-header.js +6 -7
  18. package/board/public/app-lanes.js +15 -38
  19. package/board/public/app-needs.js +36 -31
  20. package/board/public/app-snapshot.js +43 -70
  21. package/board/public/app-threads.js +38 -46
  22. package/board/public/app.html +9 -4
  23. package/board/public/app.js +78 -32
  24. package/board/public/hub-kit.js +62 -2
  25. package/board/public/hub.css +2 -0
  26. package/board/public/new-task.js +8 -6
  27. package/board/public/progress-review.html +46 -0
  28. package/board/public/progress-review.js +158 -0
  29. package/board/public/prototype-kit.js +109 -4
  30. package/board/public/prototype-switcher.js +123 -29
  31. package/board/public/prototype.html +57 -3
  32. package/board/public/router.js +7 -6
  33. package/board/public/shell.js +64 -24
  34. package/board/public/sidebar.css +1 -0
  35. package/board/public/sidebar.js +16 -69
  36. package/board/public/style.css +108 -57
  37. package/board/public/ui-cards.js +61 -46
  38. package/board/public/ui-enums.js +1 -1
  39. package/board/public/ui-header.js +25 -16
  40. package/board/public/ui-hub.js +12 -1
  41. package/board/public/ui-links.js +7 -7
  42. package/board/public/ui-page-header.js +19 -7
  43. package/board/public/ui-patch.js +7 -33
  44. package/board/public/ui-progress-bar.js +28 -0
  45. package/board/public/ui-questions.js +25 -17
  46. package/board/public/ui-sidebar.js +25 -23
  47. package/board/public/ui-snapshot.js +43 -52
  48. package/board/public/ui-threads.js +57 -28
  49. package/board/public/ui.js +92 -17
  50. package/board/public/view-hub-settings.js +12 -58
  51. package/board/public/view-hub.js +2 -1
  52. package/board/public/view-notes.js +27 -7
  53. package/board/public/view-questions.js +90 -68
  54. package/board/public/view-settings.js +133 -15
  55. package/board/render-check.mjs +10 -0
  56. package/board/review.mjs +1 -1
  57. package/board/server.mjs +19 -19
  58. package/board/stop.mjs +1 -0
  59. package/board/store.mjs +27 -18
  60. package/board/sync.mjs +2 -0
  61. package/board/tickets.mjs +3 -3
  62. package/board/worktree.mjs +22 -21
  63. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  64. package/hub/board-repo.mjs +1 -1
  65. package/hub/boards.mjs +4 -4
  66. package/hub/front-door.mjs +1 -1
  67. package/hub/github.mjs +3 -3
  68. package/hub/hub.mjs +11 -11
  69. package/hub/incoming.mjs +2 -2
  70. package/hub/owner-checks.mjs +1 -1
  71. package/hub/pr-report.mjs +2 -2
  72. package/hub/review-send.mjs +1 -1
  73. package/hub/sessions.mjs +2 -2
  74. package/hub/share.mjs +1 -1
  75. package/hub/slack.mjs +2 -2
  76. package/hub/try.mjs +7 -7
  77. package/hub/upgrade.mjs +1 -1
  78. package/hub/usage.mjs +3 -3
  79. package/hub/views.mjs +1 -1
  80. package/package.json +1 -1
  81. package/shared/card-prs.mjs +1 -1
  82. package/shared/machine-gate.mjs +2 -0
  83. package/shared/prototype-frame.js +1 -0
  84. package/shared/usage-gate.mjs +1 -1
  85. package/board/public/ui-phase-bars.js +0 -17
@@ -10,7 +10,7 @@
10
10
  <link rel="stylesheet" href="sidebar.css">
11
11
  <style>
12
12
  /* The one page every recorded prototype is (board/server.mjs fills the {{…}} slots; prototype-kit.js documents what a
13
- take gets). main keeps room below the stage for the take pill and its note. */
13
+ take gets). main keeps room below the stage for the viewer's bar and its note. */
14
14
  main.proto-main { max-width: none; padding-left: 0; padding-right: 0; padding-bottom: calc(150px + var(--app-bottom, 0px) + env(safe-area-inset-bottom)); }
15
15
  /* kit.hub() is a whole page with its own gutter and header, so it goes edge to edge. */
16
16
  main.proto-main:has(> #stage > .kit-hub) { padding-top: 0; }
@@ -20,11 +20,28 @@
20
20
  #stage:has(> .kit-hub) { padding: 0; }
21
21
  #stage .top { position: static; }
22
22
  #stage .hub-view main { padding-bottom: 16px; }
23
+ /* A served round's page, framed from the board's base prototype worktree, edge to edge above the viewer's bar. */
24
+ #stage:has(> .kit-served) { max-width: none; padding: 0; }
25
+ .kit-served { display: block; width: 100%; min-height: 320px; height: calc(100dvh - 210px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); border: 0; background: var(--panel); }
26
+ .kit-served-failed pre { margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
23
27
  .kit-missing { margin: 0; padding: 16px; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); overflow-wrap: anywhere; }
24
28
  #protoToast { position: fixed; z-index: 40; left: 50%; top: calc(64px + env(safe-area-inset-top)); transform: translateX(-50%); max-width: calc(100vw - 32px); }
25
29
  #protoToast[hidden] { display: none; }
26
30
  @media (max-width: 899px) { #protoToast { top: max(12px, env(safe-area-inset-top)); } }
27
31
 
32
+ /* A take's own boxes are capped to the stage at phone width, so a fixed-width panel never cuts off. prototype-kit.js
33
+ (phoneFit) marks what the take lays side by side with data-stack, which stacks in one column whether the take used
34
+ grid, flex (wrapping or not) or inline-block, and a frame 600px or wider with data-wide: it keeps its own layout
35
+ and scrolls sideways inside its data-scroll box. */
36
+ @media (max-width: 599px) {
37
+ #stage :where(div, section, article, aside, nav, header, footer, main, form, ul, ol, table, figure):not([data-wide]) { max-width: 100%; min-width: 0; }
38
+ #stage [data-scroll] { overflow-x: auto; max-width: 100%; }
39
+ #stage [data-wide] { max-width: none; flex-shrink: 0; }
40
+ #stage [data-stack] { display: flex !important; flex-direction: column !important; flex-wrap: nowrap !important; grid-template-columns: none !important; align-items: stretch !important; }
41
+ #stage [data-stack] > * { width: auto !important; flex: 0 0 auto !important; grid-column: auto !important; float: none !important; }
42
+ }
43
+ /* iOS Safari zooms the page when it focuses a field under 16px. */
44
+ #stage :where(input, select, textarea) { font-size: 16px; }
28
45
  #stage .kit-sidebar.sidebar { position: relative; inset: auto; z-index: auto; transform: none; visibility: visible; transition: none;
29
46
  width: min(320px, 100%); height: 640px; margin: 0 auto; padding: 0 8px; border: 1px solid var(--line); border-radius: 14px; }
30
47
  #stage .kit-sidebar.sidebar:not(.kit-head) > header.top { display: none; }
@@ -40,15 +57,52 @@
40
57
  <main class="proto-main"><div id="stage"></div></main>
41
58
  <div class="toast" id="protoToast" role="status" hidden></div>
42
59
  <script id="prototypeData" type="application/json">{{prototypeData}}</script>
43
- <script src="claude.js"></script>
44
60
  <script src="ui.js"></script>
45
61
  <script src="ui-links.js"></script>
46
62
  <script src="ui-enums.js"></script>
47
- <script src="ui-phase-bars.js"></script>
63
+ <script src="ui-progress-bar.js"></script>
64
+ <script src="ui-order.js"></script>
65
+ <script src="ui-patch.js"></script>
66
+ <script src="ui-threads.js"></script>
48
67
  <script src="ui-cards.js"></script>
68
+ <script src="ui-header.js"></script>
69
+ <script src="ui-snapshot.js"></script>
49
70
  <script src="ui-page-header.js"></script>
50
71
  <script src="ui-sidebar.js"></script>
72
+ <script src="ui-questions.js"></script>
73
+ <script src="ui-prompts.js"></script>
74
+ <script src="ui-markdown.js"></script>
51
75
  <script src="ui-hub.js"></script>
76
+ <script src="toasts.js"></script>
77
+ <script src="dialogs.js"></script>
78
+ <script src="prompts.js"></script>
79
+ <script src="rules-editor.js"></script>
80
+ <script src="claude.js"></script>
81
+ <script src="attach.js"></script>
82
+ <script src="hub-kit.js"></script>
83
+ <script src="linear-key.js"></script>
84
+ <script src="sidebar.js"></script>
85
+ <script src="app.js"></script>
86
+ <script src="new-task.js"></script>
87
+ <script src="app-core.js"></script>
88
+ <script src="app-threads.js"></script>
89
+ <script src="app-cards.js"></script>
90
+ <script src="app-lanes.js"></script>
91
+ <script src="app-asks.js"></script>
92
+ <script src="app-needs.js"></script>
93
+ <script src="app-header.js"></script>
94
+ <script src="app-arch.js"></script>
95
+ <script src="app-reader.js"></script>
96
+ <script src="app-banner.js"></script>
97
+ <script src="app-snapshot.js"></script>
98
+ <script src="view-settings.js"></script>
99
+ <script src="view-questions.js"></script>
100
+ <script src="view-notes.js"></script>
101
+ <script src="view-hub.js"></script>
102
+ <script src="view-hub-settings.js"></script>
103
+ <script src="view-frame.js"></script>
104
+ <script src="router.js"></script>
105
+ <script src="shell.js"></script>
52
106
  <script src="prototype-switcher.js"></script>
53
107
  <script src="prototype-kit.js"></script>
54
108
  <script>PrototypeKit.start(JSON.parse(document.getElementById('prototypeData').textContent));</script>
@@ -85,18 +85,19 @@ globalThis.AppRouter = (function () {
85
85
  const u = new URL(view.url, 'http://hub');
86
86
  return /^\/[^/]+\/prototype-[\w-]+\.html$/.test(u.pathname) ? u.searchParams.get('q') || null : null;
87
87
  }
88
- function levelOf(view, { boards = new Set() } = {}) {
88
+ // wide: from 900px Waiting on you is the board's pane, so a prototype's parent is the board itself.
89
+ function levelOf(view, { boards = new Set(), wide = false } = {}) {
89
90
  if (view?.kind === 'board') return 0;
90
91
  if (view?.kind === 'questions') return 1;
91
- return questionOf(view, boards) ? 2 : null;
92
+ return questionOf(view, boards) ? (wide ? 1 : 2) : null;
92
93
  }
93
94
  // → the views above view in the tree, its board first. card: the card whose thread the owner left the board from,
94
95
  // which the board keeps open.
95
- function parentsOf(view, { boards = new Set(), card = null } = {}) {
96
+ function parentsOf(view, { boards = new Set(), card = null, wide = false } = {}) {
96
97
  const q = questionOf(view, boards);
97
98
  if (!q && view?.kind !== 'questions') return [];
98
99
  const board = { kind: 'board', route: view.route, hash: card ? `#item=${encodeURIComponent(card)}` : '' };
99
- return q ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
100
+ return q && !wide ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
100
101
  }
101
102
 
102
103
  // Each history entry carries {idx}, its distance from the shell's first entry, pushed when a push opened it, and on a
@@ -109,8 +110,8 @@ globalThis.AppRouter = (function () {
109
110
  // boards() → the board routes now.
110
111
  // → {push(href, from), replace(href), up()}: push and replace write an entry for a view go is showing; replace answers
111
112
  // 'up' when it went back to a parent instead, which then takes href, and go shows it on that pop.
112
- function listen({ boards, go }) {
113
- const b = () => ({ boards: boards() });
113
+ function listen({ boards, go, wide = () => false }) {
114
+ const b = () => ({ boards: boards(), wide: wide() });
114
115
  const mark = (pushed) => (pushed ? { pushed: true } : {});
115
116
  let idx = Number.isInteger(history.state?.idx) ? history.state.idx : 0;
116
117
  let landing = null;
@@ -6,8 +6,8 @@
6
6
  // view in place through router.js; a link to anything that isn't a page loads it. Leaving a view whose root holds
7
7
  // [data-dirty], an unsaved edit, asks first. The shell holds the hub's one stream, /_hub/stream
8
8
  // (hub/stream.mjs): `change {route}` signals the open board, and `sidebar` repaints the sidebar. A board the shell
9
- // leaves is unmounted; one left fresh whose stream sent no change since paints live when it comes back, and any other
10
- // paints dimmed from its last visit (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md).
9
+ // leaves is unmounted, and paints live from its saved copy when it comes back
10
+ // (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md).
11
11
  // A push tap reaches it from hub/sw.js as {open: url}, and a page framed inside it hands it a view's URL through
12
12
  // AppShell.open(url, {replace, frames}) → true when the shell shows it in place; frames false refuses a frame view.
13
13
  // AppShell.reload() reloads the app past the shell the service worker keeps.
@@ -44,6 +44,9 @@ window.AppShell = (function () {
44
44
  }
45
45
 
46
46
  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';
47
50
  // 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.
48
51
  if (outer()?.open(location.href, { frames: false })) return;
49
52
  // The same URL and scope as hub-kit.js's push registration, so the one registration and its push subscription stay.
@@ -57,10 +60,20 @@ window.AppShell = (function () {
57
60
  // A stored sidebar's code says nothing about the shell that booted, so it adopts the next live hash.
58
61
  bootCode = data?.code || null;
59
62
  data ||= read(DATA_KEY);
60
- // 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):
61
- // the hub sends them data.shared, and style.css hides what writes under .shared-view.
63
+ // A board shared with someone else (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md):
64
+ // the hub sends them data.shared, and style.css hides what writes under .shared-view. On a board they have Full on
65
+ // (data.shared.full; docs/adr/2026-11-05-1912-a-shared-person-is-view-or-full.md) the page is .shared-full too,
66
+ // which shows the composers and card buttons again; levelHere follows the route shown and each sidebar event.
62
67
  const shared = data?.shared || null;
63
68
  document.documentElement.classList.toggle('shared-view', Boolean(shared));
69
+ const sharedHere = (route) => ({ by: data?.shared?.by ?? null, full: Boolean(data?.shared?.full?.includes(route)) });
70
+ function levelHere() {
71
+ if (!shared || !current) return;
72
+ const here = sharedHere(current.route);
73
+ document.documentElement.classList.toggle('shared-full', here.full);
74
+ const strip = current.root.querySelector('.view-only-strip');
75
+ if (strip) strip.innerHTML = BoardView.sharedStrip(here);
76
+ }
64
77
  sidebar = Sidebar.mount({ data, poll: false, reload, open: (href) => { if (!open(href)) location.assign(href); } });
65
78
  const boards = () => new Set((data?.sites || []).filter((s) => s.board).map((s) => s.route));
66
79
  const titleOf = (route) => data?.sites?.find((s) => s.route === route)?.title || route;
@@ -72,13 +85,8 @@ window.AppShell = (function () {
72
85
  listeners.get(route).add(fn);
73
86
  return () => listeners.get(route)?.delete(fn);
74
87
  };
75
- // route → {fresh, generatedAt} for each board left fresh with no change since. Trusted only while the stream is up:
76
- // a dropped stream may have missed a change, so its return forgets them all.
77
- const left = new Map();
78
- let streamUp = false;
79
88
  let current = null;
80
89
  function changed(route) {
81
- left.delete(route);
82
90
  for (const fn of listeners.get(route) || []) fn();
83
91
  }
84
92
 
@@ -94,8 +102,7 @@ window.AppShell = (function () {
94
102
  const cur = es = new EventSource('/_hub/stream');
95
103
  cur.addEventListener('open', () => {
96
104
  tries = 0;
97
- streamUp = true;
98
- if (opened) { left.clear(); if (current) changed(current.route); }
105
+ if (opened && current) changed(current.route);
99
106
  opened = true;
100
107
  });
101
108
  cur.addEventListener('change', (e) => {
@@ -107,10 +114,10 @@ window.AppShell = (function () {
107
114
  try { data = JSON.parse(e.data); } catch { return; }
108
115
  codeSeen(data.code);
109
116
  sidebar.setData(data);
117
+ levelHere();
110
118
  current?.v?.B?.renderStatus?.();
111
119
  });
112
120
  cur.addEventListener('error', () => {
113
- streamUp = false;
114
121
  if (cur.readyState !== EventSource.CLOSED || es !== cur) return;
115
122
  retry = setTimeout(openStream, Math.min(30000, 1000 * 2 ** tries++));
116
123
  });
@@ -131,6 +138,14 @@ window.AppShell = (function () {
131
138
  };
132
139
  for (const type of ['pointerdown', 'keydown', 'scroll']) addEventListener(type, beat, { capture: true, passive: true });
133
140
 
141
+ // docs/adr/2026-11-05-1834-while-typing-the-header-is-a-strip-and-the-send-bar-sits-on-the-keyboard.md
142
+ // While a text field has focus :root.typing is set and style.css disables the floating + (New task); nothing else
143
+ // on the page moves.
144
+ document.addEventListener('focusin', (ev) => {
145
+ document.documentElement.classList.toggle('typing', ev.target.matches?.('textarea, input[type="text"], input:not([type])') ?? false);
146
+ });
147
+ document.addEventListener('focusout', () => document.documentElement.classList.remove('typing'));
148
+
134
149
  // A view that changed what the sidebar's foot rows say, such as notifications, asks for a fresh copy
135
150
  // rather than waiting for the stream's next one.
136
151
  async function refreshSidebar() {
@@ -179,29 +194,23 @@ window.AppShell = (function () {
179
194
  const board = view.kind === 'board';
180
195
  const hub = route === undefined;
181
196
  const siteTitle = hub ? 'Hub' : titleOf(route);
182
- if (current) {
183
- const st = current.v.B.state;
184
- const keep = streamUp && st?.view === 'fresh' && st.board ? { fresh: st.fresh, generatedAt: st.board.generatedAt } : null;
185
- current.v.unmount();
186
- if (keep) left.set(current.route, keep);
187
- }
197
+ if (current) current.v.unmount();
188
198
  const page = board ? null : AppViews[view.kind];
189
199
  const root = document.createElement('div');
190
200
  root.className = board ? 'view-board' : page.className;
191
- root.innerHTML = board ? BoardView.markup({ title: siteTitle, shared }) : page.markup(view, { siteTitle });
201
+ root.innerHTML = board ? BoardView.markup({ title: siteTitle, shared: shared && sharedHere(route) }) : page.markup(view, { siteTitle });
192
202
  if (current) current.root.replaceWith(root); else scrim.after(root);
193
203
  const siteRoot = hub ? '/_hub' : '/' + encodeURIComponent(route);
194
204
  let v;
195
205
  if (board) {
196
- const live = streamUp ? left.get(route) || null : null;
197
- left.delete(route);
198
- v = BoardView.create(root, siteRoot, { signal: signalFor(route), live, pause: () => data?.pause, away: () => data?.away });
206
+ v = BoardView.create(root, siteRoot, { signal: signalFor(route), pause: () => data?.sites?.find((s) => s.route === route)?.pause || data?.pause, away: () => data?.away });
199
207
  } else {
200
208
  v = page.create(root, siteRoot, { view, siteTitle, boards, open: openHere, toggleSidebar: sidebar.toggle, keydown: sidebar.keydown, pointerdown: sidebar.pointerdown,
201
209
  reload: () => go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'reload'), closeSidebar: sidebar.close, up: () => router.up(),
202
210
  loaded: (url, title) => { if (current?.v === v) framed(url, title); }, sidebarChanged: refreshSidebar });
203
211
  }
204
212
  current = { view, route, root, v };
213
+ levelHere();
205
214
  document.title = board ? siteTitle : page.title(view, { siteTitle });
206
215
  if (boards().has(route)) { try { localStorage.setItem(LAST_KEY, route); } catch { /* no storage: `/` opens the start board */ } }
207
216
  sidebar.setHere(hub ? new URL(AppRouter.urlOf(view), location.href).pathname.slice(1) : route);
@@ -232,8 +241,29 @@ window.AppShell = (function () {
232
241
  // go(view, href, how, from): router.js's, with how 'replace' for a framed page's hand-off and 'reload' for a view's
233
242
  // own refresh. → true, or a promise of whether the view switched once the owner answers about an unsaved edit.
234
243
  let router = null;
244
+ const wide = matchMedia('(min-width: 900px)');
245
+ // 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
+ // from 900px the board shows Waiting on you in its pane, so every view of the questions page, a link, a push, a
247
+ // 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);
252
+ }
235
253
  function go(view, href, how, from) {
236
254
  if (!AppRouter.mounted(view)) return false;
255
+ if (view.kind === 'questions' && wide.matches) {
256
+ const board = { kind: 'board', route: view.route, hash: '' };
257
+ const boardHref = new URL(AppRouter.urlOf(board), location.href).href;
258
+ 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.
260
+ else if (current?.view.kind === 'board' && current.route === view.route && !settings) {
261
+ focusPane(view.id);
262
+ sidebar.close();
263
+ return true;
264
+ }
265
+ return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown) focusPane(view.id); return shown; });
266
+ }
237
267
  if (view.kind === 'settings') {
238
268
  if (current?.view.kind === 'board' && current.route === view.route) {
239
269
  const here = AppRouter.viewOf(location.href, { boards: boards() });
@@ -285,7 +315,17 @@ window.AppShell = (function () {
285
315
  }
286
316
  if (!AppRouter.mounted(first)) { location.replace('/_hub/'); return; }
287
317
 
288
- router = AppRouter.listen({ boards, go });
318
+ let pane = null;
319
+ if (first.kind === 'questions' && wide.matches) {
320
+ pane = { id: first.id };
321
+ first = { kind: 'board', route: first.route, hash: '' };
322
+ history.replaceState(history.state, '', AppRouter.urlOf(first));
323
+ }
324
+ 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.
326
+ wide.addEventListener('change', () => {
327
+ if (wide.matches && current?.view.kind === 'questions') go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'replace');
328
+ });
289
329
  opener = (url, { replace = false, frames = true } = {}) => {
290
330
  const view = AppRouter.viewOf(url, { boards: boards() });
291
331
  if (!AppRouter.mounted(view) || (!frames && view.kind === 'frame')) return false;
@@ -301,6 +341,7 @@ window.AppShell = (function () {
301
341
  navigator.serviceWorker?.startMessages?.();
302
342
 
303
343
  show(first);
344
+ if (pane) focusPane(pane.id);
304
345
  openStream();
305
346
  addEventListener('visibilitychange', () => { if (!document.hidden) openStream(); });
306
347
  addEventListener('pageshow', openStream);
@@ -309,7 +350,6 @@ window.AppShell = (function () {
309
350
  addEventListener('pagehide', (e) => {
310
351
  if (!e.persisted || !es) return;
311
352
  clearTimeout(retry);
312
- streamUp = false;
313
353
  es.close();
314
354
  });
315
355
  }
@@ -180,6 +180,7 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
180
180
  .sidebar .t-building { --tone: var(--color-notice); }
181
181
  .sidebar .t-ok { --tone: var(--color-success); }
182
182
  .sidebar .t-wait { --tone: var(--color-notice); }
183
+ .sidebar .t-paused { --tone: var(--muted); }
183
184
 
184
185
  @media (min-width: 900px) {
185
186
  body.has-sidebar { padding-left: 272px; }
@@ -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,7 @@
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));
108
105
  if (!data) return;
109
106
  const scroll = aside.querySelector('.sb-scroll');
110
107
  const top = painted ? scroll?.scrollTop : undefined;
@@ -116,11 +113,14 @@
116
113
  bar.style.animationDelay = `${switching.started - performance.now()}ms`;
117
114
  bar.style.animationDuration = `${IDLE_MS}ms`;
118
115
  }
116
+ // A shared person's sidebar has no update row (ui-sidebar.js).
119
117
  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;
118
+ if (fresh) {
119
+ if (held) fresh.replaceWith(held);
120
+ const row = updateRow(update, data.version && data.version !== bootVersion ? data.version : null);
121
+ if (!held || row !== updateHTML) (held || fresh).innerHTML = row;
122
+ updateHTML = row;
123
+ }
124
124
  const list = aside.querySelector('.sb-scroll');
125
125
  if (list) list.scrollTop = top ?? list.scrollHeight;
126
126
  paintStarts();
@@ -131,6 +131,7 @@
131
131
  if (!next) return;
132
132
  const starting = new Set(startsRunning().map((s) => s.route));
133
133
  for (const s of next.sites || []) if (starting.has(s.route)) joined.set(s.route, Date.now());
134
+ bootVersion ??= next.version ?? null;
134
135
  data = next;
135
136
  write(DATA_KEY, JSON.stringify(data));
136
137
  paint();
@@ -195,64 +196,10 @@
195
196
  if (!refreshApp.disabled && update !== 'reloading') { setUpdate('reloading'); reload(); }
196
197
  return;
197
198
  }
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
199
  if (e.target.closest?.('[data-new-board]')) { newBoard(); return; }
203
200
  const failed = e.target.closest?.('[data-failed-start]');
204
201
  if (failed) openFailed(failed.dataset.failedStart);
205
202
  });
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
203
  // A failed start's sheet (docs/adr/2026-11-02-2006-a-failed-start-retries-from-its-sheet.md). Dismiss deletes its Starting
257
204
  // record; Retry starts it again, and its row reads starting… until the hub's next copy says otherwise.
258
205
  function openFailed(route) {