diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 8a93ca7b71..1e810cb47c 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -713,7 +713,7 @@ Features:
Rendering invariants:
- On mobile (`max-width: 768px`), `.cc-tabpanel` remains the sole vertical scroll owner for every chart-bearing tab. Shared chart primitives (`Bar`, `StackedBar`, `Sparkline`, `LineChart`, `RadialGauge`, `Funnel`, `TokenSeriesChart`, and the Command Center recharts wrappers) must shrink within the tabpanel, keep non-zero usable height, avoid stretch/clipping artifacts, and never introduce a competing vertical overflow container.
-- The hand-rolled Activity `LineChart` uses uniform SVG scaling so point markers remain true circles and line geometry remains proportional even when the CSS chart box is wide/short on desktop or auto-aspect on mobile.
+- The hand-rolled Activity `LineChart` tracks its rendered SVG box for coordinates: populated paths fill the available chart width (no centered square letterboxing), while point markers remain true circles even when the CSS chart box is wide/short on desktop or auto-aspect on mobile.
- Mobile chart text must not rely on min-content luck: bar labels, values, token-series axis labels, funnel headers, radial labels, legends, and chart tracks need explicit `min-inline-size: 0`, wrapping, or ellipsis rules so long model/agent/repo labels cannot crush the track or create hidden horizontal overflow in a real browser.
- On tablet (`min-width: 769px` and `max-width: 1024px`), `.project-content`, `.command-center`, and `.cc-tabpanel` keep the same definite flex/min-height scroll-owner chain, while the live strip and chart grids collapse before they can create document-level horizontal overflow.
- Command Center stat cards, overview chart cards, live strips, table wrappers, Team chart panels, token-series plots, system control cards, and gauge/chart cards share the same tokenized rhythm: `--space-md` gaps/padding for card-like surfaces, `1px solid var(--border-subtle)` borders, `--radius-md` radii, and `--surface-1` backgrounds. Area-specific accents may use `color-mix(...)`, but layout, border, radius, text color, and motion must stay on design tokens, with the named 4px spacing scale (`--space-xs`/`sm`/`md`/`lg`/`xl`/`2xl`) as the canonical vocabulary.
diff --git a/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx
index ada5ed788d..db0e91bcce 100644
--- a/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx
+++ b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { render, screen } from "@testing-library/react";
+import { render, screen, waitFor } from "@testing-library/react";
import { readFileSync } from "fs";
import { resolve } from "path";
import { Bar } from "../charts/Bar";
@@ -234,23 +234,49 @@ function numericAttribute(element: Element, name: string): number {
}
function expectLineChartPointsInsideViewBox(chart: Element): void {
+ const [, , viewBoxWidth, viewBoxHeight] = viewBoxNumbers(chart);
for (const point of Array.from(chart.querySelectorAll(".cc-line-chart-point"))) {
const cx = numericAttribute(point, "cx");
const cy = numericAttribute(point, "cy");
const r = numericAttribute(point, "r");
expect(cx).toBeGreaterThanOrEqual(r);
- expect(cx).toBeLessThanOrEqual(100 - r);
+ expect(cx).toBeLessThanOrEqual(viewBoxWidth - r);
expect(cy).toBeGreaterThanOrEqual(r);
- expect(cy).toBeLessThanOrEqual(100 - r);
+ expect(cy).toBeLessThanOrEqual(viewBoxHeight - r);
}
}
-function expectLineChartMarkersDistortionProof(chart: Element): void {
- expect(chart.getAttribute("preserveAspectRatio")).toBe("xMidYMid meet");
- expect(chart.getAttribute("preserveAspectRatio")).not.toBe("none");
+function viewBoxNumbers(chart: Element): [number, number, number, number] {
+ return (chart.getAttribute("viewBox") ?? "")
+ .split(/\s+/)
+ .map(Number) as [number, number, number, number];
+}
+
+function linePointTuples(line: Element): Array<[number, number]> {
+ return (line.getAttribute("points") ?? "")
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean)
+ .map((pair) => pair.split(",").map(Number) as [number, number]);
+}
+
+function expectLineChartFillsBoxAndKeepsRoundMarkers(chart: Element): void {
+ const [, , viewBoxWidth, viewBoxHeight] = viewBoxNumbers(chart);
+ expect(viewBoxWidth).toBeGreaterThan(viewBoxHeight);
+ expect(chart.getAttribute("preserveAspectRatio")).toBe("none");
+ expect(chart.getAttribute("viewBox")).not.toBe("0 0 100 100");
expect(chart.querySelectorAll(".cc-line-chart-point").length).toBeGreaterThan(0);
}
+function expectLineChartPathFillsPlotWidth(chart: Element): void {
+ const [, , viewBoxWidth] = viewBoxNumbers(chart);
+ const line = chart.querySelector(".cc-line-chart-path");
+ expect(line).toBeTruthy();
+ const points = linePointTuples(line!);
+ expect(points[0]?.[0]).toBe(3);
+ expect(points.at(-1)?.[0]).toBe(viewBoxWidth - 3);
+}
+
describe("LineChart", () => {
it("renders a populated finite SVG line with an accessible label", () => {
render();
@@ -263,14 +289,15 @@ describe("LineChart", () => {
expect(points).not.toBe("");
expect(points).not.toMatch(/NaN|Infinity/);
expectLineChartPointsInsideViewBox(chart);
- expectLineChartMarkersDistortionProof(chart);
+ expectLineChartFillsBoxAndKeepsRoundMarkers(chart);
+ expectLineChartPathFillsPlotWidth(chart);
});
it("uses uniform SVG scaling so marker circles cannot stretch into ovals", () => {
render();
const chart = screen.getByRole("img", { name: "mobile activity trend" });
- expectLineChartMarkersDistortionProof(chart);
+ expectLineChartFillsBoxAndKeepsRoundMarkers(chart);
});
it("keeps mobile sizing non-square while SVG geometry remains uniformly scaled", () => {
@@ -278,7 +305,31 @@ describe("LineChart", () => {
render();
- expectLineChartMarkersDistortionProof(screen.getByRole("img", { name: "narrow activity trend" }));
+ const chart = screen.getByRole("img", { name: "narrow activity trend" });
+ expectLineChartFillsBoxAndKeepsRoundMarkers(chart);
+ expectLineChartPathFillsPlotWidth(chart);
+ });
+
+ it("updates the viewBox from ResizeObserver so variable mobile boxes keep round markers", async () => {
+ const originalResizeObserver = globalThis.ResizeObserver;
+ class ImmediateResizeObserver {
+ constructor(private readonly callback: ResizeObserverCallback) {}
+ observe() {
+ this.callback([{ contentRect: { width: 320, height: 160 } as DOMRectReadOnly } as ResizeObserverEntry], this as unknown as ResizeObserver);
+ }
+ unobserve() {}
+ disconnect() {}
+ }
+ globalThis.ResizeObserver = ImmediateResizeObserver as unknown as typeof ResizeObserver;
+ try {
+ render();
+ const chart = screen.getByRole("img", { name: "measured mobile activity trend" });
+ await waitFor(() => expect(chart.getAttribute("viewBox")).toBe("0 0 320 160"));
+ expectLineChartFillsBoxAndKeepsRoundMarkers(chart);
+ expectLineChartPathFillsPlotWidth(chart);
+ } finally {
+ globalThis.ResizeObserver = originalResizeObserver;
+ }
});
it("renders all-zero values as valid baseline geometry without NaN or edge clipping", () => {
@@ -296,7 +347,7 @@ describe("LineChart", () => {
const chart = screen.getByRole("img", { name: "single trend" });
expect(chart.querySelector(".cc-line-chart-path")).toBeNull();
const point = chart.querySelector(".cc-line-chart-point");
- expect(point?.getAttribute("cx")).toBe("50");
+ expect(point?.getAttribute("cx")).toBe("125");
expect(point?.getAttribute("cy")).not.toMatch(/NaN|Infinity/);
expectLineChartPointsInsideViewBox(chart);
});
diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx
index 3135cd5b15..000c698ab9 100644
--- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx
+++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx
@@ -287,26 +287,43 @@ function expectSparklineHeightsFinite(testId: string): void {
}
}
+function viewBoxNumbers(chart: Element): [number, number, number, number] {
+ return (chart.getAttribute("viewBox") ?? "")
+ .split(/\s+/)
+ .map(Number) as [number, number, number, number];
+}
+
function expectSvgLinePointsInsideViewBox(testId: string, label: string): void {
const section = screen.getByTestId(testId);
const chart = within(section).getByRole("img", { name: label });
+ const [, , viewBoxWidth, viewBoxHeight] = viewBoxNumbers(chart);
for (const point of Array.from(chart.querySelectorAll(".cc-line-chart-point"))) {
const cx = Number(point.getAttribute("cx"));
const cy = Number(point.getAttribute("cy"));
const r = Number(point.getAttribute("r"));
expect(cx).toBeGreaterThanOrEqual(r);
- expect(cx).toBeLessThanOrEqual(100 - r);
+ expect(cx).toBeLessThanOrEqual(viewBoxWidth - r);
expect(cy).toBeGreaterThanOrEqual(r);
- expect(cy).toBeLessThanOrEqual(100 - r);
+ expect(cy).toBeLessThanOrEqual(viewBoxHeight - r);
}
}
-function expectSvgLineMarkersUndistorted(testId: string, label: string): void {
+function expectSvgLineFillsBoxAndKeepsRoundMarkers(testId: string, label: string): void {
const section = screen.getByTestId(testId);
const chart = within(section).getByRole("img", { name: label });
- expect(chart.getAttribute("preserveAspectRatio")).toBe("xMidYMid meet");
- expect(chart.getAttribute("preserveAspectRatio")).not.toBe("none");
+ const [, , viewBoxWidth, viewBoxHeight] = viewBoxNumbers(chart);
+ const line = chart.querySelector(".cc-line-chart-path");
+ const pointPairs = (line?.getAttribute("points") ?? "")
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean)
+ .map((pair) => pair.split(",").map(Number) as [number, number]);
+ expect(viewBoxWidth).toBeGreaterThan(viewBoxHeight);
+ expect(chart.getAttribute("preserveAspectRatio")).toBe("none");
+ expect(chart.getAttribute("viewBox")).not.toBe("0 0 100 100");
expect(chart.querySelectorAll(".cc-line-chart-point").length).toBeGreaterThan(0);
+ expect(pointPairs[0]?.[0]).toBe(3);
+ expect(pointPairs.at(-1)?.[0]).toBe(viewBoxWidth - 3);
}
describe("useAnalyticsArea", () => {
@@ -403,13 +420,13 @@ describe("ActivityArea", () => {
expect(screen.getByRole("img", { name: "Activity trend" })).toHaveAttribute("data-scale-mode", "series");
expectRechartsWrapperWithin("cc-activity-pie", "Agent run outcome share");
expectSvgLinePointsInsideViewBox("cc-activity-line-messages", "Messages / day");
- expectSvgLineMarkersUndistorted("cc-activity-line-messages", "Messages / day");
+ expectSvgLineFillsBoxAndKeepsRoundMarkers("cc-activity-line-messages", "Messages / day");
expectSvgLinePointsInsideViewBox("cc-activity-line-agents", "Active agents / day");
- expectSvgLineMarkersUndistorted("cc-activity-line-agents", "Active agents / day");
+ expectSvgLineFillsBoxAndKeepsRoundMarkers("cc-activity-line-agents", "Active agents / day");
expectSvgLinePointsInsideViewBox("cc-activity-line-nodes", "Active nodes / day");
- expectSvgLineMarkersUndistorted("cc-activity-line-nodes", "Active nodes / day");
+ expectSvgLineFillsBoxAndKeepsRoundMarkers("cc-activity-line-nodes", "Active nodes / day");
expectSvgLinePointsInsideViewBox("cc-activity-line-throughput", "Throughput / day");
- expectSvgLineMarkersUndistorted("cc-activity-line-throughput", "Throughput / day");
+ expectSvgLineFillsBoxAndKeepsRoundMarkers("cc-activity-line-throughput", "Throughput / day");
expect(within(screen.getByTestId("cc-activity-agent-runs-sparkline")).getByRole("img", { name: "Agent runs / day" }).classList).toContain("cc-sparkline");
expectSparklineHeightsFinite("cc-activity-agent-runs-sparkline");
});
diff --git a/packages/dashboard/app/components/command-center/charts/LineChart.tsx b/packages/dashboard/app/components/command-center/charts/LineChart.tsx
index 5748bef915..c40fdfd9c9 100644
--- a/packages/dashboard/app/components/command-center/charts/LineChart.tsx
+++ b/packages/dashboard/app/components/command-center/charts/LineChart.tsx
@@ -1,3 +1,4 @@
+import { useLayoutEffect, useRef, useState } from "react";
import "./charts.css";
export interface LineChartSeries {
@@ -14,35 +15,53 @@ export interface LineChartProps {
max?: number;
}
-const VIEWBOX_SIZE = 100;
-const SINGLE_POINT_X = VIEWBOX_SIZE / 2;
+const FALLBACK_VIEWBOX_WIDTH = 250;
+const FALLBACK_VIEWBOX_HEIGHT = 100;
const POINT_RADIUS = 1.8;
const PLOT_PADDING = 3;
-const PLOT_SIZE = VIEWBOX_SIZE - PLOT_PADDING * 2;
+
+interface ChartGeometry {
+ width: number;
+ height: number;
+}
+
+const FALLBACK_GEOMETRY: ChartGeometry = { width: FALLBACK_VIEWBOX_WIDTH, height: FALLBACK_VIEWBOX_HEIGHT };
function safeHeightPercent(value: number, max: number): number {
if (!Number.isFinite(value) || value <= 0) {
return 0;
}
const denom = Number.isFinite(max) && max > 0 ? max : 1;
- return Math.max(0, Math.min(VIEWBOX_SIZE, (value / denom) * VIEWBOX_SIZE));
+ return Math.max(0, Math.min(100, (value / denom) * 100));
}
function safeCoord(value: number): number {
return Number.isFinite(value) ? value : 0;
}
-function pointFor(value: number, index: number, count: number, max: number): { x: number; y: number } {
- const x = count <= 1 ? SINGLE_POINT_X : PLOT_PADDING + (index / (count - 1)) * PLOT_SIZE;
- const height = safeHeightPercent(value, max);
+function boundedGeometry(width: number, height: number): ChartGeometry | null {
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
+ return null;
+ }
return {
- x: safeCoord(x),
- y: safeCoord(PLOT_PADDING + PLOT_SIZE * (1 - height / VIEWBOX_SIZE)),
+ width: Math.max(width, PLOT_PADDING * 2 + POINT_RADIUS * 2),
+ height: Math.max(height, PLOT_PADDING * 2 + POINT_RADIUS * 2),
};
}
-function pointsFor(values: number[], max: number): { x: number; y: number }[] {
- return values.map((value, index) => pointFor(value, index, values.length, max));
+function pointFor(value: number, index: number, count: number, max: number, geometry: ChartGeometry): { x: number; y: number } {
+ const plotWidth = Math.max(0, geometry.width - PLOT_PADDING * 2);
+ const plotHeight = Math.max(0, geometry.height - PLOT_PADDING * 2);
+ const x = count <= 1 ? geometry.width / 2 : PLOT_PADDING + (index / (count - 1)) * plotWidth;
+ const height = safeHeightPercent(value, max);
+ return {
+ x: safeCoord(x),
+ y: safeCoord(PLOT_PADDING + plotHeight * (1 - height / 100)),
+ };
+}
+
+function pointsFor(values: number[], max: number, geometry: ChartGeometry): { x: number; y: number }[] {
+ return values.map((value, index) => pointFor(value, index, values.length, max, geometry));
}
function pointsAttribute(points: { x: number; y: number }[]): string {
@@ -62,6 +81,14 @@ function computedMaxFor(series: LineChartSeries[], max?: number): number {
}, 0);
}
+function geometryFromRect(rect: Pick): ChartGeometry | null {
+ return boundedGeometry(rect.width, rect.height);
+}
+
+function geometriesMatch(left: ChartGeometry, right: ChartGeometry): boolean {
+ return Math.abs(left.width - right.width) < 0.5 && Math.abs(left.height - right.height) < 0.5;
+}
+
/**
* FNXC:CommandCenterCharts 2026-06-18-14:29:
* Command Center needed a true, zero/NaN-safe, reduced-motion-aware animated line chart for time-series metrics; reuse the Bar/Sparkline safe-height convention so malformed analytics values never leak NaN or Infinity into SVG geometry.
@@ -69,22 +96,54 @@ function computedMaxFor(series: LineChartSeries[], max?: number): number {
* FNXC:CommandCenterCharts 2026-06-19-05:24:
* Activity line charts were clipping max/min points because the data domain mapped to the full SVG viewBox edge. Reserve plot padding equal to the rendered point/stroke margin so populated, single-point, zero, and max-value series stay inside the viewBox on desktop and mobile.
*
- * FNXC:CommandCenterCharts 2026-06-20-22:59:
- * FN-6818 requires Activity markers to render as true circles independent of the chart container's aspect ratio. `preserveAspectRatio="none"` on the square viewBox stretched circle coordinates into ovals on narrow/mobile layouts; `vectorEffect="non-scaling-stroke"` only protects stroke width, not coordinate geometry, so the SVG must use uniform scaling.
+ * FNXC:CommandCenterCharts 2026-06-21-17:10:
+ * FN-6883 restores the dual invariant FN-6818 could not satisfy with a square `xMidYMid meet` viewBox: Activity line charts must fill the wide desktop/mobile CSS box without centered blank margins, and markers must remain true circles. Track the rendered SVG box with ResizeObserver and use that measured coordinate system with `preserveAspectRatio="none"`; the fallback matches the desktop 5:2 CSS ratio so first paint and jsdom tests do not reintroduce square letterboxing.
*/
export function LineChart({ series, ariaLabel, max }: LineChartProps) {
+ const svgRef = useRef(null);
+ const [geometry, setGeometry] = useState(FALLBACK_GEOMETRY);
const computedMax = computedMaxFor(series, max);
+ useLayoutEffect(() => {
+ const svg = svgRef.current;
+ if (!svg) {
+ return undefined;
+ }
+
+ const updateGeometry = (next: ChartGeometry | null) => {
+ if (!next) {
+ return;
+ }
+ setGeometry((current) => (geometriesMatch(current, next) ? current : next));
+ };
+
+ updateGeometry(geometryFromRect(svg.getBoundingClientRect()));
+
+ if (typeof ResizeObserver === "undefined") {
+ return undefined;
+ }
+
+ const observer = new ResizeObserver((entries) => {
+ const entry = entries[0];
+ if (entry) {
+ updateGeometry(geometryFromRect(entry.contentRect));
+ }
+ });
+ observer.observe(svg);
+ return () => observer.disconnect();
+ }, []);
+
return (