@leftoff/cli 0.0.0-stage → 0.6.3

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.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/CHANGELOG.md +564 -0
  3. package/LICENSE.md +9 -0
  4. package/README.md +28 -2
  5. package/assets/sample-transcript.jsonl +12 -0
  6. package/bin/checkpoints.js +7 -0
  7. package/bin/leftoff.js +585 -0
  8. package/hooks/README.md +22 -0
  9. package/hooks/settings-hooks.example.json +82 -0
  10. package/mod/.claude-plugin/plugin.json +15 -0
  11. package/mod/hooks/core.js +1397 -0
  12. package/mod/hooks/hooks.json +4 -0
  13. package/mod/hooks/register.js +1006 -0
  14. package/package.json +45 -4
  15. package/templates/agent-feed-worker/README.md +45 -0
  16. package/templates/agent-feed-worker/src/index.js +69 -0
  17. package/templates/agent-feed-worker/wrangler.toml +20 -0
  18. package/ui/app.css +1282 -0
  19. package/ui/app.js +2979 -0
  20. package/ui/apple-touch-icon.png +0 -0
  21. package/ui/changelog-md.js +64 -0
  22. package/ui/favicon-32.png +0 -0
  23. package/ui/favicon-badge-32.png +0 -0
  24. package/ui/favicon-badge.svg +1 -0
  25. package/ui/favicon.ico +0 -0
  26. package/ui/favicon.svg +1 -0
  27. package/ui/go.js +68 -0
  28. package/ui/icon-192.png +0 -0
  29. package/ui/icon-512.png +0 -0
  30. package/ui/icon-maskable-512.png +0 -0
  31. package/ui/icons.js +41 -0
  32. package/ui/index.html +55 -0
  33. package/ui/manifest.webmanifest +37 -0
  34. package/ui/needs.js +197 -0
  35. package/ui/offline.css +11 -0
  36. package/ui/offline.html +24 -0
  37. package/ui/pair.html +31 -0
  38. package/ui/picker.js +294 -0
  39. package/ui/relay-client.js +187 -0
  40. package/ui/relay-crypto.js +155 -0
  41. package/ui/relay-pair.js +48 -0
  42. package/ui/repos.js +167 -0
  43. package/ui/settings-nav.js +23 -0
  44. package/ui/summary-examples.js +66 -0
  45. package/ui/sw.js +34 -0
  46. package/ui/theme.js +41 -0
  47. package/ui/vendor/LUCIDE-LICENSE.txt +45 -0
