diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..babae1fcdd669f068b78c8a2cdbc46cd2eadc781 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,44 @@ +# Speed up `docker build` by skipping files the runtime doesn't need. + +# Local environments / caches +.venv/ +venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ + +# OS / IDE +.DS_Store +Thumbs.db +.idea/ +.vscode/ + +# Large contributor notebooks (documentation only — not needed at runtime) +malek/*.ipynb +rayen/*.ipynb +youssef/*.ipynb +yassmine/*.ipynb +*.ipynb_checkpoints/ + +# Trained weights (pulled at runtime from HF Dataset via scripts/download_models.py) +backend/data/io1_resnet50.pth +backend/data/io1_resnet50_deepfake.pth +islem/*.pth + +# Large generic weights (downloaded fresh in the Dockerfile pre-warm step) +yolov8m.pt + +# Local secrets — never bake into an image +.env +.env.local +backend/data/openai_key.txt +*.key + +# Project documentation files unrelated to runtime +ios images/ +demo/ +*.pdf +*.docx +*.xlsx +!backend/data/IO6_Base_Reference_V3_FULL.xlsx diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..ef2ce2ab25f348d9f5d4cbba634fb2488e219eff --- /dev/null +++ b/.env.example @@ -0,0 +1,59 @@ +# ─── Verify — Configuration template ─────────────────────────────────────── +# Copy this file to `.env` and fill in only the variables you need to override. +# `.env` is gitignored — never commit your real keys. + +# ─── Module loading ───────────────────────────────────────────────────────── +# Comma-separated list of modules to load at startup. Empty = all modules with +# status="active" (i.e. all 6 by default). +# LOAD_MODULES=io3,io1 + +# ─── Compute device ───────────────────────────────────────────────────────── +# FORCE_DEVICE=cpu # set to "cpu" to disable GPU detection +# FORCE_DEVICE=cuda + +# ─── OpenAI API (optional — used ONLY by the chatbot module, NOT by the 6 verdicts) ─── +# Get your key at https://platform.openai.com/api-keys +# OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# ─── io1 — Fake Media Detection ───────────────────────────────────────────── +# IO1_USE_OPENAI=off # set "on" to delegate the verdict to GPT-4o (default: local) +# IO1_DEEPFAKE_THRESHOLD=0.60 # p_fake threshold for the ResNet50 ensemble +# IO1_USE_IMAGE_DEEPFAKE=on # load Islem's Model X (resnet50_deepfake.pth) +# IO1_USE_AI_DETECTOR=on # enable the 2-detector AI consensus (Organika + umm-maybe) +# IO1_AI_TIER1_PRIMARY=0.95 # tuning of the consensus thresholds (see ai_detector.py) +# IO1_AI_TIER1_SECONDARY=0.20 +# IO1_AI_TIER2_PRIMARY=0.90 +# IO1_AI_TIER2_SECONDARY=0.40 + +# ─── io2 — Visual Manipulation / Persuasion ───────────────────────────────── +# IO2_USE_OPENAI=off +# IO2_CLICKBAIT_MODEL=valurank/distilroberta-clickbait +# IO2_ENABLE_TRANSLATION=auto # set "off" to skip the FR→EN MarianMT translator + +# ─── io3 — Image-Caption Coherence ────────────────────────────────────────── +# CLIP_FINETUNED_PATH=clip_finetuned_coherence.pth # optional fine-tuned CLIP checkpoint +# SAM_CHECKPOINT=sam_vit_b_01ec64.pth # optional Segment-Anything weights +# YOLO_WEIGHTS=yolov8m.pt +# WHISPER_MODEL=small +# ENABLE_SAM=auto + +# ─── io4 — Image Tampering Detection ──────────────────────────────────────── +# IO4_USE_OPENAI=off +# IO4_ELA_QUALITY=90 # JPEG re-save quality used by ELA +# IO4_THRESHOLD=0.50 # legacy CNN threshold (ignored when forensics path is used) + +# ─── io5 — Caption Fidelity ───────────────────────────────────────────────── +# IO5_CLIP_MODEL=ViT-B-32 +# IO5_FAITHFUL_THRESHOLD=0.60 +# IO5_MISLEADING_THRESHOLD=0.40 + +# ─── io6 — Cosmetic Ads Fact-Check ────────────────────────────────────────── +# IO6_USE_OPENAI=off +# IO6_WHISPER_MODEL=tiny # tiny|base|small|medium — accuracy/speed trade-off +# IO6_YOLO_WEIGHTS=yolov8n.pt +# IO6_ENABLE_MINILM=auto +# IO6_KB_PATH=backend/data/IO6_Base_Reference_V3_FULL.xlsx + +# ─── Narrative layer (post-processing) ────────────────────────────────────── +# IO_XAI_NARRATIVE=off # set "off" to disable the AI-written explanation (saves OpenAI calls) +# IO_XAI_MODEL=gpt-4o-mini diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..87259d9422caf9f75c86198a22dd4be3d773d4d9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.pdf filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.xlsx filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..33666eb6f7db24156cfe0033de2627cf10b3590a --- /dev/null +++ b/.gitignore @@ -0,0 +1,73 @@ +# ─── Secrets (NEVER commit) ────────────────────────────────────────────── +.env +.env.local +backend/data/openai_key.txt +*.key +*.pem +secrets/ +credentials.json +google-services.json + +# ─── Python ─────────────────────────────────────────────────────────────── +.venv/ +venv/ +env/ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# ─── Trained model weights (downloaded via scripts/download_models.py) ──── +# GitHub blocks files > 100 MB; combined model size is ~250 MB+ +*.pth +*.pt +*.onnx +*.h5 +*.keras +*.bin +*.safetensors + +# Exception: small generic YOLO weights that are useful to ship if small enough +!yolov8n.pt + +# ─── HuggingFace / cache ────────────────────────────────────────────────── +.cache/ +~/.cache/huggingface/ + +# ─── OS / IDE ───────────────────────────────────────────────────────────── +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# ─── Jupyter ────────────────────────────────────────────────────────────── +.ipynb_checkpoints/ +*.ipynb_meta + +# ─── Build / dist ───────────────────────────────────────────────────────── +dist/ +build/ +node_modules/ + +# ─── Temporary / logs ───────────────────────────────────────────────────── +*.log +*.tmp +tmp/ +/temp/ +.tox/ + +# ─── Personal scratch files ─────────────────────────────────────────────── +test_local.py +notes.md + +# Contributor notebooks (kept locally — too large for HF Space git, ~40 MB combined) +malek/*.ipynb +rayen/*.ipynb +youssef/*.ipynb +yassmine/*.ipynb diff --git a/AI_Investigate_Report_COMPLETE.pdf b/AI_Investigate_Report_COMPLETE.pdf new file mode 100755 index 0000000000000000000000000000000000000000..616eba73204bad15a7eec86a4fc7e1b37c673d4c --- /dev/null +++ b/AI_Investigate_Report_COMPLETE.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e5fbe755ecdb47fd2d62dbc779e4013618ee2848eb8957507a6da0dde1587f7f +size 1920340 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000000000000000000000000000000000000..038bb05db56d8a406ca0dd3472b5b6bc0d88448c --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,238 @@ +# Verify — Free deployment guide (HuggingFace Spaces) + +This guide deploys the **full Verify platform** (6 detection modules + chatbot + static frontend) +**on a single free HuggingFace Space**. The jury gets one public URL — e.g. `https://yassminenouisser-verify.hf.space` — that they can hit with their browser to test everything. + +> **Total cost: 0 €.** Total wall-clock time the first time: ~30 min (mostly waiting for the Space to build). +> Cold-start delay for the first request after 48h of inactivity: ~1 min (the persistent storage keeps models warm). + +--- + +## Architecture + +``` + ┌──────────────────────────────────────────────────┐ + │ HuggingFace Space (Docker SDK, CPU Basic 16GB) │ + │ │ + │ ┌────────────────────────────────────────┐ │ + │ │ FastAPI (uvicorn :7860) │ │ + │ │ ├─ / static frontend │ │ + │ │ ├─ /api/io[1-6]/... detection ML │ │ + │ │ ├─ /api/chat/... chatbot │ │ + │ │ └─ /health, /docs │ │ + │ └────────────────────────────────────────┘ │ + │ │ + │ Secrets (encrypted): │ + │ OPENAI_API_KEY │ + │ IO1_RESNET50_URL ← from HF Dataset │ + │ IO1_RESNET50_DEEPFAKE_URL ← from HF Dataset │ + └──────────────────────────────────────────────────┘ + ▲ + │ + ┌─────────────┴────────────────┐ + │ HuggingFace Dataset (free) │ + │ Verify-weights (private) │ + │ ├─ best_ResNet50.pth │ + │ └─ resnet50_deepfake.pth │ + └──────────────────────────────┘ +``` + +The Space hosts everything in one container. The two heavy `.pth` weights are stored in a +companion **HuggingFace Dataset** (also free, unlimited) so the Space repo stays under the +git-LFS-free 10 MB-per-file limit. + +--- + +## Step 1 — Create a HuggingFace account + +Go to **https://huggingface.co/join**, create your free account, verify your email. +Note your username (e.g. `yassminenouisser`). You'll use it everywhere below. + +--- + +## Step 2 — Upload Islem's `.pth` weights to a HF Dataset + +The two ResNet50 checkpoints (94 MB each) cannot live in the Space's git repo. We host them in +a HuggingFace Dataset. + +1. Go to **https://huggingface.co/new-dataset** +2. Name: `verify-weights` +3. Visibility: **Private** (the weights are Islem's research artifacts) +4. License: `other` +5. Click **Create dataset** + +Then upload the files (web UI is the easiest): + +1. Open your new dataset → **Files** tab → **Add file** → **Upload files** +2. Drag-drop `backend/data/io1_resnet50.pth` and `backend/data/io1_resnet50_deepfake.pth` +3. Commit + +The files are now at: +- `https://huggingface.co/datasets//verify-weights/resolve/main/io1_resnet50.pth` +- `https://huggingface.co/datasets//verify-weights/resolve/main/io1_resnet50_deepfake.pth` + +> ⚠️ **Private dataset**: the Space will need a **read-token** to download these. +> Generate one at https://huggingface.co/settings/tokens (role: `read`). Copy it; you'll paste +> it as a Space Secret in step 4. + +--- + +## Step 3 — Create the HuggingFace Space + +1. Go to **https://huggingface.co/new-space** +2. Owner: your username +3. Name: `verify` (the public URL will be `https://-verify.hf.space`) +4. License: `apache-2.0` +5. **SDK: `Docker`** (NOT Gradio/Streamlit — we use our own Dockerfile) +6. Hardware: **CPU basic — free** (16 GB RAM, 2 vCPU) +7. Visibility: **Public** +8. Click **Create Space** + +The Space is created empty. Don't push anything yet — first configure the secrets. + +--- + +## Step 4 — Configure Space Secrets (encrypted environment variables) + +In your Space, go to **Settings** → **Variables and secrets** → **New secret**. Add these one by one: + +| Secret name | Value | Why | +|---|---|---| +| `OPENAI_API_KEY` | `sk-proj-...` (your real key) | Needed by the chatbot module and the narrative layer | +| `IO1_RESNET50_URL` | `https://huggingface.co/datasets//verify-weights/resolve/main/io1_resnet50.pth` | Where the build script pulls Islem's weights | +| `IO1_RESNET50_DEEPFAKE_URL` | `https://huggingface.co/datasets//verify-weights/resolve/main/io1_resnet50_deepfake.pth` | Same, for Model X | +| `HUGGINGFACE_HUB_TOKEN` | the read-token you generated in step 2 | Lets the build authenticate to download from the private dataset | + +Click **Save** after each one. Secrets are encrypted and never visible in the Space's logs or code. + +--- + +## Step 5 — Push the Verify code to the Space + +Each HF Space is a git repo. From your local Verify checkout: + +```bash +cd /Users/yassminesmachine/Desktop/template + +# 1. Make sure the local repo is up to date (no uncommitted work) +git status +git add -A +git commit -m "Prepare for HuggingFace Space deployment" + +# 2. Add HF Space as a remote (use your own username + space name) +git remote add space https://huggingface.co/spaces//verify + +# 3. Push +git push space main + +# (HF git will prompt for your HuggingFace credentials — username + an access token from +# https://huggingface.co/settings/tokens, role 'write') +``` + +Once the push completes, HuggingFace starts building the Docker image automatically. You can +watch the build at **https://huggingface.co/spaces/``/verify** → **Logs** tab. + +> ⏱ **First build takes ~15 min** because the Dockerfile pre-downloads all HuggingFace models +> (CLIP, TrOCR, Whisper, etc.) inside the image. Subsequent builds are much faster — only the +> changed layers rebuild. + +--- + +## Step 6 — Verify the deployment + +When the build is done and the Space status flips to **Running**: + +1. Open `https://-verify.hf.space` in your browser → you should see the Verify homepage. +2. Click **Verifier** → choose any module → upload an image → click **Analyze**. +3. The first analysis after a cold start takes ~30-60 s (CPU + first-time module init); + subsequent calls are 5-15 s. + +Sanity checks: + +```bash +# From your laptop — confirms the API is reachable +curl https://-verify.hf.space/health | jq .status # → "ok" + +curl https://-verify.hf.space/api # → JSON describing the 6 modules + chatbot +``` + +--- + +## Step 7 — Fill in the ESPRIT submission form + +Now you can fill in section 2 of the submission PDF: + +| Champ | Valeur | +|---|---| +| Nom du projet | `Esprit-PI--2526-Verify` | +| Lien GitHub | `https://github.com//Esprit-PI--2526-Verify` | +| Lien de déploiement | **`https://-verify.hf.space`** ✓ | +| Type de projet | IA | +| Commande de lancement | `docker build -t verify . && docker run -p 7860:7860 verify` (or local: `uvicorn backend.main:app --port 8000`) | +| Temps d'installation estimé | < 10 min (local) or instant (deployed link) | + +--- + +## Updating the Space later + +After pushing changes to GitHub, push the same commits to the Space: + +```bash +git push origin main # GitHub +git push space main # HF Space (triggers rebuild) +``` + +If you only changed Python code (no new model), the rebuild takes ~2-3 min thanks to Docker +layer caching. + +--- + +## Troubleshooting + +### "Build failed: file too large" +GitHub-via-LFS isn't enabled by default on HF Spaces. Make sure `.gitignore` is excluding all +`*.pth`/`*.pt` files except `yolov8n.pt` (it's only 7 MB). +Check with: `git ls-files | xargs -I{} ls -l {} 2>/dev/null | awk '$5 > 10000000 {print}'` + +### "Space running out of memory" +The default CPU-basic Space has 16 GB RAM, which is enough. If you upgraded the hardware and +hit OOM, try `LOAD_MODULES=io1,io4` to load fewer modules at startup (set this as a Space variable). + +### Chatbot returns "service unavailable" +The `OPENAI_API_KEY` secret is missing or the key is invalid. Re-set it in **Space Settings** → +**Variables and secrets**, then restart the Space (top-right menu → **Restart this Space**). + +### Cold start is very long (> 3 min) +The first request after a 48h sleep needs to (a) re-download HF models if persistent storage was +not enabled, (b) load all PyTorch models into RAM. Enable **Persistent storage** in Space +settings (free 50 GB tier — was previously paid) to keep the cache between sleeps. + +### Frontend says "Service unavailable" +Open the browser DevTools console. If `fetch('/api/io1/health')` returns 404, the FastAPI static +mount is broken — check `backend/main.py` and confirm the `app.mount("/", StaticFiles(...))` line +is present and that `index.html` exists at the repo root. + +### Some images come up as REAL when they should be FAKE +This is the limit of the open-source AI detectors (see `MODELS.md`). The deepfake ensemble + +2-detector consensus catches ~66% of ThisPersonDoesNotExist images and ~99% of obvious DALL-E / +Midjourney / Flux images. Re-test with another sample if you hit a model blind spot. + +--- + +## Alternative: deploy WITHOUT HuggingFace Spaces (local Docker only) + +If for any reason you cannot use HF Spaces, the project also runs with plain Docker. In your +local terminal: + +```bash +docker build -t verify . +docker run -p 7860:7860 \ + -e OPENAI_API_KEY=sk-... \ + -e IO1_RESNET50_URL=https://... \ + -e IO1_RESNET50_DEEPFAKE_URL=https://... \ + verify +``` + +Then open `http://localhost:7860`. The jury will need Docker on their machine — but the +**ESPRIT acceptance criterion for IA projects** (guide page 8) is precisely that: local launch in +<20 min with Docker. So this fully passes the rubric even without HF Spaces. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..954a4a021ae92e86f5f47577339277418de66633 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,97 @@ +# ─── Verify — single-container deployment for HuggingFace Spaces ──────────── +# Serves the FastAPI backend (6 detection modules + chatbot) AND the static frontend +# from the same container, on port 7860 (HuggingFace Spaces standard). +# +# Pre-downloads the heavy HuggingFace models at BUILD time so the Space cold-start +# stays under ~60 s instead of ~10 min on first user request. +# +# Required Space "Secrets" (set in Space Settings → Variables and secrets): +# OPENAI_API_KEY for the chatbot module (optional but recommended) +# IO1_RESNET50_URL HF dataset URL for Islem's best_ResNet50.pth +# IO1_RESNET50_DEEPFAKE_URL HF dataset URL for Islem's resnet50_deepfake.pth +# HUGGINGFACE_HUB_TOKEN only if the weights dataset is private + +FROM python:3.11-slim + +# ─── System packages ──────────────────────────────────────────────────────── +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + libgl1 \ + libglib2.0-0 \ + build-essential \ + wget \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# ─── Layout & user ────────────────────────────────────────────────────────── +# HF Spaces runs the container as a non-root user with UID 1000. +RUN useradd -m -u 1000 verify +USER verify +WORKDIR /home/verify/app + +# Caches inside the user's home so HF Spaces persistent storage (if enabled) keeps them. +ENV HOME=/home/verify \ + HF_HOME=/home/verify/.cache/huggingface \ + TORCH_HOME=/home/verify/.cache/torch \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +# ─── Python dependencies (cached layer) ───────────────────────────────────── +COPY --chown=verify:verify backend/requirements.txt backend/requirements.txt +RUN pip install --user --upgrade pip \ + && pip install --user -r backend/requirements.txt + +ENV PATH="/home/verify/.local/bin:${PATH}" + +# ─── Pre-warm the HuggingFace cache during build (saves cold-start time) ──── +# This pulls the heaviest models so the first user request doesn't trigger a 5-minute +# download. Each line is independent — comment out any model you want to lazy-load. +RUN python -c "\ +from open_clip import create_model_and_transforms, get_tokenizer; \ +create_model_and_transforms('ViT-B-32', pretrained='openai'); \ +create_model_and_transforms('ViT-L-14', pretrained='laion2b_s32b_b82k'); \ +print('CLIP models cached')" \ + && python -c "\ +from transformers import AutoTokenizer, AutoModelForSequenceClassification, \ + AutoModelForImageClassification, AutoImageProcessor, \ + TrOCRProcessor, VisionEncoderDecoderModel, MarianTokenizer, MarianMTModel; \ +TrOCRProcessor.from_pretrained('microsoft/trocr-base-printed'); \ +VisionEncoderDecoderModel.from_pretrained('microsoft/trocr-base-printed'); \ +AutoTokenizer.from_pretrained('valurank/distilroberta-clickbait'); \ +AutoModelForSequenceClassification.from_pretrained('valurank/distilroberta-clickbait'); \ +AutoImageProcessor.from_pretrained('Organika/sdxl-detector'); \ +AutoModelForImageClassification.from_pretrained('Organika/sdxl-detector'); \ +AutoImageProcessor.from_pretrained('umm-maybe/AI-image-detector'); \ +AutoModelForImageClassification.from_pretrained('umm-maybe/AI-image-detector'); \ +MarianTokenizer.from_pretrained('Helsinki-NLP/opus-mt-fr-en'); \ +MarianMTModel.from_pretrained('Helsinki-NLP/opus-mt-fr-en'); \ +print('HF transformers cached')" \ + && python -c "\ +import whisper; whisper.load_model('tiny'); whisper.load_model('small'); \ +print('Whisper cached')" \ + && python -c "\ +from sentence_transformers import SentenceTransformer; \ +SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2'); \ +print('MiniLM cached')" + +# ─── EasyOCR models (download to a known place) ───────────────────────────── +RUN python -c "\ +import easyocr; easyocr.Reader(['en', 'fr'], gpu=False, verbose=False); \ +print('EasyOCR models cached')" + +# ─── Application code ─────────────────────────────────────────────────────── +COPY --chown=verify:verify . . + +# ─── Pull Islem's .pth weights (private HF Dataset) ───────────────────────── +# The download script reads IO1_RESNET50_URL & IO1_RESNET50_DEEPFAKE_URL from env. +# Failing silently here keeps `docker build` reproducible without the secrets — the +# runtime will retry on startup using the Space's Secrets. +RUN python scripts/download_models.py || echo "[build] weights not downloaded (will retry at runtime)" + +# ─── Runtime ──────────────────────────────────────────────────────────────── +EXPOSE 7860 +ENV PORT=7860 + +# Use a startup wrapper so we can retry the weights download with the Space secrets +# (which are NOT available during `docker build` — only at runtime). +CMD ["bash", "-c", "python scripts/download_models.py || true && uvicorn backend.main:app --host 0.0.0.0 --port ${PORT:-7860}"] diff --git a/MODELS.md b/MODELS.md new file mode 100644 index 0000000000000000000000000000000000000000..901bc616f320b30f0792617ecd3d6ab0ee127e3d --- /dev/null +++ b/MODELS.md @@ -0,0 +1,105 @@ +# Verify — Models inventory + +Exhaustive list of every model and trained weight file used by the Verify platform. +Total disk footprint after first run: **~4 Go** (cached in `~/.cache/huggingface/` and `~/.cache/torch/`). +Combined in-memory footprint when all modules are loaded: **~3 Go RAM**. + +Files **with a download URL** = auto-fetched from HuggingFace Hub on first use (no manual setup). +Files **without** a URL = hosted on HuggingFace Hub by the Verify team (see `scripts/download_models.py`). + +## io1 — Fake Media Detection (deepfake & AI-generated) + +| Model | Type | Size | Source | Auto-download? | +|---|---|---|---|---| +| `io1_resnet50.pth` (Islem's `best_ResNet50.pth`) | ResNet50 fine-tuned for face-deepfake (idx 0=FAKE) | 94 Mo | HuggingFace Hub (Verify Dataset) | ⚙️ `scripts/download_models.py` | +| `io1_resnet50_deepfake.pth` (Islem's `resnet50_deepfake.pth` / Model X) | ResNet50 + Dropout+Linear head (idx 0=REAL) | 94 Mo | HuggingFace Hub (Verify Dataset) | ⚙️ `scripts/download_models.py` | +| **MTCNN** (`facenet-pytorch`) | Face detector (3 stages: PNet/RNet/ONet) | 6 Mo | embedded in `facenet-pytorch` pip package | ✅ pip install | +| **Organika/sdxl-detector** | ViT image classifier for SDXL/diffusion AI images | 86 Mo | [`Organika/sdxl-detector`](https://huggingface.co/Organika/sdxl-detector) | ✅ HuggingFace | +| **umm-maybe/AI-image-detector** | ViT image classifier (precision-tuned) | 86 Mo | [`umm-maybe/AI-image-detector`](https://huggingface.co/umm-maybe/AI-image-detector) | ✅ HuggingFace | + +## io2 — Visual Manipulation / Persuasion + +| Model | Type | Size | Source | Auto-download? | +|---|---|---|---|---| +| **microsoft/trocr-base-printed** | Vision-Encoder + GPT-2 Decoder (OCR for screen text) | 558 Mo | [`microsoft/trocr-base-printed`](https://huggingface.co/microsoft/trocr-base-printed) | ✅ HuggingFace | +| **valurank/distilroberta-clickbait** | DistilRoBERTa fine-tuned for clickbait detection (binary) | 330 Mo | [`valurank/distilroberta-clickbait`](https://huggingface.co/valurank/distilroberta-clickbait) | ✅ HuggingFace | +| **open_clip ViT-B-32** (openai pretrained) | CLIP for zero-shot "clickbait visual style" scoring | 150 Mo | [`openai/clip-vit-base-patch32`](https://huggingface.co/openai/clip-vit-base-patch32) | ✅ open_clip | +| **Helsinki-NLP/opus-mt-fr-en** | MarianMT FR→EN (so the EN-only clickbait model receives EN) | 300 Mo | [`Helsinki-NLP/opus-mt-fr-en`](https://huggingface.co/Helsinki-NLP/opus-mt-fr-en) | ✅ HuggingFace | +| **EasyOCR** (EN + FR) | Text detection + recognition (CRAFT + CRNN) | 100 Mo | embedded in `easyocr` pip package | ✅ pip install | + +## io3 — Image-Caption Coherence + +| Model | Type | Size | Source | Auto-download? | +|---|---|---|---|---| +| **open_clip ViT-L-14** (laion2b_s32b_b82k) | CLIP large for image↔text similarity | 890 Mo | [`laion/CLIP-ViT-L-14-laion2B-s32B-b82K`](https://huggingface.co/laion/CLIP-ViT-L-14-laion2B-s32B-b82K) | ✅ open_clip | +| **YOLOv8m** (`yolov8m.pt`) | Object detection — 80 COCO classes | 52 Mo | [Ultralytics releases](https://github.com/ultralytics/assets/releases) | ✅ ultralytics | +| **EasyOCR** (EN + FR) | reused from io2 | shared | — | ✅ | +| **Whisper small** (`small.pt`) | OpenAI Whisper audio transcription | 470 Mo | [`openai-whisper` pip package](https://github.com/openai/whisper) | ✅ pip install | +| **SAM ViT-B** (optional) | Segment Anything for region scoring | 375 Mo | [`sam_vit_b_01ec64.pth`](https://dl.fbaipublicfiles.com/segment_anything/sam_vit_b_01ec64.pth) | ⚙️ manual download (else 3×3 grid fallback) | + +## io4 — Image Tampering (Photoshop forensics) + +| Model | Type | Size | Source | Auto-download? | +|---|---|---|---|---| +| **None** (pure model-free forensics) | ELA + Noise residual + JPEG ghost | 0 Mo | implemented in [`backend/modules/io4_photoshop/forensics.py`](backend/modules/io4_photoshop/forensics.py) | ✅ pure NumPy | + +## io5 — Caption Fidelity + +| Model | Type | Size | Source | Auto-download? | +|---|---|---|---|---| +| **open_clip ViT-B-32** (openai) | CLIP for image↔caption + per-phrase scoring | shared with io2 | — | ✅ | +| **EasyOCR** (EN + FR) | reused from io2 | shared | — | ✅ | + +## io6 — Cosmetic Ads Fact-Check + +| Model | Type | Size | Source | Auto-download? | +|---|---|---|---|---| +| **Whisper tiny** | Audio transcription (rapid version for ads) | 75 Mo | [`openai-whisper` pip package](https://github.com/openai/whisper) | ✅ pip install | +| **YOLOv8n** (`yolov8n.pt`) | Object detection (lightweight) | 7 Mo | [Ultralytics releases](https://github.com/ultralytics/assets/releases) — versioned in repo | ✅ committed | +| **EasyOCR** (FR + EN) | reused from io2 | shared | — | ✅ | +| **paraphrase-multilingual-MiniLM-L12-v2** | Sentence embeddings (semantic match with KB fake claims) | 470 Mo | [`sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2`](https://huggingface.co/sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2) | ✅ HuggingFace | +| **IO6_Base_Reference_V3_FULL.xlsx** (Yassmine's KB) | Regulatory knowledge base — 25 patterns, 33 fake claims, 145 ingredients, 93 brands | 33 Ko | versioned in `backend/data/` | ✅ committed | + +## chatbot — Verify Assistant + +| Service | Type | Size | Source | Auto-download? | +|---|---|---|---|---| +| **OpenAI gpt-4o-mini** | Topic-restricted assistant via OpenAI API | n/a (remote) | requires `OPENAI_API_KEY` env var | ☁️ remote API | + +The chatbot is **the only component that hits an external API**. The 6 detection modules above +all run 100% locally. On HuggingFace Spaces the key is set as a "Secret" (encrypted, never in the code). + +## Narrative layer (cross-module, post-processing) + +| Service | Type | Source | +|---|---|---| +| **OpenAI gpt-4o-mini** | Rewrites every module's raw result into plain-English narrative | ☁️ remote API (optional — disable with `IO_XAI_NARRATIVE=off`) | + +The narrative layer is **optional**: if `OPENAI_API_KEY` is not set, modules return their raw +local explanation only. Set `IO_XAI_NARRATIVE=off` to disable explicitly. + +--- + +## How to obtain Islem's `.pth` weights + +The two ResNet50 checkpoints used by io1 (`io1_resnet50.pth` and `io1_resnet50_deepfake.pth`) are +not versioned in this repo because each file weighs ~94 MB (GitHub's per-file limit is 100 MB but +combined size kills clone speed). + +They are hosted as a private HuggingFace Dataset by the Verify team. To download them locally: + +```bash +# Either: download via the helper script, with the URLs set in .env or exported +export IO1_RESNET50_URL=https://huggingface.co/datasets//verify-weights/resolve/main/best_ResNet50.pth +export IO1_RESNET50_DEEPFAKE_URL=https://huggingface.co/datasets//verify-weights/resolve/main/resnet50_deepfake.pth +python scripts/download_models.py + +# Or, on a HuggingFace Space: the Dockerfile pulls them automatically using HUGGINGFACE_HUB_TOKEN +# (set as a Secret in Space settings → so the file stays private). +``` + +## Auto-download summary + +After running `pip install -r backend/requirements.txt` and starting the backend once, +**12 of 14 components self-install** from public sources. Only Islem's 2 `.pth` files need a manual +step (the download script). diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e4bb35167107c0ed411113475447f6c181fc2115 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +--- +title: Verify +emoji: 🔍 +colorFrom: red +colorTo: blue +sdk: docker +app_port: 7860 +pinned: false +short_description: Multimodal disinformation detection — 6 ML modules + chatbot +license: apache-2.0 +--- + +# Verify — Plateforme multimodale de détection de désinformation + +## Description + +**Verify** est une plateforme web qui vérifie l'authenticité de contenus visuels (image, vidéo, texte, publicité) +en analysant en parallèle **six dimensions** distinctes de la désinformation : + +1. **io1 — Fake Media** : détection deepfake (face-swap) + images générées par IA (StyleGAN, DALL-E, Midjourney) +2. **io2 — Visual Manipulation** : détection des images persuasives/clickbait (texte alarmiste, urgence forcée, mise en scène) +3. **io3 — Image↔Caption Coherence** : vérifie la cohérence entre une image/vidéo et sa légende +4. **io4 — Image Tampering** : forensique photoshop (ELA + analyse de bruit + JPEG ghost) +5. **io5 — Caption Fidelity** : vérifie si une légende décrit fidèlement le contenu d'une image +6. **io6 — Cosmetic Ads Fact-Check** : audit réglementaire EU 655/2013 sur les publicités cosmétiques + +Tous les modules tournent **100% en local** — aucune API externe n'est requise pour produire un verdict. +Le backend est une seule API FastAPI qui monte les 6 modules ; le frontend est statique (HTML + JavaScript). + +## Technologies utilisées + +| Couche | Stack | +|---|---| +| **Frontend** | HTML5 · CSS3 · Vanilla JavaScript (pas de framework) | +| **Backend** | Python 3.11 · FastAPI · Uvicorn | +| **IA / ML** | PyTorch · TorchVision · HuggingFace Transformers · open_clip · Ultralytics YOLO · OpenAI Whisper · EasyOCR · facenet-pytorch (MTCNN) · sentence-transformers | +| **Connaissances** | Pandas + OpenPyXL (knowledge base Excel pour io6) | +| **Forensique** | Pure NumPy + Pillow (io4 ELA / noise / JPEG ghost — sans modèle) | + +## Prérequis + +- **Python 3.11+** (testé sur 3.11.15) +- **ffmpeg** dans le PATH (utilisé par io3 et io6 pour extraire l'audio des vidéos) +- **~4 Go RAM disponible** (les 6 modules chargent ~3 Go de modèles en mémoire au démarrage) +- Premier démarrage : ~3-5 minutes (téléchargement automatique des modèles HuggingFace, ~2 Go) +- Démarrages suivants : ~30 secondes (tout est caché localement) + +> ⚠️ **Modèles pré-entraînés non versionnés** : les poids `.pt`/`.pth` ne sont pas inclus dans le repo +> (taille > 250 Mo combinée + politique ESPRIT/GitHub). Voir la section **Installation** ci-dessous. + +## Installation + +```bash +# 1. Cloner le repo +git clone https://github.com/USERNAME/Esprit-PI-CLASSE-2526-Verify.git +cd Esprit-PI-CLASSE-2526-Verify + +# 2. Créer l'environnement Python +python3.11 -m venv .venv +source .venv/bin/activate # macOS / Linux +# .venv\Scripts\activate # Windows + +# 3. Installer les dépendances backend +pip install -r backend/requirements.txt + +# 4. Télécharger les poids ResNet50 d'Islem (io1) — non versionnés +python scripts/download_models.py + +# 5. (Optionnel) Configurer les variables d'environnement +cp .env.example .env +# Éditez .env si vous voulez activer le chatbot OpenAI ou ajuster un seuil. +``` + +## Lancement + +```bash +# Backend (terminal 1) +uvicorn backend.main:app --host 0.0.0.0 --port 8000 + +# Frontend (terminal 2) — au choix : +python -m http.server 5500 # serveur HTTP statique +# OU : extension "Live Server" dans VSCode (clic-droit sur index.html → "Open with Live Server") +``` + +Ensuite ouvrez votre navigateur sur **http://127.0.0.1:5500/index.html**. + +La doc Swagger de l'API est disponible sur **http://127.0.0.1:8000/docs**. + +## Variables d'environnement + +Voir [`.env.example`](.env.example) pour la liste complète. Les principales : + +| Variable | Description | +|---|---| +| `LOAD_MODULES=io3,io1` | Limite quels modules sont chargés au démarrage (default : tous). Pratique en dev pour économiser la mémoire. | +| `FORCE_DEVICE=cpu` | Force le CPU même si un GPU est disponible. | +| `OPENAI_API_KEY=sk-...` | (Optionnel) Active le module chatbot uniquement. **N'affecte pas les verdicts** des 6 modules d'analyse. | +| `IO1_DEEPFAKE_THRESHOLD=0.60` | Seuil de décision FAKE pour l'ensemble ResNet50 d'Islem. | +| `IO4_ELA_QUALITY=90` | Qualité JPEG utilisée par ELA dans io4. | +| `IO6_WHISPER_MODEL=tiny` | Variante Whisper pour io6 (`tiny` rapide, `small`/`medium` plus précis). | + +## Démo + +- **Site déployé** : Non disponible (déploiement local uniquement pour cette livraison) +- **Vidéo de démonstration** : voir [`demo/`](demo/) (à fournir par l'équipe) +- **Diagrammes d'architecture** : voir [`docs/`](docs/) +- **Description commerciale** : [`Verify_Description_Commerciale.pdf`](Verify_Description_Commerciale.pdf) + +## Performances des modèles + +| Module | Backend | Performance | Source | +|--------|---------|------------|--------| +| io1 deepfake | ResNet50 ensemble (Islem) + MTCNN | ~97% acc sur FaceForensics | [`islem/`](islem/) | +| io1 AI-image | Consensus Organika/sdxl + umm-maybe (HF) | ~66% recall sur TPDNE, ~0% FP sur photos réelles | tuned empirically | +| io2 NLP | DistilRoBERTa fine-tuned clickbait | ~99% acc sur Webis Clickbait 2017 | `valurank/distilroberta-clickbait` | +| io2 visuel | CLIP ViT-B/32 zero-shot | calibration manuelle | OpenAI CLIP | +| io3 cohérence | CLIP ViT-L/14 + YOLOv8m fusion | F1 ~0.85 sur dataset interne | [`youssef/`](youssef/) | +| io4 forensique | ELA + Noise + JPEG ghost (sans modèle) | détection compositing/inpainting localisé | méthode classique | +| io5 fidelity | CLIP ViT-B/32 + EasyOCR | calibrée sur paires CC3M | sigmoid calibration | +| io6 cosmétique | Whisper + KB Excel + MiniLM semantic | 25 patterns / 33 fake claims / 145 ingrédients | [`yassmine/`](yassmine/) | + +## Structure du projet + +``` +Verify/ +├── README.md ← ce fichier +├── .env.example ← variables d'environnement +├── .gitignore +├── index.html, verifier.html, ... ← frontend statique +├── assets/ ← CSS, JS, images +├── backend/ ← API FastAPI +│ ├── main.py ← entrypoint +│ ├── requirements.txt +│ ├── shared/ ← code commun (device, narration XAI) +│ ├── data/ ← KB Excel io6 (poids .pth téléchargés via script) +│ └── modules/ +│ ├── io1_ai_generated/ +│ ├── io2_persuasion/ +│ ├── io3_coherence/ +│ ├── io4_photoshop/ +│ ├── io5_caption_fidelity/ +│ └── io6_cosmetic_ads/ +├── scripts/ +│ └── download_models.py ← téléchargement automatique des poids +├── docs/ ← diagrammes architecture, doc API +├── demo/ ← captures et vidéos de démo +├── islem/, malek/, youssef/, rayen/, yassmine/ ← notebooks et docs des contributeurs +└── ios images/ ← jeu d'images de référence pour la démo +``` + +## Auteurs + +| Nom | Module | Année | Tuteur | +|---|---|---|---| +| Yassmine Nouisser | io6 — Cosmetic Ads Fact-Check + intégration globale | 2025-2026 | *(à compléter)* | +| Islem | io1 — Fake Media Detection (deepfake + AI) | 2025-2026 | *(à compléter)* | +| Malek Tirellil | io2 — Visual Manipulation Detection | 2025-2026 | *(à compléter)* | +| Youssef | io3 — Image-Caption Coherence | 2025-2026 | *(à compléter)* | +| Rayen | io4 — Image Tampering Detection | 2025-2026 | *(à compléter)* | +| Maryem | (contribution complémentaire) | 2025-2026 | *(à compléter)* | + +Classe : *(à compléter)* — Groupe : *(à compléter)* + +--- + +## Documentation supplémentaire + +- [`backend/README.md`](backend/README.md) — détails techniques de l'API +- [`docs/architecture.md`](docs/architecture.md) — architecture système et flux de données +- [`docs/api.md`](docs/api.md) — référence des endpoints +- [`docs/modules.md`](docs/modules.md) — détails par module +- [`AI_Investigate_Report_COMPLETE.pdf`](AI_Investigate_Report_COMPLETE.pdf) — rapport d'enquête initial +- [`Verify_Description_Commerciale.pdf`](Verify_Description_Commerciale.pdf) — pitch commercial + +## Licence + +Projet académique — ESPRIT School of Engineering, année universitaire 2025-2026. diff --git a/Verify_Description_Commerciale.html b/Verify_Description_Commerciale.html new file mode 100644 index 0000000000000000000000000000000000000000..ae524e37e499d0838d2f2cbcd732c0c51f46e9b8 --- /dev/null +++ b/Verify_Description_Commerciale.html @@ -0,0 +1,358 @@ + + + + +Verify — Commercial overview + + + + + + + +
+ Commercial overview +

Verify

+
+

Tunisia's platform for verifying
visual information

+
+ Overview document · 2026 +
+
+ + +
+

The project in one sentence

+

+ Verify is an independent platform for checking visual content + and its context (images, videos, captions, ads) that helps the general public, + journalists and Tunisian institutions tell, within minutes, + what is authentic, suspect, + manipulated or misleading. +

+
+ + +
+

The problem Verify addresses

+

Visual disinformation is exploding in Tunisia and across the Arab world:

+
    +
  • Images and videos created with artificial-intelligence tools and passed off as real.
  • +
  • Advertising and propaganda videos that play on emotion to influence you more effectively.
  • +
  • Misleading captions placed over genuine images to make them say something else.
  • +
  • Retouched or composited photos: elements added, erased or moved.
  • +
  • Misleading cosmetic ads: faked before/after shots, fake testimonials, unrealistic claims.
  • +
  • Gaps between what you see and what you read: exaggerations, omissions, distortions.
  • +
+

Today, the average Tunisian citizen has no simple tool, in French or Arabic, + to untangle all of this. Verify fills that gap.

+
+ + +
+

The value proposition

+
+ User promise + "Drop in an image, a video or a post — get a clear, reasoned verdict in under 2 minutes." +
+

Verify combines three complementary strengths:

+
+
+

1. In-depth analysis

+

Six ways to check a piece of content, from the image itself to the words around it.

+
+
+

2. A human newsroom

+

A team that contextualizes, investigates and publishes the evidence.

+
+
+

3. A consumer-facing media outlet

+

Every verification is explained, archived and freely accessible.

+
+
+
+ + +
+

The 6 analyses — the "Verify Toolkit"

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeAnalysisWhat it's for (in plain words)
IO1Detection of AI-created imagesSpot images and videos made with artificial-intelligence tools. We examine dozens of subtle clues, invisible to the naked eye, to deliver a clear verdict on their origin.
IO2Visual manipulation & persuasionBring to light the emotional-persuasion and visual-manipulation techniques used in advertising and propaganda videos — so you understand how someone is trying to influence you.
IO3Image & caption coherenceCheck whether an image really matches the caption it's published under, and flag the cases where the caption makes the image say something else.
IO4Detection of retouched photosSpot retouching and montages in a photo: elements added, erased or moved — and show exactly where in the image.
IO5Caption fidelityAssess how faithfully a caption describes what you actually see: exaggerations, omissions, misleading shortcuts. (Lead: Maryem)
IO6Cosmetic-ad fact-checkingCheck whether a cosmetic ad's claims hold up, in light of the EU rules on cosmetic claims: faked before/after shots, fake testimonials, unrealistic claims.
+ +

The six analyses cover the entire chain of visual disinformation:

+
    +
  • The image (IO1, IO4) — is it authentic or doctored?
  • +
  • The intent (IO2, IO6) — how is someone trying to influence me?
  • +
  • The meaning (IO3, IO5) — does what I'm told match what I see?
  • +
+
+ + +
+

How it works for the user

+
    +
  1. You drop in an image, a video, a post with its caption, or an ad.
  2. +
  3. Verify picks for you the analyses best suited to that content.
  4. +
  5. The content is scrutinized from every useful angle, and cross-checked against its context.
  6. +
  7. A score out of 100 and a clear verdict are returned: Authentic / Suspect / Manipulated / Misleading.
  8. +
  9. When there's doubt, a human analyst takes over — no ambiguous verdict is ever delivered blindly.
  10. +
  11. A public report is published: key moments of a video, retouched regions spotted in the image, a comparison between the caption and the visual, and a clear explanation. Everything is archived and viewable.
  12. +
+
+ + +
+

The media outlet that goes with the tool

+

Verify isn't just a verification tool — it's also a news site + in the style of major international media, with:

+
    +
  • A daily editorial front page.
  • +
  • In-depth investigations into visual and advertising disinformation.
  • +
  • Fact-checks tracking Tunisian current affairs (politics, climate, economy, sport, culture, health).
  • +
  • A morning newsletter: "The Verify brief — 5 minutes, zero spin, just facts."
  • +
  • A fully transparent Methodology section.
  • +
+
+ + +
+

Target audiences

+
    +
  • The connected citizen who wants to verify before sharing.
  • +
  • Journalists and newsrooms: a fast tool to use before publishing.
  • +
  • Teachers and trainers in media literacy.
  • +
  • Public institutions facing manipulation campaigns.
  • +
  • Brands and the cosmetics industry looking to detect advertising impersonations and misleading claims made in their name.
  • +
  • Advertising regulators who need objective evidence.
  • +
+
+ + +
+

What sets Verify apart

+
    +
  • Complete coverage: from the image itself (IO1, IO4) to the meaning of the post (IO3, IO5), by way of the persuasive intent (IO2, IO6).
  • +
  • A one-of-a-kind cosmetic-ad verification (IO6) — a sector especially exposed in Tunisia to misleading claims on social media.
  • +
  • The first service of its kind built for the Tunisian and Maghreb context.
  • +
  • Radical transparency: the method, the scores and the evidence behind the verdict are all published.
  • +
  • Humans above automation: no ambiguous verdict goes out without human validation.
  • +
  • Bilingual by nature: built for French- and Arabic-speaking audiences.
  • +
  • Editorial independence from parties, platforms and advertisers.
  • +
+
+ + +
+

Key figures

+
+
412
Verifications / week
+
38 %
Content flagged as misleading
+
6
Ways to check a piece of content
+
‹ 2 min
Average time to a verdict
+
+
+ + +
+

The brand promise

+ +

+ Verify · Commercial document · 2026 · Tunis +

+
+ + + diff --git a/Verify_Description_Commerciale.pdf b/Verify_Description_Commerciale.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0bb097cddca0aec9900c23ae98884b95be31513f --- /dev/null +++ b/Verify_Description_Commerciale.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:56ad22c7176fa6cd1a1cd106de31e26f44a70612defd38039f4b82289a8fcb13 +size 251853 diff --git a/archive.html b/archive.html new file mode 100644 index 0000000000000000000000000000000000000000..d48114edc920a4cbbd6a1f833746fd8bb49a8bdd --- /dev/null +++ b/archive.html @@ -0,0 +1,199 @@ + + + + + + Verifications archive — Verify + + + + + + + + + +
+
+
+ Home › + Fact-checks › + Archive +
+

Verifications archive

+

+ All analyses published by our team, sorted by module and verdict. + 412 reports in total. +

+
+ + +
+ Module: + + + + + + + +
+
+ Verdict: + + + + + + Period: + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateReferenceTitleModuleScoreVerdict
Apr 27, 2026VR-2026-0427-A93Tunis flooding video (TikTok)Coherence34Out of contextView
Apr 26, 2026VR-2026-0426-B12Viral "official" bank adPersuasion22ManipulatedView
Apr 26, 2026VR-2026-0426-C45Sfax protest photoForensics48SuspectView
Apr 25, 2026VR-2026-0425-D78Speech attributed to the Finance MinisterAI-Gen67SuspectView
Apr 24, 2026VR-2026-0424-E33Espérance image during the African titleCaption91AuthenticView
Apr 23, 2026VR-2026-0423-F09Municipal campaign poster (Sousse)Forensics54SuspectView
Apr 22, 2026VR-2026-0422-G22"Photo" of Tunis-Carthage airportAI-Gen14ManipulatedView
Apr 21, 2026VR-2026-0421-H56"New school opening" video in KairouanCaption88AuthenticView
Apr 20, 2026VR-2026-0420-I91Screenshot of an official statementPersuasion40SuspectView
Apr 19, 2026VR-2026-0419-J04Real-estate ad (Instagram account)Persuasion30ManipulatedView
Apr 18, 2026VR-2026-0418-K17Video shot in La Marsa (verified account)AI-Gen94AuthenticView
Apr 17, 2026VR-2026-0417-L88AI-generated landscape image (Tozeur)AI-Gen11ManipulatedView
+
+ +
+ + + + + + + +
+
+ + + + + + + + diff --git a/article.html b/article.html new file mode 100644 index 0000000000000000000000000000000000000000..dc0a36b76190769398bf81adcc795f7f764b5f1b --- /dev/null +++ b/article.html @@ -0,0 +1,161 @@ + + + + + + Tunis floods: our verification — Verify + + + + + + + + + +
+
+ +
+ Home › + Fact-checks › + Tunis +
+ + Fact-check +

+ Is this viral video of flooding in Tunis authentic? + Our frame-by-frame verification +

+

+ The 47-second clip topped 800,000 views on X and TikTok in two + days. Our Image–Caption Coherence and Edited Photo Detection + analyses reconstructed the origin of every shot. +

+ +
+
M
+
+
Maryem Ben Slimane
+
Lead — Caption Fidelity · Published April 27, 2026 at 9:42 AM
+
+
+ + + Urban flooding +

+ Screenshot of the viral video as it circulated on April 26 on X. Credit: "Tunisie en direct" account. +

+ + +
+ Why it matters + During extreme climate events, decontextualized videos amplify fear + and saturate emergency services. Verifying quickly and publishing + the chain of origin is the first line of defense. +
+ +

+ The video, first posted on Tuesday at 9:17 PM, shows submerged + streets presented as filmed "tonight in Tunis." Within 90 minutes, + the clip had been picked up by five pan-Arab accounts totaling 4.2 + million followers. Our team began analysis at 10:04 PM. +

+ +
+ The big picture + Three key shots — out of the twelve in the video — come from a + separate flood event in Bab Bhar, in September 2018. Two new shots + were authenticated in Manouba this morning, around 6:12 AM. +
+ +

What our modules found

+
    +
  • Image–Caption Coherence noted a 3,200 K luminance + shift between shots 4–7 and the rest: the classic signature of + nighttime archive footage re-injected into a daytime video.
  • +
  • Edited Photo Detection isolated two added objects — + a road sign and a pharmacy sign — overlaid on 2018 frames.
  • +
  • The EXIF metadata was stripped on the viral + version, but the version posted 16 minutes earlier by another + account still carries a timestamp dated September 14, 2018.
  • +
  • By contrast, two shots (8 and 11) are authentic and unpublished: + they correspond to a local flood event that occurred this morning in Manouba.
  • +
+ +

How we established this

+

+ The Verify verification process combines a reverse image search + (Yandex, TinEye, Google Lens) with our own visual analyses. For this + video, the first archived match was found in 4 minutes via TinEye + on frame 156. It pointed to a Mosaïque FM article published on + September 15, 2018. +

+ +

+ Once the source was identified, our analysts aligned each shot of + the viral video with the original archive, shot by shot. Shots 8 + and 11 produced no match: they were then submitted to AI-Generated + Media Detection, which returned a confidence score > 92% in + favor of authenticity. +

+ + + + +
+
+

Want to verify suspicious content yourself?

+

Upload an image, video or link — our five modules give you a score in less than 2 minutes.

+
+ Verify this content +
+ +

+ The stakes go beyond this one case. Over the past seven days, + Verify processed 421 user-flagged contents. 38% were marked as + manipulated, 17% as out of context. The rest — the majority — + is authentic: verification isn't just about debunking, it's also + about confirming. +

+
+ + +
+

More to read

+
+ + + +
+
+
+ + + + + + + + diff --git a/assets/css/styles.css b/assets/css/styles.css new file mode 100644 index 0000000000000000000000000000000000000000..71d94bfffd8377e7b699ddd6b481f7f8ada9c10a --- /dev/null +++ b/assets/css/styles.css @@ -0,0 +1,5289 @@ +:root { + --navy: #052962; + --navy-dark: #041e4d; + --navy-glow: rgba(5, 41, 98, 0.12); + --yellow: #ffe500; + --yellow-soft: #fff8b3; + --red: #dc2626; + --green: #16a34a; + --orange: #f59e0b; + --rose: #fbf0f3; + --beige: #f9f6e9; + --peche: #fcefe5; + --ink: #121212; + --ink-soft: #4a4a4a; + --ink-faint: #8a8a8a; + --line: #e6e6ea; + --line-soft: #f0f0f3; + --bg-soft: #fafafb; + + /* Radius scale */ + --r-sm: 6px; + --r: 10px; + --r-lg: 16px; + --r-xl: 24px; + --r-pill: 9999px; + + /* Shadow scale (modern, soft, multi-layer) */ + --sh-sm: 0 1px 2px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.04); + --sh-md: 0 4px 8px -2px rgba(15, 23, 42, 0.08), 0 2px 4px -2px rgba(15, 23, 42, 0.04); + --sh-lg: 0 14px 28px -8px rgba(15, 23, 42, 0.14), 0 6px 12px -6px rgba(15, 23, 42, 0.06); + --sh-xl: 0 28px 56px -12px rgba(5, 41, 98, 0.22), 0 12px 24px -8px rgba(15, 23, 42, 0.10); + --sh-glow: 0 0 0 4px rgba(255, 229, 0, 0.25); + --sh-ring: 0 0 0 3px rgba(5, 41, 98, 0.12); + + /* Transitions */ + --ease: cubic-bezier(0.4, 0, 0.2, 1); + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --t-fast: 0.15s var(--ease); + --t: 0.25s var(--ease); + --t-slow: 0.45s var(--ease-out); +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + font-family: 'Inter', system-ui, -apple-system, sans-serif; + color: var(--ink); + background: #ffffff; +} + +.font-serif { + font-family: 'Playfair Display', Georgia, serif; +} + +.bg-navy { background-color: var(--navy); } +.bg-yellow { background-color: var(--yellow); } +.bg-rose { background-color: var(--rose); } +.bg-beige { background-color: var(--beige); } +.bg-peche { background-color: var(--peche); } +.text-navy { color: var(--navy); } +.text-red-alert { color: var(--red); } +.text-green-ok { color: var(--green); } +.text-orange-warn { color: var(--orange); } +.border-navy { border-color: var(--navy); } + +/* Top bar */ +.topbar-pill { + background: var(--yellow); + color: var(--navy); + font-weight: 700; + border-radius: 9999px; + padding: 6px 18px; + font-size: 14px; + display: inline-block; +} + +/* Promo cards strip (Guardian-style) */ +.promo-strip { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + border-bottom: 1px solid var(--line); +} +.promo-card { + display: flex; + align-items: center; + gap: 16px; + padding: 18px 22px; + border-right: 1px solid #e6dcd0; +} +.promo-card:last-child { border-right: 0; } +.promo-card .label { + display: inline-block; + padding: 2px 10px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + border-radius: 2px; + margin-bottom: 6px; + letter-spacing: 0.6px; +} +.promo-card .title { + font-family: 'Playfair Display', serif; + font-size: 18px; + font-weight: 700; + line-height: 1.25; + color: var(--ink); +} +.promo-card .thumb { + width: 84px; + height: 84px; + border-radius: 50%; + flex-shrink: 0; + object-fit: cover; +} + +/* Navy nav */ +.navy-nav { + background: var(--navy); + color: #fff; + position: relative; +} +.navy-nav .nav-inner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 24px; + gap: 24px; +} +.navy-nav .nav-links { + font-family: 'Playfair Display', serif; + font-size: 18px; + display: flex; + gap: 22px; + align-items: center; +} +.navy-nav .nav-links a { + color: #fff; + text-decoration: none; +} +.navy-nav .nav-links a:hover { text-decoration: underline; } +.navy-nav .burger { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--yellow); + border: 0; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--navy); + font-weight: 800; +} +.navy-nav .brand { + font-family: 'Playfair Display', serif; + color: #fff; + font-weight: 800; + font-size: 56px; + line-height: 1; + letter-spacing: -1px; + text-decoration: none; +} +.navy-nav .brand .dot { color: var(--yellow); } + +/* Sub-nav chips */ +.subnav { + background: var(--navy); + border-top: 1px solid rgba(255,255,255,0.12); + padding: 10px 24px 14px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.subnav .chip { + display: inline-block; + padding: 4px 12px; + border: 1px solid rgba(255,255,255,0.45); + border-radius: 9999px; + color: #fff; + font-size: 13px; + text-decoration: none; +} +.subnav .chip:hover { + background: rgba(255,255,255,0.1); +} + +/* Hero article */ +.hero-article { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 28px; + padding: 32px 24px; + border-bottom: 1px solid var(--line); +} +.hero-article img { + width: 100%; + height: 100%; + object-fit: cover; + max-height: 460px; + display: block; +} +.hero-article h1 { + font-family: 'Playfair Display', serif; + font-size: 44px; + line-height: 1.1; + margin: 8px 0 16px; + color: var(--ink); +} +.hero-article .lede { font-size: 18px; color: var(--ink-soft); line-height: 1.55; } +.hero-article .meta { font-size: 13px; color: var(--ink-soft); margin-top: 14px; } +.hero-article .cat-tag { + display: inline-block; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + padding: 3px 10px; + border-radius: 2px; +} + +/* Smart Brevity boxes */ +.sb-box { + border-left: 5px solid; + padding: 14px 18px; + margin: 18px 0; + font-size: 16px; + line-height: 1.55; + background: #fafafa; +} +.sb-box .sb-label { + font-weight: 800; + text-transform: uppercase; + font-size: 12px; + letter-spacing: 1px; + margin-bottom: 6px; + display: block; +} +.sb-box.why { border-color: var(--red); } +.sb-box.why .sb-label { color: var(--red); } +.sb-box.big { border-color: var(--navy); } +.sb-box.big .sb-label { color: var(--navy); } +.sb-box.next { border-color: var(--green); } +.sb-box.next .sb-label { color: var(--green); } + +.sb-bullets { + list-style: none; + padding: 0; + margin: 14px 0; +} +.sb-bullets li { + padding-left: 28px; + position: relative; + margin-bottom: 10px; + font-size: 16px; + line-height: 1.55; +} +.sb-bullets li::before { + content: "›"; + position: absolute; + left: 8px; + top: -2px; + color: var(--navy); + font-weight: 700; + font-size: 22px; +} + +/* Article grid */ +.article-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 28px; + padding: 32px 24px; +} +.article-card { + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--line); + padding-bottom: 20px; +} +.article-card img { + width: 100%; + height: 200px; + object-fit: cover; +} +.article-card .cat-tag { + display: inline-block; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + padding: 3px 8px; + border-radius: 2px; + margin: 14px 0 8px; + align-self: flex-start; +} +.article-card h3 { + font-family: 'Playfair Display', serif; + font-size: 22px; + line-height: 1.2; + margin: 0 0 8px; + color: var(--ink); +} +.article-card p { font-size: 14px; color: var(--ink-soft); margin: 0 0 10px; line-height: 1.5; } +.article-card .meta { font-size: 12px; color: var(--ink-soft); } +.article-card a { text-decoration: none; color: inherit; } +.article-card a:hover h3 { text-decoration: underline; } + +.tag-pol { background: #dbeafe; color: #1e3a8a; } +.tag-eco { background: #dcfce7; color: #14532d; } +.tag-cult { background: #fef3c7; color: #78350f; } +.tag-tech { background: #e9d5ff; color: #581c87; } +.tag-sport { background: #fee2e2; color: #7f1d1d; } +.tag-sante { background: #cffafe; color: #155e75; } +.tag-clim { background: #d1fae5; color: #064e3b; } +.tag-fact { background: #fce7f3; color: #831843; } +.tag-int { background: #e0e7ff; color: #312e81; } + +/* Newsletter */ +.newsletter { + background: var(--beige); + padding: 48px 24px; + text-align: center; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} +.newsletter h2 { + font-family: 'Playfair Display', serif; + font-size: 34px; + margin: 0 0 10px; +} +.newsletter p { color: var(--ink-soft); margin: 0 0 22px; } +.newsletter form { + display: inline-flex; + gap: 0; + max-width: 520px; + width: 100%; +} +.newsletter input { + flex: 1; + padding: 14px 16px; + border: 2px solid var(--navy); + border-right: 0; + font-size: 15px; + outline: none; +} +.newsletter button { + padding: 14px 24px; + background: var(--navy); + color: #fff; + border: 0; + font-weight: 700; + cursor: pointer; +} + +/* Footer */ +.site-footer { + background: var(--navy); + color: #fff; + padding: 48px 24px 24px; +} +.site-footer .footer-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 28px; + max-width: 1200px; + margin: 0 auto; +} +.site-footer h4 { + font-family: 'Playfair Display', serif; + font-size: 18px; + margin: 0 0 14px; + color: var(--yellow); +} +.site-footer ul { list-style: none; padding: 0; margin: 0; } +.site-footer li { margin-bottom: 8px; font-size: 14px; } +.site-footer a { color: #fff; text-decoration: none; opacity: 0.9; } +.site-footer a:hover { text-decoration: underline; } +.site-footer .footer-bottom { + max-width: 1200px; + margin: 36px auto 0; + padding-top: 22px; + border-top: 1px solid rgba(255,255,255,0.18); + font-size: 13px; + color: rgba(255,255,255,0.7); + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; +} + +/* Container helper */ +.container-x { max-width: 1280px; margin: 0 auto; } + +/* Verifier hub */ +.module-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + padding: 24px; +} +.module-card { + border: 1px solid var(--line); + padding: 28px 24px; + display: flex; + flex-direction: column; + background: #fff; + transition: transform .15s ease, box-shadow .15s ease; +} +.module-card:hover { + transform: translateY(-2px); + box-shadow: 0 12px 30px rgba(5,41,98,0.10); +} +.module-card .icon { + width: 56px; + height: 56px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 28px; + margin-bottom: 18px; + background: var(--rose); +} +.module-card h3 { + font-family: 'Playfair Display', serif; + font-size: 22px; + margin: 0 0 6px; +} +.module-card .sub { + font-size: 14px; + color: var(--ink-soft); + margin-bottom: 14px; + line-height: 1.5; +} +.module-card .formats { + font-size: 12px; + color: var(--navy); + font-weight: 600; + margin-bottom: 18px; + text-transform: uppercase; + letter-spacing: 0.6px; +} +.btn-primary { + background: var(--navy); + color: #fff; + padding: 12px 22px; + border: 0; + font-weight: 700; + cursor: pointer; + text-decoration: none; + display: inline-block; + text-align: center; + border-radius: 2px; +} +.btn-primary:hover { background: var(--navy-dark); } +.btn-yellow { + background: var(--yellow); + color: var(--navy); + padding: 12px 22px; + border: 0; + font-weight: 800; + cursor: pointer; + text-decoration: none; + display: inline-block; + border-radius: 9999px; +} +.btn-outline { + background: transparent; + color: var(--navy); + padding: 12px 22px; + border: 2px solid var(--navy); + font-weight: 700; + cursor: pointer; + text-decoration: none; + display: inline-block; + border-radius: 2px; +} + +/* Drag-drop */ +.drop-zone { + border: 2px dashed #b7c0d2; + background: #f7f8fb; + padding: 60px 24px; + text-align: center; + border-radius: 8px; + transition: background .15s; +} +.drop-zone.hover { background: #eef1f7; border-color: var(--navy); } +.drop-zone .icon-up { font-size: 48px; color: var(--navy); } + +/* Progress bar */ +.progress-track { + width: 100%; + height: 12px; + background: #eef1f7; + border-radius: 9999px; + overflow: hidden; +} +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--navy), #2456b3); + width: 60%; +} + +/* Tabs */ +.tabs { + display: flex; + border-bottom: 2px solid var(--line); + gap: 4px; +} +.tab-btn { + padding: 14px 22px; + background: transparent; + border: 0; + border-bottom: 3px solid transparent; + margin-bottom: -2px; + font-weight: 600; + font-size: 15px; + cursor: pointer; + color: var(--ink-soft); + font-family: inherit; +} +.tab-btn.active { + color: var(--navy); + border-bottom-color: var(--yellow); +} +.tab-panel { display: none; padding: 28px 0; } +.tab-panel.active { display: block; } + +/* Score circle */ +.score-wrap { + display: flex; + align-items: center; + gap: 36px; + flex-wrap: wrap; +} +.score-circle { + width: 220px; + height: 220px; + position: relative; +} +.score-circle .score-text { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} +.score-circle .num { + font-family: 'Playfair Display', serif; + font-size: 60px; + font-weight: 800; + color: var(--navy); + line-height: 1; +} +.score-circle .lbl { font-size: 12px; color: var(--ink-soft); text-transform: uppercase; letter-spacing: 1px; } + +.verdict-pill { + display: inline-block; + padding: 8px 18px; + border-radius: 9999px; + font-weight: 700; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.8px; +} +.verdict-suspect { background: #fef3c7; color: #92400e; border: 1px solid #f59e0b; } +.verdict-fake { background: #fee2e2; color: #991b1b; border: 1px solid #dc2626; } +.verdict-ok { background: #dcfce7; color: #166534; border: 1px solid #16a34a; } + +.module-badge { + display: inline-block; + background: var(--navy); + color: #fff; + padding: 6px 12px; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + border-radius: 2px; +} + +/* Evidence frames */ +.evidence-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 18px; +} +.evidence-frame { + position: relative; + overflow: hidden; + border: 1px solid var(--line); +} +.evidence-frame img { width: 100%; display: block; } +.evidence-frame .overlay { + position: absolute; + border: 2px solid var(--red); + background: rgba(220,38,38,0.18); +} +.evidence-frame .label { + position: absolute; + top: 8px; + left: 8px; + background: var(--red); + color: #fff; + font-size: 11px; + font-weight: 700; + padding: 4px 8px; + text-transform: uppercase; +} + +/* Tables */ +.data-table { + width: 100%; + border-collapse: collapse; +} +.data-table th, .data-table td { + padding: 12px 14px; + text-align: left; + border-bottom: 1px solid var(--line); + font-size: 14px; +} +.data-table th { + background: #f7f8fb; + font-weight: 700; + color: var(--navy); + text-transform: uppercase; + font-size: 12px; + letter-spacing: 0.6px; +} + +/* Team cards */ +.team-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 28px; + padding: 24px; +} +.team-card { + border: 1px solid var(--line); + padding: 28px; + text-align: center; +} +.team-card .avatar { + width: 120px; + height: 120px; + border-radius: 50%; + margin: 0 auto 18px; + background: var(--rose); + display: flex; + align-items: center; + justify-content: center; + font-family: 'Playfair Display', serif; + font-size: 40px; + font-weight: 800; + color: var(--navy); +} +.team-card h3 { + font-family: 'Playfair Display', serif; + font-size: 24px; + margin: 0 0 4px; +} +.team-card .role { + color: var(--navy); + font-weight: 700; + font-size: 13px; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.6px; +} +.team-card p { font-size: 14px; color: var(--ink-soft); line-height: 1.55; } + +/* Auth forms */ +.auth-shell { + max-width: 460px; + margin: 60px auto; + padding: 36px; + border: 1px solid var(--line); + background: #fff; +} +.auth-shell h1 { + font-family: 'Playfair Display', serif; + font-size: 32px; + margin: 0 0 24px; + text-align: center; +} +.field { + margin-bottom: 16px; +} +.field label { + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; + color: var(--navy); +} +.field input { + width: 100%; + padding: 12px 14px; + border: 1px solid var(--line); + font-size: 15px; + font-family: inherit; + outline: none; +} +.field input:focus { border-color: var(--navy); } + +/* Filters bar */ +.filters { + display: flex; + gap: 8px; + flex-wrap: wrap; + padding: 16px 24px; + border-bottom: 1px solid var(--line); +} +.filter-chip { + padding: 6px 14px; + border: 1px solid var(--line); + border-radius: 9999px; + font-size: 13px; + background: #fff; + cursor: pointer; + color: var(--ink-soft); + font-family: inherit; +} +.filter-chip.active { + background: var(--navy); + color: #fff; + border-color: var(--navy); +} + +/* Verify CTA banner inside article */ +.verify-banner { + background: var(--navy); + color: #fff; + padding: 32px; + margin: 36px 0; + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + flex-wrap: wrap; +} +.verify-banner h3 { + font-family: 'Playfair Display', serif; + font-size: 24px; + margin: 0 0 6px; +} +.verify-banner p { margin: 0; opacity: 0.85; font-size: 14px; } + +/* Methodology schema */ +.method-schema { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 18px; + margin: 28px 0; +} +.method-schema .step { + border: 2px solid var(--navy); + padding: 20px; + position: relative; + background: #fff; +} +.method-schema .step .num { + position: absolute; + top: -16px; + left: 16px; + background: var(--yellow); + color: var(--navy); + font-weight: 800; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +/* ============ Module discovery additions ============ */ + +/* Header search */ +.header-search { + display: flex; + align-items: center; + background: rgba(255,255,255,0.12); + border: 1px solid rgba(255,255,255,0.25); + border-radius: 9999px; + padding: 4px 10px 4px 14px; + gap: 6px; + min-width: 280px; +} +.header-search input { + background: transparent; + border: 0; + outline: 0; + color: #fff; + font-size: 13px; + font-family: inherit; + flex: 1; + padding: 6px 0; +} +.header-search input::placeholder { color: rgba(255,255,255,0.7); } +.header-search button { + background: var(--yellow); + color: var(--navy); + border: 0; + border-radius: 9999px; + padding: 5px 14px; + font-size: 12px; + font-weight: 800; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Dropdown nav (Vérification submenu) */ +.has-dropdown { position: relative; } +.has-dropdown > a::after { + content: " ▾"; + font-size: 14px; + opacity: 0.8; +} +.dropdown-panel { + position: absolute; + top: 100%; + left: -16px; + margin-top: 12px; + background: #fff; + color: var(--ink); + width: 520px; + padding: 18px; + display: none; + grid-template-columns: 1fr 1fr; + gap: 10px; + box-shadow: 0 24px 48px rgba(0,0,0,0.18); + border-top: 4px solid var(--yellow); + z-index: 50; +} +.has-dropdown:hover .dropdown-panel, +.has-dropdown:focus-within .dropdown-panel { display: grid; } +.dropdown-panel a { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 10px; + border-radius: 4px; + color: var(--ink); + text-decoration: none; + font-family: 'Inter', sans-serif; + font-size: 14px; +} +.dropdown-panel a:hover { background: #f7f8fb; } +.dropdown-panel .dot { + width: 32px; + height: 32px; + border-radius: 8px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; +} +.dropdown-panel .dd-title { + font-weight: 700; + font-family: 'Playfair Display', serif; + font-size: 15px; + color: var(--navy); + display: block; + margin-bottom: 2px; +} +.dropdown-panel .dd-sub { font-size: 12px; color: var(--ink-soft); } +.dropdown-panel .dd-foot { + grid-column: 1 / -1; + border-top: 1px solid var(--line); + margin-top: 6px; + padding-top: 12px; + display: flex; + justify-content: space-between; + align-items: center; +} +.dropdown-panel .dd-foot a { + display: inline; + padding: 0; + color: var(--navy); + font-weight: 700; + font-size: 13px; +} + +/* Floating Action Button — quick verify from any page */ +.fab { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 60; +} +.fab-btn { + background: var(--navy); + color: #fff; + border: 0; + border-radius: 9999px; + padding: 14px 22px; + font-weight: 800; + font-size: 14px; + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + box-shadow: 0 14px 30px rgba(5,41,98,0.35); + text-transform: uppercase; + letter-spacing: 0.6px; + font-family: inherit; +} +.fab-btn:hover { background: var(--navy-dark); } +.fab-btn .pulse { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--yellow); + animation: pulse 1.6s infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(1.4); } +} +.fab-menu { + position: absolute; + bottom: 64px; + right: 0; + width: 320px; + background: #fff; + border-radius: 8px; + padding: 16px; + box-shadow: 0 30px 60px rgba(0,0,0,0.25); + display: none; + border-top: 4px solid var(--yellow); +} +.fab-menu.open { display: block; } +.fab-menu h4 { + font-family: 'Playfair Display', serif; + margin: 0 0 8px; + font-size: 16px; + color: var(--navy); +} +.fab-menu .fab-list { display: flex; flex-direction: column; gap: 4px; } +.fab-menu .fab-list a { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 4px; + text-decoration: none; + color: var(--ink); + font-size: 13px; +} +.fab-menu .fab-list a:hover { background: #f7f8fb; } +.fab-menu .fab-list .dot { + width: 26px; + height: 26px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; +} + +/* Stats / social proof band */ +.stats-band { + background: var(--navy); + color: #fff; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0; + border-bottom: 4px solid var(--yellow); +} +.stats-band .stat { + padding: 28px 24px; + border-right: 1px solid rgba(255,255,255,0.15); + text-align: center; +} +.stats-band .stat:last-child { border-right: 0; } +.stats-band .num { + font-family: 'Playfair Display', serif; + font-size: 38px; + font-weight: 800; + line-height: 1; + color: var(--yellow); +} +.stats-band .lbl { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.8px; + margin-top: 6px; + opacity: 0.9; +} + +/* ========= Verify Toolkit — editorial module showcase ========= */ +.mf-section { + background: #fff; + padding: 48px 24px 56px; + border-bottom: 1px solid var(--line); +} +.mf-head { + display: flex; + justify-content: space-between; + align-items: flex-end; + border-bottom: 2px solid var(--navy); + padding-bottom: 16px; + margin-bottom: 28px; + gap: 16px; + flex-wrap: wrap; +} +.mf-kicker { + display: inline-block; + font-size: 11px; + font-weight: 800; + letter-spacing: 1.4px; + text-transform: uppercase; + color: var(--navy); + background: var(--yellow); + padding: 3px 10px; + border-radius: 2px; +} +.mf-head h2 { + font-family: 'Playfair Display', serif; + font-size: 34px; + margin: 8px 0 0; + line-height: 1.05; + max-width: 720px; +} +.mf-all { + font-family: 'Inter', sans-serif; + font-size: 14px; + font-weight: 700; + color: var(--navy); + text-decoration: none; + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border: 2px solid var(--navy); + border-radius: 9999px; + transition: background .15s, color .15s; +} +.mf-all:hover { background: var(--navy); color: #fff; } +.mf-all span { transition: transform .15s; } +.mf-all:hover span { transform: translateX(3px); } + +.mf-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + grid-template-rows: auto auto; + gap: 16px; +} + +/* Featured card spans 2 cols + 2 rows */ +.mf-card.mf-feature { + grid-column: span 2; + grid-row: span 2; +} + +.mf-card { + position: relative; + display: flex; + flex-direction: column; + background: #fff; + text-decoration: none; + color: var(--ink); + overflow: hidden; + transition: transform .2s ease, box-shadow .2s ease; + border: 1px solid var(--line); +} +.mf-card:hover { + transform: translateY(-3px); + box-shadow: 0 18px 36px rgba(5,41,98,0.14); + border-color: var(--mc, var(--navy)); +} + +.mf-thumb { + position: relative; + aspect-ratio: 4 / 3; + overflow: hidden; + background: #f0f0f0; +} +.mf-feature .mf-thumb { + aspect-ratio: auto; + height: 100%; + flex: 1; +} +.mf-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform .4s ease; +} +/* Old scale hover removed — replaced by hover animation at end of file */ + +.mf-thumb::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(0,0,0,0) 40%, rgba(0,0,0,0.55) 100%); + z-index: 1; +} +.mf-feature .mf-thumb::before { + background: linear-gradient(180deg, rgba(0,0,0,0) 30%, rgba(0,0,0,0.85) 100%); +} + +.mf-badge { + position: absolute; + top: 12px; + left: 12px; + background: var(--mc, var(--navy)); + color: #fff; + font-family: 'Playfair Display', serif; + font-weight: 800; + font-size: 14px; + padding: 4px 12px; + letter-spacing: 1.5px; + z-index: 3; +} +.mf-pin { + position: absolute; + top: 12px; + right: 12px; + background: var(--yellow); + color: var(--navy); + font-size: 11px; + font-weight: 800; + padding: 4px 10px; + letter-spacing: 0.5px; + z-index: 3; + text-transform: uppercase; +} + +/* Featured: text overlaid on image */ +.mf-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 24px; + z-index: 2; + color: #fff; +} +.mf-overlay h3 { + font-family: 'Playfair Display', serif; + font-size: 32px; + margin: 0 0 8px; + line-height: 1.1; + color: #fff; +} +.mf-overlay p { + font-size: 15px; + line-height: 1.5; + margin: 0 0 14px; + opacity: 0.92; + max-width: 480px; +} +.mf-overlay .mf-meta { + border-top: 1px solid rgba(255,255,255,0.25); + padding-top: 12px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.6px; + display: flex; + justify-content: space-between; + opacity: 0.95; +} +.mf-overlay .arrow { + font-weight: 800; + color: var(--yellow); +} + +/* Smaller cards: text below image */ +.mf-body { + padding: 14px 16px 16px; + display: flex; + flex-direction: column; + gap: 6px; +} +.mf-body h3 { + font-family: 'Playfair Display', serif; + font-size: 17px; + margin: 0; + line-height: 1.2; + color: var(--ink); +} +.mf-body p { + font-size: 13px; + color: var(--ink-soft); + margin: 0; + line-height: 1.4; +} +.mf-body .mf-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + color: var(--ink-soft); + text-transform: uppercase; + letter-spacing: 0.6px; + border-top: 1px solid var(--line); + padding-top: 10px; + margin-top: 6px; +} +.mf-body .arrow { + color: var(--mc, var(--navy)); + font-size: 16px; + font-weight: 800; +} + +@media (max-width: 1100px) { + .mf-grid { grid-template-columns: repeat(3, 1fr); } + .mf-card.mf-feature { grid-column: span 3; grid-row: span 1; } + .mf-feature .mf-thumb { aspect-ratio: 16/7; } +} +@media (max-width: 700px) { + .mf-grid { grid-template-columns: 1fr 1fr; } + .mf-card.mf-feature { grid-column: span 2; } + .mf-head h2 { font-size: 24px; } +} + +/* Module quick-strip on homepage */ +.module-strip { + background: var(--beige); + padding: 20px 24px; + border-bottom: 1px solid var(--line); +} +.module-strip .strip-head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 14px; + flex-wrap: wrap; + gap: 8px; +} +.module-strip .strip-head h3 { + font-family: 'Playfair Display', serif; + font-size: 22px; + margin: 0; +} +.module-strip .strip-head a { color: var(--navy); font-size: 13px; font-weight: 700; text-decoration: none; } +.module-strip .strip-head a:hover { text-decoration: underline; } +.module-strip .strip-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 10px; +} +.module-strip .strip-card { + background: #fff; + padding: 14px; + border: 1px solid var(--line); + text-decoration: none; + color: var(--ink); + display: flex; + flex-direction: column; + gap: 6px; + transition: transform .15s, box-shadow .15s, border-color .15s; +} +.module-strip .strip-card:hover { + transform: translateY(-2px); + box-shadow: 0 10px 22px rgba(5,41,98,0.10); + border-color: var(--navy); +} +.module-strip .strip-card .dot { + width: 36px; + height: 36px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} +.module-strip .strip-card .code { + font-size: 11px; + font-weight: 800; + color: var(--navy); + letter-spacing: 0.6px; +} +.module-strip .strip-card .name { + font-family: 'Playfair Display', serif; + font-size: 14px; + font-weight: 700; + line-height: 1.2; +} + +/* Module Finder (verifier.html) */ +.finder { + background: linear-gradient(135deg, var(--navy) 0%, #0a3d8f 100%); + color: #fff; + padding: 36px 24px; + border-bottom: 4px solid var(--yellow); +} +.finder .container { + max-width: 1100px; + margin: 0 auto; +} +.finder .kicker { + display: inline-block; + background: var(--yellow); + color: var(--navy); + font-weight: 800; + font-size: 11px; + letter-spacing: 0.8px; + text-transform: uppercase; + padding: 4px 10px; + border-radius: 2px; + margin-bottom: 14px; +} +.finder h2 { + font-family: 'Playfair Display', serif; + font-size: 34px; + line-height: 1.1; + margin: 0 0 6px; +} +.finder .lead { font-size: 15px; opacity: 0.9; margin: 0 0 24px; max-width: 640px; } +.finder .use-cases { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} +.finder .uc { + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.18); + padding: 16px; + border-radius: 6px; + cursor: pointer; + display: flex; + gap: 12px; + align-items: flex-start; + text-decoration: none; + color: #fff; + transition: background .15s, border-color .15s; + font-family: inherit; + text-align: left; +} +.finder .uc:hover, .finder .uc.active { + background: rgba(255,229,0,0.12); + border-color: var(--yellow); +} +.finder .uc .ic { + width: 36px; + height: 36px; + border-radius: 8px; + background: rgba(255,255,255,0.12); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; +} +.finder .uc .uc-title { + font-weight: 700; + font-size: 14px; + display: block; + margin-bottom: 2px; +} +.finder .uc .uc-reco { + font-size: 12px; + opacity: 0.85; + color: var(--yellow); + font-weight: 700; +} + +/* Module card stats */ +.module-card .stats-row { + display: flex; + gap: 16px; + margin: 10px 0 14px; + font-size: 11px; + color: var(--ink-soft); + text-transform: uppercase; + letter-spacing: 0.5px; +} +.module-card .stats-row strong { + display: block; + font-family: 'Playfair Display', serif; + font-size: 18px; + color: var(--navy); + font-weight: 800; + text-transform: none; + letter-spacing: 0; +} +.module-card .reco-tag { + position: absolute; + top: -10px; + right: 14px; + background: var(--yellow); + color: var(--navy); + font-size: 10px; + font-weight: 800; + padding: 4px 10px; + border-radius: 9999px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.module-card { position: relative; } + +/* Examples row on module page */ +.examples { + background: #f7f8fb; + padding: 22px; + border: 1px solid var(--line); + margin: 18px 0; +} +.examples h4 { + font-family: 'Playfair Display', serif; + font-size: 18px; + margin: 0 0 10px; +} +.examples-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} +.examples-grid a { + display: flex; + gap: 10px; + align-items: center; + background: #fff; + border: 1px solid var(--line); + padding: 10px; + text-decoration: none; + color: var(--ink); + font-size: 13px; +} +.examples-grid a:hover { border-color: var(--navy); } +.examples-grid img { + width: 50px; + height: 50px; + object-fit: cover; + flex-shrink: 0; +} + +/* Trust band (logos / mentions) */ +.trust-band { + padding: 28px 24px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + background: #fafafa; + text-align: center; +} +.trust-band .lbl { + text-transform: uppercase; + letter-spacing: 1px; + font-size: 11px; + color: var(--ink-soft); + margin-bottom: 12px; +} +.trust-band .logos { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 36px; + font-family: 'Playfair Display', serif; + font-weight: 800; + font-size: 18px; + color: var(--ink-soft); + opacity: 0.6; +} + +/* Responsive trims */ +@media (max-width: 900px) { + .promo-strip, + .article-grid, + .module-grid, + .team-grid, + .method-schema { grid-template-columns: 1fr; } + .hero-article { grid-template-columns: 1fr; } + .hero-article h1 { font-size: 32px; } + .navy-nav .brand { font-size: 36px; } + .navy-nav .nav-links { display: none; } + .site-footer .footer-grid { grid-template-columns: repeat(2, 1fr); } + .evidence-grid { grid-template-columns: 1fr; } + .stats-band { grid-template-columns: repeat(2, 1fr); } + .module-strip .strip-grid { grid-template-columns: repeat(2, 1fr); } + .finder .use-cases { grid-template-columns: 1fr; } + .examples-grid { grid-template-columns: 1fr; } + .header-search { display: none; } + .dropdown-panel { width: 100%; left: 0; } +} + +/* ═════════════════════════════════════════════════════════ + MODERN OVERRIDES — soft corners, smooth shadows, fluid + ═════════════════════════════════════════════════════════ */ + +/* Smoother body rendering */ +html { scroll-behavior: smooth; } +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +/* Selection */ +::selection { background: var(--yellow); color: var(--navy); } + +/* Focus rings */ +*:focus-visible { + outline: 0; + box-shadow: var(--sh-ring); + border-radius: var(--r-sm); +} + +/* ---------- Sticky navbar — display:contents libère le sticky du parent ---------- */ +#site-header { display: contents; } +.navy-nav { + position: sticky; + top: 0; + z-index: 40; + transition: box-shadow var(--t); +} +.navy-nav.is-pinned { + box-shadow: 0 8px 20px -8px rgba(5, 41, 98, 0.45); +} + +/* Top bar refinement */ +.bg-navy.text-white.text-sm { + background: linear-gradient(180deg, #052962 0%, #041e4d 100%); +} + +/* ---------- Buttons modernized ---------- */ +.btn-primary, +.btn-yellow, +.btn-outline { + border-radius: var(--r); + transition: transform var(--t-fast), box-shadow var(--t), background var(--t), color var(--t); + letter-spacing: 0.2px; +} +.btn-primary { + box-shadow: 0 4px 12px -4px rgba(5, 41, 98, 0.4); +} +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 8px 20px -6px rgba(5, 41, 98, 0.5); +} +.btn-primary:active { transform: translateY(0); } + +.btn-yellow { + box-shadow: 0 4px 12px -4px rgba(255, 229, 0, 0.6); +} +.btn-yellow:hover { + transform: translateY(-1px); + box-shadow: 0 8px 20px -6px rgba(255, 229, 0, 0.7); +} + +.btn-outline:hover { + background: var(--navy); + color: #fff; + transform: translateY(-1px); +} + +/* ---------- Cards & containers softened ---------- */ +.module-card, +.team-card, +.article-card, +.mf-card, +.examples-grid a, +.module-strip .strip-card, +.auth-shell { + border-radius: var(--r-lg); + transition: transform var(--t), box-shadow var(--t), border-color var(--t); +} +.article-card { border-bottom: 0; padding-bottom: 0; } +.article-card { background: #fff; padding: 0; } +.article-card img:first-of-type { + border-radius: var(--r-lg) var(--r-lg) 0 0; +} +.article-card .cat-tag, +.article-card h3, +.article-card p, +.article-card .meta { + padding-left: 4px; + padding-right: 4px; +} +.article-card:hover { transform: translateY(-3px); box-shadow: var(--sh-lg); } + +.module-card { border-radius: var(--r-lg); } +.module-card:hover { box-shadow: var(--sh-lg); } +.module-card .icon { + border-radius: var(--r); +} + +/* ---------- Promo cards modernized ---------- */ +.promo-card { transition: background var(--t); } +.promo-card:hover { background: rgba(0,0,0,0.02); } +.promo-card .thumb { + box-shadow: 0 6px 16px -4px rgba(0,0,0,0.18); +} +.promo-card .label { + border-radius: var(--r-pill); +} + +/* ---------- Forms (modern fields) ---------- */ +.field { position: relative; } +.field input, +.newsletter input, +.drop-zone input[type="url"] { + border-radius: var(--r); + transition: border-color var(--t), box-shadow var(--t), background var(--t); + background: #fff; +} +.field input { + padding: 14px 16px; + font-size: 15px; + border: 1px solid var(--line); +} +.field input:focus, +.newsletter input:focus { + border-color: var(--navy); + box-shadow: var(--sh-ring); +} +.newsletter input { border-radius: var(--r) 0 0 var(--r); } +.newsletter button { border-radius: 0 var(--r) var(--r) 0; transition: background var(--t); } +.newsletter button:hover { background: var(--navy-dark); } + +/* Drop zone refined */ +.drop-zone { + border-radius: var(--r-xl); + background: linear-gradient(135deg, #fafbfd 0%, #f0f4fa 100%); + border-width: 2px; + border-color: #cbd5e7; + transition: border-color var(--t), background var(--t), transform var(--t); +} +.drop-zone:hover { + border-color: var(--navy); + background: linear-gradient(135deg, #f5f8fc 0%, #e8eef8 100%); +} +.drop-zone .icon-up { + display: inline-flex; + width: 72px; + height: 72px; + border-radius: 50%; + background: var(--navy); + color: #fff; + align-items: center; + justify-content: center; + font-size: 32px; + box-shadow: var(--sh-md); + animation: float 3s var(--ease) infinite; +} +@keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } +} + +/* ---------- Tags / chips softened ---------- */ +.cat-tag { + border-radius: var(--r-pill); + padding: 4px 12px !important; +} +.subnav .chip, +.filter-chip { + border-radius: var(--r-pill); + transition: background var(--t), color var(--t), border-color var(--t); +} + +/* ---------- Verdict pills get a subtle gradient ---------- */ +.verdict-suspect { + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); +} +.verdict-fake { + background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); +} +.verdict-ok { + background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%); +} + +/* ---------- Newsletter polished ---------- */ +.newsletter { + background: linear-gradient(135deg, var(--beige) 0%, #fff 100%); + position: relative; + overflow: hidden; +} +.newsletter::before { + content: ""; + position: absolute; + top: -120px; + right: -120px; + width: 280px; + height: 280px; + background: radial-gradient(circle, rgba(255, 229, 0, 0.25) 0%, transparent 70%); + pointer-events: none; +} + +/* ---------- Hero article: modern grid ---------- */ +.hero-article { gap: 36px; } +.hero-article img { border-radius: var(--r-lg); box-shadow: var(--sh-lg); } +.hero-article h1 { letter-spacing: -0.5px; } + +/* ---------- Verify banner with glow ---------- */ +.verify-banner { + border-radius: var(--r-lg); + background: linear-gradient(135deg, #052962 0%, #0a3d8f 60%, #1e4faf 100%); + position: relative; + overflow: hidden; +} +.verify-banner::before { + content: ""; + position: absolute; + top: -50%; + right: -10%; + width: 400px; + height: 400px; + background: radial-gradient(circle, rgba(255, 229, 0, 0.18) 0%, transparent 60%); + pointer-events: none; +} + +/* ---------- Tabs softened ---------- */ +.tab-btn { + border-radius: var(--r) var(--r) 0 0; + transition: background var(--t), color var(--t); +} +.tab-btn:hover { background: var(--bg-soft); color: var(--navy); } + +/* ---------- Tables ---------- */ +.data-table { border-radius: var(--r); overflow: hidden; } +.data-table tr { transition: background var(--t-fast); } +.data-table tbody tr:hover { background: var(--bg-soft); } + +/* ---------- Header search refined ---------- */ +.header-search { + border-radius: var(--r-pill); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} +.header-search button { border-radius: var(--r-pill); } + +/* ---------- Dropdown / FAB shadows softened ---------- */ +.dropdown-panel, +.fab-menu { + border-radius: var(--r-lg); + box-shadow: var(--sh-xl); +} +.dropdown-panel a, +.fab-menu .fab-list a { + border-radius: var(--r-sm); +} +.fab-btn { border-radius: var(--r-pill); } + +/* ---------- Progress bar smoother ---------- */ +.progress-track { border-radius: var(--r-pill); } +.progress-fill { + border-radius: var(--r-pill); + background: linear-gradient(90deg, #052962 0%, #2456b3 50%, #1e4faf 100%); + background-size: 200% 100%; + animation: shimmer 2s linear infinite; +} +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* ---------- Stats band: animated entry ---------- */ +.stats-band .stat { transition: background var(--t); } +.stats-band .stat:hover { + background: rgba(255, 229, 0, 0.06); +} + +/* ---------- Module Finder polish ---------- */ +.finder { border-radius: 0; position: relative; overflow: hidden; } +.finder::before { + content: ""; + position: absolute; + top: -200px; + right: -200px; + width: 600px; + height: 600px; + background: radial-gradient(circle, rgba(255,229,0,0.10) 0%, transparent 60%); + pointer-events: none; +} +.finder .uc { + border-radius: var(--r); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + transition: background var(--t), border-color var(--t), transform var(--t); +} +.finder .uc:hover { transform: translateY(-2px); } + +/* ---------- mf cards: smoother corners on radius ---------- */ +.mf-card { border-radius: var(--r-lg); } +.mf-thumb img { transition: transform var(--t-slow); } +.mf-badge { border-radius: var(--r-sm); } +.mf-pin { border-radius: var(--r-pill); } + +/* ---------- FAB modernized with gradient ---------- */ +.fab-btn { + background: linear-gradient(135deg, #052962 0%, #1e4faf 100%); + box-shadow: 0 14px 30px -8px rgba(5, 41, 98, 0.5); +} +.fab-btn:hover { + background: linear-gradient(135deg, #041e4d 0%, #052962 100%); + transform: translateY(-2px); + box-shadow: 0 18px 36px -10px rgba(5, 41, 98, 0.6); +} + +/* ---------- Auth split layout ---------- */ +.auth-split { + display: grid; + grid-template-columns: 1fr 1fr; + min-height: calc(100vh - 80px); + background: #fff; +} +.auth-split .auth-side { + background: linear-gradient(135deg, #052962 0%, #1e4faf 100%); + color: #fff; + padding: 60px 48px; + display: flex; + flex-direction: column; + justify-content: space-between; + position: relative; + overflow: hidden; +} +.auth-split .auth-side::before { + content: ""; + position: absolute; + top: -120px; + left: -120px; + width: 360px; + height: 360px; + background: radial-gradient(circle, rgba(255, 229, 0, 0.18) 0%, transparent 60%); + pointer-events: none; +} +.auth-split .auth-side::after { + content: ""; + position: absolute; + bottom: -180px; + right: -120px; + width: 420px; + height: 420px; + background: radial-gradient(circle, rgba(30, 79, 175, 0.5) 0%, transparent 60%); + pointer-events: none; +} +.auth-split .auth-side > * { position: relative; z-index: 1; } +.auth-split .auth-side h2 { + font-family: 'Playfair Display', serif; + font-size: 44px; + line-height: 1.05; + margin: 0 0 16px; + letter-spacing: -1px; +} +.auth-split .auth-side .lede { + font-size: 17px; + opacity: 0.85; + line-height: 1.55; + max-width: 420px; +} +.auth-split .auth-side .auth-features { + list-style: none; + padding: 0; + margin: 32px 0 0; + display: flex; + flex-direction: column; + gap: 14px; +} +.auth-split .auth-side .auth-features li { + display: flex; + gap: 12px; + align-items: flex-start; + font-size: 14px; + opacity: 0.92; + line-height: 1.5; +} +.auth-split .auth-side .auth-features .dot { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--yellow); + color: var(--navy); + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 13px; + flex-shrink: 0; +} +.auth-split .auth-side .testimonial { + border-top: 1px solid rgba(255,255,255,0.18); + padding-top: 24px; + font-style: italic; + font-size: 15px; + line-height: 1.6; + opacity: 0.92; +} +.auth-split .auth-side .testimonial cite { + display: block; + font-style: normal; + font-size: 13px; + opacity: 0.7; + margin-top: 8px; +} + +.auth-split .auth-form-side { + padding: 60px 48px; + display: flex; + align-items: center; + justify-content: center; +} +.auth-split .auth-card { + width: 100%; + max-width: 460px; +} +.auth-split .auth-card h1 { + font-family: 'Playfair Display', serif; + font-size: 36px; + margin: 0 0 8px; + letter-spacing: -0.5px; +} +.auth-split .auth-card .sub { + color: var(--ink-soft); + margin: 0 0 28px; + font-size: 15px; +} + +@media (max-width: 900px) { + .auth-split { grid-template-columns: 1fr; min-height: auto; } + .auth-split .auth-side { padding: 40px 28px; } + .auth-split .auth-side h2 { font-size: 32px; } + .auth-split .auth-form-side { padding: 40px 28px; } +} + +/* ---------- Scroll reveal ---------- */ +[data-reveal] { + opacity: 0; + transform: translateY(20px); + transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out); +} +[data-reveal].in { + opacity: 1; + transform: translateY(0); +} + +/* Stagger children */ +[data-reveal-stagger] > * { + opacity: 0; + transform: translateY(16px); + transition: opacity 0.5s var(--ease-out), transform 0.5s var(--ease-out); +} +[data-reveal-stagger].in > * { + opacity: 1; + transform: translateY(0); +} +[data-reveal-stagger].in > *:nth-child(1) { transition-delay: 0.05s; } +[data-reveal-stagger].in > *:nth-child(2) { transition-delay: 0.10s; } +[data-reveal-stagger].in > *:nth-child(3) { transition-delay: 0.15s; } +[data-reveal-stagger].in > *:nth-child(4) { transition-delay: 0.20s; } +[data-reveal-stagger].in > *:nth-child(5) { transition-delay: 0.25s; } +[data-reveal-stagger].in > *:nth-child(6) { transition-delay: 0.30s; } + +/* ---------- SVG icon system ---------- */ +.icon-svg { + width: 1em; + height: 1em; + display: inline-block; + vertical-align: middle; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} +.icon-tile { + width: 44px; + height: 44px; + border-radius: var(--r); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 22px; + flex-shrink: 0; +} +.icon-tile.sm { width: 32px; height: 32px; font-size: 16px; border-radius: var(--r-sm); } + +/* ═════════════════════════════════════════════════════════ + HOVER FINAL — image slides left + blurs in place + arrow + ═════════════════════════════════════════════════════════ */ + +/* The grid gets perspective so cards can tilt subtly */ +.mf-grid { + perspective: 1600px; +} + +.mf-card { + position: relative !important; + overflow: hidden !important; + isolation: isolate; + background: #fff !important; + transform-style: preserve-3d !important; + transition: transform 900ms cubic-bezier(0.16, 1, 0.3, 1), + box-shadow 900ms cubic-bezier(0.16, 1, 0.3, 1), + outline 0.7s ease 0.4s !important; + outline: 0px solid transparent !important; + outline-offset: 0px !important; +} +.mf-card:hover { + /* Lifts, tilts in 3D, glows with module color, gets a yellow halo border */ + transform: translateY(-14px) rotateX(4deg) !important; + box-shadow: 0 60px 120px -22px var(--mc, var(--navy)), + 0 30px 60px -22px rgba(0, 0, 0, 0.35) !important; + outline: 2px solid rgba(255, 229, 0, 0.9) !important; + outline-offset: 6px !important; +} + +/* ::before = blurred copy of the same image, fills the WHOLE card. + Slowly zooms (Ken Burns) and intensifies its blur during hover. */ +.mf-card::before { + content: "" !important; + position: absolute !important; + inset: -8% !important; + background-image: var(--bg-img) !important; + background-size: cover !important; + background-position: center !important; + filter: blur(16px) brightness(0.55) saturate(1.2) !important; + transform: scale(1.08) !important; + z-index: 1 !important; + pointer-events: none !important; + transition: transform 1.4s cubic-bezier(0.16, 1, 0.3, 1), + filter 0.8s ease !important; +} +.mf-card:hover::before { + transform: scale(1.18) !important; + filter: blur(22px) brightness(0.50) saturate(1.35) !important; +} + +/* Thumb keeps its image visible; bg transparent so blur shows when image moves */ +.mf-card .mf-thumb { + background: transparent !important; + position: relative !important; + z-index: 3 !important; +} + +/* Body has white bg in static — covers the blur. Fades + rises slightly on hover. + Stagger: body fades a bit AFTER the image starts moving, more elegant. */ +.mf-card .mf-body { + background: #fff !important; + position: relative !important; + z-index: 3 !important; + transition: opacity 600ms cubic-bezier(0.4, 0, 0.2, 1) 100ms, + transform 600ms cubic-bezier(0.4, 0, 0.2, 1) 100ms !important; +} +.mf-card:hover .mf-body { + opacity: 0 !important; + transform: translateY(-8px) !important; +} + +/* THE SLIDE — cinematic 1.4s, multi-axis: translate + scale + 3D rotation. + The image leaves stage like a sliding panel turning slightly into the depth. */ +.mf-card .mf-thumb img { + transition: transform 1400ms cubic-bezier(0.86, 0, 0.07, 1), + opacity 1100ms cubic-bezier(0.4, 0, 0.2, 1), + filter 1100ms ease !important; + transform: translateX(0) scale(1) rotateY(0deg) !important; + opacity: 1 !important; + filter: blur(0) !important; + transform-origin: right center !important; +} +.mf-card:hover .mf-thumb img { + /* Slides left, scales up subtly, AND rotates -8° on Y axis (opens like a door) */ + transform: translateX(-118%) scale(1.10) rotateY(-8deg) !important; + opacity: 0.85 !important; + filter: blur(3px) !important; +} + +/* Featured overlay text — same elegant fade as body */ +.mf-card.mf-feature .mf-overlay { + transition: opacity 600ms cubic-bezier(0.4, 0, 0.2, 1) 100ms, + transform 600ms cubic-bezier(0.4, 0, 0.2, 1) 100ms !important; +} +.mf-card.mf-feature:hover .mf-overlay { + opacity: 0 !important; + transform: translateY(-8px) !important; +} + +/* Featured card overlay text fades too */ +.mf-card.mf-feature .mf-overlay { + transition: opacity 400ms ease !important; +} +.mf-card.mf-feature:hover .mf-overlay { + opacity: 0 !important; +} + +/* Module badges stay visible (they contextualize the hover state) */ +.mf-card .mf-badge, +.mf-card .mf-pin { + z-index: 50 !important; + transition: transform 400ms ease !important; +} +.mf-card:hover .mf-badge { + transform: scale(1.1) !important; +} + +/* Big yellow arrow — appears after the slide is well underway, + with a horizontal yellow underline that draws in beneath it. */ +.mf-card::after { + content: "→" !important; + position: absolute !important; + inset: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + font-family: 'Playfair Display', serif !important; + font-weight: 800 !important; + font-size: 130px !important; + line-height: 1 !important; + color: #ffe500 !important; + opacity: 0 !important; + transform: translateX(140px) scale(0.4) rotate(-15deg) !important; + transition: opacity 700ms ease, + transform 950ms cubic-bezier(0.34, 1.56, 0.64, 1), + text-shadow 700ms ease !important; + z-index: 100 !important; + pointer-events: none !important; + text-shadow: 0 8px 24px rgba(0, 0, 0, 0.45) !important; + letter-spacing: -4px !important; + background-image: + linear-gradient(90deg, transparent, rgba(255,229,0,0) 50%, transparent), + linear-gradient(transparent calc(50% + 70px), var(--yellow) calc(50% + 70px), var(--yellow) calc(50% + 74px), transparent calc(50% + 74px)) !important; + background-size: 0 100%, 0 100% !important; + background-position: center, center !important; + background-repeat: no-repeat, no-repeat !important; +} +.mf-card:hover::after { + opacity: 1 !important; + transform: translateX(0) scale(1) rotate(0) !important; + text-shadow: 0 16px 60px rgba(255, 229, 0, 0.5), + 0 16px 60px rgba(0, 0, 0, 0.6) !important; + background-size: 0 100%, 35% 100% !important; + transition: opacity 700ms ease 0.55s, + transform 950ms cubic-bezier(0.34, 1.56, 0.64, 1) 0.55s, + text-shadow 700ms ease 0.55s, + background-size 700ms cubic-bezier(0.65, 0, 0.35, 1) 1.05s !important; +} +.mf-card.mf-feature::after { font-size: 240px !important; } + +/* Reduced motion fallback */ +@media (prefers-reduced-motion: reduce) { + .mf-card *, .mf-card::after { + transition: none !important; + } + .mf-card:hover .mf-thumb img { + transform: none !important; + filter: blur(8px) brightness(0.5) !important; + } +} + +/* ============================================================ + Partner fact-checks band (Tunifact / iCheck aggregation) + ============================================================ */ +.partners-section { + padding: 56px 24px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + background: var(--bg-soft); +} +.partners-section .pn-head { + max-width: 1280px; + margin: 0 auto 28px; + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + flex-wrap: wrap; +} +.partners-section .pn-kicker { + display: inline-block; + font-size: 12px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + color: var(--navy); + background: var(--yellow); + padding: 4px 10px; + border-radius: 4px; + margin-bottom: 10px; +} +.partners-section h2 { + font-family: 'Playfair Display', serif; + font-size: 34px; + line-height: 1.1; + margin: 0; + color: var(--ink); +} +.partners-section .pn-sub { + font-size: 15px; + color: var(--ink-soft); + margin-top: 8px; + max-width: 640px; +} +.partners-section .pn-partners { + display: flex; + align-items: center; + gap: 16px; + font-size: 13px; + color: var(--ink-soft); +} +.partners-section .pn-partners strong { color: var(--ink); } + +.pn-grid { + max-width: 1280px; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 22px; +} +@media (max-width: 960px) { + .pn-grid { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 640px) { + .pn-grid { grid-template-columns: 1fr; } +} +.pn-card { + display: flex; + flex-direction: column; + background: #fff; + border: 1px solid var(--line); + border-radius: var(--r-lg); + overflow: hidden; + text-decoration: none; + color: inherit; + transition: transform var(--t), box-shadow var(--t), border-color var(--t); +} +.pn-card:hover { + transform: translateY(-3px); + box-shadow: var(--sh-lg); + border-color: var(--navy-glow); +} +.pn-thumb { + position: relative; + aspect-ratio: 16 / 10; + overflow: hidden; + background: #eef1f7; +} +.pn-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform var(--t-slow); +} +.pn-card:hover .pn-thumb img { transform: scale(1.04); } + +.pn-verdict { + position: absolute; + top: 12px; + left: 12px; + padding: 4px 10px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.6px; + text-transform: uppercase; + border-radius: 4px; + border: 1px solid; +} +.pv-ok { background: #dcfce7; color: #166534; border-color: #16a34a; } +.pv-suspect { background: #fef3c7; color: #92400e; border-color: #f59e0b; } +.pv-fake { background: #fee2e2; color: #991b1b; border-color: #dc2626; } + +.pn-body { padding: 18px 18px 20px; display: flex; flex-direction: column; gap: 8px; flex: 1; } +.pn-source { + align-self: flex-start; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.6px; + text-transform: uppercase; + padding: 3px 8px; + border-radius: 3px; +} +.pn-body h3 { + font-family: 'Playfair Display', serif; + font-size: 19px; + line-height: 1.25; + font-weight: 700; + margin: 4px 0 0; + color: var(--ink); +} +.pn-body p { + font-size: 14px; + line-height: 1.55; + color: var(--ink-soft); + margin: 0; +} +.pn-meta { + margin-top: auto; + padding-top: 12px; + border-top: 1px solid var(--line-soft); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: var(--ink-faint); +} +.pn-link { font-weight: 600; color: var(--navy); } +.pn-card:hover .pn-link { text-decoration: underline; } + +.pn-skeleton { + max-width: 1280px; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 22px; +} +.pn-skel-card { + height: 360px; + border-radius: var(--r-lg); + background: linear-gradient(90deg, #eef1f7 0%, #f6f8fb 50%, #eef1f7 100%); + background-size: 200% 100%; + animation: pn-shimmer 1.4s ease-in-out infinite; +} +@keyframes pn-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +.pn-empty { + max-width: 720px; + margin: 0 auto; + text-align: center; + padding: 40px; + background: #fff; + border: 1px dashed var(--line); + border-radius: var(--r-lg); + color: var(--ink-soft); +} + +/* ============================================================ + Dashboard (statistics) + ============================================================ */ +.dash-hero { + background: var(--navy); + color: #fff; + padding: 48px 24px 36px; +} +.dash-hero .container-x { display: flex; justify-content: space-between; align-items: end; gap: 24px; flex-wrap: wrap; } +.dash-hero h1 { font-family: 'Playfair Display', serif; font-size: 42px; margin: 0 0 8px; } +.dash-hero p { opacity: 0.85; max-width: 620px; margin: 0; line-height: 1.55; } +.dash-hero .dash-period { + display: inline-flex; + gap: 6px; + background: rgba(255,255,255,0.10); + padding: 4px; + border-radius: var(--r-pill); +} +.dash-hero .dash-period button { + background: transparent; + color: #fff; + border: 0; + padding: 6px 14px; + font-weight: 600; + font-size: 13px; + border-radius: var(--r-pill); + cursor: pointer; +} +.dash-hero .dash-period button.active { + background: var(--yellow); + color: var(--navy); +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 18px; + margin-top: -28px; + padding: 0 24px; +} +@media (max-width: 960px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } } +.kpi-card { + background: #fff; + border: 1px solid var(--line); + border-radius: var(--r-lg); + padding: 20px; + box-shadow: var(--sh-md); + display: flex; + flex-direction: column; + gap: 6px; +} +.kpi-card .kpi-label { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--ink-faint); } +.kpi-card .kpi-value { font-family: 'Playfair Display', serif; font-size: 38px; line-height: 1; color: var(--navy); } +.kpi-card .kpi-delta { font-size: 13px; font-weight: 600; } +.kpi-up { color: #16a34a; } +.kpi-down { color: #dc2626; } + +.dash-row { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 22px; + padding: 36px 24px; + max-width: 1280px; + margin: 0 auto; +} +@media (max-width: 960px) { .dash-row { grid-template-columns: 1fr; } } +.dash-card { + background: #fff; + border: 1px solid var(--line); + border-radius: var(--r-lg); + padding: 22px; +} +.dash-card h3 { + font-family: 'Playfair Display', serif; + font-size: 20px; + margin: 0 0 4px; + color: var(--ink); +} +.dash-card .dash-card-sub { font-size: 13px; color: var(--ink-faint); margin: 0 0 16px; } + +.dash-bars { display: flex; flex-direction: column; gap: 12px; } +.dash-bar { display: grid; grid-template-columns: 90px 1fr 56px; gap: 10px; align-items: center; font-size: 13px; } +.dash-bar .bar-label { font-weight: 600; color: var(--ink); } +.dash-bar .bar-track { background: #eef1f7; height: 10px; border-radius: 999px; overflow: hidden; } +.dash-bar .bar-fill { height: 100%; border-radius: 999px; transition: width var(--t-slow); } +.dash-bar .bar-value { font-weight: 700; color: var(--navy); text-align: right; } + +.dash-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; } +.dash-list li { display: flex; justify-content: space-between; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--r); background: #fff; font-size: 14px; } +.dash-list li strong { font-weight: 700; color: var(--navy); } + +/* ============================================================ + Real or Fake (news verification) + ============================================================ */ +.rof-hero { + background: linear-gradient(135deg, #052962 0%, #0a3d8c 100%); + color: #fff; + padding: 64px 24px 48px; +} +.rof-hero .container-x { max-width: 920px; text-align: center; } +.rof-hero .kicker { + display: inline-block; + font-size: 12px; + font-weight: 800; + letter-spacing: 2px; + text-transform: uppercase; + background: var(--yellow); + color: var(--navy); + padding: 4px 12px; + border-radius: 4px; + margin-bottom: 14px; +} +.rof-hero h1 { font-family: 'Playfair Display', serif; font-size: 52px; line-height: 1.05; margin: 0 0 12px; } +.rof-hero p { font-size: 18px; opacity: 0.9; max-width: 700px; margin: 0 auto; line-height: 1.55; } + +.rof-form { + max-width: 760px; + margin: -28px auto 0; + background: #fff; + border-radius: var(--r-xl); + padding: 22px; + box-shadow: var(--sh-xl); + display: flex; + flex-direction: column; + gap: 12px; + position: relative; + z-index: 2; +} +.rof-form label { font-size: 12px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; color: var(--ink-faint); } +.rof-form textarea, .rof-form input[type="url"] { + border: 1px solid var(--line); + border-radius: var(--r); + padding: 14px 16px; + font-size: 15px; + font-family: inherit; + resize: vertical; + min-height: 90px; + width: 100%; + outline: none; + transition: border-color var(--t), box-shadow var(--t); +} +.rof-form textarea:focus, .rof-form input[type="url"]:focus { + border-color: var(--navy); + box-shadow: var(--sh-ring); +} +.rof-form .rof-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; } +.rof-form .rof-tabs { display: flex; gap: 4px; background: #eef1f7; padding: 4px; border-radius: var(--r-pill); } +.rof-form .rof-tabs button { + border: 0; background: transparent; padding: 6px 14px; font-size: 13px; font-weight: 600; + border-radius: var(--r-pill); cursor: pointer; color: var(--ink-soft); +} +.rof-form .rof-tabs button.active { background: var(--navy); color: #fff; } + +.rof-result { + max-width: 760px; + margin: 28px auto 0; + background: #fff; + border: 1px solid var(--line); + border-radius: var(--r-xl); + overflow: hidden; + display: none; +} +.rof-result.is-shown { display: block; animation: fadeUp 0.4s var(--ease-out); } +@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } +.rof-result .rof-banner { + padding: 18px 22px; + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 800; + font-size: 16px; + letter-spacing: 0.4px; + text-transform: uppercase; +} +.rof-banner.real { background: #dcfce7; color: #166534; } +.rof-banner.fake { background: #fee2e2; color: #991b1b; } +.rof-banner.suspect { background: #fef3c7; color: #92400e; } +.rof-result .rof-body { padding: 22px; } +.rof-result .rof-score-row { display: flex; align-items: center; gap: 18px; margin-bottom: 16px; } +.rof-result .rof-score-bar { flex: 1; background: #eef1f7; border-radius: 999px; height: 12px; overflow: hidden; } +.rof-result .rof-score-fill { height: 100%; border-radius: 999px; transition: width 0.8s var(--ease-out); } +.rof-result h4 { font-family: 'Playfair Display', serif; font-size: 18px; margin: 18px 0 8px; } +.rof-result ul.rof-signals { list-style: none; padding: 0; margin: 0; } +.rof-result ul.rof-signals li { padding: 8px 0; border-top: 1px solid var(--line-soft); display: flex; gap: 10px; font-size: 14px; } +.rof-result ul.rof-signals li:first-child { border-top: 0; } +.rof-result .sig-tag { + font-size: 10px; font-weight: 800; letter-spacing: 0.6px; text-transform: uppercase; + padding: 2px 8px; border-radius: 3px; flex-shrink: 0; align-self: start; margin-top: 2px; +} +.sig-tag.ok { background: #dcfce7; color: #166534; } +.sig-tag.warn { background: #fef3c7; color: #92400e; } +.sig-tag.bad { background: #fee2e2; color: #991b1b; } + +/* ============================================================ + Mission page (notre but) + ============================================================ */ +.mis-hero { + position: relative; + padding: 96px 24px 72px; + background: var(--navy); + color: #fff; + overflow: hidden; +} +.mis-hero::before { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 20% 0%, rgba(255,229,0,0.18), transparent 40%), + radial-gradient(circle at 90% 100%, rgba(255,255,255,0.08), transparent 50%); + pointer-events: none; +} +.mis-hero .container-x { position: relative; max-width: 920px; } +.mis-hero .mis-kicker { + display: inline-block; + font-size: 12px; + font-weight: 800; + letter-spacing: 2px; + text-transform: uppercase; + background: var(--yellow); + color: var(--navy); + padding: 4px 12px; + border-radius: 4px; + margin-bottom: 18px; +} +.mis-hero h1 { + font-family: 'Playfair Display', serif; + font-size: 64px; + line-height: 1.02; + margin: 0 0 18px; + font-weight: 800; +} +.mis-hero h1 em { font-style: italic; color: var(--yellow); } +.mis-hero .mis-lead { + font-size: 22px; + line-height: 1.45; + opacity: 0.92; + max-width: 720px; +} + +.mis-section { + max-width: 920px; + margin: 0 auto; + padding: 64px 24px; +} +.mis-section h2 { + font-family: 'Playfair Display', serif; + font-size: 34px; + margin: 0 0 18px; + color: var(--ink); +} +.mis-section p { + font-size: 17px; + line-height: 1.7; + color: var(--ink-soft); + margin: 0 0 14px; +} +.mis-divider { + height: 1px; + background: var(--line); + margin: 0 24px; + max-width: 1280px; +} + +.mis-pillars { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 22px; + margin-top: 28px; +} +@media (max-width: 880px) { .mis-pillars { grid-template-columns: 1fr; } } +.mis-pillar { + background: #fff; + border: 1px solid var(--line); + border-left: 4px solid var(--navy); + border-radius: var(--r-lg); + padding: 22px; +} +.mis-pillar:nth-child(1) { border-left-color: var(--red); } +.mis-pillar:nth-child(2) { border-left-color: var(--orange); } +.mis-pillar:nth-child(3) { border-left-color: var(--green); } +.mis-pillar h3 { + font-family: 'Playfair Display', serif; + font-size: 22px; + margin: 6px 0 10px; + color: var(--ink); +} +.mis-pillar p { font-size: 15px; line-height: 1.6; color: var(--ink-soft); margin: 0; } +.mis-pillar .mis-num { + font-family: 'Playfair Display', serif; + font-size: 13px; + font-weight: 800; + color: var(--navy); + letter-spacing: 1.4px; +} + +.mis-quote { + background: var(--beige); + border-left: 5px solid var(--yellow); + padding: 28px 28px 28px 24px; + border-radius: 0 var(--r-lg) var(--r-lg) 0; + margin: 28px 0; +} +.mis-quote blockquote { + font-family: 'Playfair Display', serif; + font-size: 24px; + line-height: 1.4; + color: var(--ink); + font-style: italic; + margin: 0 0 10px; +} +.mis-quote cite { font-size: 14px; color: var(--ink-soft); font-style: normal; } + +.mis-stats { + background: var(--navy); + color: #fff; + padding: 56px 24px; + margin-top: 0; +} +.mis-stats .container-x { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 18px; + text-align: center; +} +@media (max-width: 720px) { .mis-stats .container-x { grid-template-columns: repeat(2, 1fr); } } +.mis-stats .num { + font-family: 'Playfair Display', serif; + font-size: 48px; + line-height: 1; + color: var(--yellow); + font-weight: 800; +} +.mis-stats .lbl { font-size: 13px; opacity: 0.85; margin-top: 6px; letter-spacing: 0.4px; } + +/* ============================================================ + Fact-check archive (Tunifact-style listing) + detail + ============================================================ */ +.fc-page { background: #fafafb; min-height: 60vh; } + +.fc-toolbar { + background: #fff; + border-bottom: 1px solid var(--line); + padding: 18px 24px; +} +.fc-toolbar .container-x { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + flex-wrap: wrap; +} +.fc-toolbar h1 { + font-family: 'Playfair Display', serif; + font-size: 32px; + margin: 0; + color: var(--ink); +} +.fc-toolbar .fc-sub { font-size: 14px; color: var(--ink-soft); margin: 4px 0 0; } +.fc-toolbar .fc-search { + display: flex; + align-items: center; + gap: 0; + border: 1px solid var(--line); + border-radius: var(--r-pill); + padding: 4px 4px 4px 16px; + background: #fff; + min-width: 320px; +} +.fc-toolbar .fc-search input { + border: 0; + outline: none; + flex: 1; + font-size: 14px; + background: transparent; +} +.fc-toolbar .fc-search button { + background: var(--navy); + color: #fff; + border: 0; + border-radius: var(--r-pill); + padding: 8px 16px; + font-weight: 600; + font-size: 13px; + cursor: pointer; +} + +.fc-tabs { + display: flex; + gap: 8px; + padding: 14px 24px; + background: #fff; + border-bottom: 1px solid var(--line); + overflow-x: auto; + scrollbar-width: none; +} +.fc-tabs::-webkit-scrollbar { display: none; } +.fc-tab { + flex-shrink: 0; + padding: 8px 16px; + font-size: 13px; + font-weight: 600; + background: #f4f5f8; + color: var(--ink-soft); + border-radius: var(--r-pill); + cursor: pointer; + border: 1px solid transparent; + display: inline-flex; + align-items: center; + gap: 6px; + text-decoration: none; + transition: all var(--t-fast); +} +.fc-tab:hover { background: #e9ebf1; color: var(--ink); } +.fc-tab .fc-tab-count { + background: rgba(0,0,0,0.08); + padding: 1px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; +} +.fc-tab.active { + background: var(--navy); + color: #fff; +} +.fc-tab.active .fc-tab-count { background: rgba(255,255,255,0.20); } +.fc-tab.fake.active { background: #dc2626; } +.fc-tab.real.active { background: #16a34a; } +.fc-tab.unver.active { background: #92400e; } + +.fc-layout { + display: grid; + grid-template-columns: 1fr 320px; + gap: 28px; + max-width: 1280px; + margin: 0 auto; + padding: 28px 24px 48px; +} +@media (max-width: 960px) { .fc-layout { grid-template-columns: 1fr; } } + +.fc-feed { + display: flex; + flex-direction: column; + gap: 18px; +} +.fc-card { + display: grid; + grid-template-columns: 280px 1fr; + background: #fff; + border: 1px solid var(--line); + border-radius: var(--r-lg); + overflow: hidden; + text-decoration: none; + color: inherit; + transition: transform var(--t), box-shadow var(--t), border-color var(--t); +} +@media (max-width: 720px) { + .fc-card { grid-template-columns: 1fr; } +} +.fc-card:hover { + transform: translateY(-2px); + box-shadow: var(--sh-lg); + border-color: var(--navy-glow); +} +.fc-card-thumb { + position: relative; + aspect-ratio: 4 / 3; + background: #eef1f7; + overflow: hidden; +} +.fc-card-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform var(--t-slow); +} +.fc-card:hover .fc-card-thumb img { transform: scale(1.05); } +.fc-verdict-badge { + position: absolute; + top: 14px; + left: 14px; + padding: 6px 14px; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.6px; + text-transform: uppercase; + border-radius: 4px; + display: inline-flex; + align-items: center; + gap: 6px; + color: #fff; + box-shadow: 0 2px 4px rgba(0,0,0,0.18); +} +.v-real { background: #16a34a; } +.v-fake { background: #dc2626; } +.v-unver { background: #92400e; } + +.fc-verdict-badge::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: #fff; +} + +.fc-card-body { + padding: 22px 24px; + display: flex; + flex-direction: column; + gap: 8px; +} +.fc-card-meta { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 4px; +} +.fc-meta-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + font-size: 11px; + font-weight: 700; + border-radius: var(--r-pill); + background: #eef1f7; + color: var(--navy); +} +.fc-meta-pill.cat { background: #dbeafe; color: #1e3a8a; } +.fc-meta-pill.date { background: #f3e8ff; color: #5b21b6; } +.fc-meta-pill.read { background: #dcfce7; color: #166534; } + +.fc-card-body h2 { + font-family: 'Playfair Display', serif; + font-size: 22px; + line-height: 1.25; + font-weight: 700; + margin: 4px 0 6px; + color: var(--ink); +} +.fc-card-body p { + font-size: 14px; + line-height: 1.55; + color: var(--ink-soft); + margin: 0 0 12px; +} +.fc-card-cta { + margin-top: auto; + align-self: flex-start; + background: var(--red); + color: #fff; + padding: 8px 16px; + border-radius: var(--r); + font-weight: 700; + font-size: 13px; + display: inline-flex; + align-items: center; + gap: 6px; + text-decoration: none; + transition: background var(--t-fast); +} +.fc-card-cta::after { content: "→"; transition: transform var(--t-fast); } +.fc-card:hover .fc-card-cta { background: #b91c1c; } +.fc-card:hover .fc-card-cta::after { transform: translateX(3px); } + +.fc-card.real .fc-card-cta { background: #16a34a; } +.fc-card.real:hover .fc-card-cta { background: #15803d; } +.fc-card.unver .fc-card-cta { background: #92400e; } +.fc-card.unver:hover .fc-card-cta { background: #78350f; } + +.fc-empty { + background: #fff; + border: 1px dashed var(--line); + padding: 56px 24px; + text-align: center; + border-radius: var(--r-lg); + color: var(--ink-soft); +} + +/* Sidebar */ +.fc-side { display: flex; flex-direction: column; gap: 20px; } +.fc-side-block { + background: #fff; + border: 1px solid var(--line); + border-radius: var(--r-lg); + padding: 18px; +} +.fc-side-block h3 { + font-family: 'Playfair Display', serif; + font-size: 16px; + margin: 0 0 12px; + padding-bottom: 10px; + border-bottom: 2px solid var(--navy); + display: inline-block; + color: var(--ink); +} +.fc-side-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 12px; } +.fc-side-list li a { + display: grid; + grid-template-columns: 60px 1fr; + gap: 10px; + text-decoration: none; + color: inherit; + align-items: center; +} +.fc-side-list li img { + width: 60px; + height: 60px; + object-fit: cover; + border-radius: var(--r-sm); +} +.fc-side-list li .side-title { + font-size: 13px; + line-height: 1.35; + font-weight: 600; + color: var(--ink); +} +.fc-side-list li .side-meta { + font-size: 11px; + color: var(--ink-faint); + margin-top: 3px; +} +.fc-side-list li a:hover .side-title { color: var(--navy); } + +.fc-submit-card { + background: linear-gradient(135deg, #052962 0%, #0a3d8c 100%); + color: #fff; + border-radius: var(--r-lg); + padding: 22px; +} +.fc-submit-card h3 { + font-family: 'Playfair Display', serif; + font-size: 18px; + margin: 0 0 8px; + color: #fff; + border: 0; + padding: 0; +} +.fc-submit-card p { font-size: 13px; opacity: 0.9; margin: 0 0 14px; line-height: 1.5; } +.fc-submit-card .btn-yellow { display: inline-block; } + +/* Detail page */ +.fc-detail-hero { + background: #fff; + padding: 36px 24px 28px; + border-bottom: 1px solid var(--line); +} +.fc-detail-hero .container-x { max-width: 880px; } +.fc-breadcrumb { font-size: 13px; color: var(--ink-faint); margin-bottom: 18px; } +.fc-breadcrumb a { color: var(--navy); text-decoration: none; } +.fc-breadcrumb a:hover { text-decoration: underline; } +.fc-detail-hero .fc-detail-meta { display: flex; gap: 8px; flex-wrap: wrap; margin: 14px 0 16px; } +.fc-detail-hero h1 { + font-family: 'Playfair Display', serif; + font-size: 40px; + line-height: 1.15; + margin: 0; + color: var(--ink); +} +.fc-detail-hero .fc-detail-author { + font-size: 14px; + color: var(--ink-soft); + margin-top: 16px; +} +.fc-detail-hero .fc-detail-author strong { color: var(--ink); } + +.fc-verdict-banner { + display: flex; + align-items: center; + gap: 16px; + padding: 20px 24px; + border-radius: var(--r-lg); + margin-top: 22px; + color: #fff; +} +.fc-verdict-banner.v-real { background: linear-gradient(135deg,#16a34a,#15803d); } +.fc-verdict-banner.v-fake { background: linear-gradient(135deg,#dc2626,#991b1b); } +.fc-verdict-banner.v-unver { background: linear-gradient(135deg,#92400e,#78350f); } +.fc-verdict-banner .fc-vb-label { + font-family: 'Playfair Display', serif; + font-size: 28px; + font-weight: 800; + letter-spacing: 1px; + text-transform: uppercase; +} +.fc-verdict-banner .fc-vb-desc { font-size: 14px; opacity: 0.92; } + +.fc-detail-body { + max-width: 880px; + margin: 0 auto; + padding: 36px 24px 56px; + display: grid; + grid-template-columns: 220px 1fr; + gap: 36px; +} +@media (max-width: 880px) { .fc-detail-body { grid-template-columns: 1fr; } } + +.fc-detail-aside { font-size: 13px; } +.fc-detail-aside h4 { + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--ink-faint); + margin: 0 0 10px; +} +.fc-detail-aside ul { list-style: none; padding: 0; margin: 0 0 22px; } +.fc-detail-aside ul li { padding: 6px 0; border-top: 1px solid var(--line-soft); } +.fc-detail-aside ul li:first-child { border-top: 0; } +.fc-detail-aside ul li a { color: var(--navy); text-decoration: none; } +.fc-detail-aside ul li a:hover { text-decoration: underline; } + +.fc-detail-main img.fc-hero-img { + width: 100%; + border-radius: var(--r-lg); + margin-bottom: 28px; + display: block; + border: 1px solid var(--line); +} +.fc-detail-main h2 { + font-family: 'Playfair Display', serif; + font-size: 24px; + margin: 28px 0 12px; + color: var(--ink); +} +.fc-detail-main p { + font-size: 17px; + line-height: 1.7; + color: var(--ink-soft); + margin: 0 0 16px; +} +.fc-summary-box { + background: var(--beige); + border-left: 4px solid var(--navy); + padding: 18px 22px; + border-radius: 0 var(--r-lg) var(--r-lg) 0; + margin-bottom: 28px; +} +.fc-summary-box h3 { + font-family: 'Playfair Display', serif; + font-size: 18px; + margin: 0 0 10px; + color: var(--navy); +} +.fc-summary-box ul { list-style: none; padding: 0; margin: 0; } +.fc-summary-box li { + padding: 8px 0 8px 24px; + position: relative; + font-size: 15px; + line-height: 1.55; + color: var(--ink); +} +.fc-summary-box li::before { + content: "✓"; + position: absolute; + left: 0; + top: 8px; + color: var(--navy); + font-weight: 800; +} + +.fc-related { + border-top: 1px solid var(--line); + background: #fff; + padding: 36px 24px; +} +.fc-related .container-x { max-width: 1280px; } +.fc-related h3 { + font-family: 'Playfair Display', serif; + font-size: 24px; + margin: 0 0 18px; + color: var(--ink); +} +.fc-related-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 18px; +} +@media (max-width: 880px) { .fc-related-grid { grid-template-columns: 1fr; } } +.fc-related-card { + background: #fff; + border: 1px solid var(--line); + border-radius: var(--r-lg); + overflow: hidden; + text-decoration: none; + color: inherit; + display: flex; + flex-direction: column; + transition: box-shadow var(--t), transform var(--t); +} +.fc-related-card:hover { box-shadow: var(--sh-md); transform: translateY(-2px); } +.fc-related-card .fc-related-thumb { position: relative; aspect-ratio: 16/10; } +.fc-related-card .fc-related-thumb img { width: 100%; height: 100%; object-fit: cover; } +.fc-related-card h4 { + font-family: 'Playfair Display', serif; + font-size: 16px; + margin: 0; + padding: 14px 16px 16px; + line-height: 1.3; + color: var(--ink); +} + +/* Always show burger on every breakpoint (it opens the fullscreen menu) */ +.navy-nav .burger { display: inline-flex; } + +/* Logo sizing across the platform */ +.navy-nav .brand-logo { + display: block; + height: 64px; + width: auto; + max-width: 220px; + object-fit: contain; +} +.menu-overlay .mo-brand-logo { + display: block; + height: 84px; + width: auto; + max-width: 280px; + object-fit: contain; +} +.site-footer .footer-logo { + display: block; + height: 76px; + width: auto; + max-width: 240px; + object-fit: contain; + margin-bottom: 14px; +} +@media (max-width: 720px) { + .navy-nav .brand-logo { height: 48px; } + .menu-overlay .mo-brand-logo { height: 60px; } + .site-footer .footer-logo { height: 60px; } +} + +/* ============================================================ + Fullscreen menu overlay (triggered by hamburger button) + ============================================================ */ +.menu-overlay { + position: fixed; + inset: 0; + z-index: 9999; + background: linear-gradient(160deg, #052962 0%, #0a3d8c 60%, #062c6e 100%); + color: #fff; + display: flex; + flex-direction: column; + visibility: hidden; + opacity: 0; + transition: opacity 0.35s var(--ease-out), visibility 0.35s; +} +.menu-overlay.is-open { + visibility: visible; + opacity: 1; +} +.menu-overlay::before { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 10% 10%, rgba(255,229,0,0.10), transparent 40%), + radial-gradient(circle at 90% 90%, rgba(255,255,255,0.08), transparent 50%); + pointer-events: none; +} + +.menu-overlay .mo-top { + display: flex; + justify-content: space-between; + align-items: center; + padding: 22px 28px; + position: relative; + z-index: 1; +} +.menu-overlay .mo-brand { + font-family: 'Playfair Display', serif; + font-size: 36px; + font-weight: 800; + color: #fff; + text-decoration: none; + letter-spacing: -1px; +} +.menu-overlay .mo-brand .dot { color: var(--yellow); } + +.menu-overlay .mo-close { + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--yellow); + color: var(--navy); + border: 0; + cursor: pointer; + font-size: 20px; + font-weight: 800; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform var(--t-fast); +} +.menu-overlay .mo-close:hover { transform: rotate(90deg); } + +.menu-overlay .mo-body { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 40px 24px; + position: relative; + z-index: 1; +} +.menu-overlay .mo-tagline { + font-size: 12px; + font-weight: 700; + letter-spacing: 2px; + text-transform: uppercase; + background: var(--yellow); + color: var(--navy); + padding: 4px 12px; + border-radius: 4px; + margin-bottom: 28px; +} + +.menu-overlay nav.mo-links { + display: flex; + flex-direction: column; + gap: 8px; + text-align: center; +} +.menu-overlay nav.mo-links a { + font-family: 'Playfair Display', serif; + font-size: 38px; + font-weight: 700; + color: rgba(255,255,255,0.92); + text-decoration: none; + padding: 6px 16px; + position: relative; + transition: color var(--t-fast), transform var(--t-fast); +} +.menu-overlay nav.mo-links a:hover { + color: var(--yellow); + transform: translateX(4px); +} +.menu-overlay nav.mo-links a::before { + content: "›"; + display: inline-block; + margin-right: 12px; + opacity: 0; + transform: translateX(-8px); + transition: opacity var(--t-fast), transform var(--t-fast); + color: var(--yellow); +} +.menu-overlay nav.mo-links a:hover::before { + opacity: 1; + transform: translateX(0); +} + +.menu-overlay .mo-secondary { + margin-top: 36px; + display: flex; + gap: 22px; + font-size: 13px; + color: rgba(255,255,255,0.7); +} +.menu-overlay .mo-secondary a { + color: rgba(255,255,255,0.7); + text-decoration: none; + border-bottom: 1px solid rgba(255,255,255,0.2); + padding-bottom: 1px; + transition: color var(--t-fast), border-color var(--t-fast); +} +.menu-overlay .mo-secondary a:hover { + color: var(--yellow); + border-color: var(--yellow); +} + +.menu-overlay .mo-foot { + padding: 18px 28px; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: rgba(255,255,255,0.6); + border-top: 1px solid rgba(255,255,255,0.10); + position: relative; + z-index: 1; +} + +/* Fade-in stagger for links when overlay opens */ +.menu-overlay nav.mo-links a { + opacity: 0; + transform: translateY(12px); + transition: opacity 0.45s var(--ease-out), transform 0.45s var(--ease-out), color var(--t-fast); +} +.menu-overlay.is-open nav.mo-links a { + opacity: 1; + transform: translateY(0); +} +.menu-overlay.is-open nav.mo-links a:nth-child(1) { transition-delay: 0.05s; } +.menu-overlay.is-open nav.mo-links a:nth-child(2) { transition-delay: 0.10s; } +.menu-overlay.is-open nav.mo-links a:nth-child(3) { transition-delay: 0.15s; } +.menu-overlay.is-open nav.mo-links a:nth-child(4) { transition-delay: 0.20s; } +.menu-overlay.is-open nav.mo-links a:nth-child(5) { transition-delay: 0.25s; } +.menu-overlay.is-open nav.mo-links a:nth-child(6) { transition-delay: 0.30s; } +.menu-overlay.is-open nav.mo-links a:nth-child(7) { transition-delay: 0.35s; } + +/* Lock body scroll while overlay is open */ +body.menu-open { overflow: hidden; } + +@media (max-width: 640px) { + .menu-overlay nav.mo-links a { font-size: 28px; } + .menu-overlay .mo-brand { font-size: 28px; } +} + +/* ============================================================ + LUXE LAYER — page heroes, image-rich cards, auto-scroll + marquees, hover effects. Built on the existing navy/yellow/ + Playfair charter — no palette or font change. + ============================================================ */ + +/* ---------- Page hero ---------- */ +.lux-hero { + position: relative; + overflow: hidden; + background: var(--navy); + color: #fff; + isolation: isolate; +} +.lux-hero__bg { position: absolute; inset: 0; z-index: -2; } +.lux-hero__bg img { + width: 100%; height: 100%; object-fit: cover; display: block; + transform: scale(1.06); + animation: lux-kenburns 26s ease-in-out infinite alternate; +} +@keyframes lux-kenburns { + from { transform: scale(1.05) translate(0, 0); } + to { transform: scale(1.15) translate(-1.6%, -1.4%); } +} +.lux-hero::before { + content: ""; position: absolute; inset: 0; z-index: -1; + background: + linear-gradient(108deg, rgba(4,30,77,0.95) 0%, rgba(5,41,98,0.86) 36%, rgba(5,41,98,0.52) 68%, rgba(5,41,98,0.34) 100%), + linear-gradient(0deg, rgba(3,18,46,0.72), rgba(3,18,46,0) 52%); +} +.lux-hero__inner { + max-width: 1280px; margin: 0 auto; + padding: 88px 24px 76px; + position: relative; +} +.lux-hero__inner.narrow { max-width: 980px; } +.lux-hero__kicker { + display: inline-flex; align-items: center; gap: 8px; + background: var(--yellow); color: var(--navy); + font-weight: 800; font-size: 11px; letter-spacing: 1.4px; text-transform: uppercase; + padding: 6px 14px; border-radius: 2px; margin-bottom: 22px; +} +.lux-hero__kicker .icon-svg { width: 13px; height: 13px; } +.lux-hero h1 { + font-family: 'Playfair Display', serif; + font-size: clamp(38px, 5.2vw, 62px); + line-height: 1.05; font-weight: 800; margin: 0 0 18px; + letter-spacing: -0.01em; +} +.lux-hero h1 .accent { color: var(--yellow); } +.lux-hero .lux-hero__lead { + font-size: clamp(16px, 1.55vw, 19px); line-height: 1.62; + max-width: 58ch; opacity: 0.92; margin: 0 0 30px; +} +.lux-hero__actions { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; } +.lux-hero__actions .btn-outline { border-color: rgba(255,255,255,0.6); color: #fff; } +.lux-hero__actions .btn-outline:hover { background: #fff; color: var(--navy); } +.lux-hero__stats { + display: flex; flex-wrap: wrap; gap: 44px; + margin-top: 48px; padding-top: 26px; + border-top: 1px solid rgba(255,255,255,0.18); +} +.lux-hero__stats .s strong { + display: block; font-family: 'Playfair Display', serif; + font-size: 30px; font-weight: 800; color: var(--yellow); line-height: 1; margin-bottom: 4px; +} +.lux-hero__stats .s span { font-size: 12.5px; opacity: 0.78; letter-spacing: 0.3px; } + +/* Hero variant: framed image alongside the copy */ +.lux-hero--split .lux-hero__inner { + display: grid; grid-template-columns: 1.05fr 0.92fr; gap: 56px; align-items: center; +} +.lux-hero--split::before { background: linear-gradient(120deg, rgba(4,30,77,0.97), rgba(5,41,98,0.9)); } +.lux-hero--split .lux-hero__bg img { filter: blur(3px) brightness(0.45); } +.lux-hero__figure { + position: relative; border-radius: var(--r-lg); overflow: hidden; aspect-ratio: 4 / 3; + box-shadow: 0 44px 88px -28px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.12); +} +.lux-hero__figure > img { width: 100%; height: 100%; object-fit: cover; transition: transform 1.4s var(--ease-out); } +.lux-hero__figure:hover > img { transform: scale(1.06); } +.lux-hero__figure .fig-tag { + position: absolute; left: 14px; bottom: 14px; z-index: 2; + display: inline-flex; align-items: center; gap: 7px; + background: rgba(3,18,46,0.78); backdrop-filter: blur(6px); + color: #fff; font-size: 12px; font-weight: 600; padding: 8px 12px; border-radius: var(--r-sm); + border: 1px solid rgba(255,255,255,0.14); +} +.lux-hero__figure .fig-tag .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px rgba(22,163,74,0.22); } +.lux-hero__figure::after { content: ""; position: absolute; left: 0; bottom: 0; width: 72px; height: 4px; background: var(--yellow); z-index: 3; } + +@media (max-width: 900px) { + .lux-hero--split .lux-hero__inner { grid-template-columns: 1fr; gap: 30px; } + .lux-hero__inner { padding: 56px 20px 50px; } + .lux-hero__stats { gap: 28px; } +} + +/* ---------- Auto horizontal scroll (marquee) ---------- */ +.marquee { + position: relative; overflow: hidden; + -webkit-mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent); + mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent); +} +.marquee__track { + display: flex; width: max-content; + animation: lux-marquee var(--mq-dur, 46s) linear infinite; + will-change: transform; +} +.marquee:hover .marquee__track { animation-play-state: paused; } +.marquee[data-reverse="true"] .marquee__track { animation-direction: reverse; } +.marquee__track > * { margin-right: var(--mq-gap, 22px); } +@keyframes lux-marquee { from { transform: translateX(0); } to { transform: translateX(-50%); } } + +/* Image tile inside a marquee */ +.mq-card { + flex: 0 0 auto; width: 296px; border-radius: var(--r); + overflow: hidden; position: relative; background: #0a1b3c; + box-shadow: var(--sh-md); text-decoration: none; color: #fff; + display: block; +} +.mq-card img { width: 100%; height: 188px; object-fit: cover; display: block; transition: transform .7s var(--ease-out); } +.mq-card:hover img { transform: scale(1.08); } +.mq-card .mq-cap { + position: absolute; left: 0; right: 0; bottom: 0; padding: 30px 14px 13px; + background: linear-gradient(0deg, rgba(3,18,46,0.94), rgba(3,18,46,0.55) 50%, rgba(3,18,46,0)); + font-size: 13px; font-weight: 600; line-height: 1.35; +} +.mq-card .mq-tag { + position: absolute; top: 11px; left: 11px; + font-size: 9.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.6px; + padding: 4px 8px; border-radius: 2px; background: var(--yellow); color: var(--navy); +} +.mq-card .mq-tag.is-real { background: var(--green); color: #fff; } +.mq-card .mq-tag.is-fake { background: var(--red); color: #fff; } +.mq-card .mq-tag.is-warn { background: #92400e; color: #fff; } + +/* Text/logo marquee */ +.marquee--logos { --mq-gap: 0px; } +.marquee--logos .mq-logo { + flex: 0 0 auto; font-family: 'Playfair Display', serif; font-size: 21px; font-weight: 700; + color: var(--ink-faint); padding: 0 30px; white-space: nowrap; opacity: 0.65; letter-spacing: 0.2px; + display: inline-flex; align-items: center; gap: 30px; +} +.marquee--logos .mq-logo::before { content: "✦"; color: var(--yellow); font-size: 12px; opacity: 0.85; } + +/* Strip wrapper (title + marquee) */ +.lux-strip { padding: 30px 0; background: var(--bg-soft); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.lux-strip.on-navy { background: var(--navy-dark); border-color: rgba(255,255,255,0.08); } +.lux-strip__head { + max-width: 1280px; margin: 0 auto 18px; padding: 0 24px; + display: flex; align-items: baseline; justify-content: space-between; gap: 16px; flex-wrap: wrap; +} +.lux-strip__head .t { font-family: 'Playfair Display', serif; font-size: 22px; margin: 0; } +.lux-strip.on-navy .lux-strip__head .t { color: #fff; } +.lux-strip__head .s { font-size: 13px; color: var(--ink-faint); } +.lux-strip.on-navy .lux-strip__head .s { color: rgba(255,255,255,0.6); } + +/* ---------- Image-rich module cards ---------- */ +.modules-lux { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 26px; + max-width: 1280px; margin: 0 auto; padding: 6px 24px; +} +@media (max-width: 1000px) { .modules-lux { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 640px) { .modules-lux { grid-template-columns: 1fr; } } + +.mod-card { + --mc: var(--navy); + position: relative; display: flex; flex-direction: column; + background: #fff; border-radius: var(--r-lg); overflow: hidden; + border: 1px solid var(--line); box-shadow: var(--sh-sm); + text-decoration: none; color: inherit; + transition: transform .4s var(--ease-out), box-shadow .4s var(--ease-out), border-color .4s var(--ease-out); +} +.mod-card:hover { + transform: translateY(-8px); + box-shadow: 0 32px 64px -20px var(--navy-glow), 0 16px 30px -14px rgba(15,23,42,0.16); + border-color: transparent; +} +.mod-card::before { + content: ""; position: absolute; top: 0; left: 0; right: 0; height: 3px; z-index: 4; + background: var(--mc); transform: scaleX(0); transform-origin: left; + transition: transform .5s var(--ease-out); +} +.mod-card:hover::before { transform: scaleX(1); } +.mod-card__media { position: relative; aspect-ratio: 16 / 10; overflow: hidden; } +.mod-card__media img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 1.1s var(--ease-out); } +.mod-card:hover .mod-card__media img { transform: scale(1.09); } +.mod-card__media::after { + content: ""; position: absolute; inset: 0; pointer-events: none; + background: + linear-gradient(180deg, rgba(4,21,53,0) 42%, rgba(4,21,53,0.06) 66%, rgba(4,21,53,0.32) 100%), + linear-gradient(125deg, color-mix(in srgb, var(--mc) 32%, transparent), transparent 55%); + transition: opacity .4s; opacity: 0.9; +} +.mod-card:hover .mod-card__media::after { opacity: 0.7; } +.mod-card__badge { + position: absolute; top: 14px; left: 14px; z-index: 3; + display: inline-flex; align-items: center; gap: 6px; + background: var(--yellow); color: var(--navy); + font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; + padding: 5px 10px; border-radius: 2px; +} +.mod-card__icon { + position: absolute; left: 18px; bottom: -24px; z-index: 4; + width: 52px; height: 52px; border-radius: 14px; + display: flex; align-items: center; justify-content: center; + background: #fff; color: var(--mc); + box-shadow: 0 12px 26px -8px rgba(15,23,42,0.3); + border: 1px solid var(--line-soft); +} +.mod-card__icon .icon-svg { width: 24px; height: 24px; } +.mod-card__body { padding: 36px 22px 20px; display: flex; flex-direction: column; flex: 1; } +.mod-card__no { + font-family: 'Playfair Display', serif; font-size: 11.5px; font-weight: 700; + color: var(--ink-faint); letter-spacing: 1.4px; text-transform: uppercase; margin-bottom: 6px; +} +.mod-card h3 { font-family: 'Playfair Display', serif; font-size: 21px; line-height: 1.16; margin: 0 0 9px; } +.mod-card .mod-card__sub { font-size: 13.5px; color: var(--ink-soft); line-height: 1.55; margin: 0 0 14px; } +.mod-card__formats { font-size: 10.5px; color: var(--mc); font-weight: 700; letter-spacing: 0.6px; text-transform: uppercase; margin: 0 0 16px; opacity: 0.85; } +.mod-card__meta { + margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; + padding-top: 14px; border-top: 1px solid var(--line-soft); +} +.mod-card__stats { display: flex; gap: 16px; font-size: 10px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: 0.4px; } +.mod-card__stats b { display: block; font-family: 'Playfair Display', serif; font-size: 16px; color: var(--navy); letter-spacing: 0; text-transform: none; } +.mod-card__cta { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; font-size: 13px; color: var(--navy); white-space: nowrap; } +.mod-card__cta .icon-svg { width: 14px; height: 14px; transition: transform .25s; } +.mod-card:hover .mod-card__cta .icon-svg { transform: translateX(4px); } + +/* ---------- Centered section heading ---------- */ +.lux-shead { text-align: center; max-width: 720px; margin: 0 auto 38px; padding: 0 20px; } +.lux-shead .k { + display: inline-block; font-size: 11px; font-weight: 800; letter-spacing: 1.6px; text-transform: uppercase; + color: var(--navy); background: var(--yellow-soft); padding: 5px 12px; border-radius: 2px; margin-bottom: 14px; +} +.lux-shead h2 { font-family: 'Playfair Display', serif; font-size: clamp(28px, 3.4vw, 40px); line-height: 1.1; margin: 0 0 12px; } +.lux-shead p { font-size: 16px; color: var(--ink-soft); line-height: 1.6; margin: 0; } + +/* ---------- Shine sweep on hover ---------- */ +.lux-shine { position: relative; overflow: hidden; } +.lux-shine::after { + content: ""; position: absolute; top: -10%; left: -130%; width: 55%; height: 120%; + background: linear-gradient(110deg, transparent, rgba(255,255,255,0.4), transparent); + transform: skewX(-20deg); transition: left .75s var(--ease-out); pointer-events: none; +} +.lux-shine:hover::after { left: 140%; } + +/* ---------- Floating CTA banner with image ---------- */ +.lux-cta { + position: relative; overflow: hidden; border-radius: var(--r-xl); + max-width: 1180px; margin: 64px auto; padding: 56px 48px; color: #fff; +} +.lux-cta__bg { position: absolute; inset: 0; z-index: -2; } +.lux-cta__bg img { width: 100%; height: 100%; object-fit: cover; } +.lux-cta::before { content: ""; position: absolute; inset: 0; z-index: -1; background: linear-gradient(115deg, rgba(4,30,77,0.95), rgba(5,41,98,0.78)); } +.lux-cta h2 { font-family: 'Playfair Display', serif; font-size: clamp(26px, 3vw, 36px); margin: 0 0 10px; line-height: 1.12; } +.lux-cta p { font-size: 16px; opacity: 0.9; max-width: 56ch; margin: 0 0 24px; line-height: 1.6; } +@media (max-width: 700px) { .lux-cta { padding: 40px 26px; margin: 40px 16px; } } + +/* ============================================================ + MODULE PAGE HERO — cinematic full-bleed image + live "analysis + console" + spec ribbon. Deliberately distinct from .lux-hero. + ============================================================ */ +.mhero { + --mc: #3b82f6; + position: relative; overflow: hidden; color: #fff; isolation: isolate; + min-height: clamp(500px, 66vh, 640px); + display: flex; align-items: stretch; +} +.mhero__img { position: absolute; inset: 0; z-index: 0; } +.mhero__img img { + width: 100%; height: 100%; object-fit: cover; display: block; + transform: scale(1.05); animation: lux-kenburns 30s ease-in-out infinite alternate; +} +.mhero::before { + content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none; + background: + linear-gradient(180deg, rgba(3,15,38,0.5) 0%, rgba(3,15,38,0.18) 28%, rgba(3,15,38,0.55) 66%, rgba(3,15,38,0.93) 100%), + linear-gradient(96deg, rgba(3,15,38,0.88) 0%, rgba(3,15,38,0.42) 46%, rgba(3,15,38,0.08) 100%), + linear-gradient(0deg, color-mix(in srgb, var(--mc) 26%, transparent), transparent 58%); +} +.mhero__scan { + position: absolute; left: 0; right: 0; top: 6%; height: 2px; z-index: 2; pointer-events: none; + background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--mc) 92%, white), transparent); + box-shadow: 0 0 22px 2px color-mix(in srgb, var(--mc) 55%, transparent); + opacity: 0.5; animation: mhero-scan 6.5s cubic-bezier(.45,0,.55,1) infinite; +} +@keyframes mhero-scan { 0% { top: 5%; } 50% { top: 95%; } 100% { top: 5%; } } +.mhero__ghost { + position: absolute; right: 1.5%; bottom: -13%; z-index: 1; pointer-events: none; user-select: none; + font-family: 'Playfair Display', serif; font-weight: 800; line-height: 1; + font-size: clamp(200px, 31vw, 450px); color: rgba(255,255,255,0.055); +} +.mhero__inner { + position: relative; z-index: 3; width: 100%; + max-width: 1280px; margin: 0 auto; padding: 26px 24px 40px; + display: flex; flex-direction: column; +} +.mhero__top { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.mhero__crumb a { + color: rgba(255,255,255,0.78); text-decoration: none; font-size: 13px; + display: inline-flex; align-items: center; gap: 7px; transition: color .15s; +} +.mhero__crumb a:hover { color: #fff; } +.mhero__idx { font-size: 11px; letter-spacing: 2.2px; text-transform: uppercase; color: rgba(255,255,255,0.65); display: inline-flex; align-items: center; gap: 10px; } +.mhero__idx::before { content: ""; width: 36px; height: 1px; background: rgba(255,255,255,0.35); } +.mhero__idx b { font-family: 'Playfair Display', serif; font-size: 22px; color: #fff; letter-spacing: 0; font-weight: 800; } +.mhero__idx i { font-style: normal; opacity: 0.5; } +.mhero__main { margin-top: auto; padding-top: 56px; display: grid; grid-template-columns: 1.15fr 0.85fr; gap: 48px; align-items: end; } +.mhero__chip { + display: inline-flex; align-items: center; gap: 9px; margin-bottom: 18px; + font-size: 11px; font-weight: 800; letter-spacing: 1.3px; text-transform: uppercase; color: #fff; + padding: 7px 14px; border-radius: 9999px; + background: color-mix(in srgb, var(--mc) 24%, rgba(255,255,255,0.05)); + border: 1px solid color-mix(in srgb, var(--mc) 58%, transparent); +} +.mhero__chip .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--mc); box-shadow: 0 0 0 4px color-mix(in srgb, var(--mc) 24%, transparent); } +.mhero h1 { + font-family: 'Playfair Display', serif; font-weight: 800; letter-spacing: -0.01em; + font-size: clamp(34px, 4.4vw, 56px); line-height: 1.06; margin: 0 0 16px; max-width: 17ch; +} +.mhero__lead { font-size: clamp(15px, 1.5vw, 18px); line-height: 1.6; opacity: 0.9; max-width: 52ch; margin: 0 0 26px; } +.mhero__actions { display: flex; flex-wrap: wrap; gap: 14px; } +.mhero__actions .btn-outline { border-color: rgba(255,255,255,0.6); color: #fff; } +.mhero__actions .btn-outline:hover { background: #fff; color: var(--navy); } + +/* live analysis console */ +.mhero__console { + background: rgba(7,18,42,0.55); -webkit-backdrop-filter: blur(14px) saturate(1.3); backdrop-filter: blur(14px) saturate(1.3); + border: 1px solid rgba(255,255,255,0.14); border-radius: var(--r-lg); + padding: 18px 18px 16px; box-shadow: 0 32px 64px -22px rgba(0,0,0,0.62); + font-size: 13px; +} +.mhero__console .cc-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 13px; } +.mhero__console .cc-title { font-weight: 700; display: inline-flex; align-items: center; gap: 9px; letter-spacing: 0.2px; } +.mhero__console .cc-live { width: 8px; height: 8px; border-radius: 50%; background: var(--mc); animation: mhero-pulse 1.6s ease-out infinite; } +@keyframes mhero-pulse { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--mc) 55%, transparent); } + 70% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--mc) 0%, transparent); } + 100% { box-shadow: 0 0 0 0 transparent; } +} +.mhero__console .cc-dots { display: inline-flex; gap: 4px; } +.mhero__console .cc-dots i { width: 5px; height: 5px; border-radius: 50%; background: rgba(255,255,255,0.5); animation: cc-dots 1.2s ease-in-out infinite; } +.mhero__console .cc-dots i:nth-child(2) { animation-delay: 0.18s; } +.mhero__console .cc-dots i:nth-child(3) { animation-delay: 0.36s; } +@keyframes cc-dots { 0%, 100% { opacity: 0.25; transform: translateY(0); } 50% { opacity: 1; transform: translateY(-2px); } } +.mhero__console .cc-bar { height: 6px; border-radius: 9999px; background: rgba(255,255,255,0.12); overflow: hidden; margin-bottom: 14px; } +.mhero__console .cc-bar > i { display: block; height: 100%; width: 100%; transform-origin: left center; border-radius: 9999px; background: linear-gradient(90deg, var(--mc), color-mix(in srgb, var(--mc) 35%, white)); animation: cc-load 3s ease-in-out infinite; will-change: transform; } +@keyframes cc-load { 0% { transform: scaleX(.08); } 55% { transform: scaleX(.74); } 100% { transform: scaleX(.08); } } +.mhero__console ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 9px; } +.mhero__console li { display: flex; align-items: center; gap: 9px; color: rgba(255,255,255,0.82); } +.mhero__console li .s { margin-left: auto; font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; } +.mhero__console li .s.ok { color: #4ade80; } +.mhero__console li .s.run { color: color-mix(in srgb, var(--mc) 55%, white); animation: cc-blink 1.4s ease-in-out infinite; } +@keyframes cc-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } } +.mhero__console li .mk { width: 14px; height: 14px; border-radius: 4px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; font-size: 9px; } +.mhero__console li .mk.ok { background: rgba(74,222,128,0.18); color: #4ade80; } +.mhero__console li .mk.run { background: color-mix(in srgb, var(--mc) 20%, transparent); color: color-mix(in srgb, var(--mc) 55%, white); } + +/* spec ribbon under the module hero */ +.mspec { background: var(--navy-dark); color: #fff; border-bottom: 1px solid rgba(255,255,255,0.08); } +.mspec__inner { max-width: 1280px; margin: 0 auto; padding: 0 24px; display: flex; flex-wrap: wrap; align-items: stretch; } +.mspec__item { display: flex; align-items: center; gap: 11px; padding: 16px 26px 16px 0; } +.mspec__item + .mspec__item { padding-left: 26px; border-left: 1px solid rgba(255,255,255,0.1); } +.mspec__item strong { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 800; } +.mspec__item span { font-size: 11.5px; opacity: 0.62; text-transform: uppercase; letter-spacing: 0.5px; } +.mspec__item.is-formats { margin-left: auto; padding-left: 26px; border-left: 1px solid rgba(255,255,255,0.1); font-size: 11.5px; letter-spacing: 0.5px; text-transform: uppercase; opacity: 0.82; } +.mspec__item.is-formats b { color: var(--yellow); font-weight: 700; margin-right: 6px; } + +@media (max-width: 920px) { + .mhero__main { grid-template-columns: 1fr; gap: 24px; padding-top: 36px; } + .mhero__console { max-width: 440px; } + .mhero__inner { padding-bottom: 32px; } +} +@media (max-width: 600px) { + .mhero { min-height: auto; } + .mhero__idx b { font-size: 18px; } + .mhero__idx::before { width: 20px; } + .mspec__inner { gap: 0; } + .mspec__item { padding: 11px 16px 11px 0; } + .mspec__item + .mspec__item, .mspec__item.is-formats { padding-left: 16px; } + .mspec__item.is-formats { margin-left: 0; width: 100%; border-left: 0; border-top: 1px solid rgba(255,255,255,0.1); } +} +@media (prefers-reduced-motion: reduce) { + .mhero__img img, .mhero__scan, .mhero__console .cc-bar > i, .mhero__console .cc-dots i, + .mhero__console .cc-live, .mhero__console li .s.run { animation: none; } +} + +/* ---------- Live verdict ticker ---------- */ +.ticker-bar { display: flex; align-items: stretch; background: var(--navy-dark); color: #fff; overflow: hidden; } +.ticker-bar .ticker-label { + flex: 0 0 auto; display: inline-flex; align-items: center; gap: 9px; + background: var(--yellow); color: var(--navy); + font-weight: 800; font-size: 11px; letter-spacing: 1.4px; text-transform: uppercase; + padding: 0 22px; position: relative; z-index: 2; white-space: nowrap; +} +.ticker-bar .ticker-label .pulse-dot { + width: 8px; height: 8px; border-radius: 50%; background: var(--red); + animation: lux-pulse 1.7s ease-out infinite; +} +@keyframes lux-pulse { + 0% { box-shadow: 0 0 0 0 rgba(220,38,38,0.55); } + 70% { box-shadow: 0 0 0 9px rgba(220,38,38,0); } + 100% { box-shadow: 0 0 0 0 rgba(220,38,38,0); } +} +.ticker-bar .marquee { flex: 1 1 auto; min-width: 0; } +.marquee--ticker { --mq-gap: 0px; } +.marquee--ticker .mq-tick { + flex: 0 0 auto; display: inline-flex; align-items: center; gap: 12px; + padding: 13px 24px; font-size: 13.5px; white-space: nowrap; color: rgba(255,255,255,0.9); +} +.marquee--ticker .mq-tick > a { color: inherit; text-decoration: none; } +.marquee--ticker .mq-tick > a:hover { color: #fff; text-decoration: underline; } +.marquee--ticker .mq-tick .v { + font-size: 9.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.6px; + padding: 3px 8px; border-radius: 3px; +} +.marquee--ticker .mq-tick .v.is-real { background: var(--green); color: #fff; } +.marquee--ticker .mq-tick .v.is-fake { background: var(--red); color: #fff; } +.marquee--ticker .mq-tick .v.is-warn { background: #92400e; color: #fff; } +@media (max-width: 600px) { .ticker-bar .ticker-label { font-size: 10px; padding: 0 14px; letter-spacing: 1px; } } + +/* ---------- Big image gallery rows (auto-scroll) ---------- */ +.lux-gallery { display: flex; flex-direction: column; gap: 18px; padding: 4px 0; } +.lux-gallery .mq-card { width: 340px; } +.lux-gallery .mq-card img { height: 214px; } + +/* ---------- Module finder — creative redesign ---------- */ +.finder { + position: relative; overflow: hidden; + background: linear-gradient(135deg, #041a40 0%, #052962 46%, #0a3d8f 100%); + color: #fff; padding: 58px 24px 62px; border-bottom: 4px solid var(--yellow); +} +.finder::before { + content: ""; position: absolute; width: 460px; height: 460px; border-radius: 50%; + top: -200px; right: -120px; pointer-events: none; + background: radial-gradient(circle, rgba(255,229,0,0.16), transparent 65%); +} +.finder::after { + content: ""; position: absolute; width: 520px; height: 520px; border-radius: 50%; + bottom: -280px; left: -160px; pointer-events: none; + background: radial-gradient(circle, rgba(96,165,250,0.18), transparent 65%); +} +.finder .container { max-width: 1180px; margin: 0 auto; position: relative; z-index: 1; } +.finder .kicker { + display: inline-flex; align-items: center; gap: 7px; + background: var(--yellow); color: var(--navy); + font-weight: 800; font-size: 11px; letter-spacing: 1.2px; text-transform: uppercase; + padding: 5px 12px; border-radius: 2px; margin-bottom: 16px; +} +.finder .kicker .icon-svg { width: 12px; height: 12px; } +.finder h2 { + font-family: 'Playfair Display', serif; font-size: clamp(28px, 3.4vw, 38px); + line-height: 1.08; margin: 0 0 8px; letter-spacing: -0.01em; +} +.finder .lead { font-size: 15.5px; opacity: 0.88; margin: 0 0 28px; max-width: 620px; line-height: 1.55; } +.finder .use-cases { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } +@media (max-width: 940px) { .finder .use-cases { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 600px) { .finder .use-cases { grid-template-columns: 1fr; } } + +.finder .uc { + --uc-c: var(--yellow); + position: relative; display: flex; gap: 14px; align-items: center; + padding: 13px 16px 13px 14px; + background: rgba(255,255,255,0.055); + border: 1px solid rgba(255,255,255,0.13); + border-radius: 14px; text-decoration: none; color: #fff; overflow: hidden; + transition: transform .35s var(--ease-out), background .25s, border-color .25s, box-shadow .35s; +} +.finder .uc::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; + background: var(--uc-c); transform: scaleY(0); transform-origin: top; + transition: transform .35s var(--ease-out); +} +.finder .uc:hover, .finder .uc:focus-visible, .finder .uc.active { + transform: translateY(-4px); + background: rgba(255,255,255,0.095); + border-color: color-mix(in srgb, var(--uc-c) 55%, rgba(255,255,255,0.13)); + box-shadow: 0 20px 38px -16px rgba(0,0,0,0.55), 0 0 0 1px color-mix(in srgb, var(--uc-c) 25%, transparent); +} +.finder .uc:hover::before, .finder .uc.active::before { transform: scaleY(1); } +.finder .uc .uc-thumb { + flex: 0 0 auto; width: 62px; height: 62px; border-radius: 11px; overflow: hidden; + position: relative; box-shadow: 0 8px 18px -8px rgba(0,0,0,0.6); +} +.finder .uc .uc-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .6s var(--ease-out); } +.finder .uc:hover .uc-thumb img { transform: scale(1.13); } +.finder .uc .uc-thumb::after { + content: ""; position: absolute; inset: 0; + background: linear-gradient(140deg, color-mix(in srgb, var(--uc-c) 48%, transparent), transparent 62%); +} +.finder .uc .uc-text { display: flex; flex-direction: column; gap: 7px; min-width: 0; } +.finder .uc .uc-title { font-weight: 600; font-size: 13.5px; line-height: 1.35; } +.finder .uc .uc-reco { + display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; + font-size: 11px; font-weight: 800; letter-spacing: 0.2px; color: var(--uc-c); + background: color-mix(in srgb, var(--uc-c) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--uc-c) 30%, transparent); + padding: 4px 10px; border-radius: 9999px; line-height: 1.3; max-width: 100%; +} +.finder .uc .uc-reco .icon-svg { width: 11px; height: 11px; flex: 0 0 auto; transition: transform .25s; } +.finder .uc:hover .uc-reco .icon-svg { transform: translateX(3px); } + +/* ---------- Reduced motion ---------- */ +@media (prefers-reduced-motion: reduce) { + .lux-hero__bg img { animation: none; } + .marquee__track { animation-duration: 200s; } + .mod-card, .mod-card__media img, .mq-card img, .lux-hero__figure > img, + .finder .uc, .finder .uc .uc-thumb img { transition: none; } +} + + +/* ============================================================ + "VERIFY A FILE" MEGA-MENU — luxe redesign of .dropdown-panel + ============================================================ */ +.has-dropdown > a::after { display: inline-block; transition: transform .2s var(--ease-out); } +.has-dropdown:hover > a::after, .has-dropdown:focus-within > a::after { transform: rotate(180deg); } + +.dropdown-panel { + width: 700px; left: -20px; margin-top: 14px; padding: 0; + display: grid; grid-template-columns: 198px 1fr; gap: 0; + overflow: hidden; border-radius: 16px; border-top: 4px solid var(--yellow); + box-shadow: 0 30px 64px -16px rgba(5,41,98,0.30), 0 12px 28px -10px rgba(15,23,42,0.18); + /* animated open/close instead of display:none */ + opacity: 0; visibility: hidden; pointer-events: none; transform: translateY(-8px) scale(0.985); transform-origin: top center; + transition: opacity .2s ease, transform .26s var(--ease-out), visibility 0s linear .2s; + background: #fff; +} +.has-dropdown:hover .dropdown-panel, +.has-dropdown:focus-within .dropdown-panel { + display: grid; opacity: 1; visibility: visible; pointer-events: auto; transform: none; + transition: opacity .2s ease, transform .26s var(--ease-out), visibility 0s; +} +/* Invisible "hover bridge" so the cursor can travel from the nav link down to + the panel without the menu closing. It must NOT live on .dropdown-panel + (which is overflow:hidden → would clip it) — put it on .has-dropdown, which + isn't clipped, and only render it while the menu is open. */ +.has-dropdown { position: relative; } +.has-dropdown::before { + content: ""; position: absolute; top: 100%; left: -20px; width: 700px; height: 22px; + pointer-events: none; +} +.has-dropdown:hover::before, .has-dropdown:focus-within::before { pointer-events: auto; } + +/* left rail */ +.dropdown-panel .dd-rail { + position: relative; overflow: hidden; + background: linear-gradient(165deg, var(--navy) 0%, #0a3d8f 100%); + color: #fff; padding: 22px 20px; display: flex; flex-direction: column; +} +.dropdown-panel .dd-rail::after { + content: ""; position: absolute; width: 210px; height: 210px; border-radius: 50%; + top: -95px; right: -75px; pointer-events: none; + background: radial-gradient(circle, rgba(255,229,0,0.18), transparent 65%); +} +.dropdown-panel .dd-rail-kicker { + align-self: flex-start; font-size: 9.5px; font-weight: 800; letter-spacing: 1.4px; text-transform: uppercase; + color: var(--navy); background: var(--yellow); padding: 5px 10px; border-radius: 2px; margin-bottom: 14px; position: relative; z-index: 1; +} +.dropdown-panel .dd-rail-pitch { font-family: 'Playfair Display', serif; font-size: 16px; line-height: 1.32; margin: 0 0 16px; position: relative; z-index: 1; } +.dropdown-panel .dd-rail-cta { + align-self: flex-start; display: inline-flex; align-items: center; gap: 8px; + font-size: 12.5px; font-weight: 700; color: #fff; text-decoration: none; + padding: 8px 13px; border-radius: 9999px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.18); + transition: background .15s, border-color .15s, gap .15s; position: relative; z-index: 1; +} +.dropdown-panel .dd-rail-cta:hover { background: rgba(255,229,0,0.16); border-color: var(--yellow); gap: 11px; } +.dropdown-panel .dd-rail-cta span { transition: transform .15s; } +.dropdown-panel .dd-rail-cta:hover span { transform: translateX(2px); } +.dropdown-panel .dd-rail-links { + margin-top: auto; padding-top: 16px; border-top: 1px solid rgba(255,255,255,0.14); + display: flex; flex-direction: column; gap: 9px; position: relative; z-index: 1; +} +.dropdown-panel .dd-rail-links a { font-size: 12.5px; color: rgba(255,255,255,0.78); text-decoration: none; padding: 0; transition: color .15s; } +.dropdown-panel .dd-rail-links a:hover { color: var(--yellow); } + +/* module list */ +.dropdown-panel .dd-modules { display: grid; grid-template-columns: 1fr 1fr; gap: 3px; padding: 12px; background: #fff; } +.dropdown-panel .dd-modules a { + --m-bg: #eef1f7; --m-fg: var(--navy); + position: relative; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 11px; + padding: 11px 12px; border-radius: 11px; text-decoration: none; color: var(--ink); font-family: 'Inter', sans-serif; font-size: 14px; + transition: background .18s, transform .18s var(--ease-out); +} +.dropdown-panel .dd-modules a::before { + content: ""; position: absolute; left: 0; top: 11px; bottom: 11px; width: 3px; border-radius: 0 3px 3px 0; + background: var(--m-fg); transform: scaleY(0); transform-origin: center; transition: transform .2s var(--ease-out); +} +.dropdown-panel .dd-modules a:hover { background: color-mix(in srgb, var(--m-bg) 55%, #fff); transform: translateX(3px); } +.dropdown-panel .dd-modules a:hover::before { transform: scaleY(1); } +.dropdown-panel .dd-modules .icon-tile.sm { width: 34px; height: 34px; border-radius: 9px; transition: transform .18s var(--ease-out); } +.dropdown-panel .dd-modules a:hover .icon-tile.sm { transform: scale(1.07); } +.dropdown-panel .dd-text { min-width: 0; } +.dropdown-panel .dd-title { + font-family: 'Playfair Display', serif; font-weight: 700; font-size: 14.5px; color: var(--navy); + display: flex; align-items: center; gap: 7px; margin-bottom: 2px; line-height: 1.2; +} +.dropdown-panel .dd-title .dd-tag { + font-style: normal; font-family: 'Inter', sans-serif; font-size: 8px; font-weight: 800; letter-spacing: 0.6px; text-transform: uppercase; + color: var(--navy); background: var(--yellow); padding: 2px 6px; border-radius: 9999px; flex: 0 0 auto; +} +.dropdown-panel .dd-sub { display: block; font-size: 11.5px; color: var(--ink-soft); line-height: 1.4; } +.dropdown-panel .dd-arrow { + align-self: center; color: var(--m-fg); display: inline-flex; opacity: 0; transform: translateX(-4px); + transition: opacity .18s, transform .18s var(--ease-out); +} +.dropdown-panel .dd-modules a:hover .dd-arrow { opacity: 1; transform: translateX(0); } +.dropdown-panel .dd-arrow .icon-svg { width: 14px; height: 14px; } + +@media (max-width: 1080px) { + .dropdown-panel { width: 560px; } +} +@media (max-width: 820px) { + .dropdown-panel { width: 100%; left: 0; grid-template-columns: 1fr; } + .dropdown-panel .dd-rail { display: none; } + .dropdown-panel .dd-modules { grid-template-columns: 1fr; } +} +@media (prefers-reduced-motion: reduce) { + .dropdown-panel, .dropdown-panel .dd-modules a, .dropdown-panel .icon-tile.sm, + .has-dropdown > a::after, .dropdown-panel .dd-arrow { transition: none; } +} + +/* ---------- "Misleading" verdict (added for real fact-check data) ---------- */ +.v-misl { background: #b45309; } +.fc-tab.misl.active { background: #b45309; } +.fc-tab.misl.active .fc-tab-count { background: rgba(255,255,255,0.20); } +.fc-card.misleading .fc-card-cta { background: #b45309; } +.fc-card.misleading:hover .fc-card-cta { background: #92400e; } +.fc-verdict-banner.v-misl { background: linear-gradient(135deg, #b45309, #78350f); } + +/* ============================================================ + HOMEPAGE "VERIFY TOOLKIT" — interactive editorial showcase + (replaces the old .mf-grid mosaic) + ============================================================ */ +.toolkit { padding: 64px 0 70px; background: #fff; } +.toolkit .tk-head { + max-width: 1280px; margin: 0 auto 26px; padding: 0 24px; + display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; flex-wrap: wrap; +} +.toolkit .tk-head .k { + display: inline-block; font-size: 11px; font-weight: 800; letter-spacing: 1.6px; text-transform: uppercase; + color: var(--navy); background: var(--yellow-soft); padding: 5px 12px; border-radius: 2px; margin-bottom: 12px; +} +.toolkit .tk-head h2 { + font-family: 'Playfair Display', serif; font-size: clamp(28px, 3.4vw, 40px); line-height: 1.1; margin: 0; max-width: 22ch; +} +.toolkit .tk-grid { + max-width: 1280px; margin: 0 auto; padding: 0 24px; + display: grid; grid-template-columns: 0.92fr 1.08fr; gap: 36px; align-items: stretch; +} + +/* left: editorial directory of the six modules */ +.tk-list { display: flex; flex-direction: column; border-top: 1px solid var(--line); } +.tk-row { + --mc: var(--navy); + position: relative; display: grid; grid-template-columns: 30px 40px 1fr 16px; align-items: center; gap: 16px; + padding: 18px 14px 18px 16px; border-bottom: 1px solid var(--line); + text-decoration: none; color: inherit; cursor: pointer; + transition: background .3s var(--ease-out), padding-left .3s var(--ease-out); +} +.tk-row::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--mc); + transform: scaleY(0); transform-origin: center; transition: transform .35s var(--ease-out); +} +.tk-row .tk-no { font-family: 'Playfair Display', serif; font-size: 13px; font-weight: 700; color: var(--ink-faint); letter-spacing: 1px; transition: color .25s; } +.tk-row .tk-tile { + width: 40px; height: 40px; border-radius: 11px; flex: 0 0 auto; + display: flex; align-items: center; justify-content: center; + transition: transform .3s var(--ease-out); +} +.tk-row .tk-tile .icon-svg { width: 20px; height: 20px; } +.tk-row .tk-info h3 { font-family: 'Playfair Display', serif; font-size: 18px; font-weight: 700; color: var(--navy); margin: 0 0 2px; line-height: 1.2; } +.tk-row .tk-info p { font-size: 12.5px; color: var(--ink-soft); margin: 0; line-height: 1.4; } +.tk-row .tk-chev { color: var(--ink-faint); opacity: 0; transform: translateX(-5px); transition: opacity .25s, transform .25s var(--ease-out), color .25s; } +.tk-row .tk-chev .icon-svg { width: 16px; height: 16px; } +.tk-row:hover, .tk-row.is-current { + background: linear-gradient(90deg, color-mix(in srgb, var(--mc) 7%, #fff) 0%, #fff 70%); + padding-left: 22px; +} +.tk-row.is-current::before { transform: scaleY(1); } +.tk-row.is-current .tk-no { color: var(--mc); } +.tk-row:hover .tk-tile, .tk-row.is-current .tk-tile { transform: scale(1.07); } +.tk-row:hover .tk-chev, .tk-row.is-current .tk-chev { opacity: 1; transform: translateX(0); color: var(--mc); } + +/* right: the live preview panel (panels stacked in one grid cell, cross-fade) */ +.tk-stage { display: grid; } +.tk-panel { + grid-area: 1 / 1; opacity: 0; visibility: hidden; pointer-events: none; transform: scale(0.985) translateY(8px); + transition: opacity .4s ease, transform .55s var(--ease-out), visibility 0s linear .4s; + display: flex; flex-direction: column; overflow: hidden; + border: 1px solid var(--line); border-radius: var(--r-xl); background: #fff; + box-shadow: 0 24px 50px -22px var(--navy-glow), 0 10px 22px -12px rgba(15,23,42,0.12); +} +.tk-panel.is-active { opacity: 1; visibility: visible; pointer-events: auto; transform: none; transition: opacity .4s ease, transform .55s var(--ease-out), visibility 0s; } +.tk-panel .tk-pm { position: relative; aspect-ratio: 16 / 9; overflow: hidden; flex: 0 0 auto; } +.tk-panel .tk-pm img { width: 100%; height: 100%; object-fit: cover; display: block; transform: scale(1.04); transition: transform 6s ease-out; } +.tk-panel.is-active .tk-pm img { transform: scale(1.12); } +.tk-panel .tk-pm::after { + content: ""; position: absolute; inset: 0; + background: linear-gradient(180deg, rgba(4,21,53,0) 45%, rgba(4,21,53,0.1) 70%, rgba(4,21,53,0.42) 100%), + linear-gradient(120deg, color-mix(in srgb, var(--mc) 30%, transparent), transparent 55%); +} +.tk-panel .tk-pm .tk-pm-tag { + position: absolute; top: 16px; left: 16px; z-index: 2; + display: inline-flex; align-items: center; gap: 7px; + background: rgba(3,18,46,0.6); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); + border: 1px solid rgba(255,255,255,0.16); color: #fff; + font-size: 11px; font-weight: 700; padding: 7px 11px; border-radius: 9999px; +} +.tk-panel .tk-pm .tk-pm-tag .d { width: 6px; height: 6px; border-radius: 50%; background: var(--mc); box-shadow: 0 0 0 4px color-mix(in srgb, var(--mc) 30%, transparent); } +.tk-panel .tk-picon { + position: absolute; left: 22px; bottom: -24px; z-index: 3; + width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; + background: #fff; box-shadow: 0 12px 26px -8px rgba(15,23,42,0.3); border: 1px solid var(--line-soft); +} +.tk-panel .tk-picon .icon-svg { width: 24px; height: 24px; } +.tk-panel .tk-pb { padding: 38px 28px 26px; display: flex; flex-direction: column; flex: 1; } +.tk-panel .tk-pb .tk-pno { font-family: 'Playfair Display', serif; font-size: 11.5px; font-weight: 700; color: var(--ink-faint); letter-spacing: 1.4px; text-transform: uppercase; margin-bottom: 6px; } +.tk-panel .tk-pb h3 { font-family: 'Playfair Display', serif; font-size: clamp(22px, 2.4vw, 28px); line-height: 1.14; margin: 0 0 10px; color: var(--navy); } +.tk-panel .tk-pb .tk-lead { font-size: 14.5px; line-height: 1.62; color: var(--ink-soft); margin: 0 0 20px; } +.tk-panel .tk-pstats { display: flex; gap: 26px; padding: 15px 0; margin-bottom: 22px; border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); } +.tk-panel .tk-pstats b { display: block; font-family: 'Playfair Display', serif; font-size: 19px; font-weight: 800; color: var(--navy); } +.tk-panel .tk-pstats span { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--ink-faint); } +.tk-panel .tk-cta { + align-self: flex-start; margin-top: auto; display: inline-flex; align-items: center; gap: 9px; + background: var(--navy); color: #fff; font-weight: 700; font-size: 14px; + padding: 12px 20px; border-radius: 9999px; text-decoration: none; transition: background .2s, gap .2s; +} +.tk-panel .tk-cta:hover { background: var(--navy-dark); gap: 12px; } +.tk-panel .tk-cta .icon-svg { width: 15px; height: 15px; } + +@media (max-width: 940px) { + .toolkit .tk-grid { grid-template-columns: 1fr; gap: 22px; } + .tk-stage { display: none; } + .tk-row { grid-template-columns: 26px 38px 1fr 16px; } +} +@media (prefers-reduced-motion: reduce) { + .tk-row, .tk-panel, .tk-panel .tk-pm img, .tk-row .tk-tile, .tk-row .tk-chev { transition: none; } +} + +/* ---------- Module names: switch from Playfair serif to Inter sans ---------- */ +.tk-row .tk-info h3, +.tk-panel .tk-pb h3, +.mod-card h3, +.mhero h1 { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + letter-spacing: -0.018em; +} +.mod-card h3 { font-weight: 700; } +.tk-row .tk-info h3 { font-weight: 700; } +.tk-panel .tk-pb h3 { font-weight: 800; } +.mhero h1 { font-weight: 800; letter-spacing: -0.022em; } + +/* ============================================================ + ANALYSIS RESULT MODAL — fullscreen luxe popup for module reports + Module result HTML (#result-ioX, #process-ioX) is moved into one + of these modals at runtime by inline JS in verifier-module.html. + ============================================================ */ +.result-modal[hidden] { display: none; } +.result-modal { + position: fixed; inset: 0; z-index: 1000; + display: flex; flex-direction: column; + opacity: 0; visibility: hidden; + transition: opacity .38s ease, visibility 0s linear .38s; +} +.result-modal.is-open { + opacity: 1; visibility: visible; + transition: opacity .38s ease, visibility 0s linear 0s; +} +.result-modal__backdrop { + position: absolute; inset: 0; cursor: pointer; + background: + radial-gradient(120% 80% at 50% 0%, rgba(5,41,98,0.55), rgba(3,16,40,0.86) 60%, rgba(3,16,40,0.94)), + rgba(3,16,40,0.6); + -webkit-backdrop-filter: blur(10px) saturate(1.15); + backdrop-filter: blur(10px) saturate(1.15); +} +.result-modal__panel { + position: relative; z-index: 1; align-self: center; + margin: 4vh auto; width: calc(100% - 32px); max-width: 1180px; + max-height: 92vh; display: flex; flex-direction: column; + background: #fff; border-radius: 20px; overflow: hidden; + box-shadow: 0 60px 110px -28px rgba(0,0,0,0.55), 0 24px 48px -22px rgba(0,0,0,0.3); + transform: translateY(36px) scale(0.985); + transition: transform .55s var(--ease-out); +} +.result-modal.is-open .result-modal__panel { transform: translateY(0) scale(1); } +/* coloured top stripe — themed per module (--mc set on .result-modal) */ +.result-modal__panel::before { + content: ""; position: absolute; top: 0; left: 0; right: 0; height: 4px; z-index: 3; + background: linear-gradient(90deg, var(--mc, var(--yellow)) 0%, color-mix(in srgb, var(--mc, var(--yellow)) 55%, white) 100%); +} +/* faint colored aura around the panel */ +.result-modal__panel::after { + content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 0; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--mc, var(--navy)) 10%, transparent); + border-radius: 20px; +} + +.result-modal__head { + position: sticky; top: 0; z-index: 2; + padding: 18px 28px 14px; border-bottom: 1px solid var(--line); + background: rgba(255,255,255,0.95); + -webkit-backdrop-filter: blur(12px) saturate(1.2); + backdrop-filter: blur(12px) saturate(1.2); +} +.result-modal__head .rm-head-top { display: flex; align-items: center; gap: 16px; } +/* small thematic "report cover" thumbnail */ +.result-modal__head .rm-head-thumb { + flex: 0 0 auto; width: 60px; height: 60px; border-radius: 13px; overflow: hidden; position: relative; + border: 1px solid color-mix(in srgb, var(--mc, var(--navy)) 28%, var(--line)); + box-shadow: 0 8px 20px -10px color-mix(in srgb, var(--mc, var(--navy)) 45%, transparent), 0 1px 2px rgba(15,23,42,0.08); +} +.result-modal__head .rm-head-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } +.result-modal__head .rm-head-thumb::after { + content: ""; position: absolute; inset: 0; + background: linear-gradient(155deg, color-mix(in srgb, var(--mc, var(--navy)) 30%, transparent), transparent 55%), + linear-gradient(0deg, rgba(4,21,53,0.28), transparent 60%); +} +.result-modal__head .rm-head-titles { display: flex; flex-direction: column; gap: 6px; min-width: 0; flex: 1; } +.result-modal__head .rm-head-titles .rm-kicker { align-self: flex-start; } +.result-modal__head .rm-kicker { + font-size: 10px; font-weight: 800; letter-spacing: 1.4px; text-transform: uppercase; + color: var(--navy); background: var(--yellow); padding: 5px 11px; border-radius: 2px; white-space: nowrap; +} +.result-modal__head .rm-title { + font-family: 'Playfair Display', serif; font-size: clamp(19px, 2vw, 24px); font-weight: 800; + margin: 0; color: var(--navy); line-height: 1.15; letter-spacing: -0.01em; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; +} +.result-modal__head .rm-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; flex: 0 0 auto; } +.result-modal__head .rm-btn { + display: inline-flex; align-items: center; gap: 7px; + background: transparent; border: 1px solid var(--line); color: var(--navy); + font-family: 'Inter', sans-serif; font-weight: 600; font-size: 12.5px; + padding: 8px 14px; border-radius: 9999px; cursor: pointer; + transition: background .15s, border-color .15s, color .15s; +} +.result-modal__head .rm-btn:hover { background: var(--bg-soft); border-color: var(--navy); } +.result-modal__head .rm-close { + width: 38px; height: 38px; border-radius: 50%; + background: var(--bg-soft); border: 1px solid var(--line); color: var(--navy); + font-size: 14px; font-weight: 700; cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + transition: background .15s, color .15s, transform .25s var(--ease-out); +} +.result-modal__head .rm-close:hover { background: var(--navy); color: #fff; transform: rotate(90deg); } + +/* report metadata row */ +.result-modal__head .rm-meta { + margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--line-soft); + display: flex; flex-wrap: wrap; gap: 18px; + font-size: 11px; color: var(--ink-faint); letter-spacing: 0.5px; text-transform: uppercase; +} +.result-modal__head .rm-meta-item { display: inline-flex; align-items: center; gap: 7px; } +.result-modal__head .rm-meta-item .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--mc, var(--navy)); opacity: 0.85; } +.result-modal__head .rm-meta b { + color: var(--navy); font-weight: 800; text-transform: none; letter-spacing: 0; + font-family: 'Playfair Display', serif; font-size: 14px; +} + +.result-modal__body { + flex: 1 1 auto; overflow-y: auto; padding: 30px 36px 0; + scrollbar-width: thin; scrollbar-color: var(--line) transparent; +} +.result-modal__body::-webkit-scrollbar { width: 10px; } +.result-modal__body::-webkit-scrollbar-thumb { background: var(--line); border-radius: 10px; border: 2px solid #fff; } +.result-modal__body::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); } +.result-modal__body > [id^="result-"], +.result-modal__body > [id^="process-"] { margin: 0; } + +/* staggered fade-in of report sections on open */ +.result-modal.is-open .result-modal__body [id^="result-"] > *, +.result-modal.is-open .result-modal__body > .rm-footer { + animation: rm-rise .55s var(--ease-out) backwards; +} +.result-modal.is-open .result-modal__body [id^="result-"] > *:nth-child(1) { animation-delay: 0.14s; } +.result-modal.is-open .result-modal__body [id^="result-"] > *:nth-child(2) { animation-delay: 0.22s; } +.result-modal.is-open .result-modal__body [id^="result-"] > *:nth-child(3) { animation-delay: 0.30s; } +.result-modal.is-open .result-modal__body [id^="result-"] > *:nth-child(4) { animation-delay: 0.38s; } +.result-modal.is-open .result-modal__body [id^="result-"] > *:nth-child(5) { animation-delay: 0.46s; } +.result-modal.is-open .result-modal__body [id^="result-"] > *:nth-child(6) { animation-delay: 0.54s; } +.result-modal.is-open .result-modal__body > .rm-footer { animation-delay: 0.60s; } +@keyframes rm-rise { from { opacity: 0; transform: translateY(22px); } to { opacity: 1; transform: none; } } + +/* in-body footer with actions */ +.rm-footer { + margin: 38px -36px 0; padding: 28px 36px 32px; + border-top: 1px solid var(--line); + background: linear-gradient(180deg, #ffffff, #f8fafc); + display: flex; flex-wrap: wrap; gap: 14px; align-items: center; justify-content: space-between; +} +.rm-footer .rm-foot-msg { font-size: 13.5px; color: var(--ink-soft); display: inline-flex; align-items: center; gap: 9px; } +.rm-footer .rm-foot-msg .seal { + width: 28px; height: 28px; border-radius: 50%; + background: color-mix(in srgb, var(--mc, var(--navy)) 14%, #fff); + color: var(--mc, var(--navy)); + display: inline-flex; align-items: center; justify-content: center; font-weight: 800; font-size: 14px; +} +.rm-footer .rm-foot-actions { display: flex; gap: 10px; flex-wrap: wrap; } +.rm-footer .rm-foot-btn { + display: inline-flex; align-items: center; gap: 8px; + background: var(--navy); color: #fff; font-weight: 700; font-size: 13px; + padding: 11px 18px; border-radius: 9999px; text-decoration: none; cursor: pointer; + border: 0; font-family: inherit; + transition: background .15s, gap .15s; +} +.rm-footer .rm-foot-btn:hover { background: var(--navy-dark); gap: 11px; } +.rm-footer .rm-foot-btn.ghost { background: transparent; border: 1px solid var(--line); color: var(--navy); } +.rm-footer .rm-foot-btn.ghost:hover { background: var(--bg-soft); border-color: var(--navy); } + +/* hard scroll-lock when a modal is open */ +body.result-open { overflow: hidden; } + +@media (max-width: 720px) { + .result-modal__panel { margin: 0; max-height: 100vh; width: 100%; border-radius: 0; } + .result-modal__body { padding: 20px 18px 0; } + .result-modal__head { padding: 14px 16px 12px; } + .result-modal__head .rm-head-top { gap: 10px; } + .result-modal__head .rm-head-thumb { width: 46px; height: 46px; border-radius: 10px; } + .result-modal__head .rm-title { font-size: 16px; } + .result-modal__head .rm-btn { font-size: 11.5px; padding: 7px 11px; } + .result-modal__head .rm-btn .lbl { display: none; } + .result-modal__head .rm-meta { gap: 12px; font-size: 10.5px; } + .result-modal__head .rm-meta b { font-size: 12.5px; } + .rm-footer { margin: 28px -18px 0; padding: 22px 18px 26px; flex-direction: column; align-items: flex-start; gap: 14px; } + .rm-footer .rm-foot-actions { width: 100%; } + .rm-footer .rm-foot-btn { flex: 1 1 auto; justify-content: center; } +} +@media (prefers-reduced-motion: reduce) { + .result-modal, .result-modal__panel { transition: none; } + .result-modal.is-open .result-modal__body > * { animation: none; } + .result-modal__head .rm-close:hover { transform: none; } +} + +/* PRINT: turn the modal body into the printed page */ +@media print { + body { background: #fff !important; overflow: visible !important; } + body > *:not(.result-modal) { display: none !important; } + .result-modal { position: static !important; opacity: 1 !important; visibility: visible !important; transform: none !important; display: block !important; } + .result-modal[hidden] { display: block !important; } + .result-modal__backdrop, .result-modal__head .rm-actions, .rm-footer { display: none !important; } + .result-modal.is-open .result-modal__body > * { animation: none !important; } + .result-modal__panel { box-shadow: none !important; border-radius: 0; max-height: none !important; transform: none !important; margin: 0 !important; max-width: 100% !important; width: 100% !important; } + .result-modal__panel::before { display: none; } + .result-modal__head { position: static !important; padding: 0 0 12px; border-bottom: 1px solid #ddd; background: #fff !important; } + .result-modal__body { overflow: visible !important; padding: 16px 0 0 !important; } +} + +/* ============================================================ + ADVANCED REPORT LAYER — TOC sidebar, watermark, intro overlay, + forensic signature seal. Layered on top of the .result-modal base. + ============================================================ */ + +/* ── Layout: head + (toc | body), instead of head + body ──────────── */ +.result-modal__panel.has-rm-layout { display: grid; grid-template-rows: auto 1fr; } +.result-modal__panel.has-rm-layout .rm-layout { + display: grid; grid-template-columns: 248px 1fr; + min-height: 0; overflow: hidden; position: relative; +} +.result-modal__panel.has-rm-layout .result-modal__body { min-height: 0; } +@media (max-width: 880px) { + .result-modal__panel.has-rm-layout .rm-layout { grid-template-columns: 1fr; } + .result-modal__panel.has-rm-layout .rm-toc { display: none; } +} + +/* ── TOC sidebar ──────────────────────────────────────────────────── */ +.rm-toc { + background: linear-gradient(180deg, #fafbfd 0%, #f4f7fa 100%); + border-right: 1px solid var(--line); + padding: 26px 22px 28px; overflow-y: auto; + font-family: 'Inter', sans-serif; +} +.rm-toc h4 { + font-size: 9.5px; font-weight: 800; letter-spacing: 1.4px; + text-transform: uppercase; color: var(--ink-faint); margin: 0 0 14px; +} +.rm-toc__nav { display: flex; flex-direction: column; gap: 2px; margin-bottom: 28px; } +.rm-toc__nav a { + display: grid; grid-template-columns: 24px 1fr; gap: 10px; align-items: center; + padding: 9px 11px; border-radius: 9px; cursor: pointer; + font-size: 13px; color: var(--ink-soft); text-decoration: none; line-height: 1.35; + border-left: 2px solid transparent; + transition: background .15s, color .15s, border-color .2s, padding-left .2s; +} +.rm-toc__nav a .n { + font-family: 'Playfair Display', serif; font-weight: 800; font-size: 13px; + color: var(--ink-faint); transition: color .15s; +} +.rm-toc__nav a:hover { background: rgba(0,0,0,0.03); color: var(--navy); } +.rm-toc__nav a.is-current { + background: color-mix(in srgb, var(--mc, var(--navy)) 10%, #fff); + color: var(--navy); border-left-color: var(--mc, var(--navy)); padding-left: 13px; +} +.rm-toc__nav a.is-current .n { color: var(--mc, var(--navy)); } + +.rm-toc__about { border-top: 1px solid var(--line); padding-top: 18px; } +.rm-toc__about h5 { + font-size: 9.5px; font-weight: 800; letter-spacing: 1.4px; + text-transform: uppercase; color: var(--ink-faint); margin: 0 0 10px; +} +.rm-toc__about p { font-size: 12.5px; line-height: 1.55; color: var(--ink-soft); margin: 0 0 12px; } +.rm-toc__about ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; } +.rm-toc__about li { + font-size: 11.5px; color: var(--ink-soft); display: flex; align-items: center; gap: 8px; +} +.rm-toc__about li::before { + content: ""; width: 5px; height: 5px; border-radius: 50%; + background: var(--mc, var(--navy)); flex: 0 0 auto; opacity: 0.7; +} + +/* ── Watermark behind the body ────────────────────────────────────── */ +.rm-watermark { + position: absolute; right: -4%; bottom: -6%; + font-family: 'Playfair Display', serif; font-weight: 800; + font-size: clamp(180px, 24vw, 320px); line-height: 1; + color: rgba(5, 41, 98, 0.028); + letter-spacing: -0.04em; + pointer-events: none; user-select: none; + transform: rotate(-10deg); + z-index: 0; +} +.result-modal__body > * { position: relative; z-index: 1; } + +/* ── Compilation intro overlay (modal open theatrics) ────────────── */ +.rm-intro { + position: absolute; inset: 0; z-index: 10; + background: linear-gradient(180deg, rgba(255,255,255,0.97), rgba(248,250,253,0.97)); + -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; + opacity: 1; transition: opacity .55s ease; +} +.rm-intro.is-hidden { opacity: 0; pointer-events: none; } +.rm-intro .label { + font-family: 'Inter', sans-serif; font-size: 10.5px; font-weight: 800; letter-spacing: 1.8px; + text-transform: uppercase; color: var(--ink-faint); + display: inline-flex; align-items: center; gap: 8px; +} +.rm-intro .label .pulse { width: 7px; height: 7px; border-radius: 50%; background: var(--mc, var(--navy)); animation: rm-intro-pulse 1.4s ease-out infinite; } +@keyframes rm-intro-pulse { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--mc, var(--navy)) 50%, transparent); } + 70% { box-shadow: 0 0 0 10px transparent; } + 100% { box-shadow: 0 0 0 0 transparent; } +} +.rm-intro .text { + font-family: 'Playfair Display', serif; font-size: clamp(22px, 2.6vw, 30px); font-weight: 700; + color: var(--navy); letter-spacing: -0.01em; +} +.rm-intro .bar { + width: 240px; max-width: 60vw; height: 3px; background: #eef1f5; border-radius: 99px; overflow: hidden; +} +.rm-intro .bar > i { + display: block; height: 100%; width: 28%; + background: linear-gradient(90deg, transparent, var(--mc, var(--navy)), transparent); + animation: rm-intro-bar 1.3s ease-in-out infinite; +} +@keyframes rm-intro-bar { + 0% { transform: translateX(-110%); } + 100% { transform: translateX(440%); } +} +.rm-intro .scan { + position: absolute; left: 0; right: 0; top: 40%; height: 2px; + background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--mc, var(--navy)) 80%, white), transparent); + box-shadow: 0 0 24px 2px color-mix(in srgb, var(--mc, var(--navy)) 50%, transparent); + opacity: 0.6; + animation: rm-intro-scan 1.6s linear infinite; +} +@keyframes rm-intro-scan { + 0% { top: 18%; opacity: 0; } + 10% { opacity: 0.8; } + 90% { opacity: 0.8; } + 100% { top: 82%; opacity: 0; } +} + +/* ── Forensic signature seal block ────────────────────────────────── */ +.rm-signature { + margin: 36px 0 0; padding: 26px 28px; + background: linear-gradient(135deg, #fbfcfe 0%, #f5f7fa 100%); + border: 1px solid var(--line); border-radius: 14px; + display: grid; grid-template-columns: auto 1fr auto; gap: 28px; align-items: center; + position: relative; overflow: hidden; +} +.rm-signature::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; + background: linear-gradient(180deg, var(--mc, var(--navy)), color-mix(in srgb, var(--mc, var(--navy)) 50%, white)); +} +.rm-signature .seal { + width: 86px; height: 86px; border-radius: 50%; + border: 2px solid var(--mc, var(--navy)); + display: flex; align-items: center; justify-content: center; flex-direction: column; + color: var(--mc, var(--navy)); + font-family: 'Inter', sans-serif; font-weight: 800; font-size: 8.5px; letter-spacing: 1.4px; + position: relative; text-align: center; + transform: rotate(-7deg); + flex: 0 0 auto; +} +.rm-signature .seal::before { + content: ""; position: absolute; inset: 5px; border-radius: 50%; border: 1px dashed currentColor; +} +.rm-signature .seal strong { + font-family: 'Playfair Display', serif; font-size: 17px; font-weight: 800; + letter-spacing: -0.01em; margin: 2px 0; line-height: 1; color: var(--mc, var(--navy)); +} +.rm-signature .seal .yr { font-size: 8.5px; letter-spacing: 1.2px; opacity: 0.85; } + +.rm-signature .meta { + display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 14px 26px; margin: 0; +} +.rm-signature .meta dt { + font-size: 9.5px; font-weight: 800; letter-spacing: 1.2px; text-transform: uppercase; + color: var(--ink-faint); margin: 0; +} +.rm-signature .meta dd { + font-size: 13px; color: var(--navy); margin: 2px 0 0; font-weight: 600; + font-variant-numeric: tabular-nums; +} +.rm-signature .meta dd.mono { font-family: ui-monospace, Menlo, monospace; font-size: 11px; font-weight: 500; color: var(--ink-soft); } + +.rm-signature .by { + display: flex; flex-direction: column; align-items: flex-end; gap: 3px; + border-left: 1px solid var(--line); padding-left: 24px; +} +.rm-signature .by .lbl { font-size: 9px; letter-spacing: 1.2px; text-transform: uppercase; color: var(--ink-faint); font-weight: 800; } +.rm-signature .by .name { font-family: 'Playfair Display', serif; font-size: 24px; font-weight: 800; color: var(--navy); letter-spacing: -0.015em; } +.rm-signature .by .name .ai { color: var(--mc, var(--navy)); font-style: italic; } + +@media (max-width: 720px) { + .rm-signature { grid-template-columns: auto 1fr; gap: 18px; padding: 20px; } + .rm-signature .by { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); padding: 16px 0 0; align-items: flex-start; } + .rm-watermark { font-size: 140px; right: -10%; bottom: -10%; } +} + +/* keep the staggered fade-in working alongside new sibling elements */ +.result-modal.is-open .result-modal__body > .rm-signature { animation: rm-rise .55s var(--ease-out) backwards; animation-delay: 0.56s; } + +/* PRINT: hide TOC, intro, watermark; keep the seal block (it's part of the report) */ +@media print { + .rm-toc, .rm-intro, .rm-watermark { display: none !important; } + .result-modal__panel.has-rm-layout .rm-layout { display: block !important; } + .rm-signature { page-break-inside: avoid; } +} + +/* ============================================================ + DASHBOARD MODE — the result report renders as an analytics + bento dashboard (KPI strip + status bar + bento panels). + ============================================================ */ + +/* canvas: faint dot-grid behind the report */ +.result-modal__body { + background-image: radial-gradient(circle, rgba(15,23,42,0.04) 1px, transparent 1.5px); + background-size: 26px 26px; background-attachment: local; +} + +/* ── Status bar (top of the report body) ──────────────────────────── */ +.rm-statusbar { + display: flex; align-items: center; gap: 16px; flex-wrap: wrap; + margin: 0 0 18px; padding: 12px 18px; + background: var(--navy-dark); color: #fff; border-radius: 12px; + font-family: 'Inter', sans-serif; font-size: 12px; +} +.rm-statusbar .st-led { + display: inline-flex; align-items: center; gap: 9px; font-weight: 700; letter-spacing: 0.4px; +} +.rm-statusbar .st-led .dot { + width: 8px; height: 8px; border-radius: 50%; background: #34d399; + box-shadow: 0 0 0 0 rgba(52,211,153,0.5); animation: rm-led 1.9s ease-out infinite; +} +@keyframes rm-led { 0% { box-shadow: 0 0 0 0 rgba(52,211,153,0.5); } 70% { box-shadow: 0 0 0 7px rgba(52,211,153,0); } 100% { box-shadow: 0 0 0 0 rgba(52,211,153,0); } } +.rm-statusbar .st-item { display: inline-flex; align-items: center; gap: 7px; color: rgba(255,255,255,0.7); } +.rm-statusbar .st-item b { color: #fff; font-weight: 600; font-variant-numeric: tabular-nums; } +.rm-statusbar .st-item .mono { font-family: ui-monospace, Menlo, monospace; font-size: 11px; } +.rm-statusbar .st-item + .st-item::before { content: ""; width: 1px; height: 12px; background: rgba(255,255,255,0.16); margin-right: 7px; } +.rm-statusbar .st-spacer { margin-left: auto; } +.rm-statusbar .st-bars { display: inline-flex; gap: 3px; align-items: flex-end; height: 16px; } +.rm-statusbar .st-bars i { width: 3px; background: var(--mc, #34d399); border-radius: 1px; animation: rm-bars 1.2s ease-in-out infinite; opacity: 0.85; } +.rm-statusbar .st-bars i:nth-child(1) { height: 40%; animation-delay: 0s; } +.rm-statusbar .st-bars i:nth-child(2) { height: 75%; animation-delay: .15s; } +.rm-statusbar .st-bars i:nth-child(3) { height: 55%; animation-delay: .3s; } +.rm-statusbar .st-bars i:nth-child(4) { height: 90%; animation-delay: .45s; } +.rm-statusbar .st-bars i:nth-child(5) { height: 30%; animation-delay: .6s; } +@keyframes rm-bars { 0%,100% { transform: scaleY(.6); } 50% { transform: scaleY(1); } } + +/* ── KPI tile strip ───────────────────────────────────────────────── */ +.rm-kpi { + display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; + margin: 0 0 20px; +} +.rm-kpi__tile { + position: relative; overflow: hidden; + background: linear-gradient(160deg, #ffffff 0%, #f7f9fc 100%); + border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px 15px; + box-shadow: 0 1px 2px rgba(15,23,42,0.04); +} +.rm-kpi__tile::before { + content: ""; position: absolute; top: 0; left: 0; right: 0; height: 3px; + background: var(--kpi-c, var(--mc, var(--navy))); +} +.rm-kpi__tile::after { + content: ""; position: absolute; right: -28px; top: -28px; width: 80px; height: 80px; border-radius: 50%; + background: radial-gradient(circle, color-mix(in srgb, var(--kpi-c, var(--mc, var(--navy))) 12%, transparent), transparent 65%); + pointer-events: none; +} +.rm-kpi__tile .lbl { font-size: 9.5px; font-weight: 800; letter-spacing: 1.2px; text-transform: uppercase; color: var(--ink-faint); position: relative; z-index: 1; } +.rm-kpi__tile .val { font-family: 'Playfair Display', serif; font-size: 26px; font-weight: 800; color: var(--navy); margin-top: 6px; line-height: 1.05; letter-spacing: -0.015em; position: relative; z-index: 1; } +.rm-kpi__tile .val.sm { font-size: 17px; line-height: 1.25; } +.rm-kpi__tile .val.is-fake { color: #b91c1c; } +.rm-kpi__tile .val.is-real { color: #15803d; } +.rm-kpi__tile .bar { margin-top: 10px; height: 5px; border-radius: 99px; background: #eef1f5; overflow: hidden; position: relative; z-index: 1; } +.rm-kpi__tile .bar > i { display: block; height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--kpi-c, var(--mc, var(--navy))), color-mix(in srgb, var(--kpi-c, var(--mc, var(--navy))) 50%, white)); transition: width 1s var(--ease-out); } +.rm-kpi__tile .led2 { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 700; color: var(--navy); margin-top: 6px; position: relative; z-index: 1; } +.rm-kpi__tile .led2 .dot { width: 8px; height: 8px; border-radius: 50%; background: #16a34a; box-shadow: 0 0 0 0 rgba(22,163,74,0.5); animation: rm-led 1.9s ease-out infinite; } +.rm-kpi__tile .spark { display: flex; gap: 2px; align-items: flex-end; height: 18px; margin-top: 8px; position: relative; z-index: 1; } +.rm-kpi__tile .spark i { flex: 1; background: color-mix(in srgb, var(--kpi-c, var(--mc, var(--navy))) 35%, #eef1f5); border-radius: 1px; } + +/* ── Bento panels for the result content (.coh-results) ───────────── */ +.result-modal .coh-results { + display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 460px), 1fr)); + gap: 18px; align-items: start; +} +.result-modal .coh-results > [class*="hero"], +.result-modal .coh-results > [class*="banner"], +.result-modal .coh-results > .coh-card:last-child { grid-column: 1 / -1; } +.result-modal .coh-results > .coh-card:has(.io1-heat-grid), +.result-modal .coh-results > .coh-card:has(.io1-explain), +.result-modal .coh-results > .coh-card:has([class*="grid"]) { grid-column: 1 / -1; } +.result-modal .coh-results > .coh-card { margin-top: 0 !important; } + +/* dashboard "panel chrome" on each card */ +.result-modal .coh-results > .coh-card { position: relative; } +.result-modal .coh-results > .coh-card::after { + content: "•••"; position: absolute; top: 16px; right: 18px; + color: var(--line); font-size: 14px; letter-spacing: 1px; line-height: 1; + pointer-events: none; +} + +/* spacing of the body's flow children now that there's a KPI strip */ +.result-modal__body > .rm-kpi { animation: rm-rise .55s var(--ease-out) backwards; animation-delay: 0.10s; } +.result-modal.is-open .result-modal__body > .rm-statusbar { animation: rm-rise .5s var(--ease-out) backwards; animation-delay: 0.06s; } + +/* ── Sidebar verdict widget (top of TOC) ──────────────────────────── */ +.rm-toc__verdict { + margin-bottom: 18px; padding: 14px 14px 13px; border-radius: 12px; + background: linear-gradient(160deg, color-mix(in srgb, var(--mc, var(--navy)) 8%, #fff), #fff); + border: 1px solid color-mix(in srgb, var(--mc, var(--navy)) 18%, var(--line)); +} +.rm-toc__verdict .vw-lbl { font-size: 9px; font-weight: 800; letter-spacing: 1.4px; text-transform: uppercase; color: var(--ink-faint); } +.rm-toc__verdict .vw-val { font-family: 'Playfair Display', serif; font-size: 18px; font-weight: 800; color: var(--navy); margin-top: 4px; line-height: 1.15; } +.rm-toc__verdict .vw-val.is-fake { color: #b91c1c; } +.rm-toc__verdict .vw-val.is-real { color: #15803d; } +.rm-toc__verdict .vw-conf { display: flex; align-items: center; gap: 8px; margin-top: 9px; font-size: 12px; color: var(--ink-soft); } +.rm-toc__verdict .vw-conf .ring { width: 28px; height: 28px; border-radius: 50%; background: conic-gradient(var(--mc, var(--navy)) calc(var(--p, 0) * 1%), #eef1f5 0); display: flex; align-items: center; justify-content: center; flex: 0 0 auto; } +.rm-toc__verdict .vw-conf .ring::before { content: ""; width: 18px; height: 18px; border-radius: 50%; background: #fff; } + +@media (max-width: 720px) { + .rm-statusbar { gap: 10px; padding: 11px 14px; } + .rm-statusbar .st-spacer { display: none; } + .rm-kpi { grid-template-columns: repeat(2, 1fr); } + .result-modal__body { background-size: 22px 22px; } +} +@media (prefers-reduced-motion: reduce) { + .rm-statusbar .st-led .dot, .rm-statusbar .st-bars i, .rm-kpi__tile .led2 .dot { animation: none; } +} +@media print { + .rm-statusbar .st-bars, .result-modal .coh-results > .coh-card::after { display: none !important; } + .result-modal__body { background-image: none !important; } +} + +/* ============================================================ + CINEMATIC BACKDROP — when a result opens, the whole screen + becomes a heavily-blurred, navy-tinted view of the analysed + media; the clean white report card floats on top. + ============================================================ */ +.result-modal__bgimg { position: absolute; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; } +.result-modal__bgimg img { + width: 100%; height: 100%; object-fit: cover; display: block; + filter: blur(64px) saturate(1.05) brightness(0.5); transform: scale(1.34); + animation: lux-kenburns 38s ease-in-out infinite alternate; +} +/* the dimming layer over the blurred image — semi-transparent so the image reads through */ +.result-modal__backdrop { + z-index: 1; + background: + radial-gradient(120% 80% at 50% -10%, color-mix(in srgb, var(--mc, var(--navy)) 22%, transparent), transparent 55%), + linear-gradient(165deg, rgba(5,41,98,0.52) 0%, rgba(3,16,40,0.74) 52%, rgba(3,16,40,0.86) 100%); +} +/* the report card — clean white, a colour-tinted glow, the blurred backdrop softly showing through */ +.result-modal__panel { + z-index: 2; + background: rgba(255,255,255,0.93); + -webkit-backdrop-filter: blur(26px) saturate(1.4); backdrop-filter: blur(26px) saturate(1.4); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--mc, var(--navy)) 12%, transparent), + 0 50px 120px -24px rgba(0,0,0,0.6), + 0 24px 60px -28px color-mix(in srgb, var(--mc, var(--navy)) 50%, transparent); +} +.result-modal__panel::after { box-shadow: none; } /* drop the old inset aura — the new shadow covers it */ + +/* panel-internal surfaces stay frosted-white so the soft undertone shows */ +.result-modal__head { + background: rgba(255,255,255,0.78); + -webkit-backdrop-filter: blur(22px) saturate(1.5); backdrop-filter: blur(22px) saturate(1.5); +} +.result-modal__body { background-image: none; } +.rm-toc { + background: rgba(250,252,254,0.62); + -webkit-backdrop-filter: blur(18px) saturate(1.3); backdrop-filter: blur(18px) saturate(1.3); +} +.rm-statusbar { + background: rgba(3,16,40,0.82); + -webkit-backdrop-filter: blur(16px) saturate(1.3); backdrop-filter: blur(16px) saturate(1.3); + border: 1px solid rgba(255,255,255,0.08); +} +.rm-statusbar .st-bars i { background: var(--yellow); } +.rm-kpi__tile { + background: linear-gradient(160deg, rgba(255,255,255,0.74), rgba(246,248,251,0.66)); + -webkit-backdrop-filter: blur(18px) saturate(1.5); backdrop-filter: blur(18px) saturate(1.5); + border-color: rgba(255,255,255,0.55); + box-shadow: 0 12px 30px -16px rgba(5,41,98,0.24), 0 1px 2px rgba(15,23,42,0.04); +} +.rm-signature { background: linear-gradient(135deg, rgba(255,255,255,0.76), rgba(245,247,250,0.7)); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); } +.rm-footer { background: linear-gradient(180deg, rgba(255,255,255,0.84), rgba(248,250,253,0.8)); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); } +.rm-intro { background: linear-gradient(180deg, rgba(255,255,255,0.9), rgba(248,250,253,0.93)); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); } + +/* fallback where backdrop-filter is unsupported — solid surfaces, the image still shows around the card */ +@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { + .result-modal__panel { background: #fff; } + .result-modal__head { background: #fff; } + .rm-toc { background: linear-gradient(180deg, #fafbfd, #f4f7fa); } + .rm-statusbar { background: var(--navy-dark); } + .rm-kpi__tile { background: linear-gradient(160deg, #fff, #f7f9fc); } +} + +@media (prefers-reduced-motion: reduce) { .result-modal__bgimg img { animation: none; } } +@media print { + .result-modal__bgimg { display: none !important; } + .result-modal__panel { background: #fff !important; -webkit-backdrop-filter: none; backdrop-filter: none; } + .result-modal__backdrop { display: none !important; } +} + +/* ════════════════════════════════════════════════════════════════════════ + XAI — « Lecture du résultat » + La carte qui traduit chaque verdict en langage clair, sans jargon, pour + l'utilisateur qui consulte son analyse. Rendue par window.VerifyXAI.narrativeCard() + (components.js) et utilisée par les 5 modules. --xai-accent = couleur du module. + ════════════════════════════════════════════════════════════════════════ */ +.xai-read{ + position:relative; margin:26px 0; padding:24px 30px 20px 34px; + background: + radial-gradient(120% 90% at 100% 0%, color-mix(in srgb, var(--xai-accent,#052962) 7%, transparent), transparent 60%), + linear-gradient(180deg,#ffffff,#fbfaf6); + border:1px solid var(--line,#e6e3da); border-radius:18px; + box-shadow:0 1px 2px rgba(15,23,42,.05), 0 22px 48px -30px color-mix(in srgb, var(--xai-accent,#052962) 26%, transparent); + overflow:hidden; +} +.xai-read__rail{ position:absolute; left:0; top:0; bottom:0; width:5px; background:var(--xai-accent,#052962); } +.xai-read--ok { --xai-accent:#15803d; } +.xai-read--alert { --xai-accent:#b91c1c; } +.xai-read--warn { --xai-accent:#b45309; } + +.xai-read__head{ display:flex; align-items:center; gap:16px 26px; flex-wrap:wrap; } +.xai-read__kicker{ + flex-basis:100%; font-family:ui-monospace,Menlo,monospace; font-size:10.5px; letter-spacing:2.2px; + text-transform:uppercase; color:#9ca3af; margin-bottom:2px; +} +.xai-read__verdict{ display:flex; align-items:center; gap:13px; flex:1; min-width:220px; } +.xai-read__ico{ + width:38px; height:38px; flex:none; border-radius:50%; display:grid; place-items:center; + background:color-mix(in srgb, var(--xai-accent,#052962) 13%, #fff); color:var(--xai-accent,#052962); +} +.xai-read__ico svg{ width:20px; height:20px; } +.xai-read__verdict h3{ + margin:0; font-family:'Playfair Display',Georgia,serif; font-weight:800; line-height:1.1; + font-size:clamp(20px,2.4vw,28px); color:#0f1b33; +} +.xai-read__conf{ display:flex; flex-direction:column; gap:6px; min-width:168px; } +.xai-read__conf-bar{ height:7px; border-radius:99px; background:#eceae3; overflow:hidden; } +.xai-read__conf-bar i{ display:block; height:100%; border-radius:99px; background:var(--xai-accent,#052962); transition:width 1s var(--ease-out,ease); } +.xai-read__conf-lbl{ font-size:12px; font-weight:700; color:var(--xai-accent,#052962); letter-spacing:.2px; } + +.xai-read__lede{ + margin:16px 0 2px; font-family:'Playfair Display',Georgia,serif; font-size:clamp(16px,1.8vw,19px); + line-height:1.62; color:#243043; +} +.xai-read__cols{ display:grid; grid-template-columns:1fr 1fr; gap:16px 30px; margin-top:18px; } +@media (max-width:680px){ .xai-read__cols{ grid-template-columns:1fr; } } +.xai-read__col-h{ + display:flex; align-items:center; gap:9px; font-size:11px; font-family:ui-monospace,Menlo,monospace; + letter-spacing:1.2px; text-transform:uppercase; color:#6b7280; margin-bottom:9px; +} +.xai-read__col-h svg{ width:15px; height:15px; color:var(--xai-accent,#052962); flex:none; } +.xai-read__col ul{ margin:0; padding:0; list-style:none; display:flex; flex-direction:column; gap:8px; } +.xai-read__col li{ position:relative; padding-left:20px; font-size:14px; line-height:1.5; color:#3b4658; } +.xai-read__col li::before{ + content:""; position:absolute; left:2px; top:7px; width:7px; height:7px; border-radius:2px; + background:var(--xai-accent,#052962); transform:rotate(45deg); +} +.xai-read__col--checked li::before{ + background:transparent; border:1.6px solid color-mix(in srgb,var(--xai-accent,#052962) 55%,#fff); + border-radius:50%; transform:none; top:6px; width:8px; height:8px; +} +.xai-read__note{ + margin:18px 0 0; padding:11px 14px; border-radius:11px; + background:color-mix(in srgb,var(--xai-accent,#052962) 6%, #fff); + border:1px dashed color-mix(in srgb,var(--xai-accent,#052962) 32%, #fff); + font-size:13px; line-height:1.5; color:#4b5563; +} +.xai-read__sign{ + margin-top:18px; padding-top:12px; border-top:1px solid #ece9e1; + font-family:ui-monospace,Menlo,monospace; font-size:10px; letter-spacing:1.3px; text-transform:uppercase; color:#b6b0a3; +} +/* dans le rapport modal : aligne la carte sous la colonne éditoriale, comme les .coh-card */ +.result-modal .xai-read{ margin-left:0; } +@media print{ .xai-read{ box-shadow:none; border-color:#d8d4c8; } .xai-read__sign{ display:none; } } +/* en mode "tableau de bord" du rapport modal, la Lecture du résultat occupe toute la largeur */ +.result-modal .coh-results > .xai-read{ grid-column:1 / -1; } + +/* ───────────────────────────────────────────────────────────── + Verify Assistant — luxe chatbot widget + Mounted on every page (bottom-left, opposite the verify FAB). +───────────────────────────────────────────────────────────── */ +.vchat{ + position: fixed; + bottom: 24px; + left: 24px; + z-index: 65; + font-family: 'Inter', system-ui, -apple-system, sans-serif; +} +.vchat__launcher{ + display: inline-flex; + align-items: center; + gap: 10px; + padding: 12px 18px 12px 14px; + background: linear-gradient(135deg, #041e4d 0%, #052962 60%, #1e4faf 100%); + color: #fff; + border: 0; + border-radius: 9999px; + font-family: inherit; + font-weight: 700; + font-size: 13px; + letter-spacing: 0.4px; + cursor: pointer; + box-shadow: + 0 18px 40px -12px rgba(5, 41, 98, 0.55), + inset 0 0 0 1px rgba(255, 229, 0, 0.25); + transition: transform 0.22s var(--ease-out), box-shadow 0.22s var(--ease-out); +} +.vchat__launcher:hover{ + transform: translateY(-2px); + box-shadow: + 0 22px 48px -12px rgba(5, 41, 98, 0.65), + inset 0 0 0 1px rgba(255, 229, 0, 0.45); +} +.vchat__avatar{ + width: 30px; height: 30px; + border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + background: radial-gradient(circle at 30% 30%, #ffe500 0%, #ffd200 70%); + color: #052962; + font-weight: 900; + font-family: 'Playfair Display', Georgia, serif; + font-size: 15px; + box-shadow: inset 0 0 0 1px rgba(5, 41, 98, 0.18); + position: relative; +} +.vchat__avatar::after{ + content: ""; + position: absolute; + inset: -3px; + border-radius: 50%; + border: 2px solid rgba(255, 229, 0, 0.45); + animation: vchat-ring 2.4s ease-in-out infinite; +} +@keyframes vchat-ring{ + 0%,100% { opacity: 0.8; transform: scale(1); } + 50% { opacity: 0; transform: scale(1.35); } +} +.vchat__label{ display: inline-block; } +.vchat__chev{ font-size: 11px; opacity: 0.7; margin-left: 2px; } + +/* ── panel ── */ +.vchat__panel{ + position: absolute; + bottom: 64px; + left: 0; + width: 380px; + max-width: calc(100vw - 32px); + height: 560px; + max-height: calc(100vh - 120px); + background: #fff; + border-radius: 18px; + overflow: hidden; + display: none; + flex-direction: column; + box-shadow: + 0 40px 80px -20px rgba(5, 41, 98, 0.35), + 0 10px 30px -8px rgba(15, 23, 42, 0.18); + border: 1px solid rgba(5, 41, 98, 0.08); + transform-origin: bottom left; + animation: vchat-pop 0.32s var(--ease-out); +} +.vchat.is-open .vchat__panel{ display: flex; } +@keyframes vchat-pop{ + from { opacity: 0; transform: translateY(10px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +/* head */ +.vchat__head{ + position: relative; + padding: 16px 18px 14px; + background: + radial-gradient(120% 80% at 0% 0%, rgba(255,229,0,0.18) 0%, transparent 55%), + linear-gradient(135deg, #041e4d 0%, #052962 60%, #1e4faf 100%); + color: #fff; + border-bottom: 2px solid #ffe500; +} +.vchat__head .vchat__kicker{ + display: inline-flex; align-items: center; gap: 6px; + font-size: 10px; letter-spacing: 1.4px; text-transform: uppercase; + color: rgba(255, 229, 0, 0.95); + font-weight: 700; +} +.vchat__head h3{ + margin: 6px 0 2px; + font-family: 'Playfair Display', Georgia, serif; + font-weight: 700; + font-size: 22px; + line-height: 1.1; +} +.vchat__head p{ + margin: 0; + font-size: 12px; + color: rgba(255,255,255,0.78); + line-height: 1.4; +} +.vchat__close{ + position: absolute; + top: 12px; right: 12px; + width: 30px; height: 30px; + border-radius: 50%; + background: rgba(255,255,255,0.1); + border: 1px solid rgba(255,255,255,0.18); + color: #fff; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + font-size: 16px; + line-height: 1; + transition: background 0.18s; +} +.vchat__close:hover{ background: rgba(255,255,255,0.22); } + +/* messages */ +.vchat__body{ + flex: 1; + overflow-y: auto; + padding: 16px 16px 8px; + background: + radial-gradient(80% 60% at 100% 0%, rgba(5,41,98,0.04) 0%, transparent 60%), + #fafaf7; + display: flex; + flex-direction: column; + gap: 10px; + scroll-behavior: smooth; +} +.vchat__body::-webkit-scrollbar{ width: 6px; } +.vchat__body::-webkit-scrollbar-thumb{ background: rgba(5,41,98,0.18); border-radius: 4px; } + +.vchat__msg{ + display: flex; + gap: 8px; + max-width: 88%; + animation: vchat-fade 0.28s var(--ease-out); +} +@keyframes vchat-fade{ + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} +.vchat__msg--assistant{ align-self: flex-start; } +.vchat__msg--user{ align-self: flex-end; flex-direction: row-reverse; } + +.vchat__bubble{ + padding: 10px 13px; + border-radius: 14px; + font-size: 13.5px; + line-height: 1.55; + color: var(--ink); + word-wrap: break-word; + white-space: pre-wrap; +} +.vchat__msg--assistant .vchat__bubble{ + background: #fff; + border: 1px solid #ece9e1; + border-top-left-radius: 4px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} +.vchat__msg--user .vchat__bubble{ + background: linear-gradient(135deg, #052962 0%, #1e4faf 100%); + color: #fff; + border-top-right-radius: 4px; + box-shadow: 0 4px 10px -4px rgba(5, 41, 98, 0.4); +} +.vchat__mini{ + width: 26px; height: 26px; + flex-shrink: 0; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, #ffe500 0%, #ffd200 70%); + color: #052962; + font-family: 'Playfair Display', Georgia, serif; + font-weight: 800; + font-size: 12px; + display: inline-flex; align-items: center; justify-content: center; + box-shadow: inset 0 0 0 1px rgba(5, 41, 98, 0.18); +} + +/* typing indicator */ +.vchat__typing{ + display: inline-flex; gap: 4px; + padding: 12px 14px; +} +.vchat__typing span{ + width: 6px; height: 6px; + background: #052962; + opacity: 0.35; + border-radius: 50%; + animation: vchat-dot 1.2s infinite ease-in-out; +} +.vchat__typing span:nth-child(2){ animation-delay: 0.15s; } +.vchat__typing span:nth-child(3){ animation-delay: 0.3s; } +@keyframes vchat-dot{ + 0%, 80%, 100% { transform: scale(0.7); opacity: 0.3; } + 40% { transform: scale(1); opacity: 0.9; } +} + +/* suggestions */ +.vchat__suggest{ + display: flex; flex-wrap: wrap; gap: 6px; + padding: 4px 4px 6px; +} +.vchat__chip{ + padding: 7px 11px; + background: #fff; + border: 1px solid #e7e3d6; + border-radius: 9999px; + font-size: 12px; + font-weight: 600; + color: var(--navy); + cursor: pointer; + transition: border-color 0.18s, background 0.18s, transform 0.18s; + font-family: inherit; +} +.vchat__chip:hover{ + border-color: var(--navy); + background: #fffbe5; + transform: translateY(-1px); +} + +/* foot */ +.vchat__foot{ + border-top: 1px solid var(--line); + padding: 10px 12px; + background: #fff; + display: flex; + flex-direction: column; + gap: 6px; +} +.vchat__inputrow{ + display: flex; + align-items: flex-end; + gap: 8px; + background: #f7f6f1; + border: 1px solid #ece9e1; + border-radius: 14px; + padding: 6px 6px 6px 12px; + transition: border-color 0.18s, background 0.18s; +} +.vchat__inputrow:focus-within{ + border-color: var(--navy); + background: #fff; + box-shadow: 0 0 0 3px rgba(5, 41, 98, 0.10); +} +.vchat__input{ + flex: 1; + border: 0; + outline: 0; + background: transparent; + resize: none; + font-family: inherit; + font-size: 13.5px; + line-height: 1.45; + color: var(--ink); + padding: 8px 0; + max-height: 110px; + min-height: 22px; +} +.vchat__send{ + width: 36px; height: 36px; + border: 0; + border-radius: 50%; + background: linear-gradient(135deg, #052962 0%, #1e4faf 100%); + color: #fff; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + flex-shrink: 0; + transition: transform 0.18s, box-shadow 0.18s, opacity 0.18s; + box-shadow: 0 6px 14px -4px rgba(5, 41, 98, 0.4); +} +.vchat__send:hover:not(:disabled){ + transform: translateY(-1px) scale(1.04); + box-shadow: 0 10px 18px -4px rgba(5, 41, 98, 0.5); +} +.vchat__send:disabled{ opacity: 0.4; cursor: not-allowed; } +.vchat__send svg{ width: 16px; height: 16px; } +.vchat__hint{ + font-size: 10.5px; + color: var(--ink-faint); + letter-spacing: 0.2px; + text-align: center; + padding-bottom: 2px; +} +.vchat__hint strong{ color: var(--navy); font-weight: 700; } + +/* mobile: full-width bottom sheet */ +@media (max-width: 540px){ + .vchat{ bottom: 16px; left: 16px; right: 16px; } + .vchat__launcher .vchat__label{ display: none; } + .vchat__launcher{ padding: 10px; } + .vchat__panel{ + position: fixed; + inset: 16px; + width: auto; + height: auto; + max-height: none; + border-radius: 18px; + } +} + +/* avoid clash when both FAB and chat are open at once: nothing, they're on + opposite corners. But on small screens hide vchat label so the two pills don't fight. */ + +/* ── header actions (reset + close) ── */ +.vchat__head-actions{ + position: absolute; + top: 12px; right: 12px; + display: inline-flex; gap: 6px; +} +.vchat__head-actions .vchat__close{ position: static; } +.vchat__reset{ + width: 30px; height: 30px; + border-radius: 50%; + background: rgba(255,255,255,0.10); + border: 1px solid rgba(255,255,255,0.18); + color: #fff; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + transition: background 0.18s, transform 0.18s; +} +.vchat__reset:hover{ background: rgba(255,255,255,0.22); transform: rotate(-30deg); } +.vchat__reset svg{ width: 15px; height: 15px; } + +/* ── attach button in the input row ── */ +.vchat__attach{ + width: 32px; height: 32px; + border: 0; + background: transparent; + color: var(--navy); + border-radius: 50%; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + flex-shrink: 0; + transition: background 0.18s, transform 0.18s; +} +.vchat__attach svg{ width: 18px; height: 18px; } +.vchat__attach:hover{ background: rgba(5, 41, 98, 0.08); transform: rotate(-12deg); } + +/* ── upload preview chip above the input row ── */ +.vchat__preview{ padding: 0 0 8px; } +.vchat__prev-card{ + display: flex; align-items: center; gap: 10px; + padding: 8px 10px 8px 8px; + background: linear-gradient(135deg, rgba(255,229,0,0.10) 0%, rgba(5,41,98,0.05) 100%); + border: 1px solid rgba(5, 41, 98, 0.15); + border-radius: 12px; + position: relative; +} +.vchat__prev-card img{ + width: 42px; height: 42px; + object-fit: cover; + border-radius: 8px; + flex-shrink: 0; + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08); +} +.vchat__prev-vid{ + width: 42px; height: 42px; + border-radius: 8px; + background: var(--navy); + color: #ffe500; + display: inline-flex; align-items: center; justify-content: center; + font-size: 20px; + flex-shrink: 0; +} +.vchat__prev-meta{ flex: 1; min-width: 0; } +.vchat__prev-name{ + font-weight: 700; font-size: 12.5px; color: var(--navy); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.vchat__prev-sub{ font-size: 11px; color: var(--ink-faint); } +.vchat__prev-x{ + width: 24px; height: 24px; + background: rgba(5, 41, 98, 0.10); + border: 0; + border-radius: 50%; + cursor: pointer; + color: var(--navy); + font-size: 15px; + line-height: 1; + flex-shrink: 0; +} +.vchat__prev-x:hover{ background: rgba(5, 41, 98, 0.20); } + +/* ── media bubble (user upload, in the thread) ── */ +.vchat__bubble--media{ padding: 6px 6px 8px; max-width: 240px; } +.vchat__media img{ + display: block; + width: 100%; + max-width: 220px; + max-height: 180px; + object-fit: cover; + border-radius: 10px; +} +.vchat__media--video{ + display: flex; align-items: center; gap: 8px; + padding: 10px; + background: rgba(255, 255, 255, 0.12); + border-radius: 10px; +} +.vchat__media-ico{ font-size: 20px; } +.vchat__media-name{ + font-size: 12.5px; color: #fff; font-weight: 600; + max-width: 160px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.vchat__media-cap{ + margin-top: 6px; + padding: 0 6px; + font-size: 12.5px; + line-height: 1.45; +} + +/* ── verdict bubble (assistant — result of io1 analysis) ── */ +.vchat__bubble--verdict{ + background: #fff !important; + border: 1px solid #ece9e1; + border-left: 4px solid var(--navy); + border-top-left-radius: 4px; + padding: 14px 14px 12px; + max-width: 100%; + box-shadow: 0 4px 14px -6px rgba(15, 23, 42, 0.12); +} +.vchat__verdict--alert{ border-left-color: var(--red); } +.vchat__verdict--ok{ border-left-color: var(--green); } +.vchat__verdict--neutral{ border-left-color: var(--navy); } + +.vchat__verdict-head{ + display: flex; align-items: flex-start; gap: 10px; + margin-bottom: 8px; +} +.vchat__verdict-ico{ + width: 32px; height: 32px; + border-radius: 9px; + flex-shrink: 0; + display: inline-flex; align-items: center; justify-content: center; +} +.vchat__verdict--alert .vchat__verdict-ico{ background: #fee2e2; color: var(--red); } +.vchat__verdict--ok .vchat__verdict-ico{ background: #dcfce7; color: var(--green); } +.vchat__verdict--neutral .vchat__verdict-ico{ background: rgba(5, 41, 98, 0.10); color: var(--navy); } +.vchat__verdict-ico svg{ width: 18px; height: 18px; } + +.vchat__verdict-title{ + font-family: 'Playfair Display', Georgia, serif; + font-weight: 700; + font-size: 17px; + line-height: 1.15; + color: var(--navy); +} +.vchat__verdict-conf{ + margin-top: 2px; + font-size: 10.5px; + letter-spacing: 0.8px; + text-transform: uppercase; + font-weight: 700; + color: var(--ink-faint); +} +.vchat__verdict-sum{ + margin: 4px 0 8px; + font-size: 13px; + line-height: 1.55; + color: var(--ink); +} +.vchat__verdict-sig{ + margin: 0 0 6px; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 4px; +} +.vchat__verdict-sig li{ + position: relative; + padding-left: 14px; + font-size: 12.5px; + line-height: 1.5; + color: #1f2937; +} +.vchat__verdict-sig li::before{ + content: ""; + position: absolute; + left: 2px; top: 8px; + width: 5px; height: 5px; + border-radius: 50%; + background: var(--navy); +} +.vchat__verdict--alert .vchat__verdict-sig li::before{ background: var(--red); } +.vchat__verdict--ok .vchat__verdict-sig li::before{ background: var(--green); } +.vchat__verdict-note{ + margin: 6px 0 0; + padding: 8px 10px; + background: #fafaf3; + border: 1px dashed #e7e3d6; + border-radius: 8px; + font-size: 11.5px; + line-height: 1.45; + color: #4b5563; +} + +/* ── drag & drop overlay on the panel ── */ +.vchat__panel.is-drop::after{ + content: "Drop a photo or a video to verify"; + position: absolute; inset: 0; + background: rgba(5, 41, 98, 0.92); + color: #ffe500; + display: flex; align-items: center; justify-content: center; + font-family: 'Playfair Display', Georgia, serif; + font-weight: 700; + font-size: 18px; + text-align: center; + border: 3px dashed #ffe500; + border-radius: 18px; + z-index: 10; + pointer-events: none; +} diff --git a/assets/img/ads-marketing.jpg b/assets/img/ads-marketing.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f87719b620746da2242406bae45f03206d4d7348 --- /dev/null +++ b/assets/img/ads-marketing.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0eb61c55cacee2b1984c186caa36e549a00681a5788bc5e511887e75322bfff8 +size 222146 diff --git a/assets/img/ads.jpg b/assets/img/ads.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f87719b620746da2242406bae45f03206d4d7348 --- /dev/null +++ b/assets/img/ads.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0eb61c55cacee2b1984c186caa36e549a00681a5788bc5e511887e75322bfff8 +size 222146 diff --git a/assets/img/ads.png b/assets/img/ads.png new file mode 100644 index 0000000000000000000000000000000000000000..fdf36f3b169e68fa85b0bc68d3a826f1f686c621 --- /dev/null +++ b/assets/img/ads.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae4f12b68321e50b2282da10a67817e0e17f38482047eeec5ee8be7ef625d363 +size 434740 diff --git a/assets/img/ai-generative.jpg b/assets/img/ai-generative.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c30c5693fb225cca68195ecc48420aa37c809ba7 --- /dev/null +++ b/assets/img/ai-generative.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf04403bdff29468f0818ead5414aa2a71a9c4d9768f081fd77782a75fe4174e +size 48228 diff --git a/assets/img/cover1.png b/assets/img/cover1.png new file mode 100644 index 0000000000000000000000000000000000000000..ad3d5b150193987c023b274ba4fd46af2ca04ce8 --- /dev/null +++ b/assets/img/cover1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50f8f6e78f6b8dbed86c71d78b29c91ef66836eb590c1a1b7341b0804db27394 +size 217599 diff --git a/assets/img/cover2.png b/assets/img/cover2.png new file mode 100644 index 0000000000000000000000000000000000000000..db724c9f84dc37d475c7b957d79e59a3226dc285 --- /dev/null +++ b/assets/img/cover2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e2478e0a8a3c9b54be733a8784d1f3a09cb064cb053781c3c5955575f6221c62 +size 252981 diff --git a/assets/img/cover4.png b/assets/img/cover4.png new file mode 100644 index 0000000000000000000000000000000000000000..ae2f64912006e54f39f9c4923efd1292d3621627 --- /dev/null +++ b/assets/img/cover4.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae42f175bcedcab0f21c7686b49401e492e9adc03de4099105a0d707f5168bd8 +size 295330 diff --git a/assets/img/cover5.png b/assets/img/cover5.png new file mode 100644 index 0000000000000000000000000000000000000000..a9f415c04d746e63f7c7d66093297fffc70f506a --- /dev/null +++ b/assets/img/cover5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7db27e7dc69406079180959b1845abf0dd0eb2c5de286c324af7b940d02f3c12 +size 232659 diff --git a/assets/img/cover6.png b/assets/img/cover6.png new file mode 100644 index 0000000000000000000000000000000000000000..820f71f2a7e3c13ee7ad6577ee61c651d140977a --- /dev/null +++ b/assets/img/cover6.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0182b4565c633184040618d54c9b62fe2de2829b448d30838b448c6ebced0281 +size 208233 diff --git a/assets/img/deepfake-hero.jpg b/assets/img/deepfake-hero.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2b9e7dc92e429f3f2b4949e10808c75c0ac1c15b --- /dev/null +++ b/assets/img/deepfake-hero.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43bb30002f7fbd33e4905b4e0485bcac1ab28d8e6f1f7419c05e8d2981eb456b +size 140459 diff --git a/assets/img/examples/m-ai1.png b/assets/img/examples/m-ai1.png new file mode 100644 index 0000000000000000000000000000000000000000..2daea5eb56369c8eba544001b3ebbd2457c9c5ca --- /dev/null +++ b/assets/img/examples/m-ai1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b42748bd5fc92c3f015b12b0a5db9f225f224a8862b57ca15a29f27461801ce9 +size 208310 diff --git a/assets/img/examples/m-ai2.png b/assets/img/examples/m-ai2.png new file mode 100644 index 0000000000000000000000000000000000000000..228e515c76fed5137d791077afe67ade94e04c84 --- /dev/null +++ b/assets/img/examples/m-ai2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7fc6df17ae40883694836507876d71bf4c30e2238c8d67b2a34d63f38a17bc86 +size 207240 diff --git a/assets/img/examples/m-ai3.png b/assets/img/examples/m-ai3.png new file mode 100644 index 0000000000000000000000000000000000000000..73ee39b87e55100fa0b53bfe4452051d9eef301d --- /dev/null +++ b/assets/img/examples/m-ai3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ff103c8baade0d99d49a5b8105a0b330f84b6d503fcdbe1c36b3f07b9c8d2303 +size 238523 diff --git a/assets/img/examples/m-doc1.jpg b/assets/img/examples/m-doc1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..30b5cc1c6d3381a3cbc54c6027dbb58069b40bb0 --- /dev/null +++ b/assets/img/examples/m-doc1.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93f1eb088271b6680019d77a79d2e4e390790ba69ef064b1dd2ec2ec2f99e26d +size 108608 diff --git a/assets/img/examples/m-doc2.jpg b/assets/img/examples/m-doc2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9ad56c704d3fd798067d4b4678a44dfdde59916a --- /dev/null +++ b/assets/img/examples/m-doc2.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cec18356541223cddd0f66827be270f6aaf139f12407b43456fd4603fa714273 +size 96160 diff --git a/assets/img/examples/m-edit1.jpg b/assets/img/examples/m-edit1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2af02cd7f8682f5956223e3355640fa5c6e40151 --- /dev/null +++ b/assets/img/examples/m-edit1.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0384525312d672d5b219183f299195343e7e86c0def989f5dfd865791bdafd5 +size 279205 diff --git a/assets/img/examples/m-manip2.jpg b/assets/img/examples/m-manip2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..da8beae6d96bd3dbf2cb37ac37c1d768896a9fea --- /dev/null +++ b/assets/img/examples/m-manip2.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:60142e62ce10892414663d97990957b9936fa0e9d8e58046a2e877a6412c7086 +size 143055 diff --git a/assets/img/examples/m-manip4.jpg b/assets/img/examples/m-manip4.jpg new file mode 100644 index 0000000000000000000000000000000000000000..371af11b0b26f8392e30ab47d1a9bd03a2622737 --- /dev/null +++ b/assets/img/examples/m-manip4.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6623aee383a7a11a5ebd9e58add19bf5ca6959b160120a193ed8805d949cc451 +size 204627 diff --git a/assets/img/examples/m-manip6.jpg b/assets/img/examples/m-manip6.jpg new file mode 100644 index 0000000000000000000000000000000000000000..38f0e164a6a06d99e15da945ee3c6fa742da951e --- /dev/null +++ b/assets/img/examples/m-manip6.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b0c4d8801f1c149194a17f95cfa3e137dc2d584d2b5eca6de5910aa3a9766c7 +size 145055 diff --git a/assets/img/examples/m-news1.jpg b/assets/img/examples/m-news1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6f322cc91656c6591bec968720d01418ce688358 --- /dev/null +++ b/assets/img/examples/m-news1.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c78b4641368340e6adb5fce03a4c5a09ba32124497d607daacd4889990864e7 +size 82864 diff --git a/assets/img/examples/m-news2.jpg b/assets/img/examples/m-news2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f8852ac5f553eb2398ba8c7710456723290939c2 --- /dev/null +++ b/assets/img/examples/m-news2.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d5978e1a35a2f8b81c1e6f9699c776744ae07b922348d864be124d80308a6098 +size 112668 diff --git a/assets/img/examples/m-orig1.jpg b/assets/img/examples/m-orig1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c423a0efbb3bd0903cec41e839e545d97c68af39 --- /dev/null +++ b/assets/img/examples/m-orig1.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:136bf28068cac05457ad53023f171fa9ba4ca3d2938a9ed46d6da9a11f5cdeed +size 337791 diff --git a/assets/img/examples/m-real1.jpg b/assets/img/examples/m-real1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e36032124ea931ad4dc1238f02e904c80d6d49de --- /dev/null +++ b/assets/img/examples/m-real1.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54452d6b79eecb5fb3a28d5f353837e2cdd780474e770d61a5be1879cfd9703d +size 94313 diff --git a/assets/img/examples/m-real2.jpg b/assets/img/examples/m-real2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7449a3d56ed97eb52c7f42e443e341af4f2893b9 --- /dev/null +++ b/assets/img/examples/m-real2.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bdfbcf54d8136aae9f0ff820a123ef3ade137dbeb7eb874fb76b389cde7510de +size 174252 diff --git a/assets/img/examples/m-real3.jpg b/assets/img/examples/m-real3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f4c8befbfc021d0cbef9d4570d401250e29f9418 --- /dev/null +++ b/assets/img/examples/m-real3.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a2898789241373a600f5835ec973b481dfa5fda1fe0ea7c3ee2a9caf02f3af6 +size 178359 diff --git a/assets/img/examples/m-real4.jpg b/assets/img/examples/m-real4.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d7a598fc2f4cedaf833ff1df27572a6b65cffc5d --- /dev/null +++ b/assets/img/examples/m-real4.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c721dfc1ad904f33054337aec40c00f6f9ad2a4c117b27c72c4507ca9eb63981 +size 140258 diff --git a/assets/img/factchecks/fc-elamra.png b/assets/img/factchecks/fc-elamra.png new file mode 100644 index 0000000000000000000000000000000000000000..a6ca196b31ad12b1a9e6be5d476741ef71944d52 --- /dev/null +++ b/assets/img/factchecks/fc-elamra.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a10e6d929c9783477565665fd19106012804e7e93b21d3410a8529406707201f +size 151665 diff --git a/assets/img/factchecks/fc-gabes.jpg b/assets/img/factchecks/fc-gabes.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5ed501e9d01ec4e896533e78e663d5b584cf1d4a --- /dev/null +++ b/assets/img/factchecks/fc-gabes.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f2c92c5fecda58b7ef98ce8a5bd46aac1346c06b95f1763cf4067bcafec6a4c +size 200010 diff --git a/assets/img/factchecks/fc-ghannouchi.jpg b/assets/img/factchecks/fc-ghannouchi.jpg new file mode 100644 index 0000000000000000000000000000000000000000..005fd8412110aa70ece11b57ed073e873d87a696 --- /dev/null +++ b/assets/img/factchecks/fc-ghannouchi.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b75ce63447ff1bb958277c7a2618caae3d40ddeb5911320a31a511a5bb235d71 +size 130626 diff --git a/assets/img/factchecks/fc-guardian.png b/assets/img/factchecks/fc-guardian.png new file mode 100644 index 0000000000000000000000000000000000000000..746f41cc9c3ae29a1b554227da930c45e41bf6cb --- /dev/null +++ b/assets/img/factchecks/fc-guardian.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae8c7b4af86eaeb9bf409f6a8b36cf9666f9973a9bf5ffd1bd734462520e89c8 +size 127147 diff --git a/assets/img/factchecks/fc-henneberg.png b/assets/img/factchecks/fc-henneberg.png new file mode 100644 index 0000000000000000000000000000000000000000..5147e47ee309527eb663c3cd28b524aa2b8dbf6d --- /dev/null +++ b/assets/img/factchecks/fc-henneberg.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb94222ebb0a14a4cb85a0542478035cda8a5ef09553c6e9f355d50120ffe227 +size 91199 diff --git a/assets/img/factchecks/fc-kerkennah.jpg b/assets/img/factchecks/fc-kerkennah.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5f0f131e23a03f927c012d8bf260a85774de1de6 --- /dev/null +++ b/assets/img/factchecks/fc-kerkennah.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d84779d4246361b7617fc6a294db5fe17360e49d5846d34af9a177ca476ea6c +size 156923 diff --git a/assets/img/factchecks/fc-migrant.jpg b/assets/img/factchecks/fc-migrant.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a80df8865b1736c08f14e0e6286b54b2a84794b2 --- /dev/null +++ b/assets/img/factchecks/fc-migrant.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d21355f3bb1cbf589c068c66e32adefaba5528518b9ecb1cc8dfdcbc700f7ecb +size 204920 diff --git a/assets/img/factchecks/fc-nasrallah.png b/assets/img/factchecks/fc-nasrallah.png new file mode 100644 index 0000000000000000000000000000000000000000..f9a136941b515baacc2a201442758cc3349fb64e --- /dev/null +++ b/assets/img/factchecks/fc-nasrallah.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db15e2d1444f5655c466bc856e2474f2ad94a71544f6c8f8d9da04b16308fabf +size 180093 diff --git a/assets/img/factchecks/fc-yutong.png b/assets/img/factchecks/fc-yutong.png new file mode 100644 index 0000000000000000000000000000000000000000..2bd56994459a2224fdf31da85b06539fe6a41eb7 --- /dev/null +++ b/assets/img/factchecks/fc-yutong.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fae036aa08a0be3d4a98e526834fcaa5e6253fa4aaf56e5d2e0c0c6995c0d7fd +size 136326 diff --git a/assets/img/factchecks/fc-zammel.png b/assets/img/factchecks/fc-zammel.png new file mode 100644 index 0000000000000000000000000000000000000000..ac77f41bb11a7865f9b7662fcf1ec49071dc4763 --- /dev/null +++ b/assets/img/factchecks/fc-zammel.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61eb4222e76403075f4c60fb3bdff21f9a216a5180b55bfb073d68f3ba18e9fe +size 82875 diff --git a/assets/img/forensics.jpg b/assets/img/forensics.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f08d1b6c2c35ef59ddbacc9c1a1a8c3acc6bb670 --- /dev/null +++ b/assets/img/forensics.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b374cfeeb2cec95e9efc7511816d86f86e86237da4c05f9ce91876c9535f807a +size 182971 diff --git a/assets/img/logo-verify-white.png b/assets/img/logo-verify-white.png new file mode 100644 index 0000000000000000000000000000000000000000..696b4ed236ae03c32729dcc8be665fea0a08a0b8 --- /dev/null +++ b/assets/img/logo-verify-white.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6da186f0ddf125760e782c1df8d3e588ee70a55f96cb77cb8cc1f2e41b8452fc +size 122736 diff --git a/assets/img/logo-verify.png b/assets/img/logo-verify.png new file mode 100644 index 0000000000000000000000000000000000000000..d89216fc18db7446ddc7b5c69fadeb47c83dda50 --- /dev/null +++ b/assets/img/logo-verify.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4acedf11108893d92946a6653a6f0434b91a15ccdc2cdc9f91b2b9ea56d855c8 +size 193640 diff --git a/assets/img/module-coherence.jpg b/assets/img/module-coherence.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ecafdd0c9bfd921e5b8a23ee56fedc2e7c63dff2 --- /dev/null +++ b/assets/img/module-coherence.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:360d44c02a2a70ff8df5a7125a622182db641598784b860bfb5e84074f783797 +size 94601 diff --git a/assets/img/module-fidelity-alt.jpg b/assets/img/module-fidelity-alt.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a6248419e567cc6a0d978b1740ea3878b5386bf3 --- /dev/null +++ b/assets/img/module-fidelity-alt.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7fa5c9c60a61cd348593bf13699977ef43296392cd129142618473bdd618fa72 +size 119215 diff --git a/assets/img/module-fidelity.jpg b/assets/img/module-fidelity.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4b08a5d68891d8137fe628a66a75e69ffb0244a1 --- /dev/null +++ b/assets/img/module-fidelity.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a85c69c97d27848b2f031e99d66078c9c1cc7ec69df2e492df2533f91b132a4b +size 132008 diff --git a/assets/img/news.jpg b/assets/img/news.jpg new file mode 100644 index 0000000000000000000000000000000000000000..51409ee447d9381b77fe299bdaefb9a141f57830 --- /dev/null +++ b/assets/img/news.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98d4d580aa2b4b45dbbba8ea1fbc6699863b322a227c375c09f4603bc3e9d888 +size 93143 diff --git a/assets/img/oi1.png b/assets/img/oi1.png new file mode 100644 index 0000000000000000000000000000000000000000..0d6cdd7e4f2a6c27a0bebb79aaa8f4a4642d06fe --- /dev/null +++ b/assets/img/oi1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1969322499e0c6ef50b267f3619226ddeb3be4d35edd1e7d1e369c72547eb20a +size 162545 diff --git a/assets/img/oi2.png b/assets/img/oi2.png new file mode 100644 index 0000000000000000000000000000000000000000..3c26cf269f9019c6f6df020e2abd06730b6e6619 --- /dev/null +++ b/assets/img/oi2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9438813c450e981c642122cf53d6084b3d1623be82e6d377c9a88d5855d11ba5 +size 187122 diff --git a/assets/img/oi4.png b/assets/img/oi4.png new file mode 100644 index 0000000000000000000000000000000000000000..0de4299d5a5d40870a8d4629b06615944794d6f8 --- /dev/null +++ b/assets/img/oi4.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7e633d9b1d80b92eaf56c0de04791881f96e27df8246ed098efdd35a2bb8e93 +size 178636 diff --git a/assets/img/oi5.png b/assets/img/oi5.png new file mode 100644 index 0000000000000000000000000000000000000000..406712ac75785ad74a82181eb0fd17899211c79b --- /dev/null +++ b/assets/img/oi5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c7ddd7665ae07cdff641b2f2b7f87b660d83517e96c12bd2dfc5ade5846f66d1 +size 188730 diff --git a/assets/img/oi6.png b/assets/img/oi6.png new file mode 100644 index 0000000000000000000000000000000000000000..4550ca57d7fe122ea005f862a6e0c538bc218610 --- /dev/null +++ b/assets/img/oi6.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:828c08dde2387ccff8c8ace7281bdb0e46d4a546879e6f3be7c4bf442b927ecc +size 158171 diff --git a/assets/img/persuasion.jpg b/assets/img/persuasion.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b79a62f43418e7a2c223007b548f4811cc6eaad1 --- /dev/null +++ b/assets/img/persuasion.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1df3cf0b9fad40d49d077638fb25f58039b3fcd96b45da5994dae04869d53bca +size 38579 diff --git a/assets/img/products/p1.jpg b/assets/img/products/p1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c80e9a15bd1436db413ec98719ee30c4e20e0f27 --- /dev/null +++ b/assets/img/products/p1.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd6b90df51b48f0734f860ece2af60a1604b91ac47434b868ddcc9cff0cef751 +size 14016 diff --git a/assets/img/products/p10.jpg b/assets/img/products/p10.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b088a9887096314a08ae3444476eb60006d87132 --- /dev/null +++ b/assets/img/products/p10.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7e6d05442482f147d60e919c1abc1bcc067134a04081d91483312e1f814dadab +size 20915 diff --git a/assets/img/products/p11.jpg b/assets/img/products/p11.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6f77d638bfe21a07c2bf8bdd9a5a7eebf75cf961 --- /dev/null +++ b/assets/img/products/p11.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86c3628d15d0d8a5bb1ec0284dacb78b631cf7403caba5dddc9ad9e4f455fa1c +size 11160 diff --git a/assets/img/products/p12.jpg b/assets/img/products/p12.jpg new file mode 100644 index 0000000000000000000000000000000000000000..245dba092619f3b770454f5f3ce64e5217f4b61b --- /dev/null +++ b/assets/img/products/p12.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e892c0d0d5433cf2e5fe2c54e029508c72abd86665c9c1321b58f1a61a95390 +size 22544 diff --git a/assets/img/products/p13.jpg b/assets/img/products/p13.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d49aa5eab6b3c12fade49dad4d42f0e703a92b78 --- /dev/null +++ b/assets/img/products/p13.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d75d75b698b591a9513a102fb2d30c3a490831a1f45283924622d8c8b88b1231 +size 17039 diff --git a/assets/img/products/p14.jpg b/assets/img/products/p14.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7afe468d6d137d983bb09d7d351a8cff2a59c7c7 --- /dev/null +++ b/assets/img/products/p14.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a862a500c45394d0efb1412fa84ed1acb92a6c0f72f21e919063e799dc9157f9 +size 15888 diff --git a/assets/img/products/p15.jpg b/assets/img/products/p15.jpg new file mode 100644 index 0000000000000000000000000000000000000000..909eb316711dc86ed1bca0d104476adb1bec706a --- /dev/null +++ b/assets/img/products/p15.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aaef7d33e9b7c19a141c72fe707e1d997d99a472c7e4daa45189ed2fdae732b8 +size 24183 diff --git a/assets/img/products/p16.jpg b/assets/img/products/p16.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0b436488a33bdfae6fd52190432bccdfd19bab49 --- /dev/null +++ b/assets/img/products/p16.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7e35c144eff614701dbaff08d1cac424407f0a2b9219349306b72be6b48d3408 +size 42118 diff --git a/assets/img/products/p2.jpg b/assets/img/products/p2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e421ae24bef5a8960ce251fdf3107080576f745e --- /dev/null +++ b/assets/img/products/p2.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b2a85dd40a510351a9008a12460084fa04fdb8552d6e863db6abc5bd36e37ca +size 24557 diff --git a/assets/img/products/p3.jpg b/assets/img/products/p3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3175a3f116456c7586e8257eefd58c348efcbf62 --- /dev/null +++ b/assets/img/products/p3.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d5dfaae7e9ff112f752b9166d1c887e70b51b20e8fed743d932a36cb11d31e5 +size 16046 diff --git a/assets/img/products/p4.jpg b/assets/img/products/p4.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e61381dee74f01da1094a7f1a623a24147d4f417 --- /dev/null +++ b/assets/img/products/p4.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f988deeeb611d64c37d02322211f1e215aad40eee365952930efcb52f063e083 +size 26920 diff --git a/assets/img/products/p6.jpg b/assets/img/products/p6.jpg new file mode 100644 index 0000000000000000000000000000000000000000..091e9af297ecb85c7be6d224e544986ac45659f8 --- /dev/null +++ b/assets/img/products/p6.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:930bdc540cd9be95c20a5af91582d35e12f7159e0de6a05b9e0f684cf18da0d0 +size 23674 diff --git a/assets/img/products/p7.jpg b/assets/img/products/p7.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a78481ac34a14f659d86370295839f2ff8e8c1fc --- /dev/null +++ b/assets/img/products/p7.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf4384c338c9ef78e85786a50cf086675e8cf6e82aa1bb1e1ce9cdff69736d74 +size 13585 diff --git a/assets/img/products/p8.jpg b/assets/img/products/p8.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a1a99759d57febb58168c26e735d1802f9be1b3d --- /dev/null +++ b/assets/img/products/p8.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0625e421017487a499629974eb69efa0da3cda70d6dd933b21ca707475830375 +size 17028 diff --git a/assets/img/products/p9.jpg b/assets/img/products/p9.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b12cf8d9d9836e1d402f2939588df62de404e064 --- /dev/null +++ b/assets/img/products/p9.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd9b8a1dbc77efcad6f78279bf0883f439859c1e959c5c57adf26cfb051858de +size 16945 diff --git a/assets/js/coherence.js b/assets/js/coherence.js new file mode 100644 index 0000000000000000000000000000000000000000..9f4433d0b55e2554800afacd891c90b08747d3a2 --- /dev/null +++ b/assets/js/coherence.js @@ -0,0 +1,975 @@ +/** + * "Image & caption" analysis — frontend logic + page dispatcher. + * + * Configurable service URL: set `window.VERIFY_API_URL` before this script loads + * (legacy `window.YOUSSEF_API_URL` is also accepted), defaults to + * `http://localhost:8000`. + */ +(function () { + 'use strict'; + + const API_URL = (window.VERIFY_API_URL || window.YOUSSEF_API_URL || 'http://localhost:8000').replace(/\/+$/, ''); + const IO3 = `${API_URL}/api/io3`; + + // ─── SVG icon library — replaces emojis throughout ─────────────────────── + const ICONS = { + image: '', + video: '', + upload: '', + download: '', + check: '', + warn: '', + cross: '', + expand: '', + arrow: '', + clock: '', + wave: '', + grid: '', + box: '', + text: '', + link: '', + spark: '', + settings: '', + eye: '', + chart: '', + radar: '', + branch: '', + quote: '', + }; + + function svgIcon(key, sizeClass) { + return `${ICONS[key] || ''}`; + } + + function statusIcon(verdict) { + const map = { COHERENT: ['coh', 'check'], SUSPECT: ['sus', 'warn'], INCOHERENT: ['inc', 'cross'] }; + const [cls, ico] = map[verdict] || map.INCOHERENT; + return `
${ICONS[ico] || ''}
`; + } + + // Hydrate any inline icon placeholders that exist in static HTML + function hydrateStaticIcons(root = document) { + root.querySelectorAll('.icon[data-icon]').forEach(el => { + const key = el.dataset.icon; + if (ICONS[key] && !el.firstChild) el.innerHTML = ICONS[key]; + }); + } + + // ─── Module configuration (one entry per ?m= value) ────────────────────── + const MODULES = { + io1: { + title: 'Image or video — real or fake?', + badge: 'Tampering detection', + badgeStyle: 'background:#ede9fe;color:#5b21b6;', + description: 'Analyzes an image or a video: a face swapped with a fake (deepfake), or an image entirely fabricated by artificial intelligence. You get a verdict, the areas that led to that conclusion and a plain-language explanation.', + pane: 'io1', // handled by synthesis.js + }, + io2: { + title: 'Visual manipulation — image & video', + badge: 'Manipulation & persuasion', + badgeStyle: 'background:#fef3c7;color:#78350f;', + description: 'Spots the staging and emotional persuasion techniques used in advertising, propaganda and viral content.', + pane: 'io2', // handled by manipulation.js + }, + io4: { + title: 'Retouching & editing — photo forensics', + badge: 'Photo authenticity', + badgeStyle: 'background:#dcfce7;color:#14532d;', + description: 'Detects whether a photo has been altered — added elements, cloning, photomontage, retouching — and locates the affected areas.', + pane: 'io4', // handled by forensics.js + }, + io5: { + title: 'Image & caption — does the caption match the image?', + badge: 'Caption coherence & fidelity', + badgeStyle: 'background:#dbeafe;color:#1e3a8a;', + description: 'Compares an image or a video with the caption that accompanies it: indicates whether they really tell the same story and with what fidelity — to spot images taken out of context, misleading captions, exaggerations and omissions.', + pane: 'io3', + }, + io6: { + title: 'Advertising & cosmetics — verify the claims', + badge: 'Advertising claims', + badgeStyle: 'background:#ffedd5;color:#9a3412;', + description: 'Verifies whether the claims in a cosmetics advertisement are accurate or misleading, in light of the European rules on cosmetic claims.', + pane: 'io6', // handled by cosmetic.js + }, + }; + + // ─── DOM ready ──────────────────────────────────────────────────────────── + document.addEventListener('DOMContentLoaded', () => { + const params = new URLSearchParams(location.search); + const m = (params.get('m') || 'io1').toLowerCase(); + const cfg = MODULES[m] || MODULES.io1; + + // Populate header + document.getElementById('m-badge').textContent = cfg.badge; + document.getElementById('m-badge').setAttribute('style', cfg.badgeStyle + 'display:inline-block;'); + document.getElementById('m-title').textContent = cfg.title; + document.getElementById('m-desc').textContent = cfg.description; + + // Show the right pane (io3 here; io1/io2/io4/io6 handled by their own scripts) + const paneId = cfg.pane === 'io3' ? 'pane-io3' + : cfg.pane === 'io6' ? 'pane-io6' + : cfg.pane === 'io2' ? 'pane-io2' + : cfg.pane === 'io4' ? 'pane-io4' + : cfg.pane === 'io1' ? 'pane-io1' + : 'pane-generic'; + const pane = document.getElementById(paneId); + if (pane) pane.style.display = ''; + + if (cfg.pane === 'io3') initCoherenceModule(); + // io6 is initialized by cosmetic.js (which also overrides badge/title/desc) + hydrateStaticIcons(); + }); + + // ─── io3 init ───────────────────────────────────────────────────────────── + function initCoherenceModule() { + pingHealth(); + + // Tabs + document.querySelectorAll('.coh-tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.coh-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.coh-pane').forEach(p => p.classList.remove('active')); + tab.classList.add('active'); + document.querySelector(`.coh-pane[data-pane="${tab.dataset.tab}"]`).classList.add('active'); + }); + }); + + setupDropZone('drop-image', 'file-image', /^image\//); + setupDropZone('drop-video', 'file-video', /^video\//); + + document.getElementById('btn-image').addEventListener('click', () => analyze('image')); + document.getElementById('btn-video').addEventListener('click', () => analyze('video')); + + setupCaptionUX('text-image'); + setupCaptionUX('text-video'); + setupSuggestions(); + setupClipboardPaste(); + } + + // ─── Caption character counter ──────────────────────────────────────────── + function setupCaptionUX(textareaId) { + const ta = document.getElementById(textareaId); + if (!ta) return; + const counter = document.querySelector(`[data-counter-for="${textareaId}"]`); + if (!counter) return; + const max = parseInt(ta.getAttribute('maxlength') || '500', 10); + const update = () => { + const len = ta.value.length; + counter.textContent = `${len} / ${max}`; + counter.classList.toggle('warn', len > max * 0.85); + }; + ta.addEventListener('input', update); + update(); + } + + // ─── Caption suggestions: click to fill ─────────────────────────────────── + function setupSuggestions() { + document.querySelectorAll('.caption-suggestions').forEach(group => { + const targetId = group.dataset.target; + const ta = document.getElementById(targetId); + if (!ta) return; + group.querySelectorAll('.sg-pill').forEach(btn => { + btn.addEventListener('click', () => { + ta.value = btn.textContent.trim(); + ta.dispatchEvent(new Event('input')); + ta.focus(); + }); + }); + }); + } + + // ─── Paste an image from clipboard into the active drop zone ────────────── + function setupClipboardPaste() { + document.addEventListener('paste', (e) => { + const activePane = document.querySelector('.coh-pane.active'); + if (!activePane) return; + const kind = activePane.dataset.pane; + if (kind !== 'image') return; // video paste is uncommon, skip + const items = e.clipboardData?.items || []; + for (const item of items) { + if (item.type && item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) { + const input = document.getElementById('file-image'); + const dt = new DataTransfer(); + dt.items.add(file); + input.files = dt.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + return; + } + } + } + }); + } + + // ─── Health probe ───────────────────────────────────────────────────────── + async function pingHealth() { + const banner = document.getElementById('api-status'); + try { + const r = await fetch(`${IO3}/health`, { method: 'GET' }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const data = await r.json(); + if (data.status !== 'ok') { + banner.className = 'api-banner'; + banner.innerHTML = `Preparing the service…`; + return; + } + banner.className = 'api-banner ok'; + banner.innerHTML = `${svgIcon('check','icon-14')} Service available`; + } catch (e) { + banner.className = 'api-banner err'; + banner.innerHTML = `${svgIcon('warn','icon-14')} Service temporarily unavailable — please try again in a moment.`; + } + } + + // ─── Drop zone helper ───────────────────────────────────────────────────── + // The lives OUTSIDE the drop zone (sibling) so that + // rewriting the zone's innerHTML to show a preview doesn't destroy it. + function setupDropZone(zoneId, inputId, mimeRegex) { + const zone = document.getElementById(zoneId); + const input = document.getElementById(inputId); + if (!zone || !input) return; + + zone.addEventListener('click', (e) => { + if (e.target.closest('[data-clear]')) return; // "Change" button + input.click(); + }); + zone.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } + }); + + ['dragenter', 'dragover'].forEach(ev => + zone.addEventListener(ev, e => { e.preventDefault(); zone.classList.add('hover'); })); + ['dragleave', 'drop'].forEach(ev => + zone.addEventListener(ev, e => { e.preventDefault(); zone.classList.remove('hover'); })); + + zone.addEventListener('drop', (e) => { + const f = e.dataTransfer?.files?.[0]; + if (f && mimeRegex.test(f.type)) { + input.files = e.dataTransfer.files; + renderFilePreview(zone, input, f); + } + }); + + input.addEventListener('change', () => { + const f = input.files?.[0]; + if (f) renderFilePreview(zone, input, f); + }); + } + + function renderFilePreview(zone, input, file) { + const isImage = file.type.startsWith('image/'); + zone.classList.add('has-file'); + const sizeMb = (file.size / 1024 / 1024).toFixed(1); + const previewHtml = isImage + ? `` + : `
${svgIcon('video','icon-28')}
`; + zone.innerHTML = ` +
+ ${previewHtml} +
+
${escapeHtml(file.name)}
+
${sizeMb} MB · ${escapeHtml(file.type)}
+ +
+
`; + const clear = zone.querySelector('[data-clear]'); + if (clear) clear.addEventListener('click', (e) => { + e.stopPropagation(); + input.value = ''; + restoreDropZone(zone); + }); + } + + function restoreDropZone(zone) { + const kind = zone.dataset.kind; // "image" | "video" + const isVideo = kind === 'video'; + zone.classList.remove('has-file'); + zone.innerHTML = ` +
+
Drag ${isVideo ? 'a video' : 'an image'} here
+
or click to browse — ${isVideo ? 'MP4, MOV (max 200 MB)' : 'JPG, PNG (max 20 MB)'}
`; + } + + // ─── Analyze (image or video) ───────────────────────────────────────────── + async function analyze(kind) { + const btn = document.getElementById(`btn-${kind}`); + const statusEl = document.getElementById(`status-${kind}`); + const resultEl = document.getElementById(`result-${kind}`); + const fileInput = document.getElementById(`file-${kind}`); + const textArea = document.getElementById(`text-${kind}`); + + const file = fileInput.files?.[0]; + const text = textArea.value.trim(); + + if (!file) { + resultEl.innerHTML = `
Please choose ${kind === 'image' ? 'an image' : 'a video'}.
`; + return; + } + if (!text) { + resultEl.innerHTML = `
Please enter the caption to verify.
`; + return; + } + + btn.disabled = true; + btn.style.opacity = '0.6'; + btn.style.cursor = 'wait'; + const t0 = performance.now(); + statusEl.innerHTML = ` Analysis in progress…`; + resultEl.innerHTML = ''; + + // Start the animated process indicator + const processEl = document.getElementById(`process-${kind}`); + const processCtl = startProcessIndicator(processEl, kind); + + try { + const fd = new FormData(); + fd.append(kind, file); + fd.append('text', text); + + const resp = await fetch(`${IO3}/analyze/${kind}`, { method: 'POST', body: fd }); + if (!resp.ok) { + const txt = await resp.text(); + throw new Error(`HTTP ${resp.status} — ${txt.slice(0, 300)}`); + } + const data = await resp.json(); + const elapsed = ((performance.now() - t0) / 1000).toFixed(1); + processCtl.complete(elapsed); + statusEl.innerHTML = `${svgIcon('check','icon-14')} Analysis completed in ${elapsed} s`; + renderResult(resultEl, data, kind); + } catch (e) { + processCtl.fail(); + resultEl.innerHTML = `
The analysis could not be completed. Please try again in a moment.
`; + statusEl.textContent = 'Analysis interrupted'; + } finally { + btn.disabled = false; + btn.style.opacity = ''; + btn.style.cursor = ''; + } + } + + // ─── Process indicator (animated steps) ─────────────────────────────────── + // Steps use SVG icon keys (see ICONS object above) instead of emojis + const STEPS_IMAGE = [ + { ico: 'upload', lbl: 'Receiving the file', detail: 'Preparing the image', dur: 200 }, + { ico: 'image', lbl: 'Reading the image', detail: 'Setting, colors, atmosphere', dur: 1100 }, + { ico: 'box', lbl: 'Analyzing the scene', detail: 'Elements present in the image', dur: 700 }, + { ico: 'text', lbl: 'Reading the visible text', detail: 'Text overlaid on the image', dur: 600 }, + { ico: 'link', lbl: 'Comparing with the caption', detail: 'Image and caption matched up', dur: 700 }, + { ico: 'spark', lbl: 'Preparing the report', detail: 'Verdict and analyzed areas', dur: 600 }, + ]; + const STEPS_VIDEO = [ + { ico: 'upload', lbl: 'Receiving the file', detail: 'Preparing the video', dur: 300 }, + { ico: 'video', lbl: 'Extracting key frames', detail: 'A few representative frames', dur: 1500 }, + { ico: 'wave', lbl: 'Listening to the audio track', detail: 'Transcribing the speech', dur: 7000 }, + { ico: 'image', lbl: 'Reading the frames', detail: 'Setting, elements, visible text', dur: 3500 }, + { ico: 'box', lbl: 'Analyzing the scene', detail: 'What the video really shows', dur: 3500 }, + { ico: 'link', lbl: 'Comparing with the caption', detail: 'Image, audio and caption matched up', dur: 1500 }, + { ico: 'spark', lbl: 'Preparing the report', detail: 'Verdict and analyzed areas', dur: 1500 }, + ]; + + function startProcessIndicator(mount, kind) { + if (!mount) return { complete: () => {}, fail: () => {} }; + const steps = kind === 'video' ? STEPS_VIDEO : STEPS_IMAGE; + const totalDur = steps.reduce((a, s) => a + s.dur, 0); + + mount.innerHTML = ` +
+
+
${svgIcon('settings','icon-18')} Analysis in progress
+
0 / ${steps.length} steps
+
+
+
+ ${steps.map((s, i) => ` +
+
${svgIcon(s.ico,'icon-14')}
+
+
${escapeHtml(s.lbl)}
+
${escapeHtml(s.detail)}
+
+
${(s.dur / 1000).toFixed(1)} s
+
+ `).join('')} +
+
+ `; + + const stepEls = [...mount.querySelectorAll('.pstep')]; + const bar = mount.querySelector('[data-process-bar]'); + const statusTxt = mount.querySelector('[data-process-status]'); + + let cumul = 0; + let activeIdx = -1; + const timers = []; + let cancelled = false; + + const checkMark = svgIcon('check','icon-14'); + function activate(i) { + if (cancelled) return; + if (activeIdx >= 0 && stepEls[activeIdx]) { + stepEls[activeIdx].dataset.state = 'done'; + const t = stepEls[activeIdx].querySelector('.ps-time'); + if (t) t.innerHTML = checkMark; + } + activeIdx = i; + if (i < stepEls.length) { + stepEls[i].dataset.state = 'active'; + } + const pct = i >= 0 ? ((cumul + (steps[i]?.dur || 0)) / totalDur) * 100 : 0; + bar.style.width = Math.min(100, pct).toFixed(1) + '%'; + statusTxt.textContent = `${Math.min(i + 1, steps.length)} / ${steps.length} steps`; + } + + // Schedule each step + let elapsed = 0; + steps.forEach((s, i) => { + timers.push(setTimeout(() => activate(i), elapsed)); + elapsed += s.dur; + cumul = elapsed; + }); + + return { + complete(elapsedSec) { + cancelled = true; + timers.forEach(clearTimeout); + stepEls.forEach((el) => { + el.dataset.state = 'done'; + const t = el.querySelector('.ps-time'); + if (t) t.innerHTML = checkMark; + }); + bar.style.width = '100%'; + statusTxt.innerHTML = `${checkMark} ${steps.length} / ${steps.length} in ${elapsedSec} s`; + // Auto-collapse after 1.5s + setTimeout(() => { + if (mount.firstElementChild) { + mount.firstElementChild.style.transition = 'opacity .4s ease, max-height .4s ease, padding .4s ease, margin .4s ease'; + mount.firstElementChild.style.opacity = '0.6'; + } + }, 1500); + }, + fail() { + cancelled = true; + timers.forEach(clearTimeout); + if (activeIdx >= 0 && stepEls[activeIdx]) { + stepEls[activeIdx].dataset.state = 'pending'; + stepEls[activeIdx].style.color = 'var(--red)'; + } + statusTxt.innerHTML = `${svgIcon('cross','icon-14')} error`; + statusTxt.style.color = 'var(--red)'; + }, + }; + } + + // ─── Short explanations (info popups) ──────────────────────────────── + const XAI_DOCS = { + gradcam: { + kicker: 'Understand', + title: 'The analyzed areas', + body: ` +

This view highlights the areas of the image that mattered most when deciding whether the caption matched — warm tones (red / yellow) for what weighed most, cool tones (blue) for what was set aside.

+
    +
  • If the warm areas land on what the caption describes, the verdict is based on the right element.
  • +
  • If they land elsewhere (background, unrelated detail), treat the verdict with caution.
  • +
+ + `, + }, + }; + + // ─── Modal manager ──────────────────────────────────────────────────────── + let _activeBackdrop = null; + + function closeModal() { + if (!_activeBackdrop) return; + _activeBackdrop.classList.remove('open'); + setTimeout(() => { + _activeBackdrop?.remove(); + _activeBackdrop = null; + }, 250); + } + + function openModal({ box, lightbox = false }) { + closeModal(); + const backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop'; + backdrop.appendChild(box); + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) closeModal(); + }); + document.body.appendChild(backdrop); + _activeBackdrop = backdrop; + requestAnimationFrame(() => backdrop.classList.add('open')); + } + + function showInfoModal(key) { + const doc = XAI_DOCS[key]; + if (!doc) return; + const box = document.createElement('div'); + box.className = 'modal-box'; + box.innerHTML = ` + + +

${escapeHtml(doc.title)}

+ ${doc.body} + `; + box.querySelector('[data-close]').addEventListener('click', closeModal); + openModal({ box }); + } + + function showLightbox(src, caption) { + const box = document.createElement('div'); + box.className = 'modal-box lightbox-box'; + box.innerHTML = ` + + ${escapeHtml(caption || 'XAI visualization')} + ${caption ? `` : ''} + `; + box.querySelector('[data-close]').addEventListener('click', closeModal); + openModal({ box, lightbox: true }); + } + + // Global ESC + listeners (idempotent) + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeModal(); + }); + + // ─── Module metadata: monogram letter (CSS class), label, weight ────────── + const MODULE_META_IMAGE = { + clip: { mono: 'C', cls: 'clip', lbl: 'CLIP — semantics', weight: '40%' }, + sam: { mono: 'S', cls: 'sam', lbl: 'SAM — segments', weight: '25%' }, + whisper: { mono: 'W', cls: 'whisper', lbl: 'Whisper — audio', weight: '15%' }, + yolo: { mono: 'Y', cls: 'yolo', lbl: 'YOLO — objects', weight: '10%' }, + ocr: { mono: 'O', cls: 'ocr', lbl: 'OCR — image text', weight: '10%' }, + }; + const MODULE_META_VIDEO = { + text_image: { mono: 'T', cls: 'text_image', lbl: 'Text / Image', weight: '40%' }, + text_audio: { mono: 'A', cls: 'text_audio', lbl: 'Text / Audio', weight: '20%' }, + audio_image: { mono: 'X', cls: 'audio_image', lbl: 'Audio / Image', weight: '10%' }, + temporal: { mono: 'τ', cls: 'temporal', lbl: 'Temporal coherence', weight: '10%' }, + objects: { mono: 'O', cls: 'objects', lbl: 'Objects / Text', weight: '10%' }, + ocr: { mono: 'R', cls: 'ocr', lbl: 'OCR / Text', weight: '10%' }, + }; + + // Helper to render a monogram from a meta entry + function monoBadge(meta, size) { + const sz = size === 'lg' ? 'mono-lg' : size === 'sm' ? 'mono-sm' : ''; + return `${meta.mono}`; + } + + // ─── Raw score → displayed coherence index ───────────────────────────── + // The model's raw score is typically low (≈ 0.15–0.50): the verdict is + // "coherent" from 0.25 (image) / 0.22 (video). As-is, "47%" looks weak + // for a positive verdict. So we remap the scale to keep it intuitive: + // low threshold (SUSPECT) → 38% · high threshold (COHERENT) → 62% · strong score → ~98%. + // Monotonic remapping: does not change the order, does not change the verdict (computed server-side). + function thresholds(isVideo) { + return isVideo ? { good: 0.22, susp: 0.12, cap: 0.50 } : { good: 0.25, susp: 0.15, cap: 0.55 }; + } + function displayPct(rawScore, isVideo) { + const t = thresholds(isVideo); + const r = Math.max(0, Math.min(1, +rawScore || 0)); + let pct; + if (r <= t.susp) { + pct = (r / t.susp) * 38; // 0 .. 38 + } else if (r <= t.good) { + pct = 38 + ((r - t.susp) / (t.good - t.susp)) * 24; // 38 .. 62 + } else { + const k = Math.min(1, (r - t.good) / Math.max(1e-6, t.cap - t.good)); + pct = 62 + k * 36; // 62 .. 98 + } + return Math.max(0, Math.min(99, Math.round(pct))); + } + + // ─── SVG gauge for the verdict score ────────────────────────────────── + function buildGauge(score, isVideo) { + const R = 84, CIRC = 2 * Math.PI * R; + const pct = displayPct(score, isVideo); + const arcLen = (pct / 100) * CIRC; + return ` +
+ +
+
0 %
+
Coherence index
+
+
`; + } + + // ─── Render results ─────────────────────────────────────────────────────── + function renderResult(mount, data, kind) { + const verdict = data.verdict || 'INCOHERENT'; + const scoreGlobal = +data.score_global || 0; + const isVideo = kind === 'video'; + const xai = data.xai || {}; + const moduleAccent = (getComputedStyle(document.body).getPropertyValue('--mc') || '').trim() || '#1e3a8a'; + const narrHTML = (window.VerifyXAI && data.narrative) + ? window.VerifyXAI.narrativeCard(data.narrative, { accent: moduleAccent, verdictHint: data.verdict }) + : ''; + + const verdictWord = { COHERENT: 'Coherent', SUSPECT: 'Needs nuance', INCOHERENT: 'Incoherent' }; + const verdictSubs = { + COHERENT: 'The image and the caption tell the same story.', + SUSPECT: 'The image and the caption only partly overlap — check more closely.', + INCOHERENT: 'The image and the caption do not match. Be wary: possibly taken out of context.', + }; + const statusKicker = { + COHERENT: 'Match confirmed', + SUSPECT: 'To be examined', + INCOHERENT: 'Mismatch detected', + }; + + // What the image shows + const objectsChips = (data.objects_detected || []).map(o => `${escapeHtml(o)}`).join('') + || 'No distinct element identified.'; + const ocrChips = data.ocr_text + ? `
${escapeHtml(data.ocr_text)}
` + : 'No visible text in the image.'; + + // Video extras + const audioChip = data.has_audio + ? `${svgIcon('wave','icon-12')} audio track present` + : `no audio`; + const videoExtras = isVideo ? ` +
+
About the video
+
+ ${data.keyframes_count ? `${data.keyframes_count} key frames analyzed` : ''} + ${audioChip} + ${data.audio_language ? `language : ${escapeHtml(data.audio_language)}` : ''} +
+
+ ${data.audio_transcription ? ` +
+
Audio track transcription
+
${escapeHtml(data.audio_transcription)}
+
` : ''} + ` : ''; + + // ────────────────── Build HTML ────────────────── + mount.className = 'coh-results'; + mount.innerHTML = ` + +
+
+
+ ${statusIcon(verdict)} +
+ ${escapeHtml(statusKicker[verdict] || '')} + ${isVideo ? 'Video & caption' : 'Image & caption'} +
+
+
${escapeHtml(verdictWord[verdict] || verdict)}
+
${escapeHtml(verdictSubs[verdict] || '')}
+
+
Analysis${isVideo ? 'Video & caption' : 'Image & caption'}
+ ${isVideo ? `
Audio track${data.has_audio ? 'present' : 'absent'}
` : ''} +
+
+ ${buildGauge(scoreGlobal, isVideo)} +
+ + ${narrHTML} + + +
+
+
+ 01. +

What ${isVideo ? 'the video' : 'the image'} shows

+
+
+
The elements recognized in the visual, compared with what the caption states.
+
+
+
Recognized elements
+
${objectsChips}
+
+
+
Text read in ${isVideo ? 'the video' : 'the image'}
+ ${ocrChips} +
+
+ ${videoExtras} +
+ + ${xai.gradcam_base64 ? ` +
+
+
+ 02. +

Analyzed areas

+
+ +
+
The areas of the image that mattered most for the verdict. Warm tones for what weighed most. Click to enlarge.
+
+ Analyzed areas +
+
` : ''} + + ${narrHTML ? '' : buildExplanationCard(data, verdict, isVideo)} + `; + + // Wire interactivity + mount.querySelectorAll('[data-info]').forEach(btn => + btn.addEventListener('click', (e) => { + e.stopPropagation(); + showInfoModal(btn.dataset.info); + }) + ); + mount.querySelectorAll('[data-lightbox]').forEach(wrap => + wrap.addEventListener('click', () => { + const img = wrap.querySelector('img'); + if (img) showLightbox(img.src, 'Analyzed areas'); + }) + ); + + // Animate + requestAnimationFrame(() => { + mount.querySelectorAll('[data-target-w]').forEach(el => { + el.style.width = el.dataset.targetW + '%'; + }); + const arc = mount.querySelector('.gauge-arc'); + if (arc) { + const arclen = parseFloat(arc.dataset.arclen); + const circ = parseFloat(arc.dataset.circ); + arc.style.strokeDasharray = `${arclen} ${circ}`; + } + const counter = mount.querySelector('.gauge-num'); + if (counter) animateNumber(counter, 0, +counter.dataset.count, 1200); + }); + + mount.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + function scoreRow(key, meta, value, isVideo, tGood, tSusp) { + const v = Number.isFinite(value) ? value : 0; + const w = Math.max(0, Math.min(1, v)) * 100; + const cls = v >= tGood ? 'bar-good' : v >= tSusp ? 'bar-warn' : 'bar-bad'; + const thresholdMarkers = !isVideo ? ` + + + ` : ''; + return ` +
+
+ ${monoBadge(meta, 'sm')} + ${escapeHtml(meta.lbl)} + ${meta.weight} +
+
+
+ ${thresholdMarkers} +
+
${v.toFixed(3)}
+
`; + } + + function animateNumber(el, from, to, duration) { + const start = performance.now(); + function step(now) { + const t = Math.min(1, (now - start) / duration); + const eased = 1 - Math.pow(1 - t, 3); + el.innerHTML = Math.round(from + (to - from) * eased) + ' %'; + if (t < 1) requestAnimationFrame(step); + } + requestAnimationFrame(step); + } + + // ─────────────── XAI CARD: per-module mini-verdicts ─────────────── + function buildPerModuleCard(data, isVideo, tGood, tSusp) { + const meta = isVideo ? MODULE_META_VIDEO : MODULE_META_IMAGE; + const cells = Object.entries(meta).map(([key, m]) => { + const v = +data.scores?.[key] || 0; + const verdict = v >= tGood ? 'COHERENT' : v >= tSusp ? 'SUSPECT' : 'INCOHERENT'; + return ` +
+
${monoBadge(m, 'sm')}${escapeHtml(m.lbl.split(' — ')[0])}
+
${v.toFixed(3)}
+
${verdict}
+
`; + }).join(''); + return ` +
+
+
+ 06. +

Verdict per isolated module

+
+ +
+
If each module were asked for its decision independently, here is what it would have answered. Detects disagreements hidden by the weighting.
+
${cells}
+
`; + } + + // ─────────────── XAI CARD: SVG radar chart ─────────────── + function buildRadarCard(data, isVideo, verdict) { + const meta = isVideo ? MODULE_META_VIDEO : MODULE_META_IMAGE; + const entries = Object.entries(meta); + const N = entries.length; + const cx = 200, cy = 200, R = 140; + const angles = entries.map((_, i) => (Math.PI * 2 * i) / N - Math.PI / 2); + const polyColor = verdict === 'COHERENT' ? '#16a34a' : verdict === 'SUSPECT' ? '#f59e0b' : '#dc2626'; + const polyFill = verdict === 'COHERENT' ? 'rgba(22,163,74,.18)' : verdict === 'SUSPECT' ? 'rgba(245,158,11,.18)' : 'rgba(220,38,38,.18)'; + + // Concentric grid (circles for each 0.25 step) + const grid = [0.25, 0.5, 0.75, 1].map(r => { + const points = angles.map(a => `${(cx + Math.cos(a) * R * r).toFixed(1)},${(cy + Math.sin(a) * R * r).toFixed(1)}`).join(' '); + return ``; + }).join(''); + + // Axes lines + labels + const axes = entries.map(([k, m], i) => { + const ax = (cx + Math.cos(angles[i]) * R).toFixed(1); + const ay = (cy + Math.sin(angles[i]) * R).toFixed(1); + const lx = (cx + Math.cos(angles[i]) * (R + 22)).toFixed(1); + const ly = (cy + Math.sin(angles[i]) * (R + 22)).toFixed(1); + const anchor = Math.cos(angles[i]) > 0.3 ? 'start' : Math.cos(angles[i]) < -0.3 ? 'end' : 'middle'; + const lbl = m.lbl.split(' — ')[0].slice(0, 14); + return ` + + ${escapeHtml(lbl)} + `; + }).join(''); + + // Threshold polygon (at 0.25) + const thrR = 0.25 * R; + const thrPts = angles.map(a => `${(cx + Math.cos(a) * thrR).toFixed(1)},${(cy + Math.sin(a) * thrR).toFixed(1)}`).join(' '); + + // Actual polygon (clamped 0..1) + const dataPts = entries.map(([k, m], i) => { + const v = Math.max(0, Math.min(1, +data.scores?.[k] || 0)); + return `${(cx + Math.cos(angles[i]) * R * v).toFixed(1)},${(cy + Math.sin(angles[i]) * R * v).toFixed(1)}`; + }).join(' '); + const dataPoints = entries.map(([k, m], i) => { + const v = Math.max(0, Math.min(1, +data.scores?.[k] || 0)); + return ``; + }).join(''); + + return ` +
+
+
+ 07. +

Radar profile — 360° view

+
+ +
+
Each axis is a module. The wider and more regular the polygon, the more robust the verdict across all dimensions.
+
+
+ + ${grid} + ${axes} + + + ${dataPoints} + +
+
+
+ + Current profileActual score per module +
+
+ + COHERENT thresholdPolygon at 0.25 on all axes +
+
+ + Grid0.25 / 0.50 / 0.75 / 1.00 +
+
+
+
`; + } + + // ─────────────── XAI CARD: counterfactual analysis ─────────────── + function buildCounterfactualCard(data, currentVerdict, tGood, tSusp) { + const WEIGHTS = { clip: 0.40, sam: 0.25, whisper: 0.15, yolo: 0.10, ocr: 0.10 }; + const total = +data.score_global || 0; + const verdictOf = s => s >= tGood ? 'COHERENT' : s >= tSusp ? 'SUSPECT' : 'INCOHERENT'; + const verdictBg = v => v === 'COHERENT' ? '#dcfce7;color:#166534' : v === 'SUSPECT' ? '#fef3c7;color:#92400e' : '#fee2e2;color:#991b1b'; + + const rows = Object.entries(WEIGHTS).map(([key, w]) => { + const score = +data.scores?.[key] || 0; + const contribution = w * score; + const newScore = total - contribution; + const newVerdict = verdictOf(newScore); + const flipped = newVerdict !== currentVerdict; + const meta = MODULE_META_IMAGE[key]; + return ` +
+
${monoBadge(meta, 'sm')}${escapeHtml(meta.lbl.split(' — ')[0])}
+
contribution ${contribution.toFixed(3)} · score would become ${newScore.toFixed(3)}
+
−${contribution.toFixed(3)}
+
+ ${newVerdict} + ${flipped + ? `
${svgIcon('warn','icon-12')} verdict flipped
` + : `
stable
`} +
+
`; + }).join(''); + + return ` +
+
+
+ 08. +

Counterfactual — without this module?

+
+ +
+
For each module, we remove its contribution and see whether the verdict flips. Measures the robustness of the result.
+
${rows}
+
`; + } + + // ─────────────── Plain-language reading of the result ─────────────── + function buildExplanationCard(data, verdict, isVideo) { + const pct = displayPct(+data.score_global || 0, isVideo); + const visuel = isVideo ? 'the video' : 'the image'; + const objs = (data.objects_detected || []).filter(Boolean); + const objPhrase = objs.length + ? ` In ${visuel}, we notably recognize ${objs.slice(0, 4).map(escapeHtml).join(', ')}.` + : ''; + + let phrase; + if (verdict === 'COHERENT') { + phrase = `According to the analysis, ${visuel} and the caption tell the same story: the caption accurately describes what we see.`; + } else if (verdict === 'SUSPECT') { + phrase = `The analysis is inconclusive: the caption partly matches ${visuel}, but some elements do not overlap. Check more closely before sharing.`; + } else { + phrase = `The analysis finds a mismatch between ${visuel} and the caption: what we see does not match what the text states. Possibly an image taken out of context.`; + } + + return ` +
+
+
+ 03. +

Our reading

+
+
+
The result, explained simply.
+
+ ${phrase}${objPhrase} Coherence index: ${pct} %. +
+
`; + } + + function escapeHtml(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[c])); + } +})(); diff --git a/assets/js/components.js b/assets/js/components.js new file mode 100644 index 0000000000000000000000000000000000000000..fa842ac73a7b9993d3b461a6d69662a58478e6c8 --- /dev/null +++ b/assets/js/components.js @@ -0,0 +1,933 @@ +/* Shared header/footer/FAB components — single source of truth, injected on every page. */ + +/* ---- Inline SVG icon system (replaces emoji) ---- */ +const Icon = { + io1: '', + io2: '', + io3: '', + io4: '', + io5: '', + io6: '', + search: '', + bolt: '', + arrow: '', + shield: '', +}; + +/* Internal slugs (io1..io6) kept stable for URL backward compatibility, + but user-facing UI only shows the descriptive `name`. No IO codes shown. */ +const MODULES = [ + { id: 'io1', name: 'AI-Generated Media', short: 'AI Detection', icon: Icon.io3, bg: '#ede9fe', fg: '#5b21b6', + tagline: 'Catches images and videos produced by generative models.', + desc: 'Detects content created by GANs and diffusion models. Looks at visual artifacts, spectral signatures and inconsistencies the eye cannot see.' }, + { id: 'io2', name: 'Manipulation & Persuasion', short: 'Persuasion', icon: Icon.io2, bg: '#fef3c7', fg: '#78350f', + tagline: 'Spots the emotional triggers hidden in ads and propaganda.', + desc: 'Identifies persuasion and visual-manipulation techniques used in advertising and propaganda video — biased framing, color grading, emotional zoom.' }, + { id: 'io4', name: 'Photoshop Forensics', short: 'Forensics', icon: Icon.io4, bg: '#dcfce7', fg: '#14532d', + tagline: 'Detects local edits on otherwise authentic photos.', + desc: 'Spots local modifications: object insertion or removal, cloning, splicing, inpainting. Powered by ELA, double-JPEG analysis and quantization-table reconstruction.' }, + { id: 'io5', name: 'Caption Fidelity', short: 'Fidelity', icon: Icon.io1, bg: '#dbeafe', fg: '#1e3a8a', + tagline: 'Measures how faithfully a caption describes the post.', + desc: 'Evaluates how faithfully a caption describes its visual content. Surfaces exaggerations, omissions and editorial framing.' }, + { id: 'io6', name: 'Cosmetic Ads Fact-Check', short: 'Cosmetics', icon: Icon.io6, bg: '#ffedd5', fg: '#9a3412', + tagline: 'Verifies the visual claims of cosmetics advertising.', + desc: 'Verifies the visual claims of cosmetic advertising: retouched before/after photos, deepfaked testimonials, misleading claims.' }, +]; + +const ModuleDropdown = () => ` + +`; + +const VerifyHeader = ({ slim = false } = {}) => ` + + +`; + +const VerifyFooter = () => ` + +`; + +const FullscreenMenu = () => ` + +`; + +/* ── Verify Assistant — topic-restricted chatbot widget ── + Auto-mounted next to the FAB. Talks to POST /api/chat/ask. + Also accepts images/videos and routes them to /api/io1/analyze/* for an + AI-generated / deepfake verdict, rendered inline. */ +const VerifyChatWidget = () => ` +
+ + +
+`; + +const VerifyFAB = () => ` +
+
+

Verify now

+
+ ${MODULES.map((m) => ` + + ${m.icon} + ${m.short} · ${m.name} + + `).join('')} + + → View all modules + +
+
+ +
+`; + +document.addEventListener('DOMContentLoaded', () => { + // Header + const headerMount = document.getElementById('site-header'); + if (headerMount) { + const slim = headerMount.dataset.slim === 'true'; + headerMount.innerHTML = VerifyHeader({ slim }); + + // Sticky navbar — pin shadow on scroll + const navyNav = headerMount.querySelector('.navy-nav'); + if (navyNav) { + const onScroll = () => { + navyNav.classList.toggle('is-pinned', window.scrollY > 40); + }; + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); + } + } + + // Footer + const footerMount = document.getElementById('site-footer'); + if (footerMount) footerMount.innerHTML = VerifyFooter(); + + // Fullscreen menu overlay (mounted once, shared across all pages) + document.body.insertAdjacentHTML('beforeend', FullscreenMenu()); + const overlay = document.getElementById('menu-overlay'); + const burger = document.querySelector('.navy-nav .burger'); + const closeBtn = document.getElementById('menu-close'); + + function openMenu() { + if (!overlay) return; + overlay.hidden = false; + requestAnimationFrame(() => overlay.classList.add('is-open')); + document.body.classList.add('menu-open'); + if (closeBtn) closeBtn.focus(); + } + function closeMenu() { + if (!overlay) return; + overlay.classList.remove('is-open'); + document.body.classList.remove('menu-open'); + setTimeout(() => { overlay.hidden = true; }, 350); + if (burger) burger.focus(); + } + + if (burger) burger.addEventListener('click', openMenu); + if (closeBtn) closeBtn.addEventListener('click', closeMenu); + if (overlay) { + overlay.addEventListener('click', (e) => { + if (e.target === overlay) closeMenu(); + }); + } + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && overlay && !overlay.hidden) closeMenu(); + }); + + // FAB — auto-injected on every page (unless explicitly disabled) + if (document.body.dataset.fab !== 'off') { + document.body.insertAdjacentHTML('beforeend', VerifyFAB()); + const toggle = document.getElementById('fab-toggle'); + const menu = document.getElementById('fab-menu'); + if (toggle && menu) { + toggle.addEventListener('click', (e) => { + e.stopPropagation(); + menu.classList.toggle('open'); + }); + document.addEventListener('click', (e) => { + if (!document.getElementById('verify-fab').contains(e.target)) { + menu.classList.remove('open'); + } + }); + } + } + + // Verify Assistant (chatbot) — auto-injected unless data-chatbot="off" + if (document.body.dataset.chatbot !== 'off') { + document.body.insertAdjacentHTML('beforeend', VerifyChatWidget()); + if (window.VerifyChat && typeof window.VerifyChat.mount === 'function') { + window.VerifyChat.mount(); + } + } + + // Scroll reveal — IntersectionObserver-based + if ('IntersectionObserver' in window) { + const io = new IntersectionObserver( + (entries) => { + entries.forEach((e) => { + if (e.isIntersecting) { + e.target.classList.add('in'); + io.unobserve(e.target); + } + }); + }, + { threshold: 0.12, rootMargin: '0px 0px -40px 0px' } + ); + document + .querySelectorAll('[data-reveal], [data-reveal-stagger]') + .forEach((el) => io.observe(el)); + } else { + document + .querySelectorAll('[data-reveal], [data-reveal-stagger]') + .forEach((el) => el.classList.add('in')); + } + + // Animate stats counters when they enter the viewport + const counters = document.querySelectorAll('.stats-band .num[data-count-to]'); + if (counters.length && 'IntersectionObserver' in window) { + const cio = new IntersectionObserver((entries) => { + entries.forEach((e) => { + if (!e.isIntersecting) return; + const el = e.target; + const target = parseFloat(el.dataset.countTo); + const suffix = el.dataset.countSuffix || ''; + const decimals = parseInt(el.dataset.countDecimals || '0', 10); + let current = 0; + const start = performance.now(); + const dur = 1200; + const step = (now) => { + const t = Math.min(1, (now - start) / dur); + const eased = 1 - Math.pow(1 - t, 3); + current = target * eased; + el.textContent = current.toFixed(decimals) + suffix; + if (t < 1) requestAnimationFrame(step); + else el.textContent = target.toFixed(decimals) + suffix; + }; + requestAnimationFrame(step); + cio.unobserve(el); + }); + }, { threshold: 0.5 }); + counters.forEach((el) => cio.observe(el)); + } + + // Auto-scrolling marquees — duplicate the track content once so the + // CSS translateX(-50%) loop is seamless. Honour reduced-motion. + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + document.querySelectorAll('.marquee').forEach((mq) => { + const track = mq.querySelector('.marquee__track'); + if (!track || track.dataset.cloned === 'true') return; + track.dataset.cloned = 'true'; + track.innerHTML += track.innerHTML; + // Scale duration to content width so speed stays consistent across strips + if (!reduceMotion) { + requestAnimationFrame(() => { + const px = track.scrollWidth / 2; + const speed = parseFloat(mq.dataset.speed || '70'); // px per second + track.style.animationDuration = Math.max(14, px / speed) + 's'; + }); + } + }); +}); + +window.VERIFY_MODULES = MODULES; + +/* ───────────────────────────────────────────────────────────────────────── + XAI — "Reading the result" + Shared, jargon-free card that presents the AI-written explanation attached + by the backend to `data.narrative` ({headline, confidence_label, summary, + signals[], checked[], caveat}). Used by the result renderers of all 5 + modules: window.VerifyXAI.narrativeCard(narrative, opts). + ───────────────────────────────────────────────────────────────────────── */ +(function () { + const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + + const CONF = { + 'almost certain': { pct: 96, tone: 'hi' }, + 'very likely': { pct: 86, tone: 'hi' }, + 'likely': { pct: 72, tone: 'mid' }, + 'plausible — to confirm': { pct: 55, tone: 'lo' }, + 'plausible - to confirm': { pct: 55, tone: 'lo' }, + 'plausible': { pct: 55, tone: 'lo' }, + 'uncertain': { pct: 38, tone: 'lo' }, + }; + function confMeta(label) { + return CONF[String(label || '').toLowerCase().trim()] || { pct: 70, tone: 'mid' }; + } + + // verdict colour family, inferred from a free-text string + function familyFor(hint) { + const v = String(hint || '').toLowerCase(); + if (/suspect|suspici|to confirm|unconfirmed|uncertain|nuance|caution|doubt|needs review|to verify|to examine|inconclusive/.test(v)) return 'warn'; + if (/\b(fake|deepfake)\b|swapp|incoher|mislead|deceptiv|alter|tamper|manipul|generat|fabricat|retouch|edited|exagger|overstat|false\b|unproven|not proven/.test(v)) return 'alert'; + if (/authent|reliable|coheren|faithful|genuine|compliant|\breal\b|\bok\b|honest|credible|proven|trustworth/.test(v)) return 'ok'; + return 'neutral'; + } + + const ICO = { + ok: '', + alert: '', + warn: '', + neutral: '', + sig: '', + chk: '', + }; + + /** + * narrative : { headline, confidence_label, summary, signals[], checked[], caveat } + * opts : { accent:'#hex' (module colour), verdictHint:string } + * → returns the card HTML, or '' if there's nothing usable. + */ + function narrativeCard(narrative, opts) { + const n = narrative || {}; + if (!n.summary && !(Array.isArray(n.signals) && n.signals.length)) return ''; + opts = opts || {}; + const fam = familyFor(opts.verdictHint || n.headline || ''); + const cm = confMeta(n.confidence_label); + const accent = opts.accent || '#052962'; + const sig = (n.signals || []).filter(Boolean).slice(0, 4); + const chk = (n.checked || []).filter(Boolean).slice(0, 4); + return ` +
+ +
+ Reading the result — in plain words +
+ ${ICO[fam] || ICO.neutral} +

${esc(n.headline || "Analysis result")}

+
+ ${n.confidence_label ? ` +
+
+ ${esc(n.confidence_label)} +
` : ''} +
+ ${n.summary ? `

${esc(n.summary)}

` : ''} + ${(sig.length || chk.length) ? ` +
+ ${sig.length ? ` +
+
${ICO.sig}What led us to this verdict
+
    ${sig.map((s) => `
  • ${esc(s)}
  • `).join('')}
+
` : ''} + ${chk.length ? ` +
+
${ICO.chk}What the analysis reviewed
+
    ${chk.map((s) => `
  • ${esc(s)}
  • `).join('')}
+
` : ''} +
` : ''} + ${n.caveat ? `

${esc(n.caveat)}

` : ''} + +
`; + } + + window.VerifyXAI = { narrativeCard, confMeta, familyFor, escapeHtml: esc }; +})(); + +/* ───────────────────────────────────────────────────────────── + Verify Assistant — runtime + Renders messages, talks to POST /api/chat/ask, and persists the + conversation per-tab in sessionStorage so it survives navigation. +───────────────────────────────────────────────────────────── */ +(function () { + const STORAGE_KEY = 'verify-chat-history-v1'; + const API_BASE = () => (window.VERIFY_API_URL || 'http://localhost:8000').replace(/\/+$/, ''); + + const esc = (s) => String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + + // Minimal safe markdown: **bold**, *italic*, `code`, and line breaks. + function lightFormat(text) { + let s = esc(text); + s = s.replace(/`([^`]+)`/g, '$1'); + s = s.replace(/\*\*([^*]+)\*\*/g, '$1'); + s = s.replace(/(^|\s)\*([^*\s][^*]*[^*\s])\*(?=\s|$)/g, '$1$2'); + s = s.replace(/\n/g, '
'); + return s; + } + + const SUGGESTIONS = [ + 'How can I spot an AI-generated photo?', + 'What does the Photoshop Forensics module check?', + 'Why is this caption misleading?', + 'How does Verify rate a cosmetic ad?', + ]; + + const WELCOME = "Hi — I'm the Verify Assistant. Ask me about deepfakes, AI-generated images, edited photos, misleading captions, or how to use the Verify platform. I only answer questions on these topics."; + + function loadHistory() { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const arr = JSON.parse(raw); + return Array.isArray(arr) ? arr.slice(-24) : []; + } catch { return []; } + } + function saveHistory(arr) { + try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(arr.slice(-24))); } catch {} + } + + function bubbleHTML(msg) { + const isUser = msg.role === 'user'; + const side = isUser ? 'user' : 'assistant'; + const mini = isUser ? '' : ''; + + // Media attachment bubble (user-uploaded image / video) + if (msg.kind === 'media') { + const inner = msg.media_type === 'video' + ? `
🎬${esc(msg.name || 'video')}
` + : `
`; + return ` +
+ ${mini} +
+ ${inner} + ${msg.content ? `
${esc(msg.content)}
` : ''} +
+
`; + } + + // Verdict bubble (assistant — result of an io1 analysis) + if (msg.kind === 'verdict') { + const v = msg.verdict || {}; + const tone = v.tone || 'neutral'; + const ico = tone === 'alert' + ? '' + : tone === 'ok' + ? '' + : ''; + const sigList = (v.signals && v.signals.length) + ? `
    ${v.signals.slice(0,3).map((s) => `
  • ${esc(s)}
  • `).join('')}
` + : ''; + return ` +
+ ${mini} +
+
+ ${ico} +
+
${esc(v.headline || 'Analysis verdict')}
+ ${v.confidence ? `
${esc(v.confidence)}
` : ''} +
+
+ ${v.summary ? `

${esc(v.summary)}

` : ''} + ${sigList} + ${v.caveat ? `

${esc(v.caveat)}

` : ''} +
+
`; + } + + return ` +
+ ${mini} +
${lightFormat(msg.content)}
+
`; + } + + function suggestionsHTML() { + return ` +
+ ${SUGGESTIONS.map((s) => ``).join('')} +
`; + } + + function typingHTML() { + return ` +
+ +
+
`; + } + + function render(state) { + const body = document.getElementById('vchat-body'); + if (!body) return; + let html = ''; + if (!state.history.length) { + html += bubbleHTML({ role: 'assistant', content: WELCOME }); + html += suggestionsHTML(); + } else { + state.history.forEach((m) => { html += bubbleHTML(m); }); + } + if (state.pending) html += typingHTML(); + body.innerHTML = html; + requestAnimationFrame(() => { body.scrollTop = body.scrollHeight; }); + } + + async function callBackend(history) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 35000); + try { + const r = await fetch(`${API_BASE()}/api/chat/ask`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: history }), + signal: ctrl.signal, + }); + clearTimeout(t); + if (!r.ok) throw new Error('http ' + r.status); + const json = await r.json(); + return (json && typeof json.reply === 'string' && json.reply.trim()) + ? json.reply.trim() + : null; + } catch (e) { + clearTimeout(t); + return null; + } + } + + function offlineReply() { + return "I can't reach the Verify backend right now. Make sure the API is running (uvicorn backend.main:app --port 8000) and try again."; + } + + function formatBytes(n) { + if (!Number.isFinite(n)) return ''; + const u = ['B','KB','MB','GB']; + let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return n.toFixed(n >= 10 || i === 0 ? 0 : 1) + ' ' + u[i]; + } + + function fileToDataURL(f) { + return new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => resolve(String(r.result || '')); + r.onerror = () => reject(r.error); + r.readAsDataURL(f); + }); + } + + // Upload to io1 — returns a normalised verdict object or null on failure. + async function callIO1(file, isVideo) { + const endpoint = isVideo ? '/api/io1/analyze/video' : '/api/io1/analyze/image'; + const fd = new FormData(); + fd.append(isVideo ? 'video' : 'image', file, file.name || (isVideo ? 'clip.mp4' : 'image.jpg')); + if (!isVideo) fd.append('mode', 'auto'); + + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 120000); // image/video analysis is slower than chat + try { + const r = await fetch(`${API_BASE()}${endpoint}`, { method: 'POST', body: fd, signal: ctrl.signal }); + clearTimeout(t); + if (!r.ok) return null; + const data = await r.json(); + return normaliseVerdict(data, isVideo); + } catch { + clearTimeout(t); + return null; + } + } + + function normaliseVerdict(data, isVideo) { + if (!data || typeof data !== 'object') return null; + const n = data.narrative || {}; + const cls = String(data.verdict_class || data.verdict || '').toLowerCase(); + const isAi = String(data.verdict_kind || data.kind || '').toLowerCase() === 'ai'; + const isFake = cls === 'fake' || cls === 'ai-generated' || cls === 'ai_generated' || isAi; + const label = n.headline + || data.verdict_label + || (isFake + ? (isAi ? 'AI-generated media' : (isVideo ? 'Faked face (deepfake)' : 'Likely manipulated')) + : (isVideo ? 'Authentic video' : 'Authentic image')); + const tone = isFake ? 'alert' : (cls === 'real' || cls === 'authentic' ? 'ok' : 'neutral'); + return { + tone, + headline: label, + confidence: n.confidence_label || '', + summary: n.summary || data.verdict_explanation || '', + signals: Array.isArray(n.signals) ? n.signals : [], + caveat: n.caveat || '', + }; + } + + function mount() { + const root = document.getElementById('verify-chat'); + if (!root) return; + + const launcher = document.getElementById('vchat-launcher'); + const closeBtn = document.getElementById('vchat-close'); + const resetBtn = document.getElementById('vchat-reset'); + const form = document.getElementById('vchat-form'); + const input = document.getElementById('vchat-input'); + const sendBtn = document.getElementById('vchat-send'); + const attachBtn = document.getElementById('vchat-attach'); + const fileInput = document.getElementById('vchat-file'); + const previewBox = document.getElementById('vchat-preview'); + const panel = root.querySelector('.vchat__panel'); + + const state = { history: loadHistory(), pending: false, pendingFile: null, pendingFileURL: null }; + render(state); + + function open() { + root.classList.add('is-open'); + root.dataset.state = 'open'; + launcher.setAttribute('aria-expanded', 'true'); + if (panel) panel.setAttribute('aria-hidden', 'false'); + setTimeout(() => input && input.focus(), 50); + } + function close() { + root.classList.remove('is-open'); + root.dataset.state = 'closed'; + launcher.setAttribute('aria-expanded', 'false'); + if (panel) panel.setAttribute('aria-hidden', 'true'); + } + function toggle() { root.classList.contains('is-open') ? close() : open(); } + + launcher.addEventListener('click', toggle); + if (closeBtn) closeBtn.addEventListener('click', close); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && root.classList.contains('is-open')) close(); + }); + + // Suggestion chips (event-delegation, since they may re-render) + root.addEventListener('click', (e) => { + const chip = e.target.closest && e.target.closest('.vchat__chip'); + if (!chip) return; + const q = chip.dataset.q || chip.textContent || ''; + if (q.trim()) ask(q.trim()); + }); + + // Auto-grow textarea + function grow() { + input.style.height = 'auto'; + input.style.height = Math.min(input.scrollHeight, 110) + 'px'; + } + input.addEventListener('input', grow); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + form.requestSubmit(); + } + }); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + if (state.pending) return; + const q = (input.value || '').trim(); + const f = state.pendingFile; + if (!q && !f) return; + input.value = ''; + grow(); + if (f) { + clearPreview(); + analyzeFile(f, q); + } else { + ask(q); + } + }); + + // ── Attachments ── + attachBtn.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', () => { + const f = fileInput.files && fileInput.files[0]; + fileInput.value = ''; // allow re-picking the same file + if (!f) return; + acceptFile(f); + }); + + // Drag & drop on the panel + panel.addEventListener('dragover', (e) => { + if (e.dataTransfer && [...e.dataTransfer.items].some((it) => it.kind === 'file')) { + e.preventDefault(); + panel.classList.add('is-drop'); + } + }); + panel.addEventListener('dragleave', () => panel.classList.remove('is-drop')); + panel.addEventListener('drop', (e) => { + panel.classList.remove('is-drop'); + const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; + if (!f) return; + e.preventDefault(); + acceptFile(f); + }); + + function acceptFile(f) { + const kind = f.type.startsWith('video/') ? 'video' : f.type.startsWith('image/') ? 'image' : null; + if (!kind) { + state.history.push({ role: 'assistant', content: 'Sorry — I can only check images and videos.' }); + saveHistory(state.history); render(state); + return; + } + const MAX = 50 * 1024 * 1024; // 50 MB + if (f.size > MAX) { + state.history.push({ role: 'assistant', content: 'That file is over 50 MB — try a smaller version, please.' }); + saveHistory(state.history); render(state); + return; + } + if (state.pendingFileURL) { URL.revokeObjectURL(state.pendingFileURL); } + state.pendingFile = f; + state.pendingFileURL = URL.createObjectURL(f); + showPreview(f, kind); + } + + function showPreview(f, kind) { + const isImg = kind === 'image'; + previewBox.hidden = false; + previewBox.innerHTML = ` +
+ ${isImg + ? `` + : `🎬`} +
+
${esc(f.name || (isImg ? 'image' : 'video'))}
+
${isImg ? 'Image' : 'Video'} · ${formatBytes(f.size)} — ready to verify
+
+ +
`; + const xBtn = document.getElementById('vchat-prev-x'); + if (xBtn) xBtn.addEventListener('click', clearPreview); + } + + function clearPreview() { + if (state.pendingFileURL) { URL.revokeObjectURL(state.pendingFileURL); state.pendingFileURL = null; } + state.pendingFile = null; + previewBox.hidden = true; + previewBox.innerHTML = ''; + } + + async function ask(q) { + state.history.push({ role: 'user', content: q }); + state.pending = true; + sendBtn.disabled = true; + render(state); + + const reply = await callBackend(state.history); + state.pending = false; + sendBtn.disabled = false; + state.history.push({ role: 'assistant', content: reply || offlineReply() }); + saveHistory(state.history); + render(state); + } + + async function analyzeFile(f, caption) { + const isVideo = f.type.startsWith('video/'); + // Thumbnail data URL for images so it survives storage / re-render + let thumb = null; + if (!isVideo) { + try { thumb = await fileToDataURL(f); } catch {} + } + state.history.push({ + role: 'user', + kind: 'media', + media_type: isVideo ? 'video' : 'image', + name: f.name, + thumb, + content: caption || '', + }); + state.pending = true; + sendBtn.disabled = true; + render(state); + + const verdict = await callIO1(f, isVideo); + state.pending = false; + sendBtn.disabled = false; + + if (verdict) { + state.history.push({ role: 'assistant', kind: 'verdict', verdict }); + } else { + state.history.push({ + role: 'assistant', + content: "I couldn't analyse that file. Make sure the Verify backend is running and try again — or use the dedicated AI-Generated Media module for a full report.", + }); + } + saveHistory(state.history); + render(state); + } + + // Public API: programmatic open + reset + window.VerifyChat.open = open; + window.VerifyChat.close = close; + window.VerifyChat.reset = () => { + clearPreview(); + state.history = []; + saveHistory([]); + render(state); + }; + + if (resetBtn) resetBtn.addEventListener('click', () => window.VerifyChat.reset()); + } + + window.VerifyChat = { mount }; +})(); diff --git a/assets/js/config.js b/assets/js/config.js new file mode 100644 index 0000000000000000000000000000000000000000..1e721144ed05fa02866f09e9122cb8df7dd2808e --- /dev/null +++ b/assets/js/config.js @@ -0,0 +1,38 @@ +/* + * Verify — frontend → backend URL resolver. + * + * Loaded BEFORE all other module JS files (synthesis.js, coherence.js, etc.). + * Each module reads window.VERIFY_API_URL to know where to send /api/* requests. + * + * Auto-detection rules: + * • file:// or localhost:5500 → backend assumed on http://localhost:8000 (Live Server dev) + * • everything else (HF Space, GitHub Pages mirror, prod domain) → empty string = same-domain + * + * Override (e.g. when developing the frontend against a remote backend): + * window.VERIFY_API_URL = 'https://your-username-verify.hf.space'; + * ← include this AFTER the override + * + * The empty-string default makes every fetch a relative URL like `/api/io1/analyze/image`, + * which works when FastAPI serves both the static frontend and the API on the same port + * (this is the HuggingFace Space / single-container deployment). + */ +(function () { + // Respect any value already set by a previous inline + + + + + +
+
+
+ HomePolitics +
+

Politics

+

+ Independent coverage of Tunisian political life — institutions, + parties, elections — paired with systematic fact-check tracking. +

+
+ +
+ + + + + + + +
+ +
+ + + + + + + + + +
+ + +
+ + + + + + + + +
+
+ + + + + + + + diff --git a/connexion.html b/connexion.html new file mode 100644 index 0000000000000000000000000000000000000000..86b86fbec74f3d842789554006052510cbcc5b8f --- /dev/null +++ b/connexion.html @@ -0,0 +1,92 @@ + + + + + + Sign in — Verify + + + + + + + + + +
+ + + + +
+
+

Welcome back

+

Glad to see you again. Continue your verification work.

+ +
+
+ + +
+
+ + +
+ +
+ + Forgot password? +
+ + + +
+
+ OR +
+
+ +
+ + +
+
+ +

+ Not registered yet? Create an account +

+
+
+
+ + + + + + + + diff --git a/dashboard.html b/dashboard.html new file mode 100644 index 0000000000000000000000000000000000000000..912169475cd630ba07b4a11d9449c7aac9115d30 --- /dev/null +++ b/dashboard.html @@ -0,0 +1,242 @@ + + + + + + Statistics — Verify + + + + + + + + + + + +
+
+
+

Platform Statistics

+

Analysis volumes, verdicts and disinformation trends in Tunisia. Updated in real time.

+
+
+ + + +
+
+
+ + +
+
+ Total analyses + 889 + ▲ 12.4% vs previous period +
+
+ Flagged as manipulated + 38% + ▲ 3.1 pts +
+
+ Average analysis time + 1m 24s + ▼ 18 seconds +
+
+ Partner fact-checks + 126 + ▲ Tunifact + iCheck +
+
+ + +
+
+

Analyses over time

+

Number of analyses launched per day, across all modules.

+ +
+
+

Verdict breakdown

+

Over the selected period.

+ +
+
+ + +
+
+

Volume by module

+

Which modules are most used this week.

+
+
+
+

Top sources of disinformation

+

Where flagged content originates.

+
    +
  • Facebook41%
  • +
  • X / Twitter27%
  • +
  • TikTok14%
  • +
  • WhatsApp11%
  • +
  • YouTube5%
  • +
  • Other2%
  • +
+
+
+ + +
+
+

Activity by hour

+

Peak submission times this week.

+ +
+
+

Latest analyses

+

Live feed.

+
    +
  • Caption Fidelity & Coherence2 min ago
  • +
  • AI-Generated Media6 min ago
  • +
  • Photoshop Forensics18 min ago
  • +
  • Cosmetic Ads25 min ago
  • +
+
+
+ + + + + + + + + diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000000000000000000000000000000000000..90d0461080c2b766576d3cef1f4893c803675f1d --- /dev/null +++ b/docs/api.md @@ -0,0 +1,185 @@ +# API Reference — Verify Backend + +Base URL : `http://localhost:8000` + +Swagger interactif : `http://localhost:8000/docs` + +## Endpoints transverses + +| Méthode | Route | Description | +|---|---|---| +| `GET` | `/` | Liste de tous les modules et leurs endpoints | +| `GET` | `/health` | Status global (tous les modules) | +| `GET` | `/docs` | Swagger UI (OpenAPI 3) | + +## Endpoints par module + +Toutes les routes d'analyse retournent du **JSON**. Les réponses contiennent un champ `verdict` (texte +court, ex: `"FAKE"`/`"REAL"`/`"MISLEADING"`), un champ `confidence_pct` (entier 0-100), une +`explanation` en anglais clair, et un sous-objet `xai` avec des heatmaps en base64 PNG. + +### io1 — Fake Media Detection + +```http +GET /api/io1/health +POST /api/io1/analyze/image # multipart: image=, mode=auto|deepfake|ai +POST /api/io1/analyze/video # multipart: video= +``` + +**Modes** : +- `auto` (défaut) : détecte un visage et route vers deepfake ; sinon vers AI-image +- `deepfake` : force la détection face-deepfake (refuse si aucun visage trouvé) +- `ai` : force la détection AI-image (whole image), avec escalation 2-détecteurs + +**Réponse (extrait)** : +```json +{ + "verdict": "FAKE", + "verdict_label": "AI-generated image", + "confidence_pct": 97, + "prob_fake": 0.97, "prob_real": 0.03, + "task": "image_ai_generated", + "face_detected": true, "face_box": [x, y, w, h], + "model_used": "io1_resnet50.pth + AI-image consensus", + "xai": { "gradcam_overlay": "", "face_crop": "" }, + "explanation": "Two independent AI-image classifiers agree...", + "narrative": { "headline": "AI-generated image", "summary": "...", "signals": [...] } +} +``` + +### io2 — Visual Manipulation / Persuasion + +```http +GET /api/io2/health +POST /api/io2/analyze/image # multipart: image= +POST /api/io2/analyze/video # multipart: video= (analyse la 1ère frame) +``` + +**Verdicts** : `AUTHENTIC` · `SUSPECT` · `MANIPULATIVE` · `HIGHLY MANIPULATIVE` + +**Réponse (extrait)** : +```json +{ + "score_global": 0.73, + "label": "MANIPULATIVE", "label_color": "#F97316", + "scores_modules": { "nlp": 0.85, "clickbait": 0.61, "urgence": 0.95, "manipnet": 0.73 }, + "texte": { "extrait": "LIMITED TIME ONLY", "tone": "alarmist", "label_nlp": "Manipulator" }, + "techniques": ["limited-time", "percent-off", "fomo"], + "bounding_boxes": [{"class": "limited-time", "score": 0.9, "box": [120, 30, 280, 70]}], + "xai": { "bbox_overlay": "", "gradient_saliency": "" } +} +``` + +### io3 — Image↔Caption Coherence + +```http +GET /api/io3/health +POST /api/io3/analyze/image # multipart: image=, text= +POST /api/io3/analyze/video # multipart: video=, text= +``` + +**Verdicts** : `COHERENT` (≥0.25) · `SUSPECT` (0.15-0.25) · `INCOHERENT` (<0.15) + +**Réponse (extrait)** : +```json +{ + "verdict": "INCOHERENT", + "score_global": 0.11, + "scores": { "clip": 0.18, "sam": 0.22, "whisper": 0.5, "yolo": 0.0, "ocr": 0.0 }, + "objects_detected": ["dog", "person"], + "ocr_text": "Welcome to the park", + "xai": { "gradcam_base64": "<...>", "waterfall_base64": "<...>", "shap": { ... } } +} +``` + +### io4 — Image Tampering (Photoshop forensics) + +```http +GET /api/io4/health +POST /api/io4/analyze/image # multipart: image= +POST /api/io4/analyze/compare # multipart: image=, reference= +``` + +**Verdicts** : `AUTHENTIC` · `FAKE` (sous-classe : `Inpainting` · `Copy-move` · `Splicing` · `Enhancement`) + +**Réponse (extrait)** : +```json +{ + "verdict": "FAKE", "verdict_class": "Splicing", + "predicted_class": "Splicing", "max_confidence": 0.78, + "region": "top right", + "ela_score": 0.67, "noise_score": 0.55, "jpeg_ghost_score": 0.42, + "fused_score": 0.61, "signals_agreement": 2, + "clues": ["ELA shows a compact bright patch", "JPEG ghost detected at quality 75"], + "xai": { "tamper_mask_overlay": "", "gradcam_overlay": "" } +} +``` + +### io5 — Caption Fidelity + +```http +GET /api/io5/health +POST /api/io5/analyze # multipart: file=, text= +``` + +**Verdicts** : `FAITHFUL` · `PARTIAL` · `MISLEADING` + +**Réponse (extrait)** : +```json +{ + "verdict": "MISLEADING", "verdict_color": "#E53E3E", + "score": 0.21, "confidence": 0.92, + "raw_clip_similarity": 0.13, "calibrated_similarity": 0.14, + "ocr_overlap": 0.0, "tone_gap": 0.55, + "phrase_breakdown": [ + {"phrase": "UFO landing white house", "score": 0.08, "supported": false} + ], + "unsupported_phrases": ["UFO landing white house", "Breaking news"], + "xai": { "clip_saliency": "" } +} +``` + +### io6 — Cosmetic Ads Fact-Check + +```http +GET /api/io6/health +POST /api/io6/analyze/video # multipart: video= (vidéo MP4 d'une pub cosmétique) +``` + +**Verdicts** : `RELIABLE` (trust_score≥50) · `MISLEADING` (<50) + +**Réponse (extrait)** : +```json +{ + "global_verdict": "MISLEADING", "trust_score": 32.4, + "transcript": "L'Oréal Revitalift reduces wrinkles by 40% in 7 days...", + "language": "en", + "claims": [ + { + "claim": "reduces wrinkles by 40% in 7 days", + "verdict": "FALSE", "confidence": 0.9, "severity": "high", + "evidence": [ + {"source": "Decisive_PostProcessor", "rule_type": "FAUX_pattern", + "description": "Time-bound claim without substantiation", "eu_article": "EU 655/2013 art. 4.4"} + ], + "eu_articles": ["EU 655/2013 art. 4.4", "EU 655/2013 art. 4.2"] + } + ], + "stats": { "total": 8, "n_true": 3, "n_false": 4, "n_to_verify": 1 }, + "eu_articles_cited": ["EU 655/2013 art. 4.2", "EU 655/2013 art. 4.4"] +} +``` + +## Codes HTTP + +| Code | Sens | +|---|---| +| `200` | OK — réponse JSON | +| `400` | Bad request — input manquant ou format invalide (ex: io5 sans `text`) | +| `500` | Erreur interne du pipeline — détails dans le message | +| `503` | Module pas encore prêt (en cours de chargement initial) | + +## CORS + +L'API autorise tous les origines (`Access-Control-Allow-Origin: *`) pour faciliter le développement +avec Live Server. En production, restreindre à votre domaine via le middleware FastAPI. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000000000000000000000000000000000000..a0068c5bdcef9c3abb76277db36ab688d02e1336 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,105 @@ +# Architecture — Verify + +## Vue d'ensemble + +Verify est une **architecture en deux couches** strictement découplées : + +- **Frontend statique** (HTML/CSS/JavaScript vanilla, servi sur le port 5500) +- **Backend FastAPI** (Python, port 8000) qui héberge 6 modules ML indépendants + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ NAVIGATEUR (utilisateur) │ +│ index.html → verifier-module.html │ +└─────────────────────────────┬────────────────────────────────────────────────┘ + │ HTTP (multipart/form-data) + ▼ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ FRONTEND STATIQUE (Live Server :5500) │ +│ HTML · CSS · JavaScript (pas de framework, pas de build) │ +└─────────────────────────────┬────────────────────────────────────────────────┘ + │ fetch(`http://localhost:8000/api/ioN/analyze/*`) + ▼ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ BACKEND FastAPI (uvicorn :8000) │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────┐ │ +│ │ io1 │ │ io2 │ │ io3 │ │ io4 │ │ io5 │ │ io6 │ │ +│ │ ResNet50 │ │ TrOCR + │ │ CLIP + │ │ ELA + │ │ CLIP + │ │ KB + │ │ +│ │ + MTCNN │ │ DistilR. │ │ YOLO + │ │ Noise + │ │ EasyOCR │ │ Whisp│ │ +│ │ + Org/UM │ │ + CLIP │ │ Whisper │ │ JPEG-G │ │ │ │ +YOLO│ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────┘ │ +│ │ │ │ │ │ │ │ +│ └────────────┴────────────┼────────────┴────────────┴───────────┘ │ +│ ▼ │ +│ backend/shared/ (device.py · xai_narrative.py) │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────┐ + │ Hugging Face Hub (read-only │ ← cache local après 1er run + │ download des modèles) │ + └─────────────────────────────┘ +``` + +## Flux d'une requête type + +Exemple : l'utilisateur veut vérifier qu'une image n'est pas un deepfake. + +1. **Navigateur** : l'utilisateur ouvre `verifier-module.html?m=io1`, glisse-dépose une image, clique "Analyze". +2. **JavaScript** (`assets/js/synthesis.js`) : construit un `FormData` `{image, mode: "deepfake"}`, fait + `fetch("http://localhost:8000/api/io1/analyze/image", { method: POST, body: formData })`. +3. **FastAPI router** ([`backend/modules/io1_ai_generated/router.py`](../backend/modules/io1_ai_generated/router.py)) + reçoit la requête, sauve temporairement le fichier, appelle `pipeline.analyze_image(pil, mode)`. +4. **Pipeline** ([`backend/modules/io1_ai_generated/pipeline.py`](../backend/modules/io1_ai_generated/pipeline.py)) : + - MTCNN détecte le visage + - ResNet50 ensemble (best_ResNet50 + resnet50_deepfake) prédit `prob_fake` + - Si `mode="ai"` ou `mode="auto"`, on appelle aussi le **consensus AI** (Organika + umm-maybe) + - On agrège et on renvoie un dict avec verdict, scores, Grad-CAM heatmap (base64), explication +5. **Réponse JSON** : le frontend reçoit la réponse et affiche le verdict, la confiance, le heatmap. + +## Choix d'architecture + +### Pourquoi pas de framework frontend ? + +- Le frontend est essentiellement un **mince enrobage UI autour de l'API** : drag-drop, bouton, JSON → HTML. +- Pas de SPA, pas de state global complexe, pas de build pipeline → 0 dépendance npm. +- Permet à n'importe quel évaluateur de **servir le site avec `python -m http.server`**. + +### Pourquoi un backend unifié plutôt que 6 microservices ? + +- Les 6 modules **partagent des modèles lourds** (CLIP est utilisé par io2, io3, io5) — mutualiser la + RAM dans un seul process économise ~2 Go. +- Démarrage / déploiement plus simple : **un seul `uvicorn` à lancer**. +- L'isolation est assurée par les sous-paquets Python (`backend/modules/ioN_*`). + +### Pourquoi 100% local (pas d'API LLM dans le verdict) ? + +- **Exigence ESPRIT** : les projets étudiants doivent fonctionner sans dépendre d'un service tiers payant. +- **Reproductibilité** : un évaluateur peut cloner et tester sans avoir de clé API. +- La **clé OpenAI optionnelle** (`OPENAI_API_KEY`) n'est utilisée que par le module **chatbot**, pas + par les 6 verdicts. + +## Carte des modèles utilisés + +| Modèle | Taille | Source | Utilisé par | +|---|---|---|---| +| `io1_resnet50.pth` (best_ResNet50, Islem) | 94 Mo | hébergé hors-repo (téléchargé via `scripts/download_models.py`) | io1 | +| `io1_resnet50_deepfake.pth` (Model X, Islem) | 94 Mo | idem | io1 | +| `Organika/sdxl-detector` | 86 Mo | HuggingFace auto-download | io1 | +| `umm-maybe/AI-image-detector` | 86 Mo | HuggingFace auto-download | io1 | +| MTCNN (facenet-pytorch) | 6 Mo | pip package | io1 | +| `microsoft/trocr-base-printed` | 558 Mo | HuggingFace auto-download | io2 | +| `valurank/distilroberta-clickbait` | 330 Mo | HuggingFace auto-download | io2 | +| CLIP `ViT-B-32` (openai) | 150 Mo | open_clip auto-download | io2, io5 | +| `Helsinki-NLP/opus-mt-fr-en` | 300 Mo | HuggingFace auto-download | io2 | +| CLIP `ViT-L-14` (laion2b) | 890 Mo | open_clip auto-download | io3 | +| `yolov8m.pt` | 52 Mo | versionné dans le repo (Ultralytics) | io3 | +| `yolov8n.pt` | 7 Mo | versionné dans le repo (Ultralytics) | io6 | +| EasyOCR (en+fr) | 100 Mo | auto-download | io2, io3, io5, io6 | +| Whisper `small` | 470 Mo | auto-download | io3 | +| Whisper `tiny` | 75 Mo | auto-download | io6 | +| `paraphrase-multilingual-MiniLM-L12-v2` | 470 Mo | auto-download | io6 | + +**Total disque après premier run** : ~4 Go (cache HF dans `~/.cache/huggingface/`). diff --git a/docs/modules.md b/docs/modules.md new file mode 100644 index 0000000000000000000000000000000000000000..efcd630844e2a84206c2cce95067040ca45bc476 --- /dev/null +++ b/docs/modules.md @@ -0,0 +1,179 @@ +# Modules — Détails techniques + +Chaque module est un sous-paquet `backend/modules/ioN_*/` indépendant. Tous suivent le même contrat : + +- `pipeline.py` — fonctions de chargement (`init()`) et d'inférence (`analyze_*`) +- `router.py` — `APIRouter` FastAPI exposant les endpoints +- `__init__.py` — réexporte `router`, `init_models`, `MODULE_INFO` + +--- + +## io1 — Fake Media Detection (deepfake & AI-generated) + +**Responsable** : Islem · **Statut** : production + +### Pipeline +1. **MTCNN** (facenet-pytorch) — détection visage, crop 224×224 avec marge 20px (matche exactement + l'entraînement d'Islem) +2. **ResNet50 ensemble** — moyenne des `prob_fake` de : + - `io1_resnet50.pth` (best_ResNet50.pth d'Islem, linear head, idx 0=FAKE) + - `io1_resnet50_deepfake.pth` (Model X d'Islem, dropout+linear head, idx 0=REAL) + Le seuil de décision est **0.60** (au-dessus du naïf 0.50 pour compenser le biais OOD) +3. **AI consensus** (escalation REAL→FAKE) — pour rattraper les visages GAN/diffusion : + - **Tier 1** : Organika/sdxl-detector ≥0.95 ET umm-maybe/AI-image-detector ≥0.20 → escalade + - **Tier 2** : Organika ≥0.90 ET umm-maybe ≥0.40 → escalade + +### XAI +- **Grad-CAM** sur `layer4[-1]` du ResNet50 → heatmap visualisant les régions qui ont pesé +- **Geometric heatmap analysis** → texte explicatif sans jargon ("zones concentrées au centre du visage") +- Optionnel : **LLaVA 1.5 7B** (4-bit, GPU requis) pour un commentaire libre + +### Limites connues +- ~66% de recall sur TPDNE (StyleGAN3) — meilleur sur diffusion/DALL-E +- Modèles d'Islem entraînés sur FaceForensics → faux positifs possibles sur photos OOD + +--- + +## io2 — Visual Manipulation / Persuasion + +**Responsable** : Malek · **Statut** : production (modèles HF spécialisés en remplacement des poids +fine-tunés non livrés) + +### Pipeline +1. **TrOCR** (microsoft/trocr-base-printed) — extraction du texte visible +2. **DistilRoBERTa fine-tuned clickbait** (valurank/distilroberta-clickbait) — score manipulation NLP +3. **CLIP ViT-B/32 zero-shot** — score visuel "clickbait/sensational" vs "ordinary informational" + en comparant les similarités à 3 prompts positifs et 3 prompts négatifs +4. **EasyOCR + regex urgence** (FR/EN, 15 patterns) — détecte "LIMITED TIME", "ONLY 3 LEFT", + countdown timers, "X% OFF", "SHOCKING", excès de ponctuation, etc. +5. **Fusion heuristique** : `0.45×nlp + 0.30×clickbait + 0.25×urgence` +6. **Optionnel** : MarianMT FR→EN pour faire passer le texte français au classifieur anglais + +### XAI +- **Gradient saliency** sur CLIP (backprop de la différence positif-négatif) +- **Bounding boxes** sur les keywords d'urgence détectés par OCR + +### Verdicts (4 bandes) +| Score | Label | Couleur | +|---|---|---| +| 0.0 - 0.4 | AUTHENTIC | vert | +| 0.4 - 0.6 | SUSPECT | jaune | +| 0.6 - 0.8 | MANIPULATIVE | orange | +| ≥ 0.8 | HIGHLY MANIPULATIVE | rouge | + +--- + +## io3 — Image-Caption Coherence + +**Responsable** : Youssef · **Statut** : production + +### Pipeline (5 signaux fusionnés) +1. **CLIP ViT-L/14** (laion2b) — similarité directe image↔texte +2. **YOLOv8m** — détection d'objets, vérifie que les objets nommés dans le texte sont présents +3. **EasyOCR** — extraction du texte écran, cross-check avec la légende +4. **SAM ViT-B** (optional) — segmentation, score d'incohérence régionale ; fallback grille 3×3 sinon +5. **Whisper small** — transcription audio (vidéos uniquement) + +### Fusion +- **Image** : `0.40×CLIP + 0.25×SAM + 0.15×Whisper + 0.10×YOLO + 0.10×OCR` +- **Vidéo** : pondération différente avec ajout de scores temporels et audio↔image + +### XAI +- **Grad-CAM CLIP** sur `transformer.resblocks[-1].ln_1` +- **Waterfall chart** matplotlib des contributions des 5 modules +- **SHAP-style attribution** dictionary + +--- + +## io4 — Image Tampering (Photoshop forensics) + +**Responsable** : Rayen · **Statut** : production (forensique pure — pas de poids requis) + +### Pipeline (3 signaux indépendants) +1. **ELA (Error-Level Analysis)** — re-sauvegarde JPEG quality=90, diff amplifié → détecte les + régions ré-encodées différemment (splicing) +2. **Noise residual** — soustraction d'un filtre médian 3×3, std par bloc 32×32 → détecte les zones + avec un bruit de capteur étranger (inpainting/paste) +3. **JPEG ghost** — re-save à 6 qualités (60-90), détecte les régions avec un minimum d'erreur à une + qualité différente du reste (compression mismatch) + +### Décision +- Fusion pondérée : `0.45×ELA + 0.30×noise + 0.25×ghost` +- Bonus +0.10 si ≥2 signaux accord (≥0.40 chacun) +- Verdict : + - `< 0.35` → AUTHENTIC + - `0.35-0.55` → AUTHENTIC sauf si ≥2 signaux accord → FAKE + - `≥ 0.55` → FAKE + +### Classes +- `Inpainting` — région remplie algorithmiquement +- `Copy-move` — partie dupliquée et collée +- `Splicing` — contenu d'une autre image inséré +- `Enhancement` — ajustements globaux (luminosité, contraste) cachant une édition + +### Endpoint compare +`POST /api/io4/analyze/compare` — diff pixel-à-pixel entre suspect et original. Verdict très fiable +quand l'original est disponible. + +--- + +## io5 — Caption Fidelity + +**Responsable** : Verify team · **Statut** : production (nouveau) + +### Pipeline (4 signaux) +1. **CLIP ViT-B/32** — similarité globale image↔caption (calibrée via sigmoid centré sur 0.20) +2. **CLIP per-phrase** — découpe la caption en noun phrases, score chaque fragment séparément → + identifie quelles parties ne sont pas supportées par l'image +3. **EasyOCR cross-check** — mots de la caption qui apparaissent dans le texte écran de l'image +4. **Tone gap** — caption alarmiste sur image neutre = signal de fidélité dégradée + +### Décision +- `score = calibrated_sim` +- Bonus +0.15×ocr_overlap si overlap ≥30% +- Malus -0.20×tone_gap si tone_gap ≥30% +- Verdict : + - `≥ 0.60` → FAITHFUL + - `0.40-0.60` → PARTIAL + - `< 0.40` → MISLEADING + +### Output +Le champ `unsupported_phrases` liste les fragments de la caption qui n'ont pas trouvé d'écho dans +l'image — particulièrement utile pour expliquer pourquoi une caption est jugée MISLEADING. + +--- + +## io6 — Cosmetic Ads Fact-Check + +**Responsable** : Yassmine · **Statut** : production + +### Pipeline (4 phases) +1. **ffmpeg** — extraction audio (16 kHz mono WAV) et frames (1 par 1.4s, max 12) +2. **Whisper tiny** — transcription audio +3. **YOLOv8n** — détection objets + **EasyOCR** sur les frames pour texte écran +4. **Extraction de claims** : + - Fallback : sentence-split + filtrage par longueur (mode rapide, défaut) + - Optionnel : Phi-3 mini 4k (extraction LLM, mode `IO6_ENABLE_PHI3=on`) + +### Vérification (5 sources) +- **KB Excel patterns** : 25 regex pour patterns globaux +- **KB Excel fake_claims** : 33 phrases mensongères connues + regex +- **MiniLM semantic** : similarité sémantique avec la base de fake claims pré-encodées +- **KB ingredients/brands** : whitelist de 145 ingrédients INCI et 93 marques cosmétiques validées +- **Decisive Post-Processor** : 15 patterns FAUX (interdits EU 655/2013, ex: "in 7 days", "100% effective", + "miracle", "reduce wrinkles by X%") + 8 patterns VRAI (vocabulaire acceptable, marques connues) + +### Trust score +Moyenne pondérée par confiance des verdicts TRUE/FALSE : +- ≥ 50 → RELIABLE +- < 50 → MISLEADING + +### Articles EU cités +Chaque claim FALSE peut citer un article EU spécifique (ex: `EU 655/2013 art. 4.2` pour les claims +quantifiées non-substantiées, `EU 1223/2009` pour les claims médicales interdites). Le champ +`eu_articles_cited` agrège tous les articles violés. + +### KB Excel +La base de connaissances est dans `backend/data/IO6_Base_Reference_V3_FULL.xlsx`, 8 feuilles : +`Global_Patterns`, `Global_Fake_Claims`, `Products`, `Synonyms`, `Category_Rules`, +`Extended_Ingredients`, `Extended_Brands`, `Extended_Certifications`. diff --git a/equipe.html b/equipe.html new file mode 100644 index 0000000000000000000000000000000000000000..8c6767fea576b11f627300f6520f74cf77926d32 --- /dev/null +++ b/equipe.html @@ -0,0 +1,120 @@ + + + + + + The team — Verify + + + + + + + + + +
+
+ The team +

Six people, five modules

+

+ Each Verify module is led by a dedicated specialist — combining + computer vision research, investigative journalism, and communication + sciences. +

+
+ +
+
+
Y
+

Youssef Kamoun

+
Lead — AI-Generated Media Detection
+

+ PhD candidate in computer vision, focused on the tell-tale signs + left behind by AI image and video generators. Leads our work on + spotting synthetic media from the major creative tools. +

+
+ +
+
M
+

Malek Ouni

+
Lead — Manipulation & Persuasion
+

+ Image semiotics specialist, former editor on political + communication. Designs the checks that surface biased framing, + color grading and emotional composition. +

+
+ +
+
I
+

Islem Trabelsi

+
Lead — Caption Fidelity & Coherence
+

+ Engineer specialized in reading images and captions together. + Leads the analysis that flags decontextualized images, captions + that don't match the picture, and captions that exaggerate or + leave things out. +

+
+ +
+
R
+

Rayen Mhadhbi

+
Lead — Edited Photo Detection
+

+ Forensic analyst trained in judicial expertise. Spots local edits + on otherwise authentic images — added objects, cloned areas, + spliced-in elements and erased details. +

+
+ +
+
M
+

Maryem Ben Slimane

+
Editor-in-Chief
+

+ Investigative journalist, pan-Arab fact-checking specialist. + Coordinates the entire newsroom across all five analysis modules. +

+
+ +
+
Y
+

Yasmine Khelifi

+
Lead — Cosmetic Ads Fact-Check
+

+ Designer-developer, former lead UX of a regional advertising + platform. Owns the cosmetic-ads module — retouched before/after, + deepfaked testimonials, misleading visual promises. +

+
+
+ + +
+
+

Our mission

+

+ Provide the public, journalists and Tunisian institutions with an + independent visual verification toolkit, transparent on its methods + and free from any commercial interest in steering its verdicts. +

+
+
+ +
+

Want to join us?

+

We're hiring an engineer and a fact-checking journalist.

+ View open positions +
+
+ + + + + + + + diff --git a/fact-check.html b/fact-check.html new file mode 100644 index 0000000000000000000000000000000000000000..00b644be50fc1379a9dd195a64f83505da81aecf --- /dev/null +++ b/fact-check.html @@ -0,0 +1,157 @@ + + + + + + Fact-check — Verify + + + + + + + + + +
+ +
Loading…
+
+ + + + + + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000000000000000000000000000000000000..661ed13df5bf6b3a6eafc6085f58f15c25170628 --- /dev/null +++ b/index.html @@ -0,0 +1,307 @@ + + + + + + Verify — Tunisia's media-verification platform + + + + + + + + + + + +
+
+
+
+ + + Tunisia's media-verification platform + +

Tell what's real.

+

+ Drop in an image, a video or a post and get a clear verdict in minutes — + with the visual evidence behind it. Built with newsrooms and forensic + researchers, powered by five specialised AI modules. +

+ +
+
412fact-checks this week
+
5ways we check a story
+
‹ 2 minaverage to a verdict
+
+
+
+
+ + +
+ Just verified +
+
+
+
+ + +
+
+
0
+
Fact-checks this week
+
+
+
0
+
Content flagged as misleading
+
+
+
0
+
Ways we check a story
+
+
+
‹ 2 min
+
Average time to a verdict
+
+
+ + +
+
+
+ The Verify Toolkit +

Five ways to check what you're looking at

+
+ Explore all modules → +
+
+
+
+
+
+ + + + +
+
+ Circulating right now +

What Tunisia is sharing — and what's actually true

+

A live wall of images and videos pulled from social feeds this week. Green = verified, red = fake, amber = out of context. Hover any row to pause it.

+
+ + +
+ + +
+
+
+ Latest verdicts +

Recent fact-checks

+

+ Real claims investigated by the Verify newsroom — classified + Real, + Fake or + Not Verified. + Click any card to read the full investigation. +

+
+ Browse all fact-checks → +
+ +
+ +
+
+ + +
+

Cited & used by newsrooms across Tunisia

+
+
+ + + + + + + + +
+
+
+ + +
+
+

Got something that doesn't look right?

+

Don't share it yet. Run it through Verify first — image, video or a whole post — and see the evidence for yourself in under two minutes.

+ +
+ +
+ + +
+ + + + + + + + + + diff --git a/inscription.html b/inscription.html new file mode 100644 index 0000000000000000000000000000000000000000..46600e35aba68c6b4e08584bc970add2f6665ae0 --- /dev/null +++ b/inscription.html @@ -0,0 +1,94 @@ + + + + + + Sign up — Verify + + + + + + + + + +
+ + +
+
+

Create an account

+

Free, no credit card required. You can delete your account at any time.

+ +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + +
+ +

+ Already registered? Sign in +

+
+
+
+ + + + + + + + diff --git a/ios images/ChatGPT Image 11 mai 2026, 23_48_43.png b/ios images/ChatGPT Image 11 mai 2026, 23_48_43.png new file mode 100644 index 0000000000000000000000000000000000000000..daf7aed18369e1e5f572473b0cef9030e8b2b62d --- /dev/null +++ b/ios images/ChatGPT Image 11 mai 2026, 23_48_43.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8554d8f52037067372ac7120a5018b095c3cf151260beab2b3bada93e490beec +size 1560595 diff --git a/ios images/ChatGPT Image 12 mai 2026, 00_00_05.png b/ios images/ChatGPT Image 12 mai 2026, 00_00_05.png new file mode 100644 index 0000000000000000000000000000000000000000..edafc1809e3551e6e6438c0f993d36404f2bc5b6 --- /dev/null +++ b/ios images/ChatGPT Image 12 mai 2026, 00_00_05.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:684d496e1bc8e1f5cd6d6a2ce119937d0b2fa7c9cd85d6fcfaeb2e1bfcebfff6 +size 1705065 diff --git a/ios images/cover1.png b/ios images/cover1.png new file mode 100644 index 0000000000000000000000000000000000000000..1b256363255ed2d515b0bb79d23de0984fd9eb37 --- /dev/null +++ b/ios images/cover1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b0f3e85af5c2ee201d992edaea35945de36727fdc28f7e9e9f58329b58afdda6 +size 1679795 diff --git a/ios images/cover2.png b/ios images/cover2.png new file mode 100644 index 0000000000000000000000000000000000000000..f4c3d31f74408f0554da61ff408a0de32455f275 --- /dev/null +++ b/ios images/cover2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8668f20b16b63ec3c59b9b3f3d533adc9b9b9fc5ff6433428a626a087adf7bc5 +size 1763738 diff --git a/ios images/cover4.png b/ios images/cover4.png new file mode 100644 index 0000000000000000000000000000000000000000..460d90c0a4631cd06813d674f163d4ae8a6ef067 --- /dev/null +++ b/ios images/cover4.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8373da2179010f104539eeb3fa150a53d6059286f5e979765fa4a1053751c3f7 +size 1918614 diff --git a/ios images/cover5.png b/ios images/cover5.png new file mode 100644 index 0000000000000000000000000000000000000000..f948be22b48c20080657f8ab14c0a035326888b6 --- /dev/null +++ b/ios images/cover5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8688a0175f44a3103b89abaecdb15f4f254966e17c79441fe0e7d1ebb5276c2e +size 1694797 diff --git a/ios images/cover6.png b/ios images/cover6.png new file mode 100644 index 0000000000000000000000000000000000000000..9e1522bd832020ce6e5264feb0c85f3cf37e5ac7 --- /dev/null +++ b/ios images/cover6.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fad95d99c2c3401999c2c19be45185567a3b269507a19eb41dce6a589a9866bf +size 1560658 diff --git a/ios images/oi1.png b/ios images/oi1.png new file mode 100644 index 0000000000000000000000000000000000000000..4065ba7fe681cd37a208d61c030b00c8cd88712e --- /dev/null +++ b/ios images/oi1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:923bac6c041bec8892316353ef1e867e8b0d0d959ba5219b80db6af4ea45b2e8 +size 1489291 diff --git a/ios images/oi2.png b/ios images/oi2.png new file mode 100644 index 0000000000000000000000000000000000000000..defa43140a6f229aa2744bd27e316bab04dc9076 --- /dev/null +++ b/ios images/oi2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f2825a3f5253cfdd5face0859a6adf270f465909fb5eede4af29ef42f637c931 +size 1623049 diff --git a/ios images/oi4.png b/ios images/oi4.png new file mode 100644 index 0000000000000000000000000000000000000000..85c7091414dad5fabda2ed9747b45a369408ec88 --- /dev/null +++ b/ios images/oi4.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a44d76c5f33c7cef5498003a23c0d2155a9c5278aa133710a7ccfc050d659585 +size 1608053 diff --git a/ios images/oi5.png b/ios images/oi5.png new file mode 100644 index 0000000000000000000000000000000000000000..e30eaea5c1d90e35b6a91ca682bc1f8d23ec0b8f --- /dev/null +++ b/ios images/oi5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:843851afe9acc086f96d464b6cf862b1ec424cbcf254048af55c11bc2c82477c +size 1606371 diff --git a/ios images/oi6.png b/ios images/oi6.png new file mode 100644 index 0000000000000000000000000000000000000000..e0c629368d7508e2acc368aa35d898c3e5f42686 --- /dev/null +++ b/ios images/oi6.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e418674c37c8b9093dcbe63ca295d3a5f83d155a351f060c39e9a47e920660da +size 1589306 diff --git a/islem/Guide_Deepfake_Detection_Integration.pdf b/islem/Guide_Deepfake_Detection_Integration.pdf new file mode 100644 index 0000000000000000000000000000000000000000..13ccb11b417e80d3f324f6ed7a36181c19716342 --- /dev/null +++ b/islem/Guide_Deepfake_Detection_Integration.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9b0d5e8cbed9ae290fee5fc7b341e3317ec66cc98096f6f5b9c0f6c31170c13 +size 20747 diff --git a/islem/Input+Output+XAI_details.pdf b/islem/Input+Output+XAI_details.pdf new file mode 100644 index 0000000000000000000000000000000000000000..53086176cb1aa5fc6094084c66e2166b07c6c274 --- /dev/null +++ b/islem/Input+Output+XAI_details.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43207e736655ec4c97b4c534ca79ec06b65834db929b8b7c27d224820a45be7e +size 28611 diff --git a/islem/fake_images_detection/global_pipeline.pdf b/islem/fake_images_detection/global_pipeline.pdf new file mode 100644 index 0000000000000000000000000000000000000000..17f0effd672248972fe242f702aa82c3fd65e002 --- /dev/null +++ b/islem/fake_images_detection/global_pipeline.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dea41fe4bd8464c1eddd4a09407a3f19ae715aaa591e1ed1db19f880723e616b +size 46369 diff --git a/islem/fake_images_detection/image_integration_guide.pdf b/islem/fake_images_detection/image_integration_guide.pdf new file mode 100644 index 0000000000000000000000000000000000000000..858410f1e28bc9557d87adb29601c9c8edf8de48 --- /dev/null +++ b/islem/fake_images_detection/image_integration_guide.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd8becf2a147deda63032d408cd00d10e59c43fa463fc3c514449f621d9369b8 +size 73070 diff --git a/islem/fake_images_detection/inference.py b/islem/fake_images_detection/inference.py new file mode 100644 index 0000000000000000000000000000000000000000..db7081874e52e2de29d2a555bee1ed9710c38f43 --- /dev/null +++ b/islem/fake_images_detection/inference.py @@ -0,0 +1,222 @@ +""" +Fake media image detection — unified inference module. + +Two specialized ResNet50 models with automatic routing : + 1. Image AI vs Real → resnet50_ai_vs_real.pth + 2. Image Deepfake vs Real → resnet50_deepfake.pth + +The detector uses MTCNN to detect a face in the input image. +If a face is found → routes to the deepfake model. +If no face → routes to the AI vs Real model. + +Usage: + from inference import FakeMediaImageDetector + from PIL import Image + + detector = FakeMediaImageDetector() + img = Image.open("photo.jpg") + result = detector.detect(img) + + # result example : + # {'task': 'image_deepfake', + # 'verdict': 'DEEPFAKE', + # 'risk_score': 87, + # 'confidence': 92.3, + # 'probs': {'real': 0.13, 'fake': 0.87}, + # 'heatmap': numpy.ndarray (224, 224) values 0–1} +""" +import os +import cv2 +import numpy as np +import torch +import torch.nn as nn +from PIL import Image, ImageFile +from torchvision import models, transforms +from facenet_pytorch import MTCNN + +# Allow large + truncated images +Image.MAX_IMAGE_PIXELS = None +ImageFile.LOAD_TRUNCATED_IMAGES = True + + +# ============================================================ +# Constants +# ============================================================ +NORM_MEAN = (0.485, 0.456, 0.406) +NORM_STD = (0.229, 0.224, 0.225) +IMG_SIZE = 224 + + +# ============================================================ +# ResNet50 builder (matches the training architecture) +# ============================================================ +def build_resnet50(num_classes=2, dropout=0.3): + """Same architecture as the training notebook: ResNet50 + Dropout + Linear.""" + m = models.resnet50(weights=None) + m.fc = nn.Sequential( + nn.Dropout(dropout), + nn.Linear(m.fc.in_features, num_classes) + ) + return m + + +# ============================================================ +# Grad-CAM (for XAI heatmaps) +# ============================================================ +class GradCAM: + """Generic Grad-CAM for any CNN architecture.""" + + def __init__(self, model, target_layer): + self.model = model + self.gradients = None + self.activations = None + self._fwd = target_layer.register_forward_hook( + lambda m, i, o: setattr(self, 'activations', o.detach())) + self._bwd = target_layer.register_full_backward_hook( + lambda m, gi, go: setattr(self, 'gradients', go[0].detach())) + + def generate(self, x, class_idx=None): + self.model.eval() + self.model.zero_grad() + out = self.model(x) + if class_idx is None: + class_idx = out.argmax(1).item() + out[0, class_idx].backward(retain_graph=True) + w = self.gradients[0].mean(dim=(1, 2), keepdim=True) + h = (w * self.activations[0]).sum(0).cpu().numpy() + h = np.maximum(h, 0) + h /= h.max() + 1e-8 + return cv2.resize(h.squeeze(), (IMG_SIZE, IMG_SIZE)) + + def remove(self): + self._fwd.remove() + self._bwd.remove() + + +# ============================================================ +# Main detector class +# ============================================================ +class FakeMediaImageDetector: + """ + Unified detector for image-based fake media detection. + + Args: + ai_weights: path to the AI vs Real model weights (.pth) + dfk_weights: path to the Deepfake model weights (.pth) + device: torch.device, or None for auto-detect + """ + + VERDICT_MAP = { + "image_ai_vs_real": ("REAL", "FAKE/AI"), + "image_deepfake": ("REAL", "DEEPFAKE"), + } + + def __init__(self, + ai_weights="models/resnet50_ai_vs_real.pth", + dfk_weights="models/resnet50_deepfake.pth", + device=None): + self.device = device or torch.device( + "cuda" if torch.cuda.is_available() else "cpu" + ) + + # Build + load both ResNet50 models + self.model_ai = self._load(ai_weights) + self.model_dfk = self._load(dfk_weights) + + # Shared MTCNN face detector + self.mtcnn = MTCNN(image_size=IMG_SIZE, margin=20, keep_all=False, + post_process=False, device=self.device) + + # Standard preprocessing + self.transform = transforms.Compose([ + transforms.Resize((IMG_SIZE, IMG_SIZE)), + transforms.ToTensor(), + transforms.Normalize(NORM_MEAN, NORM_STD), + ]) + + def _load(self, weights_path): + m = build_resnet50().to(self.device) + m.load_state_dict(torch.load(weights_path, map_location=self.device)) + m.eval() + return m + + def _crop_face(self, pil_image, margin_ratio=0.15): + """Detect the largest face. Returns PIL crop or None.""" + boxes, probs = self.mtcnn.detect(pil_image) + if boxes is None or len(boxes) == 0: + return None + box = boxes[np.argmax(probs)] + x1, y1, x2, y2 = box + w, h = x2 - x1, y2 - y1 + x1 = max(0, x1 - w * margin_ratio) + y1 = max(0, y1 - h * margin_ratio) + x2 = min(pil_image.width, x2 + w * margin_ratio) + y2 = min(pil_image.height, y2 + h * margin_ratio) + return pil_image.crop((x1, y1, x2, y2)).resize((IMG_SIZE, IMG_SIZE)) + + def _predict(self, model, pil_image): + """Returns (pred_class, prob_real, prob_fake).""" + x = self.transform(pil_image.convert('RGB')).unsqueeze(0).to(self.device) + with torch.no_grad(): + probs = torch.softmax(model(x), dim=1)[0] + return probs.argmax().item(), probs[0].item(), probs[1].item() + + def _make_result(self, task, pred, p_real, p_fake, model, pil_input): + """Build the standardized output dict including Grad-CAM heatmap.""" + x = self.transform(pil_input.convert('RGB')).unsqueeze(0).to(self.device) + # Both models share the same ResNet50 architecture → same target layer + cam = GradCAM(model, model.layer4[-1]) + heatmap = cam.generate(x, class_idx=pred) + cam.remove() + + return { + "task": task, + "verdict": self.VERDICT_MAP[task][pred], + "risk_score": int(p_fake * 100), + "confidence": round(max(p_real, p_fake) * 100, 1), + "probs": {"real": p_real, "fake": p_fake}, + "heatmap": heatmap, + } + + def detect(self, image): + """ + Main entry point. Auto-routes to the right model. + + Args: + image: file path (str) or PIL.Image + + Returns: + dict with keys: task, verdict, risk_score, confidence, probs, heatmap + """ + # Accept either a path or a PIL Image + if isinstance(image, str): + pil = Image.open(image).convert('RGB') + elif isinstance(image, Image.Image): + pil = image.convert('RGB') + else: + raise TypeError("`image` must be a file path or a PIL.Image") + + # Try face detection + face = self._crop_face(pil) + + if face is not None: + # Face found → deepfake model + pred, p_r, p_f = self._predict(self.model_dfk, face) + return self._make_result("image_deepfake", pred, p_r, p_f, + self.model_dfk, face) + else: + # No face → AI vs Real model + pred, p_r, p_f = self._predict(self.model_ai, pil) + return self._make_result("image_ai_vs_real", pred, p_r, p_f, + self.model_ai, pil) + + +# ============================================================ +# Quick test (CLI) +# ============================================================ +if __name__ == "__main__": + detector = FakeMediaImageDetector() + result = detector.detect("test_image.jpg") + # Print without the heatmap (too large for stdout) + print({k: v for k, v in result.items() if k != "heatmap"}) + print(f"Heatmap shape: {result['heatmap'].shape}") diff --git a/islem/fake_images_detection/requirements.txt b/islem/fake_images_detection/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..99cfe94b424dfbb90b2a217c3cab9b067608ba0d --- /dev/null +++ b/islem/fake_images_detection/requirements.txt @@ -0,0 +1,7 @@ +torch>=2.0 +torchvision>=0.15 +timm>=0.9 +facenet-pytorch>=2.5 +numpy>=1.24 +opencv-python>=4.8 +Pillow>=10.0 \ No newline at end of file diff --git a/islem/inference.py b/islem/inference.py new file mode 100644 index 0000000000000000000000000000000000000000..13d2015b087540321219471567352560c2de94a7 --- /dev/null +++ b/islem/inference.py @@ -0,0 +1,107 @@ +import torch +import torch.nn as nn +from torchvision import models, transforms +from facenet_pytorch import MTCNN +from pytorch_grad_cam import GradCAM +from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget +from pytorch_grad_cam.utils.image import show_cam_on_image +import cv2 +import numpy as np +from PIL import Image +from transformers import AutoProcessor, LlavaForConditionalGeneration + +class DeepfakeDetector: + def __init__(self, model_path, device=None): + self.device = device if device else torch.device('cuda' if torch.cuda.is_available() else 'cpu') + + # --- 1. INITIALISATION RESNET50 --- + self.model = models.resnet50(weights=None) + num_ftrs = self.model.fc.in_features + self.model.fc = nn.Linear(num_ftrs, 2) + self.model.load_state_dict(torch.load(model_path, map_location=self.device)) + self.model.to(self.device) + self.model.eval() + + self.mtcnn = MTCNN(keep_all=False, device=self.device) + + self.transform = transforms.Compose([ + transforms.Resize((224, 224)), + transforms.ToTensor(), + transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) + ]) + + self.target_layer = self.model.layer4[-1] + self.cam = GradCAM(model=self.model, target_layers=[self.target_layer]) + + # --- 2. INITIALISATION LLAVA (Texte) --- + print("Chargement du modèle LLaVA (cela peut prendre du temps la première fois)...") + vlm_model_id = "llava-hf/llava-1.5-7b-hf" + self.vlm_processor = AutoProcessor.from_pretrained(vlm_model_id) + # On charge en 4-bit pour économiser la RAM du serveur + self.vlm_model = LlavaForConditionalGeneration.from_pretrained( + vlm_model_id, + device_map="auto", + load_in_4bit=True + ) + + def predict_and_explain(self, video_path): + """Prédit Fake/Real, génère Grad-CAM et rédige l'explication textuelle.""" + # Lecture vidéo + cap = cv2.VideoCapture(video_path) + ret, frame = cap.read() + cap.release() + + if not ret: + return {"error": "Erreur lecture vidéo"}, None + + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + + # Détection visage + face = self.mtcnn(frame_rgb) + if face is None: + return {"error": "Aucun visage détecté"}, None + + # --- PHASE 1 : VISION (ResNet50) --- + face_pil = transforms.ToPILImage()(face / 255.0) + input_tensor = self.transform(face_pil).unsqueeze(0).to(self.device) + + with torch.no_grad(): + output = self.model(input_tensor) + prob = torch.softmax(output, dim=1) + conf, pred = torch.max(prob, 1) + label = "FAKE" if pred.item() == 0 else "REAL" + + # Grad-CAM + targets = [ClassifierOutputTarget(0)] + grayscale_cam = self.cam(input_tensor=input_tensor, targets=targets)[0, :] + img_float = np.array(face_pil.resize((224, 224))) / 255.0 + cam_image = show_cam_on_image(img_float, grayscale_cam, use_rgb=True) + + # --- PHASE 2 : TEXTE (LLaVA) --- + heatmap_pil = Image.fromarray(cam_image).convert("RGB") + prompt = """USER: +You are an expert AI forensic analyst looking at a Grad-CAM heatmap overlaid on a face. +Task 1: Describe the exact geographical location of the bright red and yellow "hot zones" on the image without guessing. +Task 2: If the hot zones are tightly concentrated in the center of the face, conclude it is a 'FAKE' face-swap mask. If the hot zones are off-center, on the edges, or scattered, conclude it is a 'REAL' unmanipulated image. +Be concise and factual. ASSISTANT:""" + + inputs = self.vlm_processor(text=prompt, images=heatmap_pil, return_tensors="pt").to(self.device) + + with torch.no_grad(): + generated_ids = self.vlm_model.generate( + **inputs, + max_new_tokens=100, + do_sample=True, + temperature=0.4, + top_p=0.9 + ) + + generated_text = self.vlm_processor.batch_decode(generated_ids, skip_special_tokens=True)[0] + text_explanation = generated_text.split("ASSISTANT:")[-1].strip() + + # Retourne toutes les infos structurées + return { + "label": label, + "confidence": conf.item(), + "explanation": text_explanation + }, cam_image \ No newline at end of file diff --git a/islem/requirements.txt b/islem/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..88407cfdbbe2b9a390a22a29803ed1572548d6ed --- /dev/null +++ b/islem/requirements.txt @@ -0,0 +1,12 @@ +torch==2.0.1 +torchvision==0.15.2 +opencv-python +facenet-pytorch +pytorch-grad-cam +Pillow +numpy +matplotlib +tqdm +transformers +accelerate +bitsandbytes \ No newline at end of file diff --git a/logo verify.jpeg b/logo verify.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..4fbfd25f45f5b6e3e0bf07ba75ba421714957890 --- /dev/null +++ b/logo verify.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a5be34dcaae48a19780ae0be0a112c94c1af740da61299d37b995379374c8b30 +size 53983 diff --git a/malek/module malek _description.txt b/malek/module malek _description.txt new file mode 100644 index 0000000000000000000000000000000000000000..0f553dea51dd78c21ba7ab1c2bd3c79c9319ee8a --- /dev/null +++ b/malek/module malek _description.txt @@ -0,0 +1,171 @@ +================================================================ +MODULE : DÉTECTION DE MANIPULATION DES MÉDIAS +Projet CBL — Media Credibility | ESPRIT School of Engineering +Malek Tirellil — 3rd Year AI +================================================================ + +INPUT +----- +→ Une image (JPG / PNG / WEBP) ou une vidéo (MP4 / AVI) + contenant un contenu publicitaire ou médiatique à analyser. + + +PIPELINE — 5 MODULES EN SÉQUENCE +---------------------------------- + +IMAGE / VIDÉO + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ MODULE 1 — SO1-A : Extraction de texte (OCR) │ +│ Modèle : TrOCR (Microsoft) — ViT Encoder + GPT-2 │ +│ Input : Image brute │ +│ Output : Texte extrait (string) │ +│ Exemple : "ABONNEZ-VOUS MAINTENANT -70% EXPIRE !!!" │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ MODULE 2 — SO1-B : Classification du texte (NLP) │ +│ Modèle : RoBERTa (Meta) — fine-tuné sur 32K titres │ +│ Input : Texte extrait (traduit EN si nécessaire) │ +│ Output : Score manipulation texte [0.0 → 1.0] │ +│ Exemple : 0.997 → MANIPULATEUR │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ MODULE 3 — SO2 : Détection clickbait visuel │ +│ Modèle : ViT-B/16 (Google) — fine-tuné │ +│ Input : Image brute (224×224) │ +│ Output : Score clickbait visuel [0.0 → 1.0] │ +│ Exemple : 0.84 → FAKE / CLICKBAIT │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ MODULE 4 — SO4 : Détection éléments d'urgence │ +│ Modèle : DETR (Meta) — fine-tuné │ +│ Input : Image brute │ +│ Output : Bounding boxes des éléments détectés │ +│ + score urgence [0.0 → 1.0] │ +│ Exemple : 3 éléments → button (0.91), toggle (0.78) │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ MODULE 5 — ManipNet : Fusion multimodale (from scratch) │ +│ Modèle : ManipNet custom — CNN + Transformer + MLP │ +│ + Cross-Modal Self-Attention │ +│ Input : Image + Texte + Scores des modules 1-4 │ +│ Output : Score global manipulation [0.0 → 1.0] │ +│ Exemple : 0.87 → TRÈS MANIPULATEUR │ +└─────────────────────────────────────────────────────────┘ + + +OUTPUT FINAL — CE QUE LA PLATEFORME DOIT AFFICHER +--------------------------------------------------- + +1. SCORE GLOBAL + → Valeur numérique [0.0 → 1.0] + → Label coloré : + 0.0 – 0.4 → AUTHENTIQUE (vert) + 0.4 – 0.6 → SUSPECT (jaune) + 0.6 – 0.8 → MANIPULATEUR (orange) + 0.8 – 1.0 → TRÈS MANIPULATEUR (rouge) + +2. SCORES PAR MODULE + → Score NLP [0.0 → 1.0] + → Score Clickbait [0.0 → 1.0] + → Score Urgence [0.0 → 1.0] + → Score ManipNet [0.0 → 1.0] + +3. TEXTE EXTRAIT + → Texte brut extrait de l'image par TrOCR + → Texte traduit en anglais (si français détecté) + → Classification du texte : Manipulateur ou Neutre + +4. BOUNDING BOXES + → Coordonnées [x1, y1, x2, y2] en pixels + → À afficher sur l'image originale + → Classe de chaque élément détecté + score confiance + → Exemple : button, toggle, timer, badge, popup + +5. VISUALISATIONS XAI (Explainable AI) + → 3 images heatmap à afficher : + + a) Gradient Saliency + Colormap : Viridis (jaune → violet) + Ce que ça montre : quels pixels ont le plus influencé + la décision du modèle + Jaune = très influent | Violet = ignoré + + b) Occlusion Sensitivity + Colormap : Inferno (jaune → noir) + Ce que ça montre : quelles zones de l'image sont critiques + Masquer une zone jaune change drastiquement le score + Jaune = critique | Noir = sans impact + + c) Attention Rollout + Colormap : JET (rouge → bleu) + Ce que ça montre : quelles zones le modèle ViT regarde + pour prendre sa décision + Rouge = très regardé | Bleu = ignoré + + → Chaque heatmap est une superposition sur l'image originale + → Accompagnée d'une description textuelle courte + + +FORMAT DE DONNÉES ÉCHANGÉES (JSON) +------------------------------------ + +{ + "score_global" : 0.87, + "label" : "TRÈS MANIPULATEUR", + "label_color" : "#E53E3E", + + "scores_modules" : { + "nlp" : 0.997, + "clickbait" : 0.840, + "urgence" : 0.600, + "manipnet" : 0.870 + }, + + "texte" : { + "extrait" : "ABONNEZ-VOUS MAINTENANT -70%", + "traduit_en" : "SUBSCRIBE NOW -70% LIMITED OFFER", + "score_nlp" : 0.997, + "label_nlp" : "Manipulateur" + }, + + "bounding_boxes" : [ + { "class": "button", "score": 0.91, "box": [120, 45, 280, 90] }, + { "class": "toggle", "score": 0.78, "box": [300, 200, 380, 240] } + ], + + "xai" : { + "gradient_saliency" : "", + "occlusion_map" : "", + "attention_rollout" : "" + } +} + + +FORMATS ACCEPTÉS EN INPUT +--------------------------- +Images : JPG · PNG · WEBP · BMP +Vidéos : MP4 · AVI (→ extraction automatique de la première frame) + + +POIDS DES MODÈLES (fichiers .pt à récupérer sur Kaggle) +--------------------------------------------------------- +so1b_roberta_weights.pt → Module 2 NLP +so2_vit_best.pt → Module 3 Clickbait +so4_detr_best.pt → Module 4 Urgence +manipnet_best.pt → Module 5 ManipNet +manip_vocab.json → Vocabulaire tokenizer ManipNet + +TrOCR (Module 1) → téléchargement automatique HuggingFace + "microsoft/trocr-base-printed" + +================================================================ diff --git a/methodologie.html b/methodologie.html new file mode 100644 index 0000000000000000000000000000000000000000..4ea5a4d5947d412507c0520130d473a57f5bb24f --- /dev/null +++ b/methodologie.html @@ -0,0 +1,202 @@ + + + + + + How Verify Works — Our Methodology + + + + + + + + + + +
+
+
+ Home › + Methodology +
+

How Verify works

+

+ How we decide whether an image or video is authentic, suspect or manipulated — + and why every verdict comes with the visual evidence behind it. +

+
+ + +
+

From upload to verdict

+
+
+ 1 +

You upload

+

Drop in an image or video. We read its hidden details — when and how it was created — and identify what kind of content it is.

+
+
+ 2 +

We pick the right checks

+

Verify routes your file to the analyses that fit it best, so nothing relevant is missed and nothing irrelevant slows it down.

+
+
+ 3 +

We look closely

+

We weigh dozens of subtle signals the eye can miss — and, where it helps, trace where the image has appeared online before.

+
+
+ 4 +

You get a clear verdict

+

Everything we find is brought together into one score and a plain verdict: Authentic, Suspect, Manipulated, or Out of context.

+
+
+ 5 +

A human checks the close calls

+

When the result sits in the grey zone, a senior analyst reviews it before anything is published.

+
+
+ 6 +

You get the evidence

+

Your report shows the key frames, the file details and a plain-language explanation — and you can download it as a PDF. Everything is kept on record.

+
+
+ + + + + + + + + + + Upload + + + + + Right checks + + + + + Score / 100 + + + + + Human review + + + + + Public report + + +
+ + +
+

Five ways we check the truth of an image

+

+ Misleading visuals take many forms — a picture that was never real, a real + picture that's been edited, or a true photo wrapped in a false story. These + five analyses cover the whole chain, from the smallest detail in the pixels + to the caption it's published with. +

+ +
+ AI-Generated Media Detection +

Spot images and videos made by AI tools

+

+ AI image and video generators leave faint traces a person can't see — in + texture, light and fine detail. We read those traces to tell whether a + visual was made by a camera or by a machine, and we keep pace as new + generators appear. Every verdict comes with the visual evidence behind it. +

+
+ +
+ Visual Manipulation & Persuasion +

See how an image is built to move you

+

+ A visual can be entirely real and still designed to push a feeling — through + framing, colour, timing and the way a scene is staged. We point out the + persuasion techniques at work in ads and propaganda, so you can judge the + message, not just the picture. +

+
+ +
+ Edited Photo Detection +

Catch retouching and montage on real photos

+

+ Sometimes only a corner of a photo has been changed — something added, + removed, cloned or stitched in. We examine the picture for the tell-tale + signs of editing and show you where they are, not just that they exist. +

+
+ +
+ Caption Fidelity & Coherence +

Check that the caption matches — and honestly describes — the picture

+

+ The most common trick online isn't a fake photo — it's a genuine one used + for the wrong event, or a true photo wrapped in a caption that exaggerates + or leaves things out. We compare what an image (or video) shows with the + caption it's posted under, flag the mismatch, and rate how faithfully the + words describe the scene — built around the way newsrooms handle posts + going viral. +

+
+ +
+ Cosmetic Ads Fact-Check +

Test whether a beauty ad keeps its promises

+

+ Cosmetic advertising leans on before-and-after shots, glowing testimonials + and bold claims. We check the retouching, flag testimonials that aren't + what they seem, and weigh the promises against the EU rules on cosmetic + claims — so you know what's real. +

+
+
+ + +
+

What the verdict means

+ + + + + + + + + + +
ScoreVerdictWhat it tells you
76 – 100AuthenticNothing points to tampering, and the source checks out. Safe to trust.
45 – 75SuspectWe found real warning signs, but not enough to be certain. Treat with caution.
0 – 44ManipulatedSeveral signs line up and are confirmed. Best not to share it.
Out of contextThe image is genuine, but it's being reused for a different event.
+
+ + +
+

Our promise to you

+
    +
  • Every report shows the evidence — the key frames, the file details and a plain-language explanation of what we found — and you can download it as a PDF.
  • +
  • Close calls don't go out on autopilot: a senior analyst reviews anything in the grey zone before it's published.
  • +
  • Our work is checked twice a year by an independent panel, including forensic researchers and newsroom editors.
  • +
  • Your file is analysed in seconds and never shared. We don't keep it beyond 30 days unless you ask us to.
  • +
  • If we ever get something wrong, we correct it openly and keep the record.
  • +
+
+
+ + + + + + + + diff --git a/mission.html b/mission.html new file mode 100644 index 0000000000000000000000000000000000000000..2f1e80609f41ebcca8de91bf3c18ed8ba131e9a4 --- /dev/null +++ b/mission.html @@ -0,0 +1,165 @@ + + + + + + About — Verify, Tunisia's fact-checking platform + + + + + + + + + + +
+
+ About Verify +

Verify is a Tunisian platform that fights visual disinformation.

+

+ We help citizens, journalists and institutions tell the real from the + fake — using five AI modules, a newsroom of fact-checkers, and a network + of partners across the country. +

+
+
+ + +
+

Who we are

+

+ Verify is an independent Tunisian fact-checking platform. + We were founded in 2024 by a team of journalists, machine-learning + engineers and lawyers who shared one observation: the volume of + manipulated images, deepfake videos and false news circulating online + had grown beyond what any newsroom could handle alone. +

+

+ Today, our team works from Tunis, Sfax and Sousse. We publish every + verification in open access, document our methodology in detail, and + collaborate with Tunifact, iCheck, + AFP Factuel and the Hannah Arendt Foundation + for the Maghreb. +

+
+ +
+ + +
+

Why we exist

+

+ Every week, hundreds of manipulated visual contents circulate in + Tunisia: photos taken out of context, AI-altered videos, misleading + ads, fake government decrees on WhatsApp. By the time the truth comes + out, the lie has already been shared 800,000 times. +

+

+ Newsrooms don't have the time or the tools to verify every suspicious + content. Citizens don't have a simple way to tell what is true from + what is not. Verify exists to close that gap. +

+
+ +
+ + +
+

What Verify brings together

+

+ Verify combines three layers that, on their own, are not enough — but + together build a complete answer to visual disinformation: +

+
+
+ TECHNOLOGY +

Multi-layer analysis, from the image to the caption

+

Five specialized analyses covering the full chain of visual disinformation — from how an image was made or edited, all the way to whether the caption attached to a post actually holds up.

+
+
+ NEWSROOM +

A human editorial team that contextualizes

+

A team of journalists who investigate, contextualize and publish the evidence — because no AI score is enough without the editorial judgment that turns a signal into a verifiable story.

+
+
+ MEDIA +

A free, public-facing fact-check media

+

A consumer-grade media platform where every fact-check is explained, archived and freely accessible — built so that anyone, not just newsrooms, can use it.

+
+
+
+ +
+ + +
+

Our commitments

+
    +
  • Total transparency. Our methodology and our error rates are public. No black boxes.
  • +
  • Editorial independence. No direct political funding. External audit twice a year.
  • +
  • Privacy first. No file submitted by users is kept beyond 30 days without explicit consent.
  • +
  • Free for everyone. Free for citizens, journalists and students. Solidarity pricing for NGOs.
  • +
  • Owned mistakes. Every correction we publish is archived, flagged and explained — including when the mistake is ours.
  • +
+ +
+
+ "You don't fight disinformation with another authoritarian narrative. + You fight it with evidence, sources, and the humility to admit what + you don't know yet." +
+ — Maryem Ben Slimane, Editor-in-Chief at Verify +
+
+ + +
+
+
+
412
+
Fact-checks this week
+
+
+
38%
+
Content flagged as manipulated
+
+
+
6
+
Active AI modules
+
+
+
2 min
+
Average analysis time
+
+
+
+ + +
+

How we work

+

+ Every analysis goes through six steps: intake, routing to the right + checks, automated analysis, scoring, human review when confidence is + moderate, then publication. No verdict is published without a + cross-check. +

+

+ Our team currently includes 4 fact-checking journalists, 3 engineers, + and a network of local contributors in Tunis, Sfax and Sousse. +

+ +
+ + + + + + + + diff --git a/packs.html b/packs.html new file mode 100644 index 0000000000000000000000000000000000000000..3b24a419e3dfdb264df115cfb0cd429a2a36883b --- /dev/null +++ b/packs.html @@ -0,0 +1,584 @@ + + + + + + Plans & pricing — Verify + + + + + + + + + + + + + +
+ + + Plans & pricing + +

Pick the plan that fits how you verify.

+

+ Every plan runs on the same five AI modules and the same forensic engine. + What changes is volume, video depth, exports and the controls a team needs. +

+ +
+ Monthly + + Yearly + Save 20% +
+
+ + +
+
+ + +
+
Essential
+

For citizens, students and anyone who just needs a clear verdict on a file.

+
+ 29 DT + / month +
+
Billed monthly · cancel anytime
+ + Start with Essential + +
+
What's included
+
    +
  • 50 analyses per month
  • +
  • AI-generated media detection
  • +
  • Photoshop & edit forensics
  • +
  • Full public fact-check archive
  • +
  • Web app · email support
  • +
+
+
+ [] + Essential analysis +
+
+
+ + + + + +
+
Enterprise
+

For media groups, platforms and institutions — API access, team controls and a model tuned to your beat.

+
+ Let's talk +
+
Custom pricing · annual contract · invoiced
+ + Contact sales + +
+
Everything in Journalist Pro, plus
+
    +
  • API access — verify inside your own CMS or pipeline
  • +
  • Long-form video analysis (over 20 min)
  • +
  • Team admin panel, seats & SSO / SAML
  • +
  • Custom model training on your archive
  • +
  • Dedicated support · SLA · onboarding
  • +
+
+
+ [] + Unbiased analysis +
+
+ [] + Ethical AI +
+
+
+ +
+ +

+ Working in a classroom, an NGO or a public-interest project? + We have a free & solidarity tier → +

+
+ +
+ + +
+

Compare every plan

+

Same engine, same verdicts. The difference is scale, depth and team controls.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 EssentialJournalist ProEnterprise
Volume
Monthly analyses50Unlimited
Priority analysis queue
AI modules
AI-generated media detection
Photoshop & edit forensics
Manipulation & persuasion analysis
Caption fidelity check
Cosmetic-ads fact-check
Video
Video context check
Long-form video (over 20 min)
Reports & archive
Public fact-check archive
Downloadable PDF reports
Verification certificates
Teams & integration
API access
Team admin panel & seats
SSO / SAML
Custom model training
Support
Support levelEmailDedicated · SLA
Choose EssentialContact sales
+
+
+ + +
+

Used in newsrooms across Tunisia

+
+
+ + + + + + + + +
+
+
+ + +
+

Questions about plans

+ +
+ Is there a free plan? + +
Every public fact-check on Verify is free to read, with no account. For running your own files, the paid plans above start at 23 DT/month. We also keep a free & solidarity tier for students, classrooms, NGOs and public-interest projects — tell us about your case and we'll set you up.
+
+ +
+ What counts as one "analysis"? + +
One file (an image, a video or a post you submit) run through one module = one analysis. Running the same file through several modules counts once. Re-opening a past report is always free, on every plan.
+
+ +
+ Can I switch plans or cancel later? + +
Yes — upgrade, downgrade or cancel anytime from your account. Upgrades take effect immediately and are prorated; downgrades apply at the end of the current period. Monthly plans cancel at month end with no penalty.
+
+ +
+ What's the difference between monthly and yearly billing? + +
Same features either way. Yearly billing is roughly 20% cheaper — one invoice for the year instead of twelve — which is what the "Save 20%" toggle above shows.
+
+ +
+ Is the content I submit kept private? + +
Files you submit are used only to produce your report. We don't keep them beyond 30 days without your explicit consent, and we never publish a user-submitted file as a fact-check without asking. See our methodology for the full data policy.
+
+ +
+ How does Enterprise pricing work? + +
Enterprise is priced on volume, the number of seats, API usage and whether you want a custom-trained model. It's an annual contract, invoiced. Get in touch and we'll put together a quote — most teams are running within two weeks.
+
+
+ + +
+
+

Not sure which one yet?

+

Start on Essential, run a few real files, and move up the day you need unlimited checks or exports. Nothing here locks you in.

+ +
+
+ +
+ + + + + + + + + diff --git a/rayen/README (1).md b/rayen/README (1).md new file mode 100644 index 0000000000000000000000000000000000000000..0ef125961134f2d30571e424549a5dc78fa4e3cb --- /dev/null +++ b/rayen/README (1).md @@ -0,0 +1,348 @@ +# Image Tampering Detection — CNN From Scratch + +A dual-output convolutional neural network trained on the **DF2023 Digital Forensics Dataset** that simultaneously classifies the *type* of image manipulation and localises the tampered region at pixel level. The model is integrated into a web platform where users upload an image and receive a manipulation probability breakdown alongside a Grad-CAM heatmap that visually explains *where* the model focused its attention. + +--- + +## Table of Contents + +1. [What the model does](#what-the-model-does) +2. [Input specification](#input-specification) +3. [Model architecture](#model-architecture) +4. [Output specification](#output-specification) +5. [Interpreting the confidence scores — real vs. fake](#interpreting-the-confidence-scores--real-vs-fake) +6. [Explainability: Grad-CAM](#explainability-grad-cam) +7. [Web platform integration](#web-platform-integration) +8. [Performance benchmarks](#performance-benchmarks) +9. [Dataset & classes](#dataset--classes) + +--- + +## What the model does + +The model solves two tasks in a single forward pass: + +| Task | Output | Purpose | +|---|---|---| +| **Classification** | 4-class probability distribution | Identifies *how* the image was manipulated | +| **Segmentation** | Binary pixel mask 256×256 | Localises *where* the manipulation occurred | + +On top of these two raw outputs, the web platform generates a **Grad-CAM heatmap** — a coarse saliency map derived from the network's own gradients — to visually explain which regions drove the classification decision. + +--- + +## Input specification + +### What goes in + +The model accepts a **single RGB image** per inference call. Before the image reaches the network it goes through a fixed preprocessing pipeline: + +``` +Raw image (any resolution, any format) + ↓ +Resize to 256 × 256 pixels (bilinear interpolation) + ↓ +Convert to float32 tensor with values in [0, 1] + ↓ +Channel-wise normalisation using ImageNet statistics: + mean = [0.485, 0.456, 0.406] (R, G, B) + std = [0.229, 0.224, 0.225] (R, G, B) + ↓ +Final tensor shape: [1, 3, 256, 256] (batch=1, channels=3, height=256, width=256) +``` + +### Why these preprocessing steps matter + +**Resize to 256×256** — The convolutional layers have fixed kernel sizes and the bottleneck feature map is 16×16. A consistent input resolution ensures that spatial relationships are preserved identically across every image and that skip connections in the U-Net decoder align correctly. + +**ImageNet normalisation** — Even though this model was trained from scratch (no pretrained weights), applying the same normalisation shifts pixel distributions to roughly zero-mean / unit-variance per channel. This stabilises gradient flow during training and, more importantly for inference, means the model learned to decode these specific value ranges. Sending an un-normalised image would produce unreliable outputs. + +**What the platform should accept** — The frontend can accept JPEG, PNG, WebP, or any standard web image format. The preprocessing pipeline converts whatever format arrives into the expected tensor automatically before inference. + +--- + +## Model architecture + +The network is a **hand-built dual-output CNN** — no pretrained backbone, no external segmentation library. It is structured as a U-Net with an additional classification head grafted onto the bottleneck. + +``` +Input [B, 3, 256, 256] + │ + ▼ +┌─── Encoder (4 stages) ────────────────────────────────────────┐ +│ Stage 1 — DoubleConv(3→32) → [B, 32, 256, 256] ─ skip1 │ +│ Stage 2 — DoubleConv(32→64) → [B, 64, 128, 128] ─ skip2 │ +│ Stage 3 — DoubleConv(64→128) → [B, 128, 64, 64] ─ skip3 │ +│ Stage 4 — DoubleConv(128→256) → [B, 256, 32, 32] ─ skip4 │ +└────────────────────────────────────────────────────────────────┘ + │ MaxPool2d + ▼ + Bottleneck [B, 256, 16, 16] ← Grad-CAM targets this layer + │ + ┌────┴───────────────────────────────────────────────────┐ + ▼ ▼ +Classification head U-Net Decoder +AdaptiveAvgPool2d(1) (4 upsample + skip concat stages) +Flatten │ +Dropout(0.3) ▼ +Linear(256 → 4) Binary mask logits [B, 1, 256, 256] + │ + ▼ +4-class logits [B, 4] +``` + +**DoubleConv block** — Each stage applies two successive Conv2d(3×3) → BatchNorm2d → ReLU sequences. The double-convolution pattern increases effective receptive field and representational capacity without increasing parameter count as quickly as a single larger kernel would. + +**Skip connections** — Feature maps from each encoder stage are concatenated with the corresponding decoder output. This gives the decoder access to fine-grained spatial detail (edges, textures, colour transitions) that would otherwise be lost during downsampling — critical for accurately outlining the tampered region in the segmentation mask. + +**Bottleneck as the bridge** — At 16×16 spatial resolution, each of the 256 feature channels encodes a high-level semantic concept over a large receptive field covering most of the image. This is where the classification head operates, and it is the layer that Grad-CAM interrogates. + +**Total size: 2.15M trainable parameters.** + +--- + +## Output specification + +### 1. Classification probabilities (the "fake meter") + +The raw network output for classification is a vector of 4 unnormalised logits. The platform converts them to a probability distribution using the **softmax** function: + +``` +softmax(logits)_i = exp(logits_i) / Σ exp(logits_j) +``` + +This yields four values that sum to exactly 1.0. Each value represents the model's estimated probability that the uploaded image belongs to that manipulation class: + +| Index | Code | Manipulation type | What it means | +|---|---|---|---| +| 0 | **I** | Inpainting | A region was algorithmically filled in to remove or replace content | +| 1 | **C** | Copy-move | A patch from inside the image was duplicated and pasted elsewhere | +| 2 | **S** | Splicing | Content from a different image was inserted | +| 3 | **E** | Enhancement | Global or local adjustments (brightness, contrast, blur, sharpening) that disguise prior editing | + +The platform displays these four values as percentages, for example: + +``` +Inpainting ████░░░░░░ 12% +Copy-move ██████████ 71% ← predicted class +Splicing ███░░░░░░░ 9% +Enhancement ████░░░░░░ 8% +``` + +### 2. Pixel-level tamper mask + +In parallel, the model produces a 256×256 binary mask where each pixel is either **tampered** (value 1) or **untampered** (value 0). A sigmoid activation converts the raw mask logit to a probability per pixel; pixels above 0.5 are flagged. The platform can display this mask as a red overlay on the original image to show *where* the model found evidence of manipulation, independently of the Grad-CAM heatmap. + +--- + +## Interpreting the confidence scores — real vs. fake + +The model was trained on four manipulation classes. It has never seen a ground-truth "real / unmodified" class, because the training dataset only contains tampered images. For a web platform exposed to real-world uploads (which includes genuine photographs), a confidence-threshold rule is applied: + +### Decision logic + +``` +max_confidence = max(P_inpainting, P_copy_move, P_splicing, P_enhancement) + +if max_confidence < THRESHOLD: + verdict = "AUTHENTIC — no significant manipulation detected" +else: + verdict = f"FAKE — predicted manipulation: {argmax class}" +``` + +**Recommended threshold: 0.50** (i.e. no single class exceeds 50% confidence). + +When all four probabilities are spread roughly evenly (e.g. 27% / 24% / 26% / 23%), the model is uncertain — it cannot find a coherent tampering pattern that matches any known manipulation type. This is the expected behaviour on authentic images: the model finds nothing to latch on to, so probability mass disperses across all classes, and none clears the threshold. + +### How to display this in the platform + +``` +┌──────────────────────────────────────────────────────────┐ +│ VERDICT: ✅ AUTHENTIC IMAGE │ +│ The model found no confident evidence of manipulation. │ +│ │ +│ Manipulation scores: │ +│ Inpainting ██░░░░░░░░ 22% │ +│ Copy-move ███░░░░░░░ 28% │ +│ Splicing ██░░░░░░░░ 24% │ +│ Enhancement ██░░░░░░░░ 26% │ +└──────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────┐ +│ VERDICT: ❌ FAKE IMAGE │ +│ Predicted manipulation type: COPY-MOVE (71%) │ +│ │ +│ Manipulation scores: │ +│ Inpainting ████░░░░░░ 12% │ +│ Copy-move ██████████ 71% ← detected │ +│ Splicing ███░░░░░░░ 9% │ +│ Enhancement ████░░░░░░ 8% │ +└──────────────────────────────────────────────────────────┘ +``` + +You can expose the threshold as a user-facing slider (e.g. "Sensitivity") to let users trade off false positives vs. false negatives depending on the context. + +--- + +## Explainability: Grad-CAM + +### Why Grad-CAM + +Showing a verdict alone ("this image is fake") is not enough — users need to understand *why* the model reached that conclusion. Without an explanation, the output is untrustworthy and unactionable. **Gradient-weighted Class Activation Mapping (Grad-CAM)** addresses this by producing a heatmap that highlights which spatial regions of the image were most influential for the predicted class. + +This is especially important for a tampering-detection platform because the highlighted region should ideally coincide with the physically manipulated area, giving the user a concrete visual marker to inspect. + +### How it works (step by step) + +Grad-CAM operates on a chosen convolutional layer — here, the last encoder stage (`enc4`) — which produces feature maps of shape `[256, 32, 32]` (256 channels, each a 32×32 spatial grid). This is the richest semantic layer before pooling collapses spatial information for the classification head. + +**Step 1 — Forward pass** + +The image is passed through the network normally. The 256-channel feature map at `enc4` is captured via a forward hook and saved as `A` (activations). + +**Step 2 — Backward pass for the predicted class** + +The score of the predicted class (the highest logit) is backpropagated through the network. The gradient of this score with respect to every spatial location in `enc4`'s feature map is captured via a backward hook and saved as `∂score/∂A`. + +**Step 3 — Channel importance weights** + +For each of the 256 channels `k`, compute its importance weight `α_k` by global-average-pooling the gradient map over the 32×32 spatial grid: + +``` +α_k = (1 / (32×32)) Σ_{i,j} (∂score / ∂A_k^{ij}) +``` + +Channels whose gradients are large and positive contributed strongly to the class score; channels with near-zero or negative gradients are uninformative or suppressive. + +**Step 4 — Weighted combination + ReLU** + +The Grad-CAM heatmap is a weighted sum of the activation maps, retaining only positive contributions (ReLU ensures we focus on features that increase the class score, not features that suppress it): + +``` +CAM = ReLU( Σ_k α_k · A_k ) +``` + +The result is a single-channel spatial map of shape `[32, 32]`. + +**Step 5 — Upsampling and normalisation** + +The 32×32 CAM is bilinearly upsampled back to the original input resolution (256×256), then min-max normalised to [0, 1] so it can be rendered as a colour heatmap. Values near 1 (hot colours in the jet colormap) indicate regions the classifier relied on most; values near 0 (cool colours) are regions it largely ignored. + +**Step 6 — Overlay** + +The normalised CAM is composited over the original image with 50% transparency (alpha blending), producing the final overlay shown to the user. + +### What the Grad-CAM tells you + +- **Heatmap concentrated on a specific object or region** — the model identified a localised manipulation; the hot area is where to look for signs of editing. +- **Heatmap diffuse across the entire image** — either the manipulation affects the whole image (e.g. global enhancement), or the model is not confident in its localisation. +- **Heatmap coincides with the segmentation mask** — strong agreement between the pixel-level mask (task 2) and the Grad-CAM (task 1 explanation) increases confidence that the detection is genuine. +- **Heatmap on irrelevant regions** — a sign of a shortcut/artefact the model learned; users should treat the verdict with more scepticism. + +### Implementation reference + +```python +class GradCAM: + def __init__(self, model, target_module): + # register forward hook to capture enc4 activations + self.h_fwd = target_module.register_forward_hook(self._fwd_hook) + # register backward hook to capture gradients at enc4 + self.h_bwd = target_module.register_full_backward_hook(self._bwd_hook) + + def __call__(self, x, class_idx=None): + _, logits = self.model(x) # forward pass + score = logits[:, class_idx].sum() + score.backward() # backward pass + weights = self.gradients.mean(dim=(2,3), keepdim=True) # α_k + cam = F.relu((weights * self.activations).sum(dim=1)) # weighted sum + ReLU + cam = F.interpolate(cam, size=(256, 256), mode='bilinear') # upsample + cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8) # normalise + return cam +``` + +The model weights target `enc4` (the last encoder stage, output shape `[B, 256, 32, 32]`). This layer sees a 32×32 grid where each cell covers a 16×16 pixel receptive field in the original image, giving a good balance between spatial resolution and semantic richness. + +--- + +## Web platform integration + +### Inference pipeline per upload + +``` +User uploads image + ↓ +Preprocess: resize → tensor → normalise → [1, 3, 256, 256] + ↓ +model(image) → (mask_logits [1,1,256,256], cls_logits [1,4]) + ↓ +softmax(cls_logits) → [P_I, P_C, P_S, P_E] (four class probabilities) +sigmoid(mask_logits) → pixel-level tamper probability map +GradCAM(image, predicted_class) → heatmap [256, 256] + ↓ +Response to frontend: + - verdict: "AUTHENTIC" | "FAKE — " + - class_probabilities: { inpainting: %, copy_move: %, splicing: %, enhancement: % } + - tamper_mask: base64-encoded PNG overlay + - gradcam_heatmap: base64-encoded PNG overlay + - gradcam_on_image: base64-encoded blended composite +``` + +### What to display per result + +| UI element | Content | +|---|---| +| **Verdict badge** | Green "Authentic" or red "Fake — Copy-move" (example) | +| **Confidence bars** | Four horizontal bars, one per manipulation class, labelled with percentages | +| **Tamper mask overlay** | Original image with red-tinted pixels where the binary mask fires (pixel-level "where") | +| **Grad-CAM overlay** | Original image composited with the jet-coloured heatmap at 50% alpha (attention-level "where and why") | +| **Explanation text** | Short natural-language description of what the Grad-CAM shows, e.g.: *"The model focused on the lower-left region (red/yellow area), where it detected pixel statistics inconsistent with the surrounding texture — a common sign of copy-move forgery."* | + +### Confidence threshold setting + +Expose a configurable threshold in the platform settings (default 0.50). A user who wants fewer false positives (e.g. a journalist) should raise it to 0.65–0.70. A user who wants to catch any hint of manipulation (e.g. a forensics auditor) should lower it to 0.35–0.40. + +--- + +## Performance benchmarks + +Evaluated on a held-out test set of 24,000 images (6,000 per class), trained on 2× NVIDIA Tesla T4 GPUs. + +| Metric | Value | +|---|---| +| Test Accuracy | **95.20%** | +| Test F1 (macro) | **95.22%** | +| Test F1 (weighted) | **95.22%** | +| Test ROC-AUC (macro / OvR) | **99.59%** | +| Mean IoU (segmentation) | **70.65%** | +| Mean Dice (segmentation) | **77.90%** | +| Expected Calibration Error | **0.0139** (well-calibrated) | +| Total parameters | **2.15M** | +| Forward FLOPs (1 image) | **12.80 GFLOPs** | +| Inference latency | **4.81 ms / image** | +| Throughput | **208 FPS @ batch 64** | + +The low ECE (0.014) means the confidence percentages shown to users are trustworthy: when the model says 71%, the image really is that class about 71% of the time. This is what makes the probability display meaningful rather than decorative. + +--- + +## Dataset & classes + +The model was trained on the **DF2023 Digital Forensics 2023 Dataset (v15, COCO split)** — 160,000 images (40,000 per class), split 70% train / 15% validation / 15% test with stratified sampling to preserve class balance. + +### Manipulation classes + +**I — Inpainting** +A region of the image was removed and algorithmically filled in using content-aware or neural inpainting techniques. The forged area typically shows smooth textures that blend with the surroundings but lack natural noise patterns. Common use case: removing people or objects from photographs. + +**C — Copy-move** +A patch from one area of the image was copied and pasted into another area of the *same* image. The duplicate patch may be rotated, scaled, or slightly colour-corrected to disguise the copy. Copy-move is one of the most common forgery types in political and journalistic image manipulation. + +**S — Splicing** +Content from a *different* source image was cut and pasted into the target image. Unlike copy-move, splicing introduces foreign lighting, noise characteristics, and compression artefacts that do not match the host image's statistics — the strongest signal the model exploits. + +**E — Enhancement** +Selective or global adjustments including brightness/contrast changes, blurring, sharpening, saturation shifts, or JPEG re-compression. Enhancement is often used to disguise prior editing or to make other manipulations less detectable. + +### Why no "real" class in training + +The DF2023 dataset does not include unmodified authentic images — every sample has been manipulated in one of the four ways above. This is why the platform uses a confidence-threshold rule rather than a dedicated "real" output neuron. The absence of any strongly recognised manipulation pattern (flat probability distribution across all four classes) is used as the proxy for authenticity. diff --git a/real-or-fake.html b/real-or-fake.html new file mode 100644 index 0000000000000000000000000000000000000000..da4e011d74749bc9c9911db7a2dddd3caac693b8 --- /dev/null +++ b/real-or-fake.html @@ -0,0 +1,193 @@ + + + + + + Fact-Checks — Verify + + + + + + + + + + +
+
+
+

Fact-Checks

+

Browse all claims investigated by the Verify newsroom — sorted by date and verdict.

+
+ +
+
+ + +
+ + +
+
+ +
+ + +
+ + + + + + + + + + diff --git a/resultats.html b/resultats.html new file mode 100644 index 0000000000000000000000000000000000000000..fca002e45469339a5789bc74626d8a43194f08bd --- /dev/null +++ b/resultats.html @@ -0,0 +1,259 @@ + + + + + + Analysis results — Verify + + + + + + + + + + +
+
+
+ Verification › + AI-Generated Media › + Report #VR-2026-0427-A93 +
+ +
+
+ Module: AI-Generated Media Detection +

+ Analysis report #VR-2026-0427-A93 +

+

Analysis run on April 27, 2026 at 11:08 AM · Duration: 47 s · File: official_statement.mp4

+
+ ⬇ Download PDF report +
+
+ + +
+
+
+ +
+
67
+
Score / 100
+
+
+
+ ⚠ Suspect +

+ Significant facial-manipulation indicators detected +

+

+ The content shows several inconsistencies typical of a deepfake. + Without being able to conclude with 100% confidence, the analysis + recommends caution before any sharing or citation. Cross-check + with an independent verification via Image–Caption Coherence. +

+
+
+
+ + +
+
+ + + + + +
+ + +
+
+
+
Overall score
+
67 / 100
+
Below the confidence threshold (75)
+
+
+
Verdict
+
Suspect
+
Likely manipulation but not confirmed
+
+
+
Flagged frames
+
14 / 1,410
+
Concentrated between 0:18 and 0:24
+
+
+ +
+ Why this score + Three convergent signals: an absent blink over 4.2 seconds (abnormal + beyond 1.5 s), a lip-sync drift around 0:21, and fusion artifacts + on the left jawline. Conversely, lighting, pose and background + remain consistent — hence the absence of a definitive verdict. +
+ +
+ Recommendation + Before publication or citation: cross-check with Image–Caption + Coherence (source search) and Edited Photo Detection. If possible, + retrieve the original version from the cited source. +
+
+ + +
+

Four key frames where the module flagged inconsistencies. Red zones mark suspect pixels.

+
+
+ Frame 0:18 + +
+
+
+ Frame 0:21 + +
+
+
+ Frame 0:23 + +
+
+
+ Frame 0:24 + +
+
+
+
+ + +
+
+

Methodology applied

+

The video was broken down into 1,410 frames. Each one was + checked on three fronts at once: locating the face, mapping its + features in fine detail, and measuring whether those features + stay consistent from one frame to the next — the kind of + steadiness a genuine recording keeps and an altered one tends to + lose. +

+ +

Detected indicators

+
    +
  • Blink anomaly between 0:17.8 and 0:22.0: + no blink detected over 4.2 seconds. Normal distribution is + around once every 4–6 seconds; a human rarely exceeds 3 + seconds while speaking on camera.
  • +
  • Lip-sync mismatch on the word "Tunisia" + at 0:21.4: amplitude of phonemes /ti/ /zi/ does not match + lip trajectory (delta: 87 ms).
  • +
  • Blending traces on the left side of the + jaw between 0:23 and 0:24: an unnatural transition between + neighbouring areas, consistent with a swapped-in face.
  • +
+ +

Counter-indicators (in favor of authenticity)

+
    +
  • Lighting consistency maintained across the entire sequence.
  • +
  • Sensor noise (pattern noise) homogeneous, no abrupt break.
  • +
  • Eye reflections aligned with the main light source.
  • +
+ +

Limits of the analysis

+

Even a strong AI-generated-media detector cannot distinguish + a very high-quality deepfake from an authentic video shot in + unusual conditions (low light, aggressive compression, subject + fatigue). The 67/100 score reflects that uncertainty. For + confirmation, cross-checking with Image–Caption Coherence + (source search) remains essential. +

+
+
+ + +
+

File metadata

+ + + + + + + + + + + + + + +
File nameofficial_statement.mp4
Size87.4 MB
Duration00:00:47.12
Resolution1920 × 1080 (16:9)
Frame rate30 fps (constant)
Video codecH.264 / AVC — High Profile L4.0
Audio codecAAC LC — 48 kHz · stereo
Bitrate14.2 Mbps (video) · 192 kbps (audio)
EXIF Date CreatedMissing ⚠
EXIF SoftwareMissing ⚠
SHA-256 hasha7f4...d92e (62 chars)
+ +

Detailed check scores

+ + + + + + + + + + + + +
CheckScoreThresholdStatus
Blink consistency0.420.75Suspect
Lip sync0.580.80Suspect
Blending traces0.710.75Suspect
Lighting consistency0.890.75OK
Sensor noise0.840.75OK
Eye reflections0.780.75OK
+
+ + +
+

Three report formats are available, depending on your usage.

+
+
+
📄
+

PDF report

+

Full printable document, designed for editorial sharing.

+ Download PDF +
+
+
{ }
+

JSON data

+

Full structure of scores, frames and metadata for integration.

+ Download JSON +
+
+
📊
+

CSV spreadsheet

+

Score per frame, ready to open in any spreadsheet for your own analysis.

+ Download CSV +
+
+
+
+ + +
+
+

Want to cross-check this result?

+

Run an additional analysis on the same file with another module.

+
+ +
+
+ + + + + + + + diff --git a/scripts/download_models.py b/scripts/download_models.py new file mode 100755 index 0000000000000000000000000000000000000000..08e35d134658b564aa6efd1c9f9be72b8add1e00 --- /dev/null +++ b/scripts/download_models.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Download the model weights that are NOT versioned in the repo. + +Per the ESPRIT student guide (page 6): trained model weights bigger than ~100 MB or that pile up +quickly are hosted externally (Hugging Face Hub, Google Drive, Kaggle) and pulled at install time +by a script like this one. + +Usage: + python scripts/download_models.py + +Models downloaded here are Islem's ResNet50 weights for io1 (face deepfake detection). All other +HuggingFace / OpenAI-CLIP / Ultralytics models auto-download on first use of the backend. + +Hosting URLs are read from environment variables (set them in `.env` or export them in your shell): + IO1_RESNET50_URL public download URL for io1_resnet50.pth (~94 MB) + IO1_RESNET50_DEEPFAKE_URL public download URL for io1_resnet50_deepfake.pth (~94 MB) + +The recommended hosting is Hugging Face Hub (free, unlimited for public models — see ESPRIT +guide page 6 for the rationale). Once Islem uploads the files to a HF repo, set: + + export IO1_RESNET50_URL=https://huggingface.co///resolve/main/best_ResNet50.pth + export IO1_RESNET50_DEEPFAKE_URL=https://huggingface.co///resolve/main/resnet50_deepfake.pth +""" +from __future__ import annotations + +import hashlib +import os +import sys +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DATA_DIR = ROOT / "backend" / "data" +DATA_DIR.mkdir(parents=True, exist_ok=True) + +# (env var, target filename, expected approx size in MB) for each weight +WEIGHTS = [ + ("IO1_RESNET50_URL", "io1_resnet50.pth", 94), + ("IO1_RESNET50_DEEPFAKE_URL", "io1_resnet50_deepfake.pth", 94), +] + + +def _human(n: int) -> str: + for unit in ("B", "KB", "MB", "GB"): + if n < 1024: + return f"{n:.1f} {unit}" + n /= 1024 + return f"{n:.1f} TB" + + +def _progress(blocknum: int, blocksize: int, totalsize: int): + done = blocknum * blocksize + if totalsize > 0: + pct = min(100, done * 100 // totalsize) + sys.stdout.write(f"\r {pct:3d}% {_human(done):>10s} / {_human(totalsize):>10s}") + else: + sys.stdout.write(f"\r {_human(done):>10s}") + sys.stdout.flush() + + +def download(url: str, dest: Path): + print(f" → {url}") + print(f" into {dest}") + tmp = dest.with_suffix(dest.suffix + ".part") + urllib.request.urlretrieve(url, tmp, reporthook=_progress) + print() + tmp.replace(dest) + + +def main(): + print(f"[download_models] target directory: {DATA_DIR}") + print() + missing_env = [] + for env_var, filename, _ in WEIGHTS: + dest = DATA_DIR / filename + if dest.exists() and dest.stat().st_size > 1_000_000: + print(f" ✓ {filename} already present ({_human(dest.stat().st_size)})") + continue + url = (os.environ.get(env_var) or "").strip() + if not url: + missing_env.append((env_var, filename)) + print(f" ✗ {filename} missing — set {env_var}=") + continue + try: + download(url, dest) + print(f" ✓ {filename} ready ({_human(dest.stat().st_size)})") + except Exception as e: + print(f" ✗ download failed for {filename}: {type(e).__name__}: {e}") + sys.exit(2) + + if missing_env: + print() + print("Some weights were not downloaded because their environment variables are unset.") + print("Edit .env (or export the variables) with the hosting URLs and re-run this script.") + print() + print("Example:") + print(" export IO1_RESNET50_URL=https://huggingface.co//verify-weights/resolve/main/best_ResNet50.pth") + sys.exit(1) + + print() + print("All weights are in place. You can now run the backend:") + print(" uvicorn backend.main:app --host 0.0.0.0 --port 8000") + + +if __name__ == "__main__": + main() diff --git a/verifier-module.html b/verifier-module.html new file mode 100644 index 0000000000000000000000000000000000000000..3d17086ab08655313eef33da9c118556940423db --- /dev/null +++ b/verifier-module.html @@ -0,0 +1,4793 @@ + + + + + + Module verifier — Verify + + + + + + + + + + + +
+
+ + +
+
+ + Module 00 / 05 +
+ +
+
+ + Module +

Module

+

+ +
+ + +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + diff --git a/verifier.html b/verifier.html new file mode 100644 index 0000000000000000000000000000000000000000..fc22b99126417087e8d3af6ce7ea8db6662c3d73 --- /dev/null +++ b/verifier.html @@ -0,0 +1,300 @@ + + + + + + Verify a File — Verify + + + + + + + + + + +
+
+
+
+ + + Verify a File + +

Drop in a media.
Get the evidence behind it.

+

+ Five specialised modules read an image, a video or a post the way a forensic + analyst would — pixel by pixel, frame by frame, caption included — and hand you + a clear verdict with the proof attached. +

+ +
+
889analyses · last 7 days
+
5analysis modules
+
‹ 2 minto a verdict
+
+
+
+ Forensic analysis of a video frame +
Live: face-swap analysis in progress…
+
+
+
+ + +
+ +
+ + +
+
+
+ The Verify Toolkit +

Five ways to check what you're looking at

+

Each module is specialised for one kind of manipulation — from the raw pixel up to the words around it. Run several on the same file to cross-check the verdicts.

+
+ + +
+ + +
+
+

Recently put through Verify

+ A live sample of media our newsroom and users ran this week — hover to pause. +
+
+
+
+
+ + +
+
+ How it works +

Three steps to a verdict

+
+
+
+
1
+

Pick a module

+

Choose the module that fits your content, or use the finder above to be routed automatically.

+
+
+
2
+

Upload the content

+

Drag-and-drop your file or paste a link. Everything stays confidential and encrypted in transit.

+
+
+
3
+

Read the report

+

A clear verdict, a confidence score and the visual evidence behind it. Export it as a PDF.

+
+
+
+ + +
+

Cited & used by newsrooms across Tunisia

+
+
+ + + + + + + + +
+
+
+ + +
+
+

Not sure which module you need?

+

Our methodology page walks through every signal each module looks at — and the limits of what AI verification can, and can't, prove.

+ +
+
+ + + + + + + + + + diff --git a/yassmine/IO6_Base_Reference_V3_FULL.xlsx b/yassmine/IO6_Base_Reference_V3_FULL.xlsx new file mode 100755 index 0000000000000000000000000000000000000000..6e1af308af9d83c628c67a628f042c98d6891fae --- /dev/null +++ b/yassmine/IO6_Base_Reference_V3_FULL.xlsx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66d0737c2214d0f55e5c0b30152815c2b1152d5d28f4b90f91f4bdc7a517254c +size 33737 diff --git a/yassmine/IO6_Guide_Notebook_FR.pdf b/yassmine/IO6_Guide_Notebook_FR.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3a4d690d7874b24bebab3f2779a054b212923d8c --- /dev/null +++ b/yassmine/IO6_Guide_Notebook_FR.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48ddf1d3b2e33beaa612b5c62ef6ce890c71709520c8aa0dea72ab2704c716b1 +size 324197 diff --git a/yassmine/IO6_Rapport.tex b/yassmine/IO6_Rapport.tex new file mode 100644 index 0000000000000000000000000000000000000000..3d7bc3bfb4272a1d7c13d6125051fa1826942e0a --- /dev/null +++ b/yassmine/IO6_Rapport.tex @@ -0,0 +1,340 @@ +% ===================================================================== +% IO6 — Multimodal Fact-Checking Pipeline for Cosmetic Advertising +% Module report — AI Media Credibility Platform (CBL, Esprit) +% Structure follows the common template: IOX -- Module Name +% ===================================================================== +\documentclass[11pt,a4paper]{article} + +\usepackage[utf8]{inputenc} +\usepackage[T1]{fontenc} +\usepackage[english]{babel} +\usepackage{lmodern} +\usepackage{geometry} +\geometry{margin=2.5cm} +\usepackage{graphicx} +\usepackage{booktabs} +\usepackage{array} +\usepackage{enumitem} +\usepackage{amsmath,amssymb} +\usepackage{xcolor} +\usepackage{hyperref} +\hypersetup{colorlinks=true,linkcolor=black,urlcolor=blue,citecolor=blue} +\usepackage{fancyhdr} +\pagestyle{fancy} +\fancyhf{} +\rhead{IO6 — Multimodal Fact-Checking Pipeline} +\lhead{AI Media Credibility Platform} +\cfoot{\thepage} + +\newcommand{\verdict}[1]{\texttt{#1}} + +\begin{document} + +% ===================================================================== +\section{IO6 -- Multimodal Fact-Checking Pipeline for Cosmetic Advertising} +% ===================================================================== + +\subsection{Objective} + +The goal of module IO6 is to design and integrate a \textbf{multimodal +fact-checking pipeline} able to automatically analyse cosmetic advertisements +(video) and produce a structured, explainable verdict +(\verdict{TRUE} / \verdict{FALSE} / \verdict{TO\_VERIFY}) for every claim it +detects. The system combines three information channels --- \emph{audio}, +\emph{visual} and \emph{textual} --- inside a single, reproducible chain, in full +compliance with EU Regulation 655/2013 on cosmetic claims and Article~13 of the +EU AI Act 2024 (right to explanation). + +\paragraph{Sub-objectives.} +\begin{itemize}[noitemsep] + \item Extract spoken and on-screen claims from a video advertisement + (ASR + OCR + object detection). + \item Verify each claim against an expert knowledge base (309+ entries) and the + EU~655/2013 regulatory patterns. + \item Force a decisive verdict on ambiguous claims via a decisive + post-processor (ASA UK puffery jurisprudence). + \item Provide full explainability (LIME, SHAP-like, counterfactual, traceability + with EU article citations). + \item Automatically generate a PDF compliance report for regulatory officers. +\end{itemize} + +% --------------------------------------------------------------------- +\subsection{Problem Definition} + +Cosmetic advertising is saturated with potentially misleading claims +(``reduces wrinkles by 90\%'', ``clinically proven results'', ``100\% natural''). +Manual screening of these claims by authorities is slow, costly and hard to scale +given the volume of content being broadcast. + +\paragraph{Formulation.} +Given a video advertisement $V$, the system must produce: +\[ + f(V) \;\longrightarrow\; \big\{ (c_i,\; y_i,\; p_i,\; E_i) \big\}_{i=1}^{n}, + \qquad y_i \in \{\verdict{TRUE},\ \verdict{FALSE},\ \verdict{TO\_VERIFY}\} +\] +where $c_i$ is the extracted claim, $y_i$ the verdict, $p_i \in [0,1]$ the +calibrated confidence and $E_i$ the explanation (salient words, severity, +counterfactual, cited EU articles). A global verdict and an aggregated +\emph{Trust Score} are also computed at the video level. + +\paragraph{Constraints.} +\begin{itemize}[noitemsep] + \item \textbf{Multimodality} --- information is spread across the audio track, + on-screen text and visual elements (logos, packshots). + \item \textbf{Compliance} --- decisions must be traceable and aligned with + EU~655/2013 and the EU AI Act 2024 (Art.~13). + \item \textbf{Safety first} --- minimise false negatives on misleading claims + (\verdict{FALSE} recall is the priority). + \item \textbf{Resources} --- runs on a single GPU (Tesla T4 / P100, + $\sim$16~GB VRAM), $<\!90$~s per video. +\end{itemize} + +% --------------------------------------------------------------------- +\subsection{Datasets Used} + +\paragraph{Knowledge Base.} +Excel file \texttt{IO6\_Base\_Reference\_V3\_FULL\_ENRICHED.xlsx}: 309+ entries +spread over 8 sheets, fully editable by domain experts. + +\begin{table}[h] +\centering +\small +\begin{tabular}{>{\raggedright\arraybackslash}p{4.5cm} >{\raggedright\arraybackslash}p{9.5cm}} +\toprule +\textbf{Sheet} & \textbf{Content} \\ +\midrule +Brands & Reference pharmaceutical / cosmetic brands \\ +Fake claims & Catalogue of known misleading claims \\ +EU~655/2013 patterns & Regulatory patterns (honesty, evidence, fairness, informed choice) \\ +Ingredients & Active ingredients and admissible thresholds \\ +Certifications & Valid labels and certifications \\ +ASA UK jurisprudence & Puffery cases (tolerated advertising exaggeration) \\ +\dots & (8 sheets in total) \\ +\bottomrule +\end{tabular} +\caption{Structure of the IO6 knowledge base.} +\end{table} + +\paragraph{Evaluation data.} +\begin{itemize}[noitemsep] + \item \textbf{Validated curated cases} --- claims manually annotated against the + strict EU~655/2013 Gold Standard. + \item \textbf{Extended stratified sample} --- $N=30$ claims, inter-annotator + agreement Cohen's $\kappa = 1.0$. + \item \textbf{3-fold cross-validation} --- 100\% $\pm$ 0\%. + \item \textbf{Multi-video operational batch} --- several real advertisements + (F1 macro 92.5\%). + \item \textbf{Ablation study} --- successive removal of pipeline components. +\end{itemize} + +\paragraph{CNN-from-scratch data (complementary deliverable).} +A dataset of pharmaceutical / cosmetic logos annotated in PASCAL VOC XML format +(50 logo classes) for PharmaLogoNet --- classification + bounding-box regression. + +% --------------------------------------------------------------------- +\subsection{Preprocessing Pipeline} + +\begin{enumerate}[label=\textbf{\arabic*.}, noitemsep] + \item \textbf{Video ingestion} ($V \to$ audio + frames) with \texttt{FFmpeg}: + audio track extraction (16~kHz mono) and key-frame sampling. + \item \textbf{Audio} --- transcription by \emph{Whisper medium} (ASR) $\to$ + timestamped text + segmentation into candidate utterances. + \item \textbf{Vision} --- \emph{YOLOv8n} for object / packshot detection, + \texttt{OpenCV} + \texttt{Pillow} for cropping and normalising regions of + interest. + \item \textbf{On-screen text} --- \emph{EasyOCR} (CRAFT + CRNN) multilingual on + the frames $\to$ text strings + confidence scores. + \item \textbf{Fusion / cleaning} --- deduplication, Unicode normalisation, + confidence-threshold filtering, merging audio + OCR into unique claims $c_i$. + \item \textbf{Semantic embeddings} --- \emph{MiniLM-L12-v2} encodes every claim + and every knowledge-base entry (cosine-similarity retrieval). + \item \textbf{LLM prompt preparation} --- building the reasoning prompt for + \emph{Phi-3-mini-4k} (claim + KB context + EU patterns). +\end{enumerate} + +\noindent\textit{Reproducibility:} fixed random seeds (42), open-source models, +JSON exports of all intermediate results. Full schema: see +\texttt{Pipeline\_IO6\_Architecture\_FINAL.png}. + +% --------------------------------------------------------------------- +\subsection{Model Architecture} + +The pipeline orchestrates \textbf{six specialised models} (load $\to$ use $\to$ +unload to stay below $\sim$10~GB VRAM): + +\begin{table}[h] +\centering +\small +\begin{tabular}{>{\raggedright\arraybackslash}p{3.2cm} >{\raggedright\arraybackslash}p{4.2cm} >{\raggedright\arraybackslash}p{4.5cm} >{\raggedright\arraybackslash}p{2.4cm}} +\toprule +\textbf{Model} & \textbf{Type} & \textbf{Role} & \textbf{Source} \\ +\midrule +Whisper medium & Encoder--decoder Transformer & Speech-to-text (ASR) & OpenAI \\ +YOLOv8n & One-stage CNN detector & Object / packshot detection & Ultralytics \\ +EasyOCR & CRAFT + CRNN & Multilingual OCR & JaidedAI \\ +Phi-3-mini-4k & Decoder-only Transformer & LLM reasoning & Microsoft \\ +MiniLM-L12-v2 & Distilled BERT & Semantic embeddings & SBERT \\ +PharmaLogoNet & Custom CNN (MobileNet-style) & Logo detection & from scratch \\ +MLP Calibration Head & Multi-layer perceptron & Confidence calibration & from scratch \\ +\bottomrule +\end{tabular} +\caption{Models composing the IO6 pipeline.} +\end{table} + +\paragraph{Proprietary innovation — Decisive Post-Processor.} +A module that forces a \verdict{TRUE}/\verdict{FALSE} verdict on claims rated +ambiguous by the LLM, relying on the EU~655/2013 patterns and the ASA UK puffery +jurisprudence. + +\paragraph{Calibration head (MLP, from scratch).} +A small multi-layer perceptron that maps the raw LLM logits/scores to a calibrated +probability $p_i$ (ECE --- Expected Calibration Error --- analysis). + +\paragraph{PharmaLogoNet (CNN from scratch, complementary deliverable).} +MobileNet-inspired architecture: depthwise separable convolutions, +$\sim$1.5~M parameters ($15\times$ lighter than ResNet-50), multi-task learning +with two heads (50-class classification + bounding-box regression), GradCAM on the +last convolutional layer. + +% --------------------------------------------------------------------- +\subsection{Training Strategy} + +\begin{itemize}[noitemsep] + \item \textbf{Pretrained models} --- Whisper, YOLOv8n, EasyOCR, Phi-3 and + MiniLM are used as-is (zero-shot), without retraining. + \item \textbf{LoRA fine-tuning of Phi-3} --- domain adaptation to cosmetics on a + single GPU: rank $r=8$, $\alpha=16$ (PEFT library, quantization via + \texttt{bitsandbytes}). + \item \textbf{MLP calibration head} --- trained on the pipeline outputs to + minimise the calibration error; monitored with \textbf{six loss functions} --- + Cross-Entropy, Brier Score, MSE, MAE, KL Divergence, ECE. + \item \textbf{PharmaLogoNet (from scratch)} --- $\sim$12 epochs on Tesla T4 + ($\sim$1~h), \textbf{adaptive AMP} (auto-detects GPU: P100, T4, V100, A100 $\to$ + FP16/FP32), combined classification loss + custom \textbf{GIoU Loss} + (Rezatofighi, CVPR 2019); mAP@0.5 and mAP@0.5:0.95 metrics re-implemented in + COCO style without torchvision. + \item \textbf{Reproducibility} --- seed 42, JSON exports, Kaggle GPU + environment (T4 / P100 / V100). +\end{itemize} + +% --------------------------------------------------------------------- +\subsection{Explainability Mechanisms} + +In line with Article~13 of the EU AI Act 2024, the module provides an +\textbf{XAI suite with four complementary methods}: + +\begin{enumerate}[label=\textbf{(\alph*)}, noitemsep] + \item \textbf{LIME} (Ribeiro et~al., 2016) --- highlights the words of the claim + that contribute most to the verdict. + \item \textbf{SHAP-like severity scoring} (inspired by Lundberg \& Lee, 2017) --- + a per-claim severity score, aggregated into a scatter plot. + \item \textbf{Counterfactual analysis} (Wachter et~al., 2018) --- ``what minimal + change would flip this verdict from \verdict{FALSE} to \verdict{TRUE}?''. + \item \textbf{Regulatory traceability} --- every verdict cites the EU~655/2013 + article (or the ASA UK jurisprudence) that motivates it. +\end{enumerate} + +\noindent For the CNN deliverable, visual explainability relies on \textbf{GradCAM} +(Selvaraju et~al., 2017) applied to the last convolutional layer of +PharmaLogoNet. A \textbf{PDF explainability report} +(\texttt{Rapport\_Explicabilite\_IO6.pdf}) is generated per claim. + +% --------------------------------------------------------------------- +\subsection{Experimental Results} + +\paragraph{Multimodal pipeline --- vs strict EU~655/2013 Gold Standard.} + +\begin{table}[h] +\centering +\small +\begin{tabular}{l c c} +\toprule +\textbf{Metric} & \textbf{Score} & \textbf{``Excellent'' threshold} \\ +\midrule +Accuracy & \textbf{92.9\%} & $\ge 85\%$ \\ +Precision (macro) & \textbf{94.4\%} & $\ge 85\%$ \\ +Recall (macro) & \textbf{91.7\%} & $\ge 85\%$ \\ +F1-score (macro) & \textbf{92.5\%} & $\ge 85\%$ \\ +F1-score (weighted) & \textbf{92.7\%} & $\ge 85\%$ \\ +F1 \verdict{TRUE} & 90.9\% & $\ge 85\%$ \\ +F1 \verdict{FALSE} & 94.1\% & $\ge 85\%$ \\ +Cohen's Kappa & \textbf{1.0} & $\ge 0.8$ \\ +Cross-validation (3-fold) & \textbf{100\% $\pm$ 0\%} & stable \\ +\bottomrule +\end{tabular} +\caption{IO6 pipeline evaluation results.} +\end{table} + +\paragraph{Key strengths.} +\begin{itemize}[noitemsep] + \item Precision \verdict{TRUE} = 100\% (zero false positive on a legitimate + claim). + \item Recall \verdict{FALSE} = 100\% (no misleading claim missed --- a safety + criterion). + \item Conservative bias by design (safety-first approach for regulatory + compliance). +\end{itemize} + +\paragraph{Computational cost.} +$\sim$71~s per video on Tesla T4 (16~GB VRAM); $\sim$50 videos/hour on a single +GPU; peak VRAM $\sim$10~GB (load $\to$ use $\to$ unload pattern). + +\paragraph{Evaluation artefacts.} +\texttt{batch\_eval\_results.json}, \texttt{final\_model\_evaluation.json}, +\texttt{all\_metrics.json}, \texttt{confusion\_matrix\_normalized.png}, +\texttt{ablation\_study.png}; a 7-page analytical dashboard +(\texttt{Dashboard\_Visuals\_Batch\_Loaded.pdf}) with 6 visualisations +(Trust Score Gauge, Distribution Donut, confidence per claim, SHAP scatter, +EU patterns frequency, verdict-source pie). + +% --------------------------------------------------------------------- +\subsection{Discussion} + +\paragraph{Contributions.} +IO6 shows that an assembly of lightweight open-source models, orchestrated with a +decisive post-processor and an expert knowledge base, reaches ``excellent''-level +performance ($>92\%$ macro F1, $\kappa = 1.0$) while remaining runnable on a +single GPU and fully traceable with respect to EU law. The key innovation is the +\emph{decisive post-processor} that removes the grey zone of ambiguous verdicts, +together with the four-method XAI suite that makes every decision auditable. + +\paragraph{Limitations.} +\begin{itemize}[noitemsep] + \item Knowledge-base coverage --- very new or niche claims may fall back to + \verdict{TO\_VERIFY}. + \item Dependence on ASR and OCR quality (audio noise, stylised fonts, + low-contrast burned-in subtitles). + \item Evaluation on a still limited corpus ($\kappa = 1.0$ but modest $N$) --- + large-scale generalisation to be confirmed. + \item Phi-3-mini --- reasoning bounded by model size; LoRA fine-tuning mitigates + but does not eliminate hallucinations. + \item Conservative bias --- excellent for safety, but may over-flag some + tolerated advertising exaggeration (puffery). +\end{itemize} + +\paragraph{Future work (V2 roadmap).} +\begin{itemize}[noitemsep] + \item Continuous, versioned enrichment of the knowledge base. + \item Visual-claim detection (faked before/after) via the CNN deliverable. + \item Multilingual and multi-sector expansion (food supplements, medical + devices). + \item Advanced calibration (temperature scaling, conformal prediction). + \item Integration with the rest of the \emph{AI Media Credibility} platform + (verification API, unified dashboard). +\end{itemize} + +% --------------------------------------------------------------------- +\subsection*{Key references} +\begin{itemize}[noitemsep,leftmargin=*] + \item Hu et~al., 2022 --- \emph{LoRA: Low-Rank Adaptation of Large Language Models} (ICLR). + \item Ribeiro et~al., 2016 --- \emph{"Why Should I Trust You?": Explaining the Predictions of Any Classifier} (LIME). + \item Lundberg \& Lee, 2017 --- \emph{A Unified Approach to Interpreting Model Predictions} (SHAP). + \item Wachter et~al., 2018 --- \emph{Counterfactual Explanations Without Opening the Black Box}. + \item Selvaraju et~al., 2017 --- \emph{Grad-CAM: Visual Explanations from Deep Networks}. + \item Howard et~al., 2017 --- \emph{MobileNets: Efficient Convolutional Neural Networks for Mobile Vision}. + \item Rezatofighi et~al., 2019 --- \emph{Generalized Intersection over Union} (CVPR). + \item EU 655/2013 --- Commission Regulation laying down common criteria for the justification of claims used in relation to cosmetic products. + \item EU AI Act 2024 --- Article~13 on transparency and explainability of AI systems. +\end{itemize} + +\end{document} diff --git a/yassmine/Pipeline_IO6_Architecture_FINAL.png b/yassmine/Pipeline_IO6_Architecture_FINAL.png new file mode 100644 index 0000000000000000000000000000000000000000..e57b0c467bed4c64cc5469aafbf4241cdb0ae73d --- /dev/null +++ b/yassmine/Pipeline_IO6_Architecture_FINAL.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d738c75880e77639d5c3f1d85845f78eed93cc9b3c204e056204d7d3ed9b04b +size 595697 diff --git a/yassmine/README.md b/yassmine/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d6d51682ee22262dc35203662026ebbad93209fa --- /dev/null +++ b/yassmine/README.md @@ -0,0 +1,257 @@ +# IO6 — Multimodal Fact-Checking Pipeline for Cosmetic Advertising + +> AI Media Credibility Platform · Automated fact-checking of cosmetic advertising claims using multimodal deep learning, compliant with EU Regulation 655/2013 and the EU AI Act 2024. + +--- + +## Overview + +This project, **IO6 — Multimodal Fact-Checking Pipeline**, was developed as part of the coursework at **[Esprit School of Engineering](https://esprit.tn)** (3rd year AI), in the context of the **AI Media Credibility Platform** Challenge-Based Learning (CBL) project. It addresses the growing problem of misleading claims in cosmetic advertising by combining **audio, visual, and textual analysis** into a single explainable AI system, fully aligned with **European Union regulations** on cosmetic claims (EU 655/2013) and AI explainability (EU AI Act 2024 Article 13). + +The repository contains **two complementary deliverables** : + +1. **CNN from Scratch** — A custom convolutional neural network (PharmaLogoNet) built from scratch for pharmaceutical/cosmetic brand logo detection in images, with multi-task learning (classification + bounding box regression) and GradCAM explainability. +2. **Multimodal Pipeline** — An end-to-end fact-checking pipeline that processes video advertisements through six specialized open-source models (Whisper, YOLOv8, EasyOCR, Phi-3, MiniLM, MLP) and outputs structured verdicts (TRUE / FALSE / TO_VERIFY) with full XAI traceability. + +--- + +## Features + +### Multimodal Pipeline + +- **Multimodal extraction** combining audio (Whisper ASR), vision (YOLOv8 object detection + EasyOCR text recognition), and reasoning (Phi-3 LLM + MiniLM semantic embeddings). +- **Knowledge Base** with 309+ entries across 8 sheets (brands, fake claims, EU 655/2013 patterns, ingredients, certifications) — fully editable by domain experts. +- **LoRA fine-tuning** of Phi-3 (rank=8, alpha=16) for domain adaptation on a single GPU. +- **Decisive Post-Processor** — proprietary innovation that forces TRUE/FALSE verdicts on ambiguous claims using EU 655/2013 patterns and ASA UK puffery jurisprudence. +- **Explainability suite (XAI)** with four complementary methods : LIME word-level highlighting, SHAP-like severity scoring, counterfactual analysis (Wachter 2018), and traceability with EU article citations. +- **Five evaluation protocols** : validated curated cases, extended stratified sample (N=30, Cohen's κ=1.0), 3-fold cross-validation (100% ± 0%), multi-video operational batch (F1 macro 92.5%), and ablation study. +- **Six loss functions** suite : Cross-Entropy, Brier Score, MSE, MAE, KL Divergence, ECE — for full calibration analysis. +- **Automated PDF report generation** for compliance officers, including verdict, confidence, justifications, and EU article citations. +- **Final analytical dashboard** with six visualizations (Trust Score Gauge, Distribution Donut, Confidence per claim, SHAP Scatter, EU Patterns frequency, Verdict Source Pie). + +### CNN from Scratch + +- **Custom architecture (PharmaLogoNet)** with depthwise separable convolutions inspired by MobileNet (~1.5M parameters, 15× lighter than ResNet-50). +- **Multi-task learning** with two parallel heads : classification (50 logo classes) and bounding box regression. +- **Custom GIoU Loss** implemented from scratch (Rezatofighi CVPR 2019) for better gradient flow when bboxes don't overlap. +- **mAP@0.5 and mAP@0.5:0.95** implemented from scratch in COCO style — no torchvision dependency. +- **Adaptive AMP** (Automatic Mixed Precision) — auto-detects GPU (P100, T4, V100, A100) and adjusts FP16/FP32 accordingly. +- **GradCAM explainability** (Selvaraju 2017) on the last convolutional layer. + +### Cross-cutting features + +- **Reproducibility** : fixed random seeds (42), open-source models, JSON exports of all results. +- **Compliance** : aligned with EU 655/2013 cosmetic claims regulation and EU AI Act 2024 Article 13 (Right to Explanation). +- **Documentation** : 104-cell main notebook + 47-cell CNN notebook + Dataset Card + Ethics & Bias Statement + 23 academic references. + +--- + +## Tech Stack + +### Core ML / Deep Learning + +- **PyTorch** 2.5.1+cu121 — primary deep learning framework +- **Transformers** 4.44.2 (HuggingFace) — Phi-3, MiniLM +- **PEFT** — LoRA fine-tuning +- **Sentence-Transformers** — multilingual semantic embeddings +- **Ultralytics YOLOv8** — object detection +- **OpenAI Whisper** — speech-to-text +- **EasyOCR** (JaidedAI) — multilingual OCR +- **scikit-learn** — classical metrics and cross-validation + +### Models Used + +| Model | Type | Role | Source | +|-------|------|------|--------| +| **Whisper medium** | Transformer encoder-decoder | Speech-to-text (ASR) | OpenAI | +| **YOLOv8n** | CNN one-stage detector | Object detection | Ultralytics | +| **EasyOCR** | CRAFT + CRNN | Multilingual OCR | JaidedAI | +| **Phi-3-mini-4k** | Decoder-only Transformer | LLM reasoning | Microsoft | +| **MiniLM-L12-v2** | Distilled BERT | Semantic embeddings | Sentence-Transformers | +| **PharmaLogoNet** | Custom CNN (MobileNet-inspired) | Logo detection | Built from scratch | +| **MLP Calibration Head** | Multi-Layer Perceptron | Confidence calibration | Built from scratch | + +### Data Processing + +- **FFmpeg** — video preprocessing (audio extraction, frame sampling) +- **OpenCV** + **Pillow** — image processing +- **pandas** — tabular data manipulation +- **openpyxl** — Knowledge Base management (Excel) + +### Visualization & Reporting + +- **matplotlib** + **seaborn** — analytical charts +- **ReportLab** — automated PDF report generation +- **IPython / Jupyter** — interactive notebooks +- **Kaggle** — GPU compute environment (Tesla T4, P100, V100) + +### Regulations & Standards + +- **EU 655/2013** — Cosmetic claims regulation +- **EU AI Act 2024** — Article 13 (Right to Explanation) +- **ASA UK** — Cosmetic puffery jurisprudence +- **PASCAL VOC XML** — annotation format +- **COCO** — mAP metric standard + +--- + +## Directory Structure + +``` +. +├── README.md # This file +├── notebook_io6_final.ipynb # Main multimodal pipeline notebook (104 cells) +├── cnn-from-scratch-model.ipynb # CNN from scratch notebook (47 cells) +│ +├── architecture/ # Pipeline architecture diagrams +│ ├── Pipeline_IO6_Architecture_v2.png # Multimodal pipeline schema +│ ├── Pipeline_CNN_FromScratch.png # CNN from scratch schema +│ └── Pipeline_MLP_Calibration_FromScratch.png # MLP calibration head schema +│ +├── data/ # Knowledge Base + reference data +│ └── IO6_Base_Reference_V3_FULL_ENRICHED.xlsx # 309+ entries, 8 sheets +│ +├── docs/ # Documentation & guides +│ ├── IO6_Project_Summary_EN.pdf # Project summary (English) +│ ├── IO6_Guide_Notebook_FR.pdf # Notebook walkthrough (French) +│ ├── CNN_Guide_Notebook_FR.pdf # CNN walkthrough (French) +│ ├── Guide_Dashboard_IO6_Defense.pdf # Defense dashboard guide +│ ├── Challenges_Investigate_Rejected.png # Rejected approaches schema +│ └── Limitations_Future_Work.png # Limitations + V2 roadmap +│ +├── reports/ # Generated reports +│ ├── Dashboard_Visuals_Batch_Loaded.pdf # 7-page analytical dashboard +│ ├── Rapport_Explicabilite_IO6.pdf # Per-claim explainability report +│ └── Rapport_Video_*.pdf # Per-video compliance reports +│ +└── results/ # Evaluation results + ├── batch_eval_results.json # Multi-video batch results + ├── final_model_evaluation.json # Final metrics + ├── all_metrics.json # All loss functions + ├── confusion_matrix_normalized.png # Confusion matrix + └── ablation_study.png # Ablation study chart +``` + +--- + +## Getting Started + +### Prerequisites + +- **Python** 3.10+ +- **CUDA-capable GPU** (recommended : Tesla T4, P100, V100, or A100 with at least 16 GB VRAM) +- **FFmpeg** installed system-wide +- **Kaggle account** (for free GPU access) or local CUDA setup + +### Installation + +```bash +# Clone the repository +git clone https://github.com//io6-multimodal-fact-checking.git +cd io6-multimodal-fact-checking + +# Install dependencies +pip install -q openai-whisper ultralytics easyocr transformers accelerate \ + bitsandbytes sentence-transformers openpyxl jiwer reportlab \ + peft scikit-learn pandas matplotlib seaborn + +# Pin compatible versions (see notebook for details) +pip install Pillow==10.4.0 numpy==1.26.4 +``` + +### Running the Multimodal Pipeline + +```bash +# Open the main notebook in Jupyter or Kaggle +jupyter notebook notebook_io6_final.ipynb + +# Run all cells in order — the pipeline will : +# 1. Load 6 open-source models +# 2. Extract audio + frames from input video +# 3. Run multimodal extraction (Whisper + YOLO + OCR + Phi-3) +# 4. Verify each claim against Knowledge Base + EU patterns +# 5. Generate verdicts with XAI explanations +# 6. Output a compliance PDF report +``` + +### Running the CNN from Scratch + +```bash +# Open the CNN notebook +jupyter notebook cnn-from-scratch-model.ipynb + +# The notebook auto-detects your GPU and adapts batch size + AMP accordingly +# Training takes ~12 epochs on Tesla T4 (~1 hour) +``` + +### Test on a Sample Video + +```python +from process_video import process_video + +result = process_video("path/to/cosmetic_ad.mp4") +print(f"Verdict : {result['global_verdict']}") +print(f"Trust Score : {result['trust_score']}%") +print(f"Claims TRUE : {result['n_true']} | FALSE : {result['n_false']}") +``` + +--- + +## Results + +### Multimodal Pipeline (vs strict EU 655/2013 Gold Standard) + +| Metric | Score | Threshold (Excellent) | +|--------|-------|-----------------------| +| **Accuracy** | **92.9%** | ≥ 85% | +| **Precision (macro)** | **94.4%** | ≥ 85% | +| **Recall (macro)** | **91.7%** | ≥ 85% | +| **F1 Score (macro)** | **92.5%** | ≥ 85% | +| **F1 Score (weighted)** | **92.7%** | ≥ 85% | +| **F1 TRUE** | 90.9% | ≥ 85% | +| **F1 FALSE** | 94.1% | ≥ 85% | +| **Cohen's Kappa** | **1.0** | ≥ 0.8 | +| **Cross-Validation (3-fold)** | **100% ± 0%** | Stable | + +**Key strengths :** +- Precision TRUE = 100% (zero false positive on legitimate claims) +- Recall FALSE = 100% (zero missed misleading claim — critical for safety) +- Conservative bias by design (safety-first approach for regulatory compliance) + +### Computational Cost + +- **~71 seconds per video** on Tesla T4 (16 GB VRAM) +- ~50 videos per hour on a single GPU +- VRAM peak : ~10 GB (load → use → unload pattern) + +--- + +## Acknowledgments + +This project was completed under the supervision of the academic team at **[Esprit School of Engineering](https://esprit.tn)**, as part of the IO6 — System Integration objective in the AI specialization (3rd year). + +Special thanks to the open-source community for the foundational models that made this pipeline possible : OpenAI (Whisper), Microsoft (Phi-3), Ultralytics (YOLOv8), JaidedAI (EasyOCR), and the Sentence-Transformers / HuggingFace ecosystem. + +### Academic References + +- **Hu et al., 2022** — *LoRA: Low-Rank Adaptation of Large Language Models* (ICLR) +- **Ribeiro et al., 2016** — *"Why Should I Trust You?": Explaining the Predictions of Any Classifier* (LIME) +- **Lundberg & Lee, 2017** — *A Unified Approach to Interpreting Model Predictions* (SHAP) +- **Wachter et al., 2018** — *Counterfactual Explanations Without Opening the Black Box* +- **Selvaraju et al., 2017** — *Grad-CAM: Visual Explanations from Deep Networks* +- **Howard et al., 2017** — *MobileNets: Efficient Convolutional Neural Networks for Mobile Vision* +- **Rezatofighi et al., 2019** — *Generalized Intersection over Union* (CVPR) +- **EU 655/2013** — Commission Regulation laying down common criteria for the justification of claims used in relation to cosmetic products +- **EU AI Act 2024** — Article 13 on transparency and explainability of AI systems + +--- + +## License + +This project is released for academic and research purposes under the MIT License. The pretrained models retain their original licenses (MIT for Whisper and Phi-3, AGPL for YOLOv8, Apache 2.0 for EasyOCR and Sentence-Transformers). + +--- + +## Topics + +`python` `machine-learning` `deep-learning` `multimodal-ai` `fact-checking` `computer-vision` `natural-language-processing` `cnn-from-scratch` `pytorch` `transformers` `whisper` `yolov8` `phi-3` `lora-fine-tuning` `xai` `lime` `shap` `gradcam` `eu-ai-act` `cosmetic-claims` `regulatory-compliance` `kaggle` `jupyter-notebook` `esprit-school-of-engineering` `cbl` `system-integration` diff --git a/yolov8n.pt b/yolov8n.pt new file mode 100644 index 0000000000000000000000000000000000000000..719e6f1dbdfe7c560e5933fc8b0c5a7e857d0234 --- /dev/null +++ b/yolov8n.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f59b3d833e2ff32e194b5bb8e08d211dc7c5bdf144b90d2c8412c47ccfc83b36 +size 6549796