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:
+22
-13
@@ -1,4 +1,4 @@
|
||||
# exitchatcontrol × OKI — frontend
|
||||
# Lagé Chat Control × OKI — frontend
|
||||
|
||||
Annuaire souverain d'outils libres. SvelteKit 2 + Svelte 5, site 100 %
|
||||
prérendu, zéro CDN tiers, zéro tracker. Voir [`ADR.md`](../ADR.md) pour la
|
||||
@@ -13,18 +13,19 @@ Node ≥ 22.12, pnpm.
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm migrate # (re)génère content/ depuis l'ancien site Astro (src/)
|
||||
pnpm dev # serveur de développement
|
||||
pnpm build # build statique dans build/ (prérend les 58 pages)
|
||||
pnpm preview # sert le build localement
|
||||
pnpm check # svelte-check (types + a11y)
|
||||
pnpm dev # serveur de développement
|
||||
pnpm build # build statique dans build/ (prérend toutes les pages)
|
||||
pnpm preview # sert le build localement
|
||||
pnpm check # svelte-check (types + a11y)
|
||||
pnpm audit:a11y # audit axe-core sur le build servi en local
|
||||
```
|
||||
|
||||
## Déploiement
|
||||
|
||||
`build/` est un site statique : n'importe quel serveur de fichiers convient
|
||||
(nginx du `docker/` existant, YunoHost `my_webapp`, etc.). Aucune dépendance
|
||||
cloud, aucun service à maintenir — un miroir = une copie du dossier.
|
||||
`build/` est un site statique : n'importe quel serveur de fichiers convient.
|
||||
Voir [`docs/DEPLOYMENT.md`](../docs/DEPLOYMENT.md) pour la mise en production
|
||||
sur un VPS (Caddy, nginx, YunoHost, Docker). Aucune dépendance cloud, aucun
|
||||
service à maintenir — un miroir = une copie du dossier.
|
||||
|
||||
## Arborescence
|
||||
|
||||
@@ -32,9 +33,17 @@ cloud, aucun service à maintenir — un miroir = une copie du dossier.
|
||||
src/styles/ tokens, reset, base, layouts (cascade @layer)
|
||||
src/lib/i18n/ fr.json + gcf.json typés, store de langue (runes)
|
||||
src/lib/components/ ToolCard, DifficultyBadge, CategoryNav, SearchBar,
|
||||
LanguageSwitcher, EndMarker
|
||||
src/lib/content/ chargeur build-time des fichiers ../content/
|
||||
src/routes/ / (landing), /outils (annuaire filtrable), /outils/[slug]
|
||||
LanguageSwitcher, EndMarker, ShortcutsDialog,
|
||||
OfflineBadge, AdoptButton
|
||||
src/lib/content/ chargeurs build-time des fichiers ../content/
|
||||
(tools.server.ts, quiz.server.ts)
|
||||
src/routes/ / (landing), /outils (annuaire filtrable),
|
||||
/outils/[slug], /quiz, /contribuer
|
||||
src/service-worker.js précache + offline-first
|
||||
static/fonts/ 2 WOFF2 auto-hébergés (Atkinson Hyperlegible, Fraunces)
|
||||
scripts/ migrate-tools.mjs
|
||||
scripts/audit-a11y.mjs audit axe-core (pnpm audit:a11y)
|
||||
```
|
||||
|
||||
Le contenu éditorial (fiches d'outils, quiz, catégories) vit dans
|
||||
[`../content/`](../content/) à la racine du dépôt, pas dans `frontend/` —
|
||||
voir [`docs/CONTENT_GUIDE.md`](../docs/CONTENT_GUIDE.md) pour l'éditer.
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
{
|
||||
"name": "exitchatcontrol-oki",
|
||||
"name": "lage-chat-control",
|
||||
"private": true,
|
||||
"version": "4.0.0-alpha.1",
|
||||
"type": "module",
|
||||
"description": "Exit Chat Control × OKI — annuaire souverain d'outils libres. SvelteKit statique, zéro tracker, zéro CDN.",
|
||||
"description": "Lagé Chat Control × OKI — annuaire souverain d'outils libres. SvelteKit statique, zéro tracker, zéro CDN.",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"migrate": "node scripts/migrate-tools.mjs",
|
||||
"audit:a11y": "node scripts/audit-a11y.mjs"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -2,7 +2,7 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
import { AxeBuilder } from '@axe-core/playwright'
|
||||
|
||||
const PAGES = ['/', '/outils', '/outils/signal', '/outils/tor', '/contribuer']
|
||||
const PAGES = ['/', '/outils', '/outils/signal', '/outils/tor', '/contribuer', '/quiz']
|
||||
const browser = await chromium.launch()
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* Migration des données du site Astro vers les fichiers plats OKI (ADR-001,
|
||||
* mission §7). Extrait les 55 ToolCard des MDX français + métadonnées de
|
||||
* src/data/{tools,directory}.json et produit :
|
||||
* content/outils/<slug>.md (frontmatter JSON-par-ligne + sections HTML)
|
||||
* content/categories.json (catégories dérivées des sections sources)
|
||||
*
|
||||
* Idempotent : relançable, écrase la sortie. Usage : pnpm migrate
|
||||
*/
|
||||
import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
const SRC = path.join(ROOT, 'src')
|
||||
const OUT = path.join(ROOT, 'content')
|
||||
|
||||
const DIFFICULTY = { 1: 'beginner', 2: 'intermediate', 3: 'advanced' }
|
||||
|
||||
const tools = JSON.parse(await readFile(path.join(SRC, 'data', 'tools.json'), 'utf8'))
|
||||
const directory = JSON.parse(await readFile(path.join(SRC, 'data', 'directory.json'), 'utf8'))
|
||||
const toolById = new Map(tools.map((t) => [t.id, t]))
|
||||
const dirById = new Map(directory.map((d) => [d.id, d]))
|
||||
|
||||
function parseFrontmatter(raw) {
|
||||
const m = raw.match(/^---\n([\s\S]*?)\n---\n?/)
|
||||
if (!m) return [{}, raw]
|
||||
const meta = {}
|
||||
for (const line of m[1].split('\n')) {
|
||||
const i = line.indexOf(':')
|
||||
if (i === -1) continue
|
||||
const key = line.slice(0, i).trim()
|
||||
let value = line.slice(i + 1).trim()
|
||||
if (/^'.*'$/.test(value) || /^".*"$/.test(value)) value = value.slice(1, -1)
|
||||
meta[key] = /^\d+$/.test(value) ? Number(value) : value
|
||||
}
|
||||
return [meta, raw.slice(m[0].length)]
|
||||
}
|
||||
|
||||
/** Extrait le contenu d'un slot (Fragment/ol/ul) dans un bloc ToolCard. */
|
||||
function slot(block, name) {
|
||||
const re = new RegExp(
|
||||
`<(Fragment|ol|ul)([^>]*slot="${name}"[^>]*)>([\\s\\S]*?)</\\1>`
|
||||
)
|
||||
const m = block.match(re)
|
||||
if (!m) return null
|
||||
// Les listes gardent leur balise (le HTML passe tel quel dans le markdown) ;
|
||||
// les Fragments sont déballés.
|
||||
return m[1] === 'Fragment' ? m[3].trim() : `<${m[1]}>${m[3]}</${m[1]}>`.trim()
|
||||
}
|
||||
|
||||
/** Tags = textes des <span class="tool-tag"> hors difficulté (déjà structurée). */
|
||||
function extractTags(block) {
|
||||
const html = slot(block, 'tags') ?? ''
|
||||
const tags = [...html.matchAll(/<span class="tool-tag">([\s\S]*?)<\/span>/g)]
|
||||
.map((m) => m[1].replace(/\s+/g, ' ').trim())
|
||||
.filter((t) => !/^[🟢🟡🔴]/u.test(t))
|
||||
return tags
|
||||
}
|
||||
|
||||
/** Difficulté : `level` de tools.json, sinon l'emoji du tag MDX (🟢/🟡/🔴). */
|
||||
function difficulty(tool, block) {
|
||||
if (DIFFICULTY[tool.level]) return DIFFICULTY[tool.level]
|
||||
const html = slot(block, 'tags') ?? ''
|
||||
if (html.includes('🟢')) return 'beginner'
|
||||
if (html.includes('🟡')) return 'intermediate'
|
||||
if (html.includes('🔴')) return 'advanced'
|
||||
console.warn(`⚠ ${tool.id} : difficulté introuvable — « intermediate » par défaut`)
|
||||
return 'intermediate'
|
||||
}
|
||||
|
||||
const fm = (key, value) => (value == null ? null : `${key}: ${JSON.stringify(value)}`)
|
||||
|
||||
const sectionsDir = path.join(SRC, 'content', 'sections', 'fr')
|
||||
const files = (await readdir(sectionsDir)).filter((f) => f.endsWith('.mdx')).sort()
|
||||
|
||||
await mkdir(path.join(OUT, 'outils'), { recursive: true })
|
||||
|
||||
const categories = []
|
||||
const seen = new Set()
|
||||
let written = 0
|
||||
|
||||
for (const file of files) {
|
||||
const raw = await readFile(path.join(sectionsDir, file), 'utf8')
|
||||
const [meta, body] = parseFrontmatter(raw)
|
||||
const blocks = [...body.matchAll(/<ToolCard tool="([^"]+)"[^>]*>([\s\S]*?)<\/ToolCard>/g)]
|
||||
if (blocks.length === 0) continue
|
||||
|
||||
categories.push({
|
||||
slug: meta.id,
|
||||
title: meta.title,
|
||||
order: meta.order,
|
||||
part: meta.part
|
||||
})
|
||||
|
||||
for (const [, toolId, block] of blocks) {
|
||||
const tool = toolById.get(toolId)
|
||||
if (!tool) {
|
||||
console.warn(`⚠ ${file} : ToolCard "${toolId}" absent de tools.json — ignoré`)
|
||||
continue
|
||||
}
|
||||
const slug = toolId.replace(/^t-/, '')
|
||||
if (seen.has(slug)) {
|
||||
console.warn(`⚠ ${slug} : doublon (déjà émis) — ignoré dans ${file}`)
|
||||
continue
|
||||
}
|
||||
seen.add(slug)
|
||||
// ne jamais écraser une fiche retravaillée à la main (--force pour tout régénérer)
|
||||
const outPath = path.join(OUT, 'outils', `${slug}.md`)
|
||||
if (existsSync(outPath) && !process.argv.includes('--force')) {
|
||||
written++
|
||||
continue
|
||||
}
|
||||
const dir = dirById.get(slug)
|
||||
const nameOverride = slot(block, 'name')
|
||||
|
||||
const front = [
|
||||
fm('name', nameOverride ?? tool.name),
|
||||
fm('slug', slug),
|
||||
fm('category', meta.id),
|
||||
fm('difficulty', difficulty(tool, block)),
|
||||
fm('license', dir?.license),
|
||||
fm('url', tool.url),
|
||||
fm('color', tool.color),
|
||||
fm('monogram', tool.monogram),
|
||||
fm('iconSlug', tool.iconSlug),
|
||||
fm('tags', extractTags(block)),
|
||||
fm('lang', 'fr')
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
const sections = [
|
||||
['what', slot(block, 'what')],
|
||||
['why', slot(block, 'why')],
|
||||
['who', slot(block, 'who')],
|
||||
['install', slot(block, 'install')],
|
||||
['extra', slot(block, 'extra')]
|
||||
]
|
||||
.filter(([, html]) => html)
|
||||
.map(([key, html]) => `## ${key}\n\n${html}`)
|
||||
.join('\n\n')
|
||||
|
||||
await writeFile(outPath, `---\n${front}\n---\n\n${sections}\n`)
|
||||
written++
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(path.join(OUT, 'categories.json'), JSON.stringify(categories, null, 2) + '\n')
|
||||
console.log(`✔ ${written} fiches écrites dans content/outils/, ${categories.length} catégories`)
|
||||
|
||||
const missing = tools.filter((t) => !seen.has(t.id.replace(/^t-/, '')))
|
||||
if (missing.length) {
|
||||
console.warn(`⚠ outils de tools.json sans ToolCard fr : ${missing.map((t) => t.id).join(', ')}`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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é"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { loadQuiz } from '$lib/content/quiz.server'
|
||||
|
||||
export function load() {
|
||||
return loadQuiz()
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Contact: https://labola.o-k-i.net/cyber-mawonaj/lage-chat-control/issues
|
||||
Preferred-Languages: fr, gcf
|
||||
Expires: 2027-07-01T00:00:00.000Z
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Exit Chat Control",
|
||||
"short_name": "ExitCC",
|
||||
"name": "Lagé Chat Control",
|
||||
"short_name": "Lagé CC",
|
||||
"description": "Annuaire d'outils libres pour la souveraineté numérique",
|
||||
"lang": "fr",
|
||||
"start_url": "/",
|
||||
|
||||
Reference in New Issue
Block a user