@lovek4d/kickflip 0.1.0 → 0.3.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 (96) hide show
  1. package/CONTEXT.md +177 -78
  2. package/README.md +46 -5
  3. package/SKILL.md +58 -37
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +87 -32
  7. package/bin/statusline.mjs +4 -3
  8. package/bin/wait-events.mjs +2 -2
  9. package/board/arch-review.mjs +5 -0
  10. package/board/bin/agent.mjs +1 -1
  11. package/board/bin/board.mjs +241 -69
  12. package/board/bin/builder-stop-hook.mjs +4 -3
  13. package/board/domain.mjs +5 -3
  14. package/board/on-host.mjs +1 -0
  15. package/board/public/app-arch.js +75 -0
  16. package/board/public/app-asks.js +33 -14
  17. package/board/public/app-cards.js +242 -49
  18. package/board/public/app-core.js +39 -5
  19. package/board/public/app-header.js +24 -6
  20. package/board/public/app-lanes.js +76 -26
  21. package/board/public/app-needs.js +1 -0
  22. package/board/public/app-snapshot.js +45 -11
  23. package/board/public/app-threads.js +13 -11
  24. package/board/public/app.html +4 -1
  25. package/board/public/app.js +131 -20
  26. package/board/public/dialogs.js +114 -116
  27. package/board/public/hub-kit.js +19 -2
  28. package/board/public/hub.css +24 -18
  29. package/board/public/linear-key.js +141 -0
  30. package/board/public/router.js +6 -3
  31. package/board/public/rules-editor.js +1 -1
  32. package/board/public/shell.js +1 -1
  33. package/board/public/sidebar.css +31 -11
  34. package/board/public/sidebar.js +88 -16
  35. package/board/public/style.css +141 -46
  36. package/board/public/toasts.js +6 -4
  37. package/board/public/ui-cards.js +102 -37
  38. package/board/public/ui-header.js +17 -8
  39. package/board/public/ui-hub.js +30 -12
  40. package/board/public/ui-links.js +18 -10
  41. package/board/public/ui-order.js +18 -0
  42. package/board/public/ui-page-header.js +4 -2
  43. package/board/public/ui-questions.js +37 -16
  44. package/board/public/ui-sidebar.js +49 -11
  45. package/board/public/ui-snapshot.js +15 -9
  46. package/board/public/ui-threads.js +25 -16
  47. package/board/public/ui.js +23 -11
  48. package/board/public/view-hub-settings.js +46 -30
  49. package/board/public/view-hub.js +64 -13
  50. package/board/public/view-notes.js +7 -4
  51. package/board/public/view-polling.js +3 -3
  52. package/board/public/view-questions.js +42 -22
  53. package/board/public/view-settings.js +90 -35
  54. package/board/review.mjs +1 -0
  55. package/board/server.mjs +12 -7
  56. package/board/store.mjs +14 -6
  57. package/board/tickets.mjs +3 -3
  58. package/board/worktree.mjs +14 -13
  59. package/docs/hub-session.md +3 -3
  60. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  61. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  62. package/hub/awake.mjs +1 -0
  63. package/hub/board-repo.mjs +1 -1
  64. package/hub/boards.mjs +4 -4
  65. package/hub/close-board.mjs +1 -1
  66. package/hub/duration.mjs +1 -0
  67. package/hub/github.mjs +4 -5
  68. package/hub/home.mjs +1 -1
  69. package/hub/hub.mjs +12 -10
  70. package/hub/linear.mjs +2 -2
  71. package/hub/new-board.mjs +2 -2
  72. package/hub/pr-report.mjs +1 -0
  73. package/hub/push.mjs +2 -2
  74. package/hub/readiness.mjs +2 -0
  75. package/hub/repos.mjs +3 -2
  76. package/hub/restart-board.mjs +1 -0
  77. package/hub/routes.mjs +1 -1
  78. package/hub/schedule.mjs +1 -1
  79. package/hub/sessions.mjs +2 -2
  80. package/hub/shell.mjs +2 -1
  81. package/hub/slack.mjs +2 -0
  82. package/hub/stream.mjs +12 -12
  83. package/hub/sw.js +1 -1
  84. package/hub/trust.mjs +2 -0
  85. package/hub/try.mjs +4 -3
  86. package/hub/upgrade.mjs +1 -0
  87. package/hub/usage.mjs +3 -3
  88. package/hub/views.mjs +1 -1
  89. package/package.json +1 -1
  90. package/shared/colors.css +7 -5
  91. package/shared/fake-gh.mjs +54 -0
  92. package/shared/service.mjs +16 -16
  93. package/shared/skills.mjs +2 -0
  94. package/shared/usage-gate.mjs +1 -1
  95. package/starter/public/claude.js +3 -2
  96. package/bin/web-ts.mjs +0 -2
