@leftoff/cli 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/SECURITY.md CHANGED
@@ -35,6 +35,7 @@ The folder (`~/checkpoints`, or wherever you moved it) is plain Markdown and JSO
35
35
  | Paired browsers' keys (0.6.0) | `relay/devices/<id>.json` **in the folder, wrapped** | Each device key is encrypted to each Mac's own X25519 key (whose private half never leaves that Mac) and the file is signed by the Mac that wrote it. The sync service sees ciphertext. Revoking erases the wrapped copies. |
36
36
  | Relay admin token (0.6.0) | A wrangler secret on your Cloudflare account, and wherever you keep it | Leftoff never stores it. `leftoff relay register` sends it once, over https, to your relay. `leftoff relay signup` (0.6.1) needs no token at all. |
37
37
  | Pairing secret | `~/.config/checkpoints/syncthing-pairing.json`, mode 0600, first Mac only | Erased the moment a Mac pairs with it, or when it expires. |
38
+ | Transcript search index (0.9.6) | `~/.cache/checkpoints/search/<machine>-<hash>/` (or `$XDG_CACHE_HOME`) | Per Mac, never in the folder: words and snippets from your prompts and Claude's replies (no tool output). Another Mac only gets the search results it asks for through the sealed peer channel. Safe to delete; it is rebuilt in the background. |
38
39
  | Board token (`.secret/token`) and phone sign-ins (`.secret/remote.json`, hashes) | In the folder | **With Syncthing:** kept on each Mac by Leftoff's `.stignore`, so each Mac has its own token. **With iCloud, Dropbox or another folder:** they sync, because those tools have no ignore file Leftoff can rely on. The token only works against a Mac's board, which listens on `127.0.0.1` unless you turned on remote access. Anyone who can read your synced folder *and* reach a board could use it, so treat the folder as private. |
39
40
 
40
41
  Leftoff's `.stignore` for its Syncthing folder also keeps lock files, temp files, `.DS_Store` and the daemon's pid and log on each Mac.
@@ -93,6 +94,8 @@ A relayed request is replayed against the Mac's own board from inside the daemon
93
94
 
94
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.
95
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
+
96
99
  ### Logging
97
100
 
98
101
  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.
@@ -58,6 +58,8 @@ Each command is one envelope. It's JSON and travels the same way over every tran
58
58
  | `session.checkpoint` | `sessionId`, `length?`, `userNote?`, `next?` | `engine.generateCheckpoint()`: Checkpoint now (0.9.2); returns the checkpoint, which the asking board lists at once |
59
59
  | `session.patch` | `sessionId`, `title?`, `pinned?`, `archived?` | `engine.patchSession()`: rename, Pin, Archive (0.9.2) |
60
60
  | `session.title.auto` | `sessionId` | `engine.regenerateTitle()`: Regenerate title (0.9.2) |
61
+ | `transcripts.search` | `q`, `limit` | `engine.searchTranscripts(q, { local: true })`: search inside that Mac's transcripts (0.9.6). Answers with snippets (plain text + mark ranges), never whole transcripts; the asking Mac rebuilds any HTML itself |
62
+ | `transcripts.around` | `sessionId`, `anchor`, `q` | `engine.transcriptAround()`: the few messages around a search hit, for the session page (0.9.6) |
61
63
 
62
64
  Answers are recorded in the session's `answers.md` as *from studio's board (direct)* or *(through the relay)*.
63
65
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
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"
@@ -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.5' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.7' // 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
- await $.fs.write(st.root + '/.spool/' + st.machine + '/' + st.sessionId + '/' + Date.now() + '-' + rand() + '.json', JSON.stringify({ events: [ev] }) + '\n')
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 loadSetup($)
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.5",
3
+ "version": "0.9.7",
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
- .btn { background: var(--bg2); border: 1px solid var(--line); border-radius: 8px; padding: 5px 10px; font-size: 12.5px; color: var(--muted); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
151
- .btn:hover { color: var(--text); border-color: var(--line-strong); }
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:disabled { opacity: .55; cursor: progress; }
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(--muted); font-size: 12px; }
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
 
@@ -1878,3 +1897,121 @@ html.dv-open .lo-menu { z-index: 62; }
1878
1897
  .cp dd.cp-md a { color: var(--accent); }
1879
1898
  .cp dd.cp-md blockquote { margin: 4px 0; padding-left: 10px; border-left: 2px solid var(--line-strong); color: var(--muted); }
1880
1899
  .cp dd.cp-md hr { border: 0; border-top: 1px solid var(--line); margin: 8px 0; }
