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

@@ -1,3 +1,4 @@
import { type StatusTone, STATUS_TONE_COLOR } from '@ema-platform/shared';
import { useRef, useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import {
@@ -30,7 +31,7 @@ import {
IconTrash,
IconUpload,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { StatusBadge, notify } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
// ---------------------------------------------------------------------------
@@ -45,11 +46,11 @@ interface BSTRecord {
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
}
const STATUS_COLOR: Record<string, string> = {
Valid: 'teal',
Expiring: 'orange',
Expired: 'red',
'Pending Verification': 'yellow',
const STATUS_TONE: Record<string, StatusTone> = {
Valid: 'success',
Expiring: 'pending',
Expired: 'danger',
'Pending Verification': 'warning',
};
/** Progress across the five STCW A-VI/1 modules, as `/bst/my` reports it. */
@@ -289,14 +290,13 @@ export function BasicSafetyTrainingPage() {
</Text>
</div>
{record && (
<Badge
<StatusBadge
tone={STATUS_TONE[record.status]}
label={record.status}
size="lg"
variant="light"
color={STATUS_COLOR[record.status]}
leftSection={<IconShieldCheck size={14} />}
>
{record.status}
</Badge>
/>
)}
</Group>
@@ -321,7 +321,7 @@ export function BasicSafetyTrainingPage() {
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="sm">
<ThemeIcon size={48} radius="md" color={STATUS_COLOR[record.status]} variant="light">
<ThemeIcon size={48} radius="md" color={STATUS_TONE_COLOR[STATUS_TONE[record.status]]} variant="light">
<IconShieldCheck size={24} />
</ThemeIcon>
<div>
@@ -329,9 +329,7 @@ export function BasicSafetyTrainingPage() {
<Text fz="xs" c="dimmed">Combined certificate all 5 STCW components</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[record.status]} variant="light">
{record.status}
</Badge>
<StatusBadge tone={STATUS_TONE[record.status]} label={record.status} variant="light" />
</Group>
<Stack gap="xs" mb="md">

View File

@@ -72,7 +72,7 @@ export function IdentityDetailsStep(
);
}
/** Step 2 — Identity, Address, Physical Characteristics, Medical Certificate. */
/** Step 2 — Identity, Address and Physical Characteristics. */
export function ApplicantDetailsStep(p: StepProps) {
return (
<Stack gap="lg">
@@ -138,6 +138,29 @@ export function ApplicantDetailsStep(p: StepProps) {
/>
</Grid>
</Stack>
);
}
/**
* Step 4 — Emergency Contact and Medical Certificate.
*
* The medical certificate used to sit at the bottom of Applicant Details,
* which made that step 17 fields across four sections while this one held
* three. Both are short, unrelated-to-identity, and copied off a document in
* hand rather than recalled — so they pair here and the wizard's longest step
* drops by a third.
*/
export function EmergencyContactStep(p: StepProps) {
return (
<Stack gap="lg">
<SectionTitle title="Emergency Contact" description="The person EMA contacts in an emergency." />
<Grid>
<TextField {...p} name="emergencyContactName" label="Full Name" maxLength={255} />
<TextField {...p} name="emergencyContactRelationship" label="Relationship" maxLength={64} />
<TextField {...p} name="emergencyContactPhone" label="Phone Number" maxLength={32} />
</Grid>
<Divider />
<SectionTitle
title="Medical Certificate"
@@ -157,17 +180,3 @@ export function ApplicantDetailsStep(p: StepProps) {
</Stack>
);
}
/** Step 3 — Emergency Contact. */
export function EmergencyContactStep(p: StepProps) {
return (
<Stack gap="lg">
<SectionTitle title="Emergency Contact" description="The person EMA contacts in an emergency." />
<Grid>
<TextField {...p} name="emergencyContactName" label="Full Name" maxLength={255} />
<TextField {...p} name="emergencyContactRelationship" label="Relationship" maxLength={64} />
<TextField {...p} name="emergencyContactPhone" label="Phone Number" maxLength={32} />
</Grid>
</Stack>
);
}

View File

@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import {
Alert,
Badge,
Button,
Center,
Container,
@@ -20,7 +19,7 @@ import { notifications } from '@mantine/notifications';
import {
PHYSICAL_BOUNDS,
SEAFARER_REGISTRATION_FIELD_LABELS,
SEAFARER_REGISTRATION_STATUS_COLORS,
SEAFARER_REGISTRATION_STATUS_TONES,
SEAFARER_REGISTRATION_STATUS_LABELS,
extractErrorMessage,
extractValidationIssues,
@@ -33,7 +32,7 @@ import {
type SeafarerRegistration,
type ValidationIssue,
} from '@ema-platform/api';
import { splitPersonName } from '@ema-platform/ui';
import { splitPersonName, StatusBadge } from '@ema-platform/ui';
import { useCurrentProfile, type CurrentProfile } from '@ema-platform/auth';
import { useAppSelector } from '../../../store/hooks';
import { CheckboxField, type AnswerKey } from '../components/fields';
@@ -41,16 +40,19 @@ import { ApplicantDetailsStep, EmergencyContactStep, IdentityDetailsStep } from
import { RegistrationDocuments, documentSlots } from '../components/RegistrationDocuments';
import { RegistrationSummary } from '../components/RegistrationSummary';
const STEPS = ['Identity Details', 'Applicant Details', 'Emergency Contact', 'Documents', 'Review'];
const STEPS = [
{ label: 'Identity', description: 'Who you are' },
{ label: 'Details', description: 'Address & physical' },
{ label: 'Contact & Medical', description: 'Emergency & fitness' },
{ label: 'Documents', description: 'Upload evidence' },
{ label: 'Review', description: 'Check & submit' },
];
/** Which answers each step must have before "Continue" — mirrors the API's submission check. */
const REQUIRED_BY_STEP: AnswerKey[][] = [
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
[
'placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg',
'medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate',
],
[],
['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
[],
['declarationAccepted'],
];
@@ -123,7 +125,7 @@ export function SeafarerRegistrationPage() {
const registration = data?.registration ?? null;
const [start] = useStartSeafarerRegistrationMutation();
const [save] = useSaveSeafarerRegistrationMutation();
const [save, { isLoading: saving }] = useSaveSeafarerRegistrationMutation();
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
const [startError, setStartError] = useState<string | null>(null);
const started = useRef(false);
@@ -215,12 +217,16 @@ export function SeafarerRegistrationPage() {
}
}
setErrors(found);
const count = Object.keys(found).length;
if (count) {
const missingKeys = Object.keys(found) as AnswerKey[];
if (missingKeys.length) {
// Name the fields rather than counting them. "Complete 3 required fields"
// sends the applicant hunting up a step they have already scrolled past;
// the labels are what let them go straight to it.
const names = missingKeys.map((k) => SEAFARER_REGISTRATION_FIELD_LABELS[k]);
notifications.show({
color: 'red',
title: 'Incomplete',
message: `Complete ${count} required field${count > 1 ? 's' : ''} to continue.`,
title: missingKeys.length > 1 ? 'Some details are missing' : 'One detail is missing',
message: `${names.join(', ')}.`,
});
return false;
}
@@ -307,9 +313,10 @@ export function SeafarerRegistrationPage() {
<Text size="sm" c="dimmed">
{registration.registrationNumber}
</Text>
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}>
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
</Badge>
<StatusBadge
tone={SEAFARER_REGISTRATION_STATUS_TONES[registration.status]}
label={SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
/>
</Group>
</div>
{showSummary && !readOnly && (
@@ -362,8 +369,8 @@ export function SeafarerRegistrationPage() {
{!showSummary && (
<Paper withBorder p="lg" radius="md">
<Stepper active={active} onStepClick={goToStep} size="sm" mb="lg">
{STEPS.map((label) => (
<Stepper.Step key={label} label={label} />
{STEPS.map((step) => (
<Stepper.Step key={step.label} label={step.label} description={step.description} />
))}
</Stepper>
@@ -408,9 +415,11 @@ export function SeafarerRegistrationPage() {
Back
</Button>
{active < STEPS.length - 1 ? (
<Button onClick={() => goToStep(active + 1)}>Continue</Button>
<Button loading={saving} onClick={() => goToStep(active + 1)}>
Continue
</Button>
) : (
<Button color="teal" loading={submitting} disabled={readOnly} onClick={handleSubmit}>
<Button color="teal" loading={saving || submitting} disabled={readOnly} onClick={handleSubmit}>
{isAdjusting ? 'Resubmit corrections' : 'Submit registration'}
</Button>
)}

View File

@@ -1,12 +1,14 @@
import { type StatusTone } from '@ema-platform/shared';
import { Badge, Group, Text, Tooltip } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import { seaServiceDays, type MedicalCertificate, type SeaServiceRecord } from '@ema-platform/api';
const RECORD_STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'blue',
VERIFIED: 'green',
REJECTED: 'red',
const RECORD_STATUS_TONES: Record<string, StatusTone> = {
SUBMITTED: 'info',
VERIFIED: 'success',
REJECTED: 'danger',
};
export function fitnessOptions(t: TFunction) {
@@ -78,11 +80,12 @@ export function seaServiceColumns(
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{t(`seaRecords.columns.recordStatus.${row.original.status}`, {
<StatusBadge
tone={RECORD_STATUS_TONES[row.original.status]}
label={t(`seaRecords.columns.recordStatus.${row.original.status}`, {
defaultValue: row.original.status,
})}
</Badge>
/>
</Tooltip>
),
},
@@ -140,11 +143,12 @@ export function medicalColumns(
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{t(`seaRecords.columns.recordStatus.${row.original.status}`, {
<StatusBadge
tone={RECORD_STATUS_TONES[row.original.status]}
label={t(`seaRecords.columns.recordStatus.${row.original.status}`, {
defaultValue: row.original.status,
})}
</Badge>
/>
</Tooltip>
),
},

View File

@@ -1,3 +1,4 @@
import { type StatusTone } from '@ema-platform/shared';
import { useEffect, useState } from 'react';
import {
ActionIcon,
@@ -45,7 +46,7 @@ import {
IconX,
} from '@tabler/icons-react';
import { useNavigate, useParams } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import { StatusBadge, notify } from '@ema-platform/ui';
import type { Seafarer } from './SeafarerRegistryPage';
// ---------------------------------------------------------------------------
@@ -170,14 +171,14 @@ async function updateSeafarerStatus(_id: string, _status: string): Promise<void>
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const STATUS_COLOR: Record<string, string> = {
Active: 'teal', Pending: 'yellow', Suspended: 'red',
Approved: 'teal', Expired: 'red', Valid: 'teal',
Fit: 'teal', Unfit: 'red', Conditional: 'orange',
const STATUS_TONE: Record<string, StatusTone> = {
Active: 'success', Pending: 'warning', Suspended: 'danger',
Approved: 'success', Expired: 'danger', Valid: 'success',
Fit: 'success', Unfit: 'danger', Conditional: 'pending',
};
function Chip({ value }: { value: string }) {
return <Badge color={STATUS_COLOR[value] ?? 'gray'} variant="light" radius="sm" size="sm">{value}</Badge>;
return <StatusBadge tone={STATUS_TONE[value] ?? 'neutral'} label={value} variant="light" radius="sm" size="sm" />;
}
function InfoField({ label, value }: { label: string; value: string }) {
@@ -639,7 +640,12 @@ export function SeafarerProfilePage() {
</div>
</Group>
<Stack gap="xs" align="flex-end" style={{ flexShrink: 0 }}>
<Badge color={STATUS_COLOR[profile.status] ?? 'gray'} variant="filled" radius="sm">{profile.status}</Badge>
<StatusBadge
tone={STATUS_TONE[profile.status] ?? 'neutral'}
label={profile.status}
variant="filled"
radius="sm"
/>
<Button size="xs" variant="default" leftSection={<IconEdit size={13} />} onClick={() => notify.info('Edit profile — coming soon.')}>
Edit Profile
</Button>

View File

@@ -1,7 +1,7 @@
import { type StatusTone } from '@ema-platform/shared';
import { useEffect, useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
@@ -35,7 +35,7 @@ import {
IconX,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import { StatusBadge, notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types
@@ -163,26 +163,17 @@ function StatCard({
// ---------------------------------------------------------------------------
// Status badges
// ---------------------------------------------------------------------------
const STATUS_COLOR: Record<string, string> = {
Active: 'teal',
Pending: 'yellow',
Suspended: 'red',
Expired: 'orange',
Fit: 'teal',
Unfit: 'red',
const STATUS_TONE: Record<string, StatusTone> = {
Active: 'success',
Pending: 'warning',
Suspended: 'danger',
Expired: 'pending',
Fit: 'success',
Unfit: 'danger',
};
function StatusBadge({ value }: { value: string }) {
return (
<Badge
color={STATUS_COLOR[value] ?? 'gray'}
variant="light"
radius="sm"
size="sm"
>
{value}
</Badge>
);
function RegistryStatus({ value }: { value: string }) {
return <StatusBadge tone={STATUS_TONE[value] ?? 'neutral'} label={value} />;
}
// ---------------------------------------------------------------------------
@@ -239,9 +230,9 @@ export function SeafarerRegistryPage() {
<Table.Td><Text fz="sm">{s.mobile}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.region}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.registeredAt}</Text></Table.Td>
<Table.Td><StatusBadge value={s.medicalStatus} /></Table.Td>
<Table.Td><StatusBadge value={s.bookStatus} /></Table.Td>
<Table.Td><StatusBadge value={s.status} /></Table.Td>
<Table.Td><RegistryStatus value={s.medicalStatus} /></Table.Td>
<Table.Td><RegistryStatus value={s.bookStatus} /></Table.Td>
<Table.Td><RegistryStatus value={s.status} /></Table.Td>
<Table.Td>
<Menu position="bottom-end" shadow="sm" width={160} withinPortal>
<Menu.Target>

View File

@@ -1,9 +1,9 @@
import { type StatusTone } from '@ema-platform/shared';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
@@ -31,7 +31,7 @@ import {
IconTransferIn,
IconUser,
} from '@tabler/icons-react';
import { notify, PhoneInput } from '@ema-platform/ui';
import { StatusBadge, notify, PhoneInput } from '@ema-platform/ui';
import { isValidPhoneNumber } from 'libphonenumber-js';
// Minimal vessel type for the approved vessel list
@@ -117,11 +117,11 @@ const TRANSFER_REASONS = [
'Other',
];
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
const STATUS_TONE: Record<string, StatusTone> = {
Pending: 'neutral',
'Under Review': 'warning',
Approved: 'success',
Rejected: 'danger',
};
// ---------------------------------------------------------------------------
@@ -140,7 +140,11 @@ function TransferCard({ req }: { req: OwnershipTransferRequest }) {
<Text fz="xs" c="dimmed">{req.id} · Transfer to {req.newOwnerName}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[req.status] ?? 'gray'} variant="light">{req.status}</Badge>
<StatusBadge
tone={STATUS_TONE[req.status] ?? 'neutral'}
label={req.status}
variant="light"
/>
</Group>
<Divider my="xs" />
<SimpleGrid cols={2} spacing="xs">

View File

@@ -1,9 +1,9 @@
import { type StatusTone } from '@ema-platform/shared';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Alert,
Badge,
Button,
Card,
Divider,
@@ -27,7 +27,7 @@ import {
IconClockHour4,
IconTransferIn,
} from '@tabler/icons-react';
import { AdvancedTable } from '@ema-platform/ui';
import { StatusBadge, AdvancedTable } from '@ema-platform/ui';
import { inFlightColumns } from '../inFlightColumns';
import {
TERMINAL_STATUSES,
@@ -68,12 +68,12 @@ interface VesselRegistration {
expiryDate: string | null;
}
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Correction Required': 'orange',
const STATUS_TONE: Record<string, StatusTone> = {
Pending: 'neutral',
'Under Review': 'warning',
Approved: 'success',
Rejected: 'danger',
'Correction Required': 'pending',
};
// Inland vessel certificates (1)
@@ -281,9 +281,12 @@ export function VesselRegistrationPage() {
<Text fz="xs" c="dimmed">{registration.id}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[registration.status] ?? 'gray'} size="lg" variant="light">
{registration.status}
</Badge>
<StatusBadge
tone={STATUS_TONE[registration.status] ?? 'neutral'}
label={registration.status}
size="lg"
variant="light"
/>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">

View File

@@ -1,3 +1,5 @@
import { StatusBadge } from '@ema-platform/ui';
import { type StatusTone } from '@ema-platform/shared';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
@@ -30,10 +32,10 @@ import {
const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER';
const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green',
SUSPENDED: 'orange',
DEREGISTERED: 'gray',
const VESSEL_STATUS_TONES: Record<string, StatusTone> = {
REGISTERED: 'success',
SUSPENDED: 'pending',
DEREGISTERED: 'neutral',
};
/**
@@ -194,12 +196,11 @@ export function VesselTransferPage() {
{categoryLabels[vessel.category] ?? vessel.category}
</Table.Td>
<Table.Td>
<Badge
<StatusBadge
tone={VESSEL_STATUS_TONES[vessel.status]}
label={vessel.status}
size="sm"
color={VESSEL_STATUS_COLORS[vessel.status]}
>
{vessel.status}
</Badge>
/>
</Table.Td>
<Table.Td>
<Group justify="flex-end">

View File

@@ -10,6 +10,10 @@ export const am: Translations = {
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
},
a11y: {
skipToContent: 'ወደ ዋናው ይዘት ዝለል',
},
msg: {
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',

View File

@@ -9,6 +9,11 @@ export const en = {
tagline: 'Maritime licensing & certification services',
},
// Strings only assistive technology encounters.
a11y: {
skipToContent: 'Skip to main content',
},
msg: {
genericError: 'Something went wrong. Please try again.',
serverError: 'Server error. Please try again later.',

View File

@@ -26,6 +26,8 @@ import {
AppHeader,
AppSidebar,
filterByPermissions,
SkipLink,
MAIN_CONTENT_ID,
} from "@ema-platform/ui";
import type { NavItem } from "@ema-platform/ui";
import {
@@ -281,6 +283,10 @@ export function PortalLayout() {
: "?";
return (
<>
{/* First focusable element on the page, so a keyboard user can bypass
the nav instead of tabbing through it on every navigation. */}
<SkipLink />
<AppShell
header={{ height: 74 }}
navbar={{
@@ -335,7 +341,7 @@ export function PortalLayout() {
/>
</AppShell.Navbar>
<AppShell.Main>
<AppShell.Main id={MAIN_CONTENT_ID}>
<div key={location.pathname} className="ema-page-enter">
<Outlet />
</div>
@@ -364,5 +370,6 @@ export function PortalLayout() {
/>
</Drawer>
</AppShell>
</>
);
}

View File

@@ -1,4 +1,5 @@
import { createBrowserRouter, Navigate } from "react-router-dom";
import { ThemeGallery } from "@ema-platform/ui";
import { PortalLayout } from "./layouts/PortalLayout";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { LandingRoute } from "./components/LandingRoute";
@@ -57,6 +58,10 @@ export const router = createBrowserRouter([
// Public landing page — institutional overview + role-based entry points.
{ path: "/", element: <LandingRoute /> },
// Theme visual-regression surface. Unauthenticated by design — it renders
// only static primitives, so it needs no API and cannot flake.
{ path: "/__gallery", element: <ThemeGallery /> },
// Public auth pages
{ path: "/login", element: <LoginPage /> },
{ path: "/signup", element: <SignupPage /> },

View File

@@ -1,12 +1,57 @@
/* Portal global styles — loaded after Mantine's CSS, no Tailwind preflight so
it never fights Mantine's base styles. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
/* Inter carries no Ge'ez glyphs, so Noto Sans Ethiopic is loaded alongside it.
Without it every Amharic string in the app renders in whatever the OS
happens to substitute — different on Windows, macOS and Android. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap');
:root {
--ema-surface-light: #f5f8fc;
--ema-surface-dark: #0e1521;
}
/* ---------------------------------------------------------------------------
Scrollbars — themed instead of the raw OS default, so a dark page doesn't
carry a stark white scrollbar (or vice versa). Firefox via scrollbar-color,
Chrome/Safari/Edge via the ::-webkit-scrollbar-* pseudo-elements. Colors
come from Mantine's dark palette so they track the active color scheme
instead of a fixed gray.
These lived in `apps/portal/src/styles.css`, which nothing ever imported —
so the portal has been running with unthemed scrollbars while the backoffice
had these. Moved here, where they load.
--------------------------------------------------------------------------- */
* {
scrollbar-width: thin;
scrollbar-color: var(--mantine-color-gray-5) transparent;
}
[data-mantine-color-scheme='dark'] * {
scrollbar-color: var(--mantine-color-dark-3) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--mantine-color-gray-5);
border-radius: 8px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-gray-6);
}
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb {
background-color: var(--mantine-color-dark-3);
}
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-dark-2);
}
body {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;

View File

@@ -1,116 +1,13 @@
import {
createTheme,
rem,
type MantineColorsTuple,
} from '@mantine/core';
// ---- Coastal Modern palette ----------------------------------------------
// Portal-only theme. Lives here (not in @ema-platform/shared) so the backoffice
// is unaffected.
const emaPrimary: MantineColorsTuple = [
'#eef4ff', '#dce7fb', '#b6cdf4', '#8db0ee', '#6c97e9',
'#5887e6', '#4b7fe5', '#3b6ccc', '#3160b7', '#2453a2',
];
// Teal accent — the "coastal" half of the palette.
const emaTeal: MantineColorsTuple = [
'#e1fbf6', '#cdf3eb', '#9ee6d7', '#6bd9c1', '#46cdaf',
'#30c7a5', '#1fc29d', '#0aab89', '#009879', '#008368',
];
// Cool neutral grays (slightly blue-tinted) for surfaces & text.
const emaGray: MantineColorsTuple = [
'#f6f8fb', '#eceff4', '#dde2eb', '#c8d0dd', '#aab5c7',
'#8d9bb3', '#73839e', '#5c6b85', '#46546b', '#333f52',
];
export const portalTheme = createTheme({
primaryColor: 'emaPrimary',
primaryShade: { light: 6, dark: 5 },
colors: {
emaPrimary,
emaTeal,
gray: emaGray,
},
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
headings: {
fontFamily: 'Inter, sans-serif',
fontWeight: '700',
sizes: {
h1: { fontSize: rem(32), lineHeight: '1.25' },
h2: { fontSize: rem(25), lineHeight: '1.3' },
h3: { fontSize: rem(21), lineHeight: '1.35' },
h4: { fontSize: rem(17), lineHeight: '1.4' },
h5: { fontSize: rem(15), lineHeight: '1.45' },
},
},
defaultRadius: 'md',
radius: {
xs: rem(6),
sm: rem(8),
md: rem(12),
lg: rem(16),
xl: rem(22),
},
shadows: {
xs: '0 1px 2px rgba(15,23,42,0.06)',
sm: '0 2px 8px rgba(15,23,42,0.06), 0 1px 2px rgba(15,23,42,0.04)',
md: '0 8px 24px rgba(15,23,42,0.08)',
lg: '0 16px 40px rgba(15,23,42,0.12)',
xl: '0 24px 64px rgba(15,23,42,0.16)',
},
breakpoints: {
xs: '36em',
sm: '48em',
md: '62em',
lg: '75em',
xl: '88em',
},
cursorType: 'pointer',
components: {
Paper: {
defaultProps: { radius: 'lg' },
},
Card: {
defaultProps: { radius: 'lg' },
},
Button: {
defaultProps: { radius: 'md' },
styles: { root: { fontWeight: 600 } },
},
Badge: {
defaultProps: { radius: 'sm' },
},
ThemeIcon: {
defaultProps: { radius: 'md' },
},
NavLink: {
styles: { root: { borderRadius: rem(10), fontWeight: 500 } },
},
TextInput: { defaultProps: { radius: 'md' } },
Textarea: { defaultProps: { radius: 'md' } },
Select: { defaultProps: { radius: 'md' } },
PasswordInput: { defaultProps: { radius: 'md' } },
// Mantine's stock scroll wrapper (NativeScrollArea) discards the
// max-height it's handed unless scrollAreaComponent is set, so a modal
// taller than the viewport just gets clipped with no way to scroll it.
// Making the body the scrollport here fixes every Modal/Drawer at once.
Modal: {
styles: {
content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
Drawer: {
styles: {
content: { display: 'flex', flexDirection: 'column' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
},
other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
},
});
/**
* The portal theme now lives in `@ema-platform/shared`, alongside the
* backoffice theme and the base they share.
*
* It moved because the two themes had diverged into unrelated definitions —
* this one carried a full type scale, radius scale and component defaults that
* the backoffice simply lacked. Sharing the structure fixes the backoffice
* without changing the portal.
*
* This re-export is kept so the portal's MantineThemeProvider import stays
* valid. Prefer importing from `@ema-platform/shared` directly in new code.
*/
export { portalTheme } from '@ema-platform/shared';

View File

@@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
// After Mantine's CSS (it defines the variables these tokens resolve to),
// before the app's own, which may override them. Relative because the
// @ema-platform aliases are tsconfig paths, which do not carry subpaths.
import '../../../libs/shared/src/lib/theme/semantic.css';
import './app/theme/portal.css';
import './app/i18n/config';

View File

@@ -1,42 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ---------------------------------------------------------------------------
Scrollbars — themed instead of the raw OS default, so a dark page doesn't
carry a stark white scrollbar (or vice versa). Firefox via scrollbar-color,
Chrome/Safari/Edge via the ::-webkit-scrollbar-* pseudo-elements. Colors
come from Mantine's dark palette so they track the active color scheme
instead of a fixed gray.
--------------------------------------------------------------------------- */
* {
scrollbar-width: thin;
scrollbar-color: var(--mantine-color-gray-5) transparent;
}
[data-mantine-color-scheme='dark'] * {
scrollbar-color: var(--mantine-color-dark-3) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--mantine-color-gray-5);
border-radius: 8px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-gray-6);
}
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb {
background-color: var(--mantine-color-dark-3);
}
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-dark-2);
}