From f499196dae45c2668737c18acee500ee321eb5a2 Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Fri, 31 Jul 2026 14:37:05 +0200 Subject: [PATCH] docs: add Ombrora Convert frontend redesign implementation plan 5-task plan: design tokens/dark mode/i18n scaffold, routing+layout+ migrated tool, reassurance+formats sections, SEO metadata, responsive polish. --- .../2026-07-31-ombrora-frontend-redesign.md | 1560 +++++++++++++++++ 1 file changed, 1560 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-31-ombrora-frontend-redesign.md diff --git a/docs/superpowers/plans/2026-07-31-ombrora-frontend-redesign.md b/docs/superpowers/plans/2026-07-31-ombrora-frontend-redesign.md new file mode 100644 index 0000000..f77e39d --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-ombrora-frontend-redesign.md @@ -0,0 +1,1560 @@ +# Ombrora Convert Frontend 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 `frontend/` into a modern, minimalist, dark-mode-capable, bilingual (FR/EN) single-page site named "Ombrora Convert" that embeds the existing file-conversion tool directly in the homepage, with reassurance messaging backed by real backend facts and baseline SEO. + +**Architecture:** A `react-router-dom` shell (`/` redirects to `/fr/` or `/en/`; `/:lang/` renders the whole app) wraps a shared `Layout` (header with language switcher + dark-mode toggle, footer) around one `HomePage` that contains the hero, the drag-and-drop upload/convert tool (migrated unchanged in logic from the current `App.jsx`/`FileCard.jsx`/`api.js`), a reassurance strip, and a supported-formats grid. `react-i18next` drives all copy from `locales/fr.json`/`en.json`, keyed off the `:lang` route param. + +**Tech Stack:** React 19, Vite, `react-router-dom`, `react-i18next` + `i18next`, `@phosphor-icons/react` (icons), plain CSS (no CSS framework — matches the project's existing plain-CSS convention). + +## Global Constraints + +- Every new dependency must be pure JS with no native/binary bindings (o2switch shared hosting has no compiler toolchain) — `react-router-dom`, `react-i18next`, `i18next`, `@phosphor-icons/react` all qualify. +- Brand name is **"Ombrora Convert"** everywhere (title, header, footer, meta tags, JSON-LD) — not "file-converter" or "Convertisseur de fichiers". +- Only two locales: `fr` and `en`, addressed via URL prefix (`/fr/`, `/en/`). No other language codes are valid routes. +- Dark mode default is **light**, not dark — `prefers-color-scheme: dark` is honored only as the *initial* value before any manual toggle; an explicit toggle is persisted in `localStorage` under key `theme` and always wins afterward. +- Reassurance copy must only state what the backend actually guarantees today: auto-delete after `RETENTION_HOURS` (default 1h, `src/config.js`), max file size `MAX_FILE_SIZE_MB` (default 100MB, `src/config.js`), no account required, 100% online/nothing to install. No RGPD/hosting-location claims (not requested). +- No new automated frontend test harness. Verification per task is `npm run build` (in `frontend/`), `npm run lint` (oxlint, already configured), and manual browser checks via `npm run dev`. This mirrors the approved design spec's explicit decision, not an oversight. +- Out of scope entirely (do not implement): legal pages (mentions légales/confidentialité/CGU), programmatic per-format-pair SEO pages, SSR/prerendering, any backend/`src/` changes. `src/app.js`'s existing catch-all route (`app.get(/^\/(?!api\/).*/, ...)` serving `frontend/dist/index.html`) already supports client-side routes like `/fr/` on direct navigation/hard refresh — confirmed by reading `src/app.js:235-238` — so no backend change is needed for routing to work. + +--- + +## Task 1: Dependencies, design tokens, dark mode, i18n scaffold + +**Files:** +- Modify: `frontend/package.json` +- Modify: `frontend/src/index.css` +- Create: `frontend/src/hooks/useTheme.js` +- Create: `frontend/src/components/ThemeToggle.jsx` +- Create: `frontend/src/i18n.js` +- Create: `frontend/src/locales/fr.json` +- Create: `frontend/src/locales/en.json` + +**Interfaces:** +- Produces: `useTheme()` hook returning `{ theme: 'light'|'dark', toggleTheme: () => void }`. +- Produces: `ThemeToggle({ theme, onToggle, label })` component (no logic, pure presentation). +- Produces: `frontend/src/i18n.js` default-exports an initialized `i18next` instance; importing it for its side effect (calling `i18n.init(...)`) is required before any component calls `useTranslation()`. +- Produces: translation keys consumed by later tasks — `brand`, `nav.switchToEnglish`, `nav.switchToFrench`, `nav.themeToLight`, `nav.themeToDark`, `hero.title`, `hero.subtitle`, `hero.dropzoneLabel`, `hero.convert`, `hero.unsupportedFormat`, `quality.label`, `quality.compression`, `quality.iconSize`, `quality.compressPdf`, `job.converting`, `job.download`, `reassurance.title`, `reassurance.autoDelete`, `reassurance.noAccount`, `reassurance.online`, `reassurance.maxSize`, `formats.title`, `formats.images`, `formats.documents`, `formats.fonts`, `formats.ebooks`, `footer.rights`, `notFound.title`, `notFound.backHome`. + +- [ ] **Step 1: Install new dependencies** + +Run (from `frontend/`): +```bash +npm install react-router-dom react-i18next i18next +npm install @phosphor-icons/react +``` +Do not pin exact versions manually in `package.json` — let `npm install` resolve the current compatible range and write it, same as every existing entry in the file. + +`i18next-browser-languagedetector` is intentionally **not** installed: the spec listed it, but language detection is fully covered by (a) a plain `navigator.language` check in the `/` → `/fr/`/`/en/` redirect (Task 2) and (b) the `:lang` route param driving `i18n.changeLanguage()` directly — the extra package would have no job to do. Flagging this as a deliberate deviation from the written spec, not an oversight. + +- [ ] **Step 2: Replace `frontend/src/index.css` with the new design tokens** + +Replace the entire file content with: + +```css +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap'); + +:root { + --color-primary: #2563eb; + --color-on-primary: #ffffff; + --color-secondary: #3b82f6; + --color-accent: #d97706; + --color-background: #f8fafc; + --color-foreground: #0f172a; + --color-muted: #f1f5fd; + --color-border: #e4ecfc; + --color-destructive: #dc2626; + --color-ring: #2563eb; + + --font-sans: 'Plus Jakarta Sans', system-ui, 'Segoe UI', Roboto, sans-serif; + + color-scheme: light dark; + font: 16px/1.5 var(--font-sans); +} + +:root[data-theme='dark'] { + --color-primary: #3b82f6; + --color-on-primary: #0f172a; + --color-secondary: #60a5fa; + --color-accent: #f59e0b; + --color-background: #0f172a; + --color-foreground: #f1f5f9; + --color-muted: #1e293b; + --color-border: #334155; + --color-destructive: #f87171; + --color-ring: #3b82f6; +} + +*, *::before, *::after { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--color-background); + color: var(--color-foreground); + transition: background-color 200ms ease, color 200ms ease; +} + +#root { + min-height: 100svh; + display: flex; + flex-direction: column; +} + +a { + color: var(--color-primary); +} + +button, input, select { + font-family: inherit; +} + +button { + cursor: pointer; +} + +:focus-visible { + outline: 2px solid var(--color-ring); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + transition: none !important; + animation: none !important; + } +} +``` + +This replaces the prior Vite-template leftover token set (purple accent, `prefers-color-scheme`-only dark mode) entirely. The new dark mode is driven by the `data-theme` attribute (Step 3), not the media query alone, so a manual toggle can override system preference. + +- [ ] **Step 3: Write `frontend/src/hooks/useTheme.js`** + +```js +import { useEffect, useState } from 'react'; + +const STORAGE_KEY = 'theme'; + +function getInitialTheme() { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'light' || stored === 'dark') return stored; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +export function useTheme() { + const [theme, setTheme] = useState(getInitialTheme); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + }, [theme]); + + function toggleTheme() { + setTheme((current) => { + const next = current === 'dark' ? 'light' : 'dark'; + localStorage.setItem(STORAGE_KEY, next); + return next; + }); + } + + return { theme, toggleTheme }; +} +``` + +- [ ] **Step 4: Write `frontend/src/components/ThemeToggle.jsx`** + +```jsx +import { Moon, Sun } from '@phosphor-icons/react'; + +export function ThemeToggle({ theme, onToggle, label }) { + const isDark = theme === 'dark'; + return ( + + ); +} +``` + +`Moon`/`Sun` are confirmed exports of `@phosphor-icons/react` (verified against the package's published type declarations before writing this task). + +- [ ] **Step 5: Write `frontend/src/locales/fr.json`** + +```json +{ + "brand": "Ombrora Convert", + "nav": { + "switchToEnglish": "English", + "switchToFrench": "Français", + "themeToLight": "Activer le mode clair", + "themeToDark": "Activer le mode sombre" + }, + "hero": { + "title": "Convertissez vos fichiers en ligne, gratuitement", + "subtitle": "Glissez-déposez vos fichiers, choisissez le format de sortie, téléchargez le résultat. Aucune installation, aucun compte.", + "dropzoneLabel": "Glissez vos fichiers ici ou cliquez pour parcourir", + "convert": "Convertir", + "unsupportedFormat": "Format non supporté" + }, + "quality": { + "label": "Qualité ({{value}})", + "compression": "Compression ({{value}})", + "iconSize": "Taille de l'icône", + "compressPdf": "Compresser en JPEG" + }, + "job": { + "converting": "Conversion en cours...", + "download": "Télécharger" + }, + "reassurance": { + "title": "Pourquoi nous faire confiance", + "autoDelete": "Fichiers supprimés automatiquement après 1h", + "noAccount": "Aucun compte requis", + "online": "100% en ligne — rien à installer", + "maxSize": "Jusqu'à 100 Mo par fichier" + }, + "formats": { + "title": "Formats pris en charge", + "images": "Images", + "documents": "Documents", + "fonts": "Polices", + "ebooks": "Ebooks" + }, + "footer": { + "rights": "Ombrora Convert — Conversion de fichiers en ligne" + }, + "notFound": { + "title": "Page introuvable", + "backHome": "Retour à l'accueil" + } +} +``` + +- [ ] **Step 6: Write `frontend/src/locales/en.json`** + +```json +{ + "brand": "Ombrora Convert", + "nav": { + "switchToEnglish": "English", + "switchToFrench": "Français", + "themeToLight": "Switch to light mode", + "themeToDark": "Switch to dark mode" + }, + "hero": { + "title": "Convert your files online, for free", + "subtitle": "Drag and drop your files, pick the output format, download the result. No install, no account.", + "dropzoneLabel": "Drag your files here or click to browse", + "convert": "Convert", + "unsupportedFormat": "Unsupported format" + }, + "quality": { + "label": "Quality ({{value}})", + "compression": "Compression ({{value}})", + "iconSize": "Icon size", + "compressPdf": "Compress as JPEG" + }, + "job": { + "converting": "Converting...", + "download": "Download" + }, + "reassurance": { + "title": "Why you can trust us", + "autoDelete": "Files deleted automatically after 1h", + "noAccount": "No account required", + "online": "100% online — nothing to install", + "maxSize": "Up to 100MB per file" + }, + "formats": { + "title": "Supported formats", + "images": "Images", + "documents": "Documents", + "fonts": "Fonts", + "ebooks": "Ebooks" + }, + "footer": { + "rights": "Ombrora Convert — Online file conversion" + }, + "notFound": { + "title": "Page not found", + "backHome": "Back to home" + } +} +``` + +- [ ] **Step 7: Write `frontend/src/i18n.js`** + +```js +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import fr from './locales/fr.json'; +import en from './locales/en.json'; + +i18n.use(initReactI18next).init({ + resources: { + fr: { translation: fr }, + en: { translation: en }, + }, + lng: 'fr', + fallbackLng: 'fr', + interpolation: { escapeValue: false }, +}); + +export default i18n; +``` + +- [ ] **Step 8: Verify the build and lint still pass** + +Run (from `frontend/`): +```bash +npm run build +npm run lint +``` +Expected: both succeed with no errors. Nothing imports these new files into the app yet (that starts in Task 2), so this only confirms the new files themselves are syntactically valid and importable. + +- [ ] **Step 9: Commit** + +```bash +git add frontend/package.json frontend/package-lock.json frontend/src/index.css frontend/src/hooks/useTheme.js frontend/src/components/ThemeToggle.jsx frontend/src/i18n.js frontend/src/locales/fr.json frontend/src/locales/en.json +git commit -m "feat(frontend): add design tokens, dark mode hook, and i18n scaffold" +``` + +--- + +## Task 2: Routing shell, layout, and the migrated conversion tool + +**Files:** +- Modify: `frontend/src/main.jsx` +- Rewrite: `frontend/src/App.jsx` +- Delete: `frontend/src/App.css` (superseded by `frontend/src/styles/layout.css` and `frontend/src/styles/home.css`) +- Create: `frontend/src/components/LanguageSwitcher.jsx` +- Create: `frontend/src/components/Header.jsx` +- Create: `frontend/src/components/Footer.jsx` +- Create: `frontend/src/components/Layout.jsx` +- Create: `frontend/src/components/Dropzone.jsx` +- Create: `frontend/src/components/FileConfigCard.jsx` +- Create: `frontend/src/pages/HomePage.jsx` +- Create: `frontend/src/pages/NotFound.jsx` +- Create: `frontend/src/styles/layout.css` +- Create: `frontend/src/styles/home.css` +- Modify: `frontend/src/FileCard.jsx` + +**Interfaces:** +- Consumes (from Task 1): `useTheme()` from `../hooks/useTheme.js`; `ThemeToggle` from `../components/ThemeToggle.jsx`; the initialized `i18n` default export from `../i18n.js`; all translation keys listed in Task 1. +- Consumes (unchanged, pre-existing): `fetchFormats(source)`, `uploadFiles(items)` from `./api.js`; `FileCard({ fileName, jobId, initialError })` from `./FileCard.jsx`. +- Produces: `Layout` — renders `
` + `
` + `