@@ -0,0 +1,141 @@
1
+ // Hub's Linear key slot. Hub settings › Linear mounts it, and the Tickets control (dialogs.js) opens it over Board
2
+ // settings or New board when Linear is tapped without a working key
3
+ // (docs/adr/0117-a-board-picks-linear-only-while-hubs-key-works.md). A key saves on paste, change or Enter through
4
+ // POST /_hub/settings/linear, which checks it with Linear; linear is hub/views.mjs settingsView's `linear`.
5
+ (function () {
6
+ const FAILED = 'Could not save. Is the host awake?';
7
+ const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
8
+ const works = (linear) => Boolean(linear?.set && !linear.refused);
9
+
10
+ function slot(linear, { checking, flash }) {
11
+ const { esc, row, tile } = HubUI;
12
+ const sub = checking ? `<span class="lk-checking">${SPIN}Checking the key with Linear…</span>`
13
+ : `${linear.down ? `<span class="st down">${esc(linear.down)}</span> · ` : ''}${linear.keys
14
+ ? esc(linear.sub).replace(esc(linear.keys.label), `<a href="${esc(linear.keys.url)}" target="_blank" rel="noopener">${esc(linear.keys.label)}</a>`)
15
+ : esc(linear.sub)}`;
16
+ const remove = linear.set && !checking ? '<button type="button" class="btn quiet danger" data-linear-remove>Remove</button>' : '';
17
+ const placeholder = checking ? 'Checking…' : linear.set ? 'Paste a new key to replace it' : 'Paste a Linear API key';
18
+ return `${HubUI.flash(flash)}<div class="group">${row({ icon: tile('key', linear.refused ? 'var(--color-danger)' : 'var(--color-quiet)'), title: esc(linear.title), sub, wrap: !works(linear), trail: remove })}`
19
+ + `<form class="block lk-form"><input id="linearKey" name="key" type="password" required${checking ? ' disabled' : ''} placeholder="${placeholder}" aria-label="Linear API key" autocomplete="off" autocapitalize="off" spellcheck="false"></form></div>`
20
+ + '<p class="note">Stays on the host. Boards use it to list teams and projects, create issues and move the ones that are yours. A board can pick Linear for its tickets only while this key works.</p>';
21
+ }
22
+
23
+ // Draws the slot into root and saves from it. busy(true) starts each save and busy(false) ends it; saved(out) gets
24
+ // each answer, /_hub/api/settings's JSON, before the slot repaints; worked() follows a saved key Linear accepted.
25
+ // → {busy(), settled(): a promise that resolves once no save is out, linear(), set(linear) unless busy, destroy()}
26
+ function mount(root, { linear, saved = () => {}, worked = () => {}, busy = () => {} }) {
27
+ let current = linear, flash = null, checking = false, pending = null, destroyed = false;
28
+ const paint = () => {
29
+ const typed = root.querySelector('#linearKey')?.value || '';
30
+ root.innerHTML = slot(current, { checking, flash });
31
+ if (!checking) root.querySelector('#linearKey').value = typed;
32
+ };
33
+ function post(body) {
34
+ checking = body.has('key');
35
+ flash = null;
36
+ pending = (async () => {
37
+ try {
38
+ const r = await fetch('/_hub/settings/linear', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body });
39
+ const out = await r.json().catch(() => null);
40
+ if (destroyed) return;
41
+ if (r.ok && out) { current = out.linear; saved(out); } else flash = { text: out?.error || FAILED, error: true };
42
+ } catch { flash = { text: FAILED, error: true }; }
43
+ const key = checking;
44
+ checking = false;
45
+ pending = null;
46
+ if (destroyed) return;
47
+ busy(false);
48
+ paint();
49
+ if (key && works(current)) worked();
50
+ })();
51
+ busy(true);
52
+ paint();
53
+ return pending;
54
+ }
55
+ const save = (value) => { if (!pending && value.trim()) post(new URLSearchParams({ key: value.trim() })); };
56
+ const onPaste = (e) => {
57
+ const text = e.clipboardData?.getData('text');
58
+ if (!e.target.matches?.('#linearKey') || !text) return;
59
+ e.preventDefault();
60
+ e.target.value = text.trim();
61
+ save(e.target.value);
62
+ };
63
+ const onChange = (e) => { if (e.target.matches?.('#linearKey')) save(e.target.value); };
64
+ const onSubmit = (e) => {
65
+ if (!e.target.matches?.('.lk-form')) return;
66
+ e.preventDefault();
67
+ save(e.target.querySelector('#linearKey').value);
68
+ };
69
+ const onClick = async (e) => {
70
+ if (!e.target.closest?.('[data-linear-remove]') || pending) return;
71
+ if (await BoardDialogs.confirm('Boards on Linear can’t create issues or move tickets until you add a key again. Remove the Linear key?') && !destroyed) post(new URLSearchParams({ remove: '1' }));
72
+ };
73
+ const listeners = [['paste', onPaste], ['change', onChange], ['submit', onSubmit], ['click', onClick]];
74
+ for (const [type, fn] of listeners) root.addEventListener(type, fn);
75
+ paint();
76
+ return {
77
+ busy: () => Boolean(pending),
78
+ settled: () => pending || Promise.resolve(),
79
+ linear: () => current,
80
+ set(next) { if (!pending) { current = next; paint(); } },
81
+ destroy() { destroyed = true; for (const [type, fn] of listeners) root.removeEventListener(type, fn); },
82
+ };
83
+ }
84
+
85
+ // Done's spinner while a save is out (style.css .settings-footer [aria-busy]).
86
+ function doneBusy(buttons, busy) {
87
+ for (const b of buttons) {
88
+ if (busy) b.setAttribute('aria-busy', 'true'); else b.removeAttribute('aria-busy');
89
+ if (b.closest('.settings-footer')) b.innerHTML = `${busy ? SPIN : ''}Done`;
90
+ }
91
+ }
92
+
93
+ // The slot in a modal of its own over the page. It closes itself once a pasted key works; Done, ✕, Escape and the
94
+ // backdrop close it once no save is out. → a promise of whether the key works on closing
95
+ function open() {
96
+ const previous = document.activeElement;
97
+ const wrap = document.createElement('div');
98
+ // hub.css styles the slot under .hub-view main; style.css draws both as display: contents.
99
+ wrap.setAttribute('class', 'hub-view lk-over');
100
+ wrap.innerHTML = '<main><dialog class="settings-modal hub-modal" id="linearKeyModal" aria-labelledby="linearKeyTitle"><div class="settings-head"><div><h2 id="linearKeyTitle" tabindex="-1">Linear</h2><p class="muted small">Hub settings</p></div><button type="button" class="ghost" data-lk-done aria-label="Close Linear key">✕</button></div>'
101
+ + '<div class="set settings-body"><div class="setting-content"><p class="note">Loading…</p></div></div><div class="settings-footer"><button type="button" data-lk-done>Done</button></div></dialog></main>';
102
+ document.body.appendChild(wrap);
103
+ const dialog = wrap.querySelector('dialog');
104
+ const content = wrap.querySelector('.setting-content');
105
+ const unfit = BoardDialogs.fitSheet(dialog);
106
+ let key = null, closing = false, finished = false, resolve;
107
+ const result = new Promise((r) => { resolve = r; });
108
+ function finish() {
109
+ if (finished) return;
110
+ finished = true;
111
+ key?.destroy();
112
+ unfit();
113
+ dialog.close();
114
+ wrap.remove();
115
+ if (previous?.isConnected) previous.focus({ preventScroll: true });
116
+ resolve(works(key?.linear()));
117
+ }
118
+ function close() {
119
+ if (!key?.busy()) { finish(); return; }
120
+ if (closing) return;
121
+ closing = true;
122
+ key.settled().then(finish);
123
+ }
124
+ dialog.addEventListener('click', (e) => {
125
+ if (e.target.closest?.('[data-lk-done]')) { close(); return; }
126
+ if (e.target !== dialog) return;
127
+ const r = dialog.getBoundingClientRect();
128
+ if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) close();
129
+ });
130
+ dialog.addEventListener('cancel', (e) => { e.preventDefault(); close(); });
131
+ dialog.showModal();
132
+ wrap.querySelector('#linearKeyTitle').focus({ preventScroll: true });
133
+ fetch('/_hub/api/settings', { cache: 'no-store', headers: { accept: 'application/json' } }).then((r) => (r.ok ? r.json() : Promise.reject())).then((out) => {
134
+ if (finished) return;
135
+ key = mount(content, { linear: out.linear, worked: finish, busy: (on) => doneBusy(wrap.querySelectorAll('[data-lk-done]'), on) });
136
+ }).catch(() => { if (!finished) content.innerHTML = '<p class="note">Offline. Is the host awake?</p>'; });
137
+ return result;
138
+ }
139
+
140
+ globalThis.LinearKey = { works, mount, open, doneBusy };
141
+ })();
@@ -6,7 +6,8 @@
6
6
  // board {route, hash} /<route>/, hash such as #item=<key>&reply
