@leftoff/cli 0.9.18-beta.6 → 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 +7 -0
- package/bin/leftoff.js +569 -535
- package/docs/test-plan.md +43 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +9 -5
- package/mod/hooks/register.js +22 -1
- package/mod/skills/walkthrough/SKILL.md +2 -2
- package/package.json +1 -1
- package/ui/app.css +73 -0
- package/ui/app.js +172 -24
- package/ui/ask.js +1 -1
- package/ui/decisions.js +69 -16
- package/ui/prs.js +15 -5
- package/ui/semdiff.js +12 -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/semdiff.js
CHANGED
|
@@ -223,12 +223,19 @@ export function stepDecisionsHtml(st, data, { esc }) {
|
|
|
223
223
|
if (!ids.length) return ''
|
|
224
224
|
const by = decisionsOf(data)
|
|
225
225
|
const gone = new Set((data && data.stale && data.stale.decisions) || [])
|
|
226
|
+
const departs = new Set(Array.isArray(st.departs) ? st.departs : [])
|
|
226
227
|
return '<div class="wt-decs" role="group" aria-label="Decisions behind this step">' + ids.map((id) => {
|
|
227
228
|
const d = by.get(id)
|
|
228
|
-
|
|
229
|
-
|
|
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>'
|
|
230
235
|
}).join('') + '</div>'
|
|
231
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) ' : '') }
|
|
232
239
|
function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, stat = null, shared = null, foldShared = true, data = null }) {
|
|
233
240
|
const core = st.tag === 'Core'
|
|
234
241
|
// 2026-10-29 several repos: the step's repo leads ("cube_cloud · src/x.ts"); one repo: the file names
|
|
@@ -823,7 +830,7 @@ export function toMarkdown(data, { title = '' } = {}) {
|
|
|
823
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 || []) + ')' : '')) }
|
|
824
831
|
L.push(''); L.push((repos.length ? '#### ' : '### ') + st.n + '. ' + st.title + ' (' + st.tag + ')'); L.push('')
|
|
825
832
|
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
|
|
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
|
|
827
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) }
|
|
828
835
|
for (const sn of st.snippets || []) {
|
|
829
836
|
const fence = /```/.test(sn.patch) ? '~~~~' : '```'
|
|
@@ -855,7 +862,7 @@ export function staticDiffHtml(patch, callouts = []) {
|
|
|
855
862
|
}
|
|
856
863
|
return h + '</div>'
|
|
857
864
|
}
|
|
858
|
-
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}}'
|
|
859
866
|
// ONE self-contained file: inline CSS, the snippets baked in, no scripts, no links out.
|
|
860
867
|
// 0.9.18 a deck, like the board's: an overview, then one slide per step, then Safe to
|
|
861
868
|
// skip. Without a script: one radio per slide (hidden, still focusable, so ← / → move
|
|
@@ -891,7 +898,7 @@ export function toHtmlDoc(data, { title = '', repo = '', when = '' } = {}) {
|
|
|
891
898
|
const st = s.st
|
|
892
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
|
|
893
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>' : '')
|
|
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
|
|
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
|
|
895
902
|
const drawn = new Set()
|
|
896
903
|
for (const sn of st.snippets || []) {
|
|
897
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)
|
package/ui/testplan.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Test plan from merged PRs (testplan Part 3, docs/test-plan.md): the board's page, its Markdown
|
|
2
|
+
// checklist and its downloadable .html (the walkthrough export's look). Pure builders; app.js wires
|
|
3
|
+
// them; unit-tested in test/unit/testplan-*.test.mjs. Words are escaped first; links only to https,
|
|
4
|
+
// the board's own #/ routes, and nothing else.
|
|
5
|
+
import { EXPORT_CSS } from './semdiff.js'
|
|
6
|
+
|
|
7
|
+
const escT = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
8
|
+
export const BUDGETS = [15, 30, 60]
|
|
9
|
+
export const RISK_SHAPE = { high: '▲', medium: '◆', low: '▽' }
|
|
10
|
+
export const RISK_WORD = { high: 'High risk', medium: 'Medium risk', low: 'Low risk' }
|
|
11
|
+
export const TP_TRUST_TIP = 'Writing a test plan from another device is off. On a Mac, turn on Settings → Privacy & security → Trust my paired devices.'
|
|
12
|
+
const off = (trusted, esc) => (trusted === false ? ' disabled aria-disabled="true" data-tip="' + esc(TP_TRUST_TIP) + '"' : '')
|
|
13
|
+
const httpsUrl = (u) => (/^https:\/\/[^\s"'<>]+$/.test(String(u || '')) ? String(u) : null)
|
|
14
|
+
const day = (iso) => String(iso || '').slice(0, 10)
|
|
15
|
+
|
|
16
|
+
export const testplanHref = (id, machine = null) => '#/testplan/' + encodeURIComponent(id) + (machine ? '?machine=' + encodeURIComponent(machine) : '')
|
|
17
|
+
export function testplanNewHref({ scope = '', prs = null, since = null } = {}) {
|
|
18
|
+
const q = []
|
|
19
|
+
if (scope) q.push('scope=' + encodeURIComponent(scope))
|
|
20
|
+
if (prs && prs.length) q.push('prs=' + prs.map(encodeURIComponent).join(','))
|
|
21
|
+
if (since) q.push('since=' + encodeURIComponent(since))
|
|
22
|
+
return '#/testplan/new' + (q.length ? '?' + q.join('&') : '')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function progressLine(plan) {
|
|
26
|
+
const cs = (plan && plan.plan && plan.plan.checks) || []
|
|
27
|
+
const t = (plan && plan.ticks) || {}
|
|
28
|
+
return cs.filter((c) => t[c.id] && t[c.id].state).length + ' of ' + cs.length + ' checked'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A citation as a small chip: a PR opens on GitHub, a decision in its log; a step or checkpoint is its words.
|
|
32
|
+
export function citeChipHtml(tag, tags, { esc = escT } = {}) {
|
|
33
|
+
const x = (tags && tags[tag]) || null
|
|
34
|
+
const [kind] = String(tag).split(':')
|
|
35
|
+
const label = kind === 'pr' ? '#' + tag.slice(3) : kind === 'dec' ? tag.slice(4) : kind === 'st' ? 'step' : 'checkpoint'
|
|
36
|
+
const tip = x && x.label ? x.label : tag
|
|
37
|
+
if (kind === 'pr' && x && httpsUrl(x.url)) return '<a class="tp-cite" href="' + esc(x.url) + '" target="_blank" rel="noopener noreferrer" data-tip="' + esc(tip) + '">' + esc(label) + '</a>'
|
|
38
|
+
if (kind === 'dec' && x && x.repo) return '<a class="tp-cite" href="#/decisions/' + esc(x.repo) + '?d=' + esc(encodeURIComponent(tag.slice(4))) + '" data-tip="' + esc(tip) + '">' + esc(label) + '</a>'
|
|
39
|
+
return '<span class="tp-cite" data-tip="' + esc(tip) + '">' + esc(label + (x && x.label ? ': ' + x.label : '')) + '</span>'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const riskHtml = (r, esc) => '<span class="tp-risk tp-risk-' + esc(r) + '"><span aria-hidden="true">' + (RISK_SHAPE[r] || '◆') + '</span> ' + esc(RISK_WORD[r] || r) + '</span>'
|
|
43
|
+
|
|
44
|
+
// One check. ticks[c.id]: { state: 'done' | 'failed' | null, note }. proposed: decision id made from it.
|
|
45
|
+
export function checkHtml(c, plan, { esc = escT, trusted = true, noting = null, proposing = null } = {}) {
|
|
46
|
+
const t = (plan.ticks || {})[c.id] || {}
|
|
47
|
+
const st = t.state || null
|
|
48
|
+
const tags = (plan.plan && plan.plan.tags) || {}
|
|
49
|
+
const prop = plan.proposed && plan.proposed[c.id]
|
|
50
|
+
const prRepo = (((plan.prs || []).find((x) => (c.prs || []).includes(x.number)) || (plan.prs || [])[0] || {}).decisionRepo) || ''
|
|
51
|
+
const tick = (v, word, shape) => '<button type="button" class="btn tp-tick tp-tick-' + v + '" data-action="tp-tick" data-id="' + esc(plan.id) + '" data-check="' + esc(c.id) + '" data-state="' + v + '" aria-pressed="' + (st === v) + '"><span aria-hidden="true">' + shape + '</span> ' + word + '</button>'
|
|
52
|
+
const note = st === 'failed' || noting === c.id
|
|
53
|
+
? '<form class="tp-note" data-action="tp-note" data-id="' + esc(plan.id) + '" data-check="' + esc(c.id) + '"><label class="tp-note-l">What went wrong<input class="dec-in tp-note-in" name="note" maxlength="280" autocomplete="off" value="' + esc(t.note || '') + '" placeholder="One line, for the export"></label><div class="dec-acts"><button type="submit" class="btn">Save note</button>' +
|
|
54
|
+
(prop ? '<a class="linkish tp-prop" href="#/decisions/' + esc(prRepo) + '?d=' + esc(encodeURIComponent(prop)) + '"><span aria-hidden="true">◌</span> Proposed ' + esc(prop) + ' →</a>'
|
|
55
|
+
: '<button type="button" class="btn" data-action="tp-propose" data-id="' + esc(plan.id) + '" data-check="' + esc(c.id) + '"' + off(trusted, esc) + '>Propose a decision</button>') + '</div></form>' +
|
|
56
|
+
(proposing === c.id && !prop ? '<form class="tp-propf dec-edit" data-action="tp-propose-save" data-id="' + esc(plan.id) + '" data-check="' + esc(c.id) + '"><label>The decision (proposed to the repo’s log; you accept it there or not)<input class="dec-in" name="text" maxlength="240" autocomplete="off" value="' + esc(t.note ? 'Correct: ' + t.note : '') + '"></label><div class="dec-acts"><button type="submit" class="btn primary">Propose</button><button type="button" class="btn" data-action="tp-propose-cancel">Cancel</button></div></form>' : '')
|
|
57
|
+
: ''
|
|
58
|
+
return '<li class="tp-check' + (st ? ' tp-' + st : '') + '" id="tp-' + esc(c.id) + '">' +
|
|
59
|
+
'<div class="tp-ch"><span class="tp-n">' + esc(c.id.slice(1)) + '</span><h3 class="tp-title">' + esc(c.title) + '</h3>' + riskHtml(c.risk, esc) + '<span class="tp-min">' + esc(c.minutes) + ' min</span></div>' +
|
|
60
|
+
(c.where ? '<p class="tp-where"><span class="tp-k">Where</span> ' + esc(c.where) + '</p>' : '') +
|
|
61
|
+
(c.steps && c.steps.length ? '<ol class="tp-steps">' + c.steps.map((x) => '<li>' + esc(x) + '</li>').join('') + '</ol>' : '') +
|
|
62
|
+
(c.expect ? '<p class="tp-expect"><span class="tp-k">You should see</span> ' + esc(c.expect) + '</p>' : '') +
|
|
63
|
+
'<p class="tp-why"><span class="tp-k">Why</span> ' + esc(c.why) + '</p>' +
|
|
64
|
+
'<div class="tp-cites" aria-label="Where this comes from">' + c.cites.map((tg) => citeChipHtml(tg, tags, { esc })).join('') + '</div>' +
|
|
65
|
+
'<div class="tp-acts">' + tick('done', 'Checked', '✓') + tick('failed', 'Failed', '✕') + (st ? '<button type="button" class="linkish tp-clear" data-action="tp-tick" data-id="' + esc(plan.id) + '" data-check="' + esc(c.id) + '" data-state="">Clear</button>' : '') + '</div>' + note + '</li>'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The plan's page. ui: { trusted, noting, machine (another Mac's plan), here }
|
|
69
|
+
export function testplanPageHtml(plan, ui = {}, { esc = escT } = {}) {
|
|
70
|
+
if (!plan) return '<div class="proj tp-page"><p class="faint">Loading the test plan…</p></div>'
|
|
71
|
+
const sc = plan.scope || {}
|
|
72
|
+
const back = sc.kind === 'project' ? '<a class="back" href="#/project/' + esc(encodeURIComponent(sc.id || sc.token || '')) + '">← ' + esc(sc.label || 'Project') + '</a>' : '<a class="back" href="#/prs">← Pull requests</a>'
|
|
73
|
+
const sub = [plan.window && plan.window.label, plan.minutes + '-minute budget', (plan.prs || []).length + ' merged PR' + ((plan.prs || []).length === 1 ? '' : 's'), plan.machine && ui.here && plan.machine !== ui.here ? 'on ' + plan.machine : null].filter(Boolean).join(' · ')
|
|
74
|
+
let h = back + '<header class="tp-head"><h1>Test plan <span class="faint">· ' + esc(sc.label || '') + '</span></h1><p class="sub">' + esc(sub) + '</p></header>'
|
|
75
|
+
if (plan.status === 'running') return '<div class="proj tp-page">' + h + '<div class="tp-wait" role="status" aria-live="polite"><span class="tp-spin" aria-hidden="true"></span> Writing the test plan from ' + esc((plan.prs || []).length) + ' PRs… this takes up to a couple of minutes.</div></div>'
|
|
76
|
+
if (plan.status === 'error') return '<div class="proj tp-page">' + h + '<div class="banner warn" role="alert"><span>Couldn’t write it: ' + esc(plan.error || 'unknown error') + '</span></div></div>'
|
|
77
|
+
const p = plan.plan || { checks: [], notCovered: [], coverage: {} }
|
|
78
|
+
const by = plan.by || {}
|
|
79
|
+
const byWords = by.engine === 'claude' ? 'Written by Claude Code (read-only)' : by.engine === 'summary' ? 'Written by your summary model (' + (by.model || by.provider || '') + ')' : by.engine === 'rules' ? 'Made from Leftoff’s notes without a model' : by.engine === 'empty' ? '' : ''
|
|
80
|
+
h += '<div class="tp-bar"><span class="tp-prog" role="status"><span aria-hidden="true">☐</span> ' + esc(progressLine(plan)) + '</span><span class="tp-cov">' + esc(p.coverage && p.coverage.line || '') + '</span>' +
|
|
81
|
+
'<span class="tp-exp"><button type="button" class="btn" data-action="tp-copy" data-id="' + esc(plan.id) + '">Copy as Markdown</button><button type="button" class="btn" data-action="tp-download" data-id="' + esc(plan.id) + '">Download .html</button></span></div>'
|
|
82
|
+
if (byWords) h += '<p class="faint tp-by">' + esc(byWords) + (by.why && by.engine !== 'claude' ? ' · ' + esc(by.why) : '') + '</p>'
|
|
83
|
+
if (!p.checks.length) h += '<p class="faint">' + esc((p.coverage && p.coverage.line) || 'Nothing to check.') + '</p>'
|
|
84
|
+
else h += '<ol class="tp-list">' + p.checks.map((c) => checkHtml(c, plan, { esc, trusted: ui.trusted, noting: ui.noting, proposing: ui.proposing })).join('') + '</ol>'
|
|
85
|
+
if (p.notCovered && p.notCovered.length) h += '<section class="proj-sec tp-nc"><h2>Not covered <span class="count">' + p.notCovered.length + '</span></h2><ul class="tp-ncl">' + p.notCovered.map((n) => '<li><span class="tp-nc-w">' + esc(n.what) + '</span> <span class="faint">· ' + esc(n.why) + '</span>' + ((n.cites || []).length ? ' <span class="tp-cites">' + n.cites.map((tg) => citeChipHtml(tg, p.tags || {}, { esc })).join('') + '</span>' : '') + '</li>').join('') + '</ul></section>'
|
|
86
|
+
const nw = (plan.prs || []).filter((x) => x.noWalkthrough)
|
|
87
|
+
h += '<section class="proj-sec tp-prs"><h2>Merged PRs <span class="count">' + (plan.prs || []).length + '</span></h2><ul class="tp-prl">' + (plan.prs || []).map((x) => '<li>' + (httpsUrl(x.url) ? '<a href="' + esc(x.url) + '" target="_blank" rel="noopener noreferrer">#' + esc(x.number) + '</a>' : '#' + esc(x.number)) + ' ' + esc(x.title) + (x.noWalkthrough ? ' <span class="tp-nowt"><span aria-hidden="true">◌</span> no walkthrough</span>' : '') + '</li>').join('') + '</ul>' +
|
|
88
|
+
(nw.length ? '<p class="faint">' + nw.length + ' had no walkthrough, so only their files, checkpoints and decisions were read.</p>' : '') + '</section>'
|
|
89
|
+
if (p.notes && p.notes.length) h += '<details class="tp-notes"><summary>What was checked in the answer (' + p.notes.length + ')</summary><ul>' + p.notes.map((n) => '<li>' + esc(n) + '</li>').join('') + '</ul></details>'
|
|
90
|
+
h += '<p class="tp-del"><button type="button" class="linkish" data-action="tp-delete" data-id="' + esc(plan.id) + '">Delete this test plan</button></p>'
|
|
91
|
+
return '<div class="proj tp-page">' + h + '</div>'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Before a run: the PRs in the window, the budget (15 / 30 / 60, a choice in the request, not a setting),
|
|
95
|
+
// "Walk through the N without one first", and the plans made before.
|
|
96
|
+
export function testplanNewHtml(pv, ui = {}, { esc = escT } = {}) {
|
|
97
|
+
const sc = (pv && pv.scope) || {}
|
|
98
|
+
const back = sc.kind === 'project' ? '<a class="back" href="#/project/' + esc(encodeURIComponent(sc.id || sc.token || '')) + '">← ' + esc(sc.label || 'Project') + '</a>' : '<a class="back" href="#/prs">← Pull requests</a>'
|
|
99
|
+
let h = back + '<header class="tp-head"><h1>Test plan <span class="faint">· ' + esc(sc.label || '') + '</span></h1><p class="sub">A short manual script for the PRs merged ' + esc(pv && pv.window ? pv.window.label.toLowerCase() : 'lately') + ': what to click, what you should see, why it’s on the list.</p></header>'
|
|
100
|
+
if (!pv) return '<div class="proj tp-page">' + h + '<p class="faint">Reading the merged PRs…</p></div>'
|
|
101
|
+
const prs = pv.prs || []
|
|
102
|
+
const mins = BUDGETS.includes(ui.minutes) ? ui.minutes : 30
|
|
103
|
+
h += '<div class="tp-budget" role="group" aria-label="Time budget">' + BUDGETS.map((m) => '<button type="button" class="sask-opt" data-action="tp-minutes" data-m="' + m + '" aria-pressed="' + (m === mins) + '">' + m + ' min</button>').join('') + '</div>'
|
|
104
|
+
if ((pv.notes || []).length) h += pv.notes.map((n) => '<p class="faint">' + esc(n) + '</p>').join('')
|
|
105
|
+
if (!prs.length) h += '<p class="faint">No merged PRs ' + esc(pv.window ? pv.window.label.toLowerCase() : '') + '. They show here once the PRs page has read them.</p>'
|
|
106
|
+
else {
|
|
107
|
+
h += '<ul class="tp-prl">' + prs.map((x) => '<li>' + (httpsUrl(x.url) ? '<a href="' + esc(x.url) + '" target="_blank" rel="noopener noreferrer">#' + esc(x.number) + '</a>' : '#' + esc(x.number)) + ' ' + esc(x.title) + ' <span class="faint">· ' + esc(day(x.mergedAt)) + '</span>' + (x.walkthrough ? ' <span class="tp-wt"><span aria-hidden="true">●</span> walkthrough</span>' : ' <span class="tp-nowt"><span aria-hidden="true">◌</span> no walkthrough</span>') + '</li>').join('') + '</ul>'
|
|
108
|
+
const w = pv.walking
|
|
109
|
+
const n = pv.withoutWalkthrough || 0
|
|
110
|
+
h += '<div class="dec-acts tp-go">' + (n ? '<button type="button" class="btn" data-action="tp-walk"' + off(ui.trusted, esc) + (w && w.running ? ' disabled' : '') + '>' + (w && w.running ? 'Walking through ' + (w.done + 1) + ' of ' + w.total + '…' : 'Walk through the ' + n + ' without one first') + '</button>' : '') +
|
|
111
|
+
'<button type="button" class="btn primary" data-action="tp-run"' + off(ui.trusted, esc) + (ui.starting ? ' disabled' : '') + '>' + (ui.starting ? 'Starting…' : 'Make the test plan') + '</button></div>'
|
|
112
|
+
if (ui.trusted === false) h += '<p class="faint dec-trust"><span aria-hidden="true">◌</span> ' + esc(TP_TRUST_TIP) + '</p>'
|
|
113
|
+
}
|
|
114
|
+
const recent = (ui.recent && ui.recent.items) || []
|
|
115
|
+
if (recent.length) h += '<section class="proj-sec"><h2>Earlier test plans</h2><ul class="tp-recent">' + recent.map((r) => '<li><a href="' + esc(testplanHref(r.id, r.machine && r.machine !== ui.here ? r.machine : null)) + '">' + esc((r.scope && r.scope.label) || 'Test plan') + ' · ' + esc(day(r.createdAt)) + '</a> <span class="faint">· ' + esc(r.ticked + ' of ' + r.checks + ' checked') + (r.machine && r.machine !== ui.here ? ' · on ' + esc(r.machine) : '') + '</span></li>').join('') + '</ul></section>'
|
|
116
|
+
return '<div class="proj tp-page">' + h + '</div>'
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ------------------------------------------------------------ export
|
|
120
|
+
|
|
121
|
+
const mdEsc = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
|
|
122
|
+
// A checklist with checkboxes: ticked ones [x]; a failed one says so, with its note.
|
|
123
|
+
export function planMarkdown(plan) {
|
|
124
|
+
const p = (plan && plan.plan) || { checks: [], notCovered: [] }
|
|
125
|
+
const t = plan.ticks || {}
|
|
126
|
+
const L = ['# Test plan: ' + mdEsc((plan.scope && plan.scope.label) || ''), '', mdEsc([plan.window && plan.window.label, plan.minutes + '-minute budget', (plan.prs || []).length + ' merged PRs', progressLine(plan)].filter(Boolean).join(' · ')), '']
|
|
127
|
+
if (p.coverage && p.coverage.line) L.push(mdEsc(p.coverage.line), '')
|
|
128
|
+
for (const c of p.checks) {
|
|
129
|
+
const st = t[c.id] && t[c.id].state
|
|
130
|
+
L.push('- [' + (st ? 'x' : ' ') + '] **' + mdEsc(c.title) + '** (' + (RISK_WORD[c.risk] || c.risk) + ', ' + c.minutes + ' min)' + (st === 'failed' ? ' ✕ failed' + (t[c.id].note ? ': ' + mdEsc(t[c.id].note) : '') : ''))
|
|
131
|
+
if (c.where) L.push(' - Where: ' + mdEsc(c.where))
|
|
132
|
+
c.steps.forEach((x, i) => L.push(' ' + (i + 1) + '. ' + mdEsc(x)))
|
|
133
|
+
if (c.expect) L.push(' - You should see: ' + mdEsc(c.expect))
|
|
134
|
+
L.push(' - Why: ' + mdEsc(c.why) + (c.cites.length ? ' (' + c.cites.map((x) => '`' + x + '`').join(' ') + ')' : ''))
|
|
135
|
+
}
|
|
136
|
+
if (p.notCovered && p.notCovered.length) { L.push('', '## Not covered', ''); for (const n of p.notCovered) L.push('- ' + mdEsc(n.what) + ': ' + mdEsc(n.why)) }
|
|
137
|
+
L.push('', '## Merged PRs', '')
|
|
138
|
+
for (const x of plan.prs || []) L.push('- #' + x.number + ' ' + mdEsc(x.title) + (x.noWalkthrough ? ' (no walkthrough)' : ''))
|
|
139
|
+
return L.join('\n') + '\n'
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ONE self-contained file in the walkthrough export's look: inline CSS, no scripts, no links out
|
|
143
|
+
// (a link is its words and its address).
|
|
144
|
+
export function planHtmlDoc(plan) {
|
|
145
|
+
const e = escT
|
|
146
|
+
const p = (plan && plan.plan) || { checks: [], notCovered: [] }
|
|
147
|
+
const t = plan.ticks || {}
|
|
148
|
+
let b = '<main><h1>Test plan: ' + e((plan.scope && plan.scope.label) || '') + '</h1><p class="f">' + e([plan.window && plan.window.label, plan.minutes + '-minute budget', (plan.prs || []).length + ' merged PRs', progressLine(plan)].filter(Boolean).join(' · ')) + '</p>'
|
|
149
|
+
if (p.coverage && p.coverage.line) b += '<p class="sum">' + e(p.coverage.line) + '</p>'
|
|
150
|
+
for (const c of p.checks) {
|
|
151
|
+
const st = t[c.id] && t[c.id].state
|
|
152
|
+
b += '<h2>' + (st === 'done' ? '☑' : st === 'failed' ? '☒' : '☐') + ' ' + e(c.title) + '<span class="tag' + (c.risk === 'high' ? ' core' : '') + '">' + (RISK_SHAPE[c.risk] || '') + ' ' + e(RISK_WORD[c.risk] || c.risk) + '</span><span class="tag">' + e(c.minutes) + ' min</span></h2>'
|
|
153
|
+
if (st === 'failed') b += '<div class="co"><b>Failed</b> ' + e((t[c.id] && t[c.id].note) || '') + '</div>'
|
|
154
|
+
if (c.where) b += '<p class="f">Where: ' + e(c.where) + '</p>'
|
|
155
|
+
if (c.steps.length) b += '<ol>' + c.steps.map((x) => '<li>' + e(x) + '</li>').join('') + '</ol>'
|
|
156
|
+
if (c.expect) b += '<p>You should see: ' + e(c.expect) + '</p>'
|
|
157
|
+
b += '<p class="f">Why: ' + e(c.why) + (c.cites.length ? ' · ' + c.cites.map((x) => '<code>' + e(x) + '</code>').join(' ') : '') + '</p>'
|
|
158
|
+
}
|
|
159
|
+
if (p.notCovered && p.notCovered.length) b += '<h2>Not covered</h2><ul>' + p.notCovered.map((n) => '<li>' + e(n.what) + ' <span class="f">(' + e(n.why) + ')</span></li>').join('') + '</ul>'
|
|
160
|
+
b += '<h2>Merged PRs</h2><ul>' + (plan.prs || []).map((x) => '<li>#' + e(x.number) + ' ' + e(x.title) + (x.url ? ' <span class="u f">' + e(x.url) + '</span>' : '') + (x.noWalkthrough ? ' <span class="f">(no walkthrough)</span>' : '') + '</li>').join('') + '</ul></main>'
|
|
161
|
+
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>' + e('Test plan: ' + ((plan.scope && plan.scope.label) || '')) + '</title><style>' + EXPORT_CSS + '</style></head><body>' + b + '</body></html>\n'
|
|
162
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Full screen on a phone, sized to the window you can see (ui/app.js fitFullViewport).
|
|
2
|
+
// 2026-11-03 (Joel: "weird top padding after sending a message"): iOS pans the
|
|
3
|
+
// visual viewport up when the keyboard opens over the composer, and full screen
|
|
4
|
+
// follows that pan (top = visualViewport.offsetTop) so the composer stays above
|
|
5
|
+
// the keyboard. After a send the keyboard closes, but iOS's last report can
|
|
6
|
+
// still carry the old pan (or no report comes), and full screen stayed ~300px
|
|
7
|
+
// down the screen with the board showing through above it. So the pan only
|
|
8
|
+
// counts while the keyboard is really up (a text box has focus and the window
|
|
9
|
+
// lost a keyboard's worth of height), and never more than the room the
|
|
10
|
+
// keyboard took (a visual viewport as tall as the window can't be panned).
|
|
11
|
+
|
|
12
|
+
// A focused field the on-screen keyboard is up for.
|
|
13
|
+
const NO_KB_INPUT = /^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/i
|
|
14
|
+
export function isTypingTarget(el) {
|
|
15
|
+
if (!el || !el.tagName) return false
|
|
16
|
+
const tag = String(el.tagName).toUpperCase()
|
|
17
|
+
if (tag === 'TEXTAREA') return !el.readOnly && !el.disabled
|
|
18
|
+
if (tag === 'INPUT') return !NO_KB_INPUT.test(el.type || 'text') && !el.readOnly && !el.disabled
|
|
19
|
+
return !!el.isContentEditable
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const KEYBOARD_MIN_PX = 120 // less than this is the browser's own bars, not a keyboard
|
|
23
|
+
|
|
24
|
+
// innerHeight: the layout viewport's height; vv: { height, offsetTop, scale }; typing: a text box has focus.
|
|
25
|
+
// -> { h, top, kb } in CSS px (null without a visualViewport: the CSS falls back to 100dvh).
|
|
26
|
+
export function fullViewportFit({ innerHeight, vv, typing = false } = {}) {
|
|
27
|
+
if (!vv || !(Number(innerHeight) > 0)) return null
|
|
28
|
+
const ih = Number(innerHeight)
|
|
29
|
+
const vh = Number(vv.height) > 0 ? Number(vv.height) : ih
|
|
30
|
+
const zoomed = Number(vv.scale) > 1.01 // pinch-zoomed: follow the visual viewport as before
|
|
31
|
+
const kb = !!typing && ih - vh > KEYBOARD_MIN_PX
|
|
32
|
+
if (!kb && !zoomed) return { h: Math.round(ih), top: 0, kb: false } // no keyboard: the whole window, from its top
|
|
33
|
+
const room = Math.max(0, ih - vh)
|
|
34
|
+
const top = Math.min(Math.max(0, Number(vv.offsetTop) || 0), room)
|
|
35
|
+
return { h: Math.round(vh), top: Math.round(top), kb }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// When to look again after a text box lets go (the keyboard takes ~0.3–0.5 s to
|
|
39
|
+
// close, and iOS's reports during it can be stale or missing), in ms.
|
|
40
|
+
export const SETTLE_AFTER_BLUR_MS = [0, 120, 350, 700, 1100]
|