feat: migre le quiz, renomme en Lagé Chat Control, nettoie l'ère Astro

- Quiz de résistance (/quiz) migré d'Astro vers Svelte 5 : 12 questions,
  contenu fusionné dans content/quiz.json, score calculé localement
  (aucun envoi), barres par domaine, recommandations liées à l'annuaire
  réel (/outils?cat=…), persistance locale, partage natif/presse-papier.
  Audit axe-core : 0 violation.
- Renommage complet « Exit Chat Control » → « Lagé Chat Control » :
  i18n fr/gcf, manifest PWA, package.json, README, design system.
- README racine et frontend/README.md réécrits pour le projet Svelte :
  lancement local (pnpm dev) et déploiement VPS (renvoi vers
  docs/DEPLOYMENT.md), en français.
- CONTRIBUTING.md adapté à la nouvelle structure (content/outils/,
  forge labola) en conservant la politique éditoriale de confiance.
- Nettoyage : suppression de tout l'outillage Astro devenu inutile
  (src/, public/, scripts/, tests/, migration/, docker/, configs racine
  eslint/prettier/vitest/playwright/tsconfig, CI GitHub Actions, script
  de migration one-shot). robots.txt et security.txt recréés sous
  frontend/static/ avec les bonnes références (labola).
- Fix : pin pnpm via frontend/pnpm-workspace.yaml (onlyBuiltDependencies/
  allowBuilds pour esbuild) après suppression du package.json racine qui
  perturbait la résolution de version de corepack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sucupira
