@lovek4d/kickflip 0.6.0 → 0.7.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 +42 -26
- package/SKILL.md +16 -12
- package/bin/doctor.mjs +5 -4
- package/bin/event-lines.mjs +1 -0
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +7 -7
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +1 -1
- package/board/bin/board.mjs +70 -42
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -9
- package/board/onboarding.mjs +1 -1
- package/board/prototype-page.mjs +1 -1
- package/board/public/app-arch.js +2 -2
- package/board/public/app-banner.js +1 -1
- package/board/public/app-cards.js +36 -48
- package/board/public/app-lanes.js +6 -9
- package/board/public/app-needs.js +107 -26
- package/board/public/app-reader.js +52 -42
- package/board/public/app-threads.js +14 -10
- package/board/public/app.html +1 -1
- package/board/public/app.js +22 -12
- package/board/public/progress-review.js +4 -4
- package/board/public/prototype-kit.js +25 -11
- package/board/public/prototype-switcher.js +53 -33
- package/board/public/prototype.html +7 -4
- package/board/public/router.js +34 -6
- package/board/public/shell.js +23 -16
- package/board/public/sidebar.css +7 -2
- package/board/public/sidebar.js +3 -0
- package/board/public/style.css +69 -30
- package/board/public/ui-cards.js +61 -38
- package/board/public/ui-enums.js +12 -3
- package/board/public/ui-header.js +4 -6
- package/board/public/ui-hub.js +7 -4
- package/board/public/ui-links.js +1 -1
- package/board/public/ui-order.js +9 -6
- package/board/public/ui-page-header.js +1 -1
- package/board/public/ui-progress-bar.js +6 -7
- package/board/public/ui-questions.js +18 -3
- package/board/public/ui-sidebar.js +12 -5
- package/board/public/ui-snapshot.js +1 -1
- package/board/public/ui-threads.js +10 -12
- package/board/public/ui.js +7 -4
- package/board/public/view-hub.js +3 -1
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +19 -3
- package/board/public/view-settings.js +60 -28
- package/board/server.mjs +19 -19
- package/board/stop.mjs +1 -1
- package/board/store.mjs +27 -25
- package/board/tickets.mjs +1 -1
- package/board/transcript.mjs +4 -2
- package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
- package/hub/front-door.mjs +1 -1
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +6 -6
- package/hub/sessions.mjs +2 -2
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/package.json +1 -1
- package/shared/card-prs.mjs +1 -1
- package/shared/machine-gate.mjs +2 -2
- package/shared/tailscale.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
package/board/public/router.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ui.test.mjs runs them in node:vm. listen() catches clicks on links to a view the shell mounts, and back and forward.
|
|
4
4
|
// A view is {kind, …}:
|
|
5
5
|
// start `/`, which the shell turns into the last board
|
|
6
|
-
// board {route, hash} /<route>/, hash such as #item=<key>&reply
|
|
6
|
+
// board {route, hash} /<route>/, hash such as #selectedCard=<key>&openItemsCard=<topic id>, or the older #item=<key>&reply
|
|
7
7
|
// settings {route} /<route>/settings.html, a request to open the modal over that board
|
|
8
8
|
// questions {route, id} /<route>/questions.html#<id>
|
|
9
9
|
// notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<lineup slug>, and plan.html, which the board
|
|
@@ -45,6 +45,7 @@ globalThis.AppRouter = (function () {
|
|
|
45
45
|
if (page === 'questions.html') {
|
|
46
46
|
let id = null;
|
|
47
47
|
try { id = u.hash.length > 1 ? decodeURIComponent(u.hash.slice(1)) : null; } catch { id = u.hash.slice(1); }
|
|
48
|
+
if (id?.startsWith('openItemsCard=')) id = id.slice('openItemsCard='.length);
|
|
48
49
|
return { kind: 'questions', route, id };
|
|
49
50
|
}
|
|
50
51
|
if (page === 'notes.html' || page === 'plan.html') {
|
|
@@ -80,10 +81,10 @@ globalThis.AppRouter = (function () {
|
|
|
80
81
|
|
|
81
82
|
// The page tree (docs/adr/2026-10-30-1125-back-follows-the-page-tree.md): a board, its Waiting on you, and a prototype page tied
|
|
82
83
|
// to one of its questions by ?q=, framed. levelOf → 0, 1 or 2 down the tree, or null for a view outside it.
|
|
84
|
+
// A board's prototype page, which from 900px opens in the board's right pane (shell.js go).
|
|
85
|
+
const isPrototype = (view, boards) => view?.kind === 'frame' && boards.has(view.route) && /^\/[^/]+\/prototype-[\w-]+\.html$/.test(new URL(view.url, 'http://hub').pathname);
|
|
83
86
|
function questionOf(view, boards) {
|
|
84
|
-
|
|
85
|
-
const u = new URL(view.url, 'http://hub');
|
|
86
|
-
return /^\/[^/]+\/prototype-[\w-]+\.html$/.test(u.pathname) ? u.searchParams.get('q') || null : null;
|
|
87
|
+
return isPrototype(view, boards) ? new URL(view.url, 'http://hub').searchParams.get('q') || null : null;
|
|
87
88
|
}
|
|
88
89
|
// wide: from 900px Waiting on you is the board's pane, so a prototype's parent is the board itself.
|
|
89
90
|
function levelOf(view, { boards = new Set(), wide = false } = {}) {
|
|
@@ -96,7 +97,7 @@ globalThis.AppRouter = (function () {
|
|
|
96
97
|
function parentsOf(view, { boards = new Set(), card = null, wide = false } = {}) {
|
|
97
98
|
const q = questionOf(view, boards);
|
|
98
99
|
if (!q && view?.kind !== 'questions') return [];
|
|
99
|
-
const board = { kind: 'board', route: view.route, hash: card ? `#
|
|
100
|
+
const board = { kind: 'board', route: view.route, hash: card ? `#selectedCard=${encodeURIComponent(card)}` : '' };
|
|
100
101
|
return q && !wide ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -224,5 +225,32 @@ globalThis.AppRouter = (function () {
|
|
|
224
225
|
return { push: (href, from) => write('push', href, from), replace: (href) => write('replace', href), up };
|
|
225
226
|
}
|
|
226
227
|
|
|
227
|
-
|
|
228
|
+
// A board's fragment is &-joined keys: selectedCard=<key> (the focused card), openItemsCard=<topic id> (the selected
|
|
229
|
+
// open-items pill), review=<PR number>, and a bare reply. The older #item=<key> reads as selectedCard.
|
|
230
|
+
// → {selectedCard, openItemsCard, review, reply}, each null or false when absent.
|
|
231
|
+
function fragmentOf(hash) {
|
|
232
|
+
const out = { selectedCard: null, openItemsCard: null, review: null, reply: false };
|
|
233
|
+
const decode = (v) => { try { return decodeURIComponent(v); } catch { return v; } };
|
|
234
|
+
for (const part of String(hash).replace(/^#/, '').split('&')) {
|
|
235
|
+
const eq = part.indexOf('=');
|
|
236
|
+
const [k, v] = eq < 0 ? [part, ''] : [part.slice(0, eq), part.slice(eq + 1)];
|
|
237
|
+
if (k === 'selectedCard' || k === 'item') out.selectedCard = decode(v) || null;
|
|
238
|
+
else if (k === 'openItemsCard') out.openItemsCard = decode(v) || null;
|
|
239
|
+
else if (k === 'review' && /^\d+$/.test(v)) out.review = Number(v);
|
|
240
|
+
else if (k === 'reply') out.reply = true;
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
// hash with the named keys of changes replaced (null drops one) → the new fragment, '' for none. A #review= link is
|
|
245
|
+
// alone in its fragment, so a change to a card or pill replaces it; reply is spent once the card is shown.
|
|
246
|
+
function hashWith(hash, changes) {
|
|
247
|
+
const now = fragmentOf(hash);
|
|
248
|
+
const next = { selectedCard: now.selectedCard, openItemsCard: now.openItemsCard, ...changes };
|
|
249
|
+
const parts = [];
|
|
250
|
+
if (next.selectedCard) parts.push(`selectedCard=${encodeURIComponent(next.selectedCard)}`);
|
|
251
|
+
if (next.openItemsCard) parts.push(`openItemsCard=${encodeURIComponent(next.openItemsCard)}`);
|
|
252
|
+
return parts.length ? '#' + parts.join('&') : '';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { viewOf, urlOf, mounted, entryOf, isPrototype, listen, fragmentOf, hashWith };
|
|
228
256
|
})();
|
package/board/public/shell.js
CHANGED
|
@@ -35,6 +35,10 @@ window.AppShell = (function () {
|
|
|
35
35
|
channel.port1.onmessage = done;
|
|
36
36
|
try { controller.postMessage({ forget: 'shell' }, [channel.port2]); } catch { done(); }
|
|
37
37
|
});
|
|
38
|
+
// Only this reload starts at the top: iOS put the old offset back once the page had grown and left that area unpainted until a scroll
|
|
39
|
+
// (docs/adr/2026-11-06-1100-the-shell-turns-off-scroll-restoration.md). pagehide gives ordinary reloads their restore back.
|
|
40
|
+
history.scrollRestoration = 'manual';
|
|
41
|
+
addEventListener('pagehide', () => { history.scrollRestoration = 'auto'; }, { once: true });
|
|
38
42
|
setTimeout(() => location.reload(), 0);
|
|
39
43
|
}
|
|
40
44
|
|
|
@@ -44,9 +48,6 @@ window.AppShell = (function () {
|
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
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';
|
|
50
51
|
// 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.
|
|
51
52
|
if (outer()?.open(location.href, { frames: false })) return;
|
|
52
53
|
// The same URL and scope as hub-kit.js's push registration, so the one registration and its push subscription stay.
|
|
@@ -245,24 +246,30 @@ window.AppShell = (function () {
|
|
|
245
246
|
// Board and Waiting on you are a board's two pages (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md):
|
|
246
247
|
// from 900px the board shows Waiting on you in its pane, so every view of the questions page, a link, a push, a
|
|
247
248
|
// hand-off, a history entry or a cold load, shows the board at its own URL and opens the question's topic there (id,
|
|
248
|
-
// or null for none). A board
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
249
|
+
// or null for none). A board's prototype page does the same and opens in the pane
|
|
250
|
+
// (docs/adr/2026-11-07-2030-on-desktop-the-prototype-viewer-opens-in-the-right-pane.md).
|
|
251
|
+
const inPane = (view) => wide.matches && (view?.kind === 'questions' || AppRouter.isPrototype(view, boards()));
|
|
252
|
+
// ask(B) → true once the board's pane did what the view asks. A board just shown takes a moment to fill its pane, so
|
|
253
|
+
// the pane is asked again until it has.
|
|
254
|
+
const paneAsk = (view, href) => (view.kind === 'questions' ? (B) => B.focusQuestion?.(view.id) : (B) => B.openViewer?.(href));
|
|
255
|
+
function askPane(ask, tries = 50) {
|
|
256
|
+
if (current?.view.kind !== 'board' || ask(current.v.B) || tries <= 0) return;
|
|
257
|
+
setTimeout(() => askPane(ask, tries - 1), 100);
|
|
252
258
|
}
|
|
253
259
|
function go(view, href, how, from) {
|
|
254
260
|
if (!AppRouter.mounted(view)) return false;
|
|
255
|
-
if (view
|
|
261
|
+
if (inPane(view)) {
|
|
262
|
+
const ask = paneAsk(view, href);
|
|
256
263
|
const board = { kind: 'board', route: view.route, hash: '' };
|
|
257
264
|
const boardHref = new URL(AppRouter.urlOf(board), location.href).href;
|
|
258
265
|
if (how === 'pop') history.replaceState(history.state, '', boardHref);
|
|
259
|
-
// On its own board it only
|
|
266
|
+
// On its own board it only asks the pane, so the URL, history and an open card stay as they are.
|
|
260
267
|
else if (current?.view.kind === 'board' && current.route === view.route && !settings) {
|
|
261
|
-
|
|
268
|
+
askPane(ask);
|
|
262
269
|
sidebar.close();
|
|
263
270
|
return true;
|
|
264
271
|
}
|
|
265
|
-
return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown)
|
|
272
|
+
return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown) askPane(ask); return shown; });
|
|
266
273
|
}
|
|
267
274
|
if (view.kind === 'settings') {
|
|
268
275
|
if (current?.view.kind === 'board' && current.route === view.route) {
|
|
@@ -316,15 +323,15 @@ window.AppShell = (function () {
|
|
|
316
323
|
if (!AppRouter.mounted(first)) { location.replace('/_hub/'); return; }
|
|
317
324
|
|
|
318
325
|
let pane = null;
|
|
319
|
-
if (first
|
|
320
|
-
pane =
|
|
326
|
+
if (inPane(first)) {
|
|
327
|
+
pane = paneAsk(first, location.href);
|
|
321
328
|
first = { kind: 'board', route: first.route, hash: '' };
|
|
322
329
|
history.replaceState(history.state, '', AppRouter.urlOf(first));
|
|
323
330
|
}
|
|
324
331
|
router = AppRouter.listen({ boards, go, wide: () => wide.matches });
|
|
325
|
-
// Widening past 900px on Waiting on you swaps to the board with
|
|
332
|
+
// Widening past 900px on Waiting on you or a prototype swaps to the board with it open in the pane.
|
|
326
333
|
wide.addEventListener('change', () => {
|
|
327
|
-
if (
|
|
334
|
+
if (current && inPane(current.view)) go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'replace');
|
|
328
335
|
});
|
|
329
336
|
opener = (url, { replace = false, frames = true } = {}) => {
|
|
330
337
|
const view = AppRouter.viewOf(url, { boards: boards() });
|
|
@@ -341,7 +348,7 @@ window.AppShell = (function () {
|
|
|
341
348
|
navigator.serviceWorker?.startMessages?.();
|
|
342
349
|
|
|
343
350
|
show(first);
|
|
344
|
-
if (pane)
|
|
351
|
+
if (pane) askPane(pane);
|
|
345
352
|
openStream();
|
|
346
353
|
addEventListener('visibilitychange', () => { if (!document.hidden) openStream(); });
|
|
347
354
|
addEventListener('pageshow', openStream);
|
package/board/public/sidebar.css
CHANGED
|
@@ -41,6 +41,11 @@
|
|
|
41
41
|
html.sb-update body.has-sidebar > .top .hub, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
|
|
42
42
|
html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub::after {
|
|
43
43
|
content: ''; position: absolute; width: 10px; height: 10px; top: 3px; right: 3px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg); }
|
|
44
|
+
/* A busy hub gets an orange dot on the logo and a broken one a red dot, in the update dot's place (red, then orange, then blue). */
|
|
45
|
+
html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > .top .hub, html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
|
|
46
|
+
html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > .top .hub::after, html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > :not(.sidebar) > .top .hub::after {
|
|
47
|
+
content: ''; position: absolute; width: 10px; height: 10px; top: 3px; right: 3px; border-radius: 50%; background: var(--color-notice); box-shadow: 0 0 0 2px var(--bg); }
|
|
48
|
+
html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-sidebar > :not(.sidebar) > .top .hub::after { background: var(--color-danger); }
|
|
44
49
|
.sidebar .sb-text { flex: 1; min-width: 0; }
|
|
45
50
|
.sidebar .sb-title { display: block; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
46
51
|
.sidebar .sb-moon { display: inline-block; margin-left: 5px; color: var(--muted); vertical-align: -1px; }
|
|
@@ -168,12 +173,12 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
|
|
|
168
173
|
.sidebar .sb-ctx.stale { opacity: .6; }
|
|
169
174
|
.sidebar .lcs { order: -1; flex: 1 1 100%; min-width: 0; display: flex; flex-wrap: wrap; gap: 4px; }
|
|
170
175
|
.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); }
|
|
171
|
-
.sidebar .lc-review { --tone: var(--accent); } .sidebar .lc-progress { --tone: var(--color-notice); } .sidebar .lc-todo { --tone: var(--muted); }
|
|
176
|
+
.sidebar .lc-needs { --tone: var(--color-danger); } .sidebar .lc-review { --tone: var(--accent); } .sidebar .lc-progress { --tone: var(--color-notice); } .sidebar .lc-todo { --tone: var(--muted); }
|
|
172
177
|
.sidebar .dot { flex: none; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--tone); }
|
|
173
178
|
@keyframes sb-pulse { 50% { opacity: .35; } }
|
|
174
179
|
@media (prefers-reduced-motion: no-preference) { .sidebar .dot.pulse { animation: sb-pulse 1.4s ease-in-out infinite; } }
|
|
175
180
|
.sidebar .word { flex: none; font-weight: 600; color: var(--tone); }
|
|
176
|
-
.sidebar .t-down
|
|
181
|
+
.sidebar .t-down { --tone: var(--color-danger); }
|
|
177
182
|
.sidebar .t-starting { --tone: var(--color-notice); }
|
|
178
183
|
.sidebar .t-stopped, .sidebar .t-asleep { --tone: var(--muted); }
|
|
179
184
|
.sidebar .t-working { --tone: var(--color-notice); }
|
package/board/public/sidebar.js
CHANGED
|
@@ -102,6 +102,9 @@
|
|
|
102
102
|
// A repaint keeps the drawer's scroll, so a new copy doesn't move the list under a finger.
|
|
103
103
|
function paint() {
|
|
104
104
|
html.classList.toggle('sb-update', Boolean(update));
|
|
105
|
+
const dot = globalThis.BoardUI?.hubDot?.(data);
|
|
106
|
+
html.classList.toggle('sb-hub-busy', dot === 'busy');
|
|
107
|
+
html.classList.toggle('sb-hub-down', dot === 'down');
|
|
105
108
|
if (!data) return;
|
|
106
109
|
const scroll = aside.querySelector('.sb-scroll');
|
|
107
110
|
const top = painted ? scroll?.scrollTop : undefined;
|
package/board/public/style.css
CHANGED
|
@@ -84,8 +84,17 @@ button.chip { border: 0; }
|
|
|
84
84
|
.sub.next { color: var(--color-notice); }
|
|
85
85
|
.card-hold { color: var(--color-quiet); }
|
|
86
86
|
.card-blocked, .card-action { color: var(--color-notice); }
|
|
87
|
-
.card
|
|
88
|
-
.card-
|
|
87
|
+
/* docs/adr/2026-11-11-1200-every-lane-has-phases.md: a card's phase word, toned by who acts next (ui-cards.js PHASE_LOOK). */
|
|
88
|
+
.card-phase.ph-quiet { color: var(--color-quiet); }
|
|
89
|
+
.card-phase.ph-info { color: var(--color-info); }
|
|
90
|
+
.card-phase.ph-notice { color: var(--color-notice); }
|
|
91
|
+
.card-phase.ph-success { color: var(--color-success); }
|
|
92
|
+
.card-phase.ph-merged { color: var(--color-merged); }
|
|
93
|
+
/* The footer status of a stale card, and of one the orchestrator stalled (ui-cards.js timeLine). */
|
|
94
|
+
.card-stale { color: var(--muted); }
|
|
95
|
+
.card-stalled { color: var(--color-danger); }
|
|
96
|
+
/* A bumped-out card's footer status, and a bumped ready card's while the host holds new work (ui-cards.js timeLine). */
|
|
97
|
+
.card-bumped-out, .card-host-held { color: var(--muted); }
|
|
89
98
|
.sub.next.idle { color: var(--muted); }
|
|
90
99
|
.sub.refused { color: var(--color-danger); }
|
|
91
100
|
.toast-stack { position: fixed; left: 50%; bottom: calc(var(--app-bottom, 0px) + 12px + env(safe-area-inset-bottom));
|
|
@@ -197,7 +206,6 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
|
|
|
197
206
|
.lane-github { margin-left: auto; font-size: 12px; font-weight: 400; color: var(--muted); text-align: right; }
|
|
198
207
|
.lane-github .late { color: var(--color-notice); }
|
|
199
208
|
.msg.other { background: transparent; border: 1px solid var(--line); }
|
|
200
|
-
.status.stale { border-color: var(--color-danger); background: conic-gradient(var(--color-danger) 0 75%, transparent 75% 100%); }
|
|
201
209
|
.msg .reply, .msg .small, .rep, .sent { overflow-wrap: anywhere; }
|
|
202
210
|
code { overflow-wrap: anywhere; }
|
|
203
211
|
/* iOS Safari zooms the page when a field under 16px takes focus, which shifts the page width. */
|
|
@@ -376,29 +384,19 @@ button.attach:disabled { opacity: .35; }
|
|
|
376
384
|
@keyframes pulse-fill { 50% { background-color: color-mix(in srgb, var(--bar-fill) 35%, transparent); } }
|
|
377
385
|
/* Every pulse beats on one clock (ui-progress-bar.js onePulse), so a card's running bar and its status dot blink together. */
|
|
378
386
|
@media (prefers-reduced-motion: no-preference) { .phase-bars i.now { animation: pulse-fill 1.4s ease-in-out infinite; } }
|
|
379
|
-
/* A stale build's status line, dot included, is the needs-you red, and a tap on it nudges Claude (ui-cards.js builderStatus). */
|
|
380
|
-
/* A status line that is a button is drawn as the div it replaces: this one reset, then only a colour per state. The font
|
|
381
|
-
shorthand stays out so .sub's 12px wins. It sets no width: a button is its own block formatting context, so a full-width one
|
|
382
|
-
drops below the bump icons' float instead of sitting beside it as the div's line box does (board/layout.test.mjs). */
|
|
383
|
-
button.sub { display: block; margin-inline: 0; margin-bottom: 0; padding: 0; min-height: 0; border: 0; border-radius: 0; background: none;
|
|
384
|
-
font-family: inherit; font-weight: inherit; letter-spacing: inherit; line-height: inherit; text-align: left; appearance: none; -webkit-appearance: none; }
|
|
385
|
-
button.sub.agent.stalled { color: var(--color-danger); }
|
|
386
|
-
/* A tap on it calms the line to the in-progress orange, its dot sending a ripple, until the card's next status line (app-cards.js nudge). */
|
|
387
|
-
button.sub.agent.stalled.nudging { color: var(--color-notice); cursor: default; }
|
|
388
|
-
.nudging .working-dot { position: relative; }
|
|
389
|
-
.nudging .working-dot::after { content: ''; position: absolute; inset: -1px; border-radius: 50%; border: 1.5px solid currentColor; opacity: 0; }
|
|
390
|
-
@keyframes nudge-ping { from { transform: scale(1); opacity: .9; } to { transform: scale(2.6); opacity: 0; } }
|
|
391
|
-
@media (prefers-reduced-motion: no-preference) { .nudging .working-dot::after { animation: nudge-ping 1.1s ease-out infinite; } }
|
|
392
|
-
.stalled .working-dot { background: currentColor; }
|
|
393
387
|
.brand { display: grid; grid-template-columns: auto auto minmax(0, 1fr); column-gap: 10px; row-gap: 2px; }
|
|
394
388
|
.brand .hub { grid-row: 1 / span 2; align-self: center; }
|
|
395
389
|
/* The session's working line, "thinking", is the owner's blue with one font, size, weight and pulse on the card and in the thread. */
|
|
396
390
|
.thinking { color: var(--accent); font-size: 12px; font-weight: 600; }
|
|
397
391
|
.thinking .working-dot { background: currentColor; }
|
|
392
|
+
/* A New card's "waiting", until the orchestrator picks it up: grey, its dot still. */
|
|
393
|
+
.pickup-wait { color: var(--muted); font-size: 12px; font-weight: 600; }
|
|
394
|
+
.pickup-wait .working-dot { background: currentColor; animation: none; }
|
|
398
395
|
.msg.link { padding: 0 10px; margin: 4px 0; font-size: 13px; }
|
|
399
396
|
.msg.builder-status { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--color-notice); overflow-wrap: anywhere; }
|
|
400
397
|
.msg.builder-status .working-dot { animation: none; }
|
|
401
398
|
.msg.request { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
|
399
|
+
.msg.request.readied { color: var(--color-success); }
|
|
402
400
|
.msg.agent { padding: 0 10px; margin: 4px 0; font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
|
|
403
401
|
.msg.agent.started { color: var(--color-notice); } .msg.agent.finished { color: var(--color-success); }
|
|
404
402
|
.msg.agent .muted { font-weight: 400; }
|
|
@@ -417,9 +415,11 @@ button.sub.agent.stalled.nudging { color: var(--color-notice); cursor: default;
|
|
|
417
415
|
has picked up are grey, after the lane colours so they win. */
|
|
418
416
|
.sub.agent.quiet, .sub.asked { color: var(--muted); }
|
|
419
417
|
.sub.agent.waiting-gray { color: var(--muted); }
|
|
418
|
+
/* A stale build's status line is grey, its dot still (ui-cards.js builderStatus). */
|
|
419
|
+
.sub.agent.stale { color: var(--muted); }
|
|
420
|
+
.sub.agent.stale .working-dot { animation: none; }
|
|
420
421
|
/* The one class every paused status takes, gray: the card and thread line, the header, the hub banner. */
|
|
421
422
|
.paused { color: var(--muted); font-weight: 600; }
|
|
422
|
-
.phase-bars.in-todo { --bar-fill: var(--muted); }
|
|
423
423
|
.msg.calls { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
|
|
424
424
|
.msg.calls summary { cursor: pointer; color: var(--muted); list-style: none; }
|
|
425
425
|
.msg.calls summary::-webkit-details-marker { display: none; }
|
|
@@ -521,7 +521,7 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
|
|
|
521
521
|
}
|
|
522
522
|
@keyframes refresh-spin { to { transform: rotate(360deg); } }
|
|
523
523
|
/* INT-55: the header's right group (the time then ↻, settings) sits one gap apart; each control paints disabled until app-header.js wires it */
|
|
524
|
-
.top .nav { gap:
|
|
524
|
+
.top .nav { gap: 2px; }
|
|
525
525
|
.top .nav .refresh { flex-direction: row-reverse; gap: 2px; }
|
|
526
526
|
.top .nav #refresh, .top .nav a.icon { display: inline-flex; align-items: center; justify-content: center; margin: 0; padding: 4px; border: 0; line-height: 1; }
|
|
527
527
|
.top .nav #status { line-height: 1.1; white-space: nowrap; }
|
|
@@ -592,7 +592,7 @@ main > * { transition: opacity .25s ease; }
|
|
|
592
592
|
.view-settings .who.new { border-color: var(--accent); }
|
|
593
593
|
.view-settings .add { display: flex; gap: 6px; margin-top: 8px; }
|
|
594
594
|
.view-settings .add input { flex: 1; min-width: 0; }
|
|
595
|
-
.view-settings .add.pick { flex-
|
|
595
|
+
.view-settings .add.pick { flex-direction: column; }
|
|
596
596
|
.view-settings .share-now { color: var(--ok); font-weight: 600; }
|
|
597
597
|
.view-settings .share-level { display: flex; gap: 4px; margin-top: 6px; }
|
|
598
598
|
.view-settings .share-level button { min-height: 32px; padding: 4px 12px; font-size: 13px; }
|
|
@@ -601,7 +601,11 @@ main > * { transition: opacity .25s ease; }
|
|
|
601
601
|
.view-settings .share-reach { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0 4px; }
|
|
602
602
|
.view-settings .share-reach p { flex: 1 1 220px; margin: 0; }
|
|
603
603
|
.view-settings .share-reach button { flex: none; }
|
|
604
|
-
.view-settings .
|
|
604
|
+
.view-settings .login-row { display: flex; gap: 6px; }
|
|
605
|
+
.view-settings .login-row input { flex: 1; min-width: 0; }
|
|
606
|
+
.view-settings .chip { display: inline-flex; align-items: center; align-self: flex-start; gap: 6px; padding: 4px 6px 4px 12px; border-radius: 14px; border: 1px solid var(--accent); font-size: 14px; }
|
|
607
|
+
.view-settings .chip button { padding: 0 6px; min-height: 0; }
|
|
608
|
+
.view-settings .problem { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 9px 10px; border: 1px dashed var(--line); border-radius: 9px; }
|
|
605
609
|
.view-settings .pick-list { max-height: 160px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
|
|
606
610
|
.view-settings .pick-list button { text-align: left; }
|
|
607
611
|
.view-settings .pick-list button[aria-pressed="true"] { border-color: var(--accent); }
|
|
@@ -633,12 +637,12 @@ main > * { transition: opacity .25s ease; }
|
|
|
633
637
|
.view-questions .q { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin: 12px 0; }
|
|
634
638
|
.view-questions .q.urgent { border-color: var(--color-notice); }
|
|
635
639
|
/* docs/adr/2026-11-05-2331-a-sent-answer-is-resolved-in-place-until-the-page-reloads.md
|
|
636
|
-
A requested question takes an accent tint
|
|
637
|
-
|
|
640
|
+
A requested question takes an accent tint, with its border drawn inside the card as well, so the card keeps its size.
|
|
641
|
+
A resolved question has no border or tint of its own; its Resolved badge marks it
|
|
642
|
+
(docs/adr/2026-11-06-1200-a-resolved-question-drops-its-green-border-and-tint.md). */
|
|
638
643
|
.view-questions .q.pr-busy { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 9%, var(--panel)); box-shadow: inset 0 0 0 1px var(--accent); }
|
|
639
644
|
.view-questions .q.pr-busy .opt { opacity: .55; cursor: default; }
|
|
640
645
|
.view-questions .q.pr-busy .opt:has(input:checked) { opacity: 1; }
|
|
641
|
-
.view-questions .q.resolved { border-color: var(--color-success); background: color-mix(in srgb, var(--color-success) 9%, var(--panel)); box-shadow: inset 0 0 0 1px var(--color-success); }
|
|
642
646
|
.view-questions .q.resolved .sent-line { margin: 6px 0; font-size: 13px; color: var(--color-success); }
|
|
643
647
|
.view-questions .q h3 { font-size: 15px; margin: 6px 0 4px; line-height: 1.3; }
|
|
644
648
|
.view-questions .ctx { color: var(--muted); font-size: 13px; margin-bottom: 8px; }
|
|
@@ -659,7 +663,8 @@ main > * { transition: opacity .25s ease; }
|
|
|
659
663
|
.view-questions .q details > summary { cursor: pointer; color: var(--muted); font-size: 13px; }
|
|
660
664
|
.view-questions .topics { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 0 8px; margin-bottom: 4px; }
|
|
661
665
|
.view-questions .topics button { min-width: 0; border: 1px solid var(--line); border-radius: 999px; padding: 4px 11px; font-size: 13px;
|
|
662
|
-
background: var(--panel); color: var(--text);
|
|
666
|
+
background: var(--panel); color: var(--text); display: inline-flex; max-width: 100%; text-align: left; }
|
|
667
|
+
.view-questions .topics .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
663
668
|
.view-questions .topics button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
|
|
664
669
|
.view-questions .topics button.progress { border-color: var(--line); background: color-mix(in srgb, var(--color-notice) 12%, var(--panel)); }
|
|
665
670
|
.view-questions .topics button.progress[aria-pressed="true"] { border-color: var(--accent); background: color-mix(in srgb, var(--color-notice) 12%, var(--accent-soft)); }
|
|
@@ -680,7 +685,7 @@ main > * { transition: opacity .25s ease; }
|
|
|
680
685
|
.view-questions .q.resolved .opt:has(input:checked) .det { display: block; }
|
|
681
686
|
.view-questions .q.resolved .opt:not(:has(input:checked)) { opacity: .6; }
|
|
682
687
|
.view-questions .q.resolved .reask-line { margin-top: 10px; }
|
|
683
|
-
.view-questions .bar { position: sticky; bottom: var(--app-bottom, 0px); padding: 10px 0 max(10px, env(safe-area-inset-bottom)); background: var(--bg);
|
|
688
|
+
.view-questions .bar { position: sticky; bottom: var(--app-bottom, 0px); padding: 10px 0 max(10px, env(safe-area-inset-bottom)); background: var(--bg); z-index: 2;
|
|
684
689
|
display: flex; justify-content: space-between; align-items: center; gap: 8px; border-top: 1px solid var(--line); }
|
|
685
690
|
.view-questions .bar .send-hint { margin-left: auto; }
|
|
686
691
|
/* The + rides above the sticky Send bar rather than over its Send. */
|
|
@@ -757,6 +762,8 @@ main > * { transition: opacity .25s ease; }
|
|
|
757
762
|
:root:has(body > .top, body.has-sidebar > :not(.sidebar) > .top) {
|
|
758
763
|
--app-bottom: calc(49px + max(4px, env(safe-area-inset-bottom)));
|
|
759
764
|
}
|
|
765
|
+
/* The nav under the bar already pads the safe area (--app-bottom), so the gap under Send stays the 10px above it. */
|
|
766
|
+
.view-questions .bar { padding-bottom: 10px; }
|
|
760
767
|
body > .top, body.has-sidebar > :not(.sidebar) > .top {
|
|
761
768
|
position: fixed; top: auto; bottom: 0; left: 0; right: 0; z-index: 7;
|
|
762
769
|
border-bottom: 0; border-top: 1px solid var(--line); padding-top: 4px; padding-bottom: max(4px, env(safe-area-inset-bottom));
|
|
@@ -770,8 +777,14 @@ main > * { transition: opacity .25s ease; }
|
|
|
770
777
|
.sheet.keyboard-sheet { --sheet-limit: max(80px, calc(var(--vh, 100dvh) + var(--kb, 0px) - max(0px, calc(var(--app-bottom, 0px) - var(--kb, 0px))) - max(12px, env(safe-area-inset-top)))); }
|
|
771
778
|
}
|
|
772
779
|
|
|
773
|
-
/* Typing, a text field focused (shell.js sets :root.typing): the floating +
|
|
780
|
+
/* Typing, a text field focused (shell.js sets :root.typing): the floating + dims and stops taking taps, and the page stays as it is. */
|
|
774
781
|
:root.typing .fab { opacity: .35; pointer-events: none; }
|
|
782
|
+
/* On a phone the + hides while typing, so it never sits over the keyboard's field. */
|
|
783
|
+
@media (max-width: 699px) {
|
|
784
|
+
:root.typing .fab { display: none; }
|
|
785
|
+
/* The board page bottom bar hides too; --app-bottom stays, so the page does not move. */
|
|
786
|
+
:root.typing body > .top, :root.typing body.has-sidebar > :not(.sidebar) > .top { display: none; }
|
|
787
|
+
}
|
|
775
788
|
|
|
776
789
|
.settings-modal { position: fixed; inset: max(12px, env(safe-area-inset-top)) 0 calc(12px + max(var(--kb, 0px), var(--app-bottom, 0px))); margin: auto; width: min(680px, calc(100% - 24px));
|
|
777
790
|
height: fit-content; max-width: none; max-height: max(120px, calc(var(--vh, 100dvh) - max(0px, calc(var(--app-bottom, 0px) - var(--kb, 0px))) - 24px - env(safe-area-inset-top)));
|
|
@@ -885,6 +898,9 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
|
|
|
885
898
|
.reader-row.sys, .reader-row.out { color: var(--muted); font-size: 12.5px; }
|
|
886
899
|
.reader-row.out { padding-left: 28px; font: 11.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
887
900
|
.reader-row.tool { font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
901
|
+
.reader-row.call { cursor: pointer; }
|
|
902
|
+
.reader-caret { display: inline-block; width: 10px; margin-right: 4px; color: var(--muted); }
|
|
903
|
+
.reader-out { margin: 2px 14px 4px 28px; padding: 6px 8px; border-left: 2px solid var(--line); color: var(--muted); font: 11.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
888
904
|
.reader-tag { display: inline-block; margin-right: 6px; padding: 0 6px; border-radius: 6px; background: var(--chip); color: var(--accent); font-size: 11px; }
|
|
889
905
|
.reader-raw, .reader-text { margin: 0; padding: 2px 14px 8px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
890
906
|
.reader-md { padding: 4px 16px 12px; overflow-wrap: anywhere; line-height: 1.5; }
|
|
@@ -933,13 +949,14 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
|
|
|
933
949
|
.deck-opts { display: flex; flex-direction: column; gap: 6px; }
|
|
934
950
|
.deck-opts button { text-align: left; border-radius: 10px; padding: 10px 12px; font-size: 14px; }
|
|
935
951
|
.deck-opts button.rec { border-color: var(--accent); }
|
|
952
|
+
.deck-opts button.armed { background: var(--color-notice); border-color: var(--color-notice); color: #000; font-weight: 600; }
|
|
936
953
|
.deck-opts .rec-word { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
|
|
937
954
|
.deck-opts small { display: block; color: var(--muted); font-size: 12.5px; }
|
|
938
955
|
.deck-prompt { margin: 0; padding: 0; list-style: none; }
|
|
939
956
|
.deck-prompt li.prompt { padding: 8px 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
|
|
940
957
|
.deck-acts { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
941
958
|
.deck-bar { display: flex; align-items: center; gap: 10px; padding: 0 14px 10px; }
|
|
942
|
-
.deck-dots { flex: 1; display: flex; flex-wrap: wrap; gap: 5px; }
|
|
959
|
+
.deck-dots { flex: 1; display: flex; flex-wrap: wrap; justify-content: center; gap: 5px; }
|
|
943
960
|
.deck-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--line); }
|
|
944
961
|
.deck-dots i.on { background: var(--color-notice); }
|
|
945
962
|
.deck-bar button { border-radius: 999px; padding: 5px 12px; font-size: 13px; }
|
|
@@ -953,16 +970,38 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
|
|
|
953
970
|
.needs-deck { display: none; }
|
|
954
971
|
/* Waiting on you is the board's pane here, so neither page shows the switch to the other. */
|
|
955
972
|
.top .pages-switch { display: none; }
|
|
973
|
+
.top .nav a.pages-switch { display: none; }
|
|
956
974
|
/* The board's + sits at the bottom right of the left pane: the pane ends at the middle of the space right of the sidebar. */
|
|
957
975
|
.view-board .fab { right: calc(50% - var(--sidebar-w, 0px) / 2 + var(--fab-edge)); }
|
|
958
976
|
body.has-sidebar { --sidebar-w: 272px; }
|
|
959
977
|
html.sb-collapsed body.has-sidebar { --sidebar-w: 0px; }
|
|
960
|
-
.needs-pane { display: block; position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; overscroll-behavior: contain; padding: 12px 16px 0; border-left: 1px solid var(--line); }
|
|
978
|
+
.needs-pane { display: block; position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 12px 16px 0; border-left: 1px solid var(--line); }
|
|
961
979
|
.needs-pane .needs-questions main { padding: 0; max-width: none; }
|
|
962
980
|
.needs-pane:has(.q-empty), .needs-pane .needs-questions:has(.q-empty), .needs-pane .needs-questions:has(.q-empty) main, .needs-pane #page:has(.q-empty) { display: flex; flex-direction: column; }
|
|
963
981
|
.needs-pane .needs-questions:has(.q-empty), .needs-pane .needs-questions:has(.q-empty) main, .needs-pane #page:has(.q-empty), .needs-pane .q-empty { flex: 1; min-height: 0; }
|
|
964
982
|
.needs-pane .waiting { margin: 0 0 12px; }
|
|
983
|
+
.needs-pane * { min-width: 0; overflow-wrap: anywhere; }
|
|
984
|
+
.needs-pane pre, .needs-pane code { white-space: pre-wrap; }
|
|
985
|
+
.needs-pane :is(.deck-opts, .row, .actions, .btns) { flex-wrap: wrap; }
|
|
986
|
+
/* A prototype in the pane (app-needs.js openViewer): a top bar over the framed page, whose footer ends the pane; ⤢
|
|
987
|
+
(.widened) lays the pane over the lanes, from the sidebar's edge. */
|
|
988
|
+
.needs-pane.viewing { display: flex; flex-direction: column; padding: 0; overflow: hidden; }
|
|
989
|
+
.needs-pane.viewing > :not(.pane-viewer) { display: none; }
|
|
990
|
+
.needs-pane.viewing.widened { position: fixed; top: 56px; left: var(--sidebar-w, 0px); right: 0; bottom: 0; height: auto; z-index: 7; border-left: 0; background: var(--bg); }
|
|
991
|
+
.pane-viewer { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
992
|
+
.pv-top { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
993
|
+
.pv-top .pv-grow { flex: 1; }
|
|
994
|
+
.pv-top button { min-height: 36px; border-radius: 8px; font-weight: 600; }
|
|
995
|
+
.pv-top .pv-back { border: 0; background: transparent; color: var(--text); padding: 6px 8px; }
|
|
996
|
+
.pv-top .pv-back:hover { background: var(--line); }
|
|
997
|
+
.pv-top .pv-widen { width: 36px; padding: 0; font-size: 17px; }
|
|
998
|
+
.pv-top .pv-widen[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
|
999
|
+
.pv-top .ps-chip { border-radius: 999px; padding: 0 14px; }
|
|
1000
|
+
.pv-frame { flex: 1; min-height: 0; width: 100%; border: 0; display: block; background: var(--panel); }
|
|
1001
|
+
.pane-viewer .ps-sheet { position: absolute; left: 8px; right: 8px; top: 56px; z-index: 2; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); box-shadow: 0 8px 30px rgba(0,0,0,.25); }
|
|
1002
|
+
.pane-viewer .ps-sheet h3 { margin: 0 0 6px; font-size: 14px; }
|
|
1003
|
+
.pane-viewer .ps-sheet button { display: flex; justify-content: space-between; gap: 12px; width: 100%; border: 0; border-top: 1px solid var(--line); border-radius: 0; background: transparent; color: var(--text); padding: 10px 2px; text-align: left; }
|
|
1004
|
+
.pane-viewer .ps-sheet button span + span { color: var(--muted); }
|
|
965
1005
|
}
|
|
966
1006
|
/* The Send bar is the last thing on its page: only the phone nav under it is left clear, so its background runs to the edge. */
|
|
967
1007
|
body .view-questions main { padding-bottom: var(--app-bottom, 0px); }
|
|
968
|
-
.msg.calls.status-fold summary .fold-log { margin-left: 6px; color: var(--accent); text-decoration: underline; }
|