@lovek4d/kickflip 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +17 -14
- package/SKILL.md +2 -2
- package/bin/event-lines.mjs +1 -1
- package/board/bin/board.mjs +12 -3
- package/board/domain.mjs +7 -7
- package/board/headless.mjs +8 -8
- package/board/public/app-cards.js +2 -0
- package/board/public/app-header.js +8 -0
- package/board/public/app-lanes.js +43 -19
- package/board/public/app-snapshot.js +1 -2
- package/board/public/app-threads.js +2 -2
- package/board/public/app.html +0 -1
- package/board/public/app.js +3 -25
- package/board/public/col-resize.js +25 -44
- package/board/public/prompts.js +1 -1
- package/board/public/prototype-kit.js +1 -1
- package/board/public/prototype-switcher.js +3 -8
- package/board/public/prototype.html +2 -6
- package/board/public/router.js +16 -22
- package/board/public/shell.js +3 -39
- package/board/public/style.css +42 -95
- package/board/public/ui-cards.js +27 -23
- package/board/public/ui-enums.js +2 -2
- package/board/public/ui-page-header.js +2 -2
- package/board/public/ui-prompts.js +1 -1
- package/board/public/ui-questions.js +2 -3
- package/board/public/ui.js +1 -1
- package/board/public/view-hub.js +1 -1
- package/board/public/view-questions.js +7 -26
- package/board/server.mjs +18 -18
- package/board/store.mjs +28 -28
- package/hub/hub.mjs +8 -8
- package/hub/prompts.mjs +2 -2
- package/package.json +1 -1
- package/board/public/app-needs.js +0 -248
|
@@ -22,10 +22,9 @@
|
|
|
22
22
|
#stage .top { position: static; }
|
|
23
23
|
#stage .hub-view main { padding-bottom: 16px; }
|
|
24
24
|
/* A served round's page, framed from the board's base prototype worktree, edge to edge from under the ✕ row to the
|
|
25
|
-
viewer's footer
|
|
25
|
+
viewer's footer. */
|
|
26
26
|
#stage:has(> .kit-served) { max-width: none; padding: 0; }
|
|
27
27
|
.kit-served { display: block; width: 100%; min-height: 320px; height: calc(100dvh - 60px - var(--ps-footer-h, 150px) - var(--app-bottom, 0px) - env(safe-area-inset-top)); border: 0; background: var(--panel); }
|
|
28
|
-
.ps-pane .kit-served { height: calc(100dvh - var(--ps-footer-h, 150px)); }
|
|
29
28
|
.kit-served-failed pre { margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
|
|
30
29
|
.kit-missing { margin: 0; padding: 16px; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
|
31
30
|
#protoToast { position: fixed; z-index: 40; left: 50%; top: calc(64px + env(safe-area-inset-top)); transform: translateX(-50%); max-width: calc(100vw - 32px); }
|
|
@@ -50,10 +49,8 @@
|
|
|
50
49
|
#stage .kit-sidebar.sidebar:not(.kit-head) > header.top { display: none; }
|
|
51
50
|
@media (min-width: 900px) { #stage .kit-sidebar.sidebar { width: 272px; } }
|
|
52
51
|
/* Everything a take draws shows at every width: the board's width-gated hides are undone here (style.css hides
|
|
53
|
-
.
|
|
52
|
+
.key-hint below 600px). render-check.mjs refuses a take whose box is
|
|
54
53
|
still hidden at one of its two widths, so a gate missed here fails the round instead of drawing blank. */
|
|
55
|
-
#stage .needs-deck { display: block; }
|
|
56
|
-
#stage .needs-pane { display: block; position: static; height: auto; overflow: visible; border-left: 0; padding: 0; }
|
|
57
54
|
#stage .key-hint { display: inline; }
|
|
58
55
|
#stage .kit-sheet-strip { padding: 32px 0 0; border-radius: 12px; background: rgba(0,0,0,.35); overflow: hidden; }
|
|
59
56
|
#stage .kit-sheet { max-width: 560px; margin: 0 auto; border: 1px solid var(--line); border-bottom: 0; border-radius: 16px 16px 0 0; background: var(--panel); color: var(--text); box-shadow: 0 -8px 30px rgba(0,0,0,.18); }
|
|
@@ -99,7 +96,6 @@
|
|
|
99
96
|
<script src="app-cards.js"></script>
|
|
100
97
|
<script src="app-lanes.js"></script>
|
|
101
98
|
<script src="app-asks.js"></script>
|
|
102
|
-
<script src="app-needs.js"></script>
|
|
103
99
|
<script src="app-header.js"></script>
|
|
104
100
|
<script src="app-arch.js"></script>
|
|
105
101
|
<script src="app-reader.js"></script>
|
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 #selectedCard=<key
|
|
6
|
+
// board {route, hash} /<route>/, hash such as #selectedCard=<key>, 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=<wave slug>, and plan.html, which the board
|
|
@@ -81,24 +81,23 @@ globalThis.AppRouter = (function () {
|
|
|
81
81
|
|
|
82
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
|
|
83
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
|
|
84
|
+
// A board's prototype page.
|
|
85
85
|
const isPrototype = (view, boards) => view?.kind === 'frame' && boards.has(view.route) && /^\/[^/]+\/prototype-[\w-]+\.html$/.test(new URL(view.url, 'http://hub').pathname);
|
|
86
86
|
function questionOf(view, boards) {
|
|
87
87
|
return isPrototype(view, boards) ? new URL(view.url, 'http://hub').searchParams.get('q') || null : null;
|
|
88
88
|
}
|
|
89
|
-
|
|
90
|
-
function levelOf(view, { boards = new Set(), wide = false } = {}) {
|
|
89
|
+
function levelOf(view, { boards = new Set() } = {}) {
|
|
91
90
|
if (view?.kind === 'board') return 0;
|
|
92
91
|
if (view?.kind === 'questions') return 1;
|
|
93
|
-
return questionOf(view, boards) ?
|
|
92
|
+
return questionOf(view, boards) ? 2 : null;
|
|
94
93
|
}
|
|
95
94
|
// → the views above view in the tree, its board first. card: the card whose thread the owner left the board from,
|
|
96
95
|
// which the board keeps open.
|
|
97
|
-
function parentsOf(view, { boards = new Set(), card = null
|
|
96
|
+
function parentsOf(view, { boards = new Set(), card = null } = {}) {
|
|
98
97
|
const q = questionOf(view, boards);
|
|
99
98
|
if (!q && view?.kind !== 'questions') return [];
|
|
100
99
|
const board = { kind: 'board', route: view.route, hash: card ? `#selectedCard=${encodeURIComponent(card)}` : '' };
|
|
101
|
-
return q
|
|
100
|
+
return q ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
|
|
102
101
|
}
|
|
103
102
|
|
|
104
103
|
// Each history entry carries {idx}, its distance from the shell's first entry, pushed when a push opened it, and on a
|
|
@@ -111,8 +110,8 @@ globalThis.AppRouter = (function () {
|
|
|
111
110
|
// boards() → the board routes now.
|
|
112
111
|
// → {push(href, from), replace(href), up()}: push and replace write an entry for a view go is showing; replace answers
|
|
113
112
|
// 'up' when it went back to a parent instead, which then takes href, and go shows it on that pop.
|
|
114
|
-
function listen({ boards, go
|
|
115
|
-
const b = () => ({ boards: boards()
|
|
113
|
+
function listen({ boards, go }) {
|
|
114
|
+
const b = () => ({ boards: boards() });
|
|
116
115
|
const mark = (pushed) => (pushed ? { pushed: true } : {});
|
|
117
116
|
let idx = Number.isInteger(history.state?.idx) ? history.state.idx : 0;
|
|
118
117
|
let landing = null;
|
|
@@ -225,32 +224,27 @@ globalThis.AppRouter = (function () {
|
|
|
225
224
|
return { push: (href, from) => write('push', href, from), replace: (href) => write('replace', href), up };
|
|
226
225
|
}
|
|
227
226
|
|
|
228
|
-
// A board's fragment is &-joined keys: selectedCard=<key> (the focused card),
|
|
229
|
-
//
|
|
230
|
-
// → {selectedCard,
|
|
227
|
+
// A board's fragment is &-joined keys: selectedCard=<key> (the focused card), review=<PR number>, and a bare reply.
|
|
228
|
+
// The older #item=<key> reads as selectedCard.
|
|
229
|
+
// → {selectedCard, review, reply}, each null or false when absent.
|
|
231
230
|
function fragmentOf(hash) {
|
|
232
|
-
const out = { selectedCard: null,
|
|
231
|
+
const out = { selectedCard: null, review: null, reply: false };
|
|
233
232
|
const decode = (v) => { try { return decodeURIComponent(v); } catch { return v; } };
|
|
234
233
|
for (const part of String(hash).replace(/^#/, '').split('&')) {
|
|
235
234
|
const eq = part.indexOf('=');
|
|
236
235
|
const [k, v] = eq < 0 ? [part, ''] : [part.slice(0, eq), part.slice(eq + 1)];
|
|
237
236
|
if (k === 'selectedCard' || k === 'item') out.selectedCard = decode(v) || null;
|
|
238
|
-
else if (k === 'openItemsCard') out.openItemsCard = decode(v) || null;
|
|
239
237
|
else if (k === 'review' && /^\d+$/.test(v)) out.review = Number(v);
|
|
240
238
|
else if (k === 'reply') out.reply = true;
|
|
241
239
|
}
|
|
242
240
|
return out;
|
|
243
241
|
}
|
|
244
242
|
// 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
|
|
243
|
+
// alone in its fragment, so a change to the card replaces it; reply is spent once the card is shown.
|
|
246
244
|
function hashWith(hash, changes) {
|
|
247
|
-
const
|
|
248
|
-
|
|
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('&') : '';
|
|
245
|
+
const next = { selectedCard: fragmentOf(hash).selectedCard, ...changes };
|
|
246
|
+
return next.selectedCard ? `#selectedCard=${encodeURIComponent(next.selectedCard)}` : '';
|
|
253
247
|
}
|
|
254
248
|
|
|
255
|
-
return { viewOf, urlOf, mounted, entryOf,
|
|
249
|
+
return { viewOf, urlOf, mounted, entryOf, listen, fragmentOf, hashWith };
|
|
256
250
|
})();
|
package/board/public/shell.js
CHANGED
|
@@ -242,35 +242,10 @@ window.AppShell = (function () {
|
|
|
242
242
|
// go(view, href, how, from): router.js's, with how 'replace' for a framed page's hand-off and 'reload' for a view's
|
|
243
243
|
// own refresh. → true, or a promise of whether the view switched once the owner answers about an unsaved edit.
|
|
244
244
|
let router = null;
|
|
245
|
-
|
|
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
|
|
248
|
-
// hand-off, a history entry or a cold load, shows the board at its own URL and opens the question's topic there (id,
|
|
249
|
-
// or null for none). A board'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);
|
|
258
|
-
}
|
|
245
|
+
// Board and Waiting on you are a board's two pages at every width, and a prototype is a page of its own
|
|
246
|
+
// (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md, docs/adr/2026-11-12-0700-the-board-has-no-needs-you-deck-or-pane.md).
|
|
259
247
|
function go(view, href, how, from) {
|
|
260
248
|
if (!AppRouter.mounted(view)) return false;
|
|
261
|
-
if (inPane(view)) {
|
|
262
|
-
const ask = paneAsk(view, href);
|
|
263
|
-
const board = { kind: 'board', route: view.route, hash: '' };
|
|
264
|
-
const boardHref = new URL(AppRouter.urlOf(board), location.href).href;
|
|
265
|
-
if (how === 'pop') history.replaceState(history.state, '', boardHref);
|
|
266
|
-
// On its own board it only asks the pane, so the URL, history and an open card stay as they are.
|
|
267
|
-
else if (current?.view.kind === 'board' && current.route === view.route && !settings) {
|
|
268
|
-
askPane(ask);
|
|
269
|
-
sidebar.close();
|
|
270
|
-
return true;
|
|
271
|
-
}
|
|
272
|
-
return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown) askPane(ask); return shown; });
|
|
273
|
-
}
|
|
274
249
|
if (view.kind === 'settings') {
|
|
275
250
|
if (current?.view.kind === 'board' && current.route === view.route) {
|
|
276
251
|
const here = AppRouter.viewOf(location.href, { boards: boards() });
|
|
@@ -322,17 +297,7 @@ window.AppShell = (function () {
|
|
|
322
297
|
}
|
|
323
298
|
if (!AppRouter.mounted(first)) { location.replace('/_hub/'); return; }
|
|
324
299
|
|
|
325
|
-
|
|
326
|
-
if (inPane(first)) {
|
|
327
|
-
pane = paneAsk(first, location.href);
|
|
328
|
-
first = { kind: 'board', route: first.route, hash: '' };
|
|
329
|
-
history.replaceState(history.state, '', AppRouter.urlOf(first));
|
|
330
|
-
}
|
|
331
|
-
router = AppRouter.listen({ boards, go, wide: () => wide.matches });
|
|
332
|
-
// Widening past 900px on Waiting on you or a prototype swaps to the board with it open in the pane.
|
|
333
|
-
wide.addEventListener('change', () => {
|
|
334
|
-
if (current && inPane(current.view)) go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'replace');
|
|
335
|
-
});
|
|
300
|
+
router = AppRouter.listen({ boards, go });
|
|
336
301
|
opener = (url, { replace = false, frames = true } = {}) => {
|
|
337
302
|
const view = AppRouter.viewOf(url, { boards: boards() });
|
|
338
303
|
if (!AppRouter.mounted(view) || (!frames && view.kind === 'frame')) return false;
|
|
@@ -348,7 +313,6 @@ window.AppShell = (function () {
|
|
|
348
313
|
navigator.serviceWorker?.startMessages?.();
|
|
349
314
|
|
|
350
315
|
show(first);
|
|
351
|
-
if (pane) askPane(pane);
|
|
352
316
|
openStream();
|
|
353
317
|
addEventListener('visibilitychange', () => { if (!document.hidden) openStream(); });
|
|
354
318
|
addEventListener('pageshow', openStream);
|
package/board/public/style.css
CHANGED
|
@@ -85,7 +85,7 @@ button.chip { border: 0; }
|
|
|
85
85
|
.sub.agent { color: var(--color-notice); }
|
|
86
86
|
.sub.next { color: var(--color-notice); }
|
|
87
87
|
.card-hold { color: var(--color-quiet); }
|
|
88
|
-
.card-blocked
|
|
88
|
+
.card-blocked { color: var(--color-notice); }
|
|
89
89
|
/* 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). */
|
|
90
90
|
.card-phase.ph-quiet { color: var(--color-quiet); }
|
|
91
91
|
.card-phase.ph-info { color: var(--color-info); }
|
|
@@ -395,19 +395,22 @@ div.status-line { padding-left: 33px; text-indent: -23px; }
|
|
|
395
395
|
.msg.agent.started { color: var(--color-notice); } .msg.agent.finished { color: var(--color-success); }
|
|
396
396
|
.msg.agent .muted { font-weight: 400; }
|
|
397
397
|
/* A planner's line in the thread, dot included, is the owner's blue; its finish stays green
|
|
398
|
-
(docs/adr/2026-11-03-1551-planners-and-implementers-are-two-kinds-of-agent.md). On the card it
|
|
398
|
+
(docs/adr/2026-11-03-1551-planners-and-implementers-are-two-kinds-of-agent.md). On the card it is blue too (.sub.agent.planner below). */
|
|
399
399
|
.msg.builder-status.planner, .msg.agent.started.planner { color: var(--accent); }
|
|
400
400
|
.planner .working-dot { background: currentColor; }
|
|
401
|
-
/* A run's status line, dot included, takes its card's lane colour
|
|
401
|
+
/* A builder run's status line, dot included, takes its card's lane colour: grey in Todo, the phase-bar orange in
|
|
402
402
|
In Progress, blue in In Review; a Todo card's planner dashes are its grey, the running one pulsing
|
|
403
403
|
(docs/adr/2026-11-06-0145-every-run-takes-its-lanes-colour.md). */
|
|
404
404
|
.sub.agent.in-todo { color: var(--muted); }
|
|
405
405
|
.sub.agent.in-progress { color: var(--color-notice); }
|
|
406
406
|
.sub.agent.in-review { color: var(--color-info); }
|
|
407
407
|
.sub.agent.in-todo .working-dot, .sub.agent.in-progress .working-dot, .sub.agent.in-review .working-dot { background: currentColor; }
|
|
408
|
-
/* docs/adr/2026-11-
|
|
409
|
-
|
|
410
|
-
.sub.agent.
|
|
408
|
+
/* docs/adr/2026-11-12-0600-every-planner-launches-from-the-orchestrators-brief.md: a grill, prototype or visual's status
|
|
409
|
+
line, its brief's and its agent's, is blue in every lane, after the lane colours so it wins. */
|
|
410
|
+
.sub.agent.planner { color: var(--color-info); }
|
|
411
|
+
/* docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md: a quiet builder is grey, after the
|
|
412
|
+
lane colours so it wins. */
|
|
413
|
+
.sub.agent.quiet { color: var(--muted); }
|
|
411
414
|
.sub.agent.waiting-gray { color: var(--muted); }
|
|
412
415
|
/* A stale build's status line is grey, its dot still (ui-cards.js builderStatus). */
|
|
413
416
|
.sub.agent.stale { color: var(--muted); }
|
|
@@ -447,8 +450,11 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
|
|
|
447
450
|
/* INT-30: card line order */
|
|
448
451
|
.line6 { margin-top: 4px; }
|
|
449
452
|
.card-time time { font-variant-numeric: tabular-nums; }
|
|
450
|
-
.card-
|
|
451
|
-
.card-
|
|
453
|
+
.card-alert { color: var(--color-notice); font-weight: 600; }
|
|
454
|
+
.card-alert .sep { color: var(--muted); font-weight: 400; }
|
|
455
|
+
.card-alert .card-action { color: var(--color-notice); }
|
|
456
|
+
.card-alert .card-stalled { color: var(--color-danger); }
|
|
457
|
+
.lane[data-lane="done"] .card-alert .card-unread { color: var(--color-info); }
|
|
452
458
|
/* Lane headers: Linear-style group bands */
|
|
453
459
|
.lane > summary { display: flex; align-items: center; gap: 8px; margin: 0; min-height: 34px; padding: 5px 12px;
|
|
454
460
|
background: var(--band); border: 1px solid var(--line); border-radius: 10px; font-size: 13px; font-weight: 500; color: var(--text); }
|
|
@@ -471,7 +477,7 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
|
|
|
471
477
|
.view-only-strip b { color: var(--accent); }
|
|
472
478
|
/* A board out of reach (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md): it reads as before,
|
|
473
479
|
undimmed, and only the buttons that send, ui.js SENDS, are held, the labelled ones saying why; app-header.js stops their taps. */
|
|
474
|
-
.offline :is(form button[type="submit"], .actions button, button[data-confirm], button[data-
|
|
480
|
+
.offline :is(form button[type="submit"], .actions button, button[data-confirm], button[data-review-answer], button[data-prompt-send], button[data-prompt-choice], button[data-quick], button[data-card-bump], button[data-bump], button[data-withdraw], button[data-grill], button[data-proto], button[data-visual], button[data-plan], button[data-main-pull], button[data-arch-run], button[data-try], button[data-try-kill], button[data-reopen]) { opacity: .55; cursor: not-allowed; }
|
|
475
481
|
/* With no copy to show, the landing takes the board's place: the kickflip mark and two lines, as hub/front-door.mjs's page has them. */
|
|
476
482
|
.down-landing { min-height: 60vh; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 32px 24px; text-align: center; }
|
|
477
483
|
.down-landing-mark { display: block; margin-bottom: 8px; width: 70px; height: 60px; object-fit: contain; }
|
|
@@ -479,7 +485,7 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
|
|
|
479
485
|
.down-landing-hint { margin: 0; font-size: 14px; color: var(--muted); }
|
|
480
486
|
.down-landing-stack { margin: 0; max-width: 100%; overflow: auto; text-align: left; font-size: 12px; padding: 10px; border-radius: 8px; background: color-mix(in srgb, var(--muted) 14%, transparent); }
|
|
481
487
|
.down-landing-stack[hidden] { display: none; }
|
|
482
|
-
.view-board.down main > :not(#downLanding), .view-board.down
|
|
488
|
+
.view-board.down main > :not(#downLanding), .view-board.down #compose { display: none; }
|
|
483
489
|
.view-settings.down .settings-body > :not(.down-landing) { display: none; }
|
|
484
490
|
/* INT-28: answer banner */
|
|
485
491
|
.answer-banner { display: flex; align-items: center; gap: 4px; background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
|
@@ -534,7 +540,7 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
|
|
|
534
540
|
.board-foot .arch-bulb[hidden] { display: none; }
|
|
535
541
|
.board-foot .arch-bulb.running { color: var(--color-notice); }
|
|
536
542
|
.board-foot a.icon[aria-disabled="true"] { opacity: .35; pointer-events: none; }
|
|
537
|
-
/* The pages switch
|
|
543
|
+
/* The pages switch: the board header's ? with the open-question count on its corner, and Waiting on you's check back to the board. */
|
|
538
544
|
.top .pages-switch { position: relative; display: inline-flex; align-items: center; justify-content: center; flex: none; width: auto; height: auto; margin: 0; padding: 4px; border: 0; border-radius: 0; line-height: 1; color: var(--muted); }
|
|
539
545
|
.top .pages-switch:hover { color: var(--text); }
|
|
540
546
|
.top .pages-switch:active { background: none; }
|
|
@@ -659,13 +665,13 @@ main > * { transition: opacity .25s ease; }
|
|
|
659
665
|
(docs/adr/2026-11-09-1453-orchestrator-messages-replies-and-transcript-text-are-markdown.md), whose blocks break the
|
|
660
666
|
lines, not the text's own newlines. */
|
|
661
667
|
:is(.msg .body, .reader-row).md { white-space: normal; }
|
|
662
|
-
:is(.view-questions .ctx, .view-questions .opt .det, .
|
|
663
|
-
:is(.view-questions .ctx, .view-questions .opt .det, .
|
|
664
|
-
:is(.view-questions .ctx, .view-questions .opt .det, .
|
|
665
|
-
:is(.view-questions .ctx, .view-questions .opt .det, .
|
|
666
|
-
:is(.view-questions .ctx, .view-questions .opt .det, .
|
|
667
|
-
:is(.view-questions .ctx, .view-questions .opt .det, .
|
|
668
|
-
:is(.view-questions .ctx, .view-questions .opt .det, .
|
|
668
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .md) { overflow-wrap: anywhere; }
|
|
669
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .md) :is(p, ul, ol, blockquote, pre, table) { margin: 0 0 6px; }
|
|
670
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .md) :is(h1, h2, h3, h4, h5, h6) { font-size: inherit; font-weight: 600; margin: 6px 0 2px; }
|
|
671
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .md) :is(ul, ol) { padding-left: 18px; }
|
|
672
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .md) :is(pre, code) { white-space: pre-wrap; font-size: 12px; }
|
|
673
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .md) table { display: block; max-width: 100%; overflow-x: hidden; }
|
|
674
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .md) > :last-child { margin-bottom: 0; }
|
|
669
675
|
.view-questions .opt .suggested-why { margin-top: 4px; color: var(--color-info); font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; }
|
|
670
676
|
.view-questions .note { margin-top: 4px; }
|
|
671
677
|
.view-questions .ans { background: var(--accent-soft); border-radius: 9px; padding: 7px 10px; margin-top: 8px; font-size: 13px; }
|
|
@@ -703,8 +709,7 @@ main > * { transition: opacity .25s ease; }
|
|
|
703
709
|
/* The + rides above the sticky Send bar rather than over its Send. */
|
|
704
710
|
.view-questions .bar #qstatus { min-width: 0; }
|
|
705
711
|
.view-questions .bar #qstatus.sent { color: var(--color-success); font-weight: 600; }
|
|
706
|
-
/* With no open question, the moon and its line sit at the centre of what the page shows between its top and the nav
|
|
707
|
-
or of what the desktop pane has left under its Needs you lines. */
|
|
712
|
+
/* With no open question, the moon and its line sit at the centre of what the page shows between its top and the nav. */
|
|
708
713
|
.view-questions .q-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: var(--muted); text-align: center;
|
|
709
714
|
min-height: calc(var(--vh, 100dvh) - var(--app-bottom, 0px) - max(16px, env(safe-area-inset-top))); }
|
|
710
715
|
.view-questions .q-empty p { margin: 0; font-size: 15px; }
|
|
@@ -761,8 +766,8 @@ main > * { transition: opacity .25s ease; }
|
|
|
761
766
|
.view-notes .md-table th, .view-notes .md-table td { text-align: left; vertical-align: top; padding: 6px 8px; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
|
|
762
767
|
.view-notes .md-table th { background: var(--bg); font-weight: 600; }
|
|
763
768
|
.view-notes .md-table tr:last-child td { border-bottom: 0; }
|
|
764
|
-
/* The sidebar and every
|
|
765
|
-
.view-questions, .view-notes, .reader-sheet
|
|
769
|
+
/* The sidebar and every page never scroll sideways: content wraps at its width, overflow-x hidden is the last guard. */
|
|
770
|
+
.view-questions, .view-notes, .reader-sheet { overflow-x: hidden; }
|
|
766
771
|
/* A framed page (view-frame.js) fills the space beside the header, and scrolls inside its frame. */
|
|
767
772
|
.view-frame { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
|
|
768
773
|
.view-frame > .top { flex: none; }
|
|
@@ -781,7 +786,7 @@ main > * { transition: opacity .25s ease; }
|
|
|
781
786
|
border-bottom: 0; border-top: 1px solid var(--line); padding-top: 4px; padding-bottom: max(4px, env(safe-area-inset-bottom));
|
|
782
787
|
}
|
|
783
788
|
.top .nav #refresh, .top .nav a.icon, .top .pages-switch { min-width: 44px; min-height: 44px; }
|
|
784
|
-
body > main, body.has-sidebar > :not(.sidebar) > main
|
|
789
|
+
body > main, body.has-sidebar > :not(.sidebar) > main { padding-top: max(16px, env(safe-area-inset-top)); padding-bottom: calc(96px + var(--app-bottom, 0px) + env(safe-area-inset-bottom)); }
|
|
785
790
|
.view-frame:not(.own-header) { padding-bottom: var(--app-bottom, 0px); }
|
|
786
791
|
.sheet { bottom: max(0px, calc(var(--app-bottom, 0px) - var(--kb, 0px)));
|
|
787
792
|
max-height: calc(var(--vh, 100dvh) + var(--kb, 0px) - max(var(--app-bottom, 0px), var(--kb, 0px)) - env(safe-area-inset-top) - 12px); }
|
|
@@ -943,92 +948,34 @@ button[aria-busy="true"]:is([data-card-bump]) svg { fill: currentColor; }
|
|
|
943
948
|
.rv-acts { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
944
949
|
.view-questions .bar #qstatus .try-spin { color: var(--accent); margin-right: 6px; }
|
|
945
950
|
|
|
946
|
-
/* Needs you (app-needs.js): a deck at the top of the board on a phone; from 900px a pane beside the lanes, its lines over
|
|
947
|
-
Waiting on you (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md). */
|
|
948
|
-
.needs-pane { display: none; }
|
|
949
951
|
.col-handle { display: none; }
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
.deck-head { display: flex; align-items: center; gap: 8px; padding: 10px 14px 4px; }
|
|
953
|
-
.deck-head h2 { margin: 0; flex: 1; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--color-notice); }
|
|
954
|
-
.deck-n { min-width: 20px; padding: 1px 7px; border-radius: 999px; background: var(--color-notice); color: #1c1407; font-size: 12px; font-weight: 700; text-align: center; }
|
|
955
|
-
.deck-card { padding: 2px 14px 12px; min-width: 0; overflow-wrap: anywhere; }
|
|
956
|
-
.deck-what { margin: 0 0 2px; font-size: 12px; color: var(--muted); }
|
|
957
|
-
.deck-card h3 { margin: 0 0 4px; font-size: 15px; line-height: 1.3; }
|
|
958
|
-
.deck-card h3 a { color: inherit; text-decoration: none; }
|
|
959
|
-
.deck-card .ctx { margin: 0 0 10px; font-size: 13px; color: var(--muted); }
|
|
960
|
-
.deck-proto { margin: 0 0 10px; color: var(--accent); }
|
|
961
|
-
.deck-opts { display: flex; flex-direction: column; gap: 6px; }
|
|
962
|
-
.deck-opts button { text-align: left; border-radius: 10px; padding: 10px 12px; font-size: 14px; }
|
|
963
|
-
.deck-opts button.rec { border-color: var(--accent); }
|
|
964
|
-
.deck-opts button.armed { border-color: var(--sel-line); background: var(--sel-bg); font-weight: 600; }
|
|
965
|
-
.deck-opts .rec-word { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
|
|
966
|
-
.deck-opts .det { color: var(--muted); font-size: 12.5px; font-weight: 400; }
|
|
967
|
-
.deck-prompt { margin: 0; padding: 0; list-style: none; }
|
|
968
|
-
.deck-prompt li.prompt { padding: 8px 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
|
|
969
|
-
.deck-acts { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
970
|
-
.deck-bar { display: flex; align-items: center; gap: 10px; padding: 0 14px 10px; }
|
|
971
|
-
.deck-dots { flex: 1; display: flex; flex-wrap: wrap; justify-content: center; gap: 5px; }
|
|
972
|
-
.deck-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--line); }
|
|
973
|
-
.deck-dots i.on { background: var(--color-notice); }
|
|
974
|
-
.deck-bar button { border-radius: 999px; padding: 5px 12px; font-size: 13px; }
|
|
975
|
-
.deck-more { display: flex; align-items: center; justify-content: space-between; min-height: 46px; padding: 10px 14px; border-top: 1px solid color-mix(in srgb, var(--color-notice) 28%, var(--line));
|
|
976
|
-
background: color-mix(in srgb, var(--color-notice) 14%, var(--panel)); color: var(--text); font-weight: 600; font-size: 14px; text-decoration: none; }
|
|
977
|
-
.deck-more::after { content: '›'; color: var(--accent); font-size: 18px; }
|
|
978
|
-
.deck-more:hover { text-decoration: none; }
|
|
952
|
+
/* From 900px the lanes take the board's full width; Waiting on you is the header's ?
|
|
953
|
+
(docs/adr/2026-11-12-0700-the-board-has-no-needs-you-deck-or-pane.md). */
|
|
979
954
|
@media (min-width: 900px) {
|
|
980
|
-
.
|
|
981
|
-
.split > main { max-width: none; margin: 0; }
|
|
982
|
-
.needs-deck { display: none; }
|
|
983
|
-
/* Waiting on you is the board's pane here, so neither page shows the switch to the other. */
|
|
984
|
-
.top .pages-switch { display: none; }
|
|
985
|
-
.top .nav a.pages-switch { display: none; }
|
|
986
|
-
/* 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. */
|
|
987
|
-
.view-board .fab { right: calc(var(--pane-w, calc(50% - var(--sidebar-w, 0px) / 2)) + var(--fab-edge)); }
|
|
955
|
+
.view-board > main { max-width: none; margin: 0; }
|
|
988
956
|
body.has-sidebar { --sidebar-w: var(--sb-w, 272px); }
|
|
989
957
|
html.sb-collapsed body.has-sidebar { --sidebar-w: 0px; }
|
|
990
|
-
/* The drag
|
|
958
|
+
/* The sidebar's drag edge (col-resize.js): a hairline that thickens on hover. */
|
|
991
959
|
.col-handle { display: block; position: absolute; z-index: 5; top: 0; bottom: 0; width: 9px; cursor: col-resize; touch-action: none; }
|
|
992
960
|
.col-handle::after { content: ''; position: absolute; top: 0; bottom: 0; left: 4px; width: 1px; background: transparent; transition: background .12s, width .12s, left .12s; }
|
|
993
961
|
.col-handle:is(:hover, :focus-visible, .dragging)::after { left: 3px; width: 3px; background: var(--accent); }
|
|
994
962
|
.col-handle:focus-visible { outline: none; }
|
|
995
963
|
html.col-dragging, html.col-dragging * { cursor: col-resize !important; user-select: none; }
|
|
996
|
-
|
|
997
|
-
.
|
|
998
|
-
/* The reader sheet (app-reader.js) is a panel over the pane's half, at the width the seam gives it, while it is open. */
|
|
999
|
-
.sheet.reader-sheet { position: fixed; z-index: 30; inset: 56px 0 0 auto; margin: 0; width: var(--pane-w, calc((100vw - var(--sidebar-w, 0px)) / 2)); max-width: none;
|
|
964
|
+
/* The reader sheet (app-reader.js) is a panel over the right half of the space beside the sidebar while it is open. */
|
|
965
|
+
.sheet.reader-sheet { position: fixed; z-index: 30; inset: 56px 0 0 auto; margin: 0; width: calc((100vw - var(--sidebar-w, 0px)) / 2); max-width: none;
|
|
1000
966
|
height: auto; max-height: none; border-width: 0 0 0 1px; border-radius: 0; box-shadow: none; }
|
|
1001
967
|
.sheet.reader-sheet[open] { animation: reader-in .22s ease-out; }
|
|
1002
968
|
.sheet.reader-sheet.closing { animation: reader-out .18s ease-in forwards; }
|
|
1003
969
|
.reader-sheet::backdrop { background: transparent; }
|
|
1004
|
-
.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); }
|
|
1005
|
-
.needs-pane .needs-questions main { padding: 0; max-width: none; }
|
|
1006
|
-
.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; }
|
|
1007
|
-
.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; }
|
|
1008
|
-
.needs-pane .waiting { margin: 0 0 12px; }
|
|
1009
|
-
.needs-pane * { min-width: 0; overflow-wrap: anywhere; }
|
|
1010
|
-
.needs-pane pre, .needs-pane code { white-space: pre-wrap; }
|
|
1011
|
-
.needs-pane :is(.deck-opts, .row, .actions, .btns) { flex-wrap: wrap; }
|
|
1012
|
-
/* A prototype in the pane (app-needs.js openViewer): a top bar over the framed page, whose footer ends the pane; ⤢
|
|
1013
|
-
(.widened) lays the pane over the lanes, from the sidebar's edge. */
|
|
1014
|
-
.needs-pane.viewing { display: flex; flex-direction: column; padding: 0; overflow: hidden; }
|
|
1015
|
-
.needs-pane.viewing > :not(.pane-viewer) { display: none; }
|
|
1016
|
-
.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); }
|
|
1017
|
-
.pane-viewer { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
1018
|
-
.pv-top { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
1019
|
-
.pv-top .pv-grow { flex: 1; }
|
|
1020
|
-
.pv-top button { min-height: 36px; border-radius: 8px; font-weight: 600; }
|
|
1021
|
-
.pv-top .pv-back { border: 0; background: transparent; color: var(--text); padding: 6px 8px; }
|
|
1022
|
-
.pv-top .pv-back:hover { background: var(--line); }
|
|
1023
|
-
.pv-top .pv-widen { width: 36px; padding: 0; font-size: 17px; }
|
|
1024
|
-
.pv-top .pv-widen[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
|
1025
|
-
.pv-top .ps-chip { border-radius: 999px; padding: 0 14px; }
|
|
1026
|
-
.pv-frame { flex: 1; min-height: 0; width: 100%; border: 0; display: block; background: var(--panel); }
|
|
1027
|
-
.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); }
|
|
1028
|
-
.pane-viewer .ps-sheet h3 { margin: 0 0 6px; font-size: 14px; }
|
|
1029
|
-
.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; }
|
|
1030
|
-
.pane-viewer .ps-sheet button span + span { color: var(--muted); }
|
|
1031
970
|
}
|
|
1032
971
|
/* 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. */
|
|
1033
972
|
body .view-questions.view-questions main { padding-bottom: var(--app-bottom, 0px); }
|
|
1034
973
|
.pfile.retry { cursor: pointer; }
|
|
974
|
+
/* The Todo header's filter (docs/adr/2026-11-12-0600-the-todo-header-has-a-filter.md): a funnel at the right, orange while a box is unchecked, and its checklist under the header. */
|
|
975
|
+
.lane { position: relative; }
|
|
976
|
+
.lane-filter { margin-left: auto; display: inline-flex; align-items: center; padding: 4px; border: 0; background: none; color: var(--muted); cursor: pointer; }
|
|
977
|
+
.lane-filter.on { color: var(--color-notice); }
|
|
978
|
+
.lane-filter-menu { position: absolute; z-index: 5; top: 36px; right: 8px; display: flex; flex-direction: column; min-width: 160px; padding: 4px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: 0 6px 18px rgba(0, 0, 0, .25); }
|
|
979
|
+
.lane-filter-box { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border: 0; background: none; color: var(--text); font: inherit; text-align: left; cursor: pointer; }
|
|
980
|
+
.lane-filter-box .box { width: 14px; height: 14px; border: 1.5px solid var(--muted); border-radius: 4px; }
|
|
981
|
+
.lane-filter-box[aria-checked="true"] .box { border-color: var(--accent); background: var(--accent); }
|
package/board/public/ui-cards.js
CHANGED
|
@@ -254,7 +254,7 @@
|
|
|
254
254
|
if (b && b.kind !== BUILDER_KIND.install) return b.stopped || (b.pause ? (b.pause.paused ? 'paused' : 'pausing') : b.alive === false ? 'stopped' : b.phase === 'landing' ? 'landing' : 'implementing');
|
|
255
255
|
if (it.waitsOn === 'checking') return 'Orchestrator review';
|
|
256
256
|
if (it.lane === 'done') return 'done';
|
|
257
|
-
if (it.
|
|
257
|
+
if (it.planning) return it.planning;
|
|
258
258
|
if (it.lane === 'todo') return it.phase && (it.ready || it.phase !== 'ready') ? PHASE_LOOK[it.phase][0] : 'to do';
|
|
259
259
|
if (it.lane === 'progress') return it.reachedPhase ? `stopped at ${it.reachedPhase}` : 'in progress';
|
|
260
260
|
return 'in review';
|
|
@@ -280,18 +280,18 @@
|
|
|
280
280
|
// docs/adr/2026-11-11-1200-every-lane-has-phases.md
|
|
281
281
|
// A Todo, In Review or Done card's phase as a word, toned by who acts next: grey while a machine waits, blue while
|
|
282
282
|
// Claude works, orange while it waits on the owner, green once it can go and purple once merged. In Progress shows its
|
|
283
|
-
// phase only as its progress bar, and a card In Review for an owner wait other than the merge has none. Ready reads
|
|
283
|
+
// phase only as its progress bar, a New card has none (its footer line says it), and a card In Review for an owner wait other than the merge has none. Ready reads
|
|
284
284
|
// only on a card that can start (domain.mjs ready), so a blocked one shows what blocks it instead.
|
|
285
285
|
const PHASE_LOOK = {
|
|
286
286
|
new: ['new', 'quiet'], discussing: ['discussing', 'info'], grilling: ['grilling', 'info'], prototyping: ['prototyping', 'info'], staging: ['staging', 'notice'], ready: ['ready', 'success'],
|
|
287
287
|
draft: ['draft', 'quiet'], reviewers: ['pending review', 'notice'], merge: ['pending merge', 'notice'], stale: ['stale', 'quiet'],
|
|
288
288
|
merged: ['merged', 'merged'], closed: ['closed', 'quiet'],
|
|
289
289
|
};
|
|
290
|
-
// docs/adr/2026-11-12-0500-a-new-task-is-a-conversation.md: a discussing card
|
|
291
|
-
// owner's
|
|
292
|
-
const TURN_LOOK = { owner: ['discussing
|
|
290
|
+
// docs/adr/2026-11-12-0500-a-new-task-is-a-conversation.md: a discussing card reads just "discussing", blue on the
|
|
291
|
+
// owner's turn and grey while the orchestrator owes the reply.
|
|
292
|
+
const TURN_LOOK = { owner: ['discussing', 'info'], claude: ['discussing', 'quiet'] };
|
|
293
293
|
function phaseWord(it) {
|
|
294
|
-
if (it.kind !== 'work' || it.lane === 'progress' || !it.phase || (it.phase === 'ready' && !it.ready)) return '';
|
|
294
|
+
if (it.kind !== 'work' || it.lane === 'progress' || !it.phase || it.phase === 'new' || (it.phase === 'ready' && !it.ready)) return '';
|
|
295
295
|
const [word, tone] = it.phase === 'discussing' ? TURN_LOOK[it.turn] : PHASE_LOOK[it.phase];
|
|
296
296
|
return `<span class="card-phase ph-${tone}">${word}</span>`;
|
|
297
297
|
}
|
|
@@ -300,26 +300,29 @@
|
|
|
300
300
|
// orange while its builder runs on to its stopping point, then "paused · blocked by #61" in grey.
|
|
301
301
|
const pauseOf = (it) => (it.lane === 'progress' ? it.builder?.pause ?? null : null);
|
|
302
302
|
const pauseWords = (pause) => pause && `<span class="card-${pause.paused ? 'paused' : 'pausing'}">${pause.paused ? 'paused' : 'pausing'} · ${esc(pause.words)}</span>`;
|
|
303
|
-
// A card's last
|
|
304
|
-
//
|
|
305
|
-
// (docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md),
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
303
|
+
// A card's last two rows. The footer: the time its lane sorts by (domain.mjs sortAt), then its hold and what blocks it,
|
|
304
|
+
// or its phase word (phaseWord) when neither shows, then a build's stale in grey, held while host recovers on a bumped
|
|
305
|
+
// ready card a busy host holds, in grey (docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md),
|
|
306
|
+
// a paused build's words (pauseWords), then an In Progress card's phase bars
|
|
307
|
+
// (docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md). The row under it, drawn only when it has
|
|
308
|
+
// something: Action needed and the unread messages in bold orange, new questions counting as one (app-cards.js takes
|
|
309
|
+
// the count away when the card opens), then stalled in red, rightmost
|
|
310
|
+
// (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md,
|
|
311
|
+
// docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
|
|
312
|
+
// docs/adr/2026-11-12-0600-action-needed-and-new-messages-get-their-own-row.md).
|
|
310
313
|
const SEP = '<span class="sep"> · </span>';
|
|
311
314
|
const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
|
|
312
315
|
function timeLine(it, unread, bars) {
|
|
313
316
|
const iso = it.sortAt;
|
|
314
317
|
const pause = pauseOf(it);
|
|
315
|
-
const
|
|
318
|
+
const holdBlock = [pause?.cause !== 'held' && holdWords(it), pause?.cause !== 'blocked' && blockedWords(it)].filter(Boolean);
|
|
319
|
+
const footer = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, ...(holdBlock.length ? holdBlock : [phaseWord(it)]),
|
|
316
320
|
it.hostHeld && '<span class="card-host-held">held while host recovers</span>',
|
|
317
|
-
it.stalled
|
|
321
|
+
!it.stalled && it.stale && it.phase !== 'stale' && '<span class="card-stale">stale</span>', pauseWords(pause), bars].filter(Boolean);
|
|
318
322
|
const news = newWords(unread, 'message');
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
return `<div class="line6 sub card-time">${parts.join(SEP)}${tail}${count}</div>`;
|
|
323
|
+
const alert = [actionWords(it), news && `<span class="card-unread">${news}</span>`, it.stalled && '<span class="card-stalled">stalled</span>'].filter(Boolean);
|
|
324
|
+
return [footer.length && `<div class="line6 sub card-time">${footer.join(SEP)}</div>`,
|
|
325
|
+
alert.length && `<div class="line6 sub card-alert">${alert.join(SEP)}</div>`].filter(Boolean).join('');
|
|
323
326
|
}
|
|
324
327
|
// The Hold sheet's picker (app-cards.js): /api/board's holdChoices, the chips hub/duration.mjs shares with Snooze and
|
|
325
328
|
// Pause hub, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
|
|
@@ -346,15 +349,16 @@
|
|
|
346
349
|
const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting…</span>';
|
|
347
350
|
const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' ? thinkingLine() : ask.state === 'waiting' && PICKUP_WAIT].filter(Boolean).join(' · ');
|
|
348
351
|
function workLines(it, { unread = 0 } = {}) {
|
|
349
|
-
const
|
|
350
|
-
// A card shows its ticket, or Task when it has none; the card an ask became shows
|
|
351
|
-
const ids = it.isNew ? badge('notice', 'New') : ticket;
|
|
352
|
+
const ids = it.ticket ? ticketChip(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : it.linear?.length ? linearChips(it) : taskChip(it);
|
|
353
|
+
// A card shows its ticket, or Task when it has none; the card an ask became shows its ticket from the moment it lands.
|
|
352
354
|
const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
|
|
353
355
|
const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
|
|
356
|
+
// docs/adr/2026-11-12-0600-every-planner-launches-from-the-orchestrators-brief.md: preheating, planning prototype
|
|
357
|
+
// or planning visual while the orchestrator writes the brief, generating visual while a visual agent draws, in blue.
|
|
358
|
+
: it.planning && !it.builder ? `<div class="line3 sub agent planner">${DOT}${esc(it.planning)}</div>`
|
|
354
359
|
: it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${escText(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
|
|
355
360
|
: it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
|
|
356
361
|
: it.builder ? builderStatus(it)
|
|
357
|
-
: it.grilling || it.prototyping ? '<div class="line3 sub asked">preheating</div>'
|
|
358
362
|
: it.refusal ? `<div class="line3 sub refused">${escText(it.refusal)}</div>`
|
|
359
363
|
: it.landStopped ? `<div class="line3 sub">${escText(`${it.landStopped.words} · ${lasted(it.landStopped.at)}`)}</div>`
|
|
360
364
|
: it.working ? `<div class="line3 sub agent">${thinkingLine()}</div>`
|
package/board/public/ui-enums.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// badge shows (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). Each value is its name,
|
|
4
4
|
// since cards carry it in the board's JSON. Needs nothing else: board/domain.mjs runs this file in a vm context (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
|
|
5
5
|
(function () {
|
|
6
|
-
const BUTTON = Object.freeze({ openQuestions: 'openQuestions', github: 'github', merge: 'merge', reply: 'reply' });
|
|
7
|
-
const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', stalled: 'stalled' });
|
|
6
|
+
const BUTTON = Object.freeze({ openQuestions: 'openQuestions', github: 'github', merge: 'merge', reply: 'reply', implement: 'implement' });
|
|
7
|
+
const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', stalled: 'stalled', implement: 'implement' });
|
|
8
8
|
const BUILDER_KIND = Object.freeze({ install: 'install' });
|
|
9
9
|
const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
|
|
10
10
|
// Each lane's phases, in order, which a card stores beside its lane (docs/adr/2026-11-11-1200-every-lane-has-phases.md).
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
// lead is 'logo' (the kickflip, which toggles the sidebar, sidebar.js; logoHref, the hub's page, without it)
|
|
48
48
|
// or 'back' (the arrow, which follows backStep, with backHref the work item or the board, after the logo). The controls (time and refresh) paint disabled with refresh spinning until the
|
|
49
49
|
// page's script turns them on (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md). With controls
|
|
50
|
-
// false, trail (HTML) takes their place. pages is the switch between a board's two pages
|
|
50
|
+
// false, trail (HTML) takes their place. pages is the switch between a board's two pages
|
|
51
51
|
// (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md): 'questions' puts the ? after the controls, with
|
|
52
|
-
// the open-question count in its badge (app-
|
|
52
|
+
// the open-question count in its badge (app-header.js), and 'board' puts the check, back up to the board, after trail. leadAttrs adds to or replaces the lead link's attributes, and level is the
|
|
53
53
|
// title's heading level, for a header that isn't the page's own, such as the sidebar's (ui-sidebar.js). moon puts the
|
|
54
54
|
// away moon right of the title, hidden until app-header.js renderStatus shows it (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md).
|
|
55
55
|
function header({ lead = 'logo', title = '', titleId, backHref = './', backLabel = 'Back to the board', backId, logoSrc = 'kickflip.png',
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// '<question index>:<label>'. It waits (ui.js BUSY) and the line's other buttons are disabled.
|
|
23
23
|
function promptItem(p, { picks = {}, sending = null } = {}) {
|
|
24
24
|
const off = (tapped) => (sending === null ? '' : sending === tapped ? BoardUI.BUSY : ' disabled');
|
|
25
|
-
const where = [ago(p.at), p.cwd].filter(Boolean).map(esc).join(' · ');
|
|
25
|
+
const where = [p.board, ago(p.at), p.cwd].filter(Boolean).map(esc).join(' · ');
|
|
26
26
|
const head = `<div class="prompt-head"><b>${esc(p.questions ? 'Question' : p.tool)}</b><span class="muted small">${where}</span></div>`;
|
|
27
27
|
if (p.questions) {
|
|
28
28
|
const questions = p.questions.map((q, i) => `<div class="prompt-q"><p>${q.header ? `<span class="chip">${esc(q.header)}</span> ` : ''}${esc(q.question)}</p>
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// resolved it. A sent question draws as resolvedCard, locked, until its badge reopens it.
|
|
22
22
|
const isSent = (q) => q.state === 'answered' || q.state === 'replied' || q.state === 'resolved';
|
|
23
23
|
// A question's unsent {choice, note} on this device, in localStorage under qdraft:<id>. view-questions.js and the
|
|
24
|
-
//
|
|
24
|
+
// prototype viewer's Choose this (prototype-switcher.js) write it.
|
|
25
25
|
const getDraft = (id) => { try { return JSON.parse(localStorage.getItem('qdraft:' + id) || '{}'); } catch { return {}; } };
|
|
26
26
|
const setDraft = (id, v) => { try { localStorage.setItem('qdraft:' + id, JSON.stringify(v)); } catch {} };
|
|
27
27
|
const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(q.suggested.reason ?? `you answered this on ${q.suggested.card}: "${q.suggested.answer}"`, links) : '';
|
|
@@ -178,8 +178,7 @@
|
|
|
178
178
|
},
|
|
179
179
|
};
|
|
180
180
|
|
|
181
|
-
// A prototype viewer's Round pill and its sheet
|
|
182
|
-
// (app-needs.js). rounds: the card's prototype questions, oldest round first, as /api/board's card prototypes.rounds
|
|
181
|
+
// A prototype viewer's Round pill and its sheet (prototype-switcher.js). rounds: the card's prototype questions, oldest round first, as /api/board's card prototypes.rounds
|
|
183
182
|
// lists them ({n, q, url, confirm, state}). The pill names the round qid is in, out of the newest round's number; a
|
|
184
183
|
// card with a single round gets none.
|
|
185
184
|
function roundPill(rounds, qid) {
|