Skip to content

Migrate web UI from Mantine to shadcn/ui - #288

Open
dominiek wants to merge 3 commits into
masterfrom
shadcn-ui
Open

Migrate web UI from Mantine to shadcn/ui#288
dominiek wants to merge 3 commits into
masterfrom
shadcn-ui

Conversation

@dominiek

@dominiek dominiek commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator
Screenshot 2026-06-07 at 11 56 50 AM Screenshot 2026-06-07 at 11 56 40 AM ![Uploading Screenshot 2026-06-07 at 11.56.40 AM.png…]()

Summary

Replaces Mantine entirely with shadcn/ui (Radix UI + Tailwind CSS v4) across services/web. The UI now uses the neutral shadcn default palette in light and dark, with the brand colour exposed as a single token, and ships the Geist font (bundled, so it's stable across platforms).

$ grep -r "@mantine" src        # → nothing
CSS bundle: ~237 kB → ~49 kB

What changed

Foundation

  • Tailwind v4 via @tailwindcss/vite, components.json, cn() helper, and design tokens in src/styles/globals.css.
  • ThemeProvider (light/dark via a .dark class) + sonner toasts replacing Mantine Notifications.
  • Geist Sans + Mono via @fontsource-variable/geist.
  • ~28 shadcn/ui primitives under src/components/ui/ (button, input, form, select, dialog, sheet, dropdown-menu, table, card, tabs, command, popover, etc.).

Screens & components (Mantine → shadcn)

  • App shell: AppShell → tailored shadcn sidebar (+ mobile drawer, breadcrumb/title region).
  • All auth flows (login/signup/forgot/reset/confirm-code with InputOTP, federated buttons).
  • All CRUD screens (Shops, Products, Users, Organizations, Templates, Applications, Invites): list (shared Search framework → shadcn Table/filters/pagination), detail (Card + definition lists), and forms (react-hook-form + zod + shadcn Form).
  • Row actions (Button + size-matched DropdownMenu), the 3 modals, the form-fields, Settings, and the entire API Docs app.

Teardown

  • Removed @mantine/*, postcss-preset-mantine, postcss-simple-vars; deleted theme.js, theme.less, postcss.config.js.
  • Dropped MantineProvider / <Notifications/> from Wrapper.js; re-enabled Tailwind preflight + shadcn base layer.

Theming

Documented in services/web/README.md:

  • Brand colour — change --primary / --primary-foreground in :root and .dark (one knob drives primary buttons, the active tab indicator, focus rings).
  • Font — swap --font-sans / --font-mono + the @fontsource imports in Wrapper.js.

Notes for reviewers

  • Uploaded images render a graceful placeholder when the raw file is absent (the dev fixtures don't ship upload files).
  • react-icons (Phosphor) icons are retained alongside lucide-react.

Verification

  • yarn build ✓ · yarn lint src clean across the tree.
  • Manually verified in Chrome (light & dark): lists, detail/form screens, the sidebar, nested-nav active state, and dark-mode contrast.

🤖 Generated with Claude Code

dominiek and others added 2 commits June 5, 2026 16:02
Replace Mantine entirely with shadcn/ui (Radix + Tailwind v4) across
services/web, and add a Chrome-based visual + conformance + layout-audit
CI gate. ~100 screen/component files converted; Mantine, postcss-preset-
mantine and the custom theme are removed and Tailwind preflight re-enabled.

- Foundation: Tailwind v4 + @tailwindcss/vite, components.json, cn(),
  globals.css design tokens (literal shadcn-neutral default with a
  documented brand-primary knob), ThemeProvider (dark mode) + sonner.
- ~28 shadcn/ui primitives under src/components/ui.
- All chrome (AppShell -> Sidebar), auth flows, CRUD list/detail/form
  screens, settings, the shared Search framework, modals, form-fields,
  and the API Docs app migrated.
- Removed @mantine/*, postcss-preset-mantine, postcss-simple-vars,
  theme.js/theme.less, postcss.config.js; MantineProvider/Notifications
  dropped from Wrapper; preflight re-enabled (single @import "tailwindcss").
- Visual gate (visual/, .github/workflows/visual.yml): Playwright +
  Chromium screenshots, §5 conformance checks, and a layout audit
  (font consistency, breadcrumb alignment, list-marker, broken-image,
  dark-mode text-colour). CSS bundle ~237kB -> ~49kB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Ship Geist (Geist Sans + Mono) via @fontsource-variable/geist so the UI font
  is stable across platforms; point --font-sans/--font-mono at it.
- Fix nested sidebar active state: a parent (e.g. "Users") no longer highlights
  when a child item (e.g. "Invites") is the actual match.
- Add a Theming section to the web README (brand --primary knob, font swap,
  dark mode, adding components) and remove stale Mantine references.
- Remove the Chrome visual-regression harness and workflow that were only used
  to verify this migration (services/web/visual, .github/workflows/visual.yml,
  @playwright/test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dominiek
dominiek requested review from andrewplummer and kaareal and removed request for andrewplummer June 7, 2026 16:04

import { cn } from '@/lib/utils';

// Semantic variants only — colour carries meaning, never decoration (§5.6).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are these rules are they documented?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — §5.6 was a stale pointer to the migration plan doc (which isn't in the repo). Reworded it to be self-documenting: each variant maps to a semantic token (destructive / success / info / warning) so colour conveys state rather than decoration. (ea0b23c)

Comment thread services/web/src/utils/notify.js Outdated
* Notification shim backed by sonner, with a Mantine-compatible signature so
* migrated call sites can swap `notifications.show(...)` / `showNotification(...)`
* for `notify(...)` with minimal churn. `<Notifications/>` (Mantine) stays
* mounted until every call site is migrated (plan §6.1, teardown in §6).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe the comments needs some cleanup

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleaned up in ea0b23c — dropped the migration-plan references and the (now-wrong) line about Mantine <Notifications/> staying mounted; Mantine is fully gone. The comment now just describes the sonner wrapper and its { title, message, color } shape.

Comment on lines +3 to +14
/**
* Local replacement for the Mantine useDisclosure.
* Returns [opened, { open, close, toggle }].
*/
export function useDisclosure(initial = false) {
const [opened, setOpened] = useState(initial);
const open = useCallback(() => setOpened(true), []);
const close = useCallback(() => setOpened(false), []);
const toggle = useCallback(() => setOpened((o) => !o), []);
return [opened, { open, close, toggle }];
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

possible bs
i suspect we could kill it, but no biggie.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's actually still used in Dashboard.js and Portal.js (sidebar/drawer open state), so I kept the hook but removed the Mantine-migration framing from the doc comment (ea0b23c). Happy to inline the useState in both call sites instead if you'd rather not keep the helper.

Comment on lines +49 to +56
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<ErrorMessage error={error} />

<Textarea
rows="5"
label="Emails"
value={input}
onChange={(evt) => {
setInput(evt.target.value);
}}
onBlur={onEmailsBlur}
placeholder="Enter email addresses separated by comma or new line."
/>
<div className="flex flex-col gap-2">
<Label htmlFor="emails">Emails</Label>
<Textarea
id="emails"
rows="5"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we should expand zod to here too ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — migrated this form to react-hook-form + zod in ea0b23c. The emails textarea is parsed (comma/newline separated) and each address is validated, plus a role enum. This also tightens the old parsing, which only split on ,\s+ and did no validation.

Comment on lines +31 to +40
const schema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
isFeatured: z.boolean().optional(),
priceUsd: z.union([z.number(), z.string()]).nullable().optional(),
expiresAt: z.any().nullable().optional(),
sellingPoints: z.array(z.string()).optional(),
images: z.array(z.any()).optional(),
shop: z.any().nullable().optional(),
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good good, so now we are doing client-side validation. Wanted this for a long time.

Maybe in the future we can share a sdk for the api. So we ensure that things are aligned and typed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — a shared, typed SDK generated from the API would be the natural next step to keep client and server validation aligned. Out of scope for this PR but a good follow-up.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Client side validation is something I thought we had agreed against, and for good reason. I've been avoiding it all this time :)

Maybe AI makes this feasible now though. I don't find it to be such a good addition in any case.

- Invites form: migrate from useFields/manual state to react-hook-form +
  zod with client-side validation (per-address email validation + role
  enum); drop the unused `name` prop at the call site.
- alert.jsx / notify.js / useDisclosure.js: replace migration-plan
  references and Mantine-era framing with self-documenting comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants