Files
Garry Tan ed1e4be2f6 feat: gstack browser sidebar = interactive Claude Code REPL with live tab awareness (v1.14.0.0) (#1216)
* build: vendor xterm@5 for the Terminal sidebar tab

Adds xterm@5 + xterm-addon-fit as devDependencies and a `vendor:xterm`
build step that copies the assets into `extension/lib/` at build time.
The vendored files are .gitignored so the npm version stays the source
of truth. xterm@5 is eval-free, so no MV3 CSP changes needed.

No runtime callers yet — this just stages the assets.

* feat(server): add pty-session-cookie module for the Terminal tab

Mirrors `sse-session-cookie.ts` exactly. Mints short-lived 30-min HttpOnly
cookies for authenticating the Terminal-tab WebSocket upgrade against
the terminal-agent. Same TTL, same opportunistic-pruning shape, same
"scoped tokens never valid as root" invariant. Two registries instead of
one because the cookie names are different (`gstack_sse` vs `gstack_pty`)
and the token spaces must not overlap.

No callers yet — wired up in the next commit.

* feat(server): add terminal-agent.ts (PTY for the Terminal sidebar tab)

Translates phoenix gbrowser's Go PTY (cmd/gbd/terminal.go) into a Bun
non-compiled process. Lives separately from `sidebar-agent.ts` so a
WS-framing or PTY-cleanup bug can't take down the chat path (codex
outside-voice review caught the coupling risk).

Architecture:
- Bun.serve on 127.0.0.1:0 (never tunneled).
- POST /internal/grant accepts cookie tokens from the parent server over
  loopback, authenticated with a per-boot internal token.
- GET /ws upgrades require BOTH (a) Origin: chrome-extension://<id> and
  (b) the gstack_pty cookie minted by /pty-session. Either gate alone is
  insufficient (CSWSH defense + auth defense).
- Lazy spawn: claude PTY is not started until the WS receives its first
  data frame. Idle sidebar opens cost nothing.
- Bun PTY API: `terminal: { rows, cols, data(t, chunk) }` — verified at
  impl time on Bun 1.3.10. proc.terminal.write() for input,
  proc.terminal.resize() for resize, proc.kill() + 3s SIGKILL fallback
  on close.
- process.on('uncaughtException'|'unhandledRejection') handlers so a
  framing bug logs but doesn't kill the listener loop.

Test-only `BROWSE_TERMINAL_BINARY` env override lets the integration
tests spawn /bin/bash instead of requiring claude on every CI runner.

Not yet spawned by anything — wired in the next commit.

* feat(server): wire /pty-session route + spawn terminal-agent

Server-side glue connecting the Terminal sidebar tab to the new
terminal-agent process.

server.ts:
- New POST /pty-session route. Validates AUTH_TOKEN, mints a gstack_pty
  HttpOnly cookie via pty-session-cookie.ts, posts the cookie value to
  the agent's loopback /internal/grant. Returns the terminalPort + Set-Cookie
  to the extension.
