@leftoff/cli 0.9.7 → 0.9.8-beta.10
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 -0
- package/bin/leftoff.js +414 -379
- package/docs/SECURITY.md +10 -0
- package/docs/peer-protocol.md +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +14 -4
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +425 -1
- package/ui/app.js +952 -40
- package/ui/index.html +18 -6
- package/ui/live-status.js +97 -0
- package/ui/live-view.js +374 -0
- package/ui/live-wake.js +96 -0
- package/ui/nav.js +88 -0
- package/ui/navhide.js +35 -0
- package/ui/needs.js +33 -2
- package/ui/pull.js +31 -0
- package/ui/push.js +140 -0
- package/ui/relay-client.js +70 -5
- package/ui/restore-gate.js +26 -2
- package/ui/sw.js +66 -6
- package/ui/tabs.js +216 -0
- package/ui/tips.js +117 -0
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
|
|
|
@@ -22,13 +27,68 @@ self.addEventListener('fetch', (event) => {
|
|
|
22
27
|
if (req.mode === 'navigate') {
|
|
23
28
|
// A 502/503/504 is Tailscale Serve saying the daemon behind it is down.
|
|
24
29
|
// (The relay's own "Your Macs are asleep" page, x-leftoff-page, shows as is.)
|
|
25
|
-
|
|
30
|
+
// 0.9.7: never an endless blank page. The relay answers within a second
|
|
31
|
+
// (the board, or its own connecting page); anything slower than NAV_TIMEOUT_MS
|
|
32
|
+
// gets a page that says so, with Try again.
|
|
33
|
+
const net = fetch(req).then((res) => (res.status >= 502 && res.status <= 504 && !res.headers.get('x-leftoff-page') ? offline() : res)).catch(offline)
|
|
34
|
+
event.respondWith(Promise.race([net, new Promise((resolve) => setTimeout(() => resolve(slow(req.url)), NAV_TIMEOUT_MS))]))
|
|
26
35
|
return
|
|
27
36
|
}
|
|
28
37
|
// The offline page's own files, so it renders with no network.
|
|
29
38
|
if (OFFLINE.includes(url.pathname)) event.respondWith(fetch(req).catch(() => caches.match(url.pathname)))
|
|
30
39
|
})
|
|
31
40
|
|
|
41
|
+
const NAV_TIMEOUT_MS = 12000
|
|
42
|
+
function slow(url) {
|
|
43
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';')
|
|
44
|
+
const body = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>Leftoff · still waiting</title>' +
|
|
45
|
+
'<style>body{margin:0;font:16px/1.45 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;background:Canvas;color:CanvasText}main{max-width:460px;margin:0 auto;padding:max(48px,env(safe-area-inset-top)) 20px 40px}h1{font-size:26px;margin:0 0 10px}p{opacity:.75}a{display:flex;align-items:center;justify-content:center;min-height:48px;border-radius:12px;background:#d9622b;color:#fff;font-weight:600;text-decoration:none;margin-top:20px}</style></head>' +
|
|
46
|
+
'<body><main><h1>Leftoff is taking too long</h1><p>Your Mac hasn’t sent the board yet. It may be waking up.</p><a href="' + esc(url) + '">Try again</a></main></body></html>'
|
|
47
|
+
return new Response(body, { status: 504, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
function offline() {
|
|
33
51
|
return caches.match('/offline.html').then((r) => r || new Response('Leftoff is offline', { status: 503, headers: { 'content-type': 'text/plain' } }))
|
|
34
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/tips.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// 0.9.8 info tips: a small ⓘ button beside a control, with the details in a
|
|
2
|
+
// tooltip. Hover or keyboard focus on the ⓘ shows it (desktop); a tap pins it
|
|
3
|
+
// open as a small popover (touch); a second tap, Escape or a click outside
|
|
4
|
+
// closes it. The tip is a role="tooltip" the control and the ⓘ both point at
|
|
5
|
+
// with aria-describedby, so screen readers get the words without opening it.
|
|
6
|
+
// tipHtml() is pure (the unit tests read it); wireTips() runs once in the board.
|
|
7
|
+
|
|
8
|
+
import { icon } from './icons.js'
|
|
9
|
+
|
|
10
|
+
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
11
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
|
|
12
|
+
|
|
13
|
+
// A DOM id from any string (an ask id can carry anything).
|
|
14
|
+
export const tipId = (scope, key) => 'tip-' + String(scope || 't').replace(/[^A-Za-z0-9_-]/g, '_') + '-' + String(key || 'x').replace(/[^A-Za-z0-9_-]/g, '_')
|
|
15
|
+
|
|
16
|
+
// The ⓘ button and its (hidden) tooltip. `html` is already escaped markup.
|
|
17
|
+
export function tipHtml({ id, label, html }) {
|
|
18
|
+
return '<button type="button" class="tip-btn" aria-label="' + esc(label) + '" aria-expanded="false" aria-controls="' + esc(id) + '" aria-describedby="' + esc(id) + '">' + icon('info') + '</button>' +
|
|
19
|
+
'<span class="tip" role="tooltip" id="' + esc(id) + '" hidden>' + html + '</span>'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const OPEN_DELAY = 120
|
|
23
|
+
const CLOSE_DELAY = 180
|
|
24
|
+
|
|
25
|
+
export function wireTips(doc = document) {
|
|
26
|
+
const win = doc.defaultView
|
|
27
|
+
let cur = null // { btn, tip, pinned }
|
|
28
|
+
let timer = 0
|
|
29
|
+
const later = (fn, ms) => { win.clearTimeout(timer); timer = win.setTimeout(fn, ms) }
|
|
30
|
+
const tipOf = (btn) => btn && doc.getElementById(btn.getAttribute('aria-controls'))
|
|
31
|
+
|
|
32
|
+
function place(btn, tip) {
|
|
33
|
+
const r = btn.getBoundingClientRect()
|
|
34
|
+
const vh = win.innerHeight
|
|
35
|
+
// kept on screen, and inside a small popover (Quick look) when it sits in one
|
|
36
|
+
const box = btn.closest('.quicklook')
|
|
37
|
+
const b = box ? box.getBoundingClientRect() : { left: 0, right: doc.documentElement.clientWidth }
|
|
38
|
+
const lo = b.left + 8
|
|
39
|
+
const hi = b.right - 8
|
|
40
|
+
tip.style.maxWidth = Math.min(320, hi - lo) + 'px'
|
|
41
|
+
const w = tip.offsetWidth
|
|
42
|
+
const h = tip.offsetHeight
|
|
43
|
+
let left = r.right - w // right-aligned with the ⓘ
|
|
44
|
+
left = Math.max(lo, Math.min(left, hi - w))
|
|
45
|
+
let top = r.bottom + 6
|
|
46
|
+
let up = false
|
|
47
|
+
if (top + h > vh - 8 && r.top - 6 - h >= 8) { top = r.top - 6 - h; up = true }
|
|
48
|
+
tip.style.left = Math.round(left) + 'px'
|
|
49
|
+
tip.style.top = Math.round(top) + 'px'
|
|
50
|
+
tip.classList.toggle('up', up)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function show(btn, pinned) {
|
|
54
|
+
const tip = tipOf(btn)
|
|
55
|
+
if (!tip) return
|
|
56
|
+
win.clearTimeout(timer)
|
|
57
|
+
if (cur && cur.btn !== btn) hide()
|
|
58
|
+
tip.hidden = false
|
|
59
|
+
btn.setAttribute('aria-expanded', 'true')
|
|
60
|
+
place(btn, tip)
|
|
61
|
+
cur = { btn, tip, pinned: !!pinned || !!(cur && cur.btn === btn && cur.pinned) }
|
|
62
|
+
}
|
|
63
|
+
function hide() {
|
|
64
|
+
win.clearTimeout(timer)
|
|
65
|
+
if (!cur) return
|
|
66
|
+
cur.tip.hidden = true
|
|
67
|
+
if (cur.btn.isConnected) cur.btn.setAttribute('aria-expanded', 'false')
|
|
68
|
+
cur = null
|
|
69
|
+
}
|
|
70
|
+
const inside = (t) => !!(cur && t && t.closest && (cur.btn.contains(t) || cur.tip.contains(t)))
|
|
71
|
+
|
|
72
|
+
doc.addEventListener('click', (e) => {
|
|
73
|
+
if (cur && !cur.tip.isConnected) cur = null // the list was redrawn under it
|
|
74
|
+
const btn = e.target.closest && e.target.closest('.tip-btn')
|
|
75
|
+
if (btn) {
|
|
76
|
+
e.preventDefault(); e.stopPropagation()
|
|
77
|
+
if (cur && cur.btn === btn && cur.pinned) return hide()
|
|
78
|
+
return show(btn, true)
|
|
79
|
+
}
|
|
80
|
+
if (cur && !inside(e.target)) hide()
|
|
81
|
+
}, true)
|
|
82
|
+
// Mouse: hover the ⓘ (or the tip itself, so it can be read and selected)
|
|
83
|
+
doc.addEventListener('pointerover', (e) => {
|
|
84
|
+
if (e.pointerType !== 'mouse') return
|
|
85
|
+
const btn = e.target.closest && e.target.closest('.tip-btn')
|
|
86
|
+
if (btn) return (cur && cur.btn === btn) ? win.clearTimeout(timer) : later(() => show(btn, false), OPEN_DELAY)
|
|
87
|
+
if (inside(e.target)) win.clearTimeout(timer)
|
|
88
|
+
})
|
|
89
|
+
doc.addEventListener('pointerout', (e) => {
|
|
90
|
+
if (e.pointerType !== 'mouse') return
|
|
91
|
+
const from = e.target.closest && e.target.closest('.tip-btn, .tip')
|
|
92
|
+
if (!from) return
|
|
93
|
+
if (inside(e.relatedTarget)) return
|
|
94
|
+
if (!cur) return win.clearTimeout(timer)
|
|
95
|
+
if (!cur.pinned) later(hide, CLOSE_DELAY)
|
|
96
|
+
})
|
|
97
|
+
// Keyboard: focus on the ⓘ shows it, leaving it hides it (unless tapped open)
|
|
98
|
+
doc.addEventListener('focusin', (e) => {
|
|
99
|
+
const btn = e.target.closest && e.target.closest('.tip-btn')
|
|
100
|
+
if (btn && btn.matches(':focus-visible')) show(btn, false)
|
|
101
|
+
})
|
|
102
|
+
doc.addEventListener('focusout', (e) => {
|
|
103
|
+
if (cur && !cur.pinned && e.target === cur.btn && !inside(e.relatedTarget)) hide()
|
|
104
|
+
})
|
|
105
|
+
// Escape closes it first (before Quick look or a dialog hears the key)
|
|
106
|
+
doc.addEventListener('keydown', (e) => {
|
|
107
|
+
if (e.key !== 'Escape' || !cur) return
|
|
108
|
+
e.preventDefault(); e.stopImmediatePropagation()
|
|
109
|
+
const b = cur.btn
|
|
110
|
+
const back = doc.activeElement && doc.activeElement !== b && cur.tip.contains(doc.activeElement)
|
|
111
|
+
hide()
|
|
112
|
+
if (back) b.focus()
|
|
113
|
+
}, true)
|
|
114
|
+
win.addEventListener('resize', () => { if (cur) place(cur.btn, cur.tip) })
|
|
115
|
+
win.addEventListener('scroll', () => { if (cur) { if (!cur.btn.isConnected) hide(); else place(cur.btn, cur.tip) } }, true)
|
|
116
|
+
return { hide, isOpen: () => !!cur }
|
|
117
|
+
}
|