# Villa Mare Montenegro Demo Site Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a production-ready, interactive static demo website for "Villa Mare Montenegro" at `demo.directbalkan.com` that sells DirectBalkan's direct-booking website product to accommodation owners and serves as the reusable template architecture for future client sites.

**Architecture:** Static HTML/CSS/vanilla-JS, no build step, no framework. `demo.html` loads `js/app.js` as an ES module, which imports focused single-purpose modules (`config.js`, `i18n.js`, `placeholder.js`, `calendar.js`, `gallery.js`, `form.js`). One stylesheet (`css/style.css`) driven by CSS custom-property design tokens with a `[data-theme="dark"]` override block.

**Tech Stack:** Semantic HTML5, modern CSS (custom properties, `clamp()`, Grid/Flexbox), vanilla ES modules. No dependencies, no bundler, no test framework.

## Global Constraints

- No frameworks/libraries unless explicitly justified — plain HTML/CSS/JS only.
- Mobile-first responsive layout; primary breakpoints at `640px` and `1024px`.
- Design tokens only — no hardcoded colors/spacing/radii outside the `:root` token block and its `[data-theme="dark"]` override.
- Primary accent `#C1633A` (terracotta), secondary accent `#5C6B3F` (olive), background `#FAF6F1`, text `#2A2420` (light mode); dark-mode equivalents defined in Task 1.
- No emoji anywhere in UI copy or as design elements.
- No `localStorage`/`sessionStorage` — current language and theme choice live in memory/DOM only for the session.
- No real external image URLs — every image slot is an inline SVG/CSS placeholder with a `data-replace-with` caption (see Task 1 `js/placeholder.js`).
- No real external tracking scripts, no Google Maps embed, no real Airbnb/Booking.com data.
- `siteConfig.booking.makeWebhookUrl` starts empty; when empty, form submission simulates success and logs a console notice that it is a simulation.
- Respect `prefers-reduced-motion` for all animation/transition code.
- Every interactive element keyboard-operable with visible focus states; minimum 44×44px touch targets; one `<h1>` per page with correct heading hierarchy; skip link present.
- No console errors, no horizontal overflow, at any viewport from 375px up.
- Supported languages: `en`, `de`, `nl`. Current language is a module-level JS variable, not persisted.

---

## File Structure

```
demo.html
css/
  style.css
js/
  config.js        # siteConfig — all client-swappable data (Task 1)
  placeholder.js    # inline SVG placeholder-image renderer (Task 1)
  i18n.js           # translations dict + setLanguage()/t() (Task 3)
  calendar.js       # availability calendar widget (Task 7)
  gallery.js        # image grid + lightbox (Task 5)
  form.js           # booking request form + webhook simulate/send (Task 8)
  app.js            # wiring: init calls, theme toggle, nav, scroll reveal, WhatsApp link (Task 2, extended through Task 13)
assets/
  logo.svg          # inline-friendly Villa Mare wordmark/icon (Task 2)
README.md           # Task 15
```

---

### Task 1: Project scaffold, design tokens, config data, placeholder-image helper

**Files:**
- Create: `css/style.css`
- Create: `js/config.js`
- Create: `js/placeholder.js`
- Create: `demo.html` (minimal shell only — full markup added in later tasks)

**Interfaces:**
- Produces: `siteConfig` (named export from `js/config.js`) with shape:
  ```
  siteConfig = {
    demoMode: boolean,
    property: { name, type, location, guests, bedrooms, bathrooms, currency },
    amenities: { pool, seaView, wifi, ac, parking, terrace, checkIn, checkOut },
    contact: { email, whatsapp, phone },
    branding: { primaryColor, secondaryColor, logoText },
    languages: string[],
    booking: { makeWebhookUrl, demoMessage },
    availability: { [isoDate: string]: 'available' | 'unavailable' },
    gallery: Array<{ id: string, alt: string, replaceWith: string, variant: number }>,
    reviews: Array<{ id: number, name: string, location: string, rating: number, text: string }>
  }
  ```
- Produces: `renderPlaceholder({ alt, replaceWith, variant })` (named export from `js/placeholder.js`) → returns an HTML string of an inline SVG placeholder `<figure>` with duotone gradient + topographic lines + overlay caption. `variant` (0-4) selects a deterministic line pattern so placeholders aren't visually identical.
- Produces: CSS custom properties on `:root` and `[data-theme="dark"]` (see step 2) — every later task's CSS must use these tokens, never raw hex/px values.

- [ ] **Step 1: Create `js/config.js` with full site configuration**

```javascript
// js/config.js
// Single source of truth for all client-editable data. To create a new
// accommodation site from this template, edit this file and the content
// arrays here — no other file needs structural changes.

function toISODate(date) {
  return date.toISOString().slice(0, 10);
}

function isInAnyRange(isoDate, ranges) {
  return ranges.some(([startIso, endIso]) => isoDate >= startIso && isoDate <= endIso);
}

function buildDemoAvailability() {
  const availability = {};
  const start = new Date(Date.UTC(2026, 7, 1)); // 2026-08-01
  const end = new Date(Date.UTC(2026, 9, 0));   // 2026-09-30
  const unavailableRanges = [
    ['2026-08-14', '2026-08-18'],
    ['2026-08-24', '2026-08-25'],
    ['2026-09-05', '2026-09-10'],
    ['2026-09-20', '2026-09-22']
  ];

  for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
    const iso = toISODate(d);
    availability[iso] = isInAnyRange(iso, unavailableRanges) ? 'unavailable' : 'available';
  }
  return availability;
}

export const siteConfig = {
  demoMode: true,

  property: {
    name: 'Villa Mare Montenegro',
    type: 'Villa',
    location: 'Budva, Montenegro',
    guests: 6,
    bedrooms: 3,
    bathrooms: 2,
    currency: 'EUR'
  },

  amenities: {
    pool: true,
    seaView: true,
    wifi: true,
    ac: true,
    parking: true,
    terrace: true,
    checkIn: '15:00',
    checkOut: '10:00'
  },

  contact: {
    email: 'demo@example.com',
    whatsapp: '38267000000',
    phone: '+382 67 000 000'
  },

  branding: {
    primaryColor: '#C1633A',
    secondaryColor: '#5C6B3F',
    logoText: 'Villa Mare'
  },

  languages: ['en', 'de', 'nl'],

  booking: {
    makeWebhookUrl: '',
    demoMessage: true
  },

  availability: buildDemoAvailability(),

  gallery: [
    { id: 'exterior', alt: 'Villa Mare exterior with pool and sea view at sunset', replaceWith: 'Exterior photo, pool and sea view, sunset', variant: 0 },
    { id: 'pool', alt: 'Private swimming pool on the villa terrace', replaceWith: 'Pool photo, daytime, wide angle', variant: 1 },
    { id: 'living', alt: 'Bright open-plan living and dining area', replaceWith: 'Living room photo, natural light', variant: 2 },
    { id: 'bedroom1', alt: 'Master bedroom with sea view', replaceWith: 'Master bedroom photo', variant: 3 },
    { id: 'bedroom2', alt: 'Second bedroom with twin beds', replaceWith: 'Second bedroom photo', variant: 4 },
    { id: 'kitchen', alt: 'Fully equipped kitchen', replaceWith: 'Kitchen photo', variant: 0 },
    { id: 'terrace', alt: 'Private terrace with outdoor seating', replaceWith: 'Terrace photo, evening', variant: 1 },
    { id: 'view', alt: 'View of Budva old town from the villa', replaceWith: 'View toward Budva old town', variant: 2 }
  ],

  reviews: [
    { id: 1, name: 'Anna Müller', location: 'Munich, Germany', rating: 5, text: 'Traumhafte Villa mit unglaublichem Meerblick! Die Kommunikation mit dem Gastgeber war unkompliziert und schnell — viel einfacher als über eine Buchungsplattform.' },
    { id: 2, name: 'Sanne de Vries', location: 'Utrecht, Netherlands', rating: 5, text: 'Prachtig zwembad en een fantastisch terras. We konden rechtstreeks met de eigenaar afstemmen over onze aankomsttijd, heel prettig.' },
    { id: 3, name: 'James Whitfield', location: 'Bristol, United Kingdom', rating: 4, text: 'Lovely villa, great location close to the old town. Booking directly meant we could ask questions before committing — will book this way again.' }
  ]
};
```

- [ ] **Step 2: Create `css/style.css` with the design-token layer, reset, and base typography**

```css
/* css/style.css */

/* ===== Design tokens ===== */
:root {
  --color-primary: #C1633A;
  --color-primary-dark: #A24E2C;
  --color-secondary: #5C6B3F;
  --color-bg: #FAF6F1;
  --color-bg-alt: #F1E9DD;
  --color-surface: #FFFFFF;
  --color-text: #2A2420;
  --color-text-muted: #6B5F55;
  --color-border: #E4D9CA;
  --color-danger: #B3401E;
  --color-available: var(--color-secondary);
  --color-unavailable: #B7ACA0;
  --color-selected: var(--color-primary);

  --font-display: Georgia, 'Iowan Old Style', 'Palatino Linotype', Palatino, serif;
  --font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;

  --text-xs: clamp(0.75rem, 0.72rem + 0.15vw, 0.8125rem);
  --text-sm: clamp(0.875rem, 0.84rem + 0.15vw, 0.9375rem);
  --text-base: clamp(1rem, 0.96rem + 0.2vw, 1.0625rem);
  --text-lg: clamp(1.125rem, 1.06rem + 0.3vw, 1.25rem);
  --text-xl: clamp(1.375rem, 1.28rem + 0.4vw, 1.625rem);
  --text-2xl: clamp(1.75rem, 1.6rem + 0.7vw, 2.25rem);
  --text-3xl: clamp(2.25rem, 2rem + 1.2vw, 3rem);
  --text-4xl: clamp(2.75rem, 2.3rem + 2vw, 4rem);

  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-5: 1.5rem;
  --space-6: 2rem;
  --space-7: 3rem;
  --space-8: 4rem;

  --section-padding-y: clamp(3rem, 6vw, 7rem);
  --section-padding-x: clamp(1.25rem, 5vw, 3rem);
  --container-max: 1180px;

  --radius-sm: 6px;
  --radius-md: 10px;
  --radius-lg: 16px;

  --shadow-sm: 0 1px 3px rgba(42, 36, 32, 0.08);
  --shadow-md: 0 8px 24px rgba(42, 36, 32, 0.12);

  --transition-base: 200ms ease;
  --transition-slow: 400ms cubic-bezier(0.22, 1, 0.36, 1);
}

[data-theme="dark"] {
  --color-primary: #E0855A;
  --color-primary-dark: #C1633A;
  --color-secondary: #8AA05F;
  --color-bg: #201B17;
  --color-bg-alt: #2A2420;
  --color-surface: #2F2822;
  --color-text: #F3ECE3;
  --color-text-muted: #C4B7A8;
  --color-border: #443A32;
  --color-danger: #E0714A;
  --color-unavailable: #574C42;
}

/* ===== Reset ===== */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
body {
  margin: 0;
  background: var(--color-bg);
  color: var(--color-text);
  font-family: var(--font-body);
  font-size: var(--text-base);
  line-height: 1.6;
  overflow-x: hidden;
}
img { max-width: 100%; display: block; }
h1, h2, h3, h4 { font-family: var(--font-display); line-height: 1.2; margin: 0; color: var(--color-text); }
h1 { font-size: var(--text-4xl); }
h2 { font-size: var(--text-3xl); }
h3 { font-size: var(--text-xl); }
p { margin: 0; }
a { color: var(--color-primary); }
button { font-family: inherit; }

.container {
  max-width: var(--container-max);
  margin-inline: auto;
  padding-inline: var(--section-padding-x);
}
.section { padding-block: var(--section-padding-y); }
.section-alt { background: var(--color-bg-alt); }

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--color-primary);
  color: #fff;
  padding: var(--space-3) var(--space-5);
  z-index: 1000;
  border-radius: var(--radius-sm);
}
.skip-link:focus {
  left: var(--space-4);
  top: var(--space-4);
}

:focus-visible {
  outline: 3px solid var(--color-primary);
  outline-offset: 2px;
}

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}
```

- [ ] **Step 3: Create `js/placeholder.js`**

```javascript
// js/placeholder.js
// Renders a styled inline-SVG placeholder for image slots that don't yet
// have a real photo. Each placeholder carries a `data-replace-with` note
// so swapping in production photography later is a content edit only.

const PATTERNS = [
  'M0,60 C 40,20 80,100 120,60 S 200,20 240,60',
  'M0,20 C 60,80 100,0 160,60 S 220,20 240,60',
  'M0,80 C 50,30 90,90 140,40 S 200,80 240,40',
  'M0,40 C 40,90 90,10 130,70 S 210,10 240,70',
  'M0,70 C 45,10 95,90 150,30 S 205,90 240,30'
];

export function renderPlaceholder({ alt, replaceWith, variant = 0, aspectRatio = '4 / 3' }) {
  const pattern = PATTERNS[variant % PATTERNS.length];
  const gradientId = `ph-grad-${variant}-${Math.random().toString(36).slice(2, 8)}`;

  return `
    <figure class="placeholder-image" style="aspect-ratio: ${aspectRatio};" role="img" aria-label="${escapeAttr(alt)}">
      <svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid slice" aria-hidden="true" focusable="false">
        <defs>
          <linearGradient id="${gradientId}" x1="0" y1="0" x2="1" y2="1">
            <stop offset="0%" stop-color="var(--color-primary)" />
            <stop offset="100%" stop-color="var(--color-secondary)" />
          </linearGradient>
        </defs>
        <rect width="240" height="160" fill="url(#${gradientId})" />
        <path d="${pattern}" fill="none" stroke="rgba(255,255,255,0.35)" stroke-width="2" />
        <path d="${pattern}" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="2" transform="translate(0,30)" />
      </svg>
      <figcaption class="placeholder-image__caption">Replace: ${escapeHtml(replaceWith)}</figcaption>
    </figure>
  `;
}

function escapeHtml(str) {
  return str.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

function escapeAttr(str) {
  return escapeHtml(str);
}
```

- [ ] **Step 4: Add placeholder-image CSS to `css/style.css`**

```css
/* ===== Placeholder images ===== */
.placeholder-image {
  position: relative;
  margin: 0;
  overflow: hidden;
  border-radius: var(--radius-md);
  background: var(--color-bg-alt);
}
.placeholder-image svg { width: 100%; height: 100%; display: block; }
.placeholder-image__caption {
  position: absolute;
  left: var(--space-3);
  bottom: var(--space-3);
  background: rgba(42, 36, 32, 0.55);
  color: #fff;
  font-size: var(--text-xs);
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-sm);
  backdrop-filter: blur(4px);
}
```

- [ ] **Step 5: Create the minimal `demo.html` shell**

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Villa Mare Montenegro — Direct Booking Demo</title>
  <link rel="stylesheet" href="css/style.css" />
