@leftoff/cli 0.9.17-beta.8 → 0.9.17

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
@@ -6,6 +6,8 @@
6
6
  // ui/diff-view.js, and the two exports (one self-contained .html, Markdown for a
7
7
  // PR description). A phone shows it as a tall bottom sheet.
8
8
  import { parsePatch, splitPath } from './diff-view.js'
9
+ import { walkInline, walkInlineLinks, walkInlineExport, walkBlock, mdPlain } from './walk-md.js' // 0.9.19: the words render as Markdown
10
+ import { spotsFor, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
9
11
 
10
12
  export const CALLOUT = {
11
13
  look: { word: 'Look closely', icon: 'eye' },
@@ -57,6 +59,43 @@ export function reviewedSet(s, fp) {
57
59
  return new Set(r.map(String))
58
60
  }
59
61
 
62
+ // 2026-10-29 (Joel) a session that changed several repos: the daemon puts each
63
+ // repo's paths under its label ("cube_cloud/src/x.ts") and gives each step its
64
+ // `repo`; data.repos lists them (only when there's more than one). One repo:
65
+ // none of this shows.
66
+ export function walkRepos(data) { return data && Array.isArray(data.repos) && data.repos.length > 1 ? data.repos : [] }
67
+ // "cube_cloud/src/x.ts" -> ['cube_cloud', 'src/x.ts'] (a path in no listed repo: [null, path])
68
+ export function repoSplit(p, repos) {
69
+ const s = String(p == null ? '' : p)
70
+ for (const r of repos || []) { const l = typeof r === 'string' ? r : r && r.label; if (l && s.startsWith(l + '/')) return [l, s.slice(l.length + 1)] }
71
+ return [null, s]
72
+ }
73
+ // the path as a person reads it: "cube_cloud · src/x.ts" with several repos, else the path
74
+ export function pathLabel(p, data) {
75
+ const [r, rel] = repoSplit(p, walkRepos(data))
76
+ return r ? r + ' · ' + rel : String(p == null ? '' : p)
77
+ }
78
+ // Reading order: Core, then Supporting; several repos: repo by repo (data.repos' order), that inside each
79
+ export function readingOrder(steps, data) {
80
+ const repos = walkRepos(data).map((r) => r.label)
81
+ const tagRank = (x) => (x.tag === 'Core' ? 0 : 1)
82
+ const ri = (x) => { if (!repos.length) return 0; const i = repos.indexOf(x.repo); return i < 0 ? repos.length : i }
83
+ return (steps || []).slice().sort((a, b) => ri(a) - ri(b) || tagRank(a) - tagRank(b) || (a.n || 0) - (b.n || 0))
84
+ }
85
+ // A repo the session changed that couldn't be read here: one quiet line each, never dropped silently
86
+ export function unreadableLines(data) {
87
+ return data && Array.isArray(data.unreadable) ? data.unreadable.map((u) => (u && (u.line || (u.repo ? u.repo + ': not readable from here' + (u.why ? ' (' + u.why + ')' : '') : ''))) || '').filter(Boolean) : []
88
+ }
89
+ function unreadableHtml(data, { esc, icon }) {
90
+ const ls = unreadableLines(data)
91
+ return ls.length ? '<ul class="wt-unr" role="note">' + ls.map((l) => '<li class="faint">' + icon('info') + '<span>' + esc(l) + '</span></li>').join('') + '</ul>' : ''
92
+ }
93
+ // a repo's header: "cube_cloud · 3 steps · 5 files"
94
+ function repoHeadWords(r, steps) {
95
+ const n = steps.filter((x) => x.repo === r.label).length
96
+ return plural(n, 'step') + ' · ' + plural(r.files || 0, 'file')
97
+ }
98
+
60
99
  // Which steps show under a filter ('all' | 'look' | 'assumption' | 'untested').
61
100
  export function visibleSteps(steps, filter = 'all') {
62
101
  if (!filter || filter === 'all') return steps
@@ -74,37 +113,98 @@ export function stepAfter(steps, curId, dir, filter = 'all') {
74
113
 
75
114
  function calloutHtml(c, { esc, icon }) {
76
115
  const k = CALLOUT[c.kind] || CALLOUT.look
77
- return '<div class="wt-co wt-co-' + esc(c.kind) + '" role="note"><span class="wt-co-h">' + icon(k.icon) + '<b>' + esc(k.word) + '</b></span><span class="wt-co-b">' + esc(c.text) + '</span></div>'
116
+ return '<div class="wt-co wt-co-' + esc(c.kind) + '" role="note"><span class="wt-co-h">' + icon(k.icon) + '<b>' + esc(k.word) + '</b></span><span class="wt-co-b md-in">' + walkInlineLinks(c.text) + '</span></div>'
78
117
  }
79
118
 
80
- function stepHtml(st, { esc, icon, done, cur, open, sid }) {
119
+ // 2026-10-30 (asked: "the files in a step of the walkthrough should be collapsable"): a
120
+ // step's snippets under one header per file (chevron, status, path, +/− counts),
121
+ // each folding on its own, with Collapse all / Expand all when there are two or
122
+ // more. What's open is kept per walkthrough (localStorage, by fingerprint);
123
+ // generated files, lockfiles and files in Safe to skip start folded.
124
+ const LOCKISH = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|Cargo\.lock|Gemfile\.lock|poetry\.lock|composer\.lock|go\.sum)$|\.min\.(js|css)$|\.(snap|map)$|(^|\/)(dist|build|vendor|generated?)\//i
125
+ export function patchCounts(patch) {
126
+ let add = 0, del = 0
127
+ for (const l of String(patch || '').split('\n')) { if (l.startsWith('+') && !l.startsWith('+++')) add++; else if (l.startsWith('-') && !l.startsWith('---')) del++ }
128
+ return { add, del }
129
+ }
130
+ // a step's snippets by file, in their order: [{ file, snips: [[sn, i]], add, del }]
131
+ export function snipsByFile(st) {
132
+ const out = []
133
+ ;(st.snippets || []).forEach((sn, i) => {
134
+ let g = out.find((x) => x.file === sn.file)
135
+ if (!g) { g = { file: sn.file, snips: [], add: 0, del: 0 }; out.push(g) }
136
+ g.snips.push([sn, i])
137
+ const c = patchCounts(sn.patch); g.add += c.add; g.del += c.del
138
+ })
139
+ return out
140
+ }
141
+ // the files of a walkthrough that start folded: generated or lockfiles, and anything in Safe to skip
142
+ export function skipFiles(data) {
143
+ const out = new Set()
144
+ for (const g of (data && data.groups) || []) for (const f of g.files || []) out.add(f.path)
145
+ for (const f of (data && data.files) || []) if (f.generated) out.add(f.path)
146
+ return out
147
+ }
148
+ export const fileFoldsDefault = (file, skip) => !(skip && skip.has(file)) && !LOCKISH.test(String(file || ''))
149
+ const SF_KEY = 'lo-wt-sf:'
150
+ // per walkthrough: { "<step>|<file>": true (open) | false (folded) }, only what you changed
151
+ export function sfState(fp) { try { const j = JSON.parse(localStorage.getItem(SF_KEY + fp) || '{}'); return j && typeof j === 'object' ? j : {} } catch { return {} } }
152
+ export function sfSet(fp, keys, open) {
153
+ try {
154
+ const j = sfState(fp)
155
+ for (const k of keys) j[k] = !!open
156
+ const ks = Object.keys(j); for (const k of ks.slice(0, Math.max(0, ks.length - 400))) delete j[k]
157
+ localStorage.setItem(SF_KEY + fp, JSON.stringify(j))
158
+ // only the newest walkthroughs keep theirs
159
+ const all = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(SF_KEY)) all.push(k) }
160
+ if (all.length > 30) for (const k of all.filter((x) => x !== SF_KEY + fp).slice(0, all.length - 30)) localStorage.removeItem(k)
161
+ } catch {}
162
+ }
163
+ export function sfOpen(state, step, file, skip) { const k = step + '|' + file; return k in (state || {}) ? !!state[k] : fileFoldsDefault(file, skip) }
164
+ export const CHANGED_TIP = 'Its files changed since this walkthrough was written'
165
+
166
+ function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, stat = null }) {
81
167
  const core = st.tag === 'Core'
82
- const files = st.files.map((f) => splitPath(f)[1]).join(', ')
83
- const head = '<header class="wt-st-h"><span class="wt-n" aria-hidden="true">' + st.n + '</span><h3 data-tip="' + esc(st.title) + '">' + esc(st.title) + '</h3>' +
168
+ // 2026-10-29 several repos: the step's repo leads ("cube_cloud · src/x.ts"); one repo: the file names
169
+ const rp = st.repo ? [st.repo] : []
170
+ const files = st.repo ? st.repo + ' · ' + st.files.map((f) => repoSplit(f, rp)[1]).join(', ') : st.files.map((f) => splitPath(f)[1]).join(', ')
171
+ const head = '<header class="wt-st-h"><span class="wt-n" aria-hidden="true">' + st.n + '</span><h3 class="md-in" data-tip="' + esc(mdPlain(st.title)) + '">' + walkInline(st.title) + '</h3>' +
84
172
  '<span class="wt-tag wt-tag-' + (core ? 'core' : 'sup') + '">' + (core ? 'Core' : 'Supporting') + '</span>' +
85
- '<span class="wt-files mono" data-tip="' + esc(st.files.join('\n')) + '">' + esc(files) + '</span>' +
173
+ (st.changed ? '<span class="wt-chg" data-tip="' + CHANGED_TIP + '"><i class="wt-chg-g" aria-hidden="true"></i>Changed since</span>' : '') +
174
+ '<span class="wt-files mono' + (st.repo ? ' wt-files-r' : '') + '" data-tip="' + esc(st.files.map((f) => (st.repo ? repoSplit(f, rp).filter(Boolean).join(' · ') : f)).join('\n')) + '">' + esc(files) + '</span>' +
86
175
  '<button type="button" class="wt-rev' + (done ? ' on' : '') + '" data-action="wt-reviewed" data-id="' + esc(sid) + '" data-step="' + esc(st.id) + '" aria-pressed="' + (done ? 'true' : 'false') + '" data-tip="' + (done ? 'Reviewed · tap to undo' : 'Mark reviewed') + '">' + icon('check') + '<span>' + (done ? 'Reviewed' : 'Mark reviewed') + '</span></button></header>'
87
- const why = st.why ? '<p class="wt-why">' + esc(st.why) + '</p>' : ''
88
- const snips = st.snippets.map((sn, i) => '<div class="wt-snip"><div class="wt-snip-h"><span class="mono">' + esc(sn.file) + '</span>' + (sn.partial ? '<span class="faint">· part of the change</span>' : '') + '</div><div class="wt-snip-d" data-snip="' + esc(st.id) + ':' + i + '"></div></div>').join('')
176
+ const why = st.why ? '<div class="wt-why wt-md">' + walkBlock(st.why) + '</div>' : ''
177
+ const byFile = snipsByFile(st)
178
+ const fileHead = (g, isOpen) => '<summary class="wt-sf-h" aria-label="' + esc((st.repo ? repoSplit(g.file, rp).filter(Boolean).join(' · ') : g.file) + ', ' + g.add + ' added, ' + g.del + ' removed') + '">' + icon('chevron-right', 'ic wt-g-chev') +
179
+ (stat && stat.get(g.file) ? statusHtml(stat.get(g.file), { esc, icon }) : '') +
180
+ '<span class="mono wt-sf-p">' + esc(st.repo ? repoSplit(g.file, rp).filter(Boolean).join(' · ') : g.file) + '</span>' +
181
+ '<span class="wt-file-n"><span class="wt-add">+' + n0(g.add) + '</span> <span class="wt-del">−' + n0(g.del) + '</span></span></summary>'
182
+ const snips = (byFile.length > 1 ? '<div class="wt-sfa" role="group" aria-label="Files in this step"><button type="button" class="linkish wt-sfa-b" data-action="wt-sf-all" data-step="' + esc(st.id) + '" data-open="0">' + icon('chevrons-down-up') + 'Collapse all</button><button type="button" class="linkish wt-sfa-b" data-action="wt-sf-all" data-step="' + esc(st.id) + '" data-open="1">' + icon('chevrons-up-down') + 'Expand all</button></div>' : '') +
183
+ byFile.map((g) => {
184
+ const isOpen = sfOpen(sf, st.id, g.file, skip)
185
+ return '<details class="wt-sf" data-sfk="' + esc(st.id + '|' + g.file) + '" data-step="' + esc(st.id) + '"' + (isOpen ? ' open' : '') + '>' + fileHead(g, isOpen) +
186
+ g.snips.map(([sn, i]) => '<div class="wt-snip">' + (sn.partial ? '<div class="wt-snip-h"><span class="faint">Part of the change</span></div>' : '') + '<div class="wt-snip-d" data-snip="' + esc(st.id) + ':' + i + '"></div></div>').join('') + '</details>'
187
+ }).join('')
89
188
  const show = core || open
90
189
  const lines = st.snippets.reduce((a, s) => a + s.changed, 0)
91
190
  const code = show ? snips + (core ? '' : '<button type="button" class="wt-showc" data-action="wt-code" data-step="' + esc(st.id) + '" aria-expanded="true">' + icon('chevron-down') + 'Hide code</button>')
92
191
  : '<button type="button" class="wt-showc" data-action="wt-code" data-step="' + esc(st.id) + '" aria-expanded="false">' + icon('chevron-right') + 'Show code <span class="faint">· ' + plural(lines, 'line') + '</span></button>'
93
192
  // callouts whose line isn't in a drawn snippet (folded code) still read here
94
193
  const loose = !show && st.callouts.length ? '<div class="wt-cos">' + st.callouts.map((c) => calloutHtml(c, { esc, icon })).join('') + '</div>' : ''
95
- return '<article class="wt-st ' + (core ? 'wt-core' : 'wt-sup') + (done ? ' done' : '') + (cur ? ' cur' : '') + '" id="wt-' + esc(st.id) + '" data-step="' + esc(st.id) + '" tabindex="-1" aria-label="' + esc('Step ' + st.n + ': ' + st.title) + '">' + head + why + loose + code + '</article>'
194
+ return '<article class="wt-st ' + (core ? 'wt-core' : 'wt-sup') + (done ? ' done' : '') + (cur ? ' cur' : '') + '" id="wt-' + esc(st.id) + '" data-step="' + esc(st.id) + '" tabindex="-1" aria-label="' + esc('Step ' + st.n + ': ' + mdPlain(st.title)) + '">' + head + why + loose + code + '</article>'
96
195
  }
