@leftoff/cli 0.9.8-beta.14 → 0.9.8-beta.16
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/CHANGELOG.md +3 -0
- package/bin/leftoff.js +287 -286
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +5 -0
- package/ui/app.js +19 -6
- package/ui/tour.css +63 -0
- package/ui/tour.js +364 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.8-beta.
|
|
3
|
+
"version": "0.9.8-beta.16",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Joel"
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.8-beta.
|
|
31
|
+
const MOD_VERSION = '0.9.8-beta.16' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.8-beta.
|
|
3
|
+
"version": "0.9.8-beta.16",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -2610,3 +2610,8 @@ html.pv-open, html.pv-open body { overflow: hidden; }
|
|
|
2610
2610
|
.updatebar .ub-go:focus-visible, .updatebar .ub-x:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
2611
2611
|
@media (min-width: 700px) { .updatebar { left: calc(50% + var(--rail-w, 0px) / 2); } }
|
|
2612
2612
|
@media (prefers-reduced-motion: reduce) { .updatebar { transition: none; } }
|
|
2613
|
+
|
|
2614
|
+
/* 0.9.8: a non-Live session's Live box offers the Live resume command */
|
|
2615
|
+
.live-box.off .live-resume-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 6px; }
|
|
2616
|
+
.live-box.off .live-resume-row code { flex: 1 1 220px; min-width: 0; overflow-wrap: anywhere; font-size: 12.5px; padding: 8px 10px; border-radius: 8px; background: var(--card2); border: 1px solid var(--line); }
|
|
2617
|
+
.live-box.off .live-resume-row .btn { min-height: 44px; gap: 7px; }
|
package/ui/app.js
CHANGED
|
@@ -5,6 +5,7 @@ import { icon } from './icons.js'
|
|
|
5
5
|
import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet } from './picker.js'
|
|
6
6
|
import { SETTINGS, settingsKeyFor } from './settings-nav.js'
|
|
7
7
|
import { mountWizard, shouldAutoOpen } from './wizard.js'
|
|
8
|
+
import { shouldAutoTour, startTour } from './tour.js' // 0.9.8 the product tour
|
|
8
9
|
import { changedKeys } from './settings-dirty.js'
|
|
9
10
|
import { looksMd, stripMd, plainCp } from './cp-text.js'
|
|
10
11
|
import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
|
|
@@ -1483,7 +1484,8 @@ function liveBoxHtml(s, v) {
|
|
|
1483
1484
|
const how = state.liveHow
|
|
1484
1485
|
return '<section class="live-box off" aria-label="Live link"><div class="live-head"><h2>Live <span class="pill soon">Beta</span></h2></div>' +
|
|
1485
1486
|
'<p class="faint">To send this session messages from here or your phone, and answer its permission prompts, start it with <code>leftoff live</code>' +
|
|
1486
|
-
(how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p
|
|
1487
|
+
(how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p>' +
|
|
1488
|
+
(s.liveResumeCommand ? '<p class="faint">Already running? Exit it in its terminal, then paste this to bring it back Live:</p><div class="live-resume-row"><code class="mono">' + esc(s.liveResumeCommand) + '</code><button type="button" class="btn" data-action="copy-cmd" data-cmd="' + esc(s.liveResumeCommand) + '">' + icon('copy') + 'Copy Live resume</button></div>' : '') + '</section>'
|
|
1487
1489
|
}
|
|
1488
1490
|
const msgs = (v && v.messages) || []
|
|
1489
1491
|
const may = state.mayAnswer !== false
|
|
@@ -3176,7 +3178,7 @@ async function viewSettings() {
|
|
|
3176
3178
|
updates: '<div class="section">About</div>' +
|
|
3177
3179
|
'<label>Version</label><div class="aboutline"><span class="chip mono">v' + esc(h.version || '') + '</span><a href="#/whats-new">What’s new</a><a href="' + NPM_URL + '" target="_blank" rel="noopener">npm ↗</a></div>' +
|
|
3178
3180
|
'<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
|
|
3179
|
-
'<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a></div>' +
|
|
3181
|
+
'<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a> · <a href="#/tour">Take the tour</a></div>' +
|
|
3180
3182
|
advanced('updates', '<label for="f-updates">Check daily</label><label class="check"><input id="f-updates" type="checkbox"' + (!(c.updates && c.updates.check === false) ? ' checked' : '') + '><span>Look for a new version daily. Nothing installs on its own.</span></label>' +
|
|
3181
3183
|
'<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
|
|
3182
3184
|
'<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>'),
|
|
@@ -3875,7 +3877,7 @@ function paintListsCount(lists) {
|
|
|
3875
3877
|
// The session page's Lists section: saved lists, the hint, and "Find lists in this session".
|
|
3876
3878
|
function sessionListsSection(s) {
|
|
3877
3879
|
const f = listUi.found[s.id]
|
|
3878
|
-
let h = '<h2>Lists <span class="count">' + ((s.lists || []).length) + '</span><button type="button" class="helpq" data-action="list-help-
|
|
3880
|
+
let h = '<h2>Lists <span class="count">' + ((s.lists || []).length) + '</span><button type="button" class="helpq" data-action="list-help-inline" aria-expanded="' + (listUi.sHelp ? 'true' : 'false') + '" aria-controls="sListsHelp" title="How lists work">?</button></h2>' + (listUi.sHelp ? listsHelp().replace('id="listsHelp"', 'id="sListsHelp"') : '')
|
|
3879
3881
|
if (s.lists && s.lists.length) h += '<div class="lists">' + s.lists.map((x) => (state.listFull && state.listFull[x.ref] ? listCard(state.listFull[x.ref], { session: false }) : '')).join('') + '</div>'
|
|
3880
3882
|
if (s.listHint) h += '<div class="lfound hint"><div><strong>Claude wrote a ' + esc(s.listHint.count) + '-step list</strong>: ' + esc(s.listHint.name) + '</div><div class="row"><button type="button" class="btn primary" data-action="list-save-hint" data-id="' + esc(s.id) + '">Save</button><button type="button" class="btn" data-action="list-dismiss" data-id="' + esc(s.id) + '">Dismiss</button></div></div>'
|
|
3881
3883
|
h += '<div class="lfind"><button type="button" class="btn" data-action="list-find" data-id="' + esc(s.id) + '"' + (listUi.finding[s.id] ? ' disabled' : '') + '>' + icon('list-checks') + (listUi.finding[s.id] ? 'Reading the transcript…' : 'Find lists in this session') + '</button>' +
|
|
@@ -3920,6 +3922,7 @@ async function render() {
|
|
|
3920
3922
|
else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
|
|
3921
3923
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
3922
3924
|
else if (r.name === 'setup') await viewSetup()
|
|
3925
|
+
else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
|
|
3923
3926
|
else await viewBoard()
|
|
3924
3927
|
tabsAfterRender()
|
|
3925
3928
|
} catch (e) {
|
|
@@ -3932,12 +3935,20 @@ async function render() {
|
|
|
3932
3935
|
// it keeps its own step and fetches its own state.
|
|
3933
3936
|
async function viewSetup() {
|
|
3934
3937
|
if ($app.querySelector('.wz')) return
|
|
3935
|
-
await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/' } })
|
|
3938
|
+
await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/'; autoTour() } })
|
|
3936
3939
|
}
|
|
3937
3940
|
// Opens #/setup by itself once, on a Mac's own board, when the board opens plain (no deep link).
|
|
3941
|
+
// Otherwise the product tour gets its turn.
|
|
3938
3942
|
async function firstRunSetup() {
|
|
3939
|
-
if (location.hash.replace(/^#\/?/, ''))
|
|
3940
|
-
|
|
3943
|
+
if (!location.hash.replace(/^#\/?/, '')) {
|
|
3944
|
+
try { const s = await api('GET', '/v1/setup'); if (shouldAutoOpen(s, (state.route || {}).name) && !location.hash.replace(/^#\/?/, '')) return location.replace('#/setup') } catch {}
|
|
3945
|
+
}
|
|
3946
|
+
autoTour()
|
|
3947
|
+
}
|
|
3948
|
+
// The product tour (0.9.8, ui/tour.js): by itself once per Mac, after setup, on
|
|
3949
|
+
// the board; #/tour (Settings → Updates & about, the palette) replays it.
|
|
3950
|
+
async function autoTour() {
|
|
3951
|
+
try { const t = await api('GET', '/v1/tour'); if (shouldAutoTour(t, parseRoute().name)) startTour({ api, auto: true }) } catch {}
|
|
3941
3952
|
}
|
|
3942
3953
|
|
|
3943
3954
|
// A remote browser (Tailscale) that hasn't signed in yet: the iPhone
|
|
@@ -4631,6 +4642,7 @@ function paletteActions() {
|
|
|
4631
4642
|
}
|
|
4632
4643
|
if (projectsUi.mayEdit) out.push({ action: 'project-new', title: 'New project…', sub: 'a named group of repos', icon: 'plus' })
|
|
4633
4644
|
out.push({ action: 'gen-digest-go', title: 'Today’s digest', sub: 'open it', icon: 'newspaper', href: '#/digest' })
|
|
4645
|
+
out.push({ action: 'tour', title: 'Take the tour', sub: 'a quick look around the board', icon: 'sparkles', href: '#/tour' })
|
|
4634
4646
|
return out
|
|
4635
4647
|
}
|
|
4636
4648
|
function searchGroups(q, r) {
|
|
@@ -4950,6 +4962,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
4950
4962
|
if (action === 'list-showall') { listUi.showAll = !listUi.showAll; return render() }
|
|
4951
4963
|
if (action === 'list-help') { listUi.help = !listUi.help; return render() }
|
|
4952
4964
|
if (action === 'list-help-go') { listUi.help = true; location.hash = '#/lists'; return }
|
|
4965
|
+
if (action === 'list-help-inline') { listUi.sHelp = !listUi.sHelp; render(); return }
|
|
4953
4966
|
if (action === 'remote-link' || action === 'remote-code') {
|
|
4954
4967
|
const l = await api('POST', '/v1/remote/link', action === 'remote-link' ? { full: true } : undefined)
|
|
4955
4968
|
if (l.needed === false) { toast('No sign-in needed (remoteRequireToken is off)'); return }
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) }
|