@leftoff/cli 0.9.18-beta.12 → 0.9.18-beta.14

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/semdiff.js CHANGED
@@ -409,7 +409,7 @@ function railHtml(data, big, { esc, icon }) {
409
409
  }
410
410
 
411
411
  // 2026-10-30 Next spot: in the toolbar (not the step's footer, where Mark reviewed & next is)
412
- export const SPOT_TIP = 'Jump to the next spot worth a real look (n)'
412
+ export const SPOT_TIP = 'Next spot: a line worth a real look, from where you are. The walkthrough flagged it, or it is a risky kind of change (removed logic, auth, config, error handling) · n, Shift+N back'
413
413
  function spotBtnHtml(n, cur, { icon }) {
414
414
  if (!n) return ''
415
415
  return '<button type="button" class="btn wt-spot" data-action="wt-spot" data-tip="' + SPOT_TIP + '" aria-label="Next spot to look at, ' + spotCountWords(n, cur) + '">' + icon('crosshair') + '<span class="wt-spot-l">Next spot</span><span class="wt-spot-n" id="wtSpotN">' + spotCountWords(n, cur) + '</span></button>'
@@ -435,12 +435,12 @@ function staleBarHtml(st, { esc, icon, sid, loading }) {
435
435
  '<button type="button" class="linkish wt-upd" data-action="wt-update" data-id="' + esc(sid) + '"' + (loading ? ' disabled' : '') + '>' + icon('refresh-cw') + (loading ? 'Updating…' : 'Update') + '</button></p>'
436
436
  }
437
437
 
438
- // Download .html / Copy as Markdown: always in the header (icons on a desktop, words on a phone);
438
+ // Save as HTML (was Download .html) / Copy as Markdown: always in the header (icons on a desktop, words on a phone);
439
439
  // with nothing to export yet they stay, dimmed, and their tip says why
440
440
  function exportsHtml(ok, ui, { icon }) {
441
441
  const why = ui.loading ? 'Still reading the change' : ui.error ? 'Nothing to export: the walkthrough didn’t load' : 'Nothing to export yet'
442
442
  const b = (action, ic, label, tip) => '<button type="button" class="btn wt-exp-b" data-action="' + action + '" aria-label="' + label + '" data-tip="' + (ok ? tip : why) + '"' + (ok ? '' : ' aria-disabled="true"') + '>' + icon(ic) + '<span class="wt-exp-l">' + label + '</span></button>'
443
- return '<span class="wt-exp">' + b('wt-export-html', 'download', 'Download .html', 'Download .html · one file, opens offline anywhere') + b('wt-export-md', 'copy', 'Copy as Markdown', 'Copy as Markdown · for a PR description') + '</span>'
443
+ return '<span class="wt-exp">' + b('wt-export-html', 'download', 'Save as HTML', 'Save as HTML · one file, opens offline anywhere') + b('wt-export-md', 'copy', 'Copy as Markdown', 'Copy as Markdown · for a PR description') + '</span>'
444
444
  }
445
445
 
446
446
  // The whole walkthrough. ui: { scope, view: 'story'|'files', filter, open: Set(step ids with code shown), groups: Set(kinds open), cur, loading, error, ask }
@@ -463,7 +463,9 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
463
463
  '<button type="button" class="wt-x" data-action="wt-close" data-id="' + esc(sid) + '" aria-label="Close the walkthrough">' + icon('x') + '</button></div>'
464
464
  // 2026-10-30 the reading tools, a slim second row of the toolbar (the first stays one row): Next spot, Unified / Split
465
465
  // 2026-11-03 This session · Whole folder, at the top of the story and the Files list alike
466
- const tools = fileScopeSegHtml(data && data.fileScope, { esc, icon, action: 'wt-files', busy: !!ui.loading }) + spotBtnHtml(ok && view === 'story' ? spotsFor(data, readingOrder(data.steps, data)).length : 0, ui.spot, { icon }) +
466
+ // 2026-11-06 Next spot lives in the bar under the reading (Joel: "should probably always be visible as you scroll")
467
+ const nSpots = ok && view === 'story' ? spotsFor(data, readingOrder(data.steps, data)).length : 0
468
+ const tools = fileScopeSegHtml(data && data.fileScope, { esc, icon, action: 'wt-files', busy: !!ui.loading }) +
467
469
  modeSegHtml(ok || (data && Array.isArray(data.files) && data.files.length), ui.split)
468
470
  if (tools) h += '<div class="wt-r2" role="toolbar" aria-label="Which changes and reading tools">' + tools + '</div>'
469
471
  // which model wrote it (Settings → Models → Walk me through it picks it)
@@ -545,12 +547,12 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
545
547
  }
