Merge branch 'ui_ux' of github.com:Tria-plc/emaui into WorkflowChange

This commit is contained in:
Nati
2026-08-21 17:36:09 +00:00
92 changed files with 3024 additions and 2446 deletions

View File

@@ -107,12 +107,26 @@ export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationSta
REJECTED: 'Rejected',
};
export const SEAFARER_REGISTRATION_STATUS_COLORS: Record<SeafarerRegistrationStatus, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
RESUBMIT_REQUIRED: 'orange',
APPROVED: 'teal',
REJECTED: 'red',
/**
* Registration status → platform tone.
*
* Tones, not colours. `StatusTone` is the platform's status vocabulary and the
* one place a tone becomes a colour (`STATUS_TONE_COLOR` in
* @ema-platform/shared), so this map cannot drift the way `APPROVED: 'teal'`
* had already drifted from every other feature's green success.
*
* The union is repeated rather than imported because @ema-platform/api does not
* depend on the theme layer, and should not start to for five string literals.
*/
export const SEAFARER_REGISTRATION_STATUS_TONES: Record<
SeafarerRegistrationStatus,
'success' | 'warning' | 'danger' | 'info' | 'pending' | 'neutral'
> = {
DRAFT: 'neutral',
SUBMITTED: 'info',
RESUBMIT_REQUIRED: 'pending',
APPROVED: 'success',
REJECTED: 'danger',
};
/** Human label for each answer — the review table and the summary both use it. */

View File

@@ -1,4 +1,8 @@
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/theme/status-tone';
export * from './lib/date/date-displayer';
export * from './lib/date/use-date-displayer';
export * from './lib/date/ethiopic';

View File