7
7
  // settings {route} /<route>/settings.html, a request to open the modal over that board
8
8
  // questions {route, id} /<route>/questions.html#<id>
9
- // notes {route, item} /<route>/notes.html?item=<key>, and plan.html, which the board redirects there
9
+ // notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<lineup slug>, and plan.html, which the board
10
+ // redirects there
10
11
  // hub {hash, w} /_hub/ with a settings modal named by hash; legacy /_hub/settings and
11
12
  // /_hub/settings/polling?w=<HH:MM> open the same modals (ADR 0075)
12
13
  // frame {route, url} any other page under a route, a folder or an .html file: other sites, prototypes and
@@ -45,7 +46,9 @@ globalThis.AppRouter = (function () {
45
46
  try { id = u.hash.length > 1 ? decodeURIComponent(u.hash.slice(1)) : null; } catch { id = u.hash.slice(1); }
46
47
  return { kind: 'questions', route, id };
47
48
  }
48
- if (page === 'notes.html' || page === 'plan.html') return { kind: 'notes', route, item: u.searchParams.get('item') };
49
+ if (page === 'notes.html' || page === 'plan.html') {
50
+ return u.searchParams.has('wave') ? { kind: 'notes', route, wave: u.searchParams.get('wave') } : { kind: 'notes', route, item: u.searchParams.get('item') };
51
+ }
49
52
  return frame;
50
53
  }
