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
+17
View File
@@ -0,0 +1,17 @@
# Configuration de veille-ia — copier en .env pour le dev local.
# En production (YunoHost), ces variables sont posées par le service systemd / .env d'install.
# Dossier de données persistant (YAML + dépôt git interne). Défaut dev : ./data
# DATA_DIR=./data
# Endpoint Ollama (optionnel : l'app fonctionne sans, en mode dégradé)
# OLLAMA_URL=http://127.0.0.1:11434
# Token Bearer protégeant POST /api/alerts (généré à l'install YunoHost — jamais commité)
# ALERTS_TOKEN=
# Nom du header HTTP injecté par le SSO (défaut : ynh-user, injecté par SSOwat)
# SSO_HEADER=ynh-user
# Dev local uniquement : simule un utilisateur SSO connecté (ignoré en production)
DEV_USER=dev
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+9
View File
@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/
+42
View File
@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.17.0 create --template minimal --types ts --add prettier eslint vitest="usages:unit" sveltekit-adapter="adapter:node" --install npm app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+41
View File
@@ -0,0 +1,41 @@
import prettier from 'eslint-config-prettier';
import path from 'node:path';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig, includeIgnoreFile } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
ts.configs.recommended,
svelte.configs.recommended,
prettier,
svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser
}
}
},
{
// Override or add rule settings here, such as:
// 'svelte/button-has-type': 'error'
rules: {}
}
);
+3926
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "veille-ia",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^22",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.1.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vite": "^8.0.16",
"vitest": "^4.1.8"
}
}
+11
View File
@@ -0,0 +1,11 @@
/** @type {import("prettier").Config} */
const config = {
useTabs: true,
singleQuote: true,
trailingComma: 'none',
printWidth: 100,
plugins: ['prettier-plugin-svelte'],
overrides: [{ files: '*.svelte', options: { parser: 'svelte' } }]
};
export default config;
+16
View File
@@ -0,0 +1,16 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
/** Identifiant de l'utilisateur authentifié via le SSO YunoHost, null si non connecté. */
user: string | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<link rel="stylesheet" href="%sveltekit.assets%/fonts/fonts.css" />
<link
rel="preload"
href="%sveltekit.assets%/fonts/archivo-latin-700.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<script src="%sveltekit.assets%/theme.js"></script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
import { dev } from '$app/environment';
import { base } from '$app/paths';
import { sequence } from '@sveltejs/kit/hooks';
import { error, type Handle } from '@sveltejs/kit';
import { config } from '$lib/server/config';
import { estRoutePublique, resoudreUtilisateur } from '$lib/server/sso';
/**
* Authentification SSO : l'utilisateur vient du header injecté par le reverse proxy
* YunoHost (SSOwat). Sans utilisateur, seule la page d'accueil publique est accessible ;
* toute autre route (pages et endpoints API) renvoie 401.
*/
const authHandle: Handle = async ({ event, resolve }) => {
event.locals.user = resoudreUtilisateur(event.request.headers, {
ssoHeader: config.ssoHeader,
devUser: config.devUser,
dev
});
if (!event.locals.user && !estRoutePublique(event.url.pathname, base)) {
error(401, 'Authentification requise');
}
return resolve(event);
};
/** En-têtes de sécurité complétant la CSP (gérée par la config SvelteKit, mode nonce). */
const securityHeadersHandle: Handle = async ({ event, resolve }) => {
const response = await resolve(event);
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
return response;
};
export const handle = sequence(authHandle, securityHeadersHandle);
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
/**
* Icône du sprite static/icons.svg (set SVG OKI : 24×24, stroke 2 px, currentColor).
* Règle charte : zéro emoji en production — tout pictogramme passe par ici.
*/
import { asset } from '$app/paths';
let {
nom,
taille = 24,
label = null
}: {
/** Identifiant du <symbol> dans icons.svg (ex. "avertissement", "ka"). */
nom: string;
/** Taille en px (côté du carré). */
taille?: number;
/** Texte accessible ; null = icône décorative (aria-hidden). */
label?: string | null;
} = $props();
</script>
<svg
width={taille}
height={taille}
aria-hidden={label === null}
aria-label={label}
role={label === null ? undefined : 'img'}
>
<use href="{asset('/icons.svg')}#{nom}" />
</svg>
<style>
svg {
display: inline-block;
vertical-align: -0.2em;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: square;
stroke-linejoin: miter;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
/**
* Libellés UI — français (locale par défaut).
* Structure prête pour créole (gcf) et anglais : un fichier par locale,
* sélection future par `data-locale` (pas de routing i18n au MVP).
* Aucune chaîne en dur dans les templates : tout passe par ici.
*/
export const fr = {
app: {
nom: 'veille-ia',
description:
'Observatoire personnel de veille sur les modèles IA génératifs : sorties, licences, classements, et recommandations par profils de projets.'
},
nav: {
accueil: 'Accueil',
registre: 'Registre',
profils: 'Profils',
recommander: 'Recommander',
alertes: 'Alertes',
navigation_principale: 'Navigation principale',
aller_au_contenu: 'Aller au contenu'
},
accueil: {
titre_public: 'Observatoire de veille IA',
acces_reserve:
'Accès réservé. Cette application est privée : connectez-vous via le portail YunoHost pour y accéder.',
tableau_de_bord: 'Tableau de bord',
bienvenue: 'Connecté en tant que',
carte_registre_titre: 'Registre des modèles',
carte_registre_texte:
'Source de vérité unique : modèles, licences, capacités et scores, versionnés git.',
carte_profils_titre: 'Profils de projets',
carte_profils_texte:
'Vos 5 contextes de production et leurs contraintes juridiques (requiert, exclut, préférences).',
carte_recommander_titre: 'Moteur de recommandation',
carte_recommander_texte:
'Shortlist classée et justifiée, en croisant registre et profil : warnings inclus.',
carte_alertes_titre: 'Inbox alertes',
carte_alertes_texte:
'Événements classifiés (sorties, licences, classements) reçus depuis Node-RED.',
phase_a_venir: 'Phase à venir'
},
erreurs: {
parStatut: {
401: {
titre: 'Authentification requise',
texte: 'Cette page nécessite une connexion via le portail YunoHost.'
},
404: {
titre: 'Page introuvable',
texte_ka: 'Paj-la pa la.',
texte: "La page demandée n'existe pas ou a été déplacée."
},
500: {
titre: 'Erreur interne',
texte: "Une erreur inattendue s'est produite. Consultez les journaux du service."
}
},
retour_accueil: "Retour à l'accueil"
},
badge: {
a_verifier: 'à vérifier'
},
pied: {
ligne: 'veille-ia — application AGPL-3.0, auto-hébergée.',
registre_vide: 'Aucune donnée pour le moment : le registre sera initialisé en phase 1.'
}
} as const;
export type Libelles = typeof fr;
+26
View File
@@ -0,0 +1,26 @@
import { env } from '$env/dynamic/private';
/**
* Configuration serveur de veille-ia, lue depuis les variables d'environnement.
* Toutes les variables sont documentées dans le README et .env.example.
* Aucune valeur sensible n'est commitée : ALERTS_TOKEN est généré à l'install YunoHost.
*/
export const config = {
/** Dossier de données persistant (YAML + dépôt git). Défaut dev : ./data relatif à app/. */
dataDir: env.DATA_DIR ?? new URL('../../data/', import.meta.url).pathname,
/** Endpoint Ollama. L'app doit fonctionner même s'il est injoignable (dégradation gracieuse). */
ollamaUrl: env.OLLAMA_URL ?? 'http://127.0.0.1:11434',
/** Token Bearer protégeant POST /api/alerts (généré à l'install YNH). Jamais de défaut. */
alertsToken: env.ALERTS_TOKEN ?? null,
/** Nom du header HTTP injecté par SSOwat (via proxy_params_with_auth nginx) : utilisateur authentifié. */
ssoHeader: (env.SSO_HEADER ?? 'ynh-user').toLowerCase(),
/**
* Dev local uniquement : simule un utilisateur SSO quand le header est absent.
* Ignoré en production (voir sso.ts) — ne jamais le renseigner sur le serveur.
*/
devUser: env.DEV_USER ?? null
} as const;
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { estRoutePublique, resoudreUtilisateur } from './sso';
const optionsProd = { ssoHeader: 'ynh-user', devUser: null, dev: false };
const optionsDev = { ssoHeader: 'ynh-user', devUser: 'alice', dev: true };
function entetes(valeur?: string): Headers {
const h = new Headers();
if (valeur !== undefined) h.set('Ynh-User', valeur);
return h;
}
describe('resoudreUtilisateur', () => {
it('retourne lutilisateur du header SSO (insensible à la casse)', () => {
expect(resoudreUtilisateur(entetes('bob'), optionsProd)).toBe('bob');
});
it('tronque les espaces autour de la valeur', () => {
expect(resoudreUtilisateur(entetes(' bob '), optionsProd)).toBe('bob');
});
it('ignore un header vide', () => {
expect(resoudreUtilisateur(entetes(' '), optionsProd)).toBeNull();
});
it('retourne null sans header en production, même avec DEV_USER renseigné', () => {
expect(
resoudreUtilisateur(entetes(), { ssoHeader: 'ynh-user', devUser: 'alice', dev: false })
).toBeNull();
});
it('utilise DEV_USER en dev quand le header est absent', () => {
expect(resoudreUtilisateur(entetes(), optionsDev)).toBe('alice');
});
it('le header SSO prime sur DEV_USER en dev', () => {
expect(resoudreUtilisateur(entetes('bob'), optionsDev)).toBe('bob');
});
it('retourne null en dev sans DEV_USER', () => {
expect(
resoudreUtilisateur(entetes(), { ssoHeader: 'ynh-user', devUser: null, dev: true })
).toBeNull();
});
});
describe('estRoutePublique', () => {
it('la racine est publique (sans base path)', () => {
expect(estRoutePublique('/', '')).toBe(true);
});
it('la racine est publique (avec base path)', () => {
expect(estRoutePublique('/veille', '/veille')).toBe(true);
expect(estRoutePublique('/veille/', '/veille')).toBe(true);
});
it('les autres routes sont privées', () => {
expect(estRoutePublique('/registre', '')).toBe(false);
expect(estRoutePublique('/veille/registre', '/veille')).toBe(false);
expect(estRoutePublique('/api/alerts', '')).toBe(false);
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* Résolution de l'utilisateur courant depuis les headers de la requête.
* Fonction pure, testable unitairement sans serveur.
*
* En production, le reverse proxy YunoHost (SSOwat) injecte le header SSO
* (par défaut `Ynh-User`, via `proxy_params_with_auth` dans conf/nginx.conf du package).
* Il n'est pas spoofable : nginx vide ce header pour les requêtes client avant de
* le renseigner après authentification.
*/
export function resoudreUtilisateur(
headers: Headers,
options: { ssoHeader: string; devUser: string | null; dev: boolean }
): string | null {
const brut = headers.get(options.ssoHeader)?.trim();
if (brut) return brut;
// Dev local : utilisateur simulé, jamais actif en production.
if (options.dev && options.devUser) return options.devUser;
return null;
}
/** Routes publiques (accessibles sans utilisateur SSO) : page d'accueil minimale uniquement. */
export function estRoutePublique(pathname: string, base: string): boolean {
const chemin = base && pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
return chemin === '' || chemin === '/';
}
+188
View File
@@ -0,0 +1,188 @@
/* Base — reset, primitives, gate reduced-motion. Importé après oki-tokens.css. */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
color-scheme: dark;
}
html.light-theme {
color-scheme: light;
}
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
line-height: 1.5;
min-height: 100dvh;
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3,
h4 {
font-family: var(--font-display);
font-weight: 700;
text-transform: uppercase;
letter-spacing: -0.01em;
line-height: 1.15;
}
a {
color: var(--action);
text-underline-offset: 0.2em;
}
a:hover {
color: var(--action-hover);
}
/* Liens dans le texte : soulignés (scopés à main). */
main a {
text-decoration: underline;
}
nav a,
a.bouton,
a.carte-lien {
text-decoration: none;
}
/* Focus visible : outline or 2 px, partout. */
:focus-visible {
outline: 2px solid var(--action);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
}
img,
svg {
display: block;
max-width: 100%;
}
/* ── Skip-link ── */
.skip-link {
position: absolute;
top: -100%;
left: var(--space-2);
z-index: 100;
padding: var(--space-1) var(--space-2);
background: var(--action);
color: var(--noir-oki);
font-weight: 600;
border-radius: var(--radius-sm);
}
.skip-link:focus-visible {
top: var(--space-2);
}
/* ── Bouton OKI canonique ── */
.bouton {
display: inline-block;
padding: var(--space-1) var(--space-3);
font-family: var(--font-display);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--action);
background: transparent;
border: var(--border-btn);
border-radius: var(--radius-sm);
transition:
color var(--dur-tanbou) var(--ease-ka),
border-color var(--dur-tanbou) var(--ease-ka),
transform var(--dur-tanbou) var(--ease-ka);
}
.bouton:hover {
color: var(--action-hover);
border-color: var(--action-hover);
transform: translateY(-2px);
}
/* ── Carte OKI canonique ── */
.carte {
background: var(--card-bg);
border: var(--border-card);
border-left: 4px solid var(--action);
border-radius: var(--radius-md);
padding: var(--space-3);
transition:
transform var(--dur-tanbou) var(--ease-ka),
border-color var(--dur-tanbou) var(--ease-ka);
}
.carte:hover {
transform: translateY(-2px);
}
/* ── Tag OKI canonique (badge « à vérifier » inclus) ── */
.tag {
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.15em 0.6em;
font-size: 0.85em;
color: var(--fg);
background: color-mix(in srgb, var(--action) 8%, transparent);
border: 1px solid var(--action);
border-radius: var(--radius-sm);
}
.tag-succes {
background: color-mix(in srgb, var(--succes) 10%, transparent);
border-color: var(--succes);
}
.tag-signal {
background: color-mix(in srgb, var(--signal) 10%, transparent);
border-color: var(--signal);
}
/* ── Layout utilitaire ── */
.conteneur {
width: min(100% - 2 * var(--space-3), var(--container));
margin-inline: auto;
}
.grille {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-3);
}
/* ── Gate unique reduced-motion : contenu statique complet ── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
+64
View File
@@ -0,0 +1,64 @@
/* Tokens OKI — copie versionnée de la charte (doc2sveltekit-transition/charte-oki-design-system.md §2-3).
Ne jamais recopier ces valeurs à la main dans les composants : importer ce fichier. */
:root {
/* ── Noyau (thème sombre = défaut) ── */
--noir-oki: #0d0d0d; /* background */
--noir-profond: #1a0f1a; /* surface */
--blanc-creme: #fff8e7; /* foreground */
--line: rgba(255, 255, 255, 0.1); /* filets — jamais de gris plein */
--or-oki: #fdb813; /* accent : SEULE couleur d'action */
--rouge-oki: #ff1654; /* signal — jamais un lien/bouton */
--vert-oki: #00d66c; /* succès, validation */
--or-clair: #ffe066; /* survol des boutons */
--gris-sombre: #2d1b2e;
/* ── Sémantique dérivée ── */
--muted: color-mix(in srgb, var(--blanc-creme) 70%, transparent);
--card-bg: rgba(255, 255, 255, 0.03);
/* ── Alias applicatifs (rôles jamais permutés : or = agir · vert = valider · rouge = signaler) ── */
--bg: var(--noir-oki);
--surface: var(--noir-profond);
--fg: var(--blanc-creme);
--action: var(--or-oki);
--action-hover: var(--or-clair);
--succes: var(--vert-oki);
--signal: var(--rouge-oki);
/* ── Typographie ── */
--font-display: 'Archivo', 'Arial Black', sans-serif; /* 600900, titres + boutons */
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; /* 400800 */
/* ── Formes, espacements, layout ── */
--radius-sm: 3px; /* boutons, tags, badges */
--radius-md: 6px; /* cartes — jamais plus */
--border-card: 1px solid var(--line);
--border-btn: 2px solid var(--or-oki);
--space-1: 0.75rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2rem;
--space-5: 4rem;
--container: 1200px;
/* ── Motion (cadences gwoka, version sobre back-office) ── */
--ease-ka: cubic-bezier(0.22, 1, 0.36, 1);
--ease-syncope: cubic-bezier(0.65, 0, 0.35, 1);
--dur-tanbou: 120ms; /* micro-interactions, hovers */
--dur-mesure: 480ms; /* transitions de vues */
--dur-phrase: 960ms;
}
/* Thème clair opt-in : accents assombris pour rester WCAG AA. */
html.light-theme {
--noir-oki: #f7f2e7;
--noir-profond: #fffdf6;
--blanc-creme: #1a0f1a;
--line: rgba(26, 15, 26, 0.15);
--or-oki: #9a6a00;
--rouge-oki: #c40f42;
--vert-oki: #007a3d;
--or-clair: #7a5400;
--card-bg: rgba(26, 15, 26, 0.04);
}
+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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+50
View File
@@ -0,0 +1,50 @@
/* Fonts self-hébergées (woff2, subset latin) — zéro appel externe. */
/* Fichiers : Archivo 600/700/800 (display) + Inter 400/600/700 (body). */
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('archivo-latin-600.woff2') format('woff2');
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('archivo-latin-700.woff2') format('woff2');
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url('archivo-latin-800.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('inter-latin-400.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('inter-latin-600.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('inter-latin-700.woff2') format('woff2');
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display: none">
<!-- Set SVG veille-ia (conventions OKI : viewBox 24×24, stroke 2 px, currentColor, angles nets).
Usage : <use href="icons.svg#id" /> via le composant Icon.svelte. -->
<symbol id="ka" viewBox="0 0 24 24">
<!-- Tambour ka : icône maîtresse -->
<path d="M7 3h10l2 18H5L7 3z" />
<path d="M5 8h14M6 15h12" />
<path d="M9 3v5M15 3v5" />
</symbol>
<symbol id="accueil" viewBox="0 0 24 24">
<path d="M3 12l9-9 9 9" />
<path d="M5 10v11h14V10" />
</symbol>
<symbol id="registre" viewBox="0 0 24 24">
<path d="M4 4h16v16H4z" />
<path d="M8 8h8M8 12h8M8 16h5" />
</symbol>
<symbol id="profils" viewBox="0 0 24 24">
<circle cx="12" cy="8" r="4" />
<path d="M4 21l2-5h12l2 5" />
</symbol>
<symbol id="cible" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="4" />
<path d="M12 1v4M12 19v4M1 12h4M19 12h4" />
</symbol>
<symbol id="alerte" viewBox="0 0 24 24">
<path d="M6 16v-5a6 6 0 0 1 12 0v5l2 3H4l2-3z" />
<path d="M10 21a2 2 0 0 0 4 0" />
</symbol>
<symbol id="cle" viewBox="0 0 24 24">
<circle cx="8" cy="12" r="5" />
<path d="M13 12h9M18 12v4M21 12v3" />
</symbol>
<symbol id="avertissement" viewBox="0 0 24 24">
<path d="M12 3L22 20H2L12 3z" />
<path d="M12 10v4M12 17v0.5" />
</symbol>
<symbol id="verifie" viewBox="0 0 24 24">
<path d="M3 13l6 6L21 5" />
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+3
View File
@@ -0,0 +1,3 @@
# App privée derrière SSO : aucune indexation.
User-agent: *
Disallow: /
+11
View File
@@ -0,0 +1,11 @@
// Bascule de thème anti-FOUC — script externe (CSP : aucun script inline).
// Thème sombre par défaut (identité OKI) ; clair opt-in persisté en localStorage.
(function () {
try {
if (localStorage.getItem('veille-ia-theme') === 'light') {
document.documentElement.classList.add('light-theme');
}
} catch {
// localStorage indisponible : thème sombre par défaut, rien à faire.
}
})();
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+60
View File
@@ -0,0 +1,60 @@
import { defineConfig } from 'vitest/config';
import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
// Sous-chemin de montage (YunoHost sert l'app sous https://domaine.tld/<path>).
// Injecté au build par le script d'install YNH (BASE_PATH=/veille-ia npm run build).
// Vide en dev local. Doit commencer par '/' et ne pas finir par '/'.
const envBasePath = process.env.BASE_PATH ?? '';
if (envBasePath !== '' && (!envBasePath.startsWith('/') || envBasePath.endsWith('/'))) {
throw new Error(`BASE_PATH invalide : "${envBasePath}" (doit commencer par '/' sans '/' final)`);
}
const basePath = envBasePath as '' | `/${string}`;
export default defineConfig({
plugins: [
sveltekit({
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
adapter: adapter(),
paths: {
base: basePath,
// Obligatoire dès que paths.base est utilisé (évite les URLs relatives cassées).
relative: false
},
csp: {
mode: 'nonce',
directives: {
'default-src': ['self'],
'script-src': ['self'],
// Les transitions Svelte créent des <style> inline : unsafe-inline requis.
'style-src': ['self', 'unsafe-inline'],
'img-src': ['self', 'data:'],
'font-src': ['self'],
'connect-src': ['self'],
'frame-ancestors': ['none'],
'base-uri': ['self'],
'form-action': ['self'],
'object-src': ['none']
}
}
})
],
test: {
expect: { requireAssertions: true },
projects: [
{
extends: './vite.config.ts',
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
}
}
]
}
});