@leftoff/cli 0.9.18-beta.13 → 0.9.18-beta.14
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-statusline.js +7 -7
- package/bin/leftoff.js +771 -690
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +206 -3
- package/mod/hooks/register.js +8 -2
- package/package.json +1 -1
- package/ui/app.css +102 -2
- package/ui/app.js +224 -118
- package/ui/cp-fold.js +20 -0
- package/ui/decisions.js +2 -1
- package/ui/digest-versions.js +57 -0
- package/ui/html-preview.html +12 -0
- package/ui/html-preview.js +107 -0
- package/ui/icons.js +1 -0
- package/ui/mcp-setup.js +26 -0
- package/ui/render-digest.js +98 -0
- package/ui/render-export.js +128 -0
- package/ui/semdiff.js +38 -21
- package/ui/settings-nav.js +1 -1
- package/ui/walk-spots.js +35 -1
package/ui/cp-fold.js
CHANGED
|
@@ -20,3 +20,23 @@ export function cpFoldSummary(cp, { decided = false } = {}) {
|
|
|
20
20
|
const t = names.join(', ')
|
|
21
21
|
return t.charAt(0).toUpperCase() + t.slice(1)
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
// 2026-11-06: a checkpoint's "Got done" facts (cp.work: commits, PRs, pushes, the last test run, from the
|
|
25
|
+
// commands' own output) as short lines for its card: [{ kind, text }], at most max, then "+N more".
|
|
26
|
+
// kind is a class name (merged, opened, commit, push, pass, fail); the words say the same, shape and text.
|
|
27
|
+
export function workItems(cp, { max = 5 } = {}) {
|
|
28
|
+
const w = cp && Array.isArray(cp.work) ? cp.work : []
|
|
29
|
+
const out = []
|
|
30
|
+
for (const f of w) {
|
|
31
|
+
if (!f || typeof f !== 'object') continue
|
|
32
|
+
if (f.kind === 'pr' && f.number) out.push({ kind: f.state === 'merged' ? 'merged' : 'opened', text: (f.state === 'merged' ? 'Merged' : f.state === 'auto' ? 'Set to merge' : 'Opened') + ' PR #' + f.number + (f.title ? ': ' + String(f.title) : '') + (f.state === 'opened' ? ' (not merged yet)' : '') })
|
|
33
|
+
else if (f.kind === 'commit' && f.subject) out.push({ kind: 'commit', text: 'Committed ' + (f.sha ? '`' + String(f.sha).slice(0, 7) + '` ' : '') + String(f.subject) })
|
|
34
|
+
else if (f.kind === 'push' && f.branch) out.push({ kind: 'push', text: 'Pushed ' + String(f.branch) })
|
|
35
|
+
else if (f.kind === 'tests') out.push({ kind: f.ok ? 'pass' : 'fail', text: (f.ok ? 'Tests passed' : 'Tests failing') + (f.passed != null ? ': ' + (f.ok ? f.passed + ' passed' : f.failed + ' failed, ' + f.passed + ' passed') : '') + (f.cmd ? ' (`' + String(f.cmd).replace(/`/g, '') + '`)' : '') })
|
|
36
|
+
}
|
|
37
|
+
// the PRs and tests matter most; commits fill what's left
|
|
38
|
+
const top = out.filter((x) => x.kind !== 'commit' && x.kind !== 'push'), rest = out.filter((x) => x.kind === 'commit' || x.kind === 'push')
|
|
39
|
+
const all = [...top, ...rest]
|
|
40
|
+
if (all.length <= max) return all
|
|
41
|
+
return [...all.slice(0, max - 1), { kind: 'more', text: '+' + (all.length - max + 1) + ' more' }]
|
|
42
|
+
}
|
package/ui/decisions.js
CHANGED
|
@@ -207,7 +207,8 @@ export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
|
|
|
207
207
|
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>'
|
|
208
208
|
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. Questions you answered and plans you approved are accepted on their own. 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>'
|
|
209
209
|
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>' +
|
|
210
|
-
'<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button>' +
|
|
210
|
+
'<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button>' +
|
|
211
|
+
'<button type="button" class="btn save-html" data-action="dec-save-html" data-tip="Save as HTML · one file with nothing linked out: opens offline, on a phone, and prints">Save as HTML</button>' + testplanLinkHtml(sc, { esc, cls: 'btn' }) + '</div>' // 2026-11-06
|
|
211
212
|
h += '<section class="proj-sec dec-timeline"><h2>Timeline <span class="count">' + rows.length + '</span></h2>' + ctl +
|
|
212
213
|
(rows.length ? '<ol class="dec-tl">' + rows.map((d) => decisionItemHtml(d, opts)).join('') + '</ol>' : list.length ? '<p class="faint">Nothing accepted yet.</p>' : decisionsEmptyHtml({ esc })) + '</section>'
|
|
213
214
|
return '<div class="proj dec-page">' + h + '</div>'
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The digest's version note (2026-11-06, src/history.js): a quiet "v3 · changed since 9:00 AM" under the
|
|
2
|
+
// title when a day's digest was made more than once, and a list to open an older version
|
|
3
|
+
// (#/digest/<date>?v=N). "Since" is the version this device last opened, kept in localStorage; with none
|
|
4
|
+
// (or the newest), it just says how many versions there are. An older version says so in words, with a
|
|
5
|
+
// way back. Pure functions plus one paint; the board's renderer is not touched.
|
|
6
|
+
|
|
7
|
+
export const SEEN_KEY = 'lo.digestSeen'
|
|
8
|
+
const hm = (iso) => { const t = new Date(iso); return Number.isNaN(t.getTime()) ? '' : t.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) }
|
|
9
|
+
|
|
10
|
+
// { date: version } this device opened
|
|
11
|
+
export function readSeen(storage) { try { const o = JSON.parse(storage.getItem(SEEN_KEY) || '{}'); return o && typeof o === 'object' ? o : {} } catch { return {} } }
|
|
12
|
+
export function markSeen(storage, date, version) {
|
|
13
|
+
try {
|
|
14
|
+
const o = readSeen(storage)
|
|
15
|
+
if ((o[date] || 0) >= version) return
|
|
16
|
+
o[date] = version
|
|
17
|
+
const keep = Object.keys(o).sort().slice(-30) // a month of days is plenty
|
|
18
|
+
storage.setItem(SEEN_KEY, JSON.stringify(Object.fromEntries(keep.map((k) => [k, o[k]]))))
|
|
19
|
+
} catch {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// d: the GET /v1/digests/<date> answer; seen: the version this device last opened (or null)
|
|
23
|
+
// -> { label, older, latest, items: [{ version, label, href, current, shown }] } | null (one version: nothing to show)
|
|
24
|
+
export function versionNote(d, date, seen = null) {
|
|
25
|
+
const vs = d && d.versions
|
|
26
|
+
if (!vs || !Array.isArray(vs.versions) || vs.count < 2) return null
|
|
27
|
+
const shown = d.version || vs.latest
|
|
28
|
+
const latest = vs.versions.find((v) => v.version === vs.latest) || vs.versions[vs.versions.length - 1]
|
|
29
|
+
const since = seen && seen < vs.latest ? vs.versions.find((v) => v.version === seen) : null
|
|
30
|
+
const label = shown !== vs.latest ? 'v' + shown + ' of ' + vs.count : since ? 'v' + vs.latest + ' · changed since ' + hm(since.generatedAt) : 'v' + vs.latest + ' · ' + vs.count + ' versions'
|
|
31
|
+
const items = vs.versions.slice().reverse().map((v) => ({ version: v.version, label: 'v' + v.version + ' · ' + hm(v.generatedAt) + (v.version === vs.latest ? ' (latest)' : ''), href: '#/digest/' + date + (v.version === vs.latest ? '' : '?v=' + v.version), current: v.version === vs.latest, shown: v.version === shown }))
|
|
32
|
+
return { label, older: shown !== vs.latest, shown, latest: vs.latest, latestAt: latest.generatedAt, shownAt: (vs.versions.find((v) => v.version === shown) || {}).generatedAt, items }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function versionNoteHtml(n, { esc, date }) {
|
|
36
|
+
if (!n) return null
|
|
37
|
+
// a div, not a ul: the digest's repo filter shows and hides every ul in the article
|
|
38
|
+
const menu = '<div class="dv-list" id="dvList" role="list" hidden>' + n.items.map((i) => '<a role="listitem" href="' + esc(i.href) + '"' + (i.shown ? ' aria-current="page"' : '') + '>' + (i.shown ? '● ' : '○ ') + esc(i.label) + '</a>').join('') + '</div>'
|
|
39
|
+
const btn = '<button type="button" class="dv-btn" aria-expanded="false" aria-controls="dvList" data-tip="Earlier versions of this digest">' + esc(n.label) + ' ▾</button>'
|
|
40
|
+
const old = n.older ? '<div class="banner dv-old" role="status"><span class="grow"><b>◷ An earlier version</b>: v' + n.shown + ', made ' + esc(hm(n.shownAt)) + '. The latest is v' + n.latest + ' (' + esc(hm(n.latestAt)) + ').</span><a class="btn" href="#/digest/' + esc(date) + '">Back to latest</a></div>' : ''
|
|
41
|
+
return { old, note: '<span class="dv">' + btn + menu + '</span>' }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// After the digest is on the page: the note on its own line under the meta line, the banner above the article.
|
|
45
|
+
export function paintDigestVersion(root, d, date, { esc, storage }) {
|
|
46
|
+
const seen = storage ? readSeen(storage)[date] || null : null
|
|
47
|
+
const n = versionNote(d, date, seen)
|
|
48
|
+
if (storage && d && d.version && d.versions && d.version === d.versions.latest) markSeen(storage, date, d.version)
|
|
49
|
+
if (!n) return null
|
|
50
|
+
const html = versionNoteHtml(n, { esc, date })
|
|
51
|
+
const meta = root.querySelector('article.md .digest-meta')
|
|
52
|
+
if (meta) meta.insertAdjacentHTML('afterend', '<div class="dv-line">' + html.note + '</div>')
|
|
53
|
+
if (html.old) { const art = root.querySelector('article.md'); if (art) art.insertAdjacentHTML('beforebegin', html.old) }
|
|
54
|
+
const btn = root.querySelector('.dv-btn'), list = root.querySelector('#dvList')
|
|
55
|
+
if (btn && list) btn.addEventListener('click', () => { const open = btn.getAttribute('aria-expanded') !== 'true'; btn.setAttribute('aria-expanded', String(open)); list.hidden = !open })
|
|
56
|
+
return n
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<!-- The HTML file viewer's sandbox (ui/html-preview.js). Served with its own CSP
|
|
4
|
+
(sandbox allow-scripts, inline only unless ?remote=1). It takes one page from
|
|
5
|
+
the board by postMessage and becomes that page. -->
|
|
6
|
+
<script>
|
|
7
|
+
addEventListener('message', function (e) {
|
|
8
|
+
if (e.source !== parent || !e.data || e.data.t !== 'lo-hp-html') return
|
|
9
|
+
document.open(); document.write(String(e.data.html)); document.close()
|
|
10
|
+
})
|
|
11
|
+
parent.postMessage({ t: 'lo-hp-ready' }, '*')
|
|
12
|
+
</script>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// 2026-11-06 An HTML file in the viewer renders as a page (Preview · Code).
|
|
2
|
+
// The page never runs in the board: it runs in ui/html-preview.html, a shell
|
|
3
|
+
// served with its own CSP (previewCsp) whose `sandbox` gives it an opaque
|
|
4
|
+
// origin, so it can't read the board's storage or cookies, and its calls to
|
|
5
|
+
// the daemon carry Origin: null (refused). The board hands it the file by
|
|
6
|
+
// postMessage. Remote resources (fonts, CDN scripts, images) are blocked until
|
|
7
|
+
// you press Allow, for that view only; the bar lists what the file asks for.
|
|
8
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
9
|
+
|
|
10
|
+
export const PREVIEW_PAGE = '/html-preview.html'
|
|
11
|
+
export const VIEW_KEY = 'lo-html-view-v1'
|
|
12
|
+
|
|
13
|
+
// The shell's CSP. Inline only by default; `remote` adds http(s) sources.
|
|
14
|
+
// Never 'self': the board's own files stay out of the preview. Copied in
|
|
15
|
+
// relay/src/user.js (a unit test keeps them the same).
|
|
16
|
+
export function previewCsp(remote = false) {
|
|
17
|
+
const r = remote ? ' https: http:' : ''
|
|
18
|
+
return "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'" + r + "; style-src 'unsafe-inline'" + r +
|
|
19
|
+
'; img-src data: blob:' + r + '; font-src data:' + r + '; media-src data: blob:' + r + '; connect-src ' + (remote ? 'https: http:' : "'none'") +
|
|
20
|
+
"; frame-src 'none'; worker-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// An HTML file, from either reader: the session's files (type html) or a path
|
|
24
|
+
// in a message (type code, lang html).
|
|
25
|
+
export function isHtmlDoc(r) {
|
|
26
|
+
if (!r || r.missing || r.tooBig || r.binary || typeof r.text !== 'string') return false
|
|
27
|
+
return r.type === 'html' || (r.type === 'code' && (r.lang === 'html' || /^text\/html\b/.test(r.mime || '')))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Remote resources the file loads by itself (not links you'd click): src,
|
|
31
|
+
// srcset, poster, <link href> (not a resource hint), <object data>, CSS url()
|
|
32
|
+
// and @import. Scripts can fetch more once allowed; the bar says so.
|
|
33
|
+
const HINT_RELS = /\b(preconnect|dns-prefetch|prerender)\b/i
|
|
34
|
+
const LINK_TAGS = /^(a|area|base|form)$/i
|
|
35
|
+
function remoteUrl(u) {
|
|
36
|
+
const s = String(u || '').trim().replace(/^['"]|['"]$/g, '')
|
|
37
|
+
if (/^https?:\/\//i.test(s)) return s
|
|
38
|
+
if (/^\/\/[^/]/.test(s)) return 'https:' + s
|
|
39
|
+
return ''
|
|
40
|
+
}
|
|
41
|
+
function cssUrls(css, add) {
|
|
42
|
+
for (const m of String(css).matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi)) add(m[2], 'style')
|
|
43
|
+
for (const m of String(css).matchAll(/@import\s+(['"])([^'"]+)\1/gi)) add(m[2], 'style')
|
|
44
|
+
}
|
|
45
|
+
export function remoteRefs(html) {
|
|
46
|
+
const seen = new Map()
|
|
47
|
+
const add = (u, kind) => { const url = remoteUrl(u); if (url && !seen.has(url)) { let host = ''; try { host = new URL(url).host } catch { return } seen.set(url, { url, host, kind }) } }
|
|
48
|
+
const src = String(html || '').replace(/<!--[\s\S]*?-->/g, '')
|
|
49
|
+
for (const m of src.matchAll(/<([a-z][\w-]*)\b([^>]*)>/gi)) {
|
|
50
|
+
const tag = m[1].toLowerCase(), attrs = m[2]
|
|
51
|
+
const attr = (n) => { const a = attrs.match(new RegExp('(?:^|\\s)' + n + '\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\'|([^\\s>]+))', 'i')); return a ? (a[1] ?? a[2] ?? a[3]) : null }
|
|
52
|
+
if (LINK_TAGS.test(tag)) continue
|
|
53
|
+
const kind = tag === 'script' ? 'script' : tag === 'link' ? (/stylesheet/i.test(attr('rel') || '') ? 'style' : 'link') : tag === 'img' || tag === 'picture' || tag === 'source' ? 'image' : tag
|
|
54
|
+
if (attr('src')) add(attr('src'), kind)
|
|
55
|
+
if (attr('poster')) add(attr('poster'), 'image')
|
|
56
|
+
if (tag === 'object' && attr('data')) add(attr('data'), 'object')
|
|
57
|
+
if (tag === 'link' && attr('href') && !HINT_RELS.test(attr('rel') || '')) add(attr('href'), kind)
|
|
58
|
+
for (const part of String(attr('srcset') || '').split(',')) add(part.trim().split(/\s+/)[0], 'image')
|
|
59
|
+
if (attr('style')) cssUrls(attr('style'), add)
|
|
60
|
+
}
|
|
61
|
+
for (const m of src.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) cssUrls(m[1], add)
|
|
62
|
+
return [...seen.values()]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The hosts, in first-seen order: "fonts.googleapis.com, fonts.gstatic.com".
|
|
66
|
+
export function refHosts(refs) { return [...new Set((refs || []).map((x) => x.host))] }
|
|
67
|
+
|
|
68
|
+
// What the shell writes: the file, plus a first script that keeps a link click
|
|
69
|
+
// inside the preview from leaving it (it says where the link goes instead),
|
|
70
|
+
// passes Esc on, answers the board's ping, and says when the page is leaving.
|
|
71
|
+
// The preview's own CSP can't stop a page sending its frame somewhere (a script
|
|
72
|
+
// setting location); the board's CSP does (frame-src 'self' covers a child's
|
|
73
|
+
// navigations, so another site is refused before any request). Behind that, the
|
|
74
|
+
// board stops a preview that leaves for anywhere it can go (ping unanswered after
|
|
75
|
+
// a load, or pagehide) and says so. A meta refresh, which
|
|
76
|
+
// leaves with no script, is taken out; while blocked, so are resource hints (a
|
|
77
|
+
// preconnect isn't covered by CSP).
|
|
78
|
+
const GUARD = '<script>(function(){var P=parent,D=document;D.addEventListener("click",function(e){var a=e.target&&e.target.closest&&e.target.closest("a[href],area[href]");if(!a)return;var h=a.getAttribute("href")||"";if(h.charAt(0)==="#")return;e.preventDefault();P.postMessage({t:"lo-hp-link",href:h},"*")},true);D.addEventListener("keydown",function(e){if(e.key==="Escape")P.postMessage({t:"lo-hp-esc"},"*")});addEventListener("message",function(e){if(e.source===P&&e.data&&e.data.t==="lo-hp-ping")P.postMessage({t:"lo-hp-pong",n:e.data.n},"*")});addEventListener("pagehide",function(){P.postMessage({t:"lo-hp-left"},"*")})})()</script>'
|
|
79
|
+
const attrOf = (t, n) => (t.match(new RegExp('\\b' + n + '\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\'|([^\\s>]+))', 'i')) || []).slice(1).join(' ')
|
|
80
|
+
export function previewDoc(html, { remote = false } = {}) {
|
|
81
|
+
let s = String(html || '')
|
|
82
|
+
s = s.replace(/<meta\b[^>]*>/gi, (t) => (/^\s*refresh\s*$/i.test(attrOf(t, 'http-equiv')) ? '' : t))
|
|
83
|
+
if (!remote) s = s.replace(/<link\b[^>]*>/gi, (t) => (HINT_RELS.test(attrOf(t, 'rel')) ? '' : t))
|
|
84
|
+
const dt = s.match(/^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype[^>]*>/i)
|
|
85
|
+
return dt ? dt[0] + GUARD + s.slice(dt[0].length) : GUARD + s
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The bar above the file: Preview · Code, and in Preview what's blocked.
|
|
89
|
+
export function previewBarHtml({ mode = 'preview', refs = [], remote = false, open = false, icon = () => '' } = {}) {
|
|
90
|
+
const seg = (m, label, tip) => '<button type="button" data-action="hp-mode" data-mode="' + m + '" aria-pressed="' + (mode === m) + '" data-tip="' + esc(tip) + '">' + esc(label) + '</button>'
|
|
91
|
+
let h = '<div class="wt-seg wt-seg-sm hp-seg" role="group" aria-label="Show the file as">' + seg('preview', 'Preview', 'The page, rendered in a sandbox') + seg('code', 'Code', 'The file’s source') + '</div>'
|
|
92
|
+
if (mode === 'preview' && refs.length) {
|
|
93
|
+
const hosts = refHosts(refs)
|
|
94
|
+
const n = refs.length
|
|
95
|
+
const words = remote ? 'Loading ' + n + ' remote ' + (n === 1 ? 'resource' : 'resources') : n + ' remote ' + (n === 1 ? 'resource' : 'resources') + ' blocked'
|
|
96
|
+
h += '<div class="hp-remote' + (remote ? ' on' : '') + '">' + icon(remote ? 'globe' : 'shield', 'ic hp-ric') +
|
|
97
|
+
'<span class="hp-rw"><b>' + esc(words) + '</b><span class="hp-hosts"> from ' + esc(hosts.slice(0, 2).join(', ') + (hosts.length > 2 ? ' and ' + (hosts.length - 2) + ' more' : '')) + '</span></span>' +
|
|
98
|
+
'<button type="button" class="btn sm hp-show" data-action="hp-show" aria-expanded="' + !!open + '" aria-controls="hpList">' + (open ? 'Hide' : 'Show') + '</button>' +
|
|
99
|
+
(remote ? '<button type="button" class="btn sm" data-action="hp-block" data-tip="Reload the preview with remote resources blocked">Block</button>'
|
|
100
|
+
: '<button type="button" class="btn sm" data-action="hp-allow" data-tip="Load these, and anything they load, for this view only">Allow</button>') + '</div>'
|
|
101
|
+
if (open) {
|
|
102
|
+
h += '<ul class="hp-list" id="hpList">' + refs.map((x) => '<li><span class="hp-kind">' + esc(x.kind) + '</span><code>' + esc(x.url) + '</code></li>').join('') + '</ul>' +
|
|
103
|
+
'<p class="hp-note faint">' + (remote ? '' : 'Allowing also lets the page’s scripts load anything else they ask for. ') + 'Links in the page don’t open from the preview.</p>'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return h
|
|
107
|
+
}
|
package/ui/icons.js
CHANGED
|
@@ -57,6 +57,7 @@ export const ICONS = {
|
|
|
57
57
|
"layers": "<path d=\"M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z\"/> <path d=\"M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12\"/> <path d=\"M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17\"/>",
|
|
58
58
|
"bot": "<path d=\"M12 8V4H8\"/> <rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\"/> <path d=\"M2 14h2\"/> <path d=\"M20 14h2\"/> <path d=\"M15 13v2\"/> <path d=\"M9 13v2\"/>",
|
|
59
59
|
"smartphone": "<rect width=\"14\" height=\"20\" x=\"5\" y=\"2\" rx=\"2\" ry=\"2\"/> <path d=\"M12 18h.01\"/>",
|
|
60
|
+
"globe": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\"/> <path d=\"M2 12h20\"/>",
|
|
60
61
|
"shield": "<path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\"/>",
|
|
61
62
|
"info": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M12 16v-4\"/> <path d=\"M12 8h.01\"/>",
|
|
62
63
|
"chevron-right": "<path d=\"m9 18 6-6-6-6\"/>",
|
package/ui/mcp-setup.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// `leftoff mcp` setup (2026-11-06): the commands you run yourself to let Claude Code or Codex search
|
|
2
|
+
// Leftoff's digests, decisions and checkpoints. Leftoff never writes ~/.claude or ~/.codex; the board
|
|
3
|
+
// shows these with a Copy button, `leftoff doctor` says whether it looks added. Shared by the board and the CLI.
|
|
4
|
+
export const MCP_CLAUDE_LINE = 'claude mcp add --scope user leftoff -- leftoff mcp'
|
|
5
|
+
export const MCP_CODEX_LINE = 'codex mcp add leftoff -- leftoff mcp'
|
|
6
|
+
export const MCP_CODEX_TOML = '[mcp_servers.leftoff]\ncommand = "leftoff"\nargs = ["mcp"]'
|
|
7
|
+
|
|
8
|
+
const row = (cmd, label, esc, icon) => '<div class="mcp-row"><code class="mono">' + esc(cmd) + '</code>' +
|
|
9
|
+
'<button type="button" class="copy-float" data-action="copy-cmd" data-cmd="' + esc(cmd) + '" aria-label="' + esc(label) + '" data-tip="Copy">' + icon('copy') + '</button></div>'
|
|
10
|
+
|
|
11
|
+
// One registration's state, by shape and words: ✓ added, ○ not added, ? couldn't tell.
|
|
12
|
+
export function mcpStateHtml(r, esc) {
|
|
13
|
+
if (!r) return ''
|
|
14
|
+
const one = (who, x) => !x ? '' : x.registered ? '<span class="mcp-state ok">✓ Added to ' + who + (x.scope ? ' (' + esc(x.scope) + ')' : '') + '</span>' : x.error ? '<span class="mcp-state unk">? ' + esc(who) + ': ' + esc(x.error) + '</span>' : '<span class="mcp-state no">○ Not added to ' + who + ' yet</span>'
|
|
15
|
+
return '<div class="mcp-states">' + one('Claude Code', r.claude) + one('Codex', r.codex) + '</div>'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The Settings → Digest part: "Let Claude search your notes".
|
|
19
|
+
export function mcpSetupHtml(reg, { esc, icon }) {
|
|
20
|
+
return '<div class="section" id="mcp">Let Claude search your notes</div>' +
|
|
21
|
+
'<div class="full hint">Claude Code can search your digests, decisions and checkpoints (“what did we decide about the relay?”) through <code>leftoff mcp</code>. It only reads, on this machine; nothing leaves it. Run this once in a terminal:</div>' +
|
|
22
|
+
'<div class="full mcp-box">' + row(MCP_CLAUDE_LINE, 'Copy the Claude Code command', esc, icon) +
|
|
23
|
+
'<details class="mcp-codex"><summary>Using Codex? Show its command</summary>' + row(MCP_CODEX_LINE, 'Copy the Codex command', esc, icon) +
|
|
24
|
+
'<div class="hint">Or add to <code>~/.codex/config.toml</code> yourself:</div><pre class="mono">' + esc(MCP_CODEX_TOML) + '</pre></details>' +
|
|
25
|
+
'<div id="mcpState">' + mcpStateHtml(reg, esc) + '</div></div>'
|
|
26
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The digest's markdown as HTML: one renderer for the board (ui/app.js) and for
|
|
2
|
+
// `leftoff export digest --html` (ui/render-export.js, through node), so both say
|
|
3
|
+
// the same thing. 2026-11-06 moved here from ui/app.js's renderMd, unchanged for the
|
|
4
|
+
// board: its Go / Resume buttons, session links and Copy for standup come in as hooks
|
|
5
|
+
// (action, href, standup); an exported file passes none of them and gets plain words.
|
|
6
|
+
// No DOM, no imports: runs in a browser and in node alike.
|
|
7
|
+
|
|
8
|
+
export const escR = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
9
|
+
export const boardHref = (id) => '#/s/' + encodeURIComponent(id)
|
|
10
|
+
|
|
11
|
+
// 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
|
|
12
|
+
// links there and Start here gets that session's Go / Resume.
|
|
13
|
+
export const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
|
|
14
|
+
|
|
15
|
+
export function digestBodyHtml(md, { spaces = [], esc = escR, href = boardHref, action = null, standup = null, decLinks = true } = {}) {
|
|
16
|
+
const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
17
|
+
// testplan Part 2: the Decisions part links into the log: [D-7f3a](#/decisions/…) only, nothing else becomes a link
|
|
18
|
+
const inline = (t) => esc(t).replace(/\[([^\]]{1,80})\]\((#\/decisions\/[^)\s"<>]{1,200})\)/g, decLinks ? '<a class="dg-dec" href="$2">$1</a>' : '<span class="dg-dec">$1</span>').replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/(^|[^\w*])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>') // not inside snake_case names (clutch_hockey)
|
|
19
|
+
const out = []
|
|
20
|
+
let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false, inDone = false
|
|
21
|
+
const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
|
|
22
|
+
const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
|
|
23
|
+
// a session's link: the board's #/s/<id>; an exported file has none (href returns null)
|
|
24
|
+
const link = (id, html, cls = '') => { const u = id && href ? href(id) : null; return u ? '<a' + (cls ? ' class="' + cls + '"' : '') + ' href="' + esc(u) + '">' + html + '</a>' : (cls ? '<span class="' + cls + '">' + html + '</span>' : html) }
|
|
25
|
+
const act = (id) => (id && action ? action(id) : '')
|
|
26
|
+
// v0.5.8: each "## <space>" opens a section with its own Copy for standup;
|
|
27
|
+
// other "## " headings (Lists, Top of mind) are whole-folder sections.
|
|
28
|
+
// 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
|
|
29
|
+
const openSec = (h) => {
|
|
30
|
+
if (inSec) out.push('</section>')
|
|
31
|
+
inSec = true
|
|
32
|
+
if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
|
|
33
|
+
else if (h === 'What you got done') out.push('<section class="done-sec"><h2>' + inline(h) + '</h2>') // 2026-11-06
|
|
34
|
+
else if (spaces.includes(h)) {
|
|
35
|
+
out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
|
|
36
|
+
(standup ? standup(h) : '') + '</div>')
|
|
37
|
+
} else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
|
|
38
|
+
}
|
|
39
|
+
for (const raw of body.split('\n')) {
|
|
40
|
+
const mid = raw.match(MD_SID)
|
|
41
|
+
const id = mid ? mid[1] : null
|
|
42
|
+
const line = mid ? raw.slice(0, mid.index) : raw
|
|
43
|
+
// 0.9.8: Start here: "1. <step> — **Title** (repo)"
|
|
44
|
+
const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
|
|
45
|
+
if (st) {
|
|
46
|
+
openList('ol', 'start')
|
|
47
|
+
const t = id ? link(id, inline(st[2])) : inline(st[2])
|
|
48
|
+
out.push('<li' + (id ? ' data-sid="' + esc(id) + '"' : '') + '><div class="sh-main"><span class="sh-step">' + inline(st[1]) + '</span><span class="sh-sess">' + t + (st[3] ? ' <span class="sh-repo">' + esc(st[3]) + '</span>' : '') + '</span></div>' +
|
|
49
|
+
(id && action ? '<div class="sh-act">' + act(id) + '</div>' : '') + '</li>')
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
// 2026-11-06: What you got done: "- Merged PR #53: …", "- Still open: **Title**, next: …" (its session linked)
|
|
53
|
+
const dn = inDone && /^- /.test(line) && line.match(/^- (Merged|Opened|Set to merge when checks pass|Also committed|Committed|Tests passed|Tests failing|Decided|Waiting on you|Still open|\+\d+ more decisions)\b(.*)$/)
|
|
54
|
+
if (dn) {
|
|
55
|
+
openList('ul', 'done')
|
|
56
|
+
const kind = { 'Merged': 'merged', 'Opened': 'opened', 'Set to merge when checks pass': 'opened', 'Also committed': 'commit', 'Committed': 'commit', 'Tests passed': 'pass', 'Tests failing': 'fail', 'Decided': 'decided', 'Waiting on you': 'you', 'Still open': 'open' }[dn[1]] || 'decided'
|
|
57
|
+
const so = id && dn[2].match(/^: \*\*(.+?)\*\*(.*)$/)
|
|
58
|
+
const rest = so ? ': ' + link(id, inline(so[1])) + inline(so[2]) : inline(dn[2])
|
|
59
|
+
out.push('<li class="dn dn-' + kind + '"' + (id ? ' data-sid="' + esc(id) + '"' : '') + '><b>' + esc(dn[1]) + '</b>' + rest + '</li>')
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
if (/^- /.test(line)) {
|
|
63
|
+
openList('ul')
|
|
64
|
+
// Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
|
|
65
|
+
const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
|
|
66
|
+
const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
|
|
67
|
+
// 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
|
|
68
|
+
// session: the title links to it, the sentence under it, the tags quiet.
|
|
69
|
+
const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
|
|
70
|
+
// 0.9.8: an Unfinished one is closed, so it has its Resume beside it
|
|
71
|
+
if (bi && inUnf) { out.push('<li class="bi unf"' + attrs + '><div class="bi-body"><div class="bi-head">' + link(id, inline(bi[1]), 'bi-title') + '<span class="bi-tag">' + esc(bi[2]) + '</span></div><span class="bi-say">' + inline(bi[3]) + '</span></div>' + (action ? '<div class="sh-act">' + act(id) + '</div>' : '')); continue }
|
|
72
|
+
if (bi) { out.push('<li class="bi"' + attrs + '><div class="bi-head">' + link(id, inline(bi[1]), 'bi-title') + '<span class="bi-tag">' + esc(bi[2]) + '</span></div><span class="bi-say">' + inline(bi[3]) + '</span>'); continue }
|
|
73
|
+
// v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
|
|
74
|
+
const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
|
|
75
|
+
out.push('<li' + attrs + '>' + li); continue
|
|
76
|
+
}
|
|
77
|
+
if (/^\s{2,}\S/.test(line) && inList) {
|
|
78
|
+
// "Stopped at: … Next: …" is two lines, each with its label.
|
|
79
|
+
const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you|Merged|Commits): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
|
|
80
|
+
if (sm) out.push('<span class="sub"><span class="k">' + sm[1] + '</span> ' + inline(sm[2]) + '</span>' + (sm[3] ? '<span class="sub"><span class="k">' + sm[3] + '</span> ' + inline(sm[4]) + '</span>' : ''))
|
|
81
|
+
else out.push('<span class="sub">' + inline(t) + '</span>')
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
closeList()
|
|
85
|
+
if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
|
|
86
|
+
if (/^## /.test(line)) inDone = line.slice(3).trim() === 'What you got done'
|
|
87
|
+
if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
|
|
88
|
+
else if (/^## /.test(line)) { seenH2 = true; const h = line.slice(3).trim(); if (spaces.length || h === 'Start here' || h === 'What you got done') openSec(h); else { if (inSec) { out.push('</section>'); inSec = false } out.push('<h2>' + inline(h) + '</h2>') } }
|
|
89
|
+
// 0.9.8: Finished folds away (older digests' "Finished or closed" too)
|
|
90
|
+
else if (/^### Finished(?: or closed)? \(\d+\)\s*$/.test(line)) { out.push('<details class="fin"><summary>' + inline(line.slice(4).replace(/ or closed/, '')) + '</summary>'); inFin = true }
|
|
91
|
+
else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
|
|
92
|
+
// 0.9.8: the Yesterday / Today opener, above the first section
|
|
93
|
+
else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
|
|
94
|
+
}
|
|
95
|
+
closeList()
|
|
96
|
+
if (inSec) out.push('</section>')
|
|
97
|
+
return out.join('\n')
|
|
98
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// One self-contained .html file for a digest, a decision log or a walkthrough
|
|
2
|
+
// (2026-11-06, phase 2b). The board's "Save as HTML" and `leftoff export … --html`
|
|
3
|
+
// both call these, so the file is the same from either. The file carries its CSS
|
|
4
|
+
// inline and nothing else: no scripts, fonts, images or links out (a Content
|
|
5
|
+
// Security Policy says so too), light and dark from the reader's own setting,
|
|
6
|
+
// readable on a phone, and it prints. No DOM and no node-only imports: runs in a
|
|
7
|
+
// browser and in node.
|
|
8
|
+
import { digestBodyHtml, escR as esc } from './render-digest.js'
|
|
9
|
+
import { EXPORT_CSS, toHtmlDoc, exportName as wtExportName } from './semdiff.js'
|
|
10
|
+
import { STATUS_SHAPE, STATUS_WORD, SOURCE_WORDS, timelineRows } from './decisions.js'
|
|
11
|
+
|
|
12
|
+
export const EXPORT_CSP = "default-src 'none'; style-src 'unsafe-inline'"
|
|
13
|
+
|
|
14
|
+
// A time as words, the same on the board and the CLI (en-US, the reader's own zone unless one is given)
|
|
15
|
+
export function exportWhen(iso, { timeZone } = {}) {
|
|
16
|
+
const t = Date.parse(iso || '')
|
|
17
|
+
if (!Number.isFinite(t)) return ''
|
|
18
|
+
return new Date(t).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', ...(timeZone ? { timeZone } : {}) })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A file name for the download / the CLI's default: "leftoff-digest-2026-11-06.html"
|
|
22
|
+
export function exportFileName(kind, label = '') {
|
|
23
|
+
const slug = String(label || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48)
|
|
24
|
+
if (kind === 'walkthrough') return wtExportName(label)
|
|
25
|
+
return 'leftoff-' + kind + (slug ? '-' + slug : '') + '.html'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The page around a body: the same head for every export.
|
|
29
|
+
export function exportDoc({ title, css = '', body }) {
|
|
30
|
+
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">' +
|
|
31
|
+
'<meta name="color-scheme" content="light dark"><meta http-equiv="Content-Security-Policy" content="' + esc(EXPORT_CSP) + '">' +
|
|
32
|
+
'<title>' + esc(title) + '</title><style>' + EXPORT_CSS + PAGE_CSS + css + '</style></head><body><main>' + body + '</main></body></html>\n'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Shared by the digest and the decision log: phone width, print, reduced motion (nothing moves anyway)
|
|
36
|
+
const PAGE_CSS = 'html{-webkit-text-size-adjust:100%;text-size-adjust:100%}main{padding:24px 16px 48px}h3{font-size:15px;margin:18px 0 4px}p{margin:0 0 10px;overflow-wrap:anywhere}li{overflow-wrap:anywhere}' +
|
|
37
|
+
'.foot{margin-top:40px;padding-top:10px;border-top:1px solid #d1d9e0;color:#59636e;font-size:12px}' +
|
|
38
|
+
'@media (min-width:600px){main{padding:32px 24px 64px}}' +
|
|
39
|
+
'@media print{body{background:#fff;color:#000;font-size:12pt}main{max-width:none;padding:0}section,li{break-inside:avoid}a{color:inherit;text-decoration:none}.foot{color:#555}}' +
|
|
40
|
+
'@media (prefers-color-scheme:dark){.foot{border-color:#3d444d;color:#9198a1}}'
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------- the digest
|
|
43
|
+
|
|
44
|
+
const DIGEST_CSS = '.lede{font-size:16px;margin:0 0 10px}.meta{color:#59636e;font-size:13px;margin:0 0 18px}' +
|
|
45
|
+
'section{margin:22px 0 0}section.done-sec{margin:16px 0 4px;padding:10px 14px 6px;border:1px solid #d1d9e0;border-radius:10px;background:#f6f8fa}' +
|
|
46
|
+
'section.done-sec h2{margin:2px 0;font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#59636e}section.done-sec h3{margin:12px 0 4px;font-size:14px}' +
|
|
47
|
+
'ul.done{list-style:none;margin:0;padding:0}li.dn{position:relative;padding:3px 0 3px 22px;line-height:1.45}li.dn b{font-weight:600}' +
|
|
48
|
+
'li.dn::before{position:absolute;left:2px;top:3px;width:16px;text-align:center;font-weight:700;color:#59636e}' +
|
|
49
|
+
"li.dn-merged::before{content:'✓'}li.dn-opened::before{content:'○'}li.dn-commit::before{content:'•'}li.dn-pass::before{content:'✓'}li.dn-fail::before{content:'✗'}li.dn-decided::before{content:'◆';font-size:11px}li.dn-you::before{content:'!'}li.dn-open::before{content:'→'}li.dn-more::before{content:'+'}" +
|
|
50
|
+
'ol.start{padding-left:22px}ol.start li{margin:6px 0}.sh-main{display:flex;flex-direction:column;gap:2px}.sh-step{font-weight:600}.sh-sess{color:#59636e;font-size:14px}.sh-repo,.where,.bi-tag{color:#59636e;font-size:13px}' +
|
|
51
|
+
'ul{padding-left:20px}ul li{margin:6px 0}.bi-head{display:flex;flex-wrap:wrap;gap:4px 8px;align-items:baseline}.bi-title{font-weight:600}.bi-say{display:block}.sub{display:block;color:#59636e;font-size:14px}.sub .k{font-weight:600;margin-right:4px}' +
|
|
52
|
+
'.space-head h2{margin:0}details.fin summary{min-height:44px;display:flex;align-items:center}' +
|
|
53
|
+
'@media print{details.fin>*{display:block}section.done-sec{background:none}}' +
|
|
54
|
+
'@media (prefers-color-scheme:dark){section.done-sec{background:#161b22;border-color:#3d444d}.meta,.sh-sess,.sh-repo,.where,.bi-tag,.sub,section.done-sec h2,li.dn::before{color:#9198a1}}'
|
|
55
|
+
|
|
56
|
+
const frontmatter = (md) => {
|
|
57
|
+
const m = String(md || '').match(/^---\n([\s\S]*?)\n---\n/)
|
|
58
|
+
const out = {}
|
|
59
|
+
if (m) for (const l of m[1].split('\n')) { const kv = l.match(/^([a-z_]+):\s*(.*)$/); if (kv) out[kv[1]] = kv[2].trim() }
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
const listOf = (v) => (/^\[.*\]$/.test(v || '') ? v.slice(1, -1).split(',').map((x) => x.trim()).filter(Boolean) : [])
|
|
63
|
+
|
|
64
|
+
// md: the digest file as it is on disk (front matter and all). The session
|
|
65
|
+
// buttons, links into the board and Copy for standup are the board's; the file
|
|
66
|
+
// has the words only.
|
|
67
|
+
export function digestExportHtml(md, { date = null, timeZone } = {}) {
|
|
68
|
+
const fm = frontmatter(md)
|
|
69
|
+
const day = date || fm.date || ''
|
|
70
|
+
const made = exportWhen(fm.generated_at, { timeZone })
|
|
71
|
+
const meta = '<p class="meta">' + esc(['Daily digest', day, made ? 'made ' + made : ''].filter(Boolean).join(' · ')) + '</p>'
|
|
72
|
+
let body = digestBodyHtml(String(md || ''), { spaces: listOf(fm.spaces), href: null, action: null, standup: null, decLinks: false })
|
|
73
|
+
.replace(/<details class="fin">/g, '<details class="fin" open>') // a file is read whole (and printed): Finished starts open
|
|
74
|
+
body = /<h1>[\s\S]*?<\/h1>/.test(body) ? body.replace(/(<h1>[\s\S]*?<\/h1>)/, (h) => h + meta) : '<h1>Daily digest</h1>' + meta + body
|
|
75
|
+
return exportDoc({ title: 'Digest · ' + day, css: DIGEST_CSS, body: body + foot() })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------- the decision log
|
|
79
|
+
|
|
80
|
+
const DEC_CSS = '.dl{list-style:none;margin:0;padding:0}.dl>li{padding:12px 0;border-top:1px solid #d1d9e0}.dl>li:first-child{border-top:0}' +
|
|
81
|
+
'.dh{display:flex;flex-wrap:wrap;gap:4px 10px;align-items:baseline;font-size:13px;color:#59636e}.dh .id{font:12px ui-monospace,SFMono-Regular,Menlo,monospace}.st{color:#1f2328;font-weight:600}' +
|
|
82
|
+
'.dt{font-size:15px;margin:4px 0 2px}.dw,.da,.dc,.dm{font-size:14px;color:#59636e;margin:2px 0}.count{color:#59636e;font-weight:400}' +
|
|
83
|
+
'.dl>li.superseded .dt,.dl>li.rejected .dt{text-decoration:line-through;text-decoration-thickness:1px}' +
|
|
84
|
+
'@media (prefers-color-scheme:dark){.dl>li{border-color:#3d444d}.dh,.dw,.da,.dc,.dm,.count{color:#9198a1}.st{color:#e6edf3}}'
|
|
85
|
+
|
|
86
|
+
const dday = (iso) => String(iso || '').slice(0, 10)
|
|
87
|
+
function decisionItem(d, byId) {
|
|
88
|
+
const st = STATUS_SHAPE[d.status] ? '<span class="st"><span aria-hidden="true">' + STATUS_SHAPE[d.status] + '</span> ' + esc(STATUS_WORD[d.status]) + '</span>' : ''
|
|
89
|
+
const chain = []
|
|
90
|
+
for (const id of d.supersedes || []) { const o = byId.get(id); chain.push('Replaces ' + esc(id) + (o ? ' (' + esc(dday(o.createdAt)) + ': ' + esc(o.text) + ')' : '')) }
|
|
91
|
+
if (d.supersededBy) chain.push('Superseded by ' + esc(d.supersededBy) + (d.supersededAt ? ' on ' + esc(dday(d.supersededAt)) : ''))
|
|
92
|
+
const tp = /^testplan:(tp_[0-9a-f]{16})#c(\d+)$/.exec(String(d.checkpointId || ''))
|
|
93
|
+
const from = tp ? 'From a test plan (check ' + esc(tp[2]) + ')' : d.session && d.session.title ? 'From ' + esc(d.session.title) + (d.session.pr && d.session.pr.number ? ' · PR #' + esc(d.session.pr.number) : '') : ''
|
|
94
|
+
const meta = [esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent), from].filter(Boolean).join(' · ')
|
|
95
|
+
return '<li class="' + esc(d.status) + '"><div class="dh"><span>' + esc(dday(d.createdAt)) + '</span><span class="id">' + esc(d.id) + '</span>' + (d.repoLabel && d.multi ? '<span>' + esc(d.repoLabel) + '</span>' : '') + st + '</div>' +
|
|
96
|
+
'<div class="dt">' + esc(d.text) + (d.edited ? ' <span class="dm">(edited)</span>' : '') + '</div>' +
|
|
97
|
+
(d.why ? '<div class="dw">' + esc(d.why) + '</div>' : '') +
|
|
98
|
+
(d.alternatives && d.alternatives.length ? '<div class="da">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
|
|
99
|
+
(chain.length ? '<div class="dc">' + chain.join(' · ') + '</div>' : '') +
|
|
100
|
+
'<div class="dm">' + meta + '</div></li>'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// v: GET /v1/decisions (engine.decisionsView). all: superseded and rejected too.
|
|
104
|
+
export function decisionsExportHtml(v, { all = false } = {}) {
|
|
105
|
+
const list = (v && v.decisions) || []
|
|
106
|
+
const byId = new Map(list.map((d) => [d.id, d]))
|
|
107
|
+
const multi = ((v && v.repos) || []).length > 1
|
|
108
|
+
const sc = (v && v.scope) || {}
|
|
109
|
+
const name = (sc.kind === 'project' ? sc.name : sc.label) || sc.id || ''
|
|
110
|
+
const { review, rows } = timelineRows(list, { all })
|
|
111
|
+
const item = (d) => decisionItem({ ...d, multi }, byId)
|
|
112
|
+
let b = '<h1>Decisions' + (name ? ' · ' + esc(name) : '') + '</h1>'
|
|
113
|
+
b += '<p class="meta f">' + esc([multi ? v.repos.map((r) => r.label).join(' · ') : '', all ? 'Accepted, superseded and rejected' : 'Accepted decisions'].filter(Boolean).join(' · ')) + '</p>'
|
|
114
|
+
if (review.length) b += '<section><h2>To review <span class="count">' + review.length + '</span></h2><ol class="dl">' + review.map(item).join('') + '</ol></section>'
|
|
115
|
+
b += '<section><h2>' + (all ? 'Timeline' : 'Decided') + ' <span class="count">' + rows.length + '</span></h2>' + (rows.length ? '<ol class="dl">' + rows.map(item).join('') + '</ol>' : '<p class="f">Nothing accepted yet.</p>') + '</section>'
|
|
116
|
+
return exportDoc({ title: 'Decisions' + (name ? ' · ' + name : ''), css: DEC_CSS, body: b + foot() })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------- a walkthrough
|
|
120
|
+
|
|
121
|
+
// data: the walkthrough (POST /v1/sessions/<id>/walkthrough); s: its session ({ title, repoLabel, repoName, repo: { branch } })
|
|
122
|
+
export function walkthroughExportHtml(data, s = {}, { timeZone } = {}) {
|
|
123
|
+
const title = (s && s.title) || 'Session'
|
|
124
|
+
const repo = [s.repoLabel || s.repoName || '', s.repo && s.repo.branch].filter(Boolean).join(' · ')
|
|
125
|
+
return toHtmlDoc(data, { title, repo, when: data && data.generatedAt ? exportWhen(data.generatedAt, { timeZone }) : '' })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const foot = () => '<p class="foot">Saved from Leftoff. One file: it opens offline and asks nothing of the network.</p>'
|