# Simon's Guest House Rome — Redesign 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:** Rebuild the Simon's Guest House Rome website as a fast, bold, 15-language **static Astro** site (SEO/GEO-optimized) deployable to Aruba shared Apache. **Architecture:** Astro static output with native i18n (`prefixDefaultLocale: true`, one `/{lang}/` tree per locale). A single `LOCALES` config module is the source of truth for languages, paths, hreflang codes, direction, and Amenitiz mapping. Pure logic (locale resolution, hreflang/canonical URL building, Amenitiz deep-link mapping) lives in tested helper modules; pages/components are thin presentational layers. Images optimized at build via `astro:assets`. Apache behavior (clean URLs, caching, compression, 404) via `.htaccess`. Root `/` is a JS bridge that detects/remembers language. **Tech Stack:** Astro 5.x · `@astrojs/sitemap` · `astro:assets` (Sharp) · Vitest (unit) · Playwright `@playwright/test` (E2E) · Lighthouse CI · self-hosted variable fonts (Fraunces, Inter, Noto/IBM Plex for non-Latin scripts) · plain CSS with logical properties. **Spec:** `docs/superpowers/specs/2026-06-24-simons-guest-house-redesign-design.md` (read it; room texts are frozen in §6). ## Global Constraints - **Output 100% static** (`output: 'static'`). No PHP/MySQL, no server runtime, no contact form. - **Host:** Aruba shared Apache. Deploy = upload `dist/` to `public_html`. All paths/filenames **lowercase** (case-sensitive FS). - **Domain (canonical):** `https://www.simonsguesthouserome.com` (with `www`). - **Room content is FROZEN** — Italian source texts in spec §6 must appear **verbatim**; other languages are faithful translations. Never edit/paraphrase the Italian. - **15 locales**, paths lowercase, default + x-default = **English (`en`)**. - **Booking** is external Amenitiz: `https://simonsguesthouserome.amenitiz.io/{amenitizLocale}/booking/room`. Never mirror live prices. - **Legal (Italy):** show **CIN** + business data + Privacy/Cookie pages. Use placeholders `[[CIN]]`, `[[P.IVA]]`, `[[RAGIONE_SOCIALE]]` until client provides them (single source: `src/config/business.ts`). - **Trailing slash policy:** `trailingSlash: 'never'` + `build.format: 'file'`. Astro, sitemap, `
` canonical/alternates, and `.htaccess` MUST all agree. - **Motion** must honor `prefers-reduced-motion`. **A11y** target WCAG 2.2 AA. **Perf** target Lighthouse ≥ 95 (mobile) on Home & Camere. - **Responsive, mobile-first:** must look and work great on **phone, tablet, desktop**. Verify at ~360 / 768 / 1024 / 1440px: no horizontal overflow, tap targets ≥44px, nav collapses to hamburger <900px, fluid type/spacing (`clamp`), responsive images with correct `sizes`. - **Single source for locales:** `src/config/locales.ts` is the ONLY place the locale list/hreflang map is authored; `astro.config.ts` imports it. The static root bridge (`public/index.html`) must inline the codes but is guarded by a test that asserts it matches `LOCALE_CODES`. - **No external Google Fonts call** (self-host). **Allow AI crawlers** in robots.txt. - Commit after every task. Conventional Commits messages. --- ## File Structure (created across the plan) ``` /(root = Astro app) .gitignore package.json astro.config.mjs tsconfig.json vitest.config.ts playwright.config.ts src/ config/ locales.ts # LOCALES source of truth + helpers business.ts # NAP, CIN, P.IVA, geo, socials, Amenitiz base lib/ i18n.ts # getLangFromUrl, useTranslations, resolveLocale urls.ts # buildAlternates, canonicalFor, amenitizUrl i18n.test.ts urls.test.ts i18n/ ui.ts # UI string dictionary (per locale) content/ # page content per locale (it.ts, en.ts, ... ) layouts/Base.astro components/ Head.astro Hreflang.astro JsonLd.astro Header.astro LangSwitcher.astro Footer.astro Hero.astro SectionDivider.astro ScrollReveal.astro Gallery.astro RoomSection.astro Faq.astro Reviews.astro BookingCta.astro pages/ [lang]/index.astro [lang]/camere.astro [lang]/chi-siamo.astro [lang]/contatti.astro [lang]/privacy.astro [lang]/cookie.astro assets/images/... # curated source photos (optimized at build) styles/tokens.css styles/global.css styles/fonts.css public/ index.html # root language bridge .htaccess robots.txt llms.txt 404.html favicon.svg fonts/... # self-hosted subset fonts scripts/ validate-hreflang.mjs validate-schema.mjs _source-images/ # preserved source photos: originals/ (hi-res) + web/ (+ logo) → optimized at build docs/superpowers/{specs,plans}/... ``` --- # Phase 0 — Foundation ### Task 1: Initialize repo and Astro project **Files:** - Create: `package.json`, `.gitignore`, `astro.config.mjs`, `tsconfig.json` > Legacy cleanup already DONE before execution: old Mobirise files removed; source photos preserved in `_source-images/{originals,web}`; folder contains only `docs/` + `_source-images/`. - [ ] **Step 1: Init git** ```bash cd "/Users/paolo/Server/Siti Web/Lavoro/Simons Guest House" git init && ls -la ``` Expected: repo initialized; root has `docs/` and `_source-images/`. - [ ] **Step 2: Scaffold Astro into a temp dir, then merge to root** (`npm create astro .` refuses a non-empty dir, so scaffold in `.astro-init/` and move files in — `cp -a src/.` includes dotfiles like `.gitignore`, `tsconfig.json`.) ```bash npm create astro@latest .astro-init -- --template minimal --no-install --no-git --typescript strict --yes cp -a .astro-init/. ./ rm -rf .astro-init npm install npm install @astrojs/sitemap npm install -D vitest @playwright/test @lhci/cli ``` Expected: `package.json`, `astro.config.mjs`, `src/`, `tsconfig.json` in root; `docs/` and `_source-images/` untouched. - [ ] **Step 3: Add scripts to package.json** Edit `package.json` `"scripts"` to: ```json { "dev": "astro dev", "build": "NODE_OPTIONS=--max-old-space-size=4096 astro build", "preview": "astro preview", "test": "vitest run", "test:watch": "vitest", "e2e": "playwright test", "lhci": "lhci autorun" } ``` - [ ] **Step 4: .gitignore** Ensure `.gitignore` contains (merge with the scaffold's; do not drop these lines): ``` node_modules/ dist/ .astro/ .lighthouseci/ test-results/ playwright-report/ .superpowers/ _source-images/ .DS_Store ``` - [ ] **Step 5: Verify scaffold builds and commit** ```bash npm run build ``` Expected: build succeeds, `dist/` created. ```bash git add -A git commit -m "chore: init Astro project; preserve legacy Mobirise site under _legacy/" ``` --- ### Task 2: Locales config (source of truth) **Files:** - Create: `src/config/locales.ts` - Create: `src/config/business.ts` **Interfaces:** - Produces: `LOCALES: Locale[]`, `DEFAULT_LOCALE='en'`, `LOCALE_CODES: string[]`, `localeByPath(path): Locale|undefined`, type `Locale = { path: string; hreflang: string; htmlLang: string; dir: 'ltr'|'rtl'; label: string; amenitiz: string }`. - Produces: `BUSINESS` object (NAP, geo, CIN, socials, amenitizBase). - [ ] **Step 1: Write `src/config/locales.ts`** ```ts export type Locale = { path: string; // URL segment, lowercase, e.g. 'pt', 'zh', 'pt-pt' hreflang: string; // BCP-47 for hreflang/inLanguage, e.g. 'pt-BR' htmlLang: string; // value dir: 'ltr' | 'rtl'; label: string; // native name for the switcher amenitiz: string; // locale segment for Amenitiz booking deep-link }; // SINGLE SOURCE OF TRUTH. Add/swap a locale by editing this array only. // 15th slot default = pt-PT (per spec §13); swap to e.g. // { path:'uk', hreflang:'uk', htmlLang:'uk', dir:'ltr', label:'Українська', amenitiz:'en' } // per the client's real guest mix. export const LOCALES: Locale[] = [ { path: 'en', hreflang: 'en', htmlLang: 'en', dir: 'ltr', label: 'English', amenitiz: 'en' }, { path: 'it', hreflang: 'it', htmlLang: 'it', dir: 'ltr', label: 'Italiano', amenitiz: 'it' }, { path: 'es', hreflang: 'es', htmlLang: 'es', dir: 'ltr', label: 'Español', amenitiz: 'es' }, { path: 'de', hreflang: 'de', htmlLang: 'de', dir: 'ltr', label: 'Deutsch', amenitiz: 'de' }, { path: 'fr', hreflang: 'fr', htmlLang: 'fr', dir: 'ltr', label: 'Français', amenitiz: 'fr' }, { path: 'pt', hreflang: 'pt-BR', htmlLang: 'pt-BR', dir: 'ltr', label: 'Português', amenitiz: 'pt' }, { path: 'zh', hreflang: 'zh-Hans', htmlLang: 'zh-Hans', dir: 'ltr', label: '中文', amenitiz: 'zh' }, { path: 'ja', hreflang: 'ja', htmlLang: 'ja', dir: 'ltr', label: '日本語', amenitiz: 'ja' }, { path: 'ko', hreflang: 'ko', htmlLang: 'ko', dir: 'ltr', label: '한국어', amenitiz: 'ko' }, { path: 'ar', hreflang: 'ar', htmlLang: 'ar', dir: 'rtl', label: 'العربية', amenitiz: 'en' }, { path: 'nl', hreflang: 'nl', htmlLang: 'nl', dir: 'ltr', label: 'Nederlands', amenitiz: 'nl' }, { path: 'pl', hreflang: 'pl', htmlLang: 'pl', dir: 'ltr', label: 'Polski', amenitiz: 'pl' }, { path: 'ru', hreflang: 'ru', htmlLang: 'ru', dir: 'ltr', label: 'Русский', amenitiz: 'ru' }, { path: 'he', hreflang: 'he', htmlLang: 'he', dir: 'rtl', label: 'עברית', amenitiz: 'en' }, { path: 'pt-pt', hreflang: 'pt-PT', htmlLang: 'pt-PT', dir: 'ltr', label: 'Português (PT)', amenitiz: 'pt' }, ]; export const DEFAULT_LOCALE = 'en'; export const LOCALE_CODES = LOCALES.map((l) => l.path); export const localeByPath = (path: string): Locale | undefined => LOCALES.find((l) => l.path === path); ``` > Note: `amenitiz` values are best-guesses; Task 14 verifies the real Amenitiz locale list and adjusts unsupported ones to a confirmed fallback. - [ ] **Step 2: Write `src/config/business.ts`** ```ts export const BUSINESS = { name: "Simon's Guest House Rome", legalName: '[[RAGIONE_SOCIALE]]', vat: '[[P.IVA]]', cin: '[[CIN]]', email: 'simonsguesthouse@gmail.com', phones: ['+390669408963', '+393517400964'], whatsapp: '390669408963', instagram: 'https://www.instagram.com/simonsguesthouse/', address: { street: 'Piazza Michele Sanmicheli, 10', locality: 'Roma', region: 'RM', postalCode: '00176', country: 'IT' }, geo: { lat: 41.8896, lng: 12.5283 }, // PLACEHOLDER — verify exact coords in Task 21 hours: 'Mo-Su 10:30-19:00', numberOfRooms: 5, // spec default; confirm with client amenitizBase: 'https://simonsguesthouserome.amenitiz.io', domain: 'https://www.simonsguesthouserome.com', } as const; ``` - [ ] **Step 3: Typecheck and commit** ```bash npx astro check || npx tsc --noEmit git add -A && git commit -m "feat(config): add LOCALES source of truth and BUSINESS data" ``` --- ### Task 3: Astro config — i18n, sitemap, images, trailing slash **Files:** - Modify: `astro.config.mjs` - [ ] **Step 1: Replace scaffold config with `astro.config.ts` (single source: import LOCALES)** Delete the scaffold's `astro.config.mjs` and create `astro.config.ts`. Importing the shared `src/config/locales.ts` keeps the locale list + hreflang map in ONE place (no duplication). ```bash rm -f astro.config.mjs ``` ```ts import { defineConfig } from 'astro/config'; import sitemap from '@astrojs/sitemap'; import { LOCALES, DEFAULT_LOCALE } from './src/config/locales'; const localePaths = LOCALES.map((l) => l.path); const hreflangMap = Object.fromEntries(LOCALES.map((l) => [l.path, l.hreflang])); export default defineConfig({ site: 'https://www.simonsguesthouserome.com', output: 'static', trailingSlash: 'never', build: { format: 'file' }, i18n: { defaultLocale: DEFAULT_LOCALE, locales: localePaths, routing: { prefixDefaultLocale: true, redirectToDefaultLocale: false }, }, image: { layout: 'constrained', responsiveStyles: true }, integrations: [ sitemap({ i18n: { defaultLocale: DEFAULT_LOCALE, locales: hreflangMap } }), ], }); ``` - [ ] **Step 2: Verify build still works and commit** ```bash npm run build ``` Expected: build succeeds (no pages yet beyond scaffold; that's fine). ```bash git add -A && git commit -m "feat(config): astro i18n (15 locales), sitemap hreflang, static + clean URLs" ``` --- ### Task 4: i18n helpers (TDD — pure functions) **Files:** - Create: `src/lib/i18n.ts`, `src/lib/i18n.test.ts` - Create: `vitest.config.ts` **Interfaces:** - Produces: `getLangFromUrl(url: URL): string`, `stripLocale(pathname: string): string`, `resolveLocale(candidates: string[], supported: string[], fallback: string): string`. - [ ] **Step 1: vitest.config.ts** ```ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['src/**/*.test.ts'] } }); ``` - [ ] **Step 2: Write failing tests `src/lib/i18n.test.ts`** ```ts import { describe, it, expect } from 'vitest'; import { getLangFromUrl, stripLocale, resolveLocale } from './i18n'; describe('getLangFromUrl', () => { it('reads the locale segment', () => { expect(getLangFromUrl(new URL('https://x.com/it/camere'))).toBe('it'); }); it('falls back to en when first segment is not a known locale', () => { expect(getLangFromUrl(new URL('https://x.com/unknown/page'))).toBe('en'); }); }); describe('stripLocale', () => { it('removes the locale prefix', () => { expect(stripLocale('/it/camere')).toBe('/camere'); }); it('returns / for a locale root', () => { expect(stripLocale('/it')).toBe('/'); }); }); describe('resolveLocale', () => { const supported = ['en','it','fr','pt','zh']; it('matches exact', () => { expect(resolveLocale(['fr'], supported, 'en')).toBe('fr'); }); it('matches primary subtag (fr-CA -> fr)', () => { expect(resolveLocale(['fr-CA'], supported, 'en')).toBe('fr'); }); it('maps pt-BR to pt path', () => { expect(resolveLocale(['pt-BR'], supported, 'en')).toBe('pt'); }); it('falls back to default when unsupported', () => { expect(resolveLocale(['sv','da'], supported, 'en')).toBe('en'); }); it('respects candidate order (saved choice first)', () => { expect(resolveLocale(['de','it'], supported, 'en')).toBe('it'); }); }); ``` - [ ] **Step 3: Run tests, verify they FAIL** Run: `npm test` Expected: FAIL (module `./i18n` has no such exports). - [ ] **Step 4: Implement `src/lib/i18n.ts`** ```ts import { LOCALE_CODES, DEFAULT_LOCALE } from '../config/locales'; export function getLangFromUrl(url: URL): string { const seg = url.pathname.split('/')[1]; return LOCALE_CODES.includes(seg) ? seg : DEFAULT_LOCALE; } export function stripLocale(pathname: string): string { const parts = pathname.split('/').filter(Boolean); if (parts.length && LOCALE_CODES.includes(parts[0])) parts.shift(); return '/' + parts.join('/'); } // candidates: ordered preference list (e.g. [savedChoice, ...navigator.languages]) export function resolveLocale(candidates: string[], supported: string[], fallback: string): string { for (const raw of candidates) { if (!raw) continue; const lc = raw.toLowerCase(); if (supported.includes(lc)) return lc; // exact path match const primary = lc.split('-')[0]; if (supported.includes(primary)) return primary; // primary subtag } return fallback; } ``` - [ ] **Step 5: Run tests, verify PASS, commit** Run: `npm test` Expected: PASS (all). ```bash git add -A && git commit -m "feat(i18n): locale helpers with unit tests" ``` --- ### Task 5: URL/hreflang/Amenitiz helpers (TDD) **Files:** - Create: `src/lib/urls.ts`, `src/lib/urls.test.ts` **Interfaces:** - Produces: `buildAlternates(pathNoLocale: string): {hreflang:string, href:string}[]` (absolute, includes self + x-default), `canonicalFor(localePath: string, pathNoLocale: string): string`, `amenitizUrl(localePath: string): string`. - [ ] **Step 1: Write failing tests `src/lib/urls.test.ts`** ```ts import { describe, it, expect } from 'vitest'; import { buildAlternates, canonicalFor, amenitizUrl } from './urls'; describe('canonicalFor', () => { it('builds absolute canonical with locale, no trailing slash', () => { expect(canonicalFor('it', '/camere')).toBe('https://www.simonsguesthouserome.com/it/camere'); }); it('handles home (path /)', () => { expect(canonicalFor('en', '/')).toBe('https://www.simonsguesthouserome.com/en'); }); }); describe('buildAlternates', () => { const alts = buildAlternates('/camere'); it('includes one entry per locale + x-default', () => { expect(alts.length).toBe(16); // 15 locales + x-default }); it('uses BCP-47 hreflang codes', () => { expect(alts.find(a => a.hreflang === 'pt-BR')?.href).toBe('https://www.simonsguesthouserome.com/pt/camere'); }); it('x-default points to english', () => { expect(alts.find(a => a.hreflang === 'x-default')?.href).toBe('https://www.simonsguesthouserome.com/en/camere'); }); }); describe('amenitizUrl', () => { it('maps locale to amenitiz booking deep-link', () => { expect(amenitizUrl('fr')).toBe('https://simonsguesthouserome.amenitiz.io/fr/booking/room'); }); it('falls back for rtl/unsupported (ar -> en)', () => { expect(amenitizUrl('ar')).toBe('https://simonsguesthouserome.amenitiz.io/en/booking/room'); }); }); ``` - [ ] **Step 2: Run, verify FAIL** Run: `npm test` Expected: FAIL (no `./urls`). - [ ] **Step 3: Implement `src/lib/urls.ts`** ```ts import { LOCALES, DEFAULT_LOCALE, localeByPath } from '../config/locales'; import { BUSINESS } from '../config/business'; const SITE = BUSINESS.domain; // no trailing slash function joinPath(localePath: string, pathNoLocale: string): string { const tail = pathNoLocale === '/' ? '' : pathNoLocale; return `${SITE}/${localePath}${tail}`; } export function canonicalFor(localePath: string, pathNoLocale: string): string { return joinPath(localePath, pathNoLocale); } export function buildAlternates(pathNoLocale: string): { hreflang: string; href: string }[] { const alts = LOCALES.map((l) => ({ hreflang: l.hreflang, href: joinPath(l.path, pathNoLocale) })); alts.push({ hreflang: 'x-default', href: joinPath(DEFAULT_LOCALE, pathNoLocale) }); return alts; } export function amenitizUrl(localePath: string): string { const loc = localeByPath(localePath); const seg = loc?.amenitiz ?? 'en'; return `${BUSINESS.amenitizBase}/${seg}/booking/room`; } ``` - [ ] **Step 4: Run, verify PASS, commit** Run: `npm test` Expected: PASS. ```bash git add -A && git commit -m "feat(i18n): hreflang/canonical/amenitiz URL helpers with tests" ``` --- ### Task 6: Design tokens, global CSS (logical properties), fonts **Files:** - Create: `src/styles/tokens.css`, `src/styles/global.css`, `src/styles/fonts.css` - Create: `public/fonts/` (subset woff2 files) - [ ] **Step 1: `src/styles/tokens.css` (palette/type/space from spec §3)** ```css :root { /* palette */ --c-cream: #FBF8F2; --c-travertine: #F4EFE6; --c-ink: #1A1715; --c-coral: #F25C4D; --c-coral-700: #C9442F; --c-brass: #C9A24B; --c-muted: #6E665C; --c-line: #E2D9CA; /* type */ --font-display: 'Fraunces', Georgia, serif; --font-body: 'Inter', system-ui, sans-serif; --step--1: clamp(.83rem,.8rem + .2vw,.95rem); --step-0: clamp(1rem,.95rem + .3vw,1.15rem); --step-1: clamp(1.3rem,1.1rem + .8vw,1.6rem); --step-2: clamp(1.8rem,1.4rem + 1.6vw,2.6rem); --step-3: clamp(2.6rem,1.9rem + 3vw,4.5rem); --step-4: clamp(3.4rem,2.2rem + 5vw,7rem); /* space + misc */ --space-s: .75rem; --space-m: 1.5rem; --space-l: 3rem; --space-xl: 6rem; --measure: 68ch; --radius: 14px; --ease: cubic-bezier(.2,.7,.2,1); } ``` - [ ] **Step 2: `src/styles/global.css` (reset + logical properties + reduced motion)** ```css *,*::before,*::after{box-sizing:border-box} html{ -webkit-text-size-adjust:100%; scroll-behavior:smooth } body{ margin:0; font-family:var(--font-body); font-size:var(--step-0); color:var(--c-ink); background:var(--c-cream); line-height:1.6; text-rendering:optimizeLegibility } h1,h2,h3{ font-family:var(--font-display); line-height:1.05; font-weight:600; text-wrap:balance; margin-block:0 var(--space-m) } h1{font-size:var(--step-4)} h2{font-size:var(--step-3)} h3{font-size:var(--step-2)} p{max-inline-size:var(--measure)} a{color:inherit} img{max-inline-size:100%;height:auto;display:block} .container{ inline-size:min(100% - 2*var(--space-m), 1200px); margin-inline:auto } .section{ padding-block:var(--space-xl) } :focus-visible{ outline:3px solid var(--c-coral); outline-offset:2px } /* RTL: only mirror directional chevrons/arrows */ [dir="rtl"] .icon-dir{ transform:scaleX(-1) } @media (prefers-reduced-motion: reduce){ *,*::before,*::after{ animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; scroll-behavior:auto !important } } ``` - [ ] **Step 3: Acquire + subset fonts → `public/fonts/`, write `src/styles/fonts.css`** Download variable fonts (Fraunces, Inter) and required Noto/IBM Plex non-Latin families; subset to woff2. Latin via Inter/Fraunces; non-Latin scoped by `unicode-range` so Latin pages never download them. ```bash # example tooling note (run once): use fonttools/glyphhanger to subset. # pip install fonttools brotli (then subset each family to woff2) ``` ```css @font-face{font-family:'Fraunces';src:url('/fonts/fraunces-var.woff2') format('woff2'); font-weight:300 700;font-display:swap;font-style:normal} @font-face{font-family:'Inter';src:url('/fonts/inter-var-latin.woff2') format('woff2'); font-weight:100 900;font-display:swap;unicode-range:U+0000-024F,U+2000-206F} @font-face{font-family:'Inter';src:url('/fonts/inter-var-cyrillic.woff2') format('woff2'); font-weight:100 900;font-display:swap;unicode-range:U+0400-04FF} @font-face{font-family:'NotoArabic';src:url('/fonts/noto-arabic.woff2') format('woff2'); font-display:swap;unicode-range:U+0600-06FF} @font-face{font-family:'NotoHebrew';src:url('/fonts/noto-hebrew.woff2') format('woff2'); font-display:swap;unicode-range:U+0590-05FF} @font-face{font-family:'NotoCJK';src:url('/fonts/noto-cjk-subset.woff2') format('woff2'); font-display:swap;unicode-range:U+3000-9FFF,U+AC00-D7A3,U+3040-30FF} /* Body stacks add the non-Latin families as fallbacks */ :root{ --font-body:'Inter','NotoArabic','NotoHebrew','NotoCJK',system-ui,sans-serif } ``` - [ ] **Step 4: Build, commit** ```bash npm run build git add -A && git commit -m "feat(design): tokens, global CSS (logical properties), self-hosted fonts" ``` --- # Phase 1 — Layout & chrome ### Task 7: UI dictionary + content scaffolding **Files:** - Create: `src/i18n/ui.ts` - Create: `src/i18n/content/en.ts`, `src/i18n/content/it.ts` **Interfaces:** - Produces: `ui: RecordChoose your language / Scegli la lingua:
``` - [ ] **Step 2: Guard test — bridge SUP array matches LOCALE_CODES** Create `src/lib/bridge.test.ts` (catches drift between the static bridge and the locale source of truth): ```ts import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { LOCALE_CODES } from '../config/locales'; describe('root bridge', () => { it('SUP array matches LOCALE_CODES', () => { const html = readFileSync('public/index.html', 'utf8'); const m = html.match(/var SUP=\[([^\]]*)\]/); expect(m).not.toBeNull(); const sup = m![1].split(',').map((s) => s.trim().replace(/['"]/g, '')); expect(sup.sort()).toEqual([...LOCALE_CODES].sort()); }); }); ``` Run: `npm test` → PASS. - [ ] **Step 3: build, verify `dist/index.html` present, commit** ```bash npm run build && test -f dist/index.html && echo OK && npm test git add -A && git commit -m "feat(i18n): root language bridge with detection + noscript fallback + guard test" ``` --- # Phase 2 — Reusable components ### Task 13: Hero, SectionDivider (brush), ScrollReveal **Files:** - Create: `src/components/Hero.astro`, `src/components/SectionDivider.astro`, `src/components/ScrollReveal.astro` - [ ] **Step 1: `ScrollReveal.astro` (IntersectionObserver island, reduced-motion safe)** ```astro