fix(ui): improve React Flow dark theme (#7553)

* fix(ui): theme provider topology in dark mode

* fix(i18n): isolate topology translation keys

* refactor(i18n): reuse existing topology labels

---------

Co-authored-by: nguyenha935 <208228297+nguyenha935@users.noreply.github.com>
This commit is contained in:
nguyenha935
2026-07-19 01:14:28 +07:00
committed by GitHub
parent a7d08a43c3
commit 12bf0ed077
8 changed files with 51 additions and 28 deletions

View File

@@ -0,0 +1 @@
- **fix(ui):** Theme React Flow controls correctly in dark mode, improve idle connector contrast, and localize the provider topology legend.

View File

@@ -27,9 +27,14 @@ export function HomeProviderTopologySection({
enabled?: boolean;
}) {
const t = useTranslations("home");
const tCommon = useTranslations("common");
const tSettings = useTranslations("settings");
const tAnalytics = useTranslations("analytics");
// #4596: gate the live-WS connection so it only opens while the topology
// section is actually shown on the home page.
const { activeRequests: liveActiveRequests } = useLiveRequests({ enabled });
const activeRequests = selectActiveRequests(liveActiveRequests);
const activeProviderCount = new Set(activeRequests.map(({ provider }) => provider)).size;
return (
<Card>
@@ -37,24 +42,27 @@ export function HomeProviderTopologySection({
<div>
<h2 className="text-base font-semibold">{t("providerTopology")}</h2>
<p className="text-xs text-text-muted">
Connected providers routing through OmniRoute in real time
{t("activeError", { active: activeProviderCount, errors: errorProvider ? 1 : 0 })}
</p>
</div>
<div className="flex items-center gap-3 text-[11px] text-text-muted">
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-full bg-green-500" /> Active
<span className="size-2 rounded-full bg-green-500" />
{tCommon("active")}
</span>
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-full bg-amber-500" /> Recent
<span className="size-2 rounded-full bg-amber-500" />
{tSettings("recent")}
</span>
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-full bg-red-500" /> Error
<span className="size-2 rounded-full bg-red-500" />
{tAnalytics("modelStatusError")}
</span>
</div>
</div>
<ProviderTopology
providers={providers}
activeRequests={selectActiveRequests(liveActiveRequests)}
activeRequests={activeRequests}
lastProvider={lastProvider}
errorProvider={errorProvider}
/>

View File

@@ -482,19 +482,15 @@ select option {
color: var(--color-text-main);
}
/* ReactFlow Controls (zoom +/- buttons) dark mode theming */
.react-flow__controls-button {
background-color: var(--color-surface);
border-bottom-color: var(--color-border);
fill: var(--color-text-main);
}
.react-flow__controls-button:hover {
background-color: var(--color-bg-subtle);
}
.react-flow__controls-button svg {
fill: var(--color-text-main);
/* React Flow loads its component stylesheet after this global file in some
bundles, so direct background/fill declarations can lose to its shorthand
rules. Override the supported theme variables on our canvas instead. */
.react-flow.omniroute-flow {
--xy-controls-button-background-color: var(--color-surface);
--xy-controls-button-background-color-hover: var(--color-bg-subtle);
--xy-controls-button-color: var(--color-text-main);
--xy-controls-button-color-hover: var(--color-text-main);
--xy-controls-button-border-color: var(--color-border);
}
/* Desktop sidebar visibility must not depend on Tailwind's hidden/lg:flex cascade

View File

@@ -121,6 +121,7 @@ export function FlowCanvas({
<div ref={containerRef} className={className}>
<ReactFlow
key={fitKey}
className="omniroute-flow"
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}

View File

@@ -10,7 +10,7 @@ export const FLOW_EDGE_COLORS = {
active: STATUS_HEX.success,
error: STATUS_HEX.error,
last: STATUS_HEX.warning,
idle: "var(--color-border)",
idle: "var(--color-text-muted)",
} as const;
export interface FlowEdgeStyle {
@@ -28,5 +28,5 @@ export function edgeStyle(active: boolean, last: boolean, error: boolean): FlowE
if (error) return { stroke: FLOW_EDGE_COLORS.error, strokeWidth: 2, opacity: 0.85 };
if (active) return { stroke: FLOW_EDGE_COLORS.active, strokeWidth: 2.5, opacity: 1 };
if (last) return { stroke: FLOW_EDGE_COLORS.last, strokeWidth: 1.5, opacity: 0.6 };
return { stroke: FLOW_EDGE_COLORS.idle, strokeWidth: 1, opacity: 0.2 };
return { stroke: FLOW_EDGE_COLORS.idle, strokeWidth: 1, opacity: 0.3 };
}

View File

@@ -8,7 +8,7 @@ describe("flow edgeStyles (U0 — extracted from ProviderTopology)", () => {
assert.equal(FLOW_EDGE_COLORS.active, "#22c55e");
assert.equal(FLOW_EDGE_COLORS.error, "#ef4444");
assert.equal(FLOW_EDGE_COLORS.last, "#f59e0b");
assert.equal(FLOW_EDGE_COLORS.idle, "var(--color-border)");
assert.equal(FLOW_EDGE_COLORS.idle, "var(--color-text-muted)");
});
it("styles an error edge", () => {
@@ -37,9 +37,9 @@ describe("flow edgeStyles (U0 — extracted from ProviderTopology)", () => {
it("styles an idle edge", () => {
assert.deepEqual(edgeStyle(false, false, false), {
stroke: "var(--color-border)",
stroke: "var(--color-text-muted)",
strokeWidth: 1,
opacity: 0.2,
opacity: 0.3,
});
});

View File

@@ -1,4 +1,6 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
@@ -48,7 +50,7 @@ const edges = [{ id: "a-b", source: "a", target: "b" }];
describe("FlowCanvas (U0 — shared ReactFlow wrapper)", () => {
it("renders the canvas with Controls and hides the attribution", () => {
const container = mount(<FlowCanvas nodes={nodes} edges={edges} fitKey="x" />);
expect(container.querySelector(".react-flow")).toBeTruthy();
expect(container.querySelector(".react-flow.omniroute-flow")).toBeTruthy();
expect(container.querySelector(".react-flow__controls")).toBeTruthy();
// proOptions.hideAttribution => the attribution element must not render.
expect(container.querySelector(".react-flow__attribution")).toBeNull();
@@ -60,4 +62,13 @@ describe("FlowCanvas (U0 — shared ReactFlow wrapper)", () => {
);
expect(container.querySelector(".omni-test-canvas")).toBeTruthy();
});
it("themes controls through React Flow variables so library shorthands cannot override them", () => {
const css = readFileSync(resolve("src/app/globals.css"), "utf8");
expect(css).toContain(".react-flow.omniroute-flow");
expect(css).toContain("--xy-controls-button-background-color: var(--color-surface)");
expect(css).toContain("--xy-controls-button-color: var(--color-text-main)");
expect(css).toContain("--xy-controls-button-border-color: var(--color-border)");
});
});

View File

@@ -15,7 +15,10 @@ vi.mock("next-intl", () => ({
}));
vi.mock("next/dynamic", () => ({
default: () => (props: Record<string, unknown>) => (
<div data-testid="provider-topology" data-providers={String((props.providers as unknown[])?.length ?? 0)} />
<div
data-testid="provider-topology"
data-providers={String((props.providers as unknown[])?.length ?? 0)}
/>
),
}));
vi.mock("@/shared/components", () => ({
@@ -26,9 +29,8 @@ vi.mock("@/hooks/useLiveDashboard", () => ({
useLiveRequests: () => liveRequestsMock(),
}));
const { HomeProviderTopologySection } = await import(
"../../../src/app/(dashboard)/dashboard/HomeProviderTopologySection"
);
const { HomeProviderTopologySection } =
await import("../../../src/app/(dashboard)/dashboard/HomeProviderTopologySection");
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
@@ -63,4 +65,8 @@ it("renders the topology card and forwards providers to ProviderTopology", () =>
const topology = container.querySelector("[data-testid='provider-topology']");
expect(topology).not.toBeNull();
expect(topology?.getAttribute("data-providers")).toBe("2");
expect(container.textContent).toContain("activeError");
expect(container.textContent).toContain("active");
expect(container.textContent).toContain("recent");
expect(container.textContent).toContain("modelStatusError");
});