@leftoff/cli 0.9.18-beta.13 → 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/CHANGELOG.md +7 -0
- package/bin/leftoff-statusline.js +7 -7
- package/bin/leftoff.js +771 -690
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +206 -3
- package/mod/hooks/register.js +8 -2
- package/package.json +1 -1
- package/ui/app.css +102 -2
- package/ui/app.js +224 -118
- package/ui/cp-fold.js +20 -0
- package/ui/decisions.js +2 -1
- package/ui/digest-versions.js +57 -0
- package/ui/html-preview.html +12 -0
- package/ui/html-preview.js +107 -0
- package/ui/icons.js +1 -0
- package/ui/mcp-setup.js +26 -0
- package/ui/render-digest.js +98 -0
- package/ui/render-export.js +128 -0
- package/ui/semdiff.js +38 -21
- package/ui/settings-nav.js +1 -1
- package/ui/walk-spots.js +35 -1
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 = '
|
|
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', '
|
|
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
|
-
|
|
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
|
|
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
|
|
617
|
-
//
|
|
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:
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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('&', '&') + '" data-tip="' + (st
|
|
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('&', '&') + '" data-tip="' + revNextTip(st) + '"' + (st.show ? '' : ' hidden') + '>' + icon('check') + '<span class="wt-dl"><span class="wt-dl-l">' + l.replace('&', '&') + '</span><span class="wt-dl-s">' + sh.replace('&', '&') + '</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
|
-
|
|
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) {
|
package/ui/settings-nav.js
CHANGED
|
@@ -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' },
|
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
|