@leftoff/cli 0.9.9-beta.1 → 0.9.9-beta.10

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
@@ -106,6 +106,21 @@ Live link is opt-in, per session: only sessions started with `leftoff live` have
106
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
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
108
 
109
+ ### Start a session from the board (0.9.9 beta)
110
+
111
+ **New session** starts Claude Code on a Mac you pick, headless (`claude -p` with stream-json in and out, run by that Mac's daemon). That is more than answering a prompt (a new agent in your repo, on your Claude plan), so:
112
+
113
+ - **This Mac's own board** starts it at once. **A phone or another Mac** may start one only when that Mac has *Allow starting sessions from other devices* on (Settings → Privacy & security, off by default) **and** *Trust my paired devices* is on. The switch is kept on that Mac (`~/.config/checkpoints/local/`, not the synced folder, so turning it on one Mac never turns it on another) and only that Mac's own browser can change it (loopback; a phone, the relay or a peer gets 403). Every start from another device shows a notification on the Mac. With the switch off, the request waits up to 5 minutes for a yes on that Mac's board, like Remote Restore. At most 3 wait at once; 3 starts at once, then 1 a minute.
114
+ - **The repo list is folders that Mac has already run Claude Code in** (from its session records; no typed paths). A headless run loads a repo's hooks and `.mcp.json` without the trust dialog, so a folder Claude Code never ran in is treated as a first time:
115
+ - **Other folder…** browses that Mac's folders: names only (never a file or what's in one), inside your home by real path (a symlink out of home is refused or left out), never the synced Leftoff folder or anything in it, no hidden folders, `node_modules` or `Library`, at most 500 per listing. Another Mac's are read over the peer link (`fs.dirs`, not an open action, the same as any read), a phone's through the relay. Nothing is written.
116
+ - **A first time starts at once only from that Mac's own board** (loopback, or your hosted board open on it): you are the trust prompt. From a phone or another Mac it waits for a yes on that Mac's board, even with *Allow starting sessions from other devices* on (unless it's inside an approved folder, below), and the ask and the notification say "first time in <folder>; this runs its hooks". After one run it's in the list like the rest. Home itself is refused; a folder that isn't a git repo can't get a new worktree.
117
+ - **Approved folders** (per Mac, 0.9.9): with the switch on, a first time inside a folder you approved on that Mac starts from another device without the ask (and notifies that Mac, like any remote start). They're kept in that Mac's own local file (`~/.config/checkpoints/local/`, not `config.json`, never synced), stored as real paths inside your home; home itself, the synced Leftoff folder (or anything inside it), hidden folders and links out of home are refused. A start counts by the folder's real path, so a link that only looks inside an approved folder doesn't. They're added and removed only at the Mac itself: Settings (locked elsewhere), "Always allow in <folder>" on the first-time ask (the folder above it that holds the repos that Mac knows, or its parent; never home), or `leftoff starts allow|disallow <folder>`.
118
+ - **Permission mode `default`, always passed.** Reads in the repo run; every edit and command becomes a Needs-you request (here, the phone, any board; first answer wins) and waits for it, with no 9.5-minute cap since nobody is at a terminal. A request settled any other way is a no. Never `bypassPermissions` or `auto`. AskUserQuestion is answered "ask in words", so Claude asks in plain text and you reply in Live.
119
+ - **Other Macs** reach it over the signed, encrypted peer link (`session.start`, `session.start.get`, `session.headless`, `fs.dirs`; not open actions, so the target Mac's *Allow other machines* applies too).
120
+ - **Nothing new in the folder.** The card, its title and checkpoints as for any session, plus `startedFrom` (device and how it came, not the prompt) and the run's state. Stream output, stdin and stderr stay in the daemon's memory; Claude's transcript stays in `~/.claude` on that Mac; the Live thread and attachments stay where Live keeps them (`~/.config/checkpoints`).
121
+ - **Keeping the Mac awake.** While a turn runs, the daemon holds `caffeinate -i -w <pid>` (idle sleep waits; a closed lid on battery still sleeps). Stop interrupts the turn, closes stdin, then SIGINT, then SIGTERM. A daemon that stops pauses its sessions.
122
+ - **Logged:** one daemon-log line per start, stop and permission request (session id and tool, never prompt text).
123
+
109
124
  ### What it did: tool output and the diff (0.9.8 beta)
110
125
 
111
126
  The session page's **What it did** shows the last tools a session ran with what they printed, and **This folder's changes** (the session's folder's uncommitted changes). This puts command output and file contents on the board and on your phone, so:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.9-beta.1",
3
+ "version": "0.9.9-beta.10",
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
@@ -567,7 +567,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
567
567
  export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
568
568
  'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
569
569
  'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
570
- 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive']
570
+ 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive', 'headless', 'startedFrom']
571
571
 
572
572
  export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
573
573
  'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
@@ -914,8 +914,8 @@ export function applyEvent(s, ev) {
914
914
  s.inTurn = false
915
915
  s.activeSince = null
916
916
  s.needsInput = false
917
- if (d.countTurn !== false) s.turns = (s.turns || 0) + 1 // false: Codex already counted it at the start
918
- s.turnsSinceCheckpoint = (s.turnsSinceCheckpoint || 0) + 1
917
+ if (d.countTurn !== false && !d.again) s.turns = (s.turns || 0) + 1 // false: Codex already counted it at the start
918
+ if (!d.again) s.turnsSinceCheckpoint = (s.turnsSinceCheckpoint || 0) + 1 // again (0.9.9): a late report of a turn already ended and counted
919
919
  s.backgroundTasks = Number(d.backgroundTasks || 0) + Number(d.sessionCrons || 0)
920
920
  if (d.lastAssistantPreview) s.lastPreview = clip(d.lastAssistantPreview, 280)
921
921
  // Claude ended the turn on a question to you (v0.4.4): the board offers a reply.
@@ -1509,13 +1509,48 @@ export function fallbackTitle(session) {
1509
1509
  return repoName(session) + (branch ? '@' + branch : '') + (words ? ' · ' + words : '')
1510
1510
  }
1511
1511
 
1512
- export const TITLE_SYSTEM = 'Write a title of at most 6 words for a coding session, based on its first prompt. Reply with the title only, no quotes, no punctuation at the end.'
1512
+ // 0.9.9: the title is in the language the user writes in (English when unsure),
1513
+ // one language, one script: a small local model once named an English session
1514
+ // "Ants README笑话" ("joke" in Chinese).
1515
+ export const TITLE_LANGUAGE = 'Write it in the same language as the user\'s own messages (English if unsure), in one language only: never mix in words or characters from another language.'
1516
+ 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.'
1513
1517
 
1514
1518
  export function cleanTitle(text) {
1515
- const t = String(text || '').split('\n').map((l) => l.trim()).find(Boolean) || ''
1519
+ const t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').split('\n').map((l) => l.trim()).find(Boolean) || ''
1516
1520
  return clip(t.replace(/^title\s*:\s*/i, '').replace(/^["'`*]+|["'`*.]+$/g, ''), 60)
1517
1521
  }
1518
1522
 
1523
+ // The writing systems in a text's letters (digits, punctuation and emoji have none).
1524
+ // Chinese and Japanese characters count as one ("han").
1525
+ const SCRIPTS = [
1526
+ ['latin', /\p{Script=Latin}/u], ['han', /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Bopomofo}]/u], ['hangul', /\p{Script=Hangul}/u],
1527
+ ['cyrillic', /\p{Script=Cyrillic}/u], ['greek', /\p{Script=Greek}/u], ['arabic', /\p{Script=Arabic}/u], ['hebrew', /\p{Script=Hebrew}/u],
1528
+ ['thai', /\p{Script=Thai}/u], ['devanagari', /\p{Script=Devanagari}/u],
1529
+ ]
1530
+ export function scriptsOf(text) {
1531
+ const out = new Set()
1532
+ for (const ch of String(text || '')) {
1533
+ if (!/\p{L}/u.test(ch)) continue
1534
+ const hit = SCRIPTS.find(([, re]) => re.test(ch))
1535
+ out.add(hit ? hit[0] : 'other')
1536
+ }
1537
+ return out
1538
+ }
1539
+ // Does a generated title keep to the user's writing? Every script in it must
1540
+ // be one the user wrote in; Latin letters are always fine (file names, code,
1541
+ // "README"). A title that brings in another script is dropped for the fallback.
1542
+ export function titleFitsUser(title, userText) {
1543
+ const mine = scriptsOf(userText)
1544
+ for (const sc of scriptsOf(title)) if (sc !== 'latin' && !mine.has(sc)) return false
1545
+ return true
1546
+ }
1547
+ // The line under a title prompt that pins the language: written in Latin
1548
+ // letters only, the title is too (what small models drift from).
1549
+ export function titleLanguageLine(userText) {
1550
+ const mine = scriptsOf(userText)
1551
+ return [...mine].some((sc) => sc !== 'latin') ? 'Title language: the language of the messages above.' : 'Title language: the language of the messages above (English if unsure), Latin letters only.'
1552
+ }
1553
+
1519
1554
  // ---------------------------------------------------------------- catch me up (v0.5.0)
1520
1555
 
1521
1556
  // A model reply -> its bullet lines (at most max), or null when fewer than two.
@@ -1840,6 +1875,14 @@ export function isNonInteractive({ surface = null, entrypoint = null } = {}) {
1840
1875
  return surface === 'headless' || surface === 'codex-exec'
1841
1876
  }
1842
1877
 
1878
+ // 0.9.9: a session Leftoff itself started from the board (src/headless.js).
1879
+ // It runs as `claude -p` (entrypoint sdk-cli) but it is your work: the daemon
1880
+ // spawned it with LEFTOFF_HEADLESS=<its session id>. Anything else in that
1881
+ // variable (empty, a stray value) doesn't count.
1882
+ export function isLeftoffHeadless(value) {
1883
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || '').trim())
1884
+ }
1885
+
1843
1886
  // Our own summarizer's and titler's prompts (buildCheckpointPrompt, TITLE
1844
1887
  // prompt): a session that starts with one is Leftoff talking to a model.
1845
1888
  export function isOwnSummarizerPrompt(p) {
@@ -19,7 +19,7 @@ import {
19
19
  shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
20
20
  parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
21
21
  listHintFrom, extractChecklists, parseTodoArgs, parseDoneArgs, detectBackend, parseParkArgs,
22
- boardLinkLines, isNonInteractive, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText,
22
+ boardLinkLines, isNonInteractive, isLeftoffHeadless, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText,
23
23
  } from './core.js'
24
24
 
25
25
  const PANE_ID = 'checkpoints'
@@ -28,10 +28,11 @@ 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.9-beta.1' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.9-beta.10' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
35
+ headless: false, // 0.9.9: started from the board (LEFTOFF_HEADLESS)
35
36
  ready: false,
36
37
  sessionId: null,
37
38
  root: null,
@@ -150,10 +151,19 @@ async function sayHelloAgain($) {
150
151
  async function isInternal($) {
151
152
  // Leftoff's own claude runs (the summarizer's claude -p, claude plugin …) carry this.
152
153
  if ((await $.env.get('LEFTOFF_INTERNAL')) || (await $.env.get('CHECKPOINTS_INTERNAL'))) return true
154
+ // 0.9.9: a session Leftoff started from the board runs as claude -p, but it is yours.
155
+ if (await isHeadless($)) return false
153
156
  // 0.8.6: `claude -p` / the Agent SDK (CLAUDE_CODE_ENTRYPOINT sdk-*): not a work session.
154
157
  try { return isNonInteractive({ entrypoint: await $.env.get('CLAUDE_CODE_ENTRYPOINT') }) } catch { return false }
155
158
  }
156
159
 
160
+ // 0.9.9: started by Leftoff from the board (src/headless.js): no terminal, no
161
+ // tab to name, the daemon writes the board's messages on its stdin and answers
162
+ // its permission prompts itself, so the mod only reports.
163
+ async function isHeadless($) {
164
+ try { return isLeftoffHeadless(await $.env.get('LEFTOFF_HEADLESS')) } catch { return false }
165
+ }
166
+
157
167
  async function loadSetup($) {
158
168
  const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
159
169
  st.root = ((await $.env.get('LEFTOFF_DIR')) || (await $.env.get('CHECKPOINTS_DIR')) || home + '/checkpoints').replace(/\/+$/, '')
@@ -577,7 +587,8 @@ async function readBackend($, model) {
577
587
  // tab titled. (Claude Code's terminalTitleFromRename setting has to stay on,
578
588
  // which it is by default.) With CLAUDE_CODE_DISABLE_TERMINAL_TITLE set, Claude
579
589
  // Code draws nothing, so the mod writes OSC 2 to the session's tty instead.
580
- function tabTitleOn() { return st.cfg.tabTitle !== false && !st.internal && st.surface !== 'headless' }
590
+ const noTab = () => st.surface === 'headless' || st.surface === 'leftoff' // no terminal tab (claude -p, or started from the board)
591
+ function tabTitleOn() { return st.cfg.tabTitle !== false && !st.internal && !noTab() }
581
592
 
582
593
  function desiredTabTitle(name) {
583
594
  const repo = st.record ? repoName(st.record) : null
@@ -865,7 +876,7 @@ const ASK_MAX_MS = 9.5 * 60 * 1000
865
876
 
866
877
  async function permissionHook($, e, next) {
867
878
  const base = await next(e)
868
- if (st.internal || !st.sessionId || (base && base.decision)) return base
879
+ if (st.internal || st.headless || !st.sessionId || (base && base.decision)) return base // headless: the daemon answers on its pipe
869
880
  const opened = await api($, 'POST', '/v1/asks', {
870
881
  sessionId: e.session_id || st.sessionId, tool: e.tool_name, input: e.tool_input, suggestions: e.permission_suggestions,
871
882
  capture: 'mod', cwd: e.cwd, permissionMode: e.permission_mode,
@@ -913,7 +924,7 @@ async function toolRan($, e) {
913
924
  // typed in with $.prompt.submit, which Claude Code runs once the session is idle
914
925
  // (checked on 2.1.295: a submit during a turn waits for it to end).
915
926
  function startReplyLoop($) {
916
- if (st.replyLoop || st.internal || st.surface === 'headless') return
927
+ if (st.replyLoop || st.internal || noTab()) return
917
928
  st.replyLoop = true
918
929
  const loop = async () => {
919
930
  let backoff = 0
@@ -964,6 +975,8 @@ export function register(on, options) {
964
975
  try { model = await $.session.model() } catch {}
965
976
  let surface = null
966
977
  try { const surfaces = await $.session.surfaces(); surface = surfaces && surfaces.length ? surfaces[0] : 'headless' } catch {}
978
+ st.headless = await isHeadless($)
979
+ if (st.headless) surface = 'leftoff' // 0.9.9: started from the board, a work session (not 'headless')
967
980
  st.record = null
968
981
  // Where this session runs: Claude Code's pid + tty (for open/closed and
969
982
  // Terminal.app) and the terminal's own ids (for Go).
@@ -1006,7 +1019,7 @@ export function register(on, options) {
1006
1019
  // a new id in the same process (/clear, /resume) runs in the same tab
1007
1020
  ...(parentSessionId ? { proc: st.proc ? { ...st.proc, source: 'mod' } : null, term: st.term } : {}),
1008
1021
  })
1009
- if (st.surface !== 'headless') {
1022
+ if (!noTab()) {
1010
1023
  const own = e.session_title ? parseTabTitle(e.session_title) : null
1011
1024
  const name = own && !own.id ? own.name : (own && own.name) || st.daemonTitle || null
1012
1025
  await applyTabTitle($, name, { force: !!parentSessionId, current: e.session_title || null })
@@ -1055,8 +1068,8 @@ export function register(on, options) {
1055
1068
  })
1056
1069
  // The daemon titles a session after its first turn (a few seconds of
1057
1070
  // summarizer); name the tab once it has, without waiting for the minute heartbeat.
1058
- if (st.daemonTitle && st.surface !== 'headless') await noteTitle($, st.daemonTitle)
1059
- else if (!st.titleSoon && st.surface !== 'headless' && tabTitleOn()) {
1071
+ if (st.daemonTitle && !noTab()) await noteTitle($, st.daemonTitle)
1072
+ else if (!st.titleSoon && !noTab() && tabTitleOn()) {
1060
1073
  st.titleSoon = true
1061
1074
  $.clock.after(12000, () => { heartbeat($).catch(() => {}) })
1062
1075
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.9-beta.1",
3
+ "version": "0.9.9-beta.10",
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
@@ -182,6 +182,20 @@ h2 .count { color: var(--faint); font-weight: 500; }
182
182
  .fact { background: var(--card); box-shadow: var(--card-shadow); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; min-width: 0; }
183
183
  .fact .k { color: var(--faint); font-size: 11.5px; text-transform: uppercase; letter-spacing: .6px; }
184
184
  .fact .v { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
185
+ .fact .k, .fact .v { display: block; }
186
+ /* 0.9.9: a card with an id or a path is a button: tap copies the whole value, which is its tip */
187
+ .fact.copyable { position: relative; width: 100%; min-height: 44px; text-align: left; cursor: pointer; font: inherit; color: inherit; outline: none; -webkit-tap-highlight-color: transparent; transition: border-color .12s, background .12s; }
188
+ .fact.copyable .k { display: flex; align-items: center; gap: 6px; }
189
+ .fact-ic { display: inline-grid; margin-left: auto; color: var(--faint); opacity: .55; transition: opacity .12s, color .12s; }
190
+ .fact-ic svg { width: 13px; height: 13px; }
191
+ @media (hover: hover) { .fact.copyable:hover { border-color: var(--line-strong); } .fact.copyable:hover .fact-ic { opacity: 1; color: var(--text2); } }
192
+ .fact.copyable:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
193
+ .fact.copyable:focus-visible .fact-ic { opacity: 1; }
194
+ .fact.copyable.copied { border-color: var(--accent); }
195
+ .fact.copyable.copied .fact-ic { opacity: 1; color: var(--accent); }
196
+ .fact.copyable[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 20; padding: 4px 8px; border-radius: 7px; background: var(--card2); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow); color: var(--text2); font: 500 12px/1.35 var(--mono); text-transform: none; letter-spacing: normal; white-space: normal; overflow-wrap: anywhere; pointer-events: none; }
197
+ @media (hover: hover) { .fact.copyable[data-tip]:hover::after { display: block; } }
198
+ .fact.copyable[data-tip]:focus-visible::after, .fact.copyable.copied[data-tip]::after { display: block; }
185
199
  .strip { display: flex; gap: 3px; margin: 8px 0 4px; flex-wrap: wrap; }
186
200
  .strip span { font-size: 11px; padding: 2px 8px; border-radius: 6px; }
187
201
  .timeline { position: relative; margin-left: 8px; padding-left: 22px; border-left: 2px solid var(--line); }
@@ -2100,6 +2114,28 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2100
2114
  .lv-tick { margin-top: 4px; font-size: 11.5px; color: var(--muted); text-align: right; }
2101
2115
  .lv-tick.got span { color: var(--accent); letter-spacing: -2px; padding-right: 2px; }
2102
2116
  .lv-tick.bad { color: var(--red); text-align: left; }
2117
+ /* 0.9.9 Queued: sent while Claude works, not taken in yet: dimmed, a dashed edge, Queued (not Working).
2118
+ Compact: no sender line, a long one folds to two lines (a tap opens it), and one small row
2119
+ "◷ Queued · Send now · Edit · Cancel" of text buttons, each with a 44px hit area (the
2120
+ negative margins keep the row 18px tall and the words evenly spaced; neighbours' hit
2121
+ areas don't overlap). Several stack tight. */
2122
+ .lv-you.lv-queued { background: transparent; border-style: dashed; border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); padding: 6px 10px; margin-bottom: 6px; }
2123
+ .lv-you.lv-queued .lv-text, .lv-you.lv-queued .att-bubble { opacity: .62; }
2124
+ .lv-you.lv-queued .lv-text { margin-top: 0; }
2125
+ .lv-queued .lv-q-long { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; cursor: pointer; border-radius: 4px; }
2126
+ .lv-queued .lv-q-long.open { display: block; -webkit-line-clamp: unset; line-clamp: none; overflow: visible; }
2127
+ .lv-queued .lv-q-long:focus:not(:focus-visible) { outline: none; }
2128
+ .lv-queued .lv-q-long:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2129
+ .lv-q { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; column-gap: 5px; row-gap: 0; margin-top: 4px; min-height: 18px; font-size: 12.5px; line-height: 18px; color: var(--muted); }
2130
+ .lv-q-tag { display: inline-flex; align-items: center; gap: 4px; font-weight: 600; letter-spacing: .01em; color: var(--muted); }
2131
+ .lv-q-tag::before { content: "◷"; font-size: 13px; font-weight: 400; }
2132
+ .lv-q-dot { opacity: .7; }
2133
+ .lv-q-hint { color: var(--muted); }
2134
+ .lv-q-btn { background: none; border: 0; box-shadow: none; margin: -13px -4px; padding: 0 4px; min-height: 44px; min-width: 44px; display: inline-flex; align-items: center; justify-content: center; font-size: 14px; line-height: 18px; font-weight: 600; color: var(--accent); cursor: pointer; border-radius: 8px; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
2135
+ .lv-q-btn:hover { text-decoration: underline; text-underline-offset: 3px; }
2136
+ .lv-q-btn:focus:not(:focus-visible) { outline: none; }
2137
+ .lv-q-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -8px; }
2138
+ .lv-q-btn:disabled { opacity: .45; cursor: default; text-decoration: none; }
2103
2139
  .lv-working .lvs-line { margin-top: 0; }
2104
2140
  .lv-pending .lv-working { margin-top: 4px; }
2105
2141
  .lvs-sep { color: var(--faint); }
@@ -2846,3 +2882,237 @@ del.gd-x { background: var(--gd-del-word); }
2846
2882
  .lv-tools .lv-tool-open::after { content: '›'; position: absolute; right: 8px; top: 0; color: var(--faint); font-size: 14px; }
2847
2883
  .lv-tools .lv-tool-open:hover { background: var(--bg2); color: var(--text); }
2848
2884
  .lv-tools .lv-tool-open:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
2885
+
2886
+ /* 0.9.9 New session (ui/start.js): plain lists of big choices, inline on a phone */
2887
+ .start { display: flex; flex-direction: column; gap: 18px; max-width: 760px; margin-top: 16px; }
2888
+ .start-where .start-opt-sub { white-space: normal; }
2889
+ .start-sec { display: flex; flex-direction: column; gap: 8px; }
2890
+ .start-h { font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; margin: 0; }
2891
+ .start-opts { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; }
2892
+ .start-where { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2893
+ .start-opt { min-height: 44px; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 2px; text-align: left; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--card); color: var(--text); font: inherit; font-size: 14px; cursor: pointer; min-width: 0; }
2894
+ .start-opt:hover:not(:disabled) { border-color: var(--line-strong); }
2895
+ .start-opt[aria-checked="true"] { border-color: var(--accent); background: var(--tint-accent); box-shadow: inset 0 0 0 1px var(--accent); }
2896
+ .start-opt:disabled { opacity: .5; cursor: default; }
2897
+ .start-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2898
+ .start-opt-main { font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2899
+ .start-opt-sub { font-size: 12px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2900
+ .start-branch { font-weight: 400; color: var(--muted); font-size: 12px; }
2901
+ .start-more { align-self: flex-start; min-height: 44px; }
2902
+ .start-hint, .start-empty { margin: 0; font-size: 12.5px; color: var(--muted); }
2903
+ .start-rule { margin: 0; font-size: 13px; color: var(--text2); }
2904
+ .start-rule.bad { color: var(--red); }
2905
+ .start-ask { padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); font-size: 13.5px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
2906
+ .start-ask.wait { border-color: var(--yellow); background: var(--tint-yellow); }
2907
+ .start-ask.bad { border-color: var(--red); background: var(--tint-red); }
2908
+ .headless-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 4px 0 10px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); font-size: 13px; color: var(--text2); }
2909
+ .headless-bar .grow { flex: 1 1 200px; display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
2910
+ .headless-bar.running { border-color: var(--green); background: var(--tint-green); }
2911
+ .headless-bar.paused { border-color: var(--yellow); background: var(--tint-yellow); }
2912
+ .headless-bar .btn { min-height: 44px; }
2913
+ /* 0.9.9 Permissions: the New session choice, the session page's switch and its pill beside Live */
2914
+ .start-mode { grid-template-columns: repeat(3, minmax(0, 1fr)); }
2915
+ .start-mode .start-opt-sub { white-space: normal; }
2916
+ .headless-mode { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: -4px 0 10px; }
2917
+ .headless-mode .hm-l { font-size: 12.5px; font-weight: 600; color: var(--muted); }
2918
+ .hm-seg { display: inline-flex; border: 1px solid var(--line); border-radius: 10px; overflow: visible; background: var(--card); }
2919
+ .hm-opt { position: relative; min-height: 44px; min-width: 44px; padding: 0 14px; border: 0; border-left: 1px solid var(--line); background: transparent; color: var(--text2); font: inherit; font-size: 13.5px; cursor: pointer; }
2920
+ .hm-opt:first-child { border-left: 0; border-radius: 10px 0 0 10px; }
2921
+ .hm-opt:last-child { border-radius: 0 10px 10px 0; }
2922
+ .hm-opt[aria-checked="true"] { background: var(--tint-accent); color: var(--text); font-weight: 600; box-shadow: inset 0 0 0 1px var(--accent); }
2923
+ .hm-opt:disabled { opacity: .55; cursor: default; }
2924
+ .hm-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; z-index: 1; }
2925
+ .hm-opt[data-tip]::after, .mode-pill[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); left: 0; width: max-content; max-width: min(280px, 80vw); 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.35; font-weight: 500; text-align: left; white-space: normal; pointer-events: none; z-index: 20; }
2926
+ @media (hover: hover) { .hm-opt[data-tip]:hover::after, .mode-pill[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
2927
+ .hm-opt[data-tip]:focus-visible::after, .mode-pill[data-tip]:focus-visible::after { display: block; }
2928
+ .hm-hint { flex-basis: 100%; margin: 0; font-size: 12px; color: var(--muted); }
2929
+ .mode-pill { position: relative; display: inline-block; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--bg2); color: var(--text2); font-size: 11.5px; font-weight: 600; line-height: 1.5; white-space: nowrap; }
2930
+ .mode-pill.m-auto { border-color: var(--accent); background: var(--tint-accent); color: var(--text); }
2931
+ .mode-pill.m-acceptEdits { border-color: var(--yellow); background: var(--tint-yellow); color: var(--text); }
2932
+ @media (max-width: 640px) {
2933
+ .hm-seg { display: flex; width: 100%; }
2934
+ .hm-opt { flex: 1 1 0; padding: 0 6px; }
2935
+ }
2936
+ .headless-err { margin: -4px 0 10px; padding: 8px 12px; border-radius: 8px; background: var(--tint-red); color: var(--text2); font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 9em; overflow: auto; }
2937
+ @media (max-width: 640px) {
2938
+ .start-opts { grid-template-columns: 1fr; }
2939
+ .start-where { grid-template-columns: 1fr 1fr; }
2940
+ .start-opt { min-height: 48px; }
2941
+ .start-ask-banner .btn, .start-ask .btn { min-height: 44px; }
2942
+ }
2943
+
2944
+ /* 0.9.9 New session: a + in the top bar on a phone (next to search); on a
2945
+ desktop the button in the board's header row, like the buttons beside it */
2946
+ .topright .newbtn { display: none; }
2947
+ .newbtn .lbl { display: none; }
2948
+ .newbtn[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); right: 0; width: max-content; 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; white-space: nowrap; pointer-events: none; z-index: 20; }
2949
+ @media (hover: hover) { .newbtn[data-tip]:hover::after { display: block; } }
2950
+ @media (max-width: 640px) {
2951
+ .topright .newbtn { display: grid; place-items: center; width: 44px; height: 44px; }
2952
+ .pagehead .actions .start-new { display: none; }
2953
+ }
2954
+ /* A request banner on a phone (start or Restore asked from another device,
2955
+ the restore banner): the words use the whole width, the buttons a row of
2956
+ their own below, 44px tall */
2957
+ @media (max-width: 640px) {
2958
+ .start-ask-banner > .grow, .restore-ask > .grow, .banner.restore:not(.open) > .grow, .pair-ask > .grow { flex: 1 1 100%; }
2959
+ .start-ask-banner > .btn, .restore-ask > .btn, .banner.restore:not(.open) > .btn:not(.x), .pair-ask > .btn { flex: 1 1 0; justify-content: center; min-height: 44px; }
2960
+ .restore-ask > .rs-m { flex: 1 1 100%; }
2961
+ }
2962
+
2963
+ /* 0.9.9 Other folder…: the inline folder browser (full width; no popover) */
2964
+ .start-other .start-opt-main { display: inline-flex; align-items: center; gap: 6px; }
2965
+ .start-other .ic { width: 16px; height: 16px; color: var(--muted); }
2966
+ .start-other { border-style: dashed; }
2967
+ .start-badge { display: inline-block; margin-left: 4px; padding: 0 6px; border-radius: 6px; border: 1px solid var(--line); font-size: 11px; font-weight: 600; line-height: 18px; color: var(--muted); background: var(--bg2); vertical-align: 1px; }
2968
+ .start-badge.git { color: var(--green); border-color: color-mix(in srgb, var(--green) 40%, var(--line)); }
2969
+ .start-badge.new { color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 50%, var(--line)); }
2970
+ .start-first { color: var(--text); }
2971
+ .start-browse { display: flex; flex-direction: column; gap: 10px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--bg2); min-width: 0; }
2972
+ .sb-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-width: 0; }
2973
+ .sb-crumb { min-height: 44px; min-width: 44px; padding: 0 8px; border: 0; border-radius: 8px; background: none; color: var(--accent); font: inherit; font-size: 14px; cursor: pointer; overflow-wrap: anywhere; text-align: left; }
2974
+ .sb-crumb[aria-current] { color: var(--text); font-weight: 600; cursor: default; }
2975
+ .sb-crumb:hover:not([aria-current]) { background: var(--card); }
2976
+ .sb-sep { color: var(--faint); }
2977
+ .sb-here { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
2978
+ .sb-here .btn { min-height: 44px; }
2979
+ .sb-cur { flex: 1 1 160px; min-width: 0; font-size: 13px; color: var(--text2); overflow-wrap: anywhere; }
2980
+ .sb-cur-name { font-family: var(--mono); font-size: 12.5px; }
2981
+ .sb-cuts { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
2982
+ .sb-cuts-l { font-size: 12px; color: var(--muted); }
2983
+ .sb-cut { min-height: 44px; min-width: 44px; padding: 0 12px; border: 1px solid var(--line); border-radius: 22px; background: var(--card); color: var(--text2); font: inherit; font-family: var(--mono); font-size: 12.5px; cursor: pointer; }
2984
+ .sb-cut:hover { border-color: var(--line-strong); }
2985
+ .sb-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 6px; max-height: 360px; overflow: auto; }
2986
+ .sb-dir { width: 100%; min-height: 44px; display: flex; align-items: center; gap: 8px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--card); color: var(--text); font: inherit; font-size: 14px; text-align: left; cursor: pointer; min-width: 0; }
2987
+ .sb-dir:hover { border-color: var(--line-strong); }
2988
+ .sb-dir .ic { width: 16px; height: 16px; flex: none; color: var(--muted); }
2989
+ .sb-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2990
+ .sb-dir .sb-go { color: var(--faint); }
2991
+ .sb-foot { display: flex; justify-content: flex-end; }
2992
+ .sb-foot .btn { min-height: 44px; }
2993
+ .sb-crumb:focus-visible, .sb-cut:focus-visible, .sb-dir:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2994
+ /* Go to a folder: type a path you know */
2995
+ .sb-path { display: flex; flex-direction: column; gap: 4px; margin: 0; }
2996
+ .sb-path-l { font-size: 12px; color: var(--muted); }
2997
+ .sb-path-row { display: flex; gap: 8px; min-width: 0; }
2998
+ .sb-path-in { flex: 1 1 auto; min-width: 0; min-height: 44px; padding: 0 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--card); color: var(--text); font: inherit; font-family: var(--mono); font-size: 16px; }
2999
+ .sb-path-in:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
3000
+ .sb-path-in[aria-invalid="true"] { border-color: var(--red); }
3001
+ .sb-path-row .btn { min-height: 44px; min-width: 44px; justify-content: center; }
3002
+ .sb-path-err { margin: 0; }
3003
+ .start-custom .start-opt-main { white-space: normal; }
3004
+ @media (max-width: 640px) {
3005
+ .start-browse { padding: 10px; margin-left: -4px; margin-right: -4px; }
3006
+ .sb-list { grid-template-columns: 1fr; max-height: none; overflow: visible; }
3007
+ .sb-here .sb-use { flex: 1 1 100%; justify-content: center; }
3008
+ }
3009
+
3010
+ /* 0.9.9 asked at pairing time: may other devices start sessions here? */
3011
+ .pair-ask .grow { min-width: 0; }
3012
+ @media (pointer: coarse) { .pair-ask > .btn { min-height: 44px; } }
3013
+
3014
+ /* 0.9.9 a setting that saves on change says so beside it, then fades */
3015
+ .saved-mark { display: inline-block; margin-left: 10px; font-size: 13px; font-weight: 600; white-space: nowrap; color: var(--green, var(--accent)); transition: opacity .35s ease; }
3016
+ .saved-mark.bad { color: var(--red); font-weight: 400; white-space: normal; }
3017
+ .saved-mark.gone { opacity: 0; }
3018
+ @media (prefers-reduced-motion: reduce) { .saved-mark { transition: none; } }
3019
+
3020
+ /* 0.9.9 settings kept on one Mac only: "🖥️ studio only" beside the label (a styled tip,
3021
+ never title=), and a locked control (a lock, aria-disabled) when you're not at that Mac */
3022
+ .local-pill { position: relative; display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--bg2); color: var(--text2); font-size: 11.5px; font-weight: 500; line-height: 1.5; white-space: nowrap; vertical-align: 1px; cursor: default; }
3023
+ .local-pill::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); left: 0; width: max-content; max-width: min(240px, 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; white-space: normal; pointer-events: none; z-index: 20; }
3024
+ @media (hover: hover) { .local-pill:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
3025
+ .local-pill.show::after { display: block; }
3026
+ @media (prefers-reduced-motion: reduce) { .local-pill::after { animation: none !important; } }
3027
+ .local-pill .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
3028
+ .form label.check.locked { color: var(--muted); cursor: not-allowed; }
3029
+ .form label.check.locked input { cursor: not-allowed; }
3030
+ .form label.check .lock { display: inline-flex; color: var(--muted); }
3031
+ .form label.check .lock .ic { width: 14px; height: 14px; }
3032
+ /* 0.9.9 Settings → Approved folders: a list with Remove, and an add field (16px) */
3033
+ .form label[for="f-start-approve"] { align-self: start; padding-top: 12px; }
3034
+ .approved { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
3035
+ .approved-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
3036
+ .approved-list li { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 4px 4px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg2); }
3037
+ .approved-path { flex: 1; min-width: 0; overflow-wrap: anywhere; color: var(--text); font-size: 13px; }
3038
+ .approved .mono, .start-ask-banner .mono { font-family: var(--mono); }
3039
+ .approved-x { flex: none; min-height: 44px; min-width: 44px; justify-content: center; gap: 4px; }
3040
+ .approved-x .ic { width: 14px; height: 14px; }
3041
+ .approved-none { color: var(--faint); font-size: 13px; }
3042
+ .approved-add { display: flex; gap: 8px; align-items: center; }
3043
+ .form .approved-add input { flex: 1; min-width: 0; height: 44px; font-size: 16px; }
3044
+ .approved-add .btn { min-height: 44px; flex: none; }
3045
+ .approved.locked .approved-add input, .approved.locked .approved-add .btn { cursor: not-allowed; }
3046
+ .approved .lock { display: inline-flex; color: var(--muted); }
3047
+ .approved .lock .ic { width: 14px; height: 14px; }
3048
+ .approved-err { color: var(--red); font-size: 12.5px; overflow-wrap: anywhere; }
3049
+ .start-ask-banner .btn.approve { max-width: 100%; }
3050
+ .start-ask-banner .btn.approve .mono { overflow-wrap: anywhere; white-space: normal; text-align: left; }
3051
+ @media (max-width: 640px) {
3052
+ .start-ask-banner > .btn.approve { order: 1; flex: 1 1 100%; }
3053
+ }
3054
+
3055
+ /* 0.9.9 the ask in Live: a waiting permission prompt (or question) takes the
3056
+ composer's place at the bottom of Live, and stays in reach while you read the
3057
+ thread (sticky inside the Live box, above the phone's tab bar and the home
3058
+ indicator); the thread keeps a short row for each ask where it happened. */
3059
+ .ask-dock-wrap { position: sticky; bottom: max(12px, calc(env(safe-area-inset-bottom) + 8px)); z-index: 6; margin-top: 10px; }
3060
+ .need.ask-dock { gap: 10px; padding: 12px 14px; border-radius: 16px; border-color: color-mix(in srgb, var(--red) 50%, var(--line)); background: var(--card); box-shadow: 0 8px 28px rgba(15, 20, 35, .14), 0 1px 3px rgba(15, 20, 35, .08); }
3061
+ [data-theme="dark"] .need.ask-dock { background: var(--card2); box-shadow: 0 8px 28px rgba(0, 0, 0, .45); }
3062
+ .ask-dock .need-head { font-size: 13px; }
3063
+ .ask-count { font-size: 12px; font-weight: 600; color: var(--text2); background: var(--field); border: 1px solid var(--line); border-radius: 10px; padding: 1px 8px; font-variant-numeric: tabular-nums; }
3064
+ .ask-what { display: grid; gap: 6px; min-width: 0; }
3065
+ .ask-verb { font-weight: 600; font-size: 15px; color: var(--text); }
3066
+ .ask-detail { display: block; font-family: var(--mono); font-size: 13px; line-height: 1.5; background: var(--field); border: 1px solid var(--line); border-radius: 8px; padding: 6px 9px; white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; max-height: 40vh; overflow: auto; }
3067
+ .ask-detail.long { max-height: calc(3 * 1.5em + 12px); overflow: hidden; }
3068
+ .ask-detail.long.open { max-height: 40vh; overflow: auto; }
3069
+ .ask-more, .ask-reply-instead { justify-self: start; min-height: 44px; padding: 0 2px; margin: -6px 0 -8px; border: 0; background: none; color: var(--accent); font: inherit; font-size: 14px; font-weight: 500; cursor: pointer; -webkit-tap-highlight-color: transparent; }
3070
+ .ask-reply-instead { color: var(--muted); }
3071
+ .ask-more:hover, .ask-reply-instead:hover { text-decoration: underline; }
3072
+ .ask-more:focus-visible, .ask-reply-instead:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
3073
+ .ask-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: stretch; }
3074
+ .ask-actions .btn, .ask-dock .deny-form .btn { min-height: 44px; font-size: 14px; padding: 8px 16px; justify-content: center; white-space: nowrap; }
3075
+ .ask-actions .btn.ask-allow { min-width: 120px; font-size: 15px; }
3076
+ .ask-dock .deny-form .deny-note { flex: 1 1 220px; min-width: 0; min-height: 44px; box-sizing: border-box; font-size: 16px; }
3077
+ @media (max-width: 699.98px) {
3078
+ .ask-dock-wrap { bottom: calc(var(--bar-h) + 8px + env(safe-area-inset-bottom)); }
3079
+ body.nav-hidden .ask-dock-wrap { bottom: max(10px, calc(env(safe-area-inset-bottom) + 6px)); }
3080
+ .need.ask-dock { padding: 12px; }
3081
+ .ask-actions .btn { flex: 1 1 calc(50% - 4px); font-size: 15px; min-height: 48px; }
3082
+ .ask-actions .btn.ask-allow, .ask-actions .btn.ask-deny { flex-basis: 100%; }
3083
+ .ask-actions .always-wrap { flex: 1 1 calc(50% - 4px); }
3084
+ .ask-actions .always-wrap .btn { flex: 1 1 auto; width: 100%; }
3085
+ .ask-dock .deny-form .btn { flex: 1 1 calc(50% - 4px); min-height: 48px; }
3086
+ .ask-dock .deny-form .deny-note { flex-basis: 100%; }
3087
+ }
3088
+ /* after "Reply instead": one line above the composer, Answer brings the card back */
3089
+ .ask-slim { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 4px 4px 4px 12px; border: 1px solid color-mix(in srgb, var(--red) 40%, var(--line)); border-radius: 12px; background: var(--tint-red); font-size: 13px; min-width: 0; }
3090
+ .ask-slim .need-dot { background: var(--red); }
3091
+ .ask-slim-t { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text2); }
3092
+ .ask-slim .btn { flex: none; min-height: 44px; min-width: 44px; justify-content: center; }
3093
+ /* the row in the thread: what it asked, and how it was answered */
3094
+ .lv-ask { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 8px; row-gap: 2px; margin: 0 0 8px; padding: 6px 10px; border-left: 3px solid var(--line-strong); border-radius: 0 8px 8px 0; background: color-mix(in srgb, var(--muted) 7%, transparent); font-size: 12.5px; color: var(--text2); min-width: 0; }
3095
+ .lv-ask .lva-what { flex: 1 1 200px; min-width: 0; overflow-wrap: anywhere; }
3096
+ .lv-ask .lva-verb { font-weight: 600; }
3097
+ .lv-ask code { font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; }
3098
+ .lv-ask .lva-state { flex: none; max-width: 100%; font-weight: 600; font-size: 12px; overflow-wrap: anywhere; }
3099
+ .lv-ask.t-ok { border-left-color: var(--green); } .lv-ask.t-ok .lva-state { color: var(--green); }
3100
+ .lv-ask.t-auto { border-left-color: var(--accent); } .lv-ask.t-auto .lva-state { color: var(--accent); }
3101
+ .lv-ask.t-no { border-left-color: var(--red); } .lv-ask.t-no .lva-state { color: var(--red); }
3102
+ .lv-ask.t-wait { border-left-color: var(--red); background: var(--tint-red); } .lv-ask.t-wait .lva-state { color: var(--red); }
3103
+ .ask-note { color: var(--muted); font-size: 13px; margin-top: -4px; }
3104
+ .lv-ask .lva-what { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; }
3105
+ /* a turn's answered asks folded into one row: "▸ 7 asks · 6 allowed · 1 always allowed"; tap opens them in place */
3106
+ .lv-askgroup { margin: 0 0 8px; min-width: 0; }
3107
+ .lvag-toggle { display: flex; flex-wrap: wrap; align-items: center; column-gap: 6px; row-gap: 2px; width: 100%; min-height: 44px; margin: 0; padding: 6px 10px; border: 0; border-left: 3px solid var(--line-strong); border-radius: 0 8px 8px 0; background: color-mix(in srgb, var(--muted) 7%, transparent); color: var(--text2); font: inherit; font-size: 12.5px; text-align: left; cursor: pointer; min-width: 0; }
3108
+ .lvag-toggle:hover { background: color-mix(in srgb, var(--muted) 12%, transparent); }
3109
+ .lvag-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
3110
+ .lvag-caret { flex: none; display: inline-block; width: 12px; color: var(--muted); transition: transform .15s ease; }
3111
+ .lv-askgroup.open .lvag-caret { transform: rotate(90deg); }
3112
+ .lvag-what { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
3113
+ .lvag-sep { flex: none; color: var(--muted); }
3114
+ .lvag-n { flex: none; white-space: nowrap; font-weight: 600; color: var(--green); }
3115
+ .lvag-n.lvag-no { color: var(--red); }
3116
+ .lvag-list { list-style: none; margin: 6px 0 0; padding: 0; }
3117
+ .lvag-list .lv-ask:last-child { margin-bottom: 0; }
3118
+ @media (prefers-reduced-motion: reduce) { .lvag-caret { transition: none; } }