@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
@@ -1,43 +1,48 @@
1
- // Native confirmations cannot label their actions on iOS; labelled confirmations use a modal sheet.
2
- // Load before any page script that asks (docs/adr/0023-a-card-can-be-held-and-every-card-button-confirms.md).
1
+ // Every confirmation and notice is the browser's own window.confirm or window.alert; no page imitates a native dialog.
2
+ // Load before any page script that asks (docs/adr/0146-confirmations-are-the-browsers-own.md).
3
3
  (function () {
4
- // linear: Hub's /_hub/api/linear answer, or loadLinear() fetches it the first time Linear is the source. A New board's
5
- // "New project…" reads as new_project, which the hub creates on Start; Board Settings creates it with createProject.
6
- function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = {}, loadLinear, isNew = false, change = () => {}, createProject } = {}) {
4
+ // linear: Hub's /_hub/api/linear answer, else loadLinear() fetches it once Linear is the source. Linear is picked only
5
+ // while Hub's key works; tapping it without one opens Hub's key modal over the page (linear-key.js,
6
+ // docs/adr/0117-a-board-picks-linear-only-while-hubs-key-works.md). A New board's "New project…" reads as
7
+ // new_project, which the hub creates on Start; Board Settings creates it with createProject. change(value, valid,
8
+ // {loaded, typing}) follows each change: loaded when only Linear's lists arrived, typing while a text field is being typed in.
9
+ // GitHub issues needs repo(), the board's GitHub owner/name: without one, as on a board whose repo is another git
10
+ // remote, it can't be picked and says why (docs/adr/0127-a-board-can-work-on-any-git-remote.md).
11
+ function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject } = {}) {
7
12
  const { esc } = BoardUI;
8
13
  const draft = { prefix: 'INT', ...value };
9
- let creating = false, createError = '', destroyed = false;
10
- const fetchLinear = () => {
11
- if (!loadLinear || draft.source !== 'linear') return;
12
- const load = loadLinear;
13
- loadLinear = null;
14
+ let creating = false, createError = '', destroyed = false, asking = false, noGithub = false;
15
+ const works = () => Boolean(linear?.set && !linear.refused);
16
+ async function load() {
14
17
  linear = { loading: true };
15
- load().catch((e) => ({ set: true, error: e.message || 'Is the host awake?' })).then((next) => {
16
- if (destroyed) return;
17
- linear = next; pickDefaults(); render(); change(read(), valid(), { loaded: true });
18
- });
19
- };
20
- const workspace = () => linear.url_key ?? linear.workspace ?? draft.workspace;
18
+ render();
19
+ linear = await loadLinear().catch((e) => ({ set: true, error: e.message || 'Is the host awake?' }));
20
+ }
21
+ const workspace = () => linear?.url_key ?? linear?.workspace ?? draft.workspace;
21
22
  const newName = () => (draft.newName ?? title()).trim();
22
- const typed = () => !linear.set && !linear.loading;
23
- const typedProject = () => (draft.project && draft.project !== '__new' ? draft.project : isNew ? title() : '').trim();
23
+ const ready = () => works() && !linear.error;
24
24
  const read = () => draft.source === 'internal' ? { source: 'internal', prefix: draft.prefix || 'INT' }
25
- : draft.source === 'github' ? { source: 'github' }
26
- : typed() ? { source: 'linear', workspace: draft.workspace || '', team: draft.team || '', project: typedProject() }
25
+ : draft.source === 'github' ? { source: 'github', ...(draft.account ? { account: draft.account } : {}) }
26
+ : !ready() ? { source: 'linear', workspace: draft.workspace, team: draft.team, project: draft.project }
27
27
  : draft.project === '__new' && isNew ? { source: 'linear', workspace: workspace(), team: draft.team, new_project: newName() }
28
28
  : { source: 'linear', workspace: workspace(), team: draft.team, project: draft.project };
29
29
  const valid = () => draft.source === 'internal' ? /^[A-Z]{2,6}$/.test(draft.prefix || 'INT')
30
30
  : draft.source === 'github' ? Boolean(repo())
31
- : typed() ? /^[a-z\d][a-z\d-]*$/i.test(draft.workspace || '') && /^[A-Z][A-Z0-9]*$/.test(draft.team || '') && Boolean(typedProject())
32
- : Boolean(linear.set && !linear.error && draft.team && draft.project && (draft.project !== '__new' || (isNew && newName())));
31
+ : Boolean(ready() && draft.team && draft.project && (draft.project !== '__new' || (isNew && newName())));
33
32
  const pickDefaults = () => {
34
- if (draft.source !== 'linear' || !linear.set) return;
33
+ if (draft.source !== 'linear' || !ready()) return;
35
34
  if (!linear.teams?.some((t) => t.key === draft.team)) draft.team = linear.teams?.[0]?.key;
36
- draft.project ||= isNew ? '__new' : linear.projects?.find((p) => p.team === draft.team)?.id;
35
+ if (draft.project === '__new' || linear.projects?.some((p) => p.id === draft.project && p.team === draft.team)) return;
36
+ draft.project = isNew ? '__new' : linear.projects?.find((p) => p.team === draft.team)?.id;
37
37
  };
38
+ // A board already on Linear whose Hub key is missing or refused keeps its source and shows why.
39
+ function needsKey() {
40
+ const why = linear.refused ? 'Linear refused Hub’s key' : 'Hub has no Linear key';
41
+ return `<p class="muted small" role="status">${why}, so this board can’t list Linear’s teams or create issues there.</p><p><button type="button" data-tk-key>Add Hub’s Linear key</button></p>`;
42
+ }
38
43
  function linearDetail() {
39
- if (linear.loading) return '<p class="muted small" role="status">Loading Linear…</p>';
40
- if (!linear.set) return `<div class="tk-grid"><label class="tk-field"><span>Workspace</span><input data-tk="workspaceText" value="${esc(draft.workspace || '')}" placeholder="your-workspace" autocapitalize="off" autocomplete="off" spellcheck="false"></label><label class="tk-field"><span>Team</span><input data-tk="teamText" value="${esc(draft.team || '')}" placeholder="ENG" autocapitalize="characters" autocomplete="off" spellcheck="false"></label></div><label class="tk-field"><span>Project</span><input data-tk="projectText" value="${esc(typedProject())}" placeholder="Project name" autocomplete="off"></label><p class="muted small">The board’s session creates and moves its issues through its Linear MCP, and creates the project if it’s new. <a href="/_hub/#linear">A Linear key on Hub</a> adds team and project lists and lets kickflip do it itself.</p>`;
44
+ if (!linear || linear.loading) return '<p class="muted small" role="status">Loading Linear…</p>';
45
+ if (!works()) return needsKey();
41
46
  if (linear.error) return `<p class="muted small" role="alert">Couldn’t list Linear’s teams: ${esc(linear.error)}</p>`;
42
47
  const project = linear.projects?.find((p) => p.id === draft.project);
43
48
  const team = esc(draft.team || '');
@@ -47,17 +52,41 @@
47
52
  return `<div class="tk-grid"><label class="tk-field"><span>Team</span><select data-tk="team">${(linear.teams || []).map((t) => `<option value="${esc(t.key)}"${t.key === draft.team ? ' selected' : ''}>${esc(t.key)} · ${esc(t.name)}</option>`).join('')}</select></label><label class="tk-field"><span>Project</span><select data-tk="project">${(linear.projects || []).filter((p) => p.team === draft.team).map((p) => `<option value="${esc(p.id)}"${p.id === draft.project ? ' selected' : ''}>${esc(p.name)}</option>`).join('')}<option value="__new"${draft.project === '__new' ? ' selected' : ''}>New project…</option></select></label></div>${draft.project === '__new' ? `<div class="tk-new"><input data-tk="newName" value="${esc(draft.newName ?? title())}" aria-label="New Linear project name" placeholder="Project name" maxlength="80" autocomplete="off">${isNew ? '' : `<button type="button" data-tk-create${creating ? ' disabled' : ''}>${creating ? 'Creating…' : 'Create'}</button>`}</div>` : ''}${createError ? `<p class="muted small" role="alert">${esc(createError)}</p>` : ''}${note}`;
48
53
  }
49
54
  function render() {
50
- const segment = `<div class="tk-seg" role="group" aria-label="Ticket source">${[['linear', 'Linear'], ['github', 'GitHub issues'], ['internal', 'Internal']].map(([source, name]) => `<button type="button" data-tk-source="${source}" aria-pressed="${draft.source === source}">${name}</button>`).join('')}</div>`;
55
+ const off = !repo();
56
+ const segment = `<div class="tk-seg" role="group" aria-label="Ticket source">${[['linear', 'Linear'], ['github', 'GitHub issues'], ['internal', 'Internal']].map(([source, name]) => `<button type="button" data-tk-source="${source}" aria-pressed="${draft.source === source}"${source === 'github' && off ? ' aria-disabled="true"' : ''}>${name}</button>`).join('')}</div>`;
57
+ const why = `<p class="muted small" role="status">GitHub issues need a repo on GitHub, and ${isNew ? 'the repo chosen above' : 'this board’s repo'} isn’t on GitHub.</p>`;
51
58
  let detail;
52
- if (draft.source === 'internal') detail = `<label class="tk-field"><span>Prefix</span><input data-tk="prefix" value="${esc(draft.prefix)}" placeholder="INT" maxlength="6" pattern="[A-Z]{2,6}" title="2–6 uppercase letters" autocapitalize="characters" autocomplete="off" spellcheck="false"></label><p class="muted small" data-tk-example>Cards number ${esc(draft.prefix || 'INT')}-1, ${esc(draft.prefix || 'INT')}-2… on this board. The badge opens the card.</p>`;
53
- else if (draft.source === 'github') detail = `<div class="who tk-static"><span>Issues in ${esc(repo())}<span class="muted small sub">${isNew ? 'The repo chosen above' : 'The board’s repo, set above'}. A new card opens an issue there; its badge reads #41.</span></span></div>`;
59
+ if (draft.source === 'github' && off) detail = why;
60
+ else if (draft.source === 'internal') detail = `<label class="tk-field"><span>Prefix</span><input data-tk="prefix" value="${esc(draft.prefix)}" placeholder="INT" maxlength="6" pattern="[A-Z]{2,6}" title="2–6 uppercase letters" autocapitalize="characters" autocomplete="off" spellcheck="false"></label><p class="muted small" data-tk-example>Cards number ${esc(draft.prefix || 'INT')}-1, ${esc(draft.prefix || 'INT')}-2… on this board. The badge opens the card.</p>`;
61
+ else if (draft.source === 'github') detail = `<div class="who tk-static"><span>Issues in ${esc(repo())}<span class="muted small sub">${isNew ? 'The repo chosen above' : 'The board’s repo, set above'}. A new card opens an issue there${draft.account ? ` as gh account ${esc(draft.account)}` : ''}; its badge reads #41.</span></span></div>`;
54
62
  else detail = linearDetail();
55
- root.innerHTML = `${segment}<div class="tk-detail">${detail}</div>`;
56
- if (draft.source === 'linear' && linear.set && !linear.error && !linear.loading) {
63
+ root.innerHTML = `${segment}<div class="tk-detail">${noGithub && off && draft.source !== 'github' ? why : ''}${detail}</div>`;
64
+ if (draft.source === 'linear' && ready()) {
57
65
  root.querySelector('[data-tk="team"]').value = draft.team || '';
58
66
  root.querySelector('[data-tk="project"]').value = draft.project || '';
59
67
  }
60
68
  }
69
+ // Linear is checked again on each tap until Hub's key works, so a key added elsewhere counts.
70
+ async function pickLinear() {
71
+ if (asking || linear?.loading) return;
72
+ const before = draft.source;
73
+ draft.source = 'linear';
74
+ if (!ready()) await load();
75
+ if (destroyed) return;
76
+ if (!works()) {
77
+ draft.source = before;
78
+ render();
79
+ asking = true;
80
+ const added = await LinearKey.open();
81
+ asking = false;
82
+ if (destroyed || !added) return;
83
+ draft.source = 'linear';
84
+ await load();
85
+ if (destroyed) return;
86
+ }
87
+ pickDefaults();
88
+ render(); root.querySelector('[data-tk-source="linear"]').focus(); change(read(), valid());
89
+ }
61
90
  const click = async (e) => {
62
91
  if (e.target.closest('[data-tk-create]')) {
63
92
  const name = newName();
@@ -73,83 +102,36 @@
73
102
  render(); change(read(), valid());
74
103
  return;
75
104
  }
105
+ if (e.target.closest('[data-tk-key]')) { pickLinear(); return; }
76
106
  const button = e.target.closest('[data-tk-source]');
77
107
  if (!button) return;
108
+ noGithub = button.dataset.tkSource === 'github' && !repo();
109
+ if (noGithub) { render(); root.querySelector('[data-tk-source="github"]').focus(); return; }
110
+ if (button.dataset.tkSource === 'linear') { pickLinear(); return; }
78
111
  draft.source = button.dataset.tkSource;
79
- fetchLinear();
80
- pickDefaults();
81
112
  render(); root.querySelector(`[data-tk-source="${draft.source}"]`).focus(); change(read(), valid());
82
113
  };
83
114
  const input = (e) => {
84
115
  const field = e.target.dataset.tk;
85
- if (!field) return;
86
- if (field.endsWith('Text')) {
87
- const name = field.slice(0, -4);
88
- draft[name] = name === 'team' ? e.target.value.toUpperCase().trim() : e.target.value.trim();
89
- if (name === 'team') e.target.value = draft.team;
90
- change(read(), valid());
91
- return;
92
- }
116
+ if (!field || (e.type === 'input' && e.target.tagName === 'SELECT')) return;
93
117
  draft[field] = field === 'prefix' ? e.target.value.toUpperCase() : e.target.value;
94
118
  if (field === 'prefix') e.target.value = draft.prefix;
95
119
  if (field === 'team') draft.project = isNew ? '__new' : linear.projects?.find((p) => p.team === draft.team)?.id;
96
120
  if (field === 'team' || field === 'project') { createError = ''; render(); }
97
121
  if (field === 'prefix') root.querySelector('[data-tk-example]').textContent = `Cards number ${draft.prefix || 'INT'}-1, ${draft.prefix || 'INT'}-2… on this board. The badge opens the card.`;
98
- change(read(), valid());
122
+ change(read(), valid(), { typing: e.type === 'input' });
99
123
  };
100
124
  root.addEventListener('click', click);
101
125
  root.addEventListener('input', input);
102
126
  root.addEventListener('change', input);
103
- fetchLinear();
104
127
  render();
128
+ if (draft.source === 'linear' && !linear) load().then(() => { if (destroyed) return; pickDefaults(); render(); change(read(), valid(), { loaded: true }); });
105
129
  return { read, valid, render, nameChanged() {
106
130
  const field = root.querySelector('[data-tk="newName"]');
107
131
  if (field && draft.newName === undefined) field.value = title();
108
- const typedField = root.querySelector('[data-tk="projectText"]');
109
- if (typedField && !draft.project) typedField.value = title();
110
132
  },
111
133
  destroy() { destroyed = true; root.removeEventListener('click', click); root.removeEventListener('input', input); root.removeEventListener('change', input); } };
112
134
  }
113
- let close = null;
114
- function sheet(message, { ok, cancel = 'Cancel', signal }) {
115
- close?.(false);
116
- return new Promise((resolve) => {
117
- const previous = document.activeElement;
118
- const el = document.createElement('dialog');
119
- el.setAttribute('class', 'confirm-sheet');
120
- el.setAttribute('role', 'alertdialog');
121
- el.setAttribute('aria-modal', 'true');
122
- el.setAttribute('aria-labelledby', 'confirmSheetText');
123
- el.innerHTML = '<div class="box"><p id="confirmSheetText"></p><button type="button" data-answer="ok"></button></div>'
124
- + '<div class="box"><button type="button" class="cancel" data-answer="cancel"></button></div>';
125
- el.querySelector('p').textContent = message;
126
- el.querySelector('[data-answer="ok"]').textContent = ok;
127
- el.querySelector('[data-answer="cancel"]').textContent = cancel;
128
- let settled = false;
129
- const abort = () => done(false);
130
- function done(yes) {
131
- if (settled) return;
132
- settled = true;
133
- close = null;
134
- signal?.removeEventListener('abort', abort);
135
- el.close();
136
- el.remove();
137
- if (previous?.isConnected) previous.focus({ preventScroll: true });
138
- resolve(yes);
139
- }
140
- el.addEventListener('click', (e) => {
141
- const b = e.target.closest('[data-answer]');
142
- if (b || e.target === el) done(b?.dataset.answer === 'ok');
143
- });
144
- el.addEventListener('cancel', (e) => { e.preventDefault(); done(false); });
145
- el.addEventListener('close', () => done(false));
146
- signal?.addEventListener('abort', abort, { once: true });
147
- document.body.appendChild(el);
148
- el.showModal();
149
- el.querySelector('.cancel').focus();
150
- close = done;
151
- });
152
- }
153
135
  function fitSheet(sheet) {
154
136
  const vv = window.visualViewport;
155
137
  // iOS keeps fixed elements on the layout viewport; --kb is the part covered by its keyboard.
@@ -194,9 +176,9 @@
194
176
  el.setAttribute('class', 'sheet');
195
177
  el.setAttribute('aria-labelledby', 'newBoardTitle');
196
178
  const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
197
- const draft = { name: '', purpose: '', github: '', branch: '', tickets: { source: 'internal', prefix: 'INT' } };
179
+ const draft = { name: '', purpose: '', github: '', remote: '', branch: '', tickets: { source: 'internal', prefix: 'INT' } };
198
180
  let tickets;
199
- let repos = [], pasted, selected, edited = false, sending = false, closed = false, generation = 0, lookupTimer, linear;
181
+ let repos = [], signedIn = false, pasted, selected, edited = false, sending = false, closed = false, generation = 0, lookupTimer, linear;
200
182
  const controller = new AbortController();
201
183
  el.innerHTML = `<form class="nb-form"><div class="sheet-head"><h2 id="newBoardTitle" tabindex="-1" autofocus>${current ? 'Change repo' : 'New board'}</h2><button type="button" data-cancel-new aria-label="Close sheet">✕</button></div><div data-repo-content></div><p class="nb-error" role="alert" hidden></p><div class="nb-actions sheet-actions"><button type="button" data-cancel-new>Cancel</button><button type="submit" class="primary" hidden disabled>${current ? 'Use repo' : 'Start'}</button></div></form>`;
202
184
  const content = el.querySelector('[data-repo-content]');
@@ -211,10 +193,19 @@
211
193
  return d;
212
194
  };
213
195
  const fail = (e) => { if (!closed) { error.textContent = e.message; error.hidden = false; } };
214
- function sync() { start.disabled = sending || !draft.github || !draft.branch || (!current && (!draft.name.trim() || !tickets?.valid())); }
215
- const row = (r, highlighted = false) => `<button type="button" class="nb-repo${highlighted ? ' nb-repo-highlight' : ''}" data-repo="${esc(r.github)}"><strong>${esc(r.github)}</strong>${r.listed === false ? '<span class="nb-repo-tag">Not in your list</span>' : ''}<span class="nb-help">${r.private ? 'Private' : 'Public'} · <time datetime="${esc(r.pushedAt)}">${r.pushedAt ? `Pushed ${esc(new Date(r.pushedAt).toLocaleString())}` : r.listed === false ? 'Last push unknown' : 'No pushes yet'}</time></span><span class="nb-help">${esc(r.description)}</span><span class="nb-local">${r.cloned ? `✓ In .repos${r.shared.length ? ` · shared with ${esc(r.shared.map((b) => b.title).join(', '))}` : ' · already cloned'}` : 'Not cloned in .repos'}</span></button>`;
196
+ // A repo already on the host joins from its folder instead (docs/adr/0143-kickflip-join-adds-the-current-repository-as-a-board.md).
197
+ const joinHint = current ? '' : '<p class="nb-help" data-join-hint>A repo already on the host running Kickflip? Run <code>kickflip join</code> in its folder.</p>';
198
+ function sync() { start.disabled = sending || !(draft.github || draft.remote) || !draft.branch || (!current && (!draft.name.trim() || !tickets?.valid())); }
199
+ // A repo row's id is its GitHub owner/name, or the git link of a repo on another host, which the row names by
200
+ // host/path and labels with its host where a GitHub row says Private or Public
201
+ // (docs/adr/0127-a-board-can-work-on-any-git-remote.md).
202
+ const idOf = (r) => r.github || r.remote;
203
+ const nameOf = (r) => r.github || r.name;
204
+ const access = (r) => (r.remote ? r.host : r.private ? 'Private' : 'Public');
205
+ const row = (r, highlighted = false) => `<button type="button" class="nb-repo${highlighted ? ' nb-repo-highlight' : ''}" data-repo="${esc(idOf(r))}"><strong>${esc(nameOf(r))}</strong>${r.listed === false ? '<span class="nb-repo-tag">Not in your list</span>' : ''}<span class="nb-help">${esc(access(r))} · <time datetime="${esc(r.pushedAt)}">${r.pushedAt ? `Pushed ${esc(new Date(r.pushedAt).toLocaleString())}` : r.listed === false ? 'Last push unknown' : 'No pushes yet'}</time></span><span class="nb-help">${esc(r.description)}</span><span class="nb-local">${r.cloned ? `✓ In .repos${r.shared.length ? ` · shared with ${esc(r.shared.map((b) => b.title).join(', '))}` : ' · already cloned'}` : 'Not cloned in .repos'}</span></button>`;
216
206
  const results = (html) => { el.querySelector('[data-repo-results]').innerHTML = html; };
217
207
  function rows(query = '') {
208
+ if (!signedIn) { results(''); return; }
218
209
  const list = repos.filter((r) => `${r.github} ${r.description}`.toLowerCase().includes(query.trim().toLowerCase()));
219
210
  results(`<p class="nb-help" role="status">${list.length} of ${repos.length} repos · Newest push first</p><div class="nb-repo-list">${list.map((r) => row(r)).join('')}</div>${list.length ? '' : '<p class="nb-help">No matching repos. Try an owner or repo name.</p>'}`);
220
211
  }
@@ -225,8 +216,20 @@
225
216
  const q = value.trim();
226
217
  const host = /^(?:[a-z][a-z\d+.-]*:\/\/(?:[^@/\s]+@)?|[^@/\s]+@)([^/:?#\s]+)/i.exec(q);
227
218
  const reason = (name, message) => results(`<div class="nb-box nb-box-no" role="alert"><span><strong>${esc(name)}</strong><span class="nb-why">${esc(message)}</span></span></div>`);
219
+ const checking = (name) => results(`<p class="nb-help" role="status">Checking the hub can reach it…</p><div class="nb-box"><span class="nb-spin" aria-hidden="true"></span><span><strong>${esc(name)}</strong><span class="nb-help">Checking…</span></span></div>`);
220
+ const check = (query, name) => async () => {
221
+ try {
222
+ const repo = await request(`/_hub/repo?${query}`);
223
+ if (closed || generation !== version) return;
224
+ pasted = repo;
225
+ results(`<p class="nb-help" role="status">From your link</p><div class="nb-repo-list">${row(repo)}</div>`);
226
+ } catch (e) { if (!closed && generation === version) reason(name, e.message); }
227
+ };
228
+ if (/^(?:file:|[/~]|\.\.?\/)/i.test(q)) { reason(q, 'Paste the repo’s remote link. Local folders don’t work here.'); return; }
228
229
  if (host && !/^(?:www\.)?github\.com$/i.test(host[1])) {
229
- reason(q.replace(/^[a-z][a-z\d+.-]*:\/\//i, '').replace(/^[^@/\s]+@/, '').replace(/:/, '/').replace(/\.git$/i, ''), 'Only GitHub repos work here.');
230
+ const name = q.replace(/^[a-z][a-z\d+.-]*:\/\//i, '').replace(/^[^@/\s]+@/, '').replace(/:(?!\d+\/)/, '/').replace(/:\d+\//, '/').replace(/\.git\/?$/i, '');
231
+ checking(name);
232
+ check(`remote=${encodeURIComponent(q)}`, name)();
230
233
  return;
231
234
  }
232
235
  const link = /^(?:https?:\/\/(?:www\.)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/\s?#]+\/[^/\s?#]+)(?:\/[^\s]*)?(?:[?#][^\s]*)?$/i.exec(q);
@@ -237,17 +240,9 @@
237
240
  const github = link ? link[1].replace(/\.git$/i, '') : q;
238
241
  const listed = repos.find((r) => r.github.toLowerCase() === github.toLowerCase());
239
242
  if (listed) { results(`<p class="nb-help" role="status">Already in your list</p><div class="nb-repo-list">${row(listed, true)}</div>`); return; }
240
- results(`<p class="nb-help" role="status">Checking the hub can reach it…</p><div class="nb-box"><span class="nb-spin" aria-hidden="true"></span><span><strong>${esc(github)}</strong><span class="nb-help">Checking…</span></span></div>`);
241
- const check = async () => {
242
- try {
243
- const repo = await request(`/_hub/repo?github=${encodeURIComponent(github)}`);
244
- if (closed || generation !== version) return;
245
- pasted = repo;
246
- results(`<p class="nb-help" role="status">From your link</p><div class="nb-repo-list">${row(repo)}</div>`);
247
- } catch (e) { if (!closed && generation === version) reason(github, e.message); }
248
- };
249
- if (bare) lookupTimer = setTimeout(check, 500);
250
- else check();
243
+ checking(github);
244
+ if (bare) lookupTimer = setTimeout(check(`github=${encodeURIComponent(github)}`, github), 500);
245
+ else check(`github=${encodeURIComponent(github)}`, github)();
251
246
  }
252
247
  async function picker(fresh = false) {
253
248
  tickets?.destroy();
@@ -261,42 +256,45 @@
261
256
  try {
262
257
  const d = await request(`/_hub/repos${fresh ? '?fresh=1' : ''}`);
263
258
  if (closed || generation !== version) return;
259
+ signedIn = d.signedIn;
264
260
  if (!d.signedIn) {
265
- content.innerHTML = '<h3>GitHub isn’t connected</h3><p>gh isn’t signed in on the host running Kickflip. Sign in there to see your repos.</p><code>gh auth login</code><p><button type="button" data-check-auth>Check again</button></p>';
261
+ repos = [];
262
+ content.innerHTML = `<h3>GitHub isn’t connected</h3><p>gh isn’t signed in on the host running Kickflip. Sign in there to see your repos.</p><code>gh auth login</code><p><button type="button" data-check-auth>Check again</button></p><label class="nb-field"><span>Or paste a git link</span><input name="search" type="search" placeholder="https://host/path or user@host:path" autocomplete="off" autocapitalize="off" spellcheck="false"></label>${joinHint}<div data-repo-results></div>`;
266
263
  return;
267
264
  }
268
265
  repos = d.repos;
269
- content.innerHTML = '<p class="nb-next">1 · Choose a repo</p><p>Choose the GitHub repo this board will work on.</p><label class="nb-field"><span>Search repos or paste a GitHub link</span><input name="search" type="search" placeholder="Search or paste owner/name…" autocomplete="off" autocapitalize="off" spellcheck="false"></label><div data-repo-results></div>';
266
+ content.innerHTML = `<p class="nb-next">1 · Choose a repo</p><p>Choose the repo this board will work on: one of your GitHub repos, or any git repo by its link.</p><label class="nb-field"><span>Search repos or paste a git link</span><input name="search" type="search" placeholder="Search or paste owner/name or a git link…" autocomplete="off" autocapitalize="off" spellcheck="false"></label>${joinHint}<div data-repo-results></div>`;
270
267
  rows();
271
268
  } catch (e) { fail(e); content.innerHTML = '<button type="button" data-check-auth>Try again</button>'; }
272
269
  }
273
- async function pick(github) {
274
- selected = repos.find((r) => r.github === github) || (pasted?.github === github ? pasted : null);
270
+ async function pick(id) {
271
+ selected = repos.find((r) => r.github === id) || (pasted && idOf(pasted) === id ? pasted : null);
275
272
  if (!selected) return;
276
273
  const version = ++generation;
277
274
  clearTimeout(lookupTimer);
278
- draft.github = github;
275
+ draft.github = selected.github || '';
276
+ draft.remote = selected.remote || '';
279
277
  draft.branch = '';
280
- if (!edited) draft.name = github.split('/')[1];
278
+ if (!edited) draft.name = nameOf(selected).split('/').at(-1);
281
279
  error.hidden = true;
282
280
  const shared = selected.shared.map((b) => b.title).join(', ');
283
- content.innerHTML = `<button type="button" data-back-repos>‹ Back</button><p class="nb-next">${current ? 'Repo details' : '2 · Board details'}</p><div class="nb-selected"><strong>${esc(github)}</strong><span class="nb-help">${selected.private ? 'Private' : 'Public'}</span><p>${selected.cloned ? shared ? `Shared with ${esc(shared)}` : 'Already cloned in .repos' : 'Clones to'}<code class="nb-clone">${esc(selected.clone)}</code></p></div><label class="nb-field"><span>Default branch for this board</span><select name="branch" disabled><option value="">Loading branches…</option></select><span class="nb-help" data-branch-help></span></label><p class="nb-next">Each board’s builders work in separate worktrees off this clone. Sharing the clone doesn’t share their working files.</p>${current ? '<p class="nb-next">New builders and GitHub links will use the selected repo.</p>' : `<label class="nb-field"><span>Name</span><input name="name" type="text" value="${esc(draft.name)}" required maxlength="60" autocomplete="off"></label><label class="nb-field"><span>What it’s for</span><input name="purpose" type="text" value="${esc(draft.purpose)}" maxlength="240"><span class="nb-help">One line to get the board started. Optional.</span></label><p class="nb-next">The hub ${selected.cloned ? 'reuses the clone' : 'clones the repo'} and starts your board. You can keep using your other boards.</p>`}`;
281
+ content.innerHTML = `<button type="button" data-back-repos>‹ Back</button><p class="nb-next">${current ? 'Repo details' : '2 · Board details'}</p><div class="nb-selected"><strong>${esc(nameOf(selected))}</strong><span class="nb-help">${esc(access(selected))}</span><p>${selected.cloned ? shared ? `Shared with ${esc(shared)}` : 'Already cloned in .repos' : 'Clones to'}<code class="nb-clone">${esc(selected.clone)}</code></p></div><label class="nb-field"><span>Default branch for this board</span><select name="branch" disabled><option value="">Loading branches…</option></select><span class="nb-help" data-branch-help></span></label><p class="nb-next">Each board’s implementers work in separate worktrees off this clone. Sharing the clone doesn’t share their working files.</p>${current ? '<p class="nb-next">New implementers and GitHub links will use the selected repo.</p>' : `<label class="nb-field"><span>Name</span><input name="name" type="text" value="${esc(draft.name)}" required maxlength="60" autocomplete="off"></label><label class="nb-field"><span>What it’s for</span><input name="purpose" type="text" value="${esc(draft.purpose)}" maxlength="240"><span class="nb-help">One line to get the board started. Optional.</span></label><p class="nb-next">The hub ${selected.cloned ? 'reuses the clone' : 'clones the repo'} and starts your board. You can keep using your other boards.</p>`}`;
284
282
  if (!current) {
285
283
  content.querySelectorAll('.nb-next')[content.querySelectorAll('.nb-next').length - 1].insertAdjacentHTML('beforebegin', '<div class="nb-field tk-section" role="group" aria-labelledby="nbTicketsLabel"><span id="nbTicketsLabel">Tickets</span><span class="nb-help">Where this board’s cards get their tickets. You can change it in Board settings.</span><div data-board-tickets></div></div>');
286
- tickets = ticketsControl(content.querySelector('[data-board-tickets]'), { value: draft.tickets, ...(linear ? { linear } : { loadLinear: async () => { linear = await request('/_hub/api/linear'); return linear; } }),
284
+ tickets = ticketsControl(content.querySelector('[data-board-tickets]'), { value: draft.tickets, linear, loadLinear: async () => (linear = await request('/_hub/api/linear')),
287
285
  repo: () => draft.github, title: () => draft.name.trim(), isNew: true, change(value) { draft.tickets = value; sync(); } });
288
286
  }
289
287
  start.hidden = false;
290
288
  sync();
291
289
  try {
292
- const d = await request(`/_hub/branches?github=${encodeURIComponent(github)}`);
290
+ const d = await request(`/_hub/branches?${draft.remote ? 'remote' : 'github'}=${encodeURIComponent(id)}`);
293
291
  if (closed || generation !== version) return;
294
- draft.branch = current?.github === github && d.branches.includes(current.branch) ? current.branch : d.defaultBranch;
292
+ draft.branch = (current?.github || current?.remote) === id && d.branches.includes(current.branch) ? current.branch : d.defaultBranch;
295
293
  if (!d.branches.includes(draft.branch)) draft.branch = d.branches[0] || '';
296
294
  field('branch').innerHTML = d.branches.map((b) => `<option value="${esc(b)}"${b === draft.branch ? ' selected' : ''}>${esc(b)}${b === d.defaultBranch ? ' (repo default)' : ''}</option>`).join('');
297
295
  field('branch').value = draft.branch;
298
296
  field('branch').disabled = false;
299
- el.querySelector('[data-branch-help]').textContent = draft.branch ? `New builders branch from ${draft.branch}.` : 'This repo has no branches yet.';
297
+ el.querySelector('[data-branch-help]').textContent = draft.branch ? `New implementers branch from ${draft.branch}.` : 'This repo has no branches yet.';
300
298
  sync();
301
299
  } catch (e) { fail(e); }
302
300
  }
@@ -317,7 +315,7 @@
317
315
  form.addEventListener('change', (e) => {
318
316
  if (e.target.getAttribute('name') === 'branch') {
319
317
  draft.branch = e.target.value;
320
- el.querySelector('[data-branch-help]').textContent = `New builders branch from ${draft.branch}.`;
318
+ el.querySelector('[data-branch-help]').textContent = `New implementers branch from ${draft.branch}.`;
321
319
  sync();
322
320
  }
323
321
  });
@@ -328,7 +326,8 @@
328
326
  sync();
329
327
  error.hidden = true;
330
328
  try {
331
- await save(current ? { github: draft.github, branch: draft.branch } : { name: draft.name.trim(), purpose: draft.purpose.trim(), github: draft.github, branch: draft.branch, tickets: tickets.read() }, selected);
329
+ const repo = draft.remote ? { remote: draft.remote } : { github: draft.github };
330
+ await save(current ? { ...repo, branch: draft.branch } : { name: draft.name.trim(), purpose: draft.purpose.trim(), ...repo, branch: draft.branch, tickets: tickets.read() }, selected);
332
331
  sending = false;
333
332
  el.close();
334
333
  } catch (e) { sending = false; fail(e); sync(); }
@@ -353,8 +352,7 @@
353
352
  picker();
354
353
  return () => { sending = false; el.close(); };
355
354
  }
356
- const confirm = (message, labels) => (labels?.signal?.aborted ? Promise.resolve(false)
357
- : labels?.ok ? sheet(message, labels) : Promise.resolve(window.confirm(message)));
355
+ const confirm = (message, { signal } = {}) => Promise.resolve(!signal?.aborted && window.confirm(message));
358
356
  const notice = (message) => { window.alert(message); return Promise.resolve(); };
359
357
  globalThis.BoardDialogs = { confirm, notice, fitSheet, repoSheet, ticketsControl };
360
358
  })();
@@ -88,7 +88,8 @@
88
88
  return { busy: () => sending };
89
89
  }
90
90
 
91
- // This device's push subscription, as HubUI.deviceRow shows it: Turn on subscribes, and Test sends a test push.
91
+ // This device's push subscription, as HubUI.deviceRow shows it: Turn on subscribes, Test sends a test push, and each
92
+ // showing of the row tells the hub of a subscription this device no longer has.
92
93
  // iOS allows web push only in a Home Screen app, and asks for permission only from a tap, so requestPermission runs
93
94
  // first in the tap. paint() redraws the row, never before device() returns; changed() follows a subscription this
94
95
  // device just made.
@@ -103,15 +104,31 @@
103
104
  const keyBytes = (k) => Uint8Array.from(atob(k.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
104
105
  const post = (p, body) => fetch(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) }).then((r) => r.json());
105
106
  const show = (next, isOn = false) => { if (!B.alive()) return; state = next; on = isOn; paint(); };
107
+ // The endpoint this device last gave the hub, kept in this browser, so that once the subscription goes (the
108
+ // browser dropped or replaced it, or notifications were blocked) the page tells the hub to forget it
109
+ // (docs/adr/0149-the-hub-reads-healthy-degraded-or-a-site-down.md).
110
+ const KEPT = 'kickflip.push.endpoint';
111
+ const kept = () => { try { return localStorage.getItem(KEPT); } catch { return null; } };
112
+ const keep = (endpoint) => { try { if (endpoint) localStorage.setItem(KEPT, endpoint); else localStorage.removeItem(KEPT); } catch { /* storage blocked here */ } };
113
+ const settle = (endpoint = null) => {
114
+ const old = kept();
115
+ if (old && old !== endpoint) post('/_hub/push/unsubscribe', { endpoint: old }).catch(() => {});
116
+ keep(endpoint);
117
+ };
106
118
 
107
119
  async function render() {
108
120
  if (!('serviceWorker' in navigator) || !('PushManager' in globalThis)) {
109
121
  if (ios && !standalone) show({ title: 'Add to Home Screen', sub: 'Tap Share, then Add to Home Screen, and open Claude from your Home Screen to turn on notifications.' });
110
122
  return;
111
123
  }
112
- if (Notification.permission === 'denied') { show({ title: 'Notifications are blocked', sub: `Allow them in ${ios ? 'Settings → Notifications → Claude.' : "this browser's site settings."}` }); return; }
124
+ if (Notification.permission === 'denied') {
125
+ settle();
126
+ show({ title: 'Notifications are blocked', sub: `Allow them in ${ios ? 'Settings → Notifications → Claude.' : "this browser's site settings."}` });
127
+ return;
128
+ }
113
129
  const reg = await navigator.serviceWorker.getRegistration('/');
114
130
  const sub = await reg?.pushManager.getSubscription();
131
+ settle(sub?.toJSON().endpoint);
115
132
  if (sub) {
116
133
  post('/_hub/push/subscribe', sub.toJSON()).catch(() => {});
117
134
  show({ title: 'This device', action: { label: 'Test', act: 'test', quiet: true } }, true);
@@ -1,5 +1,6 @@
1
1
  /* The hub page and settings modals (view-hub.js, view-hub-settings.js, view-polling.js), whose root carries .hub-view, and the hub's
2
- down page (hub/hub.mjs), whose <html> does; served as /_hub/app/hub.css and /_hub/hub.css. Every rule is under
2
+ down page (hub/hub.mjs), whose <html> does, and the board's Hold sheet picker (ui-cards.js holdPicker), which shares the
3
+ duration picker's rules; served as /_hub/app/hub.css and /_hub/hub.css. Every rule is under
3
4
  .hub-view, since style.css uses the same short class names. The app shell links it after style.css, so a rule here
4
5
  wins a tie, and the header is style.css's alone, as on every view; each style.css rule that would reach these rows
5
6
  is set back here. */
@@ -27,7 +28,7 @@
27
28
  .hub-view .title { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; overflow-wrap:normal; }
28
29
  .hub-view .sub { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; overflow-wrap:normal; }
29
30
  .hub-view .sub.wrap { white-space:normal; }
30
- .hub-view .st { font-weight:600; } .hub-view .st.down { color:var(--color-danger); } .hub-view .st.wait { color:var(--color-notice); }
31
+ .hub-view .st { font-weight:600; } .hub-view button.st { background:none; border:0; padding:0; font:inherit; font-weight:600; color:inherit; cursor:pointer; text-decoration:underline dotted; text-underline-offset:3px; } .hub-view .st.down { color:var(--color-danger); } .hub-view .st.wait { color:var(--color-notice); }
31
32
  @keyframes pulse { 50% { opacity:.35; } }
32
33
  .hub-view main .icon { flex:none; width:34px; height:34px; border-radius:9px; display:grid; place-items:center; color:var(--c); background:color-mix(in srgb, var(--c) 18%, transparent); font-weight:700; font-size:16px; line-height:1; }
33
34
  .hub-view main .icon.sm { width:29px; height:29px; border-radius:7px; }
@@ -37,7 +38,7 @@
37
38
  .hub-view .badge { flex:none; min-width:22px; height:22px; padding:0 7px; border-radius:11px; background:var(--color-danger); color:#fff; font-size:12px; font-weight:600; line-height:22px; text-align:center; font-variant-numeric:tabular-nums; }
38
39
  .hub-view .chev { flex:none; color:var(--muted); font-size:1.3rem; line-height:1; }
39
40
  .hub-view .val { flex:none; color:var(--muted); font-size:13px; }
40
- .hub-view .btn { flex:none; font:inherit; font-size:13px; color:#fff; background:var(--accent); border:1px solid var(--accent); border-radius:7px; padding:6px 10px; cursor:pointer; }
41
+ .hub-view .btn { flex:none; font:inherit; font-size:13px; color:var(--on-accent); background:var(--accent); border:1px solid var(--accent); border-radius:7px; padding:6px 10px; cursor:pointer; }
41
42
  .hub-view .btn.quiet { color:var(--text); background:var(--panel); border-color:var(--line); }
42
43
  .hub-view .btn:disabled { opacity:.5; cursor:default; }
43
44
  .hub-view .btn.danger { color:var(--color-danger); }
@@ -50,12 +51,12 @@
50
51
  .hub-view .day { position:relative; padding:18px 0 20px; }
51
52
  .hub-view .day .bar { position:relative; height:40px; border-radius:8px; overflow:hidden; background:var(--chip); }
52
53
  .hub-view .day .seg { position:absolute; top:0; bottom:0; display:grid; place-items:center; overflow:hidden; border-right:2px solid var(--panel);
53
- background:color-mix(in srgb, var(--accent) var(--k), var(--chip)); }
54
+ background:color-mix(in srgb, var(--muted) var(--k), var(--chip)); }
54
55
  .hub-view .day .seg.held { background-image:repeating-linear-gradient(135deg, transparent 0 5px, color-mix(in srgb, var(--text) 22%, transparent) 5px 8px); }
55
56
  .hub-view .day .seg.sel { box-shadow:inset 0 0 0 3px var(--text); z-index:2; }
56
57
  .hub-view .day .seg span { font-size:11px; font-weight:600; line-height:1.5; color:var(--text); background:color-mix(in srgb, var(--panel) 85%, transparent); border-radius:4px; padding:0 4px; }
57
58
  .hub-view .day .pin { position:absolute; top:2px; transform:translateX(-50%); color:var(--color-notice); font-size:11px; line-height:1; }
58
- .hub-view .day .now { position:absolute; top:14px; height:48px; width:2px; margin-left:-1px; background:var(--color-danger); z-index:3; pointer-events:none; }
59
+ .hub-view .day .now { position:absolute; top:14px; height:48px; width:2px; margin-left:-1px; background:var(--text); z-index:3; pointer-events:none; }
59
60
  .hub-view .day .ticks { position:absolute; left:0; right:0; bottom:0; height:16px; font-size:10px; color:var(--muted); }
60
61
  .hub-view .day .ticks span { position:absolute; transform:translateX(-50%); }
61
62
  .hub-view .day .ticks span:first-child { transform:none; } .hub-view .day .ticks span:last-child { transform:translateX(-100%); }
@@ -80,16 +81,17 @@
80
81
  .hub-view .hold-banner > span { flex:1; min-width:0; }
81
82
  .hub-view .hold-banner form { flex:none; margin:0; }
82
83
  .hub-view .hold-banner .btn { min-height:32px; }
83
- .hub-view .snooze-head { display:flex; align-items:center; gap:10px; }
84
- .hub-view .snooze-chips { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:6px; margin-top:10px; }
85
- .hub-view .snooze-choices { display:contents; }
86
- .hub-view .snooze-chips .btn { display:block; min-height:40px; padding:6px 4px; text-align:center; white-space:nowrap; }
87
- .hub-view .snooze-custom { position:relative; margin:0; }
88
- .hub-view .snooze-custom .btn { display:grid; place-items:center; height:100%; }
89
- .hub-view .snooze-custom input[type=datetime-local] { position:absolute; inset:0; width:100%; height:100%; min-height:0; padding:0; margin:0; border:0; background:transparent; color:transparent; opacity:.011; font-size:16px; cursor:pointer; }
90
- .hub-view .snooze-custom input::-webkit-calendar-picker-indicator { position:absolute; inset:0; width:auto; height:auto; opacity:0; cursor:pointer; }
91
- .hub-view .snooze-custom:focus-within { outline:2px solid var(--accent); outline-offset:2px; border-radius:7px; }
92
- .hub-view .snooze-on .title { font-weight:600; white-space:normal; }
84
+ .hub-view .dur-head { display:flex; align-items:center; gap:10px; }
85
+ .hub-view .dur-chips { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:6px; margin-top:10px; }
86
+ .hub-view .dur-choices { display:contents; }
87
+ .hub-view .dur-chips .btn { display:block; min-height:40px; padding:6px 4px; text-align:center; white-space:nowrap; }
88
+ .hub-view .dur-custom { position:relative; margin:0; }
89
+ .hub-view .dur-custom .btn { display:grid; place-items:center; height:100%; }
90
+ .hub-view .dur-custom input[type=datetime-local] { position:absolute; inset:0; width:100%; height:100%; min-height:0; padding:0; margin:0; border:0; background:transparent; color:transparent; opacity:.011; font-size:16px; cursor:pointer; }
91
+ .hub-view .dur-custom input::-webkit-calendar-picker-indicator { position:absolute; inset:0; width:auto; height:auto; opacity:0; cursor:pointer; }
92
+ .hub-view .dur-custom:focus-within { outline:2px solid var(--accent); outline-offset:2px; border-radius:7px; }
93
+ .hub-view .dur-on .title { font-weight:600; white-space:normal; }
94
+ .hub-view .awake-on { color:var(--color-notice); font-weight:600; }
93
95
  .hub-view code { font-family:var(--mono); font-size:.85em; white-space:nowrap; }
94
96
  .hub-view footer { color:var(--muted); font-size:12px; margin:1.8rem 2px 0; }
95
97
  .hub-view .host { font-family:var(--mono); font-size:11px; overflow-wrap:anywhere; }
@@ -110,13 +112,14 @@
110
112
  .hub-view .hub-setting { width: 100%; display: flex; align-items: center; gap: 12px; padding: 14px 16px; font: inherit; text-align: left; color: var(--text); cursor: pointer; border: 1px solid var(--line); border-radius: 12px; margin: 10px 0; background: var(--panel); }
111
113
  .hub-view .hub-setting .text { flex: 1; min-width: 0; }
112
114
  .hub-view .hub-setting .title { font-weight: 600; }
115
+ .hub-view .hub-setting:disabled { cursor: default; opacity: .6; }
113
116
  .hub-view .hub-setting .sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
114
117
  .hub-view .hub-modal .settings-body { padding-top: 2px; }
115
118
  .hub-view .hub-modal .setting-content > .label:first-child { margin-top: 14px; }
116
119
  .hub-view .hub-modal .setting-content > .group:first-child { margin-top: 16px; }
117
120
  .hub-view .hub-modal .rules-box { margin-top: 14px; }
118
121
 
119
- .hub-view .u-tone-ok { --u: var(--accent); }
122
+ .hub-view .u-tone-ok { --u: var(--muted); }
120
123
  .hub-view .u-tone-high { --u: var(--color-notice); }
121
124
  .hub-view .u-tone-full { --u: var(--color-danger); }
122
125
  .hub-view .u-tone-stale, .hub-view .u-tone-none { --u: var(--muted); }
@@ -138,7 +141,6 @@
138
141
  .hub-view .segments.is-disabled button { cursor: not-allowed; }
139
142
  .hub-view .segments.is-disabled button[aria-pressed="true"] { border-color: var(--muted); background: var(--panel); color: var(--text); }
140
143
  .hub-view .u-share-desc { margin: 8px 2px 0; color: var(--muted); font-size: 13px; }
141
- .hub-view .u-share-note { margin: 4px 2px 0; color: var(--muted); font-size: 12px; }
142
144
  .hub-view .u-share-fix { margin: 6px 2px 0; }
143
145
  .hub-view .u-share-fix code { display: inline-block; padding: 3px 7px; border-radius: 6px; background: var(--chip); color: var(--text); font-size: 12.5px; user-select: all; }
144
146
  .hub-view .lk-form { display:flex; gap:8px; align-items:center; }
@@ -150,7 +152,6 @@
150
152
  .hub-view .hold-banner.ub-going { border-color: color-mix(in srgb, var(--color-danger) 45%, transparent); background: color-mix(in srgb, var(--color-danger) 10%, var(--panel)); }
151
153
  .hub-view .hold-banner.ub-going > svg { color: var(--color-danger); }
152
154
 
153
- .hub-view .u-buffer { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
154
155
  .hub-view .ub-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
155
156
  .hub-view .ub-label { min-width: 0; }
156
157
  .hub-view .ub-label .title { display: block; font-size: 14px; }
@@ -162,3 +163,8 @@
162
163
  .hub-view .ub-step output.off { border-color: var(--line); background: var(--panel); color: var(--muted); }
163
164
  .hub-view .ub-marked .u-bar { position: relative; }
164
165
  .hub-view .ub-marked .u-bar > .ub-mark { position: absolute; top: 0; bottom: 0; width: 2px; margin-left: -1px; border-radius: 0; background: var(--text); opacity: .5; }
166
+
167
+ .hub-view .rd-fix button.link { padding: 0; font-size: inherit; }
168
+ .hub-view .site-row > .site-link { flex:1; min-width:0; display:flex; align-items:center; gap:10px; color:inherit; }
169
+ .hub-view .restart-btn { min-width:76px; min-height:34px; display:inline-flex; align-items:center; justify-content:center; gap:6px; }
170
+ .hub-view .restart-btn .try-spin { width:9px; height:9px; }