@leftoff/cli 0.9.3 → 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/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  What changed in Leftoff (called Checkpoints until 0.4.5), newest first. The board's **What's new** page and `leftoff changelog` both read this file.
4
4
 
5
+ ## [0.9.4] - 2026-10-14
6
+
7
+ ### Changed
8
+ - The card's **⋯** menu has one **Checkpoint now** row again. Tap it for your default length, or tap **›** at its end to pick Short, Informative or Long.
9
+
5
10
  ## [0.9.3] - 2026-10-14
6
11
 
7
12
  ### Added
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
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"
@@ -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.3' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.4' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
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
@@ -1784,13 +1784,42 @@ html.dv-open .lo-menu { z-index: 62; }
1784
1784
  .lo-act-sub { font-size: 12.5px; color: var(--muted); font-weight: 400; line-height: 1.35; }
1785
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
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
1787
  .lo-menu.sheet .lo-act-note { font-size: 15px; padding: 10px 10px 12px; }
1791
1788
  .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; }
1789
+ /* 0.9.4: a row with a second page (Checkpoint now ›), the iOS way: the row
1790
+ does its action, the › at its right edge (its own target, a hairline before
1791
+ it) slides the menu to the page; ‹ title goes back. Fixed width on a
1792
+ desktop so the page change only eases the height. */
1793
+ .lo-actmenu.has-pages { overflow-x: hidden; }
1794
+ .lo-menu.lo-actmenu.has-pages:not(.sheet) { width: min(312px, calc(100vw - 16px)); max-width: none; }
1795
+ .lo-act-split { display: flex; align-items: stretch; position: relative; }
1796
+ .lo-act-split .lo-act-main { flex: 1 1 auto; min-width: 0; }
1797
+ .lo-act-next { flex: none; display: flex; align-items: center; justify-content: center; width: 40px; margin-left: 2px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--muted); cursor: pointer; position: relative; }
1798
+ .lo-act-next::before { content: ""; position: absolute; left: -1px; top: 9px; bottom: 9px; width: 1px; background: var(--line); }
1799
+ .lo-act-next .ic { width: 16px; height: 16px; }
1800
+ .lo-act-next:hover, .lo-act-next:focus { outline: none; background: var(--bg2); color: var(--text); }
1801
+ [data-theme="dark"] .lo-act-next:hover, [data-theme="dark"] .lo-act-next:focus, :root:not([data-theme="light"]) .lo-act-next:hover, :root:not([data-theme="light"]) .lo-act-next:focus { background: var(--line); }
1802
+ .lo-act-next:focus-visible { box-shadow: inset 0 0 0 2px var(--focus); }
1803
+ .lo-act-split:hover .lo-act-next::before, .lo-act-split:focus-within .lo-act-next::before { opacity: 0; }
1804
+ .lo-act-back { color: var(--text); font-weight: 600; border-bottom: 1px solid var(--line); border-radius: 8px 8px 0 0; margin-bottom: 4px; }
1805
+ .lo-act-back .ic { color: var(--accent); }
1806
+ .lo-act-page .lo-act.has-sub { padding-top: 9px; padding-bottom: 9px; }
1807
+ .lo-act-page .lo-act.on { font-weight: 600; }
1808
+ .lo-act .lo-act-check { color: var(--accent); }
1809
+ .lo-act .lo-act-sub { white-space: normal; }
1810
+ .lo-vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
1811
+ .lo-menu.lo-act-sizing { transition: height .2s cubic-bezier(.2, .8, .2, 1), top .2s cubic-bezier(.2, .8, .2, 1); overflow-y: hidden; }
1812
+ @keyframes lo-page-fwd { from { opacity: 0; transform: translateX(18px); } to { opacity: 1; transform: none; } }
1813
+ @keyframes lo-page-back { from { opacity: 0; transform: translateX(-18px); } to { opacity: 1; transform: none; } }
1814
+ .lo-act-page.lo-in-fwd { animation: lo-page-fwd .2s cubic-bezier(.2, .8, .2, 1); }
1815
+ .lo-act-page.lo-in-back { animation: lo-page-back .2s cubic-bezier(.2, .8, .2, 1); }
1816
+ .lo-menu.sheet .lo-act-next { width: 52px; min-height: 44px; }
1817
+ .lo-menu.sheet .lo-act-next .ic { width: 20px; height: 20px; }
1818
+ .lo-menu.sheet .lo-act-next::before { top: 12px; bottom: 12px; }
1819
+ .lo-menu.sheet .lo-act-page .lo-act.has-sub { padding-top: 12px; padding-bottom: 12px; }
1820
+ /* a phone keeps :hover on the last row tapped; only focus highlights there */
1821
+ @media (hover: none) { :root .lo-act:hover:not(:focus), :root .lo-act-next:hover:not(:focus) { background: none; } }
1822
+ @media (prefers-reduced-motion: reduce) { .lo-act-page.lo-in-fwd, .lo-act-page.lo-in-back { animation: none; } .lo-menu.lo-act-sizing { transition: none; } }
1794
1823
  .split { display: inline-flex; align-items: stretch; }
1795
1824
  .split .btn.split-main { border-top-right-radius: 0; border-bottom-right-radius: 0; }
1796
1825
  .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; }
package/ui/app.js CHANGED
@@ -420,17 +420,19 @@ function copyResumeIcon(s) {
420
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>'
421
421
  }
