@lovek4d/kickflip 0.5.3 → 0.6.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 +82 -46
- package/SKILL.md +21 -15
- package/bin/doctor.mjs +5 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +12 -12
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +179 -92
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -5
- package/board/onboarding.mjs +11 -0
- package/board/prototype-base.mjs +1 -0
- package/board/prototype-page.mjs +1 -0
- package/board/public/app-arch.js +1 -1
- package/board/public/app-cards.js +40 -47
- package/board/public/app-header.js +6 -7
- package/board/public/app-lanes.js +19 -45
- package/board/public/app-needs.js +36 -31
- package/board/public/app-snapshot.js +43 -70
- package/board/public/app-threads.js +42 -46
- package/board/public/app.html +9 -4
- package/board/public/app.js +78 -32
- package/board/public/hub-kit.js +62 -2
- package/board/public/hub.css +2 -0
- package/board/public/new-task.js +8 -6
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +109 -4
- package/board/public/prototype-switcher.js +130 -31
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +65 -24
- package/board/public/sidebar.css +6 -0
- package/board/public/sidebar.js +19 -69
- package/board/public/style.css +116 -73
- package/board/public/ui-cards.js +78 -57
- package/board/public/ui-enums.js +2 -2
- package/board/public/ui-header.js +23 -16
- package/board/public/ui-hub.js +19 -5
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-order.js +1 -1
- package/board/public/ui-page-header.js +19 -7
- package/board/public/ui-patch.js +7 -33
- package/board/public/ui-progress-bar.js +28 -0
- package/board/public/ui-questions.js +25 -17
- package/board/public/ui-sidebar.js +31 -23
- package/board/public/ui-snapshot.js +44 -53
- package/board/public/ui-threads.js +57 -28
- package/board/public/ui.js +94 -18
- package/board/public/view-hub-settings.js +12 -58
- package/board/public/view-hub.js +5 -2
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +90 -68
- package/board/public/view-settings.js +133 -15
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +20 -19
- package/board/stop.mjs +1 -0
- package/board/store.mjs +29 -18
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/transcript.mjs +2 -2
- package/board/worktree.mjs +22 -21
- package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
- package/hooks-plugin/hooks/usage-gate.mjs +20 -3
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +2 -2
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/review-send.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -1
- package/hub/slack.mjs +2 -2
- package/hub/try.mjs +6 -6
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/card-prs.mjs +1 -1
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/tailscale.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md
|
|
1
|
+
// The full-bleed prototype viewer's floating controls (docs/adr/2026-10-29-1639-prototypes-come-on-request.md,
|
|
2
|
+
// docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md,
|
|
3
|
+
// docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md).
|
|
3
4
|
// Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
|
|
4
|
-
// const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, initial: 'A', render(take) {…}, above: el? })
|
|
5
|
-
// render runs at mount and on each switch; sw.take is the current key.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, states: { empty: 'No cards', … }?, initial: 'A', render(take, state) {…}, above: el? })
|
|
6
|
+
// render runs at mount and on each switch of take or state; sw.take is the current key. A served round's named states
|
|
7
|
+
// (docs/adr/2026-11-06-1500-a-prototype-round-is-a-page-in-the-boards-base-worktree.md) are a row of chips above the
|
|
8
|
+
// pager, the state kept in ?state=, else the first. The page's header is hidden: a ✕ at the top
|
|
9
|
+
// right goes back as its back arrow does, and the bottom bar holds the ‹ take › pager. ←/→ and a sideways swipe switch
|
|
10
|
+
// unless a field has focus, and the take stays in ?variant=. With ?q=<owner question id>, the bar's second row offers
|
|
11
|
+
// the take as that question's pick: a take is pickable when an option's id equals its key. Choose this drafts it, with
|
|
12
|
+
// the note typed above the bar, in the {choice, note} questions.html keeps for that question, and goes back to the
|
|
13
|
+
// question, whose Send sends it. A card with more than one prototype round gets a Round pill at the top left, which
|
|
14
|
+
// opens a sheet of its rounds (/api/board's card prototypes.rounds, board/domain.mjs).
|
|
9
15
|
(function () {
|
|
10
16
|
const esc = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
11
17
|
|
|
@@ -24,15 +30,16 @@
|
|
|
24
30
|
const shownPick = (question, drafted) => question.state === 'requested' ? question.request?.choice ?? null
|
|
25
31
|
: question.state === 'resolved' ? pickedChoice(question)
|
|
26
32
|
: drafted ?? pickedChoice(question) ?? question.suggested?.option ?? null;
|
|
27
|
-
// none: no question, the take is not one of its options, or the question
|
|
28
|
-
//
|
|
33
|
+
// none: no question, the take is not one of its options, or the question waits on a requested prototype and the take
|
|
34
|
+
// isn't its pick. closed: the question is resolved and the take isn't its pick. pick: nothing picked yet. picked: this
|
|
35
|
+
// take is the pick. change: another take is.
|
|
29
36
|
function pickState(question, take, drafted = null) {
|
|
30
37
|
if (!question) return { kind: 'none' };
|
|
31
38
|
const option = (question.options || []).find((o) => o.id === take);
|
|
32
39
|
const picked = shownPick(question, drafted);
|
|
33
40
|
if (!option) return { kind: 'none', picked };
|
|
34
41
|
if (picked === take) return { kind: 'picked', option, picked };
|
|
35
|
-
if (locked(question)) return { kind: 'none', picked };
|
|
42
|
+
if (locked(question)) return { kind: question.state === 'resolved' ? 'closed' : 'none', picked };
|
|
36
43
|
return picked ? { kind: 'change', option, picked } : { kind: 'pick', option };
|
|
37
44
|
}
|
|
38
45
|
// questions.html keeps each question's unsent {choice, note} under this key (ui-questions.js getDraft).
|
|
@@ -47,38 +54,87 @@
|
|
|
47
54
|
if (!app?.open?.(String(url), { replace: true })) location.replace(url);
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
|
|
57
|
+
// The bar: the states' chips when the round has states, the ‹ name › pager, and below it on its own row the pick
|
|
58
|
+
// control, when there is one.
|
|
59
|
+
function pillHtml({ name, pick = { kind: 'none' }, states = null, state = null }) {
|
|
60
|
+
const chips = states ? `<div class="ps-row ps-states">${Object.entries(states).map(([key, label]) => `<button type="button" data-ps-state="${esc(key)}" aria-pressed="${key === state}">${esc(label)}</button>`).join('')}</div>` : '';
|
|
51
61
|
const control = pick.kind === 'picked' ? '<span class="ps-picked">Picked</span>'
|
|
52
|
-
: pick.kind === '
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return
|
|
62
|
+
: pick.kind === 'closed' ? '<span class="ps-closed">Round answered</span>'
|
|
63
|
+
: pick.kind === 'pick' || pick.kind === 'change' ? '<button type="button" class="ps-pick" data-ps="pick">Choose this</button>'
|
|
64
|
+
: '';
|
|
65
|
+
return `${chips}<div class="ps-row"><button type="button" data-ps="prev" aria-label="Previous take">‹</button><span class="ps-name" title="${esc(name)}">${esc(name)}</span><button type="button" data-ps="next" aria-label="Next take">›</button></div>${control ? `<div class="ps-row ps-act">${control}</div>` : ''}`;
|
|
56
66
|
}
|
|
57
67
|
|
|
68
|
+
// rounds: the card's prototype questions, oldest round first, as /api/board's card prototypes.rounds lists them
|
|
69
|
+
// ({n, q, url, confirm, state}). The pill names the round qid is in, out of the newest round's number; a card with a
|
|
70
|
+
// single round gets none.
|
|
71
|
+
function roundPill(rounds, qid) {
|
|
72
|
+
const at = (rounds || []).find((r) => r.q === qid);
|
|
73
|
+
if (!at || rounds.length < 2) return '';
|
|
74
|
+
return `<button type="button" class="ps-chip" data-ps-top="rounds">Round ${at.n} of ${Math.max(...rounds.map((r) => r.n))} · earlier</button>`;
|
|
75
|
+
}
|
|
76
|
+
const ROUND_WORD = { open: 'open', requested: 'prototype requested' };
|
|
77
|
+
function roundSheet({ ref, rounds, qid }) {
|
|
78
|
+
const rows = [...rounds].reverse().map((r) => `<button type="button" data-ps-round="${esc(r.url)}"><span>Round ${r.n}${r.confirm ? ' confirm' : ''}${r.q === qid ? ' · showing' : ''}</span><span>${ROUND_WORD[r.state] || 'answered'}</span></button>`);
|
|
79
|
+
return `<div class="ps-sheet" role="dialog" aria-label="Rounds"><h3>Rounds on ${esc(ref)}</h3>${rows.join('')}</div>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A touch from start to end: 1 for a swipe left (the next take), -1 for a swipe right, 0 for anything else: under
|
|
83
|
+
// 50px sideways, more down than across, or starting at the left edge, where the drawer's and the browser's swipes are.
|
|
84
|
+
const swipeStep = (start, end) => {
|
|
85
|
+
const dx = end.x - start.x;
|
|
86
|
+
return start.x < 24 || Math.abs(dx) < 50 || Math.abs(end.y - start.y) * 2 > Math.abs(dx) ? 0 : dx < 0 ? 1 : -1;
|
|
87
|
+
};
|
|
88
|
+
|
|
58
89
|
const CSS = `
|
|
59
|
-
.
|
|
90
|
+
body > header.top { display:none; }
|
|
91
|
+
main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
92
|
+
.ps-top { position:fixed; left:10px; right:10px; top:calc(10px + env(safe-area-inset-top)); z-index:31; display:flex; justify-content:space-between;
|
|
93
|
+
align-items:flex-start; pointer-events:none; font:600 13px -apple-system, system-ui, sans-serif; }
|
|
94
|
+
.ps-top > * { pointer-events:auto; }
|
|
95
|
+
.ps-chip, .ps-close { color:#fff; background:#000a; border:1px solid #fff4; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); font:inherit; cursor:pointer; }
|
|
96
|
+
.ps-chip { border-radius:999px; padding:0 14px; min-height:40px; }
|
|
97
|
+
.ps-close { margin-left:auto; width:40px; height:40px; border-radius:50%; font-size:17px; line-height:1; }
|
|
98
|
+
.ps-sheet { position:fixed; left:0; right:0; bottom:0; z-index:32; padding:14px 16px calc(22px + env(safe-area-inset-bottom)); border-radius:18px 18px 0 0;
|
|
99
|
+
background:#26262c; color:#fff; box-shadow:0 -8px 30px #0009; }
|
|
100
|
+
.ps-sheet h3 { margin:0 0 8px; font-size:14px; }
|
|
101
|
+
.ps-sheet button { display:flex; justify-content:space-between; gap:12px; width:100%; border:0; border-top:1px solid #fff2; background:transparent; color:#fff;
|
|
102
|
+
padding:12px 2px; font:inherit; font-weight:500; text-align:left; cursor:pointer; }
|
|
103
|
+
.ps-sheet button span + span { opacity:.7; }
|
|
104
|
+
.ps-switch { position:fixed; left:10px; right:10px; bottom:calc(var(--app-bottom, 0px) + 12px + env(safe-area-inset-bottom)); z-index:30;
|
|
60
105
|
display:flex; flex-direction:column; align-items:center; gap:6px; pointer-events:none; }
|
|
61
106
|
.ps-switch > * { pointer-events:auto; max-width:100%; min-width:0; box-sizing:border-box; }
|
|
62
|
-
.ps-
|
|
63
|
-
-webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); font:600 13px -apple-system, system-ui, sans-serif; }
|
|
64
|
-
.ps-
|
|
107
|
+
.ps-pill { display:flex; flex-direction:column; gap:6px; width:min(460px, 100%); background:#202026eb; border:1px solid #fff3; border-radius:18px; padding:6px;
|
|
108
|
+
box-shadow:0 6px 24px #0008; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); font:600 13px -apple-system, system-ui, sans-serif; }
|
|
109
|
+
.ps-row { display:flex; align-items:center; gap:2px; min-width:0; }
|
|
110
|
+
.ps-pill button { color:#fff; background:transparent; border:0; border-radius:999px; padding:7px 11px; font:inherit; white-space:nowrap; cursor:pointer; }
|
|
65
111
|
.ps-pill button { flex-shrink:0; min-width:44px; min-height:44px; }
|
|
66
|
-
.ps-
|
|
67
|
-
.ps-
|
|
68
|
-
.ps-
|
|
112
|
+
.ps-name { flex:1 1 auto; padding:7px 6px; color:#fff; white-space:nowrap; min-width:0; text-align:center; overflow:hidden; text-overflow:ellipsis; }
|
|
113
|
+
.ps-act > * { flex:1; text-align:center; }
|
|
114
|
+
.ps-states { overflow-x:auto; scrollbar-width:none; gap:4px; }
|
|
115
|
+
.ps-pill .ps-states button { background:#fff1; font-weight:500; }
|
|
116
|
+
.ps-pill .ps-states button[aria-pressed="true"] { background:#fff; color:#000; font-weight:700; }
|
|
117
|
+
.ps-pill .ps-pick { background:var(--accent, #fff); color:var(--on-accent, #000); font-weight:700; }
|
|
118
|
+
.ps-closed { padding:12px 11px; border-radius:999px; color:#fffa; background:#fff1; white-space:nowrap; }
|
|
69
119
|
.ps-switch .ps-text { width:min(420px, 100%); resize:none; border-radius:12px; border:1px solid #fff3; background:#000d; color:#fff;
|
|
70
|
-
padding:7px 10px; font:400
|
|
120
|
+
padding:7px 10px; font:400 16px -apple-system, system-ui, sans-serif; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); }
|
|
71
121
|
.ps-switch .ps-text::placeholder { color:#fffa; }
|
|
72
122
|
.ps-switch .ps-text[hidden] { display:none; }
|
|
73
|
-
.ps-picked {
|
|
123
|
+
.ps-picked { padding:12px 11px; border-radius:999px; color:#fff; background:#1f9d55; white-space:nowrap; }
|
|
74
124
|
.ps-note { background:#000d; color:#fff; border-radius:8px; padding:5px 10px; font:500 12px -apple-system, system-ui, sans-serif; text-align:center; }
|
|
75
|
-
.ps-note[hidden] { display:none; }
|
|
125
|
+
.ps-note[hidden] { display:none; }
|
|
126
|
+
@media (min-width: 900px) {
|
|
127
|
+
.ps-pill { width:min(640px, 100%); }
|
|
128
|
+
.ps-switch .ps-text { width:min(640px, 100%); }
|
|
129
|
+
.ps-states { flex-wrap:wrap; overflow-x:visible; }
|
|
130
|
+
}`;
|
|
76
131
|
|
|
77
132
|
const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
|
|
78
133
|
|
|
79
|
-
function mount({ takes, initial, render, above = null }) {
|
|
134
|
+
function mount({ takes, states = null, initial, render, above = null }) {
|
|
80
135
|
const keys = Object.keys(takes);
|
|
81
136
|
const params = new URLSearchParams(location.search);
|
|
137
|
+
let shownState = states ? startTake(Object.keys(states), params.get('state')) : null;
|
|
82
138
|
const qid = params.get('q');
|
|
83
139
|
const site = globalThis.ClaudeSite;
|
|
84
140
|
if (!document.getElementById('ps-style')) document.head.insertAdjacentHTML('beforeend', `<style id="ps-style">${CSS}</style>`);
|
|
@@ -97,20 +153,37 @@
|
|
|
97
153
|
const pill = box.querySelector('.ps-pill');
|
|
98
154
|
const noteEl = box.querySelector('.ps-note');
|
|
99
155
|
const text = box.querySelector('.ps-text');
|
|
156
|
+
// The ✕ and, on a card with earlier rounds, the Round pill and its sheet. boardCard: /api/board's card the question is on.
|
|
157
|
+
const top = document.createElement('div');
|
|
158
|
+
top.className = 'ps-top';
|
|
159
|
+
document.body.append(top);
|
|
160
|
+
let boardCard = null;
|
|
161
|
+
let sheet = false;
|
|
162
|
+
const paintTop = () => {
|
|
163
|
+
const rounds = boardCard?.prototypes?.rounds;
|
|
164
|
+
top.innerHTML = `${roundPill(rounds, qid)}<button type="button" class="ps-close" data-ps-top="close" aria-label="Close">✕</button>${sheet && rounds ? roundSheet({ ref: boardCard.ref, rounds, qid }) : ''}`;
|
|
165
|
+
};
|
|
166
|
+
paintTop();
|
|
100
167
|
|
|
101
168
|
const note = (text) => { noteEl.textContent = text || ''; noteEl.hidden = !text; };
|
|
102
169
|
const state = () => pickState(question, take, qid ? readDraft(qid).choice : null);
|
|
103
170
|
function paint() {
|
|
104
171
|
const now = state();
|
|
105
|
-
pill.innerHTML = pillHtml({ name: takes[take], pick: now });
|
|
106
|
-
text.hidden = !(now.kind === 'pick' || now.kind === 'change');
|
|
172
|
+
pill.innerHTML = pillHtml({ name: takes[take], pick: now, states, state: shownState });
|
|
173
|
+
text.hidden = !(now.kind === 'pick' || now.kind === 'change' || now.kind === 'picked');
|
|
107
174
|
}
|
|
108
175
|
function go(next) {
|
|
109
176
|
take = next;
|
|
110
177
|
const u = new URL(location.href); u.searchParams.set('variant', take); history.replaceState(null, '', u);
|
|
111
178
|
note('');
|
|
112
179
|
paint();
|
|
113
|
-
render(take);
|
|
180
|
+
render(take, shownState);
|
|
181
|
+
}
|
|
182
|
+
function show(next) {
|
|
183
|
+
shownState = next;
|
|
184
|
+
const u = new URL(location.href); u.searchParams.set('state', next); history.replaceState(null, '', u);
|
|
185
|
+
paint();
|
|
186
|
+
render(take, shownState);
|
|
114
187
|
}
|
|
115
188
|
|
|
116
189
|
async function load() {
|
|
@@ -124,6 +197,8 @@
|
|
|
124
197
|
|| (topic && (await (await fetch(site.url('api/questions?topic=' + encodeURIComponent(topic.id)), { cache: 'no-store' })).json()).questions.find((q) => q.id === qid))
|
|
125
198
|
|| null;
|
|
126
199
|
if (!question) note('This prototype’s question is gone, so there is nothing to pick.');
|
|
200
|
+
boardCard = topic && board.items.find((i) => i.questions?.topic === topic.id) || null;
|
|
201
|
+
paintTop();
|
|
127
202
|
} catch { /* keep the last question shown */ }
|
|
128
203
|
if (fromPush && pickedChoice(question)) {
|
|
129
204
|
const card = /^item:(.+)$/.exec(question.topic || '')?.[1];
|
|
@@ -148,22 +223,46 @@
|
|
|
148
223
|
});
|
|
149
224
|
|
|
150
225
|
pill.addEventListener('click', (e) => {
|
|
226
|
+
const chip = e.target.closest('[data-ps-state]')?.dataset.psState;
|
|
227
|
+
if (chip) { show(chip); return; }
|
|
151
228
|
const act = e.target.closest('[data-ps]')?.dataset.ps;
|
|
152
229
|
if (act === 'prev') go(takeAt(keys, take, -1));
|
|
153
230
|
else if (act === 'next') go(takeAt(keys, take, 1));
|
|
154
231
|
else if (act === 'pick') pick();
|
|
155
232
|
});
|
|
233
|
+
// ✕ follows the hidden header's back arrow, so it goes where back goes (router.js page tree).
|
|
234
|
+
top.addEventListener('click', (e) => {
|
|
235
|
+
const round = e.target.closest('[data-ps-round]')?.dataset.psRound;
|
|
236
|
+
if (round) { replaceWith(site.url(round)); return; }
|
|
237
|
+
const act = e.target.closest('[data-ps-top]')?.dataset.psTop;
|
|
238
|
+
if (act === 'close') document.querySelector('header.top a.back').click();
|
|
239
|
+
else if (act === 'rounds') { sheet = !sheet; paintTop(); }
|
|
240
|
+
});
|
|
156
241
|
document.addEventListener('keydown', (e) => {
|
|
157
242
|
if (typing(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
158
243
|
if (e.key === 'ArrowLeft') go(takeAt(keys, take, -1));
|
|
159
244
|
else if (e.key === 'ArrowRight') go(takeAt(keys, take, 1));
|
|
245
|
+
else if (e.key === 'Escape' && sheet) { sheet = false; paintTop(); }
|
|
160
246
|
});
|
|
247
|
+
// A swipe that starts in a box scrolling sideways is that box's own scroll.
|
|
248
|
+
let touch = null;
|
|
249
|
+
document.addEventListener('touchstart', (e) => {
|
|
250
|
+
const t = e.touches[0];
|
|
251
|
+
const scrolls = (el) => el && el !== document.body && (el.scrollWidth > el.clientWidth && /auto|scroll/.test(getComputedStyle(el).overflowX) ? true : scrolls(el.parentElement));
|
|
252
|
+
touch = e.touches.length === 1 && !typing(e.target) && !e.target.closest?.('.ps-sheet') && !scrolls(e.target) ? { x: t.clientX, y: t.clientY } : null;
|
|
253
|
+
}, { passive: true });
|
|
254
|
+
document.addEventListener('touchend', (e) => {
|
|
255
|
+
const t = e.changedTouches[0];
|
|
256
|
+
const step = touch && t ? swipeStep(touch, { x: t.clientX, y: t.clientY }) : 0;
|
|
257
|
+
touch = null;
|
|
258
|
+
if (step) go(takeAt(keys, take, step));
|
|
259
|
+
}, { passive: true });
|
|
161
260
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
|
|
162
261
|
|
|
163
262
|
go(take);
|
|
164
263
|
load();
|
|
165
|
-
return { takes: keys, get take() { return take; }, go };
|
|
264
|
+
return { takes: keys, get take() { return take; }, get state() { return shownState; }, go, show };
|
|
166
265
|
}
|
|
167
266
|
|
|
168
|
-
globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, mount };
|
|
267
|
+
globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, roundPill, roundSheet, swipeStep, mount };
|
|
169
268
|
})();
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<link rel="stylesheet" href="sidebar.css">
|
|
11
11
|
<style>
|
|
12
12
|
/* The one page every recorded prototype is (board/server.mjs fills the {{…}} slots; prototype-kit.js documents what a
|
|
13
|
-
take gets). main keeps room below the stage for the
|
|
13
|
+
take gets). main keeps room below the stage for the viewer's bar and its note. */
|
|
14
14
|
main.proto-main { max-width: none; padding-left: 0; padding-right: 0; padding-bottom: calc(150px + var(--app-bottom, 0px) + env(safe-area-inset-bottom)); }
|
|
15
15
|
/* kit.hub() is a whole page with its own gutter and header, so it goes edge to edge. */
|
|
16
16
|
main.proto-main:has(> #stage > .kit-hub) { padding-top: 0; }
|
|
@@ -20,11 +20,28 @@
|
|
|
20
20
|
#stage:has(> .kit-hub) { padding: 0; }
|
|
21
21
|
#stage .top { position: static; }
|
|
22
22
|
#stage .hub-view main { padding-bottom: 16px; }
|
|
23
|
+
/* A served round's page, framed from the board's base prototype worktree, edge to edge above the viewer's bar. */
|
|
24
|
+
#stage:has(> .kit-served) { max-width: none; padding: 0; }
|
|
25
|
+
.kit-served { display: block; width: 100%; min-height: 320px; height: calc(100dvh - 210px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); border: 0; background: var(--panel); }
|
|
26
|
+
.kit-served-failed pre { margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
|
|
23
27
|
.kit-missing { margin: 0; padding: 16px; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
|
24
28
|
#protoToast { position: fixed; z-index: 40; left: 50%; top: calc(64px + env(safe-area-inset-top)); transform: translateX(-50%); max-width: calc(100vw - 32px); }
|
|
25
29
|
#protoToast[hidden] { display: none; }
|
|
26
30
|
@media (max-width: 899px) { #protoToast { top: max(12px, env(safe-area-inset-top)); } }
|
|
27
31
|
|
|
32
|
+
/* A take's own boxes are capped to the stage at phone width, so a fixed-width panel never cuts off. prototype-kit.js
|
|
33
|
+
(phoneFit) marks what the take lays side by side with data-stack, which stacks in one column whether the take used
|
|
34
|
+
grid, flex (wrapping or not) or inline-block, and a frame 600px or wider with data-wide: it keeps its own layout
|
|
35
|
+
and scrolls sideways inside its data-scroll box. */
|
|
36
|
+
@media (max-width: 599px) {
|
|
37
|
+
#stage :where(div, section, article, aside, nav, header, footer, main, form, ul, ol, table, figure):not([data-wide]) { max-width: 100%; min-width: 0; }
|
|
38
|
+
#stage [data-scroll] { overflow-x: auto; max-width: 100%; }
|
|
39
|
+
#stage [data-wide] { max-width: none; flex-shrink: 0; }
|
|
40
|
+
#stage [data-stack] { display: flex !important; flex-direction: column !important; flex-wrap: nowrap !important; grid-template-columns: none !important; align-items: stretch !important; }
|
|
41
|
+
#stage [data-stack] > * { width: auto !important; flex: 0 0 auto !important; grid-column: auto !important; float: none !important; }
|
|
42
|
+
}
|
|
43
|
+
/* iOS Safari zooms the page when it focuses a field under 16px. */
|
|
44
|
+
#stage :where(input, select, textarea) { font-size: 16px; }
|
|
28
45
|
#stage .kit-sidebar.sidebar { position: relative; inset: auto; z-index: auto; transform: none; visibility: visible; transition: none;
|
|
29
46
|
width: min(320px, 100%); height: 640px; margin: 0 auto; padding: 0 8px; border: 1px solid var(--line); border-radius: 14px; }
|
|
30
47
|
#stage .kit-sidebar.sidebar:not(.kit-head) > header.top { display: none; }
|
|
@@ -40,15 +57,52 @@
|
|
|
40
57
|
<main class="proto-main"><div id="stage"></div></main>
|
|
41
58
|
<div class="toast" id="protoToast" role="status" hidden></div>
|
|
42
59
|
<script id="prototypeData" type="application/json">{{prototypeData}}</script>
|
|
43
|
-
<script src="claude.js"></script>
|
|
44
60
|
<script src="ui.js"></script>
|
|
45
61
|
<script src="ui-links.js"></script>
|
|
46
62
|
<script src="ui-enums.js"></script>
|
|
47
|
-
<script src="ui-
|
|
63
|
+
<script src="ui-progress-bar.js"></script>
|
|
64
|
+
<script src="ui-order.js"></script>
|
|
65
|
+
<script src="ui-patch.js"></script>
|
|
66
|
+
<script src="ui-threads.js"></script>
|
|
48
67
|
<script src="ui-cards.js"></script>
|
|
68
|
+
<script src="ui-header.js"></script>
|
|
69
|
+
<script src="ui-snapshot.js"></script>
|
|
49
70
|
<script src="ui-page-header.js"></script>
|
|
50
71
|
<script src="ui-sidebar.js"></script>
|
|
72
|
+
<script src="ui-questions.js"></script>
|
|
73
|
+
<script src="ui-prompts.js"></script>
|
|
74
|
+
<script src="ui-markdown.js"></script>
|
|
51
75
|
<script src="ui-hub.js"></script>
|
|
76
|
+
<script src="toasts.js"></script>
|
|
77
|
+
<script src="dialogs.js"></script>
|
|
78
|
+
<script src="prompts.js"></script>
|
|
79
|
+
<script src="rules-editor.js"></script>
|
|
80
|
+
<script src="claude.js"></script>
|
|
81
|
+
<script src="attach.js"></script>
|
|
82
|
+
<script src="hub-kit.js"></script>
|
|
83
|
+
<script src="linear-key.js"></script>
|
|
84
|
+
<script src="sidebar.js"></script>
|
|
85
|
+
<script src="app.js"></script>
|
|
86
|
+
<script src="new-task.js"></script>
|
|
87
|
+
<script src="app-core.js"></script>
|
|
88
|
+
<script src="app-threads.js"></script>
|
|
89
|
+
<script src="app-cards.js"></script>
|
|
90
|
+
<script src="app-lanes.js"></script>
|
|
91
|
+
<script src="app-asks.js"></script>
|
|
92
|
+
<script src="app-needs.js"></script>
|
|
93
|
+
<script src="app-header.js"></script>
|
|
94
|
+
<script src="app-arch.js"></script>
|
|
95
|
+
<script src="app-reader.js"></script>
|
|
96
|
+
<script src="app-banner.js"></script>
|
|
97
|
+
<script src="app-snapshot.js"></script>
|
|
98
|
+
<script src="view-settings.js"></script>
|
|
99
|
+
<script src="view-questions.js"></script>
|
|
100
|
+
<script src="view-notes.js"></script>
|
|
101
|
+
<script src="view-hub.js"></script>
|
|
102
|
+
<script src="view-hub-settings.js"></script>
|
|
103
|
+
<script src="view-frame.js"></script>
|
|
104
|
+
<script src="router.js"></script>
|
|
105
|
+
<script src="shell.js"></script>
|
|
52
106
|
<script src="prototype-switcher.js"></script>
|
|
53
107
|
<script src="prototype-kit.js"></script>
|
|
54
108
|
<script>PrototypeKit.start(JSON.parse(document.getElementById('prototypeData').textContent));</script>
|
package/board/public/router.js
CHANGED
|
@@ -85,18 +85,19 @@ globalThis.AppRouter = (function () {
|
|
|
85
85
|
const u = new URL(view.url, 'http://hub');
|
|
86
86
|
return /^\/[^/]+\/prototype-[\w-]+\.html$/.test(u.pathname) ? u.searchParams.get('q') || null : null;
|
|
87
87
|
}
|
|
88
|
-
|
|
88
|
+
// wide: from 900px Waiting on you is the board's pane, so a prototype's parent is the board itself.
|
|
89
|
+
function levelOf(view, { boards = new Set(), wide = false } = {}) {
|
|
89
90
|
if (view?.kind === 'board') return 0;
|
|
90
91
|
if (view?.kind === 'questions') return 1;
|
|
91
|
-
return questionOf(view, boards) ? 2 : null;
|
|
92
|
+
return questionOf(view, boards) ? (wide ? 1 : 2) : null;
|
|
92
93
|
}
|
|
93
94
|
// → the views above view in the tree, its board first. card: the card whose thread the owner left the board from,
|
|
94
95
|
// which the board keeps open.
|
|
95
|
-
function parentsOf(view, { boards = new Set(), card = null } = {}) {
|
|
96
|
+
function parentsOf(view, { boards = new Set(), card = null, wide = false } = {}) {
|
|
96
97
|
const q = questionOf(view, boards);
|
|
97
98
|
if (!q && view?.kind !== 'questions') return [];
|
|
98
99
|
const board = { kind: 'board', route: view.route, hash: card ? `#item=${encodeURIComponent(card)}` : '' };
|
|
99
|
-
return q ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
|
|
100
|
+
return q && !wide ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
// Each history entry carries {idx}, its distance from the shell's first entry, pushed when a push opened it, and on a
|
|
@@ -109,8 +110,8 @@ globalThis.AppRouter = (function () {
|
|
|
109
110
|
// boards() → the board routes now.
|
|
110
111
|
// → {push(href, from), replace(href), up()}: push and replace write an entry for a view go is showing; replace answers
|
|
111
112
|
// 'up' when it went back to a parent instead, which then takes href, and go shows it on that pop.
|
|
112
|
-
function listen({ boards, go }) {
|
|
113
|
-
const b = () => ({ boards: boards() });
|
|
113
|
+
function listen({ boards, go, wide = () => false }) {
|
|
114
|
+
const b = () => ({ boards: boards(), wide: wide() });
|
|
114
115
|
const mark = (pushed) => (pushed ? { pushed: true } : {});
|
|
115
116
|
let idx = Number.isInteger(history.state?.idx) ? history.state.idx : 0;
|
|
116
117
|
let landing = null;
|
package/board/public/shell.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// view in place through router.js; a link to anything that isn't a page loads it. Leaving a view whose root holds
|
|
7
7
|
// [data-dirty], an unsaved edit, asks first. The shell holds the hub's one stream, /_hub/stream
|
|
8
8
|
// (hub/stream.mjs): `change {route}` signals the open board, and `sidebar` repaints the sidebar. A board the shell
|
|
9
|
-
// leaves is unmounted
|
|
10
|
-
//
|
|
9
|
+
// leaves is unmounted, and paints live from its saved copy when it comes back
|
|
10
|
+
// (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md).
|
|
11
11
|
// A push tap reaches it from hub/sw.js as {open: url}, and a page framed inside it hands it a view's URL through
|
|
12
12
|
// AppShell.open(url, {replace, frames}) → true when the shell shows it in place; frames false refuses a frame view.
|
|
13
13
|
// AppShell.reload() reloads the app past the shell the service worker keeps.
|
|
@@ -35,6 +35,10 @@ window.AppShell = (function () {
|
|
|
35
35
|
channel.port1.onmessage = done;
|
|
36
36
|
try { controller.postMessage({ forget: 'shell' }, [channel.port2]); } catch { done(); }
|
|
37
37
|
});
|
|
38
|
+
// Only this reload starts at the top: iOS put the old offset back once the page had grown and left that area unpainted until a scroll
|
|
39
|
+
// (docs/adr/2026-11-06-1100-the-shell-turns-off-scroll-restoration.md). pagehide gives ordinary reloads their restore back.
|
|
40
|
+
history.scrollRestoration = 'manual';
|
|
41
|
+
addEventListener('pagehide', () => { history.scrollRestoration = 'auto'; }, { once: true });
|
|
38
42
|
setTimeout(() => location.reload(), 0);
|
|
39
43
|
}
|
|
40
44
|
|
|
@@ -57,10 +61,20 @@ window.AppShell = (function () {
|
|
|
57
61
|
// A stored sidebar's code says nothing about the shell that booted, so it adopts the next live hash.
|
|
58
62
|
bootCode = data?.code || null;
|
|
59
63
|
data ||= read(DATA_KEY);
|
|
60
|
-
// A board shared with someone else
|
|
61
|
-
// the hub sends them data.shared, and style.css hides what writes under .shared-view.
|
|
64
|
+
// A board shared with someone else (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md):
|
|
65
|
+
// the hub sends them data.shared, and style.css hides what writes under .shared-view. On a board they have Full on
|
|
66
|
+
// (data.shared.full; docs/adr/2026-11-05-1912-a-shared-person-is-view-or-full.md) the page is .shared-full too,
|
|
67
|
+
// which shows the composers and card buttons again; levelHere follows the route shown and each sidebar event.
|
|
62
68
|
const shared = data?.shared || null;
|
|
63
69
|
document.documentElement.classList.toggle('shared-view', Boolean(shared));
|
|
70
|
+
const sharedHere = (route) => ({ by: data?.shared?.by ?? null, full: Boolean(data?.shared?.full?.includes(route)) });
|
|
71
|
+
function levelHere() {
|
|
72
|
+
if (!shared || !current) return;
|
|
73
|
+
const here = sharedHere(current.route);
|
|
74
|
+
document.documentElement.classList.toggle('shared-full', here.full);
|
|
75
|
+
const strip = current.root.querySelector('.view-only-strip');
|
|
76
|
+
if (strip) strip.innerHTML = BoardView.sharedStrip(here);
|
|
77
|
+
}
|
|
64
78
|
sidebar = Sidebar.mount({ data, poll: false, reload, open: (href) => { if (!open(href)) location.assign(href); } });
|
|
65
79
|
const boards = () => new Set((data?.sites || []).filter((s) => s.board).map((s) => s.route));
|
|
66
80
|
const titleOf = (route) => data?.sites?.find((s) => s.route === route)?.title || route;
|
|
@@ -72,13 +86,8 @@ window.AppShell = (function () {
|
|
|
72
86
|
listeners.get(route).add(fn);
|
|
73
87
|
return () => listeners.get(route)?.delete(fn);
|
|
74
88
|
};
|
|
75
|
-
// route → {fresh, generatedAt} for each board left fresh with no change since. Trusted only while the stream is up:
|
|
76
|
-
// a dropped stream may have missed a change, so its return forgets them all.
|
|
77
|
-
const left = new Map();
|
|
78
|
-
let streamUp = false;
|
|
79
89
|
let current = null;
|
|
80
90
|
function changed(route) {
|
|
81
|
-
left.delete(route);
|
|
82
91
|
for (const fn of listeners.get(route) || []) fn();
|
|
83
92
|
}
|
|
84
93
|
|
|
@@ -94,8 +103,7 @@ window.AppShell = (function () {
|
|
|
94
103
|
const cur = es = new EventSource('/_hub/stream');
|
|
95
104
|
cur.addEventListener('open', () => {
|
|
96
105
|
tries = 0;
|
|
97
|
-
|
|
98
|
-
if (opened) { left.clear(); if (current) changed(current.route); }
|
|
106
|
+
if (opened && current) changed(current.route);
|
|
99
107
|
opened = true;
|
|
100
108
|
});
|
|
101
109
|
cur.addEventListener('change', (e) => {
|
|
@@ -107,10 +115,10 @@ window.AppShell = (function () {
|
|
|
107
115
|
try { data = JSON.parse(e.data); } catch { return; }
|
|
108
116
|
codeSeen(data.code);
|
|
109
117
|
sidebar.setData(data);
|
|
118
|
+
levelHere();
|
|
110
119
|
current?.v?.B?.renderStatus?.();
|
|
111
120
|
});
|
|
112
121
|
cur.addEventListener('error', () => {
|
|
113
|
-
streamUp = false;
|
|
114
122
|
if (cur.readyState !== EventSource.CLOSED || es !== cur) return;
|
|
115
123
|
retry = setTimeout(openStream, Math.min(30000, 1000 * 2 ** tries++));
|
|
116
124
|
});
|
|
@@ -131,6 +139,14 @@ window.AppShell = (function () {
|
|
|
131
139
|
};
|
|
132
140
|
for (const type of ['pointerdown', 'keydown', 'scroll']) addEventListener(type, beat, { capture: true, passive: true });
|
|
133
141
|
|
|
142
|
+
// docs/adr/2026-11-05-1834-while-typing-the-header-is-a-strip-and-the-send-bar-sits-on-the-keyboard.md
|
|
143
|
+
// While a text field has focus :root.typing is set and style.css disables the floating + (New task); nothing else
|
|
144
|
+
// on the page moves.
|
|
145
|
+
document.addEventListener('focusin', (ev) => {
|
|
146
|
+
document.documentElement.classList.toggle('typing', ev.target.matches?.('textarea, input[type="text"], input:not([type])') ?? false);
|
|
147
|
+
});
|
|
148
|
+
document.addEventListener('focusout', () => document.documentElement.classList.remove('typing'));
|
|
149
|
+
|
|
134
150
|
// A view that changed what the sidebar's foot rows say, such as notifications, asks for a fresh copy
|
|
135
151
|
// rather than waiting for the stream's next one.
|
|
136
152
|
async function refreshSidebar() {
|
|
@@ -179,29 +195,23 @@ window.AppShell = (function () {
|
|
|
179
195
|
const board = view.kind === 'board';
|
|
180
196
|
const hub = route === undefined;
|
|
181
197
|
const siteTitle = hub ? 'Hub' : titleOf(route);
|
|
182
|
-
if (current)
|
|
183
|
-
const st = current.v.B.state;
|
|
184
|
-
const keep = streamUp && st?.view === 'fresh' && st.board ? { fresh: st.fresh, generatedAt: st.board.generatedAt } : null;
|
|
185
|
-
current.v.unmount();
|
|
186
|
-
if (keep) left.set(current.route, keep);
|
|
187
|
-
}
|
|
198
|
+
if (current) current.v.unmount();
|
|
188
199
|
const page = board ? null : AppViews[view.kind];
|
|
189
200
|
const root = document.createElement('div');
|
|
190
201
|
root.className = board ? 'view-board' : page.className;
|
|
191
|
-
root.innerHTML = board ? BoardView.markup({ title: siteTitle, shared }) : page.markup(view, { siteTitle });
|
|
202
|
+
root.innerHTML = board ? BoardView.markup({ title: siteTitle, shared: shared && sharedHere(route) }) : page.markup(view, { siteTitle });
|
|
192
203
|
if (current) current.root.replaceWith(root); else scrim.after(root);
|
|
193
204
|
const siteRoot = hub ? '/_hub' : '/' + encodeURIComponent(route);
|
|
194
205
|
let v;
|
|
195
206
|
if (board) {
|
|
196
|
-
|
|
197
|
-
left.delete(route);
|
|
198
|
-
v = BoardView.create(root, siteRoot, { signal: signalFor(route), live, pause: () => data?.pause, away: () => data?.away });
|
|
207
|
+
v = BoardView.create(root, siteRoot, { signal: signalFor(route), pause: () => data?.sites?.find((s) => s.route === route)?.pause || data?.pause, away: () => data?.away });
|
|
199
208
|
} else {
|
|
200
209
|
v = page.create(root, siteRoot, { view, siteTitle, boards, open: openHere, toggleSidebar: sidebar.toggle, keydown: sidebar.keydown, pointerdown: sidebar.pointerdown,
|
|
201
210
|
reload: () => go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'reload'), closeSidebar: sidebar.close, up: () => router.up(),
|
|
202
211
|
loaded: (url, title) => { if (current?.v === v) framed(url, title); }, sidebarChanged: refreshSidebar });
|
|
203
212
|
}
|
|
204
213
|
current = { view, route, root, v };
|
|
214
|
+
levelHere();
|
|
205
215
|
document.title = board ? siteTitle : page.title(view, { siteTitle });
|
|
206
216
|
if (boards().has(route)) { try { localStorage.setItem(LAST_KEY, route); } catch { /* no storage: `/` opens the start board */ } }
|
|
207
217
|
sidebar.setHere(hub ? new URL(AppRouter.urlOf(view), location.href).pathname.slice(1) : route);
|
|
@@ -232,8 +242,29 @@ window.AppShell = (function () {
|
|
|
232
242
|
// go(view, href, how, from): router.js's, with how 'replace' for a framed page's hand-off and 'reload' for a view's
|
|
233
243
|
// own refresh. → true, or a promise of whether the view switched once the owner answers about an unsaved edit.
|
|
234
244
|
let router = null;
|
|
245
|
+
const wide = matchMedia('(min-width: 900px)');
|
|
246
|
+
// Board and Waiting on you are a board's two pages (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md):
|
|
247
|
+
// from 900px the board shows Waiting on you in its pane, so every view of the questions page, a link, a push, a
|
|
248
|
+
// hand-off, a history entry or a cold load, shows the board at its own URL and opens the question's topic there (id,
|
|
249
|
+
// or null for none). A board just shown takes a moment to fill its pane, so the pane is asked again until it has.
|
|
250
|
+
function focusPane(id, tries = 50) {
|
|
251
|
+
if (current?.view.kind !== 'board' || current.v.B.focusQuestion?.(id) || tries <= 0) return;
|
|
252
|
+
setTimeout(() => focusPane(id, tries - 1), 100);
|
|
253
|
+
}
|
|
235
254
|
function go(view, href, how, from) {
|
|
236
255
|
if (!AppRouter.mounted(view)) return false;
|
|
256
|
+
if (view.kind === 'questions' && wide.matches) {
|
|
257
|
+
const board = { kind: 'board', route: view.route, hash: '' };
|
|
258
|
+
const boardHref = new URL(AppRouter.urlOf(board), location.href).href;
|
|
259
|
+
if (how === 'pop') history.replaceState(history.state, '', boardHref);
|
|
260
|
+
// On its own board it only opens the topic, so the URL, history and an open card stay as they are.
|
|
261
|
+
else if (current?.view.kind === 'board' && current.route === view.route && !settings) {
|
|
262
|
+
focusPane(view.id);
|
|
263
|
+
sidebar.close();
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown) focusPane(view.id); return shown; });
|
|
267
|
+
}
|
|
237
268
|
if (view.kind === 'settings') {
|
|
238
269
|
if (current?.view.kind === 'board' && current.route === view.route) {
|
|
239
270
|
const here = AppRouter.viewOf(location.href, { boards: boards() });
|
|
@@ -285,7 +316,17 @@ window.AppShell = (function () {
|
|
|
285
316
|
}
|
|
286
317
|
if (!AppRouter.mounted(first)) { location.replace('/_hub/'); return; }
|
|
287
318
|
|
|
288
|
-
|
|
319
|
+
let pane = null;
|
|
320
|
+
if (first.kind === 'questions' && wide.matches) {
|
|
321
|
+
pane = { id: first.id };
|
|
322
|
+
first = { kind: 'board', route: first.route, hash: '' };
|
|
323
|
+
history.replaceState(history.state, '', AppRouter.urlOf(first));
|
|
324
|
+
}
|
|
325
|
+
router = AppRouter.listen({ boards, go, wide: () => wide.matches });
|
|
326
|
+
// Widening past 900px on Waiting on you swaps to the board with that topic open in the pane.
|
|
327
|
+
wide.addEventListener('change', () => {
|
|
328
|
+
if (wide.matches && current?.view.kind === 'questions') go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'replace');
|
|
329
|
+
});
|
|
289
330
|
opener = (url, { replace = false, frames = true } = {}) => {
|
|
290
331
|
const view = AppRouter.viewOf(url, { boards: boards() });
|
|
291
332
|
if (!AppRouter.mounted(view) || (!frames && view.kind === 'frame')) return false;
|
|
@@ -301,6 +342,7 @@ window.AppShell = (function () {
|
|
|
301
342
|
navigator.serviceWorker?.startMessages?.();
|
|
302
343
|
|
|
303
344
|
show(first);
|
|
345
|
+
if (pane) focusPane(pane.id);
|
|
304
346
|
openStream();
|
|
305
347
|
addEventListener('visibilitychange', () => { if (!document.hidden) openStream(); });
|
|
306
348
|
addEventListener('pageshow', openStream);
|
|
@@ -309,7 +351,6 @@ window.AppShell = (function () {
|
|
|
309
351
|
addEventListener('pagehide', (e) => {
|
|
310
352
|
if (!e.persisted || !es) return;
|
|
311
353
|
clearTimeout(retry);
|
|
312
|
-
streamUp = false;
|
|
313
354
|
es.close();
|
|
314
355
|
});
|
|
315
356
|
}
|
package/board/public/sidebar.css
CHANGED
|
@@ -41,6 +41,11 @@
|
|
|
41
41
|
html.sb-update body.has-sidebar > .top .hub, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
|
|
42
42
|
html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub::after {
|
|
43
43
|
content: ''; position: absolute; width: 10px; height: 10px; top: 3px; right: 3px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg); }
|
|
44
|
+
/* A busy hub gets an orange dot on the logo and a broken one a red dot, in the update dot's place (red, then orange, then blue). */
|
|
45
|
+
html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > .top .hub, html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
|
|
46
|
+
html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > .top .hub::after, html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > :not(.sidebar) > .top .hub::after {
|
|
47
|
+
content: ''; position: absolute; width: 10px; height: 10px; top: 3px; right: 3px; border-radius: 50%; background: var(--color-notice); box-shadow: 0 0 0 2px var(--bg); }
|
|
48
|
+
html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-sidebar > :not(.sidebar) > .top .hub::after { background: var(--color-danger); }
|
|
44
49
|
.sidebar .sb-text { flex: 1; min-width: 0; }
|
|
45
50
|
.sidebar .sb-title { display: block; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
46
51
|
.sidebar .sb-moon { display: inline-block; margin-left: 5px; color: var(--muted); vertical-align: -1px; }
|
|
@@ -180,6 +185,7 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
|
|
|
180
185
|
.sidebar .t-building { --tone: var(--color-notice); }
|
|
181
186
|
.sidebar .t-ok { --tone: var(--color-success); }
|
|
182
187
|
.sidebar .t-wait { --tone: var(--color-notice); }
|
|
188
|
+
.sidebar .t-paused { --tone: var(--muted); }
|
|
183
189
|
|
|
184
190
|
@media (min-width: 900px) {
|
|
185
191
|
body.has-sidebar { padding-left: 272px; }
|