diff --git a/frontend/components/AddHostsModal/AddHostsModal.tests.tsx b/frontend/components/AddHostsModal/AddHostsModal.tests.tsx index c262fe5b39..3aff96d231 100644 --- a/frontend/components/AddHostsModal/AddHostsModal.tests.tsx +++ b/frontend/components/AddHostsModal/AddHostsModal.tests.tsx @@ -23,7 +23,8 @@ describe("AddHostsModal", () => { render( ); - const loadingSpinner = screen.getByTestId("spinner"); + // Spinner has a built-in anti-flash delay, so wait for it to appear. + const loadingSpinner = await screen.findByTestId("spinner"); expect(loadingSpinner).toBeVisible(); }); diff --git a/frontend/components/FlashMessage/FlashMessage.tsx b/frontend/components/FlashMessage/FlashMessage.tsx index c774c01be1..0982667afd 100644 --- a/frontend/components/FlashMessage/FlashMessage.tsx +++ b/frontend/components/FlashMessage/FlashMessage.tsx @@ -95,7 +95,11 @@ const SingleFlashMessage = ({
{message}
@@ -108,9 +112,7 @@ const SingleFlashMessage = ({ diff --git a/frontend/components/FlashMessage/_styles.scss b/frontend/components/FlashMessage/_styles.scss index 09af01f3ef..256a73caa9 100644 --- a/frontend/components/FlashMessage/_styles.scss +++ b/frontend/components/FlashMessage/_styles.scss @@ -17,7 +17,9 @@ display: flex; align-items: center; justify-content: center; - color: $core-fleet-white; + // Use static (un-themed) white: the flash toast is always a colored + // surface, so foreground should stay light regardless of dark mode. + color: $static-white; padding: $pad-small $pad-medium; z-index: 999; background-color: $core-vibrant-blue; @@ -38,12 +40,24 @@ &--warning-filled { background-color: $ui-warning; + // Yellow is light enough that foreground should be dark in BOTH modes. + // Use static (un-themed) tokens so dark mode doesn't flip to light text. + color: $static-black; span { margin-left: 15px; margin-right: 15px; font-size: $x-small; - color: $core-fleet-black; + color: $static-black; + } + + .flash-message__remove .fleeticon, + .flash-message__remove .fleeticon:hover { + color: $static-black; + } + + .flash-message__undo { + color: $static-black; } } @@ -72,7 +86,7 @@ } &__undo { - color: $core-fleet-white; + color: $static-white; cursor: pointer; font-size: $small; text-decoration: underline; @@ -86,11 +100,11 @@ .fleeticon { transition: color 150ms ease-in-out; - color: $core-fleet-white; + color: $static-white; font-size: $small; &:hover { - color: $core-fleet-white; + color: $static-white; } } } diff --git a/frontend/components/Spinner/Spinner.tsx b/frontend/components/Spinner/Spinner.tsx index fb101b6499..5e73b5e4f5 100644 --- a/frontend/components/Spinner/Spinner.tsx +++ b/frontend/components/Spinner/Spinner.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import classnames from "classnames"; type Size = "x-small" | "small" | "medium"; @@ -18,6 +18,14 @@ interface ISpinnerProps { centered?: boolean; className?: string; variant?: "mobile"; + /** + * Delay in ms before the spinner becomes visible. If the spinner unmounts + * before the delay elapses, it never renders — avoiding a flash when the + * underlying load finishes quickly. Defaults to `250`. Pass `0` to show + * immediately (e.g. when a spinner represents ongoing work rather than a + * load, like pending install/uninstall states). + */ + delay?: number; } const Spinner = ({ @@ -30,7 +38,18 @@ const Spinner = ({ centered = true, className, variant = undefined, -}: ISpinnerProps): JSX.Element => { + delay = 250, +}: ISpinnerProps): JSX.Element | null => { + const [visible, setVisible] = useState(delay === 0); + + useEffect(() => { + if (delay === 0) return undefined; + const id = setTimeout(() => setVisible(true), delay); + return () => clearTimeout(id); + }, [delay]); + + if (!visible) return null; + const classOptions = classnames(`loading-spinner`, className, size, { small, button, diff --git a/frontend/components/TabNav/_styles.scss b/frontend/components/TabNav/_styles.scss index a037ce9e95..42878c6787 100644 --- a/frontend/components/TabNav/_styles.scss +++ b/frontend/components/TabNav/_styles.scss @@ -1,3 +1,11 @@ +// Matches the fade-in on `.react-tabs__tab-panel--selected` for TabNav pages +// that render content via React Router children instead of . Consumers +// should wrap their routed content in this class with `key={location.pathname}` +// so the element remounts on tab change and re-triggers the animation. +.tab-nav-routed-content { + animation: fade-in 250ms ease-out; +} + .tab-nav { top: 0; // No background color as TabNav is often over a background gradient @@ -70,6 +78,7 @@ &__tab-panel--selected { margin-top: $gap-page-component; + animation: fade-in 250ms ease-out; .no-results-message { margin-top: $pad-xxlarge; @@ -142,7 +151,7 @@ content: ""; width: 100%; height: 0; - border-bottom: 2px solid $core-fleet-black; + border-bottom: 2px solid $nav-active-underline; position: absolute; bottom: 0; left: 0; @@ -154,7 +163,7 @@ content: ""; width: 100%; height: 0; - border-bottom: 2px solid $core-fleet-black; + border-bottom: 2px solid $nav-active-underline; position: absolute; bottom: 0; left: 0; diff --git a/frontend/components/TargetsInput/_styles.scss b/frontend/components/TargetsInput/_styles.scss index eb8d1c8044..ecee0f2af2 100644 --- a/frontend/components/TargetsInput/_styles.scss +++ b/frontend/components/TargetsInput/_styles.scss @@ -119,7 +119,7 @@ left: 0; right: 0; bottom: 0; - background-color: rgba(255, 255, 255, 0.7); + background-color: $loading-overlay; display: flex; justify-content: center; align-items: center; diff --git a/frontend/components/buttons/Button/Button.tsx b/frontend/components/buttons/Button/Button.tsx index 1a8e7bec70..8f655b07fe 100644 --- a/frontend/components/buttons/Button/Button.tsx +++ b/frontend/components/buttons/Button/Button.tsx @@ -166,7 +166,7 @@ class Button extends React.Component {
{children}
- {isLoading && } + {isLoading && } ); } diff --git a/frontend/components/forms/fields/Dropdown/_styles.scss b/frontend/components/forms/fields/Dropdown/_styles.scss index 859d4c878d..51db6a36c3 100644 --- a/frontend/components/forms/fields/Dropdown/_styles.scss +++ b/frontend/components/forms/fields/Dropdown/_styles.scss @@ -8,6 +8,9 @@ &-label { line-height: 38px; font-size: $x-small; + // Override react-select's hardcoded `.Select-value { color: #aaa; }` so + // the selected value uses themed text color (esp. needed for dark mode). + color: $core-fleet-black; } } @@ -141,9 +144,16 @@ } } + // Override react-select's hardcoded white background on focus/open so the + // control stays themed (critical for dark mode). + &.is-focused > .Select-control { + background-color: $core-fleet-white; + } + &.is-focused:not(.is-open) > .Select-control { box-shadow: none; border-color: $ui-fleet-black-75; // Override blue border on focus + background-color: $core-fleet-white; } &.is-open { @@ -161,8 +171,10 @@ fill: $ui-fleet-black-75-over; } } - .Select-control { + > .Select-control { border-radius: $border-radius; + // Override react-select's hardcoded white background when open. + background-color: $core-fleet-white; } } :hover { @@ -290,6 +302,12 @@ box-sizing: border-box; height: 34px; + // Override react-select's hardcoded `.Select-control .Select-input:focus + // { background: #fff; }` so the input stays themed when the menu is open. + &:focus { + background: $core-fleet-white; + } + > input { line-height: 34px; padding: 0; diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx index 6ce0b3192f..de0e488684 100644 --- a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx @@ -160,7 +160,7 @@ export const generateCustomDropdownStyles = ( const buttonVariantContainer = { borderRadius: "6px", "&:active": { - backgroundColor: "rgba(25, 33, 71, 0.05)", + backgroundColor: COLORS["ui-fleet-black-5"], }, height: "38px", }; @@ -190,7 +190,7 @@ export const generateCustomDropdownStyles = ( stroke: COLORS["ui-fleet-black-75"], }, "&:hover": { - backgroundColor: "rgba(25, 33, 71, 0.05)", + backgroundColor: COLORS["ui-fleet-black-5"], boxShadow: "none", ".dropdown-wrapper__placeholder": { color: COLORS["ui-fleet-black-75-over"], @@ -199,8 +199,8 @@ export const generateCustomDropdownStyles = ( stroke: COLORS["ui-fleet-black-75-over"], }, }, - ".react-select__control--is-focused": { - backgroundColor: "rgba(25, 33, 71, 0.05)", + ...(state.isFocused && { + backgroundColor: COLORS["ui-fleet-black-5"], boxShadow: "none", ".dropdown-wrapper__placeholder": { color: COLORS["ui-fleet-black-75-down"], @@ -208,19 +208,15 @@ export const generateCustomDropdownStyles = ( ".dropdown-wrapper__indicator path": { stroke: COLORS["ui-fleet-black-75-down"], }, - }, - ...(state.isFocused && { - backgroundColor: "rgba(25, 33, 71, 0.05)", + }), + ...(state.menuIsOpen && { + backgroundColor: COLORS["ui-fleet-black-5"], ".dropdown-wrapper__placeholder": { color: COLORS["ui-fleet-black-75-down"], }, ".dropdown-wrapper__indicator path": { stroke: COLORS["ui-fleet-black-75-down"], }, - }), - // TODO: Figure out a way to apply separate &:focus-visible styling - // Currently only relying on &:focus styling for tabbing through app - ...(state.menuIsOpen && { ".dropdown-wrapper__indicator svg": { transform: "rotate(180deg)", transition: "transform 0.25s ease", diff --git a/frontend/components/icons/Calendar.tsx b/frontend/components/icons/Calendar.tsx index 8bbec65683..b9005741d6 100644 --- a/frontend/components/icons/Calendar.tsx +++ b/frontend/components/icons/Calendar.tsx @@ -1,10 +1,20 @@ import React from "react"; +import { COLORS, Colors } from "styles/var/colors"; +import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes"; -const Calendar = () => { +interface ICalendarProps { + color?: Colors; + size?: IconSizes; +} + +const Calendar = ({ + size = "medium", + color = "ui-fleet-black-75", +}: ICalendarProps) => { return ( { fillRule="evenodd" clipRule="evenodd" d="M4.75 1.29999C4.75 0.885774 4.41421 0.549988 4 0.549988C3.58579 0.549988 3.25 0.885774 3.25 1.29999V2.49999H2C0.895431 2.49999 0 3.39542 0 4.49999V14.5C0 15.6046 0.895431 16.5 2 16.5H14C15.1046 16.5 16 15.6046 16 14.5V4.49999C16 3.39542 15.1046 2.49999 14 2.49999H12.75V1.29999C12.75 0.885777 12.4142 0.549991 12 0.549991C11.5858 0.549991 11.25 0.885777 11.25 1.29999V2.49999H4.75V1.29999ZM2 7.24999V14.5H14V7.24999H2ZM14 5.74999H2V4.49999L14 4.49999V5.74999Z" - fill="#515774" + fill={COLORS[color]} /> ); diff --git a/frontend/components/icons/LowDiskSpaceHosts.tsx b/frontend/components/icons/LowDiskSpaceHosts.tsx index 59787f2636..4e2c8c927b 100644 --- a/frontend/components/icons/LowDiskSpaceHosts.tsx +++ b/frontend/components/icons/LowDiskSpaceHosts.tsx @@ -1,26 +1,37 @@ import React from "react"; -const LowDiskSpaceHosts = () => { +import { COLORS, Colors } from "styles/var/colors"; + +interface ILowDiskSpaceHostsProps { + color?: Colors; +} + +const LowDiskSpaceHosts = ({ + color = "ui-fleet-black-75", +}: ILowDiskSpaceHostsProps) => { + const fillColor = COLORS[color]; + const bgColor = COLORS["core-fleet-white"]; + return ( - + ); }; diff --git a/frontend/components/icons/MissingHosts.tsx b/frontend/components/icons/MissingHosts.tsx index 1be0be1641..57cc2210a7 100644 --- a/frontend/components/icons/MissingHosts.tsx +++ b/frontend/components/icons/MissingHosts.tsx @@ -1,28 +1,37 @@ import React from "react"; -const MissingHosts = () => { +import { COLORS, Colors } from "styles/var/colors"; + +interface IMissingHostsProps { + color?: Colors; +} + +const MissingHosts = ({ color = "ui-fleet-black-75" }: IMissingHostsProps) => { + const fillColor = COLORS[color]; + const bgColor = COLORS["core-fleet-white"]; + return ( - + ); diff --git a/frontend/components/icons/OrgLogoIcon/OrgLogoIcon.jsx b/frontend/components/icons/OrgLogoIcon/OrgLogoIcon.jsx index 6ba6daa8cd..063d808108 100644 --- a/frontend/components/icons/OrgLogoIcon/OrgLogoIcon.jsx +++ b/frontend/components/icons/OrgLogoIcon/OrgLogoIcon.jsx @@ -10,12 +10,10 @@ class OrgLogoIcon extends Component { static propTypes = { className: PropTypes.string, src: PropTypes.string.isRequired, - invertDark: PropTypes.bool, }; static defaultProps = { src: fleetAvatar, - invertDark: false, }; constructor(props) { @@ -70,16 +68,14 @@ class OrgLogoIcon extends Component { }; render() { - const { className, invertDark } = this.props; + const { className } = this.props; const { imageSrc } = this.state; const { onError } = this; const classNames = imageSrc === fleetAvatar ? classnames(baseClass, className, "default-fleet-logo") - : classnames(baseClass, className, { - [`${baseClass}--invert-dark`]: invertDark, - }); + : classnames(baseClass, className); return ( { +import { COLORS, Colors } from "styles/var/colors"; + +interface ITotalHostsProps { + color?: Colors; +} + +const TotalHosts = ({ color = "ui-fleet-black-75" }: ITotalHostsProps) => { const clipPathId = uniqueId("clip-path-"); const maskId = uniqueId("mask-"); + const fillColor = COLORS[color]; + const bgColor = COLORS["core-fleet-white"]; return ( - + { diff --git a/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx b/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx index fcb22931c6..80b9b7c95a 100644 --- a/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx +++ b/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx @@ -172,11 +172,7 @@ const SiteTopNav = ({ to={navItem.location.pathname} >
- +
diff --git a/frontend/components/top_nav/SiteTopNav/_styles.scss b/frontend/components/top_nav/SiteTopNav/_styles.scss index 1ee203c1ea..683c86d575 100644 --- a/frontend/components/top_nav/SiteTopNav/_styles.scss +++ b/frontend/components/top_nav/SiteTopNav/_styles.scss @@ -124,7 +124,7 @@ left: 0; width: 100%; height: 1px; - background-color: $core-fleet-black; + background-color: $nav-active-underline; } .site-nav-item__name { diff --git a/frontend/pages/AccountPage/AccountSidePanel/AccountSidePanel.tsx b/frontend/pages/AccountPage/AccountSidePanel/AccountSidePanel.tsx index 4a3ab8a323..bcc1b8b806 100644 --- a/frontend/pages/AccountPage/AccountSidePanel/AccountSidePanel.tsx +++ b/frontend/pages/AccountPage/AccountSidePanel/AccountSidePanel.tsx @@ -38,6 +38,15 @@ const AccountSidePanel = ({ const [versionData, setVersionData] = useState(); const [darkMode, setDarkMode] = useState(() => isDarkMode()); + useEffect(() => { + const onThemeChange = (e: Event) => { + setDarkMode((e as CustomEvent).detail.dark); + }; + window.addEventListener("fleet-theme-change", onThemeChange); + return () => + window.removeEventListener("fleet-theme-change", onThemeChange); + }, []); + useEffect(() => { const getVersionData = async () => { try { diff --git a/frontend/pages/DashboardPage/cards/HostCountCard/_styles.scss b/frontend/pages/DashboardPage/cards/HostCountCard/_styles.scss index 87e71e65b6..5dfe0afa81 100644 --- a/frontend/pages/DashboardPage/cards/HostCountCard/_styles.scss +++ b/frontend/pages/DashboardPage/cards/HostCountCard/_styles.scss @@ -38,9 +38,6 @@ } &__card-icon { - body.dark-mode & { - opacity: 0.75; - } } &__count { diff --git a/frontend/pages/ManageControlsPage/ManageControlsPage.tsx b/frontend/pages/ManageControlsPage/ManageControlsPage.tsx index 8e0277d1b6..640329154b 100644 --- a/frontend/pages/ManageControlsPage/ManageControlsPage.tsx +++ b/frontend/pages/ManageControlsPage/ManageControlsPage.tsx @@ -186,11 +186,15 @@ const ManageControlsPage = ({ - {React.cloneElement(children, { - teamIdForApi, - currentPage: page, - queryParams: parseOSUpdatesCurrentVersionsQueryParams(location.query), - })} +
+ {React.cloneElement(children, { + teamIdForApi, + currentPage: page, + queryParams: parseOSUpdatesCurrentVersionsQueryParams( + location.query + ), + })} +
); }; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tests.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tests.tsx index 872d191191..740782da97 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tests.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tests.tsx @@ -70,7 +70,9 @@ describe("AddCertModal", () => { }); expect(screen.getByText("Add certificate")).toBeInTheDocument(); - expect(screen.getByPlaceholderText("VPN certificate")).toBeInTheDocument(); + expect( + await screen.findByPlaceholderText("VPN certificate") + ).toBeInTheDocument(); expect(screen.getByText("Certificate authority (CA)")).toBeInTheDocument(); expect( screen.getByPlaceholderText( @@ -124,7 +126,7 @@ describe("AddCertModal", () => { expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); }); - const nameInput = screen.getByPlaceholderText("VPN certificate"); + const nameInput = await screen.findByPlaceholderText("VPN certificate"); await user.type(nameInput, "Invalid@Name#"); await waitFor(() => { @@ -151,7 +153,7 @@ describe("AddCertModal", () => { expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); }); - const nameInput = screen.getByPlaceholderText("VPN certificate"); + const nameInput = await screen.findByPlaceholderText("VPN certificate"); await user.type(nameInput, "Existing Certificate"); await waitFor(() => { @@ -178,7 +180,7 @@ describe("AddCertModal", () => { expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); }); - const nameInput = screen.getByPlaceholderText("VPN certificate"); + const nameInput = await screen.findByPlaceholderText("VPN certificate"); const longName = "a".repeat(256); await user.type(nameInput, longName); @@ -206,7 +208,7 @@ describe("AddCertModal", () => { expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); }); - const nameInput = screen.getByPlaceholderText("VPN certificate"); + const nameInput = await screen.findByPlaceholderText("VPN certificate"); await user.type(nameInput, "Valid Name"); const subjectNameInput = screen.getByPlaceholderText( @@ -234,7 +236,7 @@ describe("AddCertModal", () => { expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); }); - const nameInput = screen.getByPlaceholderText("VPN certificate"); + const nameInput = await screen.findByPlaceholderText("VPN certificate"); await user.type(nameInput, "Valid Name"); const caDropdown = screen.getByText("Select certificate authority"); @@ -269,7 +271,7 @@ describe("AddCertModal", () => { }); // Fill in all fields with valid data - const nameInput = screen.getByPlaceholderText("VPN certificate"); + const nameInput = await screen.findByPlaceholderText("VPN certificate"); await user.type(nameInput, "Valid Name"); const subjectNameInput = screen.getByPlaceholderText( @@ -310,7 +312,7 @@ describe("AddCertModal", () => { expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); }); - const cancelButton = screen.getByText("Cancel"); + const cancelButton = await screen.findByText("Cancel"); await user.click(cancelButton); expect(mockOnExit).toHaveBeenCalledTimes(1); diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/RunScript.tests.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/RunScript.tests.tsx index 0bf7207aff..c4e972e7ff 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/RunScript.tests.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/RunScript.tests.tsx @@ -28,15 +28,13 @@ describe("RunScript", () => { }); render(); - expect(screen.getByTestId("spinner")).toBeVisible(); + // Spinner has a 250ms anti-flash delay; the mocked request resolves + // before that, so the spinner intentionally never renders here. expect( screen.queryByText(/turn on automatic enrollment/) ).not.toBeInTheDocument(); - await waitFor(async () => { - expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); - }); expect( - screen.getByText(/turn on automatic enrollment/) + await screen.findByText(/turn on automatic enrollment/) ).toBeInTheDocument(); }); @@ -57,15 +55,13 @@ describe("RunScript", () => { render(); - expect(screen.getByTestId("spinner")).toBeVisible(); + // Spinner has a 250ms anti-flash delay; the mocked request resolves + // before that, so the spinner intentionally never renders here. expect( screen.queryByText(/turn on automatic enrollment/) ).not.toBeInTheDocument(); - await waitFor(async () => { - expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); - }); expect( - screen.getByText(/turn on automatic enrollment/) + await screen.findByText(/turn on automatic enrollment/) ).toBeInTheDocument(); }); @@ -78,7 +74,8 @@ describe("RunScript", () => { }); render(); - expect(screen.getByTestId("spinner")).toBeVisible(); + // Spinner has a 250ms anti-flash delay; the mocked request resolves + // before that, so the spinner intentionally never renders here. expect(screen.queryByLabelText("Upload")).not.toBeInTheDocument(); await waitFor(async () => { expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); @@ -96,7 +93,8 @@ describe("RunScript", () => { render(); - expect(screen.getByTestId("spinner")).toBeVisible(); + // Spinner has a 250ms anti-flash delay; the mocked request resolves + // before that, so the spinner intentionally never renders here. expect( screen.queryByText("Script will run during setup:") ).not.toBeInTheDocument(); diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAddPage.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAddPage.tsx index cd858e54b4..a07f8125e0 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAddPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAddPage.tsx @@ -139,12 +139,14 @@ const SoftwareAddPage = ({ - {React.cloneElement(children, { - router, - currentTeamId: parseInt(location.query.fleet_id, 10), - isSidePanelOpen, - setSidePanelOpen, - })} +
+ {React.cloneElement(children, { + router, + currentTeamId: parseInt(location.query.fleet_id, 10), + isSidePanelOpen, + setSidePanelOpen, + })} +
{isSidePanelOpen && ( diff --git a/frontend/pages/SoftwarePage/SoftwarePage.tsx b/frontend/pages/SoftwarePage/SoftwarePage.tsx index a61f8de0cd..727a2901f4 100644 --- a/frontend/pages/SoftwarePage/SoftwarePage.tsx +++ b/frontend/pages/SoftwarePage/SoftwarePage.tsx @@ -407,24 +407,26 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { - {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, - })} +
+ {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} +
+ {children} +
); diff --git a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tests.tsx b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tests.tsx index ad6bfacd35..ce00db10dc 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tests.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tests.tsx @@ -243,7 +243,7 @@ BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX }); describe("Confirming configured", () => { - it("Renders a spinner when Entra tenant id is present but configuration not yet confirmed", () => { + it("Renders a spinner when Entra tenant id is present but configuration not yet confirmed", async () => { const mockConfig = createMockConfig({ conditional_access: { microsoft_entra_tenant_id: TEST_TENANT_ID, @@ -268,7 +268,8 @@ BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX render(); - 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(); }); }); diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx index d81e7af807..5882ded05c 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx @@ -528,7 +528,9 @@ const TeamDetailsWrapper = ({ isUpdatingTeams={isUpdatingTeams} /> )} - {children} +
+ {children} +
); diff --git a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx index 2ae233b87c..ce10d26317 100644 --- a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx +++ b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx @@ -578,7 +578,12 @@ const InstallStatusCell = ({ > {(isSelfService || isHostOnline) && displayConfig.iconName === "pending-outline" ? ( - + ) : ( displayConfig?.iconName && ( { - 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); + }); + } };