Skip to content
Fusion

Theming — how the look is built (Mantine)

The look is all theme, no restyling. Every UI component lives in fusion-ui, is props-only and theme-agnostic, and reads its colours, fonts, radii and shadows from a Mantine theme. PulsN's "Kinto" look — gold accent, serif display headings, a warm cream page — is one object passed in at the root. Change the look by changing that object; you never touch a component.

Coming from React? This is a design-system theme provider + CSS variables: one source of truth for tokens, consumed everywhere. There is no Tailwind, no shadcn, no CSS-in-JS per component, no className soup. The component library is Mantine v9; the theme is a plain object; the tokens become CSS custom properties on :root that every component reads.

The stack: Mantine v9, wrapped once

fusion-ui is built on Mantine v9 (@mantine/core). The library owns that dependency and its stylesheet — src/mantine.ts re-exports @mantine/core, and consumers import primitives from @tikab-interactive/fusion-ui/mantine, never @mantine/core directly, so all UI flows through fusion-ui.

At the root, FusionProvider (fusion-ui/src/FusionProvider.tsx) wraps Mantine's MantineProvider. It imports @mantine/core/styles.css itself (so the app never has to), applies a theme, and forwards every other MantineProvider prop:

fusion-ui/src/FusionProvider.tsx
// fusion-ui owns the UI dependencies — including their styles. A consumer renders
// <FusionProvider> and gets Mantine's stylesheet without importing "@mantine/core/styles.css".
import "@mantine/core/styles.css";
 
import { MantineProvider } from "@mantine/core";
import type { ComponentProps, ReactNode } from "react";
 
import { fusionTheme } from "./theme";
 
type MantineProviderProps = ComponentProps<typeof MantineProvider>;
 
export type FusionProviderProps = Omit<MantineProviderProps, "theme" | "children"> & {
	/** Override or replace the default {@link fusionTheme}. */
	theme?: MantineProviderProps["theme"];
	children?: ReactNode;
};
 
/**
 * Root provider for fusion-ui. Wraps Mantine's `MantineProvider` with the
 * {@link fusionTheme}. Forwards every other `MantineProvider` prop
 * (`defaultColorScheme`, `forceColorScheme`, …).
 *
 * fusion-ui owns the Mantine stylesheet (imported here), so the consuming app only
 * needs to render `<ColorSchemeScript />` (re-exported from `fusion-ui/mantine`) in
 * the document `<head>` to avoid a color-scheme flash on first paint (SSR).
 */
export function FusionProvider({ theme, children, ...props }: FusionProviderProps) {
	return (
		<MantineProvider theme={theme ?? fusionTheme} {...props}>
			{children}
		</MantineProvider>
	);
}

fusionTheme (fusion-ui/src/theme.ts) is a deliberately neutral default — indigo primary, md radius — so other apps get sane defaults out of the box. PulsN overrides it.

The theme object

A Mantine theme is a plain object built with createTheme(...). It is the single source of truth for the design tokens. The shape PulsN uses (example/src/theme.ts, appTheme):

TokenFieldKinto value
AccentprimaryColor"gold" (a custom 10-shade tuple)
Accent shadeprimaryShade{ light: 6, dark: 5 } — slightly lighter gold in dark
Body fontfontFamily"DM Sans Variable", then a system-font fallback stack
Heading fontheadings.fontFamily"DM Serif Display" (the elegant serif display face)
Heading weightheadings.fontWeight"400" — DM Serif Display ships one weight; size carries it
Mono fontfontFamilyMonospace"JetBrains Mono Variable"
CornersdefaultRadius + radiusmd, with a softer scale (md = 0.75rem)
Palettescolors{ gold, dark } — custom gold + warm near-black neutrals (ink)
Elevationshadowsdiffuse, warm-tinted (rgba(64, 52, 28, …))

