diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/eslint.config.js b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/eslint.config.js index 215d056b5ad..5f0ef1880a8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/eslint.config.js +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/eslint.config.js @@ -15,6 +15,9 @@ export default tseslint.config( languageOptions: { ecmaVersion: 2020, globals: globals.browser, + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, }, plugins: { 'react-hooks': reactHooks, diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/src/main.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/src/main.tsx index fc5ef8082b2..3e7280c3d86 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/src/main.tsx +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/src/main.tsx @@ -3,21 +3,15 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { App } from '../../components/App.tsx'; +import { App, createScoreSummary, ReportContextProvider } from '../../components'; import './index.css' import { init, ready, getAccessToken, getConfiguration } from "azure-devops-extension-sdk"; import { getClient } from "./azure-devops-extension-api"; import { Build, Attachment, BuildRestClient } from "./azure-devops-extension-api/Build"; import { FluentProvider, webLightTheme } from '@fluentui/react-components'; -import { createScoreSummary as createScoreSummary } from '../../components/Summary.ts'; -import { ReportContextProvider } from '../../components/ReportContext.tsx'; const ErrorHtml = ({ message }: { message: string }) => - - -
{message}
- - ; +
{message}
; const ATTACHMENT_NAME = 'ai-eval-report'; const ATTACHMENT_TYPE = 'ai-eval-report-json'; @@ -53,10 +47,11 @@ const getReportData = async (client: BuildRestClient, project: string, buildId: const run = async () => { - await init(); + await init({ applyTheme: true }); await ready(); const config = getConfiguration(); + const root = createRoot(document.getElementById('root')!); config.onBuildChanged(async (build: Build) => { try { @@ -68,31 +63,26 @@ const run = async () => { const scoreSummary = createScoreSummary(dataset); - createRoot(document.getElementById('root')!).render( + root.render( + + + + + + ); + + } catch (e) { + + const message = e instanceof Error ? e.message : 'An unknown error occurred while loading the report.'; + root.render( - - - + ); - - } catch (e) { - - if (e instanceof Error) { - const err = e as Error; - createRoot(document.getElementById('root')!).render( - - - - - - ); - return; - } } }); }; -run(); \ No newline at end of file +run(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/tsconfig.app.json b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/tsconfig.app.json index fb62a3a1763..4557bb67890 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/tsconfig.app.json +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/tsconfig.app.json @@ -21,5 +21,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": [".."] + "include": ["src", "../components"] } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/App.css b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/App.css deleted file mode 100644 index 6138d88370f..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/App.css +++ /dev/null @@ -1,9 +0,0 @@ -/* -Licensed to the .NET Foundation under one or more agreements. -The .NET Foundation licenses this file to you under the MIT license. -*/ - -#root { - margin: 0 auto; - background-color: white; -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/App.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/App.tsx deleted file mode 100644 index 6d73c8220e1..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/App.tsx +++ /dev/null @@ -1,139 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -import { useState } from 'react'; -import { Settings28Regular, FilterDismissRegular, DismissRegular, ArrowDownloadRegular } from '@fluentui/react-icons'; -import { Button, Drawer, DrawerBody, DrawerHeader, DrawerHeaderTitle, SearchBox, Switch, Tooltip } from '@fluentui/react-components'; -import { makeStyles } from '@fluentui/react-components'; -import './App.css'; -import { ScenarioGroup } from './ScenarioTree'; -import { GlobalTagsDisplay, FilterableTagsDisplay, categorizeAndSortTags } from './TagsDisplay'; -import { tokens } from '@fluentui/react-components'; -import { ScoreNodeHistory } from './ScoreNodeHistory'; -import { useReportContext } from './ReportContext'; - -const useStyles = makeStyles({ - header: { - display: 'flex', - flexDirection: 'column', - gap: '8px', - position: 'sticky', - top: 0, - zIndex: 1, - padding: '0rem 2rem 1rem 2rem', - backgroundColor: tokens.colorNeutralBackground1, - borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, - marginBottom: '1rem', - }, - headerTop: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - }, - body: { - padding: '0rem 2rem', - }, - headerActions: { - display: 'flex', - alignItems: 'center', - gap: '12px', - }, - footerText: { fontSize: '0.8rem', marginTop: '2rem' }, - closeButton: { - position: 'absolute', - top: '1.5rem', - right: '1rem', - }, - switchLabel: { fontSize: '1rem', paddingTop: '1rem' }, - drawerBody: { paddingTop: '1rem' }, -}); - -export const App = () => { - const classes = useStyles(); - const { dataset, scoreSummary, selectedTags, clearFilters, searchValue, setSearchValue } = useReportContext(); - const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const { renderMarkdown, setRenderMarkdown, prettifyJson, setPrettifyJson } = useReportContext(); - const { globalTags, filterableTags } = categorizeAndSortTags(dataset, scoreSummary.primaryResult.executionName); - - const toggleSettings = () => setIsSettingsOpen(!isSettingsOpen); - const closeSettings = () => setIsSettingsOpen(false); - - const downloadDataset = () => { - // create a stringified JSON of the dataset - const dataStr = JSON.stringify(dataset, null, 2); - - // create a link to download the JSON file in the page and click it - const blob = new Blob([dataStr], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${scoreSummary.primaryResult.executionName}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - - return ( - <> -
-
-

AI Evaluation Report

-
- {(selectedTags.length > 0 || !!searchValue) && ( - -
-
- - - - - -
- -
- -
- -

- Generated at {dataset.createdAt} by Microsoft.Extensions.AI.Evaluation.Reporting version {dataset.generatorVersion} -

- - - - Settings - + + {open && conversation && ( +
+ {metaLine && ( +
+ {metaLine} +
+ )} +
+ + +
+
+ )} + + ); +}; + +export const CasesView = () => { + const classes = useStyles(); + const s = useReportStyles(); + const announce = useAnnounce(); + const { + dataset, + activeExecution, + scopedNode, + selectedScenarioLevel, + filterTree, + failedOnly, + setFailedOnly, + setCasePage, + casePage, + selectedTags, + handleTagClick, + searchValue, + setSearchValue, + clearFilters, + } = useReportContext(); + + const scopedScenarioNames = useMemo(() => { + if (!selectedScenarioLevel) return undefined; + return new Set( + scopedNode.flattenedNodes + .filter((n) => n.isLeafNode && n.scenario) + .map((n) => n.scenario!.scenarioName), + ); + }, [scopedNode, selectedScenarioLevel]); + + const filterableTags = useMemo(() => { + const results = (dataset.scenarioRunResults ?? []).filter( + (r) => r.executionName === activeExecution && + (!scopedScenarioNames || scopedScenarioNames.has(r.scenarioName)), + ); + const total = results.length; + const counts = new Map(); + for (const r of results) for (const tag of r.tags ?? []) counts.set(tag, (counts.get(tag) ?? 0) + 1); + return [...counts.entries()] + .filter(([, c]) => c !== total) + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => b.count - a.count); + }, [dataset, activeExecution, scopedScenarioNames]); + + const [openKey, setOpenKey] = useState(null); + const [tagMenuOpen, setTagMenuOpen] = useState(false); + const rowRefs = useRef(new Map()); + const registerRowRef = (key: string, el: HTMLButtonElement | null) => { + if (el) { + rowRefs.current.set(key, el); + } else { + rowRefs.current.delete(key); + } + }; + + const closeOpen = (returnFocus: boolean) => { + const key = openKey; + setOpenKey(null); + if (returnFocus && key) { + requestAnimationFrame(() => rowRefs.current.get(key)?.focus()); + } + }; + + const prevKeys = useMemo(() => { + const execs = chronologicalExecutions(dataset); + const activeIdx = execs.indexOf(activeExecution); + const prevExec = activeIdx > 0 ? execs[activeIdx - 1] : undefined; + if (!prevExec) { + return undefined; + } + return new Set( + scenariosForExecution(dataset, prevExec).map((r) => `${r.scenarioName}#${r.iterationName}`), + ); + }, [dataset, activeExecution]); + + const allRows = useMemo(() => { + const filtered = filterTree(scopedNode); + return filtered ? buildRows(filtered, prevKeys) : []; + }, [filterTree, scopedNode, prevKeys]); + + const rows = useMemo(() => { + const filtered = failedOnly ? allRows.filter((r) => r.failed) : allRows; + const sorted = [...filtered]; + sorted.sort((a, b) => { + if (a.scenOrder !== b.scenOrder) return a.scenOrder - b.scenOrder; + if (a.isNew !== b.isNew) return a.isNew ? -1 : 1; + return a.index - b.index; + }); + return sorted; + }, [allRows, failedOnly]); + + const selectedScenarioName = useMemo(() => { + if (!scopedScenarioNames) return undefined; + return scopedScenarioNames.size === 1 ? [...scopedScenarioNames][0] : undefined; + }, [scopedScenarioNames]); + + const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); + useEffect(() => { + if (casePage > pageCount) { + setCasePage(pageCount); + } + }, [casePage, pageCount, setCasePage]); + const page = Math.min(casePage, pageCount); + const pageRows = useMemo( + () => rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), + [rows, page], + ); + + useEffect(() => { + if (openKey && !pageRows.some((r) => r.key === openKey)) { + setOpenKey(null); + } + }, [openKey, pageRows]); + + const hasActiveFilter = selectedTags.length > 0 || !!searchValue || failedOnly; + const searchCount = allRows.length; + + const filterKey = `${searchValue}|${[...selectedTags].sort().join(',')}|${failedOnly}`; + const firstFilterRun = useRef(true); + useEffect(() => { + if (firstFilterRun.current) { firstFilterRun.current = false; return; } + const n = rows.length; + const id = setTimeout(() => { + announce(n === 0 ? 'No matching cases' : `${n} case${n === 1 ? '' : 's'} match your filters`); + }, 400); + return () => clearTimeout(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterKey]); + const searchPlaceholder = `Search across ${searchCount} ${searchCount === 1 ? 'case' : 'cases'}`; + const tagLabel = selectedTags.length > 0 ? `${selectedTags.length} selected` : 'All tags'; + + return ( +
+
+
+ setSearchValue(data.value)} + /> +
+ + {filterableTags.length > 0 && ( +
+ setTagMenuOpen(data.open)} + positioning="below-end" + trapFocus + > + + {tagLabel} + + +
+ Filter by tag + { + selectedTags.forEach((t) => handleTagClick(t)); + setCasePage(1); + }} + > + Clear + +
+
+ {filterableTags.map(({ tag }) => { + const active = selectedTags.includes(tag); + return ( + + ); + })} +
+
+
+
+ )} + + + + { + setFailedOnly(data.checked); + setCasePage(1); + }} + label="Show failed" + /> +
+ + {pageRows.length === 0 ? ( +
+
+ No matching cases + + {hasActiveFilter + ? 'No scenarios match the current search, tags, or failing-only filter.' + : 'There are no scenario results to display.'} + + {hasActiveFilter && ( + + )} +
+
+ ) : ( +
+
+ {pageRows.map((vm) => ( + (openKey === vm.key ? closeOpen(true) : setOpenKey(vm.key))} + registerRowRef={registerRowRef} + /> + ))} +
+
+ )} + + {pageCount > 1 && ( +
+ + Page {page} of {pageCount} + +
+ )} +
+ ); +}; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/DiagnosticsContent.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/cases/DiagnosticsContent.tsx similarity index 75% rename from src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/DiagnosticsContent.tsx rename to src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/cases/DiagnosticsContent.tsx index f45123258bb..9413f51bf38 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/DiagnosticsContent.tsx +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/cases/DiagnosticsContent.tsx @@ -3,10 +3,13 @@ import { DismissCircle16Regular, Warning16Regular, Info16Regular, Copy16Regular } from "@fluentui/react-icons"; import { Table, TableHeader, TableRow, TableHeaderCell, TableBody, TableCell, mergeClasses } from "@fluentui/react-components"; -import { useStyles } from "./Styles"; +import { useStyles } from "../styles/Styles"; +import { srOnlyStyle } from "../styles/reportStyles"; +import { useAnnounce } from "../core/Announcer"; -export const DiagnosticsContent = ({ diagnostics }: { diagnostics: EvaluationDiagnostic[]; }) => { +export const DiagnosticsContent = ({ diagnostics, metricName }: { diagnostics: EvaluationDiagnostic[]; metricName: string; }) => { const classes = useStyles(); + const announce = useAnnounce(); if (diagnostics.length === 0) { return null; @@ -35,17 +38,31 @@ export const DiagnosticsContent = ({ diagnostics }: { diagnostics: EvaluationDia }; const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); + if (!navigator.clipboard) { + announce('Copy failed'); + return; + } + navigator.clipboard.writeText(text).then( + () => announce('Diagnostic copied to clipboard'), + () => announce('Copy failed'), + ); }; return ( -
+
Severity Message - + + Actions + @@ -64,6 +81,7 @@ export const DiagnosticsContent = ({ diagnostics }: { diagnostics: EvaluationDia className={classes.copyButton} onClick={() => copyToClipboard(`${diag.severity}: ${diag.message}`)} title="Copy Diagnostic" + aria-label="Copy diagnostic" > diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/MetadataContent.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/cases/MetadataContent.tsx similarity index 84% rename from src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/MetadataContent.tsx rename to src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/cases/MetadataContent.tsx index 814191ca6e6..d3e12bc3bd2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/MetadataContent.tsx +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/cases/MetadataContent.tsx @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. import { Table, TableHeader, TableRow, TableHeaderCell, TableBody, TableCell } from "@fluentui/react-components"; -import { useStyles } from "./Styles"; +import { useStyles } from "../styles/Styles"; export const MetadataContent = ({ metadata }: { metadata: { [K: string]: string }; }) => { const classes = useStyles(); @@ -29,10 +29,15 @@ export const MetadataContent = ({ metadata }: { metadata: { [K: string]: string } return ( -
+
{tables.map((tableData, tableIndex) => (
-
+
1 ? `Metadata (${tableIndex + 1} of ${tables.length})` : 'Metadata'} + >
diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/cases/MetricPanel.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/cases/MetricPanel.tsx new file mode 100644 index 00000000000..b01aa5c7efc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/cases/MetricPanel.tsx @@ -0,0 +1,374 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import { useId, useState } from 'react'; +import { makeStyles, mergeClasses } from '@fluentui/react-components'; +import { ChevronRight16Regular } from '@fluentui/react-icons'; +import { useReportStyles, statusSolidVar, statusTextVar, type ReportStatus } from '../styles/reportStyles'; +import { formatValue } from '../core/metricModel'; +import { DiagnosticsContent } from './DiagnosticsContent'; +import { MetadataContent } from './MetadataContent'; +import { type MetricType } from './metricTypes'; + +// The 5-segment meter fills to the rating ordinal. +const RATING_PIP: Partial> = { + exceptional: 5, + good: 4, + average: 3, + poor: 2, + unacceptable: 1, +}; + +const statusKeyOf = (rating: EvaluationRating | undefined): ReportStatus => { + switch (rating) { + case 'exceptional': + case 'good': + return 'success'; + case 'average': + return 'caution'; + case 'poor': + case 'unacceptable': + return 'danger'; + default: + return 'neutral'; + } +}; + +const ratingWord = (rating: EvaluationRating | undefined): string => { + switch (rating) { + case 'exceptional': + return 'Exceptional'; + case 'good': + return 'Good'; + case 'average': + return 'Fair'; + case 'poor': + return 'Poor'; + case 'unacceptable': + return 'Weak'; + case 'inconclusive': + return 'Inconclusive'; + default: + return 'Unknown'; + } +}; + +const metricFailed = (metric: MetricType): boolean => + metric.interpretation?.failed === true || + (metric.diagnostics?.some((d) => d.severity === 'error') ?? false); + +const useStyles = makeStyles({ + headerRow: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 'var(--spacing-s)', + padding: 'var(--spacing-m) var(--spacing-l)', + borderBottom: '1px solid var(--neutral-stroke-3)', + }, + headerCount: { + fontSize: 'var(--font-size-100)', + color: 'var(--neutral-foreground-4)', + whiteSpace: 'nowrap', + }, + + rowWrap: { borderTop: '1px solid var(--neutral-stroke-3)' }, + row: { + appearance: 'none', + border: 'none', + margin: 0, + width: '100%', + font: 'inherit', + color: 'inherit', + textAlign: 'left', + display: 'flex', + alignItems: 'center', + gap: 'var(--spacing-m-nudge)', + minHeight: '44px', + padding: '12px var(--spacing-l)', + lineHeight: '20px', + cursor: 'pointer', + userSelect: 'none', + backgroundColor: 'transparent', + }, + rowInteractive: { + transition: 'background-color var(--duration-faster) var(--curve-easy-ease)', + ':hover': { background: 'var(--subtle-background-hover)' }, + ':focus-visible': { + boxShadow: + '0 0 0 2px var(--focus-stroke-inner) inset, ' + + '0 0 0 4px var(--focus-stroke-outer) inset', + borderRadius: 'inherit', + outline: 'none', + }, + '@media (forced-colors: active)': { + ':focus-visible': { + boxShadow: 'none', + outline: '2px solid Highlight', + outlineOffset: '-2px', + }, + }, + }, + caret: { + flexShrink: 0, + color: 'var(--neutral-foreground-3)', + transition: 'transform var(--duration-fast) var(--curve-easy-ease)', + }, + caretOpen: { transform: 'rotate(90deg)' }, + dotWrap: { flex: 'none', display: 'inline-flex', alignItems: 'center' }, + dot: { + width: '8px', + height: '8px', + borderRadius: 'var(--radius-circular)', + flex: 'none', + boxSizing: 'border-box', + }, + rowName: { + flex: '1 1 auto', + minWidth: 0, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + fontSize: 'var(--font-size-300)', + color: 'var(--neutral-foreground-1)', + }, + track: { + flex: 'none', + display: 'flex', + alignItems: 'stretch', + width: '96px', + height: '16px', + gap: '4px', + }, + seg: { flex: '1 1 0', minWidth: 0, borderRadius: '2px' }, + segCenter: { display: 'flex', alignItems: 'center', justifyContent: 'center' }, + segIcon: { fontSize: '11px', lineHeight: 1, fontWeight: 700 }, + + panel: { + padding: '0 var(--spacing-l) var(--spacing-l) var(--spacing-xxxl)', + display: 'flex', + flexDirection: 'column', + containerType: 'inline-size', + }, + hero: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--spacing-s)', + padding: 'var(--spacing-m) 0 var(--spacing-l)', + }, + heroLine: { display: 'flex', alignItems: 'baseline', gap: 'var(--spacing-s-nudge)' }, + heroWord: { + fontSize: 'var(--font-size-500)', + fontWeight: 'var(--font-weight-semibold)', + lineHeight: 1.2, + }, + heroNum: { + fontSize: 'var(--font-size-400)', + color: 'var(--neutral-foreground-3)', + fontVariantNumeric: 'tabular-nums', + }, + heroIcon: { fontSize: '28px', lineHeight: 1, fontWeight: 700 }, + heroTrack: { + height: '6px', + width: '100%', + borderRadius: 'var(--radius-circular)', + backgroundColor: 'var(--eval-seg-empty)', + overflow: 'hidden', + }, + heroFill: { height: '100%', borderRadius: 'var(--radius-circular)' }, + + subSection: { + borderTop: '1px solid var(--neutral-stroke-2)', + padding: 'var(--spacing-l) 0', + }, + subHeader: { + fontSize: 'var(--font-size-200)', + fontWeight: 'var(--font-weight-semibold)', + color: 'var(--neutral-foreground-2)', + marginBottom: 'var(--spacing-s)', + }, + subBody: { + fontSize: 'var(--font-size-300)', + lineHeight: 1.55, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }, + empty: { + fontSize: 'var(--font-size-200)', + color: 'var(--neutral-foreground-3)', + fontStyle: 'italic', + padding: 'var(--spacing-l)', + }, +}); + +const SegmentTrack = ({ metric, sk }: { metric: MetricType; sk: ReportStatus }) => { + const classes = useStyles(); + const solid = statusSolidVar(sk); + const aura = `0 0 0 3px color-mix(in srgb, ${solid} 18%, transparent)`; + + const neutralTrack = ( + + ); + + if (sk === 'neutral') { + return neutralTrack; + } + + if (metric.$type === 'boolean') { + const good = sk === 'success'; + return ( + + ); + } + + // string/none metrics carry no scale to plot — no meter, matching real-data rendering. + if (metric.$type !== 'numeric') { + return null; + } + + const pip = RATING_PIP[metric.interpretation?.rating as EvaluationRating]; + if (pip === undefined) { + return neutralTrack; + } + + return ( +