546
548
  const fm = big ? fileMapHtml(data, { esc, icon, cur: ui.cur }) : fl
547
549
  const dock = '<div class="wt-refs" id="wtRefs" role="list" aria-label="Lines you picked" hidden></div><button type="button" class="wt-askl" id="wtAskL" data-action="wt-ask-lines" hidden>' + icon('message-square') + '<span>Ask about these lines</span></button>' + '<form class="wt-ask" data-action-form="wt-ask" data-id="' + esc(sid) + '"><label class="lo-vh" for="wtAsk">Ask about this change</label><input id="wtAsk" class="wt-ask-in" type="text" autocomplete="off" placeholder="Ask about this change…" value="' + esc(ui.ask || '') + '"><button type="submit" class="wt-ask-b" aria-label="Put the question in the chat" data-tip="Into the chat box">' + icon('arrow-up') + '</button></form>'
548
- const pnav = ok && view === 'story' && (data.steps || []).length ? '<div class="wt-pnav"><button type="button" class="btn" data-action="wt-step" data-dir="-1">' + icon('chevron-left') + 'Back</button><span class="faint" id="wtPnavN">' + stepPos(data.steps, ui) + '</span><button type="button" class="btn" data-action="wt-step" data-dir="1">Next' + icon('chevron-right') + '</button></div>' : ''
550
+ const pnav = ok && view === 'story' && (data.steps || []).length ? '<div class="wt-pnav"><button type="button" class="btn" data-action="wt-step" data-dir="-1">' + icon('chevron-left') + 'Back</button><span class="faint" id="wtPnavN">' + stepPos(data.steps, ui) + '</span>' + spotBtnHtml(nSpots, ui.spot, { icon }) + '<button type="button" class="btn" data-action="wt-step" data-dir="1">Next' + icon('chevron-right') + '</button></div>' : ''
549
551
  const dk = deck ? deckSlides(data, ui) : null
550
552
  return '<div class="wt' + (big ? ' wt-big' : fl ? ' wt-fls' : '') + (deck ? ' wt-deckmode' : '') + '" id="wt" data-sid="' + esc(sid) + '" role="region" aria-labelledby="wtTitle"><div class="wt-grab" aria-hidden="true"></div>' +
551
553
  (deck ? '<div class="wt-dprog" aria-hidden="true"><i id="wtDprog" data-p="' + Math.round(((dk.cur + 1) / dk.slides.length) * 1000) / 10 + '"></i></div>' : '') +
552
554
  // 0.9.18 (Joel): with a file list on the left, the list and the reading each scroll on their own
553
- (fm ? '<div class="wt-2p' + (ui.rail ? ' rail' : '') + '" id="wt2p">' + railHtml(data, big, { esc, icon }) + fm + '<div class="wt-rz" role="separator" aria-orientation="vertical" aria-label="Resize the file list" tabindex="0" data-tip="Drag to resize · [ hides the list"></div>' : '') + '<div class="wt-scroll" id="wtScroll"><div class="wt-hw"><div class="wt-in">' + h + body + '</div></div></div>' + (fm ? '</div>' : '') + (deck ? deckNavHtml(dk, { esc, icon, reviewed }) : pnav) + dock + '</div>'
555
+ (fm ? '<div class="wt-2p' + (ui.rail ? ' rail' : '') + '" id="wt2p">' + railHtml(data, big, { esc, icon }) + fm + '<div class="wt-rz" role="separator" aria-orientation="vertical" aria-label="Resize the file list" tabindex="0" data-tip="Drag to resize · [ hides the list"></div>' : '') + '<div class="wt-scroll" id="wtScroll"><div class="wt-hw"><div class="wt-in">' + h + body + '</div></div></div>' + (fm ? '</div>' : '') + (deck ? deckNavHtml(dk, { esc, icon, reviewed, spots: nSpots, spot: ui.spot }) : pnav) + dock + '</div>'
554
556
  }
