diff --git a/apps/agent-forge/e2e/docShots.spec.ts b/apps/agent-forge/e2e/docShots.spec.ts index a643ddde2..bebf68894 100644 --- a/apps/agent-forge/e2e/docShots.spec.ts +++ b/apps/agent-forge/e2e/docShots.spec.ts @@ -169,7 +169,8 @@ async function shoot(page: Page, screen: string) { await page.screenshot({ path: path.join(OUT, `studio-${screen}.png`) }); } -const rows = (page: Page) => page.getByRole('main').locator('table.data-table tbody tr'); +/** Data rows only: a first load shows skeleton rows (`.skeleton-row`) until the rows arrive. */ +const rows = (page: Page) => page.getByRole('main').locator('table.data-table tbody tr:not(.skeleton-row)'); test('agents: the builder with an agent', async ({ page }) => { await open(page, '/agents'); @@ -208,6 +209,8 @@ test('monitoring: fleet and charts', async ({ page }) => { test('scores: an expanded eval', async ({ page }) => { await open(page, '/evals/scores?open=e2e-eval'); + // The evals table must show its rows, not its first-load skeleton. + await expect(rows(page).filter({ hasText: 'e2e-eval' }).first()).toBeVisible(); await expect(page.getByRole('region', { name: 'Eval e2e-eval' })).toBeVisible(); await shoot(page, 'scores'); }); @@ -254,3 +257,31 @@ test('command palette: open over Traces', async ({ page }) => { await page.waitForTimeout(150); await page.screenshot({ path: path.join(OUT, 'studio-command-palette.png') }); }); + +// --- Kit states (x- shots: review-only, never used in the docs) ------------- + +test('x-kit: traces empty state', async ({ page }) => { + await page.route('**/observe/api/v1/traces?**', (route) => json(route, { traces: [], total: 0, limit: 50, offset: 0 })); + await open(page, '/traces'); + await expect(page.getByRole('main').locator('.empty-state')).toBeVisible(); + await shoot(page, 'x-kit-empty-traces'); +}); + +test('x-kit: traces table loading', async ({ page }) => { + // Never answered: the table stays in its first-load state. + await page.route('**/observe/api/v1/traces?**', () => undefined); + await open(page, '/traces'); + await expect(page.getByRole('main').locator('table.data-table[aria-busy="true"]')).toBeVisible(); + await shoot(page, 'x-kit-loading-traces'); +}); + +test('x-kit: filter bar with a facet and the custom range open', async ({ page }) => { + await open(page, '/traces'); + await expect(rows(page).first()).toBeVisible(); + const main = page.getByRole('main'); + await main.getByRole('button', { name: 'Custom' }).click(); + await main.locator('.facet-btn').first().click(); + await expect(main.locator('.facet-pop')).toBeVisible(); + await page.waitForTimeout(300); + await page.screenshot({ path: path.join(OUT, 'studio-x-kit-filters.png') }); +}); diff --git a/apps/agent-forge/e2e/revamp.spec.ts b/apps/agent-forge/e2e/revamp.spec.ts index c9441ddff..d6650ab75 100644 --- a/apps/agent-forge/e2e/revamp.spec.ts +++ b/apps/agent-forge/e2e/revamp.spec.ts @@ -34,7 +34,8 @@ function counter() { } const primaryNav = (page: Page) => page.getByRole('navigation', { name: 'Primary' }); -const bodyRows = (page: Page) => page.getByRole('main').locator('table.data-table tbody tr'); +/** Data rows only: a first load shows skeleton rows (`.skeleton-row`) until the rows arrive. */ +const bodyRows = (page: Page) => page.getByRole('main').locator('table.data-table tbody tr:not(.skeleton-row)'); async function openApp(page: Page, hash = '') { await page.goto(`${APP}${hash ? `#${hash}` : ''}`); @@ -329,3 +330,38 @@ test('queue review at 1280px: a trace tree row name never wraps one letter per l expect(box.height, `${await name.textContent()} wraps`).toBeLessThanOrEqual(line * 2 + 1); } }); + +// The traces columns add up to ~1330px: at 1280 the table is wider than the screen, so its wrap scrolls +// sideways (and the header cannot stick to the screen); at 1920 it fits (every column shown), the wrap only clips, and it sticks. +test('traces at 1280px: a table wider than the screen scrolls sideways inside its wrap', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto(`${APP}#/traces`); + await expect(bodyRows(page).first()).toBeVisible(); + const wrap = page.getByRole('main').locator('.data-table-wrap'); + await expect(wrap).toHaveClass(/dt-scroll-x/); + expect(await wrap.evaluate((w) => w.scrollWidth > w.clientWidth && getComputedStyle(w).overflowX === 'auto')).toBe(true); +}); + +test('traces at 1920px: the table header stays in view while the screen scrolls', async ({ page }) => { + await page.setViewportSize({ width: 1920, height: 800 }); + await page.goto(`${APP}#/traces`); + await expect(bodyRows(page).nth(30)).toBeVisible(); + await expect(page.getByRole('main').locator('.data-table-wrap')).not.toHaveClass(/dt-scroll-x/); + const header = page.getByRole('main').locator('table.data-table thead th').first(); + const before = (await header.boundingBox())!; + // Scroll the screen's own scroller (the nearest scrollable ancestor of the table) by 600px. + const scrolled = await header.evaluate((th) => { + let el: HTMLElement | null = th.parentElement; + while (el && !(el.scrollHeight > el.clientHeight && /auto|scroll/.test(getComputedStyle(el).overflowY))) el = el.parentElement; + if (!el) return 0; + el.scrollTop += 600; + return el.scrollTop; + }); + expect(scrolled, 'the screen scrolls').toBeGreaterThan(300); + await expect.poll(async () => (await header.boundingBox())!.y).toBeLessThan(before.y + 1); + const after = (await header.boundingBox())!; + expect(after.y, 'header still in the viewport').toBeGreaterThanOrEqual(0); + expect(after.y + after.height).toBeLessThanOrEqual(800); + // ...and it is the sticky header that kept it there, not a page that did not move. + await expect(bodyRows(page).first()).not.toBeInViewport(); +}); diff --git a/apps/agent-forge/src/__tests__/contrast.test.ts b/apps/agent-forge/src/__tests__/contrast.test.ts index fa6dd19f4..d6b270505 100644 --- a/apps/agent-forge/src/__tests__/contrast.test.ts +++ b/apps/agent-forge/src/__tests__/contrast.test.ts @@ -64,6 +64,8 @@ const PAIRS: [text: string, background: string][] = [ ['on-accent', 'accent-solid-hover'], // A switched-on chip (Requires approval) draws --accent on the accent tint. ['accent', 'accent-soft'], + // MetricCard: a good delta draws --success on the card's --surface (a bad one --danger, listed above). + ['success', 'surface'], ]; describe('theme contrast', () => { diff --git a/apps/agent-forge/src/screens/traces/TracesScreen.tsx b/apps/agent-forge/src/screens/traces/TracesScreen.tsx index b5daac6a3..c6fc63d8d 100644 --- a/apps/agent-forge/src/screens/traces/TracesScreen.tsx +++ b/apps/agent-forge/src/screens/traces/TracesScreen.tsx @@ -11,6 +11,7 @@ */ import './traces.css'; import { useEffect, useMemo } from 'react'; +import { SearchX } from 'lucide-react'; import { navigate } from '../../router/router'; import { useRoute } from '../../router/useRoute'; import { useAgents, useDaemonDown, useDaemonHealth, useTraces } from '../../observe/useObserve'; @@ -159,6 +160,7 @@ function TracesList() { loading={traces.loading} empty={ diff --git a/apps/agent-forge/src/ui/Card.tsx b/apps/agent-forge/src/ui/Card.tsx new file mode 100644 index 000000000..6e7bd5c77 --- /dev/null +++ b/apps/agent-forge/src/ui/Card.tsx @@ -0,0 +1,44 @@ +import { useId, type ReactNode } from 'react'; +import './card.css'; + +/** Props for {@link Card}. */ +interface CardProps { + /** Header title (an h2). With `as='section'` it also labels the card. */ + title?: ReactNode; + /** Muted line under the title. */ + description?: ReactNode; + /** Right side of the header row (buttons, a range picker). */ + actions?: ReactNode; + /** `'md'` (default) pads the body; `'none'` lets a DataTable run edge to edge. */ + padding?: 'md' | 'none'; + className?: string; + /** `'section'` (default) is a region labelled by its title; `'div'` a plain box. */ + as?: 'section' | 'div'; + children?: ReactNode; +} + +/** + * The Studio's panel: surface, hairline border, large radius, a soft lift, + * and an optional header row (title + description left, actions right). + */ +export function Card({ title, description, actions, padding = 'md', className, as: Tag = 'section', children }: CardProps) { + const titleId = useId(); + const labelled = Tag === 'section' && title != null; + const classes = ['card', `card-pad-${padding}`, className].filter(Boolean).join(' '); + return ( + + {title != null && ( +
+
+

+ {title} +

+ {description != null &&

{description}

} +
+ {actions != null &&
{actions}
} +
+ )} +
{children}
+
+ ); +} diff --git a/apps/agent-forge/src/ui/DataTable.tsx b/apps/agent-forge/src/ui/DataTable.tsx index 4a97ef3c5..92d41eb54 100644 --- a/apps/agent-forge/src/ui/DataTable.tsx +++ b/apps/agent-forge/src/ui/DataTable.tsx @@ -9,7 +9,9 @@ import { type ReactNode, type RefObject, } from 'react'; +import { AlertCircle, ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight } from 'lucide-react'; import { isEditableTarget } from '../canvas/isEditableTarget'; +import { SkeletonRows } from './Skeleton'; import './dataTable.css'; /** Default minimum column width (px) when a column sets no `minWidth`. */ @@ -37,6 +39,9 @@ export interface Column { hideBelow?: number; } +/** Skeleton rows shown while a first load is in flight. */ +const SKELETON_ROWS = 6; + /** The active sort: one column key and a direction. The table never sorts rows itself. */ export interface TableSort { key: string; @@ -59,6 +64,12 @@ interface DataTableProps { density?: 'compact' | 'comfortable'; loading?: boolean; empty: ReactNode; + /** + * Opt-in bounded scroller: the wrap scrolls both ways inside this height (px + * number or CSS length) and the header sticks inside it. Without it the + * header sticks to the screen's scroller while the table fits its width. + */ + maxHeight?: number | string; } type Widths = Record; @@ -122,6 +133,29 @@ function useContainerWidth(ref: RefObject): number { return width; } +/** + * True while the table is wider than its wrap (resized columns, min-widths + * past the container). Only then does the wrap scroll sideways; otherwise it + * clips, so it is no scroll container and the sticky header sticks to the + * screen's own scroller. + */ +function useOverflowsX(wrapRef: RefObject, tableRef: RefObject): boolean { + const [overflows, setOverflows] = useState(false); + useLayoutEffect(() => { + const wrap = wrapRef.current; + const table = tableRef.current; + if (!wrap || !table) return; + const next = table.offsetWidth > wrap.clientWidth + 1; + if (next !== overflows) setOverflows(next); + }); + return overflows; +} + +function wrapClass(scrollsX: boolean, maxHeight: number | string | undefined): string { + if (maxHeight !== undefined) return 'data-table-wrap dt-scroll'; + return scrollsX ? 'data-table-wrap dt-scroll-x' : 'data-table-wrap'; +} + /** Column widths keyed by column key, remembered per table id. */ function useColumnWidths(id: string | undefined) { const [widths, setWidths] = useState(() => loadWidths(id)); @@ -234,23 +268,23 @@ interface HeaderCellProps { function HeaderCell({ column, width, sort, onSortChange, onResize, onCommit }: HeaderCellProps) { const sortable = Boolean(column.sortable); const state = ariaSort(sort, column.key); - const arrow = state === 'ascending' ? '▲' : state === 'descending' ? '▼' : ''; + const Arrow = state === 'ascending' ? ArrowUp : state === 'descending' ? ArrowDown : ArrowUpDown; return ( {sortable ? ( ) : ( - {column.header} + + {column.header} + )} @@ -263,6 +297,29 @@ function cellContent(column: Column, row: T): ReactNode { return value == null ? '' : String(value); } +/** Plain-text cell content doubles as its tooltip, so ellipsised text stays readable. */ +const titleOf = (content: ReactNode) => + (typeof content === 'string' && content !== '') || typeof content === 'number' ? String(content) : undefined; + +const alignClass = (c: Column | undefined) => (c?.align === 'right' ? 'dt-right' : undefined); + +/** Props for {@link ErrorCell}. */ +interface ErrorCellProps { + children: ReactNode; + /** Tooltip; defaults to `children` when that is a string. */ + title?: string; +} + +/** An error message in a table cell: danger text behind an alert icon, truncated with a tooltip. */ +export function ErrorCell({ children, title }: ErrorCellProps) { + return ( + + + ); +} + interface BodyRowProps { row: T; index: number; @@ -283,11 +340,14 @@ function BodyRow({ row, index, columns, active, selected, onOpen }: BodyRowPr aria-current={selected ? true : undefined} onClick={openable ? (e) => !(e.target as Element).closest('a, button, input, select, textarea') && onOpen!(index) : undefined} > - {columns.map((c) => ( - - {cellContent(c, row)} - - ))} + {columns.map((c) => { + const content = cellContent(c, row); + return ( + + {content} + + ); + })} ); } @@ -331,6 +391,20 @@ function useRowNavigation(rows: T[], rowKey: (r: T) => string, onRowOpen: ((r return { tableRef, current, onKeyDown, open }; } +/** The body with no rows: skeleton rows on a first load, else `empty` in one full-width cell. */ +function EmptyBody({ columns, loading, empty }: { columns: Column[]; loading?: boolean; empty: ReactNode }) { + if (loading) { + return alignClass(columns[i])} />; + } + return ( + + + {empty} + + + ); +} + function Pager({ offset, limit, total, onChange }: NonNullable['page']>) { const fmt = (n: number) => n.toLocaleString('en-US'); // An offset past the end (total shrank under us) still reads as a valid range. @@ -340,11 +414,13 @@ function Pager({ offset, limit, total, onChange }: NonNullable {range} - - ); @@ -354,17 +430,22 @@ function Pager({ offset, limit, total, onChange }: NonNullable`, controlled sort (server-side, * rows are rendered in the order given), keyboard row navigation, resizable * columns remembered per `id`, responsive `hideBelow` columns, and a pager. + * The header is sticky against the screen's scroller; the wrap becomes a + * horizontal scroller (`.dt-scroll-x`, header no longer sticky) only while the + * table is wider than its container. Row action buttons go in an element with + * class `dt-actions` to get the compact in-row button size. */ export function DataTable(props: DataTableProps) { - const { id, columns, rows, rowKey, sort, onSortChange, onRowOpen, selectedKey, page, density = 'compact', loading, empty } = props; + const { id, columns, rows, rowKey, sort, onSortChange, onRowOpen, selectedKey, page, density = 'compact', loading, empty, maxHeight } = props; const wrapRef = useRef(null); const containerWidth = useContainerWidth(wrapRef); const { widths, setWidth, persist } = useColumnWidths(id); const { tableRef, current, onKeyDown, open } = useRowNavigation(rows, rowKey, onRowOpen); + const scrollsX = useOverflowsX(wrapRef, tableRef); const visible = columns.filter((c) => c.hideBelow === undefined || containerWidth >= c.hideBelow); const widthOf = (c: Column) => widths[c.key] ?? c.width; return ( -
+
(props: DataTableProps) { - {rows.length === 0 && !loading ? ( - - - + {rows.length === 0 ? ( + ) : ( rows.map((r, i) => ( - {icon != null && ( + {Icon && ( )}

{title}

diff --git a/apps/agent-forge/src/ui/FilterBar.tsx b/apps/agent-forge/src/ui/FilterBar.tsx index c9eafce87..5b5be3db0 100644 --- a/apps/agent-forge/src/ui/FilterBar.tsx +++ b/apps/agent-forge/src/ui/FilterBar.tsx @@ -1,4 +1,5 @@ import { useEffect, useId, useRef, useState } from 'react'; +import { ChevronDown, Search } from 'lucide-react'; import { TimeRangePicker, type TimeRange } from './TimeRangePicker'; import './filterBar.css'; @@ -35,22 +36,25 @@ function SearchBox({ value, onChange, placeholder }: NonNullable () => clearTimeout(timer.current), []); return ( - { - const next = e.target.value; - setText(next); - clearTimeout(timer.current); - timer.current = setTimeout(() => { - emitted.current = next; - latest.current(next); - }, SEARCH_DEBOUNCE_MS); - }} - /> + + ); } @@ -92,6 +96,7 @@ function Facet({ facet }: { facet: FacetDef }) { > {facet.label} {selected > 0 && {selected}} +
`; `'div'` (default) a stack of flex rows. */ + as?: 'div' | 'tr'; + /** Extra class for the cell at column `index` (table alignment, e.g. `'dt-right'`). */ + cellClassName?: (index: number) => string | undefined; +} + +const Status = () => ( + + Loading + +); + +/** Table rows: the status sits in the first cell; the other rows are hidden from assistive tech. */ +function TableRows({ rows, cols, cellClassName }: Required>) { + return ( + <> + {Array.from({ length: rows }, (_, r) => ( + 0 ? true : undefined}> + {Array.from({ length: cols }, (_, c) => ( + + ))} + + ))} + + ); +} + +/** + * A block of shimmer rows with ONE visually-hidden `role="status"` "Loading" + * text: the loading state for tables and lists. Static under reduced motion. + */ +export function SkeletonRows({ rows = 6, cols = 1, as = 'div', cellClassName = () => undefined }: SkeletonRowsProps) { + if (as === 'tr') return ; + return ( +
+ + {Array.from({ length: rows }, (_, r) => ( +
+ {Array.from({ length: cols }, (_, c) => ( + + + + ))} +
+ ))} +
+ ); +} diff --git a/apps/agent-forge/src/ui/TimeRangePicker.tsx b/apps/agent-forge/src/ui/TimeRangePicker.tsx index f35443c93..673447d4d 100644 --- a/apps/agent-forge/src/ui/TimeRangePicker.tsx +++ b/apps/agent-forge/src/ui/TimeRangePicker.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { CalendarRange } from 'lucide-react'; import './filterBar.css'; /** Preset window names, shortest first. */ @@ -54,7 +55,10 @@ interface TimeRangePickerProps { onChange: (next: TimeRange) => void; } -/** Controlled range picker: five preset buttons plus "Custom" with two datetime-local inputs and an Apply button. */ +/** + * Controlled range picker: a segmented control of five presets plus "Custom", + * which opens two datetime-local inputs and an Apply button. + */ export function TimeRangePicker({ value, onChange }: TimeRangePickerProps) { const absolute = !('preset' in value); const key = formatTimeRange(value); @@ -74,42 +78,45 @@ export function TimeRangePicker({ value, onChange }: TimeRangePickerProps) { return (
- {PRESETS.map((p) => ( +
+ {PRESETS.map((p) => ( + + ))} - ))} - +
{customOpen && (
}> +

body

+ , + ); + const card = c.querySelector('section.card') as HTMLElement; + const title = card.querySelector('.card-header h2.card-title') as HTMLElement; + expect(title.textContent).toBe('Fleet'); + expect(title.id).not.toBe(''); + expect(card.getAttribute('aria-labelledby')).toBe(title.id); + expect(card.querySelector('.card-description')?.textContent).toBe('Agents seen in the window'); + expect(card.querySelector('.card-actions button')?.textContent).toBe('Refresh'); + expect(card.querySelector('.card-body p')?.textContent).toBe('body'); + expect(card.classList.contains('card-pad-md')).toBe(true); + }); + + it('renders no header without a title, and no aria-labelledby', () => { + const c = render( + +
- {empty} -
`s for a `
+ {r === 0 && c === 0 && } + +
+ , + ); + const card = c.querySelector('div.card') as HTMLElement; + expect(card.querySelector('.card-header')).toBeNull(); + expect(card.hasAttribute('aria-labelledby')).toBe(false); + expect(card.classList.contains('card-pad-none')).toBe(true); + expect(card.classList.contains('extra')).toBe(true); + }); + + it('a titled div card keeps its header but is not labelled by it', () => { + const c = render( + + x + , + ); + const card = c.querySelector('div.card') as HTMLElement; + expect(card.querySelector('.card-title')?.textContent).toBe('Plain'); + expect(card.hasAttribute('aria-labelledby')).toBe(false); + }); +}); diff --git a/apps/agent-forge/src/ui/__tests__/DataTable.test.tsx b/apps/agent-forge/src/ui/__tests__/DataTable.test.tsx index a863782a7..80b34fb35 100644 --- a/apps/agent-forge/src/ui/__tests__/DataTable.test.tsx +++ b/apps/agent-forge/src/ui/__tests__/DataTable.test.tsx @@ -3,7 +3,7 @@ import { resolve } from 'node:path'; import { act } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DataTable, type Column, type TableSort } from '../DataTable'; +import { DataTable, ErrorCell, type Column, type TableSort } from '../DataTable'; import { render, rerender } from './render'; interface Row { @@ -115,6 +115,36 @@ describe('DataTable rendering', () => { expect(c.textContent).not.toContain('Nothing here'); }); + it('shows six skeleton rows (one cell per visible column) and one Loading status on a first load', () => { + setWidth(1200); + const c = render( r.id} loading empty="Nothing here" />); + const skeletonRows = c.querySelectorAll('tbody tr.skeleton-row'); + expect(skeletonRows).toHaveLength(6); + expect(skeletonRows[0].querySelectorAll('td')).toHaveLength(3); + expect(skeletonRows[0].querySelectorAll('td')[1].classList.contains('dt-right')).toBe(true); + expect(c.querySelectorAll('tbody [role="status"]')).toHaveLength(1); + expect(c.querySelector('tbody [role="status"]')?.textContent).toBe('Loading'); + expect(c.textContent).not.toContain('Loading…'); + // Skeleton rows are not data rows: no row index, never focusable. + expect(c.querySelector('tbody tr[data-row-index]')).toBeNull(); + setWidth(600); + expect(c.querySelectorAll('tbody tr.skeleton-row')[0].querySelectorAll('td')).toHaveLength(2); + }); + + it('keeps real rows (no skeleton) when loading with rows already shown', () => { + const c = render( r.id} loading empty="x" />); + expect(c.querySelector('.skeleton-row')).toBeNull(); + }); + + it('titles plain-text cells so truncated text keeps a tooltip', () => { + setWidth(1200); + const c = render( r.id} empty="x" />); + const [name, tokens, id] = bodyRows(c)[0].querySelectorAll('td'); + expect(name.title).toBe('alpha'); + expect(tokens.title).toBe('10 tok'); + expect(id.title).toBe('a'); + }); + it('applies the density class (compact default, comfortable on request)', () => { const c = render( r.id} empty="x" />); expect(c.querySelector('table')?.classList.contains('dt-compact')).toBe(true); @@ -395,3 +425,81 @@ describe('DataTable paging', () => { expect(btn('Previous').disabled).toBe(false); }); }); + +describe('ErrorCell', () => { + it('renders danger text with an aria-hidden 14px alert icon and a title tooltip', () => { + const c = render(upstream 529 overloaded); + const cell = c.querySelector('.dt-error') as HTMLElement; + expect(cell.textContent).toBe('upstream 529 overloaded'); + expect(cell.title).toBe('upstream 529 overloaded'); + const svg = cell.querySelector('svg') as SVGElement; + expect(svg.getAttribute('aria-hidden')).toBe('true'); + expect(svg.getAttribute('width')).toBe('14'); + expect(cell.querySelector('.dt-error-text')?.textContent).toBe('upstream 529 overloaded'); + }); + + it('takes an explicit title for non-string content', () => { + const c = render( + + boom + , + ); + expect((c.querySelector('.dt-error') as HTMLElement).title).toBe('boom: stack'); + }); +}); + +describe('DataTable layout (review fixes)', () => { + const css = (f: string) => readFileSync(resolve(__dirname, '..', f), 'utf8'); + const rule = (src: string, sel: string) => src.match(new RegExp(`(^|\\n)${sel.replace(/[.[\]()*]/g, '\\$&')}\\s*\\{([^}]*)\\}`))?.[2] ?? ''; + + it('the wrap clips instead of scrolling, so the sticky header sticks to the screen', () => { + const table = css('dataTable.css'); + expect(rule(table, '.data-table-wrap')).toMatch(/overflow-x:\s*clip/); + expect(rule(table, '.data-table-wrap')).toMatch(/min-height:\s*min-content/); + expect(rule(table, '.data-table-wrap.dt-scroll-x')).toMatch(/overflow-x:\s*auto/); + expect(rule(css('card.css'), '.card-pad-none > .card-body')).toMatch(/overflow:\s*clip/); + }); + + it('becomes a horizontal scroller (dt-scroll-x) only while the table is wider than the wrap', () => { + const offset = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800); + const client = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(800); + const c = render( r.id} empty="x" />); + const wrap = () => c.querySelector('.data-table-wrap') as HTMLElement; + expect(wrap().classList.contains('dt-scroll-x')).toBe(false); + offset.mockReturnValue(1200); + rerender( r.id} empty="y" />); + expect(wrap().classList.contains('dt-scroll-x')).toBe(true); + offset.mockReturnValue(800); + rerender( r.id} empty="z" />); + expect(wrap().classList.contains('dt-scroll-x')).toBe(false); + client.mockRestore(); + }); + + it('maxHeight opts into a bounded scroller (both axes) whose header sticks inside it', () => { + const c = render( r.id} maxHeight={480} empty="x" />); + const wrap = c.querySelector('.data-table-wrap') as HTMLElement; + expect(wrap.classList.contains('dt-scroll')).toBe(true); + expect(wrap.style.maxHeight).toBe('480px'); + rerender( r.id} maxHeight="calc(100dvh - 200px)" empty="x" />); + expect(wrap.style.maxHeight).toBe('calc(100dvh - 200px)'); + expect(rule(css('dataTable.css'), '.data-table-wrap.dt-scroll')).toMatch(/overflow:\s*auto/); + rerender( r.id} empty="x" />); + expect(wrap.classList.contains('dt-scroll')).toBe(false); + expect(wrap.style.maxHeight).toBe(''); + }); + + it('wraps header labels in .dt-label so they ellipsise inside the flex row', () => { + setWidth(1200); + const c = render( r.id} empty="x" />); + expect([...c.querySelectorAll('th .dt-label')].map((s) => s.textContent)).toEqual(['Name', 'Tokens', 'Id']); + expect(rule(css('dataTable.css'), '.dt-label')).toMatch(/text-overflow:\s*ellipsis/); + }); + + it('pager has no side padding outside an edge-to-edge card; compact buttons only in .dt-actions', () => { + const table = css('dataTable.css'); + expect(rule(table, '.dt-pager')).toMatch(/padding:\s*var\(--space-2\)\s+0;/); + expect(rule(css('card.css'), '.card-pad-none .dt-pager')).toMatch(/padding-inline/); + expect(table).not.toMatch(/\.data-table td \.btn/); + expect(rule(table, '.dt-actions .btn')).toMatch(/min-height:\s*26px/); + }); +}); diff --git a/apps/agent-forge/src/ui/__tests__/FilterBar.test.tsx b/apps/agent-forge/src/ui/__tests__/FilterBar.test.tsx index 0d26e4359..af5cdf738 100644 --- a/apps/agent-forge/src/ui/__tests__/FilterBar.test.tsx +++ b/apps/agent-forge/src/ui/__tests__/FilterBar.test.tsx @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { act } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -181,6 +183,39 @@ describe('TimeRangePicker via FilterBar', () => { }); }); +describe('FilterBar look', () => { + it('presets and Custom sit in one segmented control; the group keeps its name', () => { + render(); + const group = container.querySelector('.time-range[role="group"]') as HTMLElement; + expect(group.getAttribute('aria-label')).toBe('Time range'); + const seg = group.querySelector('.segmented') as HTMLElement; + expect([...seg.querySelectorAll('button.seg-btn')].map((b) => b.textContent)).toEqual(['15m', '1h', '24h', '7d', '30d', 'Custom']); + }); + + it('the search box carries a decorative search icon; facet buttons a chevron', () => { + const facet: FacetDef = { key: 's', label: 'Status', options: [], value: [], onChange: vi.fn() }; + render(); + expect(container.querySelector('.filter-search-wrap svg')?.getAttribute('aria-hidden')).toBe('true'); + expect(container.querySelector('input.filter-search')?.getAttribute('aria-label')).toBe('Search'); + const btn = container.querySelector('button.facet-btn') as HTMLElement; + expect(btn.textContent).toBe('Status'); + expect(btn.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true'); + }); +}); + +describe('TimeRangePicker Custom open state', () => { + it('Custom reads as open (aria-expanded) while its inputs show, and the CSS styles that state', () => { + render(); + const custom = byText('.time-range button', 'Custom'); + expect(custom.getAttribute('aria-expanded')).toBe('false'); + click(custom); + expect(custom.getAttribute('aria-expanded')).toBe('true'); + expect(byText('.time-range button', '24h').getAttribute('aria-pressed')).toBe('true'); + const css = readFileSync(resolve(__dirname, '../filterBar.css'), 'utf8'); + expect(css).toMatch(/\.seg-btn\[aria-expanded='true'\]:not\(\[aria-pressed='true'\]\)\s*\{[^}]*background:\s*var\(--accent-soft\)/); + }); +}); + describe('TimeRangePicker re-seeding', () => { const inputs = () => [...container.querySelectorAll('input[type="datetime-local"]')] as HTMLInputElement[]; const ui = (value: TimeRange) => ; diff --git a/apps/agent-forge/src/ui/__tests__/MetricCard.test.tsx b/apps/agent-forge/src/ui/__tests__/MetricCard.test.tsx new file mode 100644 index 000000000..8a83d12a8 --- /dev/null +++ b/apps/agent-forge/src/ui/__tests__/MetricCard.test.tsx @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { MetricCard } from '../MetricCard'; +import { render } from './render'; + +const delta = (c: HTMLElement) => c.querySelector('.metric-delta') as HTMLElement; +const spoken = (c: HTMLElement) => delta(c).querySelector('.visually-hidden')?.textContent; +const ms = (v: number) => `${v} ms`; + +describe('MetricCard', () => { + it('renders the label as an overline and the value as a tabular metric', () => { + const c = render(); + expect(c.querySelector('.metric-label')?.textContent).toBe('Runs'); + expect(c.querySelector('.metric-label')?.classList.contains('overline')).toBe(true); + expect(c.querySelector('.metric-value')?.textContent).toBe('1,204'); + expect(c.querySelector('.metric-value')?.classList.contains('tabular-nums')).toBe(true); + expect(c.querySelector('.metric-hint')?.textContent).toBe('last 24 h'); + expect(delta(c)).toBeNull(); + expect(c.querySelector('svg.metric-sparkline')).toBeNull(); + }); + + it('an upward move is good by default: success tone, "up" phrase', () => { + const c = render(); + expect(delta(c).dataset.tone).toBe('good'); + expect(delta(c).textContent).toContain('12%'); + expect(spoken(c)).toBe('up 12% vs previous window'); + }); + + it('a downward move is bad by default', () => { + const c = render(); + expect(delta(c).dataset.tone).toBe('bad'); + expect(spoken(c)).toBe('down 4.5% vs previous window'); + }); + + it('goodWhen "down" flips the tones (errors, latency)', () => { + const c = render(); + expect(delta(c).dataset.tone).toBe('good'); + const up = render(); + expect(delta(up).dataset.tone).toBe('bad'); + }); + + it('zero is neutral, and format / label shape the text', () => { + const c = render(); + expect(delta(c).dataset.tone).toBe('neutral'); + expect(spoken(c)).toBe('no change vs yesterday'); + const down = render(); + expect(spoken(down)).toBe('down 40 ms vs yesterday'); + }); + + it('draws an aria-hidden sparkline polyline', () => { + const c = render(); + const svg = c.querySelector('svg.metric-sparkline') as SVGElement; + expect(svg.getAttribute('aria-hidden')).toBe('true'); + const points = svg.querySelector('polyline')?.getAttribute('points')?.trim().split(' '); + expect(points).toHaveLength(4); + }); + + it('non-finite input: a NaN/Infinity delta shows no delta, non-finite sparkline points are dropped', () => { + const c = render(); + expect(delta(c)).toBeNull(); + const points = c.querySelector('svg.metric-sparkline polyline')?.getAttribute('points')?.split(' '); + expect(points).toHaveLength(3); + expect(points?.join(' ')).not.toMatch(/NaN|Infinity/); + const inf = render(); + expect(delta(inf)).toBeNull(); + expect(inf.querySelector('svg.metric-sparkline')).toBeNull(); + }); + + it('skips a sparkline with fewer than two points', () => { + const c = render(); + expect(c.querySelector('svg.metric-sparkline')).toBeNull(); + }); + + it('loading shows skeletons instead of the value, delta and sparkline', () => { + const c = render(); + expect(c.querySelector('.metric-value')?.textContent).not.toContain('1,204'); + expect(c.querySelectorAll('.skeleton').length).toBeGreaterThanOrEqual(2); + expect(c.querySelector('svg.metric-sparkline')).toBeNull(); + expect(delta(c)).toBeNull(); + expect(c.querySelector('.metric-card')?.getAttribute('aria-busy')).toBe('true'); + }); +}); diff --git a/apps/agent-forge/src/ui/__tests__/Skeleton.test.tsx b/apps/agent-forge/src/ui/__tests__/Skeleton.test.tsx new file mode 100644 index 000000000..ba93edc39 --- /dev/null +++ b/apps/agent-forge/src/ui/__tests__/Skeleton.test.tsx @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { Skeleton, SkeletonRows } from '../Skeleton'; +import { render } from './render'; + +describe('Skeleton', () => { + it('is a decorative, aria-hidden block sized by number (px) or CSS string', () => { + const c = render(); + const el = c.querySelector('.skeleton') as HTMLElement; + expect(el.getAttribute('aria-hidden')).toBe('true'); + expect(el.style.width).toBe('120px'); + expect(el.style.height).toBe('1.5em'); + expect(el.style.borderRadius).toBe('var(--radius-sm)'); + expect(c.querySelector('[role="status"]')).toBeNull(); + }); + + it('takes a radius and a className', () => { + const c = render(); + const el = c.querySelector('.skeleton') as HTMLElement; + expect(el.classList.contains('avatar')).toBe(true); + expect(el.style.borderRadius).toBe('var(--radius-full)'); + }); +}); + +describe('SkeletonRows', () => { + it('renders rows x cols aria-hidden blocks plus one visually-hidden "Loading" status', () => { + const c = render(); + expect(c.querySelectorAll('.skeleton-row')).toHaveLength(4); + expect(c.querySelectorAll('.skeleton')).toHaveLength(12); + for (const s of c.querySelectorAll('.skeleton')) expect(s.getAttribute('aria-hidden')).toBe('true'); + const status = c.querySelectorAll('[role="status"]'); + expect(status).toHaveLength(1); + expect(status[0].textContent).toBe('Loading'); + expect(status[0].classList.contains('visually-hidden')).toBe(true); + }); + + it('defaults to six rows of one column', () => { + const c = render(); + expect(c.querySelectorAll('.skeleton-row')).toHaveLength(6); + expect(c.querySelectorAll('.skeleton')).toHaveLength(6); + }); + + it('renders table rows (one status in the first cell) when as="tr"', () => { + const c = render( +
+ + (i === 2 ? 'dt-right' : undefined)} /> + +
, + ); + const trs = c.querySelectorAll('tbody tr.skeleton-row'); + expect(trs).toHaveLength(2); + expect(trs[0].querySelectorAll('td')).toHaveLength(3); + expect(trs[0].querySelectorAll('td')[2].classList.contains('dt-right')).toBe(true); + expect(c.querySelectorAll('[role="status"]')).toHaveLength(1); + expect(trs[0].querySelector('[role="status"]')?.textContent).toBe('Loading'); + expect(trs[1].getAttribute('aria-hidden')).toBe('true'); + }); +}); diff --git a/apps/agent-forge/src/ui/__tests__/primitives.test.tsx b/apps/agent-forge/src/ui/__tests__/primitives.test.tsx index 57cea14c2..1a568b69c 100644 --- a/apps/agent-forge/src/ui/__tests__/primitives.test.tsx +++ b/apps/agent-forge/src/ui/__tests__/primitives.test.tsx @@ -1,4 +1,5 @@ import { act } from 'react'; +import { Search } from 'lucide-react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { render as mount } from './render'; @@ -97,12 +98,28 @@ describe('StatusPill', () => { describe('EmptyState', () => { it('renders the title, hint, action and a decorative icon', () => { - render(Run} icon={} />); + render(Run} icon={Search} />); const root = container.querySelector('.empty-state') as HTMLElement; expect(root.querySelector('.empty-state-title')?.textContent).toBe('No traces yet'); expect(root.querySelector('.empty-state-hint')?.textContent).toBe('Run an agent.'); expect(root.querySelector('.empty-state-action button')?.textContent).toBe('Run'); expect(root.querySelector('.empty-state-icon')?.getAttribute('aria-hidden')).toBe('true'); + const svg = root.querySelector('.empty-state-icon svg') as SVGElement; + expect(svg.classList.contains('lucide-search')).toBe(true); + expect(svg.getAttribute('width')).toBe('20'); + expect(svg.getAttribute('stroke-width')).toBe('1.75'); + }); + + it('defaults to the Inbox icon tile, and icon={null} renders no tile', () => { + render( + <> + + + , + ); + const [a, b] = [...container.querySelectorAll('.empty-state')]; + expect(a.querySelector('.empty-state-icon svg')?.classList.contains('lucide-inbox')).toBe(true); + expect(b.querySelector('.empty-state-icon')).toBeNull(); }); it('accepts a hint without an action, and an action without a hint', () => { diff --git a/apps/agent-forge/src/ui/card.css b/apps/agent-forge/src/ui/card.css new file mode 100644 index 000000000..a47af10fb --- /dev/null +++ b/apps/agent-forge/src/ui/card.css @@ -0,0 +1,65 @@ +/* Card: surface, hairline border, radius-lg, shadow-sm; optional header row. Tokens only. */ +.card { + display: flex; + flex-direction: column; + min-width: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + color: var(--text); + font-family: var(--font-ui); +} +.card-header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-2) var(--space-4); + padding: var(--space-4) var(--space-5) 0; +} +.card-heading { + min-width: 0; +} +.card-title { + margin: 0; + font-size: var(--fs-body); + font-weight: 600; + line-height: 1.4; + color: var(--text); +} +.card-description { + margin: 2px 0 0; + font-size: var(--fs-meta); + line-height: 1.5; + color: var(--text-muted); +} +.card-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} +.card-pad-md > .card-body { + padding: var(--space-4) var(--space-5); +} +/* edge to edge: a titled card still separates the header from the table with the table's own header. */ +.card-pad-none > .card-header { + padding-bottom: var(--space-3); +} +.card-pad-none > .card-body { + /* clip, not hidden: no scroll container, so a DataTable's sticky header still sticks. */ + overflow: clip; + border-radius: 0 0 var(--radius-lg) var(--radius-lg); +} +.card-pad-none > .card-body:first-child { + border-radius: var(--radius-lg); +} +/* edge-to-edge tables: the pager keeps the card's inner gutter. */ +.card-pad-none .dt-pager { + padding-inline: var(--space-3); +} +/* a table that ends the card lets the card's own border close it. */ +.card-pad-none > .card-body > .data-table-wrap:last-child .data-table tbody tr:last-child > td { + border-bottom: 0; +} diff --git a/apps/agent-forge/src/ui/dataTable.css b/apps/agent-forge/src/ui/dataTable.css index 14a9326ea..6c8018d80 100644 --- a/apps/agent-forge/src/ui/dataTable.css +++ b/apps/agent-forge/src/ui/dataTable.css @@ -1,13 +1,30 @@ -/* DataTable: fills its container, 13 px text, 32 px (compact) / 40 px (comfortable) rows. Tokens only. */ +/* + * DataTable: fills its container (a Card with padding="none" in the redesign), + * 13px text, sticky 36px header, 32px (compact) / 40px (comfortable) rows, + * hairline dividers, tabular right-aligned numbers. Tokens only. + */ .data-table-wrap { width: 100%; min-width: 0; + /* never squeezed below its rows by a height-limited flex column. */ + min-height: min-content; + /* clip, not auto: a clipping wrap is no scroll container, so the sticky header sticks to the screen. */ + overflow-x: clip; +} +/* the table is wider than the wrap (resized columns): scroll sideways instead; the header stops sticking. */ +.data-table-wrap.dt-scroll-x { overflow-x: auto; } +/* maxHeight: a bounded scroller in both axes; the sticky header sticks inside it. */ +.data-table-wrap.dt-scroll { + overflow: auto; + min-height: 0; +} .data-table { width: 100%; table-layout: fixed; - border-collapse: collapse; + border-collapse: separate; + border-spacing: 0; font-family: var(--font-ui); font-size: 13px; color: var(--text); @@ -21,43 +38,86 @@ overflow: hidden; text-overflow: ellipsis; } -.dt-compact th, +.data-table th { + position: sticky; + top: 0; + z-index: 1; + height: 36px; + background: var(--surface-2); + color: var(--text-muted); + font-size: var(--fs-meta); + font-weight: 500; +} .dt-compact td { height: 32px; } -.dt-comfortable th, .dt-comfortable td { height: 40px; } -.data-table th { - position: relative; - background: var(--surface-2); - color: var(--text-muted); - font-weight: 600; +/* the first column names the row. */ +.data-table td:first-child { + font-weight: 500; } .data-table .dt-right { text-align: right; + font-variant-numeric: tabular-nums; } +.dt-head, .dt-sort { display: inline-flex; align-items: center; - gap: 4px; + gap: var(--space-1); + width: 100%; + min-width: 0; +} +.dt-right .dt-head { + justify-content: flex-end; +} +/* the header label keeps the th ellipsis inside the flex row. */ +.dt-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dt-right .dt-sort { + flex-direction: row-reverse; +} +.dt-sort { padding: 0; background: none; border: 0; + border-radius: var(--radius-sm); color: inherit; font: inherit; cursor: pointer; } +.dt-sort:hover, +th[aria-sort="ascending"] .dt-sort, +th[aria-sort="descending"] .dt-sort { + color: var(--text); +} .dt-sort:focus-visible, -.dt-resize:focus-visible, +.dt-resize:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} .dt-row-open:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .dt-sort-arrow { + flex: none; color: var(--accent); - font-size: 10px; +} +.dt-sort-idle { + color: var(--text-faint); + opacity: 0; + transition: opacity var(--dur-fast) var(--ease); +} +.dt-sort:hover .dt-sort-idle, +.dt-sort:focus-visible .dt-sort-idle { + opacity: 1; } .dt-resize { position: absolute; @@ -68,8 +128,25 @@ cursor: col-resize; touch-action: none; } -.dt-resize:hover { - background: var(--accent-soft); +.dt-resize::after { + content: ""; + position: absolute; + top: 10px; + bottom: 10px; + right: 2px; + width: 1px; + background: var(--border); +} +.dt-resize:hover::after, +.dt-resize:focus-visible::after { + top: 0; + bottom: 0; + width: 2px; + right: 2px; + background: var(--accent); +} +.data-table tbody tr { + transition: background-color var(--dur-fast) var(--ease); } .dt-row-open { cursor: pointer; @@ -83,36 +160,62 @@ .dt-row-selected:focus { background: var(--accent-soft); } -.data-table[aria-busy='true'] tbody { +.dt-row-selected > td:first-child { + box-shadow: inset 2px 0 0 var(--accent); +} +.data-table[aria-busy="true"] tbody tr:not(.skeleton-row) { opacity: 0.6; } +/* row actions (inside an element with class dt-actions) stay inside the row height. */ +.dt-actions .btn { + min-height: 26px; + padding: 0 8px; + font-size: 12px; + border-radius: var(--radius-sm); +} .data-table .dt-empty { height: auto; - padding: 24px 10px; + padding: 0 10px; white-space: normal; text-align: center; + font-weight: 400; color: var(--text-muted); } +/* ErrorCell: danger text behind an alert icon, ellipsised. */ +.dt-error { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + color: var(--danger); + font-weight: 400; +} +.dt-error svg { + flex: none; +} +.dt-error-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .dt-pager { display: flex; align-items: center; justify-content: flex-end; - gap: 8px; - padding: 8px 0; + gap: var(--space-2); + padding: var(--space-2) 0; font-family: var(--font-ui); - font-size: 13px; + font-size: var(--fs-meta); color: var(--text-muted); } -.dt-pager button { - padding: 4px 10px; - background: var(--surface); - color: var(--text); - border: 1px solid var(--border); - border-radius: 6px; - font: inherit; - cursor: pointer; +.dt-pager-range { + margin-right: var(--space-1); + font-variant-numeric: tabular-nums; } -.dt-pager button:disabled { - opacity: 0.5; - cursor: not-allowed; +@media (prefers-reduced-motion: reduce) { + .dt-sort-idle, + .data-table tbody tr { + transition: none; + } } diff --git a/apps/agent-forge/src/ui/filterBar.css b/apps/agent-forge/src/ui/filterBar.css index 8c8d7df5e..a9168b624 100644 --- a/apps/agent-forge/src/ui/filterBar.css +++ b/apps/agent-forge/src/ui/filterBar.css @@ -1,105 +1,243 @@ -/* FilterBar, facet popovers and TimeRangePicker. Tokens only; the bar wraps at narrow widths. */ +/* FilterBar, facet popovers and the TimeRangePicker segmented control. Tokens only; the bar wraps at narrow widths. */ .filter-bar { display: flex; flex-wrap: wrap; align-items: center; - gap: 8px; + gap: var(--space-2); min-width: 0; } -.filter-search { +.filter-search-wrap { + position: relative; + display: flex; flex: 1 1 200px; min-width: 0; - padding: 6px 10px; +} +.filter-search-icon { + position: absolute; + top: 50%; + left: 10px; + transform: translateY(-50%); + color: var(--text-faint); + pointer-events: none; +} +.filter-search { + flex: 1 1 auto; + min-width: 0; + height: 32px; + padding: 0 10px 0 32px; background: var(--surface); color: var(--text); - border: 1px solid var(--border); - border-radius: 6px; + border: 1px solid var(--border-strong); + border-radius: var(--radius); font-family: var(--font-ui); + font-size: 13px; + transition: + border-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease); +} +.filter-search::placeholder { + color: var(--text-faint); + opacity: 1; +} +.filter-search:focus { + outline: 2px solid transparent; + outline-offset: -1px; + border-color: var(--accent); + box-shadow: var(--ring); } .facet { position: relative; } -.facet-btn, -.seg-btn, -.facet-clear { - padding: 6px 10px; +.facet-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 10px 0 12px; background: var(--surface); color: var(--text); - border: 1px solid var(--border); - border-radius: 6px; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); font-family: var(--font-ui); + font-size: 13px; + font-weight: 500; cursor: pointer; + transition: background-color var(--dur-fast) var(--ease); } -.seg-btn[aria-pressed='true'] { - background: var(--accent-soft); - color: var(--accent); +.facet-btn:hover { + background: var(--surface-2); +} +.facet-btn[aria-expanded='true'] { + background: var(--surface-2); border-color: var(--accent); } -.seg-btn:disabled { - opacity: 0.5; - cursor: not-allowed; +.facet-chevron { + color: var(--text-faint); + transition: transform var(--dur-fast) var(--ease); +} +.facet-btn[aria-expanded='true'] .facet-chevron { + transform: rotate(180deg); } .facet-count { - margin-left: 6px; - padding: 0 6px; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; background: var(--accent-soft); color: var(--accent); - border-radius: 8px; - font-size: 12px; + border-radius: var(--radius-full); + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; } .facet-pop { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; - min-width: 180px; + min-width: 200px; max-width: calc(100vw - 32px); max-height: 280px; overflow: auto; - padding: 6px; + padding: var(--space-1); background: var(--surface); border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius); box-shadow: var(--shadow); } .facet-opt { display: flex; align-items: center; - gap: 8px; - padding: 4px 6px; + gap: var(--space-2); + min-height: 32px; + padding: 0 var(--space-2); + border-radius: var(--radius-sm); color: var(--text); + font-size: 13px; cursor: pointer; } +.facet-opt:hover { + background: var(--surface-2); +} +.facet-opt input { + margin: 0; + accent-color: var(--accent-solid); +} .facet-opt-count { margin-left: auto; - color: var(--text-muted); - font-family: var(--font-mono); + color: var(--text-faint); font-size: 12px; + font-variant-numeric: tabular-nums; } .facet-clear { width: 100%; - margin-top: 4px; + min-height: 30px; + margin-top: var(--space-1); + padding: 0 var(--space-2); + background: none; color: var(--text-muted); + border: 0; + border-top: 1px solid var(--border); + border-radius: 0 0 var(--radius-sm) var(--radius-sm); + font-family: var(--font-ui); + font-size: 12px; + font-weight: 500; + cursor: pointer; +} +.facet-clear:hover { + color: var(--text); + background: var(--surface-2); } +.facet-btn:focus-visible, +.facet-clear:focus-visible, +.seg-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* ---------- TimeRangePicker: segmented control + custom range row ---------- */ .time-range { display: flex; flex-wrap: wrap; align-items: center; + gap: var(--space-2); + min-width: 0; +} +.segmented { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; + padding: 2px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.seg-btn { + display: inline-flex; + align-items: center; gap: 4px; + height: 26px; + padding: 0 10px; + background: transparent; + color: var(--text-muted); + border: 0; + border-radius: var(--radius-sm); + font-family: var(--font-ui); + font-size: 12px; + font-weight: 500; + font-variant-numeric: tabular-nums; + cursor: pointer; + transition: + background-color var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} +.seg-btn:hover { + color: var(--text); +} +/* Custom while its inputs are open and no absolute range is applied yet. */ +.seg-btn[aria-expanded='true']:not([aria-pressed='true']) { + background: var(--accent-soft); + color: var(--accent); +} +.seg-btn[aria-pressed='true'] { + background: var(--surface); + color: var(--text); + box-shadow: + var(--shadow-sm), + 0 0 0 1px var(--border); } .time-range-custom { display: flex; flex-wrap: wrap; align-items: center; - gap: 8px; + gap: var(--space-2); color: var(--text-muted); + font-size: 12px; + font-weight: 500; } -.time-range-custom input { - margin-left: 4px; - padding: 4px 6px; - background: var(--surface); - color: var(--text); - border: 1px solid var(--border); - border-radius: 6px; - font-family: var(--font-ui); +.time-range-custom label { + display: inline-flex; + align-items: center; + gap: 6px; +} +.time-range-custom .input { + width: auto; + min-height: 28px; + height: 28px; + padding: 0 8px; + font-size: 12px; + font-variant-numeric: tabular-nums; + color-scheme: inherit; +} +@media (prefers-reduced-motion: reduce) { + .filter-search, + .facet-btn, + .facet-chevron, + .seg-btn { + transition: none; + } } diff --git a/apps/agent-forge/src/ui/metricCard.css b/apps/agent-forge/src/ui/metricCard.css new file mode 100644 index 000000000..fc406205e --- /dev/null +++ b/apps/agent-forge/src/ui/metricCard.css @@ -0,0 +1,75 @@ +/* MetricCard: a KPI tile (overline label, 24px tabular value, delta, sparkline). Tokens only. */ +.metric-card { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-3); + min-width: 0; + padding: var(--space-4) var(--space-5); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + font-family: var(--font-ui); +} +.metric-main { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 0; +} +.metric-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.metric-value { + display: flex; + align-items: center; + min-height: 32px; + font-size: var(--fs-metric); + font-weight: 600; + line-height: 1.3; + letter-spacing: -0.01em; + color: var(--text); + white-space: nowrap; +} +.metric-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-1) var(--space-2); + font-size: var(--fs-meta); + color: var(--text-muted); +} +.metric-delta { + display: inline-flex; + align-items: center; + gap: 2px; + font-weight: 500; + font-variant-numeric: tabular-nums; + color: var(--text-muted); +} +.metric-delta[data-tone="good"] { + color: var(--success); +} +.metric-delta[data-tone="bad"] { + color: var(--danger); +} +.metric-sparkline, +.metric-spark-skeleton { + flex: none; + width: 96px; + height: 32px; +} +.metric-sparkline { + overflow: visible; +} +.metric-sparkline polyline { + fill: none; + stroke: var(--chart-1); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} diff --git a/apps/agent-forge/src/ui/primitives.css b/apps/agent-forge/src/ui/primitives.css index 8430b8163..fcdeac814 100644 --- a/apps/agent-forge/src/ui/primitives.css +++ b/apps/agent-forge/src/ui/primitives.css @@ -36,30 +36,39 @@ display: flex; flex-direction: column; align-items: center; - gap: 8px; - padding: 32px 16px; + gap: var(--space-2); + padding: var(--space-10) var(--space-4); text-align: center; color: var(--text-muted); font-family: var(--font-ui); } .empty-state-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin-bottom: var(--space-1); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-lg); color: var(--text-muted); - font-size: 24px; - line-height: 1; } .empty-state-title { margin: 0; color: var(--text); font-size: 14px; font-weight: 600; + line-height: 1.4; } .empty-state-hint { margin: 0; font-size: 13px; + line-height: 1.5; max-width: 48ch; } .empty-state-action { - margin-top: 4px; + margin-top: var(--space-2); } /* ---------- Toast ---------- */ diff --git a/apps/agent-forge/src/ui/skeleton.css b/apps/agent-forge/src/ui/skeleton.css new file mode 100644 index 000000000..c46b8c998 --- /dev/null +++ b/apps/agent-forge/src/ui/skeleton.css @@ -0,0 +1,48 @@ +/* Skeleton + SkeletonRows: shimmer blocks (surface-2 -> surface-3), static under reduced motion. Tokens only. */ +.skeleton { + display: block; + width: 100%; + height: 12px; + max-width: 100%; + background-color: var(--surface-2); + background-image: linear-gradient(90deg, var(--surface-2) 0%, var(--surface-3) 50%, var(--surface-2) 100%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.4s var(--ease) infinite; +} +@keyframes skeleton-shimmer { + from { + background-position: 100% 0; + } + to { + background-position: -100% 0; + } +} +.skeleton-rows { + display: flex; + flex-direction: column; +} +.skeleton-row { + display: flex; + align-items: center; + gap: var(--space-4); + min-height: 32px; +} +.skeleton-cell { + display: flex; + flex: 1 1 0; + min-width: 0; +} +/* table rows keep their table layout; right-aligned cells push the block right. */ +tr.skeleton-row { + display: table-row; +} +.skeleton-cell.dt-right .skeleton, +td.dt-right > .skeleton { + margin-left: auto; +} +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + background-image: none; + } +}