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
classNamesoup. The component library is Mantine v9; the theme is a plain object; the tokens become CSS custom properties on:rootthat 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 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):
| Token | Field | Kinto value |
|---|---|---|
| Accent | primaryColor | "gold" (a custom 10-shade tuple) |
| Accent shade | primaryShade | { light: 6, dark: 5 } — slightly lighter gold in dark |
| Body font | fontFamily | "DM Sans Variable", then a system-font fallback stack |
| Heading font | headings.fontFamily | "DM Serif Display" (the elegant serif display face) |
| Heading weight | headings.fontWeight | "400" — DM Serif Display ships one weight; size carries it |
| Mono font | fontFamilyMonospace | "JetBrains Mono Variable" |
| Corners | defaultRadius + radius | md, with a softer scale (md = 0.75rem) |
| Palettes | colors | { gold, dark } — custom gold + warm near-black neutrals (ink) |
| Elevation | shadows | diffuse, 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;primaryShadepicks 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 inoklch(...)for perceptually-even steps. colors.darkis special. Overriding thedarktuple replaces Mantine's built-in cool-gray neutrals — which drive every surface, border and muted text in both schemes. PulsN supplies a warminktuple 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.fontFamilyis separate fromfontFamily, so the serif display face applies to<Title>/h1–h6while 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:
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 instyles.css— e.g.body { background: cream }or even redefining--mantine-color-bodyin 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
cssVariablesResolveris 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):
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:
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):
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). ChangeprimaryColor, swap a colour tuple, adjustradius/shadows/headings.fontFamily. Every component reflows; you touch no components. - A new app, different look → pass your own
createTheme(...)toFusionProvider'sthemeprop. Omit it entirely and you get the neutralfusionThemedefault. - Page background / any
--mantine-*override → do it in acssVariablesResolver, notstyles.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-uias 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.