2026-07-10 14:02:54 -04:00
parent b13a1e7a31
commit e091ec75f7
208 changed files with 939 additions and 30555 deletions
+42
View File
@@ -0,0 +1,42 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
export interface QuizOption {
points: number
label: string
hint: string
}
export interface QuizQuestion {
id: string
/** slug de content/categories.json — relie chaque question à l'annuaire */
category: string
label: string
prompt: string
rec: string
options: QuizOption[]
}
export interface QuizBand {
id: string
min: number
max: number
title: string
summary: string
}
export interface QuizContent {
bands: QuizBand[]
questions: QuizQuestion[]
}
const CONTENT = path.resolve(process.cwd(), '..', 'content')
let cache: QuizContent | null = null
export function loadQuiz(): QuizContent {
if (!cache) {
cache = JSON.parse(readFileSync(path.join(CONTENT, 'quiz.json'), 'utf8')) as QuizContent
}
return cache
}
+23 -1
View File
@@ -1,6 +1,6 @@
{
"site": {
"name": "Exit Chat Control",
"name": "Lagé Chat Control",
"tagline": "Reprends le contrôle de ton internet",
"description": "Annuaire d'outils libres pour la souveraineté numérique — messagerie chiffrée, VPN, auto-hébergement, OS libres."
},
@@ -111,5 +111,27 @@
"footer": {
"license": "Contenu et code libres (MIT). Zéro tracker, zéro CDN, zéro cookie.",
"contribute": "Proposer un outil ou une correction"
},
"quiz": {
"nav": "Quiz",
"eyebrow": "Évaluer sa résistance à la censure",
"title": "Quiz de résistance",
"lede": "Une boussole, pas un classement : réponds à douze questions rapides pour repérer les points faibles de ta pile — sans jugement — puis suis des pistes concrètes vers l'annuaire.",
"privacyNote": "Le score est calculé localement dans ton navigateur. Rien n'est envoyé, rien n'est enregistré.",
"start": "Commencer le quiz",
"readDirectory": "Voir l'annuaire",
"progress": "{done} / {total} répondues",
"resultEyebrow": "Ton résultat",
"outOf": "/ 100",
"barsHeading": "Score par domaine",
"recHeading": "Tes 3 prochaines étapes",
"openDirectory": "Voir les outils →",
"restart": "Refaire le quiz",
"copy": "Copier le lien du résultat",
"copied": "Lien copié ✓",
"share": "Partager",
"shareText": "Mon score de résistance à la censure : {score}/100 — {band}. Évalue le tien :",
"bandsHeading": "Ce que le score signifie",
"categoriesHeading": "Les douze domaines, et où progresser"
}
}
+23 -1
View File
@@ -1,6 +1,6 @@
{
"site": {
"name": "Exit Chat Control",
"name": "Lagé Chat Control",
"tagline": "Pran kontwòl entènèt a-w",
"description": "Lis a zouti lib pou libèté niméwik — mésajri chifré, VPN, oto-ébèjman, sistèm lib."
},
@@ -111,5 +111,27 @@
"footer": {
"license": "Kontni é kòd lib (MIT). Pon trakè, pon CDN, pon cookie.",
"contribute": "Pwopozé on zouti oben on koreksyon"
},
"quiz": {
"nav": "Quiz",
"eyebrow": "Èvalyé rézistans a-w a sansi-la",
"title": "Quiz rézistans",
"lede": "On bousòl, pa on klasman : réponn douz kèsyon rapid pou trapé pwen fèb an pil a-w — san jijman — apré swiv chimen konkrè vè lis a zouti-la.",
"privacyNote": "Skò-la ka kalkilé lokalman an navigatè a-w. Ayen pa voyé, ayen pa anrèjistré.",
"start": "Koumansé quiz-la",
"readDirectory": "Gadé lis-la",
"progress": "{done} / {total} réponn",
"resultEyebrow": "Rézilta a-w",
"outOf": "/ 100",
"barsHeading": "Skò pa domèn",
"recHeading": "Twa pwochen étap a-w",
"openDirectory": "Gadé zouti yo →",
"restart": "Rifè quiz-la",
"copy": "Kopyé lyen rézilta-la",
"copied": "Lyen kopyé ✓",
"share": "Pataj",
"shareText": "Skò rézistans a sansi a mwen : {score}/100 — {band}. Èvalyé pa a-w :",
"bandsHeading": "Sa skò-la vlé di",
"categoriesHeading": "Douz domèn yo, é kotè pou pwogrésé"
}
}
+1
View File
@@ -35,6 +35,7 @@
<nav aria-label={t('nav.home')}>
<ul class="cluster">
<li><a href="/outils">{t('nav.directory')}</a></li>
<li><a href="/quiz">{t('quiz.nav')}</a></li>
<li><a href="/contribuer">{t('nav.contribute')}</a></li>
</ul>
</nav>
+5
View File
@@ -0,0 +1,5 @@
import { loadQuiz } from '$lib/content/quiz.server'
export function load() {
return loadQuiz()
}
+489
View File
@@ -0,0 +1,489 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte'
import type { QuizContent } from '$lib/content/quiz.server'
let { data }: { data: QuizContent } = $props()
// le contenu du quiz est figé pour la durée de vie de la page (pas de
// navigation interne qui le changerait) — une seule capture au montage
const { bands, questions } = $state.snapshot(data)
const STORAGE_KEY = 'oki-quiz'
function readSaved(): Record<string, number> {
if (typeof localStorage === 'undefined') return {}
try {
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record<
string,
unknown
>
const out: Record<string, number> = {}
for (const [k, v] of Object.entries(parsed)) if (typeof v === 'number') out[k] = v
return out
} catch {
return {}
}
}
// Un seul état source : { questionId: points }. Persisté en local
// uniquement (doctrine §4.3 privacyNote) — jamais envoyé nulle part.
let answers = $state<Record<string, number>>(readSaved())
$effect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(answers))
} catch {
/* mode privé : le quiz fonctionne quand même pour cette visite */
}
})
let answeredCount = $derived(Object.keys(answers).length)
let total = questions.length
let complete = $derived(answeredCount === total)
let totalPoints = $derived(Object.values(answers).reduce((s, p) => s + p, 0))
let maxPoints = $derived(
questions.reduce((s, q) => s + Math.max(...q.options.map((o) => o.points)), 0)
)
let score = $derived(maxPoints > 0 ? Math.round((totalPoints / maxPoints) * 100) : 0)
let band = $derived(bands.find((b) => score >= b.min && score <= b.max) ?? bands[0])
interface Ratio {
question: (typeof questions)[number]
points: number
max: number
ratio: number
}
let ratios = $derived<Ratio[]>(
questions.map((q) => {
const max = Math.max(...q.options.map((o) => o.points))
const points = answers[q.id] ?? 0
return { question: q, points, max, ratio: max > 0 ? points / max : 0 }
})
)
let weakest = $derived([...ratios].sort((a, b) => a.ratio - b.ratio).slice(0, 3))
let resultPanel: HTMLElement | undefined = $state()
let wasComplete = false
$effect(() => {
if (complete && !wasComplete) resultPanel?.scrollIntoView({ behavior: 'smooth', block: 'start' })
wasComplete = complete
})
function restart() {
answers = {}
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
/* rien à effacer */
}
document.getElementById('quiz-form')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
let copied = $state(false)
function shareUrl() {
return location.href.replace(/[?#].*$/, '')
}
function shareMessage() {
return t('quiz.shareText').replace('{score}', String(score)).replace('{band}', band.title)
}
async function copyLink() {
try {
await navigator.clipboard.writeText(`${shareMessage()} ${shareUrl()}`)
copied = true
setTimeout(() => (copied = false), 2000)
} catch {
/* presse-papier indisponible */
}
}
let canNativeShare = $derived(typeof navigator !== 'undefined' && 'share' in navigator)
async function nativeShare() {
try {
await navigator.share({ title: t('quiz.title'), text: shareMessage(), url: shareUrl() })
} catch {
/* annulé ou non supporté */
}
}
</script>
<svelte:head>
<title>{t('quiz.title')} — {t('site.name')}</title>
<meta name="description" content={t('quiz.lede')} />
</svelte:head>
<header class="stack">
<p class="eyebrow">{t('quiz.eyebrow')}</p>
<h1>{t('quiz.title')}</h1>
<p class="prose">{t('quiz.lede')}</p>
<p class="privacy">{t('quiz.privacyNote')}</p>
<p class="cluster">
<a class="cta" href="#quiz-form">{t('quiz.start')}</a>
<a class="secondary" href="/outils">{t('quiz.readDirectory')}</a>
</p>
<noscript>
<p class="privacy">{t('quiz.privacyNote')} — JavaScript est requis pour calculer ton score.</p>
</noscript>
</header>
{#if answeredCount > 0}
<!-- point d'arrêt naturel : la progression est visible, jamais un
chargement automatique en arrière-plan (doctrine §1.5) -->
<div class="progress-block" role="status">
<p class="progress-text">{t('quiz.progress').replace('{done}', String(answeredCount)).replace('{total}', String(total))}</p>
<div class="progress-track" aria-hidden="true">
<div class="progress-fill" style:--ratio="{(answeredCount / total) * 100}%"></div>
</div>
</div>
{/if}
<form id="quiz-form" class="stack" style="--gap: var(--space-4)">
<ol class="questions stack" style="--gap: var(--space-4)">
{#each questions as question, i (question.id)}
<li>
<fieldset class="question stack">
<legend>
<span class="qnum" aria-hidden="true">{String(i + 1).padStart(2, '0')}</span>
{question.prompt}
</legend>
<div class="options">
{#each question.options as option (option.label)}
<label class="option" class:checked={answers[question.id] === option.points}>
<input
type="radio"
name={question.id}
value={option.points}
checked={answers[question.id] === option.points}
onchange={() => (answers = { ...answers, [question.id]: option.points })}
/>
<span class="option-body">
<span class="option-label">{option.label}</span>
<span class="option-hint">{option.hint}</span>
</span>
</label>
{/each}
</div>
</fieldset>
</li>
{/each}
</ol>
</form>
{#if complete}
<section class="result stack" bind:this={resultPanel} tabindex="-1" aria-live="polite">
<p class="eyebrow">{t('quiz.resultEyebrow')}</p>
<p class="score"><strong>{score}</strong><span>{t('quiz.outOf')}</span></p>
<p class="band-title">{band.title}</p>
<p class="prose">{band.summary}</p>
<h2>{t('quiz.barsHeading')}</h2>
<div class="bars stack" style="--gap: var(--space-2)">
{#each ratios as r (r.question.id)}
<div class="bar-row">
<span class="bar-label">{r.question.label}</span>
<div class="bar-track">
<div class="bar-fill" style:--ratio="{r.ratio * 100}%"></div>
</div>
<strong class="bar-value">{r.points}/{r.max}</strong>
</div>
{/each}
</div>
<h2>{t('quiz.recHeading')}</h2>
<div class="card-grid">
<ul>
{#each weakest as r (r.question.id)}
<li class="rec-card stack">
<strong>{r.question.label}</strong>
<p class="prose">{r.question.rec}</p>
<a href="/outils?cat={r.question.category}">{t('quiz.openDirectory')}</a>
</li>
{/each}
</ul>
</div>
<div class="cluster actions">
<button type="button" onclick={copyLink}>{copied ? t('quiz.copied') : t('quiz.copy')}</button>
{#if canNativeShare}
<button type="button" onclick={nativeShare}>{t('quiz.share')}</button>
{/if}
<button type="button" class="restart" onclick={restart}>{t('quiz.restart')}</button>
</div>
</section>
{/if}
<section class="key stack">
<h2>{t('quiz.bandsHeading')}</h2>
<ul class="band-key">
{#each bands as b (b.id)}
<li>
<strong>{b.title}</strong>
<span class="range">{b.min}{b.max}</span>
<p class="prose">{b.summary}</p>
</li>
{/each}
</ul>
<h2>{t('quiz.categoriesHeading')}</h2>
<div class="card-grid">
<ul>
{#each questions as q (q.id)}
<li class="cat-card stack">
<strong>{q.label}</strong>
<p class="prose">{q.rec}</p>
<a href="/outils?cat={q.category}">{t('quiz.openDirectory')}</a>
</li>
{/each}
</ul>
</div>
</section>
<style>
.eyebrow {
font-family: var(--font-text);
font-size: var(--text-0);
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--accent-ink);
}
.privacy {
font-size: var(--text-0);
color: var(--oki-ink-2);
}
.cta {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-4);
background: var(--oki-jaune);
color: var(--oki-noir);
font-weight: 700;
border-radius: var(--radius-2);
text-decoration: none;
}
.secondary {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-4);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
text-decoration: none;
color: var(--oki-ink);
}
/* ── progression ──────────────────────────────────────────────── */
.progress-block {
position: sticky;
top: 0;
z-index: 5;
background: color-mix(in oklch, var(--oki-surface) 92%, transparent);
backdrop-filter: blur(4px);
padding-block: var(--space-2);
}
.progress-text {
font-size: var(--text-0);
color: var(--oki-ink-2);
margin-block-end: var(--space-1);
}
.progress-track,
.bar-track {
inline-size: 100%;
block-size: 0.5rem;
background: var(--oki-surface-2);
border-radius: 999px;
overflow: hidden;
}
.progress-fill,
.bar-fill {
inline-size: var(--ratio, 0%);
block-size: 100%;
background: var(--oki-jaune);
border-radius: inherit;
transition: inline-size var(--duration-2) var(--ease-out);
}
/* ── questions ────────────────────────────────────────────────── */
.question {
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
padding: var(--space-4);
}
legend {
font-weight: 700;
padding-inline: var(--space-1);
}
.qnum {
color: var(--accent-ink);
font-family: var(--font-display);
margin-inline-end: var(--space-2);
}
.options {
display: grid;
gap: var(--space-2);
grid-template-columns: 1fr;
margin-block-start: var(--space-3);
}
@container (min-width: 44rem) {
.options {
grid-template-columns: repeat(2, 1fr);
}
}
.option {
display: flex;
align-items: flex-start;
gap: var(--space-2);
min-height: var(--tap-target);
padding: var(--space-3);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
background: var(--oki-surface-2);
cursor: pointer;
}
.option.checked {
border-color: var(--oki-jaune);
background: var(--oki-surface);
outline: 2px solid var(--oki-jaune);
outline-offset: -1px;
}
.option input {
margin-block-start: 0.2em;
inline-size: 1.2rem;
block-size: 1.2rem;
accent-color: var(--oki-jaune);
flex-shrink: 0;
}
.option-body {
display: flex;
flex-direction: column;
gap: 0.2em;
}
.option-label {
font-weight: 700;
font-size: var(--text-0);
}
.option-hint {
font-size: var(--text-0);
color: var(--oki-ink-2);
}
/* ── résultat ──────────────────────────────────────────────────── */
.result {
border: 1px solid var(--oki-line);
border-inline-start: 4px solid var(--oki-jaune);
border-radius: var(--radius-2);
padding: var(--space-4);
}
.score {
display: flex;
align-items: baseline;
gap: var(--space-1);
font-family: var(--font-display);
}
.score strong {
font-size: var(--text-5);
}
.score span {
color: var(--oki-ink-2);
font-size: var(--text-2);
}
.band-title {
font-family: var(--font-text);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent-ink);
}
.bar-row {
display: grid;
grid-template-columns: 9rem 1fr 3.5rem;
align-items: center;
gap: var(--space-2);
}
.bar-label {
font-size: var(--text-0);
color: var(--oki-ink-2);
}
.bar-value {
font-size: var(--text-0);
text-align: end;
}
.rec-card,
.cat-card {
padding: var(--space-3);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
height: 100%;
}
.actions {
border-top: 1px solid var(--oki-line);
padding-block-start: var(--space-3);
}
.actions button {
min-height: var(--tap-target);
padding-inline: var(--space-3);
}
.actions .restart {
margin-inline-start: auto;
background: none;
border-color: transparent;
color: var(--oki-ink-2);
}
/* ── clé de lecture ────────────────────────────────────────────── */
.key {
border-top: 1px solid var(--oki-line);
padding-block-start: var(--space-5);
}
.band-key {
list-style: none;
padding: 0;
display: grid;
gap: var(--space-2);
}
.band-key li {
padding: var(--space-3);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
}
.band-key strong {
font-family: var(--font-display);
}
.range {
color: var(--oki-ink-2);
font-size: var(--text-0);
margin-inline-start: var(--space-2);
}
</style>
+1 -1
View File
@@ -13,7 +13,7 @@
import { build, files, version } from '$service-worker'
const CACHE = `oki-${version}`
const SHELL = ['/', '/outils', '/contribuer', ...build, ...files]
const SHELL = ['/', '/outils', '/quiz', '/contribuer', ...build, ...files]
self.addEventListener('install', (event) => {
event.waitUntil(