diff --git a/web/packages/common/src/components/DeltaText/DeltaText.stories.tsx b/web/packages/common/src/components/DeltaText/DeltaText.stories.tsx new file mode 100644 index 0000000000..8ccd6c6b0e --- /dev/null +++ b/web/packages/common/src/components/DeltaText/DeltaText.stories.tsx @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + DeltaText, + type DeltaTextSize, + formatSignedDelta, +} from '@nemo/common/src/components/DeltaText/index'; +import { Flex, Stack, Text } from '@nvidia/foundations-react-core'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + component: DeltaText, + title: 'Studio Common/DeltaText', + args: { size: 'md' }, + argTypes: { + size: { control: 'inline-radio', options: ['xs', 'sm', 'md', 'lg', 'xl'] }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Improved: Story = { + args: { value: 0.07 }, +}; + +export const Regressed: Story = { + args: { value: -0.07 }, +}; + +export const Unchanged: Story = { + args: { value: 0 }, +}; + +/** Latency fell, which is the good news — the triangle points down but the text stays green. */ +export const LowerIsBetter: Story = { + args: { value: -120, higherIsBetter: false, format: (value) => `${value.toFixed(0)} ms` }, +}; + +/** + * A qualifier after the value — what the delta is measured against — rides along in `format`, so it + * picks up the same tint and sits on the same line as the number. + * + * `kind` rather than `size` here because the design's 12px falls between the `xs` and `sm` steps. + */ +export const WithQualifier: Story = { + args: { + value: -18, + higherIsBetter: false, + format: (value) => `${formatSignedDelta(value, 0)}% vs baseline`, + kind: 'body/semibold/sm', + }, +}; + +const SIZES: [DeltaTextSize, string][] = [ + ['xs', '10px'], + ['sm', '14px'], + ['md', '18px'], + ['lg', '24px'], + ['xl', '32px'], +]; + +/** The glyph is sized in `em`, so it holds the text's cap height at every step. */ +export const Sizes: Story = { + render: () => ( + + {SIZES.map(([size, px]) => ( + + + {size} · {px} + + + + + + ))} + + ), +}; + +/** The compact `xs` treatment the design uses, sitting under the value it qualifies. */ +export const InContext: Story = { + render: () => ( + + + 0.84 + + + + ), +}; diff --git a/web/packages/common/src/components/DeltaText/DeltaText.test.tsx b/web/packages/common/src/components/DeltaText/DeltaText.test.tsx new file mode 100644 index 0000000000..56ab990ba4 --- /dev/null +++ b/web/packages/common/src/components/DeltaText/DeltaText.test.tsx @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { DeltaText, deltaTone, formatSignedDelta } from '@nemo/common/src/components/DeltaText'; +import { render, screen } from '@testing-library/react'; + +describe('formatSignedDelta', () => { + it('signs the value and uses a true minus', () => { + expect(formatSignedDelta(0.07)).toBe('+0.07'); + expect(formatSignedDelta(-0.07)).toBe('−0.07'); + expect(formatSignedDelta(0)).toBe('0.00'); + expect(formatSignedDelta(1.25, 1)).toBe('+1.3'); + }); +}); + +describe('deltaTone', () => { + it('flips with the metric direction', () => { + expect(deltaTone(1)).toBe('improved'); + expect(deltaTone(-1)).toBe('regressed'); + expect(deltaTone(1, false)).toBe('regressed'); + expect(deltaTone(-1, false)).toBe('improved'); + expect(deltaTone(0, false)).toBe('unchanged'); + }); +}); + +describe('DeltaText', () => { + it('renders a rise as an improvement', () => { + render(); + + const delta = screen.getByTestId('delta-text'); + expect(delta).toHaveAttribute('data-delta', 'improved'); + expect(delta).toHaveTextContent('+0.07'); + expect(delta).toHaveAccessibleName('Improved by 0.07'); + }); + + it('tints a fall as a regression and points the triangle down', () => { + render(); + + expect(screen.getByTestId('delta-text')).toHaveAttribute('data-delta', 'regressed'); + expect(screen.getByTestId('delta-text-icon')).toHaveClass('rotate-180'); + }); + + it('reads a fall as an improvement when lower is better', () => { + render(); + + expect(screen.getByTestId('delta-text')).toHaveAttribute('data-delta', 'improved'); + }); + + it('keeps an equals glyph in the gutter when nothing moved', () => { + render(); + + expect(screen.getByTestId('delta-text')).toHaveAttribute('data-delta', 'unchanged'); + expect(screen.getByTestId('delta-text')).toHaveAccessibleName('No change'); + + // Same gutter as a moved row, so a column of deltas stays aligned. + const icon = screen.getByTestId('delta-text-icon'); + expect(icon).toHaveAttribute('width', '0.72em'); + expect(icon).not.toHaveClass('fill-current'); + }); + + it('sizes the glyph in em so it follows the text at every step', () => { + const { rerender } = render(); + expect(screen.getByTestId('delta-text-icon')).toHaveAttribute('width', '0.72em'); + + rerender(); + const icon = screen.getByTestId('delta-text-icon'); + expect(icon).toHaveAttribute('width', '0.72em'); + expect(icon).toHaveAttribute('height', '0.72em'); + }); + + it('steps the type scale with `size`', () => { + const { rerender } = render(); + expect(screen.getByTestId('delta-text')).toHaveClass('nv-text--body-semibold-xs'); + + // `lg` reaches past the adjacent step onto 24px, so the top of the scale reads as large. + rerender(); + expect(screen.getByTestId('delta-text')).toHaveClass('nv-text--body-semibold-2xl'); + + rerender(); + expect(screen.getByTestId('delta-text')).toHaveClass('nv-text--body-semibold-3xl'); + }); + + it('lets `kind` override the weight `size` picks', () => { + render(); + + expect(screen.getByTestId('delta-text')).toHaveClass('nv-text--label-regular-sm'); + expect(screen.getByTestId('delta-text')).not.toHaveClass('nv-text--body-semibold-2xl'); + }); + + it('carries a trailing qualifier through `format`', () => { + render( + `${formatSignedDelta(value, 0)}% vs baseline`} + /> + ); + + const delta = screen.getByTestId('delta-text'); + expect(delta).toHaveTextContent('−18% vs baseline'); + // Lower is better here, so a fall is the good news. + expect(delta).toHaveAttribute('data-delta', 'improved'); + expect(delta).toHaveAccessibleName('Improved by 18% vs baseline'); + }); + + it('honors a custom format', () => { + render( `${v} ms`} />); + + expect(screen.getByTestId('delta-text')).toHaveTextContent('-120 ms'); + }); + + it('stays neutral when a positive value rounds to zero at the default precision', () => { + render(); + + const delta = screen.getByTestId('delta-text'); + expect(delta).toHaveTextContent('0.00'); + expect(delta).toHaveAttribute('data-delta', 'unchanged'); + expect(delta).toHaveAccessibleName('No change'); + + const icon = screen.getByTestId('delta-text-icon'); + expect(icon).not.toHaveClass('fill-current'); + expect(icon).not.toHaveClass('rotate-180'); + }); + + it('stays neutral when a negative value rounds to zero at the default precision', () => { + render(); + + const delta = screen.getByTestId('delta-text'); + expect(delta).toHaveAttribute('data-delta', 'unchanged'); + expect(delta).toHaveAccessibleName('No change'); + + const icon = screen.getByTestId('delta-text-icon'); + expect(icon).not.toHaveClass('fill-current'); + expect(icon).not.toHaveClass('rotate-180'); + }); + + it('stays neutral when a value rounds to zero at a coarser precision', () => { + const format = (v: number) => formatSignedDelta(v, 0); + + const { rerender } = render(); + expect(screen.getByTestId('delta-text')).toHaveAttribute('data-delta', 'unchanged'); + + rerender(); + expect(screen.getByTestId('delta-text')).toHaveAttribute('data-delta', 'unchanged'); + }); +}); diff --git a/web/packages/common/src/components/DeltaText/index.tsx b/web/packages/common/src/components/DeltaText/index.tsx new file mode 100644 index 0000000000..834691fc9e --- /dev/null +++ b/web/packages/common/src/components/DeltaText/index.tsx @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Text } from '@nvidia/foundations-react-core'; +import cn from 'classnames'; +import { Equal, Triangle } from 'lucide-react'; +import type { ComponentProps, FC } from 'react'; + +export type DeltaTone = 'improved' | 'regressed' | 'unchanged'; + +export type DeltaTextSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + +type TextKind = ComponentProps['kind']; + +export interface DeltaTextProps { + value: number; + /** When false, a lower value is the improvement (latency, cost, error rate). */ + higherIsBetter?: boolean; + format?: (value: number) => string; + /** + * One step of the type scale, carrying the glyph with it. `xs` is the compact treatment the + * design uses inside cards and table cells; step up when the delta sits beside a larger value. + */ + size?: DeltaTextSize; + /** + * Escape hatch for a caller that needs a different weight or family than the semibold `size` + * picks — e.g. `label/regular/sm`. Wins over `size`, and the glyph still tracks it. + */ + kind?: TextKind; + 'aria-label'?: string; + className?: string; +} + +const TONE_CLASS_NAME: Record = { + improved: 'text-[color:var(--text-color-brand)]', + regressed: 'text-[color:var(--text-color-accent-red)]', + unchanged: 'text-secondary', +}; + +/** + * Spread across the full type scale — 10, 14, 18, 24, 32px — rather than the adjacent steps, which + * bunch between 10 and 18 and leave nothing that reads as large next to a headline figure. Each + * step is roughly a third up on the one below it, so the difference is visible at a glance. + * + * The glyph is sized in `em`, so picking the step here is the whole job — the triangle follows the + * text rather than needing its own scale. + */ +const SIZE_KIND: Record = { + xs: 'body/semibold/xs', + sm: 'body/semibold/md', + md: 'body/semibold/xl', + lg: 'body/semibold/2xl', + xl: 'body/semibold/3xl', +}; + +/** + * Signs the value and keeps the minus a true minus (U+2212) rather than a hyphen, so a column of + * deltas lines up against `tabular-nums`. + */ +export const formatSignedDelta = (value: number, fractionDigits = 2): string => + `${value > 0 ? '+' : value < 0 ? '−' : ''}${Math.abs(value).toFixed(fractionDigits)}`; + +/** Which direction of travel is good news, and so which way the delta is tinted. */ +export const deltaTone = (value: number, higherIsBetter = true): DeltaTone => { + if (value === 0) { + return 'unchanged'; + } + return (higherIsBetter ? value > 0 : value < 0) ? 'improved' : 'regressed'; +}; + +/** + * A signed metric change as bare tinted text with a triangle: green for an improvement, red for a + * regression. + */ +export const DeltaText: FC = ({ + value, + higherIsBetter = true, + format = formatSignedDelta, + size = 'xs', + kind, + 'aria-label': ariaLabel, + className, +}) => { + const formattedValue = format(value); + const roundedValue = Number.parseFloat(formattedValue.replace('−', '-')); + const tone = deltaTone(roundedValue, higherIsBetter); + const Icon = roundedValue === 0 ? Equal : Triangle; + const magnitude = formattedValue.replace(/^[+−-]/, ''); + const label = + ariaLabel ?? + (tone === 'unchanged' + ? 'No change' + : `${tone === 'improved' ? 'Improved' : 'Regressed'} by ${magnitude}`); + + return ( + + + {formattedValue} + + ); +}; diff --git a/web/packages/storybook/.storybook/main.ts b/web/packages/storybook/.storybook/main.ts index 6405032f25..e8ee535549 100644 --- a/web/packages/storybook/.storybook/main.ts +++ b/web/packages/storybook/.storybook/main.ts @@ -9,11 +9,7 @@ import svgr from 'vite-plugin-svgr'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const config: StorybookConfig = { - stories: [ - '../../studio/src/**/*.stories.@(ts|tsx)', - '../../common/src/**/*.stories.@(ts|tsx)', - '../../sandbox/**/*.stories.@(ts|tsx)', - ], + stories: ['../../studio/src/**/*.stories.@(ts|tsx)', '../../common/src/**/*.stories.@(ts|tsx)'], staticDirs: ['../public'], addons: ['@storybook/addon-a11y'], framework: { diff --git a/web/packages/studio/src/components/charts/MetricTrend/index.tsx b/web/packages/studio/src/components/charts/MetricTrend/index.tsx index 57937bc921..81e0c51f9a 100644 --- a/web/packages/studio/src/components/charts/MetricTrend/index.tsx +++ b/web/packages/studio/src/components/charts/MetricTrend/index.tsx @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + type DeltaTone, + deltaTone, + formatSignedDelta, +} from '@nemo/common/src/components/DeltaText'; import { Flex, Stack, Tag, Text } from '@nvidia/foundations-react-core'; import { SeriesButtonGroup } from '@studio/components/charts/MetricTrend/SeriesButtonGroup'; import { useNvColorMode } from '@studio/components/DagCanvas/useNvColorMode'; @@ -66,8 +71,14 @@ const AREA_GRADIENT = { const formatPercent = (value: number): string => `${value.toFixed(1)}%`; -const formatSignedDelta = (delta: number): string => - `${delta > 0 ? '+' : delta < 0 ? '−' : ''}${Math.abs(delta).toFixed(1)}`; +const formatTrendDelta = (delta: number): string => formatSignedDelta(delta, 1); + +/** The chip form of the same green/red/muted scale {@link DeltaText} paints as bare text. */ +const TAG_COLOR: Record = { + improved: 'green', + regressed: 'red', + unchanged: 'gray', +}; /** * The latest value for the selected series, its change over the compared period, and a trendline @@ -82,7 +93,7 @@ export const MetricTrend: FC = ({ selectedSeriesId, onSeriesChange, formatValue = formatPercent, - formatDelta = formatSignedDelta, + formatDelta = formatTrendDelta, chartHeight = DEFAULT_CHART_HEIGHT, isPending = false, className, @@ -105,14 +116,14 @@ export const MetricTrend: FC = ({ }; const delta = active?.delta; - const isNegative = delta !== undefined && delta < 0; - const isZero = delta === 0; - const deltaColor = isZero ? 'gray' : isNegative ? 'red' : 'green'; + const roundedDelta = + delta !== undefined ? Number.parseFloat(formatDelta(delta).replace('−', '-')) : undefined; + const isNegative = roundedDelta !== undefined && roundedDelta < 0; + const isZero = roundedDelta === 0; + const deltaColor = TAG_COLOR[deltaTone(roundedDelta ?? 0)]; const lineColor = isNegative ? 'var(--text-color-accent-red)' : 'var(--text-color-brand)'; const colorMode = useNvColorMode(); const gradient = colorMode === 'dark' ? AREA_GRADIENT.dark : AREA_GRADIENT.light; - // A single datapoint has no line to draw, so an AreaChart shows only a lone dot. Render a - // flat ReferenceLine across the surface instead, per the design. const isSingle = active?.points.length === 1; return ( diff --git a/web/packages/studio/src/components/charts/MetricTrendPanel/MetricTrendPanel.test.tsx b/web/packages/studio/src/components/charts/MetricTrendPanel/MetricTrendPanel.test.tsx index e41d3a34e2..720948c9f1 100644 --- a/web/packages/studio/src/components/charts/MetricTrendPanel/MetricTrendPanel.test.tsx +++ b/web/packages/studio/src/components/charts/MetricTrendPanel/MetricTrendPanel.test.tsx @@ -91,6 +91,26 @@ describe('MetricTrendPanel', () => { expect(deltaTag.querySelector('svg')).not.toBeInTheDocument(); }); + it('renders a neutral delta when a nonzero delta rounds to zero at the display precision', () => { + render(); + + const deltaTag = screen.getByTestId('nv-tag-root'); + expect(deltaTag).toHaveTextContent('+0.0'); + expect(deltaTag.className).toContain('nv-tag--color-gray'); + expect(deltaTag.querySelector('svg')).not.toBeInTheDocument(); + }); + + it('renders a neutral delta when a negative delta rounds to zero at the display precision', () => { + render( + + ); + + const deltaTag = screen.getByTestId('nv-tag-root'); + expect(deltaTag).toHaveTextContent('−0.0'); + expect(deltaTag.className).toContain('nv-tag--color-gray'); + expect(deltaTag.querySelector('svg')).not.toBeInTheDocument(); + }); + it('does not show the loading skeleton for an empty series once pending finishes', () => { render(); diff --git a/web/packages/studio/src/routes/agents/AgentDetailRoute/overview/RecentExperimentsPanel.tsx b/web/packages/studio/src/routes/agents/AgentDetailRoute/overview/RecentExperimentsPanel.tsx index ca2a5e0f1e..23768a1280 100644 --- a/web/packages/studio/src/routes/agents/AgentDetailRoute/overview/RecentExperimentsPanel.tsx +++ b/web/packages/studio/src/routes/agents/AgentDetailRoute/overview/RecentExperimentsPanel.tsx @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { formatSignedDelta } from '@nemo/common/src/components/DeltaText'; import { formatEvaluatorScore } from '@nemo/common/src/utils/formatters'; import { Anchor, @@ -40,8 +41,7 @@ interface RecentExperimentsPanelProps { * relative change (a ratio of two same-unit scores), so the percent sign is accurate whatever the * underlying scale. One decimal keeps it to the width the tag has room for. */ -const formatDelta = (delta: number): string => - `${delta > 0 ? '+' : delta < 0 ? '−' : ''}${Math.abs(delta).toFixed(1)}%`; +const formatDelta = (delta: number): string => `${formatSignedDelta(delta, 1)}%`; /** * What the agent is measured against, one card per experiment.