@leftoff/cli 0.9.18-beta.5 → 0.9.18-beta.6
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 +5 -1
- package/bin/leftoff.js +563 -535
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +163 -7
- package/mod/hooks/register.js +25 -2
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +59 -0
- package/ui/app.js +136 -1
- package/ui/ask.js +4 -2
- package/ui/decisions.js +133 -0
- package/ui/semdiff.js +23 -4
package/ui/ask.js
CHANGED
|
@@ -19,7 +19,7 @@ import { glyphState, glyphHtml } from './sglyph.js' // 0.9.14: the status glyph,
|
|
|
19
19
|
export const projectHref = (token) => '#/project/' + encodeURIComponent(String(token || ''))
|
|
20
20
|
export const threadHref = (id, machine = null) => '#/ask/' + encodeURIComponent(String(id || '')) + (machine ? '?m=' + encodeURIComponent(machine) : '')
|
|
21
21
|
|
|
22
|
-
const TAG = /\[((?:s|cp)\d{1,3}|c:[0-9a-f]{7}|d:\d{4}-\d{2}-\d{2})\]/g
|
|
22
|
+
const TAG = /\[((?:s|cp)\d{1,3}|c:[0-9a-f]{7}|d:\d{4}-\d{2}-\d{2}|dec:D-[0-9a-f]{4,12})\]/g
|
|
23
23
|
|
|
24
24
|
// Where a citation chip goes: a session, a checkpoint on its session page,
|
|
25
25
|
// a digest day; a commit has no page here (its sha is copied).
|
|
@@ -28,15 +28,17 @@ export function chipHref(c) {
|
|
|
28
28
|
if (c.kind === 'session' && c.sessionId) return '#/s/' + encodeURIComponent(c.sessionId)
|
|
29
29
|
if (c.kind === 'checkpoint' && c.sessionId) return '#/s/' + encodeURIComponent(c.sessionId) + (c.cpId ? '?cp=' + encodeURIComponent(c.cpId) : '')
|
|
30
30
|
if (c.kind === 'digest' && c.date) return '#/digest/' + encodeURIComponent(c.date)
|
|
31
|
+
if (c.kind === 'decision' && c.repo && c.id) return '#/decisions/' + String(c.repo).replace(/[^\w:./@-]/g, encodeURIComponent) + '?d=' + encodeURIComponent(c.id) // 2026-11-03
|
|
31
32
|
return null
|
|
32
33
|
}
|
|
33
34
|
export function chipText(c) {
|
|
34
35
|
if (!c) return ''
|
|
35
36
|
if (c.kind === 'commit') return (c.short || String(c.sha || '').slice(0, 7)) + (c.label ? ' ' + c.label : '')
|
|
36
37
|
if (c.kind === 'digest') return 'Digest ' + (c.date || '')
|
|
38
|
+
if (c.kind === 'decision') return (c.id || 'Decision') + (c.label ? ' ' + c.label : '')
|
|
37
39
|
return c.label || (c.kind === 'checkpoint' ? 'Checkpoint' : 'Session')
|
|
38
40
|
}
|
|
39
|
-
const KIND_ICON = { session: 'history', checkpoint: 'check', commit: 'copy', digest: 'newspaper' }
|
|
41
|
+
const KIND_ICON = { session: 'history', checkpoint: 'check', commit: 'copy', digest: 'newspaper', decision: 'route' }
|
|
40
42
|
|
|
41
43
|
// One chip. Its words are filled in as text (data-qt).
|
|
42
44
|
export function chipHtml(c, key, { esc, icon = () => '', n = null } = {}) {
|
package/ui/decisions.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Decision memory (2026-11-03): pure builders for the board. A checkpoint's
|
|
2
|
+
// Decided list (the session panel), a repo's or a project's decision log (the
|
|
3
|
+
// timeline), and the decision chips a walkthrough step cites. app.js wires
|
|
4
|
+
// them; unit-tested in test/unit/decisions-ui-1103.test.mjs.
|
|
5
|
+
|
|
6
|
+
const escD = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
7
|
+
const day = (iso) => String(iso || '').slice(0, 10)
|
|
8
|
+
export const SOURCE_WORDS = { asked: 'You answered', plan: 'Plan approved', correction: 'Your correction', agent: 'Agent chose' }
|
|
9
|
+
// Status as a shape and a word, never color alone.
|
|
10
|
+
export const STATUS_SHAPE = { proposed: '◌', accepted: '●', rejected: '✕', superseded: '↻' }
|
|
11
|
+
export const STATUS_WORD = { proposed: 'Proposed', accepted: 'Accepted', rejected: 'Rejected', superseded: 'Superseded' }
|
|
12
|
+
|
|
13
|
+
// A checkpoint's decisions, under the newest checkpoint in the session panel.
|
|
14
|
+
// log: the session's decisions from its repo's log, when loaded ({ <text norm>: { id, status } }).
|
|
15
|
+
export function decidedListHtml(decided, { esc = escD, log = null, repoId = null } = {}) {
|
|
16
|
+
const list = Array.isArray(decided) ? decided : []
|
|
17
|
+
if (!list.length) return ''
|
|
18
|
+
const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
|
|
19
|
+
const rows = list.map((d) => {
|
|
20
|
+
const hit = log && log[norm(d.text)]
|
|
21
|
+
const st = hit ? '<span class="dec-st dec-st-' + esc(hit.status) + '">' + STATUS_SHAPE[hit.status] + ' ' + esc(STATUS_WORD[hit.status]) + '</span>' : ''
|
|
22
|
+
return '<li class="dec-row">' + (hit ? '<span class="dec-id">' + esc(hit.id) + '</span> ' : '') + '<span class="dec-text">' + esc(d.text) + '</span>' + st +
|
|
23
|
+
(d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
|
|
24
|
+
(d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
|
|
25
|
+
'<div class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</div></li>'
|
|
26
|
+
}).join('')
|
|
27
|
+
const link = repoId ? '<a class="linkish dec-open" href="' + esc(decisionsHref(repoId)) + '">Decision log →</a>' : ''
|
|
28
|
+
return '<ul class="dec-list">' + rows + '</ul>' + link
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ------------------------------------------------------------ the decision log (timeline)
|
|
32
|
+
|
|
33
|
+
// A route's scope: "#/decisions/gh:acme/web" (a repo) or "#/decisions/project:acme" (a project's repos, merged).
|
|
34
|
+
export function decisionScope(arg) {
|
|
35
|
+
const a = String(arg || '')
|
|
36
|
+
if (a.startsWith('project:')) return { project: a.slice(8), repo: null, api: 'project=' + encodeURIComponent(a.slice(8)) }
|
|
37
|
+
return { project: null, repo: a, api: 'repo=' + encodeURIComponent(a) }
|
|
38
|
+
}
|
|
39
|
+
export const decisionsHref = (scope) => '#/decisions/' + encodeURIComponent(scope).replace(/%3A/gi, ':').replace(/%2F/gi, '/')
|
|
40
|
+
|
|
41
|
+
// What the timeline shows: current decisions (accepted), oldest first, unless you asked for all of them
|
|
42
|
+
// (superseded and rejected too). Proposed ones are always above, to review.
|
|
43
|
+
export function timelineRows(decisions, { all = false } = {}) {
|
|
44
|
+
const list = Array.isArray(decisions) ? decisions : []
|
|
45
|
+
return {
|
|
46
|
+
review: list.filter((d) => d.status === 'proposed'),
|
|
47
|
+
rows: list.filter((d) => d.status === 'accepted' || (all && (d.status === 'superseded' || d.status === 'rejected'))),
|
|
48
|
+
hidden: list.filter((d) => d.status === 'superseded' || d.status === 'rejected').length,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sessionBit(d, esc) {
|
|
53
|
+
const s = d.session
|
|
54
|
+
if (!s || !s.id) return ''
|
|
55
|
+
const title = s.title || 'its session'
|
|
56
|
+
return '<a class="linkish dec-sess" href="#/s/' + esc(encodeURIComponent(s.id)) + '">From ' + esc(title) + '</a>' +
|
|
57
|
+
(s.pr && s.pr.url && /^https:\/\//.test(s.pr.url) ? ' · <a class="linkish dec-pr" href="' + esc(s.pr.url) + '" target="_blank" rel="noopener">PR' + (s.pr.number ? ' #' + esc(s.pr.number) : '') + '</a>' : '')
|
|
58
|
+
}
|
|
59
|
+
function chainBit(d, byId, esc) {
|
|
60
|
+
const out = []
|
|
61
|
+
for (const id of d.supersedes || []) { const o = byId.get(id); out.push('<span class="dec-chain">Replaces <button type="button" class="dec-chip" data-action="dec-show" data-id="' + esc(id) + '">' + esc(id) + '</button>' + (o ? ' (' + esc(day(o.createdAt)) + ')' : '') + '</span>') }
|
|
62
|
+
if (d.supersededBy) out.push('<span class="dec-chain">Superseded by <button type="button" class="dec-chip" data-action="dec-show" data-id="' + esc(d.supersededBy) + '">' + esc(d.supersededBy) + '</button> on ' + esc(day(d.supersededAt)) + '</span>')
|
|
63
|
+
return out.length ? '<div class="dec-chains">' + out.join('') + '</div>' : ''
|
|
64
|
+
}
|
|
65
|
+
function statusBit(d, esc) {
|
|
66
|
+
return '<span class="dec-st dec-st-' + esc(d.status) + '"><span aria-hidden="true">' + STATUS_SHAPE[d.status] + '</span> ' + esc(STATUS_WORD[d.status]) + '</span>'
|
|
67
|
+
}
|
|
68
|
+
function editForm(d, esc) {
|
|
69
|
+
return '<form class="dec-edit" data-action="dec-save" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '">' +
|
|
70
|
+
'<label>Decision<input class="dec-in" name="text" maxlength="240" value="' + esc(d.text) + '" autocomplete="off"></label>' +
|
|
71
|
+
'<label>Why<input class="dec-in" name="why" maxlength="240" value="' + esc(d.why || '') + '" autocomplete="off"></label>' +
|
|
72
|
+
'<div class="dec-acts"><button type="submit" class="btn primary">Save</button><button type="button" class="btn" data-action="dec-cancel" data-id="' + esc(d.id) + '">Cancel</button></div></form>'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// One decision. may: you can change it (the board's usual gate); editing: its id is being edited.
|
|
76
|
+
export function decisionItemHtml(d, { esc = escD, byId = new Map(), may = true, editing = null, multi = false, review = false } = {}) {
|
|
77
|
+
const repo = multi && d.repoLabel ? '<span class="dec-repo">' + esc(d.repoLabel) + '</span>' : ''
|
|
78
|
+
const head = '<div class="dec-h"><span class="dec-date">' + esc(day(d.createdAt)) + '</span><span class="dec-id">' + esc(d.id) + '</span>' + repo + statusBit(d, esc) + '</div>'
|
|
79
|
+
if (editing === d.id) return '<li class="dec-item" id="dec-' + esc(d.id) + '">' + head + editForm(d, esc) + '</li>'
|
|
80
|
+
const sug = (d.suggests || []).map((id) => { const o = byId.get(id); return o ? '<div class="dec-sug"><span>Changes <button type="button" class="dec-chip" data-action="dec-show" data-id="' + esc(id) + '">' + esc(id) + '</button>: ' + esc(o.text) + '</span>' +
|
|
81
|
+
(may ? '<span class="dec-acts"><button type="button" class="btn" data-action="dec-link" data-id="' + esc(d.id) + '" data-to="' + esc(id) + '" data-repo="' + esc(d.repo) + '">Confirm it replaces ' + esc(id) + '</button><button type="button" class="btn" data-action="dec-act" data-act="dismiss" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '">Not related</button></span>' : '') + '</div>' : '' }).join('')
|
|
82
|
+
const btn = (act, label, cls = '') => '<button type="button" class="btn' + cls + '" data-action="dec-act" data-act="' + act + '" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '">' + label + '</button>'
|
|
83
|
+
const acts = !may ? '' : review ? '<div class="dec-acts">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject') + '<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(d.id) + '">Edit</button></div>'
|
|
84
|
+
: '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : '<button type="button" class="linkish" data-action="dec-edit" data-id="' + esc(d.id) + '">Edit</button>') + '</div>'
|
|
85
|
+
return '<li class="dec-item dec-' + esc(d.status) + '" id="dec-' + esc(d.id) + '">' + head +
|
|
86
|
+
'<div class="dec-text">' + esc(d.text) + (d.edited ? ' <span class="faint" data-tip="' + esc('Edited · was: ' + ((d.original && d.original.text) || '')) + '">(edited)</span>' : '') + '</div>' +
|
|
87
|
+
(d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
|
|
88
|
+
(d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
|
|
89
|
+
chainBit(d, byId, esc) + sug +
|
|
90
|
+
'<div class="dec-meta"><span class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</span>' + (sessionBit(d, esc) ? ' · ' + sessionBit(d, esc) : '') + '</div>' + acts + '</li>'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The storage choice (one per project). Off by default: the log stays in Leftoff's folder.
|
|
94
|
+
export function storageChoiceHtml(project, { esc = escD, may = true } = {}) {
|
|
95
|
+
if (!project) return '<p class="faint dec-local">This repo isn’t in a project, so its log stays in Leftoff only.</p>'
|
|
96
|
+
const on = !!project.inRepo
|
|
97
|
+
const opt = (v, label) => '<button type="button" class="sask-opt" data-action="dec-store" data-project="' + esc(project.id) + '" data-on="' + v + '" aria-pressed="' + (on === v) + '"' + (may ? '' : ' disabled') + '>' + label + '</button>'
|
|
98
|
+
return '<div class="dec-store"><div class="dec-store-h">Keep decision log in this repo</div>' +
|
|
99
|
+
'<div class="sask-scope dec-store-opts" role="group" aria-label="Keep decision log in this repo">' + opt(false, 'Leftoff only') + opt(true, 'Also in each repo') + '</div>' +
|
|
100
|
+
'<p class="faint">' + (on ? 'Accepted decisions are written to ' + esc(project.file || 'docs/DECISIONS.md') + ' in each repo of ' + esc(project.name) + ', between Leftoff’s markers, on a Mac with a checkout. Never staged or committed: you commit it or not.'
|
|
101
|
+
: 'The log stays in Leftoff’s folder, shared by your Macs. Turn this on to also write accepted decisions into each repo’s ' + esc(project.file || 'docs/DECISIONS.md') + '.') + '</p></div>'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The whole page. v: GET /v1/decisions. ui: { all, editing, may }
|
|
105
|
+
export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
|
|
106
|
+
const list = (v && v.decisions) || []
|
|
107
|
+
const byId = new Map(list.map((d) => [d.id, d]))
|
|
108
|
+
const multi = ((v && v.repos) || []).length > 1
|
|
109
|
+
const { review, rows, hidden } = timelineRows(list, { all: !!ui.all })
|
|
110
|
+
const sc = (v && v.scope) || {}
|
|
111
|
+
const name = sc.kind === 'project' ? sc.name : sc.label
|
|
112
|
+
const opts = { esc, byId, may: ui.may !== false, editing: ui.editing || null, multi }
|
|
113
|
+
let h = '<a class="back" href="' + (sc.kind === 'project' ? '#/project/' + esc(encodeURIComponent(sc.id)) : '#/') + '">← ' + (sc.kind === 'project' ? esc(name) : 'All sessions') + '</a>'
|
|
114
|
+
h += '<header class="dec-head"><h1>Decisions <span class="faint">· ' + esc(name || '') + '</span></h1>' +
|
|
115
|
+
(multi ? '<p class="faint">' + v.repos.map((r) => esc(r.label)).join(' · ') + '</p>' : '') + '</header>'
|
|
116
|
+
if (v && (v.project || sc.kind === 'repo')) h += '<section class="proj-sec">' + storageChoiceHtml(v.project, { esc, may: ui.may !== false && ui.trusted !== false }) + '</section>'
|
|
117
|
+
if (review.length) h += '<section class="proj-sec dec-review"><h2>To review <span class="count">' + review.length + '</span></h2><p class="faint">Proposed from checkpoints. Only accepted decisions reach new sessions, Ask and walkthroughs.</p><ul class="dec-tl">' + review.map((d) => decisionItemHtml(d, { ...opts, review: true })).join('') + '</ul></section>'
|
|
118
|
+
const ctl = '<div class="dec-ctl"><button type="button" class="sask-opt" data-action="dec-all" aria-pressed="' + !!ui.all + '">' + (ui.all ? 'Showing superseded and rejected' : 'Include superseded and rejected' + (hidden ? ' (' + hidden + ')' : '')) + '</button>' +
|
|
119
|
+
'<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button></div>'
|
|
120
|
+
h += '<section class="proj-sec dec-timeline"><h2>Timeline <span class="count">' + rows.length + '</span></h2>' + ctl +
|
|
121
|
+
(rows.length ? '<ol class="dec-tl">' + rows.map((d) => decisionItemHtml(d, opts)).join('') + '</ol>' : '<p class="faint">' + (review.length ? 'Nothing accepted yet.' : 'No decisions yet. They’re proposed here when a checkpoint sees one: a question you answered, a plan you approved, a correction.') + '</p>') + '</section>'
|
|
122
|
+
return '<div class="proj dec-page">' + h + '</div>'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The project page's short Decisions section: counts, the storage choice, the way in.
|
|
126
|
+
export function projectDecisionsHtml(v, { esc = escD, may = true, scopeArg = '' } = {}) {
|
|
127
|
+
if (!v) return ''
|
|
128
|
+
const list = v.decisions || []
|
|
129
|
+
const cur = list.filter((d) => d.status === 'accepted').length, rev = list.filter((d) => d.status === 'proposed').length
|
|
130
|
+
return '<section class="proj-sec dec-proj" id="projDecisions"><h2>Decisions <span class="count">' + cur + '</span></h2>' +
|
|
131
|
+
'<p>' + (cur ? cur + ' current' : 'None accepted yet') + (rev ? ' · <strong>' + rev + ' to review</strong>' : '') + ' · <a class="linkish" href="' + esc(decisionsHref(scopeArg)) + '">Open the timeline →</a></p>' +
|
|
132
|
+
storageChoiceHtml(v.project, { esc, may }) + '</section>'
|
|
133
|
+
}
|
package/ui/semdiff.js
CHANGED
|
@@ -216,7 +216,20 @@ export function alsoIn(st, g, shared) {
|
|
|
216
216
|
}
|
|
217
217
|
export const CHANGED_TIP = 'Its files changed since this walkthrough was written'
|
|
218
218
|
|
|
219
|
-
|
|
219
|
+
// 2026-11-03 decision memory: the accepted decisions a step cites, as small chips that open the decision with its chain
|
|
220
|
+
export function decisionsOf(data) { return new Map(((data && data.decisions) || []).map((d) => [d.id, d])) }
|
|
221
|
+
export function stepDecisionsHtml(st, data, { esc }) {
|
|
222
|
+
const ids = Array.isArray(st.decisions) ? st.decisions : []
|
|
223
|
+
if (!ids.length) return ''
|
|
224
|
+
const by = decisionsOf(data)
|
|
225
|
+
const gone = new Set((data && data.stale && data.stale.decisions) || [])
|
|
226
|
+
return '<div class="wt-decs" role="group" aria-label="Decisions behind this step">' + ids.map((id) => {
|
|
227
|
+
const d = by.get(id)
|
|
228
|
+
return '<button type="button" class="dec-chip wt-dec' + (gone.has(id) ? ' wt-dec-gone' : '') + '" data-action="wt-dec" data-id="' + esc(id) + '"' + (data && data.decisionRepo ? ' data-repo="' + esc(data.decisionRepo) + '"' : '') +
|
|
229
|
+
' data-tip="' + esc((gone.has(id) ? 'Changed since this walkthrough · ' : '') + (d ? d.text : 'Decision ' + id)) + '"><span class="wt-dec-id">' + esc(id) + '</span>' + (d ? '<span class="wt-dec-t">' + esc(d.text) + '</span>' : '') + (gone.has(id) ? '<span class="wt-dec-w"> · changed since</span>' : '') + '</button>'
|
|
230
|
+
}).join('') + '</div>'
|
|
231
|
+
}
|
|
232
|
+
function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, stat = null, shared = null, foldShared = true, data = null }) {
|
|
220
233
|
const core = st.tag === 'Core'
|
|
221
234
|
// 2026-10-29 several repos: the step's repo leads ("cube_cloud · src/x.ts"); one repo: the file names
|
|
222
235
|
const rp = st.repo ? [st.repo] : []
|
|
@@ -246,7 +259,7 @@ function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, s
|
|
|
246
259
|
: '<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>'
|
|
247
260
|
// callouts whose line isn't in a drawn snippet (folded code) still read here
|
|
248
261
|
const loose = !show && st.callouts.length ? '<div class="wt-cos">' + st.callouts.map((c) => calloutHtml(c, { esc, icon })).join('') + '</div>' : ''
|
|
249
|
-
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>'
|
|
262
|
+
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 + stepDecisionsHtml(st, data, { esc }) + loose + code + '</article>'
|
|
250
263
|
}
|
|
251
264
|
|
|
252
265
|
function groupHtml(g, { esc, icon, open, data = null }) {
|
|
@@ -389,6 +402,8 @@ function modeSegHtml(show, split) {
|
|
|
389
402
|
// "3 files changed since this walkthrough · Update"
|
|
390
403
|
export function staleWords(st) {
|
|
391
404
|
if (!st) return ''
|
|
405
|
+
// 2026-11-03 a decision it cites has changed (superseded, rejected or edited) since it was written
|
|
406
|
+
if (Array.isArray(st.decisions) && st.decisions.length && !st.count) return (st.decisions.length === 1 ? 'Decision ' + st.decisions[0] + ' has' : 'Decisions ' + st.decisions.join(', ') + ' have') + ' changed since this walkthrough'
|
|
392
407
|
if (st.count == null) return 'The change has moved on since this walkthrough'
|
|
393
408
|
if (!st.count) return 'The change has moved on since this walkthrough'
|
|
394
409
|
return st.count + (st.count === 1 ? ' file changed' : ' files changed') + ' since this walkthrough'
|
|
@@ -477,7 +492,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
|
|
|
477
492
|
const core = steps.filter((x) => x.tag === 'Core' && vis.has(x.id)), sup = steps.filter((x) => x.tag !== 'Core' && vis.has(x.id))
|
|
478
493
|
const bd = data.stats.breakdown || {}
|
|
479
494
|
const shared = sharedSnips(data), skip = skipFiles(data), stat = statusMap(data)
|
|
480
|
-
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, stat, shared })
|
|
495
|
+
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, stat, shared, data })
|
|
481
496
|
const repos = walkRepos(data)
|
|
482
497
|
// 2026-10-29 several repos: one section per repo (its header with counts), its steps in reading order
|
|
483
498
|
if (repos.length) {
|
|
@@ -565,7 +580,7 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
|
|
|
565
580
|
slides.forEach((s, i) => {
|
|
566
581
|
if (s.kind === 'step') {
|
|
567
582
|
const x = s.step
|
|
568
|
-
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), shared, foldShared: false /* a slide shows one step: the earlier one isn't on screen
|
|
583
|
+
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), shared, foldShared: false /* a slide shows one step: the earlier one isn't on screen */, data }), 'Step ' + x.n + ' of ' + steps.length + ': ' + mdPlain(x.title), ' data-step="' + esc(x.id) + '"')
|
|
569
584
|
} else if (s.kind === 'skip') {
|
|
570
585
|
const groups = data.groups || []
|
|
571
586
|
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)
|
|
@@ -808,6 +823,7 @@ export function toMarkdown(data, { title = '' } = {}) {
|
|
|
808
823
|
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 || []) + ')' : '')) }
|
|
809
824
|
L.push(''); L.push((repos.length ? '#### ' : '### ') + st.n + '. ' + st.title + ' (' + st.tag + ')'); L.push('')
|
|
810
825
|
if (st.why) L.push(st.why)
|
|
826
|
+
if (Array.isArray(st.decisions) && st.decisions.length) { const by = decisionsOf(data); L.push(''); L.push('Decisions: ' + st.decisions.map((id) => '`' + id + '`' + (by.get(id) ? ' ' + by.get(id).text : '')).join('; ')) } // 2026-11-03
|
|
811
827
|
for (const c of st.callouts || []) { L.push(''); L.push('> **' + (CALLOUT[c.kind] || CALLOUT.look).word + '** (`' + pathLabel(c.file, data) + ':' + c.line + '`): ' + c.text) }
|
|
812
828
|
for (const sn of st.snippets || []) {
|
|
813
829
|
const fence = /```/.test(sn.patch) ? '~~~~' : '```'
|
|
@@ -820,6 +836,7 @@ export function toMarkdown(data, { title = '' } = {}) {
|
|
|
820
836
|
for (const g of groups) L.push('- ' + g.title + (g.note ? ' (' + g.note + ')' : '') + ': ' + plural(g.files.length + (g.kind === 'repeated' ? 1 : 0), 'file') + ', ' + plural(g.lines, 'line'))
|
|
821
837
|
}
|
|
822
838
|
if (data.coverage) { L.push(''); L.push('_Every changed line is in a step or a group: ' + n0(data.coverage.covered) + ' of ' + n0(data.coverage.total) + '._') }
|
|
839
|
+
if (Array.isArray(data.decisions) && data.decisions.length) { L.push(''); L.push('### Decisions cited'); L.push(''); for (const d of data.decisions) L.push('- `' + d.id + '` ' + d.text + (d.why ? ' (' + d.why + ')' : '')) }
|
|
823
840
|
return L.join('\n') + '\n'
|
|
824
841
|
}
|
|
825
842
|
|
|
@@ -865,6 +882,7 @@ export function toHtmlDoc(data, { title = '', repo = '', when = '' } = {}) {
|
|
|
865
882
|
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>' }
|
|
866
883
|
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>'
|
|
867
884
|
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>'
|
|
885
|
+
if (Array.isArray(data.decisions) && data.decisions.length) o += '<h2>Decisions cited</h2><ul>' + data.decisions.map((d) => '<li><code>' + escH(d.id) + '</code> ' + escH(d.text) + (d.why ? ' <span class="f">(' + escH(d.why) + ')</span>' : '') + '</li>').join('') + '</ul>' // 2026-11-03
|
|
868
886
|
b += '<section class="sl" data-i="0">' + o + nav(0) + '</section>'
|
|
869
887
|
slides.forEach((s, i) => {
|
|
870
888
|
if (!i) return
|
|
@@ -873,6 +891,7 @@ export function toHtmlDoc(data, { title = '', repo = '', when = '' } = {}) {
|
|
|
873
891
|
const st = s.st
|
|
874
892
|
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
|
|
875
893
|
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>' : '')
|
|
894
|
+
if (Array.isArray(st.decisions) && st.decisions.length) { const by = decisionsOf(data); x += '<p class="f decs">Decisions: ' + st.decisions.map((id) => '<code>' + escH(id) + '</code>' + (by.get(id) ? ' ' + escH(by.get(id).text) : '')).join('; ') + '</p>' } // 2026-11-03
|
|
876
895
|
const drawn = new Set()
|
|
877
896
|
for (const sn of st.snippets || []) {
|
|
878
897
|
const cs = (st.callouts || []).filter((c) => c.hunk === sn.hunk)
|