A couple of points worth understanding, because they're the kind of thing that bites you:

  • Colours are 10-shade tuples (MantineColorsTuple), index 0 (lightest) → 9 (darkest). primaryColor: "gold" only names which palette is the accent; primaryShade picks which index is the filled default — and it's scheme-aware, so dark mode can use a lighter shade for contrast. PulsN authors the gold in oklch(...) for perceptually-even steps.
  • colors.dark is special. Overriding the dark tuple replaces Mantine's built-in cool-gray neutrals — which drive every surface, border and muted text in both schemes. PulsN supplies a warm ink tuple there so dark mode reads warm-black, not blue-gray. That one override is why the whole app feels warm, not just the accent.
  • Headings get their own font. headings.fontFamily is separate from fontFamily, so the serif display face applies to <Title> / h1–h6 while body copy stays in DM Sans.

The fonts are self-hosted (@fontsource*), imported in __root.tsx — no CDN, so it stays air-gap safe (see deploy).

How a component consumes the theme

Components never hardcode colours. They take Mantine props (c, bg, radius, variant) or read CSS variables Mantine generated from the theme. From fusion-ui/src/elements/AskPulsNCard.tsx:

fusion-ui/src/elements/AskPulsNCard.tsx
import { Button, Group, Stack, Text, UnstyledButton } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
 
export type AskPulsNCardLabels = {
	/** The hint shown inside the launcher row, e.g. "Ask anything about your projects". */
	hint: string;
	/** Optional accessible name / heading for the launcher. */
	title?: string;
};
 
export type AskPulsNCardProps = {
	/** Open the conversation. Called with a suggested prompt when a chip is picked. */
	onOpen: (prompt?: string) => void;
	/** Suggested prompts rendered as chips below the launcher. */
	chips?: string[];
	/** Localised labels — fusion-ui stays props-only, so the consumer supplies the i18n. */
	labels: AskPulsNCardLabels;
};
 
/**
 * The "Ask" launcher — a soft, full-width row (sparkles + hint) plus optional
 * suggested-prompt chips. Presentational only: the consumer owns what opening means
 * (it wires the actual conversation panel) and supplies the strings. Clicking the
 * row calls `onOpen()`; clicking a chip calls `onOpen(chip)`. The accent follows the
 * theme primary; colours are theme-agnostic.
 */
export function AskPulsNCard({ onOpen, chips, labels }: AskPulsNCardProps) {
	return (
		<Stack gap="sm">
			<UnstyledButton
				onClick={() => onOpen()}
				aria-label={labels.title ?? labels.hint}
				style={{
					display: "block",
					width: "100%",
					textAlign: "left",
					padding: "0.5rem 0.625rem",
					borderRadius: "var(--mantine-radius-md)",
				}}
			>
				<Group gap="sm" wrap="nowrap">
					<IconSparkles size={18} color="var(--mantine-primary-color-filled)" />
					<Text c="dimmed">{labels.hint}</Text>
				</Group>
			</UnstyledButton>
			{chips && chips.length > 0 ? (
				<Group gap="xs">
					{chips.map((chip) => (
						<Button key={chip} variant="default" radius="xl" size="xs" onClick={() => onOpen(chip)}>
							{chip}
						</Button>
					))}
				</Group>
			) : null}
		</Stack>
	);
}

--mantine-primary-color-filled, c="dimmed", radius="xl" — every one of those resolves through the theme. Swap primaryColor to blue and that sparkle turns blue everywhere, with no component edit. This is what "fusion-ui owns ALL UI" means in practice: the library is presentational and theme-agnostic; the app supplies tokens (a theme) and strings (see i18n), and wires data — it never restyles a component.

CSS variables — and the page-background gotcha

Mantine generates CSS custom properties from the theme at render and writes them onto :root, split into per-scheme blocks ([data-mantine-color-scheme="light"] / "dark"). --mantine-color-gold-6, --mantine-radius-md, --mantine-color-body, … all come from your theme object. Components read those variables; that's the whole pipeline.

The gotcha: a static CSS background loses to Mantine. The warm cream page colour lives at --mantine-color-body. Mantine writes a value for that variable into its own per-scheme blocks on every render. If you try to set the page background in styles.css — e.g. body { background: cream } or even redefining --mantine-color-body in your own stylesheet — Mantine's generated block can win the cascade and you get white in light, dark-7 in dark, intermittently, depending on source order. You'll burn an afternoon on it.

