feat(oki): fondations de la migration vers la stack souveraine OKI

Architecture décidée et documentée dans ADR.md : SvelteKit natif en
fichiers plats plutôt que Strapi (55 outils, données statiques,
souveraineté et budget performance prioritaires — doctrine §1.2, §4.4).

- frontend/ : SvelteKit 2 + Svelte 5 (runes), adapter-static, 58 pages
  prérendues, CSS natif en @layer (reset→tokens→base→layouts→components),
  tokens oklch(), container queries, typo fluide clamp(), 2 polices WOFF2
  auto-hébergées (Atkinson Hyperlegible, Fraunces), zéro CDN tiers
- i18n FR/gcf typé (clé manquante = erreur de build), bascule Kréyòl
  persistée en localStorage, micro-textes créoles (« Ou pa manké ayen »)
- Composants : ToolCard, DifficultyBadge (forme+texte+couleur, jamais la
  couleur seule), CategoryNav (≤5 entrées, divulgation progressive),
  SearchBar (raccourci /), LanguageSwitcher, EndMarker
- content/ : 55 fiches migrées depuis src/ (script frontend/scripts/
  migrate-tools.mjs, idempotent), licences SPDX reprises de directory.json
- docs/ : DESIGN_SYSTEM.md et CONTENT_GUIDE.md

