feat(app) : socle SvelteKit phase 0 — SSO, thème OKI, adapter-node

- Scaffold SvelteKit 2 + Svelte 5 (runes), TypeScript strict, adapter-node
- Auth SSO via header Ynh-User (hooks.server) ; accueil public minimal sans header,
  401 sur toute autre route ; DEV_USER en dev local uniquement
- Design system OKI : tokens, thème sombre par défaut, clair opt-in anti-FOUC,
  flag-bar, fonts Archivo/Inter self-hébergées, sprite icons.svg (zéro emoji)
- CSP via kit.csp (mode nonce) + en-têtes de sécurité ; paths.base configurable
  au build (BASE_PATH) pour le sous-chemin YunoHost, paths.relative = false
- i18n fr (structure prête gcf/en), pages : accueil public/privé, erreurs designées
- Tests vitest : résolution SSO + routes publiques (10 cas)
- Vérifié : check/lint/test/build verts, test fumée HTTP sur build (SSO, CSP,
  sous-chemin /veille), zéro emoji et zéro domaine tiers dans le build
This commit is contained in:
cyber-mawonaj
2026-08-01 09:18:27 -04:00
parent dc6bf1bbde
commit ff26136dfb
37 changed files with 5178 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { fr } from '$lib/i18n/fr';
import Icon from '$lib/components/Icon.svelte';
const statut = $derived(page.status);
const contenu = $derived(
statut === 401 || statut === 404 ? fr.erreurs.parStatut[statut] : fr.erreurs.parStatut[500]
);
</script>
<svelte:head>
<title>{statut}{contenu.titre}{fr.app.nom}</title>
</svelte:head>
<section class="erreur">
<Icon nom="ka" taille={64} />
<h1>{statut}{contenu.titre}</h1>
{#if statut === 404 && 'texte_ka' in contenu}
<p lang="gcf" class="ka">{contenu.texte_ka}</p>
{/if}
<p>{contenu.texte}</p>
<a class="bouton" href={resolve('/')}>{fr.erreurs.retour_accueil}</a>
</section>
<style>
.erreur {
max-width: 60ch;
margin-inline: auto;
padding-block: var(--space-5);
text-align: center;
color: var(--fg);
}
.erreur :global(svg) {
margin-inline: auto;
color: var(--action);
margin-bottom: var(--space-3);
}
h1 {
margin-bottom: var(--space-2);
}
.ka {
color: var(--action);
font-style: italic;
margin-bottom: var(--space-1);
}
p {
color: var(--muted);
margin-bottom: var(--space-3);
}
</style>
+7
View File
@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user
};
};
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import '$lib/styles/oki-tokens.css';
import '$lib/styles/base.css';
import { resolve } from '$app/paths';
import { fr } from '$lib/i18n/fr';
import Icon from '$lib/components/Icon.svelte';
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
// Navigation privée : visible uniquement avec un utilisateur SSO.
// Les entrées "bientot" correspondent aux phases 1 et 2 du PRD.
const entrees = [
{ href: '/', libelle: fr.nav.accueil, icone: 'accueil', bientot: false },
{ href: '/registre', libelle: fr.nav.registre, icone: 'registre', bientot: true },
{ href: '/profils', libelle: fr.nav.profils, icone: 'profils', bientot: true },
{ href: '/recommander', libelle: fr.nav.recommander, icone: 'cible', bientot: true },
{ href: '/alertes', libelle: fr.nav.alertes, icone: 'alerte', bientot: true }
] as const;
</script>
<a class="skip-link" href="#contenu">{fr.nav.aller_au_contenu}</a>
<header class="entete">
<nav class="conteneur nav-principale" aria-label={fr.nav.navigation_principale}>
<a class="marque" href={resolve('/')}>
<Icon nom="ka" taille={28} />
<span>{fr.app.nom}</span>
</a>
{#if data.user}
<ul class="liens">
{#each entrees as entree (entree.href)}
<li>
{#if entree.bientot}
<span
class="lien lien-desactive"
aria-disabled="true"
title={fr.accueil.phase_a_venir}
>
<Icon nom={entree.icone} taille={18} />
{entree.libelle}
</span>
{:else}
<a class="lien" href={resolve(entree.href)}>
<Icon nom={entree.icone} taille={18} />
{entree.libelle}
</a>
{/if}
</li>
{/each}
</ul>
<span class="utilisateur">{data.user}</span>
{/if}
</nav>
<div class="flag-bar" aria-hidden="true"></div>
</header>
<main id="contenu" class="conteneur" tabindex="-1">
{@render children()}
</main>
<footer class="pied conteneur">
<p>{fr.pied.ligne}</p>
</footer>
<style>
.entete {
background: var(--surface);
border-bottom: 1px solid var(--line);
}
.nav-principale {
display: flex;
align-items: center;
gap: var(--space-3);
padding-block: var(--space-1);
}
.marque {
display: inline-flex;
align-items: center;
gap: 0.5em;
color: var(--action);
font-family: var(--font-display);
font-weight: 800;
font-size: 1.25rem;
text-transform: uppercase;
letter-spacing: -0.01em;
text-decoration: none;
}
.liens {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
list-style: none;
padding: 0;
margin-inline-start: auto;
}
.lien {
display: inline-flex;
align-items: center;
gap: 0.4em;
min-height: 44px;
color: var(--fg);
text-decoration: none;
border-bottom: 2px solid transparent;
transition:
color var(--dur-tanbou) var(--ease-ka),
border-color var(--dur-tanbou) var(--ease-ka);
}
.lien:hover {
color: var(--action);
border-color: var(--action);
}
.lien-desactive {
color: var(--muted);
cursor: not-allowed;
}
.utilisateur {
color: var(--muted);
font-size: 0.9rem;
}
/* Flag-bar OKI : 6 px, 4 segments francs, une occurrence par écran. */
.flag-bar {
height: 6px;
background: linear-gradient(
to right,
#0d0d0d 0 25%,
#fdb813 25% 50%,
#00d66c 50% 75%,
#ff1654 75% 100%
);
}
main {
padding-block: var(--space-4);
flex: 1;
}
.pied {
padding-block: var(--space-3);
color: var(--muted);
font-size: 0.85rem;
border-top: 1px solid var(--line);
}
:global(body) {
display: flex;
flex-direction: column;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { fr } from '$lib/i18n/fr';
import Icon from '$lib/components/Icon.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const cartes = [
{
titre: fr.accueil.carte_registre_titre,
texte: fr.accueil.carte_registre_texte,
icone: 'registre'
},
{
titre: fr.accueil.carte_profils_titre,
texte: fr.accueil.carte_profils_texte,
icone: 'profils'
},
{
titre: fr.accueil.carte_recommander_titre,
texte: fr.accueil.carte_recommander_texte,
icone: 'cible'
},
{
titre: fr.accueil.carte_alertes_titre,
texte: fr.accueil.carte_alertes_texte,
icone: 'alerte'
}
] as const;
</script>
<svelte:head>
<title>{fr.app.nom}{fr.accueil.titre_public}</title>
<meta name="description" content={fr.app.description} />
</svelte:head>
{#if data.user}
<h1>{fr.accueil.tableau_de_bord}</h1>
<p class="bienvenue">{fr.accueil.bienvenue} <strong>{data.user}</strong>.</p>
<div class="grille">
{#each cartes as carte (carte.icone)}
<article class="carte">
<h2>
<Icon nom={carte.icone} taille={22} />
{carte.titre}
</h2>
<p>{carte.texte}</p>
<span class="tag">{fr.accueil.phase_a_venir}</span>
</article>
{/each}
</div>
<p class="note-phase">{fr.pied.registre_vide}</p>
{:else}
<section class="public">
<h1>{fr.accueil.titre_public}</h1>
<p>{fr.app.description}</p>
<p class="acces-reserve">
<Icon nom="cle" taille={20} />
{fr.accueil.acces_reserve}
</p>
<a class="bouton" href={resolve('/')}>{fr.erreurs.retour_accueil}</a>
</section>
{/if}
<style>
h1 {
margin-bottom: var(--space-2);
}
.bienvenue {
color: var(--muted);
margin-bottom: var(--space-4);
}
.carte h2 {
display: flex;
align-items: center;
gap: 0.5em;
font-size: 1.05rem;
margin-bottom: var(--space-1);
}
.carte p {
color: var(--muted);
margin-bottom: var(--space-2);
}
.note-phase {
margin-top: var(--space-4);
color: var(--muted);
font-style: italic;
}
.public {
max-width: 60ch;
}
.public p {
margin-bottom: var(--space-2);
}
.acces-reserve {
display: flex;
align-items: center;
gap: 0.5em;
color: var(--action);
margin-bottom: var(--space-3) !important;
}
</style>