</head>
<body>
  <a class="skip-link" href="#main">Skip to main content</a>
  <main id="main">
    <p class="container" style="padding-block: 4rem;">Scaffold OK — sections added in later tasks.</p>
  </main>
  <script type="module" src="js/app.js"></script>
</body>
</html>
```

- [ ] **Step 6: Create a placeholder `js/app.js` so the module script resolves**

```javascript
// js/app.js
import { siteConfig } from './config.js';

console.log('Villa Mare demo scaffold loaded', siteConfig.property.name);
```

- [ ] **Step 7: Manual verification**

Open `demo.html` directly in a browser (double-click or `start demo.html` on Windows). Confirm:
- Page loads with no console errors (open DevTools console).
- Console prints `Villa Mare demo scaffold loaded Villa Mare Montenegro`.
- Page background is the warm off-white token color, not browser default white.
- No horizontal scrollbar at 375px width (use DevTools device toolbar).

- [ ] **Step 8: Initialize git and commit**

```bash
git init
git add demo.html css/style.css js/config.js js/placeholder.js js/app.js
git commit -m "Scaffold demo site: design tokens, config data, placeholder-image helper"
```

---

### Task 2: Header, navigation, logo, language switcher shell, skip-link wiring

**Files:**
- Create: `assets/logo.svg`
- Modify: `demo.html`
- Modify: `css/style.css`
- Modify: `js/app.js`

**Interfaces:**
- Consumes: `siteConfig.branding.logoText`, `siteConfig.languages` (from Task 1's `config.js`).
- Produces: header markup with `data-lang-switch="en|de|nl"` buttons and `data-i18n="..."` attributes on all translatable nav/CTA text (i18n wiring lands in Task 3, but the attributes must exist now so Task 3 only has to write `i18n.js`, not touch the header again). Produces: `initMobileNav()` function (named export from `js/app.js`) that later tasks/Task 13 build on.

- [ ] **Step 1: Create `assets/logo.svg`**

```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 40" width="160" height="40" role="img" aria-label="Villa Mare logo">
  <circle cx="20" cy="20" r="14" fill="#C1633A" />
  <path d="M10 22 L20 12 L30 22" fill="none" stroke="#FAF6F1" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
  <rect x="13" y="22" width="14" height="9" fill="#FAF6F1" />
  <text x="42" y="26" font-family="Georgia, serif" font-size="18" fill="#2A2420">Villa Mare</text>
</svg>
```

- [ ] **Step 2: Replace the `demo.html` scaffold body with the header + empty section anchors**

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Villa Mare Montenegro — Direct Booking Demo</title>
  <link rel="stylesheet" href="css/style.css" />
</head>
<body>
  <a class="skip-link" href="#main">Skip to main content</a>

  <div class="demo-banner" role="note">
    <p data-i18n="demoBanner">Interactive demo — availability and reviews are examples.</p>
  </div>

  <header class="site-header" id="site-header">
    <div class="container site-header__inner">
      <a class="logo" href="#main" aria-label="Villa Mare Montenegro, back to top">
        <img src="assets/logo.svg" alt="" width="160" height="40" />
      </a>

      <button class="nav-toggle" id="nav-toggle" aria-expanded="false" aria-controls="primary-nav" aria-label="Open menu">
        <span aria-hidden="true">☰</span>
      </button>

      <nav class="primary-nav" id="primary-nav" aria-label="Primary">
        <ul>
          <li><a href="#accommodation" data-i18n="nav.accommodation">Accommodation</a></li>
          <li><a href="#gallery" data-i18n="nav.gallery">Gallery</a></li>
          <li><a href="#location" data-i18n="nav.location">Location</a></li>
          <li><a href="#faq" data-i18n="nav.faq">FAQ</a></li>
          <li><a href="#contact" data-i18n="nav.contact">Contact</a></li>
        </ul>

        <div class="lang-switch" role="group" aria-label="Language">
          <button type="button" data-lang-switch="en" aria-pressed="true">EN</button>
          <button type="button" data-lang-switch="de" aria-pressed="false">DE</button>
          <button type="button" data-lang-switch="nl" aria-pressed="false">NL</button>
        </div>

        <a class="btn btn-primary" href="#availability" data-i18n="nav.checkAvailability">Check availability</a>
      </nav>
    </div>
  </header>

  <main id="main">
    <section id="accommodation"><div class="container"><p>Hero/quick-facts/intro — added in Task 4.</p></div></section>
    <section id="gallery"><div class="container"><p>Gallery — added in Task 5.</p></div></section>
    <section id="amenities"><div class="container"><p>Amenities — added in Task 6.</p></div></section>
    <section id="availability"><div class="container"><p>Calendar — added in Task 7.</p></div></section>
    <section id="contact"><div class="container"><p>Booking form — added in Task 8.</p></div></section>
    <section id="whatsapp"><div class="container"><p>WhatsApp CTA — added in Task 9.</p></div></section>
    <section id="location"><div class="container"><p>Location — added in Task 10.</p></div></section>
    <section id="reviews"><div class="container"><p>Reviews — added in Task 11.</p></div></section>
    <section id="faq"><div class="container"><p>FAQ — added in Task 11.</p></div></section>
    <section id="final-cta"><div class="container"><p>Final CTA — added in Task 12.</p></div></section>
  </main>

  <footer class="site-footer"><div class="container"><p>Footer — added in Task 12.</p></div></footer>

  <script type="module" src="js/app.js"></script>
</body>
</html>
```

- [ ] **Step 3: Add header/nav CSS to `css/style.css`**

```css
/* ===== Demo banner ===== */
.demo-banner {
  background: var(--color-bg-alt);
  color: var(--color-text-muted);
  text-align: center;
  font-size: var(--text-xs);
  padding: var(--space-2) var(--space-4);
  border-bottom: 1px solid var(--color-border);
}

/* ===== Header ===== */
.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  background: rgba(250, 246, 241, 0.85);
  backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--color-border);
}
[data-theme="dark"] .site-header { background: rgba(32, 27, 23, 0.85); }

.site-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding-block: var(--space-3);
  gap: var(--space-4);
}
.logo { display: inline-flex; }

.nav-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  background: none;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  font-size: var(--text-lg);
  cursor: pointer;
}

.primary-nav {
  display: flex;
  align-items: center;
  gap: var(--space-6);
}
.primary-nav ul {
  list-style: none;
  display: flex;
  gap: var(--space-5);
  margin: 0;
  padding: 0;
}
.primary-nav a {
  text-decoration: none;
  color: var(--color-text);
  font-size: var(--text-sm);
  min-height: 44px;
  display: inline-flex;
  align-items: center;
}
.primary-nav a:hover { color: var(--color-primary); }

.lang-switch { display: flex; gap: var(--space-1); }
.lang-switch button {
  min-width: 44px;
  min-height: 36px;
  border: 1px solid var(--color-border);
  background: var(--color-surface);
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: var(--text-xs);
  color: var(--color-text-muted);
}
.lang-switch button[aria-pressed="true"] {
  background: var(--color-primary);
  border-color: var(--color-primary);
  color: #fff;
}

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: 0 var(--space-5);
  border-radius: var(--radius-sm);
  text-decoration: none;
  font-size: var(--text-sm);
  font-weight: 600;
  border: 1px solid transparent;
  cursor: pointer;
  transition: transform var(--transition-base), box-shadow var(--transition-base), background var(--transition-base);
}
.btn:hover { transform: translateY(-1px); box-shadow: var(--shadow-sm); }
.btn-primary { background: var(--color-primary); color: #fff; }
.btn-primary:hover { background: var(--color-primary-dark); }
.btn-secondary { background: transparent; color: var(--color-text); border-color: var(--color-border); }

@media (max-width: 1023px) {
  .nav-toggle { display: inline-flex; }
  .primary-nav {
    position: fixed;
    inset: 0 0 0 30%;
    flex-direction: column;
    align-items: flex-start;
    justify-content: flex-start;
    gap: var(--space-6);
    background: var(--color-surface);
    padding: var(--space-8) var(--space-5);
    transform: translateX(100%);
    transition: transform var(--transition-slow);
  }
  .primary-nav[data-open="true"] { transform: translateX(0); box-shadow: var(--shadow-md); }
  .primary-nav ul { flex-direction: column; gap: var(--space-4); }
}
@media (min-width: 1024px) {
  .nav-toggle { display: none; }
}
```

- [ ] **Step 4: Add mobile nav + language-switch scaffolding to `js/app.js`**

```javascript
// js/app.js
import { siteConfig } from './config.js';

export function initMobileNav() {
  const toggle = document.getElementById('nav-toggle');
  const nav = document.getElementById('primary-nav');
  if (!toggle || !nav) return;

  toggle.addEventListener('click', () => {
    const isOpen = nav.getAttribute('data-open') === 'true';
    nav.setAttribute('data-open', String(!isOpen));
    toggle.setAttribute('aria-expanded', String(!isOpen));
  });

  nav.querySelectorAll('a').forEach((link) => {
    link.addEventListener('click', () => {
      nav.setAttribute('data-open', 'false');
      toggle.setAttribute('aria-expanded', 'false');
    });
  });
}

function init() {
  console.log('Villa Mare demo loaded', siteConfig.property.name);
  initMobileNav();
}

document.addEventListener('DOMContentLoaded', init);
```

- [ ] **Step 5: Manual verification**

Open `demo.html`. Confirm:
- Header sticks to top on scroll with a blurred translucent background.
- At ≤1023px width, the hamburger button appears; clicking it slides the nav panel in from the right and toggles `aria-expanded`.
- Tabbing through the header with keyboard reaches logo → nav links → language buttons → CTA, each with a visible focus ring.
- Language buttons visually show EN as active (filled) by default.
- No console errors.

- [ ] **Step 6: Commit**

```bash
git add assets/logo.svg demo.html css/style.css js/app.js
git commit -m "Add sticky header, mobile nav, language switcher shell"
```

---

### Task 3: i18n system — translations dictionary and language switching

**Files:**
- Create: `js/i18n.js`
- Modify: `js/app.js`

**Interfaces:**
- Consumes: `siteConfig.languages` from `config.js`.
- Produces (named exports from `js/i18n.js`): `t(key: string) -> string`, `setLanguage(lang: 'en'|'de'|'nl') -> void`, `getCurrentLang() -> string`, `applyTranslations() -> void`, `initI18n() -> void`. Dispatches a `window` `CustomEvent('languagechange', { detail: { lang } })` on every language switch — later tasks (`calendar.js`, `form.js`) listen for this to re-render dynamic (non-DOM-attribute) text.
- All later tasks' HTML must use `data-i18n="dot.path"` for text content, `data-i18n-placeholder="dot.path"` for input placeholders, and `data-i18n-aria-label="dot.path"` for aria-labels — `applyTranslations()` only handles these three attribute names.

- [ ] **Step 1: Create `js/i18n.js` with the full translation dictionary**

