refactor(theme): share one base theme between backoffice and portal

The two apps had drifted into unrelated themes. The portal's carried a full
type scale, radius scale, shadow ramp and component defaults; the backoffice's
had none of them — 55 lines defining two colour ramps and little else. With
nothing to inherit, its 23 features each invented their own sizing, which is
the real source of the inconsistency the UI reads with.

Promote the portal's structure to `libs/shared` as `baseTheme`, and reduce both
themes to what they should differ on: brand. The backoffice keeps #1e40af and
the portal keeps Coastal Modern — a distinct accent tells an officer which of
the two systems they are in, and the ramps are not interchangeable in contrast.

Both export names are preserved, so no consumer import changes.

Two properties are deliberately held back rather than shared:
- `colors.gray`: the portal's blue-tinted neutrals retint every dimmed label,
  neutral badge and table border. The backoffice adopts them as its own
  reviewed change, not as a side effect of sharing a base.
- `primaryShade.dark`: moves every filled control in dark mode; waits until
  dark mode is verified end to end.

Also fixes a live bug: PageLoader coloured its primary label `navy.9`, which is
defined in neither theme. Mantine drops unresolved colour keys silently, so the
label in a component used by 20 files had been rendering an inherited colour.

Adds a visual-regression harness to make all of this reviewable. It runs
against a static gallery route rather than real pages, so it needs no API,
database or auth — a theme diff cannot be masked by a migration or an expired
token. The portal is the control group: it is pixel-identical across all four
light/dark × desktop/tablet baselines, which is what makes the refactor
provably lossless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fitse-yotor
2026-08-21 11:25:26 +03:00
parent cd524ff1cc
commit 659e954306
22 changed files with 710 additions and 170 deletions

View File

@@ -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';

View File

@@ -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%)',
},
});

View File

@@ -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%)',
},
});
}),
);

View File

@@ -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;

View File

@@ -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,
},
}),
);

View File

@@ -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";

View File

