@leftoff/cli 0.9.1 → 0.9.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.
package/docs/SECURITY.md CHANGED
@@ -89,6 +89,10 @@ None of this touches the board's data: everything under [What the relay can see]
89
89
 
90
90
  A relayed request is replayed against the Mac's own board from inside the daemon (proved with a secret that never leaves memory) and counts as a **remote device**, with the same rules as a phone over Tailscale: it can read the board; answering prompts and pairing new devices need Settings → Privacy & security → *Trust my paired devices* on that Mac (`remote.allowAnswers`; 403 `trust_off` while it's off); revoking a paired device works from any paired device, itself included, since it only cuts access; Go, moving the folder, Syncthing, turning the relay on or off and the trust switch itself are refused (this Mac's business, loopback only). The daemon still listens only on 127.0.0.1: the relay connection is outbound.
91
91
 
92
+ ### Direct when on the same Mac (0.9.3)
93
+
94
+ When your hosted board is open on a Mac that runs Leftoff, the page sends the same sealed requests straight to that Mac's daemon at `http://127.0.0.1:7429/v1/relay/direct` instead of to the relay. Nothing new is trusted: the daemon opens them with the same per-device key (and the same replay and time checks), runs them the same way (a remote device, the rules above), and seals the answer. The page only switches after the daemon says it's on your account and a sealed round trip through it works, so another Leftoff on that port (someone else's account) can't serve your board: it can't open the request or forge the answer. The door is loopback only, never through a proxy, off when Use from anywhere is off, and answers CORS for exactly one origin, `https://<your name>.leftoff.dev` (with Chrome's Private Network Access header); any other page gets a 403 it can't read. If the direct path fails, the request goes through the relay once more and the board stays on the relay until a later check finds the direct path again.
95
+
92
96
  ### Logging
93
97
 
94
98
  The Worker never logs request or response bodies (it can't read them anyway) and stores only what's listed above, in your Durable Object. `wrangler.toml` turns Workers observability (request logs) off. Cloudflare's edge keeps its usual metadata for any site (IP, hostname, path such as `/_relay/api`, status), under its own policies.
@@ -55,6 +55,9 @@ Each command is one envelope. It's JSON and travels the same way over every tran
55
55
  | `ask.answer` | `askId`, `body` | `engine.answerAsk()`: the board's Allow / Deny / answer code path |
56
56
  | `session.reply` | `sessionId`, `text` | `engine.postReply()`: the board's reply box |
57
57
  | `session.go` | `sessionId` | `engine.jump()`: the board's Go |
58
+ | `session.checkpoint` | `sessionId`, `length?`, `userNote?`, `next?` | `engine.generateCheckpoint()`: Checkpoint now (0.9.2); returns the checkpoint, which the asking board lists at once |
59
+ | `session.patch` | `sessionId`, `title?`, `pinned?`, `archived?` | `engine.patchSession()`: rename, Pin, Archive (0.9.2) |
60
+ | `session.title.auto` | `sessionId` | `engine.regenerateTitle()`: Regenerate title (0.9.2) |
58
61
 
59
62
  Answers are recorded in the session's `answers.md` as *from studio's board (direct)* or *(through the relay)*.
60
63
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
5
5
  "author": {
6
6
  "name": "Joel"
package/mod/hooks/core.js CHANGED
@@ -1354,6 +1354,13 @@ export function parseCheckpointReply(text) {
1354
1354
  return { ...out, ...parseSections(rest) }
1355
1355
  }
1356
1356
 
1357
+ // 0.9.2: a checkpoint written with nothing to go on: no prompt and no reply were
1358
+ // found (the heuristic's placeholder lines). Never saved; old ones are set aside.
1359
+ export function isEmptyCheckpoint(cp) {
1360
+ return !!cp && /^No prompt captured yet\.?$/.test(String(cp.doing || '').trim()) && /^No reply captured yet\.?$/.test(String(cp.stoppedAt || '').trim())
1361
+ }
1362
+ export const NOTHING_TO_CHECKPOINT = 'Nothing to checkpoint yet — no messages found for this session'
1363
+
1357
1364
  // No model available: build a checkpoint from the last prompt and the last reply.
1358
1365
  // Informative / long add what can be read off the transcript without a model:
1359
1366
  // the files edited (with the tool), the failed tool calls, the questions asked.
@@ -14,7 +14,7 @@
14
14
  // each event is registered once per matcher.
15
15
  import {
16
16
  DEFAULT_CONFIG, DEFAULT_PORT, mergeConfig, resolveMachine, applyEvent, checkpointDue, buildCheckpointPrompt,
17
- parseCheckpointReply, heuristicCheckpoint, renderCheckpointMd, checkpointId, checkpointFileName,
17
+ parseCheckpointReply, heuristicCheckpoint, isEmptyCheckpoint, NOTHING_TO_CHECKPOINT, renderCheckpointMd, checkpointId, checkpointFileName,
18
18
  sessionFolderName, id8, clip, toIso, resumeCommand, repoName,
19
19
  shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
20
20
  parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
28
28
  const MODEL_TIMEOUT_MS = 20000
29
29
 
30
30
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
31
- const MOD_VERSION = '0.9.1' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.3' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
@@ -652,6 +652,8 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
652
652
  parsed = heuristicCheckpoint(messages, record, { length: ctx.length })
653
653
  generator = { by: 'heuristic' }
654
654
  }
655
+ // 0.9.2: nothing to go on (no prompt, no reply): don't save a placeholder over the last real one
656
+ if (isEmptyCheckpoint(parsed)) { if (trigger === 'manual') $.ui.toast(NOTHING_TO_CHECKPOINT, { timeoutMs: 6000 }); return null }
655
657
  if (opts.next) parsed = { ...parsed, next: opts.next }
656
658
  const cp = {
657
659
  sessionId: st.sessionId, createdAt: nowIso(), trigger, machine: st.machine,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
5
5
  "keywords": [
6
6
  "claude",
package/ui/app.css CHANGED
@@ -1775,3 +1775,32 @@ html.dv-open .lo-menu { z-index: 62; }
1775
1775
  .pr-more-sess > summary { list-style: none; cursor: pointer; display: inline; }
1776
1776
  .pr-more-sess > summary::-webkit-details-marker { display: none; }
1777
1777
  .pr-more-sess[open] > summary { display: none; }
1778
+
1779
+ /* 0.9.2: the ⋯ menu's sub lines, its plain note (a Mac that can't be reached),
1780
+ the row of other checkpoint lengths, and the split "Checkpoint now" button. */
1781
+ .lo-act.has-sub { align-items: flex-start; white-space: normal; }
1782
+ .lo-act.has-sub .ic { margin-top: 2px; }
1783
+ .lo-act-txt { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
1784
+ .lo-act-sub { font-size: 12.5px; color: var(--muted); font-weight: 400; line-height: 1.35; }
1785
+ .lo-act-note { display: flex; align-items: flex-start; gap: 10px; padding: 8px 10px 10px; margin-bottom: 4px; border-bottom: 1px solid var(--line); color: var(--text2); font-size: 13.5px; line-height: 1.4; white-space: normal; }
1786
+ .lo-act-note .ic { width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--muted); }
1787
+ .lo-act-row { display: flex; align-items: center; gap: 6px; padding: 2px 10px 6px 36px; }
1788
+ .lo-act-rowlabel { font-size: 12.5px; color: var(--muted); margin-right: 2px; }
1789
+ .lo-act.alt { width: auto; flex: 0 0 auto; padding: 5px 12px; border: 1px solid var(--line-strong); border-radius: 999px; font-size: 13px; color: var(--text2); }
1790
+ .lo-menu.sheet .lo-act-note { font-size: 15px; padding: 10px 10px 12px; }
1791
+ .lo-menu.sheet .lo-act-sub { font-size: 14px; }
1792
+ .lo-menu.sheet .lo-act-row { padding: 0 10px 10px 36px; gap: 8px; }
1793
+ .lo-menu.sheet .lo-act.alt { flex: 1 1 0; justify-content: center; padding: 11px 12px; font-size: 16px; min-height: 44px; }
1794
+ .split { display: inline-flex; align-items: stretch; }
1795
+ .split .btn.split-main { border-top-right-radius: 0; border-bottom-right-radius: 0; }
1796
+ .split .btn.split-more { border-top-left-radius: 0; border-bottom-left-radius: 0; padding: 0 8px; margin-left: 1px; display: inline-flex; align-items: center; }
1797
+ .split .btn.split-more .ic { width: 15px; height: 15px; margin: 0; }
1798
+ @media (pointer: coarse) { .split .btn.split-more { min-width: 40px; justify-content: center; } }
1799
+ .more-wrap { display: inline-flex; }
1800
+ /* 0.9.2: the popover's Theme control never wraps: it gets its own row under
1801
+ the label, three equal segments, whatever the popover's width. */
1802
+ .statuspop .sp-theme { flex-wrap: wrap; row-gap: 6px; }
1803
+ .statuspop .sp-theme .sp-k { width: 100%; }
1804
+ .statuspop .sp-theme .theme { flex: 1 1 100%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); flex-wrap: nowrap; }
1805
+ .statuspop .sp-theme .theme button { justify-content: center; white-space: nowrap; min-width: 0; height: 30px; padding: 0 6px; }
1806
+ @media (pointer: coarse) { .statuspop .sp-theme .theme button { height: 36px; font-size: 13px; } }
package/ui/app.js CHANGED
@@ -370,11 +370,12 @@ function goButton(s) {
370
370
  // Another machine's session (v0.5.2): Go runs there, through its Leftoff, when it allows it.
371
371
  if (!s.isLocal) {
372
372
  if (!j.peer) return ''
373
- const label = 'Go on ' + s.machine + ' ↗'
374
- if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled title="Go only works from a Mac’s own browser">' + esc(label) + '</button>'
375
- if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled title="' + esc(j.reason || '') + '">' + esc(label) + '</button><span class="why">' + esc(peerWhy(j)) + '</span></span>'
373
+ // 0.9.2: "Go ↗" like a local card (a phone cut "Go on <machine>" short); the machine is in its title and label
374
+ const label = 'Go ↗', on = 'Go on ' + s.machine
375
+ if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled aria-label="' + esc(on) + '" title="' + esc(on + ': Go only works from a Mac’s own browser') + '">' + esc(label) + '</button>'
376
+ if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '" title="' + esc(j.reason || '') + '">' + esc(label) + '</button><span class="why">' + esc(peerWhy(j)) + '</span></span>'
376
377
  const st = goState.get(s.id)
377
- return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" title="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
378
+ return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" aria-label="' + esc(on) + '" title="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
378
379
  }
379
380
  if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled title="Go switches tabs on the machine running Leftoff, so it only works from that machine’s own browser (localhost)">Go ↗</button>'
380
381
  if (!j.supported) return '<button type="button" class="btn go" disabled title="' + esc('Can’t jump: ' + (j.reason || 'unsupported terminal')) + '">Go ↗</button>'
@@ -419,35 +420,105 @@ function copyResumeIcon(s) {
419
420
  return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="Copy resume command">' + icon('copy') + '</button>'
420
421
  }
421
422
  // items: [{ action, label, icon, title, attrs }]
422
- function moreMenu(s, items) {
423
- items = items.concat([projectMenuItem(s)]).filter(Boolean) // 0.9.0: Add to project… on every card with a repo
423
+ // 0.9.2: an item may also be { note } (a plain line, no button) or { row: [items], label }
424
+ // (small buttons side by side, e.g. the other checkpoint lengths), and carry a sub line.
425
+ function moreMenu(s, items, { project = true, title = '' } = {}) {
426
+ items = items.concat([project ? projectMenuItem(s) : null]).filter(Boolean) // 0.9.0: Add to project… on every card with a repo
424
427
  if (!items.length) return ''
425
- return '<button type="button" class="btn icon-btn more" data-action="card-more" data-id="' + esc(s.id) + '" aria-label="More actions" title="More actions" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' +
426
- '<template class="more-items">' + items.map((it) => '<button type="button" data-action="' + esc(it.action) + '" data-id="' + esc(s.id) + '"' +
427
- Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + '>' +
428
- (it.icon ? icon(it.icon) : '') + esc(it.label) + '</button>').join('') + '</template>'
428
+ const btn = (it) => '<button type="button" data-action="' + esc(it.action) + '" data-id="' + esc(s.id) + '"' +
429
+ Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + (it.sub ? ' data-sub="' + esc(it.sub) + '"' : '') + '>' +
430
+ (it.icon ? icon(it.icon) : '') + esc(it.label) + '</button>'
431
+ return '<button type="button" class="btn icon-btn more" data-action="card-more" data-id="' + esc(s.id) + '"' + (title ? ' data-menu-title="' + esc(title) + '"' : '') + ' aria-label="More actions" title="More actions" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' +
432
+ '<template class="more-items">' + items.map((it) => it.note ? '<div class="more-note">' + (it.icon ? icon(it.icon) : '') + esc(it.note) + '</div>'
433
+ : it.row ? '<div class="more-row" data-label="' + esc(it.label || '') + '">' + it.row.map(btn).join('') + '</div>' : btn(it)).join('') + '</template>'
429
434
  }
430
435
  function openCardMenu(btn) {
431
436
  const tpl = btn.parentElement && btn.parentElement.querySelector('template.more-items')
432
437
  if (!tpl) return
433
438
  const card = btn.closest('.card')
434
- const title = card && card.querySelector('.title a') ? card.querySelector('.title a').textContent.trim() : ''
435
- const items = [...tpl.content.querySelectorAll('button')].map((b) => {
439
+ const title = btn.dataset.menuTitle || (card && card.querySelector('.title a') ? card.querySelector('.title a').textContent.trim() : '')
440
+ const toItem = (b) => {
436
441
  const svg = b.querySelector('svg')
437
442
  const attrs = {}
438
- for (const a of b.attributes) if (a.name.startsWith('data-') || a.name === 'title') attrs[a.name] = a.value
439
- return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs }
440
- })
443
+ for (const a of b.attributes) if ((a.name.startsWith('data-') && a.name !== 'data-sub') || a.name === 'title') attrs[a.name] = a.value
444
+ return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs, ...(b.dataset.sub ? { sub: b.dataset.sub } : {}) }
445
+ }
446
+ const items = [...tpl.content.children].map((el) => {
447
+ if (el.classList.contains('more-note')) { const svg = el.querySelector('svg'); return { note: el.textContent.trim(), icon: svg ? svg.outerHTML : '' } }
448
+ if (el.classList.contains('more-row')) return { row: [...el.querySelectorAll('button')].map(toItem), label: el.dataset.label || '' }
449
+ return el.tagName === 'BUTTON' ? toItem(el) : null
450
+ }).filter(Boolean)
441
451
  lastMenuAnchor = btn
442
452
  openActionMenu(btn, items, { title })
443
453
  }
444
- const sessionMenuItems = (s) => s.isLocal ? [
445
- { action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus' },
454
+ // 0.9.2: what this board can do with a session: 'local'; 'peer', through the
455
+ // Mac it lives on (the same path as answering and Go); 'away', when that Mac
456
+ // can't be reached from here. Agent records are read-only (null).
457
+ function reachOf(s) {
458
+ if (!s || s.agent || /^agent:/.test(String(s.id || ''))) return null
459
+ if (s.isLocal) return 'local'
460
+ const p = s.peer || {}
461
+ return p.ok || p.code === 'checking' ? 'peer' : 'away'
462
+ }
463
+ // The length "Checkpoint now" writes: yours from Settings → Summaries.
464
+ function defaultLength() { const k = state.cfg && state.cfg.summary && state.cfg.summary.length; return LENGTH_CHOICES.some((o) => o.key === k) ? k : 'informative' }
465
+ const lengthLabel = (k) => (LENGTH_CHOICES.find((o) => o.key === k) || LENGTH_CHOICES[1]).label
466
+ // Checkpoint now at your length, then the other two lengths side by side.
467
+ function checkpointItems() {
468
+ const def = defaultLength()
469
+ return [
470
+ { action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus', sub: lengthLabel(def) + ' · your default', attrs: { 'data-length': def } },
471
+ { row: LENGTH_CHOICES.filter((o) => o.key !== def).map((o) => ({ action: 'checkpoint', label: o.label, title: o.label + ' checkpoint: ' + o.blurb, attrs: { 'data-length': o.key } })), label: 'Or' },
472
+ ]
473
+ }
474
+ // The ⋯ menu when the Mac a session lives on can't be reached: why, in plain
475
+ // words, and what you can do (switch the board to that Mac, or check again).
476
+ function awayNote(s) {
477
+ const m = s.machine || 'another Mac', c = s.peer && s.peer.code
478
+ return c === 'not_allowed' ? 'Lives on ' + m + ', which doesn’t take actions from other Macs (Settings → Privacy & security on ' + m + ')'
479
+ : c === 'key_changed' ? 'Lives on ' + m + ', whose key changed; confirm it in Settings → Privacy & security'
480
+ : c === 'no_key' ? 'Lives on ' + m + ', which needs a newer Leftoff for this'
481
+ : c === 'off' ? 'Lives on ' + m + '; actions on other Macs are off here'
482
+ : asleepOnRelay(m) ? 'Lives on ' + m + ', which is asleep right now'
483
+ : 'Lives on ' + m + ', which isn’t reachable right now'
484
+ }
485
+ function asleepOnRelay(m) { const st = window.leftoffRelay && state.relayStatus; const x = st && (st.macs || []).find((y) => y.machine === m); return !!(x && !x.online) }
486
+ function awayMenuItems(s) {
487
+ const m = s.machine
488
+ const canSwitch = !!window.leftoffRelay && m && m !== window.leftoffRelay.mac && !asleepOnRelay(m)
489
+ return [
490
+ { note: awayNote(s), icon: 'wifi-off' },
491
+ canSwitch ? { action: 'relay-mac', label: 'Switch to ' + m + '’s board', icon: 'monitor', attrs: { 'data-mac': m } } : null,
492
+ { action: 'peer-recheck', label: 'Check again', icon: 'refresh-cw', attrs: { 'data-machine': m || '' } },
493
+ ].filter(Boolean)
494
+ }
495
+ const sessionMenuItems = (s) => { const r = reachOf(s); return r === 'local' || r === 'peer' ? [
496
+ ...checkpointItems(),
446
497
  { action: 'pin', label: s.pinned ? 'Unpin' : 'Pin', icon: s.pinned ? 'pin-off' : 'pin' },
447
498
  { action: 'title-auto', label: 'Regenerate title', icon: 'refresh-cw', title: 'A new title from the latest messages (like /cp-name --auto)' },
448
499
  { action: 'archive', label: 'Archive', icon: 'archive' },
449
- ] : []
450
- const roNote = (t) => '<span class="ro" title="' + esc(t) + '">' + esc(t) + '</span>'
500
+ ] : r === 'away' ? awayMenuItems(s) : [] }
501
+ // 0.9.2: "Checkpoint now" on a session's page: the main part writes your
502
+ // length; the chevron opens Short / Informative / Long (our menu, a sheet on a phone).
503
+ function checkpointSplit(s) {
504
+ const def = defaultLength()
505
+ return '<span class="split" role="group" aria-label="Checkpoint now">' +
506
+ '<button type="button" class="btn primary split-main" data-action="checkpoint" data-id="' + esc(s.id) + '" data-length="' + esc(def) + '" title="' + esc('Checkpoint now · ' + lengthLabel(def) + (s.isLocal ? '' : ' · written on ' + s.machine)) + '">Checkpoint now</button>' +
507
+ '<button type="button" class="btn primary split-more" data-action="cp-lengths" data-id="' + esc(s.id) + '" aria-label="Checkpoint length" title="Choose the length" aria-haspopup="menu" aria-expanded="false">' + icon('chevron-down') + '</button></span>'
508
+ }
509
+ function openLengthMenu(btn, id) {
510
+ const def = defaultLength()
511
+ lastMenuAnchor = btn
512
+ openActionMenu(btn, LENGTH_CHOICES.map((o) => ({ label: o.label + (o.key === def ? ' · your default' : ''), sub: o.blurb, icon: o.key === def ? icon('check') : icon('bookmark-plus'), cls: o.key === def ? 'on' : '',
513
+ attrs: { 'data-action': 'checkpoint', 'data-id': id, 'data-length': o.key } })), { title: 'Checkpoint now' })
514
+ }
515
+ const roNote = (t, tip) => '<span class="ro" title="' + esc(tip || t) + '">' + esc(t) + '</span>'
516
+ // 0.9.2: a short line on another Mac's card (the full story is its title and the ⋯ menu)
517
+ function remoteNote(s) {
518
+ if (reachOf(s) === 'peer') return roNote('via ' + s.machine, 'Lives on ' + s.machine + ' · checkpoints, answering and Go work through it')
519
+ if (/class="go-off"/.test(goButton(s))) return '' // the grey Go already says why
520
+ return roNote(s.machine + ' unreachable', awayNote(s) + ' · read-only here')
521
+ }
451
522
 
452
523
  function card(s) {
453
524
  if (s.agent) return agentCard(s)
@@ -465,7 +536,7 @@ function card(s) {
465
536
  '<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
466
537
  '<div class="actions-row">' + (goButton(s) || (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '')) +
467
538
  copyResumeIcon(s) +
468
- (s.isLocal ? moreMenu(s, sessionMenuItems(s)) : roNote(s.peer && s.peer.ok ? 'lives on ' + s.machine + ' · answering and Go work through it' : 'read-only here · lives on ' + s.machine)) +
539
+ (s.isLocal ? '' : remoteNote(s)) + moreMenu(s, sessionMenuItems(s)) +
469
540
  '</div></article>'
470
541
  }
471
542
 
@@ -1102,11 +1173,11 @@ async function viewSession(id) {
1102
1173
  const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
1103
1174
  let html = '<a class="back" href="#/">← All sessions</a>'
1104
1175
  // 0.8.9: rename in place: tap the title or the pencil (this Mac's sessions; like /cp-name, your name wins over automatic ones)
1105
- const canRename = s.isLocal !== false && !s.agent && !/^agent:/.test(String(s.id || ''))
1176
+ const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
1106
1177
  html += '<div class="pagehead"><div>' + (canRename
1107
1178
  ? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" title="Rename">' + esc(s.title || 'Untitled session') + '</span></h1><button type="button" class="icon rename-pen" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" title="Rename">' + icon('pencil') + '</button></div>'
1108
1179
  : '<h1>' + esc(s.title || 'Untitled session') + '</h1>') + '<div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + projChip(s) + wtChips(s) + (canProject(s) ? '<button type="button" class="chip proj-add" data-action="project-menu" data-key="' + esc(s.repoKey) + '" data-repo="' + esc(s.repoLabel || s.repoName || '') + '" title="' + esc(s.repoProject ? 'Move ' + (s.repoLabel || 'this repo') + ' to another project, or out of it' : 'Add ' + (s.repoLabel || 'this repo') + ' to a project') + '">' + (s.repoProject ? 'Change project' : '+ Project') + '</button>' : '') + ' &nbsp;' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
1109
- '<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (s.isLocal ? '<button type="button" class="btn primary" data-action="checkpoint" data-id="' + esc(s.id) + '">Checkpoint now</button>' : '') + '</div></div>'
1180
+ '<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
1110
1181
  html += '<div class="facts">' +
1111
1182
  fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
1112
1183
  fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id) +
@@ -1135,8 +1206,10 @@ async function viewSession(id) {
1135
1206
  if (!s.checkpoints.length) html += '<div class="empty">No checkpoints yet.</div>'
1136
1207
  else html += '<div class="timeline">' + cpTimeline(s.checkpoints.filter((cp) => !cp.doubt), newestCp) + '</div>'
1137
1208
  // 0.9.0: checkpoints written while Leftoff had another session's branch for this one: kept, folded away
1138
- const doubted = s.checkpoints.filter((cp) => cp.doubt)
1209
+ const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty')
1139
1210
  if (doubted.length) html += '<details class="tl-doubt"><summary>' + doubted.length + ' checkpoint' + (doubted.length === 1 ? '' : 's') + ' set aside: written with another session’s branch</summary><div class="timeline">' + cpTimeline(doubted, null) + '</div></details>'
1211
+ const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
1212
+ if (empties.length) html += '<details class="tl-doubt"><summary>' + empties.length + ' empty checkpoint' + (empties.length === 1 ? '' : 's') + ' set aside: no messages were found when ' + (empties.length === 1 ? 'it was' : 'they were') + ' written</summary><div class="timeline">' + cpTimeline(empties, null) + '</div></details>'
1140
1213
  html += answeredList(s)
1141
1214
  $app.innerHTML = html
1142
1215
  }
@@ -2960,19 +3033,27 @@ function paintStatus() {
2960
3033
  const pref = window.cpTheme ? window.cpTheme.pref() : 'system'
2961
3034
  const row = (k, v, cls) => '<div class="sp-row"><span class="sp-k">' + esc(k) + '</span><span class="sp-v' + (cls ? ' ' + cls : '') + '">' + v + '</span></div>'
2962
3035
  const syncWarn = !!settingsDots().sync
2963
- pop.innerHTML = '<div class="sp-head"><span class="dot ' + (h ? 'up' : 'down') + '"></span><b>' + esc(h ? h.machine : 'Leftoff') + '</b><span class="faint">' + (h ? (window.leftoffRelay ? 'through the relay' : 'this Mac') : 'offline') + '</span></div>' +
3036
+ pop.innerHTML = '<div class="sp-head"><span class="dot ' + (h ? 'up' : 'down') + '"></span><b>' + esc(h ? h.machine : 'Leftoff') + '</b><span class="faint sp-route">' + (h ? routeLabel() : 'offline') + '</span></div>' +
2964
3037
  (h ? row('Sessions', esc(h.sessionsOpen + ' open') + ' <span class="faint">of ' + esc(h.sessionsTotal) + '</span>') +
2965
3038
  (h.needsYou ? row('Needs you', '<a href="#/waiting">' + esc(h.needsYou) + ' waiting</a>') : '') +
2966
3039
  row('Summaries', esc(h.summarizer + (h.summarizerDetail && h.summarizerDetail.model ? ' · ' + h.summarizerDetail.model : '')) + ' <a href="#/settings/models">Change</a>') +
2967
3040
  relayRows(row) +
2968
3041
  ((h.machines || []).length > 1 && !window.leftoffRelay ? row('Macs', esc(h.machines.join(', '))) : '') +
2969
3042
  row('Sync', '<a href="#/settings/sync">' + esc(syncLine()) + '</a>', syncWarn ? 'warnish' : '') +
2970
- row('Version', esc('v' + h.version) + (h.update && h.update.available ? ' · <a href="#/settings/updates">v' + esc(h.update.remoteVersion || '?') + ' available</a>' : ' · <a href="#/whats-new">What’s new</a>'))
3043
+ row('Version', esc('v' + h.version) + (h.update && h.update.available ? ' · <a href="#/settings/updates">v' + esc(h.update.remoteVersion || '?') + ' available</a>' : '')) // 0.9.2: What’s new is the button below
2971
3044
  : '<p class="faint">The daemon isn’t answering.</p>') +
2972
3045
  '<div class="sp-row sp-theme"><span class="sp-k">Theme</span><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div></div>' +
2973
3046
  '<div class="sp-links"><a href="#/settings">' + icon('settings') + 'Settings</a><a href="#/whats-new">' + icon('sparkles') + 'What’s new</a></div>'
2974
3047
  placeStatus()
2975
3048
  }
3049
+ // 0.9.3: the quiet marker for how this board reaches its Mac. On your hosted
3050
+ // board: "direct · this Mac" (its own daemon over 127.0.0.1) or "via relay".
3051
+ function routeLabel() {
3052
+ const lr = window.leftoffRelay
3053
+ if (!lr) return 'this Mac'
3054
+ return lr.route === 'direct' ? 'direct · this Mac' : 'via relay'
3055
+ }
3056
+ window.addEventListener('leftoff-route', () => { if (statusOpen()) paintStatus() })
2976
3057
  // Through the relay (v0.6.0): which Mac this board comes from, and a switcher.
2977
3058
  function relayRows(row) {
2978
3059
  const lr = window.leftoffRelay
@@ -3055,7 +3136,11 @@ function paletteActions() {
3055
3136
  const sid = state.route && state.route.name === 's' ? state.route.arg : null
3056
3137
  const s = sid ? (state.sessions || []).find((x) => x.id === sid) : null
3057
3138
  if (s) {
3058
- if (s.isLocal && !s.agent) out.push({ action: 'checkpoint', id: s.id, title: 'Checkpoint now', sub: s.title || 'this session', icon: 'bookmark-plus' })
3139
+ if (reachOf(s) === 'local' || reachOf(s) === 'peer') { // 0.9.2: any length, and through the Mac it lives on
3140
+ const def = defaultLength()
3141
+ out.push({ action: 'checkpoint', id: s.id, length: def, title: 'Checkpoint now', sub: (s.title || 'this session') + ' · ' + lengthLabel(def), icon: 'bookmark-plus' })
3142
+ for (const o of LENGTH_CHOICES) if (o.key !== def) out.push({ action: 'checkpoint', id: s.id, length: o.key, title: 'Checkpoint now (' + o.label.toLowerCase() + ')', sub: o.blurb, icon: 'bookmark-plus' })
3143
+ }
3059
3144
  if (!s.agent) out.push({ action: 'resume', id: s.id, title: 'Copy resume command', sub: s.title || 'this session', icon: 'copy' })
3060
3145
  if (canProject(s)) out.push({ action: 'project-menu', key: s.repoKey, repo: s.repoLabel || '', title: s.repoProject ? 'Change project…' : 'Add to project…', sub: s.repoLabel || '', icon: 'folder' })
3061
3146
  }
@@ -3097,6 +3182,7 @@ function runPaletteItem(it) {
3097
3182
  if (it.id) b.dataset.id = it.id
3098
3183
  if (it.key) b.dataset.key = it.key
3099
3184
  if (it.repo) b.dataset.repo = it.repo
3185
+ if (it.length) b.dataset.length = it.length
3100
3186
  b.className = 'palette-proxy'
3101
3187
  const anchor = document.getElementById('searchBtn')
3102
3188
  if (anchor) { lastMenuAnchor = anchor }
@@ -3542,10 +3628,27 @@ document.addEventListener('click', async (ev) => {
3542
3628
  const cmd = s ? s.resumeCommand : (await api('GET', '/v1/sessions/' + encodeURIComponent(id))).resumeCommand
3543
3629
  if (await copy(cmd) && btn.classList.contains('copy-resume')) copiedMark(btn)
3544
3630
  } else if (action === 'checkpoint') {
3631
+ // 0.9.2: any length (data-length), and another Mac's session too: its Leftoff writes it
3632
+ const length = btn.dataset.length || null
3633
+ const away = s && !s.isLocal ? s.machine : null
3634
+ const was = btn.innerHTML
3545
3635
  btn.disabled = true; btn.textContent = 'Writing…'
3546
- if (!btn.isConnected || btn.classList.contains('palette-proxy')) toast('Writing a checkpoint…', 20000) // from the ⋯ menu (closed) or ⌘K
3547
- const cp = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true })
3548
- toast('Checkpoint saved · Next: ' + cp.next)
3636
+ if (!btn.isConnected || btn.classList.contains('palette-proxy') || !btn.closest('.split')) toast('Writing ' + (length ? lengthLabel(length).toLowerCase() + ' ' : 'a ') + 'checkpoint' + (away ? ' on ' + away : '') + '…', 40000) // from a menu (closed) or ⌘K
3637
+ let cp
3638
+ try { cp = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true, ...(length ? { length } : {}) }) }
3639
+ catch (e) { toast(e.message, 6000); if (btn.isConnected) { btn.disabled = false; btn.innerHTML = was } return }
3640
+ toast('Checkpoint saved' + (cp.peer ? ' on ' + cp.peer : '') + ' · Next: ' + cp.next)
3641
+ render()
3642
+ } else if (action === 'cp-lengths') {
3643
+ ev.preventDefault(); return openLengthMenu(btn, id)
3644
+ } else if (action === 'peer-recheck') {
3645
+ const m = btn.dataset.machine
3646
+ toast('Checking ' + (m || 'the other Mac') + '…', 8000)
3647
+ try {
3648
+ const o = await api('POST', '/v1/peers/check', {})
3649
+ const p = (o.peers || []).find((x) => x.name === m || x.machine === m)
3650
+ toast(p && p.usable && p.usable.ok ? m + ' is reachable again' : 'Still can’t reach ' + (m || 'it') + (p && p.usable && p.usable.reason ? ': ' + p.usable.reason : ''), 6000)
3651
+ } catch (e) { toast(e.message, 5000) }
3549
3652
  render()
3550
3653
  } else if (action === 'pin') {
3551
3654
  await api('PATCH', '/v1/sessions/' + encodeURIComponent(id), { pinned: !(s && s.pinned) }); render()
package/ui/direct.js ADDED
@@ -0,0 +1,114 @@
1
+ // 0.9.3: "direct when on the same Mac". The hosted board (<user>.leftoff.dev)
2
+ // open on a Mac that runs Leftoff asks that Mac's own daemon over
3
+ // http://127.0.0.1:7429 instead of through the relay; the address bar stays.
4
+ //
5
+ // The request is the very envelope the relay would carry (ui/relay-crypto.js),
6
+ // posted to /v1/relay/direct (src/direct.js) instead of /_relay/api. So going
7
+ // direct needs no new trust: the daemon opens it with this browser's paired
8
+ // key, and the board only believes an answer that opens with the same key.
9
+ // Before it switches, the board checks: the daemon says it is on this account
10
+ // (user) and a Mac you didn't steer away from (picked), and a sealed round
11
+ // trip through it works (verify). A stranger's Leftoff on the port fails all
12
+ // of it, and the browser refuses it anyway (CORS is for this Mac's own name).
13
+ //
14
+ // When anything about the direct path fails (the daemon stopped, Safari
15
+ // refusing http://127.0.0.1 from an https page, a Local Network Access
16
+ // "Block"), the call goes again through the relay once, quietly, and the board
17
+ // stays on the relay until a later probe (every minute, or when the tab comes
18
+ // back) finds the direct path again. Plain logic, no DOM: unit tested.
19
+ import { LOCAL_PORT, isPhoneUa } from './viewer.js'
20
+
21
+ export const DIRECT_PATH = '/v1/relay/direct'
22
+ export const PROBE_EVERY_MS = 60000
23
+ export const FOCUS_GAP_MS = 10000
24
+ export function directUrl(port = LOCAL_PORT) { return 'http://127.0.0.1:' + port + DIRECT_PATH }
25
+
26
+ export class DirectError extends Error { constructor(code, message) { super(message || code); this.code = code } }
27
+
28
+ // What the local daemon said -> its machine when it may serve this board, else null.
29
+ export function directMatch(info, { user, picked } = {}) {
30
+ if (!info || info.app !== 'leftoff' || !info.direct || typeof info.machine !== 'string' || !info.machine) return null
31
+ if (!user || String(info.user || '').toLowerCase() !== String(user).toLowerCase()) return null
32
+ if (picked && picked !== info.machine) return null
33
+ return info.machine
34
+ }
35
+
36
+ // A sealed request's body may only be read once; a fallback needs it again.
37
+ export function aborted(signal) { return !!(signal && signal.aborted) }
38
+
39
+ export class DirectRoute {
40
+ constructor({ user, port = LOCAL_PORT, fetchImpl, now = () => Date.now(), ua = '', maxTouchPoints = 0, every = PROBE_EVERY_MS, focusGap = FOCUS_GAP_MS } = {}) {
41
+ this.user = user; this.port = port; this.fetchImpl = fetchImpl; this.now = now
42
+ // a phone or tablet never runs Leftoff: don't ask (and don't trip a permission prompt)
43
+ this.never = isPhoneUa(ua, { maxTouchPoints })
44
+ this.every = every; this.focusGap = focusGap
45
+ this.on = false; this.machine = null; this.checkedAt = 0; this.inflight = null; this.lastError = null; this.latencyMs = null
46
+ }
47
+ get url() { return directUrl(this.port) }
48
+
49
+ // -> the machine (direct confirmed, not yet switched on) or null. Never throws.
50
+ probe({ picked = null, verify = null, timeoutMs = 1500 } = {}) {
51
+ if (this.never) return Promise.resolve(null)
52
+ if (this.inflight) return this.inflight
53
+ this.inflight = (async () => {
54
+ this.checkedAt = this.now()
55
+ const t0 = this.now()
56
+ let info
57
+ try {
58
+ const r = await this.fetchImpl(this.url, { mode: 'cors', credentials: 'omit', cache: 'no-store', signal: timeoutSignal(timeoutMs) })
59
+ if (r.status !== 200) return null
60
+ info = await r.json()
61
+ } catch { return null }
62
+ const m = directMatch(info, { user: this.user, picked })
63
+ if (!m) return null
64
+ if (verify) { let ok = false; try { ok = await verify(m) } catch { ok = false } if (!ok) return null }
65
+ this.latencyMs = Math.max(0, Math.round(this.now() - t0))
66
+ return m
67
+ })().finally(() => { this.inflight = null })
68
+ return this.inflight
69
+ }
70
+ use(machine) { this.on = true; this.machine = machine; this.lastError = null }
71
+ drop(err) { this.on = false; this.lastError = err ? (err.code || err.message || String(err)) : null; this.checkedAt = this.now() }
72
+ // time for another look? (focus: sooner, but not on every click into the window)
73
+ due({ focus = false } = {}) {
74
+ if (this.never || this.on || this.inflight) return false
75
+ return this.now() - this.checkedAt >= (focus ? this.focusGap : this.every)
76
+ }
77
+
78
+ // POST the sealed envelope; -> the sealed response envelope. Throws DirectError.
79
+ async send(env, { signal } = {}) {
80
+ let r
81
+ try {
82
+ r = await this.fetchImpl(this.url, { method: 'POST', mode: 'cors', credentials: 'omit', cache: 'no-store', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ env }), signal })
83
+ } catch (e) { if (aborted(signal)) throw e; throw new DirectError('unreachable', e && e.message) }
84
+ if (r.status !== 200) throw new DirectError('http_' + r.status)
85
+ let j
86
+ try { j = await r.json() } catch { throw new DirectError('bad_response') }
87
+ if (!j || !j.env) throw new DirectError('bad_response')
88
+ return j.env
89
+ }
90
+ }
91
+
92
+ // One call: direct when it's on, else (or when direct fails) the relay, once.
93
+ // A call the page itself aborted is not retried.
94
+ export async function directOrRelay({ route, direct, relay, signal, onDrop = () => {} }) {
95
+ if (route && route.on) {
96
+ try { return await direct() } catch (e) {
97
+ if (aborted(signal)) throw e
98
+ route.drop(e)
99
+ try { onDrop(e) } catch {}
100
+ }
101
+ }
102
+ return relay()
103
+ }
104
+
105
+ // A sealed response -> a fetch Response (204/304 carry no body).
106
+ export function toResponse(out) {
107
+ const nullBody = out.s === 204 || out.s === 205 || out.s === 304
108
+ return new Response(nullBody ? null : out.b, { status: out.s, headers: out.h })
109
+ }
110
+
111
+ function timeoutSignal(ms) {
112
+ if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) return AbortSignal.timeout(ms)
113
+ const c = new AbortController(); setTimeout(() => c.abort(), ms); return c.signal
114
+ }
package/ui/icons.js CHANGED
@@ -31,6 +31,9 @@ export const ICONS = {
31
31
  "shield": "<path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\"/>",
32
32
  "info": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M12 16v-4\"/> <path d=\"M12 8h.01\"/>",
33
33
  "chevron-right": "<path d=\"m9 18 6-6-6-6\"/>",
34
+ "chevron-down": "<path d=\"m6 9 6 6 6-6\"/>",
35
+ "wifi-off": "<path d=\"M12 20h.01\"/> <path d=\"M8.5 16.429a5 5 0 0 1 7 0\"/> <path d=\"M5 12.859a10 10 0 0 1 5.17-2.69\"/> <path d=\"M19 12.859a10 10 0 0 0-2.007-1.523\"/> <path d=\"M2 8.82a15 15 0 0 1 4.177-2.643\"/> <path d=\"M22 8.82a15 15 0 0 0-11.288-3.764\"/> <path d=\"m2 2 20 20\"/>",
36
+ "monitor": "<rect width=\"20\" height=\"14\" x=\"2\" y=\"3\" rx=\"2\"/> <line x1=\"8\" x2=\"16\" y1=\"21\" y2=\"21\"/> <line x1=\"12\" x2=\"12\" y1=\"17\" y2=\"21\"/>",
34
37
  "chevron-left": "<path d=\"m15 18-6-6 6-6\"/>",
35
38
  "mail": "<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"/> <path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/>",
36
39
  "search": "<circle cx=\"11\" cy=\"11\" r=\"8\"/> <path d=\"m21 21-4.3-4.3\"/>",
package/ui/picker.js CHANGED
@@ -324,8 +324,17 @@ export function openActionMenu(anchor, items, { title = '' } = {}) {
324
324
  menu.id = id
325
325
  menu.setAttribute('role', 'menu')
326
326
  if (title) menu.setAttribute('aria-label', title)
327
+ // 0.9.2: an item may carry a sub line ({ sub }), be a plain line ({ note }:
328
+ // what the menu is about, read out as its description) or a row of small
329
+ // buttons ({ row: [items], label }), e.g. the other checkpoint lengths.
330
+ const one = (it, extra = '') => '<button type="button" role="menuitem" tabindex="-1" class="lo-act' + extra + (it.sub ? ' has-sub' : '') + (it.cls ? ' ' + esc(it.cls) : '') + '"' + attrs(it.attrs || {}) + '>' + (it.icon || '') +
331
+ (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>') + '</button>'
332
+ const notes = []
327
333
  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('')
334
+ 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>')
335
+ : it.row ? '<div class="lo-act-row" role="group"' + (it.label ? ' aria-label="' + esc(it.label) + '"' : '') + '>' + (it.label ? '<span class="lo-act-rowlabel" aria-hidden="true">' + esc(it.label) + '</span>' : '') + it.row.map((x) => one(x, ' alt')).join('') + '</div>'
336
+ : one(it)).join('')
337
+ if (notes.length) menu.setAttribute('aria-describedby', notes.join(' '))
329
338
  let backdrop = null
330
339
  if (sheet) { backdrop = document.createElement('div'); backdrop.className = 'lo-backdrop'; document.body.appendChild(backdrop) }
331
340
  document.body.appendChild(menu)