Compare commits

...
Author SHA1 Message Date
Jules NenyandClaude Opus 5.5 5ce431d055 fix(nav): fil d'échelle du groupe M replié sur sa pastille sous 400 px
Mesuré : 387 px dans 360 (Codev hors écran, sans indice de défilement).
Accroche gardée pour les lecteurs d'écran. Passe /autofeedback de B10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBu7CSLy7PJgL4HJaV5oHP
2026-10-02 17:05:40 +02:00
Jules NenyandClaude Opus 5.5 8d05873082 docs(design-system): §Navigation par échelles (groupes, seuils, source echelles.ts)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBu7CSLy7PJgL4HJaV5oHP
2026-10-02 17:02:35 +02:00
Jules NenyandClaude Opus 5.5 358c2954c3 feat(media): ?tab= suivi par watch, sous-onglets retirés, Publications ; Signaler en pied d'À propos
La nav XL porte Recherche média / Publications ; /media relit route.query.tab à
chaque changement (un lien /media → /media?tab=projets changeait l'URL sans
changer la page). B10 M5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBu7CSLy7PJgL4HJaV5oHP
2026-10-02 17:02:10 +02:00
Jules NenyandClaude Opus 5.5 79424d29cf feat(nav): barre par échelles S / M / XL, tiroir < 1280, fil d'échelle mobile
- NavEchelles : trois groupes (accroche pastille + titre, <ul> étiqueté,
  aria-current) ≥ 1280 ; groupe courant seul 768-1279 ; lu depuis utils/echelles.ts
- NavTiroir remplace le menu déroulant : tiroir à droite, voile, Échap, focus
  piégé et rendu, cibles 44 px ; bouton « Menu » masqué ≥ 1280
- FilEchelle < 768, dans le flux avant le conteneur de page
- Soutenir et À propos dans la barre ≥ 1280 ; Signaler quitte la barre
- barre de recherche d'en-tête retirée (/ garde sa recherche dans MobileSheet
  à 768 et 1000, vérifié au DOM)
- html lang="fr" (Lighthouse html-has-lang)
B10 M2-M4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBu7CSLy7PJgL4HJaV5oHP
2026-10-02 17:02:10 +02:00
Jules NenyandClaude Opus 5.5 7762b31f1d feat(nav): onglets S / M / XL et echelleDeRoute dans utils/echelles.ts
Chaque échelle porte ses onglets (libellé, cible, actif(route)) ; echelleDeRoute
rend le groupe courant ou null. Source unique de la nav B10. Test node :
scripts/test-echelles-nav.mjs (23/23). B10 M1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBu7CSLy7PJgL4HJaV5oHP
2026-10-02 16:48:28 +02:00
Jules NenyandClaude Opus 5.5 3f5ebe1c36 fix(fiches): la grille ?vue=fiches ne déborde plus (minmax(0, 1fr))
repeat(n, 1fr) prend pour minimum le contenu le plus large ; avec le nom de
fiche en nowrap, les colonnes dépassaient le conteneur (prod 29/09 : 1658 px
dans 1088 à 1440, 534 dans 328 à 360). B10 M0, SPEC-vue-fiches §1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBu7CSLy7PJgL4HJaV5oHP
2026-10-02 16:47:33 +02:00
Jules NenyandClaude Opus 5.5 a2b89489a6 fix(budget): une table sans aucune ligne datée n'est jamais « 0 € vérifié »
Passe /autofeedback (angle checkpoint) : si NocoDB renvoie des lignes
sans timestamp, CreatedAt ni created_at, checkBudget rendait
verified=true à 0 €, soit le fail-open silencieux d'avant sous une autre
forme. Désormais verified=false et avertissement au journal. 17/17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174RrDEQFQTtySXkTcsUKuv
2026-09-29 00:27:01 +02:00
Jules NenyandClaude Opus 5.5 74337c0bcb fix(a11y): chip actif en fond plein sur tout le site, survol réservé aux pointeurs
Chip.vue : l'actif (échelle, fonction) passe de --primary, translucide
dans le thème AEP (3,96:1 composé, sous AA), à --nav-primary-solid /
--nav-text-on-primary (14,62:1 ; ≈12:1 en sombre), comme le formulaire
libre depuis AF4. Le survol d'un actif ne change plus de fond ; tous les
:hover passent sous @media (hover: hover) (plus de chip collé au tap).
Mesuré sur /, /agences, /proposer, mobile et desktop.

FormLibre.vue : surcharges locales de AF4 devenues inutiles retirées
(principal plein, bloc hover: none) ; le survol du secondaire passe
sous (hover: hover). Secondaire en contour vérifié au toucher.

Décision MOE du 29/09, veto Jules possible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174RrDEQFQTtySXkTcsUKuv
2026-09-29 00:26:05 +02:00
Jules NenyandClaude Opus 5.5 6ac5a2bdf7 fix(budget): le circuit breaker des chatbots lit enfin stats_usage
checkBudget filtrait stats_usage sur `timestamp` (where + fields) et
recevait un 422 à chaque appel : fail-open silencieux, budget jamais
vérifié côté chatbot. Aligné sur le worker : aucune colonne nommée dans
la requête, lecture paginée, mois filtré en JS (timestamp, puis
CreatedAt, puis created_at). Chaque lecture journalise le budget lu ;
un échec journalise « budget NON vérifié » et renvoie verified=false.