555
557
 
556
558
  // 0.9.18 the walkthrough as slides (Joel: "almost a slide show, each slide was
@@ -581,6 +583,9 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
581
583
  let o = '<p class="wt-sum big"><b>' + esc(data.headline) + '</b></p>' + (data.summary ? '<p class="wt-sum md-in">' + walkInlineLinks(data.summary) + '</p>' : '')
582
584
  o += '<div class="wt-meta">' + (scope ? '<span>' + icon('git-compare') + esc(scope) + '</span>' : '') + '<span>' + plural(st.files || 0, 'file') + (pm ? ' <b class="wt-pl">+' + n0(pm.add) + '</b> <b class="wt-mi">−' + n0(pm.del) + '</b>' : '') + '</span><span>' + plural(steps.length, 'step') + '</span><span id="wtProg">' + steps.filter((x) => reviewed.has(x.id)).length + ' of ' + steps.length + ' reviewed</span></div>'
583
585
  o += barHtml(st, { esc })
586
+ // 2026-11-06 the finish: "& finish" lands here, and this says it's done (app.js wtMarks keeps it current)
587
+ const allRev = steps.length > 0 && steps.every((x) => reviewed.has(x.id))
588
+ o += '<p class="wt-alldone" id="wtAllDone" role="status"' + (allRev ? '' : ' hidden') + '>' + icon('circle-check') + '<span>All ' + plural(steps.length, 'step') + ' reviewed</span></p>'
584
589
  if (data.passes > 1) o += '<p class="wt-note">' + icon('info') + '<span>Too big for one pass, so it was read in two: each folder on its own (' + plural(data.folders, 'folder') + '), then the changes ranked across all of them. Lockfiles and generated files were never sent.</span></p>'
585
590
  const cnt = (k) => steps.reduce((a, x) => a + x.callouts.filter((c) => c.kind === k).length, 0)
586
591
  const chip = (k, label, ic, n) => '<button type="button" class="wt-fc" data-action="wt-filter" data-filter="' + k + '" aria-pressed="' + ((ui.filter || 'all') === k) + '">' + (ic ? icon(ic) : '') + '<span>' + label + '</span>' + (n != null ? '<b>' + n + '</b>' : '') + '</button>'
@@ -610,29 +615,40 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
610
615
  })
611
616
  return h + '</div>'
612
617
  }
613
- // the bar under the slides: ← Prev · the current title · numbered dots · 3 / 12 · Next →
618
+ // the bar under the slides: ← Prev · the current title · numbered dots · 3 / 12 · Next spot · Next →
614
619
  // 0.9.19 (Joel: "the walk through should have 2 next buttons. One that marks as
615
620
  // viewed"): on a step's slide, a primary "Mark reviewed & next" beside the plain
616
- // Next; on the last step it reads "& finish" and goes back to the overview (the
617
- // summary with the reviewed count). Not shown on the overview or Safe to skip.
621
+ // Next. Not shown on the overview or Safe to skip.
622
+ // 2026-11-06 (Joel: "when you mark the last step as reviewed and next you just go back
623
+ // to the beginning"): on the last step it goes to the first step still unreviewed
624
+ // ("& step 2"); only when every step is reviewed does it read "& finish", and the
625
+ // overview it lands on says they all are.
618
626
  export const NEXT_TIP = 'Next, without marking (→)'
619
627
  export const REVNEXT_TIP = 'Mark this step reviewed and go to the next (Shift+→ or Enter)'
