@leftoff/cli 0.9.18-beta.8 → 0.9.18

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.
@@ -23,18 +23,63 @@ export const KEYBOARD_MIN_PX = 120 // less than this is the browser's own bars,
23
23
 
24
24
  // innerHeight: the layout viewport's height; vv: { height, offsetTop, scale }; typing: a text box has focus.
25
25
  // -> { h, top, kb } in CSS px (null without a visualViewport: the CSS falls back to 100dvh).
26
+ // 2026-11-04 (switching keyboards mid-typing hid the composer): while a text box has focus, full
27
+ // screen is exactly the visual viewport as iOS reports it right now, whatever the keyboard's height
28
+ // (a keyboard swap reports a passing full-height or tiny keyboard on the way; following each report
29
+ // means the next one puts it right). Nothing is remembered from when the box took focus. `kb` (the
30
+ // keyboard's own look: no home-indicator gap) still needs a keyboard's worth of height.
31
+ // 2026-11-07 (the maintainer's screen recording, iPhone, stock and four third-party keyboards): the
32
+ // 64px kept clear for iOS 26's ^ v ✓ bar on 11-06 was pure gap. In the recording the reply box's
33
+ // focus ring ends ~129 video px (≈76pt) above the bar's top edge on every keyboard: 64 kept + the
34
+ // box's 8px bottom padding + the bar's drawn edge. So the bar sits outside the visual viewport iOS
35
+ // reports, as before iOS 26, and full screen is the reported viewport exactly, with nothing kept.
36
+ // The 11-06 clipping was the fit measuring before iOS's last resize (switching keyboards fixed it:
37
+ // a fresh report); the board now looks again on every report and at timed beats after focus.
26
38
  export function fullViewportFit({ innerHeight, vv, typing = false } = {}) {
27
39
  if (!vv || !(Number(innerHeight) > 0)) return null
28
40
  const ih = Number(innerHeight)
29
41
  const vh = Number(vv.height) > 0 ? Number(vv.height) : ih
30
42
  const zoomed = Number(vv.scale) > 1.01 // pinch-zoomed: follow the visual viewport as before
31
43
  const kb = !!typing && ih - vh > KEYBOARD_MIN_PX
32
- if (!kb && !zoomed) return { h: Math.round(ih), top: 0, kb: false } // no keyboard: the whole window, from its top
33
- const room = Math.max(0, ih - vh)
44
+ if (!typing && !zoomed) return { h: Math.round(ih), top: 0, kb: false } // no keyboard: the whole window, from its top
45
+ const room = Math.max(0, ih - vh) // a visual viewport as tall as the window can't be panned (a stale pan)
34
46
  const top = Math.min(Math.max(0, Number(vv.offsetTop) || 0), room)
35
- return { h: Math.round(vh), top: Math.round(top), kb }
47
+ return { h: Math.round(Math.min(vh, ih)), top: Math.round(top), kb }
48
+ }
49
+
50
+ // How far full screen's composer sits outside what you can see (the visual viewport, in the same
51
+ // page coordinates as getBoundingClientRect): > 0 means move it up by that much, < 0 down, 0 it's in view.
52
+ // rect: the composer's { top, bottom }; vv: { height, offsetTop }.
53
+ export function outOfView(rect, vv, slack = 1) {
54
+ if (!rect || !vv || !(Number(vv.height) > 0)) return 0
55
+ const top = Number(vv.offsetTop) || 0, bottom = top + Number(vv.height)
56
+ 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)
57
+ if (rect.top < top - slack) return -Math.round(top - rect.top) // above the top of what you can see
58
+ return 0
36
59
  }
37
60
 
38
61
  // When to look again after a text box lets go (the keyboard takes ~0.3–0.5 s to
39
62
  // close, and iOS's reports during it can be stale or missing), in ms.
40
63
  export const SETTLE_AFTER_BLUR_MS = [0, 120, 350, 700, 1100]
64
+ // The beats after a text box takes focus and after every report while it has focus: a keyboard's
65
+ // last resize (a third-party one coming up late, a swap, the ^ v ✓ bar) can come with no event of
66
+ // its own, so the board looks again at each of these, sizing full screen to that moment's numbers.
67
+ export const SETTLE_WHILE_TYPING_MS = [0, 100, 300, 600, 1000]
68
+ // And every frame for this long after the box takes focus and after each report: iOS animates the
69
+ // keyboard (and pans) for ~0.3–0.5 s, part of it with no event, and its numbers settle a frame or
70
+ // two after the event that announced them (WebKit bug 237851: offsetTop read 0 mid-animation).
71
+ export const FOLLOW_FRAMES_MS = 1000
72
+
73
+ // 2026-11-06 (the phone's session sheet covered the reply box while typing; "sometimes I have to
74
+ // scroll that down, and then the message box appears"): the sheet went away only when the board
75
+ // measured a keyboard's worth of lost height (lf-kb), and some keyboards (a third-party one coming up
76
+ // late or in steps, or iOS shrinking the window along with the keyboard) never report that. Now the
77
+ // sheet steps aside whenever a text box outside the sheet has focus, measured or not, and comes back
78
+ // a beat after it lets go (so the tap that closed the keyboard lands where it was aimed, not on a
79
+ // sheet sliding in under the finger). A text box inside the sheet keeps it.
80
+ export const SHEET_BACK_AFTER_BLUR_MS = 400
81
+ // typing: a text box has focus; inSheet: that box is in the sheet; blurredMs: ms since a text box outside the sheet let go (null: none lately)
82
+ export function sheetAside({ typing = false, inSheet = false, blurredMs = null } = {}) {
83
+ if (typing) return !inSheet
84
+ return blurredMs != null && blurredMs >= 0 && blurredMs < SHEET_BACK_AFTER_BLUR_MS
85
+ }
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