@lovek4d/kickflip 0.5.3 → 0.6.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.
Files changed (91) hide show
  1. package/CONTEXT.md +82 -46
  2. package/SKILL.md +21 -15
  3. package/bin/doctor.mjs +5 -4
  4. package/bin/install.mjs +3 -3
  5. package/bin/kickflip.mjs +12 -12
  6. package/bin/wait-events.mjs +2 -2
  7. package/board/agents.mjs +2 -2
  8. package/board/arch-review.mjs +4 -4
  9. package/board/bin/board.mjs +179 -92
  10. package/board/build-script.mjs +2 -1
  11. package/board/domain.mjs +9 -7
  12. package/board/finish.mjs +4 -4
  13. package/board/headless.mjs +9 -5
  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-cards.js +40 -47
  19. package/board/public/app-header.js +6 -7
  20. package/board/public/app-lanes.js +19 -45
  21. package/board/public/app-needs.js +36 -31
  22. package/board/public/app-snapshot.js +43 -70
  23. package/board/public/app-threads.js +42 -46
  24. package/board/public/app.html +9 -4
  25. package/board/public/app.js +78 -32
  26. package/board/public/hub-kit.js +62 -2
  27. package/board/public/hub.css +2 -0
  28. package/board/public/new-task.js +8 -6
  29. package/board/public/progress-review.html +46 -0
  30. package/board/public/progress-review.js +158 -0
  31. package/board/public/prototype-kit.js +109 -4
  32. package/board/public/prototype-switcher.js +130 -31
  33. package/board/public/prototype.html +57 -3
  34. package/board/public/router.js +7 -6
  35. package/board/public/shell.js +65 -24
  36. package/board/public/sidebar.css +6 -0
  37. package/board/public/sidebar.js +19 -69
  38. package/board/public/style.css +116 -73
  39. package/board/public/ui-cards.js +78 -57
  40. package/board/public/ui-enums.js +2 -2
  41. package/board/public/ui-header.js +23 -16
  42. package/board/public/ui-hub.js +19 -5
  43. package/board/public/ui-links.js +7 -7
  44. package/board/public/ui-order.js +1 -1
  45. package/board/public/ui-page-header.js +19 -7
  46. package/board/public/ui-patch.js +7 -33
  47. package/board/public/ui-progress-bar.js +28 -0
  48. package/board/public/ui-questions.js +25 -17
  49. package/board/public/ui-sidebar.js +31 -23
  50. package/board/public/ui-snapshot.js +44 -53
  51. package/board/public/ui-threads.js +57 -28
  52. package/board/public/ui.js +94 -18
  53. package/board/public/view-hub-settings.js +12 -58
  54. package/board/public/view-hub.js +5 -2
  55. package/board/public/view-notes.js +27 -7
  56. package/board/public/view-questions.js +90 -68
  57. package/board/public/view-settings.js +133 -15
  58. package/board/render-check.mjs +10 -0
  59. package/board/review.mjs +1 -1
  60. package/board/server.mjs +20 -19
  61. package/board/stop.mjs +1 -0
  62. package/board/store.mjs +29 -18
  63. package/board/sync.mjs +2 -0
  64. package/board/tickets.mjs +3 -3
  65. package/board/transcript.mjs +2 -2
  66. package/board/worktree.mjs +22 -21
  67. package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
  68. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  69. package/hub/board-repo.mjs +1 -1
  70. package/hub/boards.mjs +4 -4
  71. package/hub/front-door.mjs +2 -2
  72. package/hub/github.mjs +3 -3
  73. package/hub/hub.mjs +11 -11
  74. package/hub/incoming.mjs +2 -2
  75. package/hub/owner-checks.mjs +1 -1
  76. package/hub/pr-report.mjs +2 -2
  77. package/hub/review-send.mjs +1 -1
  78. package/hub/sessions.mjs +2 -2
  79. package/hub/share.mjs +1 -1
  80. package/hub/slack.mjs +2 -2
  81. package/hub/try.mjs +6 -6
  82. package/hub/upgrade.mjs +1 -1
  83. package/hub/usage.mjs +3 -3
  84. package/hub/views.mjs +1 -1
  85. package/package.json +1 -1
  86. package/shared/card-prs.mjs +1 -1
  87. package/shared/machine-gate.mjs +2 -0
  88. package/shared/prototype-frame.js +1 -0
  89. package/shared/tailscale.mjs +1 -1
  90. package/shared/usage-gate.mjs +1 -1
  91. package/board/public/ui-phase-bars.js +0 -17
@@ -1,4 +1,4 @@
1
- // What the hub page and settings modals share, each through the view's B
1
+ // What the hub page and settings modals share, Board Settings' duration picker included, each through the view's B
2
2
  // (app.js BoardView.page), so unmounting the view stops it all: loading and refreshing the view's data, posting its
3
3
  // forms for JSON, and this device's notifications row.