620
- export const REVFINISH_TIP = 'Mark this step reviewed and finish: back to the overview (Shift+→ or Enter)'
621
- // slides: [{ step }] in deck order -> { show, last, to } (to: the slide it goes to)
622
- export function revNextState(slides, cur) {
628
+ export const REVFINISH_TIP = 'Mark this step reviewed and finish: every step is then reviewed (Shift+→ or Enter)'
629
+ export const revBackTip = (n) => 'Mark this step reviewed and go back to step ' + n + ', not reviewed yet (Shift+→ or Enter)'
630
+ // slides: [{ step }] in deck order (step: an id, or { id, n }), reviewed: the step ids already
631
+ // reviewed -> { show, last, to, back } (to: the slide it goes to; back: the step number it
632
+ // goes back to, when the last step leaves one unreviewed)
633
+ export function revNextState(slides, cur, reviewed = new Set()) {
623
634
  const s = slides[cur]
624
- if (!s || !s.step) return { show: false, last: false, to: cur + 1 }
635
+ if (!s || !s.step) return { show: false, last: false, to: cur + 1, back: null }
625
636
  let lastStep = -1
626
637
  slides.forEach((x, i) => { if (x.step) lastStep = i })
627
- const last = cur === lastStep
628
- return { show: true, last, to: last ? 0 : cur + 1 }
629
- }
630
- export function revNextLabel(last) { return last ? ['Mark reviewed & finish', 'Reviewed & finish'] : ['Mark reviewed & next', 'Reviewed & next'] }
638
+ if (cur !== lastStep) return { show: true, last: false, to: cur + 1, back: null }
639
+ const id = (x) => (x && typeof x === 'object' ? x.id : x)
640
+ const left = slides.findIndex((x, i) => i !== cur && x.step && !reviewed.has(id(x.step)))
641
+ if (left < 0) return { show: true, last: true, to: 0, back: null }
642
+ const st = slides[left].step
643
+ return { show: true, last: false, to: left, back: (st && typeof st === 'object' && st.n) || left }
644
+ }
645
+ export function revNextLabel(last, back = null) { return back != null ? ['Mark reviewed & step ' + back, 'Reviewed & step ' + back] : last ? ['Mark reviewed & finish', 'Reviewed & finish'] : ['Mark reviewed & next', 'Reviewed & next'] }
646
+ export const revNextTip = (st) => (st.back != null ? revBackTip(st.back) : st.last ? REVFINISH_TIP : REVNEXT_TIP)
631
647
  function revNextHtml(st, { icon }) {
632
- const [l, sh] = revNextLabel(st.last)
633
- return '<button type="button" class="btn primary wt-drev" id="wtDrev" data-action="wt-revnext" aria-label="' + l.replace('&', '&amp;') + '" data-tip="' + (st.last ? REVFINISH_TIP : REVNEXT_TIP) + '"' + (st.show ? '' : ' hidden') + '>' + icon('check') + '<span class="wt-dl"><span class="wt-dl-l">' + l.replace('&', '&amp;') + '</span><span class="wt-dl-s">' + sh.replace('&', '&amp;') + '</span></span></button>'
648
+ const [l, sh] = revNextLabel(st.last, st.back)
649
+ return '<button type="button" class="btn primary wt-drev" id="wtDrev" data-action="wt-revnext" aria-label="' + l.replace('&', '&amp;') + '" data-tip="' + revNextTip(st) + '"' + (st.show ? '' : ' hidden') + '>' + icon('check') + '<span class="wt-dl"><span class="wt-dl-l">' + l.replace('&', '&amp;') + '</span><span class="wt-dl-s">' + sh.replace('&', '&amp;') + '</span></span></button>'
634
650
  }
