@lovek4d/kickflip 0.0.0-stage → 0.1.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.
Files changed (111) hide show
  1. package/CONTEXT.md +414 -0
  2. package/LICENSE +57 -0
  3. package/README.md +23 -2
  4. package/SKILL.md +121 -0
  5. package/bin/doctor.mjs +3 -0
  6. package/bin/install.mjs +3 -0
  7. package/bin/kickflip.mjs +125 -0
  8. package/bin/statusline.mjs +4 -0
  9. package/bin/wait-events.mjs +3 -0
  10. package/bin/web-ts.mjs +2 -0
  11. package/board/bin/agent.mjs +2 -0
  12. package/board/bin/board.mjs +207 -0
  13. package/board/bin/builder-stop-hook.mjs +5 -0
  14. package/board/build-script.mjs +1 -0
  15. package/board/domain.mjs +3 -0
  16. package/board/public/app-asks.js +169 -0
  17. package/board/public/app-banner.js +48 -0
  18. package/board/public/app-cards.js +200 -0
  19. package/board/public/app-core.js +96 -0
  20. package/board/public/app-header.js +108 -0
  21. package/board/public/app-lanes.js +92 -0
  22. package/board/public/app-needs.js +30 -0
  23. package/board/public/app-snapshot.js +97 -0
  24. package/board/public/app-threads.js +165 -0
  25. package/board/public/app.html +83 -0
  26. package/board/public/app.js +327 -0
  27. package/board/public/attach.js +84 -0
  28. package/board/public/dialogs.js +360 -0
  29. package/board/public/hub-kit.js +151 -0
  30. package/board/public/hub.css +164 -0
  31. package/board/public/kickflip.png +0 -0
  32. package/board/public/prompts.js +64 -0
  33. package/board/public/prototype-kit.js +193 -0
  34. package/board/public/prototype-switcher.js +226 -0
  35. package/board/public/prototype.html +55 -0
  36. package/board/public/router.js +223 -0
  37. package/board/public/rules-editor.js +127 -0
  38. package/board/public/shell.js +298 -0
  39. package/board/public/sidebar.css +199 -0
  40. package/board/public/sidebar.js +287 -0
  41. package/board/public/style.css +570 -0
  42. package/board/public/toasts.js +179 -0
  43. package/board/public/ui-cards.js +153 -0
  44. package/board/public/ui-enums.js +10 -0
  45. package/board/public/ui-header.js +84 -0
  46. package/board/public/ui-hub.js +94 -0
  47. package/board/public/ui-links.js +142 -0
  48. package/board/public/ui-markdown.js +80 -0
  49. package/board/public/ui-page-header.js +63 -0
  50. package/board/public/ui-prompts.js +39 -0
  51. package/board/public/ui-questions.js +130 -0
  52. package/board/public/ui-sidebar.js +177 -0
  53. package/board/public/ui-snapshot.js +56 -0
  54. package/board/public/ui-threads.js +81 -0
  55. package/board/public/ui.js +235 -0
  56. package/board/public/view-frame.js +93 -0
  57. package/board/public/view-hub-settings.js +266 -0
  58. package/board/public/view-hub.js +99 -0
  59. package/board/public/view-notes.js +29 -0
  60. package/board/public/view-polling.js +87 -0
  61. package/board/public/view-questions.js +391 -0
  62. package/board/public/view-settings.js +328 -0
  63. package/board/server.mjs +12 -0
  64. package/board/store.mjs +7 -0
  65. package/board/tickets.mjs +3 -0
  66. package/board/worktree.mjs +14 -0
  67. package/docs/hub-session.md +11 -0
  68. package/hooks-plugin/.claude-plugin/plugin.json +5 -0
  69. package/hooks-plugin/hooks/codex-share-auto.mjs +21 -0
  70. package/hooks-plugin/hooks/hooks.json +54 -0
  71. package/hooks-plugin/hooks/usage-gate.mjs +18 -0
  72. package/hub/board-repo.mjs +1 -0
  73. package/hub/boards.mjs +4 -0
  74. package/hub/close-board.mjs +1 -0
  75. package/hub/files.mjs +1 -0
  76. package/hub/github.mjs +5 -0
  77. package/hub/home.mjs +1 -0
  78. package/hub/hub.mjs +17 -0
  79. package/hub/icon-180.png +0 -0
  80. package/hub/icon-192.png +0 -0
  81. package/hub/icon-512.png +0 -0
  82. package/hub/icons.mjs +1 -0
  83. package/hub/linear.mjs +2 -0
  84. package/hub/live.mjs +2 -0
  85. package/hub/locks.mjs +1 -0
  86. package/hub/new-board.mjs +2 -0
  87. package/hub/permission-hook.mjs +3 -0
  88. package/hub/presence.mjs +1 -0
  89. package/hub/prompts.mjs +2 -0
  90. package/hub/push.mjs +2 -0
  91. package/hub/repos.mjs +2 -0
  92. package/hub/routes.mjs +1 -0
  93. package/hub/rules.mjs +1 -0
  94. package/hub/schedule.mjs +1 -0
  95. package/hub/sessions.mjs +2 -0
  96. package/hub/shell.mjs +1 -0
  97. package/hub/stream.mjs +19 -0
  98. package/hub/sw.js +144 -0
  99. package/hub/try.mjs +3 -0
  100. package/hub/usage.mjs +3 -0
  101. package/hub/views.mjs +1 -0
  102. package/package.json +26 -4
  103. package/shared/codex.mjs +1 -0
  104. package/shared/colors.css +13 -0
  105. package/shared/repos.mjs +1 -0
  106. package/shared/service.mjs +33 -0
  107. package/shared/usage-gate.mjs +1 -0
  108. package/starter/cross-site.mjs +1 -0
  109. package/starter/public/claude.js +67 -0
  110. package/starter/public/index.html +49 -0
  111. package/starter/server.mjs +4 -0
