FN-6664: polish Command Center mobile charts

Polish Command Center chart surfaces and mobile scrolling behavior.

- Keep mobile Command Center chart tabs constrained to the tabpanel scroll owner without clipping or stretch artifacts.
- Normalize chart, stat, table, gauge, and team panel card styling around shared design tokens.
- Expand regression coverage for mobile chart sizing, overflow ownership, and tokenized area styling.
- Document the mobile rendering and token-only invariants and add a patch changeset.

Files changed:
 .../fn-6664-command-center-mobile-chart-polish.md  |   5 +
 docs/dashboard-guide.md                            |   6 +
 .../components/command-center/CommandCenter.css    | 112 ++++++++++-------
 .../__tests__/CommandCenter.mobile-scroll.test.tsx | 128 ++++++++++++++++++-
 .../command-center/areas/__tests__/areas.test.tsx  |  47 ++++++-
 .../app/components/command-center/areas/areas.css  |  63 ++++++----
 .../components/command-center/charts/charts.css    | 135 +++++++++++++--------
 7 files changed, 375 insertions(+), 121 deletions(-)

Fusion-Task-Id: FN-6664

Fusion-Task-Lineage: 14afa9c5-7287-4db1-beea-3bf7cbb70e7b
This commit is contained in:
gsxdsm
2026-06-18 19:29:40 -07:00
parent 9b396b6378
commit 21d8076ae2
7 changed files with 375 additions and 121 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix Command Center mobile chart rendering so chart primitives shrink inside the tabpanel without scroll-stealing overflow, zero-height collapse, or stretch artifacts, and normalize chart/card border and spacing rhythm across the combined analytics surfaces.

View File