```javascript
// js/i18n.js
import { siteConfig } from './config.js';

export const translations = {
  en: {
    demoBanner: 'Interactive demo — availability and reviews are examples.',
    nav: { accommodation: 'Accommodation', gallery: 'Gallery', location: 'Location', faq: 'FAQ', contact: 'Contact', checkAvailability: 'Check availability' },
    hero: {
      badge: 'Demo accommodation — Villa Mare Montenegro is a fictional example property.',
      headline: 'A private villa above the Budva coastline',
      subheadline: 'Direct booking, no commission, and a host who replies in minutes — not a support ticket.',
      ctaPrimary: 'Check availability',
      ctaWhatsapp: 'Ask on WhatsApp',
      trustDirect: 'Direct request',
      trustNoCommission: 'No booking commission through this website'
    },
    quickFacts: { guests: 'guests', bedrooms: 'bedrooms', pool: 'Private pool', seaView: 'Sea view', wifi: 'Wi-Fi', parking: 'Parking' },
    intro: {
      heading: 'About Villa Mare',
      body: 'Set on a quiet hillside above Budva, Villa Mare pairs a private pool and uninterrupted sea views with easy access to the old town. Every enquiry goes straight to the host — no call centre, no waiting on a booking platform to forward your message.'
    },
    gallery: { heading: 'Gallery', lightboxClose: 'Close image', lightboxPrev: 'Previous image', lightboxNext: 'Next image', counter: 'Image {current} of {total}' },
    amenities: {
      heading: 'Amenities',
      pool: 'Private pool', seaView: 'Sea view', wifi: 'Free Wi-Fi', ac: 'Air conditioning', parking: 'Free parking', terrace: 'Private terrace',
      checkIn: 'Check-in from', checkOut: 'Check-out until'
    },
    availability: {
      heading: 'Check availability', demoNotice: 'This is example availability for the demo.',
      monthPrev: 'Previous month', monthNext: 'Next month',
      statusAvailable: 'Available', statusSelected: 'Selected', statusUnavailable: 'Unavailable',
      arrivalLabel: 'Arrival', departureLabel: 'Departure', selectRange: 'Select your arrival and departure dates',
      clearSelection: 'Clear selection'
    },
    booking: {
      heading: 'Request a reservation', subheading: 'Send a direct request — the host confirms availability personally.',
      arrivalLabel: 'Arrival date', departureLabel: 'Departure date', guestsLabel: 'Number of guests',
      nameLabel: 'Full name', emailLabel: 'Email', whatsappLabel: 'WhatsApp number', messageLabel: 'Message',
      messagePlaceholder: 'Anything the host should know?', submitButton: 'Send request', submitting: 'Sending…',
      successTitle: 'Request sent', successMessage: 'Your request has been sent. The host will contact you to confirm availability.',
      errors: {
        required: 'This field is required.', invalidEmail: 'Enter a valid email address.',
        invalidDates: 'Choose an arrival and departure date.', departureBeforeArrival: 'Departure must be after arrival.',
        networkError: 'Something went wrong sending your request. Please try again.'
      }
    },
    whatsapp: { heading: 'Prefer WhatsApp?', body: 'Send the host a direct message with your dates and party size.', cta: 'Ask on WhatsApp' },
    location: {
      heading: 'Location', body: 'Villa Mare sits on a hillside above Budva, Montenegro — close enough to walk to the old town, far enough for a quiet night.',
      distanceBeach: '5 minute drive to the beach', distanceOldTown: '10 minute drive to Budva old town', distanceAirport: '25 minutes from Tivat Airport',
      mapLinkLabel: 'Open in Google Maps'
    },
    reviews: { heading: 'What guests say', demoLabel: 'Demo review' },
    faq: {
      heading: 'Frequently asked questions',
      q1: 'Is direct booking possible?', a1: 'Yes — every request goes straight to the host, with no third-party booking platform involved.',
      q2: 'What is the check-in time?', a2: 'Check-in is from 15:00. Earlier arrival can be arranged directly with the host.',
      q3: 'Is the pool private?', a3: 'Yes, the pool is private and reserved exclusively for guests staying at the villa.',
      q4: 'Is parking available?', a4: 'Yes, free private parking is available on-site.',
      q5: 'How do I send a reservation request?', a5: 'Use the reservation form or WhatsApp button on this page — the host replies directly.',
      q6: 'Are pets allowed?', a6: 'Small pets are welcome on request — please mention this in your reservation message.'
    },
    finalCta: { heading: 'Would you like a direct booking website like this?', cta: 'Create my accommodation website' },
    footer: {
      contact: 'Contact', privacy: 'Privacy policy', terms: 'Terms', demoLabel: 'Demo site',
      poweredBy: 'Powered by DirectBalkan', themeToggleLight: 'Switch to dark mode', themeToggleDark: 'Switch to light mode'
    }
  },

  de: {
    demoBanner: 'Interaktive Demo — Verfügbarkeit und Bewertungen sind Beispiele.',
    nav: { accommodation: 'Unterkunft', gallery: 'Galerie', location: 'Lage', faq: 'FAQ', contact: 'Kontakt', checkAvailability: 'Verfügbarkeit prüfen' },
    hero: {
      badge: 'Demo-Unterkunft — Villa Mare Montenegro ist ein fiktives Beispielobjekt.',
      headline: 'Eine private Villa oberhalb der Küste von Budva',
      subheadline: 'Direkte Buchung, keine Provision, und ein Gastgeber, der in Minuten antwortet — kein Support-Ticket.',
      ctaPrimary: 'Verfügbarkeit prüfen', ctaWhatsapp: 'Per WhatsApp fragen',
      trustDirect: 'Direkte Anfrage', trustNoCommission: 'Keine Buchungsprovision über diese Website'
    },
    quickFacts: { guests: 'Gäste', bedrooms: 'Schlafzimmer', pool: 'Privater Pool', seaView: 'Meerblick', wifi: 'WLAN', parking: 'Parkplatz' },
    intro: {
      heading: 'Über Villa Mare',
      body: 'Ruhig auf einem Hügel oberhalb von Budva gelegen, verbindet Villa Mare einen privaten Pool und einen uneingeschränkten Meerblick mit kurzen Wegen zur Altstadt. Jede Anfrage geht direkt an den Gastgeber — kein Callcenter, keine Wartezeit über eine Buchungsplattform.'
    },
    gallery: { heading: 'Galerie', lightboxClose: 'Bild schließen', lightboxPrev: 'Vorheriges Bild', lightboxNext: 'Nächstes Bild', counter: 'Bild {current} von {total}' },
    amenities: {
      heading: 'Ausstattung',
      pool: 'Privater Pool', seaView: 'Meerblick', wifi: 'Kostenloses WLAN', ac: 'Klimaanlage', parking: 'Kostenloser Parkplatz', terrace: 'Private Terrasse',
      checkIn: 'Check-in ab', checkOut: 'Check-out bis'
    },
    availability: {
      heading: 'Verfügbarkeit prüfen', demoNotice: 'Dies ist eine Beispiel-Verfügbarkeit für die Demo.',
      monthPrev: 'Vorheriger Monat', monthNext: 'Nächster Monat',
      statusAvailable: 'Verfügbar', statusSelected: 'Ausgewählt', statusUnavailable: 'Nicht verfügbar',
      arrivalLabel: 'Anreise', departureLabel: 'Abreise', selectRange: 'Wählen Sie Ihr An- und Abreisedatum',
      clearSelection: 'Auswahl zurücksetzen'
    },
    booking: {
      heading: 'Reservierungsanfrage', subheading: 'Senden Sie eine direkte Anfrage — der Gastgeber bestätigt die Verfügbarkeit persönlich.',
      arrivalLabel: 'Anreisedatum', departureLabel: 'Abreisedatum', guestsLabel: 'Anzahl der Gäste',
      nameLabel: 'Vollständiger Name', emailLabel: 'E-Mail', whatsappLabel: 'WhatsApp-Nummer', messageLabel: 'Nachricht',
      messagePlaceholder: 'Gibt es etwas, das der Gastgeber wissen sollte?', submitButton: 'Anfrage senden', submitting: 'Wird gesendet…',
      successTitle: 'Anfrage gesendet', successMessage: 'Ihre Anfrage wurde gesendet. Der Gastgeber wird sich zur Bestätigung der Verfügbarkeit bei Ihnen melden.',
      errors: {
        required: 'Dieses Feld ist erforderlich.', invalidEmail: 'Geben Sie eine gültige E-Mail-Adresse ein.',
        invalidDates: 'Wählen Sie ein An- und Abreisedatum.', departureBeforeArrival: 'Die Abreise muss nach der Anreise liegen.',
        networkError: 'Beim Senden Ihrer Anfrage ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.'
      }
    },
    whatsapp: { heading: 'Lieber per WhatsApp?', body: 'Senden Sie dem Gastgeber eine direkte Nachricht mit Ihren Daten und der Gästezahl.', cta: 'Per WhatsApp fragen' },
    location: {
      heading: 'Lage', body: 'Villa Mare liegt auf einem Hügel oberhalb von Budva, Montenegro — nah genug für einen Spaziergang zur Altstadt, ruhig genug für erholsame Nächte.',
      distanceBeach: '5 Minuten mit dem Auto zum Strand', distanceOldTown: '10 Minuten mit dem Auto zur Altstadt von Budva', distanceAirport: '25 Minuten vom Flughafen Tivat',
      mapLinkLabel: 'In Google Maps öffnen'
    },
    reviews: { heading: 'Was Gäste sagen', demoLabel: 'Demo-Bewertung' },
    faq: {
      heading: 'Häufig gestellte Fragen',
      q1: 'Ist eine Direktbuchung möglich?', a1: 'Ja — jede Anfrage geht direkt an den Gastgeber, ohne Buchungsplattform als Zwischenstelle.',
      q2: 'Wann ist der Check-in?', a2: 'Check-in ist ab 15:00 Uhr. Eine frühere Ankunft kann direkt mit dem Gastgeber vereinbart werden.',
      q3: 'Ist der Pool privat?', a3: 'Ja, der Pool ist privat und ausschließlich den Gästen der Villa vorbehalten.',
      q4: 'Gibt es einen Parkplatz?', a4: 'Ja, ein kostenloser privater Parkplatz ist vor Ort verfügbar.',
      q5: 'Wie sende ich eine Reservierungsanfrage?', a5: 'Nutzen Sie das Reservierungsformular oder den WhatsApp-Button auf dieser Seite — der Gastgeber antwortet direkt.',
      q6: 'Sind Haustiere erlaubt?', a6: 'Kleine Haustiere sind auf Anfrage willkommen — bitte in der Reservierungsnachricht erwähnen.'
    },
    finalCta: { heading: 'Möchten Sie auch eine Direktbuchungs-Website wie diese?', cta: 'Meine Unterkunfts-Website erstellen' },
    footer: {
      contact: 'Kontakt', privacy: 'Datenschutz', terms: 'AGB', demoLabel: 'Demo-Website',
      poweredBy: 'Powered by DirectBalkan', themeToggleLight: 'Zum dunklen Modus wechseln', themeToggleDark: 'Zum hellen Modus wechseln'
    }
  },

  nl: {
    demoBanner: 'Interactieve demo — beschikbaarheid en reviews zijn voorbeelden.',
    nav: { accommodation: 'Accommodatie', gallery: 'Galerij', location: 'Locatie', faq: 'FAQ', contact: 'Contact', checkAvailability: 'Beschikbaarheid checken' },
    hero: {
      badge: 'Demo-accommodatie — Villa Mare Montenegro is een fictief voorbeeldpand.',
      headline: 'Een privévilla boven de kust van Budva',
      subheadline: 'Direct boeken, geen commissie, en een gastheer die binnen minuten reageert — geen supportticket.',
      ctaPrimary: 'Beschikbaarheid checken', ctaWhatsapp: 'Vraag via WhatsApp',
      trustDirect: 'Directe aanvraag', trustNoCommission: 'Geen boekingscommissie via deze website'
    },
    quickFacts: { guests: 'gasten', bedrooms: 'slaapkamers', pool: 'Privézwembad', seaView: 'Zeezicht', wifi: 'Wifi', parking: 'Parkeren' },
    intro: {
      heading: 'Over Villa Mare',
      body: 'Rustig gelegen op een heuvel boven Budva combineert Villa Mare een privézwembad en onbelemmerd zeezicht met een korte afstand tot de oude stad. Elke aanvraag gaat rechtstreeks naar de gastheer — geen callcenter, geen wachttijd via een boekingsplatform.'
    },
    gallery: { heading: 'Galerij', lightboxClose: 'Afbeelding sluiten', lightboxPrev: 'Vorige afbeelding', lightboxNext: 'Volgende afbeelding', counter: 'Afbeelding {current} van {total}' },
    amenities: {
      heading: 'Voorzieningen',
      pool: 'Privézwembad', seaView: 'Zeezicht', wifi: 'Gratis wifi', ac: 'Airconditioning', parking: 'Gratis parkeren', terrace: 'Privéterras',
      checkIn: 'Check-in vanaf', checkOut: 'Check-out tot'
    },
    availability: {
      heading: 'Beschikbaarheid checken', demoNotice: 'Dit is voorbeeldbeschikbaarheid voor de demo.',
      monthPrev: 'Vorige maand', monthNext: 'Volgende maand',
      statusAvailable: 'Beschikbaar', statusSelected: 'Geselecteerd', statusUnavailable: 'Niet beschikbaar',
      arrivalLabel: 'Aankomst', departureLabel: 'Vertrek', selectRange: 'Selecteer uw aankomst- en vertrekdatum',
      clearSelection: 'Selectie wissen'
    },
    booking: {
      heading: 'Reserveringsaanvraag', subheading: 'Stuur een directe aanvraag — de gastheer bevestigt persoonlijk de beschikbaarheid.',
      arrivalLabel: 'Aankomstdatum', departureLabel: 'Vertrekdatum', guestsLabel: 'Aantal gasten',
      nameLabel: 'Volledige naam', emailLabel: 'E-mail', whatsappLabel: 'WhatsApp-nummer', messageLabel: 'Bericht',
      messagePlaceholder: 'Is er iets wat de gastheer moet weten?', submitButton: 'Aanvraag versturen', submitting: 'Versturen…',
      successTitle: 'Aanvraag verstuurd', successMessage: 'Uw aanvraag is verzonden. De gastheer neemt contact met u op om de beschikbaarheid te bevestigen.',
      errors: {
        required: 'Dit veld is verplicht.', invalidEmail: 'Voer een geldig e-mailadres in.',
        invalidDates: 'Kies een aankomst- en vertrekdatum.', departureBeforeArrival: 'Vertrek moet na aankomst liggen.',
        networkError: 'Er ging iets mis bij het versturen van uw aanvraag. Probeer het opnieuw.'
      }
    },
    whatsapp: { heading: 'Liever via WhatsApp?', body: 'Stuur de gastheer een directe boodschap met uw datums en aantal gasten.', cta: 'Vraag via WhatsApp' },
    location: {
      heading: 'Locatie', body: 'Villa Mare ligt op een heuvel boven Budva, Montenegro — dicht genoeg om naar de oude stad te lopen, rustig genoeg voor een goede nachtrust.',
      distanceBeach: '5 minuten rijden naar het strand', distanceOldTown: '10 minuten rijden naar de oude stad van Budva', distanceAirport: '25 minuten van luchthaven Tivat',
      mapLinkLabel: 'Openen in Google Maps'
    },
    reviews: { heading: 'Wat gasten zeggen', demoLabel: 'Demo-review' },
    faq: {
      heading: 'Veelgestelde vragen',
      q1: 'Is direct boeken mogelijk?', a1: 'Ja — elke aanvraag gaat rechtstreeks naar de gastheer, zonder tussenkomst van een boekingsplatform.',
      q2: 'Wat is de check-in tijd?', a2: 'Check-in is vanaf 15:00 uur. Eerder aankomen kan rechtstreeks met de gastheer worden afgestemd.',
      q3: 'Is het zwembad privé?', a3: 'Ja, het zwembad is privé en uitsluitend voorbehouden aan gasten van de villa.',
      q4: 'Is er parkeergelegenheid?', a4: 'Ja, er is gratis privéparkeren aanwezig op het terrein.',
      q5: 'Hoe verstuur ik een reserveringsaanvraag?', a5: 'Gebruik het reserveringsformulier of de WhatsApp-knop op deze pagina — de gastheer reageert rechtstreeks.',
      q6: 'Zijn huisdieren toegestaan?', a6: 'Kleine huisdieren zijn welkom op aanvraag — vermeld dit in uw reserveringsbericht.'
    },
    finalCta: { heading: 'Wilt u ook een directe boekingswebsite zoals deze?', cta: 'Maak mijn accommodatiewebsite' },
    footer: {
      contact: 'Contact', privacy: 'Privacybeleid', terms: 'Voorwaarden', demoLabel: 'Demosite',
      poweredBy: 'Powered by DirectBalkan', themeToggleLight: 'Schakel naar donkere modus', themeToggleDark: 'Schakel naar lichte modus'
    }
  }
};

let currentLang = 'en';

export function getCurrentLang() {
  return currentLang;
}

export function t(key) {
  const path = key.split('.');
  let node = translations[currentLang];
  for (const segment of path) {
    node = node && node[segment];
  }
  if (node === undefined) {
    let fallback = translations.en;
    for (const segment of path) fallback = fallback && fallback[segment];
    return fallback ?? key;
  }
  return node;
}

export function applyTranslations() {
  document.querySelectorAll('[data-i18n]').forEach((el) => {
    el.textContent = t(el.getAttribute('data-i18n'));
  });
  document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
    el.setAttribute('placeholder', t(el.getAttribute('data-i18n-placeholder')));
  });
  document.querySelectorAll('[data-i18n-aria-label]').forEach((el) => {
    el.setAttribute('aria-label', t(el.getAttribute('data-i18n-aria-label')));
  });
  document.documentElement.lang = currentLang;
}

export function setLanguage(lang) {
  if (!siteConfig.languages.includes(lang)) return;
  currentLang = lang;
  applyTranslations();
  document.querySelectorAll('[data-lang-switch]').forEach((btn) => {
    btn.setAttribute('aria-pressed', String(btn.getAttribute('data-lang-switch') === lang));
  });
  window.dispatchEvent(new CustomEvent('languagechange', { detail: { lang } }));
}

export function initI18n() {
  applyTranslations();
  document.querySelectorAll('[data-lang-switch]').forEach((btn) => {
    btn.addEventListener('click', () => setLanguage(btn.getAttribute('data-lang-switch')));
  });
}
```

- [ ] **Step 2: Wire `initI18n()` into `js/app.js`**

