@lovek4d/kickflip 0.1.0 → 0.4.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 +239 -104
- package/README.md +54 -4
- package/SKILL.md +63 -42
- package/bin/doctor.mjs +4 -3
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +103 -40
- package/bin/statusline.mjs +4 -3
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +5 -0
- package/board/auto-review.mjs +2 -0
- package/board/bin/agent.mjs +1 -1
- package/board/bin/board.mjs +371 -82
- package/board/bin/builder-stop-hook.mjs +4 -3
- package/board/domain.mjs +8 -3
- package/board/finish.mjs +3 -0
- package/board/headless.mjs +3 -0
- package/board/on-host.mjs +1 -0
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +66 -28
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +354 -77
- package/board/public/app-core.js +40 -8
- package/board/public/app-header.js +33 -8
- package/board/public/app-lanes.js +238 -34
- package/board/public/app-needs.js +2 -0
- package/board/public/app-snapshot.js +45 -11
- package/board/public/app-threads.js +20 -21
- package/board/public/app.html +6 -2
- package/board/public/app.js +114 -33
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +161 -118
- package/board/public/hub-kit.js +27 -7
- package/board/public/hub.css +26 -33
- package/board/public/linear-key.js +143 -0
- package/board/public/log.html +48 -0
- package/board/public/prompts.js +9 -8
- package/board/public/prototype-kit.js +6 -6
- package/board/public/prototype-switcher.js +13 -5
- package/board/public/prototype.html +1 -0
- package/board/public/router.js +14 -10
- package/board/public/rules-editor.js +4 -5
- package/board/public/shell.js +21 -7
- package/board/public/sidebar.css +51 -11
- package/board/public/sidebar.js +124 -19
- package/board/public/style.css +244 -56
- package/board/public/toasts.js +5 -3
- package/board/public/ui-cards.js +236 -66
- package/board/public/ui-enums.js +5 -4
- package/board/public/ui-header.js +23 -14
- package/board/public/ui-hub.js +34 -16
- package/board/public/ui-links.js +18 -10
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +21 -0
- package/board/public/ui-page-header.js +13 -8
- package/board/public/ui-patch.js +53 -0
- package/board/public/ui-phase-bars.js +17 -0
- package/board/public/ui-prompts.js +8 -8
- package/board/public/ui-questions.js +35 -11
- package/board/public/ui-sidebar.js +83 -21
- package/board/public/ui-snapshot.js +30 -13
- package/board/public/ui-threads.js +38 -23
- package/board/public/ui.js +96 -16
- package/board/public/view-frame.js +1 -1
- package/board/public/view-hub-settings.js +67 -44
- package/board/public/view-hub.js +105 -13
- package/board/public/view-notes.js +8 -5
- package/board/public/view-questions.js +123 -52
- package/board/public/view-settings.js +235 -62
- package/board/review.mjs +1 -0
- package/board/server.mjs +18 -7
- package/board/store.mjs +15 -6
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +20 -13
- package/docs/hub-session.md +4 -4
- 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 +3 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -10
- package/hub/incoming.mjs +2 -0
- package/hub/linear.mjs +2 -2
- package/hub/live.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -0
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- 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 +2 -2
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +8 -3
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +5 -1
- package/shared/card-prs.mjs +1 -0
- package/shared/colors.css +9 -6
- package/shared/fake-gh.mjs +105 -0
- package/shared/repos.mjs +1 -1
- 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/view-polling.js +0 -87
package/board/public/shell.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The app shell (app.html; docs/adr/
|
|
1
|
+
// The app shell (app.html; docs/adr/2026-10-29-2005-one-app-for-the-whole-hub.md): the sidebar, mounted once, beside one view at a
|
|
2
2
|
// time. A board's view is BoardView (app.js); Waiting on you and Notes are AppViews.questions and .notes,
|
|
3
3
|
// the Hub page AppViews.hub with its settings modals, and every other page AppViews.frame. Board Settings mounts over the board
|
|
4
4
|
// as a modal. Each page is {className, title(view, {siteTitle}), markup(view, {siteTitle}), create(root,
|
|
@@ -7,9 +7,10 @@
|
|
|
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
9
|
// leaves is unmounted; one left fresh whose stream sent no change since paints live when it comes back, and any other
|
|
10
|
-
// paints dimmed from its last visit (docs/adr/
|
|
10
|
+
// paints dimmed from its last visit (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.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
|
+
// AppShell.reload() reloads the app past the shell the service worker keeps.
|
|
13
14
|
window.AppShell = (function () {
|
|
14
15
|
const LAST_KEY = 'hub:lastBoard';
|
|
15
16
|
const DATA_KEY = 'hub:sidebar';
|
|
@@ -111,7 +112,20 @@ window.AppShell = (function () {
|
|
|
111
112
|
});
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
// A
|
|
115
|
+
// A tap, key or scroll on any page tells the hub the owner is here (hub/presence.mjs ownerTracker), at most once a
|
|
116
|
+
// minute while present. While away the first one goes at once and the moon goes from this page at the same moment
|
|
117
|
+
// (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md).
|
|
118
|
+
let beatAt = 0;
|
|
119
|
+
const beat = () => {
|
|
120
|
+
const away = Boolean(data?.away);
|
|
121
|
+
if (!away && Date.now() - beatAt < 60_000) return;
|
|
122
|
+
beatAt = Date.now();
|
|
123
|
+
if (away) { data = { ...data, away: false }; current?.v?.B?.renderStatus?.(); }
|
|
124
|
+
fetch('/_hub/presence', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }).catch(() => { beatAt = 0; });
|
|
125
|
+
};
|
|
126
|
+
for (const type of ['pointerdown', 'keydown', 'scroll']) addEventListener(type, beat, { capture: true, passive: true });
|
|
127
|
+
|
|
128
|
+
// A view that changed what the sidebar's foot rows say, such as notifications, asks for a fresh copy
|
|
115
129
|
// rather than waiting for the stream's next one.
|
|
116
130
|
async function refreshSidebar() {
|
|
117
131
|
sidebar.checkDevice();
|
|
@@ -175,7 +189,7 @@ window.AppShell = (function () {
|
|
|
175
189
|
if (board) {
|
|
176
190
|
const live = streamUp ? left.get(route) || null : null;
|
|
177
191
|
left.delete(route);
|
|
178
|
-
v = BoardView.create(root, siteRoot, { signal: signalFor(route), live, pause: () => data?.pause });
|
|
192
|
+
v = BoardView.create(root, siteRoot, { signal: signalFor(route), live, pause: () => data?.pause, away: () => data?.away });
|
|
179
193
|
} else {
|
|
180
194
|
v = page.create(root, siteRoot, { view, siteTitle, boards, open: openHere, toggleSidebar: sidebar.toggle, keydown: sidebar.keydown, pointerdown: sidebar.pointerdown,
|
|
181
195
|
reload: () => go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'reload'), closeSidebar: sidebar.close, up: () => router.up(),
|
|
@@ -205,7 +219,7 @@ window.AppShell = (function () {
|
|
|
205
219
|
const openHere = (href) => { if (!opener(href)) location.assign(href); };
|
|
206
220
|
|
|
207
221
|
// The board and Waiting on you follow their own hash, so a link that only moves it keeps the view.
|
|
208
|
-
const sameView = (a, b) => a.kind === b.kind && a.route === b.route && (a.kind !== 'notes' || a.item === b.item) && (a.kind !== 'frame' || a.url === b.url);
|
|
222
|
+
const sameView = (a, b) => a.kind === b.kind && a.route === b.route && (a.kind !== 'notes' || (a.item === b.item && a.wave === b.wave)) && (a.kind !== 'frame' || a.url === b.url);
|
|
209
223
|
const dirty = () => Boolean(current?.root.querySelector('[data-dirty]'));
|
|
210
224
|
addEventListener('beforeunload', (e) => { if (dirty()) { e.preventDefault(); e.returnValue = ''; } });
|
|
211
225
|
|
|
@@ -254,7 +268,7 @@ window.AppShell = (function () {
|
|
|
254
268
|
}
|
|
255
269
|
|
|
256
270
|
// `/` opens this device's last board, else the board the hub picks (sidebar data's start), else the hub's page
|
|
257
|
-
// (docs/adr/
|
|
271
|
+
// (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md).
|
|
258
272
|
let first = AppRouter.viewOf(location.href, { boards: boards() });
|
|
259
273
|
if (first?.kind === 'start') {
|
|
260
274
|
let last = null;
|
|
@@ -294,5 +308,5 @@ window.AppShell = (function () {
|
|
|
294
308
|
});
|
|
295
309
|
}
|
|
296
310
|
|
|
297
|
-
return { start, open, codeSeen };
|
|
311
|
+
return { start, open, codeSeen, reload };
|
|
298
312
|
})();
|
package/board/public/sidebar.css
CHANGED
|
@@ -30,12 +30,14 @@
|
|
|
30
30
|
.sidebar .sb-row:active { background: var(--chip); }
|
|
31
31
|
@media (hover: hover) { .sidebar .sb-row:hover { background: color-mix(in srgb, var(--chip) 70%, transparent); text-decoration: none; } }
|
|
32
32
|
.sidebar .sb-row.on { background: var(--accent-soft); }
|
|
33
|
-
.sidebar .sb-turnon { margin: 6px 0 0; background: color-mix(in srgb, var(--
|
|
33
|
+
.sidebar .sb-turnon { margin: 6px 0 0; background: color-mix(in srgb, var(--accent) 10%, var(--panel)); border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); }
|
|
34
34
|
.sidebar .sb-update-row { width: 100%; min-height: 48px; margin: 0 0 6px; padding: 6px 8px; border-radius: 10px; text-align: left; font: inherit; color: var(--text);
|
|
35
35
|
background: color-mix(in srgb, var(--accent) 10%, var(--panel)); border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); cursor: pointer; }
|
|
36
36
|
.sidebar .sb-update-row:disabled { opacity: 1; cursor: default; }
|
|
37
|
-
.sidebar .sb-go { flex: none; padding: 5px 10px; border-radius: 7px; background: var(--accent); color:
|
|
37
|
+
.sidebar .sb-go { flex: none; padding: 5px 10px; border-radius: 7px; background: var(--accent); color: var(--on-accent); font-size: 13px; font-weight: 600; }
|
|
38
38
|
.sidebar .sb-update-row:disabled .sb-go { background: transparent; color: var(--muted); padding-right: 0; font-weight: 400; }
|
|
39
|
+
/* While Reload or Upgrade runs, its pill keeps its blue with a spinner in place of its word (style.css, ui.js BUSY). */
|
|
40
|
+
.sidebar .sb-update-row[aria-busy="true"] .sb-go { background: var(--accent); color: var(--on-accent); padding-right: 10px; font-weight: 600; }
|
|
39
41
|
html.sb-update body.has-sidebar > .top .hub, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
|
|
40
42
|
html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub::after {
|
|
41
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); }
|
|
@@ -48,7 +50,39 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
|
|
|
48
50
|
.sidebar .sb-new { width: 100%; text-align: left; border: 0; background: transparent; font: inherit; }
|
|
49
51
|
.sidebar .sb-starting { color: var(--muted); }
|
|
50
52
|
.sidebar .sb-starting:hover, .sidebar .sb-starting:active { background: transparent; }
|
|
51
|
-
|
|
53
|
+
/* A starting board's steps on the board's phase bars, spread evenly across the row, its step and seconds below
|
|
54
|
+
(docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md). */
|
|
55
|
+
.sidebar .sb-steps { display: flex; margin: 6px 0 4px; }
|
|
56
|
+
.sidebar .sb-steps .phase-bars { flex: 1 1 auto; min-width: 0; display: flex; gap: 3px; }
|
|
57
|
+
.sidebar .sb-steps .phase-bars i { flex: 1 1 0; width: auto; min-width: 0; }
|
|
58
|
+
.sidebar .sb-step { font-variant-numeric: tabular-nums; }
|
|
59
|
+
/* At join the board's row fades in, ringed for a moment (sidebar.js). */
|
|
60
|
+
.sidebar .sb-joined { animation: sb-join 2.5s ease-out both; }
|
|
61
|
+
@keyframes sb-join {
|
|
62
|
+
0% { opacity: 0; transform: translateY(4px); box-shadow: 0 0 0 1px var(--accent) inset; }
|
|
63
|
+
20% { opacity: 1; transform: none; box-shadow: 0 0 0 1px var(--accent) inset; }
|
|
64
|
+
80% { box-shadow: 0 0 0 1px var(--accent) inset; }
|
|
65
|
+
100% { box-shadow: none; }
|
|
66
|
+
}
|
|
67
|
+
@media (prefers-reduced-motion: reduce) { .sidebar .sb-joined { animation: none; } }
|
|
68
|
+
.sidebar .sb-failed { width: 100%; text-align: left; border: 0; background: transparent; font: inherit; color: var(--text); }
|
|
69
|
+
.sidebar .sb-failed .sb-title { color: var(--color-danger); }
|
|
70
|
+
.sidebar .sb-failed:active { background: var(--chip); }
|
|
71
|
+
@media (hover: hover) { .sidebar .sb-failed:hover { background: color-mix(in srgb, var(--chip) 70%, transparent); } }
|
|
72
|
+
|
|
73
|
+
#failedStartSheet .sheet-head h2 { font-size: 17px; overflow-wrap: anywhere; }
|
|
74
|
+
#failedStartSheet .fs-error { margin: 4px 0 12px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--color-danger) 40%, var(--line)); border-radius: 10px;
|
|
75
|
+
color: var(--color-danger); font-size: 13px; overflow-wrap: anywhere; }
|
|
76
|
+
#failedStartSheet .fs-facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 12px; margin: 0 0 14px; font-size: 13px; }
|
|
77
|
+
#failedStartSheet .fs-facts dt { color: var(--muted); }
|
|
78
|
+
#failedStartSheet .fs-facts dd { margin: 0; overflow-wrap: anywhere; }
|
|
79
|
+
#failedStartSheet .fs-refused { margin: 0 0 8px; color: var(--color-danger); font-size: 13px; }
|
|
80
|
+
#failedStartSheet .fs-refused[hidden] { display: none; }
|
|
81
|
+
#failedStartSheet .fs-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
|
82
|
+
#failedStartSheet .fs-actions button { min-height: 44px; padding: 6px 16px; }
|
|
83
|
+
@media (min-width: 900px) {
|
|
84
|
+
#failedStartSheet { inset: 0; margin: auto; height: fit-content; max-height: calc(100dvh - 48px); }
|
|
85
|
+
}
|
|
52
86
|
.sidebar .sb-row .icon.sb-plus { color: var(--muted); background: var(--chip); }
|
|
53
87
|
|
|
54
88
|
#newBoardSheet .nb-form { min-width: 0; }
|
|
@@ -66,15 +100,15 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
|
|
|
66
100
|
#newBoardSheet .nb-repo:hover, #newBoardSheet .nb-repo:focus-visible { border-color: var(--accent); }
|
|
67
101
|
#newBoardSheet .nb-repo strong { overflow-wrap: anywhere; }
|
|
68
102
|
#newBoardSheet .nb-repo.nb-repo-highlight { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
|
|
69
|
-
#newBoardSheet .nb-repo-tag { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 6px; background: var(--
|
|
103
|
+
#newBoardSheet .nb-repo-tag { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 6px; background: var(--chip); color: var(--muted); font-size: 11px; font-weight: 600; vertical-align: 1px; white-space: nowrap; }
|
|
70
104
|
#newBoardSheet .nb-box { display: flex; gap: 10px; align-items: flex-start; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
|
|
71
105
|
#newBoardSheet .nb-box > span { min-width: 0; }
|
|
72
106
|
#newBoardSheet .nb-box strong { display: block; overflow-wrap: anywhere; }
|
|
73
107
|
#newBoardSheet .nb-box-no { border-color: color-mix(in srgb, var(--color-danger) 40%, var(--line)); }
|
|
74
108
|
#newBoardSheet .nb-why { display: block; margin-top: 4px; color: var(--color-danger); font-size: 13px; }
|
|
75
|
-
#newBoardSheet .nb-spin { flex: none; width: 16px; height: 16px; margin-top: 2px; border: 2px solid var(--line); border-top-color: var(--
|
|
109
|
+
#newBoardSheet .nb-spin { flex: none; width: 16px; height: 16px; margin-top: 2px; border: 2px solid var(--line); border-top-color: var(--color-notice); border-radius: 50%; animation: nb-spin .8s linear infinite; }
|
|
76
110
|
@keyframes nb-spin { to { transform: rotate(360deg); } }
|
|
77
|
-
#newBoardSheet .nb-local { display: block; margin-top: 8px; font-size: 12px; color: var(--
|
|
111
|
+
#newBoardSheet .nb-local { display: block; margin-top: 8px; font-size: 12px; color: var(--muted); }
|
|
78
112
|
#newBoardSheet .nb-selected { padding: 12px; border: 1px solid var(--line); border-radius: 10px; }
|
|
79
113
|
#newBoardSheet .nb-clone { display: block; margin-top: 6px; overflow-wrap: anywhere; font-size: 12px; }
|
|
80
114
|
#newBoardSheet [hidden] { display: none; }
|
|
@@ -110,11 +144,11 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
|
|
|
110
144
|
@starting-style { .sidebar.ks-on .ks-label-hint { opacity: 0; } }
|
|
111
145
|
/* The label is the bar's containing block: left 0 is the BOARDS text's edge, and right -8px crosses the label's 8px
|
|
112
146
|
side margin (.sb-label) to the rows' right edge. The bar sits in the label's 4px bottom margin. */
|
|
113
|
-
.sidebar .ks-bar { position: absolute; left: 0; right: -8px; top: 100%; height: 2px; margin-top: 1px; background: var(--
|
|
114
|
-
transform-origin: 0 50%; animation: ks-drain
|
|
147
|
+
.sidebar .ks-bar { position: absolute; left: 0; right: -8px; top: 100%; height: 2px; margin-top: 1px; background: var(--accent);
|
|
148
|
+
transform-origin: 0 50%; animation: ks-drain linear forwards; }
|
|
115
149
|
@keyframes ks-drain { to { transform: scaleX(0); } }
|
|
116
150
|
.sidebar .ks-u { transition: color 200ms linear; }
|
|
117
|
-
.sidebar.ks-on .ks-u { color: var(--
|
|
151
|
+
.sidebar.ks-on .ks-u { color: var(--accent); transition: color 150ms linear; }
|
|
118
152
|
@starting-style { .sidebar.ks-on .ks-u { color: inherit; } }
|
|
119
153
|
@media (prefers-reduced-motion: reduce) {
|
|
120
154
|
.sidebar .ks-bar { animation: none; }
|
|
@@ -126,6 +160,11 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
|
|
|
126
160
|
|
|
127
161
|
/* A site's status (hub/home.mjs statusLine): the lane chips wrap on their own line, and the status word goes below them. */
|
|
128
162
|
.sidebar .sb-status { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; min-width: 0; }
|
|
163
|
+
.sidebar .sb-head { display: flex; align-items: baseline; gap: 8px; }
|
|
164
|
+
.sidebar .sb-head .sb-title { flex: 1; min-width: 0; white-space: normal; overflow-wrap: anywhere; }
|
|
165
|
+
.sidebar .sb-ctx { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
166
|
+
.sidebar .sb-ctx.full { color: var(--color-danger); font-weight: 600; }
|
|
167
|
+
.sidebar .sb-ctx.stale { opacity: .6; }
|
|
129
168
|
.sidebar .lcs { order: -1; flex: 1 1 100%; min-width: 0; display: flex; flex-wrap: wrap; gap: 4px; }
|
|
130
169
|
.sidebar .lc { flex: none; font-size: 11px; font-weight: 600; border-radius: 6px; padding: 0 6px; line-height: 18px; color: var(--tone); background: color-mix(in srgb, var(--tone) 14%, transparent); }
|
|
131
170
|
.sidebar .lc-review { --tone: var(--accent); } .sidebar .lc-progress { --tone: var(--color-notice); } .sidebar .lc-todo { --tone: var(--muted); }
|
|
@@ -134,7 +173,8 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
|
|
|
134
173
|
@media (prefers-reduced-motion: no-preference) { .sidebar .dot.pulse { animation: sb-pulse 1.4s ease-in-out infinite; } }
|
|
135
174
|
.sidebar .word { flex: none; font-weight: 600; color: var(--tone); }
|
|
136
175
|
.sidebar .t-down, .sidebar .t-needs { --tone: var(--color-danger); }
|
|
137
|
-
.sidebar .t-starting
|
|
176
|
+
.sidebar .t-starting { --tone: var(--color-notice); }
|
|
177
|
+
.sidebar .t-stopped, .sidebar .t-asleep { --tone: var(--muted); }
|
|
138
178
|
.sidebar .t-working { --tone: var(--color-notice); }
|
|
139
179
|
.sidebar .t-building { --tone: var(--color-notice); }
|
|
140
180
|
.sidebar .t-ok { --tone: var(--color-success); }
|
|
@@ -162,7 +202,7 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
|
|
|
162
202
|
.sidebar .sb-scroll { padding-top: max(8px, env(safe-area-inset-top)); padding-bottom: 6px; }
|
|
163
203
|
}
|
|
164
204
|
|
|
165
|
-
.sidebar .u-tone-ok { --u: var(--
|
|
205
|
+
.sidebar .u-tone-ok { --u: var(--muted); }
|
|
166
206
|
.sidebar .u-tone-high { --u: var(--color-notice); }
|
|
167
207
|
.sidebar .u-tone-full { --u: var(--color-danger); }
|
|
168
208
|
.sidebar .u-tone-stale, .sidebar .u-tone-none { --u: var(--muted); }
|
package/board/public/sidebar.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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-page-header.js and ui-sidebar.js (docs/adr/
|
|
2
|
+
// class="sidebar-scrim">, after ui-phase-bars.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
|
|
6
6
|
// left edge opens it, and the scrim, Escape, ✕, the drawer's own logo or a swipe back closes it. From 900px it is a
|
|
7
7
|
// rail, which its own logo collapses and the page's logo brings back; this device remembers that, and <html> carries
|
|
8
|
-
// sb-collapsed meanwhile (docs/adr/
|
|
8
|
+
// sb-collapsed meanwhile (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md).
|
|
9
9
|
// Switch mode: s pressed twice within a second, outside a text field, a contenteditable or an open dialog, shows each
|
|
10
10
|
// row's key (ui-sidebar.js switchKeys), opening the drawer or showing a collapsed rail until it ends; the next key opens
|
|
11
11
|
// that row through open(href). Escape, a click, a held modifier, typing in a field or 3 seconds with no key end it
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
// → {setHere(route), setData(data), setUpdate(state), checkDevice(), close(), toggle(), keydown(e), pointerdown()}, toggle doing what the
|
|
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
|
+
// 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 a repaint sets its running bar's pulse from the clock, so a new copy never restarts
|
|
21
|
+
// it. When the board joins, its row fades in, and a repaint during the fade carries it on from where it was.
|
|
19
22
|
(function () {
|
|
20
23
|
const DATA_KEY = 'hub:sidebar';
|
|
21
24
|
// app.html reads it before the first paint, so a collapsed rail never shows for a moment.
|
|
@@ -24,7 +27,10 @@
|
|
|
24
27
|
// A phone has no keys to press, so it never shows the shortcuts sheet.
|
|
25
28
|
const DESKTOP = '(hover: hover) and (pointer: fine)';
|
|
26
29
|
const TWICE_MS = 1000;
|
|
27
|
-
//
|
|
30
|
+
// style.css pulse-fill's period, and sidebar.css sb-join's length.
|
|
31
|
+
const PULSE_MS = 1400;
|
|
32
|
+
const JOIN_MS = 2500;
|
|
33
|
+
// paint() sets the bar's duration from this and its delay from switching.started.
|
|
28
34
|
const IDLE_MS = 3000;
|
|
29
35
|
const FIELD = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';
|
|
30
36
|
const MODIFIERS = new Set(['Shift', 'Meta', 'Control', 'Alt', 'AltGraph', 'CapsLock', 'Fn']);
|
|
@@ -46,7 +52,7 @@
|
|
|
46
52
|
const aside = document.getElementById('sidebar');
|
|
47
53
|
const scrim = document.getElementById('sidebarScrim');
|
|
48
54
|
// ui.js replaces the BoardUI global when it loads after this, so the renderers are held here.
|
|
49
|
-
const { sidebar, updateRow, othersWaiting, switchKeys, shortcuts } = globalThis.BoardUI || {};
|
|
55
|
+
const { sidebar, failedStart, startCaption, updateRow, othersWaiting, switchKeys, shortcuts } = globalThis.BoardUI || {};
|
|
50
56
|
const none = { setHere() {}, setData() {}, setUpdate() {}, checkDevice() {}, close() {}, toggle() {}, keydown() {}, pointerdown() {} };
|
|
51
57
|
if (!aside || !sidebar) return none;
|
|
52
58
|
if (!data) { try { data = JSON.parse(read(DATA_KEY) || 'null'); } catch { data = null; } }
|
|
@@ -64,38 +70,70 @@
|
|
|
64
70
|
const logo = [...document.querySelectorAll('.top a.hub')].find(pageLogo);
|
|
65
71
|
if (!logo) return;
|
|
66
72
|
const n = othersWaiting(data, here);
|
|
67
|
-
logo.setAttribute('aria-label', `Boards${update ? ', new version ready' : ''}${n ? `, ${n} waiting on other boards` : ''}`);
|
|
73
|
+
logo.setAttribute('aria-label', `Boards${update ? ', new version ready' : data?.upgrade ? ', update available' : ''}${n ? `, ${n} waiting on other boards` : ''}`);
|
|
68
74
|
}
|
|
69
|
-
// switching: {
|
|
75
|
+
// switching: {opened, started, timer} while switch mode shows, opened when it opened the drawer, started on performance.now().
|
|
70
76
|
let switching = null;
|
|
71
77
|
let painted = false;
|
|
78
|
+
// joined: when each board that was starting joined, by route, through its fade. ticker: the seconds' interval while
|
|
79
|
+
// a start runs.
|
|
80
|
+
const joined = new Map();
|
|
81
|
+
let ticker = null;
|
|
82
|
+
const startsRunning = () => (data?.starting || []).filter((s) => !s.failed);
|
|
83
|
+
function tickSteps() {
|
|
84
|
+
for (const s of startsRunning()) {
|
|
85
|
+
const caption = aside.querySelector(`[data-starting="${s.route}"] .sb-step`);
|
|
86
|
+
if (caption) caption.textContent = startCaption(s);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function paintStarts() {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
for (const bar of aside.querySelectorAll('.sb-starting .phase-bars i.now')) bar.style.animationDelay = `-${now % PULSE_MS}ms`;
|
|
92
|
+
for (const [route, at] of joined) {
|
|
93
|
+
const row = now - at < JOIN_MS && aside.querySelector(`a.sb-row[href="/${encodeURIComponent(route)}/"]`);
|
|
94
|
+
if (!row) { joined.delete(route); continue; }
|
|
95
|
+
row.classList.add('sb-joined');
|
|
96
|
+
row.style.animationDelay = `-${now - at}ms`;
|
|
97
|
+
}
|
|
98
|
+
const running = startsRunning().length > 0;
|
|
99
|
+
if (running && !ticker) ticker = setInterval(tickSteps, 1000);
|
|
100
|
+
if (!running && ticker) { clearInterval(ticker); ticker = null; }
|
|
101
|
+
}
|
|
72
102
|
// A repaint keeps the drawer's scroll, so a new copy doesn't move the list under a finger.
|
|
73
103
|
function paint() {
|
|
104
|
+
html.classList.toggle('sb-update', Boolean(update || data?.upgrade));
|
|
74
105
|
if (!data) return;
|
|
75
106
|
const scroll = aside.querySelector('.sb-scroll');
|
|
76
107
|
const top = painted ? scroll?.scrollTop : undefined;
|
|
77
108
|
// Screen readers announce a role="status" node only when its own content changes, so the node outlives the repaint.
|
|
78
109
|
const held = aside.querySelector('.sb-update');
|
|
79
110
|
aside.innerHTML = sidebar(data, { here, device, switching: Boolean(switching) });
|
|
111
|
+
if (switching) {
|
|
112
|
+
const bar = aside.querySelector('.ks-bar');
|
|
113
|
+
bar.style.animationDelay = `${switching.started - performance.now()}ms`;
|
|
114
|
+
bar.style.animationDuration = `${IDLE_MS}ms`;
|
|
115
|
+
}
|
|
80
116
|
const fresh = aside.querySelector('.sb-update');
|
|
81
117
|
if (held) fresh.replaceWith(held);
|
|
82
|
-
const row = updateRow(update);
|
|
118
|
+
const row = updateRow(update, data.upgrade);
|
|
83
119
|
if (!held || row !== updateHTML) (held || fresh).innerHTML = row;
|
|
84
120
|
updateHTML = row;
|
|
85
121
|
const list = aside.querySelector('.sb-scroll');
|
|
86
122
|
if (list) list.scrollTop = top ?? list.scrollHeight;
|
|
123
|
+
paintStarts();
|
|
87
124
|
painted = true;
|
|
88
125
|
logoCount();
|
|
89
126
|
}
|
|
90
127
|
function setData(next) {
|
|
91
128
|
if (!next) return;
|
|
129
|
+
const starting = new Set(startsRunning().map((s) => s.route));
|
|
130
|
+
for (const s of next.sites || []) if (starting.has(s.route)) joined.set(s.route, Date.now());
|
|
92
131
|
data = next;
|
|
93
132
|
write(DATA_KEY, JSON.stringify(data));
|
|
94
133
|
paint();
|
|
95
134
|
}
|
|
96
135
|
function setUpdate(state) {
|
|
97
136
|
update = state;
|
|
98
|
-
html.classList.toggle('sb-update', Boolean(update));
|
|
99
137
|
paint();
|
|
100
138
|
}
|
|
101
139
|
|
|
@@ -140,7 +178,7 @@
|
|
|
140
178
|
const response = await fetch('/_hub/new-board', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) });
|
|
141
179
|
if (!response.ok) throw new Error(await response.text());
|
|
142
180
|
const { route } = await response.json();
|
|
143
|
-
setData({ ...data, starting: [...(data?.starting || []).filter((s) => s.route !== route), { route, title: input.name, repo: selected.clone,
|
|
181
|
+
setData({ ...data, starting: [...(data?.starting || []).filter((s) => s.route !== route), { route, title: input.name, repo: selected.clone, step: selected.cloned ? 'starting' : 'cloning', stepAt: new Date().toISOString(), askedAt: new Date().toISOString(), failed: false }] });
|
|
144
182
|
if (rail.matches) collapse(false); else open();
|
|
145
183
|
aside.querySelector(`[data-starting="${route}"]`)?.scrollIntoView({ block: 'nearest' });
|
|
146
184
|
refresh().catch(() => {});
|
|
@@ -148,22 +186,89 @@
|
|
|
148
186
|
onClose: () => aside.querySelector('[data-new-board]')?.focus({ preventScroll: true }),
|
|
149
187
|
});
|
|
150
188
|
}
|
|
151
|
-
aside.addEventListener('click',
|
|
189
|
+
aside.addEventListener('click', (e) => {
|
|
152
190
|
const refreshApp = e.target.closest?.('[data-app-reload]');
|
|
153
191
|
if (refreshApp) {
|
|
154
192
|
if (!refreshApp.disabled && update !== 'reloading') { setUpdate('reloading'); reload(); }
|
|
155
193
|
return;
|
|
156
194
|
}
|
|
195
|
+
const upgradeApp = e.target.closest?.('[data-app-upgrade]');
|
|
196
|
+
if (upgradeApp) { if (!upgradeApp.disabled) upgrade(upgradeApp); return; }
|
|
157
197
|
if (e.target.closest?.('[data-new-board]')) { newBoard(); return; }
|
|
158
|
-
const
|
|
159
|
-
if (
|
|
160
|
-
dismiss.disabled = true;
|
|
161
|
-
try {
|
|
162
|
-
const response = await fetch(`/_hub/new-board/${encodeURIComponent(dismiss.dataset.dismissBoard)}`, { method: 'DELETE' });
|
|
163
|
-
if (!response.ok) throw new Error(await response.text());
|
|
164
|
-
await refresh();
|
|
165
|
-
} catch (e) { dismiss.disabled = false; globalThis.BoardDialogs?.notice(e.message); }
|
|
198
|
+
const failed = e.target.closest?.('[data-failed-start]');
|
|
199
|
+
if (failed) openFailed(failed.dataset.failedStart);
|
|
166
200
|
});
|
|
201
|
+
// Upgrade asks the hub to run kickflip update (docs/adr/2026-11-03-1521-the-hub-offers-a-newer-kickflip-from-npm.md). The row
|
|
202
|
+
// waits (ui.js waitOn) until the hub answers with what it did, then while that update waits or runs (ui-sidebar.js
|
|
203
|
+
// updateRow), and a refusal shows as a failed update.
|
|
204
|
+
function upgrade(button) {
|
|
205
|
+
BoardUI.waitOn(button, (async () => {
|
|
206
|
+
let next;
|
|
207
|
+
try {
|
|
208
|
+
const response = await fetch('/_hub/upgrade', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
209
|
+
const body = await response.json().catch(() => ({}));
|
|
210
|
+
next = response.ok ? body.upgrade : { ...data.upgrade, state: 'failed', error: body.error || 'The hub didn’t answer.' };
|
|
211
|
+
} catch {
|
|
212
|
+
next = { ...data.upgrade, state: 'failed', error: 'The hub didn’t answer.' };
|
|
213
|
+
}
|
|
214
|
+
setData({ ...data, upgrade: next });
|
|
215
|
+
})());
|
|
216
|
+
}
|
|
217
|
+
// A failed start's sheet (docs/adr/2026-11-02-2006-a-failed-start-retries-from-its-sheet.md). Dismiss deletes its Starting
|
|
218
|
+
// record; Retry starts it again, and its row reads starting… until the hub's next copy says otherwise.
|
|
219
|
+
function openFailed(route) {
|
|
220
|
+
const s = (data?.starting || []).find((x) => x.route === route && x.failed);
|
|
221
|
+
if (!s || document.getElementById('failedStartSheet')) return;
|
|
222
|
+
const previous = document.activeElement;
|
|
223
|
+
const el = document.createElement('dialog');
|
|
224
|
+
el.setAttribute('id', 'failedStartSheet');
|
|
225
|
+
el.setAttribute('class', 'sheet');
|
|
226
|
+
el.setAttribute('aria-labelledby', 'failedStartTitle');
|
|
227
|
+
el.innerHTML = failedStart(s, { home: data.home });
|
|
228
|
+
let sending = false;
|
|
229
|
+
const done = () => { if (!sending) el.close(); };
|
|
230
|
+
// The tapped button waits (ui.js waitOn), the other disabled, until the hub answers.
|
|
231
|
+
async function act(button, request, after) {
|
|
232
|
+
sending = true;
|
|
233
|
+
const refused = el.querySelector('.fs-refused');
|
|
234
|
+
refused.hidden = true;
|
|
235
|
+
try {
|
|
236
|
+
const response = await BoardUI.waitOn(button, fetch(`/_hub/new-board/${encodeURIComponent(route)}${request.path || ''}`, request.options), { group: el.querySelectorAll('.fs-actions button') });
|
|
237
|
+
if (!response.ok) throw new Error(await response.text());
|
|
238
|
+
after();
|
|
239
|
+
sending = false;
|
|
240
|
+
el.close();
|
|
241
|
+
refresh().catch(() => {});
|
|
242
|
+
} catch (e) {
|
|
243
|
+
sending = false;
|
|
244
|
+
refused.textContent = e.message;
|
|
245
|
+
refused.hidden = false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
el.addEventListener('click', (e) => {
|
|
249
|
+
if (sending) return;
|
|
250
|
+
if (e.target === el || e.target.closest('[data-failed-close]')) { done(); return; }
|
|
251
|
+
const dismiss = e.target.closest('[data-failed-dismiss]');
|
|
252
|
+
const retry = e.target.closest('[data-failed-retry]');
|
|
253
|
+
if (dismiss) {
|
|
254
|
+
act(dismiss, { options: { method: 'DELETE' } }, () => setData({ ...data, starting: data.starting.filter((x) => x.route !== route) }));
|
|
255
|
+
} else if (retry) {
|
|
256
|
+
act(retry, { path: '/retry', options: { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' } }, () => {
|
|
257
|
+
const { error, ...rest } = s;
|
|
258
|
+
const again = { ...rest, step: 'starting', stepAt: new Date().toISOString(), askedAt: new Date().toISOString(), failed: false };
|
|
259
|
+
setData({ ...data, starting: data.starting.map((x) => (x.route === route ? again : x)) });
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
el.addEventListener('cancel', (e) => { e.preventDefault(); done(); });
|
|
264
|
+
el.addEventListener('close', () => {
|
|
265
|
+
el.remove();
|
|
266
|
+
if (previous?.isConnected) previous.focus({ preventScroll: true });
|
|
267
|
+
});
|
|
268
|
+
document.body.appendChild(el);
|
|
269
|
+
el.showModal();
|
|
270
|
+
(el.querySelector('[data-failed-retry]') || el.querySelector('[data-failed-dismiss]')).focus({ preventScroll: true });
|
|
271
|
+
}
|
|
167
272
|
document.addEventListener('click', (e) => {
|
|
168
273
|
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
|
|
169
274
|
if (e.target.closest?.('[data-sidebar-close]')) { e.preventDefault(); if (rail.matches) collapse(true); else close(); return; }
|
|
@@ -177,7 +282,7 @@
|
|
|
177
282
|
let lastS = -Infinity;
|
|
178
283
|
function startSwitch() {
|
|
179
284
|
lastS = -Infinity;
|
|
180
|
-
switching = { opened: !rail.matches && !isOpen(), timer: setTimeout(() => endSwitch(), IDLE_MS) };
|
|
285
|
+
switching = { opened: !rail.matches && !isOpen(), started: performance.now(), timer: setTimeout(() => endSwitch(), IDLE_MS) };
|
|
181
286
|
if (switching.opened) open();
|
|
182
287
|
aside.classList.add('ks-on');
|
|
183
288
|
paint();
|