diff --git a/src/app/AppRouter.tsx b/src/app/AppRouter.tsx
index df4d012..ac559b5 100644
--- a/src/app/AppRouter.tsx
+++ b/src/app/AppRouter.tsx
@@ -29,6 +29,7 @@ function LanguageSyncer() {
function AppShell() {
const location = useLocation();
+ const { texts } = useLanguage();
useEffect(() => {
const hash = location.hash.substring(1);
@@ -44,7 +45,9 @@ function AppShell() {
return (
-
Skip to main content
+
+ {texts.accessibility.skipLinks.skipToHero}
+
diff --git a/src/app/__tests__/a11y.smoke.test.tsx b/src/app/__tests__/a11y.smoke.test.tsx
index c9a28d7..ebe6fed 100644
--- a/src/app/__tests__/a11y.smoke.test.tsx
+++ b/src/app/__tests__/a11y.smoke.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { render } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
import { axe } from 'vitest-axe';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
@@ -8,6 +8,7 @@ import { ThemeProvider } from '../../contexts/ThemeContext';
import LandingPage from '../../pages/LandingPage';
import HomePage from '../../pages/HomePage';
import AuditPage from '../../pages/AuditPage';
+import Footer from '../../components/layout/Footer';
function renderPage(children: ReactNode) {
return render(
@@ -34,4 +35,11 @@ describe('accessibility smoke test', () => {
const { container } = renderPage();
expect(await axe(container)).toHaveNoViolations();
});
+
+ it('Footer keeps the active language for the portfolio link', () => {
+ renderPage();
+
+ const link = screen.getByRole('link', { name: 'Technisches Portfolio' });
+ expect(link).toHaveAttribute('href', '/de/technical');
+ });
});
diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx
index 0aac41d..00b5a86 100644
--- a/src/components/layout/Footer.tsx
+++ b/src/components/layout/Footer.tsx
@@ -6,7 +6,8 @@ import { useLanguage } from '../../contexts/LanguageContext';
export default function Footer() {
const { texts, language } = useLanguage();
const location = useLocation();
- const isTechnicalPage = location.pathname === '/technical';
+ const isTechnicalPage = location.pathname.includes('/technical', 0);
+ const technicalLinkTarget = `/${language}/technical`;
// Email obfuscation function using config
const handleEmailClick = (e: React.MouseEvent) => {
e.preventDefault();
@@ -31,7 +32,7 @@ export default function Footer() {
{texts.footer.auditLinkText}
window.scrollTo({ top: 0, behavior: 'smooth' })}
>
diff --git a/src/hooks/useAuditForm.ts b/src/hooks/useAuditForm.ts
index 01d9dc3..bc90c27 100644
--- a/src/hooks/useAuditForm.ts
+++ b/src/hooks/useAuditForm.ts
@@ -61,15 +61,6 @@ const EMPTY_VALUES: AuditFormValues = {
*/
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
-function isUsableHttpUrl(raw: string): boolean {
- try {
- const parsed = new URL(raw);
- return parsed.protocol === 'http:' || parsed.protocol === 'https:';
- } catch {
- return false;
- }
-}
-
export function validateAuditForm(
values: AuditFormValues,
t: FormTexts
@@ -94,8 +85,6 @@ export function validateAuditForm(
const url = values.url.trim();
if (!url) {
errors.url = t.urlErrorRequired;
- } else if (!isUsableHttpUrl(url)) {
- errors.url = t.urlErrorInvalid;
}
if (!values.role) {
diff --git a/src/scss/App.scss b/src/scss/App.scss
index 165f11a..7838b8c 100644
--- a/src/scss/App.scss
+++ b/src/scss/App.scss
@@ -25,7 +25,7 @@
// Standard Theme (Light) + design system tokens
:root {
@include t.theme-vars(t.$light-theme);
-
+ overflow-x: hidden;
// Spacing scale (multiples of 4px)
--space-1: 0.25rem;
--space-2: 0.5rem;
diff --git a/src/scss/globals.scss b/src/scss/globals.scss
index 32e6d0b..ec8d5b0 100644
--- a/src/scss/globals.scss
+++ b/src/scss/globals.scss
@@ -60,22 +60,26 @@
// Skip link for accessibility
.skip-link {
position: absolute;
- top: -40px;
+ top: -9999px;
left: 6px;
- background: var(--color-primary);
- color: #000000;
- padding: 8px;
+ background: #ffffff;
+ color: #001a33;
+ padding: 0.75rem 1rem;
text-decoration: none;
z-index: 9999;
- border-radius: 4px;
- font-weight: 600;
- transition: top 0.3s;
+ border-radius: 6px;
+ font-weight: 700;
+ border: 3px solid #001a33;
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.9);
+ transition: top 0.3s ease;
- &:focus {
+ &:focus,
+ &:focus-visible {
top: 6px;
- outline: 2px solid var(--color-focus-ring);
- outline-offset: 2px;
- color: #000000;
+ outline: 4px solid var(--color-accent-deco);
+ outline-offset: 3px;
+ color: #001a33;
+ background: #ffffff;
}
}
diff --git a/src/scss/layout/topbar.scss b/src/scss/layout/topbar.scss
index a5de7ce..015ad0d 100644
--- a/src/scss/layout/topbar.scss
+++ b/src/scss/layout/topbar.scss
@@ -16,6 +16,15 @@
font-size: 1.25rem;
color: var(--color-text-on-dark);
text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ border-radius: 6px;
+ padding: 0.25rem;
+
+ &:focus-visible {
+ outline: 3px solid var(--color-accent-deco);
+ outline-offset: 3px;
+ }
}
&__logo {
@@ -62,6 +71,12 @@
transform: translateY(0);
}
+ &:focus-visible {
+ outline: 3px solid var(--color-accent-deco);
+ outline-offset: 3px;
+ border-radius: 8px;
+ }
+
&--active {
color: var(
--color-text-on-dark
@@ -99,6 +114,12 @@
background-color: var(--color-accent-deco); // ochre accent on hover
}
+ &:focus-visible {
+ outline: 3px solid var(--color-accent-deco);
+ outline-offset: 3px;
+ border-radius: 6px;
+ }
+
&.active {
&__item:nth-child(1) {
transform: rotate(45deg) translate(5px, 5px);
@@ -148,6 +169,11 @@
&:hover {
background: var(--box-shadow-active);
}
+
+ &:focus-visible {
+ outline: 3px solid var(--color-accent-deco);
+ outline-offset: 3px;
+ }
}
&__items {
@@ -168,6 +194,11 @@
&:hover {
background: var(--box-shadow-active);
}
+
+ &:focus-visible {
+ outline: 3px solid var(--color-accent-deco);
+ outline-offset: 3px;
+ }
}
}
}