diff --git a/docs/superpowers/plans/2026-08-10-minimalist-ui.md b/docs/superpowers/plans/2026-08-10-minimalist-ui.md new file mode 100644 index 0000000..5e9d447 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-minimalist-ui.md @@ -0,0 +1,1291 @@ +# 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 ( + + +