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

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