Phases 6 et 7 : application SvelteKit, PWA, systemd, documentation

Application — rendu serveur sur adapter-node, conforme au §3bis :
- tableau de bord, recherche à facettes, fiche détaillée, calendrier
- /a-propos et /methode prérendues ; tout le reste en rendu serveur
- API JSON ouverte : /api/textes, /api/textes/[slug], /api/echeances
- recherche FTS5 avec surlignage et classement bm25 pondéré (le titre pèse
  dix fois plus que les points clés)
- facettes recalculées sur le résultat filtré par les AUTRES facettes : cocher
  un thème doit recompter les statuts encore disponibles, sinon les compteurs
  mentent

Identité OKI sobrifiée : tokens en CSS vanilla, thème sombre par défaut et
clair opt-in, or comme seule couleur d'action, flag-bar une fois par écran,
polices Archivo/Inter auto-hébergées. Politique de sécurité de contenu
interdisant toute requête tierce. Les statuts ne reposent jamais sur la seule
couleur : libellé en toutes lettres et pastille de forme distincte.

Budget respecté très largement : 6,6 Ko de JavaScript sur l'accueil (3 Ko
compressés) pour un plafond de 100 Ko. Les faits clés — AFD à 500 €, saisine
2026-915 DC, statut — sont lisibles sans JavaScript.

Trois défauts trouvés en testant l'application, pas en relisant le code :
- v_textes est une vue, et une vue SQLite n'a pas de rowid : la jointure avec
  l'index plein texte échouait (migration 005)
- ppl-montagne remontait en « pertinence forte » alors que le corpus dit
  « sans portée pour la Guadeloupe » — la cotation comptait le mot sans lire
  la négation. Idem pour pjl-logement et accord-globe. Corrigé par une lecture
  du voisinage, avec quatre tests de non-régression.
- les libellés d'affichage étaient importés depuis /server dans un
  composant, ce que SvelteKit interdit à raison

PWA : service worker réseau-d'abord pour les pages, cache-d'abord pour le
coffre du build. Une veille législative ne doit pas servir une page périmée
quand le réseau répond — une date de promulgation change tout. Les réponses
issues du cache portent un en-tête qui le dit.

systemd : timer du pipeline (6 h, 18 h, dimanche 9 h, avec dispersion et
rattrapage), application activée par socket avec mise en sommeil à 300 s.
Unités durcies, validées par systemd-analyze.

Documentation : README d'installation sur Debian/Ubuntu vierge,
README-pipeline avec le piège des deux couples d'identifiants PISTE, et
scripts/verifier-conformite.sh qui contrôle mécaniquement le §3bis et le §8.

