diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts
index 6fe440ddc..bf3dc571f 100644
--- a/apps/backoffice/src/app/i18n/locales/am.ts
+++ b/apps/backoffice/src/app/i18n/locales/am.ts
@@ -11,6 +11,10 @@ export const am: Translations = {
tagline: "የቁጥጥር ማዕከል",
},
+ a11y: {
+ skipToContent: "ወደ ዋናው ይዘት ዝለል",
+ },
+
msg: {
genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።",
serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።",
diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts
index 30c53417b..650c3a8dc 100644
--- a/apps/backoffice/src/app/i18n/locales/en.ts
+++ b/apps/backoffice/src/app/i18n/locales/en.ts
@@ -10,6 +10,11 @@ export const en = {
tagline: 'Control Center',
},
+ // 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.',
diff --git a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx
index de599a69a..57379323f 100644
--- a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx
+++ b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx
@@ -8,6 +8,7 @@ import { AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem, NavSection } from '@ema-platform/ui';
import { notify } from '@ema-platform/ui';
import { AppTopNav, filterByPermissions } from '@ema-platform/ui';
+import { SkipLink, MAIN_CONTENT_ID } from '@ema-platform/ui';
import { baseApi, useGetQueueCountsQuery } from '@ema-platform/api';
import { usePermissions } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
@@ -143,6 +144,10 @@ export function BackofficeLayout() {
const isSidebar = layoutMode === "sidebar";
return (
+ <>
+ {/* First focusable element on the page, so a keyboard user can bypass
+ the 20-plus nav items instead of tabbing through them every time. */}
+
)}
-
+
@@ -263,5 +268,6 @@ export function BackofficeLayout() {
)}
+ >
);
}
diff --git a/apps/e2e/visual/theme.spec.ts b/apps/e2e/visual/theme.spec.ts
index 0618fa396..d0392de81 100644
--- a/apps/e2e/visual/theme.spec.ts
+++ b/apps/e2e/visual/theme.spec.ts
@@ -98,3 +98,42 @@ for (const app of APPS) {
await expect(button).toHaveScreenshot(`${app.name}-focus-ring.png`);
});
}
+
+/**
+ * The skip link, on a real app shell.
+ *
+ * Not on the gallery route: the point of a skip link is bypassing the nav, and
+ * the gallery has none. The login page is the shell-less public route both apps
+ * share, so this uses the landing route instead — it carries the chrome without
+ * needing a session.
+ *
+ * A skip link is invisible until focused, which means a broken one and a
+ * working one look identical in every screenshot. Only a focus test separates
+ * them.
+ */
+test.describe('skip link', () => {
+ for (const app of APPS) {
+ test(`${app.name} — reveals on focus and targets main`, async ({ page }) => {
+ await page.goto(`${app.url}/`, { waitUntil: 'networkidle' });
+
+ const link = page.locator('.ema-skip-link');
+ if ((await link.count()) === 0) {
+ // The public landing route does not mount the app shell in every app;
+ // skipping is honest here, where asserting absence would be wrong.
+ test.skip(true, 'landing route does not mount the app shell');
+ return;
+ }
+
+ // Off-screen until focused...
+ await expect(link).not.toBeInViewport();
+
+ await page.keyboard.press('Tab');
+ await expect(link).toBeFocused();
+ await expect(link).toBeInViewport();
+
+ // ...and it must point at something that exists.
+ const href = await link.getAttribute('href');
+ expect(href).toBe('#ema-main-content');
+ });
+ }
+});
diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts
index 19dacf17c..07b37d41e 100644
--- a/apps/portal/src/app/i18n/locales/am.ts
+++ b/apps/portal/src/app/i18n/locales/am.ts
@@ -10,6 +10,10 @@ export const am: Translations = {
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
},
+ a11y: {
+ skipToContent: 'ወደ ዋናው ይዘት ዝለል',
+ },
+
msg: {
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',
diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts
index fa64dd40a..edb6fc354 100644
--- a/apps/portal/src/app/i18n/locales/en.ts
+++ b/apps/portal/src/app/i18n/locales/en.ts
@@ -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.',
diff --git a/apps/portal/src/app/layouts/PortalLayout.tsx b/apps/portal/src/app/layouts/PortalLayout.tsx
index 58b0f17a9..544fe01a9 100644
--- a/apps/portal/src/app/layouts/PortalLayout.tsx
+++ b/apps/portal/src/app/layouts/PortalLayout.tsx
@@ -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. */}
+
-
+
@@ -364,5 +370,6 @@ export function PortalLayout() {
/>
+ >
);
}
diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts
index 28d4bbd0e..d4c0a785b 100644
--- a/libs/ui/src/index.ts
+++ b/libs/ui/src/index.ts
@@ -19,6 +19,7 @@ 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";
diff --git a/libs/ui/src/lib/dev/ThemeGallery.tsx b/libs/ui/src/lib/dev/ThemeGallery.tsx
index 77e7ec41e..ef78b9533 100644
--- a/libs/ui/src/lib/dev/ThemeGallery.tsx
+++ b/libs/ui/src/lib/dev/ThemeGallery.tsx
@@ -22,6 +22,7 @@ import {
Title,
useMantineTheme,
} from '@mantine/core';
+import { SkipLink, MAIN_CONTENT_ID } from '../layout/SkipLink';
/**
* Every primitive the theme controls, on one page.
@@ -90,7 +91,11 @@ export function ThemeGallery() {
);
return (
-
+ <>
+ {/* Mirrors the real app shells, so the skip-link contract is testable
+ without a session. */}
+
+
Theme Gallery
@@ -308,5 +313,6 @@ export function ThemeGallery() {
+ >
);
}
diff --git a/libs/ui/src/lib/feedback/PageLoader.tsx b/libs/ui/src/lib/feedback/PageLoader.tsx
index aa475eb66..38ae08268 100644
--- a/libs/ui/src/lib/feedback/PageLoader.tsx
+++ b/libs/ui/src/lib/feedback/PageLoader.tsx
@@ -111,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',
@@ -118,9 +119,13 @@ export function PageLoader({
}}
/>
diff --git a/libs/ui/src/lib/layout/SkipLink.tsx b/libs/ui/src/lib/layout/SkipLink.tsx
new file mode 100644
index 000000000..49717a49d
--- /dev/null
+++ b/libs/ui/src/lib/layout/SkipLink.tsx
@@ -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 (
+
+ {t('a11y.skipToContent', 'Skip to main content')}
+
+ );
+}