422
422
  // items: [{ action, label, icon, title, attrs }]
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.
423
+ // 0.9.2: an item may also be { note } (a plain line, no button) and carry a sub line.
424
+ // 0.9.4: or { …item, more: { title, items } }: the row does its action, and a ›
425
+ // at its right edge opens a second page of items (e.g. the checkpoint lengths,
426
+ // each with { sub, on }).
425
427
  function moreMenu(s, items, { project = true, title = '' } = {}) {
426
428
  items = items.concat([project ? projectMenuItem(s) : null]).filter(Boolean) // 0.9.0: Add to project… on every card with a repo
427
429
  if (!items.length) return ''
428
430
  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) + '"' : '') + '>' +
431
+ Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + (it.sub ? ' data-sub="' + esc(it.sub) + '"' : '') + (it.on ? ' data-on="' + esc(it.onLabel || 'on') + '"' : '') + '>' +
430
432
  (it.icon ? icon(it.icon) : '') + esc(it.label) + '</button>'
431
433
  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
434
  '<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>'
435
+ : it.more ? '<div class="more-split" data-more-title="' + esc(it.more.title) + '">' + btn(it) + '<div class="more-sub">' + it.more.items.map(btn).join('') + '</div></div>' : btn(it)).join('') + '</template>'
434
436
  }
435
437
  function openCardMenu(btn) {
436
438
  const tpl = btn.parentElement && btn.parentElement.querySelector('template.more-items')
@@ -440,12 +442,12 @@ function openCardMenu(btn) {
440
442
  const toItem = (b) => {
441
443
  const svg = b.querySelector('svg')
442
444
  const attrs = {}
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
+ for (const a of b.attributes) if ((a.name.startsWith('data-') && a.name !== 'data-sub' && a.name !== 'data-on') || a.name === 'title') attrs[a.name] = a.value
446
+ return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs, ...(b.dataset.sub ? { sub: b.dataset.sub } : {}), ...(b.dataset.on ? { on: true, onLabel: b.dataset.on } : {}) }
445
447
  }
446
448
  const items = [...tpl.content.children].map((el) => {
447
449
  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 || '' }
450
+ if (el.classList.contains('more-split')) return { ...toItem(el.querySelector(':scope > button')), more: { title: el.dataset.moreTitle || '', items: [...el.querySelectorAll('.more-sub > button')].map(toItem) } }
449
451
  return el.tagName === 'BUTTON' ? toItem(el) : null
450
452
  }).filter(Boolean)
451
453
  lastMenuAnchor = btn
@@ -463,12 +465,13 @@ function reachOf(s) {
463
465
  // The length "Checkpoint now" writes: yours from Settings → Summaries.
464
466
  function defaultLength() { const k = state.cfg && state.cfg.summary && state.cfg.summary.length; return LENGTH_CHOICES.some((o) => o.key === k) ? k : 'informative' }
465
467
  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.
468
+ // 0.9.4: one row, "Checkpoint now" at your length; its › opens Short /
469
+ // Informative / Long, each with its line, your default ticked.
467
470
  function checkpointItems() {
468
471
  const def = defaultLength()
469
472
  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' },
473
+ { action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus', title: 'Checkpoint now · ' + lengthLabel(def), attrs: { 'data-length': def },
474
+ more: { title: 'Checkpoint length', items: LENGTH_CHOICES.map((o) => ({ action: 'checkpoint', label: o.label, sub: o.blurb, on: o.key === def, onLabel: 'your default', attrs: { 'data-length': o.key } })) } },
472
475
  ]
473
476
  }
474
477
  // The ⋯ menu when the Mac a session lives on can't be reached: why, in plain
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,39 +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)
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 = []
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>'
333
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>' : '') +
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('')
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')
337
391
  if (notes.length) menu.setAttribute('aria-describedby', notes.join(' '))
338
392
  let backdrop = null
339
393
  if (sheet) { backdrop = document.createElement('div'); backdrop.className = 'lo-backdrop'; document.body.appendChild(backdrop) }
340
394
  document.body.appendChild(menu)
341
- 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"]')] }
342
397
  anchor.setAttribute('aria-haspopup', 'menu')
343
398
  anchor.setAttribute('aria-controls', id)
344
399
  anchor.setAttribute('aria-expanded', 'true')
400
+ const backFocus = () => act && act.menu.querySelector('[data-act-page="' + act.page.dataset.page + '"]')
345
401
  menu.addEventListener('click', (e) => {
346
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 }
347
406
  // the item's own click goes on to the board's handler (the event's path is
348
407
  // already fixed); the menu just gets out of the way
349
408
  if (e.target.closest('[role="menuitem"]')) closeActionMenu()
350
409
  })
351
- 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 }) })
352
411
  menu.addEventListener('keydown', (e) => {
353
412
  const k = act ? act.items.indexOf(document.activeElement) : -1
413
+ const onSub = !!(act && act.page && Number(act.page.dataset.page) > 0)
354
414
  switch (e.key) {
355
415
  case 'ArrowDown': e.preventDefault(); actFocus(k + 1); return
356
416
  case 'ArrowUp': e.preventDefault(); actFocus(k < 0 ? -1 : k - 1); return
357
417
  case 'Home': e.preventDefault(); actFocus(0); return
358
418
  case 'End': e.preventDefault(); actFocus(-1); return
359
- 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
360
426
  case 'Tab': e.preventDefault(); closeActionMenu({ focus: true }); return
361
427
  default:
362
428
  if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && act) {