```javascript
// js/app.js (updated)
import { siteConfig } from './config.js';
import { initI18n } from './i18n.js';

export function initMobileNav() {
  const toggle = document.getElementById('nav-toggle');
  const nav = document.getElementById('primary-nav');
  if (!toggle || !nav) return;

  toggle.addEventListener('click', () => {
    const isOpen = nav.getAttribute('data-open') === 'true';
    nav.setAttribute('data-open', String(!isOpen));
    toggle.setAttribute('aria-expanded', String(!isOpen));
  });

  nav.querySelectorAll('a').forEach((link) => {
    link.addEventListener('click', () => {
      nav.setAttribute('data-open', 'false');
      toggle.setAttribute('aria-expanded', 'false');
    });
  });
}

function init() {
  console.log('Villa Mare demo loaded', siteConfig.property.name);
  initI18n();
  initMobileNav();
}

document.addEventListener('DOMContentLoaded', init);
```

- [ ] **Step 3: Manual verification**

Open `demo.html`. Confirm:
- All header nav labels, the CTA, and the demo banner render in English by default (matching `data-i18n` keys already present from Task 2).
- Clicking DE re-renders the same elements in German instantly, no page reload, and the DE button becomes visually active (`aria-pressed="true"`).
- Clicking NL does the same in Dutch.
- `document.documentElement.lang` updates (`document.querySelector('html').lang` in DevTools console) to `de`/`nl`/`en` accordingly.
- No console errors.

- [ ] **Step 4: Commit**

```bash
git add js/i18n.js js/app.js
git commit -m "Add i18n system with EN/DE/NL translations and live language switching"
```

---

### Task 4: Hero, quick facts, and introduction sections

**Files:**
- Modify: `demo.html` (replace `#accommodation` section placeholder)
- Modify: `css/style.css`
- Modify: `js/app.js`

