diff --git a/package.json b/package.json
index cc84311..8b8ae3a 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,11 @@
{
"name": "tanstack-start-example-basic-react-query",
"private": true,
- "sideEffects": false,
+ "sideEffects": [
+ "**/*.css",
+ "**/themes/styles.ts",
+ "**/themes/theme-styles.ts"
+ ],
"type": "module",
"scripts": {
"dev": "vite dev --host 0.0.0.0",
diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx
index 217155c..b9eb8f0 100644
--- a/src/app/routes/__root.tsx
+++ b/src/app/routes/__root.tsx
@@ -203,7 +203,17 @@ function RootDocument({ children }: { children: React.ReactNode }) {
media="(prefers-color-scheme: dark)"
content={CHROME_COLORS.dark.base}
/>
-
+
+ {/* Pre-hydration theme restore from ThemeProvider's localStorage
+ mirrors: data-theme (so exotic themes don't flash default),
+ forced color scheme (dark-only themes must not paint light),
+ and the theme's font stylesheets (avoid FOUT until hydration). */}
+
Appearance
+
diff --git a/src/components/avatar.tsx b/src/components/avatar.tsx
index 86c4bb8..1e5ba50 100644
--- a/src/components/avatar.tsx
+++ b/src/components/avatar.tsx
@@ -1,7 +1,7 @@
import {
Avatar as MantineAvatar,
AvatarProps as MantineAvatarProps,
- Paper,
+ Box,
Modal,
Image,
Group,
@@ -22,10 +22,17 @@ interface AvatarProps
contain?: boolean;
}
+// Mantine radius tokens ("sm", "xl") are valid as props but not as raw CSS
+// values — resolve them before using in style/borderRadius.
+const resolveRadius = (radius: string | number) =>
+ typeof radius === "string" && /^[a-z]+$/.test(radius)
+ ? `var(--mantine-radius-${radius})`
+ : radius;
+
const Avatar = ({
name,
size = 35,
- radius = "100%",
+ radius = "var(--flxn-avatar-radius, 50%)",
withBorder = true,
disableFullscreen = false,
contain = false,
@@ -34,6 +41,7 @@ const Avatar = ({
const { t } = useLingui();
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const hasImage = Boolean(props.src);
+ const frameRadius = resolveRadius(radius);
const handleAvatarClick = () => {
if (hasImage && !disableFullscreen) {
@@ -43,11 +51,20 @@ const Avatar = ({
return (
<>
-
-
+
= ({
onExited,
}) => {
const colorScheme = useComputedColorScheme("light");
+ const { metadata } = useAuth();
const contentRef = useRef(null);
+ const themeId = metadata.theme;
useEffect(() => {
if (!opened) return;
const appElement = document.querySelector(".app");
- const colors = CHROME_COLORS[colorScheme];
+ const colors = resolveChromeColors(getTheme(themeId))[colorScheme];
appElement?.classList.add("drawer-scaling");
- setThemeColorMeta(colors.dimmed);
+ pushDimmedThemeColor(colors.dimmed);
return () => {
appElement?.classList.remove("drawer-scaling");
- setThemeColorMeta(colors.base);
+ popDimmedThemeColor(colors.base);
};
- }, [opened, colorScheme]);
+ }, [opened, colorScheme, themeId]);
useEffect(() => {
if (!opened || !contentRef.current) return;
diff --git a/src/components/sheet/styles.module.css b/src/components/sheet/styles.module.css
index 28c5bf4..f8554d3 100644
--- a/src/components/sheet/styles.module.css
+++ b/src/components/sheet/styles.module.css
@@ -1,7 +1,7 @@
.drawerOverlay {
position: fixed;
inset: 0;
- background-color: rgba(0, 0, 0, 0.4);
+ background-color: var(--flxn-overlay);
z-index: 101;
}
diff --git a/src/components/swipeable-tabs.tsx b/src/components/swipeable-tabs.tsx
index 64f2bac..03d6792 100644
--- a/src/components/swipeable-tabs.tsx
+++ b/src/components/swipeable-tabs.tsx
@@ -48,6 +48,8 @@ function SwipeableTabs({
}, [search?.tab, tabs, defaultTab]);
const [activeTab, setActiveTab] = useState(getActiveTabFromUrl);
+ const [stuck, setStuck] = useState(false);
+ const sentinelRef = useRef(null);
const [rootRef, setRootRef] = useState(null);
const controlsRefs = useRef>({});
const slideRefs = useRef>({});
@@ -132,6 +134,20 @@ function SwipeableTabs({
};
}, [activeTab, updateHeight]);
+ // A 1px sentinel above the sticky strip: once it scrolls out of view the
+ // strip is pinned — flip data-stuck so themes can swap flat → docked chrome.
+ useEffect(() => {
+ const sentinel = sentinelRef.current;
+ if (!sentinel) return;
+
+ const observer = new IntersectionObserver(
+ ([entry]) => setStuck(!entry.isIntersecting),
+ { threshold: 0 }
+ );
+ observer.observe(sentinel);
+ return () => observer.disconnect();
+ }, []);
+
const setControlRef = useCallback(
(index: number) => (node: HTMLSpanElement | null) => {
controlsRefs.current[index] = node;
@@ -148,8 +164,11 @@ function SwipeableTabs({
return (
+
{tabs.map((tab, index) => (
changeTab(index)}
- style={{
- flex: 1,
- padding: "var(--mantine-spacing-sm) var(--mantine-spacing-xs)",
- textAlign: "center",
- color:
- activeTab === index
- ? "var(--mantine-primary-color-filled)"
- : "var(--mantine-color-text)",
- fontWeight: activeTab === index ? 600 : 400,
- transition: "color 200ms ease, font-weight 200ms ease",
- backgroundColor: "transparent",
- border: "none",
- borderRadius: 0,
- }}
+ className="flxn-tab"
+ data-active={activeTab === index || undefined}
>
diff --git a/src/components/team-avatar.tsx b/src/components/team-avatar.tsx
index 4a69d39..01cc3f2 100644
--- a/src/components/team-avatar.tsx
+++ b/src/components/team-avatar.tsx
@@ -62,7 +62,7 @@ const TeamAvatar = ({
top: -crownSize * 1.1,
left: "45%",
transform: "translateX(-50%)",
- color: "gold",
+ color: "var(--flxn-gold)",
rotate: "-5deg",
}}
>
@@ -83,7 +83,7 @@ const TeamAvatar = ({
top: -crownSize * 0.95,
left: "65%",
transform: "translateX(-50%)",
- color: "gold",
+ color: "var(--flxn-gold)",
rotate: "10deg",
}}
>
@@ -116,7 +116,7 @@ const TeamAvatar = ({
top: -crownSize * 0.6,
left: -crownSize * 0.25,
transform: "rotate(-25deg)",
- color: "gold",
+ color: "var(--flxn-gold)",
}}
>
diff --git a/src/contexts/auth-context.tsx b/src/contexts/auth-context.tsx
index 340ecee..fa8fca7 100644
--- a/src/contexts/auth-context.tsx
+++ b/src/contexts/auth-context.tsx
@@ -15,6 +15,7 @@ interface AuthData {
metadata: {
accentColor: MantineColor;
colorScheme: MantineColorScheme;
+ theme?: string;
locale?: string;
};
roles: string[];
diff --git a/src/features/badges/components/badge-showcase.tsx b/src/features/badges/components/badge-showcase.tsx
index 0c4bf1f..eab3042 100644
--- a/src/features/badges/components/badge-showcase.tsx
+++ b/src/features/badges/components/badge-showcase.tsx
@@ -208,8 +208,8 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
style={{
width: '100px',
height: '100px',
- borderRadius: '12px',
- background: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-7))',
+ borderRadius: 'var(--flxn-badge-radius)',
+ background: 'var(--flxn-showcase-bg)',
border: '2px solid var(--mantine-primary-color-5)',
position: 'absolute',
top: `${(i + 1) * 4}px`,
@@ -226,8 +226,8 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
style={{
width: '100px',
height: '100px',
- borderRadius: '12px',
- background: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-7))',
+ borderRadius: 'var(--flxn-badge-radius)',
+ background: 'var(--flxn-showcase-bg)',
border: display.earned
? '2px solid var(--mantine-primary-color-6)'
: '2px dashed var(--mantine-primary-color-4)',
diff --git a/src/features/bracket/components/bracket-pending.tsx b/src/features/bracket/components/bracket-pending.tsx
index 7f6f788..2de7811 100644
--- a/src/features/bracket/components/bracket-pending.tsx
+++ b/src/features/bracket/components/bracket-pending.tsx
@@ -9,7 +9,7 @@ export function BracketPending() {
p={0}
style={{
overflow: "hidden",
- backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
+ backgroundImage: `radial-gradient(circle, var(--flxn-dotgrid-color) 1px, transparent 1px)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 8px 8px",
minHeight: "70dvh",
diff --git a/src/features/bracket/components/bracket-view.tsx b/src/features/bracket/components/bracket-view.tsx
index e098e63..ef553b4 100644
--- a/src/features/bracket/components/bracket-view.tsx
+++ b/src/features/bracket/components/bracket-view.tsx
@@ -89,7 +89,7 @@ const BracketView: React.FC = ({ bracket, showControls, groupC
h={`calc(${height})`}
className={styles["bracket-container"]}
style={{
- backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
+ backgroundImage: `radial-gradient(circle, var(--flxn-dotgrid-color) 1px, transparent 1px)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 8px 8px",
}}
diff --git a/src/features/bracket/components/match-card.tsx b/src/features/bracket/components/match-card.tsx
index 936c41f..a3bbb8b 100644
--- a/src/features/bracket/components/match-card.tsx
+++ b/src/features/bracket/components/match-card.tsx
@@ -269,7 +269,11 @@ export const MatchCard: React.FC = ({
w={showToolbar || showEditButton ? 200 : 220}
withBorder
pos="relative"
- className={canTap ? styles["tappable-card"] : undefined}
+ className={
+ canTap
+ ? `flxn-bracket-card ${styles["tappable-card"]}`
+ : "flxn-bracket-card"
+ }
onClick={canTap ? () => onTap!(match) : undefined}
role={canTap ? "button" : undefined}
tabIndex={canTap ? 0 : undefined}
diff --git a/src/features/bracket/components/match-slot.tsx b/src/features/bracket/components/match-slot.tsx
index 4862d78..e1ce76e 100644
--- a/src/features/bracket/components/match-slot.tsx
+++ b/src/features/bracket/components/match-slot.tsx
@@ -83,10 +83,10 @@ export const MatchSlot: React.FC = ({
size={14}
weight="fill"
style={{
- color: 'gold',
+ color: 'var(--flxn-gold)',
marginLeft: '2px',
marginTop: '-1px',
- filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.3))',
+ filter: 'drop-shadow(0 1px 1px var(--flxn-pop-shadow))',
flexShrink: 0
}}
/>
diff --git a/src/features/matches/components/match-card.tsx b/src/features/matches/components/match-card.tsx
index fe9cd8a..fd349ce 100644
--- a/src/features/matches/components/match-card.tsx
+++ b/src/features/matches/components/match-card.tsx
@@ -52,6 +52,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
py="md"
withBorder
radius="md"
+ className="flxn-match-card"
style={{ position: "relative", zIndex: 2 }}
>
@@ -208,16 +209,12 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
diff --git a/src/features/matches/components/team-head-to-head-sheet.tsx b/src/features/matches/components/team-head-to-head-sheet.tsx
index e88be76..f343c02 100644
--- a/src/features/matches/components/team-head-to-head-sheet.tsx
+++ b/src/features/matches/components/team-head-to-head-sheet.tsx
@@ -130,7 +130,7 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
{leader && (
-
+
{leader.name} leads the series
diff --git a/src/features/players/components/player-head-to-head-sheet.tsx b/src/features/players/components/player-head-to-head-sheet.tsx
index 05ddc8b..07c2755 100644
--- a/src/features/players/components/player-head-to-head-sheet.tsx
+++ b/src/features/players/components/player-head-to-head-sheet.tsx
@@ -158,7 +158,7 @@ const PlayerHeadToHeadContent = ({
{leader && (
-
+
{leader} leads the series
diff --git a/src/features/players/components/profile/index.tsx b/src/features/players/components/profile/index.tsx
index c83d079..9b71a84 100644
--- a/src/features/players/components/profile/index.tsx
+++ b/src/features/players/components/profile/index.tsx
@@ -1,4 +1,4 @@
-import { Box, Stack, Text, Divider, Group, Button, Anchor } from "@mantine/core";
+import { Box, Stack, Text, Divider, Group, Button } from "@mantine/core";
import { Suspense, useState, useDeferredValue } from "react";
import { Trans, useLingui } from "@lingui/react/macro";
import { Link } from "@tanstack/react-router";
@@ -57,9 +57,14 @@ const Profile = ({ id }: ProfileProps) => {
Badges
-
+
+
}>
diff --git a/src/features/predictions/components/prediction-leaderboard.tsx b/src/features/predictions/components/prediction-leaderboard.tsx
index ef41e6d..0567d80 100644
--- a/src/features/predictions/components/prediction-leaderboard.tsx
+++ b/src/features/predictions/components/prediction-leaderboard.tsx
@@ -244,7 +244,7 @@ export const PredictionLeaderboard: React.FC = ({
weight="fill"
color={
entry.championCorrect && isComplete
- ? "gold"
+ ? "var(--flxn-gold)"
: "var(--mantine-color-dimmed)"
}
/>
diff --git a/src/features/predictions/components/winner-selector.tsx b/src/features/predictions/components/winner-selector.tsx
index 3c7e20b..2a92a46 100644
--- a/src/features/predictions/components/winner-selector.tsx
+++ b/src/features/predictions/components/winner-selector.tsx
@@ -79,8 +79,8 @@ const TeamChip = ({
size={14}
weight="fill"
style={{
- color: "gold",
- filter: "drop-shadow(0 1px 1px rgba(0,0,0,0.3))",
+ color: "var(--flxn-gold)",
+ filter: "drop-shadow(0 1px 1px var(--flxn-pop-shadow))",
flexShrink: 0,
}}
/>
diff --git a/src/features/settings/components/theme-picker.tsx b/src/features/settings/components/theme-picker.tsx
new file mode 100644
index 0000000..d051453
--- /dev/null
+++ b/src/features/settings/components/theme-picker.tsx
@@ -0,0 +1,111 @@
+import {
+ Box,
+ SimpleGrid,
+ Stack,
+ Text,
+ UnstyledButton,
+ useComputedColorScheme,
+} from "@mantine/core";
+import { CaretRightIcon } from "@phosphor-icons/react";
+import { Trans, useLingui } from "@lingui/react/macro";
+import Sheet from "@/components/sheet/sheet";
+import { useSheet } from "@/hooks/use-sheet";
+import { updateUserTheme } from "@/features/settings/server";
+import { useAuth } from "@/contexts/auth-context";
+import { DEFAULT_THEME_ID, THEME_LIST, getTheme } from "@/themes/registry";
+import ThemePreview from "./theme-preview";
+
+const ThemePicker = () => {
+ const { metadata, user, set } = useAuth();
+ const { t } = useLingui();
+ const sheet = useSheet();
+ const scheme = useComputedColorScheme("dark");
+ const currentId = metadata.theme || DEFAULT_THEME_ID;
+ const current = getTheme(currentId);
+
+ const handleSelect = async (id: string) => {
+ if (!user || id === currentId) return;
+ const previous = metadata;
+ set({ metadata: { ...metadata, theme: id } });
+ try {
+ await updateUserTheme({ data: id });
+ } catch {
+ set({ metadata: previous });
+ }
+ };
+
+ return (
+
+
+ Theme
+
+
+
+
+
+
+ {t(current.label)}
+
+
+
+
+
+
+ {[...THEME_LIST]
+ .sort(
+ (a, b) =>
+ (a.order ?? 100) - (b.order ?? 100) ||
+ t(a.label).localeCompare(t(b.label))
+ )
+ .map((manifest) => {
+ const isSelected = manifest.id === currentId;
+ return (
+ handleSelect(manifest.id)}
+ >
+
+
+
+
+
+ {t(manifest.label)}
+
+
+
+ );
+ })}
+
+
+
+ );
+};
+
+export default ThemePicker;
diff --git a/src/features/settings/components/theme-preview.tsx b/src/features/settings/components/theme-preview.tsx
new file mode 100644
index 0000000..37ccc8a
--- /dev/null
+++ b/src/features/settings/components/theme-preview.tsx
@@ -0,0 +1,179 @@
+import {
+ DEFAULT_THEME,
+ convertCssVariables,
+ defaultCssVariablesResolver,
+ mergeMantineTheme,
+ mergeThemeOverrides,
+} from "@mantine/core";
+import { useEffect, useMemo, useState } from "react";
+import { useLingui } from "@lingui/react/macro";
+import { useAuth } from "@/contexts/auth-context";
+import { baseTheme } from "@/lib/mantine/base-theme";
+import type { ThemeManifest } from "@/themes/types";
+
+/* Each preview is a same-origin srcdoc iframe carrying the theme's own
+ data-theme/-scheme, the app's stylesheets, the theme's font links, and its
+ Mantine variables — 1:1 with the active theme, with nothing bleeding in. */
+
+const buildMantineVarsCss = (manifest: ThemeManifest, accent: string) => {
+ const merged = mergeMantineTheme(
+ DEFAULT_THEME,
+ mergeThemeOverrides(baseTheme, manifest.mantine ?? {}, {
+ primaryColor: accent,
+ })
+ );
+ return convertCssVariables(defaultCssVariablesResolver(merged), ":root");
+};
+
+const collectAppCss = () =>
+ Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
+ .filter(
+ (el) =>
+ !el.hasAttribute("data-mantine-styles") &&
+ !el.hasAttribute("data-flxn-theme-font")
+ )
+ .map((el) =>
+ el.tagName === "LINK"
+ ? el.outerHTML
+ : ``
+ )
+ .join("\n");
+
+const PREVIEW_LOCAL_CSS = `
+html, body { margin: 0; height: 100%; overflow: hidden; }
+body { display: flex; }
+.pv-card {
+ flex: 1;
+ margin: 0.375rem;
+ padding: 0.5rem;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 6px;
+ background-color: var(--mantine-color-body);
+ border: 1px solid var(--mantine-color-default-border);
+ border-radius: var(--mantine-radius-default);
+ box-shadow: var(--mantine-shadow-sm);
+}
+.pv-heading {
+ font-family: var(--mantine-font-family-headings);
+ color: var(--mantine-color-text);
+ font-size: 0.875rem;
+ font-weight: 700;
+ line-height: 1;
+}
+.pv-row { display: flex; align-items: center; gap: 4px; }
+.pv-chip {
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+ border-radius: var(--mantine-radius-default);
+ background-color: var(--mantine-primary-color-filled);
+}
+.pv-bars { display: flex; flex-direction: column; gap: 3px; flex: 1; }
+.pv-bar {
+ display: block;
+ height: 4px;
+ border-radius: 2px;
+ background-color: var(--mantine-color-dimmed);
+}
+`;
+
+const buildSrcdoc = (
+ manifest: ThemeManifest,
+ scheme: "light" | "dark",
+ accent: string,
+ appCss: string
+) => {
+ const fontLinks = (manifest.fontLinks ?? [])
+ .map((href) => ``)
+ .join("");
+ return `
+
+
+
+
+
+${appCss}
+${fontLinks}
+
+
+
+
+
+`;
+};
+
+interface ThemePreviewProps {
+ manifest: ThemeManifest;
+ scheme: "light" | "dark";
+ height?: string;
+ /** Renders the document at height/scale and scales it down visually. */
+ scale?: number;
+}
+
+const ThemePreview = ({
+ manifest,
+ scheme,
+ height = "4.5rem",
+ scale = 1,
+}: ThemePreviewProps) => {
+ const { metadata } = useAuth();
+ const { t } = useLingui();
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+
+ const previewScheme =
+ manifest.colorSchemes === "both" ? scheme : manifest.colorSchemes;
+ const accent = metadata.accentColor || "blue";
+
+ const srcdoc = useMemo(
+ () =>
+ mounted
+ ? buildSrcdoc(manifest, previewScheme, accent, collectAppCss())
+ : null,
+ [mounted, manifest, previewScheme, accent]
+ );
+
+ const frameStyle: React.CSSProperties = {
+ width: "100%",
+ height,
+ border: "1px solid var(--mantine-color-default-border)",
+ borderRadius: "var(--mantine-radius-md)",
+ overflow: "hidden",
+ };
+
+ if (!srcdoc) return ;
+ return (
+
+
+
+ );
+};
+
+export default ThemePreview;
diff --git a/src/features/settings/server.ts b/src/features/settings/server.ts
index 4236096..d60a582 100644
--- a/src/features/settings/server.ts
+++ b/src/features/settings/server.ts
@@ -2,12 +2,28 @@ import { createServerFn } from "@tanstack/react-start";
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { serverFnLoggingMiddleware } from "@/utils/activities";
+// Whitelists: these values flow into Mantine's primaryColor / the
+// data-mantine-color-scheme attribute — an unknown value crash-loops or
+// unstyles the account's UI on every load.
+const ACCENT_COLORS = [
+ "blue",
+ "red",
+ "green",
+ "yellow",
+ "grape",
+ "orange",
+ "pink",
+ "lime",
+];
+const COLOR_SCHEMES = ["light", "dark", "auto"];
+
export const updateUserColorScheme = createServerFn({ method: "POST" })
.validator((data: string) => data)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => {
const { userAuthId, metadata } = context;
if (!userAuthId) return;
+ if (!COLOR_SCHEMES.includes(data)) return;
const { updateUserMetadataFields } = await import(
"@/utils/supertokens-core.server"
@@ -51,12 +67,41 @@ export const updateUserLocale = createServerFn({ method: "POST" })
};
});
+export const updateUserTheme = createServerFn({ method: "POST" })
+ .validator((data: string) => data)
+ .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
+ .handler(async ({ context, data }) => {
+ const { userAuthId, metadata } = context;
+ if (!userAuthId) return;
+
+ const { THEME_IDS } = await import("@/themes/registry");
+ if (!THEME_IDS.includes(data)) {
+ throw new Error("Unknown theme");
+ }
+
+ const { updateUserMetadataFields } = await import(
+ "@/utils/supertokens-core.server"
+ );
+
+ await updateUserMetadataFields(userAuthId, {
+ theme: data,
+ });
+
+ return {
+ metadata: {
+ ...metadata,
+ theme: data,
+ },
+ };
+ });
+
export const updateUserAccentColor = createServerFn({ method: "POST" })
.validator((data: string) => data)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => {
const { userAuthId, metadata } = context;
if (!userAuthId) return;
+ if (!ACCENT_COLORS.includes(data)) return;
const { updateUserMetadataFields } = await import(
"@/utils/supertokens-core.server"
diff --git a/src/features/tournaments/components/podium.tsx b/src/features/tournaments/components/podium.tsx
index 2e18af7..c7461d5 100644
--- a/src/features/tournaments/components/podium.tsx
+++ b/src/features/tournaments/components/podium.tsx
@@ -14,26 +14,20 @@ type PodiumTier = "first" | "second" | "third";
const tierStyles: Record = {
first: {
- background:
- "light-dark(color-mix(in srgb, var(--mantine-color-yellow-4) 10%, var(--mantine-color-white)), color-mix(in srgb, var(--mantine-color-yellow-8) 16%, var(--mantine-color-dark-6)))",
- border:
- "1px solid light-dark(color-mix(in srgb, var(--mantine-color-yellow-6) 45%, var(--mantine-color-gray-3)), color-mix(in srgb, var(--mantine-color-yellow-6) 35%, var(--mantine-color-dark-4)))",
+ background: "var(--flxn-podium-1-bg)",
+ border: "1px solid var(--flxn-podium-1-border)",
borderRadius: "var(--mantine-radius-md)",
boxShadow: "var(--mantine-shadow-md)",
},
second: {
- background:
- "light-dark(color-mix(in srgb, var(--mantine-color-gray-5) 10%, var(--mantine-color-white)), color-mix(in srgb, var(--mantine-color-gray-5) 10%, var(--mantine-color-dark-6)))",
- border:
- "1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
+ background: "var(--flxn-podium-2-bg)",
+ border: "1px solid var(--flxn-podium-2-border)",
borderRadius: "var(--mantine-radius-md)",
boxShadow: "var(--mantine-shadow-xs)",
},
third: {
- background:
- "light-dark(color-mix(in srgb, var(--mantine-color-orange-5) 8%, var(--mantine-color-white)), color-mix(in srgb, var(--mantine-color-orange-9) 14%, var(--mantine-color-dark-6)))",
- border:
- "1px solid light-dark(color-mix(in srgb, var(--mantine-color-orange-6) 35%, var(--mantine-color-gray-3)), color-mix(in srgb, var(--mantine-color-orange-7) 30%, var(--mantine-color-dark-4)))",
+ background: "var(--flxn-podium-3-bg)",
+ border: "1px solid var(--flxn-podium-3-border)",
borderRadius: "var(--mantine-radius-md)",
boxShadow: "var(--mantine-shadow-xs)",
},
@@ -54,8 +48,7 @@ const tierConfig: Record<
first: {
color: "yellow",
label: msg`Champions`,
- labelColor:
- "light-dark(var(--mantine-color-yellow-8), var(--mantine-color-yellow-4))",
+ labelColor: "var(--flxn-podium-1-label)",
icon: ,
iconSize: "xl",
padding: "md",
@@ -64,8 +57,7 @@ const tierConfig: Record<
second: {
color: "gray",
label: msg`2nd place`,
- labelColor:
- "light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4))",
+ labelColor: "var(--flxn-podium-2-label)",
icon: ,
iconSize: "lg",
padding: "xs",
@@ -74,8 +66,7 @@ const tierConfig: Record<
third: {
color: "orange",
label: msg`3rd place`,
- labelColor:
- "light-dark(var(--mantine-color-orange-8), var(--mantine-color-orange-4))",
+ labelColor: "var(--flxn-podium-3-label)",
icon: ,
iconSize: "lg",
padding: "xs",
diff --git a/src/features/tournaments/components/setup-group-stage.tsx b/src/features/tournaments/components/setup-group-stage.tsx
index 4987d13..5dc8528 100644
--- a/src/features/tournaments/components/setup-group-stage.tsx
+++ b/src/features/tournaments/components/setup-group-stage.tsx
@@ -320,7 +320,7 @@ const SetupGroupStage: React.FC = ({
(
-
-
+
+
diff --git a/src/hooks/use-server-events.ts b/src/hooks/use-server-events.ts
index 32b4a51..75cb8b9 100644
--- a/src/hooks/use-server-events.ts
+++ b/src/hooks/use-server-events.ts
@@ -159,16 +159,29 @@ export function useServerEvents() {
logger.error("SSE connection error", error);
disconnect();
+ // The stream is auth-guarded, so an error is often the server rejecting
+ // an expired session rather than a network blip. Try to refresh; if the
+ // session is genuinely gone, stop retrying so we don't hammer the
+ // endpoint forever — a real re-login (or a wake event) will reconnect.
+ let sessionValid = true;
+ try {
+ const { attemptRefreshingSession } = await import('supertokens-web-js/recipe/session');
+ sessionValid = await attemptRefreshingSession();
+ } catch {
+ // Refresh call itself failed (likely offline) — treat as transient.
+ }
+
+ if (disposed) return;
+
+ if (!sessionValid) {
+ logger.warn("SSE stopped: session is no longer valid");
+ return;
+ }
+
retryCount += 1;
const delay = Math.min(1000 * Math.pow(1.5, retryCount - 1), 15000);
logger.info(`SSE reconnection attempt ${retryCount} in ${Math.round(delay)}ms`);
- try {
- const { attemptRefreshingSession } = await import('supertokens-web-js/recipe/session');
- await attemptRefreshingSession();
- } catch {
- }
-
reconnect(delay);
};
};
diff --git a/src/lib/mantine/base-theme.ts b/src/lib/mantine/base-theme.ts
new file mode 100644
index 0000000..baf4296
--- /dev/null
+++ b/src/lib/mantine/base-theme.ts
@@ -0,0 +1,48 @@
+import { createTheme } from "@mantine/core";
+
+const commonInputStyles = {
+ label: {
+ padding: 5,
+ },
+ root: {
+ margin: "0",
+ },
+};
+
+// Shared by MantineProvider and the theme-picker preview iframes.
+export const baseTheme = createTheme({
+ defaultRadius: "sm",
+ respectReducedMotion: true,
+ fontFamily: '"Inter", sans-serif',
+ headings: { fontFamily: '"League Spartan", sans-serif' },
+ activeClassName: "flxn-press",
+ components: {
+ TextInput: {
+ styles: commonInputStyles,
+ },
+ DateTimePicker: {
+ styles: commonInputStyles,
+ },
+ Input: {
+ styles: commonInputStyles,
+ },
+ Select: {
+ styles: commonInputStyles,
+ },
+ Autocomplete: {
+ styles: commonInputStyles,
+ },
+ Card: {
+ defaultProps: {
+ shadow: "xs",
+ },
+ },
+ Title: {
+ styles: {
+ root: {
+ letterSpacing: "-0.01em",
+ },
+ },
+ },
+ },
+});
diff --git a/src/lib/mantine/color-scheme-provider.tsx b/src/lib/mantine/color-scheme-provider.tsx
index 7f63126..7bc15b8 100644
--- a/src/lib/mantine/color-scheme-provider.tsx
+++ b/src/lib/mantine/color-scheme-provider.tsx
@@ -1,18 +1,40 @@
import { useAuth } from "@/contexts/auth-context";
import { useMantineColorScheme } from "@mantine/core";
-import { useEffect } from "react";
-import { CHROME_COLORS, setThemeColorMeta } from "./theme-colors";
+import { useEffect, useRef } from "react";
+import { getTheme } from "@/themes/registry";
+import { resolveChromeColors } from "@/themes/chrome";
+import { setBaseThemeColor } from "./theme-colors";
const ColorSchemeProvider = ({ children }: { children: React.ReactNode }) => {
const {
- metadata: { colorScheme },
+ metadata: { colorScheme, theme },
} = useAuth();
- const { setColorScheme } = useMantineColorScheme();
+ const { colorScheme: currentScheme, setColorScheme } =
+ useMantineColorScheme();
+
+ // Mantine recreates setColorScheme every render; calling it redundantly
+ // triggers its global transition-suppression flash, so ref it and only
+ // call on an actual value change.
+ const setColorSchemeRef = useRef(setColorScheme);
+ setColorSchemeRef.current = setColorScheme;
+
+ const manifest = getTheme(theme);
+ const forcedScheme =
+ manifest.colorSchemes === "both" ? undefined : manifest.colorSchemes;
useEffect(() => {
+ const chrome = resolveChromeColors(manifest);
+
+ if (forcedScheme) {
+ setBaseThemeColor(chrome[forcedScheme].base);
+ return;
+ }
+
if (!colorScheme) return;
- setColorScheme(colorScheme);
+ if (colorScheme !== currentScheme) {
+ setColorSchemeRef.current(colorScheme);
+ }
const media = window.matchMedia("(prefers-color-scheme: dark)");
const applyThemeColor = () => {
@@ -22,7 +44,7 @@ const ColorSchemeProvider = ({ children }: { children: React.ReactNode }) => {
? "dark"
: "light"
: colorScheme;
- setThemeColorMeta(CHROME_COLORS[resolved].base);
+ setBaseThemeColor(chrome[resolved].base);
};
applyThemeColor();
@@ -31,7 +53,7 @@ const ColorSchemeProvider = ({ children }: { children: React.ReactNode }) => {
media.addEventListener("change", applyThemeColor);
return () => media.removeEventListener("change", applyThemeColor);
}
- }, [colorScheme, setColorScheme]);
+ }, [colorScheme, currentScheme, manifest, forcedScheme]);
return children;
};
diff --git a/src/lib/mantine/mantine-provider.tsx b/src/lib/mantine/mantine-provider.tsx
index 63a3915..7da6df8 100644
--- a/src/lib/mantine/mantine-provider.tsx
+++ b/src/lib/mantine/mantine-provider.tsx
@@ -1,12 +1,16 @@
import { useAuth } from "@/contexts/auth-context";
import {
- createTheme,
+ mergeThemeOverrides,
MantineProvider as MantineProviderCore,
} from "@mantine/core";
+import { baseTheme } from "./base-theme";
import { DatesProvider } from "@mantine/dates";
import { useLingui } from "@lingui/react";
import type { AppLocale } from "@/lib/i18n";
import ColorSchemeProvider from "./color-scheme-provider";
+import ThemeProvider from "@/themes/theme-provider";
+import { DEFAULT_THEME_ID, getTheme } from "@/themes/registry";
+import "@/themes/styles";
import { useState, useEffect, useMemo } from "react";
import "dayjs/locale/es-mx";
import "dayjs/locale/de";
@@ -21,15 +25,6 @@ const DAYJS_LOCALE: Record = {
ja: "ja",
};
-const commonInputStyles = {
- label: {
- padding: 5,
- },
- root: {
- margin: "0",
- },
-};
-
const pressFeedbackCss = `
.flxn-press {
transition: transform 120ms cubic-bezier(0.32, 0.72, 0, 1);
@@ -46,43 +41,6 @@ const pressFeedbackCss = `
}
`;
-const theme = createTheme({
- defaultRadius: "sm",
- respectReducedMotion: true,
- fontFamily: '"Inter", sans-serif',
- headings: { fontFamily: '"League Spartan", sans-serif' },
- activeClassName: "flxn-press",
- components: {
- TextInput: {
- styles: commonInputStyles,
- },
- DateTimePicker: {
- styles: commonInputStyles,
- },
- Input: {
- styles: commonInputStyles,
- },
- Select: {
- styles: commonInputStyles,
- },
- Autocomplete: {
- styles: commonInputStyles,
- },
- Card: {
- defaultProps: {
- shadow: "xs",
- },
- },
- Title: {
- styles: {
- root: {
- letterSpacing: "-0.01em",
- },
- },
- },
- },
-});
-
const MantineProvider = ({ children }: { children: React.ReactNode }) => {
const { metadata } = useAuth();
const { i18n } = useLingui();
@@ -93,17 +51,23 @@ const MantineProvider = ({ children }: { children: React.ReactNode }) => {
}, []);
const colorScheme = isHydrated ? metadata.colorScheme || "auto" : "auto";
+ const manifest = getTheme(isHydrated ? metadata.theme : DEFAULT_THEME_ID);
const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue";
- const themeWithPrimaryColor = useMemo(
- () => ({ ...theme, primaryColor }),
- [primaryColor]
+ const theme = useMemo(
+ () =>
+ mergeThemeOverrides(baseTheme, manifest.mantine ?? {}, { primaryColor }),
+ [manifest, primaryColor]
);
+ const forceColorScheme =
+ manifest.colorSchemes === "both" ? undefined : manifest.colorSchemes;
+
return (