+ {React.cloneElement(children, {
+ router,
+ isSoftwareEnabled: Boolean(
+ softwareConfig?.features?.enable_software_inventory
+ ),
+ perPage: DEFAULT_PAGE_SIZE,
+ orderDirection: sortDirection,
+ orderKey: sortHeader,
+ currentPage: page,
+ teamId: teamIdForApi,
+ // TODO: move down into the Software Titles component
+ platform,
+ query,
+ showExploitedVulnerabilitiesOnly,
+ softwareFilter,
+ vulnFilters: softwareVulnFilters,
+ onAddFiltersClick: toggleSoftwareFiltersModal,
+ })}
+
);
};
diff --git a/frontend/pages/admin/AdminWrapper.tsx b/frontend/pages/admin/AdminWrapper.tsx
index 6b73bf256b..0bd1cb6f44 100644
--- a/frontend/pages/admin/AdminWrapper.tsx
+++ b/frontend/pages/admin/AdminWrapper.tsx
@@ -98,7 +98,9 @@ const AdminWrapper = ({
- {children}
+ {
- it("renders loading spinner when isLoading is true", () => {
+ it("renders loading spinner when isLoading is true", async () => {
const props = createTestProps({ isLoading: true });
const render = createCustomRenderer();
render();
- expect(screen.getByTestId("spinner")).toBeInTheDocument();
+ // Spinner has a built-in anti-flash delay, so wait for it to appear.
+ expect(await screen.findByTestId("spinner")).toBeInTheDocument();
});
it("renders error state when isError is true", () => {
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/components/TileActionStatus/TileActionStatus.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/components/TileActionStatus/TileActionStatus.tsx
index 446a21f4bc..ce0c841d2c 100644
--- a/frontend/pages/hosts/details/cards/Software/SelfService/components/TileActionStatus/TileActionStatus.tsx
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/components/TileActionStatus/TileActionStatus.tsx
@@ -96,7 +96,12 @@ const TileActionStatus = ({
const renderActiveActionStatus = () => {
return (
<>
-
+
{getPendingOrRunningLabel(software.ui_status)}
>
);
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/components/UpdatesCard/UpdateSoftwareItem/UpdateSoftwareItem.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/components/UpdatesCard/UpdateSoftwareItem/UpdateSoftwareItem.tsx
index 5831b53f1d..a274ea26fd 100644
--- a/frontend/pages/hosts/details/cards/Software/SelfService/components/UpdatesCard/UpdateSoftwareItem/UpdateSoftwareItem.tsx
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/components/UpdatesCard/UpdateSoftwareItem/UpdateSoftwareItem.tsx
@@ -118,7 +118,12 @@ const InstallerStatus = ({
>
{displayConfig.iconName === "pending-outline" && (
-
+
)}
{last_install && displayConfig.displayText === "Failed" && (
@@ -171,7 +176,12 @@ const InstallerStatusAction = ({
if (ui_status === "updating") {
return (
<>
- {" "}
+ {" "}
Updating...{" "}
>
);
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/components/UpdatesCard/UpdatesCard.tests.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/components/UpdatesCard/UpdatesCard.tests.tsx
index 84f3d59bf7..2f7cc00134 100644
--- a/frontend/pages/hosts/details/cards/Software/SelfService/components/UpdatesCard/UpdatesCard.tests.tsx
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/components/UpdatesCard/UpdatesCard.tests.tsx
@@ -109,7 +109,7 @@ describe("UpdatesCard", () => {
expect(button).toBeDisabled();
});
- it("shows Spinner while loading", () => {
+ it("shows Spinner while loading", async () => {
// Non-empty enhancedSoftware, isLoading
const updates = createEnhancedSoftware(1);
render(
@@ -122,7 +122,8 @@ describe("UpdatesCard", () => {
isError={false}
/>
);
- expect(screen.getByTestId("spinner")).toBeInTheDocument();
+ // Spinner has a built-in anti-flash delay, so wait for it to appear.
+ expect(await screen.findByTestId("spinner")).toBeInTheDocument();
});
it("shows error view when isError is set", () => {
diff --git a/frontend/pages/hosts/details/modals/MDMStatusModal/MDMStatusModal.tests.tsx b/frontend/pages/hosts/details/modals/MDMStatusModal/MDMStatusModal.tests.tsx
index a36084fa0f..0749b38b1b 100644
--- a/frontend/pages/hosts/details/modals/MDMStatusModal/MDMStatusModal.tests.tsx
+++ b/frontend/pages/hosts/details/modals/MDMStatusModal/MDMStatusModal.tests.tsx
@@ -156,7 +156,7 @@ describe("MDMStatusModal - component", () => {
expect(screen.queryByText("Assigned")).not.toBeInTheDocument();
});
- it("shows spinner while DEP assignment is loading", () => {
+ it("shows spinner while DEP assignment is loading", async () => {
(hostAPI.getDepAssignment as jest.Mock).mockReturnValue(
new Promise(() => {
// never resolve
@@ -174,7 +174,8 @@ describe("MDMStatusModal - component", () => {
/>
);
- expect(screen.getByTestId("spinner")).toBeVisible();
+ // Spinner has a built-in anti-flash delay, so wait for it to appear.
+ expect(await screen.findByTestId("spinner")).toBeVisible();
});
it("shows DataError if DEP assignment fails", async () => {
diff --git a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tests.tsx b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tests.tsx
index 8de787e692..28bf50577c 100644
--- a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tests.tsx
+++ b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tests.tsx
@@ -734,7 +734,7 @@ describe("EditQueryForm - component", () => {
});
expect(
- screen.getByText(/Creating a new report for/i)
+ await screen.findByText(/Creating a new report for/i)
).toBeInTheDocument();
expect(screen.getByText("Engineering team")).toBeInTheDocument();
});
@@ -779,7 +779,9 @@ describe("EditQueryForm - component", () => {
expect(screen.queryByTestId("spinner")).not.toBeInTheDocument();
});
- expect(screen.getByText(/Running a new report for/i)).toBeInTheDocument();
+ expect(
+ await screen.findByText(/Running a new report for/i)
+ ).toBeInTheDocument();
expect(screen.getByText("Engineering team")).toBeInTheDocument();
});
});
diff --git a/frontend/styles/global/_global.scss b/frontend/styles/global/_global.scss
index 0fcd84407e..80361d75de 100644
--- a/frontend/styles/global/_global.scss
+++ b/frontend/styles/global/_global.scss
@@ -268,6 +268,45 @@ body.dark-mode pre {
background-color: #111214;
}
+body.dark-mode .data-table-block thead,
+body.dark-mode thead {
+ background-color: $dark-mode-table-header;
+}
+
+body.dark-mode .data-table-block tbody tr,
+body.dark-mode tbody tr {
+ background-color: $dark-mode-table-row;
+}
+
+body.dark-mode .site-nav-item:not(.dup-org-logo):hover {
+ background-color: $dark-mode-nav-hover;
+}
+
+body.dark-mode .card .button--inverse:hover,
+body.dark-mode .card .button--inverse-alert:hover,
+body.dark-mode .card .button--text-icon:hover,
+body.dark-mode .card .button--icon:hover {
+ background-color: $core-fleet-white;
+}
+
+body.dark-mode .checkbox-unchecked-state {
+ fill: $core-fleet-white;
+ stroke: $ui-fleet-black-25;
+}
+
+// react-select v2 (DropdownWrapper) uses inline styles via COLORS proxy,
+// so !important is needed to override them in dark mode.
+body.dark-mode .react-select__control {
+ background-color: $ui-fleet-black-5 !important;
+}
+
+// Legacy react-select v1 (Dropdown) uses SCSS-applied backgrounds.
+body.dark-mode .Select .Select-control,
+body.dark-mode .Select .Select-control .Select-value,
+body.dark-mode .Select .Select-value {
+ background-color: $ui-fleet-black-5;
+}
+
hr {
margin-top: $pad-xlarge;
margin-bottom: $pad-xlarge;
diff --git a/frontend/styles/var/colors.scss b/frontend/styles/var/colors.scss
index dcd506c10d..0280a7346b 100644
--- a/frontend/styles/var/colors.scss
+++ b/frontend/styles/var/colors.scss
@@ -85,6 +85,9 @@
--core-fleet-blue-over: #303860;
--core-fleet-blue-down: #192147;
+ // Nav active underline
+ --nav-active-underline: var(--core-fleet-black);
+
// Overlay / semi-transparent (used in Modal & Button)
--core-fleet-black-overlay-40: rgba(25, 33, 71, 0.4);
--core-fleet-black-overlay-05: rgba(25, 33, 71, 0.05);
@@ -100,20 +103,20 @@ body.dark-mode {
// Base #181a1f → Surface-0 #1e2128 → Surface-1 #252830
// → Surface-2 #32363e → Surface-3 #42464f
--core-fleet-black: #e2e4ea;
- --core-fleet-green: #009a7d;
- --core-fleet-white: #181a1f;
- --ui-fleet-black-75: #b3b6c1;
- --ui-fleet-black-50: #8b8fa2;
+ --core-fleet-green: #00C28B;
+ --core-fleet-white: #1a1c21;
+ --ui-fleet-black-75: #BEBEBF;
+ --ui-fleet-black-50: #87888B;
--ui-fleet-black-33: #636777;
--ui-fleet-black-25: #42464f;
- --ui-fleet-black-10: #32363e;
- --ui-fleet-black-5: #252830;
+ --ui-fleet-black-10: #474c58;
+ --ui-fleet-black-5: #25272D;
// Secondary / interaction
--ui-fleet-black-75-over: #c5c7d1;
--ui-fleet-black-75-down: #d5d7de;
- --core-fleet-green-over: #01a889;
- --core-fleet-green-down: #02be9c;
+ --core-fleet-green-over: #01A889;
+ --core-fleet-green-down: #02BE9C;
--ui-fleet-black-5-down: #2c2f37;
// Core accent — slightly brighter for dark-bg contrast
@@ -163,7 +166,8 @@ body.dark-mode {
--rainbow-blue: #70bbea;
// Gradients — slightly lighter top for subtle depth
- --gradient-background: #1c1f25;
+ --gradient-background: #202226;
+
// Button hover / active
--core-vibrant-red-over: #ff8da5;
@@ -175,6 +179,9 @@ body.dark-mode {
--core-fleet-blue-over: #7a7f96;
--core-fleet-blue-down: #e2e4ea;
+ // Nav active underline
+ --nav-active-underline: var(--core-fleet-green);
+
// Overlays
--core-fleet-black-overlay-40: rgba(0, 0, 0, 0.6);
--core-fleet-black-overlay-05: rgba(226, 228, 234, 0.06);
@@ -196,6 +203,7 @@ $ui-fleet-black-25: var(--ui-fleet-black-25);
$ui-fleet-black-10: var(--ui-fleet-black-10);
$ui-fleet-black-5: var(--ui-fleet-black-5);
$core-focused-outline: var(--core-fleet-black);
+$nav-active-underline: var(--nav-active-underline);
// 2025 secondary colors
$ui-fleet-black-75-over: var(--ui-fleet-black-75-over);
@@ -271,6 +279,9 @@ $loading-overlay: var(--loading-overlay);
// Use for elements that are always dark surfaces with light text (tooltips, code blocks).
$static-white: #e8eaf0;
$static-black: #192147;
+$dark-mode-table-header: #282c33;
+$dark-mode-table-row: #1f2229;
+$dark-mode-nav-hover: #1f2228;
// Opaque colors for table shadows — compile-time SCSS math, not themed.
// These are subtle edge effects; dark-mode polish can refine them later.
diff --git a/frontend/styles/var/colors.ts b/frontend/styles/var/colors.ts
index 16f31bd146..b29032d81f 100644
--- a/frontend/styles/var/colors.ts
+++ b/frontend/styles/var/colors.ts
@@ -46,6 +46,11 @@ const STATIC_COLORS = {
"core-vibrant-blue-down": "#4b4ab4",
"ui-vibrant-blue-25": "#d9d9fe",
"ui-vibrant-blue-10": "#f1f0ff",
+
+ // Static (un-themed): same value in light AND dark mode. Use for foreground
+ // on always-colored surfaces (flash toasts, tooltips, etc.)
+ "static-white": "#e8eaf0",
+ "static-black": "#192147",
} as const;
export type Colors = keyof typeof STATIC_COLORS;
diff --git a/frontend/utilities/theme.ts b/frontend/utilities/theme.ts
index 6c361ca69f..b15c46a821 100644
--- a/frontend/utilities/theme.ts
+++ b/frontend/utilities/theme.ts
@@ -1,27 +1,43 @@
const THEME_KEY = "fleet-dark-mode";
const TRANSITION_MS = 300;
+const systemPrefersDark = (): boolean => {
+ return (
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-color-scheme: dark)").matches
+ );
+};
+
export const isDarkMode = (): boolean => {
- return localStorage.getItem(THEME_KEY) === "true";
+ // Explicit user choice wins; otherwise inherit the system preference so
+ // first-time visitors match their OS theme without us persisting anything.
+ const stored = localStorage.getItem(THEME_KEY);
+ if (stored !== null) {
+ return stored === "true";
+ }
+ return systemPrefersDark();
+};
+
+// Apply a theme change to the DOM and notify listeners. `animate` adds a
+// blanket transition class so the whole UI cross-fades instead of snapping.
+const applyDarkMode = (dark: boolean, animate: boolean): void => {
+ if (animate) {
+ document.body.classList.add("theme-transition");
+ setTimeout(() => {
+ document.body.classList.remove("theme-transition");
+ }, TRANSITION_MS);
+ }
+ document.body.classList.toggle("dark-mode", dark);
+ window.dispatchEvent(
+ new CustomEvent("fleet-theme-change", { detail: { dark } })
+ );
};
export const toggleDarkMode = (): boolean => {
const dark = !isDarkMode();
localStorage.setItem(THEME_KEY, String(dark));
-
- // Add a temporary class that applies a blanket transition to all elements
- // so the entire UI fades smoothly instead of individual pieces snapping.
- document.body.classList.add("theme-transition");
- document.body.classList.toggle("dark-mode", dark);
-
- setTimeout(() => {
- document.body.classList.remove("theme-transition");
- }, TRANSITION_MS);
-
- window.dispatchEvent(
- new CustomEvent("fleet-theme-change", { detail: { dark } })
- );
-
+ applyDarkMode(dark, true);
return dark;
};
@@ -29,4 +45,15 @@ export const initTheme = (): void => {
if (isDarkMode()) {
document.body.classList.add("dark-mode");
}
+
+ // Follow OS theme changes live — but only while the user has no explicit
+ // preference stored. Once they've toggled in-app, their choice sticks
+ // regardless of what the OS does.
+ if (typeof window !== "undefined" && window.matchMedia) {
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
+ media.addEventListener("change", (e) => {
+ if (localStorage.getItem(THEME_KEY) !== null) return;
+ applyDarkMode(e.matches, true);
+ });
+ }
};