Files
portfolio/.planning/phases/02-ssr-shell/02-02-PLAN.md
T

15 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-ssr-shell 02 execute 2
02-01
app/components/layout/AppHeader.vue
app/components/layout/AppFooter.vue
app/layouts/default.vue
app/app.vue
true
COMP-05
COMP-06
I18N-03
THEME-01
truths artifacts key_links
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
path provides min_lines
app/components/layout/AppHeader.vue Sticky header with nav, lang toggle, theme toggle, mobile drawer 80
path provides min_lines
app/components/layout/AppFooter.vue Minimal footer with copyright and social icons 20
path provides contains
app/layouts/default.vue Default Nuxt layout: header + slot + footer AppHeader
from to via pattern
app/components/layout/AppHeader.vue @nuxtjs/i18n setLocale() for language switching setLocale
from to via pattern
app/components/layout/AppHeader.vue @nuxtjs/color-mode useColorMode() for theme toggle useColorMode
from to via pattern
app/layouts/default.vue app/components/layout/AppHeader.vue component import AppHeader
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.

<execution_context> @/.claude/get-shit-done/workflows/execute-plan.md @/.claude/get-shit-done/templates/summary.md </execution_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 Task 1: AppHeader with nav, language toggle, theme toggle, mobile drawer app/components/layout/AppHeader.vue - 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) 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:

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'
}
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" - 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 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. Task 2: AppFooter + default layout + app.vue update app/components/layout/AppFooter.vue, app/layouts/default.vue, app/app.vue - 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) 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).

<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.

  1. Create app/layouts/default.vue (per D-15):
<template>
  <div class="min-h-screen flex flex-col">
    <AppHeader />
    <main class="flex-1">
      <slot />
    </main>
    <AppFooter />
  </div>
</template>
  1. Update app/app.vue to use the default layout and add useLocaleHead() for global hreflang/canonical (per RESEARCH Pattern 3):
<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. 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" <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> 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().

<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>
- `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 `` and `` elements

<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>
After completion, create `.planning/phases/02-ssr-shell/02-02-SUMMARY.md`