@leftoff/cli 0.9.6 → 0.9.8-beta.1
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 +23 -0
- package/README.md +1 -1
- package/bin/leftoff.js +394 -345
- package/docs/SECURITY.md +12 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +1 -1
- package/mod/hooks/register.js +28 -3
- package/package.json +1 -1
- package/ui/app.css +121 -5
- package/ui/app.js +165 -21
- package/ui/direct.js +37 -12
- package/ui/needs.js +1 -1
- package/ui/restore-gate.js +64 -0
- package/ui/wizard.js +158 -0
package/docs/SECURITY.md
CHANGED
|
@@ -94,6 +94,18 @@ 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.
|
|
98
|
+
|
|
99
|
+
### Live link (0.9.8 beta)
|
|
100
|
+
|
|
101
|
+
Live link is opt-in, per session: only sessions started with `leftoff live` have a channel. It adds:
|
|
102
|
+
|
|
103
|
+
- **The channel** is a stdio MCP server that Claude Code starts. It opens no port. It talks to the daemon on 127.0.0.1 with the board's token, and the daemon accepts its calls (hello, next message, ack, reply, permission) from loopback only. A channel is tied to the session whose process started it, so the daemon only writes into sessions on its own Mac. Other Macs go through the peer link.
|
|
104
|
+
- **Prompts from the board or a phone** reach Claude exactly like typed input, so they follow the same rule as answering a permission: a phone needs *Trust my paired devices*, and they're rate-limited and written to the session's audit trail. Anyone who can send prompts can make Claude act, so treat trusting your paired devices as giving them your keyboard.
|
|
105
|
+
- **Permission relay** never allows anything on its own. A prompt waits for Allow once or Deny. With no answer the terminal dialog stays and decides, and an answer in the terminal wins. Live link answers one call at a time and never adds session-wide rules.
|
|
106
|
+
- **Outside events** need this Mac's event key (`~/.config/checkpoints/secrets/live-events`, mode 600, made by `leftoff live key`, replaced with `--new`). Locally that's a Bearer key. Through the relay only an HMAC signature is accepted (5-minute window, replays refused), so the relay can pass an event along but can't read the key, forge an event or change one. Both the relay and the Mac rate-limit, and bodies are capped at 16 KB. Claude's instructions call an event information, not a command, but it is still text Claude reads: anyone with the key can put words in front of your session. Keep the key in your CI's secrets.
|
|
107
|
+
- **Remote Restore** opens Ghostty tabs, so a phone or another Mac can only ask. The Mac shows a notification and a card, and only that Mac's own browser can confirm. Requests expire after 5 minutes, at most 3 wait at once, and they're rate-limited.
|
|
108
|
+
|
|
97
109
|
### Logging
|
|
98
110
|
|
|
99
111
|
The Worker never logs request or response bodies (it can't read them anyway) and stores only what's listed above, in your Durable Object. `wrangler.toml` turns Workers observability (request logs) off. Cloudflare's edge keeps its usual metadata for any site (IP, hostname, path such as `/_relay/api`, status), under its own policies.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8-beta.1",
|
|
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": "Joel"
|
package/mod/hooks/core.js
CHANGED
|
@@ -547,7 +547,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
|
|
|
547
547
|
export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
|
|
548
548
|
'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
|
|
549
549
|
'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
|
|
550
|
-
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints']
|
|
550
|
+
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink']
|
|
551
551
|
|
|
552
552
|
export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
|
|
553
553
|
'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.
|
|
31
|
+
const MOD_VERSION = '0.9.8-beta.1' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
|
@@ -103,6 +103,7 @@ async function api($, method, path, body, timeoutMs) {
|
|
|
103
103
|
// Every event goes through here: post, or spool a unique file (fs.write isn't atomic).
|
|
104
104
|
async function sendOrSpool($, type, data, timeoutMs) {
|
|
105
105
|
if (st.internal || !st.sessionId) return null
|
|
106
|
+
try { await ensureSetup($) } catch {} // a setup that failed leaves the fallback below to name the spool file's folder
|
|
106
107
|
const ev = { v: 1, type, sessionId: st.sessionId, at: nowIso(), capture: 'mod', machine: st.machine, data: data || {} }
|
|
107
108
|
st.record = applyEvent(st.record || { id: st.sessionId, turns: 0, turnsSinceCheckpoint: 0, backgroundTasks: 0 }, ev)
|
|
108
109
|
const r = await api($, 'POST', '/v1/events', { events: [ev] }, timeoutMs)
|
|
@@ -115,7 +116,17 @@ async function sendOrSpool($, type, data, timeoutMs) {
|
|
|
115
116
|
return j
|
|
116
117
|
}
|
|
117
118
|
try {
|
|
118
|
-
|
|
119
|
+
// An absolute root even when setup never came: without one the concat
|
|
120
|
+
// makes st.root null a relative "null/.spool" beside the session's cwd.
|
|
121
|
+
let root = st.root
|
|
122
|
+
if (!root) {
|
|
123
|
+
const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
|
|
124
|
+
if (home) root = home.replace(/[\\/]+$/, '') + '/checkpoints' // never a bare "/checkpoints"
|
|
125
|
+
}
|
|
126
|
+
// Only an absolute folder; otherwise skip the file but still keep the registry below.
|
|
127
|
+
if (root && /^([A-Za-z]:[\\/]|\/)/.test(String(root))) {
|
|
128
|
+
await $.fs.write(String(root) + '/.spool/' + (st.machine || 'unknown') + '/' + st.sessionId + '/' + Date.now() + '-' + rand() + '.json', JSON.stringify({ events: [ev] }) + '\n')
|
|
129
|
+
}
|
|
119
130
|
} catch {}
|
|
120
131
|
try {
|
|
121
132
|
await $.store.set('session:' + st.sessionId, {
|
|
@@ -160,6 +171,20 @@ async function loadSetup($) {
|
|
|
160
171
|
st.machine = resolveMachine({ env: await $.env.get('CHECKPOINTS_MACHINE'), hostname, cfg: st.cfg })
|
|
161
172
|
}
|
|
162
173
|
|
|
174
|
+
// loadSetup runs once per process and every sender awaits the same attempt:
|
|
175
|
+
// a hook that fires before session.start (classic.SessionStart on a cold
|
|
176
|
+
// boot, the old null/.spool race) waits for these values instead of reading
|
|
177
|
+
// st at its initial state.
|
|
178
|
+
let setupPromise = null
|
|
179
|
+
function ensureSetup($) {
|
|
180
|
+
if (!setupPromise) {
|
|
181
|
+
const p = loadSetup($)
|
|
182
|
+
setupPromise = p
|
|
183
|
+
p.catch(() => { if (setupPromise === p) setupPromise = null }) // a failed setup tries again on the next event
|
|
184
|
+
}
|
|
185
|
+
return setupPromise
|
|
186
|
+
}
|
|
187
|
+
|
|
163
188
|
async function readRepo($, cwd) {
|
|
164
189
|
let repo = null
|
|
165
190
|
try {
|
|
@@ -931,7 +956,7 @@ export function register(on, options) {
|
|
|
931
956
|
on('session.start', async ($, e, next) => {
|
|
932
957
|
try {
|
|
933
958
|
if (await isInternal($)) { st.internal = true; return next(e) }
|
|
934
|
-
await
|
|
959
|
+
try { await ensureSetup($) } catch {} // the same attempt an early hook may have started; a failure still says hello
|
|
935
960
|
st.sessionId = await $.session.id()
|
|
936
961
|
const cwd = await $.session.cwd()
|
|
937
962
|
const repo = await readRepo($, cwd)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8-beta.1",
|
|
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
|
@@ -35,6 +35,12 @@
|
|
|
35
35
|
--toast-shadow: 0 10px 30px rgba(0,0,0,.4);
|
|
36
36
|
--chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%238b93a7' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
37
37
|
--radius: 14px;
|
|
38
|
+
--btn-bg: #1d2433;
|
|
39
|
+
--btn-hover: #252d40;
|
|
40
|
+
--btn-active: #161b26;
|
|
41
|
+
--btn-line: #3a4560;
|
|
42
|
+
--btn-line-hover: #56637f;
|
|
43
|
+
--btn-shadow: none;
|
|
38
44
|
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
39
45
|
}
|
|
40
46
|
/* Light: a paper-white page with cool-gray structure, not an inverted dark.
|
|
@@ -75,6 +81,12 @@
|
|
|
75
81
|
--card-shadow: 0 1px 2px rgba(22,26,36,.04), 0 2px 8px rgba(22,26,36,.04);
|
|
76
82
|
--toast-shadow: 0 10px 30px rgba(22,26,36,.16);
|
|
77
83
|
--chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%235b6478' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
84
|
+
--btn-bg: #ffffff;
|
|
85
|
+
--btn-hover: #f4f6fa;
|
|
86
|
+
--btn-active: #e9ecf2;
|
|
87
|
+
--btn-line: #c7cedb;
|
|
88
|
+
--btn-line-hover: #9aa3b5;
|
|
89
|
+
--btn-shadow: 0 1px 1px rgba(22,26,36,.06);
|
|
78
90
|
}
|
|
79
91
|
* { box-sizing: border-box; }
|
|
80
92
|
html, body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; }
|
|
@@ -147,11 +159,18 @@ h2 .count { color: var(--faint); font-weight: 500; }
|
|
|
147
159
|
.nocp { color: var(--faint); font-size: 13px; font-style: italic; padding: 4px 0; }
|
|
148
160
|
|
|
149
161
|
.actions-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; padding-top: 2px; }
|
|
150
|
-
.
|
|
151
|
-
|
|
162
|
+
/* 0.9.7: a secondary button reads as a button on any surface (page, card, the
|
|
163
|
+
tinted banners): solid text, its own fill and a visible edge. Only a real
|
|
164
|
+
disabled state (disabled or aria-disabled) is dimmed, flat and dashed. */
|
|
165
|
+
.btn { background: var(--btn-bg); border: 1px solid var(--btn-line); border-radius: 8px; padding: 5px 10px; font-size: 12.5px; font-weight: 500; color: var(--text2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; box-shadow: var(--btn-shadow); transition: background-color .12s, border-color .12s, color .12s; }
|
|
166
|
+
.btn:hover { background: var(--btn-hover); border-color: var(--btn-line-hover); color: var(--text); }
|
|
167
|
+
.btn:active { background: var(--btn-active); box-shadow: none; }
|
|
168
|
+
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
152
169
|
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 600; }
|
|
153
170
|
.btn.primary:hover { filter: brightness(1.08); color: var(--on-accent); }
|
|
154
|
-
.btn:
|
|
171
|
+
.btn.primary:active { filter: brightness(.94); }
|
|
172
|
+
.btn:disabled, .btn[aria-disabled="true"], .btn:disabled:hover, .btn[aria-disabled="true"]:hover, .btn:disabled:active, .btn[aria-disabled="true"]:active { opacity: .5; cursor: not-allowed; box-shadow: none; filter: none; border-style: dashed; background: var(--btn-bg); border-color: var(--btn-line-hover); color: var(--muted); }
|
|
173
|
+
@media (prefers-reduced-motion: reduce) { .btn { transition: none; } }
|
|
155
174
|
|
|
156
175
|
.empty { border: 1px dashed var(--line); border-radius: var(--radius); padding: 38px; text-align: center; color: var(--muted); }
|
|
157
176
|
.empty code { background: var(--bg2); padding: 2px 6px; border-radius: 6px; }
|
|
@@ -488,7 +507,6 @@ h2 .count { color: var(--faint); font-weight: 500; }
|
|
|
488
507
|
.btn.allow:hover { filter: brightness(1.08); }
|
|
489
508
|
.btn.deny { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--line)); }
|
|
490
509
|
.btn.deny:hover { background: var(--tint-red); }
|
|
491
|
-
.btn[disabled] { opacity: .5; cursor: not-allowed; }
|
|
492
510
|
.deny-form { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
493
511
|
.deny-form[hidden] { display: none; }
|
|
494
512
|
.deny-form input, .need .qother, .reply-text { flex: 1 1 220px; min-width: 0; background: var(--field); border: 1px solid var(--line); border-radius: 8px; color: var(--text); padding: 7px 9px; font: inherit; font-size: 13px; }
|
|
@@ -635,7 +653,8 @@ html { background: var(--bg); }
|
|
|
635
653
|
.banner.restore .rs-m { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
|
636
654
|
.banner.restore .rs-m.err { color: var(--red); }
|
|
637
655
|
.banner.restore .rs-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
638
|
-
.banner.restore .rs-note { color: var(--
|
|
656
|
+
.banner.restore .rs-note { color: var(--text2); font-size: 12.5px; flex: 1 1 280px; min-width: 0; }
|
|
657
|
+
@media (pointer: coarse), (max-width: 600px) { .banner.restore .rs-actions .btn { min-height: 44px; } }
|
|
639
658
|
.banner .rs-cmds { margin: 8px 0 0; padding: 8px 10px; background: var(--bg2); border: 1px solid var(--line); border-radius: 8px; font: 12px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text2); }
|
|
640
659
|
.banner.restore .rs-result { margin: 10px 0 0; }
|
|
641
660
|
|
|
@@ -1931,3 +1950,100 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
1931
1950
|
.board-search .set-search input, .board-search input { height: 44px; }
|
|
1932
1951
|
.tx-board-link { min-height: 44px; }
|
|
1933
1952
|
}
|
|
1953
|
+
|
|
1954
|
+
/* ------------------------------------------------ first-run setup wizard (0.9.7, ui/wizard.js) */
|
|
1955
|
+
.wz { max-width: 860px; margin: 8px auto 40px; }
|
|
1956
|
+
.wz .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
1957
|
+
.wz-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
|
1958
|
+
.wz-h1 { font-size: 22px; line-height: 1.25; margin: 0; font-weight: 700; color: var(--text); }
|
|
1959
|
+
.wz-count { color: var(--muted); font-size: 13px; margin-top: 2px; }
|
|
1960
|
+
.wz-bar { list-style: none; margin: 0 0 14px; padding: 0; display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; }
|
|
1961
|
+
.wz-bar::-webkit-scrollbar { display: none; }
|
|
1962
|
+
.wz-bar li { flex: 1 0 auto; }
|
|
1963
|
+
.wz-bar button { width: 100%; display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--line); background: var(--card); color: var(--muted); font: inherit; font-size: 13px; cursor: pointer; white-space: nowrap; min-height: 40px; }
|
|
1964
|
+
.wz-bar button:hover { border-color: var(--line-strong); color: var(--text); }
|
|
1965
|
+
.wz-num { flex: none; width: 22px; height: 22px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; background: var(--bg2); color: var(--muted); border: 1px solid var(--line); }
|
|
1966
|
+
.wz-bar li.done .wz-num { background: var(--tint-green); color: var(--green); border-color: color-mix(in srgb, var(--green) 45%, var(--line)); }
|
|
1967
|
+
.wz-bar li.on button { border-color: var(--accent); color: var(--text); background: var(--tint-accent); }
|
|
1968
|
+
.wz-bar li.on .wz-num { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
|
1969
|
+
.wz-card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--card-shadow); padding: 18px; outline: none; }
|
|
1970
|
+
.wz-card:focus-visible { box-shadow: 0 0 0 2px var(--focus); }
|
|
1971
|
+
.wz .wz-step { margin: 0 0 12px; }
|
|
1972
|
+
.wz-status { display: flex; gap: 12px; align-items: flex-start; }
|
|
1973
|
+
.wz-mark { flex: none; width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; background: var(--tint-gray); color: var(--muted); }
|
|
1974
|
+
.wz-status.s-done .wz-mark, .wz-sumlist .wz-done .wz-mark { background: var(--tint-green); color: var(--green); }
|
|
1975
|
+
.wz-status.s-todo .wz-mark, .wz-sumlist .wz-todo .wz-mark { background: var(--tint-yellow); color: var(--yellow); }
|
|
1976
|
+
.wz-title { font-size: 16px; font-weight: 600; color: var(--text); line-height: 1.35; }
|
|
1977
|
+
.wz-detail { color: var(--text2); margin-top: 3px; }
|
|
1978
|
+
.wz-p { color: var(--text2); margin: 12px 0 0; }
|
|
1979
|
+
.wz-p a, .wz-detail a { color: var(--accent); }
|
|
1980
|
+
.wz-hint { color: var(--muted); font-size: 12.5px; }
|
|
1981
|
+
.wz-list { list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 6px; }
|
|
1982
|
+
.wz-list li { display: flex; align-items: center; gap: 8px; color: var(--text2); flex-wrap: wrap; }
|
|
1983
|
+
.wz-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--gray); flex: none; }
|
|
1984
|
+
.wz-dot.up { background: var(--green); } .wz-dot.down { background: var(--red); }
|
|
1985
|
+
.wz-chip { font-size: 11.5px; font-weight: 600; padding: 1px 8px; border-radius: 999px; background: var(--tint-gray); color: var(--muted); }
|
|
1986
|
+
.wz-chip.on { background: var(--tint-green); color: var(--green); }
|
|
1987
|
+
.wz-actions { display: flex; align-items: center; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
|
|
1988
|
+
.wz-result { margin-top: 12px; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); display: flex; flex-direction: column; gap: 4px; color: var(--text2); }
|
|
1989
|
+
.wz-result.ok { border-color: color-mix(in srgb, var(--green) 40%, var(--line)); }
|
|
1990
|
+
.wz-result.bad { border-color: color-mix(in srgb, var(--red) 45%, var(--line)); color: var(--text); }
|
|
1991
|
+
.wz-fix { margin-top: 14px; padding: 10px 12px; border-radius: 10px; background: var(--bg2); border: 1px solid var(--line); }
|
|
1992
|
+
.wz-fix-what { font-size: 13px; color: var(--text2); margin-bottom: 6px; }
|
|
1993
|
+
.wz-cmdrow { display: flex; align-items: center; gap: 8px; }
|
|
1994
|
+
.wz-cmd { flex: 1; min-width: 0; overflow-wrap: anywhere; color: var(--text); font-size: 13px; }
|
|
1995
|
+
.wz-sumlist { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
1996
|
+
.wz-sumlist li { display: flex; gap: 10px; align-items: flex-start; color: var(--text2); border: 0; padding: 0; }
|
|
1997
|
+
.wz-sumlist b { color: var(--text); margin-right: 4px; }
|
|
1998
|
+
.wz-h3 { font-size: 15px; margin: 18px 0 0; color: var(--text); }
|
|
1999
|
+
.wz-allset { font-weight: 600; color: var(--green); }
|
|
2000
|
+
.wz-nav { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 14px; }
|
|
2001
|
+
.wz-nav-r { display: flex; gap: 8px; }
|
|
2002
|
+
.wz .btn { font-size: 14px; padding: 8px 14px; min-height: 38px; }
|
|
2003
|
+
.wz-remote { margin-top: 10px; }
|
|
2004
|
+
@media (pointer: coarse), (max-width: 600px) {
|
|
2005
|
+
.wz .btn, .wz-bar button { min-height: 44px; }
|
|
2006
|
+
.wz-copy { min-width: 64px; justify-content: center; }
|
|
2007
|
+
}
|
|
2008
|
+
@media (max-width: 600px) {
|
|
2009
|
+
.wz { margin-top: 0; }
|
|
2010
|
+
.wz-card { padding: 14px; }
|
|
2011
|
+
.wz-bar li { flex: none; }
|
|
2012
|
+
.wz-bar .wz-lbl { display: none; }
|
|
2013
|
+
.wz-bar li.on .wz-lbl { display: inline; }
|
|
2014
|
+
.wz-cmdrow { flex-direction: column; align-items: flex-start; }
|
|
2015
|
+
.wz-cmd { width: 100%; }
|
|
2016
|
+
.wz-nav .btn { padding: 8px 16px; }
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/* 0.9.8 Live link: the Live badge, and the session page's message box with its delivery states */
|
|
2020
|
+
.pill.live { color: var(--green); background: var(--tint-green); }
|
|
2021
|
+
.live-box { margin: 16px 0 18px; border: 1px solid color-mix(in srgb, var(--green) 35%, var(--line)); background: linear-gradient(180deg, var(--tint-green), transparent 70%), var(--card); border-radius: var(--radius); padding: 12px 14px 14px; }
|
|
2022
|
+
.live-box.off { border-color: var(--line); background: var(--card); }
|
|
2023
|
+
.live-box.off p { margin: 4px 0 0; font-size: 13px; line-height: 1.5; }
|
|
2024
|
+
.live-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
|
|
2025
|
+
.live-head h2 { margin: 0; font-size: 15px; display: inline-flex; align-items: center; gap: 8px; }
|
|
2026
|
+
.live-head .faint { font-size: 12.5px; }
|
|
2027
|
+
.live-log { list-style: none; margin: 8px 0 10px; padding: 0; display: flex; flex-direction: column; gap: 8px; max-height: 420px; overflow-y: auto; }
|
|
2028
|
+
.lv { border: 1px solid var(--line); border-radius: 10px; padding: 7px 10px; background: var(--card); max-width: 88%; }
|
|
2029
|
+
.lv-you { align-self: flex-end; background: var(--tint-accent); border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
|
|
2030
|
+
.lv-event { align-self: flex-end; background: var(--tint-yellow); border-color: color-mix(in srgb, var(--yellow) 35%, var(--line)); }
|
|
2031
|
+
.lv-claude { align-self: flex-start; }
|
|
2032
|
+
.lv-who { font-size: 11.5px; color: var(--muted); display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; }
|
|
2033
|
+
.lv-text { white-space: pre-wrap; word-break: break-word; font-size: 13.5px; color: var(--text); margin-top: 2px; }
|
|
2034
|
+
.lv-state { margin-left: auto; font-weight: 600; }
|
|
2035
|
+
.lv-state.sent { color: var(--muted); }
|
|
2036
|
+
.lv-state.received { color: var(--green); }
|
|
2037
|
+
.lv-state.failed { color: var(--red); }
|
|
2038
|
+
.live-empty { margin: 6px 0 10px; font-size: 13px; }
|
|
2039
|
+
.live-err { color: var(--red); font-size: 13px; }
|
|
2040
|
+
.live-row .live-text { min-height: 44px; }
|
|
2041
|
+
.restore-ask .rs-m { color: var(--muted); font-size: 12.5px; }
|
|
2042
|
+
.rs-note.rs-ask.ok { color: var(--green); }
|
|
2043
|
+
.rs-note.rs-ask.warn { color: var(--red); }
|
|
2044
|
+
@media (pointer: coarse), (max-width: 600px) {
|
|
2045
|
+
.live-row .btn, .restore-ask .btn { min-height: 44px; min-width: 44px; }
|
|
2046
|
+
.need-actions .btn, .deny-form .btn { min-height: 44px; } /* Allow / Deny from the phone */
|
|
2047
|
+
.live-box { padding: 10px; margin-left: -4px; margin-right: -4px; }
|
|
2048
|
+
.lv { max-width: 100%; }
|
|
2049
|
+
}
|