# UI and Theming

Mantine 9.4 SSR setup, theme, providers, AppShell composition, and accessibility conventions.

## PostCSS Configuration

Mantine 9.4 requires two PostCSS plugins:

```js
// postcss.config.cjs:1
module.exports = {
  plugins: {
    "postcss-preset-mantine": {},
    "postcss-simple-vars": {
      variables: {
        "mantine-breakpoint-xs": "36em",
        "mantine-breakpoint-sm": "48em",
        "mantine-breakpoint-md": "62em",
        "mantine-breakpoint-lg": "75em",
        "mantine-breakpoint-xl": "88em",
      },
    },
  },
};
```

## Theme

A single theme object in `src/lib/theme.ts`:

```ts
// src/lib/theme.ts:1
import { createTheme, type MantineColorsTuple } from "@mantine/core";

const indigo: MantineColorsTuple = [
  "#eef2ff", "#e0e7ff", "#c7d2fe", "#a5b4fc", "#818cf8",
  "#6366f1", "#4f46e5", "#4338ca", "#3730a3", "#312e81",
];

export const theme = createTheme({
  primaryColor: "indigo",
  primaryShade: { light: 6, dark: 5 },
  defaultRadius: "md",
  fontFamily:
    "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial",
  headings: {
    fontWeight: "600",
  },
  colors: {
    indigo,
  },
  components: {
    Button: Button.extend({
      defaultProps: { variant: "filled" },
    }),
    Card: Card.extend({
      defaultProps: { withBorder: true, shadow: "sm" },
    }),
  },
});
```

