@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.
- package/CONTEXT.md +82 -46
- package/SKILL.md +21 -15
- package/bin/doctor.mjs +5 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +12 -12
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +179 -92
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -5
- 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-cards.js +40 -47
- package/board/public/app-header.js +6 -7
- package/board/public/app-lanes.js +19 -45
- package/board/public/app-needs.js +36 -31
- package/board/public/app-snapshot.js +43 -70
- package/board/public/app-threads.js +42 -46
- package/board/public/app.html +9 -4
- package/board/public/app.js +78 -32
- package/board/public/hub-kit.js +62 -2
- package/board/public/hub.css +2 -0
- package/board/public/new-task.js +8 -6
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +109 -4
- package/board/public/prototype-switcher.js +130 -31
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +65 -24
- package/board/public/sidebar.css +6 -0
- package/board/public/sidebar.js +19 -69
- package/board/public/style.css +116 -73
- package/board/public/ui-cards.js +78 -57
- package/board/public/ui-enums.js +2 -2
- package/board/public/ui-header.js +23 -16
- package/board/public/ui-hub.js +19 -5
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-order.js +1 -1
- 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 +25 -17
- package/board/public/ui-sidebar.js +31 -23
- package/board/public/ui-snapshot.js +44 -53
- package/board/public/ui-threads.js +57 -28
- package/board/public/ui.js +94 -18
- package/board/public/view-hub-settings.js +12 -58
- package/board/public/view-hub.js +5 -2
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +90 -68
- package/board/public/view-settings.js +133 -15
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +20 -19
- package/board/stop.mjs +1 -0
- package/board/store.mjs +29 -18
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/transcript.mjs +2 -2
- package/board/worktree.mjs +22 -21
- package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
- 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 +2 -2
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/review-send.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -1
- package/hub/slack.mjs +2 -2
- package/hub/try.mjs +6 -6
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/card-prs.mjs +1 -1
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/tailscale.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
package/board/public/hub-kit.js
CHANGED
|
@@ -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
|
-
|
|
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
|
})();
|
package/board/public/hub.css
CHANGED
|
@@ -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
|
|
package/board/public/new-task.js
CHANGED
|
@@ -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('
|
|
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
|
|
18
|
-
|
|
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
|
|
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('
|
|
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;
|
|
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
|
|
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>
|
|
@@ -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
|
})();
|