Compare commits

...

7 Commits

Author SHA1 Message Date
Ryan Laurie
4a01a74977 little fixes 2025-09-23 15:36:41 -06:00
Kyle Doherty
16355d68d0 surface contrast 2025-09-23 15:36:41 -06:00
Kyle Doherty
41c5db13a7 fix linter issues 2025-09-23 15:36:41 -06:00
Kyle Doherty
f220016097 remove dumb thing 2025-09-23 15:36:41 -06:00
Kyle Doherty
bc96d9afa1 use theme for charts 2025-09-23 15:36:41 -06:00
Kyle Doherty
37991ee4aa make tooltips always have white text 2025-09-23 15:36:41 -06:00
Kyle Doherty
46020187ab dark-mode wip 2025-09-23 15:36:41 -06:00
27 changed files with 524 additions and 44 deletions

View File

@@ -1,7 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import { APP_BAR_HEIGHT } from "metabase/nav/constants";
export const AppContainer = styled.div`
@@ -18,7 +17,9 @@ export const AppContentContainer = styled.div<{
position: relative;
overflow: hidden;
background-color: ${(props) =>
props.isAdminApp ? "var(--mb-color-bg-white)" : color("content")};
props.isAdminApp
? "var(--mb-color-bg-white)"
: "var(--mb-color-bg-primary)"};
@media print {
height: 100%;

View File

@@ -10,6 +10,7 @@ import FormSelect from "metabase/common/components/FormSelect";
import FormSubmitButton from "metabase/common/components/FormSubmitButton";
import { Form, FormProvider } from "metabase/forms";
import * as Errors from "metabase/lib/errors";
import { ColorSchemeToggle } from "metabase/ui";
import type { LocaleData, User } from "metabase-types/api";
import type { UserProfileData } from "../../types";
@@ -91,6 +92,30 @@ const UserProfileForm = ({
description={<CommunityLocalizationNotice isAdminView={false} />}
/>
</div>
<div style={{ marginTop: "1.5rem", marginBottom: "1.5rem" }}>
<label style={{
display: "block",
fontSize: "0.875rem",
fontWeight: "bold",
marginBottom: "0.5rem",
color: "var(--mb-color-text-primary)"
}}>
{t`Theme`}
</label>
<div style={{
display: "flex",
alignItems: "center",
gap: "0.75rem"
}}>
<ColorSchemeToggle size="md" />
<span style={{
fontSize: "0.875rem",
color: "var(--mb-color-text-secondary)"
}}>
{t`Toggle between light, dark, and system preference`}
</span>
</div>
</div>
<FormSubmitButton title={t`Update`} disabled={!dirty} primary />
<FormErrorMessage />
</Form>

View File

@@ -19,15 +19,7 @@ export const AdminNavWrapper = ({
children: React.ReactNode;
} & StackProps) => {
return (
<Stack
w="16rem"
gap={0}
bg="white"
p="md"
h="100%"
{...stackProps}
component="nav"
>
<Stack w="16rem" gap={0} p="md" h="100%" {...stackProps} component="nav">
{children}
</Stack>
);

View File

@@ -5,6 +5,7 @@ export const CollectionRoot = styled.div`
height: 100%;
overflow: hidden;
position: relative;
background-color: var(--mb-color-bg-secondary);
`;
export const CollectionMain = styled.div`

View File

@@ -16,7 +16,11 @@ export function usePalette(): ColorPalette {
white: theme.fn.themeColor("white"),
border: theme.fn.themeColor("border"),
"text-dark": theme.fn.themeColor("text-dark"),
"text-medium": theme.fn.themeColor("text-medium"),
"text-light": theme.fn.themeColor("text-light"),
"text-white": theme.fn.themeColor("text-white"),
"text-primary": theme.fn.themeColor("text-primary"),
"bg-white": theme.fn.themeColor("bg-white"),
};
}, [theme]);
}

View File

