Files
veye-lalwa/web/src/routes/textes/[slug]/+page.svelte
T
Cyber MawonajandClaude Opus 5 34d81c66a8 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>
2026-07-25 22:34:22 -04:00

572 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>