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>
572 lines
13 KiB
Svelte
572 lines
13 KiB
Svelte
<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>
|