51
54
 
@@ -56,7 +59,7 @@ globalThis.AppRouter = (function () {
56
59
  case 'board': return r + (view.hash || '');
57
60
  case 'settings': return `${r}settings.html`;
58
61
  case 'questions': return `${r}questions.html${view.id ? `#${encodeURIComponent(view.id)}` : ''}`;
59
- case 'notes': return `${r}notes.html${view.item ? `?item=${encodeURIComponent(view.item)}` : ''}`;
62
+ case 'notes': return `${r}notes.html${view.wave ? `?wave=${encodeURIComponent(view.wave)}` : view.item ? `?item=${encodeURIComponent(view.item)}` : ''}`;
60
63
  case 'hub': return `/_hub/${view.w ? `?w=${encodeURIComponent(view.w)}` : ''}${view.hash || ''}`;
61
64
  default: return view.url;
62
65
  }
@@ -39,7 +39,7 @@
39
39
  .rules-editor .rule-body { flex:1; min-width:0; }
40
40
  .rules-editor .rule-text { overflow-wrap:anywhere; }
41
41
  .rules-editor .rule-meta { display:flex; flex-wrap:wrap; gap:6px; margin-top:4px; font-size:12px; color:var(--muted); }
42
- .rules-editor .rule-by { padding:0 6px; border-radius:6px; background:var(--chip); color:var(--color-info); }
42
+ .rules-editor .rule-by { padding:0 6px; border-radius:6px; background:var(--chip); color:var(--muted); }
43
43
  .rules-editor .rule-actions { flex:none; display:flex; gap:4px; }
44
44
  .rules-editor textarea { box-sizing:border-box; width:100%; min-width:0; resize:vertical; font:inherit; font-size:16px; color:var(--text); background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:6px 8px; }
45
45
  .rules-editor .rule-add { display:flex; align-items:flex-end; gap:6px; margin-top:8px; }
@@ -205,7 +205,7 @@ window.AppShell = (function () {
205
205
  const openHere = (href) => { if (!opener(href)) location.assign(href); };
206
206
 
207
207
  // The board and Waiting on you follow their own hash, so a link that only moves it keeps the view.
208
- const sameView = (a, b) => a.kind === b.kind && a.route === b.route && (a.kind !== 'notes' || a.item === b.item) && (a.kind !== 'frame' || a.url === b.url);
208
+ const sameView = (a, b) => a.kind === b.kind && a.route === b.route && (a.kind !== 'notes' || (a.item === b.item && a.wave === b.wave)) && (a.kind !== 'frame' || a.url === b.url);
209
209
  const dirty = () => Boolean(current?.root.querySelector('[data-dirty]'));
210
210
  addEventListener('beforeunload', (e) => { if (dirty()) { e.preventDefault(); e.returnValue = ''; } });
211
211
 
@@ -30,11 +30,11 @@
30
30
  .sidebar .sb-row:active { background: var(--chip); }
31
31
  @media (hover: hover) { .sidebar .sb-row:hover { background: color-mix(in srgb, var(--chip) 70%, transparent); text-decoration: none; } }
32
32
  .sidebar .sb-row.on { background: var(--accent-soft); }
33
- .sidebar .sb-turnon { margin: 6px 0 0; background: color-mix(in srgb, var(--color-danger) 10%, var(--panel)); border: 1px solid color-mix(in srgb, var(--color-danger) 30%, var(--line)); }
33
+ .sidebar .sb-turnon { margin: 6px 0 0; background: color-mix(in srgb, var(--accent) 10%, var(--panel)); border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); }
34
34
  .sidebar .sb-update-row { width: 100%; min-height: 48px; margin: 0 0 6px; padding: 6px 8px; border-radius: 10px; text-align: left; font: inherit; color: var(--text);
35
35
  background: color-mix(in srgb, var(--accent) 10%, var(--panel)); border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); cursor: pointer; }
36
36
  .sidebar .sb-update-row:disabled { opacity: 1; cursor: default; }
37
- .sidebar .sb-go { flex: none; padding: 5px 10px; border-radius: 7px; background: var(--accent); color: #fff; font-size: 13px; font-weight: 600; }
37
+ .sidebar .sb-go { flex: none; padding: 5px 10px; border-radius: 7px; background: var(--accent); color: var(--on-accent); font-size: 13px; font-weight: 600; }
38
38
  .sidebar .sb-update-row:disabled .sb-go { background: transparent; color: var(--muted); padding-right: 0; font-weight: 400; }
39
39
  html.sb-update body.has-sidebar > .top .hub, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
40
40
  html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub::after {
@@ -48,7 +48,24 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
48
48
  .sidebar .sb-new { width: 100%; text-align: left; border: 0; background: transparent; font: inherit; }
49
49
  .sidebar .sb-starting { color: var(--muted); }
50
50
  .sidebar .sb-starting:hover, .sidebar .sb-starting:active { background: transparent; }
51
- .sidebar .sb-dismiss { flex: none; min-height: 44px; font-size: 12px; }
51
+ .sidebar .sb-failed { width: 100%; text-align: left; border: 0; background: transparent; font: inherit; color: var(--text); }
52
+ .sidebar .sb-failed .sb-title { color: var(--color-danger); }
53
+ .sidebar .sb-failed:active { background: var(--chip); }
54
+ @media (hover: hover) { .sidebar .sb-failed:hover { background: color-mix(in srgb, var(--chip) 70%, transparent); } }
55
+
56
+ #failedStartSheet .sheet-head h2 { font-size: 17px; overflow-wrap: anywhere; }
57
+ #failedStartSheet .fs-error { margin: 4px 0 12px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--color-danger) 40%, var(--line)); border-radius: 10px;
58
+ color: var(--color-danger); font-size: 13px; overflow-wrap: anywhere; }
59
+ #failedStartSheet .fs-facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 12px; margin: 0 0 14px; font-size: 13px; }
60
+ #failedStartSheet .fs-facts dt { color: var(--muted); }
61
+ #failedStartSheet .fs-facts dd { margin: 0; overflow-wrap: anywhere; }
62
+ #failedStartSheet .fs-refused { margin: 0 0 8px; color: var(--color-danger); font-size: 13px; }
63
+ #failedStartSheet .fs-refused[hidden] { display: none; }
64
+ #failedStartSheet .fs-actions { display: flex; gap: 8px; justify-content: flex-end; }
65
+ #failedStartSheet .fs-actions button { min-height: 44px; padding: 6px 16px; }
66
+ @media (min-width: 900px) {
67
+ #failedStartSheet { inset: 0; margin: auto; height: fit-content; max-height: calc(100dvh - 48px); }
68
+ }
52
69
  .sidebar .sb-row .icon.sb-plus { color: var(--muted); background: var(--chip); }
53
70
 
54
71
  #newBoardSheet .nb-form { min-width: 0; }
@@ -66,15 +83,15 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
66
83
  #newBoardSheet .nb-repo:hover, #newBoardSheet .nb-repo:focus-visible { border-color: var(--accent); }
67
84
  #newBoardSheet .nb-repo strong { overflow-wrap: anywhere; }
68
85
  #newBoardSheet .nb-repo.nb-repo-highlight { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
69
- #newBoardSheet .nb-repo-tag { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 6px; background: var(--accent-soft); color: var(--accent); font-size: 11px; font-weight: 600; vertical-align: 1px; white-space: nowrap; }
86
+ #newBoardSheet .nb-repo-tag { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 6px; background: var(--chip); color: var(--muted); font-size: 11px; font-weight: 600; vertical-align: 1px; white-space: nowrap; }
70
87
  #newBoardSheet .nb-box { display: flex; gap: 10px; align-items: flex-start; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
71
88
  #newBoardSheet .nb-box > span { min-width: 0; }
72
89
  #newBoardSheet .nb-box strong { display: block; overflow-wrap: anywhere; }
73
90
  #newBoardSheet .nb-box-no { border-color: color-mix(in srgb, var(--color-danger) 40%, var(--line)); }
74
91
  #newBoardSheet .nb-why { display: block; margin-top: 4px; color: var(--color-danger); font-size: 13px; }
75
- #newBoardSheet .nb-spin { flex: none; width: 16px; height: 16px; margin-top: 2px; border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: nb-spin .8s linear infinite; }
92
+ #newBoardSheet .nb-spin { flex: none; width: 16px; height: 16px; margin-top: 2px; border: 2px solid var(--line); border-top-color: var(--color-notice); border-radius: 50%; animation: nb-spin .8s linear infinite; }
76
93
  @keyframes nb-spin { to { transform: rotate(360deg); } }
77
- #newBoardSheet .nb-local { display: block; margin-top: 8px; font-size: 12px; color: var(--accent); }
94
+ #newBoardSheet .nb-local { display: block; margin-top: 8px; font-size: 12px; color: var(--muted); }
78
95
  #newBoardSheet .nb-selected { padding: 12px; border: 1px solid var(--line); border-radius: 10px; }
79
96
  #newBoardSheet .nb-clone { display: block; margin-top: 6px; overflow-wrap: anywhere; font-size: 12px; }
80
97
  #newBoardSheet [hidden] { display: none; }
@@ -110,11 +127,11 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
110
127
  @starting-style { .sidebar.ks-on .ks-label-hint { opacity: 0; } }
111
128
  /* The label is the bar's containing block: left 0 is the BOARDS text's edge, and right -8px crosses the label's 8px
112
129
  side margin (.sb-label) to the rows' right edge. The bar sits in the label's 4px bottom margin. */
113
- .sidebar .ks-bar { position: absolute; left: 0; right: -8px; top: 100%; height: 2px; margin-top: 1px; background: var(--color-notice);
114
- transform-origin: 0 50%; animation: ks-drain 3s linear forwards; }
130
+ .sidebar .ks-bar { position: absolute; left: 0; right: -8px; top: 100%; height: 2px; margin-top: 1px; background: var(--accent);
131
+ transform-origin: 0 50%; animation: ks-drain linear forwards; }
115
132
  @keyframes ks-drain { to { transform: scaleX(0); } }
116
133
  .sidebar .ks-u { transition: color 200ms linear; }
117
- .sidebar.ks-on .ks-u { color: var(--color-notice); transition: color 150ms linear; }
134
+ .sidebar.ks-on .ks-u { color: var(--accent); transition: color 150ms linear; }
118
135
  @starting-style { .sidebar.ks-on .ks-u { color: inherit; } }
119
136
  @media (prefers-reduced-motion: reduce) {
120
137
  .sidebar .ks-bar { animation: none; }
@@ -126,6 +143,8 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
126
143
 
127
144
  /* A site's status (hub/home.mjs statusLine): the lane chips wrap on their own line, and the status word goes below them. */
128
145
  .sidebar .sb-status { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; min-width: 0; }
146
+ .sidebar .sb-ctx { font-size: 12px; color: var(--muted); }
147
+ .sidebar .sb-ctx.stale { opacity: .6; }
129
148
  .sidebar .lcs { order: -1; flex: 1 1 100%; min-width: 0; display: flex; flex-wrap: wrap; gap: 4px; }
130
149
  .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); }
131
150
  .sidebar .lc-review { --tone: var(--accent); } .sidebar .lc-progress { --tone: var(--color-notice); } .sidebar .lc-todo { --tone: var(--muted); }
@@ -134,7 +153,8 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
134
153
  @media (prefers-reduced-motion: no-preference) { .sidebar .dot.pulse { animation: sb-pulse 1.4s ease-in-out infinite; } }
135
154
  .sidebar .word { flex: none; font-weight: 600; color: var(--tone); }
136
155
  .sidebar .t-down, .sidebar .t-needs { --tone: var(--color-danger); }
137
- .sidebar .t-starting, .sidebar .t-asleep { --tone: var(--muted); }
156
+ .sidebar .t-starting { --tone: var(--color-notice); }
157
+ .sidebar .t-stopped, .sidebar .t-asleep { --tone: var(--muted); }
138
158
  .sidebar .t-working { --tone: var(--color-notice); }
139
159
  .sidebar .t-building { --tone: var(--color-notice); }
140
160
  .sidebar .t-ok { --tone: var(--color-success); }
@@ -162,7 +182,7 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
162
182
  .sidebar .sb-scroll { padding-top: max(8px, env(safe-area-inset-top)); padding-bottom: 6px; }
163
183
  }