@@ -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 (
<Stack gap="sm">
<Title order={3}>{title}</Title>
<Divider />
{children}
</Stack>
);
}
/** A full 09 ramp, so a palette swap is visible shade by shade. */
function ColorRamp({ name }: { name: string }) {
return (
<Stack gap={4}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{name}
</Text>
<Group gap={0} wrap="nowrap">
{SWATCH_SHADES.map((shade) => (
<Box
key={shade}
style={{
background: `var(--mantine-color-${name}-${shade})`,
width: 48,
height: 40,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
fontSize: 10,
// Shades 0-4 are light enough to need dark text; 5+ need light.
color: shade < 5 ? 'var(--mantine-color-black)' : 'var(--mantine-color-white)',
}}
>
{shade}
</Box>
))}
</Group>
</Stack>
);
}
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 (
<Box p="xl" style={{ maxWidth: 1100, margin: '0 auto' }}>
<Stack gap="xl">
<Stack gap={4}>
<Title order={1}>Theme Gallery</Title>
<Text c="dimmed">
Visual-regression surface for theme changes. Not a product page.
</Text>
</Stack>
<Section title="Typography">
<Stack gap="xs">
<Title order={1}>Heading 1 Maritime licensing</Title>
<Title order={2}>Heading 2 Seafarer registry</Title>
<Title order={3}>Heading 3 Vessel registration</Title>
<Title order={4}>Heading 4 Certificate of competency</Title>
<Title order={5}>Heading 5 Sea service record</Title>
<Text>
Body text. The quick brown fox jumps over the lazy dog.
</Text>
<Text size="sm" c="dimmed">
Small dimmed text, used for subtitles and helper copy.
</Text>
<Text size="xs" c="dimmed">
Extra-small text, used for metadata.
</Text>
{/* Mixed-script line: the case a [lang] font swap would break. */}
<Text>Amharic: አበበ SF-2024-0001 </Text>
<Text ff="monospace">Monospace: ET-IMO-0231 · 28,450 GT</Text>
</Stack>
</Section>
<Section title="Color ramps">
<Stack gap="md">
{paletteNames.map((name) => (
<ColorRamp key={name} name={name} />
))}
</Stack>
</Section>
<Section title="Buttons">
<Stack gap="sm">
<Group>
<Button>Filled</Button>
<Button variant="light">Light</Button>
<Button variant="outline">Outline</Button>
<Button variant="subtle">Subtle</Button>
<Button variant="default">Default</Button>
<Button variant="white">White</Button>
</Group>
<Group>
<Button size="xs">xs</Button>
<Button size="sm">sm</Button>
<Button size="md">md</Button>
<Button size="lg">lg</Button>
</Group>
<Group>
<Button color="red">Destructive</Button>
<Button disabled>Disabled</Button>
<Button loading>Loading</Button>
<Button fullWidth>Full width</Button>
</Group>
</Stack>
</Section>
<Section title="Surfaces">
<Group align="stretch" grow>
<Card withBorder>
<Text fw={600}>Card withBorder</Text>
<Text size="sm" c="dimmed">
Radius and shadow come from the theme.
</Text>
</Card>
<Paper p="md" withBorder>
<Text fw={600}>Paper withBorder</Text>
<Text size="sm" c="dimmed">
Default radius applies here too.
</Text>
</Paper>
<Paper p="md" shadow="md">
<Text fw={600}>Paper shadow=md</Text>
<Text size="sm" c="dimmed">
Shadow ramp check.
</Text>
</Paper>
</Group>
</Section>
<Section title="Badges">
<Group>
<Badge>Default</Badge>
<Badge color="green">Active</Badge>
<Badge color="yellow">Pending</Badge>
<Badge color="red">Suspended</Badge>
<Badge color="orange">Expiring</Badge>
<Badge color="gray">Draft</Badge>
<Badge variant="filled">Filled</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="dot">Dot</Badge>
</Group>
</Section>
<Section title="Form controls">
<Stack gap="md" style={{ maxWidth: 520 }}>
<TextInput label="Text input" placeholder="you@example.com" />
<TextInput
label="With description"
description="Helper text that persists, unlike a placeholder."
placeholder="ET-000000"
/>
<TextInput
label="Error state"
error="This field is required."
placeholder="Required"
/>
<TextInput label="Disabled" disabled value="Cannot edit" />
<TextInput label="Read-only" readOnly value="Read-only value" />
<Select
label="Select"
data={['Ethiopian', 'Djiboutian', 'Kenyan']}
placeholder="Pick one"
/>
<Textarea label="Textarea" placeholder="Full permanent address" />
<Group>
<Checkbox label="Checkbox" defaultChecked />
<Radio label="Radio" defaultChecked />
<Switch label="Switch" defaultChecked />
</Group>
</Stack>
</Section>
<Section title="Feedback">
<Stack gap="sm">
<Alert color="blue" title="Information">
A unique Seafarer ID is generated on approval.
</Alert>
<Alert color="yellow" title="Warning">
Upload clear copies of all required documents.
</Alert>
<Alert color="red" title="Error">
The certificate number could not be verified.
</Alert>
<Group>
<ThemeIcon size="lg">Ic</ThemeIcon>
<ThemeIcon size="lg" variant="light">
Lt
</ThemeIcon>
<ThemeIcon size="lg" color="green" variant="light">
Ok
</ThemeIcon>
</Group>
</Stack>
</Section>
<Section title="Tabs">
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="training">Training</Tabs.Tab>
<Tabs.Tab value="medical">Medical</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="md">
<Text size="sm">Overview panel content.</Text>
</Tabs.Panel>
</Tabs>
</Section>
<Section title="Table">
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Seafarer ID</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Region</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Table.Tr>
<Table.Td>
<Anchor size="sm">SF-2024-0001</Anchor>
</Table.Td>
<Table.Td>Abebe Girma</Table.Td>
<Table.Td>Addis Ababa</Table.Td>
<Table.Td>
<Badge color="green">Active</Badge>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Anchor size="sm">SF-2024-0002</Anchor>
</Table.Td>
<Table.Td> </Table.Td>
<Table.Td>Dire Dawa</Table.Td>
<Table.Td>
<Badge color="yellow">Pending</Badge>
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Section>
<Section title="Focus states">
<Text size="sm" c="dimmed">
Tab through these to check the focus ring. Every interactive element
must show a visible indicator.
</Text>
<Group mt="sm">
<Button>Button</Button>
<Anchor href="#gallery-focus">Link</Anchor>
<TextInput placeholder="Input" />
<Checkbox label="Checkbox" />
</Group>
</Section>
</Stack>
</Box>
);
}

View File

@@ -80,7 +80,10 @@ export function PageLoader({
<Text
fw={600}
size="md"
c={isDark ? 'gray.1' : 'navy.9'}
// Was `navy.9`, which is defined in neither theme — Mantine
// silently drops unresolved keys, so this label had been
// rendering an inherited colour rather than the intended one.
c={isDark ? 'gray.1' : 'gray.9'}
style={{ lineHeight: 1.3 }}
>
{resolvedLabel}