**Interfaces:**
- Consumes: `siteConfig.property`, `siteConfig.amenities` (Task 1), `renderPlaceholder()` (Task 1's `js/placeholder.js`), `t()`/`data-i18n` (Task 3).
- Produces: `#hero-image-slot` DOM node populated by JS with a hero placeholder (so the hero image is generated the same way gallery images are — single code path, no duplicated placeholder markup in HTML).

- [ ] **Step 1: Replace the `#accommodation` section in `demo.html`**

```html
    <section id="accommodation" class="hero">
      <div class="container hero__inner">
        <div class="hero__media" id="hero-image-slot"></div>
        <div class="hero__content">
          <p class="hero__badge" data-i18n="hero.badge">Demo accommodation — Villa Mare Montenegro is a fictional example property.</p>
          <h1>Villa Mare Montenegro</h1>
          <p class="hero__location">Budva, Montenegro</p>
          <p class="hero__headline" data-i18n="hero.headline">A private villa above the Budva coastline</p>
          <p class="hero__subheadline" data-i18n="hero.subheadline">Direct booking, no commission, and a host who replies in minutes — not a support ticket.</p>
          <div class="hero__cta">
            <a class="btn btn-primary" href="#availability" data-i18n="hero.ctaPrimary">Check availability</a>
            <a class="btn btn-secondary" id="hero-whatsapp-cta" href="#" target="_blank" rel="noopener noreferrer" data-i18n="hero.ctaWhatsapp">Ask on WhatsApp</a>
          </div>
          <ul class="hero__trust">
            <li data-i18n="hero.trustDirect">Direct request</li>
            <li data-i18n="hero.trustNoCommission">No booking commission through this website</li>
          </ul>
        </div>
      </div>

      <ul class="quick-facts container">
        <li><strong id="qf-guests"></strong> <span data-i18n="quickFacts.guests">guests</span></li>
        <li><strong id="qf-bedrooms"></strong> <span data-i18n="quickFacts.bedrooms">bedrooms</span></li>
        <li data-i18n="quickFacts.pool">Private pool</li>
        <li data-i18n="quickFacts.seaView">Sea view</li>
        <li data-i18n="quickFacts.wifi">Wi-Fi</li>
        <li data-i18n="quickFacts.parking">Parking</li>
      </ul>
    </section>

    <section id="intro" class="section">
      <div class="container intro">
        <h2 data-i18n="intro.heading">About Villa Mare</h2>
        <p data-i18n="intro.body">Set on a quiet hillside above Budva, Villa Mare pairs a private pool and uninterrupted sea views with easy access to the old town. Every enquiry goes straight to the host — no call centre, no waiting on a booking platform to forward your message.</p>
      </div>
    </section>
```

- [ ] **Step 2: Add hero/quick-facts/intro CSS to `css/style.css`**

```css
/* ===== Hero ===== */
.hero { padding-top: var(--space-7); position: relative; }
.hero__inner {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-6);
  align-items: center;
}
.hero__media .placeholder-image { aspect-ratio: 4 / 3; }
.hero__badge {
  font-size: var(--text-xs);
  color: var(--color-text-muted);
  border: 1px solid var(--color-border);
  display: inline-block;
  padding: var(--space-1) var(--space-3);
  border-radius: 999px;
  margin-bottom: var(--space-3);
}
.hero__location { color: var(--color-text-muted); margin-top: var(--space-2); }
.hero__headline { font-family: var(--font-display); font-size: var(--text-2xl); margin-top: var(--space-4); }
.hero__subheadline { color: var(--color-text-muted); margin-top: var(--space-3); font-size: var(--text-lg); }
.hero__cta { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-top: var(--space-6); }
.hero__trust {
  list-style: none; padding: 0; margin-top: var(--space-5);
  display: flex; flex-direction: column; gap: var(--space-2);
  font-size: var(--text-sm); color: var(--color-text-muted);
}
.hero__trust li::before { content: '— '; color: var(--color-secondary); }

.quick-facts {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-4);
  margin-top: var(--space-7);
  padding-top: var(--space-6);
  padding-bottom: 0;
  border-top: 1px solid var(--color-border);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}
.quick-facts strong { color: var(--color-text); font-family: var(--font-display); font-size: var(--text-lg); }

.intro { max-width: 68ch; }
.intro h2 { margin-bottom: var(--space-4); }
.intro p { color: var(--color-text-muted); font-size: var(--text-lg); }

@media (min-width: 1024px) {
  .hero__inner { grid-template-columns: 1.1fr 1fr; gap: var(--space-8); }
  .quick-facts { grid-template-columns: repeat(6, 1fr); }
}
```

- [ ] **Step 3: Populate hero placeholder image and quick facts in `js/app.js`**

```javascript
// js/app.js (add these imports and additions)
import { siteConfig } from './config.js';
import { initI18n } from './i18n.js';
import { renderPlaceholder } from './placeholder.js';

// ... keep existing initMobileNav ...

function initHero() {
  const slot = document.getElementById('hero-image-slot');
  if (slot) {
    slot.innerHTML = renderPlaceholder({
      alt: 'Villa Mare exterior with pool and sea view',
      replaceWith: 'Hero photo: villa exterior, pool, and sea view, golden hour',
      variant: 0,
      aspectRatio: '4 / 3'
    });
  }

  const guestsEl = document.getElementById('qf-guests');
  const bedroomsEl = document.getElementById('qf-bedrooms');
  if (guestsEl) guestsEl.textContent = siteConfig.property.guests;
  if (bedroomsEl) bedroomsEl.textContent = siteConfig.property.bedrooms;
}

function init() {
  console.log('Villa Mare demo loaded', siteConfig.property.name);
  initI18n();
  initMobileNav();
  initHero();
}

document.addEventListener('DOMContentLoaded', init);
```

- [ ] **Step 4: Manual verification**

Open `demo.html`. Confirm:
- Hero shows the terracotta/olive gradient placeholder with "Replace: Hero photo…" caption, the property name as the page's one `<h1>`, headline/subheadline text, both CTAs, and the trust list.
- Quick facts show `6 guests` and `3 bedrooms` pulled from config, plus the four static facts.
- Layout stacks to a single column below 1024px and becomes a two-column hero above it (resize browser to confirm).
- Switching language (EN/DE/NL) updates headline, subheadline, CTAs, and quick-fact labels.
- No console errors, no horizontal overflow at 375px.

- [ ] **Step 5: Commit**

```bash
git add demo.html css/style.css js/app.js
git commit -m "Add hero, quick facts, and introduction sections"
```

---

### Task 5: Gallery grid and lightbox

**Files:**
- Create: `js/gallery.js`
- Modify: `demo.html`
- Modify: `css/style.css`
- Modify: `js/app.js`

**Interfaces:**
- Consumes: `siteConfig.gallery` (Task 1), `renderPlaceholder()` (Task 1), `t()` (Task 3).
- Produces: `initGallery(gridSelector: string) -> void` (named export from `js/gallery.js`), called once from `js/app.js`. Listens for `window`'s `languagechange` event to re-render the lightbox counter/labels if open.

- [ ] **Step 1: Replace the `#gallery` section in `demo.html`**

```html
    <section id="gallery" class="section section-alt">
      <div class="container">
        <h2 data-i18n="gallery.heading">Gallery</h2>
        <div class="gallery-grid" id="gallery-grid"></div>
      </div>

      <div class="lightbox" id="lightbox" hidden>
        <button class="lightbox__close" id="lightbox-close" data-i18n-aria-label="gallery.lightboxClose" aria-label="Close image">✕</button>
        <button class="lightbox__nav lightbox__nav--prev" id="lightbox-prev" data-i18n-aria-label="gallery.lightboxPrev" aria-label="Previous image">‹</button>
        <div class="lightbox__stage" id="lightbox-stage"></div>
        <button class="lightbox__nav lightbox__nav--next" id="lightbox-next" data-i18n-aria-label="gallery.lightboxNext" aria-label="Next image">›</button>
        <p class="lightbox__counter" id="lightbox-counter"></p>
      </div>
    </section>
```

- [ ] **Step 2: Add gallery/lightbox CSS to `css/style.css`**

```css
/* ===== Gallery ===== */
.gallery-grid {
  margin-top: var(--space-6);
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-4);
}
.gallery-grid__item {
  border: none;
  padding: 0;
  background: none;
  cursor: pointer;
  min-height: 44px;
  border-radius: var(--radius-md);
  overflow: hidden;
  transition: transform var(--transition-base);
}
.gallery-grid__item:hover { transform: translateY(-2px); }
.gallery-grid__item .placeholder-image { aspect-ratio: 4 / 3; }

.lightbox {
  position: fixed;
  inset: 0;
  background: rgba(20, 16, 13, 0.92);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  padding: var(--space-5);
}
.lightbox[hidden] { display: none; }
.lightbox__stage { max-width: min(90vw, 900px); width: 100%; }
.lightbox__stage .placeholder-image { aspect-ratio: 4 / 3; }
.lightbox__close, .lightbox__nav {
  position: absolute;
  min-width: 44px; min-height: 44px;
  background: rgba(255, 255, 255, 0.1);
  color: #fff;
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: var(--radius-sm);
  font-size: var(--text-lg);
  cursor: pointer;
}
.lightbox__close { top: var(--space-5); right: var(--space-5); }
.lightbox__nav--prev { left: var(--space-5); top: 50%; transform: translateY(-50%); }
.lightbox__nav--next { right: var(--space-5); top: 50%; transform: translateY(-50%); }
.lightbox__counter { color: #fff; margin-top: var(--space-4); font-size: var(--text-sm); }

@media (min-width: 640px) {
  .gallery-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 1024px) {
  .gallery-grid { grid-template-columns: repeat(4, 1fr); }
}
```

- [ ] **Step 3: Create `js/gallery.js`**

```javascript
// js/gallery.js
import { siteConfig } from './config.js';
import { renderPlaceholder } from './placeholder.js';
import { t } from './i18n.js';

let currentIndex = 0;
let lastFocusedTrigger = null;

export function initGallery(gridSelector) {
  const grid = document.querySelector(gridSelector);
  const lightbox = document.getElementById('lightbox');
  const stage = document.getElementById('lightbox-stage');
  const counter = document.getElementById('lightbox-counter');
  const closeBtn = document.getElementById('lightbox-close');
  const prevBtn = document.getElementById('lightbox-prev');
  const nextBtn = document.getElementById('lightbox-next');
  if (!grid || !lightbox) return;

  siteConfig.gallery.forEach((image, index) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.className = 'gallery-grid__item';
    button.innerHTML = renderPlaceholder({ alt: image.alt, replaceWith: image.replaceWith, variant: image.variant });
    button.addEventListener('click', () => openLightbox(index, button));
    grid.appendChild(button);
  });

  function renderStage() {
    const image = siteConfig.gallery[currentIndex];
    stage.innerHTML = renderPlaceholder({ alt: image.alt, replaceWith: image.replaceWith, variant: image.variant, aspectRatio: '4 / 3' });
    counter.textContent = t('gallery.counter')
      .replace('{current}', String(currentIndex + 1))
      .replace('{total}', String(siteConfig.gallery.length));
  }

  function openLightbox(index, trigger) {
    currentIndex = index;
    lastFocusedTrigger = trigger;
    renderStage();
    lightbox.hidden = false;
    closeBtn.focus();
    document.addEventListener('keydown', onKeydown);
  }

  function closeLightbox() {
    lightbox.hidden = true;
    document.removeEventListener('keydown', onKeydown);
    if (lastFocusedTrigger) lastFocusedTrigger.focus();
  }

  function showNext() {
    currentIndex = (currentIndex + 1) % siteConfig.gallery.length;
    renderStage();
  }

  function showPrev() {
    currentIndex = (currentIndex - 1 + siteConfig.gallery.length) % siteConfig.gallery.length;
    renderStage();
  }

  function onKeydown(event) {
    if (event.key === 'Escape') closeLightbox();
    if (event.key === 'ArrowRight') showNext();
    if (event.key === 'ArrowLeft') showPrev();
  }

  closeBtn.addEventListener('click', closeLightbox);
  nextBtn.addEventListener('click', showNext);
  prevBtn.addEventListener('click', showPrev);
  lightbox.addEventListener('click', (event) => {
    if (event.target === lightbox) closeLightbox();
  });

  window.addEventListener('languagechange', () => {
    if (!lightbox.hidden) renderStage();
  });
}
```

- [ ] **Step 4: Wire `initGallery()` into `js/app.js`**

```javascript
// js/app.js (add import and call)
import { initGallery } from './gallery.js';

// inside init():
  initGallery('#gallery-grid');
```

- [ ] **Step 5: Manual verification**

Open `demo.html`, scroll to Gallery. Confirm:
- 8 placeholder thumbnails render in a responsive grid (2 columns mobile, 3 tablet, 4 desktop — resize to check).
- Clicking a thumbnail opens the lightbox with that image, focus moves to the close button.
- Left/Right arrow keys and the prev/next buttons cycle images and wrap around at the ends.
- Escape key and the close button both close the lightbox and return focus to the thumbnail that opened it.
- Clicking the dark backdrop (outside the image) also closes it.
- Counter text reads "Image 1 of 8" style text and updates when navigating, and switches language when the site language changes while the lightbox is open.
- No console errors.

- [ ] **Step 6: Commit**

```bash
git add js/gallery.js demo.html css/style.css js/app.js
git commit -m "Add gallery grid with keyboard-accessible lightbox"
```

---

### Task 6: Amenities section

**Files:**
- Modify: `demo.html`
- Modify: `css/style.css`

**Interfaces:**
- Consumes: `siteConfig.amenities` (Task 1) — check-in/check-out times are read directly into the DOM by inline text in this task (simplest approach for two static values; no new JS module needed).

- [ ] **Step 1: Replace the `#amenities` section in `demo.html`**

```html
    <section id="amenities" class="section">
      <div class="container">
        <h2 data-i18n="amenities.heading">Amenities</h2>
        <ul class="amenities-list">
          <li>
            <svg class="amenities-list__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 18c1.5-2 3-2 4.5 0s3 2 4.5 0 3-2 4.5 0 3 2 4.5 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><rect x="5" y="4" width="14" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
            <span data-i18n="amenities.pool">Private pool</span>
          </li>
          <li>
            <svg class="amenities-list__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 16l5-9 4 6 3-4 6 7" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/></svg>
            <span data-i18n="amenities.seaView">Sea view</span>
          </li>
          <li>
            <svg class="amenities-list__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19a1 1 0 100-2 1 1 0 000 2zM4 10a11 11 0 0116 0M7 13a7 7 0 0110 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
            <span data-i18n="amenities.wifi">Free Wi-Fi</span>
          </li>
          <li>
            <svg class="amenities-list__icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="18" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 6V4h8v2M12 10v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
            <span data-i18n="amenities.ac">Air conditioning</span>
          </li>
          <li>
            <svg class="amenities-list__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 20V9l7-5 7 5v11M9 20v-6h6v6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>
            <span data-i18n="amenities.parking">Free parking</span>
          </li>
          <li>
            <svg class="amenities-list__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 21V9l8-6 8 6v12M9 21v-5a3 3 0 016 0v5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>
            <span data-i18n="amenities.terrace">Private terrace</span>
          </li>
        </ul>
        <p class="amenities-times">
          <span data-i18n="amenities.checkIn">Check-in from</span> <strong id="amenities-checkin"></strong>
          &nbsp;·&nbsp;
          <span data-i18n="amenities.checkOut">Check-out until</span> <strong id="amenities-checkout"></strong>
        </p>
      </div>
    </section>
```

- [ ] **Step 2: Add amenities CSS to `css/style.css`**

```css
/* ===== Amenities ===== */
.amenities-list {
  list-style: none;
  margin: var(--space-6) 0 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-5);
}
.amenities-list li {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  font-size: var(--text-base);
}
.amenities-list__icon {
  width: 28px;
  height: 28px;
  flex-shrink: 0;
  color: var(--color-primary);
}
.amenities-times {
  margin-top: var(--space-7);
  padding-top: var(--space-5);
  border-top: 1px solid var(--color-border);
  color: var(--color-text-muted);
}
.amenities-times strong { color: var(--color-text); }

@media (min-width: 640px) {
  .amenities-list { grid-template-columns: repeat(3, 1fr); }
}
```

- [ ] **Step 3: Populate check-in/check-out from config in `js/app.js`**

```javascript
// js/app.js (add inside initHero(), or a small dedicated function called from init())
function initAmenities() {
  const checkinEl = document.getElementById('amenities-checkin');
  const checkoutEl = document.getElementById('amenities-checkout');
  if (checkinEl) checkinEl.textContent = siteConfig.amenities.checkIn;
  if (checkoutEl) checkoutEl.textContent = siteConfig.amenities.checkOut;
}

// inside init():
  initAmenities();
```

- [ ] **Step 4: Manual verification**

Open `demo.html`, scroll to Amenities. Confirm:
- Six amenities render with inline SVG icons (not colored circles) and translated labels.
- Check-in/check-out show `15:00` / `10:00` from config.
- Language switch updates all amenity labels.
- Grid is 2 columns on mobile, 3 columns from 640px up.

- [ ] **Step 5: Commit**

```bash
git add demo.html css/style.css js/app.js
git commit -m "Add amenities section with inline SVG icons"
```

---

### Task 7: Availability calendar

**Files:**
- Create: `js/calendar.js`
- Modify: `demo.html`
- Modify: `css/style.css`
- Modify: `js/app.js`

**Interfaces:**
- Consumes: `siteConfig.availability` (Task 1), `t()` (Task 3).
- Produces (named exports from `js/calendar.js`): `initCalendar(containerSelector: string) -> void`, `getSelectedDates() -> { arrival: string|null, departure: string|null }` — consumed by `form.js` (Task 8) and the WhatsApp link builder (Task 9).

- [ ] **Step 1: Replace the `#availability` section in `demo.html`**

```html
    <section id="availability" class="section section-alt">
      <div class="container">
        <h2 data-i18n="availability.heading">Check availability</h2>
        <p class="availability-notice" data-i18n="availability.demoNotice">This is example availability for the demo.</p>

        <div class="calendar" id="calendar">
          <div class="calendar__header">
            <button type="button" id="calendar-prev" data-i18n-aria-label="availability.monthPrev" aria-label="Previous month">‹</button>
            <h3 id="calendar-month-label" aria-live="polite"></h3>
            <button type="button" id="calendar-next" data-i18n-aria-label="availability.monthNext" aria-label="Next month">›</button>
          </div>
          <div class="calendar__grid" id="calendar-grid" role="grid"></div>
          <ul class="calendar__legend">
            <li><span class="legend-dot legend-dot--available"></span> <span data-i18n="availability.statusAvailable">Available</span></li>
            <li><span class="legend-dot legend-dot--selected"></span> <span data-i18n="availability.statusSelected">Selected</span></li>
            <li><span class="legend-dot legend-dot--unavailable"></span> <span data-i18n="availability.statusUnavailable">Unavailable</span></li>
          </ul>
          <p class="calendar__selection" id="calendar-selection" aria-live="polite"></p>
          <button type="button" class="btn btn-secondary" id="calendar-clear" data-i18n="availability.clearSelection">Clear selection</button>
        </div>
      </div>
    </section>
```

- [ ] **Step 2: Add calendar CSS to `css/style.css`**

```css
/* ===== Calendar ===== */
.availability-notice {
  color: var(--color-text-muted);
  font-size: var(--text-sm);
  margin-top: var(--space-2);
}
.calendar {
  margin-top: var(--space-6);
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  padding: var(--space-6);
  max-width: 480px;
}
.calendar__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.calendar__header button {
  min-width: 44px; min-height: 44px;
  border: 1px solid var(--color-border);
  background: var(--color-bg);
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: var(--text-lg);
}
.calendar__grid {
  margin-top: var(--space-4);
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: var(--space-1);
}
.calendar__grid .day-cell {
  min-height: 44px;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  cursor: pointer;
  font-size: var(--text-sm);
}
.calendar__grid .day-cell--label {
  cursor: default;
  color: var(--color-text-muted);
  font-size: var(--text-xs);
}
.calendar__grid .day-cell--available { background: color-mix(in srgb, var(--color-available) 18%, transparent); color: var(--color-text); }
.calendar__grid .day-cell--unavailable { background: transparent; color: var(--color-unavailable); text-decoration: line-through; cursor: not-allowed; }
.calendar__grid .day-cell--selected, .calendar__grid .day-cell--in-range { background: var(--color-selected); color: #fff; }
.calendar__grid .day-cell--empty { visibility: hidden; }

.calendar__legend {
  list-style: none;
  display: flex;
  gap: var(--space-5);
  padding: 0;
  margin-top: var(--space-5);
  font-size: var(--text-xs);
  color: var(--color-text-muted);
}
.legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: var(--space-1); }
.legend-dot--available { background: var(--color-available); }
.legend-dot--selected { background: var(--color-selected); }
.legend-dot--unavailable { background: var(--color-unavailable); }

.calendar__selection { margin-top: var(--space-4); font-size: var(--text-sm); min-height: 1.5em; }
#calendar-clear { margin-top: var(--space-3); }
```

- [ ] **Step 3: Create `js/calendar.js`**

```javascript
// js/calendar.js
import { siteConfig } from './config.js';
import { t } from './i18n.js';

const availabilityDates = Object.keys(siteConfig.availability).sort();
const MIN_DATE = availabilityDates[0];
const MAX_DATE = availabilityDates[availabilityDates.length - 1];

let viewDate = new Date(`${MIN_DATE}T00:00:00Z`);
let selectedArrival = null;
let selectedDeparture = null;

const MONTH_FORMATTER = new Intl.DateTimeFormat('en', { month: 'long', year: 'numeric', timeZone: 'UTC' });

function toISO(date) {
  return date.toISOString().slice(0, 10);
}

function statusFor(iso) {
  return siteConfig.availability[iso] ?? 'unavailable';
}

export function getSelectedDates() {
  return { arrival: selectedArrival, departure: selectedDeparture };
}

export function initCalendar(containerSelector) {
  const root = document.querySelector(containerSelector);
  if (!root) return;

  const grid = document.getElementById('calendar-grid');
  const monthLabel = document.getElementById('calendar-month-label');
  const prevBtn = document.getElementById('calendar-prev');
  const nextBtn = document.getElementById('calendar-next');
  const selectionEl = document.getElementById('calendar-selection');
  const clearBtn = document.getElementById('calendar-clear');

  function renderSelectionText() {
    if (selectedArrival && selectedDeparture) {
      selectionEl.textContent = `${t('availability.arrivalLabel')}: ${selectedArrival} — ${t('availability.departureLabel')}: ${selectedDeparture}`;
    } else if (selectedArrival) {
      selectionEl.textContent = `${t('availability.arrivalLabel')}: ${selectedArrival}`;
    } else {
      selectionEl.textContent = t('availability.selectRange');
    }
  }

  function isInSelectedRange(iso) {
    if (!selectedArrival || !selectedDeparture) return false;
    return iso > selectedArrival && iso < selectedDeparture;
  }

  function render() {
    monthLabel.textContent = MONTH_FORMATTER.format(viewDate);
    prevBtn.disabled = toISO(startOfMonth(viewDate)) <= MIN_DATE;
    nextBtn.disabled = toISO(endOfMonth(viewDate)) >= MAX_DATE;

    grid.innerHTML = '';
    ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].forEach((label) => {
      const cell = document.createElement('div');
      cell.className = 'day-cell day-cell--label';
      cell.textContent = label;
      grid.appendChild(cell);
    });

    const firstOfMonth = startOfMonth(viewDate);
    const leadingEmpty = (firstOfMonth.getUTCDay() + 6) % 7; // Monday-first
    for (let i = 0; i < leadingEmpty; i++) {
      const cell = document.createElement('div');
      cell.className = 'day-cell day-cell--empty';
      grid.appendChild(cell);
    }

    const daysInMonth = endOfMonth(viewDate).getUTCDate();
    for (let day = 1; day <= daysInMonth; day++) {
      const date = new Date(Date.UTC(viewDate.getUTCFullYear(), viewDate.getUTCMonth(), day));
      const iso = toISO(date);
      const button = document.createElement('button');
      button.type = 'button';
      button.className = 'day-cell';
      button.textContent = String(day);

      if (iso < MIN_DATE || iso > MAX_DATE) {
        button.classList.add('day-cell--unavailable');
        button.disabled = true;
      } else if (iso === selectedArrival || iso === selectedDeparture) {
        button.classList.add('day-cell--selected');
      } else if (isInSelectedRange(iso)) {
        button.classList.add('day-cell--in-range');
      } else if (statusFor(iso) === 'available') {
        button.classList.add('day-cell--available');
      } else {
        button.classList.add('day-cell--unavailable');
        button.disabled = true;
      }

      if (!button.disabled) {
        button.addEventListener('click', () => selectDate(iso));
      }
      grid.appendChild(button);
    }

    renderSelectionText();
  }

  function selectDate(iso) {
    if (!selectedArrival || (selectedArrival && selectedDeparture)) {
      selectedArrival = iso;
      selectedDeparture = null;
    } else if (iso <= selectedArrival) {
      selectedArrival = iso;
    } else {
      selectedDeparture = iso;
    }
    render();
  }

  function startOfMonth(date) {
    return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
  }
  function endOfMonth(date) {
    return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0));
  }

  prevBtn.addEventListener('click', () => {
    viewDate = new Date(Date.UTC(viewDate.getUTCFullYear(), viewDate.getUTCMonth() - 1, 1));
    render();
  });
  nextBtn.addEventListener('click', () => {
    viewDate = new Date(Date.UTC(viewDate.getUTCFullYear(), viewDate.getUTCMonth() + 1, 1));
    render();
  });
  clearBtn.addEventListener('click', () => {
    selectedArrival = null;
    selectedDeparture = null;
    render();
  });
  window.addEventListener('languagechange', renderSelectionText);

  render();
}
```

- [ ] **Step 4: Wire `initCalendar()` into `js/app.js`**

```javascript
// js/app.js (add import and call)
import { initCalendar } from './calendar.js';

// inside init():
  initCalendar('#calendar');
```

- [ ] **Step 5: Manual verification**

Open `demo.html`, scroll to Check availability. Confirm:
- Calendar opens on August 2026 (the earliest month in the demo availability data) with a visible mix of available (tinted), unavailable (struck-through, disabled), and empty leading cells for correct weekday alignment.
- Clicking an available date sets it as arrival (highlighted); clicking a later available date sets departure and highlights the range between them; clicking an earlier date restarts the arrival.
- "Clear selection" resets both dates and the selection text.
- Month prev/next navigate between August and September 2026 and disable at the data boundaries.
- Switching language updates the legend, notice text, and selection summary text.
- No console errors.

- [ ] **Step 6: Commit**

```bash
git add js/calendar.js demo.html css/style.css js/app.js
git commit -m "Add interactive availability calendar with demo data"
```

---

### Task 8: Booking request form with Make.com webhook simulate/send

**Files:**
- Create: `js/form.js`
- Modify: `demo.html`
- Modify: `css/style.css`
- Modify: `js/app.js`

**Interfaces:**
- Consumes: `siteConfig.booking`, `siteConfig.property` (Task 1), `getSelectedDates()` (Task 7's `calendar.js`), `t()`/`getCurrentLang()` (Task 3).
- Produces: `initBookingForm(formSelector: string) -> void` (named export from `js/form.js`), called once from `js/app.js`.

- [ ] **Step 1: Replace the `#contact` section in `demo.html`**

```html
    <section id="contact" class="section">
      <div class="container">
        <h2 data-i18n="booking.heading">Request a reservation</h2>
        <p class="booking-subheading" data-i18n="booking.subheading">Send a direct request — the host confirms availability personally.</p>

        <form id="booking-form" class="booking-form" novalidate>
          <div class="form-row">
            <label for="field-arrival" data-i18n="booking.arrivalLabel">Arrival date</label>
            <input type="date" id="field-arrival" name="arrival" required />
            <p class="field-error" id="error-arrival" role="alert"></p>
          </div>

          <div class="form-row">
            <label for="field-departure" data-i18n="booking.departureLabel">Departure date</label>
            <input type="date" id="field-departure" name="departure" required />
            <p class="field-error" id="error-departure" role="alert"></p>
          </div>

          <div class="form-row">
            <label for="field-guests" data-i18n="booking.guestsLabel">Number of guests</label>
            <input type="number" id="field-guests" name="guests" min="1" max="6" value="2" required />
            <p class="field-error" id="error-guests" role="alert"></p>
          </div>

          <div class="form-row">
            <label for="field-name" data-i18n="booking.nameLabel">Full name</label>
            <input type="text" id="field-name" name="name" required autocomplete="name" />
            <p class="field-error" id="error-name" role="alert"></p>
          </div>

          <div class="form-row">
            <label for="field-email" data-i18n="booking.emailLabel">Email</label>
            <input type="email" id="field-email" name="email" required autocomplete="email" />
            <p class="field-error" id="error-email" role="alert"></p>
          </div>

          <div class="form-row">
            <label for="field-whatsapp" data-i18n="booking.whatsappLabel">WhatsApp number</label>
            <input type="tel" id="field-whatsapp" name="whatsapp" required autocomplete="tel" />
            <p class="field-error" id="error-whatsapp" role="alert"></p>
          </div>

          <div class="form-row form-row--full">
            <label for="field-message" data-i18n="booking.messageLabel">Message</label>
            <textarea id="field-message" name="message" rows="4" data-i18n-placeholder="booking.messagePlaceholder" placeholder="Anything the host should know?"></textarea>
          </div>

          <div class="form-row form-row--full">
            <button type="submit" class="btn btn-primary" id="booking-submit" data-i18n="booking.submitButton">Send request</button>
          </div>

          <div class="form-status" id="form-status" role="status" aria-live="polite" hidden></div>
        </form>
      </div>
    </section>
```

- [ ] **Step 2: Add booking-form CSS to `css/style.css`**

```css
/* ===== Booking form ===== */
.booking-subheading { color: var(--color-text-muted); margin-top: var(--space-2); }
.booking-form {
  margin-top: var(--space-7);
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-5);
  max-width: 640px;
}
.form-row { display: flex; flex-direction: column; gap: var(--space-2); }
.form-row label { font-size: var(--text-sm); font-weight: 600; }
.form-row input, .form-row textarea {
  min-height: 44px;
  padding: var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  background: var(--color-surface);
  color: var(--color-text);
  font-size: var(--text-base);
  font-family: inherit;
}
.form-row input:focus, .form-row textarea:focus { border-color: var(--color-primary); }
.field-error { color: var(--color-danger); font-size: var(--text-xs); min-height: 1.2em; margin: 0; }
.form-row[data-invalid="true"] input,
.form-row[data-invalid="true"] textarea { border-color: var(--color-danger); }

.form-status {
  padding: var(--space-4);
  border-radius: var(--radius-sm);
  font-size: var(--text-sm);
}
.form-status[data-state="success"] { background: color-mix(in srgb, var(--color-secondary) 15%, transparent); color: var(--color-secondary); }
.form-status[data-state="error"] { background: color-mix(in srgb, var(--color-danger) 12%, transparent); color: var(--color-danger); }

@media (min-width: 640px) {
  .booking-form { grid-template-columns: 1fr 1fr; }
  .form-row--full { grid-column: 1 / -1; }
}
```

- [ ] **Step 3: Create `js/form.js`**

```javascript
// js/form.js
import { siteConfig } from './config.js';
import { t, getCurrentLang } from './i18n.js';
import { getSelectedDates } from './calendar.js';

const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export function initBookingForm(formSelector) {
  const form = document.querySelector(formSelector);
  if (!form) return;

  const arrivalInput = form.querySelector('#field-arrival');
  const departureInput = form.querySelector('#field-departure');
  const submitBtn = form.querySelector('#booking-submit');
  const statusEl = document.getElementById('form-status');

  const fields = ['arrival', 'departure', 'guests', 'name', 'email', 'whatsapp'];
  let isSubmitting = false;

  // Prefill from calendar selection, if any, each time the form area gains focus/is submitted.
  function syncFromCalendar() {
    const { arrival, departure } = getSelectedDates();
    if (arrival && !arrivalInput.value) arrivalInput.value = arrival;
    if (departure && !departureInput.value) departureInput.value = departure;
  }
  form.addEventListener('focusin', syncFromCalendar, { once: true });

  function setFieldError(field, message) {
    const row = form.querySelector(`#field-${field}`).closest('.form-row');
    const errorEl = document.getElementById(`error-${field}`);
    row.setAttribute('data-invalid', message ? 'true' : 'false');
    if (errorEl) errorEl.textContent = message || '';
  }

  function validate() {
    let valid = true;
    fields.forEach((field) => setFieldError(field, ''));

    fields.forEach((field) => {
      const input = form.querySelector(`#field-${field}`);
      if (!input.value.trim()) {
        setFieldError(field, t('booking.errors.required'));
        valid = false;
      }
    });

    const emailInput = form.querySelector('#field-email');
    if (emailInput.value && !EMAIL_PATTERN.test(emailInput.value)) {
      setFieldError('email', t('booking.errors.invalidEmail'));
      valid = false;
    }

    if (arrivalInput.value && departureInput.value && departureInput.value <= arrivalInput.value) {
      setFieldError('departure', t('booking.errors.departureBeforeArrival'));
      valid = false;
    }

    return valid;
  }

  function showStatus(state, message) {
    statusEl.hidden = false;
    statusEl.dataset.state = state;
    statusEl.textContent = message;
  }

  async function sendToWebhook(payload) {
    const url = siteConfig.booking.makeWebhookUrl;
    if (!url) {
      console.info('[Villa Mare demo] No Make.com webhook configured — simulating booking request submission.', payload);
      return { ok: true, simulated: true };
    }
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload)
    });
    if (!response.ok) throw new Error(`Webhook responded with ${response.status}`);
    return { ok: true, simulated: false };
  }

  form.addEventListener('submit', async (event) => {
    event.preventDefault();
    if (isSubmitting) return;
    if (!validate()) return;

    isSubmitting = true;
    submitBtn.disabled = true;
    const originalLabel = submitBtn.textContent;
    submitBtn.textContent = t('booking.submitting');
    statusEl.hidden = true;

    const payload = {
      event: 'demo_booking_request',
      demo: true,
      property: siteConfig.property.name,
      arrival: arrivalInput.value,
      departure: departureInput.value,
      guests: Number(form.querySelector('#field-guests').value),
      name: form.querySelector('#field-name').value.trim(),
      email: form.querySelector('#field-email').value.trim(),
      whatsapp: form.querySelector('#field-whatsapp').value.trim(),
      message: form.querySelector('#field-message').value.trim(),
      language: getCurrentLang(),
      source: 'directbalkan_demo'
    };

    try {
      await sendToWebhook(payload);
      showStatus('success', t('booking.successMessage'));
      form.reset();
    } catch (error) {
      console.error('[Villa Mare demo] Booking request failed to send.', error);
      showStatus('error', t('booking.errors.networkError'));
    } finally {
      isSubmitting = false;
      submitBtn.disabled = false;
      submitBtn.textContent = originalLabel;
    }
  });
}
```

- [ ] **Step 4: Wire `initBookingForm()` into `js/app.js`**

```javascript
// js/app.js (add import and call)
import { initBookingForm } from './form.js';

