# Portfolio Développeur Web - Frontend Architecture Document ## Change Log | Date | Version | Description | Author | |------|---------|-------------|--------| | 2026-01-22 | 1.0 | Création initiale | Winston (Architect) | --- ## 1. Template and Framework Selection ### 1.1 Stack Front-End confirmé | Aspect | Choix | Justification | |--------|-------|---------------| | **Markup** | HTML5 sémantique + PHP | SEO, accessibilité, dynamisme JSON | | **Styling** | Tailwind CSS (CLI build, purge automatique) | Utility-first, dev rapide, <50kb objectif | | **Scripting** | JavaScript ES6+ vanilla | Pas de framework, simplicité, performance | | **Bundler** | Tailwind CLI + PostCSS uniquement | Léger, pas de webpack nécessaire | | **Starter template** | Aucun - construction from scratch | Contrôle total, pas de dépendances inutiles | ### 1.2 Contraintes imposées par le PRD - Pas de framework JS (React, Vue, Angular exclus) - Build uniquement pour le CSS (Tailwind CLI) - Node.js en dev uniquement, pas en production - Hébergement sur serveur personnel (nginx + PHP-FPM) ### 1.3 Rationale **Choix de ne pas utiliser de framework JS :** - Le portfolio est principalement statique avec dynamisme côté PHP (JSON → templates) - Le JS vanilla suffit pour : menu hamburger, localStorage formulaire, validation, animations - Avantages : performance optimale, pas de bundle JS lourd, maintenance simplifiée - Trade-off accepté : moins de réactivité côté client, mais adapté au use-case **Choix de Tailwind CSS :** - Utility-first = développement rapide - Purge automatique = CSS minimal en production (<50kb objectif) - Bien documenté, compatible avec PHP/HTML pur --- ## 2. Frontend Tech Stack | Catégorie | Technologie | Version | Purpose | Rationale | |-----------|-------------|---------|---------|-----------| | **Markup** | HTML5 + PHP | 8.x | Structure pages + templates dynamiques | SEO, accessibilité, dynamisme JSON | | **Styling** | Tailwind CSS | 3.x | Framework CSS utility-first | Purge auto, dev rapide, <50kb objectif | | **Build Tool** | Tailwind CLI | 3.x | Compilation CSS | Léger, pas de webpack nécessaire | | **Scripting** | JavaScript ES6+ | Vanilla | Interactions client | Menu mobile, localStorage, validation, animations | | **Icons** | Heroicons | 2.x | Iconographie cohérente | Recommandé par spec UX, SVG inline | | **Fonts** | Inter + JetBrains Mono | Variable | Typographie | Lisibilité, style moderne/technique | | **Form Protection** | reCAPTCHA v3 | - | Anti-spam invisible | Zéro friction UX | | **Dev Server** | PHP built-in / nginx | 8.x | Développement local | Simple à configurer | ### 2.1 Dépendances npm (dev only) ```json { "devDependencies": { "tailwindcss": "^3.4.0", "postcss": "^8.4.0", "autoprefixer": "^10.4.0" } } ``` ### 2.2 Dépendances Composer (PHP) ```json { "require": { "php": ">=8.0", "vlucas/phpdotenv": "^5.6" } } ``` --- ## 3. Project Structure ``` /portfolio ├── index.php # Point d'entrée + router front controller ├── config.php # Charge .env et définit les constantes ├── composer.json # Dépendances PHP ├── composer.lock ├── vendor/ # Dépendances Composer (gitignore) │ ├── .env # Variables sensibles (gitignore) ├── .env.example # Template sans valeurs sensibles │ ├── api/ │ └── contact.php # Endpoint formulaire de contact │ ├── pages/ │ ├── home.php # Page d'accueil (hero + sections rapides) │ ├── projects.php # Liste projets vedettes + secondaires │ ├── project-single.php # Page projet individuelle (template) │ ├── skills.php # Compétences & outils │ ├── about.php # Me Découvrir │ ├── contact.php # Formulaire de contact │ └── 404.php # Page erreur 404 │ ├── templates/ │ ├── header.php # , meta tags, CSS link │ ├── footer.php # Scripts JS, copyright │ ├── navbar.php # Navigation sticky + CTA │ ├── project-card.php # Carte projet réutilisable │ ├── project-card-compact.php # Carte projet secondaire (liste) │ ├── testimonial.php # Bloc témoignage │ └── breadcrumb.php # Fil d'Ariane (pages projet) │ ├── includes/ │ ├── router.php # Logique de routage (<50 lignes) │ ├── functions.php # Helpers (getProjects, getTestimonials, etc.) │ └── contact-handler.php # Traitement formulaire + envoi email │ ├── data/ │ ├── projects.json # Données des projets (mini-CMS) │ └── testimonials.json # Témoignages clients/employeurs │ ├── assets/ │ ├── css/ │ │ ├── input.css # Source Tailwind (@tailwind directives) │ │ └── output.css # CSS compilé (généré) │ ├── js/ │ │ ├── main.js # Script principal (menu, animations) │ │ ├── contact-form.js # Validation + localStorage + AJAX │ │ └── state.js # AppState (localStorage) + UIState │ ├── img/ │ │ ├── logo.svg # Logo du portfolio │ │ ├── hero/ # Images hero (photo/illustration) │ │ └── projects/ # Thumbnails et screenshots projets │ └── fonts/ │ ├── inter-var.woff2 # Police principale (variable) │ └── jetbrains-mono-var.woff2 # Police code (variable) │ ├── logs/ # Logs d'erreurs (gitignore) │ └── .gitkeep │ ├── tailwind.config.js # Configuration Tailwind (couleurs, fonts) ├── postcss.config.js # Config PostCSS (autoprefixer) ├── package.json # Scripts npm (build, watch) ├── package-lock.json ├── nginx.conf.example # Template configuration nginx ├── .gitignore └── README.md ``` --- ## 4. Component Standards ### 4.1 Template de Composant PHP ```php $project]); ?> * * @param array $project - Données du projet depuis JSON */ // Valeurs par défaut et extraction $title = $project['title'] ?? 'Sans titre'; $slug = $project['slug'] ?? '#'; $thumbnail = $project['thumbnail'] ?? 'default-project.webp'; $technologies = $project['technologies'] ?? []; $maxTechs = 4; ?>
Aperçu du projet <?= htmlspecialchars($title) ?>

