@lovek4d/kickflip 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +17 -14
- package/SKILL.md +2 -2
- package/bin/event-lines.mjs +1 -1
- package/board/bin/board.mjs +12 -3
- package/board/domain.mjs +7 -7
- package/board/headless.mjs +8 -8
- package/board/public/app-cards.js +2 -0
- package/board/public/app-header.js +8 -0
- package/board/public/app-lanes.js +43 -19
- package/board/public/app-snapshot.js +1 -2
- package/board/public/app-threads.js +2 -2
- package/board/public/app.html +0 -1
- package/board/public/app.js +3 -25
- package/board/public/col-resize.js +25 -44
- package/board/public/prompts.js +1 -1
- package/board/public/prototype-kit.js +1 -1
- package/board/public/prototype-switcher.js +3 -8
- package/board/public/prototype.html +2 -6
- package/board/public/router.js +16 -22
- package/board/public/shell.js +3 -39
- package/board/public/style.css +42 -95
- package/board/public/ui-cards.js +27 -23
- package/board/public/ui-enums.js +2 -2
- package/board/public/ui-page-header.js +2 -2
- package/board/public/ui-prompts.js +1 -1
- package/board/public/ui-questions.js +2 -3
- package/board/public/ui.js +1 -1
- package/board/public/view-hub.js +1 -1
- package/board/public/view-questions.js +7 -26
- package/board/server.mjs +18 -18
- package/board/store.mjs +28 -28
- package/hub/hub.mjs +8 -8
- package/hub/prompts.mjs +2 -2
- package/package.json +1 -1
- package/board/public/app-needs.js +0 -248
package/board/headless.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
var
|
|
2
|
-
`)}
|
|
3
|
-
`)}
|
|
4
|
-
`)}
|
|
5
|
-
`)}
|
|
6
|
-
`)}
|
|
7
|
-
`)}
|
|
8
|
-
`).map(o=>/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(o)).filter(Boolean).map(([,o,s,w
|
|
1
|
+
var Z=Object.defineProperty;var d=(t,e)=>Z(t,"name",{value:e,configurable:!0});import ee from"node:os";import{BRIEF_LINE_LIMIT as te}from"./hub-lines.mjs";import x from"node:path";import{fileURLToPath as oe}from"node:url";import{launchAgent as D}from"./agents.mjs";import{processStarts as M,ps as E}from"../hub/live.mjs";import{boardAt as F}from"../hub/boards.mjs";import{readHubRules as re}from"../hub/rules.mjs";import{builderRepo as O}from"./worktree.mjs";import{standardWords as B}from"./commit-message.mjs";import{appendStatus as ne,BoardError as se,briefFacts as j,readBoardRules as ae,recordedSession as ie,recordSession as Y}from"./store.mjs";import{HELPER as he,plans as de,VISUAL as le}from"./domain.mjs";const ce=oe(new URL("./bin/board.mjs",import.meta.url)),y=d(t=>`node ${ce} --site ${t}`,"cli"),H=d(t=>`git -c core.commentChar=';' rebase ${t}`,"rebase"),ue="a draft .md of your own outside the board's data folder (such as in /tmp)",pe=d((t,e,r)=>`\`${t} item notes ${e} --heading "${r}" --file <draft.md>\``,"notesCommand"),I=d((t,e="Rules from the hub and this board:")=>t.length?[e,...t.map(r=>`- ${r}`),""]:[],"ruleLines"),S=d(t=>t.length?["Related cards: read their notes for context; don't build their scope.",...t.map(e=>`- ${e.ref} "${e.title}" (${e.relation}, ${e.lane}): ${e.notes?`notes ${e.notes}`:"no notes"}, thread ${e.thread}`),""]:[],"linkLines"),L=d(t=>`The card's thread, which holds the owner's messages (who: love): ${t}. Read it live.`,"threadLine"),K=d(({route:t,key:e,worktree:r,base:i})=>[`- With no unverified calls, done finishes the card itself: it rebases onto ${i}, reruns the full test suite, fast-forwards ${i} and closes the card (on a board with pull requests it pushes the branch and opens a draft PR instead, titled by \`--title "<type>(<scope>): <what the PR does>"\`, a Conventional subject of at most 72 characters written from the whole diff, else the branch's first commit subject; with Auto-merge off it stops before the fast-forward, and the card waits for the owner's Merge).`,`- On a conflict it undoes its rebase and names the files: run \`${H(i)}\` in ${r}, resolve each conflict keeping both sides' intent, continue with \`git -c core.commentChar=';' rebase --continue\`, rerun the tests, and run done again.`,"- On failing tests, fix them, commit, and run done again.","- While another card is landing, done waits its turn and then lands; leave it running.",`- Finishing never goes to the orchestrator. If done refuses for a reason you can't fix, record it with \`${y(t)} builder call ${e} "<why>"\` and run done again: with the call it hands the card back.`],"finishSteps"),A=d(({route:t,key:e})=>`\`${y(t)} builder done ${e} --text "<what you checked>"\``,"finishCommand"),fe=d((t,e)=>[`5. To run a separate part of the work in parallel, ask the board for a helper: \`${t} agent launch ${e} --name <short-name> --task "<the whole task, with the files and what to hand back>"\`, adding --writes when it changes code or runs tests (it gets its own worktree off your branch, and waits for a free implementer slot). Then \`${t} agent wait ${e} --name <short-name>\`, which prints its hand-back; it returns within 10 minutes, so run it again while it says still running, and do the task yourself if it says the helper exited. Merge a writing helper's work with \`${t} agent merge ${e} --name <short-name>\` before you test and finish.`],"helperSteps");function we({route:t,key:e,ref:r,title:i,notes:n,thread:a,worktree:h,base:u,rules:p=[],links:l=[]}){const c=y(t);return[`You are the headless builder for card ${r} (${e}, "${i}") on the kickflip board /${t}. The board launched you; nobody watches this session, so never wait on a person. Work only in ${h}. Never restart the hub or a board server, and never push.`,"",`1. Run \`${c} builder start ${e} --kind build\`. If it refuses, stop.`,`2. Report progress with \`${c} builder progress ${e} --phase reading|building|testing|checking|landing "<plain, 80 characters or fewer>"\`, at least every 10 minutes. The board adds the phase word; don't start the text with it. The phases:`," - reading: the notes and the code, until the first test is written."," - building: the tests and the code, the ADR and docs, and the commit."," - testing: the full suite run before you finish."," - checking: your own review of the diff before you hand back; the Orchestrator's review of your calls continues on the same 4th bar."," - landing: set by the finish step itself; you never report it.",`3. Decide, record the call (step 4) and carry on: the orchestrator reviews your calls after the build. Ask it only when you are blocked, meaning every way forward would likely be thrown away: the spec contradicts itself or the code, a rule needs the owner's go before code is written, or the answer is a fact only the orchestrator has. Then run \`${c} builder ask ${e} "<question>"\`, do any work the answer can't change, and wait with \`${c} builder asks ${e} --wait\`, which returns within 10 minutes, so run it again while it says still waiting. If no answer comes in 30 minutes, record the question as a call, "Question: <question>", and hand back.`,`4. Record each judgment call the notes leave open with \`${c} builder call ${e} "<call>"\` instead of guessing silently or asking. A problem you find outside your card is a call "Found: <what>" for the orchestrator to file at review; ask about it at once only when it stalls other cards' builds or lands.`,...fe(c,e),"",n?`Spec: the card's notes, ${n}, all sections, where later sections override earlier ones.`:"The card has no notes file: build from its title, and record what you had to decide as calls.",`${L(a)} Read it with the notes: a message of the owner's is an instruction, but where it contradicts the notes, the notes win; that contradiction counts as blocked (step 3), so ask with \`${c} builder ask ${e} "<question>"\` and wait as step 3 says. Read the thread again in checking, before you finish, for anything the owner wrote during the build.`,"",...S(l),...I(p),"Write tests first, and run the full suite once, in the foreground, at the end. Where the repo keeps ADRs, write one named with a timestamp: docs/adr/YYYY-MM-DD-HHMM-<slug>.md.",`Commit with \`git -c core.commentChar=';' commit -F <file>\`: ${B(r)}. Land refuses any other shape.`,"",`To finish, run ${A({route:t,key:e})}.`,"- If you recorded a judgment call, it hands the card back for the orchestrator to verify your calls; stop there: the orchestrator's verification finishes the card.",...K({route:t,key:e,worktree:h,base:u}),"","Never push."].join(`
|
|
2
|
+
`)}d(we,"builderBrief");function _({route:t,key:e,ref:r,title:i,thread:n,worktree:a,base:h,answers:u=[],resumed:p=!1,why:l}){const c=y(t);return[p?`The board resumes you, the builder of card ${r} (${e}, "${i}") on the kickflip board /${t}, to land your build: ${l??"the orchestrator verified your judgment calls"}, and it waits only to land. Nobody watches this session, so never wait on a person. Work only in ${a}, never restart the hub or a board server, and never push.`:`You are the headless lander for card ${r} (${e}, "${i}") on the kickflip board /${t}. Its build is done and the orchestrator verified its judgment calls; it waits only to land. Nobody watches this session, so never wait on a person. Work only in ${a}, never restart the hub or a board server, and never push.`,"",`1. Run \`${c} builder start ${e} --kind build --label Lander --phase landing --progress "taking up the verified build"\`. If it refuses, stop.`,`2. Report progress with \`${c} builder progress ${e} --phase landing "<plain, 80 characters or fewer>"\`. The board adds the phase word; don't start the text with it.`,...u.length?["3. The owner answered the builder's deferred asks. Where an answer changes the build, change it, with its tests, and commit:",...u.map(o=>`- ${o.ask} "${o.question}": ${o.answer}`)]:[],"",L(n),`Commit with \`git -c core.commentChar=';' commit -F <file>\`: ${B(r)}. Land refuses any other shape, so reword a commit the build left in another.`,"",`To finish, run ${A({route:t,key:e})}.`,...K({route:t,key:e,worktree:a,base:h}),"","Never push."].join(`
|
|
3
|
+
`)}d(_,"landerBrief");function G({route:t,key:e,ref:r,title:i,thread:n,worktree:a,base:h,pr:u,resumed:p=!1}){const l=y(t);return[...p?[`The board resumes you, the builder of card ${r}, for this run: the lines below are your brief for it.`]:[],`You are the headless rebase run for card ${r} (${e}, "${i}") on the kickflip board /${t}. Its PR #${u} conflicts with ${h}; rebase its branch onto ${h} so the PR is clean again. Nobody watches this session, so never wait on a person. Work only in ${a}, never restart the hub or a board server, and never push.`,"",`1. Run \`${l} builder start ${e} --kind build --label Rebase --phase building --progress "Rebase run started \xB7 fetching ${h}"\`. If it refuses, stop.`,`2. Run \`git fetch origin\`, then \`${H(`origin/${h}`)}\`. Resolve each conflict keeping both sides' intent, \`git add\` it and continue with \`git -c core.commentChar=';' rebase --continue\`. Report each step with \`${l} builder progress ${e} --phase building "<line>"\`, the board adds the phase word, in these words: "Rebasing onto ${h} \xB7 resolving <n> files", then "Rebasing onto ${h} \xB7 suite running".`,"3. Run the full test suite once, in the foreground. Fix what the rebase broke, and commit each fix with `git -c core.commentChar=';' commit -F <file>`. A push with commits of its own is no longer rebase-only, so a merge after an approval won't count it as trivial.",`4. If a conflict or a failing test is past what you can resolve with confidence, run \`git -c core.commentChar=';' rebase --abort\` if a rebase is under way, record it with \`${l} builder call ${e} "Rebase failed in <the files or tests> \xB7 <why>"\`, and run the finish step: with the call it hands the card to the orchestrator.`,"",L(n),"",`To finish, run ${A({route:t,key:e})}, the text saying what you resolved. It reruns the suite and pushes the branch to PR #${u} with --force-with-lease; the PR stays as it is.`,"- If it refuses because someone pushed to the branch since your fetch, fetch and rebase again (step 2), then run done again.","- On failing tests, fix them, commit, and run done again.","","Never push."].join(`
|
|
4
|
+
`)}d(G,"rebaseBrief");function U(t,e){if(!t?.text)throw new Error("no orchestrator's brief: the board launches a grill, prototype or visual agent only from one (item brief)");return[`Start from the orchestrator's brief, its notes section "${t.heading}": what the discussion settled, what is still open, and what your ${e} should cover, with the files that matter. Read it before the thread and the notes:`,"",t.text,""]}d(U,"orchestratorsBrief");function me({route:t,key:e,ref:r,title:i,notes:n,thread:a,kind:h,repo:u,brief:p,rules:l=[],links:c=[],text:o,files:s=[],question:f,note:w,choice:m,prototype:$=null}){const b=y(t),g=h==="grill";return[`You are the headless ${g?"grill":"prototype"} planner for card ${r} (${e}, "${i}") on the kickflip board /${t}. The board launched you; nobody watches this session, so never wait on a person: the owner answers on the board, and a later planner or builder picks up from their answers. Read and run in ${u}; write no output code ${g?"there":`in ${u} itself`}, never restart the hub or a board server, and never push.`,"",...U(p,g?"questions":"takes"),`1. Run \`${b} builder start ${e} --kind ${h}\`. If it refuses, stop.`,`2. Gather the context yourself: ${n?`the card's notes, ${n}, all sections, where later sections override earlier ones; `:"the card has no notes file yet; "}its thread, ${a}, the repo's docs/adr and CONTEXT.md, and the cards its notes and thread link.`,`3. Report progress with \`${b} builder progress ${e} "<plain, 80 characters or fewer>"\` at least every 10 minutes.`,g?`4. Find every decision the card leaves open: read the code it touches and answer what the code answers yourself. Post each decision left as a question for the owner with \`${b} question add --file <json>\` on topic item:${e}, one per decision, your recommendation first. Once you finish, the orchestrator screens them before the owner sees them, settling the ones the board already answers and passing the owner the rest. Invoke the grilling skill with the Skill tool and run it as one round: its questions go to the board with question add, never into the chat, and you never wait for an answer; after the round, finish. (With no Skill tool, follow this step as written.) When the code shows the card has separable parts, each a build of its own, propose splitting it into a wave of smaller cards as one question ahead of the prototype question: its options "Split as proposed" and "Keep as one card", your recommendation first. Slice it as ~/.claude/skills/to-tickets/SKILL.md step 3, "Draft vertical slices", says (read that step; you can't invoke the skill, and skip its quiz and publish steps). The first card is the thinnest end-to-end path. The question's context lists each proposed card with its title, its scope, the files it touches and the earlier proposed cards it waits on, or none: a card waits on an earlier one only when it builds on that card or shares its seam (the same function, component, CLI command or doc paragraph); sharing only a file waits on nothing. Write the same breakdown in your notes section. On Split the orchestrator files the cards when the card stages; never file them yourself.`+($?` The card keeps the owner's answer to its prototype question, ${$}, so ask no "Prototype round first?" question: once every question you ask is answered, or as you finish when you ask none, the board moves the card ${$==="yes"?"to prototyping":"to staging"}.`:' End every grill with one more question, "Prototype round first?", whose options are yes and no, with "prototype_gate": true in its `question add --file` entry: once the owner has answered every question the board moves the card to prototyping on yes, or to staging on no, so nothing else moves it. Recommend yes when the card changes the hub\'s or a board\'s UI, no otherwise. Ask it even when it is your only question.'):`4. Invoke the prototype skill with the Skill tool and build the round as take files, one per option: record them with \`${b} prototype add <topic> --card ${e} --take <K>:<label> \u2026\`, write the take files it prints, built from the repo's existing components, helpers and scripts fed mock data (the prototype template loads the board's scripts, so a take reuses the board's components; a hub-page take reuses them by path or copy), each take showing the states it needs inside itself, then post one question whose url opens prototype-<topic>.html with \`${b} question add --file <json>\` on topic item:${e}, never asking in the chat. Build in situ only where one of the board's rules asks for it: then, run \`${b} prototype base\`, which makes it or moves it to main and prints its path, whether its build script is there and the listener a page loads. When the build script is missing, write one there that serves the app and prints "port <n>" (board/build-script.mjs). Add one page to the worktree, at a path no other round uses, built from the repo's real components, helpers and scripts fed mock data, fully interactive; it reads the take from ?variant= and the state from ?state=, and loads the listener so \`KickflipPrototype.on((take, state) => \u2026)\` re-renders it in place when the viewer switches. Record it with \`${b} prototype add <topic> --card ${e} --served <path> --take <K>:<label> \u2026 --state <key>:<label> \u2026\`, one take per option and one state for each state that matters to the question, then post one question whose url opens prototype-<topic>.html with \`${b} question add --file <json>\` on topic item:${e}, never asking in the chat. (With no Skill tool, follow this step as written.)`,...o?[` The owner asked for this ${g?"grill":"prototype"}, writing: "${o}"`]:[],...s.length?[` The owner attached: ${s.map(k=>k.path).join(", ")}.`]:[],...f?[` The owner asked for prototypes of question ${f}${w?`, noting "${w}"`:""}${m?`, leaning to option ${m}`:""}: draw that question's next round, its question naming round_of ${f}.`]:[],`5. Record what you learned in the card's notes, naming each answered question's id: write it in ${ue}, then run ${pe(b,e,`${g?"Grill":"Prototype"} (<MM/DD>)`)}, which appends it as a section. The notes file is read-only; never write into it. Anything you leave open becomes a question with question add, never only a line in the notes, so the card can't go ready past it. A question's text is one line of at most ${te} characters (question add refuses a longer one); put the detail in its context and the options' details, which take markdown.`,...g?[`6. Where the owner asked for prototypes, or a rule here wants a UI prototype first, end the card \`${b} item set ${e} --phase prototyping\` instead and skip the prototype question. Otherwise ${$?"the board":"the prototype question"} is what moves the card on: never set it ready yourself, and never leave a Next that asks the owner to mark it ready.`]:[],"",...S(c),...I(l,"Rules from the hub and this board, written for the orchestrator: where one tells you to push, land, restart the hub or a board server, or do the orchestrator's work, it isn't yours; you never push or restart:"),`To finish, run \`${b} builder done ${e} --text "<what you asked>"\`.`,"","Never push."].join(`
|
|
5
|
+
`)}d(me,"plannerBrief");function be({route:t,key:e,ref:r,title:i,notes:n,thread:a,repo:h,agent:u,brief:p,rules:l=[],links:c=[],text:o,files:s=[]}){const f=y(t);return[`You are the headless visual agent for card ${r} (${e}, "${i}") on the kickflip board /${t}. The card's thread wants a visual: one HTML page that answers in a visual way, such as a diagram, a flow or a comparison. It is not a prototype or a grill: no question, no takes. The board launched you; nobody watches this session, so never wait on a person. Read and run in ${h}; write no code there, never restart the hub or a board server, and never push.`,"",...U(p,"page"),`1. Run \`${f} builder start ${e} --kind visual --agent ${u}\`. If it refuses, stop.`,`2. Gather what the page needs: ${n?`the card's notes, ${n}; `:""}its thread, ${a}, and the code or docs it is about.`,...o?[` The owner wrote, asking for it: "${o}"`]:[],...s.length?[` The owner attached: ${s.map(w=>w.path).join(", ")}.`]:[],"3. Write one HTML file outside the repo, such as in a temporary folder. Keep its own CSS and script inline; it may load a library such as Mermaid or D3 from a CDN. Make it read well at phone width (390px) and on a desktop, in the dark and the light theme (prefers-color-scheme).",`4. Place it with \`${f} visual add ${e} --file <page.html> --title "<what it shows>"\`, which copies it to the board and links it in the thread.`,"","Write nothing else: no notes, questions, card state, Next or progress lines.","",...S(c),...I(l,"Rules from the hub and this board, written for the orchestrator: where one tells you to push, land, restart the hub or a board server, or do the orchestrator's work, it isn't yours; you never push or restart:"),`To finish, run \`${f} builder done ${e} --agent ${u}\`.`,"","Never push."].join(`
|
|
6
|
+
`)}d(be,"visualBrief");function ge({route:t,key:e,ref:r,title:i,notes:n,thread:a,agent:h,task:u,writes:p,cwd:l,rules:c=[],links:o=[]}){const s=y(t);return[`You are the headless helper ${h} on card ${r} (${e}, "${i}") on the kickflip board /${t}. Another agent working on the card asked the board for you to do one task beside it, and waits on your hand-back. Nobody watches this session, so never wait on a person. Never restart the hub or a board server, and never push.`,p?`Work only in ${l}, your own worktree, branched from the card's: commit your work there with \`git -c core.commentChar=';' commit -F <file>\`: ${B(r)}. The card's builder merges your branch, so leave nothing uncommitted.`:`Read and run in ${l}; change no files there and commit nothing.`,"","The task:",u,"",`1. Run \`${s} builder start ${e} --kind helper --agent ${h}\`. If it refuses, stop.`,`2. Report progress with \`${s} builder progress ${e} --agent ${h} --phase reading|building|testing|checking "<plain, 80 characters or fewer>"\` at least every 10 minutes; the board adds your name and the phase word.`,"3. Do only the task. Don't ask the orchestrator or the owner, record no judgment calls and post no questions: put any decision you had to make in your hand-back.",n?` The card's notes, for context: ${n}.`:"",` ${L(a)}`,"",...S(o),...I(c,"Rules from the hub and this board, written for the orchestrator: where one tells you to push, land, restart the hub or a board server, or do the orchestrator's work, it isn't yours; you never push or restart:"),`To finish, run \`${s} builder done ${e} --agent ${h} --text "<your hand-back: what you did or found, ${p?"what you committed, ":""}and any decision you made>"\`.`,"","Never push."].filter((f,w,m)=>f!==""||m[w-1]!=="").join(`
|
|
7
|
+
`)}d(ge,"helperBrief");const $e=6e4;function ye(t,{starts:e=M}={}){const r=t.filter(n=>n.pid&&n.at).map(n=>n.pid),i=r.length&&e(r)||new Map;return t.filter(n=>{const a=i.get(n.pid);return a!==void 0&&a<=Date.parse(n.at)&&a>=Date.parse(n.at)-$e})}d(ye,"launchedAgents");function Pe(t,{starts:e=M,kill:r=d((n,a)=>process.kill(-n,a),"kill"),killAfter:i=5e3}={}){const n=ye(t,{starts:e});for(const{pid:a}of n){try{r(a,"SIGTERM")}catch{continue}setTimeout(()=>{try{r(a,"SIGKILL")}catch{}},i).unref()}return n.length}d(Pe,"stopAgents");async function We(t,{grace:e=5e3}={}){const r=["-A","-o","pid=,ppid=,pgid=,args="],i=E(r)??E(r);if(i===null)throw new se("ps gave no answer, so nothing was stopped; run it again");const n=i.split(`
|
|
8
|
+
`).map(o=>/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(o)).filter(Boolean).map(([,o,s,f,w])=>({pid:Number(o),ppid:Number(s),pgid:Number(f),args:w})),a=new Set(n.filter(o=>t.includes(o.pgid)).map(o=>o.pid));for(let o=!0;o;){o=!1;for(const s of n)!a.has(s.pid)&&a.has(s.ppid)&&(a.add(s.pid),o=!0)}const h=n.find(o=>o.pid===process.pid)?.pgid,u=n.filter(o=>a.has(o.pid)&&o.pgid>1&&o.pgid!==h),p=[...new Set(u.map(o=>o.pgid))],l=d(o=>{for(const s of p)try{process.kill(-s,o)}catch{}},"signal");l("SIGTERM");const c=Date.now()+e;for(;u.some(o=>N(o.pid))&&Date.now()<c;)await new Promise(o=>setTimeout(o,50));return u.some(o=>N(o.pid))&&l("SIGKILL"),u.map(({pid:o,args:s})=>({pid:o,args:s}))}d(We,"stopRuns");const N=d(t=>{try{return process.kill(t,0),!0}catch(e){return e.code==="EPERM"}},"pidAlive");async function De(t,e,{launch:r,handBack:i,alive:n=N,held:a=d(()=>!1,"held"),stopped:h=new Set,paused:u=new Set,limitedUntil:p=d(()=>null,"limitedUntil"),atLimit:l=d(async()=>{},"atLimit")}){const c={};for(const[o,s]of Object.entries(t)){if(!e.has(o))continue;if(h.has(o)){c[o]={...s,handedBack:!0};continue}if(u.has(o)||s.pid!==null&&n(s.pid)||s.handedBack){c[o]=s;continue}const f={key:o,...s.kind?{kind:s.kind}:{},...s.model?{model:s.model}:{}},w=s.pid!==null?p(s):null;if(w){await l(f,w),c[o]={...s,pid:null};continue}if(a(s.kind??"claude")){c[o]=s;continue}if(s.pid!==null&&s.relaunched){await i(f),c[o]={...s,handedBack:!0};continue}const{key:m,...$}=f;c[o]={...$,...await r(f),relaunched:s.pid!==null||!!s.relaunched}}return c}d(De,"reviveBuilders");const V=d((t,e)=>x.join(ee.homedir(),".claude","kickflip","logs",`${e}-${t}-${new Date().toISOString().replace(/[:.]/g,"-")}.log`),"logFile");async function ve(t,{key:e,kind:r="claude",model:i,agent:n="main",brief:a,text:h,files:u,question:p,note:l,choice:c,task:o,writes:s,worktree:f},{lander:w=!1}={}){const m=await j(t,e),$=F(t)?.route??x.basename(t),b=[...re(),...await ae(t)].map(J=>J.text),{base:g,repo:k}=O(t),R=de({kind:r}),T=r===he,C=!R&&!T&&!w&&m.rebasePR!==null,P=r===le,W=R?k:T?f??m.worktree??k:m.worktree,v={...m,route:$,base:g,repo:k,rules:b,kind:r,agent:n,brief:a,text:h,files:u,question:p,note:l,choice:c,task:o,writes:s,cwd:W,pr:m.rebasePR},Q=P?"Visual agent":T?`Helper ${n}`:R?"Planner":w?"Lander":C?"Rebase run":"Builder",X=P?be(v):T?ge(v):R?me(v):w?_(v):C?G(v):we(v),q=await D({kind:R||T?"claude":r,model:i,cwd:W,site:t,prompt:X,log:V(e.split("/")[1],Q.split(" ")[0].toLowerCase())});return await Y(t,e,q.sessionId,q.pid,n,x.basename(q.log)),q}d(ve,"launchBuilder");const Me=d(({ref:t,worktree:e,cleared:r})=>`The board paused your build of card ${t}, and the pause has cleared: ${r}. Carry on where you stopped, in ${e}, following your brief as before. Your builder record is still open, so don't run builder start again.`,"resumeBrief");async function z(t,{key:e,kind:r="claude",model:i,session:n,brief:a,fresh:h=!1,freshLaunch:u={}}){if(r!=="claude"||!n||h)return{...await ve(t,{key:e,kind:r,model:i},u),resumed:!1};const p=await j(t,e),l=await D({kind:r,model:i,cwd:n.worktree??p.worktree,site:t,prompt:a(p),resume:n.id,log:V(e.split("/")[1],"builder")});return await Y(t,e,l.sessionId??n.id,l.pid,"main",x.basename(l.log)),{...l,resumed:!0}}d(z,"resumeBuilder");async function Ee(t,{key:e,kind:r="claude",model:i},{why:n,fresh:a=!1}={}){const h=await ie(t,e),u=await j(t,e),p=u.rebasePR!==null,l=h&&(!p||h.worktree===u.worktree)?h:null,c=F(t)?.route??x.basename(t),{base:o}=O(t),s=d(m=>(p?G:_)({...m,route:c,base:o,pr:m.rebasePR,why:n,resumed:!0}),"brief"),f={lander:!p};let w;try{w=await z(t,{key:e,kind:r,model:i,session:l,brief:s,fresh:a,freshLaunch:f})}catch(m){if(!l)throw m;w=await z(t,{key:e,kind:r,model:i,session:null,brief:s,freshLaunch:f})}return w.resumed&&await ne(t,e,"builder-resumed-to-land",{session:w.sessionId??l.id}),w}d(Ee,"landByBuilder");export{ce as BOARD_CLI,we as builderBrief,ge as helperBrief,Ee as landByBuilder,_ as landerBrief,ve as launchBuilder,ye as launchedAgents,N as pidAlive,me as plannerBrief,G as rebaseBrief,Me as resumeBrief,z as resumeBuilder,De as reviveBuilders,Pe as stopAgents,We as stopRuns,be as visualBrief};
|
|
@@ -396,6 +396,8 @@ BoardView.part((B) => {
|
|
|
396
396
|
B.renderBanner(state.board);
|
|
397
397
|
const unread = el.querySelector('summary .card-unread');
|
|
398
398
|
const line = unread?.parentNode;
|
|
399
|
+
const dot = unread && [unread.nextElementSibling, unread.previousElementSibling].find((n) => n?.matches('.sep'));
|
|
400
|
+
dot?.remove();
|
|
399
401
|
unread?.remove();
|
|
400
402
|
if (line && !line.children.length) line.remove();
|
|
401
403
|
if (it && !el.querySelector('.detail')) { el.insertAdjacentHTML('beforeend', detail(it)); }
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// The board view's header and GitHub status: the time since the last change signal or refresh, refresh, the In Review
|
|
2
2
|
// header's GitHub line (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md);
|
|
3
3
|
// the GitHub mark's Hub links sheet and a new board's setup checklist (docs/adr/2026-10-29-1639-one-board-registry-for-every-hub-feature.md);
|
|
4
|
+
// the ?, the way to Waiting on you at every width, with the open-question count as its badge
|
|
5
|
+
// (docs/adr/2026-11-12-0700-the-board-has-no-needs-you-deck-or-pane.md);
|
|
4
6
|
// and the wake: any tap on an asleep board's page, refresh included, asks the hub to resume its session
|
|
5
7
|
// (docs/adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md). Needs app-core.js.
|
|
6
8
|
BoardView.part((B) => {
|
|
@@ -15,6 +17,12 @@ BoardView.part((B) => {
|
|
|
15
17
|
$('#setup').hidden = !d.setup;
|
|
16
18
|
$('#setup').innerHTML = setupCard(d.setup);
|
|
17
19
|
if ($('#linksSheet').open) $('#linksList').innerHTML = linksList(d.links);
|
|
20
|
+
const open = (d.owner || []).filter((q) => q.state === 'open').length;
|
|
21
|
+
const ask = $('#questionsLink');
|
|
22
|
+
const badge = ask.querySelector('.q-badge');
|
|
23
|
+
badge.hidden = !open;
|
|
24
|
+
badge.textContent = open ? String(open) : '';
|
|
25
|
+
ask.setAttribute('aria-label', open ? `Open questions, ${open} open` : 'Open questions');
|
|
18
26
|
}
|
|
19
27
|
// The status line, and the moon right of the title while the owner reads away (hub/presence.mjs ownerTracker).
|
|
20
28
|
const renderStatus = () => {
|
|
@@ -76,7 +76,7 @@ BoardView.part((B) => {
|
|
|
76
76
|
const openReviews = new Set();
|
|
77
77
|
const reviewsHtml = (d) => BoardUI.reviewLane(d.reviews, {
|
|
78
78
|
open: { lane: store.get('lanes', {}).reviews, earlier: store.get(FOLDS['reviews-earlier'].store, false), cards: openReviews } });
|
|
79
|
-
// Opens PR number's review and its lane or fold, for a
|
|
79
|
+
// Opens PR number's review and its lane or fold, for a push's #review=<number>.
|
|
80
80
|
function openReview(number) {
|
|
81
81
|
openReviews.add(number);
|
|
82
82
|
const s = store.get('lanes', {}); s.reviews = true; store.set('lanes', s);
|
|
@@ -85,17 +85,43 @@ BoardView.part((B) => {
|
|
|
85
85
|
$(`[data-review="${number}"]`)?.scrollIntoView({ block: 'start' });
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// docs/adr/2026-11-12-0600-the-todo-header-has-a-filter.md
|
|
89
|
+
// The Todo header's filter: one box per property a Todo card can have, all checked until the owner unchecks one. An
|
|
90
|
+
// unchecked box hides every Todo card with its property and nothing else, so a card that is held and blocked hides
|
|
91
|
+
// when either box is unchecked. The unchecked boxes are kept on this device (store 'todoFilter'); the menu's open state is in memory.
|
|
92
|
+
const FILTER_BOXES = [
|
|
93
|
+
['ready', 'Ready', (i) => ['ready', 'staging'].includes(i.phase)],
|
|
94
|
+
['discussing', 'Discussing', (i) => ['discussing', 'new'].includes(i.phase)],
|
|
95
|
+
['planning', 'Planning', (i) => ['grilling', 'prototyping'].includes(i.phase)],
|
|
96
|
+
['blocked', 'Blocked', (i) => Boolean(i.blocked)],
|
|
97
|
+
['held', 'Held', (i) => Boolean(i.held)],
|
|
98
|
+
];
|
|
99
|
+
const FUNNEL = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 3H2l8 9.46V19l4 2v-8.54z"/></svg>';
|
|
100
|
+
let filterOpen = false;
|
|
101
|
+
const unchecked = () => store.get('todoFilter', []);
|
|
102
|
+
const hiddenByFilter = (it) => FILTER_BOXES.some(([id, , has]) => unchecked().includes(id) && has(it));
|
|
103
|
+
const filterHtml = () => {
|
|
104
|
+
const off = unchecked();
|
|
105
|
+
const menu = filterOpen ? `<div class="lane-filter-menu" role="group" aria-label="Show in Todo">${FILTER_BOXES.map(([id, name]) =>
|
|
106
|
+
`<button type="button" class="lane-filter-box" role="checkbox" aria-checked="${!off.includes(id)}" data-todo-filter="${id}"><span class="box"></span>${name}</button>`).join('')}</div>` : '';
|
|
107
|
+
return { button: `<button type="button" class="lane-filter${off.length ? ' on' : ''}" aria-label="Filter Todo" aria-expanded="${filterOpen}" title="${off.length ? 'Filtered' : 'Filter'}">${FUNNEL}</button>`, menu };
|
|
108
|
+
};
|
|
109
|
+
|
|
88
110
|
// shown is the board's items less pending deletes.
|
|
89
111
|
function lanesHtml(d, shown) {
|
|
90
112
|
const laneState = store.get('lanes', {});
|
|
91
113
|
return reviewsHtml(d) + LANES.map((lane) => {
|
|
92
114
|
// Items arrive in each lane's fixed order (domain.mjs laneOrder).
|
|
93
|
-
const
|
|
115
|
+
const all = shown.filter((i) => i.lane === lane.id);
|
|
116
|
+
const items = lane.id === 'todo' ? all.filter((i) => !hiddenByFilter(i)) : all;
|
|
117
|
+
if (lane.id === 'todo') shown = shown.filter((i) => i.lane !== 'todo' || items.includes(i));
|
|
118
|
+
const filter = lane.id === 'todo' ? filterHtml() : null;
|
|
94
119
|
const expanded = laneState[lane.id] ?? lane.open;
|
|
95
120
|
const open = items.filter((i) => !i.fold);
|
|
96
121
|
const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : waveGroups(lane.id, open)) + fileFolds(d, items);
|
|
97
122
|
return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
|
|
98
|
-
<summary><span class="lane-name">${lane.name}</span><span class="count">${lane.id === 'done' ? doneCount(d, items) : items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
|
|
123
|
+
<summary><span class="lane-name">${lane.name}</span><span class="count">${lane.id === 'done' ? doneCount(d, items) : lane.id === 'todo' && items.length < all.length ? `${items.length} of ${all.length}` : items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}${filter ? filter.button : ''}</summary>
|
|
124
|
+
${filter ? filter.menu : ''}
|
|
99
125
|
<div class="list">${rows || '<div class="empty">Nothing here.</div>'}
|
|
100
126
|
${lane.id === 'todo' ? BoardUI.planButton(items) : ''}</div>
|
|
101
127
|
</details>`;
|
|
@@ -172,12 +198,11 @@ BoardView.part((B) => {
|
|
|
172
198
|
return groupHtml('todo', band.slug, open, head, doneRow + todo.map((it) => B.card(it)).join(''));
|
|
173
199
|
}
|
|
174
200
|
|
|
175
|
-
// Opens the band holding card key
|
|
176
|
-
function openBand({ key
|
|
177
|
-
const lane =
|
|
178
|
-
const band = (state.board?.waveBands || []).find((b) => b.
|
|
201
|
+
// Opens the band holding card key, for a #selectedCard= link.
|
|
202
|
+
function openBand({ key }) {
|
|
203
|
+
const lane = state.board?.items.find((i) => i.key === key)?.lane || 'todo';
|
|
204
|
+
const band = (state.board?.waveBands || []).find((b) => b.keys.includes(key));
|
|
179
205
|
if (band) store.set(groupStore(`${lane}/${band.slug}`), true);
|
|
180
|
-
return band;
|
|
181
206
|
}
|
|
182
207
|
|
|
183
208
|
B.on(B.root, 'toggle', (ev) => {
|
|
@@ -199,8 +224,15 @@ BoardView.part((B) => {
|
|
|
199
224
|
catch { B.notice('Could not send. Is the host awake?'); }
|
|
200
225
|
return;
|
|
201
226
|
}
|
|
202
|
-
const
|
|
203
|
-
if (
|
|
227
|
+
const filterBtn = ev.target.closest('button.lane-filter');
|
|
228
|
+
if (filterBtn) { ev.preventDefault(); filterOpen = !filterOpen; B.render(); return; }
|
|
229
|
+
const box = ev.target.closest('[data-todo-filter]');
|
|
230
|
+
if (box) {
|
|
231
|
+
const id = box.dataset.todoFilter;
|
|
232
|
+
store.set('todoFilter', unchecked().includes(id) ? unchecked().filter((x) => x !== id) : [...unchecked(), id]);
|
|
233
|
+
B.render();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
204
236
|
const more = ev.target.closest('[data-lane-more]');
|
|
205
237
|
if (more) {
|
|
206
238
|
if (more.hasAttribute('data-fetch')) {
|
|
@@ -213,7 +245,7 @@ BoardView.part((B) => {
|
|
|
213
245
|
}
|
|
214
246
|
const pb = ev.target.closest('button[data-plan]');
|
|
215
247
|
if (pb) {
|
|
216
|
-
if (!(await B.confirm('Claude triages these Todo cards into one or more waves and asks you about them in
|
|
248
|
+
if (!(await B.confirm('Claude triages these Todo cards into one or more waves and asks you about them in Waiting on you. Nothing is implemented until you answer. Start?'))) return;
|
|
217
249
|
// Spins until a board shows a grill running on one of the cards (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
218
250
|
const keys = pb.dataset.plan.split(' ');
|
|
219
251
|
BoardUI.pending.start('todo:plan', (board) => board.items.some((i) => keys.includes(i.key) && i.grilling), B.stillWaiting);
|
|
@@ -224,14 +256,6 @@ BoardView.part((B) => {
|
|
|
224
256
|
} catch { BoardUI.pending.drop('todo:plan'); B.render(); B.notice('Could not send. Is the host awake?'); }
|
|
225
257
|
return;
|
|
226
258
|
}
|
|
227
|
-
const show = ev.target.closest('a[data-band-show]');
|
|
228
|
-
if (show) {
|
|
229
|
-
ev.preventDefault();
|
|
230
|
-
if (!openBand({ slug: show.dataset.bandShow })) return;
|
|
231
|
-
const s = store.get('lanes', {}); s.todo = true; store.set('lanes', s);
|
|
232
|
-
B.render();
|
|
233
|
-
$(`.wb[data-lane="todo"][data-band="${CSS.escape(show.dataset.bandShow)}"]`)?.scrollIntoView({ block: 'start' });
|
|
234
|
-
}
|
|
235
259
|
});
|
|
236
260
|
|
|
237
261
|
// A band's head's fold and its name's goal popover. A long-press on the name (touch only;
|
|
@@ -38,11 +38,10 @@ BoardView.part((B) => {
|
|
|
38
38
|
if (view) B.root.dataset.board = view; else delete B.root.dataset.board;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
// The lines a fresh board can change: each lane count
|
|
41
|
+
// The lines a fresh board can change: each lane count and thread message. A card shows its own sign below.
|
|
42
42
|
function lines() {
|
|
43
43
|
const out = new Map();
|
|
44
44
|
for (const count of $$('.lane[data-lane] > summary .count')) out.set(`lane:${count.closest('.lane').dataset.lane}`, count);
|
|
45
|
-
$$('#waiting li').forEach((li, i) => out.set(`needs:${i}`, li));
|
|
46
45
|
for (const msgs of $$('[data-thread] > .msgs')) [...msgs.children].forEach((m, i) => out.set(`thread:${msgs.parentElement.dataset.thread}|${i}`, m));
|
|
47
46
|
return out;
|
|
48
47
|
}
|
|
@@ -20,13 +20,13 @@ BoardView.part((B) => {
|
|
|
20
20
|
// card the orchestrator stalled (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md).
|
|
21
21
|
const replyTone = (item) => (item?.resolvedBy === BUTTON.reply ? 'resolves' : undefined);
|
|
22
22
|
const sendsOf = (item) => [item?.lane === 'todo' ? (item.ready ? 'todo-ready' : 'todo')
|
|
23
|
-
: item?.lane === 'review' ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one', replyTone(item) && 'reply-resolves'].filter(Boolean).join(' ');
|
|
23
|
+
: item?.lane === 'review' ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one', item?.resolvedBy === BUTTON.implement && 'implement-resolves', replyTone(item) && 'reply-resolves'].filter(Boolean).join(' ');
|
|
24
24
|
const pair = (kind, html) => `<span class="send-actions" data-sends="${kind}">${html}</span>`;
|
|
25
25
|
const sends = (kind, item) => {
|
|
26
26
|
const reply = (label, attrs = '') => sendButton(label, 0, attrs, replyTone(item));
|
|
27
27
|
return kind.startsWith('one') ? `<span data-sends="${kind}">${reply('Send')}</span>`
|
|
28
28
|
: kind.startsWith('review') ? pair(kind, `${doneButton(item)}${reply('Reply', ' data-intent="reply"')}`)
|
|
29
|
-
: pair(kind, `${sendButton('Implement', 1, ` class="build${kind
|
|
29
|
+
: pair(kind, `${sendButton('Implement', 1, ` class="build${kind.startsWith('todo-ready') ? ' ready' : ''}${kind.includes('implement-resolves') ? ' resolves' : ''}" data-intent="build"`)}${reply('Reply', ' data-intent="reply"')}`);
|
|
30
30
|
};
|
|
31
31
|
// The board, not the thread, decides the send buttons, so they follow each board load even when the thread is unchanged.
|
|
32
32
|
function syncSends(el, key) {
|
package/board/public/app.html
CHANGED
|
@@ -77,7 +77,6 @@ window.BoardBoot = (function () {
|
|
|
77
77
|
<script src="/_hub/app/app-cards.js"></script>
|
|
78
78
|
<script src="/_hub/app/app-lanes.js"></script>
|
|
79
79
|
<script src="/_hub/app/app-asks.js"></script>
|
|
80
|
-
<script src="/_hub/app/app-needs.js"></script>
|
|
81
80
|
<script src="/_hub/app/app-header.js"></script>
|
|
82
81
|
<script src="/_hub/app/app-arch.js"></script>
|
|
83
82
|
<script src="/_hub/app/app-reader.js"></script>
|
package/board/public/app.js
CHANGED
|
@@ -198,7 +198,6 @@ window.BoardView = (function () {
|
|
|
198
198
|
setHTML($('#lanes'), B.lanesHtml(d, d.items), { keep: '[data-thread]' });
|
|
199
199
|
for (const key of state.openItems) if (state.threadCache.has(key)) B.renderThread(key, state.threadCache.get(key));
|
|
200
200
|
B.moveStrips(d.items, strips);
|
|
201
|
-
B.renderNeeds(d);
|
|
202
201
|
B.renderBanner(d);
|
|
203
202
|
});
|
|
204
203
|
B.flashChanged();
|
|
@@ -206,23 +205,16 @@ window.BoardView = (function () {
|
|
|
206
205
|
}
|
|
207
206
|
|
|
208
207
|
// #review=<PR number> opens that review in the Auto Review lane (app-lanes.js openReview), as a held review's push does.
|
|
209
|
-
// #selectedCard=<file>/<id> (or the older #item=) opens that card
|
|
208
|
+
// #selectedCard=<file>/<id> (or the older #item=) opens that card; push notifications link here, with &reply to land in the thread's reply box
|
|
210
209
|
// (docs/adr/2026-10-29-1639-a-push-lands-where-the-owner-answers.md). An ask now in a work item's thread opens that item.
|
|
211
210
|
let focusKey = null;
|
|
212
211
|
let replyKey = null;
|
|
213
212
|
let hashRead = false;
|
|
214
213
|
// A board this page left goes back to where the owner left it once it first renders, unless its URL names a card.
|
|
215
214
|
const returnToPlace = () => B.restorePlace({ linked: /^#(?:selectedCard|item|review)=/.test(location.hash) });
|
|
216
|
-
// The pane's pill the fragment names opens once the pane has loaded; a phone has no pane, its pill is the Waiting on
|
|
217
|
-
// you page (shell.js go).
|
|
218
|
-
function openPill(id, tries = 50) {
|
|
219
|
-
if (!id || !matchMedia('(min-width: 900px)').matches || B.focusQuestion?.(id) || tries <= 0) return;
|
|
220
|
-
B.later(100, () => openPill(id, tries - 1));
|
|
221
|
-
}
|
|
222
215
|
function readHash() {
|
|
223
|
-
const { selectedCard,
|
|
216
|
+
const { selectedCard, review, reply } = AppRouter.fragmentOf(location.hash);
|
|
224
217
|
if (review && state.board) { B.openReview(review); return; }
|
|
225
|
-
openPill(openItemsCard);
|
|
226
218
|
if (!selectedCard) return;
|
|
227
219
|
focusKey = BoardUI.record(state.board?.moved, selectedCard) || selectedCard;
|
|
228
220
|
replyKey = reply ? focusKey : null;
|
|
@@ -245,15 +237,6 @@ window.BoardView = (function () {
|
|
|
245
237
|
ev.preventDefault();
|
|
246
238
|
readHash(); render(); scrollToFocus();
|
|
247
239
|
});
|
|
248
|
-
// Opens and scrolls to the card key or the band slug on the board → whether the board shows it.
|
|
249
|
-
function focusOnBoard({ key, slug }) {
|
|
250
|
-
const band = slug && state.board?.waveBands?.find((b) => b.slug === slug);
|
|
251
|
-
if (!band && !state.board?.items.some((i) => i.key === key)) return false;
|
|
252
|
-
if (key) { location.hash = AppRouter.hashWith(location.hash, { selectedCard: key }); readHash(); } else B.openBand({ slug });
|
|
253
|
-
render();
|
|
254
|
-
if (key) scrollToFocus(); else $(`section.wb[data-lane="todo"][data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
|
|
255
|
-
return true;
|
|
256
|
-
}
|
|
257
240
|
// A card missing from the last visit's board waits for the fresh one, which is final.
|
|
258
241
|
function scrollToFocus(final = true) {
|
|
259
242
|
if (!focusKey) return;
|
|
@@ -451,7 +434,7 @@ window.BoardView = (function () {
|
|
|
451
434
|
signalled();
|
|
452
435
|
render();
|
|
453
436
|
}
|
|
454
|
-
Object.assign(B, { render,
|
|
437
|
+
Object.assign(B, { render, loadBoard, send, failed, stillWaiting, reload: signalled });
|
|
455
438
|
B.headerWired();
|
|
456
439
|
const sent = BoardUI.sentNotice.take(B.route);
|
|
457
440
|
if (sent) B.toast(sent);
|
|
@@ -474,20 +457,15 @@ window.BoardView = (function () {
|
|
|
474
457
|
function markup({ title = '', shared = null } = {}) {
|
|
475
458
|
const strip = shared ? `<p class="view-only-strip">${sharedStrip(shared)}</p>\n` : '';
|
|
476
459
|
return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', pages: 'questions', moon: true })}
|
|
477
|
-
<div class="split">
|
|
478
460
|
<main>
|
|
479
461
|
${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
480
462
|
<section id="pauseBanner" class="hub-view" hidden></section>
|
|
481
463
|
<section id="answers" class="answer-banner" hidden></section>
|
|
482
|
-
<section id="needsDeck" class="needs-deck" aria-label="Needs you" hidden></section>
|
|
483
464
|
<div id="tryMain"></div>
|
|
484
465
|
<div id="lanes"></div>
|
|
485
466
|
<div id="downLanding" hidden></div>
|
|
486
467
|
${BoardUI.boardFoot()}
|
|
487
468
|
</main>
|
|
488
|
-
<div class="col-handle" data-col="pane" role="separator" aria-orientation="vertical" aria-label="Resize the Waiting on you pane" tabindex="0"></div>
|
|
489
|
-
<aside id="needsPane" class="needs-pane" aria-label="Needs you"><section id="waiting" class="waiting" hidden></section></aside>
|
|
490
|
-
</div>
|
|
491
469
|
${BoardNewTask.markup()}
|
|
492
470
|
<dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
|
|
493
471
|
<div class="sheet-body">
|
|
@@ -1,72 +1,53 @@
|
|
|
1
|
-
// Drag-resizing of the desktop
|
|
2
|
-
//
|
|
3
|
-
// and
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// stored widths land before the first paint of the board.
|
|
1
|
+
// Drag-resizing of the sidebar rail on a desktop (docs/adr/2026-11-10-1600-the-desktop-columns-drag-and-this-device-keeps-their-widths.md).
|
|
2
|
+
// The `.col-handle[data-col="sidebar"]` drags the rail's edge. Its width is clamped to 220–420px, kept per device for
|
|
3
|
+
// every board, and set on <html> as --sb-w, which style.css and sidebar.css read. A double-click on the handle forgets
|
|
4
|
+
// the width; ←/→ on the focused handle moves it 16px. Loaded after the body's first elements so the stored width lands
|
|
5
|
+
// before the first paint of the board.
|
|
7
6
|
(function () {
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
7
|
+
const KEY = 'hub:sidebarWidth';
|
|
8
|
+
const VAR = '--sb-w';
|
|
9
|
+
const MIN = 220, MAX = 420, STEP = 16;
|
|
11
10
|
const html = document.documentElement;
|
|
12
11
|
const wide = () => window.matchMedia('(min-width: 900px)').matches;
|
|
13
|
-
const read = (
|
|
14
|
-
const write = (
|
|
15
|
-
const forget = (
|
|
16
|
-
const stored = (
|
|
17
|
-
// The sidebar's width now, which the pane's room depends on.
|
|
18
|
-
const sidebarNow = (viewport) => (html.classList.contains('sb-collapsed') ? 0 : clamp('sidebar', stored('sidebar') ?? DEFAULT_SIDEBAR, viewport, 0));
|
|
12
|
+
const read = () => { try { return localStorage.getItem(KEY); } catch { return null; } };
|
|
13
|
+
const write = (v) => { try { localStorage.setItem(KEY, v); } catch { /* private mode or full: kept for this page only */ } };
|
|
14
|
+
const forget = () => { try { localStorage.removeItem(KEY); } catch { /* nothing kept */ } };
|
|
15
|
+
const stored = () => { const n = Number(read()); return Number.isFinite(n) && n > 0 ? n : null; };
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (
|
|
24
|
-
|
|
17
|
+
const clamp = (px) => Math.min(MAX, Math.max(MIN, px));
|
|
18
|
+
function paint() {
|
|
19
|
+
const px = stored();
|
|
20
|
+
if (px === null) html.style.removeProperty(VAR);
|
|
21
|
+
else html.style.setProperty(VAR, `${clamp(px)}px`);
|
|
25
22
|
}
|
|
26
|
-
function paint(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
html.style.setProperty(VARS[col], `${col === 'pane' ? clamp('pane', px, window.innerWidth, sidebarNow(window.innerWidth)) : clamp('sidebar', px)}px`);
|
|
30
|
-
}
|
|
31
|
-
function paintAll() { paint('sidebar'); paint('pane'); }
|
|
32
|
-
function set(col, px) { write(KEYS[col], String(Math.round(px))); paintAll(); }
|
|
33
|
-
function reset(col) { forget(KEYS[col]); paintAll(); }
|
|
34
|
-
paintAll();
|
|
35
|
-
window.addEventListener('resize', paintAll);
|
|
23
|
+
function set(px) { write(String(Math.round(px))); paint(); }
|
|
24
|
+
function reset() { forget(); paint(); }
|
|
25
|
+
paint();
|
|
36
26
|
|
|
37
|
-
// The width
|
|
38
|
-
const current = (
|
|
39
|
-
const edge = el.getBoundingClientRect();
|
|
40
|
-
return col === 'sidebar' ? edge.left + edge.width / 2 : window.innerWidth - (edge.left + edge.width / 2);
|
|
41
|
-
};
|
|
42
|
-
const wanted = (col, x) => (col === 'sidebar' ? x : window.innerWidth - x);
|
|
27
|
+
// The width the handle's drag edge is at now.
|
|
28
|
+
const current = (el) => { const edge = el.getBoundingClientRect(); return edge.left + edge.width / 2; };
|
|
43
29
|
|
|
44
30
|
document.addEventListener('pointerdown', (e) => {
|
|
45
31
|
const el = e.target.closest?.('.col-handle');
|
|
46
32
|
if (!el || !wide() || e.button > 0) return;
|
|
47
|
-
const col = el.dataset.col;
|
|
48
33
|
e.preventDefault();
|
|
49
34
|
el.setPointerCapture?.(e.pointerId);
|
|
50
35
|
el.classList.add('dragging');
|
|
51
36
|
html.classList.add('col-dragging');
|
|
52
|
-
const move = (m) => set(
|
|
37
|
+
const move = (m) => set(m.clientX);
|
|
53
38
|
const end = () => { el.classList.remove('dragging'); html.classList.remove('col-dragging'); el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', end); el.removeEventListener('pointercancel', end); };
|
|
54
39
|
el.addEventListener('pointermove', move);
|
|
55
40
|
el.addEventListener('pointerup', end);
|
|
56
41
|
el.addEventListener('pointercancel', end);
|
|
57
42
|
});
|
|
58
43
|
document.addEventListener('dblclick', (e) => {
|
|
59
|
-
|
|
60
|
-
if (el && wide()) reset(el.dataset.col);
|
|
44
|
+
if (e.target.closest?.('.col-handle') && wide()) reset();
|
|
61
45
|
});
|
|
62
46
|
document.addEventListener('keydown', (e) => {
|
|
63
47
|
const el = e.target.closest?.('.col-handle');
|
|
64
48
|
if (!el || !wide() || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
|
65
|
-
const col = el.dataset.col;
|
|
66
49
|
e.preventDefault();
|
|
67
|
-
|
|
68
|
-
const dx = (e.key === 'ArrowRight' ? STEP : -STEP) * (col === 'sidebar' ? 1 : -1);
|
|
69
|
-
set(col, (stored(col) ?? current(col, el)) + dx);
|
|
50
|
+
set((stored() ?? current(el)) + (e.key === 'ArrowRight' ? STEP : -STEP));
|
|
70
51
|
});
|
|
71
52
|
|
|
72
53
|
window.ColResize = { clamp, set, reset };
|
package/board/public/prompts.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Answering pending prompts from their Needs you lines (ui-prompts.js promptItem)
|
|
1
|
+
// Answering pending prompts from their Needs you lines (ui-prompts.js promptItem) on the hub page,
|
|
2
2
|
// through the hub's POST /_hub/prompts/<id>/answer (docs/adr/2026-10-30-1516-background-sessions-prompts-come-to-the-phone.md).
|
|
3
3
|
// Allow once, Deny, Skip and a single question's option send on the tap. Always allow confirms first, naming its rule,
|
|
4
4
|
// since it covers every later call like it until the session ends. Several questions, or one taking several picks,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// with board.mjs prototype add (docs/adr/2026-11-01-1448-the-board-serves-every-prototype-from-one-template.md). A take file,
|
|
4
4
|
// public/prototypes/<topic>/<KEY>.js, is a module:
|
|
5
5
|
// export function render(stage, kit) { stage.innerHTML = kit.hub(); return () => {…} } // the cleanup is optional
|
|
6
|
-
// A take draws any board class at any width: prototype.html undoes the board's width-gated hides in the stage (.
|
|
6
|
+
// A take draws any board class at any width: prototype.html undoes the board's width-gated hides in the stage (.key-hint).
|
|
7
7
|
// render runs on every switch to that take with a fresh, empty stage; it may be async. The stage contains its layout
|
|
8
8
|
// and paint, so nothing in it, position: fixed included, can cover the viewer's bar, its ✕ or the app shell.
|
|
9
9
|
// kit.sidebar(data?, {here, switching, device, head, height}) ui-sidebar.js sidebar() as an open drawer card in the
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// The full-bleed prototype viewer's controls (docs/adr/2026-10-29-1639-prototypes-come-on-request.md,
|
|
2
2
|
// docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md,
|
|
3
3
|
// docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md,
|
|
4
|
-
// docs/adr/2026-11-
|
|
4
|
+
// docs/adr/2026-11-12-0700-the-board-has-no-needs-you-deck-or-pane.md).
|
|
5
5
|
// Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
|
|
6
6
|
// const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, states: { empty: 'No cards', … }?, initial: 'A', render(take, state) {…}, above: el? })
|
|
7
7
|
// render runs at mount and on each switch of take or state; sw.take is the current key. A served round's named states
|
|
8
8
|
// (docs/adr/2026-11-06-1500-a-prototype-round-is-a-page-in-the-boards-base-worktree.md) are a row of chips above the
|
|
9
9
|
// pager, the state kept in ?state=, else the first. The page's header is hidden: a ✕ at the top
|
|
10
10
|
// right goes back as its back arrow does, and the footer, full width on the bottom edge at every width, holds the
|
|
11
|
-
// ‹ take › pager.
|
|
11
|
+
// ‹ take › pager. ←/→
|
|
12
12
|
// and a sideways swipe switch unless a field has focus, and the take stays in ?variant=. With ?q=<owner question id>, the bar's second row offers
|
|
13
13
|
// the take as that question's pick: a take is pickable when an option's id equals its key. Choose this drafts it, with
|
|
14
14
|
// the note typed above the bar, in the {choice, note} questions.html keeps for that question, and goes back to the
|
|
@@ -118,10 +118,7 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
118
118
|
.ps-pill { width:min(640px, 100%); }
|
|
119
119
|
.ps-switch .ps-text { width:min(640px, 100%); }
|
|
120
120
|
.ps-states { flex-wrap:wrap; overflow-x:visible; }
|
|
121
|
-
}
|
|
122
|
-
.ps-pane main.proto-main { padding-top:0; }
|
|
123
|
-
.ps-pane .ps-top { display:none; }
|
|
124
|
-
.ps-pane .ps-states { flex-wrap:wrap; overflow-x:visible; justify-content:center; }`;
|
|
121
|
+
}`;
|
|
125
122
|
|
|
126
123
|
const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
|
|
127
124
|
|
|
@@ -132,8 +129,6 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
132
129
|
const qid = params.get('q');
|
|
133
130
|
const site = globalThis.ClaudeSite;
|
|
134
131
|
if (!document.getElementById('ps-style')) document.head.insertAdjacentHTML('beforeend', `<style id="ps-style">${CSS}</style>`);
|
|
135
|
-
// Framed in the board's right pane (app-needs.js openViewer), whose top bar holds the way back and the Round pill.
|
|
136
|
-
if (window.frameElement?.closest('.needs-pane')) document.documentElement.classList.add('ps-pane');
|
|
137
132
|
let take = startTake(keys, params.get('variant'), initial);
|
|
138
133
|
let question = null;
|
|
139
134
|
// A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a reload stays on the
|