// inside init():
  initBookingForm('#booking-form');
```

- [ ] **Step 5: Manual verification**

Open `demo.html`, scroll to Request a reservation. Confirm:
- Submitting the empty form shows a specific "This field is required." error under each empty required field and does not submit.
- Entering an invalid email (e.g. `abc`) shows the email-specific error; fixing it clears that error on next submit attempt.
- Setting departure before arrival shows the "Departure must be after arrival." error.
- Filling all fields correctly and submitting: button shows "Sending…", becomes disabled (click it again rapidly to confirm no duplicate submission), then shows the green success banner with the exact text "Your request has been sent. The host will contact you to confirm availability." and the form clears.
- DevTools console shows the `[Villa Mare demo] No Make.com webhook configured…` info log with the full payload object matching the section-7 schema field names.
- Selecting arrival/departure dates in the calendar first, then focusing the form, prefills the date inputs.
- No console errors (the info log is expected and acceptable).

- [ ] **Step 6: Commit**

```bash
git add js/form.js demo.html css/style.css js/app.js
git commit -m "Add booking request form with validation and Make.com webhook simulate/send"
```

---

### Task 9: WhatsApp CTA with prefilled message

**Files:**
- Modify: `demo.html`
- Modify: `css/style.css`
- Modify: `js/app.js`

**Interfaces:**
- Consumes: `siteConfig.contact.whatsapp`, `siteConfig.property` (Task 1), `getSelectedDates()` (Task 7), `t()` (Task 3).
- Produces: `buildWhatsAppLink() -> string` (function in `js/app.js`), used to populate both the hero secondary CTA (`#hero-whatsapp-cta`, added as a placeholder `href="#"` in Task 4) and this section's CTA — refreshed on calendar selection and language change so the prefilled message always matches current state.

- [ ] **Step 1: Replace the `#whatsapp` section in `demo.html`**

```html
    <section id="whatsapp" class="section section-alt whatsapp-cta">
      <div class="container whatsapp-cta__inner">
        <div>
          <h2 data-i18n="whatsapp.heading">Prefer WhatsApp?</h2>
          <p data-i18n="whatsapp.body">Send the host a direct message with your dates and party size.</p>
        </div>
        <a class="btn btn-primary btn-large" id="whatsapp-cta-link" href="#" target="_blank" rel="noopener noreferrer" data-i18n="whatsapp.cta">Ask on WhatsApp</a>
      </div>
    </section>
```

- [ ] **Step 2: Add WhatsApp CTA CSS to `css/style.css`**

```css
/* ===== WhatsApp CTA ===== */
.whatsapp-cta__inner {
  display: flex;
  flex-direction: column;
  gap: var(--space-5);
  align-items: flex-start;
}
.btn-large { padding: 0 var(--space-6); min-height: 52px; font-size: var(--text-base); }

@media (min-width: 640px) {
  .whatsapp-cta__inner { flex-direction: row; align-items: center; justify-content: space-between; }
}
```

- [ ] **Step 3: Add `buildWhatsAppLink()` and wire both WhatsApp CTAs in `js/app.js`**

```javascript
// js/app.js (add import and functions)
import { getSelectedDates } from './calendar.js';

function buildWhatsAppLink() {
  const { arrival, departure } = getSelectedDates();
  const guests = document.getElementById('field-guests')?.value || siteConfig.property.guests;

  const lines = [`Hi! I'm interested in ${siteConfig.property.name}.`];
  if (arrival) lines.push(`Arrival: ${arrival}`);
  if (departure) lines.push(`Departure: ${departure}`);
  lines.push(`Guests: ${guests}`);

  const text = encodeURIComponent(lines.join('\n'));
  return `https://wa.me/${siteConfig.contact.whatsapp}?text=${text}`;
}

function refreshWhatsAppLinks() {
  const links = [document.getElementById('hero-whatsapp-cta'), document.getElementById('whatsapp-cta-link')];
  const href = buildWhatsAppLink();
  links.forEach((link) => { if (link) link.href = href; });
}

// inside init():
  refreshWhatsAppLinks();
  window.addEventListener('languagechange', refreshWhatsAppLinks);
  document.getElementById('calendar-grid')?.addEventListener('click', refreshWhatsAppLinks);
  document.getElementById('field-guests')?.addEventListener('input', refreshWhatsAppLinks);
```

- [ ] **Step 4: Manual verification**

Open `demo.html`. Confirm:
- Both "Ask on WhatsApp" buttons (hero and dedicated section) point to `https://wa.me/38267000000?text=...` (inspect the `href` in DevTools).
- Selecting arrival/departure dates in the calendar updates the prefilled message text to include those dates (check the decoded `href`).
- Changing the guests number field updates the guest count in the message.
- Links open in a new tab (`target="_blank"`) and carry `rel="noopener noreferrer"`.
- No console errors.

- [ ] **Step 5: Commit**

```bash
git add demo.html css/style.css js/app.js
git commit -m "Add WhatsApp CTA with dynamic prefilled message"
```

---

### Task 10: Location section

**Files:**
- Modify: `demo.html`
- Modify: `css/style.css`
- Modify: `js/app.js`

**Interfaces:**
- Consumes: `siteConfig.property.location` (Task 1), `renderPlaceholder()` (Task 1), `t()` (Task 3).

- [ ] **Step 1: Replace the `#location` section in `demo.html`**

```html
    <section id="location" class="section">
      <div class="container location">
        <div class="location__content">
          <h2 data-i18n="location.heading">Location</h2>
          <p data-i18n="location.body">Villa Mare sits on a hillside above Budva, Montenegro — close enough to walk to the old town, far enough for a quiet night.</p>
          <ul class="location__distances">
            <li data-i18n="location.distanceBeach">5 minute drive to the beach</li>
            <li data-i18n="location.distanceOldTown">10 minute drive to Budva old town</li>
            <li data-i18n="location.distanceAirport">25 minutes from Tivat Airport</li>
          </ul>
          <a class="location__map-link" href="https://www.google.com/maps/search/?api=1&query=Budva%2C%20Montenegro" target="_blank" rel="noopener noreferrer" data-i18n="location.mapLinkLabel">Open in Google Maps</a>
        </div>
        <div class="location__media" id="location-image-slot"></div>
      </div>
    </section>
```

- [ ] **Step 2: Add location CSS to `css/style.css`**

```css
/* ===== Location ===== */
.location {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-6);
  align-items: center;
}
.location__distances {
  list-style: none;
  padding: 0;
  margin: var(--space-5) 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  color: var(--color-text-muted);
}
.location__distances li::before { content: '— '; color: var(--color-secondary); }
.location__map-link {
  display: inline-block;
  margin-top: var(--space-2);
  font-weight: 600;
  text-decoration: underline;
}
.location__media .placeholder-image { aspect-ratio: 1 / 1; }

@media (min-width: 1024px) {
  .location { grid-template-columns: 1fr 1fr; }
}
```

- [ ] **Step 3: Populate the location placeholder image in `js/app.js`**

