The rows past the fold could not be reached at all: the pane grew to fit its content instead of scrolling, and the page has overflow hidden, so they were simply gone. A grid item defaults to min-height: auto, which means it refuses to shrink below its content's intrinsic size. The span-tree pane is a grid item, so on a trace with enough rows it expanded past its track -- measured at 704px inside a 593px row -- and the inner overflow:auto never had a reason to engage. min-width: 0 was already there; min-height: 0 is the one that mattered, and is now set on both panes and on every child of the grid. Verified by measurement rather than by eye, since the symptom is geometry: before, waterfall.scrollHeight == clientHeight (nothing to scroll) while the container overflowed. After, the pane fits its track, the inner scroller has the full 111px of overflow, and scrolling reaches the last row. Checked the stacked narrow layout too, where the same grid becomes rows. The trace list was never affected -- its scroll container already had min-height: 0 -- and still scrolls 120 rows correctly. Also makes the CDP screenshot helper print the value its injected script returns, which is what turned this from a guess into a measurement.
743 lines
27 KiB
CSS
743 lines
27 KiB
CSS
/* Fathom UI.
|
|
*
|
|
* Two constraints shape everything here. First, the reader is an engineer
|
|
* debugging an agent run, so density beats whitespace: the useful state of this
|
|
* app is a lot of information visible at once. Second, span kind is the primary
|
|
* visual channel — the same hue means the same thing in the legend, the
|
|
* waterfall, the tree, and the inspector, so the eye can learn it once.
|
|
*/
|
|
|
|
:root {
|
|
--kind-llm: #8b7cf6;
|
|
--kind-tool: #e0913a;
|
|
--kind-agent: #3b9ae1;
|
|
--kind-retrieval: #2eb8a0;
|
|
--kind-mcp: #d16ba5;
|
|
--kind-hook: #6e7a8a;
|
|
--kind-other: #7d8899;
|
|
|
|
--ok: #3fa46a;
|
|
--error: #e05252;
|
|
--warn: #d9a13b;
|
|
|
|
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
|
--sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", system-ui, sans-serif;
|
|
|
|
--radius: 6px;
|
|
--topbar-h: 48px;
|
|
}
|
|
|
|
[data-theme="dark"] {
|
|
--bg: #0e1116;
|
|
--bg-raised: #151a21;
|
|
--bg-hover: #1b222b;
|
|
--bg-active: #202a35;
|
|
--bg-input: #0a0d11;
|
|
--border: #232b35;
|
|
--border-strong: #303b48;
|
|
--text: #dce3ec;
|
|
--text-dim: #8b96a5;
|
|
--text-faint:#5d6875;
|
|
--accent: #4d9fef;
|
|
--shadow: 0 8px 24px rgba(0,0,0,.4);
|
|
}
|
|
|
|
[data-theme="light"] {
|
|
--bg: #ffffff;
|
|
--bg-raised: #f7f8fa;
|
|
--bg-hover: #eef1f5;
|
|
--bg-active: #e3e9f0;
|
|
--bg-input: #ffffff;
|
|
--border: #e0e4ea;
|
|
--border-strong: #c7cdd6;
|
|
--text: #1a1f26;
|
|
--text-dim: #5c6673;
|
|
--text-faint:#8b95a1;
|
|
--accent: #1667c7;
|
|
--shadow: 0 8px 24px rgba(20,30,50,.12);
|
|
|
|
/* The kind hues are tuned for a dark field; on white they need darkening to
|
|
hold the same contrast against the background. */
|
|
--kind-llm: #6d4fd8;
|
|
--kind-tool: #b96c12;
|
|
--kind-agent: #1a6fb5;
|
|
--kind-retrieval: #14806c;
|
|
--kind-mcp: #aa3f7c;
|
|
--kind-hook: #55616f;
|
|
--kind-other: #66707d;
|
|
--ok: #1f7a45;
|
|
--error: #c02c2c;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
html, body {
|
|
margin: 0;
|
|
height: 100%;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: var(--sans);
|
|
font-size: 13px;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
|
|
body { display: flex; flex-direction: column; overflow: hidden; }
|
|
|
|
button, input, textarea, select { font: inherit; color: inherit; }
|
|
button { cursor: pointer; }
|
|
|
|
/* ---------- top bar ---------- */
|
|
|
|
.topbar {
|
|
height: var(--topbar-h);
|
|
flex: none;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 20px;
|
|
padding: 0 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
background: var(--bg-raised);
|
|
}
|
|
|
|
.brand {
|
|
display: flex; align-items: center; gap: 8px;
|
|
text-decoration: none; color: var(--text);
|
|
font-weight: 600; letter-spacing: -.01em;
|
|
}
|
|
.brand-mark {
|
|
width: 14px; height: 14px; border-radius: 50%;
|
|
background: radial-gradient(circle at 32% 30%, var(--kind-agent), var(--kind-llm) 70%);
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--kind-agent) 18%, transparent);
|
|
}
|
|
.brand-name { font-size: 14px; }
|
|
|
|
.tabs { display: flex; gap: 2px; }
|
|
.tabs a {
|
|
padding: 5px 11px;
|
|
border-radius: var(--radius);
|
|
color: var(--text-dim);
|
|
text-decoration: none;
|
|
font-weight: 500;
|
|
}
|
|
.tabs a:hover { background: var(--bg-hover); color: var(--text); }
|
|
.tabs a.is-active { background: var(--bg-active); color: var(--text); }
|
|
|
|
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
|
|
|
.range { display: flex; background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
.range-btn {
|
|
background: none; border: none; padding: 5px 10px;
|
|
color: var(--text-dim); font-variant-numeric: tabular-nums;
|
|
}
|
|
.range-btn:hover { background: var(--bg-hover); color: var(--text); }
|
|
.range-btn.is-active { background: var(--accent); color: #fff; }
|
|
|
|
.icon-btn {
|
|
background: var(--bg-input); border: 1px solid var(--border);
|
|
border-radius: var(--radius); width: 28px; height: 28px;
|
|
color: var(--text-dim); line-height: 1;
|
|
}
|
|
.icon-btn:hover { color: var(--text); border-color: var(--border-strong); }
|
|
|
|
/* ---------- views ---------- */
|
|
|
|
.view { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
.view[hidden] { display: none; }
|
|
|
|
.table-wrap { flex: 1; min-height: 0; overflow: auto; }
|
|
|
|
/* ---------- trace list ---------- */
|
|
|
|
.list-controls {
|
|
flex: none;
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 10px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
#search {
|
|
flex: 1; max-width: 520px;
|
|
background: var(--bg-input);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 6px 10px;
|
|
outline: none;
|
|
}
|
|
#search:focus { border-color: var(--accent); }
|
|
#search::placeholder { color: var(--text-faint); }
|
|
|
|
.seg { display: flex; background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
.seg button { background: none; border: none; padding: 5px 11px; color: var(--text-dim); }
|
|
.seg button:hover { background: var(--bg-hover); color: var(--text); }
|
|
.seg button.is-active { background: var(--bg-active); color: var(--text); }
|
|
|
|
.scan-note { margin-left: auto; color: var(--text-faint); font-family: var(--mono); font-size: 11px; }
|
|
|
|
table { width: 100%; border-collapse: collapse; }
|
|
|
|
.traces thead th {
|
|
position: sticky; top: 0; z-index: 2;
|
|
background: var(--bg);
|
|
text-align: left;
|
|
font-weight: 500;
|
|
font-size: 11px;
|
|
letter-spacing: .04em;
|
|
text-transform: uppercase;
|
|
color: var(--text-faint);
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid var(--border);
|
|
white-space: nowrap;
|
|
}
|
|
.col-num { text-align: right !important; }
|
|
|
|
.traces tbody tr { border-bottom: 1px solid var(--border); cursor: pointer; }
|
|
.traces tbody tr:hover { background: var(--bg-hover); }
|
|
.traces tbody td { padding: 9px 12px; vertical-align: top; }
|
|
|
|
.cell-when { white-space: nowrap; color: var(--text-dim); font-family: var(--mono); font-size: 11.5px; }
|
|
.cell-when .abs { display: block; color: var(--text-faint); font-size: 10.5px; }
|
|
|
|
.trace-name { display: flex; align-items: baseline; gap: 7px; font-weight: 500; }
|
|
.trace-headline {
|
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
|
overflow: hidden; max-width: 80ch; line-height: 1.4;
|
|
}
|
|
.trace-name .status-dot { position: relative; top: -1px; }
|
|
.trace-id { font-family: var(--mono); font-size: 10.5px; color: var(--text-faint); margin-top: 2px; }
|
|
|
|
.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
.num .unit { color: var(--text-faint); }
|
|
.chip-live {
|
|
margin-left: 8px; padding: 0 6px; border-radius: 8px;
|
|
background: color-mix(in srgb, var(--warn) 16%, transparent);
|
|
border: 1px solid color-mix(in srgb, var(--warn) 34%, transparent);
|
|
color: var(--warn); font-size: 10px; font-weight: 600;
|
|
}
|
|
.sub-num { display: block; color: var(--text-faint); font-size: 10.5px; margin-top: 1px; }
|
|
|
|
/* The shape column is a miniature of the run: one pip per span kind, so the
|
|
* composition of a trace (mostly tools? mostly model calls?) reads at a glance
|
|
* without opening it. */
|
|
.shape { display: flex; align-items: center; gap: 6px; }
|
|
.shape-group { display: flex; align-items: center; gap: 3px; color: var(--text-dim); font-family: var(--mono); font-size: 11px; }
|
|
.pip { width: 7px; height: 7px; border-radius: 2px; flex: none; }
|
|
|
|
.status-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
|
|
.status-dot.ok { background: var(--ok); }
|
|
.status-dot.error { background: var(--error); }
|
|
|
|
.badge-err {
|
|
background: color-mix(in srgb, var(--error) 16%, transparent);
|
|
color: var(--error);
|
|
border: 1px solid color-mix(in srgb, var(--error) 35%, transparent);
|
|
border-radius: 10px; padding: 0 6px; font-size: 10.5px; font-weight: 600;
|
|
}
|
|
|
|
.empty { padding: 48px 16px; text-align: center; color: var(--text-faint); }
|
|
.empty code { font-family: var(--mono); color: var(--text-dim); }
|
|
|
|
/* ---------- trace detail ---------- */
|
|
|
|
.detail-head {
|
|
flex: none;
|
|
display: flex; align-items: flex-start; gap: 16px;
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.back {
|
|
background: var(--bg-input); border: 1px solid var(--border);
|
|
border-radius: var(--radius); padding: 5px 10px; color: var(--text-dim);
|
|
}
|
|
.back:hover { color: var(--text); border-color: var(--border-strong); }
|
|
|
|
.detail-title h1 { margin: 0; font-size: 15px; font-weight: 600; }
|
|
.detail-meta { margin-top: 3px; color: var(--text-faint); font-family: var(--mono); font-size: 11px; display: flex; gap: 12px; flex-wrap: wrap; }
|
|
|
|
.detail-stats { margin-left: auto; display: flex; gap: 22px; }
|
|
.stat { text-align: right; }
|
|
.stat-val { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 15px; }
|
|
.stat-label { color: var(--text-faint); font-size: 10.5px; text-transform: uppercase; letter-spacing: .04em; margin-top: 1px; }
|
|
|
|
/* Both panes scroll independently, which only works if they are allowed to be
|
|
* shorter than their contents.
|
|
*
|
|
* A grid item defaults to min-height: auto, meaning it refuses to shrink below
|
|
* its content's intrinsic size. Without min-height: 0 the span-tree pane simply
|
|
* grew past the viewport — measured at 704px inside a 593px row — so its inner
|
|
* overflow:auto never had a reason to engage and the rows past the fold could
|
|
* not be reached at all. min-width: 0 was there; min-height: 0 was the one that
|
|
* mattered. */
|
|
.detail-body {
|
|
flex: 1; min-height: 0;
|
|
display: grid;
|
|
grid-template-columns: minmax(420px, 1.15fr) minmax(380px, 1fr);
|
|
}
|
|
.detail-body > * { min-height: 0; }
|
|
|
|
.waterfall-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; border-right: 1px solid var(--border); }
|
|
|
|
.pane-head {
|
|
flex: none; display: flex; align-items: center; gap: 14px;
|
|
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
|
}
|
|
.pane-title { font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); }
|
|
|
|
.legend { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
.legend-item { display: flex; align-items: center; gap: 4px; color: var(--text-dim); font-size: 11px; }
|
|
|
|
.toggle:first-of-type { margin-left: auto; }
|
|
.toggle { display: flex; align-items: center; gap: 5px; color: var(--text-dim); font-size: 11px; white-space: nowrap; }
|
|
.toggle input { accent-color: var(--accent); }
|
|
|
|
/* The axis sits outside the scroll container so the time reference stays
|
|
* visible while the tree scrolls. */
|
|
.timeaxis {
|
|
flex: none; position: relative; height: 18px;
|
|
margin-left: var(--label-w, 300px);
|
|
margin-right: 66px; /* match the duration column */
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.tick { position: absolute; top: 0; height: 100%; border-left: 1px solid var(--border); padding-left: 4px;
|
|
color: var(--text-faint); font-family: var(--mono); font-size: 10px; white-space: nowrap; }
|
|
|
|
.waterfall { flex: 1; min-height: 0; overflow: auto; outline: none; }
|
|
|
|
/* Three columns: label, bar track, duration. The duration was previously
|
|
* absolutely positioned at the end of its bar, which clipped whenever a bar
|
|
* reached the full width — percentage geometry resolves against the padding
|
|
* box, so padding could not reserve room for it. Its own column also aligns the
|
|
* numbers, which makes the column scannable. */
|
|
.span-row {
|
|
display: grid;
|
|
grid-template-columns: var(--label-w, 300px) 1fr 66px;
|
|
align-items: center;
|
|
height: 26px;
|
|
cursor: pointer;
|
|
border-bottom: 1px solid transparent;
|
|
}
|
|
.span-row:hover { background: var(--bg-hover); }
|
|
.span-row.is-selected { background: var(--bg-active); }
|
|
.span-row.is-selected .span-label { color: var(--text); }
|
|
|
|
.span-label {
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding-right: 10px; min-width: 0;
|
|
color: var(--text-dim);
|
|
white-space: nowrap; overflow: hidden;
|
|
}
|
|
.span-tree { color: var(--text-faint); font-family: var(--mono); white-space: pre; flex: none; }
|
|
.span-kind-dot { width: 7px; height: 7px; border-radius: 2px; flex: none; }
|
|
.span-name { overflow: hidden; text-overflow: ellipsis; }
|
|
.span-name .qualifier { color: var(--text-faint); }
|
|
.fold-count {
|
|
flex: none; font-family: var(--mono); font-size: 10px;
|
|
color: var(--text-faint); background: var(--bg-active);
|
|
border-radius: 8px; padding: 0 5px;
|
|
}
|
|
.content-dot {
|
|
flex: none; width: 5px; height: 5px; border-radius: 50%;
|
|
background: var(--accent);
|
|
/* Marks a span that actually carries a prompt, tool payload, or response —
|
|
* the spans worth opening. */
|
|
}
|
|
|
|
.span-track { position: relative; height: 100%; }
|
|
.span-bar {
|
|
position: absolute; top: 50%; transform: translateY(-50%);
|
|
height: 11px; border-radius: 3px; min-width: 2px;
|
|
display: flex; align-items: center;
|
|
}
|
|
.span-bar.is-error { background: var(--error) !important; }
|
|
.span-dur {
|
|
padding-right: 10px; text-align: right;
|
|
font-family: var(--mono); font-size: 10.5px; color: var(--text-faint);
|
|
font-variant-numeric: tabular-nums; white-space: nowrap;
|
|
}
|
|
|
|
/* ---------- inspector ---------- */
|
|
|
|
.inspector { overflow: auto; padding: 0 0 40px; min-width: 0; min-height: 0; }
|
|
.inspector-empty { padding: 40px 16px; color: var(--text-faint); text-align: center; }
|
|
|
|
.insp-head { padding: 12px 16px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 2; }
|
|
.insp-title { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 14px; }
|
|
.insp-sub { margin-top: 4px; color: var(--text-faint); font-family: var(--mono); font-size: 11px; display: flex; gap: 12px; flex-wrap: wrap; }
|
|
|
|
.insp-section { border-bottom: 1px solid var(--border); }
|
|
.insp-section > summary {
|
|
padding: 8px 16px; cursor: pointer; list-style: none;
|
|
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
|
|
color: var(--text-faint);
|
|
display: flex; align-items: center; gap: 6px;
|
|
}
|
|
.insp-section > summary::-webkit-details-marker { display: none; }
|
|
.insp-section > summary::before { content: "▸"; font-size: 9px; transition: transform .12s; }
|
|
.insp-section[open] > summary::before { transform: rotate(90deg); }
|
|
.insp-section > summary:hover { color: var(--text); background: var(--bg-hover); }
|
|
.insp-count { margin-left: auto; font-weight: 400; color: var(--text-faint); }
|
|
|
|
.insp-body { padding: 4px 16px 14px; }
|
|
|
|
/* Attributes: a two-column grid rather than a table, so long values wrap under
|
|
* their own key instead of stretching the column for every other row. */
|
|
.attrs { display: grid; grid-template-columns: minmax(110px, max-content) 1fr; gap: 3px 14px; }
|
|
.attr-k { color: var(--text-faint); font-family: var(--mono); font-size: 11.5px; overflow-wrap: anywhere; }
|
|
.attr-v { font-family: var(--mono); font-size: 11.5px; overflow-wrap: anywhere; }
|
|
.attr-v.is-num { color: var(--kind-agent); font-variant-numeric: tabular-nums; }
|
|
.attr-v.is-bool { color: var(--kind-retrieval); }
|
|
|
|
/* The transcript is the point of the whole application: what the agent was
|
|
* asked, what it ran, what came back. It is styled as prose blocks rather than
|
|
* a data table because it is read, not scanned. */
|
|
.transcript { display: flex; flex-direction: column; gap: 10px; }
|
|
.msg { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
.msg-head {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 5px 10px; background: var(--bg-raised);
|
|
font-size: 11px; font-weight: 600;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.msg-when { margin-left: auto; font-weight: 400; color: var(--text-faint); font-family: var(--mono); font-size: 10.5px; }
|
|
.msg-body { padding: 9px 10px; }
|
|
|
|
pre.payload {
|
|
margin: 0; font-family: var(--mono); font-size: 11.5px; line-height: 1.55;
|
|
white-space: pre-wrap; overflow-wrap: anywhere;
|
|
max-height: 340px; overflow: auto;
|
|
}
|
|
.msg.prompt { border-left: 2px solid var(--kind-agent); }
|
|
.msg.tool { border-left: 2px solid var(--kind-tool); }
|
|
.msg.response { border-left: 2px solid var(--kind-llm); }
|
|
.msg.failed { border-left: 2px solid var(--error); }
|
|
|
|
.kv-inline { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 7px; }
|
|
.chip {
|
|
background: var(--bg-raised); border: 1px solid var(--border);
|
|
border-radius: 10px; padding: 1px 8px;
|
|
font-family: var(--mono); font-size: 10.5px; color: var(--text-dim);
|
|
}
|
|
.chip b { color: var(--text); font-weight: 600; }
|
|
|
|
.copy-btn {
|
|
background: none; border: 1px solid var(--border); border-radius: 4px;
|
|
padding: 1px 6px; font-size: 10px; color: var(--text-faint);
|
|
}
|
|
.copy-btn:hover { color: var(--text); border-color: var(--border-strong); }
|
|
|
|
/* ---------- explore ---------- */
|
|
|
|
.explore-head { flex: none; padding: 12px 16px 0; }
|
|
#query-input {
|
|
width: 100%; resize: vertical;
|
|
background: var(--bg-input); border: 1px solid var(--border);
|
|
border-radius: var(--radius); padding: 10px 12px;
|
|
font-family: var(--mono); font-size: 12.5px; line-height: 1.6;
|
|
outline: none;
|
|
}
|
|
#query-input:focus { border-color: var(--accent); }
|
|
|
|
.explore-actions { display: flex; align-items: center; gap: 12px; padding: 10px 0; }
|
|
.primary {
|
|
background: var(--accent); color: #fff; border: none;
|
|
border-radius: var(--radius); padding: 6px 16px; font-weight: 600;
|
|
}
|
|
.primary:hover { filter: brightness(1.1); }
|
|
.hint { color: var(--text-faint); font-size: 11px; font-family: var(--mono); }
|
|
|
|
.examples { flex: none; display: flex; gap: 8px; flex-wrap: wrap; padding: 0 16px 12px; }
|
|
.example {
|
|
background: var(--bg-raised); border: 1px solid var(--border);
|
|
border-radius: 12px; padding: 3px 11px; color: var(--text-dim); font-size: 11.5px;
|
|
}
|
|
.example:hover { color: var(--text); border-color: var(--accent); }
|
|
|
|
.query-error {
|
|
margin: 0 16px 12px; padding: 9px 12px;
|
|
background: color-mix(in srgb, var(--error) 10%, transparent);
|
|
border: 1px solid color-mix(in srgb, var(--error) 34%, transparent);
|
|
border-radius: var(--radius);
|
|
color: var(--error); font-family: var(--mono); font-size: 11.5px;
|
|
}
|
|
|
|
.chart-wrap { flex: none; padding: 0 16px 14px; }
|
|
#chart { width: 100%; height: 190px; display: block; overflow: visible; }
|
|
|
|
.results thead th {
|
|
position: sticky; top: 0; z-index: 2; background: var(--bg);
|
|
text-align: left; font-weight: 500; font-size: 11px;
|
|
letter-spacing: .04em; text-transform: uppercase; color: var(--text-faint);
|
|
padding: 8px 12px; border-bottom: 1px solid var(--border); white-space: nowrap;
|
|
}
|
|
.results tbody td {
|
|
padding: 6px 12px; border-bottom: 1px solid var(--border);
|
|
font-family: var(--mono); font-size: 11.5px;
|
|
}
|
|
.results tbody tr:hover { background: var(--bg-hover); }
|
|
.results td.is-num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
.results td.is-link { color: var(--accent); cursor: pointer; }
|
|
.results td.is-link:hover { text-decoration: underline; }
|
|
|
|
/* ---------- toast ---------- */
|
|
|
|
.toast {
|
|
position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%);
|
|
background: var(--bg-active); border: 1px solid var(--border-strong);
|
|
border-radius: var(--radius); padding: 7px 14px;
|
|
box-shadow: var(--shadow); z-index: 50;
|
|
}
|
|
|
|
/* ---------- narrow screens ---------- */
|
|
|
|
@media (max-width: 1000px) {
|
|
.detail-body { grid-template-columns: 1fr; grid-template-rows: minmax(200px, 45%) 1fr; }
|
|
.waterfall-pane { border-right: none; border-bottom: 1px solid var(--border); }
|
|
.detail-stats { display: none; }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
* { transition: none !important; }
|
|
}
|
|
|
|
|
|
/* ---------- live updates ---------- */
|
|
|
|
/* The stream indicator is deliberately small. It matters when it is wrong —
|
|
* a dead stream otherwise looks identical to "no traces are running" — and
|
|
* should be ignorable the rest of the time. */
|
|
.live-dot {
|
|
width: 7px; height: 7px; border-radius: 50%;
|
|
flex: none; cursor: default;
|
|
}
|
|
.live-dot.is-live { background: var(--ok); }
|
|
.live-dot.is-connecting { background: var(--warn); animation: live-pulse 1.4s ease-in-out infinite; }
|
|
.live-dot.is-offline { background: var(--text-faint); }
|
|
|
|
@keyframes live-pulse {
|
|
0%, 100% { opacity: .35; }
|
|
50% { opacity: 1; }
|
|
}
|
|
|
|
/* A newly arrived trace gets a brief wash of colour rather than an animation
|
|
* that moves anything. Movement in a list someone is reading is the thing to
|
|
* avoid; a tint that fades is noticeable without being disruptive. */
|
|
.traces tbody tr.is-new {
|
|
animation: row-arrive 2.2s ease-out;
|
|
}
|
|
|
|
@keyframes row-arrive {
|
|
0% { background: color-mix(in srgb, var(--accent) 22%, transparent); }
|
|
100% { background: transparent; }
|
|
}
|
|
|
|
/* Shown only when traces arrive while the reader is scrolled into the list, so
|
|
* nothing is inserted above them mid-read. */
|
|
.new-pill {
|
|
position: absolute; left: 50%; transform: translateX(-50%);
|
|
/* Clears the controls row and the sticky table header, so it floats in
|
|
the list rather than sitting on the first row's text. */
|
|
top: calc(var(--topbar-h) + 88px);
|
|
z-index: 20;
|
|
background: var(--accent); color: #fff; border: none;
|
|
border-radius: 14px; padding: 5px 14px;
|
|
font-size: 11.5px; font-weight: 600;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
.new-pill:hover { filter: brightness(1.08); }
|
|
.new-pill[hidden] { display: none; }
|
|
|
|
#view-traces { position: relative; }
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.traces tbody tr.is-new { animation: none; background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
|
.live-dot.is-connecting { animation: none; }
|
|
}
|
|
|
|
/* ---------- insights ---------- */
|
|
|
|
.tab-count {
|
|
margin-left: 6px; padding: 0 5px; border-radius: 8px;
|
|
background: var(--accent); color: #fff;
|
|
font-size: 10px; font-weight: 700; font-variant-numeric: tabular-nums;
|
|
}
|
|
.tab-count[hidden] { display: none; }
|
|
|
|
.insights-head {
|
|
flex: none; display: flex; align-items: center; gap: 16px;
|
|
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
/* The summary states what was analysed and how much of the spend the findings
|
|
* actually speak to. A page of advice with no denominator invites the reader to
|
|
* assume it covers everything. */
|
|
.insights-summary { color: var(--text-dim); font-size: 12px; }
|
|
.insights-summary b { color: var(--text); font-family: var(--mono); }
|
|
|
|
.insights-body { flex: 1; min-height: 0; overflow: auto; padding: 4px 16px 60px; }
|
|
|
|
.theme { margin-top: 22px; max-width: 900px; }
|
|
.theme-title { font-size: 14px; font-weight: 600; }
|
|
.theme-sub { color: var(--text-dim); margin: 4px 0 12px; line-height: 1.55; max-width: 74ch; }
|
|
|
|
.insight {
|
|
border: 1px solid var(--border); border-left-width: 3px;
|
|
border-radius: var(--radius);
|
|
margin-bottom: 10px; background: var(--bg-raised);
|
|
}
|
|
.insight.sev-high { border-left-color: var(--error); }
|
|
.insight.sev-medium { border-left-color: var(--warn); }
|
|
.insight.sev-low { border-left-color: var(--border-strong); }
|
|
|
|
.insight-head {
|
|
display: flex; align-items: flex-start; gap: 12px;
|
|
padding: 11px 13px; cursor: pointer;
|
|
}
|
|
.insight-head:hover { background: var(--bg-hover); }
|
|
.insight-title { font-weight: 600; flex: 1; min-width: 0; line-height: 1.45; }
|
|
|
|
/* The number is the argument, so it is the largest thing in the row. */
|
|
.insight-cost {
|
|
font-family: var(--mono); font-variant-numeric: tabular-nums;
|
|
font-size: 15px; white-space: nowrap; text-align: right;
|
|
}
|
|
.insight-cost .sub {
|
|
display: block; font-size: 10.5px; color: var(--text-faint); font-weight: 400;
|
|
}
|
|
|
|
.insight-body { padding: 0 13px 13px; }
|
|
.insight-body p { margin: 0 0 10px; line-height: 1.6; color: var(--text-dim); max-width: 74ch; }
|
|
|
|
.suggestion {
|
|
border-left: 2px solid var(--accent);
|
|
padding: 7px 0 7px 11px; margin: 0 0 12px;
|
|
color: var(--text); line-height: 1.6; max-width: 74ch;
|
|
}
|
|
|
|
.steps { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 12px; }
|
|
.step {
|
|
font-family: var(--mono); font-size: 11px;
|
|
background: var(--bg-active); border: 1px solid var(--border);
|
|
border-radius: 4px; padding: 2px 7px;
|
|
}
|
|
.step-arrow { color: var(--text-faint); align-self: center; }
|
|
|
|
.evidence-title {
|
|
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
|
letter-spacing: .05em; color: var(--text-faint); margin-bottom: 6px;
|
|
}
|
|
.evidence { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
/* The prompt is what identifies a trace to a reader, so it gets a floor on its
|
|
* width; the detail is corroboration and truncates first. Letting the detail
|
|
* size to content crushed the prompt to a few characters. */
|
|
.ev-row {
|
|
display: grid;
|
|
grid-template-columns: 74px minmax(160px, 1.1fr) minmax(0, 2fr) 60px;
|
|
gap: 10px; align-items: baseline;
|
|
padding: 6px 10px; cursor: pointer;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
.evidence .ev-row:first-child { border-top: none; }
|
|
.ev-row:hover { background: var(--bg-hover); }
|
|
.ev-when { color: var(--text-faint); font-family: var(--mono); font-size: 10.5px; }
|
|
.ev-prompt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
|
|
.ev-detail {
|
|
font-family: var(--mono); font-size: 10.5px; color: var(--text-faint);
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.ev-cost { font-family: var(--mono); font-size: 11px; text-align: right; font-variant-numeric: tabular-nums; }
|
|
|
|
.insight-actions { display: flex; gap: 8px; margin-top: 12px; }
|
|
.dismiss {
|
|
background: none; border: 1px solid var(--border); border-radius: var(--radius);
|
|
padding: 4px 10px; color: var(--text-faint); font-size: 11.5px;
|
|
}
|
|
.dismiss:hover { color: var(--text); border-color: var(--border-strong); }
|
|
|
|
|
|
/* ---------- local model status ---------- */
|
|
|
|
/* Shown because it changes what the findings mean: clustering by meaning can
|
|
* group runs that clustering by wording cannot, so two machines can disagree.
|
|
* Stating which was used is cheaper than leaving someone to wonder. */
|
|
.local-status {
|
|
margin-left: auto;
|
|
display: flex; align-items: center; gap: 9px;
|
|
font-size: 11.5px; color: var(--text-dim);
|
|
}
|
|
.local-status[hidden] { display: none; }
|
|
.local-status code { font-family: var(--mono); font-size: 10.5px; color: var(--text); }
|
|
|
|
.local-switch { display: flex; align-items: center; gap: 5px; cursor: pointer; white-space: nowrap; }
|
|
.local-switch input { accent-color: var(--accent); }
|
|
|
|
.method-tag {
|
|
font-family: var(--mono); font-size: 9.5px;
|
|
text-transform: uppercase; letter-spacing: .05em;
|
|
border: 1px solid var(--border); border-radius: 8px;
|
|
padding: 0 6px; color: var(--text-faint); margin-left: 8px;
|
|
vertical-align: 1px;
|
|
}
|
|
.method-tag.is-semantic { border-color: color-mix(in srgb, var(--kind-llm) 45%, transparent); color: var(--kind-llm); }
|
|
|
|
.insight-label { color: var(--text-faint); font-weight: 400; }
|
|
|
|
|
|
/* ---------- insights empty states ---------- */
|
|
|
|
/* Centred, narrow, and quiet. The page has nothing to list, so the job is to
|
|
* say which kind of nothing it is and then get out of the way. */
|
|
.insights-state {
|
|
max-width: 46ch;
|
|
margin: 14vh auto 0;
|
|
text-align: center;
|
|
padding: 0 16px;
|
|
}
|
|
|
|
.state-mark {
|
|
width: 34px; height: 34px; margin: 0 auto 18px;
|
|
border-radius: 50%;
|
|
border: 1px solid var(--border-strong);
|
|
background:
|
|
radial-gradient(circle at 50% 50%,
|
|
color-mix(in srgb, var(--kind-agent) 26%, transparent) 0 22%,
|
|
transparent 23%);
|
|
}
|
|
|
|
/* The good-news and still-working cases get a fainter mark: nothing is wrong,
|
|
* so nothing should draw the eye. */
|
|
.insights-state.is-quiet .state-mark {
|
|
border-color: var(--border);
|
|
background: none;
|
|
}
|
|
|
|
.state-title {
|
|
margin: 0 0 10px;
|
|
font-size: 16px; font-weight: 600; letter-spacing: -.01em;
|
|
}
|
|
|
|
.state-line {
|
|
margin: 0 0 9px;
|
|
color: var(--text-dim); line-height: 1.65;
|
|
}
|
|
|
|
/* Block, not inline-block: sitting inline let the hint and the button share a
|
|
line and run into each other. */
|
|
.state-hint {
|
|
display: block; width: fit-content; margin: 14px auto 0;
|
|
background: var(--bg-input); border: 1px solid var(--border);
|
|
border-radius: var(--radius); padding: 7px 12px;
|
|
font-family: var(--mono); font-size: 11.5px; color: var(--text);
|
|
text-align: left;
|
|
}
|
|
|
|
.insights-state .primary,
|
|
.insights-state a.primary {
|
|
display: block; width: fit-content; margin: 16px auto 0;
|
|
text-decoration: none;
|
|
}
|
|
|
|
/* With nothing to show, the header bar has nothing in it either and its rule
|
|
* just draws a line under an empty strip. */
|
|
.insights-head:not(:has(.insights-summary > *)) {
|
|
border-bottom: none;
|
|
}
|