- /health response gains `terminalPort` (just the port number — never a
  shell token). Tokens flow via the cookie path, never /health, because
  /health already surfaces AUTH_TOKEN to localhost callers in headed mode
  (that's a separate v1.1+ TODO).
- /pty-session and /terminal/* are deliberately NOT added to TUNNEL_PATHS,
  so the dual-listener tunnel surface 404s by default-deny.
- Shutdown path now also pkills terminal-agent and unlinks its state files
  (terminal-port + terminal-internal-token) so a reconnect doesn't try to
  hit a dead port.

cli.ts:
- After spawning sidebar-agent.ts, also spawn terminal-agent.ts. Same
  pattern: pkill old instances, Bun.spawn(['bun', 'run', script]) with
  BROWSE_STATE_FILE + BROWSE_SERVER_PORT env. Non-fatal if the spawn
  fails — chat still works without the terminal agent.

* feat(extension): Terminal as default sidebar tab

Adds a primary tab bar (Terminal | Chat) above the existing tab-content
panes. Terminal is the default-active tab; clicking Chat returns to the
existing claude -p one-shot flow which is preserved verbatim.

manifest.json: adds ws://127.0.0.1:*/ to host_permissions so MV3 doesn't
block the WebSocket upgrade.

sidepanel.html: new primary-tabs nav, new #tab-terminal pane with a
"Press any key to start Claude Code" bootstrap card, claude-not-found
install card, xterm mount point, and "session ended" restart UI. Loads
xterm.js + xterm-addon-fit + sidepanel-terminal.js. tab-chat is no
longer the .active default.

sidepanel.js: new activePrimaryPaneId() helper that reads which primary
tab is selected. Debug-close paths now route back to whichever primary
pane is active (was hardcoded to tab-chat). Primary-tab click handler
toggles .active classes and aria-selected. window.gstackServerPort and
window.gstackAuthToken exposed so sidepanel-terminal.js can build the
/pty-session POST and the WS URL.

sidepanel-terminal.js (new): xterm.js lifecycle. Lazy-spawn — first
keystroke fires POST /pty-session, then opens
ws://127.0.0.1:<terminalPort>/ws. Origin + cookie are set automatically
by the browser. Resize observer sends {type:"resize"} text frames.
ResizeObserver, tab-switch hooks, restart button, install-card retry.
On WS close shows "Session ended, click to restart" — no auto-reconnect
(codex outside-voice flagged that as session-burning).

sidepanel.css: primary-tabs bar + Terminal pane styling (full-height
xterm container, install card, ended state).

* test: terminal-agent + cookie module + sidebar default-tab regression

Three new test files:

terminal-agent.test.ts (16 tests): pty-session-cookie mint/validate/
revoke, Set-Cookie shape (HttpOnly + SameSite=Strict + Path=/, NO Secure
since 127.0.0.1 over HTTP), source-level guards that /pty-session and
/terminal/* are NOT in TUNNEL_PATHS, /health does NOT surface ptyToken
or gstack_pty, terminal-agent binds 127.0.0.1, /ws upgrade enforces
chrome-extension:// Origin AND gstack_pty cookie, lazy-spawn invariant
(spawnClaude is called from message handler, not upgrade), uncaughtException/
unhandledRejection handlers exist, SIGINT-then-SIGKILL cleanup.

terminal-agent-integration.test.ts (7 tests): spawns the agent as a real
subprocess in a tmp state dir. Verifies /internal/grant accepts/rejects
the loopback token, /ws gates (no Origin → 403, bad Origin → 403, no
cookie → 401), real WebSocket round-trip with /bin/bash via the
BROWSE_TERMINAL_BINARY override (write 'echo hello-pty-world\n', read it
back), and resize message acceptance.

sidebar-tabs.test.ts (13 tests): structural regression suite locking the
load-bearing invariants of the default-tab change — Terminal is .active,
Chat is not, xterm assets are loaded, debug-close path no longer hardcodes
tab-chat (uses activePrimaryPaneId), primary-tab click handler exists,
chat surface is not accidentally deleted, terminal JS does NOT auto-
reconnect on close, manifest declares ws:// + http:// localhost host
permissions, no unsafe-eval.

Plan called for Playwright + extension regression; the codebase doesn't
ship Playwright extension launcher infra, so we follow the existing
extension-test pattern (source-level structural assertions). Same
load-bearing intent — locks the invariants before they regress.

* docs: Terminal flow + threat model + v1.1 follow-ups

SIDEBAR_MESSAGE_FLOW.md: new "Terminal flow" section. Documents the WS
upgrade path (/pty-session cookie mint → /ws Origin + cookie gate →
lazy claude spawn), the dual-token model (AUTH_TOKEN for /pty-session,
gstack_pty cookie for /ws, INTERNAL_TOKEN for server↔agent loopback),
and the threat-model boundary — the Terminal tab bypasses the entire
prompt-injection security stack on purpose; user keystrokes are the
trust source. That trust assumption is load-bearing on three transport
guarantees: local-only listener, Origin gate, cookie auth. Drop any
one of those three and the tab becomes unsafe.

CLAUDE.md: extends the "Sidebar architecture" note to include
terminal-agent.ts in the read-this-first list. Adds a "Terminal tab is
its own process" note so a future contributor doesn't bolt PTY logic
onto sidebar-agent.ts.

TODOS.md: three new follow-ups under a new "Sidebar Terminal" section:
  - v1.1: PTY session survives sidebar reload (Issue 1C deferred).
  - v1.1+: audit /health AUTH_TOKEN distribution (codex finding #2 —
    a pre-existing soft leak that cc-pty-import sidesteps but doesn't
    fix).
  - v1.1+: apply terminal-agent's process.on exception handlers to
    sidebar-agent.ts (codex finding #4 — chat path has no fatal
    handlers).

* feat(extension): Terminal-only sidebar — auth fix, UX polish, chat rip

The chat queue path is gone. The Chrome side panel is now just an
interactive claude PTY in xterm.js. Activity / Refs / Inspector still
exist behind the `debug` toggle in the footer.

Three threads of change, all from dogfood iteration on top of
cc-pty-import:

1. fix(server): cross-port WS auth via Sec-WebSocket-Protocol
   - Browsers can't set Authorization on a WebSocket upgrade. We had
     been minting an HttpOnly gstack_pty cookie via /pty-session, but
     SameSite=Strict cookies don't survive the cross-port jump from
     server.ts:34567 to the agent's random port from a chrome-extension
     origin. The WS opened then immediately closed → "Session ended."
   - /pty-session now also returns ptySessionToken in the JSON body.
   - Extension calls `new WebSocket(url, [`gstack-pty.<token>`])`.
     Browser sends Sec-WebSocket-Protocol on the upgrade.
   - Agent reads the protocol header, validates against validTokens,
     and MUST echo the protocol back (Chromium closes the connection
     immediately if a server doesn't pick one of the offered protocols).
   - Cookie path is kept as a fallback for non-browser callers (curl,
     integration tests).
   - New integration test exercises the full protocol-auth round-trip
     via raw fetch+Upgrade so a future regression of this exact class
     fails in CI.

2. fix(extension): UX polish on the Terminal pane
   - Eager auto-connect when the sidebar opens — no "Press any key to
     start" friction every reload.
   - Always-visible ↻ Restart button in the terminal toolbar (not
     gated on the ENDED state) so the user can force a fresh claude
     mid-session.
   - MutationObserver on #tab-terminal's class attribute drives a
     fitAddon.fit() + term.refresh() when the pane becomes visible
     again — xterm doesn't auto-redraw after display:none → display:flex.

3. feat(extension): rip the chat tab + sidebar-agent.ts
   - Sidebar is Terminal-only. No more Terminal | Chat primary nav.
   - sidebar-agent.ts deleted. /sidebar-command, /sidebar-chat,
     /sidebar-agent/event, /sidebar-tabs* and friends all deleted.
   - The pickSidebarModel router (sonnet vs opus) is gone — the live
     PTY uses whatever model the user's `claude` CLI is configured with.
   - Quick-actions (🧹 Cleanup / 📸 Screenshot / 🍪 Cookies) survive
     in the Terminal toolbar. Cleanup now injects its prompt into the
     live PTY via window.gstackInjectToTerminal — no more
     /sidebar-command POST. The Inspector "Send to Code" action uses
     the same injection path.
   - clear-chat button removed from the footer.
   - sidepanel.js shed ~900 lines of chat polling, optimistic UI,
     stop-agent, etc.

Net diff: -3.4k lines across 16 files. CLAUDE.md, TODOS.md, and
docs/designs/SIDEBAR_MESSAGE_FLOW.md rewritten to match. The sidebar
regression test (browse/test/sidebar-tabs.test.ts) is rewritten as 27
structural assertions locking the new layout — Terminal sole pane,
no chat input, quick-actions in toolbar, eager-connect, MutationObserver
repaint, restart helper.

* feat: live tab awareness for the Terminal pane

claude in the PTY now has continuous tab-aware context. Three pieces:

1. Live state files. background.js listens to chrome.tabs.onActivated /
   onCreated / onRemoved / onUpdated (throttled to URL/title/status==
   complete so loading spinners don't spam) and pushes a snapshot. The
   sidepanel relays it as a custom event; sidepanel-terminal.js sends
   {type:"tabState"} text frames over the live PTY WebSocket.
   terminal-agent.ts writes:
     <stateDir>/tabs.json          all open tabs (id, url, title, active,
                                   pinned, audible, windowId)
     <stateDir>/active-tab.json    current active tab (skips chrome:// and
                                   chrome-extension:// internal pages)
   Atomic write via tmp + rename so claude never reads a half-written
   document. A fresh snapshot is pushed on WS open so the files exist by
   the time claude finishes booting.

2. New $B tab-each <command> [args...] meta-command. Fans out a single
   command across every open tab, returns
   {command, args, total, results: [{tabId, url, title, status, output}]}.
   Skips chrome:// pages; restores the originally active tab in a finally
   block (so a mid-batch error doesn't leave the user looking at a
   different tab); uses bringToFront: false so the OS window doesn't
   jump on every fanout. Scope-checks the inner command BEFORE the loop.

3. --append-system-prompt hint at spawn time. Claude is told about both
   the state files and the $B tab-each command up front, so it doesn't
   have to discover the surface by trial. Passed via the --append-system-
   prompt CLI flag, NOT as a leading PTY write — the hint stays out of
   the visible transcript.

Tests:
- browse/test/tab-each.test.ts (new) — registration + source-level
  invariants (scope check before loop, finally-restore, bringToFront:false,
  chrome:// skip) + behavior tests with a mock BrowserManager that verify
  iteration order, JSON shape, error handling, and active-tab restore.
- browse/test/terminal-agent.test.ts — three new assertions for
  tabState handler shape, atomic-write pattern, and the
  --append-system-prompt wiring at spawn.

Verified live: opened 5 tabs, ran $B tab-each url against the live
server, got per-tab JSON results back, original active tab restored
without OS focus stealing.

* chore: drop sidebar-agent test refs after chat rip

Five test files / describe blocks targeted the deleted chat path:
- browse/test/security-e2e-fullstack.test.ts (full-stack chat-pipeline E2E
  with mock claude — whole file gone)
- browse/test/security-review-fullstack.test.ts (review-flow E2E with real
  classifier — whole file gone)
- browse/test/security-review-sidepanel-e2e.test.ts (Playwright E2E for
  the security event banner that was ripped from sidepanel.html)
- browse/test/security-audit-r2.test.ts (5 describe blocks: agent queue
  permissions, isValidQueueEntry stateFile traversal, loadSession session-ID
  validation, switchChatTab DocumentFragment, pollChat reentrancy guard,
  /sidebar-tabs URL sanitization, sidebar-agent SIGTERM→SIGKILL escalation,
  AGENT_SRC top-level read converted to graceful fallback)
- browse/test/security-adversarial-fixes.test.ts (canary stream-chunk split
  detection on detectCanaryLeak; one tool-output test on sidebar-agent)
- test/skill-validation.test.ts (sidebar agent #584 describe block)

These all assumed sidebar-agent.ts existed and tested chat-queue plumbing,
chat-tab DOM round-trip, chat-polling reentrancy, or per-message classifier
canary detection. With the live PTY there is no chat queue, no chat tab,
no LLM stream to canary-scan, and no per-message subprocess. The Terminal
pane's invariants are covered by the new browse/test/sidebar-tabs.test.ts
(27 structural assertions), browse/test/terminal-agent.test.ts, and
browse/test/terminal-agent-integration.test.ts.

bun test → exit 0, 0 failures.

* chore: bump version and changelog (v1.14.0.0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(extension): xterm fills the full Terminal panel height

The Terminal pane only rendered into the top portion of the panel — most
of the panel below the prompt was an empty black gap. Three layered
issues, all about xterm.js measuring dimensions during a layout state
that wasn't ready yet:

1. order-of-operations in connect(): ensureXterm() ran BEFORE
   setState(LIVE), so term.open() measured els.mount while it was still
   display:none. xterm caches a 0-size viewport synchronously inside
   open() and never auto-recovers when the container goes visible.
   Flipped: setState(LIVE) → ensureXterm.

2. first fit() ran synchronously before the browser had applied the
   .active class transition. Wrapped in requestAnimationFrame so layout
   has settled before fit() reads clientHeight.

3. CSS flex-overflow trap: .terminal-mount has flex:1 inside the
   flex-column #tab-terminal, but .tab-content's `overflow-y: auto` and
   the lack of `min-height: 0` on .terminal-mount meant the item
   couldn't shrink below content size. flex:1 then refused to expand
   into available space and xterm rendered into whatever its initial
   2x2 measurement happened to be.

Fixes:
- extension/sidepanel-terminal.js: reorder + RAF fit
- extension/sidepanel.css: .terminal-mount gets `flex: 1 1 0` +
  `min-height: 0` + `position: relative`. #tab-terminal overrides
  .tab-content's `overflow-y: auto` to `overflow: hidden` (xterm has
  its own viewport scroll; the parent shouldn't compete) and explicitly
  re-declares `display: flex; flex-direction: column` for #tab-terminal.active.

bun test browse/test/sidebar-tabs.test.ts → 27/27 pass.
Manually verified: side panel opens → Terminal fills full panel height,
xterm scrollback works, debug-tab toggle still repaints correctly.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:52:15 -07:00

1750 lines
40 KiB
CSS

/* gstack browse — Side Panel
* Design system: DESIGN.md (Industrial/Utilitarian, amber accent, zinc neutrals)
*/
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
/* Brand — amber accent, rare and meaningful */
--amber-400: #FBBF24;
--amber-500: #F59E0B;
--amber-600: #D97706;
/* Neutrals — cool zinc */
--zinc-50: #FAFAFA;
--zinc-400: #A1A1AA;
--zinc-600: #52525B;
--zinc-800: #27272A;
/* Surfaces */
--bg-base: #0C0C0C;
--bg-surface: #141414;
--bg-hover: #1a1a1a;
--border: #262626;
--border-subtle: #1f1f1f;
/* Text hierarchy */
--text-heading: #FAFAFA;
--text-body: #e0e0e0;
--text-label: #A1A1AA;
--text-meta: #52525B;
--text-disabled: #3f3f46;
/* Semantic */
--success: #22C55E;
--warning: #F59E0B;
--error: #EF4444;
--info: #3B82F6;
/* Typography */
--font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
/* Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
}
/* ─── Security Shield ───────────────────────────────────────────── */
/* 3 states — green=protected, amber=degraded, red=inactive.
Custom SVG outline + "SEC" label in JetBrains Mono to match the
industrial/CLI aesthetic (design review Pass 7 decision). */
.security-shield {
position: absolute;
top: 6px;
right: 8px;
z-index: 10;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: var(--radius-sm, 4px);
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.04em;
background: rgba(255, 255, 255, 0.02);
transition: color 200ms ease-out, background 200ms ease-out;
cursor: default;
}
.security-shield[data-status="protected"] {
color: var(--success, #22C55E);
}
.security-shield[data-status="degraded"] {
color: var(--amber-400, #FBBF24);
}
.security-shield[data-status="inactive"] {
color: var(--error, #EF4444);
}
/* ─── Connection Banner ─────────────────────────────────────────── */
.conn-banner {
padding: 6px 10px;
font-size: 10px;
font-family: var(--font-mono);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.conn-banner.reconnecting {
background: rgba(245, 158, 11, 0.1);
border-bottom: 1px solid rgba(245, 158, 11, 0.2);
color: var(--amber-400);
}
.conn-banner.dead {
background: rgba(239, 68, 68, 0.1);
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
}
.conn-banner.reconnected {
background: rgba(34, 197, 94, 0.1);
border-bottom: 1px solid rgba(34, 197, 94, 0.2);
color: var(--success);
animation: fadeOut 3s ease forwards;
animation-delay: 2s;
}
@keyframes fadeOut {
to { opacity: 0; height: 0; padding: 0; overflow: hidden; }
}
.conn-banner-text {
flex: 1;
}
/* ─── Security Banner ─────────────────────────────────────────────
Variant A approved in /plan-design-review 2026-04-19. Centered
alert-heavy. Fires on security_event — canary leaks + ML BLOCK
verdicts. Trust UX: layer names + confidence scores in mono so
the user can see exactly WHY the session was terminated.
*/
.security-banner {
position: relative;
/* Sit above the absolutely-positioned security-shield (z-index: 10) so
the banner's close button and controls receive clicks. Without this
the shield at top-right overlaps the banner's close X region and
intercepts pointer events. */
z-index: 20;
padding: 20px 16px;
text-align: center;
background: rgba(20, 20, 20, 0.98);
border-bottom: 1px solid rgba(239, 68, 68, 0.3);
animation: securityBannerEnter 250ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes securityBannerEnter {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.security-banner-close {
position: absolute;
top: 6px;
right: 6px;
width: 28px;
height: 28px;
background: transparent;
border: none;
color: var(--zinc-500, #71717A);
font-size: 20px;
line-height: 1;
cursor: pointer;
border-radius: var(--radius-md, 8px);
padding: 0;
}
.security-banner-close:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--zinc-300, #D4D4D8);
}
.security-banner-close:focus-visible {
outline: 2px solid var(--amber-500);
outline-offset: 2px;
}
.security-banner-icon {
color: var(--error);
display: flex;
justify-content: center;
margin-bottom: 8px;
}
.security-banner-title {
font-family: var(--font-display, 'Satoshi', sans-serif);
font-weight: 700;
font-size: 18px;
color: var(--error);
margin-bottom: 2px;
}
.security-banner-subtitle {
font-family: var(--font-body, 'DM Sans', sans-serif);
font-size: 13px;
color: var(--zinc-400, #A1A1AA);
margin-bottom: 12px;
}
.security-banner-expand {
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-md, 8px);
padding: 6px 12px;
color: var(--zinc-300, #D4D4D8);
font-family: var(--font-body, 'DM Sans', sans-serif);
font-size: 12px;
cursor: pointer;
}
.security-banner-expand:hover {
background: rgba(255, 255, 255, 0.04);
}
.security-banner-expand:focus-visible {
outline: 2px solid var(--amber-500);
outline-offset: 2px;
}
.security-banner-chevron {
transition: transform 200ms ease-out;
}
.security-banner-details {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
text-align: left;
}
.security-banner-section-label {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 10px;
letter-spacing: 0.08em;
color: var(--zinc-500, #71717A);
margin-bottom: 6px;
}
.security-banner-layers {
display: flex;
flex-direction: column;
gap: 4px;
}
.security-banner-layer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.02);
border-radius: var(--radius-sm, 4px);
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 12px;
}
.security-banner-layer-name {
color: var(--zinc-300, #D4D4D8);
}
.security-banner-layer-score {
color: var(--amber-400);
font-variant-numeric: tabular-nums;
}
.security-banner-suspect {
margin: 4px 0 0;
padding: 8px 10px;
background: var(--zinc-900, #18181B);
border: 1px solid var(--zinc-700, #3F3F46);
border-radius: var(--radius-sm, 4px);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
color: var(--zinc-300, #D4D4D8);
white-space: pre-wrap;
word-break: break-word;
max-height: 160px;
overflow-y: auto;
}
.security-banner-actions {
display: flex;
gap: 8px;
justify-content: center;
margin-top: 14px;
}
.security-banner-btn {
flex: 1;
padding: 8px 14px;
border-radius: var(--radius-md, 6px);
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.security-banner-btn-block {
background: var(--red-600, #DC2626);
color: white;
border-color: var(--red-700, #B91C1C);
}
.security-banner-btn-block:hover {
background: var(--red-700, #B91C1C);
}
.security-banner-btn-allow {
background: transparent;
color: var(--zinc-200, #E4E4E7);
border-color: var(--zinc-600, #52525B);
}
.security-banner-btn-allow:hover {
background: var(--zinc-800, #27272A);
border-color: var(--zinc-500, #71717A);
}
.security-banner-btn:focus-visible {
outline: 2px solid var(--amber-400);
outline-offset: 2px;
}
.conn-btn {
font-size: 9px;
font-family: var(--font-mono);
padding: 2px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-label);
transition: all 150ms;
}
.conn-btn:hover {
background: var(--bg-hover);
color: var(--text-heading);
}
.conn-copy {
color: var(--text-meta);
font-style: italic;
}
body {
background: var(--bg-base);
color: var(--text-body);
font-family: var(--font-system);
font-size: 12px;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Grain texture overlay */
body::after {
content: '';
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
z-index: 9999;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* ─── Status Dot ──────────────────────────────────────── */
.dot {
width: 8px; height: 8px;
border-radius: var(--radius-full);
background: var(--text-disabled);
flex-shrink: 0;
transition: background 150ms;
}
.dot.connected { background: var(--success); }
.dot.reconnecting {
background: var(--amber-500);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* ─── Chat Messages ───────────────────────────────────── */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-loading {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
height: 100%;
text-align: left;
color: var(--text-meta);
gap: 12px;
font-size: 13px;
padding: 24px;
}
.chat-loading-spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border);
border-top-color: var(--amber-500);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.chat-welcome {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
height: 100%;
text-align: left;
color: var(--text-label);
gap: 8px;
padding: 24px;
}
.chat-welcome-icon {
width: 40px;
height: 40px;
background: var(--amber-500);
color: #000;
font-weight: 800;
font-size: 22px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
.chat-welcome .muted { color: var(--text-meta); font-size: 12px; }
.chat-bubble {
max-width: 90%;
padding: 6px 10px;
border-radius: var(--radius-lg);
font-size: 11px;
line-height: 1.4;
word-break: break-word;
animation: slideIn 150ms ease-out;
}
.chat-bubble.user {
align-self: flex-end;
background: var(--amber-500);
color: #000;
border-bottom-right-radius: var(--radius-sm);
}
.chat-notification {
text-align: left;
font-size: 11px;
color: var(--text-meta);
padding: 4px 12px;
font-family: var(--font-mono);
}
.chat-bubble.assistant {
align-self: flex-start;
background: var(--bg-surface);
color: var(--text-body);
border: 1px solid var(--border);
border-bottom-left-radius: var(--radius-sm);
}
.chat-bubble.assistant pre {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 8px;
margin: 6px 0;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 12px;
white-space: pre-wrap;
}
.chat-bubble .chat-time, .agent-response > .chat-time {
font-size: 9px;
opacity: 0.4;
margin-top: 2px;
display: block;
}
/* ─── Agent Streaming Response ─────────────────────────── */
.agent-response {
align-self: flex-start;
max-width: 95%;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-sm);
padding: 6px 8px;
display: flex;
flex-direction: column;
gap: 3px;
animation: slideIn 150ms ease-out;
}
.agent-tool {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 8px;
background: rgba(245, 158, 11, 0.06);
border-left: 2px solid var(--amber-500);
border-radius: 0 4px 4px 0;
font-size: 12px;
font-family: var(--font-system);
margin: 2px 0;
}
.tool-icon {
flex-shrink: 0;
font-size: 11px;
line-height: 1.5;
}
.tool-description {
color: var(--text-body);
line-height: 1.5;
word-break: break-word;
}
/* Collapsed reasoning disclosure */
.agent-reasoning {
margin: 4px 0;
}
.agent-reasoning summary {
cursor: pointer;
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-meta);
padding: 3px 0;
user-select: none;
list-style: none;
}
.agent-reasoning summary::before {
content: '▶ ';
font-size: 9px;
}
.agent-reasoning[open] summary::before {
content: '▼ ';
}
.agent-reasoning summary:hover {
color: var(--text-label);
}
.agent-reasoning .agent-tool {
margin-left: 4px;
}
/* Legacy classes kept for compat */
.tool-name {
color: var(--amber-500);
font-weight: 600;
flex-shrink: 0;
}
.tool-input {
color: var(--text-disabled);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-text {
color: var(--text-body);
font-size: 12.5px;
line-height: 1.5;
word-break: break-word;
padding: 2px 0;
}
.agent-text pre {
background: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: 3px;
padding: 4px 6px;
margin: 4px 0;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 10px;
white-space: pre-wrap;
}
.agent-error {
color: var(--error);
font-size: 12px;
font-family: var(--font-mono);
}
/* Thinking dots animation */
.agent-thinking {
display: flex;
gap: 4px;
padding: 4px 0;
}
.thinking-dot {
width: 4px;
height: 4px;
background: var(--text-disabled);
border-radius: 50%;
animation: thinkingPulse 1.4s ease-in-out infinite;
}
.thinking-dot:nth-child(2) { animation-delay: 0.2s; }
.thinking-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes thinkingPulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
/* ─── Footer Buttons ──────────────────────────────────── */
.footer-left {
display: flex;
gap: 4px;
}
.footer-btn, .debug-toggle {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-meta);
font-family: var(--font-mono);
font-size: 10px;
padding: 2px 6px;
cursor: pointer;
transition: all 150ms;
}
.footer-btn:hover, .debug-toggle:hover {
color: var(--text-label);
border-color: var(--zinc-600);
}
.debug-toggle.active {
color: var(--amber-400);
border-color: var(--amber-500);
}
.debug-tabs {
border-top: 1px solid var(--border);
}
.close-debug {
width: 36px;
flex: none !important;
font-size: 16px;
color: var(--text-meta) !important;
}
.close-debug:hover { color: var(--text-label) !important; }
/* ─── Tab Bar ─────────────────────────────────────────── */
.tabs {
height: 36px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
display: flex;
flex-shrink: 0;
}
.tab {
flex: 1;
background: none;
border: none;
color: var(--text-label);
font-size: 12px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 150ms;
}
.tab:hover:not(.disabled) { color: var(--zinc-50); }
.tab.active {
color: var(--text-heading);
border-bottom-color: var(--amber-500);
}
.tab.disabled {
color: var(--text-disabled);
cursor: not-allowed;
}
/* ─── Tab Content ─────────────────────────────────────── */
.tab-content {
display: none;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.tab-content.active { display: flex; flex-direction: column; }
/* ─── Terminal Tab ────────────────────────────────────────────── */
/* The Terminal pane manages its own scrolling (xterm has a viewport with
scrollback). The default .tab-content rules above set overflow-y: auto,
which collapses min-height for nested flex children — that's why
.terminal-mount couldn't grow to fill available space. Override here. */
#tab-terminal {
background: #0a0a0a;
padding: 0;
overflow: hidden;
min-height: 0;
}
#tab-terminal.active {
display: flex;
flex-direction: column;
}
.terminal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 4px 8px;
border-bottom: 1px solid #1a1a1a;
background: #0a0a0a;
flex-shrink: 0;
}
.terminal-toolbar-actions {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.terminal-toolbar-btn {
background: transparent;
border: 1px solid #27272a;
color: #a1a1aa;
padding: 3px 10px;
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
border-radius: 3px;
cursor: pointer;
}
.terminal-toolbar-btn:hover {
color: #f59e0b;
border-color: #f59e0b;
}
.terminal-bootstrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: #71717a;
padding: 24px;
}
.terminal-bootstrap-icon {
font-size: 32px;
color: #f59e0b;
margin-bottom: 8px;
}
.terminal-bootstrap p { margin: 4px 0; }
.terminal-install-card {
margin: 24px;
padding: 16px;
border: 1px solid #27272a;
border-radius: 6px;
text-align: center;
}
.terminal-install-card a { color: #f59e0b; }
.install-retry-btn {
margin-top: 12px;
padding: 6px 14px;
background: #f59e0b;
color: #0a0a0a;
border: none;
border-radius: 4px;
font-family: inherit;
font-size: 12px;
cursor: pointer;
}
.install-retry-btn:hover { opacity: 0.9; }
.terminal-mount {
/* min-height: 0 is the standard flex-overflow fix — without it, a flex
item with overflowing content can't shrink below its content size,
so flex:1 refuses to expand into available space and xterm renders
into whatever the content happens to be (i.e. its own initial 2x2
measurement). With min-height:0 the item respects the flex parent's
remaining space and xterm grows to fill it. */
flex: 1 1 0;
min-height: 0;
width: 100%;
background: #0a0a0a;
padding: 8px;
box-sizing: border-box;
/* position: relative so xterm's absolutely-positioned helpers (the
hidden textarea for input) anchor inside us, not on body. */
position: relative;
}
.terminal-mount .xterm,
.terminal-mount .xterm .xterm-viewport,
.terminal-mount .xterm .xterm-screen {
height: 100% !important;
}
.terminal-ended {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #71717a;
padding: 24px;
}
/* ─── Activity Feed ───────────────────────────────────── */
#activity-feed { flex: 1; }
.activity-entry {
padding: 8px 12px;
border-left: 3px solid var(--border);
border-bottom: 1px solid var(--border-subtle);
cursor: pointer;
transition: background 150ms;
animation: slideIn 150ms ease-out;
}
.activity-entry:hover { background: var(--bg-hover); }
@media (prefers-reduced-motion: reduce) {
.activity-entry { animation: none; }
}
@keyframes slideIn {
from { transform: translateY(8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* Left border colors by type */
.activity-entry.nav { border-left-color: var(--info); }
.activity-entry.interaction { border-left-color: var(--success); }
.activity-entry.observe { border-left-color: var(--amber-400); }
.activity-entry.error { border-left-color: var(--error); }
.activity-entry.pending {
border-left-color: var(--amber-500);
animation: slideIn 150ms ease-out, borderPulse 2s ease-in-out infinite;
}
@keyframes borderPulse {
0%, 100% { border-left-color: rgba(245, 158, 11, 0.3); }
50% { border-left-color: rgba(245, 158, 11, 1); }
}
.entry-header {
display: flex;
align-items: baseline;
gap: 8px;
}
.entry-time {
color: var(--text-meta);
font-family: var(--font-mono);
font-size: 11px;
flex-shrink: 0;
}
.entry-command {
color: var(--text-heading);
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
}
.entry-args {
color: var(--text-label);
font-family: var(--font-mono);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.entry-status {
font-size: 11px;
margin-top: 2px;
display: flex;
align-items: center;
gap: 4px;
}
.entry-status .ok { color: var(--success); }
.entry-status .err { color: var(--error); }
.entry-status .duration { color: var(--text-meta); }
/* Expanded state */
.entry-detail {
display: none;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--border);
}
.activity-entry.expanded .entry-detail { display: block; }
.activity-entry.expanded .entry-args { white-space: normal; }
.entry-result {
color: var(--zinc-400);
font-family: var(--font-mono);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
/* ─── Refs Tab ────────────────────────────────────────── */
.ref-row {
height: 32px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--border-subtle);
font-size: 12px;
}
.ref-id {
color: var(--amber-400);
font-family: var(--font-mono);
font-weight: 600;
min-width: 32px;
}
.ref-role {
color: var(--text-label);
min-width: 60px;
}
.ref-name {
color: var(--text-body);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.refs-footer {
padding: 8px 12px;
color: var(--text-meta);
font-size: 11px;
border-top: 1px solid var(--border);
}
/* ─── Session Placeholder ─────────────────────────────── */
.session-placeholder {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
height: 100%;
text-align: left;
color: var(--text-label);
padding: 24px;
gap: 8px;
}
.session-placeholder .muted { color: var(--text-meta); font-size: 12px; }
/* ─── Empty State ─────────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 40px 24px;
text-align: left;
color: var(--text-label);
gap: 4px;
}
.empty-state .muted { color: var(--text-meta); font-size: 12px; }
.empty-state code {
background: var(--bg-surface);
padding: 2px 6px;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 12px;
}
/* ─── Gap Banner ──────────────────────────────────────── */
.gap-banner {
background: rgba(245, 158, 11, 0.08);
border-bottom: 1px solid var(--amber-500);
color: var(--amber-400);
font-size: 11px;
padding: 6px 12px;
animation: bannerSlide 250ms ease-out;
}
@keyframes bannerSlide {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
/* ─── Command Bar ─────────────────────────────────────── */
/* ─── Quick Actions Toolbar ─────────────────────────────── */
.quick-actions {
display: flex;
gap: 6px;
padding: 4px 8px;
background: var(--bg-surface);
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.quick-action-btn {
display: flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 10px;
background: none;
border: 1px solid var(--zinc-600);
border-radius: var(--radius-sm);
color: var(--text-label);
font-family: var(--font-system);
font-size: 11px;
cursor: pointer;
transition: all 150ms;
}
.quick-action-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-body);
border-color: var(--zinc-400);
}
.quick-action-btn:active {
transform: scale(0.96);
}
.quick-action-btn.disabled, .inspector-action-btn.disabled {
pointer-events: none;
opacity: 0.3;
cursor: not-allowed;
}
.quick-action-btn.loading {
pointer-events: none;
opacity: 0.5;
}
.quick-action-btn.loading::after {
content: '';
display: inline-block;
width: 10px;
height: 10px;
border: 2px solid var(--zinc-600);
border-top-color: var(--amber-400);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.command-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: var(--bg-surface);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.command-prompt {
color: var(--amber-500);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
user-select: none;
}
.command-input {
flex: 1;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 6px 8px;
color: var(--text-heading);
font-family: var(--font-system);
font-size: 11px;
outline: none;
transition: border-color 150ms;
}
.command-input:focus { border-color: var(--amber-500); }
.command-input::placeholder { color: var(--text-disabled); font-size: 10px; }
.command-input.sent {
border-color: var(--success);
transition: border-color 150ms;
}
.command-input.error {
border-color: var(--error);
animation: shake 300ms ease;
}
.command-input.error::placeholder {
color: var(--error);
opacity: 0.8;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
.send-btn {
width: 26px;
height: 26px;
background: var(--amber-500);
border: none;
border-radius: var(--radius-sm);
color: #000;
font-size: 14px;
font-weight: 700;
cursor: pointer;
flex-shrink: 0;
transition: all 150ms;
display: flex;
align-items: center;
justify-content: center;
}
.send-btn:hover { background: var(--amber-400); }
.send-btn:active { transform: scale(0.93); }
.send-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.stop-btn {
width: 26px;
height: 26px;
background: var(--error);
border: none;
border-radius: var(--radius-sm);
color: #fff;
font-size: 10px;
font-weight: 700;
cursor: pointer;
flex-shrink: 0;
line-height: 26px;
text-align: center;
}
.stop-btn:hover { background: #dc2626; }
.stop-btn:active { transform: scale(0.93); }
/* ─── Footer ──────────────────────────────────────────── */
footer {
height: 28px;
background: var(--bg-surface);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
font-size: 10px;
color: var(--text-meta);
flex-shrink: 0;
}
#footer-url {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 50%;
}
.footer-right {
display: flex;
align-items: center;
gap: 6px;
}
.footer-port {
color: var(--text-meta);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
transition: color 150ms;
}
.footer-port:hover { color: var(--text-label); }
.port-input {
width: 56px;
padding: 2px 6px;
background: var(--bg-base);
border: 1px solid var(--zinc-600);
border-radius: var(--radius-sm);
color: var(--text-heading);
font-family: var(--font-mono);
font-size: 11px;
outline: none;
transition: border-color 150ms;
}
.port-input:focus { border-color: var(--amber-500); }
/* ─── Experimental Banner ─────────────────────────────── */
.experimental-banner {
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.15);
color: var(--zinc-400);
padding: 6px 12px;
border-radius: 6px;
font-size: 11px;
margin: 6px 12px;
text-align: left;
flex-shrink: 0;
}
/* ─── Browser Tab Bar ─────────────────────────────────── */
.browser-tabs {
display: flex;
gap: 1px;
padding: 4px 8px;
background: var(--bg-base);
border-bottom: 1px solid var(--border);
overflow-x: auto;
flex-shrink: 0;
scrollbar-width: none;
}
.browser-tabs::-webkit-scrollbar { display: none; }
.browser-tab {
padding: 4px 10px;
font-size: 11px;
font-family: var(--font-system);
color: var(--text-meta);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
white-space: nowrap;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
transition: background 100ms, color 100ms;
}
.browser-tab:hover {
background: var(--bg-hover);
color: var(--text-label);
}
.browser-tab.active {
background: var(--bg-surface);
color: var(--text-body);
border-color: var(--border);
}
/* ─── Inspector Tab ──────────────────────────────────── */
.inspector-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.inspector-pick-btn {
display: flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 10px;
background: none;
border: 1px solid var(--amber-500);
border-radius: var(--radius-sm);
color: var(--amber-500);
font-family: var(--font-system);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 150ms;
flex-shrink: 0;
}
.inspector-pick-btn:hover {
background: rgba(245, 158, 11, 0.1);
color: var(--amber-400);
}
.inspector-pick-btn.active {
background: var(--amber-500);
color: #000;
}
.inspector-pick-icon {
font-size: 14px;
line-height: 1;
}
/* ─── Action Buttons (Cleanup, Screenshot) ─────────────────── */
.inspector-action-btn {
display: flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
padding: 0;
background: none;
border: 1px solid var(--zinc-600);
border-radius: var(--radius-sm);
color: var(--text-label);
font-size: 14px;
cursor: pointer;
transition: all 150ms;
flex-shrink: 0;
}
.inspector-action-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-body);
border-color: var(--zinc-400);
}
.inspector-action-btn:active {
transform: scale(0.95);
}
.inspector-action-btn.loading {
pointer-events: none;
opacity: 0.5;
position: relative;
}
.inspector-action-btn.loading::after {
content: '';
position: absolute;
width: 12px;
height: 12px;
border: 2px solid var(--zinc-600);
border-top-color: var(--amber-400);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.inspector-selected {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-body);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.inspector-mode-badge {
font-family: var(--font-mono);
font-size: 10px;
padding: 1px 6px;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.inspector-mode-badge.basic {
background: var(--zinc-800);
color: var(--zinc-400);
}
.inspector-mode-badge.cdp {
background: rgba(34, 197, 94, 0.15);
color: var(--success);
}
/* Inspector content area */
.inspector-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* Empty state */
.inspector-empty {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 40px 24px;
text-align: left;
gap: 6px;
}
.inspector-empty-icon {
font-size: 24px;
color: var(--zinc-600);
margin-bottom: 4px;
}
.inspector-empty p {
color: var(--zinc-400);
font-size: 13px;
margin: 0;
}
.inspector-empty .muted {
color: var(--zinc-600);
font-size: 12px;
}
/* Loading state */
.inspector-loading {
padding: 16px 12px;
}
.inspector-loading-text {
font-size: 12px;
color: var(--amber-500);
margin-bottom: 12px;
animation: pulse 2s ease-in-out infinite;
}
.inspector-skeleton {
display: flex;
flex-direction: column;
gap: 8px;
}
.inspector-skeleton-bar {
height: 12px;
background: var(--zinc-800);
border-radius: var(--radius-sm);
animation: shimmer 1.5s ease-in-out infinite;
}
.inspector-skeleton-bar:nth-child(1) { width: 80%; }
.inspector-skeleton-bar:nth-child(2) { width: 60%; }
.inspector-skeleton-bar:nth-child(3) { width: 70%; }
@keyframes shimmer {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.7; }
}
/* Error state */
.inspector-error {
padding: 16px 12px;
color: var(--error);
font-size: 12px;
font-family: var(--font-mono);
}
/* Inspector sections */
.inspector-section {
border-bottom: 1px solid var(--border-subtle);
}
.inspector-section-header {
font-family: var(--font-system);
font-size: 13px;
font-weight: 600;
color: var(--zinc-400);
padding: 8px 12px 4px;
}
.inspector-section-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
font-family: var(--font-system);
font-size: 13px;
font-weight: 600;
color: var(--zinc-400);
cursor: pointer;
text-align: left;
transition: color 150ms;
}
.inspector-section-toggle:hover {
color: var(--text-body);
}
.inspector-toggle-arrow {
font-size: 10px;
color: var(--zinc-400);
flex-shrink: 0;
width: 12px;
}
.inspector-section-body {
padding: 4px 12px 8px;
}
.inspector-section-body.collapsed {
display: none;
}
.inspector-rule-count {
font-size: 11px;
font-weight: 400;
color: var(--zinc-600);
margin-left: 4px;
}
.inspector-no-data {
color: var(--zinc-600);
font-size: 11px;
font-style: italic;
padding: 4px 0;
}
/* ─── Box Model ──────────────────────────────────────── */
.inspector-boxmodel {
padding: 8px 12px 12px;
}
.boxmodel-margin,
.boxmodel-border,
.boxmodel-padding,
.boxmodel-content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed;
text-align: center;
}
.boxmodel-margin {
background: rgba(245, 158, 11, 0.08);
border-color: rgba(245, 158, 11, 0.3);
padding: 14px 20px;
border-radius: var(--radius-sm);
}
.boxmodel-border {
background: rgba(161, 161, 170, 0.08);
border-color: rgba(161, 161, 170, 0.3);
padding: 14px 20px;
width: 100%;
}
.boxmodel-padding {
background: rgba(34, 197, 94, 0.08);
border-color: rgba(34, 197, 94, 0.3);
padding: 14px 20px;
width: 100%;
}
.boxmodel-content {
background: rgba(59, 130, 246, 0.08);
border-color: rgba(59, 130, 246, 0.3);
padding: 8px 12px;
width: 100%;
min-height: 28px;
}
.boxmodel-content span {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-body);
}
.boxmodel-label {
position: absolute;
top: 1px;
left: 4px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--zinc-400);
pointer-events: none;
}
.boxmodel-value {
position: absolute;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-body);
}
.boxmodel-value.boxmodel-top { top: 1px; left: 50%; transform: translateX(-50%); }
.boxmodel-value.boxmodel-right { right: 4px; top: 50%; transform: translateY(-50%); }
.boxmodel-value.boxmodel-bottom { bottom: 1px; left: 50%; transform: translateX(-50%); }
.boxmodel-value.boxmodel-left { left: 4px; top: 50%; transform: translateY(-50%); }
/* ─── Matched Rules ──────────────────────────────────── */
.inspector-rule {
padding: 6px 0;
border-bottom: 1px solid var(--border-subtle);
}
.inspector-rule:last-child {
border-bottom: none;
}
.inspector-rule-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 2px;
}
.inspector-selector {
font-family: var(--font-mono);
font-size: 12px;
color: var(--amber-400);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 35ch;
}
.inspector-specificity {
font-family: var(--font-mono);
font-size: 10px;
background: var(--zinc-600);
color: var(--zinc-400);
padding: 0 4px;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.inspector-rule-props {
padding-left: 12px;
}
.inspector-prop {
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
}
.inspector-prop.overridden {
text-decoration: line-through;
opacity: 0.5;
}
.inspector-prop-name {
color: var(--zinc-400);
}
.inspector-prop-value {
color: var(--text-body);
}
.inspector-important {
color: var(--error);
font-size: 10px;
}
.inspector-rule-source {
font-family: var(--font-mono);
font-size: 11px;
color: var(--zinc-600);
margin-top: 2px;
}
/* UA rules */
.inspector-ua-rules {
margin-top: 4px;
}
.inspector-ua-toggle {
display: flex;
align-items: center;
gap: 4px;
background: none;
border: none;
font-family: var(--font-mono);
font-size: 11px;
color: var(--zinc-600);
cursor: pointer;
padding: 4px 0;
transition: color 150ms;
}
.inspector-ua-toggle:hover {
color: var(--zinc-400);
}
.inspector-ua-body.collapsed {
display: none;
}
/* ─── Computed Styles ────────────────────────────────── */
.inspector-computed-row {
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
padding: 0 0 0 4px;
}
.inspector-computed-row .inspector-prop-name {
color: var(--zinc-400);
}
.inspector-computed-row .inspector-prop-value {
color: var(--text-body);
}
/* ─── Quick Edit ─────────────────────────────────────── */
.inspector-quickedit-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.inspector-quickedit-row {
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
display: flex;
align-items: center;
gap: 4px;
}
.inspector-quickedit-row .inspector-prop-name {
color: var(--zinc-400);
flex-shrink: 0;
}
.inspector-quickedit-value {
color: var(--text-body);
cursor: pointer;
padding: 1px 4px;
border-radius: 2px;
transition: background 150ms;
min-width: 40px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inspector-quickedit-value:hover {
background: var(--bg-hover);
}
.inspector-quickedit-input {
font-family: var(--font-mono);
font-size: 12px;
background: var(--bg-base);
border: 1px solid var(--amber-500);
border-radius: 2px;
color: var(--text-heading);
padding: 1px 4px;
outline: none;
width: 100%;
}
/* ─── Send to Agent ──────────────────────────────────── */
.inspector-send {
padding: 8px 12px;
background: var(--bg-surface);
border-top: 1px solid var(--border);
flex-shrink: 0;
position: sticky;
bottom: 0;
}
.inspector-send-btn {
width: 100%;
height: 32px;
background: var(--amber-500);
border: none;
border-radius: var(--radius-md);
color: #000;
font-family: var(--font-system);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 150ms;
}
.inspector-send-btn:hover {
background: var(--amber-400);
}
.inspector-send-btn:active {
transform: scale(0.98);
}
/* ─── Accessibility ───────────────────────────────────── */
:focus-visible {
outline: 2px solid var(--amber-500);
outline-offset: 1px;
}