docs(02): create phase 2 SSR shell plans
This commit is contained in:
+8
-10
@@ -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.
|
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 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
|
- [ ] **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
|
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 `<title>`, `og:title`, `og:description`, and JSON-LD script tag
|
4. `curl http://localhost:3000` response includes `<title>`, `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
|
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:
|
Plans:
|
||||||
- [x] 01-01-PLAN.md — Scaffold Nuxt 4, modules, TypeScript strict, interfaces
|
- [ ] 02-01-PLAN.md — Design system, color-mode, i18n translations, sitemap config
|
||||||
- [x] 01-02-PLAN.md — Migration donnees statiques + useProjects()
|
- [ ] 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
|
**UI hint**: yes
|
||||||
|
|
||||||
### Phase 3: Pages & Ship
|
### 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
|
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
|
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
|
5. Google Analytics 4 events appear in GA4 DebugView when browsing in production mode
|
||||||
**Plans**: 2 plans
|
**Plans**: TBD
|
||||||
Plans:
|
|
||||||
- [ ] 01-01-PLAN.md — Scaffold Nuxt 4, modules, TypeScript strict, interfaces
|
|
||||||
- [ ] 01-02-PLAN.md — Migration donnees statiques + useProjects()
|
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
@@ -71,6 +69,6 @@ Phases execute in numeric order: 1 → 2 → 3
|
|||||||
|
|
||||||
| Phase | Plans Complete | Status | Completed |
|
| Phase | Plans Complete | Status | Completed |
|
||||||
|-------|----------------|--------|-----------|
|
|-------|----------------|--------|-----------|
|
||||||
| 1. Foundation | 0/TBD | Not started | - |
|
| 1. Foundation | 2/2 | Complete | 2026-04-08 |
|
||||||
| 2. SSR Shell | 0/TBD | Not started | - |
|
| 2. SSR Shell | 0/3 | Planning complete | - |
|
||||||
| 3. Pages & Ship | 0/TBD | Not started | - |
|
| 3. Pages & Ship | 0/TBD | Not started | - |
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user