@leftoff/cli 0.9.1 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/picker.js CHANGED
@@ -283,8 +283,9 @@ function onKey(host, e) {
283
283
  // choosing one closes the menu. Same look as the selects' menu: under (or
284
284
  // above) the ⋯ on a desktop, a bottom sheet under 600px. Arrows, Home/End,
285
285
  // Enter/Space, Escape (focus back on the ⋯), Tab, and a tap outside closes.
286
- // items: [{ label, icon (svg markup, optional), attrs: { 'data-action': …, 'data-id': … }, cls }]
287
- let act = null // { anchor, menu, backdrop, items }
286
+ // items: [{ label, icon (svg markup, optional), attrs: { 'data-action': …, 'data-id': … }, cls, sub, on }]
287
+ // plus { note } (a plain line) and { …item, more: { title, items } } (a › to a second page)
288
+ let act = null // { anchor, menu, backdrop, items, page, up }
288
289
  export function actionMenuOpen() { return !!act }
289
290
  export function closeActionMenu({ focus = false } = {}) {
290
291
  if (!act) return
@@ -294,18 +295,22 @@ export function closeActionMenu({ focus = false } = {}) {
294
295
  anchor.setAttribute('aria-expanded', 'false')
295
296
  if (focus && anchor.isConnected) anchor.focus()
296
297
  }
297
- function placeAction() {
298
+ // h: the height to place for (a page change animates to it); keep: stay on the
299
+ // side of the ⋯ the menu opened on, so a second page doesn't make it jump.
300
+ function placeAction({ h: hTo, keep = false } = {}) {
298
301
  if (!act) return
299
302
  const { menu, anchor } = act
300
303
  const vw = document.documentElement.clientWidth, vh = (window.visualViewport && window.visualViewport.height) || window.innerHeight
301
304
  if (menu.classList.contains('sheet')) { menu.style.maxHeight = Math.round(vh * 0.72) + 'px'; return }
302
305
  const r = anchor.getBoundingClientRect()
303
306
  const pad = 8
304
- const w = menu.offsetWidth, h = menu.offsetHeight
305
- const up = vh - r.bottom - pad < h + 6 && r.top - pad > vh - r.bottom - pad
307
+ const w = menu.offsetWidth, h = hTo || menu.offsetHeight
308
+ const up = keep && act.up != null ? act.up : vh - r.bottom - pad < h + 6 && r.top - pad > vh - r.bottom - pad
309
+ act.up = up
306
310
  // right edges line up (the ⋯ sits at the end of the row), never past the viewport
307
311
  menu.style.left = Math.max(pad, Math.min(r.right - w, vw - pad - w)) + 'px'
308
- menu.style.top = (up ? Math.max(pad, r.top - 6 - h) : r.bottom + 6) + 'px'
312
+ // never past the bottom either (a taller second page can't fall off the screen)
313
+ menu.style.top = (up ? Math.max(pad, r.top - 6 - h) : Math.max(pad, Math.min(r.bottom + 6, vh - pad - h))) + 'px'
309
314
  menu.classList.toggle('up', up)
310
315
  }
311
316
  function actFocus(k) {
@@ -314,6 +319,44 @@ function actFocus(k) {
314
319
  if (!n) return
315
320
  act.items[((k % n) + n) % n].focus()
316
321
  }
322
+ const SVG = (d, cls = 'ic') => '<svg class="' + cls + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + d + '</svg>'
323
+ const ACT_NEXT = SVG('<path d="m9 18 6-6-6-6"/>', 'ic lo-act-chev')
324
+ const ACT_BACK = SVG('<path d="m15 18-6-6 6-6"/>', 'ic lo-act-chev')
325
+ const ACT_CHECK = SVG('<path d="M20 6 9 17l-5-5"/>', 'ic lo-act-check')
326
+ const reducedMotion = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
327
+ // 0.9.4: show page p of the menu (0 is the main one). The new page slides in
328
+ // from the side it lives on and the menu's height eases to the new one (its
329
+ // width is fixed), unless you asked for less motion.
330
+ function actPage(p, { focus } = {}) {
331
+ if (!act) return
332
+ const { menu } = act
333
+ const to = menu.querySelector('.lo-act-page[data-page="' + p + '"]'), from = act.page
334
+ if (!to || !from || to === from) return
335
+ const back = p < Number(from.dataset.page)
336
+ const h0 = menu.offsetHeight
337
+ from.hidden = true; to.hidden = false
338
+ from.classList.remove('lo-in-fwd', 'lo-in-back')
339
+ act.page = to
340
+ act.items = [...to.querySelectorAll('[role="menuitem"]')]
341
+ menu.classList.toggle('on-sub', p > 0)
342
+ const h1 = menu.offsetHeight
343
+ if (!reducedMotion()) {
344
+ to.classList.remove('lo-in-fwd', 'lo-in-back'); void to.offsetWidth
345
+ to.classList.add(back ? 'lo-in-back' : 'lo-in-fwd')
346
+ if (h1 !== h0) {
347
+ menu.classList.remove('lo-act-sizing')
348
+ menu.style.height = h0 + 'px'; void menu.offsetHeight
349
+ menu.classList.add('lo-act-sizing')
350
+ menu.style.height = h1 + 'px'
351
+ let t = 0
352
+ const done = (e) => { if (e && e.target !== menu) return; clearTimeout(t); menu.removeEventListener('transitionend', done); if (act && act.menu === menu) { menu.style.height = ''; menu.classList.remove('lo-act-sizing') } }
353
+ menu.addEventListener('transitionend', done); t = setTimeout(done, 320)
354
+ }
355
+ }
356
+ placeAction({ h: h1, keep: true })
357
+ const el = focus || (p > 0 ? (to.querySelector('.lo-act.on') || act.items[1] || act.items[0]) : act.items[0])
358
+ if (el) el.focus({ preventScroll: true })
359
+ }
317
360
  export function openActionMenu(anchor, items, { title = '' } = {}) {
318
361
  if (act && act.anchor === anchor) { closeActionMenu({ focus: true }); return null }
319
362
  closeActionMenu(); closeMenu()
@@ -324,30 +367,62 @@ export function openActionMenu(anchor, items, { title = '' } = {}) {
324
367
  menu.id = id
325
368
  menu.setAttribute('role', 'menu')
326
369
  if (title) menu.setAttribute('aria-label', title)
370
+ // 0.9.2: an item may carry a sub line ({ sub }) or be a plain line ({ note }:
371
+ // what the menu is about, read out as its description).
372
+ // 0.9.4: an item may open a second page ({ more: { title, items } }), the iOS
373
+ // way: the row itself does its action, and a › at its right edge (its own
374
+ // target) slides the menu over to that page, which starts with a ‹ title row
375
+ // (back). Items there may be { on }: a ✓, e.g. your default length.
376
+ const one = (it, extra = '') => '<button type="button" role="menuitem" tabindex="-1" class="lo-act' + extra + (it.sub ? ' has-sub' : '') + (it.on ? ' on' : '') + (it.cls ? ' ' + esc(it.cls) : '') + '"' + attrs(it.attrs || {}) + '>' + (it.icon || '') +
377
+ (it.sub ? '<span class="lo-act-txt"><span>' + esc(it.label) + '</span><span class="lo-act-sub">' + esc(it.sub) + '</span></span>' : '<span>' + esc(it.label) + '</span>') +
378
+ (it.on && it.onLabel ? '<span class="lo-vh">' + esc(it.onLabel) + '</span>' : '') + '</button>'
379
+ const notes = [], subs = []
380
+ const split = (it) => { subs.push(it.more); const p = subs.length
381
+ return '<div class="lo-act-split" role="none">' + one(it, ' lo-act-main') +
382
+ '<button type="button" role="menuitem" tabindex="-1" class="lo-act-next" data-act-page="' + p + '" aria-haspopup="menu" aria-label="' + esc(it.more.title) + '" title="' + esc(it.more.title) + '">' + ACT_NEXT + '</button></div>' }
383
+ const main = items.map((it, i) => it.note ? (notes.push(id + '-n' + i), '<div class="lo-act-note" id="' + id + '-n' + i + '" role="none">' + (it.icon || '') + '<span>' + esc(it.note) + '</span></div>')
384
+ : it.more ? split(it) : one(it)).join('')
385
+ const page = (m, p) => '<div class="lo-act-page" data-page="' + p + '" role="group" aria-label="' + esc(m.title) + '" hidden>' +
386
+ '<button type="button" role="menuitem" tabindex="-1" class="lo-act lo-act-back" data-act-back aria-label="' + esc('Back · ' + m.title) + '">' + ACT_BACK + '<span>' + esc(m.title) + '</span></button>' +
387
+ (m.items || []).map((x) => one({ ...x, icon: x.on ? ACT_CHECK : '<span class="ic" aria-hidden="true"></span>' })).join('') + '</div>'
327
388
  menu.innerHTML = (sheet && title ? '<div class="lo-sheet-head"><span>' + esc(title) + '</span><button type="button" class="lo-sheet-done" data-act-close>Done</button></div>' : '') +
328
- items.map((it) => '<button type="button" role="menuitem" tabindex="-1" class="lo-act' + (it.cls ? ' ' + esc(it.cls) : '') + '"' + attrs(it.attrs || {}) + '>' + (it.icon || '') + '<span>' + esc(it.label) + '</span></button>').join('')
389
+ (subs.length ? '<div class="lo-act-page" data-page="0" role="none">' + main + '</div>' + subs.map((m, j) => page(m, j + 1)).join('') : main)
390
+ if (subs.length) menu.classList.add('has-pages')
391
+ if (notes.length) menu.setAttribute('aria-describedby', notes.join(' '))
329
392
  let backdrop = null
330
393
  if (sheet) { backdrop = document.createElement('div'); backdrop.className = 'lo-backdrop'; document.body.appendChild(backdrop) }
331
394
  document.body.appendChild(menu)
332
- act = { anchor, menu, backdrop, items: [...menu.querySelectorAll('[role="menuitem"]')] }
395
+ const page0 = menu.querySelector('.lo-act-page[data-page="0"]')
396
+ act = { anchor, menu, backdrop, page: page0, up: null, items: [...(page0 || menu).querySelectorAll('[role="menuitem"]')] }
333
397
  anchor.setAttribute('aria-haspopup', 'menu')
334
398
  anchor.setAttribute('aria-controls', id)
335
399
  anchor.setAttribute('aria-expanded', 'true')
400
+ const backFocus = () => act && act.menu.querySelector('[data-act-page="' + act.page.dataset.page + '"]')
336
401
  menu.addEventListener('click', (e) => {
337
402
  if (e.target.closest('[data-act-close]')) { closeActionMenu({ focus: true }); return }
403
+ const next = e.target.closest('[data-act-page]')
404
+ if (next) { e.preventDefault(); actPage(Number(next.dataset.actPage)); return }
405
+ if (e.target.closest('[data-act-back]')) { e.preventDefault(); actPage(0, { focus: backFocus() }); return }
338
406
  // the item's own click goes on to the board's handler (the event's path is
339
407
  // already fixed); the menu just gets out of the way
340
408
  if (e.target.closest('[role="menuitem"]')) closeActionMenu()
341
409
  })
342
- menu.addEventListener('mousemove', (e) => { const it = e.target.closest('[role="menuitem"]'); if (it && document.activeElement !== it) it.focus({ preventScroll: true }) })
410
+ menu.addEventListener('mousemove', (e) => { const it = e.target.closest('[role="menuitem"]'); if (it && document.activeElement !== it && act && act.page && act.page.contains(it)) it.focus({ preventScroll: true }) })
343
411
  menu.addEventListener('keydown', (e) => {
344
412
  const k = act ? act.items.indexOf(document.activeElement) : -1
413
+ const onSub = !!(act && act.page && Number(act.page.dataset.page) > 0)
345
414
  switch (e.key) {
346
415
  case 'ArrowDown': e.preventDefault(); actFocus(k + 1); return
347
416
  case 'ArrowUp': e.preventDefault(); actFocus(k < 0 ? -1 : k - 1); return
348
417
  case 'Home': e.preventDefault(); actFocus(0); return
349
418
  case 'End': e.preventDefault(); actFocus(-1); return
350
- case 'Escape': e.preventDefault(); e.stopPropagation(); closeActionMenu({ focus: true }); return
419
+ case 'ArrowRight': {
420
+ const row = document.activeElement && document.activeElement.closest('.lo-act-split')
421
+ if (row) { e.preventDefault(); actPage(Number(row.querySelector('[data-act-page]').dataset.actPage)) }
422
+ return
423
+ }
424
+ case 'ArrowLeft': if (onSub) { e.preventDefault(); actPage(0, { focus: backFocus() }) } return
425
+ case 'Escape': e.preventDefault(); e.stopPropagation(); if (onSub) actPage(0, { focus: backFocus() }); else closeActionMenu({ focus: true }); return
351
426
  case 'Tab': e.preventDefault(); closeActionMenu({ focus: true }); return
352
427
  default:
353
428
  if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && act) {
@@ -7,6 +7,10 @@
7
7
  // your Macs through /_relay/api. The relay passes ciphertext. The live-update
8
8
  // stream becomes a long-poll over the same tunnel. Which Mac: the one you
9
9
  // picked in the status popover, else the most recently active one.
10
+ //
11
+ // 0.9.3: on a Mac that runs Leftoff itself, the same sealed calls go straight
12
+ // to its daemon on 127.0.0.1 (ui/direct.js), and back through the relay the
13
+ // moment that path fails.
10
14
  import { sealRequest, openResponse } from './relay-crypto.js'
11
15
 
12
16
  const meta = typeof document !== 'undefined' && document.querySelector('meta[name="leftoff-relay"]')
@@ -41,6 +45,18 @@ export async function forgetDeviceKey() {
41
45
  // ------------------------------------------------------------ state
42
46
 
43
47
  const relay = { user: RELAY_USER, device: null, key: null, status: null, mac: null, ready: null, latencyMs: null }
48
+ // ui/direct.js loads only on the board itself (never on pair.html, which the
49
+ // relay serves before a browser is paired and where this file is imported too).
50
+ let route = null, directOrRelay = null
51
+ async function loadDirect() {
52
+ try {
53
+ const d = await import('./direct.js')
54
+ route = new d.DirectRoute({ user: RELAY_USER, fetchImpl: realFetch, ua: navigator.userAgent, maxTouchPoints: navigator.maxTouchPoints || 0 })
55
+ directOrRelay = d.directOrRelay
56
+ } catch { route = null }
57
+ }
58
+ const INIT_WAIT_MS = 1200 // how long the first paint waits for this Mac's own answer
59
+ const RELOAD_KEY = 'leftoff.direct.reloaded'
44
60
 
45
61
  function pickedMac() { try { return localStorage.getItem(MAC_KEY) } catch { return null } }
46
62
  function remember(mac) {
@@ -55,9 +71,12 @@ async function refreshStatus() {
55
71
  relay.status = await res.json()
56
72
  relay.latencyMs = Math.round(performance.now() - t0)
57
73
  const online = relay.status.macs.filter((m) => m.online).map((m) => m.machine)
58
- const want = pickedMac()
59
- relay.mac = want && online.includes(want) ? want : relay.status.default
60
- cookieMac(relay.mac)
74
+ // on this Mac's direct path the board stays on this Mac, whatever the relay sees
75
+ if (!onDirect()) {
76
+ const want = pickedMac()
77
+ relay.mac = want && online.includes(want) ? want : relay.status.default
78
+ cookieMac(relay.mac)
79
+ }
61
80
  return relay.status
62
81
  }
63
82
 
@@ -65,8 +84,60 @@ async function init() {
65
84
  const rec = await loadDeviceKey()
66
85
  if (!rec || !rec.key || (rec.user && rec.user !== RELAY_USER)) { needsKey(); throw new Error('no device key') }
67
86
  relay.device = rec.device; relay.key = rec.key
68
- await refreshStatus()
87
+ // Ask this computer's own Leftoff while the relay answers; a quick yes goes direct.
88
+ await loadDirect()
89
+ const probing = probeDirect()
90
+ const [, quick] = await Promise.all([refreshStatus(), Promise.race([probing, new Promise((r) => setTimeout(() => r(undefined), INIT_WAIT_MS))])])
91
+ if (quick) goDirect(quick)
92
+ else if (quick === undefined) probing.then((m) => { if (m) lateDirect(m) })
69
93
  if (!relay.mac) { asleep(); throw new Error('asleep') }
94
+ watchDirect()
95
+ }
96
+
97
+ // ------------------------------------------------------------ direct (0.9.3)
98
+
99
+ function probeDirect() {
100
+ return route ? route.probe({ picked: pickedMac(), verify: verifyDirect }) : Promise.resolve(null)
101
+ }
102
+ // A sealed round trip through this Mac's daemon: only our own Mac can answer it.
103
+ async function verifyDirect(machine) {
104
+ const env = await sealRequest(relay.key, { device: relay.device, machine, body: { m: 'GET', p: '/v1/remote/ping', b: null, h: {} } })
105
+ const out = await openResponse(relay.key, env, await route.send(env))
106
+ return out.s === 200
107
+ }
108
+ function onDirect() { return !!(route && route.on && route.machine === relay.mac) }
109
+ function routeChanged() { try { window.dispatchEvent(new CustomEvent('leftoff-route', { detail: { route: onDirect() ? 'direct' : 'relay', mac: relay.mac } })) } catch {} }
110
+ // A sealed response -> a fetch Response (204/304 carry no body).
111
+ function toResponse(out) { return new Response(out.s === 204 || out.s === 205 || out.s === 304 ? null : out.b, { status: out.s, headers: out.h }) }
112
+ function goDirect(machine) {
113
+ route.use(machine)
114
+ if (relay.mac !== machine) { relay.mac = machine; cookieMac(machine) }
115
+ routeChanged()
116
+ }
117
+ // The first answer came after the board had loaded. Same Mac: just switch.
118
+ // Another Mac (the relay's pick), and you didn't choose it: load once more so
119
+ // the whole board comes from this one (once per tab, so a slow daemon can't loop).
120
+ function lateDirect(machine) {
121
+ if (!route || route.on) return
122
+ if (machine === relay.mac) return goDirect(machine)
123
+ if (pickedMac()) return
124
+ try { if (sessionStorage.getItem(RELOAD_KEY)) return; sessionStorage.setItem(RELOAD_KEY, '1') } catch { return }
125
+ location.reload()
126
+ }
127
+ function dropDirect() { routeChanged() }
128
+ // Back on the relay: look again every minute and when the tab comes back.
129
+ // A later yes only switches the path for the Mac already on screen.
130
+ let watching = false
131
+ function watchDirect() {
132
+ if (watching || !route || route.never) return
133
+ watching = true
134
+ const again = (focus) => {
135
+ if (!route.due({ focus })) return
136
+ probeDirect().then((m) => { if (m && !route.on && m === relay.mac) goDirect(m) })
137
+ }
138
+ setInterval(() => again(false), 15000)
139
+ window.addEventListener('focus', () => again(true))
140
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') again(true) })
70
141
  }
71
142
 
72
143
  // ------------------------------------------------------------ the tunnel
@@ -79,17 +150,38 @@ async function tunnel(url, init = {}, retried = false) {
79
150
  const h = {}
80
151
  const hdrs = new Headers(init.headers || {})
81
152
  if (hdrs.get('content-type')) h['content-type'] = hdrs.get('content-type')
82
- const env = await sealRequest(relay.key, { device: relay.device, machine: relay.mac, body: { m: method, p: url.pathname + url.search, b, h } })
153
+ const body = { m: method, p: url.pathname + url.search, b, h }
154
+ if (!onDirect()) return viaRelay(body, init, retried)
155
+ return directOrRelay({
156
+ route,
157
+ signal: init.signal,
158
+ direct: async () => {
159
+ const env = await sealRequest(relay.key, { device: relay.device, machine: route.machine, body })
160
+ return toResponse(await openResponse(relay.key, env, await route.send(env, { signal: init.signal })))
161
+ },
162
+ relay: () => viaRelay(body, init, retried),
163
+ onDrop: dropDirect,
164
+ })
165
+ }
166
+
167
+ async function viaRelay(body, init, retried) {
168
+ const gone = relay.mac
169
+ const env = await sealRequest(relay.key, { device: relay.device, machine: gone, body })
83
170
  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' })
84
- if (res.status === 200) {
85
- const out = await openResponse(relay.key, env, await res.json())
86
- return new Response(out.b, { status: out.s, headers: out.h })
87
- }
171
+ if (res.status === 200) return toResponse(await openResponse(relay.key, env, await res.json()))
88
172
  const j = await res.clone().json().catch(() => ({}))
89
173
  if (res.status === 401) { notPaired(j.code === 'unknown_device'); return res }
90
174
  if (res.status === 503 && j.code === 'mac_offline' && !retried) {
91
175
  // the Mac you were on went to sleep: another one takes over, or the asleep page
92
- if (j.default && j.default !== relay.mac) { relay.mac = j.default; cookieMac(relay.mac); window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: relay.mac, switched: true } })); return tunnel(url, init, true) }
176
+ // another call already moved the board to the Mac that took over: follow it
177
+ if (relay.mac && relay.mac !== gone) return viaRelay(body, init, true)
178
+ if (j.default && j.default !== gone) { relay.mac = j.default; cookieMac(relay.mac); window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: relay.mac, switched: true } })); return viaRelay(body, init, true) }
179
+ // 0.9.3: the relay can still list a Mac that just went away (its socket
180
+ // closing as we asked, e.g. this Mac's daemon stopping under the direct
181
+ // path): look again in a moment before calling it asleep.
182
+ await new Promise((r) => setTimeout(r, 1500))
183
+ await refreshStatus().catch(() => {})
184
+ if (relay.mac && relay.mac !== gone) { window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: relay.mac, switched: true } })); return viaRelay(body, init, true) }
93
185
  asleep()
94
186
  }
95
187
  return res
@@ -176,6 +268,9 @@ export const leftoffRelay = {
176
268
  get mac() { return relay.mac },
177
269
  get device() { return relay.device },
178
270
  get latencyMs() { return relay.latencyMs },
271
+ // 0.9.3: 'direct' (this Mac's own daemon over 127.0.0.1) or 'relay'
272
+ get route() { return onDirect() ? 'direct' : 'relay' },
273
+ get directLatencyMs() { return route ? route.latencyMs : null },
179
274
  status: () => refreshStatus(),
180
275
  // Switch Macs: the board reloads from the other one.
181
276
  pick(mac) { remember(mac); cookieMac(mac); location.reload() },