@leftoff/cli 0.9.8-beta.2 → 0.9.8-beta.21

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/ui/lightbox.js ADDED
@@ -0,0 +1,452 @@
1
+ // A full-screen preview for images (0.9.8): a dark backdrop, the picture fit
2
+ // to the screen, pinch / wheel / double-tap zoom, a drag to pan when zoomed,
3
+ // a swipe (or ← →, or the edge arrows) between the pictures of one message,
4
+ // and Esc, ×, a tap on the backdrop or a swipe down to close. The pure parts
5
+ // first (unit-tested); the DOM part only runs in a browser.
6
+
7
+ export const LB_MIN = 1
8
+ export const LB_MAX = 4
9
+ export const LB_ZOOM = 2 // a double tap: fit ↔ 2x
10
+ export const LB_TAP_MOVE = 10 // px a tap may wander
11
+ export const LB_DOUBLE_MS = 300
12
+ export const LB_DOUBLE_DIST = 30
13
+ export const LB_SWIPE_MIN = 40 // px: shorter is never a swipe
14
+ export const LB_SWIPE_FRAC = 0.22 // of the width: a slow drag this far turns the page
15
+ export const LB_FLICK = 0.45 // px/ms: a quick flick past LB_SWIPE_MIN does too
16
+ export const LB_CLOSE_DIST = 110 // px down: close
17
+ export const LB_IDLE_MS = 2600 // the top bar fades after this long untouched
18
+
19
+ const num = (v, d = 0) => (Number.isFinite(v) ? v : d)
20
+
21
+ export function clampScale(s) { return Math.min(LB_MAX, Math.max(LB_MIN, num(s, LB_MIN))) }
22
+
23
+ // The picture's size fit inside the view (object-fit: contain).
24
+ export function fitRect(nw, nh, vw, vh) {
25
+ if (!(nw > 0 && nh > 0 && vw > 0 && vh > 0)) return { w: Math.max(0, num(vw)), h: Math.max(0, num(vh)) }
26
+ const k = Math.min(vw / nw, vh / nh)
27
+ return { w: nw * k, h: nh * k }
28
+ }
29
+
30
+ // Pan never shows past the picture's edge; at fit (or where the zoomed picture
31
+ // is still smaller than the view on an axis) it stays centred on that axis.
32
+ export function clampPan(st, fit, view) {
33
+ const s = clampScale(st.scale)
34
+ const mx = Math.max(0, (fit.w * s - view.w) / 2)
35
+ const my = Math.max(0, (fit.h * s - view.h) / 2)
36
+ const c = (v, m) => (m === 0 ? 0 : Math.min(m, Math.max(-m, num(v))))
37
+ return { scale: s, x: c(st.x, mx), y: c(st.y, my) }
38
+ }
39
+
40
+ // Zoom to `next` keeping the point (px, py) — measured from the view's centre —
41
+ // under the finger or the pointer.
42
+ export function zoomAt(st, next, px, py, fit, view) {
43
+ const s = clampScale(st.scale)
44
+ const n = clampScale(next)
45
+ const k = n / s
46
+ return clampPan({ scale: n, x: px - k * (px - num(st.x)), y: py - k * (py - num(st.y)) }, fit, view)
47
+ }
48
+
49
+ // A double tap or double-click: zoomed → back to fit; at fit → 2x at that point.
50
+ export function toggleZoom(st, px, py, fit, view) {
51
+ if (clampScale(st.scale) > LB_MIN + 0.01) return { scale: LB_MIN, x: 0, y: 0 }
52
+ return zoomAt({ scale: LB_MIN, x: 0, y: 0 }, LB_ZOOM, px, py, fit, view)
53
+ }
54
+
55
+ // A wheel notch → a zoom factor. ctrl+wheel is a trackpad pinch (small deltas).
56
+ export function wheelFactor(deltaY, { ctrl = false, lines = false } = {}) {
57
+ const dy = num(deltaY) * (lines ? 16 : 1)
58
+ return Math.exp(-dy * (ctrl ? 0.01 : 0.002))
59
+ }
60
+
61
+ // Two fingers: the scale follows the ratio of their spread.
62
+ export function pinchScale(s0, d0, d) {
63
+ if (!(d0 > 0) || !(d > 0)) return clampScale(s0)
64
+ return clampScale(num(s0, 1) * (d / d0))
65
+ }
66
+
67
+ // One step through the pictures: no wrap (the first has no previous, the last no next).
68
+ export function stepIndex(i, d, n) {
69
+ if (!(n > 0)) return 0
70
+ return Math.min(n - 1, Math.max(0, Math.trunc(num(i)) + Math.sign(num(d))))
71
+ }
72
+ export const hasPrev = (i, n) => n > 1 && i > 0
73
+ export const hasNext = (i, n) => n > 1 && i < n - 1
74
+ export const counterText = (i, n) => (n > 1 ? (i + 1) + ' of ' + n : '')
75
+
76
+ // Which way a one-finger drag is going, once it has gone far enough to say.
77
+ export function dragAxis(dx, dy) {
78
+ if (Math.hypot(dx, dy) < LB_TAP_MOVE) return null
79
+ return Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y'
80
+ }
81
+
82
+ // What a finished one-finger drag does at fit: 'next', 'prev', 'close' or
83
+ // 'back' (spring back). Zoomed, a drag pans and decides nothing ('none').
84
+ export function swipeDecision({ dx = 0, dy = 0, dt = 1, scale = 1, vw = 390, index = 0, count = 1 } = {}) {
85
+ if (clampScale(scale) > LB_MIN + 0.01) return 'none'
86
+ const t = Math.max(1, num(dt, 1))
87
+ const ax = Math.abs(dx), ay = Math.abs(dy)
88
+ if (ax < LB_SWIPE_MIN && ay < LB_SWIPE_MIN) return 'back'
89
+ if (ax >= ay) {
90
+ const far = ax >= Math.max(LB_SWIPE_MIN, vw * LB_SWIPE_FRAC) || ax / t >= LB_FLICK
91
+ if (!far) return 'back'
92
+ if (dx < 0) return hasNext(index, count) ? 'next' : 'back'
93
+ return hasPrev(index, count) ? 'prev' : 'back'
94
+ }
95
+ if (dy <= 0) return 'back'
96
+ return dy >= LB_CLOSE_DIST || dy / t >= LB_FLICK ? 'close' : 'back'
97
+ }
98
+
99
+ // While a drag follows the finger: sideways past the ends it resists; up it
100
+ // resists; down fades the backdrop.
101
+ export function dragOffset(axis, dx, dy, { index = 0, count = 1 } = {}) {
102
+ if (axis === 'x') {
103
+ const edge = (dx > 0 && !hasPrev(index, count)) || (dx < 0 && !hasNext(index, count))
104
+ return { x: edge ? dx / 3 : dx, y: 0 }
105
+ }
106
+ if (axis === 'y') return { x: 0, y: dy > 0 ? dy : dy / 4 }
107
+ return { x: 0, y: 0 }
108
+ }
109
+ export function dragFade(dy, vh) { return dy > 0 && vh > 0 ? Math.max(0.25, 1 - dy / (vh * 0.7)) : 1 }
110
+
111
+ export function isDoubleTap(prev, cur) {
112
+ return !!(prev && cur && cur.t - prev.t <= LB_DOUBLE_MS && Math.hypot(cur.x - prev.x, cur.y - prev.y) <= LB_DOUBLE_DIST)
113
+ }
114
+
115
+ // ------------------------------------------------------------- in the browser
116
+
117
+ const DL_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>'
118
+ const X_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6 6 18"/></svg>'
119
+ const PREV_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m15 5-7 7 7 7"/></svg>'
120
+ const NEXT_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m9 5 7 7-7 7"/></svg>'
121
+
122
+ let ui = null
123
+
124
+ function build() {
125
+ const root = document.createElement('div')
126
+ root.className = 'lightbox'
127
+ root.id = 'lightbox'
128
+ root.hidden = true
129
+ root.tabIndex = -1
130
+ root.setAttribute('role', 'dialog')
131
+ root.setAttribute('aria-modal', 'true')
132
+ root.setAttribute('aria-label', 'Image preview')
133
+ root.innerHTML =
134
+ '<div class="lb-back" aria-hidden="true"></div>' +
135
+ '<div class="lb-stage"><img class="lb-img" alt="" draggable="false"><span class="lb-spin spin" aria-hidden="true" hidden></span><div class="lb-err" hidden></div></div>' +
136
+ '<div class="lb-bar">' +
137
+ '<div class="lb-meta"><span class="lb-name"></span><span class="lb-sub"><span class="lb-size"></span><span class="lb-count"></span></span></div>' +
138
+ '<a class="lb-btn lb-dl" role="button" aria-disabled="true">' + DL_IC + '<span class="lb-dl-label">Download</span></a>' +
139
+ '<button type="button" class="lb-btn lb-x" aria-label="Close" title="Close (Esc)">' + X_IC + '</button>' +
140
+ '</div>' +
141
+ '<button type="button" class="lb-nav lb-prev" aria-label="Previous image" title="Previous (←)">' + PREV_IC + '</button>' +
142
+ '<button type="button" class="lb-nav lb-next" aria-label="Next image" title="Next (→)">' + NEXT_IC + '</button>' +
143
+ '<div class="lb-live" aria-live="polite"></div>'
144
+ document.body.appendChild(root)
145
+ const q = (s) => root.querySelector(s)
146
+ ui = { root, back: q('.lb-back'), stage: q('.lb-stage'), img: q('.lb-img'), spin: q('.lb-spin'), err: q('.lb-err'), bar: q('.lb-bar'), name: q('.lb-name'), size: q('.lb-size'), count: q('.lb-count'), dl: q('.lb-dl'), x: q('.lb-x'), prev: q('.lb-prev'), next: q('.lb-next'), live: q('.lb-live'),
147
+ items: [], index: 0, st: { scale: 1, x: 0, y: 0 }, drag: { x: 0, y: 0 }, ptrs: new Map(), g: null, lastTap: null, tapT: 0, idleT: 0, token: 0, opts: null, closing: 0 }
148
+ wire()
149
+ return ui
150
+ }
151
+
152
+ const reduced = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
153
+ const finePointer = () => !!(window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches)
154
+
155
+ // The view: the stage less the safe areas (its padding), and where its centre sits.
156
+ function view() {
157
+ const cs = getComputedStyle(ui.stage)
158
+ const pl = parseFloat(cs.paddingLeft) || 0, pr = parseFloat(cs.paddingRight) || 0, pt = parseFloat(cs.paddingTop) || 0, pb = parseFloat(cs.paddingBottom) || 0
159
+ const W = ui.stage.clientWidth || window.innerWidth, H = ui.stage.clientHeight || window.innerHeight
160
+ return { w: Math.max(1, W - pl - pr), h: Math.max(1, H - pt - pb), cx: (pl - pr) / 2, cy: (pt - pb) / 2, W, H }
161
+ }
162
+ function natural() {
163
+ const it = ui.items[ui.index] || {}
164
+ const nw = ui.img.naturalWidth || it.w || 0, nh = ui.img.naturalHeight || it.h || 0
165
+ return { nw, nh }
166
+ }
167
+ function fit() { const v = view(), n = natural(); return fitRect(n.nw, n.nh, v.w, v.h) }
168
+
169
+ function apply({ anim = false } = {}) {
170
+ const v = view(), f = fit()
171
+ ui.img.style.width = f.w + 'px'
172
+ ui.img.style.height = f.h + 'px'
173
+ ui.root.classList.toggle('lb-anim', anim && !reduced())
174
+ const x = v.cx + ui.st.x + ui.drag.x, y = v.cy + ui.st.y + ui.drag.y
175
+ ui.img.style.transform = 'translate(-50%, -50%) translate3d(' + x.toFixed(2) + 'px, ' + y.toFixed(2) + 'px, 0) scale(' + ui.st.scale.toFixed(4) + ')'
176
+ ui.root.classList.toggle('lb-zoomed', ui.st.scale > LB_MIN + 0.01)
177
+ ui.root.dataset.scale = ui.st.scale.toFixed(3)
178
+ }
179
+ function setFade(o) { ui.back.style.opacity = String(o); ui.bar.style.opacity = o < 1 ? String(Math.max(0, (o - 0.25) / 0.75) * 0.6) : '' }
180
+
181
+ function showBar() {
182
+ ui.root.classList.remove('lb-idle')
183
+ clearTimeout(ui.idleT)
184
+ ui.idleT = setTimeout(() => { if (ui.root.hidden || ui.root.contains(document.activeElement) && document.activeElement.matches(':focus-visible') && document.activeElement !== ui.root) return; ui.root.classList.add('lb-idle') }, LB_IDLE_MS)
185
+ }
186
+ function toggleBar() { if (ui.root.classList.contains('lb-idle')) showBar(); else { clearTimeout(ui.idleT); ui.root.classList.add('lb-idle') } }
187
+
188
+ function paintMeta() {
189
+ const it = ui.items[ui.index] || {}, n = ui.items.length
190
+ ui.name.textContent = it.name || ''
191
+ ui.name.title = it.name || ''
192
+ ui.size.textContent = it.sizeLabel || ''
193
+ ui.count.textContent = counterText(ui.index, n)
194
+ ui.count.hidden = n < 2
195
+ ui.prev.hidden = !hasPrev(ui.index, n)
196
+ ui.next.hidden = !hasNext(ui.index, n)
197
+ ui.root.classList.toggle('lb-many', n > 1)
198
+ }
199
+ function setDownload(url) {
200
+ const it = ui.items[ui.index] || {}
201
+ if (url && it.download !== false) { ui.dl.href = url; ui.dl.setAttribute('download', it.name || 'image'); ui.dl.removeAttribute('aria-disabled'); ui.dl.removeAttribute('role'); ui.dl.hidden = false }
202
+ else { ui.dl.removeAttribute('href'); ui.dl.removeAttribute('download'); ui.dl.setAttribute('role', 'button'); ui.dl.setAttribute('aria-disabled', 'true'); ui.dl.hidden = it.download === false }
203
+ }
204
+
205
+ // Show picture i: the thumbnail (blurred) at once, the full one when it lands.
206
+ function show(i, { from = 0 } = {}) {
207
+ const it = ui.items[i]
208
+ if (!it) return
209
+ ui.index = i
210
+ const token = ++ui.token
211
+ ui.st = { scale: 1, x: 0, y: 0 }
212
+ ui.drag = { x: 0, y: 0 }
213
+ ui.err.hidden = true
214
+ ui.img.alt = it.name || ''
215
+ paintMeta()
216
+ setDownload(it.url || null)
217
+ if (ui.items.length > 1) ui.live.textContent = (it.name || 'Image') + ', ' + counterText(i, ui.items.length)
218
+ const done = (url) => {
219
+ if (token !== ui.token) return
220
+ it.url = url
221
+ const pre = new Image()
222
+ pre.onload = () => {
223
+ if (token !== ui.token) return
224
+ it.w = pre.naturalWidth; it.h = pre.naturalHeight
225
+ ui.img.src = url
226
+ ui.img.classList.remove('lb-loading')
227
+ ui.spin.hidden = true
228
+ setDownload(url)
229
+ apply()
230
+ }
231
+ pre.onerror = () => { if (token !== ui.token) return; ui.spin.hidden = true; ui.img.classList.remove('lb-loading'); if (!it.thumb) ui.img.removeAttribute('src'); ui.err.textContent = 'Can’t show this picture here.'; ui.err.hidden = false }
232
+ pre.src = url
233
+ }
234
+ if (it.url) { ui.img.src = it.url; ui.img.classList.remove('lb-loading'); ui.spin.hidden = true }
235
+ else if (it.thumb) { ui.img.src = it.thumb; ui.img.classList.add('lb-loading'); ui.spin.hidden = false }
236
+ else { ui.img.removeAttribute('src'); ui.img.classList.remove('lb-loading'); ui.spin.hidden = false }
237
+ // slide in from the side it came from
238
+ if (from && !reduced()) { ui.drag = { x: from * Math.min(view().w, 600) * 0.35, y: 0 }; apply(); void ui.img.offsetWidth; ui.drag = { x: 0, y: 0 }; apply({ anim: true }) } else apply()
239
+ if (it.url) return
240
+ Promise.resolve().then(() => it.load()).then(done, (e) => {
241
+ if (token !== ui.token) return
242
+ ui.spin.hidden = true
243
+ ui.err.textContent = 'Couldn’t load it' + (e && e.message ? ': ' + e.message : '')
244
+ ui.err.hidden = false
245
+ })
246
+ }
247
+
248
+ function go(d) {
249
+ const n = ui.items.length, to = stepIndex(ui.index, d, n)
250
+ if (to === ui.index) { ui.drag = { x: 0, y: 0 }; apply({ anim: true }); return false }
251
+ show(to, { from: d })
252
+ return true
253
+ }
254
+
255
+ function viewPoint(clientX, clientY) {
256
+ const r = ui.stage.getBoundingClientRect(), v = view()
257
+ return { x: clientX - r.left - r.width / 2 - v.cx, y: clientY - r.top - r.height / 2 - v.cy }
258
+ }
259
+ function zoomTo(scale, clientX, clientY, anim) {
260
+ const p = viewPoint(clientX, clientY)
261
+ ui.st = zoomAt(ui.st, scale, p.x, p.y, fit(), view())
262
+ apply({ anim })
263
+ }
264
+
265
+ function wire() {
266
+ const { root, stage, img } = ui
267
+ ui.x.addEventListener('click', () => close())
268
+ ui.prev.addEventListener('click', () => { go(-1); showBar() })
269
+ ui.next.addEventListener('click', () => { go(1); showBar() })
270
+ ui.dl.addEventListener('click', (e) => { if (!ui.dl.getAttribute('href')) e.preventDefault() })
271
+ img.addEventListener('load', () => { if (!root.hidden) apply() })
272
+ root.addEventListener('pointermove', (e) => { if (e.pointerType === 'mouse') showBar() })
273
+ root.addEventListener('focusin', () => { if (document.activeElement !== root) showBar() })
274
+ window.addEventListener('resize', () => { if (root.hidden) return; ui.st = clampPan(ui.st, fit(), view()); apply() })
275
+
276
+ stage.addEventListener('pointerdown', (e) => {
277
+ if (e.button > 0 || ui.closing) return
278
+ try { stage.setPointerCapture(e.pointerId) } catch {}
279
+ ui.ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY })
280
+ if (ui.ptrs.size === 1) ui.g = { kind: 'one', x0: e.clientX, y0: e.clientY, t0: Date.now(), st0: { ...ui.st }, axis: null, moved: false, onImg: e.target === img, type: e.pointerType || 'mouse' }
281
+ else if (ui.ptrs.size === 2) {
282
+ const [a, b] = [...ui.ptrs.values()]
283
+ ui.drag = { x: 0, y: 0 }; setFade(1)
284
+ ui.g = { kind: 'pinch', d0: Math.hypot(a.x - b.x, a.y - b.y), st0: { ...ui.st }, m0: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 } }
285
+ }
286
+ })
287
+ stage.addEventListener('pointermove', (e) => {
288
+ if (!ui.ptrs.has(e.pointerId) || !ui.g) return
289
+ ui.ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY })
290
+ const g = ui.g
291
+ if (g.kind === 'pinch' && ui.ptrs.size >= 2) {
292
+ const [a, b] = [...ui.ptrs.values()]
293
+ const m = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
294
+ const p0 = viewPoint(g.m0.x, g.m0.y)
295
+ const s = pinchScale(g.st0.scale, g.d0, Math.hypot(a.x - b.x, a.y - b.y))
296
+ const z = zoomAt(g.st0, s, p0.x, p0.y, fit(), view())
297
+ ui.st = clampPan({ scale: z.scale, x: z.x + (m.x - g.m0.x), y: z.y + (m.y - g.m0.y) }, fit(), view())
298
+ apply()
299
+ return
300
+ }
301
+ if (g.kind !== 'one') return
302
+ const dx = e.clientX - g.x0, dy = e.clientY - g.y0
303
+ if (!g.moved && Math.hypot(dx, dy) >= LB_TAP_MOVE) g.moved = true
304
+ if (!g.moved) return
305
+ if (g.st0.scale > LB_MIN + 0.01) { ui.st = clampPan({ scale: g.st0.scale, x: g.st0.x + dx, y: g.st0.y + dy }, fit(), view()); apply(); return }
306
+ if (g.type === 'mouse') return // a mouse at fit: the arrows and the keys turn the page
307
+ if (!g.axis) g.axis = dragAxis(dx, dy)
308
+ ui.drag = dragOffset(g.axis, dx, dy, { index: ui.index, count: ui.items.length })
309
+ setFade(g.axis === 'y' ? dragFade(ui.drag.y, view().H) : 1)
310
+ apply()
311
+ })
312
+ const end = (e, cancelled) => {
313
+ if (!ui.ptrs.has(e.pointerId)) return
314
+ ui.ptrs.delete(e.pointerId)
315
+ const g = ui.g
316
+ if (!g) return
317
+ if (g.kind === 'pinch') {
318
+ if (ui.ptrs.size) return
319
+ ui.g = null
320
+ if (ui.st.scale < LB_MIN + 0.03) ui.st = { scale: 1, x: 0, y: 0 }
321
+ ui.st = clampPan(ui.st, fit(), view()); apply({ anim: true })
322
+ return
323
+ }
324
+ ui.g = null
325
+ if (cancelled) { ui.drag = { x: 0, y: 0 }; setFade(1); apply({ anim: true }); return }
326
+ if (!g.moved) return tap(e, g)
327
+ if (g.st0.scale > LB_MIN + 0.01 || g.type === 'mouse') return
328
+ const v = view()
329
+ const d = swipeDecision({ dx: e.clientX - g.x0, dy: e.clientY - g.y0, dt: Date.now() - g.t0, scale: ui.st.scale, vw: v.w, index: ui.index, count: ui.items.length })
330
+ if (d === 'close') return close({ swiped: true })
331
+ setFade(1)
332
+ if (d === 'next' || d === 'prev') { go(d === 'next' ? 1 : -1); return }
333
+ ui.drag = { x: 0, y: 0 }; apply({ anim: true })
334
+ }
335
+ stage.addEventListener('pointerup', (e) => end(e, false))
336
+ stage.addEventListener('pointercancel', (e) => end(e, true))
337
+ stage.addEventListener('lostpointercapture', (e) => { if (ui.ptrs.has(e.pointerId)) end(e, true) })
338
+ // Our own double-tap: stop the browser's double-tap zoom and dblclick text selection here.
339
+ stage.addEventListener('dblclick', (e) => e.preventDefault())
340
+ stage.addEventListener('wheel', (e) => {
341
+ e.preventDefault()
342
+ if (!e.ctrlKey && ui.st.scale > LB_MIN + 0.01 && !e.shiftKey) {
343
+ ui.st = clampPan({ scale: ui.st.scale, x: ui.st.x - e.deltaX, y: ui.st.y - e.deltaY }, fit(), view()); apply(); return
344
+ }
345
+ zoomTo(ui.st.scale * wheelFactor(e.deltaY, { ctrl: e.ctrlKey, lines: e.deltaMode === 1 }), e.clientX, e.clientY, false)
346
+ }, { passive: false })
347
+ // Safari's own pinch events: ours handles the pinch, the page stays put.
348
+ for (const t of ['gesturestart', 'gesturechange']) stage.addEventListener(t, (e) => e.preventDefault())
349
+
350
+ // Keys while open: Esc, ← →, and Tab kept inside.
351
+ window.addEventListener('keydown', (e) => {
352
+ if (root.hidden) return
353
+ if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); close(); return }
354
+ if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); e.stopImmediatePropagation(); go(e.key === 'ArrowRight' ? 1 : -1); return }
355
+ if ((e.key === '+' || e.key === '=') && !e.metaKey && !e.ctrlKey) { e.preventDefault(); e.stopImmediatePropagation(); const r = ui.stage.getBoundingClientRect(); zoomTo(ui.st.scale * 1.5, r.left + r.width / 2, r.top + r.height / 2, true); return }
356
+ if ((e.key === '-' || e.key === '0') && !e.metaKey && !e.ctrlKey) { e.preventDefault(); e.stopImmediatePropagation(); ui.st = { scale: 1, x: 0, y: 0 }; apply({ anim: true }); return }
357
+ if (e.key === 'Tab') {
358
+ const f = [...root.querySelectorAll('a[href], button')].filter((b) => !b.hidden && !b.disabled && b.offsetParent !== null)
359
+ if (!f.length) { e.preventDefault(); root.focus(); return }
360
+ const i = f.indexOf(document.activeElement)
361
+ e.preventDefault(); e.stopImmediatePropagation()
362
+ const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i < 0 || i >= f.length - 1 ? 0 : i + 1)
363
+ f[n].focus()
364
+ }
365
+ }, true)
366
+ // Focus never leaves the dialog while it's open.
367
+ document.addEventListener('focusin', (e) => { if (!root.hidden && !ui.closing && !root.contains(e.target)) ui.x.focus({ preventScroll: true }) })
368
+ }
369
+
370
+ function tap(e, g) {
371
+ const cur = { t: Date.now(), x: e.clientX, y: e.clientY }
372
+ if (isDoubleTap(ui.lastTap, cur)) {
373
+ clearTimeout(ui.tapT); ui.lastTap = null
374
+ const p = viewPoint(e.clientX, e.clientY)
375
+ ui.st = toggleZoom(ui.st, p.x, p.y, fit(), view())
376
+ apply({ anim: true })
377
+ return
378
+ }
379
+ ui.lastTap = cur
380
+ const act = () => { if (!g.onImg && ui.st.scale <= LB_MIN + 0.01) close(); else if (g.type === 'mouse') showBar(); else toggleBar() }
381
+ clearTimeout(ui.tapT)
382
+ if (g.type === 'mouse' && !g.onImg) return act() // a click on the backdrop closes at once
383
+ if (g.type === 'mouse') { showBar(); return }
384
+ ui.tapT = setTimeout(act, LB_DOUBLE_MS) // a touch waits to see if a second tap follows
385
+ }
386
+
387
+ // opts: { items: [{ name, sizeLabel, thumb, load: () => Promise<url>, w, h, el }],
388
+ // index, from (the thumbnail tapped), refocus: () => element to focus after }
389
+ export function openLightbox(opts) {
390
+ if (!ui) build()
391
+ const { root } = ui
392
+ clearTimeout(ui.closing); ui.closing = 0
393
+ ui.opts = opts
394
+ ui.items = (opts.items || []).map((it) => ({ ...it }))
395
+ ui.lastTap = null; ui.ptrs.clear(); ui.g = null
396
+ root.classList.remove('lb-out', 'lb-in', 'lb-idle')
397
+ root.hidden = false
398
+ document.documentElement.classList.add('lb-open')
399
+ setFade(1)
400
+ const i = Math.max(0, Math.min(ui.items.length - 1, opts.index || 0))
401
+ show(i)
402
+ root.classList.toggle('lb-fine', finePointer())
403
+ // grow out of the thumbnail when there is one on screen; otherwise a quick fade
404
+ const el = opts.from
405
+ const r = el && el.getBoundingClientRect ? el.getBoundingClientRect() : null
406
+ const f = fit(), v = view()
407
+ if (!reduced()) {
408
+ if (r && r.width > 4 && r.height > 4 && f.w > 0 && r.bottom > 0 && r.top < v.H) {
409
+ const k = Math.max(r.width / f.w, r.height / f.h)
410
+ const sr = ui.stage.getBoundingClientRect()
411
+ const save = ui.st
412
+ ui.st = { scale: k, x: r.left + r.width / 2 - (sr.left + sr.width / 2) - v.cx, y: r.top + r.height / 2 - (sr.top + sr.height / 2) - v.cy }
413
+ apply()
414
+ ui.root.dataset.scale = '1.000'
415
+ void ui.img.offsetWidth
416
+ ui.st = save
417
+ }
418
+ void root.offsetWidth
419
+ root.classList.add('lb-in')
420
+ apply({ anim: true })
421
+ } else { root.classList.add('lb-in'); apply() }
422
+ ui.x.focus({ preventScroll: true })
423
+ showBar()
424
+ }
425
+
426
+ export function closeLightbox(o) { if (ui && !ui.root.hidden) close(o) }
427
+ export function lightboxOpen() { return !!(ui && !ui.root.hidden && !ui.closing) }
428
+
429
+ function close({ swiped = false } = {}) {
430
+ if (!ui || ui.root.hidden || ui.closing) return
431
+ const { root } = ui
432
+ clearTimeout(ui.tapT); clearTimeout(ui.idleT)
433
+ const opts = ui.opts || {}
434
+ const finish = () => {
435
+ ui.closing = 0
436
+ root.hidden = true
437
+ root.classList.remove('lb-out', 'lb-in', 'lb-anim', 'lb-zoomed', 'lb-idle')
438
+ ui.img.removeAttribute('src'); ui.token++
439
+ ui.items = []; ui.opts = null
440
+ setFade(1)
441
+ document.documentElement.classList.remove('lb-open')
442
+ let t = null
443
+ try { t = opts.refocus ? opts.refocus() : null } catch {}
444
+ if (!t || !t.isConnected) t = opts.from && opts.from.isConnected ? opts.from : null
445
+ if (t) t.focus({ preventScroll: true })
446
+ if (opts.onClose) try { opts.onClose() } catch {}
447
+ }
448
+ if (reduced()) return finish()
449
+ if (swiped) { ui.drag = { x: 0, y: ui.drag.y + view().H * 0.25 }; apply({ anim: true }) }
450
+ root.classList.add('lb-out')
451
+ ui.closing = setTimeout(finish, 170)
452
+ }
package/ui/live-status.js CHANGED
@@ -12,6 +12,10 @@
12
12
  // └─► failed └─► stopped (the turn ended with no answer)
