@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.
Files changed (110) hide show
  1. package/CONTEXT.md +105 -68
  2. package/README.md +1 -1
  3. package/SKILL.md +27 -21
  4. package/bin/kickflip.mjs +27 -18
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -0
  7. package/board/arch-review.mjs +4 -4
  8. package/board/bin/board.mjs +199 -79
  9. package/board/build-script.mjs +2 -1
  10. package/board/domain.mjs +9 -7
  11. package/board/finish.mjs +4 -4
  12. package/board/headless.mjs +9 -4
  13. package/board/on-host.mjs +1 -1
  14. package/board/onboarding.mjs +11 -0
  15. package/board/prototype-base.mjs +1 -0
  16. package/board/prototype-page.mjs +1 -0
  17. package/board/public/app-arch.js +1 -1
  18. package/board/public/app-asks.js +6 -6
  19. package/board/public/app-banner.js +7 -13
  20. package/board/public/app-cards.js +75 -37
  21. package/board/public/app-core.js +26 -8
  22. package/board/public/app-header.js +8 -8
  23. package/board/public/app-lanes.js +67 -58
  24. package/board/public/app-needs.js +140 -10
  25. package/board/public/app-reader.js +1 -1
  26. package/board/public/app-snapshot.js +46 -72
  27. package/board/public/app-threads.js +78 -75
  28. package/board/public/app.html +9 -4
  29. package/board/public/app.js +114 -38
  30. package/board/public/attach.js +2 -2
  31. package/board/public/dialogs.js +9 -39
  32. package/board/public/hub-kit.js +66 -4
  33. package/board/public/hub.css +2 -0
  34. package/board/public/linear-key.js +46 -35
  35. package/board/public/new-task.js +20 -39
  36. package/board/public/progress-review.html +46 -0
  37. package/board/public/progress-review.js +158 -0
  38. package/board/public/prototype-kit.js +137 -7
  39. package/board/public/prototype-switcher.js +152 -123
  40. package/board/public/prototype.html +57 -3
  41. package/board/public/router.js +7 -6
  42. package/board/public/shell.js +68 -26
  43. package/board/public/sidebar.css +1 -0
  44. package/board/public/sidebar.js +16 -30
  45. package/board/public/style.css +205 -83
  46. package/board/public/ui-cards.js +91 -65
  47. package/board/public/ui-enums.js +1 -1
  48. package/board/public/ui-header.js +27 -18
  49. package/board/public/ui-hub.js +12 -1
  50. package/board/public/ui-links.js +7 -7
  51. package/board/public/ui-order.js +12 -10
  52. package/board/public/ui-page-header.js +19 -7
  53. package/board/public/ui-patch.js +7 -33
  54. package/board/public/ui-progress-bar.js +28 -0
  55. package/board/public/ui-questions.js +47 -52
  56. package/board/public/ui-sidebar.js +34 -26
  57. package/board/public/ui-snapshot.js +43 -52
  58. package/board/public/ui-threads.js +59 -30
  59. package/board/public/ui.js +125 -18
  60. package/board/public/view-hub-settings.js +22 -67
  61. package/board/public/view-hub.js +19 -4
  62. package/board/public/view-notes.js +27 -7
  63. package/board/public/view-questions.js +205 -240
  64. package/board/public/view-settings.js +313 -76
  65. package/board/render-check.mjs +10 -0
  66. package/board/review.mjs +1 -1
  67. package/board/server.mjs +19 -20
  68. package/board/stop.mjs +1 -0
  69. package/board/store.mjs +29 -15
  70. package/board/sync.mjs +2 -0
  71. package/board/tickets.mjs +3 -3
  72. package/board/transcript.mjs +2 -0
  73. package/board/worktree.mjs +25 -20
  74. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  75. package/hooks-plugin/hooks/hooks.json +9 -0
  76. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/front-door.mjs +5 -5
  80. package/hub/github.mjs +3 -3
  81. package/hub/hub.mjs +11 -11
  82. package/hub/incoming.mjs +2 -2
  83. package/hub/linear.mjs +1 -2
  84. package/hub/live.mjs +2 -2
  85. package/hub/locks.mjs +1 -1
  86. package/hub/owner-checks.mjs +1 -1
  87. package/hub/pr-report.mjs +2 -2
  88. package/hub/presence.mjs +1 -1
  89. package/hub/push.mjs +2 -2
  90. package/hub/readiness.mjs +1 -2
  91. package/hub/review-send.mjs +1 -0
  92. package/hub/secrets.mjs +2 -0
  93. package/hub/self-restart.mjs +2 -0
  94. package/hub/sessions.mjs +2 -2
  95. package/hub/share.mjs +1 -0
  96. package/hub/slack.mjs +2 -2
  97. package/hub/stream.mjs +10 -13
  98. package/hub/try.mjs +7 -7
  99. package/hub/upgrade.mjs +1 -1
  100. package/hub/usage.mjs +3 -3
  101. package/hub/views.mjs +1 -1
  102. package/package.json +2 -1
  103. package/shared/audit.mjs +4 -0
  104. package/shared/card-prs.mjs +1 -1
  105. package/shared/fake-gh.mjs +17 -4
  106. package/shared/machine-gate.mjs +2 -0
  107. package/shared/prototype-frame.js +1 -0
  108. package/shared/service.mjs +4 -4
  109. package/shared/usage-gate.mjs +1 -1
  110. package/board/public/ui-phase-bars.js +0 -17
