@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
package/board/public/dialogs.js
CHANGED
|
@@ -1,43 +1,48 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Load before any page script that asks (docs/adr/
|
|
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,
|
|
5
|
-
//
|
|
6
|
-
|
|
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
|
|
11
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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' || !
|
|
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
|
|
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 (!
|
|
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
|
|
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 === '
|
|
53
|
-
else if (draft.source === '
|
|
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' &&
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
-
|
|
215
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
274
|
-
selected = repos.find((r) => r.github ===
|
|
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 =
|
|
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(
|
|
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,
|
|
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(
|
|
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 ===
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
})();
|
package/board/public/hub-kit.js
CHANGED
|
@@ -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,
|
|
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') {
|
|
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);
|
package/board/public/hub.css
CHANGED
|
@@ -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
|
|
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
|
|
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(--
|
|
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(--
|
|
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 .
|
|
84
|
-
.hub-view .
|
|
85
|
-
.hub-view .
|
|
86
|
-
.hub-view .
|
|
87
|
-
.hub-view .
|
|
88
|
-
.hub-view .
|
|
89
|
-
.hub-view .
|
|
90
|
-
.hub-view .
|
|
91
|
-
.hub-view .
|
|
92
|
-
.hub-view .
|
|
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(--
|
|
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; }
|