feat(design): refonte hi-fi (tokens, pills glyphes, dark mode, accueil)
All checks were successful
Deploy info-canicule / deploy (push) Successful in 1m30s

Adopte le design system livré par Claude Design (Info Canicule.html) :
palette --paper/--brand/--ink + ramp vigilance, Public Sans + Manrope,
header sticky blurred avec toggle clair/sombre, pills vigilance avec
glyphes ●▲◆■ (a11y daltonisme), home restructurée (hero, stat tiles,
map + sidebar avec recherche département, liste filtrable, CTA conseils).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian 2026-05-27 18:41:57 +02:00
parent a8830a4f34
commit 72b3785499
7 changed files with 1003 additions and 250 deletions

View file

@ -55,17 +55,15 @@ for (const [code] of entries) {
{
entries.map(([code, dept]) => {
const colorId = colorsByDept.get(code) ?? 1;
const color = COLORS[colorId];
return (
<a href={`/departement/${code}`} class="cursor-pointer">
<path
d={dept.d}
data-code={code}
data-name={dept.name}
fill={color.hex}
stroke="#ffffff"
stroke-width="0.8"
class="transition-opacity hover:opacity-80"
class:list={['france-map-dept', `france-map-fill-${colorId}`]}
stroke="var(--paper-2)"
stroke-width="0.5"
/>
</a>
);

View file

@ -6,15 +6,17 @@ interface Props {
colorId: ColorId;
phenomenonId?: PhenomenonId;
showLevel?: boolean;
size?: 'sm' | 'lg';
}
const { colorId, phenomenonId, showLevel = false } = Astro.props;
const { colorId, phenomenonId, showLevel = false, size = 'sm' } = Astro.props;
const phenomenon = phenomenonId ? PHENOMENA[phenomenonId] : null;
const colorClass = `vigilance-chip-${colorId}`;
// Glyphes redondants à la couleur (accessibilité daltonisme + niveaux de gris).
const GLYPHS: Record<ColorId, string> = { 1: '●', 2: '▲', 3: '◆', 4: '■' };
---
<span class:list={['vigilance-chip', colorClass]}>
{phenomenon && <span aria-hidden="true">{phenomenon.emoji}</span>}
<span class:list={['vigilance-chip', `vigilance-chip-${colorId}`, size === 'lg' && 'vigilance-chip-lg']}>
<span class="glyph" aria-hidden="true">{GLYPHS[colorId]}</span>
{phenomenon ? phenomenon.label : COLORS[colorId].name}
{showLevel && <span class="text-xs opacity-75">— {COLOR_LABEL[colorId]}</span>}
</span>

View file

@ -1,22 +1,28 @@
---
import { COLORS, COLOR_LABEL } from '../lib/phenomena';
import VigilanceChip from './VigilanceChip.astro';
import { COLOR_LABEL } from '../lib/phenomena';
import type { ColorId } from '../lib/phenomena';
interface Props {
inline?: boolean;
}
const { inline = false } = Astro.props;
const levels: ColorId[] = [1, 2, 3, 4];
const NAMES: Record<ColorId, string> = { 1: 'Vert', 2: 'Jaune', 3: 'Orange', 4: 'Rouge' };
---
<div class="flex flex-wrap items-center gap-3 text-sm">
<span class="font-semibold text-slate-700">Niveaux :</span>
<div class:list={['flex flex-wrap items-center gap-x-4 gap-y-2 text-sm', inline ? 'flex-row' : 'flex-col items-start sm:flex-row sm:items-center']}>
<span class="kicker shrink-0">Légende</span>
{
levels.map((id) => (
<span class="inline-flex items-center gap-2">
<span
class="inline-block h-4 w-4 rounded border border-slate-300"
style={`background-color: ${COLORS[id].hex};`}
aria-hidden="true"
/>
<span class="capitalize text-slate-700">{COLORS[id].name}</span>
<span class="hidden text-xs text-slate-500 sm:inline">— {COLOR_LABEL[id]}</span>
<VigilanceChip colorId={id} />
<span style="color: var(--ink-soft); font-size: 0.85rem;" class="hidden sm:inline">
{NAMES[id]} — {COLOR_LABEL[id]}
</span>
<span style="color: var(--ink-soft); font-size: 0.85rem;" class="sm:hidden">
{NAMES[id]}
</span>
</span>
))
}

View file

@ -23,6 +23,9 @@ const fullOgImage = ogImage.startsWith('http') ? ogImage : `${SITE}${ogImage}`;
const umamiId = process.env.UMAMI_WEBSITE_ID;
const umamiSrc = process.env.UMAMI_SRC ?? 'https://analytics.nocleus.com/script.js';
const path = Astro.url.pathname;
const isActive = (p: string) => (p === '/' ? path === '/' : path.startsWith(p));
const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
@ -44,7 +47,7 @@ const jsonLd = {
'@type': 'Service',
'@id': `${SITE}/#service`,
name: 'Info Canicule',
serviceType: 'Service d\'information météorologique grand public',
serviceType: "Service d'information météorologique grand public",
areaServed: { '@type': 'Country', name: 'France' },
audience: { '@type': 'PeopleAudience', audienceType: 'Grand public, personnes fragiles' },
provider: {
@ -69,7 +72,7 @@ const jsonLd = {
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content={description} />
{noindex ? <meta name="robots" content="noindex, nofollow" /> : <meta name="robots" content="index, follow, max-image-preview:large" />}
<link rel="canonical" href={canonicalUrl} />
@ -90,68 +93,234 @@ const jsonLd = {
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={fullOgImage} />
<meta name="theme-color" content="#ea580c" />
<meta name="theme-color" content="#fdfaf2" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#14110d" media="(prefers-color-scheme: dark)" />
<meta name="format-detection" content="telephone=yes" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700&family=Manrope:wght@500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
{/* Avoid flash of light theme on dark-preferring devices */}
<script is:inline>
(function () {
try {
var saved = localStorage.getItem('ic-theme');
var mql = window.matchMedia('(prefers-color-scheme: dark)');
var theme = saved || (mql.matches ? 'dark' : 'light');
if (theme === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
} catch (_) {}
})();
</script>
{umamiId && (
<script defer src={umamiSrc} data-website-id={umamiId} data-do-not-track="true"></script>
)}
</head>
<body class="min-h-screen flex flex-col">
<header class="border-b border-slate-200 bg-white">
<div class="container-tight flex flex-wrap items-center justify-between gap-3 py-4">
<a href="/" class="flex items-center gap-2 no-underline">
<img src="/favicon.svg" alt="" width="32" height="32" class="h-8 w-8" />
<span class="text-lg font-bold text-canicule-700">Info Canicule</span>
<body class="flex flex-col" style="min-height: 100dvh;">
<a href="#main" class="skip-link">Aller au contenu principal</a>
<header class="site-header">
<div class="container-tight flex items-center justify-between gap-3 py-3.5">
<a href="/" class="inline-flex items-center gap-2.5 font-display text-[1.1rem] font-extrabold no-underline" style="color: var(--ink);">
<svg width="28" height="28" viewBox="0 0 32 32" aria-hidden="true">
<circle cx="16" cy="16" r="14" fill="var(--sun)" />
<circle cx="16" cy="16" r="11" fill="var(--brand)" />
<path d="M16 7 C12 11 12 16 16 21 C20 16 20 11 16 7 Z" fill="#fff" />
</svg>
<span>Info Canicule</span>
</a>
<nav class="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm font-medium text-slate-600">
<a href="/">Carte</a>
<a href="/conseils">Conseils</a>
<a href="/a-propos">À propos</a>
<a href="/soutenir" class="text-canicule-700">☕ Soutenir</a>
<nav id="nav-main" class="nav-links flex items-center gap-1" aria-label="Navigation principale">
<a href="/" class:list={['nav-link', isActive('/') && 'is-active']}>Carte</a>
<a href="/conseils" class:list={['nav-link', isActive('/conseils') && 'is-active']}>Conseils</a>
<a href="/a-propos" class:list={['nav-link', isActive('/a-propos') && 'is-active']}>À propos</a>
<a href="/soutenir" class:list={['nav-link', isActive('/soutenir') && 'is-active']} style="color: var(--brand-deep);">☕ Soutenir</a>
</nav>
<div class="flex items-center gap-2">
<button
id="theme-toggle"
type="button"
class="icon-btn"
aria-label="Basculer entre mode clair et mode sombre"
title="Basculer mode clair / sombre"
>
<svg id="theme-icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
<svg id="theme-icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:none;">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
<button
id="nav-toggle"
type="button"
class="icon-btn"
data-mobile-only
aria-label="Ouvrir le menu"
aria-expanded="false"
aria-controls="nav-main"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
</div>
</div>
</header>
<main class="flex-1">
<main id="main" class="flex-1">
<slot />
</main>
<footer class="border-t border-slate-200 bg-white">
<div class="container-tight py-6 text-sm text-slate-500">
<div class="grid gap-4 sm:grid-cols-3">
<footer class="site-footer">
<div class="container-tight py-8">
<div class="grid gap-8" style="grid-template-columns: 1.4fr 1fr 1fr 1fr;">
<div>
<div class="flex items-center gap-2">
<img src="/favicon.svg" alt="" width="28" height="28" class="h-7 w-7" />
<p class="font-semibold text-slate-700">Info Canicule</p>
</div>
<p class="mt-1 text-xs">
<a href="/" class="inline-flex items-center gap-2.5 font-display text-[1.05rem] font-extrabold no-underline" style="color: var(--ink);">
<svg width="24" height="24" viewBox="0 0 32 32" aria-hidden="true">
<circle cx="16" cy="16" r="14" fill="var(--sun)" />
<circle cx="16" cy="16" r="11" fill="var(--brand)" />
<path d="M16 7 C12 11 12 16 16 21 C20 16 20 11 16 7 Z" fill="#fff" />
</svg>
Info Canicule
</a>
<p class="mt-2 text-sm" style="color: var(--ink-soft); max-width: 36ch;">
Service d'information publique gratuit, sans publicité, non lucratif.
</p>
</div>
<div>
<p class="font-semibold text-slate-700">Liens</p>
<ul class="mt-1 space-y-0.5">
<li><a href="/a-propos">À propos</a></li>
<li><a href="/mentions-legales">Mentions légales</a></li>
<li><a href="/dependances">Dépendances</a></li>
<li><a href="/soutenir">☕ Soutenir sur Ko-fi</a></li>
<h4>Le site</h4>
<ul class="flex flex-col gap-2 text-sm">
<li><a href="/a-propos" style="color: var(--ink-2);">À propos</a></li>
<li><a href="/conseils" style="color: var(--ink-2);">Conseils par phénomène</a></li>
<li><a href="/soutenir" style="color: var(--ink-2);">☕ Soutenir le projet</a></li>
</ul>
</div>
<div>
<p class="font-semibold text-slate-700">Données</p>
<p class="mt-1 text-xs">
<a href="https://meteo.data.gouv.fr/" rel="noopener">Météo France</a>
— <a href="https://www.etalab.gouv.fr/licence-ouverte-open-licence/" rel="noopener">Licence Ouverte 2.0</a>.
En urgence : <a href="tel:112" class="text-canicule-700 font-semibold">112</a>.
</p>
<h4>Mentions</h4>
<ul class="flex flex-col gap-2 text-sm">
<li><a href="/mentions-legales" style="color: var(--ink-2);">Mentions légales</a></li>
<li><a href="/dependances" style="color: var(--ink-2);">Dépendances</a></li>
<li><a href={`${SITE}/sitemap-index.xml`} style="color: var(--ink-2);">Plan du site</a></li>
</ul>
</div>
<div>
<h4>Données</h4>
<ul class="flex flex-col gap-2 text-sm">
<li>
<a href="https://meteo.data.gouv.fr/" rel="noopener" style="color: var(--ink-2);">Météo France (open data)</a>
</li>
<li>
<a href="https://www.etalab.gouv.fr/licence-ouverte-open-licence/" rel="noopener" style="color: var(--ink-2);">Licence Ouverte 2.0</a>
</li>
<li>
En urgence : <a href="tel:112" style="color: var(--brand-deep); font-weight: 600;">112</a>
</li>
</ul>
</div>
</div>
<p class="mt-4 text-xs text-slate-400">
Édité à titre personnel, sans but lucratif —
<a href="/mentions-legales" class="text-canicule-700">mentions légales</a>.
</p>
<div class="mt-7 flex flex-wrap items-center justify-between gap-3 border-t pt-5 text-xs" style="border-color: var(--line); color: var(--ink-soft);">
<span>Édité à titre personnel, sans but lucratif.</span>
<span>
Données officielles · <a href="https://vigilance.meteofrance.fr/" rel="noopener" style="color: var(--brand-deep);">vigilance.meteofrance.fr</a>
</span>
</div>
</div>
</footer>
<style>
/* Header nav links */
.nav-link {
color: var(--ink-2);
padding: 8px 14px;
border-radius: var(--r-md);
font-weight: 500;
font-size: 0.95rem;
text-decoration: none;
border-bottom: none;
}
.nav-link:hover { background: var(--paper-warm); color: var(--ink); border-bottom: none; }
.nav-link.is-active { color: var(--ink); background: var(--paper-warm); }
@media (max-width: 720px) {
.nav-links {
display: none !important;
}
.nav-links.open {
display: flex !important;
flex-direction: column;
align-items: stretch;
position: fixed;
inset: 60px 0 auto 0;
background: var(--paper-2);
padding: 12px;
border-bottom: 1px solid var(--line);
box-shadow: var(--sh-3);
gap: 4px;
z-index: 49;
}
.nav-links.open .nav-link { width: 100%; padding: 14px 18px; }
}
[data-mobile-only] { display: none !important; }
@media (max-width: 720px) {
[data-mobile-only] { display: inline-flex !important; }
}
@media (max-width: 900px) {
.site-footer > div > div:first-child { grid-template-columns: 1fr 1fr !important; }
}
@media (max-width: 900px) {
.site-footer .grid { grid-template-columns: 1fr 1fr !important; }
}
@media (max-width: 540px) {
.site-footer .grid { grid-template-columns: 1fr !important; }
}
</style>
<script is:inline>
(function () {
var root = document.documentElement;
var toggle = document.getElementById('theme-toggle');
var iconSun = document.getElementById('theme-icon-sun');
var iconMoon = document.getElementById('theme-icon-moon');
function syncIcons() {
var dark = root.getAttribute('data-theme') === 'dark';
if (iconSun) iconSun.style.display = dark ? 'none' : '';
if (iconMoon) iconMoon.style.display = dark ? '' : 'none';
}
syncIcons();
if (toggle) {
toggle.addEventListener('click', function () {
var dark = root.getAttribute('data-theme') === 'dark';
if (dark) { root.removeAttribute('data-theme'); }
else { root.setAttribute('data-theme', 'dark'); }
try { localStorage.setItem('ic-theme', dark ? 'light' : 'dark'); } catch (_) {}
syncIcons();
});
}
var navToggle = document.getElementById('nav-toggle');
var nav = document.getElementById('nav-main');
if (navToggle && nav) {
navToggle.addEventListener('click', function () {
var open = nav.classList.toggle('open');
navToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
nav.addEventListener('click', function (e) {
if (e.target && e.target.tagName === 'A') {
nav.classList.remove('open');
navToggle.setAttribute('aria-expanded', 'false');
}
});
}
})();
</script>
</body>
</html>

View file

@ -2,17 +2,15 @@
import Base from '../layouts/Base.astro';
import FranceMap from '../components/FranceMap.astro';
import DepartementGrid from '../components/DepartementGrid.astro';
import VigilanceLegend from '../components/VigilanceLegend.astro';
import VigilanceChip from '../components/VigilanceChip.astro';
import { getVigilanceSnapshot, maxColorByDepartement, activeAlerts, currentEcheance } from '../lib/vigilance';
import { getDepartement } from '../lib/departements';
import { getVigilanceSnapshot, maxColorByDepartement, currentEcheance } from '../lib/vigilance';
import { getDepartement, DEPARTEMENTS } from '../lib/departements';
import { PHENOMENA } from '../lib/phenomena';
import type { VigilanceAlert } from '../lib/vigilance';
export const prerender = false;
// Force la revalidation côté navigateur — la page change toutes les ~5 min
// (cache Valkey TTL + bulletins MF), donc on évite le cache local agressif
// qui faisait servir une carte de la veille.
// Force la revalidation côté navigateur — la page change toutes les ~5 min.
Astro.response.headers.set('Cache-Control', 'public, max-age=60, must-revalidate');
let snapshot;
@ -23,14 +21,9 @@ try {
error = (e as Error).message;
}
// Determine l'écheance "aujourd'hui réel" — peut être J ou J1 selon l'heure
// (entre minuit et la publication du bulletin suivant ~6h, J du bulletin = hier).
const todayEch: 'J' | 'J1' = snapshot ? currentEcheance(snapshot) : 'J';
// "Demain" n'existe que quand `todayEch === 'J'` : le bulletin n'a pas de J2.
// Entre minuit et la pub ~6h, on est sur J1 = aujourd'hui réel, donc pas de "demain" dispo.
const tomorrowAvailable = todayEch === 'J';
// Onglet sélectionné via ?echeance=tomorrow ; défaut = today.
const requestedView = new URL(Astro.request.url).searchParams.get('echeance');
const view: 'today' | 'tomorrow' =
requestedView === 'tomorrow' && tomorrowAvailable ? 'tomorrow' : 'today';
@ -52,7 +45,6 @@ if (snapshot) {
}
}
// Tri des départements actifs par code (2A/2B inséré après 19).
function deptSortKey(code: string): string {
if (code === '2A') return '19.1';
if (code === '2B') return '19.2';
@ -62,9 +54,33 @@ const activeDeptCodes = [...alertsByDept.keys()].sort((a, b) =>
deptSortKey(a).localeCompare(deptSortKey(b), 'en', { numeric: true }),
);
const totalAlertes = activeDeptCodes.length;
const canicule = alertsToday.filter((a) => a.phenomenonId === 6).length;
const orages = alertsToday.filter((a) => a.phenomenonId === 3).length;
const orange = alertsToday.filter((a) => a.colorId >= 3).length;
// Phénomène dominant (le plus représenté en alerte, niveau max gardé pour la pill)
let dominantId: number | null = null;
let dominantColor = 1;
if (alertsToday.length > 0) {
const tally = new Map<number, { count: number; maxColor: number }>();
for (const a of alertsToday) {
const t = tally.get(a.phenomenonId) ?? { count: 0, maxColor: 1 };
t.count += 1;
if (a.colorId > t.maxColor) t.maxColor = a.colorId;
tally.set(a.phenomenonId, t);
}
let bestCount = 0;
for (const [id, t] of tally) {
if (t.count > bestCount) {
bestCount = t.count;
dominantId = id;
dominantColor = t.maxColor;
}
}
}
const dominant = dominantId ? PHENOMENA[dominantId as keyof typeof PHENOMENA] : null;
const productDate = snapshot?.productDatetime
? new Date(snapshot.productDatetime).toLocaleString('fr-FR', {
dateStyle: 'long',
@ -73,200 +89,409 @@ const productDate = snapshot?.productDatetime
})
: 'inconnu';
// Date de validité de l'écheance affichée (pour clarifier au visiteur).
// On la dérive du "maintenant" Paris (+1 jour si l'onglet "demain" est actif),
// pas d'un sample d'alerte : en jour calme tout est vert, donc pas d'alerte sample.
const fmtParisDate = (d: Date) =>
d.toLocaleDateString('fr-FR', {
weekday: 'long', day: 'numeric', month: 'long', timeZone: 'Europe/Paris',
});
const fmtParisDateShort = (d: Date) =>
d.toLocaleDateString('fr-FR', {
weekday: 'short', day: 'numeric', month: 'short', timeZone: 'Europe/Paris',
});
const nowParis = new Date();
const tomorrowParis = new Date(nowParis.getTime() + 86_400_000);
const todayLabel = fmtParisDate(nowParis);
const tomorrowLabel = fmtParisDate(tomorrowParis);
const dayLabel = view === 'tomorrow' ? tomorrowLabel : todayLabel;
const todayShort = fmtParisDateShort(nowParis);
const tomorrowShort = fmtParisDateShort(tomorrowParis);
// Données pour la recherche département (sérialisées vers le JS client).
const allDepts = DEPARTEMENTS.map((d) => ({ code: d.code, name: d.name }));
---
<Base>
<section class="bg-gradient-to-b from-canicule-50 to-white">
<div class="container-tight py-10">
<h1 class="text-3xl font-bold text-slate-900 sm:text-4xl">
Vigilance Météo France en temps réel
<section class="container-tight" style="padding-block: clamp(28px, 5vw, 56px) 28px;">
{/* Hero */}
<div class="flex flex-col gap-3" style="margin-bottom: 28px;">
<div class="kicker">Vigilance Météo France · <span class="capitalize">{todayLabel}</span></div>
<h1>
Toutes les alertes météo,
<span style="color: var(--brand-deep);">en clair</span>.
</h1>
<p class="mt-2 max-w-2xl text-slate-600">
Carte des alertes Vigilance par département, conseils officiels et numéros d'urgence.
{dayLabel && (
<>
Affichage pour <strong class="capitalize">{dayLabel}</strong>.
</>
)}
<p style="font-size: clamp(1rem, 0.9rem + 0.4vw, 1.18rem); color: var(--ink-2); max-width: 720px; line-height: 1.55;">
Canicule, orages, vent, pluie, neige, avalanches&nbsp;: retrouvez la vigilance de chaque département, ce qu'il faut faire, et prévenez un proche en quelques secondes.
</p>
<p class="mt-1 text-xs text-slate-500">
<p class="kicker" style="text-transform: none; letter-spacing: 0; font-size: 0.78rem;">
Bulletin Météo France émis le {productDate} (heure de Paris).
</p>
</div>
</section>
{
!error && snapshot && (
<section class="container-tight pt-6">
<div role="tablist" aria-label="Échéance de la vigilance" class="inline-flex rounded-lg border border-slate-200 bg-white p-1 text-sm shadow-sm">
{/* Day tabs */}
{!error && snapshot && (
<div role="tablist" aria-label="Échéance de la vigilance" class="flex flex-wrap gap-2" style="margin-bottom: 20px;">
<a
href="/"
role="tab"
aria-selected={view === 'today' ? 'true' : 'false'}
class:list={['ic-btn ic-btn-sm', view === 'today' ? 'ic-btn-primary' : 'ic-btn-ghost']}
style="text-decoration: none;"
>
Aujourd'hui · <span class="capitalize">{todayShort}</span>
</a>
{tomorrowAvailable ? (
<a
href="/"
href="/?echeance=tomorrow"
role="tab"
aria-selected={view === 'today' ? 'true' : 'false'}
class:list={[
'rounded-md px-4 py-1.5 font-medium no-underline transition-colors',
view === 'today'
? 'bg-canicule-700 text-white'
: 'text-slate-700 hover:bg-slate-100',
]}
aria-selected={view === 'tomorrow' ? 'true' : 'false'}
class:list={['ic-btn ic-btn-sm', view === 'tomorrow' ? 'ic-btn-primary' : 'ic-btn-ghost']}
style="text-decoration: none;"
>
Aujourd'hui
<span class="ml-1 hidden text-xs opacity-80 sm:inline capitalize">· {todayLabel}</span>
Demain · <span class="capitalize">{tomorrowShort}</span>
</a>
{tomorrowAvailable ? (
<a
href="/?echeance=tomorrow"
role="tab"
aria-selected={view === 'tomorrow' ? 'true' : 'false'}
class:list={[
'rounded-md px-4 py-1.5 font-medium no-underline transition-colors',
view === 'tomorrow'
? 'bg-canicule-700 text-white'
: 'text-slate-700 hover:bg-slate-100',
]}
>
Demain
<span class="ml-1 hidden text-xs opacity-80 sm:inline capitalize">· {tomorrowLabel}</span>
</a>
) : (
<span
role="tab"
aria-selected="false"
aria-disabled="true"
title="L'échéance « demain » n'est plus disponible : Météo France publie le prochain bulletin vers 6h, qui couvrira à nouveau aujourd'hui + demain."
class="cursor-not-allowed rounded-md px-4 py-1.5 font-medium text-slate-400"
>
Demain
<span class="ml-1 hidden text-xs sm:inline">· en attente du prochain bulletin</span>
</span>
)}
</div>
</section>
)
}
) : (
<span
role="tab"
aria-selected="false"
aria-disabled="true"
title="L'échéance « demain » n'est pas encore disponible : Météo France publie le prochain bulletin vers 6h."
class="ic-btn ic-btn-sm ic-btn-ghost"
style="opacity: 0.55; cursor: not-allowed;"
>
Demain · en attente du prochain bulletin
</span>
)}
</div>
)}
<section class="container-tight py-8">
{
error && (
<div class="rounded border border-red-200 bg-red-50 p-4 text-red-800">
<strong>Données indisponibles.</strong> Réessayer dans quelques minutes. Détail technique :
{error}
</div>
)
}
{error && (
<div class="v-block v-rouge" style="margin-bottom: 24px;">
<strong>Données Météo France momentanément indisponibles.</strong>
<p style="margin-top: 6px; font-size: 0.92rem;">
Réessayez dans quelques minutes. En urgence, consultez
<a href="https://vigilance.meteofrance.fr/" rel="noopener" style="color: inherit; text-decoration: underline;">vigilance.meteofrance.fr</a>.
</p>
</div>
)}
{
!error && (
<div class="grid gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-2xl font-bold text-canicule-700">{canicule}</div>
<div class="text-sm text-slate-600">départements en vigilance canicule</div>
{/* Stat tiles */}
{!error && (
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); margin-bottom: 24px;">
<div class="ic-card" style="padding: 18px;">
<div style="font-family: var(--font-display); font-weight: 800; font-size: clamp(1.6rem, 1.3rem + 1.2vw, 2.4rem); line-height: 1; color: var(--v-jaune); letter-spacing: -0.02em;">
{totalAlertes}
</div>
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-2xl font-bold text-orange-700">{orange}</div>
<div class="text-sm text-slate-600">en niveau orange ou rouge (tous phénomènes)</div>
</div>
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-2xl font-bold text-yellow-700">{orages}</div>
<div class="text-sm text-slate-600">en vigilance orages</div>
<div style="color: var(--ink-soft); font-size: 0.88rem; font-weight: 500; margin-top: 6px;">
départements en alerte
</div>
</div>
)
}
<div class="ic-card" style="padding: 18px;">
<div style="font-family: var(--font-display); font-weight: 800; font-size: clamp(1.6rem, 1.3rem + 1.2vw, 2.4rem); line-height: 1; color: var(--v-orange); letter-spacing: -0.02em;">
{orange}
</div>
<div style="color: var(--ink-soft); font-size: 0.88rem; font-weight: 500; margin-top: 6px;">
en orange ou rouge
</div>
</div>
<div class="ic-card" style="padding: 18px;">
<div style="font-family: var(--font-display); font-weight: 800; font-size: clamp(1.6rem, 1.3rem + 1.2vw, 2.4rem); line-height: 1; color: var(--v-orange); letter-spacing: -0.02em;">
{canicule}
</div>
<div style="color: var(--ink-soft); font-size: 0.88rem; font-weight: 500; margin-top: 6px;">
touchés par canicule
</div>
</div>
<div class="ic-card" style="padding: 18px;">
<div style="font-family: var(--font-display); font-weight: 800; font-size: clamp(1.6rem, 1.3rem + 1.2vw, 2.4rem); line-height: 1; color: var(--v-jaune); letter-spacing: -0.02em;">
{orages}
</div>
<div style="color: var(--ink-soft); font-size: 0.88rem; font-weight: 500; margin-top: 6px;">
vigilance orages
</div>
</div>
</div>
)}
{/* Map + sidebar */}
{!error && (
<div class="grid items-start gap-6 home-grid" style="grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);">
<div class="ic-card" style="padding: clamp(8px, 2vw, 18px);">
<FranceMap colorsByDept={colorsByDept} alertsByDept={alertsByDept} />
<p style="margin-top: 10px; color: var(--ink-soft); font-size: 0.82rem; text-align: center;">
Survolez ou tapotez un département pour le détail, cliquez pour la page complète.
</p>
</div>
<div class="flex flex-col gap-4">
<div class="ic-card">
<div class="kicker" style="margin-bottom: 12px;">Légende vigilance</div>
<div class="flex flex-col gap-2.5">
<div class="flex items-center gap-2.5"><VigilanceChip colorId={1} /> <span style="color: var(--ink-soft); font-size: 0.88rem;">Pas de vigilance particulière</span></div>
<div class="flex items-center gap-2.5"><VigilanceChip colorId={2} /> <span style="color: var(--ink-soft); font-size: 0.88rem;">Soyez attentif</span></div>
<div class="flex items-center gap-2.5"><VigilanceChip colorId={3} /> <span style="color: var(--ink-soft); font-size: 0.88rem;">Soyez très vigilant</span></div>
<div class="flex items-center gap-2.5"><VigilanceChip colorId={4} /> <span style="color: var(--ink-soft); font-size: 0.88rem;">Vigilance absolue</span></div>
</div>
<p style="color: var(--ink-soft); font-size: 0.78rem; margin-top: 12px; line-height: 1.45;">
Chaque niveau a une <strong>couleur</strong> et un <strong>symbole</strong> (●▲◆■) — afin de rester lisible en cas de daltonisme ou d'impression en niveaux de gris.
</p>
</div>
<div class="ic-card">
<div class="kicker">Êtes-vous concerné ?</div>
<h3 style="margin-top: 8px; margin-bottom: 12px;">Voir mon département</h3>
<div class="ic-input" style="position: relative;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--ink-mute); flex-shrink: 0;">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
id="dept-search-input"
type="search"
autocomplete="off"
placeholder="Tapez 04 ou Alpes-de-Haute-Provence…"
aria-label="Rechercher un département"
aria-controls="dept-search-results"
aria-autocomplete="list"
/>
</div>
<ul
id="dept-search-results"
role="listbox"
aria-label="Résultats"
style="display:none; position: absolute; left: 0; right: 0; margin-top: 6px; background: var(--paper-2); border: 1px solid var(--line-strong); border-radius: var(--r-md); box-shadow: var(--sh-3); list-style: none; padding: 6px; z-index: 20; max-height: 260px; overflow-y: auto;"
></ul>
<p style="color: var(--ink-soft); font-size: 0.82rem; margin-top: 10px;">
Cliquez aussi sur la carte ou choisissez dans la liste ci-dessous.
</p>
</div>
<div class="grid gap-3" style="grid-template-columns: 1fr 1fr;">
<div class="ic-card" style="padding: 16px;">
<div class="kicker">Phénomène dominant</div>
{dominant ? (
<>
<div style="margin-top: 10px;">
<strong style="font-size: 1.05rem;">{dominant.label}</strong>
</div>
<div style="margin-top: 8px;">
<VigilanceChip colorId={dominantColor as 1|2|3|4} />
</div>
</>
) : (
<>
<div style="margin-top: 10px;">
<strong style="font-size: 1.05rem;">Calme</strong>
</div>
<div style="margin-top: 8px;">
<VigilanceChip colorId={1} />
</div>
</>
)}
</div>
<div class="ic-card" style="padding: 16px;">
<div class="kicker">Échéance</div>
<div style="font-family: var(--font-display); font-weight: 800; font-size: 1.4rem; margin-top: 10px; line-height: 1.05; letter-spacing: -0.02em; text-transform: capitalize;">
{view === 'tomorrow' ? tomorrowShort : todayShort}
</div>
<div style="color: var(--ink-soft); font-size: 0.85rem; margin-top: 6px;">
{view === 'tomorrow' ? 'Prévisions de demain' : "Données d'aujourd'hui"}
</div>
</div>
</div>
</div>
</div>
)}
{/* Vue par région (liste repliable) */}
{!error && (
<details class="ic-card" style="margin-top: 24px;">
<summary style="cursor: pointer; font-weight: 600; color: var(--ink-2);">
Vue par région (liste complète)
</summary>
<div style="margin-top: 16px;">
<DepartementGrid colorsByDept={colorsByDept} />
</div>
</details>
)}
</section>
{
!error && (
<>
<section class="container-tight pb-2">
<div class="mb-2 flex flex-wrap items-center justify-between gap-3">
<h2 class="text-xl font-semibold text-slate-900 capitalize">
Niveau par département{dayLabel ? ` — ${dayLabel}` : ''}
</h2>
<VigilanceLegend />
</div>
<p class="text-sm text-slate-500">
Survolez un département pour voir le détail des alertes, cliquez pour la page complète.
</p>
</section>
<div class="container-tight pb-4">
<FranceMap colorsByDept={colorsByDept} alertsByDept={alertsByDept} />
{/* Liste des départements en alerte */}
{!error && activeDeptCodes.length > 0 && (
<section class="container-tight" style="padding-block: 32px;" id="liste-depts">
<div class="flex flex-wrap items-center justify-between gap-3" style="margin-bottom: 16px;">
<h2>Départements en alerte ({activeDeptCodes.length})</h2>
<div class="ic-input" style="max-width: 320px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--ink-mute); flex-shrink: 0;">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
id="dept-list-filter"
type="search"
autocomplete="off"
placeholder="Filtrer par numéro ou nom…"
aria-label="Filtrer les départements en alerte"
/>
</div>
<section class="container-tight pb-8">
<details class="rounded border border-slate-200 bg-white p-4">
<summary class="cursor-pointer font-medium text-slate-700">
Vue par région (liste)
</summary>
<div class="mt-4">
<DepartementGrid colorsByDept={colorsByDept} />
</div>
</details>
<p class="mt-4 text-center text-xs text-slate-500">
Source officielle :
<a href="https://vigilance.meteofrance.fr/" rel="noopener" class="text-canicule-700 font-medium">
vigilance.meteofrance.fr
</a>
— toujours s'y référer en cas d'urgence.
</p>
<p class="mt-2 text-center text-xs text-slate-500">
<strong>Outre-mer non couvert</strong> par cette source open data :
<a href="/departement/971" class="text-canicule-700">Guadeloupe</a> ·
<a href="/departement/972" class="text-canicule-700">Martinique</a> ·
<a href="/departement/973" class="text-canicule-700">Guyane</a> ·
<a href="/departement/974" class="text-canicule-700">La Réunion</a> ·
<a href="/departement/976" class="text-canicule-700">Mayotte</a> →
<a href="https://vigilance.meteofrance.fr/" rel="noopener" class="text-canicule-700">vigilance.meteofrance.fr</a>
</p>
</section>
</>
)
}
</div>
<p style="color: var(--ink-soft); font-size: 0.92rem; margin-bottom: 12px;">
Triés par numéro. Un département peut cumuler plusieurs phénomènes (ex&nbsp;: canicule + orages).
</p>
<div class="ic-card" style="padding: 6px;">
<ul id="dept-list" class="grid gap-x-4 list-dept" style="grid-template-columns: 1fr 1fr; list-style: none; padding: 0; margin: 0;">
{activeDeptCodes.map((code) => {
const dept = getDepartement(code);
if (!dept) return null;
const alerts = (alertsByDept.get(code) ?? []).slice().sort((a, b) => b.colorId - a.colorId);
return (
<li data-search={`${code} ${dept.name}`.toLowerCase()}>
<a
href={`/departement/${code}`}
class="dept-row flex flex-wrap items-center justify-between gap-3"
style="padding: 12px 14px; border-bottom: 1px dashed var(--line); border-radius: 6px; color: inherit; text-decoration: none; transition: background-color .12s;"
>
<span class="inline-flex items-baseline gap-2.5" style="min-width: 0;">
<span style="font-family: var(--font-mono); font-size: 0.85rem; color: var(--ink-mute); min-width: 28px;">{code}</span>
<span style="font-weight: 600;">{dept.name}</span>
</span>
<span class="inline-flex flex-wrap items-center gap-1.5" style="flex-shrink: 0;">
{alerts.map((a) => (
<VigilanceChip colorId={a.colorId} phenomenonId={a.phenomenonId} />
))}
</span>
</a>
</li>
);
})}
</ul>
<p id="dept-list-empty" style="display:none; padding: 16px; text-align: center; color: var(--ink-soft);">
Aucun département ne correspond.
</p>
</div>
</section>
)}
{
!error && activeDeptCodes.length > 0 && (
<section class="border-t border-slate-200 bg-white">
<div class="container-tight py-8">
<h2 class="mb-4 text-xl font-semibold text-slate-900">
Départements en alerte ({activeDeptCodes.length})
</h2>
<p class="mb-4 text-sm text-slate-500">
Triés par numéro de département. Un département peut cumuler plusieurs phénomènes (ex: canicule + orages).
</p>
<ul class="space-y-2">
{activeDeptCodes.map((code) => {
const dept = getDepartement(code);
if (!dept) return null;
const alerts = (alertsByDept.get(code) ?? []).slice().sort((a, b) => b.colorId - a.colorId);
return (
<li class="rounded border border-slate-200 px-3 py-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<a href={`/departement/${code}`} class="font-medium no-underline">
<span class="font-mono text-slate-500">{code}</span> · {dept.name}
</a>
<div class="flex flex-wrap gap-1.5">
{alerts.map((a) => (
<VigilanceChip colorId={a.colorId} phenomenonId={a.phenomenonId} />
))}
</div>
</div>
</li>
);
})}
</ul>
</div>
</section>
)
}
{/* CTA banner */}
<section class="container-tight" style="padding-block: 16px 32px;">
<div class="ic-card-soft flex flex-wrap items-center justify-between gap-4" style="padding: 28px;">
<div>
<h3 style="margin-bottom: 6px;">Vous connaissez quelqu'un de fragile&nbsp;?</h3>
<p style="color: var(--ink-soft); max-width: 540px;">
Retrouvez les bons gestes par phénomène (canicule, orages, vent…) à imprimer ou à transmettre à un proche.
</p>
</div>
<a href="/conseils" class="ic-btn ic-btn-brand ic-btn-lg" style="text-decoration: none;">
Voir les conseils →
</a>
</div>
</section>
{/* Outre-mer note */}
<section class="container-tight" style="padding-block: 0 24px;">
<p style="color: var(--ink-soft); font-size: 0.82rem; text-align: center;">
Source officielle :
<a href="https://vigilance.meteofrance.fr/" rel="noopener" style="color: var(--brand-deep); font-weight: 500;">vigilance.meteofrance.fr</a>
— toujours s'y référer en cas d'urgence.
</p>
<p style="color: var(--ink-soft); font-size: 0.82rem; text-align: center; margin-top: 8px;">
<strong>Outre-mer non couvert</strong> par cette source open data :
<a href="/departement/971" style="color: var(--brand-deep);">Guadeloupe</a> ·
<a href="/departement/972" style="color: var(--brand-deep);">Martinique</a> ·
<a href="/departement/973" style="color: var(--brand-deep);">Guyane</a> ·
<a href="/departement/974" style="color: var(--brand-deep);">La Réunion</a> ·
<a href="/departement/976" style="color: var(--brand-deep);">Mayotte</a>
</p>
</section>
<script type="application/json" id="ic-all-depts" set:html={JSON.stringify(allDepts)} />
<style>
@media (max-width: 900px) {
.home-grid { grid-template-columns: 1fr !important; }
}
@media (max-width: 720px) {
.list-dept { grid-template-columns: 1fr !important; }
}
.dept-row:hover, .dept-row:focus-visible {
background-color: var(--paper-warm);
border-bottom-color: var(--line-strong);
}
</style>
<script is:inline>
(function () {
// Filter for "liste des départements en alerte"
var filter = document.getElementById('dept-list-filter');
var list = document.getElementById('dept-list');
var empty = document.getElementById('dept-list-empty');
if (filter && list) {
filter.addEventListener('input', function () {
var q = filter.value.trim().toLowerCase();
var shown = 0;
list.querySelectorAll('li').forEach(function (li) {
var hit = !q || li.getAttribute('data-search').indexOf(q) !== -1;
li.style.display = hit ? '' : 'none';
if (hit) shown++;
});
if (empty) empty.style.display = shown === 0 ? 'block' : 'none';
});
}
// Search-and-go for the sidebar dept search
var dataEl = document.getElementById('ic-all-depts');
var input = document.getElementById('dept-search-input');
var results = document.getElementById('dept-search-results');
if (!dataEl || !input || !results) return;
var ALL = [];
try { ALL = JSON.parse(dataEl.textContent || '[]'); } catch (_) {}
var active = 0;
var matches = [];
function render() {
if (!matches.length) { results.style.display = 'none'; results.innerHTML = ''; return; }
results.style.display = 'block';
results.innerHTML = matches.map(function (d, i) {
return '<li role="option" aria-selected="' + (i === active) + '">' +
'<a href="/departement/' + d.code + '" data-i="' + i + '" style="' +
'display:flex; gap:10px; align-items:center; width:100%; padding:8px 10px;' +
'border-radius:6px; text-decoration:none; color:inherit;' +
(i === active ? 'background:var(--paper-warm);' : '') + '">' +
'<span style="font-family:var(--font-mono);font-size:0.85rem;color:var(--ink-mute);min-width:28px;">' + d.code + '</span>' +
'<span style="font-weight:500;">' + d.name + '</span>' +
'</a>' +
'</li>';
}).join('');
}
input.addEventListener('input', function () {
var q = input.value.trim().toLowerCase();
matches = !q ? [] : ALL.filter(function (d) {
return (d.code + ' ' + d.name).toLowerCase().indexOf(q) !== -1;
}).slice(0, 8);
active = 0;
render();
});
input.addEventListener('keydown', function (e) {
if (!matches.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(matches.length - 1, active + 1); render(); }
if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(0, active - 1); render(); }
if (e.key === 'Enter') {
e.preventDefault();
window.location.href = '/departement/' + matches[active].code;
}
if (e.key === 'Escape') { matches = []; render(); }
});
results.addEventListener('mouseover', function (e) {
var a = e.target.closest('a[data-i]');
if (a) { active = +a.getAttribute('data-i'); render(); }
});
document.addEventListener('click', function (e) {
if (!input.contains(e.target) && !results.contains(e.target)) {
matches = [];
render();
}
});
})();
</script>
</Base>

View file

@ -2,35 +2,386 @@
@tailwind components;
@tailwind utilities;
/* ─── Design tokens ───────────────────────────────────────── */
:root {
--paper: #fdfaf2;
--paper-2: #ffffff;
--paper-elev: #ffffff;
--paper-warm: #f8f1e1;
--ink: #1b1814;
--ink-2: #3a342c;
--ink-soft: #6b6457;
--ink-mute: #968d7d;
--line: #ebe2cb;
--line-strong: #d5c9aa;
--brand: #d97757;
--brand-deep: #b45a3a;
--brand-soft: #fde2d3;
--brand-tint: #fff1e8;
--brand-ink: #6b2c11;
--sun: #f4c97d;
--sun-deep: #c98e2a;
--v-vert: #2f7d3a;
--v-vert-bg: #e8f3e6;
--v-vert-ink: #1b4a22;
--v-jaune: #b08a00;
--v-jaune-bg: #fff5cf;
--v-jaune-ink: #5e4900;
--v-orange: #c25f00;
--v-orange-bg: #ffd9b3;
--v-orange-ink: #5a2a00;
--v-rouge: #c01818;
--v-rouge-bg: #ffb3b3;
--v-rouge-ink: #5a0a0a;
/* Vivid map fills — closer to Météo France's signage */
--map-vert: #c8e3c4;
--map-jaune: #ffdf3d;
--map-orange: #ff9024;
--map-rouge: #e2231a;
--font-ui: 'Public Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-display: 'Manrope', 'Public Sans', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--r-md: 10px;
--r-lg: 16px;
--r-pill: 999px;
--sh-1: 0 1px 2px rgba(27, 24, 20, 0.04);
--sh-2: 0 2px 8px rgba(27, 24, 20, 0.06), 0 1px 2px rgba(27, 24, 20, 0.04);
--sh-3: 0 12px 32px rgba(27, 24, 20, 0.10), 0 2px 6px rgba(27, 24, 20, 0.06);
}
[data-theme='dark'] {
--paper: #14110d;
--paper-2: #1c1814;
--paper-elev: #221d18;
--paper-warm: #2a221a;
--ink: #f7f1e2;
--ink-2: #d8cfba;
--ink-soft: #a89e89;
--ink-mute: #756e60;
--line: #2f2820;
--line-strong: #46402f;
--brand: #ec9272;
--brand-deep: #f4b39a;
--brand-soft: #3d2317;
--brand-tint: #2a1b10;
--brand-ink: #ffd4c1;
--v-vert: #7dd99a;
--v-vert-bg: #15301b;
--v-vert-ink: #b8e8c5;
--v-jaune: #f0c860;
--v-jaune-bg: #36290c;
--v-jaune-ink: #ffe6a3;
--v-orange: #ffa970;
--v-orange-bg: #3a1d0a;
--v-orange-ink: #ffd3b3;
--v-rouge: #ff8a8a;
--v-rouge-bg: #381212;
--v-rouge-ink: #ffc4c4;
--map-vert: #2d5a35;
--map-jaune: #f0c860;
--map-orange: #ff9050;
--map-rouge: #e85050;
--sh-2: 0 2px 8px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.25);
--sh-3: 0 12px 32px rgba(0, 0, 0, 0.45), 0 2px 6px rgba(0, 0, 0, 0.3);
}
@layer base {
html {
@apply text-slate-900 antialiased;
background: var(--paper);
color: var(--ink);
font-family: var(--font-ui);
font-size: 17px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
@apply antialiased;
}
body {
@apply bg-slate-50;
background: var(--paper);
color: var(--ink);
min-height: 100dvh;
transition: background-color .25s ease, color .25s ease;
}
@media (max-width: 720px) {
html { font-size: 16px; }
}
h1, h2, h3, h4 {
font-family: var(--font-display);
color: var(--ink);
letter-spacing: -0.01em;
margin: 0;
}
h1 {
font-size: clamp(2rem, 1.4rem + 2.5vw, 3.25rem);
line-height: 1.05;
font-weight: 800;
letter-spacing: -0.025em;
}
h2 {
font-size: clamp(1.5rem, 1.1rem + 1.5vw, 2.25rem);
line-height: 1.15;
font-weight: 700;
letter-spacing: -0.02em;
}
h3 {
font-size: clamp(1.15rem, 1rem + 0.6vw, 1.5rem);
line-height: 1.25;
font-weight: 700;
}
a {
@apply underline-offset-2 hover:underline;
color: var(--brand-deep);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color .15s;
}
a:hover { border-bottom-color: currentColor; }
*:focus { outline: none; }
*:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
border-radius: 3px;
}
}
@layer components {
.container-tight {
@apply mx-auto w-full max-w-5xl px-4 sm:px-6;
max-width: 1240px;
margin-inline: auto;
width: 100%;
padding-inline: clamp(16px, 4vw, 32px);
}
.kicker {
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-soft);
font-weight: 500;
}
.ic-card {
background: var(--paper-elev);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-1);
padding: 20px;
transition: border-color .15s, box-shadow .15s, transform .15s;
}
.ic-card-soft {
background: var(--paper-warm);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 20px;
}
.ic-card-interactive { cursor: pointer; }
.ic-card-interactive:hover {
border-color: var(--ink-soft);
box-shadow: var(--sh-2);
transform: translateY(-2px);
}
/* Buttons */
.ic-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 18px;
border-radius: var(--r-pill);
border: 1.5px solid transparent;
font: 600 0.95rem/1 var(--font-ui);
cursor: pointer;
transition: transform .08s ease, box-shadow .15s, background-color .15s, border-color .15s, color .15s;
white-space: nowrap;
background: transparent;
color: var(--ink);
text-decoration: none;
}
.ic-btn:hover { transform: translateY(-1px); border-bottom-color: transparent; }
.ic-btn:active { transform: translateY(0); }
.ic-btn-primary { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.ic-btn-primary:hover { background: var(--ink-2); border-color: var(--ink-2); box-shadow: var(--sh-2); }
.ic-btn-brand { background: var(--brand); color: #fff; border-color: var(--brand); }
.ic-btn-brand:hover { background: var(--brand-deep); border-color: var(--brand-deep); box-shadow: var(--sh-2); }
.ic-btn-ghost { background: transparent; color: var(--ink); border-color: var(--line-strong); }
.ic-btn-ghost:hover { background: var(--paper-warm); border-color: var(--ink-soft); }
.ic-btn-sm { min-height: 36px; padding: 0 14px; font-size: 0.85rem; }
.ic-btn-lg { min-height: 52px; padding: 0 24px; font-size: 1rem; }
/* Vigilance pill (legend + chip) */
.vigilance-chip {
@apply inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium ring-1 ring-inset;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var(--r-pill);
border: 1.5px solid;
font-size: 0.82rem;
font-weight: 600;
line-height: 1;
font-family: var(--font-ui);
background: var(--paper-elev);
}
.vigilance-chip-1 {
@apply bg-green-50 text-green-800 ring-green-200;
.vigilance-chip .glyph { font-size: 0.75em; line-height: 1; }
.vigilance-chip-lg { padding: 8px 16px; font-size: 0.95rem; }
.vigilance-chip-lg .glyph { font-size: 0.85em; }
.vigilance-chip-1 { color: var(--v-vert); background: var(--v-vert-bg); border-color: var(--v-vert); }
.vigilance-chip-2 { color: var(--v-jaune); background: var(--v-jaune-bg); border-color: var(--v-jaune); }
.vigilance-chip-3 { color: var(--v-orange); background: var(--v-orange-bg); border-color: var(--v-orange); }
.vigilance-chip-4 { color: var(--v-rouge); background: var(--v-rouge-bg); border-color: var(--v-rouge); }
/* Vigilance block (colored card for hero alerts) */
.v-block {
border-radius: var(--r-lg);
border-width: 1.5px;
border-style: solid;
padding: 20px;
}
.vigilance-chip-2 {
@apply bg-yellow-50 text-yellow-900 ring-yellow-300;
.v-vert { background-color: var(--v-vert-bg); border-color: var(--v-vert); color: var(--v-vert-ink); }
.v-jaune { background-color: var(--v-jaune-bg); border-color: var(--v-jaune); color: var(--v-jaune-ink); }
.v-orange { background-color: var(--v-orange-bg); border-color: var(--v-orange); color: var(--v-orange-ink); }
.v-rouge { background-color: var(--v-rouge-bg); border-color: var(--v-rouge); color: var(--v-rouge-ink); }
/* Input */
.ic-input {
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
width: 100%;
padding: 0 16px;
border-radius: var(--r-pill);
border: 1.5px solid var(--line-strong);
background: var(--paper-2);
font: 500 1rem var(--font-ui);
color: var(--ink);
transition: border-color .15s, box-shadow .15s;
}
.vigilance-chip-3 {
@apply bg-orange-50 text-orange-900 ring-orange-300;
.ic-input:focus-within {
border-color: var(--brand);
box-shadow: 0 0 0 4px var(--brand-tint);
}
.vigilance-chip-4 {
@apply bg-red-50 text-red-900 ring-red-300;
.ic-input input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: inherit;
font: inherit;
min-width: 0;
}
.ic-input input::placeholder { color: var(--ink-mute); }
}
/* Skip link */
.skip-link {
position: absolute;
top: -100px;
left: 8px;
background: var(--ink);
color: var(--paper);
padding: 10px 16px;
border-radius: var(--r-md);
font-weight: 600;
z-index: 1000;
transition: top .15s;
}
.skip-link:focus { top: 8px; border-bottom: none; }
/* Dept row hover (homepage list) */
.dept-row:hover, .dept-row:focus-visible {
background-color: var(--paper-warm);
}
/* Sticky blurred header */
.site-header {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--paper) 90%, transparent);
backdrop-filter: saturate(140%) blur(12px);
border-bottom: 1px solid var(--line);
}
/* Footer */
.site-footer {
margin-top: 80px;
border-top: 1px solid var(--line);
background: var(--paper-warm);
}
.site-footer h4 {
color: var(--ink-soft);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 12px;
font-family: var(--font-mono);
font-weight: 500;
}
/* Map fills (vivid, MF-like) */
.france-map-dept {
transition: filter .15s, stroke-width .15s;
cursor: pointer;
}
.france-map-dept:hover, .france-map-dept:focus {
stroke: var(--ink);
stroke-width: 1.2;
filter: brightness(1.05);
outline: none;
}
.france-map-fill-1 { fill: var(--map-vert); }
.france-map-fill-2 { fill: var(--map-jaune); }
.france-map-fill-3 { fill: var(--map-orange); }
.france-map-fill-4 { fill: var(--map-rouge); }
/* Icon button (header toggles) */
.icon-btn {
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--r-pill);
background: transparent;
border: 1.5px solid var(--line-strong);
cursor: pointer;
color: var(--ink);
transition: background .15s, border-color .15s;
}
.icon-btn:hover { background: var(--paper-warm); border-color: var(--ink-soft); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
transition-duration: .01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
@media print {
.site-header, .site-footer, .no-print { display: none !important; }
body { background: white; color: black; font-size: 12pt; }
.v-block, .ic-card { box-shadow: none !important; break-inside: avoid; }
}