@lovek4d/kickflip 0.7.0 → 0.7.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 +43 -43
- package/SKILL.md +12 -10
- package/bin/event-lines.mjs +1 -1
- package/bin/kickflip.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +87 -87
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -8
- package/board/hub-lines.mjs +1 -0
- package/board/onboarding.mjs +1 -1
- package/board/public/app-arch.js +1 -1
- package/board/public/app-cards.js +5 -6
- package/board/public/app-header.js +1 -1
- package/board/public/app-lanes.js +37 -59
- package/board/public/app-needs.js +16 -11
- package/board/public/app-reader.js +2 -2
- package/board/public/app.js +2 -2
- package/board/public/dialogs.js +1 -1
- package/board/public/hub-kit.js +1 -1
- package/board/public/hub.css +2 -0
- package/board/public/linear-key.js +1 -1
- package/board/public/new-task.js +1 -1
- package/board/public/prototype-kit.js +2 -5
- package/board/public/router.js +1 -1
- package/board/public/sidebar.css +2 -2
- package/board/public/sidebar.js +2 -2
- package/board/public/style.css +20 -29
- package/board/public/ui-cards.js +19 -17
- package/board/public/ui-hub.js +1 -5
- package/board/public/ui-links.js +5 -5
- package/board/public/ui-order.js +5 -6
- package/board/public/ui-questions.js +12 -10
- package/board/public/ui-threads.js +10 -7
- package/board/public/ui.js +11 -2
- package/board/public/view-hub-settings.js +28 -31
- package/board/public/view-hub.js +7 -9
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +18 -5
- package/board/public/view-settings.js +3 -3
- package/board/render-check.mjs +10 -10
- package/board/review.mjs +1 -1
- package/board/server.mjs +18 -19
- package/board/stop.mjs +1 -1
- package/board/store.mjs +26 -25
- package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
- package/hub/awake.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/try.mjs +6 -7
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/usage-gate.mjs +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var b=Object.defineProperty;var t=(e,o)=>b(e,"name",{value:o,configurable:!0});import w from"node:os";const h=100,P=2e3,$=/(?:~|\/)[^\s:;,()'"]*\/([^\s:;,()'"/]+)/g;function r(e,o,a=""){const d=h-e.length-a.length;let l=String(o??"").split(w.homedir()).join("~").replace(/\s+/g," ").trim();return l.length>d&&(l=l.replace($,"\u2026/$1")),l.length<=d?e+l+a:`${e}${l.slice(0,d-1).trimEnd()}\u2026${a.replace(/^\./,"")}`}t(r,"fit");const i="Held",m="Resumed",k="Blocked",s="Fault",c="move",g="questions",u="Implementation requested",f="Prototype requested",n="Marked done",y="prototype",x="owner/a-long-repo-name#10000",v="Wed, 11/30, 12:00 PM",p={"host-busy":{mark:i,text:t(()=>"Host is busy, work will resume when the host recovers","text")},"planner-held":{mark:i,text:t(({kind:e})=>`The ${e} waits and starts when the hold lifts.`,"text"),longest:{kind:y}},paused:{mark:i,text:t(({phase:e,words:o,clear:a})=>r(`Paused${e?` at ${e}`:""}: `,o,`. It resumes once ${a}.`),"text"),longest:{phase:"building",clear:"the usage pause lifts"}},"run-stopped":{mark:i,text:t(({words:e})=>e,"text"),longest:{words:"build stopped by orchestrator"}},"land-moved-first":{mark:c,text:t(({words:e})=>e,"text"),longest:{words:"land moved first by orchestrator"}},"builder-stopped":{mark:i,text:t(()=>"Stopped its builder.","text")},"land-waiting":{mark:i,text:t(({card:e})=>r("Waiting for ",e," to land"),"text")},resumed:{mark:m,text:t(({clear:e})=>`Resumed: ${e}.`,"text"),longest:{clear:"the usage pause lifted"}},"hold-ended":{mark:m,text:t(({at:e})=>`Resumed: its hold until ${e} ended.`,"text"),longest:{at:v}},unblocked:{mark:m,text:t(({lifted:e})=>r("Unblocked: ",e,"."),"text")},"unblocked-done":{mark:m,text:t(({card:e})=>r("Unblocked: ",e," is done."),"text")},"blocked-pausing":{mark:k,text:t(({blocked:e})=>r("Blocked ",e,": its builder pauses at its next stopping point and it stays In Progress."),"text")},"blocked-stays":{mark:k,text:t(({blocked:e})=>r("Blocked ",e,": it stays In Progress."),"text")},"cant-start-yet":{mark:k,text:t(({names:e})=>r("Can\u2019t start yet: waits on ",e,". Bumped to the top of Todo."),"text")},"merge-blocked":{mark:k,text:t(({blocked:e})=>r("Not merged: blocked ",e,"."),"text")},pulled:{mark:u,text:t(()=>"Pulled in: an implementer slot was free.","text")},"helper-asked":{mark:u,text:t(({agent:e,writes:o})=>r("Asked for helper ",e,o?", which writes code":""),"text"),longest:{writes:!0}},"arch-review":{mark:u,text:t(({card:e})=>r("Started an architecture review of ",e,"; its report becomes the notes"),"text")},"prototype-after":{mark:f,text:t(()=>"Prototype round asked: it moves to prototyping once every question is answered.","text")},"prototype-round":{mark:f,text:t(()=>"You asked for a prototype round, so the board moved the card to prototyping.","text")},"new-question":{mark:g,text:t(()=>"A new question on it, so it is grilling until you answer.","text")},"screen-timeout":{mark:g,text:t(()=>"Questions passed to you unscreened: the orchestrator didn\u2019t screen them in 15 minutes.","text")},"ask-moved":{mark:c,text:t(({board:e})=>r("Moved to ",e,"."),"text")},"pr-found":{mark:c,text:t(({pr:e,branch:o})=>r(`Found PR #${e} on branch `,o,"."),"text"),longest:{pr:1e5}},"ci-passed":{mark:n,text:t(({pr:e})=>`CI passed, so PR #${e} is marked ready for review.`,"text"),longest:{pr:1e5}},"worktree-remade":{mark:c,text:t(()=>"Remade its leftover worktree: it held no commits or changes.","text")},merged:{mark:n,text:t(({words:e})=>r("",e),"text")},"already-merged":{mark:n,text:t(({prs:e,many:o})=>r(`PR${o?"s":""} `,e,` ${o?"were":"was"} already merged.`),"text"),longest:{many:!0}},"ask-moved-closed":{mark:n,text:t(({board:e})=>r("Closed: its ask moved to ",e,"."),"text")},"check-passed":{mark:n,text:t(({found:e})=>r("Check passed: ",e,". Closed."),"text")},"findings-settled":{mark:n,text:t(({filed:e})=>r("Every finding is settled: ",e?`filed ${e}`:"none filed","."),"text")},"branch-merged":{mark:n,text:t(({branch:e,into:o,host:a})=>r("",`${e} is merged into ${o} on ${a}`,", so this card is Done."),"text")},"branch-gone":{mark:n,text:t(({branch:e,host:o})=>r("",`${e} is gone from ${o}`,", so this card is Done."),"text")},"prs-closed":{mark:n,text:t(({prs:e})=>r("Closed: ",e," closed unmerged."),"text"),longest:{prs:`${x}, ${x}`}},"landed-on-merge":{mark:n,text:t(({base:e})=>r("Landed on ",e," on your Merge."),"text")},"lander-handoff":{mark:u,text:t(({why:e})=>r("Not merged yet: ",e,". Its builder resumes to fix it and land it."),"text")},"builder-resumed-to-land":{mark:u,text:t(({session:e})=>r("Builder resumed to land, session ",e),"text")},"build-refused":{mark:s,fault:!0,text:t(({why:e})=>r("Couldn't start implementing: ",e),"text")},"planner-failed":{mark:s,fault:!0,text:t(({kind:e,error:o})=>r(`Couldn't start the ${e}: `,o),"text"),longest:{kind:"visual agent"}},"helper-failed":{mark:s,fault:!0,text:t(({name:e,error:o})=>r(`Couldn't start helper ${e}: `,o),"text"),longest:{name:"a-helper-name-of-24-char"}},"exited-twice":{mark:s,fault:!0,text:t(()=>"Its builder exited twice unfinished; it reads stopped until the orchestrator takes it up.","text")},"resume-failed":{mark:s,fault:!0,text:t(({error:e})=>r("Couldn't resume the builder headless: ",e),"text")},"relaunch-failed":{mark:s,fault:!0,text:t(({error:e})=>r("Couldn't relaunch the builder headless: ",e),"text")},"lander-failed":{mark:s,fault:!0,text:t(({error:e})=>r("Couldn't launch the lander; land it by hand: ",e),"text")},"rebase-failed":{mark:s,fault:!0,text:t(({pr:e,error:o})=>r(`PR #${e} conflicts, but the rebase run couldn't start: `,o),"text"),longest:{pr:1e5}},"rebase-launch-failed":{mark:s,fault:!0,text:t(({error:e})=>r("Couldn't launch the rebase run; it reads stopped: ",e),"text")},"merge-refused":{mark:s,fault:!0,text:t(({why:e})=>r("Not merged: ",e,"."),"text")},"land-failed":{mark:s,fault:!0,text:t(({why:e})=>r("Not merged: ",e,". Tap Merge again."),"text")},"push-failed":{mark:s,fault:!0,text:t(({base:e,error:o})=>r(`Landed, but ${e} didn't reach origin: `,o),"text"),longest:{base:"a-long-base-branch"}},"answers-failed":{mark:s,fault:!0,text:t(({what:e,error:o})=>r(`Answers recorded, but ${e} failed: `,o),"text"),longest:{what:"writing them into its notes"}},"notes-missing":{mark:s,fault:!0,text:t(()=>"Its notes file was missing, so the board started a new one.","text")},"notes-shared":{mark:s,fault:!0,text:t(({card:e})=>r("It shared its notes file with ",e,", so the board gave it its own copy."),"text")},"check-broken":{mark:s,fault:!0,text:t(({error:e})=>r("The owner step's check can't run, so it stopped: ",e),"text")},"run-failed":{mark:s,fault:!0,text:t(({kind:e,code:o,why:a})=>r(`${e} failed (exit ${o})`,a?`: ${a}`:""),"text"),longest:{kind:"Serve",code:255}}};function C(e,o={}){const a=p[e];if(!a)throw new Error(`no status line kind "${e}"`);const d=a.text(o);if(!d||d.length>h)throw new Error(`the "${e}" status line runs to ${d.length} characters, past ${h}: ${d}`);return d}t(C,"hubText");const T=t(e=>({mark:p[e.line]?.mark||"",fault:!!p[e.line]?.fault}),"hubLineOf"),R="hub";export{h as BRIEF_LINE_LIMIT,R as HUB,p as HUB_LINES,P as MESSAGE_LIMIT,r as fit,T as hubLineOf,C as hubText};
|
package/board/onboarding.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var h=Object.defineProperty;var o=(e,i)=>h(e,"name",{value:i,configurable:!0});import l from"node:fs";import d from"node:path";import{execFileSync as c}from"node:child_process";const p="# Prepare .kickflip/build\n\nThe hub builds and serves a card's version of this repo by running one script, `.kickflip/build <
|
|
1
|
+
var h=Object.defineProperty;var o=(e,i)=>h(e,"name",{value:i,configurable:!0});import l from"node:fs";import d from"node:path";import{execFileSync as c}from"node:child_process";const p="# Prepare .kickflip/build\n\nThe hub builds and serves a card's version of this repo by running one script, `.kickflip/build <file>/<id> <worktree>`, in the card's worktree. This board has none on main yet.\n\nContract (docs/adr/2026-11-01-1846-a-boards-build-script-serves-or-builds-each-card-and-main.md):\n- Print exactly `port <n>` on stdout to serve a website on that port.\n- Or exit 0 with no port for an app build, publishing the install with `board.mjs item set --install --install-sha`.\n- The file is a regular file in the repo, executable, writable by no one but its owner.\n\nWhat this card does:\n- Look at the repo for what it builds: a website or server, an iOS or Android app, a CLI, a library with nothing to serve.\n- If it finds one type, write `.kickflip/build` for it.\n- If it finds several, ask the owner which the board should serve, write one `.kickflip/build-<target>` for each type the owner wants (for example `build-web`, `build-ios`), and set this board's Build target in Board Settings to the one picked.\n- If nothing here builds, say so and write nothing.\n",u=`# Write CONTEXT.md and the first ADR
|
|
2
2
|
|
|
3
3
|
Cards in this repo are grilled and built against its own words and decisions. This card writes them down.
|
|
4
4
|
|
package/board/public/app-arch.js
CHANGED
|
@@ -65,7 +65,7 @@ BoardView.part((B) => {
|
|
|
65
65
|
sheet.showModal();
|
|
66
66
|
});
|
|
67
67
|
B.on(sheet, 'click', (ev) => {
|
|
68
|
-
if (ev
|
|
68
|
+
if (BoardUI.onBackdrop(ev, sheet) || ev.target.closest('#archClose')) { sheet.close(); return; }
|
|
69
69
|
const open = ev.target.closest('[data-arch-open]');
|
|
70
70
|
if (open) { ev.preventDefault(); openCard(open.dataset.archOpen); return; }
|
|
71
71
|
if (ev.target.closest('[data-arch-run]')) run();
|
|
@@ -76,7 +76,7 @@ BoardView.part((B) => {
|
|
|
76
76
|
loadMainLog();
|
|
77
77
|
});
|
|
78
78
|
B.on($('#mainLogClose'), 'click', closeMainLog);
|
|
79
|
-
B.on(mainSheet, 'click', (ev) => { if (ev
|
|
79
|
+
B.on(mainSheet, 'click', (ev) => { if (BoardUI.onBackdrop(ev, mainSheet)) closeMainLog(); });
|
|
80
80
|
B.on(mainSheet, 'cancel', (ev) => { ev.preventDefault(); closeMainLog(); });
|
|
81
81
|
B.on(mainRetry, 'click', async () => {
|
|
82
82
|
if (mainRetry.disabled) return;
|
|
@@ -179,7 +179,7 @@ BoardView.part((B) => {
|
|
|
179
179
|
}
|
|
180
180
|
B.on($('#holdClose'), 'click', closeHold);
|
|
181
181
|
B.on(holdSheet, 'click', (ev) => {
|
|
182
|
-
if (ev
|
|
182
|
+
if (BoardUI.onBackdrop(ev, holdSheet)) { closeHold(); return; }
|
|
183
183
|
const chip = ev.target.closest('[data-hold-for]');
|
|
184
184
|
if (chip) { hold(chip.dataset.holdFor, chip.value); return; }
|
|
185
185
|
const input = ev.target.closest('[data-hold-at]');
|
|
@@ -215,7 +215,7 @@ BoardView.part((B) => {
|
|
|
215
215
|
const pr = it.pr;
|
|
216
216
|
const ctx = links(it.key);
|
|
217
217
|
const rows = [];
|
|
218
|
-
rows.push(['Wave', escText(it.
|
|
218
|
+
if (it.wave) rows.push(['Wave', escText(it.wave.title)]);
|
|
219
219
|
if (pr) {
|
|
220
220
|
const ci = pr.checks ? `${pr.checks.state}${pr.checks.fail ? ` (${pr.checks.fail} failing)` : pr.checks.pending ? ` (${pr.checks.pending} running)` : ''}` : 'unknown';
|
|
221
221
|
if (pr.state === 'OPEN') {
|
|
@@ -315,16 +315,15 @@ BoardView.part((B) => {
|
|
|
315
315
|
// (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md,
|
|
316
316
|
// docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md).
|
|
317
317
|
const bumpedAsk = (it) => Boolean(it.isNew && it.bumpedAt);
|
|
318
|
-
// band {step, actions} draws the card in a wave band (app-lanes.js).
|
|
319
318
|
// Its open questions asked since this browser last opened it are one more unread message: the thread's "N new
|
|
320
319
|
// questions" line (app-threads.js; docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
|
|
321
|
-
function card(it
|
|
320
|
+
function card(it) {
|
|
322
321
|
const unseen = (times) => (state.openItems.has(it.key) ? 0 : unreadCount(times, seen[it.key]));
|
|
323
322
|
const unread = unseen(it.othersAt) + Math.min(1, unseen(it.questions?.askedAt));
|
|
324
323
|
return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
|
|
325
324
|
<summary class="work">
|
|
326
325
|
<span class="status ${it.lane}"></span>
|
|
327
|
-
<div class="title-wrap">${workLines(it, { unread, top: bumpedAsk(it)
|
|
326
|
+
<div class="title-wrap">${workLines(it, { unread, top: bumpedAsk(it) })}</div>
|
|
328
327
|
</summary>
|
|
329
328
|
${state.openItems.has(it.key) ? detail(it) : ''}
|
|
330
329
|
</details>`;
|
|
@@ -51,7 +51,7 @@ BoardView.part((B) => {
|
|
|
51
51
|
linksSheet.showModal();
|
|
52
52
|
});
|
|
53
53
|
B.on($('#linksClose'), 'click', closeLinks);
|
|
54
|
-
B.on(linksSheet, 'click', (ev) => { if (ev
|
|
54
|
+
B.on(linksSheet, 'click', (ev) => { if (BoardUI.onBackdrop(ev, linksSheet)) closeLinks(); });
|
|
55
55
|
B.on(linksSheet, 'cancel', (ev) => { ev.preventDefault(); closeLinks(); });
|
|
56
56
|
function askGitHub() {
|
|
57
57
|
state.githubPending = Date.now();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Done's and Asks' Show more,
|
|
2
|
-
// Done's finished-
|
|
2
|
+
// Done's finished-item-file folds, and Todo's wave bands with their Implement and card order.
|
|
3
3
|
// Needs app-core.js and app-cards.js.
|
|
4
4
|
BoardView.part((B) => {
|
|
5
5
|
const { $, store, state } = B;
|
|
@@ -52,7 +52,7 @@ BoardView.part((B) => {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// A fold's open state lives in storage, not in setHTML's summary-text match, because its row's count changes.
|
|
55
|
-
// Fold ids are
|
|
55
|
+
// Fold ids are file:<slug> (board/domain.mjs fileFold), so any id maps to its own storage key.
|
|
56
56
|
const FOLDS = new Proxy({}, { get: (_, id) => ({ store: `foldOpen:${String(id)}` }) });
|
|
57
57
|
// A fold's header reads like a lane's: its name, then its count.
|
|
58
58
|
function fold(id, list, name) {
|
|
@@ -63,12 +63,12 @@ BoardView.part((B) => {
|
|
|
63
63
|
</details>`;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
// One row per finished
|
|
67
|
-
function
|
|
68
|
-
const slugs = [...new Set(items.filter((i) => i.fold === `
|
|
66
|
+
// One row per finished item file, newest card first, as Done's items arrive sorted.
|
|
67
|
+
function fileFolds(d, items) {
|
|
68
|
+
const slugs = [...new Set(items.filter((i) => i.fold === `file:${i.file}`).map((i) => i.file))];
|
|
69
69
|
return slugs.map((slug) => {
|
|
70
|
-
const w = d.
|
|
71
|
-
return fold(`
|
|
70
|
+
const w = d.itemFiles.find((x) => x.slug === slug);
|
|
71
|
+
return fold(`file:${slug}`, items.filter((i) => i.fold === `file:${slug}`), escText(w.title));
|
|
72
72
|
}).join('');
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -94,7 +94,7 @@ BoardView.part((B) => {
|
|
|
94
94
|
const items = shown.filter((i) => i.lane === lane.id);
|
|
95
95
|
const expanded = laneState[lane.id] ?? lane.open;
|
|
96
96
|
const open = items.filter((i) => !i.fold);
|
|
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)) +
|
|
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)) + fileFolds(d, items);
|
|
98
98
|
return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
|
|
99
99
|
<summary><span class="lane-name">${lane.name}</span><span class="count">${lane.id === 'done' ? doneCount(d, items) : items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
|
|
100
100
|
<div class="list">${rows || '<div class="empty">Nothing here.</div>'}
|
|
@@ -125,22 +125,30 @@ BoardView.part((B) => {
|
|
|
125
125
|
const grouped = bands.filter((b) => inBand(b).length);
|
|
126
126
|
if (!grouped.length) return cards.map((it) => B.card(it)).join('');
|
|
127
127
|
const loose = cards.filter((i) => !bands.some((b) => b.keys.includes(i.key)));
|
|
128
|
-
const
|
|
129
|
-
return grouped.map((b) =>
|
|
130
|
-
+ (loose.length ?
|
|
128
|
+
const body = (list) => list.map((it) => B.card(it)).join('');
|
|
129
|
+
return grouped.map((b) => groupHtml(laneId, b.slug, true, BoardUI.WAVE_ICON + groupLabel(escText(b.title), inBand(b).length) + waveCounts(b, laneId), body(inBand(b)))).join('')
|
|
130
|
+
+ (loose.length ? groupHtml(laneId, NO_WAVE, true, groupLabel('No wave', loose.length), body(loose)) : '');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The one header every lane gives every group, a wave or No wave: a tap on it folds the group, and its open state
|
|
134
|
+
// lives in storage under the lane and the group's slug. head is the header's content, body the cards under it.
|
|
135
|
+
const NO_WAVE = 'no-wave';
|
|
136
|
+
const groupStore = (id) => FOLDS[`band:${id}`].store;
|
|
137
|
+
const groupLabel = (name, n) => `<span class="wb-label"><span class="lane-name">${name}</span> <span class="count">${n}</span></span>`;
|
|
138
|
+
function groupHtml(laneId, slug, open, head, body) {
|
|
139
|
+
const id = `${laneId}/${slug}`;
|
|
140
|
+
const isOpen = store.get(groupStore(id), open);
|
|
141
|
+
const header = `<div class="wb-head" data-band-toggle="${esc(id)}" role="button" tabindex="0" aria-expanded="${isOpen}">${head}</div>`;
|
|
142
|
+
return `<section class="wb" data-band="${esc(slug)}" data-lane="${laneId}"${isOpen ? ' data-open' : ''}>${header}${isOpen ? `<div class="wb-body">${body}</div>` : ''}</section>`;
|
|
131
143
|
}
|
|
132
144
|
|
|
133
145
|
// docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md
|
|
134
146
|
// Todo's wave bands (domain.mjs waveBandsOf), then its cards in no wave under No wave. A band's open state lives
|
|
135
|
-
// in storage once the owner taps its head; until then only the first band whose next card is ready (
|
|
136
|
-
// any Todo card) starts open. A band's name shows its goal as a tooltip, or a popover on a long-press.
|
|
137
|
-
//
|
|
138
|
-
let menu = null;
|
|
147
|
+
// in storage once the owner taps its head; until then only the first band whose next card is ready (an item file's:
|
|
148
|
+
// any Todo card) starts open. A band's name shows its goal as a tooltip, or a popover on a long-press. goalShown is
|
|
149
|
+
// the band whose goal popover is open.
|
|
139
150
|
let goalShown = null;
|
|
140
|
-
const bandStore = (slug) => FOLDS[`band:${slug}`].store;
|
|
141
|
-
const bandOpen = (band, first) => store.get(bandStore(band.slug), band.slug === first);
|
|
142
151
|
const LANE_WORDS = { progress: 'in progress', review: 'in review' };
|
|
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>';
|
|
144
152
|
|
|
145
153
|
// Todo's wave bands and loose cards, in the order they arrive (domain.mjs laneOrder).
|
|
146
154
|
function todoRows(d, shown, todo) {
|
|
@@ -150,8 +158,8 @@ BoardView.part((B) => {
|
|
|
150
158
|
const loose = todo.filter((i) => !banded.has(i.key));
|
|
151
159
|
const first = bands.find((b) => (b.next ? b.next.ready : b.keys.some((k) => byKey.get(k)?.ready)))?.slug;
|
|
152
160
|
if (!bands.length) return loose.map((it) => B.card(it)).join('');
|
|
153
|
-
return bands.map((b) => bandHtml(b, b.keys.map((k) => byKey.get(k)).filter(Boolean),
|
|
154
|
-
+ (loose.length ?
|
|
161
|
+
return bands.map((b) => bandHtml(b, b.keys.map((k) => byKey.get(k)).filter(Boolean), b.slug === first)).join('')
|
|
162
|
+
+ (loose.length ? groupHtml('todo', NO_WAVE, true, groupLabel('No wave', loose.length),loose.map((it) => B.card(it)).join('')) : '');
|
|
155
163
|
}
|
|
156
164
|
|
|
157
165
|
function bandHtml(band, cards, open) {
|
|
@@ -160,38 +168,23 @@ BoardView.part((B) => {
|
|
|
160
168
|
const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${LANE_WORDS[i.lane]}`);
|
|
161
169
|
const progress = now.length ? ` <span class="count">· ${now.join(' · ')}</span>` : '';
|
|
162
170
|
const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${escText(band.title)}</span>`;
|
|
163
|
-
const notes = band.kind === '
|
|
171
|
+
const notes = band.kind === 'wave' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
|
|
164
172
|
const next = band.next;
|
|
165
173
|
const go = !next ? '' : next.ready
|
|
166
174
|
? `<button type="button" class="wv-link" data-confirm="card-build" data-key="${esc(next.key)}">Implement</button>`
|
|
167
175
|
: `<span class="wb-next">next: ${esc(next.ref)} ${esc(next.word)}</span>`;
|
|
168
176
|
const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
|
|
169
|
-
const head =
|
|
170
|
-
+ `<span class="wb-
|
|
171
|
-
+ waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}</span>${go}</span>${pop}</div>`;
|
|
172
|
-
if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
|
|
177
|
+
const head = `${BoardUI.WAVE_ICON}<span class="wb-label">${name} <span class="count">${todo.length}</span>${progress}</span>`
|
|
178
|
+
+ waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}</span>${go}</span>${pop}`;
|
|
173
179
|
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>` : '';
|
|
174
|
-
|
|
175
|
-
const actions = (it, at) => (band.kind !== 'lineup' ? null : cardMenu(band.slug, it.key, at, todo.length));
|
|
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>`;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// A lineup card's ⋯ and, while open, its menu: Move up and Move down among the band's Todo cards. A move waits for
|
|
180
|
-
// the server (board/server.mjs lineup-move): the tapped move waits and the other is disabled meanwhile.
|
|
181
|
-
function cardMenu(slug, key, at, count) {
|
|
182
|
-
const shown = menu === key;
|
|
183
|
-
const item = (by, label) => {
|
|
184
|
-
const off = by < 0 ? at === 0 : at === count - 1;
|
|
185
|
-
return `<button type="button" role="menuitem" data-band-move="${by}" data-band="${esc(slug)}" data-key="${esc(key)}"${off ? ' disabled' : ''}>${label}</button>`;
|
|
186
|
-
};
|
|
187
|
-
return `<div class="ask-actions"><button type="button" class="ghost icon wb-dots" data-band-menu="${esc(key)}" aria-label="Card menu" aria-haspopup="menu" aria-expanded="${shown}">${DOTS}</button>`
|
|
188
|
-
+ (shown ? `<div class="wb-menu" role="menu">${item(-1, '↑ Move up')}${item(1, '↓ Move down')}</div>` : '') + '</div>';
|
|
180
|
+
return groupHtml('todo', band.slug, open, head, doneRow + todo.map((it) => B.card(it)).join(''));
|
|
189
181
|
}
|
|
190
182
|
|
|
191
183
|
// Opens the band holding card key or named slug, for a #selectedCard= link or a Needs you line.
|
|
192
184
|
function openBand({ key, slug }) {
|
|
185
|
+
const lane = (key && state.board?.items.find((i) => i.key === key)?.lane) || 'todo';
|
|
193
186
|
const band = (state.board?.waveBands || []).find((b) => b.slug === slug || b.keys.includes(key));
|
|
194
|
-
if (band) store.set(
|
|
187
|
+
if (band) store.set(groupStore(`${lane}/${band.slug}`), true);
|
|
195
188
|
return band;
|
|
196
189
|
}
|
|
197
190
|
|
|
@@ -245,11 +238,11 @@ BoardView.part((B) => {
|
|
|
245
238
|
if (!openBand({ slug: show.dataset.bandShow })) return;
|
|
246
239
|
const s = store.get('lanes', {}); s.todo = true; store.set('lanes', s);
|
|
247
240
|
B.render();
|
|
248
|
-
$(`.wb[data-band="${CSS.escape(show.dataset.bandShow)}"]`)?.scrollIntoView({ block: 'start' });
|
|
241
|
+
$(`.wb[data-lane="todo"][data-band="${CSS.escape(show.dataset.bandShow)}"]`)?.scrollIntoView({ block: 'start' });
|
|
249
242
|
}
|
|
250
243
|
});
|
|
251
244
|
|
|
252
|
-
// A band's
|
|
245
|
+
// A band's head's fold and its name's goal popover. A long-press on the name (touch only;
|
|
253
246
|
// a mouse has the tooltip) opens the popover, and the click that ends the press neither folds the band nor closes it.
|
|
254
247
|
let press = null;
|
|
255
248
|
let pressed = false;
|
|
@@ -272,24 +265,9 @@ BoardView.part((B) => {
|
|
|
272
265
|
}
|
|
273
266
|
if (pressed) { pressed = false; ev.preventDefault(); return; }
|
|
274
267
|
if (goalShown) { goalShown = null; B.render(); if (ev.target.closest('.wb-head')) return; }
|
|
275
|
-
const dots = ev.target.closest('button[data-band-menu]');
|
|
276
|
-
if (dots) { ev.preventDefault(); menu = menu === dots.dataset.bandMenu ? null : dots.dataset.bandMenu; B.render(); return; }
|
|
277
|
-
const mv = ev.target.closest('button[data-band-move]');
|
|
278
|
-
if (mv) {
|
|
279
|
-
ev.preventDefault();
|
|
280
|
-
if (mv.disabled) return;
|
|
281
|
-
const { band, key } = mv.dataset;
|
|
282
|
-
try { await waitOn(mv, B.send('lineup-move', { lineup: band, key, by: Number(mv.dataset.bandMove) }), { group: mv.parentNode.querySelectorAll('button') }); }
|
|
283
|
-
catch { B.toast('Could not move it. Is the host awake?'); }
|
|
284
|
-
menu = null;
|
|
285
|
-
B.render();
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
if (menu && !ev.target.closest('.wb-menu')) { menu = null; B.render(); if (ev.target.closest('summary')) { ev.preventDefault(); return; } }
|
|
289
268
|
const head = ev.target.closest('[data-band-toggle]');
|
|
290
269
|
if (head && !ev.target.closest('a, button')) {
|
|
291
|
-
|
|
292
|
-
store.set(bandStore(slug), head.getAttribute('aria-expanded') !== 'true');
|
|
270
|
+
store.set(groupStore(head.dataset.bandToggle), head.getAttribute('aria-expanded') !== 'true');
|
|
293
271
|
B.render();
|
|
294
272
|
}
|
|
295
273
|
});
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// Needs app-core.js.
|
|
16
16
|
BoardView.part((B) => {
|
|
17
17
|
const { $, plural, state, toast } = B;
|
|
18
|
-
const { esc, escText, promptItem, choiceLabel, isPrototype, getDraft, setDraft, noteOf, waitOn } = BoardUI;
|
|
18
|
+
const { esc, escText, markdown, promptItem, choiceLabel, isPrototype, getDraft, setDraft, noteOf, waitOn } = BoardUI;
|
|
19
19
|
|
|
20
20
|
const prompts = PromptAnswers.wire(B, {
|
|
21
21
|
list: () => (state.board?.needs || []).filter((line) => line.prompt).map((line) => line.prompt),
|
|
@@ -65,6 +65,7 @@ BoardView.part((B) => {
|
|
|
65
65
|
// order; any other line as itself. → [{id, what, html}]
|
|
66
66
|
function deckOf(d, needs) {
|
|
67
67
|
const items = d.items || [];
|
|
68
|
+
const links = BoardUI.linkContext(items);
|
|
68
69
|
const owner = (d.owner || []).filter((q) => q.state === 'open');
|
|
69
70
|
const topicOf = (line) => line.topic || items.find((it) => it.key === line.key)?.questions?.topic || null;
|
|
70
71
|
return needs.flatMap((line) => {
|
|
@@ -73,7 +74,7 @@ BoardView.part((B) => {
|
|
|
73
74
|
const topic = line.questions && topicOf(line);
|
|
74
75
|
const order = (d.ownerTopics || []).find((t) => t.id === topic)?.questions || [];
|
|
75
76
|
const asks = topic ? owner.filter((q) => q.topic === topic).sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)) : [];
|
|
76
|
-
if (asks.length) return asks.map((q) => ({ id: `q:${q.id}`, what, html: questionHtml(q) }));
|
|
77
|
+
if (asks.length) return asks.map((q) => ({ id: `q:${q.id}`, what, html: questionHtml(q, links) }));
|
|
77
78
|
const [label, href, attrs] = line.questions ? [`Answer ${plural(line.questions.open, 'question')}`, line.questions.url, '']
|
|
78
79
|
: line.ready ? ['Ready on GitHub', line.ready, ' target="_blank" rel="noopener"']
|
|
79
80
|
: line.action ? [line.action.label, line.action.url, '']
|
|
@@ -85,14 +86,14 @@ BoardView.part((B) => {
|
|
|
85
86
|
});
|
|
86
87
|
}
|
|
87
88
|
// The marked option, the question's drafted pick, reads Send “<label>”.
|
|
88
|
-
const questionHtml = (q) => {
|
|
89
|
+
const questionHtml = (q, links) => {
|
|
89
90
|
const marked = getDraft(q.id).choice;
|
|
90
91
|
const optionHtml = (o) => {
|
|
91
92
|
const armed = o.id === marked;
|
|
92
93
|
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 ? `<
|
|
94
|
+
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 ? `<div class="det">${markdown(o.detail, links)}</div>` : ''}</button>`;
|
|
94
95
|
};
|
|
95
|
-
return `<h3>${escText(q.question)}</h3>${q.context ? `<
|
|
96
|
+
return `<h3>${escText(q.question)}</h3>${q.context ? `<div class="ctx">${markdown(q.context, links)}</div>` : ''}
|
|
96
97
|
${isPrototype(q.url) ? `<a class="button deck-proto" href="${esc(q.url)}">View prototypes</a>` : ''}
|
|
97
98
|
<div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map(optionHtml).join('')}</div>`;
|
|
98
99
|
};
|
|
@@ -110,7 +111,7 @@ BoardView.part((B) => {
|
|
|
110
111
|
showing = now.id;
|
|
111
112
|
const at = deck.indexOf(now);
|
|
112
113
|
const open = (d.owner || []).filter((q) => q.state === 'open').length;
|
|
113
|
-
const drafted = (d.owner || []).filter((q) => q.state === 'open' &&
|
|
114
|
+
const drafted = (d.owner || []).filter((q) => q.state === 'open' && getDraft(q.id).choice != null).length;
|
|
114
115
|
BoardUI.setHTML(el, `<div class="deck-head"><h2>Needs you</h2><span class="deck-n">${deck.length}</span></div>
|
|
115
116
|
<div class="deck-card" data-deck="${esc(now.id)}"><p class="deck-what">${escText(now.what)}</p>${now.html}</div>
|
|
116
117
|
${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>` : ''}
|
|
@@ -156,10 +157,10 @@ BoardView.part((B) => {
|
|
|
156
157
|
// From 900px, Waiting on you in the pane under the Needs you lines, mounted while the width lasts.
|
|
157
158
|
let questions = null;
|
|
158
159
|
// 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
|
+
// wave:<slug> or file:<slug> a band; any other topic, or one the board doesn't show, does nothing.
|
|
160
161
|
function openOnBoard(id) {
|
|
161
162
|
if (id.startsWith('item:')) B.focusOnBoard({ key: id.slice(5) });
|
|
162
|
-
else if (id.startsWith('wave:')) B.focusOnBoard({ slug: id.slice(5) });
|
|
163
|
+
else if (id.startsWith('wave:') || id.startsWith('file:')) B.focusOnBoard({ slug: id.slice(5) });
|
|
163
164
|
}
|
|
164
165
|
function fitPane() {
|
|
165
166
|
if (wide.matches === Boolean(questions)) return;
|
|
@@ -231,9 +232,13 @@ BoardView.part((B) => {
|
|
|
231
232
|
else viewer.sheet = !viewer.sheet;
|
|
232
233
|
paintViewer();
|
|
233
234
|
});
|
|
234
|
-
// A push to a card's thread (#
|
|
235
|
-
// among them, closes the viewer, so the thread it sent the owner to shows.
|
|
236
|
-
|
|
235
|
+
// A push to a card's thread (#selectedCard=<key>&reply, board/store.mjs threadUrl), the switcher's for a question
|
|
236
|
+
// already picked among them, closes the viewer, so the thread it sent the owner to shows. The older #item=<key>&reply
|
|
237
|
+
// reads the same.
|
|
238
|
+
B.on(window, 'hashchange', () => {
|
|
239
|
+
const at = AppRouter.fragmentOf(location.hash);
|
|
240
|
+
if (at.selectedCard && at.reply) closeViewer();
|
|
241
|
+
});
|
|
237
242
|
|
|
238
243
|
fitPane();
|
|
239
244
|
B.on(wide, 'change', fitPane);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "View transcript"
|
|
2
|
-
// (ui-threads.js) and a card's or a
|
|
2
|
+
// (ui-threads.js) and a card's or a wave's Notes (app-cards.js, app-lanes.js) open over the board in one sheet in
|
|
3
3
|
// place of log.html and notes.html, which still open on their own. It closes on its ✕, Escape, a tap on the dim area
|
|
4
4
|
// and Back, through a history entry of its own that carries the board's URL, so the router's pop keeps the board.
|
|
5
5
|
// A link in it closes it first, then opens through the shell.
|
|
@@ -148,7 +148,7 @@ BoardView.part((B) => {
|
|
|
148
148
|
else openNotes(href.slice(href.indexOf('?')));
|
|
149
149
|
});
|
|
150
150
|
B.on(sheet, 'click', (ev) => {
|
|
151
|
-
if (ev
|
|
151
|
+
if (BoardUI.onBackdrop(ev, sheet)) { close(); return; }
|
|
152
152
|
const act = ev.target.closest('[data-reader]')?.dataset.reader;
|
|
153
153
|
if (act === 'close') close();
|
|
154
154
|
else if (act === 'raw') { log.raw = !log.raw; log.text = null; drawLog(); follow(); if (log.raw) readRaw(opened); }
|
package/board/public/app.js
CHANGED
|
@@ -206,7 +206,7 @@ window.BoardView = (function () {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
// #review=<PR number> opens that review in the Auto Review lane (app-lanes.js openReview), as a held review's push does.
|
|
209
|
-
// #selectedCard=<
|
|
209
|
+
// #selectedCard=<file>/<id> (or the older #item=) opens that card, and &openItemsCard=<topic id> opens that pill in the pane; push notifications link here, with &reply to land in the thread's reply box
|
|
210
210
|
// (docs/adr/2026-10-29-1639-a-push-lands-where-the-owner-answers.md). An ask now in a work item's thread opens that item.
|
|
211
211
|
let focusKey = null;
|
|
212
212
|
let replyKey = null;
|
|
@@ -251,7 +251,7 @@ window.BoardView = (function () {
|
|
|
251
251
|
if (!band && !state.board?.items.some((i) => i.key === key)) return false;
|
|
252
252
|
if (key) { location.hash = AppRouter.hashWith(location.hash, { selectedCard: key }); readHash(); } else B.openBand({ slug });
|
|
253
253
|
render();
|
|
254
|
-
if (key) scrollToFocus(); else $(`section.wb[data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
|
|
254
|
+
if (key) scrollToFocus(); else $(`section.wb[data-lane="todo"][data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
|
|
255
255
|
return true;
|
|
256
256
|
}
|
|
257
257
|
// A card missing from the last visit's board waits for the fresh one, which is final.
|
package/board/public/dialogs.js
CHANGED
|
@@ -348,7 +348,7 @@
|
|
|
348
348
|
} catch (e) { sending = false; fail(e); sync(); }
|
|
349
349
|
});
|
|
350
350
|
el.addEventListener('cancel', (e) => { e.preventDefault(); done(); });
|
|
351
|
-
el.addEventListener('click', (e) => { if (e
|
|
351
|
+
el.addEventListener('click', (e) => { if (BoardUI.onBackdrop(e, el)) done(); });
|
|
352
352
|
el.addEventListener('close', () => {
|
|
353
353
|
if (closed) return;
|
|
354
354
|
closed = true;
|
package/board/public/hub-kit.js
CHANGED
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
// The one duration picker Snooze,
|
|
184
|
+
// The one duration picker Snooze, Pause hub and Pause board share (hub/duration.mjs): chips that post
|
|
185
185
|
// `name` to `action` at once, then Custom, whose date and time posts as `name` too. While its setting is on, `on`
|
|
186
186
|
// replaces it with when it ends and a button that posts `name`=off.
|
|
187
187
|
function durationPicker({ id, title, icon, action, name, choices, on, button }) {
|
package/board/public/hub.css
CHANGED
|
@@ -95,6 +95,8 @@
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
.hub-view .hub-settings { margin-top: 24px; }
|
|
98
|
+
.hub-view .hub-inline > div + div { margin-top: 16px; }
|
|
99
|
+
.hub-view .hub-inline + .label { margin-top: 24px; }
|
|
98
100
|
.hub-view .hub-setting { width: 100%; display: flex; align-items: center; gap: 12px; padding: 14px 16px; font: inherit; text-align: left; color: var(--text); cursor: pointer; border: 1px solid var(--line); border-radius: 12px; margin: 10px 0; background: var(--panel); }
|
|
99
101
|
.hub-view .hub-setting .text { flex: 1; min-width: 0; }
|
|
100
102
|
.hub-view .hub-setting .title { font-weight: 600; }
|
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
}
|
|
137
137
|
dialog.addEventListener('click', (e) => {
|
|
138
138
|
if (e.target.closest?.('[data-lk-done]')) { close(); return; }
|
|
139
|
-
if (e
|
|
139
|
+
if (!BoardUI.onBackdrop(e, dialog)) return;
|
|
140
140
|
const r = dialog.getBoundingClientRect();
|
|
141
141
|
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) close();
|
|
142
142
|
});
|
package/board/public/new-task.js
CHANGED
|
@@ -75,7 +75,7 @@ globalThis.BoardNewTask = (function () {
|
|
|
75
75
|
B.newTaskKey = newTaskKey;
|
|
76
76
|
B.on(document, 'keydown', newTaskKey);
|
|
77
77
|
B.on($('#sheetClose'), 'click', closeSheet);
|
|
78
|
-
B.on(sheet, 'click', (ev) => { if (ev
|
|
78
|
+
B.on(sheet, 'click', (ev) => { if (BoardUI.onBackdrop(ev, sheet)) closeSheet(); });
|
|
79
79
|
B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
|
|
80
80
|
// Discuss has the session grill or prototype the task at once; Implement sends the ask with quick: true, and the
|
|
81
81
|
// session marks the card ready (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
|
|
@@ -87,7 +87,6 @@
|
|
|
87
87
|
usage: fake.usage(),
|
|
88
88
|
hub: { tone: 'ok', word: 'healthy', detail: '3 of 3 sites up' },
|
|
89
89
|
sites: fake.sites(),
|
|
90
|
-
polling: { title: 'Checks every minute while you’re here', lines: ['Next check 12:00pm · last 11:45am, 8 PRs, no change', 'No pushes queued.'], count: 0 },
|
|
91
90
|
settings: [['notifications', 'Notifications', 'Pushes on · Off on weekends · This device on'], ['quiet', 'Quiet hours', '10:00pm – 8:00am · pushes held'],
|
|
92
91
|
['codex', 'Codex share', 'Auto'], ['rules', 'Rules for every board', '1 rule'], ['closed', 'Closed boards', 'No closed boards']],
|
|
93
92
|
}),
|
|
@@ -105,7 +104,7 @@
|
|
|
105
104
|
|
|
106
105
|
function hub(data = {}) {
|
|
107
106
|
const d = { ...fake.hub(), header: true, ...data };
|
|
108
|
-
const { row, tile,
|
|
107
|
+
const { row, tile, CHEVRON } = HubUI;
|
|
109
108
|
const site = (s) => row({ icon: s.icon, title: esc(s.title), sub: s.status ?? '<span class="st ok">up · listening · every link passes</span>', wrap: true, trail: CHEVRON, href: `/${s.route}/` });
|
|
110
109
|
const settings = d.settings.map(([id, title, sub]) => `<button type="button" class="hub-setting" data-setting="${esc(id)}" data-kit-inert aria-haspopup="dialog"><span class="text"><span class="title">${esc(title)}</span><span class="sub">${esc(sub)}</span></span>${CHEVRON}</button>`).join('');
|
|
111
110
|
const usage = d.usageCard ?? HubUI.usageCard(d.usage);
|
|
@@ -114,9 +113,7 @@
|
|
|
114
113
|
<div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span> · ${esc(d.hub.detail)}`, wrap: true })}</div>
|
|
115
114
|
<h2 class="label">Boards</h2>
|
|
116
115
|
<div class="group">${d.sites.map(site).join('')}</div>
|
|
117
|
-
<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, events (its session and a watcher run, or the hub can wake it), daily wakes, pushes and reviewer links. One that just broke reads settling first.</p>
|
|
118
|
-
<h2 class="label">Polling status</h2>
|
|
119
|
-
<div class="group">${nowRow(d.polling, tile('clock', '#3b82f6'))}</div></div>
|
|
116
|
+
<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, events (its session and a watcher run, or the hub can wake it), daily wakes, pushes and reviewer links. One that just broke reads settling first.</p></div>
|
|
120
117
|
<section class="hub-settings"><h2 class="label">Hub settings</h2>${settings}</section>
|
|
121
118
|
<div class="hub-foot"><footer>${d.sites.length} sites. Each belongs to one Claude session, which adds it with <code>/kickflip <route></code>. This page refreshes every 30 seconds.<br><span class="host">mac.example.ts.net</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v0.4.2</span></footer></div></main></div>`;
|
|
122
119
|
}
|
package/board/public/router.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
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
|
-
// notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<
|
|
9
|
+
// notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<wave slug>, and plan.html, which the board
|
|
10
10
|
// redirects there
|
|
11
11
|
// hub {hash} /_hub/ with a settings modal named by hash; legacy /_hub/settings opens the same modals
|
|
12
12
|
// ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)),
|
package/board/public/sidebar.css
CHANGED
|
@@ -150,11 +150,11 @@ html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-
|
|
|
150
150
|
@starting-style { .sidebar.ks-on .ks-label-hint { opacity: 0; } }
|
|
151
151
|
/* The label is the bar's containing block: left 0 is the BOARDS text's edge, and right -8px crosses the label's 8px
|
|
152
152
|
side margin (.sb-label) to the rows' right edge. The bar sits in the label's 4px bottom margin. */
|
|
153
|
-
.sidebar .ks-bar { position: absolute; left: 0; right: -8px; top: 100%; height: 2px; margin-top: 1px; background: var(--
|
|
153
|
+
.sidebar .ks-bar { position: absolute; left: 0; right: -8px; top: 100%; height: 2px; margin-top: 1px; background: var(--color-notice);
|
|
154
154
|
transform-origin: 0 50%; animation: ks-drain linear forwards; }
|
|
155
155
|
@keyframes ks-drain { to { transform: scaleX(0); } }
|
|
156
156
|
.sidebar .ks-u { transition: color 200ms linear; }
|
|
157
|
-
.sidebar.ks-on .ks-u { color: var(--
|
|
157
|
+
.sidebar.ks-on .ks-u { color: var(--color-notice); transition: color 150ms linear; }
|
|
158
158
|
@starting-style { .sidebar.ks-on .ks-u { color: inherit; } }
|
|
159
159
|
@media (prefers-reduced-motion: reduce) {
|
|
160
160
|
.sidebar .ks-bar { animation: none; }
|
package/board/public/sidebar.js
CHANGED
|
@@ -236,7 +236,7 @@
|
|
|
236
236
|
}
|
|
237
237
|
el.addEventListener('click', (e) => {
|
|
238
238
|
if (sending) return;
|
|
239
|
-
if (e
|
|
239
|
+
if (BoardUI.onBackdrop(e, el) || e.target.closest('[data-failed-close]')) { done(); return; }
|
|
240
240
|
const dismiss = e.target.closest('[data-failed-dismiss]');
|
|
241
241
|
const retry = e.target.closest('[data-failed-retry]');
|
|
242
242
|
if (dismiss) {
|
|
@@ -309,7 +309,7 @@
|
|
|
309
309
|
el.innerHTML = shortcuts();
|
|
310
310
|
el.addEventListener('cancel', (e) => { e.preventDefault(); closeHelp(); });
|
|
311
311
|
el.addEventListener('close', closeHelp);
|
|
312
|
-
el.addEventListener('click', (e) => { if (e
|
|
312
|
+
el.addEventListener('click', (e) => { if (BoardUI.onBackdrop(e, el)) closeHelp(); });
|
|
313
313
|
help = { el, previous: document.activeElement };
|
|
314
314
|
document.body.appendChild(el);
|
|
315
315
|
el.showModal();
|