@leftoff/cli 0.9.8-beta.9 → 0.9.9-beta.1

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
@@ -47,17 +47,22 @@ export function cardPrsHtml(prs) {
47
47
  }
48
48
 
49
49
  // The session page: its PRs with Unlink, and "Link a PR" (a text box: a URL or #number).
50
- export function sessionPrsSection(s, { mayEdit = true } = {}) {
50
+ export function sessionPrsSection(s, { mayEdit = true, open = null, details = {} } = {}) {
51
51
  const list = (s.prs || []).filter((p) => p && p.number)
52
52
  let h = '<h2>Pull requests' + (list.length ? ' <span class="count">' + list.length + '</span>' : '') + '</h2>'
53
53
  if (list.length) {
54
54
  h += '<div class="prlist">' + list.map((p) => {
55
55
  const url = safeUrl(p.url)
56
- return '<div class="prrow slim"><div class="pr-main">' +
56
+ // 0.9.8: tap the title (or Details) for the same details as the PRs page, in place; GitHub ↗ still goes out
57
+ const isOpen = !!(open && open.has && open.has(p.url))
58
+ return '<article class="prrow slim' + (isOpen ? ' open' : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
57
59
  '<div class="pr-line1"><span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) + '</div>' +
58
- '<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || 'Pull request #' + p.number) + ' ↗</a>' : esc(p.title || '')) + '</div>' +
59
- '<div class="pr-meta">' + esc(p.repo || '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + '</div></div>' +
60
- (mayEdit ? '<button type="button" class="btn sm" data-action="pr-unlink" data-id="' + esc(s.id) + '" data-url="' + esc(p.url) + '">Unlink</button>' : '') + '</div>'
60
+ '<div class="pr-title"><button type="button" class="linkish pr-title-btn" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + isOpen + '">' + esc(p.title || 'Pull request #' + p.number) + '</button></div>' +
61
+ '<div class="pr-meta">' + esc(p.repo || '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + '</div>' +
62
+ (isOpen ? prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
63
+ '<div class="pr-actions"><button type="button" class="btn sm pr-toggle" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + isOpen + '">' + (isOpen ? 'Less ▴' : 'Details ▾') + '</button>' +
64
+ (url ? '<a class="btn sm" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">GitHub ↗</a>' : '') +
65
+ (mayEdit ? '<button type="button" class="btn sm" data-action="pr-unlink" data-id="' + esc(s.id) + '" data-url="' + esc(p.url) + '">Unlink</button>' : '') + '</div></article>'
61
66
  }).join('') + '</div>'
62
67
  } 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>'
63
68
  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>'
@@ -248,6 +248,7 @@ function patchedFetch(input, init) {
248
248
  }
249
249
 
250
250
  // EventSource('/v1/stream') -> long-polls of GET /v1/events through the tunnel.
251
+ export const POLL_WAIT_MS = 20000, POLL_MARGIN_MS = 12000
251
252
  class RelayEventSource extends EventTarget {
252
253
  constructor(url) {
253
254
  super()
@@ -259,11 +260,20 @@ class RelayEventSource extends EventTarget {
259
260
  let fails = 0
260
261
  while (this.readyState !== 2) {
261
262
  try {
262
- this.ctrl = new AbortController()
263
- const res = await patchedFetch('/v1/events?after=' + (this.seq == null ? -1 : this.seq) + '&ms=20000', { signal: this.ctrl.signal })
264
- if (!res.ok) throw new Error('HTTP ' + res.status)
265
- const j = await res.json()
263
+ // 0.9.8: a poll that outlives its wait plus a margin is dead (iOS
264
+ // suspended the page mid-request, or the relay dropped it silently):
265
+ // abort it and poll again instead of waiting forever.
266
+ const ctrl = this.ctrl = new AbortController()
267
+ const timer = setTimeout(() => ctrl.abort(), POLL_WAIT_MS + POLL_MARGIN_MS)
268
+ let j
269
+ try {
270
+ const res = await patchedFetch('/v1/events?after=' + (this.seq == null ? -1 : this.seq) + '&ms=' + POLL_WAIT_MS, { signal: ctrl.signal })
271
+ if (!res.ok) throw new Error('HTTP ' + res.status)
272
+ j = await res.json()
273
+ } finally { clearTimeout(timer) }
274
+ if (this.readyState === 2) return
266
275
  if (this.readyState === 0) { this.readyState = 1; if (this.onopen) this.onopen(new Event('open')) }
276
+ this.dispatchEvent(new MessageEvent('ping', { data: '{}' })) // a poll that answered is the stream's heartbeat
267
277
  const first = this.seq == null
268
278
  this.seq = j.seq
269
279
  if (!first) for (const e of j.events || []) {
@@ -62,3 +62,23 @@ export function restoreWhen(r = {}, { short = false } = {}) {
62
62
  if (r.restartKind === 'boot') return 'before ' + (r.ghostty === false ? 'this machine' : 'your Mac') + ' restarted'
63
63
  return 'before Leftoff restarted'
64
64
  }
65
+
66
+ // 0.9.8: the folded banner names the sessions, so you can tell which ones
67
+ // before you open the list. Each title in <b> (escaped; "(untitled)" when it
68
+ // has none); one session also gets its repo; four or more: the first two and
69
+ // "N more".
70
+ // [{ title, repoLabel }], esc -> "<b>Fix login flow</b> (leftoff) was"
71
+ export function restoreNames(sessions = [], esc = (x) => String(x)) {
72
+ const name = (s) => '<b>' + esc((s && s.title) || '(untitled)') + '</b>'
73
+ const n = sessions.length
74
+ if (!n) return ''
75
+ if (n === 1) return name(sessions[0]) + (sessions[0].repoLabel ? ' (' + esc(sessions[0].repoLabel) + ')' : '') + ' was'
76
+ const shown = n <= 3 ? sessions.map(name) : [name(sessions[0]), name(sessions[1]), (n - 2) + ' more']
77
+ return shown.slice(0, -1).join(', ') + ' and ' + shown[shown.length - 1] + ' were'
78
+ }
79
+
80
+ // The folded banner's sentence: who, and when.
81
+ // -> '<b>Fix login flow</b> (leftoff) was open before your Mac restarted (Oct 14 at 2:50 PM).'
82
+ export function restoreHeadline(r = {}, { esc = (x) => String(x), clock = (iso) => iso } = {}) {
83
+ return restoreNames(r.sessions || [], esc) + ' open ' + esc(restoreWhen(r)) + (r.restartAt ? ' (' + esc(clock(r.restartAt)) + ')' : '') + '.'
84
+ }
package/ui/tabs.js CHANGED
@@ -143,6 +143,7 @@ export function tabView(t, session, { title, working } = {}) {
143
143
  }
144
144
 
145
145
  const X = '<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="M18 6 6 18"/><path d="m6 6 12 12"/></svg>'
146
+ const PIN = '<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 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>'
146
147
  const BOARD_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"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>'
147
148
  const DOTS = '<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"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>'
148
149
  const PREV = '<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="m15 18-6-6 6-6"/></svg>'
@@ -156,6 +157,8 @@ function marks(v, esc) {
156
157
  function label(v) { return (v.preview ? 'Preview: ' : '') + v.title + (v.working ? ' · Claude is working' : '') + (v.needs ? ' · needs you' : '') }
157
158
 
158
159
  // The strip (700px and up): Board, then the open sessions, then ⋯ (all of them).
160
+ // Every session tab has a × (a preview has its Keep-open pin before it); the
161
+ // buttons' names show as styled tips (data-tip, app.js), not native titles.
159
162
  // The current tab is aria-current="true" (the current item of this set); the rail keeps "page".
160
163
  export function stripHtml(views, activeId, onBoard, esc) {
161
164
  const board = '<a class="tab tab-board' + (onBoard ? ' on' : '') + '" href="#/" data-tab="board"' + (onBoard ? ' aria-current="true"' : '') + ' title="Board">' + BOARD_IC + '<span class="tab-t">Board</span></a>'
@@ -165,12 +168,13 @@ export function stripHtml(views, activeId, onBoard, esc) {
165
168
  '<a class="tab tab-s' + (on ? ' on' : '') + (v.preview ? ' preview' : '') + '" href="' + esc(v.href) + '" data-tab="' + esc(v.id) + '"' + (on ? ' aria-current="true"' : '') + ' title="' + esc(v.title + (v.preview ? ' · preview: double-click to keep it open' : '')) + '" aria-label="' + esc(label(v)) + '" draggable="true">' +
166
169
  '<span class="tab-t">' + esc(v.title) + '</span>' + marks(v, esc) + '</a>' +
167
170
  (v.preview
168
- ? '<button type="button" class="tab-x tab-keep" data-tab-keep="' + esc(v.id) + '" title="Keep open" aria-label="' + esc('Keep ' + v.title + ' open') + '"><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 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg></button>'
169
- : '<button type="button" class="tab-x" data-tab-close="' + esc(v.id) + '" title="Close tab" aria-label="' + esc('Close ' + v.title) + '">' + X + '</button>') +
171
+ ? '<button type="button" class="tab-x tab-keep" data-tab-keep="' + esc(v.id) + '" data-tip="Keep open" aria-label="' + esc('Keep ' + v.title + ' open') + '">' + PIN + '</button>'
172
+ : '') +
173
+ '<button type="button" class="tab-x" data-tab-close="' + esc(v.id) + '" data-tip="Close tab" aria-label="' + esc('Close ' + v.title) + '">' + X + '</button>' +
170
174
  '</div>'
171
175
  }).join('')
172
176
  return '<div class="tabstrip-in">' + board + '<div class="tab-scroll" role="list">' + tabs + '</div></div>' +
173
- '<button type="button" class="tab-all" id="tabAll" title="All open sessions" aria-label="All open sessions (' + views.length + ')" aria-haspopup="menu"' + (views.length ? '' : ' hidden') + '>' + DOTS + '</button>'
177
+ '<button type="button" class="tab-all" id="tabAll" data-tip="All open sessions" aria-label="All open sessions (' + views.length + ')" aria-haspopup="menu"' + (views.length ? '' : ' hidden') + '>' + DOTS + '</button>'
174
178
  }
175
179
 
176
180
  // The menu behind ⋯ (picker.js's action menu): every open session, in order.
@@ -181,12 +185,12 @@ export function allTabsItems(views, activeId) {
181
185
  })))
182
186
  }
183
187
 
184
- // The phone's Open row on the board: the same order, the same marks, the preview in italics.
188
+ // The phone's Open row on the board: the same order, the same marks, the preview in italics, a × on each.
185
189
  export function openRowHtml(views, esc) {
186
190
  if (!views.length) return ''
187
191
  return '<div class="openrow-h"><span>Open</span><span class="faint">' + views.length + '</span></div><div class="openrow-chips">' + views.map((v) =>
188
192
  '<span class="ochip-cell' + (v.preview ? ' preview' : '') + '"><a class="ochip' + (v.preview ? ' preview' : '') + '" href="' + esc(v.href) + '" data-tab="' + esc(v.id) + '" aria-label="' + esc(label(v)) + '"><span class="tab-t">' + esc(v.title) + '</span>' + marks(v, esc) + '</a>' +
189
- (v.preview ? '' : '<button type="button" class="ochip-x" data-tab-close="' + esc(v.id) + '" aria-label="' + esc('Close ' + v.title) + '" title="Close">' + X + '</button>') + '</span>').join('') + '</div>'
193
+ '<button type="button" class="ochip-x" data-tab-close="' + esc(v.id) + '" aria-label="' + esc('Close ' + v.title) + '">' + X + '</button></span>').join('') + '</div>'
190
194
  }
191
195
 
192
196
  // A session page on a phone: "2 of 4" with ‹ › (44px) for people who don't swipe,
package/ui/tour.css ADDED
@@ -0,0 +1,63 @@
1
+ /* The product tour (0.9.8, ui/tour.js). Loaded only when the tour starts. */
2
+ .tour { position: fixed; inset: 0; z-index: 900; pointer-events: auto; touch-action: none; }
3
+ .tour-dim { position: absolute; inset: 0; background: rgba(3, 5, 10, .7); animation: tour-in .18s ease-out; }
4
+ .tour-dim[hidden], .tour-hole[hidden] { display: none; }
5
+ /* The spotlight: a clear box whose huge shadow dims everything around it. */
6
+ .tour-hole { position: fixed; border-radius: 12px; box-shadow: 0 0 0 2px var(--accent), 0 0 0 200vmax rgba(3, 5, 10, .7); pointer-events: none;
7
+ transition: left .25s ease, top .25s ease, width .25s ease, height .25s ease; }
8
+ [data-theme="light"] .tour-dim { background: rgba(15, 18, 28, .6); }
9
+ [data-theme="light"] .tour-hole { box-shadow: 0 0 0 2px var(--accent), 0 0 0 200vmax rgba(15, 18, 28, .6); }
10
+ .tour-card { position: fixed; left: 0; top: 0; width: 340px; max-width: calc(100vw - 16px - env(safe-area-inset-left) - env(safe-area-inset-right)); box-sizing: border-box;
11
+ background: var(--card2); color: var(--text); border: 1px solid var(--line-strong); border-radius: 16px; padding: 16px 16px 10px;
12
+ box-shadow: 0 18px 50px rgba(0, 0, 0, .45); transition: left .25s ease, top .25s ease; outline: none; }
13
+ [data-theme="light"] .tour-card { background: var(--card); box-shadow: 0 18px 50px rgba(22, 26, 36, .28); }
14
+ .tour-count { font-size: 12px; font-weight: 600; color: var(--muted); letter-spacing: .2px; }
15
+ .tour-card .tour-title { font-size: 17px; font-weight: 650; margin: 2px 0 6px; line-height: 1.25; color: var(--text); text-transform: none; letter-spacing: normal; }
16
+ .tour-body { font-size: 14px; line-height: 1.45; color: var(--text2); margin: 0 0 10px; }
17
+ .tour-nav { display: flex; align-items: center; gap: 8px; }
18
+ .tour-grow { flex: 1; }
19
+ .tour-nav .btn { min-height: 44px; box-sizing: border-box; text-decoration: none; min-width: 44px; padding: 6px 14px; font-size: 14px; justify-content: center; }
20
+ .tour-nav .tour-skip { color: var(--muted); padding: 6px 4px; background: none; border: 0; box-shadow: none; }
21
+ .tour-nav .tour-skip:hover { color: var(--text); background: none; }
22
+ .tour-still .tour-hole, .tour-still .tour-card, .tour-still .tour-dim { transition: none; animation: none; }
23
+ @media (prefers-reduced-motion: reduce) { .tour-hole, .tour-card, .tour-dim { transition: none; animation: none; } }
24
+ @keyframes tour-in { from { opacity: 0; } to { opacity: 1; } }
25
+ /* Read by tour.js for the safe areas. */
26
+ .tour-safe { position: fixed; inset: 0; visibility: hidden; pointer-events: none; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
27
+ @media (max-width: 699.98px) { .tour-card { width: 100vw; } }
28
+
29
+ /* Small drawings for a part that isn't on screen. */
30
+ .tour-illus { background: var(--bg); border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin: 0 0 12px; font-size: 12.5px; color: var(--text2); display: grid; gap: 8px; }
31
+ .tour-illus .ti-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
32
+ .tour-illus .ti-faint { color: var(--muted); }
33
+ .tour-illus .ti-pill { border-radius: 999px; padding: 1px 8px; font-size: 11px; font-weight: 600; background: var(--tint-cyan); color: var(--cyan); margin-left: auto; }
34
+ .tour-illus .ti-pill.ti-needs { background: var(--tint-yellow); color: var(--yellow); margin-left: 0; }
35
+ .tour-illus .ti-btn { border: 1px solid var(--btn-line); background: var(--btn-bg); border-radius: 7px; padding: 3px 9px; font-size: 12px; }
36
+ .tour-illus .ti-btn.ti-primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 600; }
37
+ .tour-illus .ti-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex: none; }
38
+ .tour-illus .ti-card, .tour-illus .ti-need { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 10px; display: grid; gap: 6px; }
39
+ .tour-illus .ti-need { border-color: var(--yellow); }
40
+ .tour-illus .ti-need.ti-mini { padding: 8px; }
41
+ .tour-illus .ti-cp { display: grid; gap: 2px; border-left: 2px solid var(--line-strong); padding-left: 8px; }
42
+ .tour-illus .ti-cp i { font-style: normal; color: var(--muted); }
43
+ .tour-illus .ti-live { display: grid; gap: 6px; }
44
+ .tour-illus .ti-msg { max-width: 80%; border-radius: 12px; padding: 5px 10px; }
45
+ .tour-illus .ti-you { justify-self: end; background: var(--tint-accent); color: var(--text); }
46
+ .tour-illus .ti-claude { justify-self: start; background: var(--card); border: 1px solid var(--line); }
47
+ .tour-illus .ti-composer { display: flex; align-items: center; gap: 8px; border: 1px solid var(--line-strong); border-radius: 18px; padding: 4px 4px 4px 10px; background: var(--field); }
48
+ .tour-illus .ti-clip { display: inline-flex; width: 16px; height: 16px; color: var(--muted); }
49
+ .tour-illus .ti-clip svg { width: 16px; height: 16px; }
50
+ .tour-illus .ti-input { flex: 1; color: var(--faint); }
51
+ .tour-illus .ti-send { width: 24px; height: 24px; border-radius: 50%; background: var(--accent); color: var(--on-accent); display: inline-flex; align-items: center; justify-content: center; font-weight: 700; }
52
+ .tour-illus .ti-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); }
53
+ .tour-illus .ti-tab { padding: 5px 10px; border: 1px solid var(--line); border-bottom: 0; border-radius: 8px 8px 0 0; background: var(--card); color: var(--muted); white-space: nowrap; }
54
+ .tour-illus .ti-tab.ti-on { color: var(--text); background: var(--card2); font-weight: 600; }
55
+ .tour-illus .ti-phone { width: 150px; margin: 0 auto; border: 2px solid var(--line-strong); border-radius: 22px; padding: 10px 8px 14px; display: grid; gap: 8px; background: var(--card); }
56
+ .tour-illus .ti-url { font-size: 10.5px; text-align: center; background: var(--bg2); border-radius: 8px; padding: 3px 6px; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
57
+ .tour-illus .ti-note { display: grid; gap: 1px; background: var(--card2); border: 1px solid var(--line); border-radius: 10px; padding: 6px 8px; font-size: 11px; box-shadow: 0 4px 12px rgba(0, 0, 0, .18); }
58
+ .tour-illus .ti-note span { color: var(--muted); }
59
+ .tour-illus .ti-lines { display: grid; gap: 5px; }
60
+ .tour-illus .ti-lines span { height: 7px; border-radius: 4px; background: var(--line); }
61
+ .tour-illus .ti-lines span:nth-child(2) { width: 70%; }
62
+ .tour-illus .ti-lines span:nth-child(3) { width: 45%; }
63
+ @media (max-height: 700px) { .tour-illus { padding: 8px; gap: 6px; } .tour-illus .ti-phone { width: 130px; } }
package/ui/tour.js ADDED
@@ -0,0 +1,364 @@
1
+ // The product tour (0.9.8): six quick spotlight steps over the real board,
2
+ // separate from the setup wizard (ui/wizard.js). It dims the page, cuts a hole
3
+ // around the part it talks about and puts a small card beside it. A part that
4
+ // isn't on screen (no session yet, tabs on a phone, Live outside a session)
5
+ // gets the card in the middle with a small drawing instead: never a spotlight
6
+ // on nothing, and nothing is written into your sessions for it.
7
+ //
8
+ // It runs by itself once per Mac (the record sits beside the wizard's,
9
+ // GET /v1/tour, POST /v1/tour/seen), after setup has been seen, on a plain
10
+ // board open. Settings → Updates & about → "Take the tour" and the command
11
+ // palette replay it (#/tour). The pure parts below are shared with the unit
12
+ // tests; startTour() is the DOM side.
13
+
14
+ export const TOUR_KEYS = ['needs', 'card', 'live', 'tabs', 'catchup', 'phone']
15
+
16
+ // Does the board start it by itself? state: GET /v1/tour. routeName: the
17
+ // route the board is on. Not over the setup wizard: on this Mac's own board
18
+ // it waits until setup has been seen (another device never gets the wizard).
19
+ export function shouldAutoTour(state, routeName) {
20
+ return !!state && state.auto === true && state.seen === false && (state.setupSeen === true || state.local === false) && (!routeName || routeName === 'board')
21
+ }
22
+
23
+ // The six steps for what's on screen. found: { needs: 'strip' | 'nav' | null,
24
+ // card: bool, live: bool, tabs: 'strip' | 'row' | null, catchup: bool }.
25
+ // ctx: { narrow: phone width, standalone: the Home Screen app, host: the
26
+ // leftoff.dev name or null }. Each step: { key, title, body, target, illus }:
27
+ // target names what to spotlight (null: the card sits in the middle with
28
+ // illus, a small drawing).
29
+ export function tourSteps(found = {}, ctx = {}) {
30
+ const f = found || {}
31
+ const host = ctx.host || 'yourname.leftoff.dev'
32
+ const steps = []
33
+ steps.push(f.needs === 'strip'
34
+ ? { key: 'needs', title: 'Needs you', body: 'Sessions waiting on you come first: a permission prompt or a question from Claude. Answer right here, or Go to the terminal.', target: 'needs-strip', illus: null }
35
+ : { key: 'needs', title: 'Needs you', body: 'When a session stops for a permission prompt or a question, it lands here, longest wait first. Answer from the board, or Go to the terminal.', target: f.needs === 'nav' ? 'needs-nav' : null, illus: f.needs === 'nav' ? null : 'needs' })
36
+ steps.push({ key: 'card', title: 'A session', body: 'One card per Claude Code session: its status and its latest checkpoint (what it was doing, where it stopped, what’s next). Go jumps to its terminal, Resume reopens a closed one, and Checkpoint now is in the ⋯ menu.', target: f.card ? 'card' : null, illus: f.card ? null : 'card' })
37
+ steps.push({ key: 'live', title: 'Live', body: 'Start a session with leftoff live and talk to it from its page: Claude reads your message as its next prompt. Attach photos or files with the paperclip, and answer permission prompts here or on your phone.', target: f.live ? 'live' : null, illus: f.live ? null : 'live' })
38
+ steps.push(ctx.narrow
39
+ ? { key: 'tabs', title: 'Open sessions', body: 'Sessions you open stay in an Open row on the board. On a session, swipe or tap ‹ › to move between them.', target: f.tabs === 'row' ? 'tabs-row' : null, illus: f.tabs === 'row' ? null : 'tabs' }
40
+ : { key: 'tabs', title: 'Tabs', body: 'Sessions open in tabs up here, next to the board. A click opens a preview tab; it stays once you type in it or double-click it.', target: f.tabs === 'strip' ? 'tabs-strip' : null, illus: f.tabs === 'strip' ? null : 'tabs' })
41
+ steps.push({ key: 'catchup', title: 'Catch up', body: 'Back after a while? Catch me up tells you what got done and what’s waiting. The digest sums up each day.', target: f.catchup ? 'catchup' : null, illus: f.catchup ? null : 'catchup' })
42
+ steps.push(ctx.narrow && ctx.standalone
43
+ ? { key: 'phone', title: 'Your phone', body: 'You’re in the Home Screen app. Tap Turn on notifications under More, and your phone buzzes when a session needs you.', target: null, illus: 'phone', host }
44
+ : { key: 'phone', title: 'Your phone', body: 'Open ' + host + ' on your iPhone and add it to your Home Screen. It works like an app, with a notification when a session needs you.', target: null, illus: 'phone', host, link: { href: '#/settings/phone', label: 'Phone settings' } })
45
+ return steps
46
+ }
47
+
48
+ // Next index for a move: dir -1 | 1, clamped to the steps there are.
49
+ export function moveIndex(i, n, dir) {
50
+ const k = Math.max(0, Math.min(Number(n) - 1, (Number(i) || 0) + (dir < 0 ? -1 : 1)))
51
+ return Number.isFinite(k) ? k : 0
52
+ }
53
+
54
+ // Where the card goes. target: { top, left, width, height } (the spotlight)
55
+ // or null (the middle). card: { width, height }. vp: { width, height }.
56
+ // insets: { top, right, bottom, left } kept clear (safe areas, the phone's
57
+ // bottom bar). Below, else above, else beside, else over the bottom of the
58
+ // target; always inside vp minus insets (and a margin) when it fits there.
59
+ export function placeCard(target, card, vp, insets = {}, { gap = 12, margin = 8 } = {}) {
60
+ const ins = { top: 0, right: 0, bottom: 0, left: 0, ...insets }
61
+ const x0 = ins.left + margin, x1 = vp.width - ins.right - margin
62
+ const y0 = ins.top + margin, y1 = vp.height - ins.bottom - margin
63
+ const cw = Math.min(card.width, x1 - x0), ch = card.height
64
+ const clampX = (x) => Math.max(x0, Math.min(x1 - cw, x))
65
+ const clampY = (y) => Math.max(y0, Math.min(y1 - ch, y))
66
+ const out = (left, top, side) => ({ left: Math.round(left), top: Math.round(top), side })
67
+ if (!target) return out(clampX((x0 + x1 - cw) / 2), clampY((y0 + y1 - ch) / 2), 'center')
68
+ const t = { top: target.top, left: target.left, bottom: target.top + target.height, right: target.left + target.width }
69
+ const midX = clampX(t.left + (t.right - t.left) / 2 - cw / 2)
70
+ if (y1 - (t.bottom + gap) >= ch) return out(midX, Math.max(y0, t.bottom + gap), 'below')
71
+ if (t.top - gap - y0 >= ch) return out(midX, Math.min(y1 - ch, t.top - gap - ch), 'above')
72
+ const midY = clampY(t.top)
73
+ if (x1 - (t.right + gap) >= cw) return out(t.right + gap, midY, 'right')
74
+ if (t.left - gap - x0 >= cw) return out(t.left - gap - cw, midY, 'left')
75
+ return out(midX, clampY(y1 - ch), 'over')
76
+ }
77
+
78
+ // The spotlight for an element's rect: a little room around it, kept on screen.
79
+ export function holeFor(rect, vp, pad = 6) {
80
+ const left = Math.max(2, rect.left - pad), top = Math.max(2, rect.top - pad)
81
+ const right = Math.min(vp.width - 2, rect.left + rect.width + pad), bottom = Math.min(vp.height - 2, rect.top + rect.height + pad)
82
+ if (right - left < 4 || bottom - top < 4) return null
83
+ return { left, top, width: right - left, height: bottom - top }
84
+ }
85
+
86
+ // ------------------------------------------------------------- drawings
87
+
88
+ const CLIP = '<svg 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="m21.4 11.1-8.9 8.9a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6l8.2-8.2"/></svg>'
89
+ const ESC = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
90
+
91
+ // Small static drawings for a step whose part isn't on screen. Demo names only.
92
+ export function illusHtml(name, step = {}) {
93
+ const box = (inner) => '<div class="tour-illus tour-illus-' + name + '" aria-hidden="true">' + inner + '</div>'
94
+ if (name === 'needs') return box('<div class="ti-need"><span class="ti-pill ti-needs">needs you</span><b>api</b><span class="ti-faint">Bash: pnpm test</span><div class="ti-row"><span class="ti-btn ti-primary">Allow</span><span class="ti-btn">Deny</span></div></div>')
95
+ if (name === 'card') return box('<div class="ti-card"><div class="ti-row"><span class="ti-dot"></span><b>Fix the login redirect</b><span class="ti-pill">waiting</span></div><div class="ti-faint">acme/web · studio · 2 min</div>' +
96
+ '<div class="ti-cp"><div><i>Doing</i> moving the redirect into middleware</div><div><i>Stopped at</i> tests for the expired-session case</div><div><i>Next</i> run the e2e suite</div></div>' +
97
+ '<div class="ti-row"><span class="ti-btn ti-primary">Go</span><span class="ti-btn">⋯</span></div></div>')
98
+ if (name === 'live') return box('<div class="ti-live"><div class="ti-msg ti-you">Can you run the tests?</div><div class="ti-msg ti-claude">Running them now.</div>' +
99
+ '<div class="ti-need ti-mini"><span class="ti-faint">Allow Bash: pnpm test?</span><div class="ti-row"><span class="ti-btn ti-primary">Allow</span><span class="ti-btn">Deny</span></div></div>' +
100
+ '<div class="ti-composer"><span class="ti-clip">' + CLIP + '</span><span class="ti-input">Message Claude</span><span class="ti-send">↑</span></div></div>')
101
+ if (name === 'tabs') return box('<div class="ti-tabs"><span class="ti-tab ti-on">Board</span><span class="ti-tab"><i>acme/web</i></span><span class="ti-tab">api ×</span></div>')
102
+ if (name === 'catchup') return box('<div class="ti-row"><span class="ti-btn ti-primary">Catch me up</span><span class="ti-btn">Today’s digest →</span></div>')
103
+ if (name === 'phone') return box('<div class="ti-phone"><div class="ti-url">' + ESC(step.host || 'yourname.leftoff.dev') + '</div><div class="ti-note"><b>Leftoff</b><span>api needs you: Allow Bash?</span></div><div class="ti-lines"><span></span><span></span><span></span></div></div>')
104
+ return ''
105
+ }
106
+
107
+ // The card's inside for step i of n.
108
+ export function cardHtml(step, i, n) {
109
+ const last = i === n - 1
110
+ return (step.illus ? illusHtml(step.illus, step) : '') +
111
+ '<div class="tour-count">' + (i + 1) + ' of ' + n + '</div>' +
112
+ '<h2 class="tour-title" id="tourTitle">' + ESC(step.title) + '</h2>' +
113
+ '<p class="tour-body" id="tourBody">' + ESC(step.body) + '</p>' +
114
+ '<div class="tour-nav">' + (last ? (step.link ? '<a class="btn tour-link" href="' + ESC(step.link.href) + '" data-tour="link">' + ESC(step.link.label) + '</a>' : '') + '<span class="tour-grow"></span>' : '<button type="button" class="btn linkish tour-skip" data-tour="skip">Skip tour</button><span class="tour-grow"></span>') +
115
+ (i > 0 ? '<button type="button" class="btn" data-tour="back">Back</button>' : '') +
116
+ '<button type="button" class="btn primary" data-tour="' + (last ? 'done' : 'next') + '">' + (last ? 'Done' : 'Next') + '</button></div>'
117
+ }
118
+
119
+ // ------------------------------------------------------------- the DOM side
120
+
121
+ const SEL = {
122
+ 'needs-strip': '#app .needs-strip',
123
+ 'needs-nav': '#needsTab',
124
+ card: '#app article.card:not(.agentcard):not(.closed-card)',
125
+ live: '#app #live',
126
+ 'tabs-strip': '#tabStrip',
127
+ 'tabs-row': '#openRow',
128
+ catchup: '#app .pagehead .actions',
129
+ }
130
+ function shown(el) {
131
+ if (!el || el.hidden || el.closest('[hidden]')) return false
132
+ const r = el.getBoundingClientRect()
133
+ if (r.width < 4 || r.height < 4) return false
134
+ const cs = getComputedStyle(el)
135
+ return cs.visibility !== 'hidden' && cs.display !== 'none'
136
+ }
137
+ // The room a target gets on screen: under the top bars, above the phone's bottom bar.
138
+ function room() {
139
+ const ins = insets()
140
+ let top = ins.top
141
+ for (const el of document.querySelectorAll('.top, #tabStrip')) {
142
+ if (!shown(el)) continue
143
+ const r = el.getBoundingClientRect(), pos = getComputedStyle(el).position
144
+ if ((pos === 'fixed' || pos === 'sticky') && r.top < 10 && r.bottom < window.innerHeight / 3) top = Math.max(top, r.bottom)
145
+ }
146
+ return { top, bottom: window.innerHeight - ins.bottom, ins }
147
+ }
148
+ function findEl(target, cardH = 260) {
149
+ if (!target) return null
150
+ const all = [...document.querySelectorAll(SEL[target] || '#none')].filter(shown)
151
+ if (target === 'card') {
152
+ // a session card short enough to sit beside the tour's card, else the shortest of the first few
153
+ const cards = all.filter((el) => !el.closest('.needs-strip')).slice(0, 8)
154
+ const r = room(), fits = (el) => el.getBoundingClientRect().height + cardH + 40 <= r.bottom - r.top
155
+ return cards.find((el) => fits(el) && el.querySelector('.actions-row')) || cards.sort((a, b) => a.offsetHeight - b.offsetHeight)[0] || null
156
+ }
157
+ return all[0] || null
158
+ }
159
+ // What gets the spotlight: the element, or for the Needs you strip its head and first ask.
160
+ function rectOf(target, el) {
161
+ if (target === 'needs-strip') {
162
+ const parts = [el.querySelector('.needs-head'), el.querySelector('.needs-list > *')].filter(Boolean).map((x) => x.getBoundingClientRect())
163
+ if (parts.length) {
164
+ const l = Math.min(...parts.map((r) => r.left)), t = Math.min(...parts.map((r) => r.top)), rr = Math.max(...parts.map((r) => r.right)), b = Math.max(...parts.map((r) => r.bottom))
165
+ return { left: l, top: t, width: rr - l, height: b - t }
166
+ }
167
+ }
168
+ return el.getBoundingClientRect()
169
+ }
170
+ // What's on screen right now, for tourSteps().
171
+ export function probe() {
172
+ const has = (t) => !!findEl(t)
173
+ return {
174
+ needs: has('needs-strip') ? 'strip' : has('needs-nav') ? 'nav' : null,
175
+ card: has('card'), live: has('live'),
176
+ tabs: has('tabs-strip') ? 'strip' : has('tabs-row') ? 'row' : null,
177
+ catchup: has('catchup'),
178
+ }
179
+ }
180
+
181
+ let current = null // one tour at a time
182
+ export function tourOpen() { return !!current }
183
+
184
+ function loadCss() {
185
+ if (document.querySelector('link[data-tour-css]')) return Promise.resolve()
186
+ return new Promise((resolve) => {
187
+ const l = document.createElement('link')
188
+ l.rel = 'stylesheet'; l.href = 'tour.css'; l.dataset.tourCss = '1'
189
+ const t = setTimeout(resolve, 2000)
190
+ l.onload = l.onerror = () => { clearTimeout(t); resolve() }
191
+ document.head.appendChild(l)
192
+ })
193
+ }
194
+ const waitFor = (pred, ms = 4000) => new Promise((resolve) => {
195
+ const t0 = Date.now()
196
+ const tick = () => { if (pred()) return resolve(true); if (Date.now() - t0 > ms) return resolve(false); setTimeout(tick, 60) }
197
+ tick()
198
+ })
199
+ const narrowQ = () => window.matchMedia('(max-width: 699.98px)').matches
200
+ const reduced = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches
201
+ const isStandalone = () => !!(window.navigator.standalone || (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches))
202
+
203
+ // Safe areas, read from a probe the stylesheet pads with env(), plus the
204
+ // phone's bottom bar while it shows.
205
+ function insets() {
206
+ const p = document.createElement('div')
207
+ p.className = 'tour-safe'
208
+ document.body.appendChild(p)
209
+ const cs = getComputedStyle(p)
210
+ const ins = { top: parseFloat(cs.paddingTop) || 0, right: parseFloat(cs.paddingRight) || 0, bottom: parseFloat(cs.paddingBottom) || 0, left: parseFloat(cs.paddingLeft) || 0 }
211
+ p.remove()
212
+ const bar = document.querySelector('.top .tabs')
213
+ if (bar && narrowQ() && !document.body.classList.contains('nav-hidden')) {
214
+ const r = bar.getBoundingClientRect()
215
+ if (r.height && r.top > window.innerHeight / 2) ins.bottom = Math.max(ins.bottom, window.innerHeight - r.top)
216
+ }
217
+ return ins
218
+ }
219
+
220
+ // Start it. deps: { api, auto: started by itself, onEnd(how) }. Goes to the
221
+ // board first when it's somewhere else.
222
+ export async function startTour({ api, auto = false, onEnd = () => {} } = {}) {
223
+ if (current) return current
224
+ const me = (current = { closed: false })
225
+ const back = document.activeElement && document.activeElement !== document.body ? document.activeElement : null
226
+ if (!/^#\/?(\?|$)/.test(location.hash || '#/')) location.hash = '#/'
227
+ // the board, drawn (its head links to the digest; no other page's does)
228
+ await Promise.all([loadCss(), waitFor(() => /^#\/?(\?|$)/.test(location.hash || '#/') && !!document.querySelector('#app .pagehead .actions a[href="#/digest"]'))])
229
+ if (me.closed) return
230
+ if (document.querySelector('.wz')) { current = null; return } // the setup wizard is up: not over it
231
+ let host = /\.leftoff\.dev$/.test(location.hostname) ? location.hostname : null
232
+ if (!host && api) { try { const r = await api('GET', '/v1/relay', undefined, { timeoutMs: 1500 }); if (r && r.host) host = r.host } catch {} }
233
+
234
+ const root = document.createElement('div')
235
+ root.className = 'tour' + (reduced() ? ' tour-still' : '')
236
+ root.innerHTML = '<div class="tour-dim"></div><div class="tour-hole" hidden></div>' +
237
+ '<div class="tour-card" role="dialog" aria-modal="true" aria-labelledby="tourTitle" aria-describedby="tourBody" tabindex="-1"></div>'
238
+ document.body.appendChild(root)
239
+ document.body.classList.add('tour-on')
240
+ const dim = root.querySelector('.tour-dim'), hole = root.querySelector('.tour-hole'), card = root.querySelector('.tour-card')
241
+ let i = 0, steps = [], raf = 0, timer = 0, pinned = null, marked = null
242
+
243
+ const ctx = () => ({ narrow: narrowQ(), standalone: isStandalone(), host })
244
+ const place = () => {
245
+ raf = 0
246
+ if (me.closed) return
247
+ const step = steps[i]
248
+ const el = step && (pinned && pinned.isConnected ? pinned : findEl(step.target, card.offsetHeight))
249
+ const vp = { width: window.innerWidth, height: window.innerHeight }
250
+ let h = el ? holeFor(rectOf(step.target, el), vp) : null
251
+ // A target too tall to sit beside the card: the spotlight keeps its top part.
252
+ if (h && step.target !== 'needs-nav' && step.target !== 'tabs-strip') {
253
+ const rm = room(), spare = rm.bottom - 12 - card.offsetHeight - 12 - h.top
254
+ if (h.top + h.height > rm.bottom - card.offsetHeight - 24 && h.top - rm.top < card.offsetHeight + 24 && spare >= 80) h = { ...h, height: Math.min(h.height, spare) }
255
+ }
256
+ // the spotlit element carries data-tour-on (the browser check reads it)
257
+ const lit = h ? el : null
258
+ if (marked !== lit) { if (marked) marked.removeAttribute('data-tour-on'); if (lit) lit.setAttribute('data-tour-on', step.target); marked = lit }
259
+ root.dataset.step = step ? step.key : ''
260
+ root.dataset.mode = h ? 'spot' : 'center'
261
+ if (h) {
262
+ hole.hidden = false; dim.hidden = true
263
+ Object.assign(hole.style, { left: h.left + 'px', top: h.top + 'px', width: h.width + 'px', height: h.height + 'px' })
264
+ } else { hole.hidden = true; dim.hidden = false }
265
+ const at = placeCard(h, { width: card.offsetWidth, height: card.offsetHeight }, vp, insets())
266
+ Object.assign(card.style, { left: at.left + 'px', top: at.top + 'px' })
267
+ card.dataset.side = at.side
268
+ }
269
+ const soon = () => { if (!raf) raf = requestAnimationFrame(place) }
270
+
271
+ const show = (k, focus = true) => {
272
+ i = k
273
+ steps = tourSteps(probe(), ctx())
274
+ let step = steps[i]
275
+ // The target a step names: brought on screen under the top bars (the bars
276
+ // held, so the phone's bottom bar doesn't slide away under it).
277
+ if (window.leftoffNav) window.leftoffNav.show()
278
+ card.innerHTML = cardHtml(step, i, steps.length) // measured below
279
+ const el = findEl(step.target, card.offsetHeight)
280
+ pinned = el
281
+ if (el && step.target !== 'needs-nav' && step.target !== 'tabs-strip') {
282
+ const r = rectOf(step.target, el), rm = room()
283
+ const fitsWithCard = r.height + card.offsetHeight + 24 <= rm.bottom - rm.top
284
+ if (fitsWithCard ? (r.top < rm.top + 8 || r.top + r.height + card.offsetHeight + 24 > rm.bottom) : (r.top < rm.top + 8 || r.top > rm.top + 24)) window.scrollBy(0, r.top - rm.top - 12)
285
+ }
286
+ if (!el && step.target) { steps[i] = step = { ...step, target: null, illus: step.illus || step.key } }
287
+ card.innerHTML = cardHtml(step, i, steps.length)
288
+ card.dataset.key = step.key
289
+ place()
290
+ clearTimeout(timer); timer = setTimeout(place, 260) // after the bars and the scroll settle
291
+ if (focus) { const b = card.querySelector('[data-tour="next"], [data-tour="done"]'); if (b) b.focus({ preventScroll: true }) }
292
+ }
293
+
294
+ const end = (how) => {
295
+ if (me.closed) return
296
+ me.closed = true; current = null
297
+ cancelAnimationFrame(raf); clearTimeout(timer)
298
+ mo.disconnect()
299
+ window.removeEventListener('resize', soon); window.removeEventListener('scroll', soon, true)
300
+ document.removeEventListener('keydown', onKey, true)
301
+ root.remove(); document.body.classList.remove('tour-on')
302
+ if (api) api('POST', '/v1/tour/seen', { how }).catch(() => {})
303
+ document.removeEventListener('focusin', onFocus)
304
+ window.removeEventListener('hashchange', onHash)
305
+ if (marked) marked.removeAttribute('data-tour-on')
306
+ // focus goes back where it was; if that's gone (Settings → Take the tour), to the page's heading
307
+ const to = back && back.isConnected ? back : document.querySelector('#app .pagehead h1')
308
+ if (to && to !== back) to.tabIndex = -1
309
+ if (to && to.focus) { try { to.focus({ preventScroll: true }) } catch {} }
310
+ onEnd(how)
311
+ }
312
+ me.end = end
313
+
314
+ const go = (dir) => { const k = moveIndex(i, steps.length, dir); if (k !== i) show(k) }
315
+ root.addEventListener('click', (e) => {
316
+ const b = e.target.closest('[data-tour]')
317
+ if (!b) return
318
+ const a = b.dataset.tour
319
+ if (a === 'next') go(1)
320
+ else if (a === 'back') go(-1)
321
+ else if (a === 'done') end('done')
322
+ else if (a === 'skip') end('skipped')
323
+ else if (a === 'link') { e.preventDefault(); const to = b.getAttribute('href'); end('done'); location.hash = to }
324
+ })
325
+ // Nothing under the dim scrolls or takes a tap while it's up.
326
+ const still = (e) => { if (!card.contains(e.target)) e.preventDefault() }
327
+ root.addEventListener('wheel', still, { passive: false })
328
+ root.addEventListener('touchmove', still, { passive: false })
329
+ function onKey(e) {
330
+ if (me.closed) return
331
+ const k = e.key
332
+ if (k === 'Escape') { e.preventDefault(); end('skipped') }
333
+ else if (k === 'ArrowRight' || k === 'PageDown') { e.preventDefault(); go(1) }
334
+ else if (k === 'ArrowLeft' || k === 'PageUp') { e.preventDefault(); go(-1) }
335
+ else if (k === 'Enter' && !(e.target.closest && e.target.closest('button, a') && card.contains(e.target))) { e.preventDefault(); if (i === steps.length - 1) end('done'); else go(1) }
336
+ else if (k === 'Tab') {
337
+ // focus stays in the card
338
+ const f = [...card.querySelectorAll('button, a[href]')].filter((x) => !x.disabled)
339
+ if (!f.length) return
340
+ const at = f.indexOf(document.activeElement)
341
+ e.preventDefault()
342
+ const n = e.shiftKey ? (at <= 0 ? f.length - 1 : at - 1) : (at < 0 || at === f.length - 1 ? 0 : at + 1)
343
+ f[n].focus()
344
+ } else if (!card.contains(e.target)) e.preventDefault()
345
+ e.stopPropagation() // the board's own shortcuts wait
346
+ }
347
+ document.addEventListener('keydown', onKey, true)
348
+ function onFocus(e) { if (!me.closed && !root.contains(e.target)) { const b = card.querySelector('[data-tour="next"], [data-tour="done"]'); if (b) b.focus({ preventScroll: true }) } }
349
+ document.addEventListener('focusin', onFocus)
350
+ // The board redraws under it (live updates): the spotlight follows.
351
+ const mo = new MutationObserver(() => { if (pinned && !pinned.isConnected) pinned = null; soon() })
352
+ const app = document.getElementById('app')
353
+ if (app) mo.observe(app, { childList: true, subtree: true })
354
+ window.addEventListener('resize', soon)
355
+ window.addEventListener('scroll', soon, true)
356
+ // Leaving the board (a link, the back button) ends it.
357
+ function onHash() { if (!me.closed && !/^#\/?(\?|$)/.test(location.hash || '#/')) end('skipped') }
358
+ window.addEventListener('hashchange', onHash)
359
+ show(0)
360
+ return me
361
+ }
362
+
363
+ // Close a running tour (the board calls this before it opens something else).
364
+ export function endTour(how = 'skipped') { if (current && current.end) current.end(how) }