13
13
  // received/working with nothing new for LIVE_QUIET_MS reads "quiet" until
14
14
  // something happens again; replied and failed are final.
15
+ // A message that arrived while Claude was already working on another one is
16
+ // folded into that turn (folded: the id of the message that owns the turn):
17
+ // it has no Working bubble of its own, and when the turn ends it is
18
+ // "Answered with the reply above".
15
19
 
16
20
  export const LIVE_QUIET_MS = 3 * 60 * 1000
17
21
  export const LIVE_PHASES = ['sent', 'received', 'working', 'replied', 'stopped', 'failed']
@@ -22,7 +26,7 @@ const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, '
22
26
  export function initStatus(at = 0) { return { phase: 'sent', at, last: at, tool: null, waiting: null, ran: [], endedAt: null } }
23
27
 
24
28
  // One thing that happened to the message. ev: { type, at, tool?, detail?, asked?, text? }
25
- // delivered | failed | started | tool | tool_done | ask | ask_done | text | reply | ended
29
+ // delivered | failed | started | tool | tool_done | ask | ask_done | text | reply | ended | folded | answered | lost
26
30
  export function stepStatus(st, ev = {}) {
27
31
  const s = { ...(st || initStatus(ev.at || 0)), ran: [...((st && st.ran) || [])] }
28
32
  const at = ev.at || s.last
@@ -31,18 +35,21 @@ export function stepStatus(st, ev = {}) {
31
35
  case 'delivered': if (s.phase === 'sent') s.phase = 'received'; break
32
36
  case 'failed': if (s.phase === 'sent') { s.phase = 'failed'; s.error = ev.error || null; s.endedAt = at } break
33
37
  case 'started': case 'text': if (s.phase !== 'replied') s.phase = 'working'; if (ev.type === 'text') s.waiting = null; break
34
- case 'tool': s.phase = 'working'; s.tool = { name: clip(ev.tool, 40), detail: clip(ev.detail, 80) }; s.waiting = null; break
38
+ case 'tool': s.phase = 'working'; s.tool = { name: clip(ev.tool, 40), detail: clip(ev.detail, 300) }; s.waiting = null; break
35
39
  case 'tool_done': {
36
40
  const name = clip(ev.tool || (s.tool && s.tool.name), 40)
37
- if (name) s.ran.push({ tool: name, detail: clip(ev.detail || (s.tool && s.tool.detail), 80), asked: !!ev.asked })
41
+ if (name) s.ran.push({ tool: name, detail: clip(ev.detail || (s.tool && s.tool.detail), 300), asked: !!ev.asked })
38
42
  if (s.ran.length > 20) s.ran = s.ran.slice(-20)
39
43
  s.tool = null; s.waiting = null
40
44
  break
41
45
  }
42
- case 'ask': s.phase = 'working'; s.waiting = { tool: clip(ev.tool, 40), detail: clip(ev.detail, 80) }; break
46
+ case 'ask': s.phase = 'working'; s.waiting = { tool: clip(ev.tool, 40), detail: clip(ev.detail, 300) }; break
43
47
  case 'ask_done': s.waiting = null; break
44
48
  case 'reply': s.phase = 'replied'; s.tool = null; s.waiting = null; s.endedAt = at; break
45
49
  case 'ended': s.tool = null; s.waiting = null; s.endedAt = at; s.phase = ev.text ? 'replied' : 'stopped'; break
50
+ case 'folded': if (s.phase === 'sent' || s.phase === 'received') s.phase = 'working'; s.folded = ev.owner || true; s.tool = null; s.waiting = null; break
51
+ case 'answered': s.phase = 'replied'; s.folded = ev.owner || s.folded || true; s.tool = null; s.waiting = null; s.endedAt = at; break // folded: the reply above is its answer
52
+ case 'lost': s.tool = null; s.waiting = null; s.endedAt = at; s.phase = 'stopped'; s.lost = true; break // a restart came in between and no answer is in the transcript
46
53
  default: return s
47
54
  }
48
55
  s.last = at
@@ -52,22 +59,23 @@ export function stepStatus(st, ev = {}) {
52
59
  // The line under the message: { label, steps, spin, tone, elapsedMs }
53
60
  export function statusView(st, now = Date.now()) {
54
61
  if (!st) return null
55
- const quiet = (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
62
+ const quiet = !st.folded && (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
56
63
  const elapsedMs = Math.max(0, (st.endedAt || now) - (st.at || now))
57
64
  let label
58
65
  if (st.phase === 'sent') label = 'Sent, waiting for the session'
59
66
  else if (st.phase === 'received') label = quiet ? 'Received · no word from Claude yet' : 'Received · Claude is starting…'
67
+ else if (st.phase === 'working' && st.folded) label = 'Taken into the turn above'
60
68
  else if (st.phase === 'working') {
61
69
  if (st.waiting) label = 'Waiting for your OK' + (st.waiting.tool ? ' (' + st.waiting.tool + (st.waiting.detail ? ': ' + st.waiting.detail : '') + ')' : '')
62
70
  else if (st.tool) label = 'Claude is working… (running ' + st.tool.name + (st.tool.detail ? ': ' + st.tool.detail : '') + ')'
63
71
  else label = quiet ? 'Claude has been quiet for a while (check the terminal)' : 'Claude is working…'
64
- } else if (st.phase === 'replied') label = 'Replied'
65
- else if (st.phase === 'stopped') label = 'Stopped without a reply'
72
+ } else if (st.phase === 'replied') label = st.folded ? 'Answered with the reply above' : 'Replied'
73
+ else if (st.phase === 'stopped') label = st.lost ? 'No reply recorded' : 'Stopped without a reply'
66
74
  else label = 'Didn’t arrive'
67
75
  const at = { sent: 0, received: 1, working: 2, replied: 3, stopped: 3, failed: 0 }[st.phase] || 0
68
- const steps = ['Sent', 'Received', st.phase === 'stopped' ? 'Stopped' : 'Working', st.phase === 'stopped' ? null : 'Replied'].filter(Boolean)
76
+ const steps = ['Sent', 'Received', st.phase === 'stopped' ? (st.lost ? 'No reply' : 'Stopped') : 'Working', st.phase === 'stopped' ? null : 'Replied'].filter(Boolean)
69
77
  .map((name, i) => ({ name, state: st.phase === 'failed' ? (i === 0 ? 'bad' : 'todo') : i < at ? 'done' : i === at ? (FINAL.has(st.phase) || st.phase === 'stopped' ? 'done' : 'now') : 'todo' }))
70
- const spin = !quiet && (st.phase === 'sent' || st.phase === 'received' || st.phase === 'working') && !st.waiting
78
+ const spin = !quiet && !st.folded && (st.phase === 'sent' || st.phase === 'received' || st.phase === 'working') && !st.waiting
71
79
  const tone = st.phase === 'failed' ? 'bad' : st.phase === 'stopped' || quiet || st.waiting ? 'warn' : st.phase === 'replied' ? 'good' : 'busy'
72
80
  return { label, steps, spin, tone, quiet, elapsedMs, final: FINAL.has(st.phase) || st.phase === 'stopped' }
73
81
  }