@@ -248,25 +252,7 @@ export function makeRequestDetail(
{/* Mirrors the shared category hover with the tip off — a cross-card hover must not float a second tooltip
over a bar the pointer does not rest on (same rule as the browser's own bar). */}
-
- {CATS.map((c, i) => {
- const v = delta ? deltas[i] : req[c.key] || 0
- return (
-
-
- {catLabel(c.key)}
- {delta ? (
- 0 ? ' lc-detail-num-up' : v < 0 ? ' lc-detail-num-down' : '')}>
- {(v > 0 ? '+' : '') + fmt(v)}
-
- ) : (
- {'≈' + fmt(v)}
- )}
- {!delta && req.total > 0 ? `${Math.round(v / req.total * 100)}%` : ''}
-
- )
- })}
-
+
)
})
diff --git a/src/client/components/stackedBar.tsx b/src/client/components/stackedBar.tsx
index 6a85b52..e0ac29a 100644
--- a/src/client/components/stackedBar.tsx
+++ b/src/client/components/stackedBar.tsx
@@ -289,17 +289,19 @@ export function makeStackedBar(kit: ViewKit): (props: StackedBarProps) => ReactE
}
}
-export function makeLegend(kit: ViewKit): (props: {
+/** The category chips' interface, shared by the composition card's legend and the trend panel. */
+export interface LegendProps {
parts: PartsPart[]
hoverKey?: string | null
onHoverKey?: (key: string | null) => void
-}) => ReactElement {
+ /** Signed figures (the trend panel's Delta mode): the count prints with its sign and the sign's color,
+ * and the share drops out — a signed change has no share of the used total. */
+ delta?: boolean
+}
+
+export function makeLegend(kit: ViewKit): (props: LegendProps) => ReactElement {
const { t, fmt, catLabel } = kit
- return function Legend(props: {
- parts: PartsPart[]
- hoverKey?: string | null
- onHoverKey?: (key: string | null) => void
- }): ReactElement {
+ return function Legend(props: LegendProps): ReactElement {
let total = 0
for (const p of props.parts) total += p.raw ?? p.value
return (
@@ -307,19 +309,20 @@ export function makeLegend(kit: ViewKit): (props: {
{props.parts.map((p) => {
const count = p.raw ?? p.value
const on = props.hoverKey !== undefined && props.hoverKey === p.key
+ const sign = props.delta === true && count !== 0 ? ' lc-chip-nums-' + (count > 0 ? 'up' : 'down') : ''
return (
{ if (props.onHoverKey !== undefined) props.onHoverKey(p.key) }}
onMouseLeave={() => { if (props.onHoverKey !== undefined) props.onHoverKey(null) }}
>
{catLabel(p.key)}
-
- {'≈' + fmt(count)}
- {total > 0 ? {`${Math.round(count / total * 100)}%`} : null}
+
+ {props.delta === true ? (count > 0 ? '+' : '') + fmt(count) : '≈' + fmt(count)}
+ {props.delta !== true && total > 0 ? {`${Math.round(count / total * 100)}%`} : null}
)
diff --git a/src/client/styles/requestDetail.css b/src/client/styles/requestDetail.css
index 6f186d9..f7b514b 100644
--- a/src/client/styles/requestDetail.css
+++ b/src/client/styles/requestDetail.css
@@ -13,8 +13,6 @@
/* Sign semantics mirror the harness diff blocks. */
.lc-detail-metric-up { color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 15%, transparent); }
.lc-detail-metric-down { color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 15%, transparent); }
-/* auto-fit instead of a fixed pair: a row's min-content is ~196px, below which two columns would spill. */
-.lc-detail-rows { margin-top: 10px; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 4px 24px; }
/* A fixed three-row lane (3 x the 27px row + 2 x the 2px gap): the container exists even when no row resolves, AND
every row is a constant height whether or not it carries a chip — a chip is 2px taller than bare text, so without
the floor the lane measured 79.5px on an input-less step and 81.5px on one with chips, moving the trend card (and
@@ -34,15 +32,3 @@ button.lc-brief-row-link { cursor: pointer; }
.lc-brief-chip-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lc-brief-chip-link { cursor: pointer; }
.lc-brief-more { flex: none; font-size: 11px; color: var(--dsw-alias-label-secondary); }
-.lc-detail-row { display: flex; align-items: center; gap: 8px; border-radius: 4px; /* Padding + matching negative margin: the highlight paints past the text while the layout stays put. */ padding: 1px 4px; margin: -1px -4px; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
-/* Category hover-link: the shared hover lights the matching detail row. */
-.lc-detail-row i { transition: box-shadow var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
-.lc-detail-row-on { background: var(--dsw-alias-interactive-bg-hover); }
-.lc-detail-row-on i { box-shadow: 0 0 0 1px var(--dsw-alias-brand-primary); }
-.lc-detail-row-on .lc-detail-label { font-weight: 600; }
-.lc-detail-label { min-width: 70px; white-space: nowrap; color: var(--dsw-alias-label-secondary); }
-/* The figures pin to the row's right edge, so they form one column across the labels' ragged widths. */
-.lc-detail-num { margin-left: auto; width: 52px; text-align: right; font-variant-numeric: tabular-nums; }
-.lc-detail-num-up { color: var(--dsw-alias-state-success-primary); }
-.lc-detail-num-down { color: var(--dsw-alias-state-error-primary); }
-.lc-detail-pct { width: 34px; text-align: right; color: var(--dsw-alias-label-secondary); }
diff --git a/src/client/styles/stackedBar.css b/src/client/styles/stackedBar.css
index 6cb3a89..1e38620 100644
--- a/src/client/styles/stackedBar.css
+++ b/src/client/styles/stackedBar.css
@@ -38,8 +38,11 @@
.lc-chip { display: flex; align-items: center; gap: 5px; min-width: 0; color: var(--dsw-alias-label-primary); padding: 1px 6px; border-radius: 6px; cursor: pointer; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-chip-label { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.lc-chip-nums { margin-left: auto; flex: none; display: inline-flex; align-items: baseline; gap: 6px; white-space: nowrap; }
-.lc-chip i, .lc-detail-row i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; }
+.lc-chip i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; }
.lc-chip i { transition: box-shadow var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-chip em { font-style: normal; color: var(--dsw-alias-label-secondary); }
+/* Signed figures (the trend panel's Delta mode): the sign semantics mirror the harness diff blocks. */
+.lc-chip-nums-up { color: var(--dsw-alias-state-success-primary); }
+.lc-chip-nums-down { color: var(--dsw-alias-state-error-primary); }
.lc-chip-on { font-weight: 600; background: var(--dsw-alias-interactive-bg-hover); }
.lc-chip-on i { box-shadow: 0 0 0 1px var(--dsw-alias-brand-primary); }
diff --git a/tests/client/components/contextView.interactions.spec.ts b/tests/client/components/contextView.interactions.spec.ts
index 272d75f..d879fd8 100644
--- a/tests/client/components/contextView.interactions.spec.ts
+++ b/tests/client/components/contextView.interactions.spec.ts
@@ -142,7 +142,7 @@ describe('ContextView — interactions', () => {
const detailBar = query(m.container, '.lc-detail .lc-stacked')
assert.ok(detailBar.className.includes('lc-stacked-dim'))
assert.ok(queryAll(detailBar, '.lc-stacked-seg-on').length === 1)
- assert.ok(queryAll(m.container, '.lc-detail-row-on').length === 1, 'the matching detail row lights too')
+ assert.ok(queryAll(m.container, '.lc-detail .lc-chip-on').length === 1, 'the matching detail chip lights too')
await unhover(toolsRow)
assert.equal(query(m.container, '.lc-chart').hasAttribute('data-catdim'), false)
@@ -156,6 +156,20 @@ describe('ContextView — interactions', () => {
await m.unmount()
})
+ test('trend panel chip hover feeds the shared category state back to the composition legend', async () => {
+ const m = await mountRich('sv-link-chip')
+ // The panel's chips are hover SOURCES, exactly as the composition legend's are; "tools" is the second chip.
+ const chip = queryAll(m.container, '.lc-detail .lc-legend .lc-chip')[1]
+ await hover(chip)
+ assert.equal(query(m.container, '.lc-chart').getAttribute('data-catdim'), 'tools')
+ const comp = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['overview.title']))
+ assert.ok(comp !== undefined)
+ assert.equal(queryAll(comp, '.lc-legend .lc-chip-on').length, 1, 'the composition legend chip lights with it')
+ await unhover(chip)
+ assert.equal(query(m.container, '.lc-chart').hasAttribute('data-catdim'), false)
+ await m.unmount()
+ })
+
test('expanding a browser category focuses the trend bars on it; collapsing restores all', async () => {
const m = await mountRich('sv-focus')
assert.equal(text(query(m.container, '.lc-axis-top')), '420', 'unfocused: the axis tops at the largest whole-bar total')
diff --git a/tests/client/components/contextView.splitDetail.spec.ts b/tests/client/components/contextView.splitDetail.spec.ts
index fe38a16..2b8ed23 100644
--- a/tests/client/components/contextView.splitDetail.spec.ts
+++ b/tests/client/components/contextView.splitDetail.spec.ts
@@ -71,8 +71,11 @@ describe('ContextView — the split generation (slim head + detail channel)', ()
// Pending: the trend card stands in the same boxes the loaded panel uses, so its arrival changes no card's height.
assert.equal(queryAll(m.container, '.lc-trend-lane').length, 1, 'the reserved body renders while the read is in flight')
- assert.equal(queryAll(m.container, '.lc-trend-lane .lc-detail-rows .lc-detail-row').length, 7,
- 'the lane reserves one row per category')
+ assert.equal(queryAll(m.container, '.lc-trend-lane .lc-legend .lc-chip').length, 7,
+ 'the lane reserves one chip per category')
+ const laneChip = query(m.container, '.lc-trend-lane .lc-legend .lc-chip')
+ assert.deepEqual(Array.from(laneChip.children).map(el => el.className.split(' ')[0]), ['', 'lc-chip-label', 'lc-chip-nums'],
+ 'the reserved chips wear the legend chip anatomy, so the panels measure alike')
assert.equal(queryAll(m.container, '.lc-trend-lane .lc-detail-metrics').length, 0,
'no fabricated figures in the reserved panel')
diff --git a/tests/client/components/requestDetail.spec.ts b/tests/client/components/requestDetail.spec.ts
index c3ab918..bf97426 100644
--- a/tests/client/components/requestDetail.spec.ts
+++ b/tests/client/components/requestDetail.spec.ts
@@ -2,14 +2,14 @@ import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { makeRequestDetail } from '../../../src/client/components/requestDetail'
-import { makeStackedBar } from '../../../src/client/components/stackedBar'
+import { makeStackedBar, makeLegend } from '../../../src/client/components/stackedBar'
import type { RequestRecord, ContextEventRecord, SurfaceNode } from '../../../src/shared/types'
import type { ConversationNodeLike } from '../../../src/client/services'
import type { StepBrief } from '../../../src/client/brief'
import { makeKit, mount, click, queryAll, query, surfaceNode, text } from '../helpers/kit'
const kit = makeKit()
-const RequestDetail = makeRequestDetail(kit, makeStackedBar(kit))
+const RequestDetail = makeRequestDetail(kit, makeStackedBar(kit), makeLegend(kit))
function req(over: Partial): RequestRecord {
return {
@@ -23,12 +23,11 @@ function req(over: Partial): RequestRecord {
/** Full-category fixture so every row renders a non-zero figure. */
const FULL = { system: 100, tools: 200, user: 300, inject: 50, assistant: 400, tool: 250, total: 1300 }
-/** A detail row's anatomy — dot, label, figure, share — by each child's base class (a delta row's figure adds a
- * sign modifier). Pinned structurally, so reintroducing a per-row mini bar fails here in either mode, instead of
- * passing an absence check on a deleted class. */
-const ROW_ANATOMY = ['', 'lc-detail-label', 'lc-detail-num', 'lc-detail-pct']
+/** A category chip's anatomy — dot, label, figures — by each child's base class (a delta chip's figures add a
+ * sign modifier). Pinned structurally: a child added, removed or reordered fails here in either mode. */
+const CHIP_ANATOMY = ['', 'lc-chip-label', 'lc-chip-nums']
const anatomy = (container: HTMLElement): string[][] =>
- queryAll(container, '.lc-detail-row').map(r => Array.from(r.children).map(el => el.className.split(' ')[0]))
+ queryAll(container, '.lc-legend .lc-chip').map(c => Array.from(c.children).map(el => el.className.split(' ')[0]))
describe('RequestDetail header', () => {
test('null request renders nothing', async () => {
@@ -37,7 +36,7 @@ describe('RequestDetail header', () => {
await m.unmount()
})
- test('step detail: title, time, usage chips, per-category rows', async () => {
+ test('step detail: title, time, usage chips, per-category chips', async () => {
const m = await mount(h(RequestDetail, {
request: req({ prompt: 1000, output: 120, cacheRead: 500 }),
}))
@@ -52,13 +51,14 @@ describe('RequestDetail header', () => {
const ms = await mount(h(RequestDetail, { request: req({}), stepsOf: (turn) => (turn === 1 ? 7 : 1) }))
assert.ok(text(query(ms.container, '.lc-detail-head')).includes('Turn 1 · Step 1 of 7'))
await ms.unmount()
- const rows = queryAll(m.container, '.lc-detail-row')
- assert.equal(rows.length, 7)
- assert.ok(text(rows[0]).includes('System Prompt'))
- assert.ok(text(rows[0]).includes('≈100'))
- const pcts = queryAll(m.container, '.lc-detail-pct').map(el => text(el))
+ const chips = queryAll(m.container, '.lc-legend .lc-chip')
+ assert.equal(chips.length, 7)
+ assert.ok(text(chips[0]).includes('System Prompt'))
+ assert.ok(text(chips[0]).includes('≈100'))
+ const pcts = queryAll(m.container, '.lc-chip-nums em').map(el => text(el))
assert.ok(pcts.some(p => p.endsWith('%')), 'percentages rendered when total > 0')
- assert.deepEqual(anatomy(m.container), Array.from({ length: 7 }, () => ROW_ANATOMY))
+ assert.equal(pcts[0], '8%', 'the share rides the used total: 100 of 1300')
+ assert.deepEqual(anatomy(m.container), Array.from({ length: 7 }, () => CHIP_ANATOMY))
await m.unmount()
})
@@ -144,7 +144,7 @@ describe('RequestDetail header', () => {
})
describe('RequestDetail delta mode', () => {
- test('signed deltas vs the previous record: net chip, signed rows, no usage chips', async () => {
+ test('signed deltas vs the previous record: net chip, signed chips, no usage chips', async () => {
const request = req({ system: 150, tools: 200, user: 250, inject: 0, assistant: 300, tool: 350, total: 1250, prompt: 900 })
const prev = req({ ...FULL, inject: 0 })
const m = await mount(h(RequestDetail, { request, prev }))
@@ -156,14 +156,14 @@ describe('RequestDetail delta mode', () => {
assert.ok(text(metric).includes('Δ 0'))
assert.ok(!metric.className.includes('lc-detail-metric-up'))
assert.ok(!metric.className.includes('lc-detail-metric-down'))
- const nums = queryAll(m.container, '.lc-detail-num')
- assert.ok(text(nums[0]).includes('+50'))
- assert.ok(nums[0].className.includes('lc-detail-num-up'))
- assert.ok(text(nums[2]).includes('-50'))
- assert.ok(nums[2].className.includes('lc-detail-num-down'))
- assert.ok(!nums[1].className.includes('lc-detail-num-up'), 'zero delta is neutral')
- assert.ok(queryAll(m.container, '.lc-detail-pct').every(el => text(el) === ''), 'no percentages in delta mode')
- assert.deepEqual(anatomy(m.container), Array.from({ length: 7 }, () => ROW_ANATOMY), 'delta rows carry no diverging bar')
+ const figs = queryAll(m.container, '.lc-chip-nums')
+ assert.ok(text(figs[0]).includes('+50'))
+ assert.ok(figs[0].className.includes('lc-chip-nums-up'))
+ assert.ok(text(figs[2]).includes('-50'))
+ assert.ok(figs[2].className.includes('lc-chip-nums-down'))
+ assert.ok(!figs[1].className.includes('lc-chip-nums-up'), 'zero delta is neutral')
+ assert.equal(queryAll(m.container, '.lc-chip-nums em').length, 0, 'a signed change carries no share')
+ assert.deepEqual(anatomy(m.container), Array.from({ length: 7 }, () => CHIP_ANATOMY), 'delta chips carry no diverging bar')
await m.unmount()
})
@@ -172,8 +172,8 @@ describe('RequestDetail delta mode', () => {
const metric = query(m.container, '.lc-detail-metric')
assert.ok(metric.className.includes('lc-detail-metric-up'))
assert.ok(text(metric).includes('Δ +1.3k'))
- assert.equal(queryAll(m.container, '.lc-detail-num-down').length, 0)
- assert.equal(queryAll(m.container, '.lc-detail-num-up').length, 6, 'every non-zero category reads up')
+ assert.equal(queryAll(m.container, '.lc-chip-nums-down').length, 0)
+ assert.equal(queryAll(m.container, '.lc-chip-nums-up').length, 6, 'every non-zero category reads up')
await m.unmount()
const m2 = await mount(h(RequestDetail, {
@@ -183,25 +183,25 @@ describe('RequestDetail delta mode', () => {
const metric2 = query(m2.container, '.lc-detail-metric')
assert.ok(metric2.className.includes('lc-detail-metric-down'))
assert.ok(text(metric2).includes('Δ -1.3k'))
- assert.equal(queryAll(m2.container, '.lc-detail-num-up').length, 0)
- assert.equal(queryAll(m2.container, '.lc-detail-num-down').length, 6)
+ assert.equal(queryAll(m2.container, '.lc-chip-nums-up').length, 0)
+ assert.equal(queryAll(m2.container, '.lc-chip-nums-down').length, 6)
await m2.unmount()
})
- test('cumulative rows with a zero total render zero figures and no percentages', async () => {
+ test('cumulative chips with a zero total render zero figures and no shares', async () => {
const m = await mount(h(RequestDetail, {
request: req({ system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 }),
}))
- const figs = queryAll(m.container, '.lc-detail-num').map(el => text(el))
+ const figs = queryAll(m.container, '.lc-chip-nums').map(el => text(el))
assert.equal(figs.length, 7)
assert.ok(figs.every(f => f === '≈0'))
- assert.ok(queryAll(m.container, '.lc-detail-pct').every(el => text(el) === ''))
+ assert.equal(queryAll(m.container, '.lc-chip-nums em').length, 0)
await m.unmount()
})
})
describe('RequestDetail composition bar hover-link', () => {
- test('the mirrored hoverKey dims the bar, lights the matching segment and detail row; the tip stays off', async () => {
+ test('the mirrored hoverKey dims the bar, lights the matching segment and chip; the tip stays off', async () => {
const m = await mount(h(RequestDetail, { request: req({}), hoverKey: 'tools' }))
const bar = query(m.container, '.lc-stacked')
assert.ok(bar.className.includes('lc-stacked-dim'), 'a mirrored hover dims the bar')
@@ -209,24 +209,24 @@ describe('RequestDetail composition bar hover-link', () => {
assert.equal(segs.length, 6, 'all six categories render')
assert.ok(segs[1].className.includes('lc-stacked-seg-on'), 'exactly the tools segment (CATS order) lights')
assert.ok(segs.filter(s => s.className.includes('lc-stacked-seg-on')).length === 1)
- const rows = queryAll(m.container, '.lc-detail-row')
- assert.ok(rows[1].className.includes('lc-detail-row-on'), 'the tools row lights too')
- assert.ok(rows.filter(r => r.className.includes('lc-detail-row-on')).length === 1)
+ const chips = queryAll(m.container, '.lc-legend .lc-chip')
+ assert.ok(chips[1].className.includes('lc-chip-on'), 'the tools chip lights too')
+ assert.ok(chips.filter(c => c.className.includes('lc-chip-on')).length === 1)
assert.equal(queryAll(m.container, '.lc-bar-tip').length, 0, 'a mirrored hover floats no tooltip')
const m2 = await mount(h(RequestDetail, { request: req({}) }))
assert.equal(query(m2.container, '.lc-stacked').className, 'lc-stacked')
- assert.equal(queryAll(m2.container, '.lc-detail-row-on').length, 0)
+ assert.equal(queryAll(m2.container, '.lc-chip-on').length, 0)
assert.equal(queryAll(m2.container, '.lc-bar-tip').length, 0)
await m2.unmount()
await m.unmount()
})
- test('delta-mode detail rows light the matching category too', async () => {
+ test('delta-mode detail chips light the matching category too', async () => {
const m = await mount(h(RequestDetail, { request: req({}), prev: req({ ...FULL }), hoverKey: 'user' }))
- const rows = queryAll(m.container, '.lc-detail-row')
- assert.ok(rows[2].className.includes('lc-detail-row-on'), 'the user row lights in delta mode')
- assert.ok(rows.filter(r => r.className.includes('lc-detail-row-on')).length === 1)
+ const chips = queryAll(m.container, '.lc-legend .lc-chip')
+ assert.ok(chips[2].className.includes('lc-chip-on'), 'the user chip lights in delta mode')
+ assert.ok(chips.filter(c => c.className.includes('lc-chip-on')).length === 1)
await m.unmount()
})
})