1900
+
1901
+ /* 0.9.6: search inside transcripts — ⌘K's "In transcripts", the Search page, the session page's "Found in the transcript" */
1902
+ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-mark); color: inherit; border-radius: 3px; padding: 0 1px; box-decoration-break: clone; -webkit-box-decoration-break: clone; }
1903
+ /* (the dark theme is the default; the light one sets data-theme="light") */
1904
+ :root, [data-theme="dark"] { --tx-mark: rgba(250, 204, 21, .32); }
1905
+ [data-theme="light"] { --tx-mark: #fde68a; }
1906
+ .cmdk-opt.tx-opt { align-items: flex-start; }
1907
+ .cmdk-opt.tx-opt .ic { margin-top: 2px; }
1908
+ .co-snip { font-size: 12.5px; color: var(--muted); line-height: 1.45; margin-top: 2px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
1909
+ .tx-who { font-weight: 600; font-size: 11.5px; color: var(--faint); }
1910
+ .tx-who.a { color: var(--accent); }
1911
+ .cmdk-note.tx-note { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; padding: 8px 12px 10px; }
1912
+ .cmdk-note.tx-note .spin { width: 12px; height: 12px; }
1913
+ .tx-note-line { flex-basis: 100%; }
1914
+ .search-page-form { display: flex; gap: 8px; align-items: center; margin: 4px 0 18px; max-width: 640px; }
1915
+ .search-page-form .set-search { flex: 1; }
1916
+ .search-page-form .set-search input { height: 44px; font-size: 16px; border-radius: 11px; }
1917
+ .search-page-form .btn { height: 44px; }
1918
+ .tx-section { margin: 4px 0 22px; }
1919
+ .tx-h { display: flex; align-items: baseline; gap: 8px; }
1920
+ .tx-count { font-size: 12.5px; font-weight: 500; color: var(--faint); }
1921
+ .tx-list { display: flex; flex-direction: column; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--card-shadow); overflow: hidden; }
1922
+ .tx-hit { display: flex; flex-direction: column; gap: 3px; padding: 12px 14px; min-height: 44px; color: var(--text2); text-decoration: none; border-top: 1px solid var(--line); }
1923
+ .tx-hit:first-child { border-top: 0; }
1924
+ .tx-hit:hover, .tx-hit:focus-visible { background: var(--bg2); }
1925
+ .tx-hit:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
1926
+ .tx-title { font-weight: 600; color: var(--text); display: flex; gap: 8px; align-items: baseline; min-width: 0; overflow-wrap: anywhere; }
1927
+ .tx-tag { font-size: 11px; font-weight: 500; color: var(--faint); border: 1px solid var(--line); border-radius: 6px; padding: 0 5px; }
1928
+ .tx-sub { font-size: 12.5px; color: var(--faint); }
1929
+ .tx-snip { font-size: 13.5px; line-height: 1.5; color: var(--muted); overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
1930
+ .tx-note { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; margin: 8px 2px; }
1931
+ .tx-note.tx-indexing { color: var(--text2); }
1932
+ .tx-more { margin: 8px 2px; font-size: 13.5px; }
1933
+ .tx-board-link { display: inline-flex; align-items: center; gap: 5px; min-height: 32px; }
1934
+ .tx-board-link .ic { width: 14px; height: 14px; }
1935
+ .tx-around { margin: 0 0 20px; padding: 12px 14px 14px; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--card-shadow); }
1936
+ .tx-around-head { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 600; letter-spacing: .4px; text-transform: uppercase; color: var(--faint); margin-bottom: 6px; }
1937
+ .tx-around-head .tx-close { margin-left: auto; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; font-size: 20px; text-decoration: none; color: var(--muted); text-transform: none; }
1938
+ .tx-around-head .tx-close:hover { background: var(--bg2); }
1939
+ .tx-msg { padding: 10px 12px; border-radius: 10px; margin-top: 6px; border: 1px solid transparent; }
1940
+ .tx-msg.u { background: var(--bg2); }
1941
+ .tx-msg.hit { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); }
1942
+ .tx-msg.hit:focus { outline: none; }
1943
+ .tx-msg-head { display: flex; gap: 8px; align-items: baseline; margin-bottom: 3px; }
1944
+ .tx-msg-head .t { font-size: 12px; color: var(--faint); }
1945
+ .tx-msg-text { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; line-height: 1.5; color: var(--text2); max-height: 22em; overflow-y: auto; }
1946
+ .tx-msg:not(.hit) .tx-msg-text { max-height: 7.5em; }
1947
+ @media (max-width: 899.98px) {
1948
+ .co-snip { font-size: 13.5px; }
1949
+ .tx-around-head .tx-close { width: 44px; height: 44px; margin: -8px -8px -8px auto; }
1950
+ .board-search .set-search input, .board-search input { height: 44px; }
1951
+ .tx-board-link { min-height: 44px; }
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
+ }