164
184
 
165
- .sidebar .u-tone-ok { --u: var(--accent); }
185
+ .sidebar .u-tone-ok { --u: var(--muted); }
166
186
  .sidebar .u-tone-high { --u: var(--color-notice); }
167
187
  .sidebar .u-tone-full { --u: var(--color-danger); }
168
188
  .sidebar .u-tone-stale, .sidebar .u-tone-none { --u: var(--muted); }
@@ -24,7 +24,7 @@
24
24
  // A phone has no keys to press, so it never shows the shortcuts sheet.
25
25
  const DESKTOP = '(hover: hover) and (pointer: fine)';
26
26
  const TWICE_MS = 1000;
27
- // sidebar.css .ks-bar drains over the same time.
27
+ // paint() sets the bar's duration from this and its delay from switching.started.
28
28
  const IDLE_MS = 3000;
29
29
  const FIELD = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';
30
30
  const MODIFIERS = new Set(['Shift', 'Meta', 'Control', 'Alt', 'AltGraph', 'CapsLock', 'Fn']);
@@ -46,7 +46,7 @@
46
46
  const aside = document.getElementById('sidebar');
47
47
  const scrim = document.getElementById('sidebarScrim');
48
48
  // ui.js replaces the BoardUI global when it loads after this, so the renderers are held here.
