@leftoff/cli 0.9.17-beta.9 → 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/CHANGELOG.md +19 -1
- package/bin/leftoff.js +484 -474
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +144 -2
- package/ui/app.js +332 -18
- package/ui/back-line.js +5 -2
- package/ui/diff-view.js +71 -10
- package/ui/icons.js +4 -0
- package/ui/recap-line.js +5 -2
- package/ui/semdiff.js +282 -54
- package/ui/session-catchup.js +5 -1
- package/ui/walk-lines.js +119 -0
- package/ui/walk-md.js +40 -0
- package/ui/walk-pop.js +24 -5
- package/ui/walk-spots.js +89 -0
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">' +
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
const
|
|
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-
|
|
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 ? '<
|
|
88
|
-
const
|
|
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">' +
|
|
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
|
-
|
|
273
|
-
|
|
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 =
|
|
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">' +
|
|
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
|
-
|
|
332
|
-
|
|
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('&', '&') + '" data-tip="' + (st.last ? REVFINISH_TIP : REVNEXT_TIP) + '"' + (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>'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
601
|
+
const ck = (sp ? 'S|' : 'U|') + key
|
|
602
|
+
let node = cache.get(ck)
|
|
410
603
|
if (!node) {
|
|
411
|
-
node =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
470
|
-
|
|
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) || '​') + '</span></div>'
|
|
498
|
-
for (const c of cs) h += '<div class="co ' + escH(c.kind) + '"><b>' + escH((CALLOUT[c.kind] || CALLOUT.look).word) + '</b> ' +
|
|
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 =
|
|
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">' +
|
|
522
|
-
o += '<
|
|
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
|
-
|
|
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> ' +
|
|
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'
|