@leftoff/cli 0.9.17-beta.6 → 0.9.17-beta.7

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/prs.js CHANGED
@@ -28,15 +28,41 @@ export function checksHtml(ck, { compact = false } = {}) {
28
28
  return '<span class="pr-checks ' + cls + '"' + (compact ? ' title="' + esc(full) + '" aria-label="' + esc(full) + '"' : '') + '><span aria-hidden="true">' + ic + '</span> ' + esc(compact ? short : full) + '</span>'
29
29
  }
30
30
  const STATE = { open: 'Open', draft: 'Draft', merged: 'Merged', closed: 'Closed' }
31
+ // 2026-10-29 State by shape plus the word (never colour alone): open ⟟ a PR glyph, draft one with a
32
+ // dashed head, merged the merge glyph, closed the PR glyph with an ✕; not checked a dashed ring.
33
+ const svg = (d) => '<svg class="pr-st-ic" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + d + '</svg>'
34
+ export const STATE_IC = {
35
+ open: svg('<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M6 9v12"/>'),
36
+ draft: svg('<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M18 6V5"/><path d="M18 11v-1"/><path d="M6 9v12"/>'),
37
+ merged: svg('<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>'),
38
+ closed: svg('<circle cx="6" cy="6" r="3"/><path d="M6 9v12"/><path d="m21 3-6 6"/><path d="m21 9-6-6"/><path d="M18 11.5V15"/><circle cx="18" cy="18" r="3"/>'),
39
+ unchecked: svg('<circle cx="12" cy="12" r="8" stroke-dasharray="3 3.3"/>'),
40
+ }
31
41
  export function stateHtml(state) {
32
42
  if (!state) return ''
33
- return '<span class="pr-state ' + esc(state) + '">' + esc(STATE[state] || state) + '</span>'
43
+ return '<span class="pr-state ' + esc(state) + '">' + (STATE_IC[state] || '') + esc(STATE[state] || state) + '</span>'
44
+ }
45
+ // 2026-10-29 A short ref that couldn't be checked: why, for its data-tip.
46
+ export function uncheckedTip(p) {
47
+ const what = p.label || ((p.repo ? p.repo.split('/')[1] : p.name || '') + '#' + p.number)
48
+ const name = String(what).split('#')[0]
49
+ if (p.unchecked === 'ambiguous') return 'Named in this session, but “' + name + '” is more than one GitHub repo here' + (p.options && p.options.length ? ' (' + p.options.join(', ') + ')' : '') + ', so it wasn’t checked. Link the right one below.'
50
+ if (p.unchecked === 'unknown') return 'Named in this session, but Leftoff doesn’t know which GitHub repo “' + name + '” is yet (this session has no GitHub remote), so it wasn’t checked. It links once a session in that repo runs on one of your machines, or link it below.'
51
+ if (p.unchecked === 'no_gh') return 'Named in this session (' + (p.repo || name) + '). No machine with gh signed in has answered yet, so it wasn’t checked.'
52
+ if (p.unchecked === 'failed') return 'Named in this session (' + (p.repo || name) + '), but gh couldn’t read it just now, so it wasn’t checked. It tries again in a few minutes.'
53
+ return 'Named in this session (' + (p.repo || name) + '). Checking it with gh…'
54
+ }
55
+ export function uncheckedHtml(p) {
56
+ const what = p.label || ((p.repo ? p.repo.split('/')[1] : p.name || '') + '#' + p.number)
57
+ const word = p.unchecked === 'checking' ? 'checking' : 'not checked'
58
+ const tip = uncheckedTip(p)
59
+ return '<span class="pr-unchecked" tabindex="0" role="note" data-tip="' + esc(tip) + '" aria-label="' + esc(what + ', ' + word + '. ' + tip) + '">' + STATE_IC.unchecked + '<span class="pr-un-ref">' + esc(what) + '</span><span class="pr-un-word"> · ' + esc(word) + '</span></span>'
34
60
  }
