@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/onboarding.mjs
CHANGED
|
@@ -8,4 +8,4 @@ What this card does:
|
|
|
8
8
|
- Ask the owner what the project is for and which words they use for its parts; their wording goes in verbatim.
|
|
9
9
|
- If the repo already has a CONTEXT.md or ADRs, extend them rather than start again.
|
|
10
10
|
`,b=[{id:"onboard-build",title:"Prepare .kickflip/build",notes:p,needsNoBuild:!0},{id:"onboard-context",title:"Write the repo's CONTEXT.md glossary and first ADR",notes:u}];function T(e,i){try{return c("git",["-C",e,"ls-tree",`${i}:.kickflip`],{encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:3e3}).split(`
|
|
11
|
-
`).some(r=>/^100755 blob \S+\tbuild(-.+)?$/.test(r))}catch{return!1}}o(T,"hasBuildScript");function y(e,{prefix:i="INT",hasBuild:s,now:r=new Date}){return b.filter(t=>!(t.needsNoBuild&&s)).map((t,a)=>{const n=`${t.id}-notes.md`;return l.writeFileSync(d.join(d.dirname(e),n),t.notes),{id:t.id,title:t.title,created:new Date(r.getTime()+a).toISOString(),
|
|
11
|
+
`).some(r=>/^100755 blob \S+\tbuild(-.+)?$/.test(r))}catch{return!1}}o(T,"hasBuildScript");function y(e,{prefix:i="INT",hasBuild:s,now:r=new Date}){return b.filter(t=>!(t.needsNoBuild&&s)).map((t,a)=>{const n=`${t.id}-notes.md`;return l.writeFileSync(d.join(d.dirname(e),n),t.notes),{id:t.id,title:t.title,created:new Date(r.getTime()+a).toISOString(),phase:"grilling",ticket:`${i}-${a+1}`,plan:n}})}o(y,"onboardingItems");export{T as hasBuildScript,y as onboardingItems};
|
package/board/prototype-page.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var d=Object.defineProperty;var r=(t,e)=>d(t,"name",{value:e,configurable:!0});import c from"node:fs/promises";import o from"node:path";import{fileURLToPath as u}from"node:url";import{takeFile as f}from"./store.mjs";const g=o.join(o.dirname(u(import.meta.url)),"public","prototype.html"),k=r(t=>String(t).replace(/[&<>"']/g,e=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[e]),"esc"),$=r(t=>`${Math.floor(t.mtimeMs).toString(36)}-${t.size.toString(36)}`,"versionOf"),p=r(t=>JSON.stringify(t).replace(/</g,"\\u003c").replace(/\u2028/g,"\\u2028").replace(/\u2029/g,"\\u2029"),"scriptJSON");async function O(t,e){const n=e.served?e.takes:await Promise.all(e.takes.map(async a=>{const l=`prototypes/${e.topic}/${a.key}.js`,i=await c.stat(f(t,e.topic,a.key)).catch(()=>null);return{key:a.key,label:a.label,src:i?.isFile()?`${l}?v=${$(i)}`:null,file:l}})),s=`${e.title} \xB7 prototype`,m={lead:"back",backHref:e.card?`./#selectedCard=${encodeURIComponent(e.card)}`:"./",title:s,controls:!1},y={prototypeTitle:k(s),prototypeHeader:`<!--header ${p(m).replace(/>/g,"\\u003e")}-->`,prototypeData:p({topic:e.topic,title:e.title,takes:n,...e.served?{served:e.served,states:e.states||null}:{}})};return(await c.readFile(g,"utf8")).replace(/\{\{(prototypeTitle|prototypeHeader|prototypeData)\}\}/g,(a,l)=>y[l])}r(O,"prototypePage");export{g as PROTOTYPE_TEMPLATE,k as esc,O as prototypePage,$ as versionOf};
|
package/board/public/app-arch.js
CHANGED
|
@@ -16,7 +16,7 @@ BoardView.part((B) => {
|
|
|
16
16
|
+ `<button type="button" class="arch-run" data-arch-open="${esc(last.key)}">Open ${esc(last.ref)}</button>`;
|
|
17
17
|
}
|
|
18
18
|
const lastHtml = last ? `<div class="arch-last"><span>Last review: <b>${esc(last.wave)}</b> · ${esc(day(last.at))}</span>`
|
|
19
|
-
+ `<span class="muted">${B.plural(last.findings, 'finding')}, ${last.built} built · <a href="#
|
|
19
|
+
+ `<span class="muted">${B.plural(last.findings, 'finding')}, ${last.built} built · <a href="#selectedCard=${encodeURIComponent(last.key)}" data-arch-open="${esc(last.key)}">${esc(last.ref)} ${LINK_ARROW}</a></span></div>` : '';
|
|
20
20
|
const waits = a.waits ? `<p class="arch-waits" role="status">${esc(a.waits)}</p>` : '';
|
|
21
21
|
return `${lastHtml}${waits}<button type="button" class="primary arch-run" data-arch-run${starting ? BoardUI.BUSY : a.waits ? ' disabled' : ''}>Run a review of ${esc(a.repo)}</button>`
|
|
22
22
|
+ '<p class="muted">Claude reads the repo and makes a wave. Its first card asks you one question per finding.</p>';
|
|
@@ -36,7 +36,7 @@ BoardView.part((B) => {
|
|
|
36
36
|
|
|
37
37
|
function openCard(key) {
|
|
38
38
|
sheet.close();
|
|
39
|
-
const hash = `#
|
|
39
|
+
const hash = `#selectedCard=${encodeURIComponent(key)}`;
|
|
40
40
|
if (location.hash === hash) window.dispatchEvent(new HashChangeEvent('hashchange'));
|
|
41
41
|
else location.hash = hash;
|
|
42
42
|
}
|
|
@@ -33,7 +33,7 @@ BoardView.part((B) => {
|
|
|
33
33
|
if (!open) return;
|
|
34
34
|
const key = open.dataset.answer;
|
|
35
35
|
markSeen(d.items.find((i) => i.key === key));
|
|
36
|
-
const hash = `#
|
|
36
|
+
const hash = `#selectedCard=${encodeURIComponent(key)}`;
|
|
37
37
|
if (location.hash === hash) history.replaceState(null, '', location.pathname + location.search);
|
|
38
38
|
location.hash = hash;
|
|
39
39
|
});
|
|
@@ -249,7 +249,7 @@ BoardView.part((B) => {
|
|
|
249
249
|
</div>`;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
const cardLink = (l) => `<a class="card-link" href="#
|
|
252
|
+
const cardLink = (l) => `<a class="card-link" href="#selectedCard=${encodeURIComponent(l.key)}">${esc(l.ref)}</a>`;
|
|
253
253
|
// A card whose branch is pushed to a plain git remote has no PR to link, so its line copies the branch name
|
|
254
254
|
// (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
|
|
255
255
|
const pushedLink = (it) => `<a href="#" data-copy-branch="${esc(it.pushed.branch)}" title="Copy the branch name">${esc(pushedWords(it))}</a>`;
|
|
@@ -269,7 +269,6 @@ BoardView.part((B) => {
|
|
|
269
269
|
const openPRs = (it) => (it.prs || []).filter((p) => p.state === 'OPEN');
|
|
270
270
|
const CONFIRM = {
|
|
271
271
|
resume: (it, ref) => `Unhold ${ref}?${it.heldUntil ? ` It's held until ${heldUntilWords(it.heldUntil)}.` : ''} It can start again once nothing blocks it.`,
|
|
272
|
-
nudge: () => 'Nudge Claude to check this implementer?',
|
|
273
272
|
// docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md
|
|
274
273
|
merge: (it, ref) => (it.mergeReady ? `Merge ${ref}? The board rebases ${it.mergeReady.branch} onto ${it.mergeReady.base} as it is now, reruns the tests if moved, and lands it.`
|
|
275
274
|
: openPRs(it).length > 1
|
|
@@ -301,24 +300,21 @@ BoardView.part((B) => {
|
|
|
301
300
|
// (docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md).
|
|
302
301
|
const close = it.lane === 'todo' && it.finishable ? ask('finish', 'Close') : '';
|
|
303
302
|
// A Todo card's Implement is in its thread box (app-threads.js; docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md).
|
|
304
|
-
return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${hold}${close}${notesButton(it)}${tryButton(it)}${actionButton(it)}${
|
|
303
|
+
return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${hold}${close}${notesButton(it)}${tryButton(it)}${actionButton(it)}${mergeButton(it)}`)}</div>`;
|
|
305
304
|
}
|
|
306
305
|
|
|
307
306
|
// board/domain.mjs actionButtonOf picks the button and resolvedBy its colour (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
|
|
307
|
+
// Every prototype round is a question on its card, so this button reaches the prototypes too, Done cards included
|
|
308
|
+
// (docs/adr/2026-11-09-1100-needs-you-button-reaches-the-prototypes.md).
|
|
308
309
|
const ACTION_BUTTONS = {
|
|
309
|
-
[BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">
|
|
310
|
+
[BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Needs you</a>`,
|
|
310
311
|
};
|
|
311
312
|
const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
|
|
312
|
-
// docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md
|
|
313
|
-
// On any card with prototypes (board/domain.mjs prototypesOf), Done ones too, so old rounds stay a tap away: it opens
|
|
314
|
-
// the viewer on the latest round, solid orange while a round waits on the owner's pick.
|
|
315
|
-
const prototypesButton = (it) => {
|
|
316
|
-
const latest = it.prototypes?.rounds.at(-1);
|
|
317
|
-
return latest ? `<a class="button${it.prototypes.open ? ' resolves' : ''}" href="${esc(latest.url)}">Prototypes</a>` : '';
|
|
318
|
-
};
|
|
319
313
|
|
|
320
|
-
//
|
|
321
|
-
|
|
314
|
+
// A bumped New card takes no second bump, which would only move it behind the earlier bumps
|
|
315
|
+
// (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md,
|
|
316
|
+
// docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md).
|
|
317
|
+
const bumpedAsk = (it) => Boolean(it.isNew && it.bumpedAt);
|
|
322
318
|
// band {step, actions} draws the card in a wave band (app-lanes.js).
|
|
323
319
|
// Its open questions asked since this browser last opened it are one more unread message: the thread's "N new
|
|
324
320
|
// questions" line (app-threads.js; docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
|
|
@@ -328,7 +324,7 @@ BoardView.part((B) => {
|
|
|
328
324
|
return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
|
|
329
325
|
<summary class="work">
|
|
330
326
|
<span class="status ${it.lane}"></span>
|
|
331
|
-
<div class="title-wrap">${workLines(it, { unread, top:
|
|
327
|
+
<div class="title-wrap">${workLines(it, { unread, top: bumpedAsk(it), ...band })}</div>
|
|
332
328
|
</summary>
|
|
333
329
|
${state.openItems.has(it.key) ? detail(it) : ''}
|
|
334
330
|
</details>`;
|
|
@@ -395,6 +391,13 @@ BoardView.part((B) => {
|
|
|
395
391
|
drawn = true;
|
|
396
392
|
}
|
|
397
393
|
|
|
394
|
+
// A card the owner opens is the URL's selected card. A card restored open on load fires toggle too, so the tap is
|
|
395
|
+
// what counts, not the toggle.
|
|
396
|
+
B.on(B.root, 'click', (ev) => {
|
|
397
|
+
const card = ev.target.closest?.('details.item > summary')?.parentNode;
|
|
398
|
+
if (card && !card.open) B.saveFragment({ selectedCard: card.dataset.key });
|
|
399
|
+
});
|
|
400
|
+
|
|
398
401
|
B.on(B.root, 'toggle', (ev) => {
|
|
399
402
|
const el = ev.target;
|
|
400
403
|
if (!el.matches?.('details.item')) return;
|
|
@@ -476,7 +479,6 @@ BoardView.part((B) => {
|
|
|
476
479
|
// A data-confirm button's tap: Hold's sheet, or its confirm, then its event. The thread box's Done comes here on ⌥↵ too.
|
|
477
480
|
async function confirmTap(b) {
|
|
478
481
|
const { key, confirm: act } = b.dataset;
|
|
479
|
-
if (b.classList.contains('nudging')) return;
|
|
480
482
|
const it = state.board.items.find((i) => i.key === key);
|
|
481
483
|
if (it && act === 'hold') { openHold(it); return; }
|
|
482
484
|
if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
|
|
@@ -484,40 +486,26 @@ BoardView.part((B) => {
|
|
|
484
486
|
// server records them (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
|
|
485
487
|
const sent = { resume: ['card-hold', { key, hold: false }], merge: ['card-merge', { key }], 'card-build': ['card-build', { key }],
|
|
486
488
|
finish: it.kind === 'ask' ? ['ask-done', { id: it.id }] : ['card-done', { key }] }[act];
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if (pend) BoardUI.pending.start(pend, (board) => { const c = board.items.find((i) => i.key === key); return !c || c.lane !== 'review' || !c.merging; });
|
|
500
|
-
try {
|
|
501
|
-
const out = await waitOn(b, B.sentHere(key, () => B.send(...sent, close)));
|
|
502
|
-
if (pend) { BoardUI.pending.answered(pend, out.board.generatedAt); B.render(); }
|
|
503
|
-
} catch (e) {
|
|
504
|
-
if (pend) { BoardUI.pending.drop(pend); B.render(); }
|
|
505
|
-
B.failed(e);
|
|
506
|
-
}
|
|
507
|
-
if (act === 'finish') B.syncSends(key);
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
// A stalled line's nudge calms it to orange with a ripple, not a spinner, until a board shows the card's status line
|
|
511
|
-
// changed, the builder no longer stalled or the card gone (style.css .nudging).
|
|
512
|
-
const id = `${key}:nudge`;
|
|
513
|
-
const { progress, at } = it.builder;
|
|
514
|
-
BoardUI.pending.start(id, (board) => { const c = board.items.find((i) => i.key === key); return !c?.builder?.stalled || c.builder.progress !== progress || c.builder.at !== at; }, B.stillWaiting);
|
|
515
|
-
B.render();
|
|
489
|
+
// Done closes its card once the server answers, before the answer's board lands, or app-core.js steady would
|
|
490
|
+
// anchor it and scroll the page after it into Done (docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md).
|
|
491
|
+
const close = act === 'finish' ? () => {
|
|
492
|
+
const card = $(`details.item[data-key="${CSS.escape(key)}"]`);
|
|
493
|
+
if (card) BoardUI.closeDetails(card);
|
|
494
|
+
state.openItems.delete(key);
|
|
495
|
+
store.set('openItems', [...state.openItems]);
|
|
496
|
+
} : null;
|
|
497
|
+
// docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md: Merge spins until a board shows the card left
|
|
498
|
+
// In Review, or no longer merging there, which is its refusal.
|
|
499
|
+
const pend = act === 'merge' ? `${key}:merge` : null;
|
|
500
|
+
if (pend) BoardUI.pending.start(pend, (board) => { const c = board.items.find((i) => i.key === key); return !c || c.lane !== 'review' || !c.merging; });
|
|
516
501
|
try {
|
|
517
|
-
const out = await B.sentHere(key, () => B.send(
|
|
518
|
-
BoardUI.pending.answered(
|
|
519
|
-
} catch
|
|
520
|
-
|
|
502
|
+
const out = await waitOn(b, B.sentHere(key, () => B.send(...sent, close)));
|
|
503
|
+
if (pend) { BoardUI.pending.answered(pend, out.board.generatedAt); B.render(); }
|
|
504
|
+
} catch (e) {
|
|
505
|
+
if (pend) { BoardUI.pending.drop(pend); B.render(); }
|
|
506
|
+
B.failed(e);
|
|
507
|
+
}
|
|
508
|
+
if (act === 'finish') B.syncSends(key);
|
|
521
509
|
}
|
|
522
510
|
|
|
523
511
|
Object.assign(B, { captureStrips, moveStrips, detail, card, confirmTap });
|
|
@@ -6,7 +6,6 @@ BoardView.part((B) => {
|
|
|
6
6
|
const { esc, escText, waitOn } = BoardUI;
|
|
7
7
|
const LANES = [
|
|
8
8
|
{ id: 'review', name: 'In Review', open: true },
|
|
9
|
-
{ id: 'stale', name: 'Stale', open: true, hideEmpty: true },
|
|
10
9
|
{ id: 'progress', name: 'In Progress', open: true },
|
|
11
10
|
{ id: 'todo', name: 'Todo', open: true },
|
|
12
11
|
{ id: 'done', name: 'Done', open: false, capped: true },
|
|
@@ -93,7 +92,6 @@ BoardView.part((B) => {
|
|
|
93
92
|
return reviewsHtml(d) + LANES.map((lane) => {
|
|
94
93
|
// Items arrive in each lane's fixed order (domain.mjs laneOrder).
|
|
95
94
|
const items = shown.filter((i) => i.lane === lane.id);
|
|
96
|
-
if (lane.hideEmpty && !items.length) return '';
|
|
97
95
|
const expanded = laneState[lane.id] ?? lane.open;
|
|
98
96
|
const open = items.filter((i) => !i.fold);
|
|
99
97
|
const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : waveGroups(lane.id, open)) + waveFolds(d, items);
|
|
@@ -108,19 +106,18 @@ BoardView.part((B) => {
|
|
|
108
106
|
// docs/adr/2026-11-05-0215-every-lane-groups-its-cards-by-wave.md
|
|
109
107
|
// Every lane but Todo groups its cards under a header per open wave (state.board.waveBands) that has cards there, in
|
|
110
108
|
// band order, then the cards in no wave under No wave. A lane with no card in a wave lists its cards plainly. A header
|
|
111
|
-
// counts its wave's cards in Todo, In Progress
|
|
109
|
+
// counts its wave's cards in Todo, In Progress, In Review and Done as x/y/k/z, from every lane, with
|
|
112
110
|
// the lane's own number bold in that lane's colour (style.css: review blue, progress orange, Todo and Done gray) and a tooltip that spells the counts out.
|
|
113
111
|
// A tap on the counts opens the tooltip (countsShown, the header's lane/wave); a second tap or a tap elsewhere closes it.
|
|
114
112
|
let countsShown = null;
|
|
115
113
|
const COUNT_LANES = [['todo', 'in Todo'], ['progress', 'in progress'], ['review', 'in review'], ['done', 'done']];
|
|
116
114
|
function waveCounts(band, laneId) {
|
|
117
|
-
const lanes = state.board.items.reduce((m, i) => m.set(i.key, i.lane
|
|
115
|
+
const lanes = state.board.items.reduce((m, i) => m.set(i.key, i.lane), new Map());
|
|
118
116
|
const n = COUNT_LANES.map(([id]) => band.keys.filter((k) => lanes.get(k) === id).length);
|
|
119
|
-
const here = laneId === 'stale' ? 'progress' : laneId;
|
|
120
117
|
const id = `${laneId}/${band.slug}`;
|
|
121
118
|
const spelled = COUNT_LANES.map(([, word], at) => `${n[at]} ${word}`);
|
|
122
119
|
const tip = `<span class="wb-tip" role="tooltip">${spelled.map((t) => `<div>${t}</div>`).join('')}</span>`;
|
|
123
|
-
return `<button type="button" class="wb-counts${countsShown === id ? ' open' : ''}" data-counts="${esc(id)}" aria-label="${spelled.join(', ')}">${COUNT_LANES.map(([lane], at) => `<span${lane ===
|
|
120
|
+
return `<button type="button" class="wb-counts${countsShown === id ? ' open' : ''}" data-counts="${esc(id)}" aria-label="${spelled.join(', ')}">${COUNT_LANES.map(([lane], at) => `<span${lane === laneId ? ` class="wv-cur wv-${lane}"` : ''}>${n[at]}</span>`).join('<span aria-hidden="true">/</span>')}${tip}</button>`;
|
|
124
121
|
}
|
|
125
122
|
function waveGroups(laneId, cards) {
|
|
126
123
|
const bands = state.board.waveBands || [];
|
|
@@ -142,7 +139,7 @@ BoardView.part((B) => {
|
|
|
142
139
|
let goalShown = null;
|
|
143
140
|
const bandStore = (slug) => FOLDS[`band:${slug}`].store;
|
|
144
141
|
const bandOpen = (band, first) => store.get(bandStore(band.slug), band.slug === first);
|
|
145
|
-
const LANE_WORDS = { progress: 'in progress', review: 'in review'
|
|
142
|
+
const LANE_WORDS = { progress: 'in progress', review: 'in review' };
|
|
146
143
|
const DOTS = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>';
|
|
147
144
|
|
|
148
145
|
// Todo's wave bands and loose cards, in the order they arrive (domain.mjs laneOrder).
|
|
@@ -173,7 +170,7 @@ BoardView.part((B) => {
|
|
|
173
170
|
+ `<span class="wb-label">${name} <span class="count">${todo.length}</span>${progress}</span>`
|
|
174
171
|
+ waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}</span>${go}</span>${pop}</div>`;
|
|
175
172
|
if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
|
|
176
|
-
const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#
|
|
173
|
+
const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#selectedCard=${encodeURIComponent(i.key)}">${esc(i.ref)}</a>`).join(', ')}</div>` : '';
|
|
177
174
|
const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
|
|
178
175
|
const actions = (it, at) => (band.kind !== 'lineup' ? null : cardMenu(band.slug, it.key, at, todo.length));
|
|
179
176
|
return `<section class="wb" data-band="${esc(band.slug)}" data-open>${head}<div class="wb-body">${doneRow}${todo.map((it, at) => B.card(it, { step: step(it), actions: actions(it, at) })).join('')}</div></section>`;
|
|
@@ -191,7 +188,7 @@ BoardView.part((B) => {
|
|
|
191
188
|
+ (shown ? `<div class="wb-menu" role="menu">${item(-1, '↑ Move up')}${item(1, '↓ Move down')}</div>` : '') + '</div>';
|
|
192
189
|
}
|
|
193
190
|
|
|
194
|
-
// Opens the band holding card key or named slug, for a #
|
|
191
|
+
// Opens the band holding card key or named slug, for a #selectedCard= link or a Needs you line.
|
|
195
192
|
function openBand({ key, slug }) {
|
|
196
193
|
const band = (state.board?.waveBands || []).find((b) => b.slug === slug || b.keys.includes(key));
|
|
197
194
|
if (band) store.set(bandStore(band.slug), true);
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
// The board view's Needs you (domain.mjs needsOf), led by the board session's pending prompts, answered in place
|
|
2
2
|
// (prompts.js). It draws twice, and the stylesheet shows one by width
|
|
3
3
|
// (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md):
|
|
4
|
-
// - on a phone, #needsDeck at the top of the board: one thing at a time, a question's
|
|
5
|
-
//
|
|
4
|
+
// - on a phone, #needsDeck at the top of the board: one thing at a time, a question's option marked on the first tap,
|
|
5
|
+
// turning into Send “<label>”, and sent with the question's drafted note and files on a tap on it
|
|
6
|
+
// (docs/adr/2026-11-07-1212-a-deck-option-sends-on-a-second-tap.md), a prompt's buttons answering it, Prev and Next
|
|
7
|
+
// beside a dot for each thing waiting, and All open questions at its foot;
|
|
6
8
|
// - from 900px, #waiting, one line each, in the pane beside the lanes, over Waiting on you mounted there
|
|
7
9
|
// (view-questions.js, embedded) while the width lasts. Waiting on you shows the questions there, so #waiting leaves out
|
|
8
10
|
// a line that only asks questions, and with nothing else left the orange section is gone. Every questions URL lands
|
|
9
|
-
// here at that width (shell.js go, focusQuestion), and a pill
|
|
10
|
-
// (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md).
|
|
11
|
+
// here at that width (shell.js go, focusQuestion), and two taps on a pill focus its card on the board
|
|
12
|
+
// (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md). A prototype opens in the pane too, in place of
|
|
13
|
+
// Waiting on you until its ‹ Open questions (openViewer).
|
|
11
14
|
// Below 900px the header's ? opens Waiting on you, with the open-question count as its badge.
|
|
12
15
|
// Needs app-core.js.
|
|
13
16
|
BoardView.part((B) => {
|
|
14
17
|
const { $, plural, state, toast } = B;
|
|
15
|
-
const { esc, escText, promptItem, choiceLabel, isPrototype, getDraft, setDraft } = BoardUI;
|
|
18
|
+
const { esc, escText, promptItem, choiceLabel, isPrototype, getDraft, setDraft, noteOf, waitOn } = BoardUI;
|
|
16
19
|
|
|
17
20
|
const prompts = PromptAnswers.wire(B, {
|
|
18
21
|
list: () => (state.board?.needs || []).filter((line) => line.prompt).map((line) => line.prompt),
|
|
@@ -29,6 +32,7 @@ BoardView.part((B) => {
|
|
|
29
32
|
renderList(wide.matches ? needs.filter((line) => !line.questions || line.ready || line.action) : needs);
|
|
30
33
|
renderDeck(d, needs);
|
|
31
34
|
renderAsk(d);
|
|
35
|
+
if (viewer) paintViewer(d);
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
// The header's ? below 900px, which opens Waiting on you, carries the open-question count as its badge.
|
|
@@ -80,11 +84,17 @@ BoardView.part((B) => {
|
|
|
80
84
|
html: `<h3>${line.key ? `<a href="${esc(line.url)}">${escText(title)}</a>` : escText(title)}</h3>${line.review ? '<p class="ctx">review held for you</p>' : ''}<div class="deck-acts"><a class="button primary" href="${esc(href)}"${attrs}>${esc(label)}</a></div>` }];
|
|
81
85
|
});
|
|
82
86
|
}
|
|
87
|
+
// The marked option, the question's drafted pick, reads Send “<label>”.
|
|
83
88
|
const questionHtml = (q) => {
|
|
84
|
-
const
|
|
89
|
+
const marked = getDraft(q.id).choice;
|
|
90
|
+
const optionHtml = (o) => {
|
|
91
|
+
const armed = o.id === marked;
|
|
92
|
+
const label = armed ? `Send “${esc(o.label)}”` : `${esc(choiceLabel(q, o.id))}${o.recommended ? ' <span class="rec-word">recommended</span>' : ''}`;
|
|
93
|
+
return `<button type="button" class="${[o.recommended && 'rec', armed && 'armed'].filter(Boolean).join(' ')}" aria-pressed="${armed}" data-deck-pick="${esc(o.id)}"><span class="lbl">${label}</span>${o.detail ? `<small>${escText(o.detail)}</small>` : ''}</button>`;
|
|
94
|
+
};
|
|
85
95
|
return `<h3>${escText(q.question)}</h3>${q.context ? `<p class="ctx">${escText(q.context)}</p>` : ''}
|
|
86
96
|
${isPrototype(q.url) ? `<a class="button deck-proto" href="${esc(q.url)}">View prototypes</a>` : ''}
|
|
87
|
-
<div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map(
|
|
97
|
+
<div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map(optionHtml).join('')}</div>`;
|
|
88
98
|
};
|
|
89
99
|
|
|
90
100
|
// The thing on screen, by id, so an arrival or a repaint keeps it; once it is gone the deck shows what took its place.
|
|
@@ -98,46 +108,73 @@ BoardView.part((B) => {
|
|
|
98
108
|
if (!deck.length) { el.innerHTML = ''; showing = null; return; }
|
|
99
109
|
const now = deck.find((x) => x.id === showing) || deck[Math.min(Math.max(before, 0), deck.length - 1)];
|
|
100
110
|
showing = now.id;
|
|
111
|
+
const at = deck.indexOf(now);
|
|
101
112
|
const open = (d.owner || []).filter((q) => q.state === 'open').length;
|
|
102
113
|
const drafted = (d.owner || []).filter((q) => q.state === 'open' && 'choice' in getDraft(q.id)).length;
|
|
103
114
|
BoardUI.setHTML(el, `<div class="deck-head"><h2>Needs you</h2><span class="deck-n">${deck.length}</span></div>
|
|
104
115
|
<div class="deck-card" data-deck="${esc(now.id)}"><p class="deck-what">${escText(now.what)}</p>${now.html}</div>
|
|
105
|
-
${deck.length > 1 ? `<div class="deck-bar"><span class="deck-dots" aria-hidden="true">${deck.map((x) => `<i${x === now ? ' class="on"' : ''}></i>`).join('')}</span><button type="button" class="ghost" data-deck-
|
|
116
|
+
${deck.length > 1 ? `<div class="deck-bar"><button type="button" class="ghost" data-deck-prev${at === 0 ? ' disabled' : ''}>← Prev</button><span class="deck-dots" aria-hidden="true">${deck.map((x) => `<i${x === now ? ' class="on"' : ''}></i>`).join('')}</span><button type="button" class="ghost" data-deck-next${at === deck.length - 1 ? ' disabled' : ''}>Next →</button></div>` : ''}
|
|
106
117
|
${open ? `<a class="deck-more" href="questions.html">All open questions · ${open}${drafted ? ` · ${drafted} drafted, Send there` : ''}</a>` : ''}`);
|
|
107
118
|
}
|
|
108
119
|
|
|
120
|
+
// The deck's index one step from the thing on screen; the ends hold.
|
|
121
|
+
const stepFrom = (step) => Math.min(Math.max(deck.findIndex((x) => x.id === showing) + step, 0), deck.length - 1);
|
|
109
122
|
B.on(B.root, 'click', (ev) => {
|
|
110
123
|
if (!ev.target.closest?.('#needsDeck')) return;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
showing = deck[(
|
|
124
|
+
const step = ev.target.closest('[data-deck-prev]') ? -1 : ev.target.closest('[data-deck-next]') ? 1 : 0;
|
|
125
|
+
if (step) {
|
|
126
|
+
showing = deck[stepFrom(step)].id;
|
|
114
127
|
renderNeeds(state.board);
|
|
115
128
|
return;
|
|
116
129
|
}
|
|
117
130
|
const opt = ev.target.closest('button[data-deck-pick]');
|
|
118
131
|
if (!opt) return;
|
|
119
132
|
const id = opt.closest('[data-deck]').dataset.deck.slice(2);
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
133
|
+
const choice = opt.dataset.deckPick;
|
|
134
|
+
const draft = getDraft(id);
|
|
135
|
+
if (draft.choice !== choice) {
|
|
136
|
+
setDraft(id, { ...draft, choice });
|
|
137
|
+
renderNeeds(state.board);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
sendAnswer((state.board.owner || []).find((q) => q.id === id), choice, opt);
|
|
124
141
|
});
|
|
125
142
|
|
|
143
|
+
// The marked option's tap sends it with the question's drafted note and files, as the questions page's Send does, and
|
|
144
|
+
// spins until the board shows the question answered, which takes it off the deck. Files still uploading hold it.
|
|
145
|
+
async function sendAnswer(q, choice, button) {
|
|
146
|
+
const composer = 'q:' + q.id;
|
|
147
|
+
if (BoardAttach.busy(composer, B.site.root)) { toast('Wait for the upload to finish'); return; }
|
|
148
|
+
const files = BoardAttach.files(composer, B.site.root);
|
|
149
|
+
const answer = { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: noteOf(q, getDraft(q.id)).trim(), ...(files.length ? { files } : {}) };
|
|
150
|
+
const sent = () => { setDraft(q.id, {}); BoardAttach.clear(composer, B.site.root); };
|
|
151
|
+
try {
|
|
152
|
+
await waitOn(button, BoardUI.pending.within(B.send('owner-answers', { answers: [answer] }, sent), B.stillWaiting));
|
|
153
|
+
} catch (e) { B.failed(e); }
|
|
154
|
+
}
|
|
155
|
+
|
|
126
156
|
// From 900px, Waiting on you in the pane under the Needs you lines, mounted while the width lasts.
|
|
127
157
|
let questions = null;
|
|
158
|
+
// Two taps on a pane pill focus its topic on the board: item:<key> is a card (an ask is one, ask:<id>),
|
|
159
|
+
// wave:<slug> a band; any other topic, or one the board doesn't show, does nothing.
|
|
160
|
+
function openOnBoard(id) {
|
|
161
|
+
if (id.startsWith('item:')) B.focusOnBoard({ key: id.slice(5) });
|
|
162
|
+
else if (id.startsWith('wave:')) B.focusOnBoard({ slug: id.slice(5) });
|
|
163
|
+
}
|
|
128
164
|
function fitPane() {
|
|
129
165
|
if (wide.matches === Boolean(questions)) return;
|
|
130
166
|
if (state.board) renderNeeds(state.board);
|
|
131
|
-
if (questions) { questions.unmount(); questions.slot.remove(); questions = null; return; }
|
|
167
|
+
if (questions) { closeViewer(); questions.unmount(); questions.slot.remove(); questions = null; return; }
|
|
132
168
|
$('#needsPane').insertAdjacentHTML('beforeend', `<div class="view-questions needs-questions">${AppViews.questions.markup({ embedded: true })}</div>`);
|
|
133
169
|
const slot = $('#needsPane .needs-questions');
|
|
134
|
-
questions = Object.assign(AppViews.questions.create(slot, B.site.root, { embedded: true }), { slot });
|
|
170
|
+
questions = Object.assign(AppViews.questions.create(slot, B.site.root, { embedded: true, openOnBoard, onTopic: (id) => B.saveFragment({ openItemsCard: id }) }), { slot });
|
|
135
171
|
questions.mount();
|
|
136
172
|
}
|
|
137
173
|
// The shell's one rule for a questions URL from 900px (shell.js go): it shows the board and asks here for the question
|
|
138
174
|
// or topic id, or null for none. The pane opens its topic, resolved or not, and focuses its pill → false while there
|
|
139
175
|
// is no pane or it hasn't loaded, so the shell asks again.
|
|
140
176
|
function focusQuestion(id) {
|
|
177
|
+
closeViewer();
|
|
141
178
|
const topic = (state.board?.ownerTopics || []).find((t) => t.id === id || t.questions.includes(id));
|
|
142
179
|
if (!questions?.B.openTopic(topic?.id ?? id)) return false;
|
|
143
180
|
const pill = questions.slot.querySelector('.topics button[aria-pressed="true"]');
|
|
@@ -145,18 +182,62 @@ BoardView.part((B) => {
|
|
|
145
182
|
pill?.scrollIntoView({ block: 'nearest' });
|
|
146
183
|
return true;
|
|
147
184
|
}
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
185
|
+
|
|
186
|
+
// From 900px a prototype opens in the pane (docs/adr/2026-11-07-2030-on-desktop-the-prototype-viewer-opens-in-the-right-pane.md,
|
|
187
|
+
// shell.js go): a top bar of ‹ Open questions, ⤢ and the card's Round pill over the prototype page framed at the
|
|
188
|
+
// pane's width, which shows its footer at the bottom of the pane (prototype-switcher.js .ps-pane). ⤢ widens the pane
|
|
189
|
+
// over the lanes and back. ‹ returns to Waiting on you with the round's question focused, as Choose this does through
|
|
190
|
+
// the shell. viewer: {url, q, widened, sheet} while one is open. → false while there is no pane, so the shell asks again.
|
|
191
|
+
let viewer = null;
|
|
192
|
+
function openViewer(url) {
|
|
193
|
+
if (!questions) return false;
|
|
194
|
+
viewer = { url, q: new URL(url, location.href).searchParams.get('q'), widened: viewer?.widened ?? false, sheet: false };
|
|
195
|
+
paintViewer();
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
// board: the board download it shows, which renderNeeds passes before state.board holds it.
|
|
199
|
+
function paintViewer(board = state.board) {
|
|
200
|
+
const pane = $('#needsPane');
|
|
201
|
+
pane.classList.toggle('viewing', Boolean(viewer));
|
|
202
|
+
pane.classList.toggle('widened', Boolean(viewer?.widened));
|
|
203
|
+
let box = pane.querySelector('.pane-viewer');
|
|
204
|
+
if (!viewer) { box?.remove(); return; }
|
|
205
|
+
if (!box) {
|
|
206
|
+
pane.insertAdjacentHTML('beforeend', '<div class="pane-viewer"><div class="pv-top"></div><iframe class="pv-frame" title="Prototype"></iframe></div>');
|
|
207
|
+
box = pane.querySelector('.pane-viewer');
|
|
208
|
+
}
|
|
209
|
+
const card = (board?.items || []).find((it) => it.prototypes?.rounds?.some((r) => r.q === viewer.q));
|
|
210
|
+
const rounds = card?.prototypes.rounds;
|
|
211
|
+
box.querySelector('.pv-top').innerHTML = `<button type="button" class="pv-back" data-pv="back">‹ Open questions</button><span class="pv-grow"></span>
|
|
212
|
+
<button type="button" class="pv-widen" data-pv="widen" aria-pressed="${viewer.widened}" aria-label="Widen over the lanes" title="Widen over the lanes">⤢</button>${BoardUI.roundPill(rounds, viewer.q)}`;
|
|
213
|
+
box.querySelector('.ps-sheet')?.remove();
|
|
214
|
+
if (viewer.sheet && rounds) box.insertAdjacentHTML('beforeend', BoardUI.roundSheet({ ref: card.ref, rounds, qid: viewer.q }));
|
|
215
|
+
const frame = box.querySelector('iframe.pv-frame');
|
|
216
|
+
if (frame.getAttribute('src') !== viewer.url) frame.setAttribute('src', viewer.url);
|
|
217
|
+
}
|
|
218
|
+
function closeViewer() {
|
|
219
|
+
if (!viewer) return;
|
|
220
|
+
viewer = null;
|
|
221
|
+
paintViewer();
|
|
222
|
+
}
|
|
223
|
+
B.on(B.root, 'click', (ev) => {
|
|
224
|
+
if (!viewer || !ev.target.closest?.('#needsPane .pane-viewer')) return;
|
|
225
|
+
const round = ev.target.closest('[data-ps-round]')?.dataset.psRound;
|
|
226
|
+
if (round) { openViewer(round); return; }
|
|
227
|
+
const act = ev.target.closest('[data-pv], [data-ps-top]');
|
|
228
|
+
if (!act) return;
|
|
229
|
+
if (act.dataset.pv === 'back') { focusQuestion(viewer.q); return; }
|
|
230
|
+
if (act.dataset.pv === 'widen') viewer.widened = !viewer.widened;
|
|
231
|
+
else viewer.sheet = !viewer.sheet;
|
|
232
|
+
paintViewer();
|
|
156
233
|
});
|
|
234
|
+
// A push to a card's thread (#item=<key>&reply, board/store.mjs pushUrl), the switcher's for a question already picked
|
|
235
|
+
// among them, closes the viewer, so the thread it sent the owner to shows.
|
|
236
|
+
B.on(window, 'hashchange', () => { if (/^#item=[^&]+&reply$/.test(location.hash)) closeViewer(); });
|
|
237
|
+
|
|
157
238
|
fitPane();
|
|
158
239
|
B.on(wide, 'change', fitPane);
|
|
159
240
|
B.atUnmount(() => { questions?.unmount(); questions = null; });
|
|
160
241
|
|
|
161
|
-
Object.assign(B, { renderNeeds, focusQuestion });
|
|
242
|
+
Object.assign(B, { renderNeeds, focusQuestion, openViewer });
|
|
162
243
|
});
|