feat(oki): intro publique, chimen, PWA offline, contribuer, kréyòl, ton OKI

- Landing : intro « Pourquoi ce site ? » (Chat Control expliqué au grand
  public) + sommaire-chimen en 3 arcs (Fondations 🟢 / Reprendre le
  contrôle 🟡 / Autodéfense avancée 🔴) avec compte d'outils par catégorie
- PWA : service worker offline-first (précache coquille, network-first
  avec repli cache pour les fiches consultées), badge hors-ligne discret
- Page /contribuer (3 voies + forge labola) + template d'issue Forgejo
  .gitea/ISSUE_TEMPLATE/nouvo-zouti.yaml ; liens GitHub → labola.o-k-i.net
- Fiches jumelles créoles : chargeur content/outils/gcf/, affichage selon
  la langue avec bandeaux « an chantyé » / « póko tradui », 6 pilotes
  (signal, protonmail, brave, bitwarden, ublock, quad9) — À FAIRE RELIRE
  par un locuteur natif
- Ton OKI : les 6 mêmes fiches réécrites (tutoiement, registre
  militant-chaleureux) ; le script de migration n'écrase plus les fiches
  retravaillées (--force pour tout régénérer)
- UI : View Transitions (respect reduced-motion), aide clavier « ? »
  (dialog natif, raccourcis / h a), progression locale « jès fèt »
  (localStorage uniquement, compteur visible après le premier geste)
- docs/DEPLOYMENT.md : guide VPS (Caddy, nginx, YunoHost, Docker, rsync)
- Audits : axe-core 0 violation (5 pages), Lighthouse 99-98/100/100/100 ;
  corrections contraste monogrammes + ordre des titres