Budgets vérifiés : JS 41 Ko gzip, CSS 2,9 Ko gzip, svelte-check 0 erreur.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
sucupira
2026-07-10 12:51:17 -04:00
parent f4e09ca886
commit db5a997e50
99 changed files with 5356 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
# exitchatcontrol × 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
décision d'architecture et `../docs/` pour le design system et le guide
de contenu.
## Prérequis
Node ≥ 22.12, pnpm.
## Commandes
```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)
```
## 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.
## Arborescence
```
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]
static/fonts/ 2 WOFF2 auto-hébergés (Atkinson Hyperlegible, Fraunces)
scripts/ migrate-tools.mjs
```
+31
View File
@@ -0,0 +1,31 @@
{
"name": "exitchatcontrol-oki",
"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.",
"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"
},
"engines": {
"node": ">=22.12"
},
"devDependencies": {
"@fontsource/atkinson-hyperlegible": "^5.2.8",
"@fontsource/fraunces": "^5.2.9",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@types/node": "^26.1.1",
"marked": "^16.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^7.0.0"
}
}
+1076
View File
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
/**
* 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 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)) 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)
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(path.join(OUT, 'outils', `${slug}.md`), `---\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(', ')}`)
}
+14
View File
@@ -0,0 +1,14 @@
/*
* app.css — ordre de cascade explicite et auditable (doctrine §5.2).
* Chaque couche est déclarée AVANT tout import : la position dans la
* cascade est ici, pas dans l'ordre des fichiers.
*/
@layer reset, tokens, base, layouts, components, utilities;
/* @font-face n'est pas concerné par les couches */
@import './styles/fonts.css';
@import './styles/reset.css' layer(reset);
@import './styles/tokens.css' layer(tokens);
@import './styles/base.css' layer(base);
@import './styles/layouts.css' layer(layouts);
+12
View File
@@ -0,0 +1,12 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#17181f" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -0,0 +1,101 @@
<script lang="ts">
import { browser } from '$app/environment'
import { page } from '$app/state'
import { t } from '$lib/i18n/index.svelte'
interface NavCategory {
slug: string
title: string
}
let { categories }: { categories: NavCategory[] } = $props()
// Hick-Hyman (doctrine §1.3) : ≤ 5 entrées visibles, le reste en
// divulgation progressive (<details>, fonctionne sans JS).
const VISIBLE = 4
let visible = $derived(categories.slice(0, VISIBLE))
let rest = $derived(categories.slice(VISIBLE))
// searchParams non lisibles au prerender : garde `browser`
let current = $derived(browser ? page.url.searchParams.get('cat') : null)
function href(slug: string | null): string {
return slug ? `/outils?cat=${slug}` : '/outils'
}
</script>
<nav aria-label={t('directory.filterCategory')} class="catnav">
<ul class="cluster">
<li><a href={href(null)} aria-current={current === null ? 'page' : undefined}>{t('directory.allCategories')}</a></li>
{#each visible as cat (cat.slug)}
<li>
<a href={href(cat.slug)} aria-current={current === cat.slug ? 'page' : undefined}>{cat.title}</a>
</li>
{/each}
{#if rest.length > 0}
<li>
<details>
<summary>+ {rest.length}</summary>
<ul class="stack">
{#each rest as cat (cat.slug)}
<li>
<a href={href(cat.slug)} aria-current={current === cat.slug ? 'page' : undefined}>{cat.title}</a>
</li>
{/each}
</ul>
</details>
</li>
{/if}
</ul>
</nav>
<style>
ul {
list-style: none;
padding: 0;
}
a {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-3);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
text-decoration: none;
color: var(--oki-ink);
}
a[aria-current='page'] {
background: var(--oki-jaune);
border-color: var(--oki-jaune);
color: var(--oki-noir);
font-weight: 700;
}
details {
position: relative;
}
summary {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-3);
border: 1px dashed var(--oki-line);
border-radius: var(--radius-2);
cursor: pointer;
}
details ul {
position: absolute;
z-index: 2;
inset-inline-start: 0;
margin-block-start: var(--space-1);
padding: var(--space-2);
background: var(--oki-surface);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
min-inline-size: 16rem;
box-shadow: 0 4px 16px oklch(0% 0 0 / 0.15);
}
</style>
@@ -0,0 +1,36 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte'
// Jamais la couleur seule : forme + texte + couleur (doctrine §1.1 —
// ~8 % de déficience de vision des couleurs).
const SHAPES = { beginner: '●', intermediate: '◆', advanced: '▲' } as const
let { difficulty }: { difficulty: keyof typeof SHAPES } = $props()
</script>
<span class="badge badge-{difficulty}">
<span aria-hidden="true">{SHAPES[difficulty]}</span>
{t(`difficulty.${difficulty}`)}
</span>
<style>
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-0);
font-weight: 600;
padding: 0.15em 0.6em;
border-radius: var(--radius-2);
border: 1px solid currentColor;
}
.badge-beginner {
color: var(--diff-beginner-ink);
}
.badge-intermediate {
color: var(--diff-intermediate-ink);
}
.badge-advanced {
color: var(--diff-advanced-ink);
}
</style>
@@ -0,0 +1,34 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte'
</script>
<!-- Marqueur de fin explicite : restitue le point d'arrêt naturel que le
scroll infini supprime (doctrine §1.5 et §3.2). -->
<p class="end" role="status">
<strong>{t('directory.endOfList')}</strong>
<span>{t('directory.endOfListSub')}</span>
</p>
<style>
.end {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
text-align: center;
padding-block: var(--space-5);
border-top: 4px double var(--oki-line);
margin-block-start: var(--space-5);
}
strong {
font-family: var(--font-display);
font-size: var(--text-2);
color: var(--accent-ink);
}
span {
color: var(--oki-ink-2);
font-size: var(--text-0);
}
</style>
@@ -0,0 +1,40 @@
<script lang="ts">
import { getLocale, setLocale, locales, localeNames, t } from '$lib/i18n/index.svelte'
</script>
<!-- Bascule explicite FR / Kréyòl, persistée en localStorage (mission §4.3).
Amélioration progressive : sans JS, le site reste lisible en français. -->
<div class="switch" role="group" aria-label={t('lang.switchLabel')}>
{#each locales as locale (locale)}
<button
type="button"
aria-pressed={getLocale() === locale}
onclick={() => setLocale(locale)}
>
{localeNames[locale]}
</button>
{/each}
</div>
<style>
.switch {
display: inline-flex;
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
overflow: hidden;
}
button {
border: none;
border-radius: 0;
background: transparent;
min-height: var(--tap-target);
font-size: var(--text-0);
}
button[aria-pressed='true'] {
background: var(--oki-jaune);
color: var(--oki-noir);
font-weight: 700;
}
</style>
@@ -0,0 +1,43 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte'
let { value = $bindable('') }: { value?: string } = $props()
let input: HTMLInputElement
// 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
e.preventDefault()
input.focus()
}
}
</script>
<svelte:window {onkeydown} />
<p class="search">
<label>
<span class="visually-hidden">{t('search.label')}</span>
<input
bind:this={input}
bind:value
type="search"
placeholder={t('search.placeholder')}
autocomplete="off"
/>
</label>
</p>
<style>
input {
inline-size: 100%;
max-inline-size: 28rem;
min-height: var(--tap-target);
padding-inline: var(--space-3);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
background: var(--oki-surface);
}
</style>
@@ -0,0 +1,99 @@
<script lang="ts">
import DifficultyBadge from './DifficultyBadge.svelte'
import { t } from '$lib/i18n/index.svelte'
interface CardTool {
name: string
slug: string
difficulty: 'beginner' | 'intermediate' | 'advanced'
license?: string
color?: string
monogram?: string
tags: string[]
excerpt: string
}
let { tool }: { tool: CardTool } = $props()
</script>
<article class="card">
<header class="cluster">
<span class="monogram" style:--tool-color={tool.color} aria-hidden="true">
{tool.monogram ?? tool.name.slice(0, 2)}
</span>
<h3><a href="/outils/{tool.slug}">{tool.name}</a></h3>
</header>
<p class="excerpt"><span class="visually-hidden">{t('tool.why')} : </span>{tool.excerpt}</p>
<footer class="cluster">
<DifficultyBadge difficulty={tool.difficulty} />
{#if tool.license}
<span class="meta">{tool.license}</span>
{/if}
{#each tool.tags as tag (tag)}
<span class="meta">{tag}</span>
{/each}
</footer>
</article>
<style>
.card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--oki-line);
border-left: 4px solid var(--oki-jaune);
border-radius: var(--radius-2);
background: var(--oki-surface);
height: 100%;
}
.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;
font-weight: 700;
font-size: var(--text-0);
}
h3 {
font-size: var(--text-2);
}
/* toute la carte est cliquable via le pseudo-élément du lien —
un vrai <a>, pas de div cliquable (doctrine §4.3) */
h3 a {
text-decoration: none;
}
h3 a::after {
content: '';
position: absolute;
inset: 0;
}
h3 a:focus-visible::after {
outline: var(--focus-ring);
outline-offset: -3px;
border-radius: var(--radius-2);
}
.excerpt {
color: var(--oki-ink-2);
font-size: var(--text-0);
flex-grow: 1;
}
.meta {
font-size: var(--text-0);
color: var(--oki-ink-2);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
padding: 0.1em 0.6em;
}
</style>
+117
View File
@@ -0,0 +1,117 @@
/**
* 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 path from 'node:path'
import { marked } from 'marked'
export type Difficulty = 'beginner' | 'intermediate' | 'advanced'
export interface ToolSections {
what?: string
why?: string
who?: string
install?: string
extra?: string
}
export interface Tool {
name: string
slug: string
category: string
difficulty: Difficulty
license?: string
url: string
color?: string
monogram?: string
iconSlug?: string
tags: string[]
/** extrait texte brut de « why », pour les cartes */
excerpt: string
sections: ToolSections
}
export interface Category {
slug: string
title: string
order: number
part: number
}
const CONTENT = path.resolve(process.cwd(), '..', 'content')
const SECTION_KEYS = ['what', 'why', 'who', 'install', 'extra'] as const
function parseFrontmatter(raw: string): [Record<string, unknown>, string] {
const m = raw.match(/^---\n([\s\S]*?)\n---\n?/)
if (!m) throw new Error('frontmatter manquant')
const meta: Record<string, unknown> = {}
for (const line of m[1].split('\n')) {
const i = line.indexOf(':')
if (i === -1) continue
// les valeurs sont encodées en JSON par le script de migration
meta[line.slice(0, i).trim()] = JSON.parse(line.slice(i + 1).trim())
}
return [meta, raw.slice(m[0].length)]
}
function parseSections(body: string): ToolSections {
const sections: ToolSections = {}
const parts = body.split(/^## (\w+)\s*$/m)
for (let i = 1; i < parts.length; i += 2) {
const key = parts[i] as (typeof SECTION_KEYS)[number]
if (SECTION_KEYS.includes(key)) {
sections[key] = marked.parse(parts[i + 1].trim(), { async: false })
}
}
return sections
}
const ENTITIES: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' '
}
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:amp|lt|gt|quot|#39|nbsp);/g, (e) => ENTITIES[e])
.replace(/[ \t\r\n]+/g, ' ')
.trim()
}
function loadTool(file: string): Tool {
const raw = readFileSync(path.join(CONTENT, 'outils', file), 'utf8')
const [meta, body] = parseFrontmatter(raw)
const sections = parseSections(body)
const why = stripHtml(sections.why ?? sections.what ?? '')
return {
...(meta as unknown as Omit<Tool, 'sections' | 'excerpt'>),
excerpt: why.length > 160 ? `${why.slice(0, 157).trimEnd()}` : why,
sections
}
}
let cache: { tools: Tool[]; categories: Category[] } | null = null
export function loadContent() {
if (cache) return cache
const categories = (
JSON.parse(readFileSync(path.join(CONTENT, 'categories.json'), 'utf8')) as Category[]
).sort((a, b) => a.order - b.order)
const order = new Map(categories.map((c, i) => [c.slug, i]))
const tools = readdirSync(path.join(CONTENT, 'outils'))
.filter((f) => f.endsWith('.md'))
.map(loadTool)
.sort(
(a, b) =>
(order.get(a.category) ?? 99) - (order.get(b.category) ?? 99) ||
a.name.localeCompare(b.name, 'fr')
)
cache = { tools, categories }
return cache
}
+63
View File
@@ -0,0 +1,63 @@
{
"site": {
"name": "Exit 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."
},
"nav": {
"home": "Accueil",
"directory": "Annuaire",
"contribute": "Contribuer",
"skipToContent": "Aller au contenu"
},
"hero": {
"title": "Chwazi zouti ou",
"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"
},
"directory": {
"title": "Annuaire des outils",
"intro": "Chaque outil est libre, vérifié, et classé par difficulté. Commence par le vert.",
"filterCategory": "Catégorie",
"filterDifficulty": "Difficulté",
"allCategories": "Toutes les catégories",
"allDifficulties": "Toutes",
"results": "outils",
"resultOne": "outil",
"empty": "Ayen pa maché èvè filtè-lasa — essaie d'élargir les filtres.",
"endOfList": "Ou pa manké ayen",
"endOfListSub": "Tu es au bout de la liste — rien ne se recharge dans ton dos.",
"prevPage": "Page précédente",
"nextPage": "Page suivante",
"page": "Page"
},
"tool": {
"what": "À quoi ça sert",
"why": "Pourquoi c'est important",
"who": "Pour qui, quand",
"install": "Installer et utiliser",
"officialSite": "Site officiel",
"sourceCode": "Code source",
"license": "Licence",
"category": "Catégorie",
"backToDirectory": "Retour à l'annuaire"
},
"difficulty": {
"beginner": "Débutant",
"intermediate": "Intermédiaire",
"advanced": "Avancé"
},
"search": {
"label": "Rechercher un outil",
"placeholder": "Rechercher… (touche /)",
"noResults": "Aucun outil trouvé"
},
"lang": {
"switchLabel": "Langue",
"switchTo": "Passer en"
},
"footer": {
"license": "Contenu et code libres (MIT). Zéro tracker, zéro CDN, zéro cookie.",
"contribute": "Proposer un outil ou une correction"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"site": {
"name": "Exit 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."
},
"nav": {
"home": "Lakou",
"directory": "Lis a zouti",
"contribute": "Pòté mannèv",
"skipToContent": "Alé dirèk asi kontni-la"
},
"hero": {
"title": "Chwazi zouti ou",
"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"
},
"directory": {
"title": "Lis a zouti",
"intro": "Chak zouti lib, vérifyé, é rangé pa difikilté. Koumansé èvè vèw-la.",
"filterCategory": "Katégori",
"filterDifficulty": "Difikilté",
"allCategories": "Tout katégori",
"allDifficulties": "Tout",
"results": "zouti",
"resultOne": "zouti",
"empty": "Ayen pa maché èvè filtè-lasa — èséyé louvri filtè a-w.",
"endOfList": "Ou pa manké ayen",
"endOfListSub": "Ou rivé an bout a lis-la — ayen pa ka richajé dèyè do a-w.",
"prevPage": "Paj avan",
"nextPage": "Paj apré",
"page": "Paj"
},
"tool": {
"what": "Pou ki sa i sèwvi",
"why": "Poukisa sa enpòwtan",
"who": "Pou kimoun, kitan",
"install": "Enstalé é sèwvi",
"officialSite": "Sit ofisyèl",
"sourceCode": "Kòd sous",
"license": "Lisans",
"category": "Katégori",
"backToDirectory": "Viré an lis-la"
},
"difficulty": {
"beginner": "Débitan",
"intermediate": "Entèmédia",
"advanced": "Avansé"
},
"search": {
"label": "Chèché on zouti",
"placeholder": "Chèché… (touch /)",
"noResults": "Pon zouti pa touvé"
},
"lang": {
"switchLabel": "Lang",
"switchTo": "Pasé an"
},
"footer": {
"license": "Kontni é kòd lib (MIT). Pon trakè, pon CDN, pon cookie.",
"contribute": "Pwopozé on zouti oben on koreksyon"
}
}
+54
View File
@@ -0,0 +1,54 @@
import fr from './fr.json'
import gcf from './gcf.json'
import { browser } from '$app/environment'
import { defaultLocale, type Locale } from './locales'
export { locales, localeNames, defaultLocale, type Locale } from './locales'
type Dict = typeof fr
// gcf est vérifié par TypeScript contre la forme du français : une clé
// manquante est une erreur de build, pas un fallback silencieux (ADR-001).
const dicts: Record<Locale, Dict> = { fr, gcf }
const STORAGE_KEY = 'oki-lang'
function initialLocale(): Locale {
if (!browser) return defaultLocale
const saved = localStorage.getItem(STORAGE_KEY)
return saved === 'gcf' || saved === 'fr' ? saved : defaultLocale
}
// Rune d'état partagée (Svelte 5) : la langue de l'interface.
// Le HTML prérendu est en fr ; le créole est une amélioration progressive
// (persistance localStorage — mission §4.3, doctrine §4.2).
const state = $state({ locale: initialLocale() })
export function getLocale(): Locale {
return state.locale
}
export function setLocale(locale: Locale) {
state.locale = locale
if (browser) {
localStorage.setItem(STORAGE_KEY, locale)
document.documentElement.lang = locale
}
}
function lookup(dict: Dict, path: string): unknown {
return path.split('.').reduce<unknown>((node, key) => {
if (node && typeof node === 'object' && key in node) {
return (node as Record<string, unknown>)[key]
}
return undefined
}, dict)
}
/** Chaîne d'interface pour `path` dans la langue courante (réactif). */
export function t(path: string): string {
const hit = lookup(dicts[state.locale], path) ?? lookup(dicts[defaultLocale], path)
if (typeof hit !== 'string') {
throw new Error(`Clé i18n manquante « ${path} » (locale : ${state.locale})`)
}
return hit
}
+11
View File
@@ -0,0 +1,11 @@
// gcf = créole guadeloupéen (ISO 639-3, valide BCP 47).
// La langue du cœur d'OKI : les micro-textes créoles activent les circuits
// de confiance avant toute lecture consciente (doctrine §4.2).
export const locales = ['fr', 'gcf'] as const
export type Locale = (typeof locales)[number]
export const defaultLocale: Locale = 'fr'
export const localeNames: Record<Locale, string> = {
fr: 'Français',
gcf: 'Kréyòl'
}
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import '../app.css'
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'
import { t } from '$lib/i18n/index.svelte'
let { children } = $props()
</script>
<a class="skip-link" href="#main">{t('nav.skipToContent')}</a>
<header class="center">
<div class="cluster bar">
<a class="brand" href="/">
<span class="brand-mark" aria-hidden="true"></span>
{t('site.name')}
</a>
<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>
</ul>
</nav>
<LanguageSwitcher />
</div>
</header>
<main id="main" class="center stack" style="--gap: var(--space-5)">
{@render children()}
</main>
<footer class="center">
<p>{t('footer.license')}</p>
<p>
<a href="https://github.com/exitchatcontrol/exitchatcontrol.org" rel="external">
{t('footer.contribute')}
</a>
</p>
</footer>
<style>
.bar {
justify-content: space-between;
padding-block: var(--space-3);
border-bottom: 4px solid var(--oki-jaune);
margin-block-end: var(--space-4);
}
.brand {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-family: var(--font-display);
font-weight: 700;
font-size: var(--text-2);
text-decoration: none;
color: var(--oki-ink);
min-height: var(--tap-target);
}
/* marque OKI : les trois bandes panafricaines — identité du territoire
dès le premier écran, test des 50 ms (doctrine §1.1 et §4.2) */
.brand-mark {
inline-size: 0.9rem;
block-size: 1.6rem;
border-radius: 2px;
background: linear-gradient(
to bottom,
var(--oki-vert) 0 33%,
var(--oki-jaune) 33% 66%,
var(--oki-rouge) 66% 100%
);
}
nav ul {
list-style: none;
padding: 0;
}
nav a {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-2);
text-decoration: none;
color: var(--oki-ink);
}
nav a:hover {
text-decoration: underline;
text-decoration-color: var(--oki-jaune);
text-decoration-thickness: 3px;
}
main {
padding-block-end: var(--space-6);
}
footer {
border-top: 1px solid var(--oki-line);
padding-block: var(--space-4);
color: var(--oki-ink-2);
font-size: var(--text-0);
}
</style>
+3
View File
@@ -0,0 +1,3 @@
// Tout le site est prérendu (ADR-001) : HTML statique servi par nginx,
// un lien fonctionne sans JS (doctrine §4.4).
export const prerender = true
+11
View File
@@ -0,0 +1,11 @@
import { loadContent } from '$lib/content/tools.server'
export function load() {
const { tools, categories } = loadContent()
return {
categories,
toolCount: tools.length,
// les premiers gestes : outils débutant, un par catégorie
starters: tools.filter((t) => t.difficulty === 'beginner').slice(0, 3)
}
}
+92
View File
@@ -0,0 +1,92 @@
<script lang="ts">
import ToolCard from '$lib/components/ToolCard.svelte'
import { t } from '$lib/i18n/index.svelte'
let { data } = $props()
</script>
<svelte:head>
<title>{t('site.name')}{t('site.tagline')}</title>
<meta name="description" content={t('site.description')} />
</svelte:head>
<!-- Valeur livrée en < 5 s (doctrine §2.1) : le héros dit quoi, pour qui,
et donne UNE action primaire (Von Restorff, doctrine §1.2). -->
<section class="hero stack">
<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>
</section>
<section class="stack">
<h2>{t('directory.intro')}</h2>
<div class="card-grid">
<ul>
{#each data.starters as tool (tool.slug)}
<li><ToolCard {tool} /></li>
{/each}
</ul>
</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);
}
.hero h1 {
color: var(--accent-ink);
}
.sub {
font-size: var(--text-2);
color: var(--oki-ink-2);
max-width: var(--measure);
}
/* une seule action primaire par écran (Von Restorff, doctrine §1.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;
}
.cta:hover {
filter: brightness(1.05);
}
.cats {
list-style: none;
padding: 0;
}
.cats a {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-3);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
text-decoration: none;
color: var(--oki-ink);
}
.cats a:hover {
border-color: var(--oki-jaune);
}
</style>
@@ -0,0 +1,10 @@
import { loadContent } from '$lib/content/tools.server'
export function load() {
const { tools, categories } = loadContent()
return {
categories,
// seules les données de carte partent au client (budget JS, doctrine §4.4)
tools: tools.map(({ sections: _sections, ...card }) => card)
}
}
+136
View File
@@ -0,0 +1,136 @@
<script lang="ts">
import { browser } from '$app/environment'
import { page } from '$app/state'
import CategoryNav from '$lib/components/CategoryNav.svelte'
import SearchBar from '$lib/components/SearchBar.svelte'
import ToolCard from '$lib/components/ToolCard.svelte'
import EndMarker from '$lib/components/EndMarker.svelte'
import DifficultyBadge from '$lib/components/DifficultyBadge.svelte'
import { t } from '$lib/i18n/index.svelte'
let { data } = $props()
let query = $state('')
const DIFFICULTIES = ['beginner', 'intermediate', 'advanced'] as const
// Chaque filtre vit dans l'URL (query param) : partageable (mission §5.1).
// Le HTML prérendu montre la liste complète ; le filtrage est une
// amélioration progressive côté client (ADR-001). Les searchParams ne
// sont pas lisibles au prerender, d'où la garde `browser`.
let cat = $derived(browser ? page.url.searchParams.get('cat') : null)
let diff = $derived(browser ? page.url.searchParams.get('diff') : null)
let filtered = $derived(
data.tools.filter(
(tool) =>
(!cat || tool.category === cat) &&
(!diff || tool.difficulty === diff) &&
(!query ||
`${tool.name} ${tool.tags.join(' ')} ${tool.excerpt}`
.toLocaleLowerCase('fr')
.includes(query.toLocaleLowerCase('fr')))
)
)
function diffHref(d: string | null): string {
const params = new URLSearchParams(browser ? page.url.searchParams : undefined)
if (cat) params.set('cat', cat)
if (d) params.set('diff', d)
else params.delete('diff')
const qs = params.toString()
return qs ? `/outils?${qs}` : '/outils'
}
</script>
<svelte:head>
<title>{t('directory.title')}{t('site.name')}</title>
<meta name="description" content={t('directory.intro')} />
</svelte:head>
<header class="stack">
<h1>{t('directory.title')}</h1>
<p>{t('directory.intro')}</p>
</header>
<div class="stack filters">
<SearchBar bind:value={query} />
<CategoryNav categories={data.categories} />
<nav aria-label={t('directory.filterDifficulty')}>
<ul class="cluster">
<li>
<a href={diffHref(null)} aria-current={diff === null ? 'page' : undefined}>
{t('directory.allDifficulties')}
</a>
</li>
{#each DIFFICULTIES as d (d)}
<li>
<a href={diffHref(d)} aria-current={diff === d ? 'page' : undefined}>
<DifficultyBadge difficulty={d} />
</a>
</li>
{/each}
</ul>
</nav>
</div>
<section class="stack" aria-live="polite">
<p class="count">
{filtered.length}
{filtered.length === 1 ? t('directory.resultOne') : t('directory.results')}
</p>
{#if filtered.length === 0}
<p class="empty">{t('directory.empty')}</p>
{:else}
<div class="card-grid">
<ul>
{#each filtered as tool (tool.slug)}
<li><ToolCard {tool} /></li>
{/each}
</ul>
</div>
{/if}
<EndMarker />
</section>
<style>
.filters {
--gap: var(--space-3);
}
nav ul {
list-style: none;
padding: 0;
}
nav a {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-3);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
text-decoration: none;
color: var(--oki-ink);
}
nav a[aria-current='page'] {
background: var(--oki-surface-2);
border-color: var(--oki-ink);
font-weight: 700;
}
.count {
color: var(--oki-ink-2);
font-size: var(--text-0);
}
.empty {
padding: var(--space-5);
border: 1px dashed var(--oki-line);
border-radius: var(--radius-2);
text-align: center;
color: var(--oki-ink-2);
}
</style>
@@ -0,0 +1,14 @@
import { error } from '@sveltejs/kit'
import { loadContent } from '$lib/content/tools.server'
export function entries() {
return loadContent().tools.map(({ slug }) => ({ slug }))
}
export function load({ params }: { params: { slug: string } }) {
const { tools, categories } = 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 }
}
@@ -0,0 +1,132 @@
<script lang="ts">
import DifficultyBadge from '$lib/components/DifficultyBadge.svelte'
import { t } from '$lib/i18n/index.svelte'
let { data } = $props()
let tool = $derived(data.tool)
const SECTION_ORDER = ['what', 'why', 'who', 'install', 'extra'] as const
// « extra » n'a pas d'intitulé i18n dédié : rattaché à l'installation
const HEADINGS: Record<string, string> = {
what: 'tool.what',
why: 'tool.why',
who: 'tool.who',
install: 'tool.install',
extra: 'tool.install'
}
let jsonLd = $derived(
`<script type="application/ld+json">${JSON.stringify({
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: tool.name,
url: tool.url,
license: tool.license ? `https://spdx.org/licenses/${tool.license}` : undefined,
applicationCategory: data.category?.title
})}${'<'}/script>`
)
</script>
<svelte:head>
<title>{tool.name}{t('site.name')}</title>
<meta name="description" content={tool.excerpt} />
<meta property="og:title" content={tool.name} />
<meta property="og:description" content={tool.excerpt} />
<meta property="og:type" content="article" />
<!-- eslint-disable-next-line svelte/no-at-html-tags — JSON-LD généré au build depuis nos propres données -->
{@html jsonLd}
</svelte:head>
<nav aria-label={t('tool.backToDirectory')}>
<a href="/outils">{t('tool.backToDirectory')}</a>
</nav>
<article class="stack" style="--gap: var(--space-4)">
<header class="stack">
<div class="cluster">
<span class="monogram" style:--tool-color={tool.color} aria-hidden="true">
{tool.monogram ?? tool.name.slice(0, 2)}
</span>
<h1>{tool.name}</h1>
</div>
<div class="cluster meta">
<DifficultyBadge difficulty={tool.difficulty} />
{#if data.category}
<a href="/outils?cat={data.category.slug}">{data.category.title}</a>
{/if}
{#if tool.license}
<span>{t('tool.license')} : {tool.license}</span>
{/if}
{#each tool.tags as tag (tag)}
<span>{tag}</span>
{/each}
</div>
<p>
<a class="cta" href={tool.url} rel="external">{t('tool.officialSite')}</a>
</p>
</header>
{#each SECTION_ORDER as key (key)}
{#if tool.sections[key]}
<section class="prose stack">
<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>
</section>
{/if}
{/each}
</article>
<style>
.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;
font-weight: 700;
}
.meta {
color: var(--oki-ink-2);
font-size: var(--text-0);
}
.meta > * {
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
padding: 0.15em 0.6em;
}
.meta a {
color: inherit;
}
.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;
}
section h2 {
font-size: var(--text-3);
border-bottom: 3px solid var(--oki-jaune);
display: inline-block;
padding-block-end: var(--space-1);
}
nav a {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
}
</style>
+99
View File
@@ -0,0 +1,99 @@
/*
* base.css — HTML sémantique nu (doctrine §4.3 : l'accessibilité est
* d'abord un problème de HTML correct).
*/
body {
font-family: var(--font-text);
font-size: var(--text-1);
line-height: var(--leading);
color: var(--oki-ink);
background: var(--oki-surface);
}
h1,
h2,
h3 {
font-family: var(--font-display);
line-height: 1.15;
text-wrap: balance;
}
h1 {
font-size: var(--text-5);
}
h2 {
font-size: var(--text-4);
}
h3 {
font-size: var(--text-2);
}
p,
li {
max-width: var(--measure);
}
a {
color: var(--accent-ink);
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
}
a:hover {
text-decoration-thickness: 2px;
}
/* focus visible sur TOUT élément interactif (doctrine §4.3) */
:focus-visible {
outline: var(--focus-ring);
outline-offset: 2px;
border-radius: var(--radius-1);
}
button {
min-height: var(--tap-target); /* Fitts, doctrine §1.3 */
padding-inline: var(--space-3);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
background: var(--oki-surface-2);
cursor: pointer;
}
code {
font-size: 0.9em;
background: var(--oki-surface-2);
padding: 0.1em 0.35em;
border-radius: var(--radius-1);
}
pre {
overflow-x: auto;
padding: var(--space-3);
background: var(--oki-surface-2);
border-radius: var(--radius-2);
}
pre code {
background: none;
padding: 0;
}
::selection {
background: var(--oki-jaune);
color: var(--oki-noir);
}
/* lien d'évitement clavier */
.skip-link {
position: absolute;
inset-inline-start: var(--space-3);
inset-block-start: var(--space-3);
translate: 0 -300%;
background: var(--oki-surface);
padding: var(--space-2) var(--space-3);
z-index: 10;
}
.skip-link:focus {
translate: 0 0;
}
+20
View File
@@ -0,0 +1,20 @@
/*
* Polices auto-hébergées, servies depuis notre domaine — jamais de Google
* Fonts (doctrine §5.3 : performance, RGPD, souveraineté).
* Budget : 2 fichiers WOFF2 maximum. Le gras du corps est synthétisé.
*/
@font-face {
font-family: 'Atkinson Hyperlegible';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/atkinson-hyperlegible-latin-400-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/fraunces-latin-700-normal.woff2') format('woff2');
}
+63
View File
@@ -0,0 +1,63 @@
/*
* layouts.css — primitives de composition (approche « Every Layout » :
* des compositions, pas des pages — doctrine §5.2).
* Le responsive passe par container queries, jamais par media queries
* de largeur d'écran (doctrine §2.2 point 6).
*/
.stack {
display: flex;
flex-direction: column;
gap: var(--gap, var(--space-3));
}
.cluster {
display: flex;
flex-wrap: wrap;
gap: var(--gap, var(--space-2));
align-items: var(--align, center);
}
.center {
box-sizing: content-box;
max-inline-size: var(--max, 70rem);
margin-inline: auto;
padding-inline: var(--space-3);
}
.prose {
max-inline-size: var(--measure);
}
/* grille de cartes : pilotée par le conteneur, pas par l'écran */
.card-grid {
container-type: inline-size;
}
.card-grid > ul {
list-style: none;
padding: 0;
display: grid;
gap: var(--space-3);
grid-template-columns: 1fr;
}
@container (min-width: 44rem) {
.card-grid > ul {
grid-template-columns: repeat(2, 1fr);
}
}
@container (min-width: 68rem) {
.card-grid > ul {
grid-template-columns: repeat(3, 1fr);
}
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
+54
View File
@@ -0,0 +1,54 @@
/* Reset moderne minimal (doctrine §5.2 — ~30 lignes, rien de plus). */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
}
html {
/* jamais de font-size en px sur html : respect de la taille utilisateur (doctrine §4.3) */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
min-height: 100dvh;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
img,
picture,
svg,
video {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
p,
h1,
h2,
h3,
h4 {
overflow-wrap: break-word;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+77
View File
@@ -0,0 +1,77 @@
/*
* tokens.css — design tokens OKI (doctrine §5.2).
* Toute valeur visuelle du site vit ici ; les composants Svelte consomment
* les tokens, jamais de valeur brute (couleur, taille) dans un composant.
* Couleurs en oklch() : luminance perceptuelle maîtrisée => contraste WCAG
* calculable (méthode modus-vivendi, doctrine §4.3).
*/
:root {
color-scheme: light dark;
/* ── Identité panafricaine OKI ─────────────────────────────────────── */
--oki-ink: light-dark(oklch(18% 0.02 260), oklch(94% 0.01 260));
--oki-surface: light-dark(oklch(97% 0.005 90), oklch(14% 0.02 260));
--oki-surface-2: light-dark(oklch(93% 0.008 90), oklch(20% 0.02 260));
--oki-line: light-dark(oklch(85% 0.01 90), oklch(32% 0.02 260));
--oki-ink-2: light-dark(oklch(38% 0.02 260), oklch(75% 0.01 260));
/* Couleurs identitaires (drapeau panafricain — jaune/vert/rouge/noir) */
--oki-jaune: oklch(75% 0.18 85);
--oki-vert: oklch(55% 0.18 145);
--oki-rouge: oklch(55% 0.22 25);
--oki-noir: oklch(15% 0.01 260);
/* Accent principal : le jaune OKI, décliné pour rester AA sur chaque fond */
--accent: light-dark(oklch(52% 0.14 85), var(--oki-jaune));
--accent-ink: light-dark(oklch(35% 0.1 85), oklch(85% 0.16 85));
/* ── Difficulté — jamais portée par la couleur seule (doctrine §1.1) ──
Chaque niveau = couleur + texte + icône dans DifficultyBadge. */
--diff-beginner: oklch(65% 0.15 145);
--diff-intermediate: oklch(75% 0.16 85);
--diff-advanced: oklch(55% 0.18 25);
/* variantes texte, contrastées AA sur --oki-surface */
--diff-beginner-ink: light-dark(oklch(42% 0.14 145), oklch(78% 0.15 145));
--diff-intermediate-ink: light-dark(oklch(48% 0.13 85), oklch(80% 0.15 85));
--diff-advanced-ink: light-dark(oklch(46% 0.18 25), oklch(72% 0.16 25));
/* ── Typographie fluide (clamp, zéro media query — doctrine §5.2) ────
Échelle modulaire ratio 1.25, corps 16px mobile → 18px desktop. */
--text-0: clamp(0.875rem, 0.85rem + 0.15vw, 0.9375rem);
--text-1: clamp(1rem, 0.95rem + 0.4vw, 1.125rem);
--text-2: clamp(1.25rem, 1.17rem + 0.5vw, 1.4rem);
--text-3: clamp(1.56rem, 1.42rem + 0.8vw, 1.75rem);
--text-4: clamp(1.95rem, 1.7rem + 1.4vw, 2.44rem);
--text-5: clamp(2.44rem, 2rem + 2.4vw, 3.4rem);
--font-text: 'Atkinson Hyperlegible', system-ui, sans-serif;
--font-display: 'Fraunces', var(--font-text);
--measure: 65ch; /* largeur de lecture 45-75ch (doctrine §2.2) */
--leading: 1.6;
/* ── Espacement modulaire ────────────────────────────────────────── */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
/* ── Interaction ─────────────────────────────────────────────────── */
--tap-target: 48px; /* Fitts (doctrine §1.3) */
--radius-1: 4px;
--radius-2: 10px;
--duration-1: 120ms;
--duration-2: 240ms;
--ease-out: cubic-bezier(0.2, 0, 0, 1);
--focus-ring: 3px solid var(--accent);
}
@media (prefers-contrast: more) {
:root {
--oki-ink: light-dark(oklch(5% 0.01 260), oklch(99% 0 0));
--oki-line: light-dark(oklch(40% 0.01 260), oklch(70% 0.01 260));
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+18
View File
@@ -0,0 +1,18 @@
{
"name": "Exit Chat Control",
"short_name": "ExitCC",
"description": "Annuaire d'outils libres pour la souveraineté numérique",
"lang": "fr",
"start_url": "/",
"display": "standalone",
"background_color": "#17181f",
"theme_color": "#17181f",
"icons": [
{
"src": "/favicon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
+19
View File
@@ -0,0 +1,19 @@
import adapter from '@sveltejs/adapter-static'
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
// Site 100 % prérendu : servi par nginx sur l'infra OKI, mirrorable
// (ADR-001 ; doctrine §4.4 — HTML rendu serveur, un lien marche sans JS).
adapter: adapter({
fallback: '404.html'
}),
prerender: {
handleHttpError: 'fail'
}
}
}
export default config
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+12
View File
@@ -0,0 +1,12 @@
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [sveltekit()],
server: {
fs: {
// le contenu éditorial vit à la racine du repo, hors de frontend/
allow: ['..']
}
}
})