@@ -1,16 +1,17 @@
1
- // The floating ‹ take › pill every prototype page switches its takes with (docs/adr/2026-10-29-1639-prototypes-come-on-request.md).
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. ←/→ switch unless a field has focus, and the
5
- // take stays in ?variant=. With ?q=<owner question id>, the pill offers the take as that question's answer: a take
6
- // is pickable when an option's id equals its key. Pick sends the owner-answer event questions.html sends, with the
7
- // note typed above the pill, then moves on through BoardUI.nextQuestion (ui-questions.js).
8
- // PrototypeSwitcher.current is the mounted switcher, whose go(key) shows another take in place; Waiting on you uses it
9
- // to switch its full-width frame without a reload (view-questions.js inPlace).
10
- // With ?take=<key> the page shows that take alone, with no header or pill, as questions.html frames it
11
- // (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md); a page hides its own extras under html.ps-embed. There main's
12
- // bottom padding, kept clear for the pill, shrinks to 12px whatever the page sets, since questions.html sizes the frame
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 current pick is the latest answer that chose an option; a note-only answer keeps the earlier pick.
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
- // none: no question, or the take is not one of its options. pick: nothing picked yet. picked: this take is the
26
- // owner's pick. change: another take is.
27
- function pickState(question, take) {
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 = pickedChoice(question);
39
+ const picked = shownPick(question, drafted);
31
40
  if (!option) return { kind: 'none', picked };
32
- if (!picked) return { kind: 'pick', option };
33
- return { kind: picked === take ? 'picked' : 'change', option, picked };
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
- // Matches ui-questions.js choiceLabel for a single-choice question.
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
- // In place of this page: framed in the app shell, the shell shows the URL's view in place (board/public/shell.js
42
- // AppShell.open); alone, or for a URL that isn't a view, the page loads it.
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
- // Prototype pages need only claude.js and this switcher. In the shell, its BoardUI also owns the board's notice.
50
- let questionsUI = null;
51
- const sharedQuestions = (site) => (questionsUI ||= (async () => {
52
- try { if (window.top !== window && window.top.BoardUI?.nextQuestion) return window.top.BoardUI; } catch {}
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 === 'pick' || pick.kind === 'change'
69
- ? `<button type="button" class="ps-pick" data-ps="pick"${busy ? ' disabled' : ''}>${busy ? 'Sending…' : pick.kind === 'pick' ? 'Pick this' : 'Change pick'}</button>`
70
- : '';
71
- return `<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>${control}`;
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
- .ps-switch { position:fixed; left:16px; right:16px; bottom:calc(var(--app-bottom, 0px) + 14px + env(safe-area-inset-bottom)); z-index:30;
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-switch .pill, .ps-pill { display:flex; align-items:center; gap:2px; background:#000d; border:1px solid #fff3; border-radius:999px; padding:4px;
79
- -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); font:600 13px -apple-system, system-ui, sans-serif; }
80
- .ps-switch .pill button, .ps-pill button { color:#fff; background:transparent; border:0; border-radius:999px; padding:7px 11px; font:inherit; white-space:nowrap; cursor:pointer; }
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-switch .pill button.on { background:#fff; color:#000; }
83
- .ps-name { flex:0 1 auto; padding:7px 6px; color:#fff; white-space:nowrap; min-width:0; text-align:center; overflow:hidden; text-overflow:ellipsis; }
84
- .ps-pill .ps-pick { background:#fff; color:#000; margin-left:2px; }
85
- .ps-pill .ps-pick:disabled { opacity:.6; cursor:default; }
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 14px -apple-system, system-ui, sans-serif; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); }
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 { margin-left:2px; padding:7px 11px; border-radius:999px; color:#fff; background:#1f9d55; white-space:nowrap; }
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
- let topics = [];
123
- // The board's cards, which name their own question topics (ui-questions.js cardTopics).
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
- let busy = false;
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 state = pickState(question, take);
143
- pill.innerHTML = pillHtml({ name: takes[take], pick: state, busy });
144
- text.hidden = !(state.kind === 'pick' || state.kind === 'change');
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 = topics.find((t) => t.questions.includes(qid));
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
- async function pick() {
181
- const state = pickState(question, take);
182
- if (busy || !(state.kind === 'pick' || state.kind === 'change')) return;
183
- busy = true; paint();
184
- const answered = question;
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 (globalThis.PrototypeSwitcher.current = { takes: keys, get take() { return take; }, go });
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, answerLabel, pillHtml, mount };
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 take pill and its note. */
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-phase-bars.js"></script>
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>
@@ -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
- function levelOf(view, { boards = new Set() } = {}) {
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;