- Budgets : JS 46 Ko gzip, CSS 3,5 Ko, 59 pages prérendues

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
sucupira
2026-07-10 13:38:43 -04:00
parent db5a997e50
commit b13a1e7a31
38 changed files with 1285 additions and 147 deletions
+4 -1
View File
@@ -10,14 +10,17 @@
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"migrate": "node scripts/migrate-tools.mjs"
"migrate": "node scripts/migrate-tools.mjs",
"audit:a11y": "node scripts/audit-a11y.mjs"
},
"engines": {
"node": ">=22.12"
},
"devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@fontsource/atkinson-hyperlegible": "^5.2.8",
"@fontsource/fraunces": "^5.2.9",
"@playwright/test": "1.61.1",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
+57
View File
@@ -8,12 +8,18 @@ importers:
.:
devDependencies:
'@axe-core/playwright':
specifier: ^4.12.1
version: 4.12.1(playwright-core@1.61.1)
'@fontsource/atkinson-hyperlegible':
specifier: ^5.2.8
version: 5.2.8
'@fontsource/fraunces':
specifier: ^5.2.9
version: 5.2.9
'@playwright/test':
specifier: 1.61.1
version: 1.61.1
'@sveltejs/adapter-static':
specifier: ^3.0.0
version: 3.0.10(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.56.4)(vite@7.3.6(@types/node@26.1.1)))(svelte@5.56.4)(typescript@5.9.3)(vite@7.3.6(@types/node@26.1.1)))
@@ -44,6 +50,11 @@ importers:
packages:
'@axe-core/playwright@4.12.1':
resolution: {integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==}
peerDependencies:
playwright-core: '>= 1.0.0'
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
@@ -222,6 +233,11 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@playwright/test@1.61.1':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -432,6 +448,10 @@ packages:
resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
engines: {node: '>= 0.4'}
axe-core@4.12.1:
resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==}
engines: {node: '>=4'}
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -480,6 +500,11 @@ packages:
picomatch:
optional: true
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -527,6 +552,16 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
postcss@8.5.16:
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
engines: {node: ^10 || ^12 || >=14}
@@ -636,6 +671,11 @@ packages:
snapshots:
'@axe-core/playwright@4.12.1(playwright-core@1.61.1)':
dependencies:
axe-core: 4.12.1
playwright-core: 1.61.1
'@esbuild/aix-ppc64@0.28.1':
optional: true
@@ -737,6 +777,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@playwright/test@1.61.1':
dependencies:
playwright: 1.61.1
'@polka/url@1.0.0-next.29': {}
'@rollup/rollup-android-arm-eabi@4.62.2':
@@ -877,6 +921,8 @@ snapshots:
aria-query@5.3.1: {}
axe-core@4.12.1: {}
axobject-query@4.1.0: {}
chokidar@4.0.3:
@@ -930,6 +976,9 @@ snapshots:
optionalDependencies:
picomatch: 4.0.5
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -959,6 +1008,14 @@ snapshots:
picomatch@4.0.5: {}
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
postcss@8.5.16:
dependencies:
nanoid: 3.3.15
+24
View File
@@ -0,0 +1,24 @@
// Audit axe-core des pages clés servies sur localhost:4173
import { chromium } from '@playwright/test'
import { AxeBuilder } from '@axe-core/playwright'
const PAGES = ['/', '/outils', '/outils/signal', '/outils/tor', '/contribuer']
const browser = await chromium.launch()
const context = await browser.newContext()
const page = await context.newPage()
let total = 0
for (const path of PAGES) {
await page.goto(`http://localhost:4173${path}`, { waitUntil: 'networkidle' })
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag22aa']).analyze()
total += results.violations.length
console.log(`\n═══ ${path}${results.violations.length} violation(s)`)
for (const v of results.violations) {
console.log(` [${v.impact}] ${v.id}: ${v.help}`)
for (const n of v.nodes.slice(0, 3)) console.log(`${n.target.join(' ')}`)
}
}
await browser.close()
console.log(`\nTOTAL: ${total} violation(s)`)
process.exit(total > 0 ? 1 : 0)
+9 -2
View File
@@ -8,6 +8,7 @@
* 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'
@@ -31,7 +32,7 @@ function parseFrontmatter(raw) {
if (i === -1) continue
const key = line.slice(0, i).trim()
let value = line.slice(i + 1).trim()
if (/^'.*'$/.test(value)) value = value.slice(1, -1)
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)]
@@ -105,6 +106,12 @@ for (const file of files) {
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')
@@ -135,7 +142,7 @@ for (const file of files) {
.map(([key, html]) => `## ${key}\n\n${html}`)
.join('\n\n')
await writeFile(path.join(OUT, 'outils', `${slug}.md`), `---\n${front}\n---\n\n${sections}\n`)
await writeFile(outPath, `---\n${front}\n---\n\n${sections}\n`)
written++
}
}
+39
View File
@@ -0,0 +1,39 @@
import { browser } from '$app/environment'
/**
* « Jès fèt » — les outils que la personne a adoptés.
* Effet Zeigarnik utilisé positivement (doctrine §1.2) : une progression
* choisie, pas un streak. Stocké uniquement sur l'appareil, jamais envoyé
* nulle part (pas de compte, pas de télémétrie — doctrine §3.3 : aucune
* métrique anxiogène ; le compteur n'apparaît qu'après le premier geste).
*/
const STORAGE_KEY = 'oki-jes'
function initial(): string[] {
if (!browser) return []
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as string[]) : []
} catch {
return []
}
}
const state = $state({ slugs: initial() })
function persist() {
if (browser) localStorage.setItem(STORAGE_KEY, JSON.stringify(state.slugs))
}
export function isAdopted(slug: string): boolean {
return state.slugs.includes(slug)
}
export function adoptedCount(): number {
return state.slugs.length
}
export function toggleAdopted(slug: string) {
state.slugs = isAdopted(slug) ? state.slugs.filter((s) => s !== slug) : [...state.slugs, slug]
persist()
}
@@ -0,0 +1,28 @@
<script lang="ts">
import { isAdopted, toggleAdopted } from '$lib/adopted.svelte'
import { t } from '$lib/i18n/index.svelte'
let { slug }: { slug: string } = $props()
let adopted = $derived(isAdopted(slug))
</script>
<!-- Amélioration progressive : sans JS le bouton n'apparaît pas du tout
(rendu identique, aucune fonction cassée). -->
<button type="button" class:adopted aria-pressed={adopted} onclick={() => toggleAdopted(slug)}>
{adopted ? t('adopted.marked') : t('adopted.mark')}
<span class="visually-hidden">{t('adopted.srHint')}</span>
</button>
<style>
button {
border: 2px solid var(--oki-vert);
background: transparent;
color: var(--oki-ink);
font-weight: 600;
}
button.adopted {
background: var(--oki-vert);
color: oklch(98% 0 0);
}
</style>
@@ -0,0 +1,31 @@
<script lang="ts">
import { browser } from '$app/environment'
import { t } from '$lib/i18n/index.svelte'
// Technologie périphérique (doctrine §3.5) : l'état réseau est une
// information non urgente — un badge discret, jamais une interruption.
let offline = $state(browser ? !navigator.onLine : false)
</script>
<svelte:window ononline={() => (offline = false)} onoffline={() => (offline = true)} />
{#if offline}
<p class="offline" role="status">
<strong>{t('offline.badge')}</strong>{t('offline.detail')}
</p>
{/if}
<style>
.offline {
font-size: var(--text-0);
color: var(--oki-ink-2);
background: var(--oki-surface-2);
border-block: 1px solid var(--oki-line);
padding: var(--space-2) var(--space-3);
text-align: center;
}
strong {
color: var(--accent-ink);
}
</style>
+3 -2
View File
@@ -7,8 +7,9 @@
// raccourci « / » desktop (doctrine §2.2 — le clavier est roi)
function onkeydown(e: KeyboardEvent) {
if (e.key === '/' && document.activeElement !== input) {
const target = e.target as HTMLElement
if (target.closest('input, textarea, [contenteditable]')) return
const target = e.target
if (target instanceof HTMLElement && target.closest('input, textarea, [contenteditable]'))
return
e.preventDefault()
input.focus()
}
@@ -0,0 +1,99 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { t } from '$lib/i18n/index.svelte'
// Le clavier est roi sur desktop (doctrine §2.2.3) : raccourcis découvrables
// via « ? », jamais porteurs d'information exclusive (tout est aussi dans la nav).
let dialog: HTMLDialogElement
const KEYS = [
{ key: '/', label: 'shortcuts.search' },
{ key: 'h', label: 'shortcuts.home' },
{ key: 'a', label: 'shortcuts.directory' },
{ key: '?', label: 'shortcuts.help' },
{ key: 'Échap', label: 'shortcuts.close' }
]
function onkeydown(e: KeyboardEvent) {
if (e.ctrlKey || e.metaKey || e.altKey) return
const target = e.target
if (target instanceof HTMLElement && target.closest('input, textarea, select, [contenteditable]'))
return
if (e.key === '?') {
e.preventDefault()
dialog.open ? dialog.close() : dialog.showModal()
} else if (e.key === 'h') {
goto('/')
} else if (e.key === 'a') {
goto('/outils')
}
}
</script>
<svelte:window {onkeydown} />
<!-- <dialog> natif : focus piégé, Échap géré par le navigateur, zéro ARIA maison -->
<dialog bind:this={dialog}>
<h2>{t('shortcuts.title')}</h2>
<dl>
{#each KEYS as { key, label } (key)}
<div>
<dt><kbd>{key}</kbd></dt>
<dd>{t(label)}</dd>
</div>
{/each}
</dl>
<form method="dialog">
<button>{t('shortcuts.close')}</button>
</form>
</dialog>
<style>
dialog {
border: 1px solid var(--oki-line);
border-top: 4px solid var(--oki-jaune);
border-radius: var(--radius-2);
background: var(--oki-surface);
color: var(--oki-ink);
padding: var(--space-4);
max-inline-size: 24rem;
}
dialog::backdrop {
background: oklch(0% 0 0 / 0.5);
}
h2 {
font-size: var(--text-2);
margin-block-end: var(--space-3);
}
dl {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-block-end: var(--space-4);
}
dl div {
display: flex;
align-items: center;
gap: var(--space-3);
}
kbd {
display: inline-grid;
place-items: center;
min-inline-size: 2rem;
padding: 0.15em 0.5em;
border: 1px solid var(--oki-line);
border-bottom-width: 3px;
border-radius: var(--radius-1);
background: var(--oki-surface-2);
font-size: var(--text-0);
}
dd {
margin: 0;
}
</style>
+25 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import DifficultyBadge from './DifficultyBadge.svelte'
import { isAdopted } from '$lib/adopted.svelte'
import { t } from '$lib/i18n/index.svelte'
interface CardTool {
@@ -22,6 +23,13 @@
{tool.monogram ?? tool.name.slice(0, 2)}
</span>
<h3><a href="/outils/{tool.slug}">{tool.name}</a></h3>
{#if isAdopted(tool.slug)}
<!-- information périphérique (doctrine §3.5) : discret, jamais bloquant -->
<span class="adopted" title={t('adopted.marked')}>
<span aria-hidden="true"></span>
<span class="visually-hidden">{t('adopted.marked')}</span>
</span>
{/if}
</header>
<p class="excerpt"><span class="visually-hidden">{t('tool.why')} : </span>{tool.excerpt}</p>
@@ -51,14 +59,17 @@
height: 100%;
}
/* la couleur de marque en anneau, jamais en fond de texte :
contraste AA garanti quel que soit l'outil (doctrine §4.3) */
.monogram {
display: grid;
place-items: center;
inline-size: 2.5rem;
block-size: 2.5rem;
border-radius: var(--radius-2);
background: var(--tool-color, var(--oki-surface-2));
color: white;
background: var(--oki-surface-2);
border: 3px solid var(--tool-color, var(--oki-line));
color: var(--oki-ink);
font-weight: 700;
font-size: var(--text-0);
}
@@ -83,6 +94,18 @@
border-radius: var(--radius-2);
}
.adopted {
display: grid;
place-items: center;
inline-size: 1.4rem;
block-size: 1.4rem;
border-radius: 50%;
background: var(--oki-vert);
color: oklch(98% 0 0);
font-size: var(--text-0);
margin-inline-start: auto;
}
.excerpt {
color: var(--oki-ink-2);
font-size: var(--text-0);
+26 -3
View File
@@ -2,7 +2,7 @@
* Chargeur de contenu — s'exécute uniquement au build (site prérendu,
* ADR-001) : ni fs ni marked ne sont expédiés au client.
*/
import { readFileSync, readdirSync } from 'node:fs'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
import { marked } from 'marked'
@@ -96,7 +96,30 @@ function loadTool(file: string): Tool {
}
}
let cache: { tools: Tool[]; categories: Category[] } | null = null
export interface GcfTool {
sections: ToolSections
excerpt: string
}
/** Fiches jumelles créoles : content/outils/gcf/<slug>.md (CONTENT_GUIDE). */
function loadGcf(): Record<string, GcfTool> {
const dir = path.join(CONTENT, 'outils', 'gcf')
if (!existsSync(dir)) return {}
const out: Record<string, GcfTool> = {}
for (const file of readdirSync(dir).filter((f) => f.endsWith('.md'))) {
const raw = readFileSync(path.join(dir, file), 'utf8')
const [meta, body] = parseFrontmatter(raw)
const sections = parseSections(body)
const why = stripHtml(sections.why ?? sections.what ?? '')
out[(meta.slug as string) ?? file.replace(/\.md$/, '')] = {
excerpt: why.length > 160 ? `${why.slice(0, 157).trimEnd()}` : why,
sections
}
}
return out
}
let cache: { tools: Tool[]; categories: Category[]; gcf: Record<string, GcfTool> } | null = null
export function loadContent() {
if (cache) return cache
@@ -112,6 +135,6 @@ export function loadContent() {
(order.get(a.category) ?? 99) - (order.get(b.category) ?? 99) ||
a.name.localeCompare(b.name, 'fr')
)
cache = { tools, categories }
cache = { tools, categories, gcf: loadGcf() }
return cache
}
+53 -1
View File
@@ -15,6 +15,21 @@
"subtitle": "Reprends le contrôle de ton internet : des outils libres, expliqués simplement, du premier geste au serveur auto-hébergé.",
"cta": "Voir l'annuaire"
},
"intro": {
"title": "Pourquoi ce site ?",
"p1": "L'Union européenne veut imposer le scan de toutes tes conversations privées : c'est le projet « Chat Control ». Et même quand la loi recule, l'infrastructure de surveillance, elle, continue d'avancer.",
"p2": "Ce site est un guide de terrain. Des outils libres, vérifiés, expliqués pas à pas — pour sortir des plateformes qui te surveillent et reprendre la main : toi, ta famille, ton lakou.",
"p3": "Pas besoin d'être technicien·ne. Commence par les fondations, avance à ton rythme, le reste vient après."
},
"sommaire": {
"title": "Le chimen",
"sub": "Trois étapes, du premier geste à l'autodéfense complète. Chaque catégorie est une porte d'entrée."
},
"arc": {
"fondations": "Les fondations",
"controle": "Reprendre le contrôle",
"avance": "Autodéfense avancée"
},
"directory": {
"title": "Annuaire des outils",
"intro": "Chaque outil est libre, vérifié, et classé par difficulté. Commence par le vert.",
@@ -40,7 +55,16 @@
"sourceCode": "Code source",
"license": "Licence",
"category": "Catégorie",
"backToDirectory": "Retour à l'annuaire"
"backToDirectory": "Retour à l'annuaire",
"notTranslated": "Fich-lasa póko tradui an kréyòl — la voici en français.",
"draftTranslation": "Tradiksyon kréyòl an chantyé — un doute ? La version française fait foi."
},
"adopted": {
"mark": "Je m'en sers",
"marked": "Je m'en sers ✓",
"progressBefore": "Ou ja fè",
"progressAfter": "gestes — chaque outil adopté est une reprise de terrain.",
"srHint": "Enregistré uniquement sur cet appareil, jamais envoyé nulle part."
},
"difficulty": {
"beginner": "Débutant",
@@ -56,6 +80,34 @@
"switchLabel": "Langue",
"switchTo": "Passer en"
},
"shortcuts": {
"title": "Raccourcis clavier",
"search": "Rechercher",
"help": "Afficher cette aide",
"home": "Aller à l'accueil",
"directory": "Aller à l'annuaire",
"close": "Fermer"
},
"offline": {
"badge": "Hors-ligne",
"detail": "Tu lis la copie locale — les fiches déjà visitées restent disponibles."
},
"contrib": {
"title": "Pòté mannèv — Contribuer",
"intro": "Ce guide vit par celles et ceux qui s'en servent. Trois façons d'aider, de la plus simple à la plus complète.",
"suggest": "Proposer un outil",
"suggestBody": "Tu utilises un outil libre qui manque à l'annuaire ? Ouvre une demande avec le formulaire : nom, lien, pourquoi il compte. On s'occupe de la fiche.",
"suggestCta": "Ouvrir une demande",
"fix": "Corriger une fiche",
"fixBody": "Une étape d'installation qui a changé, un lien mort, une tournure plus claire ? Chaque fiche est un simple fichier texte dans le dossier content/outils/.",
"fixCta": "Parcourir les fiches",
"translate": "Traduire en kréyòl",
"translateBody": "Le créole est la langue du cœur de ce site. Les fiches attendent leur jumelle gcf — et les traductions existantes ont besoin de relecteurs et relectrices natifs.",
"translateCta": "Lire le guide de contenu",
"repo": "Tout se passe sur la forge",
"repoBody": "Le code, le contenu et les discussions vivent sur labola, la forge auto-hébergée d'OKI. Pas de compte Microsoft requis.",
"repoCta": "Voir le dépôt"
},
"footer": {
"license": "Contenu et code libres (MIT). Zéro tracker, zéro CDN, zéro cookie.",
"contribute": "Proposer un outil ou une correction"
+53 -1
View File
@@ -15,6 +15,21 @@
"subtitle": "Pran kontwòl entènèt a-w : zouti lib, èspliké senp, dépi prèmyé jès jis sèwvè a-w.",
"cta": "Gadé lis-la"
},
"intro": {
"title": "Poukisa sit-lasa ?",
"p1": "Linyon éwopéyèn vlé enposé skannaj a tout konvèsasyon privé a-w : sé pwojé « Chat Control ». É menm lè lwa-la ka fè bak, enfrastrikti a survéyans-la ka kontinyé vansé.",
"p2": "Sit-lasa sé on gid a téren. Zouti lib, vérifyé, èspliké pa a pa — pou sòti an platfòm ki ka survéyé-w é répran lanmen : vou, fanmi a-w, lakou a-w.",
"p3": "Pa bizwen sav enfòwmatik. Koumansé èvè fondasyon-la, vansé a vitès a-w, rès-la ka vin apré."
},
"sommaire": {
"title": "Chimen-la",
"sub": "Twa létap, dépi prèmyé jès jis otodéfans konplé. Chak katégori sé on pòt."
},
"arc": {
"fondations": "Fondasyon-la",
"controle": "Répran kontwòl",
"avance": "Otodéfans avansé"
},
"directory": {
"title": "Lis a zouti",
"intro": "Chak zouti lib, vérifyé, é rangé pa difikilté. Koumansé èvè vèw-la.",
@@ -40,7 +55,16 @@
"sourceCode": "Kòd sous",
"license": "Lisans",
"category": "Katégori",
"backToDirectory": "Viré an lis-la"
"backToDirectory": "Viré an lis-la",
"notTranslated": "Fich-lasa póko tradui an kréyòl — mi'y an fransé.",
"draftTranslation": "Tradiksyon kréyòl an chantyé — on dout ? Vèwsyon fransé-la ka fè fwa."
},
"adopted": {
"mark": "An ka sèvi épi'y",
"marked": "An ka sèvi épi'y ✓",
"progressBefore": "Ou ja fè",
"progressAfter": "jès — chak zouti adopté sé on moso téren ou répran.",
"srHint": "Anrèjistré asi apawèy-lasa sèlman, janmé voyé okenn koté."
},
"difficulty": {
"beginner": "Débitan",
@@ -56,6 +80,34 @@
"switchLabel": "Lang",
"switchTo": "Pasé an"
},
"shortcuts": {
"title": "Rakousi klavyé",
"search": "Chèché",
"help": "Montré èd-lasa",
"home": "Alé an lakou-la",
"directory": "Alé an lis-la",
"close": "Fèmé"
},
"offline": {
"badge": "Dékonèkté",
"detail": "Ou ka li kopi lokal-la — fich ou ja vizité ka rété la."
},
"contrib": {
"title": "Pòté mannèv",
"intro": "Gid-lasa ka viv gras a moun ki ka sèvi épi'y. Twa mannyè pou édé, dépi pi senp jis pi konplé.",
"suggest": "Pwopozé on zouti",
"suggestBody": "Ou ka sèvi épi on zouti lib ki manké an lis-la ? Louvè on dimann èvè fòwmilè-la : non, lyen, poukisa i konté. Nou ka okipé fich-la.",
"suggestCta": "Louvè on dimann",
"fix": "Korijé on fich",
"fixBody": "On létap ki chanjé, on lyen mò, on fraz pi klè ? Chak fich sé on senp fichyé tèks an dosyé content/outils/.",
"fixCta": "Gadé fich-la yo",
"translate": "Tradui an kréyòl",
"translateBody": "Kréyòl sé lang a kè a sit-lasa. Fich-la yo ka atann jimèl gcf a-yo — é tradiksyon ki ja la bizwen lektè natif.",
"translateCta": "Li gid a kontni-la",
"repo": "Tout ka pasé asi fòwj-la",
"repoBody": "Kòd, kontni é diskisyon ka viv asi labola, fòwj oto-ébèjé a OKI. Pa bizwen kont Microsoft.",
"repoCta": "Gadé dépo-la"
},
"footer": {
"license": "Kontni é kòd lib (MIT). Pon trakè, pon CDN, pon cookie.",
"contribute": "Pwopozé on zouti oben on koreksyon"
+20
View File
@@ -0,0 +1,20 @@
/** Constantes du site — un seul endroit à changer si la forge bouge. */
export const REPO_URL = 'https://labola.o-k-i.net/cyber-mawonaj/lage-chat-control'
export const NEW_TOOL_ISSUE_URL = `${REPO_URL}/issues/new?template=.gitea/ISSUE_TEMPLATE/nouvo-zouti.yaml`
/**
* Les trois arcs du chimen (sommaire) : progression Fondations →
* Reprendre le contrôle → Autodéfense avancée. Une catégorie appartient
* au premier arc dont `maxPart` couvre son `part`.
*/
export const ARCS = [
{ id: 'fondations', maxPart: 4, difficulty: 'beginner' },
{ id: 'controle', maxPart: 9, difficulty: 'intermediate' },
{ id: 'avance', maxPart: 99, difficulty: 'advanced' }
] as const
export type ArcId = (typeof ARCS)[number]['id']
export function arcOf(part: number): ArcId {
return (ARCS.find((a) => part <= a.maxPart) ?? ARCS[ARCS.length - 1]).id
}
+23 -8
View File
@@ -1,9 +1,27 @@
<script lang="ts">
import '../app.css'
import { onNavigate } from '$app/navigation'
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'
import OfflineBadge from '$lib/components/OfflineBadge.svelte'
import ShortcutsDialog from '$lib/components/ShortcutsDialog.svelte'
import { t } from '$lib/i18n/index.svelte'
import { REPO_URL } from '$lib/site'
let { children } = $props()
// View Transitions : la navigation glisse au lieu de sauter — amélioration
// progressive pure (navigateurs sans support : rien ne change), et
// désactivée si la personne préfère réduire les animations (doctrine §4.3).
onNavigate((navigation) => {
if (!document.startViewTransition) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
return new Promise((resolve) => {
document.startViewTransition(async () => {
resolve()
await navigation.complete
})
})
})
</script>
<a class="skip-link" href="#main">{t('nav.skipToContent')}</a>
@@ -17,17 +35,16 @@
<nav aria-label={t('nav.home')}>
<ul class="cluster">
<li><a href="/outils">{t('nav.directory')}</a></li>
<li>
<a href="https://github.com/exitchatcontrol/exitchatcontrol.org" rel="external">
{t('nav.contribute')}
</a>
</li>
<li><a href="/contribuer">{t('nav.contribute')}</a></li>
</ul>
</nav>
<LanguageSwitcher />
</div>
</header>
<OfflineBadge />
<ShortcutsDialog />
<main id="main" class="center stack" style="--gap: var(--space-5)">
{@render children()}
</main>
@@ -35,9 +52,7 @@
<footer class="center">
<p>{t('footer.license')}</p>
<p>
<a href="https://github.com/exitchatcontrol/exitchatcontrol.org" rel="external">
{t('footer.contribute')}
</a>
<a href={REPO_URL} rel="external">{t('footer.contribute')}</a>
</p>
</footer>
+19 -3
View File
@@ -1,11 +1,27 @@
import { loadContent } from '$lib/content/tools.server'
import { ARCS, arcOf } from '$lib/site'
export function load() {
const { tools, categories } = loadContent()
const countBy = new Map<string, number>()
for (const tool of tools) {
countBy.set(tool.category, (countBy.get(tool.category) ?? 0) + 1)
}
// le sommaire : catégories groupées par arc (Fondations → Contrôle → Avancé)
const arcs = ARCS.map((arc) => ({
id: arc.id,
difficulty: arc.difficulty,
categories: categories
.filter((c) => arcOf(c.part) === arc.id)
.map((c) => ({ ...c, count: countBy.get(c.slug) ?? 0 }))
}))
return {
categories,
arcs,
toolCount: tools.length,
// les premiers gestes : outils débutant, un par catégorie
starters: tools.filter((t) => t.difficulty === 'beginner').slice(0, 3)
// les premiers gestes : outils débutant mis en avant
starters: tools
.filter((t) => t.difficulty === 'beginner')
.slice(0, 3)
.map(({ sections: _sections, ...card }) => card)
}
}
+168 -13
View File
@@ -1,8 +1,12 @@
<script lang="ts">
import ToolCard from '$lib/components/ToolCard.svelte'
import { adoptedCount } from '$lib/adopted.svelte'
import { t } from '$lib/i18n/index.svelte'
let { data } = $props()
// n'apparaît qu'après le premier geste : jamais de « 0/55 » culpabilisant
// (doctrine §3.3 — pas de métrique anxiogène)
let jes = $derived(adoptedCount())
</script>
<svelte:head>
@@ -16,6 +20,52 @@
<h1>{t('hero.title')}</h1>
<p class="sub">{t('hero.subtitle')}</p>
<p><a class="cta" href="/outils">{t('hero.cta')} · {data.toolCount} {t('directory.results')}</a></p>
{#if jes > 0}
<p class="jes" role="status">
<span class="jes-mark" aria-hidden="true"></span>
{t('adopted.progressBefore')}
<strong>{jes}</strong>
{t('adopted.progressAfter')}
</p>
{/if}
</section>
<!-- L'intro publique : de quoi il s'agit, avant toute navigation.
Motif en F (doctrine §1.1) : l'essentiel au premier mot de chaque ¶. -->
<section class="intro stack prose">
<h2>{t('intro.title')}</h2>
<p>{t('intro.p1')}</p>
<p>{t('intro.p2')}</p>
<p>{t('intro.p3')}</p>
</section>
<!-- Le sommaire-chimen : trois arcs, une progression lisible sans lecture
(reconnaissance, doctrine §1.2) — le rail reprend vert/jaune/rouge,
doublé par texte + numérotation (jamais couleur seule, §1.1). -->
<section class="stack" aria-labelledby="sommaire-title">
<h2 id="sommaire-title">{t('sommaire.title')}</h2>
<p class="sub2">{t('sommaire.sub')}</p>
<ol class="chimen">
{#each data.arcs as arc, i (arc.id)}
<li class="arc arc-{arc.difficulty}">
<h3>
<span class="arc-num" aria-hidden="true">{i + 1}</span>
{t(`arc.${arc.id}`)}
<span class="arc-diff">{t(`difficulty.${arc.difficulty}`)}</span>
</h3>
<ul class="cluster">
{#each arc.categories as cat (cat.slug)}
<li>
<a href="/outils?cat={cat.slug}">
{cat.title}
<span class="count">{cat.count}</span>
</a>
</li>
{/each}
</ul>
</li>
{/each}
</ol>
</section>
<section class="stack">
@@ -29,15 +79,6 @@
</div>
</section>
<section class="stack">
<h2>{t('directory.filterCategory')}</h2>
<ul class="cluster cats">
{#each data.categories as cat (cat.slug)}
<li><a href="/outils?cat={cat.slug}">{cat.title}</a></li>
{/each}
</ul>
</section>
<style>
.hero {
padding-block: var(--space-5);
@@ -53,6 +94,10 @@
max-width: var(--measure);
}
.sub2 {
color: var(--oki-ink-2);
}
/* une seule action primaire par écran (Von Restorff, doctrine §1.2) */
.cta {
display: inline-flex;
@@ -70,14 +115,116 @@
filter: brightness(1.05);
}
.cats {
.jes {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--oki-ink-2);
font-size: var(--text-0);
max-width: var(--measure);
}
.jes-mark {
display: grid;
place-items: center;
inline-size: 1.4rem;
block-size: 1.4rem;
border-radius: 50%;
background: var(--oki-vert);
color: oklch(98% 0 0);
flex-shrink: 0;
}
.intro {
border-inline-start: 4px solid var(--oki-rouge);
padding-inline-start: var(--space-4);
}
/* ── le chimen ─────────────────────────────────────────────────── */
.chimen {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
}
.arc {
--rail: var(--oki-line);
position: relative;
padding-inline-start: var(--space-5);
padding-block-end: var(--space-5);
}
/* le rail vertical, coloré par arc */
.arc::before {
content: '';
position: absolute;
inset-block: 0.4rem 0;
inset-inline-start: calc(1.1rem - 2px);
width: 4px;
border-radius: 2px;
background: var(--rail);
}
.arc:last-child::before {
inset-block-end: auto;
height: 2rem;
}
.arc-beginner {
--rail: var(--diff-beginner);
}
.arc-intermediate {
--rail: var(--diff-intermediate);
}
.arc-advanced {
--rail: var(--diff-advanced);
}
.arc h3 {
display: flex;
align-items: center;
gap: var(--space-2);
margin-block-end: var(--space-3);
}
.arc-num {
position: absolute;
inset-inline-start: 0;
display: grid;
place-items: center;
inline-size: 2.2rem;
block-size: 2.2rem;
border-radius: 50%;
background: var(--rail);
color: var(--oki-noir);
font-family: var(--font-display);
font-size: var(--text-1);
}
.arc-advanced .arc-num {
color: oklch(98% 0 0);
}
.arc-diff {
font-family: var(--font-text);
font-size: var(--text-0);
font-weight: 400;
color: var(--oki-ink-2);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
padding: 0.1em 0.6em;
}
.arc ul {
list-style: none;
padding: 0;
}
.cats a {
.arc a {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-height: var(--tap-target);
padding-inline: var(--space-3);
border: 1px solid var(--oki-line);
@@ -86,7 +233,15 @@
color: var(--oki-ink);
}
.cats a:hover {
border-color: var(--oki-jaune);
.arc a:hover {
border-color: var(--rail);
}
.count {
font-size: var(--text-0);
color: var(--oki-ink-2);
background: var(--oki-surface-2);
border-radius: var(--radius-2);
padding: 0.05em 0.5em;
}
</style>
@@ -0,0 +1,91 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte'
import { NEW_TOOL_ISSUE_URL, REPO_URL } from '$lib/site'
// de la plus simple à la plus complète (divulgation progressive, §1.3)
const WAYS = [
{ key: 'suggest', href: NEW_TOOL_ISSUE_URL },
{ key: 'fix', href: `${REPO_URL}/src/branch/main/content/outils` },
{ key: 'translate', href: `${REPO_URL}/src/branch/main/docs/CONTENT_GUIDE.md` }
] as const
</script>
<svelte:head>
<title>{t('contrib.title')}{t('site.name')}</title>
<meta name="description" content={t('contrib.intro')} />
</svelte:head>
<header class="stack">
<h1>{t('contrib.title')}</h1>
<p class="prose">{t('contrib.intro')}</p>
</header>
<ol class="ways stack" style="--gap: var(--space-4)">
{#each WAYS as way, i (way.key)}
<li class="way stack">
<h2><span class="num" aria-hidden="true">{i + 1}</span>{t(`contrib.${way.key}`)}</h2>
<p class="prose">{t(`contrib.${way.key}Body`)}</p>
<p><a class="action" href={way.href} rel="external">{t(`contrib.${way.key}Cta`)}</a></p>
</li>
{/each}
</ol>
<section class="repo stack">
<h2>{t('contrib.repo')}</h2>
<p class="prose">{t('contrib.repoBody')}</p>
<p><a class="action" href={REPO_URL} rel="external">{t('contrib.repoCta')}</a></p>
</section>
<style>
.ways {
list-style: none;
padding: 0;
}
.way {
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
padding: var(--space-4);
}
.way h2 {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--text-3);
}
.num {
display: grid;
place-items: center;
inline-size: 2.2rem;
block-size: 2.2rem;
border-radius: 50%;
background: var(--oki-jaune);
color: var(--oki-noir);
font-size: var(--text-1);
flex-shrink: 0;
}
.action {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-4);
border: 2px solid var(--oki-jaune);
border-radius: var(--radius-2);
font-weight: 700;
text-decoration: none;
color: var(--oki-ink);
}
.action:hover {
background: var(--oki-jaune);
color: var(--oki-noir);
}
.repo {
border-inline-start: 4px solid var(--oki-vert);
padding-inline-start: var(--space-4);
}
</style>
+5 -2
View File
@@ -74,10 +74,11 @@
</div>
<section class="stack" aria-live="polite">
<p class="count">
<!-- h2 : préserve l'ordre des titres (h1 → h2 → h3 des cartes) -->
<h2 class="count">
{filtered.length}
{filtered.length === 1 ? t('directory.resultOne') : t('directory.results')}
</p>
</h2>
{#if filtered.length === 0}
<p class="empty">{t('directory.empty')}</p>
@@ -124,6 +125,8 @@
.count {
color: var(--oki-ink-2);
font-size: var(--text-0);
font-family: var(--font-text);
font-weight: 400;
}
.empty {
@@ -6,9 +6,10 @@ export function entries() {
}
export function load({ params }: { params: { slug: string } }) {
const { tools, categories } = loadContent()
const { tools, categories, gcf } = loadContent()
const tool = tools.find((t) => t.slug === params.slug)
if (!tool) error(404)
const category = categories.find((c) => c.slug === tool.category)
return { tool, category }
// la jumelle créole si elle existe (affichée selon la langue choisie)
return { tool, category, gcf: gcf[tool.slug] ?? null }
}
+31 -7
View File
@@ -1,9 +1,14 @@
<script lang="ts">
import AdoptButton from '$lib/components/AdoptButton.svelte'
import DifficultyBadge from '$lib/components/DifficultyBadge.svelte'
import { t } from '$lib/i18n/index.svelte'
import { getLocale, t } from '$lib/i18n/index.svelte'
let { data } = $props()
let tool = $derived(data.tool)
// fiche jumelle créole : affichée quand la langue est gcf et qu'elle existe ;
// sinon repli français signalé (jamais de page vide)
let useGcf = $derived(getLocale() === 'gcf' && data.gcf !== null)
let sections = $derived(useGcf && data.gcf ? data.gcf.sections : tool.sections)
const SECTION_ORDER = ['what', 'why', 'who', 'install', 'extra'] as const
// « extra » n'a pas d'intitulé i18n dédié : rattaché à l'installation
@@ -61,32 +66,41 @@
<span>{tag}</span>
{/each}
</div>
<p>
<p class="cluster">
<a class="cta" href={tool.url} rel="external">{t('tool.officialSite')}</a>
<AdoptButton slug={tool.slug} />
</p>
</header>
{#if getLocale() === 'gcf'}
<p class="translation-note" role="note">
{data.gcf ? t('tool.draftTranslation') : t('tool.notTranslated')}
</p>
{/if}
{#each SECTION_ORDER as key (key)}
{#if tool.sections[key]}
<section class="prose stack">
{#if sections[key]}
<section class="prose stack" lang={useGcf ? 'gcf' : 'fr'}>
<h2>{t(HEADINGS[key])}</h2>
<!-- HTML produit au build par marked depuis notre propre contenu versionné -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="stack">{@html tool.sections[key]}</div>
<div class="stack">{@html sections[key]}</div>
</section>
{/if}
{/each}
</article>
<style>
/* couleur de marque en anneau, texte en encre : contraste AA garanti */
.monogram {
display: grid;
place-items: center;
inline-size: 3.5rem;
block-size: 3.5rem;
border-radius: var(--radius-2);
background: var(--tool-color, var(--oki-surface-2));
color: white;
background: var(--oki-surface-2);
border: 4px solid var(--tool-color, var(--oki-line));
color: var(--oki-ink);
font-weight: 700;
}
@@ -117,6 +131,16 @@
text-decoration: none;
}
.translation-note {
font-size: var(--text-0);
color: var(--oki-ink-2);
border: 1px dashed var(--oki-line);
border-inline-start: 4px solid var(--oki-jaune);
border-radius: var(--radius-1);
padding: var(--space-2) var(--space-3);
max-width: var(--measure);
}
section h2 {
font-size: var(--text-3);
border-bottom: 3px solid var(--oki-jaune);
+71
View File
@@ -0,0 +1,71 @@
/// <reference types="@sveltejs/kit" />
/**
* Service worker offline-first (doctrine §2.1.7) : résilience réseau
* (3G irrégulière, forfaits comptés) + souveraineté (le guide reste lisible
* même si le site est bloqué ou hors-ligne après une première visite).
*
* Stratégies :
* - assets versionnés (/_app/immutable/…) : cache-first, immuables ;
* - pages HTML : network-first avec repli cache — chaque page visitée
* devient disponible hors-ligne (« cache des fiches consultées ») ;
* - coquille (/, /outils, polices, manifest) : précachée à l'installation.
*/
import { build, files, version } from '$service-worker'
const CACHE = `oki-${version}`
const SHELL = ['/', '/outils', '/contribuer', ...build, ...files]
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE)
.then((cache) => cache.addAll(SHELL))
.then(() => self.skipWaiting())
)
})
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
)
})
self.addEventListener('fetch', (event) => {
const { request } = event
if (request.method !== 'GET') return
const url = new URL(request.url)
if (url.origin !== self.location.origin) return
// assets au nom haché : jamais modifiés, cache-first
if (url.pathname.startsWith('/_app/immutable/')) {
event.respondWith(
caches.match(request).then((hit) => hit ?? fetch(request))
)
return
}
// pages et autres fichiers : network-first, la copie en cache est le filet
event.respondWith(
(async () => {
const cache = await caches.open(CACHE)
try {
const response = await fetch(request)
if (response.ok) cache.put(request, response.clone())
return response
} catch {
const hit = await cache.match(request, { ignoreSearch: request.mode === 'navigate' })
if (hit) return hit
// navigation vers une page jamais visitée : renvoyer l'accueil
// plutôt qu'une erreur réseau brute
if (request.mode === 'navigate') {
const home = await cache.match('/')
if (home) return home
}
throw new Error('offline')
}
})()
)
})