@leftoff/cli 0.9.8-beta.2 → 0.9.8-beta.20
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 +39 -2
- package/bin/leftoff.js +423 -392
- package/docs/peer-protocol.md +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +24 -4
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +492 -22
- package/ui/app.js +1165 -83
- package/ui/attach.js +211 -0
- package/ui/composer.js +46 -0
- package/ui/index.html +4 -0
- package/ui/lightbox.js +452 -0
- package/ui/live-status.js +17 -9
- package/ui/live-view.js +229 -12
- package/ui/live-wake.js +96 -0
- package/ui/navhide.js +35 -0
- package/ui/pdfview.js +510 -0
- package/ui/prs.js +10 -5
- package/ui/push.js +140 -0
- package/ui/relay-client.js +70 -5
- package/ui/sw.js +52 -5
- package/ui/tabs.js +216 -0
- package/ui/tour.css +63 -0
- package/ui/tour.js +364 -0
- package/ui/update-bar.js +158 -0
- package/ui/vendor/PDFJS-LICENSE.txt +179 -0
- package/ui/vendor/pdfjs/pdf.min.js +27 -0
- package/ui/vendor/pdfjs/pdf.worker.min.js +27 -0
package/ui/push.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Phone notifications (0.9.8): the one per-device button. No setting: the
|
|
2
|
+
// button and the browser's own permission prompt are the opt-in, and the same
|
|
3
|
+
// button turns them off for this device. The Mac that owns a session sends
|
|
4
|
+
// the push (src/push.js); this file only subscribes this browser.
|
|
5
|
+
//
|
|
6
|
+
// iPhone: Web Push works for a board added to the Home Screen (iOS 16.4+), so
|
|
7
|
+
// Safari in a tab gets one line saying how, and the button appears in the
|
|
8
|
+
// Home Screen app. Pure parts (pushSupport, pushUi) are shared with the tests.
|
|
9
|
+
|
|
10
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';')
|
|
11
|
+
const BELL = '<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="M10.268 21a2 2 0 0 0 3.464 0"/> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>'
|
|
12
|
+
|
|
13
|
+
export const isIos = (ua = '', touch = 0) => /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && touch > 1)
|
|
14
|
+
|
|
15
|
+
// What this browser can do -> { state } : insecure | add_to_home | unsupported | denied | off
|
|
16
|
+
export function pushSupport({ ua = '', touch = 0, standalone = false, hasPush = false, hasSW = false, secure = true, permission = 'default' } = {}) {
|
|
17
|
+
const ios = isIos(ua, touch)
|
|
18
|
+
if (!secure) return { state: 'insecure', ios }
|
|
19
|
+
if (ios && !standalone) return { state: 'add_to_home', ios }
|
|
20
|
+
if (!hasPush || !hasSW) return { state: 'unsupported', ios }
|
|
21
|
+
if (permission === 'denied') return { state: 'denied', ios }
|
|
22
|
+
return { state: 'off', ios }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The block for a slot. st: pushSupport() plus { on, busy, note, noteBad }
|
|
26
|
+
export function pushUi(st = {}) {
|
|
27
|
+
const note = st.note ? '<p class="push-note' + (st.noteBad ? ' bad' : '') + '" role="status">' + esc(st.note) + '</p>' : ''
|
|
28
|
+
const busy = st.busy ? ' disabled aria-busy="true"' : ''
|
|
29
|
+
const wrap = (inner) => '<div class="push-box">' + BELL + '<div class="push-main">' + inner + note + '</div></div>'
|
|
30
|
+
switch (st.on ? 'on' : st.state) {
|
|
31
|
+
case 'add_to_home': return wrap('<p class="push-line">Add Leftoff to your Home Screen first (Share → Add to Home Screen), then turn on notifications here.</p>')
|
|
32
|
+
case 'denied': return wrap('<p class="push-line">Notifications are blocked for Leftoff on this device. Allow them in ' + (st.ios ? 'Settings → Notifications → Leftoff' : 'the browser’s site settings') + ', then come back.</p>')
|
|
33
|
+
case 'on': return wrap('<p class="push-line"><strong>Notifications on for this device</strong></p><div class="push-acts"><button type="button" class="btn" data-push="test"' + busy + '>Send a test</button><button type="button" class="btn ghost" data-push="off"' + busy + '>Turn off</button></div>')
|
|
34
|
+
case 'off': return wrap('<p class="push-line">Get a notification when Claude needs you or a Live reply lands.</p><div class="push-acts"><button type="button" class="btn primary" data-push="on"' + busy + '>' + (st.busy ? 'Turning on…' : 'Turn on notifications') + '</button></div>')
|
|
35
|
+
default: return '' // no Push here (or not https): nothing to offer
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
40
|
+
const unb64u = (s) => Uint8Array.from(atob(String(s).replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((String(s).length + 3) % 4)), (c) => c.charCodeAt(0))
|
|
41
|
+
|
|
42
|
+
const ui = { st: null, on: false, sub: null, busy: false, note: '', noteBad: false, api: null, refreshed: false }
|
|
43
|
+
|
|
44
|
+
function env() {
|
|
45
|
+
return {
|
|
46
|
+
ua: navigator.userAgent, touch: navigator.maxTouchPoints || 0,
|
|
47
|
+
standalone: !!(window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || navigator.standalone === true,
|
|
48
|
+
hasPush: 'PushManager' in window && 'Notification' in window, hasSW: 'serviceWorker' in navigator,
|
|
49
|
+
secure: !!window.isSecureContext, permission: 'Notification' in window ? Notification.permission : 'default',
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function registration() {
|
|
54
|
+
if (!('serviceWorker' in navigator)) return null
|
|
55
|
+
return (await navigator.serviceWorker.getRegistration('/')) || (await navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => null))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const keyOf = (sub) => { try { const k = sub.options && sub.options.applicationServerKey; return k ? b64u(k) : null } catch { return null } }
|
|
59
|
+
|
|
60
|
+
async function load() {
|
|
61
|
+
const e = env()
|
|
62
|
+
ui.st = pushSupport(e)
|
|
63
|
+
ui.sub = null; ui.on = false
|
|
64
|
+
if (ui.st.state !== 'off') return
|
|
65
|
+
try { const reg = await registration(); ui.sub = reg && reg.pushManager ? await reg.pushManager.getSubscription() : null } catch { ui.sub = null }
|
|
66
|
+
ui.on = !!ui.sub && e.permission === 'granted'
|
|
67
|
+
// Once a page load: tell this Mac again (it shares with your other Macs), so a
|
|
68
|
+
// Mac that lost track, or a new one, knows this device.
|
|
69
|
+
if (ui.on && !ui.refreshed && ui.api) { ui.refreshed = true; ui.api('POST', '/v1/push/subscribe', { subscription: ui.sub.toJSON(), serverKey: keyOf(ui.sub), ua: navigator.userAgent }).catch(() => {}) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function paintPush() {
|
|
73
|
+
const slots = document.querySelectorAll('[data-push-slot]')
|
|
74
|
+
if (!slots.length) return
|
|
75
|
+
const html = ui.st ? pushUi({ ...ui.st, on: ui.on, busy: ui.busy, note: ui.note, noteBad: ui.noteBad }) : ''
|
|
76
|
+
for (const el of slots) { el.innerHTML = html; el.hidden = !html; el.setAttribute('data-pp', '1') }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function turnOn() {
|
|
80
|
+
// The permission prompt first, straight from the tap (iOS asks only then).
|
|
81
|
+
const perm = await Notification.requestPermission()
|
|
82
|
+
if (perm !== 'granted') { ui.st = pushSupport(env()); ui.note = perm === 'denied' ? '' : 'Notifications weren’t allowed. Tap again to try.'; return }
|
|
83
|
+
await registration()
|
|
84
|
+
const reg = await navigator.serviceWorker.ready
|
|
85
|
+
const info = await ui.api('GET', '/v1/push')
|
|
86
|
+
let sub = await reg.pushManager.getSubscription()
|
|
87
|
+
// An existing subscription stays (the Mac that made it shares its key); a new one uses this Mac's key.
|
|
88
|
+
if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: unb64u(info.publicKey) })
|
|
89
|
+
await ui.api('POST', '/v1/push/subscribe', { subscription: sub.toJSON(), serverKey: keyOf(sub) || info.publicKey, ua: navigator.userAgent })
|
|
90
|
+
ui.sub = sub; ui.on = true; ui.refreshed = true
|
|
91
|
+
ui.note = 'You’ll get a notification when Claude needs you or a Live reply lands.'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function turnOff() {
|
|
95
|
+
const sub = ui.sub
|
|
96
|
+
if (sub) {
|
|
97
|
+
const endpoint = sub.endpoint
|
|
98
|
+
try { await sub.unsubscribe() } catch {}
|
|
99
|
+
await ui.api('POST', '/v1/push/unsubscribe', { endpoint }).catch(() => {})
|
|
100
|
+
}
|
|
101
|
+
ui.sub = null; ui.on = false
|
|
102
|
+
ui.note = 'Notifications off for this device.'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function sendTest() {
|
|
106
|
+
const r = await ui.api('POST', '/v1/push/test', { endpoint: ui.sub && ui.sub.endpoint })
|
|
107
|
+
if (r && r.ok) { ui.note = 'Sent from ' + (r.machine || 'your Mac') + '. It should show up in a few seconds.'; return }
|
|
108
|
+
if (r && r.error === 'not_subscribed') { ui.on = false; ui.sub = null; throw new Error('This Mac didn’t know this device. Turn notifications on again.') }
|
|
109
|
+
throw new Error('The push service didn’t take it' + (r && r.error ? ' (' + r.error + ')' : '') + '.')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Wire it once: api is app.js's api(method, path, body).
|
|
113
|
+
export function mountPush({ api }) {
|
|
114
|
+
ui.api = api
|
|
115
|
+
document.addEventListener('click', async (ev) => {
|
|
116
|
+
const b = ev.target.closest && ev.target.closest('[data-push]')
|
|
117
|
+
if (!b || ui.busy) return
|
|
118
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
119
|
+
const what = b.getAttribute('data-push')
|
|
120
|
+
ui.busy = true; ui.note = ''; ui.noteBad = false; paintPush()
|
|
121
|
+
try {
|
|
122
|
+
if (what === 'on') await turnOn()
|
|
123
|
+
else if (what === 'off') await turnOff()
|
|
124
|
+
else if (what === 'test') await sendTest()
|
|
125
|
+
} catch (e) { ui.note = e && e.message ? e.message : String(e); ui.noteBad = true }
|
|
126
|
+
ui.busy = false
|
|
127
|
+
paintPush()
|
|
128
|
+
})
|
|
129
|
+
load().then(paintPush, paintPush)
|
|
130
|
+
// Settings is drawn on demand: paint a slot the moment one appears.
|
|
131
|
+
const app = document.getElementById('app')
|
|
132
|
+
if (app && window.MutationObserver) new MutationObserver(() => { if (document.querySelector('[data-push-slot]:not([data-pp])')) paintPush() }).observe(app, { childList: true, subtree: true })
|
|
133
|
+
// A tapped notification: the service worker asks the open board to show that session.
|
|
134
|
+
if ('serviceWorker' in navigator) navigator.serviceWorker.addEventListener('message', (ev) => {
|
|
135
|
+
const d = ev.data || {}
|
|
136
|
+
if (d.type !== 'leftoff-open' || typeof d.url !== 'string') return
|
|
137
|
+
try { const u = new URL(d.url, location.origin); if (u.origin === location.origin && location.hash !== u.hash) location.hash = u.hash } catch {}
|
|
138
|
+
})
|
|
139
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && !ui.busy) load().then(paintPush, paintPush) })
|
|
140
|
+
}
|
package/ui/relay-client.js
CHANGED
|
@@ -44,7 +44,7 @@ export async function forgetDeviceKey() {
|
|
|
44
44
|
|
|
45
45
|
// ------------------------------------------------------------ state
|
|
46
46
|
|
|
47
|
-
const relay = { user: RELAY_USER, device: null, key: null, status: null, mac: null, ready: null, latencyMs: null }
|
|
47
|
+
const relay = { user: RELAY_USER, device: null, key: null, status: null, mac: null, ready: null, latencyMs: null, owners: new Map() }
|
|
48
48
|
// ui/direct.js loads only on the board itself (never on pair.html, which the
|
|
49
49
|
// relay serves before a browser is paired and where this file is imported too).
|
|
50
50
|
let route = null, directOrRelay = null
|
|
@@ -140,6 +140,39 @@ function watchDirect() {
|
|
|
140
140
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') again(true) })
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
// ------------------------------------------------------------ auto-switch host (0.9.8)
|
|
144
|
+
// On your leftoff.dev board, a session that lives on another Mac loads its own
|
|
145
|
+
// data (Live, its transcript, Find lists, files...) straight from that Mac
|
|
146
|
+
// through the relay, instead of from the Mac serving the board. The board says
|
|
147
|
+
// which Mac each session lives on (leftoffRelay.owner); these decide where one
|
|
148
|
+
// call goes. Pure and exported, so they are unit-tested.
|
|
149
|
+
|
|
150
|
+
// /v1/sessions/<id>/<anything> -> <id>; the record itself (/v1/sessions/<id>) stays on
|
|
151
|
+
// the board's Mac (it's the synced copy the board lists), and so does everything else.
|
|
152
|
+
const SESSION_SUB = /^\/v1\/sessions\/([^/?#]+)\/[^?#]+$/
|
|
153
|
+
export function sessionOfPath(pathname) {
|
|
154
|
+
const m = SESSION_SUB.exec(String(pathname || ''))
|
|
155
|
+
if (!m) return null
|
|
156
|
+
try { return decodeURIComponent(m[1]) } catch { return null }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Which Mac a call should go to, or null for the board's own Mac.
|
|
160
|
+
// owners: Map(sessionId -> machine) the board filled in; current: the Mac serving the board;
|
|
161
|
+
// online: the Macs the relay says are connected now.
|
|
162
|
+
export function ownerRoute(pathname, owners, { current = null, online = [] } = {}) {
|
|
163
|
+
const id = sessionOfPath(pathname)
|
|
164
|
+
if (!id || !owners || typeof owners.get !== 'function') return null
|
|
165
|
+
const m = owners.get(id)
|
|
166
|
+
if (!m || m === current) return null
|
|
167
|
+
return online.includes(m) ? m : null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A failed hop to the owner falls back to the board's Mac (which reaches the
|
|
171
|
+
// owner over the peer link, or says plainly that it's offline).
|
|
172
|
+
export function fallBack({ status = 0, code = '' } = {}) {
|
|
173
|
+
return status === 0 || status === 401 || status === 403 && code === 'unknown_device' || status === 502 || status === 503 || status === 504 || code === 'mac_offline' || code === 'timeout'
|
|
174
|
+
}
|
|
175
|
+
|
|
143
176
|
// ------------------------------------------------------------ the tunnel
|
|
144
177
|
|
|
145
178
|
async function tunnel(url, init = {}, retried = false) {
|
|
@@ -151,6 +184,9 @@ async function tunnel(url, init = {}, retried = false) {
|
|
|
151
184
|
const hdrs = new Headers(init.headers || {})
|
|
152
185
|
if (hdrs.get('content-type')) h['content-type'] = hdrs.get('content-type')
|
|
153
186
|
const body = { m: method, p: url.pathname + url.search, b, h }
|
|
187
|
+
// 0.9.8: a session that lives on another Mac answers for itself (ui/owner-route.js)
|
|
188
|
+
const owner = ownerRoute(url.pathname, relay.owners, { current: relay.mac, online: onlineMacs() })
|
|
189
|
+
if (owner) { const r = await viaOwner(owner, body, init).catch(() => null); if (r) return r }
|
|
154
190
|
if (!onDirect()) return viaRelay(body, init, retried)
|
|
155
191
|
return directOrRelay({
|
|
156
192
|
route,
|
|
@@ -164,6 +200,22 @@ async function tunnel(url, init = {}, retried = false) {
|
|
|
164
200
|
})
|
|
165
201
|
}
|
|
166
202
|
|
|
203
|
+
function onlineMacs() { return ((relay.status && relay.status.macs) || []).filter((m) => m.online).map((m) => m.machine) }
|
|
204
|
+
// Straight to the session's own Mac through the relay; null sends the call to the
|
|
205
|
+
// board's Mac instead (the owner went away, or doesn't know this browser yet).
|
|
206
|
+
async function viaOwner(machine, body, init) {
|
|
207
|
+
const env = await sealRequest(relay.key, { device: relay.device, machine, body })
|
|
208
|
+
const res = await realFetch('/_relay/api', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(env), signal: init.signal, cache: 'no-store' })
|
|
209
|
+
if (res.status !== 200) {
|
|
210
|
+
const j = await res.json().catch(() => ({}))
|
|
211
|
+
if (j.code === 'mac_offline') refreshStatus().catch(() => {})
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
const out = toResponse(await openResponse(relay.key, env, await res.json()))
|
|
215
|
+
if (out.status === 401 || out.status === 403) { const j = await out.clone().json().catch(() => ({})); if (fallBack({ status: out.status, code: j.code || '' }) || /device/.test(j.code || '')) return null }
|
|
216
|
+
return out
|
|
217
|
+
}
|
|
218
|
+
|
|
167
219
|
async function viaRelay(body, init, retried) {
|
|
168
220
|
const gone = relay.mac
|
|
169
221
|
const env = await sealRequest(relay.key, { device: relay.device, machine: gone, body })
|
|
@@ -196,6 +248,7 @@ function patchedFetch(input, init) {
|
|
|
196
248
|
}
|
|
197
249
|
|
|
198
250
|
// EventSource('/v1/stream') -> long-polls of GET /v1/events through the tunnel.
|
|
251
|
+
export const POLL_WAIT_MS = 20000, POLL_MARGIN_MS = 12000
|
|
199
252
|
class RelayEventSource extends EventTarget {
|
|
200
253
|
constructor(url) {
|
|
201
254
|
super()
|
|
@@ -207,11 +260,20 @@ class RelayEventSource extends EventTarget {
|
|
|
207
260
|
let fails = 0
|
|
208
261
|
while (this.readyState !== 2) {
|
|
209
262
|
try {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
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
|
|
214
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
|
|
215
277
|
const first = this.seq == null
|
|
216
278
|
this.seq = j.seq
|
|
217
279
|
if (!first) for (const e of j.events || []) {
|
|
@@ -272,6 +334,9 @@ export const leftoffRelay = {
|
|
|
272
334
|
get route() { return onDirect() ? 'direct' : 'relay' },
|
|
273
335
|
get directLatencyMs() { return route ? route.latencyMs : null },
|
|
274
336
|
status: () => refreshStatus(),
|
|
337
|
+
// 0.9.8: which Mac a session lives on (null: the board's own), so its calls go there
|
|
338
|
+
owner(id, machine) { if (!id) return; if (machine) relay.owners.set(String(id), String(machine)); else relay.owners.delete(String(id)) },
|
|
339
|
+
ownerOf(id) { const m = relay.owners.get(String(id)); return m && m !== relay.mac && onlineMacs().includes(m) ? m : null },
|
|
275
340
|
// Switch Macs: the board reloads from the other one.
|
|
276
341
|
pick(mac) { remember(mac); cookieMac(mac); location.reload() },
|
|
277
342
|
// This browser revoked itself (0.6.6).
|
package/ui/sw.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
// Leftoff's service worker (v0.4.6).
|
|
2
|
-
// can't be reached (Tailscale off on the
|
|
3
|
-
// stopped), a page navigation shows
|
|
4
|
-
// It caches nothing else: no API
|
|
5
|
-
// request goes straight to the
|
|
1
|
+
// Leftoff's service worker (v0.4.6). Two jobs:
|
|
2
|
+
// 1. When the Mac running Leftoff can't be reached (Tailscale off on the
|
|
3
|
+
// phone, the Mac asleep, the daemon stopped), a page navigation shows
|
|
4
|
+
// offline.html instead of Safari's error. It caches nothing else: no API
|
|
5
|
+
// data, no board pages, no assets. Every other request goes straight to the
|
|
6
|
+
// network, untouched.
|
|
7
|
+
// 2. (0.9.8) Phone notifications: a push from the Mac that owns a session
|
|
8
|
+
// becomes a notification (the session's title, one short line, a tag per
|
|
9
|
+
// session and kind so a burst replaces the last one), and a tap opens that
|
|
10
|
+
// session (its Live chat, or the permission card at the top).
|
|
6
11
|
const CACHE = 'leftoff-offline-v1'
|
|
7
12
|
const OFFLINE = ['/offline.html', '/offline.css', '/icon-192.png']
|
|
8
13
|
|
|
@@ -45,3 +50,45 @@ function slow(url) {
|
|
|
45
50
|
function offline() {
|
|
46
51
|
return caches.match('/offline.html').then((r) => r || new Response('Leftoff is offline', { status: 503, headers: { 'content-type': 'text/plain' } }))
|
|
47
52
|
}
|
|
53
|
+
|
|
54
|
+
// ------------------------------------------------------------ notifications (0.9.8)
|
|
55
|
+
|
|
56
|
+
// Safari must show every push (it takes the permission back after a few
|
|
57
|
+
// silent ones), so only other browsers skip one for the session already on screen.
|
|
58
|
+
const MAY_SKIP = /Chrome|CriOS|Firefox|FxiOS|Edg\//.test(self.navigator.userAgent || '')
|
|
59
|
+
const sameSession = (clientUrl, url) => {
|
|
60
|
+
try { const a = new URL(clientUrl).hash, b = new URL(url, self.location.origin).hash; return !!b && b !== '#/' && (a === b || a.startsWith(b + '/') || a.startsWith(b + '?')) } catch { return false }
|
|
61
|
+
}
|
|
62
|
+
const windows = () => self.clients.matchAll({ type: 'window', includeUncontrolled: true })
|
|
63
|
+
|
|
64
|
+
self.addEventListener('push', (event) => {
|
|
65
|
+
let d = {}
|
|
66
|
+
try { d = event.data ? event.data.json() : {} } catch { d = { body: event.data ? event.data.text() : '' } }
|
|
67
|
+
const url = typeof d.url === 'string' && d.url.startsWith('/') ? d.url : '/'
|
|
68
|
+
event.waitUntil((async () => {
|
|
69
|
+
if (MAY_SKIP && d.kind !== 'test') {
|
|
70
|
+
const on = (await windows()).some((c) => c.focused && c.visibilityState === 'visible' && sameSession(c.url, url))
|
|
71
|
+
if (on) return
|
|
72
|
+
}
|
|
73
|
+
await self.registration.showNotification(String(d.title || 'Leftoff').slice(0, 80), {
|
|
74
|
+
body: String(d.body || '').slice(0, 180), tag: d.tag || undefined, renotify: !!d.tag, icon: '/icon-192.png',
|
|
75
|
+
data: { url, kind: d.kind || null, sessionId: d.sessionId || null }, timestamp: Date.now(),
|
|
76
|
+
})
|
|
77
|
+
})())
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
self.addEventListener('notificationclick', (event) => {
|
|
81
|
+
event.notification.close()
|
|
82
|
+
const url = new URL((event.notification.data && event.notification.data.url) || '/', self.location.origin).href
|
|
83
|
+
event.waitUntil((async () => {
|
|
84
|
+
const list = (await windows()).filter((c) => new URL(c.url).origin === self.location.origin)
|
|
85
|
+
const c = list.find((x) => x.focused) || list[0]
|
|
86
|
+
if (c) {
|
|
87
|
+
// the board routes by hash: tell the page (no reload), then bring it forward
|
|
88
|
+
c.postMessage({ type: 'leftoff-open', url })
|
|
89
|
+
try { await c.focus() } catch {}
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
await self.clients.openWindow(url)
|
|
93
|
+
})())
|
|
94
|
+
})
|
package/ui/tabs.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Tabs for sessions (0.9.8). The board is always the first tab and can't be
|
|
2
|
+
// closed. Clicking a session opens it in ONE preview tab (its title in italics)
|
|
3
|
+
// that the next click replaces, VS Code style; it stays open (permanent, with a
|
|
4
|
+
// close ×) once you send a Live message in it, type there, choose Keep open or
|
|
5
|
+
// double-click the tab, and a middle-click or ⌘/Ctrl-click opens a session
|
|
6
|
+
// straight as permanent. The open set and its order are kept per device
|
|
7
|
+
// (localStorage) and survive a reload; a deep link opens as the preview.
|
|
8
|
+
//
|
|
9
|
+
// The layout follows the window's WIDTH, never the device: under 700px no strip
|
|
10
|
+
// (an Open row on the board, swipe or ‹ › between open sessions on a session
|
|
11
|
+
// page); 700–1099px the strip with the rail as icons; from 1100px the full rail
|
|
12
|
+
// and the strip. Pure functions over a plain model; app.js wires them.
|
|
13
|
+
|
|
14
|
+
export const TABS_KEY = 'cp.tabs'
|
|
15
|
+
export const MAX_TABS = 40
|
|
16
|
+
export const NARROW_MAX = 699.98 // < 700: phones, a foldable closed
|
|
17
|
+
export const WIDE_MIN = 1100 // ≥ 1100: iPad landscape, a Mac
|
|
18
|
+
|
|
19
|
+
// 'narrow' | 'medium' | 'wide' for a CSS width
|
|
20
|
+
export function layoutFor(width) {
|
|
21
|
+
const w = Number(width) || 0
|
|
22
|
+
return w < 700 ? 'narrow' : w < WIDE_MIN ? 'medium' : 'wide'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const clean = (e) => ({ id: String(e.id), title: typeof e.title === 'string' ? e.title.slice(0, 200) : '', preview: !!e.preview, ...(typeof e.hash === 'string' && e.hash.startsWith('#/s/') ? { hash: e.hash } : {}) })
|
|
26
|
+
|
|
27
|
+
// Any stored or hand-made value → a valid model: string ids, no repeats, at most
|
|
28
|
+
// one preview (the last one wins), at most MAX_TABS.
|
|
29
|
+
export function normalize(raw) {
|
|
30
|
+
const list = raw && Array.isArray(raw.tabs) ? raw.tabs : []
|
|
31
|
+
const seen = new Set(), tabs = []
|
|
32
|
+
for (const e of list) {
|
|
33
|
+
if (!e || e.id == null || e.id === '' || seen.has(String(e.id))) continue
|
|
34
|
+
seen.add(String(e.id)); tabs.push(clean(e))
|
|
35
|
+
}
|
|
36
|
+
let pi = -1
|
|
37
|
+
tabs.forEach((t, i) => { if (t.preview) { if (pi >= 0) tabs[pi].preview = false; pi = i } })
|
|
38
|
+
return { tabs: tabs.slice(-MAX_TABS) }
|
|
39
|
+
}
|
|
40
|
+
export function emptyTabs() { return { tabs: [] } }
|
|
41
|
+
export function parseStored(text) {
|
|
42
|
+
try { return normalize(JSON.parse(text || 'null')) } catch { return emptyTabs() }
|
|
43
|
+
}
|
|
44
|
+
export function serialize(m) { return JSON.stringify({ v: 1, tabs: normalize(m).tabs }) }
|
|
45
|
+
|
|
46
|
+
export const indexOf = (m, id) => m.tabs.findIndex((t) => t.id === String(id))
|
|
47
|
+
export const isOpen = (m, id) => indexOf(m, id) >= 0
|
|
48
|
+
export const previewOf = (m) => m.tabs.find((t) => t.preview) || null
|
|
49
|
+
|
|
50
|
+
// Open a session. Already open: it just gets focus (permanent promotes a preview).
|
|
51
|
+
// Not open: as the preview, replacing the old preview in place; or, permanent,
|
|
52
|
+
// appended at the end.
|
|
53
|
+
export function openTab(m, id, { permanent = false, title, hash } = {}) {
|
|
54
|
+
const tabs = m.tabs.map((t) => ({ ...t }))
|
|
55
|
+
id = String(id)
|
|
56
|
+
const i = tabs.findIndex((t) => t.id === id)
|
|
57
|
+
const extra = { ...(title ? { title: String(title).slice(0, 200) } : {}), ...(hash && hash.startsWith('#/s/') ? { hash } : {}) }
|
|
58
|
+
if (i >= 0) {
|
|
59
|
+
Object.assign(tabs[i], extra)
|
|
60
|
+
if (permanent) tabs[i].preview = false
|
|
61
|
+
return { tabs }
|
|
62
|
+
}
|
|
63
|
+
const e = { id, title: '', preview: !permanent, ...extra }
|
|
64
|
+
const pi = tabs.findIndex((t) => t.preview)
|
|
65
|
+
if (!permanent && pi >= 0) tabs[pi] = e
|
|
66
|
+
else tabs.push(e)
|
|
67
|
+
// too many: the oldest permanent tab that isn't this one goes
|
|
68
|
+
while (tabs.length > MAX_TABS) { const k = tabs.findIndex((t) => t.id !== id); tabs.splice(k, 1) }
|
|
69
|
+
return { tabs }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The preview becomes permanent (a Live message, typing there, Keep open, a double-click).
|
|
73
|
+
export function promote(m, id) {
|
|
74
|
+
const i = indexOf(m, id)
|
|
75
|
+
if (i < 0 || !m.tabs[i].preview) return m
|
|
76
|
+
return { tabs: m.tabs.map((t, k) => (k === i ? { ...t, preview: false } : t)) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Keep a tab's title (shown while its session isn't loaded) and where in it you were.
|
|
80
|
+
export function remember(m, id, { title, hash } = {}) {
|
|
81
|
+
const i = indexOf(m, id)
|
|
82
|
+
if (i < 0) return m
|
|
83
|
+
const t = m.tabs[i]
|
|
84
|
+
const nt = { ...t, ...(title && title !== t.title ? { title: String(title).slice(0, 200) } : {}), ...(hash && hash.startsWith('#/s/') && hash !== t.hash ? { hash } : {}) }
|
|
85
|
+
if (nt.title === t.title && nt.hash === t.hash) return m
|
|
86
|
+
return { tabs: m.tabs.map((x, k) => (k === i ? nt : x)) }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Close a tab. When it was the one you're on, `next` is where to go: the
|
|
90
|
+
// neighbour on the right, else the one on the left, else the board (null).
|
|
91
|
+
export function closeTab(m, id, activeId = null) {
|
|
92
|
+
const i = indexOf(m, id)
|
|
93
|
+
if (i < 0) return { model: m, next: activeId == null ? null : String(activeId) }
|
|
94
|
+
const tabs = m.tabs.filter((_, k) => k !== i)
|
|
95
|
+
let next = activeId == null ? null : String(activeId)
|
|
96
|
+
if (String(id) === next) next = tabs.length ? (tabs[i] || tabs[i - 1]).id : null
|
|
97
|
+
return { model: { tabs }, next }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Move a tab to another position (drag on the strip).
|
|
101
|
+
export function moveTab(m, id, to) {
|
|
102
|
+
const i = indexOf(m, id)
|
|
103
|
+
if (i < 0) return m
|
|
104
|
+
const tabs = m.tabs.slice()
|
|
105
|
+
const [t] = tabs.splice(i, 1)
|
|
106
|
+
tabs.splice(Math.max(0, Math.min(tabs.length, to)), 0, t)
|
|
107
|
+
return { tabs }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Where a session sits among the open ones (the phone's "2 of 4", swipe and ‹ ›).
|
|
111
|
+
export function neighbours(m, id) {
|
|
112
|
+
const i = indexOf(m, id)
|
|
113
|
+
if (i < 0) return { index: 0, count: m.tabs.length, prev: null, next: null }
|
|
114
|
+
return { index: i + 1, count: m.tabs.length, prev: i > 0 ? m.tabs[i - 1].id : null, next: i < m.tabs.length - 1 ? m.tabs[i + 1].id : null }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The session id a hash opens (#/s/<id>, #/s/<id>/live, #/s/<id>?hit=…), or null.
|
|
118
|
+
export function sessionOfHash(hash) {
|
|
119
|
+
const h = String(hash || '').replace(/^#\/?/, '').split('?')[0]
|
|
120
|
+
const p = h.split('/').filter(Boolean)
|
|
121
|
+
if (p[0] !== 's' || !p[1]) return null
|
|
122
|
+
try { return decodeURIComponent(p[1]) } catch { return p[1] }
|
|
123
|
+
}
|
|
124
|
+
// Where a tab goes back to: its own page, keeping /live (not a search hit).
|
|
125
|
+
export function tabHref(t) {
|
|
126
|
+
const base = '#/s/' + encodeURIComponent(t.id)
|
|
127
|
+
if (t.hash && sessionOfHash(t.hash) === t.id && t.hash.split('?')[0].endsWith('/live')) return base + '/live'
|
|
128
|
+
return base
|
|
129
|
+
}
|
|
130
|
+
export function keepHash(hash) { return String(hash || '').split('?')[0] }
|
|
131
|
+
|
|
132
|
+
// What a tab shows for its session: the title, the Live pulse while Claude is
|
|
133
|
+
// working (the Live chip's own signal), a dot when it needs you (the same as
|
|
134
|
+
// Needs you). `session` may be missing (not loaded yet): the kept title, no marks.
|
|
135
|
+
export function tabView(t, session, { title, working } = {}) {
|
|
136
|
+
const s = session || null
|
|
137
|
+
return {
|
|
138
|
+
id: t.id, preview: !!t.preview, href: tabHref(t),
|
|
139
|
+
title: (s && title ? title(s) : '') || t.title || 'Session',
|
|
140
|
+
working: !!(s && working && working(s)),
|
|
141
|
+
needs: !!(s && s.needsYou && !s.archived),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
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 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
|
+
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
|
+
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>'
|
|
149
|
+
const NEXT = '<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="m9 18 6-6-6-6"/></svg>'
|
|
150
|
+
|
|
151
|
+
// The marks after a title: a pulsing Live dot, or the red needs-you dot (both when both).
|
|
152
|
+
function marks(v, esc) {
|
|
153
|
+
return (v.working ? '<span class="tab-live" title="Claude is working"><span class="lo-vh">, Claude is working</span></span>' : '') +
|
|
154
|
+
(v.needs ? '<span class="tab-need" title="Needs you"><span class="lo-vh">, needs you</span></span>' : '')
|
|
155
|
+
}
|
|
156
|
+
function label(v) { return (v.preview ? 'Preview: ' : '') + v.title + (v.working ? ' · Claude is working' : '') + (v.needs ? ' · needs you' : '') }
|
|
157
|
+
|
|
158
|
+
// The strip (700px and up): Board, then the open sessions, then ⋯ (all of them).
|
|
159
|
+
// The current tab is aria-current="true" (the current item of this set); the rail keeps "page".
|
|
160
|
+
export function stripHtml(views, activeId, onBoard, esc) {
|
|
161
|
+
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>'
|
|
162
|
+
const tabs = views.map((v) => {
|
|
163
|
+
const on = v.id === activeId
|
|
164
|
+
return '<div class="tab-cell' + (on ? ' on' : '') + (v.preview ? ' preview' : '') + '" data-tab-id="' + esc(v.id) + '">' +
|
|
165
|
+
'<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
|
+
'<span class="tab-t">' + esc(v.title) + '</span>' + marks(v, esc) + '</a>' +
|
|
167
|
+
(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>') +
|
|
170
|
+
'</div>'
|
|
171
|
+
}).join('')
|
|
172
|
+
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>'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The menu behind ⋯ (picker.js's action menu): every open session, in order.
|
|
177
|
+
export function allTabsItems(views, activeId) {
|
|
178
|
+
return [{ label: 'Board', attrs: { 'data-tab-go': '#/' }, on: activeId == null }].concat(views.map((v) => ({
|
|
179
|
+
label: v.title + (v.preview ? ' (preview)' : ''), sub: [v.working ? 'Claude is working' : '', v.needs ? 'Needs you' : ''].filter(Boolean).join(' · ') || undefined,
|
|
180
|
+
on: v.id === activeId, attrs: { 'data-tab-go': v.href },
|
|
181
|
+
})))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The phone's Open row on the board: the same order, the same marks, the preview in italics.
|
|
185
|
+
export function openRowHtml(views, esc) {
|
|
186
|
+
if (!views.length) return ''
|
|
187
|
+
return '<div class="openrow-h"><span>Open</span><span class="faint">' + views.length + '</span></div><div class="openrow-chips">' + views.map((v) =>
|
|
188
|
+
'<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>'
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A session page on a phone: "2 of 4" with ‹ › (44px) for people who don't swipe,
|
|
193
|
+
// and Keep open on a preview.
|
|
194
|
+
export function pagerHtml(m, id, views, esc) {
|
|
195
|
+
const n = neighbours(m, id)
|
|
196
|
+
if (!n.index || n.count < 2) {
|
|
197
|
+
const v = views.find((x) => x.id === String(id))
|
|
198
|
+
return v && v.preview ? '<div class="pager-in"><span class="pager-n faint">Preview</span><button type="button" class="btn pager-keep" data-tab-keep="' + esc(v.id) + '">Keep open</button></div>' : ''
|
|
199
|
+
}
|
|
200
|
+
const byId = (x) => views.find((v) => v.id === x)
|
|
201
|
+
const v = byId(String(id))
|
|
202
|
+
const btn = (to, dir) => {
|
|
203
|
+
const t = to ? byId(to) : null
|
|
204
|
+
return t ? '<a class="pager-b pager-' + dir + '" href="' + esc(t.href) + '" data-tab="' + esc(t.id) + '" data-pager="' + dir + '" aria-label="' + esc((dir === 'prev' ? 'Previous: ' : 'Next: ') + t.title) + '" title="' + esc(t.title) + '">' + (dir === 'prev' ? PREV : NEXT) + '</a>'
|
|
205
|
+
: '<span class="pager-b pager-' + dir + ' off" aria-hidden="true">' + (dir === 'prev' ? PREV : NEXT) + '</span>'
|
|
206
|
+
}
|
|
207
|
+
return '<div class="pager-in">' + btn(n.prev, 'prev') + '<span class="pager-n" aria-live="polite">' + n.index + ' of ' + n.count + '<span class="lo-vh"> open sessions</span></span>' +
|
|
208
|
+
(v && v.preview ? '<button type="button" class="btn pager-keep" data-tab-keep="' + esc(v.id) + '">Keep open</button>' : '') + btn(n.next, 'next') + '</div>'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A horizontal swipe on a phone's session page: which way, or null. Mostly
|
|
212
|
+
// sideways (twice as far across as down), at least 60px, quick (under 700ms).
|
|
213
|
+
export function swipeDir(dx, dy, ms) {
|
|
214
|
+
if (Math.abs(dx) < 60 || Math.abs(dx) < 2 * Math.abs(dy) || ms > 700) return null
|
|
215
|
+
return dx < 0 ? 'next' : 'prev'
|
|
216
|
+
}
|
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; } }
|