diff --git a/app/Controllers/LocaleSwitcher.php b/app/Controllers/LocaleSwitcher.php
index 490a211..25a35ff 100644
--- a/app/Controllers/LocaleSwitcher.php
+++ b/app/Controllers/LocaleSwitcher.php
@@ -11,10 +11,13 @@ use App\Core\Locale;
* Renders the language switcher widget. Pure HTML generation — no
* side effects, no header writing.
*
- * Output is semantic HTML (a
of links) with `aria-current` for the
- * active locale, `hreflang` for SEO, and `lang` for screen readers.
- * The basic list-of-locale-codes is the MVP. Sub-Issue D (responsive
- * SVG flag UI) refines the presentation.
+ * Each option gets:
+ * - an inline 24×16 SVG flag (sub-Issue D),
+ * - `hreflang` and `lang` for SEO and screen readers,
+ * - `aria-current="true"` on the active option.
+ *
+ * Active option is rendered as a (not a link) so it cannot
+ * be reactivated. All options are ≥44px touch targets via CSS.
*/
final class LocaleSwitcher
{
@@ -26,7 +29,7 @@ final class LocaleSwitcher
public function render(): string
{
- $path = $this->sanitisePath($this->currentPath);
+ $path = $this->sanitisePath($this->currentPath);
$ariaLabel = htmlspecialchars(
I18n::t('locale.switcher.aria', [], $this->currentLocale),
ENT_QUOTES,
@@ -36,17 +39,35 @@ final class LocaleSwitcher
$html = '';
foreach (Locale::SUPPORTED as $code) {
$isCurrent = $code === $this->currentLocale;
- $name = htmlspecialchars(I18n::t('locale.' . $code, [], $this->currentLocale), ENT_QUOTES, 'UTF-8');
+ $name = htmlspecialchars(
+ I18n::t('locale.' . $code, [], $this->currentLocale),
+ ENT_QUOTES,
+ 'UTF-8',
+ );
$codeAttr = htmlspecialchars($code, ENT_QUOTES, 'UTF-8');
+ $flag = self::flagSvg($code);
- $html .= '';
+ $classes = 'locale-switcher__option';
if ($isCurrent) {
- $html .= '' . $codeAttr . ' (' . $name . ') ';
+ $classes .= ' is-current';
+ }
+
+ $html .= ' ';
+ if ($isCurrent) {
+ $html .= ''
+ . $flag
+ . '' . $name . ' '
+ . ' ';
} else {
$url = '/locale?set=' . rawurlencode($code) . '&return=' . rawurlencode($path);
- $html .= ''
- . $codeAttr
- . ' (' . $name . ') '
+ $html .= ' '
+ . $flag
+ . '' . $name . ' '
. ' ';
}
$html .= ' ';
@@ -56,6 +77,49 @@ final class LocaleSwitcher
return $html;
}
+ /**
+ * Inline 24×16 SVG for the four supported locales.
+ *
+ * - DE: black/red/gold horizontal stripes (Germany)
+ * - EN: simplified Union Jack (en-GB per ADR-002)
+ * - UK: blue/yellow horizontal stripes (Ukraine)
+ * - RU: white/blue/red horizontal stripes (Russia)
+ *
+ * Decorative: marked `aria-hidden="true"` + `focusable="false"`.
+ * The full accessible name is conveyed by the visible label and
+ * the 's hreflang/lang.
+ */
+ public static function flagSvg(string $locale): string
+ {
+ $svg = match ($locale) {
+ 'de' => ''
+ . ' '
+ . ' '
+ . ' '
+ . ' ',
+ 'en' => ''
+ . ' '
+ . ' '
+ . ' '
+ . ' '
+ . ' '
+ . ' ',
+ 'uk' => ''
+ . ' '
+ . ' '
+ . ' ',
+ 'ru' => ''
+ . ' '
+ . ' '
+ . ' '
+ . ' ',
+ default => ''
+ . ' '
+ . ' ',
+ };
+ return $svg;
+ }
+
/**
* Make sure the path is safe to embed as a query string value and
* a redirect target. Drops query/fragment, keeps only the path.
diff --git a/app/Core/I18n.php b/app/Core/I18n.php
index 637ee13..7a151f2 100644
--- a/app/Core/I18n.php
+++ b/app/Core/I18n.php
@@ -48,14 +48,6 @@ final class I18n
}
$text ??= $key;
return self::interpolate($text, $params);
-
- if ($params !== []) {
- $search = array_map(static fn (string $k): string => '{' . $k . '}', array_keys($params));
- $replace = array_values($params);
- $text = str_replace($search, $replace, $text);
- }
-
- return $text;
}
/**
diff --git a/public/css/haus-schleusingen.css b/public/css/haus-schleusingen.css
index 54a4aa7..1e4252a 100755
--- a/public/css/haus-schleusingen.css
+++ b/public/css/haus-schleusingen.css
@@ -1316,3 +1316,122 @@ footer {
text-align: center;
}
}
+
+/* LOCALE SWITCHER (sub-Issue D) */
+.locale-switcher {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.locale-switcher__item {
+ display: flex;
+}
+
+.locale-switcher__option {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ min-height: 44px;
+ min-width: 44px;
+ padding: 0.45rem 0.55rem;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ text-decoration: none;
+ font-size: 0.78rem;
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ color: rgb(255 255 255 / 90%);
+ background: transparent;
+ transition:
+ background 0.2s,
+ border-color 0.2s,
+ color 0.2s,
+ transform 0.15s;
+ cursor: pointer;
+}
+
+.locale-switcher__option:hover,
+.locale-switcher__option:focus-visible {
+ background: rgb(255 255 255 / 14%);
+ border-color: rgb(255 255 255 / 30%);
+ color: var(--white);
+ transform: translateY(-1px);
+}
+
+.locale-switcher__option.is-current {
+ background: rgb(255 255 255 / 20%);
+ border-color: var(--accent-light);
+ color: var(--white);
+ cursor: default;
+}
+
+.locale-switcher__option.is-current:hover {
+ transform: none;
+}
+
+nav.scrolled .locale-switcher__option {
+ color: var(--stone);
+}
+
+nav.scrolled .locale-switcher__option:hover,
+nav.scrolled .locale-switcher__option:focus-visible {
+ background: var(--warm);
+ border-color: var(--stone);
+ color: var(--dark);
+}
+
+nav.scrolled .locale-switcher__option.is-current {
+ background: var(--cream);
+ border-color: var(--accent);
+ color: var(--dark);
+}
+
+.locale-switcher__option .flag {
+ width: 24px;
+ height: 16px;
+ flex: 0 0 24px;
+ border-radius: 2px;
+ box-shadow: 0 0 0 1px rgb(0 0 0 / 15%);
+ display: block;
+}
+
+.locale-switcher__label {
+ white-space: nowrap;
+}
+
+/* Hide labels on small screens, keep the 44px flag target */
+@media (width <= 720px) {
+ .locale-switcher__label {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+
+ .locale-switcher__option {
+ min-width: 44px;
+ padding: 0.55rem;
+ }
+}
+
+/* VISUALLY HIDDEN (a11y) */
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
diff --git a/tests/Controllers/LocaleSwitcherTest.php b/tests/Controllers/LocaleSwitcherTest.php
new file mode 100644
index 0000000..2db00e8
--- /dev/null
+++ b/tests/Controllers/LocaleSwitcherTest.php
@@ -0,0 +1,128 @@
+,
+ * - inactive locales are links to /locale?set=...&return=...,
+ * - every item contains a flag SVG,
+ * - the rendered label is in the current locale's language.
+ */
+final class LocaleSwitcherTest extends TestCase
+{
+ #[Test]
+ public function rendersFourItemsForAllSupportedLocales(): void
+ {
+ $html = (new LocaleSwitcher('en', '/'))->render();
+ self::assertStringContainsString('. The active
+ // locale renders as (no hreflang). Together all
+ // 4 must be present in either form.
+ foreach (Locale::SUPPORTED as $code) {
+ self::assertTrue(
+ str_contains($html, 'hreflang="' . $code . '"') || str_contains($html, 'lang="' . $code . '"'),
+ "locale '$code' is missing from switcher",
+ );
+ }
+ self::assertSame(4, substr_count($html, 'class="flag"'), 'expected 4 flag SVGs');
+ }
+
+ #[Test]
+ public function marksCurrentLocaleWithAriaCurrentAndSpan(): void
+ {
+ $html = (new LocaleSwitcher('uk', '/'))->render();
+ self::assertStringContainsString('is-current', $html);
+ self::assertStringContainsString('aria-current="true"', $html);
+ self::assertStringContainsString('lang="uk"', $html);
+
+ // active option must be a , not an
+ self::assertMatchesRegularExpression(
+ '/]*aria-current="true"[^>]*lang="uk"/',
+ $html,
+ );
+ }
+
+ #[Test]
+ public function inactiveLocalesAreLinksToLocaleController(): void
+ {
+ $html = (new LocaleSwitcher('de', '/foo/bar'))->render();
+ self::assertStringContainsString('href="/locale?set=en&return=%2Ffoo%2Fbar"', $html);
+ self::assertStringContainsString('href="/locale?set=uk&return=%2Ffoo%2Fbar"', $html);
+ self::assertStringContainsString('href="/locale?set=ru&return=%2Ffoo%2Fbar"', $html);
+ }
+
+ #[Test]
+ public function stripsQueryAndFragmentFromReturnPath(): void
+ {
+ $html = (new LocaleSwitcher('de', '/?lang=uk#kontakt'))->render();
+ // sanitisePath keeps only the path part
+ self::assertStringContainsString('return=%2F', $html);
+ self::assertStringNotContainsString('return=%2F%3Flang', $html);
+ self::assertStringNotContainsString('return=%2F%23kontakt', $html);
+ }
+
+ #[Test]
+ public function rejectsPathsThatDoNotStartWithSlash(): void
+ {
+ $html = (new LocaleSwitcher('de', 'https://evil.example/'))->render();
+ // sanitisePath falls back to '/'
+ self::assertStringContainsString('return=%2F', $html);
+ self::assertStringNotContainsString('evil.example', $html);
+ }
+
+ /**
+ * @return array
+ */
+ public static function flagDataProvider(): array
+ {
+ return [
+ 'DE Germany' => ['de', '#FFCC00'],
+ 'EN UnionJack' => ['en', '#C8102E'],
+ 'UK Ukraine' => ['uk', '#FFD500'],
+ 'RU Russia' => ['ru', '#D52B1E'],
+ ];
+ }
+
+ #[Test]
+ public function flagSvgReturnsValidSvgForEverySupportedLocale(): void
+ {
+ foreach (Locale::SUPPORTED as $code) {
+ $svg = LocaleSwitcher::flagSvg($code);
+ self::assertStringStartsWith('', $svg);
+ }
+ }
+
+ #[Test]
+ public function flagSvgHasFallbackForUnknownLocale(): void
+ {
+ $svg = LocaleSwitcher::flagSvg('xx');
+ self::assertStringStartsWith('', $svg);
+ }
+
+ #[Test]
+ public function ariaLabelUsesCurrentLocaleName(): void
+ {
+ $htmlDe = (new LocaleSwitcher('de', '/'))->render();
+ $htmlEn = (new LocaleSwitcher('en', '/'))->render();
+
+ self::assertStringContainsString('aria-label="Sprache wählen"', $htmlDe);
+ self::assertStringContainsString('aria-label="Choose language"', $htmlEn);
+ }
+}