@phenx-inc/ctlsurf 0.13.6 → 0.14.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/out/headless/index.mjs +13663 -385
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +11665 -286
- package/out/main/node-CUnQxFha.js +868 -0
- package/out/preload/index.js +17 -0
- package/out/renderer/assets/{cssMode-CsSLnKZ-.js → cssMode-bQYoCKra.js} +3 -3
- package/out/renderer/assets/{freemarker2-C1XYRbOq.js → freemarker2-BS1ERQl_.js} +1 -1
- package/out/renderer/assets/{handlebars-BSdbbnMW.js → handlebars-B-ZEHRLr.js} +1 -1
- package/out/renderer/assets/{html-96igtyOz.js → html-lk9e1Lkl.js} +1 -1
- package/out/renderer/assets/{htmlMode-BRnCcIzp.js → htmlMode-Ds1eMYgM.js} +3 -3
- package/out/renderer/assets/{index-Cc-jDHm3.js → index-BJNR4hRK.js} +380 -23
- package/out/renderer/assets/{index-BEekQe3-.css → index-BpCORklm.css} +217 -0
- package/out/renderer/assets/{javascript-7MdRNHk9.js → javascript-D6OeziKX.js} +2 -2
- package/out/renderer/assets/{jsonMode-oGam2go_.js → jsonMode-t5SESE75.js} +3 -3
- package/out/renderer/assets/{liquid-DoKC7ba2.js → liquid-B5Wjh0p_.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-C2h5qGCv.js → lspLanguageFeatures-BEgiDCIW.js} +1 -1
- package/out/renderer/assets/{mdx-0w8n1HGo.js → mdx-ByNAXh8t.js} +1 -1
- package/out/renderer/assets/{python-BfY4HssB.js → python-CIFO0XQW.js} +1 -1
- package/out/renderer/assets/{razor-BoDk9Ula.js → razor-LXHhGcNO.js} +1 -1
- package/out/renderer/assets/{tsMode-CX0oVKRt.js → tsMode-WiO8CDaI.js} +1 -1
- package/out/renderer/assets/{typescript-BqIU7abP.js → typescript-ZBQlq256.js} +1 -1
- package/out/renderer/assets/{xml-BlSDnxFw.js → xml-DBaLJlQU.js} +1 -1
- package/out/renderer/assets/{yaml-bVMmGUtU.js → yaml-CId9skah.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +2 -1
- package/src/main/index.ts +36 -2
- package/src/main/observer/anthropic.ts +48 -0
- package/src/main/observer/budget.ts +79 -0
- package/src/main/observer/ledger.ts +61 -0
- package/src/main/observer/observer.ts +566 -0
- package/src/main/observer/spans.ts +170 -0
- package/src/main/observer/store.ts +200 -0
- package/src/main/observer/tier0.ts +234 -0
- package/src/main/observer/tier1.ts +306 -0
- package/src/main/observer/tier2.ts +125 -0
- package/src/main/observer/types.ts +224 -0
- package/src/main/orchestrator.ts +74 -0
- package/src/main/transcripts.ts +18 -0
- package/src/preload/index.ts +36 -0
- package/src/renderer/App.tsx +22 -0
- package/src/renderer/components/ObserverPanel.tsx +289 -0
- package/src/renderer/components/SettingsDialog.tsx +104 -0
- package/src/renderer/components/TerminalPanel.tsx +29 -0
- package/src/renderer/styles.css +217 -0
|
@@ -35,21 +35,60 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|
|
35
35
|
const [saved, setSaved] = useState(false)
|
|
36
36
|
const [version, setVersion] = useState('')
|
|
37
37
|
|
|
38
|
+
// Observer (global) — the key is write-only over IPC; we only learn
|
|
39
|
+
// whether one is configured (hasApiKey) and from where (keySource).
|
|
40
|
+
const [obsEnabled, setObsEnabled] = useState(false)
|
|
41
|
+
const [obsApiKey, setObsApiKey] = useState('')
|
|
42
|
+
const [obsHasKey, setObsHasKey] = useState(false)
|
|
43
|
+
const [obsKeySource, setObsKeySource] = useState<'settings' | 'env' | null>(null)
|
|
44
|
+
const [obsCapUsd, setObsCapUsd] = useState(1)
|
|
45
|
+
const [obsSaved, setObsSaved] = useState(false)
|
|
46
|
+
|
|
38
47
|
const loadProfiles = async () => {
|
|
39
48
|
const data = await window.worker.listProfiles() as ProfileListResponse
|
|
40
49
|
setProfileList(data)
|
|
41
50
|
setActiveProfileId(data.activeProfile)
|
|
42
51
|
}
|
|
43
52
|
|
|
53
|
+
const loadObserver = async () => {
|
|
54
|
+
try {
|
|
55
|
+
const o = await window.worker.getObserverSettings()
|
|
56
|
+
setObsEnabled(o.enabled)
|
|
57
|
+
setObsHasKey(o.hasApiKey)
|
|
58
|
+
setObsKeySource(o.keySource)
|
|
59
|
+
setObsCapUsd(o.dailyCapUsd)
|
|
60
|
+
setObsApiKey('')
|
|
61
|
+
} catch { /* older main process without observer IPC */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
useEffect(() => {
|
|
45
65
|
if (open) {
|
|
46
66
|
loadProfiles()
|
|
67
|
+
loadObserver()
|
|
47
68
|
setEditingId(null)
|
|
48
69
|
setSaved(false)
|
|
70
|
+
setObsSaved(false)
|
|
49
71
|
window.worker.getVersion().then(setVersion).catch(() => { /* ignore */ })
|
|
50
72
|
}
|
|
51
73
|
}, [open])
|
|
52
74
|
|
|
75
|
+
const handleObserverSave = async () => {
|
|
76
|
+
const data: { enabled: boolean; anthropicApiKey?: string; dailyCapUsd?: number } = {
|
|
77
|
+
enabled: obsEnabled,
|
|
78
|
+
dailyCapUsd: Math.max(0.05, obsCapUsd) || 1,
|
|
79
|
+
}
|
|
80
|
+
// Only send the key if the user typed a new one (blank = keep stored).
|
|
81
|
+
if (obsApiKey.trim()) {
|
|
82
|
+
data.anthropicApiKey = obsApiKey.trim()
|
|
83
|
+
}
|
|
84
|
+
const o = await window.worker.setObserverSettings(data)
|
|
85
|
+
setObsHasKey(o.hasApiKey)
|
|
86
|
+
setObsKeySource(o.keySource)
|
|
87
|
+
setObsApiKey('')
|
|
88
|
+
setObsSaved(true)
|
|
89
|
+
setTimeout(() => setObsSaved(false), 900)
|
|
90
|
+
}
|
|
91
|
+
|
|
53
92
|
const startEditing = (profile: ProfileSummary) => {
|
|
54
93
|
setEditingId(profile.id)
|
|
55
94
|
setName(profile.name)
|
|
@@ -238,6 +277,71 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|
|
238
277
|
</div>
|
|
239
278
|
)}
|
|
240
279
|
|
|
280
|
+
{!editingId && (
|
|
281
|
+
// ─── Observer (global) ─────────────────
|
|
282
|
+
<div className="settings-section">
|
|
283
|
+
<h4>Observer</h4>
|
|
284
|
+
<p className="settings-hint">
|
|
285
|
+
Reviews agent sessions for assumptions, unverified claims, and
|
|
286
|
+
dependency/license risk, tracks time per work item, and posts a
|
|
287
|
+
session digest to the project's Agent Datastore page.
|
|
288
|
+
</p>
|
|
289
|
+
|
|
290
|
+
<label className="settings-checkbox">
|
|
291
|
+
<input
|
|
292
|
+
type="checkbox"
|
|
293
|
+
checked={obsEnabled}
|
|
294
|
+
onChange={e => setObsEnabled(e.target.checked)}
|
|
295
|
+
/>
|
|
296
|
+
<span>Enable Observer</span>
|
|
297
|
+
<span className="settings-hint">
|
|
298
|
+
Applies to newly started agent tabs. Advisory only — never
|
|
299
|
+
blocks or slows the agent.
|
|
300
|
+
</span>
|
|
301
|
+
</label>
|
|
302
|
+
|
|
303
|
+
<label>
|
|
304
|
+
Anthropic API Key
|
|
305
|
+
<input
|
|
306
|
+
type="password"
|
|
307
|
+
value={obsApiKey}
|
|
308
|
+
onChange={e => setObsApiKey(e.target.value)}
|
|
309
|
+
placeholder={
|
|
310
|
+
obsKeySource === 'settings' ? '***configured*** (leave blank to keep)'
|
|
311
|
+
: obsKeySource === 'env' ? 'using ANTHROPIC_API_KEY from environment'
|
|
312
|
+
: 'sk-ant-…'
|
|
313
|
+
}
|
|
314
|
+
/>
|
|
315
|
+
<span className="settings-hint">
|
|
316
|
+
Your own Anthropic key — separate from the ctlsurf profile key.
|
|
317
|
+
Without one, the Observer still records the session ledger,
|
|
318
|
+
Tier-0 tags, and spans, but skips model review and digests.
|
|
319
|
+
</span>
|
|
320
|
+
</label>
|
|
321
|
+
|
|
322
|
+
<label>
|
|
323
|
+
Daily budget cap (USD)
|
|
324
|
+
<input
|
|
325
|
+
type="number"
|
|
326
|
+
min={0.05}
|
|
327
|
+
step={0.25}
|
|
328
|
+
value={obsCapUsd}
|
|
329
|
+
onChange={e => setObsCapUsd(parseFloat(e.target.value) || 1)}
|
|
330
|
+
/>
|
|
331
|
+
<span className="settings-hint">
|
|
332
|
+
Model review pauses for the day once spend reaches the cap;
|
|
333
|
+
Tier-0 tagging and spans continue.
|
|
334
|
+
</span>
|
|
335
|
+
</label>
|
|
336
|
+
|
|
337
|
+
<div className="settings-actions">
|
|
338
|
+
<button className="btn-primary" onClick={handleObserverSave}>
|
|
339
|
+
{obsSaved ? 'Saved!' : 'Save Observer Settings'}
|
|
340
|
+
</button>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
|
|
241
345
|
<div className="settings-section settings-about">
|
|
242
346
|
<h4>About</h4>
|
|
243
347
|
<div className="about-name">ctlsurf{version ? ` · v${version}` : ''}</div>
|
|
@@ -189,6 +189,12 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
|
|
|
189
189
|
// Off by default on macOS; without this a right-click would reselect the
|
|
190
190
|
// word under the cursor and clobber a multi-line table selection.
|
|
191
191
|
rightClickSelectsWord: false,
|
|
192
|
+
// A mouse-tracking TUI (notably tmux with `set -g mouse on`) swallows plain
|
|
193
|
+
// drags, so they yank into a remote tmux buffer instead of selecting here.
|
|
194
|
+
// Option+drag is the only way back to a local selection on macOS, and xterm
|
|
195
|
+
// gates it behind this flag. Elsewhere Shift+drag already does it. Costs us
|
|
196
|
+
// Option+drag column-select on macOS, which nothing here relies on.
|
|
197
|
+
macOptionClickForcesSelection: true,
|
|
192
198
|
fontSize: 14,
|
|
193
199
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', monospace",
|
|
194
200
|
theme: {
|
|
@@ -222,6 +228,29 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
|
|
|
222
228
|
terminal.loadAddon(fitAddon)
|
|
223
229
|
terminal.loadAddon(new WebLinksAddon(handleTerminalLink))
|
|
224
230
|
|
|
231
|
+
// OSC 52 — how a remote program (tmux's mouse-drag yank, vim, an agent TUI)
|
|
232
|
+
// asks the terminal it's attached to for a system-clipboard write. Real
|
|
233
|
+
// terminals honour it; xterm ships no handler, so those yanks used to vanish.
|
|
234
|
+
// Payload is `<targets>;<base64>`; `?` is a read-back request we decline
|
|
235
|
+
// rather than answer, so the remote can never exfiltrate the clipboard.
|
|
236
|
+
terminal.parser.registerOscHandler(52, (data) => {
|
|
237
|
+
const payload = data.slice(data.indexOf(';') + 1)
|
|
238
|
+
if (!payload || payload === '?') return false
|
|
239
|
+
// ~1MB of base64. A runaway program shouldn't be able to wedge the main
|
|
240
|
+
// process with an unbounded clipboard write.
|
|
241
|
+
if (payload.length > 1_400_000) return false
|
|
242
|
+
let text: string
|
|
243
|
+
try {
|
|
244
|
+
const bin = atob(payload)
|
|
245
|
+
text = new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)))
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.warn('[copy] OSC 52 payload was not valid base64', err)
|
|
248
|
+
return false
|
|
249
|
+
}
|
|
250
|
+
copyTextValue(text)
|
|
251
|
+
return true
|
|
252
|
+
})
|
|
253
|
+
|
|
225
254
|
// Selection actions via ⌘/Ctrl+Shift+[C|E|S] → copy / convert-to-table /
|
|
226
255
|
// read-aloud. Handled here (not as DOM listeners) so they work even while a
|
|
227
256
|
// mouse-tracking TUI is running, where right-click and mouse selection are
|
package/src/renderer/styles.css
CHANGED
|
@@ -2382,3 +2382,220 @@ html, body, #root {
|
|
|
2382
2382
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
|
2383
2383
|
pointer-events: none;
|
|
2384
2384
|
}
|
|
2385
|
+
|
|
2386
|
+
/* ─── Observer pane ─────────────────────────────── */
|
|
2387
|
+
|
|
2388
|
+
/* Header on/off switch */
|
|
2389
|
+
.observer-switch { display: inline-flex; align-items: center; cursor: pointer; padding-top: 2px; }
|
|
2390
|
+
.observer-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
|
|
2391
|
+
.observer-switch-track {
|
|
2392
|
+
width: 34px; height: 19px; border-radius: 10px;
|
|
2393
|
+
background: #3b4261; position: relative; transition: background 0.15s ease;
|
|
2394
|
+
display: inline-block; flex-shrink: 0;
|
|
2395
|
+
}
|
|
2396
|
+
.observer-switch-thumb {
|
|
2397
|
+
position: absolute; top: 2px; left: 2px; width: 15px; height: 15px;
|
|
2398
|
+
border-radius: 50%; background: #c0caf5; transition: transform 0.15s ease;
|
|
2399
|
+
}
|
|
2400
|
+
.observer-switch input:checked + .observer-switch-track { background: #9ece6a; }
|
|
2401
|
+
.observer-switch input:checked + .observer-switch-track .observer-switch-thumb {
|
|
2402
|
+
transform: translateX(15px); background: #1a1b26;
|
|
2403
|
+
}
|
|
2404
|
+
.observer-switch input:disabled + .observer-switch-track { opacity: 0.5; }
|
|
2405
|
+
|
|
2406
|
+
.obs-feed { gap: 6px; }
|
|
2407
|
+
|
|
2408
|
+
/* Finding / digest cards */
|
|
2409
|
+
.obs-card {
|
|
2410
|
+
position: relative;
|
|
2411
|
+
display: flex;
|
|
2412
|
+
flex-direction: column;
|
|
2413
|
+
gap: 5px;
|
|
2414
|
+
width: 100%;
|
|
2415
|
+
text-align: left;
|
|
2416
|
+
appearance: none;
|
|
2417
|
+
font: inherit;
|
|
2418
|
+
color: inherit;
|
|
2419
|
+
cursor: pointer;
|
|
2420
|
+
background: #15161f;
|
|
2421
|
+
border: 1px solid #262a3f;
|
|
2422
|
+
border-left: 3px solid #565f89;
|
|
2423
|
+
border-radius: 9px;
|
|
2424
|
+
padding: 9px 26px 9px 12px;
|
|
2425
|
+
transition: border-color 0.12s ease, background 0.12s ease;
|
|
2426
|
+
}
|
|
2427
|
+
.obs-card:hover { background: #181a26; border-color: #323752; border-left-width: 3px; }
|
|
2428
|
+
.obs-card:active { transform: scale(0.995); }
|
|
2429
|
+
|
|
2430
|
+
.obs-card-top {
|
|
2431
|
+
display: flex;
|
|
2432
|
+
align-items: center;
|
|
2433
|
+
gap: 8px;
|
|
2434
|
+
min-width: 0;
|
|
2435
|
+
}
|
|
2436
|
+
.obs-chip {
|
|
2437
|
+
display: inline-flex;
|
|
2438
|
+
align-items: center;
|
|
2439
|
+
gap: 5px;
|
|
2440
|
+
flex-shrink: 0;
|
|
2441
|
+
font-size: 9.5px;
|
|
2442
|
+
font-weight: 600;
|
|
2443
|
+
letter-spacing: 0.06em;
|
|
2444
|
+
text-transform: uppercase;
|
|
2445
|
+
padding: 2px 7px 2px 6px;
|
|
2446
|
+
border: 1px solid;
|
|
2447
|
+
border-radius: 999px;
|
|
2448
|
+
line-height: 1.4;
|
|
2449
|
+
}
|
|
2450
|
+
.obs-chip-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
|
|
2451
|
+
.obs-card-cat {
|
|
2452
|
+
font-size: 10px;
|
|
2453
|
+
color: #565f89;
|
|
2454
|
+
letter-spacing: 0.05em;
|
|
2455
|
+
text-transform: uppercase;
|
|
2456
|
+
white-space: nowrap;
|
|
2457
|
+
overflow: hidden;
|
|
2458
|
+
text-overflow: ellipsis;
|
|
2459
|
+
}
|
|
2460
|
+
.obs-card-time {
|
|
2461
|
+
margin-left: auto;
|
|
2462
|
+
flex-shrink: 0;
|
|
2463
|
+
font-size: 10px;
|
|
2464
|
+
color: #414868;
|
|
2465
|
+
font-variant-numeric: tabular-nums;
|
|
2466
|
+
}
|
|
2467
|
+
.obs-card-title {
|
|
2468
|
+
font-size: 12.5px;
|
|
2469
|
+
font-weight: 600;
|
|
2470
|
+
color: #c0caf5;
|
|
2471
|
+
line-height: 1.4;
|
|
2472
|
+
word-break: break-word;
|
|
2473
|
+
}
|
|
2474
|
+
.obs-card-body {
|
|
2475
|
+
font-size: 11.5px;
|
|
2476
|
+
color: #9aa5ce;
|
|
2477
|
+
line-height: 1.5;
|
|
2478
|
+
word-break: break-word;
|
|
2479
|
+
}
|
|
2480
|
+
.obs-card-body p { margin: 0 0 6px; white-space: pre-wrap; }
|
|
2481
|
+
.obs-card-facts { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
2482
|
+
.obs-fact {
|
|
2483
|
+
font-size: 10px;
|
|
2484
|
+
color: #565f89;
|
|
2485
|
+
background: #1c1e2b;
|
|
2486
|
+
border: 1px solid #262a3f;
|
|
2487
|
+
border-radius: 5px;
|
|
2488
|
+
padding: 1px 6px;
|
|
2489
|
+
font-variant-numeric: tabular-nums;
|
|
2490
|
+
}
|
|
2491
|
+
.obs-card-caret {
|
|
2492
|
+
position: absolute;
|
|
2493
|
+
right: 10px;
|
|
2494
|
+
top: 10px;
|
|
2495
|
+
font-size: 10px;
|
|
2496
|
+
color: #414868;
|
|
2497
|
+
transition: transform 0.12s ease;
|
|
2498
|
+
}
|
|
2499
|
+
.obs-card-caret.open { transform: rotate(90deg); }
|
|
2500
|
+
|
|
2501
|
+
/* One-line heartbeats (reviews ran, attach/detach) and health warnings */
|
|
2502
|
+
.obs-line {
|
|
2503
|
+
display: flex;
|
|
2504
|
+
align-items: baseline;
|
|
2505
|
+
gap: 8px;
|
|
2506
|
+
padding: 3px 12px 3px 14px;
|
|
2507
|
+
font-size: 11px;
|
|
2508
|
+
color: #565f89;
|
|
2509
|
+
line-height: 1.45;
|
|
2510
|
+
}
|
|
2511
|
+
.obs-line-dot {
|
|
2512
|
+
flex-shrink: 0;
|
|
2513
|
+
width: 4px; height: 4px;
|
|
2514
|
+
border-radius: 50%;
|
|
2515
|
+
background: #3b4261;
|
|
2516
|
+
align-self: center;
|
|
2517
|
+
}
|
|
2518
|
+
.obs-line.health { color: #e0af68; }
|
|
2519
|
+
.obs-line.health .obs-line-dot { background: #e0af68; }
|
|
2520
|
+
.obs-line-text { min-width: 0; word-break: break-word; }
|
|
2521
|
+
.obs-line-time {
|
|
2522
|
+
margin-left: auto;
|
|
2523
|
+
flex-shrink: 0;
|
|
2524
|
+
font-size: 10px;
|
|
2525
|
+
color: #414868;
|
|
2526
|
+
font-variant-numeric: tabular-nums;
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
/* Sensitivity lever + processing indicator row */
|
|
2530
|
+
.obs-toolbar {
|
|
2531
|
+
display: flex;
|
|
2532
|
+
align-items: center;
|
|
2533
|
+
justify-content: space-between;
|
|
2534
|
+
gap: 10px;
|
|
2535
|
+
padding: 8px 14px;
|
|
2536
|
+
border-bottom: 1px solid #23263a;
|
|
2537
|
+
}
|
|
2538
|
+
.obs-sensitivity {
|
|
2539
|
+
display: inline-flex;
|
|
2540
|
+
background: #15161f;
|
|
2541
|
+
border: 1px solid #262a3f;
|
|
2542
|
+
border-radius: 7px;
|
|
2543
|
+
padding: 2px;
|
|
2544
|
+
gap: 2px;
|
|
2545
|
+
}
|
|
2546
|
+
.obs-sens-btn {
|
|
2547
|
+
appearance: none;
|
|
2548
|
+
border: none;
|
|
2549
|
+
background: transparent;
|
|
2550
|
+
color: #565f89;
|
|
2551
|
+
font: inherit;
|
|
2552
|
+
font-size: 10.5px;
|
|
2553
|
+
font-weight: 600;
|
|
2554
|
+
padding: 3px 9px;
|
|
2555
|
+
border-radius: 5px;
|
|
2556
|
+
cursor: pointer;
|
|
2557
|
+
transition: background 0.12s ease, color 0.12s ease;
|
|
2558
|
+
white-space: nowrap;
|
|
2559
|
+
}
|
|
2560
|
+
.obs-sens-btn:hover { color: #9aa5ce; }
|
|
2561
|
+
.obs-sens-btn.active { background: #2a2f4a; color: #c0caf5; }
|
|
2562
|
+
|
|
2563
|
+
/* "reviewing" bouncing dots */
|
|
2564
|
+
.obs-reviewing {
|
|
2565
|
+
display: inline-flex;
|
|
2566
|
+
align-items: center;
|
|
2567
|
+
gap: 3px;
|
|
2568
|
+
font-size: 10.5px;
|
|
2569
|
+
color: #7aa2f7;
|
|
2570
|
+
letter-spacing: 0.03em;
|
|
2571
|
+
}
|
|
2572
|
+
.obs-reviewing-dot {
|
|
2573
|
+
width: 4px; height: 4px;
|
|
2574
|
+
border-radius: 50%;
|
|
2575
|
+
background: #7aa2f7;
|
|
2576
|
+
animation: obs-dot-bounce 1.1s ease-in-out infinite;
|
|
2577
|
+
}
|
|
2578
|
+
.obs-reviewing-dot:nth-child(2) { animation-delay: 0.15s; }
|
|
2579
|
+
.obs-reviewing-dot:nth-child(3) { animation-delay: 0.3s; margin-right: 4px; }
|
|
2580
|
+
@keyframes obs-dot-bounce {
|
|
2581
|
+
0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
|
|
2582
|
+
30% { transform: translateY(-3px); opacity: 1; }
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
/* Pulsing eye badge while a model review is in flight */
|
|
2586
|
+
.obs-badge { position: relative; }
|
|
2587
|
+
.obs-badge.reviewing { color: #7aa2f7; }
|
|
2588
|
+
.obs-badge.reviewing::after {
|
|
2589
|
+
content: '';
|
|
2590
|
+
position: absolute;
|
|
2591
|
+
inset: -1px;
|
|
2592
|
+
border-radius: inherit;
|
|
2593
|
+
border: 1px solid #7aa2f7;
|
|
2594
|
+
animation: obs-badge-pulse 1.6s ease-out infinite;
|
|
2595
|
+
pointer-events: none;
|
|
2596
|
+
}
|
|
2597
|
+
@keyframes obs-badge-pulse {
|
|
2598
|
+
0% { opacity: 0.9; transform: scale(1); }
|
|
2599
|
+
70% { opacity: 0; transform: scale(1.35); }
|
|
2600
|
+
100% { opacity: 0; transform: scale(1.35); }
|
|
2601
|
+
}
|