49
- const { sidebar, updateRow, othersWaiting, switchKeys, shortcuts } = globalThis.BoardUI || {};
49
+ const { sidebar, failedStart, updateRow, othersWaiting, switchKeys, shortcuts } = globalThis.BoardUI || {};
50
50
  const none = { setHere() {}, setData() {}, setUpdate() {}, checkDevice() {}, close() {}, toggle() {}, keydown() {}, pointerdown() {} };
51
51
  if (!aside || !sidebar) return none;
52
52
  if (!data) { try { data = JSON.parse(read(DATA_KEY) || 'null'); } catch { data = null; } }
@@ -64,22 +64,28 @@
64
64
  const logo = [...document.querySelectorAll('.top a.hub')].find(pageLogo);
65
65
  if (!logo) return;
66
66
  const n = othersWaiting(data, here);
67
- logo.setAttribute('aria-label', `Boards${update ? ', new version ready' : ''}${n ? `, ${n} waiting on other boards` : ''}`);
67
+ logo.setAttribute('aria-label', `Boards${update ? ', new version ready' : data?.upgrade ? ', update available' : ''}${n ? `, ${n} waiting on other boards` : ''}`);
68
68
  }
69
- // switching: {timer, opened} while switch mode shows, opened when it opened the drawer.
69
+ // switching: {opened, started, timer} while switch mode shows, opened when it opened the drawer, started on performance.now().
70
70
  let switching = null;
