Additional design revisions to dark mode theme (#43634)

This commit is contained in:
Luke Heath
2026-04-23 10:13:21 -05:00
committed by GitHub
parent bf3a12a960
commit 255be3fd80
38 changed files with 372 additions and 161 deletions
@@ -23,7 +23,8 @@ describe("AddHostsModal", () => {
render(
<AddHostsModal isAnyTeamSelected={false} isLoading onCancel={noop} />
);
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();
});
@@ -95,7 +95,11 @@ const SingleFlashMessage = ({
<div className={`${baseClass}__content`}>
<Icon
name={alertType === "success" ? "success" : "error"}
color="core-fleet-white"
// Static (un-themed) so icon stays light on the colored toast
// surface in both light and dark mode.
color={
alertType === "warning-filled" ? "static-black" : "static-white"
}
/>
<span>{message}</span>
</div>
@@ -108,9 +112,7 @@ const SingleFlashMessage = ({
<Icon
name="close"
color={
alertType === "warning-filled"
? "core-fleet-black"
: "core-fleet-white"
alertType === "warning-filled" ? "static-black" : "static-white"
}
/>
</button>
+19 -5
View File
@@ -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;
}
}
}
+21 -2
View File
@@ -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,
+11 -2
View File
@@ -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 <TabPanel>. 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;
@@ -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;
@@ -166,7 +166,7 @@ class Button extends React.Component<IButtonProps, IButtonState> {
<div className={isLoading ? "transparent-text" : "children-wrapper"}>
{children}
</div>
{isLoading && <Spinner small button white={!onWhite} />}
{isLoading && <Spinner small button white={!onWhite} delay={0} />}
</button>
);
}
@@ -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;
@@ -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",
+14 -4
View File
@@ -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 (
<svg
width="16"
height="16"
width={ICON_SIZES[size]}
height={ICON_SIZES[size]}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@@ -13,7 +23,7 @@ const Calendar = () => {
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]}
/>
</svg>
);
@@ -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 (
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M19 10.5H4a3 3 0 0 0-3 3v5a3 3 0 0 0 3 3h8m-6.964-7v3m3-3v3"
stroke="#515774"
stroke={fillColor}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="m10.267 21.477 8.553-14.97c.768-1.343 2.705-1.343 3.473 0l8.554 14.97c.762 1.333-.201 2.992-1.737 2.992H12.003c-1.536 0-2.498-1.66-1.736-2.992Z"
fill="#515774"
fill={fillColor}
/>
<path
d="M20.5 12.5v4"
stroke="#fff"
stroke={bgColor}
strokeWidth="2"
strokeLinecap="round"
/>
<path d="M20.5 20.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" fill="#fff" />
<path d="M20.5 20.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" fill={bgColor} />
</svg>
);
};
+17 -8
View File
@@ -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 (
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M19.5 13c0 1.215-.385 2.715-1.05 4.328-.658 1.599-1.563 3.242-2.536 4.726-.974 1.485-1.998 2.782-2.883 3.695-.443.458-.832.799-1.143 1.017-.214.15-.34.204-.388.224a1.946 1.946 0 0 1-.388-.224c-.311-.218-.7-.559-1.144-1.017-.884-.913-1.908-2.21-2.882-3.695-.973-1.484-1.878-3.127-2.536-4.726C3.885 15.715 3.5 14.215 3.5 13c0-2.553.904-4.533 2.315-5.878C7.233 5.77 9.226 5 11.5 5c2.274 0 4.267.77 5.685 2.122C18.595 8.467 19.5 10.447 19.5 13Z"
fill="#515774"
stroke="#515774"
fill={fillColor}
stroke={fillColor}
strokeWidth="2"
/>
<path d="M11.5 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" fill="#fff" />
<path d="M11.5 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" fill={bgColor} />
<path
d="M22.498 26.857a6.857 6.857 0 1 0 0-13.714 6.857 6.857 0 0 0 0 13.714Z"
fill="#fff"
stroke="#515774"
fill={bgColor}
stroke={fillColor}
strokeWidth="2.286"
/>
<path
d="M25.395 18.39a.857.857 0 0 0-1.212-1.213l-4.445 4.445a.857.857 0 1 0 1.212 1.212l4.445-4.445Z"
fill="#515774"
fill={fillColor}
/>
<path
d="M20.888 17.108a.857.857 0 1 0-1.212 1.212l4.445 4.445a.857.857 0 1 0 1.212-1.212l-4.445-4.445Z"
fill="#515774"
fill={fillColor}
/>
</svg>
);
@@ -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 (
<img
@@ -10,7 +10,3 @@
transform: scale(0.5);
}
body.dark-mode .default-fleet-logo,
body.dark-mode .org-logo-icon--invert-dark {
filter: brightness(0) invert(1);
}
+16 -8
View File
@@ -2,25 +2,33 @@ import React from "react";
import { uniqueId } from "lodash";
const TotalHosts = () => {
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 (
<svg width="33" height="33" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath={`url(#${clipPathId})`}>
<path
d="m2.921 10.73 8.412-4.59 8.413 4.59-8.413 4.59-8.412-4.59Z"
stroke="#515774"
stroke={fillColor}
strokeWidth="2"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="m7.249 13.374-6.416 3.5 10.5 5.73 10.5-5.73-6.415-3.5-4.085 2.23-4.084-2.23Z"
fill="#515774"
fill={fillColor}
/>
<mask id={maskId} fill="#fff">
<mask id={maskId} fill={bgColor}>
<path
fillRule="evenodd"
clipRule="evenodd"
@@ -29,22 +37,22 @@ const TotalHosts = () => {
</mask>
<path
d="m.833 22.729-.958-1.756-3.217 1.756 3.217 1.756.958-1.756Zm8.422-4.595 1.033-1.713-.983-.593-1.008.55.958 1.756Zm2.078 10.324-.958 1.756.958.523.958-.523-.958-1.756Zm10.5-5.729.958 1.756 3.218-1.756-3.218-1.756-.958 1.756Zm-9.821-5.359.958-1.756-1.008-.55-.983.594 1.033 1.712ZM10 18.583l-1.033 1.713 1.033.623 1.033-.623L10 18.583Zm-8.209 5.902 8.422-4.595-1.916-3.512-8.422 4.595 1.916 3.512Zm10.5 2.218-10.5-5.73-1.916 3.512 10.5 5.729 1.916-3.511Zm8.584-5.73-10.5 5.73 1.916 3.51 10.5-5.728-1.916-3.512Zm-9.821-1.847 9.821 5.359 1.916-3.512-9.821-5.358-1.916 3.51Zm-.075-3.469-2.012 1.214 2.066 3.425 2.012-1.213-2.066-3.425Zm.054 1.214-.745-.45-2.066 3.426.745.449 2.066-3.425Z"
fill="#515774"
fill={fillColor}
mask={`url(#${maskId})`}
/>
<path
d="M24.833 27.357a6.857 6.857 0 1 0 0-13.714 6.857 6.857 0 0 0 0 13.714Z"
fill="#fff"
fill={bgColor}
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M24.833 14.786a5.714 5.714 0 1 0 0 11.428 5.714 5.714 0 0 0 0-11.428Zm-8 5.714a8 8 0 1 1 16 0 8 8 0 0 1-16 0Z"
fill="#515774"
fill={fillColor}
/>
<path
d="m27.957 18.822-3.535 3.535-2.122-2.121"
stroke="#515774"
stroke={fillColor}
strokeWidth="1.71"
strokeLinecap="round"
/>
@@ -172,11 +172,7 @@ const SiteTopNav = ({
to={navItem.location.pathname}
>
<div className={`${navItemBaseClass}__logo`}>
<OrgLogoIcon
className="logo"
src={orgLogoURL}
invertDark={!hasDarkLogo}
/>
<OrgLogoIcon className="logo" src={orgLogoURL} />
</div>
</Link>
</li>
@@ -124,7 +124,7 @@
left: 0;
width: 100%;
height: 1px;
background-color: $core-fleet-black;
background-color: $nav-active-underline;
}
.site-nav-item__name {
@@ -38,6 +38,15 @@ const AccountSidePanel = ({
const [versionData, setVersionData] = useState<IVersionData>();
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 {
@@ -38,9 +38,6 @@
}
&__card-icon {
body.dark-mode & {
opacity: 0.75;
}
}
&__count {
@@ -186,11 +186,15 @@ const ManageControlsPage = ({
</TabList>
</Tabs>
</TabNav>
{React.cloneElement(children, {
teamIdForApi,
currentPage: page,
queryParams: parseOSUpdatesCurrentVersionsQueryParams(location.query),
})}
<div key={location?.pathname} className="tab-nav-routed-content">
{React.cloneElement(children, {
teamIdForApi,
currentPage: page,
queryParams: parseOSUpdatesCurrentVersionsQueryParams(
location.query
),
})}
</div>
</div>
);
};
@@ -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);
@@ -28,15 +28,13 @@ describe("RunScript", () => {
});
render(<RunScript router={createMockRouter()} currentTeamId={1} />);
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(<RunScript router={createMockRouter()} currentTeamId={1} />);
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(<RunScript router={createMockRouter()} currentTeamId={1} />);
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(<RunScript router={createMockRouter()} currentTeamId={1} />);
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();
@@ -139,12 +139,14 @@ const SoftwareAddPage = ({
</TabList>
</Tabs>
</TabNav>
{React.cloneElement(children, {
router,
currentTeamId: parseInt(location.query.fleet_id, 10),
isSidePanelOpen,
setSidePanelOpen,
})}
<div key={location?.pathname} className="tab-nav-routed-content">
{React.cloneElement(children, {
router,
currentTeamId: parseInt(location.query.fleet_id, 10),
isSidePanelOpen,
setSidePanelOpen,
})}
</div>
</MainContent>
{isSidePanelOpen && (
<SidePanelContent>
+20 -18
View File
@@ -407,24 +407,26 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
</TabList>
</Tabs>
</TabNav>
{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,
})}
<div key={location?.pathname} className="tab-nav-routed-content">
{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,
})}
</div>
</div>
);
};
+3 -1
View File
@@ -98,7 +98,9 @@ const AdminWrapper = ({
</TabList>
</Tabs>
</TabNav>
{children}
<div key={pathname} className="tab-nav-routed-content">
{children}
</div>
</>
</MainContent>
);
@@ -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(<ConditionalAccess />);
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();
});
});
@@ -528,7 +528,9 @@ const TeamDetailsWrapper = ({
isUpdatingTeams={isUpdatingTeams}
/>
)}
{children}
<div key={location.pathname} className="tab-nav-routed-content">
{children}
</div>
</>
</MainContent>
);
@@ -578,7 +578,12 @@ const InstallStatusCell = ({
>
{(isSelfService || isHostOnline) &&
displayConfig.iconName === "pending-outline" ? (
<Spinner size="x-small" includeContainer={false} centered={false} />
<Spinner
size="x-small"
includeContainer={false}
centered={false}
delay={0}
/>
) : (
displayConfig?.iconName && (
<Icon
@@ -73,13 +73,14 @@ const createTestProps = (
});
describe("SelfServiceCard", () => {
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(<SelfServiceCard {...props} />);
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", () => {
@@ -96,7 +96,12 @@ const TileActionStatus = ({
const renderActiveActionStatus = () => {
return (
<>
<Spinner size="x-small" includeContainer={false} centered={false} />
<Spinner
size="x-small"
includeContainer={false}
centered={false}
delay={0}
/>
{getPendingOrRunningLabel(software.ui_status)}
</>
);
@@ -118,7 +118,12 @@ const InstallerStatus = ({
>
<div className={`${baseClass}__status-with-tooltip`}>
{displayConfig.iconName === "pending-outline" && (
<Spinner size="x-small" includeContainer={false} centered={false} />
<Spinner
size="x-small"
includeContainer={false}
centered={false}
delay={0}
/>
)}
{last_install && displayConfig.displayText === "Failed" && (
<span data-testid={`${baseClass}__status--test`}>
@@ -171,7 +176,12 @@ const InstallerStatusAction = ({
if (ui_status === "updating") {
return (
<>
<Spinner size="x-small" includeContainer={false} centered={false} />{" "}
<Spinner
size="x-small"
includeContainer={false}
centered={false}
delay={0}
/>{" "}
Updating...{" "}
</>
);
@@ -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", () => {
@@ -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 () => {
@@ -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();
});
});
+39
View File
@@ -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;
+20 -9
View File
@@ -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.
+5
View File
@@ -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;
+42 -15
View File
@@ -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);
});
}
};