feat: let users pick an accent color

Appearance gains an accent picker: Thingport green plus colors from provider brands (Thingiverse, Printables, Thangs, MyMiniFactory, Cults3D). The choice is stored per user beside the theme and recolors the app, the logo and its seasonal overlays at once.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-20 10:16:55 +02:00
1 parent 60ae936268
commit d26f730bee
22 files changed
+366 -86

No files matched your search

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "accentColor" TEXT;
+2
View File
@@ -127,6 +127,8 @@ model User {
slicer String?
// "light" | "dark" | "system"; null means never set.
themePreference String?
// One of ACCENT_COLORS; null means Thingport green.
accentColor String?
// Which tree the models page shows: "categories" | "folders"; null means categories.
categoriesView String?
// The dashboard widgets the user has switched on or off, as { [widgetId]: boolean }. A widget
+12 -5
View File
@@ -111,7 +111,12 @@ import {
setConsumeSettings,
} from "../services/consumeService";
import { CONSUME_DIR } from "../config";
import { THEME_SELECTIONS, getUserTheme, setUserTheme } from "../services/themePreferenceService";
import {
ACCENT_COLORS,
THEME_SELECTIONS,
getUserThemePreferences,
setUserThemePreferences,
} from "../services/themePreferenceService";
import { getUserAuthorPreviewEnabled, setUserAuthorPreviewEnabled } from "../services/authorPreviewPreferenceService";
import { checkForUpdates } from "../services/versionService";
import { getWeatherLocation, setWeatherLocation, weatherLocationSchema } from "../services/weatherLocationService";
@@ -968,17 +973,19 @@ router.patch(
router.get(
"/settings/theme",
asyncHandler(async (req, res) => {
res.json({ theme: await getUserTheme(req.userId!) });
res.json(await getUserThemePreferences(req.userId!));
}),
);
const themeSettingsSchema = z.object({ theme: z.enum(THEME_SELECTIONS).nullable() });
const themeSettingsSchema = z.object({
theme: z.enum(THEME_SELECTIONS).nullable().optional(),
accent: z.enum(ACCENT_COLORS).nullable().optional(),
});
router.patch(
"/settings/theme",
asyncHandler(async (req, res) => {
const body = parseBody(themeSettingsSchema, req.body);
const theme = await setUserTheme(req.userId!, body.theme);
res.json({ theme });
res.json(await setUserThemePreferences(req.userId!, body));
}),
);
+31 -9
View File
@@ -1,21 +1,43 @@
import { prisma } from "../db";
// Kept in sync by hand with the frontend's ThemeSelection.
// Kept in sync by hand with the frontend's ThemeSelection and AccentColor.
export const THEME_SELECTIONS = ["light", "dark", "system"] as const;
export type ThemeSelection = (typeof THEME_SELECTIONS)[number];
export const ACCENT_COLORS = ["green", "blue", "orange", "amber", "teal", "purple"] as const;
export type AccentColor = (typeof ACCENT_COLORS)[number];
function isThemeSelection(value: string): value is ThemeSelection {
return (THEME_SELECTIONS as readonly string[]).includes(value);
}
export async function getUserTheme(userId: string): Promise<ThemeSelection | null> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { themePreference: true } });
const value = user?.themePreference;
return value && isThemeSelection(value) ? value : null;
function isAccentColor(value: string): value is AccentColor {
return (ACCENT_COLORS as readonly string[]).includes(value);
}
export async function setUserTheme(userId: string, theme: string | null): Promise<ThemeSelection | null> {
const next = theme && isThemeSelection(theme) ? theme : null;
await prisma.user.update({ where: { id: userId }, data: { themePreference: next } });
return next;
export type ThemePreferences = { theme: ThemeSelection | null; accent: AccentColor | null };
export async function getUserThemePreferences(userId: string): Promise<ThemePreferences> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { themePreference: true, accentColor: true },
});
const theme = user?.themePreference;
const accent = user?.accentColor;
return {
theme: theme && isThemeSelection(theme) ? theme : null,
accent: accent && isAccentColor(accent) ? accent : null,
};
}
/** Only the fields given change; null clears one back to the frontend's default. */
export async function setUserThemePreferences(
userId: string,
patch: { theme?: string | null; accent?: string | null },
): Promise<ThemePreferences> {
const data: { themePreference?: ThemeSelection | null; accentColor?: AccentColor | null } = {};
if (patch.theme !== undefined)
data.themePreference = patch.theme && isThemeSelection(patch.theme) ? patch.theme : null;
if (patch.accent !== undefined) data.accentColor = patch.accent && isAccentColor(patch.accent) ? patch.accent : null;
await prisma.user.update({ where: { id: userId }, data });
return getUserThemePreferences(userId);
}
+7 -4
View File
@@ -46,6 +46,7 @@ import { settingsApi, type PreviewMode } from "./api/settings";
import { clearToken, clearUser, readToken, readUser, storeToken, storeUser } from "./utils/auth";
import { type AppSettings, type MakerWorldSettings, loadSettings, saveSettings } from "./utils/settings";
import { useResolvedTheme } from "./hooks/useResolvedTheme";
import { setCachedAccentColor, useAccentColor } from "./hooks/useAccentColor";
import type { ResolvedTheme, ThemeSelection } from "./constants/settingsOptions";
import { buildTheme } from "./theme";
@@ -309,7 +310,8 @@ export default function App() {
// Server-persisted so it follows the account across devices.
const [themeSelection, setThemeSelection] = React.useState<ThemeSelection>("light");
const resolvedTheme = useResolvedTheme(themeSelection);
const muiTheme = React.useMemo(() => buildTheme(resolvedTheme), [resolvedTheme]);
const accent = useAccentColor();
const muiTheme = React.useMemo(() => buildTheme(resolvedTheme, accent), [resolvedTheme, accent]);
// index.html's theme-color tags follow the OS; this makes the title bar follow the theme
// actually shown, which can be a manual light/dark choice.
React.useEffect(() => {
@@ -333,15 +335,16 @@ export default function App() {
if (!token) return;
(async () => {
try {
const { theme } = await settingsApi.getTheme();
const saved = await settingsApi.getTheme();
// Null means never set; keep the current theme.
if (theme) setThemeSelection(theme);
if (saved.theme) setThemeSelection(saved.theme);
if (saved.accent) setCachedAccentColor(saved.accent);
} catch {}
})();
}, [token]);
const handleThemeChange = React.useCallback((selected: ThemeSelection) => {
setThemeSelection(selected);
void settingsApi.updateTheme(selected).catch(() => {});
void settingsApi.updateTheme({ theme: selected }).catch(() => {});
}, []);
React.useEffect(() => {
saveSettings(settings);
+6 -4
View File
@@ -1,6 +1,6 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk, readErrorMessage, UnauthorizedError } from "./client";
import type { ThemeSelection } from "../constants/settingsOptions";
import type { AccentColor, ThemeSelection } from "../constants/settingsOptions";
import { invalidateAiCategorizationStatus } from "./aiCategorization";
export type StorageToken =
@@ -184,6 +184,8 @@ export const FRONTEND_VERSION: string = import.meta.env.VITE_APP_VERSION as stri
export const FRONTEND_GIT_SHA: string | null = (import.meta.env.VITE_GIT_SHA as string | undefined) || null;
export type ThemePreferences = { theme: ThemeSelection | null; accent: AccentColor | null };
export const settingsApi = {
getAiCategorization: async (): Promise<AiCategorizationSettings> => {
const res = await fetch(`${apiBase()}/settings/ai-categorization`, { headers: authHeaders() });
@@ -670,17 +672,17 @@ export const settingsApi = {
},
// Null means never set.
getTheme: async (): Promise<{ theme: ThemeSelection | null }> => {
getTheme: async (): Promise<ThemePreferences> => {
const res = await fetch(`${apiBase()}/settings/theme`, { headers: authHeaders() });
assertOk(res, "Failed to load theme setting");
return res.json();
},
updateTheme: async (theme: ThemeSelection): Promise<{ theme: ThemeSelection | null }> => {
updateTheme: async (patch: Partial<ThemePreferences>): Promise<ThemePreferences> => {
const res = await fetch(`${apiBase()}/settings/theme`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ theme }),
body: JSON.stringify(patch),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) {
+2 -3
View File
@@ -1,7 +1,6 @@
import Box from "@mui/material/Box";
import type { ResolvedTheme } from "../constants/settingsOptions";
import markColor from "../assets/logos/thingport-mark-color.svg";
import markOnDark from "../assets/logos/thingport-mark-on-dark.svg";
import { useBrandMark } from "../hooks/useBrandLogo";
type Props = {
theme: ResolvedTheme;
@@ -16,7 +15,7 @@ const HEIGHTS: Record<NonNullable<Props["size"]>, string> = {
/** Icon-only mark, for places where the `theme` prop rather than MUI's theme picks the variant. */
export default function BrandMark({ theme, size = "md" }: Props) {
const src = theme === "dark" ? markOnDark : markColor;
const src = useBrandMark(theme === "dark");
return (
<Box component="img" src={src} alt="Thingport" sx={{ height: HEIGHTS[size], width: "auto", display: "block" }} />
);
@@ -7,8 +7,11 @@ import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../../api/client";
import { settingsApi } from "../../../api/settings";
import { matchLanguage, SUPPORTED_LANGUAGES } from "../../../constants/languages";
import type { ThemeSelection } from "../../../constants/settingsOptions";
import { ACCENT_COLORS, type AccentColor, type ThemeSelection } from "../../../constants/settingsOptions";
import { setCachedAccentColor, useAccentColor } from "../../../hooks/useAccentColor";
import { setCachedAuthorPreviewEnabled, useAuthorPreviewEnabled } from "../../../hooks/useAuthorPreviewEnabled";
import { accentSwatch } from "../../../theme";
import ColorSwatches from "../../controls/ColorSwatches";
import Segmented from "../../controls/Segmented";
import HelpTip from "../../HelpTip";
import { PanelHeader, SectionLabel } from "./parts";
@@ -23,12 +26,28 @@ type Props = {
onUnauthorized?: () => void;
};
/** Theme, language and author preview, each applied as it's changed. */
/** Theme, accent, language and author preview, each applied as it's changed. */
export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }: Props) {
const { t, i18n } = useTranslation("app");
const authorPreview = useAuthorPreviewEnabled();
const [authorPreviewError, setAuthorPreviewError] = React.useState<string | null>(null);
const language = matchLanguage(i18n.resolvedLanguage ?? i18n.language);
const accent = useAccentColor();
const [accentError, setAccentError] = React.useState<string | null>(null);
const changeAccent = async (next: AccentColor) => {
const previous = accent;
setAccentError(null);
// The whole app recolours straight away; put back if the save fails.
setCachedAccentColor(next);
try {
await settingsApi.updateTheme({ accent: next });
} catch (err) {
setCachedAccentColor(previous);
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setAccentError(t("appearance.accentFailed"));
}
};
const toggleAuthorPreview = async (enabled: boolean) => {
setAuthorPreviewError(null);
@@ -57,6 +76,23 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
options={THEMES.map((value) => ({ value, label: t(`appearance.themes.${value}`) }))}
/>
</div>
<div>
<SectionLabel>{t("appearance.accent")}</SectionLabel>
<ColorSwatches
label={t("appearance.accent")}
value={accent}
onChange={(next) => void changeAccent(next)}
options={ACCENT_COLORS.map((value) => {
const { color, contrast } = accentSwatch(value);
return { value, label: t(`appearance.accents.${value}`), color, contrast };
})}
/>
{accentError && (
<Typography variant="caption" color="error" sx={{ display: "block", mt: 1 }}>
{accentError}
</Typography>
)}
</div>
<div>
<SectionLabel>{t("profile.language.heading")}</SectionLabel>
{SUPPORTED_LANGUAGES.length > MAX_SEGMENTED_LANGUAGES ? (
+3 -3
View File
@@ -3,8 +3,7 @@ import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Tooltip from "@mui/material/Tooltip";
import { useTheme, type SxProps, type Theme } from "@mui/material/styles";
import markColor from "../assets/logos/thingport-mark-color.svg";
import markOnDark from "../assets/logos/thingport-mark-on-dark.svg";
import { useBrandMark } from "../hooks/useBrandLogo";
import { printProviderInfo, providerChipSx } from "../constants/importProviders";
import { hasProviderLogo, ProviderLogo } from "./Layout/ConfigurationDialog/providerLogos";
import { useProviderAbout } from "./providerAbout/ProviderAboutProvider";
@@ -34,6 +33,7 @@ type Props = {
export default function ProviderBadge({ provider, size = 28, interactive = true, sx }: Props) {
const { t } = useTranslation(["models", "app"]);
const theme = useTheme();
const markSrc = useBrandMark();
const about = useProviderAbout(provider);
const openAbout = interactive ? about : undefined;
const info = printProviderInfo(provider);
@@ -111,7 +111,7 @@ export default function ProviderBadge({ provider, size = 28, interactive = true,
) : (
<Box
component="img"
src={theme.palette.mode === "dark" ? markOnDark : markColor}
src={markSrc}
alt=""
aria-hidden="true"
sx={{ width: logoSize, height: logoSize, objectFit: "contain", display: "block" }}
+11 -9
View File
@@ -1,9 +1,7 @@
import { useEffect, useState } from "react";
import { keyframes } from "@emotion/react";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import lockupColor from "../assets/logos/thingport-lockup-compact-color.svg";
import lockupOnDark from "../assets/logos/thingport-lockup-compact-on-dark.svg";
import { useBrandLockup, useTintedSvg } from "../hooks/useBrandLogo";
import { seasonFor } from "../utils/season";
type Props = {
@@ -18,8 +16,12 @@ const HEIGHTS: Record<NonNullable<Props["size"]>, string> = {
sm: "1.125rem",
};
// Each season's overlay is named after it, in lower case; only today's is loaded.
export const SEASONAL_OVERLAYS = import.meta.glob<string>("../assets/logos/seasonal/*.svg", { import: "default" });
// Each season's overlay is named after it, in lower case; only today's is loaded. As text, so its Thingport green
// can take the accent too; the colours it dresses the mark in, like Šaltibarščių diena's pink, stay its own.
export const SEASONAL_OVERLAYS = import.meta.glob<string>("../assets/logos/seasonal/*.svg", {
query: "?raw",
import: "default",
});
// Upside down all day, with a wobble now and then as if it noticed.
const upsideDown = keyframes`
@@ -31,17 +33,17 @@ const upsideDown = keyframes`
`;
export default function Wordmark({ size = "md", seasonal = false }: Props) {
const theme = useTheme();
const [season] = useState(() => (seasonal ? seasonFor(new Date()) : null));
const [overlay, setOverlay] = useState<string | null>(null);
const src = theme.palette.mode === "dark" ? lockupOnDark : lockupColor;
const [overlaySvg, setOverlaySvg] = useState<string | null>(null);
const overlay = useTintedSvg(overlaySvg);
const src = useBrandLockup();
const height = HEIGHTS[size];
useEffect(() => {
const load = season && SEASONAL_OVERLAYS[`../assets/logos/seasonal/${season.toLowerCase()}.svg`];
if (!load) return;
let active = true;
void load().then((url) => active && setOverlay(url));
void load().then((svg) => active && setOverlaySvg(svg));
return () => {
active = false;
};
@@ -3,7 +3,7 @@ import Box from "@mui/material/Box";
import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
import PriorityHighRoundedIcon from "@mui/icons-material/PriorityHighRounded";
import QuestionMarkRoundedIcon from "@mui/icons-material/QuestionMarkRounded";
import markColor from "../../assets/logos/thingport-mark-color.svg";
import { useBrandMark } from "../../hooks/useBrandLogo";
import { ModelShape, prefersReducedMotion, type Palette, type ShapeKind } from "../storageAnimations/parts";
export type UpdateCheckState = "checking" | "unknown" | "outdated" | "current";
@@ -75,6 +75,8 @@ const BUBBLES = {
export default function UpdateCheckAnimation({ size, state }: { size: number; state: UpdateCheckState }) {
const still = prefersReducedMotion();
// The light-background mark in either theme.
const markSrc = useBrandMark(false);
const checking = state === "checking";
const logo = size * (checking ? 0.36 : 0.5);
const shape = size * 0.12;
@@ -114,7 +116,7 @@ export default function UpdateCheckAnimation({ size, state }: { size: number; st
>
<Box
component="img"
src={markColor}
src={markSrc}
alt=""
sx={{ width: logo, height: logo, display: "block", transition: "width 300ms ease, height 300ms ease" }}
/>
@@ -0,0 +1,82 @@
import CheckIcon from "@mui/icons-material/Check";
import Box from "@mui/material/Box";
import Tooltip from "@mui/material/Tooltip";
import type { Theme } from "@mui/material/styles";
const SIZE = 22;
const trackBg = (theme: Theme) => (theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.06)" : "rgba(0, 0, 0, 0.05)");
type Props<T extends string> = {
value: T;
/** `label` is the tooltip and the accessible name; `contrast` colours the tick on the chosen swatch. */
options: { value: T; label: string; color: string; contrast: string }[];
onChange: (value: T) => void;
label: string;
};
/** Round colour swatches on Segmented's track; the chosen one is ringed and ticked. */
export default function ColorSwatches<T extends string>({ value, options, onChange, label }: Props<T>) {
return (
<Box
component="fieldset"
aria-label={label}
sx={{
m: 0,
minWidth: 0,
display: "inline-flex",
flexWrap: "wrap",
maxWidth: "100%",
gap: 0.75,
p: "6px",
borderRadius: "10px",
bgcolor: trackBg,
border: 1,
borderColor: "divider",
}}
>
{options.map((option) => {
const chosen = option.value === value;
return (
<Tooltip key={option.value} title={option.label}>
<Box
component="button"
type="button"
aria-pressed={chosen}
aria-label={option.label}
onClick={() => onChange(option.value)}
sx={{
width: SIZE,
height: SIZE,
p: 0,
border: 0,
borderRadius: "50% !important",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
bgcolor: option.color,
color: option.contrast,
// A gap in the page's colour, then a ring in the swatch's own.
boxShadow: (theme) =>
chosen ? `0 0 0 2px ${theme.palette.background.paper}, 0 0 0 4px ${option.color}` : "none",
transform: chosen ? "scale(1)" : "scale(0.9)",
transition: "transform 150ms, box-shadow 150ms",
"&:hover": { transform: "scale(1.05)" },
"&:focus-visible": { outline: "2px solid", outlineColor: "text.primary", outlineOffset: 4 },
"& > svg": {
fontSize: 14,
opacity: chosen ? 1 : 0,
transform: chosen ? "scale(1)" : "scale(0.4)",
transition: "opacity 150ms, transform 200ms",
},
"@media (prefers-reduced-motion: reduce)": { transition: "none", "& > svg": { transition: "none" } },
}}
>
<CheckIcon />
</Box>
</Tooltip>
);
})}
</Box>
);
}
@@ -6,8 +6,7 @@ import Typography from "@mui/material/Typography";
import { alpha, useTheme } from "@mui/material/styles";
import ExtensionOutlinedIcon from "@mui/icons-material/ExtensionOutlined";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import markColor from "../../assets/logos/thingport-mark-color.svg";
import markOnDark from "../../assets/logos/thingport-mark-on-dark.svg";
import { useBrandMark } from "../../hooks/useBrandLogo";
import { type Need, PROVIDER_FACTS } from "../../constants/providerFacts";
import { ProviderLogo, type ProviderId } from "../Layout/ConfigurationDialog/providerLogos";
@@ -52,6 +51,7 @@ type Props = {
export default function ProviderFlowAnimation({ id, name, accent, textAccent }: Props) {
const { t } = useTranslation("app");
const theme = useTheme();
const markSrc = useBrandMark();
const facts = PROVIDER_FACTS[id];
const helperIcon = { fontSize: 26, color: "text.secondary" } as const;
const nodes: Node[] = [
@@ -79,14 +79,7 @@ export default function ProviderFlowAnimation({ id, name, accent, textAccent }:
{
key: "thingport",
label: "Thingport",
icon: (
<Box
component="img"
src={theme.palette.mode === "dark" ? markOnDark : markColor}
alt=""
sx={{ width: 30, height: 30, display: "block" }}
/>
),
icon: <Box component="img" src={markSrc} alt="" sx={{ width: 30, height: 30, display: "block" }} />,
},
];
@@ -29,6 +29,10 @@ export const NORMALIZE_3MF_SLICER_IDS: ReadonlySet<string> = new Set([
/** "system" follows prefers-color-scheme; resolve with useResolvedTheme before rendering. */
export type ThemeSelection = "light" | "dark" | "system";
export type ResolvedTheme = "light" | "dark";
/** Kept in sync by hand with the backend's ACCENT_COLORS. Thingport green first, the rest from providers' brands. */
export const ACCENT_COLORS = ["green", "blue", "orange", "amber", "teal", "purple"] as const;
export type AccentColor = (typeof ACCENT_COLORS)[number];
export const DEFAULT_ACCENT: AccentColor = "green";
export type ThemeOption = { id: ThemeSelection; label: string; description: string };
export const THEME_OPTIONS: ThemeOption[] = [
+23
View File
@@ -0,0 +1,23 @@
import { useSyncExternalStore } from "react";
import { DEFAULT_ACCENT, type AccentColor } from "../constants/settingsOptions";
// One value for the whole app: App builds the theme from it and the Appearance panel changes it, so a pick
// recolours everything at once. App loads it with the theme.
let current: AccentColor = DEFAULT_ACCENT;
const listeners = new Set<() => void>();
export function setCachedAccentColor(accent: AccentColor) {
current = accent;
listeners.forEach((listener) => listener());
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function useAccentColor(): AccentColor {
return useSyncExternalStore(subscribe, () => current);
}
+34
View File
@@ -0,0 +1,34 @@
import { useMemo } from "react";
import { useTheme } from "@mui/material/styles";
import lockupColor from "../assets/logos/thingport-lockup-compact-color.svg?raw";
import lockupOnDark from "../assets/logos/thingport-lockup-compact-on-dark.svg?raw";
import markColor from "../assets/logos/thingport-mark-color.svg?raw";
import markOnDark from "../assets/logos/thingport-mark-on-dark.svg?raw";
// The logo files are drawn in Thingport green, which stands for the accent, so it's swapped for the one picked.
// Every other colour, such as a seasonal overlay's own, is left alone.
const BRAND_GREEN = /#00b800/gi;
function tint(svg: string, accent: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg.replace(BRAND_GREEN, accent))}`;
}
/** An SVG's text as an <img> src, its Thingport green in the theme's accent; null while there's no SVG. */
export function useTintedSvg(svg: string | null): string | null {
const accent = useTheme().palette.primary.main;
return useMemo(() => svg && tint(svg, accent), [svg, accent]);
}
/** The icon-only mark; `onDark` defaults to the theme's mode. */
export function useBrandMark(onDark?: boolean): string {
const { palette } = useTheme();
const svg = (onDark ?? palette.mode === "dark") ? markOnDark : markColor;
return useMemo(() => tint(svg, palette.primary.main), [svg, palette.primary.main]);
}
/** The mark beside the name. */
export function useBrandLockup(): string {
const { palette } = useTheme();
const svg = palette.mode === "dark" ? lockupOnDark : lockupColor;
return useMemo(() => tint(svg, palette.primary.main), [svg, palette.primary.main]);
}
+11 -1
View File
@@ -1574,7 +1574,17 @@
"system": "System"
},
"on": "On",
"off": "Off"
"off": "Off",
"accent": "Accent color",
"accentFailed": "Couldn't save the accent color.",
"accents": {
"green": "Thingport green",
"blue": "Thingiverse blue",
"orange": "Printables orange",
"amber": "Thangs yellow",
"teal": "MyMiniFactory teal",
"purple": "Cults3D purple"
}
},
"configuration": {
"title": "Configuration",
+11 -1
View File
@@ -1640,7 +1640,17 @@
"system": "Pagal sistemą"
},
"on": "Įjungta",
"off": "Išjungta"
"off": "Išjungta",
"accent": "Akcento spalva",
"accentFailed": "Nepavyko išsaugoti akcento spalvos.",
"accents": {
"green": "Thingport žalia",
"blue": "Thingiverse mėlyna",
"orange": "Printables oranžinė",
"amber": "Thangs geltona",
"teal": "MyMiniFactory žydra",
"purple": "Cults3D violetinė"
}
},
"configuration": {
"title": "Konfigūracija",
+1 -1
View File
@@ -276,7 +276,7 @@ export default function ModelCard({
minWidth: 0,
color: "#858585",
...((author || showViewerAsAuthor) && !selectMode
? { cursor: "pointer", "&:hover": { color: "#00b800" } }
? { cursor: "pointer", "&:hover": { color: "primary.main" } }
: undefined),
}}
onClick={(e) => {
@@ -4,6 +4,7 @@ import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ImageOutlinedIcon from "@mui/icons-material/ImageOutlined";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { alpha, useTheme } from "@mui/material/styles";
import type { PreviewMode } from "../../api/settings";
const TILE = 104;
@@ -53,15 +54,16 @@ const sweep = keyframes`
/** The Thingport cube, standing in for a rendered thumbnail. */
function Cube({ animation, waiting }: { animation: string; waiting?: boolean }) {
const { light, main, dark } = useTheme().palette.primary;
return (
<Box
component="svg"
viewBox="0 0 40 40"
sx={{ position: "absolute", inset: 0, m: "auto", width: 30, height: 30, opacity: waiting ? 0 : 1, animation }}
>
<path d="M20 5 L34 13 L20 21 L6 13 Z" fill="#3fd13f" />
<path d="M6 13 L20 21 L20 36 L6 28 Z" fill="#00b800" />
<path d="M34 13 L20 21 L20 36 L34 28 Z" fill="#008f00" />
<path d="M20 5 L34 13 L20 21 L6 13 Z" fill={light} />
<path d="M6 13 L20 21 L20 36 L6 28 Z" fill={main} />
<path d="M34 13 L20 21 L20 36 L34 28 Z" fill={dark} />
</Box>
);
}
@@ -202,7 +204,7 @@ export default function PreviewModeIllustration({ value, options, onChange, disa
border: "2px solid",
borderColor: chosen ? "primary.main" : "transparent",
boxShadow: chosen
? "0 0 0 4px rgba(0, 184, 0, 0.18), 0 4px 12px rgba(0, 0, 0, 0.12)"
? (theme) => `0 0 0 4px ${alpha(theme.palette.primary.main, 0.18)}, 0 4px 12px rgba(0, 0, 0, 0.12)`
: "inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04)",
display: "flex",
alignItems: "center",
@@ -4,10 +4,10 @@ import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { alpha, useTheme } from "@mui/material/styles";
const TILE = 104;
const HEIGHT = 58;
const EDGE = "#00b800";
/** The vase's radius at height t (0 bottom, 1 top): both models are sampled from it, so they're the same shape. */
const profile = (t: number) => 14 + 12.5 * Math.sin(Math.PI * (0.15 + 0.75 * t)) - 5 * t;
@@ -59,6 +59,7 @@ const DETAILED = faces(16, 6);
const SIMPLIFIED = faces(6, 3);
function Model({ mesh, caption }: { mesh: Face[]; caption: string }) {
const edge = useTheme().palette.primary.main;
return (
<Stack alignItems="center" spacing={0.75}>
<Box
@@ -101,8 +102,8 @@ function Model({ mesh, caption }: { mesh: Face[]; caption: string }) {
transform: face.transform,
}}
>
<polygon points={face.points} fill="rgba(0, 184, 0, 0.1)" stroke={EDGE} strokeWidth={0.8} />
<path d={face.diagonal} stroke={EDGE} strokeWidth={0.5} strokeOpacity={0.6} />
<polygon points={face.points} fill={alpha(edge, 0.1)} stroke={edge} strokeWidth={0.8} />
<path d={face.diagonal} stroke={edge} strokeWidth={0.5} strokeOpacity={0.6} />
</Box>
))}
</Box>
+69 -25
View File
@@ -1,7 +1,7 @@
import { alpha, createTheme, darken, type Theme, type ThemeOptions } from "@mui/material/styles";
import type { ResolvedTheme } from "./constants/settingsOptions";
import { alpha, createTheme, darken, lighten, type Theme, type ThemeOptions } from "@mui/material/styles";
import { DEFAULT_ACCENT, type AccentColor, type ResolvedTheme } from "./constants/settingsOptions";
export type { ResolvedTheme };
export type { AccentColor, ResolvedTheme };
export const THEME_IDS: ResolvedTheme[] = ["light", "dark"];
@@ -69,12 +69,15 @@ type ThemeDef = {
headingText: string;
};
const THEME_DEFS: Record<ResolvedTheme, ThemeDef> = {
type AccentKeys = "accent" | "accentLight" | "accentStrong" | "accentSoft" | "accentContrast" | "selectedNavText";
/** A theme before its accent: where the two tinted backgrounds are left out, they're taken from the accent. */
type BaseThemeDef = Omit<ThemeDef, AccentKeys | "subnavBackground" | "selectedNavBackground"> &
Partial<Pick<ThemeDef, "subnavBackground" | "selectedNavBackground">>;
const THEME_DEFS: Record<ResolvedTheme, BaseThemeDef> = {
light: {
mode: "light",
pageBackground: "#f7f7f7",
// A shade under the page, faintly green: neither the white sidebar nor the content.
subnavBackground: "#ecf1ec",
panel: "#ffffff",
panelStrong: "#ffffff",
border: "#ebebeb",
@@ -82,17 +85,10 @@ const THEME_DEFS: Record<ResolvedTheme, ThemeDef> = {
text: "#1f1f1f",
textMuted: "#858585",
textSubtle: "#858585",
accent: "#00b800",
accentLight: "#5be584",
accentStrong: darken("#00b800", 0.15),
accentSoft: alpha("#00b800", 0.16),
accentContrast: "#ffffff",
altText: "rgb(255, 114, 32)",
modelColor: "#cbd5e1",
modelEmissive: "#94a3b8",
navInactiveText: "#858585",
selectedNavBackground: alpha("#5be584", 0.2),
selectedNavText: "#00b800",
headingText: "#1f1f1f",
},
dark: {
@@ -107,29 +103,81 @@ const THEME_DEFS: Record<ResolvedTheme, ThemeDef> = {
text: "#828690",
textMuted: "#828690",
textSubtle: "#828690",
accent: "#00b800",
accentLight: "#5be584",
accentStrong: darken("#00b800", 0.15),
accentSoft: alpha("#00b800", 0.16),
accentContrast: "#ffffff",
altText: "rgb(255, 114, 32)",
modelColor: "#e2e8f0",
modelEmissive: "#475569",
navInactiveText: "#969ba0",
selectedNavBackground: "linear-gradient(to right, rgb(24, 31, 57) 0%, rgba(49, 206, 255, 0) 100%)",
selectedNavText: "#00b800",
headingText: "#ffffff",
},
};
type AccentDef = {
main: string;
/** Defaults to a lightened main. */
light?: string;
/** Text on a filled accent: white unless the accent is too pale for it. */
contrastText?: string;
/** Light mode's sub-menu background: a shade under the page, faintly tinted, so it's neither the white sidebar
* nor the content. Defaults to a little of the accent in grey. */
lightSubnav?: string;
};
// Picked to be far apart: the providers' greens (MakerWorld, Creality) and violets (NexPrint) are left out as too
// close to Thingport green and Cults3D purple.
const ACCENTS: Record<AccentColor, AccentDef> = {
green: { main: "#00b800", light: "#5be584", lightSubnav: "#ecf1ec" },
blue: { main: "#248bfb" }, // Thingiverse
orange: { main: "#fa6831" }, // Printables
amber: { main: "#f2a900", contrastText: "#1f1f1f" }, // Thangs, a shade deeper to read on white
teal: { main: "#00b39a" }, // MyMiniFactory, likewise
purple: { main: "#822ef5" }, // Cults3D
};
/** The accent's colour and the colour that reads on it, for pickers. */
export function accentSwatch(accent: AccentColor): { color: string; contrast: string } {
const { main, contrastText = "#ffffff" } = ACCENTS[accent];
return { color: main, contrast: contrastText };
}
/** `weight` of `color` over `base`, both hex. */
function mixHex(base: string, color: string, weight: number): string {
const channels = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const a = channels(base);
const b = channels(color);
return `#${a
.map((v, i) =>
Math.round(v + (b[i] - v) * weight)
.toString(16)
.padStart(2, "0"),
)
.join("")}`;
}
function withAccent(d: BaseThemeDef, accent: AccentColor): ThemeDef {
const a = ACCENTS[accent];
const light = a.light ?? lighten(a.main, 0.35);
return {
...d,
subnavBackground: d.subnavBackground ?? a.lightSubnav ?? mixHex("#f2f2f2", a.main, 0.06),
selectedNavBackground: d.selectedNavBackground ?? alpha(light, 0.2),
accent: a.main,
accentLight: light,
accentStrong: darken(a.main, 0.15),
accentSoft: alpha(a.main, 0.16),
accentContrast: accentSwatch(accent).contrast,
selectedNavText: a.main,
};
}
/** The veil behind modals and the open search. */
export function veilColor(mode: "light" | "dark"): string {
return mode === "dark" ? "rgba(0, 0, 0, 0.5)" : "rgba(255, 255, 255, 0.6)";
}
export const VEIL_BLUR = "blur(1.5px)";
export function buildTheme(id: ResolvedTheme): Theme {
const d = THEME_DEFS[id];
export function buildTheme(id: ResolvedTheme, accent: AccentColor = DEFAULT_ACCENT): Theme {
const d = withAccent(THEME_DEFS[id], accent);
const options: ThemeOptions = {
palette: {
mode: d.mode,
@@ -213,10 +261,6 @@ export function subtleTextColor(id: ResolvedTheme): string {
return THEME_DEFS[id].textSubtle;
}
export function accentSoftColor(id: ResolvedTheme): string {
return THEME_DEFS[id].accentSoft;
}
/** The border colour for container edges: the divider in light mode, invisible in dark mode where
* panels already contrast with the background. */
export function dividerBorderColor(theme: Theme): string {