@lovek4d/kickflip 0.1.0 → 0.4.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.
Files changed (120) hide show
  1. package/CONTEXT.md +239 -104
  2. package/README.md +54 -4
  3. package/SKILL.md +63 -42
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +103 -40
  7. package/bin/statusline.mjs +4 -3
  8. package/bin/wait-events.mjs +2 -2
  9. package/board/arch-review.mjs +5 -0
  10. package/board/auto-review.mjs +2 -0
  11. package/board/bin/agent.mjs +1 -1
  12. package/board/bin/board.mjs +371 -82
  13. package/board/bin/builder-stop-hook.mjs +4 -3
  14. package/board/domain.mjs +8 -3
  15. package/board/finish.mjs +3 -0
  16. package/board/headless.mjs +3 -0
  17. package/board/on-host.mjs +1 -0
  18. package/board/permissions.mjs +2 -0
  19. package/board/public/app-arch.js +75 -0
  20. package/board/public/app-asks.js +66 -28
  21. package/board/public/app-banner.js +2 -2
  22. package/board/public/app-cards.js +354 -77
  23. package/board/public/app-core.js +40 -8
  24. package/board/public/app-header.js +33 -8
  25. package/board/public/app-lanes.js +238 -34
  26. package/board/public/app-needs.js +2 -0
  27. package/board/public/app-snapshot.js +45 -11
  28. package/board/public/app-threads.js +20 -21
  29. package/board/public/app.html +6 -2
  30. package/board/public/app.js +114 -33
  31. package/board/public/attach.js +39 -3
  32. package/board/public/dialogs.js +161 -118
  33. package/board/public/hub-kit.js +27 -7
  34. package/board/public/hub.css +26 -33
  35. package/board/public/linear-key.js +143 -0
  36. package/board/public/log.html +48 -0
  37. package/board/public/prompts.js +9 -8
  38. package/board/public/prototype-kit.js +6 -6
  39. package/board/public/prototype-switcher.js +13 -5
  40. package/board/public/prototype.html +1 -0
  41. package/board/public/router.js +14 -10
  42. package/board/public/rules-editor.js +4 -5
  43. package/board/public/shell.js +21 -7
  44. package/board/public/sidebar.css +51 -11
  45. package/board/public/sidebar.js +124 -19
  46. package/board/public/style.css +244 -56
  47. package/board/public/toasts.js +5 -3
  48. package/board/public/ui-cards.js +236 -66
  49. package/board/public/ui-enums.js +5 -4
  50. package/board/public/ui-header.js +23 -14
  51. package/board/public/ui-hub.js +34 -16
  52. package/board/public/ui-links.js +18 -10
  53. package/board/public/ui-markdown.js +1 -1
  54. package/board/public/ui-order.js +21 -0
  55. package/board/public/ui-page-header.js +13 -8
  56. package/board/public/ui-patch.js +53 -0
  57. package/board/public/ui-phase-bars.js +17 -0
  58. package/board/public/ui-prompts.js +8 -8
  59. package/board/public/ui-questions.js +35 -11
  60. package/board/public/ui-sidebar.js +83 -21
  61. package/board/public/ui-snapshot.js +30 -13
  62. package/board/public/ui-threads.js +38 -23
  63. package/board/public/ui.js +96 -16
  64. package/board/public/view-frame.js +1 -1
  65. package/board/public/view-hub-settings.js +67 -44
  66. package/board/public/view-hub.js +105 -13
  67. package/board/public/view-notes.js +8 -5
  68. package/board/public/view-questions.js +123 -52
  69. package/board/public/view-settings.js +235 -62
  70. package/board/review.mjs +1 -0
  71. package/board/server.mjs +18 -7
  72. package/board/store.mjs +15 -6
  73. package/board/tickets.mjs +3 -3
  74. package/board/worktree.mjs +20 -13
  75. package/docs/hub-session.md +4 -4
  76. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  77. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  78. package/hub/awake.mjs +1 -0
  79. package/hub/board-repo.mjs +1 -1
  80. package/hub/boards.mjs +4 -4
  81. package/hub/close-board.mjs +1 -1
  82. package/hub/duration.mjs +1 -0
  83. package/hub/github.mjs +3 -4
  84. package/hub/home.mjs +1 -1
  85. package/hub/hub.mjs +12 -10
  86. package/hub/incoming.mjs +2 -0
  87. package/hub/linear.mjs +2 -2
  88. package/hub/live.mjs +2 -2
  89. package/hub/new-board.mjs +2 -2
  90. package/hub/permission-denied-hook.mjs +2 -0
  91. package/hub/pr-report.mjs +3 -0
  92. package/hub/presence.mjs +1 -1
  93. package/hub/prompts.mjs +1 -1
  94. package/hub/push.mjs +2 -2
  95. package/hub/readiness.mjs +2 -0
  96. package/hub/repos.mjs +3 -2
  97. package/hub/restart-board.mjs +1 -0
  98. package/hub/routes.mjs +1 -1
  99. package/hub/schedule.mjs +1 -1
  100. package/hub/sessions.mjs +2 -2
  101. package/hub/shell.mjs +2 -1
  102. package/hub/slack.mjs +2 -0
  103. package/hub/stream.mjs +12 -12
  104. package/hub/sw.js +2 -2
  105. package/hub/trust.mjs +2 -0
  106. package/hub/try.mjs +8 -3
  107. package/hub/upgrade.mjs +1 -0
  108. package/hub/usage.mjs +3 -3
  109. package/hub/views.mjs +1 -1
  110. package/package.json +5 -1
  111. package/shared/card-prs.mjs +1 -0
  112. package/shared/colors.css +9 -6
  113. package/shared/fake-gh.mjs +105 -0
  114. package/shared/repos.mjs +1 -1
  115. package/shared/service.mjs +16 -16
  116. package/shared/skills.mjs +2 -0
  117. package/shared/usage-gate.mjs +1 -1
  118. package/starter/public/claude.js +3 -2
  119. package/bin/web-ts.mjs +0 -2
  120. package/board/public/view-polling.js +0 -87
