@lovek4d/kickflip 0.7.8 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +10 -6
- package/SKILL.md +19 -13
- package/bin/event-lines.mjs +1 -1
- package/bin/install.mjs +3 -3
- package/board/bin/board.mjs +35 -13
- package/board/commit-message.mjs +6 -0
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +5 -4
- package/board/headless.mjs +8 -8
- package/board/hub-lines.mjs +1 -1
- package/board/public/app-header.js +23 -0
- package/board/public/app-needs.js +1 -1
- package/board/public/app-reader.js +6 -2
- package/board/public/app.html +2 -0
- package/board/public/app.js +2 -0
- package/board/public/attach.js +12 -0
- package/board/public/col-resize.js +73 -0
- package/board/public/dialogs.js +3 -0
- package/board/public/prototype-kit.js +1 -1
- package/board/public/prototype.html +1 -0
- package/board/public/sidebar.css +8 -2
- package/board/public/style.css +33 -11
- package/board/public/ui-header.js +2 -2
- package/board/public/ui-sidebar.js +11 -3
- package/board/public/ui-threads.js +1 -0
- package/board/public/view-hub.js +2 -2
- package/board/public/view-questions.js +2 -1
- package/board/render-check.mjs +8 -8
- package/board/server.mjs +18 -18
- package/board/store.mjs +32 -31
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +21 -19
- package/hooks-plugin/hooks/usage-gate.mjs +1 -3
- package/hub/boards.mjs +4 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +11 -11
- package/hub/pr-report.mjs +2 -2
- package/hub/readiness.mjs +1 -1
- package/hub/review-send.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/slack.mjs +2 -2
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/fake-gh.mjs +9 -4
package/board/hub-lines.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var b=Object.defineProperty;var t=(e,
|
|
1
|
+
var b=Object.defineProperty;var t=(e,a)=>b(e,"name",{value:a,configurable:!0});import $ from"node:os";const p=100,g=2e3,y=/(?:~|\/)[^\s:;,()'"]*\/([^\s:;,()'"/]+)/g;function r(e,a,s=""){const d=p-e.length-s.length;let l=String(a??"").split($.homedir()).join("~").replace(/\s+/g," ").trim();return l.length>d&&(l=l.replace(y,"\u2026/$1")),l.length<=d?e+l+s:`${e}${l.slice(0,d-1).trimEnd()}\u2026${s.replace(/^\./,"")}`}t(r,"fit");const i="Held",h="Resumed",m="Blocked",o="Fault",u="move",x="questions",k="Implementation requested",f="Prototype requested",n="Marked done",v="prototype",w="owner/a-long-repo-name#10000",I="Wed, 11/30, 12:00 PM",c={"host-busy":{mark:i,text:t(()=>"Host is busy, work will resume when the host recovers","text")},"planner-held":{mark:i,text:t(({kind:e})=>`The ${e} waits and starts when the hold lifts.`,"text"),longest:{kind:v}},paused:{mark:i,text:t(({phase:e,words:a,clear:s})=>r(`Paused${e?` at ${e}`:""}: `,a,`. It resumes once ${s}.`),"text"),longest:{phase:"building",clear:"the usage pause lifts"}},"run-stopped":{mark:i,text:t(({words:e})=>e,"text"),longest:{words:"build stopped by orchestrator"}},"land-moved-first":{mark:u,text:t(({words:e})=>e,"text"),longest:{words:"land moved first by orchestrator"}},"builder-stopped":{mark:i,text:t(()=>"Stopped its builder.","text")},"land-waiting":{mark:i,text:t(({card:e})=>r("Waiting for ",e," to land"),"text")},resumed:{mark:h,text:t(({clear:e})=>`Resumed: ${e}.`,"text"),longest:{clear:"the usage pause lifted"}},"hold-ended":{mark:h,text:t(({at:e})=>`Resumed: its hold until ${e} ended.`,"text"),longest:{at:I}},unblocked:{mark:h,text:t(({lifted:e})=>r("Unblocked: ",e,"."),"text")},"unblocked-done":{mark:h,text:t(({card:e})=>r("Unblocked: ",e," is done."),"text")},"blocked-pausing":{mark:m,text:t(({blocked:e})=>r("Blocked ",e,": its builder pauses at its next stopping point and it stays In Progress."),"text")},"blocked-stays":{mark:m,text:t(({blocked:e})=>r("Blocked ",e,": it stays In Progress."),"text")},"cant-start-yet":{mark:m,text:t(({names:e})=>r("Can\u2019t start yet: waits on ",e,". Bumped to the top of Todo."),"text")},"split-moved":{mark:m,text:t(({card:e,to:a})=>r("Split: ",`${e} became ${a}`,", so it waits on those now."),"text")},"merge-blocked":{mark:m,text:t(({blocked:e})=>r("Not merged: blocked ",e,"."),"text")},pulled:{mark:k,text:t(()=>"Pulled in: an implementer slot was free.","text")},"helper-asked":{mark:k,text:t(({agent:e,writes:a})=>r("Asked for helper ",e,a?", which writes code":""),"text"),longest:{writes:!0}},"arch-review":{mark:k,text:t(({card:e})=>r("Started an architecture review of ",e,"; its report becomes the notes"),"text")},"prototype-after":{mark:f,text:t(()=>"Prototype round asked: it moves to prototyping once every question is answered.","text")},"prototype-round":{mark:f,text:t(()=>"You asked for a prototype round, so the board moved the card to prototyping.","text")},"ticket-made":{mark:u,text:t(({ticket:e})=>r("The board made ",e,"."),"text")},"new-question":{mark:x,text:t(()=>"A new question on it, so it is grilling until you answer.","text")},"screen-timeout":{mark:x,text:t(()=>"Questions passed to you unscreened: the orchestrator didn\u2019t screen them in 15 minutes.","text")},"ask-moved":{mark:u,text:t(({board:e})=>r("Moved to ",e,"."),"text")},"card-merged":{mark:u,text:t(({card:e})=>r("Merged into ",e,"."),"text")},"pr-found":{mark:u,text:t(({pr:e,branch:a})=>r(`Found PR #${e} on branch `,a,"."),"text"),longest:{pr:1e5}},"ci-passed":{mark:n,text:t(({pr:e})=>`CI passed, so PR #${e} is marked ready for review.`,"text"),longest:{pr:1e5}},"worktree-remade":{mark:u,text:t(()=>"Remade its leftover worktree: it held no commits or changes.","text")},merged:{mark:n,text:t(({words:e})=>r("",e),"text")},"already-merged":{mark:n,text:t(({prs:e,many:a})=>r(`PR${a?"s":""} `,e,` ${a?"were":"was"} already merged.`),"text"),longest:{many:!0}},"ask-moved-closed":{mark:n,text:t(({board:e})=>r("Closed: its ask moved to ",e,"."),"text")},"check-passed":{mark:n,text:t(({found:e})=>r("Check passed: ",e,". Closed."),"text")},"findings-settled":{mark:n,text:t(({filed:e})=>r("Every finding is settled: ",e?`filed ${e}`:"none filed","."),"text")},"branch-merged":{mark:n,text:t(({branch:e,into:a,host:s})=>r("",`${e} is merged into ${a} on ${s}`,", so this card is Done."),"text")},"branch-gone":{mark:n,text:t(({branch:e,host:a})=>r("",`${e} is gone from ${a}`,", so this card is Done."),"text")},"prs-closed":{mark:n,text:t(({prs:e})=>r("Closed: ",e," closed unmerged."),"text"),longest:{prs:`${w}, ${w}`}},"landed-on-merge":{mark:n,text:t(({base:e})=>r("Landed on ",e," on your Merge."),"text")},"lander-handoff":{mark:k,text:t(({why:e})=>r("Not merged yet: ",e,". Its builder resumes to fix it and land it."),"text")},"builder-resumed-to-land":{mark:k,text:t(({session:e})=>r("Builder resumed to land, session ",e),"text")},"build-refused":{mark:o,fault:!0,detail:t(({why:e})=>e,"detail"),text:t(({why:e})=>r("Couldn't start implementing: ",e),"text")},"planner-unasked":{mark:o,fault:!0,detail:t(({kind:e})=>e,"detail"),text:t(({kind:e})=>`Its ${e} planner ended without posting a question, so it waits on the orchestrator.`,"text"),longest:{kind:"prototype"}},"planner-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({kind:e,error:a})=>r(`Couldn't start the ${e}: `,a),"text"),longest:{kind:"visual agent"}},"helper-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({name:e,error:a})=>r(`Couldn't start helper ${e}: `,a),"text"),longest:{name:"a-helper-name-of-24-char"}},"exited-twice":{mark:o,fault:!0,text:t(()=>"Its builder exited twice unfinished; it reads stopped until the orchestrator takes it up.","text")},"resume-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({error:e})=>r("Couldn't resume the builder headless: ",e),"text")},"relaunch-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({error:e})=>r("Couldn't relaunch the builder headless: ",e),"text")},"lander-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({error:e})=>r("Couldn't launch the lander; land it by hand: ",e),"text")},"rebase-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({pr:e,error:a})=>r(`PR #${e} conflicts, but the rebase run couldn't start: `,a),"text"),longest:{pr:1e5}},"rebase-launch-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({error:e})=>r("Couldn't launch the rebase run; it reads stopped: ",e),"text")},"gh-limited":{mark:o,fault:!0,detail:t(({words:e})=>e,"detail"),text:t(({what:e,words:a})=>r(`${e}: `,a,"."),"text"),longest:{what:"No PR opened"}},"merge-refused":{mark:o,fault:!0,detail:t(({why:e})=>e,"detail"),text:t(({why:e})=>r("Not merged: ",e,"."),"text")},"land-failed":{mark:o,fault:!0,detail:t(({why:e})=>e,"detail"),text:t(({why:e})=>r("Not merged: ",e,". Tap Merge again."),"text")},"push-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({base:e,error:a})=>r(`Landed, but ${e} didn't reach origin: `,a),"text"),longest:{base:"a-long-base-branch"}},"answers-failed":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({what:e,error:a})=>r(`Answers recorded, but ${e} failed: `,a),"text"),longest:{what:"writing them into its notes"}},"notes-missing":{mark:o,fault:!0,text:t(()=>"Its notes file was missing, so the board started a new one.","text")},"notes-shared":{mark:o,fault:!0,text:t(({card:e})=>r("It shared its notes file with ",e,", so the board gave it its own copy."),"text")},"check-broken":{mark:o,fault:!0,detail:t(({error:e})=>e,"detail"),text:t(({error:e})=>r("The owner step's check can't run, so it stopped: ",e),"text")},"run-failed":{mark:o,fault:!0,detail:t(({why:e})=>e,"detail"),text:t(({kind:e,code:a})=>`${e} failed (exit ${a})`,"text"),longest:{kind:"Serve",code:255}}};function C(e,a={}){const s=c[e];if(!s)throw new Error(`no status line kind "${e}"`);const d=s.text(a);if(!d||d.length>p)throw new Error(`the "${e}" status line runs to ${d.length} characters, past ${p}: ${d}`);return d}t(C,"hubText");function T(e,a={}){const s=String(c[e]?.detail?.(a)??"").trim();return s.length>g?`\u2026${s.slice(-(g-1))}`:s}t(T,"detailOf");const R=t(e=>({mark:c[e.line]?.mark||"",fault:!!c[e.line]?.fault}),"hubLineOf"),S="hub";export{p as BRIEF_LINE_LIMIT,S as HUB,c as HUB_LINES,g as MESSAGE_LIMIT,T as detailOf,r as fit,R as hubLineOf,C as hubText};
|
|
@@ -19,11 +19,34 @@ BoardView.part((B) => {
|
|
|
19
19
|
// The status line, and the moon right of the title while the owner reads away (hub/presence.mjs ownerTracker).
|
|
20
20
|
const renderStatus = () => {
|
|
21
21
|
$('#status').innerHTML = statusLine(state.fresh?.at, { link: state.link, watcher: state.board?.checks?.watcher, pause: B.pause?.(), waking: state.waking });
|
|
22
|
+
renderPause(B.pause?.());
|
|
22
23
|
const moon = $('.away-moon');
|
|
23
24
|
const away = Boolean(B.away?.());
|
|
24
25
|
moon.classList.toggle('on', away);
|
|
25
26
|
moon.setAttribute('aria-hidden', String(!away));
|
|
26
27
|
};
|
|
28
|
+
// The hub's pause banner (HubUI.pauseBanner) at the top of the board while the gate pauses or nears its line, its forms
|
|
29
|
+
// posting to the hub. Pause board's own pause has no hub form, so the header's title alone says it.
|
|
30
|
+
const renderPause = (pause) => {
|
|
31
|
+
const el = $('#pauseBanner');
|
|
32
|
+
const html = pause?.manual === 'board' ? '' : HubUI.pauseBanner(pause);
|
|
33
|
+
el.hidden = !html;
|
|
34
|
+
BoardUI.setHTML(el, html);
|
|
35
|
+
};
|
|
36
|
+
B.on($('#pauseBanner'), 'submit', async (ev) => {
|
|
37
|
+
const form = ev.target;
|
|
38
|
+
const action = form.getAttribute('action') || '';
|
|
39
|
+
if (!action.startsWith('/_hub/')) return;
|
|
40
|
+
ev.preventDefault();
|
|
41
|
+
const body = new URLSearchParams(new FormData(form));
|
|
42
|
+
if (ev.submitter?.name) body.append(ev.submitter.name, ev.submitter.value);
|
|
43
|
+
try {
|
|
44
|
+
const r = await fetch(action, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body });
|
|
45
|
+
if (!r.ok) throw new Error(String(r.status));
|
|
46
|
+
const out = await r.json().catch(() => null);
|
|
47
|
+
if (out?.pause) renderPause(out.pause);
|
|
48
|
+
} catch { toast('Could not reach the hub. Is the host awake?'); }
|
|
49
|
+
});
|
|
27
50
|
// A board load and its open threads landed; via is ui-header.js nextFresh's.
|
|
28
51
|
function landed(via) {
|
|
29
52
|
state.fresh = nextFresh(state.fresh, { via, at: new Date().toISOString(), sig: freshSig(state.board, state.threadCache, state.openItems) });
|
|
@@ -90,7 +90,7 @@ BoardView.part((B) => {
|
|
|
90
90
|
const marked = getDraft(q.id).choice;
|
|
91
91
|
const optionHtml = (o) => {
|
|
92
92
|
const armed = o.id === marked;
|
|
93
|
-
const label = armed ? `Send “${esc(o.label)}
|
|
93
|
+
const label = armed ? `Send “${esc(o.label)}”?` : `${esc(choiceLabel(q, o.id))}${o.recommended ? ' <span class="rec-word">recommended</span>' : ''}`;
|
|
94
94
|
return `<button type="button" class="${[o.recommended && 'rec', armed && 'armed'].filter(Boolean).join(' ')}" aria-pressed="${armed}" data-deck-pick="${esc(o.id)}"><span class="lbl">${label}</span>${o.detail ? `<div class="det">${markdown(o.detail, links)}</div>` : ''}</button>`;
|
|
95
95
|
};
|
|
96
96
|
return `<h3>${escText(q.question)}</h3>${q.context ? `<div class="ctx">${markdown(q.context, links)}</div>` : ''}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "View transcript"
|
|
2
2
|
// (ui-threads.js) and a card's or a wave's Notes (app-cards.js, app-lanes.js) open over the board in one sheet in
|
|
3
3
|
// place of log.html and notes.html, which still open on their own. It closes on its ✕, Escape, a tap on the dim area
|
|
4
|
-
// and Back, through a history entry of its own that carries the board's URL, so the router's pop keeps the board.
|
|
4
|
+
// and Back (from 900px it is not modal: no dim area, and the left pane stays live), through a history entry of its own that carries the board's URL, so the router's pop keeps the board.
|
|
5
5
|
// A link in it closes it first, then opens through the shell.
|
|
6
6
|
// A log written as a stream (headless.mjs: one stream-json event per line) reads as the run's transcript, the whole of
|
|
7
7
|
// it as GET /api/transcript's rows: what Claude says, and each tool call as one line whose output opens on a tap, with a
|
|
@@ -17,6 +17,7 @@ BoardView.part((B) => {
|
|
|
17
17
|
const more = sheet.querySelector('.reader-new');
|
|
18
18
|
let opened = 0;
|
|
19
19
|
let pushed = false;
|
|
20
|
+
let modal = true;
|
|
20
21
|
let pending = null;
|
|
21
22
|
let log = null;
|
|
22
23
|
let stopReading = null;
|
|
@@ -109,7 +110,8 @@ BoardView.part((B) => {
|
|
|
109
110
|
tools.innerHTML = '';
|
|
110
111
|
more.hidden = true;
|
|
111
112
|
body.innerHTML = '<p class="muted">Loading…</p>';
|
|
112
|
-
|
|
113
|
+
modal = !matchMedia('(min-width: 900px)').matches;
|
|
114
|
+
if (modal) sheet.showModal(); else sheet.show();
|
|
113
115
|
history.pushState({ ...history.state, idx: history.state.idx + 1, reader: true }, '', location.href);
|
|
114
116
|
pushed = true;
|
|
115
117
|
return opened;
|
|
@@ -169,6 +171,8 @@ BoardView.part((B) => {
|
|
|
169
171
|
pending = new URL(a.getAttribute('href'), location.href).href;
|
|
170
172
|
close();
|
|
171
173
|
});
|
|
174
|
+
// A non-modal sheet (from 900px) gets no cancel event from Escape, so the page's own key closes it.
|
|
175
|
+
B.on(document, 'keydown', (ev) => { if (ev.key === 'Escape' && sheet.open && !modal) close(); });
|
|
172
176
|
B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); close(); });
|
|
173
177
|
B.on(body, 'scroll', () => {
|
|
174
178
|
if (!log?.stream) return;
|
package/board/public/app.html
CHANGED
|
@@ -42,6 +42,7 @@ window.BoardBoot = (function () {
|
|
|
42
42
|
<body class="has-sidebar">
|
|
43
43
|
<aside id="sidebar" class="sidebar" aria-label="Boards">{{sidebar}}</aside>
|
|
44
44
|
<div id="sidebarScrim" class="sidebar-scrim"></div>
|
|
45
|
+
<div class="col-handle" data-col="sidebar" role="separator" aria-orientation="vertical" aria-label="Resize the boards sidebar" tabindex="0"></div>
|
|
45
46
|
<script id="sidebarData" type="application/json">{{sidebarData}}</script>
|
|
46
47
|
<script src="/_hub/app/ui.js"></script>
|
|
47
48
|
<script src="/_hub/app/ui-links.js"></script>
|
|
@@ -67,6 +68,7 @@ window.BoardBoot = (function () {
|
|
|
67
68
|
<script src="/_hub/app/attach.js"></script>
|
|
68
69
|
<script src="/_hub/app/hub-kit.js"></script>
|
|
69
70
|
<script src="/_hub/app/linear-key.js"></script>
|
|
71
|
+
<script src="/_hub/app/col-resize.js"></script>
|
|
70
72
|
<script src="/_hub/app/sidebar.js"></script>
|
|
71
73
|
<script src="/_hub/app/app.js"></script>
|
|
72
74
|
<script src="/_hub/app/new-task.js"></script>
|
package/board/public/app.js
CHANGED
|
@@ -457,6 +457,7 @@ window.BoardView = (function () {
|
|
|
457
457
|
<div class="split">
|
|
458
458
|
<main>
|
|
459
459
|
${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
460
|
+
<section id="pauseBanner" class="hub-view" hidden></section>
|
|
460
461
|
<section id="answers" class="answer-banner" hidden></section>
|
|
461
462
|
<section id="needsDeck" class="needs-deck" aria-label="Needs you" hidden></section>
|
|
462
463
|
<div id="tryMain"></div>
|
|
@@ -464,6 +465,7 @@ ${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
|
464
465
|
<div id="downLanding" hidden></div>
|
|
465
466
|
${BoardUI.boardFoot()}
|
|
466
467
|
</main>
|
|
468
|
+
<div class="col-handle" data-col="pane" role="separator" aria-orientation="vertical" aria-label="Resize the Waiting on you pane" tabindex="0"></div>
|
|
467
469
|
<aside id="needsPane" class="needs-pane" aria-label="Needs you"><section id="waiting" class="waiting" hidden></section></aside>
|
|
468
470
|
</div>
|
|
469
471
|
${BoardNewTask.markup()}
|
package/board/public/attach.js
CHANGED
|
@@ -76,12 +76,24 @@ window.BoardAttach = (function () {
|
|
|
76
76
|
paint(id, site);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// Pressing the button does not take focus from the text box, so the cursor stays where it was while the picker opens
|
|
80
|
+
// (iOS may still hide the keyboard for its picker), and a pick puts focus back in the box that held it.
|
|
81
|
+
let focused = null;
|
|
82
|
+
document.addEventListener('mousedown', (ev) => {
|
|
83
|
+
const input = ev.target.closest && ev.target.closest('input[data-attach]');
|
|
84
|
+
if (!input) return;
|
|
85
|
+
const a = document.activeElement;
|
|
86
|
+
focused = a && a.tagName === 'TEXTAREA' && input.closest('[data-composer]')?.contains(a) ? a : null;
|
|
87
|
+
ev.preventDefault();
|
|
88
|
+
});
|
|
79
89
|
document.addEventListener('change', (ev) => {
|
|
80
90
|
const input = ev.target.closest && ev.target.closest('input[data-attach]');
|
|
81
91
|
const c = input && input.closest('[data-composer]');
|
|
82
92
|
if (!c) return;
|
|
83
93
|
addFiles(c, input.files);
|
|
84
94
|
input.value = '';
|
|
95
|
+
if (focused) focused.focus({ preventScroll: true });
|
|
96
|
+
focused = null;
|
|
85
97
|
});
|
|
86
98
|
// A drag of files over a composer marks it .dragging, which outlines its drop zone. dragenter and dragleave fire on
|
|
87
99
|
// each child it crosses, so the composer counts them, and a drop ends the drag.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Drag-resizing of the desktop columns (docs/adr/2026-11-10-1600-the-desktop-columns-drag-and-this-device-keeps-their-widths.md).
|
|
2
|
+
// A `.col-handle[data-col="sidebar"|"pane"]` drags its column's edge: the sidebar rail's, or the seam between the lanes
|
|
3
|
+
// and the Waiting on you pane. Widths are clamped (sidebar 220–420px, the lanes and the pane each 360px or more), kept
|
|
4
|
+
// per device for every board, and set on <html> as --sb-w and --pane-w, which style.css and sidebar.css read. A double-click
|
|
5
|
+
// on a handle forgets the width; ←/→ on a focused handle moves it 16px. Loaded after the body's first elements so the
|
|
6
|
+
// stored widths land before the first paint of the board.
|
|
7
|
+
(function () {
|
|
8
|
+
const KEYS = { sidebar: 'hub:sidebarWidth', pane: 'hub:paneWidth' };
|
|
9
|
+
const VARS = { sidebar: '--sb-w', pane: '--pane-w' };
|
|
10
|
+
const SIDEBAR_MIN = 220, SIDEBAR_MAX = 420, LANE_MIN = 360, STEP = 16, DEFAULT_SIDEBAR = 272;
|
|
11
|
+
const html = document.documentElement;
|
|
12
|
+
const wide = () => window.matchMedia('(min-width: 900px)').matches;
|
|
13
|
+
const read = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
|
|
14
|
+
const write = (k, v) => { try { localStorage.setItem(k, v); } catch { /* private mode or full: kept for this page only */ } };
|
|
15
|
+
const forget = (k) => { try { localStorage.removeItem(k); } catch { /* nothing kept */ } };
|
|
16
|
+
const stored = (col) => { const n = Number(read(KEYS[col])); return Number.isFinite(n) && n > 0 ? n : null; };
|
|
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));
|
|
19
|
+
|
|
20
|
+
// → px clamped for the column: the pane's ceiling leaves the lanes their minimum beside the sidebar, and its floor
|
|
21
|
+
// wins where the window is too narrow for both.
|
|
22
|
+
function clamp(col, px, viewport, sidebarW) {
|
|
23
|
+
if (col === 'sidebar') return Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, px));
|
|
24
|
+
return Math.max(LANE_MIN, Math.min(px, viewport - sidebarW - LANE_MIN));
|
|
25
|
+
}
|
|
26
|
+
function paint(col) {
|
|
27
|
+
const px = stored(col);
|
|
28
|
+
if (px === null) { html.style.removeProperty(VARS[col]); return; }
|
|
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);
|
|
36
|
+
|
|
37
|
+
// The width a handle's drag edge is at now, and the width the pointer at x asks for.
|
|
38
|
+
const current = (col, el) => {
|
|
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);
|
|
43
|
+
|
|
44
|
+
document.addEventListener('pointerdown', (e) => {
|
|
45
|
+
const el = e.target.closest?.('.col-handle');
|
|
46
|
+
if (!el || !wide() || e.button > 0) return;
|
|
47
|
+
const col = el.dataset.col;
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
el.setPointerCapture?.(e.pointerId);
|
|
50
|
+
el.classList.add('dragging');
|
|
51
|
+
html.classList.add('col-dragging');
|
|
52
|
+
const move = (m) => set(col, wanted(col, m.clientX));
|
|
53
|
+
const end = () => { el.classList.remove('dragging'); html.classList.remove('col-dragging'); el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', end); el.removeEventListener('pointercancel', end); };
|
|
54
|
+
el.addEventListener('pointermove', move);
|
|
55
|
+
el.addEventListener('pointerup', end);
|
|
56
|
+
el.addEventListener('pointercancel', end);
|
|
57
|
+
});
|
|
58
|
+
document.addEventListener('dblclick', (e) => {
|
|
59
|
+
const el = e.target.closest?.('.col-handle');
|
|
60
|
+
if (el && wide()) reset(el.dataset.col);
|
|
61
|
+
});
|
|
62
|
+
document.addEventListener('keydown', (e) => {
|
|
63
|
+
const el = e.target.closest?.('.col-handle');
|
|
64
|
+
if (!el || !wide() || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
|
65
|
+
const col = el.dataset.col;
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
// → grows the sidebar and shrinks the pane, as the edge moves right.
|
|
68
|
+
const dx = (e.key === 'ArrowRight' ? STEP : -STEP) * (col === 'sidebar' ? 1 : -1);
|
|
69
|
+
set(col, (stored(col) ?? current(col, el)) + dx);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
window.ColResize = { clamp, set, reset };
|
|
73
|
+
})();
|
package/board/public/dialogs.js
CHANGED
|
@@ -171,12 +171,15 @@
|
|
|
171
171
|
vv?.addEventListener('resize', fit);
|
|
172
172
|
vv?.addEventListener('scroll', fit);
|
|
173
173
|
window.addEventListener('resize', fit);
|
|
174
|
+
// A file picker closing returns focus to the window without a resize, so the sheet is measured again.
|
|
175
|
+
window.addEventListener('focus', fit);
|
|
174
176
|
window.addEventListener('scroll', pin);
|
|
175
177
|
return () => {
|
|
176
178
|
clearTimeout(settle);
|
|
177
179
|
vv?.removeEventListener('resize', fit);
|
|
178
180
|
vv?.removeEventListener('scroll', fit);
|
|
179
181
|
window.removeEventListener('resize', fit);
|
|
182
|
+
window.removeEventListener('focus', fit);
|
|
180
183
|
window.removeEventListener('scroll', pin);
|
|
181
184
|
};
|
|
182
185
|
}
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
<div class="group">${d.sites.map(site).join('')}</div>
|
|
116
116
|
<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, events (its session and a watcher run, or the hub can wake it), daily wakes, pushes and reviewer links. One that just broke reads settling first.</p></div>
|
|
117
117
|
<section class="hub-settings"><h2 class="label">Hub settings</h2>${settings}</section>
|
|
118
|
-
<div class="hub-foot"><footer
|
|
118
|
+
<div class="hub-foot"><footer><span class="host">mac.example.ts.net</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v0.4.2</span></footer></div></main></div>`;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
const LANES = [['review', 'In Review'], ['progress', 'In Progress'], ['todo', 'Todo'], ['done', 'Done']];
|
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
<script src="attach.js"></script>
|
|
85
85
|
<script src="hub-kit.js"></script>
|
|
86
86
|
<script src="linear-key.js"></script>
|
|
87
|
+
<script src="col-resize.js"></script>
|
|
87
88
|
<script src="sidebar.js"></script>
|
|
88
89
|
<script src="app.js"></script>
|
|
89
90
|
<script src="new-task.js"></script>
|
package/board/public/sidebar.css
CHANGED
|
@@ -188,12 +188,14 @@ html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-
|
|
|
188
188
|
.sidebar .t-paused { --tone: var(--muted); }
|
|
189
189
|
|
|
190
190
|
@media (min-width: 900px) {
|
|
191
|
-
body.has-sidebar { padding-left: 272px; }
|
|
192
|
-
body.has-sidebar .sidebar { width: 272px; transform: none; visibility: visible; transition: none; z-index: 4; }
|
|
191
|
+
body.has-sidebar { padding-left: var(--sidebar-w, 272px); }
|
|
192
|
+
body.has-sidebar .sidebar { width: var(--sb-w, 272px); transform: none; visibility: visible; transition: none; z-index: 4; }
|
|
193
193
|
body.has-sidebar .sidebar .sb-close { display: none; }
|
|
194
194
|
body.has-sidebar .sidebar-scrim { display: none; }
|
|
195
195
|
html:not(.sb-collapsed) body.has-sidebar > .top .hub, html:not(.sb-collapsed) body.has-sidebar > :not(.sidebar) > .top .hub { display: none; }
|
|
196
196
|
html.sb-collapsed body.has-sidebar { padding-left: 0; }
|
|
197
|
+
body.has-sidebar > .col-handle { position: fixed; left: calc(var(--sidebar-w, 272px) - 4px); }
|
|
198
|
+
html.sb-collapsed body.has-sidebar > .col-handle { display: none; }
|
|
197
199
|
html.sb-collapsed body.has-sidebar .sidebar { transform: translateX(-102%); visibility: hidden; }
|
|
198
200
|
/* Switch mode shows a collapsed rail over the page until it ends, and leaves it collapsed. */
|
|
199
201
|
html.sb-collapsed body.has-sidebar .sidebar.ks-on { transform: none; visibility: visible; z-index: 20; }
|
|
@@ -244,3 +246,7 @@ html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-
|
|
|
244
246
|
font: 600 12.5px/1 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; }
|
|
245
247
|
.shortcuts-sheet .sc-foot { margin: 14px 0 0; padding-top: 10px; border-top: 1px solid var(--line); font-size: 12.5px; color: var(--muted); }
|
|
246
248
|
@media (prefers-reduced-motion: reduce) { .sheet.shortcuts-sheet[open] { animation: none; } }
|
|
249
|
+
/* A held row (hub/home.mjs holdNeeds): its last good line, flat gray with (stale) after the word. */
|
|
250
|
+
.sidebar .held .dot, .sidebar .held .word, .sidebar .held .lc { --tone: var(--muted); }
|
|
251
|
+
.sidebar .held .dot.pulse { animation: none; }
|
|
252
|
+
.sidebar .held .stale-word { flex: none; color: var(--muted); font-size: 11px; }
|
package/board/public/style.css
CHANGED
|
@@ -186,7 +186,7 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
|
|
|
186
186
|
.refresh #refresh { padding: 4px; margin-left: -4px; flex: none; }
|
|
187
187
|
.refresh #status { font-size: 12px; min-width: 0; }
|
|
188
188
|
.refresh .late { color: var(--color-notice); font-weight: 600; }
|
|
189
|
-
/* The header's time keeps its text; a stopped or offline board turns it and the refresh arrow red
|
|
189
|
+
/* The header's time keeps its text; a stopped or offline board turns it and the refresh arrow red. */
|
|
190
190
|
.refresh .down, .refresh:has(.down) #refresh { color: var(--color-danger); }
|
|
191
191
|
.refresh .hold, .refresh:has(.hold) #refresh { color: var(--color-notice); }
|
|
192
192
|
.lane-github { margin-left: auto; font-size: 12px; font-weight: 400; color: var(--muted); text-align: right; }
|
|
@@ -240,7 +240,7 @@ code { overflow-wrap: anywhere; }
|
|
|
240
240
|
/* Compose button and sheet */
|
|
241
241
|
main { padding-bottom: calc(110px + env(safe-area-inset-bottom)); }
|
|
242
242
|
/* The + sits two thirds of (16px + the safe area) from the corner, and at least 32px where there is no safe area. */
|
|
243
|
-
:root { --fab-edge: max(32px, calc((16px + env(safe-area-inset-bottom)) * 2 / 3)); }
|
|
243
|
+
:root { --sel-bg: var(--accent-soft); --sel-line: var(--accent); --fab-edge: max(32px, calc((16px + env(safe-area-inset-bottom)) * 2 / 3)); }
|
|
244
244
|
.fab { position: fixed; z-index: 6; right: max(32px, calc((16px + max(env(safe-area-inset-right), env(safe-area-inset-bottom))) * 2 / 3)); bottom: calc(var(--app-bottom, 0px) + var(--fab-edge));
|
|
245
245
|
width: 75px; height: 75px; padding: 0; border-radius: 50%; border: 0; background: var(--accent); color: var(--on-accent);
|
|
246
246
|
display: inline-flex; align-items: center; justify-content: center; box-shadow: 0 6px 18px rgba(0,0,0,.25); }
|
|
@@ -289,13 +289,15 @@ main { padding-bottom: calc(110px + env(safe-area-inset-bottom)); }
|
|
|
289
289
|
@keyframes sheet-in { from { transform: translateY(100%); } }
|
|
290
290
|
@keyframes sheet-out { to { transform: translateY(100%); } }
|
|
291
291
|
@keyframes fade-in { from { opacity: 0; } }
|
|
292
|
+
@keyframes reader-in { from { transform: translateX(100%); } }
|
|
293
|
+
@keyframes reader-out { to { transform: translateX(100%); } }
|
|
292
294
|
@media (min-width: 700px) {
|
|
293
295
|
.sheet { bottom: calc(var(--kb, 0px) + 24px); max-height: calc(var(--vh, 100dvh) - env(safe-area-inset-top) - 12px); padding-bottom: 0;
|
|
294
296
|
border-bottom: 1px solid var(--line); border-radius: 16px; }
|
|
295
297
|
.sheet form { padding-bottom: 14px; }
|
|
296
298
|
}
|
|
297
299
|
@media (prefers-reduced-motion: reduce) {
|
|
298
|
-
.sheet[open], .sheet[open]::backdrop, .sheet.closing, .sheet.closing::backdrop { animation: none; }
|
|
300
|
+
.sheet[open], .sheet.reader-sheet[open], .sheet[open]::backdrop, .sheet.closing, .sheet.reader-sheet.closing, .sheet.closing::backdrop { animation: none; }
|
|
299
301
|
.fab:active { transform: none; }
|
|
300
302
|
}
|
|
301
303
|
/* The hub lives at the tailnet root, outside this site, so its link is root-absolute. */
|
|
@@ -417,6 +419,11 @@ div.status-line { padding-left: 33px; text-indent: -23px; }
|
|
|
417
419
|
/* Only a run's finished line is green: the fold's summary mirrors its last line, green once the run has finished, else the
|
|
418
420
|
started and status lines' orange or a planner's blue (docs/adr/2026-11-07-1600-only-a-runs-finished-line-is-green.md). */
|
|
419
421
|
.msg.calls.status-fold summary.finished { color: var(--color-success); }
|
|
422
|
+
.msg.fault-detail summary { cursor: pointer; list-style: none; overflow-wrap: anywhere; }
|
|
423
|
+
.msg.fault-detail summary::-webkit-details-marker { display: none; }
|
|
424
|
+
.msg.fault-detail[open] summary .mark-icon { transform: rotate(90deg); }
|
|
425
|
+
/* The whole error of a fault line, monospace, wrapping at any width. */
|
|
426
|
+
.fault-error { margin: 4px 0 4px 23px; padding: 8px; font: 12px/1.4 ui-monospace, monospace; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--bg-soft, rgba(127,127,127,.12)); border-radius: 6px; color: var(--fg, inherit); }
|
|
420
427
|
.msg.calls ol { margin: 4px 0; padding-left: 23px; }
|
|
421
428
|
.agent-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: currentColor; margin-right: 5px; vertical-align: 1px; }
|
|
422
429
|
/* Every thread status line's leading mark, icon or dot, sits centred in one 18px box, so the text after it always starts
|
|
@@ -636,7 +643,7 @@ main > * { transition: opacity .25s ease; }
|
|
|
636
643
|
.view-questions .ctx { color: var(--muted); font-size: 13px; margin-bottom: 8px; }
|
|
637
644
|
.view-questions .opt { display: grid; grid-template-columns: 22px 1fr; gap: 6px; padding: 8px; border: 1px solid var(--line);
|
|
638
645
|
border-radius: 8px; margin: 6px 0; cursor: pointer; }
|
|
639
|
-
.view-questions .opt:has(input:checked) { border-color: var(--
|
|
646
|
+
.view-questions .opt:has(input:checked) { border-color: var(--sel-line); background: var(--sel-bg); }
|
|
640
647
|
.view-questions .opt input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--accent); }
|
|
641
648
|
.view-questions .opt .lbl { font-weight: 600; }
|
|
642
649
|
.view-questions .opt .rec { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
|
|
@@ -664,9 +671,9 @@ main > * { transition: opacity .25s ease; }
|
|
|
664
671
|
.view-questions .topics button { min-width: 0; border: 1px solid var(--line); border-radius: 999px; padding: 4px 11px; font-size: 13px;
|
|
665
672
|
background: var(--panel); color: var(--text); display: inline-flex; max-width: 100%; text-align: left; }
|
|
666
673
|
.view-questions .topics .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
667
|
-
.view-questions .topics button[aria-pressed="true"] { border-color: var(--
|
|
674
|
+
.view-questions .topics button[aria-pressed="true"] { border-color: var(--sel-line); background: var(--sel-bg); }
|
|
668
675
|
.view-questions .topics button.progress { border-color: var(--line); background: color-mix(in srgb, var(--color-notice) 12%, var(--panel)); }
|
|
669
|
-
.view-questions .topics button.progress[aria-pressed="true"] { border-color: var(--
|
|
676
|
+
.view-questions .topics button.progress[aria-pressed="true"] { border-color: var(--sel-line); background: var(--sel-bg); }
|
|
670
677
|
@keyframes pill-arrive { from { opacity: 0; transform: translateX(-12px); box-shadow: 0 0 0 3px var(--accent); } to { opacity: 1; transform: none; box-shadow: 0 0 0 0 transparent; } }
|
|
671
678
|
@media (prefers-reduced-motion: no-preference) { .view-questions .topics button.arrived { animation: pill-arrive .6s ease-out; } }
|
|
672
679
|
.view-questions .topics .n { color: var(--muted); margin-left: 4px; }
|
|
@@ -934,6 +941,7 @@ button[aria-busy="true"]:is([data-card-bump]) svg { fill: currentColor; }
|
|
|
934
941
|
/* Needs you (app-needs.js): a deck at the top of the board on a phone; from 900px a pane beside the lanes, its lines over
|
|
935
942
|
Waiting on you (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md). */
|
|
936
943
|
.needs-pane { display: none; }
|
|
944
|
+
.col-handle { display: none; }
|
|
937
945
|
.needs-deck { margin: 0 0 12px; border: 1px solid color-mix(in srgb, var(--color-notice) 45%, var(--line)); border-radius: 14px;
|
|
938
946
|
background: color-mix(in srgb, var(--color-notice) 7%, var(--panel)); overflow: hidden; }
|
|
939
947
|
.deck-head { display: flex; align-items: center; gap: 8px; padding: 10px 14px 4px; }
|
|
@@ -948,7 +956,7 @@ button[aria-busy="true"]:is([data-card-bump]) svg { fill: currentColor; }
|
|
|
948
956
|
.deck-opts { display: flex; flex-direction: column; gap: 6px; }
|
|
949
957
|
.deck-opts button { text-align: left; border-radius: 10px; padding: 10px 12px; font-size: 14px; }
|
|
950
958
|
.deck-opts button.rec { border-color: var(--accent); }
|
|
951
|
-
.deck-opts button.armed {
|
|
959
|
+
.deck-opts button.armed { border-color: var(--sel-line); background: var(--sel-bg); font-weight: 600; }
|
|
952
960
|
.deck-opts .rec-word { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
|
|
953
961
|
.deck-opts .det { color: var(--muted); font-size: 12.5px; font-weight: 400; }
|
|
954
962
|
.deck-prompt { margin: 0; padding: 0; list-style: none; }
|
|
@@ -964,16 +972,30 @@ button[aria-busy="true"]:is([data-card-bump]) svg { fill: currentColor; }
|
|
|
964
972
|
.deck-more::after { content: '›'; color: var(--accent); font-size: 18px; }
|
|
965
973
|
.deck-more:hover { text-decoration: none; }
|
|
966
974
|
@media (min-width: 900px) {
|
|
967
|
-
.split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
|
|
975
|
+
.split { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) var(--pane-w, minmax(0, 1fr)); align-items: start; }
|
|
968
976
|
.split > main { max-width: none; margin: 0; }
|
|
969
977
|
.needs-deck { display: none; }
|
|
970
978
|
/* Waiting on you is the board's pane here, so neither page shows the switch to the other. */
|
|
971
979
|
.top .pages-switch { display: none; }
|
|
972
980
|
.top .nav a.pages-switch { display: none; }
|
|
973
981
|
/* The board's + sits at the bottom right of the left pane: the pane ends at the middle of the space right of the sidebar. */
|
|
974
|
-
.view-board .fab { right: calc(50% - var(--sidebar-w, 0px) / 2 + var(--fab-edge)); }
|
|
975
|
-
body.has-sidebar { --sidebar-w: 272px; }
|
|
982
|
+
.view-board .fab { right: calc(var(--pane-w, calc(50% - var(--sidebar-w, 0px) / 2)) + var(--fab-edge)); }
|
|
983
|
+
body.has-sidebar { --sidebar-w: var(--sb-w, 272px); }
|
|
976
984
|
html.sb-collapsed body.has-sidebar { --sidebar-w: 0px; }
|
|
985
|
+
/* The drag edges (col-resize.js): a hairline that thickens on hover, on the pane's left edge and the sidebar's right. */
|
|
986
|
+
.col-handle { display: block; position: absolute; z-index: 5; top: 0; bottom: 0; width: 9px; cursor: col-resize; touch-action: none; }
|
|
987
|
+
.col-handle::after { content: ''; position: absolute; top: 0; bottom: 0; left: 4px; width: 1px; background: transparent; transition: background .12s, width .12s, left .12s; }
|
|
988
|
+
.col-handle:is(:hover, :focus-visible, .dragging)::after { left: 3px; width: 3px; background: var(--accent); }
|
|
989
|
+
.col-handle:focus-visible { outline: none; }
|
|
990
|
+
html.col-dragging, html.col-dragging * { cursor: col-resize !important; user-select: none; }
|
|
991
|
+
.split > .col-handle { right: calc(var(--pane-w, 50%) - 4px); }
|
|
992
|
+
.split:has(.needs-pane.widened) > .col-handle { display: none; }
|
|
993
|
+
/* The reader sheet (app-reader.js) is a panel over the pane's half, at the width the seam gives it, while it is open. */
|
|
994
|
+
.sheet.reader-sheet { position: fixed; z-index: 30; inset: 56px 0 0 auto; margin: 0; width: var(--pane-w, calc((100vw - var(--sidebar-w, 0px)) / 2)); max-width: none;
|
|
995
|
+
height: auto; max-height: none; border-width: 0 0 0 1px; border-radius: 0; box-shadow: none; }
|
|
996
|
+
.sheet.reader-sheet[open] { animation: reader-in .22s ease-out; }
|
|
997
|
+
.sheet.reader-sheet.closing { animation: reader-out .18s ease-in forwards; }
|
|
998
|
+
.reader-sheet::backdrop { background: transparent; }
|
|
977
999
|
.needs-pane { display: block; position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 12px 16px 0; border-left: 1px solid var(--line); }
|
|
978
1000
|
.needs-pane .needs-questions main { padding: 0; max-width: none; }
|
|
979
1001
|
.needs-pane:has(.q-empty), .needs-pane .needs-questions:has(.q-empty), .needs-pane .needs-questions:has(.q-empty) main, .needs-pane #page:has(.q-empty) { display: flex; flex-direction: column; }
|
|
@@ -1003,5 +1025,5 @@ button[aria-busy="true"]:is([data-card-bump]) svg { fill: currentColor; }
|
|
|
1003
1025
|
.pane-viewer .ps-sheet button span + span { color: var(--muted); }
|
|
1004
1026
|
}
|
|
1005
1027
|
/* The Send bar is the last thing on its page: only the phone nav under it is left clear, so its background runs to the edge. */
|
|
1006
|
-
body .view-questions main { padding-bottom: var(--app-bottom, 0px); }
|
|
1028
|
+
body .view-questions.view-questions main { padding-bottom: var(--app-bottom, 0px); }
|
|
1007
1029
|
.pfile.retry { cursor: pointer; }
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh). It reads that age;
|
|
5
5
|
// the board's other states only move the span's colour (the arrow follows it, style.css) and its title. watcher is
|
|
6
6
|
// /api/board's checks.watcher (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md): stopped
|
|
7
|
-
// (nothing will wake it) and an offline link turn it red, paused usage
|
|
7
|
+
// (nothing will wake it) and an offline link turn it red, paused usage leaves it as it is (the board's pause banner says it). A busy host doesn't change it
|
|
8
8
|
// at all (docs/adr/2026-11-06-1305-a-busy-host-shows-only-on-the-hub-row-and-the-logo-dot.md).
|
|
9
9
|
// link is 'online', 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it, and the icon spins).
|
|
10
10
|
const statusLine = (at, { link = 'online', watcher, pause, waking = false } = {}) => {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
: watcher === 'asleep' ? 'Asleep: its session idled out. Refresh, or any tap on the page, wakes it' : '';
|
|
19
19
|
if (!at && !reason) return '';
|
|
20
20
|
const title = [reason, at && `Updated ${since(at)}: the last change from the host, or your last refresh`].filter(Boolean).join('. ');
|
|
21
|
-
return `<span class="fresh${down ? ' down' :
|
|
21
|
+
return `<span class="fresh${down ? ' down' : ''}" title="${esc(title)}">${at ? shortSince(at) : ''}</span>`;
|
|
22
22
|
};
|
|
23
23
|
// What the page shows, for telling whether a change signal brought anything new; generatedAt changes on every load.
|
|
24
24
|
const freshSig = (board, threads, openKeys) => JSON.stringify([{ ...board, generatedAt: null }, [...openKeys].sort().map((k) => [k, threads.get(k) ?? null])]);
|
|
@@ -44,7 +44,15 @@
|
|
|
44
44
|
if (w.state === 'reset') return `Reset ${esc(usageClock(w.resets_at))}`;
|
|
45
45
|
return `<span class="u-num">${Math.round(w.used_percentage)}%</span> · resets ${esc(usageClock(w.resets_at))}`;
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
// Claude's week shows as a second bar once it is live within the near band under the pause line, which starts twice the
|
|
48
|
+
// buffer below 100 (shared/usage-gate.mjs nearFrom): orange under the line, red at it. Off (100) has no band.
|
|
49
|
+
function weekBar(reading, pauseAt) {
|
|
50
|
+
const w = reading.seven_day;
|
|
51
|
+
if (!(pauseAt < 100) || w?.state !== 'live' || !(w.used_percentage >= 2 * pauseAt - 100)) return '';
|
|
52
|
+
const tone = w.used_percentage >= pauseAt ? 'full' : 'high';
|
|
53
|
+
return `<span class="u-one u-tone-${tone}"><span class="u-line"><span class="u-name">Claude week</span> ${usageWords(reading, 'seven_day')}</span>${usageBar(w)}</span>`;
|
|
54
|
+
}
|
|
55
|
+
function sidebarUsage(data, pauseAt) {
|
|
48
56
|
const products = usageProducts(data);
|
|
49
57
|
return products.length ? `<span class="sb-usage">${products.map((p) => {
|
|
50
58
|
const r = data[p];
|
|
@@ -54,7 +62,7 @@
|
|
|
54
62
|
: w.state === 'stale' ? `${name} · last seen ${esc(usageClock(r.at))}`
|
|
55
63
|
: w.state === 'reset' ? `${name} · reset ${esc(usageClock(w.resets_at))}`
|
|
56
64
|
: `${name} ${usageWords(r, 'five_hour')}`;
|
|
57
|
-
return `<span class="u-one u-tone-${usageTone(w)}"><span class="u-line">${words}</span>${usageBar(w)}</span
|
|
65
|
+
return `<span class="u-one u-tone-${usageTone(w)}"><span class="u-line">${words}</span>${usageBar(w)}</span>${p === 'claude' ? weekBar(r, pauseAt) : ''}`;
|
|
58
66
|
}).join('')}</span>` : '';
|
|
59
67
|
}
|
|
60
68
|
// The utility rows, in the order they show.
|
|
@@ -227,7 +235,7 @@
|
|
|
227
235
|
const turnOn = device === 'off' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Turn on notifications', sub: 'Off on this device', cls: 'sb-turnon' })
|
|
228
236
|
: device === 'install' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Add Claude to your Home Screen', sub: 'Notifications come only to the Home Screen app', cls: 'sb-turnon' })
|
|
229
237
|
: '';
|
|
230
|
-
const usage = sidebarUsage(data?.usage);
|
|
238
|
+
const usage = sidebarUsage(data?.usage, data?.pause?.pause_at);
|
|
231
239
|
const hub = data?.hub?.tone === 'down' || data?.hub?.tone === 'wait' ? data.hub : paused ? { tone: 'paused', word: 'paused', detail: pauseUntilWords(data.pause.resets_at) } : busy ? { tone: 'wait', word: 'busy', detail: 'the host is busy' } : data?.hub;
|
|
232
240
|
const n = data?.notifications || {};
|
|
233
241
|
const lower = (text) => String(text || '').replace(/^\w/, (c) => c.toLowerCase());
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
: m.request ? `<div class="msg status-line request${m.command === 'readied' ? ' readied' : ''}">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
|
|
50
50
|
// docs/adr/2026-11-09-1200-the-board-writes-status-lines-and-only-the-orchestrator-writes-messages.md: the board's
|
|
51
51
|
// own line, its kind's mark and words, red for a fault.
|
|
52
|
+
: m.kind === 'hub' && m.detail ? `<details class="msg status-line request hub fault-detail${m.fault ? ' fault' : ''}"><summary>${statusMark(m.mark)}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></summary><pre class="fault-error">${esc(m.detail)}</pre></details>`
|
|
52
53
|
: m.kind === 'hub' ? `<div class="msg status-line request hub${m.fault ? ' fault' : ''}">${statusMark(m.mark)}${m.link ? `<a href="${esc(m.link)}">${escText(m.text)}</a>` : escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
53
54
|
// docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md: a card's move between lanes.
|
|
54
55
|
: m.kind === 'move' ? `<div class="msg status-line request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
package/board/public/view-hub.js
CHANGED
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
// GitHub's row while its rate limit holds is red, a fault, and says when it resets
|
|
52
52
|
// (docs/adr/2026-11-09-1626-gh-refuses-while-githubs-hourly-limit-holds.md).
|
|
53
53
|
const rows = integrations.map((it) => row({ icon: tile(it.id, it.ready ? 'var(--color-quiet)' : it.limited ? 'var(--color-danger)' : 'var(--color-notice)'), title: esc(it.name), wrap: true, trail: keyButton(it),
|
|
54
|
-
sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}${devices(it)}`
|
|
54
|
+
sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}${devices(it)}${it.note ? `<span class="links"><span class="rd-waits">${esc(it.note)}</span></span>` : ''}`
|
|
55
55
|
: `${it.limited ? '<span class="st down">rate limited</span>' : '<span class="st wait">not ready</span>'} · ${esc(it.missing)}${missingLinks(it)}` })).join('');
|
|
56
56
|
return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
|
|
57
57
|
<p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
|
|
@@ -92,7 +92,7 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
// The version is a button: a tap checks npm now (POST /_hub/check-update) and the answer shows beside it (updateNote).
|
|
95
|
-
const footer = (d, note = '') => `<footer
|
|
95
|
+
const footer = (d, note = '') => `<footer><span class="host">${esc(d.host)}</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / <button type="button" class="version" data-check-update>v${esc(d.version)}</button>${note ? ` <span class="update-note" role="status">${esc(note)}</span>` : ''}</span></footer>`;
|
|
96
96
|
// What the hub's answer to a version tap says (hub/upgrade.mjs check()).
|
|
97
97
|
const updateNote = (out) => ({
|
|
98
98
|
clone: `${out.commit} · pulls with main`,
|
|
@@ -390,7 +390,8 @@
|
|
|
390
390
|
const first = topics.find((t) => t.id === topicId)?.questions[0];
|
|
391
391
|
if (first) setHash(first);
|
|
392
392
|
ctx.onTopic?.(topicId);
|
|
393
|
-
|
|
393
|
+
const y = scrollY;
|
|
394
|
+
render(); scroll(y);
|
|
394
395
|
if (ctx.embedded) openOnBoard(topicId);
|
|
395
396
|
}
|
|
396
397
|
});
|