35
61
  const REVIEW = { approved: 'Approved', changes_requested: 'Changes requested', review_required: 'Review needed' }
36
62
 
37
63
  // A session card's PRs: one compact line each (#, title, state, checks), three at most.
38
64
  export function cardPrsHtml(prs) {
39
- const list = (prs || []).filter((p) => p && p.number)
65
+ const list = (prs || []).filter((p) => p && p.number && !p.unchecked)
40
66
  if (!list.length) return ''
41
67
  const shown = list.slice(0, 3)
42
68
  const more = list.length - shown.length
@@ -80,8 +106,12 @@ export function prActionsHtml(p, { unlinkId = null } = {}) {
80
106
 
81
107
  // The session page: its PRs with Unlink, and "Link a PR" (a text box: a URL or #number).
82
108
  export function sessionPrsSection(s, { mayEdit = true, open = null, details = {} } = {}) {
83
- const list = (s.prs || []).filter((p) => p && p.number)
84
- let h = '<h2>Pull requests' + (list.length ? ' <span class="count">' + list.length + '</span>' : '') + '</h2>'
109
+ // 2026-10-29 + prsMore: closed PRs (a card leaves them out) and refs that couldn't be checked
110
+ const more = (s.prsMore || []).filter((p) => p && p.number)
111
+ const list = (s.prs || []).filter((p) => p && p.number).concat(more.filter((p) => !p.unchecked && p.url && !(s.prs || []).some((x) => x && x.url === p.url)))
112
+ const unchecked = more.filter((p) => p.unchecked)
113
+ const n = list.length + unchecked.length
114
+ let h = '<h2>Pull requests' + (n ? ' <span class="count">' + n + '</span>' : '') + '</h2>'
85
115
  if (list.length) {
86
116
  h += '<div class="prlist">' + list.map((p) => {
87
117
  // 0.9.8: tap the title (or More…) for the same details as the PRs page, in place; GitHub ↗ still goes out
@@ -93,13 +123,28 @@ export function sessionPrsSection(s, { mayEdit = true, open = null, details = {}
93
123
  (isOpen ? prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
94
124
  prActionsHtml(p, { unlinkId: mayEdit ? s.id : null }) + '</article>'
95
125
  }).join('') + '</div>'
96
- } else h += '<div class="nocp">No pull requests linked yet. They link on their own from gh pr create, PR links in the chat, or the branch.</div>'
97
- if (mayEdit) h += '<form class="prlink addform sess-add" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false" enterkeyhint="done"><button type="submit" class="btn sm">Link</button></form>'
126
+ }
127
+ if (unchecked.length) h += '<div class="pr-unlist">' + unchecked.map(uncheckedHtml).join('') + '</div>'
128
+ if (!n) h += '<p class="nocp pr-none" data-tip="' + esc(PR_HOW) + '">No PR found for this session' + (noPrWhy(s) ? '<span class="pr-none-why"> · ' + esc(noPrWhy(s)) + '</span>' : '') +
129
+ (mayEdit ? ' · <label class="linkish pr-none-link" for="' + esc(prInputId(s.id)) + '">Link a PR…</label>' : '') + '</p>'
130
+ if (mayEdit) h += '<form class="prlink addform sess-add" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input id="' + esc(prInputId(s.id)) + '" name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false" enterkeyhint="done"><button type="submit" class="btn sm">Link</button></form>'
98
131
  return h
99
132
  }
133
+ // 2026-10-28 No PR: one quiet line saying why, then Link a PR… (it focuses the box below).
134
+ const PR_HOW = 'PRs link on their own from gh pr create, a PR link or number in your messages or the title, gh pr checkout, a push to a PR’s branch, or the branch itself.'
135
+ const prInputId = (id) => 'prref-' + String(id || '').replace(/[^A-Za-z0-9_-]/g, '_')
136
+ const DEFAULTS = ['main', 'master', 'trunk', 'develop']
137
+ export function noPrWhy(s) {
138
+ const r = (s && s.repo) || {}
139
+ if (!r.root && !r.name && !(s && s.repoName && s.repoName !== 'no-repo')) return 'not in a git repo'
140
+ if (r.branch && DEFAULTS.includes(r.branch)) return 'it ran on ' + r.branch + ' and named no PR'
141
+ if (r.branch) return 'no open PR for ' + r.branch
142
+ return ''
143
+ }
100
144
  // 0.8.5: why a PR is linked, in words (the daemon sends created / you / branch / manual).
101
- export const WHY = { created: 'created here', you: 'you mentioned it', branch: 'same branch', mentioned: 'same branch', manual: 'linked by you' }
102
- const WHY_ORDER = ['manual', 'created', 'you', 'branch', 'mentioned']
145
+ // 2026-10-28: + gh (worked on it with gh), pushed, title, commits, ghview.
146
+ export const WHY = { named: 'named in this session', suggested: 'named in this session', created: 'created here', gh: 'worked on here', pushed: 'pushed to its branch', you: 'you mentioned it', title: 'named in the title', commits: 'has this session’s commits', branch: 'same branch', ghview: 'looked up here', mentioned: 'same branch', manual: 'linked by you' }
147
+ const WHY_ORDER = ['manual', 'created', 'gh', 'pushed', 'you', 'suggested', 'title', 'commits', 'branch', 'ghview', 'named', 'mentioned']
103
148
  export function whyText(r) { const v = r && (r.why || (Array.isArray(r.via) ? WHY_ORDER.find((k) => r.via.includes(k)) : r.via)); return v ? WHY[v] || '' : '' }
104
149
 
105
150
  // gh missing or signed out: the 0.6.5 status line with the fix to copy.
@@ -0,0 +1,247 @@
1
+ // 0.9.18 Scroll, focus and open <details> kept through every redraw (Joel, Oct 28: "the right
2
+ // sidebar ... is doing the scroll jump thing too. It seems to be a bug that's in a lot of places").
3
+ //
4
+ // A live update (session.updated over SSE -> soon() -> render()) repaints whole pages with
5
+ // innerHTML (paintSession, the board, Settings, PRs ...) and polls repaint parts of them. A box
6
+ // that is rebuilt starts at scroll 0, and one taken out and put back in is reset to 0, so
7
+ // whatever you had scrolled jumped to the top. Rather than patch each panel, this watches the
8
+ // page itself: it records where every scroll box is as you scroll (keyed by a stable path:
9
+ // an id or data-scroll-key, then tag.class:index below it), and right after any DOM change
10
+ // (a MutationObserver, which runs before the browser paints, so there's no visible jump) puts a
11
+ // box that came back at 0 where it was. It never moves a box the app itself positioned since
12
+ // (its own scrollTop / scrollTo / scrollIntoView: the chat's stick-to-bottom, a jump to a file),
13
+ // never carries a place over to another page (records belong to the route they were made on),
14
+ // and only ever moves a box away from 0, never to the top. A box whose content came back
15
+ // shorter for a moment (a placeholder, a late image) is put back as the content grows.
16
+ // Focus and the caret in a field come back when a redraw removed the focused element; a
17
+ // <details> you opened or closed stays that way.
18
+
19
+ // (whole class names that come and go with state; is-* and has-* too)
20
+ const STATE = /^(?:(?:is|has)-.*|on|off|cur|current|open|shut|closed|active|sel|selected|hide|hidden|show|shown|stuck|fold|folded|live|busy|err|warn|dim|faint|new|seen|away|up|down|sheet-up|collapsed|wide|narrow|empty|loading|ready|copied|pinned|exited|done)$/
21
+
22
+ // One step of an element's path: its own key, or tag + first non-state class + index among like siblings
23
+ export function scrollSeg(el) {
24
+ const k = el.getAttribute && el.getAttribute('data-scroll-key')
25
+ if (k) return '[' + k + ']'
26
+ if (el.id) return '#' + el.id
27
+ const tag = String(el.tagName || '').toLowerCase()
28
+ let cls = ''
29
+ for (const c of el.classList || []) if (!STATE.test(c)) { cls = c; break }
30
+ let i = 0
31
+ for (let p = el.previousElementSibling; p; p = p.previousElementSibling) if (p.tagName === el.tagName && (!cls || (p.classList && p.classList.contains(cls)))) i++
32
+ return tag + (cls ? '.' + cls : '') + (i ? ':' + i : '')
33
+ }
34
+ // The path up to the nearest id / data-scroll-key (or the body)
35
+ export function scrollKey(el) {
36
+ const parts = []
37
+ for (let e = el; e && e.nodeType === 1; e = e.parentElement) {
38
+ const tag = String(e.tagName || '').toLowerCase()
39
+ if (tag === 'html') { parts.unshift('html'); break }
40
+ parts.unshift(scrollSeg(e))
41
+ if (tag === 'body' || e.id || (e.getAttribute && e.getAttribute('data-scroll-key'))) break
42
+ }
43
+ return parts.join('>')
44
+ }
45
+ // Back from a path to the element in the page now (null when it isn't there)
46
+ export function findByKey(doc, key) {
47
+ const parts = String(key || '').split('>')
48
+ if (!parts[0]) return null
49
+ let el
50
+ const a = parts[0]
51
+ if (a === 'html') el = doc.documentElement
52
+ else if (a === 'body') el = doc.body
53
+ else if (a[0] === '#') el = doc.getElementById(a.slice(1))
54
+ else if (a[0] === '[') { const v = a.slice(1, -1); el = [...doc.querySelectorAll('[data-scroll-key]')].find((e) => e.getAttribute('data-scroll-key') === v) || null }
55
+ else return null
56
+ for (let i = 1; el && i < parts.length; i++) {
57
+ let next = null
58
+ for (const c of el.children) if (scrollSeg(c) === parts[i]) { next = c; break }
59
+ el = next
60
+ }
61
+ return el || null
62
+ }
63
+ // Whether to put a box back: it came back at 0 (rebuilt or put back in) where it had been further on,
64
+ // and nothing (the app, you) moved it since the record. -> { top, left } to set, or null
65
+ export function restoreFor(rec, cur, { wroteAt = 0 } = {}) {
66
+ if (!rec || wroteAt > rec.at) return null
67
+ const t = rec.top > 1 && cur.top === 0 && Math.abs(cur.top - rec.top) > 1
68
+ const l = rec.left > 1 && cur.left === 0 && Math.abs(cur.left - rec.left) > 1
69
+ if (!t && !l) return null
70
+ return { top: t ? rec.top : null, left: l ? rec.left : null }
71
+ }
72
+
73
+ export function createScrollKeep({ win = window, doc = document, now = () => performance.now(), maxRecords = 80 } = {}) {
74
+ const recs = new Map() // route|key -> { top, left, at, el: WeakRef }
75
+ const wrote = new WeakMap() // element -> when the app itself last positioned it
76
+ const pending = new Map() // element -> { top, left, until, set } a restore the content wasn't tall enough for yet
77
+ const details = new Map() // route|key -> open, the <details> you opened or closed
78
+ let lastInput = -1e9, appUntil = -1e9, quietUntil = -1e9, restoring = 0
79
+ let focus = null // { el, key, start, end, dir } what has focus (kept when a redraw removed it)
80
+ const stats = { restored: 0, refocused: 0, reopened: 0 }
81
+ const route = () => win.location.pathname + win.location.hash
82
+ const se = () => doc.scrollingElement || doc.documentElement
83
+ const W = typeof WeakRef === 'function' ? (x) => new WeakRef(x) : (x) => ({ deref: () => x })
84
+ const keyFor = (el) => el === se() ? 'doc' : scrollKey(el)
85
+ const elFor = (key) => key === 'doc' ? se() : findByKey(doc, key)
86
+ const user = () => now() - lastInput < 800
87
+
88
+ function record(el, t = now()) {
89
+ if (!el || !el.isConnected) return
90
+ const k = route() + '|' + keyFor(el)
91
+ recs.delete(k)
92
+ recs.set(k, { top: el.scrollTop, left: el.scrollLeft, at: t, el: W(el) })
93
+ if (recs.size > maxRecords) recs.delete(recs.keys().next().value)
94
+ }
95
+ function set(el, top, left) {
96
+ restoring++
97
+ try { if (top != null && el.scrollTop !== top) el.scrollTop = top; if (left != null && el.scrollLeft !== left) el.scrollLeft = left } finally { restoring-- }
98
+ quietUntil = now() + 250
99
+ }
100
+
101
+ // -- as you scroll
102
+ function onScroll(e) {
103
+ const el = e.target === doc || e.target === win ? se() : e.target
104
+ if (!el || el.nodeType !== 1 || !el.isConnected) return
105
+ const p = pending.get(el)
106
+ const t = now()
107
+ if (p) {
108
+ // still short of where it was and nobody moved it (our own set, or the browser clamping it): keep waiting
109
+ if (!user() && t < p.until && (el.scrollTop === p.set || el.scrollTop < (p.top || 0))) return
110
+ pending.delete(el)
111
+ }
112
+ const k = route() + '|' + keyFor(el)
113
+ const r = recs.get(k)
114
+ const app = t - (wrote.get(el) || -1e9) < 1500 || t < appUntil
115
+ if (r && !user() && !app && el.scrollTop < r.top - 1 && t - r.at > 0) {
116
+ // moved up with no hand on it: content came back shorter (clamped) or the box was reset; put it back as it grows
117
+ const max = el.scrollHeight - el.clientHeight
118
+ if (el.scrollTop === 0 || el.scrollTop >= max - 1) { pending.set(el, { top: r.top, left: null, until: t + 2000, set: el.scrollTop }); tick(); return }
119
+ }
120
+ record(el, t)
121
+ }
122
+
123
+ // -- the app positioning a box itself (never undone)
124
+ function mark(el) { if (!restoring && el && el.nodeType === 1) { wrote.set(el, now()); pending.delete(el) } }
125
+ function patchProto() {
126
+ const E = win.Element && win.Element.prototype
127
+ if (!E || E.__scrollKeep) return
128
+ E.__scrollKeep = true
129
+ for (const prop of ['scrollTop', 'scrollLeft']) {
130
+ const d = Object.getOwnPropertyDescriptor(E, prop)
131
+ if (!d || !d.set) continue
132
+ Object.defineProperty(E, prop, { configurable: true, enumerable: d.enumerable, get: d.get, set(v) { d.set.call(this, v); if (!restoring) { mark(this); record(this) } } })
133
+ }
134
+ for (const m of ['scrollTo', 'scroll', 'scrollBy']) {
135
+ const f = E[m]; if (typeof f !== 'function') continue
136
+ E[m] = function (...a) { const r = f.apply(this, a); if (!restoring) { mark(this); if (a[0] && a[0].behavior === 'smooth') appUntil = now() + 1500; record(this) } return r }
137
+ }
138
+ const siv = E.scrollIntoView
139
+ if (typeof siv === 'function') E.scrollIntoView = function (...a) {
140
+ const r = siv.apply(this, a)
141
+ if (!restoring) { appUntil = now() + (a[0] && a[0].behavior === 'smooth' ? 1500 : 300); for (let p = this.parentElement; p; p = p.parentElement) { mark(p); record(p) } }
142
+ return r
143
+ }
144
+ for (const m of ['scrollTo', 'scroll', 'scrollBy']) {
145
+ const f = win[m]; if (typeof f !== 'function') continue
146
+ win[m] = function (...a) { const r = f.apply(win, a); if (!restoring) { mark(se()); if (a[0] && a[0].behavior === 'smooth') appUntil = now() + 1500; record(se()) } return r }
147
+ }
148
+ }
149
+
150
+ // -- focus and the caret
151
+ function caret(el) {
152
+ try { if (el && /^(INPUT|TEXTAREA)$/.test(el.tagName) && el.selectionStart != null) return { start: el.selectionStart, end: el.selectionEnd, dir: el.selectionDirection } } catch {}
153
+ return null
154
+ }
155
+ function onFocusIn(e) { const el = e.target; if (!el || el.nodeType !== 1 || restoring) return; focus = { el, key: scrollKey(el), route: route(), ...(caret(el) || {}) } }
156
+ function onFocusOut(e) {
157
+ const el = e.target
158
+ // a real blur (you clicked away) leaves the element in the page; one a redraw caused takes it out
159
+ win.setTimeout(() => { if (focus && focus.el === el && el.isConnected && doc.activeElement !== el) focus = null }, 0)
160
+ }
161
+ function onSel() { const a = doc.activeElement; if (focus && a === focus.el) Object.assign(focus, caret(a) || {}) }
162
+
163
+ // -- right after any change to the page
164
+ function restoreAll() {
165
+ const r = route() + '|'
166
+ for (const [k, rec] of recs) {
167
+ if (!k.startsWith(r)) continue
168
+ let el = rec.el.deref()
169
+ if (!el || !el.isConnected) el = elFor(k.slice(r.length))
170
+ if (!el) continue
171
+ if (el !== rec.el.deref()) rec.el = W(el)
172
+ const cur = { top: el.scrollTop, left: el.scrollLeft }
173
+ const want = restoreFor(rec, cur, { wroteAt: wrote.get(el) || 0 })
174
+ if (!want) continue
175
+ set(el, want.top, want.left)
176
+ stats.restored++
177
+ if (want.top != null && el.scrollTop < want.top - 1) { pending.set(el, { top: want.top, left: want.left, until: now() + 2000, set: el.scrollTop }); tick() }
178
+ }
179
+ for (const [k, open] of details) {
180
+ if (!k.startsWith(r)) continue
181
+ const d = findByKey(doc, k.slice(r.length))
182
+ if (d && d.tagName === 'DETAILS' && d.open !== open && !d.__skSeen) { d.__skSeen = true; restoring++; try { d.open = open } finally { restoring-- }; stats.reopened++ }
183
+ if (d) d.__skSeen = true
184
+ }
185
+ const a = doc.activeElement
186
+ if (focus && focus.route !== route()) focus = null // another page: what had focus there stays there
187
+ if (focus && !focus.el.isConnected && (!a || a === doc.body)) {
188
+ const el = findByKey(doc, focus.key)
189
+ if (el && typeof el.focus === 'function' && !el.disabled) {
190
+ restoring++
191
+ try {
192
+ el.focus({ preventScroll: true })
193
+ if (focus.start != null && caret(el) && el.value.length >= focus.end) el.setSelectionRange(focus.start, focus.end, focus.dir || 'none')
194
+ } catch {} finally { restoring-- }
195
+ if (doc.activeElement === el) { focus = { ...focus, el }; stats.refocused++ }
196
+ } else focus = null
197
+ }
198
+ }
199
+ // a box the content wasn't tall enough for yet: try again for a moment as it grows
200
+ let ticking = false
201
+ function tick() {
202
+ if (ticking) return
203
+ ticking = true
204
+ const step = () => {
205
+ const t = now()
206
+ for (const [el, p] of pending) {
207
+ if (t > p.until || !el.isConnected || user() || el.scrollTop !== p.set) { pending.delete(el); continue }
208
+ set(el, p.top, p.left)
209
+ p.set = el.scrollTop
210
+ if (Math.abs(el.scrollTop - p.top) <= 1) { pending.delete(el); record(el) }
211
+ }
212
+ if (pending.size) win.requestAnimationFrame(step); else ticking = false
213
+ }
214
+ win.requestAnimationFrame(step)
215
+ }
216
+ function onMutations(list) {
217
+ for (const m of list) if (m.addedNodes.length || m.removedNodes.length) { restoreAll(); for (const [el] of pending) if (el.isConnected && el.scrollTop !== pending.get(el).set) pending.delete(el); return }
218
+ }
219
+ function onToggle(e) {
220
+ const d = e.target
221
+ if (!d || d.tagName !== 'DETAILS' || restoring) return
222
+ d.__skSeen = true
223
+ if (now() - lastInput < 600) details.set(route() + '|' + scrollKey(d), d.open)
224
+ }
225
+
226
+ function start() {
227
+ patchProto()
228
+ const input = () => { lastInput = now() }
229
+ for (const ev of ['wheel', 'touchstart', 'touchmove', 'keydown', 'pointerdown', 'mousedown']) win.addEventListener(ev, input, { capture: true, passive: true })
230
+ doc.addEventListener('scroll', onScroll, { capture: true, passive: true })
231
+ doc.addEventListener('focusin', onFocusIn, true)
232
+ doc.addEventListener('focusout', onFocusOut, true)
233
+ doc.addEventListener('selectionchange', onSel)
234
+ doc.addEventListener('keyup', onSel, true)
235
+ doc.addEventListener('input', onSel, true)
236
+ doc.addEventListener('toggle', onToggle, true)
237
+ new win.MutationObserver(onMutations).observe(doc.body, { childList: true, subtree: true })
238
+ }
239
+ return {
240
+ start,
241
+ restoreNow: restoreAll,
242
+ // our own restore scrolls: a tip that hides on scroll stays up for these
243
+ quiet: () => now() < quietUntil,
244
+ stats,
245
+ _recs: recs,
246
+ }
247
+ }
package/ui/walk-pop.js CHANGED
@@ -61,7 +61,7 @@ export function popHtml(c, { esc, icon }) {
61
61
  }
62
62
 
63
63
  // the popover itself: one element on <body>, anchored under the button it explains
64
- export function initPops({ esc, icon, contentFor, doc = document, win = window }) {
64
+ export function initPops({ esc, icon, contentFor, doc = document, win = window, quiet = () => false }) {
65
65
  const pop = doc.createElement('div')
66
66
  pop.className = 'lo-pop'; pop.id = 'loPop'; pop.setAttribute('role', 'tooltip'); pop.hidden = true
67
67
  doc.body.appendChild(pop)
@@ -129,7 +129,7 @@ export function initPops({ esc, icon, contentFor, doc = document, win = window }
129
129
  }, true)
130
130
  doc.addEventListener('keydown', (e) => { if (e.key === 'Escape' && cur) { hide(); e.stopPropagation() } }, true)
131
131
  for (const ev of ['resize', 'hashchange']) win.addEventListener(ev, () => { if (cur) hide() }, true)
132
- // a scroll that moves the button closes it (the thread scrolling beside it doesn't)
133
- win.addEventListener('scroll', (e) => { const t = e.target; if (cur && t && (t === doc || t === win || (t.contains && t.contains(cur)))) hide() }, true)
132
+ // a scroll that moves the button closes it (the thread scrolling beside it doesn't; 0.9.18 nor a redraw putting a box back, quiet())
133
+ win.addEventListener('scroll', (e) => { const t = e.target; if (cur && !quiet() && t && (t === doc || t === win || (t.contains && t.contains(cur)))) hide() }, true)
134
134
  return { hide, refresh, el: pop }
135
135
  }