@lovek4d/kickflip 0.6.1 → 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 +29 -17
- package/SKILL.md +12 -8
- package/bin/event-lines.mjs +1 -0
- 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 +46 -32
- 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 +13 -4
- package/board/public/app-lanes.js +2 -2
- package/board/public/app-needs.js +107 -26
- package/board/public/app-reader.js +52 -42
- package/board/public/app-threads.js +2 -2
- 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 +47 -32
- package/board/public/prototype.html +7 -4
- package/board/public/router.js +34 -6
- package/board/public/shell.js +19 -13
- package/board/public/sidebar.css +2 -2
- package/board/public/style.css +58 -11
- package/board/public/ui-cards.js +44 -27
- package/board/public/ui-enums.js +10 -1
- package/board/public/ui-links.js +1 -1
- package/board/public/ui-order.js +8 -5
- package/board/public/ui-progress-bar.js +6 -7
- package/board/public/ui-questions.js +18 -3
- package/board/public/ui-sidebar.js +3 -2
- package/board/public/ui-threads.js +10 -12
- package/board/public/ui.js +4 -2
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +18 -2
- package/board/public/view-settings.js +60 -28
- package/board/server.mjs +19 -20
- package/board/stop.mjs +1 -1
- package/board/store.mjs +25 -25
- package/board/tickets.mjs +1 -1
- package/board/transcript.mjs +4 -2
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +6 -6
- package/hub/try.mjs +6 -6
- package/hub/upgrade.mjs +1 -1
- package/package.json +1 -1
- package/shared/tailscale.mjs +1 -1
|
@@ -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,19 +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; }
|
|
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; }
|
|
125
115
|
.ps-note[hidden] { display:none; }
|
|
126
116
|
@media (min-width: 900px) {
|
|
117
|
+
.ps-switch { align-items:center; }
|
|
127
118
|
.ps-pill { width:min(640px, 100%); }
|
|
128
119
|
.ps-switch .ps-text { width:min(640px, 100%); }
|
|
129
120
|
.ps-states { flex-wrap:wrap; overflow-x:visible; }
|
|
130
|
-
}
|
|
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; }`;
|
|
131
125
|
|
|
132
126
|
const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
|
|
133
127
|
|
|
@@ -138,6 +132,8 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
138
132
|
const qid = params.get('q');
|
|
139
133
|
const site = globalThis.ClaudeSite;
|
|
140
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');
|
|
141
137
|
let take = startTake(keys, params.get('variant'), initial);
|
|
142
138
|
let question = null;
|
|
143
139
|
// A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a reload stays on the
|
|
@@ -149,18 +145,23 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
149
145
|
box.className = 'ps-switch';
|
|
150
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>';
|
|
151
147
|
if (above) box.prepend(above);
|
|
148
|
+
box.classList.add('ps-pending');
|
|
152
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);
|
|
153
153
|
const pill = box.querySelector('.ps-pill');
|
|
154
154
|
const noteEl = box.querySelector('.ps-note');
|
|
155
155
|
const text = box.querySelector('.ps-text');
|
|
156
156
|
// The ✕ and, on a card with earlier rounds, the Round pill and its sheet. boardCard: /api/board's card the question is on.
|
|
157
157
|
const top = document.createElement('div');
|
|
158
|
-
top.className = 'ps-top';
|
|
158
|
+
top.className = 'ps-top ps-pending';
|
|
159
159
|
document.body.append(top);
|
|
160
160
|
let boardCard = null;
|
|
161
161
|
let sheet = false;
|
|
162
162
|
const paintTop = () => {
|
|
163
163
|
const rounds = boardCard?.prototypes?.rounds;
|
|
164
|
+
const { roundPill, roundSheet } = globalThis.BoardUI;
|
|
164
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 }) : ''}`;
|
|
165
166
|
};
|
|
166
167
|
paintTop();
|
|
@@ -186,7 +187,20 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
186
187
|
render(take, shownState);
|
|
187
188
|
}
|
|
188
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);
|
|
189
200
|
async function load() {
|
|
201
|
+
try { await fetchQuestion(); } finally { reveal(); }
|
|
202
|
+
}
|
|
203
|
+
async function fetchQuestion() {
|
|
190
204
|
if (!qid || !site) return;
|
|
191
205
|
try {
|
|
192
206
|
const board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
|
|
@@ -202,7 +216,7 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
202
216
|
} catch { /* keep the last question shown */ }
|
|
203
217
|
if (fromPush && pickedChoice(question)) {
|
|
204
218
|
const card = /^item:(.+)$/.exec(question.topic || '')?.[1];
|
|
205
|
-
replaceWith(site.url(card ? `#
|
|
219
|
+
replaceWith(site.url(card ? `#selectedCard=${encodeURIComponent(card)}&reply` : 'questions.html'));
|
|
206
220
|
return;
|
|
207
221
|
}
|
|
208
222
|
if (question && !text.value) text.value = readDraft(qid).note || '';
|
|
@@ -260,9 +274,10 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
260
274
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
|
|
261
275
|
|
|
262
276
|
go(take);
|
|
277
|
+
measure();
|
|
263
278
|
load();
|
|
264
279
|
return { takes: keys, get take() { return take; }, get state() { return shownState; }, go, show };
|
|
265
280
|
}
|
|
266
281
|
|
|
267
|
-
globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml,
|
|
282
|
+
globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, swipeStep, mount };
|
|
268
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); }
|
package/board/public/router.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ui.test.mjs runs them in node:vm. listen() catches clicks on links to a view the shell mounts, and back and forward.
|
|
4
4
|
// A view is {kind, …}:
|
|
5
5
|
// start `/`, which the shell turns into the last board
|
|
6
|
-
// board {route, hash} /<route>/, hash such as #item=<key>&reply
|
|
6
|
+
// board {route, hash} /<route>/, hash such as #selectedCard=<key>&openItemsCard=<topic id>, or the older #item=<key>&reply
|
|
7
7
|
// settings {route} /<route>/settings.html, a request to open the modal over that board
|
|
8
8
|
// questions {route, id} /<route>/questions.html#<id>
|
|
9
9
|
// notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<lineup slug>, and plan.html, which the board
|
|
@@ -45,6 +45,7 @@ globalThis.AppRouter = (function () {
|
|
|
45
45
|
if (page === 'questions.html') {
|
|
46
46
|
let id = null;
|
|
47
47
|
try { id = u.hash.length > 1 ? decodeURIComponent(u.hash.slice(1)) : null; } catch { id = u.hash.slice(1); }
|
|
48
|
+
if (id?.startsWith('openItemsCard=')) id = id.slice('openItemsCard='.length);
|
|
48
49
|
return { kind: 'questions', route, id };
|
|
49
50
|
}
|
|
50
51
|
if (page === 'notes.html' || page === 'plan.html') {
|
|
@@ -80,10 +81,10 @@ globalThis.AppRouter = (function () {
|
|
|
80
81
|
|
|
81
82
|
// The page tree (docs/adr/2026-10-30-1125-back-follows-the-page-tree.md): a board, its Waiting on you, and a prototype page tied
|
|
82
83
|
// to one of its questions by ?q=, framed. levelOf → 0, 1 or 2 down the tree, or null for a view outside it.
|
|
84
|
+
// A board's prototype page, which from 900px opens in the board's right pane (shell.js go).
|
|
85
|
+
const isPrototype = (view, boards) => view?.kind === 'frame' && boards.has(view.route) && /^\/[^/]+\/prototype-[\w-]+\.html$/.test(new URL(view.url, 'http://hub').pathname);
|
|
83
86
|
function questionOf(view, boards) {
|
|
84
|
-
|
|
85
|
-
const u = new URL(view.url, 'http://hub');
|
|
86
|
-
return /^\/[^/]+\/prototype-[\w-]+\.html$/.test(u.pathname) ? u.searchParams.get('q') || null : null;
|
|
87
|
+
return isPrototype(view, boards) ? new URL(view.url, 'http://hub').searchParams.get('q') || null : null;
|
|
87
88
|
}
|
|
88
89
|
// wide: from 900px Waiting on you is the board's pane, so a prototype's parent is the board itself.
|
|
89
90
|
function levelOf(view, { boards = new Set(), wide = false } = {}) {
|
|
@@ -96,7 +97,7 @@ globalThis.AppRouter = (function () {
|
|
|
96
97
|
function parentsOf(view, { boards = new Set(), card = null, wide = false } = {}) {
|
|
97
98
|
const q = questionOf(view, boards);
|
|
98
99
|
if (!q && view?.kind !== 'questions') return [];
|
|
99
|
-
const board = { kind: 'board', route: view.route, hash: card ? `#
|
|
100
|
+
const board = { kind: 'board', route: view.route, hash: card ? `#selectedCard=${encodeURIComponent(card)}` : '' };
|
|
100
101
|
return q && !wide ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -224,5 +225,32 @@ globalThis.AppRouter = (function () {
|
|
|
224
225
|
return { push: (href, from) => write('push', href, from), replace: (href) => write('replace', href), up };
|
|
225
226
|
}
|
|
226
227
|
|
|
227
|
-
|
|
228
|
+
// A board's fragment is &-joined keys: selectedCard=<key> (the focused card), openItemsCard=<topic id> (the selected
|
|
229
|
+
// open-items pill), review=<PR number>, and a bare reply. The older #item=<key> reads as selectedCard.
|
|
230
|
+
// → {selectedCard, openItemsCard, review, reply}, each null or false when absent.
|
|
231
|
+
function fragmentOf(hash) {
|
|
232
|
+
const out = { selectedCard: null, openItemsCard: null, review: null, reply: false };
|
|
233
|
+
const decode = (v) => { try { return decodeURIComponent(v); } catch { return v; } };
|
|
234
|
+
for (const part of String(hash).replace(/^#/, '').split('&')) {
|
|
235
|
+
const eq = part.indexOf('=');
|
|
236
|
+
const [k, v] = eq < 0 ? [part, ''] : [part.slice(0, eq), part.slice(eq + 1)];
|
|
237
|
+
if (k === 'selectedCard' || k === 'item') out.selectedCard = decode(v) || null;
|
|
238
|
+
else if (k === 'openItemsCard') out.openItemsCard = decode(v) || null;
|
|
239
|
+
else if (k === 'review' && /^\d+$/.test(v)) out.review = Number(v);
|
|
240
|
+
else if (k === 'reply') out.reply = true;
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
// hash with the named keys of changes replaced (null drops one) → the new fragment, '' for none. A #review= link is
|
|
245
|
+
// alone in its fragment, so a change to a card or pill replaces it; reply is spent once the card is shown.
|
|
246
|
+
function hashWith(hash, changes) {
|
|
247
|
+
const now = fragmentOf(hash);
|
|
248
|
+
const next = { selectedCard: now.selectedCard, openItemsCard: now.openItemsCard, ...changes };
|
|
249
|
+
const parts = [];
|
|
250
|
+
if (next.selectedCard) parts.push(`selectedCard=${encodeURIComponent(next.selectedCard)}`);
|
|
251
|
+
if (next.openItemsCard) parts.push(`openItemsCard=${encodeURIComponent(next.openItemsCard)}`);
|
|
252
|
+
return parts.length ? '#' + parts.join('&') : '';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { viewOf, urlOf, mounted, entryOf, isPrototype, listen, fragmentOf, hashWith };
|
|
228
256
|
})();
|
package/board/public/shell.js
CHANGED
|
@@ -246,24 +246,30 @@ window.AppShell = (function () {
|
|
|
246
246
|
// Board and Waiting on you are a board's two pages (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md):
|
|
247
247
|
// from 900px the board shows Waiting on you in its pane, so every view of the questions page, a link, a push, a
|
|
248
248
|
// hand-off, a history entry or a cold load, shows the board at its own URL and opens the question's topic there (id,
|
|
249
|
-
// or null for none). A board
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
249
|
+
// or null for none). A board's prototype page does the same and opens in the pane
|
|
250
|
+
// (docs/adr/2026-11-07-2030-on-desktop-the-prototype-viewer-opens-in-the-right-pane.md).
|
|
251
|
+
const inPane = (view) => wide.matches && (view?.kind === 'questions' || AppRouter.isPrototype(view, boards()));
|
|
252
|
+
// ask(B) → true once the board's pane did what the view asks. A board just shown takes a moment to fill its pane, so
|
|
253
|
+
// the pane is asked again until it has.
|
|
254
|
+
const paneAsk = (view, href) => (view.kind === 'questions' ? (B) => B.focusQuestion?.(view.id) : (B) => B.openViewer?.(href));
|
|
255
|
+
function askPane(ask, tries = 50) {
|
|
256
|
+
if (current?.view.kind !== 'board' || ask(current.v.B) || tries <= 0) return;
|
|
257
|
+
setTimeout(() => askPane(ask, tries - 1), 100);
|
|
253
258
|
}
|
|
254
259
|
function go(view, href, how, from) {
|
|
255
260
|
if (!AppRouter.mounted(view)) return false;
|
|
256
|
-
if (view
|
|
261
|
+
if (inPane(view)) {
|
|
262
|
+
const ask = paneAsk(view, href);
|
|
257
263
|
const board = { kind: 'board', route: view.route, hash: '' };
|
|
258
264
|
const boardHref = new URL(AppRouter.urlOf(board), location.href).href;
|
|
259
265
|
if (how === 'pop') history.replaceState(history.state, '', boardHref);
|
|
260
|
-
// On its own board it only
|
|
266
|
+
// On its own board it only asks the pane, so the URL, history and an open card stay as they are.
|
|
261
267
|
else if (current?.view.kind === 'board' && current.route === view.route && !settings) {
|
|
262
|
-
|
|
268
|
+
askPane(ask);
|
|
263
269
|
sidebar.close();
|
|
264
270
|
return true;
|
|
265
271
|
}
|
|
266
|
-
return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown)
|
|
272
|
+
return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown) askPane(ask); return shown; });
|
|
267
273
|
}
|
|
268
274
|
if (view.kind === 'settings') {
|
|
269
275
|
if (current?.view.kind === 'board' && current.route === view.route) {
|
|
@@ -317,15 +323,15 @@ window.AppShell = (function () {
|
|
|
317
323
|
if (!AppRouter.mounted(first)) { location.replace('/_hub/'); return; }
|
|
318
324
|
|
|
319
325
|
let pane = null;
|
|
320
|
-
if (first
|
|
321
|
-
pane =
|
|
326
|
+
if (inPane(first)) {
|
|
327
|
+
pane = paneAsk(first, location.href);
|
|
322
328
|
first = { kind: 'board', route: first.route, hash: '' };
|
|
323
329
|
history.replaceState(history.state, '', AppRouter.urlOf(first));
|
|
324
330
|
}
|
|
325
331
|
router = AppRouter.listen({ boards, go, wide: () => wide.matches });
|
|
326
|
-
// Widening past 900px on Waiting on you swaps to the board with
|
|
332
|
+
// Widening past 900px on Waiting on you or a prototype swaps to the board with it open in the pane.
|
|
327
333
|
wide.addEventListener('change', () => {
|
|
328
|
-
if (
|
|
334
|
+
if (current && inPane(current.view)) go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'replace');
|
|
329
335
|
});
|
|
330
336
|
opener = (url, { replace = false, frames = true } = {}) => {
|
|
331
337
|
const view = AppRouter.viewOf(url, { boards: boards() });
|
|
@@ -342,7 +348,7 @@ window.AppShell = (function () {
|
|
|
342
348
|
navigator.serviceWorker?.startMessages?.();
|
|
343
349
|
|
|
344
350
|
show(first);
|
|
345
|
-
if (pane)
|
|
351
|
+
if (pane) askPane(pane);
|
|
346
352
|
openStream();
|
|
347
353
|
addEventListener('visibilitychange', () => { if (!document.hidden) openStream(); });
|
|
348
354
|
addEventListener('pageshow', openStream);
|
package/board/public/sidebar.css
CHANGED
|
@@ -173,12 +173,12 @@ html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-
|
|
|
173
173
|
.sidebar .sb-ctx.stale { opacity: .6; }
|
|
174
174
|
.sidebar .lcs { order: -1; flex: 1 1 100%; min-width: 0; display: flex; flex-wrap: wrap; gap: 4px; }
|
|
175
175
|
.sidebar .lc { flex: none; font-size: 11px; font-weight: 600; border-radius: 6px; padding: 0 6px; line-height: 18px; color: var(--tone); background: color-mix(in srgb, var(--tone) 14%, transparent); }
|
|
176
|
-
.sidebar .lc-review { --tone: var(--accent); } .sidebar .lc-progress { --tone: var(--color-notice); } .sidebar .lc-todo { --tone: var(--muted); }
|
|
176
|
+
.sidebar .lc-needs { --tone: var(--color-danger); } .sidebar .lc-review { --tone: var(--accent); } .sidebar .lc-progress { --tone: var(--color-notice); } .sidebar .lc-todo { --tone: var(--muted); }
|
|
177
177
|
.sidebar .dot { flex: none; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--tone); }
|
|
178
178
|
@keyframes sb-pulse { 50% { opacity: .35; } }
|
|
179
179
|
@media (prefers-reduced-motion: no-preference) { .sidebar .dot.pulse { animation: sb-pulse 1.4s ease-in-out infinite; } }
|
|
180
180
|
.sidebar .word { flex: none; font-weight: 600; color: var(--tone); }
|
|
181
|
-
.sidebar .t-down
|
|
181
|
+
.sidebar .t-down { --tone: var(--color-danger); }
|
|
182
182
|
.sidebar .t-starting { --tone: var(--color-notice); }
|
|
183
183
|
.sidebar .t-stopped, .sidebar .t-asleep { --tone: var(--muted); }
|
|
184
184
|
.sidebar .t-working { --tone: var(--color-notice); }
|
package/board/public/style.css
CHANGED
|
@@ -84,11 +84,17 @@ button.chip { border: 0; }
|
|
|
84
84
|
.sub.next { color: var(--color-notice); }
|
|
85
85
|
.card-hold { color: var(--color-quiet); }
|
|
86
86
|
.card-blocked, .card-action { color: var(--color-notice); }
|
|
87
|
-
.card
|
|
88
|
-
.card-
|
|
87
|
+
/* docs/adr/2026-11-11-1200-every-lane-has-phases.md: a card's phase word, toned by who acts next (ui-cards.js PHASE_LOOK). */
|
|
88
|
+
.card-phase.ph-quiet { color: var(--color-quiet); }
|
|
89
|
+
.card-phase.ph-info { color: var(--color-info); }
|
|
90
|
+
.card-phase.ph-notice { color: var(--color-notice); }
|
|
91
|
+
.card-phase.ph-success { color: var(--color-success); }
|
|
92
|
+
.card-phase.ph-merged { color: var(--color-merged); }
|
|
89
93
|
/* The footer status of a stale card, and of one the orchestrator stalled (ui-cards.js timeLine). */
|
|
90
94
|
.card-stale { color: var(--muted); }
|
|
91
95
|
.card-stalled { color: var(--color-danger); }
|
|
96
|
+
/* A bumped-out card's footer status, and a bumped ready card's while the host holds new work (ui-cards.js timeLine). */
|
|
97
|
+
.card-bumped-out, .card-host-held { color: var(--muted); }
|
|
92
98
|
.sub.next.idle { color: var(--muted); }
|
|
93
99
|
.sub.refused { color: var(--color-danger); }
|
|
94
100
|
.toast-stack { position: fixed; left: 50%; bottom: calc(var(--app-bottom, 0px) + 12px + env(safe-area-inset-bottom));
|
|
@@ -383,10 +389,14 @@ button.attach:disabled { opacity: .35; }
|
|
|
383
389
|
/* The session's working line, "thinking", is the owner's blue with one font, size, weight and pulse on the card and in the thread. */
|
|
384
390
|
.thinking { color: var(--accent); font-size: 12px; font-weight: 600; }
|
|
385
391
|
.thinking .working-dot { background: currentColor; }
|
|
392
|
+
/* A New card's "waiting", until the orchestrator picks it up: grey, its dot still. */
|
|
393
|
+
.pickup-wait { color: var(--muted); font-size: 12px; font-weight: 600; }
|
|
394
|
+
.pickup-wait .working-dot { background: currentColor; animation: none; }
|
|
386
395
|
.msg.link { padding: 0 10px; margin: 4px 0; font-size: 13px; }
|
|
387
396
|
.msg.builder-status { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--color-notice); overflow-wrap: anywhere; }
|
|
388
397
|
.msg.builder-status .working-dot { animation: none; }
|
|
389
398
|
.msg.request { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
|
399
|
+
.msg.request.readied { color: var(--color-success); }
|
|
390
400
|
.msg.agent { padding: 0 10px; margin: 4px 0; font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
|
|
391
401
|
.msg.agent.started { color: var(--color-notice); } .msg.agent.finished { color: var(--color-success); }
|
|
392
402
|
.msg.agent .muted { font-weight: 400; }
|
|
@@ -410,7 +420,6 @@ button.attach:disabled { opacity: .35; }
|
|
|
410
420
|
.sub.agent.stale .working-dot { animation: none; }
|
|
411
421
|
/* The one class every paused status takes, gray: the card and thread line, the header, the hub banner. */
|
|
412
422
|
.paused { color: var(--muted); font-weight: 600; }
|
|
413
|
-
.phase-bars.in-todo { --bar-fill: var(--muted); }
|
|
414
423
|
.msg.calls { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
|
|
415
424
|
.msg.calls summary { cursor: pointer; color: var(--muted); list-style: none; }
|
|
416
425
|
.msg.calls summary::-webkit-details-marker { display: none; }
|
|
@@ -583,7 +592,7 @@ main > * { transition: opacity .25s ease; }
|
|
|
583
592
|
.view-settings .who.new { border-color: var(--accent); }
|
|
584
593
|
.view-settings .add { display: flex; gap: 6px; margin-top: 8px; }
|
|
585
594
|
.view-settings .add input { flex: 1; min-width: 0; }
|
|
586
|
-
.view-settings .add.pick { flex-
|
|
595
|
+
.view-settings .add.pick { flex-direction: column; }
|
|
587
596
|
.view-settings .share-now { color: var(--ok); font-weight: 600; }
|
|
588
597
|
.view-settings .share-level { display: flex; gap: 4px; margin-top: 6px; }
|
|
589
598
|
.view-settings .share-level button { min-height: 32px; padding: 4px 12px; font-size: 13px; }
|
|
@@ -592,7 +601,11 @@ main > * { transition: opacity .25s ease; }
|
|
|
592
601
|
.view-settings .share-reach { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0 4px; }
|
|
593
602
|
.view-settings .share-reach p { flex: 1 1 220px; margin: 0; }
|
|
594
603
|
.view-settings .share-reach button { flex: none; }
|
|
595
|
-
.view-settings .
|
|
604
|
+
.view-settings .login-row { display: flex; gap: 6px; }
|
|
605
|
+
.view-settings .login-row input { flex: 1; min-width: 0; }
|
|
606
|
+
.view-settings .chip { display: inline-flex; align-items: center; align-self: flex-start; gap: 6px; padding: 4px 6px 4px 12px; border-radius: 14px; border: 1px solid var(--accent); font-size: 14px; }
|
|
607
|
+
.view-settings .chip button { padding: 0 6px; min-height: 0; }
|
|
608
|
+
.view-settings .problem { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 9px 10px; border: 1px dashed var(--line); border-radius: 9px; }
|
|
596
609
|
.view-settings .pick-list { max-height: 160px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
|
|
597
610
|
.view-settings .pick-list button { text-align: left; }
|
|
598
611
|
.view-settings .pick-list button[aria-pressed="true"] { border-color: var(--accent); }
|
|
@@ -650,7 +663,8 @@ main > * { transition: opacity .25s ease; }
|
|
|
650
663
|
.view-questions .q details > summary { cursor: pointer; color: var(--muted); font-size: 13px; }
|
|
651
664
|
.view-questions .topics { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 0 8px; margin-bottom: 4px; }
|
|
652
665
|
.view-questions .topics button { min-width: 0; border: 1px solid var(--line); border-radius: 999px; padding: 4px 11px; font-size: 13px;
|
|
653
|
-
background: var(--panel); color: var(--text);
|
|
666
|
+
background: var(--panel); color: var(--text); display: inline-flex; max-width: 100%; text-align: left; }
|
|
667
|
+
.view-questions .topics .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
654
668
|
.view-questions .topics button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
|
|
655
669
|
.view-questions .topics button.progress { border-color: var(--line); background: color-mix(in srgb, var(--color-notice) 12%, var(--panel)); }
|
|
656
670
|
.view-questions .topics button.progress[aria-pressed="true"] { border-color: var(--accent); background: color-mix(in srgb, var(--color-notice) 12%, var(--accent-soft)); }
|
|
@@ -671,7 +685,7 @@ main > * { transition: opacity .25s ease; }
|
|
|
671
685
|
.view-questions .q.resolved .opt:has(input:checked) .det { display: block; }
|
|
672
686
|
.view-questions .q.resolved .opt:not(:has(input:checked)) { opacity: .6; }
|
|
673
687
|
.view-questions .q.resolved .reask-line { margin-top: 10px; }
|
|
674
|
-
.view-questions .bar { position: sticky; bottom: var(--app-bottom, 0px); padding: 10px 0 max(10px, env(safe-area-inset-bottom)); background: var(--bg);
|
|
688
|
+
.view-questions .bar { position: sticky; bottom: var(--app-bottom, 0px); padding: 10px 0 max(10px, env(safe-area-inset-bottom)); background: var(--bg); z-index: 2;
|
|
675
689
|
display: flex; justify-content: space-between; align-items: center; gap: 8px; border-top: 1px solid var(--line); }
|
|
676
690
|
.view-questions .bar .send-hint { margin-left: auto; }
|
|
677
691
|
/* The + rides above the sticky Send bar rather than over its Send. */
|
|
@@ -748,6 +762,8 @@ main > * { transition: opacity .25s ease; }
|
|
|
748
762
|
:root:has(body > .top, body.has-sidebar > :not(.sidebar) > .top) {
|
|
749
763
|
--app-bottom: calc(49px + max(4px, env(safe-area-inset-bottom)));
|
|
750
764
|
}
|
|
765
|
+
/* The nav under the bar already pads the safe area (--app-bottom), so the gap under Send stays the 10px above it. */
|
|
766
|
+
.view-questions .bar { padding-bottom: 10px; }
|
|
751
767
|
body > .top, body.has-sidebar > :not(.sidebar) > .top {
|
|
752
768
|
position: fixed; top: auto; bottom: 0; left: 0; right: 0; z-index: 7;
|
|
753
769
|
border-bottom: 0; border-top: 1px solid var(--line); padding-top: 4px; padding-bottom: max(4px, env(safe-area-inset-bottom));
|
|
@@ -761,8 +777,14 @@ main > * { transition: opacity .25s ease; }
|
|
|
761
777
|
.sheet.keyboard-sheet { --sheet-limit: max(80px, calc(var(--vh, 100dvh) + var(--kb, 0px) - max(0px, calc(var(--app-bottom, 0px) - var(--kb, 0px))) - max(12px, env(safe-area-inset-top)))); }
|
|
762
778
|
}
|
|
763
779
|
|
|
764
|
-
/* Typing, a text field focused (shell.js sets :root.typing): the floating +
|
|
780
|
+
/* Typing, a text field focused (shell.js sets :root.typing): the floating + dims and stops taking taps, and the page stays as it is. */
|
|
765
781
|
:root.typing .fab { opacity: .35; pointer-events: none; }
|
|
782
|
+
/* On a phone the + hides while typing, so it never sits over the keyboard's field. */
|
|
783
|
+
@media (max-width: 699px) {
|
|
784
|
+
:root.typing .fab { display: none; }
|
|
785
|
+
/* The board page bottom bar hides too; --app-bottom stays, so the page does not move. */
|
|
786
|
+
:root.typing body > .top, :root.typing body.has-sidebar > :not(.sidebar) > .top { display: none; }
|
|
787
|
+
}
|
|
766
788
|
|
|
767
789
|
.settings-modal { position: fixed; inset: max(12px, env(safe-area-inset-top)) 0 calc(12px + max(var(--kb, 0px), var(--app-bottom, 0px))); margin: auto; width: min(680px, calc(100% - 24px));
|
|
768
790
|
height: fit-content; max-width: none; max-height: max(120px, calc(var(--vh, 100dvh) - max(0px, calc(var(--app-bottom, 0px) - var(--kb, 0px))) - 24px - env(safe-area-inset-top)));
|
|
@@ -876,6 +898,9 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
|
|
|
876
898
|
.reader-row.sys, .reader-row.out { color: var(--muted); font-size: 12.5px; }
|
|
877
899
|
.reader-row.out { padding-left: 28px; font: 11.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
878
900
|
.reader-row.tool { font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
901
|
+
.reader-row.call { cursor: pointer; }
|
|
902
|
+
.reader-caret { display: inline-block; width: 10px; margin-right: 4px; color: var(--muted); }
|
|
903
|
+
.reader-out { margin: 2px 14px 4px 28px; padding: 6px 8px; border-left: 2px solid var(--line); color: var(--muted); font: 11.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
879
904
|
.reader-tag { display: inline-block; margin-right: 6px; padding: 0 6px; border-radius: 6px; background: var(--chip); color: var(--accent); font-size: 11px; }
|
|
880
905
|
.reader-raw, .reader-text { margin: 0; padding: 2px 14px 8px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
881
906
|
.reader-md { padding: 4px 16px 12px; overflow-wrap: anywhere; line-height: 1.5; }
|
|
@@ -924,13 +949,14 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
|
|
|
924
949
|
.deck-opts { display: flex; flex-direction: column; gap: 6px; }
|
|
925
950
|
.deck-opts button { text-align: left; border-radius: 10px; padding: 10px 12px; font-size: 14px; }
|
|
926
951
|
.deck-opts button.rec { border-color: var(--accent); }
|
|
952
|
+
.deck-opts button.armed { background: var(--color-notice); border-color: var(--color-notice); color: #000; font-weight: 600; }
|
|
927
953
|
.deck-opts .rec-word { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
|
|
928
954
|
.deck-opts small { display: block; color: var(--muted); font-size: 12.5px; }
|
|
929
955
|
.deck-prompt { margin: 0; padding: 0; list-style: none; }
|
|
930
956
|
.deck-prompt li.prompt { padding: 8px 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
|
|
931
957
|
.deck-acts { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
932
958
|
.deck-bar { display: flex; align-items: center; gap: 10px; padding: 0 14px 10px; }
|
|
933
|
-
.deck-dots { flex: 1; display: flex; flex-wrap: wrap; gap: 5px; }
|
|
959
|
+
.deck-dots { flex: 1; display: flex; flex-wrap: wrap; justify-content: center; gap: 5px; }
|
|
934
960
|
.deck-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--line); }
|
|
935
961
|
.deck-dots i.on { background: var(--color-notice); }
|
|
936
962
|
.deck-bar button { border-radius: 999px; padding: 5px 12px; font-size: 13px; }
|
|
@@ -949,12 +975,33 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
|
|
|
949
975
|
.view-board .fab { right: calc(50% - var(--sidebar-w, 0px) / 2 + var(--fab-edge)); }
|
|
950
976
|
body.has-sidebar { --sidebar-w: 272px; }
|
|
951
977
|
html.sb-collapsed body.has-sidebar { --sidebar-w: 0px; }
|
|
952
|
-
.needs-pane { display: block; position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; overscroll-behavior: contain; padding: 12px 16px 0; border-left: 1px solid var(--line); }
|
|
978
|
+
.needs-pane { display: block; position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 12px 16px 0; border-left: 1px solid var(--line); }
|
|
953
979
|
.needs-pane .needs-questions main { padding: 0; max-width: none; }
|
|
954
980
|
.needs-pane:has(.q-empty), .needs-pane .needs-questions:has(.q-empty), .needs-pane .needs-questions:has(.q-empty) main, .needs-pane #page:has(.q-empty) { display: flex; flex-direction: column; }
|
|
955
981
|
.needs-pane .needs-questions:has(.q-empty), .needs-pane .needs-questions:has(.q-empty) main, .needs-pane #page:has(.q-empty), .needs-pane .q-empty { flex: 1; min-height: 0; }
|
|
956
982
|
.needs-pane .waiting { margin: 0 0 12px; }
|
|
983
|
+
.needs-pane * { min-width: 0; overflow-wrap: anywhere; }
|
|
984
|
+
.needs-pane pre, .needs-pane code { white-space: pre-wrap; }
|
|
985
|
+
.needs-pane :is(.deck-opts, .row, .actions, .btns) { flex-wrap: wrap; }
|
|
986
|
+
/* A prototype in the pane (app-needs.js openViewer): a top bar over the framed page, whose footer ends the pane; ⤢
|
|
987
|
+
(.widened) lays the pane over the lanes, from the sidebar's edge. */
|
|
988
|
+
.needs-pane.viewing { display: flex; flex-direction: column; padding: 0; overflow: hidden; }
|
|
989
|
+
.needs-pane.viewing > :not(.pane-viewer) { display: none; }
|
|
990
|
+
.needs-pane.viewing.widened { position: fixed; top: 56px; left: var(--sidebar-w, 0px); right: 0; bottom: 0; height: auto; z-index: 7; border-left: 0; background: var(--bg); }
|
|
991
|
+
.pane-viewer { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
992
|
+
.pv-top { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
993
|
+
.pv-top .pv-grow { flex: 1; }
|
|
994
|
+
.pv-top button { min-height: 36px; border-radius: 8px; font-weight: 600; }
|
|
995
|
+
.pv-top .pv-back { border: 0; background: transparent; color: var(--text); padding: 6px 8px; }
|
|
996
|
+
.pv-top .pv-back:hover { background: var(--line); }
|
|
997
|
+
.pv-top .pv-widen { width: 36px; padding: 0; font-size: 17px; }
|
|
998
|
+
.pv-top .pv-widen[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
|
999
|
+
.pv-top .ps-chip { border-radius: 999px; padding: 0 14px; }
|
|
1000
|
+
.pv-frame { flex: 1; min-height: 0; width: 100%; border: 0; display: block; background: var(--panel); }
|
|
1001
|
+
.pane-viewer .ps-sheet { position: absolute; left: 8px; right: 8px; top: 56px; z-index: 2; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); box-shadow: 0 8px 30px rgba(0,0,0,.25); }
|
|
1002
|
+
.pane-viewer .ps-sheet h3 { margin: 0 0 6px; font-size: 14px; }
|
|
1003
|
+
.pane-viewer .ps-sheet button { display: flex; justify-content: space-between; gap: 12px; width: 100%; border: 0; border-top: 1px solid var(--line); border-radius: 0; background: transparent; color: var(--text); padding: 10px 2px; text-align: left; }
|
|
1004
|
+
.pane-viewer .ps-sheet button span + span { color: var(--muted); }
|
|
957
1005
|
}
|
|
958
1006
|
/* The Send bar is the last thing on its page: only the phone nav under it is left clear, so its background runs to the edge. */
|
|
959
1007
|
body .view-questions main { padding-bottom: var(--app-bottom, 0px); }
|
|
960
|
-
.msg.calls.status-fold summary .fold-log { margin-left: 6px; color: var(--accent); text-decoration: underline; }
|