71
71
  let painted = false;
72
72
  // A repaint keeps the drawer's scroll, so a new copy doesn't move the list under a finger.
73
73
  function paint() {
74
+ html.classList.toggle('sb-update', Boolean(update || data?.upgrade));
74
75
  if (!data) return;
75
76
  const scroll = aside.querySelector('.sb-scroll');
76
77
  const top = painted ? scroll?.scrollTop : undefined;
77
78
  // Screen readers announce a role="status" node only when its own content changes, so the node outlives the repaint.
78
79
  const held = aside.querySelector('.sb-update');
79
80
  aside.innerHTML = sidebar(data, { here, device, switching: Boolean(switching) });
81
+ if (switching) {
82
+ const bar = aside.querySelector('.ks-bar');
83
+ bar.style.animationDelay = `${switching.started - performance.now()}ms`;
84
+ bar.style.animationDuration = `${IDLE_MS}ms`;
85
+ }
80
86
  const fresh = aside.querySelector('.sb-update');
81
87
  if (held) fresh.replaceWith(held);
82
- const row = updateRow(update);
88
+ const row = updateRow(update, data.upgrade);
83
89
  if (!held || row !== updateHTML) (held || fresh).innerHTML = row;
84
90
  updateHTML = row;
85
91
  const list = aside.querySelector('.sb-scroll');
@@ -95,7 +101,6 @@
95
101
  }
96
102
  function setUpdate(state) {
97
103
  update = state;
98
- html.classList.toggle('sb-update', Boolean(update));
99
104
  paint();
100
105
  }
101
106
 
@@ -148,22 +153,89 @@
148
153
  onClose: () => aside.querySelector('[data-new-board]')?.focus({ preventScroll: true }),
149
154
  });
150
155
  }