The fix is to inject the value through Mantine, so it lands inside the same generated per-scheme blocks. That is exactly what cssVariablesResolver is for.

cssVariablesResolver is a MantineProvider prop: a function returning variables Mantine merges into the ones it emits — keyed by variables (both schemes), light, and dark. PulsN's (example/src/theme.ts, appCssVariablesResolver):

example/src/theme.ts
export const appCssVariablesResolver: CSSVariablesResolver = () => ({
	variables: {
		// Carola's prose reads in a calm, READABLE body serif — Georgia (a screen-reading serif at
		// regular weight), NOT the DM Serif DISPLAY face used for the greeting headline. The display
		// serif's high stroke-contrast reads heavy/bold at body size; this keeps her turns serif + light.
		"--fusion-reading-serif": 'Georgia, "Times New Roman", serif',
	},
	light: { "--mantine-color-body": "oklch(0.963 0.006 90)" },
	dark: { "--mantine-color-body": "oklch(0.178 0.008 75)" },
});

Because the resolver runs inside Mantine's variable generation, its --mantine-color-body is part of the authoritative per-scheme block and wins — reliably, in both schemes. styles.css then only adds things that genuinely belong in CSS and can't fight Mantine: a soft radial glow on .mantine-AppShell-main, the iOS ≥16px input fix, safe- area padding (see mobile conventions). Its own header comment points back here: the body background is set by the resolver, not by CSS.

It's wired in at the root alongside the theme:

example/src/routes/__root.tsx
function RootDocument({ children }: { children: React.ReactNode }) {
	return (
		<html lang={getLocale()} {...mantineHtmlProps}>
			<head>
				<ColorSchemeScript />
				<HeadContent />
			</head>
			<body>
				<FusionProvider theme={appTheme} cssVariablesResolver={appCssVariablesResolver}>
					{children}
				</FusionProvider>
				<HydrationMarker />
				<Scripts />
			</body>
		</html>
	);
}

Light / dark

Colour scheme is Mantine's, end to end. To avoid a first-paint flash on SSR, __root.tsx renders <ColorSchemeScript /> in <head> (re-exported from fusion-ui/mantine) and spreads mantineHtmlProps on <html> — the script sets the scheme attribute before React hydrates, so there's no flicker. Mantine stores the choice and applies it via the data-mantine-color-scheme attribute the per-scheme variable blocks key off.

Anything can flip it through the useMantineColorScheme() hook. fusion-ui ships a ColorSchemeToggle (an ActionIcon calling setColorScheme). And Carola can flip it herself: the chat registers a client tool, set_color_scheme, in CarolaRail.tsx / CarolaInline.tsx (see the agent docs):

example/src/components/CarolaRail.tsx
			toolDefinition({
				name: "set_color_scheme",
				description: "Switch the app's color scheme. Use 'dark' or 'light'.",
				inputSchema: z.object({ scheme: z.enum(["light", "dark"]) }),
			}).client(({ scheme }) => {
				setColorScheme(scheme);
				return { applied: scheme };
			}),

It's a client tool: the model decides, but the effect runs in the browser via the same setColorScheme everything else uses. Ask Carola to go dark and the page does — because the theme already drives both schemes, nothing else has to change.

Extending / customizing the look

  • Re-skin the whole app → edit the theme object (example/src/theme.ts). Change primaryColor, swap a colour tuple, adjust radius / shadows / headings.fontFamily. Every component reflows; you touch no components.
  • A new app, different look → pass your own createTheme(...) to FusionProvider's theme prop. Omit it entirely and you get the neutral fusionTheme default.
  • Page background / any --mantine-* override → do it in a cssVariablesResolver, not styles.css. That's the only place an override reliably beats Mantine's generated variables.
  • Genuinely-static CSS (a decorative gradient on an AppShell region, a media-query hack) → styles.css, scoped to a Mantine class. It loads after Mantine's stylesheet, so it wins overlaps — but it must never try to redefine a --mantine-* token; use the resolver for that.
  • A new reusable component → build it in fusion-ui as props-only and theme-agnostic (Mantine props + var(--mantine-*), no hardcoded colours), with a Storybook story. Keep app-specific styling out of it — the app passes a theme, not overrides.