@lovek4d/kickflip 0.5.3 → 0.6.0
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 +69 -37
- package/SKILL.md +19 -13
- 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 +156 -83
- 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 +27 -13
- package/board/public/app-header.js +6 -7
- package/board/public/app-lanes.js +15 -38
- package/board/public/app-needs.js +36 -31
- package/board/public/app-snapshot.js +43 -70
- package/board/public/app-threads.js +38 -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 +123 -29
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +64 -24
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -69
- package/board/public/style.css +108 -57
- package/board/public/ui-cards.js +61 -46
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +25 -16
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- 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 +25 -23
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +57 -28
- package/board/public/ui.js +92 -17
- package/board/public/view-hub-settings.js +12 -58
- package/board/public/view-hub.js +2 -1
- 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 +19 -19
- package/board/stop.mjs +1 -0
- package/board/store.mjs +27 -18
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +22 -21
- 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 +1 -1
- 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 +7 -7
- 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/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
|
@@ -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.
|
|
@@ -44,6 +44,9 @@ window.AppShell = (function () {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
async function start() {
|
|
47
|
+
// The shell sends every view to the top itself, so the browser's own scroll restore is off: after a reload iOS put the old
|
|
48
|
+
// offset back once the page had grown, and left that area unpainted until a scroll (docs/adr/2026-11-06-1100-the-shell-turns-off-scroll-restoration.md).
|
|
49
|
+
history.scrollRestoration = 'manual';
|
|
47
50
|
// A frame that lands on a view's URL, such as a framed page's link back to its card, shows it in the shell around it.
|
|
48
51
|
if (outer()?.open(location.href, { frames: false })) return;
|
|
49
52
|
// The same URL and scope as hub-kit.js's push registration, so the one registration and its push subscription stay.
|
|
@@ -57,10 +60,20 @@ window.AppShell = (function () {
|
|
|
57
60
|
// A stored sidebar's code says nothing about the shell that booted, so it adopts the next live hash.
|
|
58
61
|
bootCode = data?.code || null;
|
|
59
62
|
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.
|
|
63
|
+
// A board shared with someone else (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md):
|
|
64
|
+
// the hub sends them data.shared, and style.css hides what writes under .shared-view. On a board they have Full on
|
|
65
|
+
// (data.shared.full; docs/adr/2026-11-05-1912-a-shared-person-is-view-or-full.md) the page is .shared-full too,
|
|
66
|
+
// which shows the composers and card buttons again; levelHere follows the route shown and each sidebar event.
|
|
62
67
|
const shared = data?.shared || null;
|
|
63
68
|
document.documentElement.classList.toggle('shared-view', Boolean(shared));
|
|
69
|
+
const sharedHere = (route) => ({ by: data?.shared?.by ?? null, full: Boolean(data?.shared?.full?.includes(route)) });
|
|
70
|
+
function levelHere() {
|
|
71
|
+
if (!shared || !current) return;
|
|
72
|
+
const here = sharedHere(current.route);
|
|
73
|
+
document.documentElement.classList.toggle('shared-full', here.full);
|
|
74
|
+
const strip = current.root.querySelector('.view-only-strip');
|
|
75
|
+
if (strip) strip.innerHTML = BoardView.sharedStrip(here);
|
|
76
|
+
}
|
|
64
77
|
sidebar = Sidebar.mount({ data, poll: false, reload, open: (href) => { if (!open(href)) location.assign(href); } });
|
|
65
78
|
const boards = () => new Set((data?.sites || []).filter((s) => s.board).map((s) => s.route));
|
|
66
79
|
const titleOf = (route) => data?.sites?.find((s) => s.route === route)?.title || route;
|
|
@@ -72,13 +85,8 @@ window.AppShell = (function () {
|
|
|
72
85
|
listeners.get(route).add(fn);
|
|
73
86
|
return () => listeners.get(route)?.delete(fn);
|
|
74
87
|
};
|
|
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
88
|
let current = null;
|
|
80
89
|
function changed(route) {
|
|
81
|
-
left.delete(route);
|
|
82
90
|
for (const fn of listeners.get(route) || []) fn();
|
|
83
91
|
}
|
|
84
92
|
|
|
@@ -94,8 +102,7 @@ window.AppShell = (function () {
|
|
|
94
102
|
const cur = es = new EventSource('/_hub/stream');
|
|
95
103
|
cur.addEventListener('open', () => {
|
|
96
104
|
tries = 0;
|
|
97
|
-
|
|
98
|
-
if (opened) { left.clear(); if (current) changed(current.route); }
|
|
105
|
+
if (opened && current) changed(current.route);
|
|
99
106
|
opened = true;
|
|
100
107
|
});
|
|
101
108
|
cur.addEventListener('change', (e) => {
|
|
@@ -107,10 +114,10 @@ window.AppShell = (function () {
|
|
|
107
114
|
try { data = JSON.parse(e.data); } catch { return; }
|
|
108
115
|
codeSeen(data.code);
|
|
109
116
|
sidebar.setData(data);
|
|
117
|
+
levelHere();
|
|
110
118
|
current?.v?.B?.renderStatus?.();
|
|
111
119
|
});
|
|
112
120
|
cur.addEventListener('error', () => {
|
|
113
|
-
streamUp = false;
|
|
114
121
|
if (cur.readyState !== EventSource.CLOSED || es !== cur) return;
|
|
115
122
|
retry = setTimeout(openStream, Math.min(30000, 1000 * 2 ** tries++));
|
|
116
123
|
});
|
|
@@ -131,6 +138,14 @@ window.AppShell = (function () {
|
|
|
131
138
|
};
|
|
132
139
|
for (const type of ['pointerdown', 'keydown', 'scroll']) addEventListener(type, beat, { capture: true, passive: true });
|
|
133
140
|
|
|
141
|
+
// docs/adr/2026-11-05-1834-while-typing-the-header-is-a-strip-and-the-send-bar-sits-on-the-keyboard.md
|
|
142
|
+
// While a text field has focus :root.typing is set and style.css disables the floating + (New task); nothing else
|
|
143
|
+
// on the page moves.
|
|
144
|
+
document.addEventListener('focusin', (ev) => {
|
|
145
|
+
document.documentElement.classList.toggle('typing', ev.target.matches?.('textarea, input[type="text"], input:not([type])') ?? false);
|
|
146
|
+
});
|
|
147
|
+
document.addEventListener('focusout', () => document.documentElement.classList.remove('typing'));
|
|
148
|
+
|
|
134
149
|
// A view that changed what the sidebar's foot rows say, such as notifications, asks for a fresh copy
|
|
135
150
|
// rather than waiting for the stream's next one.
|
|
136
151
|
async function refreshSidebar() {
|
|
@@ -179,29 +194,23 @@ window.AppShell = (function () {
|
|
|
179
194
|
const board = view.kind === 'board';
|
|
180
195
|
const hub = route === undefined;
|
|
181
196
|
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
|
-
}
|
|
197
|
+
if (current) current.v.unmount();
|
|
188
198
|
const page = board ? null : AppViews[view.kind];
|
|
189
199
|
const root = document.createElement('div');
|
|
190
200
|
root.className = board ? 'view-board' : page.className;
|
|
191
|
-
root.innerHTML = board ? BoardView.markup({ title: siteTitle, shared }) : page.markup(view, { siteTitle });
|
|
201
|
+
root.innerHTML = board ? BoardView.markup({ title: siteTitle, shared: shared && sharedHere(route) }) : page.markup(view, { siteTitle });
|
|
192
202
|
if (current) current.root.replaceWith(root); else scrim.after(root);
|
|
193
203
|
const siteRoot = hub ? '/_hub' : '/' + encodeURIComponent(route);
|
|
194
204
|
let v;
|
|
195
205
|
if (board) {
|
|
196
|
-
|
|
197
|
-
left.delete(route);
|
|
198
|
-
v = BoardView.create(root, siteRoot, { signal: signalFor(route), live, pause: () => data?.pause, away: () => data?.away });
|
|
206
|
+
v = BoardView.create(root, siteRoot, { signal: signalFor(route), pause: () => data?.sites?.find((s) => s.route === route)?.pause || data?.pause, away: () => data?.away });
|
|
199
207
|
} else {
|
|
200
208
|
v = page.create(root, siteRoot, { view, siteTitle, boards, open: openHere, toggleSidebar: sidebar.toggle, keydown: sidebar.keydown, pointerdown: sidebar.pointerdown,
|
|
201
209
|
reload: () => go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'reload'), closeSidebar: sidebar.close, up: () => router.up(),
|
|
202
210
|
loaded: (url, title) => { if (current?.v === v) framed(url, title); }, sidebarChanged: refreshSidebar });
|
|
203
211
|
}
|
|
204
212
|
current = { view, route, root, v };
|
|
213
|
+
levelHere();
|
|
205
214
|
document.title = board ? siteTitle : page.title(view, { siteTitle });
|
|
206
215
|
if (boards().has(route)) { try { localStorage.setItem(LAST_KEY, route); } catch { /* no storage: `/` opens the start board */ } }
|
|
207
216
|
sidebar.setHere(hub ? new URL(AppRouter.urlOf(view), location.href).pathname.slice(1) : route);
|
|
@@ -232,8 +241,29 @@ window.AppShell = (function () {
|
|
|
232
241
|
// 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
242
|
// own refresh. → true, or a promise of whether the view switched once the owner answers about an unsaved edit.
|
|
234
243
|
let router = null;
|
|
244
|
+
const wide = matchMedia('(min-width: 900px)');
|
|
245
|
+
// 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):
|
|
246
|
+
// from 900px the board shows Waiting on you in its pane, so every view of the questions page, a link, a push, a
|
|
247
|
+
// hand-off, a history entry or a cold load, shows the board at its own URL and opens the question's topic there (id,
|
|
248
|
+
// or null for none). A board just shown takes a moment to fill its pane, so the pane is asked again until it has.
|
|
249
|
+
function focusPane(id, tries = 50) {
|
|
250
|
+
if (current?.view.kind !== 'board' || current.v.B.focusQuestion?.(id) || tries <= 0) return;
|
|
251
|
+
setTimeout(() => focusPane(id, tries - 1), 100);
|
|
252
|
+
}
|
|
235
253
|
function go(view, href, how, from) {
|
|
236
254
|
if (!AppRouter.mounted(view)) return false;
|
|
255
|
+
if (view.kind === 'questions' && wide.matches) {
|
|
256
|
+
const board = { kind: 'board', route: view.route, hash: '' };
|
|
257
|
+
const boardHref = new URL(AppRouter.urlOf(board), location.href).href;
|
|
258
|
+
if (how === 'pop') history.replaceState(history.state, '', boardHref);
|
|
259
|
+
// On its own board it only opens the topic, so the URL, history and an open card stay as they are.
|
|
260
|
+
else if (current?.view.kind === 'board' && current.route === view.route && !settings) {
|
|
261
|
+
focusPane(view.id);
|
|
262
|
+
sidebar.close();
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown) focusPane(view.id); return shown; });
|
|
266
|
+
}
|
|
237
267
|
if (view.kind === 'settings') {
|
|
238
268
|
if (current?.view.kind === 'board' && current.route === view.route) {
|
|
239
269
|
const here = AppRouter.viewOf(location.href, { boards: boards() });
|
|
@@ -285,7 +315,17 @@ window.AppShell = (function () {
|
|
|
285
315
|
}
|
|
286
316
|
if (!AppRouter.mounted(first)) { location.replace('/_hub/'); return; }
|
|
287
317
|
|
|
288
|
-
|
|
318
|
+
let pane = null;
|
|
319
|
+
if (first.kind === 'questions' && wide.matches) {
|
|
320
|
+
pane = { id: first.id };
|
|
321
|
+
first = { kind: 'board', route: first.route, hash: '' };
|
|
322
|
+
history.replaceState(history.state, '', AppRouter.urlOf(first));
|
|
323
|
+
}
|
|
324
|
+
router = AppRouter.listen({ boards, go, wide: () => wide.matches });
|
|
325
|
+
// Widening past 900px on Waiting on you swaps to the board with that topic open in the pane.
|
|
326
|
+
wide.addEventListener('change', () => {
|
|
327
|
+
if (wide.matches && current?.view.kind === 'questions') go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'replace');
|
|
328
|
+
});
|
|
289
329
|
opener = (url, { replace = false, frames = true } = {}) => {
|
|
290
330
|
const view = AppRouter.viewOf(url, { boards: boards() });
|
|
291
331
|
if (!AppRouter.mounted(view) || (!frames && view.kind === 'frame')) return false;
|
|
@@ -301,6 +341,7 @@ window.AppShell = (function () {
|
|
|
301
341
|
navigator.serviceWorker?.startMessages?.();
|
|
302
342
|
|
|
303
343
|
show(first);
|
|
344
|
+
if (pane) focusPane(pane.id);
|
|
304
345
|
openStream();
|
|
305
346
|
addEventListener('visibilitychange', () => { if (!document.hidden) openStream(); });
|
|
306
347
|
addEventListener('pageshow', openStream);
|
|
@@ -309,7 +350,6 @@ window.AppShell = (function () {
|
|
|
309
350
|
addEventListener('pagehide', (e) => {
|
|
310
351
|
if (!e.persisted || !es) return;
|
|
311
352
|
clearTimeout(retry);
|
|
312
|
-
streamUp = false;
|
|
313
353
|
es.close();
|
|
314
354
|
});
|
|
315
355
|
}
|
package/board/public/sidebar.css
CHANGED
|
@@ -180,6 +180,7 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
|
|
|
180
180
|
.sidebar .t-building { --tone: var(--color-notice); }
|
|
181
181
|
.sidebar .t-ok { --tone: var(--color-success); }
|
|
182
182
|
.sidebar .t-wait { --tone: var(--color-notice); }
|
|
183
|
+
.sidebar .t-paused { --tone: var(--muted); }
|
|
183
184
|
|
|
184
185
|
@media (min-width: 900px) {
|
|
185
186
|
body.has-sidebar { padding-left: 272px; }
|
package/board/public/sidebar.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The sidebar's behaviour on a page with <aside id="sidebar" class="sidebar"> and <div id="sidebarScrim"
|
|
2
|
-
// class="sidebar-scrim">, after ui-
|
|
2
|
+
// class="sidebar-scrim">, after ui-progress-bar.js, ui-page-header.js and ui-sidebar.js (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md).
|
|
3
3
|
// Sidebar.mount({here, data}) paints `data` or the last copy this device stored; the app shell hands it each new copy
|
|
4
4
|
// through setData from its one stream (shell.js). The page header's logo (a.hub) toggles the sidebar at every
|
|
5
5
|
// width, with what waits on the other sites in its accessible label. Below 900px the sidebar is a drawer: the logo or a swipe in from the
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
// logo does, checkDevice reading this device's push subscription again, and keydown and pointerdown taking a framed
|
|
18
18
|
// page's events (view-frame.js), as the document's own
|
|
19
19
|
// A starting board's row (docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md): its seconds
|
|
20
|
-
// tick in place each second, and
|
|
21
|
-
//
|
|
20
|
+
// tick in place each second, and its running bar beats on every pulse's one clock (ui-progress-bar.js onePulse). When
|
|
21
|
+
// the board joins, its row fades in, and a repaint during the fade carries it on from where it was.
|
|
22
22
|
(function () {
|
|
23
23
|
const DATA_KEY = 'hub:sidebar';
|
|
24
24
|
// app.html reads it before the first paint, so a collapsed rail never shows for a moment.
|
|
@@ -27,8 +27,7 @@
|
|
|
27
27
|
// A phone has no keys to press, so it never shows the shortcuts sheet.
|
|
28
28
|
const DESKTOP = '(hover: hover) and (pointer: fine)';
|
|
29
29
|
const TWICE_MS = 1000;
|
|
30
|
-
//
|
|
31
|
-
const PULSE_MS = 1400;
|
|
30
|
+
// sidebar.css sb-join's length.
|
|
32
31
|
const JOIN_MS = 2500;
|
|
33
32
|
// paint() sets the bar's duration from this and its delay from switching.started.
|
|
34
33
|
const IDLE_MS = 3000;
|
|
@@ -58,9 +57,8 @@
|
|
|
58
57
|
if (!data) { try { data = JSON.parse(read(DATA_KEY) || 'null'); } catch { data = null; } }
|
|
59
58
|
let device = null;
|
|
60
59
|
let update = null;
|
|
61
|
-
//
|
|
62
|
-
let
|
|
63
|
-
const upgradeShown = () => (restart ? { state: restart.error ? 'updated' : 'restarting', ...restart } : data?.upgrade);
|
|
60
|
+
// The hub's version when this page booted; the row names the version only once it differs.
|
|
61
|
+
let bootVersion = data?.version ?? null;
|
|
64
62
|
let updateHTML = '';
|
|
65
63
|
const rail = matchMedia(RAIL);
|
|
66
64
|
const html = document.documentElement;
|
|
@@ -73,7 +71,7 @@
|
|
|
73
71
|
const logo = [...document.querySelectorAll('.top a.hub')].find(pageLogo);
|
|
74
72
|
if (!logo) return;
|
|
75
73
|
const n = othersWaiting(data, here);
|
|
76
|
-
logo.setAttribute('aria-label', `Boards${update ? ', new version ready' :
|
|
74
|
+
logo.setAttribute('aria-label', `Boards${update ? ', new version ready' : ''}${n ? `, ${n} waiting on other boards` : ''}`);
|
|
77
75
|
}
|
|
78
76
|
// switching: {opened, started, timer} while switch mode shows, opened when it opened the drawer, started on performance.now().
|
|
79
77
|
let switching = null;
|
|
@@ -91,7 +89,6 @@
|
|
|
91
89
|
}
|
|
92
90
|
function paintStarts() {
|
|
93
91
|
const now = Date.now();
|
|
94
|
-
for (const bar of aside.querySelectorAll('.sb-starting .phase-bars i.now')) bar.style.animationDelay = `-${now % PULSE_MS}ms`;
|
|
95
92
|
for (const [route, at] of joined) {
|
|
96
93
|
const row = now - at < JOIN_MS && aside.querySelector(`a.sb-row[href="/${encodeURIComponent(route)}/"]`);
|
|
97
94
|
if (!row) { joined.delete(route); continue; }
|
|
@@ -104,7 +101,7 @@
|
|
|
104
101
|
}
|
|
105
102
|
// A repaint keeps the drawer's scroll, so a new copy doesn't move the list under a finger.
|
|
106
103
|
function paint() {
|
|
107
|
-
html.classList.toggle('sb-update', Boolean(update
|
|
104
|
+
html.classList.toggle('sb-update', Boolean(update));
|
|
108
105
|
if (!data) return;
|
|
109
106
|
const scroll = aside.querySelector('.sb-scroll');
|
|
110
107
|
const top = painted ? scroll?.scrollTop : undefined;
|
|
@@ -116,11 +113,14 @@
|
|
|
116
113
|
bar.style.animationDelay = `${switching.started - performance.now()}ms`;
|
|
117
114
|
bar.style.animationDuration = `${IDLE_MS}ms`;
|
|
118
115
|
}
|
|
116
|
+
// A shared person's sidebar has no update row (ui-sidebar.js).
|
|
119
117
|
const fresh = aside.querySelector('.sb-update');
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
118
|
+
if (fresh) {
|
|
119
|
+
if (held) fresh.replaceWith(held);
|
|
120
|
+
const row = updateRow(update, data.version && data.version !== bootVersion ? data.version : null);
|
|
121
|
+
if (!held || row !== updateHTML) (held || fresh).innerHTML = row;
|
|
122
|
+
updateHTML = row;
|
|
123
|
+
}
|
|
124
124
|
const list = aside.querySelector('.sb-scroll');
|
|
125
125
|
if (list) list.scrollTop = top ?? list.scrollHeight;
|
|
126
126
|
paintStarts();
|
|
@@ -131,6 +131,7 @@
|
|
|
131
131
|
if (!next) return;
|
|
132
132
|
const starting = new Set(startsRunning().map((s) => s.route));
|
|
133
133
|
for (const s of next.sites || []) if (starting.has(s.route)) joined.set(s.route, Date.now());
|
|
134
|
+
bootVersion ??= next.version ?? null;
|
|
134
135
|
data = next;
|
|
135
136
|
write(DATA_KEY, JSON.stringify(data));
|
|
136
137
|
paint();
|
|
@@ -195,64 +196,10 @@
|
|
|
195
196
|
if (!refreshApp.disabled && update !== 'reloading') { setUpdate('reloading'); reload(); }
|
|
196
197
|
return;
|
|
197
198
|
}
|
|
198
|
-
const restartApp = e.target.closest?.('[data-app-restart]');
|
|
199
|
-
if (restartApp) { if (!restartApp.disabled) restartHub(); return; }
|
|
200
|
-
const upgradeApp = e.target.closest?.('[data-app-upgrade]');
|
|
201
|
-
if (upgradeApp) { if (!upgradeApp.disabled) upgrade(upgradeApp); return; }
|
|
202
199
|
if (e.target.closest?.('[data-new-board]')) { newBoard(); return; }
|
|
203
200
|
const failed = e.target.closest?.('[data-failed-start]');
|
|
204
201
|
if (failed) openFailed(failed.dataset.failedStart);
|
|
205
202
|
});
|
|
206
|
-
// Restart asks the hub to restart (POST /_hub/restart-hub), then polls until it answers with other code than it ran (its
|
|
207
|
-
// x-hub-code) and reloads the page (docs/adr/2026-11-05-2200-the-hub-pulls-a-new-version-itself-and-offers-restart.md).
|
|
208
|
-
// Past RESTART_TRIES polls the row goes back to Restart with how to restart by hand.
|
|
209
|
-
const RESTART_TRIES = 120;
|
|
210
|
-
async function restartHub() {
|
|
211
|
-
if (restart) return;
|
|
212
|
-
const version = data.upgrade.version;
|
|
213
|
-
restart = { version };
|
|
214
|
-
paint();
|
|
215
|
-
let before, command;
|
|
216
|
-
try {
|
|
217
|
-
const response = await fetch('/_hub/restart-hub', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: '{}' });
|
|
218
|
-
const body = await response.json().catch(() => ({}));
|
|
219
|
-
if (!response.ok) throw new Error(body.error || 'The hub didn’t restart.');
|
|
220
|
-
before = response.headers.get('x-hub-code');
|
|
221
|
-
command = body.command;
|
|
222
|
-
} catch (e) {
|
|
223
|
-
restart = { version, error: e.message || 'The hub didn’t answer.' };
|
|
224
|
-
paint();
|
|
225
|
-
restart = null;
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
for (let i = 0; i < RESTART_TRIES; i++) {
|
|
229
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
230
|
-
try {
|
|
231
|
-
const response = await fetch('/_hub/api/hub', { cache: 'no-store', headers: { accept: 'application/json' } });
|
|
232
|
-
const code = response.headers.get('x-hub-code');
|
|
233
|
-
if (response.ok && code && code !== before) { reload(); return; }
|
|
234
|
-
} catch { /* the hub is down while it restarts */ }
|
|
235
|
-
}
|
|
236
|
-
restart = { version, error: `The hub hasn’t come back. On the host, run ${command}` };
|
|
237
|
-
paint();
|
|
238
|
-
restart = null;
|
|
239
|
-
}
|
|
240
|
-
// Update pulls again after a failed pull (docs/adr/2026-11-05-2200-the-hub-pulls-a-new-version-itself-and-offers-restart.md). The row
|
|
241
|
-
// waits (ui.js waitOn) until the hub answers with what it did, then while that pull runs (ui-sidebar.js
|
|
242
|
-
// updateRow), and a refusal shows as a failed update.
|
|
243
|
-
function upgrade(button) {
|
|
244
|
-
BoardUI.waitOn(button, (async () => {
|
|
245
|
-
let next;
|
|
246
|
-
try {
|
|
247
|
-
const response = await fetch('/_hub/upgrade', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
248
|
-
const body = await response.json().catch(() => ({}));
|
|
249
|
-
next = response.ok ? body.upgrade : { ...data.upgrade, state: 'failed', error: body.error || 'The hub didn’t answer.' };
|
|
250
|
-
} catch {
|
|
251
|
-
next = { ...data.upgrade, state: 'failed', error: 'The hub didn’t answer.' };
|
|
252
|
-
}
|
|
253
|
-
setData({ ...data, upgrade: next });
|
|
254
|
-
})());
|
|
255
|
-
}
|
|
256
203
|
// A failed start's sheet (docs/adr/2026-11-02-2006-a-failed-start-retries-from-its-sheet.md). Dismiss deletes its Starting
|
|
257
204
|
// record; Retry starts it again, and its row reads starting… until the hub's next copy says otherwise.
|
|
258
205
|
function openFailed(route) {
|