# Ombrora Minimalist UI -- 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:** Restyle the Ombrora frontend with Tailwind CSS v4, light/dark mode, flag-image language switcher, SEO-optimized markup, and a hero + reassurance layout. **Architecture:** `src/app/[locale]/layout.tsx` is the real shell (renders ``/``); the root `src/app/layout.tsx` just passes children through. All styling goes through a single `src/app/globals.css` imported in the locale layout. next-themes wraps the tree and toggles a `.dark` class on ``. **Tech Stack:** Next.js 16 App Router, Tailwind CSS v4 (`@tailwindcss/postcss`), next-themes, `next/font/google` (Inter), next-intl v4. ## Global Constraints - Tailwind v4 only -- no `tailwind.config.ts`, config lives in CSS (`@custom-variant dark`) - `npm install -D` for tailwindcss + @tailwindcss/postcss (build-time, not runtime) - `next-themes` pure JS -- fine for o2switch - Flag SVGs stored in `/public/flags/` -- no CDN runtime calls - Inter font via `next/font/google` with `subsets: ['latin', 'latin-ext']` -- covers FR/ES/IT accents - `NEXT_PUBLIC_BASE_URL` env var for canonical URLs -- set in `.env.local` - No external icon library -- SVG paths are inlined - Never touch files in `src/app/api/`, `src/lib/`, `worker/`, or any test file - `src/app/layout.tsx` (root) -- do not modify, it just returns children - `src/app/page.tsx` (non-locale) -- do not modify ## File Map ``` NEW postcss.config.mjs src/app/globals.css src/components/ThemeToggle.tsx src/components/Header.tsx src/components/ReassuranceSection.tsx public/flags/en.svg public/flags/fr.svg public/flags/es.svg public/flags/it.svg MODIFIED messages/en.json messages/fr.json messages/es.json messages/it.json src/app/[locale]/layout.tsx src/app/[locale]/page.tsx src/app/[locale]/status/[uuid]/page.tsx src/components/SubmitForm.tsx src/components/StatusView.tsx src/components/LanguageSwitcher.tsx ``` --- ### Task 1: Tailwind CSS v4 setup **Files:** - Create: `postcss.config.mjs` - Create: `src/app/globals.css` - Modify: `.env.local` (create if absent) - DevDependencies: `tailwindcss`, `@tailwindcss/postcss` **Interfaces:** - Produces: Tailwind utility classes available in all TSX files; `dark:` variant active when `` has class `dark` - [ ] **Step 1: Install Tailwind** ```bash npm install -D tailwindcss @tailwindcss/postcss ``` Expected: `tailwindcss` and `@tailwindcss/postcss` appear in `devDependencies` in `package.json`. - [ ] **Step 2: Create PostCSS config** Create `postcss.config.mjs` at the repo root: ```js export default { plugins: { '@tailwindcss/postcss': {}, }, } ``` - [ ] **Step 3: Create globals.css** Create `src/app/globals.css`: ```css @import 'tailwindcss'; /* Dark mode: activated when any ancestor has the .dark class (set by next-themes) */ @custom-variant dark (&:where(.dark, .dark *)); ``` - [ ] **Step 4: Add NEXT_PUBLIC_BASE_URL to .env.local** Add to `.env.local` (create the file if it does not exist): ``` NEXT_PUBLIC_BASE_URL=http://localhost:3000 ``` (Set the production value before deploying to o2switch.) - [ ] **Step 5: Smoke-test** ```bash npm run dev ``` Open `http://localhost:3000/fr`. Add `className="text-violet-600"` temporarily to any element in `src/app/[locale]/page.tsx` and verify the element turns violet in the browser. Remove the temporary class. - [ ] **Step 6: Commit** ```bash git add postcss.config.mjs src/app/globals.css .env.local package.json package-lock.json git commit -m "feat: install Tailwind CSS v4 with PostCSS" ``` --- ### Task 2: Update translation files (all 4 locales) **Files:** - Modify: `messages/en.json`, `messages/fr.json`, `messages/es.json`, `messages/it.json` **Interfaces:** - Produces: new keys `meta.description`, `home.subheading`, `home.formats`, `reassurance.*`, `footer.copyright` - Consumed by: Tasks 7, 8, 9, 10 (layout, SubmitForm, ReassuranceSection, home page) - [ ] **Step 1: Replace messages/en.json** ```json { "meta": { "title": "Ombrora -- Free Video Downloader", "description": "Download YouTube, Vimeo, and more in mp4, mp3, webm -- free, fast, no account required." }, "home": { "heading": "Download your videos for free", "subheading": "Free, fast, no account required", "urlLabel": "Video URL", "format": "Format", "quality": "Quality", "subtitles": "Subtitles (fr, en)", "advanced": "Advanced options", "formats": "mp4 · mp3 · webm · mkv", "submit": "Download", "submitting": "Sending...", "errorRateLimit": "Too many submissions. Try again in an hour.", "errorGeneric": "Submission error." }, "status": { "heading": "Download status", "notFound": "Download not found.", "loading": "Loading...", "pending": "Pending", "processing": "Processing...", "polling": "This page refreshes automatically every 5 seconds.", "ready": "Download ready", "download": "Download", "linkExpires": "Link valid until", "failed": "Download failed.", "deleted": "The file has expired and was deleted. The download is no longer available.", "unknown": "Unknown status" }, "reassurance": { "free": "Free", "freeDesc": "No account, no sign-up", "fast": "Fast", "fastDesc": "Ready in seconds", "private": "Private", "privateDesc": "No data retained", "formats": "Multi-format", "formatsDesc": "mp4, mp3, webm, mkv -- up to 1080p" }, "footer": { "copyright": "© 2026 Ombrora" } } ``` - [ ] **Step 2: Replace messages/fr.json** ```json { "meta": { "title": "Ombrora -- Telechargeur de videos gratuit", "description": "Telechargez vos videos YouTube, Vimeo et plus en mp4, mp3, webm -- gratuit, rapide, sans compte." }, "home": { "heading": "Telechargez vos videos gratuitement", "subheading": "Gratuit, rapide, sans compte", "urlLabel": "URL de la video", "format": "Format", "quality": "Qualite", "subtitles": "Sous-titres (fr, en)", "advanced": "Options avancees", "formats": "mp4 · mp3 · webm · mkv", "submit": "Telecharger", "submitting": "Envoi en cours...", "errorRateLimit": "Trop de soumissions. Reessayez dans une heure.", "errorGeneric": "Erreur de soumission." }, "status": { "heading": "Statut du telechargement", "notFound": "Telechargement introuvable.", "loading": "Chargement...", "pending": "En attente", "processing": "En cours de traitement...", "polling": "Cette page se rafraichit automatiquement toutes les 5 secondes.", "ready": "Telechargement pret", "download": "Telecharger", "linkExpires": "Lien valide jusqu'au", "failed": "Echec du telechargement.", "deleted": "Le fichier a expire et a ete supprime. Le telechargement n'est plus disponible.", "unknown": "Statut inconnu" }, "reassurance": { "free": "Gratuit", "freeDesc": "Sans compte, sans inscription", "fast": "Rapide", "fastDesc": "Pret en quelques secondes", "private": "Prive", "privateDesc": "Aucune donnee conservee", "formats": "Multi-format", "formatsDesc": "mp4, mp3, webm, mkv -- jusqu'en 1080p" }, "footer": { "copyright": "© 2026 Ombrora" } } ``` - [ ] **Step 3: Replace messages/es.json** ```json { "meta": { "title": "Ombrora -- Descargador de videos gratuito", "description": "Descarga videos de YouTube, Vimeo y mas en mp4, mp3, webm -- gratis, rapido, sin cuenta." }, "home": { "heading": "Descarga tus videos gratis", "subheading": "Gratis, rapido, sin cuenta", "urlLabel": "URL del video", "format": "Formato", "quality": "Calidad", "subtitles": "Subtitulos (fr, en)", "advanced": "Opciones avanzadas", "formats": "mp4 · mp3 · webm · mkv", "submit": "Descargar", "submitting": "Enviando...", "errorRateLimit": "Demasiadas solicitudes. Intentalo de nuevo en una hora.", "errorGeneric": "Error de envio." }, "status": { "heading": "Estado de la descarga", "notFound": "Descarga no encontrada.", "loading": "Cargando...", "pending": "Pendiente", "processing": "Procesando...", "polling": "Esta pagina se actualiza automaticamente cada 5 segundos.", "ready": "Descarga lista", "download": "Descargar", "linkExpires": "Enlace valido hasta", "failed": "Error de descarga.", "deleted": "El archivo ha expirado y fue eliminado. La descarga ya no esta disponible.", "unknown": "Estado desconocido" }, "reassurance": { "free": "Gratuito", "freeDesc": "Sin cuenta, sin registro", "fast": "Rapido", "fastDesc": "Listo en segundos", "private": "Privado", "privateDesc": "Sin datos retenidos", "formats": "Multi-formato", "formatsDesc": "mp4, mp3, webm, mkv -- hasta 1080p" }, "footer": { "copyright": "© 2026 Ombrora" } } ``` - [ ] **Step 4: Replace messages/it.json** ```json { "meta": { "title": "Ombrora -- Scaricatore di video gratuito", "description": "Scarica video da YouTube, Vimeo e altro in mp4, mp3, webm -- gratis, veloce, senza account." }, "home": { "heading": "Scarica i tuoi video gratuitamente", "subheading": "Gratuito, veloce, senza account", "urlLabel": "URL del video", "format": "Formato", "quality": "Qualita", "subtitles": "Sottotitoli (fr, en)", "advanced": "Opzioni avanzate", "formats": "mp4 · mp3 · webm · mkv", "submit": "Scarica", "submitting": "Invio in corso...", "errorRateLimit": "Troppe richieste. Riprova tra un'ora.", "errorGeneric": "Errore di invio." }, "status": { "heading": "Stato del download", "notFound": "Download non trovato.", "loading": "Caricamento...", "pending": "In attesa", "processing": "In elaborazione...", "polling": "Questa pagina si aggiorna automaticamente ogni 5 secondi.", "ready": "Download pronto", "download": "Scarica", "linkExpires": "Link valido fino al", "failed": "Download fallito.", "deleted": "Il file e scaduto ed e stato eliminato. Il download non e piu disponibile.", "unknown": "Stato sconosciuto" }, "reassurance": { "free": "Gratuito", "freeDesc": "Senza account, senza registrazione", "fast": "Veloce", "fastDesc": "Pronto in pochi secondi", "private": "Privato", "privateDesc": "Nessun dato conservato", "formats": "Multi-formato", "formatsDesc": "mp4, mp3, webm, mkv -- fino a 1080p" }, "footer": { "copyright": "© 2026 Ombrora" } } ``` - [ ] **Step 5: Commit** ```bash git add messages/ git commit -m "feat: add SEO, reassurance, and subheading translation keys" ``` --- ### Task 3: Download flag SVGs **Files:** - Create: `public/flags/en.svg`, `public/flags/fr.svg`, `public/flags/es.svg`, `public/flags/it.svg` **Interfaces:** - Produces: static SVG files served at `/flags/{locale}.svg` - Consumed by: Task 5 (LanguageSwitcher) - [ ] **Step 1: Create the flags directory and download SVGs** Run in PowerShell from the repo root: ```powershell New-Item -ItemType Directory -Force -Path public/flags Invoke-WebRequest -Uri "https://flagcdn.com/us.svg" -OutFile "public/flags/en.svg" Invoke-WebRequest -Uri "https://flagcdn.com/fr.svg" -OutFile "public/flags/fr.svg" Invoke-WebRequest -Uri "https://flagcdn.com/es.svg" -OutFile "public/flags/es.svg" Invoke-WebRequest -Uri "https://flagcdn.com/it.svg" -OutFile "public/flags/it.svg" ``` Expected: 4 SVG files appear under `public/flags/`. Each file starts with ` ) } function MoonIcon() { return ( ) } export function ThemeToggle() { const { theme, setTheme } = useTheme() const [mounted, setMounted] = useState(false) useEffect(() => { setMounted(true) }, []) if (!mounted) { // Prevents hydration mismatch -- renders a same-size placeholder return
} return ( ) } ``` - [ ] **Step 3: Verify the component compiles** ```bash npx tsc --noEmit ``` Expected: no errors in `ThemeToggle.tsx`. - [ ] **Step 4: Commit** ```bash git add src/components/ThemeToggle.tsx package.json package-lock.json git commit -m "feat: add ThemeToggle component with next-themes" ``` --- ### Task 5: Restyle LanguageSwitcher **Files:** - Modify: `src/components/LanguageSwitcher.tsx` **Interfaces:** - Consumes: `/public/flags/{locale}.svg` (from Task 3) - Produces: `export function LanguageSwitcher()` -- same signature as before, now uses `` flags - [ ] **Step 1: Rewrite LanguageSwitcher.tsx** ```tsx 'use client' import { useLocale } from 'next-intl' import { useRouter, usePathname } from '@/navigation' import { routing } from '@/i18n/routing' const FLAG_LABELS: Record = { en: 'English', fr: 'Francais', es: 'Espanol', it: 'Italiano', } export function LanguageSwitcher() { const locale = useLocale() const router = useRouter() const pathname = usePathname() return (
{routing.locales.map((loc) => ( ))}
) } ``` - [ ] **Step 2: Verify** ```bash npx tsc --noEmit ``` Open `http://localhost:3000/fr`. The header should show 4 flag images. The active flag has a violet ring; others are dimmed. - [ ] **Step 3: Commit** ```bash git add src/components/LanguageSwitcher.tsx git commit -m "feat: replace emoji flags with SVG flag images in LanguageSwitcher" ``` --- ### Task 6: Header component **Files:** - Create: `src/components/Header.tsx` **Interfaces:** - Consumes: `ThemeToggle` (Task 4), `LanguageSwitcher` (Task 5) - Produces: `export function Header()` -- sticky header with logo, theme toggle, language switcher - [ ] **Step 1: Create Header.tsx** ```tsx import { ThemeToggle } from '@/components/ThemeToggle' import { LanguageSwitcher } from '@/components/LanguageSwitcher' export function Header() { return (
Ombrora
) } ``` - [ ] **Step 2: Verify** ```bash npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add src/components/Header.tsx git commit -m "feat: add Header component with logo, theme toggle, and language switcher" ``` --- ### Task 7: Locale layout update **Files:** - Modify: `src/app/[locale]/layout.tsx` **Interfaces:** - Consumes: `Header` (Task 6), `globals.css` (Task 1), translation keys `meta.*` and `footer.*` (Task 2) - Produces: full page shell with Inter font, ThemeProvider, SEO metadata, JSON-LD, Header, footer - [ ] **Step 1: Rewrite src/app/[locale]/layout.tsx** ```tsx import type { Metadata } from 'next' import { Inter } from 'next/font/google' import { NextIntlClientProvider } from 'next-intl' import { getTranslations, getMessages } from 'next-intl/server' import { notFound } from 'next/navigation' import { ThemeProvider } from 'next-themes' import { routing } from '@/i18n/routing' import { Header } from '@/components/Header' import '../globals.css' const inter = Inter({ subsets: ['latin', 'latin-ext'] }) export async function generateMetadata({ params, }: { params: Promise<{ locale: string }> }): Promise { const { locale } = await params const t = await getTranslations({ locale, namespace: 'meta' }) const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? '' return { title: t('title'), description: t('description'), robots: { index: true, follow: true }, openGraph: { title: t('title'), description: t('description'), url: `${baseUrl}/${locale}`, siteName: 'Ombrora', locale, }, alternates: { canonical: `${baseUrl}/${locale}`, languages: Object.fromEntries( routing.locales.map((l) => [l, `/${l}`]) ), }, } } export default async function LocaleLayout({ children, params, }: { children: React.ReactNode params: Promise<{ locale: string }> }) { const { locale } = await params if (!(routing.locales as readonly string[]).includes(locale)) { notFound() } const messages = await getMessages() const t = await getTranslations({ locale, namespace: 'footer' }) const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? '' const jsonLd = { '@context': 'https://schema.org', '@type': 'WebApplication', name: 'Ombrora', url: baseUrl, description: 'Free, fast, private video downloader.', applicationCategory: 'MultimediaApplication', operatingSystem: 'Web', offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR', }, } return (