@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.
- package/CONTEXT.md +177 -78
- package/README.md +46 -5
- package/SKILL.md +58 -37
- package/bin/doctor.mjs +4 -3
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +87 -32
- package/bin/statusline.mjs +4 -3
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +5 -0
- package/board/bin/agent.mjs +1 -1
- package/board/bin/board.mjs +241 -69
- package/board/bin/builder-stop-hook.mjs +4 -3
- package/board/domain.mjs +5 -3
- package/board/on-host.mjs +1 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +33 -14
- package/board/public/app-cards.js +242 -49
- package/board/public/app-core.js +39 -5
- package/board/public/app-header.js +24 -6
- package/board/public/app-lanes.js +76 -26
- package/board/public/app-needs.js +1 -0
- package/board/public/app-snapshot.js +45 -11
- package/board/public/app-threads.js +13 -11
- package/board/public/app.html +4 -1
- package/board/public/app.js +131 -20
- package/board/public/dialogs.js +114 -116
- package/board/public/hub-kit.js +19 -2
- package/board/public/hub.css +24 -18
- package/board/public/linear-key.js +141 -0
- package/board/public/router.js +6 -3
- package/board/public/rules-editor.js +1 -1
- package/board/public/shell.js +1 -1
- package/board/public/sidebar.css +31 -11
- package/board/public/sidebar.js +88 -16
- package/board/public/style.css +141 -46
- package/board/public/toasts.js +6 -4
- package/board/public/ui-cards.js +102 -37
- package/board/public/ui-header.js +17 -8
- package/board/public/ui-hub.js +30 -12
- package/board/public/ui-links.js +18 -10
- package/board/public/ui-order.js +18 -0
- package/board/public/ui-page-header.js +4 -2
- package/board/public/ui-questions.js +37 -16
- package/board/public/ui-sidebar.js +49 -11
- package/board/public/ui-snapshot.js +15 -9
- package/board/public/ui-threads.js +25 -16
- package/board/public/ui.js +23 -11
- package/board/public/view-hub-settings.js +46 -30
- package/board/public/view-hub.js +64 -13
- package/board/public/view-notes.js +7 -4
- package/board/public/view-polling.js +3 -3
- package/board/public/view-questions.js +42 -22
- package/board/public/view-settings.js +90 -35
- package/board/review.mjs +1 -0
- package/board/server.mjs +12 -7
- package/board/store.mjs +14 -6
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +14 -13
- package/docs/hub-session.md +3 -3
- package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/duration.mjs +1 -0
- package/hub/github.mjs +4 -5
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -10
- package/hub/linear.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/pr-report.mjs +1 -0
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -2
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/shell.mjs +2 -1
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +12 -12
- package/hub/sw.js +1 -1
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +4 -3
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/colors.css +7 -5
- package/shared/fake-gh.mjs +54 -0
- package/shared/service.mjs +16 -16
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -1
- package/starter/public/claude.js +3 -2
- 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
|
+
})();
|
package/board/public/router.js
CHANGED
|
@@ -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}
|
|
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')
|
|
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(--
|
|
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; }
|
package/board/public/shell.js
CHANGED
|
@@ -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
|
|
package/board/public/sidebar.css
CHANGED
|
@@ -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(--
|
|
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:
|
|
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-
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
114
|
-
transform-origin: 0 50%; animation: ks-drain
|
|
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(--
|
|
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
|
|
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(--
|
|
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); }
|
package/board/public/sidebar.js
CHANGED
|
@@ -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
|
-
//
|
|
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: {
|
|
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',
|
|
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
|
|
159
|
-
if (
|
|
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();
|