40 KiB
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)
{
"devDependencies": {
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
}
}
2.2 Dépendances Composer (PHP)
{
"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 # <head>, 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
/**
* Composant : Project Card
* Usage : <?php include_template('project-card', ['project' => $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;
?>
<article class="project-card group bg-surface rounded-lg overflow-hidden transition-all duration-200 hover:-translate-y-1 hover:shadow-xl">
<a href="/projet/<?= htmlspecialchars($slug) ?>" class="block">
<picture>
<source srcset="/assets/img/projects/<?= htmlspecialchars($thumbnail) ?>" type="image/webp">
<img
src="/assets/img/projects/<?= htmlspecialchars(str_replace('.webp', '.jpg', $thumbnail)) ?>"
alt="Aperçu du projet <?= htmlspecialchars($title) ?>"
width="400"
height="225"
loading="lazy"
class="w-full aspect-video object-cover"
>
</picture>
<div class="p-4">
<h3 class="text-lg font-semibold text-text-primary group-hover:text-primary transition-colors">
<?= htmlspecialchars($title) ?>
</h3>
<div class="flex flex-wrap gap-2 mt-3">
<?php foreach (array_slice($technologies, 0, $maxTechs) as $tech): ?>
<span class="badge"><?= htmlspecialchars($tech) ?></span>
<?php endforeach; ?>
<?php if (count($technologies) > $maxTechs): ?>
<span class="badge badge-muted">+<?= count($technologies) - $maxTechs ?></span>
<?php endif; ?>
</div>
</div>
</a>
</article>
4.2 Helper pour inclure les templates
// 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
/* 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 :
{
"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 :
{
"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
// 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)
// 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 |
| Externe | SMTP/mail() | Serveur mail |
6.2 Endpoint Formulaire de Contact
<?php
// api/contact.php
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => 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
// 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)
// 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
// includes/router.php
class Router
{
private array $routes = [];
private string $basePath;
public function __construct(string $basePath = '')
{
$this->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
// index.php - Front Controller
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/router.php';
session_start();
$router = new Router();
$router
->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
// 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
// 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
/* 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
{
"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)
# 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
require_once __DIR__ . '/vendor/autoload.php';
use Dotenv\Dotenv;
$dotenv = Dotenv::createImmutable(__DIR__);
$dotenv->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)
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
# 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é
-
TOUJOURS échapper les sorties PHP
<?= htmlspecialchars($var, ENT_QUOTES, 'UTF-8') ?> -
JAMAIS faire confiance aux entrées utilisateur - Valider côté serveur
-
TOUJOURS valider le token CSRF sur les formulaires POST
-
JAMAIS exposer les erreurs PHP en production
Performance
-
TOUJOURS utiliser lazy loading sur les images below-the-fold
<img loading="lazy" ...> -
TOUJOURS spécifier width/height sur les images (évite CLS)
-
TOUJOURS utiliser le format WebP avec fallback
-
JAMAIS charger de JS bloquant dans le
<head>
Accessibilité
-
TOUJOURS associer les labels aux inputs
<label for="email">Email</label><input id="email"> -
TOUJOURS fournir un texte alt pour les images informatives
-
JAMAIS supprimer le focus outline sans alternative
-
TOUJOURS respecter la hiérarchie des titres (H1 → H2 → H3)
-
UN SEUL H1 par page
-
TOUJOURS utiliser les landmarks HTML5 (
<header>,<nav>,<main>,<footer>)
11.2 Quick Reference
Commandes fréquentes
# Développement
php -S localhost:8000 # Serveur PHP local
npm run dev # Watch Tailwind CSS
# Build
npm run build # Compile + minify CSS
# Composer
composer install # Installer dépendances
Patterns d'import PHP
// Dans index.php
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/router.php';
// Dans les pages
include_template('header', ['title' => $pageTitle]);
include_template('navbar');
include_template('footer');
// Récupération de données
$projects = getProjects();
$project = getProjectBySlug($slug);
Patterns Tailwind courants
<!-- Container -->
<div class="container-content">
<!-- Grille responsive -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Carte interactive -->
<article class="card-interactive">
<div class="card-body">
<!-- Boutons -->
<a href="/contact" class="btn-primary">Me contacter</a>
<a href="/projets" class="btn-secondary">Voir les projets</a>
<!-- Badge -->
<span class="badge">PHP</span>
Checklist avant commit
- Code testé localement
- Pas d'erreurs dans la console
- CSS compilé (
npm run build) - Responsive vérifié
- Données sensibles non commitées
12. Ressources
| Ressource | URL |
|---|---|
| Tailwind CSS Docs | https://tailwindcss.com/docs |
| Heroicons | https://heroicons.com |
| W3C Validator | https://validator.w3.org |
| Can I Use | https://caniuse.com |
| PHP Manual | https://www.php.net/manual/fr |
Document généré par Winston (Architect) - Méthode BMAD