@@ -0,0 +1,156 @@
import type { CSSProperties } from 'react';
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.
*
* Noto Sans Ethiopic sits directly after Inter rather than being swapped in by
* a `[lang='am']` rule. Browsers fall back per *glyph*, not per element, so one
* stack renders Latin in Inter and Ge'ez in Noto automatically — including
* inside a single string. That matters here: a registry is full of mixed-script
* lines like an Amharic name beside a Latin IMO number, and a language-scoped
* swap would render one half of those in the wrong face.
*
* Inter carries no Ge'ez glyphs at all, so before this the Amharic half of a
* bilingual system rendered in whatever the OS happened to substitute.
*/
export const EMA_FONT_STACK =
'Inter, "Noto Sans Ethiopic", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
export const baseTheme = createTheme({
fontFamily: EMA_FONT_STACK,
headings: {
fontFamily: EMA_FONT_STACK,
fontWeight: '700',
// A little looser than the portal's original values. Ge'ez has taller
// ascenders and deeper descenders than Latin, so a heading set to Inter's
// natural leading clips its Amharic rendering — which only became visible
// once Ethiopic was actually being rendered rather than substituted.
sizes: {
h1: { fontSize: rem(32), lineHeight: '1.3' },
h2: { fontSize: rem(25), lineHeight: '1.35' },
h3: { fontSize: rem(21), lineHeight: '1.4' },
h4: { fontSize: rem(17), lineHeight: '1.45' },
h5: { fontSize: rem(15), lineHeight: '1.5' },
},
},
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' } },
// The page sits on a tinted surface and cards float on white. Without this
// the main area is the same white as every Paper on it, and the card
// borders are the only thing separating content from chrome.
AppShell: {
styles: { main: { background: 'var(--ema-surface-page)' } },
},
// Tables — the registry look: quiet uppercase headers, hairline row
// borders, a tint on hover, no zebra striping and no column rules. Set
// once here so the 14 pages rendering a raw <Table> match the 28 that go
// through AdvancedTable instead of each picking their own density.
//
// Header text is `text-secondary` rather than the lighter dimmed gray the
// mockup used: at 11px uppercase, gray-5 on white fails 4.5:1.
Table: {
defaultProps: { highlightOnHover: true, verticalSpacing: 'sm', horizontalSpacing: 'md' },
styles: {
table: {
'--table-border-color': 'var(--ema-border-subtle)',
'--table-hover-color': 'var(--ema-surface-page)',
'--table-striped-color': 'var(--ema-surface-sunken)',
} as CSSProperties,
th: {
fontSize: rem(11),
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--ema-text-secondary)',
whiteSpace: 'nowrap',
},
td: { fontSize: rem(13) },
},
},
// 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

@@ -0,0 +1,178 @@
/* ============================================================================
Semantic tokens.
These name a *role* — "the page background", "a subtle border", "danger" —
rather than a colour. Feature code should reach for these instead of a hex,
because a hex cannot follow the colour scheme and a Mantine shade index
(`gray.5`) says nothing about why that shade was chosen.
Every token resolves to a Mantine variable rather than a literal. That is
deliberate: Mantine already recomputes its own variables under
[data-mantine-color-scheme], so tokens defined in terms of them switch for
free and can never drift from the theme. A parallel palette of raw hexes
would recreate exactly the problem this layer exists to fix.
Loaded once per app, after Mantine's CSS.
============================================================================ */
:root {
/* --- Surfaces ---------------------------------------------------------- */
/* The page itself, a raised card, and a recessed well. */
--ema-surface-page: var(--mantine-color-gray-0);
--ema-surface-raised: var(--mantine-color-white);
--ema-surface-sunken: var(--mantine-color-gray-1);
/* --- Borders ----------------------------------------------------------- */
/* Subtle separates rows; strong outlines an input or a focused container. */
--ema-border-subtle: var(--mantine-color-gray-2);
--ema-border-strong: var(--mantine-color-gray-4);
/* --- Text -------------------------------------------------------------- */
/* Secondary must stay a *text* colour: it has to clear 4.5:1, not 3:1, so
it deliberately sits darker than the gray-5 that reads as "dimmed". */
--ema-text-primary: var(--mantine-color-gray-9);
--ema-text-secondary: var(--mantine-color-gray-7);
--ema-text-disabled: var(--mantine-color-gray-5);
/* --- Status ------------------------------------------------------------
Six tones, which is the entire vocabulary a status needs. Domain statuses
map onto these rather than each picking their own colour.
`-fg` is text on the app background; `-bg` is a tint to sit that text on.
Both are needed because a badge and a label have different contrast
requirements against the same surface. */
--ema-status-success-fg: var(--mantine-color-green-8);
--ema-status-success-bg: var(--mantine-color-green-0);
--ema-status-warning-fg: var(--mantine-color-yellow-8);
--ema-status-warning-bg: var(--mantine-color-yellow-0);
--ema-status-danger-fg: var(--mantine-color-red-8);
--ema-status-danger-bg: var(--mantine-color-red-0);
--ema-status-info-fg: var(--mantine-color-blue-8);
--ema-status-info-bg: var(--mantine-color-blue-0);
--ema-status-pending-fg: var(--mantine-color-orange-8);
--ema-status-pending-bg: var(--mantine-color-orange-0);
--ema-status-neutral-fg: var(--mantine-color-gray-7);
--ema-status-neutral-bg: var(--mantine-color-gray-1);
/* --- Focus -------------------------------------------------------------
One ring for the whole platform. Sized to stay visible against both a
white card and a tinted surface. */
--ema-focus-ring: var(--mantine-primary-color-filled);
--ema-focus-ring-width: 2px;
--ema-focus-ring-offset: 2px;
}
[data-mantine-color-scheme='dark'] {
/* Dark is not light inverted. Surfaces lift with elevation rather than
dropping, and text steps down from white rather than up from black. */
--ema-surface-page: var(--mantine-color-dark-8);
--ema-surface-raised: var(--mantine-color-dark-7);
--ema-surface-sunken: var(--mantine-color-dark-9);
--ema-border-subtle: var(--mantine-color-dark-4);
--ema-border-strong: var(--mantine-color-dark-3);
--ema-text-primary: var(--mantine-color-gray-0);
--ema-text-secondary: var(--mantine-color-gray-4);
--ema-text-disabled: var(--mantine-color-dark-2);
/* Saturated mid-shades go muddy on a dark ground; these step lighter so the
foreground still clears 4.5:1 and the tint stays distinguishable. */
--ema-status-success-fg: var(--mantine-color-green-4);
--ema-status-success-bg: var(--mantine-color-green-9);
--ema-status-warning-fg: var(--mantine-color-yellow-4);
--ema-status-warning-bg: var(--mantine-color-yellow-9);
--ema-status-danger-fg: var(--mantine-color-red-4);
--ema-status-danger-bg: var(--mantine-color-red-9);
--ema-status-info-fg: var(--mantine-color-blue-4);
--ema-status-info-bg: var(--mantine-color-blue-9);
--ema-status-pending-fg: var(--mantine-color-orange-4);
--ema-status-pending-bg: var(--mantine-color-orange-9);
--ema-status-neutral-fg: var(--mantine-color-gray-4);
--ema-status-neutral-bg: var(--mantine-color-dark-5);
}
/* ============================================================================
Focus.
The codebase had no :focus-visible rule anywhere, which is the single
largest accessibility gap in it. :focus-visible rather than :focus so a
mouse click does not leave a ring behind — that is the behaviour that gets
focus rings deleted from designs in the first place.
============================================================================ */
/* Mantine already rings its own controls (`.mantine-focus-auto:focus-visible`
resolves to the same 2px solid primary). This rule is the safety net for
everything it does not own: plain anchors, custom elements, and the
UnstyledButtons this codebase uses for its own controls.
Note there is deliberately no `outline: none` opt-out for the Mantine
classes. An earlier attempt at one suppressed Mantine's working ring and
left portal buttons with no focus indicator at all — which of the two rules
won came down to stylesheet order, and that differs between the apps.
Matching values mean overlap is invisible, so overlap is the safe default. */
:focus-visible {
outline: var(--ema-focus-ring-width) solid var(--ema-focus-ring);
outline-offset: var(--ema-focus-ring-offset);
}
/* ============================================================================
Screen-reader-only utility.
No equivalent existed anywhere in the codebase, so anything needing a text
alternative had nowhere to put it.
============================================================================ */
.ema-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
/* clip-path rather than the legacy clip: it does not force a layer and is
not deprecated. */
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* A skip link is sr-only until focused, then must be plainly visible. */
.ema-skip-link {
position: absolute;
top: 0;
left: 0;
z-index: 9999;
padding: 0.75rem 1.25rem;
background: var(--ema-surface-raised);
color: var(--ema-text-primary);
border: 1px solid var(--ema-border-strong);
border-radius: 0 0 var(--mantine-radius-md) 0;
font-weight: 600;
text-decoration: none;
/* Off-screen rather than display:none, so it stays focusable. */
transform: translateY(-150%);
}
.ema-skip-link:focus-visible {
transform: translateY(0);
}
/* ============================================================================
Reduced motion.
Honour the OS setting globally. Animation is not removed outright — a
near-instant transition still conveys that something changed, without the
movement that triggers vestibular symptoms.
============================================================================ */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -0,0 +1,51 @@
/**
* The platform's status vocabulary.
*
* There are 48 separate status→colour maps across the codebase, each deciding
* independently what "pending" looks like. They disagree. The fix is not one
* bigger map — domain statuses genuinely differ per feature — but one small set
* of *tones* that every domain maps onto, so the colour decision is made six
* times instead of forty-eight.
*
* `semantic.css` carries the CSS-variable form of these for stylesheet use.
* This module is for the many places that need a Mantine `color` prop instead.
*/
export type StatusTone =
| 'success'
| 'warning'
| 'danger'
| 'info'
| 'pending'
| 'neutral';
/**
* Tone → Mantine colour name.
*
* Deliberately the only place a tone becomes a colour. Changing the platform's
* idea of "warning" is an edit here, not a sweep through 48 files.
*/
export const STATUS_TONE_COLOR: Record<StatusTone, string> = {
success: 'green',
warning: 'yellow',
danger: 'red',
info: 'blue',
pending: 'orange',
neutral: 'gray',
};
/**
* Tone → CSS custom properties, for inline styles and stylesheets.
*
* Returns variable references rather than resolved colours so the values keep
* following the active colour scheme.
*/
export function statusToneVars(tone: StatusTone): {
color: string;
background: string;
} {
return {
color: `var(--ema-status-${tone}-fg)`,
background: `var(--ema-status-${tone}-bg)`,
};
}

View File

@@ -9,6 +9,7 @@ export * from "./lib/feedback/FeatureUnavailable";
export * from "./lib/feedback/EmptyState";
export * from "./lib/feedback/ErrorState";
export * from "./lib/feedback/PageLoader";
export * from "./lib/feedback/StatusBadge";
export * from "./lib/components/MaritimeLoader";
export * from "./lib/theme/maritime-loader-theme";
export * from "./lib/layout/AppHeader";
@@ -19,13 +20,17 @@ export * from "./lib/layout/BrandAvatar";
export * from "./lib/layout/ColorSchemeToggle";
export * from "./lib/layout/LanguageSwitcher";
export * from "./lib/layout/PageHeader";
export * from "./lib/layout/SkipLink";
export * from "./lib/input/PasswordRequirements";
export * from "./lib/input/CountrySelect";
export * from "./lib/input/PhoneInput";
export * from "./lib/input/phone";
export * from "./lib/data/AdvancedTable";
export * from "./lib/data/WaitingFor";
export * from "./lib/data/StatTile";
export * from "./lib/feedback/use-error-handler";
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

@@ -51,6 +51,10 @@ interface AdvancedTableProps<T> {
rowStyle?: (row: T, index: number) => CSSProperties | undefined;
/** Makes rows clickable (adds pointer cursor). */
onRowClick?: (row: T) => void;
/** Card title, top-left. Defaults to `tableName`, which every caller already passes. */
title?: ReactNode;
/** Search box, filters, export — rendered top-right before Refresh/View. */
toolbar?: ReactNode;
}
function getByPath(obj: unknown, path?: string): unknown {
@@ -82,6 +86,8 @@ export function AdvancedTable<T extends { id?: string | number }>({
verticalSpacing = "sm",
rowStyle,
onRowClick,
title,
toolbar,
}: AdvancedTableProps<T>) {
const { t } = useTranslation();
const [visible, setVisible] = useState<boolean[]>(
@@ -94,13 +100,22 @@ export function AdvancedTable<T extends { id?: string | number }>({
});
const shownColumns = columns.filter((_, i) => visible[i] ?? true);
const heading = title ?? tableName;
const from = itemCount === 0 ? 0 : pageIndex * pageSize + 1;
const to = Math.min(itemCount, pageIndex * pageSize + data.length);
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="md">
<Paper withBorder radius="lg" p={0}>
<Group justify="space-between" px="md" py="sm" wrap="wrap" gap="sm">
<Group gap="xs">
<Text fw={600}>{""}</Text>
{heading && (
<Text fw={600} size="sm">
{heading}
</Text>
)}
</Group>
<Group gap="xs">
<Group gap="xs" wrap="wrap">
{toolbar}
{refresh && (
<Button
variant="default"
@@ -162,13 +177,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
</Group>
<Table.ScrollContainer minWidth={480}>
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
verticalSpacing={verticalSpacing}
>
<Table verticalSpacing={verticalSpacing}>
<Table.Thead>
<Table.Tr>
{shownColumns.map((col, i) => (
@@ -230,8 +239,21 @@ export function AdvancedTable<T extends { id?: string | number }>({
</Table>
</Table.ScrollContainer>
{(itemCount > pageSize || onPageSizeChange) && (
<Group justify="flex-end" mt="md">
<Group
justify="space-between"
px="md"
py="sm"
style={{ borderTop: "1px solid var(--ema-border-subtle)" }}
>
<Text size="xs" c="dimmed">
{t("common.showingRange", {
from,
to,
total: itemCount,
defaultValue: "Showing {{from}}{{to}} of {{total}}",
})}
</Text>
<Group gap="sm">
{onPageSizeChange && (
<Select
size="sm"
@@ -253,7 +275,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
/>
)}
</Group>
)}
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,83 @@
import type { ReactNode } from 'react';
import { Group, Paper, Text, ThemeIcon, UnstyledButton } from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
import { STATUS_TONE_COLOR, type StatusTone } from '@ema-platform/shared';
import './stat-tile.css';
export interface StatTileProps {
label: string;
/** The number itself. A string so callers can pass "—" while loading. */
value: ReactNode;
/** One line under the value: what the number means, or how it is trending. */
hint?: ReactNode;
icon?: Icon;
/**
* Which tone the icon carries. Tone rather than colour so a tile counting
* overdue work is the same red as an overdue badge.
*/
tone?: StatusTone;
/** Makes the whole tile a button — use when the number has somewhere to go. */
onClick?: () => void;
}
/**
* One figure on a dashboard.
*
* The four stat cards on the backoffice home were `<Card>` + two `<Text>`,
* re-declared inline on every dashboard that wanted them — so the logistics
* overview and the backoffice home showed the same kind of number at different
* sizes. The number leads, the label sits above it small and quiet, and the
* icon is decoration that carries the tone.
*
* A tile with `onClick` becomes a real button: dashboards exist to be a
* jumping-off point, and a count you cannot click is a dead end.
*/
export function StatTile({ label, value, hint, icon: TileIcon, tone, onClick }: StatTileProps) {
const body = (
<>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text size="xs" c="dimmed" tt="uppercase" fw={700} lh={1.4}>
{label}
</Text>
{TileIcon && (
<ThemeIcon
size={38}
radius="md"
variant="light"
color={tone ? STATUS_TONE_COLOR[tone] : undefined}
>
<TileIcon size={20} stroke={1.7} />
</ThemeIcon>
)}
</Group>
<Text fz={30} fw={800} lh={1.15} mt="xs">
{value}
</Text>
{hint && (
<Text size="xs" c="dimmed" mt={2}>
{hint}
</Text>
)}
</>
);
if (!onClick) {
return (
<Paper withBorder radius="lg" p="lg">
{body}
</Paper>
);
}
return (
<UnstyledButton
onClick={onClick}
className="ema-stat-tile-clickable"
style={{ display: 'block', width: '100%', height: '100%' }}
>
<Paper withBorder radius="lg" p="lg" h="100%">
{body}
</Paper>
</UnstyledButton>
);
}

View File

@@ -0,0 +1,83 @@
import { Text, Tooltip } from '@mantine/core';
import { statusToneVars } from '@ema-platform/shared';
export interface WaitingForProps {
/** When the clock started — normally the moment the applicant submitted. */
since: string | null | undefined;
/**
* Days after which the wait reads as overdue. Amber at half of it, red past
* it. Defaults to 14, which is the shortest SLA any configured licence type
* currently uses; pass the real one where a queue knows it.
*/
slaDays?: number;
/** Set once the item is decided — a closed item is not waiting for anyone. */
done?: boolean;
}
/**
* Fraction of the target elapsed before the wait reads as at-risk. Matches the
* licence queue's own `WARNING_RATIO`, so a queue using this component and one
* using `computeSla` turn amber at the same point rather than disagreeing.
*/
const WARNING_RATIO = 0.7;
/** Whole days between `since` and now, floored. Negative clock skew reads as 0. */
function daysSince(since: string): number {
const ms = Date.now() - new Date(since).getTime();
return Math.max(0, Math.floor(ms / 86_400_000));
}
/**
* How long an item has been sitting in a queue.
*
* No review queue showed this. An officer opening a list of thirty
* registrations could see what each one *was*, but not which had been waiting
* three days and which had been waiting three weeks — so the queue was worked
* top-down by whatever the sort happened to be rather than by urgency.
*
* Colour comes from the platform's status tones rather than its own scale, so
* "overdue" here is the same red as "rejected" everywhere else.
*/
export function WaitingFor({ since, slaDays = 14, done }: WaitingForProps) {
if (!since) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
const days = daysSince(since);
// A decided item keeps its elapsed time visible — useful when reviewing how
// long something took — but never coloured, because nothing is pending.
const tone = done
? 'neutral'
: days >= slaDays
? 'danger'
: days >= slaDays * WARNING_RATIO
? 'pending'
: 'neutral';
const label = days === 0 ? 'today' : `${days}d`;
return (
<Tooltip
label={
done
? `Took ${days} day${days === 1 ? '' : 's'}`
: `Waiting ${days} day${days === 1 ? '' : 's'} · ${slaDays}-day target`
}
withArrow
>
<Text
size="sm"
fw={tone === 'neutral' ? 400 : 600}
c={tone === 'neutral' ? 'dimmed' : undefined}
style={tone === 'neutral' ? undefined : { color: statusToneVars(tone).color }}
>
{label}
</Text>
</Tooltip>
);
}

View File

@@ -0,0 +1,16 @@
/*
* A clickable StatTile. The hover cue lives here rather than in inline style
* handlers so it can use a real `:hover` — a JS mouseenter/leave pair misses
* keyboard focus, and `:focus-visible` needs the app's focus ring anyway.
*/
.ema-stat-tile-clickable > * {
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.ema-stat-tile-clickable:hover > * {
border-color: var(--mantine-primary-color-filled);
}
.ema-stat-tile-clickable:focus-visible > * {
border-color: var(--mantine-primary-color-filled);
}

View File

@@ -0,0 +1,362 @@
import {
SimpleGrid,
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';
import { IconAlertTriangle, IconInbox } from '@tabler/icons-react';
import { SkipLink, MAIN_CONTENT_ID } from '../layout/SkipLink';
import { StatusBadge } from '../feedback/StatusBadge';
import { WaitingFor } from '../data/WaitingFor';
import { StatTile } from '../data/StatTile';
/**
* Fixed clock. The gallery is screenshotted by the visual suite, so a tile
* reading "3d" must read "3d" tomorrow too — `Date.now()` would rewrite the
* baseline every day.
*/
const GALLERY_NOW = '2026-08-21T00:00:00.000Z';
const daysAgo = (n: number) =>
new Date(Date.parse(GALLERY_NOW) - n * 86_400_000).toISOString();
/**
* 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 (
<>
{/* Mirrors the real app shells, so the skip-link contract is testable
without a session. */}
<SkipLink />
<Box id={MAIN_CONTENT_ID} 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>
{/* Ge'ez at heading size, where its taller ascenders clip first. */}
<Title order={2}> </Title>
<Title order={4}> Seafarer Registry</Title>
<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="Status badges">
<Group>
<StatusBadge tone="success" label="Approved" />
<StatusBadge tone="warning" label="Expiring" />
<StatusBadge tone="danger" label="Rejected" />
<StatusBadge tone="info" label="Submitted" />
<StatusBadge tone="pending" label="Corrections requested" />
<StatusBadge tone="neutral" label="Draft" />
</Group>
</Section>
<Section title="Waiting for">
<Group>
<WaitingFor since={GALLERY_NOW} />
<WaitingFor since={daysAgo(9)} />
<WaitingFor since={daysAgo(16)} />
<WaitingFor since={daysAgo(30)} done />
<WaitingFor since={null} />
</Group>
</Section>
<Section title="Stat tiles">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<StatTile label="Awaiting claim" value={12} hint="Nobody has picked these up" icon={IconInbox} tone="info" />
<StatTile label="Needs applicant action" value={3} hint="Returned for corrections" icon={IconAlertTriangle} tone="pending" />
<StatTile label="Overdue" value={2} hint="Past the turnaround target" icon={IconAlertTriangle} tone="danger" />
<StatTile label="Not permitted" value="—" hint="No access to this queue" icon={IconInbox} tone="neutral" />
</SimpleGrid>
</Section>
<Section title="Table">
<Table>
<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}
@@ -108,6 +111,7 @@ export function PageLoader({
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: '40%',
background: 'linear-gradient(90deg, #078930, #FCD116, #2563EB)',
borderRadius: '2px',
@@ -115,9 +119,13 @@ export function PageLoader({
}}
/>
<style>{`
/* Translating rather than animating \`left\`: the latter runs
layout on every frame of an animation that plays during page
loads, which is exactly when the main thread is busiest.
Reduced motion is handled globally in semantic.css. */
@keyframes ema-shimmer {
0% { left: -40%; }
100% { left: 100%; }
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
}
`}</style>
</Box>

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from 'react';
import { Badge, type BadgeProps } from '@mantine/core';
import { STATUS_TONE_COLOR, type StatusTone } from '@ema-platform/shared';
export interface StatusBadgeProps extends Omit<BadgeProps, 'color' | 'children'> {
/** The platform tone this status maps onto. */
tone: StatusTone;
/** Human label. Already translated by the caller. */
label: ReactNode;
}
/**
* One badge for every status in the platform.
*
* There were 29 files rendering `<Badge variant="light" color={MAP[status]}>`,
* each re-deciding size, variant and radius alongside the colour — which is why
* a "pending" badge in one queue did not match "pending" in the next. Callers
* now supply a tone and a label; how a status *looks* is decided once, here.
*
* Tone rather than colour on purpose: `STATUS_TONE_COLOR` is the single place a
* tone becomes a Mantine colour, so restyling the platform's idea of "danger"
* stays one edit rather than a sweep.
*/
export function StatusBadge({ tone, label, ...props }: StatusBadgeProps) {
return (
<Badge variant="light" size="sm" radius="sm" {...props} color={STATUS_TONE_COLOR[tone]}>
{label}
</Badge>
);
}

View File

@@ -157,8 +157,8 @@ function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle
height: rem(40),
borderRadius: rem(10),
opacity: item.soon ? 0.55 : 1,
color: branchActive ? 'var(--mantine-color-blue-6)' : undefined,
backgroundColor: branchActive ? 'var(--mantine-color-blue-light)' : undefined,
color: branchActive ? 'var(--mantine-primary-color-filled)' : undefined,
backgroundColor: branchActive ? 'var(--mantine-primary-color-light)' : undefined,
}}
>
<ItemIcon size={20} stroke={1.6} />
@@ -222,7 +222,10 @@ function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle
opened={hasChildren ? opened : undefined}
onChange={hasChildren ? onToggle : undefined}
onClick={() => !hasChildren && onNavigate(item)}
variant="light"
// A leaf that is the current page is filled in the app's primary; a
// branch whose child is current stays a tint, so the filled item is
// always exactly the page you are on.
variant={hasChildren ? 'light' : 'filled'}
styles={{
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
label: { fontWeight: 500 },

View File

@@ -2,17 +2,54 @@ import { Group, Stack, Text, Title } from '@mantine/core';
import type { ReactNode } from 'react';
interface PageHeaderProps {
title: string;
subtitle?: string;
/**
* The page's name. A `ReactNode` rather than a string because detail pages
* title themselves with the record they are showing, which is often a
* localised or composed value rather than a literal.
*/
title: ReactNode;
subtitle?: ReactNode;
/**
* Identifiers that belong beside the title rather than under it — a
* reference number, a status badge. Rendered inline, small and dimmed.
*/
meta?: ReactNode;
/** Right-aligned actions (e.g. a primary button). */
action?: ReactNode;
/**
* Set when the header sits directly inside a gapped `<Stack>`, which already
* spaces it from what follows — the built-in margin would double that gap.
*/
noMargin?: boolean;
}
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
/**
* The heading every backoffice page starts with.
*
* It exists because 25 pages each rolled their own: eight at `order={2}`,
* seventeen at `order={3}`, with `mb="xs"`, `mb={4}`, `mb="lg"` and nothing at
* all between them. Clicking between two screens moved the title, which is the
* single loudest signal that a product was assembled rather than designed.
*
* `order={2}` is deliberate — it is the page's only `h2`, sitting under the
* app-level `h1`, so the heading outline reads correctly for a screen reader.
* Section headings inside cards stay at `order={4}`/`{5}` and are not this
* component's business.
*/
export function PageHeader({ title, subtitle, meta, action, noMargin }: PageHeaderProps) {
return (
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
<Group
justify="space-between"
align="flex-end"
wrap="wrap"
gap="sm"
mb={noMargin ? undefined : 'lg'}
>
<Stack gap={2}>
<Title order={2}>{title}</Title>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2}>{title}</Title>
{meta}
</Group>
{subtitle && (
<Text c="dimmed" size="sm">
{subtitle}

View File

@@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next';
/** The id the skip link targets. Exported so the main region cannot drift. */
export const MAIN_CONTENT_ID = 'ema-main-content';
/**
* "Skip to main content" — the first thing a keyboard user should reach.
*
* Both apps put a sidebar of 20-plus navigation items before the page body, so
* without this, reaching the actual content means tabbing through every one of
* them on every navigation. WCAG 2.4.1 asks for a bypass; there was none.
*
* Hidden until focused, which is why it is positioned off-screen rather than
* `display: none` — the latter would make it unfocusable and defeat the point.
* Styling lives in `semantic.css` as `.ema-skip-link`.
*
* Render it as the first child of the shell, before the header.
*/
export function SkipLink() {
const { t } = useTranslation();
return (
<a className="ema-skip-link" href={`#${MAIN_CONTENT_ID}`}>
{t('a11y.skipToContent', 'Skip to main content')}
</a>
);
}