```javascript
// js/app.js (add function and call)
function initLocation() {
  const slot = document.getElementById('location-image-slot');
  if (slot) {
    slot.innerHTML = renderPlaceholder({
      alt: 'Map area showing Budva, Montenegro',
      replaceWith: 'Static map image or embed centered on Budva, Montenegro',
      variant: 3,
      aspectRatio: '1 / 1'
    });
  }
}

// inside init():
  initLocation();
```

- [ ] **Step 4: Manual verification**

Open `demo.html`, scroll to Location. Confirm:
- Section shows the location description, three distance bullet points, a placeholder "map" image, and a working "Open in Google Maps" link.
- Inspect the map link: `target="_blank"` and `rel="noopener noreferrer"` are present; clicking it opens Google Maps search for Budva, Montenegro in a new tab.
- Layout stacks on mobile, becomes two columns at 1024px+.
- Language switch updates all text.

- [ ] **Step 5: Commit**

```bash
git add demo.html css/style.css js/app.js
git commit -m "Add location section with map placeholder and safe external link"
```

---

### Task 11: Reviews and FAQ sections

**Files:**
- Modify: `demo.html`
- Modify: `css/style.css`
- Modify: `js/app.js`

**Interfaces:**
- Consumes: `siteConfig.reviews` (Task 1, rendered once — review text stays fixed per guest's own language regardless of UI language switch, matching the brief's intent that these represent real international guests), `t()` (Task 3, for the FAQ questions/answers which do translate with the UI language, and for the reviews heading/demo label).
- Produces: `initReviews()` function in `js/app.js` that renders `siteConfig.reviews` into `#reviews-list`.

- [ ] **Step 1: Replace the `#reviews` and `#faq` sections in `demo.html`**

```html
    <section id="reviews" class="section section-alt">
      <div class="container">
        <h2 data-i18n="reviews.heading">What guests say</h2>
        <ul class="reviews-list" id="reviews-list"></ul>
      </div>
    </section>

    <section id="faq" class="section">
      <div class="container faq">
        <h2 data-i18n="faq.heading">Frequently asked questions</h2>
        <div class="faq-list">
          <details><summary data-i18n="faq.q1">Is direct booking possible?</summary><p data-i18n="faq.a1"></p></details>
          <details><summary data-i18n="faq.q2">What is the check-in time?</summary><p data-i18n="faq.a2"></p></details>
          <details><summary data-i18n="faq.q3">Is the pool private?</summary><p data-i18n="faq.a3"></p></details>
          <details><summary data-i18n="faq.q4">Is parking available?</summary><p data-i18n="faq.a4"></p></details>
          <details><summary data-i18n="faq.q5">How do I send a reservation request?</summary><p data-i18n="faq.a5"></p></details>
          <details><summary data-i18n="faq.q6">Are pets allowed?</summary><p data-i18n="faq.a6"></p></details>
        </div>
      </div>
    </section>
```

- [ ] **Step 2: Add reviews/FAQ CSS to `css/style.css`**

```css
/* ===== Reviews ===== */
.reviews-list {
  list-style: none;
  margin: var(--space-6) 0 0;
  padding: 0;
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-5);
}
.review-card {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  padding: var(--space-6);
}
.review-card__rating { color: var(--color-primary); font-size: var(--text-sm); }
.review-card__text { margin-top: var(--space-3); color: var(--color-text); font-style: italic; }
.review-card__meta { margin-top: var(--space-4); display: flex; justify-content: space-between; align-items: center; font-size: var(--text-sm); color: var(--color-text-muted); }
.review-card__badge {
  font-size: var(--text-xs);
  border: 1px solid var(--color-border);
  border-radius: 999px;
  padding: 2px var(--space-2);
}

@media (min-width: 1024px) {
  .reviews-list { grid-template-columns: repeat(3, 1fr); }
}

/* ===== FAQ ===== */
.faq-list { margin-top: var(--space-6); display: flex; flex-direction: column; gap: var(--space-3); max-width: 72ch; }
.faq-list details {
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: var(--space-4) var(--space-5);
  background: var(--color-surface);
}
.faq-list summary {
  cursor: pointer;
  font-weight: 600;
  min-height: 44px;
  display: flex;
  align-items: center;
}
.faq-list details p { margin-top: var(--space-3); color: var(--color-text-muted); }
```

- [ ] **Step 3: Add `initReviews()` and populate FAQ answer text (translated) in `js/app.js`**

```javascript
// js/app.js (add function)
function initReviews() {
  const list = document.getElementById('reviews-list');
  if (!list) return;
  list.innerHTML = siteConfig.reviews.map((review) => `
    <li class="review-card">
      <p class="review-card__rating" aria-label="${review.rating} out of 5 stars">${'★'.repeat(review.rating)}${'☆'.repeat(5 - review.rating)}</p>
      <p class="review-card__text">"${review.text}"</p>
      <div class="review-card__meta">
        <span>${review.name}, ${review.location}</span>
        <span class="review-card__badge" data-i18n="reviews.demoLabel">Demo review</span>
      </div>
    </li>
  `).join('');
}

// inside init(), call BEFORE initI18n() so the injected data-i18n badge gets translated on first paint,
// or call applyTranslations() again after initReviews() — simplest is to call initReviews() first:
```

Update the `init()` function so `initReviews()` runs before `initI18n()`:

```javascript
function init() {
  console.log('Villa Mare demo loaded', siteConfig.property.name);
  initReviews();
  initI18n();
  initMobileNav();
  initHero();
  initAmenities();
  initGallery('#gallery-grid');
  initCalendar('#calendar');
  initBookingForm('#booking-form');
  initLocation();
  refreshWhatsAppLinks();
  window.addEventListener('languagechange', refreshWhatsAppLinks);
  document.getElementById('calendar-grid')?.addEventListener('click', refreshWhatsAppLinks);
  document.getElementById('field-guests')?.addEventListener('input', refreshWhatsAppLinks);
}
```

The FAQ `<p data-i18n="faq.a1">` etc. elements are already empty in the HTML and get filled by the existing `applyTranslations()` call inside `initI18n()` — no extra JS needed for FAQ answers beyond what Task 3 already built.

- [ ] **Step 4: Manual verification**

Open `demo.html`, scroll to Reviews and FAQ. Confirm:
- Three review cards render with star ratings, guest name/location, and a "Demo review" badge; review text itself stays in German/Dutch/English respectively regardless of the site language switcher.
- Switching site language still translates the "What guests say" heading and "Demo review" badges (only the review body text stays fixed).
- FAQ shows 6 native `<details>/<summary>` items; clicking a question expands its answer; the answer text is present (not blank) and translates with the language switcher.
- Keyboard: Tab reaches each `<summary>`, Enter/Space toggles it.
- No console errors.

- [ ] **Step 5: Commit**

```bash
git add demo.html css/style.css js/app.js
git commit -m "Add reviews and FAQ sections"
```

---

### Task 12: Final CTA, footer, and theme toggle

**Files:**
- Modify: `demo.html`
- Modify: `css/style.css`
- Modify: `js/app.js`