Import in `providers.tsx`. See [ARCHITECTURE.md](ARCHITECTURE.md#providers-tree).

## Notifications

Use `notifications.show(...)` for transient feedback:

```tsx
// src/features/users/components/DeleteUserButton.tsx:1
"use client";

import { Button } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/axios";

export function DeleteUserButton({ userId }: { userId: string }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: () => api.delete(`/api/users/${userId}`),
    onSuccess: () => {
      notifications.show({
        title: "User deleted",
        message: "The user has been removed.",
        color: "green",
      });
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },
    onError: (err) => {
      notifications.show({
        title: "Delete failed",
        message: err instanceof Error ? err.message : "Unknown error",
        color: "red",
      });
    },
  });

  return (
    <Button
      color="red"
      variant="light"
      loading={mutation.isPending}
      onClick={() => mutation.mutate()}
    >
      Delete
    </Button>
  );
}
```

Register `<Notifications position="top-right" />` once at the root — never inside feature trees.

## Modals

Use `modals.openConfirmModal(...)` for confirmations:

```tsx
// src/features/users/components/PromoteUserButton.tsx:1
"use client";

import { Button } from "@mantine/core";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";

export function PromoteUserButton({ userId }: { userId: string }) {
  function handleClick() {
    modals.openConfirmModal({
      title: "Promote to admin?",
      children: "This grants the admin role to the user.",
      labels: { confirm: "Promote", cancel: "Cancel" },
      onConfirm: async () => {
        // call API ...
        notifications.show({ message: "User promoted", color: "green" });
      },
    });
  }

  return <Button onClick={handleClick}>Promote</Button>;
}
```

Register `<ModalsProvider />` at the root.

## AppShell

The dashboard chrome lives in `src/components/AppShell.tsx`:

```tsx
// src/components/AppShell.tsx:1
"use client";

import {
  AppShell as MantineAppShell,
  Burger,
  Group,
  NavLink,
  ScrollArea,
  Title,
  ActionIcon,
  useMantineColorScheme,
  Avatar,
  Menu,
  Text,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
  IconLayoutDashboard,
  IconUsers,
  IconSettings,
  IconSun,
  IconMoon,
  IconLogout,
} from "@tabler/icons-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import type { ReactNode } from "react";

type NavItem = { href: string; label: string; icon: ReactNode };

const NAV: NavItem[] = [
  { href: "/dashboard", label: "Overview", icon: <IconLayoutDashboard size={18} /> },
  { href: "/dashboard/users", label: "Users", icon: <IconUsers size={18} /> },
  { href: "/dashboard/settings", label: "Settings", icon: <IconSettings size={18} /> },
];

export function AppShell({
  user,
  children,
}: {
  user: { name: string; email: string };
  children: ReactNode;
}) {
  const [opened, { toggle }] = useDisclosure();
  const { colorScheme, setColorScheme } = useMantineColorScheme();
  const pathname = usePathname();
  const router = useRouter();

  return (
    <MantineAppShell
      header={{ height: 60 }}
      navbar={{ width: 260, breakpoint: "sm", collapsed: { mobile: !opened } }}
      padding="md"
    >
      <MantineAppShell.Header>
        <Group h="100%" px="md" justify="space-between">
          <Group>
            <Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
            <Title order={4}>Dashboard</Title>
          </Group>
          <Group>
            <ActionIcon
              variant="subtle"
              onClick={() =>
                setColorScheme(colorScheme === "dark" ? "light" : "dark")
              }
              aria-label="Toggle color scheme"
            >
              {colorScheme === "dark" ? <IconSun size={18} /> : <IconMoon size={18} />}
            </ActionIcon>
            <Menu position="bottom-end" withArrow>
              <Menu.Target>
                <Avatar radius="xl" style={{ cursor: "pointer" }}>
                  {user.name.charAt(0)}
                </Avatar>
              </Menu.Target>
              <Menu.Dropdown>
                <Menu.Label>
                  <Text size="sm" fw={600}>{user.name}</Text>
                  <Text size="xs" c="dimmed">{user.email}</Text>
                </Menu.Label>
                <Menu.Divider />
                <Menu.Item
                  leftSection={<IconLogout size={14} />}
                  onClick={() => router.push("/logout")}
                >
                  Sign out
                </Menu.Item>
              </Menu.Dropdown>
            </Menu>
          </Group>
        </Group>
      </MantineAppShell.Header>

      <MantineAppShell.Navbar p="md">
        <ScrollArea>
          {NAV.map((item) => (
            <NavLink
              key={item.href}
              component={Link}
              href={item.href}
              label={item.label}
              leftSection={item.icon}
              active={pathname === item.href}
            />
          ))}
        </ScrollArea>
      </MantineAppShell.Navbar>

      <MantineAppShell.Main>{children}</MantineAppShell.Main>
    </MantineAppShell>
  );
}
```

## Color Scheme

- Default `auto` — respects OS preference.
- Persisted in `localStorage` automatically by Mantine.
- Toggle via `useMantineColorScheme()`. Never read `window.matchMedia` directly.

## Accessibility Conventions

- Every `TextInput`, `Select`, etc. takes `label` (string, not JSX) — Mantine wires `htmlFor` automatically.
- Use `description` for hint text, `error` for validation messages. Mantine wires `aria-describedby` automatically.
- Icon-only buttons always get `aria-label`.
- Modals trap focus automatically; provide `title` for screen readers.
- Tables: provide `caption` for screen readers, use `scope="col"` on header cells.
- Color contrast: rely on Mantine defaults; if customizing, verify with WCAG AA checker.

## Icons

Use `@tabler/icons-react`. Pick the icon that semantically matches the action, not the visual approximation.

```tsx
import { IconPlus } from "@tabler/icons-react";

<Button leftSection={<IconPlus size={16} />}>Create</Button>
```

Never mix icon libraries inside one feature.

## Loading and Error States

| State | Pattern |
|-------|---------|
| Initial load | `loading.tsx` with `<Center><Loader /></Center>` |
| Fetching in client | `<Skeleton height={40} />` matching content shape |
| Empty | Empty-state component with icon + copy + CTA |
| Error | `error.tsx` with reset button; feature errors via notifications |