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
+22 -13
View File
@@ -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.
+2 -3
View File
@@ -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": {
+4
View File
@@ -0,0 +1,4 @@
onlyBuiltDependencies:
- esbuild
allowBuilds:
esbuild: true
+1 -1
View File
@@ -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()
-156
View File
@@ -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(', ')}`)
}
+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(
+3
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
+2 -2
View File
@@ -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": "/",