@lovek4d/kickflip 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +60 -48
- package/SKILL.md +20 -14
- package/bin/event-lines.mjs +1 -0
- package/bin/kickflip.mjs +8 -8
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +118 -104
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -9
- package/board/hub-lines.mjs +1 -0
- package/board/onboarding.mjs +2 -2
- package/board/prototype-page.mjs +1 -1
- package/board/public/app-arch.js +3 -3
- package/board/public/app-banner.js +1 -1
- package/board/public/app-cards.js +17 -9
- package/board/public/app-header.js +1 -1
- package/board/public/app-lanes.js +39 -61
- package/board/public/app-needs.js +116 -30
- package/board/public/app-reader.js +54 -44
- package/board/public/app-threads.js +2 -2
- package/board/public/app.html +1 -1
- package/board/public/app.js +23 -13
- package/board/public/dialogs.js +1 -1
- package/board/public/hub-kit.js +1 -1
- package/board/public/hub.css +2 -0
- package/board/public/linear-key.js +1 -1
- package/board/public/new-task.js +1 -1
- package/board/public/progress-review.js +4 -4
- package/board/public/prototype-kit.js +27 -16
- package/board/public/prototype-switcher.js +47 -32
- package/board/public/prototype.html +7 -4
- package/board/public/router.js +35 -7
- package/board/public/shell.js +19 -13
- package/board/public/sidebar.css +4 -4
- package/board/public/sidebar.js +2 -2
- package/board/public/style.css +74 -36
- package/board/public/ui-cards.js +56 -37
- package/board/public/ui-enums.js +10 -1
- package/board/public/ui-hub.js +1 -5
- package/board/public/ui-links.js +6 -6
- package/board/public/ui-order.js +8 -6
- package/board/public/ui-progress-bar.js +6 -7
- package/board/public/ui-questions.js +30 -13
- package/board/public/ui-sidebar.js +3 -2
- package/board/public/ui-threads.js +20 -19
- package/board/public/ui.js +15 -4
- package/board/public/view-hub-settings.js +28 -31
- package/board/public/view-hub.js +7 -9
- package/board/public/view-notes.js +2 -2
- package/board/public/view-questions.js +35 -6
- package/board/public/view-settings.js +63 -31
- package/board/render-check.mjs +10 -10
- package/board/review.mjs +1 -1
- package/board/server.mjs +18 -20
- package/board/stop.mjs +1 -1
- package/board/store.mjs +26 -25
- package/board/tickets.mjs +1 -1
- package/board/transcript.mjs +4 -2
- package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
- package/hub/awake.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/github.mjs +3 -3
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/try.mjs +6 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/tailscale.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
|
@@ -87,7 +87,6 @@
|
|
|
87
87
|
usage: fake.usage(),
|
|
88
88
|
hub: { tone: 'ok', word: 'healthy', detail: '3 of 3 sites up' },
|
|
89
89
|
sites: fake.sites(),
|
|
90
|
-
polling: { title: 'Checks every minute while you’re here', lines: ['Next check 12:00pm · last 11:45am, 8 PRs, no change', 'No pushes queued.'], count: 0 },
|
|
91
90
|
settings: [['notifications', 'Notifications', 'Pushes on · Off on weekends · This device on'], ['quiet', 'Quiet hours', '10:00pm – 8:00am · pushes held'],
|
|
92
91
|
['codex', 'Codex share', 'Auto'], ['rules', 'Rules for every board', '1 rule'], ['closed', 'Closed boards', 'No closed boards']],
|
|
93
92
|
}),
|
|
@@ -105,7 +104,7 @@
|
|
|
105
104
|
|
|
106
105
|
function hub(data = {}) {
|
|
107
106
|
const d = { ...fake.hub(), header: true, ...data };
|
|
108
|
-
const { row, tile,
|
|
107
|
+
const { row, tile, CHEVRON } = HubUI;
|
|
109
108
|
const site = (s) => row({ icon: s.icon, title: esc(s.title), sub: s.status ?? '<span class="st ok">up · listening · every link passes</span>', wrap: true, trail: CHEVRON, href: `/${s.route}/` });
|
|
110
109
|
const settings = d.settings.map(([id, title, sub]) => `<button type="button" class="hub-setting" data-setting="${esc(id)}" data-kit-inert aria-haspopup="dialog"><span class="text"><span class="title">${esc(title)}</span><span class="sub">${esc(sub)}</span></span>${CHEVRON}</button>`).join('');
|
|
111
110
|
const usage = d.usageCard ?? HubUI.usageCard(d.usage);
|
|
@@ -114,9 +113,7 @@
|
|
|
114
113
|
<div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span> · ${esc(d.hub.detail)}`, wrap: true })}</div>
|
|
115
114
|
<h2 class="label">Boards</h2>
|
|
116
115
|
<div class="group">${d.sites.map(site).join('')}</div>
|
|
117
|
-
<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, events (its session and a watcher run, or the hub can wake it), daily wakes, pushes and reviewer links. One that just broke reads settling first.</p>
|
|
118
|
-
<h2 class="label">Polling status</h2>
|
|
119
|
-
<div class="group">${nowRow(d.polling, tile('clock', '#3b82f6'))}</div></div>
|
|
116
|
+
<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, events (its session and a watcher run, or the hub can wake it), daily wakes, pushes and reviewer links. One that just broke reads settling first.</p></div>
|
|
120
117
|
<section class="hub-settings"><h2 class="label">Hub settings</h2>${settings}</section>
|
|
121
118
|
<div class="hub-foot"><footer>${d.sites.length} sites. Each belongs to one Claude session, which adds it with <code>/kickflip <route></code>. This page refreshes every 30 seconds.<br><span class="host">mac.example.ts.net</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v0.4.2</span></footer></div></main></div>`;
|
|
122
119
|
}
|
|
@@ -205,25 +202,39 @@
|
|
|
205
202
|
const stage = document.getElementById('stage');
|
|
206
203
|
let at = { take: null, state: null };
|
|
207
204
|
let frame = null;
|
|
205
|
+
// The run the frame shows: its since from api/try/prototypes.
|
|
206
|
+
let framed = null;
|
|
208
207
|
let origin = null;
|
|
209
208
|
let started = false;
|
|
210
209
|
const serve = () => fetch(site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: 'prototypes' }) }).catch(() => {});
|
|
211
210
|
const post = () => frame.contentWindow.postMessage({ kickflip: 'prototype', ...at }, origin);
|
|
211
|
+
// Polls the serve while the page is open (docs/adr/2026-11-07-1231-a-base-move-restarts-the-prototype-serve-and-a-demo-hub-never-restarts-itself.md):
|
|
212
|
+
// a ready run the frame doesn't show, the first or a new one after a restart or a base move, gets a new frame at the
|
|
213
|
+
// current take and state; a serve that's down shows Starting. A poll that can't reach the board leaves the stage.
|
|
212
214
|
async function poll() {
|
|
213
|
-
let run
|
|
214
|
-
try { run = await (await fetch(site.url('api/try/prototypes'), { cache: 'no-store' })).json(); } catch {
|
|
215
|
+
let run;
|
|
216
|
+
try { run = await (await fetch(site.url('api/try/prototypes'), { cache: 'no-store' })).json(); } catch {
|
|
217
|
+
if (!frame) stage.innerHTML = servedStatus({});
|
|
218
|
+
wait(poll, 1500);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
215
221
|
if (run.state === 'ready' && run.https) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
if (!frame || (run.since ?? null) !== framed) {
|
|
223
|
+
const src = servedSrc({ https: run.https, path: data.served, ...at });
|
|
224
|
+
origin = new URL(src).origin;
|
|
225
|
+
framed = run.since ?? null;
|
|
226
|
+
frame = document.createElement('iframe');
|
|
227
|
+
frame.className = 'kit-served';
|
|
228
|
+
frame.title = data.title;
|
|
229
|
+
frame.src = src;
|
|
230
|
+
// A switch made while the page loaded reaches it once it has.
|
|
231
|
+
nativeAdd.call(frame, 'load', post);
|
|
232
|
+
stage.replaceChildren(frame);
|
|
233
|
+
}
|
|
234
|
+
wait(poll, 3000);
|
|
225
235
|
return;
|
|
226
236
|
}
|
|
237
|
+
frame = null;
|
|
227
238
|
stage.innerHTML = servedStatus(run);
|
|
228
239
|
// A serve that's down is started once from here; a failed one waits for Try again.
|
|
229
240
|
if (!run.state && !started) { started = true; serve(); }
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
// The full-bleed prototype viewer's
|
|
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
|
-
// docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md
|
|
3
|
+
// docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md,
|
|
4
|
+
// docs/adr/2026-11-07-2030-on-desktop-the-prototype-viewer-opens-in-the-right-pane.md).
|
|
4
5
|
// Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
|
|
5
6
|
// const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, states: { empty: 'No cards', … }?, initial: 'A', render(take, state) {…}, above: el? })
|
|
6
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
|
|
7
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
|
|
8
9
|
// pager, the state kept in ?state=, else the first. The page's header is hidden: a ✕ at the top
|
|
9
|
-
// right goes back as its back arrow does, and the
|
|
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. Framed in the board's right pane, the pane's top bar takes the ✕'s and the Round pill's place. ←/→
|
|
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
|
|
11
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
|
|
12
14
|
// the note typed above the bar, in the {choice, note} questions.html keeps for that question, and goes back to the
|
|
13
15
|
// question, whose Send sends it. A card with more than one prototype round gets a Round pill at the top left, which
|
|
@@ -65,20 +67,6 @@
|
|
|
65
67
|
return `${chips}<div class="ps-row"><button type="button" data-ps="prev" aria-label="Previous take">‹</button><span class="ps-name" title="${esc(name)}">${esc(name)}</span><button type="button" data-ps="next" aria-label="Next take">›</button></div>${control ? `<div class="ps-row ps-act">${control}</div>` : ''}`;
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
// rounds: the card's prototype questions, oldest round first, as /api/board's card prototypes.rounds lists them
|
|
69
|
-
// ({n, q, url, confirm, state}). The pill names the round qid is in, out of the newest round's number; a card with a
|
|
70
|
-
// single round gets none.
|
|
71
|
-
function roundPill(rounds, qid) {
|
|
72
|
-
const at = (rounds || []).find((r) => r.q === qid);
|
|
73
|
-
if (!at || rounds.length < 2) return '';
|
|
74
|
-
return `<button type="button" class="ps-chip" data-ps-top="rounds">Round ${at.n} of ${Math.max(...rounds.map((r) => r.n))} · earlier</button>`;
|
|
75
|
-
}
|
|
76
|
-
const ROUND_WORD = { open: 'open', requested: 'prototype requested' };
|
|
77
|
-
function roundSheet({ ref, rounds, qid }) {
|
|
78
|
-
const rows = [...rounds].reverse().map((r) => `<button type="button" data-ps-round="${esc(r.url)}"><span>Round ${r.n}${r.confirm ? ' confirm' : ''}${r.q === qid ? ' · showing' : ''}</span><span>${ROUND_WORD[r.state] || 'answered'}</span></button>`);
|
|
79
|
-
return `<div class="ps-sheet" role="dialog" aria-label="Rounds"><h3>Rounds on ${esc(ref)}</h3>${rows.join('')}</div>`;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
70
|
// A touch from start to end: 1 for a swipe left (the next take), -1 for a swipe right, 0 for anything else: under
|
|
83
71
|
// 50px sideways, more down than across, or starting at the left edge, where the drawer's and the browser's swipes are.
|
|
84
72
|
const swipeStep = (start, end) => {
|
|
@@ -88,10 +76,13 @@
|
|
|
88
76
|
|
|
89
77
|
const CSS = `
|
|
90
78
|
body > header.top { display:none; }
|
|
79
|
+
/* style.css sets --app-bottom on a phone for the bar the header becomes; hidden here, it leaves nothing to clear. */
|
|
80
|
+
:root { --app-bottom:0px !important; }
|
|
91
81
|
main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
92
82
|
.ps-top { position:fixed; left:10px; right:10px; top:calc(10px + env(safe-area-inset-top)); z-index:31; display:flex; justify-content:space-between;
|
|
93
83
|
align-items:flex-start; pointer-events:none; font:600 13px -apple-system, system-ui, sans-serif; }
|
|
94
84
|
.ps-top > * { pointer-events:auto; }
|
|
85
|
+
.ps-pending { visibility:hidden; }
|
|
95
86
|
.ps-chip, .ps-close { color:#fff; background:#000a; border:1px solid #fff4; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); font:inherit; cursor:pointer; }
|
|
96
87
|
.ps-chip { border-radius:999px; padding:0 14px; min-height:40px; }
|
|
97
88
|
.ps-close { margin-left:auto; width:40px; height:40px; border-radius:50%; font-size:17px; line-height:1; }
|
|
@@ -101,11 +92,10 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
101
92
|
.ps-sheet button { display:flex; justify-content:space-between; gap:12px; width:100%; border:0; border-top:1px solid #fff2; background:transparent; color:#fff;
|
|
102
93
|
padding:12px 2px; font:inherit; font-weight:500; text-align:left; cursor:pointer; }
|
|
103
94
|
.ps-sheet button span + span { opacity:.7; }
|
|
104
|
-
.ps-switch { position:fixed; left:
|
|
105
|
-
|
|
106
|
-
.ps-switch > * {
|
|
107
|
-
.ps-pill { display:flex; flex-direction:column; gap:6px;
|
|
108
|
-
box-shadow:0 6px 24px #0008; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); font:600 13px -apple-system, system-ui, sans-serif; }
|
|
95
|
+
.ps-switch { position:fixed; left:0; right:0; bottom:var(--app-bottom, 0px); z-index:30; display:flex; flex-direction:column; align-items:stretch;
|
|
96
|
+
background:#202026; border-top:1px solid #fff3; padding:6px 6px calc(6px + env(safe-area-inset-bottom)); box-sizing:border-box; }
|
|
97
|
+
.ps-switch > * { min-width:0; box-sizing:border-box; }
|
|
98
|
+
.ps-pill { display:flex; flex-direction:column; gap:6px; border-radius:0; font:600 13px -apple-system, system-ui, sans-serif; }
|
|
109
99
|
.ps-row { display:flex; align-items:center; gap:2px; min-width:0; }
|
|
110
100
|
.ps-pill button { color:#fff; background:transparent; border:0; border-radius:999px; padding:7px 11px; font:inherit; white-space:nowrap; cursor:pointer; }
|
|
111
101
|
.ps-pill button { flex-shrink:0; min-width:44px; min-height:44px; }
|
|
@@ -115,19 +105,23 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
115
105
|
.ps-pill .ps-states button { background:#fff1; font-weight:500; }
|
|
116
106
|
.ps-pill .ps-states button[aria-pressed="true"] { background:#fff; color:#000; font-weight:700; }
|
|
117
107
|
.ps-pill .ps-pick { background:var(--accent, #fff); color:var(--on-accent, #000); font-weight:700; }
|
|
118
|
-
.ps-closed { padding:
|
|
119
|
-
.ps-switch .ps-text {
|
|
120
|
-
padding:7px 10px; font:400 16px -apple-system, system-ui, sans-serif;
|
|
108
|
+
.ps-closed { box-sizing:border-box; min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 11px; border-radius:999px; color:#fffa; background:#fff1; white-space:nowrap; }
|
|
109
|
+
.ps-switch .ps-text { margin-bottom:6px; resize:none; border-radius:12px; border:1px solid #fff3; background:#000d; color:#fff;
|
|
110
|
+
padding:7px 10px; font:400 16px -apple-system, system-ui, sans-serif; }
|
|
121
111
|
.ps-switch .ps-text::placeholder { color:#fffa; }
|
|
122
112
|
.ps-switch .ps-text[hidden] { display:none; }
|
|
123
|
-
.ps-picked { padding:
|
|
124
|
-
.ps-note { background:#000d; color:#fff; border-radius:8px; padding:5px 10px; font:500 12px -apple-system, system-ui, sans-serif; text-align:center; }
|
|
113
|
+
.ps-picked { box-sizing:border-box; min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 11px; border-radius:999px; color:#fff; background:#1f9d55; white-space:nowrap; }
|
|
114
|
+
.ps-note { margin-bottom:6px; background:#000d; color:#fff; border-radius:8px; padding:5px 10px; font:500 12px -apple-system, system-ui, sans-serif; text-align:center; }
|
|
125
115
|
.ps-note[hidden] { display:none; }
|
|
126
116
|
@media (min-width: 900px) {
|
|
117
|
+
.ps-switch { align-items:center; }
|
|
127
118
|
.ps-pill { width:min(640px, 100%); }
|
|
128
119
|
.ps-switch .ps-text { width:min(640px, 100%); }
|
|
129
120
|
.ps-states { flex-wrap:wrap; overflow-x:visible; }
|
|
130
|
-
}
|
|
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; }`;
|
|
131
125
|
|
|
132
126
|
const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
|
|
133
127
|
|
|
@@ -138,6 +132,8 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
138
132
|
const qid = params.get('q');
|
|
139
133
|
const site = globalThis.ClaudeSite;
|
|
140
134
|
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');
|
|
141
137
|
let take = startTake(keys, params.get('variant'), initial);
|
|
142
138
|
let question = null;
|
|
143
139
|
// A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a reload stays on the
|
|
@@ -149,18 +145,23 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
149
145
|
box.className = 'ps-switch';
|
|
150
146
|
box.innerHTML = '<div class="ps-note" role="status" hidden></div><textarea class="ps-text" rows="2" placeholder="Note with your pick (optional)" aria-label="Note with your pick" hidden></textarea><div class="ps-pill"></div>';
|
|
151
147
|
if (above) box.prepend(above);
|
|
148
|
+
box.classList.add('ps-pending');
|
|
152
149
|
document.body.append(box);
|
|
150
|
+
// The footer's height, so the page above it ends where it starts (prototype.html reads --ps-footer-h).
|
|
151
|
+
const measure = () => document.documentElement.style.setProperty('--ps-footer-h', `${box.offsetHeight}px`);
|
|
152
|
+
new ResizeObserver(measure).observe(box);
|
|
153
153
|
const pill = box.querySelector('.ps-pill');
|
|
154
154
|
const noteEl = box.querySelector('.ps-note');
|
|
155
155
|
const text = box.querySelector('.ps-text');
|
|
156
156
|
// The ✕ and, on a card with earlier rounds, the Round pill and its sheet. boardCard: /api/board's card the question is on.
|
|
157
157
|
const top = document.createElement('div');
|
|
158
|
-
top.className = 'ps-top';
|
|
158
|
+
top.className = 'ps-top ps-pending';
|
|
159
159
|
document.body.append(top);
|
|
160
160
|
let boardCard = null;
|
|
161
161
|
let sheet = false;
|
|
162
162
|
const paintTop = () => {
|
|
163
163
|
const rounds = boardCard?.prototypes?.rounds;
|
|
164
|
+
const { roundPill, roundSheet } = globalThis.BoardUI;
|
|
164
165
|
top.innerHTML = `${roundPill(rounds, qid)}<button type="button" class="ps-close" data-ps-top="close" aria-label="Close">✕</button>${sheet && rounds ? roundSheet({ ref: boardCard.ref, rounds, qid }) : ''}`;
|
|
165
166
|
};
|
|
166
167
|
paintTop();
|
|
@@ -186,7 +187,20 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
186
187
|
render(take, shownState);
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
// The footer and the Round pill stay hidden until the first load settles (or 1.5s pass), so they appear complete in one paint.
|
|
191
|
+
let revealed = false;
|
|
192
|
+
const reveal = () => {
|
|
193
|
+
if (revealed) return;
|
|
194
|
+
revealed = true;
|
|
195
|
+
measure();
|
|
196
|
+
box.classList.remove('ps-pending');
|
|
197
|
+
top.classList.remove('ps-pending');
|
|
198
|
+
};
|
|
199
|
+
setTimeout(reveal, 1500);
|
|
189
200
|
async function load() {
|
|
201
|
+
try { await fetchQuestion(); } finally { reveal(); }
|
|
202
|
+
}
|
|
203
|
+
async function fetchQuestion() {
|
|
190
204
|
if (!qid || !site) return;
|
|
191
205
|
try {
|
|
192
206
|
const board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
|
|
@@ -202,7 +216,7 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
202
216
|
} catch { /* keep the last question shown */ }
|
|
203
217
|
if (fromPush && pickedChoice(question)) {
|
|
204
218
|
const card = /^item:(.+)$/.exec(question.topic || '')?.[1];
|
|
205
|
-
replaceWith(site.url(card ? `#
|
|
219
|
+
replaceWith(site.url(card ? `#selectedCard=${encodeURIComponent(card)}&reply` : 'questions.html'));
|
|
206
220
|
return;
|
|
207
221
|
}
|
|
208
222
|
if (question && !text.value) text.value = readDraft(qid).note || '';
|
|
@@ -260,9 +274,10 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
260
274
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
|
|
261
275
|
|
|
262
276
|
go(take);
|
|
277
|
+
measure();
|
|
263
278
|
load();
|
|
264
279
|
return { takes: keys, get take() { return take; }, get state() { return shownState; }, go, show };
|
|
265
280
|
}
|
|
266
281
|
|
|
267
|
-
globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml,
|
|
282
|
+
globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, swipeStep, mount };
|
|
268
283
|
})();
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
<link rel="stylesheet" href="sidebar.css">
|
|
11
11
|
<style>
|
|
12
12
|
/* The one page every recorded prototype is (board/server.mjs fills the {{…}} slots; prototype-kit.js documents what a
|
|
13
|
-
take gets). main keeps room below the stage for the viewer's
|
|
14
|
-
|
|
13
|
+
take gets). main keeps room below the stage for the viewer's footer, at its measured height (prototype-switcher.js),
|
|
14
|
+
which already holds the bottom inset. */
|
|
15
|
+
main.proto-main { max-width: none; padding-left: 0; padding-right: 0; padding-bottom: calc(var(--ps-footer-h, 150px) + var(--app-bottom, 0px)); }
|
|
15
16
|
/* kit.hub() is a whole page with its own gutter and header, so it goes edge to edge. */
|
|
16
17
|
main.proto-main:has(> #stage > .kit-hub) { padding-top: 0; }
|
|
17
18
|
/* contain and the transform make the stage the containing block of everything in it, position: fixed included, and
|
|
@@ -20,9 +21,11 @@
|
|
|
20
21
|
#stage:has(> .kit-hub) { padding: 0; }
|
|
21
22
|
#stage .top { position: static; }
|
|
22
23
|
#stage .hub-view main { padding-bottom: 16px; }
|
|
23
|
-
/* A served round's page, framed from the board's base prototype worktree, edge to edge
|
|
24
|
+
/* A served round's page, framed from the board's base prototype worktree, edge to edge from under the ✕ row to the
|
|
25
|
+
viewer's footer; in the board's right pane (.ps-pane) there is no ✕ row, so it starts at the top. */
|
|
24
26
|
#stage:has(> .kit-served) { max-width: none; padding: 0; }
|
|
25
|
-
.kit-served { display: block; width: 100%; min-height: 320px; height: calc(100dvh -
|
|
27
|
+
.kit-served { display: block; width: 100%; min-height: 320px; height: calc(100dvh - 60px - var(--ps-footer-h, 150px) - var(--app-bottom, 0px) - env(safe-area-inset-top)); border: 0; background: var(--panel); }
|
|
28
|
+
.ps-pane .kit-served { height: calc(100dvh - var(--ps-footer-h, 150px)); }
|
|
26
29
|
.kit-served-failed pre { margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
|
|
27
30
|
.kit-missing { margin: 0; padding: 16px; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
|
28
31
|
#protoToast { position: fixed; z-index: 40; left: 50%; top: calc(64px + env(safe-area-inset-top)); transform: translateX(-50%); max-width: calc(100vw - 32px); }
|
package/board/public/router.js
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// ui.test.mjs runs them in node:vm. listen() catches clicks on links to a view the shell mounts, and back and forward.
|
|
4
4
|
// A view is {kind, …}:
|
|
5
5
|
// start `/`, which the shell turns into the last board
|
|
6
|
-
// board {route, hash} /<route>/, hash such as #item=<key>&reply
|
|
6
|
+
// board {route, hash} /<route>/, hash such as #selectedCard=<key>&openItemsCard=<topic id>, or the older #item=<key>&reply
|
|
7
7
|
// settings {route} /<route>/settings.html, a request to open the modal over that board
|
|
8
8
|
// questions {route, id} /<route>/questions.html#<id>
|
|
9
|
-
// notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<
|
|
9
|
+
// notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<wave slug>, and plan.html, which the board
|
|
10
10
|
// redirects there
|
|
11
11
|
// hub {hash} /_hub/ with a settings modal named by hash; legacy /_hub/settings opens the same modals
|
|
12
12
|
// ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)),
|
|
@@ -45,6 +45,7 @@ globalThis.AppRouter = (function () {
|
|
|
45
45
|
if (page === 'questions.html') {
|
|
46
46
|
let id = null;
|
|
47
47
|
try { id = u.hash.length > 1 ? decodeURIComponent(u.hash.slice(1)) : null; } catch { id = u.hash.slice(1); }
|
|
48
|
+
if (id?.startsWith('openItemsCard=')) id = id.slice('openItemsCard='.length);
|
|
48
49
|
return { kind: 'questions', route, id };
|
|
49
50
|
}
|
|
50
51
|
if (page === 'notes.html' || page === 'plan.html') {
|
|
@@ -80,10 +81,10 @@ globalThis.AppRouter = (function () {
|
|
|
80
81
|
|
|
81
82
|
// The page tree (docs/adr/2026-10-30-1125-back-follows-the-page-tree.md): a board, its Waiting on you, and a prototype page tied
|
|
82
83
|
// to one of its questions by ?q=, framed. levelOf → 0, 1 or 2 down the tree, or null for a view outside it.
|
|
84
|
+
// A board's prototype page, which from 900px opens in the board's right pane (shell.js go).
|
|
85
|
+
const isPrototype = (view, boards) => view?.kind === 'frame' && boards.has(view.route) && /^\/[^/]+\/prototype-[\w-]+\.html$/.test(new URL(view.url, 'http://hub').pathname);
|
|
83
86
|
function questionOf(view, boards) {
|
|
84
|
-
|
|
85
|
-
const u = new URL(view.url, 'http://hub');
|
|
86
|
-
return /^\/[^/]+\/prototype-[\w-]+\.html$/.test(u.pathname) ? u.searchParams.get('q') || null : null;
|
|
87
|
+
return isPrototype(view, boards) ? new URL(view.url, 'http://hub').searchParams.get('q') || null : null;
|
|
87
88
|
}
|
|
88
89
|
// wide: from 900px Waiting on you is the board's pane, so a prototype's parent is the board itself.
|
|
89
90
|
function levelOf(view, { boards = new Set(), wide = false } = {}) {
|
|
@@ -96,7 +97,7 @@ globalThis.AppRouter = (function () {
|
|
|
96
97
|
function parentsOf(view, { boards = new Set(), card = null, wide = false } = {}) {
|
|
97
98
|
const q = questionOf(view, boards);
|
|
98
99
|
if (!q && view?.kind !== 'questions') return [];
|
|
99
|
-
const board = { kind: 'board', route: view.route, hash: card ? `#
|
|
100
|
+
const board = { kind: 'board', route: view.route, hash: card ? `#selectedCard=${encodeURIComponent(card)}` : '' };
|
|
100
101
|
return q && !wide ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -224,5 +225,32 @@ globalThis.AppRouter = (function () {
|
|
|
224
225
|
return { push: (href, from) => write('push', href, from), replace: (href) => write('replace', href), up };
|
|
225
226
|
}
|
|
226
227
|
|
|
227
|
-
|
|
228
|
+
// A board's fragment is &-joined keys: selectedCard=<key> (the focused card), openItemsCard=<topic id> (the selected
|
|
229
|
+
// open-items pill), review=<PR number>, and a bare reply. The older #item=<key> reads as selectedCard.
|
|
230
|
+
// → {selectedCard, openItemsCard, review, reply}, each null or false when absent.
|
|
231
|
+
function fragmentOf(hash) {
|
|
232
|
+
const out = { selectedCard: null, openItemsCard: null, review: null, reply: false };
|
|
233
|
+
const decode = (v) => { try { return decodeURIComponent(v); } catch { return v; } };
|
|
234
|
+
for (const part of String(hash).replace(/^#/, '').split('&')) {
|
|
235
|
+
const eq = part.indexOf('=');
|
|
236
|
+
const [k, v] = eq < 0 ? [part, ''] : [part.slice(0, eq), part.slice(eq + 1)];
|
|
237
|
+
if (k === 'selectedCard' || k === 'item') out.selectedCard = decode(v) || null;
|
|
238
|
+
else if (k === 'openItemsCard') out.openItemsCard = decode(v) || null;
|
|
239
|
+
else if (k === 'review' && /^\d+$/.test(v)) out.review = Number(v);
|
|
240
|
+
else if (k === 'reply') out.reply = true;
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
// hash with the named keys of changes replaced (null drops one) → the new fragment, '' for none. A #review= link is
|
|
245
|
+
// alone in its fragment, so a change to a card or pill replaces it; reply is spent once the card is shown.
|
|
246
|
+
function hashWith(hash, changes) {
|
|
247
|
+
const now = fragmentOf(hash);
|
|
248
|
+
const next = { selectedCard: now.selectedCard, openItemsCard: now.openItemsCard, ...changes };
|
|
249
|
+
const parts = [];
|
|
250
|
+
if (next.selectedCard) parts.push(`selectedCard=${encodeURIComponent(next.selectedCard)}`);
|
|
251
|
+
if (next.openItemsCard) parts.push(`openItemsCard=${encodeURIComponent(next.openItemsCard)}`);
|
|
252
|
+
return parts.length ? '#' + parts.join('&') : '';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { viewOf, urlOf, mounted, entryOf, isPrototype, listen, fragmentOf, hashWith };
|
|
228
256
|
})();
|
package/board/public/shell.js
CHANGED
|
@@ -246,24 +246,30 @@ window.AppShell = (function () {
|
|
|
246
246
|
// Board and Waiting on you are a board's two pages (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md):
|
|
247
247
|
// from 900px the board shows Waiting on you in its pane, so every view of the questions page, a link, a push, a
|
|
248
248
|
// hand-off, a history entry or a cold load, shows the board at its own URL and opens the question's topic there (id,
|
|
249
|
-
// or null for none). A board
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
249
|
+
// or null for none). A board's prototype page does the same and opens in the pane
|
|
250
|
+
// (docs/adr/2026-11-07-2030-on-desktop-the-prototype-viewer-opens-in-the-right-pane.md).
|
|
251
|
+
const inPane = (view) => wide.matches && (view?.kind === 'questions' || AppRouter.isPrototype(view, boards()));
|
|
252
|
+
// ask(B) → true once the board's pane did what the view asks. A board just shown takes a moment to fill its pane, so
|
|
253
|
+
// the pane is asked again until it has.
|
|
254
|
+
const paneAsk = (view, href) => (view.kind === 'questions' ? (B) => B.focusQuestion?.(view.id) : (B) => B.openViewer?.(href));
|
|
255
|
+
function askPane(ask, tries = 50) {
|
|
256
|
+
if (current?.view.kind !== 'board' || ask(current.v.B) || tries <= 0) return;
|
|
257
|
+
setTimeout(() => askPane(ask, tries - 1), 100);
|
|
253
258
|
}
|
|
254
259
|
function go(view, href, how, from) {
|
|
255
260
|
if (!AppRouter.mounted(view)) return false;
|
|
256
|
-
if (view
|
|
261
|
+
if (inPane(view)) {
|
|
262
|
+
const ask = paneAsk(view, href);
|
|
257
263
|
const board = { kind: 'board', route: view.route, hash: '' };
|
|
258
264
|
const boardHref = new URL(AppRouter.urlOf(board), location.href).href;
|
|
259
265
|
if (how === 'pop') history.replaceState(history.state, '', boardHref);
|
|
260
|
-
// On its own board it only
|
|
266
|
+
// On its own board it only asks the pane, so the URL, history and an open card stay as they are.
|
|
261
267
|
else if (current?.view.kind === 'board' && current.route === view.route && !settings) {
|
|
262
|
-
|
|
268
|
+
askPane(ask);
|
|
263
269
|
sidebar.close();
|
|
264
270
|
return true;
|
|
265
271
|
}
|
|
266
|
-
return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown)
|
|
272
|
+
return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown) askPane(ask); return shown; });
|
|
267
273
|
}
|
|
268
274
|
if (view.kind === 'settings') {
|
|
269
275
|
if (current?.view.kind === 'board' && current.route === view.route) {
|
|
@@ -317,15 +323,15 @@ window.AppShell = (function () {
|
|
|
317
323
|
if (!AppRouter.mounted(first)) { location.replace('/_hub/'); return; }
|
|
318
324
|
|
|
319
325
|
let pane = null;
|
|
320
|
-
if (first
|
|
321
|
-
pane =
|
|
326
|
+
if (inPane(first)) {
|
|
327
|
+
pane = paneAsk(first, location.href);
|
|
322
328
|
first = { kind: 'board', route: first.route, hash: '' };
|
|
323
329
|
history.replaceState(history.state, '', AppRouter.urlOf(first));
|
|
324
330
|
}
|
|
325
331
|
router = AppRouter.listen({ boards, go, wide: () => wide.matches });
|
|
326
|
-
// Widening past 900px on Waiting on you swaps to the board with
|
|
332
|
+
// Widening past 900px on Waiting on you or a prototype swaps to the board with it open in the pane.
|
|
327
333
|
wide.addEventListener('change', () => {
|
|
328
|
-
if (
|
|
334
|
+
if (current && inPane(current.view)) go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'replace');
|
|
329
335
|
});
|
|
330
336
|
opener = (url, { replace = false, frames = true } = {}) => {
|
|
331
337
|
const view = AppRouter.viewOf(url, { boards: boards() });
|
|
@@ -342,7 +348,7 @@ window.AppShell = (function () {
|
|
|
342
348
|
navigator.serviceWorker?.startMessages?.();
|
|
343
349
|
|
|
344
350
|
show(first);
|
|
345
|
-
if (pane)
|
|
351
|
+
if (pane) askPane(pane);
|
|
346
352
|
openStream();
|
|
347
353
|
addEventListener('visibilitychange', () => { if (!document.hidden) openStream(); });
|
|
348
354
|
addEventListener('pageshow', openStream);
|
package/board/public/sidebar.css
CHANGED
|
@@ -150,11 +150,11 @@ html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-
|
|
|
150
150
|
@starting-style { .sidebar.ks-on .ks-label-hint { opacity: 0; } }
|
|
151
151
|
/* The label is the bar's containing block: left 0 is the BOARDS text's edge, and right -8px crosses the label's 8px
|
|
152
152
|
side margin (.sb-label) to the rows' right edge. The bar sits in the label's 4px bottom margin. */
|
|
153
|
-
.sidebar .ks-bar { position: absolute; left: 0; right: -8px; top: 100%; height: 2px; margin-top: 1px; background: var(--
|
|
153
|
+
.sidebar .ks-bar { position: absolute; left: 0; right: -8px; top: 100%; height: 2px; margin-top: 1px; background: var(--color-notice);
|
|
154
154
|
transform-origin: 0 50%; animation: ks-drain linear forwards; }
|
|
155
155
|
@keyframes ks-drain { to { transform: scaleX(0); } }
|
|
156
156
|
.sidebar .ks-u { transition: color 200ms linear; }
|
|
157
|
-
.sidebar.ks-on .ks-u { color: var(--
|
|
157
|
+
.sidebar.ks-on .ks-u { color: var(--color-notice); transition: color 150ms linear; }
|
|
158
158
|
@starting-style { .sidebar.ks-on .ks-u { color: inherit; } }
|
|
159
159
|
@media (prefers-reduced-motion: reduce) {
|
|
160
160
|
.sidebar .ks-bar { animation: none; }
|
|
@@ -173,12 +173,12 @@ html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-
|
|
|
173
173
|
.sidebar .sb-ctx.stale { opacity: .6; }
|
|
174
174
|
.sidebar .lcs { order: -1; flex: 1 1 100%; min-width: 0; display: flex; flex-wrap: wrap; gap: 4px; }
|
|
175
175
|
.sidebar .lc { flex: none; font-size: 11px; font-weight: 600; border-radius: 6px; padding: 0 6px; line-height: 18px; color: var(--tone); background: color-mix(in srgb, var(--tone) 14%, transparent); }
|
|
176
|
-
.sidebar .lc-review { --tone: var(--accent); } .sidebar .lc-progress { --tone: var(--color-notice); } .sidebar .lc-todo { --tone: var(--muted); }
|
|
176
|
+
.sidebar .lc-needs { --tone: var(--color-danger); } .sidebar .lc-review { --tone: var(--accent); } .sidebar .lc-progress { --tone: var(--color-notice); } .sidebar .lc-todo { --tone: var(--muted); }
|
|
177
177
|
.sidebar .dot { flex: none; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--tone); }
|
|
178
178
|
@keyframes sb-pulse { 50% { opacity: .35; } }
|
|
179
179
|
@media (prefers-reduced-motion: no-preference) { .sidebar .dot.pulse { animation: sb-pulse 1.4s ease-in-out infinite; } }
|
|
180
180
|
.sidebar .word { flex: none; font-weight: 600; color: var(--tone); }
|
|
181
|
-
.sidebar .t-down
|
|
181
|
+
.sidebar .t-down { --tone: var(--color-danger); }
|
|
182
182
|
.sidebar .t-starting { --tone: var(--color-notice); }
|
|
183
183
|
.sidebar .t-stopped, .sidebar .t-asleep { --tone: var(--muted); }
|
|
184
184
|
.sidebar .t-working { --tone: var(--color-notice); }
|
package/board/public/sidebar.js
CHANGED
|
@@ -236,7 +236,7 @@
|
|
|
236
236
|
}
|
|
237
237
|
el.addEventListener('click', (e) => {
|
|
238
238
|
if (sending) return;
|
|
239
|
-
if (e
|
|
239
|
+
if (BoardUI.onBackdrop(e, el) || e.target.closest('[data-failed-close]')) { done(); return; }
|
|
240
240
|
const dismiss = e.target.closest('[data-failed-dismiss]');
|
|
241
241
|
const retry = e.target.closest('[data-failed-retry]');
|
|
242
242
|
if (dismiss) {
|
|
@@ -309,7 +309,7 @@
|
|
|
309
309
|
el.innerHTML = shortcuts();
|
|
310
310
|
el.addEventListener('cancel', (e) => { e.preventDefault(); closeHelp(); });
|
|
311
311
|
el.addEventListener('close', closeHelp);
|
|
312
|
-
el.addEventListener('click', (e) => { if (e
|
|
312
|
+
el.addEventListener('click', (e) => { if (BoardUI.onBackdrop(e, el)) closeHelp(); });
|
|
313
313
|
help = { el, previous: document.activeElement };
|
|
314
314
|
document.body.appendChild(el);
|
|
315
315
|
el.showModal();
|