4
4
  (function () {
@@ -169,5 +169,65 @@
169
169
  return { state: (line) => (state && on ? { ...state, sub: `On · ${line}` } : state), busy: () => busy, ready };
170
170
  }
171
171
 
172
- globalThis.HubKit = { live, forms, device };
172
+ // datetime-local speaks the device's clock; the picker's hidden field carries its UTC ISO value to the server.
173
+ const localInput = (d) => {
174
+ const pad = (n) => String(n).padStart(2, '0');
175
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
176
+ };
177
+ function customBounds(input, reset = false) {
178
+ const now = Date.now();
179
+ input.min = localInput(new Date(now + 60_000));
180
+ input.max = localInput(new Date(now + 30 * 86_400_000));
181
+ if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
182
+ }
183
+
184
+ // The one duration picker Snooze, Keep awake, Pause hub and Pause board share (hub/duration.mjs): chips that post
185
+ // `name` to `action` at once, then Custom, whose date and time posts as `name` too. While its setting is on, `on`
186
+ // replaces it with when it ends and a button that posts `name`=off.
187
+ function durationPicker({ id, title, icon, action, name, choices, on, button }) {
188
+ const { esc } = HubUI;
189
+ if (on) return `<div class="row dur-on">${icon}<span class="body"><span class="text title">${esc(on.title)}</span><form method="post" action="${action}"><button class="btn quiet" name="${name}" value="off">${button}</button></form></span></div>`;
190
+ if (!choices) return '';
191
+ const chips = choices.map((c) => `<button class="btn quiet" name="${name}" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
192
+ return `<div class="block" role="group" aria-labelledby="${id}Title"><div class="dur-head">${icon}<span class="title" id="${id}Title">${title}</span></div><div class="dur-chips"><form class="dur-choices" method="post" action="${action}">${chips}</form><form class="dur-custom" method="post" action="${action}"><input type="hidden" name="${name}"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-dur-at required aria-label="Custom ${title.toLowerCase()} until a date and time"></form></div></div>`;
193
+ }
194
+
195
+ // Wires every duration picker under B.root: Custom opens its picker, and closing it submits its form with the time
196
+ // chosen. → painted(), which a view calls after drawing a picker to set Custom's bounds and suggested time.
197
+ function durationPickers(B) {
198
+ let openPicker = null;
199
+ B.on(B.root, 'click', (e) => {
200
+ const input = e.target.closest?.('[data-dur-at]');
201
+ if (!input) return;
202
+ openPicker = input;
203
+ customBounds(input, input.value === input.defaultValue);
204
+ try { input.showPicker(); } catch { /* iOS opens its wheel on the tap itself. */ }
205
+ });
206
+ // A chip's pointerdown comes before Custom's focusout; without this reset Custom posts first and the chip's post is dropped.
207
+ B.on(B.root, 'pointerdown', (e) => {
208
+ const input = e.target.closest?.('.dur-choices button') && e.target.closest('.dur-chips')?.querySelector('[data-dur-at]');
209
+ if (!input) return;
210
+ openPicker = null;
211
+ input.value = input.defaultValue;
212
+ });
213
+ // Closing an opened picker accepts its suggested time; an unchanged field reached by Tab does nothing.
214
+ const saveCustom = (input) => {
215
+ if (!input.value || (input !== openPicker && input.value === input.defaultValue) || !input.checkValidity()) return;
216
+ const at = new Date(input.value);
217
+ if (Number.isNaN(at.getTime())) return;
218
+ input.form.querySelector('input[type="hidden"]').value = at.toISOString();
219
+ input.form.requestSubmit();
220
+ openPicker = null;
221
+ input.defaultValue = input.value;
222
+ };
223
+ B.on(B.root, 'change', (e) => {
224
+ if (e.target.matches?.('[data-dur-at]') && document.activeElement !== e.target) saveCustom(e.target);
225
+ });
226
+ B.on(B.root, 'focusout', (e) => {
227
+ if (e.target.matches?.('[data-dur-at]')) saveCustom(e.target);
228
+ });
229
+ return { painted: () => { for (const input of B.root.querySelectorAll('[data-dur-at]')) customBounds(input, true); } };
230
+ }
231
+
232
+ globalThis.HubKit = { live, forms, device, durationPicker, durationPickers };
173
233
  })();
@@ -135,6 +135,8 @@
135
135
  .hub-view .lk-form input:focus { outline:2px solid var(--accent-soft); border-color:var(--accent); }
136
136
 
137
137
  .hub-view .ub-banner > button { flex: none; min-height: 32px; }
138
+ .hub-view .hold-banner.ub-paused { border-color: var(--line); background: var(--panel); }
139
+ .hub-view .hold-banner.ub-paused > svg { color: var(--muted); }
138
140
  .hub-view .hold-banner.ub-going { border-color: color-mix(in srgb, var(--color-danger) 45%, transparent); background: color-mix(in srgb, var(--color-danger) 10%, var(--panel)); }
139
141
  .hub-view .hold-banner.ub-going > svg { color: var(--color-danger); }
140
142
 
@@ -8,14 +8,16 @@ globalThis.BoardNewTask = (function () {
8
8
  <div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
9
9
  ${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
10
10
  <div class="pending-files" data-files></div>
11
- <div class="send-actions">${BoardUI.sendButton('Quick build', 1, ' data-intent="quick"')}${BoardUI.sendButton('Discuss', 0, ' data-intent="discuss"')}</div>
11
+ <div class="send-actions">${BoardUI.sendButton('Implement', 1, ' data-intent="quick"')}${BoardUI.sendButton('Discuss', 0, ' data-intent="discuss"')}</div>
12
12
  <p class="muted small" id="askStatus" aria-live="polite"></p>
13
13
  </form>
14
14
  </dialog>`;
15
15
 
16
16
  function wire(B) {
17
- // A shared person looks only (shell.js marks the page shared-view, and style.css hides the +), so n twice opens nothing.
18
- if (document.documentElement.classList.contains('shared-view')) return;
17
+ // A View person looks only (shell.js marks the page shared-view without shared-full, and style.css hides the +), so
18
+ // n twice opens nothing.
19
+ const html = document.documentElement.classList;
20
+ if (html.contains('shared-view') && !html.contains('shared-full')) return;
19
21
  const { $, store, toast, quieted, sendDraft } = B;
20
22
  const { waitOn, keyedSend } = BoardUI;
21
23
  const send = (kind, data) => (B.send ? B.send(kind, data) : B.site.send(kind, data));
@@ -24,7 +26,7 @@ globalThis.BoardNewTask = (function () {
24
26
  const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
25
27
  // Files picked before the app shell switched away from this board are still attached, so they show again.
26
28
  BoardAttach.paint('ask', B.site.root);
27
- // ⌘↵ sends the ask as Discuss and ⌥↵ as Quick build (ui.js keyedSend).
29
+ // ⌘↵ sends the ask as Discuss and ⌥↵ as Implement (ui.js keyedSend).
28
30
  B.on($('#askText'), 'keydown', (ev) => {
29
31
  const button = keyedSend(ev, $('#askForm'));
30
32
  if (!button) return;
@@ -64,7 +66,7 @@ globalThis.BoardNewTask = (function () {
64
66
  if (ev.defaultPrevented) { lastN = -Infinity; return; }
65
67
  if (ev.key !== 'n' || ev.metaKey || ev.ctrlKey || ev.altKey || ev.repeat || ev.isComposing) return;
66
68
  const field = ev.target.closest && ev.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
67
- if (field || document.querySelector('dialog[open]') || B.root.classList.contains('offline')) { lastN = -Infinity; return; }
69
+ if (field || document.querySelector('dialog[open]') || B.root.classList.contains('down')) { lastN = -Infinity; return; }
68
70
  if (ev.timeStamp - lastN > TWICE_MS) { lastN = ev.timeStamp; return; }
69
71
  lastN = -Infinity;
70
72
  ev.preventDefault();
@@ -75,7 +77,7 @@ globalThis.BoardNewTask = (function () {
75
77
  B.on($('#sheetClose'), 'click', closeSheet);
76
78
  B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
77
79
  B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
78
- // Discuss has the session grill or prototype the task at once; Quick build sends the ask with quick: true, and the
80
+ // Discuss has the session grill or prototype the task at once; Implement sends the ask with quick: true, and the
79
81
  // session marks the card ready (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
80
82
  B.on($('#askForm'), 'submit', async (ev) => {
81
83
  ev.preventDefault();
@@ -0,0 +1,46 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>Progress bars</title>
7
+ <link rel="stylesheet" href="colors.css">
8
+ <link rel="stylesheet" href="style.css">
9
+ <style>
10
+ /* The progress-bar review (progress-review.js): the board's lanes, each card one bar or status line drawn by the real
11
+ renderers, its colour the lane's --lane, which the lane's colour pick sets; the open card's editor beneath. */
12
+ .pr-lanes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
13
+ @media (max-width: 799px) { .pr-lanes { grid-template-columns: minmax(0, 1fr); } }
14
+ .pr-lane > h2 { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 0 0 6px; font-size: 13px; color: var(--lane); border-bottom: 2px solid var(--lane); padding-bottom: 4px; }
15
+ .pr-lane input[type="color"] { width: 32px; height: 24px; padding: 0; border: 1px solid var(--line); border-radius: 6px; background: none; }
16
+ .pr-lane .sub.agent:not(.waiting-gray, .quiet) { color: var(--lane); }
17
+ .pr-lane .phase-bars:not(.dim) { --bar-fill: var(--lane); }
18
+ .pr-lane .item[data-open] > summary { outline: 2px solid var(--accent); outline-offset: -2px; }
19
+ .pr-lane .card-time .sb-steps .phase-bars { gap: 2px; }
20
+ .pr-editor { margin-top: 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); padding: 12px; }
21
+ .pr-editor h2 { margin: 0 0 8px; font-size: 15px; }
22
+ .pr-editor label { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
23
+ .pr-editor :where(input, select) { font-size: 16px; }
24
+ .pr-editor ol { margin: 8px 0 0; padding: 0; list-style: none; }
25
+ .pr-editor li { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
26
+ .pr-editor li.over { outline: 2px dashed var(--accent); }
27
+ .pr-editor .grip { cursor: grab; color: var(--muted); }
28
+ .pr-send { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
29
+ </style>
30
+ </head>
31
+ <body>
32
+ <!--header {"lead":"back","title":"Progress bars","backHref":"./","controls":false}-->
33
+ <main>
34
+ <p class="muted">Every progress bar and status line the board draws, in the lane it shows in. Tap one to rename it, move it to another lane or rename and reorder its steps; pick each lane's colour; then Send.</p>
35
+ <div id="review"></div>
36
+ </main>
37
+ <script src="ui.js"></script>
38
+ <script src="ui-links.js"></script>
39
+ <script src="ui-enums.js"></script>
40
+ <script src="ui-progress-bar.js"></script>
41
+ <script src="ui-cards.js"></script>
42
+ <script src="ui-sidebar.js"></script>
43
+ <script src="claude.js"></script>
44
+ <script src="progress-review.js"></script>
45
+ </body>
46
+ </html>
@@ -0,0 +1,158 @@
1
+ // The progress-bar review page (progress-review.html; docs/adr/2026-11-06-0145-every-run-takes-its-lanes-colour.md): the
2
+ // board's lanes with one made-up card per progress bar or status line the board draws, each drawn by the real renderer
3
+ // (ui-cards.js workLines, ui-sidebar.js startSteps) and its steps read from them (BUILD_PHASES, START_STEPS). Tapping a
4
+ // card opens its editor beneath: its name, its lane and its steps' names and order. Each lane has a colour pick. Send
5
+ // posts the whole review as one owner ask, the event the + makes, which the orchestrator files as its own card.
6
+ (function () {
7
+ const LANES = [['todo', 'Todo'], ['progress', 'In Progress'], ['review', 'In Review']];
8
+ const LANE_NAME = Object.fromEntries(LANES);
9
+ const STEP_SEP = ' › ';
10
+ const ago = (minutes) => new Date(Date.now() - minutes * 60000).toISOString();
11
+
12
+ // Each bar or line: the lane it shows in, when, its steps (none for a status line) and the card fields that make the
13
+ // real renderer draw it there.
14
+ function entries() {
15
+ const { BUILD_PHASES, START_STEPS } = BoardUI;
16
+ const build = () => [...BUILD_PHASES];
17
+ return [
18
+ { id: 'grill', name: 'Grilling', lane: 'todo', when: 'A grill planner reads a Todo card and asks its questions', steps: build(),
19
+ card: { builder: { planner: true, kind: 'grill', progress: 'Grilling · the notes', phase: null, at: ago(2) } } },
20
+ { id: 'prototype', name: 'Prototyping', lane: 'todo', when: 'A prototype planner draws a Todo card’s takes', steps: build(),
21
+ card: { builder: { planner: true, kind: 'prototype', progress: 'Prototyping · the takes', phase: null, at: ago(2) } } },
22
+ { id: 'build', name: 'Build', lane: 'progress', when: 'A builder works the card, step by step', steps: build(),
23
+ card: { builder: { progress: 'Testing · the suite', phase: 'testing', at: ago(2) } } },
24
+ { id: 'check', name: 'Orchestrator review', lane: 'progress', when: 'The orchestrator checks the builder’s calls, on step 4', steps: build(),
25
+ card: { waitsOn: 'checking' } },
26
+ { id: 'install', name: 'Install', lane: 'progress', when: 'The install builder runs; the card keeps the step its build reached', steps: build(),
27
+ card: { builder: { kind: 'install', progress: 'Installing on the phone', phase: null, at: ago(1) }, reachedPhase: 'landing' } },
28
+ { id: 'start', name: 'Board start', lane: 'progress', when: 'A new board clones, starts its orchestrator and lands, in the sidebar', steps: Object.values(START_STEPS),
29
+ start: { step: 'starting', stepAt: ago(0.5) } },
30
+ { id: 'rebase', name: 'Rebase run', lane: 'review', when: 'A PR conflicts with main and the board rebases it', steps: [],
31
+ card: { builder: { progress: 'Rebasing onto main', phase: null, at: ago(1) } } },
32
+ { id: 'merging', name: 'Merging', lane: 'review', when: 'The owner tapped Merge', steps: [], card: { next: 'Merging' } },
33
+ { id: 'stopped', name: 'Land stopped', lane: 'review', when: 'A land stopped short of main', steps: [],
34
+ card: { landStopped: { words: 'Land stopped: CI failed', at: ago(3) } } },
35
+ ].map((e) => ({ ...e, cardLane: e.lane }));
36
+ }
37
+
38
+ // A card's markup, as a board card draws it, the entry's step names beneath. The card stays in the lane the board draws
39
+ // it in today (cardLane), so its renderer draws what the board does; the page's lane column only recolours it.
40
+ function cardHtml(e) {
41
+ const { esc, escText, workLines, startSteps, startCaption } = BoardUI;
42
+ const steps = `<div class="line3 sub muted">${e.steps.length ? esc(e.steps.join(STEP_SEP)) : 'a status line, no bar'}</div>`;
43
+ const when = `<div class="line3 sub muted">${escText(e.when)}</div>`;
44
+ if (e.start) {
45
+ return `<div class="line1 title">${escText(e.name)}</div><div class="line3 sub agent">${esc(startCaption(e.start))}</div>${when}${steps}<div class="line6 sub card-time">${startSteps(e.start)}</div>`;
46
+ }
47
+ const card = { kind: 'work', key: `review/${e.id}`, id: e.id, ref: e.id, title: e.name, internal: true, held: false, risky: false,
48
+ deps: [], blockedBy: [], bumpable: false, isNew: false, builder: null, next: null, sortAt: ago(6), ...e.card, lane: e.cardLane };
49
+ return `${workLines(card)}${when}${steps}`;
50
+ }
51
+
52
+ // Send's text: every bar with its lane and steps, each change marked with what the board draws now (was).
53
+ // now and was are {entries, colours: {lane: colour}}.
54
+ function reviewText(now, was) {
55
+ const before = Object.fromEntries(was.entries.map((e) => [e.id, e]));
56
+ const changed = (a, b) => (a === b ? a : `${a} (was ${b})`);
57
+ const colours = LANES.map(([lane, name]) => `${name} ${changed(now.colours[lane], was.colours[lane])}`).join(', ');
58
+ const lines = now.entries.map((e) => {
59
+ const b = before[e.id];
60
+ const steps = e.steps.length ? changed(e.steps.join(STEP_SEP), b.steps.join(STEP_SEP)) : 'a status line, no bar';
61
+ return `${changed(e.name, b.name)}, ${changed(LANE_NAME[e.lane], LANE_NAME[b.lane])}: ${steps}`;
62
+ });
63
+ return ['Progress bar review', '', `Lane colours: ${colours}`, '', ...lines].join('\n');
64
+ }
65
+
66
+ function mount(root) {
67
+ const { esc } = BoardUI;
68
+ const css = getComputedStyle(document.documentElement);
69
+ const laneColour = { todo: '--muted', progress: '--color-notice', review: '--color-info' };
70
+ const colours = Object.fromEntries(LANES.map(([lane]) => [lane, css.getPropertyValue(laneColour[lane]).trim()]));
71
+ const was = { entries: entries(), colours: { ...colours } };
72
+ const list = entries();
73
+ let open = list[0];
74
+ let dragged = null;
75
+
76
+ const lanes = () => `<div class="pr-lanes">${LANES.map(([lane, name]) => `<section class="pr-lane" style="--lane:${esc(colours[lane])}">
77
+ <h2><span>${esc(name)}</span><input type="color" data-colour="${lane}" value="${esc(colours[lane])}" aria-label="${esc(name)} colour"></h2>
78
+ <div class="lane"><div class="list">${list.filter((e) => e.lane === lane).map((e) => `<details class="item" data-entry="${e.id}"${e === open ? ' data-open' : ''}>
79
+ <summary class="work"><span class="status ${e.cardLane}"></span><div class="title-wrap">${cardHtml(e)}</div></summary></details>`).join('') || '<div class="empty">Nothing here</div>'}</div></div>
80
+ </section>`).join('')}</div>`;
81
+ const editor = () => `<section class="pr-editor" aria-label="Edit ${esc(open.name)}"><h2>Editing ${esc(open.name)}</h2>
82
+ <label>Name <input data-name value="${esc(open.name)}"></label>
83
+ <label>Lane <select data-lane>${LANES.map(([lane, name]) => `<option value="${lane}"${lane === open.lane ? ' selected' : ''}>${esc(name)}</option>`).join('')}</select></label>
84
+ ${open.steps.length ? `<ol>${open.steps.map((s, k) => `<li draggable="true" data-k="${k}"><span class="grip" aria-hidden="true">⠿</span>
85
+ <input data-step="${k}" value="${esc(s)}" aria-label="Step ${k + 1}">
86
+ <button type="button" class="btn quiet" data-move="${k}" value="-1" aria-label="Move step ${k + 1} earlier"${k ? '' : ' disabled'}>↑</button>
87
+ <button type="button" class="btn quiet" data-move="${k}" value="1" aria-label="Move step ${k + 1} later"${k < open.steps.length - 1 ? '' : ' disabled'}>↓</button></li>`).join('')}</ol>`
88
+ : '<p class="muted">A status line, no steps.</p>'}</section>`;
89
+ root.innerHTML = '<div data-lanes></div><div data-editor></div><div class="pr-send"><button type="button" class="primary" data-send>Send</button><span class="muted" role="status" data-sent></span></div>';
90
+ const drawLanes = () => { root.querySelector('[data-lanes]').innerHTML = lanes(); };
91
+ const draw = () => { drawLanes(); root.querySelector('[data-editor]').innerHTML = editor(); };
92
+ const move = (from, to) => {
93
+ if (to < 0 || to >= open.steps.length) return;
94
+ const [step] = open.steps.splice(from, 1);
95
+ open.steps.splice(to, 0, step);
96
+ draw();
97
+ };
98
+ draw();
99
+
100
+ root.addEventListener('click', async (ev) => {
101
+ const summary = ev.target.closest('summary');
102
+ if (summary) {
103
+ ev.preventDefault();
104
+ open = list.find((e) => e.id === summary.parentElement.dataset.entry);
105
+ draw();
106
+ return;
107
+ }
108
+ const b = ev.target.closest('button');
109
+ if (b?.dataset.move !== undefined) { move(Number(b.dataset.move), Number(b.dataset.move) + Number(b.value)); return; }
110
+ if (b?.dataset.send === undefined) return;
111
+ const sent = root.querySelector('[data-sent]');
112
+ b.disabled = true;
113
+ try {
114
+ await ClaudeSite.send('ask', { text: reviewText({ entries: list, colours }, was) });
115
+ sent.textContent = 'Sent. The orchestrator files it as a card.';
116
+ } catch (e) {
117
+ sent.textContent = `Couldn’t send: ${e.reason || e.message}`;
118
+ } finally {
119
+ b.disabled = false;
120
+ }
121
+ });
122
+ // Typing renames on the cards as it goes; the editor itself is redrawn only on a change of lane or order, so it keeps focus.
123
+ root.addEventListener('input', (ev) => {
124
+ const t = ev.target;
125
+ if (t.dataset.colour) colours[t.dataset.colour] = t.value;
126
+ else if (t.dataset.name !== undefined) open.name = t.value;
127
+ else if (t.dataset.step) open.steps[Number(t.dataset.step)] = t.value;
128
+ else return;
129
+ drawLanes();
130
+ });
131
+ root.addEventListener('change', (ev) => {
132
+ if (ev.target.dataset.lane === undefined) return;
133
+ open.lane = ev.target.value;
134
+ draw();
135
+ });
136
+ root.addEventListener('dragstart', (ev) => { dragged = ev.target.closest?.('li[data-k]')?.dataset.k ?? null; });
137
+ root.addEventListener('dragover', (ev) => {
138
+ const li = ev.target.closest?.('li[data-k]');
139
+ if (dragged === null || !li) return;
140
+ ev.preventDefault();
141
+ root.querySelectorAll('li.over').forEach((x) => x.classList.toggle('over', x === li));
142
+ li.classList.add('over');
143
+ });
144
+ root.addEventListener('drop', (ev) => {
145
+ const li = ev.target.closest?.('li[data-k]');
146
+ if (dragged === null || !li) return;
147
+ ev.preventDefault();
148
+ const from = Number(dragged);
149
+ dragged = null;
150
+ move(from, Number(li.dataset.k));
151
+ });
152
+ root.addEventListener('dragend', () => { dragged = null; root.querySelectorAll('li.over').forEach((x) => x.classList.remove('over')); });
153
+ }
154
+
155
+ globalThis.ProgressReview = { entries, cardHtml, reviewText, mount };
156
+ const root = globalThis.document?.getElementById('review');
157
+ if (root) mount(root);
158
+ })();
@@ -1,9 +1,10 @@
1
+ // A served round (prototype add --served) has no take files: start frames its page instead (served below).
1
2
  // What a prototype's take files get, on the page board/server.mjs serves from prototype.html for each topic recorded
2
3
  // with board.mjs prototype add (docs/adr/2026-11-01-1448-the-board-serves-every-prototype-from-one-template.md). A take file,
3
4
  // public/prototypes/<topic>/<KEY>.js, is a module:
4
5
  // export function render(stage, kit) { stage.innerHTML = kit.hub(); return () => {…} } // the cleanup is optional
5
6
  // render runs on every switch to that take with a fresh, empty stage; it may be async. The stage contains its layout
6
- // and paint, so nothing in it, position: fixed included, can cover the header, the take pill or the app shell.
7
+ // and paint, so nothing in it, position: fixed included, can cover the viewer's bar, its ✕ or the app shell.
7
8
  // kit.sidebar(data?, {here, switching, device, head, height}) ui-sidebar.js sidebar() as an open drawer card in the
8
9
  // page's flow, rail width from 900px; head shows its Kickflip header
9
10
  // kit.hub(data?) the Hub page (view-hub.js) in flow: {usage, usageCard (HTML in its place),
@@ -14,14 +15,56 @@
14
15
  // kit.on(target, type, fn, opts) a listener removed on the next switch
15
16
  // kit.fake made-up data: at(ms), sites(), usage({claude, codex}), sidebar(), hub(), items()
16
17
  // kit.esc HTML escaping
17
- // BoardUI and HubUI, the real renderers, are globals too. A link in the stage never navigates and a form never
18
+ // The page loads every script app.html does, in its order, so BoardUI and HubUI, every real renderer included, are globals too. A link in the stage never navigates and a form never
18
19
  // submits: each toasts unless the take's own handler shows a toast. Listeners a take adds to document or window,
19
20
  // and its timers, end on the next switch. Interactions must work on a tap: a phone has no hover.
21
+ // At phone width (under 600px) the page caps every box in the stage to the stage's width and stacks a take's side-by-side
22
+ // panels in one column, whether they sit in a grid, a flex row (wrapping or not) or inline-block (phoneFit below).
23
+ // A frame laid out wider than the phone (a 720px or desktop mock) keeps its own layout and scrolls sideways in its box.
24
+ // Fields in the stage are 16px, so iOS Safari doesn't zoom when one gets focus.
20
25
  (function () {
21
26
  const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
22
27
  const wait = globalThis.setTimeout.bind(globalThis);
23
28
  const unwait = globalThis.clearTimeout.bind(globalThis);
24
29
 
30
+ // Phone width: the page caps the take's boxes to the stage (prototype.html). Panels the take puts side by side get
31
+ // data-stack, which CSS stacks in one column, and a frame the take gives a width of 600px or more (a 720px or desktop
32
+ // mock) gets data-wide, with its parent data-scroll, so the frame keeps its layout and scrolls sideways in its box.
33
+ const PHONE = '(max-width: 599px)';
34
+ const WIDE = 600;
35
+ const px = (v) => (/^[\d.]+px$/.test(v) ? parseFloat(v) : 0);
36
+ const rulesOf = (list) => [...list].flatMap((r) => (r.selectorText ? [r] : r.cssRules && !(r.media && !matchMedia(r.media.mediaText).matches) ? rulesOf(r.cssRules) : []));
37
+ const styleRules = () => [...document.styleSheets].flatMap((sheet) => { try { return rulesOf(sheet.cssRules); } catch { return []; } });
38
+ const declaredWidth = (el, rules) => Math.max(px(el.style.width), px(el.style.minWidth), ...rules.filter((r) => el.matches(r.selectorText)).map((r) => Math.max(px(r.style.width), px(r.style.minWidth))));
39
+ const sideBySide = (a, b) => a.right <= b.left + 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 40;
40
+ const isPanel = (el) => el.offsetWidth >= 120 && el.offsetHeight >= 80 && getComputedStyle(el).position !== 'absolute';
41
+ function phoneFit(stage) {
42
+ stage.querySelectorAll('[data-wide], [data-scroll], [data-stack]').forEach((el) => { delete el.dataset.wide; delete el.dataset.scroll; delete el.dataset.stack; });
43
+ if (!matchMedia(PHONE).matches) return;
44
+ const rules = styleRules();
45
+ for (const el of stage.querySelectorAll('*')) {
46
+ if (declaredWidth(el, rules) < WIDE || el.parentElement.closest('[data-wide]')) continue;
47
+ el.dataset.wide = '';
48
+ el.parentElement.dataset.scroll = '';
49
+ }
50
+ const walk = (el) => {
51
+ const rects = [...el.children].filter(isPanel).map((c) => c.getBoundingClientRect());
52
+ if (rects.some((a, i) => rects.some((b, j) => i < j && sideBySide(a, b)))) { el.dataset.stack = ''; return; }
53
+ [...el.children].filter((c) => !('wide' in c.dataset)).forEach(walk);
54
+ };
55
+ walk(stage);
56
+ }
57
+ function fitPhone(stage, ends) {
58
+ let queued = false;
59
+ const observer = new MutationObserver(() => queue());
60
+ const run = () => { queued = false; observer.disconnect(); phoneFit(stage); observer.observe(stage, { childList: true, subtree: true }); };
61
+ const queue = () => { if (!queued) { queued = true; requestAnimationFrame(run); } };
62
+ const mq = matchMedia(PHONE);
63
+ mq.addEventListener('change', queue);
64
+ ends.push(() => { observer.disconnect(); mq.removeEventListener('change', queue); });
65
+ run();
66
+ }
67
+
25
68
  const at = (ms) => new Date(Date.now() + ms).toISOString();
26
69
  const svg = (paths) => `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
27
70
  const siteIcon = (color, paths) => `<span class="icon" style="--c:${color}" aria-hidden="true">${svg(paths)}</span>`;
@@ -66,7 +109,7 @@
66
109
  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}/` });
67
110
  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('');
68
111
  const usage = d.usageCard ?? HubUI.usageCard(d.usage);
69
- const header = d.header ? BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png', logoHref: '/_hub/', settingsHref: null }) : '';
112
+ const header = d.header ? BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png', logoHref: '/_hub/' }) : '';
70
113
  return `<div class="hub-view view-hub kit-hub">${header}<main class="has-top"><div class="hub-status">${usage}${d.afterUsage || ''}<h2 class="label">Health</h2>
71
114
  <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>
72
115
  <h2 class="label">Boards</h2>
@@ -142,7 +185,68 @@
142
185
  // place → [scrollTop, scrollLeft] of each box in stage scrolled away from its start.
143
186
  const scrolledBoxes = (stage) => new Map(placesIn(stage).filter(([el]) => el.scrollTop || el.scrollLeft).map(([el, place]) => [place, [el.scrollTop, el.scrollLeft]]));
144
187
 
188
+ // docs/adr/2026-11-06-1500-a-prototype-round-is-a-page-in-the-boards-base-worktree.md
189
+ // A served round frames its page from the board's base prototype worktree, at that serve's try port on this host, the
190
+ // take and state in its URL at first and then sent by message, which shared/prototype-frame.js hands the page.
191
+ function servedSrc({ https, path, take, state }) {
192
+ const url = new URL(path, `https://${location.hostname}:${https}`);
193
+ url.searchParams.set('variant', take);
194
+ if (state) url.searchParams.set('state', state);
195
+ return url.href;
196
+ }
197
+ // The stage while the serve isn't ready ({state, lines} from api/try/prototypes): Starting, or its failure with the
198
+ // log's last lines and Try again.
199
+ function servedStatus(run) {
200
+ if (run.state !== 'failed') return '<p class="kit-missing">Starting the prototype site…</p>';
201
+ return `<div class="kit-missing kit-served-failed"><p>The prototype site didn’t start.</p><pre>${esc((run.lines || []).join('\n'))}</pre><button type="button" data-kit-served="retry">Try again</button></div>`;
202
+ }
203
+ function served(data) {
204
+ const site = globalThis.ClaudeSite;
205
+ const stage = document.getElementById('stage');
206
+ let at = { take: null, state: null };
207
+ let frame = null;
208
+ let origin = null;
209
+ let started = false;
210
+ const serve = () => fetch(site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: 'prototypes' }) }).catch(() => {});
211
+ const post = () => frame.contentWindow.postMessage({ kickflip: 'prototype', ...at }, origin);
212
+ async function poll() {
213
+ let run = {};
214
+ try { run = await (await fetch(site.url('api/try/prototypes'), { cache: 'no-store' })).json(); } catch { /* the board is out of reach; ask again */ }
215
+ if (run.state === 'ready' && run.https) {
216
+ const src = servedSrc({ https: run.https, path: data.served, ...at });
217
+ origin = new URL(src).origin;
218
+ frame = document.createElement('iframe');
219
+ frame.className = 'kit-served';
220
+ frame.title = data.title;
221
+ frame.src = src;
222
+ // A switch made while the page loaded reaches it once it has.
223
+ nativeAdd.call(frame, 'load', post);
224
+ stage.replaceChildren(frame);
225
+ return;
226
+ }
227
+ stage.innerHTML = servedStatus(run);
228
+ // A serve that's down is started once from here; a failed one waits for Try again.
229
+ if (!run.state && !started) { started = true; serve(); }
230
+ if (run.state !== 'failed') wait(poll, 1500);
231
+ }
232
+ nativeAdd.call(stage, 'click', (e) => {
233
+ if (!e.target.closest?.('[data-kit-served="retry"]')) return;
234
+ stage.innerHTML = servedStatus({});
235
+ serve().then(() => wait(poll, 1500));
236
+ });
237
+ const takes = Object.fromEntries(data.takes.map((t) => [t.key, t.label]));
238
+ const states = data.states ? Object.fromEntries(data.states.map((s) => [s.key, s.label])) : null;
239
+ const sw = PrototypeSwitcher.mount({ takes, states, initial: data.takes[0].key, render(take, state) {
240
+ at = { take, state };
241
+ document.documentElement.dataset.take = take;
242
+ if (frame) post();
243
+ } });
244
+ poll();
245
+ return sw;
246
+ }
247
+
145
248
  function start(data) {
249
+ if (data.served) return served(data);
146
250
  const keys = data.takes.map((t) => t.key);
147
251
  const takes = Object.fromEntries(data.takes.map((t) => [t.key, t.label]));
148
252
  let cleanup = null;
@@ -208,11 +312,12 @@
208
312
  console.error(err);
209
313
  if (n === shown) stage.innerHTML = `<p class="kit-missing">Take ${esc(key)} failed: ${esc(err?.message || err)}</p>`;
210
314
  }
315
+ fitPhone(stage, ends);
211
316
  settle();
212
317
  }
213
318
 
214
319
  return PrototypeSwitcher.mount({ takes, initial: keys[0], render });
215
320
  }
216
321
 
217
- globalThis.PrototypeKit = { ...kit, start };
322
+ globalThis.PrototypeKit = { ...kit, start, servedSrc, servedStatus };
218
323
  })();