diff --git a/services/web/README.md b/services/web/README.md index 897e20924..7996bc0eb 100644 --- a/services/web/README.md +++ b/services/web/README.md @@ -80,8 +80,10 @@ object `window.__env_conf`. ## Frameworks Used -- [Mantine](https://mantine.dev/) -- [React Icons](https://react-icons.github.io/react-icons/) +- [shadcn/ui](https://ui.shadcn.com/) — UI components (owned source, copied into `src/components/ui`) +- [Radix UI](https://www.radix-ui.com/) — accessible primitives behind shadcn/ui +- [Tailwind CSS v4](https://tailwindcss.com/) — styling +- [Lucide](https://lucide.dev/) / [React Icons](https://react-icons.github.io/react-icons/) - [Vite](https://vite.dev/) - [React](https://facebook.github.io/react/) - [Bedrock Router](https://github.com/bedrockio/router) @@ -92,7 +94,7 @@ object `window.__env_conf`. ## Other Features - Full login/signup flow with separate dashboard and homepage router -- Uses Mantine as a rich UI element base +- Uses shadcn/ui (Radix UI + Tailwind CSS v4) as the UI component base - Uses ES6 style React components - Code hotswapping - Static server @@ -129,6 +131,44 @@ Markdown: callParams({ method: 'POST', path: '/1/auth/login' }); ``` -## Updating Styles +## Theming -see [Mantine's Theming](https://mantine.dev/theming/theme-object) +The UI is built on [shadcn/ui](https://ui.shadcn.com/) + Tailwind CSS v4. All +design tokens live in **`src/styles/globals.css`** as CSS variables, defaulting +to the neutral shadcn palette in both light (`:root`) and dark (`.dark`). + +### Brand colour + +The primary colour is a single knob. To brand the app, change `--primary` and +`--primary-foreground` in **both** `:root` and `.dark` (they are tagged +`BRAND PRIMARY` inline). This drives every primary button, the active tab +indicator and (optionally) the focus ring — nothing else needs to change. + +```css +/* src/styles/globals.css — example: a green brand */ +:root { --primary: oklch(0.55 0.12 152); --primary-foreground: oklch(0.985 0 0); } +.dark { --primary: oklch(0.62 0.13 152); --primary-foreground: oklch(0.205 0 0); } +``` + +### Font + +The app ships [Geist](https://vercel.com/font) (bundled via +`@fontsource-variable/geist`, so it's stable across platforms). It's wired in +two places — swap both to use a different font: + +- the imports in `src/Wrapper.js` (`@fontsource-variable/geist*`) +- the `--font-sans` / `--font-mono` tokens in `src/styles/globals.css` + +### Dark mode + +`ThemeProvider` (`src/components/ThemeProvider.jsx`) toggles a `.dark` class on +``; the footer switch flips it. Defaults to light. + +### Adding components + +shadcn components live in `src/components/ui`. Add more with the CLI (config in +`components.json`): + +```bash +npx shadcn@latest add +``` diff --git a/services/web/components.json b/services/web/components.json new file mode 100644 index 000000000..dd4cae458 --- /dev/null +++ b/services/web/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": false, + "tailwind": { + "config": "", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "ui": "@/components/ui", + "lib": "@/lib", + "utils": "@/lib/utils", + "hooks": "@/hooks" + } +} diff --git a/services/web/jsconfig.json b/services/web/jsconfig.json index f0482d3f7..6e3900088 100644 --- a/services/web/jsconfig.json +++ b/services/web/jsconfig.json @@ -8,7 +8,11 @@ "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, + "baseUrl": ".", "paths": { + "@/*": [ + "./src/*" + ], "helpers/*": [ "./src/helpers/*" ], diff --git a/services/web/package.json b/services/web/package.json index f4823d34b..3fed91f70 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -20,39 +20,59 @@ "@bedrockio/config": "^3.0.0", "@bedrockio/logger": "^1.1.5", "@bedrockio/router": "^0.3.2", - "@mantine/core": "^8.3.10", - "@mantine/dates": "^8.3.10", - "@mantine/form": "^8.3.10", - "@mantine/hooks": "^8.3.10", - "@mantine/notifications": "^8.3.10", + "@fontsource-variable/geist": "^5.2.9", + "@fontsource-variable/geist-mono": "^5.2.8", + "@hookform/resolvers": "^5.4.0", "@mdx-js/rollup": "^3.1.0", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@sentry/browser": "^9.15.0", "@simplewebauthn/browser": "^13.1.0", + "@tailwindcss/vite": "^4.3.0", "@uiw/react-json-view": "^2.0.0-alpha.37", "@vitejs/plugin-react": "^4.4.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", "dayjs": "^1.11.13", "hast-util-sanitize": "^4.0.0", "hast-util-to-mdast": "^8.4.1", + "input-otp": "^1.4.2", "less": "^4.3.0", "lodash-es": "^4.17.21", + "lucide-react": "^1.17.0", "postcss": "^8.5.3", - "postcss-preset-mantine": "^1.17.0", - "postcss-simple-vars": "^7.0.1", "prism-react-renderer": "^2.4.1", "prop-types": "^15.8.1", "qr.js": "^0.0.0", "react": "^19.1.1", "react-day-picker": "^9.5.0", "react-dom": "^19.1.1", - "react-dropzone": "^14.3.8", + "react-dropzone": "^15.0.0", "react-helmet-async": "^1.3.0", + "react-hook-form": "^7.77.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "rehype-autolink-headings": "^7.1.0", "rehype-slug": "^6.0.0", "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.0", + "tw-animate-css": "^1.4.0", "vite": "^6.3.5", - "vitest": "^3.1.3" + "vitest": "^3.1.3", + "zod": "^4.4.3" }, "devDependencies": { "@bedrockio/eslint-plugin": "^1.5.3", diff --git a/services/web/postcss.config.js b/services/web/postcss.config.js deleted file mode 100644 index 0d6062caa..000000000 --- a/services/web/postcss.config.js +++ /dev/null @@ -1,14 +0,0 @@ -export default { - 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', - }, - }, - }, -}; diff --git a/services/web/src/Wrapper.js b/services/web/src/Wrapper.js index 5998617aa..a61c1d49e 100644 --- a/services/web/src/Wrapper.js +++ b/services/web/src/Wrapper.js @@ -1,9 +1,4 @@ -// TODO: remove? - -// part of integration with mantine import { BrowserRouter, Route, Routes } from '@bedrockio/router'; -import { MantineProvider } from '@mantine/core'; -import { Notifications } from '@mantine/notifications'; import dayjs from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import React, { Suspense } from 'react'; @@ -12,13 +7,18 @@ import { HelmetProvider } from 'react-helmet-async'; import SessionSwitch from 'helpers/SessionSwitch'; import { SessionProvider, useSession } from 'stores/session'; +import { ThemeProvider } from '@/components/ThemeProvider'; +import { Toaster } from '@/components/ui/sonner'; + import LoadingScreen from 'screens/Loading'; import Unsubscribe from 'screens/Unsubscribe'; import 'utils/sentry'; import { hasAccess } from 'utils/user'; -import { theme } from './theme'; +import '@fontsource-variable/geist'; +import '@fontsource-variable/geist-mono'; +import './styles/globals.css'; dayjs.extend(customParseFormat); @@ -41,8 +41,8 @@ function AppSwitch() { export default function Wrapper() { return ( - - + + @@ -57,7 +57,7 @@ export default function Wrapper() { - + ); } diff --git a/services/web/src/components/Auth/Apple/DisableButton.js b/services/web/src/components/Auth/Apple/DisableButton.js index c06c5e232..f6d757804 100644 --- a/services/web/src/components/Auth/Apple/DisableButton.js +++ b/services/web/src/components/Auth/Apple/DisableButton.js @@ -1,10 +1,12 @@ -import { Button } from '@mantine/core'; import { noop } from 'lodash'; import PropTypes from 'prop-types'; import { useState } from 'react'; import { useSession } from 'stores/session'; +import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; + import { disable } from 'utils/auth/apple'; /** @@ -15,7 +17,7 @@ import { disable } from 'utils/auth/apple'; * @returns {JSX.Element} The DisableButton component. */ export default function DisableButton(props) { - const { onError = noop, onDisabled = noop } = props; + const { onError = noop, onDisabled = noop, ...rest } = props; const [loading, setLoading] = useState(false); const { user, updateUser } = useSession(); @@ -34,11 +36,12 @@ export default function DisableButton(props) { return ( ); diff --git a/services/web/src/components/Auth/Apple/SignInButton.js b/services/web/src/components/Auth/Apple/SignInButton.js index 68d9de827..ccb13a599 100644 --- a/services/web/src/components/Auth/Apple/SignInButton.js +++ b/services/web/src/components/Auth/Apple/SignInButton.js @@ -1,9 +1,10 @@ import { useNavigate } from '@bedrockio/router'; -import { ActionIcon, Button } from '@mantine/core'; import { FaApple } from 'react-icons/fa'; import { useSession } from 'stores/session'; +import { Button } from '@/components/ui/button'; + import { signInWithApple } from 'utils/auth/apple'; export default function AppleSignInButton({ @@ -34,19 +35,20 @@ export default function AppleSignInButton({ if (type === 'login') { return ( - - + ); } return ( - ); diff --git a/services/web/src/components/Auth/Federated.js b/services/web/src/components/Auth/Federated.js index 3f6fbca5f..f32c74d5a 100644 --- a/services/web/src/components/Auth/Federated.js +++ b/services/web/src/components/Auth/Federated.js @@ -1,5 +1,3 @@ -import { Divider, Group, Stack } from '@mantine/core'; - import { canShowAppleSignin } from 'utils/auth/apple'; import { canShowGoogleSignin } from 'utils/auth/google'; import { canShowPasskey } from 'utils/auth/passkey'; @@ -21,16 +19,23 @@ export default function Federated(props) { return null; } - const Wrapper = type === 'signup' ? Stack : Group; - return ( <> - - +
+
+ OR +
+
+
*]:flex-1 [&>*]:justify-center' + }> {showPasskey && } {showGoogle && } {showApple && } - +
); } diff --git a/services/web/src/components/Auth/Google/DisableButton.js b/services/web/src/components/Auth/Google/DisableButton.js index 4e0224442..979919efa 100644 --- a/services/web/src/components/Auth/Google/DisableButton.js +++ b/services/web/src/components/Auth/Google/DisableButton.js @@ -1,10 +1,12 @@ -import { Button } from '@mantine/core'; import { noop } from 'lodash'; import PropTypes from 'prop-types'; import { useState } from 'react'; import { useSession } from 'stores/session'; +import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; + import { disable } from 'utils/auth/google'; /** @@ -12,7 +14,7 @@ import { disable } from 'utils/auth/google'; * @param {object} props - Component props. * @param {Function} [props.onError=noop] - Callback function for errors. * @param {Function} [props.onDisabled=noop] - Callback function after successful disabling. - * @param {object} rest - Remaining props to pass to the Mantine Button. + * @param {object} rest - Remaining props to pass to the Button. * @returns {JSX.Element} */ export default function DisableButton({ @@ -42,12 +44,13 @@ export default function DisableButton({ return ( ); diff --git a/services/web/src/components/Auth/Google/SignInButton.js b/services/web/src/components/Auth/Google/SignInButton.js index 3217cce2b..4b97b944e 100644 --- a/services/web/src/components/Auth/Google/SignInButton.js +++ b/services/web/src/components/Auth/Google/SignInButton.js @@ -1,10 +1,11 @@ import { useNavigate } from '@bedrockio/router'; -import { ActionIcon, Button } from '@mantine/core'; import { useSession } from 'stores/session'; import logo from 'assets/google-logo.svg'; +import { Button } from '@/components/ui/button'; + import { signInWithGoogle } from 'utils/auth/google'; export default function GoogleSignInButton({ @@ -35,22 +36,20 @@ export default function GoogleSignInButton({ if (type === 'login') { return ( - Google - + ); } return ( - ); diff --git a/services/web/src/components/Auth/OptionalPassword.js b/services/web/src/components/Auth/OptionalPassword.js index a54ddc79d..a54a5ff6a 100644 --- a/services/web/src/components/Auth/OptionalPassword.js +++ b/services/web/src/components/Auth/OptionalPassword.js @@ -1,4 +1,4 @@ -import { PasswordInput } from '@mantine/core'; +import { PasswordInput } from '@/components/ui/password-input'; import { AUTH_TYPE } from 'utils/env'; diff --git a/services/web/src/components/Auth/PasskeyButton.js b/services/web/src/components/Auth/PasskeyButton.js index 33b73d802..48c4de693 100644 --- a/services/web/src/components/Auth/PasskeyButton.js +++ b/services/web/src/components/Auth/PasskeyButton.js @@ -1,10 +1,11 @@ import { useNavigate } from '@bedrockio/router'; -import { ActionIcon } from '@mantine/core'; import { noop } from 'lodash'; import { PiFingerprintBold } from 'react-icons/pi'; import { useSession } from 'stores/session'; +import { Button } from '@/components/ui/button'; + import { login } from 'utils/auth/passkey'; export default function PasskeyButton(props) { @@ -31,13 +32,13 @@ export default function PasskeyButton(props) { } return ( - - + ); } diff --git a/services/web/src/components/Authenticator.js b/services/web/src/components/Authenticator.js index 805ef29ff..1cc33b45b 100644 --- a/services/web/src/components/Authenticator.js +++ b/services/web/src/components/Authenticator.js @@ -1,14 +1,3 @@ -import { - Button, - Container, - Divider, - Group, - Loader, - PinInput, - Stack, - Text, -} from '@mantine/core'; - import { useEffect, useState } from 'react'; import { useSession } from 'stores/session'; @@ -17,6 +6,15 @@ import ErrorMessage from 'components/ErrorMessage'; import { ExternalLink } from 'components/Link'; import QRCode from 'components/QRCode'; +import { Button } from '@/components/ui/button'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from '@/components/ui/input-otp'; +import { Separator } from '@/components/ui/separator'; +import { Spinner } from '@/components/ui/spinner'; + import { request } from 'utils/api'; export default function Authenticator({ onClose, onSuccess }) { @@ -85,14 +83,14 @@ export default function Authenticator({ onClose, onSuccess }) { /> ); } else { - return ; + return ; } }; return ( - - 1. Download an authenticator app. - +
+

1. Download an authenticator app.

+

We recommend{' '} Google Authenticator @@ -102,41 +100,41 @@ export default function Authenticator({ onClose, onSuccess }) { 1Password , or Authy. - - 2. Scan this barcode using your authenticator app - - {renderQrCode()} - - 3. Enter the security code generated by your app +

+

+ 2. Scan this barcode using your authenticator app +

+
{renderQrCode()}
+

+ 3. Enter the security code generated by your app +

- - - - - - - +
+
); } diff --git a/services/web/src/components/BackLink.js b/services/web/src/components/BackLink.js index 00f291104..0189b76cf 100644 --- a/services/web/src/components/BackLink.js +++ b/services/web/src/components/BackLink.js @@ -1,5 +1,5 @@ import { useNavigate } from '@bedrockio/router'; -import { NavLink } from '@mantine/core'; +import { ArrowLeft } from 'lucide-react'; export default function BackLink(props) { const { label = 'Back' } = props; @@ -11,5 +11,13 @@ export default function BackLink(props) { navigate.back(); } - return ; + return ( + + + {label} + + ); } diff --git a/services/web/src/components/Code/index.js b/services/web/src/components/Code/index.js index 07867131c..e28b1d7cf 100644 --- a/services/web/src/components/Code/index.js +++ b/services/web/src/components/Code/index.js @@ -1,4 +1,3 @@ -import { Paper } from '@mantine/core'; import { Highlight, themes } from 'prism-react-renderer'; import PropTypes from 'prop-types'; import { useMemo, useState } from 'react'; @@ -68,7 +67,7 @@ export default function Code({ code, language, scroll, action, ...rest }) { }, [language]); return ( - +
{action || ( @@ -82,7 +81,7 @@ export default function Code({ code, language, scroll, action, ...rest }) {
)}
-
+
); } diff --git a/services/web/src/components/ErrorBoundary.jsx b/services/web/src/components/ErrorBoundary.jsx index 59dae66e9..a53fa6565 100644 --- a/services/web/src/components/ErrorBoundary.jsx +++ b/services/web/src/components/ErrorBoundary.jsx @@ -1,8 +1,10 @@ -import { Button, Group, Paper, Stack, Text, Title } from '@mantine/core'; import PropTypes from 'prop-types'; import React from 'react'; import { PiArrowClockwiseBold } from 'react-icons/pi'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; + class ErrorBoundary extends React.Component { constructor(props) { super(props); @@ -24,17 +26,16 @@ class ErrorBoundary extends React.Component { render() { if (this.state.hasError) { return ( - - - Something went wrong - + + +

Something went wrong

+

An error occurred while rendering this component. You can try to reload the page or continue using other parts of the application. - - - - - - + + + ); } return this.props.children; diff --git a/services/web/src/components/ErrorMessage.js b/services/web/src/components/ErrorMessage.js index 80fb49e89..4f232b0d0 100644 --- a/services/web/src/components/ErrorMessage.js +++ b/services/web/src/components/ErrorMessage.js @@ -2,15 +2,17 @@ // also captures and logs error stack for production // debugging. -import { Alert } from '@mantine/core'; import { useEffect } from 'react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { cn } from '@/lib/utils'; + import { ApiError } from 'utils/api'; import { CustomError } from 'utils/error'; import { captureError } from 'utils/sentry'; export default function ErrorMessage(props) { - const { error, ...rest } = props; + const { error, className } = props; useEffect(() => { if (canLogError(error)) { @@ -35,8 +37,8 @@ export default function ErrorMessage(props) { } return ( - - {error.message || 'An error occurred'} + + {error.message || 'An error occurred'} ); } diff --git a/services/web/src/components/Footer.js b/services/web/src/components/Footer.js index 7fd584664..867bfad75 100644 --- a/services/web/src/components/Footer.js +++ b/services/web/src/components/Footer.js @@ -1,47 +1,28 @@ -import { Group, Switch, Text, useMantineColorScheme } from '@mantine/core'; -import { PiMoonBold, PiSunBold } from 'react-icons/pi'; - import Logo from 'components/Logo'; +import { useTheme } from '@/components/ThemeProvider'; +import { Switch } from '@/components/ui/switch'; + export default function Footer() { - const { setColorScheme, colorScheme } = useMantineColorScheme(); + const { resolvedTheme, setTheme } = useTheme(); + const isDark = resolvedTheme === 'dark'; + + function onToggle(checked) { + setTheme(checked ? 'dark' : 'light'); + } + return ( -