package/hub/views.mjs CHANGED
@@ -1 +1 @@
1
- var P=Object.defineProperty;var l=(e,t)=>P(e,"name",{value:t,configurable:!0});import{clock as m,windowAt as q,holdLine as x,checksIn as z,checksADay as I,holdsOvernight as C,daySegments as M,splitPoint as H,snoozeEnd as U}from"./schedule.mjs";import{siteIcon as R}from"./icons.mjs";import{codexStatus as L}from"../shared/codex.mjs";const y=l(e=>e.charAt(0).toUpperCase()+e.slice(1),"capital"),g=l((e,t)=>`${e} ${t}${e===1?"":"s"}`,"plural"),b=l(e=>e>=60&&e%60===0?`${e/60} h`:`${e} min`,"every"),p=l(e=>{const[t,o]=e.split(":").map(Number),s=new Date;return s.setHours(t,o,0,0),m(s,s)},"timeOfDay"),V=l(e=>{const[t,o]=e.split(":").map(Number);return t*60+o},"minuteOf"),$=l(e=>`${(e/1440*100).toFixed(3)}%`,"pct"),v=l((e,t)=>`checks every ${e.window.every_minutes} min until ${m(e.window.endsAt,t)}${e.hold?`; ${x(e.hold,t)}`:""}`,"nowLine"),N=l(e=>[`${g(e.windows.length,"window")}, ${I(e)} checks a day`,C(e)&&"held overnight",e.wakes.length?`wakes ${e.wakes.map(p).join(", ")}`:"no wakes"].filter(Boolean).join(" \xB7 "),"pollingSummary"),F=l((e,t)=>e.count?`${e.count} push${e.count===1?"":"es"} queued; next send ${m(e.nextFlushAt,t)}.`:"No pushes queued.","queuedLine");function T({running:e=!1,nextAt:t,last:o=null},s){if(e)return"Checking GitHub now";const n=`Next check ${m(t,s)}`;if(!o)return n;const d=o.failed.length?"could not reach GitHub for every PR":o.watched?`${g(o.watched,"PR")}, ${o.changes.length?`${o.changes.length} changed`:"no change"}`:"no PRs watched";return`${n} \xB7 last ${m(new Date(o.at),s)}, ${d}`}l(T,"pollLine");function O(e,t,o){return{title:y(v(e,o)),lines:[T(t,o),F(e,o)],count:e.count}}l(O,"pollingNow");function B(e,t){const o={port:"Down \xB7 not answering",files:"Down \xB7 folder missing"}[e.serves]||"Down \xB7 restarting";if(e.state!=="up")return{tone:e.state==="down"?"down":"wait",word:e.state==="down"?o:y(e.state),lines:[]};if(!t)return{tone:"ok",word:"Up",lines:[]};const s=t.filter(r=>!r.ok),n=s.filter(r=>r.state==="broken"),d=t.find(r=>r.link==="events"),a=d?.listen==="asleep"?"asleep":d?.ok?"listening":"not listening",w=n.length?`${g(n.length,"link")} broken \xB7 ${a}`:s.length?`Up \xB7 ${a} \xB7 ${s.length} settling`:`Up \xB7 ${a} \xB7 every link passes`;return{tone:n.length?"down":s.length?"wait":"ok",word:w,lines:s.map(r=>({link:r.link,detail:`${r.state==="broken"?"":"settling: "}${r.detail}`}))}}l(B,"siteHealth");const A=[{value:"always",label:"Always on",description:"Codex builds and reviews; past the pause line, Opus builders take over until it resets, and while Codex is out of usage that work pauses until a usage-reset check succeeds."},{value:"auto",label:"Auto",description:"Codex builds and reviews; while Codex is past the pause line or out of usage, Opus builders take over until it resets."},{value:"off",label:"Off",description:"No Codex."}],G=l((e,t)=>({usable:t.usable,tag:t.usable?null:y(t.reason),share:e,choices:A.map(({value:o,label:s})=>({value:o,label:s,on:o===e})),description:t.usable?A.find(o=>o.value===e).description:t.words,fix:t.fix,note:t.usable?"Applies to every board. A session's own /codex-share overrides it for that session.":null}),"codexView");function ee({now:e,st:t,poll:o,sidebar:s,rows:n,links:d=new Map,code:a={},host:w="",rules:r=[],remembered:f=new Map,codexShare:i="off",codex:c=L({installed:!1})}){const h=new Set(s.sites.filter(u=>u.board).map(u=>u.route)),k=a.running&&a.running===a.onDisk?` \xB7 runs the code in its files (${a.onDisk})`:"",_=l(u=>({route:u.route,title:u.title,icon:R(u.route,u),href:`/${encodeURIComponent(u.route)}/`,...B(u,d.get(u.route)),remembered:f.get(u.route)||[]}),"site"),D=[["Boards",n.filter(u=>h.has(u.route))],["Pages",n.filter(u=>!h.has(u.route))]].filter(([,u])=>u.length).map(([u,E])=>({label:u,sites:E.map(_)}));return{hub:{tone:s.hub.tone,word:s.hub.word,detail:`${s.hub.detail}${k}`},groups:D,usage:s.usage,pause:s.pause,codex:G(i,c),empty:n.length===0,polling:{...O(t,o,e),summary:N(t.settings)},notifications:{on:t.settings.notifications.on,text:s.notifications.text,device:v(t,e)},hold:t.hold&&t.hold.why!=="off"?{text:`Notifications ${x(t.hold,e)}`,end:t.hold.why==="snoozed"}:null,rules:r.length?g(r.length,"rule"):"None yet",sites:g(n.length,"site"),host:w,total:s.total}}l(ee,"hubView");const j=[["3h","3h"],["6h","6h"],["1d","1d"],["3d","3d"],["monday","Monday"]],te="That snooze ends in the past or more than 30 days on. Pick another time.",K=l((e,t)=>e.on&&e.snoozed_until&&new Date(e.snoozed_until)>t?new Date(e.snoozed_until):null,"snoozedUntil");function ne({now:e,st:t,rules:o=[],route:s={atMac:!1,desktops:0},linear:n={set:!1}}){const d=t.settings,a=d.notifications,[w,r]=[d.windows[0],d.windows.at(-1)],f=K(a,e),i=t.count?` The ${t.count} queued push${t.count===1?" is":"es are"} dropped now.`:"",c=j.map(([h,k])=>({value:h,label:k,until:`until ${m(U(e,h,d),e)}`}));return{snoozed:f?{title:`Snoozed until ${m(f,e)}`}:null,device:v(t,e),on:{on:a.on,sub:a.on?"On. Turn off to drop every push, urgent ones too.":"Off. Every push is dropped, urgent ones too. Needs you and the badge still update.",confirm:`Turn off notifications? Every push is dropped until you turn them back on, urgent ones too.${i} Needs you and the badge still update.`},presence:a.on?s.atMac?s.desktops?{title:"At the host",sub:"Pushes go to the host only. Prompts, faults and urgent pushes still reach the phone."}:{title:"At the host",sub:"No notifications on the host, so pushes still go to the phone."}:{title:"Away from the host",sub:"Pushes go to every device. At the host means unlocked and used in the last 3 minutes."}:null,snooze:a.on&&!f?{choices:c}:null,weekends:{on:a.weekends_off,sub:`Pushes wait from Friday ${p(r.from)} until Monday ${p(w.from)}. Urgent ones still go.`},polling:N(d),rules:o,linear:n.set?{set:!0,title:`${n.workspace} workspace`,sub:"Key \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",summary:`Key set \xB7 ${n.workspace} workspace`}:{set:!1,title:"No key",sub:"Paste a personal API key from Linear\u2019s Settings \u2192 Security & access.",summary:"No key"}}}l(ne,"settingsView");const W=[5,10,15,30,60,120],Z=l(e=>Math.round(100-Math.log(e/5)/Math.log(24)*70),"shade"),S=l((e,t)=>e.windows.length===1?"All day":`${p(e.windows[t].from)} \u2013 ${p(e.windows[(t+1)%e.windows.length].from)}`,"windowRange");function oe({now:e,st:t,poll:o,select:s=null}){const n=t.settings,d=n.windows.findIndex(i=>i.from===s),a=d>=0?d:n.windows.findIndex(i=>i.from===q(e,n).from),w=n.windows.length===1,[r,f]=[n.windows[0],n.windows.at(-1)];return{select:n.windows[a].from,now:O(t,o,e),day:{segments:M(n).map(i=>{const c=n.windows[i.window];return{w:c.from,left:$(i.start),width:$(i.end-i.start),shade:`${Z(c.every_minutes)}%`,held:i.held,label:i.end-i.start>=110?b(c.every_minutes).replace(" min","m").replace(" h","h"):"",aria:`${S(n,i.window)}, every ${b(c.every_minutes)}${i.held?", pushes held":""}`}}),pins:n.wakes.map(i=>({left:$(V(i)),title:`Wake ${p(i)}`})),now:$(e.getHours()*60+e.getMinutes()),held:C(n),wakes:n.wakes.length>0},windows:n.windows.map((i,c)=>({from:i.from,range:S(n,c),ends:w?"\u2014":p(n.windows[(c+1)%n.windows.length].from),every:[...new Set([...W,i.every_minutes])].sort((h,k)=>h-k).map(h=>({minutes:h,label:b(h),on:h===i.every_minutes})),checks:`${g(z(n,c),"check")} in this window`,split:!!H(n,c),remove:!w})),hold:{on:n.hold_overnight,sub:w?"With one window there is no overnight to hold.":`Pushes queued after ${p(f.from)} wait until ${p(r.from)}. GitHub checks still run.`},wakes:n.wakes.map(i=>({value:i,label:p(i)}))}}l(oe,"pollingView");function se(e){return e?e.ok?{sent:e.sent,count:e.count,ok:!0,message:`Sent ${e.count} queued push${e.count===1?"":"es"}.`}:{sent:e.sent,count:e.count,ok:!1,message:"Could not send the queue. It stays queued for the next check."}:{sent:0,count:0,ok:!0,message:"Nothing was queued."}}l(se,"flushView");export{te as SNOOZE_REFUSED,y as capital,b as every,se as flushView,ee as hubView,v as nowLine,g as plural,T as pollLine,N as pollingSummary,oe as pollingView,ne as settingsView,B as siteHealth};
1
+ var _=Object.defineProperty;var s=(e,t)=>_(e,"name",{value:t,configurable:!0});import{clock as c,holdLine as w,sendWords as H,snoozeEnd as f}from"./schedule.mjs";import{CHOICES as T,FOREVER as k,durationEnd as V,endOf as F}from"./duration.mjs";import{siteIcon as K}from"./icons.mjs";import{codexStatus as M}from"../shared/codex.mjs";const W=s(e=>e.charAt(0).toUpperCase()+e.slice(1),"capital"),b=s((e,t)=>`${e} ${t}${e===1?"":"s"}`,"plural"),h=s(e=>{const[t,n]=e.split(":").map(Number),o=new Date;return o.setHours(t,n,0,0),c(o,o)},"timeOfDay"),B=s(e=>`checks every ${e.every===1?"minute":`${e.every} min`} while you\u2019re ${e.present?"here":"away"}${e.low?", slower while GitHub\u2019s points are low":""}`,"cadenceLine"),G=s((e,t,n)=>`${B(t)}${e.hold?`; ${w(e.hold,n)}`:""}`,"nowLine"),S=s((e,t)=>e.hold?w(e.hold,t):"pushes go out after each check","deviceLine"),Z=s(e=>`${h(e.quiet.start)} \u2013 ${h(e.quiet.end)}${e.quiet.hold?" \xB7 pushes held":""}`,"quietSummary"),Q="checks every minute while you're here, every 5 when away",X=s((e,t)=>e.count?`${e.count} push${e.count===1?"":"es"} queued; next send ${H(e.nextFlushAt,t)}.`:"No pushes queued.","queuedLine");function Y({running:e=!1,nextAt:t,last:n=null},o){if(e)return"Checking GitHub now";const l=`Next check ${c(t,o)}`;if(!n)return l;const u=n.failed.length?"could not reach GitHub for every PR":n.watched?`${b(n.watched,"PR")}, ${n.changes.length?`${n.changes.length} changed`:"no change"}`:"no PRs watched";return`${l} \xB7 last ${c(new Date(n.at),o)}, ${u}`}s(Y,"pollLine");function j(e,t,n){return{title:W(G(e,t,n)),lines:[Y(t,n),X(e,n)],count:e.count}}s(j,"pollingNow");function J(e,t){const n={port:"down \xB7 not answering",files:"down \xB7 folder missing"}[e.serves]||"down \xB7 restarting";if(e.state!=="up")return{tone:e.state==="down"?"down":"wait",word:e.state==="down"?n:e.state,lines:[]};if(!t)return{tone:"ok",word:"up",lines:[]};const o=t.filter(r=>!r.ok),l=o.filter(r=>r.state==="broken"),u=t.find(r=>r.link==="events"),a=u?.listen==="asleep"||u?.listen==="waking"?u.listen:u?.ok?"listening":"not listening",d=l.length?`degraded \xB7 ${a}`:o.length?`up \xB7 ${a} \xB7 ${o.length} settling`:`up \xB7 ${a} \xB7 every link passes`;return{tone:o.length?"wait":"ok",word:d,lines:o.map(r=>({link:r.link,detail:`${r.state==="broken"?"":"settling: "}${r.detail}`}))}}s(J,"siteHealth");const A=[{value:"always",label:"Always on",description:s(()=>"Codex implements and reviews. Work pauses when it runs out.","description")},{value:"auto",label:"Auto",description:s(e=>`Codex implements and reviews, and ${e} takes over while it's out.`,"description")},{value:"off",label:"Off",description:s(e=>`${e} implements and reviews.`,"description")}],ee=s((e,t,n)=>({usable:t.usable,tag:t.usable?null:t.reason,share:e,choices:A.map(({value:o,label:l})=>({value:o,label:l,on:o===e})),description:t.usable?A.find(o=>o.value===e).description(q[n]):t.words,fix:t.fix}),"codexView"),q={opus:"Opus",sonnet:"Sonnet",auto:"Claude"},N=[{value:"opus",label:"Opus",description:"Always Opus. Auto would give small cards to Sonnet."},{value:"auto",label:"Auto",description:"Auto: small cards get Sonnet, the rest get Opus."},{value:"sonnet",label:"Sonnet",description:"Always Sonnet. Auto would give large cards to Opus."}],te=s(e=>({model:e,builder:q[e],choices:N.map(({value:t,label:n})=>({value:t,label:n,on:t===e})),description:N.find(t=>t.value===e).description}),"claudeView");function de({now:e,st:t,poll:n,sidebar:o,rows:l,links:u=new Map,code:a={},host:d="",rules:r=[],remembered:y=new Map,integrations:p=[],codexShare:R="off",claudeModel:x="auto",codex:U=M({installed:!1}),awake:O=null}){const $=new Set(o.sites.filter(i=>i.board).map(i=>i.route)),E=!!(a.running&&a.running===a.onDisk),z=E?` \xB7 runs the code in its files (${a.onDisk})`:"",D=s(i=>({route:i.route,title:i.title,icon:K(i.route,i),href:`/${encodeURIComponent(i.route)}/`,...J(i,u.get(i.route)),remembered:y.get(i.route)||[],restart:$.has(i.route)&&i.restart===!0}),"site"),I=[["Boards",l.filter(i=>$.has(i.route))],["Pages",l.filter(i=>!$.has(i.route))]].filter(([,i])=>i.length).map(([i,L])=>({label:i,sites:L.map(D)}));return{hub:{tone:o.hub.tone,word:o.hub.word,detail:`${o.hub.detail}${z}`,restart:!E},awake:O&&g(O,e,v),groups:I,integrations:p,usage:o.usage,pause:o.pause,codex:ee(R,U,x),claude:te(x),empty:l.length===0,polling:j(t,n,e),notifications:{on:t.settings.notifications.on,text:o.notifications.text,device:S(t,e)},hold:t.hold&&t.hold.why!=="off"?{text:`Notifications ${w(t.hold,e)}`,end:t.hold.why==="snoozed"}:null,rules:r.length?b(r.length,"rule"):"None yet",sites:b(l.length,"site"),host:d,total:o.total}}s(de,"hubView");const ce="That snooze ends in the past or more than 30 days on. Pick another time.",he="That keep awake ends in the past or more than 30 days on. Pick another time.",C="until you end it",v="until you turn it off",g=s((e,t,n)=>e===k?n:`until ${c(e,t)}`,"untilWords"),P=s((e,t)=>T.map(([n,o])=>({value:n,label:o,until:g(V(e,n),e,t)})),"durationChoices"),ne=s((e,t)=>e.on?F(e.snoozed_until,t):null,"snoozedUntil");function pe({now:e,st:t,rules:n=[],route:o={atMac:!1,desktops:0},linear:l={set:!1},awake:u={until:null,unsupported:null}}){const a=t.settings,d=a.notifications,r=ne(d,e),y=t.count?` The ${t.count} queued push${t.count===1?" is":"es are"} dropped now.`:"",p=r&&`Snoozed ${g(r,e,C)}`;return{snoozed:r?{title:p,summary:r===k?"Snoozed \xB7 forever":p}:null,device:S(t,e),on:{on:d.on,sub:d.on?"On. Turn off to drop every push, urgent ones too.":"Off. Every push is dropped, urgent ones too. Needs you and the badge still update.",confirm:`Turn off notifications? Every push is dropped until you turn them back on, urgent ones too.${y} Needs you and the badge still update.`},presence:d.on?o.atMac?o.desktops?{title:"At the host",sub:"Pushes go to the host only. Prompts, faults and urgent pushes still reach the phone."}:{title:"At the host",sub:"No notifications on the host, so pushes still go to the phone."}:{title:"Away from the host",sub:"Pushes go to every device. At the host means unlocked and used in the last 3 minutes."}:null,snooze:d.on&&!r?{choices:P(e,C)}:null,awake:{unsupported:u.unsupported,on:u.until?{title:`Keeping awake ${g(u.until,e,v)}`}:null,choices:u.unsupported||u.until?null:P(e,v),summary:u.unsupported||(u.until?u.until===k?"On \xB7 forever":`On until ${c(u.until,e)}`:"Off")},weekends:{on:d.weekends_off,sub:`Pushes wait from Friday ${h(a.quiet.start)} until Monday ${h(a.quiet.end)}. Urgent ones still go.`},quiet:se(a,e),rules:n,linear:oe(l)}}s(pe,"settingsView");const m={url:"https://linear.app/settings/account/security",label:"Linear \u203A Settings \u203A Security & access \u203A Personal API keys"};function oe(e){const t=e.workspace?`${e.workspace} workspace`:"";return e.set?e.refused?{set:!0,refused:!0,title:t||"Linear key",down:"doesn\u2019t work",sub:`Linear refused the key. Make a new one in ${m.label} and paste it here.`,keys:m,summary:t?`Key doesn\u2019t work \xB7 ${t}`:"Key doesn\u2019t work"}:{set:!0,title:t,sub:"Key \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",summary:`Key set \xB7 ${t}`}:{set:!1,title:"No key",sub:`Make a personal API key in ${m.label}, then paste it here.`,keys:m,summary:"No key"}}s(oe,"linearWords");function se(e,t){const n=e.quiet,[o,l]=[h(n.start),h(n.end)],u=f(t,"tonight",e),a=[["Snooze tonight",`Until ${c(u,t)}${n.hold?`, then quiet hours hold pushes until ${c(f(u,"morning",e),t)}`:""}.`],["Snooze morning",`Until ${c(f(t,"morning",e),t)}.`],["Snooze monday",`Until ${c(f(t,"monday",e),t)}.`],["Off on weekends",e.notifications.weekends_off?`Pushes wait from Friday ${o} until Monday ${l}.`:"Off."],["Daily wake",`Every board wakes once at ${l}, when quiet hours end.`]];return{start:n.start,end:n.end,summary:Z(e),hint:Q,hold:{on:n.hold,sub:n.hold?`Pushes queued after ${o} wait until ${l}. Urgent ones still go.`:"Off. Pushes go out all night."},reads:a.map(([d,r])=>({title:d,sub:r}))}}s(se,"quietView");function fe(e){return e?e.ok?{sent:e.sent,count:e.count,ok:!0,message:`Sent ${e.count} queued push${e.count===1?"":"es"}.`}:{sent:e.sent,count:e.count,ok:!1,message:"Could not send the queue. It stays queued for the next check."}:{sent:0,count:0,ok:!0,message:"Nothing was queued."}}s(fe,"flushView");export{he as AWAKE_REFUSED,Q as QUIET_HINT,ce as SNOOZE_REFUSED,B as cadenceLine,W as capital,P as durationChoices,fe as flushView,de as hubView,G as nowLine,b as plural,Y as pollLine,Z as quietSummary,pe as settingsView,J as siteHealth};
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@lovek4d/kickflip",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "Follow and steer Claude Code sessions from your phone.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/lovek4d/kickflip.git"
9
+ },
6
10
  "type": "module",
7
11
  "bin": {
8
12
  "kickflip": "bin/kickflip.mjs"
@@ -0,0 +1 @@
1
+ var s=Object.defineProperty;var t=(r,e)=>s(r,"name",{value:e,configurable:!0});const a=/^(?:([\w.-]+\/[\w.-]+)#)?([1-9]\d*)$/,o=t(r=>Array.isArray(r?.prs)?r.prs:Number.isInteger(r?.pr)?[{number:r.pr}]:[],"storedPRs"),l=t((r,e)=>o(r).map(n=>({repo:n.repo||e,number:n.number})).filter(n=>n.repo),"cardPRs"),m=t(r=>o(r).length>0,"hasPR"),p=t(({repo:r,number:e})=>`${r}#${e}`,"prKeyOf"),x=t(({repo:r,number:e})=>`https://github.com/${r}/pull/${e}`,"prURL"),d=t((r,e)=>r.map(n=>n.repo&&n.repo!==e?p(n):`#${n.number}`).join(", "),"prNames");function R(r){const e=a.exec(String(r).trim());return e?{...e[1]?{repo:e[1]}:{},number:Number(e[2])}:null}t(R,"parsePRRef");const i=["MERGED","CLOSED"],u=t(r=>r.checks?.state==="fail"||!!r.failing?.length,"failing");function c(r){return r?.state?i.includes(r.state)?5:r.isDraft?1:u(r)?2:r.reviewDecision==="APPROVED"?4:3:0}t(c,"readiness");function h(r){if(!r.length)return-1;const e=r.map(c);return e.every(n=>n===5)?Math.max(0,r.findIndex(n=>n.state==="MERGED")):e.indexOf(Math.min(...e))}t(h,"leastReady");const E=t(r=>r.length>0&&r.every(e=>i.includes(e?.state))&&r.some(e=>e.state==="MERGED"),"allLanded");export{E as allLanded,l as cardPRs,m as hasPR,h as leastReady,R as parsePRRef,p as prKeyOf,d as prNames,x as prURL,o as storedPRs};
package/shared/colors.css CHANGED
@@ -1,13 +1,16 @@
1
- /* The one colour set for the hub and every board (docs/adr/0033-one-colour-constants-file-shared-by-the-hub-and-every-board.md).
2
- board/server.mjs serves it as colors.css and hub/hub.mjs as /_hub/colors.css, linked before each page's own CSS. */
1
+ /* The one colour set for the hub and every board (docs/adr/2026-10-29-1639-one-colour-constants-file-shared-by-the-hub-and-every-board.md).
2
+ board/server.mjs serves it as colors.css and hub/hub.mjs as /_hub/colors.css, linked before each page's own CSS.
3
+ --accent is the one human blue: new messages, links, buttons, focus rings, the owner's bubble (--accent-soft) and
4
+ planner lines; --on-accent is the label on an accent fill (docs/adr/2026-11-03-1627-one-human-blue.md). */
3
5
  :root {
4
6
  --bg: #fbfbfc; --panel: #ffffff; --line: #e6e6ea; --text: #1b1b1f; --muted: #6b6b76;
5
- --accent: #5e6ad2; --accent-soft: #eef0ff; --ok: #1f9d55; --warn: #c27c0e; --bad: #d1435b;
6
- --chip: #f1f1f4; --band: #f2f2f5; --love: #eef6ff; --claude: #fdf3e4;
7
+ --accent: #2b6cb0; --accent-soft: #eef6ff; --on-accent: #ffffff; --ok: #1f9d55; --warn: #c27c0e; --bad: #d1435b; --purple: #8250df;
8
+ --chip: #f1f1f4; --band: #f2f2f5; --claude: #fdf3e4;
7
9
  --color-quiet: var(--muted); --color-info: var(--accent); --color-notice: var(--warn); --color-danger: var(--bad); --color-success: var(--ok);
10
+ --color-merged: var(--purple);
8
11
  }
9
12
  @media (prefers-color-scheme: dark) {
10
13
  :root { --bg: #111114; --panel: #18181c; --line: #2a2a31; --text: #ececf1; --muted: #9a9aa6;
11
- --accent: #8c95f0; --accent-soft: #23253d; --ok: #3ccf7f; --warn: #e3a33a; --bad: #f0687f;
12
- --chip: #24242b; --band: #202026; --love: #1b2533; --claude: #2d2416; }
14
+ --accent: #6fa8e8; --accent-soft: #1b2533; --on-accent: #0b0b0e; --ok: #3ccf7f; --warn: #e3a33a; --bad: #f0687f; --purple: #ab7df8;
15
+ --chip: #24242b; --band: #202026; --claude: #2d2416; }
13
16
  }
@@ -0,0 +1,105 @@
1
+ var p=Object.defineProperty;var t=(e,r)=>p(e,"name",{value:r,configurable:!0});import n from"node:fs";import l from"node:path";import{PR_VIEW_FIELDS as g}from"../hub/pr-report.mjs";const o=t((e,r,s,i)=>{const a=l.join(e,r,`${String(s).replace(/[/#]/g,"__")}.json`);n.mkdirSync(l.dirname(a),{recursive:!0}),n.writeFileSync(a,JSON.stringify(i))},"write"),w=t((e,r,s)=>o(e,"pr",r,s),"writePR"),x=t((e,r,s)=>o(e,"issue-comments",r,s),"writeIssueComments"),v=t((e,r,s)=>o(e,"review-comments",r,s),"writeReviewComments"),b=t((e,r,s)=>o(e,"reviews",r,s),"writeReviews"),S=t((e,r,s)=>o(e,"merge",r,s),"writeMerge"),_=t((e,r,s)=>o(e,"ready",r,s),"writeReady"),j=t((e,r)=>o(e,"","user",r),"writeUser"),R=t((e,r)=>o(e,"","accounts",r),"writeAccounts"),c=t((e,r)=>{try{return n.readFileSync(l.join(e,r),"utf8").trim().split(`
2
+ `).filter(Boolean).map(JSON.parse)}catch{return[]}},"jsonl"),f=t(e=>c(e,"calls.jsonl"),"calls"),O=t((e,r)=>o(e,"","rate",r),"writeRate"),N=t(e=>f(e).filter(r=>r[0]==="api"&&r[1]==="graphql").map(r=>[...r[3].matchAll(/repository\(owner: "([^"]+)", name: "([^"]+)"\) \{ pullRequest\(number: (\d+)\)/g)].map(([,s,i,a])=>`${s}/${i}#${a}`)),"queried"),k=t(e=>c(e,"tokens.jsonl"),"tokens");function E({head:e="abc1234567",failing:r=[],pending:s=0,...i}={}){return{state:"OPEN",isDraft:!1,reviewDecision:"REVIEW_REQUIRED",mergeable:"MERGEABLE",mergeStateStatus:"BLOCKED",headRefOid:e,statusCheckRollup:[...r.map(a=>({__typename:"CheckRun",name:a,status:"COMPLETED",conclusion:"FAILURE"})),...Array.from({length:s},(a,u)=>({__typename:"CheckRun",name:`ci-${u}`,status:"IN_PROGRESS",conclusion:""}))],...i}}t(E,"prView");function J(e){const r=l.join(e,"bin");return n.mkdirSync(r,{recursive:!0}),n.writeFileSync(l.join(r,"gh"),`#!${process.execPath}
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const dir = ${JSON.stringify(e)};
6
+ const args = process.argv.slice(2);
7
+ fs.appendFileSync(path.join(dir, 'calls.jsonl'), JSON.stringify(args) + '\\n');
8
+ fs.appendFileSync(path.join(dir, 'tokens.jsonl'), JSON.stringify({ args, token: process.env.GH_TOKEN || null }) + '\\n');
9
+ const fail = () => { console.error('no fixture for: ' + args.join(' ')); process.exit(1); };
10
+ // The repo's own fixture for PR n, else the one for n in any repo.
11
+ const fixture = (folder, repo, n) => {
12
+ const own = path.join(dir, folder, repo.replace('/', '__') + '__' + n + '.json');
13
+ return fs.existsSync(own) ? own : path.join(dir, folder, n + '.json');
14
+ };
15
+ if (args.length === 4 && args[0] === 'auth' && args[1] === 'token' && args[2] === '-u') {
16
+ let accounts = {};
17
+ try { accounts = JSON.parse(fs.readFileSync(path.join(dir, 'accounts.json'), 'utf8')); } catch {}
18
+ if (!accounts[args[3]]) { console.error('no oauth token found for github.com account ' + args[3]); process.exit(1); }
19
+ console.log(accounts[args[3]]);
20
+ process.exit(0);
21
+ }
22
+ if (args[0] === 'issue' && args.includes('--repo')) {
23
+ const repo = args[args.indexOf('--repo') + 1];
24
+ if (args[1] === 'create') {
25
+ const n = fs.readFileSync(path.join(dir, 'calls.jsonl'), 'utf8').split('\\n').filter((l) => l.startsWith('["issue","create"')).length;
26
+ console.log('https://github.com/' + repo + '/issues/' + n);
27
+ } else if (args[1] === 'view') console.log(JSON.stringify(args[6] === 'number,url' ? { number: Number(args[2]), url: 'https://github.com/' + repo + '/issues/' + args[2] } : { state: 'OPEN', assignees: [] }));
28
+ else if (args[1] !== 'close') fail();
29
+ process.exit(0);
30
+ }
31
+ if (args.length === 6 && args[0] === 'pr' && args[1] === 'merge' && /^\\d+$/.test(args[2]) && args[3] === '--repo' && args[5] === '--squash') {
32
+ let refused = null;
33
+ try { refused = JSON.parse(fs.readFileSync(fixture('merge', args[4], args[2]), 'utf8')).error; } catch {}
34
+ if (refused) { console.error(refused); process.exit(1); }
35
+ process.exit(0);
36
+ }
37
+ if (args.length === 5 && args[0] === 'pr' && args[1] === 'ready' && /^\\d+$/.test(args[2]) && args[3] === '--repo') {
38
+ let refused = null;
39
+ try { refused = JSON.parse(fs.readFileSync(fixture('ready', args[4], args[2]), 'utf8')).error; } catch {}
40
+ if (refused) { console.error(refused); process.exit(1); }
41
+ console.log('\u2713 Pull request ' + args[4] + '#' + args[2] + ' is marked as "ready for review"');
42
+ process.exit(0);
43
+ }
44
+ // A comment or review-comment reply posted with gh api <endpoint> -f body=\u2026: answers the endpoint as its html_url.
45
+ if (args.length === 4 && args[0] === 'api' && args[2] === '-f' && args[3].startsWith('body=') && /^repos\\/[^/]+\\/[^/]+\\/(issues\\/\\d+\\/comments|pulls\\/\\d+\\/comments\\/\\d+\\/replies)$/.test(args[1])) {
46
+ console.log(JSON.stringify({ html_url: 'https://github.com/' + args[1].slice('repos/'.length) }));
47
+ process.exit(0);
48
+ }
49
+ // The hub's one GraphQL query (hub/pr-report.mjs fetchPRs), answered from the same fixtures as the REST calls: each
50
+ // aliased PR from its pr, issue-comments, review-comments and reviews fixtures, the viewer from user.json and the rate
51
+ // limit from rate.json. A PR without a fixture, or {fail: true}, comes back null with an error, as GitHub answers.
52
+ if (args.length === 4 && args[0] === 'api' && args[1] === 'graphql' && args[2] === '-f' && args[3].startsWith('query=')) {
53
+ const read = (file, fallback) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; } };
54
+ const rate = read(path.join(dir, 'rate.json'), { remaining: 4990, resetAt: '2026-10-23T12:00:00Z' });
55
+ if (rate.fail) fail();
56
+ const user = read(path.join(dir, 'user.json'), null);
57
+ const author = (u) => (u?.login ? (/\\[bot\\]$/.test(u.login) ? { __typename: 'Bot', login: u.login.replace(/\\[bot\\]$/, '') } : { __typename: 'User', login: u.login }) : null);
58
+ const list = (folder, repo, n) => { const v = read(fixture(folder, repo, n), []); return Array.isArray(v) ? v : []; };
59
+ const data = { viewer: user ? { login: user.login } : null, rateLimit: rate };
60
+ const errors = [];
61
+ for (const [, alias, owner, name, n] of args[3].matchAll(/(\\w+): repository\\(owner: "([^"]+)", name: "([^"]+)"\\) \\{ pullRequest\\(number: (\\d+)\\)/g)) {
62
+ const repo = owner + '/' + name;
63
+ const view = read(fixture('pr', repo, n), null);
64
+ if (!view || view.fail) { data[alias] = { pullRequest: null }; errors.push({ path: [alias, 'pullRequest'], message: 'Could not resolve to a PullRequest with the number of ' + n + '.' }); continue; }
65
+ const { statusCheckRollup = [], ...fields } = view;
66
+ data[alias] = { pullRequest: { ...fields,
67
+ commits: { nodes: [{ commit: { statusCheckRollup: { contexts: { nodes: statusCheckRollup } } } }] },
68
+ comments: { nodes: list('issue-comments', repo, n).map((c) => ({ databaseId: c.id, author: author(c.user), createdAt: c.created_at, body: c.body, url: c.html_url })) },
69
+ reviews: { nodes: list('reviews', repo, n).map((r) => ({ databaseId: r.id, author: author(r.user), submittedAt: r.submitted_at, body: r.body, state: r.state, url: r.html_url })) },
70
+ reviewThreads: { nodes: list('review-comments', repo, n).map((c) => ({ comments: { nodes: [{ databaseId: c.id, author: author(c.user), createdAt: c.created_at, body: c.body, url: c.html_url, path: c.path, line: c.line, replyTo: c.in_reply_to_id == null ? null : { databaseId: c.in_reply_to_id } }] } })) },
71
+ } };
72
+ }
73
+ console.log(JSON.stringify(errors.length ? { data, errors } : { data }));
74
+ if (errors.length) { console.error('gh: ' + errors[0].message); process.exit(1); }
75
+ process.exit(0);
76
+ }
77
+ // gh pr create --repo <repo> \u2026: answers the new PR's URL, numbered 100 plus the creates before it.
78
+ if (args[0] === 'pr' && args[1] === 'create' && args.includes('--repo')) {
79
+ const n = fs.readFileSync(path.join(dir, 'calls.jsonl'), 'utf8').split('\\n').filter((l) => l.startsWith('["pr","create"')).length;
80
+ console.log('https://github.com/' + args[args.indexOf('--repo') + 1] + '/pull/' + (99 + n));
81
+ process.exit(0);
82
+ }
83
+ let file;
84
+ let list = false;
85
+ if (args.length === 7 && args[0] === 'pr' && args[1] === 'view' && /^\\d+$/.test(args[2]) && args[3] === '--repo' && args[5] === '--json' && args[6] === ${JSON.stringify(g)}) file = fixture('pr', args[4], args[2]);
86
+ else if (args.length === 2 && args[0] === 'api' && args[1] === 'user') file = path.join(dir, 'user.json');
87
+ else if (args.length === 4 && args[0] === 'api' && args[1] === '--paginate' && args[2] === '--slurp') {
88
+ const match = /^repos\\/([^/]+\\/[^/]+)\\/(issues|pulls)\\/(\\d+)\\/(comments\\?since=.+|reviews)$/.exec(args[3]);
89
+ if (!match || (match[2] === 'issues' && match[4] === 'reviews')) process.exit(1);
90
+ file = fixture(match[4] === 'reviews' ? 'reviews' : match[2] === 'issues' ? 'issue-comments' : 'review-comments', match[1], match[3]);
91
+ list = true;
92
+ } else process.exit(1);
93
+ let value;
94
+ try { value = fs.readFileSync(file, 'utf8'); } catch { if (!list) fail(); value = '[]'; }
95
+ if (list) {
96
+ value = JSON.parse(value);
97
+ if (value.fail) fail();
98
+ const pages = [];
99
+ for (let i = 0; i < value.length; i += 2) pages.push(value.slice(i, i + 2));
100
+ console.log(JSON.stringify(pages));
101
+ } else {
102
+ try { if (JSON.parse(value).fail) fail(); } catch {}
103
+ console.log(value);
104
+ }
105
+ `,{mode:493}),r}t(J,"fakeGh");export{f as calls,J as fakeGh,E as prView,N as queried,k as tokens,R as writeAccounts,x as writeIssueComments,S as writeMerge,w as writePR,O as writeRate,_ as writeReady,v as writeReviewComments,b as writeReviews,j as writeUser};
package/shared/repos.mjs CHANGED
@@ -1 +1 @@
1
- var t=Object.defineProperty;var e=(r,i)=>t(r,"name",{value:i,configurable:!0});import m from"node:fs";import p from"node:os";import o from"node:path";import{fileURLToPath as s}from"node:url";const f=e(()=>o.resolve(o.dirname(s(import.meta.url)),".."),"cloneRoot"),h=e((r=f())=>m.existsSync(o.join(r,".git"))?o.resolve(r,".repos"):o.join(p.homedir(),".claude","kickflip","repos"),"reposDir");export{f as cloneRoot,h as reposDir};
1
+ var i=Object.defineProperty;var r=(o,t)=>i(o,"name",{value:t,configurable:!0});import m from"node:os";import e from"node:path";import{fileURLToPath as p}from"node:url";const f=r(()=>e.resolve(e.dirname(p(import.meta.url)),".."),"cloneRoot"),n=r((o=m.homedir())=>e.join(o,".claude","kickflip","repos"),"reposDir");export{f as cloneRoot,n as reposDir};
@@ -1,33 +1,33 @@
1
- var L=Object.defineProperty;var n=(i,d)=>L(i,"name",{value:d,configurable:!0});import u from"node:fs";import A from"node:os";import f from"node:path";import{spawnSync as T}from"node:child_process";const $="local.claude.kickflip-hub",b="local.claude.web-ts-hub",p="kickflip-hub.service";function P(i,d,{env:y=process.env,timeout:o=1e4}={}){const e=T(i,d,{encoding:"utf8",env:y,timeout:o});return{status:e.status??1,stdout:e.stdout||"",error:(e.error?.message||e.stderr||`exit ${e.status}`).trim().split(`
2
- `)[0]}}n(P,"runCommand");function U({platform:i=process.platform,home:d=A.homedir(),uid:y=process.getuid?.(),env:o=process.env,run:e=P}={}){const a=n((r,s,l={})=>{try{return e(r,s,{env:o,...l})}catch(c){return{status:1,stdout:"",error:c.message}}},"exec");if(i==="darwin")return x({home:d,uid:y,exec:a});if(i==="linux")return O({home:d,uid:y,exec:a});const t=`kickflip keeps the hub running with launchd on macOS or systemd on Linux, not on ${i}`;return{manager:null,item:"hub service",file:null,id:null,restartCommand:"node hub/hub.mjs from the kickflip folder",definition:n(()=>null,"definition"),install:n(async()=>{throw new Error(t)},"install"),restart:n(()=>t,"restart"),remove:n(()=>{},"remove"),status:n(()=>({installed:!1,running:!1,detail:t}),"status"),notes:n(()=>[],"notes")}}n(U,"hubService");const E=12e4;function x({home:i,uid:d,exec:y}){const o=f.join(i,"Library","LaunchAgents"),e=`gui/${d}`,a=f.join(o,`${$}.plist`),t=f.join(o,`${b}.plist`),r=n((s,l)=>y("launchctl",s,l),"launchctl");return{manager:"launchd",item:"hub login item",file:a,id:`${e}/${$}`,restartCommand:`launchctl kickstart -k gui/$(id -u)/${$}`,definition({node:s,entry:l,state:c,port:g,log:h}){const m=n(k=>String(k).replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;"),"xml");return`<?xml version="1.0" encoding="UTF-8"?>
1
+ var S=Object.defineProperty;var n=(i,u)=>S(i,"name",{value:u,configurable:!0});import p from"node:fs";import v from"node:os";import $ from"node:path";import{spawnSync as A}from"node:child_process";const d="local.claude.kickflip-hub",m="kickflip-hub.service";function E(i,u,{env:g=process.env,timeout:o=1e4}={}){const r=A(i,u,{encoding:"utf8",env:g,timeout:o});return{status:r.status??1,stdout:r.stdout||"",error:(r.error?.message||r.stderr||`exit ${r.status}`).trim().split(`
2
+ `)[0]}}n(E,"runCommand");function D({platform:i=process.platform,home:u=v.homedir(),uid:g=process.getuid?.(),env:o=process.env,run:r=E}={}){const a=n((t,s,c={})=>{try{return r(t,s,{env:o,...c})}catch(l){return{status:1,stdout:"",error:l.message}}},"exec");if(i==="darwin")return L({home:u,uid:g,exec:a});if(i==="linux")return T({home:u,uid:g,exec:a});const e=`kickflip keeps the hub running with launchd on macOS or systemd on Linux, not on ${i}`;return{manager:null,item:"hub service",file:null,id:null,restartCommand:"node hub/hub.mjs from the kickflip folder",definition:n(()=>null,"definition"),install:n(async()=>{throw new Error(e)},"install"),restart:n(()=>e,"restart"),remove:n(()=>{},"remove"),status:n(()=>({installed:!1,running:!1,detail:e}),"status"),notes:n(()=>[],"notes")}}n(D,"hubService");const b=12e4;function L({home:i,uid:u,exec:g}){const o=$.join(i,"Library","LaunchAgents"),r=`gui/${u}`,a=$.join(o,`${d}.plist`),e=n((t,s)=>g("launchctl",t,s),"launchctl");return{manager:"launchd",item:"hub login item",file:a,id:`${r}/${d}`,restartCommand:`launchctl kickstart -k gui/$(id -u)/${d}`,definition({node:t,entry:s,state:c,port:l,log:h}){const y=n(w=>String(w).replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;"),"xml");return`<?xml version="1.0" encoding="UTF-8"?>
3
3
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4
4
  <plist version="1.0">
5
5
  <dict>
6
- <key>Label</key><string>${$}</string>
6
+ <key>Label</key><string>${d}</string>
7
7
  <key>ProgramArguments</key>
8
- <array><string>${m(s)}</string><string>${m(l)}</string></array>
9
- <key>WorkingDirectory</key><string>${m(c)}</string>
8
+ <array><string>${y(t)}</string><string>${y(s)}</string></array>
9
+ <key>WorkingDirectory</key><string>${y(c)}</string>
10
10
  <key>EnvironmentVariables</key>
11
- <dict><key>PATH</key><string>${m(f.dirname(s))}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string><key>KICKFLIP_HUB_PORT</key><string>${g}</string></dict>
11
+ <dict><key>PATH</key><string>${y($.dirname(t))}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string><key>KICKFLIP_HUB_PORT</key><string>${l}</string></dict>
12
12
  <key>RunAtLoad</key><true/>
13
13
  <key>KeepAlive</key><true/>
14
- <key>StandardOutPath</key><string>${m(h)}</string>
15
- <key>StandardErrorPath</key><string>${m(h)}</string>
14
+ <key>StandardOutPath</key><string>${y(h)}</string>
15
+ <key>StandardErrorPath</key><string>${y(h)}</string>
16
16
  </dict>
17
17
  </plist>
18
- `},async install(s,{wait:l=n(g=>new Promise(h=>setTimeout(h,g)),"wait"),dryRun:c=!1}={}){let g=null;try{g=u.readFileSync(t,"utf8")}catch(v){if(v.code!=="ENOENT")throw v}const h=[];if(c)return g!==null&&h.push(`would stop ${e}/${b} and remove ${t}`),[...h,`would write ${a} and start ${e}/${$}`];u.mkdirSync(o,{recursive:!0}),u.writeFileSync(a,s),g!==null&&(r(["bootout",`${e}/${b}`]),u.unlinkSync(t),h.push(`stopped ${e}/${b} and removed ${t}`)),r(["bootout",`${e}/${$}`]),await l(500);const m=r(["bootstrap",e,a]);if(m.status===0)return[...h,`started ${e}/${$} from ${a}`];if(g===null)throw new Error(m.error);u.writeFileSync(t,g),u.rmSync(a,{force:!0});const k=r(["bootstrap",e,t]);throw new Error(`${m.error}; ${k.status===0?`restarted ${e}/${b} from ${t}`:`could not restart ${e}/${b} either (${k.error})`}`)},restart(){const s=r(["kickstart","-k",`${e}/${$}`],{timeout:E});return s.status===0?null:s.error},remove(){r(["bootout",`${e}/${$}`]),u.rmSync(a,{force:!0})},status(){const s=r(["print",`${e}/${$}`]),l=u.existsSync(a),c=l&&s.status===0&&/\bstate = running\b/.test(s.stdout||"");return{installed:l,running:c,detail:c?"running":l?"not running":"not installed"}},notes:n(()=>[],"notes")}}n(x,"launchd");const w=n(i=>String(i).replaceAll("%","%%").replaceAll("$","$$$$").replace(/[\r\n]/g," "),"unitValue"),S=n(i=>`"${w(i).replaceAll("\\","\\\\").replaceAll('"','\\"')}"`,"execWord");function O({home:i,uid:d,exec:y}){const o=f.join(i,".config","systemd","user",p),e=n((t,r)=>y("systemctl",["--user",...t],r),"systemctl"),a=n(t=>{const r=e(t);if(r.status!==0)throw new Error(`systemctl --user ${t.join(" ")}: ${r.error}`)},"step");return{manager:"systemd",item:"hub service",file:o,id:p,restartCommand:`systemctl --user restart ${p}`,definition({node:t,entry:r,state:s,port:l,log:c}){return`[Unit]
18
+ `},async install(t,{wait:s=n(l=>new Promise(h=>setTimeout(h,l)),"wait"),dryRun:c=!1}={}){if(c)return[`would write ${a} and start ${r}/${d}`];p.mkdirSync(o,{recursive:!0}),p.writeFileSync(a,t),e(["bootout",`${r}/${d}`]),await s(500);const l=e(["bootstrap",r,a]);if(l.status===0)return[`started ${r}/${d} from ${a}`];throw new Error(l.error)},restart(){const t=e(["kickstart","-k",`${r}/${d}`],{timeout:b});return t.status===0?null:t.error},remove(){e(["bootout",`${r}/${d}`]),p.rmSync(a,{force:!0})},status(){const t=e(["print",`${r}/${d}`]),s=p.existsSync(a),c=s&&t.status===0&&/\bstate = running\b/.test(t.stdout||"");return{installed:s,running:c,detail:c?"running":s?"not running":"not installed"}},notes:n(()=>[],"notes")}}n(L,"launchd");const k=n(i=>String(i).replaceAll("%","%%").replaceAll("$","$$$$").replace(/[\r\n]/g," "),"unitValue"),f=n(i=>`"${k(i).replaceAll("\\","\\\\").replaceAll('"','\\"')}"`,"execWord");function T({home:i,uid:u,exec:g}){const o=$.join(i,".config","systemd","user",m),r=n((e,t)=>g("systemctl",["--user",...e],t),"systemctl"),a=n(e=>{const t=r(e);if(t.status!==0)throw new Error(`systemctl --user ${e.join(" ")}: ${t.error}`)},"step");return{manager:"systemd",item:"hub service",file:o,id:m,restartCommand:`systemctl --user restart ${m}`,definition({node:e,entry:t,state:s,port:c,log:l}){return`[Unit]
19
19
  Description=kickflip hub
20
20
 
21
21
  [Service]
22
- ExecStart=${S(t)} ${S(r)}
23
- WorkingDirectory=${w(s)}
24
- Environment=${S(`PATH=${f.dirname(t)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`)}
25
- Environment=KICKFLIP_HUB_PORT=${l}
22
+ ExecStart=${f(e)} ${f(t)}
23
+ WorkingDirectory=${k(s)}
24
+ Environment=${f(`PATH=${$.dirname(e)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`)}
25
+ Environment=KICKFLIP_HUB_PORT=${c}
26
26
  Restart=always
27
27
  RestartSec=2
28
- StandardOutput=append:${w(c)}
29
- StandardError=append:${w(c)}
28
+ StandardOutput=append:${k(l)}
29
+ StandardError=append:${k(l)}
30
30
 
31
31
  [Install]
32
32
  WantedBy=default.target
33
- `},async install(t,{dryRun:r=!1}={}){return r?[`would write ${o}, enable and start ${p}`]:(u.mkdirSync(f.dirname(o),{recursive:!0}),u.writeFileSync(o,t),a(["daemon-reload"]),a(["enable",p]),a(["restart",p]),[`enabled and started ${p} from ${o}`])},restart(){const t=e(["restart",p],{timeout:E});return t.status===0?null:t.error},remove(){e(["disable","--now",p]),u.rmSync(o,{force:!0}),e(["daemon-reload"])},status(){const t=e(["is-active",p]),r=u.existsSync(o),s=r&&t.status===0&&(t.stdout||"").trim()==="active";return{installed:r,running:s,detail:s?"running":r?"not running":"not installed"}},notes(){const t=y("loginctl",["show-user",String(d),"--property=Linger"]),r=t.status===0&&/^Linger=yes$/m.test(t.stdout||"");return[{item:"hub without a login",state:r?"ok":"optional",detail:r?"the user lingers, so the hub runs with nobody logged in":"the hub stops when your last login session ends",fix:'sudo loginctl enable-linger "$USER"'},{item:"macOS-only parts",state:"note",detail:"At the host always reads away, since presence comes from macOS ioreg, so pushes reach every device"}]}}}n(O,"systemd");export{$ as LOGIN_LABEL,p as UNIT,U as hubService,P as runCommand};
33
+ `},async install(e,{dryRun:t=!1}={}){return t?[`would write ${o}, enable and start ${m}`]:(p.mkdirSync($.dirname(o),{recursive:!0}),p.writeFileSync(o,e),a(["daemon-reload"]),a(["enable",m]),a(["restart",m]),[`enabled and started ${m} from ${o}`])},restart(){const e=r(["restart",m],{timeout:b});return e.status===0?null:e.error},remove(){r(["disable","--now",m]),p.rmSync(o,{force:!0}),r(["daemon-reload"])},status(){const e=r(["is-active",m]),t=p.existsSync(o),s=t&&e.status===0&&(e.stdout||"").trim()==="active";return{installed:t,running:s,detail:s?"running":t?"not running":"not installed"}},notes(){const e=g("loginctl",["show-user",String(u),"--property=Linger"]),t=e.status===0&&/^Linger=yes$/m.test(e.stdout||"");return[{item:"hub without a login",state:t?"ok":"optional",detail:t?"the user lingers, so the hub runs with nobody logged in":"the hub stops when your last login session ends",fix:'sudo loginctl enable-linger "$USER"'},{item:"macOS-only parts",state:"note",detail:"At the host always reads away, since presence comes from macOS ioreg, so pushes reach every device"}]}}}n(T,"systemd");export{d as LOGIN_LABEL,m as UNIT,D as hubService,E as runCommand};
@@ -0,0 +1,2 @@
1
+ var g=Object.defineProperty;var l=(t,e)=>g(t,"name",{value:e,configurable:!0});import k from"node:fs";import c from"node:os";import a from"node:path";const o="mattpocock/skills",d=["improve-codebase-architecture","codebase-design","grilling","prototype"],m=l((t,e=c.homedir())=>a.join(e,".claude","skills",t,"SKILL.md"),"skillFile"),f=l((t=c.homedir())=>d.filter(e=>!k.existsSync(m(e,t))),"missingSkills"),v=l(t=>["--yes","skills","add",o,"-g","-y","-a","claude-code","--skill",...t],"addArgv"),w=l(t=>`npx skills add ${o} -g -y -a claude-code --skill ${t.join(" ")}`,"addCommand"),E=l(t=>`npx skills update ${t.join(" ")} -g -y`,"updateCommand");function x(t){let e;try{e=JSON.parse(k.readFileSync(a.join(t,".agents",".skill-lock.json"),"utf8"))}catch{return{}}return Object.fromEntries(d.flatMap(r=>{const s=e?.skills?.[r];return s?.source===o&&s.skillPath&&s.skillFolderHash?[[r,{folder:a.posix.dirname(s.skillPath),hash:s.skillFolderHash}]]:[]}))}l(x,"lockedSkills");function F({home:t=c.homedir(),run:e,gh:r=!0}={}){const s=f(t),n=x(t),h=d.filter(i=>!s.includes(i)&&n[i]);if(!r||!h.length)return{missing:s,stale:[],checked:!1};const p=e("gh",["api",`repos/${o}/git/trees/HEAD?recursive=1`,"--jq",'.tree[] | select(.type == "tree") | .path + " " + .sha']);if(p.status!==0)return{missing:s,stale:[],checked:!1};const u=new Map(p.stdout.split(`
2
+ `).filter(Boolean).map(i=>i.split(" ")));return{missing:s,stale:h.filter(i=>u.has(n[i].folder)&&u.get(n[i].folder)!==n[i].hash),checked:!0}}l(F,"skillsState");const S=l(t=>t.length<3?t.join(" and "):`${t.slice(0,-1).join(", ")} and ${t.at(-1)}`,"list");function I(t=c.homedir()){const e=f(t);return e.length?`Architecture reviews wait on ${S(e)} from ${o}, which ${e.length===1?"isn\u2019t":"aren\u2019t"} installed. Run kickflip doctor --fix on the host to add ${e.length===1?"it":"them"}.`:null}l(I,"reviewWaits");export{d as REVIEW_SKILLS,o as SKILLS_SOURCE,v as addArgv,w as addCommand,f as missingSkills,I as reviewWaits,m as skillFile,F as skillsState,E as updateCommand};
@@ -1 +1 @@
1
- var m=Object.defineProperty;var n=(e,t)=>m(e,"name",{value:t,configurable:!0});import g from"node:os";import{usageReader as D}from"../hub/usage.mjs";import{readSettingsFile as f,writeSettingsFile as x,readCodexShare as S}from"../hub/push.mjs";import{codexStatusReader as N}from"./codex.mjs";const G=n(e=>Number.isInteger(e)&&e>=50&&e<=100&&e%5===0,"validPauseAt"),d=n(e=>Number(e),"timeOf"),O=["five_hour","seven_day"];function A({claude:e,codex:t,pauseAt:s=90,keepGoing:r,now:a=Date.now()}={}){const p=G(s)?s:90,l=n(c=>p===100?null:O.find(o=>{const i=c?.[o];return i?.state==="live"&&Number.isFinite(i.used_percentage)&&i.used_percentage>=p&&(!i.resets_at||Date.parse(i.resets_at)>d(a))})||null,"past"),w=n((c,o)=>({window:o,used:o?c[o].used_percentage:null,resets_at:o?c[o].resets_at:null}),"details"),u=l(e),h=w(e,u),k=u&&r?.window===u&&Math.abs(Date.parse(r.until)-Date.parse(h.resets_at))<=3e5&&d(a)<Date.parse(r.until),_=l(t);return{pause_at:p,state:u?k?"going":"paused":"normal",...h,codex:{past:!!_,...w(t,_)}}}n(A,"usageGate");const b=new Map;function E({home:e=g.homedir(),now:t=Date.now()}={}){let s=b.get(e);s||(s={now:d(t)},s.usage=D({home:e,now:n(()=>s.now,"now")}),s.codex=N({home:e,now:n(()=>s.now,"now")}),b.set(e,s)),s.now=d(t);const r=f(e);return A({...s.usage({codexShare:S(s.codex(),e)}),pauseAt:r.pauseAt,keepGoing:r.keepGoing,now:t})}n(E,"readUsageGate");function M(e,{home:t=g.homedir()}={}){if(!/^(?:50|55|60|65|70|75|80|85|90|95|100)$/.test(String(e)))throw Object.assign(new Error("pause-at takes 50..100 in steps of 5"),{status:400});x({...f(t),pauseAt:Number(e)},t)}n(M,"setPauseAt");function C(e,{home:t=g.homedir(),now:s=Date.now()}={}){if(!["on","off"].includes(e))throw Object.assign(new Error("keep-going takes on or off"),{status:400});const r=f(t);if(e==="on"){const a=E({home:t,now:s});if(a.state!=="paused")throw Object.assign(new Error("Nothing is paused."),{status:409});r.keepGoing={window:a.window,until:a.resets_at}}else delete r.keepGoing;x(r,t)}n(C,"setKeepGoing");const K=n(e=>{try{return["github-change","check-github","card-unblocked"].includes(JSON.parse(e).kind)}catch{return!1}},"heldUsageEvent"),P=n(e=>`Work is paused: Claude's ${e.window==="five_hour"?"5-hour":"weekly"} usage is at ${Math.round(e.used)}%. Resumes at ${e.resets_at}. The owner can tap Keep going on the Hub page.`,"pauseReason");export{K as heldUsageEvent,P as pauseReason,E as readUsageGate,C as setKeepGoing,M as setPauseAt,A as usageGate,G as validPauseAt};
1
+ var S=Object.defineProperty;var o=(e,t)=>S(e,"name",{value:t,configurable:!0});import g from"node:os";import{usageReader as $}from"../hub/usage.mjs";import{readSettingsFile as x,writeSettingsFile as m,readCodexShare as A,readClaudeModel as D}from"../hub/push.mjs";import{codexStatus as N,codexStatusReader as M,effectiveCodexShare as O}from"./codex.mjs";const E=o(e=>Number.isInteger(e)&&e>=50&&e<=100&&e%5===0,"validPauseAt"),h=o(e=>Number(e),"timeOf"),G=["five_hour","seven_day"];function v({claude:e,codex:t,pauseAt:s=90,keepGoing:n,now:a=Date.now()}={}){const d=E(s)?s:90,r=o(f=>d===100?null:G.find(i=>{const c=f?.[i];return c?.state==="live"&&Number.isFinite(c.used_percentage)&&c.used_percentage>=d&&(!c.resets_at||Date.parse(c.resets_at)>h(a))})||null,"past"),l=o((f,i)=>({window:i,used:i?f[i].used_percentage:null,resets_at:i?f[i].resets_at:null}),"details"),u=r(e),p=l(e,u),C=u&&n?.window===u&&Math.abs(Date.parse(n.until)-Date.parse(p.resets_at))<=3e5&&h(a)<Date.parse(n.until),w=r(t);return{pause_at:d,state:u?C?"going":"paused":"normal",...p,codex:{past:!!w,...l(t,w)}}}o(v,"usageGate");const _=new Map;function k({home:e=g.homedir(),now:t=Date.now()}={}){let s=_.get(e);s||(s={now:h(t)},s.usage=$({home:e,now:o(()=>s.now,"now")}),s.codex=M({home:e,now:o(()=>s.now,"now")}),_.set(e,s)),s.now=h(t);const n=x(e);return v({...s.usage({codexShare:A(s.codex(),e)}),pauseAt:n.pauseAt,keepGoing:n.keepGoing,now:t})}o(k,"readUsageGate");function U(e,{home:t=g.homedir()}={}){if(!/^(?:50|55|60|65|70|75|80|85|90|95|100)$/.test(String(e)))throw Object.assign(new Error("pause-at takes 50..100 in steps of 5"),{status:400});m({...x(t),pauseAt:Number(e)},t)}o(U,"setPauseAt");function W(e,{home:t=g.homedir(),now:s=Date.now()}={}){if(!["on","off"].includes(e))throw Object.assign(new Error("keep-going takes on or off"),{status:400});const n=x(t);if(e==="on"){const a=k({home:t,now:s});if(a.state!=="paused")throw Object.assign(new Error("Nothing is paused."),{status:409});n.keepGoing={window:a.window,until:a.resets_at}}else delete n.keepGoing;m(n,t)}o(W,"setKeepGoing");const z=o(e=>{try{return["github-change","check-github","card-unblocked","card-pull","card-plan","overnight-triage","pr-review"].includes(JSON.parse(e).kind)}catch{return!1}},"heldUsageEvent");function B({home:e=g.homedir(),now:t=Date.now(),card:s={}}={}){const n=o(l=>{const{model:u,reason:p}=P(D(e),s);return{kind:"claude",model:u,reason:`${l} ${p}`}},"claude"),a=N({home:e});if(!a.usable)return n(a.words);const d=O(x(e).codexShare,a);if(d==="off")return n("Codex share is off.");const r=k({home:e,now:t});return r.codex.past?n(`Codex is at ${Math.round(r.codex.used)}%, past the ${r.pause_at}% line until ${r.codex.resets_at}.`):{kind:"codex",reason:`Codex share is ${d} and Codex is under the ${r.pause_at}% line.`}}o(B,"builderPick");const b={opus:"Opus",sonnet:"Sonnet"};function P(e,t){return e!=="auto"?{model:e,reason:`Claude model is ${b[e]}.`}:t.model?{model:t.model,reason:`Claude model is Auto and the card asks for Claude ${b[t.model]}.`}:t.size==="small"?{model:"sonnet",reason:"Claude model is Auto and the card is small."}:{model:"opus",reason:"Claude model is Auto and the card isn't small."}}o(P,"claudePick");const H=o(e=>`Work is paused: Claude's ${e.window==="five_hour"?"5-hour":"weekly"} usage is at ${Math.round(e.used)}%. Resumes at ${e.resets_at}. The owner can tap Keep going on the Hub page.`,"pauseReason");export{B as builderPick,z as heldUsageEvent,H as pauseReason,k as readUsageGate,W as setKeepGoing,U as setPauseAt,v as usageGate,E as validPauseAt};
@@ -2,7 +2,8 @@
2
2
  // relative path, e.g. <script src="claude.js"></script> or "../claude.js":
3
3
  // its own URL tells it the site's root, so links survive the /<route> prefix.
4
4
  // ClaudeSite.url('notes.html') absolute URL under the site root
5
- // await ClaudeSite.send(kind, data) sends same-origin application/json, records an event and wakes the owning session → {id}
5
+ // await ClaudeSite.send(kind, data) sends same-origin application/json, records an event and wakes the owning session → {id};
6
+ // a refusal throws with .status and .reason, the server's {error} or ''
6
7
  // await ClaudeSite.events({kind}) events newest first, each with .reply ({html} or {json}) or null
7
8
  // ClaudeSite.watch(fn, {kind}) calls fn(events) now and on change; polls every 10 s while
8
9
  // any event waits for a reply, else every 60 s; returns stop()
@@ -17,7 +18,7 @@ window.ClaudeSite = (function () {
17
18
 
18
19
  async function send(kind, data) {
19
20
  const r = await fetch(url('_claude/events'), { method: 'POST', mode: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...(data || {}), kind }) });
20
- if (!r.ok) throw new Error('send failed: ' + r.status);
21
+ if (!r.ok) throw Object.assign(new Error('send failed: ' + r.status), { status: r.status, reason: (await r.json().catch(() => null))?.error || '' });
21
22
  return r.json();
22
23
  }
23
24
 
package/bin/web-ts.mjs DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- import"./kickflip.mjs";
@@ -1,87 +0,0 @@
1
- // HubPolling, the editor inside Hub’s Polling modal (ADR 0075): the day’s windows, overnight hold
2
- // and daily wakes, in hub/views.mjs pollingView's words from GET /_hub/api/polling. ?w= names the window shown,
3
- // which a tap on the day bar changes in place. Each change posts to /_hub/settings/polling and saves at once.
4
- (function () {
5
- // The last data, which a return to the view paints at once.
6
- let last = null;
7
- const { esc } = HubUI;
8
- const ACTION = '/_hub/settings/polling';
9
- // Each change posts op and w, the shown window's start.
10
- const form = (op, w, inner, attrs = '') => `<form method="post" action="${ACTION}"${attrs}>${op ? `<input type="hidden" name="op" value="${op}">` : ''}<input type="hidden" name="w" value="${esc(w)}">${inner}</form>`;
11
-
12
- function dayBar(day, sel) {
13
- const segs = day.segments.map((g) => `<a class="seg${g.w === sel ? ' sel' : ''}${g.held ? ' held' : ''}" href="${esc(AppRouter.urlOf({ kind: 'hub', hash: '#polling', w: g.w }))}" data-w="${esc(g.w)}" style="left:${g.left};width:${g.width};--k:${g.shade}"`
14
- + ` aria-label="${esc(g.aria)}"${g.w === sel ? ' aria-current="true"' : ''}>${g.label ? `<span>${esc(g.label)}</span>` : ''}</a>`).join('');
15
- const pins = day.pins.map((p) => `<span class="pin" style="left:${p.left}" title="${esc(p.title)}" aria-hidden="true">▼</span>`).join('');
16
- const ticks = ['12am', '6am', '12pm', '6pm', '12am'].map((t, i) => `<span style="left:${i * 25}%">${t}</span>`).join('');
17
- const legend = ['<span><i style="background:var(--accent)"></i>darker checks more often</span>',
18
- day.held && '<span><i style="background:repeating-linear-gradient(135deg,var(--accent) 0 3px,var(--chip) 3px 5px)"></i>pushes held</span>',
19
- day.wakes && '<span style="color:var(--color-notice)">▼ wake</span>',
20
- '<span><i style="background:var(--color-danger);width:2px"></i>now</span>'].filter(Boolean).join('');
21
- return `<div class="day">${pins}<span class="now" style="left:${day.now}" aria-hidden="true"></span><div class="bar">${segs}</div><div class="ticks" aria-hidden="true">${ticks}</div></div><div class="legend">${legend}</div>`;
22
- }
23
-
24
- function html(d, sel, flash) {
25
- const { row, tile, nowRow } = HubUI;
26
- const w = d.windows.find((x) => x.from === sel);
27
- const chips = w.every.map((c) => `<button class="btn${c.on ? '' : ' quiet'}" name="value" value="${c.minutes}"${c.on ? ' aria-pressed="true"' : ''}>${esc(c.label)}</button>`).join('');
28
- const wakeChips = d.wakes.map((t) => `<button class="btn quiet" name="value" value="${esc(t.value)}" aria-label="Remove the ${esc(t.label)} wake">${esc(t.label)} ✕</button>`).join('');
29
- return `${HubUI.flash(flash)}<h2 class="label" style="margin-top:4px">Now</h2>
30
- <div class="group">${nowRow(d.now)}</div>
31
- <h2 class="label" id="day">Your day</h2>
32
- <div class="group"><div class="block">${dayBar(d.day, w.from)}</div></div>
33
- <h2 class="label" id="window">${esc(w.range)}</h2>
34
- <div class="group">
35
- ${form('from', w.from, `<label class="body"><span class="text title">Starts</span><input type="time" name="value" value="${esc(w.from)}" required data-autosave></label>`, ' class="row field"')}
36
- ${row({ title: 'Ends', trail: `<span class="val">${esc(w.ends)}</span>` })}
37
- ${form('every', w.from, `<span class="title">Checks every</span><span class="chips">${chips}</span><span class="sub" style="margin-top:6px">${esc(w.checks)}</span>`, ' class="block"')}
38
- ${form('', w.from, `<button class="btn quiet" name="op" value="split"${w.split ? '' : ' disabled'}>Split in two</button><button class="btn quiet danger" name="op" value="remove"${w.remove ? '' : ' disabled'}>Remove</button>`, ' class="block split"')}
39
- </div>
40
- <h2 class="label" id="quiet">Quiet times</h2>
41
- <div class="group">${form('hold', w.from, `${tile('moon', '#6366f1')}<label class="body"><span class="text"><span class="title">Hold pushes overnight</span></span><input type="checkbox" name="value" value="on" class="toggle" data-submit${d.hold.on ? ' checked' : ''}></label>`, ' class="row field"')}</div>
42
- <h2 class="label" id="wakes">Daily wakes</h2>
43
- <div class="group"><div class="block"><div class="chips" style="margin-top:0">${wakeChips ? form('wake-remove', w.from, wakeChips) : ''}${form('wake-add', w.from, '<input type="time" name="value" required aria-label="New wake time"><button class="btn quiet">+ Add</button>', ' class="field"')}</div></div></div>`;
44
- }
45
-
46
- globalThis.HubPolling = {
47
- create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
48
- const main = root;
49
- let sel = ctx.view?.w || null;
50
- let flash = null;
51
- const shown = () => (last.windows.some((x) => x.from === sel) ? sel : last.select);
52
- const paint = () => { if (last) main.innerHTML = html(last, shown(), flash); };
53
- // The shown window lives in the URL, so a reload or a shared link shows it too.
54
- const select = (w) => {
55
- sel = w;
56
- history.replaceState(history.state, '', AppRouter.urlOf({ kind: 'hub', hash: '#polling', w }));
57
- paint();
58
- };
59
- B.selectWindow = select;
60
- B.on(root, 'click', (e) => {
61
- const seg = e.target.closest?.('a.seg[data-w]');
62
- if (!seg || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
63
- e.preventDefault();
64
- select(seg.dataset.w);
65
- });
66
- const forms = HubKit.forms(B, {
67
- answer(out, action) {
68
- if (action === ACTION) { last = out; flash = null; select(out.select); }
69
- else { flash = { text: out.message, error: !out.ok }; load(); }
70
- ctx.changed?.();
71
- ctx.sidebarChanged?.();
72
- },
73
- fail(message) { flash = { text: message, error: true }; paint(); },
74
- });
75
- const load = HubKit.live(B, {
76
- url: () => `/_hub/api/polling${sel ? `?w=${encodeURIComponent(sel)}` : ''}`,
77
- paint(d) {
78
- if (!d) { if (!last) main.innerHTML = '<p class="note">Offline. Is the host awake?</p>'; return; }
79
- last = d;
80
- paint();
81
- },
82
- busy: () => forms.busy() || Boolean(root.querySelector('input:focus')),
83
- });
84
- paint();
85
- }),
86
- };
87
- })();