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:
@@ -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>
|
||||
Reference in New Issue
Block a user