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
+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>