@@ -92,7 +92,7 @@
.AdminListItem.selected,
.AdminListItem:hover {
background-color: white;
background-color: var(--mb-color-bg-white);
border-color: var(--mb-color-border);
margin-left: -0.5em;
margin-right: -0.5em;

View File

@@ -96,7 +96,7 @@
}
.ButtonWhite {
background-color: white;
background-color: var(--mb-color-bg-white);
color: var(--mb-color-text-dark);
border-color: var(--mb-color-border);
}

View File

@@ -40,7 +40,7 @@
}
.ModalFull {
background-color: white;
background-color: var(--mb-color-bg-white);
position: fixed;
top: 0;
bottom: 0;

View File

@@ -10,7 +10,7 @@ export const rootStyle = css`
height: 100%; /* ensure the entire page will fill the window */
display: flex;
flex-direction: column;
background-color: var(--mb-color-bg-light);
background-color: var(--mb-color-bg-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
`;

View File

@@ -106,7 +106,7 @@
--mb-color-icon-primary-disabled: var(--mb-base-color-gray-30);
--mb-color-icon-secondary: var(--mb-base-color-brand-20);
--mb-color-icon-secondary-disabled: var(--mb-base-color-gray-10);
--mb-color-tooltip-text: var(--mb-color-text-white);
--mb-color-tooltip-text: #fff; /* we always want white text for tooltips */
--mb-color-tooltip-text-secondary: var(--mb-color-text-light);
--mb-color-tooltip-background: var(--mb-color-background-inverse);
--mb-color-tooltip-background-focused: color-mix(

View File

@@ -169,7 +169,7 @@
font-size: 10px;
text-align: center;
text-transform: uppercase;
background-color: white;
background-color: var(--mb-color-bg-white);
padding-left: var(--padding-1);
padding-right: var(--padding-1);
position: absolute;
@@ -248,7 +248,7 @@
}
.GuiBuilderSectionLabel {
background-color: white;
background-color: var(--mb-color-bg-white);
position: absolute;
top: -7px;
left: 10px;

View File

@@ -42,6 +42,7 @@
min-height: 100%;
height: 1px;
/* prevents header from scrolling so we can have a fixed sidebar */
background-color: var(--mb-color-bg-secondary);
&.isFullHeight {
height: 100%;

View File

@@ -6,20 +6,27 @@ import { CSS_VARIABLES_TO_SDK_THEME_MAP } from "metabase/embedding-sdk/theme/css
import { getDynamicCssVariables } from "metabase/embedding-sdk/theme/dynamic-css-vars";
import { SDK_TO_MAIN_APP_COLORS_MAPPING } from "metabase/embedding-sdk/theme/embedding-color-palette";
import type { MantineTheme } from "metabase/ui";
import { SEMANTIC_COLOR_SCHEMES } from "metabase/ui/utils/color-schemes";
/**
* Defines the CSS variables used across Metabase.
*/
export function getMetabaseCssVariables(theme: MantineTheme) {
const colorScheme = theme.other?.colorScheme || "light";
return css`
:root {
--mb-default-font-family: "${theme.fontFamily}";
--mb-default-monospace-font-family: ${theme.fontFamilyMonospace};
/* Semantic colors */
--mb-color-brand: ${theme.colors.brand[0]};
--mb-color-summarize: ${theme.colors.summarize[0]};
--mb-color-filter: ${theme.colors.filter[0]};
/* Semantic colors - theme aware */
${Object.entries(SEMANTIC_COLOR_SCHEMES)
.map(([colorName, colors]) => {
const colorValue = (colors as any)[colorScheme] || colors.light;
return `--mb-color-${colorName}: ${colorValue};`;
})
.join('\n ')}
${getThemeSpecificCssVariables(theme)}
${getDynamicCssVariables(theme)}
}

View File

@@ -0,0 +1,64 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { t } from "ttag";
import { Icon } from "metabase/ui";
import { useColorScheme } from "metabase/ui/components/theme/ColorSchemeProvider";
interface ColorSchemeToggleProps {
size?: "xs" | "sm" | "md" | "lg" | "xl";
variant?:
| "subtle"
| "light"
| "filled"
| "outline"
| "default"
| "transparent"
| "gradient";
}
export function ColorSchemeToggle({
size = "md",
variant = "subtle",
}: ColorSchemeToggleProps) {
const { colorScheme, resolvedColorScheme, toggleColorScheme } =
useColorScheme();
const getIcon = () => {
switch (colorScheme) {
case "light":
return "sun";
case "dark":
return "moon";
case "auto":
return resolvedColorScheme === "dark" ? "moon" : "sun";
default:
return "sun";
}
};
const getTooltipText = () => {
switch (colorScheme) {
case "light":
return t`Switch to dark mode`;
case "dark":
return t`Switch to system preference`;
case "auto":
return t`Switch to light mode`;
default:
return t`Toggle theme`;
}
};
return (
<Tooltip label={getTooltipText()}>
<ActionIcon
onClick={toggleColorScheme}
size={size}
variant={variant}
aria-label={getTooltipText()}
>
<Icon name={getIcon()} />
</ActionIcon>
</Tooltip>
);
}

View File

@@ -0,0 +1 @@
export { ColorSchemeToggle } from "./ColorSchemeToggle";

View File

@@ -1,5 +1,6 @@
export * from "./ActionIcon";
export * from "./Button";
export * from "./ColorSchemeToggle";
export * from "./CopyButton";
export * from "./PopoverBackButton";
export * from "./UnstyledButton";
export * from "./CopyButton";

View File

@@ -9,11 +9,7 @@
}
.overlay {
background-color: color-mix(
in hsl,
var(--mb-color-bg-black),
transparent 40%
);
background-color: var(--mb-color-overlay);
padding: 11px;
}

View File

@@ -0,0 +1,112 @@
import type { MantineColorScheme } from "@mantine/core";
import {
type ReactNode,
createContext,
useContext,
useEffect,
useState,
} from "react";
export type ColorScheme = "light" | "dark" | "auto";
interface ColorSchemeContextType {
colorScheme: ColorScheme;
resolvedColorScheme: MantineColorScheme;
setColorScheme: (scheme: ColorScheme) => void;
toggleColorScheme: () => void;
}
const ColorSchemeContext = createContext<ColorSchemeContextType | null>(null);
interface ColorSchemeProviderProps {
children: ReactNode;
defaultColorScheme?: ColorScheme;
}
function getSystemColorScheme(): MantineColorScheme {
if (typeof window === "undefined") {
return "light";
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function resolveColorScheme(scheme: ColorScheme): MantineColorScheme {
return scheme === "auto" ? getSystemColorScheme() : scheme;
}
export function ColorSchemeProvider({
children,
defaultColorScheme = "light",
}: ColorSchemeProviderProps) {
const [colorScheme, setColorScheme] = useState<ColorScheme>(() => {
// Try to get saved preference from localStorage
if (typeof window !== "undefined") {
const saved = localStorage.getItem("metabase-color-scheme");
if (saved === "light" || saved === "dark" || saved === "auto") {
return saved;
}
}
return defaultColorScheme;
});
const [resolvedColorScheme, setResolvedColorScheme] =
useState<MantineColorScheme>(() => resolveColorScheme(colorScheme));
useEffect(() => {
// Save to localStorage
localStorage.setItem("metabase-color-scheme", colorScheme);
// Update resolved scheme
setResolvedColorScheme(resolveColorScheme(colorScheme));
}, [colorScheme]);
useEffect(() => {
if (colorScheme === "auto") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (e: MediaQueryListEvent) => {
setResolvedColorScheme(e.matches ? "dark" : "light");
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}
}, [colorScheme]);
const toggleColorScheme = () => {
setColorScheme((current) => {
switch (current) {
case "light":
return "dark";
case "dark":
return "auto";
case "auto":
default:
return "light";
}
});
};
const value: ColorSchemeContextType = {
colorScheme,
resolvedColorScheme,
setColorScheme,
toggleColorScheme,
};
return (
<ColorSchemeContext.Provider value={value}>
{children}
</ColorSchemeContext.Provider>
);
}
export function useColorScheme(): ColorSchemeContextType {
const context = useContext(ColorSchemeContext);
if (!context) {
throw new Error("useColorScheme must be used within a ColorSchemeProvider");
}
return context;
}

View File

@@ -0,0 +1,2 @@
export { ColorSchemeProvider, useColorScheme } from "./ColorSchemeProvider";
export type { ColorScheme } from "./ColorSchemeProvider";

View File

@@ -8,6 +8,7 @@ import { type ReactNode, useContext, useMemo } from "react";
import { isEmbeddingSdk } from "metabase/embedding-sdk/config";
import { getThemeOverrides } from "../../../theme";
import { ColorSchemeProvider, useColorScheme } from "../ColorSchemeProvider";
import { DatesProvider } from "../DatesProvider";
import { ThemeProviderContext } from "./context";
@@ -23,10 +24,12 @@ interface ThemeProviderProps {
theme?: MantineThemeOverride;
}
export const ThemeProvider = (props: ThemeProviderProps) => {
const ThemeProviderInner = (props: ThemeProviderProps) => {
const { resolvedColorScheme } = useColorScheme();
// Merge default theme overrides with user-provided theme overrides
const theme = useMemo(() => {
const theme = merge(getThemeOverrides(), props.theme) as MantineTheme;
const theme = merge(getThemeOverrides(resolvedColorScheme), props.theme) as MantineTheme;
return {
...theme,
@@ -65,7 +68,7 @@ export const ThemeProvider = (props: ThemeProviderProps) => {
},
},
} as MantineTheme;
}, [props.theme]);
}, [props.theme, resolvedColorScheme]);
const { withCssVariables, withGlobalClasses } =
useContext(ThemeProviderContext);
@@ -86,3 +89,11 @@ export const ThemeProvider = (props: ThemeProviderProps) => {
</MantineProvider>
);
};
export const ThemeProvider = (props: ThemeProviderProps) => {
return (
<ColorSchemeProvider>
<ThemeProviderInner {...props} />
</ColorSchemeProvider>
);
};

View File

@@ -1 +1,2 @@
export * from "./ColorSchemeProvider";
export * from "./ThemeProvider";

View File

@@ -1,4 +1,4 @@
import type { MantineThemeOverride } from "@mantine/core";
import type { MantineColorScheme, MantineThemeOverride } from "@mantine/core";
import { rem } from "@mantine/core";
import { DEFAULT_METABASE_COMPONENT_THEME } from "metabase/embedding-sdk/theme";
@@ -56,7 +56,7 @@ import {
titleOverrides,
tooltipOverrides,
} from "./components";
import { getThemeColors } from "./utils/colors";
import { getThemeAwareColors } from "./utils/color-schemes";
export const breakpoints = {
xs: "23em",
@@ -67,12 +67,19 @@ export const breakpoints = {
};
export type BreakpointName = keyof typeof breakpoints;
export const getThemeOverrides = (): MantineThemeOverride => ({
export const getThemeOverrides = (
colorScheme: MantineColorScheme = "light",
): MantineThemeOverride => ({
focusClassName: Styles.focus,
breakpoints,
colors: getThemeColors(),
colors: getThemeAwareColors(colorScheme),
primaryColor: "brand",
primaryShade: 0,
// Store colorScheme in other property for access later
other: {
...DEFAULT_METABASE_COMPONENT_THEME,
colorScheme,
},
shadows: {
// eslint-disable-next-line no-color-literals
sm: "0px 1px 4px 2px rgba(0, 0, 0, 0.08)",
@@ -188,5 +195,4 @@ export const getThemeOverrides = (): MantineThemeOverride => ({
...hoverCardOverrides,
...listOverrides,
},
other: DEFAULT_METABASE_COMPONENT_THEME,
});

View File

@@ -0,0 +1,255 @@
/* eslint-disable no-color-literals */
import type { MantineColorScheme, MantineTheme } from "@mantine/core";
type ColorShades = MantineTheme["colors"]["dark"];
export const SEMANTIC_COLOR_SCHEMES = {
// Backgrounds
"bg-primary": {
light: "#FFFFFF",
dark: "#1A1B1E",
},
"bg-secondary": {
light: "#F9FBFC",
dark: "#25262B",
},
"bg-tertiary": {
light: "#EDF2F5",
dark: "#2C2E33",
},
"bg-hover": {
light: "#F9FBFC",
dark: "#373A40",
},
overlay: {
light: "hsla(225, 7.1%, 11%, 40%)",
dark: "hsla(225, 7.1%, 31%, 40%)",
},
// Text colors
"text-primary": {
light: "#4C5773",
dark: "#C1C2C5",
},
"text-secondary": {
light: "#696E7B",
dark: "#A6A7AB",
},
"text-tertiary": {
light: "#949AAB",
dark: "#909296",
},
"text-inverse": {
light: "#FFFFFF",
dark: "#1A1B1E",
},
// Borders
"border-primary": {
light: "#EEECEC",
dark: "#373A40",
},
"border-secondary": {
light: "#DCDFE0",
dark: "#2C2E33",
},
// Brand colors (usually stay the same)
brand: {
light: "#509EE3",
dark: "#509EE3",
},
"brand-light": {
light: "#EEF6FD",
dark: "#1A3A52",
},
"brand-lighter": {
light: "#F8FBFE",
dark: "#0F2A3C",
},
// Status colors
success: {
light: "#84BB4C",
dark: "#51CF66",
},
error: {
light: "hsla(358, 71%, 62%, 1)",
dark: "#FF6B6B",
},
warning: {
light: "#F9CF48",
dark: "#FFD43B",
},
danger: {
light: "hsla(358, 71%, 62%, 1)",
dark: "#FF6B6B",
},
// Functional colors
summarize: {
light: "#88BF4D",
dark: "#69DB7C",
},
filter: {
light: "#7172AD",
dark: "#9775FA",
},
focus: {
light: "#CBE2F7",
dark: "#1A3A52",
},
shadow: {
light: "rgba(0, 0, 0, 0.08)",
dark: "rgba(0, 0, 0, 0.25)",
},
// Legacy compatibility colors
"bg-black": {
light: "#2E353B",
dark: "#1A1B1E",
},
"bg-dark": {
light: "#93A1AB",
dark: "#373A40",
},
"bg-medium": {
light: "#EDF2F5",
dark: "#2C2E33",
},
"bg-light": {
light: "#F9FBFC",
dark: "#25262B",
},
"bg-white": {
light: "#FFFFFF",
dark: "#1A1B1E",
},
"text-dark": {
light: "#4C5773",
dark: "#C1C2C5",
},
"text-medium": {
light: "#696E7B",
dark: "#A6A7AB",
},
"text-light": {
light: "#949AAB",
dark: "#909296",
},
"text-white": {
light: "#FFFFFF",
dark: "#1A1B1E",
},
"text-secondary-inverse": {
light: "#B7BCBF",
dark: "#6C6E73",
},
border: {
light: "#EEECEC",
dark: "#373A40",
},
white: {
light: "#FFFFFF",
dark: "#1A1B1E",
},
background: {
light: "#FFFFFF",
dark: "#1A1B1E",
},
"background-hover": {
light: "#F9FBFC",
dark: "#373A40",
},
"background-disabled": {
light: "#F3F5F7",
dark: "#2C2E33",
},
"background-light": {
light: "#FAFAFB",
dark: "#25262B",
},
"accent-gray": {
light: "#F3F3F4",
dark: "#373A40",
},
"accent-gray-light": {
light: "#FAFAFB",
dark: "#2C2E33",
},
} as const;
export type SemanticColorName = keyof typeof SEMANTIC_COLOR_SCHEMES;
export function getColorShades(colorName: string): ColorShades {
return [
colorName,
colorName,
colorName,
colorName,
colorName,
colorName,
colorName,
colorName,
colorName,
colorName,
];
}
export function getThemeAwareColors(
colorScheme: MantineColorScheme,
): Record<string, ColorShades> {
const semanticColors = Object.fromEntries(
Object.entries(SEMANTIC_COLOR_SCHEMES).map(([name, colors]) => [
name,
getColorShades((colors as any)[colorScheme] || colors.light),
]),
);
// Keep original Mantine colors as transparent (they handle dark mode internally)
const originalColors = [
"dark",
"gray",
"red",
"pink",
"grape",
"violet",
"indigo",
"blue",
"cyan",
"green",
"lime",
"yellow",
"orange",
"teal",
].reduce(
(acc, name) => {
acc[name] = getColorShades("transparent");
return acc;
},
{} as Record<string, ColorShades>,
);
return {
...originalColors,
...semanticColors,
};
}
/**
* Get a color value for the current color scheme
*/
export function getSemanticColor(
colorName: SemanticColorName,
colorScheme: MantineColorScheme,
): string {
const colors = SEMANTIC_COLOR_SCHEMES[colorName];
return (colors as any)[colorScheme] || colors.light;
}
/**
* CSS color variable from Metabase's theme
*/
export function color(colorName: SemanticColorName): string {
return `var(--mb-color-${colorName})`;
}

View File

@@ -91,7 +91,7 @@ export const getAxisNameDefaultOption = (
nameLocation: "middle",
nameRotate: rotate,
nameTextStyle: {
color: getColor("text-dark"),
color: getColor("text-primary"),
fontSize: theme.cartesian.label.fontSize,
fontWeight: CHART_STYLE.axisName.weight,
fontFamily,
@@ -105,7 +105,7 @@ export const getTicksDefaultOption = ({
}: RenderingContext) => {
return {
hideOverlap: true,
color: getColor("text-dark"),
color: getColor("text-primary"),
fontSize: theme.cartesian.label.fontSize,
fontWeight: CHART_STYLE.axisTicks.weight,
fontFamily,

View File

@@ -300,7 +300,7 @@ export const buildEChartsLabelOptions = (
fontFamily: renderingContext.fontFamily,
fontWeight: CHART_STYLE.seriesLabels.weight,
fontSize,
color: renderingContext.getColor("text-dark"),
color: renderingContext.getColor("text-primary"),
textBorderColor: renderingContext.getColor("bg-white"),
textBorderWidth: 3,
formatter:
@@ -440,7 +440,7 @@ function getDataLabelSeriesOption(
fontFamily: renderingContext.fontFamily,
fontWeight: CHART_STYLE.seriesLabels.weight,
fontSize: CHART_STYLE.seriesLabels.size,
color: renderingContext.getColor("text-dark"),
color: renderingContext.getColor("text-primary"),
textBorderColor: renderingContext.getColor("bg-white"),
textBorderWidth: 3,
},

View File

@@ -64,7 +64,7 @@ export const getSankeyChartOption = (
show: settings["sankey.show_edge_labels"],
formatter: (params) =>
typeof params.value === "number" ? valueFormatter(params.value) : "",
color: renderingContext.getColor("text-dark"),
color: renderingContext.getColor("text-primary"),
fontSize: SANKEY_CHART_STYLE.edgeLabels.size,
textBorderWidth: SANKEY_CHART_STYLE.edgeLabels.textBorderWidth,
textBorderColor: renderingContext.getColor("bg-white"),
@@ -83,7 +83,7 @@ export const getSankeyChartOption = (
curveness: 0.5,
},
label: {
color: renderingContext.getColor("text-dark"),
color: renderingContext.getColor("text-primary"),
fontSize: nodeLabelStyle.size,
fontWeight: nodeLabelStyle.weight,
fontFamily: nodeLabelStyle.family,

View File

@@ -124,7 +124,7 @@ function getTotalGraphicOption(
fontWeight: "700",
textAlign: "center",
fontFamily: renderingContext.fontFamily,
fill: renderingContext.getColor("text-dark"),
fill: renderingContext.getColor("text-primary"),
text: valueText,
},
},
@@ -288,7 +288,7 @@ function getSeriesDataFromSlices(
},
label: {
opacity:
labelColor === renderingContext.getColor("text-dark") ? 0.3 : 1,
labelColor === renderingContext.getColor("text-primary") ? 0.3 : 1,
},
},
};