172 tests, npm run check à 0 erreur, make verifier au vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Cyber Mawonaj
2026-07-25 22:34:22 -04:00
co-authored by Claude Opus 5
parent feb5544599
commit 34d81c66a8
52 changed files with 7459 additions and 40 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.svelte-kit/
build/
static/polices/
+3
View File
@@ -0,0 +1,3 @@
engine-strict=false
fund=false
audit=false
+2304
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "veille-legislative-971-web",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Interface de la veille législative Guadeloupe — SvelteKit, rendu serveur",
"license": "AGPL-3.0-or-later",
"scripts": {
"dev": "vite dev --port 5971",
"build": "vite build",
"preview": "vite preview",
"start": "node build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"polices": "node scripts/copier-polices.js"
},
"devDependencies": {
"@fontsource/archivo": "5.1.0",
"@fontsource/inter": "5.1.0",
"@sveltejs/adapter-node": "5.2.12",
"@sveltejs/kit": "2.17.2",
"@sveltejs/vite-plugin-svelte": "5.0.3",
"@types/better-sqlite3": "7.6.12",
"svelte": "5.20.2",
"svelte-check": "4.1.4",
"typescript": "5.7.3",
"vite": "6.1.0"
},
"dependencies": {
"better-sqlite3": "11.8.1"
}
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Copie les polices Archivo et Inter depuis node_modules vers static/polices/.
*
* Le self-hébergement n'est pas une préférence : la politique de sécurité de
* contenu déclarée dans svelte.config.js interdit toute requête vers un hôte
* tiers, Google Fonts compris.
*/
import { copyFileSync, mkdirSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const DESTINATION = 'static/polices';
mkdirSync(DESTINATION, { recursive: true });
const familles = [
{ paquet: '@fontsource/archivo', graisses: ['400', '600', '700', '900'] },
{ paquet: '@fontsource/inter', graisses: ['400', '500', '600', '700'] }
];
let copiees = 0;
for (const { paquet, graisses } of familles) {
const source = join('node_modules', paquet, 'files');
for (const fichier of readdirSync(source)) {
const estLatin = fichier.includes('-latin-') && fichier.endsWith('.woff2');
const estNormal = fichier.includes('-normal.woff2');
const graisseVoulue = graisses.some((g) => fichier.includes(`-${g}-`));
if (estLatin && estNormal && graisseVoulue) {
copyFileSync(join(source, fichier), join(DESTINATION, fichier));
copiees += 1;
}
}
}
console.log(`${copiees} fichiers de police copiés dans ${DESTINATION}`);
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="fr" %sveltekit.theme%>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#0D0D0D" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<link
rel="preload"
href="%sveltekit.assets%/polices/archivo-latin-700-normal.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="%sveltekit.assets%/polices/inter-latin-400-normal.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<!--
Restauration du thème avant le premier rendu, pour éviter le flash de
thème clair. Le script est externe : la politique de sécurité de contenu
interdit l'inline.
-->
<script src="%sveltekit.assets%/theme.js"></script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -0,0 +1,48 @@
<script lang="ts">
/** Cotation de pertinence guadeloupéenne, avec son motif quand il existe. */
interface Props {
pertinence: string | null;
note?: string | null;
aVerifier?: boolean;
}
const { pertinence, note = null, aVerifier = false }: Props = $props();
const LIBELLES: Record<string, string> = {
forte: 'Gwadloup : forte',
moyenne: 'Gwadloup : moyenne',
faible: 'Gwadloup : faible'
};
const OPACITES: Record<string, string> = { forte: '100%', moyenne: '70%', faible: '45%' };
</script>
{#if pertinence}
<span
class="badge"
style="--intensite: {OPACITES[pertinence] ?? '50%'}"
title={note ?? undefined}
>
{LIBELLES[pertinence] ?? pertinence}{#if aVerifier}<abbr title="Cotation déduite automatiquement, non relue">*</abbr>{/if}
</span>
{/if}
<style>
.badge {
display: inline-flex;
align-items: center;
font-size: 0.76rem;
font-weight: 600;
padding: 0.16em 0.6em;
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--turquoise-caraibes) var(--intensite), transparent);
background: color-mix(in srgb, var(--turquoise-caraibes) 12%, transparent);
color: var(--blanc-creme);
white-space: nowrap;
}
abbr {
text-decoration: none;
color: var(--or-oki);
margin-left: 0.15em;
cursor: help;
}
</style>
+71
View File
@@ -0,0 +1,71 @@
<script lang="ts">
/**
* Badge de statut.
*
* L'accessibilité daltonienne interdit de faire porter l'information par la
* seule couleur : chaque statut porte donc son libellé en toutes lettres et
* un motif de bordure distinct. La couleur ne fait que renforcer.
*/
import { LIBELLES_STATUT } from '$lib/libelles';
interface Props {
statut: string;
devantCc?: boolean;
compact?: boolean;
}
const { statut, devantCc = false, compact = false }: Props = $props();
const COULEURS: Record<string, string> = {
promulguee: 'var(--statut-promulguee)',
validee_cc: 'var(--statut-attente)',
adoptee_non_promulguee: 'var(--statut-attente)',
saisie_cc: 'var(--statut-cc)',
censuree_partiellement: 'var(--statut-cc)',
navette: 'var(--statut-navette)',
deposee_non_examinee: 'var(--statut-projet)',
annoncee: 'var(--statut-projet)',
rejetee: 'var(--statut-rejetee)'
};
const couleur = $derived(COULEURS[statut] ?? 'var(--muted-fort)');
const libelle = $derived(LIBELLES_STATUT[statut] ?? statut);
</script>
<span class="badge" class:compact style="--teinte: {couleur}">
<span class="pastille" aria-hidden="true"></span>
{libelle}
</span>
{#if devantCc && statut !== 'saisie_cc'}
<span class="badge cc" style="--teinte: var(--statut-cc)">
<span class="pastille" aria-hidden="true"></span>
Devant le Conseil constitutionnel
</span>
{/if}
<style>
.badge {
display: inline-flex;
align-items: center;
gap: 0.4em;
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.01em;
padding: 0.16em 0.6em;
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--teinte) 65%, transparent);
background: color-mix(in srgb, var(--teinte) 12%, transparent);
color: var(--blanc-creme);
white-space: nowrap;
}
.badge.compact {
font-size: 0.72rem;
}
.pastille {
width: 0.5em;
height: 0.5em;
background: var(--teinte);
border-radius: 1px;
flex: none;
}
</style>
+129
View File
@@ -0,0 +1,129 @@
<script lang="ts">
/** Carte compacte de résultat : titre, badges, date clé, résumé deux lignes. */
import BadgeGuadeloupe from './BadgeGuadeloupe.svelte';
import BadgeStatut from './BadgeStatut.svelte';
import { LIBELLES_THEME, LIBELLES_TYPE } from '$lib/libelles';
import { dateLongue, extrait } from '$lib/format';
import type { Texte } from '$lib/types';
const { texte }: { texte: Texte } = $props();
const dateCle = $derived(
texte.date_promulgation ?? texte.date_adoption ?? texte.statut_date ?? null
);
const libelleDate = $derived(
texte.date_promulgation
? `Promulguée le ${dateLongue(texte.date_promulgation)}`
: texte.date_adoption
? `Adoptée le ${dateLongue(texte.date_adoption)}`
: dateCle
? dateLongue(dateCle)
: ''
);
</script>
<article class="carte resultat">
<h3>
<a href="/textes/{texte.id}">
{#if texte.numero_officiel}<span class="numero">{texte.numero_officiel}</span>{/if}
{texte.titre_court}
</a>
</h3>
<div class="ligne badges">
<BadgeStatut statut={texte.statut} devantCc={texte.devant_cc === 1} compact />
<BadgeGuadeloupe
pertinence={texte.guadeloupe_pertinence}
note={texte.guadeloupe_note}
aVerifier={texte.a_verifier === 1}
/>
<span class="type">{LIBELLES_TYPE[texte.type] ?? texte.type}</span>
</div>
{#if libelleDate}<p class="date">{libelleDate}</p>{/if}
{#if texte.extrait}
<p class="resume">{@html texte.extrait}</p>
{:else if texte.resume}
<p class="resume">{extrait(texte.resume, 200)}</p>
{:else if texte.points_cles.length}
<p class="resume">{extrait(texte.points_cles[0], 200)}</p>
{/if}
{#if texte.themes.length}
<div class="ligne themes">
{#each texte.themes as theme (theme)}
<span class="tag">{LIBELLES_THEME[theme] ?? theme}</span>
{/each}
</div>
{/if}
<p class="meta">
{texte.nb_sources}
{texte.nb_sources > 1 ? 'sources' : 'source'}
{#if texte.a_verifier === 1}
· <span class="verifier">à vérifier</span>
{/if}
</p>
</article>
<style>
.resultat {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
h3 {
margin: 0;
font-size: 1rem;
line-height: 1.3;
text-transform: none;
}
h3 a {
color: var(--blanc-creme);
text-decoration: none;
}
h3 a:hover {
color: var(--or-oki);
text-decoration: underline;
}
.numero {
font-family: var(--font-mono);
font-size: 0.82em;
color: var(--or-oki);
margin-right: 0.35em;
}
.badges {
gap: 0.4rem;
}
.type {
font-size: 0.76rem;
color: var(--muted-fort);
}
.date {
margin: 0;
font-size: 0.82rem;
color: var(--muted);
}
.resume {
margin: 0;
font-size: 0.9rem;
color: var(--muted);
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.themes {
gap: 0.3rem;
}
.meta {
margin: 0;
font-size: 0.75rem;
color: var(--muted-fort);
}
.verifier {
color: var(--or-oki);
}
</style>
@@ -0,0 +1,159 @@
<script lang="ts">
/**
* Panneau de filtres à facettes.
*
* Chaque facette est un lien : la page fonctionne sans JavaScript, et l'état
* des filtres vit dans l'URL, donc se partage et se recharge à l'identique.
* Les compteurs sont recalculés sur le résultat filtré par les *autres*
* facettes — cocher un thème doit recompter les statuts encore disponibles.
*/
import type { Facette, Filtres, Resultats } from '$lib/types';
interface Props {
facettes: Resultats['facettes'];
filtres: Filtres;
lien: (champ: keyof Filtres, valeur: string) => string;
}
const { facettes, filtres, lien }: Props = $props();
const GROUPES: { champ: keyof Filtres; titre: string; cle: keyof Resultats['facettes'] }[] = [
{ champ: 'statut', titre: 'Statut', cle: 'statut' },
{ champ: 'guadeloupe', titre: 'Pertinence Guadeloupe', cle: 'guadeloupe' },
{ champ: 'impact', titre: 'Impact sur les libertés', cle: 'impact' },
{ champ: 'theme', titre: 'Thème', cle: 'theme' },
{ champ: 'type', titre: 'Type de texte', cle: 'type' },
{ champ: 'confiance', titre: 'Niveau de confiance', cle: 'confiance' }
];
function ouvertParDefaut(champ: keyof Filtres, liste: Facette[]): boolean {
return (filtres[champ] as string[]).length > 0 || liste.some((f) => f.actif) ||
champ === 'statut' || champ === 'guadeloupe';
}
</script>
<aside class="panneau-facettes" aria-label="Filtres">
{#each GROUPES as groupe (groupe.champ)}
{@const liste = facettes[groupe.cle]}
{#if liste.length}
<details class="groupe" open={ouvertParDefaut(groupe.champ, liste)}>
<summary>
{groupe.titre}
{#if (filtres[groupe.champ] as string[]).length}
<span class="compteur-actifs">{(filtres[groupe.champ] as string[]).length}</span>
{/if}
</summary>
<ul>
{#each liste as facette (facette.valeur)}
<li>
<!--
Ce sont des liens, pas des cases à cocher : `aria-pressed` n'est pas
valide sur le rôle `link`. L'état est donc porté par un texte
réservé aux lecteurs d'écran, qui dit aussi ce que fera le clic.
-->
<a
href={lien(groupe.champ, facette.valeur)}
class="facette"
class:actif={facette.actif}
rel="nofollow"
>
<span class="case" aria-hidden="true">{facette.actif ? '×' : '+'}</span>
<span class="libelle">{facette.libelle}</span>
<span class="nombre">{facette.nombre}</span>
<span class="visuellement-cache">
{facette.actif
? 'filtre actif, activer pour le retirer'
: `${facette.nombre} résultats, activer pour filtrer`}
</span>
</a>
</li>
{/each}
</ul>
</details>
{/if}
{/each}
</aside>
<style>
.panneau-facettes {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.groupe {
border: var(--border-card);
border-radius: var(--radius-md);
background: var(--card-bg);
}
summary {
cursor: pointer;
padding: 0.6rem 0.8rem;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.03em;
display: flex;
align-items: center;
gap: 0.5rem;
}
summary::marker {
color: var(--or-oki);
}
.compteur-actifs {
background: var(--or-oki);
color: var(--noir-oki);
border-radius: var(--radius-sm);
font-size: 0.7rem;
padding: 0 0.4em;
min-width: 1.3em;
text-align: center;
}
ul {
list-style: none;
margin: 0;
padding: 0 0.5rem 0.5rem;
display: flex;
flex-direction: column;
gap: 1px;
}
.facette {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.32rem 0.45rem;
border-radius: var(--radius-sm);
color: var(--blanc-creme);
text-decoration: none;
font-size: 0.85rem;
border: 1px solid transparent;
}
.facette:hover {
background: var(--card-bg-survol);
border-color: var(--line);
}
.facette.actif {
background: color-mix(in srgb, var(--or-oki) 14%, transparent);
border-color: color-mix(in srgb, var(--or-oki) 60%, transparent);
}
.case {
font-family: var(--font-mono);
color: var(--or-oki);
width: 1ch;
flex: none;
}
.libelle {
flex: 1;
}
.nombre {
color: var(--muted-fort);
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
/** Formatage des dates et des libellés, en français. */
const MOIS = [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'
];
/** « 2026-06-12 » → « 12 juin 2026 ». */
export function dateLongue(iso: string | null | undefined): string {
if (!iso) return '';
const [a, m, j] = iso.slice(0, 10).split('-').map(Number);
if (!a || !m || !j) return iso;
return `${j === 1 ? '1ᵉʳ' : j} ${MOIS[m - 1]} ${a}`;
}
/** « 2026-06-12 » → « 12 juin ». */
export function dateCourte(iso: string | null | undefined): string {
if (!iso) return '';
const [, m, j] = iso.slice(0, 10).split('-').map(Number);
if (!m || !j) return iso;
return `${j === 1 ? '1ᵉʳ' : j} ${MOIS[m - 1]}`;
}
export function mois(iso: string): string {
const [a, m] = iso.slice(0, 10).split('-').map(Number);
return `${MOIS[m - 1]} ${a}`;
}
/** Nombre de jours entre deux dates ISO, positif dans le futur. */
export function joursRestants(iso: string, depuis = '2026-07-25'): number {
const cible = new Date(iso.slice(0, 10)).getTime();
const base = new Date(depuis).getTime();
return Math.round((cible - base) / 86_400_000);
}
export function pluriel(n: number, singulier: string, plurielMot?: string): string {
return n > 1 ? (plurielMot ?? `${singulier}s`) : singulier;
}
/** Tronque sur une frontière de mot, sans couper au milieu. */
export function extrait(texte: string | null | undefined, taille = 180): string {
if (!texte) return '';
if (texte.length <= taille) return texte;
const coupe = texte.slice(0, taille);
const espace = coupe.lastIndexOf(' ');
return `${coupe.slice(0, espace > 0 ? espace : taille)}`;
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Libellés d'affichage du vocabulaire métier.
*
* Ce module est partagé serveur et client : il ne touche ni à la base ni au
* système de fichiers. Les composants d'interface en ont besoin, et SvelteKit
* interdit — à raison — d'importer `$lib/server` depuis du code client.
*/
export const LIBELLES_STATUT: Record<string, string> = {
promulguee: 'Promulguée',
adoptee_non_promulguee: 'Adoptée, non promulguée',
saisie_cc: 'Devant le Conseil constitutionnel',
validee_cc: 'Validée, en attente de promulgation',
censuree_partiellement: 'Censurée partiellement',
navette: 'En navette',
deposee_non_examinee: 'Déposée, non examinée',
annoncee: 'Annoncée',
rejetee: 'Rejetée'
};
export const LIBELLES_THEME: Record<string, string> = {
justice: 'Justice',
securite: 'Sécurité',
numerique: 'Numérique',
social: 'Social',
fiscal: 'Fiscal',
environnement: 'Environnement',
agriculture: 'Agriculture',
sante: 'Santé',
memoire_patrimoine: 'Mémoire et patrimoine',
outre_mer: 'Outre-mer',
economie: 'Économie',
migration: 'Migration',
institutions: 'Institutions'
};
export const LIBELLES_TYPE: Record<string, string> = {
loi: 'Loi',
loi_organique: 'Loi organique',
pjl: 'Projet de loi',
ppl: 'Proposition de loi',
ordonnance: 'Ordonnance',
decision_cc: 'Décision du Conseil constitutionnel',
decret: 'Décret',
accord_international: 'Accord international'
};
export const LIBELLES_PERTINENCE: Record<string, string> = {
forte: 'Forte',
moyenne: 'Moyenne',
faible: 'Faible'
};
export const LIBELLES_CONFIANCE: Record<string, string> = {
high: 'Élevée',
medium: 'Moyenne',
low: 'Faible'
};
export const LIBELLES_IMPACT: Record<string, string> = {
'individus:positif': 'Individus — droits étendus',
'individus:negatif': 'Individus — droits restreints',
'individus:mixte': 'Individus — effet mixte',
'associations:positif': 'Associations — droits étendus',
'associations:negatif': 'Associations — droits restreints',
'associations:mixte': 'Associations — effet mixte',
'entreprises:positif': 'Entreprises — obligations allégées',
'entreprises:negatif': 'Entreprises — obligations renforcées',
'entreprises:mixte': 'Entreprises — effet mixte'
};
export const LIBELLES_ETAPE: Record<string, string> = {
depot: 'Dépôt',
adoption_1re_lecture: 'Adoption en première lecture',
adoption_definitive: 'Adoption définitive',
commission_mixte_paritaire: 'Commission mixte paritaire',
transmission: 'Transmission',
saisine_cc: 'Saisine du Conseil constitutionnel',
decision_cc: 'Décision du Conseil constitutionnel',
promulgation: 'Promulgation',
publication_jo: 'Publication au Journal officiel',
entree_vigueur: 'Entrée en vigueur',
rejet: 'Rejet',
annonce: 'Annonce',
autre: 'Étape'
};
export const LIBELLES_RESULTAT_CC: Record<string, string> = {
conforme: 'Conforme',
conforme_avec_reserves: 'Conforme avec réserves',
non_conformite_partielle: 'Non-conformité partielle',
non_conformite_totale: 'Non-conformité totale',
en_instance: 'En instance'
};
+53
View File
@@ -0,0 +1,53 @@
/**
* Connexion SQLite partagée.
*
* La base est ouverte une seule fois, en lecture seule : l'application web ne
* modifie jamais les données, c'est le rôle du pipeline. Le mode WAL permet au
* pipeline d'écrire pendant que l'application lit, sans blocage.
*/
import Database from 'better-sqlite3';
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ICI = dirname(fileURLToPath(import.meta.url));
/** Emplacements possibles de la base, du plus explicite au plus conventionnel. */
function trouverLaBase(): string {
const candidats = [
process.env.VEILLE_DB,
resolve(process.cwd(), '../data/veille.db'),
resolve(process.cwd(), 'data/veille.db'),
resolve(ICI, '../../../../data/veille.db')
].filter(Boolean) as string[];
for (const candidat of candidats) {
if (existsSync(candidat)) return candidat;
}
throw new Error(
`Base introuvable. Essayés : ${candidats.join(', ')}. ` +
'Lancer `make seed` à la racine du dépôt, ou définir VEILLE_DB.'
);
}
let instance: Database.Database | null = null;
export function base(): Database.Database {
if (instance) return instance;
instance = new Database(trouverLaBase(), { readonly: true, fileMustExist: true });
instance.pragma('journal_mode = WAL');
// Les requêtes de facettes lisent beaucoup et écrivent rien : un cache
// généreux évite de relire les mêmes pages à chaque requête HTTP.
instance.pragma('cache_size = -16000');
return instance;
}
export function fermer(): void {
instance?.close();
instance = null;
}
/** Date d'arrêté du corpus de recherche, affichée partout où c'est utile. */
export const DATE_ARRETE_CORPUS = '2026-07-25';
+573
View File
@@ -0,0 +1,573 @@
/**
* Requêtes de consultation : recherche plein texte, facettes, fiches, calendrier.
*
* Tout se joue en SQL. La recherche plein texte passe par l'index FTS5 à
* tokenizer `unicode61 remove_diacritics 2` — « chlordecone » retrouve
* « chlordécone » — et le classement par `bm25()` pondère les colonnes : un mot
* dans le titre pèse dix fois plus que dans les points clés.
*
* Les facettes sont calculées sur le résultat filtré **par les autres
* facettes**, pas sur la table entière : cocher « santé » doit recompter les
* statuts disponibles parmi les textes de santé, sinon les compteurs mentent.
*/
import { base } from '$serveur/db';
import {
LIBELLES_CONFIANCE,
LIBELLES_IMPACT,
LIBELLES_PERTINENCE,
LIBELLES_STATUT,
LIBELLES_THEME,
LIBELLES_TYPE
} from '$lib/libelles';
import type {
Echeance,
Facette,
Filtres,
Insight,
Resultats,
Texte,
TexteDetaille
} from '$lib/types';
const PAR_PAGE = 20;
/** Pondération bm25 : titre court, titre officiel, résumé, points clés, note, numéro. */
const POIDS_BM25 = '10.0, 5.0, 3.0, 2.0, 2.0, 1.0';
// ─────────────────────────────────────────────────────────────────────────────
// Lecture des filtres depuis l'URL
// ─────────────────────────────────────────────────────────────────────────────
export function lireFiltres(parametres: URLSearchParams): Filtres {
const multiple = (nom: string) =>
parametres
.getAll(nom)
.flatMap((valeur) => valeur.split(','))
.map((valeur) => valeur.trim())
.filter(Boolean);
const page = Number.parseInt(parametres.get('page') ?? '1', 10);
return {
q: (parametres.get('q') ?? '').trim(),
statut: multiple('statut'),
theme: multiple('theme'),
type: multiple('type'),
impact: multiple('impact'),
guadeloupe: multiple('guadeloupe'),
confiance: multiple('confiance'),
from: parametres.get('from'),
to: parametres.get('to'),
tri: parametres.get('tri') === 'date' ? 'date' : 'pertinence',
page: Number.isFinite(page) && page > 0 ? page : 1
};
}
/** Reconstruit une URL de recherche en basculant une valeur de facette. */
export function basculerFacette(
filtres: Filtres,
champ: keyof Filtres,
valeur: string
): string {
const parametres = new URLSearchParams();
if (filtres.q) parametres.set('q', filtres.q);
for (const nom of ['statut', 'theme', 'type', 'impact', 'guadeloupe', 'confiance'] as const) {
let valeurs = [...filtres[nom]];
if (nom === champ) {
valeurs = valeurs.includes(valeur)
? valeurs.filter((v) => v !== valeur)
: [...valeurs, valeur];
}
for (const v of valeurs) parametres.append(nom, v);
}
if (filtres.from) parametres.set('from', filtres.from);
if (filtres.to) parametres.set('to', filtres.to);
if (filtres.tri !== 'pertinence') parametres.set('tri', filtres.tri);
const chaine = parametres.toString();
return chaine ? `/recherche?${chaine}` : '/recherche';
}
// ─────────────────────────────────────────────────────────────────────────────
// Construction du SQL
// ─────────────────────────────────────────────────────────────────────────────
interface Clause {
sql: string;
parametres: unknown[];
}
/**
* Conditions issues des filtres, hors recherche plein texte.
*
* `exclure` permet de recalculer les facettes d'un champ en ignorant ses
* propres sélections — c'est ce qui rend les compteurs honnêtes.
*/
function conditions(filtres: Filtres, exclure?: string): Clause {
const morceaux: string[] = [];
const parametres: unknown[] = [];
if (filtres.statut.length && exclure !== 'statut') {
// `saisie_cc` n'est pas seulement un statut : c'est aussi l'état des
// textes dont une affaire est en instance devant le Conseil. La vue
// `v_textes` expose cette lecture combinée sous `devant_cc`.
const statutsSimples = filtres.statut.filter((s) => s !== 'saisie_cc');
const clauses: string[] = [];
if (statutsSimples.length) {
clauses.push(`statut IN (${statutsSimples.map(() => '?').join(', ')})`);
parametres.push(...statutsSimples);
}
if (filtres.statut.includes('saisie_cc')) clauses.push('devant_cc = 1');
morceaux.push(`(${clauses.join(' OR ')})`);
}
if (filtres.theme.length && exclure !== 'theme') {
const clauses = filtres.theme.map(
() => `EXISTS (SELECT 1 FROM json_each(themes) WHERE json_each.value = ?)`
);
morceaux.push(`(${clauses.join(' OR ')})`);
parametres.push(...filtres.theme);
}
if (filtres.type.length && exclure !== 'type') {
morceaux.push(`type IN (${filtres.type.map(() => '?').join(', ')})`);
parametres.push(...filtres.type);
}
if (filtres.guadeloupe.length && exclure !== 'guadeloupe') {
morceaux.push(
`guadeloupe_pertinence IN (${filtres.guadeloupe.map(() => '?').join(', ')})`
);
parametres.push(...filtres.guadeloupe);
}
if (filtres.confiance.length && exclure !== 'confiance') {
morceaux.push(`confiance IN (${filtres.confiance.map(() => '?').join(', ')})`);
parametres.push(...filtres.confiance);
}
if (filtres.impact.length && exclure !== 'impact') {
// Un filtre d'impact s'écrit « public:sens », par exemple
// « associations:negatif ». Les deux moitiés doivent correspondre
// ensemble, d'où l'accès direct par clé JSON.
const clauses = filtres.impact.map(() => `json_extract(impacts, ?) = ?`);
morceaux.push(`(${clauses.join(' OR ')})`);
for (const valeur of filtres.impact) {
const [pub, sens] = valeur.split(':');
parametres.push(`$.${pub}.sens`, sens);
}
}
if (filtres.from) {
morceaux.push(`COALESCE(date_promulgation, date_adoption, statut_date) >= ?`);
parametres.push(filtres.from);
}
if (filtres.to) {
morceaux.push(`COALESCE(date_promulgation, date_adoption, statut_date) <= ?`);
parametres.push(filtres.to);
}
return { sql: morceaux.length ? morceaux.join(' AND ') : '1 = 1', parametres };
}
/**
* Traduit une saisie libre en requête FTS5.
*
* Chaque terme est cité pour neutraliser la syntaxe FTS (`NEAR`, `*`, `:`)
* qu'un visiteur ne saisit pas volontairement, et suffixé d'une étoile pour
* accepter les préfixes : « chlord » trouve « chlordécone ».
*/
function requeteFts(saisie: string): string {
const termes = saisie
.replace(/["^*:()]/g, ' ')
.split(/\s+/)
.map((terme) => terme.trim())
.filter((terme) => terme.length > 1);
if (!termes.length) return '';
return termes.map((terme) => `"${terme}"*`).join(' AND ');
}
// ─────────────────────────────────────────────────────────────────────────────
// Recherche
// ─────────────────────────────────────────────────────────────────────────────
export function rechercher(filtres: Filtres): Resultats {
const db = base();
const where = conditions(filtres);
const fts = filtres.q ? requeteFts(filtres.q) : '';
const source = fts
? `v_textes t JOIN textes_fts f ON f.rowid = t.rowid AND textes_fts MATCH ?`
: `v_textes t`;
const parametresSource = fts ? [fts] : [];
const ordre = fts
? filtres.tri === 'date'
? `COALESCE(t.date_promulgation, t.date_adoption, t.statut_date) DESC`
: `bm25(textes_fts, ${POIDS_BM25})`
: `COALESCE(t.date_promulgation, t.date_adoption, t.statut_date) DESC, t.id`;
const total = (
db
.prepare(`SELECT COUNT(*) AS n FROM ${source} WHERE ${where.sql}`)
.get(...parametresSource, ...where.parametres) as { n: number }
).n;
const extrait = fts
? `snippet(textes_fts, -1, '<mark>', '</mark>', '…', 24) AS extrait`
: `NULL AS extrait`;
const lignes = db
.prepare(
`SELECT t.*, ${extrait}
FROM ${source}
WHERE ${where.sql}
ORDER BY ${ordre}
LIMIT ? OFFSET ?`
)
.all(
...parametresSource,
...where.parametres,
PAR_PAGE,
(filtres.page - 1) * PAR_PAGE
) as Record<string, unknown>[];
return {
textes: lignes.map(hydrater),
total,
page: filtres.page,
pages: Math.max(1, Math.ceil(total / PAR_PAGE)),
facettes: {
statut: facetteStatut(filtres, fts),
theme: facetteTheme(filtres, fts),
type: facetteSimple(filtres, fts, 'type', LIBELLES_TYPE),
guadeloupe: facetteSimple(
filtres,
fts,
'guadeloupe_pertinence',
LIBELLES_PERTINENCE,
'guadeloupe'
),
impact: facetteImpact(filtres, fts),
confiance: facetteSimple(filtres, fts, 'confiance', LIBELLES_CONFIANCE)
}
};
}
function sourceEtParametres(fts: string): [string, unknown[]] {
return fts
? [`v_textes t JOIN textes_fts f ON f.rowid = t.rowid AND textes_fts MATCH ?`, [fts]]
: [`v_textes t`, []];
}
function facetteSimple(
filtres: Filtres,
fts: string,
colonne: string,
libelles: Record<string, string>,
champFiltre?: keyof Filtres
): Facette[] {
const champ = (champFiltre ?? colonne) as keyof Filtres;
const where = conditions(filtres, champ as string);
const [source, parametresSource] = sourceEtParametres(fts);
const lignes = base()
.prepare(
`SELECT ${colonne} AS valeur, COUNT(*) AS nombre
FROM ${source}
WHERE ${where.sql} AND ${colonne} IS NOT NULL
GROUP BY ${colonne}
ORDER BY nombre DESC`
)
.all(...parametresSource, ...where.parametres) as { valeur: string; nombre: number }[];
const selection = filtres[champ] as string[];
return lignes.map((ligne) => ({
valeur: ligne.valeur,
libelle: libelles[ligne.valeur] ?? ligne.valeur,
nombre: ligne.nombre,
actif: selection.includes(ligne.valeur)
}));
}
function facetteStatut(filtres: Filtres, fts: string): Facette[] {
const facettes = facetteSimple(filtres, fts, 'statut', LIBELLES_STATUT);
// Facette dérivée : « devant le Conseil constitutionnel » recouvre à la
// fois le statut `saisie_cc` et les textes ayant une affaire en instance.
const where = conditions(filtres, 'statut');
const [source, parametresSource] = sourceEtParametres(fts);
const nombre = (
base()
.prepare(`SELECT COUNT(*) AS n FROM ${source} WHERE ${where.sql} AND devant_cc = 1`)
.get(...parametresSource, ...where.parametres) as { n: number }
).n;
if (nombre > 0 && !facettes.some((f) => f.valeur === 'saisie_cc')) {
facettes.unshift({
valeur: 'saisie_cc',
libelle: LIBELLES_STATUT.saisie_cc,
nombre,
actif: filtres.statut.includes('saisie_cc')
});
}
return facettes;
}
function facetteTheme(filtres: Filtres, fts: string): Facette[] {
const where = conditions(filtres, 'theme');
const [source, parametresSource] = sourceEtParametres(fts);
const lignes = base()
.prepare(
`SELECT json_each.value AS valeur, COUNT(*) AS nombre
FROM ${source}, json_each(t.themes)
WHERE ${where.sql}
GROUP BY json_each.value
ORDER BY nombre DESC`
)
.all(...parametresSource, ...where.parametres) as { valeur: string; nombre: number }[];
return lignes.map((ligne) => ({
valeur: ligne.valeur,
libelle: LIBELLES_THEME[ligne.valeur] ?? ligne.valeur,
nombre: ligne.nombre,
actif: filtres.theme.includes(ligne.valeur)
}));
}
function facetteImpact(filtres: Filtres, fts: string): Facette[] {
const where = conditions(filtres, 'impact');
const [source, parametresSource] = sourceEtParametres(fts);
const lignes = base()
.prepare(
`SELECT json_each.key || ':' || json_extract(json_each.value, '$.sens') AS valeur,
COUNT(*) AS nombre
FROM ${source}, json_each(t.impacts)
WHERE ${where.sql}
GROUP BY valeur
ORDER BY nombre DESC`
)
.all(...parametresSource, ...where.parametres) as { valeur: string; nombre: number }[];
return lignes
.filter((ligne) => !ligne.valeur.endsWith(':neutre'))
.map((ligne) => ({
valeur: ligne.valeur,
libelle: LIBELLES_IMPACT[ligne.valeur] ?? ligne.valeur,
nombre: ligne.nombre,
actif: filtres.impact.includes(ligne.valeur)
}));
}
// ─────────────────────────────────────────────────────────────────────────────
// Fiche détaillée
// ─────────────────────────────────────────────────────────────────────────────
export function texte(slug: string): TexteDetaille | null {
const db = base();
const ligne = db.prepare(`SELECT * FROM v_textes WHERE id = ?`).get(slug) as
| Record<string, unknown>
| undefined;
if (!ligne) return null;
const detaille = hydrater(ligne) as TexteDetaille;
detaille.sources = db
.prepare(
`SELECT url, titre, editeur, date_publication, tier, extrait_verbatim,
contexte, confiance, marqueur, fichier_origine
FROM sources WHERE texte_id = ?
ORDER BY tier ASC, date_publication DESC NULLS LAST, id`
)
.all(slug) as TexteDetaille['sources'];
detaille.evenements = db
.prepare(
`SELECT date_evenement, type_etape, description, source_url, previsionnel
FROM evenements WHERE texte_id = ?
ORDER BY date_evenement ASC, id`
)
.all(slug) as TexteDetaille['evenements'];
detaille.decisions_cc = db
.prepare(`SELECT * FROM decisions_cc WHERE texte_id = ? ORDER BY numero_affaire`)
.all(slug) as TexteDetaille['decisions_cc'];
return detaille;
}
export function tousLesSlugs(): string[] {
return (base().prepare(`SELECT id FROM textes ORDER BY id`).all() as { id: string }[]).map(
(l) => l.id
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Tableau de bord
// ─────────────────────────────────────────────────────────────────────────────
export interface Compteurs {
total: number;
parStatut: { valeur: string; libelle: string; nombre: number }[];
devantCc: number;
guadeloupeForte: number;
aVerifier: number;
sources: number;
}
export function compteurs(): Compteurs {
const db = base();
const un = (sql: string) => (db.prepare(sql).get() as { n: number }).n;
return {
total: un(`SELECT COUNT(*) AS n FROM textes`),
parStatut: (
db
.prepare(
`SELECT statut AS valeur, COUNT(*) AS nombre FROM textes
GROUP BY statut ORDER BY nombre DESC`
)
.all() as { valeur: string; nombre: number }[]
).map((l) => ({ ...l, libelle: LIBELLES_STATUT[l.valeur] ?? l.valeur })),
devantCc: un(`SELECT COUNT(*) AS n FROM v_textes WHERE devant_cc = 1`),
guadeloupeForte: un(
`SELECT COUNT(*) AS n FROM textes WHERE guadeloupe_pertinence = 'forte'`
),
aVerifier: un(`SELECT COUNT(*) AS n FROM textes WHERE a_verifier = 1`),
sources: un(`SELECT COUNT(*) AS n FROM sources`)
};
}
/** Échéances des `jours` prochains jours, textes et repères nationaux mêlés. */
export function prochainesEcheances(jours = 60, aPartirDe?: string): Echeance[] {
const depart = aPartirDe ?? '2026-07-25';
const fin = new Date(depart);
fin.setDate(fin.getDate() + jours);
const limite = fin.toISOString().slice(0, 10);
const db = base();
const nationales = db
.prepare(
`SELECT * FROM echeances
WHERE date_echeance >= ? AND date_echeance <= ?
ORDER BY date_echeance`
)
.all(depart, limite) as Echeance[];
const desTextes = db
.prepare(
`SELECT NULL AS id, 'texte-' || id AS code, prochaine_echeance AS date_echeance,
titre_court AS libelle, prochaine_echeance_label AS description,
'texte' AS portee, id AS texte_id,
CASE WHEN guadeloupe_pertinence = 'forte' THEN 1 ELSE NULL END
AS concerne_guadeloupe,
guadeloupe_note AS note_guadeloupe, 1 AS previsionnel,
NULL AS source_url, NULL AS source_extrait
FROM textes
WHERE prochaine_echeance IS NOT NULL
AND prochaine_echeance >= ? AND prochaine_echeance <= ?
ORDER BY prochaine_echeance`
)
.all(depart, limite) as Echeance[];
return [...nationales, ...desTextes].sort((a, b) =>
a.date_echeance.localeCompare(b.date_echeance)
);
}
/** Toutes les échéances, pour la page calendrier. */
export function calendrier(): Echeance[] {
return prochainesEcheances(400, '2026-07-01');
}
export function decisionsAttendues(): (Echeance & { numero_affaire: string })[] {
return base()
.prepare(
`SELECT d.numero_affaire, d.date_decision_attendue AS date_echeance,
COALESCE(t.titre_court, d.resume) AS libelle, d.resume AS description,
d.texte_id, t.guadeloupe_pertinence
FROM decisions_cc d
LEFT JOIN textes t ON t.id = d.texte_id
WHERE d.date_decision IS NULL
ORDER BY COALESCE(d.date_decision_attendue, '9999'), d.numero_affaire`
)
.all() as (Echeance & { numero_affaire: string })[];
}
export function insights(): Insight[] {
return (
base().prepare(`SELECT * FROM insights ORDER BY numero`).all() as Record<
string,
unknown
>[]
).map((ligne) => ({
numero: ligne.numero as number,
titre: ligne.titre as string,
corps: ligne.corps as string,
implications: ligne.implications as string | null,
confiance: ligne.confiance as string,
derive_de: JSON.parse((ligne.derive_de as string) || '[]')
}));
}
export interface Changement {
horodatage: string;
mode: string;
texte_id: string | null;
titre: string | null;
nature: string;
champ: string | null;
ancienne_valeur: string | null;
nouvelle_valeur: string | null;
description: string | null;
}
/** Derniers changements détectés par le pipeline, pour l'accueil. */
export function derniersChangements(limite = 12): Changement[] {
return base()
.prepare(
`SELECT v.horodatage, v.mode, c.texte_id, t.titre_court AS titre,
c.nature, c.champ, c.ancienne_valeur, c.nouvelle_valeur, c.description
FROM veille_changements c
JOIN veille_log v ON v.id = c.run_id
LEFT JOIN textes t ON t.id = c.texte_id
ORDER BY v.horodatage DESC, c.id DESC
LIMIT ?`
)
.all(limite) as Changement[];
}
export interface Run {
horodatage: string;
mode: string;
duree_s: number | null;
ajouts: number;
modifications: number;
echecs: number;
rapport: string | null;
}
export function dernierRun(): Run | null {
return (
(base()
.prepare(
`SELECT horodatage, mode, duree_s, ajouts, modifications, echecs, rapport
FROM veille_log ORDER BY horodatage DESC, id DESC LIMIT 1`
)
.get() as Run | undefined) ?? null
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Hydratation
// ─────────────────────────────────────────────────────────────────────────────
function hydrater(ligne: Record<string, unknown>): Texte {
return {
...(ligne as unknown as Texte),
themes: JSON.parse((ligne.themes as string) || '[]'),
impacts: JSON.parse((ligne.impacts as string) || '{}'),
points_cles: JSON.parse((ligne.points_cles as string) || '[]')
};
}
+342
View File
@@ -0,0 +1,342 @@
/**
* Socle : polices auto-hébergées, réinitialisation, éléments communs.
*
* Aucune requête tierce. Les `@font-face` pointent vers `static/polices/`,
* copiées depuis npm par `npm run polices`.
*/
/* ── Polices ─────────────────────────────────────────────────────────────── */
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/polices/archivo-latin-400-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+2000-206F, U+20AC;
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/polices/archivo-latin-600-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/polices/archivo-latin-700-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/polices/inter-latin-400-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/polices/inter-latin-500-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/polices/inter-latin-600-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/polices/inter-latin-700-normal.woff2') format('woff2');
}
/* ── Réinitialisation ────────────────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
body {
margin: 0;
background: var(--noir-oki);
color: var(--blanc-creme);
font-family: var(--font-body);
font-size: 1rem;
line-height: 1.55;
font-weight: 400;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3,
h4 {
font-family: var(--font-display);
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.15;
margin: 0 0 var(--space-2);
text-wrap: balance;
}
h1 {
font-size: clamp(1.55rem, 3.4vw, 2.3rem);
text-transform: uppercase;
}
h2 {
font-size: clamp(1.2rem, 2.4vw, 1.55rem);
text-transform: uppercase;
}
h3 {
font-size: 1.05rem;
}
h4 {
font-size: 0.95rem;
}
p {
margin: 0 0 var(--space-2);
text-wrap: pretty;
}
a {
color: var(--or-oki);
text-decoration-thickness: 1px;
text-underline-offset: 0.18em;
}
a:hover {
color: var(--or-clair);
}
:focus-visible {
outline: 3px solid var(--or-oki);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
img,
svg {
max-width: 100%;
height: auto;
}
mark {
background: color-mix(in srgb, var(--or-oki) 26%, transparent);
color: inherit;
padding: 0 0.15em;
border-radius: 2px;
font-weight: 600;
}
hr {
border: 0;
border-top: 1px solid var(--line);
margin: var(--space-4) 0;
}
/* ── Mise en page ────────────────────────────────────────────────────────── */
.contenant {
width: min(100% - 2rem, var(--container));
margin-inline: auto;
}
.section {
padding-block: var(--space-4);
}
.pile {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.ligne {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
align-items: center;
}
.grille {
display: grid;
gap: var(--space-2);
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
}
/* ── Signature de marque ─────────────────────────────────────────────────── */
.flag-bar {
height: 6px;
background: linear-gradient(
to right,
#0d0d0d 0 25%,
var(--or-oki) 25% 50%,
var(--vert-oki) 50% 75%,
var(--rouge-oki) 75% 100%
);
}
:root.light-theme .flag-bar {
background: linear-gradient(
to right,
#000 0 25%,
var(--or-oki) 25% 50%,
var(--vert-oki) 50% 75%,
var(--rouge-oki) 75% 100%
);
}
/* ── Carte canonique ─────────────────────────────────────────────────────── */
.carte {
background: var(--card-bg);
border: var(--border-card);
border-left: 4px solid var(--or-oki);
border-radius: var(--radius-md);
padding: var(--space-2);
transition:
transform var(--dur-tanbou) var(--ease-ka),
border-color var(--dur-tanbou) var(--ease-ka),
background var(--dur-tanbou) var(--ease-ka);
}
.carte:hover {
background: var(--card-bg-survol);
transform: translateY(-2px);
}
/* ── Tag canonique ───────────────────────────────────────────────────────── */
.tag {
display: inline-flex;
align-items: center;
gap: 0.35em;
background: color-mix(in srgb, var(--or-oki) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--or-oki) 55%, transparent);
border-radius: var(--radius-sm);
color: var(--blanc-creme);
font-size: 0.78rem;
padding: 0.15em 0.55em;
text-decoration: none;
white-space: nowrap;
}
/* ── Bouton canonique ────────────────────────────────────────────────────── */
.bouton {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5em;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.55em 1.1em;
border: var(--border-btn);
border-radius: var(--radius-sm);
background: transparent;
color: var(--or-oki);
cursor: pointer;
text-decoration: none;
overflow: hidden;
transition: color var(--dur-tanbou) var(--ease-syncope);
}
.bouton::after {
content: '';
position: absolute;
inset: 0;
background: var(--or-oki);
transform: scaleX(0);
transform-origin: left;
transition: transform var(--dur-tanbou) var(--ease-syncope);
z-index: -1;
}
.bouton:hover {
color: var(--noir-oki);
}
.bouton:hover::after {
transform: scaleX(1);
}
.bouton > * {
position: relative;
z-index: 1;
}
.bouton-discret {
border-color: var(--line);
color: var(--blanc-creme);
}
/* ── Accessibilité ───────────────────────────────────────────────────────── */
.lien-evitement {
position: absolute;
left: -9999px;
top: 0;
background: var(--or-oki);
color: var(--noir-oki);
padding: var(--space-1) var(--space-2);
font-weight: 700;
z-index: 100;
}
.lien-evitement:focus {
left: 0;
}
.visuellement-cache {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* Une seule garde, au niveau global : sans mouvement, tout reste lisible. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.carte:hover {
transform: none;
}
}
/* ── Impression : la veille se transmet aussi sur papier ─────────────────── */
@media print {
:root {
--noir-oki: #fff;
--blanc-creme: #000;
--card-bg: transparent;
--line: #999;
--muted: #444;
}
.entete-site,
.pied-site,
.panneau-facettes,
.bouton {
display: none !important;
}
a[href^='http']::after {
content: ' (' attr(href) ')';
font-size: 0.75em;
word-break: break-all;
}
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Tokens OKI — Organisation KA Internationale.
*
* Valeurs mesurées sur o-k-i.net, reprises telles quelles de la charte. Le
* thème sombre est l'identité par défaut ; le clair est une variante opt-in qui
* assombrit les accents pour rester au contraste AA.
*
* Trois règles ne se négocient pas :
* · l'or porte toute l'interaction — c'est la seule couleur d'action ;
* · rouge = signaler, vert = valider, or = agir : jamais permuter ;
* · angles nets partout, 3 px sur les petits éléments, 6 px sur les cartes.
*
* Usage propre à cette application : c'est un outil de veille, pas une vitrine.
* La densité informationnelle prime, le motion est réduit au strict minimum, et
* la flag-bar n'apparaît qu'une fois par écran.
*/
:root {
/* ── Noyau ── */
--noir-oki: #0d0d0d;
--noir-profond: #1a0f1a;
--blanc-creme: #fff8e7;
--line: rgba(255, 255, 255, 0.1);
--or-oki: #fdb813;
--rouge-oki: #ff1654;
/* ── Étendue, avec parcimonie ── */
--vert-oki: #00d66c;
--turquoise-caraibes: #00ced1;
--jaune-soleil: #ffd700;
--orange-flamme: #ff6b35;
--violet-nuit: #6b2d5c;
--bleu-ocean: #0077be;
--or-clair: #ffe066;
--gris-sombre: #2d1b2e;
/* ── Sémantique dérivée ── */
--muted: color-mix(in srgb, var(--blanc-creme) 70%, transparent);
--muted-fort: color-mix(in srgb, var(--blanc-creme) 55%, transparent);
--card-bg: rgba(255, 255, 255, 0.03);
--card-bg-survol: rgba(255, 255, 255, 0.06);
/* ── Typographie ── */
--font-display: 'Archivo', 'Arial Black', sans-serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: ui-monospace, 'SFMono-Regular', 'Menlo', monospace;
/* ── Formes et espacements ── */
--radius-sm: 3px;
--radius-md: 6px;
--border-card: 1px solid var(--line);
--border-btn: 2px solid var(--or-oki);
--space-1: 0.75rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2rem;
--space-5: 4rem;
--container: 1200px;
/* ── Motion : les cadences gwoka ── */
--ease-ka: cubic-bezier(0.22, 1, 0.36, 1);
--ease-syncope: cubic-bezier(0.65, 0, 0.35, 1);
--dur-tanbou: 120ms;
--dur-mesure: 480ms;
--dur-phrase: 960ms;
/* ── Sémantique métier ──
* Les statuts sont d'abord distingués par leur libellé et leur forme, la
* couleur ne fait que renforcer : un daltonien doit pouvoir lire la page.
*/
--statut-promulguee: var(--vert-oki);
--statut-attente: var(--or-oki);
--statut-cc: var(--orange-flamme);
--statut-navette: var(--turquoise-caraibes);
--statut-projet: var(--muted-fort);
--statut-rejetee: var(--rouge-oki);
--impact-positif: var(--vert-oki);
--impact-negatif: var(--rouge-oki);
--impact-mixte: var(--jaune-soleil);
--impact-neutre: var(--muted-fort);
}
/* Thème clair — opt-in, accents assombris pour le contraste AA. */
:root.light-theme {
--noir-oki: #fff8e7;
--blanc-creme: #0d0d0d;
--noir-profond: #f5f0e8;
--gris-sombre: #e8ddd0;
--or-oki: #8f5c00;
--vert-oki: #006b3d;
--rouge-oki: #a01030;
--turquoise-caraibes: #006b75;
--bleu-ocean: #004b7f;
--or-clair: #a86e00;
--jaune-soleil: #7a5300;
--orange-flamme: #b34700;
--violet-nuit: #4a1f40;
--line: rgba(0, 0, 0, 0.14);
--card-bg: rgba(0, 0, 0, 0.025);
--card-bg-survol: rgba(0, 0, 0, 0.05);
--muted: color-mix(in srgb, var(--blanc-creme) 72%, transparent);
--muted-fort: color-mix(in srgb, var(--blanc-creme) 58%, transparent);
}
+180
View File
@@ -0,0 +1,180 @@
/** Vocabulaire du domaine, aligné sur le schéma SQLite et les modèles pydantic. */
export type Statut =
| 'promulguee'
| 'adoptee_non_promulguee'
| 'saisie_cc'
| 'validee_cc'
| 'censuree_partiellement'
| 'navette'
| 'deposee_non_examinee'
| 'annoncee'
| 'rejetee';
export type TypeTexte =
| 'loi'
| 'loi_organique'
| 'pjl'
| 'ppl'
| 'ordonnance'
| 'decision_cc'
| 'decret'
| 'accord_international';
export type Theme =
| 'justice'
| 'securite'
| 'numerique'
| 'social'
| 'fiscal'
| 'environnement'
| 'agriculture'
| 'sante'
| 'memoire_patrimoine'
| 'outre_mer'
| 'economie'
| 'migration'
| 'institutions';
export type Public = 'individus' | 'associations' | 'entreprises';
export type SensImpact = 'positif' | 'negatif' | 'mixte' | 'neutre';
export type Pertinence = 'forte' | 'moyenne' | 'faible';
export type Confiance = 'high' | 'medium' | 'low';
export type RegimeLibertes = 'droits_plus' | 'controle_plus' | 'mixte' | 'neutre';
export interface Impact {
sens: SensImpact;
note: string;
}
export interface Source {
url: string;
titre: string | null;
editeur: string | null;
date_publication: string | null;
tier: 'T1' | 'T2';
extrait_verbatim: string | null;
contexte: string | null;
confiance: Confiance | null;
marqueur: string | null;
fichier_origine: string | null;
}
export interface Evenement {
date_evenement: string;
type_etape: string;
description: string;
source_url: string | null;
previsionnel: 0 | 1;
}
export interface DecisionCC {
numero_affaire: string;
date_saisine: string | null;
date_decision: string | null;
resultat: string | null;
saisissants: string | null;
resume: string | null;
url: string | null;
date_decision_attendue: string | null;
}
export interface Texte {
id: string;
numero_officiel: string | null;
type: TypeTexte;
titre_court: string;
titre_officiel: string | null;
statut: Statut;
statut_date: string | null;
date_depot: string | null;
date_adoption: string | null;
date_promulgation: string | null;
date_entree_vigueur: string | null;
prochaine_echeance: string | null;
prochaine_echeance_label: string | null;
themes: Theme[];
impacts: Partial<Record<Public, Impact>>;
regime_libertes: RegimeLibertes | null;
regime_libertes_note: string | null;
guadeloupe_pertinence: Pertinence | null;
guadeloupe_note: string | null;
resume: string | null;
points_cles: string[];
confiance: Confiance;
source_seed: string | null;
a_verifier: 0 | 1;
motif_verification: string | null;
derniere_verif: string | null;
nb_sources: number;
nb_evenements: number;
devant_cc: 0 | 1;
/** Extrait surligné, présent uniquement sur les résultats de recherche. */
extrait?: string | null;
}
export interface TexteDetaille extends Texte {
sources: Source[];
evenements: Evenement[];
decisions_cc: DecisionCC[];
}
export interface Echeance {
id: number;
code: string;
date_echeance: string;
libelle: string;
description: string | null;
portee: 'nationale' | 'texte';
texte_id: string | null;
concerne_guadeloupe: 0 | 1 | null;
note_guadeloupe: string | null;
previsionnel: 0 | 1;
source_url: string | null;
source_extrait: string | null;
}
export interface Insight {
numero: number;
titre: string;
corps: string;
implications: string | null;
confiance: string;
derive_de: string[];
}
export interface Facette {
valeur: string;
libelle: string;
nombre: number;
actif: boolean;
}
export interface Filtres {
q: string;
statut: string[];
theme: string[];
type: string[];
impact: string[];
guadeloupe: string[];
confiance: string[];
from: string | null;
to: string | null;
tri: 'date' | 'pertinence';
page: number;
}
export interface Resultats {
textes: Texte[];
total: number;
page: number;
pages: number;
facettes: {
statut: Facette[];
theme: Facette[];
type: Facette[];
guadeloupe: Facette[];
impact: Facette[];
confiance: Facette[];
};
}
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import { page } from '$app/state';
</script>
<svelte:head>
<title>{page.status} · Veille législative Gwadloup</title>
<meta name="robots" content="noindex" />
</svelte:head>
<div class="erreur">
<p class="code">{page.status}</p>
{#if page.status === 404}
<h1>Pa ni ayen la</h1>
<p class="explication">
Cette page n'existe pas — ou n'existe plus. Le texte que vous cherchez a peut-être un
autre identifiant.
</p>
{:else}
<h1>Sa pa maché</h1>
<p class="explication">{page.error?.message ?? 'Une erreur est survenue.'}</p>
{/if}
<div class="ligne actions">
<a class="bouton" href="/"><span>Tableau de bord</span></a>
<a class="bouton bouton-discret" href="/recherche"><span>Rechercher un texte</span></a>
</div>
<div class="flag-bar" aria-hidden="true"></div>
</div>
<style>
.erreur {
max-width: 48ch;
margin: var(--space-5) auto;
text-align: center;
}
.code {
font-family: var(--font-display);
font-size: 4rem;
font-weight: 900;
line-height: 1;
color: var(--or-oki);
margin: 0;
}
h1 {
margin-top: var(--space-1);
}
.explication {
color: var(--muted);
}
.actions {
justify-content: center;
margin-block: var(--space-3);
}
.flag-bar {
max-width: 56px;
margin-inline: auto;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
/**
* Le rendu serveur est le défaut sur toute l'application.
*
* On ne pose JAMAIS `ssr = false` ici : le mode SPA est explicitement écarté
* par l'architecture de rendu (§3bis), pour le référencement comme pour la
* lisibilité sans JavaScript. Le prérendu est activé page par page, uniquement
* sur celles dont le contenu est figé (`/a-propos`, `/methode`).
*/
export const prerender = false;
+220
View File
@@ -0,0 +1,220 @@
<script lang="ts">
import '$lib/styles/oki-tokens.css';
import '$lib/styles/base.css';
import { page } from '$app/state';
const { children } = $props();
const NAVIGATION = [
{ href: '/', libelle: 'Tableau de bord' },
{ href: '/recherche', libelle: 'Recherche' },
{ href: '/calendrier', libelle: 'Calendrier' },
{ href: '/methode', libelle: 'Méthode' },
{ href: '/a-propos', libelle: 'À propos' }
];
const actif = (href: string) =>
href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
</script>
<a class="lien-evitement" href="#contenu">Aller au contenu</a>
<header class="entete-site">
<div class="contenant barre">
<a class="marque" href="/">
<span class="monogramme" aria-hidden="true">VL</span>
<span class="titre">
<strong>Veille législative</strong>
<small>Gwadloup · libertés, associations, entreprises</small>
</span>
</a>
<nav aria-label="Navigation principale">
<ul>
{#each NAVIGATION as entree (entree.href)}
<li>
<a href={entree.href} aria-current={actif(entree.href) ? 'page' : undefined}>
{entree.libelle}
</a>
</li>
{/each}
</ul>
</nav>
<button
type="button"
class="bascule-theme"
aria-label="Basculer entre thème sombre et thème clair"
onclick={() => {
const racine = document.documentElement;
const clair = racine.classList.toggle('light-theme');
try {
localStorage.setItem('theme', clair ? 'clair' : 'sombre');
} catch {
/* stockage indisponible : le choix ne survivra pas au rechargement */
}
}}
>
<span aria-hidden="true"></span>
</button>
</div>
<!-- Signature de marque : une seule occurrence pleine largeur par écran. -->
<div class="flag-bar"></div>
</header>
<main id="contenu" class="contenant">
{@render children()}
</main>
<footer class="pied-site">
<div class="contenant">
<p class="doctrine">
Aucune donnée législative n'est inventée. Chaque fait porte sa source, sa date et son
niveau de confiance. Ce qui n'est pas établi est marqué « à vérifier ».
</p>
<p class="federe">
<span class="monogramme petit" aria-hidden="true">OKI</span>
Un service libre opéré par
<a href="https://o-k-i.net" rel="noopener">ORGANISATION KA INTERNATIONALE</a>
· o-k-i.net
</p>
</div>
</footer>
<style>
.entete-site {
position: sticky;
top: 0;
z-index: 20;
background: var(--noir-oki);
border-bottom: 1px solid var(--line);
}
.barre {
display: flex;
align-items: center;
gap: var(--space-2);
padding-block: 0.7rem;
flex-wrap: wrap;
}
.marque {
display: flex;
align-items: center;
gap: 0.6rem;
text-decoration: none;
color: var(--blanc-creme);
margin-right: auto;
}
.monogramme {
font-family: var(--font-display);
font-weight: 900;
font-size: 0.95rem;
letter-spacing: -0.02em;
color: var(--noir-oki);
background: var(--or-oki);
padding: 0.28em 0.45em;
border-radius: var(--radius-sm);
flex: none;
}
.monogramme.petit {
font-size: 0.7rem;
padding: 0.2em 0.35em;
}
.titre {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.titre strong {
font-family: var(--font-display);
font-weight: 700;
font-size: 0.98rem;
text-transform: uppercase;
letter-spacing: -0.01em;
}
.titre small {
font-size: 0.72rem;
color: var(--muted-fort);
}
nav ul {
list-style: none;
display: flex;
gap: 0.15rem;
margin: 0;
padding: 0;
flex-wrap: wrap;
}
nav a {
display: block;
padding: 0.35rem 0.65rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
color: var(--blanc-creme);
text-decoration: none;
border: 1px solid transparent;
}
nav a:hover {
color: var(--or-oki);
border-color: var(--line);
}
nav a[aria-current='page'] {
color: var(--or-oki);
border-color: color-mix(in srgb, var(--or-oki) 55%, transparent);
background: color-mix(in srgb, var(--or-oki) 10%, transparent);
}
.bascule-theme {
background: transparent;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--blanc-creme);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0.35rem 0.55rem;
}
.bascule-theme:hover {
color: var(--or-oki);
border-color: var(--or-oki);
}
main {
min-height: 60vh;
padding-block: var(--space-3) var(--space-5);
}
.pied-site {
border-top: 1px solid var(--line);
padding-block: var(--space-3);
font-size: 0.82rem;
color: var(--muted-fort);
}
.doctrine {
max-width: 62ch;
margin-bottom: var(--space-2);
}
.federe {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
margin: 0;
}
@media (max-width: 640px) {
.marque {
margin-right: 0;
}
nav ul {
gap: 0;
}
nav a {
padding: 0.3rem 0.45rem;
font-size: 0.8rem;
}
}
</style>
+26
View File
@@ -0,0 +1,26 @@
import {
compteurs,
derniersChangements,
dernierRun,
decisionsAttendues,
insights,
prochainesEcheances
} from '$serveur/requetes';
import { DATE_ARRETE_CORPUS } from '$serveur/db';
import type { PageServerLoad } from './$types';
/**
* Tableau de bord — rendu serveur, sans prérendu.
*
* Les compteurs et les derniers changements doivent refléter le dernier
* passage du pipeline sans qu'on ait à reconstruire le site.
*/
export const load: PageServerLoad = async () => ({
compteurs: compteurs(),
echeances: prochainesEcheances(45),
decisionsAttendues: decisionsAttendues(),
changements: derniersChangements(8),
run: dernierRun(),
insights: insights(),
dateArrete: DATE_ARRETE_CORPUS
});
+362
View File
@@ -0,0 +1,362 @@
<script lang="ts">
import BadgeStatut from '$composants/BadgeStatut.svelte';
import { dateLongue, joursRestants } from '$lib/format';
import type { PageData } from './$types';
const { data }: { data: PageData } = $props();
// L'encart « point de veille » tourne sur les sept analyses transversales.
let analyseCourante = $state(0);
const analyse = $derived(data.insights[analyseCourante]);
</script>
<svelte:head>
<title>Veille législative Gwadloup — tableau de bord</title>
<meta
name="description"
content="Suivi des lois françaises et de leurs effets sur les libertés des individus, des associations et des entreprises, avec un zoom Guadeloupe."
/>
</svelte:head>
<section class="hero">
<h1>Ki lwa ka chanjé lavi an nou ?</h1>
<p class="accroche">
{data.compteurs.total} textes suivis, {data.compteurs.sources} sources vérifiables.
Données arrêtées au {dateLongue(data.dateArrete)}.
</p>
<form class="recherche" action="/recherche" method="get" role="search">
<label class="visuellement-cache" for="q">Rechercher dans les textes suivis</label>
<input
id="q"
name="q"
type="search"
placeholder="chlordécone, associations, facturation électronique…"
autocomplete="off"
/>
<button class="bouton" type="submit"><span>Rechercher</span></button>
</form>
</section>
<section class="section">
<h2>Où en sont les textes</h2>
<div class="compteurs">
{#each data.compteurs.parStatut as ligne (ligne.valeur)}
<a class="compteur" href="/recherche?statut={ligne.valeur}">
<strong>{ligne.nombre}</strong>
<span>{ligne.libelle}</span>
</a>
{/each}
</div>
<div class="ligne raccourcis">
<a class="bouton bouton-discret" href="/recherche?statut=saisie_cc">
<span>{data.compteurs.devantCc} devant le Conseil constitutionnel</span>
</a>
<a class="bouton bouton-discret" href="/recherche?guadeloupe=forte">
<span>{data.compteurs.guadeloupeForte} de pertinence forte pour la Guadeloupe</span>
</a>
<a class="bouton bouton-discret" href="/recherche?confiance=low">
<span>{data.compteurs.aVerifier} à vérifier</span>
</a>
</div>
</section>
<div class="deux-colonnes">
<section class="section">
<h2>Prochaines échéances</h2>
{#if data.echeances.length}
<ol class="echeances">
{#each data.echeances as echeance (echeance.code)}
{@const jours = joursRestants(echeance.date_echeance, data.dateArrete)}
<li class="carte echeance" class:gwada={echeance.concerne_guadeloupe === 1}>
<div class="quand">
<time datetime={echeance.date_echeance}>{dateLongue(echeance.date_echeance)}</time>
{#if jours >= 0}<span class="dans">dans {jours} j</span>{/if}
</div>
<h3>
{#if echeance.texte_id}
<a href="/textes/{echeance.texte_id}">{echeance.libelle}</a>
{:else}
{echeance.libelle}
{/if}
</h3>
{#if echeance.description}<p>{echeance.description}</p>{/if}
{#if echeance.concerne_guadeloupe === 0}
<p class="hors-gwada">Ne concerne pas la Guadeloupe.</p>
{/if}
{#if echeance.note_guadeloupe}
<p class="note-gwada">{echeance.note_guadeloupe}</p>
{/if}
</li>
{/each}
</ol>
<p><a href="/calendrier">Voir tout le calendrier →</a></p>
{:else}
<p class="vide">Aucune échéance dans les 45 prochains jours.</p>
{/if}
</section>
<div class="colonne-secondaire">
<section class="section">
<h2>Point de veille</h2>
{#if analyse}
<article class="carte analyse">
<h3>{analyse.titre}</h3>
<p>{analyse.corps.slice(0, 460)}{analyse.corps.length > 460 ? '…' : ''}</p>
{#if analyse.implications}
<p class="implications"><strong>Implications —</strong> {analyse.implications}</p>
{/if}
<div class="ligne navigation-analyse">
<span class="position">{analyseCourante + 1} / {data.insights.length}</span>
<button
type="button"
class="bouton bouton-discret"
onclick={() =>
(analyseCourante = (analyseCourante + 1) % data.insights.length)}
>
<span>Analyse suivante</span>
</button>
</div>
</article>
{/if}
</section>
<section class="section">
<h2>Décisions attendues</h2>
<ul class="decisions">
{#each data.decisionsAttendues as decision (decision.numero_affaire)}
<li>
<span class="affaire">{decision.numero_affaire}</span>
{#if decision.texte_id}
<a href="/textes/{decision.texte_id}">{decision.libelle}</a>
{:else}
<span>{decision.libelle}</span>
{/if}
{#if decision.date_echeance}
<span class="quand-decision">{dateLongue(decision.date_echeance)}</span>
{/if}
</li>
{/each}
</ul>
</section>
<section class="section">
<h2>Derniers changements détectés</h2>
{#if data.changements.length}
<ul class="changements">
{#each data.changements as changement, i (i)}
<li>
{#if changement.texte_id}
<a href="/textes/{changement.texte_id}">{changement.titre ?? changement.texte_id}</a>
{:else}
<span>{changement.description}</span>
{/if}
{#if changement.champ}
<span class="diff">
{changement.champ} : {changement.ancienne_valeur ?? '—'}
<strong>{changement.nouvelle_valeur}</strong>
</span>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="vide">
Aucun changement enregistré. Le pipeline n'a pas encore tourné en mode écriture —
lancer <code>make update</code>.
</p>
{/if}
{#if data.run}
<p class="run">
Dernier passage : {data.run.horodatage} ({data.run.mode}),
{data.run.ajouts} ajout(s), {data.run.modifications} modification(s).
</p>
{/if}
</section>
</div>
</div>
<style>
.hero {
padding-block: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--line);
}
.accroche {
color: var(--muted);
max-width: 60ch;
}
.recherche {
display: flex;
gap: 0.5rem;
max-width: 720px;
margin-top: var(--space-2);
}
.recherche input {
flex: 1;
background: var(--card-bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--blanc-creme);
font: inherit;
padding: 0.6rem 0.8rem;
}
.recherche input:focus {
border-color: var(--or-oki);
}
.compteurs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.6rem;
}
.compteur {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.7rem 0.85rem;
background: var(--card-bg);
border: var(--border-card);
border-left: 4px solid var(--or-oki);
border-radius: var(--radius-md);
color: var(--blanc-creme);
text-decoration: none;
}
.compteur:hover {
background: var(--card-bg-survol);
}
.compteur strong {
font-family: var(--font-display);
font-size: 1.6rem;
line-height: 1;
color: var(--or-oki);
}
.compteur span {
font-size: 0.8rem;
color: var(--muted);
}
.raccourcis {
margin-top: var(--space-2);
}
.deux-colonnes {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
gap: var(--space-3);
}
@media (max-width: 900px) {
.deux-colonnes {
grid-template-columns: 1fr;
}
}
.echeances {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.echeance {
border-left-color: var(--muted-fort);
}
.echeance.gwada {
border-left-color: var(--turquoise-caraibes);
}
.echeance h3 {
font-size: 0.95rem;
text-transform: none;
margin: 0.2rem 0 0.3rem;
}
.echeance p {
font-size: 0.85rem;
color: var(--muted);
margin: 0 0 0.3rem;
}
.quand {
display: flex;
gap: 0.6rem;
align-items: baseline;
font-size: 0.8rem;
}
.quand time {
color: var(--or-oki);
font-weight: 600;
}
.dans {
color: var(--muted-fort);
}
.hors-gwada {
color: var(--rouge-oki) !important;
font-weight: 600;
}
.note-gwada {
color: var(--turquoise-caraibes) !important;
}
.analyse h3 {
text-transform: none;
font-size: 1rem;
}
.analyse p {
font-size: 0.87rem;
color: var(--muted);
}
.implications {
border-left: 2px solid var(--or-oki);
padding-left: 0.7rem;
}
.navigation-analyse {
justify-content: space-between;
}
.position {
font-size: 0.78rem;
color: var(--muted-fort);
font-variant-numeric: tabular-nums;
}
.decisions,
.changements {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
font-size: 0.85rem;
}
.decisions li,
.changements li {
padding-bottom: 0.45rem;
border-bottom: 1px solid var(--line);
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: baseline;
}
.affaire {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--orange-flamme);
flex: none;
}
.quand-decision,
.diff {
color: var(--muted-fort);
font-size: 0.78rem;
}
.vide,
.run {
font-size: 0.82rem;
color: var(--muted-fort);
}
code {
font-family: var(--font-mono);
font-size: 0.85em;
background: var(--card-bg);
padding: 0.1em 0.3em;
border-radius: 2px;
}
</style>
+121
View File
@@ -0,0 +1,121 @@
<svelte:head>
<title>À propos · Veille législative Gwadloup</title>
<meta
name="description"
content="Qui édite cette veille législative, avec quels moyens, sous quelle licence, et comment la reprendre."
/>
</svelte:head>
<h1>À propos</h1>
<div class="prose">
<p class="chapeau">
Cette plateforme suit la production législative française et ce qu'elle change aux libertés
des individus, des associations et des entreprises — avec un zoom Guadeloupe et outre-mer.
</p>
<h2>Pourquoi</h2>
<p>
Les textes qui restreignent ou étendent des libertés se lisent rarement dans un seul journal.
Ils arrivent par morceaux : un article dans une loi de simplification, un cavalier censuré
dans une loi de finances, un décret d'application six mois plus tard. Suivre cela demande du
temps que peu d'associations et de petites structures ont.
</p>
<p>
Cette veille rassemble ce travail au même endroit, avec les sources en clair, et signale
explicitement ce qui n'est pas établi.
</p>
<h2>Ce que la plateforme ne fait pas</h2>
<ul>
<li>Elle ne donne pas de conseil juridique. Un texte cité ici ne remplace pas un avocat.</li>
<li>
Elle n'invente rien. Quand une information manque, elle manque : la fiche le dit, plutôt
que de combler le vide.
</li>
<li>
Elle ne suit pas votre navigation. Aucun traceur, aucun cookie de mesure, aucune requête
vers un service tiers — polices comprises.
</li>
</ul>
<h2>Technique</h2>
<p>
Tout est libre et auto-hébergeable sur un serveur modeste : un pipeline Python qui lit les
sources officielles, une base SQLite unique, une application SvelteKit rendue côté serveur.
La recherche plein texte est assurée par SQLite lui-même. Aucun service externe n'est
nécessaire au fonctionnement.
</p>
<p>
Le code est sous licence AGPL. La sauvegarde consiste à copier un fichier.
</p>
<h2>Reprendre les données</h2>
<p>
Une interface de programmation en lecture est ouverte, sans clé ni inscription :
</p>
<ul class="api">
<li><code>GET /api/textes</code> — liste filtrable</li>
<li><code>GET /api/textes/[identifiant]</code> — fiche complète avec sources</li>
<li><code>GET /api/echeances</code> — calendrier</li>
</ul>
<p>
Si vous réutilisez ces données, citez la source primaire plutôt que cette plateforme : elle
n'est qu'un intermédiaire.
</p>
<h2>Contact</h2>
<p>
Une erreur, un texte manquant, une source cassée ? Signalez-le — une veille se corrige à
plusieurs. <a href="mailto:cyber.mawonaj@gmail.com">cyber.mawonaj@gmail.com</a>
</p>
<p class="lien-methode">
<a class="bouton" href="/methode"><span>Lire la méthode en détail</span></a>
</p>
</div>
<style>
.prose {
max-width: 68ch;
}
.chapeau {
font-size: 1.05rem;
color: var(--blanc-creme);
}
.prose h2 {
font-size: 0.85rem;
letter-spacing: 0.05em;
color: var(--or-oki);
border-bottom: 1px solid var(--line);
padding-bottom: 0.35rem;
margin-top: var(--space-4);
}
.prose p,
.prose li {
color: var(--muted);
}
.prose ul {
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.prose li::marker {
color: var(--or-oki);
}
code {
font-family: var(--font-mono);
font-size: 0.88em;
background: var(--card-bg);
border: 1px solid var(--line);
padding: 0.1em 0.35em;
border-radius: 2px;
}
.api {
font-size: 0.9rem;
}
.lien-methode {
margin-top: var(--space-4);
}
</style>
+5
View File
@@ -0,0 +1,5 @@
/**
* Page figée : aucun contenu dynamique, donc exclue du manifeste du rendu
* serveur et servie en statique (§3bis de l'architecture de rendu).
*/
export const prerender = true;
+26
View File
@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import { calendrier, decisionsAttendues, prochainesEcheances } from '$serveur/requetes';
import type { RequestHandler } from './$types';
/**
* `GET /api/echeances` — calendrier.
*
* `?jours=N` restreint aux N prochains jours ; sans paramètre, tout le
* calendrier est rendu, décisions attendues comprises.
*/
export const GET: RequestHandler = async ({ url }) => {
const jours = Number.parseInt(url.searchParams.get('jours') ?? '', 10);
return json(
{
echeances: Number.isFinite(jours) && jours > 0 ? prochainesEcheances(jours) : calendrier(),
decisions_attendues: decisionsAttendues()
},
{
headers: {
'cache-control': 'public, max-age=300',
'access-control-allow-origin': '*'
}
}
);
};
+36
View File
@@ -0,0 +1,36 @@
import { json } from '@sveltejs/kit';
import { lireFiltres, rechercher } from '$serveur/requetes';
import type { RequestHandler } from './$types';
/**
* `GET /api/textes` — liste filtrable.
*
* Mêmes paramètres que la page de recherche : `q`, `statut`, `theme`, `type`,
* `impact`, `guadeloupe`, `confiance`, `from`, `to`, `tri`, `page`. Les
* paramètres multivalués acceptent la répétition (`?theme=sante&theme=social`)
* comme la virgule (`?theme=sante,social`).
*
* Ouverte sans clé : ces données sont publiques et proviennent de sources
* publiques. Le cache est court — le pipeline peut écrire à tout moment.
*/
export const GET: RequestHandler = async ({ url }) => {
const filtres = lireFiltres(url.searchParams);
const resultats = rechercher(filtres);
return json(
{
filtres,
total: resultats.total,
page: resultats.page,
pages: resultats.pages,
textes: resultats.textes,
facettes: resultats.facettes
},
{
headers: {
'cache-control': 'public, max-age=300',
'access-control-allow-origin': '*'
}
}
);
};
@@ -0,0 +1,16 @@
import { error, json } from '@sveltejs/kit';
import { texte } from '$serveur/requetes';
import type { RequestHandler } from './$types';
/** `GET /api/textes/[slug]` — fiche complète, sources et timeline comprises. */
export const GET: RequestHandler = async ({ params }) => {
const fiche = texte(params.slug);
if (!fiche) error(404, { message: `Texte inconnu : ${params.slug}` });
return json(fiche, {
headers: {
'cache-control': 'public, max-age=300',
'access-control-allow-origin': '*'
}
});
};
+22
View File
@@ -0,0 +1,22 @@
import { calendrier, decisionsAttendues } from '$serveur/requetes';
import { DATE_ARRETE_CORPUS } from '$serveur/db';
import type { PageServerLoad } from './$types';
/** Calendrier — rendu serveur : les échéances vivent avec la base. */
export const load: PageServerLoad = async () => {
const echeances = calendrier();
// Regroupement par mois, dans l'ordre chronologique.
const parMois = new Map<string, typeof echeances>();
for (const echeance of echeances) {
const cle = echeance.date_echeance.slice(0, 7);
if (!parMois.has(cle)) parMois.set(cle, []);
parMois.get(cle)!.push(echeance);
}
return {
mois: [...parMois.entries()].map(([cle, liste]) => ({ cle, echeances: liste })),
decisions: decisionsAttendues(),
dateArrete: DATE_ARRETE_CORPUS
};
};
+209
View File
@@ -0,0 +1,209 @@
<script lang="ts">
import { dateLongue, joursRestants, mois as libelleMois } from '$lib/format';
import type { PageData } from './$types';
const { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>Calendrier des échéances · Veille législative Gwadloup</title>
<meta
name="description"
content="Décisions attendues du Conseil constitutionnel, entrées en vigueur du 1er septembre 2026, sénatoriales du 27 septembre, projet de loi de finances 2027."
/>
</svelte:head>
<h1>Calendrier</h1>
<p class="intro">
Échéances de la fenêtre de veille. Les dates postérieures au
{dateLongue(data.dateArrete)} sont <strong>prévisionnelles</strong> : elles indiquent ce qui est
attendu, jamais ce qui est acquis.
</p>
{#if data.decisions.length}
<section class="section">
<h2>Décisions attendues du Conseil constitutionnel</h2>
<ul class="decisions">
{#each data.decisions as decision (decision.numero_affaire)}
<li class="carte">
<span class="affaire">{decision.numero_affaire}</span>
<div>
{#if decision.texte_id}
<a href="/textes/{decision.texte_id}">{decision.libelle}</a>
{:else}
<span>{decision.libelle}</span>
{/if}
{#if decision.date_echeance}
<p class="quand">Attendue vers le {dateLongue(decision.date_echeance)}</p>
{/if}
</div>
</li>
{/each}
</ul>
</section>
{/if}
{#each data.mois as bloc (bloc.cle)}
<section class="section mois">
<h2>{libelleMois(bloc.cle + '-01')}</h2>
<ol class="liste">
{#each bloc.echeances as echeance (echeance.code)}
{@const jours = joursRestants(echeance.date_echeance, data.dateArrete)}
<li
class="carte entree"
class:gwada={echeance.concerne_guadeloupe === 1}
class:hors-gwada={echeance.concerne_guadeloupe === 0}
class:passe={jours < 0}
>
<div class="date">
<time datetime={echeance.date_echeance}>{dateLongue(echeance.date_echeance)}</time>
{#if echeance.previsionnel === 1}
<span class="prevu">prévisionnel</span>
{/if}
</div>
<div class="contenu">
<h3>
{#if echeance.texte_id}
<a href="/textes/{echeance.texte_id}">{echeance.libelle}</a>
{:else}
{echeance.libelle}
{/if}
</h3>
{#if echeance.description}<p>{echeance.description}</p>{/if}
{#if echeance.concerne_guadeloupe === 0}
<p class="mention-gwada negative">Ne concerne pas la Guadeloupe</p>
{:else if echeance.concerne_guadeloupe === 1}
<p class="mention-gwada positive">Concerne la Guadeloupe</p>
{/if}
{#if echeance.note_guadeloupe}<p class="note">{echeance.note_guadeloupe}</p>{/if}
{#if echeance.source_extrait}
<blockquote>{echeance.source_extrait}</blockquote>
{/if}
{#if echeance.source_url}
<a class="source" href={echeance.source_url} rel="noopener nofollow" target="_blank">
Source
</a>
{/if}
</div>
</li>
{/each}
</ol>
</section>
{/each}
<style>
.intro {
max-width: 70ch;
color: var(--muted);
}
.mois h2,
.section h2 {
font-size: 0.85rem;
letter-spacing: 0.05em;
color: var(--or-oki);
border-bottom: 1px solid var(--line);
padding-bottom: 0.35rem;
}
.liste,
.decisions {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.decisions li {
display: flex;
gap: 0.8rem;
align-items: baseline;
border-left-color: var(--orange-flamme);
}
.affaire {
font-family: var(--font-mono);
font-size: 0.82rem;
color: var(--orange-flamme);
flex: none;
}
.quand {
font-size: 0.8rem;
color: var(--muted-fort);
margin: 0.2rem 0 0;
}
.entree {
display: grid;
grid-template-columns: minmax(150px, auto) 1fr;
gap: var(--space-2);
border-left-color: var(--muted-fort);
}
.entree.gwada {
border-left-color: var(--turquoise-caraibes);
}
.entree.hors-gwada {
border-left-color: var(--rouge-oki);
}
.entree.passe {
opacity: 0.65;
}
.date {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
time {
color: var(--or-oki);
font-weight: 600;
font-size: 0.85rem;
}
.prevu {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted-fort);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0 0.35em;
align-self: flex-start;
}
.contenu h3 {
font-size: 0.95rem;
text-transform: none;
margin-bottom: 0.3rem;
}
.contenu p {
font-size: 0.85rem;
color: var(--muted);
margin: 0 0 0.35rem;
}
.mention-gwada {
font-weight: 600;
font-size: 0.82rem !important;
}
.mention-gwada.negative {
color: var(--rouge-oki) !important;
}
.mention-gwada.positive {
color: var(--turquoise-caraibes) !important;
}
.note {
color: var(--turquoise-caraibes) !important;
}
blockquote {
margin: 0.4rem 0;
padding-left: 0.8rem;
border-left: 2px solid var(--line);
font-size: 0.82rem;
color: var(--muted-fort);
font-style: italic;
}
.source {
font-size: 0.78rem;
}
@media (max-width: 620px) {
.entree {
grid-template-columns: 1fr;
gap: 0.4rem;
}
}
</style>
+206
View File
@@ -0,0 +1,206 @@
<svelte:head>
<title>Méthode · Veille législative Gwadloup</title>
<meta
name="description"
content="Comment cette veille distingue promulgué, adopté et en navette ; d'où viennent les données ; ce qu'elles ne disent pas."
/>
</svelte:head>
<h1>Méthode</h1>
<div class="prose">
<p class="chapeau">
Une veille législative ne vaut que par ce qu'elle refuse d'affirmer. Cette page dit d'où
viennent les données, comment elles sont qualifiées, et où sont leurs limites.
</p>
<h2>La règle d'or : le statut prime sur l'intitulé</h2>
<p>
Un texte « voté » n'est pas un texte applicable. Entre le vote et l'entrée en vigueur, il
peut être censuré, amputé, ou attendre des décrets pendant des mois. La confusion entre ces
états est la première source d'erreur dans le débat public — et elle change tout pour qui
veut savoir ce qui s'applique aujourd'hui.
</p>
<dl class="statuts">
<dt>Promulguée</dt>
<dd>
Le texte porte un numéro de loi et a été publié au Journal officiel. Il produit ses
effets, sous réserve de ses propres dates d'entrée en vigueur différées.
</dd>
<dt>Adoptée, non promulguée</dt>
<dd>
Le Parlement a voté définitivement, mais le texte n'est pas signé. Il n'a pas de numéro.
Il ne s'applique pas. Une saisine du Conseil constitutionnel suspend la promulgation
pendant un mois.
</dd>
<dt>Devant le Conseil constitutionnel</dt>
<dd>
Une affaire est enregistrée et la décision n'est pas rendue. Le texte peut en sortir
intact, amputé, ou annulé. Tant que la décision n'est pas rendue, son contenu applicable
est <em>inconnu</em>.
</dd>
<dt>Validée, en attente de promulgation</dt>
<dd>
Le Conseil a statué favorablement ; il ne manque que la signature. La promulgation est
très probable mais pas encore intervenue.
</dd>
<dt>En navette</dt>
<dd>
Le texte fait l'aller-retour entre l'Assemblée et le Sénat. Son contenu bouge encore.
Rien de ce qu'il contient n'est acquis.
</dd>
<dt>Déposée, non examinée · Annoncée</dt>
<dd>
Le texte existe sur le papier — ou seulement dans une déclaration. Il documente une
intention politique, pas le droit positif.
</dd>
</dl>
<h2>D'où viennent les données</h2>
<p>
Le socle est un corpus de recherche arrêté au <strong>25 juillet 2026</strong>, couvrant la
production législative du 1<sup>er</sup> mai au 30 septembre 2026 : un rapport consolidé de
onze chapitres, douze rapports de dimension thématique, une bibliographie de 481 références,
et un fichier d'arbitrage des désaccords tranchés sur sources primaires.
</p>
<p>
Chaque fait importé conserve sa citation d'origine — adresse, date, extrait verbatim — et son
niveau de confiance. Les marqueurs de citation du rapport sont résolus vers leur source :
aucune affirmation n'entre en base sans que l'on puisse remonter à ce qui l'atteste.
</p>
<h2>Comment la base se met à jour</h2>
<p>
Un pipeline interroge régulièrement les sources officielles : Légifrance par son interface de
programmation publique, la liste chronologique des lois promulguées du Sénat, et le registre
des affaires du Conseil constitutionnel. Chaque source est interrogée séparément ; si l'une
est indisponible, les autres continuent.
</p>
<p>
Un texte collecté est rapproché de la base par son numéro officiel, à défaut par similarité
de titre. Entre deux seuils, le rapprochement est <strong>signalé mais pas appliqué</strong> :
mieux vaut une vérification humaine qu'une fusion erronée. En cas de contradiction entre
sources, la source primaire l'emporte — Journal officiel et Légifrance devant les assemblées,
les assemblées devant la presse.
</p>
<h2>Ce qui est déduit, et signalé comme tel</h2>
<p>
Certaines informations ne figurent nulle part explicitement et sont déduites. Elles portent
alors la mention <strong>« à vérifier »</strong> et un motif lisible sur la fiche :
</p>
<ul>
<li>
<strong>Pertinence Guadeloupe des textes non promulgués.</strong> Le rapport ne cote que
les lois promulguées ; pour les autres, la cotation vient d'une détection de vocabulaire.
</li>
<li>
<strong>Sens des impacts par public.</strong> Le rapport consacre un chapitre aux
individus, un aux associations, un aux entreprises : le placement d'un texte dans l'un de
ces chapitres est un choix éditorial explicite, et sert de base à la ventilation. Le sens
— favorable, défavorable, mixte — vient de la cotation manuelle quand elle existe, du
vocabulaire sinon.
</li>
<li>
<strong>Thèmes.</strong> Déduits par mots-clés sur l'intitulé et le résumé.
</li>
<li>
<strong>Textes ajoutés par le pipeline.</strong> Ils entrent avec une confiance basse et
le drapeau de revue : la machine propose, un humain valide.
</li>
</ul>
<h2>Les échéances sont des prévisions</h2>
<p>
Toute date postérieure au 25 juillet 2026 est prévisionnelle, et affichée comme telle. Une
décision « attendue vers le 24 août » peut tomber avant, après, ou être reportée. Les
libellés du corpus — « fin août », « automne 2026 » — sont conservés tels quels ; la date
associée ne sert qu'au tri et au filtrage.
</p>
<h2>Limites connues</h2>
<ul>
<li>
Le corpus s'arrête au 25 juillet 2026. Tout ce qui suit dépend des passages du pipeline.
</li>
<li>
Certaines références du corpus n'ont pas d'adresse consultable — surtout dans la
dimension consacrée aux associations. Elles sont conservées comme références
bibliographiques, sans lien.
</li>
<li>
Le contenu du titre « adaptation outre-mer » de la loi « Riposte » n'était pas public à
la date d'arrêté. Les effets ultramarins de ce texte sont donc incomplets.
</li>
<li>
La veille suit les lois, pas les décrets d'application. Or c'est souvent au niveau
réglementaire que se jouent les effets concrets — les décrets chlordécone en sont
l'exemple.
</li>
</ul>
<h2>Corriger une erreur</h2>
<p>
Si une fiche est fausse, elle doit être corrigée, pas défendue.
<a href="mailto:cyber.mawonaj@gmail.com">Signalez-la</a> en indiquant l'identifiant du texte
et la source qui vous donne raison.
</p>
</div>
<style>
.prose {
max-width: 70ch;
}
.chapeau {
font-size: 1.05rem;
color: var(--blanc-creme);
}
.prose h2 {
font-size: 0.85rem;
letter-spacing: 0.05em;
color: var(--or-oki);
border-bottom: 1px solid var(--line);
padding-bottom: 0.35rem;
margin-top: var(--space-4);
}
.prose p,
.prose li,
.prose dd {
color: var(--muted);
}
.prose ul {
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prose li::marker {
color: var(--or-oki);
}
.statuts {
display: grid;
gap: 0.3rem 0;
margin: var(--space-2) 0;
}
.statuts dt {
font-family: var(--font-display);
font-weight: 700;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--blanc-creme);
margin-top: var(--space-2);
}
.statuts dd {
margin: 0;
padding-left: 0.9rem;
border-left: 2px solid var(--line);
font-size: 0.9rem;
}
</style>
+2
View File
@@ -0,0 +1,2 @@
/** Page figée, prérendue au build — voir a-propos/+page.ts. */
export const prerender = true;
+34
View File
@@ -0,0 +1,34 @@
import { basculerFacette, lireFiltres, rechercher } from '$serveur/requetes';
import type { PageServerLoad } from './$types';
/**
* Recherche à facettes — rendu serveur, jamais prérendue.
*
* Les facettes vivent dans `url.searchParams` : une URL de recherche est
* partageable et se recharge à l'identique. Le prérendu interdit d'y accéder,
* c'est pourquoi cette route reste en rendu serveur (§3bis).
*/
export const load: PageServerLoad = async ({ url }) => {
const filtres = lireFiltres(url.searchParams);
const resultats = rechercher(filtres);
return {
filtres,
resultats,
// Les liens de facette sont calculés côté serveur : la page fonctionne
// entièrement sans JavaScript.
liens: Object.fromEntries(
(['statut', 'theme', 'type', 'impact', 'guadeloupe', 'confiance'] as const).map(
(champ) => [
champ,
Object.fromEntries(
resultats.facettes[champ === 'guadeloupe' ? 'guadeloupe' : champ].map((f) => [
f.valeur,
basculerFacette(filtres, champ, f.valeur)
])
)
]
)
)
};
};
+215
View File
@@ -0,0 +1,215 @@
<script lang="ts">
import CarteTexte from '$composants/CarteTexte.svelte';
import PanneauFacettes from '$composants/PanneauFacettes.svelte';
import { pluriel } from '$lib/format';
import type { Filtres } from '$lib/types';
import type { PageData } from './$types';
const { data }: { data: PageData } = $props();
const lien = (champ: keyof Filtres, valeur: string) =>
(data.liens as Record<string, Record<string, string>>)[champ as string]?.[valeur] ??
'/recherche';
const chipsActifs = $derived(
(['statut', 'guadeloupe', 'impact', 'theme', 'type', 'confiance'] as const).flatMap(
(champ) =>
data.resultats.facettes[champ]
.filter((f) => f.actif)
.map((f) => ({ champ, ...f }))
)
);
function urlPage(numero: number): string {
const parametres = new URLSearchParams(
typeof window === 'undefined' ? '' : window.location.search
);
parametres.set('page', String(numero));
return `/recherche?${parametres.toString()}`;
}
</script>
<svelte:head>
<title>
{data.filtres.q ? `« ${data.filtres.q} » recherche` : 'Recherche'} · Veille législative
</title>
<meta name="robots" content="noindex, follow" />
</svelte:head>
<h1>Recherche</h1>
<form class="recherche" action="/recherche" method="get" role="search">
<label class="visuellement-cache" for="q">Rechercher</label>
<input id="q" name="q" type="search" value={data.filtres.q} placeholder="Mot-clé, numéro de loi…" />
{#each ['statut', 'theme', 'type', 'impact', 'guadeloupe', 'confiance'] as champ (champ)}
{#each data.filtres[champ as keyof Filtres] as string[] as valeur (valeur)}
<input type="hidden" name={champ} value={valeur} />
{/each}
{/each}
<button class="bouton" type="submit"><span>Chercher</span></button>
</form>
{#if chipsActifs.length}
<div class="ligne chips">
<span class="etiquette">Filtres actifs :</span>
{#each chipsActifs as chip (chip.champ + chip.valeur)}
<a class="chip" href={lien(chip.champ as keyof Filtres, chip.valeur)} rel="nofollow">
{chip.libelle}
<span aria-hidden="true">×</span>
<span class="visuellement-cache">Retirer ce filtre</span>
</a>
{/each}
<a class="chip effacer" href={data.filtres.q ? `/recherche?q=${encodeURIComponent(data.filtres.q)}` : '/recherche'}>
Tout effacer
</a>
</div>
{/if}
<div class="mise-en-page">
<PanneauFacettes facettes={data.resultats.facettes} filtres={data.filtres} {lien} />
<div class="resultats">
<p class="total" role="status">
<strong>{data.resultats.total}</strong>
{pluriel(data.resultats.total, 'texte')}
{#if data.filtres.q}pour « {data.filtres.q} »{/if}
</p>
{#if data.resultats.textes.length}
<div class="liste">
{#each data.resultats.textes as texte (texte.id)}
<CarteTexte {texte} />
{/each}
</div>
{#if data.resultats.pages > 1}
<nav class="pagination" aria-label="Pagination">
{#if data.resultats.page > 1}
<a class="bouton bouton-discret" href={urlPage(data.resultats.page - 1)}>
<span>← Précédent</span>
</a>
{/if}
<span class="position">Page {data.resultats.page} sur {data.resultats.pages}</span>
{#if data.resultats.page < data.resultats.pages}
<a class="bouton bouton-discret" href={urlPage(data.resultats.page + 1)}>
<span>Suivant →</span>
</a>
{/if}
</nav>
{/if}
{:else}
<div class="carte vide">
<h2>Aucun résultat</h2>
<p>
Aucun texte ne correspond à cette combinaison de filtres. Essayez de retirer un
critère, ou de chercher sur un mot plus général.
</p>
<a class="bouton" href="/recherche"><span>Repartir de zéro</span></a>
</div>
{/if}
</div>
</div>
<style>
.recherche {
display: flex;
gap: 0.5rem;
margin-bottom: var(--space-2);
max-width: 720px;
}
.recherche input[type='search'] {
flex: 1;
background: var(--card-bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--blanc-creme);
font: inherit;
padding: 0.55rem 0.8rem;
}
.recherche input:focus {
border-color: var(--or-oki);
}
.chips {
margin-bottom: var(--space-2);
gap: 0.35rem;
}
.etiquette {
font-size: 0.78rem;
color: var(--muted-fort);
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4em;
background: color-mix(in srgb, var(--or-oki) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--or-oki) 55%, transparent);
border-radius: var(--radius-sm);
color: var(--blanc-creme);
font-size: 0.78rem;
padding: 0.18em 0.6em;
text-decoration: none;
}
.chip:hover {
background: color-mix(in srgb, var(--or-oki) 22%, transparent);
}
.chip.effacer {
background: transparent;
border-color: var(--line);
color: var(--muted);
}
.mise-en-page {
display: grid;
grid-template-columns: minmax(210px, 260px) minmax(0, 1fr);
gap: var(--space-3);
align-items: start;
}
@media (max-width: 820px) {
.mise-en-page {
grid-template-columns: 1fr;
}
}
.total {
font-size: 0.9rem;
color: var(--muted);
margin-bottom: var(--space-2);
}
.total strong {
color: var(--or-oki);
font-size: 1.1rem;
}
.liste {
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
margin-top: var(--space-3);
}
.position {
font-size: 0.82rem;
color: var(--muted-fort);
}
.vide {
text-align: center;
padding: var(--space-4) var(--space-2);
border-left-color: var(--muted-fort);
}
.vide h2 {
font-size: 1.05rem;
}
.vide p {
color: var(--muted);
max-width: 46ch;
margin-inline: auto;
}
</style>
@@ -0,0 +1,19 @@
import { error } from '@sveltejs/kit';
import { texte } from '$serveur/requetes';
import { DATE_ARRETE_CORPUS } from '$serveur/db';
import type { PageServerLoad } from './$types';
/**
* Fiche détaillée — rendu serveur.
*
* Pas de prérendu : la fiche doit être fraîche dès que le pipeline écrit dans
* SQLite, sans reconstruction du site. C'est aussi la page la plus utile au
* référencement, donc celle qui gagne le plus au rendu serveur.
*/
export const load: PageServerLoad = async ({ params }) => {
const fiche = texte(params.slug);
if (!fiche) {
error(404, `Aucun texte suivi ne porte l'identifiant « ${params.slug} ».`);
}
return { texte: fiche, dateArrete: DATE_ARRETE_CORPUS };
};
+571
View File
@@ -0,0 +1,571 @@
<script lang="ts">
import BadgeGuadeloupe from '$composants/BadgeGuadeloupe.svelte';
import BadgeStatut from '$composants/BadgeStatut.svelte';
import { dateLongue } from '$lib/format';
import {
LIBELLES_CONFIANCE,
LIBELLES_ETAPE,
LIBELLES_IMPACT,
LIBELLES_RESULTAT_CC,
LIBELLES_THEME,
LIBELLES_TYPE
} from '$lib/libelles';
import type { PageData } from './$types';
const { data }: { data: PageData } = $props();
const t = $derived(data.texte);
const PUBLICS = ['individus', 'associations', 'entreprises'] as const;
let copie = $state(false);
function citation(): string {
const nom = t.numero_officiel ? `Loi n° ${t.numero_officiel}` : t.titre_court;
const date = t.date_promulgation
? `, ${dateLongue(t.date_promulgation)}`
: t.date_adoption
? `, adoptée le ${dateLongue(t.date_adoption)}`
: '';
const url =
typeof window === 'undefined' ? `/textes/${t.id}` : window.location.href;
return `${nom}${t.titre_court}${date}. Veille législative Gwadloup, consulté le ${dateLongue(
new Date().toISOString()
)}. ${url}`;
}
async function copierCitation() {
try {
await navigator.clipboard.writeText(citation());
copie = true;
setTimeout(() => (copie = false), 2500);
} catch {
/* presse-papiers refusé : la citation reste sélectionnable à la main */
}
}
</script>
<svelte:head>
<title>{t.titre_court} · Veille législative Gwadloup</title>
<meta name="description" content={(t.resume ?? t.titre_court).slice(0, 180)} />
</svelte:head>
<nav class="fil" aria-label="Fil d'Ariane">
<a href="/">Tableau de bord</a>
<a href="/recherche">Recherche</a>
<span aria-current="page">{t.numero_officiel ? `Loi n° ${t.numero_officiel}` : t.titre_court}</span>
</nav>
<article class="fiche">
<header>
{#if t.numero_officiel}<p class="numero">Loi n° {t.numero_officiel}</p>{/if}
<h1>{t.titre_court}</h1>
{#if t.titre_officiel && t.titre_officiel !== t.titre_court}
<p class="titre-officiel">{t.titre_officiel}</p>
{/if}
<div class="ligne badges">
<BadgeStatut statut={t.statut} devantCc={t.devant_cc === 1} />
<BadgeGuadeloupe
pertinence={t.guadeloupe_pertinence}
note={t.guadeloupe_note}
aVerifier={t.a_verifier === 1}
/>
<span class="tag">{LIBELLES_TYPE[t.type] ?? t.type}</span>
<span class="tag confiance" data-niveau={t.confiance}>
Confiance : {LIBELLES_CONFIANCE[t.confiance] ?? t.confiance}
</span>
</div>
{#if t.a_verifier === 1 && t.motif_verification}
<aside class="avertissement" role="note">
<strong>À vérifier —</strong>
{t.motif_verification}
</aside>
{/if}
</header>
<div class="corps">
<div class="principal">
{#if t.resume}
<section>
<h2>Résumé</h2>
<p class="resume">{t.resume}</p>
</section>
{/if}
{#if t.points_cles.length}
<section>
<h2>Points clés</h2>
<ul class="points">
{#each t.points_cles as point (point)}
<li>{point}</li>
{/each}
</ul>
</section>
{/if}
{#if Object.keys(t.impacts).length}
<section>
<h2>Effets sur les libertés</h2>
<div class="impacts">
{#each PUBLICS as pub (pub)}
{@const impact = t.impacts[pub]}
{#if impact}
<div class="impact" data-sens={impact.sens}>
<h3>{pub}</h3>
<p class="sens">{LIBELLES_IMPACT[`${pub}:${impact.sens}`] ?? impact.sens}</p>
{#if impact.note}<p class="note">{impact.note}</p>{/if}
</div>
{/if}
{/each}
</div>
</section>
{/if}
{#if t.evenements.length}
<section>
<h2>Parcours du texte</h2>
<ol class="timeline">
{#each t.evenements as evenement (evenement.date_evenement + evenement.type_etape)}
<li class:previsionnel={evenement.previsionnel === 1}>
<time datetime={evenement.date_evenement}>
{dateLongue(evenement.date_evenement)}
</time>
<div>
<strong>{LIBELLES_ETAPE[evenement.type_etape] ?? evenement.type_etape}</strong>
{#if evenement.previsionnel === 1}
<span class="prevu">prévisionnel</span>
{/if}
<p>{evenement.description}</p>
</div>
</li>
{/each}
</ol>
</section>
{/if}
{#if t.decisions_cc.length}
<section>
<h2>Conseil constitutionnel</h2>
{#each t.decisions_cc as decision (decision.numero_affaire)}
<div class="carte decision">
<h3>Affaire n° {decision.numero_affaire}</h3>
<dl>
{#if decision.date_saisine}
<dt>Saisine</dt>
<dd>{dateLongue(decision.date_saisine)}</dd>
{/if}
{#if decision.date_decision}
<dt>Décision</dt>
<dd>{dateLongue(decision.date_decision)}</dd>
{:else if decision.date_decision_attendue}
<dt>Décision attendue</dt>
<dd>{dateLongue(decision.date_decision_attendue)}</dd>
{/if}
{#if decision.resultat}
<dt>Résultat</dt>
<dd>{LIBELLES_RESULTAT_CC[decision.resultat] ?? decision.resultat}</dd>
{/if}
{#if decision.saisissants}
<dt>Saisissants</dt>
<dd>{decision.saisissants}</dd>
{/if}
</dl>
{#if decision.resume}<p>{decision.resume}</p>{/if}
</div>
{/each}
</section>
{/if}
<section>
<h2>Sources ({t.sources.length})</h2>
<p class="avant-sources">
Chaque source est citée telle qu'elle a été consultée. Les sources primaires
(Journal officiel, assemblées, Conseil constitutionnel, Conseil d'État) sont
marquées T1, la presse et les analyses T2.
</p>
<ol class="sources">
{#each t.sources as source (source.url + (source.marqueur ?? ''))}
<li class="carte source" data-tier={source.tier}>
<div class="ligne entete-source">
<span class="tier">{source.tier}</span>
{#if source.editeur}<span class="editeur">{source.editeur}</span>{/if}
{#if source.date_publication}
<time datetime={source.date_publication}>
{dateLongue(source.date_publication)}
</time>
{/if}
</div>
{#if source.titre}<p class="titre-source">{source.titre}</p>{/if}
{#if source.extrait_verbatim}
<blockquote>{source.extrait_verbatim}</blockquote>
{/if}
{#if source.contexte}<p class="contexte">{source.contexte}</p>{/if}
<a class="url" href={source.url} rel="noopener nofollow" target="_blank">
{source.url}
</a>
</li>
{/each}
</ol>
</section>
</div>
<aside class="lateral">
<div class="carte encart">
<h2>Repères</h2>
<dl>
{#if t.date_depot}
<dt>Dépôt</dt>
<dd>{dateLongue(t.date_depot)}</dd>
{/if}
{#if t.date_adoption}
<dt>Adoption</dt>
<dd>{dateLongue(t.date_adoption)}</dd>
{/if}
{#if t.date_promulgation}
<dt>Promulgation</dt>
<dd>{dateLongue(t.date_promulgation)}</dd>
{/if}
{#if t.date_entree_vigueur}
<dt>Entrée en vigueur</dt>
<dd>{dateLongue(t.date_entree_vigueur)}</dd>
{/if}
{#if t.prochaine_echeance_label}
<dt>Prochaine étape</dt>
<dd>{t.prochaine_echeance_label}</dd>
{/if}
</dl>
</div>
{#if t.guadeloupe_note}
<div class="carte encart gwada">
<h2>Guadeloupe</h2>
<p>{t.guadeloupe_note}</p>
</div>
{/if}
{#if t.themes.length}
<div class="carte encart">
<h2>Thèmes</h2>
<div class="ligne">
{#each t.themes as theme (theme)}
<a class="tag" href="/recherche?theme={theme}">
{LIBELLES_THEME[theme] ?? theme}
</a>
{/each}
</div>
</div>
{/if}
<div class="carte encart">
<h2>Citer cette fiche</h2>
<button type="button" class="bouton" onclick={copierCitation}>
<span>{copie ? 'Copié' : 'Copier la citation'}</span>
</button>
{#if t.source_seed}
<p class="provenance">Origine : <code>{t.source_seed}</code></p>
{/if}
{#if t.derniere_verif}
<p class="provenance">Dernière vérification : {t.derniere_verif}</p>
{/if}
</div>
</aside>
</div>
</article>
<style>
.fil {
font-size: 0.78rem;
color: var(--muted-fort);
margin-bottom: var(--space-2);
}
.fil a {
color: var(--muted);
}
.numero {
font-family: var(--font-mono);
color: var(--or-oki);
margin: 0 0 0.2rem;
font-size: 0.9rem;
}
.titre-officiel {
color: var(--muted);
font-size: 0.92rem;
max-width: 72ch;
}
.badges {
margin-bottom: var(--space-2);
}
.confiance[data-niveau='low'] {
border-color: color-mix(in srgb, var(--rouge-oki) 55%, transparent);
background: color-mix(in srgb, var(--rouge-oki) 10%, transparent);
}
.avertissement {
border: 1px solid color-mix(in srgb, var(--or-oki) 55%, transparent);
background: color-mix(in srgb, var(--or-oki) 9%, transparent);
border-radius: var(--radius-md);
padding: 0.7rem 0.9rem;
font-size: 0.86rem;
margin-bottom: var(--space-2);
}
.corps {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, 300px);
gap: var(--space-3);
align-items: start;
}
@media (max-width: 900px) {
.corps {
grid-template-columns: 1fr;
}
}
section {
margin-bottom: var(--space-4);
}
section h2 {
font-size: 0.85rem;
letter-spacing: 0.05em;
color: var(--or-oki);
border-bottom: 1px solid var(--line);
padding-bottom: 0.35rem;
margin-bottom: var(--space-2);
}
.resume {
font-size: 1rem;
max-width: 70ch;
}
.points {
margin: 0;
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
font-size: 0.92rem;
max-width: 74ch;
}
.points li::marker {
color: var(--or-oki);
}
.impacts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.7rem;
}
.impact {
border: var(--border-card);
border-left: 4px solid var(--impact-neutre);
border-radius: var(--radius-md);
padding: 0.7rem 0.85rem;
background: var(--card-bg);
}
.impact[data-sens='positif'] {
border-left-color: var(--impact-positif);
}
.impact[data-sens='negatif'] {
border-left-color: var(--impact-negatif);
}
.impact[data-sens='mixte'] {
border-left-color: var(--impact-mixte);
}
.impact h3 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted-fort);
margin-bottom: 0.2rem;
}
.impact .sens {
font-weight: 600;
font-size: 0.88rem;
margin-bottom: 0.3rem;
}
.impact .note {
font-size: 0.82rem;
color: var(--muted);
margin: 0;
}
.timeline {
list-style: none;
margin: 0;
padding: 0 0 0 1rem;
border-left: 2px solid var(--line);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.timeline li {
position: relative;
display: grid;
grid-template-columns: minmax(120px, auto) 1fr;
gap: 0.8rem;
font-size: 0.88rem;
}
.timeline li::before {
content: '';
position: absolute;
left: -1.35rem;
top: 0.4rem;
width: 8px;
height: 8px;
background: var(--or-oki);
border-radius: 1px;
}
.timeline li.previsionnel::before {
background: transparent;
border: 2px solid var(--or-oki);
}
.timeline time {
color: var(--or-oki);
font-weight: 600;
font-size: 0.82rem;
}
.timeline p {
margin: 0.15rem 0 0;
color: var(--muted);
font-size: 0.85rem;
}
.prevu {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted-fort);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0 0.35em;
margin-left: 0.35em;
}
.decision {
border-left-color: var(--orange-flamme);
margin-bottom: 0.6rem;
}
.decision h3 {
font-family: var(--font-mono);
font-size: 0.9rem;
text-transform: none;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.8rem;
margin: 0 0 0.5rem;
font-size: 0.85rem;
}
dt {
color: var(--muted-fort);
}
dd {
margin: 0;
}
.avant-sources {
font-size: 0.82rem;
color: var(--muted-fort);
max-width: 68ch;
}
.sources {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.source {
border-left-color: var(--muted-fort);
font-size: 0.86rem;
}
.source[data-tier='T1'] {
border-left-color: var(--vert-oki);
}
.entete-source {
gap: 0.5rem;
font-size: 0.76rem;
color: var(--muted-fort);
}
.tier {
font-family: var(--font-mono);
font-weight: 700;
color: var(--vert-oki);
}
.source[data-tier='T2'] .tier {
color: var(--muted-fort);
}
.editeur {
font-weight: 600;
color: var(--blanc-creme);
}
.titre-source {
margin: 0.3rem 0;
}
blockquote {
margin: 0.4rem 0;
padding-left: 0.8rem;
border-left: 2px solid var(--or-oki);
color: var(--muted);
font-style: italic;
}
.contexte {
font-size: 0.82rem;
color: var(--muted-fort);
margin: 0.3rem 0;
}
.url {
font-size: 0.75rem;
word-break: break-all;
font-family: var(--font-mono);
}
.lateral {
display: flex;
flex-direction: column;
gap: 0.7rem;
position: sticky;
top: 5.5rem;
}
.encart h2 {
font-size: 0.75rem;
border: 0;
padding: 0;
margin-bottom: 0.5rem;
}
.encart.gwada {
border-left-color: var(--turquoise-caraibes);
}
.encart p {
font-size: 0.85rem;
color: var(--muted);
margin: 0;
}
.provenance {
font-size: 0.74rem !important;
color: var(--muted-fort) !important;
margin-top: 0.5rem !important;
}
code {
font-family: var(--font-mono);
font-size: 0.9em;
word-break: break-all;
}
@media (max-width: 900px) {
.lateral {
position: static;
}
}
@media (max-width: 560px) {
.timeline li {
grid-template-columns: 1fr;
gap: 0.15rem;
}
}
</style>
+121
View File
@@ -0,0 +1,121 @@
/// <reference types="@sveltejs/kit" />
/// <reference lib="webworker" />
/**
* Service worker minimal — consultation hors ligne des dernières données.
*
* Deux stratégies, et pas une de plus :
*
* - **Le coffre du build** (JS, CSS, polices, icônes) est mis en cache à
* l'installation et servi depuis le cache. Ces fichiers portent une empreinte
* dans leur nom : ils ne changent jamais sous le même nom.
* - **Les pages et l'API** passent par le réseau d'abord, avec repli sur le
* cache. Une veille législative doit montrer l'état le plus récent qu'elle
* peut atteindre ; servir une page périmée alors que le réseau répond serait
* trompeur sur un sujet où une date de promulgation change tout.
*
* Le hors-ligne n'est donc pas un mode de consultation normal : c'est un filet
* pour les connexions instables, fréquentes en zone rurale guadeloupéenne. La
* page servie depuis le cache le signale à l'utilisateur via l'en-tête ajouté
* `X-Depuis-Cache`.
*/
import { build, files, version } from '$service-worker';
const sw = self as unknown as ServiceWorkerGlobalScope;
const CACHE_COFFRE = `coffre-${version}`;
const CACHE_PAGES = `pages-${version}`;
// Les polices et le manifeste valent d'être précachés ; pas les gros binaires.
const A_PRECACHER = [...build, ...files.filter((f) => !f.endsWith('.map'))];
sw.addEventListener('install', (evenement) => {
evenement.waitUntil(
caches.open(CACHE_COFFRE).then((cache) => cache.addAll(A_PRECACHER))
);
// Le nouveau worker prend la main sans attendre la fermeture des onglets :
// une correction de données ne doit pas rester bloquée derrière un onglet
// ouvert depuis une semaine.
sw.skipWaiting();
});
sw.addEventListener('activate', (evenement) => {
evenement.waitUntil(
(async () => {
const noms = await caches.keys();
await Promise.all(
noms
.filter((nom) => nom !== CACHE_COFFRE && nom !== CACHE_PAGES)
.map((nom) => caches.delete(nom))
);
await sw.clients.claim();
})()
);
});
sw.addEventListener('fetch', (evenement) => {
const requete = evenement.request;
if (requete.method !== 'GET') return;
const url = new URL(requete.url);
// Rien qui ne vienne de cette origine : la politique de sécurité de contenu
// interdit déjà les tiers, le worker ne les mettra pas en cache non plus.
if (url.origin !== location.origin) return;
// Coffre du build : cache d'abord, sans condition.
if (A_PRECACHER.includes(url.pathname)) {
evenement.respondWith(
caches
.open(CACHE_COFFRE)
.then((cache) => cache.match(url.pathname))
.then((reponse) => reponse ?? fetch(requete))
);
return;
}
// Pages et API : réseau d'abord, cache en repli.
evenement.respondWith(reseauPuisCache(requete));
});
async function reseauPuisCache(requete: Request): Promise<Response> {
const cache = await caches.open(CACHE_PAGES);
try {
const reponse = await fetch(requete);
if (reponse.ok) {
cache.put(requete, reponse.clone());
}
return reponse;
} catch {
const enCache = await cache.match(requete);
if (enCache) {
// On signale explicitement que la réponse peut être périmée.
const entetes = new Headers(enCache.headers);
entetes.set('X-Depuis-Cache', 'true');
return new Response(enCache.body, {
status: enCache.status,
statusText: enCache.statusText,
headers: entetes
});
}
return new Response(
`<!doctype html><html lang="fr"><head><meta charset="utf-8">
<title>Hors ligne</title>
<style>
body{background:#0D0D0D;color:#FFF8E7;font-family:system-ui,sans-serif;
display:grid;place-items:center;min-height:100vh;margin:0;text-align:center;padding:2rem}
h1{font-size:1.4rem;text-transform:uppercase;letter-spacing:-0.01em}
p{color:#B6B1A6;max-width:40ch}
.bar{height:6px;width:56px;margin:1.5rem auto 0;background:linear-gradient(to right,
#0D0D0D 0 25%,#FDB813 25% 50%,#00D66C 50% 75%,#FF1654 75% 100%)}
</style></head><body><div>
<h1>Pa ni rézo</h1>
<p>Cette page n'a pas encore été consultée et le réseau est indisponible.
Les pages déjà visitées restent accessibles.</p>
<div class="bar"></div>
</div></body></html>`,
{ status: 503, headers: { 'content-type': 'text/html; charset=utf-8' } }
);
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Veille législative Gwadloup">
<rect width="64" height="64" fill="#0D0D0D"/>
<rect x="0" y="52" width="16" height="12" fill="#0D0D0D"/>
<rect x="16" y="52" width="16" height="12" fill="#FDB813"/>
<rect x="32" y="52" width="16" height="12" fill="#00D66C"/>
<rect x="48" y="52" width="16" height="12" fill="#FF1654"/>
<path d="M14 12 L24 44 L34 12" fill="none" stroke="#FDB813" stroke-width="7" stroke-linejoin="miter"/>
<rect x="40" y="12" width="7" height="32" fill="#FDB813"/>
<rect x="40" y="37" width="14" height="7" fill="#FDB813"/>
</svg>

After

Width:  |  Height:  |  Size: 643 B

+17
View File
@@ -0,0 +1,17 @@
{
"name": "Veille législative Gwadloup",
"short_name": "Vèy Lwa",
"description": "Lois françaises et effets sur les libertés des individus, des associations et des entreprises — zoom Guadeloupe.",
"lang": "fr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#0D0D0D",
"theme_color": "#0D0D0D",
"icons": [
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "maskable" }
],
"categories": ["news", "government", "reference"]
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Applique le thème choisi avant le premier rendu.
*
* Le thème sombre est l'identité par défaut : on n'ajoute la classe que si le
* visiteur a explicitement demandé le clair, ou si son système le préfère sans
* qu'il ait exprimé de choix.
*/
(function () {
try {
var choix = localStorage.getItem('theme');
var systemeClair =
window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
if (choix === 'clair' || (choix === null && systemeClair)) {
document.documentElement.classList.add('light-theme');
}
} catch (e) {
/* stockage indisponible : on garde le thème sombre par défaut */
}
})();
+39
View File
@@ -0,0 +1,39 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/**
* Architecture de rendu : « transitional app » en rendu serveur sur
* adapter-node, avec prérendu ciblé des seules pages figées.
*
* Le rendu serveur est le défaut partout : les fiches doivent être fraîches dès
* que le pipeline écrit dans SQLite, et les facettes de recherche vivent dans
* `url.searchParams`, ce que le prérendu interdit.
*
* `precompress` génère les variantes .gz et .br au build ; la négociation est
* faite par le reverse proxy.
*/
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({ precompress: true }),
alias: {
$composants: 'src/lib/composants',
$serveur: 'src/lib/server'
},
csp: {
// Aucune ressource tierce : ni police, ni script, ni image externe.
directives: {
'default-src': ['self'],
'img-src': ['self', 'data:'],
'style-src': ['self', 'unsafe-inline'],
'font-src': ['self'],
'connect-src': ['self'],
'frame-ancestors': ['none'],
'base-uri': ['self'],
'form-action': ['self']
}
}
}
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
// better-sqlite3 est un module natif : il ne doit jamais être empaqueté.
ssr: { external: ['better-sqlite3'] },
build: { target: 'es2022' }
});