@@ -676,6 +676,10 @@ Features:
- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. Motion-heavy accents respect reduced-motion preferences.
- CSV exports are available from the analytics endpoints with `?format=csv`. The Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`.
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`, and `TokenSeriesChart`) must shrink within the tabpanel, keep non-zero usable height, avoid stretch/clipping artifacts, and never introduce a competing vertical overflow container.
- Command Center stat cards, overview chart cards, live strips, table wrappers, Team chart panels, token-series plots, and gauge/chart cards share the same tokenized rhythm: `--border-width` borders, `--radius-md` radii, and `--space-*` gaps/padding. Area-specific accents may use `color-mix(...)`, but layout, border, radius, text color, and motion must stay on design tokens.
Data states:
- Overview shows a loading state while core analytics settle, then shows `No usage data yet. Run some agents to populate the Command Center.` only after the selected range has settled with no core usage data.
- GitHub issue analytics is local and additive: empty filed/fixed totals render the GitHub area's empty state; malformed historical `githubTracking` JSON is skipped instead of breaking the Command Center.
@@ -1249,6 +1253,8 @@ The `index.html` shell is templated server-side: the server injects a per-user `
**Always reference tokens. Never hardcode pixels, hex, or `rgba()` in component CSS** — global/theme token CSS is also covered by `global-theme-css-no-raw-rgba.test.ts`, so raw `rgba()` belongs only in explicit `var(--token, rgba(...))` fallbacks. For translucent backgrounds use `color-mix(in srgb, var(--color) X%, transparent)`, not `rgba()`.
Command Center chart surfaces are a stricter token-only zone: `CommandCenter.css`, `areas/areas.css`, and `charts/charts.css` should avoid raw color fallbacks and hardcoded dimensions in component rules, keep secondary copy on `--text-muted`, use `--duration-*` for animation durations, and encode mobile chart invariants with shared classes rather than one-off area styles.
### Theme system
Dark/light modes via `data-theme`; 54 color themes via `data-color-theme` (lazy-loaded from `app/public/theme-data.css`).

View File

@@ -11,10 +11,10 @@ The FN-4286 dashboard text-token guard requires command-center secondary copy to
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-4, 1rem);
gap: var(--space-4);
min-height: 0;
width: 100%;
padding: var(--space-4, 1rem);
inline-size: 100%;
padding: var(--space-4);
}
/*
@@ -26,15 +26,15 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-3, 0.75rem);
gap: var(--space-3);
}
.cc-title {
display: flex;
align-items: center;
gap: var(--space-2, 0.5rem);
gap: var(--space-2);
margin: 0;
font-size: var(--font-size-lg, 1.1rem);
font-size: var(--font-size-lg);
}
/* ---- Tabs ---- */
@@ -42,34 +42,36 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: var(--space-1, 0.25rem);
border-bottom: 1px solid var(--border-subtle, rgba(127, 127, 127, 0.25));
gap: var(--space-1);
border-bottom: var(--border-width) solid var(--border-subtle);
}
.cc-tab {
appearance: none;
background: none;
border: none;
border-bottom: 2px solid transparent;
border-bottom: var(--border-width-thick) solid transparent;
color: var(--text-muted);
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: var(--font-size-sm, 0.85rem);
font-size: var(--font-size-sm);
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.cc-tab:hover {
color: var(--text-primary, #ddd);
color: var(--text-primary);
}
.cc-tab.active {
color: var(--text-primary, #ddd);
border-bottom-color: var(--color-accent, #4f8cff);
color: var(--text-primary);
border-bottom-color: var(--color-accent);
}
.cc-tabpanel {
flex: 1;
min-height: 0;
min-inline-size: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
outline: none;
@@ -78,39 +80,48 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
@media (max-width: 768px) {
.cc-tabpanel {
padding-bottom: calc(var(--space-4, 1rem) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
padding-bottom: calc(var(--space-4) + env(safe-area-inset-bottom, 0) + var(--standalone-bottom-gap));
}
}
/* ---- Overview ---- */
.cc-overview {
min-inline-size: 0;
display: flex;
flex-direction: column;
gap: var(--space-4, 1rem);
gap: var(--space-4);
}
.cc-stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: var(--space-3, 0.75rem);
gap: var(--space-3);
}
/*
FNXC:CommandCenterStyling 2026-06-18-00:00:
Command Center chart/stat surfaces share one tokenized card rhythm so new chart areas do not drift in border, radius, or spacing when Team/System/agent-runs render together (FN-6664).
*/
.cc-stat-card {
display: flex;
flex-direction: column;
gap: var(--space-1, 0.25rem);
padding: var(--space-3, 0.75rem);
gap: var(--space-2);
min-inline-size: 0;
padding: var(--space-3);
border: var(--border-width) solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-1);
}
.cc-stat-label {
font-size: var(--font-size-sm, 0.85rem);
font-size: var(--font-size-sm);
color: var(--text-muted);
}
.cc-stat-value {
font-size: var(--font-size-xl, 1.5rem);
font-size: var(--font-size-xl);
font-variant-numeric: tabular-nums;
color: var(--text-primary, #ddd);
color: var(--text-primary);
}
.cc-stat-card--gauge {
@@ -118,21 +129,25 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
text-align: center;
}
/*
FNXC:CommandCenterStyling 2026-06-18-00:00:
Command Center live/chart containers must shrink inside the mobile tabpanel without creating a second scroll owner or clipping chart height (FN-6664).
*/
.cc-live-strip {
position: relative;
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(16rem, 2fr) minmax(10rem, 1fr);
align-items: center;
gap: var(--space-3, 0.75rem);
padding: var(--space-3, 0.75rem);
border: var(--border-width, 0.0625rem) solid color-mix(in srgb, var(--color-accent) 35%, var(--border-subtle));
gap: var(--space-3);
padding: var(--space-3);
border: var(--border-width) solid var(--border-subtle);
border-radius: var(--radius-md);
background:
linear-gradient(135deg, color-mix(in srgb, var(--color-accent) 14%, transparent), transparent),
var(--surface-1);
box-shadow: 0 0 var(--space-4) color-mix(in srgb, var(--color-accent) 18%, transparent);
overflow: hidden;
font-size: var(--font-size-sm, 0.85rem);
font-size: var(--font-size-sm);
}
.cc-live-strip::before {
@@ -149,6 +164,7 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
.cc-live-strip-heading,
.cc-live-strip-metrics,
.cc-live-trend {
min-inline-size: 0;
position: relative;
z-index: 1;
}
@@ -156,7 +172,7 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
.cc-live-strip-heading {
display: flex;
align-items: center;
gap: var(--space-2, 0.5rem);
gap: var(--space-2);
}
.cc-live-strip-label {
@@ -165,17 +181,19 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
}
.cc-live-strip-metrics {
min-inline-size: 0;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-2, 0.5rem);
gap: var(--space-2);
}
.cc-live-metric {
min-inline-size: 0;
display: flex;
flex-direction: column;
gap: var(--space-1, 0.25rem);
min-width: 0;
padding: var(--space-2, 0.5rem);
gap: var(--space-1);
min-inline-size: 0;
padding: var(--space-2);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface-2) 70%, transparent);
animation: cc-live-signal-pulse calc(var(--duration-slow) * 6) ease-in-out infinite;
@@ -183,7 +201,7 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
.cc-live-metric-value {
color: var(--text-primary);
font-size: var(--font-size-lg, 1.1rem);
font-size: var(--font-size-lg);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
@@ -210,19 +228,20 @@ Overview token totals now live-poll and should visibly count up on change in bot
.cc-live-metric-label,
.cc-live-trend-label {
color: var(--text-muted);
font-size: var(--font-size-xs, 0.75rem);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.cc-live-trend {
min-inline-size: 0;
display: flex;
flex-direction: column;
gap: var(--space-2, 0.5rem);
gap: var(--space-2);
}
.cc-live-trend .cc-sparkline {
height: 2.5rem;
block-size: var(--space-10);
}
.cc-live-trend .cc-sparkline-bar {
@@ -263,6 +282,7 @@ FNXC:CommandCenterStyling 2026-06-18-00:00:
Overview charts must use dashboard tokens only and keep motion decorative; animations use --duration-* values and are disabled for reduced-motion users so the graph-rich snapshot does not violate accessibility or the mobile scroll contract.
*/
.cc-overview-charts {
min-inline-size: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
@@ -270,13 +290,15 @@ Overview charts must use dashboard tokens only and keep motion decorative; anima
}
.cc-overview-chart-card {
min-inline-size: 0;
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 0;
min-inline-size: 0;
padding: var(--space-3);
border-color: color-mix(in srgb, var(--color-accent) 22%, var(--border-subtle));
border: var(--border-width) solid var(--border-subtle);
border-radius: var(--radius-md);
background:
linear-gradient(145deg, color-mix(in srgb, var(--color-accent) 10%, transparent), transparent),
var(--surface-1);
@@ -320,7 +342,7 @@ Overview charts must use dashboard tokens only and keep motion decorative; anima
}
.cc-overview-chart-card .cc-sparkline {
height: var(--space-16);
block-size: var(--space-16);
}
.cc-overview-chart-card .cc-bar-fill,
@@ -367,10 +389,12 @@ Overview charts must use dashboard tokens only and keep motion decorative; anima
}
.cc-live-strip-metrics {
min-inline-size: 0;
grid-template-columns: 1fr;
}
.cc-overview-charts {
min-inline-size: 0;
grid-template-columns: 1fr;
}
@@ -387,20 +411,20 @@ Overview charts must use dashboard tokens only and keep motion decorative; anima
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2, 0.5rem);
padding: var(--space-6, 2rem);
gap: var(--space-2);
padding: var(--space-6);
color: var(--text-muted);
text-align: center;
}
.cc-error {
color: var(--color-error, #e5484d);
color: var(--color-error);
}
.cc-loading .cc-chart-skeleton {
height: 1rem;
border-radius: var(--radius-sm, 4px);
background: var(--surface-2, rgba(127, 127, 127, 0.12));
block-size: var(--space-4);
border-radius: var(--radius-sm);
background: var(--surface-2);
animation: cc-shell-pulse var(--duration-slow) ease-in-out infinite;
}

View File

@@ -78,6 +78,10 @@ function populatedTokenFixture() {
cost: { usd: 9, unavailable: false, stale: false },
},
],
series: [
{ bucket: "2026-06-17T00:00:00.000Z", totalTokens: 250 },
{ bucket: "2026-06-18T00:00:00.000Z", totalTokens: 750 },
],
};
}
@@ -92,6 +96,65 @@ function populatedToolsFixture() {
};
}
function populatedProductivityFixture() {
return {
modifiedFiles: 6,
commits: 2,
pullRequests: 1,
loc: { value: 42, unavailable: false },
byLanguage: [{ language: "TypeScript", count: 6 }],
};
}
function emptyProductivityFixture() {
return {
modifiedFiles: 0,
commits: 0,
pullRequests: 0,
loc: { value: null, unavailable: true },
byLanguage: [],
};
}
function populatedTeamFixture() {
return {
...emptyTeamFixture(),
totals: {
tokens: { inputTokens: 900, outputTokens: 450, cachedTokens: 150, cacheWriteTokens: 0, totalTokens: 1500, nTasks: 2 },
cost: { usd: 4.25, unavailable: false, stale: false },
filesChanged: 7,
tasksCompleted: 3,
tasksInProgress: 1,
tasksInReview: 0,
},
agents: [
{
agentId: "agent-alpha",
agentName: "Alpha Agent",
role: "executor",
state: "running",
tokens: { inputTokens: 900, outputTokens: 450, cachedTokens: 150, cacheWriteTokens: 0, totalTokens: 1500, nTasks: 2 },
cost: { usd: 4.25, unavailable: false, stale: false },
filesChanged: 7,
tasksCompleted: 3,
tasksInProgress: 1,
tasksInReview: 0,
},
],
};
}
function populatedSignalsFixture() {
return {
totalSignals: 3,
open: 2,
resolved: 1,
mttr: { value: 30, unavailable: false },
bySource: [{ source: "sentry", count: 2 }],
bySeverity: [{ severity: "high", count: 1 }],
};
}
function emptyGithubFixture() {
return { filed: 0, fixed: 0, net: 0, daily: [], byRepo: [] };
}
@@ -171,9 +234,10 @@ function mockOverviewApi({ populated = false }: { populated?: boolean } = {}) {
if (path.startsWith("/command-center/tokens")) return Promise.resolve(populated ? populatedTokenFixture() : emptyTokenFixture());
if (path.startsWith("/command-center/tools")) return Promise.resolve(populated ? populatedToolsFixture() : emptyToolsFixture());
if (path.startsWith("/command-center/activity")) return Promise.resolve(populated ? populatedActivityFixture() : emptyActivityFixture());
if (path.startsWith("/command-center/github")) return Promise.resolve(emptyGithubFixture());
if (path.startsWith("/command-center/team")) return Promise.resolve(emptyTeamFixture());
if (path.startsWith("/command-center/signals")) return Promise.resolve({ totalSignals: 0, open: 0, resolved: 0, mttr: { value: null, unavailable: true }, bySource: [], bySeverity: [] });
if (path.startsWith("/command-center/productivity")) return Promise.resolve(populated ? populatedProductivityFixture() : emptyProductivityFixture());
if (path.startsWith("/command-center/github")) return Promise.resolve(populated ? { filed: 3, fixed: 1, net: 2, daily: [{ date: "2026-06-18", filed: 3, fixed: 1 }], byRepo: [{ repo: "acme/repo", filed: 3, fixed: 1 }] } : emptyGithubFixture());
if (path.startsWith("/command-center/team")) return Promise.resolve(populated ? populatedTeamFixture() : emptyTeamFixture());
if (path.startsWith("/command-center/signals")) return Promise.resolve(populated ? populatedSignalsFixture() : { totalSignals: 0, open: 0, resolved: 0, mttr: { value: null, unavailable: true }, bySource: [], bySeverity: [] });
if (path === "/system-stats") return Promise.resolve(systemStatsFixture());
if (path === "/settings/global") return Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 });
if (path === "/command-center/live") {
@@ -198,6 +262,9 @@ function injectCommandCenterCss() {
style.textContent = [
loadStylesCss(),
readFileSync(join(__dirname, "..", "CommandCenter.css"), "utf-8"),
readFileSync(join(__dirname, "..", "charts", "charts.css"), "utf-8"),
readFileSync(join(__dirname, "..", "areas", "areas.css"), "utf-8"),
readFileSync(join(__dirname, "..", "areas", "SystemStatsArea.css"), "utf-8"),
].join("\n");
document.head.appendChild(style);
}
@@ -235,6 +302,28 @@ function assertScrollOwnerContract(panel: HTMLElement) {
expect(window.getComputedStyle(tablist).flexShrink).toBe("0");
}
function assertNoChartScrollSteal(panel: HTMLElement) {
const chartContainers = panel.querySelectorAll<HTMLElement>(
".cc-bar-chart, .cc-bar-row, .cc-sparkline, .cc-line-chart, .cc-radial-gauge, .cc-funnel, .cc-token-series, .cc-token-series-plot, .cc-overview-chart-card, .cc-team-chart-panel, .cc-stat-card",
);
expect(chartContainers.length).toBeGreaterThan(0);
for (const container of chartContainers) {
const style = window.getComputedStyle(container);
expect(style.overflowY === "auto" || style.overflowY === "scroll").toBe(false);
expect(style.maxInlineSize === "100%" || style.maxWidth === "100%" || style.overflowX === "hidden" || style.display.length > 0).toBe(true);
}
}
async function openChartTab(tab: string) {
fireEvent.click(screen.getByTestId(`command-center-tab-${tab}`));
const panel = screen.getByTestId(`command-center-panel-${tab}`);
expect(panel).toBe(screen.getByRole("tabpanel"));
await vi.waitFor(() => {
expect(screen.queryByTestId(`cc-area-${tab}-loading`)).toBeNull();
});
return panel;
}
describe("CommandCenter mobile scroll regression (FN-6595)", () => {
beforeEach(() => {
apiMock.mockReset();
@@ -282,6 +371,39 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => {
assertScrollOwnerContract(screen.getByTestId("command-center-panel-overview"));
});
it("keeps every populated chart-bearing tab inside the mobile tabpanel scroll owner", async () => {
mockOverviewApi({ populated: true });
render(<CommandCenter />);
const overviewPanel = screen.getByTestId("command-center-panel-overview");
await screen.findByTestId("command-center-overview-charts");
assertScrollOwnerContract(overviewPanel);
assertNoChartScrollSteal(overviewPanel);
for (const tab of ["tokens", "tools", "activity", "productivity", "team", "ecosystem", "github", "signals", "system"]) {
const panel = await openChartTab(tab);
if (tab === "system") await screen.findByTestId("cc-area-system");
assertScrollOwnerContract(panel);
assertNoChartScrollSteal(panel);
expect(panel.textContent).not.toContain("NaN");
}
});
it("encodes the mobile chart CSS fixes for the discovered overflow primitives", () => {
const styles = Array.from(document.head.querySelectorAll("style"))
.map((style) => style.textContent ?? "")
.join("\n");
expect(styles).toContain(".cc-tabpanel");
expect(styles).toContain("overflow-x: hidden");
expect(styles).toContain("grid-template-columns: minmax(0, 1fr) minmax(var(--space-12), 2fr)");
expect(styles).toContain(".cc-line-chart");
expect(styles).toContain("aspect-ratio: auto");
expect(styles).toContain(".cc-radial-gauge-ring");
expect(styles).toContain("inline-size: clamp(var(--space-20), 44vw, var(--space-32))");
expect(styles).toContain("min-inline-size: 0");
});
it("keeps the same flex-fill scroll-owner contract outside the mobile breakpoint", () => {
mockMobileMatchMedia(false);
render(<CommandCenter />);

View File

@@ -17,6 +17,7 @@ import { ProductivityArea } from "../ProductivityArea";
import { GithubArea } from "../GithubArea";
import { SignalsArea } from "../SignalsArea";
import { ActivityArea } from "../ActivityArea";
import { EcosystemArea } from "../EcosystemArea";
import { useAnalyticsArea } from "../useAnalyticsArea";
import type { DateRange } from "../DateRangePicker";
@@ -492,7 +493,7 @@ describe("ToolsArea", () => {
});
describe("ProductivityArea", () => {
it("renders unavailable LOC as the dash sentinel, never 0", async () => {
it("renders unavailable LOC as the dash sentinel, never 0 and keeps chart geometry finite", async () => {
apiMock.mockResolvedValue({
from: "2026-06-08",
to: null,
@@ -509,6 +510,50 @@ describe("ProductivityArea", () => {
expect(loc.getAttribute("title")).toBeTruthy();
// The commits outcome counter still shows a real number.
expect(screen.getByTestId("cc-productivity-commits").textContent).toContain("4");
expect(screen.getByRole("list", { name: "Files by language" })).toBeTruthy();
expect(screen.getByTestId("cc-area-productivity").textContent).not.toContain("NaN");
});
it("renders empty, loading, and error states without empty chart shells", async () => {
apiMock.mockResolvedValueOnce({
from: null,
to: null,
modifiedFiles: 0,
byLanguage: [],
commits: 0,
pullRequests: 0,
loc: { value: null, unavailable: true },
});
const { unmount } = render(<ProductivityArea range={range7d} />);
await screen.findByTestId("cc-area-productivity-empty");
expect(screen.queryByRole("list", { name: "Files by language" })).toBeNull();
unmount();
apiMock.mockImplementationOnce(() => new Promise(() => undefined));
const pending = render(<ProductivityArea range={range7d} />);
expect(screen.getByTestId("cc-area-productivity-loading")).toBeTruthy();
pending.unmount();
apiMock.mockRejectedValueOnce(new Error("productivity failed"));
render(<ProductivityArea range={range7d} />);
await screen.findByTestId("cc-area-productivity-error");
expect(screen.getByTestId("cc-area-productivity-error").textContent).toContain("productivity failed");
});
});
describe("EcosystemArea", () => {
it("renders populated and empty model chart states without NaN or empty chart shells", async () => {
apiMock.mockResolvedValueOnce(tokenFixture());
const { unmount } = render(<EcosystemArea range={range7d} />);
await screen.findByTestId("cc-area-ecosystem");
expect(screen.getByRole("list", { name: "Tasks per model" })).toBeTruthy();
expect(screen.getByTestId("cc-area-ecosystem").textContent).not.toContain("NaN");
unmount();
apiMock.mockResolvedValueOnce({ ...tokenFixture(), groups: [], totals: { ...tokenFixture().totals, totalTokens: 0, nTasks: 0 } });
render(<EcosystemArea range={range7d} />);
await screen.findByTestId("cc-area-ecosystem-empty");
expect(screen.queryByRole("list", { name: "Tasks per model" })).toBeNull();
});
});

View File

@@ -10,13 +10,19 @@ Area headings, table metadata, and empty states use --text-muted so the analytic
.cc-area {
display: flex;
flex-direction: column;
gap: var(--space-4, 1rem);
gap: var(--space-4);
min-inline-size: 0;
}
/*
FNXC:CommandCenterStyling 2026-06-18-00:00:
Chart-bearing Command Center areas use one tokenized section rhythm and logical shrink bounds so mobile charts stay inside .cc-tabpanel without scroll-steal (FN-6664).
*/
.cc-area-section {
display: flex;
flex-direction: column;
gap: var(--space-2, 0.5rem);
gap: var(--space-3);
min-inline-size: 0;
}
.cc-area-section-header {
@@ -28,7 +34,7 @@ Area headings, table metadata, and empty states use --text-muted so the analytic
.cc-area-section-title {
margin: 0;
font-size: var(--font-size-sm, 0.85rem);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
@@ -39,11 +45,11 @@ Area headings, table metadata, and empty states use --text-muted so the analytic
.cc-area .cc-stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: var(--space-3, 0.75rem);
gap: var(--space-3);
}
.cc-stat-sub {
font-size: var(--font-size-xs, 0.75rem);
font-size: var(--font-size-xs);
color: var(--text-muted);
}
@@ -98,22 +104,31 @@ The Tokens area needs a real hour/day/week control and live token-number motion.
}
/* ---- Tables ---- */
/*
FNXC:CommandCenterStyling 2026-06-18-00:00:
Command Center table-heavy chart areas share the same border/radius rhythm as chart cards while keeping horizontal table overflow scoped to the table wrapper, not the mobile tabpanel vertical scroll owner (FN-6664).
*/
.cc-table-wrap {
max-inline-size: 100%;
border: var(--border-width) solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-1);
overflow-x: auto;
overflow-y: hidden;
}
.cc-table {
width: 100%;
inline-size: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm, 0.85rem);
font-size: var(--font-size-sm);
font-variant-numeric: tabular-nums;
}
.cc-table th,
.cc-table td {
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
padding: var(--space-2) var(--space-3);
text-align: right;
border-bottom: 1px solid var(--border-subtle, rgba(127, 127, 127, 0.2));
border-bottom: var(--border-width) solid var(--border-subtle);
white-space: nowrap;
}
@@ -133,7 +148,7 @@ The Tokens area needs a real hour/day/week control and live token-number motion.
}
.cc-table th.cc-sortable:hover {
color: var(--text-primary, #ddd);
color: var(--text-primary);
}
.cc-table tbody tr {
@@ -141,31 +156,31 @@ The Tokens area needs a real hour/day/week control and live token-number motion.
}
.cc-table tbody tr.cc-row-selected {
background: var(--surface-2, rgba(127, 127, 127, 0.12));
background: var(--surface-2);
}
.cc-table tbody tr.cc-row-selected td {
color: var(--text-primary, #ddd);
color: var(--text-primary);
}
.cc-sort-caret {
margin-left: var(--space-1, 0.25rem);
margin-inline-start: var(--space-1);
font-size: 0.7em;
color: var(--color-accent, #4f8cff);
color: var(--color-accent);
}
/* Unavailable sentinel ("—") with a help cursor for its tooltip. */
.cc-unavailable {
color: var(--text-muted);
cursor: help;
border-bottom: 1px dotted var(--border-subtle, rgba(127, 127, 127, 0.4));
border-bottom: var(--border-width) dotted var(--border-subtle);
}
.cc-loading-inline {
display: flex;
align-items: center;
gap: var(--space-2, 0.5rem);
padding: var(--space-4, 1rem);
gap: var(--space-2);
padding: var(--space-4);
color: var(--text-muted);
}
@@ -174,18 +189,18 @@ The Tokens area needs a real hour/day/week control and live token-number motion.
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2, 0.5rem);
padding: var(--space-6, 2rem);
gap: var(--space-2);
padding: var(--space-6);
color: var(--text-muted);
text-align: center;
}
.cc-area-error {
color: var(--color-error, #e5484d);
color: var(--color-error);
}
.cc-pricing-note {
font-size: var(--font-size-xs, 0.75rem);
font-size: var(--font-size-xs);
color: var(--text-muted);
}
@@ -202,8 +217,8 @@ The Team view must use dashboard design tokens only, preserve .cc-tabpanel as th
.cc-team-chart-panel {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
gap: var(--space-3);
min-inline-size: 0;
padding: var(--space-3);
border: var(--border-width) solid var(--border-subtle);
border-radius: var(--radius-md);
@@ -229,7 +244,7 @@ The Team view must use dashboard design tokens only, preserve .cc-tabpanel as th
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
min-inline-size: 0;
}
.cc-team-agent-name,

View File

@@ -10,6 +10,22 @@ Chart labels and legends must use --text-muted so command-center CSS stays align
* invalidate animation declarations (see animation-duration-tokens.css.test.ts).
*/
/*
FNXC:CommandCenterStyling 2026-06-18-00:00:
Command Center charts must render within the mobile tabpanel without overflow scroll-steal, zero-height collapse, or stretch; every primitive opts into logical shrink bounds before area layouts compose it (FN-6664).
*/
.cc-bar-chart,
.cc-stacked-bar,
.cc-sparkline,
.cc-token-series,
.cc-token-series-plot,
.cc-line-chart,
.cc-radial-gauge,
.cc-funnel {
min-inline-size: 0;
max-inline-size: 100%;
}
/* ---- Bar ---- */
.cc-bar-chart {
list-style: none;
@@ -17,18 +33,18 @@ Chart labels and legends must use --text-muted so command-center CSS stays align
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2, 0.5rem);
gap: var(--space-2);
}
.cc-bar-row {
display: grid;
grid-template-columns: minmax(6rem, 12rem) 1fr auto;
grid-template-columns: minmax(var(--space-20), 32%) minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2, 0.5rem);
gap: var(--space-2);
}
.cc-bar-label {
font-size: var(--font-size-sm, 0.85rem);
font-size: var(--font-size-sm);
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
@@ -37,43 +53,43 @@ Chart labels and legends must use --text-muted so command-center CSS stays align
.cc-bar-track {
position: relative;
height: 0.75rem;
background: var(--surface-2, rgba(127, 127, 127, 0.12));
border-radius: var(--radius-sm, 4px);
block-size: var(--space-3);
background: var(--surface-2);
border-radius: var(--radius-sm);
overflow: hidden;
}
.cc-bar-fill {
height: 100%;
background: var(--color-accent, #4f8cff);
border-radius: var(--radius-sm, 4px);
block-size: 100%;
background: var(--color-accent);
border-radius: var(--radius-sm);
transition: width var(--transition-normal);
}
.cc-bar-value {
font-size: var(--font-size-sm, 0.85rem);
font-size: var(--font-size-sm);
font-variant-numeric: tabular-nums;
color: var(--text-primary, #ddd);
color: var(--text-primary);
}
/* ---- StackedBar ---- */
.cc-stacked-bar {
display: flex;
flex-direction: column;
gap: var(--space-2, 0.5rem);
gap: var(--space-2);
}
.cc-stacked-track {
display: flex;
height: 0.75rem;
background: var(--surface-2, rgba(127, 127, 127, 0.12));
border-radius: var(--radius-sm, 4px);
block-size: var(--space-3);
background: var(--surface-2);
border-radius: var(--radius-sm);
overflow: hidden;
}
.cc-stacked-segment {
height: 100%;
background: var(--color-accent, #4f8cff);
block-size: 100%;
background: var(--color-accent);
transition: width var(--transition-normal);
}
@@ -83,22 +99,22 @@ Chart labels and legends must use --text-muted so command-center CSS stays align
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--space-3, 0.75rem);
gap: var(--space-3);
}
.cc-stacked-legend-item {
display: flex;
align-items: center;
gap: var(--space-1, 0.25rem);
font-size: var(--font-size-sm, 0.85rem);
gap: var(--space-1);
font-size: var(--font-size-sm);
color: var(--text-muted);
}
.cc-stacked-swatch {
width: 0.6rem;
height: 0.6rem;
border-radius: 2px;
background: var(--color-accent, #4f8cff);
inline-size: var(--space-2);
block-size: var(--space-2);
border-radius: var(--radius-sm);
background: var(--color-accent);
display: inline-block;
}
@@ -106,15 +122,15 @@ Chart labels and legends must use --text-muted so command-center CSS stays align
.cc-sparkline {
display: flex;
align-items: flex-end;
gap: 1px;
height: 2rem;
gap: var(--border-width);
block-size: var(--space-8);
}
.cc-sparkline-bar {
flex: 1 1 0;
min-width: 1px;
background: var(--color-accent, #4f8cff);
border-radius: 1px;
min-inline-size: var(--border-width);
background: var(--color-accent);
border-radius: var(--border-width);
transition: height var(--transition-normal);
}
@@ -205,7 +221,7 @@ Line-chart motion is decorative, token-timed, and disabled for reduced-motion us
block-size: clamp(var(--space-16), 22vw, calc(var(--space-20) * 2));
aspect-ratio: 5 / 2;
color: var(--color-accent);
overflow: visible;
overflow: hidden;
}
.cc-line-chart-series {
@@ -252,7 +268,8 @@ Line-chart motion is decorative, token-timed, and disabled for reduced-motion us
@media (max-width: 768px) {
.cc-line-chart {
block-size: clamp(var(--space-14), 34vw, calc(var(--space-20) + var(--space-12)));
block-size: clamp(var(--space-16), 44vw, calc(var(--space-20) + var(--space-12)));
aspect-ratio: auto;
}
}
@@ -268,7 +285,7 @@ Line-chart motion is decorative, token-timed, and disabled for reduced-motion us
position: relative;
display: grid;
place-items: center;
width: clamp(6rem, 32vw, 9rem);
inline-size: clamp(var(--space-24), 32vw, var(--space-36));
aspect-ratio: 1;
border-radius: 50%;
background:
@@ -284,7 +301,7 @@ Line-chart motion is decorative, token-timed, and disabled for reduced-motion us
position: absolute;
inset: var(--space-2);
border-radius: inherit;
border: var(--border-width, 0.0625rem) solid color-mix(in srgb, var(--color-accent) 45%, transparent);
border: var(--border-width) solid color-mix(in srgb, var(--color-accent) 45%, transparent);
box-shadow: inset 0 0 var(--space-3) color-mix(in srgb, var(--color-accent) 22%, transparent);
animation: cc-radial-gauge-sweep calc(var(--duration-slow) * 8) linear infinite;
}
@@ -294,7 +311,7 @@ Line-chart motion is decorative, token-timed, and disabled for reduced-motion us
z-index: 1;
display: grid;
place-items: center;
width: 58%;
inline-size: 58%;
aspect-ratio: 1;
background: var(--surface-1);
border-radius: 50%;
@@ -346,19 +363,19 @@ Line-chart motion is decorative, token-timed, and disabled for reduced-motion us
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2, 0.5rem);
gap: var(--space-2);
}
.cc-funnel-stage {
display: flex;
flex-direction: column;
gap: var(--space-1, 0.25rem);
gap: var(--space-1);
}
.cc-funnel-header {
display: flex;
justify-content: space-between;
font-size: var(--font-size-sm, 0.85rem);
font-size: var(--font-size-sm);
color: var(--text-muted);
}
@@ -367,30 +384,30 @@ Line-chart motion is decorative, token-timed, and disabled for reduced-motion us
}
.cc-funnel-track {
height: 1rem;
background: var(--surface-2, rgba(127, 127, 127, 0.12));
border-radius: var(--radius-sm, 4px);
block-size: var(--space-4);
background: var(--surface-2);
border-radius: var(--radius-sm);
overflow: hidden;
}
.cc-funnel-fill {
height: 100%;
background: var(--color-accent, #4f8cff);
border-radius: var(--radius-sm, 4px);
block-size: 100%;
background: var(--color-accent);
border-radius: var(--radius-sm);
transition: width var(--transition-normal);
}
.cc-funnel-value {
font-size: var(--font-size-sm, 0.85rem);
font-size: var(--font-size-sm);
font-variant-numeric: tabular-nums;
color: var(--text-primary, #ddd);
color: var(--text-primary);
}
/* ---- Loading shimmer (used by chart skeletons) ---- */
.cc-chart-skeleton {
height: 0.75rem;
border-radius: var(--radius-sm, 4px);
background: var(--surface-2, rgba(127, 127, 127, 0.12));
block-size: var(--space-3);
border-radius: var(--radius-sm);
background: var(--surface-2);
animation: cc-chart-pulse var(--duration-slow) ease-in-out infinite;
}
@@ -403,3 +420,23 @@ Line-chart motion is decorative, token-timed, and disabled for reduced-motion us
opacity: 0.9;
}
}
@media (max-width: 768px) {
.cc-bar-row {
grid-template-columns: minmax(0, 1fr) minmax(var(--space-12), 2fr);
}
.cc-bar-value {
grid-column: 1 / -1;
justify-self: end;
}
.cc-radial-gauge-ring {
inline-size: clamp(var(--space-20), 44vw, var(--space-32));
}
.cc-sparkline {
block-size: clamp(var(--space-8), 18vw, var(--space-14));
}
}