Test : scripts/test-circuit-breaker.mjs, faux NocoDB qui rejette en 422
toute colonne absente (15/15 ; 11 échecs si le filtre revient).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174RrDEQFQTtySXkTcsUKuv
2026-09-29 00:19:06 +02:00
18 changed files with 1195 additions and 323 deletions
+26
View File
@@ -82,3 +82,29 @@ Le favicon (`public/favicon.svg`, décliné en `favicon-32.png`, `favicon-16.png
1. **Texte sombre sur l'accent safran, jamais blanc.** `--accent`/`--on-accent` : le blanc sur `#f5b342` échoue le contrôle AA (décision DS du 20/07/2026). `--on-accent` est toujours une couleur sombre (`#1a2238` clair, `#111520` sombre). Vérifié au pixel sur la variante `posture` de `Chip.vue`.
2. **`--nav-text-on-primary` reste une valeur littérale**, pas un alias — voir l'explication détaillée dans la section tokens ci-dessus. Si un nouveau composant a besoin de texte de contraste sur un fond bleu nuit, vérifie d'abord si ce fond est `--nav-primary` (translucide) ou `--nav-primary-solid` (opaque) avant de choisir la variable de texte : elles n'ont pas la même réponse en mode sombre.
## Navigation par échelles S / M / XL (B10, 02/10/2026)
**Source unique : `utils/echelles.ts`.** Chaque échelle porte ses `onglets` (`libelle`, `to`, `actif(route)`) ; `ORDRE_ECHELLES` fixe l'ordre S → M → XL ; `echelleDeRoute(route)` rend le groupe et l'onglet courants, ou `null` hors groupe (Manifeste, À propos, Proposer, Signaler, fiche, `/rag`). Aucun libellé d'échelle ni d'onglet ne se recopie dans un composant : ajouter un onglet = une ligne dans ce fichier, et `scripts/test-echelles-nav.mjs` (une ligne de cas).
| Échelle | Accroche | Onglets |
|---|---|---|
| S | Partir de soi | Jobs (`/trouver-du-taf`) · Outils (`/outils`) |
| M | La communauté | Écosystème d'entraide (`/`) · Réseaux AEP (`/agences`) · Codev (`/codev*`) |
| XL | Contribuer | Recherche média (`/media`) · Publications (`/media?tab=projets`) |
**Seuils** (CSS des composants, pas de breakpoint Tailwind : 1280 et 1440 n'en sont pas) :
| Largeur | En-tête | Sous l'en-tête |
|---|---|---|
| ≥ 1440 | logo complet · trois groupes · Manifeste · À propos · Soutenir (contour safran) · + Proposer (plein safran) · sombre | — |
| 1280-1439 | idem, logo réduit au bloc « AEP » | — |
| 1024-1279 | logo complet · groupe courant seul · Manifeste · + Proposer · sombre · « Menu » | — |
| 768-1023 | logo réduit · groupe courant seul · Manifeste · + Proposer · sombre · « Menu » | — |
| < 768 | logo complet · sombre · + (icône) · ☰ | `FilEchelle` (28 px), sauf hors groupe |
**Composants** : `NavEchelles.vue` (barre ; chaque groupe est un `<ul>` étiqueté par son accroche via `aria-labelledby`, onglet courant en `aria-current="page"`), `NavTiroir.vue` (menu < 1280 : `role="dialog"`, voile, Échap, focus piégé et rendu à l'élément d'ouverture, fermeture au changement de route et au passage à ≥ 1280), `FilEchelle.vue` (< 768, monté dans `app.vue` entre `<header>` et le conteneur de page, **jamais dedans** : `/` y est en `overflow-hidden`). Le lien de don vit dans `utils/liens.ts` (`SOUTENIR_URL`).
**Texte atténué** : `color-mix(in srgb, var(--text) 78 %, transparent)`, pas `--text-muted` (3,7:1 sur blanc, sous AA à 0,8 rem) ni `opacity` (qui éteindrait aussi l'anneau de focus).
**Signaler** n'est plus dans la barre d'ordinateur : il reste dans le tiroir, dans chaque fiche, et en pied d'À propos.
+2
View File
@@ -204,6 +204,8 @@ cout_eur = ((tokens_in × 0.02 + tokens_out × 0.04) / 1_000_000) × 0.93
Le filtre NocoDB par date (`gte,YYYY-MM-DD`) n'est pas supporté en v0.301.5. Contournement : récupération de tous les records stats_usage (limit=1000) et filtre JavaScript par mois/année.
**Côté chatbot (AF5, 29/09)** : `server/utils/circuitBreaker.ts` n'appliquait pas ce contournement. Il envoyait `where=(timestamp,gte,…)&fields=cout_eur,timestamp` et recevait un 422 à chaque appel : fail-open silencieux, budget jamais vérifié depuis la V2. Il suit désormais le worker : **aucune colonne nommée dans la requête** (ni `where`, ni `fields`, ni `sort`), lecture paginée (`limit`/`offset` 1000), mois filtré en JS sur `timestamp`, sinon `CreatedAt`, sinon `created_at`. Chaque lecture écrit au journal `[circuitBreaker] budget lu : X € / 20 € (n lignes AAAA-MM sur N lues)` ; un échec écrit `budget NON vérifié`. Test : `node scripts/test-circuit-breaker.mjs` (faux NocoDB qui rejette en 422 toute colonne absente). Seule `/api/chatbot` vérifie le budget ; `-reseaux` et `-taff` ne le lisent ni ne l'écrivent (asymétrie pré-existante), et les appels servis par un tier gratuit s'inscrivent à 0 €.
---
## 7. Infrastructure
+131 -191
View File
@@ -1,12 +1,12 @@
<template>
<div class="flex flex-col h-screen overflow-hidden" :class="{ dark: isDark }" style="background: var(--nav-bg);">
<!-- ═══════════════════════════════════════ TOP NAV GLOBAL -->
<!-- ═══════════════════════════════════════ TOP NAV GLOBAL (B10 : échelles S / M / XL) -->
<header
class="flex items-center justify-between px-4 py-2.5 shrink-0 relative z-[9999] shadow-sm gap-3"
class="app-header flex items-center px-4 shrink-0 relative z-[9999] shadow-sm gap-3"
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"
>
<!-- Logo -->
<!-- Logo : texte complet sauf 768-1023 et 1280-1439 (place des onglets) -->
<a href="/" class="logo-link flex items-center gap-2 hover:opacity-90 transition-opacity shrink-0" title="Architecture d'Écologie Politique">
<div
class="h-8 px-2 rounded-lg flex items-center justify-center shrink-0"
@@ -20,112 +20,38 @@
</div>
</a>
<!-- ── Onglets desktop (≥1024px) — remplace la barre de recherche ── -->
<nav class="hidden lg:flex flex-1 justify-center items-end gap-0 mx-6" aria-label="Navigation projets">
<NuxtLink
to="/outils"
class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/outils' }"
>
Outils
</NuxtLink>
<NuxtLink
to="/"
class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/' }"
>
Écosystème Entraide
</NuxtLink>
<NuxtLink
to="/agences"
class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/agences' }"
>
Réseaux AEP
</NuxtLink>
<NuxtLink
to="/trouver-du-taf"
class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/trouver-du-taf' }"
>
Jobs
</NuxtLink>
<NuxtLink
to="/codev"
class="nav-tab"
:class="{ 'nav-tab--active': route.path.startsWith('/codev') }"
>
Codev
</NuxtLink>
<NuxtLink
to="/media"
class="nav-tab"
:class="{ 'nav-tab--active': route.path.startsWith('/media') }"
>
recherche-média
</NuxtLink>
</nav>
<!-- ── Barre recherche mobile (640px–1024px) — masquée < 640px car accessible dans la sheet -->
<div class="hidden sm:flex lg:hidden flex-1 mx-2">
<label class="flex items-center gap-2 w-full px-3 py-1.5 rounded-xl border" style="background: var(--nav-bg); border-color: var(--nav-bg-alt);">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="headerSearch"
type="search"
placeholder="Rechercher…"
class="flex-1 bg-transparent border-0 outline-none text-sm"
style="color: var(--nav-text); font-family: var(--nav-font);"
autocomplete="off"
@input="onHeaderSearch"
@keydown.enter="onHeaderSearch"
/>
<button
v-if="headerSearch"
type="button"
@click.stop="clearHeaderSearch"
class="flex-shrink-0"
style="color: var(--nav-text-muted);"
aria-label="Effacer la recherche"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
</div>
<!-- Échelles : trois groupes ≥ 1280, groupe courant 768-1279, rien < 768 (fil d'échelle) -->
<NavEchelles />
<div class="flex-1 md:hidden" aria-hidden="true" />
<!-- Actions droite -->
<div class="flex items-center gap-2 shrink-0">
<NuxtLink
to="/manifeste"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
:style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'"
class="header-lien hidden md:inline-flex"
:aria-current="route.path === '/manifeste' ? 'page' : undefined"
>
Manifeste
</NuxtLink>
<NuxtLink
to="/a-propos"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
style="color: var(--nav-text-muted);"
class="header-lien header-xl"
:aria-current="route.path === '/a-propos' ? 'page' : undefined"
>
À propos
</NuxtLink>
<NuxtLink
to="/signaler"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden lg:inline-flex items-center gap-1"
style="color: var(--nav-text-muted);"
<a
:href="SOUTENIR_URL"
target="_blank"
rel="noopener noreferrer"
class="header-soutenir header-xl"
>
Signaler
</NuxtLink>
<!-- Proposer une ressource (5 onglets) -->
Soutenir
</a>
<!-- Proposer une ressource -->
<NuxtLink
to="/proposer"
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
style="background: var(--nav-accent); color: var(--nav-text);"
class="header-proposer hidden md:inline-flex"
>
+ Proposer
</NuxtLink>
@@ -153,68 +79,51 @@
<!-- Mobile : proposer icône -->
<NuxtLink
to="/proposer"
class="sm:hidden p-2 rounded-lg"
style="background: var(--nav-accent); color: var(--nav-text);"
class="md:hidden p-2 rounded-lg"
style="background: var(--accent); color: var(--on-accent);"
title="Proposer une ressource"
aria-label="Proposer"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</NuxtLink>
<!-- Menu déroulant — toutes largeurs (les onglets n'y apparaissent qu'en dessous de 1024px) ; porte « Soutenir » -->
<div class="relative">
<button
@click="hamburgerOpen = !hamburgerOpen"
class="p-2 rounded-lg transition-all hover:opacity-80"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
:aria-label="hamburgerOpen ? 'Fermer le menu' : 'Menu'"
:aria-expanded="hamburgerOpen"
>
<svg v-if="!hamburgerOpen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<div
v-if="hamburgerOpen"
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[210px] py-1"
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
@click="hamburgerOpen = false"
>
<NuxtLink to="/outils" class="lg:hidden block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/outils' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Outils</NuxtLink>
<NuxtLink to="/" class="lg:hidden block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide</NuxtLink>
<NuxtLink to="/agences" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/agences' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Réseaux AEP</NuxtLink>
<NuxtLink to="/trouver-du-taf" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
<NuxtLink to="/media" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/media') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">recherche-média</NuxtLink>
<NuxtLink to="/codev" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
<div class="lg:hidden" style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
<NuxtLink to="/manifeste" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'">Manifeste</NuxtLink>
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
<a href="https://liberapay.com/trans-former.fr/donate" target="_blank" rel="noopener noreferrer" class="block mx-3 my-2 px-4 py-2 rounded-lg text-sm font-semibold text-center transition-opacity hover:opacity-80" style="background: var(--nav-accent); color: var(--nav-text);">Soutenir</a>
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
</div>
</div>
<!-- Menu (< 1280) : ouvre le tiroir ; le mot est écrit à partir de 768 -->
<button
type="button"
class="header-menu"
aria-label="Ouvrir le menu"
aria-haspopup="dialog"
:aria-expanded="menuOpen"
@click="menuOpen = true"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
<span class="header-menu__mot" aria-hidden="true">Menu</span>
</button>
</div>
</header>
<!-- Fil d'échelle (< 768) : dans le flux de la colonne, hors du conteneur de page -->
<FilEchelle />
<!-- Contenu page (flex-1 pour remplir l'espace) -->
<div class="flex-1" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
<div class="flex-1 min-h-0" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
<NuxtPage />
</div>
<NavTiroir :open="menuOpen" @close="menuOpen = false" />
</div>
</template>
<script setup lang="ts">
const router = useRouter()
import { SOUTENIR_URL } from '~/utils/liens'
const route = useRoute()
const hamburgerOpen = ref(false)
watch(() => route.path, () => { hamburgerOpen.value = false })
const menuOpen = ref(false)
// ── Dark mode ─────────────────────────────────────────────────────────────
const isDark = ref(false)
@@ -237,40 +146,14 @@ function toggleDark() {
localStorage.setItem('aep_theme', 'light')
}
}
// ── Barre de recherche header mobile ─────────────────────────────────────
const headerSearch = ref((route.query.q as string) ?? '')
// Sync depuis URL quand la route change
watch(() => route.query.q, (v) => {
headerSearch.value = (v as string) ?? ''
})
function onHeaderSearch() {
const q = headerSearch.value.trim()
if (route.path === '/') {
router.replace({ query: q ? { ...route.query, q } : { ...route.query, q: undefined } })
} else if (q) {
router.push({ path: '/', query: { q } })
}
}
function clearHeaderSearch() {
headerSearch.value = ''
if (route.path === '/') {
const q = { ...route.query }
delete q.q
router.replace({ query: Object.keys(q).length ? q : undefined })
}
}
// ── Fiche aléatoire ───────────────────────────────────────────────────────
function goRandom() {
router.push({ path: '/', query: { random: '1' } })
}
</script>
<style>
/* ── En-tête ──────────────────────────────────────────────────────────── */
.app-header {
min-height: 52px;
}
/* ── Logo header (texte 2 lignes) ─────────────────────────────────────── */
.logo-text {
line-height: 1.05;
@@ -285,39 +168,96 @@ function goRandom() {
@media (min-width: 1024px) {
.logo-line-1, .logo-line-2 { font-size: 0.85rem; }
}
/* Le groupe courant (768-1023) et les trois groupes (1280-1439) prennent la place du texte */
@media (min-width: 768px) and (max-width: 1023px), (min-width: 1280px) and (max-width: 1439px) {
.logo-text { display: none; }
}
/* ── Onglets header desktop ───────────────────────────────────────────── */
.nav-tab {
position: relative;
display: inline-flex;
flex-direction: column;
/* ── Liens et boutons de droite ───────────────────────────────────────── */
.header-lien {
align-items: center;
padding: 6px 16px 8px;
font-size: 0.8rem;
font-weight: 500;
color: var(--nav-text-muted);
padding: 6px 8px;
border-radius: var(--radius-md);
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
white-space: nowrap;
text-decoration: none;
transition: color 0.15s;
border-bottom: 2px solid transparent;
gap: 2px;
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.header-lien:hover,
.header-lien[aria-current="page"] {
color: var(--text);
}
.header-lien[aria-current="page"] {
font-weight: var(--fw-bold);
}
.nav-tab:hover {
color: var(--nav-text);
.header-soutenir {
align-items: center;
padding: 5px 10px;
border: 1.5px solid var(--accent);
border-radius: var(--radius-md);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
white-space: nowrap;
text-decoration: none;
color: var(--text);
}
.nav-tab--active {
color: var(--nav-text);
border-bottom-color: var(--nav-primary-solid);
font-weight: 600;
.header-proposer {
align-items: center;
padding: 6px 11px;
border-radius: var(--radius-md);
background: var(--accent);
color: var(--on-accent);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
white-space: nowrap;
text-decoration: none;
}
.nav-tab-badge {
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--nav-text-muted);
opacity: 0.65;
.header-soutenir:hover,
.header-proposer:hover {
opacity: 0.85;
}
.header-lien:focus-visible,
.header-soutenir:focus-visible,
.header-proposer:focus-visible,
.header-menu:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* À propos et Soutenir : barre complète seulement (≥ 1280), sinon dans le tiroir */
.header-xl { display: none; }
@media (min-width: 1280px) {
.header-xl { display: inline-flex; }
}
.header-menu {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: var(--bg-alt);
color: var(--text);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
cursor: pointer;
}
.header-menu__mot { display: none; }
@media (min-width: 768px) {
.header-menu {
padding: 5px 10px;
border-color: var(--border);
background: transparent;
}
.header-menu__mot { display: inline; }
}
@media (min-width: 1280px) {
.header-menu { display: none; }
}
</style>
+31 -24
View File
@@ -77,8 +77,12 @@ const emit = defineEmits<{
}
}
.chip:hover {
transform: translateY(-1px);
/* Survol : uniquement sur pointeur capable de survol. Sur tactile, :hover
reste collé après un tap (chip « allumé » sans être actif). */
@media (hover: hover) {
.chip:hover {
transform: translateY(-1px);
}
}
.chip:focus-visible {
@@ -92,19 +96,21 @@ const emit = defineEmits<{
color: var(--text);
background: transparent;
}
.chip--echelle:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: var(--on-primary);
@media (hover: hover) {
.chip--echelle:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: var(--on-primary);
}
}
/* Actif = fond PLEIN. --primary est translucide dans le thème AEP
(rgba(26,34,56,.6) : 4,16:1 sur le fond de page, sous AA) ; le couple
--nav-primary-solid / --nav-text-on-primary (≈14,6:1) reste lisible aussi
en sombre. Le survol d'un chip actif ne change pas de fond. */
.chip--echelle.chip--active {
background: var(--primary);
border-color: var(--primary);
color: var(--on-primary);
}
.chip--echelle.chip--active:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
background: var(--nav-primary-solid);
border-color: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
}
/* ── Fonction/hashtag : pleine bleu, inactif = bg-alt ────────────────────── */
@@ -113,16 +119,15 @@ const emit = defineEmits<{
background: var(--bg-alt);
color: var(--text);
}
.chip--fonction:hover {
background: var(--primary-hover);
color: var(--on-primary);
@media (hover: hover) {
.chip--fonction:hover {
background: var(--primary-hover);
color: var(--on-primary);
}
}
.chip--fonction.chip--active {
background: var(--primary);
color: var(--on-primary);
}
.chip--fonction.chip--active:hover {
background: var(--primary-hover);
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
}
/* ── Posture (préparée pour plus tard) : outline safran ──────────────────── */
@@ -131,9 +136,11 @@ const emit = defineEmits<{
color: var(--text);
background: transparent;
}
.chip--posture:hover {
background: var(--accent);
color: var(--on-accent);
@media (hover: hover) {
.chip--posture:hover {
background: var(--accent);
color: var(--on-accent);
}
}
.chip--posture.chip--active {
background: var(--accent);
+3 -3
View File
@@ -150,19 +150,19 @@ function toggle(tag: string) {
.fiches-panel__grid {
display: grid;
grid-template-columns: 1fr;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: var(--space-sm);
}
@media (min-width: 768px) {
.fiches-panel__grid {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1280px) {
.fiches-panel__grid {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>
+99
View File
@@ -0,0 +1,99 @@
<!--
FilEchelle — B10-M4
< 768 px : une ligne de 28 px sous la barre, « S · Partir de soi · Outils · Jobs » :
l'échelle courante, l'onglet courant, puis ses voisins du groupe (liens).
Absente sur les pages hors groupe (Manifeste, À propos, Proposer, Signaler, fiche).
Montée dans le flux de la colonne d'app.vue, AVANT le conteneur de page : la carte
de / perd ces 28 px à vue, jamais en dessous de la ligne de flottaison.
-->
<template>
<nav v-if="courant" class="fil-echelle" :class="{ 'fil-echelle--long': voisins.length > 1 }" aria-label="Échelle courante">
<span class="fil-echelle__pastille">{{ courant.echelle }}</span>
<span class="fil-echelle__titre">{{ ECHELLES[courant.echelle].titre }}</span>
<span class="fil-echelle__sep" aria-hidden="true">·</span>
<span class="fil-echelle__courant" aria-current="page">{{ courant.onglet.libelle }}</span>
<template v-for="o in voisins" :key="o.to">
<span class="fil-echelle__sep" aria-hidden="true">·</span>
<NuxtLink :to="o.to" class="fil-echelle__lien">{{ o.libelle }}</NuxtLink>
</template>
</nav>
</template>
<script setup lang="ts">
import { ECHELLES, echelleDeRoute } from '~/utils/echelles'
const route = useRoute()
const courant = computed(() => echelleDeRoute(route))
const voisins = computed(() =>
courant.value ? ECHELLES[courant.value.echelle].onglets.filter((o) => o !== courant.value!.onglet) : [],
)
</script>
<style scoped>
.fil-echelle {
display: flex;
align-items: center;
flex-shrink: 0;
height: 28px;
padding: 0 var(--space-sm);
gap: 5px;
overflow-x: auto;
scrollbar-width: none;
white-space: nowrap;
font-size: 0.72rem;
line-height: 1;
background: var(--surface);
border-bottom: 1px solid var(--bg-alt);
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.fil-echelle::-webkit-scrollbar {
display: none;
}
.fil-echelle__pastille {
padding: 2px 5px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--on-accent);
font-weight: var(--fw-bold);
}
.fil-echelle__courant {
font-weight: var(--fw-bold);
color: var(--text);
}
.fil-echelle__lien {
color: inherit;
text-decoration: underline;
text-decoration-color: var(--border);
text-underline-offset: 3px;
}
.fil-echelle__lien:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
/* Groupe à trois onglets (M) sous 400 px : l'accroche se replie sur sa pastille
(lue quand même par les lecteurs d'écran), sinon « Codev » sort de l'écran. */
@media (max-width: 399px) {
.fil-echelle--long .fil-echelle__titre {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.fil-echelle--long .fil-echelle__titre + .fil-echelle__sep {
display: none;
}
}
@media (min-width: 768px) {
.fil-echelle {
display: none;
}
}
</style>
+9 -36
View File
@@ -283,19 +283,11 @@ async function submit() {
white-space: nowrap;
}
/* ── B5-M2c : distinction principal / second choix + contraste chip actif ──
Mesuré avant modif (script de capture, af4-avant-*) : le chip actif de
Chip.vue (variant fonction, fond rgba(26,34,56,.6) sur fond de page
#f8f6f1) rend ≈ 4.16:1 au repos — sous le seuil AA 4.5:1. C'est un défaut
général de Chip.vue (touche aussi les filtres de / et /agences), signalé
hors mandat — voir rapport. On neutralise ce point pour CE formulaire
seulement, sans toucher Chip.vue : fond plein (--nav-primary-solid) au
lieu de la version translucide, pairing déjà utilisé ailleurs sur le site
(pages/trouver-du-taf.vue .taff-tab--active). Mesuré après : ≈14.6:1. */
.field-group :deep(.chip--fonction.chip--active) {
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
}
/* ── B5-M2c : distinction principal / second choix ──
Le principal actif (fond plein, ≈14,6:1) et le survol gardé par
@media (hover: hover) viennent de Chip.vue depuis AF5 : les surcharges
locales de AF4 (principal plein, survol collant neutralisé sur tactile)
ont été retirées. */
/* Chip secondaire (deuxième choix) : contour au lieu de plein, pour ne
jamais se confondre avec le principal — ni à l'œil ni au clic. Texte
@@ -306,32 +298,13 @@ async function submit() {
border-color: var(--nav-primary-solid) !important;
color: var(--nav-text) !important;
}
.field-group :deep(.chip--secondaire.chip--active:hover) {
background: rgba(var(--nav-primary-raw), 0.08) !important;
border-color: var(--nav-primary-solid) !important;
color: var(--nav-text) !important;
}
/* Survol collant sur tactile (le :hover de Chip.vue n'est pas gardé par
@media (hover: hover)) : neutralisé ici pour les deux chips actifs de ce
formulaire, sans toucher Chip.vue. Mesuré : le survol n'aggravait pas le
contraste du principal (6.68:1 contre 4.16:1 au repos, avant modif) mais
figeait un fond et un transform incohérents après un tap tant que rien ne
les levait. */
@media (hover: none) {
.field-group :deep(.chip--fonction.chip--active) {
transform: none !important;
}
.field-group :deep(.chip--fonction.chip--active:hover) {
background: var(--nav-primary-solid) !important;
color: var(--nav-text-on-primary) !important;
transform: none !important;
}
/* Survol du secondaire : pointeur capable de survol seulement, comme Chip.vue
(sur tactile, :hover reste collé après un tap). */
@media (hover: hover) {
.field-group :deep(.chip--secondaire.chip--active:hover) {
background: transparent !important;
background: rgba(var(--nav-primary-raw), 0.08) !important;
border-color: var(--nav-primary-solid) !important;
color: var(--nav-text) !important;
transform: none !important;
}
}
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="media-tab-projets" style="padding: 1.5rem; overflow-y: auto;">
<div style="max-width: 70ch; margin-bottom: 1.5rem;">
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.5rem; color: var(--nav-text);">PFE engagés</h2>
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.5rem; color: var(--nav-text);">Publications</h2>
<p style="font-size: 0.9rem; line-height: 1.6; color: var(--nav-text);">
Mutualiser le savoir. Voici les PFE engagés publiés en ligne dont nous avons connaissance.
Partage-nous le lien de ton travail si tu veux participer à cette initiative.
+148
View File
@@ -0,0 +1,148 @@
<!--
NavEchelles — B10-M2
Barre des trois échelles S / M / XL, lue depuis utils/echelles.ts (source unique).
≥ 1280 px : les trois groupes ; 768-1279 px : le seul groupe de la page courante ;
< 768 px : rien (FilEchelle + NavTiroir prennent le relais).
Chaque groupe = <ul> étiqueté par son accroche (pastille + titre de l'échelle).
-->
<template>
<nav class="nav-echelles" aria-label="Échelles">
<div
v-for="id in ORDRE_ECHELLES"
:key="id"
class="nav-echelles__grp"
:class="{ 'nav-echelles__grp--courant': courant?.echelle === id }"
>
<span :id="`nav-echelle-${id}`" class="nav-echelles__kick">
<span class="nav-echelles__pastille">{{ id }}</span>{{ ECHELLES[id].titre }}
</span>
<ul class="nav-echelles__tabs" :aria-labelledby="`nav-echelle-${id}`">
<li v-for="o in ECHELLES[id].onglets" :key="o.to">
<NuxtLink
:to="o.to"
class="nav-echelles__tab"
:class="{ 'nav-echelles__tab--actif': o.actif(route) }"
:aria-current="o.actif(route) ? 'page' : undefined"
>{{ o.libelle }}</NuxtLink>
</li>
</ul>
</div>
</nav>
</template>
<script setup lang="ts">
import { ECHELLES, ORDRE_ECHELLES, echelleDeRoute } from '~/utils/echelles'
const route = useRoute()
const courant = computed(() => echelleDeRoute(route))
</script>
<style scoped>
.nav-echelles {
display: none;
align-self: stretch;
flex: 1 1 auto;
min-width: 0;
gap: var(--space-lg);
}
.nav-echelles__grp {
position: relative;
display: none;
flex-direction: column;
justify-content: flex-end;
min-width: 0;
}
.nav-echelles__kick {
padding: 0 var(--space-xs);
font-size: 0.66rem;
font-weight: var(--fw-semibold);
letter-spacing: 0.04em;
line-height: 1.3;
white-space: nowrap;
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.nav-echelles__grp--courant .nav-echelles__kick {
color: var(--text);
}
.nav-echelles__pastille {
display: inline-block;
margin-right: 5px;
padding: 0 5px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--on-accent);
font-weight: var(--fw-bold);
}
.nav-echelles__tabs {
display: flex;
margin: 0;
padding: 0;
list-style: none;
}
.nav-echelles__tab {
display: inline-block;
padding: 4px 9px 9px;
font-size: 0.8rem;
font-weight: var(--fw-medium);
white-space: nowrap;
text-decoration: none;
color: color-mix(in srgb, var(--text) 78%, transparent);
border-bottom: 2px solid transparent;
transition: color var(--dur-fast) var(--ease-out);
}
.nav-echelles__tab:hover {
color: var(--text);
}
.nav-echelles__tab:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
border-radius: var(--radius-sm);
}
.nav-echelles__tab--actif {
color: var(--text);
font-weight: var(--fw-semibold);
border-bottom-color: var(--nav-primary-solid);
}
/* 768-1279 : le seul groupe courant, aligné à gauche après le logo */
@media (min-width: 768px) {
.nav-echelles {
display: flex;
justify-content: flex-start;
}
.nav-echelles__grp--courant {
display: flex;
}
}
/* ≥ 1280 : les trois groupes, centrés, séparés par un filet */
@media (min-width: 1280px) {
.nav-echelles {
justify-content: center;
}
.nav-echelles__grp {
display: flex;
}
.nav-echelles__grp + .nav-echelles__grp::before {
content: "";
position: absolute;
left: calc(var(--space-lg) / -2);
top: 10px;
bottom: 10px;
border-left: 1px solid var(--border);
}
}
@media (prefers-reduced-motion: reduce) {
.nav-echelles__tab { transition: none; }
}
</style>
+313
View File
@@ -0,0 +1,313 @@
<!--
NavTiroir — B10-M3
Menu < 1280 px : tiroir pleine hauteur à droite, voile, Échap, focus piégé,
focus rendu à l'élément qui l'a ouvert. Les trois échelles dans l'ordre de la
barre (utils/echelles.ts), puis Manifeste, À propos, Signaler, et Soutenir.
Se ferme seul au changement de route et si la fenêtre passe à ≥ 1280 px.
-->
<template>
<Teleport to="body">
<Transition name="nav-tiroir">
<div v-if="open" class="nav-tiroir" @keydown="onKeydown">
<div class="nav-tiroir__voile" aria-hidden="true" @click="fermer" />
<div
ref="panneau"
class="nav-tiroir__panneau"
role="dialog"
aria-modal="true"
aria-labelledby="nav-tiroir-titre"
>
<div class="nav-tiroir__tete">
<h2 id="nav-tiroir-titre" class="nav-tiroir__titre">Menu</h2>
<button type="button" class="nav-tiroir__fermer" aria-label="Fermer le menu" @click="fermer">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<nav class="nav-tiroir__corps" aria-label="Échelles et pages">
<p class="nav-tiroir__cadre">{{ ECHELLES_CADRE_COURT }}</p>
<div v-for="id in ORDRE_ECHELLES" :key="id" class="nav-tiroir__grp">
<span :id="`nav-tiroir-echelle-${id}`" class="nav-tiroir__kick">
<span class="nav-tiroir__pastille">{{ id }}</span>{{ ECHELLES[id].titre }}
</span>
<ul class="nav-tiroir__liste" :aria-labelledby="`nav-tiroir-echelle-${id}`">
<li v-for="o in ECHELLES[id].onglets" :key="o.to">
<NuxtLink
:to="o.to"
class="nav-tiroir__lien"
:class="{ 'nav-tiroir__lien--actif': o.actif(route) }"
:aria-current="o.actif(route) ? 'page' : undefined"
@click="fermer"
>{{ o.libelle }}</NuxtLink>
</li>
</ul>
</div>
<hr class="nav-tiroir__sep" />
<ul class="nav-tiroir__liste">
<li v-for="p in PAGES" :key="p.to">
<NuxtLink
:to="p.to"
class="nav-tiroir__second"
:aria-current="route.path === p.to ? 'page' : undefined"
@click="fermer"
>{{ p.libelle }}</NuxtLink>
</li>
</ul>
<a :href="SOUTENIR_URL" target="_blank" rel="noopener noreferrer" class="nav-tiroir__soutenir">Soutenir</a>
</nav>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ECHELLES, ECHELLES_CADRE_COURT, ORDRE_ECHELLES } from '~/utils/echelles'
import { SOUTENIR_URL } from '~/utils/liens'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const PAGES = [
{ libelle: 'Manifeste', to: '/manifeste' },
{ libelle: 'À propos', to: '/a-propos' },
{ libelle: 'Signaler', to: '/signaler' },
]
const route = useRoute()
const panneau = ref<HTMLElement | null>(null)
let retourFocus: HTMLElement | null = null
function fermer() {
emit('close')
}
function focusables(): HTMLElement[] {
if (!panneau.value) return []
return [...panneau.value.querySelectorAll<HTMLElement>('a[href], button:not([disabled])')]
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
fermer()
return
}
if (e.key !== 'Tab') return
const els = focusables()
if (!els.length) return
const premier = els[0]
const dernier = els[els.length - 1]
if (e.shiftKey && document.activeElement === premier) {
e.preventDefault()
dernier.focus()
} else if (!e.shiftKey && document.activeElement === dernier) {
e.preventDefault()
premier.focus()
}
}
watch(() => props.open, async (ouvert) => {
if (ouvert) {
retourFocus = document.activeElement as HTMLElement | null
await nextTick()
focusables()[0]?.focus()
} else if (retourFocus) {
// Le bouton d'ouverture peut ne plus être visible (fenêtre élargie) : focus() est alors sans effet.
retourFocus.focus()
retourFocus = null
}
})
watch(() => route.fullPath, () => { if (props.open) fermer() })
let mq: MediaQueryList | null = null
function onLarge(e: MediaQueryListEvent) { if (e.matches && props.open) fermer() }
onMounted(() => {
mq = window.matchMedia('(min-width: 1280px)')
mq.addEventListener('change', onLarge)
})
onBeforeUnmount(() => mq?.removeEventListener('change', onLarge))
</script>
<style scoped>
.nav-tiroir {
position: fixed;
inset: 0;
z-index: 10000;
}
.nav-tiroir__voile {
position: absolute;
inset: 0;
background: rgba(var(--nav-primary-raw), 0.35);
}
.nav-tiroir__panneau {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-lg);
}
.nav-tiroir__tete {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: var(--space-xs) var(--space-sm) var(--space-xs) var(--space-md);
border-bottom: 1px solid var(--border);
}
.nav-tiroir__titre {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
}
.nav-tiroir__fermer {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--text);
cursor: pointer;
}
.nav-tiroir__corps {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--space-sm) var(--space-md) var(--space-lg);
}
.nav-tiroir__cadre {
margin: 0 0 var(--space-xs);
font-size: var(--fs-xs);
line-height: var(--lh-base);
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.nav-tiroir__grp {
margin-top: var(--space-sm);
}
.nav-tiroir__kick {
display: block;
margin-bottom: var(--space-2xs);
font-size: 0.72rem;
font-weight: var(--fw-bold);
letter-spacing: 0.04em;
text-transform: uppercase;
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.nav-tiroir__pastille {
display: inline-block;
margin-right: 6px;
padding: 0 5px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--on-accent);
}
.nav-tiroir__liste {
margin: 0;
padding: 0;
list-style: none;
}
.nav-tiroir__lien,
.nav-tiroir__second {
display: flex;
align-items: center;
min-height: 44px;
padding: 0 var(--space-xs);
border-radius: var(--radius-md);
text-decoration: none;
}
.nav-tiroir__lien {
font-size: var(--fs-base);
color: var(--text);
}
.nav-tiroir__lien--actif {
background: var(--bg-alt);
font-weight: var(--fw-semibold);
}
.nav-tiroir__second {
font-size: var(--fs-sm);
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.nav-tiroir__lien:focus-visible,
.nav-tiroir__second:focus-visible,
.nav-tiroir__soutenir:focus-visible,
.nav-tiroir__fermer:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.nav-tiroir__sep {
margin: var(--space-sm) 0;
border: 0;
border-top: 1px solid var(--border);
}
.nav-tiroir__soutenir {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
margin-top: var(--space-sm);
border-radius: var(--radius-md);
background: var(--accent);
color: var(--on-accent);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
text-decoration: none;
}
.nav-tiroir-enter-active,
.nav-tiroir-leave-active {
transition: opacity var(--dur-base) var(--ease-out);
}
.nav-tiroir-enter-active .nav-tiroir__panneau,
.nav-tiroir-leave-active .nav-tiroir__panneau {
transition: transform var(--dur-base) var(--ease-out);
}
.nav-tiroir-enter-from,
.nav-tiroir-leave-to {
opacity: 0;
}
.nav-tiroir-enter-from .nav-tiroir__panneau,
.nav-tiroir-leave-to .nav-tiroir__panneau {
transform: translateX(100%);
}
@media (prefers-reduced-motion: reduce) {
.nav-tiroir-enter-active,
.nav-tiroir-leave-active,
.nav-tiroir-enter-active .nav-tiroir__panneau,
.nav-tiroir-leave-active .nav-tiroir__panneau {
transition: none;
}
}
</style>
+1
View File
@@ -3,6 +3,7 @@ export default defineNuxtConfig({
app: {
head: {
htmlAttrs: { lang: 'fr' },
link: [
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32.png' },
+18
View File
@@ -185,6 +185,12 @@
</div>
</section>
<!-- B10-M5 : Signaler a quitté la barre d'ordinateur ; il reste ici, dans le menu et dans chaque fiche -->
<p class="section-text apropos-pied">
Une fiche fausse, un lien mort, un contenu à retirer ?
<NuxtLink to="/signaler">Signaler un problème</NuxtLink>
</p>
</div>
</div>
</template>
@@ -270,6 +276,18 @@ function copyApiUrl() {
margin: 0 0 1rem;
}
.apropos-pied {
padding-top: var(--space-md);
border-top: 1px solid var(--border);
}
.apropos-pied a {
color: var(--text);
font-weight: var(--fw-semibold);
text-decoration: underline;
text-underline-offset: 3px;
}
.section-intro {
color: var(--nav-text-muted);
margin-bottom: 1.25rem;
+7 -40
View File
@@ -1,56 +1,23 @@
<template>
<!-- B10-M5 : plus de sous-onglets ici, la nav XL porte « Recherche média » et « Publications » (?tab=projets) -->
<div class="media-page" style="background: var(--nav-bg);">
<nav class="subtabs" style="display:flex; gap:0; border-bottom: 1px solid var(--nav-bg-alt); background: var(--nav-surface); padding: 0 1rem;">
<button
:class="['subtab-btn', { active: tab === 'visuel' }]"
@click="tab = 'visuel'"
>
📚 bibliothèque des pensées écologiques
</button>
<button
:class="['subtab-btn', { active: tab === 'projets' }]"
@click="tab = 'projets'"
>
📐 Projets
</button>
</nav>
<MediaTabVisuel v-if="tab === 'visuel'" />
<MediaTabProjets v-else-if="tab === 'projets'" />
<MediaTabProjets v-else />
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const tab = ref<'visuel' | 'projets'>(
(['visuel', 'projets'].includes(route.query.tab as string)
? route.query.tab as 'visuel' | 'projets'
: 'visuel')
)
watch(tab, (newTab) => {
router.replace({ query: { ...route.query, tab: newTab } })
// Lu à chaque changement de ?tab= (un lien /media → /media?tab=projets ne remonte pas la page).
const tab = ref<'visuel' | 'projets'>(route.query.tab === 'projets' ? 'projets' : 'visuel')
watch(() => route.query.tab, (t) => {
tab.value = t === 'projets' ? 'projets' : 'visuel'
})
useHead({ title: 'AEP - Media' })
useHead(() => ({ title: tab.value === 'projets' ? 'AEP - Publications' : 'AEP - Recherche média' }))
</script>
<style scoped>
.media-page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.subtabs { display: flex; gap: 0; flex-shrink: 0; }
.subtab-btn {
padding: 10px 18px;
font-size: 0.85rem;
font-weight: 500;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
color: var(--nav-text-muted);
transition: color 0.15s, border-color 0.15s;
}
.subtab-btn:hover { color: var(--nav-text); }
.subtab-btn.active { color: var(--nav-primary-solid); border-bottom-color: var(--nav-primary-solid); font-weight: 600; }
</style>
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env node
/**
* Test du circuit breaker budget (server/utils/circuitBreaker.ts), AF5.
* Charge le VRAI module TS via jiti (comme test-submit-libre.mjs).
*
* Le faux NocoDB ci-dessous se comporte comme le vrai sur le point qui a cassé :
* toute colonne nommée dans `where`, `fields` ou `sort` et absente du schéma
* renvoie un 422. Réintroduire un filtre sur un champ absent fait donc échouer
* le cas « schéma sans timestamp ».
* Usage : node scripts/test-circuit-breaker.mjs
*/
import { createJiti } from 'jiti'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const jiti = createJiti(import.meta.url)
const { checkBudget, BUDGET_MAX_EUR } = await jiti.import(
join(__dirname, '..', 'server', 'utils', 'circuitBreaker.ts'),
)
const NOW = new Date('2026-09-29T10:00:00Z')
/** Colonnes référencées par une requête NocoDB v2 (where, fields, sort). */
function colonnesReferencees(query) {
const cols = []
if (query.where) {
for (const m of String(query.where).matchAll(/\(([^,()]+),/g)) cols.push(m[1].trim())
}
if (query.fields) cols.push(...String(query.fields).split(',').map((s) => s.trim()))
if (query.sort) cols.push(...String(query.sort).split(',').map((s) => s.trim().replace(/^-/, '')))
return cols.filter(Boolean)
}
/** Faux NocoDB : schéma de colonnes + lignes ; 422 sur colonne inconnue ; pagination limit/offset. */
function fauxNocodb({ schema, rows, panne = false }) {
const appels = []
const fetchJson = async (url, { headers, query }) => {
appels.push({ url, query })
if (panne) throw Object.assign(new Error('[GET] 500 Internal Server Error'), { statusCode: 500 })
if (headers['xc-token'] !== 'tok') throw Object.assign(new Error('401'), { statusCode: 401 })
const inconnues = colonnesReferencees(query).filter((c) => !schema.includes(c))
if (inconnues.length) {
throw Object.assign(new Error(`[GET] 422 Field '${inconnues[0]}' not found`), { statusCode: 422 })
}
const limit = Number(query.limit ?? 25)
const offset = Number(query.offset ?? 0)
const list = rows.slice(offset, offset + limit)
return { list, pageInfo: { isLastPage: offset + limit >= rows.length } }
}
return { fetchJson, appels }
}
const base = { nocodbUrl: 'http://noco', nocodbToken: 'tok', statsTableId: 'mbbq7n47ixy19mc', now: NOW }
// Schéma supposé de la prod : pas de colonne `timestamp`, date système `CreatedAt`.
const SCHEMA_SANS_TS = ['Id', 'model', 'endpoint', 'tokens_in', 'tokens_out', 'cout_eur', 'orga_id', 'CreatedAt', 'UpdatedAt']
const SCHEMA_AVEC_TS = [...SCHEMA_SANS_TS, 'timestamp']
const results = []
function check(name, cond, detail = '') {
results.push({ name, ok: !!cond, detail })
}
// Journal capturé pour vérifier la ligne de budget
const logs = []
const origInfo = console.info
const origWarn = console.warn
console.info = (...a) => logs.push(['info', a.join(' ')])
console.warn = (...a) => logs.push(['warn', a.join(' ')])
try {
// 0. Le faux NocoDB rejette bien l'ancienne requête (sinon le test ne prouverait rien)
{
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows: [] })
let status = null
try {
await fetchJson('x', {
headers: { 'xc-token': 'tok' },
query: { where: '(timestamp,gte,2026-09-01T00:00:00.000Z)', limit: 1000, fields: 'cout_eur,timestamp' },
})
} catch (e) {
status = e.statusCode
}
check('faux NocoDB : ancienne requête (where/fields sur timestamp) → 422', status === 422, `statut ${status}`)
}
// 1. Schéma sans timestamp : lu, mois courant seul, aucune colonne nommée
{
const rows = [
{ Id: 1, cout_eur: 0.5, CreatedAt: '2026-09-03T08:00:00.000Z' },
{ Id: 2, cout_eur: '1.25', CreatedAt: '2026-09-28 22:10:00+00:00' },
{ Id: 3, cout_eur: 7, CreatedAt: '2026-08-31T23:59:00.000Z' }, // mois précédent
{ Id: 4, cout_eur: null, CreatedAt: '2026-09-10T00:00:00.000Z' },
]
const { fetchJson, appels } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
logs.length = 0
const s = await checkBudget({ ...base, fetchJson })
check('sans timestamp : budget vérifié (pas de 422)', s.verified === true, JSON.stringify(s))
check('sans timestamp : cumul = 1,75 € (août exclu)', Math.abs(s.cumulEur - 1.75) < 1e-9, `cumul ${s.cumulEur}`)
check('sans timestamp : 3 lignes du mois sur 4', s.lignesMois === 3 && s.lignesLues === 4)
check('aucune colonne nommée dans la requête', appels.every((a) => colonnesReferencees(a.query).length === 0),
JSON.stringify(appels.map((a) => a.query)))
const ligne = logs.find(([niv, t]) => niv === 'info' && t.includes('[circuitBreaker] budget lu'))
check('journal : ligne « budget lu » avec le cumul', ligne && ligne[1].includes('1.7500 €'), ligne?.[1] ?? 'absente')
}
// 2. Schéma avec timestamp : timestamp prioritaire sur CreatedAt (ordre du worker)
{
const rows = [
{ Id: 1, cout_eur: 2, timestamp: '2026-09-15T12:00:00.000Z', CreatedAt: '2026-08-15T12:00:00.000Z' },
{ Id: 2, cout_eur: 3, timestamp: '2026-08-15T12:00:00.000Z', CreatedAt: '2026-09-15T12:00:00.000Z' },
]
const { fetchJson } = fauxNocodb({ schema: SCHEMA_AVEC_TS, rows })
const s = await checkBudget({ ...base, fetchJson })
check('avec timestamp : timestamp prioritaire (cumul 2 €)', s.verified && s.cumulEur === 2, `cumul ${s.cumulEur}`)
}
// 3. Lignes sans aucune date : ignorées et comptées
{
const rows = [{ Id: 1, cout_eur: 9 }, { Id: 2, cout_eur: 1, CreatedAt: '2026-09-02T00:00:00Z' }]
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
logs.length = 0
const s = await checkBudget({ ...base, fetchJson })
check('sans date : ignorée, comptée', s.cumulEur === 1 && s.lignesSansDate === 1)
check('journal : lignes sans date signalées', logs.some(([, t]) => t.includes('1 sans date ignorées')))
}
// 3bis. Aucune ligne datée : jamais « 0 € vérifié »
{
const rows = [{ Id: 1, cout_eur: 30 }, { Id: 2, cout_eur: 1 }]
const { fetchJson } = fauxNocodb({ schema: ['Id', 'cout_eur'], rows })
logs.length = 0
const s = await checkBudget({ ...base, fetchJson })
check('aucune date : verified=false, pas 0 € vérifié', s.verified === false && s.lignesSansDate === 2)
check('aucune date : avertissement « NON vérifié »', logs.some(([niv, t]) => niv === 'warn' && t.includes('NON vérifié')))
}
// 4. Pagination : 2 500 lignes du mois à 0,01 € → 25 € → bloqué
{
const rows = Array.from({ length: 2500 }, (_, i) => ({ Id: i + 1, cout_eur: 0.01, CreatedAt: '2026-09-20T00:00:00Z' }))
const { fetchJson, appels } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
logs.length = 0
const s = await checkBudget({ ...base, fetchJson })
check('pagination : 2 500 lignes lues en 3 pages', s.lignesLues === 2500 && appels.length === 3, `${s.lignesLues} lignes, ${appels.length} appels`)
check(`seuil : 25 € ≥ ${BUDGET_MAX_EUR} € → blocked`, s.blocked === true && s.warning === true)
check('journal : BLOQUÉ', logs.some(([, t]) => t.includes('BLOQUÉ')))
}
// 5. Alerte entre 18 et 20 €
{
const rows = [{ Id: 1, cout_eur: 18.5, CreatedAt: '2026-09-20T00:00:00Z' }]
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
const s = await checkBudget({ ...base, fetchJson })
check('18,5 € → warning sans blocage', s.warning === true && s.blocked === false)
}
// 6. Panne NocoDB : fail-open, mais verified=false et avertissement au journal
{
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows: [], panne: true })
logs.length = 0
const s = await checkBudget({ ...base, fetchJson })
check('panne : fail-open, verified=false', s.blocked === false && s.verified === false)
check('panne : avertissement « NON vérifié »', logs.some(([niv, t]) => niv === 'warn' && t.includes('NON vérifié')))
}
} finally {
console.info = origInfo
console.warn = origWarn
}
let failed = 0
for (const r of results) {
if (!r.ok) failed++
console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.name}${r.ok ? '' : ` → ${r.detail}`}`)
}
console.log(failed ? `\nÉCHEC — ${failed}/${results.length} tests en échec` : `\nPASS — ${results.length}/${results.length} tests passés`)
process.exit(failed ? 1 : 0)
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* Test des données de nav S / M / XL (B10-M1).
* Charge le VRAI fichier utils/echelles.ts via jiti (même patron que test-submit-libre.mjs) :
* chaque route → bon groupe et bon onglet actif, un seul onglet actif par route.
* Usage : node scripts/test-echelles-nav.mjs
*/
import { createJiti } from 'jiti'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const jiti = createJiti(import.meta.url)
const { ECHELLES, ORDRE_ECHELLES, ECHELLES_CADRE_COURT, echelleDeRoute } = await jiti.import(
join(__dirname, '..', 'utils', 'echelles.ts'),
)
const cases = [
// [path, query, groupe attendu, onglet attendu]
['/trouver-du-taf', {}, 'S', 'Jobs'],
['/outils', {}, 'S', 'Outils'],
['/', {}, 'M', "Écosystème d'entraide"],
['/', { vue: 'fiches' }, 'M', "Écosystème d'entraide"],
['/agences', {}, 'M', 'Réseaux AEP'],
['/agences', { vue: 'fiches' }, 'M', 'Réseaux AEP'],
['/codev', {}, 'M', 'Codev'],
['/codev/demo', {}, 'M', 'Codev'],
['/codev/carto', {}, 'M', 'Codev'],
['/media', {}, 'XL', 'Recherche média'],
['/media', { tab: 'visuel' }, 'XL', 'Recherche média'],
['/media', { tab: 'projets' }, 'XL', 'Publications'],
['/manifeste', {}, null, null],
['/a-propos', {}, null, null],
['/proposer', {}, null, null],
['/signaler', {}, null, null],
['/fiche/42', {}, null, null],
['/outilsXYZ', {}, null, null],
['/codevelopper', {}, null, null],
]
let fail = 0
for (const [path, query, groupe, onglet] of cases) {
const route = { path, query }
const r = echelleDeRoute(route)
const gotG = r?.echelle ?? null
const gotO = r?.onglet.libelle ?? null
// Un seul onglet actif sur tout le site pour une route donnée.
const actifs = ORDRE_ECHELLES.flatMap((id) => ECHELLES[id].onglets.filter((o) => o.actif(route)))
const ok = gotG === groupe && gotO === onglet && actifs.length === (groupe ? 1 : 0)
if (!ok) fail++
console.log(`${ok ? 'OK ' : 'FAIL'} ${path}${Object.keys(query).length ? '?' + new URLSearchParams(query) : ''} → ${gotG ?? '∅'} / ${gotO ?? '∅'} (actifs : ${actifs.length})`)
}
// Ordre et contenu des groupes (directive Jules du 29/09 + arbitrage 1A).
const attendu = {
S: ['Jobs', 'Outils'],
M: ["Écosystème d'entraide", 'Réseaux AEP', 'Codev'],
XL: ['Recherche média', 'Publications'],
}
for (const id of ORDRE_ECHELLES) {
const got = ECHELLES[id].onglets.map((o) => o.libelle)
const ok = JSON.stringify(got) === JSON.stringify(attendu[id])
if (!ok) fail++
console.log(`${ok ? 'OK ' : 'FAIL'} groupe ${id} = ${got.join(' · ')}`)
}
const cadreOk = ECHELLES_CADRE_COURT === 'Trois échelles, imbriquées. Entrez par celle qui presse.'
if (!cadreOk) fail++
console.log(`${cadreOk ? 'OK ' : 'FAIL'} cadre court = « ${ECHELLES_CADRE_COURT} »`)
const total = cases.length + ORDRE_ECHELLES.length + 1
console.log(`\n${total - fail}/${total}`)
process.exit(fail ? 1 : 0)
+100 -27
View File
@@ -19,53 +19,126 @@ export interface BudgetStatus {
cumulEur: number
blocked: boolean
warning: boolean
/** false si stats_usage n'a pas pu être lu : le budget n'est alors PAS vérifié (fail-open). */
verified: boolean
/** Lignes lues dans stats_usage (toutes dates). */
lignesLues: number
/** Lignes retenues pour le mois courant. */
lignesMois: number
/** Lignes sans aucun champ date reconnu (ignorées, comme dans le worker). */
lignesSansDate: number
}
/** Page NocoDB v2 : seuls les champs lus ici sont typés. */
interface StatsPage {
list?: Record<string, unknown>[]
pageInfo?: { isLastPage?: boolean }
}
type FetchJson = (
url: string,
opts: { headers: Record<string, string>; query: Record<string, string | number> },
) => Promise<StatsPage>
const PAGE_SIZE = 1000
const MAX_PAGES = 20 // 20 000 lignes : garde-fou contre une boucle, pas une limite métier
/**
* Date d'une ligne de stats_usage, dans l'ordre du worker (`worker/enrich.js`,
* getBudgetMoisCourant) : `timestamp` écrit par les routes et le worker, sinon
* la colonne système de NocoDB (`CreatedAt`, ou `created_at` selon la version).
*/
export function dateLigne(row: Record<string, unknown>): Date | null {
const brut = row.timestamp ?? row.CreatedAt ?? row.created_at
if (brut === null || brut === undefined || brut === '') return null
const d = new Date(brut as string)
return Number.isNaN(d.getTime()) ? null : d
}
/**
* Calcule le cumul de dépenses IA du mois courant depuis stats_usage NocoDB.
* Retourne blocked=true si le budget est atteint.
*
* AF5 (29/09) : la requête ne nomme plus AUCUNE colonne (ni `where`, ni `fields`,
* ni `sort`). L'ancienne version filtrait sur `timestamp` côté NocoDB et recevait
* un 422 à chaque appel : budget jamais vérifié côté chatbot, en silence
* (fail-open). On lit la table page par page et on filtre le mois en JS,
* comme le worker, qui lit la même table sans erreur.
*/
export async function checkBudget(config: {
nocodbUrl: string
nocodbToken: string
statsTableId: string
/** Injecté par les tests ; `$fetch` de Nitro sinon. */
fetchJson?: FetchJson
/** Injecté par les tests ; maintenant sinon. */
now?: Date
}): Promise<BudgetStatus> {
const { nocodbUrl, nocodbToken, statsTableId } = config
const fetchJson: FetchJson = config.fetchJson ?? ((url, opts) => $fetch<StatsPage>(url, opts))
const now = config.now ?? new Date()
const annee = now.getUTCFullYear()
const mois = now.getUTCMonth()
// Premier du mois courant à minuit UTC
const now = new Date()
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
const monthStartIso = monthStart.toISOString()
const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
let cumulEur = 0
let lignesLues = 0
let lignesMois = 0
let lignesSansDate = 0
try {
// Fetch toutes les entrées du mois courant (NocoDB v2)
const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
const res = await $fetch<{ list: { cout_eur: number | null; timestamp: string }[] }>(
url,
{
for (let page = 0; page < MAX_PAGES; page++) {
const res = await fetchJson(url, {
headers: { 'xc-token': nocodbToken },
query: {
where: `(timestamp,gte,${monthStartIso})`,
limit: 1000,
fields: 'cout_eur,timestamp',
},
},
)
const rows = res?.list ?? []
const cumulEur = rows.reduce((sum, row) => sum + (Number(row.cout_eur) || 0), 0)
return {
cumulEur,
blocked: cumulEur >= BUDGET_MAX_EUR,
warning: cumulEur >= BUDGET_WARN_EUR,
query: { limit: PAGE_SIZE, offset: page * PAGE_SIZE },
})
const rows = res?.list ?? []
for (const row of rows) {
lignesLues++
const d = dateLigne(row)
if (!d) {
lignesSansDate++
continue
}
if (d.getUTCFullYear() === annee && d.getUTCMonth() === mois) {
lignesMois++
cumulEur += Number(row.cout_eur) || 0
}
}
if (rows.length < PAGE_SIZE || res?.pageInfo?.isLastPage) break
}
} catch (e) {
// En cas d'erreur de lecture, on ne bloque PAS pour ne pas pénaliser les utilisateurs
console.warn('[circuitBreaker] Erreur lecture stats_usage — budget non vérifié:', (e as Error).message)
return { cumulEur: 0, blocked: false, warning: false }
console.warn('[circuitBreaker] Erreur lecture stats_usage — budget NON vérifié:', (e as Error).message)
return { cumulEur: 0, blocked: false, warning: false, verified: false, lignesLues, lignesMois, lignesSansDate }
}
// Des lignes, mais aucune datée : on ne sait pas ce qui relève du mois.
// Rendre « 0 € vérifié » reproduirait le fail-open silencieux d'avant AF5.
if (lignesLues > 0 && lignesSansDate === lignesLues) {
console.warn(
`[circuitBreaker] budget NON vérifié : ${lignesLues} lignes stats_usage sans timestamp, CreatedAt ni created_at`,
)
return { cumulEur: 0, blocked: false, warning: false, verified: false, lignesLues, lignesMois, lignesSansDate }
}
const status: BudgetStatus = {
cumulEur,
blocked: cumulEur >= BUDGET_MAX_EUR,
warning: cumulEur >= BUDGET_WARN_EUR,
verified: true,
lignesLues,
lignesMois,
lignesSansDate,
}
const mm = String(mois + 1).padStart(2, '0')
const suffixe = status.blocked ? ' — BLOQUÉ' : status.warning ? ' — alerte' : ''
const sansDate = lignesSansDate ? `, ${lignesSansDate} sans date ignorées` : ''
console.info(
`[circuitBreaker] budget lu : ${cumulEur.toFixed(4)} € / ${BUDGET_MAX_EUR} € ` +
`(${lignesMois} lignes ${annee}-${mm} sur ${lignesLues} lues${sansDate})${suffixe}`,
)
return status
}
/**
+50 -1
View File
@@ -1,10 +1,23 @@
/**
* Les trois échelles du deck du 23/09/2026 (conférence AEP), slides 15 à 18.
* Texte repris mot pour mot : c'est la tête de chaque onglet (directive Jules du 25/09, point 2).
* Source unique : la future nav S / M / XL (arbitrage n° 1, table R) lira aussi ce fichier.
* Source unique : la nav S / M / XL (B10 : NavEchelles, NavTiroir, FilEchelle) lit aussi ce fichier.
* Aucun libellé d'échelle ni d'onglet ne se recopie ailleurs.
*/
export type EchelleId = 'S' | 'M' | 'XL'
/** Ce que la nav lit d'une route : compatible avec `useRoute()` sans en dépendre (testable en node). */
export interface RouteNav {
path: string
query?: Record<string, unknown>
}
export interface OngletNav {
libelle: string
to: string
actif: (route: RouteNav) => boolean
}
export interface Echelle {
id: EchelleId
titre: string
@@ -13,8 +26,13 @@ export interface Echelle {
phrase: string
/** Catégorie de /proposer présélectionnée par le lien de contribution. */
categorieProposer: 'ecosysteme' | 'reseau' | 'jobs' | 'outils' | 'references'
/** Onglets du groupe, dans l'ordre de la barre, du menu et du fil d'échelle (B10, directive Jules du 29/09 + arbitrage 1A du 26/09). */
onglets: OngletNav[]
}
const sousChemin = (base: string) => (r: RouteNav) => r.path === base || r.path.startsWith(`${base}/`)
const surPublications = (r: RouteNav) => r.query?.tab === 'projets'
export const ECHELLES: Record<EchelleId, Echelle> = {
S: {
id: 'S',
@@ -31,6 +49,10 @@ export const ECHELLES: Record<EchelleId, Echelle> = {
],
phrase: 'Des ressources, des méthodes, des pairs : matériel et mutualisé.',
categorieProposer: 'outils',
onglets: [
{ libelle: 'Jobs', to: '/trouver-du-taf', actif: sousChemin('/trouver-du-taf') },
{ libelle: 'Outils', to: '/outils', actif: sousChemin('/outils') },
],
},
M: {
id: 'M',
@@ -46,6 +68,11 @@ export const ECHELLES: Record<EchelleId, Echelle> = {
],
phrase: "C'est le groupe qui remet un individu debout. L'affinité fait tenir, la confrontation fait avancer.",
categorieProposer: 'ecosysteme',
onglets: [
{ libelle: "Écosystème d'entraide", to: '/', actif: (r) => r.path === '/' },
{ libelle: 'Réseaux AEP', to: '/agences', actif: sousChemin('/agences') },
{ libelle: 'Codev', to: '/codev', actif: sousChemin('/codev') },
],
},
XL: {
id: 'XL',
@@ -61,8 +88,30 @@ export const ECHELLES: Record<EchelleId, Echelle> = {
],
phrase: 'La bibliothèque, les récits, la filiation entre projets.',
categorieProposer: 'references',
onglets: [
{ libelle: 'Recherche média', to: '/media', actif: (r) => sousChemin('/media')(r) && !surPublications(r) },
{ libelle: 'Publications', to: '/media?tab=projets', actif: (r) => sousChemin('/media')(r) && surPublications(r) },
],
},
}
/** Ordre des groupes dans la barre et le menu. */
export const ORDRE_ECHELLES: EchelleId[] = ['S', 'M', 'XL']
/** Slide 15, commune aux trois échelles. */
export const ECHELLES_CADRE = "Trois échelles, imbriquées. Pas un escalier : ça circule dans les deux sens. Entrez par celle qui presse."
/** Version courte de la slide 15 (tête du menu) : première et dernière phrase, tirées du texte ci-dessus. */
export const ECHELLES_CADRE_COURT = (() => {
const phrases = ECHELLES_CADRE.match(/[^.]+\./g) ?? [ECHELLES_CADRE]
return phrases.length > 1 ? `${phrases[0].trim()} ${phrases[phrases.length - 1].trim()}` : ECHELLES_CADRE
})()
/** Groupe de la page courante et son onglet actif, ou null hors groupe (Manifeste, À propos, Proposer, fiche…). */
export function echelleDeRoute(route: RouteNav): { echelle: EchelleId; onglet: OngletNav } | null {
for (const id of ORDRE_ECHELLES) {
const onglet = ECHELLES[id].onglets.find((o) => o.actif(route))
if (onglet) return { echelle: id, onglet }
}
return null
}
+2
View File
@@ -0,0 +1,2 @@
/** Lien de don (Liberapay), partagé par la barre, le tiroir et À propos. */
export const SOUTENIR_URL = 'https://liberapay.com/trans-former.fr/donate'