97
196
 
98
- function groupHtml(g, { esc, icon, open }) {
197
+ function groupHtml(g, { esc, icon, open, data = null }) {
198
+ const pl = (p) => pathLabel(p, data) // 2026-10-29 several repos: "cube_cloud · src/x.ts"
99
199
  const head = '<summary>' + icon('chevron-right', 'ic wt-g-chev') + icon(GROUP_ICON[g.kind] || 'layers', 'ic wt-g-ic') +
100
200
  '<span class="wt-g-t"><b>' + esc(g.title) + '</b>' + (g.note ? ' <span class="faint">· ' + esc(g.note) + '</span>' : '') + '</span>' +
101
201
  '<span class="wt-g-n">' + plural(g.files.length + (g.kind === 'repeated' ? 1 : 0), 'file') + ' <span class="faint">· ' + plural(g.lines, 'line') + '</span></span></summary>'
102
202
  let body = ''
103
203
  if (g.kind === 'repeated') {
104
- body = '<p class="faint wt-g-p">One example, <span class="mono">' + esc(g.example.file) + '</span>:</p><div class="wt-snip-d" data-group-ex="' + esc(g.kind) + '"></div>' +
204
+ body = '<p class="faint wt-g-p">One example, <span class="mono">' + esc(pl(g.example.file)) + '</span>:</p><div class="wt-snip-d" data-group-ex="' + esc(g.kind) + '"></div>' +
105
205
  '<p class="faint wt-g-p">The same edit in ' + plural(g.files.length, 'more file') + ', each checked to match exactly:</p>'
106
206
  }
107
- body += '<ul class="wt-g-files">' + g.files.map((f) => '<li><span class="mono">' + esc(f.path) + '</span><span class="faint">' + plural(f.lines, 'line') + (f.why ? ' · ' + esc(f.why) : '') + '</span></li>').join('') + '</ul>'
207
+ body += '<ul class="wt-g-files">' + g.files.map((f) => '<li><span class="mono">' + esc(pl(f.path)) + '</span><span class="faint">' + plural(f.lines, 'line') + (f.why ? ' · ' + esc(f.why) : '') + '</span></li>').join('') + '</ul>'
108
208
  return '<details class="wt-grp" id="wt-g-' + esc(g.kind) + '" data-group="' + esc(g.kind) + '"' + (open ? ' open' : '') + '>' + head + '<div class="wt-g-body">' + body + '</div></details>'
109
209
  }
110
210
 
@@ -170,7 +270,7 @@ function fmDirHtml(d, { esc, icon, cur, depth, closed, sec, stat, fileHtml = fmF
170
270
  function fileMapHtml(data, { esc, icon, cur }) {
171
271
  const anyLook = (data.fileMap || []).some((sec) => sec.items.some((x) => x.look))
172
272
  let h = '<nav class="wt-fm wt-side" aria-label="Files in this change"><div class="wt-fm-h">Files <span class="faint">' + n0(data.stats.files) + '</span>' +
173
- (anyLook ? '<span class="wt-fm-hint">' + icon('eye') + '= worth a close look</span>' : '') + '</div>'
273
+ (anyLook ? '<span class="wt-fm-hint">' + icon('eye') + '= worth a close look</span>' : '') + '<button type="button" class="wt-railt" data-action="wt-rail" aria-expanded="true" aria-label="Hide the file list" data-tip="Hide the file list ([)">' + icon('panel-left-close') + '</button>' + '</div>'
174
274
  const closed = fmClosed(data.fingerprint || '')
175
275
  const stat = statusMap(data)
176
276
  for (const sec of data.fileMap || []) {
@@ -203,6 +303,47 @@ function scopesHtml(data, scope, { esc, icon, sid }) {
203
303
  (x.key === 'pr' ? icon('git-pull-request') : '') + '<span>' + esc(x.label) + '</span></button>').join('') + '</div>'
204
304
  }
205
305
 
306
+ // 2026-10-30 (asked: "the left side file list should be somewhat collapsible"): folded to a
307
+ // slim rail (a show button, then each section's count by its swatch), with [ or the
308
+ // button; its width drags (app.js keeps both per device)
309
+ export const RAIL_MIN = 180, RAIL_MAX = 480, RAIL_DEFAULT = 256
310
+ function railHtml(data, big, { esc, icon }) {
311
+ let h = '<div class="wt-rail"><button type="button" class="wt-railt" data-action="wt-rail" aria-expanded="false" aria-label="Show the file list" data-tip="Show the file list ([)">' + icon('panel-left-open') + '</button>'
312
+ if (big) for (const sec of data.fileMap || []) {
313
+ const n = sec.items.reduce((a, x) => a + (x.count || 1), 0)
314
+ h += '<span class="wt-rail-s" role="img" aria-label="' + esc(sec.label + ': ' + plural(n, 'file')) + '" data-tip="' + esc(sec.label + ' · ' + plural(n, 'file')) + '"><i class="wt-sw wt-bk-' + esc(sec.kind) + '" aria-hidden="true"></i><b>' + n0(n) + '</b></span>'
315
+ } else {
316
+ const n = ((data && data.files) || []).length
317
+ h += '<span class="wt-rail-s" role="img" aria-label="' + plural(n, 'file') + '" data-tip="' + plural(n, 'file') + '">' + icon('file-text') + '<b>' + n0(n) + '</b></span>'
318
+ }
319
+ return h + '</div>'
320
+ }
321
+
322
+ // 2026-10-30 Next spot: in the toolbar (not the step's footer, where Mark reviewed & next is)
323
+ export const SPOT_TIP = 'Jump to the next spot worth a real look (n)'
324
+ function spotBtnHtml(n, cur, { icon }) {
325
+ if (!n) return ''
326
+ 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>'
327
+ }
328
+ // 2026-10-30 Unified / Split, kept per device; offered only on a screen 1000px or wider (CSS hides it under)
329
+ export const SPLIT_MIN = 1000
330
+ function modeSegHtml(show, split) {
331
+ if (!show) return ''
332
+ return '<div class="wt-seg wt-seg-sm wt-dmode" role="group" aria-label="Diff layout"><button type="button" data-action="wt-dmode" data-mode="unified" aria-pressed="' + !split + '" data-tip="One column, old and new lines together">Unified</button><button type="button" data-action="wt-dmode" data-mode="split" aria-pressed="' + !!split + '" data-tip="Side by side: old on the left, new on the right">Split</button></div>'
333
+ }
334
+ // "3 files changed since this walkthrough · Update"
335
+ export function staleWords(st) {
336
+ if (!st) return ''
337
+ if (st.count == null) return 'The change has moved on since this walkthrough'
338
+ if (!st.count) return 'The change has moved on since this walkthrough'
339
+ return st.count + (st.count === 1 ? ' file changed' : ' files changed') + ' since this walkthrough'
340
+ }
341
+ function staleBarHtml(st, { esc, icon, sid, loading }) {
342
+ const tip = st.files && st.files.length ? st.files.slice(0, 12).join('\n') + (st.files.length > 12 ? '\n+' + (st.files.length - 12) + ' more' : '') : ''
343
+ return '<p class="wt-stale" role="status"><i class="wt-chg-g" aria-hidden="true"></i><span' + (tip ? ' class="wt-stale-w" tabindex="0" data-tip="' + esc(tip) + '"' : '') + '>' + esc(staleWords(st)) + '</span>' +
344
+ '<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>'
345
+ }
346
+
206
347
  // Download .html / Copy as Markdown: always in the header (icons on a desktop, words on a phone);
207
348
  // with nothing to export yet they stay, dimmed, and their tip says why
208
349
  function exportsHtml(ok, ui, { icon }) {
@@ -229,10 +370,16 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
229
370
  (ok && view === 'story' && (data.steps || []).length ? '<button type="button" class="btn wt-deckt" data-action="wt-deck" aria-pressed="' + (ui.deck === false) + '" data-tip="' + (ui.deck === false ? 'Show as slides, one step at a time · now: all steps on one page' : 'Show all steps on one page · now: slides') + '" aria-label="' + (ui.deck === false ? 'Show as slides' : 'Show all steps on one page') + '">' + icon(ui.deck === false ? 'gallery-horizontal' : 'rows-3') + '<span class="wt-deckt-l">' + (ui.deck === false ? 'Slides' : 'Show all') + '</span></button>' : '') +
230
371
  exportsHtml(ok, ui, { icon }) + '</div>' +
231
372
  '<button type="button" class="wt-x" data-action="wt-close" data-id="' + esc(sid) + '" aria-label="Close the walkthrough">' + icon('x') + '</button></div>'
373
+ // 2026-10-30 the reading tools, a slim second row of the toolbar (the first stays one row): Next spot, Unified / Split
374
+ const tools = spotBtnHtml(ok && view === 'story' ? spotsFor(data, readingOrder(data.steps, data)).length : 0, ui.spot, { icon }) +
375
+ modeSegHtml(ok || (data && Array.isArray(data.files) && data.files.length), ui.split)
376
+ if (tools) h += '<div class="wt-r2" role="toolbar" aria-label="Reading tools">' + tools + '</div>'
232
377
  // which model wrote it (Settings → Models → Walk me through it picks it)
233
378
  // the session's own agent couldn't (a Codex session, no transcript here, it didn't answer): said quietly, the reason in the tip
234
379
  if (ok) h += '<p class="faint wt-by">Written by ' + (data.modelLabel ? esc(data.modelLabel) : 'your summarizer') + (when ? ' · ' + esc(when) : '') +
235
380
  (data.path === 'fallback' ? ' · <span class="wt-path" tabindex="0" data-tip="' + esc('Not the session’s agent: ' + (data.pathWhy || 'it couldn’t be forked here')) + '">not the session’s agent</span>' : '') + '</p>'
381
+ // 2026-10-30 a kept walkthrough for a diff that has moved on: said so, with Update (never shown as current)
382
+ if (ok && data.stale) h += staleBarHtml(data.stale, { esc, icon, sid, loading: ui.loading })
236
383
  // nothing new since the last checkpoint: the whole session instead, and a quiet word saying so
237
384
  if (ui.fellBack && scope === 'session') h += '<p class="faint wt-fb" role="status">' + icon('info') + '<span>Nothing new since the last checkpoint, so this is the whole session.</span></p>'
238
385
  // the session's agent can take a few minutes: Stop is always there while it reads
@@ -241,6 +388,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
241
388
  if (data && !ui.loading && data.status !== 'ok' && data.message) {
242
389
  const msg = data.status === 'empty' && scope === 'session' ? 'No changes in this session yet.' : data.status === 'empty' && scope === 'pr' ? 'No changes in this PR yet.' : data.message
243
390
  h += '<p class="wt-note">' + icon('info') + '<span>' + esc(msg) + (data.status === 'no_model' ? ' <a href="#/settings/summaries">Settings</a>' : '') + '</span></p>'
391
+ h += unreadableHtml(data, { esc, icon }) // 2026-10-29 a repo that couldn't be read says so
244
392
  // a fallback says why (the first reason; the model's whole reply: leftoff walkthrough debug)
245
393
  if (data.status === 'fallback' && data.reason) h += '<details class="wt-whyd"><summary>Why?</summary><p class="faint">' + esc(data.reason) + (data.debug ? '<br>The model’s whole reply: <span class="mono">' + esc(data.debug) + '</span>' : '') + '</p></details>'
246
394
  // a small local model (under 7B) is the likely reason: one quiet line pointing at a better one
@@ -251,8 +399,10 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
251
399
  const steps = data.steps || []
252
400
  const vis = visibleSteps(steps, ui.filter)
253
401
  const done = steps.filter((x) => reviewed.has(x.id)).length
254
- h += '<p class="wt-sum big"><b>' + esc(data.headline) + '</b></p>' + (data.summary ? '<p class="wt-sum">' + esc(data.summary) + '</p>' : '')
402
+ h += '<p class="wt-sum big"><b>' + esc(data.headline) + '</b></p>' + (data.summary ? '<p class="wt-sum md-in">' + walkInlineLinks(data.summary) + '</p>' : '')
255
403
  h += barHtml(data.stats, { esc })
404
+ if (walkRepos(data).length) h += '<p class="faint wt-repos">' + icon('folder') + '<span>' + plural(walkRepos(data).length, 'repo') + ': ' + walkRepos(data).map((r) => esc(r.label)).join(', ') + '</span></p>'
405
+ h += unreadableHtml(data, { esc, icon })
256
406
  if (data.passes > 1) h += '<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>'
257
407
  const cnt = (k) => steps.reduce((a, x) => a + x.callouts.filter((c) => c.kind === k).length, 0)
258
408
  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>'
@@ -268,9 +418,19 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
268
418
  const vis = new Set(visibleSteps(steps, ui.filter).map((x) => x.id))
269
419
  const core = steps.filter((x) => x.tag === 'Core' && vis.has(x.id)), sup = steps.filter((x) => x.tag !== 'Core' && vis.has(x.id))
270
420
  const bd = data.stats.breakdown || {}
271
- const one = (st) => stepHtml(st, { esc, icon, done: reviewed.has(st.id), cur: ui.cur === st.id, open: ui.open && ui.open.has(st.id), sid })
272
- if (core.length) body += '<h4 class="wt-sec"><i class="wt-sw wt-bk-core" aria-hidden="true"></i>Core <span class="faint">· read these · ' + plural(bd.core ? bd.core.files : 0, 'file') + '</span></h4>' + core.map(one).join('')
273
- if (sup.length) body += '<h4 class="wt-sec"><i class="wt-sw wt-bk-sup" aria-hidden="true"></i>Supporting <span class="faint">· skim · ' + plural(bd.sup ? bd.sup.files : 0, 'file') + '</span></h4>' + sup.map(one).join('')
421
+ const one = (st) => stepHtml(st, { esc, icon, done: reviewed.has(st.id), cur: ui.cur === st.id, open: ui.open && ui.open.has(st.id), sid, sf: ui.sf || {}, skip: skipFiles(data), stat: statusMap(data) })
422
+ const repos = walkRepos(data)
423
+ // 2026-10-29 several repos: one section per repo (its header with counts), its steps in reading order
424
+ if (repos.length) {
425
+ const order = readingOrder(steps.filter((x) => vis.has(x.id)), data)
426
+ for (const r of repos) {
427
+ const mine = order.filter((x) => x.repo === r.label)
428
+ if (mine.length) body += '<h4 class="wt-sec wt-rh">' + icon('folder') + '<span class="mono">' + esc(r.label) + '</span> <span class="faint">· ' + repoHeadWords(r, steps) + '</span></h4>' + mine.map(one).join('')
429
+ }
430
+ const rest = order.filter((x) => !repos.some((r) => r.label === x.repo))
431
+ if (rest.length) body += rest.map(one).join('')
432
+ } else if (core.length) body += '<h4 class="wt-sec"><i class="wt-sw wt-bk-core" aria-hidden="true"></i>Core <span class="faint">· read these · ' + plural(bd.core ? bd.core.files : 0, 'file') + '</span></h4>' + core.map(one).join('')
433
+ if (sup.length && !repos.length) body += '<h4 class="wt-sec"><i class="wt-sw wt-bk-sup" aria-hidden="true"></i>Supporting <span class="faint">· skim · ' + plural(bd.sup ? bd.sup.files : 0, 'file') + '</span></h4>' + sup.map(one).join('')
274
434
  const hidden = steps.length - vis.size
275
435
  if (hidden) body += '<p class="faint wt-hid">' + plural(hidden, 'step') + ' with nothing like that ' + (hidden === 1 ? 'is' : 'are') + ' hidden · <a href="#" data-action="wt-filter" data-filter="all">Show all</a></p>'
276
436
  if (!steps.length) body += '<p class="faint wt-hid">Nothing here needs a close read.</p>'
@@ -278,7 +438,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
278
438
  if (groups.length && (!ui.filter || ui.filter === 'all')) {
279
439
  const n = groups.reduce((a, g) => a + g.files.length + (g.kind === 'repeated' ? 1 : 0), 0), l = groups.reduce((a, g) => a + g.lines, 0)
280
440
  body += '<h4 class="wt-sec"><i class="wt-sw wt-bk-mech" aria-hidden="true"></i>Safe to skip <span class="faint">· ' + plural(n, 'file') + ' · ' + plural(l, 'line') + ', folded by kind</span></h4>' +
281
- groups.map((g) => groupHtml(g, { esc, icon, open: ui.groups && ui.groups.has(g.kind) })).join('')
441
+ groups.map((g) => groupHtml(g, { esc, icon, open: ui.groups && ui.groups.has(g.kind), data })).join('')
282
442
  }
283
443
  body += '<p class="wt-cov">' + icon('circle-check') + 'Every changed line is in a step or a group: ' + n0(data.coverage.covered) + ' of ' + n0(data.coverage.total) + '.</p>'
284
444
  }
@@ -288,13 +448,13 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
288
448
  body += filesHtml(data.files, { esc, icon })
289
449
  }
290
450
  const fm = big ? fileMapHtml(data, { esc, icon, cur: ui.cur }) : fl
291
- const dock = '<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>'
451
+ 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>'
292
452
  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>' : ''
293
453
  const dk = deck ? deckSlides(data, ui) : null
294
454
  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>' +
295
455
  (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>' : '') +
296
456
  // 0.9.18 (Joel): with a file list on the left, the list and the reading each scroll on their own
297
- (fm ? '<div class="wt-2p">' + fm : '') + '<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>'
457
+ (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>'
298
458
  }
299
459
 
300
460
  // 0.9.18 the walkthrough as slides (Joel: "almost a slide show, each slide was
@@ -305,7 +465,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
305
465
  export function deckSlides(data, ui = {}) {
306
466
  const steps = data.steps || []
307
467
  const vis = new Set(visibleSteps(steps, ui.filter).map((x) => x.id))
308
- const order = [...steps.filter((x) => x.tag === 'Core' && vis.has(x.id)), ...steps.filter((x) => x.tag !== 'Core' && vis.has(x.id))]
468
+ const order = readingOrder(steps.filter((x) => vis.has(x.id)), data) // 2026-10-29 several repos: repo by repo
309
469
  const slides = [{ kind: 'overview', title: 'Overview' }, ...order.map((st) => ({ kind: 'step', title: st.title, step: st }))]
310
470
  if ((data.groups || []).length && (!ui.filter || ui.filter === 'all')) slides.push({ kind: 'skip', title: 'Safe to skip' })
311
471
  const cur = Math.max(0, Math.min(slides.length - 1, Number(ui.slide) || 0))
@@ -321,15 +481,21 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
321
481
  const st = data.stats || {}
322
482
  const scope = SCOPE_WORDS[ui.scope] || ''
323
483
  const pm = Array.isArray(data.files) && data.files.length ? data.files.reduce((a, f) => ({ add: a.add + (f.add || 0), del: a.del + (f.del || 0) }), { add: 0, del: 0 }) : null
324
- let o = '<p class="wt-sum big"><b>' + esc(data.headline) + '</b></p>' + (data.summary ? '<p class="wt-sum">' + esc(data.summary) + '</p>' : '')
484
+ 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>' : '')
325
485
  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>'
326
486
  o += barHtml(st, { esc })
327
487
  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>'
328
488
  const cnt = (k) => steps.reduce((a, x) => a + x.callouts.filter((c) => c.kind === k).length, 0)
329
489
  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>'
330
490
  if (cnt('look') || cnt('assumption') || cnt('untested')) o += '<div class="wt-flt">' + chip('all', 'All ' + steps.length + ' steps', null, null) + (cnt('look') ? chip('look', 'Look closely', 'eye', cnt('look')) : '') + (cnt('assumption') ? chip('assumption', 'Assumption', 'circle-help', cnt('assumption')) : '') + (cnt('untested') ? chip('untested', 'Untested', 'circle-dashed', cnt('untested')) : '') + '</div>'
331
- o += '<h4 class="wt-sec">Reading order</h4><ol class="wt-agenda">' + slides.map((s, i) => i === 0 ? '' : '<li><button type="button" class="wt-ag" data-action="wt-slide" data-slide="' + i + '"' + (s.step ? ' data-step="' + esc(s.step.id) + '"' : '') + '>' +
332
- (s.step ? '<span class="wt-n" aria-hidden="true">' + s.step.n + '</span>' : '<span class="wt-n wt-n-skip" aria-hidden="true">' + icon('layers') + '</span>') + '<span class="wt-ag-t">' + esc(s.title) + '</span>' +
491
+ const repos = walkRepos(data)
492
+ if (repos.length) o += '<p class="faint wt-repos">' + icon('folder') + '<span>' + plural(repos.length, 'repo') + ': ' + repos.map((r) => esc(r.label)).join(', ') + '</span></p>'
493
+ o += unreadableHtml(data, { esc, icon })
494
+ // several repos: a small header per repo, with its counts, where its steps start
495
+ let lastRepo = null
496
+ const rh = (s) => { if (!repos.length || !s.step || s.step.repo === lastRepo) return ''; lastRepo = s.step.repo; const r = repos.find((x) => x.label === s.step.repo); return r ? '<li class="wt-ag-rh" role="presentation"><b class="mono">' + esc(r.label) + '</b><span class="faint"> · ' + repoHeadWords(r, steps) + '</span></li>' : '' }
497
+ o += '<h4 class="wt-sec">Reading order</h4><ol class="wt-agenda">' + slides.map((s, i) => i === 0 ? '' : rh(s) + '<li><button type="button" class="wt-ag" data-action="wt-slide" data-slide="' + i + '"' + (s.step ? ' data-step="' + esc(s.step.id) + '"' : '') + '>' +
498
+ (s.step ? '<span class="wt-n" aria-hidden="true">' + s.step.n + '</span>' : '<span class="wt-n wt-n-skip" aria-hidden="true">' + icon('layers') + '</span>') + '<span class="wt-ag-t md-in">' + walkInline(s.title) + '</span>' +
333
499
  (s.step ? '<span class="wt-tag wt-tag-' + (s.step.tag === 'Core' ? 'core' : 'sup') + '">' + (s.step.tag === 'Core' ? 'Core' : 'Supporting') + '</span>' + '<span class="wt-ag-ok"' + (reviewed.has(s.step.id) ? '' : ' hidden') + '>' + icon('check') + 'Reviewed</span>' : '<span class="faint">skim or skip</span>') + '</button></li>').join('') + '</ol>'
334
500
  const hidden = steps.length - (slides.filter((s) => s.step).length)
335
501
  if (hidden) o += '<p class="faint wt-hid">' + plural(hidden, 'step') + ' with nothing like that ' + (hidden === 1 ? 'is' : 'are') + ' hidden · <a href="#" data-action="wt-filter" data-filter="all">Show all</a></p>'
@@ -338,26 +504,48 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
338
504
  slides.forEach((s, i) => {
339
505
  if (s.kind === 'step') {
340
506
  const x = s.step
341
- h += sec(i, stepHtml(x, { esc, icon, done: reviewed.has(x.id), cur: false, open: true, sid }), 'Step ' + x.n + ' of ' + steps.length + ': ' + x.title, ' data-step="' + esc(x.id) + '"')
507
+ h += sec(i, stepHtml(x, { esc, icon, done: reviewed.has(x.id), cur: false, open: true, sid, sf: ui.sf || {}, skip: skipFiles(data), stat: statusMap(data) }), 'Step ' + x.n + ' of ' + steps.length + ': ' + mdPlain(x.title), ' data-step="' + esc(x.id) + '"')
342
508
  } else if (s.kind === 'skip') {
343
509
  const groups = data.groups || []
344
510
  const n = groups.reduce((a, g) => a + g.files.length + (g.kind === 'repeated' ? 1 : 0), 0), l = groups.reduce((a, g) => a + g.lines, 0)
345
- h += sec(i, '<h4 class="wt-sec"><i class="wt-sw wt-bk-mech" aria-hidden="true"></i>Safe to skip <span class="faint">· ' + plural(n, 'file') + ' · ' + plural(l, 'line') + ', folded by kind</span></h4>' + groups.map((g) => groupHtml(g, { esc, icon, open: ui.groups && ui.groups.has(g.kind) })).join(''), 'Safe to skip')
511
+ h += sec(i, '<h4 class="wt-sec"><i class="wt-sw wt-bk-mech" aria-hidden="true"></i>Safe to skip <span class="faint">· ' + plural(n, 'file') + ' · ' + plural(l, 'line') + ', folded by kind</span></h4>' + groups.map((g) => groupHtml(g, { esc, icon, open: ui.groups && ui.groups.has(g.kind), data })).join(''), 'Safe to skip')
346
512
  }
347
513
  })
348
514
  return h + '</div>'
349
515
  }
350
516
  // the bar under the slides: ← Prev · the current title · numbered dots · 3 / 12 · Next →
517
+ // 0.9.19 (Joel: "the walk through should have 2 next buttons. One that marks as
518
+ // viewed"): on a step's slide, a primary "Mark reviewed & next" beside the plain
519
+ // Next; on the last step it reads "& finish" and goes back to the overview (the
520
+ // summary with the reviewed count). Not shown on the overview or Safe to skip.
521
+ export const NEXT_TIP = 'Next, without marking (→)'
522
+ export const REVNEXT_TIP = 'Mark this step reviewed and go to the next (Shift+→ or Enter)'
523
+ export const REVFINISH_TIP = 'Mark this step reviewed and finish: back to the overview (Shift+→ or Enter)'
524
+ // slides: [{ step }] in deck order -> { show, last, to } (to: the slide it goes to)
525
+ export function revNextState(slides, cur) {
526
+ const s = slides[cur]
527
+ if (!s || !s.step) return { show: false, last: false, to: cur + 1 }
528
+ let lastStep = -1
529
+ slides.forEach((x, i) => { if (x.step) lastStep = i })
530
+ const last = cur === lastStep
531
+ return { show: true, last, to: last ? 0 : cur + 1 }
532
+ }
533
+ export function revNextLabel(last) { return last ? ['Mark reviewed & finish', 'Reviewed & finish'] : ['Mark reviewed & next', 'Reviewed & next'] }
534
+ function revNextHtml(st, { icon }) {
535
+ const [l, sh] = revNextLabel(st.last)
536
+ 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>'
537
+ }
351
538
  export function deckNavHtml({ slides, cur }, { esc, icon, reviewed = new Set() }) {
352
539
  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) + '"' : '') +
353
- ' aria-label="' + esc((i === 0 ? 'Overview' : s.step ? 'Step ' + s.step.n + ': ' + s.title : s.title) + (s.step && reviewed.has(s.step.id) ? ', reviewed' : '')) + '" data-tip="' + esc(i === 0 ? 'Overview' : s.step ? 'Step ' + s.step.n + ': ' + s.title : s.title) + '"' + (i === cur ? ' aria-current="step"' : '') + '>' +
540
+ ' 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"' : '') + '>' +
354
541
  (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>'
355
542
  return '<nav class="wt-dnav" aria-label="Slides">' +
356
543
  '<button type="button" class="btn wt-dprev" data-action="wt-dstep" data-dir="-1" aria-label="Previous slide" data-tip="Previous slide (←)"' + (cur === 0 ? ' disabled' : '') + '>' + icon('chevron-left') + '<span class="wt-dl">Prev</span></button>' +
357
- '<span class="wt-dnow" id="wtDnow">' + esc(slides[cur].title) + '</span>' +
544
+ '<span class="wt-dnow" id="wtDnow">' + esc(mdPlain(slides[cur].title)) + '</span>' +
358
545
  '<div class="wt-dots" id="wtDots" role="group" aria-label="Go to a slide">' + slides.map(dot).join('') + '</div>' +
359
546
  '<span class="wt-dcount" id="wtDcount" aria-live="polite">' + (cur + 1) + ' / ' + slides.length + '</span>' +
360
- '<button type="button" class="btn wt-dnext" data-action="wt-dstep" data-dir="1" aria-label="Next slide" data-tip="Next slide (→)"' + (cur === slides.length - 1 ? ' disabled' : '') + '><span class="wt-dl">Next</span>' + icon('chevron-right') + '</button></nav>'
547
+ revNextHtml(revNextState(slides, cur), { icon }) +
548
+ '<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>'
361
549
  }
362
550
  export function stepPos(steps, ui) {
363
551
  const vis = visibleSteps(steps || [], ui.filter)
@@ -369,7 +557,7 @@ export function filesHtml(files, { esc, icon }) {
369
557
  const open = files.length <= 10
370
558
  return '<div class="wt-files-v">' + files.map((f, i) => {
371
559
  const what = f.hidden ? 'not shown (keys and env files never are)' : f.binary ? 'binary file' : f.generated ? 'generated or lockfile · ' + plural(f.add + f.del, 'line') : !f.lines ? (f.status === 'renamed' ? 'renamed, nothing changed inside' : 'no lines changed') : ''
372
- return '<details class="wt-file" id="wt-f-' + i + '" data-file-i="' + i + '"' + (open && !what ? ' open' : '') + '><summary>' + icon('chevron-right', 'ic wt-g-chev') + statusHtml(f, { esc, icon }) + '<span class="mono wt-file-p">' + esc(f.path) + '</span>' + (f.oldPath ? '<span class="faint wt-file-o">from ' + esc(f.oldPath) + '</span>' : '') +
560
+ return '<details class="wt-file" id="wt-f-' + i + '" data-file-i="' + i + '"' + (open && !what ? ' open' : '') + '><summary>' + icon('chevron-right', 'ic wt-g-chev') + statusHtml(f, { esc, icon }) + '<span class="mono wt-file-p">' + esc(f.repo && String(f.path).startsWith(f.repo + '/') ? f.repo + ' · ' + f.path.slice(f.repo.length + 1) : f.path) + '</span>' + (f.oldPath ? '<span class="faint wt-file-o">from ' + esc(f.oldPath) + '</span>' : '') +
373
561
  '<span class="wt-file-n"><span class="wt-add">+' + n0(f.add) + '</span> <span class="wt-del">−' + n0(f.del) + '</span></span></summary>' +
374
562
  (what ? '<p class="faint wt-g-p">' + esc(what) + '</p>' : '<div class="wt-snip-d" data-file="' + i + '"></div>' + (f.clipped ? '<p class="faint wt-g-p">… too big to show all of it here</p>' : '')) + '</details>'
375
563
  }).join('') + '</div>'
@@ -393,28 +581,41 @@ export function filesListHtml(data, { esc, icon, mobile = false }) {
393
581
  const add = files.reduce((a, f) => a + (f.add || 0), 0), del = files.reduce((a, f) => a + (f.del || 0), 0)
394
582
  const head = 'Files <span class="faint">' + n0(files.length) + '</span><span class="wt-fl-tot"><span class="wt-add">+' + n0(add) + '</span><span class="wt-del">−' + n0(del) + '</span></span>'
395
583
  if (mobile) return '<details class="wt-fl-m"><summary>' + icon('chevron-right', 'ic wt-g-chev') + head + '</summary><nav class="wt-fm wt-fl" aria-label="Files in this change">' + rows + '</nav></details>'
396
- return '<nav class="wt-fm wt-fl wt-side" aria-label="Files in this change"><div class="wt-fm-h">' + head + '</div>' + rows + '</nav>'
584
+ return '<nav class="wt-fm wt-fl wt-side" aria-label="Files in this change"><div class="wt-fm-h">' + head + '<button type="button" class="wt-railt" data-action="wt-rail" aria-expanded="true" aria-label="Hide the file list" data-tip="Hide the file list ([)">' + icon('panel-left-close') + '</button>' + '</div>' + rows + '</nav>'
397
585
  }
398
586
 
399
587
  // Draw the snippets (diff-view.js's renderDiff) and put each callout under its
400
588
  // line, the line marked in the gutter. Drawn nodes are kept per answer, so a
401
589
  // redraw moves them back in. -> how many were drawn.
402
590
  const drawnFor = new WeakMap()
403
- export function hydrateWalkthrough(root, data, { renderDiff, esc, icon }) {
591
+ // 2026-10-30 split: the side-by-side drawing (diff-view.js renderSplit) where asked and
592
+ // the screen is wide; each drawn patch knows its key (data-wtk) and each row its index
593
+ // into parsePatch's rows (data-ri), which is what a picked line is (ui/walk-lines.js)
594
+ export function hydrateWalkthrough(root, data, { renderDiff, renderSplit = null, split = false, esc, icon }) {
404
595
  if (!root || !data) return 0
405
596
  let cache = drawnFor.get(data)
406
597
  if (!cache) { cache = new Map(); drawnFor.set(data, cache) }
407
598
  const doc = root.ownerDocument
599
+ const sp = !!(split && renderSplit)
408
600
  const draw = (key, patch, label, callouts) => {
409
- let node = cache.get(key)
601
+ const ck = (sp ? 'S|' : 'U|') + key
602
+ let node = cache.get(ck)
410
603
  if (!node) {
411
- node = renderDiff(doc, patch, { label })
604
+ if (sp) node = renderSplit(doc, patch, { label })
605
+ else {
606
+ node = renderDiff(doc, patch, { label })
607
+ ;[...node.children].forEach((r, i) => { r.dataset.ri = String(i) }) // one child per parsed row, in order
608
+ }
412
609
  node.classList.add('wt-gd')
610
+ node.dataset.wtk = key
413
611
  for (const g of node.querySelectorAll('.gd-gap')) g.remove()
414
612
  for (const c of callouts || []) {
415
- const rows = [...node.querySelectorAll('.gd-row')]
416
613
  const sel = c.side === 'old' ? '.gd-o' : '.gd-n'
417
- const row = rows.find((r) => { const x = r.querySelector(sel); return x && x.textContent === String(c.line) })
614
+ let row = null
615
+ if (sp) {
616
+ const half = [...node.querySelectorAll('.gd-half')].find((x) => { const g = x.querySelector(sel); return g && g.textContent === String(c.line) })
617
+ if (half) { half.classList.add('wt-mark', 'wt-mark-' + c.kind); row = half.parentNode }
618
+ } else row = [...node.querySelectorAll('.gd-row')].find((r) => { const x = r.querySelector(sel); return x && x.textContent === String(c.line) })
418
619
  const box = doc.createElement('div')
419
620
  box.innerHTML = calloutHtml(c, { esc, icon })
420
621
  const el = box.firstChild
@@ -427,7 +628,7 @@ export function hydrateWalkthrough(root, data, { renderDiff, esc, icon }) {
427
628
  after.after(el)
428
629
  } else node.appendChild(el)
429
630
  }
430
- cache.set(key, node)
631
+ cache.set(ck, node)
431
632
  }
432
633
  return node
433
634
  }
@@ -435,7 +636,7 @@ export function hydrateWalkthrough(root, data, { renderDiff, esc, icon }) {
435
636
  for (const s of root.querySelectorAll('.wt-bk-s[data-flex]')) s.style.flex = s.getAttribute('data-flex') + ' 1 0'
436
637
  let k = 0
437
638
  for (const slot of root.querySelectorAll('[data-snip]')) {
438
- if (slot.firstChild) continue
639
+ if (slot.firstChild || (slot.closest('details.wt-sf') && !slot.closest('details.wt-sf[open]'))) continue // a folded file draws when it opens
439
640
  const [sid, i] = slot.getAttribute('data-snip').split(':')
440
641
  const st = (data.steps || []).find((x) => x.id === sid)
441
642
  const sn = st && st.snippets[+i]
@@ -454,6 +655,20 @@ export function hydrateWalkthrough(root, data, { renderDiff, esc, icon }) {
454
655
  }
455
656
  return k
456
657
  }
658
+ // A drawn patch's key -> what a pick in it reads: { patch, file, label, step, stepTitle }
659
+ export function patchFor(data, key) {
660
+ const k = String(key || '')
661
+ if (!data) return null
662
+ if (k.startsWith('s:')) {
663
+ const [, sid, i] = k.split(':')
664
+ const st = (data.steps || []).find((x) => x.id === sid)
665
+ const sn = st && st.snippets[+i]
666
+ return sn ? { patch: sn.patch, file: sn.file, label: pathLabel(sn.file, data), step: sid, stepTitle: 'step ' + st.n + ', ' + mdPlain(st.title) } : null
667
+ }
668
+ if (k.startsWith('f:')) { const p = k.slice(2); const f = (data.files || []).find((x) => x.path === p); return f && f.lines ? { patch: f.lines, file: p, label: pathLabel(p, data), step: null, stepTitle: '' } : null }
669
+ if (k.startsWith('g:')) { const g = (data.groups || []).find((x) => x.kind === k.slice(2)); return g && g.example ? { patch: g.example.patch, file: g.example.file, label: pathLabel(g.example.file, data), step: null, stepTitle: '' } : null }
670
+ return null
671
+ }
457
672
 
458
673
  // ---------------------------------------------------------------- exports
459
674
 
@@ -466,13 +681,20 @@ export function toMarkdown(data, { title = '' } = {}) {
466
681
  L.push('')
467
682
  if (data.headline) L.push('**' + data.headline + '**')
468
683
  if (data.summary) { L.push(''); L.push(data.summary) }
469
- for (const st of data.steps || []) {
470
- L.push(''); L.push('### ' + st.n + '. ' + st.title + ' (' + st.tag + ')'); L.push('')
684
+ // 2026-10-29 several repos: a heading per repo, its steps under it
685
+ const repos = walkRepos(data)
686
+ const unr = unreadableLines(data)
687
+ if (repos.length) { L.push(''); L.push('_' + plural(repos.length, 'repo') + ': ' + repos.map((r) => r.label).join(', ') + '_') }
688
+ for (const l of unr) { L.push(''); L.push('_' + l + '_') }
689
+ let lastRepo = null
690
+ for (const st of repos.length ? readingOrder(data.steps, data) : data.steps || []) {
691
+ if (repos.length && st.repo && st.repo !== lastRepo) { lastRepo = st.repo; const r = repos.find((x) => x.label === st.repo); L.push(''); L.push('### ' + st.repo + (r ? ' (' + repoHeadWords(r, data.steps || []) + ')' : '')) }
692
+ L.push(''); L.push((repos.length ? '#### ' : '### ') + st.n + '. ' + st.title + ' (' + st.tag + ')'); L.push('')
471
693
  if (st.why) L.push(st.why)
472
- for (const c of st.callouts || []) { L.push(''); L.push('> **' + (CALLOUT[c.kind] || CALLOUT.look).word + '** (`' + c.file + ':' + c.line + '`): ' + c.text) }
694
+ for (const c of st.callouts || []) { L.push(''); L.push('> **' + (CALLOUT[c.kind] || CALLOUT.look).word + '** (`' + pathLabel(c.file, data) + ':' + c.line + '`): ' + c.text) }
473
695
  for (const sn of st.snippets || []) {
474
696
  const fence = /```/.test(sn.patch) ? '~~~~' : '```'
475
- L.push(''); L.push('<details><summary>' + escH(sn.file) + '</summary>'); L.push(''); L.push(fence + 'diff'); L.push(String(sn.patch).replace(/\n$/, '')); L.push(fence); L.push(''); L.push('</details>')
697
+ L.push(''); L.push('<details><summary>' + escH(pathLabel(sn.file, data)) + '</summary>'); L.push(''); L.push(fence + 'diff'); L.push(String(sn.patch).replace(/\n$/, '')); L.push(fence); L.push(''); L.push('</details>')
476
698
  }
477
699
  }
478
700
  const groups = data.groups || []
@@ -495,11 +717,11 @@ export function staticDiffHtml(patch, callouts = []) {
495
717
  if (r.k === 'note') { h += '<div class="r h"><span class="g"></span><span class="g"></span><span class="m"></span><span class="c">' + escH(r.t) + '</span></div>'; continue }
496
718
  const cs = callouts.filter((c) => (c.side === 'old' ? r.o : r.n) === c.line && (c.side === 'old' ? r.k !== 'add' : r.k !== 'del'))
497
719
  h += '<div class="r ' + r.k + (cs.length ? ' mark' : '') + '"><span class="g">' + (r.o == null ? '' : r.o) + '</span><span class="g">' + (r.n == null ? '' : r.n) + '</span><span class="m">' + mk[r.k] + '</span><span class="c">' + (escH(r.t) || '&#8203;') + '</span></div>'
498
- for (const c of cs) h += '<div class="co ' + escH(c.kind) + '"><b>' + escH((CALLOUT[c.kind] || CALLOUT.look).word) + '</b> ' + escH(c.text) + '</div>'
720
+ for (const c of cs) h += '<div class="co ' + escH(c.kind) + '"><b>' + escH((CALLOUT[c.kind] || CALLOUT.look).word) + '</b> ' + walkInlineExport(c.text) + '</div>'
499
721
  }
500
722
  return h + '</div>'
501
723
  }
502
- const EXPORT_CSS = 'body{margin:0;background:#fff;color:#1f2328;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}main{max-width:900px;margin:0 auto;padding:32px 20px 64px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin:32px 0 6px}.f{color:#59636e;font-size:13px}.sum{font-size:15px}.tag{display:inline-flex;align-items:center;height:20px;padding:0 8px;border:1px solid #d1d9e0;border-radius:999px;font-size:12px;line-height:1;margin-left:8px;vertical-align:2px}.tag.core{font-weight:700;border-width:2px}.fl{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;color:#59636e;margin:10px 0 4px}.d{border:1px solid #d1d9e0;border-radius:6px;overflow:hidden;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.r{display:grid;grid-template-columns:44px 44px 18px 1fr}.g{color:#59636e;text-align:right;padding:0 6px;user-select:none}.m{text-align:center}.c{white-space:pre-wrap;word-break:break-word;padding-right:8px}.r.h{background:#ddf4ff;color:#59636e}.r.add{background:#e6ffec}.r.add .g{background:#ccffd8}.r.del{background:#ffebe9}.r.del .g{background:#ffd7d5}.r.mark .g:first-child{box-shadow:inset 3px 0 0 #1f2328}.co{margin:4px 8px 6px 106px;padding:6px 10px;border:1px solid #1f2328;border-radius:6px;font:13px/1.45 -apple-system,system-ui,sans-serif;white-space:normal}.co.assumption{border-style:dotted}.co.untested{border-style:dashed}.co b{margin-right:6px}.loose .co{margin-left:0}details{margin:8px 0}summary{cursor:pointer}ul{padding-left:20px}@media (prefers-color-scheme:dark){body{background:#0d1117;color:#e6edf3}.f,.g,.fl{color:#9198a1}.d,.tag{border-color:#3d444d}.r.h{background:#121d2f}.r.add{background:#12261e}.r.add .g{background:#1b4721}.r.del{background:#25171c}.r.del .g{background:#542426}.co,.r.mark .g:first-child{border-color:#e6edf3;box-shadow:inset 3px 0 0 #e6edf3}}'
724
+ const EXPORT_CSS = 'body{margin:0;background:#fff;color:#1f2328;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}main{max-width:900px;margin:0 auto;padding:32px 20px 64px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin:32px 0 6px}.f{color:#59636e;font-size:13px}.sum{font-size:15px}.tag{display:inline-flex;align-items:center;height:20px;padding:0 8px;border:1px solid #d1d9e0;border-radius:999px;font-size:12px;line-height:1;margin-left:8px;vertical-align:2px}.tag.core{font-weight:700;border-width:2px}.fl{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;color:#59636e;margin:10px 0 4px}.d{border:1px solid #d1d9e0;border-radius:6px;overflow:hidden;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.r{display:grid;grid-template-columns:44px 44px 18px 1fr}.g{color:#59636e;text-align:right;padding:0 6px;user-select:none}.m{text-align:center}.c{white-space:pre-wrap;word-break:break-word;padding-right:8px}.r.h{background:#ddf4ff;color:#59636e}.r.add{background:#e6ffec}.r.add .g{background:#ccffd8}.r.del{background:#ffebe9}.r.del .g{background:#ffd7d5}.r.mark .g:first-child{box-shadow:inset 3px 0 0 #1f2328}.co{margin:4px 8px 6px 106px;padding:6px 10px;border:1px solid #1f2328;border-radius:6px;font:13px/1.45 -apple-system,system-ui,sans-serif;white-space:normal}.co.assumption{border-style:dotted}.co.untested{border-style:dashed}.co b{margin-right:6px}.loose .co{margin-left:0}details{margin:8px 0}summary{cursor:pointer}ul{padding-left:20px}code{font:.86em ui-monospace,SFMono-Regular,Menlo,monospace;background:#f6f8fa;border:1px solid #d1d9e0;border-radius:5px;padding:0 4px;overflow-wrap:break-word;word-break:normal;-webkit-box-decoration-break:clone;box-decoration-break:clone}h2 code{font-size:.84em}.why{margin:8px 0 12px;overflow-wrap:break-word}.why p{margin:0 0 8px}.why :is(ul,ol){margin:0 0 8px;padding-left:20px}.why li{margin:2px 0}.why h2,.why h3,.why h4,.why h5,.why h6{font-size:1em;margin:10px 0 4px}.why blockquote{margin:0 0 8px;padding-left:10px;border-left:3px solid #d1d9e0}.why pre{margin:0 0 8px;overflow-x:auto;background:#f6f8fa;border:1px solid #d1d9e0;border-radius:6px;padding:8px 10px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}.why pre code{background:none;border:0;padding:0;font-size:inherit;overflow-wrap:normal;word-break:normal}.co code{font-size:.84em}a{color:#0969da;overflow-wrap:anywhere}.u{overflow-wrap:anywhere}.tk-c{color:#59636e}.tk-s{color:#0a3069}.tk-k{color:#cf222e}.tk-n{color:#0550ae}.tk-add{color:#116329}.tk-del{color:#82071e}@media print{.why pre{white-space:pre-wrap}code{background:none}}@media (prefers-color-scheme:dark){body{background:#0d1117;color:#e6edf3}code,.why pre{background:#161b22;border-color:#3d444d}.why blockquote{border-color:#3d444d}a{color:#4493f8}.tk-c{color:#9198a1}.tk-s{color:#a5d6ff}.tk-k{color:#ff7b72}.tk-n{color:#79c0ff}.tk-add{color:#7ee787}.tk-del{color:#ffa198}.f,.g,.fl{color:#9198a1}.d,.tag{border-color:#3d444d}.r.h{background:#121d2f}.r.add{background:#12261e}.r.add .g{background:#1b4721}.r.del{background:#25171c}.r.del .g{background:#542426}.co,.r.mark .g:first-child{border-color:#e6edf3;box-shadow:inset 3px 0 0 #e6edf3}}'
503
725
  // ONE self-contained file: inline CSS, the snippets baked in, no scripts, no links out.
504
726
  // 0.9.18 a deck, like the board's: an overview, then one slide per step, then Safe to
505
727
  // skip. Without a script: one radio per slide (hidden, still focusable, so ← / → move
@@ -507,19 +729,24 @@ const EXPORT_CSS = 'body{margin:0;background:#fff;color:#1f2328;font:15px/1.5 -a
507
729
  // Printed, every slide shows.
508
730
  export function toHtmlDoc(data, { title = '', repo = '', when = '' } = {}) {
509
731
  const steps = data.steps || []
510
- const order = [...steps.filter((x) => x.tag === 'Core'), ...steps.filter((x) => x.tag !== 'Core')]
732
+ const order = readingOrder(steps, data) // 2026-10-29 several repos: repo by repo
733
+ const repos = walkRepos(data)
511
734
  const groups = data.groups || []
512
735
  const slides = [{ t: 'Overview' }, ...order.map((st) => ({ t: st.title, st })), ...(groups.length ? [{ t: 'Safe to skip', skip: true }] : [])]
513
736
  const N = slides.length
514
737
  const nav = (i) => '<nav class="dn">' + (i > 0 ? '<label class="pv" for="s' + (i - 1) + '">‹ Prev</label>' : '<span class="pv off">‹ Prev</span>') +
515
- '<span class="dots">' + slides.map((s, j) => '<label for="s' + j + '"' + (j === i ? ' class="cur"' : '') + ' aria-label="' + escH(j === 0 ? 'Overview' : s.st ? 'Step ' + s.st.n + ': ' + s.t : s.t) + '">' + (j === 0 ? '◎' : s.st ? s.st.n : '…') + '</label>').join('') + '</span>' +
738
+ '<span class="dots">' + slides.map((s, j) => '<label for="s' + j + '"' + (j === i ? ' class="cur"' : '') + ' aria-label="' + escH(j === 0 ? 'Overview' : s.st ? 'Step ' + s.st.n + ': ' + mdPlain(s.t) : s.t) + '">' + (j === 0 ? '◎' : s.st ? s.st.n : '…') + '</label>').join('') + '</span>' +
516
739
  '<span class="ct">' + (i + 1) + ' / ' + N + '</span>' + (i < N - 1 ? '<label class="nx" for="s' + (i + 1) + '">Next ›</label>' : '<span class="nx off">Next ›</span>') + '</nav>'
517
740
  let b = '<main><h1>' + escH(title ? 'Walkthrough: ' + title : 'Walkthrough') + '</h1><p class="f">' + escH([repo, when].filter(Boolean).join(' · ')) + '</p><div class="deck">'
518
- b += slides.map((s, i) => '<input type="radio" name="slide" class="slr" id="s' + i + '"' + (i === 0 ? ' checked' : '') + ' aria-label="' + escH('Slide ' + (i + 1) + ': ' + s.t) + '">').join('')
741
+ b += slides.map((s, i) => '<input type="radio" name="slide" class="slr" id="s' + i + '"' + (i === 0 ? ' checked' : '') + ' aria-label="' + escH('Slide ' + (i + 1) + ': ' + mdPlain(s.t)) + '">').join('')
519
742
  b += '<div class="pb"><i></i></div>'
520
743
  // the overview: the summary and the reading order
521
- let o = '<p class="sum"><b>' + escH(data.headline || '') + '</b></p>' + (data.summary ? '<p class="sum">' + escH(data.summary) + '</p>' : '')
522
- o += '<h2>Reading order</h2><ol class="ag">' + slides.map((s, i) => i === 0 ? '' : '<li><label for="s' + i + '">' + (s.st ? s.st.n + '. ' : '') + escH(s.t) + (s.st ? ' <span class="tag ' + (s.st.tag === 'Core' ? 'core' : '') + '">' + escH(s.st.tag) + '</span>' : '') + '</label></li>').join('') + '</ol>'
744
+ let o = '<p class="sum"><b>' + escH(data.headline || '') + '</b></p>' + (data.summary ? '<p class="sum">' + walkInlineExport(data.summary) + '</p>' : '')
745
+ if (repos.length) o += '<p class="f">' + plural(repos.length, 'repo') + ': ' + repos.map((r) => escH(r.label)).join(', ') + '</p>'
746
+ for (const l of unreadableLines(data)) o += '<p class="f">' + escH(l) + '</p>'
747
+ let agRepo = null
748
+ const agHead = (s) => { if (!repos.length || !s.st || !s.st.repo || s.st.repo === agRepo) return ''; agRepo = s.st.repo; const r = repos.find((x) => x.label === s.st.repo); return '<li class="rh"><h3>' + escH(s.st.repo) + (r ? ' <span class="f">· ' + repoHeadWords(r, steps) + '</span>' : '') + '</h3></li>' }
749
+ o += '<h2>Reading order</h2><ol class="ag">' + slides.map((s, i) => i === 0 ? '' : agHead(s) + '<li><label for="s' + i + '">' + (s.st ? s.st.n + '. ' : '') + walkInline(s.t) + (s.st ? ' <span class="tag ' + (s.st.tag === 'Core' ? 'core' : '') + '">' + escH(s.st.tag) + '</span>' : '') + '</label></li>').join('') + '</ol>'
523
750
  if (data.coverage) o += '<p class="f">Every changed line is in a step or a group: ' + n0(data.coverage.covered) + ' of ' + n0(data.coverage.total) + '.</p>'
524
751
  b += '<section class="sl" data-i="0">' + o + nav(0) + '</section>'
525
752
  slides.forEach((s, i) => {
@@ -527,21 +754,22 @@ export function toHtmlDoc(data, { title = '', repo = '', when = '' } = {}) {
527
754
  let x = ''
528
755
  if (s.st) {
529
756
  const st = s.st
530
- x += '<h2>' + st.n + '. ' + escH(st.title) + '<span class="tag ' + (st.tag === 'Core' ? 'core' : '') + '">' + escH(st.tag) + '</span></h2>' + (st.why ? '<p>' + escH(st.why) + '</p>' : '')
757
+ if (repos.length && st.repo) { const r = repos.find((y) => y.label === st.repo); x += '<h3 class="rp">' + escH(st.repo) + (r ? ' <span class="f">· ' + repoHeadWords(r, steps) + '</span>' : '') + '</h3>' } // 2026-10-29 the step's repo
758
+ x += '<h2>' + st.n + '. ' + walkInline(st.title) + '<span class="tag ' + (st.tag === 'Core' ? 'core' : '') + '">' + escH(st.tag) + '</span></h2>' + (st.why ? '<div class="why">' + walkBlock(st.why, { exported: true }) + '</div>' : '')
531
759
  const drawn = new Set()
532
760
  for (const sn of st.snippets || []) {
533
761
  const cs = (st.callouts || []).filter((c) => c.hunk === sn.hunk)
534
762
  cs.forEach((c) => drawn.add(c))
535
- x += '<div class="fl">' + escH(sn.file) + '</div>' + staticDiffHtml(sn.patch, cs)
763
+ x += '<div class="fl">' + escH(pathLabel(sn.file, data)) + '</div>' + staticDiffHtml(sn.patch, cs)
536
764
  }
537
765
  const loose = (st.callouts || []).filter((c) => !drawn.has(c))
538
- if (loose.length) x += '<div class="loose">' + loose.map((c) => '<div class="co ' + escH(c.kind) + '"><b>' + escH((CALLOUT[c.kind] || CALLOUT.look).word) + '</b> ' + escH(c.text) + '</div>').join('') + '</div>'
766
+ if (loose.length) x += '<div class="loose">' + loose.map((c) => '<div class="co ' + escH(c.kind) + '"><b>' + escH((CALLOUT[c.kind] || CALLOUT.look).word) + '</b> ' + walkInlineExport(c.text) + '</div>').join('') + '</div>'
539
767
  } else {
540
768
  x += '<h2>Safe to skip</h2>'
541
769
  for (const g of groups) {
542
770
  x += '<details><summary><b>' + escH(g.title) + '</b>' + (g.note ? ' · ' + escH(g.note) : '') + ' · ' + plural(g.files.length + (g.kind === 'repeated' ? 1 : 0), 'file') + '</summary>'
543
- if (g.kind === 'repeated' && g.example) x += '<div class="fl">' + escH(g.example.file) + '</div>' + staticDiffHtml(g.example.patch)
544
- x += '<ul>' + g.files.map((f) => '<li><code>' + escH(f.path) + '</code> · ' + plural(f.lines, 'line') + '</li>').join('') + '</ul></details>'
771
+ if (g.kind === 'repeated' && g.example) x += '<div class="fl">' + escH(pathLabel(g.example.file, data)) + '</div>' + staticDiffHtml(g.example.patch)
772
+ x += '<ul>' + g.files.map((f) => '<li><code>' + escH(pathLabel(f.path, data)) + '</code> · ' + plural(f.lines, 'line') + '</li>').join('') + '</ul></details>'
545
773
  }
546
774
  }
547
775
  b += '<section class="sl" data-i="' + i + '">' + x + nav(i) + '</section>'
@@ -551,7 +779,7 @@ export function toHtmlDoc(data, { title = '', repo = '', when = '' } = {}) {
551
779
  const deckCss = '.slr{position:absolute;opacity:0;width:1px;height:1px;margin:0;pointer-events:none}.sl{display:none}.pb{position:sticky;top:0;height:3px;background:#eaeef2;margin:0 0 16px}.pb i{display:block;height:100%;background:#57606a;width:0}' +
552
780
  '.dn{display:flex;align-items:center;gap:8px;margin:28px 0 0;padding:10px 0 0;border-top:1px solid #d0d7de;font-size:14px}.dn label{cursor:pointer;user-select:none}.pv,.nx{padding:6px 10px;border:1px solid #d0d7de;border-radius:8px}.off{opacity:.35}.ct{color:#57606a;font-size:13px}' +
553
781
  '.dots{flex:1;display:flex;gap:4px;overflow-x:auto;justify-content:center}.dots label{flex:none;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border:1.5px solid #d0d7de;border-radius:50%;font-size:12px;color:#57606a}.dots label.cur{background:#1f2328;border-color:#1f2328;color:#fff}' +
554
- '.ag{padding-left:0;list-style:none}.ag li{margin:4px 0}.ag label{display:block;padding:8px 10px;border:1px solid #d0d7de;border-radius:8px;cursor:pointer}.slr:focus-visible~.pb{outline:2px solid #0969da}' +
782
+ '.ag{padding-left:0;list-style:none}.ag li{margin:4px 0}.ag li.rh h3{font-size:14px;margin:14px 0 2px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.rp{font:600 13px ui-monospace,SFMono-Regular,Menlo,monospace;margin:0 0 2px}.ag label{display:block;padding:8px 10px;border:1px solid #d0d7de;border-radius:8px;cursor:pointer}.slr:focus-visible~.pb{outline:2px solid #0969da}' +
555
783
  slides.map((_, i) => '#s' + i + ':checked~.sl[data-i="' + i + '"]{display:block}#s' + i + ':checked~.pb i{width:' + (Math.round(((i + 1) / N) * 1000) / 10) + '%}').join('') +
556
784
  '@media print{.sl{display:block!important;break-after:page}.dn,.pb{display:none}}@media (prefers-color-scheme:dark){.dots label.cur{background:#e6edf3;color:#0d1117}}'
557
785
  return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'"><title>' + escH(title ? 'Walkthrough: ' + title : 'Walkthrough') + '</title><style>' + EXPORT_CSS + deckCss + '</style></head><body>' + b + '</body></html>\n'