**Interfaces:**
- Consumes: `siteConfig.contact`, `siteConfig.property` (Task 1), `t()` (Task 3).
- Produces: `initThemeToggle()` function in `js/app.js`, sets `document.documentElement.setAttribute('data-theme', ...)` (the attribute Task 1's dark-mode CSS block already targets).

- [ ] **Step 1: Replace the `#final-cta` section and footer in `demo.html`**

```html
    <section id="final-cta" class="section final-cta">
      <div class="container final-cta__inner">
        <h2 data-i18n="finalCta.heading">Would you like a direct booking website like this?</h2>
        <a class="btn btn-primary btn-large" href="https://directbalkan.com/en/contact.html" data-i18n="finalCta.cta">Create my accommodation website</a>
      </div>
    </section>
  </main>

  <footer class="site-footer">
    <div class="container site-footer__inner">
      <div class="site-footer__brand">
        <strong>Villa Mare Montenegro</strong>
        <span class="site-footer__badge" data-i18n="footer.demoLabel">Demo site</span>
      </div>

      <ul class="site-footer__links">
        <li><span data-i18n="footer.contact">Contact</span>: <a href="mailto:demo@example.com" id="footer-email">demo@example.com</a></li>
        <li><a href="#whatsapp" id="footer-whatsapp">WhatsApp</a></li>
        <li><a href="#" data-i18n="footer.privacy">Privacy policy</a></li>
        <li><a href="#" data-i18n="footer.terms">Terms</a></li>
      </ul>

      <div class="site-footer__meta">
        <button type="button" id="theme-toggle" class="theme-toggle" data-i18n-aria-label="footer.themeToggleDark" aria-label="Switch to dark mode">
          <span aria-hidden="true">◐</span>
        </button>
        <p data-i18n="footer.poweredBy">Powered by DirectBalkan</p>
      </div>
    </div>
  </footer>
```

Note: the closing `</main>` above must replace the existing one at the end of `demo.html` — remove the old `</main>` and `<footer>...</footer>` placeholder block from Task 2 so there is exactly one `<main>`/`<footer>` pair.

- [ ] **Step 2: Add final-CTA/footer/theme-toggle CSS to `css/style.css`**

```css
/* ===== Final CTA ===== */
.final-cta { text-align: center; }
.final-cta__inner { display: flex; flex-direction: column; align-items: center; gap: var(--space-6); max-width: 640px; margin-inline: auto; }

/* ===== Footer ===== */
.site-footer {
  background: var(--color-bg-alt);
  border-top: 1px solid var(--color-border);
  padding-block: var(--space-7);
}
.site-footer__inner {
  display: flex;
  flex-direction: column;
  gap: var(--space-5);
}
.site-footer__brand { display: flex; align-items: center; gap: var(--space-3); }
.site-footer__badge {
  font-size: var(--text-xs);
  border: 1px solid var(--color-border);
  border-radius: 999px;
  padding: 2px var(--space-2);
  color: var(--color-text-muted);
}
.site-footer__links { list-style: none; display: flex; flex-wrap: wrap; gap: var(--space-5); padding: 0; margin: 0; font-size: var(--text-sm); }
.site-footer__meta { display: flex; align-items: center; gap: var(--space-3); font-size: var(--text-sm); color: var(--color-text-muted); }
.theme-toggle {
  min-width: 44px; min-height: 44px;
  border: 1px solid var(--color-border);
  background: var(--color-surface);
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: var(--text-lg);
}

@media (min-width: 1024px) {
  .site-footer__inner { flex-direction: row; align-items: center; justify-content: space-between; }
}
```

- [ ] **Step 3: Add `initThemeToggle()` and footer contact wiring in `js/app.js`**

```javascript
// js/app.js (add import and functions)
import { t } from './i18n.js';

function initThemeToggle() {
  const toggle = document.getElementById('theme-toggle');
  if (!toggle) return;
  toggle.addEventListener('click', () => {
    const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
    const nextTheme = isDark ? 'light' : 'dark';
    document.documentElement.setAttribute('data-theme', nextTheme);
    toggle.setAttribute('aria-label', nextTheme === 'dark' ? t('footer.themeToggleDark') : t('footer.themeToggleLight'));
  });
}

function initFooterContact() {
  const emailLink = document.getElementById('footer-email');
  if (emailLink) {
    emailLink.href = `mailto:${siteConfig.contact.email}`;
    emailLink.textContent = siteConfig.contact.email;
  }
  const whatsappLink = document.getElementById('footer-whatsapp');
  if (whatsappLink) whatsappLink.addEventListener('click', (e) => e.preventDefault() || document.getElementById('whatsapp')?.scrollIntoView({ behavior: 'smooth' }));
}

// inside init():
  initThemeToggle();
  initFooterContact();
```

- [ ] **Step 4: Manual verification**

Open `demo.html`, scroll to the bottom. Confirm:
- Final CTA renders with heading and a button linking to `https://directbalkan.com/en/contact.html`.
- Footer shows property name, "Demo site" badge, working `mailto:` link built from config, a WhatsApp link that smooth-scrolls to the WhatsApp section, privacy/terms placeholder links, the theme toggle, and "Powered by DirectBalkan".
- Clicking the theme toggle switches the whole page to dark-mode tokens (background/text/surface colors all flip per Task 1's `[data-theme="dark"]` block) and the toggle's `aria-label` updates; clicking again reverts to light.
- There is exactly one `<main>` and one `<footer>` in the page (View Source / Elements panel) — no leftover duplicate from Task 2's scaffold.
- No console errors.

- [ ] **Step 5: Commit**

```bash
git add demo.html css/style.css js/app.js
git commit -m "Add final CTA, footer, and dark-mode theme toggle"
```

---

### Task 13: Scroll-reveal polish and reduced-motion handling

**Files:**
- Modify: `css/style.css`
- Modify: `js/app.js`

**Interfaces:**
- Consumes: nothing new — operates on existing `.section` elements already in the DOM from prior tasks.
- Produces: `initScrollReveal()` function in `js/app.js`, adds/relies on a `.reveal` / `.reveal--visible` CSS pair.

- [ ] **Step 1: Add reveal CSS to `css/style.css`**

```css
/* ===== Scroll reveal ===== */
.reveal {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity var(--transition-slow), transform var(--transition-slow);
}
.reveal--visible {
  opacity: 1;
  transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
  .reveal { opacity: 1; transform: none; transition: none; }
}
```

- [ ] **Step 2: Add `initScrollReveal()` to `js/app.js` and apply the `.reveal` class to sections**

```javascript
// js/app.js (add function)
function initScrollReveal() {
  const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const sections = document.querySelectorAll('main > section');
  sections.forEach((section) => section.classList.add('reveal'));

  if (prefersReducedMotion || !('IntersectionObserver' in window)) {
    sections.forEach((section) => section.classList.add('reveal--visible'));
    return;
  }

  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        entry.target.classList.add('reveal--visible');
        observer.unobserve(entry.target);
      }
    });
  }, { threshold: 0.15 });

  sections.forEach((section) => observer.observe(section));
}

// inside init():
  initScrollReveal();
```

- [ ] **Step 3: Manual verification**

Open `demo.html`. Confirm:
- Sections below the fold fade/slide into view as you scroll to them; the hero (`#accommodation`, first section) is visible immediately.
- In DevTools, enable "Emulate CSS prefers-reduced-motion: reduce" (Rendering tab): all sections appear instantly with no animation.
- No layout shift/jank, no console errors.

- [ ] **Step 4: Commit**

```bash
git add css/style.css js/app.js
git commit -m "Add scroll-reveal animation respecting prefers-reduced-motion"
```

---

### Task 14: Mobile sticky CTA bar

**Files:**
- Modify: `demo.html`
- Modify: `css/style.css`
- Modify: `js/app.js`

**Interfaces:**
- Consumes: `t()` (Task 3), `buildWhatsAppLink()` (Task 9, already refreshed by `refreshWhatsAppLinks()`).
- Produces: `initStickyCta()` function in `js/app.js`.

- [ ] **Step 1: Add the sticky CTA bar markup to `demo.html`, immediately before `<script type="module" src="js/app.js">`**

```html
  <div class="mobile-sticky-cta" id="mobile-sticky-cta">
    <a class="btn btn-primary" href="#availability" data-i18n="hero.ctaPrimary">Check availability</a>
    <a class="btn btn-secondary" id="sticky-whatsapp-cta" href="#" target="_blank" rel="noopener noreferrer" data-i18n="hero.ctaWhatsapp">Ask on WhatsApp</a>
  </div>

  <script type="module" src="js/app.js"></script>
```

- [ ] **Step 2: Add sticky-CTA CSS to `css/style.css`**

```css
/* ===== Mobile sticky CTA ===== */
.mobile-sticky-cta {
  display: none;
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 90;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  background: var(--color-surface);
  border-top: 1px solid var(--color-border);
  box-shadow: 0 -4px 16px rgba(42, 36, 32, 0.08);
}
.mobile-sticky-cta .btn { flex: 1; }

@media (max-width: 1023px) {
  .mobile-sticky-cta { display: flex; }
  body { padding-bottom: 76px; } /* keep footer content from being covered */
}
```

- [ ] **Step 3: Include the sticky WhatsApp link in `refreshWhatsAppLinks()` in `js/app.js`**

```javascript
// js/app.js — update refreshWhatsAppLinks() to include the new sticky link
function refreshWhatsAppLinks() {
  const links = [
    document.getElementById('hero-whatsapp-cta'),
    document.getElementById('whatsapp-cta-link'),
    document.getElementById('sticky-whatsapp-cta')
  ];
  const href = buildWhatsAppLink();
  links.forEach((link) => { if (link) link.href = href; });
}
```

- [ ] **Step 4: Manual verification**

Resize the browser to ≤1023px (or use device toolbar at 375px). Confirm:
- A sticky two-button bar appears fixed to the bottom of the viewport with "Check availability" and "Ask on WhatsApp".
- It does not cover footer content (footer remains fully readable/clickable by scrolling to the very bottom).
- It disappears at ≥1024px viewport width.
- Both buttons work (jump to availability anchor; WhatsApp link carries the current prefilled message).
- No horizontal overflow introduced by the bar.

- [ ] **Step 5: Commit**

```bash
git add demo.html css/style.css js/app.js
git commit -m "Add non-blocking mobile sticky CTA bar"
```

---

### Task 15: SEO meta, structured data, performance attributes

**Files:**
- Modify: `demo.html`

**Interfaces:**
- Consumes: `siteConfig.property`/`siteConfig.contact` values, copied as literal text into meta tags and JSON-LD (this file has no JS templating for `<head>` content, so values are hand-copied from `config.js` — a future client swap must update both `config.js` and these tags, noted in the README in Task 16).

- [ ] **Step 1: Expand the `<head>` in `demo.html`**

```html
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Villa Mare Montenegro — Private Villa with Pool in Budva | Direct Booking Demo</title>
  <meta name="description" content="Villa Mare Montenegro: a private 3-bedroom villa with pool and sea view in Budva, Montenegro. Request your stay directly with the host — no booking commission. Interactive DirectBalkan demo." />
  <link rel="canonical" href="https://demo.directbalkan.com/demo.html" />
  <link rel="icon" href="assets/logo.svg" type="image/svg+xml" />

  <meta property="og:type" content="website" />
  <meta property="og:title" content="Villa Mare Montenegro — Direct Booking Demo" />
  <meta property="og:description" content="A private villa with pool and sea view in Budva, Montenegro. Direct booking, no commission." />
  <meta property="og:url" content="https://demo.directbalkan.com/demo.html" />
  <meta property="og:locale" content="en_US" />

  <link rel="stylesheet" href="css/style.css" />

  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "LodgingBusiness",
    "name": "Villa Mare Montenegro",
    "description": "Private 3-bedroom villa with pool and sea view in Budva, Montenegro. Demo listing for DirectBalkan.",
    "address": {
      "@type": "PostalAddress",
      "addressLocality": "Budva",
      "addressCountry": "ME"
    },
    "amenityFeature": [
      { "@type": "LocationFeatureSpecification", "name": "Private pool", "value": true },
      { "@type": "LocationFeatureSpecification", "name": "Sea view", "value": true },
      { "@type": "LocationFeatureSpecification", "name": "Free Wi-Fi", "value": true },
      { "@type": "LocationFeatureSpecification", "name": "Free parking", "value": true }
    ],
    "numberOfRooms": 3,
    "petsAllowed": true
  }
  </script>
</head>
```

- [ ] **Step 2: Add `width`/`height`/`loading`/`decoding` attributes to below-the-fold raster-equivalent slots**

The gallery/location placeholders are inline SVG rendered via JS, so they have no `<img>` tag to annotate — but add explicit dimensions to the `.placeholder-image` figure via CSS `aspect-ratio` (already done in Tasks 1/4/5/10) so layout doesn't shift, which is the CLS-prevention goal `width`/`height`/`loading="lazy"` normally serve for real `<img>` tags. Add this note as an HTML comment directly above the `#gallery-grid` div in `demo.html` so a future implementer swapping in real photos knows to carry the attributes forward:

```html
        <!-- When replacing placeholders with real photos, add loading="lazy" decoding="async"
             width="800" height="600" (or your image's real dimensions) to each <img> tag
             to preserve layout stability and defer offscreen image loads. The hero image
             should NOT use loading="lazy" (it's above the fold). -->
        <div class="gallery-grid" id="gallery-grid"></div>
```

- [ ] **Step 3: Manual verification**

Open `demo.html`. Confirm:
- Browser tab title matches the new `<title>`.
- View Source shows the meta description, canonical, OG tags, favicon link, and the JSON-LD script block.
- Paste the JSON-LD block into a JSON validator (or `JSON.parse()` it in the DevTools console) — confirms it's syntactically valid.
- No console errors from the added `<head>` content.

- [ ] **Step 4: Commit**

```bash
git add demo.html
git commit -m "Add SEO meta tags, Open Graph tags, and LodgingBusiness structured data"
```

---

### Task 16: README documentation and final QA pass

**Files:**
- Create: `README.md`
- Create: `robots.txt`
- Create: `sitemap.xml`

**Interfaces:**
- Consumes: nothing (documentation task) — but must accurately describe every file/config field produced by Tasks 1–15.

- [ ] **Step 1: Create `robots.txt`**

```
User-agent: *
Allow: /

Sitemap: https://demo.directbalkan.com/sitemap.xml
```

- [ ] **Step 2: Create `sitemap.xml`**

```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://demo.directbalkan.com/demo.html</loc>
    <changefreq>monthly</changefreq>
    <priority>1.0</priority>
  </url>
</urlset>
```

- [ ] **Step 3: Create `README.md`**

```markdown
# Villa Mare Montenegro — DirectBalkan Demo Template

Interactive demo of a direct-booking accommodation website, built for DirectBalkan
to show prospective clients (accommodation owners) what a DirectBalkan site looks
like, and to serve as the reusable template for real client builds.

**This is a demo.** No real bookings, payments, or messages are processed against
a live host. Availability and reviews are fixed example data.

## Running locally

No build step required. Either:

- Open `demo.html` directly in a browser, or
- Serve the folder with any static file server, e.g. `npx live-server` from this
  directory, then visit the printed local URL.

ES modules (`js/*.js`) require the page to be loaded via `http://` or `https://`
in some browsers when using certain features — if anything behaves oddly when
double-clicking the file directly, use a local static server instead.

## Project structure

| File | Responsibility |
|---|---|
| `demo.html` | Page markup and section structure |
| `css/style.css` | All styling, design tokens, light/dark themes |
| `js/config.js` | **All client-editable data** — property info, contact, branding, availability, gallery captions, reviews |
| `js/placeholder.js` | Renders styled inline-SVG placeholders for image slots without real photos |
| `js/i18n.js` | EN/DE/NL translation dictionary and language switching |
| `js/calendar.js` | Availability calendar widget |
| `js/gallery.js` | Image grid + lightbox |
| `js/form.js` | Booking request form, validation, Make.com webhook |
| `js/app.js` | Wiring: init calls, nav, theme toggle, scroll reveal, WhatsApp links, sticky CTA |
| `assets/logo.svg` | Inline Villa Mare wordmark |

## Creating a new accommodation site from this template

For most changes, you only need to edit `js/config.js` and the translation
strings in `js/i18n.js` — no component logic needs to change:

1. **Property details, contact, branding colors, WhatsApp number, availability
   dates, gallery captions, reviews** → edit the `siteConfig` object in
   `js/config.js`.
2. **All visible copy** (nav, hero, buttons, FAQ, footer, error messages) →
   edit the `translations` object in `js/i18n.js`. Keep the same key structure
   across `en`/`de`/`nl` (and any new language you add to `siteConfig.languages`).
3. **Colors** → change `siteConfig.branding.primaryColor` /
   `secondaryColor` for reference, and update the matching `--color-primary`
   / `--color-secondary` custom properties (and their dark-mode equivalents)
   near the top of `css/style.css`.
4. **Photos** — replace placeholders one section at a time:
   - Hero: in `js/app.js`, `initHero()` calls `renderPlaceholder(...)` on
     `#hero-image-slot`. Replace that call with a real `<img>` tag (no
     `loading="lazy"` — it's above the fold).
   - Gallery: `siteConfig.gallery` in `config.js` is the array `gallery.js`
     loops over. Replace each entry's placeholder rendering in `gallery.js`
     with a real `<img src="assets/images/...">`, keeping `alt` text from
     the config entry. Add `loading="lazy" decoding="async" width height`
     to each image.
   - Location: same pattern in `initLocation()` in `js/app.js`.
5. **WhatsApp** → set `siteConfig.contact.whatsapp` to the real number in
   international format without `+` or spaces (e.g. `38267000000`).
6. **Languages** → add/remove entries in `siteConfig.languages` and add a
   matching top-level key to `translations` in `js/i18n.js`, plus a
   `data-lang-switch="xx"` button in the header markup in `demo.html`.

## Connecting Make.com

1. Create a Custom Webhook trigger in Make.com and copy its URL.
2. Paste it into `siteConfig.booking.makeWebhookUrl` in `js/config.js`.
3. The form (`js/form.js`) will now `POST` this JSON payload on submit:

```json
{
  "event": "demo_booking_request",
  "demo": true,
  "property": "Villa Mare Montenegro",
  "arrival": "2026-08-20",
  "departure": "2026-08-25",
  "guests": 4,
  "name": "Jane Doe",
  "email": "jane@example.com",
  "whatsapp": "+31612345678",
  "message": "Looking forward to it!",
  "language": "en",
  "source": "directbalkan_demo"
}
```

**Security note:** this webhook URL is called directly from the browser. Before
using this in production for a real client, put a server-side layer in front
of it (e.g. a small serverless function) that validates input, applies rate
limiting, and adds spam protection (honeypot field or a CAPTCHA) before
forwarding to Make.com. Calling a public Make.com webhook straight from
client-side JavaScript, unprotected, is fine for a sales demo but not for a
production booking form.

## Demo → production checklist

- [ ] Replace all placeholder images with real photography (see "Photos" above).
- [ ] Set the real Make.com webhook URL and add the server-side protection layer noted above.
- [ ] Replace `siteConfig.demoMode = true` messaging (the "Interactive demo" banner
      in `demo.html` and `demoBanner` key in `js/i18n.js`) — remove or adapt for
      a real client's site.
- [ ] Replace demo reviews in `siteConfig.reviews` with real guest reviews (with permission).
- [ ] Replace `siteConfig.availability` demo data with a real availability feed
      or manual updates from the host.
- [ ] Update `<title>`, meta description, canonical URL, Open Graph tags, and
      the JSON-LD `LodgingBusiness` block in `demo.html` `<head>` for the real property.
- [ ] Point `robots.txt`'s `Sitemap:` line and `sitemap.xml`'s `<loc>` at the real domain.
- [ ] Replace footer privacy/terms placeholder links (`href="#"`) with real pages.

## Custom domain setup (future)

This is a static site — any static host (Netlify, Vercel, Cloudflare Pages, or
traditional shared hosting via FTP) works. Point the client's domain's DNS at
the chosen host, upload this folder's contents to the web root, and update
`robots.txt` / `sitemap.xml` / canonical tags to the final domain.

## Known limitations

- No automated tests — this is a static marketing/demo site; verification is
  manual (see the implementation plan's per-task QA steps).
- Calendar availability only covers August–September 2026 demo data; extend
  `buildDemoAvailability()` in `js/config.js` for a longer real-world window.
- Reviews are fixed demo content and do not translate with the language
  switcher (intentional — they represent real guests writing in their own
  language).
- No online payment, iCal sync, multi-unit support, price calculation, or
  real Google Maps embed — see "Optional modules" in the original brief for
  what can be layered on later without restructuring this template.

## Architecture summary

Static HTML + CSS + vanilla ES modules, no build step. `js/config.js` holds
all client-swappable data; `js/i18n.js` holds all UI copy in three languages;
one focused module per interactive widget (`calendar.js`, `gallery.js`,
`form.js`); `js/app.js` wires everything together and owns cross-cutting
concerns (navigation, theme, scroll reveal, WhatsApp link generation). This
separation means a new client site is primarily a content/config edit, not a
rebuild.

## QA checklist

- [ ] Works on desktop and at 375px mobile width, no horizontal overflow
- [ ] Header navigation and mobile hamburger menu work
- [ ] Gallery grid and lightbox (open/close/prev/next/keyboard) work
- [ ] Language switcher (EN/DE/NL) updates all copy instantly, no reload
- [ ] Calendar renders demo availability, date selection and range highlight work
- [ ] Booking form validates required fields with specific error messages
- [ ] Booking form shows the success message and simulates the webhook when `makeWebhookUrl` is empty
- [ ] WhatsApp links (hero, dedicated section, sticky bar, footer) carry a correctly prefilled message
- [ ] FAQ `<details>` sections expand/collapse and are keyboard-operable
- [ ] Dark-mode toggle switches all tokens correctly and persists only for the session
- [ ] Keyboard navigation reaches every interactive element with a visible focus ring
- [ ] `prefers-reduced-motion` disables scroll-reveal and other transitions
- [ ] No console errors anywhere on the page
- [ ] Demo content (banner, badges, availability notice, review labels) is clearly marked
- [ ] A new accommodation can be created by editing only `js/config.js` and `js/i18n.js`
```

- [ ] **Step 4: Full manual QA pass**

Work through every checkbox in the README's "QA checklist" section on both a
desktop-width browser window and a 375px-wide emulated device, for all three
languages and both themes. Fix anything that fails before considering the
demo complete.

- [ ] **Step 5: Commit**

```bash
git add README.md robots.txt sitemap.xml
git commit -m "Add README, robots.txt, sitemap.xml, and final QA documentation"
```

---
