Files
Portfolio-Codex/docs/ui-architecture.md

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
Email 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é

  1. TOUJOURS échapper les sorties PHP

    <?= htmlspecialchars($var, ENT_QUOTES, 'UTF-8') ?>
    
  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

  1. TOUJOURS utiliser lazy loading sur les images below-the-fold

    <img loading="lazy" ...>
    
  2. TOUJOURS spécifier width/height sur les images (évite CLS)

  3. TOUJOURS utiliser le format WebP avec fallback

  4. JAMAIS charger de JS bloquant dans le <head>

Accessibilité

  1. TOUJOURS associer les labels aux inputs

    <label for="email">Email</label><input id="email">
    
  2. TOUJOURS fournir un texte alt pour les images informatives

  3. JAMAIS supprimer le focus outline sans alternative

  4. TOUJOURS respecter la hiérarchie des titres (H1 → H2 → H3)

  5. UN SEUL H1 par page

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