@leftoff/cli 0.9.18-beta.5 → 0.9.18-beta.7
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 +12 -1
- package/bin/leftoff.js +599 -537
- package/docs/test-plan.md +43 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +172 -12
- package/mod/hooks/register.js +46 -2
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +132 -0
- package/ui/app.js +296 -13
- package/ui/ask.js +5 -3
- package/ui/decisions.js +186 -0
- package/ui/prs.js +15 -5
- package/ui/semdiff.js +31 -5
- package/ui/session-catchup.js +18 -2
- package/ui/summary-examples.js +1 -1
- package/ui/testplan.js +162 -0
- package/ui/viewport-fit.js +40 -0
package/ui/decisions.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
// 1k: a proposed one is reviewed right here (Accept / Reject / Edit, the log page's API and gate). trusted: false on a
|
|
16
|
+
// device that may look but not change the log (the buttons show disabled, with the reason). editing: the id being edited.
|
|
17
|
+
export function decidedListHtml(decided, { esc = escD, log = null, repoId = null, trusted = true, editing = null, may = true } = {}) {
|
|
18
|
+
const list = Array.isArray(decided) ? decided : []
|
|
19
|
+
if (!list.length) return ''
|
|
20
|
+
const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
|
|
21
|
+
const rows = list.map((d) => {
|
|
22
|
+
const hit = log && log[norm(d.text)]
|
|
23
|
+
const st = hit ? '<span class="dec-st dec-st-' + esc(hit.status) + '"><span aria-hidden="true">' + STATUS_SHAPE[hit.status] + '</span> ' + esc(STATUS_WORD[hit.status]) + '</span>' : ''
|
|
24
|
+
if (hit && editing === hit.id) return '<li class="dec-row" id="decp-' + esc(hit.id) + '"><span class="dec-id">' + esc(hit.id) + '</span>' + editForm({ ...hit, repo: hit.repo || repoId }, esc) + '</li>'
|
|
25
|
+
const rp = (hit && (hit.repo || repoId)) || ''
|
|
26
|
+
const btn = (act, label, cls) => '<button type="button" class="btn' + cls + '" data-action="dec-act" data-act="' + act + '" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>' + label + '</button>'
|
|
27
|
+
const acts = hit && hit.status === 'proposed' && may ? '<div class="dec-acts dec-acts-panel">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject', '') +
|
|
28
|
+
'<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>Edit</button></div>' : ''
|
|
29
|
+
return '<li class="dec-row"' + (hit ? ' id="decp-' + esc(hit.id) + '"' : '') + '>' + (hit ? '<span class="dec-id">' + esc(hit.id) + '</span> ' : '') + '<span class="dec-text">' + esc(hit && hit.edited ? hit.text : d.text) + '</span>' + st +
|
|
30
|
+
(d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
|
|
31
|
+
(d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
|
|
32
|
+
'<div class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</div>' + acts + '</li>'
|
|
33
|
+
}).join('')
|
|
34
|
+
const link = repoId ? '<a class="linkish dec-open" href="' + esc(decisionsHref(repoId)) + '">Decision log →</a>' : ''
|
|
35
|
+
return '<ul class="dec-list">' + rows + '</ul>' + link
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 1b: one quiet line in the session panel: the session started with its repo's decisions (shape plus
|
|
39
|
+
// words), or wasn't given them because Claude Code skipped Leftoff's SessionStart (the why in the tip).
|
|
40
|
+
export function decisionsGivenHtml(s, { esc = escD, when = (iso) => String(iso || '').slice(0, 16).replace('T', ' ') } = {}) {
|
|
41
|
+
if (!s) return ''
|
|
42
|
+
const g = s.decisionsGiven
|
|
43
|
+
if (g && g.n > 0) {
|
|
44
|
+
const words = g.via === 'command' ? 'Given ' + g.n + ' decision' + (g.n === 1 ? '' : 's') + ' with /cp-decisions' : 'Started with ' + g.n + ' decision' + (g.n === 1 ? '' : 's')
|
|
45
|
+
const tip = (g.via === 'command' ? 'You ran /cp-decisions' : 'Leftoff added the repo’s accepted decisions when the session started') + (g.at ? ' · ' + when(g.at) : '') + '. Only accepted decisions are given.'
|
|
46
|
+
return '<p class="dec-given" data-tip="' + esc(tip) + '"><span class="dec-given-i" aria-hidden="true">●</span> ' + esc(words) + '</p>'
|
|
47
|
+
}
|
|
48
|
+
const m = s.decisionsMissed
|
|
49
|
+
if (m && m.n > 0) return '<p class="dec-given dec-missed" data-tip="' + esc(m.tip || '') + '"><span class="dec-given-i" aria-hidden="true">◌</span> ' + esc(m.label || ('Not given its repo’s ' + m.n + ' decisions')) + ' <span class="dec-given-how">· type <code>/cp-decisions</code></span></p>'
|
|
50
|
+
return ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ------------------------------------------------------------ the decision log (timeline)
|
|
54
|
+
|
|
55
|
+
// A route's scope: "#/decisions/gh:acme/web" (a repo) or "#/decisions/project:acme" (a project's repos, merged).
|
|
56
|
+
export function decisionScope(arg) {
|
|
57
|
+
const a = String(arg || '')
|
|
58
|
+
if (a.startsWith('project:')) return { project: a.slice(8), repo: null, api: 'project=' + encodeURIComponent(a.slice(8)) }
|
|
59
|
+
return { project: null, repo: a, api: 'repo=' + encodeURIComponent(a) }
|
|
60
|
+
}
|
|
61
|
+
export const decisionsHref = (scope) => '#/decisions/' + encodeURIComponent(scope).replace(/%3A/gi, ':').replace(/%2F/gi, '/')
|
|
62
|
+
|
|
63
|
+
// What the timeline shows: current decisions (accepted), oldest first, unless you asked for all of them
|
|
64
|
+
// (superseded and rejected too). Proposed ones are always above, to review.
|
|
65
|
+
export function timelineRows(decisions, { all = false } = {}) {
|
|
66
|
+
const list = Array.isArray(decisions) ? decisions : []
|
|
67
|
+
return {
|
|
68
|
+
review: list.filter((d) => d.status === 'proposed'),
|
|
69
|
+
rows: list.filter((d) => d.status === 'accepted' || (all && (d.status === 'superseded' || d.status === 'rejected'))),
|
|
70
|
+
hidden: list.filter((d) => d.status === 'superseded' || d.status === 'rejected').length,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sessionBit(d, esc) {
|
|
75
|
+
// testplan Part 3: proposed from a failed check of a test plan: the plan is where it came from
|
|
76
|
+
const tp = /^testplan:(tp_[0-9a-f]{16})#(c\d+)$/.exec(String(d.checkpointId || ''))
|
|
77
|
+
if (tp) return '<a class="linkish dec-sess" href="#/testplan/' + esc(tp[1]) + '">From a test plan (check ' + esc(tp[2].slice(1)) + ')</a>'
|
|
78
|
+
const s = d.session
|
|
79
|
+
if (!s || !s.id) return ''
|
|
80
|
+
const title = s.title || 'its session'
|
|
81
|
+
return '<a class="linkish dec-sess" href="#/s/' + esc(encodeURIComponent(s.id)) + '">From ' + esc(title) + '</a>' +
|
|
82
|
+
(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>' : '')
|
|
83
|
+
}
|
|
84
|
+
function chainBit(d, byId, esc) {
|
|
85
|
+
const out = []
|
|
86
|
+
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>') }
|
|
87
|
+
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>')
|
|
88
|
+
return out.length ? '<div class="dec-chains">' + out.join('') + '</div>' : ''
|
|
89
|
+
}
|
|
90
|
+
function statusBit(d, esc) {
|
|
91
|
+
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>'
|
|
92
|
+
}
|
|
93
|
+
function editForm(d, esc) {
|
|
94
|
+
return '<form class="dec-edit" data-action="dec-save" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '">' +
|
|
95
|
+
'<label>Decision<input class="dec-in" name="text" maxlength="240" value="' + esc(d.text) + '" autocomplete="off"></label>' +
|
|
96
|
+
'<label>Why<input class="dec-in" name="why" maxlength="240" value="' + esc(d.why || '') + '" autocomplete="off"></label>' +
|
|
97
|
+
'<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>'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 1h: POST /v1/decisions/action is trustedOnly: on a paired device without Trust my paired devices the
|
|
101
|
+
// buttons show, disabled, with this tip, rather than failing on a click.
|
|
102
|
+
export const DEC_TRUST_TIP = 'Changing the decision log from another device is off. On a Mac, turn on Settings → Privacy & security → Trust my paired devices.'
|
|
103
|
+
const offAttrs = (trusted, esc) => (trusted === false ? ' disabled aria-disabled="true" data-tip="' + esc(DEC_TRUST_TIP) + '"' : '')
|
|
104
|
+
|
|
105
|
+
// One decision. may: you can change it (the board's usual gate); editing: its id is being edited.
|
|
106
|
+
// trusted (1h): false on a device that may look but not change the log. edit: false hides Edit (the viewer).
|
|
107
|
+
// replaces (1g): an id this one may be confirmed to replace (a walkthrough step departs from it).
|
|
108
|
+
export function decisionItemHtml(d, { esc = escD, byId = new Map(), may = true, editing = null, multi = false, review = false, trusted = true, edit = true, replaces = null } = {}) {
|
|
109
|
+
const repo = multi && d.repoLabel ? '<span class="dec-repo">' + esc(d.repoLabel) + '</span>' : ''
|
|
110
|
+
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>'
|
|
111
|
+
if (editing === d.id) return '<li class="dec-item" id="dec-' + esc(d.id) + '">' + head + editForm(d, esc) + '</li>'
|
|
112
|
+
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>' +
|
|
113
|
+
(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) + '"' + offAttrs(trusted, esc) + '>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) + '"' + offAttrs(trusted, esc) + '>Not related</button></span>' : '') + '</div>' : '' }).join('')
|
|
114
|
+
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) + '"' + offAttrs(trusted, esc) + '>' + label + '</button>'
|
|
115
|
+
const editBtn = (cls) => edit ? '<button type="button" class="' + cls + '" data-action="dec-edit" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Edit</button>' : ''
|
|
116
|
+
const link = replaces && !(d.supersedes || []).includes(replaces) ? '<button type="button" class="btn" data-action="dec-link" data-id="' + esc(d.id) + '" data-to="' + esc(replaces) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Confirm it replaces ' + esc(replaces) + '</button>' : ''
|
|
117
|
+
const acts = !may && !link ? '' : !may ? '<div class="dec-acts">' + link + '</div>' : review ? '<div class="dec-acts">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject') + editBtn('btn') + link + '</div>'
|
|
118
|
+
: '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : editBtn('linkish')) + link + '</div>'
|
|
119
|
+
return '<li class="dec-item dec-' + esc(d.status) + '" id="dec-' + esc(d.id) + '">' + head +
|
|
120
|
+
'<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>' +
|
|
121
|
+
(d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
|
|
122
|
+
(d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
|
|
123
|
+
chainBit(d, byId, esc) + sug +
|
|
124
|
+
'<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>'
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The storage choice (one per project). Off by default: the log stays in Leftoff's folder.
|
|
128
|
+
export function storageChoiceHtml(project, { esc = escD, may = true, trusted = true } = {}) {
|
|
129
|
+
if (!project) return '<p class="faint dec-local">This repo isn’t in a project, so its log stays in Leftoff only.</p>'
|
|
130
|
+
const on = !!project.inRepo
|
|
131
|
+
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) + '"' + (trusted === false ? offAttrs(false, esc) : may ? '' : ' disabled') + '>' + label + '</button>'
|
|
132
|
+
return '<div class="dec-store"><div class="dec-store-h">Keep decision log in this repo</div>' +
|
|
133
|
+
'<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>' +
|
|
134
|
+
'<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.'
|
|
135
|
+
: '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>'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The whole page. v: GET /v1/decisions. ui: { all, editing, may }
|
|
139
|
+
export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
|
|
140
|
+
const list = (v && v.decisions) || []
|
|
141
|
+
const byId = new Map(list.map((d) => [d.id, d]))
|
|
142
|
+
const multi = ((v && v.repos) || []).length > 1
|
|
143
|
+
const { review, rows, hidden } = timelineRows(list, { all: !!ui.all })
|
|
144
|
+
const sc = (v && v.scope) || {}
|
|
145
|
+
const name = sc.kind === 'project' ? sc.name : sc.label
|
|
146
|
+
const opts = { esc, byId, may: ui.may !== false, editing: ui.editing || null, multi, trusted: ui.trusted !== false }
|
|
147
|
+
let h = '<a class="back" href="' + (sc.kind === 'project' ? '#/project/' + esc(encodeURIComponent(sc.id)) : '#/') + '">← ' + (sc.kind === 'project' ? esc(name) : 'All sessions') + '</a>'
|
|
148
|
+
h += '<header class="dec-head"><h1>Decisions <span class="faint">· ' + esc(name || '') + '</span></h1>' +
|
|
149
|
+
(multi ? '<p class="faint">' + v.repos.map((r) => esc(r.label)).join(' · ') + '</p>' : '') + '</header>'
|
|
150
|
+
if (ui.trusted === false) h += '<p class="faint dec-trust"><span aria-hidden="true">◌</span> View only on this device. ' + esc(DEC_TRUST_TIP) + '</p>' // 1h: the reason in words too (a phone shows no tips)
|
|
151
|
+
if (v && (v.project || sc.kind === 'repo')) h += '<section class="proj-sec">' + storageChoiceHtml(v.project, { esc, may: ui.may !== false, trusted: ui.trusted !== false }) + '</section>'
|
|
152
|
+
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>'
|
|
153
|
+
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>' +
|
|
154
|
+
'<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button>' + testplanLinkHtml(sc, { esc, cls: 'btn' }) + '</div>'
|
|
155
|
+
h += '<section class="proj-sec dec-timeline"><h2>Timeline <span class="count">' + rows.length + '</span></h2>' + ctl +
|
|
156
|
+
(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>'
|
|
157
|
+
return '<div class="proj dec-page">' + h + '</div>'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// testplan Part 3: the way in to a test plan of the scope's merged PRs (a project, or a repo's log)
|
|
161
|
+
export function testplanLinkHtml(sc, { esc = escD, cls = 'btn' } = {}) {
|
|
162
|
+
if (!sc) return ''
|
|
163
|
+
const token = sc.kind === 'project' ? sc.id : sc.label || sc.id
|
|
164
|
+
if (!token) return ''
|
|
165
|
+
return '<a class="' + cls + ' tp-open" href="#/testplan/new?scope=' + esc(encodeURIComponent(token)) + '" data-tip="A short manual test plan for the PRs merged lately">Test plan</a>'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 1k: a quiet count of decisions waiting to be reviewed: a shape plus a number (and the words for a screen reader and the tip), never a colored badge alone.
|
|
169
|
+
export function reviewCountHtml(n, { esc = escD, href = null, cls = '' } = {}) {
|
|
170
|
+
if (!(n > 0)) return ''
|
|
171
|
+
const words = n + ' decision' + (n === 1 ? '' : 's') + ' to review'
|
|
172
|
+
const inner = '<span aria-hidden="true">◌</span> ' + n
|
|
173
|
+
return href ? '<a class="dec-rc ' + cls + '" href="' + esc(href) + '" data-tip="' + esc(words) + '" aria-label="' + esc(words) + '">' + inner + '<span class="dec-rc-w"> to review</span></a>'
|
|
174
|
+
: '<span class="dec-rc ' + cls + '" role="img" data-tip="' + esc(words) + '" aria-label="' + esc(words) + '">' + inner + '</span>'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The project page's short Decisions section: counts, the storage choice, the way in.
|
|
178
|
+
export function projectDecisionsHtml(v, { esc = escD, may = true, trusted = true, scopeArg = '' } = {}) {
|
|
179
|
+
if (!v) return ''
|
|
180
|
+
const list = v.decisions || []
|
|
181
|
+
const cur = list.filter((d) => d.status === 'accepted').length, rev = list.filter((d) => d.status === 'proposed').length
|
|
182
|
+
return '<section class="proj-sec dec-proj" id="projDecisions"><h2>Decisions <span class="count">' + cur + '</span></h2>' +
|
|
183
|
+
'<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>' +
|
|
184
|
+
'<div class="dec-acts">' + testplanLinkHtml(v.scope, { esc }) + '</div>' +
|
|
185
|
+
storageChoiceHtml(v.project, { esc, may, trusted }) + (trusted === false ? '<p class="faint dec-trust"><span aria-hidden="true">◌</span> View only on this device. ' + esc(DEC_TRUST_TIP) + '</p>' : '') + '</section>'
|
|
186
|
+
}
|
package/ui/prs.js
CHANGED
|
@@ -182,8 +182,11 @@ function sessionLinks(r, opts) {
|
|
|
182
182
|
|
|
183
183
|
// One PR row on the PRs tab. 0.8.5: tap the row to expand it in place
|
|
184
184
|
// (`open`, with `detail` as /v1/prs/detail answered: { loading } | { ok, detail } | { ok: false, code }).
|
|
185
|
-
export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null, me = null } = {}) {
|
|
185
|
+
export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null, me = null, picks = null } = {}) {
|
|
186
186
|
const p = r.pr || {}
|
|
187
|
+
// testplan Part 3: a merged PR can be picked for a test plan (a box plus the word, the state in aria-pressed)
|
|
188
|
+
const picked = !!(picks && picks.has(p.url))
|
|
189
|
+
const pick = picks && p.state === 'merged' ? '<button type="button" class="pr-pick" data-action="prs-pick" data-url="' + esc(p.url) + '" aria-pressed="' + picked + '" data-tip="' + (picked ? 'Picked for a test plan' : 'Pick for a test plan') + '"><span aria-hidden="true">' + (picked ? '☑' : '☐') + '</span><span class="pr-pick-w">' + (picked ? 'Picked' : 'Pick') + '</span></button>' : ''
|
|
187
190
|
const url = safeUrl(p.url)
|
|
188
191
|
// 0.8.9: what the checks and review chips don't already say (a conflict, your review)
|
|
189
192
|
const bwhy = r.bucketWhy !== undefined ? r.bucketWhy : prBucket(p, me).why
|
|
@@ -195,7 +198,7 @@ export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, s
|
|
|
195
198
|
: [p.repo, p.branch, when].filter(Boolean)
|
|
196
199
|
const why = whyText(r)
|
|
197
200
|
const unlinkTo = !team && mayEdit && (r.sessions || []).length === 1 ? r.sessions[0] : null
|
|
198
|
-
return '<article class="prrow' + (open ? ' open' : '') + '" data-pr="' + esc(p.url) + '"
|
|
201
|
+
return '<article class="prrow' + (open ? ' open' : '') + (pick ? ' pickable' : '') + (picked ? ' picked' : '') + '" data-pr="' + esc(p.url) + '">' + pick + '<div class="pr-main">' +
|
|
199
202
|
'<div class="pr-line1">' + line1 + '</div>' +
|
|
200
203
|
'<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || '') + '</a>' : esc(p.title || '')) + '</div>' +
|
|
201
204
|
'<div class="pr-meta">' + meta.map(esc).join(' · ') + (why ? ' · <span class="pr-why">' + esc(why) + '</span>' : '') + prMoreHtml(p, open) + '</div>' +
|
|
@@ -411,12 +414,19 @@ export const isOpenPr = (r) => !['merged', 'closed'].includes(String((r && r.pr
|
|
|
411
414
|
function doneGroupHtml(g, { open, rowOpts }) {
|
|
412
415
|
return '<details class="prgroup done b-' + g.key + '" data-group="' + g.key + '"' + (open ? ' open' : '') + '><summary><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span><span class="chev" aria-hidden="true">›</span></h2></summary>' + g.rows.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</details>'
|
|
413
416
|
}
|
|
414
|
-
|
|
417
|
+
// testplan Part 3: what's picked, and the way to a test plan of exactly those
|
|
418
|
+
export function prPicksBarHtml(picks) {
|
|
419
|
+
const n = picks ? picks.size : 0
|
|
420
|
+
if (!n) return ''
|
|
421
|
+
const href = '#/testplan/new?prs=' + [...picks].map(encodeURIComponent).join(',')
|
|
422
|
+
return '<div class="prs-picks" role="status"><span><span aria-hidden="true">☑</span> ' + n + ' merged PR' + (n === 1 ? '' : 's') + ' picked</span><a class="btn primary" href="' + esc(href) + '">Test plan for these</a><button type="button" class="linkish" data-action="prs-pick-clear">Clear</button></div>'
|
|
423
|
+
}
|
|
424
|
+
export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null, filter = {}, open = null, details = null, quietOpen = false, doneOpen = null, projects = [], picks = null } = {}) {
|
|
415
425
|
const f = { repos: [], state: null, q: '', project: null, ...filter }
|
|
416
426
|
if (f.project && !(projects || []).some((p) => p.id === f.project)) f.project = null
|
|
417
427
|
const all = prRows(d)
|
|
418
428
|
const me = d.me || null
|
|
419
|
-
let h = prsCutHtml(d) // 0.9.12: first, so a phone sees it
|
|
429
|
+
let h = prsCutHtml(d) + prPicksBarHtml(picks) // 0.9.12: first, so a phone sees it
|
|
420
430
|
if (all.length) {
|
|
421
431
|
// 0.9.0: projects first (when you have any), then the repos in the picked one
|
|
422
432
|
if ((projects || []).length) {
|
|
@@ -449,7 +459,7 @@ export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true
|
|
|
449
459
|
const rows = filterPrRows(all, f, { me, projects })
|
|
450
460
|
const { groups, quiet } = groupPrRows(rows, { now, me })
|
|
451
461
|
const showMachine = !!d.multiMachine
|
|
452
|
-
const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, me, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null })
|
|
462
|
+
const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, me, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null, picks: view === 'mine' ? picks : null })
|
|
453
463
|
if (!all.length && d.tooBig) {
|
|
454
464
|
h += '<div class="empty">Too many PRs to load at once. <button type="button" class="linkish" data-action="prs-refresh">Try again</button></div>'
|
|
455
465
|
} else if (!all.length) {
|
package/ui/semdiff.js
CHANGED
|
@@ -216,7 +216,27 @@ 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
|
+
const departs = new Set(Array.isArray(st.departs) ? st.departs : [])
|
|
227
|
+
return '<div class="wt-decs" role="group" aria-label="Decisions behind this step">' + ids.map((id) => {
|
|
228
|
+
const d = by.get(id)
|
|
229
|
+
const dep = departs.has(id), prop = !!(d && d.status === 'proposed')
|
|
230
|
+
// 1g/1f: shape plus word, never color alone: ≠ Departs, ◌ Proposed
|
|
231
|
+
const marks = (dep ? '<span class="wt-dec-rel wt-dec-dep"><span aria-hidden="true">≠</span> Departs</span>' : '') + (prop ? '<span class="wt-dec-rel wt-dec-prop"><span aria-hidden="true">◌</span> Proposed</span>' : '')
|
|
232
|
+
const tip = (gone.has(id) ? 'Changed since this walkthrough · ' : '') + (dep ? 'This step departs from it · ' : '') + (prop ? 'Proposed by this session, not accepted yet · ' : '') + (d ? d.text : 'Decision ' + id)
|
|
233
|
+
return '<button type="button" class="dec-chip wt-dec' + (gone.has(id) ? ' wt-dec-gone' : '') + (dep ? ' wt-dec-departs' : '') + (prop ? ' wt-dec-proposed' : '') + '" data-action="wt-dec" data-id="' + esc(id) + '"' + (dep ? ' data-departs="1"' : '') + (data && data.decisionRepo ? ' data-repo="' + esc(data.decisionRepo) + '"' : '') +
|
|
234
|
+
' data-tip="' + esc(tip) + '"><span class="wt-dec-id">' + esc(id) + '</span>' + marks + (d ? '<span class="wt-dec-t">' + esc(d.text) + '</span>' : '') + (gone.has(id) ? '<span class="wt-dec-w"> · changed since</span>' : '') + '</button>'
|
|
235
|
+
}).join('') + '</div>'
|
|
236
|
+
}
|
|
237
|
+
// how the export names a cited decision: "departs from" / "(proposed)"
|
|
238
|
+
function decWord(st, id, d) { return (Array.isArray(st.departs) && st.departs.includes(id) ? 'departs from ' : '') + (d && d.status === 'proposed' ? '(proposed) ' : '') }
|
|
239
|
+
function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, stat = null, shared = null, foldShared = true, data = null }) {
|
|
220
240
|
const core = st.tag === 'Core'
|
|
221
241
|
// 2026-10-29 several repos: the step's repo leads ("cube_cloud · src/x.ts"); one repo: the file names
|
|
222
242
|
const rp = st.repo ? [st.repo] : []
|
|
@@ -246,7 +266,7 @@ function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, s
|
|
|
246
266
|
: '<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
267
|
// callouts whose line isn't in a drawn snippet (folded code) still read here
|
|
248
268
|
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>'
|
|
269
|
+
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
270
|
}
|
|
251
271
|
|
|
252
272
|
function groupHtml(g, { esc, icon, open, data = null }) {
|
|
@@ -389,6 +409,8 @@ function modeSegHtml(show, split) {
|
|
|
389
409
|
// "3 files changed since this walkthrough · Update"
|
|
390
410
|
export function staleWords(st) {
|
|
391
411
|
if (!st) return ''
|
|
412
|
+
// 2026-11-03 a decision it cites has changed (superseded, rejected or edited) since it was written
|
|
413
|
+
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
414
|
if (st.count == null) return 'The change has moved on since this walkthrough'
|
|
393
415
|
if (!st.count) return 'The change has moved on since this walkthrough'
|
|
394
416
|
return st.count + (st.count === 1 ? ' file changed' : ' files changed') + ' since this walkthrough'
|
|
@@ -477,7 +499,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
|
|
|
477
499
|
const core = steps.filter((x) => x.tag === 'Core' && vis.has(x.id)), sup = steps.filter((x) => x.tag !== 'Core' && vis.has(x.id))
|
|
478
500
|
const bd = data.stats.breakdown || {}
|
|
479
501
|
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 })
|
|
502
|
+
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
503
|
const repos = walkRepos(data)
|
|
482
504
|
// 2026-10-29 several repos: one section per repo (its header with counts), its steps in reading order
|
|
483
505
|
if (repos.length) {
|
|
@@ -565,7 +587,7 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
|
|
|
565
587
|
slides.forEach((s, i) => {
|
|
566
588
|
if (s.kind === 'step') {
|
|
567
589
|
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
|
|
590
|
+
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
591
|
} else if (s.kind === 'skip') {
|
|
570
592
|
const groups = data.groups || []
|
|
571
593
|
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 +830,7 @@ export function toMarkdown(data, { title = '' } = {}) {
|
|
|
808
830
|
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
831
|
L.push(''); L.push((repos.length ? '#### ' : '### ') + st.n + '. ' + st.title + ' (' + st.tag + ')'); L.push('')
|
|
810
832
|
if (st.why) L.push(st.why)
|
|
833
|
+
if (Array.isArray(st.decisions) && st.decisions.length) { const by = decisionsOf(data); L.push(''); L.push('Decisions: ' + st.decisions.map((id) => decWord(st, id, by.get(id)) + '`' + id + '`' + (by.get(id) ? ' ' + by.get(id).text : '')).join('; ')) } // 2026-11-03
|
|
811
834
|
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
835
|
for (const sn of st.snippets || []) {
|
|
813
836
|
const fence = /```/.test(sn.patch) ? '~~~~' : '```'
|
|
@@ -820,6 +843,7 @@ export function toMarkdown(data, { title = '' } = {}) {
|
|
|
820
843
|
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
844
|
}
|
|
822
845
|
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) + '._') }
|
|
846
|
+
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
847
|
return L.join('\n') + '\n'
|
|
824
848
|
}
|
|
825
849
|
|
|
@@ -838,7 +862,7 @@ export function staticDiffHtml(patch, callouts = []) {
|
|
|
838
862
|
}
|
|
839
863
|
return h + '</div>'
|
|
840
864
|
}
|
|
841
|
-
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}}'
|
|
865
|
+
export 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}}'
|
|
842
866
|
// ONE self-contained file: inline CSS, the snippets baked in, no scripts, no links out.
|
|
843
867
|
// 0.9.18 a deck, like the board's: an overview, then one slide per step, then Safe to
|
|
844
868
|
// skip. Without a script: one radio per slide (hidden, still focusable, so ← / → move
|
|
@@ -865,6 +889,7 @@ export function toHtmlDoc(data, { title = '', repo = '', when = '' } = {}) {
|
|
|
865
889
|
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
890
|
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
891
|
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>'
|
|
892
|
+
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
893
|
b += '<section class="sl" data-i="0">' + o + nav(0) + '</section>'
|
|
869
894
|
slides.forEach((s, i) => {
|
|
870
895
|
if (!i) return
|
|
@@ -873,6 +898,7 @@ export function toHtmlDoc(data, { title = '', repo = '', when = '' } = {}) {
|
|
|
873
898
|
const st = s.st
|
|
874
899
|
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
900
|
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>' : '')
|
|
901
|
+
if (Array.isArray(st.decisions) && st.decisions.length) { const by = decisionsOf(data); x += '<p class="f decs">Decisions: ' + st.decisions.map((id) => escH(decWord(st, id, by.get(id))) + '<code>' + escH(id) + '</code>' + (by.get(id) ? ' ' + escH(by.get(id).text) : '')).join('; ') + '</p>' } // 2026-11-03
|
|
876
902
|
const drawn = new Set()
|
|
877
903
|
for (const sn of st.snippets || []) {
|
|
878
904
|
const cs = (st.callouts || []).filter((c) => c.hunk === sn.hunk)
|
package/ui/session-catchup.js
CHANGED
|
@@ -29,17 +29,33 @@ export function sessionCatchupHtml(ui, { esc, icon = () => '', sid = '' } = {})
|
|
|
29
29
|
: '<ul class="scu-body scu-list">' + body.map((_, i) => '<li data-scu="b-' + i + '"></li>').join('') + '</ul>'
|
|
30
30
|
return '<div class="scu" role="region" aria-label="Catch me up">' + head('<span class="scu-when" data-scu="when"></span>') +
|
|
31
31
|
(goal ? '<p class="scu-goal" data-scu="goal"></p>' : '') +
|
|
32
|
-
text + (next != null ? '<p class="scu-next" data-scu="next"></p>' : '') +
|
|
32
|
+
text + (next != null ? '<p class="scu-next" data-scu="next"></p>' : '') + recapDecisionsHtml(ui.data, { esc }) +
|
|
33
33
|
'<div class="msg-foot"><p class="scu-by" data-scu="by"></p>' + copyButtonHtml(recapSource(ui.data), { deferred: true }) + '</div></div>'
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// testplan Part 2: what the session decided since you looked, each with its word in the log (shape plus word).
|
|
37
|
+
const DEC_SHAPE = { proposed: '◌', accepted: '●', rejected: '✕', superseded: '↻', unlogged: '·' }
|
|
38
|
+
const DEC_WORD = { proposed: 'Proposed', accepted: 'Accepted', rejected: 'Rejected', superseded: 'Superseded', unlogged: 'Not in the log' }
|
|
39
|
+
export function recapDecisionsHtml(d, { esc }) {
|
|
40
|
+
const list = (d && Array.isArray(d.decisions) ? d.decisions : []).filter((x) => x && x.text)
|
|
41
|
+
if (!list.length) return ''
|
|
42
|
+
const sinceWord = d && d.recap && d.recap.since ? 'Decided since you looked' : 'Decided in this session'
|
|
43
|
+
return '<div class="scu-decs"><h3 class="scu-dec-h">' + esc(sinceWord) + '</h3><ul class="scu-dec-l">' + list.map((x) =>
|
|
44
|
+
'<li><span class="dec-st dec-st-' + esc(x.status) + '"><span aria-hidden="true">' + (DEC_SHAPE[x.status] || '·') + '</span> ' + esc(DEC_WORD[x.status] || x.status) + '</span> ' +
|
|
45
|
+
(x.id ? '<span class="dec-id">' + esc(x.id) + '</span> ' : '') + '<span class="dec-text">' + esc(x.text) + '</span>' + (x.supersededBy ? ' <span class="faint">→ ' + esc(x.supersededBy) + '</span>' : '') + '</li>').join('') + '</ul></div>'
|
|
46
|
+
}
|
|
47
|
+
export function recapDecisionsText(d) {
|
|
48
|
+
const list = (d && Array.isArray(d.decisions) ? d.decisions : []).filter((x) => x && x.text)
|
|
49
|
+
return list.length ? 'Decided:\n' + list.map((x) => '- ' + (x.id ? x.id + ' ' : '') + x.text + ' (' + (DEC_WORD[x.status] || x.status).toLowerCase() + ')').join('\n') : ''
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
// 0.9.11: what Copy takes from a recap, as markdown: the goal, the lines as a
|
|
37
53
|
// list (or a sentence or two, as shown), and the Next line.
|
|
38
54
|
export function recapSource(d) {
|
|
39
55
|
const { body, next } = recapParts(d)
|
|
40
56
|
const goal = recapGoal(d)
|
|
41
57
|
const text = body.length <= 2 ? body.join(' ') : body.map((b) => '- ' + b).join('\n')
|
|
42
|
-
return [goal, text, next].filter((x) => x && String(x).trim()).join('\n\n')
|
|
58
|
+
return [goal, text, next, recapDecisionsText(d)].filter((x) => x && String(x).trim()).join('\n\n')
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
// The recap's lines: what happened, and the one "Next: …" / "Up to you…" /
|
package/ui/summary-examples.js
CHANGED
|
@@ -56,7 +56,7 @@ export function formatCheckpointText(cp, { indent = '' } = {}) {
|
|
|
56
56
|
line(title + ':')
|
|
57
57
|
items.forEach((x, i) => line((title === 'Next steps' ? ' ' + (i + 1) + '. ' : ' - ') + fmt(x)))
|
|
58
58
|
}
|
|
59
|
-
list('
|
|
59
|
+
list('Approach notes', cp.decisions)
|
|
60
60
|
list('Open questions', cp.questions)
|
|
61
61
|
list('Files touched', cp.files, (f) => (f.path || '') + (f.path && f.why ? ' — ' : '') + (f.why || ''))
|
|
62
62
|
list('What didn’t work', cp.didntWork)
|