@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
@@ -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>
@@ -75,7 +118,7 @@
75
118
  <h2 class="label">Polling status</h2>
76
119
  <div class="group">${nowRow(d.polling, tile('clock', '#3b82f6'))}</div></div>
77
120
  <section class="hub-settings"><h2 class="label">Hub settings</h2>${settings}</section>
78
- <div class="hub-foot"><footer>${d.sites.length} sites. Each belongs to one Claude session, which adds it with <code>/kickflip &lt;route&gt;</code>. This page refreshes every 30 seconds.<br><span class="host">mac.example.ts.net</span></footer></div></main></div>`;
121
+ <div class="hub-foot"><footer>${d.sites.length} sites. Each belongs to one Claude session, which adds it with <code>/kickflip &lt;route&gt;</code>. This page refreshes every 30 seconds.<br><span class="host">mac.example.ts.net</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v0.4.2</span></footer></div></main></div>`;
79
122
  }
80
123
 
81
124
  const LANES = [['review', 'In Review'], ['progress', 'In Progress'], ['todo', 'Todo'], ['done', 'Done']];
@@ -127,7 +170,83 @@
127
170
 
128
171
  const kit = { sidebar, hub, cards, sheet, toast, on, fake, esc };
129
172
 
173
+ // [element, place] for each element in stage, its place the tag and class of it and each ancestor up to the stage, with
174
+ // a count telling apart boxes that share one, so a take drawn again finds the same box.
175
+ function placesIn(stage) {
176
+ const seen = new Map();
177
+ return [...stage.querySelectorAll('*')].map((el) => {
178
+ let path = '';
179
+ for (let at = el; at && at !== stage; at = at.parentElement) path = `${at.tagName}.${at.className}/${path}`;
180
+ const n = seen.get(path) || 0;
181
+ seen.set(path, n + 1);
182
+ return [el, `${path}#${n}`];
183
+ });
184
+ }
185
+ // place → [scrollTop, scrollLeft] of each box in stage scrolled away from its start.
186
+ const scrolledBoxes = (stage) => new Map(placesIn(stage).filter(([el]) => el.scrollTop || el.scrollLeft).map(([el, place]) => [place, [el.scrollTop, el.scrollLeft]]));
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
+
130
248
  function start(data) {
249
+ if (data.served) return served(data);
131
250
  const keys = data.takes.map((t) => t.key);
132
251
  const takes = Object.fromEntries(data.takes.map((t) => [t.key, t.label]));
133
252
  let cleanup = null;
@@ -158,13 +277,23 @@
158
277
  try { cleanup?.(); } catch (err) { console.error(err); }
159
278
  cleanup = null;
160
279
  ends.splice(0).forEach((end) => end());
161
- // A switch keeps the page where it is: the new stage holds the old one's height until the take has drawn.
280
+ // A switch keeps the page where it is: the new stage holds the old one's height until the take has drawn, and
281
+ // each box scrolled inside the take scrolls where it was once the new take has drawn the same box.
162
282
  const old = document.getElementById('stage');
163
283
  const stage = old.cloneNode(false);
164
284
  const y = window.scrollY;
285
+ const scrolled = scrolledBoxes(old);
165
286
  stage.style.minHeight = `${old.offsetHeight}px`;
166
287
  old.replaceWith(stage);
167
- const settle = () => { if (n !== shown) return; stage.style.minHeight = ''; window.scrollTo(0, y); };
288
+ const settle = () => {
289
+ if (n !== shown) return;
290
+ stage.style.minHeight = '';
291
+ for (const [el, place] of placesIn(stage)) {
292
+ const at = scrolled.get(place);
293
+ if (at) [el.scrollTop, el.scrollLeft] = at;
294
+ }
295
+ window.scrollTo(0, y);
296
+ };
168
297
  document.documentElement.dataset.take = key;
169
298
  const take = data.takes.find((t) => t.key === key);
170
299
  if (!take.src) {
@@ -183,11 +312,12 @@
183
312
  console.error(err);
184
313
  if (n === shown) stage.innerHTML = `<p class="kit-missing">Take ${esc(key)} failed: ${esc(err?.message || err)}</p>`;
185
314
  }
315
+ fitPhone(stage, ends);
186
316
  settle();
187
317
  }
188
318
 
189
319
  return PrototypeSwitcher.mount({ takes, initial: keys[0], render });
190
320
  }
191
321
 
192
- globalThis.PrototypeKit = { ...kit, start };
322
+ globalThis.PrototypeKit = { ...kit, start, servedSrc, servedStatus };
193
323
  })();