$maxTechs): ?> +
``` ### 4.2 Helper pour inclure les templates ```php // includes/functions.php /** * Inclut un template avec des données * @param string $name Nom du template (sans .php) * @param array $data Variables à passer au template */ function include_template(string $name, array $data = []): void { extract($data); include __DIR__ . "/../templates/{$name}.php"; } ``` ### 4.3 Conventions de Nommage | Élément | Convention | Exemple | |---------|------------|---------| | **Fichiers PHP pages** | kebab-case | `project-single.php` | | **Fichiers PHP templates** | kebab-case | `project-card.php` | | **Fonctions PHP** | camelCase | `getProjectBySlug()` | | **Variables PHP** | camelCase | `$projectData` | | **Fichiers JS** | kebab-case | `contact-form.js` | | **Fonctions JS** | camelCase | `validateForm()` | | **Classes JS** | PascalCase | `FormValidator` | | **Classes CSS custom** | kebab-case | `.project-card` | | **Classes Tailwind** | Utility classes | `bg-surface text-primary` | | **IDs HTML** | kebab-case | `id="contact-form"` | | **Data attributes** | kebab-case | `data-project-slug` | | **Images** | kebab-case + slug | `ecommerce-xyz-thumb.webp` | | **Constantes PHP** | SCREAMING_SNAKE | `RECAPTCHA_SITE_KEY` | ### 4.4 Classes CSS Personnalisées ```css /* assets/css/input.css */ @layer components { /* Boutons */ .btn { @apply inline-flex items-center justify-center px-6 py-3 font-medium rounded-lg transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-background; } .btn-primary { @apply btn bg-primary text-background hover:bg-primary-light focus:ring-primary; } .btn-secondary { @apply btn border-2 border-primary text-primary hover:bg-primary hover:text-background focus:ring-primary; } .btn-ghost { @apply btn text-primary hover:text-primary-light; } /* Badges */ .badge { @apply inline-block px-2 py-1 text-xs font-medium bg-surface-light text-text-secondary rounded; } .badge-muted { @apply bg-border text-text-muted; } /* Inputs */ .input { @apply w-full px-4 py-3 bg-surface border border-border rounded-lg text-text-primary placeholder-text-muted focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all duration-150; } .input-error { @apply border-error focus:border-error focus:ring-error/20; } /* Labels */ .label { @apply block text-sm font-medium text-text-secondary mb-2; } .label-required::after { @apply text-error ml-1; content: '*'; } } ``` --- ## 5. State Management ### 5.1 Vue d'ensemble | Couche | Type de données | Méthode de gestion | |--------|-----------------|-------------------| | **PHP (Serveur)** | Projets, témoignages | Fichiers JSON (lecture seule) | | **JS (Client)** | État UI temporaire | Variables JS en mémoire | | **JS (Client)** | Données persistantes | localStorage | ### 5.2 Structure des données JSON **`data/projects.json` :** ```json { "projects": [ { "id": 1, "title": "Site E-commerce XYZ", "slug": "ecommerce-xyz", "category": "vedette", "thumbnail": "ecommerce-xyz-thumb.webp", "url": "https://example.com", "technologies": ["PHP", "JavaScript", "Tailwind CSS", "MySQL"], "context": "Description du contexte et des besoins client...", "solution": "Explication technique des choix...", "teamwork": "Rôle dans l'équipe, organisation...", "duration": "3 mois", "screenshots": [ "ecommerce-xyz-screen-1.webp", "ecommerce-xyz-screen-2.webp" ] } ] } ``` **`data/testimonials.json` :** ```json { "testimonials": [ { "id": 1, "quote": "Excellent travail, livraison dans les délais...", "author_name": "Marie Dupont", "author_role": "Directrice Marketing", "author_company": "Entreprise XYZ", "author_photo": "marie-dupont.webp", "project_slug": "ecommerce-xyz", "date": "2025-06-15", "featured": true } ] } ``` ### 5.3 Fonctions PHP d'accès aux données ```php // includes/functions.php /** * Charge et parse un fichier JSON */ function loadJsonData(string $filename): array { $path = __DIR__ . "/../data/{$filename}"; if (!file_exists($path)) { return []; } $content = file_get_contents($path); $data = json_decode($content, true); if (json_last_error() !== JSON_ERROR_NONE) { error_log("JSON parse error in {$filename}: " . json_last_error_msg()); return []; } return $data; } /** * Récupère tous les projets */ function getProjects(): array { $data = loadJsonData('projects.json'); return $data['projects'] ?? []; } /** * Récupère les projets par catégorie */ function getProjectsByCategory(string $category): array { return array_filter(getProjects(), fn($p) => $p['category'] === $category); } /** * Récupère un projet par son slug */ function getProjectBySlug(string $slug): ?array { $projects = getProjects(); foreach ($projects as $project) { if ($project['slug'] === $slug) { return $project; } } return null; } /** * Récupère tous les témoignages */ function getTestimonials(): array { $data = loadJsonData('testimonials.json'); return $data['testimonials'] ?? []; } /** * Récupère les témoignages mis en avant */ function getFeaturedTestimonials(): array { return array_filter(getTestimonials(), fn($t) => $t['featured'] === true); } /** * Récupère le témoignage lié à un projet */ function getTestimonialByProject(string $projectSlug): ?array { $testimonials = getTestimonials(); foreach ($testimonials as $testimonial) { if (($testimonial['project_slug'] ?? '') === $projectSlug) { return $testimonial; } } return null; } ``` ### 5.4 État côté client (JavaScript) ```javascript // assets/js/state.js /** * Gestionnaire d'état simple pour le localStorage */ const AppState = { STORAGE_KEY: 'portfolio_contact_form', saveFormData(data) { try { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data)); } catch (e) { console.warn('localStorage non disponible'); } }, getFormData() { try { const data = localStorage.getItem(this.STORAGE_KEY); return data ? JSON.parse(data) : null; } catch (e) { return null; } }, clearFormData() { try { localStorage.removeItem(this.STORAGE_KEY); } catch (e) { // Silencieux } }, isStorageAvailable() { try { const test = '__storage_test__'; localStorage.setItem(test, test); localStorage.removeItem(test); return true; } catch (e) { return false; } } }; /** * État UI en mémoire (non persisté) */ const UIState = { mobileMenuOpen: false, toggleMobileMenu() { this.mobileMenuOpen = !this.mobileMenuOpen; return this.mobileMenuOpen; } }; ``` --- ## 6. API Integration ### 6.1 Vue d'ensemble des intégrations | Intégration | Type | Méthode | Endpoint/Service | |-------------|------|---------|------------------| | Formulaire contact | Interne | POST (AJAX) | `/api/contact.php` | | reCAPTCHA v3 | Externe | JS + POST | Google API | | Email | Externe | SMTP/mail() | Serveur mail | ### 6.2 Endpoint Formulaire de Contact ```php false, 'error' => 'Méthode non autorisée']); exit; } $input = json_decode(file_get_contents('php://input'), true); if (!$input) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Données invalides']); exit; } try { // 1. Valider le token CSRF if (!validateCsrfToken($input['csrf_token'] ?? '')) { throw new Exception('Token de sécurité invalide'); } // 2. Valider reCAPTCHA $recaptchaScore = verifyRecaptcha($input['recaptcha_token'] ?? ''); if ($recaptchaScore < 0.5) { throw new Exception('Vérification anti-spam échouée'); } // 3. Valider et nettoyer les données $data = validateContactData($input); // 4. Envoyer l'email $sent = sendContactEmail($data); if (!$sent) { throw new Exception('Erreur lors de l\'envoi du message'); } echo json_encode([ 'success' => true, 'message' => 'Votre message a bien été envoyé !' ]); } catch (Exception $e) { http_response_code(400); echo json_encode([ 'success' => false, 'error' => $e->getMessage() ]); } ``` ### 6.3 Fonctions de validation ```php // includes/functions.php (suite) /** * Génère un token CSRF */ function generateCsrfToken(): string { if (session_status() === PHP_SESSION_NONE) { session_start(); } if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } return $_SESSION['csrf_token']; } /** * Valide un token CSRF */ function validateCsrfToken(string $token): bool { if (session_status() === PHP_SESSION_NONE) { session_start(); } return hash_equals($_SESSION['csrf_token'] ?? '', $token); } /** * Vérifie le token reCAPTCHA v3 auprès de Google */ function verifyRecaptcha(string $token): float { if (empty($token)) { return 0.0; } $response = file_get_contents('https://www.google.com/recaptcha/api/siteverify', false, stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => 'Content-Type: application/x-www-form-urlencoded', 'content' => http_build_query([ 'secret' => RECAPTCHA_SECRET_KEY, 'response' => $token, 'remoteip' => $_SERVER['REMOTE_ADDR'] ?? '' ]) ] ])); if ($response === false) { error_log('reCAPTCHA verification failed: unable to connect'); return 0.0; } $result = json_decode($response, true); if (!($result['success'] ?? false)) { error_log('reCAPTCHA verification failed: ' . json_encode($result['error-codes'] ?? [])); return 0.0; } return (float) ($result['score'] ?? 0.0); } /** * Valide et nettoie les données du formulaire */ function validateContactData(array $input): array { $errors = []; $required = ['nom', 'prenom', 'email', 'categorie', 'objet', 'message']; foreach ($required as $field) { if (empty(trim($input[$field] ?? ''))) { $errors[] = "Le champ {$field} est requis"; } } if (!filter_var($input['email'] ?? '', FILTER_VALIDATE_EMAIL)) { $errors[] = "L'adresse email n'est pas valide"; } $validCategories = ['projet', 'poste', 'autre']; if (!in_array($input['categorie'] ?? '', $validCategories)) { $errors[] = "Catégorie invalide"; } if (strlen($input['message'] ?? '') > 5000) { $errors[] = "Le message est trop long (max 5000 caractères)"; } if (!empty($errors)) { throw new Exception(implode(', ', $errors)); } return [ 'nom' => htmlspecialchars(trim($input['nom']), ENT_QUOTES, 'UTF-8'), 'prenom' => htmlspecialchars(trim($input['prenom']), ENT_QUOTES, 'UTF-8'), 'email' => filter_var(trim($input['email']), FILTER_SANITIZE_EMAIL), 'entreprise' => htmlspecialchars(trim($input['entreprise'] ?? ''), ENT_QUOTES, 'UTF-8'), 'categorie' => $input['categorie'], 'objet' => htmlspecialchars(trim($input['objet']), ENT_QUOTES, 'UTF-8'), 'message' => htmlspecialchars(trim($input['message']), ENT_QUOTES, 'UTF-8'), ]; } /** * Envoie l'email de contact */ function sendContactEmail(array $data): bool { $categorieLabels = [ 'projet' => 'Projet freelance', 'poste' => 'Proposition de poste', 'autre' => 'Autre' ]; $subject = "[Portfolio] {$categorieLabels[$data['categorie']]} - {$data['objet']}"; $body = " Nouveau message depuis le portfolio --------------------------------- Nom : {$data['prenom']} {$data['nom']} Email : {$data['email']} Entreprise : {$data['entreprise']} Catégorie : {$categorieLabels[$data['categorie']]} --------------------------------- Objet : {$data['objet']} Message : {$data['message']} --------------------------------- Envoyé le : " . date('d/m/Y à H:i') . " IP : " . ($_SERVER['REMOTE_ADDR'] ?? 'inconnue'); $headers = [ 'From' => CONTACT_EMAIL, 'Reply-To' => $data['email'], 'Content-Type' => 'text/plain; charset=UTF-8', 'X-Mailer' => 'PHP/' . phpversion() ]; return mail( CONTACT_EMAIL, $subject, $body, $headers ); } ``` ### 6.4 Client JavaScript (AJAX) ```javascript // assets/js/contact-form.js const ContactAPI = { endpoint: '/api/contact.php', async submit(formData, csrfToken, recaptchaToken) { const payload = { ...formData, csrf_token: csrfToken, recaptcha_token: recaptchaToken }; const response = await fetch(this.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(payload) }); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || 'Une erreur est survenue'); } return data; } }; const RecaptchaService = { siteKey: null, init(siteKey) { this.siteKey = siteKey; }, async getToken(action = 'contact') { return new Promise((resolve, reject) => { if (!window.grecaptcha) { console.warn('reCAPTCHA non disponible'); resolve(''); return; } grecaptcha.ready(() => { grecaptcha.execute(this.siteKey, { action }) .then(resolve) .catch(reject); }); }); } }; ``` --- ## 7. Routing ### 7.1 Structure des URLs | URL | Page | Description | |-----|------|-------------| | `/` | `pages/home.php` | Page d'accueil | | `/projets` | `pages/projects.php` | Liste des projets | | `/projet/{slug}` | `pages/project-single.php` | Page projet individuelle | | `/competences` | `pages/skills.php` | Compétences & outils | | `/a-propos` | `pages/about.php` | Me Découvrir | | `/contact` | `pages/contact.php` | Formulaire de contact | | `/api/contact` | `api/contact.php` | Endpoint formulaire (POST) | | `/*` | `pages/404.php` | Page non trouvée | ### 7.2 Router PHP (< 50 lignes) ```php basePath = $basePath; } public function add(string $pattern, string $handler): self { $regex = preg_replace('/\{(\w+)\}/', '([^/]+)', $pattern); $regex = '#^' . $regex . '$#'; $this->routes[$regex] = $handler; return $this; } public function resolve(string $uri): array { $uri = parse_url($uri, PHP_URL_PATH); $uri = rtrim($uri, '/') ?: '/'; foreach ($this->routes as $regex => $handler) { if (preg_match($regex, $uri, $matches)) { array_shift($matches); return [$handler, $matches]; } } return ['pages/404.php', []]; } public function dispatch(): void { $uri = $_SERVER['REQUEST_URI'] ?? '/'; [$handler, $params] = $this->resolve($uri); $GLOBALS['routeParams'] = $params; require __DIR__ . '/../' . $handler; } } ``` ### 7.3 Point d'entrée (index.php) ```php add('/', 'pages/home.php') ->add('/projets', 'pages/projects.php') ->add('/projet/{slug}', 'pages/project-single.php') ->add('/competences', 'pages/skills.php') ->add('/a-propos', 'pages/about.php') ->add('/contact', 'pages/contact.php'); $router->dispatch(); ``` ### 7.4 Helpers URL ```php // includes/functions.php (suite) function projectUrl(string $slug): string { return '/projet/' . urlencode($slug); } function absoluteUrl(string $path): string { $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; return "{$protocol}://{$host}{$path}"; } function isCurrentUrl(string $path): bool { $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); $currentPath = rtrim($currentPath, '/') ?: '/'; $path = rtrim($path, '/') ?: '/'; return $currentPath === $path; } ``` --- ## 8. Styling Guidelines ### 8.1 Configuration Tailwind ```javascript // tailwind.config.js /** @type {import('tailwindcss').Config} */ module.exports = { content: [ './*.php', './pages/**/*.php', './templates/**/*.php', './assets/js/**/*.js' ], theme: { extend: { colors: { primary: { DEFAULT: '#FA784F', light: '#FB9570', dark: '#E5623A', }, background: '#17171F', surface: { DEFAULT: '#1E1E28', light: '#2A2A36', }, border: '#3A3A48', text: { primary: '#F5F5F7', secondary: '#A1A1AA', muted: '#71717A', }, success: '#34D399', warning: '#FBBF24', error: '#F87171', info: '#60A5FA', }, fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'], mono: ['JetBrains Mono', 'monospace'], }, fontSize: { 'display': ['2.5rem', { lineHeight: '1.2', fontWeight: '700' }], 'heading': ['2rem', { lineHeight: '1.3', fontWeight: '600' }], 'subheading': ['1.5rem', { lineHeight: '1.4', fontWeight: '600' }], 'body': ['1rem', { lineHeight: '1.6', fontWeight: '400' }], 'small': ['0.875rem', { lineHeight: '1.5', fontWeight: '400' }], }, maxWidth: { 'content': '1280px', }, boxShadow: { 'card': '0 4px 20px rgba(0, 0, 0, 0.25)', 'card-hover': '0 10px 40px rgba(0, 0, 0, 0.3)', 'input-focus': '0 0 0 3px rgba(250, 120, 79, 0.2)', }, }, }, plugins: [], } ``` ### 8.2 Fichier CSS Principal ```css /* assets/css/input.css */ @tailwind base; @tailwind components; @tailwind utilities; @layer base { html { @apply scroll-smooth; } body { @apply bg-background text-text-primary font-sans antialiased; } h1 { @apply text-display text-text-primary; } h2 { @apply text-heading text-text-primary; } h3 { @apply text-subheading text-text-primary; } p { @apply text-body text-text-secondary; } a { @apply text-primary hover:text-primary-light transition-colors duration-150; } :focus-visible { @apply outline-none ring-2 ring-primary ring-offset-2 ring-offset-background; } ::selection { @apply bg-primary/30 text-text-primary; } } @layer components { .container-content { @apply max-w-content mx-auto px-4 sm:px-6 lg:px-8; } .btn { @apply inline-flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-background disabled:opacity-50 disabled:cursor-not-allowed; } .btn-primary { @apply btn bg-primary text-background hover:bg-primary-light active:bg-primary-dark focus:ring-primary; } .btn-secondary { @apply btn border-2 border-primary text-primary bg-transparent hover:bg-primary hover:text-background focus:ring-primary; } .btn-ghost { @apply btn text-primary bg-transparent hover:text-primary-light hover:bg-surface-light focus:ring-primary; } .badge { @apply inline-flex items-center px-2.5 py-1 text-xs font-medium rounded bg-surface-light text-text-secondary; } .badge-primary { @apply bg-primary/20 text-primary; } .badge-muted { @apply bg-border text-text-muted; } .card { @apply bg-surface rounded-lg overflow-hidden border border-border/50 transition-all duration-200; } .card-interactive { @apply card cursor-pointer hover:-translate-y-1 hover:shadow-card-hover hover:border-border; } .card-body { @apply p-4 sm:p-6; } .input { @apply w-full px-4 py-3 bg-surface border border-border rounded-lg text-text-primary placeholder-text-muted transition-all duration-150 focus:outline-none focus:border-primary focus:shadow-input-focus; } .input-error { @apply border-error focus:border-error focus:shadow-[0_0_0_3px_rgba(248,113,113,0.2)]; } .textarea { @apply input min-h-[150px] resize-y; } .label { @apply block text-sm font-medium text-text-secondary mb-2; } .label-required::after { content: '*'; @apply text-error ml-1; } .error-message { @apply text-sm text-error mt-1.5 flex items-center gap-1; } .section { @apply py-16 sm:py-24; } .section-header { @apply text-center mb-12; } .section-title { @apply text-heading mb-4; } .section-subtitle { @apply text-body text-text-secondary max-w-2xl mx-auto; } .testimonial { @apply bg-surface-light rounded-lg p-6 border-l-4 border-primary; } .breadcrumb { @apply flex items-center gap-2 text-sm text-text-muted; } .breadcrumb-link { @apply text-text-secondary hover:text-primary transition-colors; } .breadcrumb-current { @apply text-text-primary; } } @layer utilities { .animate-fade-in { animation: fadeIn 0.6s ease-out forwards; } .animate-fade-in-up { animation: fadeInUp 0.6s ease-out forwards; } .animation-delay-100 { animation-delay: 100ms; } .animation-delay-200 { animation-delay: 200ms; } .animation-delay-300 { animation-delay: 300ms; } .aspect-thumbnail { aspect-ratio: 16 / 9; } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes fadeInUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } ``` ### 8.3 Scripts npm ```json { "scripts": { "dev": "tailwindcss -i ./assets/css/input.css -o ./assets/css/output.css --watch", "build": "tailwindcss -i ./assets/css/input.css -o ./assets/css/output.css --minify" } } ``` --- ## 9. Testing Requirements ### 9.1 Vue d'ensemble des tests | Type | Outil/Méthode | Objectif | Fréquence | |------|---------------|----------|-----------| | **Validation HTML** | W3C Validator | Structure sémantique valide | Chaque page | | **Validation CSS** | W3C CSS Validator | CSS sans erreurs | Après build | | **Tests responsive** | DevTools + appareils | Affichage multi-écrans | Chaque feature | | **Tests performance** | Lighthouse | Score > 90 | Avant déploiement | | **Tests formulaire** | Manuel | Envoi/réception email | Après modification | | **Tests sécurité** | Manuel + outils | XSS, CSRF, injection | Avant déploiement | | **Tests accessibilité** | Lighthouse + axe | WCAG 2.1 AA | Chaque page | ### 9.2 Objectifs Lighthouse (PRD NFR9-12) | Métrique | Objectif | Maximum | |----------|----------|---------| | Performance | > 90 | > 80 | | Accessibility | > 90 | > 85 | | Best Practices | > 90 | > 85 | | SEO | > 90 | > 85 | ### 9.3 Core Web Vitals | Métrique | Objectif | Maximum | |----------|----------|---------| | FCP (First Contentful Paint) | < 1.5s | < 2.5s | | LCP (Largest Contentful Paint) | < 2.5s | < 4s | | CLS (Cumulative Layout Shift) | < 0.1 | < 0.25 | | TTI (Time to Interactive) | < 3s | < 5s | ### 9.4 Matrice de compatibilité navigateurs | Navigateur | Version | Support | |------------|---------|---------| | Chrome | 90+ | Complet | | Firefox | 90+ | Complet | | Safari | 14+ | Complet | | Edge | 90+ | Complet | | Samsung Internet | 15+ | Complet | | IE 11 | - | Non supporté | --- ## 10. Environment Configuration ### 10.1 Variables d'environnement (.env.example) ```env # Application APP_ENV=development APP_DEBUG=true APP_URL=http://localhost:8000 # reCAPTCHA v3 RECAPTCHA_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI RECAPTCHA_SECRET_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe # Contact Email CONTACT_EMAIL=contact@example.com # SMTP (optionnel) SMTP_ENABLED=false SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_USERNAME= SMTP_PASSWORD= SMTP_ENCRYPTION=tls # Sécurité APP_SECRET=your_random_secret_key_here ``` ### 10.2 Configuration PHP (config.php) ```php load(); $dotenv->required([ 'APP_ENV', 'APP_DEBUG', 'RECAPTCHA_SITE_KEY', 'RECAPTCHA_SECRET_KEY', 'CONTACT_EMAIL', ])->notEmpty(); // Constantes globales define('APP_ENV', $_ENV['APP_ENV']); define('APP_DEBUG', filter_var($_ENV['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN)); define('APP_URL', $_ENV['APP_URL'] ?? 'http://localhost:8000'); define('APP_SECRET', $_ENV['APP_SECRET'] ?? 'insecure-default-key'); define('RECAPTCHA_SITE_KEY', $_ENV['RECAPTCHA_SITE_KEY']); define('RECAPTCHA_SECRET_KEY', $_ENV['RECAPTCHA_SECRET_KEY']); define('RECAPTCHA_THRESHOLD', 0.5); define('CONTACT_EMAIL', $_ENV['CONTACT_EMAIL']); define('SMTP_ENABLED', filter_var($_ENV['SMTP_ENABLED'] ?? false, FILTER_VALIDATE_BOOLEAN)); define('SMTP_HOST', $_ENV['SMTP_HOST'] ?? ''); define('SMTP_PORT', (int) ($_ENV['SMTP_PORT'] ?? 587)); define('SMTP_USERNAME', $_ENV['SMTP_USERNAME'] ?? ''); define('SMTP_PASSWORD', $_ENV['SMTP_PASSWORD'] ?? ''); define('SMTP_ENCRYPTION', $_ENV['SMTP_ENCRYPTION'] ?? 'tls'); define('ROOT_PATH', __DIR__); define('PAGES_PATH', __DIR__ . '/pages'); define('TEMPLATES_PATH', __DIR__ . '/templates'); define('DATA_PATH', __DIR__ . '/data'); define('ASSETS_PATH', __DIR__ . '/assets'); // Configuration PHP selon environnement if (APP_DEBUG) { error_reporting(E_ALL); ini_set('display_errors', '1'); } else { error_reporting(E_ALL); ini_set('display_errors', '0'); ini_set('log_errors', '1'); ini_set('error_log', ROOT_PATH . '/logs/php-errors.log'); } date_default_timezone_set('Europe/Paris'); mb_internal_encoding('UTF-8'); ini_set('session.cookie_httponly', '1'); ini_set('session.cookie_secure', APP_ENV === 'production' ? '1' : '0'); ini_set('session.cookie_samesite', 'Lax'); ``` ### 10.3 Configuration Nginx (production) ```nginx server { listen 80; server_name monportfolio.fr www.monportfolio.fr; return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; server_name monportfolio.fr www.monportfolio.fr; ssl_certificate /etc/letsencrypt/live/monportfolio.fr/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/monportfolio.fr/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; root /var/www/portfolio; index index.php; charset utf-8; # Headers de sécurité add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; # Bloquer fichiers sensibles location ~ /\.(env|git|htaccess) { deny all; return 404; } location ^~ /vendor/ { deny all; return 404; } location ^~ /node_modules/ { deny all; return 404; } location ^~ /logs/ { deny all; return 404; } location ^~ /data/ { deny all; return 404; } # Assets statiques location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; gzip_static on; } # API location ^~ /api/ { try_files $uri /api/index.php?$query_string; location ~ \.php$ { fastcgi_pass unix:/var/run/php/php8.2-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } } # Router PHP location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass unix:/var/run/php/php8.2-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } # Compression gzip on; gzip_vary on; gzip_min_length 1024; gzip_types text/plain text/css text/javascript application/javascript application/json image/svg+xml; } ``` ### 10.4 Fichier .gitignore ```gitignore # Environnement .env !.env.example # Dépendances /vendor/ /node_modules/ # Build /assets/css/output.css # Logs /logs/*.log # IDE & OS .idea/ .vscode/ .DS_Store Thumbs.db ``` --- ## 11. Frontend Developer Standards ### 11.1 Règles critiques (à ne jamais violer) #### Sécurité 1. **TOUJOURS échapper les sorties PHP** ```php ``` 2. **JAMAIS faire confiance aux entrées utilisateur** - Valider côté serveur 3. **TOUJOURS valider le token CSRF sur les formulaires POST** 4. **JAMAIS exposer les erreurs PHP en production** #### Performance 5. **TOUJOURS utiliser lazy loading sur les images below-the-fold** ```html ``` 6. **TOUJOURS spécifier width/height sur les images** (évite CLS) 7. **TOUJOURS utiliser le format WebP avec fallback** 8. **JAMAIS charger de JS bloquant dans le ``** #### Accessibilité 9. **TOUJOURS associer les labels aux inputs** ```html ``` 10. **TOUJOURS fournir un texte alt pour les images informatives** 11. **JAMAIS supprimer le focus outline sans alternative** 12. **TOUJOURS respecter la hiérarchie des titres** (H1 → H2 → H3) 13. **UN SEUL H1 par page** 14. **TOUJOURS utiliser les landmarks HTML5** (`
`, `