@lovek4d/kickflip 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +105 -68
- package/README.md +1 -1
- package/SKILL.md +27 -21
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +199 -79
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -4
- package/board/on-host.mjs +1 -1
- package/board/onboarding.mjs +11 -0
- package/board/prototype-base.mjs +1 -0
- package/board/prototype-page.mjs +1 -0
- package/board/public/app-arch.js +1 -1
- package/board/public/app-asks.js +6 -6
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +75 -37
- package/board/public/app-core.js +26 -8
- package/board/public/app-header.js +8 -8
- package/board/public/app-lanes.js +67 -58
- package/board/public/app-needs.js +140 -10
- package/board/public/app-reader.js +1 -1
- package/board/public/app-snapshot.js +46 -72
- package/board/public/app-threads.js +78 -75
- package/board/public/app.html +9 -4
- package/board/public/app.js +114 -38
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +66 -4
- package/board/public/hub.css +2 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +20 -39
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +137 -7
- package/board/public/prototype-switcher.js +152 -123
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +68 -26
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -30
- package/board/public/style.css +205 -83
- package/board/public/ui-cards.js +91 -65
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +27 -18
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-page-header.js +19 -7
- package/board/public/ui-patch.js +7 -33
- package/board/public/ui-progress-bar.js +28 -0
- package/board/public/ui-questions.js +47 -52
- package/board/public/ui-sidebar.js +34 -26
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +59 -30
- package/board/public/ui.js +125 -18
- package/board/public/view-hub-settings.js +22 -67
- package/board/public/view-hub.js +19 -4
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +205 -240
- package/board/public/view-settings.js +313 -76
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +19 -20
- package/board/stop.mjs +1 -0
- package/board/store.mjs +29 -15
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +25 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- package/hooks-plugin/hooks/usage-gate.mjs +20 -3
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +5 -5
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/owner-checks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/presence.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +1 -2
- package/hub/review-send.mjs +1 -0
- package/hub/secrets.mjs +2 -0
- package/hub/self-restart.mjs +2 -0
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -0
- package/hub/slack.mjs +2 -2
- package/hub/stream.mjs +10 -13
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +2 -1
- package/shared/audit.mjs +4 -0
- package/shared/card-prs.mjs +1 -1
- package/shared/fake-gh.mjs +17 -4
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/service.mjs +4 -4
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
|
@@ -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
|
|
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,
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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/'
|
|
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 <route></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 <route></code>. This page refreshes every 30 seconds.<br><span class="host">mac.example.ts.net</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v0.4.2</span></footer></div></main></div>`;
|
|
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 = () => {
|
|
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
|
})();
|