Compare commits
15 Commits
d9b4c71735
...
a765497bc9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a765497bc9 | ||
|
|
949ab201b1 | ||
|
|
38410c4ebc | ||
|
|
a879aa0165 | ||
|
|
ce87b8b531 | ||
|
|
3b4c73425a | ||
|
|
acaea97415 | ||
|
|
69e23d959a | ||
|
|
391985cd42 | ||
|
|
70691ff242 | ||
|
|
08235b0faf | ||
|
|
9a14803d26 | ||
|
|
7dd8023222 | ||
|
|
c737312ada | ||
|
|
4bc035b783 |
@@ -1,131 +1,123 @@
|
||||
name: Deploy Feature Branch to Test
|
||||
name: Deploy Feature Branch to Test (haus.test.kies-media.de)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "feature/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Branch or tag to deploy (default: HEAD)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
lint-php:
|
||||
name: PHP Syntax Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install PHP
|
||||
run: apt-get update -qq && apt-get install -y -qq php-cli > /dev/null 2>&1
|
||||
|
||||
- name: PHP Lint
|
||||
run: |
|
||||
errors=0
|
||||
while IFS= read -r file; do
|
||||
if ! php -l "$file" > /dev/null 2>&1; then
|
||||
echo "❌ Syntax error in $file"
|
||||
php -l "$file"
|
||||
errors=1
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./vendor/*")
|
||||
if [ "$errors" -eq 1 ]; then
|
||||
echo "::error::PHP lint check failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All PHP files pass syntax check"
|
||||
|
||||
lint-css:
|
||||
name: CSS Lint (stylelint)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js & stylelint
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq npm nodejs > /dev/null 2>&1
|
||||
npm install -g stylelint stylelint-config-standard stylelint-prettier > /dev/null 2>&1
|
||||
|
||||
- name: CSS Lint
|
||||
run: |
|
||||
npx stylelint "**/*.css" --config .stylelintrc.json --allow-empty-input
|
||||
echo "✅ All CSS files pass lint"
|
||||
|
||||
lint-html:
|
||||
name: HTML Lint (htmlhint)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js & htmlhint
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq npm nodejs > /dev/null 2>&1
|
||||
npm install -g htmlhint > /dev/null 2>&1
|
||||
|
||||
- name: HTML Lint
|
||||
run: |
|
||||
npx htmlhint "**/*.html" --config .htmlhintrc
|
||||
echo "✅ All HTML files pass lint"
|
||||
|
||||
deploy:
|
||||
name: Deploy to Test Environment
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-php, lint-css, lint-html]
|
||||
container:
|
||||
volumes:
|
||||
- /var/www/test/html:/deploy
|
||||
concurrency:
|
||||
group: deploy-test
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Show branch info
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
echo "=== Deploying branch: ${{ gitea.ref_name }} ==="
|
||||
echo "=== Commit: ${{ gitea.sha }} ==="
|
||||
echo "=== By: ${{ gitea.actor }} ==="
|
||||
echo "=== All lint checks passed ✅ ==="
|
||||
date
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H 188.245.242.194 >> ~/.ssh/known_hosts 2>/dev/null
|
||||
echo "✅ SSH key configured"
|
||||
|
||||
- name: Deploy to test environment
|
||||
- name: Verify SSH connectivity
|
||||
run: |
|
||||
echo "Syncing files to test environment..."
|
||||
apt-get update -qq && apt-get install -y -qq rsync > /dev/null 2>&1 || true
|
||||
|
||||
rsync -av --delete \
|
||||
ssh -o StrictHostKeyChecking=yes -i ~/.ssh/id_ed25519 \
|
||||
haustest@188.245.242.194 "echo 'SSH-OK as:' \$(whoami) 'on' \$(hostname)"
|
||||
|
||||
- name: Backup current test deployment
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_ed25519 haustest@188.245.242.194 \
|
||||
"cd /home/haustest/htdocs && \
|
||||
tar czf /home/haustest/backup-pre-deploy-\$(date +%Y%m%d-%H%M%S).tar.gz \
|
||||
haus.test.kies-media.de && \
|
||||
ls -lh /home/haustest/backup-pre-deploy-*.tar.gz | tail -1"
|
||||
|
||||
- name: Rsync to test environment
|
||||
run: |
|
||||
rsync -avz --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='tests' \
|
||||
--exclude='docs' \
|
||||
--exclude='.gitea' \
|
||||
--exclude='.husky' \
|
||||
--exclude='.prettierrc' \
|
||||
--exclude='.prettierignore' \
|
||||
--exclude='.stylelintrc.json' \
|
||||
--exclude='.htmlhintrc' \
|
||||
--exclude='.gitignore' \
|
||||
--exclude='.dockerignore' \
|
||||
--exclude='.continue' \
|
||||
--exclude='.husky' \
|
||||
--exclude='Dockerfile' \
|
||||
--exclude='.dockerignore' \
|
||||
--exclude='nginx.conf' \
|
||||
--exclude='eslint.config.js' \
|
||||
--exclude='package.json' \
|
||||
--exclude='package-lock.json' \
|
||||
--exclude='docs/' \
|
||||
--exclude='phpunit.xml' \
|
||||
--exclude='scripts' \
|
||||
--exclude='AGENTS.md' \
|
||||
--exclude='README.md' \
|
||||
--exclude='scripts/' \
|
||||
./ /deploy/
|
||||
|
||||
--exclude='CLAUDE.md' \
|
||||
--exclude='*.md' \
|
||||
--exclude='.htmlhintrc' \
|
||||
--exclude='.prettierrc' \
|
||||
--exclude='.prettierignore' \
|
||||
--exclude='.stylelintrc.json' \
|
||||
--exclude='.editorconfig' \
|
||||
--exclude='.well-known' \
|
||||
-e "ssh -i ~/.ssh/id_ed25519" \
|
||||
./ haustest@188.245.242.194:/home/haustest/htdocs/haus.test.kies-media.de/
|
||||
|
||||
echo "✅ Deployment complete!"
|
||||
|
||||
- name: Set permissions
|
||||
- name: Smoke test
|
||||
run: |
|
||||
chown -R 33:33 /deploy/ 2>/dev/null || true
|
||||
chmod -R 755 /deploy/ 2>/dev/null || true
|
||||
echo "✅ Permissions set"
|
||||
sleep 2
|
||||
echo "--- HTTP status codes ---"
|
||||
for path in "/" "/impressum" "/datenschutz"; do
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Cache-Control: no-cache" \
|
||||
"https://haus.test.kies-media.de${path}")
|
||||
echo " $path → HTTP $code"
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "❌ Smoke test failed for $path"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo "--- Locale switcher present? ---"
|
||||
if curl -sL "https://haus.test.kies-media.de/" | grep -q "class=\"locale-switcher\""; then
|
||||
echo " ✅ Locale switcher rendered"
|
||||
else
|
||||
echo " ❌ Locale switcher MISSING"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
echo "--- All 4 locales serving? ---"
|
||||
for loc in de en uk ru; do
|
||||
lang=$(curl -sL -H "Cache-Control: no-cache" \
|
||||
-b "locale=$loc" \
|
||||
"https://haus.test.kies-media.de/" \
|
||||
| grep -oE '<html lang="[a-z]+"' | head -1)
|
||||
echo " locale=$loc → $lang"
|
||||
done
|
||||
echo ""
|
||||
echo "🎉 Test deployment verified: https://haus.test.kies-media.de"
|
||||
|
||||
- name: Deployment summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "=========================================="
|
||||
echo " 🚀 Deployment Summary"
|
||||
echo "=========================================="
|
||||
echo " Branch: ${{ gitea.ref_name }}"
|
||||
echo " Commit: ${{ gitea.sha }}"
|
||||
echo " Target: http://178.104.150.0:6427/"
|
||||
echo " Lint: ✅ All checks passed"
|
||||
echo " Time: $(date)"
|
||||
echo "=========================================="
|
||||
echo "### 🚀 Deployment Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Target:** https://haus.test.kies-media.de" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Branch:** \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Commit:** \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Server:** haustest@188.245.242.194" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Review URL:** https://haus.test.kies-media.de" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -25,10 +25,13 @@ abstract class Controller
|
||||
* Render a view inside a layout.
|
||||
*
|
||||
* @param array<string,mixed> $data
|
||||
* @param string|null $forceLocale If set, overrides the locale resolved from
|
||||
* cookie/Accept-Language for this render. Used by legal pages (Impressum,
|
||||
* Datenschutz) that must be served in German only by German law.
|
||||
*/
|
||||
protected function render(string $view, array $data = [], string $layout = 'main'): void
|
||||
protected function render(string $view, array $data = [], string $layout = 'main', ?string $forceLocale = null): void
|
||||
{
|
||||
$locale = LocaleController::current();
|
||||
$locale = $forceLocale ?? LocaleController::current();
|
||||
$i18n = static fn (string $key, array $params = []): string => I18n::t($key, $params, $locale);
|
||||
|
||||
$globals = [
|
||||
|
||||
@@ -11,17 +11,18 @@ class DatenschutzController extends Controller
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$locale = LocaleController::current();
|
||||
|
||||
// Legal pages (Datenschutzerklärung) must be served in German only by GDPR / German law.
|
||||
// Force German locale for render() so <html lang="de"> + German meta are emitted
|
||||
// regardless of cookie/Accept-Language.
|
||||
$this->render('datenschutz/index', [
|
||||
'pageTitle' => I18n::t('legal.privacy_h1', [], $locale) . ' – ' . I18n::t('site.title', [], $locale),
|
||||
'pageDescription' => I18n::t('legal.privacy_h1', [], $locale) . ' – ' . I18n::t('site.title', [], $locale),
|
||||
'pageTitle' => I18n::t('legal.privacy_h1', [], 'de') . ' – ' . I18n::t('site.title', [], 'de'),
|
||||
'pageDescription' => I18n::t('legal.privacy_h1', [], 'de') . ' – ' . I18n::t('site.title', [], 'de'),
|
||||
'robots' => 'noindex',
|
||||
'canonical' => I18n::t('site.canonical_base', [], $locale) . '/datenschutz',
|
||||
'ogLocale' => Locale::toOgLocale($locale),
|
||||
'ogUrl' => I18n::t('site.canonical_base', [], $locale) . '/datenschutz',
|
||||
'ogTitle' => I18n::t('legal.privacy_h1', [], $locale),
|
||||
'ogDescription' => I18n::t('legal.privacy_h1', [], $locale),
|
||||
]);
|
||||
'canonical' => I18n::t('site.canonical_base', [], 'de') . '/datenschutz',
|
||||
'ogLocale' => Locale::toOgLocale('de'),
|
||||
'ogUrl' => I18n::t('site.canonical_base', [], 'de') . '/datenschutz',
|
||||
'ogTitle' => I18n::t('legal.privacy_h1', [], 'de'),
|
||||
'ogDescription' => I18n::t('legal.privacy_h1', [], 'de'),
|
||||
], 'main', 'de');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,17 +11,18 @@ class ImpressumController extends Controller
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$locale = LocaleController::current();
|
||||
|
||||
// Legal pages (Impressum) must be served in German only by German law (TMG §5).
|
||||
// Force German locale for render() so <html lang="de"> + German meta are emitted
|
||||
// regardless of cookie/Accept-Language.
|
||||
$this->render('impressum/index', [
|
||||
'pageTitle' => I18n::t('legal.imprint_h1', [], $locale) . ' – ' . I18n::t('site.title', [], $locale),
|
||||
'pageDescription' => I18n::t('legal.imprint_h1', [], $locale) . ' – ' . I18n::t('site.title', [], $locale),
|
||||
'pageTitle' => I18n::t('legal.imprint_h1', [], 'de') . ' – ' . I18n::t('site.title', [], 'de'),
|
||||
'pageDescription' => I18n::t('legal.imprint_h1', [], 'de') . ' – ' . I18n::t('site.title', [], 'de'),
|
||||
'robots' => 'noindex',
|
||||
'canonical' => I18n::t('site.canonical_base', [], $locale) . '/impressum',
|
||||
'ogLocale' => Locale::toOgLocale($locale),
|
||||
'ogUrl' => I18n::t('site.canonical_base', [], $locale) . '/impressum',
|
||||
'ogTitle' => I18n::t('legal.imprint_h1', [], $locale),
|
||||
'ogDescription' => I18n::t('legal.imprint_h1', [], $locale),
|
||||
]);
|
||||
'canonical' => I18n::t('site.canonical_base', [], 'de') . '/impressum',
|
||||
'ogLocale' => Locale::toOgLocale('de'),
|
||||
'ogUrl' => I18n::t('site.canonical_base', [], 'de') . '/impressum',
|
||||
'ogTitle' => I18n::t('legal.imprint_h1', [], 'de'),
|
||||
'ogDescription' => I18n::t('legal.imprint_h1', [], 'de'),
|
||||
], 'main', 'de');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,18 @@ use App\Core\Locale;
|
||||
* Renders the language switcher widget. Pure HTML generation — no
|
||||
* side effects, no header writing.
|
||||
*
|
||||
* Each option gets:
|
||||
* - an inline 24×16 SVG flag (sub-Issue D),
|
||||
* Single <details>-based dropdown shown at every viewport. The
|
||||
* trigger is a flag-sized button (24×16 SVG + tiny caret) that
|
||||
* opens to a menu of all 4 supported locales.
|
||||
*
|
||||
* Each menu option gets:
|
||||
* - an inline 24×16 SVG flag,
|
||||
* - `hreflang` and `lang` for SEO and screen readers,
|
||||
* - `aria-current="true"` on the active option.
|
||||
*
|
||||
* Active option is rendered as a <span> (not a link) so it cannot
|
||||
* be reactivated. All options are ≥44px touch targets via CSS.
|
||||
* The active option is rendered as a <span> (not a link) so it
|
||||
* cannot be reactivated. The trigger and every menu option are
|
||||
* ≥44px touch targets via CSS.
|
||||
*/
|
||||
final class LocaleSwitcher
|
||||
{
|
||||
@@ -36,7 +41,25 @@ final class LocaleSwitcher
|
||||
'UTF-8',
|
||||
);
|
||||
|
||||
$html = '<ul class="locale-switcher" role="list" aria-label="' . $ariaLabel . '">';
|
||||
$currentName = htmlspecialchars(
|
||||
I18n::t('locale.' . $this->currentLocale, [], $this->currentLocale),
|
||||
ENT_QUOTES,
|
||||
'UTF-8',
|
||||
);
|
||||
$currentCode = htmlspecialchars($this->currentLocale, ENT_QUOTES, 'UTF-8');
|
||||
$currentFlag = self::flagImg($this->currentLocale);
|
||||
|
||||
$html = '<details class="locale-switcher">';
|
||||
$html .= '<summary class="locale-switcher__trigger"'
|
||||
. ' aria-label="' . $ariaLabel . '"'
|
||||
. ' title="' . $currentName . '"'
|
||||
. '>';
|
||||
$html .= '<span class="locale-switcher__current" lang="' . $currentCode . '">';
|
||||
$html .= $currentFlag;
|
||||
$html .= '</span>';
|
||||
$html .= '<span class="locale-switcher__caret" aria-hidden="true">▾</span>';
|
||||
$html .= '</summary>';
|
||||
$html .= '<ul class="locale-switcher__menu" role="list">';
|
||||
foreach (Locale::SUPPORTED as $code) {
|
||||
$isCurrent = $code === $this->currentLocale;
|
||||
$name = htmlspecialchars(
|
||||
@@ -45,22 +68,19 @@ final class LocaleSwitcher
|
||||
'UTF-8',
|
||||
);
|
||||
$codeAttr = htmlspecialchars($code, ENT_QUOTES, 'UTF-8');
|
||||
$flag = self::flagSvg($code);
|
||||
$flag = self::flagImg($code);
|
||||
|
||||
$classes = 'locale-switcher__option';
|
||||
$html .= '<li>';
|
||||
if ($isCurrent) {
|
||||
$classes .= ' is-current';
|
||||
}
|
||||
|
||||
$html .= '<li class="locale-switcher__item">';
|
||||
if ($isCurrent) {
|
||||
$html .= '<span class="' . $classes . '" aria-current="true" lang="' . $codeAttr . '">'
|
||||
$html .= '<span class="locale-switcher__option is-current"'
|
||||
. ' aria-current="true"'
|
||||
. ' lang="' . $codeAttr . '">'
|
||||
. $flag
|
||||
. '<span class="locale-switcher__label">' . $name . '</span>'
|
||||
. '</span>';
|
||||
} else {
|
||||
$url = '/locale?set=' . rawurlencode($code) . '&return=' . rawurlencode($path);
|
||||
$html .= '<a class="' . $classes . '"'
|
||||
$html .= '<a class="locale-switcher__option"'
|
||||
. ' href="' . $url . '"'
|
||||
. ' hreflang="' . $codeAttr . '"'
|
||||
. ' lang="' . $codeAttr . '"'
|
||||
@@ -73,51 +93,50 @@ final class LocaleSwitcher
|
||||
$html .= '</li>';
|
||||
}
|
||||
$html .= '</ul>';
|
||||
$html .= '</details>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline 24×16 SVG for the four supported locales.
|
||||
* Country flag for the given locale. Renders a 24×18 <img>
|
||||
* pointing at the official flag-icons SVG asset shipped under
|
||||
* public/img/flags/. 4:3 aspect (de/gb/ua/ru), crisp at any DPI,
|
||||
* no external CDN dependency.
|
||||
*
|
||||
* - 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 <a>'s hreflang/lang.
|
||||
* Decorative: `alt=""` (the visible locale-switcher label and
|
||||
* the <a>'s `hreflang`/`lang` carry the accessible name).
|
||||
*/
|
||||
public static function flagSvg(string $locale): string
|
||||
public static function flagImg(string $locale): string
|
||||
{
|
||||
$svg = match ($locale) {
|
||||
'de' => '<svg class="flag" viewBox="0 0 24 16" aria-hidden="true" focusable="false">'
|
||||
. '<rect width="24" height="5.33" fill="#000"/>'
|
||||
. '<rect y="5.33" width="24" height="5.34" fill="#DD0000"/>'
|
||||
. '<rect y="10.67" width="24" height="5.33" fill="#FFCC00"/>'
|
||||
. '</svg>',
|
||||
'en' => '<svg class="flag" viewBox="0 0 24 16" aria-hidden="true" focusable="false">'
|
||||
. '<rect width="24" height="16" fill="#012169"/>'
|
||||
. '<path d="M0,0 L24,16 M24,0 L0,16" stroke="#fff" stroke-width="2.4"/>'
|
||||
. '<path d="M0,0 L24,16 M24,0 L0,16" stroke="#C8102E" stroke-width="1.2"/>'
|
||||
. '<path d="M12,0 V16 M0,8 H24" stroke="#fff" stroke-width="3.2"/>'
|
||||
. '<path d="M12,0 V16 M0,8 H24" stroke="#C8102E" stroke-width="1.6"/>'
|
||||
. '</svg>',
|
||||
'uk' => '<svg class="flag" viewBox="0 0 24 16" aria-hidden="true" focusable="false">'
|
||||
. '<rect width="24" height="8" fill="#005BBB"/>'
|
||||
. '<rect y="8" width="24" height="8" fill="#FFD500"/>'
|
||||
. '</svg>',
|
||||
'ru' => '<svg class="flag" viewBox="0 0 24 16" aria-hidden="true" focusable="false">'
|
||||
. '<rect width="24" height="5.33" fill="#fff"/>'
|
||||
. '<rect y="5.33" width="24" height="5.34" fill="#0039A6"/>'
|
||||
. '<rect y="10.67" width="24" height="5.33" fill="#D52B1E"/>'
|
||||
. '</svg>',
|
||||
default => '<svg class="flag" viewBox="0 0 24 16" aria-hidden="true" focusable="false">'
|
||||
. '<rect width="24" height="16" fill="#888"/>'
|
||||
. '</svg>',
|
||||
$src = self::flagSource($locale);
|
||||
// 32×24 = ~4:3, large enough that the flag is the visual
|
||||
// anchor of the option. No loading="lazy" — these are 4
|
||||
// small SVGs that must be ready the moment the <details>
|
||||
// opens (lazy would cause a flash of empty boxes).
|
||||
return '<img class="flag" src="' . $src . '" alt="" width="32" height="24">';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map our locale codes to flag-icons file names. Locale "en"
|
||||
* is en-GB per ADR-002, so the asset is "gb.svg". Anything we
|
||||
* do not know falls back to a transparent 1×1 gif so the layout
|
||||
* stays intact and the alt text (from the surrounding <a>) is
|
||||
* the only signal.
|
||||
*/
|
||||
private static function flagSource(string $locale): string
|
||||
{
|
||||
$file = match ($locale) {
|
||||
'de' => 'de',
|
||||
'en' => 'gb',
|
||||
'uk' => 'ua',
|
||||
'ru' => 'ru',
|
||||
default => null,
|
||||
};
|
||||
return $svg;
|
||||
if ($file === null) {
|
||||
return 'data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAACAkQBADs=';
|
||||
}
|
||||
return '/img/flags/' . $file . '.svg';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,22 +15,23 @@ declare(strict_types=1);
|
||||
*/
|
||||
|
||||
$gridItems = [
|
||||
['img' => 'bilder/Außenansicht-2.png', 'key' => 'gallery.exterior', 'alt' => 'gallery.alt.exterior', 'class' => 'span-2 row-2'],
|
||||
['img' => 'bilder/Wohnzimmer-1.png', 'key' => 'gallery.living', 'alt' => 'gallery.alt.living', 'class' => 'span-2 row-1'],
|
||||
['img' => 'bilder/Küche-1.png', 'key' => 'gallery.kitchen', 'alt' => 'gallery.alt.kitchen', 'class' => ''],
|
||||
['img' => 'bilder/Schlafzimmer-1.png','key' => 'gallery.bedroom', 'alt' => 'gallery.alt.bedroom', 'class' => ''],
|
||||
['img' => 'bilder/Badezimmer-1.png', 'key' => 'gallery.bath', 'alt' => 'gallery.alt.bath', 'class' => ''],
|
||||
['img' => 'bilder/Kinderzimmer-1-1.png', 'key' => 'gallery.kid1', 'alt' => 'gallery.alt.kid1', 'class' => ''],
|
||||
['img' => 'bilder/Kinderzimmer-2.png','key' => 'gallery.kid2', 'alt' => 'gallery.alt.kid2', 'class' => ''],
|
||||
['img' => 'bilder/Kinderzimmer-Detail.png','key' => 'gallery.kid_detail', 'alt' => 'gallery.alt.kid_detail', 'class' => 'span-2 row-1'],
|
||||
['img' => 'bilder/Gästezimmer.png', 'key' => 'gallery.guest', 'alt' => 'gallery.alt.guest', 'class' => ''],
|
||||
['img' => 'bilder/Wohnbereich.png', 'key' => 'gallery.area1', 'alt' => 'gallery.alt.living', 'class' => ''],
|
||||
['img' => 'bilder/Wohnbereich-Detail.png', 'key' => 'gallery.area2', 'alt' => 'gallery.alt.living', 'class' => ''],
|
||||
['img' => 'bilder/Außenansicht-1.png','key' => 'gallery.area3', 'alt' => 'gallery.alt.exterior', 'class' => 'span-2 row-1'],
|
||||
// NOTE: image filenames reflect the actual files in public/bilder/ on the server.
|
||||
// 3 items were removed (gästezimmer / wohnbereich / wohnbereich-detail)
|
||||
// because no matching files exist in the image inventory.
|
||||
['img' => 'bilder/Außenansicht-2.png', 'key' => 'gallery.exterior', 'alt' => 'gallery.alt.exterior', 'class' => 'span-2 row-2'],
|
||||
['img' => 'bilder/wohnzimmer2.png', 'key' => 'gallery.living', 'alt' => 'gallery.alt.living', 'class' => 'span-2 row-1'],
|
||||
['img' => 'bilder/Küche 1.jpg', 'key' => 'gallery.kitchen', 'alt' => 'gallery.alt.kitchen', 'class' => ''],
|
||||
['img' => 'bilder/schlafzimmer.png', 'key' => 'gallery.bedroom', 'alt' => 'gallery.alt.bedroom', 'class' => ''],
|
||||
['img' => 'bilder/Bad.jpg', 'key' => 'gallery.bath', 'alt' => 'gallery.alt.bath', 'class' => ''],
|
||||
['img' => 'bilder/Kinderzimmer 2.jpg', 'key' => 'gallery.kid1', 'alt' => 'gallery.alt.kid1', 'class' => ''],
|
||||
['img' => 'bilder/Kinderzimmer 3.jpg', 'key' => 'gallery.kid2', 'alt' => 'gallery.alt.kid2', 'class' => ''],
|
||||
['img' => 'bilder/kinderzimmer 2 2.webp', 'key' => 'gallery.kid_detail', 'alt' => 'gallery.alt.kid_detail', 'class' => 'span-2 row-1'],
|
||||
['img' => 'bilder/Außenansicht-2.png', 'key' => 'gallery.area3', 'alt' => 'gallery.alt.exterior', 'class' => 'span-2 row-1'],
|
||||
];
|
||||
?>
|
||||
<header class="hero" id="hero">
|
||||
<img src="/bilder/hero-bg.jpg" alt="" class="hero-bg" id="heroBg" loading="eager" decoding="async" fetchpriority="high">
|
||||
<img src="/bilder/Außenansicht-2.webp" alt="" class="hero-bg" id="heroBg" loading="eager" decoding="async" fetchpriority="high">
|
||||
<div class="hero-overlay" aria-hidden="true"></div>
|
||||
<div class="hero-content" id="heroContent">
|
||||
<span class="hero-tag"><?= htmlspecialchars($t('hero.tag'), ENT_QUOTES) ?></span>
|
||||
<h1 class="hero-h1">
|
||||
@@ -44,7 +45,6 @@ $gridItems = [
|
||||
<li class="hero-meta-item"><?= htmlspecialchars($t('hero.rooms'), ENT_QUOTES) ?></li>
|
||||
<li class="hero-meta-item"><?= htmlspecialchars($t('hero.floors'), ENT_QUOTES) ?></li>
|
||||
</ul>
|
||||
<a class="hero-cta" href="#galerie"><?= htmlspecialchars($t('hero.discover'), ENT_QUOTES) ?> ↓</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -86,13 +86,13 @@ $gridItems = [
|
||||
<span class="section-eyebrow"><?= htmlspecialchars($t('gallery.eyebrow'), ENT_QUOTES) ?></span>
|
||||
<h2><?= htmlspecialchars($t('gallery.h2'), ENT_QUOTES) ?></h2>
|
||||
</div>
|
||||
<div class="gallery-grid">
|
||||
<div class="masonry-grid">
|
||||
<?php foreach ($gridItems as $item): ?>
|
||||
<button type="button" class="grid-item <?= htmlspecialchars($item['class'], ENT_QUOTES) ?>"
|
||||
<button type="button" class="grid-item"
|
||||
data-img="<?= htmlspecialchars($item['img'], ENT_QUOTES) ?>"
|
||||
aria-label="<?= htmlspecialchars($t($item['key']) . $t('gallery.zoom'), ENT_QUOTES) ?>">
|
||||
<img src="/<?= htmlspecialchars($item['img'], ENT_QUOTES) ?>" alt="<?= htmlspecialchars($t($item['alt']), ENT_QUOTES) ?>" loading="lazy" decoding="async">
|
||||
<span class="grid-caption"><?= htmlspecialchars($t($item['key']), ENT_QUOTES) ?></span>
|
||||
<span class="grid-item-label"><?= htmlspecialchars($t($item['key']), ENT_QUOTES) ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
@@ -105,6 +105,13 @@ $gridItems = [
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$floorImageMap = [
|
||||
'eg' => 'bilder/grundrisse/EG.png',
|
||||
'og1' => 'bilder/grundrisse/OG 1 2.png',
|
||||
'og2' => 'bilder/grundrisse/OG 2 grundriss.png',
|
||||
'attic' => 'bilder/grundrisse/Dachboden unten.png',
|
||||
];
|
||||
|
||||
$floors = [
|
||||
['id' => 'eg', 'titleKey' => 'floors.eg.title', 'areaKey' => 'floors.eg.area', 'altKey' => 'floors.alt.eg',
|
||||
'rooms' => [
|
||||
@@ -148,7 +155,7 @@ $gridItems = [
|
||||
<span class="floor-area"><?= htmlspecialchars($t($floor['areaKey']), ENT_QUOTES) ?></span>
|
||||
</summary>
|
||||
<div class="floor-body">
|
||||
<img src="/bilder/grundriss-<?= htmlspecialchars($floor['id'], ENT_QUOTES) ?>.png"
|
||||
<img src="/<?= htmlspecialchars($floorImageMap[$floor['id']] ?? 'bilder/grundrisse/EG.png', ENT_QUOTES) ?>"
|
||||
alt="<?= htmlspecialchars($t($floor['altKey']), ENT_QUOTES) ?>"
|
||||
loading="lazy" decoding="async"
|
||||
class="floor-plan-img">
|
||||
@@ -225,17 +232,17 @@ $gridItems = [
|
||||
<span class="lage-feature-desc"><?= htmlspecialchars($t('loc.center_desc'), ENT_QUOTES) ?></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="lage-map">
|
||||
<iframe
|
||||
title="<?= htmlspecialchars($t('loc.map_title'), ENT_QUOTES) ?>"
|
||||
src="https://www.openstreetmap.org/export/embed.html?bbox=10.7535%2C50.5095%2C10.7705%2C50.5185&layer=mapnik&marker=50.5140%2C10.7620"
|
||||
loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe>
|
||||
<p class="lage-address">
|
||||
<strong><?= htmlspecialchars($t('loc.address'), ENT_QUOTES) ?>:</strong><br>
|
||||
<?= /* address HTML is XSS-safe — composed of trusted translations */ $t('loc.address_val') ?>
|
||||
</p>
|
||||
</div>
|
||||
<div class="lage-map-wrapper">
|
||||
<iframe
|
||||
title="<?= htmlspecialchars($t('loc.map_title'), ENT_QUOTES) ?>"
|
||||
src="https://www.openstreetmap.org/export/embed.html?bbox=10.7535%2C50.5095%2C10.7705%2C50.5185&layer=mapnik&marker=50.5140%2C10.7620"
|
||||
loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe>
|
||||
<p class="lage-address">
|
||||
<strong><?= htmlspecialchars($t('loc.address'), ENT_QUOTES) ?>:</strong><br>
|
||||
<?= /* address HTML is XSS-safe — composed of trusted translations */ $t('loc.address_val') ?>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -266,7 +273,7 @@ $gridItems = [
|
||||
<form class="contact-form" method="post" action="/#kontakt" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '', ENT_QUOTES) ?>">
|
||||
<input type="hidden" name="form_time" value="<?= htmlspecialchars((string) time(), ENT_QUOTES) ?>">
|
||||
<div class="form-hp" aria-hidden="true">
|
||||
<div class="hp-field" aria-hidden="true">
|
||||
<label for="website-hp"><?= htmlspecialchars($t('contact.hp_label'), ENT_QUOTES) ?></label>
|
||||
<input type="text" id="website-hp" name="website" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ $canonical = $canonical ?? $canonicalBase . ($currentPath === '/' ? '
|
||||
$siteName = I18n::t('site.name', [], $locale);
|
||||
$ogTitle = $openGraph['ogTitle'] ?? $title;
|
||||
$ogDescription = $openGraph['ogDescription'] ?? $description;
|
||||
$ogImage = $openGraph['ogImage'] ?? 'https://haus-schleusingen.de/bilder/Aussenansicht-2.webp';
|
||||
$ogImage = $openGraph['ogImage'] ?? 'https://haus-schleusingen.de/bilder/Außenansicht-2.png';
|
||||
$ogUrl = $openGraph['ogUrl'] ?? $canonical;
|
||||
$hreflangs = Locale::hreflangAlternates($currentPath === '/' ? '/' : $currentPath, $canonicalBase);
|
||||
|
||||
@@ -72,14 +72,14 @@ $navItems = [
|
||||
<meta property="og:description" content="<?= htmlspecialchars($ogDescription ?? $pageDescription ?? $t('site.description'), ENT_QUOTES) ?>">
|
||||
<meta property="og:locale" content="<?= htmlspecialchars($ogLocale ?? Locale::toOgLocale($locale), ENT_QUOTES) ?>">
|
||||
<meta property="og:site_name" content="<?= htmlspecialchars($ogSiteName ?? $t('site.name'), ENT_QUOTES) ?>">
|
||||
<meta property="og:image" content="<?= htmlspecialchars($ogImage ?? ($t('site.canonical_base') . '/bilder/hero-bg.jpg'), ENT_QUOTES) ?>">
|
||||
<meta property="og:image" content="<?= htmlspecialchars($ogImage ?? ($t('site.canonical_base') . '/bilder/Außenansicht-2.png'), ENT_QUOTES) ?>">
|
||||
|
||||
<?php if (isset($structuredData)): ?>
|
||||
<script type="application/ld+json"><?= $structuredData ?></script>
|
||||
<?php endif; ?>
|
||||
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
<link rel="stylesheet" href="/css/haus-schleusingen.css" />
|
||||
<link rel="stylesheet" href="/css/haus-schleusingen.css?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/css/haus-schleusingen.css') ?: time() ?>" />
|
||||
<?php if (isset($extraCss)): ?>
|
||||
<style><?= $extraCss ?></style>
|
||||
<?php endif; ?>
|
||||
@@ -87,11 +87,10 @@ $navItems = [
|
||||
<body>
|
||||
<a class="skip-link" href="#main"><?= htmlspecialchars($t('nav.skip'), ENT_QUOTES) ?></a>
|
||||
|
||||
<nav id="navbar" class="scrolled" aria-label="<?= htmlspecialchars($t('nav.main'), ENT_QUOTES) ?>">
|
||||
<nav id="navbar" aria-label="<?= htmlspecialchars($t('nav.main'), ENT_QUOTES) ?>">
|
||||
<div class="nav-logo">
|
||||
<a href="<?= htmlspecialchars($homeUrl) ?>">
|
||||
<a href="<?= htmlspecialchars($homeUrl) ?>" aria-label="<?= htmlspecialchars($siteName, ENT_QUOTES) ?>">
|
||||
<span class="logo-icon" aria-hidden="true">🏠</span>
|
||||
<span class="logo-text"><?= htmlspecialchars($siteName) ?></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -50,11 +50,22 @@ a:focus-visible {
|
||||
--cream: #f5f0e8;
|
||||
--warm: #e8dfd0;
|
||||
--stone: #7a7062;
|
||||
--stone-strong: #5a5043;
|
||||
--dark: #1c1a17;
|
||||
--charcoal: #2e2b26;
|
||||
--accent: #8b6914;
|
||||
--accent-light: #c49a2a;
|
||||
--accent-strong: #5a450d;
|
||||
--white: #fdfcfa;
|
||||
|
||||
/* Text variants — keep light text consistent across dark backgrounds */
|
||||
--text-muted: #6e6557;
|
||||
--text-muted-on-dark: rgb(245 240 232 / 82%);
|
||||
--text-faint-on-dark: rgb(245 240 232 / 65%);
|
||||
|
||||
/* Nav: always visible, glass effect on top of hero */
|
||||
--nav-bg: rgb(253 252 250 / 92%);
|
||||
--nav-border: rgb(232 223 208 / 70%);
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -76,7 +87,7 @@ body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* NAV */
|
||||
/* NAV — always visible, glass effect over hero */
|
||||
nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -86,38 +97,53 @@ nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 3rem;
|
||||
background: transparent;
|
||||
padding: 0.95rem 3rem;
|
||||
background: var(--nav-bg);
|
||||
backdrop-filter: saturate(180%) blur(14px);
|
||||
border-bottom: 1px solid var(--nav-border);
|
||||
transition:
|
||||
background 0.4s,
|
||||
padding 0.4s;
|
||||
padding 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
box-shadow: 0 1px 12px rgb(28 26 23 / 4%);
|
||||
}
|
||||
|
||||
nav.scrolled {
|
||||
background: var(--white);
|
||||
backdrop-filter: none;
|
||||
padding: 0.85rem 3rem;
|
||||
border-bottom: 1px solid var(--warm);
|
||||
box-shadow: 0 1px 8px rgb(0 0 0 / 6%);
|
||||
padding: 0.65rem 3rem;
|
||||
box-shadow: 0 2px 16px rgb(28 26 23 / 8%);
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
.nav-logo a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--dark);
|
||||
font-family: "Cormorant Garamond", serif;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--white);
|
||||
transition: color 0.4s;
|
||||
}
|
||||
|
||||
nav.scrolled .nav-logo {
|
||||
color: var(--dark);
|
||||
.nav-logo a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Hide logo text everywhere — logo icon is the only brand mark */
|
||||
.logo-text {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 2.5rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
@@ -126,16 +152,11 @@ nav.scrolled .nav-logo {
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
color: rgb(255 255 255 / 85%);
|
||||
color: var(--stone-strong);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
nav.scrolled .nav-links a {
|
||||
color: var(--stone);
|
||||
}
|
||||
|
||||
.nav-links a:hover,
|
||||
nav.scrolled .nav-links a:hover {
|
||||
.nav-links a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -156,12 +177,14 @@ nav.scrolled .nav-links a:hover {
|
||||
background 0.3s,
|
||||
transform 0.2s,
|
||||
box-shadow 0.3s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-cta:hover {
|
||||
background: var(--accent-light);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgb(139 105 20 / 50%);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
/* HAMBURGER */
|
||||
@@ -177,6 +200,7 @@ nav.scrolled .nav-links a:hover {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
.nav-hamburger span,
|
||||
@@ -185,13 +209,14 @@ nav.scrolled .nav-links a:hover {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 2px;
|
||||
background: var(--white);
|
||||
background: var(--dark);
|
||||
border-radius: 1px;
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.3s ease,
|
||||
background 0.4s;
|
||||
opacity 0.3s ease;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.nav-hamburger span::before,
|
||||
@@ -200,30 +225,25 @@ nav.scrolled .nav-links a:hover {
|
||||
}
|
||||
|
||||
.nav-hamburger span::before {
|
||||
transform: translateY(-7px);
|
||||
transform: translate(-50%, calc(-50% - 7px));
|
||||
}
|
||||
|
||||
.nav-hamburger span::after {
|
||||
transform: translateY(7px);
|
||||
}
|
||||
|
||||
nav.scrolled .nav-hamburger span,
|
||||
nav.scrolled .nav-hamburger span::before,
|
||||
nav.scrolled .nav-hamburger span::after {
|
||||
background: var(--dark);
|
||||
transform: translate(-50%, calc(-50% + 7px));
|
||||
}
|
||||
|
||||
.nav-hamburger.active span {
|
||||
background: transparent;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.nav-hamburger.active span::before {
|
||||
transform: rotate(45deg);
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
background: var(--dark);
|
||||
}
|
||||
|
||||
.nav-hamburger.active span::after {
|
||||
transform: rotate(-45deg);
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
background: var(--dark);
|
||||
}
|
||||
|
||||
@@ -255,10 +275,13 @@ nav.scrolled .nav-hamburger span::after {
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center 20%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center 20%;
|
||||
transform: scale(1.05);
|
||||
transition: transform 8s ease-out;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hero-bg.loaded {
|
||||
@@ -268,12 +291,15 @@ nav.scrolled .nav-hamburger span::after {
|
||||
.hero-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgb(28 26 23 / 85%) 0%,
|
||||
rgb(28 26 23 / 30%) 50%,
|
||||
rgb(28 26 23 / 10%) 100%
|
||||
rgb(20 18 15 / 92%) 0%,
|
||||
rgb(20 18 15 / 60%) 30%,
|
||||
rgb(20 18 15 / 35%) 60%,
|
||||
rgb(20 18 15 / 20%) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
@@ -303,6 +329,7 @@ nav.scrolled .nav-hamburger span::after {
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-light);
|
||||
margin-bottom: 1.25rem;
|
||||
text-shadow: 0 1px 8px rgb(0 0 0 / 40%);
|
||||
}
|
||||
|
||||
.hero-tag::before {
|
||||
@@ -320,6 +347,9 @@ nav.scrolled .nav-hamburger span::after {
|
||||
color: var(--white);
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 1.5rem;
|
||||
text-shadow:
|
||||
0 2px 24px rgb(0 0 0 / 50%),
|
||||
0 1px 3px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.hero h1 em {
|
||||
@@ -332,8 +362,9 @@ nav.scrolled .nav-hamburger span::after {
|
||||
gap: 2.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.82rem;
|
||||
color: rgb(255 255 255 / 60%);
|
||||
color: var(--text-muted-on-dark);
|
||||
letter-spacing: 0.05em;
|
||||
text-shadow: 0 1px 6px rgb(0 0 0 / 40%);
|
||||
}
|
||||
|
||||
.hero-meta strong {
|
||||
@@ -415,7 +446,7 @@ nav.scrolled .nav-hamburger span::after {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--stone);
|
||||
color: var(--text-muted-on-dark);
|
||||
}
|
||||
|
||||
/* INTRO */
|
||||
@@ -435,10 +466,10 @@ nav.scrolled .nav-hamburger span::after {
|
||||
|
||||
.section-eyebrow {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
color: var(--accent-strong);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@@ -570,7 +601,7 @@ nav.scrolled .nav-hamburger span::after {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(255 255 255 / 90%);
|
||||
color: var(--text-muted-on-dark);
|
||||
border-radius: 0 0 3px 3px;
|
||||
transform: translateY(6px);
|
||||
opacity: 0;
|
||||
@@ -806,15 +837,21 @@ nav.scrolled .nav-hamburger span::after {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--stone);
|
||||
color: var(--text-muted-on-dark);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.price-card.highlight .pc-label {
|
||||
color: rgb(255 255 255 / 70%);
|
||||
.price-card .price-label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted-on-dark);
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.pc-val {
|
||||
.price-card .price-value {
|
||||
display: block;
|
||||
font-family: "Cormorant Garamond", serif;
|
||||
font-size: 2.8rem;
|
||||
font-weight: 600;
|
||||
@@ -823,13 +860,35 @@ nav.scrolled .nav-hamburger span::after {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.pc-sub {
|
||||
.price-card .price-unit {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--stone);
|
||||
color: var(--text-muted-on-dark);
|
||||
}
|
||||
|
||||
.price-card.highlight .pc-sub {
|
||||
color: rgb(255 255 255 / 70%);
|
||||
.price-card.highlight .price-label,
|
||||
.price-card.highlight .price-unit {
|
||||
color: var(--text-muted-on-dark);
|
||||
}
|
||||
|
||||
.pricing-section .rent-notes {
|
||||
margin-top: 2.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 12rem 1fr;
|
||||
gap: 0.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.pricing-section .rent-notes dt {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted-on-dark);
|
||||
}
|
||||
|
||||
.pricing-section .rent-notes dd {
|
||||
margin: 0;
|
||||
color: var(--white);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.price-note {
|
||||
@@ -855,7 +914,7 @@ nav.scrolled .nav-hamburger span::after {
|
||||
|
||||
/* LAGE */
|
||||
.lage-section {
|
||||
padding: 6rem 3rem;
|
||||
padding: 6rem 3rem 0;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -870,58 +929,79 @@ nav.scrolled .nav-hamburger span::after {
|
||||
|
||||
.lage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--warm);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
.lage-item {
|
||||
.lage-features {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.lage-features > li {
|
||||
background: var(--white);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
align-items: flex-start;
|
||||
transition: background 0.3s;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--warm);
|
||||
border-radius: 2px;
|
||||
transition:
|
||||
background 0.3s,
|
||||
transform 0.3s,
|
||||
box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.lage-item:hover {
|
||||
.lage-features > li::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--accent);
|
||||
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 110-5 2.5 2.5 0 010 5z'/></svg>")
|
||||
center/contain no-repeat;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.lage-features > li:hover {
|
||||
background: var(--cream);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 24px rgb(28 26 23 / 8%);
|
||||
}
|
||||
|
||||
.lage-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
background: var(--cream);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
color: var(--accent);
|
||||
.lage-feature-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--dark);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.lage-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.lage-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--stone);
|
||||
.lage-feature-desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.lage-map-wrapper {
|
||||
margin-top: 3rem;
|
||||
border: 1px solid var(--warm);
|
||||
position: relative;
|
||||
left: 50%;
|
||||
right: 50%;
|
||||
width: 100vw;
|
||||
margin: 0 -50vw;
|
||||
border-top: 1px solid var(--warm);
|
||||
border-bottom: 1px solid var(--warm);
|
||||
background: var(--white);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lage-map-wrapper iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
height: 480px;
|
||||
border: 0;
|
||||
filter: grayscale(30%) contrast(1.05);
|
||||
transition: filter 0.4s ease;
|
||||
}
|
||||
@@ -930,10 +1010,34 @@ nav.scrolled .nav-hamburger span::after {
|
||||
filter: grayscale(0%) contrast(1);
|
||||
}
|
||||
|
||||
.lage-address {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 3rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lage-address strong {
|
||||
color: var(--dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.lage-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.lage-map-wrapper iframe {
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
.lage-address {
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* CONTACT */
|
||||
@@ -1000,44 +1104,74 @@ nav.scrolled .nav-hamburger span::after {
|
||||
font-size: 0.88rem;
|
||||
background: var(--white);
|
||||
border: 1px solid var(--warm);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 2px;
|
||||
padding: 0.85rem 1rem;
|
||||
color: var(--dark);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.form-field input:hover,
|
||||
.form-field textarea:hover,
|
||||
.form-field select:hover {
|
||||
border-color: var(--stone);
|
||||
}
|
||||
|
||||
.form-field input:focus,
|
||||
.form-field textarea:focus,
|
||||
.form-field select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgb(139 105 20 / 12%);
|
||||
}
|
||||
|
||||
.form-field select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' stroke='%237a7062' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 1rem center;
|
||||
padding-right: 2.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-field.full {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
.btn-submit,
|
||||
.form-submit {
|
||||
font-family: "DM Sans", sans-serif;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
background: var(--accent);
|
||||
color: var(--white);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
padding: 1.1rem;
|
||||
padding: 1.15rem;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
box-shadow: 0 2px 12px rgb(139 105 20 / 25%);
|
||||
transition:
|
||||
background 0.3s,
|
||||
transform 0.2s;
|
||||
transform 0.2s,
|
||||
box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
.btn-submit:hover,
|
||||
.form-submit:hover {
|
||||
background: var(--accent-light);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 20px rgb(139 105 20 / 40%);
|
||||
}
|
||||
|
||||
.btn-submit:active,
|
||||
.form-submit:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.form-errors {
|
||||
@@ -1089,6 +1223,25 @@ nav.scrolled .nav-hamburger span::after {
|
||||
color: var(--stone);
|
||||
}
|
||||
|
||||
.contact-direct {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.contact-direct a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.contact-direct a:hover {
|
||||
color: var(--accent-light);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.contact-details {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
@@ -1187,12 +1340,13 @@ footer {
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (width <= 900px) {
|
||||
nav {
|
||||
nav,
|
||||
nav.scrolled {
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
nav.scrolled {
|
||||
padding: 0.75rem 1.5rem;
|
||||
.nav-cta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
@@ -1203,6 +1357,11 @@ footer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Logo: keep icon, hide text on small viewports */
|
||||
.logo-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile slide-down nav */
|
||||
nav.mobile-open .nav-links {
|
||||
display: flex;
|
||||
@@ -1254,6 +1413,9 @@ footer {
|
||||
|
||||
.hero-content {
|
||||
padding: 0 1.5rem 4rem;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.facts-strip {
|
||||
@@ -1270,11 +1432,25 @@ footer {
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
|
||||
.intro-grid {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.intro-text {
|
||||
padding-right: 0;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.intro-stats {
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem 2rem;
|
||||
}
|
||||
|
||||
.intro-stats > .stat {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.masonry-grid {
|
||||
column-count: 2;
|
||||
}
|
||||
@@ -1310,6 +1486,15 @@ footer {
|
||||
padding: 4rem 1.5rem;
|
||||
}
|
||||
|
||||
.pricing-section {
|
||||
padding: 4rem 1.5rem;
|
||||
}
|
||||
|
||||
.pricing-section .rent-notes {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
@@ -1317,85 +1502,117 @@ footer {
|
||||
}
|
||||
}
|
||||
|
||||
/* LOCALE SWITCHER (sub-Issue D) */
|
||||
/* LOCALE SWITCHER — single <details> dropdown, flag-sized trigger.
|
||||
Design goals (after Martin-feedback round 3):
|
||||
- The flag is the visual anchor of every row, not a tiny icon
|
||||
drowning in padding.
|
||||
- Trigger is a 44×44 touch target with the flag centred, no
|
||||
artificial 1px outline (real flag-icons need no border).
|
||||
- Menu rows are 40px+ tall with comfortable flag+label spacing.
|
||||
- No FOUC on open: SVGs load eager (4 small files). */
|
||||
.locale-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.locale-switcher__item {
|
||||
display: flex;
|
||||
.locale-switcher__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
min-height: 44px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
color: var(--dark);
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.locale-switcher__trigger::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.locale-switcher__trigger::marker {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.locale-switcher__trigger:hover,
|
||||
.locale-switcher__trigger:focus-visible {
|
||||
background: rgb(0 0 0 / 6%);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.locale-switcher__current {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.locale-switcher__caret {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.locale-switcher[open] .locale-switcher__caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.locale-switcher__menu,
|
||||
.locale-switcher__menu li {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.locale-switcher__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 180px;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
background: var(--white);
|
||||
border: 1px solid var(--warm);
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 1px 2px rgb(0 0 0 / 6%),
|
||||
0 8px 24px rgb(0 0 0 / 14%);
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.locale-switcher__option {
|
||||
display: inline-flex;
|
||||
display: 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;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-size: 0.78rem;
|
||||
color: var(--dark);
|
||||
font-size: 0.9rem;
|
||||
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.is-current {
|
||||
background: var(--cream);
|
||||
color: var(--accent-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
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%);
|
||||
/* Flag is the visual anchor: 32×24, no border, no rounded corners
|
||||
(flags look better as crisp rectangles than as pills). */
|
||||
.locale-switcher .flag {
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
flex: 0 0 32px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1403,26 +1620,18 @@ nav.scrolled .locale-switcher__option.is-current {
|
||||
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-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.locale-switcher__option {
|
||||
min-width: 44px;
|
||||
padding: 0.55rem;
|
||||
}
|
||||
/* Trigger on transparent nav (top-of-page): white caret on dark bg */
|
||||
nav:not(.scrolled) .locale-switcher__trigger {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
nav:not(.scrolled) .locale-switcher__trigger:hover,
|
||||
nav:not(.scrolled) .locale-switcher__trigger:focus-visible {
|
||||
background: rgb(255 255 255 / 12%);
|
||||
}
|
||||
|
||||
/* Flag stays the same regardless of nav state — SVG defines its own colours */
|
||||
|
||||
/* VISUALLY HIDDEN (a11y) */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
|
||||
5
public/img/flags/de.svg
Normal file
5
public/img/flags/de.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-de" viewBox="0 0 640 480">
|
||||
<path fill="#fc0" d="M0 320h640v160H0z"/>
|
||||
<path fill="#000001" d="M0 0h640v160H0z"/>
|
||||
<path fill="red" d="M0 160h640v160H0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 221 B |
7
public/img/flags/gb.svg
Normal file
7
public/img/flags/gb.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-gb" viewBox="0 0 640 480">
|
||||
<path fill="#012169" d="M0 0h640v480H0z"/>
|
||||
<path fill="#FFF" d="m75 0 244 181L562 0h78v62L400 241l240 178v61h-80L320 301 81 480H0v-60l239-178L0 64V0z"/>
|
||||
<path fill="#C8102E" d="m424 281 216 159v40L369 281zm-184 20 6 35L54 480H0zM640 0v3L391 191l2-44L590 0zM0 0l239 176h-60L0 42z"/>
|
||||
<path fill="#FFF" d="M241 0v480h160V0zM0 160v160h640V160z"/>
|
||||
<path fill="#C8102E" d="M0 193v96h640v-96zM273 0v480h96V0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 504 B |
5
public/img/flags/ru.svg
Normal file
5
public/img/flags/ru.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ru" viewBox="0 0 640 480">
|
||||
<path fill="#fff" d="M0 0h640v160H0z"/>
|
||||
<path fill="#0039a6" d="M0 160h640v160H0z"/>
|
||||
<path fill="#d52b1e" d="M0 320h640v160H0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 225 B |
6
public/img/flags/ua.svg
Normal file
6
public/img/flags/ua.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ua" viewBox="0 0 640 480">
|
||||
<g fill-rule="evenodd" stroke-width="1pt">
|
||||
<path fill="gold" d="M0 0h640v480H0z"/>
|
||||
<path fill="#0057b8" d="M0 0h640v240H0z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 232 B |
@@ -11,19 +11,28 @@ use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Renders the language switcher widget and checks that:
|
||||
* - 4 items, one per supported locale,
|
||||
* - exactly one <details class="locale-switcher"> dropdown,
|
||||
* - 4 menu items, one per supported locale,
|
||||
* - the active locale is marked aria-current="true" and is a <span>,
|
||||
* - inactive locales are <a> links to /locale?set=...&return=...,
|
||||
* - every item contains a flag SVG,
|
||||
* - the trigger and every menu item contain a flag SVG,
|
||||
* - the rendered label is in the current locale's language.
|
||||
*/
|
||||
final class LocaleSwitcherTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function rendersFourItemsForAllSupportedLocales(): void
|
||||
public function rendersSingleDropdownForAllSupportedLocales(): void
|
||||
{
|
||||
$html = (new LocaleSwitcher('en', '/'))->render();
|
||||
self::assertStringContainsString('<ul class="locale-switcher"', $html);
|
||||
|
||||
// exactly one <details class="locale-switcher"> (no -mobile suffix, no desktop <ul>)
|
||||
self::assertStringContainsString('<details class="locale-switcher">', $html);
|
||||
self::assertStringNotContainsString('locale-switcher-mobile', $html);
|
||||
self::assertStringNotContainsString('<ul class="locale-switcher"', $html);
|
||||
self::assertStringNotContainsString('locale-switcher__item', $html);
|
||||
|
||||
// the menu lists all 4 supported locales
|
||||
self::assertSame(4, substr_count($html, 'class="locale-switcher__option'), 'expected 4 menu options');
|
||||
|
||||
// The 3 inactive locales render as <a hreflang="..">. The active
|
||||
// locale renders as <span lang=".."> (no hreflang). Together all
|
||||
@@ -34,7 +43,9 @@ final class LocaleSwitcherTest extends TestCase
|
||||
"locale '$code' is missing from switcher",
|
||||
);
|
||||
}
|
||||
self::assertSame(4, substr_count($html, 'class="flag"'), 'expected 4 flag SVGs');
|
||||
|
||||
// 1 flag in trigger + 4 flags in menu = 5 total
|
||||
self::assertSame(5, substr_count($html, 'class="flag"'), 'expected 5 flag SVGs (1 trigger + 4 menu)');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -86,34 +97,35 @@ final class LocaleSwitcherTest extends TestCase
|
||||
public static function flagDataProvider(): array
|
||||
{
|
||||
return [
|
||||
'DE Germany' => ['de', '#FFCC00'],
|
||||
'EN UnionJack' => ['en', '#C8102E'],
|
||||
'UK Ukraine' => ['uk', '#FFD500'],
|
||||
'RU Russia' => ['ru', '#D52B1E'],
|
||||
'DE Germany' => ['de', 'de.svg'],
|
||||
'EN en-GB' => ['en', 'gb.svg'],
|
||||
'UK Ukraine' => ['uk', 'ua.svg'],
|
||||
'RU Russia' => ['ru', 'ru.svg'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function flagSvgReturnsValidSvgForEverySupportedLocale(): void
|
||||
public function flagImgReturnsValidImgForEverySupportedLocale(): void
|
||||
{
|
||||
foreach (Locale::SUPPORTED as $code) {
|
||||
$svg = LocaleSwitcher::flagSvg($code);
|
||||
self::assertStringStartsWith('<svg', $svg);
|
||||
self::assertStringContainsString('viewBox="0 0 24 16"', $svg);
|
||||
self::assertStringContainsString('aria-hidden="true"', $svg);
|
||||
self::assertStringContainsString('focusable="false"', $svg);
|
||||
self::assertStringContainsString('class="flag"', $svg);
|
||||
self::assertStringEndsWith('</svg>', $svg);
|
||||
$img = LocaleSwitcher::flagImg($code);
|
||||
self::assertStringStartsWith('<img', $img);
|
||||
self::assertStringContainsString('class="flag"', $img);
|
||||
self::assertStringContainsString('width="32" height="24"', $img);
|
||||
self::assertStringContainsString('alt=""', $img);
|
||||
self::assertStringEndsWith('>', $img);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function flagSvgHasFallbackForUnknownLocale(): void
|
||||
public function flagImgHasFallbackForUnknownLocale(): void
|
||||
{
|
||||
$svg = LocaleSwitcher::flagSvg('xx');
|
||||
self::assertStringStartsWith('<svg', $svg);
|
||||
self::assertStringContainsString('class="flag"', $svg);
|
||||
self::assertStringEndsWith('</svg>', $svg);
|
||||
$img = LocaleSwitcher::flagImg('xx');
|
||||
self::assertStringStartsWith('<img', $img);
|
||||
self::assertStringContainsString('class="flag"', $img);
|
||||
// 1×1 transparent gif keeps the layout stable even when the
|
||||
// locale code is not one of our four.
|
||||
self::assertStringContainsString('data:image/gif', $img);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -125,4 +137,17 @@ final class LocaleSwitcherTest extends TestCase
|
||||
self::assertStringContainsString('aria-label="Sprache wählen"', $htmlDe);
|
||||
self::assertStringContainsString('aria-label="Choose language"', $htmlEn);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function triggerContainsCurrentLocaleFlag(): void
|
||||
{
|
||||
// The closed dropdown shows the current locale's flag in the trigger
|
||||
$html = (new LocaleSwitcher('de', '/'))->render();
|
||||
// The first <img class="flag"> in the document is the trigger and it
|
||||
// must point at the German flag asset under /img/flags/.
|
||||
$deFlag = LocaleSwitcher::flagImg('de');
|
||||
$pos = strpos($html, $deFlag);
|
||||
self::assertNotFalse($pos, 'expected German flag <img> in the trigger (first <img class="flag"> in document)');
|
||||
self::assertStringContainsString('src="/img/flags/de.svg"', $deFlag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ final class HomeViewA11yTest extends TestCase
|
||||
{
|
||||
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: false);
|
||||
|
||||
self::assertStringContainsString('class="form-hp"', $html);
|
||||
self::assertStringContainsString('class="hp-field"', $html);
|
||||
self::assertStringContainsString('aria-hidden="true"', $html);
|
||||
self::assertStringContainsString('tabindex="-1"', $html);
|
||||
self::assertStringContainsString('autocomplete="off"', $html);
|
||||
|
||||
Reference in New Issue
Block a user