From 55497fe00143879ec3bcb314cbe7ea76b4f7d75f Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Wed, 8 Apr 2026 16:10:05 +0200 Subject: [PATCH] docs(02): create phase 2 SSR shell plans --- .planning/ROADMAP.md | 18 +- .planning/phases/02-ssr-shell/02-01-PLAN.md | 325 ++++++++++++++++++++ .planning/phases/02-ssr-shell/02-02-PLAN.md | 318 +++++++++++++++++++ .planning/phases/02-ssr-shell/02-03-PLAN.md | 212 +++++++++++++ 4 files changed, 863 insertions(+), 10 deletions(-) create mode 100644 .planning/phases/02-ssr-shell/02-01-PLAN.md create mode 100644 .planning/phases/02-ssr-shell/02-02-PLAN.md create mode 100644 .planning/phases/02-ssr-shell/02-03-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 4e94b9b..d01a838 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -12,7 +12,7 @@ Three phases following the strict build order from research: first lay the Nuxt Decimal phases appear between their surrounding integers in numeric order. -- [ ] **Phase 1: Foundation** - Nuxt 4 project scaffold, all modules configured, static data migrated, composables ported +- [x] **Phase 1: Foundation** - Nuxt 4 project scaffold, all modules configured, static data migrated, composables ported - [ ] **Phase 2: SSR Shell** - i18n FR/EN, dark/light theme, SEO per route, header + footer layout - [ ] **Phase 3: Pages & Ship** - All 8 pages, interactive components, EmailJS plugin, GA4, Dockerfile @@ -42,10 +42,11 @@ Plans: 3. Toggling dark/light mode in the header persists across page reload with no flash on cold load 4. `curl http://localhost:3000` response includes ``, `og:title`, `og:description`, and JSON-LD script tag 5. `http://localhost:3000/sitemap.xml` returns a valid XML sitemap with `hreflang` alternates for FR and EN URLs -**Plans**: 2 plans +**Plans**: 3 plans Plans: -- [x] 01-01-PLAN.md — Scaffold Nuxt 4, modules, TypeScript strict, interfaces -- [x] 01-02-PLAN.md — Migration donnees statiques + useProjects() +- [ ] 02-01-PLAN.md — Design system, color-mode, i18n translations, sitemap config +- [ ] 02-02-PLAN.md — Header, footer, default layout with nav and toggles +- [ ] 02-03-PLAN.md — Per-route SEO metadata and JSON-LD structured data **UI hint**: yes ### Phase 3: Pages & Ship @@ -58,10 +59,7 @@ Plans: 3. Submitting the contact form with valid data shows a success toast; EmailJS delivers the email 4. `docker build` completes and `docker run` serves the SSR app on port 3000 5. Google Analytics 4 events appear in GA4 DebugView when browsing in production mode -**Plans**: 2 plans -Plans: -- [ ] 01-01-PLAN.md — Scaffold Nuxt 4, modules, TypeScript strict, interfaces -- [ ] 01-02-PLAN.md — Migration donnees statiques + useProjects() +**Plans**: TBD **UI hint**: yes ## Progress @@ -71,6 +69,6 @@ Phases execute in numeric order: 1 → 2 → 3 | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| -| 1. Foundation | 0/TBD | Not started | - | -| 2. SSR Shell | 0/TBD | Not started | - | +| 1. Foundation | 2/2 | Complete | 2026-04-08 | +| 2. SSR Shell | 0/3 | Planning complete | - | | 3. Pages & Ship | 0/TBD | Not started | - | diff --git a/.planning/phases/02-ssr-shell/02-01-PLAN.md b/.planning/phases/02-ssr-shell/02-01-PLAN.md new file mode 100644 index 0000000..ca85584 --- /dev/null +++ b/.planning/phases/02-ssr-shell/02-01-PLAN.md @@ -0,0 +1,325 @@ +--- +phase: 02-ssr-shell +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - nuxt.config.ts + - app.config.ts + - app/assets/css/main.css + - app/locales/fr.json + - app/locales/en.json + - public/og-image.png +autonomous: true +requirements: [I18N-01, I18N-02, I18N-04, I18N-05, THEME-02, THEME-03, SEO-03] + +must_haves: + truths: + - "Color mode cookie config is FOUC-free with dark default" + - "i18n baseUrl is set for absolute canonical/hreflang URLs" + - "fr.json and en.json contain nav, footer, seo, and a11y translation keys" + - "Sitemap generates with hreflang alternates" + - "Brand color #85cb85 is defined as CSS theme variable and referenced in app.config.ts" + artifacts: + - path: "app/assets/css/main.css" + provides: "@theme with --color-brand-* shades" + contains: "--color-brand-500" + - path: "app.config.ts" + provides: "Nuxt UI primary color mapping" + contains: "primary: 'brand'" + - path: "app/locales/fr.json" + provides: "French translations for Phase 2" + contains: "nav" + - path: "app/locales/en.json" + provides: "English translations for Phase 2" + contains: "nav" + key_links: + - from: "app.config.ts" + to: "app/assets/css/main.css" + via: "brand color name reference" + pattern: "primary.*brand" + - from: "nuxt.config.ts" + to: "app/assets/css/main.css" + via: "css config array" + pattern: "css.*main.css" +--- + +<objective> +Configure the design system, color-mode, i18n translations, and sitemap for SSR-safe rendering. + +Purpose: Lay the cross-cutting foundation (colors, translations, cookies) that the header/footer/SEO plans depend on. +Output: nuxt.config.ts with color-mode, app.config.ts with brand color, main.css with @theme, enriched fr.json/en.json, static og:image. +</objective> + +<execution_context> +@~/.claude/get-shit-done/workflows/execute-plan.md +@~/.claude/get-shit-done/templates/summary.md +</execution_context> + +<context> +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/02-ssr-shell/02-CONTEXT.md +@.planning/phases/02-ssr-shell/02-RESEARCH.md +@.planning/phases/02-ssr-shell/02-UI-SPEC.md + +<interfaces> +<!-- From nuxt.config.ts (current state): --> +<!-- modules: ['@nuxt/ui', '@nuxtjs/i18n', '@nuxt/eslint', '@nuxtjs/sitemap', 'nuxt-gtag', '@nuxt/image'] --> +<!-- i18n already configured with prefix_except_default, FR default, cookie detection --> +<!-- CRITICAL: Do NOT add @nuxtjs/color-mode to modules[] — @nuxt/ui auto-registers it --> +</interfaces> +</context> + +<tasks> + +<task type="auto"> + <name>Task 1: Design system + color-mode + sitemap config</name> + <files>app/assets/css/main.css, app.config.ts, nuxt.config.ts</files> + <read_first> + - nuxt.config.ts (current module list — do NOT duplicate color-mode) + - .planning/phases/02-ssr-shell/02-RESEARCH.md (Pattern 5 for CSS @theme, Pattern 1 for colorMode config) + - .planning/phases/02-ssr-shell/02-UI-SPEC.md (color section for exact hex values) + - src/config/site.ts (site URL: https://killiandalcin.fr) + </read_first> + <action> +1. Create `app/assets/css/main.css` with Tailwind v4 + Nuxt UI imports and brand color @theme: +```css +@import "tailwindcss"; +@import "@nuxt/ui"; + +@theme { + --color-brand-50: #f0faf0; + --color-brand-100: #dcf3dc; + --color-brand-200: #bbe8bb; + --color-brand-300: #8dd98d; + --color-brand-400: #a3d6a3; + --color-brand-500: #85cb85; + --color-brand-600: #5aaa5a; + --color-brand-700: #3f8c3f; + --color-brand-800: #2e6b2e; + --color-brand-900: #1f4f1f; + --color-brand-950: #122d12; +} +``` + +2. Create `app.config.ts`: +```typescript +export default defineAppConfig({ + ui: { + colors: { + primary: 'brand', + }, + }, +}) +``` + +3. Update `nuxt.config.ts` — add these keys (do NOT add @nuxtjs/color-mode to modules[]): +- `css: ['~/assets/css/main.css']` +- `colorMode` block: `{ preference: 'dark', fallback: 'dark', storage: 'cookie', storageKey: 'nuxt-color-mode', cookieName: 'nuxt-color-mode', classSuffix: '' }` +- `i18n.baseUrl: 'https://killiandalcin.fr'` +- `site: { url: 'https://killiandalcin.fr', name: 'Killian Dalcin - Developpeur Full Stack' }` + +Do NOT touch existing modules array or i18n locale config — they are correct from Phase 1. + +4. Copy or create a static og:image file at `public/og-image.png` (1200x630). If no real image available, create a placeholder text file noting it needs a real image. Per user decision: static image in public/, no nuxt-og-image module. + </action> + <verify> + <automated>grep -q "color-brand-500" app/assets/css/main.css && grep -q "primary.*brand" app.config.ts && grep -q "colorMode" nuxt.config.ts && grep -q "baseUrl" nuxt.config.ts && grep -q "css:" nuxt.config.ts && echo "PASS" || echo "FAIL"</automated> + </verify> + <acceptance_criteria> + - app/assets/css/main.css contains `--color-brand-500: #85cb85` + - app/assets/css/main.css contains `@import "tailwindcss"` and `@import "@nuxt/ui"` + - app.config.ts contains `primary: 'brand'` + - nuxt.config.ts contains `colorMode:` with `storage: 'cookie'` and `preference: 'dark'` + - nuxt.config.ts contains `baseUrl: 'https://killiandalcin.fr'` inside i18n block + - nuxt.config.ts contains `site:` with `url: 'https://killiandalcin.fr'` + - nuxt.config.ts does NOT contain `'@nuxtjs/color-mode'` in modules array + - nuxt.config.ts contains `css: ['~/assets/css/main.css']` + - public/og-image.png exists + </acceptance_criteria> + <done>Design system configured: brand color in CSS @theme, Nuxt UI maps primary to brand, color-mode uses cookie with dark default, i18n baseUrl and site.url set for absolute SEO URLs, static og:image in public/.</done> +</task> + +<task type="auto"> + <name>Task 2: Migrate i18n translations for Phase 2 scope</name> + <files>app/locales/fr.json, app/locales/en.json</files> + <read_first> + - app/locales/fr.json (currently empty {}) + - app/locales/en.json (currently empty {}) + - src/locales/fr.ts (source translations to migrate — nav, footer keys) + - src/locales/en.ts (source EN translations) + - .planning/phases/02-ssr-shell/02-UI-SPEC.md (Copywriting Contract table — exact copy for all nav, footer, a11y keys) + - app/data/projects.ts (check which i18n keys projects reference — those need translation entries too) + </read_first> + <action> +Enrich app/locales/fr.json and app/locales/en.json with ALL keys needed by Phase 2 (header, footer, SEO metadata, accessibility labels). Also migrate existing project/page translation keys from src/locales/ that are already referenced by data files. + +**Phase 2 keys to add (from UI-SPEC Copywriting Contract):** + +fr.json top-level structure: +```json +{ + "nav": { + "home": "Accueil", + "projects": "Projets", + "about": "A propos", + "contact": "Contact", + "fiverr": "Fiverr", + "formation": "Formation" + }, + "footer": { + "copyright": "© 2026 Killian Dalcin" + }, + "a11y": { + "logoLabel": "Killian Dalcin — Developpeur Full Stack — Retour a l'accueil", + "openMenu": "Ouvrir le menu de navigation", + "closeMenu": "Fermer le menu de navigation", + "closeDrawer": "Fermer le menu", + "langToggle": "Changer la langue — actuellement Francais", + "themeDark": "Activer le mode clair", + "themeLight": "Activer le mode sombre", + "github": "GitHub de Killian Dalcin (nouvelle fenetre)", + "linkedin": "LinkedIn de Killian Dalcin (nouvelle fenetre)", + "fiverr": "Fiverr de Killian Dalcin (nouvelle fenetre)" + }, + "seo": { + "home": { + "title": "Killian Dalcin — Developpeur Full Stack Freelance", + "description": "Portfolio de Killian Dalcin, developpeur full stack freelance specialise en Vue.js, React et Node.js. Applications web performantes et solutions sur-mesure." + }, + "projects": { + "title": "Projets — Killian Dalcin", + "description": "Decouvrez mes realisations en developpement web : applications Vue.js, API Node.js, bots Discord et solutions d'entreprise." + }, + "about": { + "title": "A propos — Killian Dalcin", + "description": "Biographie et competences de Killian Dalcin, developpeur full stack freelance base en France." + }, + "contact": { + "title": "Contact — Killian Dalcin", + "description": "Contactez Killian Dalcin pour discuter de votre projet de developpement web." + }, + "fiverr": { + "title": "Services Fiverr — Killian Dalcin", + "description": "Services de developpement disponibles sur Fiverr : bots Discord, plugins Minecraft, applications web." + }, + "formation": { + "title": "Formation — Killian Dalcin", + "description": "Formations et cours proposes par Killian Dalcin en developpement web." + } + } +} +``` + +en.json same structure with English translations: +```json +{ + "nav": { + "home": "Home", + "projects": "Projects", + "about": "About", + "contact": "Contact", + "fiverr": "Fiverr", + "formation": "Training" + }, + "footer": { + "copyright": "© 2026 Killian Dalcin" + }, + "a11y": { + "logoLabel": "Killian Dalcin — Full Stack Developer — Back to homepage", + "openMenu": "Open navigation menu", + "closeMenu": "Close navigation menu", + "closeDrawer": "Close menu", + "langToggle": "Change language — currently English", + "themeDark": "Switch to light mode", + "themeLight": "Switch to dark mode", + "github": "Killian Dalcin on GitHub (opens in new tab)", + "linkedin": "Killian Dalcin on LinkedIn (opens in new tab)", + "fiverr": "Killian Dalcin on Fiverr (opens in new tab)" + }, + "seo": { + "home": { + "title": "Killian Dalcin — Freelance Full Stack Developer", + "description": "Portfolio of Killian Dalcin, freelance full stack developer specializing in Vue.js, React and Node.js. High-performance web applications and custom solutions." + }, + "projects": { + "title": "Projects — Killian Dalcin", + "description": "Discover my web development projects: Vue.js applications, Node.js APIs, Discord bots and enterprise solutions." + }, + "about": { + "title": "About — Killian Dalcin", + "description": "Biography and skills of Killian Dalcin, freelance full stack developer based in France." + }, + "contact": { + "title": "Contact — Killian Dalcin", + "description": "Contact Killian Dalcin to discuss your web development project." + }, + "fiverr": { + "title": "Fiverr Services — Killian Dalcin", + "description": "Development services available on Fiverr: Discord bots, Minecraft plugins, web applications." + }, + "formation": { + "title": "Training — Killian Dalcin", + "description": "Training and courses offered by Killian Dalcin in web development." + } + } +} +``` + +ALSO: migrate all existing translation keys from src/locales/fr.ts and src/locales/en.ts that are referenced by app/data/*.ts files (project titles, descriptions, testimonials, FAQ, techstack categories, home page content, etc.). Merge them into the same fr.json/en.json files under their existing key structure (e.g., `projects.xinko.title`, `home.title`, etc.). + +Per D-06: one file per language, enrich existing files. + </action> + <verify> + <automated>node -e "const fr=require('./app/locales/fr.json'); const en=require('./app/locales/en.json'); const checks=['nav.home','footer.copyright','a11y.logoLabel','seo.home.title','seo.projects.title']; const ok=checks.every(k=>{const p=k.split('.'); let v=fr; for(const s of p) v=v?.[s]; return !!v}) && checks.every(k=>{const p=k.split('.'); let v=en; for(const s of p) v=v?.[s]; return !!v}); console.log(ok?'PASS':'FAIL')"</automated> + </verify> + <acceptance_criteria> + - app/locales/fr.json contains keys: nav.home, nav.projects, nav.about, nav.contact, nav.fiverr, nav.formation + - app/locales/fr.json contains keys: footer.copyright, a11y.logoLabel, a11y.openMenu, a11y.themeDark + - app/locales/fr.json contains keys: seo.home.title, seo.home.description, seo.projects.title + - app/locales/en.json contains the same key structure with English values + - en.json nav.formation value is "Training" (not "Formation") + - Both files are valid JSON (node -e "require('./app/locales/fr.json')" exits 0) + - Existing i18n keys referenced by app/data/*.ts are present in both locale files + </acceptance_criteria> + <done>Both fr.json and en.json contain all nav, footer, a11y, seo keys from UI-SPEC copywriting contract plus migrated keys from src/locales/ for data file references.</done> +</task> + +</tasks> + +<threat_model> +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Cookie → Server | i18n and color-mode cookies read by server to determine locale/theme | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-01 | Tampering | color-mode cookie | accept | Cookie only controls CSS class (dark/light) — no security impact if tampered | +| T-02-02 | Tampering | i18n cookie | accept | Cookie only controls locale (fr/en) — no security impact if tampered | +| T-02-03 | Information Disclosure | site.url in nuxt.config | accept | Public URL, no secret information | +</threat_model> + +<verification> +- `npx nuxi typecheck` passes +- `pnpm dev` starts without errors +- fr.json and en.json are valid JSON with all Phase 2 keys +</verification> + +<success_criteria> +- Brand color #85cb85 registered as Nuxt UI primary +- Color-mode configured with cookie storage, dark default, no FOUC +- i18n baseUrl set for absolute hreflang/canonical URLs +- All Phase 2 translation keys present in both locale files +- Static og:image exists in public/ +</success_criteria> + +<output> +After completion, create `.planning/phases/02-ssr-shell/02-01-SUMMARY.md` +</output> diff --git a/.planning/phases/02-ssr-shell/02-02-PLAN.md b/.planning/phases/02-ssr-shell/02-02-PLAN.md new file mode 100644 index 0000000..08554cd --- /dev/null +++ b/.planning/phases/02-ssr-shell/02-02-PLAN.md @@ -0,0 +1,318 @@ +--- +phase: 02-ssr-shell +plan: 02 +type: execute +wave: 2 +depends_on: [02-01] +files_modified: + - app/components/layout/AppHeader.vue + - app/components/layout/AppFooter.vue + - app/layouts/default.vue + - app/app.vue +autonomous: true +requirements: [COMP-05, COMP-06, I18N-03, THEME-01] + +must_haves: + truths: + - "Header is sticky with logo left, nav center-right, toggles far right" + - "Language toggle switches FR/EN and persists via cookie" + - "Theme toggle switches dark/light and persists via cookie" + - "Mobile hamburger opens UDrawer with nav links and toggles" + - "Footer shows copyright and social icon links" + - "Default layout wraps all pages with header + slot + footer" + artifacts: + - path: "app/components/layout/AppHeader.vue" + provides: "Sticky header with nav, lang toggle, theme toggle, mobile drawer" + min_lines: 80 + - path: "app/components/layout/AppFooter.vue" + provides: "Minimal footer with copyright and social icons" + min_lines: 20 + - path: "app/layouts/default.vue" + provides: "Default Nuxt layout: header + slot + footer" + contains: "AppHeader" + key_links: + - from: "app/components/layout/AppHeader.vue" + to: "@nuxtjs/i18n" + via: "setLocale() for language switching" + pattern: "setLocale" + - from: "app/components/layout/AppHeader.vue" + to: "@nuxtjs/color-mode" + via: "useColorMode() for theme toggle" + pattern: "useColorMode" + - from: "app/layouts/default.vue" + to: "app/components/layout/AppHeader.vue" + via: "component import" + pattern: "AppHeader" +--- + +<objective> +Build the header (with desktop nav, mobile drawer, language/theme toggles), footer, and default layout. + +Purpose: Provide the visible SSR shell that wraps all pages — navigation, toggles, and footer are functional. +Output: AppHeader.vue, AppFooter.vue, default.vue layout, updated app.vue. +</objective> + +<execution_context> +@~/.claude/get-shit-done/workflows/execute-plan.md +@~/.claude/get-shit-done/templates/summary.md +</execution_context> + +<context> +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/02-ssr-shell/02-CONTEXT.md +@.planning/phases/02-ssr-shell/02-RESEARCH.md +@.planning/phases/02-ssr-shell/02-UI-SPEC.md +@.planning/phases/02-ssr-shell/02-01-SUMMARY.md + +<interfaces> +<!-- From app.config.ts (created by Plan 01): primary color = 'brand' (#85cb85) --> +<!-- From app/locales/fr.json (created by Plan 01): keys nav.*, footer.*, a11y.* --> +<!-- From nuxt.config.ts: colorMode configured with cookie, i18n with prefix_except_default --> +<!-- From src/config/site.ts: social links array with Gitea, LinkedIn, Discord, Email --> + +<!-- Nuxt UI v3 components available (auto-imported): UDrawer, UButton, UIcon, UNavigationMenu --> +<!-- @nuxtjs/i18n composables: useI18n(), useSetLocale(), useSwitchLocalePath(), useLocalePath() --> +<!-- @nuxtjs/color-mode composable: useColorMode() --> +<!-- Nuxt Icon sets: heroicons:*, simple-icons:* --> + +<!-- User post-research decision: Footer social icon uses Gitea icon (simple-icons:gitea), NOT GitHub --> +<!-- Social links from src/config/site.ts: Gitea (gitea.kamisama.ovh), LinkedIn, Discord, Email --> +<!-- D-05 says: GitHub, LinkedIn, Fiverr — BUT user corrected to Gitea. Use: Gitea, LinkedIn, Fiverr --> +</interfaces> +</context> + +<tasks> + +<task type="auto"> + <name>Task 1: AppHeader with nav, language toggle, theme toggle, mobile drawer</name> + <files>app/components/layout/AppHeader.vue</files> + <read_first> + - src/components/layout/AppHeader.vue (old header — migration reference for structure and nav links) + - .planning/phases/02-ssr-shell/02-UI-SPEC.md (Component Inventory: AppHeader, LanguageToggle, ThemeToggle, MobileDrawer specs; Interaction States table; Copywriting Contract for aria-labels) + - .planning/phases/02-ssr-shell/02-RESEARCH.md (Pattern 2: Language Switcher with useSetLocale; Pattern 1: ThemeToggle with useColorMode) + - app/locales/fr.json (verify nav.* and a11y.* keys exist from Plan 01) + - src/config/site.ts (check logo image path — public/images/logo.webp) + </read_first> + <action> +Create `app/components/layout/AppHeader.vue` as a single-file component containing: + +**Structure (per D-01, D-03):** +- `<header>` with `class="sticky top-0 z-[1020] bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800"` +- Inner wrapper: `<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">` + +**Left — Logo:** +- `<NuxtLink to="localePath('/')" :aria-label="t('a11y.logoLabel')">` +- Contains `<NuxtImg src="/images/logo.webp" alt="Killian Dalcin" width="40" height="40" loading="eager" />` + `<span class="text-lg font-semibold">Killian</span>` + +**Center-Right — Desktop nav (hidden md:flex):** +- Use `<nav>` with `<NuxtLink>` for each route: home(/), projects(/projects), about(/about), contact(/contact), fiverr(/fiverr), formation(/formation) +- Use `useLocalePath()` to generate locale-aware paths +- Active link detection: `class` binding comparing `route.path` with `localePath(path)` +- Active state: `border-b-2 border-primary-500` accent underline +- Default state: `text-gray-700 dark:text-gray-300` +- Hover state: `hover:text-primary-500` +- Focus: `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2` +- Nav link labels from `t('nav.home')`, `t('nav.projects')`, etc. +- `aria-current="page"` on active link + +**Far Right — Toggles:** + +Language toggle (per D-04 — simple text button FR/EN): +- `<button>` displaying `locale === 'fr' ? 'EN' : 'FR'` (shows the OTHER language to switch to) +- `class="min-w-11 min-h-11 inline-flex items-center justify-center text-gray-700 dark:text-gray-300 font-medium hover:text-primary-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 transition-colors"` +- Click handler: `const setLocale = useSetLocale(); setLocale(locale.value === 'fr' ? 'en' : 'fr')` +- `:aria-label="t('a11y.langToggle')"` + +Theme toggle (per D-09): +- `<button>` with `<UIcon>`: show `heroicons:sun` when dark mode active (clicking switches to light), `heroicons:moon` when light mode active +- Icon size: `class="w-5 h-5"` +- Button: `class="min-w-11 min-h-11 inline-flex items-center justify-center text-gray-600 dark:text-gray-400 hover:text-primary-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 transition-colors duration-300"` +- Click: `const colorMode = useColorMode(); colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'` +- `:aria-label="colorMode.value === 'dark' ? t('a11y.themeDark') : t('a11y.themeLight')"` + +Hamburger button (md:hidden): +- `<button @click="drawerOpen = true" class="md:hidden min-w-11 min-h-11 ..." :aria-label="t('a11y.openMenu')">` +- `<UIcon name="heroicons:bars-3" class="w-6 h-6" />` + +**Mobile Drawer (per D-02):** +- `<UDrawer v-model:open="drawerOpen" side="left">` +- Inside: close button with `<UIcon name="heroicons:x-mark" />` and `:aria-label="t('a11y.closeDrawer')"` +- Nav links stacked full-width, same routes as desktop +- Language toggle and theme toggle at bottom +- Click any nav link sets `drawerOpen = false` + +**Script setup:** +```typescript +const { t, locale } = useI18n() +const localePath = useLocalePath() +const setLocale = useSetLocale() +const colorMode = useColorMode() +const route = useRoute() +const drawerOpen = ref(false) + +const navLinks = computed(() => [ + { key: 'home', path: '/' }, + { key: 'projects', path: '/projects' }, + { key: 'about', path: '/about' }, + { key: 'contact', path: '/contact' }, + { key: 'fiverr', path: '/fiverr' }, + { key: 'formation', path: '/formation' }, +]) + +function toggleLocale() { + setLocale(locale.value === 'fr' ? 'en' : 'fr') +} + +function toggleTheme() { + colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark' +} +``` + </action> + <verify> + <automated>grep -q "useColorMode" app/components/layout/AppHeader.vue && grep -q "useSetLocale\|setLocale" app/components/layout/AppHeader.vue && grep -q "UDrawer" app/components/layout/AppHeader.vue && grep -q "sticky" app/components/layout/AppHeader.vue && grep -q "a11y.logoLabel" app/components/layout/AppHeader.vue && echo "PASS" || echo "FAIL"</automated> + </verify> + <acceptance_criteria> + - app/components/layout/AppHeader.vue exists and contains `sticky top-0` + - Contains `useColorMode()` for theme toggle + - Contains `useSetLocale()` or `setLocale` for language switching + - Contains `UDrawer` for mobile navigation + - Contains `z-[1020]` for z-index + - Contains `heroicons:sun` and `heroicons:moon` for theme icons + - Contains `heroicons:bars-3` for hamburger + - Contains `t('a11y.logoLabel')` for logo aria-label + - Contains `localePath` for locale-aware routing + - Contains `min-w-11 min-h-11` on interactive buttons (44px touch targets) + - Contains `aria-current` for active nav link + - Contains `focus-visible:ring-2` on interactive elements + </acceptance_criteria> + <done>AppHeader renders sticky header with desktop nav links, FR/EN text toggle using setLocale, dark/light icon toggle using useColorMode, and mobile UDrawer. All interactive elements have WCAG touch targets, focus rings, and ARIA labels from i18n.</done> +</task> + +<task type="auto"> + <name>Task 2: AppFooter + default layout + app.vue update</name> + <files>app/components/layout/AppFooter.vue, app/layouts/default.vue, app/app.vue</files> + <read_first> + - src/components/layout/AppFooter.vue (old footer — migration reference) + - src/config/site.ts (social links: Gitea, LinkedIn, Discord, Email — note user wants Gitea icon not GitHub, and D-05 specifies Fiverr link too) + - .planning/phases/02-ssr-shell/02-UI-SPEC.md (AppFooter spec, Interaction States for social icons) + - .planning/phases/02-ssr-shell/02-RESEARCH.md (Pattern 3: useLocaleHead in app.vue) + - app/app.vue (current state — has useHead with htmlAttrs lang) + - app/locales/fr.json (verify footer.* and a11y.* keys) + </read_first> + <action> +1. Create `app/components/layout/AppFooter.vue`: + +Per D-05: single band footer — copyright + social icons. +User post-research decision: use Gitea icon (not GitHub). Social links: Gitea (gitea.kamisama.ovh/kayjaydee), LinkedIn (linkedin.com/in/killian-dal-cin), Fiverr (fiverr.com/users/mr_kayjaydee). + +```vue +<script setup lang="ts"> +const { t } = useI18n() + +const socialLinks = [ + { name: 'gitea', url: 'https://gitea.kamisama.ovh/kayjaydee', icon: 'simple-icons:gitea', ariaKey: 'a11y.github' }, + { name: 'linkedin', url: 'https://linkedin.com/in/killian-dal-cin', icon: 'simple-icons:linkedin', ariaKey: 'a11y.linkedin' }, + { name: 'fiverr', url: 'https://www.fiverr.com/users/mr_kayjaydee', icon: 'simple-icons:fiverr', ariaKey: 'a11y.fiverr' }, +] +</script> +``` + +Template: +- `<footer class="py-6 bg-gray-100 dark:bg-gray-800">` +- Inner: `<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-col md:flex-row items-center justify-between gap-4">` +- Left: `<p class="text-sm text-gray-600 dark:text-gray-400">{{ t('footer.copyright') }}</p>` +- Right: social icons in flex row, each `<a :href="link.url" target="_blank" rel="noopener noreferrer" :aria-label="t(link.ariaKey)">` +- Icon: `<UIcon :name="link.icon" class="w-5 h-5 text-gray-500 dark:text-gray-400 hover:text-primary-500 transition-colors duration-150" />` +- Focus ring on each `<a>`: `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2` + +Note: a11y.github key text says "GitHub de Killian Dalcin" but links to Gitea — the executor should update the a11y key in fr.json/en.json to say "Gitea" instead of "GitHub" if not already correct. Check and fix if needed. + +2. Create `app/layouts/default.vue` (per D-15): +```vue +<template> + <div class="min-h-screen flex flex-col"> + <AppHeader /> + <main class="flex-1"> + <slot /> + </main> + <AppFooter /> + </div> +</template> +``` + +3. Update `app/app.vue` to use the default layout and add useLocaleHead() for global hreflang/canonical (per RESEARCH Pattern 3): +```vue +<script setup lang="ts"> +const { locale } = useI18n() +const head = useLocaleHead({ addSeoAttributes: true }) + +useHead({ + htmlAttrs: { lang: locale }, + link: computed(() => head.value.link || []), + meta: computed(() => head.value.meta || []), +}) +</script> + +<template> + <NuxtLayout> + <NuxtPage /> + </NuxtLayout> +</template> +``` + +Remove the existing `<NuxtRouteAnnouncer />` and `<div>` wrapper — the layout handles structure now. + </action> + <verify> + <automated>grep -q "AppHeader" app/layouts/default.vue && grep -q "AppFooter" app/layouts/default.vue && grep -q "simple-icons:gitea" app/components/layout/AppFooter.vue && grep -q "useLocaleHead" app/app.vue && grep -q "NuxtLayout" app/app.vue && echo "PASS" || echo "FAIL"</automated> + </verify> + <acceptance_criteria> + - app/components/layout/AppFooter.vue contains `simple-icons:gitea` (not github) + - app/components/layout/AppFooter.vue contains `simple-icons:linkedin` and `simple-icons:fiverr` + - app/components/layout/AppFooter.vue contains `target="_blank"` and `rel="noopener noreferrer"` + - app/components/layout/AppFooter.vue contains `t('footer.copyright')` + - app/layouts/default.vue contains `<AppHeader />` and `<AppFooter />` + - app/layouts/default.vue contains `<slot />` + - app/app.vue contains `useLocaleHead({ addSeoAttributes: true })` + - app/app.vue contains `<NuxtLayout>` wrapping `<NuxtPage />` + - app/app.vue does NOT contain `<NuxtRouteAnnouncer />` + </acceptance_criteria> + <done>AppFooter renders copyright + Gitea/LinkedIn/Fiverr social icons. Default layout wraps header + slot + footer. app.vue uses NuxtLayout and injects global hreflang/canonical via useLocaleHead().</done> +</task> + +</tasks> + +<threat_model> +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| External links (footer) | Social icon links open external URLs in new tabs | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-04 | Tampering | External social links | mitigate | All external links use `rel="noopener noreferrer"` to prevent reverse tabnabbing | +| T-02-05 | Spoofing | Locale switching | accept | setLocale only accepts 'fr' or 'en' — constrained by i18n config, no injection risk | +</threat_model> + +<verification> +- `pnpm dev` starts and renders header + footer on localhost:3000 +- Language toggle switches between FR/EN URLs +- Theme toggle switches dark/light classes +- Mobile hamburger opens UDrawer +- `curl http://localhost:3000` returns HTML with `<header>` and `<footer>` elements +</verification> + +<success_criteria> +- Header sticky with nav links, FR/EN toggle, dark/light toggle, mobile drawer +- Footer shows copyright and 3 social icon links +- Default layout renders header + page content + footer +- app.vue injects global hreflang/canonical metadata +- All interactive elements have focus rings and ARIA labels +</success_criteria> + +<output> +After completion, create `.planning/phases/02-ssr-shell/02-02-SUMMARY.md` +</output> diff --git a/.planning/phases/02-ssr-shell/02-03-PLAN.md b/.planning/phases/02-ssr-shell/02-03-PLAN.md new file mode 100644 index 0000000..5ffa184 --- /dev/null +++ b/.planning/phases/02-ssr-shell/02-03-PLAN.md @@ -0,0 +1,212 @@ +--- +phase: 02-ssr-shell +plan: 03 +type: execute +wave: 2 +depends_on: [02-01] +files_modified: + - app/pages/index.vue + - app/pages/projects.vue + - app/pages/about.vue + - app/pages/contact.vue + - app/pages/fiverr.vue + - app/pages/formation.vue +autonomous: true +requirements: [SEO-01, SEO-02, SEO-04] + +must_haves: + truths: + - "Every route has unique title, description, og:title, og:description in SSR HTML" + - "Homepage includes JSON-LD Person + ProfessionalService schema" + - "Every route has og:image with absolute URL" + - "curl output for each route contains <title> and og:description meta tag" + artifacts: + - path: "app/pages/index.vue" + provides: "Homepage with SEO metadata and JSON-LD" + contains: "useSeoMeta" + - path: "app/pages/projects.vue" + provides: "Projects stub page with SEO metadata" + contains: "useSeoMeta" + key_links: + - from: "app/pages/index.vue" + to: "app/locales/fr.json" + via: "t('seo.home.title') for localized SEO" + pattern: "seo\\.home\\.title" + - from: "app/pages/index.vue" + to: "JSON-LD" + via: "useHead script tag" + pattern: "application/ld\\+json" +--- + +<objective> +Add per-route SEO metadata (useSeoMeta) and JSON-LD structured data to all page stubs. + +Purpose: Every route returns correct, unique, localized SEO tags in server-rendered HTML — verifiable by curl. +Output: 6 page files with useSeoMeta(), homepage with JSON-LD, all with og:image. +</objective> + +<execution_context> +@~/.claude/get-shit-done/workflows/execute-plan.md +@~/.claude/get-shit-done/templates/summary.md +</execution_context> + +<context> +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/02-ssr-shell/02-CONTEXT.md +@.planning/phases/02-ssr-shell/02-RESEARCH.md +@.planning/phases/02-ssr-shell/02-UI-SPEC.md +@.planning/phases/02-ssr-shell/02-01-SUMMARY.md + +<interfaces> +<!-- From app/locales/fr.json (Plan 01): seo.home.title, seo.home.description, seo.projects.title, etc. --> +<!-- From nuxt.config.ts (Plan 01): site.url = 'https://killiandalcin.fr' --> +<!-- From public/og-image.png (Plan 01): static og:image file --> +<!-- Nuxt built-in: useSeoMeta(), useHead() — auto-imported --> +<!-- @nuxtjs/i18n: useI18n() for t() function --> +</interfaces> +</context> + +<tasks> + +<task type="auto"> + <name>Task 1: Per-route SEO metadata on all page stubs</name> + <files>app/pages/index.vue, app/pages/projects.vue, app/pages/about.vue, app/pages/contact.vue, app/pages/fiverr.vue, app/pages/formation.vue</files> + <read_first> + - app/pages/index.vue (current stub — will be enhanced) + - app/locales/fr.json (verify seo.* keys exist) + - .planning/phases/02-ssr-shell/02-RESEARCH.md (Pattern 3: useSeoMeta per route; Pattern 4: JSON-LD) + - .planning/phases/02-ssr-shell/02-UI-SPEC.md (SEO Contract table) + - src/config/site.ts (siteConfig.seo.organization for JSON-LD schema data; url: https://killiandalcin.fr) + </read_first> + <action> +Update each page stub to include `useSeoMeta()` with localized metadata. Pages remain stubs (minimal template content) — Phase 3 fills real content. + +**Pattern for every page** (example: projects.vue): +```vue +<script setup lang="ts"> +const { t } = useI18n() + +useSeoMeta({ + title: () => t('seo.projects.title'), + description: () => t('seo.projects.description'), + ogTitle: () => t('seo.projects.title'), + ogDescription: () => t('seo.projects.description'), + ogImage: 'https://killiandalcin.fr/og-image.png', + ogImageWidth: 1200, + ogImageHeight: 630, + ogType: 'website', +}) +</script> + +<template> + <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> + <h1 class="text-2xl font-bold">{{ t('nav.projects') }}</h1> + <p class="text-gray-600 dark:text-gray-400 mt-4">Phase 3 content placeholder</p> + </div> +</template> +``` + +Apply this pattern to all 6 pages using their respective seo.{page}.title and seo.{page}.description keys: +- index.vue → seo.home.* +- projects.vue → seo.projects.* +- about.vue → seo.about.* +- contact.vue → seo.contact.* +- fiverr.vue → seo.fiverr.* +- formation.vue → seo.formation.* + +All pages use `ogImage: 'https://killiandalcin.fr/og-image.png'` (per user decision: static image, no nuxt-og-image). + +**Homepage (index.vue) ADDITIONALLY gets JSON-LD** (per D-11, SEO-02): +```typescript +useHead({ + script: [ + { + type: 'application/ld+json', + innerHTML: JSON.stringify({ + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'Person', + name: 'Killian Dalcin', + url: 'https://killiandalcin.fr', + jobTitle: 'Developpeur Full Stack Freelance', + email: 'contact@killiandalcin.fr', + sameAs: [ + 'https://linkedin.com/in/killian-dal-cin', + 'https://www.fiverr.com/users/mr_kayjaydee', + 'https://gitea.kamisama.ovh/kayjaydee', + ], + }, + { + '@type': 'ProfessionalService', + name: 'Killian Dalcin - Developpeur Full Stack', + url: 'https://killiandalcin.fr', + logo: 'https://killiandalcin.fr/images/logo.webp', + priceRange: '$$$', + areaServed: 'Worldwide', + }, + ], + }), + }, + ], +}) +``` + +Create pages that do not yet exist (projects.vue, about.vue, contact.vue, fiverr.vue, formation.vue) as new files. Update existing index.vue. + +Each stub page template should have: +- `<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">` wrapper (per D-16) +- An `<h1>` using the nav translation key +- A placeholder paragraph + </action> + <verify> + <automated>grep -q "useSeoMeta" app/pages/index.vue && grep -q "application/ld+json" app/pages/index.vue && grep -q "useSeoMeta" app/pages/projects.vue && grep -q "useSeoMeta" app/pages/about.vue && grep -q "useSeoMeta" app/pages/contact.vue && grep -q "useSeoMeta" app/pages/fiverr.vue && grep -q "useSeoMeta" app/pages/formation.vue && grep -q "og-image.png" app/pages/index.vue && echo "PASS" || echo "FAIL"</automated> + </verify> + <acceptance_criteria> + - All 6 page files exist under app/pages/ + - Every page contains `useSeoMeta` with title, description, ogTitle, ogDescription, ogImage + - ogImage value is `https://killiandalcin.fr/og-image.png` on every page + - index.vue contains `application/ld+json` with `Person` and `ProfessionalService` + - index.vue JSON-LD contains `sameAs` array with LinkedIn, Fiverr, Gitea URLs + - Each page uses localized seo keys: `t('seo.home.title')`, `t('seo.projects.title')`, etc. + - Each page template has `max-w-7xl mx-auto` wrapper + - `npx nuxi typecheck` passes + </acceptance_criteria> + <done>All 6 routes have unique, localized SEO metadata via useSeoMeta(). Homepage includes JSON-LD with Person + ProfessionalService schema. Every page has og:image with absolute URL.</done> +</task> + +</tasks> + +<threat_model> +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| SEO meta tags | Server-rendered meta tags include user-controlled translation values | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-06 | Injection | JSON-LD innerHTML | mitigate | JSON.stringify() escapes special characters; no user input in JSON-LD — all values are hardcoded constants | +| T-02-07 | Information Disclosure | og:image URL | accept | Public URL pointing to public image — no sensitive data | +</threat_model> + +<verification> +- `pnpm dev` then `curl http://localhost:3000` returns HTML containing `<title>`, `og:title`, `og:description` meta tags, and JSON-LD script +- `curl http://localhost:3000/en/` returns English title/description +- `curl http://localhost:3000/projects` returns projects-specific title +- Each page curl output contains `og-image.png` in a meta tag +</verification> + +<success_criteria> +- All 6 routes have unique, localized SEO metadata in server-rendered HTML +- Homepage JSON-LD contains Person + ProfessionalService +- og:image present on every route with absolute URL +- `npx nuxi typecheck` passes +</success_criteria> + +<output> +After completion, create `.planning/phases/02-ssr-shell/02-03-SUMMARY.md` +</output>