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:
@@ -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",
|
||||
|
||||
Generated
+57
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})()
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user