635
- export function deckNavHtml({ slides, cur }, { esc, icon, reviewed = new Set() }) {
651
+ export function deckNavHtml({ slides, cur }, { esc, icon, reviewed = new Set(), spots = 0, spot = -1 }) {
636
652
  const dot = (s, i) => '<button type="button" class="wt-dot' + (i === cur ? ' cur' : '') + (s.step && reviewed.has(s.step.id) ? ' done' : '') + '" data-action="wt-slide" data-slide="' + i + '"' + (s.step ? ' data-step="' + esc(s.step.id) + '"' : '') +
637
653
  ' aria-label="' + esc((i === 0 ? 'Overview' : s.step ? 'Step ' + s.step.n + ': ' + mdPlain(s.title) : s.title) + (s.step && reviewed.has(s.step.id) ? ', reviewed' : '')) + '" data-tip="' + esc(i === 0 ? 'Overview' : s.step ? 'Step ' + s.step.n + ': ' + mdPlain(s.title) : s.title) + '"' + (i === cur ? ' aria-current="step"' : '') + '>' +
638
654
  (i === 0 ? icon('route') : s.step ? '<span class="wt-dot-n">' + s.step.n + '</span>' : icon('layers')) + '<span class="wt-dot-ok" aria-hidden="true">' + icon('check') + '</span></button>'
@@ -641,7 +657,8 @@ export function deckNavHtml({ slides, cur }, { esc, icon, reviewed = new Set() }
641
657
  '<span class="wt-dnow" id="wtDnow">' + esc(mdPlain(slides[cur].title)) + '</span>' +
642
658
  '<div class="wt-dots" id="wtDots" role="group" aria-label="Go to a slide">' + slides.map(dot).join('') + '</div>' +
643
659
  '<span class="wt-dcount" id="wtDcount" aria-live="polite">' + (cur + 1) + ' / ' + slides.length + '</span>' +
644
- revNextHtml(revNextState(slides, cur), { icon }) +
660
+ spotBtnHtml(spots, spot, { icon }) +
661
+ revNextHtml(revNextState(slides.map((x) => ({ step: x.step ? { id: x.step.id, n: x.step.n } : null })), cur, reviewed), { icon }) +
645
662
  '<button type="button" class="btn wt-dnext" data-action="wt-dstep" data-dir="1" aria-label="Next slide, without marking" data-tip="' + NEXT_TIP + '"' + (cur === slides.length - 1 ? ' disabled' : '') + '><span class="wt-dl">Next</span>' + icon('chevron-right') + '</button></nav>'
646
663
  }
647
664
  export function stepPos(steps, ui) {
@@ -3,7 +3,7 @@
3
3
  // unit tests read it too (test/unit/settings-nav.test.mjs).
4
4
  export const SETTINGS = [
5
5
  { key: 'general', label: 'General', icon: 'sliders-horizontal', sub: 'Checkpoints, notifications and theme.', words: 'dormant gap checkpoint notify notification needs you macos theme appearance light dark system pr cards pull request github gh', save: true },
6
- { key: 'digest', label: 'Digest', icon: 'newspaper', sub: 'When the daily digest is written.', words: 'time days weekdays every day quiet skip empty time zone schedule morning automatic', save: true },
6
+ { key: 'digest', label: 'Digest', icon: 'newspaper', sub: 'When the daily digest is written.', words: 'time days weekdays every day quiet skip empty time zone schedule morning automatic versions mcp claude codex search notes', save: true },
7
7
  { key: 'spaces', label: 'Spaces', icon: 'layers', sub: 'Work, Personal or your own names, per Mac and per repo.', words: 'work personal repo override mac default standup agent feed' },
8
8
  { key: 'sync', label: 'Sync', icon: 'folder-sync', sub: 'One folder, every machine.', words: 'syncthing pair pairing join code icloud dropbox folder move backup other macs machines install accept rename retire merge reinstall' },
9
9
  { key: 'agents', label: 'Agents', icon: 'bot', sub: 'Work from AI assistants outside Claude Code.', words: 'tab feed agent key url host hosting add refresh remove' },
@@ -0,0 +1,41 @@
1
+ // 2026-11-06 subagents working, on the board (src/subagents.js reads them): the session's status says
2
+ // "2 subagents working" instead of looking idle, and Live shows a line with what the newest one is
3
+ // doing ("Bash: pnpm test"). Words and a shape (the spinner), never color alone.
4
+
5
+ export function subCount(s) { const n = s && s.subagents ? Number(s.subagents.n) || 0 : 0; return n > 0 ? n : 0 }
6
+ export function subLabel(sub) {
7
+ const n = sub ? Number(sub.n) || 0 : 0
8
+ return n > 0 ? n + (n === 1 ? ' subagent working' : ' subagents working') : ''
9
+ }
10
+ export function subStep(sub) {
11
+ const l = sub && sub.latest
12
+ if (!l) return ''
13
+ return String(l.tool ? l.tool + (l.detail ? ': ' + l.detail : '') : l.detail || '').replace(/\s+/g, ' ').trim().slice(0, 160)
14
+ }
15
+ const who = (a) => (a && (a.label || (a.type ? a.type + ' agent' : ''))) || 'a subagent'
16
+ export function subTip(sub) {
17
+ const list = (sub && Array.isArray(sub.agents) ? sub.agents : []).map((a) => who(a) + (a && a.bg ? ' (background)' : ''))
18
+ const step = subStep(sub)
19
+ return (list.length ? list.join('\n') : subLabel(sub)) + (step ? '\nNow: ' + step : '')
20
+ }
21
+ // The status pill while subagents work (a session in a turn or with background work)
22
+ export function subPillHtml(s, esc) {
23
+ const sub = s && s.subagents
24
+ const label = subLabel(sub)
25
+ if (!label) return ''
26
+ const tip = subTip(sub)
27
+ return '<span class="pill s-active sub-pill" tabindex="0" data-tip="' + esc(tip) + '" aria-label="' + esc(label + ': ' + tip.replace(/\n/g, ', ')) + '"><span class="sub-spin" aria-hidden="true"></span>' + esc(label) + '</span>'
28
+ }
29
+ // Live's line under the thread
30
+ export function subStripHtml(sub, esc, { now = Date.now() } = {}) {
31
+ const label = subLabel(sub)
32
+ if (!label) return ''
33
+ const step = subStep(sub)
34
+ const at = sub.latest && sub.latest.at ? Number(sub.latest.at) : 0
35
+ const ago = at ? Math.max(0, Math.round((now - at) / 1000)) : null
36
+ const when = ago == null ? '' : ago < 5 ? 'just now' : ago < 90 ? ago + 's ago' : Math.round(ago / 60) + 'm ago'
37
+ return '<div class="lv-subs" role="status" data-tip="' + esc(subTip(sub)) + '"><span class="sub-spin" aria-hidden="true"></span><span class="lv-subs-what"><b>' + esc(label) + '</b>' +
38
+ (step ? '<span class="lv-subs-step">' + esc(step) + '</span>' : '') + '</span>' + (when ? '<span class="faint lv-subs-when">' + esc(when) + '</span>' : '') + '</div>'
39
+ }
40
+ // What Live's refetch compares, beside the thread's own signature
41
+ export function subSig(sub) { return sub && sub.n > 0 ? sub.n + ':' + (sub.latest ? sub.latest.at + (sub.latest.tool || '') + (sub.latest.detail || '') : '') : '' }
@@ -22,13 +22,14 @@ export function isTypingTarget(el) {
22
22
  export const KEYBOARD_MIN_PX = 120 // less than this is the browser's own bars, not a keyboard
23
23
 
24
24
  // innerHeight: the layout viewport's height; vv: { height, offsetTop, scale }; typing: a text box has focus.
25
- // -> { h, top, kb } in CSS px (null without a visualViewport: the CSS falls back to 100dvh).
25
+ // accessory: px a keyboard bar drawn over the page covers (keyboardAccessoryPx).
26
+ // -> { h, top, kb, pad? } in CSS px (pad only when there's room to keep) (null without a visualViewport: the CSS falls back to 100dvh).
26
27
  // 2026-11-04 (switching keyboards mid-typing hid the composer): while a text box has focus, full
27
28
  // screen is exactly the visual viewport as iOS reports it right now, whatever the keyboard's height
28
29
  // (a keyboard swap reports a passing full-height or tiny keyboard on the way; following each report
29
30
  // means the next one puts it right). Nothing is remembered from when the box took focus. `kb` (the
30
31
  // keyboard's own look: no home-indicator gap) still needs a keyboard's worth of height.
31
- export function fullViewportFit({ innerHeight, vv, typing = false } = {}) {
32
+ export function fullViewportFit({ innerHeight, vv, typing = false, accessory = 0 } = {}) {
32
33
  if (!vv || !(Number(innerHeight) > 0)) return null
33
34
  const ih = Number(innerHeight)
34
35
  const vh = Number(vv.height) > 0 ? Number(vv.height) : ih
@@ -37,15 +38,32 @@ export function fullViewportFit({ innerHeight, vv, typing = false } = {}) {
37
38
  if (!typing && !zoomed) return { h: Math.round(ih), top: 0, kb: false } // no keyboard: the whole window, from its top
38
39
  const room = Math.max(0, ih - vh) // a visual viewport as tall as the window can't be panned (a stale pan)
39
40
  const top = Math.min(Math.max(0, Number(vv.offsetTop) || 0), room)
40
- return { h: Math.round(Math.min(vh, ih)), top: Math.round(top), kb }
41
+ // pad: room kept clear at the bottom for a keyboard bar drawn over the page (iOS 26's form bar), only with a keyboard up
42
+ const pad = kb ? Math.max(0, Math.round(Number(accessory) || 0)) : 0
43
+ const fit = { h: Math.round(Math.min(vh, ih)), top: Math.round(top), kb }
44
+ if (pad) fit.pad = pad
45
+ return fit
46
+ }
47
+
48
+ // 2026-11-06 (Joel, iPhone: "So close but not quite": the sheet stepped aside, but only the reply
49
+ // box's top edge showed above the keyboard's ^ v ✓ bar). Since iOS 26 that bar floats over the
50
+ // page (Liquid Glass: it and the band behind it show the page through), and the visual viewport iOS
51
+ // reports runs down behind it, so a reply box placed at the reported bottom sits under the bar. No
52
+ // browser number says how tall the bar is, so on an iPhone or iPad whose Safari is 26 or later
53
+ // (glass: a CSS feature that arrived in Safari 26) full screen keeps this much clear at its bottom
54
+ // while a keyboard is up. On older iOS the bar was outside the reported viewport: nothing kept.
55
+ // Measured from the screenshot: the band from the bar's top edge to the keys is about 68px.
56
+ export const ACCESSORY_PX = 64
57
+ export function keyboardAccessoryPx({ ios = false, glass = false } = {}) {
58
+ return ios && glass ? ACCESSORY_PX : 0
41
59
  }
42
60
 
43
61
  // How far full screen's composer sits outside what you can see (the visual viewport, in the same
44
62
  // page coordinates as getBoundingClientRect): > 0 means move it up by that much, < 0 down, 0 it's in view.
45
63
  // rect: the composer's { top, bottom }; vv: { height, offsetTop }.
46
- export function outOfView(rect, vv, slack = 1) {
64
+ export function outOfView(rect, vv, slack = 1, pad = 0) {
47
65
  if (!rect || !vv || !(Number(vv.height) > 0)) return 0
48
- const top = Number(vv.offsetTop) || 0, bottom = top + Number(vv.height)
66
+ const top = Number(vv.offsetTop) || 0, bottom = top + Number(vv.height) - Math.max(0, Number(pad) || 0) // pad: covered by a keyboard bar
49
67
  if (rect.bottom > bottom + slack) return Math.round(Math.min(rect.bottom - bottom, rect.top - top)) // below the keyboard's edge (never past its own top)
50
68
  if (rect.top < top - slack) return -Math.round(top - rect.top) // above the top of what you can see
51
69
  return 0
@@ -57,6 +75,10 @@ export const SETTLE_AFTER_BLUR_MS = [0, 120, 350, 700, 1100]
57
75
  // The same beats after every report while a text box has focus: a keyboard swap's last change can
58
76
  // come with no event of its own, so the board looks again a few times after each one.
59
77
  export const SETTLE_WHILE_TYPING_MS = SETTLE_AFTER_BLUR_MS
78
+ // And every frame for this long after the box takes focus and after each report: iOS animates the
79
+ // keyboard (and pans) for ~0.3–0.5 s, part of it with no event, and its numbers settle a frame or
80
+ // two after the event that announced them (WebKit bug 237851: offsetTop read 0 mid-animation).
81
+ export const FOLLOW_FRAMES_MS = 1000
60
82
 
61
83
  // 2026-11-06 (the phone's session sheet covered the reply box while typing; "sometimes I have to
62
84
  // scroll that down, and then the message box appears"): the sheet went away only when the board
package/ui/walk-spots.js CHANGED
@@ -58,7 +58,7 @@ export function spotsFor(data, order) {
58
58
  snips.forEach((sn, i) => {
59
59
  const here = []
60
60
  const lineIn = (side, line) => parsePatch(sn.patch).rows.some((r) => isLine(r) && (side === 'old' ? r.o : r.n) === line)
61
- for (const c of st.callouts || []) if (c.hunk === sn.hunk && lineIn(c.side, c.line)) here.push({ side: c.side, line: c.line, why: SPOT_WORDS[c.kind] || SPOT_WORDS.look, kind: 'callout' })
61
+ for (const c of st.callouts || []) if (c.hunk === sn.hunk && lineIn(c.side, c.line)) here.push({ side: c.side, line: c.line, why: SPOT_WORDS[c.kind] || SPOT_WORDS.look, kind: 'callout', note: c.text || '' })
62
62
  for (const m of model) if (m.step === st.id && m.hunk === sn.hunk && lineIn(m.side, m.line) && !here.some((x) => x.side === m.side && x.line === m.line)) here.push({ side: m.side, line: m.line, why: m.why || 'Worth a look', kind: 'model' })
63
63
  if (!here.length && !model.length) {
64
64
  const r = snippetRisk(sn.patch, sn.file)
@@ -76,6 +76,40 @@ export function spotsFor(data, order) {
76
76
  return main.length ? main : sup
77
77
  }
78
78
 
79
+ // 2026-11-06 (Joel: "if used midway through will just start from the first, not the next
80
+ // from where you are"): where Next spot goes from where you're reading. rank: a step id ->
81
+ // its place in reading order; at: the place you're on (-1 the overview, Infinity past the
82
+ // last step). Carrying on from the spot you were last at when it's on this step; else the
83
+ // first spot on this step or after it (dir 1), or the last one before it (dir -1), wrapping.
84
+ export function spotFrom(spots, rank, at, cur, dir = 1) {
85
+ const n = spots.length
86
+ if (!n) return -1
87
+ const r = (sp) => { const v = rank(sp.step); return v == null || v < 0 ? Infinity : v }
88
+ if (cur != null && cur >= 0 && cur < n && r(spots[cur]) === at) return nextSpotIndex(n, cur, dir)
89
+ if (dir > 0) { const i = spots.findIndex((sp) => r(sp) >= at); return i < 0 ? 0 : i }
90
+ for (let i = n - 1; i >= 0; i--) if (r(spots[i]) < at) return i
91
+ return n - 1
92
+ }
93
+
94
+ // 2026-11-06 (Joel: "it needs a tooltip / popover explaining why it's a spot"): a spot's chip
95
+ // explains itself: who picked it and what to check. Plain text.
96
+ const RISK_WHY = {
97
+ 'Removed logic': 'Several lines of code go away with little added in their place. Check nothing still relies on them.',
98
+ 'Auth / permissions': 'A changed line touches auth or permissions (a token, a role, a session, a check). Check who can do what now.',
99
+ 'Error handling': 'A changed line handles errors. Check what happens now when this fails.',
100
+ Config: 'A config file. A change here affects every run, not one code path.',
101
+ Migration: 'A migration. Check it runs once, on real data, and what undoing it takes.',
102
+ }
103
+ export function spotTip(sp) {
104
+ if (!sp) return ''
105
+ const why = String(sp.why || '')
106
+ if (sp.kind === 'callout') return 'The walkthrough flags this line (' + why + ')' + (sp.note ? ': ' + sp.note : '.')
107
+ if (sp.kind === 'model') return 'The model that wrote this walkthrough picked this line as worth a real look: ' + (why || 'no reason given') + '.'
108
+ if (sp.kind === 'risk') return why + ', found by Leftoff\'s own check (no model). ' + (RISK_WHY[why] || '')
109
+ if (sp.kind === 'core') return 'The first change in a Core step: the heart of what this change does.'
110
+ return 'Nothing riskier was found, so this is the next Supporting change to read.'
111
+ }
112
+
79
113
  // The spot after (dir 1) or before (-1) index cur, wrapping; -1 when there are none.
80
114
  export function nextSpotIndex(n, cur, dir = 1) {
81
115
  if (!n) return -1