diff --git a/.gitignore b/.gitignore
index b833848bb..4b15bf545 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,7 @@ apps/backoffice/public/_um/
apps/backoffice/public/tinymce/
local-packages/iamui-extracted/
+
+# Playwright visual-regression artifacts (baselines under apps/e2e/visual are tracked)
+test-results/
+dist/visual-report/
diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx
index 8019270ed..46135bab4 100644
--- a/apps/backoffice/src/app/router/index.tsx
+++ b/apps/backoffice/src/app/router/index.tsx
@@ -12,6 +12,7 @@ import {
RequirePermission,
LICENSE_PERMISSIONS as P,
} from '@ema-platform/auth';
+import { ThemeGallery } from '@ema-platform/ui';
import { AuthLayout } from '../layouts/AuthLayout';
import { BackofficeLayout } from '../layouts/BackofficeLayout';
import { ProtectedRoute } from './ProtectedRoute';
@@ -68,6 +69,9 @@ const router = createBrowserRouter([
],
},
{ path: '/um/*', element: },
+ // Theme visual-regression surface. Unauthenticated by design — it renders
+ // only static primitives, so it needs no API and cannot flake.
+ { path: '/__gallery', element: },
{ path: '/', element: },
{ path: '/profile-setup', element: },
{
diff --git a/apps/e2e/visual.config.ts b/apps/e2e/visual.config.ts
new file mode 100644
index 000000000..be273b9e0
--- /dev/null
+++ b/apps/e2e/visual.config.ts
@@ -0,0 +1,65 @@
+import { defineConfig, devices } from '@playwright/test';
+
+/**
+ * Visual-regression suite for theme work.
+ *
+ * Deliberately separate from `playwright.config.ts`. That suite drives real
+ * cross-app workflows and therefore needs the API, a database and migrations;
+ * this one only needs to know what the theme renders. Loading the same
+ * dependencies here would make a screenshot diff fail for reasons that have
+ * nothing to do with the theme — a migration, a seeded row, an expired token.
+ *
+ * So: static routes only, `vite preview` over an already-built bundle, no
+ * backend. Run `vite build` for both apps first.
+ */
+
+const PORTAL_PORT = Number(process.env.VISUAL_PORTAL_PORT ?? 4312);
+const BACKOFFICE_PORT = Number(process.env.VISUAL_BACKOFFICE_PORT ?? 4313);
+
+export const VISUAL = {
+ portalUrl: `http://localhost:${PORTAL_PORT}`,
+ backofficeUrl: `http://localhost:${BACKOFFICE_PORT}`,
+};
+
+export default defineConfig({
+ testDir: './visual',
+ workers: 1,
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ // A visual diff that passes on a retry is a flake, and a flake here would
+ // mask exactly the regressions this suite exists to catch.
+ retries: 0,
+ timeout: 60_000,
+ expect: {
+ // Anti-aliasing differs slightly between runs; a handful of pixels is not
+ // a regression. Anything the theme actually changed is far larger.
+ toHaveScreenshot: { maxDiffPixelRatio: 0.01, animations: 'disabled' },
+ },
+ reporter: [['list'], ['html', { outputFolder: '../../dist/visual-report', open: 'never' }]],
+
+ use: {
+ trace: 'retain-on-failure',
+ actionTimeout: 15_000,
+ },
+
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+
+ webServer: [
+ {
+ name: 'portal',
+ command: `npx vite preview --config apps/portal/vite.config.mts --port ${PORTAL_PORT} --strictPort`,
+ cwd: '../..',
+ url: VISUAL.portalUrl,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+ {
+ name: 'backoffice',
+ command: `npx vite preview --config apps/backoffice/vite.config.mts --port ${BACKOFFICE_PORT} --strictPort`,
+ cwd: '../..',
+ url: VISUAL.backofficeUrl,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+ ],
+});
diff --git a/apps/e2e/visual/theme.spec.ts b/apps/e2e/visual/theme.spec.ts
new file mode 100644
index 000000000..eee86e15f
--- /dev/null
+++ b/apps/e2e/visual/theme.spec.ts
@@ -0,0 +1,61 @@
+import { test, expect, type Page } from '@playwright/test';
+import { VISUAL } from '../visual.config';
+
+/**
+ * Theme baselines.
+ *
+ * These exist so a change to the shared theme can be reviewed as a diff rather
+ * than trusted. The gallery route renders every primitive the theme controls,
+ * so one screenshot per app per scheme per width covers the whole surface.
+ *
+ * Update baselines deliberately, never reflexively:
+ * npx playwright test -c apps/e2e/visual.config.ts --update-snapshots
+ * A diff you did not intend is the entire point of the suite.
+ */
+
+const WIDTHS = [
+ { name: 'desktop', width: 1440, height: 1200 },
+ { name: 'tablet', width: 768, height: 1200 },
+] as const;
+
+const SCHEMES = ['light', 'dark'] as const;
+
+const APPS = [
+ { name: 'backoffice', url: VISUAL.backofficeUrl },
+ { name: 'portal', url: VISUAL.portalUrl },
+] as const;
+
+/**
+ * Set the scheme the way the app itself does — the pre-paint script in
+ * index.html reads this key. Setting it before navigation means the very first
+ * paint is already correct, so no screenshot catches a flash of the wrong one.
+ */
+async function gotoGallery(page: Page, baseUrl: string, scheme: string) {
+ await page.addInitScript((value) => {
+ window.localStorage.setItem('mantine-color-scheme-value', value);
+ }, scheme);
+
+ await page.goto(`${baseUrl}/__gallery`, { waitUntil: 'networkidle' });
+
+ // The gallery is static, but web fonts are not: screenshotting before they
+ // settle bakes a fallback-font baseline that every later run then fails
+ // against.
+ await page.evaluate(() => document.fonts.ready);
+ await expect(page.getByRole('heading', { name: 'Theme Gallery' })).toBeVisible();
+}
+
+for (const app of APPS) {
+ for (const scheme of SCHEMES) {
+ for (const size of WIDTHS) {
+ test(`${app.name} gallery — ${scheme} — ${size.name}`, async ({ page }) => {
+ await page.setViewportSize({ width: size.width, height: size.height });
+ await gotoGallery(page, app.url, scheme);
+
+ await expect(page).toHaveScreenshot(
+ `${app.name}-gallery-${scheme}-${size.name}.png`,
+ { fullPage: true },
+ );
+ });
+ }
+ }
+}
diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-dark-desktop-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-dark-desktop-chromium-win32.png
new file mode 100644
index 000000000..fe9da8df6
Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-dark-desktop-chromium-win32.png differ
diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-dark-tablet-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-dark-tablet-chromium-win32.png
new file mode 100644
index 000000000..b2ac0a6c9
Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-dark-tablet-chromium-win32.png differ
diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-light-desktop-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-light-desktop-chromium-win32.png
new file mode 100644
index 000000000..79df6ccab
Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-light-desktop-chromium-win32.png differ
diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-light-tablet-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-light-tablet-chromium-win32.png
new file mode 100644
index 000000000..c98ab52b8
Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-gallery-light-tablet-chromium-win32.png differ
diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-dark-desktop-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-dark-desktop-chromium-win32.png
new file mode 100644
index 000000000..406819423
Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-dark-desktop-chromium-win32.png differ
diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-dark-tablet-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-dark-tablet-chromium-win32.png
new file mode 100644
index 000000000..c4ccb4e98
Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-dark-tablet-chromium-win32.png differ
diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-light-desktop-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-light-desktop-chromium-win32.png
new file mode 100644
index 000000000..69b66859a
Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-light-desktop-chromium-win32.png differ
diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-light-tablet-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-light-tablet-chromium-win32.png
new file mode 100644
index 000000000..f6cf3fc82
Binary files /dev/null and b/apps/e2e/visual/theme.spec.ts-snapshots/portal-gallery-light-tablet-chromium-win32.png differ
diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx
index f5bb914ea..6fdf0a3f3 100644
--- a/apps/portal/src/app/router.tsx
+++ b/apps/portal/src/app/router.tsx
@@ -1,4 +1,5 @@
import { createBrowserRouter, Navigate } from "react-router-dom";
+import { ThemeGallery } from "@ema-platform/ui";
import { PortalLayout } from "./layouts/PortalLayout";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { LandingRoute } from "./components/LandingRoute";
@@ -57,6 +58,10 @@ export const router = createBrowserRouter([
// Public landing page — institutional overview + role-based entry points.
{ path: "/", element: },
+ // Theme visual-regression surface. Unauthenticated by design — it renders
+ // only static primitives, so it needs no API and cannot flake.
+ { path: "/__gallery", element: },
+
// Public auth pages
{ path: "/login", element: },
{ path: "/signup", element: },
diff --git a/apps/portal/src/app/theme/portalTheme.ts b/apps/portal/src/app/theme/portalTheme.ts
index 01bba6e3c..b78975fe9 100644
--- a/apps/portal/src/app/theme/portalTheme.ts
+++ b/apps/portal/src/app/theme/portalTheme.ts
@@ -1,116 +1,13 @@
-import {
- createTheme,
- rem,
- type MantineColorsTuple,
-} from '@mantine/core';
-
-// ---- Coastal Modern palette ----------------------------------------------
-// Portal-only theme. Lives here (not in @ema-platform/shared) so the backoffice
-// is unaffected.
-
-const emaPrimary: MantineColorsTuple = [
- '#eef4ff', '#dce7fb', '#b6cdf4', '#8db0ee', '#6c97e9',
- '#5887e6', '#4b7fe5', '#3b6ccc', '#3160b7', '#2453a2',
-];
-
-// Teal accent — the "coastal" half of the palette.
-const emaTeal: MantineColorsTuple = [
- '#e1fbf6', '#cdf3eb', '#9ee6d7', '#6bd9c1', '#46cdaf',
- '#30c7a5', '#1fc29d', '#0aab89', '#009879', '#008368',
-];
-
-// Cool neutral grays (slightly blue-tinted) for surfaces & text.
-const emaGray: MantineColorsTuple = [
- '#f6f8fb', '#eceff4', '#dde2eb', '#c8d0dd', '#aab5c7',
- '#8d9bb3', '#73839e', '#5c6b85', '#46546b', '#333f52',
-];
-
-export const portalTheme = createTheme({
- primaryColor: 'emaPrimary',
- primaryShade: { light: 6, dark: 5 },
- colors: {
- emaPrimary,
- emaTeal,
- gray: emaGray,
- },
- fontFamily:
- 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
- headings: {
- fontFamily: 'Inter, sans-serif',
- fontWeight: '700',
- sizes: {
- h1: { fontSize: rem(32), lineHeight: '1.25' },
- h2: { fontSize: rem(25), lineHeight: '1.3' },
- h3: { fontSize: rem(21), lineHeight: '1.35' },
- h4: { fontSize: rem(17), lineHeight: '1.4' },
- h5: { fontSize: rem(15), lineHeight: '1.45' },
- },
- },
- defaultRadius: 'md',
- radius: {
- xs: rem(6),
- sm: rem(8),
- md: rem(12),
- lg: rem(16),
- xl: rem(22),
- },
- shadows: {
- xs: '0 1px 2px rgba(15,23,42,0.06)',
- sm: '0 2px 8px rgba(15,23,42,0.06), 0 1px 2px rgba(15,23,42,0.04)',
- md: '0 8px 24px rgba(15,23,42,0.08)',
- lg: '0 16px 40px rgba(15,23,42,0.12)',
- xl: '0 24px 64px rgba(15,23,42,0.16)',
- },
- breakpoints: {
- xs: '36em',
- sm: '48em',
- md: '62em',
- lg: '75em',
- xl: '88em',
- },
- cursorType: 'pointer',
- components: {
- Paper: {
- defaultProps: { radius: 'lg' },
- },
- Card: {
- defaultProps: { radius: 'lg' },
- },
- Button: {
- defaultProps: { radius: 'md' },
- styles: { root: { fontWeight: 600 } },
- },
- Badge: {
- defaultProps: { radius: 'sm' },
- },
- ThemeIcon: {
- defaultProps: { radius: 'md' },
- },
- NavLink: {
- styles: { root: { borderRadius: rem(10), fontWeight: 500 } },
- },
- TextInput: { defaultProps: { radius: 'md' } },
- Textarea: { defaultProps: { radius: 'md' } },
- Select: { defaultProps: { radius: 'md' } },
- PasswordInput: { defaultProps: { radius: 'md' } },
- // Mantine's stock scroll wrapper (NativeScrollArea) discards the
- // max-height it's handed unless scrollAreaComponent is set, so a modal
- // taller than the viewport just gets clipped with no way to scroll it.
- // Making the body the scrollport here fixes every Modal/Drawer at once.
- Modal: {
- styles: {
- content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
- body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
- },
- },
- Drawer: {
- styles: {
- content: { display: 'flex', flexDirection: 'column' },
- body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
- },
- },
- },
- other: {
- heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
- },
-});
+/**
+ * The portal theme now lives in `@ema-platform/shared`, alongside the
+ * backoffice theme and the base they share.
+ *
+ * It moved because the two themes had diverged into unrelated definitions —
+ * this one carried a full type scale, radius scale and component defaults that
+ * the backoffice simply lacked. Sharing the structure fixes the backoffice
+ * without changing the portal.
+ *
+ * This re-export is kept so the portal's MantineThemeProvider import stays
+ * valid. Prefer importing from `@ema-platform/shared` directly in new code.
+ */
+export { portalTheme } from '@ema-platform/shared';
diff --git a/libs/shared/src/index.ts b/libs/shared/src/index.ts
index 4c88f69e7..040d0f5ff 100644
--- a/libs/shared/src/index.ts
+++ b/libs/shared/src/index.ts
@@ -1,4 +1,7 @@
+export * from './lib/theme/palettes';
+export * from './lib/theme/base-theme';
export * from './lib/theme/ema-theme';
+export * from './lib/theme/portal-theme';
export * from './lib/date/date-displayer';
export * from './lib/date/use-date-displayer';
export * from './lib/date/ethiopic';
diff --git a/libs/shared/src/lib/theme/base-theme.ts b/libs/shared/src/lib/theme/base-theme.ts
new file mode 100644
index 000000000..43dc11c80
--- /dev/null
+++ b/libs/shared/src/lib/theme/base-theme.ts
@@ -0,0 +1,116 @@
+import { createTheme, rem } from '@mantine/core';
+
+/**
+ * Everything both apps agree on: scale, shape, elevation, and component
+ * defaults. No colours — those are the one thing the backoffice and the portal
+ * deliberately differ on, so each theme layers its own ramps over this.
+ *
+ * This began as the portal's theme. The backoffice had no heading scale, no
+ * radius scale and no component defaults at all, which is why its features
+ * drifted: with nothing to inherit, every page invented its own spacing and
+ * sizing. Promoting the portal's structure here fixes 23 features by editing
+ * one file, and costs the portal nothing — the values are unchanged.
+ */
+
+/**
+ * The type stack.
+ *
+ * Ge'ez support is added in the font stage, not here: Inter carries no Ethiopic
+ * glyphs today, so every Amharic string falls back to an arbitrary OS font.
+ * Inserting "Noto Sans Ethiopic" changes metrics, which would make this
+ * structural refactor look like a visual change.
+ */
+export const EMA_FONT_STACK =
+ 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
+
+export const baseTheme = createTheme({
+ fontFamily: EMA_FONT_STACK,
+
+ headings: {
+ fontFamily: EMA_FONT_STACK,
+ fontWeight: '700',
+ // Exactly the portal's original values. Ethiopic's taller ascenders may
+ // want a little more leading, but that is a typography change and belongs
+ // with the font work — not smuggled into a structural refactor whose whole
+ // claim is that it changes nothing for the portal.
+ sizes: {
+ h1: { fontSize: rem(32), lineHeight: '1.25' },
+ h2: { fontSize: rem(25), lineHeight: '1.3' },
+ h3: { fontSize: rem(21), lineHeight: '1.35' },
+ h4: { fontSize: rem(17), lineHeight: '1.4' },
+ h5: { fontSize: rem(15), lineHeight: '1.45' },
+ },
+ },
+
+ defaultRadius: 'md',
+ radius: {
+ xs: rem(6),
+ sm: rem(8),
+ md: rem(12),
+ lg: rem(16),
+ xl: rem(22),
+ },
+
+ shadows: {
+ xs: '0 1px 2px rgba(15,23,42,0.06)',
+ sm: '0 2px 8px rgba(15,23,42,0.06), 0 1px 2px rgba(15,23,42,0.04)',
+ md: '0 8px 24px rgba(15,23,42,0.08)',
+ lg: '0 16px 40px rgba(15,23,42,0.12)',
+ xl: '0 24px 64px rgba(15,23,42,0.16)',
+ },
+
+ breakpoints: {
+ xs: '36em',
+ sm: '48em',
+ md: '62em',
+ lg: '75em',
+ xl: '88em',
+ },
+
+ cursorType: 'pointer',
+
+ // Draw a focus ring for keyboard users only. The codebase had no
+ // `:focus-visible` handling anywhere, which is the single largest WCAG gap.
+ focusRing: 'auto',
+
+ // Shade 6 in light. The dark counterpart is deliberately left unset until
+ // dark mode is verified end to end — changing it moves every filled control.
+ primaryShade: { light: 6 },
+
+ components: {
+ Paper: { defaultProps: { radius: 'lg' } },
+ Card: { defaultProps: { radius: 'lg' } },
+ Button: {
+ defaultProps: { radius: 'md' },
+ styles: { root: { fontWeight: 600 } },
+ },
+ Badge: { defaultProps: { radius: 'sm' } },
+ ThemeIcon: { defaultProps: { radius: 'md' } },
+ NavLink: { styles: { root: { borderRadius: rem(10), fontWeight: 500 } } },
+ TextInput: { defaultProps: { radius: 'md' } },
+ Textarea: { defaultProps: { radius: 'md' } },
+ Select: { defaultProps: { radius: 'md' } },
+ PasswordInput: { defaultProps: { radius: 'md' } },
+
+ // Mantine's stock scroll wrapper (NativeScrollArea) discards the
+ // max-height it's handed unless scrollAreaComponent is set, so a modal
+ // taller than the viewport just gets clipped with no way to scroll it.
+ // Making the body the scrollport here fixes every Modal/Drawer at once.
+ Modal: {
+ styles: {
+ content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
+ body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
+ },
+ },
+ Drawer: {
+ styles: {
+ content: { display: 'flex', flexDirection: 'column' },
+ body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
+ },
+ },
+ },
+
+ other: {
+ heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
+ },
+});
diff --git a/libs/shared/src/lib/theme/ema-theme.ts b/libs/shared/src/lib/theme/ema-theme.ts
index 6122e2b36..62b8ecaff 100644
--- a/libs/shared/src/lib/theme/ema-theme.ts
+++ b/libs/shared/src/lib/theme/ema-theme.ts
@@ -1,55 +1,32 @@
-import { createTheme, type MantineColorsTuple } from '@mantine/core';
+import { createTheme, mergeThemeOverrides } from '@mantine/core';
+import { baseTheme } from './base-theme';
+import { emaBlue, emaSecondary } from './palettes';
-const emaPrimary: MantineColorsTuple = [
- '#eff6ff', '#dbeafe', '#bfdbfe', '#93c5fd', '#60a5fa',
- '#3b82f6', '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a',
-];
-
-const emaSecondary: MantineColorsTuple = [
- '#fdf8f6', '#f2e8e5', '#eaddd7', '#e0cec7', '#d2bab0',
- '#bfa094', '#a18072', '#977669', '#65524d', '#2c1f1a',
-];
-
-export const emaTheme = createTheme({
- primaryColor: 'emaPrimary',
- colors: {
- emaPrimary,
- emaSecondary,
- },
- fontFamily: 'Inter, sans-serif',
- defaultRadius: 'md',
- breakpoints: {
- xs: '36em',
- sm: '48em',
- md: '62em',
- lg: '75em',
- xl: '88em',
- },
- shadows: {
- xs: '0 1px 3px rgba(0,0,0,0.05)',
- sm: '0 1px 5px rgba(0,0,0,0.07)',
- md: '0 4px 20px rgba(15,23,42,0.08)',
- lg: '0 8px 30px rgba(15,23,42,0.12)',
- },
- components: {
- // Mantine's stock scroll wrapper (NativeScrollArea) discards the
- // max-height it's handed unless scrollAreaComponent is set, so a modal
- // taller than the viewport just gets clipped with no way to scroll it.
- // Making the body the scrollport here fixes every Modal/Drawer at once.
- Modal: {
- styles: {
- content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
- body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
- },
+/**
+ * Backoffice theme.
+ *
+ * Structure, scale and component defaults come from `baseTheme`; this file
+ * contributes only the brand. The backoffice keeps its own blue rather than
+ * adopting the portal's: shade 8 (#1e40af) is the higher-contrast choice for a
+ * tool staff read all day, and a distinct accent tells an officer at a glance
+ * which of the two systems they are looking at — worth having when both share
+ * a domain vocabulary but not the same authority.
+ *
+ * The export name is load-bearing: `libs/shared/src/index.ts` and the
+ * backoffice's MantineThemeProvider both import `emaTheme` by name.
+ *
+ * Note this theme does NOT override `colors.gray`. The portal's blue-tinted
+ * neutrals shift every dimmed label, neutral badge and table border, so that
+ * change is being made one app at a time rather than as a side effect of
+ * sharing a base.
+ */
+export const emaTheme = mergeThemeOverrides(
+ baseTheme,
+ createTheme({
+ primaryColor: 'emaPrimary',
+ colors: {
+ emaPrimary: emaBlue,
+ emaSecondary,
},
- Drawer: {
- styles: {
- content: { display: 'flex', flexDirection: 'column' },
- body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
- },
- },
- },
- other: {
- heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
- },
-});
+ }),
+);
diff --git a/libs/shared/src/lib/theme/palettes.ts b/libs/shared/src/lib/theme/palettes.ts
new file mode 100644
index 000000000..e894b66b2
--- /dev/null
+++ b/libs/shared/src/lib/theme/palettes.ts
@@ -0,0 +1,66 @@
+import type { MantineColorsTuple } from '@mantine/core';
+
+/**
+ * Every colour ramp the platform uses, in one place.
+ *
+ * Themes compose these; they do not define colours inline. Keeping the ramps
+ * separate from the themes is what lets the backoffice and the portal share a
+ * structure while keeping distinct brands — and it gives the hardcoded hexes
+ * scattered through feature code somewhere legitimate to be migrated to.
+ */
+
+/**
+ * Backoffice brand. Shade 8 (#1e40af) is the accessible-government blue; the
+ * ramp is deliberately more saturated than the portal's because staff tools
+ * are read all day under worse conditions than a citizen portal.
+ */
+export const emaBlue: MantineColorsTuple = [
+ '#eff6ff', '#dbeafe', '#bfdbfe', '#93c5fd', '#60a5fa',
+ '#3b82f6', '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a',
+];
+
+/** Backoffice secondary — a warm brown, used sparingly for accents. */
+export const emaSecondary: MantineColorsTuple = [
+ '#fdf8f6', '#f2e8e5', '#eaddd7', '#e0cec7', '#d2bab0',
+ '#bfa094', '#a18072', '#977669', '#65524d', '#2c1f1a',
+];
+
+/** Portal brand — the "Coastal Modern" blue. Softer than the backoffice ramp. */
+export const emaCoastalBlue: MantineColorsTuple = [
+ '#eef4ff', '#dce7fb', '#b6cdf4', '#8db0ee', '#6c97e9',
+ '#5887e6', '#4b7fe5', '#3b6ccc', '#3160b7', '#2453a2',
+];
+
+/** Portal accent — the "coastal" half of the palette. */
+export const emaTeal: MantineColorsTuple = [
+ '#e1fbf6', '#cdf3eb', '#9ee6d7', '#6bd9c1', '#46cdaf',
+ '#30c7a5', '#1fc29d', '#0aab89', '#009879', '#008368',
+];
+
+/**
+ * Cool, slightly blue-tinted neutrals for surfaces and text.
+ *
+ * Overriding Mantine's stock `gray` with this shifts every `c="dimmed"`, every
+ * neutral badge and every table border in whichever app adopts it — so it is
+ * applied per-theme rather than in the base, and promoted one app at a time.
+ */
+export const emaGray: MantineColorsTuple = [
+ '#f6f8fb', '#eceff4', '#dde2eb', '#c8d0dd', '#aab5c7',
+ '#8d9bb3', '#73839e', '#5c6b85', '#46546b', '#333f52',
+];
+
+/**
+ * Ethiopian flag colours.
+ *
+ * These are intentional brand, not drift — they appear in the boot splashes,
+ * the maritime loader and the landing page. They live here so those usages can
+ * reference a name instead of repeating a hex, but they are deliberately NOT
+ * semantic tokens: `emaFlag.green` means "the flag's green", never "success".
+ */
+export const emaFlag = {
+ blue: '#0284C7',
+ yellow: '#FCD116',
+ green: '#078930',
+ gold: '#D4AF37',
+ sky: '#38BDF8',
+} as const;
diff --git a/libs/shared/src/lib/theme/portal-theme.ts b/libs/shared/src/lib/theme/portal-theme.ts
new file mode 100644
index 000000000..8909ca005
--- /dev/null
+++ b/libs/shared/src/lib/theme/portal-theme.ts
@@ -0,0 +1,29 @@
+import { createTheme, mergeThemeOverrides } from '@mantine/core';
+import { baseTheme } from './base-theme';
+import { emaCoastalBlue, emaGray, emaTeal } from './palettes';
+
+/**
+ * Portal theme — "Coastal Modern".
+ *
+ * Structure comes from `baseTheme` (which this theme's own structure was the
+ * source of, so nothing here changes visually). What remains is the brand: a
+ * softer blue than the backoffice, a teal accent, and cool blue-tinted
+ * neutrals in place of Mantine's stock gray.
+ *
+ * The gray override stays portal-only for now. It is the widest-reaching
+ * single line in either theme — it retints every dimmed label, neutral badge
+ * and table border — so the backoffice adopts it as its own reviewed change,
+ * not as a side effect of sharing a base.
+ */
+export const portalTheme = mergeThemeOverrides(
+ baseTheme,
+ createTheme({
+ primaryColor: 'emaPrimary',
+ primaryShade: { light: 6, dark: 5 },
+ colors: {
+ emaPrimary: emaCoastalBlue,
+ emaTeal,
+ gray: emaGray,
+ },
+ }),
+);
diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts
index 3934e4136..28d4bbd0e 100644
--- a/libs/ui/src/index.ts
+++ b/libs/ui/src/index.ts
@@ -29,3 +29,4 @@ export * from "./lib/data/useServerTable";
export * from "./lib/landing/LandingPage";
export * from "./lib/landing/landing-copy";
export * from "./lib/utils/person-name";
+export * from "./lib/dev/ThemeGallery";
diff --git a/libs/ui/src/lib/dev/ThemeGallery.tsx b/libs/ui/src/lib/dev/ThemeGallery.tsx
new file mode 100644
index 000000000..ba90fd555
--- /dev/null
+++ b/libs/ui/src/lib/dev/ThemeGallery.tsx
@@ -0,0 +1,309 @@
+import {
+ Alert,
+ Anchor,
+ Badge,
+ Box,
+ Button,
+ Card,
+ Checkbox,
+ Divider,
+ Group,
+ Paper,
+ Radio,
+ Select,
+ Stack,
+ Switch,
+ Table,
+ Tabs,
+ Text,
+ Textarea,
+ TextInput,
+ ThemeIcon,
+ Title,
+ useMantineTheme,
+} from '@mantine/core';
+
+/**
+ * Every primitive the theme controls, on one page.
+ *
+ * This is the visual-regression surface for theme work. A real feature page
+ * renders a handful of primitives and only their happy states; a theme change
+ * that breaks `Button disabled` or `TextInput error` would sail past it. This
+ * renders all of them, including the states nothing else shows, so a screenshot
+ * diff says precisely what a theme edit did.
+ *
+ * Deliberately free of data, auth and network so it cannot flake: mounted at
+ * `/__gallery` outside the protected routes, it needs no API and no database.
+ *
+ * Not part of the product. Excluded from the nav on purpose.
+ */
+
+const SWATCH_SHADES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ {title}
+
+ {children}
+
+ );
+}
+
+/** A full 0–9 ramp, so a palette swap is visible shade by shade. */
+function ColorRamp({ name }: { name: string }) {
+ return (
+
+
+ {name}
+
+
+ {SWATCH_SHADES.map((shade) => (
+
+ {shade}
+
+ ))}
+
+
+ );
+}
+
+export function ThemeGallery() {
+ const theme = useMantineTheme();
+ const paletteNames = Object.keys(theme.colors).filter((c) =>
+ // The stock Mantine ramps are noise here; show the ones this app defines,
+ // plus gray because overriding it is the riskiest single theme change.
+ c.startsWith('ema') || c === 'gray',
+ );
+
+ return (
+
+
+
+ Theme Gallery
+
+ Visual-regression surface for theme changes. Not a product page.
+
+
+
+
+
+ Heading 1 — Maritime licensing
+ Heading 2 — Seafarer registry
+ Heading 3 — Vessel registration
+ Heading 4 — Certificate of competency
+ Heading 5 — Sea service record
+
+ Body text. The quick brown fox jumps over the lazy dog.
+
+
+ Small dimmed text, used for subtitles and helper copy.
+
+
+ Extra-small text, used for metadata.
+
+ {/* Mixed-script line: the case a [lang] font swap would break. */}
+ Amharic: አበበ ግርማ — SF-2024-0001 — የአዲስ አበባ ወደብ
+ Monospace: ET-IMO-0231 · 28,450 GT
+
+
+
+
+
+ {paletteNames.map((name) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Card withBorder
+
+ Radius and shadow come from the theme.
+
+
+
+ Paper withBorder
+
+ Default radius applies here too.
+
+
+
+ Paper shadow=md
+
+ Shadow ramp check.
+
+
+
+
+
+
+
+ Default
+ Active
+ Pending
+ Suspended
+ Expiring
+ Draft
+ Filled
+ Outline
+ Dot
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A unique Seafarer ID is generated on approval.
+
+
+ Upload clear copies of all required documents.
+
+
+ The certificate number could not be verified.
+
+
+ Ic
+
+ Lt
+
+
+ Ok
+
+
+
+
+
+
+
+
+ Overview
+ Training
+ Medical
+
+
+ Overview panel content.
+
+
+
+
+
+
+
+
+ Seafarer ID
+ Name
+ Region
+ Status
+
+
+
+
+
+ SF-2024-0001
+
+ Abebe Girma
+ Addis Ababa
+
+ Active
+
+
+
+
+ SF-2024-0002
+
+ ሳራ ተስፋዬ
+ Dire Dawa
+
+ Pending
+
+
+
+
+
+
+
+
+ Tab through these to check the focus ring. Every interactive element
+ must show a visible indicator.
+
+
+
+ Link
+
+
+
+
+
+
+ );
+}
diff --git a/libs/ui/src/lib/feedback/PageLoader.tsx b/libs/ui/src/lib/feedback/PageLoader.tsx
index 4609f44bc..aa475eb66 100644
--- a/libs/ui/src/lib/feedback/PageLoader.tsx
+++ b/libs/ui/src/lib/feedback/PageLoader.tsx
@@ -80,7 +80,10 @@ export function PageLoader({
{resolvedLabel}