@lovek4d/kickflip 0.8.0 → 0.10.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.
@@ -1 +1 @@
1
- var w=Object.defineProperty;var t=(e,a)=>w(e,"name",{value:a,configurable:!0});import $ from"node:os";const p=100,g=2e3,y=/(?:~|\/)[^\s:;,()'"]*\/([^\s:;,()'"/]+)/g;function r(e,a,s=""){const d=p-e.length-s.length;let l=String(a??"").split($.homedir()).join("~").replace(/\s+/g," ").trim();return l.length>d&&(l=l.replace(y,"\u2026/$1")),l.length<=d?e+l+s:`${e}${l.slice(0,d-1).trimEnd()}\u2026${s.replace(/^\./,"")}`}t(r,"fit");const i="Held",c="Resumed",m="Blocked",o="Fault",u="move",x="questions",k="Implementation requested",f="Prototype requested",n="Marked done",v="prototype",b="owner/a-long-repo-name#10000",I="Wed, 11/30, 12:00 PM",h={"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:v}},paused:{mark:i,text:t(({phase:e,words:a,clear:s})=>r(`Paused${e?` at ${e}`:""}: `,a,`. It resumes once ${s}.`),"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:u,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:c,text:t(({clear:e})=>`Resumed: ${e}.`,"text"),longest:{clear:"the usage pause lifted"}},"hold-ended":{mark:c,text:t(({at:e})=>`Resumed: its hold until ${e} ended.`,"text"),longest:{at:I}},unblocked:{mark:c,text:t(({lifted:e})=>r("Unblocked: ",e,"."),"text")},"unblocked-done":{mark:c,text:t(({card:e})=>r("Unblocked: ",e," is done."),"text")},"blocked-pausing":{mark:m,text:t(({blocked:e})=>r("Blocked ",e,": its builder pauses at its next stopping point and it stays In Progress."),"text")},"blocked-stays":{mark:m,text:t(({blocked:e})=>r("Blocked ",e,": it stays In Progress."),"text")},"cant-start-yet":{mark:m,text:t(({names:e})=>r("Can\u2019t start yet: waits on ",e,". Bumped to the top of Todo."),"text")},"split-moved":{mark:m,text:t(({card:e,to:a})=>r("Split: ",`${e} became ${a}`,", so it waits on those now."),"text")},"merge-blocked":{mark:m,text:t(({blocked:e})=>r("Not merged: blocked ",e,"."),"text")},pulled:{mark:k,text:t(()=>"Pulled in: an implementer slot was free.","text")},"helper-asked":{mark:k,text:t(({agent:e,writes:a})=>r("Asked for helper ",e,a?", which writes code":""),"text"),longest:{writes:!0}},"arch-review":{mark:k,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")},"ticket-made":{mark:u,text:t(({ticket:e})=>r("The board made ",e,"."),"text")},"new-question":{mark:x,text:t(()=>"A new question on it, so it is grilling until you answer.","text")},"screen-timeout":{mark:x,text:t(()=>"Questions passed to you unscreened: the orchestrator didn\u2019t screen them in 15 minutes.","text")},"ask-moved":{mark:u,text:t(({board:e})=>r("Moved to ",e,"."),"text")},"card-merged":{mark:u,text:t(({card:e})=>r("Merged into ",e,"."),"text")},"pr-found":{mark:u,text:t(({pr:e,branch:a})=>r(`Found PR #${e} on branch `,a,"."),"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:u,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:a})=>r(`PR${a?"s":""} `,e,` ${a?"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:a,host:s})=>r("",`${e} is merged into ${a} on ${s}`,", so this card is Done."),"text")},"branch-gone":{mark:n,text:t(({branch:e,host:a})=>r("",`${e} is gone from ${a}`,", so this card is Done."),"text")},"prs-closed":{mark:n,text:t(({prs:e})=>r("Closed: ",e," closed unmerged."),"text"),longest:{prs:`${b}, ${b}`}},"landed-on-merge":{mark:n,text:t(({base:e})=>r("Landed on ",e," on your Merge."),"text")},"lander-handoff":{mark:k,text:t(({why:e})=>r("Not merged yet: ",e,". Its builder resumes to fix it and land it."),"text")},"builder-resumed-to-land":{mark:k,text:t(({session:e})=>r("Builder resumed to land, session ",e),"text")},"build-refused":{mark:o,fault:!0,detail:t(({why:e})=>e,"detail"),text:t(({why:e})=>r("Couldn't start implementing: ",e),"text")},"planner-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({kind:e,error:a})=>r(`Couldn't start the ${e}: `,a),"text"),longest:{kind:"visual agent"}},"helper-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({name:e,error:a})=>r(`Couldn't start helper ${e}: `,a),"text"),longest:{name:"a-helper-name-of-24-char"}},"exited-twice":{mark:o,fault:!0,text:t(()=>"Its builder exited twice unfinished; it reads stopped until the orchestrator takes it up.","text")},"resume-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({error:e})=>r("Couldn't resume the builder headless: ",e),"text")},"relaunch-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({error:e})=>r("Couldn't relaunch the builder headless: ",e),"text")},"lander-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({error:e})=>r("Couldn't launch the lander; land it by hand: ",e),"text")},"rebase-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({pr:e,error:a})=>r(`PR #${e} conflicts, but the rebase run couldn't start: `,a),"text"),longest:{pr:1e5}},"rebase-launch-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({error:e})=>r("Couldn't launch the rebase run; it reads stopped: ",e),"text")},"gh-limited":{mark:o,fault:!0,detail:t(({words:e})=>e,"detail"),text:t(({what:e,words:a})=>r(`${e}: `,a,"."),"text"),longest:{what:"No PR opened"}},"merge-refused":{mark:o,fault:!0,detail:t(({why:e})=>e,"detail"),text:t(({why:e})=>r("Not merged: ",e,"."),"text")},"land-failed":{mark:o,fault:!0,detail:t(({why:e})=>e,"detail"),text:t(({why:e})=>r("Not merged: ",e,". Tap Merge again."),"text")},"push-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({base:e,error:a})=>r(`Landed, but ${e} didn't reach origin: `,a),"text"),longest:{base:"a-long-base-branch"}},"answers-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({what:e,error:a})=>r(`Answers recorded, but ${e} failed: `,a),"text"),longest:{what:"writing them into its notes"}},"notes-missing":{mark:o,fault:!0,text:t(()=>"Its notes file was missing, so the board started a new one.","text")},"notes-shared":{mark:o,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:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({error:e})=>r("The owner step's check can't run, so it stopped: ",e),"text")},"run-failed":{mark:o,fault:!0,detail:t(({why:e})=>e,"detail"),text:t(({kind:e,code:a})=>`${e} failed (exit ${a})`,"text"),longest:{kind:"Serve",code:255}}};function C(e,a={}){const s=h[e];if(!s)throw new Error(`no status line kind "${e}"`);const d=s.text(a);if(!d||d.length>p)throw new Error(`the "${e}" status line runs to ${d.length} characters, past ${p}: ${d}`);return d}t(C,"hubText");function T(e,a={}){const s=String(h[e]?.detail?.(a)??"").trim();return s.length>g?`\u2026${s.slice(-(g-1))}`:s}t(T,"detailOf");const R=t(e=>({mark:h[e.line]?.mark||"",fault:!!h[e.line]?.fault}),"hubLineOf"),S="hub";export{p as BRIEF_LINE_LIMIT,S as HUB,h as HUB_LINES,g as MESSAGE_LIMIT,T as detailOf,r as fit,R as hubLineOf,C as hubText};
1
+ var b=Object.defineProperty;var t=(e,a)=>b(e,"name",{value:a,configurable:!0});import $ from"node:os";const p=100,g=2e3,y=/(?:~|\/)[^\s:;,()'"]*\/([^\s:;,()'"/]+)/g;function r(e,a,s=""){const d=p-e.length-s.length;let l=String(a??"").split($.homedir()).join("~").replace(/\s+/g," ").trim();return l.length>d&&(l=l.replace(y,"\u2026/$1")),l.length<=d?e+l+s:`${e}${l.slice(0,d-1).trimEnd()}\u2026${s.replace(/^\./,"")}`}t(r,"fit");const i="Held",h="Resumed",m="Blocked",o="Fault",u="move",x="questions",k="Implementation requested",f="Prototype requested",n="Marked done",v="prototype",w="owner/a-long-repo-name#10000",I="Wed, 11/30, 12:00 PM",c={"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:v}},paused:{mark:i,text:t(({phase:e,words:a,clear:s})=>r(`Paused${e?` at ${e}`:""}: `,a,`. It resumes once ${s}.`),"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:u,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:h,text:t(({clear:e})=>`Resumed: ${e}.`,"text"),longest:{clear:"the usage pause lifted"}},"hold-ended":{mark:h,text:t(({at:e})=>`Resumed: its hold until ${e} ended.`,"text"),longest:{at:I}},unblocked:{mark:h,text:t(({lifted:e})=>r("Unblocked: ",e,"."),"text")},"unblocked-done":{mark:h,text:t(({card:e})=>r("Unblocked: ",e," is done."),"text")},"blocked-pausing":{mark:m,text:t(({blocked:e})=>r("Blocked ",e,": its builder pauses at its next stopping point and it stays In Progress."),"text")},"blocked-stays":{mark:m,text:t(({blocked:e})=>r("Blocked ",e,": it stays In Progress."),"text")},"cant-start-yet":{mark:m,text:t(({names:e})=>r("Can\u2019t start yet: waits on ",e,". Bumped to the top of Todo."),"text")},"split-moved":{mark:m,text:t(({card:e,to:a})=>r("Split: ",`${e} became ${a}`,", so it waits on those now."),"text")},"merge-blocked":{mark:m,text:t(({blocked:e})=>r("Not merged: blocked ",e,"."),"text")},pulled:{mark:k,text:t(()=>"Pulled in: an implementer slot was free.","text")},"helper-asked":{mark:k,text:t(({agent:e,writes:a})=>r("Asked for helper ",e,a?", which writes code":""),"text"),longest:{writes:!0}},"arch-review":{mark:k,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")},"ticket-made":{mark:u,text:t(({ticket:e})=>r("The board made ",e,"."),"text")},"new-question":{mark:x,text:t(()=>"A new question on it, so it is grilling until you answer.","text")},"screen-timeout":{mark:x,text:t(()=>"Questions passed to you unscreened: the orchestrator didn\u2019t screen them in 15 minutes.","text")},"ask-moved":{mark:u,text:t(({board:e})=>r("Moved to ",e,"."),"text")},"card-merged":{mark:u,text:t(({card:e})=>r("Merged into ",e,"."),"text")},"pr-found":{mark:u,text:t(({pr:e,branch:a})=>r(`Found PR #${e} on branch `,a,"."),"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:u,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:a})=>r(`PR${a?"s":""} `,e,` ${a?"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:a,host:s})=>r("",`${e} is merged into ${a} on ${s}`,", so this card is Done."),"text")},"branch-gone":{mark:n,text:t(({branch:e,host:a})=>r("",`${e} is gone from ${a}`,", so this card is Done."),"text")},"prs-closed":{mark:n,text:t(({prs:e})=>r("Closed: ",e," closed unmerged."),"text"),longest:{prs:`${w}, ${w}`}},"landed-on-merge":{mark:n,text:t(({base:e})=>r("Landed on ",e," on your Merge."),"text")},"lander-handoff":{mark:k,text:t(({why:e})=>r("Not merged yet: ",e,". Its builder resumes to fix it and land it."),"text")},"builder-resumed-to-land":{mark:k,text:t(({session:e})=>r("Builder resumed to land, session ",e),"text")},"build-refused":{mark:o,fault:!0,detail:t(({why:e})=>e,"detail"),text:t(({why:e})=>r("Couldn't start implementing: ",e),"text")},"planner-unasked":{mark:o,fault:!0,detail:t(({kind:e})=>e,"detail"),text:t(({kind:e})=>`Its ${e} planner ended without posting a question, so it waits on the orchestrator.`,"text"),longest:{kind:"prototype"}},"planner-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({kind:e,error:a})=>r(`Couldn't start the ${e}: `,a),"text"),longest:{kind:"visual agent"}},"helper-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({name:e,error:a})=>r(`Couldn't start helper ${e}: `,a),"text"),longest:{name:"a-helper-name-of-24-char"}},"exited-twice":{mark:o,fault:!0,text:t(()=>"Its builder exited twice unfinished; it reads stopped until the orchestrator takes it up.","text")},"resume-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({error:e})=>r("Couldn't resume the builder headless: ",e),"text")},"relaunch-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({error:e})=>r("Couldn't relaunch the builder headless: ",e),"text")},"lander-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({error:e})=>r("Couldn't launch the lander; land it by hand: ",e),"text")},"rebase-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({pr:e,error:a})=>r(`PR #${e} conflicts, but the rebase run couldn't start: `,a),"text"),longest:{pr:1e5}},"rebase-launch-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({error:e})=>r("Couldn't launch the rebase run; it reads stopped: ",e),"text")},"gh-limited":{mark:o,fault:!0,detail:t(({words:e})=>e,"detail"),text:t(({what:e,words:a})=>r(`${e}: `,a,"."),"text"),longest:{what:"No PR opened"}},"merge-refused":{mark:o,fault:!0,detail:t(({why:e})=>e,"detail"),text:t(({why:e})=>r("Not merged: ",e,"."),"text")},"land-failed":{mark:o,fault:!0,detail:t(({why:e})=>e,"detail"),text:t(({why:e})=>r("Not merged: ",e,". Tap Merge again."),"text")},"push-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({base:e,error:a})=>r(`Landed, but ${e} didn't reach origin: `,a),"text"),longest:{base:"a-long-base-branch"}},"answers-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({what:e,error:a})=>r(`Answers recorded, but ${e} failed: `,a),"text"),longest:{what:"writing them into its notes"}},"notes-missing":{mark:o,fault:!0,text:t(()=>"Its notes file was missing, so the board started a new one.","text")},"notes-shared":{mark:o,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:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({error:e})=>r("The owner step's check can't run, so it stopped: ",e),"text")},"run-failed":{mark:o,fault:!0,detail:t(({why:e})=>e,"detail"),text:t(({kind:e,code:a})=>`${e} failed (exit ${a})`,"text"),longest:{kind:"Serve",code:255}}};function C(e,a={}){const s=c[e];if(!s)throw new Error(`no status line kind "${e}"`);const d=s.text(a);if(!d||d.length>p)throw new Error(`the "${e}" status line runs to ${d.length} characters, past ${p}: ${d}`);return d}t(C,"hubText");function T(e,a={}){const s=String(c[e]?.detail?.(a)??"").trim();return s.length>g?`\u2026${s.slice(-(g-1))}`:s}t(T,"detailOf");const R=t(e=>({mark:c[e.line]?.mark||"",fault:!!c[e.line]?.fault}),"hubLineOf"),S="hub";export{p as BRIEF_LINE_LIMIT,S as HUB,c as HUB_LINES,g as MESSAGE_LIMIT,T as detailOf,r as fit,R as hubLineOf,C as hubText};
@@ -19,11 +19,34 @@ BoardView.part((B) => {
19
19
  // The status line, and the moon right of the title while the owner reads away (hub/presence.mjs ownerTracker).
20
20
  const renderStatus = () => {
21
21
  $('#status').innerHTML = statusLine(state.fresh?.at, { link: state.link, watcher: state.board?.checks?.watcher, pause: B.pause?.(), waking: state.waking });
22
+ renderPause(B.pause?.());
22
23
  const moon = $('.away-moon');
23
24
  const away = Boolean(B.away?.());
24
25
  moon.classList.toggle('on', away);
25
26
  moon.setAttribute('aria-hidden', String(!away));
26
27
  };
28
+ // The hub's pause banner (HubUI.pauseBanner) at the top of the board while the gate pauses or nears its line, its forms
29
+ // posting to the hub. Pause board's own pause has no hub form, so the header's title alone says it.
30
+ const renderPause = (pause) => {
31
+ const el = $('#pauseBanner');
32
+ const html = pause?.manual === 'board' ? '' : HubUI.pauseBanner(pause);
33
+ el.hidden = !html;
34
+ BoardUI.setHTML(el, html);
35
+ };
36
+ B.on($('#pauseBanner'), 'submit', async (ev) => {
37
+ const form = ev.target;
38
+ const action = form.getAttribute('action') || '';
39
+ if (!action.startsWith('/_hub/')) return;
40
+ ev.preventDefault();
41
+ const body = new URLSearchParams(new FormData(form));
42
+ if (ev.submitter?.name) body.append(ev.submitter.name, ev.submitter.value);
43
+ try {
44
+ const r = await fetch(action, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body });
45
+ if (!r.ok) throw new Error(String(r.status));
46
+ const out = await r.json().catch(() => null);
47
+ if (out?.pause) renderPause(out.pause);
48
+ } catch { toast('Could not reach the hub. Is the host awake?'); }
49
+ });
27
50
  // A board load and its open threads landed; via is ui-header.js nextFresh's.
28
51
  function landed(via) {
29
52
  state.fresh = nextFresh(state.fresh, { via, at: new Date().toISOString(), sig: freshSig(state.board, state.threadCache, state.openItems) });
@@ -1,7 +1,7 @@
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
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
- // and Back, through a history entry of its own that carries the board's URL, so the router's pop keeps the board.
4
+ // and Back (from 900px it is not modal: no dim area, and the left pane stays live), 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.
6
6
  // A log written as a stream (headless.mjs: one stream-json event per line) reads as the run's transcript, the whole of
7
7
  // it as GET /api/transcript's rows: what Claude says, and each tool call as one line whose output opens on a tap, with a
@@ -17,6 +17,7 @@ BoardView.part((B) => {
17
17
  const more = sheet.querySelector('.reader-new');
18
18
  let opened = 0;
19
19
  let pushed = false;
20
+ let modal = true;
20
21
  let pending = null;
21
22
  let log = null;
22
23
  let stopReading = null;
@@ -109,7 +110,8 @@ BoardView.part((B) => {
109
110
  tools.innerHTML = '';
110
111
  more.hidden = true;
111
112
  body.innerHTML = '<p class="muted">Loading…</p>';
112
- sheet.showModal();
113
+ modal = !matchMedia('(min-width: 900px)').matches;
114
+ if (modal) sheet.showModal(); else sheet.show();
113
115
  history.pushState({ ...history.state, idx: history.state.idx + 1, reader: true }, '', location.href);
114
116
  pushed = true;
115
117
  return opened;
@@ -169,6 +171,8 @@ BoardView.part((B) => {
169
171
  pending = new URL(a.getAttribute('href'), location.href).href;
170
172
  close();
171
173
  });
174
+ // A non-modal sheet (from 900px) gets no cancel event from Escape, so the page's own key closes it.
175
+ B.on(document, 'keydown', (ev) => { if (ev.key === 'Escape' && sheet.open && !modal) close(); });
172
176
  B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); close(); });
173
177
  B.on(body, 'scroll', () => {
174
178
  if (!log?.stream) return;
@@ -457,6 +457,7 @@ window.BoardView = (function () {
457
457
  <div class="split">
458
458
  <main>
459
459
  ${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
460
+ <section id="pauseBanner" class="hub-view" hidden></section>
460
461
  <section id="answers" class="answer-banner" hidden></section>
461
462
  <section id="needsDeck" class="needs-deck" aria-label="Needs you" hidden></section>
462
463
  <div id="tryMain"></div>
@@ -76,12 +76,24 @@ window.BoardAttach = (function () {
76
76
  paint(id, site);
77
77
  }
78
78
 
79
+ // Pressing the button does not take focus from the text box, so the cursor stays where it was while the picker opens
80
+ // (iOS may still hide the keyboard for its picker), and a pick puts focus back in the box that held it.
81
+ let focused = null;
82
+ document.addEventListener('mousedown', (ev) => {
83
+ const input = ev.target.closest && ev.target.closest('input[data-attach]');
84
+ if (!input) return;
85
+ const a = document.activeElement;
86
+ focused = a && a.tagName === 'TEXTAREA' && input.closest('[data-composer]')?.contains(a) ? a : null;
87
+ ev.preventDefault();
88
+ });
79
89
  document.addEventListener('change', (ev) => {
80
90
  const input = ev.target.closest && ev.target.closest('input[data-attach]');
81
91
  const c = input && input.closest('[data-composer]');
82
92
  if (!c) return;
83
93
  addFiles(c, input.files);
84
94
  input.value = '';
95
+ if (focused) focused.focus({ preventScroll: true });
96
+ focused = null;
85
97
  });
86
98
  // A drag of files over a composer marks it .dragging, which outlines its drop zone. dragenter and dragleave fire on
87
99
  // each child it crosses, so the composer counts them, and a drop ends the drag.
@@ -171,12 +171,15 @@
171
171
  vv?.addEventListener('resize', fit);
172
172
  vv?.addEventListener('scroll', fit);
173
173
  window.addEventListener('resize', fit);
174
+ // A file picker closing returns focus to the window without a resize, so the sheet is measured again.
175
+ window.addEventListener('focus', fit);
174
176
  window.addEventListener('scroll', pin);
175
177
  return () => {
176
178
  clearTimeout(settle);
177
179
  vv?.removeEventListener('resize', fit);
178
180
  vv?.removeEventListener('scroll', fit);
179
181
  window.removeEventListener('resize', fit);
182
+ window.removeEventListener('focus', fit);
180
183
  window.removeEventListener('scroll', pin);
181
184
  };
182
185
  }
@@ -115,7 +115,7 @@
115
115
  <div class="group">${d.sites.map(site).join('')}</div>
116
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>
117
117
  <section class="hub-settings"><h2 class="label">Hub settings</h2>${settings}</section>
118
- <div class="hub-foot"><footer>${d.sites.length} sites. Each belongs to one Claude session, which adds it with <code>/kickflip &lt;route&gt;</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>`;
118
+ <div class="hub-foot"><footer><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>`;
119
119
  }
120
120
 
121
121
  const LANES = [['review', 'In Review'], ['progress', 'In Progress'], ['todo', 'Todo'], ['done', 'Done']];
@@ -186,7 +186,7 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
186
186
  .refresh #refresh { padding: 4px; margin-left: -4px; flex: none; }
187
187
  .refresh #status { font-size: 12px; min-width: 0; }
188
188
  .refresh .late { color: var(--color-notice); font-weight: 600; }
189
- /* The header's time keeps its text; a stopped or offline board turns it and the refresh arrow red, paused usage orange. */
189
+ /* The header's time keeps its text; a stopped or offline board turns it and the refresh arrow red. */
190
190
  .refresh .down, .refresh:has(.down) #refresh { color: var(--color-danger); }
191
191
  .refresh .hold, .refresh:has(.hold) #refresh { color: var(--color-notice); }
192
192
  .lane-github { margin-left: auto; font-size: 12px; font-weight: 400; color: var(--muted); text-align: right; }
@@ -991,7 +991,7 @@ button[aria-busy="true"]:is([data-card-bump]) svg { fill: currentColor; }
991
991
  .split > .col-handle { right: calc(var(--pane-w, 50%) - 4px); }
992
992
  .split:has(.needs-pane.widened) > .col-handle { display: none; }
993
993
  /* 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. */
994
- .sheet.reader-sheet { inset: 56px 0 0 auto; margin: 0; width: var(--pane-w, calc((100vw - var(--sidebar-w, 0px)) / 2)); max-width: none;
994
+ .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;
995
995
  height: auto; max-height: none; border-width: 0 0 0 1px; border-radius: 0; box-shadow: none; }
996
996
  .sheet.reader-sheet[open] { animation: reader-in .22s ease-out; }
997
997
  .sheet.reader-sheet.closing { animation: reader-out .18s ease-in forwards; }
@@ -4,7 +4,7 @@
4
4
  // The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh). It reads that age;
5
5
  // the board's other states only move the span's colour (the arrow follows it, style.css) and its title. watcher is
6
6
  // /api/board's checks.watcher (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md): stopped
7
- // (nothing will wake it) and an offline link turn it red, paused usage turns it orange. A busy host doesn't change it
7
+ // (nothing will wake it) and an offline link turn it red, paused usage leaves it as it is (the board's pause banner says it). A busy host doesn't change it
8
8
  // at all (docs/adr/2026-11-06-1305-a-busy-host-shows-only-on-the-hub-row-and-the-logo-dot.md).
9
9
  // link is 'online', 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it, and the icon spins).
10
10
  const statusLine = (at, { link = 'online', watcher, pause, waking = false } = {}) => {
@@ -18,7 +18,7 @@
18
18
  : watcher === 'asleep' ? 'Asleep: its session idled out. Refresh, or any tap on the page, wakes it' : '';
19
19
  if (!at && !reason) return '';
20
20
  const title = [reason, at && `Updated ${since(at)}: the last change from the host, or your last refresh`].filter(Boolean).join('. ');
21
- return `<span class="fresh${down ? ' down' : paused ? ' hold' : ''}" title="${esc(title)}">${at ? shortSince(at) : ''}</span>`;
21
+ return `<span class="fresh${down ? ' down' : ''}" title="${esc(title)}">${at ? shortSince(at) : ''}</span>`;
22
22
  };
23
23
  // What the page shows, for telling whether a change signal brought anything new; generatedAt changes on every load.
24
24
  const freshSig = (board, threads, openKeys) => JSON.stringify([{ ...board, generatedAt: null }, [...openKeys].sort().map((k) => [k, threads.get(k) ?? null])]);
@@ -51,7 +51,7 @@
51
51
  // GitHub's row while its rate limit holds is red, a fault, and says when it resets
52
52
  // (docs/adr/2026-11-09-1626-gh-refuses-while-githubs-hourly-limit-holds.md).
53
53
  const rows = integrations.map((it) => row({ icon: tile(it.id, it.ready ? 'var(--color-quiet)' : it.limited ? 'var(--color-danger)' : 'var(--color-notice)'), title: esc(it.name), wrap: true, trail: keyButton(it),
54
- sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}${devices(it)}`
54
+ sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}${devices(it)}${it.note ? `<span class="links"><span class="rd-waits">${esc(it.note)}</span></span>` : ''}`
55
55
  : `${it.limited ? '<span class="st down">rate limited</span>' : '<span class="st wait">not ready</span>'} · ${esc(it.missing)}${missingLinks(it)}` })).join('');
56
56
  return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
57
57
  <p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
@@ -92,7 +92,7 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
92
92
  }
93
93
 
94
94
  // The version is a button: a tap checks npm now (POST /_hub/check-update) and the answer shows beside it (updateNote).
95
- const footer = (d, note = '') => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip &lt;route&gt;</code>. This page refreshes every 30 seconds.<br><span class="host">${esc(d.host)}</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / <button type="button" class="version" data-check-update>v${esc(d.version)}</button>${note ? ` <span class="update-note" role="status">${esc(note)}</span>` : ''}</span></footer>`;
95
+ const footer = (d, note = '') => `<footer><span class="host">${esc(d.host)}</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / <button type="button" class="version" data-check-update>v${esc(d.version)}</button>${note ? ` <span class="update-note" role="status">${esc(note)}</span>` : ''}</span></footer>`;
96
96
  // What the hub's answer to a version tap says (hub/upgrade.mjs check()).
97
97
  const updateNote = (out) => ({
98
98
  clone: `${out.commit} · pulls with main`,
@@ -390,7 +390,8 @@
390
390
  const first = topics.find((t) => t.id === topicId)?.questions[0];
391
391
  if (first) setHash(first);
392
392
  ctx.onTopic?.(topicId);
393
- render(); scroll(0);
393
+ const y = scrollY;
394
+ render(); scroll(y);
394
395
  if (ctx.embedded) openOnBoard(topicId);
395
396
  }
396
397
  });