@@ -0,0 +1,298 @@
1
+ // The app shell (app.html; docs/adr/0055-one-app-for-the-whole-hub.md): the sidebar, mounted once, beside one view at a
2
+ // time. A board's view is BoardView (app.js); Waiting on you and Notes are AppViews.questions and .notes,
3
+ // the Hub page AppViews.hub with its settings modals, and every other page AppViews.frame. Board Settings mounts over the board
4
+ // as a modal. Each page is {className, title(view, {siteTitle}), markup(view, {siteTitle}), create(root,
5
+ // siteRoot, ctx)}; a hub page's siteRoot is /_hub. Following a link to any of them, the sidebar's included, swaps the
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
+ // [data-dirty], an unsaved edit, asks first. The shell holds the hub's one stream, /_hub/stream
8
+ // (hub/stream.mjs): `change {route}` signals the open board, and `sidebar` repaints the sidebar. A board the shell
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/0028-the-board-opens-on-the-last-visit.md).
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
+ // AppShell.open(url, {replace, frames}) → true when the shell shows it in place; frames false refuses a frame view.
13
+ window.AppShell = (function () {
14
+ const LAST_KEY = 'hub:lastBoard';
15
+ const DATA_KEY = 'hub:sidebar';
16
+ const read = (k) => { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch { return null; } };
17
+ let opener = null;
18
+ const open = (url, opts) => Boolean(opener?.(url, opts));
19
+ let bootCode = null;
20
+ let updateReady = false;
21
+ let sidebar = null;
22
+ function codeSeen(hash) {
23
+ if (!hash) return;
24
+ if (!bootCode) { bootCode = hash; return; }
25
+ if (hash !== bootCode && !updateReady) { updateReady = true; sidebar?.setUpdate('ready'); }
26
+ }
27
+
28
+ async function reload() {
29
+ const controller = navigator.serviceWorker?.controller;
30
+ if (controller) await new Promise((resolve) => {
31
+ const channel = new MessageChannel();
32
+ const done = () => { clearTimeout(timer); channel.port1.close(); channel.port2.close(); resolve(); };
33
+ const timer = setTimeout(done, 1000);
34
+ channel.port1.onmessage = done;
35
+ try { controller.postMessage({ forget: 'shell' }, [channel.port2]); } catch { done(); }
36
+ });
37
+ setTimeout(() => location.reload(), 0);
38
+ }
39
+
40
+ // The app shell around this page when it is framed in one.
41
+ function outer() {
42
+ try { return window.top !== window && typeof window.top.AppShell?.open === 'function' ? window.top.AppShell : null; } catch { return null; }
43
+ }
44
+
45
+ async function start() {
46
+ // 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.
47
+ if (outer()?.open(location.href, { frames: false })) return;
48
+ // The same URL and scope as hub-kit.js's push registration, so the one registration and its push subscription stay.
49
+ navigator.serviceWorker?.register('/sw.js', { scope: '/' }).catch(() => {});
50
+ let data = null;
51
+ try { data = JSON.parse(document.getElementById('sidebarData')?.textContent || 'null'); } catch { data = null; }
52
+ // The board server's copy of the shell, for a load the hub forwards, carries no sidebar data.
53
+ if (!data) {
54
+ try { const r = await fetch('/_hub/sidebar.json', { cache: 'no-store' }); if (r.ok) data = await r.json(); } catch { /* the cached copy */ }
55
+ }
56
+ // A stored sidebar's code says nothing about the shell that booted, so it adopts the next live hash.
57
+ bootCode = data?.code || null;
58
+ data ||= read(DATA_KEY);
59
+ sidebar = Sidebar.mount({ data, poll: false, reload, open: (href) => { if (!open(href)) location.assign(href); } });
60
+ const boards = () => new Set((data?.sites || []).filter((s) => s.board).map((s) => s.route));
61
+ const titleOf = (route) => data?.sites?.find((s) => s.route === route)?.title || route;
62
+
63
+ // route → the views' change listeners; a view subscribes through BoardView.create's signal.
64
+ const listeners = new Map();
65
+ const signalFor = (route) => (fn) => {
66
+ if (!listeners.has(route)) listeners.set(route, new Set());
67
+ listeners.get(route).add(fn);
68
+ return () => listeners.get(route)?.delete(fn);
69
+ };
70
+ // route → {fresh, generatedAt} for each board left fresh with no change since. Trusted only while the stream is up:
71
+ // a dropped stream may have missed a change, so its return forgets them all.
72
+ const left = new Map();
73
+ let streamUp = false;
74
+ let current = null;
75
+ function changed(route) {
76
+ left.delete(route);
77
+ for (const fn of listeners.get(route) || []) fn();
78
+ }
79
+
80
+ let es = null;
81
+ let opened = false;
82
+ let tries = 0;
83
+ let retry = null;
84
+ // An EventSource retries a dropped stream by itself, but gives up on an answer that isn't a stream, such as while
85
+ // the hub restarts, so a closed one reopens here with a growing backoff.
86
+ function openStream() {
87
+ if (!('EventSource' in window) || (es && es.readyState !== EventSource.CLOSED)) return;
88
+ clearTimeout(retry);
89
+ const cur = es = new EventSource('/_hub/stream');
90
+ cur.addEventListener('open', () => {
91
+ tries = 0;
92
+ streamUp = true;
93
+ if (opened) { left.clear(); if (current) changed(current.route); }
94
+ opened = true;
95
+ });
96
+ cur.addEventListener('change', (e) => {
97
+ let d = null;
98
+ try { d = JSON.parse(e.data); } catch { return; }
99
+ if (typeof d?.route === 'string') changed(d.route);
100
+ });
101
+ cur.addEventListener('sidebar', (e) => {
102
+ try { data = JSON.parse(e.data); } catch { return; }
103
+ codeSeen(data.code);
104
+ sidebar.setData(data);
105
+ current?.v?.B?.renderStatus?.();
106
+ });
107
+ cur.addEventListener('error', () => {
108
+ streamUp = false;
109
+ if (cur.readyState !== EventSource.CLOSED || es !== cur) return;
110
+ retry = setTimeout(openStream, Math.min(30000, 1000 * 2 ** tries++));
111
+ });
112
+ }
113
+
114
+ // A view that changed what the sidebar's foot rows say, such as notifications or polling, asks for a fresh copy
115
+ // rather than waiting for the stream's next one.
116
+ async function refreshSidebar() {
117
+ sidebar.checkDevice();
118
+ try {
119
+ const r = await fetch('/_hub/sidebar.json', { cache: 'no-store' });
120
+ if (r.ok) { data = await r.json(); codeSeen(data.code); sidebar.setData(data); current?.v?.B?.renderStatus?.(); }
121
+ } catch { /* the stream brings the next copy */ }
122
+ }
123
+
124
+ const scrim = document.getElementById('sidebarScrim');
125
+ let settings = null;
126
+ function removeSettings() {
127
+ if (!settings) return;
128
+ settings.v.unmount();
129
+ settings.root.remove();
130
+ settings = null;
131
+ }
132
+ function dismissSettings() {
133
+ const closing = settings;
134
+ const finish = () => {
135
+ if (settings !== closing) return;
136
+ removeSettings();
137
+ router.replace(AppRouter.urlOf(current.view));
138
+ };
139
+ if (!settings?.root.querySelector('[data-dirty]')) { finish(); return; }
140
+ BoardDialogs.confirm('Leave without saving? Your unsaved edits here will be lost.').then((leave) => { if (leave) finish(); });
141
+ }
142
+ function showSettings() {
143
+ if (settings) return;
144
+ const root = document.createElement('div');
145
+ root.className = 'view-settings';
146
+ root.innerHTML = AppViews.settings.markup({}, { siteTitle: titleOf(current.route) });
147
+ current.root.appendChild(root);
148
+ const v = AppViews.settings.create(root, '/' + encodeURIComponent(current.route), { dismiss: dismissSettings, open: openHere, sidebarChanged: refreshSidebar, siteTitle: titleOf(current.route) });
149
+ settings = { root, v };
150
+ v.mount();
151
+ }
152
+
153
+ // current: {view, route, root, v}, the router's view shown, its root element and its mounted {B, mount, unmount}.
154
+ function show(view) {
155
+ const modal = view.kind === 'settings';
156
+ if (modal) view = { kind: 'board', route: view.route, hash: '' };
157
+ removeSettings();
158
+ const { route } = view;
159
+ const board = view.kind === 'board';
160
+ const hub = route === undefined;
161
+ const siteTitle = hub ? 'Hub' : titleOf(route);
162
+ if (current) {
163
+ const st = current.v.B.state;
164
+ const keep = streamUp && st?.view === 'fresh' && st.board ? { fresh: st.fresh, generatedAt: st.board.generatedAt } : null;
165
+ current.v.unmount();
166
+ if (keep) left.set(current.route, keep);
167
+ }
168
+ const page = board ? null : AppViews[view.kind];
169
+ const root = document.createElement('div');
170
+ root.className = board ? 'view-board' : page.className;
171
+ root.innerHTML = board ? BoardView.markup({ title: siteTitle }) : page.markup(view, { siteTitle });
172
+ if (current) current.root.replaceWith(root); else scrim.after(root);
173
+ const siteRoot = hub ? '/_hub' : '/' + encodeURIComponent(route);
174
+ let v;
175
+ if (board) {
176
+ const live = streamUp ? left.get(route) || null : null;
177
+ left.delete(route);
178
+ v = BoardView.create(root, siteRoot, { signal: signalFor(route), live, pause: () => data?.pause });
179
+ } else {
180
+ v = page.create(root, siteRoot, { view, siteTitle, boards, open: openHere, toggleSidebar: sidebar.toggle, keydown: sidebar.keydown, pointerdown: sidebar.pointerdown,
181
+ reload: () => go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'reload'), closeSidebar: sidebar.close, up: () => router.up(),
182
+ loaded: (url, title) => { if (current?.v === v) framed(url, title); }, sidebarChanged: refreshSidebar });
183
+ }
184
+ current = { view, route, root, v };
185
+ document.title = board ? siteTitle : page.title(view, { siteTitle });
186
+ if (boards().has(route)) { try { localStorage.setItem(LAST_KEY, route); } catch { /* no storage: `/` opens the start board */ } }
187
+ sidebar.setHere(hub ? new URL(AppRouter.urlOf(view), location.href).pathname.slice(1) : route);
188
+ scrollTo(0, 0);
189
+ v.mount();
190
+ if (modal) showSettings();
191
+ }
192
+
193
+ // A framed page loaded url in the frame: the top's URL and title follow it, without a history entry of their own,
194
+ // since the frame's navigation made one. Any other view shows in the top instead.
195
+ function framed(url, title) {
196
+ const { href } = AppRouter.entryOf(url);
197
+ const view = AppRouter.viewOf(href, { boards: boards() });
198
+ if (view && view.kind !== 'frame') { if (!opener(href, { replace: true })) location.replace(href); return; }
199
+ document.title = title;
200
+ if (!view) return;
201
+ history.replaceState(history.state, '', href);
202
+ current.view = view;
203
+ if (view.route !== current.route) { current.route = view.route; sidebar.setHere(view.route); }
204
+ }
205
+ const openHere = (href) => { if (!opener(href)) location.assign(href); };
206
+
207
+ // The board and Waiting on you follow their own hash, so a link that only moves it keeps the view.
208
+ const sameView = (a, b) => a.kind === b.kind && a.route === b.route && (a.kind !== 'notes' || a.item === b.item) && (a.kind !== 'frame' || a.url === b.url);
209
+ const dirty = () => Boolean(current?.root.querySelector('[data-dirty]'));
210
+ addEventListener('beforeunload', (e) => { if (dirty()) { e.preventDefault(); e.returnValue = ''; } });
211
+
212
+ // go(view, href, how, from): router.js's, with how 'replace' for a framed page's hand-off and 'reload' for a view's
213
+ // own refresh. → true, or a promise of whether the view switched once the owner answers about an unsaved edit.
214
+ let router = null;
215
+ function go(view, href, how, from) {
216
+ if (!AppRouter.mounted(view)) return false;
217
+ if (view.kind === 'settings') {
218
+ if (current?.view.kind === 'board' && current.route === view.route) {
219
+ const here = AppRouter.viewOf(location.href, { boards: boards() });
220
+ if (here?.kind === 'board') current.view = here;
221
+ router.replace(href);
222
+ showSettings();
223
+ sidebar.close();
224
+ return true;
225
+ }
226
+ const board = { kind: 'board', route: view.route, hash: '' };
227
+ return Promise.resolve(go(board, AppRouter.urlOf(board), how, from)).then((shown) => {
228
+ if (!shown) return false;
229
+ router.replace(href);
230
+ showSettings();
231
+ return true;
232
+ });
233
+ }
234
+ if (view.kind === 'hub' && current?.view.kind === 'hub' && how !== 'reload') {
235
+ return Promise.resolve(current.v.B.openSettings(view)).then((shown) => {
236
+ if (shown) { current.view = view; sidebar.close(); }
237
+ return shown;
238
+ });
239
+ }
240
+ const stays = how !== 'reload' && current && sameView(current.view, view);
241
+ const finish = () => {
242
+ const moved = how === 'push' ? router.push(href, from) : how === 'replace' ? router.replace(href) : null;
243
+ if (moved === 'up') { sidebar.close(); return true; }
244
+ if (!stays) show(view);
245
+ else {
246
+ current.view = view;
247
+ if (how !== 'pop') dispatchEvent(new HashChangeEvent('hashchange'));
248
+ }
249
+ sidebar.close();
250
+ return true;
251
+ };
252
+ if ((stays && !settings) || !dirty()) { if (stays && settings) removeSettings(); return finish(); }
253
+ return BoardDialogs.confirm('Leave without saving? Your unsaved edits here will be lost.').then((leave) => (leave ? (removeSettings(), finish()) : false));
254
+ }
255
+
256
+ // `/` opens this device's last board, else the board the hub picks (sidebar data's start), else the hub's page
257
+ // (docs/adr/0050-a-sidebar-of-every-board-replaces-the-hub-home.md).
258
+ let first = AppRouter.viewOf(location.href, { boards: boards() });
259
+ if (first?.kind === 'start') {
260
+ let last = null;
261
+ try { last = localStorage.getItem(LAST_KEY); } catch {}
262
+ const pick = boards().has(last) ? last : data?.start || null;
263
+ history.replaceState(history.state, '', boards().has(pick) ? `/${encodeURIComponent(pick)}/` : '/_hub/');
264
+ first = AppRouter.viewOf(location.href, { boards: boards() });
265
+ }
266
+ if (!AppRouter.mounted(first)) { location.replace('/_hub/'); return; }
267
+
268
+ router = AppRouter.listen({ boards, go });
269
+ opener = (url, { replace = false, frames = true } = {}) => {
270
+ const view = AppRouter.viewOf(url, { boards: boards() });
271
+ if (!AppRouter.mounted(view) || (!frames && view.kind === 'frame')) return false;
272
+ go(view, new URL(url, location.href).href, replace ? 'replace' : 'push');
273
+ return true;
274
+ };
275
+ // Answered at once, since the switch may wait on the owner's answer about an unsaved edit.
276
+ navigator.serviceWorker?.addEventListener('message', (e) => {
277
+ const url = e.data?.open;
278
+ if (typeof url !== 'string') return;
279
+ e.ports?.[0]?.postMessage({ routed: open(url) });
280
+ });
281
+ navigator.serviceWorker?.startMessages?.();
282
+
283
+ show(first);
284
+ openStream();
285
+ addEventListener('visibilitychange', () => { if (!document.hidden) openStream(); });
286
+ addEventListener('pageshow', openStream);
287
+ // A page kept in the back-forward cache keeps its stream's connection, and a few such pages take every connection
288
+ // the browser allows the hub, so the next page's requests wait.
289
+ addEventListener('pagehide', (e) => {
290
+ if (!e.persisted || !es) return;
291
+ clearTimeout(retry);
292
+ streamUp = false;
293
+ es.close();
294
+ });
295
+ }
296
+
297
+ return { start, open, codeSeen };
298
+ })();
@@ -0,0 +1,199 @@
1
+ /* The sidebar (ui-sidebar.js, sidebar.js): a rail from 900px, which collapses out of sight, and a drawer below it.
2
+ The app shell and the hub's pages link it after colors.css; the hub serves it as /_hub/sidebar.css. Every rule is
3
+ scoped to .sidebar, its scrim, the page classes sidebar.js sets or the shared New board dialog, since each page's own CSS uses the same short class names. */
4
+ .sidebar { position: fixed; z-index: 20; top: 0; bottom: 0; left: 0; width: min(86vw, 320px); display: flex; flex-direction: column;
5
+ padding: 0 8px 0 max(8px, env(safe-area-inset-left));
6
+ background: var(--panel); border-right: 1px solid var(--line); overflow: hidden; overscroll-behavior: contain; touch-action: pan-y;
7
+ transform: translateX(-102%); visibility: hidden; transition: transform .24s cubic-bezier(.2,.8,.2,1), visibility 0s linear .24s;
8
+ font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif; color: var(--text); }
9
+ .sidebar.open { transform: none; visibility: visible; transition: transform .24s cubic-bezier(.2,.8,.2,1); }
10
+ /* Safari tints its toolbars with the background of fixed elements at the screen's edges, even a hidden or opacity: 0
11
+ one, so a closed scrim has no background colour, or the bars and the strip below a bottom sheet turn grey. */
12
+ .sidebar-scrim { position: fixed; inset: 0; z-index: 19; background: transparent; pointer-events: none; transition: background-color .2s; }
13
+ .sidebar-scrim.on { background: rgba(0,0,0,.4); pointer-events: auto; }
14
+ @media (prefers-reduced-motion: reduce) { .sidebar, .sidebar.open, .sidebar-scrim { transition: none; } }
15
+
16
+ /* The head is the page header itself (ui-page-header.js), edge to edge across the sidebar and pinned while the list
17
+ scrolls, so its logo and title sit where the page header's do. Its ✕ mirrors the logo's 44px box on the right. */
18
+ .sidebar > .top { flex: none; margin: 0 -8px 0 calc(-1 * max(8px, env(safe-area-inset-left))); min-height: 53px; background: var(--panel); }
19
+ .sidebar .sb-close { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; margin: 0 -10px 0 0; padding: 0; border: 0; border-radius: 10px; background: none; color: var(--muted); cursor: pointer; }
20
+ .sidebar .sb-close:active { background: var(--chip); }
21
+ @media (hover: hover) { .sidebar .sb-close:hover { background: var(--chip); color: var(--text); } }
22
+ .sidebar .sb-label { margin: 12px 8px 4px; font-size: 11.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
23
+ .sidebar .sb-group { display: flex; flex-direction: column; gap: 2px; }
24
+ .sidebar .sb-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding-bottom: max(12px, env(safe-area-inset-bottom)); display: flex; flex-direction: column; }
25
+ .sidebar .sb-scroll > section { flex: none; }
26
+ .sidebar .sb-utility { padding-top: 6px; }
27
+ .sidebar .sb-boards { margin-top: auto; }
28
+ .sidebar .sb-empty { margin: 4px 8px; color: var(--muted); font-size: 12.5px; }
29
+ .sidebar .sb-row { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 6px 8px; border-radius: 10px; color: var(--text); text-decoration: none; }
30
+ .sidebar .sb-row:active { background: var(--chip); }
31
+ @media (hover: hover) { .sidebar .sb-row:hover { background: color-mix(in srgb, var(--chip) 70%, transparent); text-decoration: none; } }
32
+ .sidebar .sb-row.on { background: var(--accent-soft); }
33
+ .sidebar .sb-turnon { margin: 6px 0 0; background: color-mix(in srgb, var(--color-danger) 10%, var(--panel)); border: 1px solid color-mix(in srgb, var(--color-danger) 30%, var(--line)); }
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
+ 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
+ .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: #fff; font-size: 13px; font-weight: 600; }
38
+ .sidebar .sb-update-row:disabled .sb-go { background: transparent; color: var(--muted); padding-right: 0; font-weight: 400; }
39
+ html.sb-update body.has-sidebar > .top .hub, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
40
+ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub::after {
41
+ 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); }
42
+ .sidebar .sb-text { flex: 1; min-width: 0; }
43
+ .sidebar .sb-title { display: block; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
44
+ .sidebar .sb-moon { display: inline-block; margin-left: 5px; color: var(--muted); vertical-align: -1px; }
45
+ .sidebar .sb-moon svg { display: block; }
46
+ @media (hover: none) { .sidebar .sb-moon { pointer-events: none; } }
47
+ .sidebar .sb-sub { display: block; font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
48
+ .sidebar .sb-new { width: 100%; text-align: left; border: 0; background: transparent; font: inherit; }
49
+ .sidebar .sb-starting { color: var(--muted); }
50
+ .sidebar .sb-starting:hover, .sidebar .sb-starting:active { background: transparent; }
51
+ .sidebar .sb-dismiss { flex: none; min-height: 44px; font-size: 12px; }
52
+ .sidebar .sb-row .icon.sb-plus { color: var(--muted); background: var(--chip); }
53
+
54
+ #newBoardSheet .nb-form { min-width: 0; }
55
+ #newBoardSheet .sheet-head { margin-bottom: 8px; }
56
+ #newBoardSheet .sheet-head h2 { font-size: 17px; }
57
+ #newBoardSheet .nb-field { display: block; padding: 8px 0; margin: 0; }
58
+ #newBoardSheet .nb-field > span:first-child { display: block; margin-bottom: 6px; font-weight: 600; font-size: 13px; }
59
+ #newBoardSheet :is(input, select) { display: block; width: 100%; min-width: 0; min-height: 44px; padding: 8px 10px;
60
+ color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 8px; font: inherit; font-size: 16px; text-align: left; box-sizing: border-box; }
61
+ #newBoardSheet select { text-overflow: ellipsis; }
62
+ #newBoardSheet :is(input, select):focus { outline: 2px solid var(--accent-soft); border-color: var(--accent); }
63
+ #newBoardSheet .sheet-head { display: flex; align-items: center; justify-content: space-between; }
64
+ #newBoardSheet .nb-repo-list { display: grid; gap: 8px; }
65
+ #newBoardSheet .nb-repo { display: block; width: 100%; text-align: left; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--text); }
66
+ #newBoardSheet .nb-repo:hover, #newBoardSheet .nb-repo:focus-visible { border-color: var(--accent); }
67
+ #newBoardSheet .nb-repo strong { overflow-wrap: anywhere; }
68
+ #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(--accent-soft); color: var(--accent); font-size: 11px; font-weight: 600; vertical-align: 1px; white-space: nowrap; }
70
+ #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
+ #newBoardSheet .nb-box > span { min-width: 0; }
72
+ #newBoardSheet .nb-box strong { display: block; overflow-wrap: anywhere; }
73
+ #newBoardSheet .nb-box-no { border-color: color-mix(in srgb, var(--color-danger) 40%, var(--line)); }
74
+ #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(--accent); border-radius: 50%; animation: nb-spin .8s linear infinite; }
76
+ @keyframes nb-spin { to { transform: rotate(360deg); } }
77
+ #newBoardSheet .nb-local { display: block; margin-top: 8px; font-size: 12px; color: var(--accent); }
78
+ #newBoardSheet .nb-selected { padding: 12px; border: 1px solid var(--line); border-radius: 10px; }
79
+ #newBoardSheet .nb-clone { display: block; margin-top: 6px; overflow-wrap: anywhere; font-size: 12px; }
80
+ #newBoardSheet [hidden] { display: none; }
81
+ #newBoardSheet .nb-help { display: block; margin-top: 5px; color: var(--muted); font-size: 12px; font-weight: 400; }
82
+ #newBoardSheet .nb-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 6px; padding-bottom: 2px; }
83
+ #newBoardSheet .nb-actions button { min-height: 44px; }
84
+ #newBoardSheet .nb-next { font-size: 12px; color: var(--muted); margin: 0 0 10px; }
85
+ #newBoardSheet .nb-error { color: var(--color-danger); font-size: 13px; }
86
+ @media (min-width: 900px) {
87
+ #newBoardSheet { inset: 0; margin: auto; height: fit-content; max-height: calc(100dvh - 48px); }
88
+ }
89
+
90
+ /* The site tiles hub/icons.mjs siteIcon draws, and the hub rows' solid tiles. */
91
+ .sidebar .icon { flex: none; width: 32px; height: 32px; border-radius: 9px; display: grid; place-items: center; color: var(--c);
92
+ background: color-mix(in srgb, var(--c) 18%, transparent); font-weight: 700; font-size: 15px; line-height: 1; }
93
+ .sidebar .icon.sm { width: 32px; height: 32px; border-radius: 8px; }
94
+ .sidebar .icon.solid { color: #fff; background: var(--c); }
95
+ @media (prefers-color-scheme: dark) { .sidebar .icon:not(.solid) { color: color-mix(in srgb, var(--c) 70%, #fff); } }
96
+ .sidebar .icon svg { display: block; }
97
+
98
+ /* Switch mode (sidebar.js): while .ks-on, each row's key in its tile in place of its glyph, the title's letter in
99
+ builder orange, and the Boards label's hint with a bar that drains over the 3 seconds switch mode waits for a key.
100
+ Its marks stay in the markup until the next paint, so they fade out on toasts.js timing; @starting-style fades them in.
101
+ Nothing in the hint takes space, so no row moves when switch mode starts or ends. */
102
+ .sidebar .ks-key { display: none; }
103
+ .sidebar.ks-on .icon:has(.ks-key) { font-size: 0; }
104
+ .sidebar.ks-on .icon:has(.ks-key) > svg { display: none; }
105
+ .sidebar.ks-on .ks-key { display: block; font: 700 15px/1 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; }
106
+ .sidebar .ks-label { position: relative; display: flex; align-items: baseline; gap: .45em; white-space: nowrap; }
107
+ .sidebar .ks-label-hint { font-weight: 400; letter-spacing: 0; text-transform: none;
108
+ opacity: 0; visibility: hidden; transition: opacity 200ms linear, visibility 0s linear 200ms; }
109
+ .sidebar.ks-on .ks-label-hint { opacity: 1; visibility: visible; transition: opacity 150ms linear; }
110
+ @starting-style { .sidebar.ks-on .ks-label-hint { opacity: 0; } }
111
+ /* 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
+ 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(--color-notice);
114
+ transform-origin: 0 50%; animation: ks-drain 3s linear forwards; }
115
+ @keyframes ks-drain { to { transform: scaleX(0); } }
116
+ .sidebar .ks-u { transition: color 200ms linear; }
117
+ .sidebar.ks-on .ks-u { color: var(--color-notice); transition: color 150ms linear; }
118
+ @starting-style { .sidebar.ks-on .ks-u { color: inherit; } }
119
+ @media (prefers-reduced-motion: reduce) {
120
+ .sidebar .ks-bar { animation: none; }
121
+ .sidebar .ks-label-hint { transition: opacity 100ms linear, visibility 0s linear 100ms; }
122
+ .sidebar.ks-on .ks-label-hint { transition: opacity 80ms linear; }
123
+ .sidebar .ks-u { transition: color 100ms linear; }
124
+ .sidebar.ks-on .ks-u { transition: color 80ms linear; }
125
+ }
126
+
127
+ /* A site's status (hub/home.mjs statusLine): the lane chips wrap on their own line, and the status word goes below them. */
128
+ .sidebar .sb-status { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; min-width: 0; }
129
+ .sidebar .lcs { order: -1; flex: 1 1 100%; min-width: 0; display: flex; flex-wrap: wrap; gap: 4px; }
130
+ .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
+ .sidebar .lc-review { --tone: var(--accent); } .sidebar .lc-progress { --tone: var(--color-notice); } .sidebar .lc-todo { --tone: var(--muted); }
132
+ .sidebar .dot { flex: none; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--tone); }
133
+ @keyframes sb-pulse { 50% { opacity: .35; } }
134
+ @media (prefers-reduced-motion: no-preference) { .sidebar .dot.pulse { animation: sb-pulse 1.4s ease-in-out infinite; } }
135
+ .sidebar .word { flex: none; font-weight: 600; color: var(--tone); }
136
+ .sidebar .t-down, .sidebar .t-needs { --tone: var(--color-danger); }
137
+ .sidebar .t-starting, .sidebar .t-asleep { --tone: var(--muted); }
138
+ .sidebar .t-working { --tone: var(--color-notice); }
139
+ .sidebar .t-building { --tone: var(--color-notice); }
140
+ .sidebar .t-ok { --tone: var(--color-success); }
141
+ .sidebar .t-wait { --tone: var(--color-notice); }
142
+
143
+ @media (min-width: 900px) {
144
+ body.has-sidebar { padding-left: 272px; }
145
+ body.has-sidebar .sidebar { width: 272px; transform: none; visibility: visible; transition: none; z-index: 4; }
146
+ body.has-sidebar .sidebar .sb-close { display: none; }
147
+ body.has-sidebar .sidebar-scrim { display: none; }
148
+ html:not(.sb-collapsed) body.has-sidebar > .top .hub, html:not(.sb-collapsed) body.has-sidebar > :not(.sidebar) > .top .hub { display: none; }
149
+ html.sb-collapsed body.has-sidebar { padding-left: 0; }
150
+ html.sb-collapsed body.has-sidebar .sidebar { transform: translateX(-102%); visibility: hidden; }
151
+ /* Switch mode shows a collapsed rail over the page until it ends, and leaves it collapsed. */
152
+ html.sb-collapsed body.has-sidebar .sidebar.ks-on { transform: none; visibility: visible; z-index: 20; }
153
+ html.sb-anim body.has-sidebar { transition: padding-left .24s cubic-bezier(.2,.8,.2,1); }
154
+ html.sb-anim body.has-sidebar .sidebar { transition: transform .24s cubic-bezier(.2,.8,.2,1), visibility 0s linear .24s; }
155
+ html.sb-anim:not(.sb-collapsed) body.has-sidebar .sidebar { transition: transform .24s cubic-bezier(.2,.8,.2,1); }
156
+ }
157
+ @media (min-width: 900px) and (prefers-reduced-motion: reduce) { html.sb-anim body.has-sidebar, html.sb-anim body.has-sidebar .sidebar { transition: none; } }
158
+
159
+ @media (max-width: 899px) {
160
+ .sidebar > .top { position: static; order: 2; margin-bottom: 0; border-bottom: 0; border-top: 1px solid var(--line);
161
+ padding-top: 4px; padding-bottom: max(4px, env(safe-area-inset-bottom)); }
162
+ .sidebar .sb-scroll { padding-top: max(8px, env(safe-area-inset-top)); padding-bottom: 6px; }
163
+ }
164
+
165
+ .sidebar .u-tone-ok { --u: var(--accent); }
166
+ .sidebar .u-tone-high { --u: var(--color-notice); }
167
+ .sidebar .u-tone-full { --u: var(--color-danger); }
168
+ .sidebar .u-tone-stale, .sidebar .u-tone-none { --u: var(--muted); }
169
+ .sidebar .u-bar { display: block; height: 4px; border-radius: 2px; background: var(--chip); overflow: hidden; }
170
+ .sidebar .u-bar > i { display: block; height: 100%; border-radius: inherit; background: var(--u); }
171
+ .sidebar .u-tone-stale .u-bar, .sidebar .u-tone-none .u-bar { background: repeating-linear-gradient(135deg, var(--chip) 0 4px, color-mix(in srgb, var(--muted) 22%, var(--chip)) 4px 6px); }
172
+ .sidebar .u-num { color: var(--u); font-weight: 600; font-variant-numeric: tabular-nums; }
173
+ .sidebar .u-tone-ok .u-num { color: inherit; font-weight: 400; }
174
+
175
+ .sidebar .sb-row.sb-hub-usage { align-items: flex-start; padding-top: 8px; padding-bottom: 10px; }
176
+ .sidebar .sb-hub-usage > .icon { margin-top: 2px; }
177
+ .sidebar .sb-hub-usage:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
178
+ .sidebar .sb-usage { display: flex; flex-direction: column; gap: 7px; margin-top: 8px; color: var(--muted); font-size: 12px; line-height: 1.3; }
179
+ .sidebar .sb-usage .u-one { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
180
+ .sidebar .sb-usage .u-line { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
181
+ .sidebar .sb-usage .u-name { color: var(--text); font-weight: 600; }
182
+
183
+ /* The shortcuts sheet (sidebar.js, h h): centred near the top of the window over the dimmed page, never on a phone. */
184
+ .sheet.shortcuts-sheet { inset: 12vh 0 auto; max-width: 440px; max-height: 76vh; padding: 0; border: 1px solid var(--line); border-radius: 14px;
185
+ box-shadow: 0 10px 30px rgba(0,0,0,.18); }
186
+ .sheet.shortcuts-sheet[open] { animation: fade-in .16s ease-out; }
187
+ .shortcuts-sheet .sheet-body { padding: 14px 18px; }
188
+ .shortcuts-sheet .sheet-head h2 { font-size: 16px; }
189
+ .shortcuts-sheet .sc-group { margin-top: 12px; }
190
+ .shortcuts-sheet .sc-group h3 { margin: 0 0 4px; font-size: 11.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
191
+ .shortcuts-sheet .sc-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 34px; padding: 3px 0; }
192
+ .shortcuts-sheet .sc-what { min-width: 0; }
193
+ .shortcuts-sheet .sc-sub { display: block; font-size: 12px; color: var(--muted); }
194
+ .shortcuts-sheet .sc-keys { flex: none; display: inline-flex; align-items: center; gap: 4px; }
195
+ .shortcuts-sheet .sc-key { display: inline-flex; align-items: center; justify-content: center; min-width: 24px; height: 24px; padding: 0 6px; box-sizing: border-box;
196
+ border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 6px; background: var(--chip); color: var(--text);
197
+ font: 600 12.5px/1 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; }
198
+ .shortcuts-sheet .sc-foot { margin: 14px 0 0; padding-top: 10px; border-top: 1px solid var(--line); font-size: 12.5px; color: var(--muted); }
199
+ @media (prefers-reduced-motion: reduce) { .sheet.shortcuts-sheet[open] { animation: none; } }