mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
Merge branch 'ui_ux' of github.com:Tria-plc/emaui into WorkflowChange
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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';
|
||||
|
||||
156
libs/shared/src/lib/theme/base-theme.ts
Normal file
156
libs/shared/src/lib/theme/base-theme.ts
Normal 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%)',
|
||||
},
|
||||
});
|
||||
@@ -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%)',
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
66
libs/shared/src/lib/theme/palettes.ts
Normal file
66
libs/shared/src/lib/theme/palettes.ts
Normal 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;
|
||||
29
libs/shared/src/lib/theme/portal-theme.ts
Normal file
29
libs/shared/src/lib/theme/portal-theme.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
178
libs/shared/src/lib/theme/semantic.css
Normal file
178
libs/shared/src/lib/theme/semantic.css
Normal 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;
|
||||
}
|
||||
}
|
||||
51
libs/shared/src/lib/theme/status-tone.ts
Normal file
51
libs/shared/src/lib/theme/status-tone.ts
Normal 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)`,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
83
libs/ui/src/lib/data/StatTile.tsx
Normal file
83
libs/ui/src/lib/data/StatTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
libs/ui/src/lib/data/WaitingFor.tsx
Normal file
83
libs/ui/src/lib/data/WaitingFor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
libs/ui/src/lib/data/stat-tile.css
Normal file
16
libs/ui/src/lib/data/stat-tile.css
Normal 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);
|
||||
}
|
||||
362
libs/ui/src/lib/dev/ThemeGallery.tsx
Normal file
362
libs/ui/src/lib/dev/ThemeGallery.tsx
Normal 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 0–9 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
30
libs/ui/src/lib/feedback/StatusBadge.tsx
Normal file
30
libs/ui/src/lib/feedback/StatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}
|
||||
|
||||
27
libs/ui/src/lib/layout/SkipLink.tsx
Normal file
27
libs/ui/src/lib/layout/SkipLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user