@lovek4d/kickflip 0.5.0 → 0.6.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 +105 -68
- package/README.md +1 -1
- package/SKILL.md +27 -21
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +199 -79
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -4
- package/board/on-host.mjs +1 -1
- package/board/onboarding.mjs +11 -0
- package/board/prototype-base.mjs +1 -0
- package/board/prototype-page.mjs +1 -0
- package/board/public/app-arch.js +1 -1
- package/board/public/app-asks.js +6 -6
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +75 -37
- package/board/public/app-core.js +26 -8
- package/board/public/app-header.js +8 -8
- package/board/public/app-lanes.js +67 -58
- package/board/public/app-needs.js +140 -10
- package/board/public/app-reader.js +1 -1
- package/board/public/app-snapshot.js +46 -72
- package/board/public/app-threads.js +78 -75
- package/board/public/app.html +9 -4
- package/board/public/app.js +114 -38
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +66 -4
- package/board/public/hub.css +2 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +20 -39
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +137 -7
- package/board/public/prototype-switcher.js +152 -123
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +68 -26
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -30
- package/board/public/style.css +205 -83
- package/board/public/ui-cards.js +91 -65
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +27 -18
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-page-header.js +19 -7
- package/board/public/ui-patch.js +7 -33
- package/board/public/ui-progress-bar.js +28 -0
- package/board/public/ui-questions.js +47 -52
- package/board/public/ui-sidebar.js +34 -26
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +59 -30
- package/board/public/ui.js +125 -18
- package/board/public/view-hub-settings.js +22 -67
- package/board/public/view-hub.js +19 -4
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +205 -240
- package/board/public/view-settings.js +313 -76
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +19 -20
- package/board/stop.mjs +1 -0
- package/board/store.mjs +29 -15
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +25 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- package/hooks-plugin/hooks/usage-gate.mjs +20 -3
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +5 -5
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/owner-checks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/presence.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +1 -2
- package/hub/review-send.mjs +1 -0
- package/hub/secrets.mjs +2 -0
- package/hub/self-restart.mjs +2 -0
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -0
- package/hub/slack.mjs +2 -2
- package/hub/stream.mjs +10 -13
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +2 -1
- package/shared/audit.mjs +4 -0
- package/shared/card-prs.mjs +1 -1
- package/shared/fake-gh.mjs +17 -4
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/service.mjs +4 -4
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The full-bleed prototype viewer's floating controls (docs/adr/2026-10-29-1639-prototypes-come-on-request.md,
|
|
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).
|
|
2
4
|
// Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
|
|
3
|
-
// const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, initial: 'A', render(take) {…}, above: el? })
|
|
4
|
-
// render runs at mount and on each switch; sw.take is the current key.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// to the page's height.
|
|
5
|
+
// const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, states: { empty: 'No cards', … }?, initial: 'A', render(take, state) {…}, above: el? })
|
|
6
|
+
// 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
|
+
// (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
|
+
// 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 bottom bar holds the ‹ take › pager. ←/→ and a sideways swipe switch
|
|
10
|
+
// unless a field has focus, and the take stays in ?variant=. With ?q=<owner question id>, the bar's second row offers
|
|
11
|
+
// 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
|
+
// the note typed above the bar, in the {choice, note} questions.html keeps for that question, and goes back to the
|
|
13
|
+
// question, whose Send sends it. A card with more than one prototype round gets a Round pill at the top left, which
|
|
14
|
+
// opens a sheet of its rounds (/api/board's card prototypes.rounds, board/domain.mjs).
|
|
14
15
|
(function () {
|
|
15
16
|
const esc = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
16
17
|
|
|
@@ -20,110 +21,122 @@
|
|
|
20
21
|
};
|
|
21
22
|
const startTake = (keys, wanted, initial) => (keys.includes(wanted) ? wanted : keys.includes(initial) ? initial : keys[0]);
|
|
22
23
|
|
|
23
|
-
// The owner's
|
|
24
|
+
// The owner's last sent pick is the latest answer that chose an option; a note-only answer keeps the earlier pick.
|
|
24
25
|
const pickedChoice = (question) => [...(question?.answers || [])].reverse().find((a) => a.choice)?.choice ?? null;
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
26
|
+
// The pick a question shows on Waiting on you (ui-questions.js choiceOf): drafted, this device's unsent pick, which Send
|
|
27
|
+
// would send, else the last answer's, else the option the overnight triage suggested. A resolved question is locked at
|
|
28
|
+
// its last answer, and one waiting on the prototype the owner asked for at the request's pick.
|
|
29
|
+
const locked = (question) => question.state === 'resolved' || question.state === 'requested';
|
|
30
|
+
const shownPick = (question, drafted) => question.state === 'requested' ? question.request?.choice ?? null
|
|
31
|
+
: question.state === 'resolved' ? pickedChoice(question)
|
|
32
|
+
: drafted ?? pickedChoice(question) ?? question.suggested?.option ?? null;
|
|
33
|
+
// none: no question, the take is not one of its options, or the question waits on a requested prototype and the take
|
|
34
|
+
// isn't its pick. closed: the question is resolved and the take isn't its pick. pick: nothing picked yet. picked: this
|
|
35
|
+
// take is the pick. change: another take is.
|
|
36
|
+
function pickState(question, take, drafted = null) {
|
|
28
37
|
if (!question) return { kind: 'none' };
|
|
29
38
|
const option = (question.options || []).find((o) => o.id === take);
|
|
30
|
-
const picked =
|
|
39
|
+
const picked = shownPick(question, drafted);
|
|
31
40
|
if (!option) return { kind: 'none', picked };
|
|
32
|
-
if (
|
|
33
|
-
return { kind:
|
|
41
|
+
if (picked === take) return { kind: 'picked', option, picked };
|
|
42
|
+
if (locked(question)) return { kind: question.state === 'resolved' ? 'closed' : 'none', picked };
|
|
43
|
+
return picked ? { kind: 'change', option, picked } : { kind: 'pick', option };
|
|
34
44
|
}
|
|
35
|
-
//
|
|
36
|
-
const answerLabel = (option) => `${/^[A-Z][0-9]?$/i.test(option.id) ? `${option.id}. ` : ''}${option.label}`;
|
|
37
|
-
const questionUrl = (q) => (/^prototype-[\w-]+\.html(?:[?#]|$)/.test(q.url || '') ? q.url : `questions.html#${encodeURIComponent(q.id)}`);
|
|
38
|
-
// questions.html keeps each question's unsent {choice, note} under this key; a pick sent here takes its note and clears it.
|
|
45
|
+
// questions.html keeps each question's unsent {choice, note} under this key (ui-questions.js getDraft).
|
|
39
46
|
const draftKey = (qid) => 'qdraft:' + qid;
|
|
40
47
|
const readDraft = (qid) => { try { return JSON.parse(localStorage.getItem(draftKey(qid)) || '{}'); } catch { return {}; } };
|
|
41
|
-
|
|
42
|
-
//
|
|
48
|
+
const writeDraft = (qid, draft) => { try { localStorage.setItem(draftKey(qid), JSON.stringify(draft)); } catch { /* nothing kept */ } };
|
|
49
|
+
// In place of this page: framed in the app shell, the shell shows the URL's view in place, going up to the question
|
|
50
|
+
// the viewer sits under (board/public/shell.js AppShell.open, router.js); alone, the page loads it.
|
|
43
51
|
function replaceWith(url) {
|
|
44
52
|
let app = null;
|
|
45
53
|
try { app = window.top !== window ? window.top.AppShell : null; } catch { app = null; }
|
|
46
54
|
if (!app?.open?.(String(url), { replace: true })) location.replace(url);
|
|
47
55
|
}
|
|
48
56
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
for (const [file, key] of [['ui.js', 'esc'], ['ui-links.js', 'linkText'], ['ui-questions.js', 'nextQuestion']]) {
|
|
54
|
-
if (globalThis.BoardUI?.[key]) continue;
|
|
55
|
-
await new Promise((resolve, reject) => {
|
|
56
|
-
const script = document.createElement('script');
|
|
57
|
-
script.src = site.url(file);
|
|
58
|
-
script.onload = resolve;
|
|
59
|
-
script.onerror = reject;
|
|
60
|
-
document.head.append(script);
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
return globalThis.BoardUI;
|
|
64
|
-
})().catch((e) => { questionsUI = null; throw e; }));
|
|
65
|
-
|
|
66
|
-
function pillHtml({ name, pick = { kind: 'none' }, busy = false }) {
|
|
57
|
+
// The bar: the states' chips when the round has states, the ‹ name › pager, and below it on its own row the pick
|
|
58
|
+
// control, when there is one.
|
|
59
|
+
function pillHtml({ name, pick = { kind: 'none' }, states = null, state = null }) {
|
|
60
|
+
const chips = states ? `<div class="ps-row ps-states">${Object.entries(states).map(([key, label]) => `<button type="button" data-ps-state="${esc(key)}" aria-pressed="${key === state}">${esc(label)}</button>`).join('')}</div>` : '';
|
|
67
61
|
const control = pick.kind === 'picked' ? '<span class="ps-picked">Picked</span>'
|
|
68
|
-
: pick.kind === '
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return
|
|
62
|
+
: pick.kind === 'closed' ? '<span class="ps-closed">Round answered</span>'
|
|
63
|
+
: pick.kind === 'pick' || pick.kind === 'change' ? '<button type="button" class="ps-pick" data-ps="pick">Choose this</button>'
|
|
64
|
+
: '';
|
|
65
|
+
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
|
+
}
|
|
67
|
+
|
|
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>`;
|
|
72
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
|
+
// 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
|
+
// 50px sideways, more down than across, or starting at the left edge, where the drawer's and the browser's swipes are.
|
|
84
|
+
const swipeStep = (start, end) => {
|
|
85
|
+
const dx = end.x - start.x;
|
|
86
|
+
return start.x < 24 || Math.abs(dx) < 50 || Math.abs(end.y - start.y) * 2 > Math.abs(dx) ? 0 : dx < 0 ? 1 : -1;
|
|
87
|
+
};
|
|
73
88
|
|
|
74
89
|
const CSS = `
|
|
75
|
-
.
|
|
90
|
+
body > header.top { display:none; }
|
|
91
|
+
main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
92
|
+
.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
|
+
align-items:flex-start; pointer-events:none; font:600 13px -apple-system, system-ui, sans-serif; }
|
|
94
|
+
.ps-top > * { pointer-events:auto; }
|
|
95
|
+
.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
|
+
.ps-chip { border-radius:999px; padding:0 14px; min-height:40px; }
|
|
97
|
+
.ps-close { margin-left:auto; width:40px; height:40px; border-radius:50%; font-size:17px; line-height:1; }
|
|
98
|
+
.ps-sheet { position:fixed; left:0; right:0; bottom:0; z-index:32; padding:14px 16px calc(22px + env(safe-area-inset-bottom)); border-radius:18px 18px 0 0;
|
|
99
|
+
background:#26262c; color:#fff; box-shadow:0 -8px 30px #0009; }
|
|
100
|
+
.ps-sheet h3 { margin:0 0 8px; font-size:14px; }
|
|
101
|
+
.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
|
+
padding:12px 2px; font:inherit; font-weight:500; text-align:left; cursor:pointer; }
|
|
103
|
+
.ps-sheet button span + span { opacity:.7; }
|
|
104
|
+
.ps-switch { position:fixed; left:10px; right:10px; bottom:calc(var(--app-bottom, 0px) + 12px + env(safe-area-inset-bottom)); z-index:30;
|
|
76
105
|
display:flex; flex-direction:column; align-items:center; gap:6px; pointer-events:none; }
|
|
77
106
|
.ps-switch > * { pointer-events:auto; max-width:100%; min-width:0; box-sizing:border-box; }
|
|
78
|
-
.ps-
|
|
79
|
-
-webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); font:600 13px -apple-system, system-ui, sans-serif; }
|
|
80
|
-
.ps-
|
|
107
|
+
.ps-pill { display:flex; flex-direction:column; gap:6px; width:min(460px, 100%); background:#202026eb; border:1px solid #fff3; border-radius:18px; padding: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; }
|
|
109
|
+
.ps-row { display:flex; align-items:center; gap:2px; min-width:0; }
|
|
110
|
+
.ps-pill button { color:#fff; background:transparent; border:0; border-radius:999px; padding:7px 11px; font:inherit; white-space:nowrap; cursor:pointer; }
|
|
81
111
|
.ps-pill button { flex-shrink:0; min-width:44px; min-height:44px; }
|
|
82
|
-
.ps-
|
|
83
|
-
.ps-
|
|
84
|
-
.ps-
|
|
85
|
-
.ps-pill .ps-
|
|
112
|
+
.ps-name { flex:1 1 auto; padding:7px 6px; color:#fff; white-space:nowrap; min-width:0; text-align:center; overflow:hidden; text-overflow:ellipsis; }
|
|
113
|
+
.ps-act > * { flex:1; text-align:center; }
|
|
114
|
+
.ps-states { overflow-x:auto; scrollbar-width:none; gap:4px; }
|
|
115
|
+
.ps-pill .ps-states button { background:#fff1; font-weight:500; }
|
|
116
|
+
.ps-pill .ps-states button[aria-pressed="true"] { background:#fff; color:#000; font-weight:700; }
|
|
117
|
+
.ps-pill .ps-pick { background:var(--accent, #fff); color:var(--on-accent, #000); font-weight:700; }
|
|
118
|
+
.ps-closed { padding:12px 11px; border-radius:999px; color:#fffa; background:#fff1; white-space:nowrap; }
|
|
86
119
|
.ps-switch .ps-text { width:min(420px, 100%); resize:none; border-radius:12px; border:1px solid #fff3; background:#000d; color:#fff;
|
|
87
|
-
padding:7px 10px; font:400
|
|
120
|
+
padding:7px 10px; font:400 16px -apple-system, system-ui, sans-serif; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); }
|
|
88
121
|
.ps-switch .ps-text::placeholder { color:#fffa; }
|
|
89
122
|
.ps-switch .ps-text[hidden] { display:none; }
|
|
90
|
-
.ps-picked {
|
|
123
|
+
.ps-picked { padding:12px 11px; border-radius:999px; color:#fff; background:#1f9d55; white-space:nowrap; }
|
|
91
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; }
|
|
92
|
-
.ps-note[hidden] { display:none; }
|
|
93
|
-
.ps-embed header.top { display:none; }
|
|
94
|
-
.ps-embed main { padding-bottom:12px !important; }`;
|
|
125
|
+
.ps-note[hidden] { display:none; }`;
|
|
95
126
|
|
|
96
127
|
const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
|
|
97
128
|
|
|
98
|
-
function mount({ takes, initial, render, above = null }) {
|
|
129
|
+
function mount({ takes, states = null, initial, render, above = null }) {
|
|
99
130
|
const keys = Object.keys(takes);
|
|
100
131
|
const params = new URLSearchParams(location.search);
|
|
132
|
+
let shownState = states ? startTake(Object.keys(states), params.get('state')) : null;
|
|
101
133
|
const qid = params.get('q');
|
|
102
134
|
const site = globalThis.ClaudeSite;
|
|
103
135
|
if (!document.getElementById('ps-style')) document.head.insertAdjacentHTML('beforeend', `<style id="ps-style">${CSS}</style>`);
|
|
104
|
-
if (params.has('take')) {
|
|
105
|
-
let only = startTake(keys, params.get('take'), initial);
|
|
106
|
-
document.documentElement.classList.add('ps-embed');
|
|
107
|
-
render(only);
|
|
108
|
-
const embedded = {
|
|
109
|
-
takes: keys,
|
|
110
|
-
get take() { return only; },
|
|
111
|
-
go(next) {
|
|
112
|
-
if (!keys.includes(next) || next === only) return;
|
|
113
|
-
only = next;
|
|
114
|
-
const u = new URL(location.href); u.searchParams.set('take', only); history.replaceState(history.state, '', u);
|
|
115
|
-
render(only);
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
return (globalThis.PrototypeSwitcher.current = embedded);
|
|
119
|
-
}
|
|
120
136
|
let take = startTake(keys, params.get('variant'), initial);
|
|
121
137
|
let question = null;
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
let cards = [];
|
|
125
|
-
// A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a pick made here or a
|
|
126
|
-
// reload stays on the page; only the first load sends an already-picked question on to its card's thread.
|
|
138
|
+
// A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a reload stays on the
|
|
139
|
+
// page; only the first load sends an already-picked question on to its card's thread.
|
|
127
140
|
let fromPush = params.get('from') === 'push';
|
|
128
141
|
if (fromPush) { const u = new URL(location.href); u.searchParams.delete('from'); history.replaceState(null, '', u); }
|
|
129
142
|
|
|
@@ -135,36 +148,52 @@
|
|
|
135
148
|
const pill = box.querySelector('.ps-pill');
|
|
136
149
|
const noteEl = box.querySelector('.ps-note');
|
|
137
150
|
const text = box.querySelector('.ps-text');
|
|
138
|
-
|
|
151
|
+
// The ✕ and, on a card with earlier rounds, the Round pill and its sheet. boardCard: /api/board's card the question is on.
|
|
152
|
+
const top = document.createElement('div');
|
|
153
|
+
top.className = 'ps-top';
|
|
154
|
+
document.body.append(top);
|
|
155
|
+
let boardCard = null;
|
|
156
|
+
let sheet = false;
|
|
157
|
+
const paintTop = () => {
|
|
158
|
+
const rounds = boardCard?.prototypes?.rounds;
|
|
159
|
+
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 }) : ''}`;
|
|
160
|
+
};
|
|
161
|
+
paintTop();
|
|
139
162
|
|
|
140
163
|
const note = (text) => { noteEl.textContent = text || ''; noteEl.hidden = !text; };
|
|
164
|
+
const state = () => pickState(question, take, qid ? readDraft(qid).choice : null);
|
|
141
165
|
function paint() {
|
|
142
|
-
const
|
|
143
|
-
pill.innerHTML = pillHtml({ name: takes[take], pick:
|
|
144
|
-
text.hidden = !(
|
|
166
|
+
const now = state();
|
|
167
|
+
pill.innerHTML = pillHtml({ name: takes[take], pick: now, states, state: shownState });
|
|
168
|
+
text.hidden = !(now.kind === 'pick' || now.kind === 'change');
|
|
145
169
|
}
|
|
146
170
|
function go(next) {
|
|
147
171
|
take = next;
|
|
148
172
|
const u = new URL(location.href); u.searchParams.set('variant', take); history.replaceState(null, '', u);
|
|
149
173
|
note('');
|
|
150
174
|
paint();
|
|
151
|
-
render(take);
|
|
175
|
+
render(take, shownState);
|
|
176
|
+
}
|
|
177
|
+
function show(next) {
|
|
178
|
+
shownState = next;
|
|
179
|
+
const u = new URL(location.href); u.searchParams.set('state', next); history.replaceState(null, '', u);
|
|
180
|
+
paint();
|
|
181
|
+
render(take, shownState);
|
|
152
182
|
}
|
|
153
183
|
|
|
154
184
|
async function load() {
|
|
155
185
|
if (!qid || !site) return;
|
|
156
|
-
let board = null;
|
|
157
186
|
try {
|
|
158
|
-
board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
|
|
159
|
-
topics = board.ownerTopics;
|
|
160
|
-
cards = board.items || [];
|
|
187
|
+
const board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
|
|
161
188
|
// A resolved question is left out of /api/board, and its topic's resolved questions hold it
|
|
162
189
|
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
163
|
-
const topic =
|
|
190
|
+
const topic = board.ownerTopics.find((t) => t.questions.includes(qid));
|
|
164
191
|
question = board.owner.find((q) => q.id === qid)
|
|
165
192
|
|| (topic && (await (await fetch(site.url('api/questions?topic=' + encodeURIComponent(topic.id)), { cache: 'no-store' })).json()).questions.find((q) => q.id === qid))
|
|
166
193
|
|| null;
|
|
167
194
|
if (!question) note('This prototype’s question is gone, so there is nothing to pick.');
|
|
195
|
+
boardCard = topic && board.items.find((i) => i.questions?.topic === topic.id) || null;
|
|
196
|
+
paintTop();
|
|
168
197
|
} catch { /* keep the last question shown */ }
|
|
169
198
|
if (fromPush && pickedChoice(question)) {
|
|
170
199
|
const card = /^item:(.+)$/.exec(question.topic || '')?.[1];
|
|
@@ -174,37 +203,13 @@
|
|
|
174
203
|
if (question && !text.value) text.value = readDraft(qid).note || '';
|
|
175
204
|
fromPush = false;
|
|
176
205
|
paint();
|
|
177
|
-
return board;
|
|
178
206
|
}
|
|
179
207
|
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
let ui, pills, name;
|
|
186
|
-
try {
|
|
187
|
-
ui = await sharedQuestions(site);
|
|
188
|
-
pills = ui.topicRow(topics, answered.topic).pills;
|
|
189
|
-
const topic = topics.find((t) => t.id === answered.topic);
|
|
190
|
-
name = topic ? ui.topicName(ui.cardTopics([topic], cards)[0]) : '';
|
|
191
|
-
await site.send('owner-answer', { id: answered.id, question: answered.question, choice: state.option.id, label: answerLabel(state.option), note: text.value.trim() });
|
|
192
|
-
} catch { busy = false; paint(); note('Could not send. Is the host awake?'); return; }
|
|
193
|
-
try { localStorage.removeItem(draftKey(qid)); } catch { /* nothing kept */ }
|
|
194
|
-
text.value = '';
|
|
195
|
-
busy = false;
|
|
196
|
-
note('Sent. Claude replies on the card.');
|
|
197
|
-
const board = await load();
|
|
198
|
-
const next = ui.nextQuestion(board?.owner || [], pills, answered);
|
|
199
|
-
if (next) {
|
|
200
|
-
const u = new URL(site.url(questionUrl(next)), location.href);
|
|
201
|
-
if (globalThis.PageBack?.pushed) u.searchParams.set('from', 'push');
|
|
202
|
-
replaceWith(u);
|
|
203
|
-
} else {
|
|
204
|
-
const route = new URL(site.url(''), location.href).pathname.replace(/^\/+|\/+$/g, '');
|
|
205
|
-
ui.sentNotice.put(route, `Sent 1 answer${name ? ` to ${name}` : ''}.`);
|
|
206
|
-
replaceWith(site.url('./'));
|
|
207
|
-
}
|
|
208
|
+
function pick() {
|
|
209
|
+
const now = state();
|
|
210
|
+
if (!(now.kind === 'pick' || now.kind === 'change')) return;
|
|
211
|
+
writeDraft(qid, { ...readDraft(qid), choice: now.option.id, note: text.value.trim() });
|
|
212
|
+
replaceWith(site.url(`questions.html#${encodeURIComponent(qid)}`));
|
|
208
213
|
}
|
|
209
214
|
text.addEventListener('keydown', (e) => {
|
|
210
215
|
if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey) || e.isComposing) return;
|
|
@@ -213,22 +218,46 @@
|
|
|
213
218
|
});
|
|
214
219
|
|
|
215
220
|
pill.addEventListener('click', (e) => {
|
|
221
|
+
const chip = e.target.closest('[data-ps-state]')?.dataset.psState;
|
|
222
|
+
if (chip) { show(chip); return; }
|
|
216
223
|
const act = e.target.closest('[data-ps]')?.dataset.ps;
|
|
217
224
|
if (act === 'prev') go(takeAt(keys, take, -1));
|
|
218
225
|
else if (act === 'next') go(takeAt(keys, take, 1));
|
|
219
226
|
else if (act === 'pick') pick();
|
|
220
227
|
});
|
|
228
|
+
// ✕ follows the hidden header's back arrow, so it goes where back goes (router.js page tree).
|
|
229
|
+
top.addEventListener('click', (e) => {
|
|
230
|
+
const round = e.target.closest('[data-ps-round]')?.dataset.psRound;
|
|
231
|
+
if (round) { replaceWith(site.url(round)); return; }
|
|
232
|
+
const act = e.target.closest('[data-ps-top]')?.dataset.psTop;
|
|
233
|
+
if (act === 'close') document.querySelector('header.top a.back').click();
|
|
234
|
+
else if (act === 'rounds') { sheet = !sheet; paintTop(); }
|
|
235
|
+
});
|
|
221
236
|
document.addEventListener('keydown', (e) => {
|
|
222
237
|
if (typing(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
223
238
|
if (e.key === 'ArrowLeft') go(takeAt(keys, take, -1));
|
|
224
239
|
else if (e.key === 'ArrowRight') go(takeAt(keys, take, 1));
|
|
240
|
+
else if (e.key === 'Escape' && sheet) { sheet = false; paintTop(); }
|
|
225
241
|
});
|
|
242
|
+
// A swipe that starts in a box scrolling sideways is that box's own scroll.
|
|
243
|
+
let touch = null;
|
|
244
|
+
document.addEventListener('touchstart', (e) => {
|
|
245
|
+
const t = e.touches[0];
|
|
246
|
+
const scrolls = (el) => el && el !== document.body && (el.scrollWidth > el.clientWidth && /auto|scroll/.test(getComputedStyle(el).overflowX) ? true : scrolls(el.parentElement));
|
|
247
|
+
touch = e.touches.length === 1 && !typing(e.target) && !e.target.closest?.('.ps-sheet') && !scrolls(e.target) ? { x: t.clientX, y: t.clientY } : null;
|
|
248
|
+
}, { passive: true });
|
|
249
|
+
document.addEventListener('touchend', (e) => {
|
|
250
|
+
const t = e.changedTouches[0];
|
|
251
|
+
const step = touch && t ? swipeStep(touch, { x: t.clientX, y: t.clientY }) : 0;
|
|
252
|
+
touch = null;
|
|
253
|
+
if (step) go(takeAt(keys, take, step));
|
|
254
|
+
}, { passive: true });
|
|
226
255
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
|
|
227
256
|
|
|
228
257
|
go(take);
|
|
229
258
|
load();
|
|
230
|
-
return
|
|
259
|
+
return { takes: keys, get take() { return take; }, get state() { return shownState; }, go, show };
|
|
231
260
|
}
|
|
232
261
|
|
|
233
|
-
globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState,
|
|
262
|
+
globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, roundPill, roundSheet, swipeStep, mount };
|
|
234
263
|
})();
|
|
@@ -10,7 +10,7 @@
|
|
|
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
|
|
13
|
+
take gets). main keeps room below the stage for the viewer's bar and its note. */
|
|
14
14
|
main.proto-main { max-width: none; padding-left: 0; padding-right: 0; padding-bottom: calc(150px + var(--app-bottom, 0px) + env(safe-area-inset-bottom)); }
|
|
15
15
|
/* kit.hub() is a whole page with its own gutter and header, so it goes edge to edge. */
|
|
16
16
|
main.proto-main:has(> #stage > .kit-hub) { padding-top: 0; }
|
|
@@ -20,11 +20,28 @@
|
|
|
20
20
|
#stage:has(> .kit-hub) { padding: 0; }
|
|
21
21
|
#stage .top { position: static; }
|
|
22
22
|
#stage .hub-view main { padding-bottom: 16px; }
|
|
23
|
+
/* A served round's page, framed from the board's base prototype worktree, edge to edge above the viewer's bar. */
|
|
24
|
+
#stage:has(> .kit-served) { max-width: none; padding: 0; }
|
|
25
|
+
.kit-served { display: block; width: 100%; min-height: 320px; height: calc(100dvh - 210px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); border: 0; background: var(--panel); }
|
|
26
|
+
.kit-served-failed pre { margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
|
|
23
27
|
.kit-missing { margin: 0; padding: 16px; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
|
24
28
|
#protoToast { position: fixed; z-index: 40; left: 50%; top: calc(64px + env(safe-area-inset-top)); transform: translateX(-50%); max-width: calc(100vw - 32px); }
|
|
25
29
|
#protoToast[hidden] { display: none; }
|
|
26
30
|
@media (max-width: 899px) { #protoToast { top: max(12px, env(safe-area-inset-top)); } }
|
|
27
31
|
|
|
32
|
+
/* A take's own boxes are capped to the stage at phone width, so a fixed-width panel never cuts off. prototype-kit.js
|
|
33
|
+
(phoneFit) marks what the take lays side by side with data-stack, which stacks in one column whether the take used
|
|
34
|
+
grid, flex (wrapping or not) or inline-block, and a frame 600px or wider with data-wide: it keeps its own layout
|
|
35
|
+
and scrolls sideways inside its data-scroll box. */
|
|
36
|
+
@media (max-width: 599px) {
|
|
37
|
+
#stage :where(div, section, article, aside, nav, header, footer, main, form, ul, ol, table, figure):not([data-wide]) { max-width: 100%; min-width: 0; }
|
|
38
|
+
#stage [data-scroll] { overflow-x: auto; max-width: 100%; }
|
|
39
|
+
#stage [data-wide] { max-width: none; flex-shrink: 0; }
|
|
40
|
+
#stage [data-stack] { display: flex !important; flex-direction: column !important; flex-wrap: nowrap !important; grid-template-columns: none !important; align-items: stretch !important; }
|
|
41
|
+
#stage [data-stack] > * { width: auto !important; flex: 0 0 auto !important; grid-column: auto !important; float: none !important; }
|
|
42
|
+
}
|
|
43
|
+
/* iOS Safari zooms the page when it focuses a field under 16px. */
|
|
44
|
+
#stage :where(input, select, textarea) { font-size: 16px; }
|
|
28
45
|
#stage .kit-sidebar.sidebar { position: relative; inset: auto; z-index: auto; transform: none; visibility: visible; transition: none;
|
|
29
46
|
width: min(320px, 100%); height: 640px; margin: 0 auto; padding: 0 8px; border: 1px solid var(--line); border-radius: 14px; }
|
|
30
47
|
#stage .kit-sidebar.sidebar:not(.kit-head) > header.top { display: none; }
|
|
@@ -40,15 +57,52 @@
|
|
|
40
57
|
<main class="proto-main"><div id="stage"></div></main>
|
|
41
58
|
<div class="toast" id="protoToast" role="status" hidden></div>
|
|
42
59
|
<script id="prototypeData" type="application/json">{{prototypeData}}</script>
|
|
43
|
-
<script src="claude.js"></script>
|
|
44
60
|
<script src="ui.js"></script>
|
|
45
61
|
<script src="ui-links.js"></script>
|
|
46
62
|
<script src="ui-enums.js"></script>
|
|
47
|
-
<script src="ui-
|
|
63
|
+
<script src="ui-progress-bar.js"></script>
|
|
64
|
+
<script src="ui-order.js"></script>
|
|
65
|
+
<script src="ui-patch.js"></script>
|
|
66
|
+
<script src="ui-threads.js"></script>
|
|
48
67
|
<script src="ui-cards.js"></script>
|
|
68
|
+
<script src="ui-header.js"></script>
|
|
69
|
+
<script src="ui-snapshot.js"></script>
|
|
49
70
|
<script src="ui-page-header.js"></script>
|
|
50
71
|
<script src="ui-sidebar.js"></script>
|
|
72
|
+
<script src="ui-questions.js"></script>
|
|
73
|
+
<script src="ui-prompts.js"></script>
|
|
74
|
+
<script src="ui-markdown.js"></script>
|
|
51
75
|
<script src="ui-hub.js"></script>
|
|
76
|
+
<script src="toasts.js"></script>
|
|
77
|
+
<script src="dialogs.js"></script>
|
|
78
|
+
<script src="prompts.js"></script>
|
|
79
|
+
<script src="rules-editor.js"></script>
|
|
80
|
+
<script src="claude.js"></script>
|
|
81
|
+
<script src="attach.js"></script>
|
|
82
|
+
<script src="hub-kit.js"></script>
|
|
83
|
+
<script src="linear-key.js"></script>
|
|
84
|
+
<script src="sidebar.js"></script>
|
|
85
|
+
<script src="app.js"></script>
|
|
86
|
+
<script src="new-task.js"></script>
|
|
87
|
+
<script src="app-core.js"></script>
|
|
88
|
+
<script src="app-threads.js"></script>
|
|
89
|
+
<script src="app-cards.js"></script>
|
|
90
|
+
<script src="app-lanes.js"></script>
|
|
91
|
+
<script src="app-asks.js"></script>
|
|
92
|
+
<script src="app-needs.js"></script>
|
|
93
|
+
<script src="app-header.js"></script>
|
|
94
|
+
<script src="app-arch.js"></script>
|
|
95
|
+
<script src="app-reader.js"></script>
|
|
96
|
+
<script src="app-banner.js"></script>
|
|
97
|
+
<script src="app-snapshot.js"></script>
|
|
98
|
+
<script src="view-settings.js"></script>
|
|
99
|
+
<script src="view-questions.js"></script>
|
|
100
|
+
<script src="view-notes.js"></script>
|
|
101
|
+
<script src="view-hub.js"></script>
|
|
102
|
+
<script src="view-hub-settings.js"></script>
|
|
103
|
+
<script src="view-frame.js"></script>
|
|
104
|
+
<script src="router.js"></script>
|
|
105
|
+
<script src="shell.js"></script>
|
|
52
106
|
<script src="prototype-switcher.js"></script>
|
|
53
107
|
<script src="prototype-kit.js"></script>
|
|
54
108
|
<script>PrototypeKit.start(JSON.parse(document.getElementById('prototypeData').textContent));</script>
|
package/board/public/router.js
CHANGED
|
@@ -85,18 +85,19 @@ globalThis.AppRouter = (function () {
|
|
|
85
85
|
const u = new URL(view.url, 'http://hub');
|
|
86
86
|
return /^\/[^/]+\/prototype-[\w-]+\.html$/.test(u.pathname) ? u.searchParams.get('q') || null : null;
|
|
87
87
|
}
|
|
88
|
-
|
|
88
|
+
// wide: from 900px Waiting on you is the board's pane, so a prototype's parent is the board itself.
|
|
89
|
+
function levelOf(view, { boards = new Set(), wide = false } = {}) {
|
|
89
90
|
if (view?.kind === 'board') return 0;
|
|
90
91
|
if (view?.kind === 'questions') return 1;
|
|
91
|
-
return questionOf(view, boards) ? 2 : null;
|
|
92
|
+
return questionOf(view, boards) ? (wide ? 1 : 2) : null;
|
|
92
93
|
}
|
|
93
94
|
// → the views above view in the tree, its board first. card: the card whose thread the owner left the board from,
|
|
94
95
|
// which the board keeps open.
|
|
95
|
-
function parentsOf(view, { boards = new Set(), card = null } = {}) {
|
|
96
|
+
function parentsOf(view, { boards = new Set(), card = null, wide = false } = {}) {
|
|
96
97
|
const q = questionOf(view, boards);
|
|
97
98
|
if (!q && view?.kind !== 'questions') return [];
|
|
98
99
|
const board = { kind: 'board', route: view.route, hash: card ? `#item=${encodeURIComponent(card)}` : '' };
|
|
99
|
-
return q ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
|
|
100
|
+
return q && !wide ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
// Each history entry carries {idx}, its distance from the shell's first entry, pushed when a push opened it, and on a
|
|
@@ -109,8 +110,8 @@ globalThis.AppRouter = (function () {
|
|
|
109
110
|
// boards() → the board routes now.
|
|
110
111
|
// → {push(href, from), replace(href), up()}: push and replace write an entry for a view go is showing; replace answers
|
|
111
112
|
// 'up' when it went back to a parent instead, which then takes href, and go shows it on that pop.
|
|
112
|
-
function listen({ boards, go }) {
|
|
113
|
-
const b = () => ({ boards: boards() });
|
|
113
|
+
function listen({ boards, go, wide = () => false }) {
|
|
114
|
+
const b = () => ({ boards: boards(), wide: wide() });
|
|
114
115
|
const mark = (pushed) => (pushed ? { pushed: true } : {});
|
|
115
116
|
let idx = Number.isInteger(history.state?.idx) ? history.state.idx : 0;
|
|
116
117
|
let landing = null;
|