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 + +
+ +
+ + + + + + +