@leftoff/cli 0.9.18-beta.10 → 0.9.18-beta.12
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 +8 -0
- package/bin/leftoff-statusline.js +4 -4
- package/bin/leftoff.js +576 -553
- package/docs/SECURITY.md +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +50 -12
- package/mod/hooks/register.js +11 -3
- package/package.json +1 -1
- package/ui/app.css +58 -2
- package/ui/app.js +194 -11
- package/ui/chip-fade.js +1 -1
- package/ui/decisions.js +15 -5
- package/ui/fs-side.js +29 -0
- package/ui/live-view.js +16 -2
- package/ui/prs.js +28 -10
- package/ui/pull.js +14 -1
- package/ui/semdiff.js +20 -6
- package/ui/sglyph.js +2 -2
- package/ui/slash-commands.js +89 -0
- package/ui/slash.js +82 -0
- package/ui/tabs.js +10 -3
- package/ui/viewport-fit.js +14 -0
package/docs/SECURITY.md
CHANGED
|
@@ -94,7 +94,7 @@ A relayed request is replayed against the Mac's own board from inside the daemon
|
|
|
94
94
|
|
|
95
95
|
When your hosted board is open on a Mac that runs Leftoff, the page sends the same sealed requests straight to that Mac's daemon at `http://127.0.0.1:7429/v1/relay/direct` instead of to the relay. Nothing new is trusted: the daemon opens them with the same per-device key (and the same replay and time checks), runs them the same way (a remote device, the rules above), and seals the answer. The page only switches after the daemon says it's on your account and a sealed round trip through it works, so another Leftoff on that port (someone else's account) can't serve your board: it can't open the request or forge the answer. The door is loopback only, never through a proxy, off when Use from anywhere is off, and answers CORS for exactly one origin, `https://<your name>.leftoff.dev` (with Chrome's Private Network Access header); any other page gets a 403 it can't read. If the direct path fails, the request goes through the relay once more and the board stays on the relay until a later check finds the direct path again.
|
|
96
96
|
|
|
97
|
-
Since 0.9.7 the page also tries `https://leftoff.localhost/v1/relay/direct` (Leftoff's own Caddy from `leftoff setup`, bound to loopback; it rewrites Host, drops X-Forwarded-*, and passes the page's Origin through), since Safari won't let an https page call plain `http://127.0.0.1`. A request that came in through this door came from a browser on this Mac, from your own board's origin, sealed with a paired key, so it may also do the one thing only this Mac's own browser may do: **Restore** (`POST /v1/restore`, which opens Ghostty tabs here). The daemon marks such requests with a second in-memory secret that only the door sets; a sealed request can't carry it, and every other endpoint treats a direct request exactly like the relay (a paired remote device). Through the relay, from a phone or another Mac, Restore stays off.
|
|
97
|
+
Since 0.9.7 the page also tries `https://leftoff.localhost/v1/relay/direct` (Leftoff's own Caddy from `leftoff setup`, bound to loopback, or since 0.9.18 another app's Caddy on port 443 that imports Leftoff's site file, which turns away any client not on loopback by its address; it rewrites Host, drops X-Forwarded-*, and passes the page's Origin through), since Safari won't let an https page call plain `http://127.0.0.1`. A request that came in through this door came from a browser on this Mac, from your own board's origin, sealed with a paired key, so it may also do the one thing only this Mac's own browser may do: **Restore** (`POST /v1/restore`, which opens Ghostty tabs here). The daemon marks such requests with a second in-memory secret that only the door sets; a sealed request can't carry it, and every other endpoint treats a direct request exactly like the relay (a paired remote device). Through the relay, from a phone or another Mac, Restore stays off.
|
|
98
98
|
|
|
99
99
|
### Live link (0.9.8 beta)
|
|
100
100
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.18-beta.
|
|
3
|
+
"version": "0.9.18-beta.12",
|
|
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
|
@@ -1397,7 +1397,7 @@ const CHECKPOINT_BASE = [
|
|
|
1397
1397
|
const DECIDED_SECTION = [
|
|
1398
1398
|
'Only if the transcript shows a decision (a choice between real alternatives that the developer made or approved, a plan they approved, a correction they gave such as "no, do X instead", or a trade-off the agent chose and said so), add after those lines a "Decided:" heading and one "- " line per decision, at most 4:',
|
|
1399
1399
|
'- <what was decided, in the words the transcript uses> · why: <the reason given, one line> · over: <alternative>; <alternative> · from: <asked | plan | correction | agent>',
|
|
1400
|
-
'A task done, a file edited or a routine step is not a decision. Never invent a choice or a reason the transcript does not show. When unsure, or when there is none, leave the Decided heading out entirely. Skip decisions listed as already recorded.',
|
|
1400
|
+
'A task done, a file edited or a routine step is not a decision, and neither is a one-off choice about this task right now (who does a step, whether to commit, push or deploy now). Never invent a choice or a reason the transcript does not show. When unsure, or when there is none, leave the Decided heading out entirely. Skip decisions listed as already recorded.',
|
|
1401
1401
|
]
|
|
1402
1402
|
const INFORMATIVE_SECTIONS = [
|
|
1403
1403
|
'Then these sections, each a heading line followed by "- " bullets (write "- none" when there is nothing; never invent):',
|
|
@@ -1577,12 +1577,23 @@ function planHeadline(plan) {
|
|
|
1577
1577
|
return h.replace(/^#{1,6}\s+/, '').replace(/^(?:plan|implementation plan)\s*[:\-–—]\s*/i, '').replace(/\*\*/g, '').trim()
|
|
1578
1578
|
}
|
|
1579
1579
|
|
|
1580
|
-
//
|
|
1581
|
-
//
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1580
|
+
// 2026-11-05: a question about one task right now isn't a decision for the repo: who does it ("You update
|
|
1581
|
+
// it" / "I'll handle it"), or whether to go ahead with a commit, push, deploy and the like. Never recorded.
|
|
1582
|
+
const ACTOR_OPT = /^(?:you|i|i'll|i will|i'd|me|let me|we|we'll|leave it|someone)\b/i
|
|
1583
|
+
const NOW_Q = /\b(?:go ahead|proceed|right now|commit|push|merge|deploy|ship|publish|release|restart|open (?:a|the) pr|create (?:a|the) pr|who (?:should|will|does))\b/i
|
|
1584
|
+
const YESNO_OPT = /^(?:yes|no|y|n|ok|okay|sure|go ahead|do it|proceed|not now|later|skip|wait|stop|hold off|cancel|continue)\b/i
|
|
1585
|
+
export function isOneOffQuestion(q) {
|
|
1586
|
+
const labels = (Array.isArray(q && q.options) ? q.options : []).map((o) => String((o && o.label) || o || '').replace(/\s*\(Recommended\)\s*/i, '').trim()).filter(Boolean)
|
|
1587
|
+
if (labels.length < 2) return false
|
|
1588
|
+
if (labels.every((l) => ACTOR_OPT.test(l))) return true
|
|
1589
|
+
return NOW_Q.test(String((q && q.question) || '')) && labels.every((l) => YESNO_OPT.test(l))
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// The transcript's own decisions, oldest first, and the answers it later changed:
|
|
1593
|
+
// a question asked again (same header, else the same question) and answered
|
|
1594
|
+
// differently keeps only the last answer; the earlier ones are `reversed` (texts).
|
|
1595
|
+
function readStated(messages) {
|
|
1596
|
+
const out = [], at = new Map(), replaced = new Set()
|
|
1586
1597
|
for (const m of messages || []) {
|
|
1587
1598
|
if (!m || m.role !== 'assistant') continue
|
|
1588
1599
|
for (const u of m.toolUses || []) {
|
|
@@ -1593,17 +1604,22 @@ export function decisionsFromMessages(messages) {
|
|
|
1593
1604
|
const ans = questionAnswers(u.text, qs.map((q) => String(q.question)))
|
|
1594
1605
|
for (const q of qs) {
|
|
1595
1606
|
const a = ans[q.question]
|
|
1596
|
-
if (!a) continue
|
|
1607
|
+
if (!a || isOneOffQuestion(q)) continue
|
|
1597
1608
|
const labels = q.options.map((o) => String((o && o.label) || o || '').trim()).filter(Boolean)
|
|
1598
1609
|
const picked = labels.filter((l) => a === l || a.split(/\s*,\s*/).includes(l))
|
|
1599
1610
|
const chosen = picked.length ? picked.join(', ') : a
|
|
1600
1611
|
const opt = q.options.find((o) => o && o.label === picked[0])
|
|
1601
|
-
|
|
1612
|
+
const d = {
|
|
1602
1613
|
text: clip(String(q.header ? q.header + ': ' : '') + chosen.replace(/\s*\(Recommended\)\s*/i, ''), 240),
|
|
1603
1614
|
why: opt && opt.description ? opt.description : '',
|
|
1604
1615
|
alternatives: labels.filter((l) => !picked.includes(l)).map((l) => l.replace(/\s*\(Recommended\)\s*/i, '')),
|
|
1605
1616
|
source: 'asked',
|
|
1606
|
-
}
|
|
1617
|
+
}
|
|
1618
|
+
const topic = 'q:' + decisionNorm(q.header || q.question)
|
|
1619
|
+
const was = at.get(topic)
|
|
1620
|
+
if (was != null && decisionNorm(out[was].text) !== decisionNorm(d.text)) { replaced.add(out[was].text); out[was] = null }
|
|
1621
|
+
if (was != null && out[was]) continue // the same answer again
|
|
1622
|
+
at.set(topic, out.length); out.push(d)
|
|
1607
1623
|
}
|
|
1608
1624
|
}
|
|
1609
1625
|
if (name === 'ExitPlanMode' && u.text && PLAN_OK.test(u.text)) {
|
|
@@ -1612,7 +1628,29 @@ export function decisionsFromMessages(messages) {
|
|
|
1612
1628
|
}
|
|
1613
1629
|
}
|
|
1614
1630
|
}
|
|
1615
|
-
|
|
1631
|
+
const found = out.filter(Boolean)
|
|
1632
|
+
const kept = new Set(found.map((d) => decisionNorm(d.text)))
|
|
1633
|
+
return { found, reversed: [...replaced].filter((t) => !kept.has(decisionNorm(t))) }
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// The decisions a transcript states outright, with no model: a question you
|
|
1637
|
+
// answered from its options (AskUserQuestion, the board's Needs you), its other
|
|
1638
|
+
// options as the alternatives, only its last answer; a plan you approved
|
|
1639
|
+
// (ExitPlanMode). Messages are src/transcript.js's shape, which is also
|
|
1640
|
+
// $.session.messages()'s.
|
|
1641
|
+
export function decisionsFromMessages(messages) {
|
|
1642
|
+
return cleanDecided(readStated(messages).found)
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// 2026-11-05: what the log settles on its own (src/decisions.js settleStated): the stated decisions,
|
|
1646
|
+
// redacted as checkpoints save them, and the texts of answers changed later in the session.
|
|
1647
|
+
export function statedDecisions(messages, patterns = []) {
|
|
1648
|
+
const red = (t) => redact(String(t || ''), patterns || [])
|
|
1649
|
+
const { found, reversed } = readStated(messages)
|
|
1650
|
+
return {
|
|
1651
|
+
found: cleanDecided(found).map((d) => ({ ...d, text: red(d.text), why: red(d.why), alternatives: d.alternatives.map(red) })),
|
|
1652
|
+
reversed: reversed.map((t) => red(clip(oneLine(t), 240))),
|
|
1653
|
+
}
|
|
1616
1654
|
}
|
|
1617
1655
|
|
|
1618
1656
|
// The checkpoint's decisions: what the transcript states outright first, then the model's.
|
|
@@ -1910,7 +1948,7 @@ export const TITLE_LANGUAGE = 'Write it in the same language as the user\'s own
|
|
|
1910
1948
|
export const TITLE_SYSTEM = 'Write a title of at most 6 words for a coding session, based on its first prompt. ' + TITLE_LANGUAGE + ' Reply with the title only, no quotes, no punctuation at the end.'
|
|
1911
1949
|
|
|
1912
1950
|
export function cleanTitle(text) {
|
|
1913
|
-
const t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').split('\n').map((l) => l.trim()).find(Boolean) || ''
|
|
1951
|
+
const t = stripChannel(String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '')).split('\n').map((l) => l.trim()).find(Boolean) || '' // 0.9.18: a model that echoes the Live wrapper
|
|
1914
1952
|
return clip(t.replace(/^title\s*:\s*/i, '').replace(/^["'`*]+|["'`*.]+$/g, ''), 60)
|
|
1915
1953
|
}
|
|
1916
1954
|
|
package/mod/hooks/register.js
CHANGED
|
@@ -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.12' // keep in step with .claude-plugin/plugin.json
|
|
36
36
|
|
|
37
37
|
const st = {
|
|
38
38
|
internal: false,
|
|
@@ -1107,14 +1107,22 @@ function startReplyLoop($) {
|
|
|
1107
1107
|
let backoff = 0
|
|
1108
1108
|
while (st.replyLoop && st.sessionId) {
|
|
1109
1109
|
const sid = st.sessionId
|
|
1110
|
-
const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(sid) + '/replies/next?ms=' + ASK_POLL_MS, undefined, ASK_POLL_MS + 5000)
|
|
1110
|
+
const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(sid) + '/replies/next?cmds=1&ms=' + ASK_POLL_MS, undefined, ASK_POLL_MS + 5000)
|
|
1111
1111
|
if (!r || !r.ok) {
|
|
1112
1112
|
// Daemon down: try again on the heartbeat rather than spinning.
|
|
1113
1113
|
st.replyLoop = false
|
|
1114
1114
|
return
|
|
1115
1115
|
}
|
|
1116
1116
|
const reply = r.json && r.json.reply
|
|
1117
|
-
|
|
1117
|
+
// a slash command from the board (the daemon checked it against its list):
|
|
1118
|
+
// Claude Code runs it as if typed, queued behind a running turn (as /rename)
|
|
1119
|
+
if (reply && reply.command && typeof reply.command.name === 'string' && /^[a-z][\w-]{0,30}$/.test(reply.command.name) && sid === st.sessionId) {
|
|
1120
|
+
try {
|
|
1121
|
+
await $.command.run({ command: reply.command.name, args: String(reply.command.args || '').replace(/[\r\n]+/g, ' ').slice(0, 500) })
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
try { $.ui.toast('Leftoff: couldn’t run the board’s /' + reply.command.name + ': ' + String((err && err.message) || err).slice(0, 120)) } catch {}
|
|
1124
|
+
}
|
|
1125
|
+
} else if (reply && reply.text && !String(reply.text).startsWith('/') && sid === st.sessionId) {
|
|
1118
1126
|
try {
|
|
1119
1127
|
await $.prompt.submit({ text: String(reply.text) })
|
|
1120
1128
|
} catch (err) {
|
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.12",
|
|
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
|
@@ -1578,6 +1578,7 @@ a.cpr:hover { border-color: var(--line-strong); }
|
|
|
1578
1578
|
.pr-ic[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% - 4px); right: 0; width: max-content; max-width: min(260px, 70vw); padding: 4px 8px; border-radius: 7px; background: var(--card2); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow); color: var(--text2); font-size: 12px; line-height: 1.3; font-weight: 500; text-align: left; white-space: nowrap; letter-spacing: normal; pointer-events: none; z-index: 30; }
|
|
1579
1579
|
@media (hover: hover) and (pointer: fine) { .pr-ic[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
|
|
1580
1580
|
.pr-ic[data-tip]:focus-visible::after, .pr-ic.copied[data-tip]::after { display: block; }
|
|
1581
|
+
.pr-ic[aria-expanded="true"][data-tip]::after { display: none !important; } /* 2026-11-06 Open's menu is up: its tip would cover the title */
|
|
1581
1582
|
@media (hover: hover) and (pointer: fine) { .pr-ic.copied[data-tip]:hover::after { animation: none; } }
|
|
1582
1583
|
@media (prefers-reduced-motion: reduce) { .pr-ic[data-tip]::after { animation: none !important; } }
|
|
1583
1584
|
.prlist { margin-bottom: 8px; }
|
|
@@ -1641,6 +1642,9 @@ a.cpr:hover { border-color: var(--line-strong); }
|
|
|
1641
1642
|
.prrow .pr-line1 { padding-right: 124px; min-height: 24px; }
|
|
1642
1643
|
.prrow:not(:has(.pr-unlink)) .pr-line1 { padding-right: 80px; }
|
|
1643
1644
|
.prrow:not(:has(.pr-ic)) .pr-line1 { padding-right: 0; }
|
|
1645
|
+
/* a closed compact row (the session panel) keeps its glyphs in the row, after the one-line title */
|
|
1646
|
+
.pr-compact .prrow:not(.open) .pr-actions { position: static; margin: 0 -2px 0 0; }
|
|
1647
|
+
.pr-compact .prrow:not(.open) { align-items: center; }
|
|
1644
1648
|
.pr-actions, .prrow.open .pr-actions { position: absolute; top: 0; right: 2px; margin: 0; }
|
|
1645
1649
|
}
|
|
1646
1650
|
|
|
@@ -2491,6 +2495,20 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2491
2495
|
|
|
2492
2496
|
/* 0.9.8 attachments: a paperclip beside ↑, chips above the words inside the box, thumbnails in your bubble */
|
|
2493
2497
|
.composer { flex-wrap: wrap; }
|
|
2498
|
+
/* "/" commands (ui/slash.js): a list above the composer; options 44px tall */
|
|
2499
|
+
.composer .slash-menu { position: absolute; left: 0; right: 0; bottom: calc(100% + 6px); z-index: 30; max-height: min(320px, 50vh); overflow-y: auto; overscroll-behavior: contain; padding: 4px; border: 1px solid var(--line-strong); border-radius: 14px; background: var(--card); box-shadow: 0 8px 24px rgba(0,0,0,.22); cursor: default; }
|
|
2500
|
+
.slash-h { padding: 6px 10px 2px; font: 600 11.5px/1.4 var(--mono); color: var(--muted); }
|
|
2501
|
+
.slash-opt { display: flex; align-items: baseline; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 11px 10px; border-radius: 10px; cursor: pointer; }
|
|
2502
|
+
.slash-opt[aria-selected="true"] { background: var(--bg2); }
|
|
2503
|
+
@media (hover: hover) { .slash-opt:hover { background: var(--bg2); } }
|
|
2504
|
+
.slash-opt.off { cursor: not-allowed; opacity: .6; }
|
|
2505
|
+
.slash-l { flex: none; font: 500 13.5px/1.5 var(--mono); color: var(--text); }
|
|
2506
|
+
.slash-hint { color: var(--muted); font-weight: 400; }
|
|
2507
|
+
.slash-w { min-width: 0; font-size: 13px; line-height: 1.5; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2508
|
+
/* a slash command's bubble: the command in mono, then what Claude Code printed */
|
|
2509
|
+
.lv-cmd code { font-size: 13.5px; background: none; border: 0; padding: 0; }
|
|
2510
|
+
.lv-cmd-out { margin: 6px 0 0; padding: 0; font: 12.5px/1.5 var(--mono); color: var(--text2); white-space: pre-wrap; overflow-wrap: anywhere; background: none; border: 0; max-height: 240px; overflow-y: auto; }
|
|
2511
|
+
.lv-cmd-done { font-family: inherit; color: var(--muted); }
|
|
2494
2512
|
.composer .att-chips { flex: 0 0 100%; order: -1; display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 8px 2px 0; margin-left: -6px; }
|
|
2495
2513
|
.composer .att-chips[hidden] { display: none; }
|
|
2496
2514
|
.composer.att-drop { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); background: color-mix(in srgb, var(--accent) 7%, var(--card2)); }
|
|
@@ -2842,7 +2860,18 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2842
2860
|
.tab:hover { color: var(--text); }
|
|
2843
2861
|
.tab:focus-visible, .tab-x:focus-visible, .tab-all:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
2844
2862
|
.tab-board { flex: none; border-right: 1px solid var(--line); }
|
|
2845
|
-
.tab-cell { position: relative; flex: 0 1 200px; min-width:
|
|
2863
|
+
.tab-cell { position: relative; flex: 0 1 200px; min-width: 140px; display: flex; align-items: stretch; border-right: 1px solid var(--line); }
|
|
2864
|
+
/* 0.9.18 a busy strip: the tab you're on keeps its full width (only the others shrink), the rest
|
|
2865
|
+
keep 140px and the strip scrolls with a fade at the edge that has more (ui/chip-fade.js);
|
|
2866
|
+
a pinned tab is its pin and its status shape, the name in its tip and label. */
|
|
2867
|
+
.tab-cell.on { flex-shrink: 0; }
|
|
2868
|
+
.tab-cell.pinned { flex: none; min-width: 0; }
|
|
2869
|
+
.tab-cell.pinned .tab-s { padding-right: 12px; }
|
|
2870
|
+
.tab-cell.pinned .tab-t { display: none; }
|
|
2871
|
+
.tab-cell.pinned .tab-pin { margin-right: 0; }
|
|
2872
|
+
#tabStrip .tab-scroll.fade-r { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent); }
|
|
2873
|
+
#tabStrip .tab-scroll.fade-l { -webkit-mask-image: linear-gradient(to left, #000 calc(100% - 32px), transparent); mask-image: linear-gradient(to left, #000 calc(100% - 32px), transparent); }
|
|
2874
|
+
#tabStrip .tab-scroll.fade-l.fade-r { -webkit-mask-image: linear-gradient(to right, transparent, #000 32px, #000 calc(100% - 32px), transparent); mask-image: linear-gradient(to right, transparent, #000 32px, #000 calc(100% - 32px), transparent); }
|
|
2846
2875
|
.tab-s { flex: 1; min-width: 0; padding-right: 0; }
|
|
2847
2876
|
.tab.on, .tab-cell.on { color: var(--text); background: var(--card); }
|
|
2848
2877
|
.tab-board.on, .tab-cell.on { box-shadow: inset 0 -2px 0 var(--accent); }
|
|
@@ -4518,15 +4547,32 @@ html:not(.live-full) .fs-side { display: none; }
|
|
|
4518
4547
|
html.live-full.fs-on .live-box.full { height: calc(var(--lf-h, 100vh) - var(--fs-peek, 56px)); height: calc(var(--lf-h, 100dvh) - 56px - env(safe-area-inset-bottom)); padding-bottom: 6px; }
|
|
4519
4548
|
html.live-full.lf-kb .fs-side { display: none; }
|
|
4520
4549
|
html.live-full.lf-kb.fs-on .live-box.full { height: var(--lf-h, 100dvh); }
|
|
4550
|
+
/* 2026-11-06 typing outside the sheet: it steps aside whether or not a keyboard's height was measured (app.js fitSheetAside) */
|
|
4551
|
+
html.live-full.lf-typing .fs-side { display: none; }
|
|
4552
|
+
html.live-full.lf-typing.fs-on .live-box.full { height: var(--lf-h, 100dvh); }
|
|
4553
|
+
/* the sheet follows a finger (app.js); its peek is the handle, its content scrolls natively */
|
|
4554
|
+
.fs-side.dragging { transition: none; }
|
|
4555
|
+
.fs-peek { touch-action: none; }
|
|
4521
4556
|
}
|
|
4522
4557
|
@media (prefers-reduced-motion: reduce) { .fs-side { transition: none; } }
|
|
4523
4558
|
/* the tab strip: pinned tabs at the left (a pin), each tab's status as a shape AND a word, + New session */
|
|
4524
|
-
.tab-cell.pinned .tab-t { max-width: 10em; }
|
|
4525
4559
|
.tab-pin { display: inline-flex; flex: none; margin-right: 5px; color: var(--text2); }
|
|
4526
4560
|
.tab-pin .ic { width: 12px; height: 12px; }
|
|
4527
4561
|
.lf-tab .tab-pin { margin-right: 2px; }
|
|
4528
4562
|
.tab-st { display: inline-flex; align-items: center; gap: 3px; flex: none; margin-left: 6px; }
|
|
4529
4563
|
.tab-w { font-size: 11.5px; font-weight: 550; color: var(--text2); white-space: nowrap; }
|
|
4564
|
+
/* 0.9.18 the strip: a closable tab's shape sits in the ×'s place; the × on hover or keyboard focus.
|
|
4565
|
+
A pinned tab and a touch screen (no hover) keep the shape beside the title. */
|
|
4566
|
+
.tab-st:empty, .tab-st-touch { display: none; }
|
|
4567
|
+
.tab-x .tab-xsg { position: absolute; left: 50%; top: 50%; margin: -7px 0 0 -7px; }
|
|
4568
|
+
.tab-x.has-st .ic { opacity: 0; }
|
|
4569
|
+
.tab-cell:hover .tab-x.has-st .ic, .tab-x.has-st:focus-visible .ic { opacity: 1; }
|
|
4570
|
+
.tab-cell:hover .tab-x .tab-xsg, .tab-x:focus-visible .tab-xsg { visibility: hidden; }
|
|
4571
|
+
@media (hover: none), (pointer: coarse) {
|
|
4572
|
+
.tab-st-touch:not(:empty) { display: inline-flex; }
|
|
4573
|
+
.tab-x .tab-xsg { display: none; }
|
|
4574
|
+
.tab-x.has-st .ic { opacity: 1; }
|
|
4575
|
+
}
|
|
4530
4576
|
.tab-new { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 44px; color: var(--text2); border-radius: 8px; }
|
|
4531
4577
|
.tab-new .ic { width: 16px; height: 16px; }
|
|
4532
4578
|
@media (hover: hover) { .tab-new:hover { color: var(--text); background: var(--card2); } }
|
|
@@ -4594,6 +4640,7 @@ html:not(.live-full) .fs-side { display: none; }
|
|
|
4594
4640
|
.wt-seg::-webkit-scrollbar { display: none; }
|
|
4595
4641
|
.wt-seg button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 38px; padding: 1px 14px 0; box-sizing: border-box; border: 0; border-radius: 8px; background: none; color: var(--muted); font: inherit; font-size: 13px; font-weight: 550; line-height: 1; white-space: nowrap; cursor: pointer; }
|
|
4596
4642
|
.wt-seg button .ic { width: 14px; height: 14px; margin-top: -1px; }
|
|
4643
|
+
.wt-seg button .wt-seg-chev { width: 12px; height: 12px; margin: -1px -4px 0 -2px; opacity: .7; }
|
|
4597
4644
|
.wt-seg button[aria-pressed="true"] { background: var(--card2); color: var(--text); box-shadow: inset 0 0 0 1px var(--line-strong); }
|
|
4598
4645
|
.wt-seg button:disabled { opacity: .45; cursor: default; }
|
|
4599
4646
|
.wt-seg-sm button { height: 34px; padding: 1px 12px 0; }
|
|
@@ -5204,6 +5251,14 @@ html.nav-drawer-open, html.nav-drawer-open body { overflow: hidden; }
|
|
|
5204
5251
|
.pr-compact .pr-title-btn { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; font-weight: 500; color: var(--text); }
|
|
5205
5252
|
.pr-compact .pr-meta { margin-top: 2px; font-size: 12px; }
|
|
5206
5253
|
.pr-compact .pr-actions { margin: 0 -2px 0 0; }
|
|
5254
|
+
/* open: the actions sit at the top right beside the title line, so the details below take the panel's full width */
|
|
5255
|
+
.pr-compact .prrow.open { position: relative; }
|
|
5256
|
+
.pr-compact .prrow.open.pr-acts-1 .pr-c1 { padding-right: 48px; }
|
|
5257
|
+
.pr-compact .prrow.open.pr-acts-2 .pr-c1 { padding-right: 92px; }
|
|
5258
|
+
.pr-compact .prrow.open.pr-acts-3 .pr-c1 { padding-right: 136px; }
|
|
5259
|
+
.pr-compact .prrow.open.pr-acts-4 .pr-c1 { padding-right: 180px; }
|
|
5260
|
+
.pr-compact .prrow.open .pr-actions { position: absolute; top: 8px; right: 4px; margin: 0; }
|
|
5261
|
+
@media (pointer: fine) { .pr-compact .prrow.open.pr-acts-1 .pr-c1 { padding-right: 40px; } .pr-compact .prrow.open.pr-acts-2 .pr-c1 { padding-right: 72px; } .pr-compact .prrow.open.pr-acts-3 .pr-c1 { padding-right: 104px; } .pr-compact .prrow.open.pr-acts-4 .pr-c1 { padding-right: 136px; } }
|
|
5207
5262
|
@media (pointer: fine) { .pr-compact .pr-actions .pr-ic { width: 32px; height: 32px; } }
|
|
5208
5263
|
.sprs .pr-unfold { margin: 0 0 8px; }
|
|
5209
5264
|
.sprs .pr-unfold > .pr-unsum { list-style: none; display: inline-flex; align-items: center; gap: 6px; height: 32px; line-height: 1; padding: 0 10px; border: 1px dashed var(--line-strong); border-radius: 999px; font-size: 12.5px; color: var(--muted); cursor: pointer; }
|
|
@@ -5470,6 +5525,7 @@ a.dec-rc:hover .dec-rc-w { text-decoration: underline; }
|
|
|
5470
5525
|
.dec-row-h .dec-st { margin-left: 0; font-size: 11.5px; color: var(--text); }
|
|
5471
5526
|
.dec-row-h .dec-id { font: 500 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text2); opacity: .85; }
|
|
5472
5527
|
.dec-row-h .dec-src { margin-top: 0; font-size: 11.5px; }
|
|
5528
|
+
.dec-auto { font-size: 11.5px; color: var(--text2); }
|
|
5473
5529
|
.dec-dot { color: var(--text2); opacity: .6; }
|
|
5474
5530
|
.dec-list .dec-text { font-size: 14px; font-weight: 500; line-height: 1.4; margin-top: 2px; overflow-wrap: anywhere; }
|
|
5475
5531
|
.dec-row-rejected .dec-text { text-decoration: line-through; text-decoration-thickness: 1px; color: var(--text2); }
|