151
- aside.addEventListener('click', async (e) => {
156
+ aside.addEventListener('click', (e) => {
152
157
  const refreshApp = e.target.closest?.('[data-app-reload]');
153
158
  if (refreshApp) {
154
159
  if (!refreshApp.disabled && update !== 'reloading') { setUpdate('reloading'); reload(); }
155
160
  return;
156
161
  }
162
+ const upgradeApp = e.target.closest?.('[data-app-upgrade]');
163
+ if (upgradeApp) { if (!upgradeApp.disabled) upgrade(upgradeApp); return; }
157
164
  if (e.target.closest?.('[data-new-board]')) { newBoard(); return; }
158
- const dismiss = e.target.closest?.('[data-dismiss-board]');
159
- if (!dismiss || dismiss.disabled) return;
160
- dismiss.disabled = true;
161
- try {
162
- const response = await fetch(`/_hub/new-board/${encodeURIComponent(dismiss.dataset.dismissBoard)}`, { method: 'DELETE' });
163
- if (!response.ok) throw new Error(await response.text());
164
- await refresh();
165
- } catch (e) { dismiss.disabled = false; globalThis.BoardDialogs?.notice(e.message); }
165
+ const failed = e.target.closest?.('[data-failed-start]');
166
+ if (failed) openFailed(failed.dataset.failedStart);
166
167
  });
168
+ // Upgrade asks the hub to run kickflip update (docs/adr/0161-the-hub-offers-a-newer-kickflip-from-npm.md). The row
169
+ // keeps its words until the hub answers with what it did, and a refusal shows as a failed update.
170
+ let upgrading = false;
171
+ async function upgrade(button) {
172
+ if (upgrading) return;
173
+ upgrading = true;
174
+ button.disabled = true;
175
+ let next;
176
+ try {
177
+ const response = await fetch('/_hub/upgrade', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
178
+ const body = await response.json().catch(() => ({}));
179
+ next = response.ok ? body.upgrade : { ...data.upgrade, state: 'failed', error: body.error || 'The hub didn’t answer.' };
180
+ } catch {
181
+ next = { ...data.upgrade, state: 'failed', error: 'The hub didn’t answer.' };
182
+ } finally { upgrading = false; }
183
+ setData({ ...data, upgrade: next });
184
+ }
185
+ // A failed start's sheet (docs/adr/0130-a-failed-start-retries-from-its-sheet.md). Dismiss deletes its Starting
186
+ // record; Retry starts it again, and its row reads Starting… until the hub's next copy says otherwise.
187
+ function openFailed(route) {
188
+ const s = (data?.starting || []).find((x) => x.route === route && x.failed);
189
+ if (!s || document.getElementById('failedStartSheet')) return;
190
+ const previous = document.activeElement;
191
+ const el = document.createElement('dialog');
192
+ el.setAttribute('id', 'failedStartSheet');
193
+ el.setAttribute('class', 'sheet');
194
+ el.setAttribute('aria-labelledby', 'failedStartTitle');
195
+ el.innerHTML = failedStart(s, { home: data.home });
196
+ let sending = false;
197
+ const done = () => { if (!sending) el.close(); };
198
+ async function act(request, after) {
199
+ sending = true;
200
+ el.querySelectorAll('.fs-actions button').forEach((b) => { b.disabled = true; });
201
+ const refused = el.querySelector('.fs-refused');
202
+ refused.hidden = true;
203
+ try {
204
+ const response = await fetch(`/_hub/new-board/${encodeURIComponent(route)}${request.path || ''}`, request.options);
205
+ if (!response.ok) throw new Error(await response.text());
206
+ after();
207
+ sending = false;
208
+ el.close();
209
+ refresh().catch(() => {});
210
+ } catch (e) {
211
+ sending = false;
212
+ el.querySelectorAll('.fs-actions button').forEach((b) => { b.disabled = false; });
213
+ refused.textContent = e.message;
214
+ refused.hidden = false;
215
+ }
216
+ }
217
+ el.addEventListener('click', (e) => {
218
+ if (sending) return;
219
+ if (e.target === el || e.target.closest('[data-failed-close]')) { done(); return; }
220
+ if (e.target.closest('[data-failed-dismiss]')) {
221
+ act({ options: { method: 'DELETE' } }, () => setData({ ...data, starting: data.starting.filter((x) => x.route !== route) }));
222
+ } else if (e.target.closest('[data-failed-retry]')) {
223
+ act({ path: '/retry', options: { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' } }, () => {
224
+ const { error, ...rest } = s;
225
+ const again = { ...rest, phase: 'starting', askedAt: new Date().toISOString(), failed: false };
226
+ setData({ ...data, starting: data.starting.map((x) => (x.route === route ? again : x)) });
227
+ });
228
+ }
229
+ });
230
+ el.addEventListener('cancel', (e) => { e.preventDefault(); done(); });
231
+ el.addEventListener('close', () => {
232
+ el.remove();
233
+ if (previous?.isConnected) previous.focus({ preventScroll: true });
234
+ });
235
+ document.body.appendChild(el);
236
+ el.showModal();
237
+ (el.querySelector('[data-failed-retry]') || el.querySelector('[data-failed-dismiss]')).focus({ preventScroll: true });
238
+ }
167
239
  document.addEventListener('click', (e) => {
168
240
  if (e.metaKey || e.ctrlKey || e.shiftKey) return;
169
241
  if (e.target.closest?.('[data-sidebar-close]')) { e.preventDefault(); if (rail.matches) collapse(true); else close(); return; }
@@ -177,7 +249,7 @@
177
249
  let lastS = -Infinity;
178
250
  function startSwitch() {
179
251
  lastS = -Infinity;
180
- switching = { opened: !rail.matches && !isOpen(), timer: setTimeout(() => endSwitch(), IDLE_MS) };
252
+ switching = { opened: !rail.matches && !isOpen(), started: performance.now(), timer: setTimeout(() => endSwitch(), IDLE_MS) };
181
253
  if (switching.opened) open();
182
254
  aside.classList.add('ks-on');
183
255
  paint();