Additional design revisions to dark mode theme (#43634)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+10
-8
@@ -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);
|
||||
|
||||
+10
-12
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -98,7 +98,9 @@ const AdminWrapper = ({
|
||||
</TabList>
|
||||
</Tabs>
|
||||
</TabNav>
|
||||
{children}
|
||||
<div key={pathname} className="tab-nav-routed-content">
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
</MainContent>
|
||||
);
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+3
-2
@@ -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", () => {
|
||||
|
||||
+6
-1
@@ -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)}
|
||||
</>
|
||||
);
|
||||
|
||||
+12
-2
@@ -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...{" "}
|
||||
</>
|
||||
);
|
||||
|
||||
+3
-2
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user