Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ export const ReadOnlyCommandBlock = ({
EditorView.theme({
".highlight": {
"background-color":
colorMode === "light" ? colors.purple[200] : colors.purple[700],
colorMode === "light"
? colors.accentHue[200]
: colors.accentHue[700],
},
}),
];
Expand Down
3 changes: 1 addition & 2 deletions console/src/components/SimpleSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ const SimpleSelect = forwardRef<SimpleSelectProps, "select">((props, ref) => {
},
_focus: {
borderColor: colors.accent.brightPurple,
boxShadow:
"0px 0px 0px 0px hsla(0, 0%, 0%, 0), 0px 0px 0px 0px hsla(0, 0%, 0%, 0), 0px 0px 0px 2px hsla(257, 100%, 65%, 0.24)", // accent.brightPurple,
boxShadow: `0px 0px 0px 0px hsla(0, 0%, 0%, 0), 0px 0px 0px 0px hsla(0, 0%, 0%, 0), ${shadows.input.focus}`,
},
_invalid: {
borderColor: colors.accent.red,
Expand Down
4 changes: 2 additions & 2 deletions console/src/components/licenseComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ export const LicenseKeyCTAContent = ({
textAlign="left"
>
<HStack align="center" spacing={3}>
<BoxIcon color={colors.purple[500]} />
<BoxIcon color={colors.accentHue[500]} />
<Text textStyle="heading-sm">
{" "}
{selfManagedMode === "enterprise"
Expand Down Expand Up @@ -353,7 +353,7 @@ export const LicenseKeyCTAContent = ({
Want to learn more?{" "}
<TextLink
as="a"
color={colors.purple[400]}
color={colors.accentHue[400]}
href={docUrls["/docs/installation/"]}
target="_blank"
rel="noreferrer"
Expand Down
9 changes: 9 additions & 0 deletions console/src/config/AppConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
getEnvironmentdWebsocketScheme,
getFronteggUrl,
} from "./apiUrls";
import { type ConsoleAppearance } from "./appearance";
import { buildConstants } from "./buildConstants";
import { getCloudRegions } from "./cloudRegions";
import { getConsoleEnvironment } from "./consoleEnvironment";
Expand Down Expand Up @@ -95,6 +96,10 @@ interface IBaseAppConfig {
environmentdWebsocketScheme: WebsocketScheme;
// Whether query retries in react-query are enabled
reactQueryRetriesEnabled: boolean;
// How this instance's console distinguishes itself from the consoles of
// other instances. Never set in cloud mode, which serves one console for all
// of an organization's regions.
appearance: ConsoleAppearance | undefined;
}

export class CloudAppConfig implements IBaseAppConfig {
Expand Down Expand Up @@ -200,6 +205,8 @@ export class CloudAppConfig implements IBaseAppConfig {
// Whether the current environment requires user registration outside of the Console. This occurs in production
// when the Console's 'sign up' button links to the Marketing site.
requiresExternalRegistration = this.#consoleEnvironment === "production";

appearance = undefined;
}

export class SelfManagedAppConfig implements IBaseAppConfig {
Expand All @@ -209,6 +216,8 @@ export class SelfManagedAppConfig implements IBaseAppConfig {

balancerdDnsNames: string[] | undefined = appConfigJson.balancerdDnsNames;

appearance: ConsoleAppearance | undefined = appConfigJson.appearance;

environmentdScheme = getEnvironmentdScheme({
buildConstants,
isLocalImpersonation: false,
Expand Down
42 changes: 42 additions & 0 deletions console/src/config/appearance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright Materialize, Inc. and contributors. All rights reserved.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0.

import { documentTitle, parseConsoleAppearance } from "./appearance";

describe("documentTitle", () => {
it("names the instance when it has a display name", () => {
expect(documentTitle({ displayName: "prod" })).toEqual(
"Materialize Console 路 prod",
);
});

it("falls back to the plain title", () => {
expect(documentTitle(undefined)).toEqual("Materialize Console");
expect(documentTitle({})).toEqual("Materialize Console");
});
});

describe("parseConsoleAppearance", () => {
it("returns undefined when unconfigured", () => {
expect(parseConsoleAppearance(undefined)).toBeUndefined();
expect(parseConsoleAppearance(null)).toBeUndefined();
});

it("keeps a display name", () => {
expect(parseConsoleAppearance({ displayName: "prod" })).toEqual({
displayName: "prod",
});
});

it("treats an empty display name as unset", () => {
expect(parseConsoleAppearance({ displayName: "" })).toEqual({
displayName: undefined,
});
});
});
62 changes: 62 additions & 0 deletions console/src/config/appearance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright Materialize, Inc. and contributors. All rights reserved.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0.

/**
* @module
* Per-instance appearance, set on the Materialize resource and delivered to
* the browser in app-config.json. Self-managed only, since Cloud serves one
* console for all of a user's regions.
*/

/**
* The hues an instance may accent its console with. Each names a hue of the
* palette, which the theme reads the appropriate shades of. See
* `~/theme/accent`.
*/
export const ACCENT_COLORS = ["blue", "orange", "purple"] as const;

export type AccentColor = (typeof ACCENT_COLORS)[number];

export interface ConsoleAppearance {
/** A short name for the instance, such as "dev" or "prod". */
displayName?: string;
accentColor?: AccentColor;
}

const BASE_DOCUMENT_TITLE = "Materialize Console";

/** Builds the browser tab title, which names the instance when configured. */
export const documentTitle = (appearance: ConsoleAppearance | undefined) =>
appearance?.displayName
? `${BASE_DOCUMENT_TITLE} 路 ${appearance.displayName}`
: BASE_DOCUMENT_TITLE;

const isAccentColor = (value: unknown): value is AccentColor =>
ACCENT_COLORS.includes(value as AccentColor);

/**
* Validates the appearance read out of app-config.json.
*
* Orchestratord writes that file and may be newer than the console it serves,
* so an accent color this build doesn't know is dropped rather than handed to
* the theme as an unresolvable color.
*/
export const parseConsoleAppearance = (
appearance: { displayName?: string; accentColor?: string } | null | undefined,
): ConsoleAppearance | undefined => {
if (!appearance) {
return undefined;
}
return {
displayName: appearance.displayName || undefined,
accentColor: isAccentColor(appearance.accentColor)
? appearance.accentColor
: undefined,
};
};
4 changes: 4 additions & 0 deletions console/src/config/importAppConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// by the Apache License, Version 2.0.

import { type SelfManagedAuthMode } from "./AppConfig";
import { type ConsoleAppearance, parseConsoleAppearance } from "./appearance";

const DEFAULT_APP_CONFIG = {
auth: {
Expand Down Expand Up @@ -35,6 +36,7 @@ export function importAppConfig(): {
mode: SelfManagedAuthMode;
};
balancerdDnsNames?: string[];
appearance?: ConsoleAppearance;
} {
if (process.env.NODE_ENV === "test") {
return DEFAULT_APP_CONFIG;
Expand All @@ -45,9 +47,11 @@ export function importAppConfig(): {
mode: SelfManagedAuthMode;
};
balancerd_dns_names?: string[];
appearance?: { displayName?: string; accentColor?: string };
};
return {
auth: json.auth,
balancerdDnsNames: json.balancerd_dns_names,
appearance: parseConsoleAppearance(json.appearance),
};
}
4 changes: 4 additions & 0 deletions console/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ import "~/sentry";
import React from "react";
import { createRoot } from "react-dom/client";

import { appConfig } from "~/config/AppConfig";
import { documentTitle } from "~/config/appearance";
import { App } from "~/platform/App";

import { addChunkLoadErrorListener } from "./utils/chunkLoadErrorHandler";

document.title = documentTitle(appConfig.appearance);

const rootEl = document.createElement("div");
document.body.appendChild(rootEl);
const root = createRoot(rootEl);
Expand Down
3 changes: 1 addition & 2 deletions console/src/layouts/NavBar/NavItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,7 @@ export const NavItem = (props: NavItemProps) => {
showActiveStyle
? {
...NAV_HOVER_STYLES,
// slightly more opaque than colors.background.accent
bg: "rgba(90, 52, 203, 0.2)",
bg: colors.background.accentActive,
}
: undefined
}
Expand Down
2 changes: 1 addition & 1 deletion console/src/platform/shell/HistorySearchModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const HistoryList = ({
const { colors } = useTheme<MaterializeTheme>();
const { colorMode } = useColorMode();
const highlightBgColor =
colorMode === "light" ? colors.purple[200] : colors.purple[700];
colorMode === "light" ? colors.accentHue[200] : colors.accentHue[700];
const highlightFgColor =
colorMode === "light" ? "unset" : colors.foreground.primary;

Expand Down
109 changes: 109 additions & 0 deletions console/src/theme/accent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright Materialize, Inc. and contributors. All rights reserved.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0.

import { ACCENT_COLORS } from "~/config/appearance";

import { accentHueFor, buildAccentPalettes } from "./accent";
import colors from "./colors";
import { darkColors } from "./dark";
import { lightColors } from "./light";

describe("accentHueFor", () => {
it("defaults to Materialize purple", () => {
expect(accentHueFor(undefined)).toBe(colors.purple);
});

it("resolves a configured accent color to its hue", () => {
expect(accentHueFor("blue")).toBe(colors.blue);
});
});

describe("buildAccentPalettes", () => {
// An instance that configures nothing must render exactly as it did before
// the accent became configurable, so these pin the shades the themes used
// when they were hardcoded.
it("reproduces the original purple tokens by default", () => {
const { light, dark } = buildAccentPalettes(undefined);

expect(light).toEqual({
primary: "#472F85",
bright: "#5A34CB",
wash: "rgba(90, 52, 203, 0.08)",
focusRing: "0px 0px 0px 2px rgba(127, 78, 255, 0.24)",
});
expect(dark).toEqual({
primary: "#7F4EFF",
bright: "#B59AFF",
wash: "rgba(181, 154, 255, 0.08)",
focusRing: "0px 0px 0px 2px rgba(181, 154, 255, 0.4)",
});
});

it("washes the active navigation item the same in both themes", () => {
expect(buildAccentPalettes(undefined).activeWash).toEqual(
"rgba(90, 52, 203, 0.2)",
);
});

it("accents a configured hue with that hue's shades", () => {
const { light, dark } = buildAccentPalettes("orange");

expect(light.primary).toEqual(colors.orange[600]);
expect(light.bright).toEqual(colors.orange[600]);
expect(dark.primary).toEqual(colors.orange[500]);
expect(dark.bright).toEqual(colors.orange[300]);
});

it("washes the accent at the same opacity for any hue", () => {
const { light, dark } = buildAccentPalettes("blue");

expect(light.wash).toEqual("rgba(0, 114, 180, 0.08)");
expect(dark.wash).toEqual("rgba(89, 195, 255, 0.08)");
});
});

/** WCAG relative luminance of a `#rgb` or `#rrggbb` color. */
const luminance = (hex: string) => {
const digits = hex.replace("#", "");
const width = digits.length / 3;
const channels = [0, 1, 2]
.map((i) => digits.slice(i * width, (i + 1) * width))
.map((channel) => (width === 1 ? channel.repeat(2) : channel))
.map((channel) => parseInt(channel, 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
};

const contrast = (a: string, b: string) => {
const [dimmer, brighter] = [luminance(a), luminance(b)].sort((x, y) => x - y);
return (brighter + 0.05) / (dimmer + 0.05);
};

describe("accent shades", () => {
// The shades are chosen by hand per hue, so hold them to the ratios that
// choice promises rather than trusting the table.
it.each(ACCENT_COLORS)("stay legible for %s", (accentColor) => {
const { light, dark } = buildAccentPalettes(accentColor);

// Drawn as text on the page, and as a surface behind a white label.
expect(
contrast(light.bright, lightColors.background.primary),
).toBeGreaterThanOrEqual(4.5);
expect(
contrast(light.primary, lightColors.foreground.primaryButtonLabel),
).toBeGreaterThanOrEqual(4.5);
expect(
contrast(dark.bright, darkColors.background.secondary),
).toBeGreaterThanOrEqual(4.5);
// Large, bold button labels, which WCAG holds to 3:1.
expect(
contrast(dark.primary, darkColors.foreground.primaryButtonLabel),
).toBeGreaterThanOrEqual(3);
});
});
Loading
Loading