@@ -0,0 +1,48 @@
1
+ // The pairing page at <user>.leftoff.dev/pair.html (v0.6.0). The QR code's
2
+ // link carries the one-time code after "#", which the browser never sends to
3
+ // any server; typed codes work too. This page makes the browser's half of the
4
+ // key exchange, checks the Mac's half against the code, and keeps the
5
+ // resulting key in IndexedDB (not extractable), then opens the board.
6
+ import { codeFromHash, normalizeCode, pairIdOf, newEcdh, pairMac, pairMacOk, deriveDeviceKey } from './relay-crypto.js'
7
+ import { saveDeviceKey } from './relay-client.js'
8
+
9
+ const $ = (id) => document.getElementById(id)
10
+ const user = location.hostname.split('.')[0]
11
+ $('host').textContent = location.host
12
+
13
+ function deviceName() {
14
+ const ua = navigator.userAgent
15
+ const dev = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android phone' : /Macintosh/.test(ua) ? 'Mac' : /Windows/.test(ua) ? 'Windows PC' : 'Browser'
16
+ const br = /CriOS|Chrome\//.test(ua) && !/Edg\//.test(ua) ? 'Chrome' : /FxiOS|Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge' : /Safari\//.test(ua) ? 'Safari' : ''
17
+ return dev + (br ? ' · ' + br : '')
18
+ }
19
+
20
+ function say(text, kind = '') { const m = $('msg'); m.textContent = text; m.className = 'pmsg ' + kind }
21
+
22
+ async function pair(code) {
23
+ const n = normalizeCode(code)
24
+ if (!n) return say('That doesn’t look like a code: 16 letters and digits, like 7KQ2-…', 'bad')
25
+ if (!globalThis.crypto || !crypto.subtle) return say('This browser can’t do the encryption Leftoff needs (open the page over https).', 'bad')
26
+ $('go').disabled = true; say('Pairing…')
27
+ try {
28
+ const pair = pairIdOf(n)
29
+ const me = await newEcdh()
30
+ const name = ($('name').value || deviceName()).slice(0, 60)
31
+ const mac = await pairMac(n, ['browser', pair, me.pub])
32
+ const res = await fetch('/_pair', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ pair, pub: me.pub, name, mac }) })
33
+ const j = await res.json().catch(() => ({}))
34
+ if (!res.ok) { $('go').disabled = false; return say(j.message || 'Pairing failed (' + res.status + ').', 'bad') }
35
+ // The Mac proves it knows the code too, so the relay can't have swapped keys.
36
+ if (!(await pairMacOk(n, ['mac', pair, me.pub, j.pub, j.device], j.mac))) { $('go').disabled = false; return say('The answer didn’t prove it came from your Mac. Nothing was saved. Make a new code and try again.', 'bad') }
37
+ const { key } = await deriveDeviceKey({ privateKey: me.privateKey, peerPub: j.pub, code: n, device: j.device })
38
+ await saveDeviceKey({ device: j.device, key, user: j.user || user, machine: j.machine, name, pairedAt: new Date().toISOString() })
39
+ history.replaceState(null, '', '/pair.html')
40
+ say('Paired with ' + j.machine + '. Opening your board…', 'ok')
41
+ setTimeout(() => location.replace('/'), 600)
42
+ } catch (e) { $('go').disabled = false; say('Pairing failed: ' + e.message, 'bad') }
43
+ }
44
+
45
+ $('name').placeholder = deviceName()
46
+ $('form').addEventListener('submit', (ev) => { ev.preventDefault(); pair($('code').value) })
47
+ const fromLink = codeFromHash(location.hash)
48
+ if (fromLink) { $('code').value = fromLink; history.replaceState(null, '', '/pair.html'); pair(fromLink) }
package/ui/repos.js ADDED
@@ -0,0 +1,167 @@
1
+ // Repo filtering (v0.4.2): which repo a session belongs to, short labels that
2
+ // stay unique, the chip row's counts, and the filter state (status AND repo)
3
+ // with its URL hash and localStorage forms. Plain functions with no DOM, so
4
+ // the board, the daemon, the CLI and the tests all share one copy.
5
+
6
+ // Path pieces, either slash.
7
+ export function segments(p) { return String(p || '').split(/[\\/]+/).filter(Boolean) }
8
+ export function baseOf(p) { const s = segments(p); return s.length ? s[s.length - 1] : '' }
9
+
10
+ // The repo key for a session: the git root when one is known (recorded by the
11
+ // mod or the daemon, or found by the daemon's .git walk as `repoRoot`), else
12
+ // the working directory, else 'no-repo'. The daemon fills `repoKey`; this is
13
+ // the same rule for objects that don't carry it yet.
14
+ export function repoKeyOf(s) {
15
+ if (!s) return 'no-repo'
16
+ if (s.repoKey) return s.repoKey
17
+ const r = s.repo || {}
18
+ return s.repoRoot || r.root || s.cwd || r.name || s.repoName || 'no-repo'
19
+ }
20
+
21
+ // Short labels: the folder name, and for folder names that collide, as many
22
+ // parent folders as it takes to tell them apart ("colorGenie/expo" vs
23
+ // "food_fight/expo"). keys -> Map(key -> label).
24
+ export function labelRepos(keys) {
25
+ const uniq = [...new Set([...keys].filter(Boolean))]
26
+ const depth = new Map(uniq.map((k) => [k, 1]))
27
+ const label = (k) => {
28
+ if (k === 'no-repo') return 'no repo'
29
+ const s = segments(k)
30
+ return s.length ? s.slice(-Math.min(depth.get(k), s.length)).join('/') : k
31
+ }
32
+ for (let round = 0; round < 12; round++) {
33
+ const byLabel = new Map()
34
+ for (const k of uniq) { const l = label(k); byLabel.set(l, [...(byLabel.get(l) || []), k]) }
35
+ let grew = false
36
+ for (const ks of byLabel.values()) {
37
+ if (ks.length < 2) continue
38
+ for (const k of ks) if (depth.get(k) < segments(k).length) { depth.set(k, depth.get(k) + 1); grew = true }
39
+ }
40
+ if (!grew) break
41
+ }
42
+ return new Map(uniq.map((k) => [k, label(k)]))
43
+ }
44
+
45
+ const t = (iso) => { const n = Date.parse(iso || ''); return isNaN(n) ? 0 : n }
46
+ export const lastTouch = (s) => Math.max(t(s.lastActivityAt), t(s.endedAt), t(s.startedAt))
47
+
48
+ // The chip row: one entry per repo with its count and latest activity, most
49
+ // recently active first. `countIn` (optional) is the subset to count, so the
50
+ // chips can reflect the status filter while still listing every repo.
51
+ export function repoChips(sessions, { countIn } = {}) {
52
+ const m = new Map()
53
+ for (const s of sessions) {
54
+ const key = repoKeyOf(s)
55
+ const e = m.get(key) || { key, label: s.repoLabel || null, count: 0, total: 0, last: 0 }
56
+ e.total++
57
+ e.last = Math.max(e.last, lastTouch(s))
58
+ if (!e.label && s.repoLabel) e.label = s.repoLabel
59
+ m.set(key, e)
60
+ }
61
+ if (countIn) for (const s of countIn) { const e = m.get(repoKeyOf(s)); if (e) e.count++ }
62
+ else for (const e of m.values()) e.count = e.total
63
+ const labels = labelRepos(m.keys())
64
+ for (const e of m.values()) if (!e.label) e.label = labels.get(e.key)
65
+ return [...m.values()].sort((a, b) => (b.last - a.last) || a.label.localeCompare(b.label))
66
+ }
67
+
68
+ // Does a session belong to the repo a token names? A token is whatever sits
69
+ // in the URL or the CLI flag: a label ("clutch_hunt", "colorGenie/expo"), the
70
+ // full path, or the folder name. Case doesn't matter. `loose` also takes a
71
+ // substring of the label or folder name (the CLI's --repo hunt).
72
+ export function repoMatches(s, token, { loose = false } = {}) {
73
+ const tok = String(token || '').trim().toLowerCase().replace(/\/+$/, '')
74
+ if (!tok) return false
75
+ const key = repoKeyOf(s)
76
+ const cands = [key, s.repoLabel, s.repoName, baseOf(key)].filter(Boolean).map((x) => String(x).toLowerCase())
77
+ if (cands.includes(tok)) return true
78
+ // "parent/base" given for a key that is ".../parent/base"
79
+ if (tok.includes('/') && String(key).toLowerCase().endsWith('/' + tok)) return true
80
+ return loose && cands.some((c) => c.includes(tok))
81
+ }
82
+
83
+ // The CLI's --repo: exact matches win; with none, a substring.
84
+ export function filterByRepo(sessions, token) {
85
+ if (!token) return sessions
86
+ const exact = sessions.filter((s) => repoMatches(s, token))
87
+ return exact.length ? exact : sessions.filter((s) => repoMatches(s, token, { loose: true }))
88
+ }
89
+
90
+ // ------------------------------------------------------------ board filter state
91
+
92
+ export const STATUS_FILTERS = {
93
+ needs: ['needs_input'],
94
+ active: ['active', 'background'],
95
+ waiting: ['waiting'],
96
+ dormant: ['dormant', 'stale'],
97
+ ended: ['ended'],
98
+ }
99
+
100
+ export const STORE_KEY = 'cp.repos'
101
+
102
+ const splitList = (v) => String(v || '').split(',').map((x) => x.trim()).filter(Boolean)
103
+
104
+ // From the hash's query (URLSearchParams or string) and storage: the hash wins
105
+ // when it says anything about repos (`repo=` empty means "all"); otherwise the
106
+ // last selection from localStorage.
107
+ export function readFilter(params, storage) {
108
+ const p = typeof params === 'string' ? new URLSearchParams(params.replace(/^.*\?/, '')) : (params || new URLSearchParams())
109
+ let repos
110
+ let from = 'none'
111
+ if (p.has('repo')) { repos = p.getAll('repo').flatMap(splitList); from = 'hash' }
112
+ else {
113
+ try { const v = storage && storage.getItem(STORE_KEY); repos = v ? JSON.parse(v) : [] ; if (repos.length) from = 'storage' } catch { repos = [] }
114
+ if (!Array.isArray(repos)) repos = []
115
+ }
116
+ const status = p.get('status')
117
+ return { repos: [...new Set(repos.map(String))], status: status && STATUS_FILTERS[status] ? status : null, from }
118
+ }
119
+
120
+ export function saveRepos(storage, repos) {
121
+ try { if (!storage) return; if (repos && repos.length) storage.setItem(STORE_KEY, JSON.stringify(repos)); else storage.removeItem(STORE_KEY) } catch {}
122
+ }
123
+
124
+ // The board's hash for a filter: "#/", "#/?repo=clutch_hunt",
125
+ // "#/?repo=a,b&status=active". Commas and slashes stay readable.
126
+ export function filterHash({ repos = [], status = null } = {}) {
127
+ const parts = []
128
+ if (repos.length) parts.push('repo=' + repos.map((r) => encodeURIComponent(r).replace(/%2F/gi, '/')).join(','))
129
+ if (status) parts.push('status=' + encodeURIComponent(status))
130
+ return '#/' + (parts.length ? '?' + parts.join('&') : '')
131
+ }
132
+
133
+ // A click on a repo chip ({ key, label } or a bare label). Plain click: just
134
+ // this repo, or clear when it was already the only one selected. Shift/Cmd/
135
+ // Ctrl-click: add it, or take it out. Tokens that name the chip another way
136
+ // (its full path, from a shared link) count as the chip.
137
+ export function chipSelected(selected, chip) {
138
+ const c = typeof chip === 'string' ? { key: chip, label: chip } : chip
139
+ const probe = { repoKey: c.key, repoLabel: c.label }
140
+ return (selected || []).some((tok) => tok === c.label || repoMatches(probe, tok))
141
+ }
142
+ export function toggleRepo(selected, chip, multi = false) {
143
+ const c = typeof chip === 'string' ? { key: chip, label: chip } : chip
144
+ const cur = [...(selected || [])]
145
+ const probe = { repoKey: c.key, repoLabel: c.label }
146
+ const isIt = (tok) => tok === c.label || repoMatches(probe, tok)
147
+ const has = cur.some(isIt)
148
+ if (multi) return has ? cur.filter((x) => !isIt(x)) : [...cur, c.label]
149
+ return has && cur.every(isIt) ? [] : [c.label]
150
+ }
151
+
152
+ // Status AND repo. `repos` are tokens (labels); an empty list means all repos.
153
+ export function applyFilter(sessions, { repos = [], status = null } = {}) {
154
+ const statuses = status ? STATUS_FILTERS[status] : null
155
+ return sessions.filter((s) => (!statuses || statuses.includes(s.status)) && (!repos.length || repos.some((r) => repoMatches(s, r))))
156
+ }
157
+
158
+ // Which chips show in the row and which go behind "More ▾": the top `max` by
159
+ // recent activity, plus any selected one that would otherwise be hidden.
160
+ export function splitChips(chips, selected = [], max = 8) {
161
+ if (chips.length <= max) return { shown: chips, more: [] }
162
+ const isSel = (c) => chipSelected(selected, c)
163
+ const shown = chips.slice(0, max)
164
+ const more = []
165
+ for (const c of chips.slice(max)) (isSel(c) ? shown : more).push(c)
166
+ return { shown, more }
167
+ }
@@ -0,0 +1,23 @@
1
+ // Settings sections (v0.5.9): their order, names, icons, the words the search
2
+ // box also matches, and the old names a link may still use. Pure data, so the
3
+ // unit tests read it too (test/unit/settings-nav.test.mjs).
4
+ export const SETTINGS = [
5
+ { key: 'general', label: 'General', icon: 'sliders-horizontal', sub: 'Checkpoints, notifications and theme.', words: 'dormant gap checkpoint notify notification needs you macos theme appearance light dark system', save: true },
6
+ { key: 'digest', label: 'Digest', icon: 'newspaper', sub: 'When the daily digest is written.', words: 'time days weekdays every day quiet skip empty time zone schedule morning automatic', save: true },
7
+ { key: 'spaces', label: 'Spaces', icon: 'layers', sub: 'Work, Personal or your own names, per Mac and per repo.', words: 'work personal repo override mac default standup agent feed' },
8
+ { key: 'sync', label: 'Sync', icon: 'folder-sync', sub: 'One folder, every Mac.', words: 'syncthing pair pairing join code icloud dropbox folder move backup other macs install accept' },
9
+ { key: 'agents', label: 'Agents', icon: 'bot', sub: 'Work from AI assistants outside Claude Code.', words: 'tab feed agent key url host hosting add refresh remove' },
10
+ { key: 'models', label: 'Models & summaries', icon: 'sparkles', sub: 'Who writes your checkpoints, and how long they are.', words: 'summarizer provider model ollama anthropic openai claude haiku sonnet opus api key server url length short long informative', save: true },
11
+ { key: 'phone', label: 'Phone & remote', icon: 'smartphone', sub: 'Your board on your iPhone, from anywhere or over Tailscale.', words: 'use from anywhere relay leftoff.dev subdomain pair pairing device devices revoke tailscale remote access qr code sign in signed iphone home screen url app' },
12
+ { key: 'security', label: 'Privacy & security', icon: 'shield', sub: 'Who can answer prompts here, and machine keys.', words: 'answer answering permission prompts other machines go keys fingerprint trust privacy data protected encryption', save: true },
13
+ { key: 'updates', label: 'Updates & about', icon: 'info', sub: 'Version, updates and this Mac.', words: 'update upgrade version check about machine folder what’s new whats new changelog', save: true },
14
+ ]
15
+ export const SETTINGS_ALIAS = { anywhere: 'phone', relay: 'phone', remote: 'phone', tailscale: 'phone', iphone: 'phone', machines: 'security', answering: 'security', privacy: 'security', summaries: 'models', summarizer: 'models', model: 'models', ai: 'models', syncthing: 'sync', pairing: 'sync', pair: 'sync', about: 'updates', update: 'updates', version: 'updates', checkpoints: 'general', appearance: 'general', theme: 'general', notify: 'general' }
16
+ // The section a route asks for: #/settings/<key>, or the old ?<key> form
17
+ // (#/settings?spaces, ?sync, ?remote…); null means the list.
18
+ export function settingsKeyFor(arg, paramKeys = []) {
19
+ const want = (k) => { k = String(k || '').toLowerCase(); return SETTINGS.some((x) => x.key === k) ? k : SETTINGS_ALIAS[k] || null }
20
+ if (arg) { const k = want(arg); if (k) return k }
21
+ for (const k of paramKeys) { const w = want(k); if (w) return w }
22
+ return null
23
+ }
@@ -0,0 +1,66 @@
1
+ // Checkpoint lengths (v0.4.6): the example shown for each one in the setup
2
+ // wizard and in Settings → Summaries, and the plain-text form of a checkpoint
3
+ // the CLI prints. Pure, no DOM: the daemon, the CLI and the board import it.
4
+
5
+ // One made-up session, written at each length. Short is the three lines every
6
+ // length starts with; the board, the pane and the digest only ever use those.
7
+ const BASE = {
8
+ doing: 'Adding retry with backoff to the sync client (src/sync/client.ts).',
9
+ stoppedAt: 'Unit tests pass; the integration test times out after 30s on the second retry.',
10
+ next: 'YOU: pick between faking the clock in the integration test or raising its timeout.',
11
+ }
12
+ const INFORMATIVE = {
13
+ decisions: [
14
+ 'Exponential backoff capped at 30s: the API rate-limits at 1 request/s.',
15
+ 'Retries live in the client, not the job queue, so every caller gets them.',
16
+ ],
17
+ questions: ['Should a 429 count toward the 5-retry cap?'],
18
+ files: [
19
+ { path: 'src/sync/client.ts', why: 'the retry loop and backoff' },
20
+ { path: 'src/sync/client.test.ts', why: 'unit tests for 500, 429 and timeouts' },
21
+ { path: 'test/sync.int.test.ts', why: 'raised the timeout to see if it was just slow' },
22
+ ],
23
+ didntWork: ['Jest fake timers with fetch: the mocked response never resolved.'],
24
+ }
25
+ const LONG = {
26
+ recap: 'The session set out to make sync survive the API\'s rate limit after Monday\'s failed imports. ' +
27
+ 'It added a retry loop with exponential backoff to the client and covered 500s, 429s and timeouts with unit tests. ' +
28
+ 'Faking timers broke fetch, so the integration test still runs on real time, and the second retry pushes it past 30 seconds. ' +
29
+ 'Everything else is green; the open choice is how to test the slow path.',
30
+ nextSteps: [
31
+ 'Fake the clock in the integration test with an injectable sleep().',
32
+ 'Decide whether 429s count toward the retry cap and note it in the README.',
33
+ 'Open the PR against main and link the import incident.',
34
+ ],
35
+ }
36
+ export const SUMMARY_EXAMPLES = {
37
+ short: { ...BASE },
38
+ informative: { ...BASE, ...INFORMATIVE },
39
+ long: { ...BASE, ...INFORMATIVE, ...LONG },
40
+ }
41
+ export const LENGTH_CHOICES = [
42
+ { key: 'short', label: 'Short', approx: '~300 tokens', blurb: 'Doing, Stopped at, Next. Three lines.' },
43
+ { key: 'informative', label: 'Informative', approx: '~700 tokens', blurb: 'Adds key decisions, open questions, files touched and why, and what didn’t work.', recommended: true },
44
+ { key: 'long', label: 'Long', approx: '~1500 tokens', blurb: 'Adds a narrative recap and the next 3 steps.' },
45
+ ]
46
+
47
+ // A checkpoint as plain text (the CLI, the setup wizard).
48
+ export function formatCheckpointText(cp, { indent = '' } = {}) {
49
+ const out = []
50
+ const line = (s) => out.push(indent + s)
51
+ line('Doing: ' + (cp.doing || ''))
52
+ line('Stopped at: ' + (cp.stoppedAt || ''))
53
+ line('Next: ' + (cp.next || ''))
54
+ const list = (title, items, fmt = (x) => x) => {
55
+ if (!Array.isArray(items) || !items.length) return
56
+ line(title + ':')
57
+ items.forEach((x, i) => line((title === 'Next steps' ? ' ' + (i + 1) + '. ' : ' - ') + fmt(x)))
58
+ }
59
+ list('Key decisions', cp.decisions)
60
+ list('Open questions', cp.questions)
61
+ list('Files touched', cp.files, (f) => (f.path || '') + (f.path && f.why ? ' — ' : '') + (f.why || ''))
62
+ list('What didn’t work', cp.didntWork)
63
+ if (cp.recap) line('Recap: ' + cp.recap)
64
+ list('Next steps', cp.nextSteps)
65
+ return out.join('\n')
66
+ }
package/ui/sw.js ADDED
@@ -0,0 +1,34 @@
1
+ // Leftoff's service worker (v0.4.6). One job: when the Mac running Leftoff
2
+ // can't be reached (Tailscale off on the phone, the Mac asleep, the daemon
3
+ // stopped), a page navigation shows offline.html instead of Safari's error.
4
+ // It caches nothing else: no API data, no board pages, no assets. Every other
5
+ // request goes straight to the network, untouched.
6
+ const CACHE = 'leftoff-offline-v1'
7
+ const OFFLINE = ['/offline.html', '/offline.css', '/icon-192.png']
8
+
9
+ self.addEventListener('install', (event) => {
10
+ event.waitUntil(caches.open(CACHE).then((c) => c.addAll(OFFLINE)).then(() => self.skipWaiting()))
11
+ })
12
+
13
+ self.addEventListener('activate', (event) => {
14
+ event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()))
15
+ })
16
+
17
+ self.addEventListener('fetch', (event) => {
18
+ const req = event.request
19
+ if (req.method !== 'GET') return
20
+ const url = new URL(req.url)
21
+ if (url.origin !== self.location.origin) return
22
+ if (req.mode === 'navigate') {
23
+ // A 502/503/504 is Tailscale Serve saying the daemon behind it is down.
24
+ // (The relay's own "Your Macs are asleep" page, x-leftoff-page, shows as is.)
25
+ event.respondWith(fetch(req).then((res) => (res.status >= 502 && res.status <= 504 && !res.headers.get('x-leftoff-page') ? offline() : res)).catch(offline))
26
+ return
27
+ }
28
+ // The offline page's own files, so it renders with no network.
29
+ if (OFFLINE.includes(url.pathname)) event.respondWith(fetch(req).catch(() => caches.match(url.pathname)))
30
+ })
31
+
32
+ function offline() {
33
+ return caches.match('/offline.html').then((r) => r || new Response('Leftoff is offline', { status: 503, headers: { 'content-type': 'text/plain' } }))
34
+ }
package/ui/theme.js ADDED
@@ -0,0 +1,41 @@
1
+ // Theme: light / dark / system. Loaded as a blocking script in <head>, before
2
+ // the stylesheet paints, so the page never flashes the wrong theme. (The CSP
3
+ // allows no inline script, so it lives in its own file.) The choice is kept in
4
+ // localStorage 'cp.theme'; "system" follows prefers-color-scheme live.
5
+ (function () {
6
+ var KEY = 'cp.theme'
7
+ var root = document.documentElement
8
+ var mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null
9
+ function pref() {
10
+ try { var v = localStorage.getItem(KEY); return v === 'light' || v === 'dark' ? v : 'system' } catch (e) { return 'system' }
11
+ }
12
+ function resolve(p) { return p === 'system' ? (mq && mq.matches ? 'light' : 'dark') : p }
13
+ function apply() {
14
+ var p = pref()
15
+ var t = resolve(p)
16
+ root.setAttribute('data-theme', t)
17
+ root.setAttribute('data-theme-pref', p)
18
+ // The browser's / home-screen app's bar color follows the theme picked here.
19
+ var metas = document.querySelectorAll('meta[name="theme-color"]')
20
+ for (var m = 0; m < metas.length; m++) metas[m].setAttribute('content', t === 'light' ? '#f5f6f9' : '#0d1016')
21
+ var btns = document.querySelectorAll('[data-theme-set]')
22
+ for (var i = 0; i < btns.length; i++) btns[i].setAttribute('aria-pressed', String(btns[i].getAttribute('data-theme-set') === p))
23
+ }
24
+ function set(p) {
25
+ try { if (p === 'system') localStorage.removeItem(KEY); else localStorage.setItem(KEY, p) } catch (e) {}
26
+ apply()
27
+ }
28
+ apply()
29
+ if (mq) {
30
+ var onChange = function () { if (pref() === 'system') apply() }
31
+ if (mq.addEventListener) mq.addEventListener('change', onChange); else if (mq.addListener) mq.addListener(onChange)
32
+ }
33
+ // Another tab changed it.
34
+ window.addEventListener('storage', function (e) { if (e.key === KEY) apply() })
35
+ document.addEventListener('click', function (e) {
36
+ var b = e.target.closest && e.target.closest('[data-theme-set]')
37
+ if (b) { e.preventDefault(); set(b.getAttribute('data-theme-set')) }
38
+ })
39
+ document.addEventListener('DOMContentLoaded', apply)
40
+ window.cpTheme = { pref: pref, set: set, apply: apply, resolved: function () { return resolve(pref()) } }
41
+ })()
@@ -0,0 +1,45 @@
1
+ Icons in ui/icons.js and ui/index.html are from Lucide (https://lucide.dev), lucide-static v1.54.0.
2
+
3
+ ISC License
4
+
5
+ Copyright (c) 2026 Lucide Icons and Contributors
6
+
7
+ Permission to use, copy, modify, and/or distribute this software for any
8
+ purpose with or without fee is hereby granted, provided that the above
9
+ copyright notice and this permission notice appear in all copies.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
17
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18
+
19
+ ---
20
+
21
+ The following Lucide icons are derived from the Feather project:
22
+
23
+ airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
24
+
25
+ The MIT License (MIT) (for the icons listed above)
26
+
27
+ Copyright (c) 2013-present Cole Bemis
28
+
29
+ Permission is hereby granted, free of charge, to any person obtaining a copy
30
+ of this software and associated documentation files (the "Software"), to deal
31
+ in the Software without restriction, including without limitation the rights
32
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
33
+ copies of the Software, and to permit persons to whom the Software is
34
+ furnished to do so, subject to the following conditions:
35
+
36
+ The above copyright notice and this permission notice shall be included in all
37
+ copies or substantial portions of the Software.
38
+
39
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
40
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
41
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
42
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
43
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
44
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
45
+ SOFTWARE.