@lovek4d/kickflip 0.5.3 → 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 +69 -37
- package/SKILL.md +19 -13
- 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 +156 -83
- 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 +27 -13
- package/board/public/app-header.js +6 -7
- package/board/public/app-lanes.js +15 -38
- package/board/public/app-needs.js +36 -31
- package/board/public/app-snapshot.js +43 -70
- package/board/public/app-threads.js +38 -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 +123 -29
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +64 -24
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -69
- package/board/public/style.css +108 -57
- package/board/public/ui-cards.js +61 -46
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +25 -16
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- 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 +25 -23
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +57 -28
- package/board/public/ui.js +92 -17
- package/board/public/view-hub-settings.js +12 -58
- package/board/public/view-hub.js +2 -1
- 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 +19 -19
- package/board/stop.mjs +1 -0
- package/board/store.mjs +27 -18
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +22 -21
- 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 +1 -1
- 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 +7 -7
- 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/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>
|
|
@@ -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
|
})();
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md
|
|
1
|
+
// The full-bleed prototype viewer's floating controls (docs/adr/2026-10-29-1639-prototypes-come-on-request.md,
|
|
2
|
+
// docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md,
|
|
3
|
+
// docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md).
|
|
3
4
|
// Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
|
|
4
|
-
// const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, initial: 'A', render(take) {…}, above: el? })
|
|
5
|
-
// render runs at mount and on each switch; sw.take is the current key.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, states: { empty: 'No cards', … }?, initial: 'A', render(take, state) {…}, above: el? })
|
|
6
|
+
// render runs at mount and on each switch of take or state; sw.take is the current key. A served round's named states
|
|
7
|
+
// (docs/adr/2026-11-06-1500-a-prototype-round-is-a-page-in-the-boards-base-worktree.md) are a row of chips above the
|
|
8
|
+
// pager, the state kept in ?state=, else the first. The page's header is hidden: a ✕ at the top
|
|
9
|
+
// right goes back as its back arrow does, and the bottom bar holds the ‹ take › pager. ←/→ and a sideways swipe switch
|
|
10
|
+
// unless a field has focus, and the take stays in ?variant=. With ?q=<owner question id>, the bar's second row offers
|
|
11
|
+
// the take as that question's pick: a take is pickable when an option's id equals its key. Choose this drafts it, with
|
|
12
|
+
// the note typed above the bar, in the {choice, note} questions.html keeps for that question, and goes back to the
|
|
13
|
+
// question, whose Send sends it. A card with more than one prototype round gets a Round pill at the top left, which
|
|
14
|
+
// opens a sheet of its rounds (/api/board's card prototypes.rounds, board/domain.mjs).
|
|
9
15
|
(function () {
|
|
10
16
|
const esc = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
11
17
|
|
|
@@ -24,15 +30,16 @@
|
|
|
24
30
|
const shownPick = (question, drafted) => question.state === 'requested' ? question.request?.choice ?? null
|
|
25
31
|
: question.state === 'resolved' ? pickedChoice(question)
|
|
26
32
|
: drafted ?? pickedChoice(question) ?? question.suggested?.option ?? null;
|
|
27
|
-
// none: no question, the take is not one of its options, or the question
|
|
28
|
-
//
|
|
33
|
+
// none: no question, the take is not one of its options, or the question waits on a requested prototype and the take
|
|
34
|
+
// isn't its pick. closed: the question is resolved and the take isn't its pick. pick: nothing picked yet. picked: this
|
|
35
|
+
// take is the pick. change: another take is.
|
|
29
36
|
function pickState(question, take, drafted = null) {
|
|
30
37
|
if (!question) return { kind: 'none' };
|
|
31
38
|
const option = (question.options || []).find((o) => o.id === take);
|
|
32
39
|
const picked = shownPick(question, drafted);
|
|
33
40
|
if (!option) return { kind: 'none', picked };
|
|
34
41
|
if (picked === take) return { kind: 'picked', option, picked };
|
|
35
|
-
if (locked(question)) return { kind: 'none', picked };
|
|
42
|
+
if (locked(question)) return { kind: question.state === 'resolved' ? 'closed' : 'none', picked };
|
|
36
43
|
return picked ? { kind: 'change', option, picked } : { kind: 'pick', option };
|
|
37
44
|
}
|
|
38
45
|
// questions.html keeps each question's unsent {choice, note} under this key (ui-questions.js getDraft).
|
|
@@ -47,38 +54,82 @@
|
|
|
47
54
|
if (!app?.open?.(String(url), { replace: true })) location.replace(url);
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
|
|
57
|
+
// The bar: the states' chips when the round has states, the ‹ name › pager, and below it on its own row the pick
|
|
58
|
+
// control, when there is one.
|
|
59
|
+
function pillHtml({ name, pick = { kind: 'none' }, states = null, state = null }) {
|
|
60
|
+
const chips = states ? `<div class="ps-row ps-states">${Object.entries(states).map(([key, label]) => `<button type="button" data-ps-state="${esc(key)}" aria-pressed="${key === state}">${esc(label)}</button>`).join('')}</div>` : '';
|
|
51
61
|
const control = pick.kind === 'picked' ? '<span class="ps-picked">Picked</span>'
|
|
52
|
-
: pick.kind === '
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return
|
|
62
|
+
: pick.kind === 'closed' ? '<span class="ps-closed">Round answered</span>'
|
|
63
|
+
: pick.kind === 'pick' || pick.kind === 'change' ? '<button type="button" class="ps-pick" data-ps="pick">Choose this</button>'
|
|
64
|
+
: '';
|
|
65
|
+
return `${chips}<div class="ps-row"><button type="button" data-ps="prev" aria-label="Previous take">‹</button><span class="ps-name" title="${esc(name)}">${esc(name)}</span><button type="button" data-ps="next" aria-label="Next take">›</button></div>${control ? `<div class="ps-row ps-act">${control}</div>` : ''}`;
|
|
56
66
|
}
|
|
57
67
|
|
|
68
|
+
// rounds: the card's prototype questions, oldest round first, as /api/board's card prototypes.rounds lists them
|
|
69
|
+
// ({n, q, url, confirm, state}). The pill names the round qid is in, out of the newest round's number; a card with a
|
|
70
|
+
// single round gets none.
|
|
71
|
+
function roundPill(rounds, qid) {
|
|
72
|
+
const at = (rounds || []).find((r) => r.q === qid);
|
|
73
|
+
if (!at || rounds.length < 2) return '';
|
|
74
|
+
return `<button type="button" class="ps-chip" data-ps-top="rounds">Round ${at.n} of ${Math.max(...rounds.map((r) => r.n))} · earlier</button>`;
|
|
75
|
+
}
|
|
76
|
+
const ROUND_WORD = { open: 'open', requested: 'prototype requested' };
|
|
77
|
+
function roundSheet({ ref, rounds, qid }) {
|
|
78
|
+
const rows = [...rounds].reverse().map((r) => `<button type="button" data-ps-round="${esc(r.url)}"><span>Round ${r.n}${r.confirm ? ' confirm' : ''}${r.q === qid ? ' · showing' : ''}</span><span>${ROUND_WORD[r.state] || 'answered'}</span></button>`);
|
|
79
|
+
return `<div class="ps-sheet" role="dialog" aria-label="Rounds"><h3>Rounds on ${esc(ref)}</h3>${rows.join('')}</div>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A touch from start to end: 1 for a swipe left (the next take), -1 for a swipe right, 0 for anything else: under
|
|
83
|
+
// 50px sideways, more down than across, or starting at the left edge, where the drawer's and the browser's swipes are.
|
|
84
|
+
const swipeStep = (start, end) => {
|
|
85
|
+
const dx = end.x - start.x;
|
|
86
|
+
return start.x < 24 || Math.abs(dx) < 50 || Math.abs(end.y - start.y) * 2 > Math.abs(dx) ? 0 : dx < 0 ? 1 : -1;
|
|
87
|
+
};
|
|
88
|
+
|
|
58
89
|
const CSS = `
|
|
59
|
-
.
|
|
90
|
+
body > header.top { display:none; }
|
|
91
|
+
main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
92
|
+
.ps-top { position:fixed; left:10px; right:10px; top:calc(10px + env(safe-area-inset-top)); z-index:31; display:flex; justify-content:space-between;
|
|
93
|
+
align-items:flex-start; pointer-events:none; font:600 13px -apple-system, system-ui, sans-serif; }
|
|
94
|
+
.ps-top > * { pointer-events:auto; }
|
|
95
|
+
.ps-chip, .ps-close { color:#fff; background:#000a; border:1px solid #fff4; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); font:inherit; cursor:pointer; }
|
|
96
|
+
.ps-chip { border-radius:999px; padding:0 14px; min-height:40px; }
|
|
97
|
+
.ps-close { margin-left:auto; width:40px; height:40px; border-radius:50%; font-size:17px; line-height:1; }
|
|
98
|
+
.ps-sheet { position:fixed; left:0; right:0; bottom:0; z-index:32; padding:14px 16px calc(22px + env(safe-area-inset-bottom)); border-radius:18px 18px 0 0;
|
|
99
|
+
background:#26262c; color:#fff; box-shadow:0 -8px 30px #0009; }
|
|
100
|
+
.ps-sheet h3 { margin:0 0 8px; font-size:14px; }
|
|
101
|
+
.ps-sheet button { display:flex; justify-content:space-between; gap:12px; width:100%; border:0; border-top:1px solid #fff2; background:transparent; color:#fff;
|
|
102
|
+
padding:12px 2px; font:inherit; font-weight:500; text-align:left; cursor:pointer; }
|
|
103
|
+
.ps-sheet button span + span { opacity:.7; }
|
|
104
|
+
.ps-switch { position:fixed; left:10px; right:10px; bottom:calc(var(--app-bottom, 0px) + 12px + env(safe-area-inset-bottom)); z-index:30;
|
|
60
105
|
display:flex; flex-direction:column; align-items:center; gap:6px; pointer-events:none; }
|
|
61
106
|
.ps-switch > * { pointer-events:auto; max-width:100%; min-width:0; box-sizing:border-box; }
|
|
62
|
-
.ps-
|
|
63
|
-
-webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); font:600 13px -apple-system, system-ui, sans-serif; }
|
|
64
|
-
.ps-
|
|
107
|
+
.ps-pill { display:flex; flex-direction:column; gap:6px; width:min(460px, 100%); background:#202026eb; border:1px solid #fff3; border-radius:18px; padding:6px;
|
|
108
|
+
box-shadow:0 6px 24px #0008; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); font:600 13px -apple-system, system-ui, sans-serif; }
|
|
109
|
+
.ps-row { display:flex; align-items:center; gap:2px; min-width:0; }
|
|
110
|
+
.ps-pill button { color:#fff; background:transparent; border:0; border-radius:999px; padding:7px 11px; font:inherit; white-space:nowrap; cursor:pointer; }
|
|
65
111
|
.ps-pill button { flex-shrink:0; min-width:44px; min-height:44px; }
|
|
66
|
-
.ps-
|
|
67
|
-
.ps-
|
|
68
|
-
.ps-
|
|
112
|
+
.ps-name { flex:1 1 auto; padding:7px 6px; color:#fff; white-space:nowrap; min-width:0; text-align:center; overflow:hidden; text-overflow:ellipsis; }
|
|
113
|
+
.ps-act > * { flex:1; text-align:center; }
|
|
114
|
+
.ps-states { overflow-x:auto; scrollbar-width:none; gap:4px; }
|
|
115
|
+
.ps-pill .ps-states button { background:#fff1; font-weight:500; }
|
|
116
|
+
.ps-pill .ps-states button[aria-pressed="true"] { background:#fff; color:#000; font-weight:700; }
|
|
117
|
+
.ps-pill .ps-pick { background:var(--accent, #fff); color:var(--on-accent, #000); font-weight:700; }
|
|
118
|
+
.ps-closed { padding:12px 11px; border-radius:999px; color:#fffa; background:#fff1; white-space:nowrap; }
|
|
69
119
|
.ps-switch .ps-text { width:min(420px, 100%); resize:none; border-radius:12px; border:1px solid #fff3; background:#000d; color:#fff;
|
|
70
|
-
padding:7px 10px; font:400
|
|
120
|
+
padding:7px 10px; font:400 16px -apple-system, system-ui, sans-serif; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); }
|
|
71
121
|
.ps-switch .ps-text::placeholder { color:#fffa; }
|
|
72
122
|
.ps-switch .ps-text[hidden] { display:none; }
|
|
73
|
-
.ps-picked {
|
|
123
|
+
.ps-picked { padding:12px 11px; border-radius:999px; color:#fff; background:#1f9d55; white-space:nowrap; }
|
|
74
124
|
.ps-note { background:#000d; color:#fff; border-radius:8px; padding:5px 10px; font:500 12px -apple-system, system-ui, sans-serif; text-align:center; }
|
|
75
125
|
.ps-note[hidden] { display:none; }`;
|
|
76
126
|
|
|
77
127
|
const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
|
|
78
128
|
|
|
79
|
-
function mount({ takes, initial, render, above = null }) {
|
|
129
|
+
function mount({ takes, states = null, initial, render, above = null }) {
|
|
80
130
|
const keys = Object.keys(takes);
|
|
81
131
|
const params = new URLSearchParams(location.search);
|
|
132
|
+
let shownState = states ? startTake(Object.keys(states), params.get('state')) : null;
|
|
82
133
|
const qid = params.get('q');
|
|
83
134
|
const site = globalThis.ClaudeSite;
|
|
84
135
|
if (!document.getElementById('ps-style')) document.head.insertAdjacentHTML('beforeend', `<style id="ps-style">${CSS}</style>`);
|
|
@@ -97,12 +148,23 @@
|
|
|
97
148
|
const pill = box.querySelector('.ps-pill');
|
|
98
149
|
const noteEl = box.querySelector('.ps-note');
|
|
99
150
|
const text = box.querySelector('.ps-text');
|
|
151
|
+
// The ✕ and, on a card with earlier rounds, the Round pill and its sheet. boardCard: /api/board's card the question is on.
|
|
152
|
+
const top = document.createElement('div');
|
|
153
|
+
top.className = 'ps-top';
|
|
154
|
+
document.body.append(top);
|
|
155
|
+
let boardCard = null;
|
|
156
|
+
let sheet = false;
|
|
157
|
+
const paintTop = () => {
|
|
158
|
+
const rounds = boardCard?.prototypes?.rounds;
|
|
159
|
+
top.innerHTML = `${roundPill(rounds, qid)}<button type="button" class="ps-close" data-ps-top="close" aria-label="Close">✕</button>${sheet && rounds ? roundSheet({ ref: boardCard.ref, rounds, qid }) : ''}`;
|
|
160
|
+
};
|
|
161
|
+
paintTop();
|
|
100
162
|
|
|
101
163
|
const note = (text) => { noteEl.textContent = text || ''; noteEl.hidden = !text; };
|
|
102
164
|
const state = () => pickState(question, take, qid ? readDraft(qid).choice : null);
|
|
103
165
|
function paint() {
|
|
104
166
|
const now = state();
|
|
105
|
-
pill.innerHTML = pillHtml({ name: takes[take], pick: now });
|
|
167
|
+
pill.innerHTML = pillHtml({ name: takes[take], pick: now, states, state: shownState });
|
|
106
168
|
text.hidden = !(now.kind === 'pick' || now.kind === 'change');
|
|
107
169
|
}
|
|
108
170
|
function go(next) {
|
|
@@ -110,7 +172,13 @@
|
|
|
110
172
|
const u = new URL(location.href); u.searchParams.set('variant', take); history.replaceState(null, '', u);
|
|
111
173
|
note('');
|
|
112
174
|
paint();
|
|
113
|
-
render(take);
|
|
175
|
+
render(take, shownState);
|
|
176
|
+
}
|
|
177
|
+
function show(next) {
|
|
178
|
+
shownState = next;
|
|
179
|
+
const u = new URL(location.href); u.searchParams.set('state', next); history.replaceState(null, '', u);
|
|
180
|
+
paint();
|
|
181
|
+
render(take, shownState);
|
|
114
182
|
}
|
|
115
183
|
|
|
116
184
|
async function load() {
|
|
@@ -124,6 +192,8 @@
|
|
|
124
192
|
|| (topic && (await (await fetch(site.url('api/questions?topic=' + encodeURIComponent(topic.id)), { cache: 'no-store' })).json()).questions.find((q) => q.id === qid))
|
|
125
193
|
|| null;
|
|
126
194
|
if (!question) note('This prototype’s question is gone, so there is nothing to pick.');
|
|
195
|
+
boardCard = topic && board.items.find((i) => i.questions?.topic === topic.id) || null;
|
|
196
|
+
paintTop();
|
|
127
197
|
} catch { /* keep the last question shown */ }
|
|
128
198
|
if (fromPush && pickedChoice(question)) {
|
|
129
199
|
const card = /^item:(.+)$/.exec(question.topic || '')?.[1];
|
|
@@ -148,22 +218,46 @@
|
|
|
148
218
|
});
|
|
149
219
|
|
|
150
220
|
pill.addEventListener('click', (e) => {
|
|
221
|
+
const chip = e.target.closest('[data-ps-state]')?.dataset.psState;
|
|
222
|
+
if (chip) { show(chip); return; }
|
|
151
223
|
const act = e.target.closest('[data-ps]')?.dataset.ps;
|
|
152
224
|
if (act === 'prev') go(takeAt(keys, take, -1));
|
|
153
225
|
else if (act === 'next') go(takeAt(keys, take, 1));
|
|
154
226
|
else if (act === 'pick') pick();
|
|
155
227
|
});
|
|
228
|
+
// ✕ follows the hidden header's back arrow, so it goes where back goes (router.js page tree).
|
|
229
|
+
top.addEventListener('click', (e) => {
|
|
230
|
+
const round = e.target.closest('[data-ps-round]')?.dataset.psRound;
|
|
231
|
+
if (round) { replaceWith(site.url(round)); return; }
|
|
232
|
+
const act = e.target.closest('[data-ps-top]')?.dataset.psTop;
|
|
233
|
+
if (act === 'close') document.querySelector('header.top a.back').click();
|
|
234
|
+
else if (act === 'rounds') { sheet = !sheet; paintTop(); }
|
|
235
|
+
});
|
|
156
236
|
document.addEventListener('keydown', (e) => {
|
|
157
237
|
if (typing(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
158
238
|
if (e.key === 'ArrowLeft') go(takeAt(keys, take, -1));
|
|
159
239
|
else if (e.key === 'ArrowRight') go(takeAt(keys, take, 1));
|
|
240
|
+
else if (e.key === 'Escape' && sheet) { sheet = false; paintTop(); }
|
|
160
241
|
});
|
|
242
|
+
// A swipe that starts in a box scrolling sideways is that box's own scroll.
|
|
243
|
+
let touch = null;
|
|
244
|
+
document.addEventListener('touchstart', (e) => {
|
|
245
|
+
const t = e.touches[0];
|
|
246
|
+
const scrolls = (el) => el && el !== document.body && (el.scrollWidth > el.clientWidth && /auto|scroll/.test(getComputedStyle(el).overflowX) ? true : scrolls(el.parentElement));
|
|
247
|
+
touch = e.touches.length === 1 && !typing(e.target) && !e.target.closest?.('.ps-sheet') && !scrolls(e.target) ? { x: t.clientX, y: t.clientY } : null;
|
|
248
|
+
}, { passive: true });
|
|
249
|
+
document.addEventListener('touchend', (e) => {
|
|
250
|
+
const t = e.changedTouches[0];
|
|
251
|
+
const step = touch && t ? swipeStep(touch, { x: t.clientX, y: t.clientY }) : 0;
|
|
252
|
+
touch = null;
|
|
253
|
+
if (step) go(takeAt(keys, take, step));
|
|
254
|
+
}, { passive: true });
|
|
161
255
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
|
|
162
256
|
|
|
163
257
|
go(take);
|
|
164
258
|
load();
|
|
165
|
-
return { takes: keys, get take() { return take; }, go };
|
|
259
|
+
return { takes: keys, get take() { return take; }, get state() { return shownState; }, go, show };
|
|
166
260
|
}
|
|
167
261
|
|
|
168
|
-
globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, mount };
|
|
262
|
+
globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, roundPill, roundSheet, swipeStep, mount };
|
|
169
263
|
})();
|