@lovek4d/kickflip 0.6.0 → 0.7.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 +42 -26
- package/SKILL.md +16 -12
- package/bin/doctor.mjs +5 -4
- package/bin/event-lines.mjs +1 -0
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +7 -7
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +1 -1
- package/board/bin/board.mjs +70 -42
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -9
- package/board/onboarding.mjs +1 -1
- package/board/prototype-page.mjs +1 -1
- package/board/public/app-arch.js +2 -2
- package/board/public/app-banner.js +1 -1
- package/board/public/app-cards.js +36 -48
- package/board/public/app-lanes.js +6 -9
- package/board/public/app-needs.js +107 -26
- package/board/public/app-reader.js +52 -42
- package/board/public/app-threads.js +14 -10
- package/board/public/app.html +1 -1
- package/board/public/app.js +22 -12
- package/board/public/progress-review.js +4 -4
- package/board/public/prototype-kit.js +25 -11
- package/board/public/prototype-switcher.js +53 -33
- package/board/public/prototype.html +7 -4
- package/board/public/router.js +34 -6
- package/board/public/shell.js +23 -16
- package/board/public/sidebar.css +7 -2
- package/board/public/sidebar.js +3 -0
- package/board/public/style.css +69 -30
- package/board/public/ui-cards.js +61 -38
- package/board/public/ui-enums.js +12 -3
- package/board/public/ui-header.js +4 -6
- package/board/public/ui-hub.js +7 -4
- package/board/public/ui-links.js +1 -1
- package/board/public/ui-order.js +9 -6
- package/board/public/ui-page-header.js +1 -1
- package/board/public/ui-progress-bar.js +6 -7
- package/board/public/ui-questions.js +18 -3
- package/board/public/ui-sidebar.js +12 -5
- package/board/public/ui-snapshot.js +1 -1
- package/board/public/ui-threads.js +10 -12
- package/board/public/ui.js +7 -4
- package/board/public/view-hub.js +3 -1
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +19 -3
- package/board/public/view-settings.js +60 -28
- package/board/server.mjs +19 -19
- package/board/stop.mjs +1 -1
- package/board/store.mjs +27 -25
- package/board/tickets.mjs +1 -1
- package/board/transcript.mjs +4 -2
- package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
- package/hub/front-door.mjs +1 -1
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +6 -6
- package/hub/sessions.mjs +2 -2
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/package.json +1 -1
- package/shared/card-prs.mjs +1 -1
- package/shared/machine-gate.mjs +2 -2
- package/shared/tailscale.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
// The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "
|
|
1
|
+
// The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "View transcript"
|
|
2
2
|
// (ui-threads.js) and a card's or a lineup's Notes (app-cards.js, app-lanes.js) open over the board in one sheet in
|
|
3
3
|
// place of log.html and notes.html, which still open on their own. It closes on its ✕, Escape, a tap on the dim area
|
|
4
4
|
// and Back, through a history entry of its own that carries the board's URL, so the router's pop keeps the board.
|
|
5
5
|
// A link in it closes it first, then opens through the shell.
|
|
6
|
-
// A log written as a stream (headless.mjs: one stream-json event per line) reads as
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// A log written as a stream (headless.mjs: one stream-json event per line) reads as the run's transcript, the whole of
|
|
7
|
+
// it as GET /api/transcript's rows: what Claude says, and each tool call as one line whose output opens on a tap, with a
|
|
8
|
+
// raw toggle for the file as it is (docs/adr/2026-11-07-1224-view-transcript-reads-the-whole-run-as-rows.md); any other
|
|
9
|
+
// log reads as its text. Every 3 seconds until the run's result arrives it reads on from where it left off, and follows
|
|
10
|
+
// new lines while read at its end.
|
|
9
11
|
BoardView.part((B) => {
|
|
10
12
|
const { $, state } = B;
|
|
11
13
|
const { esc, markdown, linkContext } = BoardUI;
|
|
@@ -19,31 +21,13 @@ BoardView.part((B) => {
|
|
|
19
21
|
let log = null;
|
|
20
22
|
let stopReading = null;
|
|
21
23
|
|
|
22
|
-
//
|
|
23
|
-
function
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
return events.some(Boolean) ? lines.map((line, i) => events[i] ?? line) : null;
|
|
24
|
+
// A row of the transcript (transcript.mjs); a call with its output opens it on a tap of its line.
|
|
25
|
+
function rowHtml(r, i) {
|
|
26
|
+
const tag = r.label ? `<span class="reader-tag">${esc(r.label)}</span>` : '';
|
|
27
|
+
if (r.out === undefined) return `<div class="reader-row ${r.kind}">${tag}${esc(r.body)}</div>`;
|
|
28
|
+
const on = log.opened.has(i);
|
|
29
|
+
return `<div class="reader-row tool call" data-call="${i}"><span class="reader-caret">${on ? '▾' : '▸'}</span>${tag}${esc(r.body)}</div>${on ? `<div class="reader-out">${esc(r.out)}</div>` : ''}`;
|
|
29
30
|
}
|
|
30
|
-
const short = (v) => (v.length > 400 ? `${v.slice(0, 400)}…` : v);
|
|
31
|
-
const textOf = (content) => (typeof content === 'string' ? content : content.map((part) => part.text ?? '').join('\n'));
|
|
32
|
-
const callOf = (input) => input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.description ?? JSON.stringify(input);
|
|
33
|
-
// A stream line's readable rows {kind, label, body}. Thinking and events other than the start, messages and the
|
|
34
|
-
// result have none; the raw view shows them.
|
|
35
|
-
function rowsOf(e) {
|
|
36
|
-
if (typeof e === 'string') return [{ kind: 'out', body: e }];
|
|
37
|
-
if (e.type === 'system') return e.subtype === 'init' ? [{ kind: 'sys', label: 'start', body: `${e.model} in ${e.cwd}` }] : [];
|
|
38
|
-
if (e.type === 'result') return [{ kind: 'text end', label: e.is_error ? 'stopped' : 'done', body: e.result ?? e.subtype }];
|
|
39
|
-
if (e.type !== 'assistant' && e.type !== 'user') return [];
|
|
40
|
-
const content = e.message.content;
|
|
41
|
-
if (typeof content === 'string') return [{ kind: 'text', body: content }];
|
|
42
|
-
return content.flatMap((c) => (c.type === 'text' ? [{ kind: 'text', body: c.text }]
|
|
43
|
-
: c.type === 'tool_use' ? [{ kind: 'tool', label: c.name.toLowerCase(), body: callOf(c.input) }]
|
|
44
|
-
: c.type === 'tool_result' ? [{ kind: 'out', body: short(textOf(c.content)) }] : []));
|
|
45
|
-
}
|
|
46
|
-
const rowHtml = (r) => `<div class="reader-row ${r.kind}">${r.label ? `<span class="reader-tag">${esc(r.label)}</span>` : ''}${esc(r.body)}</div>`;
|
|
47
31
|
|
|
48
32
|
const atEnd = () => body.scrollHeight - body.scrollTop - body.clientHeight < 40;
|
|
49
33
|
const toEnd = () => { body.scrollTop = body.scrollHeight; };
|
|
@@ -57,11 +41,11 @@ BoardView.part((B) => {
|
|
|
57
41
|
if (log?.unseen) more.textContent = `${log.unseen} new ${log.unseen === 1 ? 'line' : 'lines'} ↓`;
|
|
58
42
|
}
|
|
59
43
|
function drawLog() {
|
|
60
|
-
const {
|
|
61
|
-
body.innerHTML = !
|
|
62
|
-
: !stream ? `<pre class="reader-text">${esc(
|
|
63
|
-
: raw ? `<pre class="reader-raw">${esc(text)}</pre>`
|
|
64
|
-
:
|
|
44
|
+
const { rows, stream, raw, text } = log;
|
|
45
|
+
body.innerHTML = !rows.length ? '<p class="muted reader-empty">The log is empty so far.</p>'
|
|
46
|
+
: !stream ? `<pre class="reader-text">${esc(rows.map((r) => `${r.body}\n`).join(''))}</pre>`
|
|
47
|
+
: raw ? (text === null ? '<p class="muted">Loading…</p>' : `<pre class="reader-raw">${esc(text)}</pre>`)
|
|
48
|
+
: rows.map(rowHtml).join('');
|
|
65
49
|
drawTools();
|
|
66
50
|
drawMore();
|
|
67
51
|
}
|
|
@@ -73,24 +57,44 @@ BoardView.part((B) => {
|
|
|
73
57
|
drawMore();
|
|
74
58
|
}
|
|
75
59
|
|
|
60
|
+
// Reads the transcript on from where the last read ended; an output joins its call's line.
|
|
76
61
|
async function readLog(n) {
|
|
77
62
|
let answer;
|
|
78
63
|
try {
|
|
79
|
-
const r = await fetch(B.site.url(`api/
|
|
64
|
+
const r = await fetch(B.site.url(`api/transcript?file=${encodeURIComponent(log.file)}&from=${log.next}`), { cache: 'no-store' });
|
|
80
65
|
answer = { ok: r.ok, ...(await r.json()) };
|
|
81
66
|
} catch { answer = null; }
|
|
82
67
|
if (n !== opened) return;
|
|
83
68
|
if (answer && !answer.ok) { body.innerHTML = `<p class="muted">${esc(answer.error || 'Could not load this log.')}</p>`; return; }
|
|
84
|
-
if (answer && answer.
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
69
|
+
if (answer && (!log.read || answer.next !== log.next)) {
|
|
70
|
+
let added = 0;
|
|
71
|
+
for (const row of answer.rows) {
|
|
72
|
+
if (row.for) { log.calls.get(row.for).out = row.body; continue; }
|
|
73
|
+
if (row.id) log.calls.set(row.id, row);
|
|
74
|
+
log.rows.push(row);
|
|
75
|
+
added++;
|
|
76
|
+
}
|
|
77
|
+
Object.assign(log, { read: true, next: answer.next, stream: log.stream || answer.stream, finished: log.finished || answer.finished });
|
|
78
|
+
if (!log.following) log.unseen += added;
|
|
89
79
|
drawLog();
|
|
90
80
|
if (log.following) toEnd();
|
|
81
|
+
if (log.raw) readRaw(n);
|
|
91
82
|
}
|
|
92
83
|
if (!log.finished) stopReading = B.later(3000, () => readLog(n));
|
|
93
84
|
}
|
|
85
|
+
// The raw toggle's file as it is: GET /api/log, its last 256 KiB.
|
|
86
|
+
async function readRaw(n) {
|
|
87
|
+
let text;
|
|
88
|
+
try {
|
|
89
|
+
const r = await fetch(B.site.url(`api/log?file=${encodeURIComponent(log.file)}`), { cache: 'no-store' });
|
|
90
|
+
const answer = await r.json();
|
|
91
|
+
text = r.ok ? answer.text : answer.error;
|
|
92
|
+
} catch { text = 'Could not load this log.'; }
|
|
93
|
+
if (n !== opened || !log.raw) return;
|
|
94
|
+
log.text = text;
|
|
95
|
+
drawLog();
|
|
96
|
+
if (log.following) toEnd();
|
|
97
|
+
}
|
|
94
98
|
|
|
95
99
|
function show(title, ref) {
|
|
96
100
|
opened++;
|
|
@@ -107,8 +111,8 @@ BoardView.part((B) => {
|
|
|
107
111
|
return opened;
|
|
108
112
|
}
|
|
109
113
|
function openLog(file) {
|
|
110
|
-
const n = show(`${file.split('-')[0]}
|
|
111
|
-
log = { file,
|
|
114
|
+
const n = show(`${file.split('-')[0]} transcript`, file);
|
|
115
|
+
log = { file, read: false, next: 0, rows: [], calls: new Map(), opened: new Set(), stream: false, finished: false, raw: false, text: null, following: true, unseen: 0 };
|
|
112
116
|
readLog(n);
|
|
113
117
|
}
|
|
114
118
|
async function openNotes(search) {
|
|
@@ -147,8 +151,14 @@ BoardView.part((B) => {
|
|
|
147
151
|
if (ev.target === sheet) { close(); return; }
|
|
148
152
|
const act = ev.target.closest('[data-reader]')?.dataset.reader;
|
|
149
153
|
if (act === 'close') close();
|
|
150
|
-
else if (act === 'raw') { log.raw = !log.raw; drawLog(); follow(); }
|
|
154
|
+
else if (act === 'raw') { log.raw = !log.raw; log.text = null; drawLog(); follow(); if (log.raw) readRaw(opened); }
|
|
151
155
|
else if (act === 'end') follow();
|
|
156
|
+
const call = ev.target.closest('[data-call]');
|
|
157
|
+
if (call) {
|
|
158
|
+
const i = Number(call.dataset.call);
|
|
159
|
+
if (!log.opened.delete(i)) log.opened.add(i);
|
|
160
|
+
drawLog();
|
|
161
|
+
}
|
|
152
162
|
const a = ev.target.closest('a[href]');
|
|
153
163
|
if (!a || !plainTap(ev) || (a.target && a.target !== '_self')) return;
|
|
154
164
|
ev.preventDefault();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// A card's thread on the board view: loading and rendering it, and its reply box. Needs app-core.js.
|
|
2
2
|
BoardView.part((B) => {
|
|
3
3
|
const { $, store, state, quieted, sendDraft, links } = B;
|
|
4
|
-
const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, visualButton, attachButton, doneButton, doneTone, waitOn, newQuestionsLine } = BoardUI;
|
|
4
|
+
const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, visualButton, attachButton, doneButton, doneTone, waitOn, newQuestionsLine, BUTTON } = BoardUI;
|
|
5
5
|
|
|
6
6
|
const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more', visual: 'visual-request' };
|
|
7
7
|
// A /prototype, /grill or /visual word in a message, the last one winning, and the message without it.
|
|
@@ -16,14 +16,18 @@ BoardView.part((B) => {
|
|
|
16
16
|
// A Todo card's reply box sends with Reply (intent: 'reply') or Implement (intent: 'build'), which is the card's build
|
|
17
17
|
// action: builder orange once the card is ready, quiet before. An In Review card's box has Done then Reply
|
|
18
18
|
// (ui-threads.js doneButton), Done in its tone. Any other card's box sends with Send, carrying no intent. A pair wraps
|
|
19
|
-
// onto its own line under the row.
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
19
|
+
// onto its own line under the row. Send or Reply is orange while a message clears the card's Action needed, as on a
|
|
20
|
+
// card the orchestrator stalled (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md).
|
|
21
|
+
const replyTone = (item) => (item?.resolvedBy === BUTTON.reply ? 'resolves' : undefined);
|
|
22
|
+
const sendsOf = (item) => [item?.lane === 'todo' ? (item.ready ? 'todo-ready' : 'todo')
|
|
23
|
+
: item?.lane === 'review' ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one', replyTone(item) && 'reply-resolves'].filter(Boolean).join(' ');
|
|
23
24
|
const pair = (kind, html) => `<span class="send-actions" data-sends="${kind}">${html}</span>`;
|
|
24
|
-
const sends = (kind, item) =>
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
const sends = (kind, item) => {
|
|
26
|
+
const reply = (label, attrs = '') => sendButton(label, 0, attrs, replyTone(item));
|
|
27
|
+
return kind.startsWith('one') ? `<span data-sends="${kind}">${reply('Send')}</span>`
|
|
28
|
+
: kind.startsWith('review') ? pair(kind, `${doneButton(item)}${reply('Reply', ' data-intent="reply"')}`)
|
|
29
|
+
: pair(kind, `${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${reply('Reply', ' data-intent="reply"')}`);
|
|
30
|
+
};
|
|
27
31
|
// The board, not the thread, decides the send buttons, so they follow each board load even when the thread is unchanged.
|
|
28
32
|
function syncSends(el, key) {
|
|
29
33
|
const at = el.querySelector('form [data-sends]');
|
|
@@ -140,8 +144,8 @@ BoardView.part((B) => {
|
|
|
140
144
|
const text = flag ? slashOf(ta.value).text : ta.value.trim();
|
|
141
145
|
const id = f.dataset.composer;
|
|
142
146
|
const files = BoardAttach.files(id, B.site.root);
|
|
143
|
-
// An empty box still sends Implement, the card's build action: the board server
|
|
144
|
-
//
|
|
147
|
+
// An empty box still sends Implement, the card's build action: the board server stages the card for the
|
|
148
|
+
// orchestrator's review (docs/adr/2026-11-06-0021-a-button-without-a-text-box-posts-a-keyed-status-line.md).
|
|
145
149
|
const intent = flag ? null : button.dataset.intent || null;
|
|
146
150
|
if ((!flag && !text && !files.length && intent !== 'build') || BoardAttach.busy(id, B.site.root)) return;
|
|
147
151
|
const key = f.dataset.reply;
|
package/board/public/app.html
CHANGED
|
@@ -22,7 +22,7 @@ window.BoardBoot = (function () {
|
|
|
22
22
|
const get = (u) => { const p = fetch(base + u, { cache: 'no-store' }).then((r) => (r.ok ? r.json().catch(() => { throw unanswered(r); }) : Promise.reject(unanswered(r)))); p.catch(() => {}); return p; };
|
|
23
23
|
let open = [];
|
|
24
24
|
try { open = JSON.parse(localStorage.getItem(`site:${route}:openItems`) || '[]'); } catch {}
|
|
25
|
-
const focus =
|
|
25
|
+
const focus = /[#&](?:selectedCard|item)=([^&]+)/.exec(location.hash);
|
|
26
26
|
if (focus) open.push(decodeURIComponent(focus[1]));
|
|
27
27
|
let since = null;
|
|
28
28
|
try { const c = JSON.parse(localStorage.getItem(`site:${route}:copyVersion`) || 'null'); if (c?.build === document.documentElement.dataset.build && Number.isInteger(c.version)) since = c.version; } catch {}
|
package/board/public/app.js
CHANGED
|
@@ -173,7 +173,7 @@ window.BoardView = (function () {
|
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
// The board itself, after every part: rendering, the
|
|
176
|
+
// The board itself, after every part: rendering, the URL fragment, loading and the change signal.
|
|
177
177
|
function start(B) {
|
|
178
178
|
const { $, $$, store, state, steady, markSeen } = B;
|
|
179
179
|
const { setHTML, preloadKeys } = BoardUI;
|
|
@@ -206,31 +206,41 @@ window.BoardView = (function () {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
// #review=<PR number> opens that review in the Auto Review lane (app-lanes.js openReview), as a held review's push does.
|
|
209
|
-
// #
|
|
209
|
+
// #selectedCard=<wave>/<id> (or the older #item=) opens that card, and &openItemsCard=<topic id> opens that pill in the pane; push notifications link here, with &reply to land in the thread's reply box
|
|
210
210
|
// (docs/adr/2026-10-29-1639-a-push-lands-where-the-owner-answers.md). An ask now in a work item's thread opens that item.
|
|
211
211
|
let focusKey = null;
|
|
212
212
|
let replyKey = null;
|
|
213
213
|
let hashRead = false;
|
|
214
214
|
// A board this page left goes back to where the owner left it once it first renders, unless its URL names a card.
|
|
215
|
-
const returnToPlace = () => B.restorePlace({ linked: /^#(?:item|review)=/.test(location.hash) });
|
|
215
|
+
const returnToPlace = () => B.restorePlace({ linked: /^#(?:selectedCard|item|review)=/.test(location.hash) });
|
|
216
|
+
// The pane's pill the fragment names opens once the pane has loaded; a phone has no pane, its pill is the Waiting on
|
|
217
|
+
// you page (shell.js go).
|
|
218
|
+
function openPill(id, tries = 50) {
|
|
219
|
+
if (!id || !matchMedia('(min-width: 900px)').matches || B.focusQuestion?.(id) || tries <= 0) return;
|
|
220
|
+
B.later(100, () => openPill(id, tries - 1));
|
|
221
|
+
}
|
|
216
222
|
function readHash() {
|
|
217
|
-
const review =
|
|
218
|
-
if (review && state.board) { B.openReview(
|
|
219
|
-
|
|
220
|
-
if (!
|
|
221
|
-
focusKey =
|
|
222
|
-
|
|
223
|
-
replyKey = m[2] ? focusKey : null;
|
|
223
|
+
const { selectedCard, openItemsCard, review, reply } = AppRouter.fragmentOf(location.hash);
|
|
224
|
+
if (review && state.board) { B.openReview(review); return; }
|
|
225
|
+
openPill(openItemsCard);
|
|
226
|
+
if (!selectedCard) return;
|
|
227
|
+
focusKey = BoardUI.record(state.board?.moved, selectedCard) || selectedCard;
|
|
228
|
+
replyKey = reply ? focusKey : null;
|
|
224
229
|
state.openItems.add(focusKey);
|
|
225
230
|
state.restored.delete(focusKey);
|
|
226
231
|
const it = state.board?.items.find((i) => i.key === focusKey);
|
|
227
232
|
if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); B.openBand({ key: focusKey }); }
|
|
228
233
|
if (it?.fold) store.set(B.FOLDS[it.fold].store, true);
|
|
229
234
|
}
|
|
235
|
+
// The owner's own moves rewrite the fragment in place, with no history entry and no hashchange.
|
|
236
|
+
B.saveFragment = (changes) => {
|
|
237
|
+
const hash = AppRouter.hashWith(location.hash, changes);
|
|
238
|
+
if (hash !== location.hash) history.replaceState(history.state, '', location.pathname + location.search + hash);
|
|
239
|
+
};
|
|
230
240
|
B.on(window, 'hashchange', () => { readHash(); if (state.board) { render(); scrollToFocus(); } });
|
|
231
241
|
// A card link whose hash is already the page's fires no hashchange, so its tap opens the card here.
|
|
232
242
|
B.on(B.root, 'click', (ev) => {
|
|
233
|
-
const a = ev.target.closest?.('a[href^="#item="]');
|
|
243
|
+
const a = ev.target.closest?.('a[href^="#item="], a[href^="#selectedCard="]');
|
|
234
244
|
if (!a || ev.defaultPrevented || a.getAttribute('href') !== location.hash || !state.board) return;
|
|
235
245
|
ev.preventDefault();
|
|
236
246
|
readHash(); render(); scrollToFocus();
|
|
@@ -239,7 +249,7 @@ window.BoardView = (function () {
|
|
|
239
249
|
function focusOnBoard({ key, slug }) {
|
|
240
250
|
const band = slug && state.board?.waveBands?.find((b) => b.slug === slug);
|
|
241
251
|
if (!band && !state.board?.items.some((i) => i.key === key)) return false;
|
|
242
|
-
if (key) { location.hash =
|
|
252
|
+
if (key) { location.hash = AppRouter.hashWith(location.hash, { selectedCard: key }); readHash(); } else B.openBand({ slug });
|
|
243
253
|
render();
|
|
244
254
|
if (key) scrollToFocus(); else $(`section.wb[data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
|
|
245
255
|
return true;
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
const { BUILD_PHASES, START_STEPS } = BoardUI;
|
|
16
16
|
const build = () => [...BUILD_PHASES];
|
|
17
17
|
return [
|
|
18
|
-
{ id: 'grill', name: 'Grilling', lane: 'todo', when: 'A grill planner reads a Todo card and asks its questions', steps:
|
|
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:
|
|
21
|
-
card: { builder: { planner: true, kind: 'prototype', progress: 'Prototyping · the takes', phase: null, at: ago(2) } } },
|
|
18
|
+
{ id: 'grill', name: 'Grilling', lane: 'todo', when: 'A grill planner reads a Todo card and asks its questions', steps: [],
|
|
19
|
+
card: { phase: 'grilling', 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: [],
|
|
21
|
+
card: { phase: 'prototyping', builder: { planner: true, kind: 'prototype', progress: 'Prototyping · the takes', phase: null, at: ago(2) } } },
|
|
22
22
|
{ id: 'build', name: 'Build', lane: 'progress', when: 'A builder works the card, step by step', steps: build(),
|
|
23
23
|
card: { builder: { progress: 'Testing · the suite', phase: 'testing', at: ago(2) } } },
|
|
24
24
|
{ id: 'check', name: 'Orchestrator review', lane: 'progress', when: 'The orchestrator checks the builder’s calls, on step 4', steps: build(),
|
|
@@ -205,25 +205,39 @@
|
|
|
205
205
|
const stage = document.getElementById('stage');
|
|
206
206
|
let at = { take: null, state: null };
|
|
207
207
|
let frame = null;
|
|
208
|
+
// The run the frame shows: its since from api/try/prototypes.
|
|
209
|
+
let framed = null;
|
|
208
210
|
let origin = null;
|
|
209
211
|
let started = false;
|
|
210
212
|
const serve = () => fetch(site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: 'prototypes' }) }).catch(() => {});
|
|
211
213
|
const post = () => frame.contentWindow.postMessage({ kickflip: 'prototype', ...at }, origin);
|
|
214
|
+
// Polls the serve while the page is open (docs/adr/2026-11-07-1231-a-base-move-restarts-the-prototype-serve-and-a-demo-hub-never-restarts-itself.md):
|
|
215
|
+
// a ready run the frame doesn't show, the first or a new one after a restart or a base move, gets a new frame at the
|
|
216
|
+
// current take and state; a serve that's down shows Starting. A poll that can't reach the board leaves the stage.
|
|
212
217
|
async function poll() {
|
|
213
|
-
let run
|
|
214
|
-
try { run = await (await fetch(site.url('api/try/prototypes'), { cache: 'no-store' })).json(); } catch {
|
|
218
|
+
let run;
|
|
219
|
+
try { run = await (await fetch(site.url('api/try/prototypes'), { cache: 'no-store' })).json(); } catch {
|
|
220
|
+
if (!frame) stage.innerHTML = servedStatus({});
|
|
221
|
+
wait(poll, 1500);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
215
224
|
if (run.state === 'ready' && run.https) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
+
if (!frame || (run.since ?? null) !== framed) {
|
|
226
|
+
const src = servedSrc({ https: run.https, path: data.served, ...at });
|
|
227
|
+
origin = new URL(src).origin;
|
|
228
|
+
framed = run.since ?? null;
|
|
229
|
+
frame = document.createElement('iframe');
|
|
230
|
+
frame.className = 'kit-served';
|
|
231
|
+
frame.title = data.title;
|
|
232
|
+
frame.src = src;
|
|
233
|
+
// A switch made while the page loaded reaches it once it has.
|
|
234
|
+
nativeAdd.call(frame, 'load', post);
|
|
235
|
+
stage.replaceChildren(frame);
|
|
236
|
+
}
|
|
237
|
+
wait(poll, 3000);
|
|
225
238
|
return;
|
|
226
239
|
}
|
|
240
|
+
frame = null;
|
|
227
241
|
stage.innerHTML = servedStatus(run);
|
|
228
242
|
// A serve that's down is started once from here; a failed one waits for Try again.
|
|
229
243
|
if (!run.state && !started) { started = true; serve(); }
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
// The full-bleed prototype viewer's
|
|
1
|
+
// The full-bleed prototype viewer's controls (docs/adr/2026-10-29-1639-prototypes-come-on-request.md,
|
|
2
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
|
+
// docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md,
|
|
4
|
+
// docs/adr/2026-11-07-2030-on-desktop-the-prototype-viewer-opens-in-the-right-pane.md).
|
|
4
5
|
// Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
|
|
5
6
|
// const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, states: { empty: 'No cards', … }?, initial: 'A', render(take, state) {…}, above: el? })
|
|
6
7
|
// 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
8
|
// (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
9
|
// 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
|
|
10
|
-
//
|
|
10
|
+
// right goes back as its back arrow does, and the footer, full width on the bottom edge at every width, holds the
|
|
11
|
+
// ‹ take › pager. Framed in the board's right pane, the pane's top bar takes the ✕'s and the Round pill's place. ←/→
|
|
12
|
+
// and a sideways swipe switch unless a field has focus, and the take stays in ?variant=. With ?q=<owner question id>, the bar's second row offers
|
|
11
13
|
// 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
14
|
// the note typed above the bar, in the {choice, note} questions.html keeps for that question, and goes back to the
|
|
13
15
|
// question, whose Send sends it. A card with more than one prototype round gets a Round pill at the top left, which
|
|
@@ -65,20 +67,6 @@
|
|
|
65
67
|
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>` : ''}`;
|
|
66
68
|
}
|
|
67
69
|
|
|
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
70
|
// 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
71
|
// 50px sideways, more down than across, or starting at the left edge, where the drawer's and the browser's swipes are.
|
|
84
72
|
const swipeStep = (start, end) => {
|
|
@@ -88,10 +76,13 @@
|
|
|
88
76
|
|
|
89
77
|
const CSS = `
|
|
90
78
|
body > header.top { display:none; }
|
|
79
|
+
/* style.css sets --app-bottom on a phone for the bar the header becomes; hidden here, it leaves nothing to clear. */
|
|
80
|
+
:root { --app-bottom:0px !important; }
|
|
91
81
|
main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
92
82
|
.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
83
|
align-items:flex-start; pointer-events:none; font:600 13px -apple-system, system-ui, sans-serif; }
|
|
94
84
|
.ps-top > * { pointer-events:auto; }
|
|
85
|
+
.ps-pending { visibility:hidden; }
|
|
95
86
|
.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
87
|
.ps-chip { border-radius:999px; padding:0 14px; min-height:40px; }
|
|
97
88
|
.ps-close { margin-left:auto; width:40px; height:40px; border-radius:50%; font-size:17px; line-height:1; }
|
|
@@ -101,11 +92,10 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
101
92
|
.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
93
|
padding:12px 2px; font:inherit; font-weight:500; text-align:left; cursor:pointer; }
|
|
103
94
|
.ps-sheet button span + span { opacity:.7; }
|
|
104
|
-
.ps-switch { position:fixed; left:
|
|
105
|
-
|
|
106
|
-
.ps-switch > * {
|
|
107
|
-
.ps-pill { display:flex; flex-direction:column; gap: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; }
|
|
95
|
+
.ps-switch { position:fixed; left:0; right:0; bottom:var(--app-bottom, 0px); z-index:30; display:flex; flex-direction:column; align-items:stretch;
|
|
96
|
+
background:#202026; border-top:1px solid #fff3; padding:6px 6px calc(6px + env(safe-area-inset-bottom)); box-sizing:border-box; }
|
|
97
|
+
.ps-switch > * { min-width:0; box-sizing:border-box; }
|
|
98
|
+
.ps-pill { display:flex; flex-direction:column; gap:6px; border-radius:0; font:600 13px -apple-system, system-ui, sans-serif; }
|
|
109
99
|
.ps-row { display:flex; align-items:center; gap:2px; min-width:0; }
|
|
110
100
|
.ps-pill button { color:#fff; background:transparent; border:0; border-radius:999px; padding:7px 11px; font:inherit; white-space:nowrap; cursor:pointer; }
|
|
111
101
|
.ps-pill button { flex-shrink:0; min-width:44px; min-height:44px; }
|
|
@@ -115,14 +105,23 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
115
105
|
.ps-pill .ps-states button { background:#fff1; font-weight:500; }
|
|
116
106
|
.ps-pill .ps-states button[aria-pressed="true"] { background:#fff; color:#000; font-weight:700; }
|
|
117
107
|
.ps-pill .ps-pick { background:var(--accent, #fff); color:var(--on-accent, #000); font-weight:700; }
|
|
118
|
-
.ps-closed { padding:
|
|
119
|
-
.ps-switch .ps-text {
|
|
120
|
-
padding:7px 10px; font:400 16px -apple-system, system-ui, sans-serif;
|
|
108
|
+
.ps-closed { box-sizing:border-box; min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 11px; border-radius:999px; color:#fffa; background:#fff1; white-space:nowrap; }
|
|
109
|
+
.ps-switch .ps-text { margin-bottom:6px; resize:none; border-radius:12px; border:1px solid #fff3; background:#000d; color:#fff;
|
|
110
|
+
padding:7px 10px; font:400 16px -apple-system, system-ui, sans-serif; }
|
|
121
111
|
.ps-switch .ps-text::placeholder { color:#fffa; }
|
|
122
112
|
.ps-switch .ps-text[hidden] { display:none; }
|
|
123
|
-
.ps-picked { padding:
|
|
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; }
|
|
125
|
-
.ps-note[hidden] { display:none; }
|
|
113
|
+
.ps-picked { box-sizing:border-box; min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 11px; border-radius:999px; color:#fff; background:#1f9d55; white-space:nowrap; }
|
|
114
|
+
.ps-note { margin-bottom:6px; background:#000d; color:#fff; border-radius:8px; padding:5px 10px; font:500 12px -apple-system, system-ui, sans-serif; text-align:center; }
|
|
115
|
+
.ps-note[hidden] { display:none; }
|
|
116
|
+
@media (min-width: 900px) {
|
|
117
|
+
.ps-switch { align-items:center; }
|
|
118
|
+
.ps-pill { width:min(640px, 100%); }
|
|
119
|
+
.ps-switch .ps-text { width:min(640px, 100%); }
|
|
120
|
+
.ps-states { flex-wrap:wrap; overflow-x:visible; }
|
|
121
|
+
}
|
|
122
|
+
.ps-pane main.proto-main { padding-top:0; }
|
|
123
|
+
.ps-pane .ps-top { display:none; }
|
|
124
|
+
.ps-pane .ps-states { flex-wrap:wrap; overflow-x:visible; justify-content:center; }`;
|
|
126
125
|
|
|
127
126
|
const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
|
|
128
127
|
|
|
@@ -133,6 +132,8 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
133
132
|
const qid = params.get('q');
|
|
134
133
|
const site = globalThis.ClaudeSite;
|
|
135
134
|
if (!document.getElementById('ps-style')) document.head.insertAdjacentHTML('beforeend', `<style id="ps-style">${CSS}</style>`);
|
|
135
|
+
// Framed in the board's right pane (app-needs.js openViewer), whose top bar holds the way back and the Round pill.
|
|
136
|
+
if (window.frameElement?.closest('.needs-pane')) document.documentElement.classList.add('ps-pane');
|
|
136
137
|
let take = startTake(keys, params.get('variant'), initial);
|
|
137
138
|
let question = null;
|
|
138
139
|
// A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a reload stays on the
|
|
@@ -144,18 +145,23 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
144
145
|
box.className = 'ps-switch';
|
|
145
146
|
box.innerHTML = '<div class="ps-note" role="status" hidden></div><textarea class="ps-text" rows="2" placeholder="Note with your pick (optional)" aria-label="Note with your pick" hidden></textarea><div class="ps-pill"></div>';
|
|
146
147
|
if (above) box.prepend(above);
|
|
148
|
+
box.classList.add('ps-pending');
|
|
147
149
|
document.body.append(box);
|
|
150
|
+
// The footer's height, so the page above it ends where it starts (prototype.html reads --ps-footer-h).
|
|
151
|
+
const measure = () => document.documentElement.style.setProperty('--ps-footer-h', `${box.offsetHeight}px`);
|
|
152
|
+
new ResizeObserver(measure).observe(box);
|
|
148
153
|
const pill = box.querySelector('.ps-pill');
|
|
149
154
|
const noteEl = box.querySelector('.ps-note');
|
|
150
155
|
const text = box.querySelector('.ps-text');
|
|
151
156
|
// The ✕ and, on a card with earlier rounds, the Round pill and its sheet. boardCard: /api/board's card the question is on.
|
|
152
157
|
const top = document.createElement('div');
|
|
153
|
-
top.className = 'ps-top';
|
|
158
|
+
top.className = 'ps-top ps-pending';
|
|
154
159
|
document.body.append(top);
|
|
155
160
|
let boardCard = null;
|
|
156
161
|
let sheet = false;
|
|
157
162
|
const paintTop = () => {
|
|
158
163
|
const rounds = boardCard?.prototypes?.rounds;
|
|
164
|
+
const { roundPill, roundSheet } = globalThis.BoardUI;
|
|
159
165
|
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
166
|
};
|
|
161
167
|
paintTop();
|
|
@@ -165,7 +171,7 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
165
171
|
function paint() {
|
|
166
172
|
const now = state();
|
|
167
173
|
pill.innerHTML = pillHtml({ name: takes[take], pick: now, states, state: shownState });
|
|
168
|
-
text.hidden = !(now.kind === 'pick' || now.kind === 'change');
|
|
174
|
+
text.hidden = !(now.kind === 'pick' || now.kind === 'change' || now.kind === 'picked');
|
|
169
175
|
}
|
|
170
176
|
function go(next) {
|
|
171
177
|
take = next;
|
|
@@ -181,7 +187,20 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
181
187
|
render(take, shownState);
|
|
182
188
|
}
|
|
183
189
|
|
|
190
|
+
// The footer and the Round pill stay hidden until the first load settles (or 1.5s pass), so they appear complete in one paint.
|
|
191
|
+
let revealed = false;
|
|
192
|
+
const reveal = () => {
|
|
193
|
+
if (revealed) return;
|
|
194
|
+
revealed = true;
|
|
195
|
+
measure();
|
|
196
|
+
box.classList.remove('ps-pending');
|
|
197
|
+
top.classList.remove('ps-pending');
|
|
198
|
+
};
|
|
199
|
+
setTimeout(reveal, 1500);
|
|
184
200
|
async function load() {
|
|
201
|
+
try { await fetchQuestion(); } finally { reveal(); }
|
|
202
|
+
}
|
|
203
|
+
async function fetchQuestion() {
|
|
185
204
|
if (!qid || !site) return;
|
|
186
205
|
try {
|
|
187
206
|
const board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
|
|
@@ -197,7 +216,7 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
197
216
|
} catch { /* keep the last question shown */ }
|
|
198
217
|
if (fromPush && pickedChoice(question)) {
|
|
199
218
|
const card = /^item:(.+)$/.exec(question.topic || '')?.[1];
|
|
200
|
-
replaceWith(site.url(card ? `#
|
|
219
|
+
replaceWith(site.url(card ? `#selectedCard=${encodeURIComponent(card)}&reply` : 'questions.html'));
|
|
201
220
|
return;
|
|
202
221
|
}
|
|
203
222
|
if (question && !text.value) text.value = readDraft(qid).note || '';
|
|
@@ -255,9 +274,10 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
255
274
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
|
|
256
275
|
|
|
257
276
|
go(take);
|
|
277
|
+
measure();
|
|
258
278
|
load();
|
|
259
279
|
return { takes: keys, get take() { return take; }, get state() { return shownState; }, go, show };
|
|
260
280
|
}
|
|
261
281
|
|
|
262
|
-
globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml,
|
|
282
|
+
globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, swipeStep, mount };
|
|
263
283
|
})();
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
<link rel="stylesheet" href="sidebar.css">
|
|
11
11
|
<style>
|
|
12
12
|
/* The one page every recorded prototype is (board/server.mjs fills the {{…}} slots; prototype-kit.js documents what a
|
|
13
|
-
take gets). main keeps room below the stage for the viewer's
|
|
14
|
-
|
|
13
|
+
take gets). main keeps room below the stage for the viewer's footer, at its measured height (prototype-switcher.js),
|
|
14
|
+
which already holds the bottom inset. */
|
|
15
|
+
main.proto-main { max-width: none; padding-left: 0; padding-right: 0; padding-bottom: calc(var(--ps-footer-h, 150px) + var(--app-bottom, 0px)); }
|
|
15
16
|
/* kit.hub() is a whole page with its own gutter and header, so it goes edge to edge. */
|
|
16
17
|
main.proto-main:has(> #stage > .kit-hub) { padding-top: 0; }
|
|
17
18
|
/* contain and the transform make the stage the containing block of everything in it, position: fixed included, and
|
|
@@ -20,9 +21,11 @@
|
|
|
20
21
|
#stage:has(> .kit-hub) { padding: 0; }
|
|
21
22
|
#stage .top { position: static; }
|
|
22
23
|
#stage .hub-view main { padding-bottom: 16px; }
|
|
23
|
-
/* A served round's page, framed from the board's base prototype worktree, edge to edge
|
|
24
|
+
/* A served round's page, framed from the board's base prototype worktree, edge to edge from under the ✕ row to the
|
|
25
|
+
viewer's footer; in the board's right pane (.ps-pane) there is no ✕ row, so it starts at the top. */
|
|
24
26
|
#stage:has(> .kit-served) { max-width: none; padding: 0; }
|
|
25
|
-
.kit-served { display: block; width: 100%; min-height: 320px; height: calc(100dvh -
|
|
27
|
+
.kit-served { display: block; width: 100%; min-height: 320px; height: calc(100dvh - 60px - var(--ps-footer-h, 150px) - var(--app-bottom, 0px) - env(safe-area-inset-top)); border: 0; background: var(--panel); }
|
|
28
|
+
.ps-pane .kit-served { height: calc(100dvh - var(--ps-footer-h, 150px)); }
|
|
26
29
|
.kit-served-failed pre { margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
|
|
27
30
|
.kit-missing { margin: 0; padding: 16px; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
|
28
31
|
#protoToast { position: fixed; z-index: 40; left: 50%; top: calc(64px + env(safe-area-inset-top)); transform: translateX(-50%); max-width: calc(100vw - 32px); }
|