@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.18-beta.
|
|
3
|
+
"version": "0.9.18-beta.14",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "One Silly App"
|
package/mod/hooks/core.js
CHANGED
|
@@ -508,6 +508,10 @@ export function renderCheckpointMd(cp) {
|
|
|
508
508
|
const bullet = (x) => '- ' + oneLine(x).replace(/^\*+/, '')
|
|
509
509
|
// 2026-11-03 decision memory: the decisions this checkpoint saw, first of the
|
|
510
510
|
// sections, so an older reader (no section open yet) skips the heading and its lines.
|
|
511
|
+
// 2026-11-06: what the session's commands show got done (workFacts), first of all, so the
|
|
512
|
+
// digest can say what shipped in past tense; an older reader skips the heading and its lines
|
|
513
|
+
const work = (Array.isArray(cp.work) ? cp.work : []).map(renderWorkLine).filter(Boolean)
|
|
514
|
+
if (work.length) { lines.push('', '### ' + WORK_TITLE); for (const w of work) lines.push('- ' + w) }
|
|
511
515
|
const decided = cleanDecided(cp.decided)
|
|
512
516
|
if (decided.length) { lines.push('', '### ' + DECIDED_TITLE); for (const d of decided) lines.push(renderDecidedLine(d)) }
|
|
513
517
|
for (const k of SECTION_ORDER) {
|
|
@@ -566,7 +570,15 @@ export function parseCheckpointMd(text) {
|
|
|
566
570
|
if (cp.format === 'markdown') for (const f of ['doing', 'stoppedAt', 'next']) cp[f] = cp[f].replace(/\n{3,}/g, '\n\n').trim()
|
|
567
571
|
const sec = []
|
|
568
572
|
for (; k < lines.length; k++) { if (/^Resume: `/.test(lines[k])) break; sec.push(lines[k]) }
|
|
573
|
+
// 2026-11-06: "### Got done" lines are facts in a fixed shape (renderWorkLine); read them here, not as model prose
|
|
574
|
+
const work = []
|
|
575
|
+
let inWork = false
|
|
576
|
+
for (const l of sec) {
|
|
577
|
+
if (/^###\s/.test(l)) { inWork = l.replace(/^###\s+/, '').trim() === WORK_TITLE; continue }
|
|
578
|
+
if (inWork) { const f = parseWorkLine(l); if (f) work.push(f) }
|
|
579
|
+
}
|
|
569
580
|
Object.assign(cp, parseSections(sec))
|
|
581
|
+
if (work.length) cp.work = work
|
|
570
582
|
return cp
|
|
571
583
|
}
|
|
572
584
|
|
|
@@ -1336,6 +1348,189 @@ export function transcriptSlice(messages, { maxInputChars = 24000, cwd } = {}) {
|
|
|
1336
1348
|
return blocks.reverse().join('\n')
|
|
1337
1349
|
}
|
|
1338
1350
|
|
|
1351
|
+
// ---------------------------------------------------------------- work facts (2026-11-06)
|
|
1352
|
+
//
|
|
1353
|
+
// What a session got done, read from the commands it ran and what they printed,
|
|
1354
|
+
// never from a model: commits ("[branch abc1234] subject"), PRs opened (gh pr
|
|
1355
|
+
// create's URL) and merged (gh pr merge, or GitHub's MCP merge tool), branches
|
|
1356
|
+
// pushed, and the last test run with its counts. A checkpoint keeps them under
|
|
1357
|
+
// "### Got done", one line each, so the digest can say "Merged PR #53: …" in
|
|
1358
|
+
// past tense instead of the last status line ("PR 53 was ready to merge").
|
|
1359
|
+
export const WORK_TITLE = 'Got done'
|
|
1360
|
+
export const WORK_MAX = { prs: 6, commits: 10, pushes: 3 }
|
|
1361
|
+
|
|
1362
|
+
const quoted = (s) => s == null ? '' : String(s)
|
|
1363
|
+
function argOf(cmd, names) {
|
|
1364
|
+
for (const n of names) {
|
|
1365
|
+
const re = new RegExp('(?:^|\\s)' + n.replace(/-/g, '\\-') + '(?:=|\\s+)(?:"((?:[^"\\\\]|\\\\.)*)"|\'([^\']*)\'|(\\S+))')
|
|
1366
|
+
const m = String(cmd).match(re)
|
|
1367
|
+
if (m) return quoted(m[1] != null ? m[1].replace(/\\(.)/g, '$1') : m[2] != null ? m[2] : m[3])
|
|
1368
|
+
}
|
|
1369
|
+
return ''
|
|
1370
|
+
}
|
|
1371
|
+
const firstLineOf = (t) => String(t || '').split('\n').map((x) => x.trim()).find(Boolean) || ''
|
|
1372
|
+
const TEST_CMD = /(?:^|[\s;&|(])((?:(?:env\s+(?:-u\s+\S+\s+)*)?(?:timeout\s+(?:-k\s+\S+\s+)?\S+\s+)?)?(?:(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?test(?::[\w:.-]+)?|node\s+(?:--?[\w-]+(?:[= ]\S+)?\s+)*--test\b|npx\s+(?:vitest|jest|mocha)|vitest|jest|pytest|python3?\s+-m\s+pytest|go\s+test|cargo\s+test|mocha|rspec|swift\s+test|make\s+test|deno\s+test))/
|
|
1373
|
+
function testCounts(text) {
|
|
1374
|
+
const t = String(text || '')
|
|
1375
|
+
let m, passed = null, failed = null
|
|
1376
|
+
if ((m = t.match(/test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed/))) return { passed: +m[1], failed: +m[2] }
|
|
1377
|
+
const np = [...t.matchAll(/^\s*(?:#|ℹ)\s*pass\s+(\d+)\s*$/gm)].pop(), nf = [...t.matchAll(/^\s*(?:#|ℹ)\s*fail\s+(\d+)\s*$/gm)].pop()
|
|
1378
|
+
if (np || nf) return { passed: np ? +np[1] : 0, failed: nf ? +nf[1] : 0 }
|
|
1379
|
+
if ((m = t.match(/^\s*Tests:?\s+(.*)$/m)) && /\d+ (?:passed|failed)/.test(m[1])) {
|
|
1380
|
+
const p = m[1].match(/(\d+) passed/), f = m[1].match(/(\d+) failed/)
|
|
1381
|
+
return { passed: p ? +p[1] : 0, failed: f ? +f[1] : 0 }
|
|
1382
|
+
}
|
|
1383
|
+
const lines = t.split('\n').filter((l) => /\d+ (?:passed|failed)\b/.test(l))
|
|
1384
|
+
if (lines.length) {
|
|
1385
|
+
const l = lines[lines.length - 1], p = l.match(/(\d+) passed/), f = l.match(/(\d+) failed/)
|
|
1386
|
+
passed = p ? +p[1] : 0; failed = f ? +f[1] : 0
|
|
1387
|
+
return { passed, failed }
|
|
1388
|
+
}
|
|
1389
|
+
return null
|
|
1390
|
+
}
|
|
1391
|
+
const PR_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/
|
|
1392
|
+
|
|
1393
|
+
export function workFacts(messages) {
|
|
1394
|
+
const prs = new Map() // number -> { kind: 'pr', number, title, state, repo }
|
|
1395
|
+
const commits = [], pushes = []
|
|
1396
|
+
let tests = null
|
|
1397
|
+
const results = new Map()
|
|
1398
|
+
for (const m of messages || []) for (const r of m.toolResults || []) if (r && r.tool_use_id) results.set(r.tool_use_id, r)
|
|
1399
|
+
const pr = (number, patch) => {
|
|
1400
|
+
const n = Number(number)
|
|
1401
|
+
if (!n) return
|
|
1402
|
+
const cur = prs.get(n) || { kind: 'pr', number: n, title: '', state: 'opened', repo: null }
|
|
1403
|
+
// merged is final: a later "opened" (a re-run of create) doesn't take it back
|
|
1404
|
+
const state = cur.state === 'merged' ? 'merged' : (patch.state || cur.state)
|
|
1405
|
+
prs.delete(n) // newest last
|
|
1406
|
+
prs.set(n, { ...cur, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v)), state })
|
|
1407
|
+
}
|
|
1408
|
+
for (const m of messages || []) {
|
|
1409
|
+
if (m.role !== 'assistant') continue
|
|
1410
|
+
for (const t of m.toolUses || []) {
|
|
1411
|
+
const name = String(t.tool || t.name || '')
|
|
1412
|
+
const res = (t.tool_use_id && results.get(t.tool_use_id)) || null
|
|
1413
|
+
const text = String(t.text != null ? t.text : (res && res.text) || '')
|
|
1414
|
+
const isError = !!(t.isError || (res && res.isError))
|
|
1415
|
+
const input = t.input || {}
|
|
1416
|
+
// GitHub's MCP tools (mcp__github__create_pull_request / merge_pull_request)
|
|
1417
|
+
if (/(?:^|__)create_pull_request$/.test(name)) {
|
|
1418
|
+
if (isError) continue
|
|
1419
|
+
const u = text.match(PR_URL), n = (u && u[2]) || ((text.match(/"number"\s*:\s*(\d+)/) || [])[1])
|
|
1420
|
+
pr(n, { title: clip(String(input.title || ''), 120), state: 'opened', repo: u ? u[1] : (input.owner && input.repo ? input.owner + '/' + input.repo : null) })
|
|
1421
|
+
continue
|
|
1422
|
+
}
|
|
1423
|
+
if (/(?:^|__)merge_pull_request$/.test(name)) {
|
|
1424
|
+
if (isError || /not mergeable|failed|error/i.test(firstLineOf(text))) continue
|
|
1425
|
+
pr(input.pull_number || input.pullNumber || input.number, { state: 'merged', title: clip(String(input.commit_title || ''), 120).replace(/\s*\(#\d+\)$/, ''), repo: input.owner && input.repo ? input.owner + '/' + input.repo : null })
|
|
1426
|
+
continue
|
|
1427
|
+
}
|
|
1428
|
+
if (name !== 'Bash' || !input.command) continue
|
|
1429
|
+
const cmd = String(input.command)
|
|
1430
|
+
if (/\bgit\b[^\n|;&]*\bcommit\b/.test(cmd) && !/--dry-run/.test(cmd)) {
|
|
1431
|
+
for (const c of text.matchAll(/^\[([^\]\s]+)(?: \([^)]*\))? ([0-9a-f]{7,40})\] (.+)$/gm)) {
|
|
1432
|
+
const subject = clip(c[3].trim(), 120)
|
|
1433
|
+
const i = commits.findIndex((x) => x.subject === subject) // an --amend: the new sha, one line
|
|
1434
|
+
if (i >= 0) commits.splice(i, 1)
|
|
1435
|
+
commits.push({ kind: 'commit', sha: c[2].slice(0, 7), subject, branch: c[1] })
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
if (/\bgh\s+pr\s+create\b/.test(cmd) && !isError) {
|
|
1439
|
+
const u = text.match(PR_URL)
|
|
1440
|
+
if (u) pr(u[2], { title: clip(argOf(cmd, ['--title', '-t']), 120), state: 'opened', repo: u[1], branch: argOf(cmd, ['--head', '-H']).replace(/^[\w.-]+:/, '') || pushes[pushes.length - 1] || (commits.length ? commits[commits.length - 1].branch : '') })
|
|
1441
|
+
}
|
|
1442
|
+
if (/\bgh\s+pr\s+merge\b/.test(cmd) && !isError) {
|
|
1443
|
+
const n = (cmd.match(/\bgh\s+pr\s+merge\s+(?:[^\s-]\S*\/pull\/)?#?(\d+)\b/) || [])[1] || (text.match(/pull request (?:[\w.-]+\/[\w.-]+)?#(\d+)/i) || [])[1] || ((text.match(PR_URL) || [])[2])
|
|
1444
|
+
const title = (text.match(/pull request (?:[\w.-]+\/[\w.-]+)?#\d+ \((.+)\)\s*$/im) || [])[1] || ''
|
|
1445
|
+
if (/automatically merged|auto-?merge|merge queue|will be merged/i.test(text) || /--auto\b/.test(cmd)) pr(n, { state: 'auto', title: clip(title, 120) })
|
|
1446
|
+
else if (!/not mergeable|failed|error:/i.test(text)) pr(n, { state: 'merged', title: clip(title, 120) })
|
|
1447
|
+
}
|
|
1448
|
+
if (/\bgit\s+push\b/.test(cmd) && !isError) {
|
|
1449
|
+
const b = [...text.matchAll(/(?:\[new branch\]|\.\.|\+)\s*\S*\s+(\S+)\s+->\s+(\S+)/g)].map((x) => x[2]).pop() || [...text.matchAll(/\s(\S+)\s+->\s+(\S+)/g)].map((x) => x[2]).pop()
|
|
1450
|
+
if (b) { const i = pushes.indexOf(b); if (i >= 0) pushes.splice(i, 1); pushes.push(b) }
|
|
1451
|
+
}
|
|
1452
|
+
const tc = cmd.match(TEST_CMD)
|
|
1453
|
+
if (tc) {
|
|
1454
|
+
const counts = testCounts(text)
|
|
1455
|
+
if (counts || isError) tests = { kind: 'tests', ok: !isError && (!counts || counts.failed === 0), passed: counts ? counts.passed : null, failed: counts ? counts.failed : null, cmd: clip(tc[1].replace(/^env\s+(?:-u\s+\S+\s+)*/, '').replace(/^timeout\s+(?:-k\s+\S+\s+)?\S+\s+/, '').replace(/\s--?(?:import|require|loader|r)[= ]\S+/g, '').trim(), 60) }
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
const out = []
|
|
1460
|
+
const prList = [...prs.values()].slice(-WORK_MAX.prs)
|
|
1461
|
+
out.push(...prList.filter((p) => p.state === 'merged'), ...prList.filter((p) => p.state !== 'merged'))
|
|
1462
|
+
out.push(...commits.slice(-WORK_MAX.commits))
|
|
1463
|
+
out.push(...pushes.slice(-WORK_MAX.pushes).map((branch) => ({ kind: 'push', branch })))
|
|
1464
|
+
if (tests) out.push(tests)
|
|
1465
|
+
return out
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const oneL = (s) => String(s == null ? '' : s).replace(/\s*\n\s*/g, ' ').trim()
|
|
1469
|
+
// One fact, one line; parseWorkLine reads it back.
|
|
1470
|
+
export function renderWorkLine(f) {
|
|
1471
|
+
if (!f) return ''
|
|
1472
|
+
if (f.kind === 'pr') {
|
|
1473
|
+
const head = (f.state === 'merged' ? 'Merged' : f.state === 'auto' ? 'Set to merge' : 'Opened') + ' PR #' + f.number + (f.repo ? ' (' + f.repo + ')' : '') + (f.branch ? ' from ' + f.branch : '')
|
|
1474
|
+
return head + (f.title ? ': ' + oneL(f.title) : '')
|
|
1475
|
+
}
|
|
1476
|
+
if (f.kind === 'commit') return 'Committed ' + f.sha + (f.branch ? ' on ' + f.branch : '') + ': ' + oneL(f.subject)
|
|
1477
|
+
if (f.kind === 'push') return 'Pushed ' + f.branch
|
|
1478
|
+
if (f.kind === 'tests') {
|
|
1479
|
+
const n = f.passed != null ? f.passed + ' passed' + (f.failed ? ', ' + f.failed + ' failed' : '') : ''
|
|
1480
|
+
return 'Tests ' + (f.ok ? 'passed' : 'failed') + (n ? ': ' + n : '') + (f.cmd ? ' (' + oneL(f.cmd).replace(/[()]/g, '') + ')' : '')
|
|
1481
|
+
}
|
|
1482
|
+
return ''
|
|
1483
|
+
}
|
|
1484
|
+
export function parseWorkLine(line) {
|
|
1485
|
+
const t = String(line || '').replace(/^\s*[-*•]\s*/, '').trim()
|
|
1486
|
+
let m
|
|
1487
|
+
if ((m = t.match(/^(Merged|Opened|Set to merge) PR #(\d+)(?: \(([\w.-]+\/[\w.-]+)\))?(?: from (\S+?))?(?::\s*(.*))?$/))) return { kind: 'pr', number: +m[2], state: m[1] === 'Merged' ? 'merged' : m[1] === 'Opened' ? 'opened' : 'auto', repo: m[3] || null, ...(m[4] ? { branch: m[4] } : {}), title: (m[5] || '').trim() }
|
|
1488
|
+
if ((m = t.match(/^Committed ([0-9a-f]{7,40})(?: on (\S+))?:\s*(.*)$/))) return { kind: 'commit', sha: m[1], branch: m[2] || null, subject: m[3].trim() }
|
|
1489
|
+
if ((m = t.match(/^Pushed (\S+)$/))) return { kind: 'push', branch: m[1] }
|
|
1490
|
+
if ((m = t.match(/^Tests (passed|failed)(?::\s*(\d+) passed(?:, (\d+) failed)?)?(?: \((.*)\))?$/))) return { kind: 'tests', ok: m[1] === 'passed', passed: m[2] != null ? +m[2] : null, failed: m[2] != null ? +(m[3] || 0) : null, cmd: m[4] || '' }
|
|
1491
|
+
return null
|
|
1492
|
+
}
|
|
1493
|
+
// "PR 53 is ready to merge" / "Merge PR 53" about a PR that has merged: a status line gone stale.
|
|
1494
|
+
export const STATUSY_PR = /\bPR\s*#?(\d+)\b[^.;]{0,60}?\b(?:ready to merge|ready for review|ready to land|awaiting (?:review|merge|approval)|waiting (?:on|for) (?:review|merge|approval|checks|CI)|open(?:ed)?(?: and)? (?:for|awaiting)|in review|pending (?:review|merge))/i
|
|
1495
|
+
export const STALE_NEXT_PR = /\b(?:merge|land|ship|review|approve|squash)\b[^.;]{0,40}?\bPR\s*#?(\d+)/i
|
|
1496
|
+
// A checkpoint whose Stopped at / Next calls a PR its own facts show merged ready or next to merge:
|
|
1497
|
+
// Stopped at becomes what the facts say, Next is dropped (the digest's next step comes from elsewhere).
|
|
1498
|
+
// -> { stoppedAt, next, corrected: [fields] }; nothing changed when nothing is contradicted.
|
|
1499
|
+
export function reconcileWithWork(cp, work) {
|
|
1500
|
+
const merged = new Map((work || []).filter((f) => f && f.kind === 'pr' && f.state === 'merged').map((f) => [f.number, f]))
|
|
1501
|
+
const out = { stoppedAt: cp.stoppedAt, next: cp.next, corrected: [] }
|
|
1502
|
+
if (!merged.size) return out
|
|
1503
|
+
const one = (t) => String(t || '').replace(/\s+/g, ' ')
|
|
1504
|
+
const m = one(cp.stoppedAt).match(STATUSY_PR)
|
|
1505
|
+
if (m && merged.has(Number(m[1]))) {
|
|
1506
|
+
const pr = merged.get(Number(m[1]))
|
|
1507
|
+
const tests = (work || []).filter((f) => f.kind === 'tests').pop()
|
|
1508
|
+
out.stoppedAt = 'PR #' + pr.number + (pr.title ? ' (' + clip(pr.title, 80) + ')' : '') + ' is merged' + (tests ? '; tests ' + (tests.ok ? 'pass' : 'fail') + (tests.passed != null ? ' (' + (tests.ok ? tests.passed + ' passed' : tests.failed + ' failed') + ')' : '') : '') + '.'
|
|
1509
|
+
out.corrected.push('stoppedAt')
|
|
1510
|
+
}
|
|
1511
|
+
const n = one(cp.next).match(STALE_NEXT_PR)
|
|
1512
|
+
if (n && merged.has(Number(n[1]))) { out.next = ''; out.corrected.push('next') }
|
|
1513
|
+
return out
|
|
1514
|
+
}
|
|
1515
|
+
// A work list from anywhere (the mod's POST, a file) made safe: only what renders and reads back.
|
|
1516
|
+
export function cleanWork(list) {
|
|
1517
|
+
const out = []
|
|
1518
|
+
for (const f of Array.isArray(list) ? list : []) {
|
|
1519
|
+
const w = typeof f === 'string' ? parseWorkLine(f) : parseWorkLine(renderWorkLine(f))
|
|
1520
|
+
if (w) out.push(w)
|
|
1521
|
+
}
|
|
1522
|
+
return out.slice(0, WORK_MAX.prs + WORK_MAX.commits + WORK_MAX.pushes + 1)
|
|
1523
|
+
}
|
|
1524
|
+
// The same fact in two checkpoints is one fact (a commit by its subject, since an amend changes the sha).
|
|
1525
|
+
export function workKey(f) {
|
|
1526
|
+
if (!f) return ''
|
|
1527
|
+
if (f.kind === 'pr') return 'pr:#' + f.number
|
|
1528
|
+
if (f.kind === 'commit') return 'commit:' + String(f.subject || '').toLowerCase()
|
|
1529
|
+
if (f.kind === 'push') return 'push:' + f.branch
|
|
1530
|
+
if (f.kind === 'tests') return 'tests'
|
|
1531
|
+
return ''
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1339
1534
|
// ---------------------------------------------------------------- checkpoint length (v0.4.6)
|
|
1340
1535
|
//
|
|
1341
1536
|
// config summary.length picks how much a checkpoint says. Every length keeps the
|
|
@@ -1388,10 +1583,11 @@ const CHECKPOINT_BASE = [
|
|
|
1388
1583
|
'You write checkpoints for a developer juggling many coding-agent sessions, read when they come back after half an hour, a day or a weekend. Given the recent transcript of ONE session, start with exactly these five lines:',
|
|
1389
1584
|
'Goal: <the outcome the developer is after, from what they asked for, in a few plain words; not the latest step. Keep the previous goal unless they changed course or it is done and they moved on>',
|
|
1390
1585
|
'Progress: <one of Just started, Underway, Mostly done, Done, Stuck> - <what is left between here and the goal, a few words>',
|
|
1391
|
-
'Doing: <what the session was working on, one sentence, concrete nouns: files, features, errors>',
|
|
1392
|
-
'Stopped at: <the exact state when it went quiet: what passed, what failed, what is half-done; say what was actually run or checked, and what is only assumed>',
|
|
1586
|
+
'Doing: <what the session got done or was working on, one sentence, concrete nouns: files, features, errors; past tense for what is finished, e.g. "Merged PR #53, which keeps the reply box above the keyboard">',
|
|
1587
|
+
'Stopped at: <the exact state when it went quiet, as outcomes: what passed, what failed, what is half-done, what is merged and what is not; say what was actually run or checked, and what is only assumed>',
|
|
1393
1588
|
'Next: <the single next action a human or the agent should take>',
|
|
1394
1589
|
'If the session is blocked on the human (a question, a permission, a decision), start Next with "YOU:".',
|
|
1590
|
+
'Say outcomes, not statuses: "Merged PR #53" or "Opened PR #53, not merged yet", never "PR 53 was ready to merge". The "Verified from the commands\' output" line is the record of commits, PRs, pushes and tests: never contradict it, and never claim a merge, commit or passing test it does not show.',
|
|
1395
1591
|
'Describe only what the transcript shows; the lines above it (repo, branch, title, what was asked, previous checkpoint) are background and may be out of date.',
|
|
1396
1592
|
]
|
|
1397
1593
|
// 2026-11-03 decision memory: only when the transcript shows one, quoted, never invented.
|
|
@@ -1478,12 +1674,15 @@ export function buildCheckpointPrompt({ session, previous, messages, cfg = DEFAU
|
|
|
1478
1674
|
const red = (t) => redact(String(t || ''), (cfg.redact && cfg.redact.patterns) || [])
|
|
1479
1675
|
const found = decisionsFromMessages(messages).map((d) => ({ ...d, text: red(d.text), why: red(d.why), alternatives: d.alternatives.map(red) }))
|
|
1480
1676
|
if (found.length) head.push('Decisions already recorded from the session\'s questions and plans (don\'t repeat them): ' + found.map((d) => '“' + clip(d.text, 120) + '”').join('; '))
|
|
1677
|
+
// 2026-11-06: what the commands' own output shows got done, so the model says "Merged PR #53" and not "PR 53 was ready to merge"
|
|
1678
|
+
const work = workFacts(messages).map((f) => (f.kind === 'pr' || f.kind === 'commit' ? { ...f, title: f.title && red(f.title), subject: f.subject && red(f.subject) } : f)).map((f) => Object.fromEntries(Object.entries(f).filter(([, v]) => v !== undefined)))
|
|
1679
|
+
head.push('Verified from the commands\' output (git, gh, the test runner) this session: ' + (work.length ? work.map(renderWorkLine).join('; ') : 'no commit, PR, push or test run'))
|
|
1481
1680
|
if (session.needsInput) head.push('State: the session is waiting on the developer (' + (session.lastNotification || 'a prompt') + ').')
|
|
1482
1681
|
if (wantTitle) head.push('Also write the line: Title: <max 6 words>.')
|
|
1483
1682
|
const slice = transcriptSlice(messages, { maxInputChars, cwd })
|
|
1484
1683
|
const patterns = (cfg.redact && cfg.redact.patterns) || []
|
|
1485
1684
|
const prompt = redact(head.join('\n') + '\nTranscript (oldest→newest, trimmed):\n' + (slice || '(empty)'), patterns)
|
|
1486
|
-
return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens, found }
|
|
1685
|
+
return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens, found, work }
|
|
1487
1686
|
}
|
|
1488
1687
|
|
|
1489
1688
|
// ---------------------------------------------------------------- decisions (2026-11-03)
|
|
@@ -1672,6 +1871,9 @@ const SECTION_KEYS = {
|
|
|
1672
1871
|
'didn\'t work': 'didntWork', 'didnt work': 'didntWork', 'did not work': 'didntWork', 'what didn\'t work': 'didntWork', 'what didnt work': 'didntWork', 'tried': 'didntWork',
|
|
1673
1872
|
'recap': 'recap',
|
|
1674
1873
|
'next steps': 'nextSteps', 'next 3 steps': 'nextSteps',
|
|
1874
|
+
// 2026-11-06: "Got done" is written in code from the commands' output (workFacts); a model
|
|
1875
|
+
// that writes the heading itself is not a source for it, so its lines are dropped
|
|
1876
|
+
'got done': 'gotDone',
|
|
1675
1877
|
'decided': 'decided', // 2026-11-03 decision memory
|
|
1676
1878
|
}
|
|
1677
1879
|
export const SECTION_TITLES = { decisions: 'Approach notes', questions: 'Open questions', files: 'Files touched', didntWork: 'What didn\'t work', recap: 'Recap', nextSteps: 'Next steps' }
|
|
@@ -1701,6 +1903,7 @@ function parseSections(lines) {
|
|
|
1701
1903
|
const push = (k, v) => {
|
|
1702
1904
|
v = String(v || '').replace(/\*\*/g, '').trim()
|
|
1703
1905
|
if (!v || NONE_RE.test(v)) return
|
|
1906
|
+
if (k === 'gotDone') return
|
|
1704
1907
|
if (k === 'recap') { ex.recap = ex.recap ? ex.recap + ' ' + v : v; return }
|
|
1705
1908
|
if (k === 'files') { const f = parseFileNote(v); if (f.path || f.why) ex.files.push(f); return }
|
|
1706
1909
|
if (k === 'decided') { const d = parseDecidedLine(v); if (d) ex.decided.push(d); return }
|
package/mod/hooks/register.js
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
shortId, formatTabTitle, parseTabTitle, tabRenameWaits, detectTerminal, parseProcLine, trailingQuestion,
|
|
20
20
|
parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
|
|
21
21
|
listHintFrom, backgroundListFrom, extractChecklists, parseTodoArgs, parseDoneArgs, detectBackend, parseParkArgs,
|
|
22
|
-
boardLinkLines, isNonInteractive, isLeftoffHeadless, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText,
|
|
22
|
+
boardLinkLines, isNonInteractive, isLeftoffHeadless, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText, reconcileWithWork,
|
|
23
23
|
} from './core.js'
|
|
24
24
|
|
|
25
25
|
const PANE_ID = 'checkpoints'
|
|
@@ -32,7 +32,7 @@ const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can
|
|
|
32
32
|
const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
|
|
33
33
|
|
|
34
34
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
35
|
-
const MOD_VERSION = '0.9.18-beta.
|
|
35
|
+
const MOD_VERSION = '0.9.18-beta.14' // keep in step with .claude-plugin/plugin.json
|
|
36
36
|
|
|
37
37
|
const st = {
|
|
38
38
|
internal: false,
|
|
@@ -866,6 +866,11 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
|
|
|
866
866
|
}
|
|
867
867
|
// 0.9.2: nothing to go on (no prompt, no reply): don't save a placeholder over the last real one
|
|
868
868
|
if (isEmptyCheckpoint(parsed)) { if (trigger === 'manual') $.ui.toast(NOTHING_TO_CHECKPOINT, { timeoutMs: 6000 }); return null }
|
|
869
|
+
// 2026-11-06: a Stopped at / Next that calls a PR this session merged "ready to merge": the facts win
|
|
870
|
+
if (parsed.format !== 'markdown') {
|
|
871
|
+
const fix = reconcileWithWork(parsed, ctx.work)
|
|
872
|
+
if (fix.corrected.length) { parsed = { ...parsed, stoppedAt: fix.stoppedAt, next: fix.next }; generator = { ...(generator || {}), corrected: fix.corrected.join(' ') } }
|
|
873
|
+
}
|
|
869
874
|
if (opts.next) parsed = { ...parsed, next: opts.next }
|
|
870
875
|
const cp = {
|
|
871
876
|
sessionId: st.sessionId, createdAt: nowIso(), trigger, machine: st.machine,
|
|
@@ -874,6 +879,7 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
|
|
|
874
879
|
doing: parsed.doing, stoppedAt: parsed.stoppedAt, next: parsed.next, openQuestion: parsed.openQuestion || null,
|
|
875
880
|
userNote: userNote || null, filesTouched: ctx.filesTouched, turnsSinceLast: record.turnsSinceCheckpoint || 0,
|
|
876
881
|
parseOk: parsed.parseOk, ...(parsed.format === 'markdown' ? { format: 'markdown' } : {}), generator, length: ctx.length,
|
|
882
|
+
work: ctx.work || [], // 2026-11-06: what the commands' output shows got done
|
|
877
883
|
decided: mergeDecided(ctx.found, generator && generator.by === 'heuristic' ? [] : parsed.decided, { earlier: st.lastCheckpoint && st.lastCheckpoint.decided }), // 2026-11-03 decision memory
|
|
878
884
|
decisions: parsed.decisions || [], questions: parsed.questions || [], files: parsed.files || [],
|
|
879
885
|
didntWork: parsed.didntWork || [], recap: parsed.recap || '', nextSteps: parsed.nextSteps || [],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.18-beta.
|
|
3
|
+
"version": "0.9.18-beta.14",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -435,6 +435,29 @@ h2 .count { color: var(--faint); font-weight: 500; }
|
|
|
435
435
|
.pagehead .actions { flex-wrap: wrap; }
|
|
436
436
|
}
|
|
437
437
|
.digest-meta { color: var(--faint); font-size: 12.5px; margin: -2px 0 12px; }
|
|
438
|
+
/* 2026-11-06: the digest's versions (ui/digest-versions.js): quiet, one line, a list on tap */
|
|
439
|
+
.dv-line { margin: -10px 0 8px; }
|
|
440
|
+
.dv { position: relative; display: inline-block; }
|
|
441
|
+
.dv-btn { min-height: 44px; padding: 0 10px; margin: 0 0 0 -10px; border: 0; background: transparent; color: var(--muted); font: inherit; font-size: 12.5px; cursor: pointer; border-radius: 8px; -webkit-tap-highlight-color: transparent; }
|
|
442
|
+
.dv-btn:hover, .dv-btn[aria-expanded="true"] { color: var(--text2); background: var(--bg2); }
|
|
443
|
+
.dv-btn:focus-visible { outline: 2px solid var(--accent, currentColor); outline-offset: 1px; }
|
|
444
|
+
.dv-list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: 190px; margin: 0; padding: 4px; list-style: none; background: var(--card); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--card-shadow); }
|
|
445
|
+
.dv-list[hidden] { display: none; }
|
|
446
|
+
.dv-list a { display: flex; align-items: center; min-height: 44px; padding: 0 10px; border-radius: 7px; color: var(--text2); text-decoration: none; font-size: 13px; white-space: nowrap; }
|
|
447
|
+
.dv-list a:hover { background: var(--bg2); }
|
|
448
|
+
.dv-list a[aria-current="page"] { color: var(--text); font-weight: 600; }
|
|
449
|
+
.dv-old .btn { min-height: 44px; }
|
|
450
|
+
/* Settings → Digest → Let Claude search your notes (ui/mcp-setup.js) */
|
|
451
|
+
.form .full.mcp-box { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; justify-content: stretch; min-width: 0; }
|
|
452
|
+
.mcp-row { position: relative; display: flex; align-items: center; gap: 6px; padding: 6px 4px 6px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg2); }
|
|
453
|
+
.mcp-row code { flex: 1; min-width: 0; white-space: normal; overflow-wrap: anywhere; font-size: 12.5px; border: 0; background: transparent; padding: 0; }
|
|
454
|
+
.mcp-row .copy-float { flex: none; width: 44px; height: 44px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; }
|
|
455
|
+
.mcp-row .copy-float:hover, .mcp-row .copy-float.copied { color: var(--text); background: var(--card); }
|
|
456
|
+
.mcp-row .copy-float svg { width: 16px; height: 16px; }
|
|
457
|
+
.mcp-codex summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; color: var(--text2); font-size: 13px; }
|
|
458
|
+
.mcp-codex pre { margin: 6px 0 0; padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg2); font-size: 12px; overflow-x: auto; }
|
|
459
|
+
.mcp-states { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12.5px; color: var(--text2); }
|
|
460
|
+
.mcp-state.ok { color: var(--text); }
|
|
438
461
|
@media (max-width: 640px) {
|
|
439
462
|
/* the five status counts as one compact row that scrolls, not a 3-row grid */
|
|
440
463
|
.stats { display: flex; overflow-x: auto; scrollbar-width: none; gap: 8px; margin: 14px -14px 0; padding: 2px 14px; }
|
|
@@ -1755,6 +1778,33 @@ html.dv-open, html.dv-open body { overflow: hidden; }
|
|
|
1755
1778
|
.dv-sub { padding: 6px 14px 0; }
|
|
1756
1779
|
.dv-dl .dl-word { display: none; }
|
|
1757
1780
|
}
|
|
1781
|
+
/* 2026-11-06 An HTML file: Preview · Code (ui/html-preview.js). Preview fills the sheet with the
|
|
1782
|
+
sandboxed page on white (a page with no background of its own is drawn on white, as in a browser);
|
|
1783
|
+
a line under the switch says what remote resources were blocked, with Show and Allow. */
|
|
1784
|
+
.hp-bar { flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding-bottom: 10px; }
|
|
1785
|
+
.hp-remote { display: flex; align-items: center; gap: 8px; flex: 1 1 320px; min-width: 0; font-size: 12.5px; color: var(--text2); }
|
|
1786
|
+
.hp-ric { flex: none; width: 15px; height: 15px; color: var(--muted); }
|
|
1787
|
+
.hp-remote.on .hp-ric { color: var(--accent); }
|
|
1788
|
+
.hp-rw { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1789
|
+
.hp-rw b { font-weight: 600; color: var(--text); }
|
|
1790
|
+
.hp-remote .btn { flex: none; }
|
|
1791
|
+
.hp-list { flex-basis: 100%; margin: 0; padding: 8px 10px; list-style: none; border: 1px solid var(--line); border-radius: 10px; background: var(--bg2); max-height: 160px; overflow: auto; display: grid; gap: 4px; }
|
|
1792
|
+
.hp-list li { display: flex; gap: 8px; align-items: baseline; min-width: 0; font-size: 12px; }
|
|
1793
|
+
.hp-list code { font: 12px/1.4 var(--mono); color: var(--text); overflow-wrap: anywhere; }
|
|
1794
|
+
.hp-kind { flex: none; min-width: 42px; font-size: 11px; color: var(--muted); }
|
|
1795
|
+
.hp-note { flex-basis: 100%; margin: 0; font-size: 12px; }
|
|
1796
|
+
.dv-body.dv-hpv { display: flex; flex-direction: column; overflow: hidden; }
|
|
1797
|
+
.dv-hpv .hp-main { flex: 1; min-height: 0; display: flex; }
|
|
1798
|
+
.dv-hpv .hp-stopped { flex: 1; justify-content: center; }
|
|
1799
|
+
.hp-stopped .btn { margin-top: 10px; }
|
|
1800
|
+
.hp-frame { flex: 1; width: 100%; min-height: 0; border: 1px solid var(--line); border-radius: 10px; background: #fff; color-scheme: light; }
|
|
1801
|
+
@media (max-width: 599.98px) {
|
|
1802
|
+
.hp-seg { flex: 1 1 100%; display: flex; }
|
|
1803
|
+
.hp-seg button { flex: 1 1 0; height: 44px; }
|
|
1804
|
+
.hp-remote { flex-basis: 100%; }
|
|
1805
|
+
.hp-remote .btn { min-height: 44px; }
|
|
1806
|
+
.hp-hosts { display: none; }
|
|
1807
|
+
}
|
|
1758
1808
|
html.dv-open .lo-backdrop { z-index: 61; }
|
|
1759
1809
|
html.dv-open .lo-menu { z-index: 62; }
|
|
1760
1810
|
|
|
@@ -3104,6 +3154,26 @@ html.pv-open, html.pv-open body { overflow: hidden; }
|
|
|
3104
3154
|
.md .bi-title:hover { text-decoration: underline; text-underline-offset: 2px; }
|
|
3105
3155
|
.md .bi-tag { color: var(--faint); font-size: 12px; }
|
|
3106
3156
|
.md section.start-sec + .digest-hint, .md p.lede + .digest-hint { margin-top: 14px; }
|
|
3157
|
+
/* 2026-11-06: What you got done: per project, one fact a line, each led by a shape and its word (never colour alone) */
|
|
3158
|
+
.md section.done-sec { margin: 16px 0 4px; padding: 10px 14px 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg2); }
|
|
3159
|
+
.md section.done-sec h2 { margin: 2px 0 2px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
|
|
3160
|
+
.md section.done-sec h3 { margin: 12px 0 4px; font-size: 14px; color: var(--text); }
|
|
3161
|
+
.md ul.done, .cp ul:has(> li.dn) { list-style: none; margin: 0; padding: 0; }
|
|
3162
|
+
.md li.dn, .cp li.dn { position: relative; padding: 3px 0 3px 22px; line-height: 1.45; color: var(--text2); overflow-wrap: anywhere; }
|
|
3163
|
+
.md li.dn b { color: var(--text); font-weight: 600; }
|
|
3164
|
+
.md li.dn a { color: var(--text); text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 2px; }
|
|
3165
|
+
.md li.dn::before, .cp li.dn::before { position: absolute; left: 2px; top: 3px; width: 16px; text-align: center; font-weight: 700; color: var(--muted); }
|
|
3166
|
+
li.dn-merged::before { content: '✓'; }
|
|
3167
|
+
li.dn-opened::before { content: '○'; }
|
|
3168
|
+
li.dn-commit::before, li.dn-push::before { content: '•'; }
|
|
3169
|
+
li.dn-pass::before { content: '✓'; }
|
|
3170
|
+
li.dn-fail::before { content: '✗'; }
|
|
3171
|
+
li.dn-decided::before { content: '◆'; font-size: 11px; }
|
|
3172
|
+
li.dn-you::before { content: '!'; }
|
|
3173
|
+
li.dn-open::before { content: '→'; }
|
|
3174
|
+
li.dn-more::before { content: '+'; }
|
|
3175
|
+
.cp dd.cp-done { margin-bottom: 6px; }
|
|
3176
|
+
@media (max-width: 600px) { .md section.done-sec { padding: 8px 12px 4px; margin-left: -4px; margin-right: -4px; } }
|
|
3107
3177
|
.md section.start-sec ~ #digestSpaces { margin-top: 16px; }
|
|
3108
3178
|
.md .bi-say { display: block; color: var(--text2); line-height: 1.45; margin-top: 1px; }
|
|
3109
3179
|
.md details.fin { margin: 10px 0 4px; }
|
|
@@ -4809,6 +4879,13 @@ details[open] > summary .wt-g-chev { transform: rotate(90deg); }
|
|
|
4809
4879
|
.wt-ask:has(.wt-ask-in:focus-visible) { border-color: var(--text2); }
|
|
4810
4880
|
.wt-ask-b { flex: none; width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 10px; background: var(--card2); color: var(--text2); cursor: pointer; }
|
|
4811
4881
|
.wt-pnav { display: none; }
|
|
4882
|
+
/* 2026-11-06 the long page has a bar too when there's a spot to jump to (Next spot stays on screen) */
|
|
4883
|
+
.wt-pnav:has(.wt-spot) { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 14px; border-top: 1px solid var(--line); background: var(--bg); }
|
|
4884
|
+
.wt-pnav:has(.wt-spot) .btn { min-height: 32px; padding: 0 12px; display: inline-flex; align-items: center; gap: 4px; }
|
|
4885
|
+
/* the finish: every step reviewed, said on the overview */
|
|
4886
|
+
.wt-alldone { display: flex; align-items: center; gap: 8px; margin: 14px 0 0; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); color: var(--text); font-size: 13.5px; font-weight: 600; }
|
|
4887
|
+
.wt-alldone[hidden] { display: none; }
|
|
4888
|
+
.wt-alldone .ic { width: 16px; height: 16px; color: var(--green); }
|
|
4812
4889
|
.wt :is(button, a, summary):focus-visible, .fs-wt:focus-visible, .wt-since:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
4813
4890
|
@media (prefers-reduced-motion: no-preference) { .wt-st { transition: border-color .15s, box-shadow .15s; } details[open] > summary .wt-g-chev { transition: transform .12s; } }
|
|
4814
4891
|
@media (max-width: 1100px) { .wt.wt-big .wt-hw { grid-template-columns: minmax(0, 1fr); } .wt.wt-big .wt-fm { display: none; } .wt-big .wt-in { padding-left: 28px; } }
|
|
@@ -5405,8 +5482,23 @@ code .file-ref-off { font-size: inherit; }
|
|
|
5405
5482
|
.wt-spot .ic { width: 15px; height: 15px; }
|
|
5406
5483
|
.wt-spot-n { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
5407
5484
|
.wt-gd .wt-spot-on { position: relative; box-shadow: inset 4px 0 0 var(--text), inset 0 0 0 1px var(--line-strong); }
|
|
5408
|
-
|
|
5409
|
-
.
|
|
5485
|
+
/* 2026-11-06 every spot's chip at its line, all the time; the one Next spot is on is solid. The chip
|
|
5486
|
+
is a .tip-btn (ui/tips.js): hover, focus or a tap explains why it's a spot. A 20px pill in a hit
|
|
5487
|
+
area as tall as the row (44px on touch, by its padding) */
|
|
5488
|
+
.wt-spot-tag { position: absolute; right: 6px; top: 0; bottom: 0; z-index: 1; display: flex; align-items: center; pointer-events: none; }
|
|
5489
|
+
.wt-gd [data-ri]:has(> .wt-spot-tag) { position: relative; }
|
|
5490
|
+
.wt-spot-tag .tip-btn.wt-spot-chip { position: static; width: auto; height: 20px; display: inline-flex; align-items: center; gap: 4px; padding: 1px 7px 0; box-sizing: border-box; border: 1px dashed var(--line-strong); border-radius: 10px; background: var(--card); color: var(--muted); font-family: var(--sans, inherit); font-size: 11px; font-weight: 600; line-height: 1; white-space: nowrap; cursor: help; pointer-events: auto; }
|
|
5491
|
+
.wt-spot-tag.cur .tip-btn.wt-spot-chip { border-style: solid; color: var(--text); }
|
|
5492
|
+
.wt-spot-tag .tip-btn.wt-spot-chip:hover, .wt-spot-tag .tip-btn.wt-spot-chip[aria-expanded="true"] { color: var(--text); background: var(--card2); }
|
|
5493
|
+
.wt-spot-tag .tip-btn.wt-spot-chip .ic { width: 11px; height: 11px; }
|
|
5494
|
+
.wt-spot-tag .tip { font-family: var(--sans, system-ui, sans-serif); font-weight: 400; white-space: normal; pointer-events: auto; }
|
|
5495
|
+
@media (pointer: coarse) { .wt-spot-tag .tip-btn.wt-spot-chip { height: 28px; padding: 1px 9px 0; border-radius: 14px; } .wt-spot-tag .tip-btn.wt-spot-chip::after { content: ''; position: absolute; inset: -8px -4px; } .wt-spot-tag .tip-btn.wt-spot-chip { position: relative; } }
|
|
5496
|
+
/* Next spot in the bar under the reading (slides, and the long page) */
|
|
5497
|
+
.wt-dnav .btn.wt-spot { height: 44px; }
|
|
5498
|
+
@media (pointer: fine) { .wt-dnav .btn.wt-spot { height: 30px; min-height: 30px; } }
|
|
5499
|
+
@media (max-width: 699.98px) { .wt-dnav .btn.wt-spot { width: auto; padding: 0 10px; gap: 4px; } .wt-dnav .wt-spot-l { display: none; } }
|
|
5500
|
+
.wt-pnav .wt-spot { margin-left: auto; }
|
|
5501
|
+
.wt-pnav .wt-spot + .btn { margin-left: 0; }
|
|
5410
5502
|
/* the file list: folds to a slim rail, its edge drags */
|
|
5411
5503
|
.wt-railt { flex: none; display: grid; place-items: center; width: 32px; height: 32px; margin-left: auto; border: 0; border-radius: 8px; background: none; color: var(--muted); cursor: pointer; }
|
|
5412
5504
|
.wt-railt .ic { width: 16px; height: 16px; }
|
|
@@ -5664,3 +5756,11 @@ a.tp-cite::after { content: ''; position: absolute; inset: -10px -4px; }
|
|
|
5664
5756
|
.lv-subs-step { color: var(--muted); font-family: var(--mono); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
5665
5757
|
.lv-subs-when { font-size: 12px; flex: none; }
|
|
5666
5758
|
@media (prefers-reduced-motion: reduce) { .sub-spin { animation: none; border-right-color: currentColor; border-style: dotted; } }
|
|
5759
|
+
|
|
5760
|
+
/* 2026-11-06 Save as HTML (digest, decision log; the walkthrough's is in its header): a quiet button,
|
|
5761
|
+
44px to a finger. The file is one self-contained .html (ui/render-export.js). */
|
|
5762
|
+
.digest-save { display: flex; justify-content: flex-end; margin: -8px 0 8px; }
|
|
5763
|
+
.btn.save-html { background: none; box-shadow: none; color: var(--muted); }
|
|
5764
|
+
.btn.save-html:hover { color: var(--text); }
|
|
5765
|
+
@media (pointer: coarse) { .btn.save-html { min-height: 44px; padding: 0 12px; } }
|
|
5766
|
+
@media (max-width: 600px) { .digest-save { justify-content: flex-start; margin: -6px 0 10px; } .btn.save-html { min-height: 44px; } }
|